@chenkai114/dsh-team-admin 0.1.7 → 0.1.8

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.8",
4
4
  "description": "DSH 插件:GUI 管理任意会话的智能体团队 —— 查看成员、删除角色并清理其持久会话(进回收站,可恢复)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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,58 @@
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
+ for (const key of Object.keys(node)) {
35
+ if (key === 'subagentCatalog' || key === 'agentTeam') continue
36
+ visit(node[key], depth + 1)
37
+ }
38
+ }
39
+ visit(payload, 0)
40
+ return payload
41
+ }
42
+
43
+ /**
44
+ * 从持久化状态里收集已删除成员 id。
45
+ * @param {object|null} state - store.readAll() 的返回值。
46
+ * @returns {Set<string>} 墓碑成员 id 集合。
47
+ */
48
+ export function collectDismissedIds(state) {
49
+ const ids = new Set()
50
+ const buckets = state !== null && typeof state === 'object' && state.dismissed !== null && typeof state.dismissed === 'object'
51
+ ? state.dismissed
52
+ : {}
53
+ for (const bucket of Object.values(buckets)) {
54
+ if (bucket === null || typeof bucket !== 'object') continue
55
+ for (const memberId of Object.keys(bucket)) ids.add(memberId)
56
+ }
57
+ return ids
58
+ }
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
 
7
7
  import { createApi } from './api.mjs'
8
+ import { collectDismissedIds, stripDismissedMembers } from './filter.mjs'
8
9
  import { dismissMember } from './dismiss.mjs'
9
10
  import { openStore } from './store.mjs'
10
11
  import { listTeamSessions, readTeam } from './team-read.mjs'
@@ -16,8 +17,10 @@ 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
19
22
  /** 与 package.json 版本保持一致(发布前由测试校验)。 */
20
- const PLUGIN_VERSION = '0.1.7'
23
+ const PLUGIN_VERSION = '0.1.8'
21
24
 
22
25
  /**
23
26
  * 解析插件数据目录。
@@ -61,4 +64,50 @@ export function apply(ctx) {
61
64
  () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }),
62
65
  'team-admin routes',
63
66
  )
67
+ if (!installSessionListFilter(ctx, { getStore, logSystem, silent: true }) && typeof ctx.inject === 'function') {
68
+ // 插件可能早于 sessionController 注册;等服务可用后再包一次。
69
+ ctx.inject(['sessionController'], (sub) => installSessionListFilter(sub, { getStore, logSystem }))
70
+ }
71
+ }
72
+
73
+ /**
74
+ * 给宿主的 sessionController.list 包一层:把已删除成员从 subagentCatalog / agentTeam.members 里摘掉。
75
+ * 顶部「子智能体」选择器与内置「智能体团队」面板都读这份响应,在源头过滤一次即可覆盖所有客户端,
76
+ * 不需要改宿主源码,也不用重编译。
77
+ * @param {object} ctx - Cordis 插件上下文。
78
+ * @param {{ getStore: () => Promise<object>, logSystem: (line: string) => void }} deps - 数据目录与日志。
79
+ * @returns {boolean} 成功包上返回 true;宿主没有该服务时返回 false。
80
+ */
81
+ export function installSessionListFilter(ctx, { getStore, logSystem, silent = false }) {
82
+ const get = ctx !== null && typeof ctx?.get === 'function' ? ctx.get.bind(ctx) : null
83
+ const service = get === null ? undefined : get('sessionController')
84
+ if (service === null || service === undefined || typeof service.list !== 'function') {
85
+ if (silent !== true) logSystem('team-admin:未找到 sessionController.list,跳过会话列表过滤')
86
+ return false
87
+ }
88
+ const original = service.list
89
+ let cached = { at: 0, ids: new Set() }
90
+ const dismissedIds = async () => {
91
+ if (Date.now() - cached.at < DISMISSED_TTL_MS) return cached.ids
92
+ let ids = new Set()
93
+ try {
94
+ const store = await getStore()
95
+ ids = collectDismissedIds(await store.readAll())
96
+ } catch (error) {
97
+ logSystem(`team-admin:读取墓碑失败,本次不过滤会话列表(${error instanceof Error ? error.message : String(error)})`)
98
+ }
99
+ cached = { at: Date.now(), ids }
100
+ return ids
101
+ }
102
+ service.list = async function teamAdminFilteredList(request, signal) {
103
+ const value = await original.call(this, request, signal)
104
+ try {
105
+ const ids = await dismissedIds()
106
+ return ids.size === 0 ? value : stripDismissedMembers(value, ids)
107
+ } catch (error) {
108
+ logSystem(`team-admin:过滤会话列表失败(${error instanceof Error ? error.message : String(error)})`)
109
+ return value
110
+ }
111
+ }
112
+ return true
64
113
  }