@chenkai114/dsh-team-admin 0.1.6 → 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 +1 -1
- package/src/client.js +39 -1
- package/src/filter.mjs +58 -0
- package/src/index.js +50 -1
package/package.json
CHANGED
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
|
-
|
|
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,
|
|
@@ -1081,6 +1115,10 @@
|
|
|
1081
1115
|
}
|
|
1082
1116
|
}
|
|
1083
1117
|
|
|
1118
|
+
// 宿主模块表装载阶段就装好过滤器:客户端插件要等 slots 服务才 apply,
|
|
1119
|
+
// 而会话列表请求可能更早发生,所以不能只在 apply 里装。
|
|
1120
|
+
installSessionListFilter()
|
|
1121
|
+
|
|
1084
1122
|
// 宿主模块表:以包名注册,factory 返回 { inject, apply }
|
|
1085
1123
|
if (typeof window !== 'undefined' && window !== null && window.__ModuleLoader__ !== undefined && window.__ModuleLoader__ !== null
|
|
1086
1124
|
&& typeof window.__ModuleLoader__.load === 'function') {
|
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.
|
|
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
|
}
|