@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.
Files changed (2) hide show
  1. package/dist/static/index.html +351 -259
  2. package/package.json +1 -1
@@ -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 t = getToken();
287
- return t ? { 'Authorization': 'Bearer ' + t } : {};
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 t = prompt('需要 Bearer token 访问安全策略管控台:');
296
- if (t) { localStorage.setItem(TOKEN_KEY, t); return api(path, opts); }
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(t) { if (!t) return ''; return RISK_TYPE_NAMES[t] || t; }
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(t) {
367
- if (!t) return '';
368
- return `<span class="ag-risk-type ag-rt-${escapeHtml(t)}" title="风险类型: ${escapeHtml(t)}">${escapeHtml(riskTypeName(t))}</span>`;
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">内置</span>' : '<span class="ag-badge ag-badge-custom">自定义</span>';
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(r) { return r ? '<span class="ag-badge ag-badge-removed" title="此规则已从当前默认规则集移除,仅作历史保留;如需可手动启用">已移除</span>' : ''; }
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="源码出厂的原始拦截模式。当前模式与之不同时表示已被用户覆盖">原始: ${denyModeLabel(rule.source_deny_mode)}</span>`;
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="当前拦截模式已偏离源码默认值">已覆盖</span>';
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 t = document.createElement('div');
400
- t.className = 'ag-toast ag-toast-' + type;
401
- t.textContent = msg;
402
- document.body.appendChild(t);
403
- setTimeout(() => t.remove(), 3000);
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 api('env-overrides');
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 覆盖</span>` : '';
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 强制禁用</div>`;
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">层级全局开关</div>
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">规则统计</div>
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">L1 规则启用</div><div class="value">${countEnabled(state.config.L1_rules)}/${countRules(state.config.L1_rules)}</div></div>
457
- <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>
458
- <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>
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">Token 用量统计
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)">今日</button>
465
- <button class="ag-btn ag-btn-sm active" data-days="7" onclick="loadOverviewToken(7)">近7天</button>
466
- <button class="ag-btn ag-btn-sm" data-days="0" onclick="loadOverviewToken(0)">全部</button>
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">加载中...</div>
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">总 Token</div><div class="value">${formatTokens(s.total_tokens)}</div></div>
481
- <div class="ag-stat-card"><div class="label">输入 Token</div><div class="value">${formatTokens(s.total_prompt_tokens)}</div></div>
482
- <div class="ag-stat-card"><div class="label">输出 Token</div><div class="value">${formatTokens(s.total_completion_tokens)}</div></div>
483
- <div class="ag-stat-card"><div class="label">LLM 调用次数</div><div class="value">${s.total_calls}</div></div>
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} 次, 输入 ${formatTokens(v.prompt_tokens)}, 输出 ${formatTokens(v.completion_tokens)})</span></div>`;
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 || '<div class="ag-empty">无分项数据</div>';
499
- } catch { el('overview-token-summary').innerHTML = '<div class="ag-empty">无数据</div>'; }
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
- function countEnabled(lr) {
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
- function countEnabledSkills(ls) {
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
- state.config = await apiPut('layers', { layer_key: key, enabled: !cur });
517
- state.envOverrides = await api('env-overrides');
518
- renderCurrent();
519
- showToast(`${LAYER_NAMES[key]?.name || key} 已${!cur ? '启用' : '禁用'}`);
520
- } catch (e) { showToast('操作失败: ' + e.message, 'error'); }
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
- // 按 categoryNames 定义的顺序渲染,保证 user_custom 等固定类目在最末。
556
- // 落盘 JSON 的键顺序会被合并/迁移逻辑打乱,不能直接 Object.entries(layer)。
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}')">+ 新增规则</button></div>`;
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
- const enabledCount = rules.filter(r => r.enabled).length;
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} 启用</span></div>
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(catEnabled, false, '')}</div>
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 += '<div class="ag-empty">无规则</div>';
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' : ''}>仅拦截写入</option>
603
- <option value="deny_read" ${(r.deny_mode || 'deny_write') === 'deny_read' ? 'selected' : ''}>仅拦截读取</option>
604
- <option value="deny_both" ${(r.deny_mode || 'deny_write') === 'deny_both' ? 'selected' : ''}>读写均拦截</option>
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 ? '此规则已从默认规则集移除,可删除清理' : '删除自定义规则'}">删除</button>` : '';
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
- const ids = Object.keys(agents);
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 = '<div class="ag-card"><div class="ag-empty">无 agent 定制规则</div></div>';
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
- const enCount = rules.filter(r => r.enabled).length;
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} 启用</span></div>
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 += '<div class="ag-empty">无规则</div>';
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}')">+ 新增</button></div>`;
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 ? '<span class="ag-badge ag-badge-builtin" title="源码内置规则">内置</span>' : '<span class="ag-badge ag-badge-custom">自定义</span>')
710
- + (r.source_removed ? '<span class="ag-badge ag-badge-removed" title="此规则已从默认规则集移除,仅作历史保留">已移除</span>' : '')
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 ? '触发关键词' : (r.pattern ? '规则' : '规则')));
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,'&quot;')})">删除</button>`;
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,'&quot;')})">${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(`删除规则 ${r.value || r.pattern || r.keyword || r.path || ''}?`)) return;
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(`新增 ${agentId} / ${cat} 规则`, body, true);
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
- let html = `<div class="ag-card" style="padding:8px 16px"><button class="ag-btn ag-btn-primary" onclick="showAddSkillModal()">+ 新增 Skill</button></div>`;
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
- const enabledCount = skills.filter(s => s.enabled).length;
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} 启用</span></div>
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(catEnabled, false, '')}</div>
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 += '<div class="ag-empty">无 skill</div>';
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}')">删除</button>` : '';
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">触发关键词:</span> ${escapeHtml(kw)}</div>
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}')">详情</button>${deleteBtn}</div>
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 详情', '<p style="color:var(--ag-text-tertiary);padding:8px 0">加载中...</p>', false);
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 = '<p style="color:var(--ag-red);padding:8px 0">加载失败: ' + escapeHtml(e.message) + '</p>';
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> 行</span></div><div class="ag-skill-content">';
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">全局规则搜索</div>
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()">搜索</button>
848
- <button class="ag-btn ag-btn-ghost" onclick="clearSearch()">清空</button>
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">搜索范围:L1 规则的正则/原因/风险类型、L2 规则的路径/描述、L3 Skill 的关键词/ID。命中后可直接启用或禁用对应规则。</div>
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">搜索结果 <span class="ag-cat-count" id="search-count">0 条</span></div>
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 = '<div class="ag-empty">未找到匹配的规则</div>'; return; }
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' : ''}>仅拦截写入</option>
894
- <option value="deny_read" ${(r.deny_mode || 'deny_write') === 'deny_read' ? 'selected' : ''}>仅拦截读取</option>
895
- <option value="deny_both" ${(r.deny_mode || 'deny_write') === 'deny_both' ? 'selected' : ''}>读写均拦截</option>
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">分类: ${escapeHtml(r.category)}</span>
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">规则:</span> ${escapeHtml(ruleText)}</div>
907
- ${reason ? '<div class="ag-rule-reason"><span class="ag-rule-field-label">原因:</span> ' + escapeHtml(reason) + '</div>' : ''}
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">Token 用量详细统计
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)">今日</button>
934
- <button class="ag-btn ag-btn-sm" data-days="7" onclick="loadTokenStats(7)">近7天</button>
935
- <button class="ag-btn ag-btn-sm" data-days="30" onclick="loadTokenStats(30)">近30天</button>
936
- <button class="ag-btn ag-btn-sm" data-days="0" onclick="loadTokenStats(0)">全部</button>
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">至</span>
940
- <input type="date" id="token-date-to" class="ag-date-input" title="结束日期">
941
- <button class="ag-btn ag-btn-sm" onclick="loadTokenStatsByRange()">查询</button>
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">加载中...</div>
1010
+ <div id="token-summary" class="ag-empty">${t('加载中...', 'Loading...')}</div>
945
1011
  </div>
946
- <div class="ag-card"><div class="ag-section-title">按步骤分组</div><div id="token-by-step"></div></div>
947
- <div class="ag-card"><div class="ag-section-title">按模型分组</div><div id="token-by-model"></div></div>
948
- <div class="ag-card"><div class="ag-section-title">Token 消耗趋势(按模型)</div><div id="token-trend-chart"></div></div>
949
- <div class="ag-card"><div class="ag-section-title">最近调用记录</div><div id="token-recent" class="ag-empty">加载中...</div></div>`;
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">总 Token</div><div class="value">' + formatTokens(s.total_tokens) + '</div></div>' +
996
- '<div class="ag-stat-card"><div class="label">输入 Token</div><div class="value">' + formatTokens(s.total_prompt_tokens) + '</div></div>' +
997
- '<div class="ag-stat-card"><div class="label">输出 Token</div><div class="value">' + formatTokens(s.total_completion_tokens) + '</div></div>' +
998
- '<div class="ag-stat-card"><div class="label">调用次数</div><div class="value">' + s.total_calls + '</div></div></div>';
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 || '<div class="ag-empty">无分项数据</div>';
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 || '<div class="ag-empty">无分模型数据</div>';
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 = '<div class="ag-empty">暂无记录</div>'; return; }
1075
- let html = '<table class="ag-table"><tr><th>时间</th><th>步骤</th><th>模型</th><th>输入</th><th>输出</th><th>总计</th></tr>';
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 = '<div class="ag-empty">加载失败</div>'; }
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 = '<div class="ag-empty">当前没有环境变量覆盖</div>';
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">环境变量覆盖</div>
1106
- <div class="ag-section-desc">环境变量优先级最高,会覆盖 Web UI 的设置。清除环境变量后 Web 设置才能生效。</div>
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">配置文件路径</div>
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
- <p>配置文件: ~/.config/agentmoss/agent_moss_runtime.json</p>
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">访问 Token</div>
1119
- <div class="ag-section-desc">此处设置的是浏览器本地 token,console 调用 API 时以 Authorization: Bearer &lt;token&gt; 携带。服务端是否要求认证取决于环境变量 AGENT_MOSS_CONSOLE_TOKEN:未设置则本机免认证;已设置则请求须携带相同值才放行。当前本地 token:${getToken() ? '已设置' : '未设置'}</div>
1120
- <input class="ag-input" id="settings-token" placeholder="输入新 token" value="${getToken()}">
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('已保存')">保存</button></div>
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">危险操作</div>
1125
- <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>
1126
- <button class="ag-btn ag-btn-danger" style="margin-left:8px" onclick="resetTokenStats()">清除 Token 统计</button>
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">规则类型<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>
1138
- <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>
1139
- <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>
1140
- <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>
1141
- <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>
1142
- <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>
1143
- <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>
1144
- <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>`;
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">规则类型<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>
1147
- <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>
1148
- <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>
1149
- <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>
1150
- <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>
1151
- <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>
1152
- <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>`;
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">Skill ID(文件名)</div><input class="ag-input" id="input-skill-id" placeholder="如: my_custom_guard"><div class="ag-form-hint">Skill 文件名(不含扩展名),英文+下划线。会成为该 Skill 的唯一标识。</div></div>
1159
- <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>
1160
- <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>
1161
- <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>`);
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 t = document.getElementById('rule-type')?.value;
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 = t === 'pattern' ? '' : 'none';
1167
- if (k) k.style.display = t === 'keyword' ? '' : 'none';
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 t = document.getElementById('rule-type-l2')?.value;
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 = t === 'path' ? '' : 'none';
1173
- if (k) k.style.display = t === 'pattern' ? '' : 'none';
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(t => {
1231
- t.onclick = () => {
1297
+ document.querySelectorAll('.ag-tab[data-page]').forEach(tab => {
1298
+ tab.onclick = () => {
1232
1299
  stopTokenAutoRefresh();
1233
- state.currentPage = t.dataset.page;
1300
+ state.currentPage = tab.dataset.page;
1234
1301
  document.querySelectorAll('.ag-tab').forEach(x => x.classList.remove('active'));
1235
- t.classList.add('active');
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
- refreshConfig().catch(e => { document.body.innerHTML = '<div style="padding:40px;color:#FF3B30">加载失败: ' + e.message + '</div>'; });
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>