@kenhkl/agent-moss 0.10.1 → 0.10.2

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.
@@ -0,0 +1,1132 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AgentMoss 安全策略管控台</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
8
+ <style>
9
+ /* ===== OpenDesk --ag- design tokens(浅色玻璃态 + dark mode)===== */
10
+ :root {
11
+ --ag-bg: #f5f5f7;
12
+ --ag-card-bg: rgba(255,255,255,.72);
13
+ --ag-card-border: rgba(255,255,255,.5);
14
+ --ag-text: #1d1d1f;
15
+ --ag-text-secondary: #6e6e73;
16
+ --ag-text-tertiary: #aeaeb2;
17
+ --ag-text-heading: #1d1d1f;
18
+ --ag-accent: #007AFF;
19
+ --ag-accent-hover: #005EC4;
20
+ --ag-red: #FF3B30;
21
+ --ag-green: #34C759;
22
+ --ag-orange: #FF9500;
23
+ --ag-purple: #AF52DE;
24
+ --ag-teal: #5AC8FA;
25
+ --ag-yellow: #FFCC00;
26
+ --ag-input-bg: rgba(255,255,255,.6);
27
+ --ag-input-border: rgba(0,0,0,.08);
28
+ --ag-input-focus-ring: rgba(0,122,255,.3);
29
+ --ag-divider: rgba(0,0,0,.06);
30
+ --ag-code-bg: rgba(0,0,0,.04);
31
+ --ag-danger-bg: rgba(255,59,48,.06);
32
+ --ag-danger-border: rgba(255,59,48,.18);
33
+ --ag-tab-active-border: #007AFF;
34
+ --ag-tab-inactive: #aeaeb2;
35
+ --ag-badge-deny-bg: rgba(255,59,48,.12);
36
+ --ag-badge-allow-bg: rgba(52,199,89,.12);
37
+ }
38
+ [data-theme="dark"] {
39
+ --ag-bg: #1c1c1e;
40
+ --ag-card-bg: rgba(44,44,46,.75);
41
+ --ag-card-border: rgba(255,255,255,.08);
42
+ --ag-text: #e5e5e7;
43
+ --ag-text-secondary: #98989d;
44
+ --ag-text-tertiary: #636366;
45
+ --ag-text-heading: #e5e5e7;
46
+ --ag-accent: #0A84FF;
47
+ --ag-accent-hover: #409CFF;
48
+ --ag-input-bg: rgba(255,255,255,.08);
49
+ --ag-input-border: rgba(255,255,255,.1);
50
+ --ag-input-focus-ring: rgba(10,132,255,.35);
51
+ --ag-divider: rgba(255,255,255,.08);
52
+ --ag-code-bg: rgba(255,255,255,.06);
53
+ --ag-danger-bg: rgba(255,69,58,.12);
54
+ --ag-badge-deny-bg: rgba(255,69,58,.18);
55
+ --ag-badge-allow-bg: rgba(48,209,88,.18);
56
+ --ag-tab-active-border: #0A84FF;
57
+ --ag-tab-inactive: #636366;
58
+ }
59
+ *, *::before, *::after { box-sizing: border-box; }
60
+ body {
61
+ margin: 0; background: var(--ag-bg); color: var(--ag-text);
62
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', sans-serif;
63
+ -webkit-font-smoothing: antialiased; font-size: 14px;
64
+ }
65
+ .ag-wrap { display: flex; flex-direction: column; min-height: 100vh; }
66
+ .ag-tabs { display: flex; align-items: flex-end; border-bottom: 1px solid var(--ag-divider); padding: 20px 20px 0; gap: 4px; }
67
+ .ag-tab { padding: 8px 16px 7px; font-size: 13px; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; user-select: none; color: var(--ag-tab-inactive); transition: color .2s; }
68
+ .ag-tab:hover { color: var(--ag-text); }
69
+ .ag-tab.active { color: var(--ag-accent); border-bottom-color: var(--ag-tab-active-border); }
70
+ .ag-tab-spacer { flex: 1; }
71
+ .ag-theme-btn { padding: 6px 12px; font-size: 12px; cursor: pointer; background: var(--ag-input-bg); border: 1px solid var(--ag-input-border); border-radius: 8px; color: var(--ag-text-secondary); }
72
+ .ag-panel { flex: 1; overflow: auto; padding: 20px; max-width: 1100px; margin: 0 auto; width: 100%; }
73
+ .ag-panel.hidden { display: none; }
74
+ .ag-card { background: var(--ag-card-bg); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 14px; border: 1px solid var(--ag-card-border); box-shadow: 0 2px 8px rgba(0,0,0,.04); padding: 16px; margin-bottom: 16px; }
75
+ .ag-section-title { font-size: 13px; font-weight: 600; color: var(--ag-text-heading); margin: 0 0 14px; display: flex; align-items: center; gap: 8px; }
76
+ .ag-section-title::before { content: ''; display: block; width: 3px; height: 14px; background: var(--ag-accent); border-radius: 2px; }
77
+ .ag-section-desc { font-size: 12px; color: var(--ag-text-secondary); line-height: 1.5; margin: 0 0 14px; }
78
+ .ag-label { display: block; font-size: 12px; font-weight: 500; color: var(--ag-text-secondary); margin-bottom: 6px; }
79
+ .ag-input, .ag-select, textarea.ag-input { width: 100%; padding: 8px 10px; font-size: 13px; border: 1px solid var(--ag-input-border); border-radius: 8px; background: var(--ag-input-bg); color: var(--ag-text); outline: none; transition: border-color .2s, box-shadow .2s; -webkit-appearance: none; font-family: inherit; }
80
+ .ag-input:focus, .ag-select:focus { border-color: var(--ag-accent); box-shadow: 0 0 0 3px var(--ag-input-focus-ring); }
81
+
82
+ /* Toggle */
83
+ .ag-toggle { position: relative; width: 42px; height: 24px; border-radius: 12px; border: none; background: #d1d5db; cursor: pointer; transition: background .22s ease; padding: 0; flex-shrink: 0; }
84
+ [data-theme="dark"] .ag-toggle { background: #48484a; }
85
+ .ag-toggle.on { background: var(--ag-accent); }
86
+ .ag-toggle.disabled { opacity: .4; cursor: not-allowed; }
87
+ .ag-toggle-knob { position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); transition: transform .22s ease; }
88
+ .ag-toggle.on .ag-toggle-knob { transform: translateX(18px); }
89
+ .ag-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
90
+ .ag-toggle-row-label { font-size: 13px; font-weight: 500; color: var(--ag-text); }
91
+ .ag-toggle-row-desc { font-size: 11px; color: var(--ag-text-tertiary); margin-top: 2px; }
92
+ .ag-env-badge { font-size: 10px; color: var(--ag-orange); margin-left: 8px; }
93
+ .ag-env-override-badge { background: var(--ag-red); color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 4px; margin-top: 4px; display: inline-block; }
94
+
95
+ /* 按钮 */
96
+ .ag-btn { display: inline-flex; align-items: center; padding: 6px 14px; font-size: 12px; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all .2s; border: none; gap: 6px; }
97
+ .ag-btn-primary { background: var(--ag-accent); color: #fff; }
98
+ .ag-btn-primary:hover { background: var(--ag-accent-hover); }
99
+ .ag-btn-ghost { background: transparent; color: var(--ag-accent); border: 1px solid var(--ag-accent); }
100
+ .ag-btn-danger { background: transparent; color: var(--ag-red); border: 1px solid var(--ag-red); }
101
+ .ag-btn-danger:hover { background: var(--ag-danger-bg); }
102
+ .ag-btn-sm { padding: 4px 10px; font-size: 11px; border-radius: 6px; }
103
+ .ag-btn-sm.active { background: var(--ag-accent); color: #fff; border: 1px solid var(--ag-accent); }
104
+
105
+ /* 分类可折叠 + 大类总开关 */
106
+ .ag-cat-header { display: flex; align-items: center; justify-content: space-between; cursor: pointer; padding: 4px 0 8px; border-bottom: 1px solid var(--ag-divider); }
107
+ .ag-cat-name { font-size: 13px; font-weight: 600; color: var(--ag-text-heading); display: flex; align-items: center; gap: 8px; }
108
+ .ag-cat-count { font-size: 11px; color: var(--ag-text-secondary); background: var(--ag-code-bg); padding: 1px 8px; border-radius: 10px; }
109
+ .ag-cat-controls { display: flex; align-items: center; gap: 12px; }
110
+ .ag-expand-icon { font-size: 12px; color: var(--ag-text-tertiary); transition: transform .2s; display: inline-block; }
111
+ .ag-expand-icon.open { transform: rotate(180deg); }
112
+ .ag-rule-list { padding: 6px 0; }
113
+
114
+ /* 规则项卡片化 */
115
+ .ag-rule-item { display: flex; align-items: flex-start; gap: 12px; padding: 10px 12px; margin: 6px 0; font-size: 13px; border: 1px solid var(--ag-divider); border-radius: 8px; background: var(--ag-card-bg); transition: border-color .15s, box-shadow .15s; }
116
+ .ag-rule-item:hover { border-color: var(--ag-accent); box-shadow: 0 2px 8px rgba(0,122,255,.08); }
117
+ .ag-rule-body { flex: 1; min-width: 0; }
118
+ .ag-rule-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; }
119
+ .ag-rule-pattern { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; color: var(--ag-text); word-break: break-all; line-height: 1.5; }
120
+ .ag-rule-reason { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; color: var(--ag-text-secondary); word-break: break-all; line-height: 1.5; margin-top: 3px; }
121
+ .ag-rule-field-label { color: var(--ag-text-heading); font-family: inherit; margin-right: 2px; }
122
+ .ag-rule-id { color: var(--ag-text-tertiary); font-size: 11px; font-family: monospace; margin-top: 3px; }
123
+ .ag-rule-controls { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
124
+
125
+ /* Badge */
126
+ .ag-badge { display: inline-block; min-width: 48px; text-align: center; padding: 2px 6px; border-radius: 8px; font-size: 10px; font-weight: 600; line-height: 1.4; }
127
+ .ag-badge-critical { background: var(--ag-badge-deny-bg); color: var(--ag-red); }
128
+ .ag-badge-high { background: rgba(255,149,0,.12); color: var(--ag-orange); }
129
+ .ag-badge-medium, .ag-badge-low { background: var(--ag-code-bg); color: var(--ag-text-secondary); }
130
+ .ag-badge-allow { background: var(--ag-badge-allow-bg); color: var(--ag-green); }
131
+ .ag-badge-deny { background: var(--ag-badge-deny-bg); color: var(--ag-red); }
132
+ .ag-badge-builtin { background: var(--ag-code-bg); color: var(--ag-text-secondary); font-size: 10px; padding: 1px 6px; border-radius: 3px; }
133
+ .ag-badge-custom { background: rgba(0,122,255,.12); color: var(--ag-accent); font-size: 10px; padding: 1px 6px; border-radius: 3px; }
134
+ .ag-badge-removed { background: rgba(255,149,0,.12); color: var(--ag-orange); font-size: 10px; padding: 1px 6px; border-radius: 3px; border: 1px solid rgba(255,149,0,.3); }
135
+
136
+ /* 风险类型标签(左边框彩色) */
137
+ .ag-risk-type { font-size: 11px; padding: 2px 8px; border-radius: 4px; font-weight: 500; border-left: 3px solid; background: var(--ag-code-bg); color: var(--ag-text); }
138
+ .ag-risk-type::before { content: "🏷 "; opacity: 0.7; }
139
+ .ag-rt-script_execution { border-left-color: #6366f1; }
140
+ .ag-rt-file_access { border-left-color: #0ea5e9; }
141
+ .ag-rt-data_exfiltration { border-left-color: #f59e0b; }
142
+ .ag-rt-privilege_escalation { border-left-color: #dc2626; }
143
+ .ag-rt-persistence { border-left-color: #7c3aed; }
144
+ .ag-rt-lateral_movement { border-left-color: #db2777; }
145
+ .ag-rt-config_tampering { border-left-color: #ea580c; }
146
+ .ag-rt-supply_chain { border-left-color: #16a34a; }
147
+ .ag-rt-resource_exhaustion { border-left-color: #0891b2; }
148
+ .ag-rt-intent_deviation { border-left-color: #9333ea; }
149
+
150
+ /* deny_mode badge 三色 */
151
+ .ag-deny-mode { font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 600; }
152
+ .ag-deny-mode.deny-both { background: rgba(175,82,222,.12); color: var(--ag-purple); }
153
+ .ag-deny-mode.deny-write { background: rgba(52,199,89,.12); color: var(--ag-green); }
154
+ .ag-deny-mode.deny-read { background: rgba(255,149,0,.12); color: var(--ag-orange); }
155
+ .ag-source-badge { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: rgba(10,132,255,.08); color: var(--ag-accent); font-weight: 500; }
156
+ .ag-overridden-hint { font-size: 10px; color: var(--ag-red); font-weight: 500; }
157
+
158
+ /* deny_mode select */
159
+ .ag-deny-select { font-size: 11px; padding: 2px 4px; border: 1px solid var(--ag-input-border); border-radius: 4px; background: var(--ag-input-bg); color: var(--ag-text); cursor: pointer; max-width: 100px; }
160
+ .ag-deny-select:disabled { opacity: 0.5; cursor: not-allowed; }
161
+
162
+ /* skip_l3 checkbox */
163
+ .ag-skip-l3 { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; color: var(--ag-text-secondary); cursor: pointer; white-space: nowrap; }
164
+ .ag-skip-l3:hover { color: var(--ag-text); }
165
+ .ag-skip-l3 input { width: 14px; height: 14px; cursor: pointer; accent-color: var(--ag-accent); }
166
+
167
+ /* 搜索结果层标识 */
168
+ .ag-layer-badge { font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 600; color: #fff; }
169
+ .ag-layer-l1 { background: #6366f1; }
170
+ .ag-layer-l2 { background: #0ea5e9; }
171
+ .ag-layer-l3 { background: #14b8a6; }
172
+ .ag-status-text { font-size: 11px; font-weight: 500; }
173
+ .ag-status-on { color: var(--ag-green); }
174
+ .ag-status-off { color: var(--ag-text-tertiary); }
175
+ .ag-search-cat { font-size: 11px; color: var(--ag-text-tertiary); }
176
+
177
+ /* 统计卡片 */
178
+ .ag-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
179
+ .ag-stat-card { background: var(--ag-code-bg); border-radius: 10px; padding: 12px; }
180
+ .ag-stat-card .label { font-size: 11px; color: var(--ag-text-secondary); }
181
+ .ag-stat-card .value { font-size: 22px; font-weight: 600; color: var(--ag-text-heading); margin-top: 4px; }
182
+
183
+ /* 进度条行 */
184
+ .ag-progress-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; font-size: 13px; }
185
+ .ag-progress-label { color: var(--ag-text-secondary); min-width: 100px; }
186
+ .ag-progress-value { color: var(--ag-text-heading); font-weight: 600; }
187
+ .ag-progress-pct { font-size: 11px; color: var(--ag-text-tertiary); }
188
+ .ag-progress-bar { flex: 1; background: var(--ag-divider); height: 6px; border-radius: 3px; margin-left: 8px; overflow: hidden; }
189
+ .ag-progress-fill { background: var(--ag-accent); height: 6px; border-radius: 3px; }
190
+ .ag-progress-fill.green { background: var(--ag-green); }
191
+
192
+ /* 日期选择器 */
193
+ .ag-date-input { padding: 3px 8px; font-size: 12px; height: 24px; border: 1px solid var(--ag-input-border); border-radius: 4px; background: var(--ag-input-bg); color: var(--ag-text); font-family: inherit; box-sizing: border-box; }
194
+ .ag-date-input:focus { outline: none; border-color: var(--ag-accent); box-shadow: 0 0 0 2px var(--ag-input-focus-ring); }
195
+ .ag-date-sep { color: var(--ag-text-tertiary); font-size: 12px; padding: 0 4px; }
196
+
197
+ .ag-search { margin-bottom: 12px; }
198
+ .ag-empty { color: var(--ag-text-tertiary); font-size: 12px; padding: 12px 0; text-align: center; }
199
+ #token-trend-chart { width: 100%; height: 320px; }
200
+ .ag-link { color: var(--ag-accent); cursor: pointer; text-decoration: none; font-size: 12px; }
201
+ .ag-link:hover { text-decoration: underline; }
202
+
203
+ /* Toast */
204
+ .ag-toast { position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px; font-size: 13px; z-index: 200; box-shadow: 0 4px 12px rgba(0,0,0,.15); animation: ag-slide-in .3s ease; }
205
+ .ag-toast-success { background: var(--ag-green); color: #fff; }
206
+ .ag-toast-error { background: var(--ag-red); color: #fff; }
207
+ .ag-toast-warning { background: var(--ag-orange); color: #fff; }
208
+ @keyframes ag-slide-in { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
209
+
210
+ /* Modal */
211
+ .ag-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: none; align-items: center; justify-content: center; z-index: 100; }
212
+ .ag-modal-overlay.show { display: flex; }
213
+ .ag-modal { background: var(--ag-bg); border: 1px solid var(--ag-card-border); border-radius: 14px; padding: 24px; width: 540px; max-width: 92vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.2); }
214
+ .ag-modal.ag-modal-wide { width: 820px; }
215
+ .ag-modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
216
+ .ag-modal-title { font-size: 15px; font-weight: 600; color: var(--ag-text-heading); }
217
+ .ag-modal-close { width: 28px; height: 28px; border: none; background: transparent; border-radius: 6px; cursor: pointer; font-size: 18px; line-height: 1; color: var(--ag-text-tertiary); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
218
+ .ag-modal-close:hover { background: var(--ag-code-bg); color: var(--ag-text); }
219
+ .ag-form-group { margin-bottom: 12px; }
220
+ .ag-form-label { font-size: 13px; font-weight: 500; margin-bottom: 4px; color: var(--ag-text); }
221
+ .ag-form-label .ag-required { color: var(--ag-red); margin-left: 2px; }
222
+ .ag-form-hint { font-size: 11px; color: var(--ag-text-tertiary); margin-top: 3px; line-height: 1.5; }
223
+ .ag-form-hint code { background: var(--ag-code-bg); padding: 1px 4px; border-radius: 3px; font-family: 'SF Mono', monospace; }
224
+ .ag-form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
225
+
226
+ /* Skill 详情 */
227
+ .ag-skill-detail-header { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
228
+ .ag-skill-detail-id { font-size: 17px; font-weight: 700; color: var(--ag-text-heading); font-family: 'SF Mono', monospace; word-break: break-all; }
229
+ .ag-skill-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--ag-divider); }
230
+ .ag-skill-meta-item { font-size: 12px; color: var(--ag-text-secondary); background: var(--ag-code-bg); padding: 3px 10px; border-radius: 4px; border: 1px solid var(--ag-divider); }
231
+ .ag-skill-meta-item strong { color: var(--ag-text); font-weight: 600; }
232
+ .ag-skill-content { font-family: 'SF Mono', monospace; font-size: 12.5px; line-height: 1.7; color: var(--ag-text); white-space: pre-wrap; word-break: break-word; }
233
+ .ag-skill-content pre { background: #1e293b; color: #e2e8f0; padding: 14px 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; font-size: 12.5px; line-height: 1.65; }
234
+ .ag-skill-content pre code { background: transparent; color: inherit; padding: 0; }
235
+
236
+ /* 表格 */
237
+ .ag-table { width: 100%; font-size: 12px; border-collapse: collapse; }
238
+ .ag-table th { padding: 6px; text-align: left; border-bottom: 1px solid var(--ag-divider); color: var(--ag-text-secondary); font-weight: 500; }
239
+ .ag-table td { padding: 6px; border-bottom: 1px solid var(--ag-divider); }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <div class="ag-wrap">
244
+ <div class="ag-tabs" id="tabs">
245
+ <div class="ag-tab active" data-page="overview">概览</div>
246
+ <div class="ag-tab" data-page="search">搜索规则</div>
247
+ <div class="ag-tab" data-page="l1">L1 规则</div>
248
+ <div class="ag-tab" data-page="l2">L2 规则</div>
249
+ <div class="ag-tab" data-page="l3">L3 Skills</div>
250
+ <div class="ag-tab" data-page="token">Token</div>
251
+ <div class="ag-tab" data-page="settings">设置</div>
252
+ <div class="ag-tab-spacer"></div>
253
+ <button class="ag-theme-btn" id="theme-btn">🌙 暗色</button>
254
+ </div>
255
+ <div class="ag-panel" id="page-overview"></div>
256
+ <div class="ag-panel hidden" id="page-search"></div>
257
+ <div class="ag-panel hidden" id="page-l1"></div>
258
+ <div class="ag-panel hidden" id="page-l2"></div>
259
+ <div class="ag-panel hidden" id="page-l3"></div>
260
+ <div class="ag-panel hidden" id="page-token"></div>
261
+ <div class="ag-panel hidden" id="page-settings"></div>
262
+ </div>
263
+
264
+ <!-- Modal -->
265
+ <div class="ag-modal-overlay" id="modal-overlay">
266
+ <div class="ag-modal" id="modal">
267
+ <div class="ag-modal-header">
268
+ <div class="ag-modal-title" id="modal-title"></div>
269
+ <button class="ag-modal-close" onclick="closeModal()" title="关闭">✕</button>
270
+ </div>
271
+ <div id="modal-body"></div>
272
+ <div class="ag-form-actions" id="modal-actions">
273
+ <button class="ag-btn ag-btn-ghost" onclick="closeModal()">取消</button>
274
+ <button class="ag-btn ag-btn-primary" id="modal-submit" onclick="submitModal()">确认</button>
275
+ </div>
276
+ </div>
277
+ </div>
278
+
279
+ <script>
280
+ // ===== API client =====
281
+ const TOKEN_KEY = 'am_console_token';
282
+ function getToken() { return localStorage.getItem(TOKEN_KEY) || ''; }
283
+ function authHeaders() {
284
+ const t = getToken();
285
+ return t ? { 'Authorization': 'Bearer ' + t } : {};
286
+ }
287
+ async function api(path, opts = {}) {
288
+ const res = await fetch('/console/api/' + path, {
289
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
290
+ ...opts,
291
+ });
292
+ if (res.status === 401) {
293
+ const t = prompt('需要 Bearer token 访问安全策略管控台:');
294
+ if (t) { localStorage.setItem(TOKEN_KEY, t); return api(path, opts); }
295
+ throw new Error('未授权');
296
+ }
297
+ if (!res.ok) {
298
+ const d = await res.json().catch(() => ({}));
299
+ throw new Error(d.detail || res.statusText);
300
+ }
301
+ return res.json();
302
+ }
303
+ function apiPut(path, body) { return api(path, { method: 'PUT', body: JSON.stringify(body) }); }
304
+ function apiPost(path, body) { return api(path, { method: 'POST', body: JSON.stringify(body) }); }
305
+ function apiDelete(path, body) { return api(path, { method: 'DELETE', body: JSON.stringify(body) }); }
306
+
307
+ let state = { config: {}, envOverrides: {}, currentPage: 'overview' };
308
+ const expandedCats = new Set();
309
+ let currentModalAction = null;
310
+
311
+ // ===== 静态文案 =====
312
+ const LAYER_NAMES = {
313
+ L1_heuristic: { name: 'L1 静态检测', desc: '正则/关键词匹配,仅看下一步动作' },
314
+ L2_logic_rules: { name: 'L2 逻辑规则', desc: 'read_before_write + 授权检测使用历史动作序列,敏感路径/危险模式仅匹配下一步' },
315
+ L3_llm_analysis: { name: 'L3 LLM 分析', desc: 'Skill 匹配 + LLM 语义判断' },
316
+ };
317
+ const L1_CATEGORY_NAMES = {
318
+ dangerous_commands: '⚡ 危险命令检测',
319
+ injection_detection: '🎯 注入攻击检测',
320
+ sensitive_path_access: '🛡️ 敏感路径访问',
321
+ user_custom: '🔧 用户自定义',
322
+ };
323
+ const L2_CATEGORY_NAMES = {
324
+ intent_consistency: '🎯 意图一致性 📋对比原始意图',
325
+ password_modify_consent: '🔑 密码修改授权 📋涉及历史分析',
326
+ user_deletion_consent: '👤 用户删除授权 📋涉及历史分析',
327
+ read_before_write: '📄 读写一致性 📋涉及历史分析',
328
+ user_custom: '🔧 用户自定义',
329
+ };
330
+ const L3_CATEGORY_NAMES = {
331
+ file_operations: '📁 文件操作',
332
+ network_security: '🌐 网络安全',
333
+ persistence_supply_chain: '🔒 持久化/供应链',
334
+ intent_and_general: '🎯 意图/通用',
335
+ resource: '💻 资耗',
336
+ user_custom: '🔧 用户自定义',
337
+ };
338
+ const RISK_TYPE_NAMES = {
339
+ script_execution: '脚本执行', file_access: '文件访问', data_exfiltration: '数据外泄',
340
+ privilege_escalation: '提权', persistence: '持久化后门', lateral_movement: '横向移动',
341
+ config_tampering: '配置篡改', supply_chain: '供应链', resource_exhaustion: '资源耗尽',
342
+ intent_deviation: '意图偏离',
343
+ };
344
+ const RISK_TOOLTIPS = {
345
+ critical: '严重:必须拒绝,不可逆破坏或提权操作',
346
+ high: '高危:强烈建议拒绝,需人工审核',
347
+ medium: '中危:需谨慎评估,结合意图判断',
348
+ low: '低危:常规操作,可按需放行',
349
+ };
350
+ const DENY_MODE_LABELS = { deny_write: '仅拦截写入', deny_read: '仅拦截读取', deny_both: '读写均拦截' };
351
+
352
+ function riskTypeName(t) { if (!t) return ''; return RISK_TYPE_NAMES[t] || t; }
353
+ function denyModeLabel(m) { return DENY_MODE_LABELS[m] || m; }
354
+ function denyModeClass(m) { return ({ deny_write: 'deny-write', deny_read: 'deny-read', deny_both: 'deny-both' })[m] || ''; }
355
+
356
+ // ===== Helpers =====
357
+ function el(id) { return document.getElementById(id); }
358
+ function escapeHtml(s) { return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
359
+ function riskBadge(level) {
360
+ const cls = ['low','medium','high','critical'].includes(level) ? 'ag-badge-' + level : 'ag-badge-low';
361
+ const tip = RISK_TOOLTIPS[level] || RISK_TOOLTIPS.low;
362
+ return `<span class="ag-badge ${cls}" title="${tip}">${level || 'low'}</span>`;
363
+ }
364
+ function riskTypeBadge(t) {
365
+ if (!t) return '';
366
+ return `<span class="ag-risk-type ag-rt-${escapeHtml(t)}" title="风险类型: ${escapeHtml(t)}">${escapeHtml(riskTypeName(t))}</span>`;
367
+ }
368
+ function builtinBadge(builtin) {
369
+ return builtin !== false ? '<span class="ag-badge ag-badge-builtin">内置</span>' : '<span class="ag-badge ag-badge-custom">自定义</span>';
370
+ }
371
+ function removedBadge(r) { return r ? '<span class="ag-badge ag-badge-removed" title="此规则已从当前默认规则集移除,仅作历史保留;如需可手动启用">已移除</span>' : ''; }
372
+ function denyModeBadge(mode) {
373
+ const m = mode || 'deny_write';
374
+ return `<span class="ag-deny-mode ${denyModeClass(m)}" title="拦截模式: ${denyModeLabel(m)}">${denyModeLabel(m)}</span>`;
375
+ }
376
+ function sourceDenyModeBadges(rule) {
377
+ if (rule.builtin !== false && rule.source_deny_mode) {
378
+ let h = `<span class="ag-source-badge" title="源码出厂的原始拦截模式。当前模式与之不同时表示已被用户覆盖">原始: ${denyModeLabel(rule.source_deny_mode)}</span>`;
379
+ if (rule.deny_mode && rule.deny_mode !== rule.source_deny_mode) {
380
+ h += '<span class="ag-overridden-hint" title="当前拦截模式已偏离源码默认值">已覆盖</span>';
381
+ }
382
+ return h;
383
+ }
384
+ return '';
385
+ }
386
+ function toggle(on, disabled, onclick) {
387
+ return `<button class="ag-toggle ${on ? 'on' : ''} ${disabled ? 'disabled' : ''}" ${disabled ? 'disabled' : ''} onclick="${disabled ? '' : onclick}"><span class="ag-toggle-knob"></span></button>`;
388
+ }
389
+ function formatTokens(n) {
390
+ if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿';
391
+ if (n >= 1e4) return (n / 1e4).toFixed(1) + '万';
392
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
393
+ return String(n || 0);
394
+ }
395
+ function showToast(msg, type) {
396
+ if (!type) type = 'success';
397
+ const t = document.createElement('div');
398
+ t.className = 'ag-toast ag-toast-' + type;
399
+ t.textContent = msg;
400
+ document.body.appendChild(t);
401
+ setTimeout(() => t.remove(), 3000);
402
+ }
403
+ function openModal(title, bodyHtml, showActions) {
404
+ el('modal-title').textContent = title;
405
+ el('modal-body').innerHTML = bodyHtml;
406
+ el('modal-actions').style.display = showActions !== false ? '' : 'none';
407
+ el('modal-overlay').classList.add('show');
408
+ }
409
+ function closeModal() {
410
+ el('modal-overlay').classList.remove('show');
411
+ el('modal').classList.remove('ag-modal-wide');
412
+ currentModalAction = null;
413
+ }
414
+
415
+ // ===== Pages =====
416
+ async function refreshConfig() {
417
+ state.config = await api('config');
418
+ state.envOverrides = await api('env-overrides');
419
+ renderCurrent();
420
+ }
421
+ function renderCurrent() {
422
+ const pages = ['overview','search','l1','l2','l3','token','settings'];
423
+ pages.forEach(p => {
424
+ const panel = el('page-' + p);
425
+ if (panel) panel.classList.toggle('hidden', p !== state.currentPage);
426
+ });
427
+ const fn = window['render_' + state.currentPage];
428
+ if (fn) fn();
429
+ }
430
+
431
+ // ===== 概览页 =====
432
+ function render_overview() {
433
+ const layers = state.config.layers || {};
434
+ const eo = state.envOverrides || {};
435
+ const envBadge = k => eo[k] && eo[k].overridden ? `<span class="ag-env-badge">⚠ 已被 env 覆盖</span>` : '';
436
+ const envOverrideBox = k => {
437
+ if (!eo[k] || !eo[k].overridden) return '';
438
+ return `<div class="ag-env-override-badge">⚠️ 环境变量 ${eo[k].env_var || ''}=1 强制禁用</div>`;
439
+ };
440
+ el('page-overview').innerHTML = `
441
+ <div class="ag-card">
442
+ <div class="ag-section-title">层级全局开关</div>
443
+ ${Object.entries(LAYER_NAMES).map(([key, info]) => {
444
+ const enabled = layers[key] !== false;
445
+ const overridden = eo[key] && eo[key].overridden;
446
+ const click = overridden ? '' : `toggleLayer('${key}')`;
447
+ return `<div class="ag-toggle-row"><div><div class="ag-toggle-row-label">${info.name} ${envBadge(key)}</div><div class="ag-toggle-row-desc">${info.desc}</div>${envOverrideBox(key)}</div>${toggle(enabled, overridden, click)}</div>`;
448
+ }).join('')}
449
+ </div>
450
+ <div class="ag-card">
451
+ <div class="ag-section-title">规则统计</div>
452
+ <div class="ag-stat-grid">
453
+ <div class="ag-stat-card"><div class="label">L1 规则启用</div><div class="value">${countEnabled(state.config.L1_rules)}/${countRules(state.config.L1_rules)}</div></div>
454
+ <div class="ag-stat-card"><div class="label">L2 规则启用</div><div class="value">${countEnabled(state.config.L2_rules)}/${countRules(state.config.L2_rules)}</div></div>
455
+ <div class="ag-stat-card"><div class="label">L3 Skills 启用</div><div class="value">${countEnabledSkills(state.config.L3_skills)}/${countSkills(state.config.L3_skills)}</div></div>
456
+ </div>
457
+ </div>
458
+ <div class="ag-card">
459
+ <div class="ag-section-title">Token 用量统计
460
+ <div style="margin-left:auto;display:flex;gap:6px">
461
+ <button class="ag-btn ag-btn-sm" data-days="1" onclick="loadOverviewToken(1)">今日</button>
462
+ <button class="ag-btn ag-btn-sm active" data-days="7" onclick="loadOverviewToken(7)">近7天</button>
463
+ <button class="ag-btn ag-btn-sm" data-days="0" onclick="loadOverviewToken(0)">全部</button>
464
+ </div>
465
+ </div>
466
+ <div id="overview-token-summary" class="ag-empty">加载中...</div>
467
+ <div id="overview-token-breakdown" style="margin-top:16px"></div>
468
+ </div>`;
469
+ loadOverviewToken(7);
470
+ }
471
+ async function loadOverviewToken(days) {
472
+ document.querySelectorAll('#page-overview [data-days]').forEach(b => b.classList.toggle('active', b.dataset.days === String(days)));
473
+ try {
474
+ const s = await api('token-stats?days=' + days);
475
+ el('overview-token-summary').innerHTML = `
476
+ <div class="ag-stat-grid">
477
+ <div class="ag-stat-card"><div class="label">总 Token</div><div class="value">${formatTokens(s.total_tokens)}</div></div>
478
+ <div class="ag-stat-card"><div class="label">输入 Token</div><div class="value">${formatTokens(s.total_prompt_tokens)}</div></div>
479
+ <div class="ag-stat-card"><div class="label">输出 Token</div><div class="value">${formatTokens(s.total_completion_tokens)}</div></div>
480
+ <div class="ag-stat-card"><div class="label">LLM 调用次数</div><div class="value">${s.total_calls}</div></div>
481
+ </div>`;
482
+ const byStep = s.by_step || {};
483
+ const known = [{ key: 'L3_security_judge', name: 'L3 安全判断' }, { key: 'step2_policy_gen', name: '策略生成' }];
484
+ const rendered = new Set();
485
+ let html = '';
486
+ for (const { key, name } of known) {
487
+ const v = byStep[key] || { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, calls: 0 };
488
+ rendered.add(key);
489
+ html += `<div class="ag-progress-row"><span class="ag-progress-label">${name}</span><span class="ag-progress-value">${formatTokens(v.total_tokens)}</span><span class="ag-progress-pct">(${v.calls} 次, 输入 ${formatTokens(v.prompt_tokens)}, 输出 ${formatTokens(v.completion_tokens)})</span></div>`;
490
+ }
491
+ for (const [step, v] of Object.entries(byStep)) {
492
+ if (rendered.has(step)) continue;
493
+ html += `<div class="ag-progress-row"><span class="ag-progress-label">${step}</span><span class="ag-progress-value">${formatTokens(v.total_tokens)}</span><span class="ag-progress-pct">(${v.calls} 次)</span></div>`;
494
+ }
495
+ el('overview-token-breakdown').innerHTML = html || '<div class="ag-empty">无分项数据</div>';
496
+ } catch { el('overview-token-summary').innerHTML = '<div class="ag-empty">无数据</div>'; }
497
+ }
498
+ function countRules(lr) { if (!lr) return 0; return Object.values(lr).reduce((s, c) => s + (c.rules ? c.rules.length : 0), 0); }
499
+ function countEnabled(lr) {
500
+ if (!lr) return 0;
501
+ return Object.values(lr).reduce((s, c) => s + ((c.rules || []).filter(r => r.enabled && c.category_enabled !== false).length), 0);
502
+ }
503
+ function countSkills(ls) { if (!ls) return 0; return Object.values(ls).reduce((s, c) => s + (c.skills ? c.skills.length : 0), 0); }
504
+ function countEnabledSkills(ls) {
505
+ if (!ls) return 0;
506
+ return Object.values(ls).reduce((s, c) => s + ((c.skills || []).filter(s => s.enabled && c.category_enabled !== false).length), 0);
507
+ }
508
+
509
+ // ===== 层级开关 =====
510
+ async function toggleLayer(key) {
511
+ try {
512
+ const cur = state.config.layers[key] !== false;
513
+ state.config = await apiPut('layers', { layer_key: key, enabled: !cur });
514
+ state.envOverrides = await api('env-overrides');
515
+ renderCurrent();
516
+ showToast(`${LAYER_NAMES[key]?.name || key} 已${!cur ? '启用' : '禁用'}`);
517
+ } catch (e) { showToast('操作失败: ' + e.message, 'error'); }
518
+ }
519
+
520
+ // ===== 分类开关 =====
521
+ async function toggleCategory(layer, category) {
522
+ try {
523
+ const cur = state.config[layer]?.[category]?.category_enabled !== false;
524
+ state.config = await apiPut('categories/enabled', { layer, category, enabled: !cur });
525
+ renderCurrent();
526
+ showToast('分类已' + (!cur ? '启用' : '禁用'));
527
+ } catch (e) { showToast('操作失败: ' + e.message, 'error'); }
528
+ }
529
+ async function toggleSkillCategory(category) {
530
+ try {
531
+ const cur = state.config.L3_skills?.[category]?.category_enabled !== false;
532
+ state.config = await apiPut('skill-categories/enabled', { category, enabled: !cur });
533
+ renderCurrent();
534
+ showToast('Skill 分类已' + (!cur ? '启用' : '禁用'));
535
+ } catch (e) { showToast('操作失败: ' + e.message, 'error'); }
536
+ }
537
+
538
+ // ===== 折叠展开 =====
539
+ function toggleExpand(id) {
540
+ const list = el('list-' + id);
541
+ const icon = el('expand-' + id);
542
+ if (!list) return;
543
+ const willOpen = list.style.display === 'none';
544
+ list.style.display = willOpen ? '' : 'none';
545
+ if (icon) icon.classList.toggle('open', willOpen);
546
+ if (willOpen) expandedCats.add(id); else expandedCats.delete(id);
547
+ }
548
+
549
+ // ===== L1/L2 规则页 =====
550
+ function renderRuleLayer(layerKey, containerId, categoryNames) {
551
+ const layer = state.config[layerKey] || {};
552
+ // 按 categoryNames 定义的顺序渲染,保证 user_custom 等固定类目在最末。
553
+ // 落盘 JSON 的键顺序会被合并/迁移逻辑打乱,不能直接 Object.entries(layer)。
554
+ const orderedEntries = [
555
+ ...Object.keys(categoryNames).filter(k => k in layer).map(k => [k, layer[k]]),
556
+ ...Object.entries(layer).filter(([k]) => !(k in categoryNames)),
557
+ ];
558
+ let html = `<div class="ag-card" style="padding:8px 16px"><button class="ag-btn ag-btn-primary" onclick="showAddRuleModal('${layerKey}')">+ 新增规则</button></div>`;
559
+ for (const [catName, catData] of orderedEntries) {
560
+ const displayName = categoryNames[catName] || catName;
561
+ const rules = catData.rules || [];
562
+ const catEnabled = catData.category_enabled !== false;
563
+ const enabledCount = rules.filter(r => r.enabled).length;
564
+ const expanded = expandedCats.has(`${containerId}-${catName}`);
565
+ html += `<div class="ag-card">
566
+ <div class="ag-cat-header" onclick="toggleExpand('${containerId}-${catName}')">
567
+ <div class="ag-cat-name">${escapeHtml(displayName)} <span class="ag-cat-count">${enabledCount}/${rules.length} 启用</span></div>
568
+ <div class="ag-cat-controls">
569
+ <div onclick="event.stopPropagation();toggleCategory('${layerKey}','${catName}')">${toggle(catEnabled, false, '')}</div>
570
+ <span class="ag-expand-icon ${expanded ? 'open' : ''}" id="expand-${containerId}-${catName}">▼</span>
571
+ </div>
572
+ </div>
573
+ <div class="ag-rule-list" id="list-${containerId}-${catName}" style="display:${expanded ? '' : 'none'}">`;
574
+ if (!rules.length) {
575
+ html += '<div class="ag-empty">无规则</div>';
576
+ } else {
577
+ for (const r of rules) {
578
+ html += renderRuleItem(layerKey, catName, r);
579
+ }
580
+ }
581
+ html += '</div></div>';
582
+ }
583
+ el(containerId).innerHTML = html;
584
+ }
585
+ function renderRuleItem(layerKey, catName, r) {
586
+ const isL2 = layerKey === 'L2_rules';
587
+ const isL2Sensitive = isL2 && catName === 'sensitive_path_access';
588
+ let patternDisplay = r.pattern || r.keyword || r.path || (r.intent_keywords && r.intent_keywords.join(', ')) || r.reason || r.id || '';
589
+ let reasonDisplay = r.reason || r.desc || '';
590
+ let patternLabel = '规则';
591
+ if (isL2) {
592
+ if (r.intent_keywords) patternLabel = '触发关键词';
593
+ else if (r.path) patternLabel = '敏感路径';
594
+ else if (r.pattern) patternLabel = '检测模式';
595
+ }
596
+ const reasonLabel = isL2 ? '规则说明' : '原因';
597
+ const tooltip = `${patternLabel}: ${patternDisplay}\n${reasonLabel}: ${reasonDisplay || '(无)'}\nID: ${r.id}`;
598
+ const denyModeSelect = isL2Sensitive ? `<select class="ag-deny-select" onchange="updateDenyMode('${layerKey}','${catName}','${r.id}',this.value)" ${r.enabled ? '' : 'disabled'}>
599
+ <option value="deny_write" ${(r.deny_mode || 'deny_write') === 'deny_write' ? 'selected' : ''}>仅拦截写入</option>
600
+ <option value="deny_read" ${(r.deny_mode || 'deny_write') === 'deny_read' ? 'selected' : ''}>仅拦截读取</option>
601
+ <option value="deny_both" ${(r.deny_mode || 'deny_write') === 'deny_both' ? 'selected' : ''}>读写均拦截</option>
602
+ </select>` : '';
603
+ const skipL3 = isL2 ? `<label class="ag-skip-l3" title="禁用该规则时,L3 对相关路径/关键词做豁免放行(但仍分析命令其他部分的安全风险)。关闭则 L3 可能独立拦截"><input type="checkbox" ${r.skip_l3_on_disabled ? 'checked' : ''} onchange="updateSkipL3('${layerKey}','${catName}','${r.id}',this.checked)"> 禁用时豁免L3</label>` : '';
604
+ const deleteBtn = (r.builtin === false || r.source_removed) ? `<button class="ag-btn ag-btn-danger ag-btn-sm" onclick="deleteRule('${layerKey}','${catName}','${r.id}')" title="${r.source_removed ? '此规则已从默认规则集移除,可删除清理' : '删除自定义规则'}">删除</button>` : '';
605
+ return `<div class="ag-rule-item" title="${escapeHtml(tooltip)}">
606
+ <div class="ag-rule-body">
607
+ <div class="ag-rule-tags">${builtinBadge(r.builtin)} ${removedBadge(r.source_removed)} ${riskBadge(r.risk_level)} ${riskTypeBadge(r.risk_type)}${isL2Sensitive ? ' ' + denyModeBadge(r.deny_mode) + ' ' + sourceDenyModeBadges(r) : ''}</div>
608
+ <div class="ag-rule-pattern"><span class="ag-rule-field-label">${patternLabel}:</span> ${escapeHtml(patternDisplay)}</div>
609
+ ${reasonDisplay ? `<div class="ag-rule-reason"><span class="ag-rule-field-label">${reasonLabel}:</span> ${escapeHtml(reasonDisplay)}</div>` : ''}
610
+ <div class="ag-rule-id">ID: ${escapeHtml(r.id)}</div>
611
+ </div>
612
+ <div class="ag-rule-controls">${denyModeSelect}${toggle(r.enabled, false, `toggleRule('${layerKey}','${catName}','${r.id}',${!r.enabled})`)}${skipL3}${deleteBtn}</div>
613
+ </div>`;
614
+ }
615
+ function render_l1() { renderRuleLayer('L1_rules', 'page-l1', L1_CATEGORY_NAMES); }
616
+ function render_l2() { renderRuleLayer('L2_rules', 'page-l2', L2_CATEGORY_NAMES); }
617
+ async function toggleRule(layer, cat, id, en) {
618
+ try {
619
+ state.config = await apiPut('rules/enabled', { layer, category: cat, rule_id: id, enabled: en });
620
+ renderCurrent(); if (state.currentPage === 'search') searchRules();
621
+ showToast('规则 ' + id + ' 已' + (en ? '启用' : '禁用'));
622
+ } catch (e) { showToast('操作失败: ' + e.message, 'error'); }
623
+ }
624
+ async function updateDenyMode(layer, cat, id, mode) {
625
+ try {
626
+ state.config = await apiPut('rules/deny_mode', { layer, category: cat, rule_id: id, deny_mode: mode });
627
+ renderCurrent(); if (state.currentPage === 'search') searchRules();
628
+ showToast('拦截模式已更新为 ' + denyModeLabel(mode));
629
+ } catch (e) { showToast('操作失败: ' + e.message, 'error'); }
630
+ }
631
+ async function updateSkipL3(layer, cat, id, skip) {
632
+ try {
633
+ state.config = await apiPut('rules/skip_l3', { layer, category: cat, rule_id: id, skip_l3_on_disabled: skip });
634
+ renderCurrent(); if (state.currentPage === 'search') searchRules();
635
+ showToast('规则 ' + id + ' 禁用时豁免L3 已' + (skip ? '开启' : '关闭'));
636
+ } catch (e) { showToast('操作失败: ' + e.message, 'error'); }
637
+ }
638
+ async function deleteRule(layer, cat, id) {
639
+ // 找到该规则判断是自定义还是已移除,给不同确认文案
640
+ const layerData = (state.config[layer] || {})[cat] || {};
641
+ const rule = (layerData.rules || []).find(r => r.id === id) || {};
642
+ const msg = rule.source_removed
643
+ ? '确定删除已移除的规则 "' + id + '"?\n(此规则已从默认规则集移除,删除可清理历史保留。下次升级不会再恢复)'
644
+ : '确定删除自定义规则 "' + id + '"?';
645
+ if (!confirm(msg)) return;
646
+ try {
647
+ state.config = await apiDelete('rules', { layer, category: cat, rule_id: id });
648
+ renderCurrent(); showToast('规则 ' + id + ' 已删除');
649
+ } catch (e) { showToast('删除失败: ' + e.message, 'error'); }
650
+ }
651
+
652
+ // ===== L3 Skills 页 =====
653
+ function render_l3() {
654
+ const l3 = state.config.L3_skills || {};
655
+ let html = `<div class="ag-card" style="padding:8px 16px"><button class="ag-btn ag-btn-primary" onclick="showAddSkillModal()">+ 新增 Skill</button></div>`;
656
+ for (const [catName, catData] of Object.entries(l3)) {
657
+ const displayName = L3_CATEGORY_NAMES[catName] || catName;
658
+ const skills = catData.skills || [];
659
+ const catEnabled = catData.category_enabled !== false;
660
+ const enabledCount = skills.filter(s => s.enabled).length;
661
+ const expanded = expandedCats.has('page-l3-' + catName);
662
+ html += `<div class="ag-card">
663
+ <div class="ag-cat-header" onclick="toggleExpand('page-l3-${catName}')">
664
+ <div class="ag-cat-name">${escapeHtml(displayName)} <span class="ag-cat-count">${enabledCount}/${skills.length} 启用</span></div>
665
+ <div class="ag-cat-controls">
666
+ <div onclick="event.stopPropagation();toggleSkillCategory('${catName}')">${toggle(catEnabled, false, '')}</div>
667
+ <span class="ag-expand-icon ${expanded ? 'open' : ''}" id="expand-page-l3-${catName}">▼</span>
668
+ </div>
669
+ </div>
670
+ <div class="ag-rule-list" id="list-page-l3-${catName}" style="display:${expanded ? '' : 'none'}">`;
671
+ if (!skills.length) {
672
+ html += '<div class="ag-empty">无 skill</div>';
673
+ } else {
674
+ for (const s of skills) {
675
+ const kw = (s.keywords || []).join(', ');
676
+ const deleteBtn = s.builtin === false ? `<button class="ag-btn ag-btn-danger ag-btn-sm" onclick="deleteSkill('${s.id}','${catName}')">删除</button>` : '';
677
+ html += `<div class="ag-rule-item">
678
+ <div class="ag-rule-body">
679
+ <div class="ag-rule-tags">${builtinBadge(s.builtin)} <strong>${escapeHtml(s.id)}</strong></div>
680
+ <div class="ag-rule-reason"><span class="ag-rule-field-label">触发关键词:</span> ${escapeHtml(kw)}</div>
681
+ </div>
682
+ <div class="ag-rule-controls">${toggle(s.enabled, false, `toggleSkill('${s.id}',${!s.enabled})`)}<button class="ag-btn ag-btn-ghost ag-btn-sm" onclick="showSkillDetail('${s.id}')">详情</button>${deleteBtn}</div>
683
+ </div>`;
684
+ }
685
+ }
686
+ html += '</div></div>';
687
+ }
688
+ el('page-l3').innerHTML = html;
689
+ }
690
+ async function toggleSkill(skillId, en) {
691
+ try {
692
+ state.config = await apiPut('skills/enabled', { skill_id: skillId, enabled: en });
693
+ renderCurrent(); if (state.currentPage === 'search') searchRules();
694
+ showToast('Skill ' + skillId + ' 已' + (en ? '启用' : '禁用'));
695
+ } catch (e) { showToast('操作失败: ' + e.message, 'error'); }
696
+ }
697
+ async function deleteSkill(skillId, cat) {
698
+ if (!confirm('确定删除自定义 Skill "' + skillId + '"?')) return;
699
+ try {
700
+ state.config = await apiDelete('skills', { skill_id: skillId, category: cat });
701
+ renderCurrent(); showToast('Skill ' + skillId + ' 已删除');
702
+ } catch (e) { showToast('删除失败: ' + e.message, 'error'); }
703
+ }
704
+ async function showSkillDetail(skillId) {
705
+ openModal('🤖 Skill 详情', '<p style="color:var(--ag-text-tertiary);padding:8px 0">加载中...</p>', false);
706
+ el('modal').classList.add('ag-modal-wide');
707
+ try {
708
+ const data = await api('skills/' + encodeURIComponent(skillId) + '/content');
709
+ el('modal-body').innerHTML = renderSkillContent(data.content || '', skillId, data.path || '');
710
+ } catch (e) {
711
+ el('modal-body').innerHTML = '<p style="color:var(--ag-red);padding:8px 0">加载失败: ' + escapeHtml(e.message) + '</p>';
712
+ }
713
+ }
714
+ function renderSkillContent(md, skillId, filePath) {
715
+ const lines = (md || '').split('\n');
716
+ let html = '<div class="ag-skill-detail-header"><span class="ag-skill-detail-id">' + escapeHtml(skillId) + '</span></div><div class="ag-skill-meta"><span class="ag-skill-meta-item">📄 <strong>' + escapeHtml(filePath || '(未知)') + '</strong></span><span class="ag-skill-meta-item">📏 <strong>' + lines.length + '</strong> 行</span></div><div class="ag-skill-content">';
717
+ let inCode = false, codeBuf = [], textBuf = [];
718
+ const flushText = () => { if (textBuf.length) { html += escapeHtml(textBuf.join('\n')); textBuf = []; } };
719
+ const flushCode = () => { if (codeBuf.length) { html += '<pre><code>' + escapeHtml(codeBuf.join('\n')) + '</code></pre>'; codeBuf = []; } };
720
+ for (const line of lines) {
721
+ if (/^```/.test(line.trim())) {
722
+ if (inCode) { flushText(); flushCode(); inCode = false; }
723
+ else { flushText(); inCode = true; }
724
+ continue;
725
+ }
726
+ if (inCode) codeBuf.push(line); else textBuf.push(line);
727
+ }
728
+ flushText(); if (inCode) flushCode();
729
+ html += '</div>';
730
+ return html;
731
+ }
732
+
733
+ // ===== 搜索页 =====
734
+ function render_search() {
735
+ el('page-search').innerHTML = `
736
+ <div class="ag-card">
737
+ <div class="ag-section-title">全局规则搜索</div>
738
+ <div style="display:flex;gap:8px">
739
+ <input class="ag-input" id="search-input" placeholder="输入关键词搜索规则,如: sysfs, rm -rf, /etc/shadow, 数据外泄..." oninput="searchRules()" style="flex:1">
740
+ <button class="ag-btn ag-btn-primary" onclick="searchRules()">搜索</button>
741
+ <button class="ag-btn ag-btn-ghost" onclick="clearSearch()">清空</button>
742
+ </div>
743
+ <div class="ag-form-hint" style="margin-top:8px">搜索范围:L1 规则的正则/原因/风险类型、L2 规则的路径/描述、L3 Skill 的关键词/ID。命中后可直接启用或禁用对应规则。</div>
744
+ </div>
745
+ <div class="ag-card" id="search-results-card" style="display:none">
746
+ <div class="ag-section-title">搜索结果 <span class="ag-cat-count" id="search-count">0 条</span></div>
747
+ <div id="search-results"></div>
748
+ </div>`;
749
+ setTimeout(() => { const inp = el('search-input'); if (inp) inp.focus(); }, 0);
750
+ }
751
+ function searchRules() {
752
+ const q = (el('search-input') ? el('search-input').value : '').trim().toLowerCase();
753
+ const card = el('search-results-card'), list = el('search-results'), cnt = el('search-count');
754
+ if (!q) { card.style.display = 'none'; list.innerHTML = ''; return; }
755
+ const results = [];
756
+ for (const [cat, cd] of Object.entries(state.config.L1_rules || {})) {
757
+ for (const r of (cd.rules || [])) {
758
+ const hay = [r.id, r.pattern, r.keyword, r.reason, r.risk_type, r.risk_level].filter(Boolean).join(' ').toLowerCase();
759
+ if (hay.includes(q)) results.push({ layer: 'L1_rules', layerName: 'L1', category: cat, ...r });
760
+ }
761
+ }
762
+ for (const [cat, cd] of Object.entries(state.config.L2_rules || {})) {
763
+ for (const r of (cd.rules || [])) {
764
+ const hay = [r.id, r.path, r.pattern, r.desc, r.reason, r.risk_type, r.risk_level].filter(Boolean).join(' ').toLowerCase();
765
+ if (hay.includes(q)) results.push({ layer: 'L2_rules', layerName: 'L2', category: cat, ...r });
766
+ }
767
+ }
768
+ for (const [cat, cd] of Object.entries(state.config.L3_skills || {})) {
769
+ for (const s of (cd.skills || [])) {
770
+ const hay = [s.id, (s.keywords || []).join(' ')].filter(Boolean).join(' ').toLowerCase();
771
+ if (hay.includes(q)) results.push({ layer: 'L3_skills', layerName: 'L3', category: cat, ...s, isSkill: true });
772
+ }
773
+ }
774
+ cnt.textContent = results.length + ' 条';
775
+ card.style.display = '';
776
+ if (!results.length) { list.innerHTML = '<div class="ag-empty">未找到匹配的规则</div>'; return; }
777
+ list.innerHTML = results.map(renderSearchItem).join('');
778
+ }
779
+ function renderSearchItem(r) {
780
+ const isL2Sensitive = r.layer === 'L2_rules' && r.category === 'sensitive_path_access';
781
+ let ruleText = r.pattern || r.path || r.keyword || (r.keywords && r.keywords.join(', ')) || (r.intent_keywords && r.intent_keywords.join(', ')) || r.reason || r.desc || '';
782
+ const reason = r.reason || r.desc || '';
783
+ const tooltip = '规则: ' + ruleText + '\n原因: ' + (reason || '(无)') + '\nID: ' + r.id;
784
+ const toggleFn = r.isSkill ? "toggleSkill('" + r.id + "'," + (!r.enabled) + ")" : "toggleRule('" + r.layer + "','" + r.category + "','" + r.id + "'," + (!r.enabled) + ")";
785
+ const denyModeSelect = isL2Sensitive ? `<select class="ag-deny-select" onchange="updateDenyMode('${r.layer}','${r.category}','${r.id}',this.value)" ${r.enabled ? '' : 'disabled'}>
786
+ <option value="deny_write" ${(r.deny_mode || 'deny_write') === 'deny_write' ? 'selected' : ''}>仅拦截写入</option>
787
+ <option value="deny_read" ${(r.deny_mode || 'deny_write') === 'deny_read' ? 'selected' : ''}>仅拦截读取</option>
788
+ <option value="deny_both" ${(r.deny_mode || 'deny_write') === 'deny_both' ? 'selected' : ''}>读写均拦截</option>
789
+ </select>` : '';
790
+ const skipL3 = r.layer === 'L2_rules' ? `<label class="ag-skip-l3"><input type="checkbox" ${r.skip_l3_on_disabled ? 'checked' : ''} onchange="updateSkipL3('${r.layer}','${r.category}','${r.id}',this.checked)"> 禁用时豁免L3</label>` : '';
791
+ return `<div class="ag-rule-item" title="${escapeHtml(tooltip)}">
792
+ <div class="ag-rule-body">
793
+ <div class="ag-rule-tags">
794
+ <span class="ag-layer-badge ag-layer-${r.layerName.toLowerCase()}">${r.layerName}</span>
795
+ ${builtinBadge(r.builtin)} ${removedBadge(r.source_removed)} ${riskBadge(r.risk_level)} ${riskTypeBadge(r.risk_type)}
796
+ ${isL2Sensitive ? denyModeBadge(r.deny_mode) + ' ' + sourceDenyModeBadges(r) : ''}
797
+ <span class="ag-search-cat">分类: ${escapeHtml(r.category)}</span>
798
+ </div>
799
+ <div class="ag-rule-pattern"><span class="ag-rule-field-label">规则:</span> ${escapeHtml(ruleText)}</div>
800
+ ${reason ? '<div class="ag-rule-reason"><span class="ag-rule-field-label">原因:</span> ' + escapeHtml(reason) + '</div>' : ''}
801
+ <div class="ag-rule-id">ID: ${escapeHtml(r.id)}</div>
802
+ </div>
803
+ <div class="ag-rule-controls">
804
+ ${denyModeSelect}
805
+ <span class="ag-status-text ${r.enabled ? 'ag-status-on' : 'ag-status-off'}">${r.enabled ? '已启用' : '已禁用'}</span>
806
+ ${toggle(r.enabled, false, toggleFn)}
807
+ ${skipL3}
808
+ </div>
809
+ </div>`;
810
+ }
811
+ function clearSearch() {
812
+ const inp = el('search-input');
813
+ if (inp) inp.value = '';
814
+ searchRules();
815
+ }
816
+
817
+ // ===== Token 页 =====
818
+ let tokenStatsDays = 7;
819
+ let tokenRefreshTimer = null;
820
+ let trendChartInstance = null;
821
+ function render_token() {
822
+ el('page-token').innerHTML = `
823
+ <div class="ag-card">
824
+ <div class="ag-section-title">Token 用量详细统计
825
+ <div style="margin-left:auto;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
826
+ <button class="ag-btn ag-btn-sm" data-days="1" onclick="loadTokenStats(1)">今日</button>
827
+ <button class="ag-btn ag-btn-sm" data-days="7" onclick="loadTokenStats(7)">近7天</button>
828
+ <button class="ag-btn ag-btn-sm" data-days="30" onclick="loadTokenStats(30)">近30天</button>
829
+ <button class="ag-btn ag-btn-sm" data-days="0" onclick="loadTokenStats(0)">全部</button>
830
+ <span style="width:1px;height:20px;background:var(--ag-divider);margin:0 4px"></span>
831
+ <input type="date" id="token-date-from" class="ag-date-input" title="开始日期">
832
+ <span class="ag-date-sep">至</span>
833
+ <input type="date" id="token-date-to" class="ag-date-input" title="结束日期">
834
+ <button class="ag-btn ag-btn-sm" onclick="loadTokenStatsByRange()">查询</button>
835
+ </div>
836
+ </div>
837
+ <div id="token-summary" class="ag-empty">加载中...</div>
838
+ </div>
839
+ <div class="ag-card"><div class="ag-section-title">按步骤分组</div><div id="token-by-step"></div></div>
840
+ <div class="ag-card"><div class="ag-section-title">按模型分组</div><div id="token-by-model"></div></div>
841
+ <div class="ag-card"><div class="ag-section-title">Token 消耗趋势(按模型)</div><div id="token-trend-chart"></div></div>
842
+ <div class="ag-card"><div class="ag-section-title">最近调用记录</div><div id="token-recent" class="ag-empty">加载中...</div></div>`;
843
+ startTokenAutoRefresh();
844
+ }
845
+ function startTokenAutoRefresh() {
846
+ stopTokenAutoRefresh();
847
+ if (tokenStatsDays == null) tokenStatsDays = 7;
848
+ loadTokenStats(tokenStatsDays);
849
+ tokenRefreshTimer = setInterval(() => loadTokenStats(tokenStatsDays), 10000);
850
+ }
851
+ function stopTokenAutoRefresh() {
852
+ if (tokenRefreshTimer) { clearInterval(tokenRefreshTimer); tokenRefreshTimer = null; }
853
+ }
854
+ async function loadTokenStats(days) {
855
+ tokenStatsDays = days;
856
+ document.querySelectorAll('#page-token [data-days]').forEach(b => b.classList.toggle('active', b.dataset.days === String(days)));
857
+ const fromEl = el('token-date-from'), toEl = el('token-date-to');
858
+ if (fromEl) fromEl.value = ''; if (toEl) toEl.value = '';
859
+ try {
860
+ const s = await api('token-stats?days=' + days);
861
+ renderTokenSummary(s);
862
+ renderTokenByStep(s);
863
+ renderTokenByModel(s);
864
+ loadTokenTrend(days);
865
+ loadTokenRecent();
866
+ } catch (e) { console.warn('Token stats load failed:', e); }
867
+ }
868
+ async function loadTokenStatsByRange() {
869
+ const from = el('token-date-from') ? el('token-date-from').value : '';
870
+ const to = el('token-date-to') ? el('token-date-to').value : '';
871
+ if (!from || !to) { showToast('请选择开始和结束日期', 'error'); return; }
872
+ if (from > to) { showToast('开始日期不能晚于结束日期', 'error'); return; }
873
+ document.querySelectorAll('#page-token [data-days]').forEach(b => b.classList.remove('active'));
874
+ stopTokenAutoRefresh();
875
+ try {
876
+ const params = new URLSearchParams({ start_date: from, end_date: to });
877
+ const s = await api('token-stats?' + params);
878
+ tokenStatsDays = null;
879
+ renderTokenSummary(s);
880
+ renderTokenByStep(s);
881
+ renderTokenByModel(s);
882
+ loadTokenTrend(null, from, to);
883
+ loadTokenRecent();
884
+ } catch (e) { showToast('查询失败: ' + e.message, 'error'); }
885
+ }
886
+ function renderTokenSummary(s) {
887
+ el('token-summary').innerHTML = '<div class="ag-stat-grid">' +
888
+ '<div class="ag-stat-card"><div class="label">总 Token</div><div class="value">' + formatTokens(s.total_tokens) + '</div></div>' +
889
+ '<div class="ag-stat-card"><div class="label">输入 Token</div><div class="value">' + formatTokens(s.total_prompt_tokens) + '</div></div>' +
890
+ '<div class="ag-stat-card"><div class="label">输出 Token</div><div class="value">' + formatTokens(s.total_completion_tokens) + '</div></div>' +
891
+ '<div class="ag-stat-card"><div class="label">调用次数</div><div class="value">' + s.total_calls + '</div></div></div>';
892
+ }
893
+ function renderTokenByStep(s) {
894
+ const byStep = s.by_step || {};
895
+ const known = [{ key: 'L3_security_judge', name: 'L3 安全判断' }, { key: 'step2_policy_gen', name: '策略生成' }];
896
+ const rendered = new Set();
897
+ let html = '';
898
+ const total = s.total_tokens || 0;
899
+ for (const { key, name } of known) {
900
+ const v = byStep[key] || { total_tokens: 0, calls: 0 };
901
+ rendered.add(key);
902
+ const pct = total > 0 ? (v.total_tokens / total * 100).toFixed(1) : 0;
903
+ html += '<div class="ag-progress-row"><span class="ag-progress-label">' + name + '</span><span class="ag-progress-value">' + formatTokens(v.total_tokens) + '</span><span class="ag-progress-pct">' + pct + '% (' + v.calls + ' 次)</span><div class="ag-progress-bar"><div class="ag-progress-fill" style="width:' + pct + '%"></div></div></div>';
904
+ }
905
+ for (const [step, v] of Object.entries(byStep)) {
906
+ if (rendered.has(step)) continue;
907
+ const pct = total > 0 ? (v.total_tokens / total * 100).toFixed(1) : 0;
908
+ html += '<div class="ag-progress-row"><span class="ag-progress-label">' + step + '</span><span class="ag-progress-value">' + formatTokens(v.total_tokens) + '</span><span class="ag-progress-pct">' + pct + '% (' + v.calls + ' 次)</span><div class="ag-progress-bar"><div class="ag-progress-fill" style="width:' + pct + '%"></div></div></div>';
909
+ }
910
+ el('token-by-step').innerHTML = html || '<div class="ag-empty">无分项数据</div>';
911
+ }
912
+ function renderTokenByModel(s) {
913
+ const byModel = s.by_model || {};
914
+ const total = s.total_tokens || 0;
915
+ let html = '';
916
+ for (const [model, v] of Object.entries(byModel)) {
917
+ const pct = total > 0 ? (v.total_tokens / total * 100).toFixed(1) : 0;
918
+ html += '<div class="ag-progress-row"><span class="ag-progress-label">' + escapeHtml(model) + '</span><span class="ag-progress-value">' + formatTokens(v.total_tokens) + '</span><span class="ag-progress-pct">' + pct + '% (' + v.calls + ' 次)</span><div class="ag-progress-bar"><div class="ag-progress-fill green" style="width:' + pct + '%"></div></div></div>';
919
+ }
920
+ el('token-by-model').innerHTML = html || '<div class="ag-empty">无分模型数据</div>';
921
+ }
922
+ async function loadTokenTrend(days, fromOverride, toOverride) {
923
+ const trendEl = el('token-trend-chart');
924
+ if (!trendEl) return;
925
+ let params;
926
+ if (fromOverride && toOverride) params = new URLSearchParams({ start_date: fromOverride, end_date: toOverride });
927
+ else if (days != null) params = new URLSearchParams({ days: days });
928
+ else { const end = new Date(), start = new Date(end.getTime() - 6 * 86400000); const fmt = d => d.toISOString().slice(0, 10); params = new URLSearchParams({ start_date: fmt(start), end_date: fmt(end) }); }
929
+ try {
930
+ const data = await api('token-stats/trend?' + params);
931
+ renderTokenTrend(data);
932
+ } catch (e) { console.warn('Token trend load failed:', e); }
933
+ }
934
+ const TREND_COLORS = ['#007AFF','#FF3B30','#34C759','#FF9500','#AF52DE','#5AC8FA','#FFCC00','#64D2FF'];
935
+ function renderTokenTrend(data) {
936
+ const dom = el('token-trend-chart');
937
+ if (!dom || !window.echarts) return;
938
+ if (dom.offsetWidth === 0) dom.style.width = '100%';
939
+ if (!trendChartInstance) trendChartInstance = echarts.init(dom);
940
+ trendChartInstance.resize();
941
+ const dates = data.dates || [], models = data.models || [], series = data.series || {};
942
+ const fmtTokens = (n) => { if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿'; if (n >= 1e4) return (n / 1e4).toFixed(1) + '万'; return String(n || 0); };
943
+ let xMin, xMax;
944
+ if (dates.length > 0) { const d = 86400000; xMin = new Date(dates[0]).getTime() - d; xMax = new Date(dates[dates.length - 1]).getTime() + d; }
945
+ trendChartInstance.setOption({
946
+ color: TREND_COLORS,
947
+ tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'line' } },
948
+ legend: { data: models, top: 5, type: 'scroll' },
949
+ grid: { left: 60, right: 20, top: 50, bottom: 30 },
950
+ xAxis: { type: "time", min: xMin, max: xMax, axisLabel: { fontSize: 11 } },
951
+ yAxis: { type: 'value', axisLabel: { fontSize: 11, formatter: fmtTokens } },
952
+ series: models.map((m, i) => ({
953
+ name: m, type: 'line', smooth: false, symbol: 'circle', symbolSize: 6,
954
+ areaStyle: { opacity: 0.1 }, lineStyle: { width: 2 },
955
+ itemStyle: { color: TREND_COLORS[i % TREND_COLORS.length] },
956
+ label: { show: true, position: 'top', fontSize: 11, formatter: ({ value }) => value[1] == null ? '' : fmtTokens(value[1]) },
957
+ connectNulls: true,
958
+ data: dates.map(d => { const v = series[m] && series[m][d]; return [d, v == null ? null : v]; }),
959
+ })),
960
+ }, true);
961
+ }
962
+ async function loadTokenRecent() {
963
+ const recentEl = el('token-recent');
964
+ if (!recentEl) return;
965
+ try {
966
+ const records = await api('token-stats/recent?limit=10');
967
+ if (!records || records.length === 0) { recentEl.innerHTML = '<div class="ag-empty">暂无记录</div>'; return; }
968
+ let html = '<table class="ag-table"><tr><th>时间</th><th>步骤</th><th>模型</th><th>输入</th><th>输出</th><th>总计</th></tr>';
969
+ for (const r of records.reverse()) {
970
+ const stepName = r.step === 'L3_security_judge' ? 'L3 安全判断' : r.step === 'step2_policy_gen' ? '策略生成' : escapeHtml(r.step);
971
+ const ts = r.timestamp ? escapeHtml(r.timestamp.slice(0, 19)) : '';
972
+ html += '<tr><td>' + ts + '</td><td>' + stepName + '</td><td style="color:var(--ag-text-secondary)">' + escapeHtml(r.model || '') + '</td><td>' + r.prompt_tokens + '</td><td>' + r.completion_tokens + '</td><td><strong>' + r.total_tokens + '</strong></td></tr>';
973
+ }
974
+ html += '</table>';
975
+ recentEl.innerHTML = html;
976
+ } catch (e) { recentEl.innerHTML = '<div class="ag-empty">加载失败</div>'; }
977
+ }
978
+ async function resetTokenStats() {
979
+ if (!confirm('确定清除所有 Token 统计数据?')) return;
980
+ try { await apiPost('token-stats/reset'); showToast('Token 统计已清除'); loadTokenStats(7); }
981
+ catch (e) { showToast('清除失败: ' + e.message, 'error'); }
982
+ }
983
+
984
+ // ===== 设置页 =====
985
+ function render_settings() {
986
+ const eo = state.envOverrides || {};
987
+ let envHtml = '';
988
+ if (Object.keys(eo).length === 0) {
989
+ envHtml = '<div class="ag-empty">当前没有环境变量覆盖</div>';
990
+ } else {
991
+ for (const [k, v] of Object.entries(eo)) {
992
+ const layerName = LAYER_NAMES[k]?.name || k;
993
+ envHtml += '<div class="ag-rule-item"><span class="ag-badge ' + (v.overridden ? 'ag-badge-critical' : 'ag-badge-low') + '">' + (v.overridden ? '强制禁用' : '无覆盖') + '</span><span style="font-size:13px">' + layerName + ': ' + escapeHtml(v.env_var || '') + '=' + escapeHtml(v.env_value || '') + '</span></div>';
994
+ }
995
+ }
996
+ el('page-settings').innerHTML = `
997
+ <div class="ag-card">
998
+ <div class="ag-section-title">环境变量覆盖</div>
999
+ <div class="ag-section-desc">环境变量优先级最高,会覆盖 Web UI 的设置。清除环境变量后 Web 设置才能生效。</div>
1000
+ ${envHtml}
1001
+ </div>
1002
+ <div class="ag-card">
1003
+ <div class="ag-section-title">配置文件路径</div>
1004
+ <div style="font-size:13px;line-height:1.8">
1005
+ <p>配置文件: ~/.config/agentmoss/agent_moss_runtime.json</p>
1006
+ <p>环境变量 AGENT_MOSS_RUNTIME_CONFIG_PATH 可指定自定义路径</p>
1007
+ <p>优先级: 环境变量 > runtime JSON > config.json > 源码默认值</p>
1008
+ </div>
1009
+ </div>
1010
+ <div class="ag-card">
1011
+ <div class="ag-section-title">访问 Token</div>
1012
+ <div class="ag-section-desc">此处设置的是浏览器本地 token,console 调用 API 时以 Authorization: Bearer &lt;token&gt; 携带。服务端是否要求认证取决于环境变量 AGENT_MOSS_CONSOLE_TOKEN:未设置则本机免认证;已设置则请求须携带相同值才放行。当前本地 token:${getToken() ? '已设置' : '未设置'}</div>
1013
+ <input class="ag-input" id="settings-token" placeholder="输入新 token" value="${getToken()}">
1014
+ <div style="margin-top:8px"><button class="ag-btn ag-btn-primary" onclick="localStorage.setItem('am_console_token',document.getElementById('settings-token').value);showToast('已保存')">保存</button></div>
1015
+ </div>
1016
+ <div class="ag-card">
1017
+ <div class="ag-section-title">危险操作</div>
1018
+ <button class="ag-btn ag-btn-danger" onclick="if(confirm('确定重置所有规则到出厂默认?所有自定义规则和开关设置将丢失。')){apiPost('reset').then(c=>{state.config=c;state.envOverrides=api('env-overrides');renderCurrent();showToast('配置已重置到出厂默认')}).catch(e=>showToast('重置失败: '+e.message,'error'))}">重置规则到出厂默认</button>
1019
+ <button class="ag-btn ag-btn-danger" style="margin-left:8px" onclick="resetTokenStats()">清除 Token 统计</button>
1020
+ </div>`;
1021
+ }
1022
+
1023
+ // ===== Modal 新增规则/新增 Skill =====
1024
+ function showAddRuleModal(layer) {
1025
+ currentModalAction = { type: 'add_rule', layer };
1026
+ const isL1 = layer === 'L1_rules', isL2 = layer === 'L2_rules';
1027
+ const title = isL1 ? '新增 L1 静态规则' : '新增 L2 规则';
1028
+ let formHtml = '';
1029
+ if (isL1) {
1030
+ formHtml = `<div class="ag-form-group"><div class="ag-form-label">规则类型<span class="ag-required">*</span></div><select class="ag-select" id="rule-type" onchange="updateRuleForm()"><option value="pattern">正则模式匹配</option><option value="keyword">关键词匹配</option></select><div class="ag-form-hint">正则模式:用正则表达式匹配命令(如 <code>\\brm\\s+-rf</code>);关键词匹配:命令中包含指定文字即命中。</div></div>
1031
+ <div class="ag-form-group" id="form-pattern"><div class="ag-form-label">正则 Pattern<span class="ag-required">*</span></div><input class="ag-input" id="input-pattern" placeholder="如: \\brm\\s+-rf"><div class="ag-form-hint">Python 正则表达式,命中该正则的命令会触发本规则。</div></div>
1032
+ <div class="ag-form-group" id="form-keyword" style="display:none"><div class="ag-form-label">关键词<span class="ag-required">*</span></div><input class="ag-input" id="input-keyword" placeholder="如: ignore previous instructions"><div class="ag-form-hint">命令文本中包含此关键词即命中(不区分大小写)。</div></div>
1033
+ <div class="ag-form-group"><div class="ag-form-label">风险等级<span class="ag-required">*</span></div><select class="ag-select" id="input-risk-level"><option value="critical">Critical(严重)</option><option value="high">High(高危)</option><option value="medium">Medium(中危)</option><option value="low">Low(低危)</option></select><div class="ag-form-hint"><strong>critical / high</strong>:在 L1 层直接拦截(Deny),命令不会执行;<br><strong>medium / low</strong>:不在 L1 拦截,转交 L3 LLM 深度分析判断。</div></div>
1034
+ <div class="ag-form-group"><div class="ag-form-label">风险类型</div><input class="ag-input" id="input-risk-type" placeholder="如: script_execution, file_access, data_exfiltration"><div class="ag-form-hint">风险分类标签,用于统计与展示。可选值:script_execution(脚本执行)、file_access(文件访问)、data_exfiltration(数据外泄)等,可自定义。</div></div>
1035
+ <div class="ag-form-group"><div class="ag-form-label">拦截原因<span class="ag-required">*</span></div><input class="ag-input" id="input-reason" placeholder="描述为什么这条规则要拦截"><div class="ag-form-hint">命中时展示给用户的说明文字,应写清为什么危险。L3 分析时也会参考此原因。</div></div>
1036
+ <div class="ag-form-group"><div class="ag-form-label">语言(仅关键词类型)</div><select class="ag-select" id="input-lang"><option value="en">English</option><option value="zh">中文</option></select><div class="ag-form-hint">关键词的语言,影响匹配时的分词处理。</div></div>
1037
+ <div class="ag-form-group"><div class="ag-form-label">所属分类</div><select class="ag-select" id="input-category"><option value="user_custom">用户自定义</option><option value="dangerous_commands">危险命令检测</option><option value="injection_detection">注入攻击检测</option></select></div>`;
1038
+ } else if (isL2) {
1039
+ formHtml = `<div class="ag-form-group"><div class="ag-form-label">规则类型<span class="ag-required">*</span></div><select class="ag-select" id="rule-type-l2" onchange="updateL2RuleForm()"><option value="path">敏感路径</option><option value="pattern">正则模式</option></select><div class="ag-form-hint">敏感路径:匹配文件路径(如 <code>/etc/shadow</code>);正则模式:用正则匹配命令文本。</div></div>
1040
+ <div class="ag-form-group" id="form-l2-path"><div class="ag-form-label">路径<span class="ag-required">*</span></div><input class="ag-input" id="input-l2-path" placeholder="如: /etc/shadow, .ssh/id_rsa"><div class="ag-form-hint">要拦截的文件路径,多个用英文逗号分隔。命令访问到这些路径即命中。</div></div>
1041
+ <div class="ag-form-group" id="form-l2-pattern" style="display:none"><div class="ag-form-label">正则 Pattern<span class="ag-required">*</span></div><input class="ag-input" id="input-l2-pattern" placeholder="如: \\bpasswd\\s+--stdin"><div class="ag-form-hint">Python 正则表达式,命中即触发。</div></div>
1042
+ <div class="ag-form-group"><div class="ag-form-label">描述<span class="ag-required">*</span></div><input class="ag-input" id="input-l2-desc" placeholder="路径/规则的中文描述"><div class="ag-form-hint">命中时展示给用户的说明。</div></div>
1043
+ <div class="ag-form-group"><div class="ag-form-label">风险等级<span class="ag-required">*</span></div><select class="ag-select" id="input-l2-risk-level"><option value="critical">Critical(严重)</option><option value="high">High(高危)</option><option value="medium">Medium(中危)</option><option value="low">Low(低危)</option></select><div class="ag-form-hint"><strong>critical / high</strong>:在 L2 层直接拦截;<br><strong>medium / low</strong>:不在 L2 拦截,转交 L3 LLM 判断。</div></div>
1044
+ <div class="ag-form-group"><div class="ag-form-label">拦截模式</div><select class="ag-select" id="input-l2-deny-mode"><option value="deny_write">仅拦截写入</option><option value="deny_read">仅拦截读取</option><option value="deny_both">读写均拦截</option></select><div class="ag-form-hint">选择该路径的拦截模式:仅拦截写入(默认)、仅拦截读取(如 /dev/random)、读写均拦截(凭据文件)。</div></div>
1045
+ <div class="ag-form-group"><div class="ag-form-label">所属分类</div><select class="ag-select" id="input-l2-category"><option value="sensitive_path_access">敏感路径访问</option><option value="password_modify_consent">密码修改授权</option><option value="user_deletion_consent">用户删除授权</option><option value="intent_consistency">意图一致性</option></select></div>`;
1046
+ }
1047
+ openModal(title, formHtml);
1048
+ }
1049
+ function showAddSkillModal() {
1050
+ currentModalAction = { type: 'add_skill' };
1051
+ openModal('新增 L3 Skill', `<div class="ag-form-group"><div class="ag-form-label">Skill ID(文件名)</div><input class="ag-input" id="input-skill-id" placeholder="如: my_custom_guard"><div class="ag-form-hint">Skill 文件名(不含扩展名),英文+下划线。会成为该 Skill 的唯一标识。</div></div>
1052
+ <div class="ag-form-group"><div class="ag-form-label">触发关键词<span class="ag-required">*</span></div><input class="ag-input" id="input-skill-keywords" placeholder="逗号分隔, 如: custom, 特殊, special"><div class="ag-form-hint">命令/动作中包含这些关键词时,该 Skill 会被匹配并注入 L3 的 LLM prompt。</div></div>
1053
+ <div class="ag-form-group"><div class="ag-form-label">Skill 内容(Markdown)<span class="ag-required">*</span></div><textarea class="ag-input" id="input-skill-content" rows="4" placeholder="# Skill 规则标题\n\n## 适用场景\n..."></textarea><div class="ag-form-hint">Markdown 格式的规则说明。命中关键词后会作为提示注入 LLM。</div></div>
1054
+ <div class="ag-form-group"><div class="ag-form-label">所属分类</div><select class="ag-select" id="input-skill-category"><option value="user_custom">用户自定义</option><option value="file_operations">文件操作</option><option value="network_security">网络安全</option><option value="intent_and_general">意图/通用</option></select></div>`);
1055
+ }
1056
+ function updateRuleForm() {
1057
+ const t = document.getElementById('rule-type')?.value;
1058
+ const p = document.getElementById('form-pattern'), k = document.getElementById('form-keyword');
1059
+ if (p) p.style.display = t === 'pattern' ? '' : 'none';
1060
+ if (k) k.style.display = t === 'keyword' ? '' : 'none';
1061
+ }
1062
+ function updateL2RuleForm() {
1063
+ const t = document.getElementById('rule-type-l2')?.value;
1064
+ const p = document.getElementById('form-l2-path'), k = document.getElementById('form-l2-pattern');
1065
+ if (p) p.style.display = t === 'path' ? '' : 'none';
1066
+ if (k) k.style.display = t === 'pattern' ? '' : 'none';
1067
+ }
1068
+ async function submitModal() {
1069
+ if (!currentModalAction) return;
1070
+ try {
1071
+ if (currentModalAction.type === 'add_rule') {
1072
+ const layer = currentModalAction.layer;
1073
+ const isL1 = layer === 'L1_rules', isL2 = layer === 'L2_rules';
1074
+ let body = { layer };
1075
+ if (isL1) {
1076
+ const ruleType = document.getElementById('rule-type')?.value || 'pattern';
1077
+ body.category = document.getElementById('input-category')?.value || 'user_custom';
1078
+ body.risk_level = document.getElementById('input-risk-level')?.value;
1079
+ body.risk_type = document.getElementById('input-risk-type')?.value || undefined;
1080
+ body.reason = document.getElementById('input-reason')?.value;
1081
+ if (ruleType === 'pattern') body.pattern = document.getElementById('input-pattern')?.value;
1082
+ else { body.keyword = document.getElementById('input-keyword')?.value; body.lang = document.getElementById('input-lang')?.value; }
1083
+ } else if (isL2) {
1084
+ const ruleType = document.getElementById('rule-type-l2')?.value || 'path';
1085
+ body.category = document.getElementById('input-l2-category')?.value || 'sensitive_path_access';
1086
+ body.risk_level = document.getElementById('input-l2-risk-level')?.value;
1087
+ body.desc = document.getElementById('input-l2-desc')?.value;
1088
+ const dm = document.getElementById('input-l2-deny-mode')?.value;
1089
+ if (dm) body.deny_mode = dm;
1090
+ if (ruleType === 'path') body.path = document.getElementById('input-l2-path')?.value;
1091
+ else { body.pattern = document.getElementById('input-l2-pattern')?.value; body.risk_type = 'script_execution'; }
1092
+ }
1093
+ state.config = await apiPost('rules', body);
1094
+ showToast('规则已添加');
1095
+ } else if (currentModalAction.type === 'add_skill') {
1096
+ const keywords = (document.getElementById('input-skill-keywords')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
1097
+ state.config = await apiPost('skills', {
1098
+ skill_id: document.getElementById('input-skill-id')?.value,
1099
+ category: document.getElementById('input-skill-category')?.value || 'user_custom',
1100
+ keywords,
1101
+ content: document.getElementById('input-skill-content')?.value,
1102
+ });
1103
+ showToast('Skill 已添加');
1104
+ }
1105
+ renderCurrent();
1106
+ closeModal();
1107
+ } catch (e) { showToast('添加失败: ' + e.message, 'error'); }
1108
+ }
1109
+
1110
+ // ===== Tab nav + theme =====
1111
+ document.querySelectorAll('.ag-tab[data-page]').forEach(t => {
1112
+ t.onclick = () => {
1113
+ stopTokenAutoRefresh();
1114
+ state.currentPage = t.dataset.page;
1115
+ document.querySelectorAll('.ag-tab').forEach(x => x.classList.remove('active'));
1116
+ t.classList.add('active');
1117
+ renderCurrent();
1118
+ };
1119
+ });
1120
+ el('theme-btn').onclick = () => {
1121
+ const cur = document.documentElement.getAttribute('data-theme');
1122
+ const next = cur === 'dark' ? 'light' : 'dark';
1123
+ document.documentElement.setAttribute('data-theme', next);
1124
+ el('theme-btn').textContent = next === 'dark' ? '☀️ 浅色' : '🌙 暗色';
1125
+ if (state.currentPage === 'token') render_token();
1126
+ };
1127
+
1128
+ // ===== Init =====
1129
+ refreshConfig().catch(e => { document.body.innerHTML = '<div style="padding:40px;color:#FF3B30">加载失败: ' + e.message + '</div>'; });
1130
+ </script>
1131
+ </body>
1132
+ </html>