dsh-redteam-report 0.2.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/src/client.js ADDED
@@ -0,0 +1,602 @@
1
+ // 红队报告 · Client 半边
2
+ //
3
+ // lib/client.js 由 `npm run build:lib` 从本文件生成,不要手改 lib/。
4
+ // 生成器以函数名 `applyClient` 为入口锚点,并在其中注入 host.call / styles.insert 垫片
5
+ // ——静态 bundle 里没有这两个闭包符号,原因见 ../../docs/DEVELOPMENT.md。
6
+ //
7
+ // 注意:本文件必须以 `return { name, inject, apply }` 块【结尾】,生成器据此剥离动态包装。
8
+ //
9
+ // ── 界面结构 ──────────────────────────────────────────────────────────────────
10
+ // ┌ 顶栏:报告份数 · 当前字数(未保存)· 证据规模 · 生成进度 + 刷新
11
+ // ├ 标签页:报告 / 证据 / 设置 / 日志
12
+ // ├ 报告:报告列表(切换)· 标题 · 操作行(生成 / 保存 | 导出 MD·HTML·Word | 导入记忆 / 删除)
13
+ // │ 左编辑(Markdown,带字数)右预览(宿主渲染的完整 HTML,所见即导出)
14
+ // ├ 证据:试算证据 —— 会话采集表 + **真正喂给模型的那份 digest**
15
+ // ├ 设置:撰写模型 · 证据预算 · 路径 · 额外要求
16
+ // └ 日志:最近操作与错误
17
+ //
18
+ // ── 两条设计纪律 ─────────────────────────────────────────────────────────────
19
+ // 1. **一屏只留一个主操作**。原来九个按钮挤在一行里(生成 / 新建×2 / 保存 / 导出×3 /
20
+ // 导入 / 删除),主次不分;现在生成与保存是主,导出归成一组,删除这类破坏性操作
21
+ // 弱化到右侧并带二次语义(红色文字而不是红色实心块)。
22
+ // 2. **颜色只用真实存在的主题 token**。之前写的 --dsw-alias-bg-l1/l2/l3 并不存在,
23
+ // 于是背景一直掉回硬编码 rgba —— 深浅色主题下观感不一致,看着「脏」。
24
+
25
+ function applyClient(ctx) {
26
+ const slots = ctx.slots
27
+
28
+ const PANEL_KEY = 'redteam-report'
29
+ const TABS = [['doc', '报告'], ['evi', '证据'], ['set', '设置'], ['log', '日志']]
30
+ const PREVIEW_DEBOUNCE_MS = 400
31
+
32
+ // ── 通用小工具 ──────────────────────────────────────────────────────────────
33
+ function el(tag, props) {
34
+ const children = Array.prototype.slice.call(arguments, 2)
35
+ return React.createElement.apply(React, [tag, props || {}].concat(children))
36
+ }
37
+
38
+ // 过桥的参数必须是 JSON:丢掉 undefined / null,否则 bridge 会直接拒绝
39
+ function jsonArgs(o) {
40
+ const out = {}
41
+ for (const k of Object.keys(o || {})) {
42
+ const v = o[k]
43
+ if (v === undefined || v === null) continue
44
+ out[k] = v
45
+ }
46
+ return out
47
+ }
48
+
49
+ function fmtTime(ms) {
50
+ if (!ms) return '—'
51
+ const n = Number(ms)
52
+ if (!isFinite(n) || n <= 0) return String(ms)
53
+ const d = new Date(n)
54
+ if (isNaN(d.getTime())) return String(ms)
55
+ const p = function (x) { return x < 10 ? '0' + x : String(x) }
56
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes())
57
+ }
58
+
59
+ function reportError(e) { return String((e && e.message) || e) }
60
+ function fmtNum(n) { const v = Number(n) || 0; return v >= 10000 ? (v / 1000).toFixed(1) + 'k' : String(v) }
61
+
62
+ // UI 基元:按钮 / 卡片 / 徽标 / 按钮组。两个层级(主/次)就够,
63
+ // 全部平铺才是「丑」的根源 —— 人看不出该点哪个。
64
+ function btn(label, opts, onClick) {
65
+ const o = opts || {}
66
+ const cls = 'rtr-btn' + (o.primary ? ' rtr-btn-primary' : '') + (o.danger ? ' rtr-btn-danger' : '') + (o.mini ? ' rtr-btn-mini' : '')
67
+ return el('button', { key: o.key || label, className: cls, disabled: o.disabled === true, title: o.title || '', onClick: onClick }, label)
68
+ }
69
+
70
+ function card(title, sub, children, extra) {
71
+ return el('section', { className: 'rtr-card' },
72
+ el('div', { className: 'rtr-card-h' },
73
+ el('span', { className: 'rtr-card-t' }, title),
74
+ sub ? el('span', { className: 'rtr-card-s' }, sub) : null,
75
+ el('span', { className: 'rtr-sp' }),
76
+ extra || null),
77
+ el('div', { className: 'rtr-card-b' }, children))
78
+ }
79
+
80
+ function badge(text, kind) {
81
+ return el('span', { className: 'rtr-badge' + (kind ? ' rtr-badge-' + kind : '') }, text)
82
+ }
83
+
84
+ function group(label, children) {
85
+ return el('div', { className: 'rtr-group' }, el('span', { className: 'rtr-group-l' }, label), children)
86
+ }
87
+
88
+ function hint(text) { return el('span', { className: 'rtr-hint' }, text) }
89
+
90
+ function Panel() {
91
+ const [snap, setSnap] = React.useState(null)
92
+ const [tab, setTab] = React.useState('doc')
93
+ const [busy, setBusy] = React.useState('')
94
+ const [error, setError] = React.useState(null)
95
+ const [toast, setToast] = React.useState('')
96
+ const [draft, setDraft] = React.useState(null)
97
+ const [title, setTitle] = React.useState('')
98
+ const [markdown, setMarkdown] = React.useState('')
99
+ const [dirty, setDirty] = React.useState(false)
100
+ const [html, setHtml] = React.useState('')
101
+ const [instruction, setInstruction] = React.useState('')
102
+ const [evidence, setEvidence] = React.useState(null)
103
+ const [exportInfo, setExportInfo] = React.useState(null)
104
+
105
+ function applySnap(r) {
106
+ if (!r || r.ok !== true) { setError((r && r.error) || '宿主没有返回数据'); return }
107
+ setError(null)
108
+ setSnap(r.snapshot)
109
+ setDraft(function (d) { return d || JSON.parse(JSON.stringify(r.snapshot.settings)) })
110
+ }
111
+
112
+ function refresh() {
113
+ setBusy('load')
114
+ return host.call('snapshot', {}).then(applySnap)
115
+ .catch(function (e) { setError(reportError(e)) })
116
+ .then(function () { setBusy('') })
117
+ }
118
+
119
+ function call(method, args, label) {
120
+ setBusy(method)
121
+ setToast('')
122
+ return host.call(method, jsonArgs(args || {}))
123
+ .then(function (r) {
124
+ if (r && r.snapshot) applySnap({ ok: true, snapshot: r.snapshot })
125
+ if (r && r.ok === false) { setError(r.error || (label + '失败')); return r }
126
+ setError(null)
127
+ if (label) setToast(label + '完成')
128
+ return r
129
+ })
130
+ .catch(function (e) { setError(reportError(e)); return null })
131
+ .then(function (r) { setBusy(''); return r })
132
+ }
133
+
134
+ React.useEffect(function () { refresh() }, [])
135
+
136
+ // 生成是后台任务:轮询 snapshot 才能看到进度与正在长出来的正文。
137
+ const liveRef = React.useRef({ generating: false, dirty: false })
138
+ liveRef.current = { generating: !!(snap && snap.status && snap.status.generating), dirty: dirty }
139
+ React.useEffect(function () {
140
+ if (typeof ctx.interval !== 'function') return undefined
141
+ return ctx.interval(function () {
142
+ if (!liveRef.current.generating) return
143
+ host.call('snapshot', {}).then(function (r) {
144
+ if (!r || r.ok !== true) return
145
+ setSnap(r.snapshot)
146
+ // 用户改过就不覆盖他的字;没改过就把流出来的正文同步进来。
147
+ if (!liveRef.current.dirty && r.snapshot.current) {
148
+ setMarkdown(r.snapshot.current.markdown || '')
149
+ setTitle(r.snapshot.current.title || '')
150
+ }
151
+ }).catch(function () {})
152
+ }, 2500)
153
+ }, [])
154
+
155
+ // 切换当前报告时同步编辑框(用户有未保存的改动就不动)
156
+ const curId = snap && snap.currentId ? snap.currentId : ''
157
+ React.useEffect(function () {
158
+ if (!snap || !snap.current || dirty) return
159
+ setTitle(snap.current.title || '')
160
+ setMarkdown(snap.current.markdown || '')
161
+ }, [curId])
162
+
163
+ // 预览:防抖后让宿主渲染,拿到完整 HTML 文档塞进 iframe。
164
+ // 定时器走 ctx.get('timer'):动态客户端沙箱里没有 setTimeout。
165
+ React.useEffect(function () {
166
+ const timer = ctx.get('timer')
167
+ const run = function () {
168
+ host.call('preview', jsonArgs({ id: curId, title: title, markdown: markdown }))
169
+ .then(function (r) { if (r && r.ok === true) setHtml(r.html) })
170
+ .catch(function () {})
171
+ }
172
+ if (timer && typeof timer.timeout === 'function') {
173
+ const dispose = timer.timeout(run, PREVIEW_DEBOUNCE_MS)
174
+ return function () { try { dispose() } catch (e) {} }
175
+ }
176
+ run()
177
+ return undefined
178
+ }, [markdown, title, curId])
179
+
180
+ function doGenerate(createNew) {
181
+ setToast('')
182
+ setExportInfo(null)
183
+ call('generate', { reportId: createNew ? undefined : curId, createNew: createNew === true, instruction: instruction.trim() || undefined }, '已开始生成')
184
+ .then(function (r) {
185
+ if (r && r.ok === true) { setDirty(false); setMarkdown(''); setTitle(''); setTab('doc') }
186
+ })
187
+ }
188
+
189
+ function doSave() {
190
+ call('saveDraft', { id: curId, title: title, markdown: markdown }, '保存').then(function (r) {
191
+ if (r && r.ok === true) setDirty(false)
192
+ })
193
+ }
194
+
195
+ function doExport(format) {
196
+ setExportInfo(null)
197
+ call('export', { id: curId, format: format }).then(function (r) {
198
+ if (r && r.ok === true) setExportInfo(r)
199
+ })
200
+ }
201
+
202
+ function doImport() {
203
+ call('importToMemory', { id: curId }, '导入记忆').then(function (r) {
204
+ if (r && r.ok === true) setToast('已导入记忆:新增 ' + r.added + ' 条 / 覆盖 ' + r.updated + ' 条' + (r.indexError ? '(索引未同步,本地已存)' : ''))
205
+ })
206
+ }
207
+
208
+ function doCollect() {
209
+ setEvidence(null)
210
+ call('collect', { preview: 12000 }, '试算证据').then(function (r) {
211
+ if (r && r.ok === true) setEvidence(r.evidence)
212
+ })
213
+ }
214
+
215
+ function doNew() {
216
+ call('create', { title: '' }, '新建').then(function (r) {
217
+ if (r && r.ok === true) { setDirty(false); setMarkdown(''); setTitle(''); setTab('doc') }
218
+ })
219
+ }
220
+
221
+ function doRemove() {
222
+ if (!curId) { setError('先选中一份报告'); return }
223
+ call('remove', { ids: [curId] }, '删除报告').then(function (r) {
224
+ if (r && r.ok === true) { setDirty(false); setMarkdown(''); setTitle(''); setEvidence(null) }
225
+ })
226
+ }
227
+
228
+ function setField(k, v) {
229
+ setDraft(function (d) {
230
+ const n = JSON.parse(JSON.stringify(d || {}))
231
+ n[k] = v
232
+ return n
233
+ })
234
+ }
235
+
236
+ function setModelField(k, v) {
237
+ setDraft(function (d) {
238
+ const n = JSON.parse(JSON.stringify(d || {}))
239
+ if (!n.model) n.model = { provider: '', model: '' }
240
+ n.model[k] = v
241
+ return n
242
+ })
243
+ }
244
+
245
+ const st = snap ? snap.status : null
246
+ const reports = (snap && snap.reports) || []
247
+ const cur = snap && snap.current ? snap.current : null
248
+ const generating = !!(st && st.generating)
249
+ const evMeta = (cur && cur.meta && cur.meta.evidence) || (st && st.evidence) || null
250
+ const hasBody = !!String(markdown || '').trim()
251
+
252
+ function head() {
253
+ const bits = ['报告 ' + reports.length + ' 份']
254
+ if (cur) bits.push(fmtNum(markdown.length) + ' 字' + (dirty ? '(未保存)' : ''))
255
+ if (evMeta) bits.push('证据:会话 ' + (evMeta.sessions || 0) + ' · 矩阵 ' + (evMeta.matrixConfirmed || 0) + '/' + (evMeta.matrixSuspected || 0) + ' · 记忆 ' + (evMeta.memoryHits || 0))
256
+ return el('div', { className: 'rtr-head' },
257
+ el('span', { className: 'rtr-brand' }, '红队报告'),
258
+ el('span', { className: 'rtr-stat' }, bits.join(' · ')),
259
+ st && st.progress ? el('span', { className: 'rtr-stat rtr-brand-c' }, st.progress.text) : null,
260
+ el('span', { className: 'rtr-sp' }),
261
+ btn('刷新', { disabled: !!busy }, refresh))
262
+ }
263
+
264
+ // ── 报告 tab ──────────────────────────────────────────────────────────────
265
+ function docTab() {
266
+ if (!reports.length) {
267
+ return el('div', { className: 'rtr-body' },
268
+ card('还没有报告', '一句话说清它读什么', [
269
+ el('div', { className: 'rtr-hint' },
270
+ '点下面的按钮,AI 会自动收集三样东西再动笔:**当前工作区的对话**(用户要求 / 关键操作 / 结果与结论)、'
271
+ + '**攻击矩阵的命中**(已确认与疑似分开,带技术点名字与判据)、**记忆库里相关的知识条目**。'
272
+ + '写完之后可以在「证据」页看到它到底读到了什么。'),
273
+ el('div', { className: 'rtr-row' },
274
+ btn('生成报告', { primary: true, disabled: !!busy || generating }, function () { doGenerate(false) }),
275
+ btn('新建空白报告', { disabled: !!busy }, doNew)),
276
+ ]))
277
+ }
278
+ return el('div', { className: 'rtr-body' },
279
+ // 报告列表:切换用,带字数与时间,比一行纯标题好认
280
+ card('报告', reports.length + ' 份', [
281
+ el('div', { className: 'rtr-chips' }, reports.map(function (r) {
282
+ return el('button', {
283
+ key: r.id, className: 'rtr-chip' + (r.id === curId ? ' rtr-chip-on' : ''), disabled: !!busy,
284
+ title: (r.provider ? r.provider + '/' + r.model + ' · ' : '') + '更新于 ' + fmtTime(r.updatedAt),
285
+ onClick: function () { if (r.id === curId) return; setDirty(false); call('select', { id: r.id }, '') },
286
+ },
287
+ el('span', { className: 'rtr-chip-t' }, r.title || '未命名'),
288
+ el('span', { className: 'rtr-chip-m' }, fmtNum(r.chars) + ' 字 · ' + fmtTime(r.updatedAt)))
289
+ })),
290
+ ], btn('新建空白', { mini: true, disabled: !!busy }, doNew)),
291
+
292
+ curId ? el('div', { className: 'rtr-edit' },
293
+ el('input', {
294
+ className: 'rtr-in rtr-title', value: title, placeholder: '报告标题',
295
+ onChange: function (e) { setTitle(e.target.value); setDirty(true) },
296
+ }),
297
+
298
+ // 一条操作行:主操作在左,导出成组在中间,破坏性操作弱化在右
299
+ el('div', { className: 'rtr-tools' },
300
+ btn(generating ? '生成中…' : (hasBody ? '重新生成' : '生成报告'), { primary: true, disabled: !!busy || generating }, function () { doGenerate(false) }),
301
+ btn(dirty ? '保存 *' : '保存', { disabled: !!busy || generating || !dirty }, doSave),
302
+ el('span', { className: 'rtr-sep' }),
303
+ group('导出', el('span', { className: 'rtr-seg' },
304
+ btn('Markdown', { mini: true, disabled: !!busy || !hasBody }, function () { doExport('md') }),
305
+ btn('HTML', { mini: true, disabled: !!busy || !hasBody }, function () { doExport('html') }),
306
+ btn('Word', { mini: true, primary: true, disabled: !!busy || !hasBody, title: '导出 .docx(落盘路径显示在下面)' }, function () { doExport('docx') }))),
307
+ btn('导入记忆', { disabled: !!busy || !hasBody, title: '把报告按章节切段写进红队记忆库' }, doImport),
308
+ el('span', { className: 'rtr-sp' }),
309
+ btn('删除', { danger: true, mini: true, disabled: !!busy, title: '删除这份报告(不可恢复)' }, doRemove)),
310
+
311
+ el('div', { className: 'rtr-panes' },
312
+ el('div', { className: 'rtr-pane' },
313
+ el('div', { className: 'rtr-pane-h' },
314
+ el('span', null, 'Markdown'),
315
+ el('span', { className: 'rtr-sp' }),
316
+ hint(fmtNum(markdown.length) + ' 字') + ''),
317
+ el('textarea', {
318
+ className: 'rtr-ta', value: markdown, placeholder: '点「生成报告」让 AI 写,或者直接在这里手写…',
319
+ onChange: function (e) { setMarkdown(e.target.value); setDirty(true) },
320
+ })),
321
+ el('div', { className: 'rtr-pane' },
322
+ el('div', { className: 'rtr-pane-h' }, el('span', null, '预览'), el('span', { className: 'rtr-sp' }), hint('与导出的 HTML / Word 同一套渲染')),
323
+ html
324
+ ? el('iframe', { className: 'rtr-frame', srcDoc: html, sandbox: 'allow-same-origin' })
325
+ : el('div', { className: 'rtr-frame rtr-frame-empty' }, '预览生成中…'))),
326
+
327
+ exportInfo ? el('div', { className: exportInfo.writeError ? 'rtr-warn' : 'rtr-ok' },
328
+ '已导出 ' + exportInfo.name + '(' + exportInfo.bytes + ' 字节)'
329
+ + (exportInfo.path ? ':' + exportInfo.path : '(未落盘)')
330
+ + (exportInfo.writeError ? '|落盘失败:' + exportInfo.writeError : '')) : null,
331
+
332
+ cur && cur.meta && cur.meta.generatedAt
333
+ ? el('div', { className: 'rtr-hint' }, '生成于 ' + fmtTime(cur.meta.generatedAt) + ' · 模型 ' + (cur.meta.provider || '?') + '/' + (cur.meta.model || '?')
334
+ + (cur.meta.evidence ? ' · 证据 digest ' + fmtNum(cur.meta.evidence.digestChars) + ' 字' : ''))
335
+ : null) : null
336
+ )
337
+ }
338
+
339
+ // ── 证据 tab ──────────────────────────────────────────────────────────────
340
+ function eviTab() {
341
+ return el('div', { className: 'rtr-body' },
342
+ el('div', { className: 'rtr-tools' },
343
+ btn(busy === 'collect' ? '试算中…' : '试算证据', { primary: true, disabled: !!busy }, doCollect),
344
+ hint('把真正喂给模型的那份材料打出来 —— 报告写得不对,九成是证据没采到或提示词没说清')),
345
+
346
+ !evidence ? card('还没试算', null, [
347
+ el('div', { className: 'rtr-hint' }, '点「试算证据」:它会真的跑一遍采集(读工作区会话、攻击矩阵、记忆库),并把裁剪后的 digest 原样显示在这里。不写任何文件、不调模型。'),
348
+ ]) : el('div', { className: 'rtr-body' },
349
+ el('div', { className: 'rtr-row' },
350
+ badge('会话 ' + evidence.sessions.length, evidence.sessions.length ? 'brand' : 'warn'),
351
+ badge('已确认 ' + evidence.matrix.confirmed, evidence.matrix.confirmed ? 'ok' : 'warn'),
352
+ badge('疑似 ' + evidence.matrix.suspected, evidence.matrix.suspected ? 'warn' : ''),
353
+ badge('记忆 ' + evidence.memory.count, evidence.memory.count ? 'brand' : 'warn'),
354
+ badge('digest ' + fmtNum(evidence.digestChars) + ' 字', 'brand')),
355
+ hint('工作区 ' + ((evidence.workspace && evidence.workspace.path) || '(无)')
356
+ + ' · 矩阵来源 ' + evidence.matrix.from
357
+ + (evidence.memory.available ? '' : ' · 记忆插件未在运行')),
358
+ evidence.matrix.error ? el('div', { className: 'rtr-warn' }, '矩阵读取问题:' + evidence.matrix.error) : null,
359
+ evidence.queries && evidence.queries.length ? hint('记忆检索词:' + evidence.queries.join(' / ')) : null,
360
+
361
+ card('会话采集', evidence.sessions.length + ' 个',
362
+ evidence.sessions.length
363
+ ? el('table', { className: 'rtr-tbl' },
364
+ el('thead', null, el('tr', null,
365
+ el('th', null, '会话'), el('th', null, '用户要求'), el('th', null, '操作'), el('th', null, '结果'), el('th', null, '时间'))),
366
+ el('tbody', null, evidence.sessions.map(function (s) {
367
+ return el('tr', { key: s.id },
368
+ el('td', null, s.title || String(s.id).slice(0, 14)),
369
+ el('td', null, String(s.users)),
370
+ el('td', null, String(s.ops)),
371
+ el('td', null, String(s.results)),
372
+ el('td', { className: 'rtr-hint' }, fmtTime(s.firstAt) + ' → ' + fmtTime(s.lastAt)))
373
+ })))
374
+ : el('div', { className: 'rtr-dim' }, '没有采到会话(工作区里还没有对话,或者 sessions 服务不可用)')),
375
+
376
+ card('digest', '前 ' + fmtNum(evidence.digest.length) + ' 字' + (evidence.truncated ? ',实际 ' + fmtNum(evidence.digestChars) + ' 字' : ''),
377
+ el('pre', { className: 'rtr-pre' }, evidence.digest)))
378
+ )
379
+ }
380
+
381
+ // ── 设置 tab ──────────────────────────────────────────────────────────────
382
+ function setTab_() {
383
+ if (!draft) return el('div', { className: 'rtr-body rtr-dim' }, '加载中…')
384
+ return el('div', { className: 'rtr-body' },
385
+ el('div', { className: 'rtr-tools' },
386
+ btn('保存设置', { primary: true, disabled: !!busy }, function () { call('saveSettings', draft, '保存设置') }),
387
+ btn('用当前会话默认模型', { disabled: !!busy }, function () { setModelField('provider', ''); setModelField('model', '') }),
388
+ hint('留空 provider / model = 用你正在对话的那个模型(' + ((snap.model && snap.model.provider) || '?') + '/' + ((snap.model && snap.model.model) || '?') + ',来源 ' + ((snap.model && snap.model.from) || '?') + ')')),
389
+
390
+ el('div', { className: 'rtr-grid' },
391
+ card('撰写模型', '报告由它写', [
392
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, 'provider'),
393
+ el('input', { className: 'rtr-in', value: (draft.model && draft.model.provider) || '', placeholder: '留空 = 当前会话默认(例如 deepseek-official)', onChange: function (e) { setModelField('provider', e.target.value) } })),
394
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, 'model'),
395
+ el('input', { className: 'rtr-in', value: (draft.model && draft.model.model) || '', placeholder: '留空 = 当前会话默认(例如 deepseek-flash)', onChange: function (e) { setModelField('model', e.target.value) } })),
396
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, 'maxTokens'),
397
+ el('input', { className: 'rtr-in rtr-in-xs', type: 'number', value: draft.maxTokens, onChange: function (e) { setField('maxTokens', Number(e.target.value)) } })),
398
+ ]),
399
+
400
+ card('证据预算', '喂给模型多少材料', [
401
+ el('div', { className: 'rtr-grid2' },
402
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '最多读几个会话'),
403
+ el('input', { className: 'rtr-in', type: 'number', value: draft.sessionLimit, onChange: function (e) { setField('sessionLimit', Number(e.target.value)) } })),
404
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '每个会话最多取多少字'),
405
+ el('input', { className: 'rtr-in', type: 'number', value: draft.sessionChars, onChange: function (e) { setField('sessionChars', Number(e.target.value)) } })),
406
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '已确认命中上限'),
407
+ el('input', { className: 'rtr-in', type: 'number', value: draft.maxConfirmed, onChange: function (e) { setField('maxConfirmed', Number(e.target.value)) } })),
408
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '疑似命中上限'),
409
+ el('input', { className: 'rtr-in', type: 'number', value: draft.maxSuspected, onChange: function (e) { setField('maxSuspected', Number(e.target.value)) } })),
410
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, 'digest 总上限(字)'),
411
+ el('input', { className: 'rtr-in', type: 'number', value: draft.digestMax, onChange: function (e) { setField('digestMax', Number(e.target.value)) } })),
412
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '记忆:条数 / 查询数'),
413
+ el('div', { className: 'rtr-row' },
414
+ el('input', { className: 'rtr-in rtr-in-xs', type: 'number', value: draft.memoryTopK, onChange: function (e) { setField('memoryTopK', Number(e.target.value)) } }),
415
+ el('input', { className: 'rtr-in rtr-in-xs', type: 'number', value: draft.memoryQueries, onChange: function (e) { setField('memoryQueries', Number(e.target.value)) } })))),
416
+ ]),
417
+
418
+ card('路径', '留空就用默认', [
419
+ hint('相对名由宿主按插件自己的工作区解析(本机实测落在 /home/kali/桌面);写不进去时改成能写的绝对路径。'),
420
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '攻击矩阵存储'),
421
+ el('input', { className: 'rtr-in', value: draft.matrixStore || '', placeholder: (st && st.matrixPath) || '', onChange: function (e) { setField('matrixStore', e.target.value) } })),
422
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '报告库文件'),
423
+ el('input', { className: 'rtr-in', value: draft.storePath || '', onChange: function (e) { setField('storePath', e.target.value) } })),
424
+ el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '导出目录'),
425
+ el('input', { className: 'rtr-in', value: draft.exportDir || '', placeholder: '留空 = 与报告库同目录', onChange: function (e) { setField('exportDir', e.target.value) } })),
426
+ hint('当前落盘:' + ((st && st.storePath) || '(未解析)')),
427
+ ]),
428
+
429
+ card('额外要求', '每次生成都会带上', [
430
+ el('textarea', {
431
+ className: 'rtr-ta rtr-ta-sm', value: draft.instruction || '',
432
+ placeholder: '例如:重点写未授权访问链路,每条发现给出修复优先级;不要写攻击载荷细节。',
433
+ onChange: function (e) { setField('instruction', e.target.value) },
434
+ }),
435
+ ]))
436
+ )
437
+ }
438
+
439
+ function logTab() {
440
+ return el('div', { className: 'rtr-body' },
441
+ el('div', { className: 'rtr-tools' },
442
+ btn('刷新', { disabled: !!busy }, refresh),
443
+ btn('清空日志', { disabled: !!busy }, function () { call('logClear', {}, '清空日志') })),
444
+ el('div', { className: 'rtr-logs' }, (snap.log || []).slice().reverse().map(function (l) {
445
+ return el('div', { key: l.seq, className: 'rtr-ln rtr-ln-' + (l.level || 'info') },
446
+ el('span', { className: 'rtr-lt' }, fmtTime(l.at)),
447
+ el('span', { className: 'rtr-ll' }, l.level || ''),
448
+ el('span', { className: 'rtr-lx' }, l.text))
449
+ })))
450
+ }
451
+
452
+ return el('div', { className: 'rtr-root' },
453
+ head(),
454
+ error ? el('div', { className: 'rtr-errbar' }, el('span', null, error), el('button', { className: 'rtr-x', onClick: function () { setError(null) } }, '×')) : null,
455
+ toast ? el('div', { className: 'rtr-ok' }, toast) : null,
456
+ st && st.persistence === 'memory' ? el('div', { className: 'rtr-warn' }, 'fs 服务不可用:报告只存在内存里,重启会丢') : null,
457
+ st && st.lastError && st.persistence === 'error' ? el('div', { className: 'rtr-warn' }, st.lastError) : null,
458
+
459
+ el('div', { className: 'rtr-tabs' }, TABS.map(function (t) {
460
+ return el('button', { key: t[0], className: 'rtr-tab' + (tab === t[0] ? ' rtr-tab-on' : ''), onClick: function () { setTab(t[0]) } }, t[1])
461
+ })),
462
+
463
+ !snap ? el('div', { className: 'rtr-body rtr-dim' }, busy === 'load' ? '加载中…' : '正在读取报告库…') : null,
464
+ snap && tab === 'doc' ? docTab() : null,
465
+ snap && tab === 'evi' ? eviTab() : null,
466
+ snap && tab === 'set' ? setTab_() : null,
467
+ snap && tab === 'log' ? logTab() : null
468
+ )
469
+ }
470
+
471
+ // 侧边栏图标:一份带批注点的文档
472
+ function Glyph(props) {
473
+ const size = props && props.size ? props.size : 16
474
+ const active = props && props.active
475
+ const c = active ? 'var(--dsw-alias-brand-primary)' : 'currentColor'
476
+ return React.createElement('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none' },
477
+ React.createElement('path', { d: 'M6 3.5h8.5L19 8v12.5H6z', stroke: c, strokeWidth: 1.6, strokeLinejoin: 'round' }),
478
+ React.createElement('path', { d: 'M14.2 3.6V8H19', stroke: c, strokeWidth: 1.4, strokeLinejoin: 'round' }),
479
+ React.createElement('path', { d: 'M9 12h6M9 15h6M9 18h3.5', stroke: c, strokeWidth: 1.4, strokeLinecap: 'round' }),
480
+ React.createElement('circle', { cx: 5.2, cy: 12, r: 1.5, fill: c }),
481
+ React.createElement('circle', { cx: 5.2, cy: 16.6, r: 1.5, fill: c })
482
+ )
483
+ }
484
+
485
+ // ── 样式:全部走主题 token ──────────────────────────────────────────────────
486
+ ctx.effect(function () {
487
+ return styles.insert([
488
+ '.rtr-root{padding:14px 16px;display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary)}',
489
+ '.rtr-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
490
+ '.rtr-brand{font-size:14px;font-weight:600;letter-spacing:.2px}',
491
+ '.rtr-brand-c{color:var(--dsw-alias-brand-primary)}',
492
+ '.rtr-stat{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
493
+ '.rtr-sp{flex:1 1 auto}',
494
+ '.rtr-dim{color:var(--dsw-alias-label-secondary)}',
495
+ '.rtr-hint{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
496
+ '.rtr-err{color:var(--dsw-alias-state-error-primary);font-size:12px}',
497
+ '.rtr-ok{color:var(--dsw-alias-state-success-primary);font-size:12px;word-break:break-all}',
498
+ '.rtr-warn{color:var(--dsw-alias-state-warn-primary);font-size:12px}',
499
+ '.rtr-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}',
500
+ '.rtr-x{background:transparent;border:none;color:inherit;font-size:16px;line-height:1;cursor:pointer;padding:0 4px}',
501
+
502
+ '.rtr-tabs{display:flex;gap:2px;border-bottom:1px solid var(--dsw-alias-border-l1)}',
503
+ '.rtr-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}',
504
+ '.rtr-tab:hover{color:var(--dsw-alias-label-primary)}',
505
+ '.rtr-tab-on{color:var(--dsw-alias-brand-primary);border-bottom-color:var(--dsw-alias-brand-primary);font-weight:600}',
506
+ '.rtr-body{display:flex;flex-direction:column;gap:12px}',
507
+
508
+ '.rtr-tools{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
509
+ '.rtr-sep{width:1px;height:18px;background:var(--dsw-alias-border-l1)}',
510
+ '.rtr-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
511
+ '.rtr-group{display:flex;align-items:center;gap:6px}',
512
+ '.rtr-group-l{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
513
+ '.rtr-seg{display:inline-flex}',
514
+
515
+ '.rtr-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}',
516
+ '.rtr-card-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
517
+ '.rtr-card-t{font-size:12.5px;font-weight:600}',
518
+ '.rtr-card-s{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
519
+ '.rtr-card-b{display:flex;flex-direction:column;gap:8px}',
520
+
521
+ '.rtr-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)}',
522
+ '.rtr-btn:hover:not(:disabled){border-color:var(--dsw-alias-border-l2)}',
523
+ '.rtr-btn:disabled{opacity:.45;cursor:not-allowed}',
524
+ '.rtr-btn-primary{background:var(--dsw-alias-brand-primary);border-color:transparent;color:#fff}',
525
+ '.rtr-btn-primary:hover:not(:disabled){filter:brightness(1.06)}',
526
+ '.rtr-btn-danger{color:var(--dsw-alias-state-error-primary)}',
527
+ '.rtr-btn-mini{height:22px;padding:0 8px;font-size:11.5px}',
528
+ '.rtr-seg .rtr-btn{border-radius:0;margin-left:-1px}',
529
+ '.rtr-seg .rtr-btn:first-child{border-radius:7px 0 0 7px;margin-left:0}',
530
+ '.rtr-seg .rtr-btn:last-child{border-radius:0 7px 7px 0}',
531
+
532
+ '.rtr-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)}',
533
+ '.rtr-in:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}',
534
+ '.rtr-in-xs{width:64px}',
535
+ '.rtr-title{height:32px;font-size:14px;font-weight:600}',
536
+ '.rtr-ta{min-height:120px;box-sizing:border-box;width:100%;padding:8px;border-radius:8px;font-size:12px;font-family:ui-monospace,monospace;line-height:1.6;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);resize:vertical;flex:1 1 auto}',
537
+ '.rtr-ta:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}',
538
+ '.rtr-ta-sm{min-height:74px}',
539
+
540
+ '.rtr-badge{display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:5px;font-size:11.5px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);white-space:nowrap}',
541
+ '.rtr-badge-ok{color:var(--dsw-alias-state-success-primary)}',
542
+ '.rtr-badge-warn{color:var(--dsw-alias-state-warn-primary)}',
543
+ '.rtr-badge-brand{color:var(--dsw-alias-brand-primary)}',
544
+
545
+ '.rtr-chips{display:flex;gap:6px;flex-wrap:wrap}',
546
+ '.rtr-chip{display:flex;flex-direction:column;align-items:flex-start;gap:1px;text-align:left;max-width:280px;padding:5px 10px;border-radius:8px;cursor:pointer;font-family:inherit;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}',
547
+ '.rtr-chip:hover:not(:disabled){border-color:var(--dsw-alias-border-l2)}',
548
+ '.rtr-chip-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}',
549
+ '.rtr-chip-t{font-size:12px;font-weight:600;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
550
+ '.rtr-chip-m{font-size:11px;color:var(--dsw-alias-label-secondary)}',
551
+
552
+ '.rtr-edit{display:flex;flex-direction:column;gap:8px}',
553
+ '.rtr-panes{display:grid;grid-template-columns:1fr 1fr;gap:10px;min-height:52vh}',
554
+ '.rtr-pane{display:flex;flex-direction:column;gap:5px;min-width:0}',
555
+ '.rtr-pane-h{display:flex;align-items:baseline;gap:8px;font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
556
+ '.rtr-frame{flex:1 1 auto;min-height:46vh;width:100%;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:#fff}',
557
+ '.rtr-frame-empty{padding:10px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
558
+
559
+ '.rtr-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;align-items:start}',
560
+ '.rtr-grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px}',
561
+ '.rtr-f{display:flex;flex-direction:column;gap:4px}',
562
+ '.rtr-f-l{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
563
+
564
+ '.rtr-tbl{width:100%;border-collapse:collapse;font-size:12px}',
565
+ '.rtr-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)}',
566
+ '.rtr-tbl td{padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);vertical-align:top}',
567
+ '.rtr-tbl tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
568
+ '.rtr-tbl tbody tr:last-child td{border-bottom:none}',
569
+
570
+ '.rtr-pre{white-space:pre-wrap;word-break:break-word;font-size:11.5px;line-height:1.6;max-height:52vh;overflow:auto;margin:0;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);font-family:ui-monospace,monospace}',
571
+
572
+ '.rtr-logs{display:flex;flex-direction:column;gap:2px;font-size:11.5px;max-height:60vh;overflow:auto}',
573
+ '.rtr-ln{display:flex;gap:8px;align-items:baseline}',
574
+ '.rtr-lt{flex:0 0 auto;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}',
575
+ '.rtr-ll{flex:0 0 auto;min-width:34px;color:var(--dsw-alias-label-secondary)}',
576
+ '.rtr-lx{white-space:pre-wrap;word-break:break-word}',
577
+ '.rtr-ln-err .rtr-lx{color:var(--dsw-alias-state-error-primary)}',
578
+ '.rtr-ln-warn .rtr-lx{color:var(--dsw-alias-state-warn-primary)}',
579
+ '.rtr-ln-ok .rtr-lx{color:var(--dsw-alias-state-success-primary)}',
580
+ ].join('\n'))
581
+ }, 'redteam-report: styles')
582
+
583
+ ctx.effect(function () {
584
+ return slots.inject('sidebar.panellist', function () {
585
+ return slots.register({ name: 'sidebar.panellist', id: PANEL_KEY, order: 60, label: '红队报告' }, Glyph)
586
+ })
587
+ }, 'redteam-report: panel button')
588
+
589
+ ctx.effect(function () {
590
+ return slots.inject('main', function () {
591
+ return slots.register({ name: 'main', key: PANEL_KEY }, Panel)
592
+ })
593
+ }, 'redteam-report: main panel')
594
+
595
+ console.log('[rtreport] redteam-report client half ready; panel =', PANEL_KEY)
596
+ }
597
+
598
+ return {
599
+ name: 'redteam-report',
600
+ inject: ['slots', 'timer'],
601
+ apply: applyClient
602
+ }