dsh-redteam-memory 0.4.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/LICENSE +21 -0
- package/README.md +219 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +918 -0
- package/lib/host.js +1828 -0
- package/lib/parts/client.head.js +15 -0
- package/lib/parts/client.shim.js +45 -0
- package/lib/parts/client.tail.js +5 -0
- package/lib/parts/host.head.js +80 -0
- package/lib/parts/host.tail.js +37 -0
- package/package.json +70 -0
- package/src/client.js +860 -0
- package/src/host.js +1711 -0
- package/tools/build-lib.mjs +86 -0
- package/tools/prepare-gh-packages.mjs +66 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
// 常驻(静态)客户端半边 —— 浏览器 bundle 形态(由 tools/build-lib.mjs 生成)。
|
|
2
|
+
//
|
|
3
|
+
// client-modules 是 CJS 懒执行模型:bundle 只【注册】工厂,副作用留在闭包内,
|
|
4
|
+
// 首次 require 时物化。因此这里用 window.__ModuleLoader__.load({id, factory}) 注册。
|
|
5
|
+
//
|
|
6
|
+
// 包装刻意保持极薄(只做作用域与导出),全部改造集中在主体自己的 applyClient 里,
|
|
7
|
+
// 见 lib/parts/client.shim.js。
|
|
8
|
+
window.__ModuleLoader__.load({
|
|
9
|
+
id: 'dsh-redteam-memory',
|
|
10
|
+
factory: (require) => {
|
|
11
|
+
let React = require('react');
|
|
12
|
+
var module = { exports: {} };
|
|
13
|
+
var exports = module.exports;
|
|
14
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
15
|
+
|
|
16
|
+
// 红队记忆 · Client 半边
|
|
17
|
+
//
|
|
18
|
+
// lib/client.js 由 `npm run build:lib` 从本文件生成,不要手改 lib/。
|
|
19
|
+
// 生成器以函数名 `applyClient` 为入口锚点,并在其中注入 host.call / styles.insert 垫片
|
|
20
|
+
// ——静态 bundle 里没有这两个闭包符号,原因见 ../../docs/DEVELOPMENT.md。
|
|
21
|
+
//
|
|
22
|
+
// 注意:本文件必须以 `return { name, inject, apply }` 块【结尾】,生成器据此剥离动态包装。
|
|
23
|
+
//
|
|
24
|
+
// ── 两个界面,各管一件事 ──────────────────────────────────────────────────────
|
|
25
|
+
// 红队记忆(main 面板) 知识库 / 检索 / 日志 —— 只用不管配
|
|
26
|
+
// 红队设置(settings 页) Milvus / 向量模型 / 重排 / MinIO / 本地库 —— 只配不管用
|
|
27
|
+
//
|
|
28
|
+
// 分开的理由很具体:数据库地址、向量模型 Key 这些东西一年改两次,却原来占着记忆面板的
|
|
29
|
+
// 一个大 tab;而「导入一份报告」「查一条技巧」每天都要用。配置挪到 DSH 设置里(settings.section
|
|
30
|
+
// 插槽,注册成「红队设置」一页),数据面板回到只有三件事。
|
|
31
|
+
//
|
|
32
|
+
// ── 导入只认四类格式 ─────────────────────────────────────────────────────────
|
|
33
|
+
// pdf / word(.docx) / md / txt。格式越多,「导入失败」的成因越多,而这几类覆盖了
|
|
34
|
+
// 红队现场真正会拿到的东西:报告(pdf/docx)、笔记(md)、扫描导出(txt)。
|
|
35
|
+
//
|
|
36
|
+
// ── 样式 ─────────────────────────────────────────────────────────────────────
|
|
37
|
+
// 颜色一律用主题 token(--dsw-alias-bg-layer-1/2、--dsw-alias-label-secondary…),
|
|
38
|
+
// 不写死、也不用 opacity 凑层级:写死的色在深色主题下会脏,opacity 会让可读性飘。
|
|
39
|
+
|
|
40
|
+
function applyClient(ctx) {
|
|
41
|
+
// ── 静态形态垫片(动态半边的闭包符号在静态包里不存在)──
|
|
42
|
+
//
|
|
43
|
+
// 1) host.call:转到宿主 HTTP 路由(见 lib/host.js 的 /dsh-redteam-memory/rpc)。
|
|
44
|
+
// 选 HTTP 而非 ctx.remote:Remote 需 typert 代码生成(zod schema + 生成绑定),
|
|
45
|
+
// 而本插件有 20 个无类型 JSON 句柄,为此引入整套生成链不划算;
|
|
46
|
+
// 且 fetch 只被【动态】半边屏蔽,静态模块可直接用。
|
|
47
|
+
// 2) styles.insert:动态 runner 把它作为闭包参数注入,静态 bundle 里没有,
|
|
48
|
+
// 故自行插入 <style> 元素(浏览器全局可用),并登记到 fiber 便于卸载清理。
|
|
49
|
+
// 路径必须由生成器按包名填充(/dsh-redteam-memory)。这里曾经写死成
|
|
50
|
+
// `/dsh-redteam-asset-graph/rpc` —— 那是从资产图谱早期版本复制骨架时带过来的缺陷:
|
|
51
|
+
// 本插件的面板会去打资产图谱的路由,请求全 404,两者同时安装还会读到对方的数据
|
|
52
|
+
// (资产图谱 README 记的那次事故是同一个根因)。测试里钉住了实际请求的 url。
|
|
53
|
+
const RPC_PATH = '/dsh-redteam-memory/rpc'
|
|
54
|
+
const host = {
|
|
55
|
+
call(method, args) {
|
|
56
|
+
return fetch(RPC_PATH, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ method: method, args: args === undefined ? null : args }),
|
|
60
|
+
}).then(function (res) {
|
|
61
|
+
return res.json().catch(function () { return null }).then(function (payload) {
|
|
62
|
+
if (!res.ok || !payload || payload.ok !== true) {
|
|
63
|
+
const detail = (payload && payload.error) || ('HTTP ' + res.status)
|
|
64
|
+
throw new Error('redteam-memory rpc ' + method + ' 失败:' + detail)
|
|
65
|
+
}
|
|
66
|
+
return payload.result
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const STYLE_ID = 'redteam-memory-styles'
|
|
73
|
+
const styles = {
|
|
74
|
+
insert(css) {
|
|
75
|
+
if (typeof document === 'undefined') return function () {}
|
|
76
|
+
let el = document.getElementById(STYLE_ID)
|
|
77
|
+
if (!el) { el = document.createElement('style'); el.id = STYLE_ID; document.head.appendChild(el) }
|
|
78
|
+
el.textContent += String(css) + '\n'
|
|
79
|
+
const dispose = function () { if (el && el.parentNode) el.parentNode.removeChild(el) }
|
|
80
|
+
try { ctx.effect(function () { return dispose }, 'redteam-memory: styles') } catch (e) { return dispose }
|
|
81
|
+
return dispose
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const slots = ctx.slots
|
|
86
|
+
|
|
87
|
+
// Shared settings contribution point. Values are React components, never JSON/RPC data.
|
|
88
|
+
let reportComponent = null
|
|
89
|
+
let settingsClosed = false
|
|
90
|
+
const settingsListeners = new Set()
|
|
91
|
+
function notifySettings() {
|
|
92
|
+
for (const listener of settingsListeners) listener()
|
|
93
|
+
}
|
|
94
|
+
const settingsHub = {
|
|
95
|
+
current() { return reportComponent },
|
|
96
|
+
subscribe(listener) {
|
|
97
|
+
if (settingsClosed) throw new Error('红队设置页已卸载')
|
|
98
|
+
if (typeof listener !== 'function') throw new TypeError('设置订阅者必须是函数')
|
|
99
|
+
settingsListeners.add(listener)
|
|
100
|
+
listener()
|
|
101
|
+
return function () { settingsListeners.delete(listener) }
|
|
102
|
+
},
|
|
103
|
+
register(component) {
|
|
104
|
+
if (settingsClosed) throw new Error('红队设置页已卸载')
|
|
105
|
+
if (typeof component !== 'function' && (!component || typeof component !== 'object')) throw new TypeError('报告设置必须提供 React 组件')
|
|
106
|
+
if (reportComponent !== null) throw new Error('报告设置已注册,请先卸载旧组件')
|
|
107
|
+
reportComponent = component
|
|
108
|
+
notifySettings()
|
|
109
|
+
let registered = true
|
|
110
|
+
return function () {
|
|
111
|
+
if (!registered) return
|
|
112
|
+
registered = false
|
|
113
|
+
if (reportComponent !== component) return
|
|
114
|
+
reportComponent = null
|
|
115
|
+
notifySettings()
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
ctx.effect(function () {
|
|
120
|
+
const dispose = ctx.provide('redteamSettingsUI', settingsHub)
|
|
121
|
+
return function () {
|
|
122
|
+
settingsClosed = true
|
|
123
|
+
reportComponent = null
|
|
124
|
+
settingsListeners.clear()
|
|
125
|
+
dispose()
|
|
126
|
+
}
|
|
127
|
+
}, 'redteam-memory: shared settings hub')
|
|
128
|
+
|
|
129
|
+
const PANEL_KEY = 'redteam-memory'
|
|
130
|
+
const SETTINGS_KEY = 'redteam-memory'
|
|
131
|
+
const PAGE = 30
|
|
132
|
+
|
|
133
|
+
// ── 通用小工具 ──────────────────────────────────────────────────────────────
|
|
134
|
+
function el(tag, props) {
|
|
135
|
+
const children = Array.prototype.slice.call(arguments, 2)
|
|
136
|
+
return React.createElement.apply(React, [tag, props || {}].concat(children))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 过桥的参数必须是 JSON:丢掉 undefined / null,否则 bridge 会直接拒绝
|
|
140
|
+
function jsonArgs(o) {
|
|
141
|
+
const out = {}
|
|
142
|
+
for (const k of Object.keys(o || {})) {
|
|
143
|
+
const v = o[k]
|
|
144
|
+
if (v === undefined || v === null) continue
|
|
145
|
+
out[k] = v
|
|
146
|
+
}
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function fmtTime(ms) {
|
|
151
|
+
if (!ms) return '—'
|
|
152
|
+
const n = Number(ms)
|
|
153
|
+
if (!isFinite(n) || n <= 0) return String(ms)
|
|
154
|
+
const d = new Date(n)
|
|
155
|
+
if (isNaN(d.getTime())) return String(ms)
|
|
156
|
+
const p = function (x) { return x < 10 ? '0' + x : String(x) }
|
|
157
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes())
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fmtSize(n) {
|
|
161
|
+
const v = Number(n) || 0
|
|
162
|
+
if (v < 1024) return v + ' B'
|
|
163
|
+
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB'
|
|
164
|
+
return (v / 1024 / 1024).toFixed(1) + ' MB'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function reportError(e) { return String((e && e.message) || e) }
|
|
168
|
+
|
|
169
|
+
// UI 基元:按钮 / 卡片 / 字段 / 徽标。写一次,两个面板共用,样式才可能一致。
|
|
170
|
+
function btn(label, opts, onClick) {
|
|
171
|
+
const o = opts || {}
|
|
172
|
+
const cls = 'rtm-btn' + (o.primary ? ' rtm-btn-primary' : '') + (o.danger ? ' rtm-btn-danger' : '') + (o.mini ? ' rtm-btn-mini' : '')
|
|
173
|
+
return el('button', {
|
|
174
|
+
key: o.key || label, className: cls, disabled: o.disabled === true,
|
|
175
|
+
title: o.title || '', onClick: onClick,
|
|
176
|
+
}, label)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function card(title, sub, children, extra) {
|
|
180
|
+
return el('section', { className: 'rtm-card' },
|
|
181
|
+
el('div', { className: 'rtm-card-h' },
|
|
182
|
+
el('span', { className: 'rtm-card-t' }, title),
|
|
183
|
+
sub ? el('span', { className: 'rtm-card-s' }, sub) : null,
|
|
184
|
+
el('span', { className: 'rtm-sp' }),
|
|
185
|
+
extra || null),
|
|
186
|
+
el('div', { className: 'rtm-card-b' }, children))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function badge(text, kind) {
|
|
190
|
+
return el('span', { className: 'rtm-badge' + (kind ? ' rtm-badge-' + kind : '') }, text)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 用一张字段表渲染四组配置,免得把同样的表单写四遍。
|
|
194
|
+
// 注意按**草稿**里的 provider 算候选项,不是已保存的设置 —— 否则你在界面上切到
|
|
195
|
+
// 「智谱 BigModel」时,模型下拉还是阿里的那几个,得先保存一次才生效。
|
|
196
|
+
function fieldSpecs(snap, draft) {
|
|
197
|
+
const embed = (snap && snap.embedPresets) || []
|
|
198
|
+
const rerank = (snap && snap.rerankPresets) || []
|
|
199
|
+
const cur = draft || (snap && snap.settings) || {}
|
|
200
|
+
const curEmbedProvider = (cur.embed && cur.embed.provider) || ''
|
|
201
|
+
const curRerankProvider = (cur.rerank && cur.rerank.provider) || ''
|
|
202
|
+
const embedCur = (embed.filter(function (x) { return x.id === curEmbedProvider })[0] || {}).models || []
|
|
203
|
+
const rerankCur = (rerank.filter(function (x) { return x.id === curRerankProvider })[0] || {}).models || []
|
|
204
|
+
return [
|
|
205
|
+
{ g: 'milvus', k: 'uri', label: 'Milvus 地址', ph: 'http://127.0.0.1:19530' },
|
|
206
|
+
{ g: 'milvus', k: 'token', label: 'Token', ph: 'Zilliz 云必填;自建留空', secret: true },
|
|
207
|
+
{ g: 'milvus', k: 'dbName', label: '数据库', ph: 'default' },
|
|
208
|
+
{ g: 'milvus', k: 'collection', label: 'Collection', ph: 'redteam_memory' },
|
|
209
|
+
{ g: 'milvus', k: 'metric', label: '距离度量', options: ['COSINE', 'L2', 'IP'] },
|
|
210
|
+
|
|
211
|
+
{ g: 'embed', k: 'provider', label: '服务', options: embed.map(function (x) { return { v: x.id, t: x.label } }) },
|
|
212
|
+
{ g: 'embed', k: 'model', label: '模型', options: embedCur.map(function (m) { return { v: m.id, t: m.id + '(' + m.dimension + ' 维)' } }), free: true },
|
|
213
|
+
{ g: 'embed', k: 'dimension', label: '维度', num: true, ph: '与模型匹配,例如 1024' },
|
|
214
|
+
{ g: 'embed', k: 'apiKey', label: 'API Key', secret: true },
|
|
215
|
+
{ g: 'embed', k: 'baseUrl', label: '自定义接口地址', ph: '留空用官方默认' },
|
|
216
|
+
|
|
217
|
+
{ g: 'rerank', k: 'enabled', label: '启用重排', bool: true },
|
|
218
|
+
{ g: 'rerank', k: 'provider', label: '服务', options: rerank.map(function (x) { return { v: x.id, t: x.label } }) },
|
|
219
|
+
{ g: 'rerank', k: 'model', label: '模型', options: rerankCur.map(function (m) { return { v: m, t: m } }), free: true },
|
|
220
|
+
{ g: 'rerank', k: 'topN', label: '保留条数', num: true },
|
|
221
|
+
{ g: 'rerank', k: 'apiKey', label: 'API Key', secret: true },
|
|
222
|
+
{ g: 'rerank', k: 'baseUrl', label: '自定义接口地址', ph: '留空用官方默认' },
|
|
223
|
+
|
|
224
|
+
{ g: 's3', k: 'enabled', label: '关联 MinIO(Milvus 存储桶)', bool: true },
|
|
225
|
+
{ g: 's3', k: 'endpoint', label: '端点', ph: 'http://minio:9000' },
|
|
226
|
+
{ g: 's3', k: 'bucket', label: '桶名', ph: 'mimo' },
|
|
227
|
+
{ g: 's3', k: 'region', label: 'Region', ph: 'us-east-1' },
|
|
228
|
+
{ g: 's3', k: 'prefix', label: '前缀(可选)', ph: '' },
|
|
229
|
+
{ g: 's3', k: 'accessKey', label: 'Access Key', secret: true },
|
|
230
|
+
{ g: 's3', k: 'secretKey', label: 'Secret Key', secret: true },
|
|
231
|
+
{ g: 's3', k: 'pathStyle', label: 'path-style 寻址', bool: true, hint: 'MinIO / 自建一般要开;AWS 关掉' },
|
|
232
|
+
|
|
233
|
+
// top: true 表示这是 settings 的顶层字段(不挂在任何分组下)
|
|
234
|
+
{ top: true, k: 'storePath', label: '本地库文件', ph: '.redteam-memory.json', hint: '相对名由宿主解析;写不进去时改成能写的绝对路径' },
|
|
235
|
+
{ top: true, k: 'searchTopK', label: '默认返回条数', num: true, hint: 'memory_search 不传 topK 时用它,也用作这里「检索」页的默认值' },
|
|
236
|
+
]
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderFieldNode(f, cur, onChange) {
|
|
240
|
+
if (f.bool) {
|
|
241
|
+
return el('label', { key: f.g + f.k, className: 'rtm-f rtm-f-row' },
|
|
242
|
+
el('input', { type: 'checkbox', checked: cur === true, onChange: function (e) { onChange(e.target.checked) } }),
|
|
243
|
+
el('span', null, f.label),
|
|
244
|
+
f.hint ? el('span', { className: 'rtm-hint' }, f.hint) : null)
|
|
245
|
+
}
|
|
246
|
+
if (f.options) {
|
|
247
|
+
const opts = f.options.map(function (o) { return typeof o === 'string' ? { v: o, t: o } : o })
|
|
248
|
+
const known = opts.some(function (o) { return o.v === cur })
|
|
249
|
+
return el('label', { key: f.g + f.k, className: 'rtm-f' },
|
|
250
|
+
el('span', { className: 'rtm-f-l' }, f.label),
|
|
251
|
+
el('select', { className: 'rtm-in', value: known ? cur : '__custom__', onChange: function (e) { if (e.target.value !== '__custom__') onChange(e.target.value) } },
|
|
252
|
+
opts.map(function (o) { return el('option', { key: o.v, value: o.v }, o.t) }),
|
|
253
|
+
f.free ? el('option', { value: '__custom__' }, '(自定义,用下面的输入框)') : null),
|
|
254
|
+
f.free ? el('input', { className: 'rtm-in', value: cur || '', placeholder: f.ph || '', onChange: function (e) { onChange(e.target.value) } }) : null,
|
|
255
|
+
f.hint ? el('span', { className: 'rtm-hint' }, f.hint) : null)
|
|
256
|
+
}
|
|
257
|
+
return el('label', { key: 'x' + (f.g || '') + f.k, className: 'rtm-f' },
|
|
258
|
+
el('span', { className: 'rtm-f-l' }, f.label),
|
|
259
|
+
el('input', {
|
|
260
|
+
className: 'rtm-in',
|
|
261
|
+
type: f.secret ? 'password' : (f.num ? 'number' : 'text'),
|
|
262
|
+
value: cur === undefined || cur === null ? '' : String(cur),
|
|
263
|
+
placeholder: f.ph || '',
|
|
264
|
+
onChange: function (e) { onChange(f.num ? Number(e.target.value) : e.target.value) },
|
|
265
|
+
}),
|
|
266
|
+
f.hint ? el('span', { className: 'rtm-hint' }, f.hint) : null)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 打开设置页的提示。DSH 没有「打开设置」的客户端服务(layout.selectPanel 只选主面板),
|
|
270
|
+
// 所以这里给出确切路径,而不是放一个点了没反应的按钮。
|
|
271
|
+
function settingsHint() {
|
|
272
|
+
return el('span', { className: 'rtm-hint' }, '数据库 / 向量模型 / 重排 / MinIO 在「设置 → 红队设置」里')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
276
|
+
// 红队记忆(main 面板)
|
|
277
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
278
|
+
function MemoryPanel() {
|
|
279
|
+
const [snap, setSnap] = React.useState(null)
|
|
280
|
+
const [tab, setTab] = React.useState('kb')
|
|
281
|
+
const [busy, setBusy] = React.useState('')
|
|
282
|
+
const [error, setError] = React.useState(null)
|
|
283
|
+
const [toast, setToast] = React.useState('')
|
|
284
|
+
const [draft, setDraft] = React.useState(null)
|
|
285
|
+
const [list, setList] = React.useState({ entries: [], total: 0, offset: 0 })
|
|
286
|
+
const [selected, setSelected] = React.useState([])
|
|
287
|
+
const [detail, setDetail] = React.useState(null)
|
|
288
|
+
const [importPath, setImportPath] = React.useState('')
|
|
289
|
+
const [capOpen, setCapOpen] = React.useState(false)
|
|
290
|
+
const [capTest, setCapTest] = React.useState('')
|
|
291
|
+
const [capInfo, setCapInfo] = React.useState(null)
|
|
292
|
+
const [query, setQuery] = React.useState('')
|
|
293
|
+
const [topK, setTopK] = React.useState(8)
|
|
294
|
+
const [useRerank, setUseRerank] = React.useState(true)
|
|
295
|
+
const [hits, setHits] = React.useState(null)
|
|
296
|
+
|
|
297
|
+
function applySnap(r) {
|
|
298
|
+
if (!r || r.ok !== true) { setError((r && r.error) || '宿主没有返回数据'); return }
|
|
299
|
+
setError(null)
|
|
300
|
+
setSnap(r.snapshot)
|
|
301
|
+
setDraft(function (d) { return d || JSON.parse(JSON.stringify(r.snapshot.settings)) })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function refresh() {
|
|
305
|
+
setBusy('load')
|
|
306
|
+
return host.call('snapshot', {}).then(applySnap)
|
|
307
|
+
.catch(function (e) { setError(reportError(e)) })
|
|
308
|
+
.then(function () { setBusy('') })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function call(method, args, label) {
|
|
312
|
+
setBusy(method)
|
|
313
|
+
setToast('')
|
|
314
|
+
return host.call(method, jsonArgs(args || {}))
|
|
315
|
+
.then(function (r) {
|
|
316
|
+
if (r && r.snapshot) applySnap({ ok: true, snapshot: r.snapshot })
|
|
317
|
+
if (r && r.ok === false) { setError(r.error || (label + '失败')); return r }
|
|
318
|
+
setError(null)
|
|
319
|
+
if (label) setToast(label + '完成')
|
|
320
|
+
return r
|
|
321
|
+
})
|
|
322
|
+
.catch(function (e) { setError(reportError(e)); return null })
|
|
323
|
+
.then(function (r) { setBusy(''); return r })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
React.useEffect(function () { refresh() }, [])
|
|
327
|
+
|
|
328
|
+
// 面板自己刷新:导入/向量化可能耗时,捕获是「悄悄发生的」,状态得能自己走
|
|
329
|
+
const liveRef = React.useRef({ busy: '' })
|
|
330
|
+
liveRef.current = { busy: busy }
|
|
331
|
+
React.useEffect(function () {
|
|
332
|
+
if (typeof ctx.interval !== 'function') return undefined
|
|
333
|
+
return ctx.interval(function () {
|
|
334
|
+
if (liveRef.current.busy) return
|
|
335
|
+
host.call('snapshot', {}).then(function (r) {
|
|
336
|
+
if (!r || r.ok !== true) return
|
|
337
|
+
setSnap(r.snapshot)
|
|
338
|
+
setDraft(function (d) { return d || JSON.parse(JSON.stringify(r.snapshot.settings)) })
|
|
339
|
+
}).catch(function () {})
|
|
340
|
+
}, 4000)
|
|
341
|
+
}, [])
|
|
342
|
+
|
|
343
|
+
function loadList(offset) {
|
|
344
|
+
const off = typeof offset === 'number' ? offset : list.offset
|
|
345
|
+
setBusy('list')
|
|
346
|
+
return host.call('listKnowledge', jsonArgs({ limit: PAGE, offset: off }))
|
|
347
|
+
.then(function (r) {
|
|
348
|
+
if (!r || r.ok !== true) { setError((r && r.error) || '读取列表失败'); return }
|
|
349
|
+
setError(null)
|
|
350
|
+
setList({ entries: r.entries || [], total: r.total || 0, offset: off })
|
|
351
|
+
})
|
|
352
|
+
.catch(function (e) { setError(reportError(e)) })
|
|
353
|
+
.then(function () { setBusy('') })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 依赖必须带上本地条数:snapshot 是挂载之后才到的,只依赖 tab 的话这个效果永远不会重跑;
|
|
357
|
+
// 而条数变化恰好是「刚导入 / 刚捕获」的信号,列表因此能自己刷新。
|
|
358
|
+
React.useEffect(function () {
|
|
359
|
+
if (tab === 'kb' && snap) loadList(0)
|
|
360
|
+
}, [tab, snap && snap.status && snap.status.localCount])
|
|
361
|
+
|
|
362
|
+
function doImport() {
|
|
363
|
+
if (!importPath.trim()) { setError('先填文件路径(建议绝对路径)'); return }
|
|
364
|
+
call('importFile', { path: importPath.trim() }, '导入').then(function (r) {
|
|
365
|
+
if (r && r.ok === true) {
|
|
366
|
+
setImportPath('')
|
|
367
|
+
setToast('已导入 ' + r.parsed + ' 条(' + r.ext + ',' + r.chars + ' 字,' + r.how + ')' + (r.indexError ? '|本地已存,索引未同步' : ''))
|
|
368
|
+
loadList(0)
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function doSyncIndex(all) {
|
|
374
|
+
call('syncIndex', { all: all === true }, all === true ? '整库重建索引' : '同步索引').then(function (r) {
|
|
375
|
+
if (r && r.ok === true) setToast((r.entries || 0) + ' 条本地条目 → ' + (r.synced || 0) + ' 行索引')
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function doRemove() {
|
|
380
|
+
if (!selected.length) { setError('先勾选要删除的条目'); return }
|
|
381
|
+
call('removeKnowledge', { ids: selected }, '删除 ' + selected.length + ' 条').then(function (r) {
|
|
382
|
+
if (r && r.ok === true) { setSelected([]); setDetail(null); loadList(list.offset) }
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function doDrop() {
|
|
387
|
+
call('dropCollection', {}, '删除向量索引').then(function (r) {
|
|
388
|
+
if (r && r.ok === true) { setSelected([]); setDetail(null); loadList(0) }
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function toggle(id) {
|
|
393
|
+
setSelected(function (s) { return s.indexOf(id) >= 0 ? s.filter(function (x) { return x !== id }) : s.concat([id]) })
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function openEntry(id) {
|
|
397
|
+
setBusy('detail')
|
|
398
|
+
host.call('getEntry', { id: id })
|
|
399
|
+
.then(function (r) { if (r && r.ok === true) { setDetail(r.entry); setError(null) } else { setError((r && r.error) || '读取失败') } })
|
|
400
|
+
.catch(function (e) { setError(reportError(e)) })
|
|
401
|
+
.then(function () { setBusy('') })
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function doSearch() {
|
|
405
|
+
if (!query.trim()) { setError('先填检索内容'); return }
|
|
406
|
+
setBusy('search')
|
|
407
|
+
setToast('')
|
|
408
|
+
host.call('search', jsonArgs({ query: query.trim(), topK: topK, rerank: useRerank }))
|
|
409
|
+
.then(function (r) {
|
|
410
|
+
if (!r || r.ok !== true) { setError((r && r.error) || '检索失败'); setHits(null); return }
|
|
411
|
+
setError(null)
|
|
412
|
+
setHits(r)
|
|
413
|
+
})
|
|
414
|
+
.catch(function (e) { setError(reportError(e)) })
|
|
415
|
+
.then(function () { setBusy('') })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function setCaptureField(k, v) {
|
|
419
|
+
setDraft(function (d) {
|
|
420
|
+
const n = JSON.parse(JSON.stringify(d || {}))
|
|
421
|
+
if (!n.capture) n.capture = { enabled: true, phrases: '', kind: 'note', withContext: true }
|
|
422
|
+
n.capture[k] = v
|
|
423
|
+
return n
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 触发词干跑:只判断会不会被捕,不写入 —— 调触发词时先用它验,免得写进一堆噪声。
|
|
428
|
+
function doCaptureTest() {
|
|
429
|
+
setBusy('capTest')
|
|
430
|
+
host.call('captureTest', jsonArgs({ text: capTest }))
|
|
431
|
+
.then(function (r) { setCapInfo(r || { matched: false }); if (r && r.ok === false) setError(r.error || '试跑失败') })
|
|
432
|
+
.catch(function (e) { setError(reportError(e)) })
|
|
433
|
+
.then(function () { setBusy('') })
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function doCaptureAdd() {
|
|
437
|
+
call('captureAdd', { text: capTest, kind: (draft && draft.capture.kind) || 'note' }, '捕获入库').then(function (r) {
|
|
438
|
+
if (r && r.ok === true) { setCapTest(''); setCapInfo(null); loadList(0) }
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const st = snap ? snap.status : null
|
|
443
|
+
const cap = (draft && draft.capture) || { enabled: true, phrases: '' }
|
|
444
|
+
const captures = (snap && snap.captures) || []
|
|
445
|
+
const tabs = [['kb', '知识库'], ['search', '检索'], ['log', '日志']]
|
|
446
|
+
|
|
447
|
+
function head() {
|
|
448
|
+
return el('div', { className: 'rtm-head' },
|
|
449
|
+
el('span', { className: 'rtm-brand' }, '红队记忆'),
|
|
450
|
+
st ? el('span', { className: 'rtm-stat' },
|
|
451
|
+
'本地 ' + (st.localCount || 0) + ' 条 · 索引 ' + (st.indexed || 0) + ' 条' + (st.pending ? '(待同步 ' + st.pending + ')' : '')
|
|
452
|
+
+ ' · ' + (st.embedReady ? '向量模型已配' : '未配向量模型')) : null,
|
|
453
|
+
st && st.progress ? el('span', { className: 'rtm-stat rtm-brand-c' }, st.progress.text) : null,
|
|
454
|
+
el('span', { className: 'rtm-sp' }),
|
|
455
|
+
settingsHint(),
|
|
456
|
+
btn('刷新', { disabled: !!busy }, refresh))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function kbTab() {
|
|
460
|
+
return el('div', { className: 'rtm-body' },
|
|
461
|
+
// 知识库只保留同步入口;查询在独立检索页,导入走文件入口。
|
|
462
|
+
el('div', { className: 'rtm-tools' },
|
|
463
|
+
btn('同步索引' + (st && st.pending ? '(' + st.pending + ')' : ''), {
|
|
464
|
+
disabled: !!busy || !(st && st.pending > 0),
|
|
465
|
+
title: st && st.pending > 0 ? '把本地还没进索引的 ' + st.pending + ' 条同步到 Milvus' : '本地条目都已进索引',
|
|
466
|
+
}, function () { doSyncIndex(false) })),
|
|
467
|
+
|
|
468
|
+
// 导入:只有一条路、四种格式
|
|
469
|
+
card('导入文件', snap.importLabel || 'pdf / word(.docx) / md / txt', [
|
|
470
|
+
el('div', { className: 'rtm-row' },
|
|
471
|
+
el('input', {
|
|
472
|
+
className: 'rtm-in rtm-grow', value: importPath, placeholder: '文件绝对路径,例如 /home/you/report.pdf',
|
|
473
|
+
onChange: function (e) { setImportPath(e.target.value) }, onKeyDown: function (e) { if (e.key === 'Enter') doImport() },
|
|
474
|
+
}),
|
|
475
|
+
btn('导入', { primary: true, disabled: !!busy }, doImport)),
|
|
476
|
+
el('div', { className: 'rtm-hint' }, 'md / txt 直接读;word(.docx) 用 unzip 取正文;pdf 用 pdftotext 抽文本。换成别的格式会被直接拒绝并说明原因 —— 不猜、也不静默失败。'),
|
|
477
|
+
]),
|
|
478
|
+
|
|
479
|
+
// 对话捕获:默认折叠成一行 —— 它开着就行,不需要天天看
|
|
480
|
+
el('section', { className: 'rtm-card' },
|
|
481
|
+
el('div', { className: 'rtm-card-h' },
|
|
482
|
+
el('label', { className: 'rtm-f rtm-f-row' },
|
|
483
|
+
el('input', { type: 'checkbox', checked: cap.enabled === true, onChange: function (e) { setCaptureField('enabled', e.target.checked) } }),
|
|
484
|
+
el('span', { className: 'rtm-card-t' }, '对话捕获')),
|
|
485
|
+
badge(cap.enabled === true ? '开' : '关', cap.enabled === true ? 'ok' : 'warn'),
|
|
486
|
+
el('span', { className: 'rtm-card-s' }, '触发词 ' + ((snap.capturePhrases || []).length) + ' 个 · 最近捕获 ' + captures.length + ' 条'),
|
|
487
|
+
el('span', { className: 'rtm-sp' }),
|
|
488
|
+
btn(capOpen ? '收起' : '展开', { mini: true }, function () { setCapOpen(!capOpen) })),
|
|
489
|
+
capOpen ? el('div', { className: 'rtm-card-b' },
|
|
490
|
+
el('div', { className: 'rtm-row' },
|
|
491
|
+
el('input', {
|
|
492
|
+
className: 'rtm-in rtm-grow', value: String(cap.phrases || ''), placeholder: '触发词,逗号分隔',
|
|
493
|
+
onChange: function (e) { setCaptureField('phrases', e.target.value) },
|
|
494
|
+
}),
|
|
495
|
+
btn('保存触发词', { disabled: !!busy }, function () { call('saveSettings', draft, '保存触发词') })),
|
|
496
|
+
el('div', { className: 'rtm-row' },
|
|
497
|
+
el('input', {
|
|
498
|
+
className: 'rtm-in rtm-grow', value: capTest, placeholder: '试一句,例如:这台机的指纹写入记忆',
|
|
499
|
+
onChange: function (e) { setCapTest(e.target.value) }, onKeyDown: function (e) { if (e.key === 'Enter') doCaptureTest() },
|
|
500
|
+
}),
|
|
501
|
+
btn('干跑', { disabled: !!busy || !capTest.trim(), title: '只判断会不会被捕,不写入' }, doCaptureTest),
|
|
502
|
+
btn('直接入库', { disabled: !!busy || !capTest.trim() }, doCaptureAdd)),
|
|
503
|
+
capInfo ? el('div', { className: capInfo.matched === true ? 'rtm-ok' : 'rtm-warn' },
|
|
504
|
+
capInfo.matched === true
|
|
505
|
+
? ('会捕获:触发词「' + capInfo.phrase + '」→ ' + capInfo.text)
|
|
506
|
+
: (capInfo.error || '不会捕获:没有一个触发词命中')) : null,
|
|
507
|
+
captures.length ? el('div', { className: 'rtm-caps' }, captures.slice(0, 6).map(function (c, i) {
|
|
508
|
+
return el('div', { key: i, className: 'rtm-cap-i' },
|
|
509
|
+
el('span', { className: 'rtm-lt' }, fmtTime(c.at)),
|
|
510
|
+
badge(c.phrase || '人工', 'brand'),
|
|
511
|
+
el('span', { className: 'rtm-cap-x' }, c.text),
|
|
512
|
+
el('span', { className: 'rtm-hint' }, c.error ? '索引未同步' : (c.indexed ? '已进索引' : '仅本地')))
|
|
513
|
+
})) : null,
|
|
514
|
+
captures.length ? el('div', { className: 'rtm-row' }, btn('清空捕获记录', { mini: true }, function () { call('captureClear', {}, '清空捕获记录') })) : null
|
|
515
|
+
) : null),
|
|
516
|
+
|
|
517
|
+
// 列表
|
|
518
|
+
card('知识库', '本地 ' + ((st && st.localCount) || 0) + ' 条 · 命中 ' + list.total + ' 条',
|
|
519
|
+
el('div', null,
|
|
520
|
+
(list.entries || []).length === 0
|
|
521
|
+
? el('div', { className: 'rtm-dim' }, '本地库还没有条目。请导入一份 pdf / word / md / txt。')
|
|
522
|
+
: el('table', { className: 'rtm-tbl' },
|
|
523
|
+
el('thead', null, el('tr', null,
|
|
524
|
+
el('th', { className: 'rtm-th-x' }, ''),
|
|
525
|
+
el('th', null, '标题'),
|
|
526
|
+
el('th', null, '类型'),
|
|
527
|
+
el('th', null, '标签'),
|
|
528
|
+
el('th', null, '索引'),
|
|
529
|
+
el('th', null, '更新时间'))),
|
|
530
|
+
el('tbody', null, list.entries.map(function (e1) {
|
|
531
|
+
return el('tr', { key: e1.id, className: selected.indexOf(e1.id) >= 0 ? 'rtm-tr-on' : '' },
|
|
532
|
+
el('td', null, el('input', { type: 'checkbox', checked: selected.indexOf(e1.id) >= 0, onChange: function () { toggle(e1.id) } })),
|
|
533
|
+
el('td', null, el('a', { className: 'rtm-lnk', onClick: function () { openEntry(e1.id) }, title: e1.source || '' }, e1.title || '(无标题)')),
|
|
534
|
+
el('td', null, e1.kind || ''),
|
|
535
|
+
el('td', { className: 'rtm-tagc' }, e1.tags || ''),
|
|
536
|
+
el('td', null, e1.indexed ? badge('已索引', 'ok') : badge('仅本地')),
|
|
537
|
+
el('td', { className: 'rtm-hint' }, fmtTime(e1.updated_at)))
|
|
538
|
+
}))),
|
|
539
|
+
list.total > PAGE ? el('div', { className: 'rtm-pager' },
|
|
540
|
+
btn('上一页', { mini: true, disabled: !!busy || list.offset <= 0 }, function () { loadList(Math.max(0, list.offset - PAGE)) }),
|
|
541
|
+
el('span', { className: 'rtm-hint' }, (list.offset + 1) + '–' + Math.min(list.offset + PAGE, list.total) + ' / ' + list.total),
|
|
542
|
+
btn('下一页', { mini: true, disabled: !!busy || list.offset + PAGE >= list.total }, function () { loadList(list.offset + PAGE) })) : null),
|
|
543
|
+
el('div', { className: 'rtm-row' },
|
|
544
|
+
btn('删除选中' + (selected.length ? '(' + selected.length + ')' : ''), { danger: true, mini: true, disabled: !!busy || !selected.length }, doRemove),
|
|
545
|
+
btn('删除向量索引', { danger: true, mini: true, disabled: !!busy || !(snap.settings.milvus && snap.settings.milvus.uri), title: '删掉整个 collection;本地库不动,重新同步会自动重建' }, doDrop))),
|
|
546
|
+
|
|
547
|
+
detail ? el('section', { className: 'rtm-card' },
|
|
548
|
+
el('div', { className: 'rtm-card-h' },
|
|
549
|
+
el('span', { className: 'rtm-card-t' }, detail.title || '(无标题)'),
|
|
550
|
+
el('span', { className: 'rtm-card-s' }, (detail.kind || '') + ' · ' + (detail.source || '—') + ' · ' + fmtTime(detail.created_at)),
|
|
551
|
+
el('span', { className: 'rtm-sp' }),
|
|
552
|
+
btn('关闭', { mini: true }, function () { setDetail(null) })),
|
|
553
|
+
el('pre', { className: 'rtm-pre' }, detail.text)) : null)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function searchTab() {
|
|
557
|
+
return el('div', { className: 'rtm-body' },
|
|
558
|
+
el('div', { className: 'rtm-tools' },
|
|
559
|
+
el('input', {
|
|
560
|
+
className: 'rtm-in rtm-grow', value: query, placeholder: '例如:间接提示词注入怎么验证',
|
|
561
|
+
onChange: function (e) { setQuery(e.target.value) }, onKeyDown: function (e) { if (e.key === 'Enter') doSearch() },
|
|
562
|
+
}),
|
|
563
|
+
el('label', { className: 'rtm-f rtm-f-row' }, el('span', { className: 'rtm-f-l' }, 'topK'),
|
|
564
|
+
el('input', { className: 'rtm-in rtm-in-xs', type: 'number', value: topK, onChange: function (e) { setTopK(Number(e.target.value)) } })),
|
|
565
|
+
el('label', { className: 'rtm-f rtm-f-row' },
|
|
566
|
+
el('input', { type: 'checkbox', checked: useRerank, onChange: function (e) { setUseRerank(e.target.checked) } }),
|
|
567
|
+
el('span', null, '用重排')),
|
|
568
|
+
btn('检索', { primary: true, disabled: !!busy }, doSearch)),
|
|
569
|
+
|
|
570
|
+
hits ? (hits.ok !== true
|
|
571
|
+
? el('div', { className: 'rtm-err' }, hits.error || '检索失败')
|
|
572
|
+
: el('div', { className: 'rtm-body' },
|
|
573
|
+
el('div', { className: 'rtm-row' },
|
|
574
|
+
el('span', { className: 'rtm-card-t' }, '命中 ' + hits.hits.length + ' 条'),
|
|
575
|
+
badge(hits.mode === 'vector' ? '向量检索' : (hits.mode === 'hybrid' ? '向量 + 本地关键词' : '本地关键词'), hits.mode === 'local' ? 'warn' : 'brand'),
|
|
576
|
+
hits.reranked ? badge('已重排', 'ok') : null),
|
|
577
|
+
hits.vectorError ? el('div', { className: 'rtm-warn' }, '向量检索失败,已退回本地:' + hits.vectorError) : null,
|
|
578
|
+
hits.hits.map(function (h, i) {
|
|
579
|
+
return el('div', { key: i, className: 'rtm-hit' },
|
|
580
|
+
el('div', { className: 'rtm-row' },
|
|
581
|
+
el('span', { className: 'rtm-hit-t' }, h.title),
|
|
582
|
+
badge(h.mode === 'vector' ? '召回 ' + Number(h.score).toFixed(3) : '本地 ' + Number(h.score).toFixed(0), h.mode === 'vector' ? 'brand' : 'warn'),
|
|
583
|
+
h.rerankScore !== null ? badge('重排 ' + Number(h.rerankScore).toFixed(3), 'ok') : null,
|
|
584
|
+
h.indexed ? null : badge('仅本地')),
|
|
585
|
+
el('div', { className: 'rtm-hit-b' }, h.text))
|
|
586
|
+
})))
|
|
587
|
+
: card('这里做什么', null, el('div', { className: 'rtm-hint' },
|
|
588
|
+
'填一句话点「检索」,看到的就是模型调用 memory_search 时拿到的内容。没配向量模型时走本地关键词检索(标题×3 / 标签×2 / 正文×1)。')))
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function logTab() {
|
|
592
|
+
return el('div', { className: 'rtm-body' },
|
|
593
|
+
el('div', { className: 'rtm-tools' },
|
|
594
|
+
btn('刷新', { disabled: !!busy }, refresh),
|
|
595
|
+
btn('清空日志', { disabled: !!busy }, function () { call('logClear', {}, '清空日志') })),
|
|
596
|
+
el('div', { className: 'rtm-logs' }, (snap.log || []).slice().reverse().map(function (l) {
|
|
597
|
+
return el('div', { key: l.seq, className: 'rtm-ln rtm-ln-' + (l.level || 'info') },
|
|
598
|
+
el('span', { className: 'rtm-lt' }, fmtTime(l.at)),
|
|
599
|
+
el('span', { className: 'rtm-ll' }, l.level || ''),
|
|
600
|
+
el('span', { className: 'rtm-lx' }, l.text))
|
|
601
|
+
})))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return el('div', { className: 'rtm-root' },
|
|
605
|
+
head(),
|
|
606
|
+
error ? el('div', { className: 'rtm-errbar' }, el('span', null, error), el('button', { className: 'rtm-x', onClick: function () { setError(null) } }, '×')) : null,
|
|
607
|
+
toast ? el('div', { className: 'rtm-ok' }, toast) : null,
|
|
608
|
+
st && st.persistence === 'memory' ? el('div', { className: 'rtm-warn' }, 'fs 服务不可用:设置与条目只存在内存里,重启会丢') : null,
|
|
609
|
+
st && st.lastError && st.persistence === 'error' ? el('div', { className: 'rtm-warn' }, st.lastError) : null,
|
|
610
|
+
el('div', { className: 'rtm-tabs' }, tabs.map(function (t) {
|
|
611
|
+
return el('button', { key: t[0], className: 'rtm-tab' + (tab === t[0] ? ' rtm-tab-on' : ''), onClick: function () { setTab(t[0]) } }, t[1])
|
|
612
|
+
})),
|
|
613
|
+
!snap ? el('div', { className: 'rtm-body rtm-dim' }, busy === 'load' ? '加载中…' : '正在读取本地库…') : null,
|
|
614
|
+
snap && tab === 'kb' ? kbTab() : null,
|
|
615
|
+
snap && tab === 'search' ? searchTab() : null,
|
|
616
|
+
snap && tab === 'log' ? logTab() : null
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
621
|
+
// 红队设置(DSH 设置里的一页)
|
|
622
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
623
|
+
function UnifiedSettingsPage() {
|
|
624
|
+
const [Report, setReport] = React.useState(function () { return settingsHub.current() })
|
|
625
|
+
React.useEffect(function () {
|
|
626
|
+
return settingsHub.subscribe(function () { setReport(function () { return settingsHub.current() }) })
|
|
627
|
+
}, [])
|
|
628
|
+
return el('div', { className: 'rtm-body' },
|
|
629
|
+
el(SettingsPage),
|
|
630
|
+
Report ? el(Report) : el('div', { className: 'rtm-set' }, '报告设置尚未加载;启用红队报告插件后将在此显示。'))
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function SettingsPage() {
|
|
634
|
+
const [snap, setSnap] = React.useState(null)
|
|
635
|
+
const [draft, setDraft] = React.useState(null)
|
|
636
|
+
const [busy, setBusy] = React.useState('')
|
|
637
|
+
const [error, setError] = React.useState(null)
|
|
638
|
+
const [toast, setToast] = React.useState('')
|
|
639
|
+
const [tests, setTests] = React.useState({})
|
|
640
|
+
const [s3objs, setS3objs] = React.useState(null)
|
|
641
|
+
|
|
642
|
+
function applySnap(r) {
|
|
643
|
+
if (!r || r.ok !== true) { setError((r && r.error) || '宿主没有返回数据'); return }
|
|
644
|
+
setError(null)
|
|
645
|
+
setSnap(r.snapshot)
|
|
646
|
+
setDraft(function (d) { return d || JSON.parse(JSON.stringify(r.snapshot.settings)) })
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function refresh() {
|
|
650
|
+
setBusy('load')
|
|
651
|
+
return host.call('snapshot', {}).then(applySnap)
|
|
652
|
+
.catch(function (e) { setError(reportError(e)) })
|
|
653
|
+
.then(function () { setBusy('') })
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
React.useEffect(function () { refresh() }, [])
|
|
657
|
+
|
|
658
|
+
function call(method, args, label) {
|
|
659
|
+
setBusy(method)
|
|
660
|
+
setToast('')
|
|
661
|
+
return host.call(method, jsonArgs(args || {}))
|
|
662
|
+
.then(function (r) {
|
|
663
|
+
if (r && r.snapshot) applySnap({ ok: true, snapshot: r.snapshot })
|
|
664
|
+
if (r && r.ok === false) { setError(r.error || (label + '失败')); return r }
|
|
665
|
+
setError(null)
|
|
666
|
+
if (label) setToast(label + '完成')
|
|
667
|
+
return r
|
|
668
|
+
})
|
|
669
|
+
.catch(function (e) { setError(reportError(e)); return null })
|
|
670
|
+
.then(function (r) { setBusy(''); return r })
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function test(kind, method) {
|
|
674
|
+
setBusy(method)
|
|
675
|
+
setToast('')
|
|
676
|
+
host.call(method, {})
|
|
677
|
+
.then(function (r) {
|
|
678
|
+
setTests(function (t) { const n = Object.assign({}, t); n[kind] = r || { ok: false, error: '无响应' }; return n })
|
|
679
|
+
if (r && r.ok === true) { setError(null); setToast(kind + '连通') } else { setError((r && r.error) || (kind + '测试失败')) }
|
|
680
|
+
})
|
|
681
|
+
.catch(function (e) {
|
|
682
|
+
setTests(function (t) { const n = Object.assign({}, t); n[kind] = { ok: false, error: reportError(e) }; return n })
|
|
683
|
+
setError(reportError(e))
|
|
684
|
+
})
|
|
685
|
+
.then(function () { setBusy('') })
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function testLine(kind) {
|
|
689
|
+
const t = tests[kind]
|
|
690
|
+
if (!t) return null
|
|
691
|
+
if (t.ok === true) {
|
|
692
|
+
const extra = kind === 'Milvus'
|
|
693
|
+
? ('collection ' + (t.exists ? '存在,' + t.rowCount + ' 行' : '尚未创建') + ',共 ' + ((t.collections || []).length) + ' 个,' + t.ms + 'ms')
|
|
694
|
+
: kind === '向量模型' ? ('维度 ' + t.dimension + ',' + t.ms + 'ms')
|
|
695
|
+
: kind === '重排模型' ? ('返回 ' + ((t.order || []).length) + ' 条排序,' + t.ms + 'ms')
|
|
696
|
+
: ('桶 ' + t.bucket + ' 可列举')
|
|
697
|
+
return el('div', { className: 'rtm-ok' }, '✓ ' + kind + ':' + extra)
|
|
698
|
+
}
|
|
699
|
+
return el('div', { className: 'rtm-err' }, '✗ ' + kind + ':' + (t.error || '失败'))
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const st = snap ? snap.status : null
|
|
703
|
+
const fields = snap && draft ? fieldSpecs(snap, draft) : []
|
|
704
|
+
const group = function (g) { return fields.filter(function (f) { return f.g === g }).map(function (f) { return renderField0(f, draft, setDraft, snap) }) }
|
|
705
|
+
|
|
706
|
+
return el('div', { className: 'rtm-set' },
|
|
707
|
+
el('div', { className: 'rtm-head' },
|
|
708
|
+
el('span', { className: 'rtm-brand' }, '记忆与向量存储'),
|
|
709
|
+
st ? el('span', { className: 'rtm-stat' },
|
|
710
|
+
'本地 ' + (st.localCount || 0) + ' 条 · 索引 ' + (st.indexed || 0) + ' 条' + (st.pending ? '(待同步 ' + st.pending + ')' : '')) : null,
|
|
711
|
+
el('span', { className: 'rtm-sp' }),
|
|
712
|
+
btn('保存记忆设置', { primary: true, disabled: !!busy }, function () { call('saveSettings', draft, '保存记忆设置') })),
|
|
713
|
+
|
|
714
|
+
error ? el('div', { className: 'rtm-errbar' }, el('span', null, error), el('button', { className: 'rtm-x', onClick: function () { setError(null) } }, '×')) : null,
|
|
715
|
+
toast ? el('div', { className: 'rtm-ok' }, toast) : null,
|
|
716
|
+
|
|
717
|
+
!draft ? el('div', { className: 'rtm-dim' }, '加载中…') : el('div', null,
|
|
718
|
+
el('div', { className: 'rtm-hint' }, '只有想用「向量检索」时才需要 Milvus 与向量模型;不配也能记、也能按关键词查,配好后点「同步索引」把已有条目补进索引。'),
|
|
719
|
+
el('div', { className: 'rtm-row' },
|
|
720
|
+
btn('测试 Milvus', { disabled: !!busy }, function () { test('Milvus', 'testMilvus') }),
|
|
721
|
+
btn('测试向量模型', { disabled: !!busy }, function () { test('向量模型', 'testEmbed') }),
|
|
722
|
+
btn('测试重排', { disabled: !!busy }, function () { test('重排模型', 'testRerank') }),
|
|
723
|
+
btn('测试 MinIO', { disabled: !!busy }, function () { test('MinIO', 'testS3') }),
|
|
724
|
+
el('span', { className: 'rtm-sp' }),
|
|
725
|
+
btn('同步索引' + (st && st.pending ? '(' + st.pending + ')' : ''), { disabled: !!busy || !(st && st.pending > 0) }, function () { call('syncIndex', { all: false }, '同步索引') }),
|
|
726
|
+
btn('整库重建索引', { disabled: !!busy, title: '把本地全部条目重新写一遍索引(换过向量模型或维度后用它)' }, function () { call('syncIndex', { all: true }, '整库重建索引') })),
|
|
727
|
+
testLine('Milvus'), testLine('向量模型'), testLine('重排模型'), testLine('MinIO'),
|
|
728
|
+
|
|
729
|
+
el('div', { className: 'rtm-grid' },
|
|
730
|
+
card('Milvus', '向量库', group('milvus')),
|
|
731
|
+
card('向量模型', 'Embedding', group('embed')),
|
|
732
|
+
card('重排模型', '可选', group('rerank')),
|
|
733
|
+
card('MinIO', 'Milvus 的存储桶', group('s3')),
|
|
734
|
+
card('本地库', '权威数据', groupTop(fields, draft, setDraft, snap),
|
|
735
|
+
el('div', { className: 'rtm-row' },
|
|
736
|
+
btn('删除向量索引', { danger: true, mini: true, disabled: !!busy || !(draft.milvus && draft.milvus.uri), title: '删掉整个 collection;本地库不动' }, function () { call('dropCollection', {}, '删除向量索引') }),
|
|
737
|
+
btn('列举 MinIO 对象', { mini: true, disabled: !!busy }, function () {
|
|
738
|
+
setBusy('s3')
|
|
739
|
+
host.call('s3List', jsonArgs({ prefix: (draft.s3.prefix) || '', limit: 30 }))
|
|
740
|
+
.then(function (r) { if (r && r.ok === true) { setS3objs(r); setError(null) } else setError((r && r.error) || 'MinIO 列举失败') })
|
|
741
|
+
.catch(function (e) { setError(reportError(e)) })
|
|
742
|
+
.then(function () { setBusy('') })
|
|
743
|
+
}),
|
|
744
|
+
btn('导出到 MinIO', { mini: true, disabled: !!busy, title: '把整个知识库导出成 JSON 放进 MinIO 桶(导出源是本地库)' }, function () { call('s3Backup', {}, '导出到 MinIO') }))),
|
|
745
|
+
|
|
746
|
+
s3objs ? card('s3://' + s3objs.bucket, s3objs.objects.length + ' 个对象' + (s3objs.truncated ? '(已截断)' : ''),
|
|
747
|
+
el('table', { className: 'rtm-tbl' },
|
|
748
|
+
el('thead', null, el('tr', null, el('th', null, 'Key'), el('th', null, '大小'), el('th', null, '修改时间'))),
|
|
749
|
+
el('tbody', null, s3objs.objects.map(function (o, i) {
|
|
750
|
+
return el('tr', { key: i }, el('td', null, o.key), el('td', null, fmtSize(o.size)), el('td', { className: 'rtm-hint' }, o.lastModified))
|
|
751
|
+
})))) : null))
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// 顶层字段(本地库文件 / 默认返回条数)与分组字段拆开,卡片里才排得整齐。
|
|
756
|
+
function groupTop(fields, draft, setDraft, snap) {
|
|
757
|
+
return fields.filter(function (f) { return f.top === true }).map(function (f) { return renderField0(f, draft, setDraft, snap) })
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// 真正渲染一个字段:provider 联动需要看预设,所以这里把 snap 一起带上。
|
|
761
|
+
function renderField0(f, draft, setDraft, snap) {
|
|
762
|
+
const cur = f.top ? (draft ? draft[f.k] : '') : (draft ? draft[f.g][f.k] : '')
|
|
763
|
+
function onChange(v) {
|
|
764
|
+
setDraft(function (d) {
|
|
765
|
+
const n = JSON.parse(JSON.stringify(d))
|
|
766
|
+
if (f.top) { n[f.k] = v; return n }
|
|
767
|
+
n[f.g][f.k] = v
|
|
768
|
+
// 换服务商时把模型与接口地址一起重置:避免「智谱的服务 + 阿里的模型」这种自相矛盾的组合
|
|
769
|
+
if (f.k === 'provider') {
|
|
770
|
+
const groups = f.g === 'embed' ? ((snap && snap.embedPresets) || []) : ((snap && snap.rerankPresets) || [])
|
|
771
|
+
const p = groups.filter(function (x) { return x.id === v })[0]
|
|
772
|
+
if (p) {
|
|
773
|
+
const first = p.models && p.models[0]
|
|
774
|
+
n[f.g].model = first ? (first.id || first) : ''
|
|
775
|
+
n[f.g].baseUrl = ''
|
|
776
|
+
if (f.g === 'embed' && first && first.dimension) n.embed.dimension = first.dimension
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (f.k === 'model' && f.g === 'embed') {
|
|
780
|
+
const p = ((snap && snap.embedPresets) || []).filter(function (x) { return x.id === n.embed.provider })[0]
|
|
781
|
+
const mm = p && p.models ? p.models.filter(function (x) { return x.id === v })[0] : null
|
|
782
|
+
if (mm && mm.dimension) n.embed.dimension = mm.dimension
|
|
783
|
+
}
|
|
784
|
+
return n
|
|
785
|
+
})
|
|
786
|
+
}
|
|
787
|
+
return renderFieldNode(f, cur, onChange)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ── 侧边栏图标:书脊 + 搜索结果点 ────────────────────────────────────────────
|
|
791
|
+
function Glyph(props) {
|
|
792
|
+
const size = props && props.size ? props.size : 16
|
|
793
|
+
const active = props && props.active
|
|
794
|
+
const c = active ? 'var(--dsw-alias-brand-primary)' : 'currentColor'
|
|
795
|
+
return React.createElement('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none' },
|
|
796
|
+
React.createElement('path', { d: 'M5 4.5h6.2c1.6 0 2.8 1.2 2.8 2.8v12.2H7.8C6.2 19.5 5 18.3 5 16.7z', stroke: c, strokeWidth: 1.6, strokeLinejoin: 'round' }),
|
|
797
|
+
React.createElement('path', { d: 'M19 6.5v13H13.9', stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }),
|
|
798
|
+
React.createElement('circle', { cx: 15.4, cy: 10.2, r: 2.4, stroke: c, strokeWidth: 1.4 }),
|
|
799
|
+
React.createElement('path', { d: 'M17.2 12l2.4 2.4', stroke: c, strokeWidth: 1.4, strokeLinecap: 'round' })
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ── 样式:全部走主题 token ──────────────────────────────────────────────────
|
|
804
|
+
ctx.effect(function () {
|
|
805
|
+
return styles.insert([
|
|
806
|
+
'.rtm-root{padding:14px 16px;display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary)}',
|
|
807
|
+
'.rtm-set{padding:16px 18px;display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary)}',
|
|
808
|
+
'.rtm-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
|
809
|
+
'.rtm-brand{font-size:14px;font-weight:600;letter-spacing:.2px}',
|
|
810
|
+
'.rtm-brand-c{color:var(--dsw-alias-brand-primary)}',
|
|
811
|
+
'.rtm-stat{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
812
|
+
'.rtm-sp{flex:1 1 auto}',
|
|
813
|
+
'.rtm-dim{color:var(--dsw-alias-label-secondary)}',
|
|
814
|
+
'.rtm-hint{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
815
|
+
'.rtm-err{color:var(--dsw-alias-state-error-primary);font-size:12px}',
|
|
816
|
+
'.rtm-ok{color:var(--dsw-alias-state-success-primary);font-size:12px}',
|
|
817
|
+
'.rtm-warn{color:var(--dsw-alias-state-warn-primary);font-size:12px}',
|
|
818
|
+
'.rtm-errbar{display:flex;justify-content:space-between;gap:8px;align-items:flex-start;color:var(--dsw-alias-state-error-primary);font-size:12px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px}',
|
|
819
|
+
'.rtm-x{background:transparent;border:none;color:inherit;font-size:16px;line-height:1;cursor:pointer;padding:0 4px}',
|
|
820
|
+
|
|
821
|
+
'.rtm-tabs{display:flex;gap:2px;border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
822
|
+
'.rtm-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--dsw-alias-label-secondary);padding:6px 12px;font-size:12.5px;cursor:pointer;font-family:inherit}',
|
|
823
|
+
'.rtm-tab:hover{color:var(--dsw-alias-label-primary)}',
|
|
824
|
+
'.rtm-tab-on{color:var(--dsw-alias-brand-primary);border-bottom-color:var(--dsw-alias-brand-primary);font-weight:600}',
|
|
825
|
+
'.rtm-body{display:flex;flex-direction:column;gap:12px}',
|
|
826
|
+
|
|
827
|
+
'.rtm-tools{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
|
|
828
|
+
'.rtm-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}',
|
|
829
|
+
'.rtm-card-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
830
|
+
'.rtm-card-t{font-size:12.5px;font-weight:600}',
|
|
831
|
+
'.rtm-card-s{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
832
|
+
'.rtm-card-b{display:flex;flex-direction:column;gap:8px}',
|
|
833
|
+
|
|
834
|
+
'.rtm-btn{height:26px;padding:0 10px;border-radius:7px;font-size:12px;font-family:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}',
|
|
835
|
+
'.rtm-btn:hover:not(:disabled){border-color:var(--dsw-alias-border-l2)}',
|
|
836
|
+
'.rtm-btn:disabled{opacity:.45;cursor:not-allowed}',
|
|
837
|
+
'.rtm-btn-primary{background:var(--dsw-alias-brand-primary);border-color:transparent;color:#fff}',
|
|
838
|
+
'.rtm-btn-primary:hover:not(:disabled){filter:brightness(1.06)}',
|
|
839
|
+
'.rtm-btn-danger{color:var(--dsw-alias-state-error-primary)}',
|
|
840
|
+
'.rtm-btn-mini{height:22px;padding:0 8px;font-size:11.5px}',
|
|
841
|
+
|
|
842
|
+
'.rtm-in{height:26px;box-sizing:border-box;width:100%;padding:0 8px;border-radius:7px;font-size:12px;font-family:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1)}',
|
|
843
|
+
'.rtm-in:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}',
|
|
844
|
+
'.rtm-in-xs{width:64px;flex:0 0 auto}',
|
|
845
|
+
'.rtm-grow{flex:1 1 240px;min-width:160px;width:auto}',
|
|
846
|
+
'.rtm-ta{min-height:64px;box-sizing:border-box;width:100%;padding:7px 8px;border-radius:7px;font-size:12px;font-family:ui-monospace,monospace;line-height:1.55;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);resize:vertical}',
|
|
847
|
+
'.rtm-ta:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}',
|
|
848
|
+
|
|
849
|
+
'.rtm-badge{display:inline-flex;align-items:center;height:18px;padding:0 6px;border-radius:5px;font-size:11px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);white-space:nowrap}',
|
|
850
|
+
'.rtm-badge-ok{color:var(--dsw-alias-state-success-primary)}',
|
|
851
|
+
'.rtm-badge-warn{color:var(--dsw-alias-state-warn-primary)}',
|
|
852
|
+
'.rtm-badge-err{color:var(--dsw-alias-state-error-primary)}',
|
|
853
|
+
'.rtm-badge-brand{color:var(--dsw-alias-brand-primary)}',
|
|
854
|
+
|
|
855
|
+
'.rtm-tbl{width:100%;border-collapse:collapse;font-size:12px}',
|
|
856
|
+
'.rtm-tbl th{text-align:left;font-weight:500;font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
857
|
+
'.rtm-tbl td{padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);vertical-align:top}',
|
|
858
|
+
'.rtm-tbl tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
|
|
859
|
+
'.rtm-tbl tbody tr:last-child td{border-bottom:none}',
|
|
860
|
+
'.rtm-th-x{width:28px}',
|
|
861
|
+
'.rtm-tr-on td{background:var(--dsw-alias-bg-layer-2)}',
|
|
862
|
+
'.rtm-lnk{color:var(--dsw-alias-brand-primary);cursor:pointer;text-decoration:none}',
|
|
863
|
+
'.rtm-lnk:hover{text-decoration:underline}',
|
|
864
|
+
'.rtm-tagc{color:var(--dsw-alias-label-secondary);font-size:11.5px}',
|
|
865
|
+
'.rtm-pager{display:flex;align-items:center;gap:10px;padding:6px 0}',
|
|
866
|
+
|
|
867
|
+
'.rtm-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;align-items:start}',
|
|
868
|
+
'.rtm-f{display:flex;flex-direction:column;gap:4px}',
|
|
869
|
+
'.rtm-f-row{flex-direction:row;align-items:center;gap:6px}',
|
|
870
|
+
'.rtm-f-l{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
871
|
+
'.rtm-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
|
|
872
|
+
|
|
873
|
+
'.rtm-hit{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-brand-primary);border-radius:8px;padding:8px 10px;display:flex;flex-direction:column;gap:5px;background:var(--dsw-alias-bg-layer-1)}',
|
|
874
|
+
'.rtm-hit-t{font-weight:600;font-size:12.5px}',
|
|
875
|
+
'.rtm-hit-b{font-size:12px;line-height:1.55;white-space:pre-wrap}',
|
|
876
|
+
'.rtm-pre{white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.6;max-height:44vh;overflow:auto;margin:0;font-family:inherit}',
|
|
877
|
+
|
|
878
|
+
'.rtm-caps{display:flex;flex-direction:column;gap:4px}',
|
|
879
|
+
'.rtm-cap-i{display:flex;gap:8px;align-items:baseline;font-size:11.5px}',
|
|
880
|
+
'.rtm-cap-x{flex:1 1 auto;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
881
|
+
|
|
882
|
+
'.rtm-logs{display:flex;flex-direction:column;gap:2px;font-size:11.5px;max-height:60vh;overflow:auto}',
|
|
883
|
+
'.rtm-ln{display:flex;gap:8px;align-items:baseline}',
|
|
884
|
+
'.rtm-lt{flex:0 0 auto;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}',
|
|
885
|
+
'.rtm-ll{flex:0 0 auto;min-width:34px;color:var(--dsw-alias-label-secondary)}',
|
|
886
|
+
'.rtm-lx{white-space:pre-wrap;word-break:break-word}',
|
|
887
|
+
'.rtm-ln-err .rtm-lx{color:var(--dsw-alias-state-error-primary)}',
|
|
888
|
+
'.rtm-ln-warn .rtm-lx{color:var(--dsw-alias-state-warn-primary)}',
|
|
889
|
+
'.rtm-ln-ok .rtm-lx{color:var(--dsw-alias-state-success-primary)}',
|
|
890
|
+
].join('\n'))
|
|
891
|
+
}, 'redteam-memory: styles')
|
|
892
|
+
|
|
893
|
+
ctx.effect(function () {
|
|
894
|
+
return slots.inject('sidebar.panellist', function () {
|
|
895
|
+
return slots.register({ name: 'sidebar.panellist', id: PANEL_KEY, order: 70, label: '红队记忆' }, Glyph)
|
|
896
|
+
})
|
|
897
|
+
}, 'redteam-memory: panel button')
|
|
898
|
+
|
|
899
|
+
ctx.effect(function () {
|
|
900
|
+
return slots.inject('main', function () {
|
|
901
|
+
return slots.register({ name: 'main', key: PANEL_KEY }, MemoryPanel)
|
|
902
|
+
})
|
|
903
|
+
}, 'redteam-memory: main panel')
|
|
904
|
+
|
|
905
|
+
// 配置进 DSH 设置里的一页,而不是占着数据面板的 tab。
|
|
906
|
+
ctx.effect(function () {
|
|
907
|
+
return slots.inject('settings.section', function () {
|
|
908
|
+
return slots.register({ name: 'settings.section', id: SETTINGS_KEY, order: 100, label: '红队设置' }, UnifiedSettingsPage)
|
|
909
|
+
})
|
|
910
|
+
}, 'redteam-memory: 设置页')
|
|
911
|
+
|
|
912
|
+
console.log('[rtmemory] redteam-memory client half ready; panels =', PANEL_KEY, '+ 设置页 红队设置')
|
|
913
|
+
}
|
|
914
|
+
exports.inject = ['slots', 'timer']
|
|
915
|
+
exports.apply = function (ctx) { return applyClient(ctx) }
|
|
916
|
+
return module.exports
|
|
917
|
+
}
|
|
918
|
+
});
|