agim-cli 1.1.10 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/cli.js +78 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/approval-bus.d.ts +18 -0
  5. package/dist/core/approval-bus.d.ts.map +1 -1
  6. package/dist/core/approval-bus.js +111 -0
  7. package/dist/core/approval-bus.js.map +1 -1
  8. package/dist/core/approval-router.d.ts.map +1 -1
  9. package/dist/core/approval-router.js +12 -0
  10. package/dist/core/approval-router.js.map +1 -1
  11. package/dist/core/audit-log.d.ts +39 -0
  12. package/dist/core/audit-log.d.ts.map +1 -1
  13. package/dist/core/audit-log.js +124 -0
  14. package/dist/core/audit-log.js.map +1 -1
  15. package/dist/core/boot-state.d.ts +17 -0
  16. package/dist/core/boot-state.d.ts.map +1 -0
  17. package/dist/core/boot-state.js +77 -0
  18. package/dist/core/boot-state.js.map +1 -0
  19. package/dist/core/job-recovery.d.ts +41 -1
  20. package/dist/core/job-recovery.d.ts.map +1 -1
  21. package/dist/core/job-recovery.js +216 -4
  22. package/dist/core/job-recovery.js.map +1 -1
  23. package/dist/core/memory-consolidate.d.ts +12 -0
  24. package/dist/core/memory-consolidate.d.ts.map +1 -0
  25. package/dist/core/memory-consolidate.js +242 -0
  26. package/dist/core/memory-consolidate.js.map +1 -0
  27. package/dist/core/memory-distill.d.ts +30 -0
  28. package/dist/core/memory-distill.d.ts.map +1 -0
  29. package/dist/core/memory-distill.js +213 -0
  30. package/dist/core/memory-distill.js.map +1 -0
  31. package/dist/core/memory-rpc.d.ts +11 -0
  32. package/dist/core/memory-rpc.d.ts.map +1 -0
  33. package/dist/core/memory-rpc.js +94 -0
  34. package/dist/core/memory-rpc.js.map +1 -0
  35. package/dist/core/memory-vector.d.ts +44 -0
  36. package/dist/core/memory-vector.d.ts.map +1 -0
  37. package/dist/core/memory-vector.js +360 -0
  38. package/dist/core/memory-vector.js.map +1 -0
  39. package/dist/core/memory.d.ts +140 -0
  40. package/dist/core/memory.d.ts.map +1 -0
  41. package/dist/core/memory.js +714 -0
  42. package/dist/core/memory.js.map +1 -0
  43. package/dist/core/persona.d.ts +24 -0
  44. package/dist/core/persona.d.ts.map +1 -0
  45. package/dist/core/persona.js +80 -0
  46. package/dist/core/persona.js.map +1 -0
  47. package/dist/core/push-rpc.d.ts +26 -0
  48. package/dist/core/push-rpc.d.ts.map +1 -0
  49. package/dist/core/push-rpc.js +123 -0
  50. package/dist/core/push-rpc.js.map +1 -0
  51. package/dist/core/router.d.ts.map +1 -1
  52. package/dist/core/router.js +26 -1
  53. package/dist/core/router.js.map +1 -1
  54. package/dist/core/types.d.ts +41 -0
  55. package/dist/core/types.d.ts.map +1 -1
  56. package/dist/plugins/agents/claude-code/index.d.ts +9 -0
  57. package/dist/plugins/agents/claude-code/index.d.ts.map +1 -1
  58. package/dist/plugins/agents/claude-code/index.js +37 -0
  59. package/dist/plugins/agents/claude-code/index.js.map +1 -1
  60. package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts +8 -0
  61. package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts.map +1 -1
  62. package/dist/plugins/agents/claude-code/mcp-approval-server.js +181 -0
  63. package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -1
  64. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +5 -1
  65. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -1
  66. package/dist/plugins/messengers/telegram/telegram-adapter.js +85 -0
  67. package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -1
  68. package/dist/web/public/settings.html +106 -10
  69. package/dist/web/public/tasks.html +977 -1
  70. package/dist/web/server.d.ts.map +1 -1
  71. package/dist/web/server.js +433 -6
  72. package/dist/web/server.js.map +1 -1
  73. package/dist/web/viewer-render.d.ts.map +1 -1
  74. package/dist/web/viewer-render.js +7 -4
  75. package/dist/web/viewer-render.js.map +1 -1
  76. package/package.json +4 -1
@@ -230,6 +230,72 @@
230
230
  body: 'An outbox row enters this state after 6 failed delivery attempts (cumulative ~3h of backoff). It will no longer be retried automatically. Click "Retry" to put it back into the pending queue from scratch.',
231
231
  },
232
232
  },
233
+ // ── v1.2 Cost & Health tab ──
234
+ costH2: '💰 Cost & Health',
235
+ costWindow: 'Window',
236
+ costDays1: '1 day',
237
+ costDays7: '7 days',
238
+ costDays30: '30 days',
239
+ costDays90: '90 days',
240
+ costRefresh: 'Refresh',
241
+ costDailyTrend: 'Daily trend',
242
+ costMetricCalls: 'Calls',
243
+ costMetricCost: 'Cost ($)',
244
+ costMetricErrors: 'Errors',
245
+ costMetricLatency: 'Avg latency (ms)',
246
+ costTopUser: 'Top by User',
247
+ costTopAgent: 'Top by Agent',
248
+ costTopPlatform: 'Top by Platform',
249
+ // ── v1.5/1.6 Memory tab ──
250
+ memoryH2: '🧠 Long-term memory',
251
+ memoryIntro: 'Facts auto-extracted from conversations + persona summary. Pick a user → view / edit / delete. All data lives in ~/.agim/memory.db locally; nothing is uploaded.',
252
+ memoryUser: 'User',
253
+ memoryUserLoading: '— loading —',
254
+ memoryReloadUsers: '🔄 Reload users',
255
+ memoryExportUser: '📦 Export user JSON',
256
+ memoryPersonaCard: 'Persona summary',
257
+ memoryPersonaPlaceholder: '(No persona for this user yet — will be auto-generated at next consolidation, or write one manually.)',
258
+ memoryPersonaSave: 'Save persona',
259
+ memoryPersonaDelete: 'Delete persona',
260
+ memoryFactsCard: 'Facts',
261
+ memoryFactsSearch: 'Search (FTS5)',
262
+ memoryFactsCatAll: 'All types',
263
+ memoryFactsQuery: 'Query',
264
+ memoryFactsSelectUser: '(Select a user)',
265
+ memoryFactsPrev: '← Prev',
266
+ memoryFactsNext: 'Next →',
267
+ memoryFactsClearLowConf: '🗑 Clear low-confidence',
268
+ memoryFactsClearAll: '⚠️ Clear all',
269
+ // ── v1.6 Vector card ──
270
+ vecTitle: '🔍 Vector retrieval',
271
+ vecSubtitle: '(optional enhancement · default off)',
272
+ vecWhyHeading: 'Why add vectors?',
273
+ vecWhyDefault: 'Default (FTS5 keyword): only literal hits — "Tencent" → facts containing "Tencent".',
274
+ vecWhyAdded: 'With vectors: also recall synonyms / cross-lingual / conceptual queries:',
275
+ vecWhyEx1: '▸ "investment preference" → recalls "user prefers low-drawdown, steady"',
276
+ vecWhyEx2: '▸ "Beijing" → recalls "user works in 北京"',
277
+ vecWhyEx3: '▸ "my son\'s birthday" → recalls "Tom born 2018-03-15"',
278
+ vecWhenHeading: 'When to enable',
279
+ vecWhenBody: '50+ facts stored AND agent often "forgets" what the user said. Stock install does not need it — FTS5 is enough.',
280
+ vecBackend: 'Backend',
281
+ vecBackendOff: 'Off (FTS5 only)',
282
+ vecBackendLocal: 'Local BGE (one-time download ~250MB)',
283
+ vecBackendOpenai: 'Remote OpenAI-compatible (baseUrl + key)',
284
+ vecLocalModel: 'Local model',
285
+ vecLocalSmall: 'Small (small, ~100MB, speed-first)',
286
+ vecLocalBase: 'Medium (base, ~250MB, recommended)',
287
+ vecLocalLarge: 'Large (large, ~500MB, accuracy-first)',
288
+ vecLocalCustom: 'Custom…',
289
+ vecLocalHint: 'All three presets are BGE Chinese-optimized models (open-sourced by BAAI; Xenova provides ONNX quantized builds on HuggingFace). First download goes to ~/.agim/cache/transformers.',
290
+ vecDownloadBtn: '📥 Download model (first time 5–10 min, WiFi recommended)',
291
+ vecOpenaiKey: 'API Key',
292
+ vecOpenaiReveal: '🔓 Reveal & edit',
293
+ vecOpenaiKeyHint: 'Masked by default; if you don\'t click reveal, save will not overwrite the existing value.',
294
+ vecTestBtn: '🧪 Test connection (10s timeout)',
295
+ vecSaveBtn: 'Save config',
296
+ vecBackfillBtn: '🔄 Backfill all',
297
+ vecClearBtn: '🗑 Clear vector index',
298
+ vecMemoryDisabled: '○ Memory not enabled (turn on in Settings → automation)',
233
299
  },
234
300
  zh: {
235
301
  title: 'Agim — 任务',
@@ -435,6 +501,72 @@
435
501
  body: '投递队列中连续 6 次失败(累计退避约 3 小时)的行进入此状态,不再自动重试。点击"重试"会将其重新入队、退避计数清零。',
436
502
  },
437
503
  },
504
+ // ── v1.2 Cost & Health tab ──
505
+ costH2: '💰 成本与健康',
506
+ costWindow: '窗口',
507
+ costDays1: '1 天',
508
+ costDays7: '7 天',
509
+ costDays30: '30 天',
510
+ costDays90: '90 天',
511
+ costRefresh: '刷新',
512
+ costDailyTrend: '每日趋势',
513
+ costMetricCalls: '调用数',
514
+ costMetricCost: '成本($)',
515
+ costMetricErrors: '错误数',
516
+ costMetricLatency: '平均耗时(ms)',
517
+ costTopUser: '用户排行',
518
+ costTopAgent: 'Agent 排行',
519
+ costTopPlatform: '平台排行',
520
+ // ── v1.5/1.6 Memory tab ──
521
+ memoryH2: '🧠 长期记忆管理',
522
+ memoryIntro: 'agent 自动从对话中提取的事实 + 记忆画像(persona summary)。选用户 → 查看 / 编辑 / 删除。全部数据存在本机 ~/.agim/memory.db,不会上传到任何外部服务。',
523
+ memoryUser: '用户',
524
+ memoryUserLoading: '— 加载中 —',
525
+ memoryReloadUsers: '🔄 重载用户列表',
526
+ memoryExportUser: '📦 导出此用户 JSON',
527
+ memoryPersonaCard: '记忆画像',
528
+ memoryPersonaPlaceholder: '(此用户暂无记忆画像 — 等下次 consolidation 自动生成,或手动写入)',
529
+ memoryPersonaSave: '保存记忆画像',
530
+ memoryPersonaDelete: '删除记忆画像',
531
+ memoryFactsCard: 'Facts',
532
+ memoryFactsSearch: '搜索(FTS5)',
533
+ memoryFactsCatAll: '所有类型',
534
+ memoryFactsQuery: '查询',
535
+ memoryFactsSelectUser: '(请选择用户)',
536
+ memoryFactsPrev: '← 上一页',
537
+ memoryFactsNext: '下一页 →',
538
+ memoryFactsClearLowConf: '🗑 清低置信度',
539
+ memoryFactsClearAll: '⚠️ 清空所有',
540
+ // ── v1.6 Vector card ──
541
+ vecTitle: '🔍 向量召回',
542
+ vecSubtitle: '(可选增强 · 默认关闭)',
543
+ vecWhyHeading: '为什么加向量?',
544
+ vecWhyDefault: '默认(FTS5 关键词):只能命中字面 — 「腾讯」→ 含「腾讯」字样的事实。',
545
+ vecWhyAdded: '加向量后:召回同义 / 跨语言 / 概念性查询:',
546
+ vecWhyEx1: '▸ 「投资偏好」→ 召回「用户偏好低回撤、稳健」',
547
+ vecWhyEx2: '▸ 「Beijing」→ 召回「用户在北京工作」',
548
+ vecWhyEx3: '▸ 「我儿子生日」→ 召回「Tom 2018-03-15 出生」',
549
+ vecWhenHeading: '何时启用',
550
+ vecWhenBody: '积累 50+ 条事实、agent 经常"忘了用户说过 X"。初装不需要 — FTS5 已经够用。',
551
+ vecBackend: '后端',
552
+ vecBackendOff: '关闭(仅 FTS5)',
553
+ vecBackendLocal: '本地 BGE(一次性下载 ~250MB)',
554
+ vecBackendOpenai: '远程 OpenAI 兼容(baseUrl + key)',
555
+ vecLocalModel: '本地模型',
556
+ vecLocalSmall: '小(small,~100MB,速度优先)',
557
+ vecLocalBase: '中(base,~250MB,推荐)',
558
+ vecLocalLarge: '大(large,~500MB,精度优先)',
559
+ vecLocalCustom: '自定义…',
560
+ vecLocalHint: '三个预设都是 BGE 中文模型(智源研究院开源,HuggingFace 上 Xenova 提供 ONNX 量化版)。首次下载到 ~/.agim/cache/transformers',
561
+ vecDownloadBtn: '📥 下载模型(首次 5-10 min,建议 WiFi)',
562
+ vecOpenaiKey: 'API Key',
563
+ vecOpenaiReveal: '🔓 显示并编辑',
564
+ vecOpenaiKeyHint: '默认掩码显示;保存时如未点开「显示」,原值不会被覆盖。',
565
+ vecTestBtn: '🧪 测试连接(10s 超时)',
566
+ vecSaveBtn: '保存配置',
567
+ vecBackfillBtn: '🔄 回填全部',
568
+ vecClearBtn: '🗑 清空向量索引',
569
+ vecMemoryDisabled: '○ 记忆未启用(在 Settings → 自动化记忆开启)',
438
570
  },
439
571
  };
440
572
  window.__t = T[window.__lang];
@@ -772,6 +904,8 @@
772
904
  <button type="button" class="tab" data-tab="audit" id="tab-audit"></button>
773
905
  <button type="button" class="tab" data-tab="outbox" id="tab-outbox">Outbox</button>
774
906
  <button type="button" class="tab" data-tab="a2a" id="tab-a2a">A2A</button>
907
+ <button type="button" class="tab" data-tab="cost" id="tab-cost">💰 Cost &amp; Health</button>
908
+ <button type="button" class="tab" data-tab="memory" id="tab-memory">🧠 Memory</button>
775
909
  </div>
776
910
 
777
911
  <section id="jobs-pane">
@@ -941,6 +1075,210 @@
941
1075
  <div id="a2a-list"></div>
942
1076
  <div id="a2a-tree"></div>
943
1077
  </section>
1078
+
1079
+ <!-- v1.3 — Cost & Health: aggregated metrics from the audit-log -->
1080
+ <section id="cost-pane" hidden>
1081
+ <h2 style="margin-top:0" data-i18n="costH2">💰 Cost &amp; Health</h2>
1082
+ <div class="toolbar" style="margin-bottom:12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
1083
+ <label><span data-i18n="costWindow">窗口</span>:
1084
+ <select id="cost-days">
1085
+ <option value="1" data-i18n="costDays1">1 天</option>
1086
+ <option value="7" selected data-i18n="costDays7">7 天</option>
1087
+ <option value="30" data-i18n="costDays30">30 天</option>
1088
+ <option value="90" data-i18n="costDays90">90 天</option>
1089
+ </select>
1090
+ </label>
1091
+ <button type="button" id="btn-cost-refresh" data-i18n="costRefresh">刷新</button>
1092
+ <span class="muted" id="cost-range" style="font-size:12px"></span>
1093
+ </div>
1094
+
1095
+ <!-- KPI cards -->
1096
+ <div id="cost-kpi" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:16px"></div>
1097
+
1098
+ <!-- Daily trendline -->
1099
+ <div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px;margin-bottom:16px">
1100
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
1101
+ <strong data-i18n="costDailyTrend">每日趋势</strong>
1102
+ <div style="font-size:12px" class="muted">
1103
+ <label><input type="radio" name="cost-trend-metric" value="calls" checked> <span data-i18n="costMetricCalls">调用数</span></label>
1104
+ <label style="margin-left:8px"><input type="radio" name="cost-trend-metric" value="cost"> <span data-i18n="costMetricCost">成本($)</span></label>
1105
+ <label style="margin-left:8px"><input type="radio" name="cost-trend-metric" value="errors"> <span data-i18n="costMetricErrors">错误数</span></label>
1106
+ <label style="margin-left:8px"><input type="radio" name="cost-trend-metric" value="avgLatencyMs"> <span data-i18n="costMetricLatency">平均耗时(ms)</span></label>
1107
+ </div>
1108
+ </div>
1109
+ <div style="position:relative;height:280px"><canvas id="cost-trend"></canvas></div>
1110
+ </div>
1111
+
1112
+ <!-- Top-N tables -->
1113
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:12px">
1114
+ <div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px">
1115
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
1116
+ <strong data-i18n="costTopUser">Top by User</strong>
1117
+ <select class="cost-topn-by" data-target="user">
1118
+ <option value="cost">cost</option>
1119
+ <option value="calls" selected>calls</option>
1120
+ <option value="errors">errors</option>
1121
+ <option value="avg_latency">avg latency</option>
1122
+ </select>
1123
+ </div>
1124
+ <div id="cost-topn-user"></div>
1125
+ </div>
1126
+ <div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px">
1127
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
1128
+ <strong data-i18n="costTopAgent">Top by Agent</strong>
1129
+ <select class="cost-topn-by" data-target="agent">
1130
+ <option value="cost">cost</option>
1131
+ <option value="calls" selected>calls</option>
1132
+ <option value="errors">errors</option>
1133
+ <option value="avg_latency">avg latency</option>
1134
+ </select>
1135
+ </div>
1136
+ <div id="cost-topn-agent"></div>
1137
+ </div>
1138
+ <div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px">
1139
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
1140
+ <strong data-i18n="costTopPlatform">Top by Platform</strong>
1141
+ <select class="cost-topn-by" data-target="platform">
1142
+ <option value="cost">cost</option>
1143
+ <option value="calls" selected>calls</option>
1144
+ <option value="errors">errors</option>
1145
+ <option value="avg_latency">avg latency</option>
1146
+ </select>
1147
+ </div>
1148
+ <div id="cost-topn-platform"></div>
1149
+ </div>
1150
+ </div>
1151
+ </section>
1152
+
1153
+ <!-- v1.5 — Memory admin tab: per-user persona + facts inspection / edit -->
1154
+ <section id="memory-pane" hidden>
1155
+ <h2 style="margin-top:0" data-i18n="memoryH2">🧠 长期记忆管理</h2>
1156
+ <p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px" data-i18n="memoryIntro">
1157
+ agent 自动从对话中提取的事实 + 记忆画像(persona summary)。选用户 → 查看 / 编辑 / 删除。
1158
+ 全部数据存在本机 ~/.agim/memory.db,不会上传到任何外部服务。
1159
+ </p>
1160
+ <div class="toolbar" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
1161
+ <label><span data-i18n="memoryUser">用户</span>:
1162
+ <select id="mem-user-select" style="min-width:280px">
1163
+ <option value="" data-i18n="memoryUserLoading">— 加载中 —</option>
1164
+ </select>
1165
+ </label>
1166
+ <button type="button" id="btn-mem-refresh-users" data-i18n="memoryReloadUsers">🔄 重载用户列表</button>
1167
+ <a id="btn-mem-export" href="#" target="_blank" style="display:none" data-i18n="memoryExportUser">📦 导出此用户 JSON</a>
1168
+ </div>
1169
+
1170
+ <!-- v1.6 — Vector retrieval (optional enhancement) -->
1171
+ <div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px;margin-bottom:16px">
1172
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
1173
+ <strong><span data-i18n="vecTitle">🔍 向量召回</span> <span class="muted" style="font-weight:400;font-size:12px" data-i18n="vecSubtitle">(可选增强 · 默认关闭)</span></strong>
1174
+ <span class="muted" id="mem-vec-status-badge" style="font-size:12px">—</span>
1175
+ </div>
1176
+ <div style="background:var(--code,#f4f6f8);padding:10px;border-radius:6px;font-size:12px;color:var(--muted,#656d76);margin-bottom:10px;line-height:1.6">
1177
+ <strong style="color:var(--fg,#1f2328)" data-i18n="vecWhyHeading">为什么加向量?</strong><br>
1178
+ <span data-i18n="vecWhyDefault">默认(FTS5 关键词):只能命中字面 — 「腾讯」→ 含「腾讯」字样的事实。</span><br>
1179
+ <span data-i18n="vecWhyAdded">加向量后:召回同义 / 跨语言 / 概念性查询:</span><br>
1180
+ <span data-i18n="vecWhyEx1">▸ 「投资偏好」→ 召回「用户偏好低回撤、稳健」</span><br>
1181
+ <span data-i18n="vecWhyEx2">▸ 「Beijing」→ 召回「用户在北京工作」</span><br>
1182
+ <span data-i18n="vecWhyEx3">▸ 「我儿子生日」→ 召回「Tom 2018-03-15 出生」</span><br>
1183
+ <strong style="color:var(--fg,#1f2328)" data-i18n="vecWhenHeading">何时启用</strong>:<span data-i18n="vecWhenBody">积累 50+ 条事实、agent 经常"忘了用户说过 X"。初装不需要 — FTS5 已经够用。</span>
1184
+ </div>
1185
+
1186
+ <label data-i18n="vecBackend">后端</label>
1187
+ <select id="mem-vec-backend" style="max-width:280px">
1188
+ <option value="off" data-i18n="vecBackendOff">关闭(仅 FTS5)</option>
1189
+ <option value="local" data-i18n="vecBackendLocal">本地 BGE(一次性下载 ~250MB)</option>
1190
+ <option value="openai" data-i18n="vecBackendOpenai">远程 OpenAI 兼容(baseUrl + key)</option>
1191
+ </select>
1192
+
1193
+ <!-- Local config (shown when backend === 'local') -->
1194
+ <div id="mem-vec-local-cfg" style="display:none;margin-top:10px;padding:10px;border:1px dashed var(--border,#d0d7de);border-radius:6px">
1195
+ <label data-i18n="vecLocalModel">本地模型</label>
1196
+ <select id="mem-vec-local-preset" style="max-width:400px">
1197
+ <option value="Xenova/bge-small-zh-v1.5" data-i18n="vecLocalSmall">小(small,~100MB,速度优先)</option>
1198
+ <option value="Xenova/bge-base-zh-v1.5" selected data-i18n="vecLocalBase">中(base,~250MB,推荐)</option>
1199
+ <option value="Xenova/bge-large-zh-v1.5" data-i18n="vecLocalLarge">大(large,~500MB,精度优先)</option>
1200
+ <option value="__custom__" data-i18n="vecLocalCustom">自定义…</option>
1201
+ </select>
1202
+ <input type="text" id="mem-vec-local-model" placeholder="Xenova/your-model" style="max-width:400px;margin-top:6px;display:none" />
1203
+ <div class="muted" style="font-size:11px;margin-top:4px" data-i18n="vecLocalHint">三个预设都是 BGE 中文模型(智源研究院开源,HuggingFace 上 Xenova 提供 ONNX 量化版)。首次下载到 ~/.agim/cache/transformers</div>
1204
+ <div style="margin-top:8px">
1205
+ <button type="button" id="btn-mem-vec-download" class="btn" data-i18n="vecDownloadBtn">📥 下载模型(首次 5-10 min,建议 WiFi)</button>
1206
+ <span id="mem-vec-download-progress" class="muted" style="font-size:12px;margin-left:8px"></span>
1207
+ </div>
1208
+ </div>
1209
+
1210
+ <!-- OpenAI config (shown when backend === 'openai') -->
1211
+ <div id="mem-vec-openai-cfg" style="display:none;margin-top:10px;padding:10px;border:1px dashed var(--border,#d0d7de);border-radius:6px">
1212
+ <label>Base URL</label>
1213
+ <input type="text" id="mem-vec-openai-url" placeholder="https://api.openai.com/v1" style="max-width:400px" />
1214
+ <label style="margin-top:6px">Model</label>
1215
+ <input type="text" id="mem-vec-openai-model" placeholder="text-embedding-3-small" style="max-width:400px" />
1216
+ <label style="margin-top:6px" data-i18n="vecOpenaiKey">API Key</label>
1217
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
1218
+ <input type="password" id="mem-vec-openai-key" placeholder="sk-..." style="max-width:400px;background:#f6f8fa;color:#57606a" autocomplete="off" readonly />
1219
+ <button type="button" id="btn-mem-vec-openai-reveal" class="btn" style="font-size:12px;padding:2px 8px" data-i18n="vecOpenaiReveal">🔓 显示并编辑</button>
1220
+ </div>
1221
+ <div class="muted" style="font-size:11px;margin-top:4px" data-i18n="vecOpenaiKeyHint">默认掩码显示;保存时如未点开「显示」,原值不会被覆盖。</div>
1222
+ <div style="margin-top:8px">
1223
+ <button type="button" id="btn-mem-vec-test" class="btn" data-i18n="vecTestBtn">🧪 测试连接(10s 超时)</button>
1224
+ <span id="mem-vec-test-result" class="muted" style="font-size:12px;margin-left:8px"></span>
1225
+ </div>
1226
+ </div>
1227
+
1228
+ <div class="actions" style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
1229
+ <button type="button" id="btn-mem-vec-save" class="btn btn-primary" data-i18n="vecSaveBtn">保存配置</button>
1230
+ <button type="button" id="btn-mem-vec-backfill" class="btn" data-i18n="vecBackfillBtn">🔄 回填全部</button>
1231
+ <button type="button" id="btn-mem-vec-clear" class="btn" data-i18n="vecClearBtn">🗑 清空向量索引</button>
1232
+ </div>
1233
+ <p class="muted" id="mem-vec-status-detail" style="margin-top:8px;font-size:12px"></p>
1234
+ </div>
1235
+
1236
+ <!-- 记忆画像 display + editor -->
1237
+ <div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px;margin-bottom:16px">
1238
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
1239
+ <strong data-i18n="memoryPersonaCard">记忆画像</strong>
1240
+ <span class="muted" id="mem-persona-meta" style="font-size:12px"></span>
1241
+ </div>
1242
+ <textarea id="mem-persona-text" rows="6" style="width:100%;font-family:ui-monospace,monospace;font-size:13px;box-sizing:border-box" data-i18n-attr="placeholder:memoryPersonaPlaceholder" placeholder="(此用户暂无记忆画像 — 等下次 consolidation 自动生成,或手动写入)"></textarea>
1243
+ <div class="actions" style="margin-top:8px;display:flex;gap:8px">
1244
+ <button type="button" id="btn-mem-persona-save" class="btn btn-primary" data-i18n="memoryPersonaSave">保存记忆画像</button>
1245
+ <button type="button" id="btn-mem-persona-delete" class="btn" data-i18n="memoryPersonaDelete">删除记忆画像</button>
1246
+ </div>
1247
+ <p class="muted" id="mem-persona-status" style="margin-top:6px;font-size:12px"></p>
1248
+ </div>
1249
+
1250
+ <!-- Facts table + filters -->
1251
+ <div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px;margin-bottom:16px">
1252
+ <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px">
1253
+ <strong><span data-i18n="memoryFactsCard">Facts</span> <span id="mem-facts-total" class="muted" style="font-weight:400;font-size:12px"></span></strong>
1254
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
1255
+ <input type="text" id="mem-facts-query" data-i18n-attr="placeholder:memoryFactsSearch" placeholder="搜索(FTS5)" style="width:200px;font-size:12px" />
1256
+ <select id="mem-facts-category" style="font-size:12px">
1257
+ <option value="" data-i18n="memoryFactsCatAll">所有类型</option>
1258
+ <option value="fact">fact</option>
1259
+ <option value="preference">preference</option>
1260
+ <option value="goal">goal</option>
1261
+ <option value="history">history</option>
1262
+ <option value="profile">profile</option>
1263
+ </select>
1264
+ <button type="button" id="btn-mem-facts-refresh" style="font-size:12px" data-i18n="memoryFactsQuery">查询</button>
1265
+ </div>
1266
+ </div>
1267
+ <div id="mem-facts-list" data-i18n="memoryFactsSelectUser">(请选择用户)</div>
1268
+ <div style="display:flex;justify-content:space-between;margin-top:8px;font-size:12px;align-items:center">
1269
+ <div class="muted">
1270
+ <button type="button" id="btn-mem-facts-prev" data-i18n="memoryFactsPrev">← 上一页</button>
1271
+ <span id="mem-facts-pagelabel">—</span>
1272
+ <button type="button" id="btn-mem-facts-next" data-i18n="memoryFactsNext">下一页 →</button>
1273
+ </div>
1274
+ <div>
1275
+ <button type="button" id="btn-mem-facts-clear-lowconf" class="btn" title="删除 confidence ≤ 0.4 的所有事实" data-i18n="memoryFactsClearLowConf">🗑 清低置信度</button>
1276
+ <button type="button" id="btn-mem-facts-clear-all" class="btn" style="color:#cf222e" title="慎用:清空此用户的所有事实" data-i18n="memoryFactsClearAll">⚠️ 清空所有</button>
1277
+ </div>
1278
+ </div>
1279
+ <p class="muted" id="mem-facts-status" style="margin-top:6px;font-size:12px"></p>
1280
+ </div>
1281
+ </section>
944
1282
  </main>
945
1283
 
946
1284
  <div class="modal-bg" id="modal-bg">
@@ -1071,6 +1409,8 @@
1071
1409
  document.getElementById('files-pane').hidden = tab !== 'files';
1072
1410
  document.getElementById('outbox-pane').hidden = tab !== 'outbox';
1073
1411
  document.getElementById('a2a-pane').hidden = tab !== 'a2a';
1412
+ document.getElementById('cost-pane').hidden = tab !== 'cost';
1413
+ document.getElementById('memory-pane').hidden = tab !== 'memory';
1074
1414
  // Lazy-load on first activation; auto-refresh hooks below kick in too.
1075
1415
  if (tab === 'schedules') loadSchedules();
1076
1416
  if (tab === 'background') { ensureBgRootsLoaded().then(loadBgjobs); }
@@ -1081,6 +1421,15 @@
1081
1421
  if (tab === 'files') ensureFilesAgentLoaded().then(() => loadFiles(filesPath));
1082
1422
  if (tab === 'outbox') loadOutbox();
1083
1423
  if (tab === 'a2a') loadA2A();
1424
+ if (tab === 'cost') loadCost();
1425
+ if (tab === 'memory') {
1426
+ loadMemoryUsers();
1427
+ // P1-9: only load vector card + poll status when memory is
1428
+ // actually enabled. Otherwise the dashboard would burn requests
1429
+ // and confuse users on a stock install where memory is off.
1430
+ checkMemoryEnabledThen(() => { loadMemVecConfig(); loadMemVecStatus(); });
1431
+ }
1432
+ if (tab !== 'memory') stopMemVecPolling();
1084
1433
  // Pause/resume auto-refresh so hidden tabs don't poll.
1085
1434
  setupBgAutoRefresh();
1086
1435
  setupApprovalsAutoRefresh();
@@ -1092,7 +1441,19 @@
1092
1441
  async function api(path, init) {
1093
1442
  const headers = { 'Content-Type': 'application/json', ...(init?.headers) };
1094
1443
  const res = await fetch(path, { ...init, headers, credentials: 'same-origin' });
1095
- if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
1444
+ if (!res.ok) {
1445
+ // v1.5 — attach status + try to surface server-provided error so
1446
+ // callers can branch on `err.status === 404` etc. Falls back to a
1447
+ // plain statusText when the body isn't JSON.
1448
+ let msg = `${res.status} ${res.statusText}`;
1449
+ try {
1450
+ const j = await res.json();
1451
+ if (j && j.error) msg = `${res.status} ${j.error}`;
1452
+ } catch { /* not JSON; keep statusText */ }
1453
+ const err = new Error(msg);
1454
+ err.status = res.status;
1455
+ throw err;
1456
+ }
1096
1457
  return res.json();
1097
1458
  }
1098
1459
 
@@ -2370,6 +2731,621 @@
2370
2731
  if (id) loadA2ATree(id);
2371
2732
  });
2372
2733
 
2734
+ // ─── v1.3 — Cost & Health tab ────────────────────────────────────
2735
+ let costChart = null;
2736
+ let costChartJsLoaded = null;
2737
+
2738
+ function ensureChartJs() {
2739
+ if (window.Chart) return Promise.resolve();
2740
+ if (costChartJsLoaded) return costChartJsLoaded;
2741
+ costChartJsLoaded = new Promise((resolve, reject) => {
2742
+ const s = document.createElement('script');
2743
+ s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js';
2744
+ s.crossOrigin = 'anonymous';
2745
+ s.onload = () => resolve();
2746
+ s.onerror = () => reject(new Error('chart.js failed to load'));
2747
+ document.head.appendChild(s);
2748
+ });
2749
+ return costChartJsLoaded;
2750
+ }
2751
+
2752
+ function fmtCost(n) { return '$' + (Math.round(n * 10000) / 10000).toFixed(4); }
2753
+ function fmtMs(n) {
2754
+ if (n < 1000) return n + 'ms';
2755
+ if (n < 60000) return (n / 1000).toFixed(1) + 's';
2756
+ return (n / 60000).toFixed(1) + 'm';
2757
+ }
2758
+ function fmtPct(n) { return (n * 100).toFixed(2) + '%'; }
2759
+
2760
+ async function loadCost() {
2761
+ const days = parseInt(document.getElementById('cost-days').value, 10) || 7;
2762
+ try {
2763
+ const data = await api('/api/health/summary?days=' + days);
2764
+ renderCostKpis(data);
2765
+ renderCostRange(data);
2766
+ await ensureChartJs().catch(() => null);
2767
+ renderCostTrend(data);
2768
+ } catch (err) {
2769
+ document.getElementById('cost-kpi').innerHTML = '<div class="error">加载失败: ' + (err.message || err) + '</div>';
2770
+ }
2771
+ // Load three top-N tables in parallel.
2772
+ for (const dim of ['user', 'agent', 'platform']) {
2773
+ const sel = document.querySelector('.cost-topn-by[data-target="' + dim + '"]');
2774
+ loadTopN(dim, sel ? sel.value : 'calls');
2775
+ }
2776
+ }
2777
+
2778
+ function renderCostKpis(data) {
2779
+ const t = data.totals;
2780
+ const card = (label, value, sub) => `
2781
+ <div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px">
2782
+ <div style="font-size:11px;color:var(--muted,#656d76);text-transform:uppercase;letter-spacing:.5px">${label}</div>
2783
+ <div style="font-size:22px;font-weight:600;margin-top:4px">${value}</div>
2784
+ ${sub ? '<div style="font-size:11px;color:var(--muted,#656d76);margin-top:2px">' + sub + '</div>' : ''}
2785
+ </div>`;
2786
+ document.getElementById('cost-kpi').innerHTML = [
2787
+ card('调用数', t.calls.toLocaleString()),
2788
+ card('成本', fmtCost(t.cost), '近 ' + data.days + ' 天'),
2789
+ card('错误率', fmtPct(t.errorRate), t.errors + ' / ' + t.calls),
2790
+ card('平均耗时', fmtMs(t.avgLatencyMs), 'p95: ' + fmtMs(t.p95LatencyMs)),
2791
+ ].join('');
2792
+ }
2793
+
2794
+ function renderCostRange(data) {
2795
+ document.getElementById('cost-range').textContent = data.since + ' → ' + data.until;
2796
+ }
2797
+
2798
+ function renderCostTrend(data) {
2799
+ const canvas = document.getElementById('cost-trend');
2800
+ if (!canvas || !window.Chart) return;
2801
+ const metric = (document.querySelector('input[name="cost-trend-metric"]:checked')?.value) || 'calls';
2802
+ const labels = data.byDay.map(d => d.date);
2803
+ const values = data.byDay.map(d => d[metric]);
2804
+ const labelMap = { calls: '调用数', cost: '成本($)', errors: '错误数', avgLatencyMs: '平均耗时(ms)' };
2805
+ if (costChart) costChart.destroy();
2806
+ costChart = new Chart(canvas, {
2807
+ type: 'line',
2808
+ data: {
2809
+ labels,
2810
+ datasets: [{
2811
+ label: labelMap[metric] || metric,
2812
+ data: values,
2813
+ borderColor: '#2563eb',
2814
+ backgroundColor: 'rgba(37,99,235,0.10)',
2815
+ tension: 0.25,
2816
+ fill: true,
2817
+ }],
2818
+ },
2819
+ options: {
2820
+ responsive: true,
2821
+ maintainAspectRatio: false,
2822
+ plugins: { legend: { display: false } },
2823
+ scales: { y: { beginAtZero: true } },
2824
+ },
2825
+ });
2826
+ }
2827
+
2828
+ async function loadTopN(dim, by) {
2829
+ const wrap = document.getElementById('cost-topn-' + dim);
2830
+ if (!wrap) return;
2831
+ wrap.innerHTML = '<div class="muted">加载中…</div>';
2832
+ const days = parseInt(document.getElementById('cost-days').value, 10) || 7;
2833
+ try {
2834
+ const data = await api('/api/health/topn?dim=' + dim + '&by=' + by + '&days=' + days + '&limit=10');
2835
+ if (!data.items.length) { wrap.innerHTML = '<div class="muted">(无数据)</div>'; return; }
2836
+ wrap.innerHTML = '<table style="width:100%;font-size:12px;border-collapse:collapse">' +
2837
+ '<thead><tr style="border-bottom:1px solid var(--border,#d0d7de)">' +
2838
+ '<th style="text-align:left;padding:4px 6px;font-weight:600">key</th>' +
2839
+ '<th style="text-align:right;padding:4px 6px">calls</th>' +
2840
+ '<th style="text-align:right;padding:4px 6px">cost</th>' +
2841
+ '<th style="text-align:right;padding:4px 6px">err</th>' +
2842
+ '<th style="text-align:right;padding:4px 6px">avg ms</th>' +
2843
+ '</tr></thead><tbody>' +
2844
+ data.items.map(it => `<tr style="border-bottom:1px solid var(--border,#d0d7de)">
2845
+ <td style="padding:4px 6px;word-break:break-all;font-family:ui-monospace,monospace">${escapeHtmlSafe(it.key).slice(0, 50)}</td>
2846
+ <td style="text-align:right;padding:4px 6px">${it.calls}</td>
2847
+ <td style="text-align:right;padding:4px 6px">${fmtCost(it.cost)}</td>
2848
+ <td style="text-align:right;padding:4px 6px">${it.errors}</td>
2849
+ <td style="text-align:right;padding:4px 6px">${fmtMs(it.avgLatencyMs)}</td>
2850
+ </tr>`).join('') + '</tbody></table>';
2851
+ } catch (err) {
2852
+ wrap.innerHTML = '<div class="error">加载失败: ' + (err.message || err) + '</div>';
2853
+ }
2854
+ }
2855
+
2856
+ function escapeHtmlSafe(s) {
2857
+ return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2858
+ }
2859
+
2860
+ document.getElementById('btn-cost-refresh')?.addEventListener('click', loadCost);
2861
+ document.getElementById('cost-days')?.addEventListener('change', loadCost);
2862
+ document.querySelectorAll('input[name="cost-trend-metric"]').forEach(el => {
2863
+ el.addEventListener('change', () => loadCost());
2864
+ });
2865
+ document.querySelectorAll('.cost-topn-by').forEach(el => {
2866
+ el.addEventListener('change', () => loadTopN(el.dataset.target, el.value));
2867
+ });
2868
+
2869
+ // ─── v1.5 — Memory admin tab ──────────────────────────────────────
2870
+ let memCurrentUser = '';
2871
+ let memFactsPage = { offset: 0, limit: 20, total: 0 };
2872
+
2873
+ function fmtMemDate(unix) {
2874
+ if (!unix) return '—';
2875
+ return new Date(unix * 1000).toISOString().slice(0, 19).replace('T', ' ');
2876
+ }
2877
+
2878
+ // ─── v1.6 vector backend UI ────────────────────────────────────
2879
+ let memVecPollTimer = null;
2880
+
2881
+ function showVecBackendCfg(backend) {
2882
+ const local = document.getElementById('mem-vec-local-cfg');
2883
+ const openai = document.getElementById('mem-vec-openai-cfg');
2884
+ if (local) local.style.display = backend === 'local' ? 'block' : 'none';
2885
+ if (openai) openai.style.display = backend === 'openai' ? 'block' : 'none';
2886
+ }
2887
+
2888
+ async function loadMemVecStatus() {
2889
+ try {
2890
+ const data = await api('/api/memory/vector/status' + (memCurrentUser ? '?user_key=' + encodeURIComponent(memCurrentUser) : ''));
2891
+ const s = data.status || {};
2892
+ const cov = data.coverage || { total: 0, withEmbedding: 0 };
2893
+ const badge = document.getElementById('mem-vec-status-badge');
2894
+ if (badge) {
2895
+ badge.textContent = s.ready
2896
+ ? `✓ ${s.backend} · dim ${s.dims} · 索引 ${cov.withEmbedding}/${cov.total}`
2897
+ : `○ ${s.backend} · 未就绪${s.lastError ? ' (' + s.lastError.slice(0, 60) + ')' : ''}`;
2898
+ badge.style.color = s.ready ? '#16a34a' : 'var(--muted, #656d76)';
2899
+ }
2900
+ const detail = document.getElementById('mem-vec-status-detail');
2901
+ if (detail) {
2902
+ const lines = [];
2903
+ if (s.detail && s.detail.progress) {
2904
+ const p = s.detail.progress;
2905
+ if (p.phase === 'downloading') {
2906
+ const pct = p.bytesTotal > 0 ? Math.round(p.bytesDone / p.bytesTotal * 100) : 0;
2907
+ lines.push(`📥 下载中:${p.filesDone}/${p.filesTotal} 文件,${(p.bytesDone / 1024 / 1024).toFixed(1)} / ${(p.bytesTotal / 1024 / 1024).toFixed(1)} MB (${pct}%)`);
2908
+ } else if (p.phase === 'ready') {
2909
+ lines.push(`✓ 模型就绪(耗时 ${((p.finishedAt - p.startedAt) / 1000).toFixed(1)}s)`);
2910
+ } else if (p.phase === 'failed') {
2911
+ lines.push(`❌ 下载失败:${escapeHtmlSafe(p.error || '未知错误')}`);
2912
+ }
2913
+ }
2914
+ if (data.jobs && data.jobs.length > 0) {
2915
+ data.jobs.forEach(j => {
2916
+ const elapsed = ((j.finishedAt || Date.now()) - j.startedAt) / 1000;
2917
+ // Note: lines already contain escaped fragments — joining
2918
+ // raw avoids the double-escape that mangled `<`/`&` chars
2919
+ // in error messages.
2920
+ lines.push(`[${escapeHtmlSafe(j.kind)}] ${escapeHtmlSafe(j.phase)} · ${elapsed.toFixed(1)}s · ${escapeHtmlSafe(j.message)}`);
2921
+ });
2922
+ }
2923
+ detail.innerHTML = lines.join('<br>') || '';
2924
+ }
2925
+ // Update download button if a job is in flight.
2926
+ const dlBtn = document.getElementById('btn-mem-vec-download');
2927
+ if (dlBtn) {
2928
+ const hasDownloading = (data.jobs || []).some(j => j.kind === 'download' && j.phase === 'running');
2929
+ dlBtn.disabled = hasDownloading || s.ready;
2930
+ if (s.ready) dlBtn.textContent = '✓ 已就绪';
2931
+ else if (hasDownloading) dlBtn.textContent = '⏳ 下载中…';
2932
+ else dlBtn.textContent = '📥 下载模型(首次 5-10 min,建议 WiFi)';
2933
+ }
2934
+ return data;
2935
+ } catch (err) {
2936
+ const badge = document.getElementById('mem-vec-status-badge');
2937
+ if (badge) badge.textContent = '状态加载失败: ' + (err.message || err);
2938
+ }
2939
+ }
2940
+
2941
+ function startMemVecPolling() {
2942
+ stopMemVecPolling();
2943
+ memVecPollTimer = setInterval(async () => {
2944
+ const data = await loadMemVecStatus();
2945
+ // P1-8: auto-stop once there's nothing to watch. Without this the
2946
+ // 2 s poll keeps firing forever and floods the server log.
2947
+ const s = (data && data.status) || {};
2948
+ const running = ((data && data.jobs) || []).some(j => j.phase === 'running');
2949
+ if (!running && s.ready) stopMemVecPolling();
2950
+ if (!running && !s.ready && s.backend === 'off') stopMemVecPolling();
2951
+ }, 2000);
2952
+ }
2953
+ function stopMemVecPolling() {
2954
+ if (memVecPollTimer) { clearInterval(memVecPollTimer); memVecPollTimer = null; }
2955
+ }
2956
+
2957
+ async function checkMemoryEnabledThen(fn) {
2958
+ try {
2959
+ const data = await api('/api/env');
2960
+ const env = data.env || {};
2961
+ const raw = String(env.IMHUB_MEMORY_ENABLED || '').toLowerCase();
2962
+ const enabled = raw === '1' || raw === 'true' || raw === 'yes';
2963
+ if (enabled) {
2964
+ fn();
2965
+ return;
2966
+ }
2967
+ // Render a disabled state in the vector card area so the user
2968
+ // understands why nothing else loads.
2969
+ const badge = document.getElementById('mem-vec-status-badge');
2970
+ if (badge) {
2971
+ badge.textContent = (T && T.vecMemoryDisabled) || '○ 记忆未启用(在 Settings → 自动化记忆开启)';
2972
+ badge.style.color = 'var(--muted, #656d76)';
2973
+ }
2974
+ } catch { /* fall through silently */ }
2975
+ }
2976
+
2977
+ async function loadMemVecConfig() {
2978
+ try {
2979
+ // P1-7: do NOT auto-reveal secrets on tab open. Load masked; user
2980
+ // explicitly clicks 「🔓 显示」 to unmask the OpenAI key when they
2981
+ // want to edit it.
2982
+ const data = await api('/api/env');
2983
+ const env = data.env || {};
2984
+ const backend = (env['IMHUB_MEMORY_VECTOR_BACKEND'] || 'off').toLowerCase();
2985
+ document.getElementById('mem-vec-backend').value = ['off', 'local', 'openai'].includes(backend) ? backend : 'off';
2986
+ const lmPreset = document.getElementById('mem-vec-local-preset');
2987
+ const lmInput = document.getElementById('mem-vec-local-model');
2988
+ const presetVals = ['Xenova/bge-small-zh-v1.5','Xenova/bge-base-zh-v1.5','Xenova/bge-large-zh-v1.5'];
2989
+ const customLm = (env['IMHUB_MEMORY_VECTOR_LOCAL_MODEL'] || '').trim();
2990
+ if (customLm && presetVals.includes(customLm)) {
2991
+ lmPreset.value = customLm;
2992
+ lmInput.style.display = 'none';
2993
+ lmInput.value = '';
2994
+ } else if (customLm) {
2995
+ lmPreset.value = '__custom__';
2996
+ lmInput.value = customLm;
2997
+ lmInput.style.display = 'block';
2998
+ } else {
2999
+ lmPreset.value = 'Xenova/bge-base-zh-v1.5';
3000
+ lmInput.style.display = 'none';
3001
+ lmInput.value = '';
3002
+ }
3003
+ document.getElementById('mem-vec-openai-url').value = env['IMHUB_MEMORY_VECTOR_OPENAI_BASE_URL'] || '';
3004
+ document.getElementById('mem-vec-openai-model').value = env['IMHUB_MEMORY_VECTOR_OPENAI_MODEL'] || '';
3005
+ document.getElementById('mem-vec-openai-key').value = env['IMHUB_MEMORY_VECTOR_OPENAI_API_KEY'] || '';
3006
+ showVecBackendCfg(document.getElementById('mem-vec-backend').value);
3007
+ } catch { /* ignore */ }
3008
+ }
3009
+
3010
+ document.getElementById('mem-vec-backend')?.addEventListener('change', (e) => {
3011
+ showVecBackendCfg(e.target.value);
3012
+ });
3013
+ document.getElementById('mem-vec-local-preset')?.addEventListener('change', (e) => {
3014
+ const lm = document.getElementById('mem-vec-local-model');
3015
+ if (e.target.value === '__custom__') {
3016
+ lm.style.display = 'block';
3017
+ lm.focus();
3018
+ } else {
3019
+ lm.style.display = 'none';
3020
+ }
3021
+ });
3022
+ document.getElementById('btn-mem-vec-openai-reveal')?.addEventListener('click', async () => {
3023
+ const input = document.getElementById('mem-vec-openai-key');
3024
+ try {
3025
+ const data = await api('/api/env?reveal=1');
3026
+ input.value = (data.env && data.env['IMHUB_MEMORY_VECTOR_OPENAI_API_KEY']) || '';
3027
+ input.readOnly = false;
3028
+ input.style.background = '';
3029
+ input.style.color = '';
3030
+ input.type = 'text';
3031
+ input.focus();
3032
+ const btn = document.getElementById('btn-mem-vec-openai-reveal');
3033
+ if (btn) btn.style.display = 'none';
3034
+ } catch (err) {
3035
+ alert('显示失败: ' + (err.message || err));
3036
+ }
3037
+ });
3038
+ // True when the OpenAI key field holds the actual key (user clicked
3039
+ // 「显示」). False when it's still the masked default — save handler
3040
+ // omits the field in that case so the server doesn't overwrite.
3041
+ function openAiKeyWasRevealed() {
3042
+ const input = document.getElementById('mem-vec-openai-key');
3043
+ return input && !input.readOnly;
3044
+ }
3045
+ function resolveLocalModel() {
3046
+ const preset = document.getElementById('mem-vec-local-preset').value;
3047
+ if (preset === '__custom__') {
3048
+ return document.getElementById('mem-vec-local-model').value.trim() || null;
3049
+ }
3050
+ if (preset === 'Xenova/bge-base-zh-v1.5') return null; // default
3051
+ return preset;
3052
+ }
3053
+ document.getElementById('btn-mem-vec-save')?.addEventListener('click', async () => {
3054
+ const backend = document.getElementById('mem-vec-backend').value;
3055
+ const updates = {
3056
+ IMHUB_MEMORY_VECTOR_BACKEND: backend,
3057
+ IMHUB_MEMORY_VECTOR_LOCAL_MODEL: resolveLocalModel(),
3058
+ IMHUB_MEMORY_VECTOR_OPENAI_BASE_URL: document.getElementById('mem-vec-openai-url').value.trim() || null,
3059
+ IMHUB_MEMORY_VECTOR_OPENAI_MODEL: document.getElementById('mem-vec-openai-model').value.trim() || null,
3060
+ };
3061
+ // Only send the API key when the user actually revealed + edited it.
3062
+ // Otherwise the input still holds the masked value; the server-side
3063
+ // guard would skip it anyway, but omitting client-side avoids the
3064
+ // round-trip and makes intent explicit.
3065
+ if (openAiKeyWasRevealed()) {
3066
+ updates.IMHUB_MEMORY_VECTOR_OPENAI_API_KEY =
3067
+ document.getElementById('mem-vec-openai-key').value.trim() || null;
3068
+ }
3069
+ const detail = document.getElementById('mem-vec-status-detail');
3070
+ try {
3071
+ await api('/api/env', { method: 'PUT', body: JSON.stringify({ updates }) });
3072
+ if (detail) detail.innerHTML = '✓ 已保存。切换后端后注意:embedding 模型变了,旧的向量索引不可比,建议「清空向量索引」+「回填全部」。';
3073
+ await loadMemVecStatus();
3074
+ } catch (err) {
3075
+ if (detail) detail.innerHTML = '保存失败: ' + escapeHtmlSafe(err.message || String(err));
3076
+ }
3077
+ });
3078
+ // Auto-save the current dropdown selection to env before the action,
3079
+ // so users who pick "local" / "openai" and immediately click download
3080
+ // / test don't get a "backend is off" 400 from the previous saved state.
3081
+ async function ensureVecBackendApplied(requiredBackend) {
3082
+ const cur = document.getElementById('mem-vec-backend').value;
3083
+ if (cur !== requiredBackend) {
3084
+ document.getElementById('mem-vec-backend').value = requiredBackend;
3085
+ showVecBackendCfg(requiredBackend);
3086
+ }
3087
+ const updates = {
3088
+ IMHUB_MEMORY_VECTOR_BACKEND: requiredBackend,
3089
+ IMHUB_MEMORY_VECTOR_LOCAL_MODEL: resolveLocalModel(),
3090
+ IMHUB_MEMORY_VECTOR_OPENAI_BASE_URL: document.getElementById('mem-vec-openai-url').value.trim() || null,
3091
+ IMHUB_MEMORY_VECTOR_OPENAI_MODEL: document.getElementById('mem-vec-openai-model').value.trim() || null,
3092
+ };
3093
+ if (openAiKeyWasRevealed()) {
3094
+ updates.IMHUB_MEMORY_VECTOR_OPENAI_API_KEY =
3095
+ document.getElementById('mem-vec-openai-key').value.trim() || null;
3096
+ }
3097
+ await api('/api/env', { method: 'PUT', body: JSON.stringify({ updates }) });
3098
+ }
3099
+
3100
+ document.getElementById('btn-mem-vec-download')?.addEventListener('click', async () => {
3101
+ try {
3102
+ await ensureVecBackendApplied('local');
3103
+ await api('/api/memory/vector/download', { method: 'POST' });
3104
+ startMemVecPolling();
3105
+ } catch (err) {
3106
+ alert('启动下载失败: ' + (err.message || err));
3107
+ }
3108
+ });
3109
+ document.getElementById('btn-mem-vec-test')?.addEventListener('click', async () => {
3110
+ const resultEl = document.getElementById('mem-vec-test-result');
3111
+ if (resultEl) resultEl.textContent = '⏳ 测试中…';
3112
+ try {
3113
+ await ensureVecBackendApplied('openai');
3114
+ const r = await api('/api/memory/vector/test', { method: 'POST' });
3115
+ if (r.ok) {
3116
+ if (resultEl) resultEl.textContent = `✓ 通过 · dim ${r.dims} · ${r.latencyMs}ms`;
3117
+ } else {
3118
+ if (resultEl) resultEl.textContent = '❌ ' + (r.error || '未知错误');
3119
+ }
3120
+ } catch (err) {
3121
+ if (resultEl) resultEl.textContent = '❌ ' + (err.message || err);
3122
+ }
3123
+ });
3124
+ document.getElementById('btn-mem-vec-backfill')?.addEventListener('click', async () => {
3125
+ if (!confirm('回填所有未索引事实的 embedding?(按当前后端模型)')) return;
3126
+ try {
3127
+ await api('/api/memory/vector/backfill' + (memCurrentUser ? '?user_key=' + encodeURIComponent(memCurrentUser) : ''), {
3128
+ method: 'POST',
3129
+ body: JSON.stringify({}),
3130
+ });
3131
+ startMemVecPolling();
3132
+ } catch (err) {
3133
+ alert('启动回填失败: ' + (err.message || err));
3134
+ }
3135
+ });
3136
+ document.getElementById('btn-mem-vec-clear')?.addEventListener('click', async () => {
3137
+ if (!confirm('清空所有 embedding?(不会删 fact 本身;下次回填可重建)')) return;
3138
+ try {
3139
+ const r = await api('/api/memory/vector/clear' + (memCurrentUser ? '?user_key=' + encodeURIComponent(memCurrentUser) : ''), {
3140
+ method: 'POST',
3141
+ });
3142
+ alert(`已清空 ${r.cleared} 条 embedding`);
3143
+ await loadMemVecStatus();
3144
+ } catch (err) {
3145
+ alert('失败: ' + (err.message || err));
3146
+ }
3147
+ });
3148
+
3149
+ async function loadMemoryUsers() {
3150
+ const select = document.getElementById('mem-user-select');
3151
+ if (!select) return;
3152
+ try {
3153
+ const data = await api('/api/memory/users');
3154
+ const prev = memCurrentUser || select.value;
3155
+ select.innerHTML = '<option value="">— 选择用户 —</option>' +
3156
+ data.users.map(u =>
3157
+ `<option value="${escapeHtmlSafe(u.user_key)}">${escapeHtmlSafe(u.user_key)} · ${u.fact_count} 条事实 · ${u.has_persona ? '✓ 记忆画像' : '无记忆画像'}</option>`
3158
+ ).join('');
3159
+ if (prev && data.users.some(u => u.user_key === prev)) {
3160
+ select.value = prev;
3161
+ memCurrentUser = prev;
3162
+ loadMemoryUserDetail();
3163
+ }
3164
+ } catch (err) {
3165
+ select.innerHTML = '<option value="">加载失败: ' + (err.message || err) + '</option>';
3166
+ }
3167
+ }
3168
+
3169
+ async function loadMemoryUserDetail() {
3170
+ if (!memCurrentUser) return;
3171
+ // Update export link
3172
+ const exportLink = document.getElementById('btn-mem-export');
3173
+ if (exportLink) {
3174
+ exportLink.href = '/api/memory/export?user_key=' + encodeURIComponent(memCurrentUser);
3175
+ exportLink.style.display = 'inline';
3176
+ }
3177
+ // Persona
3178
+ const personaText = document.getElementById('mem-persona-text');
3179
+ const personaMeta = document.getElementById('mem-persona-meta');
3180
+ try {
3181
+ const p = await api('/api/memory/persona?user_key=' + encodeURIComponent(memCurrentUser));
3182
+ if (personaText) personaText.value = p.summary || '';
3183
+ if (personaMeta) personaMeta.textContent = 'updated ' + fmtMemDate(p.updated_at);
3184
+ } catch (err) {
3185
+ if (err.status === 404) {
3186
+ if (personaText) personaText.value = '';
3187
+ if (personaMeta) personaMeta.textContent = '(暂无记忆画像)';
3188
+ } else {
3189
+ if (personaMeta) personaMeta.textContent = '加载失败: ' + (err.message || err);
3190
+ }
3191
+ }
3192
+ // Facts
3193
+ memFactsPage = { offset: 0, limit: 20, total: 0 };
3194
+ await loadMemoryFacts();
3195
+ }
3196
+
3197
+ async function loadMemoryFacts() {
3198
+ if (!memCurrentUser) return;
3199
+ const listEl = document.getElementById('mem-facts-list');
3200
+ const totalEl = document.getElementById('mem-facts-total');
3201
+ const pageLabel = document.getElementById('mem-facts-pagelabel');
3202
+ if (listEl) listEl.innerHTML = '<span class="muted">加载中…</span>';
3203
+ const query = document.getElementById('mem-facts-query')?.value?.trim() || '';
3204
+ const category = document.getElementById('mem-facts-category')?.value || '';
3205
+ const qs = new URLSearchParams({
3206
+ user_key: memCurrentUser,
3207
+ limit: String(memFactsPage.limit),
3208
+ offset: String(memFactsPage.offset),
3209
+ });
3210
+ if (query) qs.append('query', query);
3211
+ if (category) qs.append('category', category);
3212
+ try {
3213
+ const data = await api('/api/memory/facts?' + qs.toString());
3214
+ memFactsPage.total = data.total;
3215
+ if (totalEl) totalEl.textContent = `(共 ${data.total} 条)`;
3216
+ if (pageLabel) {
3217
+ const start = data.total === 0 ? 0 : data.offset + 1;
3218
+ const end = Math.min(data.offset + data.limit, data.total);
3219
+ pageLabel.textContent = `${start}-${end} / ${data.total}`;
3220
+ }
3221
+ if (data.facts.length === 0) {
3222
+ listEl.innerHTML = '<div class="muted">(无匹配事实)</div>';
3223
+ return;
3224
+ }
3225
+ listEl.innerHTML = '<table style="width:100%;font-size:12px;border-collapse:collapse">' +
3226
+ '<thead><tr style="border-bottom:1px solid var(--border,#d0d7de)">' +
3227
+ '<th style="text-align:left;padding:4px 6px;width:50px">id</th>' +
3228
+ '<th style="text-align:left;padding:4px 6px">what</th>' +
3229
+ '<th style="text-align:left;padding:4px 6px;width:90px">category</th>' +
3230
+ '<th style="text-align:right;padding:4px 6px;width:60px">conf</th>' +
3231
+ '<th style="text-align:left;padding:4px 6px;width:140px">created</th>' +
3232
+ '<th style="text-align:center;padding:4px 6px;width:60px">删除</th>' +
3233
+ '</tr></thead><tbody>' +
3234
+ data.facts.map(f => `<tr style="border-bottom:1px solid var(--border,#d0d7de)" data-id="${f.id}">
3235
+ <td style="padding:4px 6px;font-family:ui-monospace,monospace">${f.id}</td>
3236
+ <td style="padding:4px 6px">${escapeHtmlSafe(f.what)}${f.who ? ' <span class="muted">(' + escapeHtmlSafe(f.who) + ')</span>' : ''}</td>
3237
+ <td style="padding:4px 6px"><code>${escapeHtmlSafe(f.category)}</code></td>
3238
+ <td style="text-align:right;padding:4px 6px">${(f.confidence * 100).toFixed(0)}%</td>
3239
+ <td style="padding:4px 6px;font-size:11px">${escapeHtmlSafe(String(f.created_at).replace('T', ' ').slice(0, 19))}</td>
3240
+ <td style="text-align:center;padding:4px 6px"><button type="button" class="btn-mem-del" data-id="${f.id}" style="font-size:11px;padding:2px 6px">🗑</button></td>
3241
+ </tr>`).join('') +
3242
+ '</tbody></table>';
3243
+ // Wire delete buttons.
3244
+ listEl.querySelectorAll('.btn-mem-del').forEach(b => {
3245
+ b.addEventListener('click', async () => {
3246
+ const id = parseInt(b.dataset.id, 10);
3247
+ if (!confirm(`删除事实 #${id}?`)) return;
3248
+ try {
3249
+ await api('/api/memory/facts/' + id + '?user_key=' + encodeURIComponent(memCurrentUser), { method: 'DELETE' });
3250
+ await loadMemoryFacts();
3251
+ } catch (err) {
3252
+ alert('删除失败: ' + (err.message || err));
3253
+ }
3254
+ });
3255
+ });
3256
+ } catch (err) {
3257
+ listEl.innerHTML = '<div class="error">加载失败: ' + (err.message || err) + '</div>';
3258
+ }
3259
+ }
3260
+
3261
+ document.getElementById('mem-user-select')?.addEventListener('change', (e) => {
3262
+ memCurrentUser = e.target.value;
3263
+ if (memCurrentUser) loadMemoryUserDetail();
3264
+ });
3265
+ document.getElementById('btn-mem-refresh-users')?.addEventListener('click', loadMemoryUsers);
3266
+ document.getElementById('btn-mem-facts-refresh')?.addEventListener('click', () => {
3267
+ memFactsPage.offset = 0;
3268
+ loadMemoryFacts();
3269
+ });
3270
+ document.getElementById('mem-facts-query')?.addEventListener('keypress', (e) => {
3271
+ if (e.key === 'Enter') { memFactsPage.offset = 0; loadMemoryFacts(); }
3272
+ });
3273
+ document.getElementById('mem-facts-category')?.addEventListener('change', () => {
3274
+ memFactsPage.offset = 0;
3275
+ loadMemoryFacts();
3276
+ });
3277
+ document.getElementById('btn-mem-facts-prev')?.addEventListener('click', () => {
3278
+ if (memFactsPage.offset > 0) {
3279
+ memFactsPage.offset = Math.max(0, memFactsPage.offset - memFactsPage.limit);
3280
+ loadMemoryFacts();
3281
+ }
3282
+ });
3283
+ document.getElementById('btn-mem-facts-next')?.addEventListener('click', () => {
3284
+ if (memFactsPage.offset + memFactsPage.limit < memFactsPage.total) {
3285
+ memFactsPage.offset += memFactsPage.limit;
3286
+ loadMemoryFacts();
3287
+ }
3288
+ });
3289
+ document.getElementById('btn-mem-persona-save')?.addEventListener('click', async () => {
3290
+ if (!memCurrentUser) return;
3291
+ const summary = document.getElementById('mem-persona-text')?.value?.trim() || '';
3292
+ if (!summary) { alert('记忆画像内容不能为空(要删除请用"删除记忆画像"按钮)'); return; }
3293
+ const status = document.getElementById('mem-persona-status');
3294
+ try {
3295
+ await api('/api/memory/persona?user_key=' + encodeURIComponent(memCurrentUser), {
3296
+ method: 'PUT',
3297
+ body: JSON.stringify({ summary }),
3298
+ });
3299
+ if (status) status.textContent = '✓ 已保存';
3300
+ await loadMemoryUserDetail();
3301
+ } catch (err) {
3302
+ if (status) status.textContent = '保存失败: ' + (err.message || err);
3303
+ }
3304
+ });
3305
+ document.getElementById('btn-mem-persona-delete')?.addEventListener('click', async () => {
3306
+ if (!memCurrentUser) return;
3307
+ if (!confirm('删除此用户的记忆画像?(事实保留,下次 consolidation 会重新生成)')) return;
3308
+ const status = document.getElementById('mem-persona-status');
3309
+ try {
3310
+ await api('/api/memory/persona?user_key=' + encodeURIComponent(memCurrentUser), { method: 'DELETE' });
3311
+ if (status) status.textContent = '✓ 已删除';
3312
+ await loadMemoryUserDetail();
3313
+ } catch (err) {
3314
+ if (status) status.textContent = '删除失败: ' + (err.message || err);
3315
+ }
3316
+ });
3317
+ document.getElementById('btn-mem-facts-clear-lowconf')?.addEventListener('click', async () => {
3318
+ if (!memCurrentUser) return;
3319
+ if (!confirm('删除 confidence ≤ 40% 的所有事实?')) return;
3320
+ const status = document.getElementById('mem-facts-status');
3321
+ try {
3322
+ const r = await api('/api/memory/facts?user_key=' + encodeURIComponent(memCurrentUser), {
3323
+ method: 'DELETE',
3324
+ body: JSON.stringify({ max_confidence: 0.4 }),
3325
+ });
3326
+ if (status) status.textContent = `✓ 删除了 ${r.deleted} 条低置信度事实`;
3327
+ await loadMemoryFacts();
3328
+ } catch (err) {
3329
+ if (status) status.textContent = '失败: ' + (err.message || err);
3330
+ }
3331
+ });
3332
+ document.getElementById('btn-mem-facts-clear-all')?.addEventListener('click', async () => {
3333
+ if (!memCurrentUser) return;
3334
+ if (!confirm('⚠️ 真的要清空此用户的所有事实吗?此操作不可撤销。')) return;
3335
+ if (!confirm('再次确认:所有事实将被永久删除(persona 不动)')) return;
3336
+ const status = document.getElementById('mem-facts-status');
3337
+ try {
3338
+ const r = await api('/api/memory/facts?user_key=' + encodeURIComponent(memCurrentUser), {
3339
+ method: 'DELETE',
3340
+ body: JSON.stringify({ confirm_clear: true }),
3341
+ });
3342
+ if (status) status.textContent = `✓ 清空了 ${r.deleted} 条事实`;
3343
+ await loadMemoryFacts();
3344
+ } catch (err) {
3345
+ if (status) status.textContent = '失败: ' + (err.message || err);
3346
+ }
3347
+ });
3348
+
2373
3349
  // Initial load
2374
3350
  loadJobs();
2375
3351
  </script>