@kenhkl/agent-moss 0.12.1 → 0.12.3
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/dist/static/index.html +351 -259
- package/package.json +1 -1
package/dist/static/index.html
CHANGED
|
@@ -279,12 +279,30 @@ body {
|
|
|
279
279
|
</div>
|
|
280
280
|
|
|
281
281
|
<script>
|
|
282
|
+
// ===== i18n =====
|
|
283
|
+
// 语言来源优先级:URL ?lang=en > ?lang=zh > 系统/文档语言。默认 zh。
|
|
284
|
+
// en 模式下所有用户可见文案翻译为英文;数据契约 key(deny_write/critical/分类key/风险类型key)
|
|
285
|
+
// 保持原样,它们是接口取值,翻错会破坏与后端对接。
|
|
286
|
+
function detectLang() {
|
|
287
|
+
const params = new URLSearchParams(window.location.search);
|
|
288
|
+
const q = params.get('lang');
|
|
289
|
+
if (q === 'en' || q === 'zh') return q;
|
|
290
|
+
const docLang = (document.documentElement.lang || '').toLowerCase();
|
|
291
|
+
return docLang.indexOf('zh') === 0 ? 'zh' : 'zh'; // 默认中文
|
|
292
|
+
}
|
|
293
|
+
const LANG = detectLang();
|
|
294
|
+
const isEn = LANG === 'en';
|
|
295
|
+
/** t(zh, en):中文为默认回退,en 模式下返回英文翻译。 */
|
|
296
|
+
function t(zh, en) { return isEn ? (en != null && en !== '' ? en : zh) : zh; }
|
|
297
|
+
function applyTitle() { document.title = t('AgentMoss 安全策略管控台', 'AgentMoss Policy Console'); }
|
|
298
|
+
document.documentElement.lang = isEn ? 'en' : 'zh-CN';
|
|
299
|
+
|
|
282
300
|
// ===== API client =====
|
|
283
301
|
const TOKEN_KEY = 'am_console_token';
|
|
284
302
|
function getToken() { return localStorage.getItem(TOKEN_KEY) || ''; }
|
|
285
303
|
function authHeaders() {
|
|
286
|
-
const
|
|
287
|
-
return
|
|
304
|
+
const t_ = getToken();
|
|
305
|
+
return t_ ? { 'Authorization': 'Bearer ' + t_ } : {};
|
|
288
306
|
}
|
|
289
307
|
async function api(path, opts = {}) {
|
|
290
308
|
const res = await fetch('/console/api/' + path, {
|
|
@@ -292,9 +310,9 @@ async function api(path, opts = {}) {
|
|
|
292
310
|
...opts,
|
|
293
311
|
});
|
|
294
312
|
if (res.status === 401) {
|
|
295
|
-
const
|
|
296
|
-
if (
|
|
297
|
-
throw new Error('未授权');
|
|
313
|
+
const t_ = prompt(t('需要 Bearer token 访问安全策略管控台:', 'Bearer token required to access the policy console: '));
|
|
314
|
+
if (t_) { localStorage.setItem(TOKEN_KEY, t_); return api(path, opts); }
|
|
315
|
+
throw new Error(t('未授权', 'Unauthorized'));
|
|
298
316
|
}
|
|
299
317
|
if (!res.ok) {
|
|
300
318
|
const d = await res.json().catch(() => ({}));
|
|
@@ -305,53 +323,58 @@ async function api(path, opts = {}) {
|
|
|
305
323
|
function apiPut(path, body) { return api(path, { method: 'PUT', body: JSON.stringify(body) }); }
|
|
306
324
|
function apiPost(path, body) { return api(path, { method: 'POST', body: JSON.stringify(body) }); }
|
|
307
325
|
function apiDelete(path, body) { return api(path, { method: 'DELETE', body: JSON.stringify(body) }); }
|
|
326
|
+
/** 拉取并解包 env 覆盖映射(/api/env-overrides 返回 {overrides: {...}},此处解出净映射)。 */
|
|
327
|
+
async function loadEnvOverrides() {
|
|
328
|
+
const resp = await api('env-overrides');
|
|
329
|
+
return resp?.overrides || {};
|
|
330
|
+
}
|
|
308
331
|
|
|
309
332
|
let state = { config: {}, envOverrides: {}, currentPage: 'overview' };
|
|
310
333
|
const expandedCats = new Set();
|
|
311
334
|
let currentModalAction = null;
|
|
312
335
|
|
|
313
|
-
// ===== 静态文案 =====
|
|
336
|
+
// ===== 静态文案 / i18n 名称字典 =====
|
|
314
337
|
const LAYER_NAMES = {
|
|
315
|
-
L1_heuristic: { name: 'L1 静态检测', desc: '正则/关键词匹配,仅看下一步动作' },
|
|
316
|
-
L2_logic_rules: { name: 'L2 逻辑规则', desc: 'read_before_write + 授权检测使用历史动作序列,敏感路径/危险模式仅匹配下一步' },
|
|
317
|
-
L3_llm_analysis: { name: 'L3 LLM 分析', desc: 'Skill 匹配 + LLM 语义判断' },
|
|
338
|
+
L1_heuristic: { name: t('L1 静态检测', 'L1 Heuristic Detection'), desc: t('正则/关键词匹配,仅看下一步动作', 'Regex/keyword matching; only inspects the next action') },
|
|
339
|
+
L2_logic_rules: { name: t('L2 逻辑规则', 'L2 Logic Rules'), desc: t('read_before_write + 授权检测使用历史动作序列,敏感路径/危险模式仅匹配下一步', 'read_before_write + authorization checks use historical action sequences; sensitive paths/dangerous patterns match only the next action') },
|
|
340
|
+
L3_llm_analysis: { name: t('L3 LLM 分析', 'L3 LLM Analysis'), desc: t('Skill 匹配 + LLM 语义判断', 'Skill matching + LLM semantic judgment') },
|
|
318
341
|
};
|
|
319
342
|
const L1_CATEGORY_NAMES = {
|
|
320
|
-
dangerous_commands: '⚡ 危险命令检测',
|
|
321
|
-
injection_detection: '🎯 注入攻击检测',
|
|
322
|
-
sensitive_path_access: '🛡️ 敏感路径访问',
|
|
323
|
-
user_custom: '🔧 用户自定义',
|
|
343
|
+
dangerous_commands: t('⚡ 危险命令检测', '⚡ Dangerous Commands'),
|
|
344
|
+
injection_detection: t('🎯 注入攻击检测', '🎯 Injection Attack Detection'),
|
|
345
|
+
sensitive_path_access: t('🛡️ 敏感路径访问', '🛡️ Sensitive Path Access'),
|
|
346
|
+
user_custom: t('🔧 用户自定义', '🔧 User Custom'),
|
|
324
347
|
};
|
|
325
348
|
const L2_CATEGORY_NAMES = {
|
|
326
|
-
intent_consistency: '🎯 意图一致性 📋对比原始意图',
|
|
327
|
-
password_modify_consent: '🔑 密码修改授权 📋涉及历史分析',
|
|
328
|
-
user_deletion_consent: '👤 用户删除授权 📋涉及历史分析',
|
|
329
|
-
read_before_write: '📄 读写一致性 📋涉及历史分析',
|
|
330
|
-
user_custom: '🔧 用户自定义',
|
|
349
|
+
intent_consistency: t('🎯 意图一致性 📋对比原始意图', '🎯 Intent Consistency 📋vs original intent'),
|
|
350
|
+
password_modify_consent: t('🔑 密码修改授权 📋涉及历史分析', '🔑 Password Modify Consent 📋uses history'),
|
|
351
|
+
user_deletion_consent: t('👤 用户删除授权 📋涉及历史分析', '👤 User Deletion Consent 📋uses history'),
|
|
352
|
+
read_before_write: t('📄 读写一致性 📋涉及历史分析', '📄 Read-before-Write 📋uses history'),
|
|
353
|
+
user_custom: t('🔧 用户自定义', '🔧 User Custom'),
|
|
331
354
|
};
|
|
332
355
|
const L3_CATEGORY_NAMES = {
|
|
333
|
-
file_operations: '📁 文件操作',
|
|
334
|
-
network_security: '🌐 网络安全',
|
|
335
|
-
persistence_supply_chain: '🔒 持久化/供应链',
|
|
336
|
-
intent_and_general: '🎯 意图/通用',
|
|
337
|
-
resource: '💻 资耗',
|
|
338
|
-
user_custom: '🔧 用户自定义',
|
|
356
|
+
file_operations: t('📁 文件操作', '📁 File Operations'),
|
|
357
|
+
network_security: t('🌐 网络安全', '🌐 Network Security'),
|
|
358
|
+
persistence_supply_chain: t('🔒 持久化/供应链', '🔒 Persistence/Supply Chain'),
|
|
359
|
+
intent_and_general: t('🎯 意图/通用', '🎯 Intent/General'),
|
|
360
|
+
resource: t('💻 资耗', '💻 Resource Exhaustion'),
|
|
361
|
+
user_custom: t('🔧 用户自定义', '🔧 User Custom'),
|
|
339
362
|
};
|
|
340
363
|
const RISK_TYPE_NAMES = {
|
|
341
|
-
script_execution: '脚本执行', file_access: '文件访问', data_exfiltration: '数据外泄',
|
|
342
|
-
privilege_escalation: '提权', persistence: '持久化后门', lateral_movement: '横向移动',
|
|
343
|
-
config_tampering: '配置篡改', supply_chain: '供应链', resource_exhaustion: '资源耗尽',
|
|
344
|
-
intent_deviation: '意图偏离',
|
|
364
|
+
script_execution: t('脚本执行', 'Script Execution'), file_access: t('文件访问', 'File Access'), data_exfiltration: t('数据外泄', 'Data Exfiltration'),
|
|
365
|
+
privilege_escalation: t('提权', 'Privilege Escalation'), persistence: t('持久化后门', 'Persistence Backdoor'), lateral_movement: t('横向移动', 'Lateral Movement'),
|
|
366
|
+
config_tampering: t('配置篡改', 'Config Tampering'), supply_chain: t('供应链', 'Supply Chain'), resource_exhaustion: t('资源耗尽', 'Resource Exhaustion'),
|
|
367
|
+
intent_deviation: t('意图偏离', 'Intent Deviation'),
|
|
345
368
|
};
|
|
346
369
|
const RISK_TOOLTIPS = {
|
|
347
|
-
critical: '严重:必须拒绝,不可逆破坏或提权操作',
|
|
348
|
-
high: '高危:强烈建议拒绝,需人工审核',
|
|
349
|
-
medium: '中危:需谨慎评估,结合意图判断',
|
|
350
|
-
low: '低危:常规操作,可按需放行',
|
|
370
|
+
critical: t('严重:必须拒绝,不可逆破坏或提权操作', 'Critical: must reject; irreversible damage or privilege escalation'),
|
|
371
|
+
high: t('高危:强烈建议拒绝,需人工审核', 'High: strongly recommend rejecting; requires human review'),
|
|
372
|
+
medium: t('中危:需谨慎评估,结合意图判断', 'Medium: evaluate carefully; judge with intent'),
|
|
373
|
+
low: t('低危:常规操作,可按需放行', 'Low: normal operation; may allow as needed'),
|
|
351
374
|
};
|
|
352
|
-
const DENY_MODE_LABELS = { deny_write: '仅拦截写入', deny_read: '仅拦截读取', deny_both: '读写均拦截' };
|
|
375
|
+
const DENY_MODE_LABELS = { deny_write: t('仅拦截写入', 'Block Write Only'), deny_read: t('仅拦截读取', 'Block Read Only'), deny_both: t('读写均拦截', 'Block Both Read & Write') };
|
|
353
376
|
|
|
354
|
-
function riskTypeName(
|
|
377
|
+
function riskTypeName(tt) { if (!tt) return ''; return RISK_TYPE_NAMES[tt] || tt; }
|
|
355
378
|
function denyModeLabel(m) { return DENY_MODE_LABELS[m] || m; }
|
|
356
379
|
function denyModeClass(m) { return ({ deny_write: 'deny-write', deny_read: 'deny-read', deny_both: 'deny-both' })[m] || ''; }
|
|
357
380
|
|
|
@@ -363,23 +386,23 @@ function riskBadge(level) {
|
|
|
363
386
|
const tip = RISK_TOOLTIPS[level] || RISK_TOOLTIPS.low;
|
|
364
387
|
return `<span class="ag-badge ${cls}" title="${tip}">${level || 'low'}</span>`;
|
|
365
388
|
}
|
|
366
|
-
function riskTypeBadge(
|
|
367
|
-
if (!
|
|
368
|
-
return `<span class="ag-risk-type ag-rt-${escapeHtml(
|
|
389
|
+
function riskTypeBadge(tt) {
|
|
390
|
+
if (!tt) return '';
|
|
391
|
+
return `<span class="ag-risk-type ag-rt-${escapeHtml(tt)}" title="${t('风险类型: ', 'Risk type: ')}${escapeHtml(tt)}">${escapeHtml(riskTypeName(tt))}</span>`;
|
|
369
392
|
}
|
|
370
393
|
function builtinBadge(builtin) {
|
|
371
|
-
return builtin !== false ? '<span class="ag-badge ag-badge-builtin"
|
|
394
|
+
return builtin !== false ? '<span class="ag-badge ag-badge-builtin">' + t('内置', 'Built-in') + '</span>' : '<span class="ag-badge ag-badge-custom">' + t('自定义', 'Custom') + '</span>';
|
|
372
395
|
}
|
|
373
|
-
function removedBadge(
|
|
396
|
+
function removedBadge(removed) { return removed ? '<span class="ag-badge ag-badge-removed" title="' + t('此规则已从当前默认规则集移除,仅作历史保留;如需可手动启用', 'This rule has been removed from the current default set and is kept for history; you can enable it manually') + '">' + t('已移除', 'Removed') + '</span>' : ''; }
|
|
374
397
|
function denyModeBadge(mode) {
|
|
375
398
|
const m = mode || 'deny_write';
|
|
376
|
-
return `<span class="ag-deny-mode ${denyModeClass(m)}" title="拦截模式: ${denyModeLabel(m)}">${denyModeLabel(m)}</span>`;
|
|
399
|
+
return `<span class="ag-deny-mode ${denyModeClass(m)}" title="${t('拦截模式: ', 'Interception mode: ')}${denyModeLabel(m)}">${denyModeLabel(m)}</span>`;
|
|
377
400
|
}
|
|
378
401
|
function sourceDenyModeBadges(rule) {
|
|
379
402
|
if (rule.builtin !== false && rule.source_deny_mode) {
|
|
380
|
-
let h = `<span class="ag-source-badge" title="源码出厂的原始拦截模式。当前模式与之不同时表示已被用户覆盖"
|
|
403
|
+
let h = `<span class="ag-source-badge" title="${t('源码出厂的原始拦截模式。当前模式与之不同时表示已被用户覆盖', 'Source default interception mode. A different current mode means the user has overridden it')}">${t('原始: ', 'Source: ')}${denyModeLabel(rule.source_deny_mode)}</span>`;
|
|
381
404
|
if (rule.deny_mode && rule.deny_mode !== rule.source_deny_mode) {
|
|
382
|
-
h += '<span class="ag-overridden-hint" title="当前拦截模式已偏离源码默认值"
|
|
405
|
+
h += '<span class="ag-overridden-hint" title="' + t('当前拦截模式已偏离源码默认值', 'Current mode deviates from the source default') + '">' + t('已覆盖', 'Overridden') + '</span>';
|
|
383
406
|
}
|
|
384
407
|
return h;
|
|
385
408
|
}
|
|
@@ -389,18 +412,18 @@ function toggle(on, disabled, onclick) {
|
|
|
389
412
|
return `<button class="ag-toggle ${on ? 'on' : ''} ${disabled ? 'disabled' : ''}" ${disabled ? 'disabled' : ''} onclick="${disabled ? '' : onclick}"><span class="ag-toggle-knob"></span></button>`;
|
|
390
413
|
}
|
|
391
414
|
function formatTokens(n) {
|
|
392
|
-
if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿';
|
|
393
|
-
if (n >= 1e4) return (n / 1e4).toFixed(1) + '万';
|
|
415
|
+
if (n >= 1e8) return (n / 1e8).toFixed(1) + t('亿', 'B');
|
|
416
|
+
if (n >= 1e4) return (n / 1e4).toFixed(1) + t('万', 'W');
|
|
394
417
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
395
418
|
return String(n || 0);
|
|
396
419
|
}
|
|
397
420
|
function showToast(msg, type) {
|
|
398
421
|
if (!type) type = 'success';
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
document.body.appendChild(
|
|
403
|
-
setTimeout(() =>
|
|
422
|
+
const tt = document.createElement('div');
|
|
423
|
+
tt.className = 'ag-toast ag-toast-' + type;
|
|
424
|
+
tt.textContent = msg;
|
|
425
|
+
document.body.appendChild(tt);
|
|
426
|
+
setTimeout(() => tt.remove(), 3000);
|
|
404
427
|
}
|
|
405
428
|
function openModal(title, bodyHtml, showActions) {
|
|
406
429
|
el('modal-title').textContent = title;
|
|
@@ -417,8 +440,27 @@ function closeModal() {
|
|
|
417
440
|
// ===== Pages =====
|
|
418
441
|
async function refreshConfig() {
|
|
419
442
|
state.config = await api('config');
|
|
420
|
-
state.envOverrides = await
|
|
443
|
+
state.envOverrides = await loadEnvOverrides();
|
|
421
444
|
state.agentRules = await api('agents');
|
|
445
|
+
// 探测当前进程的 instance(AGENT_MOSS_INSTANCE,如 opendesk/xiaoo)。
|
|
446
|
+
// 有 instance 时 Agent Rules 页只显示当前 agent 的定制规则,避免出现别的 agent 的规则造成困惑;
|
|
447
|
+
// 无 instance(本地独立起服务/非 agent 调用)则显示全部。
|
|
448
|
+
state.instance = '';
|
|
449
|
+
try {
|
|
450
|
+
const h = await fetch('/api/v1/health', { signal: AbortSignal.timeout(2000) });
|
|
451
|
+
if (h.ok) {
|
|
452
|
+
const hd = await h.json();
|
|
453
|
+
state.instance = hd?.instance || '';
|
|
454
|
+
}
|
|
455
|
+
} catch { /* health 不可达,instance 保持空(显示全部) */ }
|
|
456
|
+
// 探测服务端是否开启了 console 访问 token 认证(AGENT_MOSS_CONSOLE_TOKEN)。
|
|
457
|
+
// 不带 token 裸请求一个 API 端点:返回 401 说明开启了认证 → 设置页显示"访问 Token"卡片;
|
|
458
|
+
// 返回 200 说明本机免认证 → 隐藏该卡片(单机使用无需配置,避免误导)。
|
|
459
|
+
state.consoleAuthRequired = false;
|
|
460
|
+
try {
|
|
461
|
+
const probe = await fetch('/console/api/env-overrides', { signal: AbortSignal.timeout(2000) });
|
|
462
|
+
state.consoleAuthRequired = probe.status === 401;
|
|
463
|
+
} catch { /* 探测失败,默认不显示 token 卡片 */ }
|
|
422
464
|
renderCurrent();
|
|
423
465
|
}
|
|
424
466
|
function renderCurrent() {
|
|
@@ -435,14 +477,14 @@ function renderCurrent() {
|
|
|
435
477
|
function render_overview() {
|
|
436
478
|
const layers = state.config.layers || {};
|
|
437
479
|
const eo = state.envOverrides || {};
|
|
438
|
-
const envBadge = k => eo[k] && eo[k].overridden ? `<span class="ag-env-badge">⚠ 已被 env
|
|
480
|
+
const envBadge = k => eo[k] && eo[k].overridden ? `<span class="ag-env-badge">⚠ ${t('已被 env 覆盖', 'Overridden by env')}</span>` : '';
|
|
439
481
|
const envOverrideBox = k => {
|
|
440
482
|
if (!eo[k] || !eo[k].overridden) return '';
|
|
441
|
-
return `<div class="ag-env-override-badge">⚠️ 环境变量 ${eo[k].env_var || ''}=1
|
|
483
|
+
return `<div class="ag-env-override-badge">⚠️ ${t('环境变量', 'Env var')} ${eo[k].env_var || ''}=1 ${t('强制禁用', 'forced disabled')}</div>`;
|
|
442
484
|
};
|
|
443
485
|
el('page-overview').innerHTML = `
|
|
444
486
|
<div class="ag-card">
|
|
445
|
-
<div class="ag-section-title"
|
|
487
|
+
<div class="ag-section-title">${t('层级全局开关', 'Layer Master Switches')}</div>
|
|
446
488
|
${Object.entries(LAYER_NAMES).map(([key, info]) => {
|
|
447
489
|
const enabled = layers[key] !== false;
|
|
448
490
|
const overridden = eo[key] && eo[key].overridden;
|
|
@@ -451,22 +493,23 @@ function render_overview() {
|
|
|
451
493
|
}).join('')}
|
|
452
494
|
</div>
|
|
453
495
|
<div class="ag-card">
|
|
454
|
-
<div class="ag-section-title"
|
|
496
|
+
<div class="ag-section-title">${t('规则统计', 'Rule Statistics')}</div>
|
|
455
497
|
<div class="ag-stat-grid">
|
|
456
|
-
<div class="ag-stat-card"><div class="label"
|
|
457
|
-
<div class="ag-stat-card"><div class="label"
|
|
458
|
-
<div class="ag-stat-card"><div class="label"
|
|
498
|
+
<div class="ag-stat-card"><div class="label">${t('L1 规则启用', 'L1 Rules Enabled')}</div><div class="value">${countEnabled(state.config.L1_rules, 'L1_heuristic')}/${countRules(state.config.L1_rules)}</div></div>
|
|
499
|
+
<div class="ag-stat-card"><div class="label">${t('L2 规则启用', 'L2 Rules Enabled')}</div><div class="value">${countEnabled(state.config.L2_rules, 'L2_logic_rules')}/${countRules(state.config.L2_rules)}</div></div>
|
|
500
|
+
<div class="ag-stat-card"><div class="label">${t('L3 Skills 启用', 'L3 Skills Enabled')}</div><div class="value">${countEnabledSkills(state.config.L3_skills, 'L3_llm_analysis')}/${countSkills(state.config.L3_skills)}</div></div>
|
|
459
501
|
</div>
|
|
460
502
|
</div>
|
|
461
503
|
<div class="ag-card">
|
|
462
|
-
<div class="ag-section-title"
|
|
504
|
+
<div class="ag-section-title">${t('Token 用量统计', 'Token Usage')
|
|
505
|
+
}
|
|
463
506
|
<div style="margin-left:auto;display:flex;gap:6px">
|
|
464
|
-
<button class="ag-btn ag-btn-sm" data-days="1" onclick="loadOverviewToken(1)"
|
|
465
|
-
<button class="ag-btn ag-btn-sm active" data-days="7" onclick="loadOverviewToken(7)"
|
|
466
|
-
<button class="ag-btn ag-btn-sm" data-days="0" onclick="loadOverviewToken(0)"
|
|
507
|
+
<button class="ag-btn ag-btn-sm" data-days="1" onclick="loadOverviewToken(1)">${t('今日', 'Today')}</button>
|
|
508
|
+
<button class="ag-btn ag-btn-sm active" data-days="7" onclick="loadOverviewToken(7)">${t('近7天', '7 days')}</button>
|
|
509
|
+
<button class="ag-btn ag-btn-sm" data-days="0" onclick="loadOverviewToken(0)">${t('全部', 'All')}</button>
|
|
467
510
|
</div>
|
|
468
511
|
</div>
|
|
469
|
-
<div id="overview-token-summary" class="ag-empty"
|
|
512
|
+
<div id="overview-token-summary" class="ag-empty">${t('加载中...', 'Loading...')}</div>
|
|
470
513
|
<div id="overview-token-breakdown" style="margin-top:16px"></div>
|
|
471
514
|
</div>`;
|
|
472
515
|
loadOverviewToken(7);
|
|
@@ -477,35 +520,40 @@ async function loadOverviewToken(days) {
|
|
|
477
520
|
const s = await api('token-stats?days=' + days);
|
|
478
521
|
el('overview-token-summary').innerHTML = `
|
|
479
522
|
<div class="ag-stat-grid">
|
|
480
|
-
<div class="ag-stat-card"><div class="label"
|
|
481
|
-
<div class="ag-stat-card"><div class="label"
|
|
482
|
-
<div class="ag-stat-card"><div class="label"
|
|
483
|
-
<div class="ag-stat-card"><div class="label"
|
|
523
|
+
<div class="ag-stat-card"><div class="label">${t('总 Token', 'Total Tokens')}</div><div class="value">${formatTokens(s.total_tokens)}</div></div>
|
|
524
|
+
<div class="ag-stat-card"><div class="label">${t('输入 Token', 'Prompt Tokens')}</div><div class="value">${formatTokens(s.total_prompt_tokens)}</div></div>
|
|
525
|
+
<div class="ag-stat-card"><div class="label">${t('输出 Token', 'Completion Tokens')}</div><div class="value">${formatTokens(s.total_completion_tokens)}</div></div>
|
|
526
|
+
<div class="ag-stat-card"><div class="label">${t('LLM 调用次数', 'LLM Calls')}</div><div class="value">${s.total_calls}</div></div>
|
|
484
527
|
</div>`;
|
|
485
528
|
const byStep = s.by_step || {};
|
|
486
|
-
const known = [{ key: 'L3_security_judge', name: 'L3 安全判断' }, { key: 'step2_policy_gen', name: '策略生成' }];
|
|
529
|
+
const known = [{ key: 'L3_security_judge', name: t('L3 安全判断', 'L3 Security Judge') }, { key: 'step2_policy_gen', name: t('策略生成', 'Policy Generation') }];
|
|
487
530
|
const rendered = new Set();
|
|
488
531
|
let html = '';
|
|
489
532
|
for (const { key, name } of known) {
|
|
490
533
|
const v = byStep[key] || { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, calls: 0 };
|
|
491
534
|
rendered.add(key);
|
|
492
|
-
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}
|
|
535
|
+
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} ${t('次', 'calls')}, ${t('输入', 'in')} ${formatTokens(v.prompt_tokens)}, ${t('输出', 'out')} ${formatTokens(v.completion_tokens)})</span></div>`;
|
|
493
536
|
}
|
|
494
537
|
for (const [step, v] of Object.entries(byStep)) {
|
|
495
538
|
if (rendered.has(step)) continue;
|
|
496
|
-
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>`;
|
|
539
|
+
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} ${t('次', 'calls')})</span></div>`;
|
|
497
540
|
}
|
|
498
|
-
el('overview-token-breakdown').innerHTML = html ||
|
|
499
|
-
} catch { el('overview-token-summary').innerHTML =
|
|
541
|
+
el('overview-token-breakdown').innerHTML = html || `<div class="ag-empty">${t('无分项数据', 'No breakdown data')}</div>`;
|
|
542
|
+
} catch { el('overview-token-summary').innerHTML = `<div class="ag-empty">${t('无数据', 'No data')}</div>`; }
|
|
500
543
|
}
|
|
501
544
|
function countRules(lr) { if (!lr) return 0; return Object.values(lr).reduce((s, c) => s + (c.rules ? c.rules.length : 0), 0); }
|
|
502
|
-
|
|
545
|
+
// 统计"启用"的规则数,口径与后端执行一致:层级开 && 大类开 && 单条规则开。
|
|
546
|
+
// layerKey 为层级总开关(如 L1_heuristic),关闭时返回 0。
|
|
547
|
+
function countEnabled(lr, layerKey) {
|
|
503
548
|
if (!lr) return 0;
|
|
549
|
+
if (layerKey && state.config.layers?.[layerKey] === false) return 0;
|
|
504
550
|
return Object.values(lr).reduce((s, c) => s + ((c.rules || []).filter(r => r.enabled && c.category_enabled !== false).length), 0);
|
|
505
551
|
}
|
|
506
552
|
function countSkills(ls) { if (!ls) return 0; return Object.values(ls).reduce((s, c) => s + (c.skills ? c.skills.length : 0), 0); }
|
|
507
|
-
|
|
553
|
+
// 同 countEnabled:层级关闭时 L3 skills 启用数归 0。
|
|
554
|
+
function countEnabledSkills(ls, layerKey) {
|
|
508
555
|
if (!ls) return 0;
|
|
556
|
+
if (layerKey && state.config.layers?.[layerKey] === false) return 0;
|
|
509
557
|
return Object.values(ls).reduce((s, c) => s + ((c.skills || []).filter(s => s.enabled && c.category_enabled !== false).length), 0);
|
|
510
558
|
}
|
|
511
559
|
|
|
@@ -513,11 +561,13 @@ function countEnabledSkills(ls) {
|
|
|
513
561
|
async function toggleLayer(key) {
|
|
514
562
|
try {
|
|
515
563
|
const cur = state.config.layers[key] !== false;
|
|
516
|
-
|
|
517
|
-
state.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
564
|
+
// PUT /api/layers 只返回 {layers, env_overrides}(不含规则数据),
|
|
565
|
+
// 不能用它整体覆盖 state.config,否则 L1/L2/L3 规则会丢失变成 0/0。
|
|
566
|
+
await apiPut('layers', { layer_key: key, enabled: !cur });
|
|
567
|
+
// 重新拉取完整 config,保证规则统计/列表不丢
|
|
568
|
+
await refreshConfig();
|
|
569
|
+
showToast(`${LAYER_NAMES[key]?.name || key} ${!cur ? t('已启用', 'enabled') : t('已禁用', 'disabled')}`);
|
|
570
|
+
} catch (e) { showToast(t('操作失败: ', 'Operation failed: ') + e.message, 'error'); }
|
|
521
571
|
}
|
|
522
572
|
|
|
523
573
|
// ===== 分类开关 =====
|
|
@@ -526,16 +576,16 @@ async function toggleCategory(layer, category) {
|
|
|
526
576
|
const cur = state.config[layer]?.[category]?.category_enabled !== false;
|
|
527
577
|
state.config = await apiPut('categories/enabled', { layer, category, enabled: !cur });
|
|
528
578
|
renderCurrent();
|
|
529
|
-
showToast('分类已' + (!cur ? '启用' : '禁用'));
|
|
530
|
-
} catch (e) { showToast('操作失败: ' + e.message, 'error'); }
|
|
579
|
+
showToast(t('分类已', 'Category ') + (!cur ? t('启用', 'enabled') : t('禁用', 'disabled')));
|
|
580
|
+
} catch (e) { showToast(t('操作失败: ', 'Operation failed: ') + e.message, 'error'); }
|
|
531
581
|
}
|
|
532
582
|
async function toggleSkillCategory(category) {
|
|
533
583
|
try {
|
|
534
584
|
const cur = state.config.L3_skills?.[category]?.category_enabled !== false;
|
|
535
585
|
state.config = await apiPut('skill-categories/enabled', { category, enabled: !cur });
|
|
536
586
|
renderCurrent();
|
|
537
|
-
showToast('Skill 分类已' + (!cur ? '启用' : '禁用'));
|
|
538
|
-
} catch (e) { showToast('操作失败: ' + e.message, 'error'); }
|
|
587
|
+
showToast(t('Skill 分类已', 'Skill category ') + (!cur ? t('启用', 'enabled') : t('禁用', 'disabled')));
|
|
588
|
+
} catch (e) { showToast(t('操作失败: ', 'Operation failed: ') + e.message, 'error'); }
|
|
539
589
|
}
|
|
540
590
|
|
|
541
591
|
// ===== 折叠展开 =====
|
|
@@ -550,32 +600,39 @@ function toggleExpand(id) {
|
|
|
550
600
|
}
|
|
551
601
|
|
|
552
602
|
// ===== L1/L2 规则页 =====
|
|
603
|
+
// 规则层容器 -> 层级总开关 key 的映射(用于大类开关随层级开关联动)
|
|
604
|
+
const LAYER_SWITCH_KEY = { L1_rules: 'L1_heuristic', L2_rules: 'L2_logic_rules', L3_skills: 'L3_llm_analysis' };
|
|
553
605
|
function renderRuleLayer(layerKey, containerId, categoryNames) {
|
|
554
606
|
const layer = state.config[layerKey] || {};
|
|
555
|
-
//
|
|
556
|
-
|
|
607
|
+
// 层级总开关:关闭时整层大类开关随动显示为关(仅 UI 联动,不改后端数据)
|
|
608
|
+
const layerSwitchKey = LAYER_SWITCH_KEY[layerKey];
|
|
609
|
+
const layerEnabled = layerSwitchKey ? state.config.layers?.[layerSwitchKey] !== false : true;
|
|
557
610
|
const orderedEntries = [
|
|
558
611
|
...Object.keys(categoryNames).filter(k => k in layer).map(k => [k, layer[k]]),
|
|
559
612
|
...Object.entries(layer).filter(([k]) => !(k in categoryNames)),
|
|
560
613
|
];
|
|
561
|
-
let html = `<div class="ag-card" style="padding:8px 16px"><button class="ag-btn ag-btn-primary" onclick="showAddRuleModal('${layerKey}')">+
|
|
614
|
+
let html = `<div class="ag-card" style="padding:8px 16px"><button class="ag-btn ag-btn-primary" onclick="showAddRuleModal('${layerKey}')">+ ${t('新增规则', 'Add Rule')}</button></div>`;
|
|
562
615
|
for (const [catName, catData] of orderedEntries) {
|
|
563
616
|
const displayName = categoryNames[catName] || catName;
|
|
564
617
|
const rules = catData.rules || [];
|
|
565
618
|
const catEnabled = catData.category_enabled !== false;
|
|
566
|
-
|
|
619
|
+
// 大类总开关显示状态 = 层级开 && 大类开;层级关时大类开关需禁用(无意义)
|
|
620
|
+
const catSwitchOn = layerEnabled && catEnabled;
|
|
621
|
+
const catSwitchDisabled = !layerEnabled;
|
|
622
|
+
// 大类计数与后端执行口径一致:大类总开关关闭时启用数归 0(分母仍为规则总数)
|
|
623
|
+
const enabledCount = catSwitchOn ? rules.filter(r => r.enabled).length : 0;
|
|
567
624
|
const expanded = expandedCats.has(`${containerId}-${catName}`);
|
|
568
625
|
html += `<div class="ag-card">
|
|
569
626
|
<div class="ag-cat-header" onclick="toggleExpand('${containerId}-${catName}')">
|
|
570
|
-
<div class="ag-cat-name">${escapeHtml(displayName)} <span class="ag-cat-count">${enabledCount}/${rules.length}
|
|
627
|
+
<div class="ag-cat-name">${escapeHtml(displayName)} <span class="ag-cat-count">${enabledCount}/${rules.length} ${t('启用', 'enabled')}</span></div>
|
|
571
628
|
<div class="ag-cat-controls">
|
|
572
|
-
<div onclick="event.stopPropagation();toggleCategory('${layerKey}','${catName}')">${toggle(
|
|
629
|
+
<div onclick="event.stopPropagation();toggleCategory('${layerKey}','${catName}')">${toggle(catSwitchOn, catSwitchDisabled, '')}</div>
|
|
573
630
|
<span class="ag-expand-icon ${expanded ? 'open' : ''}" id="expand-${containerId}-${catName}">▼</span>
|
|
574
631
|
</div>
|
|
575
632
|
</div>
|
|
576
633
|
<div class="ag-rule-list" id="list-${containerId}-${catName}" style="display:${expanded ? '' : 'none'}">`;
|
|
577
634
|
if (!rules.length) {
|
|
578
|
-
html +=
|
|
635
|
+
html += `<div class="ag-empty">${t('无规则', 'No rules')}</div>`;
|
|
579
636
|
} else {
|
|
580
637
|
for (const r of rules) {
|
|
581
638
|
html += renderRuleItem(layerKey, catName, r);
|
|
@@ -590,21 +647,21 @@ function renderRuleItem(layerKey, catName, r) {
|
|
|
590
647
|
const isL2Sensitive = isL2 && catName === 'sensitive_path_access';
|
|
591
648
|
let patternDisplay = r.pattern || r.keyword || r.path || (r.intent_keywords && r.intent_keywords.join(', ')) || r.reason || r.id || '';
|
|
592
649
|
let reasonDisplay = r.reason || r.desc || '';
|
|
593
|
-
let patternLabel = '规则';
|
|
650
|
+
let patternLabel = t('规则', 'Rule');
|
|
594
651
|
if (isL2) {
|
|
595
|
-
if (r.intent_keywords) patternLabel = '触发关键词';
|
|
596
|
-
else if (r.path) patternLabel = '敏感路径';
|
|
597
|
-
else if (r.pattern) patternLabel = '检测模式';
|
|
652
|
+
if (r.intent_keywords) patternLabel = t('触发关键词', 'Trigger Keywords');
|
|
653
|
+
else if (r.path) patternLabel = t('敏感路径', 'Sensitive Path');
|
|
654
|
+
else if (r.pattern) patternLabel = t('检测模式', 'Detection Pattern');
|
|
598
655
|
}
|
|
599
|
-
const reasonLabel = isL2 ? '规则说明' : '原因';
|
|
600
|
-
const tooltip = `${patternLabel}: ${patternDisplay}\n${reasonLabel}: ${reasonDisplay || '(无)'}\nID: ${r.id}`;
|
|
656
|
+
const reasonLabel = isL2 ? t('规则说明', 'Rule Description') : t('原因', 'Reason');
|
|
657
|
+
const tooltip = `${patternLabel}: ${patternDisplay}\n${reasonLabel}: ${reasonDisplay || t('(无)', '(none)')}\nID: ${r.id}`;
|
|
601
658
|
const denyModeSelect = isL2Sensitive ? `<select class="ag-deny-select" onchange="updateDenyMode('${layerKey}','${catName}','${r.id}',this.value)" ${r.enabled ? '' : 'disabled'}>
|
|
602
|
-
<option value="deny_write" ${(r.deny_mode || 'deny_write') === 'deny_write' ? 'selected' : ''}
|
|
603
|
-
<option value="deny_read" ${(r.deny_mode || 'deny_write') === 'deny_read' ? 'selected' : ''}
|
|
604
|
-
<option value="deny_both" ${(r.deny_mode || 'deny_write') === 'deny_both' ? 'selected' : ''}
|
|
659
|
+
<option value="deny_write" ${(r.deny_mode || 'deny_write') === 'deny_write' ? 'selected' : ''}>${t('仅拦截写入', 'Block Write Only')}</option>
|
|
660
|
+
<option value="deny_read" ${(r.deny_mode || 'deny_write') === 'deny_read' ? 'selected' : ''}>${t('仅拦截读取', 'Block Read Only')}</option>
|
|
661
|
+
<option value="deny_both" ${(r.deny_mode || 'deny_write') === 'deny_both' ? 'selected' : ''}>${t('读写均拦截', 'Block Both')}</option>
|
|
605
662
|
</select>` : '';
|
|
606
|
-
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>` : '';
|
|
607
|
-
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 ? '此规则已从默认规则集移除,可删除清理' : '删除自定义规则'}"
|
|
663
|
+
const skipL3 = isL2 ? `<label class="ag-skip-l3" title="${t('禁用该规则时,L3 对相关路径/关键词做豁免放行(但仍分析命令其他部分的安全风险)。关闭则 L3 可能独立拦截', 'When this rule is disabled, L3 exempts the related path/keyword (but still analyzes other parts of the command). Turning off lets L3 block independently')}"><input type="checkbox" ${r.skip_l3_on_disabled ? 'checked' : ''} onchange="updateSkipL3('${layerKey}','${catName}','${r.id}',this.checked)"> ${t('禁用时豁免L3', 'Exempt L3 when disabled')}</label>` : '';
|
|
664
|
+
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 ? t('此规则已从默认规则集移除,可删除清理', 'This rule was removed from the default set; you may delete it') : t('删除自定义规则', 'Delete custom rule')}">${t('删除', 'Delete')}</button>` : '';
|
|
608
665
|
return `<div class="ag-rule-item" title="${escapeHtml(tooltip)}">
|
|
609
666
|
<div class="ag-rule-body">
|
|
610
667
|
<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>
|
|
@@ -621,50 +678,56 @@ async function toggleRule(layer, cat, id, en) {
|
|
|
621
678
|
try {
|
|
622
679
|
state.config = await apiPut('rules/enabled', { layer, category: cat, rule_id: id, enabled: en });
|
|
623
680
|
renderCurrent(); if (state.currentPage === 'search') searchRules();
|
|
624
|
-
showToast('规则 ' + id + ' 已' + (en ? '启用' : '禁用'));
|
|
625
|
-
} catch (e) { showToast('操作失败: ' + e.message, 'error'); }
|
|
681
|
+
showToast(t('规则 ', 'Rule ') + id + t(' 已', ' ') + (en ? t('启用', 'enabled') : t('禁用', 'disabled')));
|
|
682
|
+
} catch (e) { showToast(t('操作失败: ', 'Operation failed: ') + e.message, 'error'); }
|
|
626
683
|
}
|
|
627
684
|
async function updateDenyMode(layer, cat, id, mode) {
|
|
628
685
|
try {
|
|
629
686
|
state.config = await apiPut('rules/deny_mode', { layer, category: cat, rule_id: id, deny_mode: mode });
|
|
630
687
|
renderCurrent(); if (state.currentPage === 'search') searchRules();
|
|
631
|
-
showToast('拦截模式已更新为 ' + denyModeLabel(mode));
|
|
632
|
-
} catch (e) { showToast('操作失败: ' + e.message, 'error'); }
|
|
688
|
+
showToast(t('拦截模式已更新为 ', 'Interception mode updated to ') + denyModeLabel(mode));
|
|
689
|
+
} catch (e) { showToast(t('操作失败: ', 'Operation failed: ') + e.message, 'error'); }
|
|
633
690
|
}
|
|
634
691
|
async function updateSkipL3(layer, cat, id, skip) {
|
|
635
692
|
try {
|
|
636
693
|
state.config = await apiPut('rules/skip_l3', { layer, category: cat, rule_id: id, skip_l3_on_disabled: skip });
|
|
637
694
|
renderCurrent(); if (state.currentPage === 'search') searchRules();
|
|
638
|
-
showToast('规则 ' + id + ' 禁用时豁免L3 已' + (skip ? '开启' : '关闭'));
|
|
639
|
-
} catch (e) { showToast('操作失败: ' + e.message, 'error'); }
|
|
695
|
+
showToast(t('规则 ', 'Rule ') + id + t(' 禁用时豁免L3 已', ' disable-exempt-L3 ') + (skip ? t('开启', 'ON') : t('关闭', 'OFF')));
|
|
696
|
+
} catch (e) { showToast(t('操作失败: ', 'Operation failed: ') + e.message, 'error'); }
|
|
640
697
|
}
|
|
641
698
|
async function deleteRule(layer, cat, id) {
|
|
642
|
-
// 找到该规则判断是自定义还是已移除,给不同确认文案
|
|
643
699
|
const layerData = (state.config[layer] || {})[cat] || {};
|
|
644
700
|
const rule = (layerData.rules || []).find(r => r.id === id) || {};
|
|
645
701
|
const msg = rule.source_removed
|
|
646
|
-
? '确定删除已移除的规则 "' + id + '"?\n(此规则已从默认规则集移除,删除可清理历史保留。下次升级不会再恢复)'
|
|
647
|
-
: '确定删除自定义规则 "' + id + '"?';
|
|
702
|
+
? t('确定删除已移除的规则 "', 'Delete removed rule "') + id + t('"?\n(此规则已从默认规则集移除,删除可清理历史保留。下次升级不会再恢复)', '"?\n(This rule was removed from the default set. Deleting cleans up the historical entry. It will not be restored on the next upgrade.)')
|
|
703
|
+
: t('确定删除自定义规则 "', 'Delete custom rule "') + id + t('"?', '"?');
|
|
648
704
|
if (!confirm(msg)) return;
|
|
649
705
|
try {
|
|
650
706
|
state.config = await apiDelete('rules', { layer, category: cat, rule_id: id });
|
|
651
|
-
renderCurrent(); showToast('规则 ' + id + ' 已删除');
|
|
652
|
-
} catch (e) { showToast('删除失败: ' + e.message, 'error'); }
|
|
707
|
+
renderCurrent(); showToast(t('规则 ', 'Rule ') + id + t(' 已删除', ' deleted'));
|
|
708
|
+
} catch (e) { showToast(t('删除失败: ', 'Delete failed: ') + e.message, 'error'); }
|
|
653
709
|
}
|
|
654
710
|
|
|
655
711
|
// ===== Agent 定制规则页 =====
|
|
656
712
|
const AGENT_CATEGORY_NAMES = {
|
|
657
|
-
builtin_safe_action_types: '安全操作白名单(跳 L2+L3)',
|
|
658
|
-
builtin_safe_skills: '信任内置 Skill(跳 L2+L3)',
|
|
659
|
-
extra_command_patterns: '专属危险命令正则(L1 追加)',
|
|
660
|
-
extra_injection_keywords: '专属注入关键词(L1 追加)',
|
|
661
|
-
extra_sensitive_paths: '专属敏感路径(L1 追加)',
|
|
713
|
+
builtin_safe_action_types: t('安全操作白名单(跳 L2+L3)', 'Safe Action Whitelist (skip L2+L3)'),
|
|
714
|
+
builtin_safe_skills: t('信任内置 Skill(跳 L2+L3)', 'Trusted Built-in Skills (skip L2+L3)'),
|
|
715
|
+
extra_command_patterns: t('专属危险命令正则(L1 追加)', 'Dedicated Dangerous Command Regex (L1 extra)'),
|
|
716
|
+
extra_injection_keywords: t('专属注入关键词(L1 追加)', 'Dedicated Injection Keywords (L1 extra)'),
|
|
717
|
+
extra_sensitive_paths: t('专属敏感路径(L1 追加)', 'Dedicated Sensitive Paths (L1 extra)'),
|
|
662
718
|
};
|
|
663
719
|
function render_agents() {
|
|
664
720
|
const agents = (state.agentRules && state.agentRules.agents) || {};
|
|
665
|
-
|
|
721
|
+
// 有 instance(当前是某 agent 调用)时,只显示该 agent 的定制规则(即使无规则也显示空,
|
|
722
|
+
// 避免出现别的 agent 的规则造成困惑);无 instance(本地独立起/非 agent 调用)显示全部。
|
|
723
|
+
let ids;
|
|
724
|
+
if (state.instance) {
|
|
725
|
+
ids = agents[state.instance] ? [state.instance] : [];
|
|
726
|
+
} else {
|
|
727
|
+
ids = Object.keys(agents);
|
|
728
|
+
}
|
|
666
729
|
if (!ids.length) {
|
|
667
|
-
el('page-agents').innerHTML =
|
|
730
|
+
el('page-agents').innerHTML = `<div class="ag-card"><div class="ag-empty">${t('无 agent 定制规则', 'No agent custom rules')}</div></div>`;
|
|
668
731
|
return;
|
|
669
732
|
}
|
|
670
733
|
let html = '';
|
|
@@ -679,18 +742,19 @@ function render_agents() {
|
|
|
679
742
|
const rules = catData.rules || [];
|
|
680
743
|
const catEnabled = catData.category_enabled !== false;
|
|
681
744
|
const expanded = expandedCats.has(`agents-${agentId}-${catName}`);
|
|
682
|
-
|
|
745
|
+
// 大类计数与后端执行口径一致:大类总开关关闭时启用数归 0
|
|
746
|
+
const enCount = catEnabled ? rules.filter(r => r.enabled).length : 0;
|
|
683
747
|
html += `<div class="ag-cat-header" onclick="toggleExpand('agents-${agentId}-${catName}')">
|
|
684
|
-
<div class="ag-cat-name">${escapeHtml(AGENT_CATEGORY_NAMES[catName] || catName)} <span class="ag-cat-count">${enCount}/${rules.length}
|
|
748
|
+
<div class="ag-cat-name">${escapeHtml(AGENT_CATEGORY_NAMES[catName] || catName)} <span class="ag-cat-count">${enCount}/${rules.length} ${t('启用', 'enabled')}</span></div>
|
|
685
749
|
<div class="ag-cat-controls"><div onclick="event.stopPropagation();toggleAgentCategory('${agentId}','${catName}')">${toggle(catEnabled, false, '')}</div><span class="ag-expand-icon ${expanded ? 'open' : ''}">▼</span></div>
|
|
686
750
|
</div>
|
|
687
751
|
<div class="ag-rule-list" id="list-agents-${agentId}-${catName}" style="display:${expanded ? '' : 'none'}">`;
|
|
688
752
|
if (!rules.length) {
|
|
689
|
-
html +=
|
|
753
|
+
html += `<div class="ag-empty">${t('无规则', 'No rules')}</div>`;
|
|
690
754
|
} else {
|
|
691
755
|
for (const r of rules) html += renderAgentRuleItem(agentId, catName, r);
|
|
692
756
|
}
|
|
693
|
-
html += `<div style="padding:8px 0"><button class="ag-btn ag-btn-ghost" onclick="showAddAgentRuleModal('${agentId}','${catName}')">+
|
|
757
|
+
html += `<div style="padding:8px 0"><button class="ag-btn ag-btn-ghost" onclick="showAddAgentRuleModal('${agentId}','${catName}')">+ ${t('新增', 'Add')}</button></div>`;
|
|
694
758
|
html += '</div>';
|
|
695
759
|
}
|
|
696
760
|
html += '</div>';
|
|
@@ -698,21 +762,16 @@ function render_agents() {
|
|
|
698
762
|
el('page-agents').innerHTML = html;
|
|
699
763
|
}
|
|
700
764
|
function renderAgentRuleItem(agentId, catName, r) {
|
|
701
|
-
// Agent 定制规则按类别字段不同,分别提取展示维度:
|
|
702
|
-
// builtin_safe_action_types / builtin_safe_skills → value(白名单,无 risk)
|
|
703
|
-
// extra_command_patterns → pattern + risk_level + risk_type + reason
|
|
704
|
-
// extra_injection_keywords → keyword + risk_level + lang
|
|
705
|
-
// extra_sensitive_paths → path + risk_level + desc
|
|
706
765
|
const isValue = catName === 'builtin_safe_action_types' || catName === 'builtin_safe_skills';
|
|
707
766
|
const patternDisplay = r.value || r.pattern || r.keyword || r.path || r.id || JSON.stringify(r).slice(0, 60);
|
|
708
767
|
const reasonDisplay = r.reason || r.desc || r.lang || '';
|
|
709
|
-
const tagLine = (r.builtin !== false ?
|
|
710
|
-
+ (r.source_removed ?
|
|
768
|
+
const tagLine = (r.builtin !== false ? `<span class="ag-badge ag-badge-builtin" title="${t('源码内置规则', 'Built-in source rule')}">${t('内置', 'Built-in')}</span>` : `<span class="ag-badge ag-badge-custom">${t('自定义', 'Custom')}</span>`)
|
|
769
|
+
+ (r.source_removed ? `<span class="ag-badge ag-badge-removed" title="${t('此规则已从默认规则集移除,仅作历史保留', 'This rule was removed from the default set and is kept as history')}">${t('已移除', 'Removed')}</span>` : '')
|
|
711
770
|
+ (isValue ? '' : (riskBadge(r.risk_level) + (r.risk_type ? riskTypeBadge(r.risk_type) : '')));
|
|
712
|
-
const patternLabel = isValue ? '白名单' : (r.path ? '敏感路径' : (r.keyword ? '触发关键词' : (
|
|
713
|
-
const reasonLabel = r.reason || r.desc ? '原因' : (r.lang ? '语言' : '说明');
|
|
714
|
-
const tooltip = `${patternLabel}: ${patternDisplay}\n${reasonLabel}: ${reasonDisplay || '(无)'}`;
|
|
715
|
-
const delBtn = r.builtin ? '' : `<button class="ag-btn ag-btn-ghost ag-btn-sm" onclick="deleteAgentRule('${agentId}','${catName}',${JSON.stringify(r).replace(/"/g,'"')})"
|
|
771
|
+
const patternLabel = isValue ? t('白名单', 'Whitelist') : (r.path ? t('敏感路径', 'Sensitive Path') : (r.keyword ? t('触发关键词', 'Trigger Keywords') : t('规则', 'Rule')));
|
|
772
|
+
const reasonLabel = r.reason || r.desc ? t('原因', 'Reason') : (r.lang ? t('语言', 'Language') : t('说明', 'Description'));
|
|
773
|
+
const tooltip = `${patternLabel}: ${patternDisplay}\n${reasonLabel}: ${reasonDisplay || t('(无)', '(none)')}`;
|
|
774
|
+
const delBtn = r.builtin ? '' : `<button class="ag-btn ag-btn-ghost ag-btn-sm" onclick="deleteAgentRule('${agentId}','${catName}',${JSON.stringify(r).replace(/"/g,'"')})">${t('删除', 'Delete')}</button>`;
|
|
716
775
|
return `<div class="ag-rule-item" title="${escapeHtml(tooltip)}">
|
|
717
776
|
<div class="ag-rule-body">
|
|
718
777
|
<div class="ag-rule-tags">${tagLine}</div>
|
|
@@ -740,7 +799,7 @@ async function toggleAgentCategory(agentId, cat) {
|
|
|
740
799
|
}
|
|
741
800
|
async function deleteAgentRule(agentId, cat, r) {
|
|
742
801
|
const fp = { value: r.value, pattern: r.pattern, keyword: r.keyword, path: r.path };
|
|
743
|
-
if (!confirm(
|
|
802
|
+
if (!confirm(t('删除规则 ', 'Delete rule ') + (r.value || r.pattern || r.keyword || r.path || '') + '?')) return;
|
|
744
803
|
try {
|
|
745
804
|
state.agentRules = await apiDelete('agents/rules', { agent_id: agentId, category: cat, ...fp });
|
|
746
805
|
render_agents();
|
|
@@ -749,44 +808,50 @@ async function deleteAgentRule(agentId, cat, r) {
|
|
|
749
808
|
function showAddAgentRuleModal(agentId, cat) {
|
|
750
809
|
const isValueCat = cat === 'builtin_safe_action_types' || cat === 'builtin_safe_skills';
|
|
751
810
|
const body = isValueCat
|
|
752
|
-
? `<input class="ag-input" id="ar-value" placeholder="值(前缀尾随 - 或精确工具名)" style="width:100%">`
|
|
753
|
-
: `<input class="ag-input" id="ar-pattern" placeholder="正则/关键词/路径" style="width:100%;margin-bottom:8px">
|
|
811
|
+
? `<input class="ag-input" id="ar-value" placeholder="${t('值(前缀尾随 - 或精确工具名)', 'Value (prefix/suffix - or exact tool name)')}" style="width:100%">`
|
|
812
|
+
: `<input class="ag-input" id="ar-pattern" placeholder="${t('正则/关键词/路径', 'Regex/keyword/path')}" style="width:100%;margin-bottom:8px">
|
|
754
813
|
<input class="ag-input" id="ar-risk_level" placeholder="risk_level (high/medium/...)" style="width:100%">`;
|
|
755
|
-
openModal(
|
|
814
|
+
openModal(t('新增 ', 'Add ') + agentId + ' / ' + cat + t(' 规则', ' rule'), body, true);
|
|
756
815
|
currentModalAction = { type: 'agent_rule', agentId, cat, isValueCat };
|
|
757
816
|
}
|
|
758
817
|
|
|
759
818
|
// ===== L3 Skills 页 =====
|
|
760
819
|
function render_l3() {
|
|
761
820
|
const l3 = state.config.L3_skills || {};
|
|
762
|
-
|
|
821
|
+
// 层级总开关:关闭时 L3 大类开关随动显示为关(仅 UI 联动,不改后端数据)
|
|
822
|
+
const layerEnabled = state.config.layers?.['L3_llm_analysis'] !== false;
|
|
823
|
+
let html = `<div class="ag-card" style="padding:8px 16px"><button class="ag-btn ag-btn-primary" onclick="showAddSkillModal()">+ ${t('新增 Skill', 'Add Skill')}</button></div>`;
|
|
763
824
|
for (const [catName, catData] of Object.entries(l3)) {
|
|
764
825
|
const displayName = L3_CATEGORY_NAMES[catName] || catName;
|
|
765
826
|
const skills = catData.skills || [];
|
|
766
827
|
const catEnabled = catData.category_enabled !== false;
|
|
767
|
-
|
|
828
|
+
// 大类总开关显示状态 = 层级开 && 大类开;层级关时大类开关需禁用(无意义)
|
|
829
|
+
const catSwitchOn = layerEnabled && catEnabled;
|
|
830
|
+
const catSwitchDisabled = !layerEnabled;
|
|
831
|
+
// 大类计数与后端执行口径一致:大类总开关关闭时启用数归 0
|
|
832
|
+
const enabledCount = catSwitchOn ? skills.filter(s => s.enabled).length : 0;
|
|
768
833
|
const expanded = expandedCats.has('page-l3-' + catName);
|
|
769
834
|
html += `<div class="ag-card">
|
|
770
835
|
<div class="ag-cat-header" onclick="toggleExpand('page-l3-${catName}')">
|
|
771
|
-
<div class="ag-cat-name">${escapeHtml(displayName)} <span class="ag-cat-count">${enabledCount}/${skills.length}
|
|
836
|
+
<div class="ag-cat-name">${escapeHtml(displayName)} <span class="ag-cat-count">${enabledCount}/${skills.length} ${t('启用', 'enabled')}</span></div>
|
|
772
837
|
<div class="ag-cat-controls">
|
|
773
|
-
<div onclick="event.stopPropagation();toggleSkillCategory('${catName}')">${toggle(
|
|
838
|
+
<div onclick="event.stopPropagation();toggleSkillCategory('${catName}')">${toggle(catSwitchOn, catSwitchDisabled, '')}</div>
|
|
774
839
|
<span class="ag-expand-icon ${expanded ? 'open' : ''}" id="expand-page-l3-${catName}">▼</span>
|
|
775
840
|
</div>
|
|
776
841
|
</div>
|
|
777
842
|
<div class="ag-rule-list" id="list-page-l3-${catName}" style="display:${expanded ? '' : 'none'}">`;
|
|
778
843
|
if (!skills.length) {
|
|
779
|
-
html +=
|
|
844
|
+
html += `<div class="ag-empty">${t('无 skill', 'No skill')}</div>`;
|
|
780
845
|
} else {
|
|
781
846
|
for (const s of skills) {
|
|
782
847
|
const kw = (s.keywords || []).join(', ');
|
|
783
|
-
const deleteBtn = s.builtin === false ? `<button class="ag-btn ag-btn-danger ag-btn-sm" onclick="deleteSkill('${s.id}','${catName}')"
|
|
848
|
+
const deleteBtn = s.builtin === false ? `<button class="ag-btn ag-btn-danger ag-btn-sm" onclick="deleteSkill('${s.id}','${catName}')">${t('删除', 'Delete')}</button>` : '';
|
|
784
849
|
html += `<div class="ag-rule-item">
|
|
785
850
|
<div class="ag-rule-body">
|
|
786
851
|
<div class="ag-rule-tags">${builtinBadge(s.builtin)} <strong>${escapeHtml(s.id)}</strong></div>
|
|
787
|
-
<div class="ag-rule-reason"><span class="ag-rule-field-label"
|
|
852
|
+
<div class="ag-rule-reason"><span class="ag-rule-field-label">${t('触发关键词:', 'Trigger keywords: ')}</span> ${escapeHtml(kw)}</div>
|
|
788
853
|
</div>
|
|
789
|
-
<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}')"
|
|
854
|
+
<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}')">${t('详情', 'Details')}</button>${deleteBtn}</div>
|
|
790
855
|
</div>`;
|
|
791
856
|
}
|
|
792
857
|
}
|
|
@@ -798,29 +863,29 @@ async function toggleSkill(skillId, en) {
|
|
|
798
863
|
try {
|
|
799
864
|
state.config = await apiPut('skills/enabled', { skill_id: skillId, enabled: en });
|
|
800
865
|
renderCurrent(); if (state.currentPage === 'search') searchRules();
|
|
801
|
-
showToast('Skill ' + skillId + ' 已' + (en ? '启用' : '禁用'));
|
|
802
|
-
} catch (e) { showToast('操作失败: ' + e.message, 'error'); }
|
|
866
|
+
showToast('Skill ' + skillId + t(' 已', ' ') + (en ? t('启用', 'enabled') : t('禁用', 'disabled')));
|
|
867
|
+
} catch (e) { showToast(t('操作失败: ', 'Operation failed: ') + e.message, 'error'); }
|
|
803
868
|
}
|
|
804
869
|
async function deleteSkill(skillId, cat) {
|
|
805
|
-
if (!confirm('确定删除自定义 Skill "' + skillId + '"?')) return;
|
|
870
|
+
if (!confirm(t('确定删除自定义 Skill "', 'Delete custom Skill "') + skillId + t('"?', '"? '))) return;
|
|
806
871
|
try {
|
|
807
872
|
state.config = await apiDelete('skills', { skill_id: skillId, category: cat });
|
|
808
|
-
renderCurrent(); showToast('Skill ' + skillId + ' 已删除');
|
|
809
|
-
} catch (e) { showToast('删除失败: ' + e.message, 'error'); }
|
|
873
|
+
renderCurrent(); showToast('Skill ' + skillId + t(' 已删除', ' deleted'));
|
|
874
|
+
} catch (e) { showToast(t('删除失败: ', 'Delete failed: ') + e.message, 'error'); }
|
|
810
875
|
}
|
|
811
876
|
async function showSkillDetail(skillId) {
|
|
812
|
-
openModal('🤖 Skill 详情', '
|
|
877
|
+
openModal(t('🤖 Skill 详情', '🤖 Skill Details'), `<p style="color:var(--ag-text-tertiary);padding:8px 0">${t('加载中...', 'Loading...')}</p>`, false);
|
|
813
878
|
el('modal').classList.add('ag-modal-wide');
|
|
814
879
|
try {
|
|
815
880
|
const data = await api('skills/' + encodeURIComponent(skillId) + '/content');
|
|
816
881
|
el('modal-body').innerHTML = renderSkillContent(data.content || '', skillId, data.path || '');
|
|
817
882
|
} catch (e) {
|
|
818
|
-
el('modal-body').innerHTML =
|
|
883
|
+
el('modal-body').innerHTML = `<p style="color:var(--ag-red);padding:8px 0">${t('加载失败: ', 'Load failed: ')}${escapeHtml(e.message)}</p>`;
|
|
819
884
|
}
|
|
820
885
|
}
|
|
821
886
|
function renderSkillContent(md, skillId, filePath) {
|
|
822
887
|
const lines = (md || '').split('\n');
|
|
823
|
-
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>
|
|
888
|
+
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 || t('(未知)', '(unknown)')) + '</strong></span><span class="ag-skill-meta-item">📏 <strong>' + lines.length + '</strong> ' + t('行', 'lines') + '</span></div><div class="ag-skill-content">';
|
|
824
889
|
let inCode = false, codeBuf = [], textBuf = [];
|
|
825
890
|
const flushText = () => { if (textBuf.length) { html += escapeHtml(textBuf.join('\n')); textBuf = []; } };
|
|
826
891
|
const flushCode = () => { if (codeBuf.length) { html += '<pre><code>' + escapeHtml(codeBuf.join('\n')) + '</code></pre>'; codeBuf = []; } };
|
|
@@ -841,16 +906,16 @@ function renderSkillContent(md, skillId, filePath) {
|
|
|
841
906
|
function render_search() {
|
|
842
907
|
el('page-search').innerHTML = `
|
|
843
908
|
<div class="ag-card">
|
|
844
|
-
<div class="ag-section-title"
|
|
909
|
+
<div class="ag-section-title">${t('全局规则搜索', 'Global Rule Search')}</div>
|
|
845
910
|
<div style="display:flex;gap:8px">
|
|
846
|
-
<input class="ag-input" id="search-input" placeholder="输入关键词搜索规则,如: sysfs, rm -rf, /etc/shadow, 数据外泄..." oninput="searchRules()" style="flex:1">
|
|
847
|
-
<button class="ag-btn ag-btn-primary" onclick="searchRules()"
|
|
848
|
-
<button class="ag-btn ag-btn-ghost" onclick="clearSearch()"
|
|
911
|
+
<input class="ag-input" id="search-input" placeholder="${t('输入关键词搜索规则,如: sysfs, rm -rf, /etc/shadow, 数据外泄...', 'Search rules by keyword, e.g. sysfs, rm -rf, /etc/shadow, data exfiltration...')}" oninput="searchRules()" style="flex:1">
|
|
912
|
+
<button class="ag-btn ag-btn-primary" onclick="searchRules()">${t('搜索', 'Search')}</button>
|
|
913
|
+
<button class="ag-btn ag-btn-ghost" onclick="clearSearch()">${t('清空', 'Clear')}</button>
|
|
849
914
|
</div>
|
|
850
|
-
<div class="ag-form-hint" style="margin-top:8px"
|
|
915
|
+
<div class="ag-form-hint" style="margin-top:8px">${t('搜索范围:L1 规则的正则/原因/风险类型、L2 规则的路径/描述、L3 Skill 的关键词/ID。命中后可直接启用或禁用对应规则。', 'Search scope: L1 rule regex/reason/risk type, L2 rule path/description, L3 Skill keyword/ID. You can enable or disable the matching rule directly.')}</div>
|
|
851
916
|
</div>
|
|
852
917
|
<div class="ag-card" id="search-results-card" style="display:none">
|
|
853
|
-
<div class="ag-section-title"
|
|
918
|
+
<div class="ag-section-title">${t('搜索结果', 'Search Results')} <span class="ag-cat-count" id="search-count">0 ${t('条', 'results')}</span></div>
|
|
854
919
|
<div id="search-results"></div>
|
|
855
920
|
</div>`;
|
|
856
921
|
setTimeout(() => { const inp = el('search-input'); if (inp) inp.focus(); }, 0);
|
|
@@ -878,38 +943,38 @@ function searchRules() {
|
|
|
878
943
|
if (hay.includes(q)) results.push({ layer: 'L3_skills', layerName: 'L3', category: cat, ...s, isSkill: true });
|
|
879
944
|
}
|
|
880
945
|
}
|
|
881
|
-
cnt.textContent = results.length + ' 条';
|
|
946
|
+
cnt.textContent = results.length + ' ' + t('条', 'results');
|
|
882
947
|
card.style.display = '';
|
|
883
|
-
if (!results.length) { list.innerHTML =
|
|
948
|
+
if (!results.length) { list.innerHTML = `<div class="ag-empty">${t('未找到匹配的规则', 'No matching rules found')}</div>`; return; }
|
|
884
949
|
list.innerHTML = results.map(renderSearchItem).join('');
|
|
885
950
|
}
|
|
886
951
|
function renderSearchItem(r) {
|
|
887
952
|
const isL2Sensitive = r.layer === 'L2_rules' && r.category === 'sensitive_path_access';
|
|
888
953
|
let ruleText = r.pattern || r.path || r.keyword || (r.keywords && r.keywords.join(', ')) || (r.intent_keywords && r.intent_keywords.join(', ')) || r.reason || r.desc || '';
|
|
889
954
|
const reason = r.reason || r.desc || '';
|
|
890
|
-
const tooltip = '规则: ' + ruleText + '\n原因: ' + (reason || '(无)') + '\nID: ' + r.id;
|
|
955
|
+
const tooltip = t('规则: ', 'Rule: ') + ruleText + t('\n原因: ', '\nReason: ') + (reason || t('(无)', '(none)')) + '\nID: ' + r.id;
|
|
891
956
|
const toggleFn = r.isSkill ? "toggleSkill('" + r.id + "'," + (!r.enabled) + ")" : "toggleRule('" + r.layer + "','" + r.category + "','" + r.id + "'," + (!r.enabled) + ")";
|
|
892
957
|
const denyModeSelect = isL2Sensitive ? `<select class="ag-deny-select" onchange="updateDenyMode('${r.layer}','${r.category}','${r.id}',this.value)" ${r.enabled ? '' : 'disabled'}>
|
|
893
|
-
<option value="deny_write" ${(r.deny_mode || 'deny_write') === 'deny_write' ? 'selected' : ''}
|
|
894
|
-
<option value="deny_read" ${(r.deny_mode || 'deny_write') === 'deny_read' ? 'selected' : ''}
|
|
895
|
-
<option value="deny_both" ${(r.deny_mode || 'deny_write') === 'deny_both' ? 'selected' : ''}
|
|
958
|
+
<option value="deny_write" ${(r.deny_mode || 'deny_write') === 'deny_write' ? 'selected' : ''}>${t('仅拦截写入', 'Block Write Only')}</option>
|
|
959
|
+
<option value="deny_read" ${(r.deny_mode || 'deny_write') === 'deny_read' ? 'selected' : ''}>${t('仅拦截读取', 'Block Read Only')}</option>
|
|
960
|
+
<option value="deny_both" ${(r.deny_mode || 'deny_write') === 'deny_both' ? 'selected' : ''}>${t('读写均拦截', 'Block Both')}</option>
|
|
896
961
|
</select>` : '';
|
|
897
|
-
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>` : '';
|
|
962
|
+
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)"> ${t('禁用时豁免L3', 'Exempt L3 when disabled')}</label>` : '';
|
|
898
963
|
return `<div class="ag-rule-item" title="${escapeHtml(tooltip)}">
|
|
899
964
|
<div class="ag-rule-body">
|
|
900
965
|
<div class="ag-rule-tags">
|
|
901
966
|
<span class="ag-layer-badge ag-layer-${r.layerName.toLowerCase()}">${r.layerName}</span>
|
|
902
967
|
${builtinBadge(r.builtin)} ${removedBadge(r.source_removed)} ${riskBadge(r.risk_level)} ${riskTypeBadge(r.risk_type)}
|
|
903
968
|
${isL2Sensitive ? denyModeBadge(r.deny_mode) + ' ' + sourceDenyModeBadges(r) : ''}
|
|
904
|
-
<span class="ag-search-cat"
|
|
969
|
+
<span class="ag-search-cat">${t('分类: ', 'Category: ')}${escapeHtml(r.category)}</span>
|
|
905
970
|
</div>
|
|
906
|
-
<div class="ag-rule-pattern"><span class="ag-rule-field-label"
|
|
907
|
-
${reason ?
|
|
971
|
+
<div class="ag-rule-pattern"><span class="ag-rule-field-label">${t('规则:', 'Rule:')}</span> ${escapeHtml(ruleText)}</div>
|
|
972
|
+
${reason ? `<div class="ag-rule-reason"><span class="ag-rule-field-label">${t('原因:', 'Reason:')}</span> ${escapeHtml(reason)}</div>` : ''}
|
|
908
973
|
<div class="ag-rule-id">ID: ${escapeHtml(r.id)}</div>
|
|
909
974
|
</div>
|
|
910
975
|
<div class="ag-rule-controls">
|
|
911
976
|
${denyModeSelect}
|
|
912
|
-
<span class="ag-status-text ${r.enabled ? 'ag-status-on' : 'ag-status-off'}">${r.enabled ? '已启用' : '已禁用'}</span>
|
|
977
|
+
<span class="ag-status-text ${r.enabled ? 'ag-status-on' : 'ag-status-off'}">${r.enabled ? t('已启用', 'Enabled') : t('已禁用', 'Disabled')}</span>
|
|
913
978
|
${toggle(r.enabled, false, toggleFn)}
|
|
914
979
|
${skipL3}
|
|
915
980
|
</div>
|
|
@@ -928,25 +993,26 @@ let trendChartInstance = null;
|
|
|
928
993
|
function render_token() {
|
|
929
994
|
el('page-token').innerHTML = `
|
|
930
995
|
<div class="ag-card">
|
|
931
|
-
<div class="ag-section-title"
|
|
996
|
+
<div class="ag-section-title">${t('Token 用量详细统计', 'Detailed Token Statistics')
|
|
997
|
+
}
|
|
932
998
|
<div style="margin-left:auto;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
|
933
|
-
<button class="ag-btn ag-btn-sm" data-days="1" onclick="loadTokenStats(1)"
|
|
934
|
-
<button class="ag-btn ag-btn-sm" data-days="7" onclick="loadTokenStats(7)"
|
|
935
|
-
<button class="ag-btn ag-btn-sm" data-days="30" onclick="loadTokenStats(30)"
|
|
936
|
-
<button class="ag-btn ag-btn-sm" data-days="0" onclick="loadTokenStats(0)"
|
|
999
|
+
<button class="ag-btn ag-btn-sm" data-days="1" onclick="loadTokenStats(1)">${t('今日', 'Today')}</button>
|
|
1000
|
+
<button class="ag-btn ag-btn-sm" data-days="7" onclick="loadTokenStats(7)">${t('近7天', '7 days')}</button>
|
|
1001
|
+
<button class="ag-btn ag-btn-sm" data-days="30" onclick="loadTokenStats(30)">${t('近30天', '30 days')}</button>
|
|
1002
|
+
<button class="ag-btn ag-btn-sm" data-days="0" onclick="loadTokenStats(0)">${t('全部', 'All')}</button>
|
|
937
1003
|
<span style="width:1px;height:20px;background:var(--ag-divider);margin:0 4px"></span>
|
|
938
|
-
<input type="date" id="token-date-from" class="ag-date-input" title="开始日期">
|
|
939
|
-
<span class="ag-date-sep"
|
|
940
|
-
<input type="date" id="token-date-to" class="ag-date-input" title="结束日期">
|
|
941
|
-
<button class="ag-btn ag-btn-sm" onclick="loadTokenStatsByRange()"
|
|
1004
|
+
<input type="date" id="token-date-from" class="ag-date-input" title="${t('开始日期', 'Start date')}">
|
|
1005
|
+
<span class="ag-date-sep">${t('至', 'to')}</span>
|
|
1006
|
+
<input type="date" id="token-date-to" class="ag-date-input" title="${t('结束日期', 'End date')}">
|
|
1007
|
+
<button class="ag-btn ag-btn-sm" onclick="loadTokenStatsByRange()">${t('查询', 'Query')}</button>
|
|
942
1008
|
</div>
|
|
943
1009
|
</div>
|
|
944
|
-
<div id="token-summary" class="ag-empty"
|
|
1010
|
+
<div id="token-summary" class="ag-empty">${t('加载中...', 'Loading...')}</div>
|
|
945
1011
|
</div>
|
|
946
|
-
<div class="ag-card"><div class="ag-section-title"
|
|
947
|
-
<div class="ag-card"><div class="ag-section-title"
|
|
948
|
-
<div class="ag-card"><div class="ag-section-title"
|
|
949
|
-
<div class="ag-card"><div class="ag-section-title"
|
|
1012
|
+
<div class="ag-card"><div class="ag-section-title">${t('按步骤分组', 'By Step')}</div><div id="token-by-step"></div></div>
|
|
1013
|
+
<div class="ag-card"><div class="ag-section-title">${t('按模型分组', 'By Model')}</div><div id="token-by-model"></div></div>
|
|
1014
|
+
<div class="ag-card"><div class="ag-section-title">${t('Token 消耗趋势(按模型)', 'Token Consumption Trend (by Model)')}</div><div id="token-trend-chart"></div></div>
|
|
1015
|
+
<div class="ag-card"><div class="ag-section-title">${t('最近调用记录', 'Recent Calls')}</div><div id="token-recent" class="ag-empty">${t('加载中...', 'Loading...')}</div></div>`;
|
|
950
1016
|
startTokenAutoRefresh();
|
|
951
1017
|
}
|
|
952
1018
|
function startTokenAutoRefresh() {
|
|
@@ -975,8 +1041,8 @@ async function loadTokenStats(days) {
|
|
|
975
1041
|
async function loadTokenStatsByRange() {
|
|
976
1042
|
const from = el('token-date-from') ? el('token-date-from').value : '';
|
|
977
1043
|
const to = el('token-date-to') ? el('token-date-to').value : '';
|
|
978
|
-
if (!from || !to) { showToast('请选择开始和结束日期', 'error'); return; }
|
|
979
|
-
if (from > to) { showToast('开始日期不能晚于结束日期', 'error'); return; }
|
|
1044
|
+
if (!from || !to) { showToast(t('请选择开始和结束日期', 'Please select start and end dates'), 'error'); return; }
|
|
1045
|
+
if (from > to) { showToast(t('开始日期不能晚于结束日期', 'Start date cannot be later than end date'), 'error'); return; }
|
|
980
1046
|
document.querySelectorAll('#page-token [data-days]').forEach(b => b.classList.remove('active'));
|
|
981
1047
|
stopTokenAutoRefresh();
|
|
982
1048
|
try {
|
|
@@ -988,18 +1054,18 @@ async function loadTokenStatsByRange() {
|
|
|
988
1054
|
renderTokenByModel(s);
|
|
989
1055
|
loadTokenTrend(null, from, to);
|
|
990
1056
|
loadTokenRecent();
|
|
991
|
-
} catch (e) { showToast('查询失败: ' + e.message, 'error'); }
|
|
1057
|
+
} catch (e) { showToast(t('查询失败: ', 'Query failed: ') + e.message, 'error'); }
|
|
992
1058
|
}
|
|
993
1059
|
function renderTokenSummary(s) {
|
|
994
1060
|
el('token-summary').innerHTML = '<div class="ag-stat-grid">' +
|
|
995
|
-
'<div class="ag-stat-card"><div class="label"
|
|
996
|
-
'<div class="ag-stat-card"><div class="label"
|
|
997
|
-
'<div class="ag-stat-card"><div class="label"
|
|
998
|
-
'<div class="ag-stat-card"><div class="label"
|
|
1061
|
+
'<div class="ag-stat-card"><div class="label">' + t('总 Token', 'Total Tokens') + '</div><div class="value">' + formatTokens(s.total_tokens) + '</div></div>' +
|
|
1062
|
+
'<div class="ag-stat-card"><div class="label">' + t('输入 Token', 'Prompt Tokens') + '</div><div class="value">' + formatTokens(s.total_prompt_tokens) + '</div></div>' +
|
|
1063
|
+
'<div class="ag-stat-card"><div class="label">' + t('输出 Token', 'Completion Tokens') + '</div><div class="value">' + formatTokens(s.total_completion_tokens) + '</div></div>' +
|
|
1064
|
+
'<div class="ag-stat-card"><div class="label">' + t('调用次数', 'Calls') + '</div><div class="value">' + s.total_calls + '</div></div></div>';
|
|
999
1065
|
}
|
|
1000
1066
|
function renderTokenByStep(s) {
|
|
1001
1067
|
const byStep = s.by_step || {};
|
|
1002
|
-
const known = [{ key: 'L3_security_judge', name: 'L3 安全判断' }, { key: 'step2_policy_gen', name: '策略生成' }];
|
|
1068
|
+
const known = [{ key: 'L3_security_judge', name: t('L3 安全判断', 'L3 Security Judge') }, { key: 'step2_policy_gen', name: t('策略生成', 'Policy Generation') }];
|
|
1003
1069
|
const rendered = new Set();
|
|
1004
1070
|
let html = '';
|
|
1005
1071
|
const total = s.total_tokens || 0;
|
|
@@ -1007,14 +1073,14 @@ function renderTokenByStep(s) {
|
|
|
1007
1073
|
const v = byStep[key] || { total_tokens: 0, calls: 0 };
|
|
1008
1074
|
rendered.add(key);
|
|
1009
1075
|
const pct = total > 0 ? (v.total_tokens / total * 100).toFixed(1) : 0;
|
|
1010
|
-
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>';
|
|
1076
|
+
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 + ' ' + t('次', 'calls') + ')</span><div class="ag-progress-bar"><div class="ag-progress-fill" style="width:' + pct + '%"></div></div></div>';
|
|
1011
1077
|
}
|
|
1012
1078
|
for (const [step, v] of Object.entries(byStep)) {
|
|
1013
1079
|
if (rendered.has(step)) continue;
|
|
1014
1080
|
const pct = total > 0 ? (v.total_tokens / total * 100).toFixed(1) : 0;
|
|
1015
|
-
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>';
|
|
1081
|
+
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 + ' ' + t('次', 'calls') + ')</span><div class="ag-progress-bar"><div class="ag-progress-fill" style="width:' + pct + '%"></div></div></div>';
|
|
1016
1082
|
}
|
|
1017
|
-
el('token-by-step').innerHTML = html ||
|
|
1083
|
+
el('token-by-step').innerHTML = html || `<div class="ag-empty">${t('无分项数据', 'No breakdown data')}</div>`;
|
|
1018
1084
|
}
|
|
1019
1085
|
function renderTokenByModel(s) {
|
|
1020
1086
|
const byModel = s.by_model || {};
|
|
@@ -1022,9 +1088,9 @@ function renderTokenByModel(s) {
|
|
|
1022
1088
|
let html = '';
|
|
1023
1089
|
for (const [model, v] of Object.entries(byModel)) {
|
|
1024
1090
|
const pct = total > 0 ? (v.total_tokens / total * 100).toFixed(1) : 0;
|
|
1025
|
-
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>';
|
|
1091
|
+
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 + ' ' + t('次', 'calls') + ')</span><div class="ag-progress-bar"><div class="ag-progress-fill green" style="width:' + pct + '%"></div></div></div>';
|
|
1026
1092
|
}
|
|
1027
|
-
el('token-by-model').innerHTML = html ||
|
|
1093
|
+
el('token-by-model').innerHTML = html || `<div class="ag-empty">${t('无分模型数据', 'No per-model data')}</div>`;
|
|
1028
1094
|
}
|
|
1029
1095
|
async function loadTokenTrend(days, fromOverride, toOverride) {
|
|
1030
1096
|
const trendEl = el('token-trend-chart');
|
|
@@ -1046,7 +1112,7 @@ function renderTokenTrend(data) {
|
|
|
1046
1112
|
if (!trendChartInstance) trendChartInstance = echarts.init(dom);
|
|
1047
1113
|
trendChartInstance.resize();
|
|
1048
1114
|
const dates = data.dates || [], models = data.models || [], series = data.series || {};
|
|
1049
|
-
const fmtTokens = (n) => { if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿'; if (n >= 1e4) return (n / 1e4).toFixed(1) + '万'; return String(n || 0); };
|
|
1115
|
+
const fmtTokens = (n) => { if (n >= 1e8) return (n / 1e8).toFixed(1) + t('亿', 'B'); if (n >= 1e4) return (n / 1e4).toFixed(1) + t('万', 'W'); return String(n || 0); };
|
|
1050
1116
|
let xMin, xMax;
|
|
1051
1117
|
if (dates.length > 0) { const d = 86400000; xMin = new Date(dates[0]).getTime() - d; xMax = new Date(dates[dates.length - 1]).getTime() + d; }
|
|
1052
1118
|
trendChartInstance.setOption({
|
|
@@ -1071,21 +1137,21 @@ async function loadTokenRecent() {
|
|
|
1071
1137
|
if (!recentEl) return;
|
|
1072
1138
|
try {
|
|
1073
1139
|
const records = await api('token-stats/recent?limit=10');
|
|
1074
|
-
if (!records || records.length === 0) { recentEl.innerHTML =
|
|
1075
|
-
let html = '<table class="ag-table"><tr><th
|
|
1140
|
+
if (!records || records.length === 0) { recentEl.innerHTML = `<div class="ag-empty">${t('暂无记录', 'No records yet')}</div>`; return; }
|
|
1141
|
+
let html = '<table class="ag-table"><tr><th>' + t('时间', 'Time') + '</th><th>' + t('步骤', 'Step') + '</th><th>' + t('模型', 'Model') + '</th><th>' + t('输入', 'Input') + '</th><th>' + t('输出', 'Output') + '</th><th>' + t('总计', 'Total') + '</th></tr>';
|
|
1076
1142
|
for (const r of records.reverse()) {
|
|
1077
|
-
const stepName = r.step === 'L3_security_judge' ? 'L3 安全判断' : r.step === 'step2_policy_gen' ? '策略生成' : escapeHtml(r.step);
|
|
1143
|
+
const stepName = r.step === 'L3_security_judge' ? t('L3 安全判断', 'L3 Security Judge') : r.step === 'step2_policy_gen' ? t('策略生成', 'Policy Generation') : escapeHtml(r.step);
|
|
1078
1144
|
const ts = r.timestamp ? escapeHtml(r.timestamp.slice(0, 19)) : '';
|
|
1079
1145
|
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>';
|
|
1080
1146
|
}
|
|
1081
1147
|
html += '</table>';
|
|
1082
1148
|
recentEl.innerHTML = html;
|
|
1083
|
-
} catch (e) { recentEl.innerHTML =
|
|
1149
|
+
} catch (e) { recentEl.innerHTML = `<div class="ag-empty">${t('加载失败', 'Load failed')}</div>`; }
|
|
1084
1150
|
}
|
|
1085
1151
|
async function resetTokenStats() {
|
|
1086
|
-
if (!confirm('确定清除所有 Token 统计数据?')) return;
|
|
1087
|
-
try { await apiPost('token-stats/reset'); showToast('Token 统计已清除'); loadTokenStats(7); }
|
|
1088
|
-
catch (e) { showToast('清除失败: ' + e.message, 'error'); }
|
|
1152
|
+
if (!confirm(t('确定清除所有 Token 统计数据?', 'Clear all Token statistics?'))) return;
|
|
1153
|
+
try { await apiPost('token-stats/reset'); showToast(t('Token 统计已清除', 'Token statistics cleared')); loadTokenStats(7); }
|
|
1154
|
+
catch (e) { showToast(t('清除失败: ', 'Clear failed: ') + e.message, 'error'); }
|
|
1089
1155
|
}
|
|
1090
1156
|
|
|
1091
1157
|
// ===== 设置页 =====
|
|
@@ -1093,37 +1159,38 @@ function render_settings() {
|
|
|
1093
1159
|
const eo = state.envOverrides || {};
|
|
1094
1160
|
let envHtml = '';
|
|
1095
1161
|
if (Object.keys(eo).length === 0) {
|
|
1096
|
-
envHtml =
|
|
1162
|
+
envHtml = `<div class="ag-empty">${t('当前没有环境变量覆盖', 'No environment variable overrides currently')}</div>`;
|
|
1097
1163
|
} else {
|
|
1098
1164
|
for (const [k, v] of Object.entries(eo)) {
|
|
1099
1165
|
const layerName = LAYER_NAMES[k]?.name || k;
|
|
1100
|
-
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>';
|
|
1166
|
+
envHtml += '<div class="ag-rule-item"><span class="ag-badge ' + (v.overridden ? 'ag-badge-critical' : 'ag-badge-low') + '">' + (v.overridden ? t('强制禁用', 'Forced disabled') : t('无覆盖', 'No override')) + '</span><span style="font-size:13px">' + layerName + ': ' + escapeHtml(v.env_var || '') + '=' + escapeHtml(v.env_value || '') + '</span></div>';
|
|
1101
1167
|
}
|
|
1102
1168
|
}
|
|
1103
1169
|
el('page-settings').innerHTML = `
|
|
1170
|
+
${Object.keys(eo).length > 0 ? `
|
|
1104
1171
|
<div class="ag-card">
|
|
1105
|
-
<div class="ag-section-title"
|
|
1106
|
-
<div class="ag-section-desc"
|
|
1172
|
+
<div class="ag-section-title">${t('环境变量覆盖', 'Environment Variable Overrides')}</div>
|
|
1173
|
+
<div class="ag-section-desc">${t('环境变量优先级最高,会覆盖 Web UI 的设置。清除环境变量后 Web 设置才能生效。', 'Environment variables take highest priority and override Web UI settings. Web settings take effect only after clearing the environment variables.')}</div>
|
|
1107
1174
|
${envHtml}
|
|
1108
|
-
</div
|
|
1175
|
+
</div>` : ''}
|
|
1109
1176
|
<div class="ag-card">
|
|
1110
|
-
<div class="ag-section-title"
|
|
1177
|
+
<div class="ag-section-title">${t('配置文件', 'Config File')}</div>
|
|
1178
|
+
<div class="ag-section-desc">${t('你在本控制台做的所有修改(层级开关、各类规则、Agent 定制)都会自动保存到下面的配置文件中。它是策略的“存档”,可备份、迁移,或复制到其他机器复用同一套安全策略。', 'All your changes here (layer switches, rules, agent customizations) are saved automatically to the config file below. It is the "source of truth" for your policy — you can back it up, migrate it, or copy it to another machine to reuse the same security policy.')}</div>
|
|
1111
1179
|
<div style="font-size:13px;line-height:1.8">
|
|
1112
|
-
<
|
|
1113
|
-
<p>环境变量 AGENT_MOSS_RUNTIME_CONFIG_PATH 可指定自定义路径</p>
|
|
1114
|
-
<p>优先级: 环境变量 > runtime JSON > config.json > 源码默认值</p>
|
|
1180
|
+
<code>~/.config/agentmoss/agent_moss_runtime.json</code>
|
|
1115
1181
|
</div>
|
|
1116
1182
|
</div>
|
|
1183
|
+
${state.consoleAuthRequired ? `
|
|
1117
1184
|
<div class="ag-card">
|
|
1118
|
-
<div class="ag-section-title"
|
|
1119
|
-
<div class="ag-section-desc"
|
|
1120
|
-
<input class="ag-input" id="settings-token" placeholder="
|
|
1121
|
-
<div style="margin-top:8px"><button class="ag-btn ag-btn-primary" onclick="localStorage.setItem('am_console_token',document.getElementById('settings-token').value);showToast('已保存')"
|
|
1122
|
-
</div
|
|
1185
|
+
<div class="ag-section-title">${t('访问 Token', 'Access Token')}</div>
|
|
1186
|
+
<div class="ag-section-desc">${t('服务端已开启访问认证(设置了 AGENT_MOSS_CONSOLE_TOKEN)。在此填入服务端配置的 token,浏览器会用它访问控制台;token 不匹配则无法查看或修改规则。', 'The server requires an access token (AGENT_MOSS_CONSOLE_TOKEN is set). Enter the token configured on the server; the browser will send it with every console request. An invalid token blocks viewing or editing rules.')}</div>
|
|
1187
|
+
<input class="ag-input" id="settings-token" placeholder="${t('输入服务端配置的 token', 'Enter the server-configured token')}" value="${getToken()}">
|
|
1188
|
+
<div style="margin-top:8px"><button class="ag-btn ag-btn-primary" onclick="localStorage.setItem('am_console_token',document.getElementById('settings-token').value);showToast(t('已保存','Saved'))">${t('保存', 'Save')}</button></div>
|
|
1189
|
+
</div>` : ''}
|
|
1123
1190
|
<div class="ag-card">
|
|
1124
|
-
<div class="ag-section-title"
|
|
1125
|
-
<button class="ag-btn ag-btn-danger" onclick="if(confirm('确定重置所有规则到出厂默认?所有自定义规则和开关设置将丢失。')){apiPost('reset').then(c=>{state.config=c;state.envOverrides=
|
|
1126
|
-
<button class="ag-btn ag-btn-danger" style="margin-left:8px" onclick="resetTokenStats()"
|
|
1191
|
+
<div class="ag-section-title">${t('危险操作', 'Danger Zone')}</div>
|
|
1192
|
+
<button class="ag-btn ag-btn-danger" onclick="if(confirm(t('确定重置所有规则到出厂默认?所有自定义规则和开关设置将丢失。','Reset all rules to factory defaults? All custom rules and switch settings will be lost.'))){apiPost('reset').then(c=>{state.config=c;return loadEnvOverrides().then(o=>{state.envOverrides=o;renderCurrent();showToast(t('配置已重置到出厂默认','Configuration reset to factory defaults'))})}).catch(e=>showToast(t('重置失败: ','Reset failed: ')+e.message,'error'))}">${t('重置规则到出厂默认', 'Reset Rules to Factory Defaults')}</button>
|
|
1193
|
+
<button class="ag-btn ag-btn-danger" style="margin-left:8px" onclick="resetTokenStats()">${t('清除 Token 统计', 'Clear Token Statistics')}</button>
|
|
1127
1194
|
</div>`;
|
|
1128
1195
|
}
|
|
1129
1196
|
|
|
@@ -1131,46 +1198,46 @@ function render_settings() {
|
|
|
1131
1198
|
function showAddRuleModal(layer) {
|
|
1132
1199
|
currentModalAction = { type: 'add_rule', layer };
|
|
1133
1200
|
const isL1 = layer === 'L1_rules', isL2 = layer === 'L2_rules';
|
|
1134
|
-
const title = isL1 ? '新增 L1 静态规则' : '新增 L2 规则';
|
|
1201
|
+
const title = isL1 ? t('新增 L1 静态规则', 'Add L1 Static Rule') : t('新增 L2 规则', 'Add L2 Rule');
|
|
1135
1202
|
let formHtml = '';
|
|
1136
1203
|
if (isL1) {
|
|
1137
|
-
formHtml = `<div class="ag-form-group"><div class="ag-form-label"
|
|
1138
|
-
<div class="ag-form-group" id="form-pattern"><div class="ag-form-label"
|
|
1139
|
-
<div class="ag-form-group" id="form-keyword" style="display:none"><div class="ag-form-label"
|
|
1140
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1141
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1142
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1143
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1144
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1204
|
+
formHtml = `<div class="ag-form-group"><div class="ag-form-label">${t('规则类型', 'Rule Type')}<span class="ag-required">*</span></div><select class="ag-select" id="rule-type" onchange="updateRuleForm()"><option value="pattern">${t('正则模式匹配', 'Regex pattern matching')}</option><option value="keyword">${t('关键词匹配', 'Keyword matching')}</option></select><div class="ag-form-hint">${t('正则模式:用正则表达式匹配命令(如 ', 'Regex mode: match the command with a regex (e.g. ')}<code>\\brm\\s+-rf</code>${t(');关键词匹配:命令中包含指定文字即命中。', '); keyword mode: command containing the text matches.')}</div></div>
|
|
1205
|
+
<div class="ag-form-group" id="form-pattern"><div class="ag-form-label">${t('正则 Pattern', 'Regex Pattern')}<span class="ag-required">*</span></div><input class="ag-input" id="input-pattern" placeholder="${t('如: ', 'e.g. ')}${'\\brm\\s+-rf'}"><div class="ag-form-hint">${t('Python 正则表达式,命中该正则的命令会触发本规则。', 'Python regex. Commands matching this regex trigger this rule.')}</div></div>
|
|
1206
|
+
<div class="ag-form-group" id="form-keyword" style="display:none"><div class="ag-form-label">${t('关键词', 'Keyword')}<span class="ag-required">*</span></div><input class="ag-input" id="input-keyword" placeholder="${t('如: ignore previous instructions', 'e.g. ignore previous instructions')}"><div class="ag-form-hint">${t('命令文本中包含此关键词即命中(不区分大小写)。', 'Command text containing this keyword matches (case-insensitive).')}</div></div>
|
|
1207
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('风险等级', 'Risk Level')}<span class="ag-required">*</span></div><select class="ag-select" id="input-risk-level"><option value="critical">Critical(${t('严重', 'Critical')})</option><option value="high">High(${t('高危', 'High')})</option><option value="medium">Medium(${t('中危', 'Medium')})</option><option value="low">Low(${t('低危', 'Low')})</option></select><div class="ag-form-hint"><strong>critical / high</strong>:${t('在 L1 层直接拦截(Deny),命令不会执行;', 'block directly at L1 (Deny), the command will not run;')}<br><strong>medium / low</strong>:${t('不在 L1 拦截,转交 L3 LLM 深度分析判断。', 'not blocked at L1; handed to L3 LLM deep analysis.')}</div></div>
|
|
1208
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('风险类型', 'Risk Type')}</div><input class="ag-input" id="input-risk-type" placeholder="${t('如: script_execution, file_access, data_exfiltration', 'e.g. script_execution, file_access, data_exfiltration')}"><div class="ag-form-hint">${t('风险分类标签,用于统计与展示。可选值:script_execution(脚本执行)、file_access(文件访问)、data_exfiltration(数据外泄)等,可自定义。', 'Risk classification label for stats and display. Options: script_execution, file_access, data_exfiltration, etc. Custom values allowed.')}</div></div>
|
|
1209
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('拦截原因', 'Block Reason')}<span class="ag-required">*</span></div><input class="ag-input" id="input-reason" placeholder="${t('描述为什么这条规则要拦截', 'Describe why this rule blocks')}"><div class="ag-form-hint">${t('命中时展示给用户的说明文字,应写清为什么危险。L3 分析时也会参考此原因。', 'Text shown to the user on match; explain why it is dangerous. L3 analysis also references this reason.')}</div></div>
|
|
1210
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('语言(仅关键词类型)', 'Language (keyword type only)')}</div><select class="ag-select" id="input-lang"><option value="en">English</option><option value="zh">中文</option></select><div class="ag-form-hint">${t('关键词的语言,影响匹配时的分词处理。', 'The language of the keyword affects tokenization during matching.')}</div></div>
|
|
1211
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('所属分类', 'Category')}</div><select class="ag-select" id="input-category"><option value="user_custom">${t('用户自定义', 'User custom')}</option><option value="dangerous_commands">${t('危险命令检测', 'Dangerous commands')}</option><option value="injection_detection">${t('注入攻击检测', 'Injection detection')}</option></select></div>`;
|
|
1145
1212
|
} else if (isL2) {
|
|
1146
|
-
formHtml = `<div class="ag-form-group"><div class="ag-form-label"
|
|
1147
|
-
<div class="ag-form-group" id="form-l2-path"><div class="ag-form-label"
|
|
1148
|
-
<div class="ag-form-group" id="form-l2-pattern" style="display:none"><div class="ag-form-label"
|
|
1149
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1150
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1151
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1152
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1213
|
+
formHtml = `<div class="ag-form-group"><div class="ag-form-label">${t('规则类型', 'Rule Type')}<span class="ag-required">*</span></div><select class="ag-select" id="rule-type-l2" onchange="updateL2RuleForm()"><option value="path">${t('敏感路径', 'Sensitive path')}</option><option value="pattern">${t('正则模式', 'Regex pattern')}</option></select><div class="ag-form-hint">${t('敏感路径:匹配文件路径(如 ', 'Sensitive path: match a file path (e.g. ')}<code>/etc/shadow</code>${t(');正则模式:用正则匹配命令文本。', '); regex mode: match command text with regex.')}</div></div>
|
|
1214
|
+
<div class="ag-form-group" id="form-l2-path"><div class="ag-form-label">${t('路径', 'Path')}<span class="ag-required">*</span></div><input class="ag-input" id="input-l2-path" placeholder="${t('如: /etc/shadow, .ssh/id_rsa', 'e.g. /etc/shadow, .ssh/id_rsa')}"><div class="ag-form-hint">${t('要拦截的文件路径,多个用英文逗号分隔。命令访问到这些路径即命中。', 'File paths to block; separate multiple with commas. Commands accessing these paths match.')}</div></div>
|
|
1215
|
+
<div class="ag-form-group" id="form-l2-pattern" style="display:none"><div class="ag-form-label">${t('正则 Pattern', 'Regex Pattern')}<span class="ag-required">*</span></div><input class="ag-input" id="input-l2-pattern" placeholder="${t('如: ', 'e.g. ')}${'\\bpasswd\\s+--stdin'}"><div class="ag-form-hint">${t('Python 正则表达式,命中即触发。', 'Python regex; triggers on match.')}</div></div>
|
|
1216
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('描述', 'Description')}<span class="ag-required">*</span></div><input class="ag-input" id="input-l2-desc" placeholder="${t('路径/规则的中文描述', 'Chinese description of the path/rule')}"><div class="ag-form-hint">${t('命中时展示给用户的说明。', 'Description shown to the user on match.')}</div></div>
|
|
1217
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('风险等级', 'Risk Level')}<span class="ag-required">*</span></div><select class="ag-select" id="input-l2-risk-level"><option value="critical">Critical(${t('严重', 'Critical')})</option><option value="high">High(${t('高危', 'High')})</option><option value="medium">Medium(${t('中危', 'Medium')})</option><option value="low">Low(${t('低危', 'Low')})</option></select><div class="ag-form-hint"><strong>critical / high</strong>:${t('在 L2 层直接拦截;', 'block directly at L2;')}<br><strong>medium / low</strong>:${t('不在 L2 拦截,转交 L3 LLM 判断。', 'not blocked at L2; handed to L3 LLM.')}</div></div>
|
|
1218
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('拦截模式', 'Interception Mode')}</div><select class="ag-select" id="input-l2-deny-mode"><option value="deny_write">${t('仅拦截写入', 'Block Write Only')}</option><option value="deny_read">${t('仅拦截读取', 'Block Read Only')}</option><option value="deny_both">${t('读写均拦截', 'Block Both')}</option></select><div class="ag-form-hint">${t('选择该路径的拦截模式:仅拦截写入(默认)、仅拦截读取(如 /dev/random)、读写均拦截(凭据文件)。', 'Choose interception mode: block write only (default), block read only (e.g. /dev/random), block both (credential files).')}</div></div>
|
|
1219
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('所属分类', 'Category')}</div><select class="ag-select" id="input-l2-category"><option value="sensitive_path_access">${t('敏感路径访问', 'Sensitive path access')}</option><option value="password_modify_consent">${t('密码修改授权', 'Password modify consent')}</option><option value="user_deletion_consent">${t('用户删除授权', 'User deletion consent')}</option><option value="intent_consistency">${t('意图一致性', 'Intent consistency')}</option></select></div>`;
|
|
1153
1220
|
}
|
|
1154
1221
|
openModal(title, formHtml);
|
|
1155
1222
|
}
|
|
1156
1223
|
function showAddSkillModal() {
|
|
1157
1224
|
currentModalAction = { type: 'add_skill' };
|
|
1158
|
-
openModal('新增 L3 Skill', `<div class="ag-form-group"><div class="ag-form-label"
|
|
1159
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1160
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1161
|
-
<div class="ag-form-group"><div class="ag-form-label"
|
|
1225
|
+
openModal(t('新增 L3 Skill', 'Add L3 Skill'), `<div class="ag-form-group"><div class="ag-form-label">${t('Skill ID(文件名)', 'Skill ID (file name)')}</div><input class="ag-input" id="input-skill-id" placeholder="${t('如: my_custom_guard', 'e.g. my_custom_guard')}"><div class="ag-form-hint">${t('Skill 文件名(不含扩展名),英文+下划线。会成为该 Skill 的唯一标识。', 'Skill file name (without extension), letters + underscores. Becomes the Skill’s unique ID.')}</div></div>
|
|
1226
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('触发关键词', 'Trigger Keywords')}<span class="ag-required">*</span></div><input class="ag-input" id="input-skill-keywords" placeholder="${t('逗号分隔, 如: custom, 特殊, special', 'comma-separated, e.g. custom, special')}"><div class="ag-form-hint">${t('命令/动作中包含这些关键词时,该 Skill 会被匹配并注入 L3 的 LLM prompt。', 'When the command/action contains these keywords, the Skill matches and is injected into the L3 LLM prompt.')}</div></div>
|
|
1227
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('Skill 内容(Markdown)', 'Skill Content (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">${t('Markdown 格式的规则说明。命中关键词后会作为提示注入 LLM。', 'Markdown rule description. Injected into the LLM prompt on keyword match.')}</div></div>
|
|
1228
|
+
<div class="ag-form-group"><div class="ag-form-label">${t('所属分类', 'Category')}</div><select class="ag-select" id="input-skill-category"><option value="user_custom">${t('用户自定义', 'User custom')}</option><option value="file_operations">${t('文件操作', 'File operations')}</option><option value="network_security">${t('网络安全', 'Network security')}</option><option value="intent_and_general">${t('意图/通用', 'Intent/general')}</option></select></div>`);
|
|
1162
1229
|
}
|
|
1163
1230
|
function updateRuleForm() {
|
|
1164
|
-
const
|
|
1231
|
+
const tt = document.getElementById('rule-type')?.value;
|
|
1165
1232
|
const p = document.getElementById('form-pattern'), k = document.getElementById('form-keyword');
|
|
1166
|
-
if (p) p.style.display =
|
|
1167
|
-
if (k) k.style.display =
|
|
1233
|
+
if (p) p.style.display = tt === 'pattern' ? '' : 'none';
|
|
1234
|
+
if (k) k.style.display = tt === 'keyword' ? '' : 'none';
|
|
1168
1235
|
}
|
|
1169
1236
|
function updateL2RuleForm() {
|
|
1170
|
-
const
|
|
1237
|
+
const tt = document.getElementById('rule-type-l2')?.value;
|
|
1171
1238
|
const p = document.getElementById('form-l2-path'), k = document.getElementById('form-l2-pattern');
|
|
1172
|
-
if (p) p.style.display =
|
|
1173
|
-
if (k) k.style.display =
|
|
1239
|
+
if (p) p.style.display = tt === 'path' ? '' : 'none';
|
|
1240
|
+
if (k) k.style.display = tt === 'pattern' ? '' : 'none';
|
|
1174
1241
|
}
|
|
1175
1242
|
async function submitModal() {
|
|
1176
1243
|
if (!currentModalAction) return;
|
|
@@ -1198,7 +1265,7 @@ async function submitModal() {
|
|
|
1198
1265
|
else { body.pattern = document.getElementById('input-l2-pattern')?.value; body.risk_type = 'script_execution'; }
|
|
1199
1266
|
}
|
|
1200
1267
|
state.config = await apiPost('rules', body);
|
|
1201
|
-
showToast('规则已添加');
|
|
1268
|
+
showToast(t('规则已添加', 'Rule added'));
|
|
1202
1269
|
} else if (currentModalAction.type === 'add_skill') {
|
|
1203
1270
|
const keywords = (document.getElementById('input-skill-keywords')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1204
1271
|
state.config = await apiPost('skills', {
|
|
@@ -1207,45 +1274,70 @@ async function submitModal() {
|
|
|
1207
1274
|
keywords,
|
|
1208
1275
|
content: document.getElementById('input-skill-content')?.value,
|
|
1209
1276
|
});
|
|
1210
|
-
showToast('Skill 已添加');
|
|
1277
|
+
showToast(t('Skill 已添加', 'Skill added'));
|
|
1211
1278
|
} else if (currentModalAction.type === 'agent_rule') {
|
|
1212
1279
|
const { agentId, cat, isValueCat } = currentModalAction;
|
|
1213
1280
|
const payload = { agent_id: agentId, category: cat };
|
|
1214
1281
|
if (isValueCat) {
|
|
1215
1282
|
payload.value = el('ar-value').value.trim();
|
|
1216
|
-
if (!payload.value) { showToast('值不能为空', 'error'); return; }
|
|
1283
|
+
if (!payload.value) { showToast(t('值不能为空', 'Value cannot be empty'), 'error'); return; }
|
|
1217
1284
|
} else {
|
|
1218
1285
|
payload.pattern = el('ar-pattern').value.trim();
|
|
1219
1286
|
payload.risk_level = el('ar-risk_level').value.trim() || undefined;
|
|
1220
1287
|
}
|
|
1221
1288
|
state.agentRules = await apiPost('agents/rules', payload);
|
|
1222
|
-
showToast('Agent 规则已添加');
|
|
1289
|
+
showToast(t('Agent 规则已添加', 'Agent rule added'));
|
|
1223
1290
|
}
|
|
1224
1291
|
renderCurrent();
|
|
1225
1292
|
closeModal();
|
|
1226
|
-
} catch (e) { showToast('添加失败: ' + e.message, 'error'); }
|
|
1293
|
+
} catch (e) { showToast(t('添加失败: ', 'Add failed: ') + e.message, 'error'); }
|
|
1227
1294
|
}
|
|
1228
1295
|
|
|
1229
1296
|
// ===== Tab nav + theme =====
|
|
1230
|
-
document.querySelectorAll('.ag-tab[data-page]').forEach(
|
|
1231
|
-
|
|
1297
|
+
document.querySelectorAll('.ag-tab[data-page]').forEach(tab => {
|
|
1298
|
+
tab.onclick = () => {
|
|
1232
1299
|
stopTokenAutoRefresh();
|
|
1233
|
-
state.currentPage =
|
|
1300
|
+
state.currentPage = tab.dataset.page;
|
|
1234
1301
|
document.querySelectorAll('.ag-tab').forEach(x => x.classList.remove('active'));
|
|
1235
|
-
|
|
1302
|
+
tab.classList.add('active');
|
|
1236
1303
|
renderCurrent();
|
|
1237
1304
|
};
|
|
1238
1305
|
});
|
|
1306
|
+
function applyTabLabels() {
|
|
1307
|
+
const labels = {
|
|
1308
|
+
overview: t('概览', 'Overview'),
|
|
1309
|
+
search: t('搜索规则', 'Search Rules'),
|
|
1310
|
+
l1: t('L1 规则', 'L1 Rules'),
|
|
1311
|
+
l2: t('L2 规则', 'L2 Rules'),
|
|
1312
|
+
l3: t('L3 Skills', 'L3 Skills'),
|
|
1313
|
+
agents: t('Agent 定制', 'Agent Rules'),
|
|
1314
|
+
token: t('Token', 'Token'),
|
|
1315
|
+
settings: t('设置', 'Settings'),
|
|
1316
|
+
};
|
|
1317
|
+
document.querySelectorAll('.ag-tab[data-page]').forEach(tab => {
|
|
1318
|
+
tab.textContent = labels[tab.dataset.page] || tab.textContent;
|
|
1319
|
+
});
|
|
1320
|
+
const themeBtn = el('theme-btn');
|
|
1321
|
+
if (themeBtn) themeBtn.textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? t('☀️ 浅色', '☀️ Light') : t('🌙 暗色', '🌙 Dark');
|
|
1322
|
+
const closeBtn = document.querySelector('.ag-modal-close');
|
|
1323
|
+
if (closeBtn) closeBtn.setAttribute('title', t('关闭', 'Close'));
|
|
1324
|
+
const cancelBtn = el('modal-actions') ? document.querySelector('#modal-actions .ag-btn-ghost') : null;
|
|
1325
|
+
if (cancelBtn) cancelBtn.textContent = t('取消', 'Cancel');
|
|
1326
|
+
const submitBtn = el('modal-submit');
|
|
1327
|
+
if (submitBtn) submitBtn.textContent = t('确认', 'Confirm');
|
|
1328
|
+
}
|
|
1239
1329
|
el('theme-btn').onclick = () => {
|
|
1240
1330
|
const cur = document.documentElement.getAttribute('data-theme');
|
|
1241
1331
|
const next = cur === 'dark' ? 'light' : 'dark';
|
|
1242
1332
|
document.documentElement.setAttribute('data-theme', next);
|
|
1243
|
-
el('theme-btn').textContent = next === 'dark' ? '☀️ 浅色' : '🌙 暗色';
|
|
1333
|
+
el('theme-btn').textContent = next === 'dark' ? t('☀️ 浅色', '☀️ Light') : t('🌙 暗色', '🌙 Dark');
|
|
1244
1334
|
if (state.currentPage === 'token') render_token();
|
|
1245
1335
|
};
|
|
1246
1336
|
|
|
1247
1337
|
// ===== Init =====
|
|
1248
|
-
|
|
1338
|
+
applyTitle();
|
|
1339
|
+
applyTabLabels();
|
|
1340
|
+
refreshConfig().catch(e => { document.body.innerHTML = '<div style="padding:40px;color:#FF3B30">' + t('加载失败: ', 'Load failed: ') + escapeHtml(e.message) + '</div>'; });
|
|
1249
1341
|
</script>
|
|
1250
1342
|
</body>
|
|
1251
1343
|
</html>
|