agim-cli 1.2.70 → 1.2.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/dist/web/public/assets/{a2a-CzOCrC9J.js → a2a-BbtW9q1L.js} +2 -2
- package/dist/web/public/assets/{a2a-CzOCrC9J.js.map → a2a-BbtW9q1L.js.map} +1 -1
- package/dist/web/public/assets/{activity-BlasCFZq.js → activity-CJmQLZ6_.js} +2 -2
- package/dist/web/public/assets/{activity-BlasCFZq.js.map → activity-CJmQLZ6_.js.map} +1 -1
- package/dist/web/public/assets/{admins-CQOShfRl.js → admins-BJP9koxS.js} +2 -2
- package/dist/web/public/assets/{admins-CQOShfRl.js.map → admins-BJP9koxS.js.map} +1 -1
- package/dist/web/public/assets/{agents-UstP4DoK.js → agents-DC0XFC6e.js} +2 -2
- package/dist/web/public/assets/{agents-UstP4DoK.js.map → agents-DC0XFC6e.js.map} +1 -1
- package/dist/web/public/assets/{approvals-BvfcdYLU.js → approvals-DiPkpPN4.js} +2 -2
- package/dist/web/public/assets/{approvals-BvfcdYLU.js.map → approvals-DiPkpPN4.js.map} +1 -1
- package/dist/web/public/assets/{asks-DAJ1edZu.js → asks-CG9chQgT.js} +2 -2
- package/dist/web/public/assets/{asks-DAJ1edZu.js.map → asks-CG9chQgT.js.map} +1 -1
- package/dist/web/public/assets/{audit-CAOadRDl.js → audit-B40mxlW1.js} +2 -2
- package/dist/web/public/assets/{audit-CAOadRDl.js.map → audit-B40mxlW1.js.map} +1 -1
- package/dist/web/public/assets/{bell-BuZoWBMK.js → bell-BC8Jqt9f.js} +2 -2
- package/dist/web/public/assets/{bell-BuZoWBMK.js.map → bell-BC8Jqt9f.js.map} +1 -1
- package/dist/web/public/assets/{bgjobs-C5jF3BWG.js → bgjobs-ClrQ2jzx.js} +2 -2
- package/dist/web/public/assets/{bgjobs-C5jF3BWG.js.map → bgjobs-ClrQ2jzx.js.map} +1 -1
- package/dist/web/public/assets/{brain-DaFGi4GZ.js → brain-BBx7RF2j.js} +2 -2
- package/dist/web/public/assets/{brain-DaFGi4GZ.js.map → brain-BBx7RF2j.js.map} +1 -1
- package/dist/web/public/assets/{briefcase-BXKiYFAz.js → briefcase-TihsRkSy.js} +2 -2
- package/dist/web/public/assets/{briefcase-BXKiYFAz.js.map → briefcase-TihsRkSy.js.map} +1 -1
- package/dist/web/public/assets/{chevron-right-Bi2RmsHt.js → chevron-right-DTLZLXDP.js} +2 -2
- package/dist/web/public/assets/{chevron-right-Bi2RmsHt.js.map → chevron-right-DTLZLXDP.js.map} +1 -1
- package/dist/web/public/assets/{circle-check-NYJSuZMK.js → circle-check-EHM2OMD5.js} +2 -2
- package/dist/web/public/assets/{circle-check-NYJSuZMK.js.map → circle-check-EHM2OMD5.js.map} +1 -1
- package/dist/web/public/assets/{circle-check-big-wxQV9Apf.js → circle-check-big-DJXNAZ3u.js} +2 -2
- package/dist/web/public/assets/{circle-check-big-wxQV9Apf.js.map → circle-check-big-DJXNAZ3u.js.map} +1 -1
- package/dist/web/public/assets/{circle-x-D7EpTdJQ.js → circle-x-DsaHSb_C.js} +2 -2
- package/dist/web/public/assets/{circle-x-D7EpTdJQ.js.map → circle-x-DsaHSb_C.js.map} +1 -1
- package/dist/web/public/assets/{confirm-dialog-BecM1NAT.js → confirm-dialog-DezSK6wB.js} +2 -2
- package/dist/web/public/assets/{confirm-dialog-BecM1NAT.js.map → confirm-dialog-DezSK6wB.js.map} +1 -1
- package/dist/web/public/assets/{data-table-Bh5TFhXw.js → data-table-DdSnbgyt.js} +2 -2
- package/dist/web/public/assets/{data-table-Bh5TFhXw.js.map → data-table-DdSnbgyt.js.map} +1 -1
- package/dist/web/public/assets/{dialog-8m5Uihhi.js → dialog-45x-p0sR.js} +2 -2
- package/dist/web/public/assets/{dialog-8m5Uihhi.js.map → dialog-45x-p0sR.js.map} +1 -1
- package/dist/web/public/assets/{download-DWvNllY2.js → download-BL4jeu4M.js} +2 -2
- package/dist/web/public/assets/{download-DWvNllY2.js.map → download-BL4jeu4M.js.map} +1 -1
- package/dist/web/public/assets/{email-Cr1x3fdt.js → email-BGUO-gme.js} +2 -2
- package/dist/web/public/assets/{email-Cr1x3fdt.js.map → email-BGUO-gme.js.map} +1 -1
- package/dist/web/public/assets/{empty-state-BESUGVnV.js → empty-state-n-LfHo6N.js} +2 -2
- package/dist/web/public/assets/{empty-state-BESUGVnV.js.map → empty-state-n-LfHo6N.js.map} +1 -1
- package/dist/web/public/assets/{external-link-BwscsXby.js → external-link-CZd6ma6Y.js} +2 -2
- package/dist/web/public/assets/{external-link-BwscsXby.js.map → external-link-CZd6ma6Y.js.map} +1 -1
- package/dist/web/public/assets/{eye-C7gaSIZv.js → eye-LsErAgqh.js} +2 -2
- package/dist/web/public/assets/{eye-C7gaSIZv.js.map → eye-LsErAgqh.js.map} +1 -1
- package/dist/web/public/assets/{facts-B9hm9627.js → facts-CxZ3L_7a.js} +2 -2
- package/dist/web/public/assets/{facts-B9hm9627.js.map → facts-CxZ3L_7a.js.map} +1 -1
- package/dist/web/public/assets/{goals-D3f1Dw2M.js → goals-BjzgqVnW.js} +2 -2
- package/dist/web/public/assets/{goals-D3f1Dw2M.js.map → goals-BjzgqVnW.js.map} +1 -1
- package/dist/web/public/assets/{health-BLwrXebg.js → health-BYX5IK98.js} +2 -2
- package/dist/web/public/assets/{health-BLwrXebg.js.map → health-BYX5IK98.js.map} +1 -1
- package/dist/web/public/assets/{heart-pulse-BtioxtCU.js → heart-pulse-D_xUK9r0.js} +2 -2
- package/dist/web/public/assets/{heart-pulse-BtioxtCU.js.map → heart-pulse-D_xUK9r0.js.map} +1 -1
- package/dist/web/public/assets/{heartbeat-BHPx1z33.js → heartbeat-DnSegIYS.js} +2 -2
- package/dist/web/public/assets/{heartbeat-BHPx1z33.js.map → heartbeat-DnSegIYS.js.map} +1 -1
- package/dist/web/public/assets/{hot-CuaGMYzV.js → hot-C5TxCTVL.js} +2 -2
- package/dist/web/public/assets/{hot-CuaGMYzV.js.map → hot-C5TxCTVL.js.map} +1 -1
- package/dist/web/public/assets/{index-Cc8hxJWT.js → index-CbWhcThC.js} +3 -3
- package/dist/web/public/assets/{index-Cc8hxJWT.js.map → index-CbWhcThC.js.map} +1 -1
- package/dist/web/public/assets/{installed-D_BoGzel.js → installed-D_Hjs7uj.js} +2 -2
- package/dist/web/public/assets/{installed-D_BoGzel.js.map → installed-D_Hjs7uj.js.map} +1 -1
- package/dist/web/public/assets/{jobs-D0eeD9ye.js → jobs-DWtjJIAD.js} +2 -2
- package/dist/web/public/assets/{jobs-D0eeD9ye.js.map → jobs-DWtjJIAD.js.map} +1 -1
- package/dist/web/public/assets/{layout-DPxfAgBW.js → layout-54-PzplU.js} +2 -2
- package/dist/web/public/assets/{layout-DPxfAgBW.js.map → layout-54-PzplU.js.map} +1 -1
- package/dist/web/public/assets/{layout-bRkGiGF-.js → layout-BLK9XuQB.js} +2 -2
- package/dist/web/public/assets/{layout-bRkGiGF-.js.map → layout-BLK9XuQB.js.map} +1 -1
- package/dist/web/public/assets/{layout-CvRW3Bn_.js → layout-CTyy2kgf.js} +2 -2
- package/dist/web/public/assets/{layout-CvRW3Bn_.js.map → layout-CTyy2kgf.js.map} +1 -1
- package/dist/web/public/assets/{layout-Wl7Pr1-s.js → layout-CvvZcaiO.js} +2 -2
- package/dist/web/public/assets/{layout-Wl7Pr1-s.js.map → layout-CvvZcaiO.js.map} +1 -1
- package/dist/web/public/assets/{layout-CQe9ncYF.js → layout-D1-LnIA_.js} +2 -2
- package/dist/web/public/assets/{layout-CQe9ncYF.js.map → layout-D1-LnIA_.js.map} +1 -1
- package/dist/web/public/assets/{llm-CJGxq9Ru.js → llm-D59Tv6ne.js} +2 -2
- package/dist/web/public/assets/{llm-CJGxq9Ru.js.map → llm-D59Tv6ne.js.map} +1 -1
- package/dist/web/public/assets/{loader-circle-D4WrM89N.js → loader-circle-U64eR4W2.js} +2 -2
- package/dist/web/public/assets/{loader-circle-D4WrM89N.js.map → loader-circle-U64eR4W2.js.map} +1 -1
- package/dist/web/public/assets/{map-pin-DGSzw_UH.js → map-pin-D08YlPs3.js} +2 -2
- package/dist/web/public/assets/{map-pin-DGSzw_UH.js.map → map-pin-D08YlPs3.js.map} +1 -1
- package/dist/web/public/assets/{mcp-DYp7QbX8.js → mcp-Br9fBIWu.js} +2 -2
- package/dist/web/public/assets/{mcp-DYp7QbX8.js.map → mcp-Br9fBIWu.js.map} +1 -1
- package/dist/web/public/assets/{memos-Dz0jD4R5.js → memos-nZKV3SLq.js} +2 -2
- package/dist/web/public/assets/{memos-Dz0jD4R5.js.map → memos-nZKV3SLq.js.map} +1 -1
- package/dist/web/public/assets/messengers-C8ss4WVx.js +7 -0
- package/dist/web/public/assets/messengers-C8ss4WVx.js.map +1 -0
- package/dist/web/public/assets/{native-agent-ChgK3ceB.js → native-agent-BiiYK91m.js} +2 -2
- package/dist/web/public/assets/{native-agent-ChgK3ceB.js.map → native-agent-BiiYK91m.js.map} +1 -1
- package/dist/web/public/assets/{network-D8uD8vuE.js → network-D9rhTHz5.js} +2 -2
- package/dist/web/public/assets/{network-D8uD8vuE.js.map → network-D9rhTHz5.js.map} +1 -1
- package/dist/web/public/assets/{outbox-CRcmD2P1.js → outbox-BS_gla4W.js} +2 -2
- package/dist/web/public/assets/{outbox-CRcmD2P1.js.map → outbox-BS_gla4W.js.map} +1 -1
- package/dist/web/public/assets/{pagination-BK8pXuOn.js → pagination-CSuYj-hv.js} +2 -2
- package/dist/web/public/assets/{pagination-BK8pXuOn.js.map → pagination-CSuYj-hv.js.map} +1 -1
- package/dist/web/public/assets/{persona-Dk4Y24TF.js → persona-BByKd8Zd.js} +2 -2
- package/dist/web/public/assets/{persona-Dk4Y24TF.js.map → persona-BByKd8Zd.js.map} +1 -1
- package/dist/web/public/assets/{play-BAwY0kZY.js → play-B_11jKz_.js} +2 -2
- package/dist/web/public/assets/{play-BAwY0kZY.js.map → play-B_11jKz_.js.map} +1 -1
- package/dist/web/public/assets/{plus-Qg7H_hBC.js → plus-BxhCliGV.js} +2 -2
- package/dist/web/public/assets/{plus-Qg7H_hBC.js.map → plus-BxhCliGV.js.map} +1 -1
- package/dist/web/public/assets/{policy-bd1_X4KT.js → policy-CHbUM0MU.js} +2 -2
- package/dist/web/public/assets/{policy-bd1_X4KT.js.map → policy-CHbUM0MU.js.map} +1 -1
- package/dist/web/public/assets/{refresh-ccw-DjeeSzbz.js → refresh-ccw-BKw5Agez.js} +2 -2
- package/dist/web/public/assets/{refresh-ccw-DjeeSzbz.js.map → refresh-ccw-BKw5Agez.js.map} +1 -1
- package/dist/web/public/assets/{reminders-CyNYrPCo.js → reminders-_MsAKlS7.js} +2 -2
- package/dist/web/public/assets/{reminders-CyNYrPCo.js.map → reminders-_MsAKlS7.js.map} +1 -1
- package/dist/web/public/assets/{save-B6_ssx8c.js → save-DznSJZGN.js} +2 -2
- package/dist/web/public/assets/{save-B6_ssx8c.js.map → save-DznSJZGN.js.map} +1 -1
- package/dist/web/public/assets/{schedules-DVxENvba.js → schedules-CiRZXonB.js} +2 -2
- package/dist/web/public/assets/{schedules-DVxENvba.js.map → schedules-CiRZXonB.js.map} +1 -1
- package/dist/web/public/assets/{search-BJwBDf0Q.js → search-CXr_r7L8.js} +2 -2
- package/dist/web/public/assets/{search-BJwBDf0Q.js.map → search-CXr_r7L8.js.map} +1 -1
- package/dist/web/public/assets/{security-Co1823U6.js → security-CjySIq-k.js} +2 -2
- package/dist/web/public/assets/{security-Co1823U6.js.map → security-CjySIq-k.js.map} +1 -1
- package/dist/web/public/assets/{service-BJ2kE446.js → service-LXlgJooI.js} +2 -2
- package/dist/web/public/assets/{service-BJ2kE446.js.map → service-LXlgJooI.js.map} +1 -1
- package/dist/web/public/assets/{status-badge-3nnQTZXz.js → status-badge-BsPU9NHK.js} +2 -2
- package/dist/web/public/assets/{status-badge-3nnQTZXz.js.map → status-badge-BsPU9NHK.js.map} +1 -1
- package/dist/web/public/assets/{subtasks-BgHm-5dB.js → subtasks-DM8tJkC7.js} +2 -2
- package/dist/web/public/assets/{subtasks-BgHm-5dB.js.map → subtasks-DM8tJkC7.js.map} +1 -1
- package/dist/web/public/assets/{table-CNXI0HD-.js → table-CQbO75bn.js} +2 -2
- package/dist/web/public/assets/{table-CNXI0HD-.js.map → table-CQbO75bn.js.map} +1 -1
- package/dist/web/public/assets/{topn-C1PyG8Ns.js → topn-CaCYQ1NI.js} +2 -2
- package/dist/web/public/assets/{topn-C1PyG8Ns.js.map → topn-CaCYQ1NI.js.map} +1 -1
- package/dist/web/public/assets/{trash-2-CjkttCxU.js → trash-2-CdgBbY1Z.js} +2 -2
- package/dist/web/public/assets/{trash-2-CjkttCxU.js.map → trash-2-CdgBbY1Z.js.map} +1 -1
- package/dist/web/public/assets/{use-background-tasks-CLf78_yn.js → use-background-tasks-DUDxcbbl.js} +2 -2
- package/dist/web/public/assets/{use-background-tasks-CLf78_yn.js.map → use-background-tasks-DUDxcbbl.js.map} +1 -1
- package/dist/web/public/assets/{use-llm-admin-CzL0zcIi.js → use-llm-admin-Bpy7nDu-.js} +2 -2
- package/dist/web/public/assets/{use-llm-admin-CzL0zcIi.js.map → use-llm-admin-Bpy7nDu-.js.map} +1 -1
- package/dist/web/public/assets/{use-memory-DrJjdPYo.js → use-memory-B00AYXi0.js} +2 -2
- package/dist/web/public/assets/{use-memory-DrJjdPYo.js.map → use-memory-B00AYXi0.js.map} +1 -1
- package/dist/web/public/assets/{use-observability-1j7-vMy5.js → use-observability-7ETfQyEP.js} +2 -2
- package/dist/web/public/assets/{use-observability-1j7-vMy5.js.map → use-observability-7ETfQyEP.js.map} +1 -1
- package/dist/web/public/assets/{use-settings-BLcy2mrv.js → use-settings-tzqyzhw3.js} +2 -2
- package/dist/web/public/assets/{use-settings-BLcy2mrv.js.map → use-settings-tzqyzhw3.js.map} +1 -1
- package/dist/web/public/assets/{use-workspace-BLXvLTDF.js → use-workspace-B5gbK4s5.js} +2 -2
- package/dist/web/public/assets/{use-workspace-BLXvLTDF.js.map → use-workspace-B5gbK4s5.js.map} +1 -1
- package/dist/web/public/assets/{useQuery-xB5dCeu-.js → useQuery-wnQifGeZ.js} +2 -2
- package/dist/web/public/assets/{useQuery-xB5dCeu-.js.map → useQuery-wnQifGeZ.js.map} +1 -1
- package/dist/web/public/assets/{vector-EOjQsr7r.js → vector-or1-MfRX.js} +2 -2
- package/dist/web/public/assets/{vector-EOjQsr7r.js.map → vector-or1-MfRX.js.map} +1 -1
- package/dist/web/public/assets/{viewer-D_I2ikzf.js → viewer-C0f5b5eT.js} +2 -2
- package/dist/web/public/assets/{viewer-D_I2ikzf.js.map → viewer-C0f5b5eT.js.map} +1 -1
- package/dist/web/public/assets/{workspace-lMCzYJAB.js → workspace-CGE46-O2.js} +2 -2
- package/dist/web/public/assets/{workspace-lMCzYJAB.js.map → workspace-CGE46-O2.js.map} +1 -1
- package/dist/web/public/assets/{workspaces-Ciyjiowm.js → workspaces-CqDIcIXo.js} +2 -2
- package/dist/web/public/assets/{workspaces-Ciyjiowm.js.map → workspaces-CqDIcIXo.js.map} +1 -1
- package/dist/web/public/assets/{x-DP0VnoG9.js → x-DBZPbfu3.js} +2 -2
- package/dist/web/public/assets/{x-DP0VnoG9.js.map → x-DBZPbfu3.js.map} +1 -1
- package/dist/web/public/index.html +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +21 -41
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
- package/dist/web/public/_app.js +0 -248
- package/dist/web/public/assets/messengers-Bewhby2_.js +0 -7
- package/dist/web/public/assets/messengers-Bewhby2_.js.map +0 -1
- package/dist/web/public/memos.html +0 -352
- package/dist/web/public/reminders.html +0 -332
- package/dist/web/public/settings.html +0 -2488
- package/dist/web/public/tasks.html +0 -3724
|
@@ -1,3724 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>Agim — Tasks</title>
|
|
7
|
-
<!-- Shared utilities: theme manager applies before first paint, error
|
|
8
|
-
boundary surfaces silent script failures, i18n + api helpers are
|
|
9
|
-
used by the page-specific script below. -->
|
|
10
|
-
<script src="/_app.js"></script>
|
|
11
|
-
<script>
|
|
12
|
-
// Wrapped in an IIFE so `const T` / `LANGS` / `savedLang` / `browserLang`
|
|
13
|
-
// stay out of the global script scope. Without this, the second
|
|
14
|
-
// <script> tag below also declares `const T = window.__t`, and classic
|
|
15
|
-
// (non-module) `<script>` tags share the same top-level lexical scope —
|
|
16
|
-
// which made the page silently die with "Identifier 'T' has already
|
|
17
|
-
// been declared" before any tab labels / buttons could be filled.
|
|
18
|
-
(() => {
|
|
19
|
-
const LANGS = { en: 'English', zh: '中文' };
|
|
20
|
-
const savedLang = localStorage.getItem('im-hub-lang');
|
|
21
|
-
const browserLang = navigator.language.startsWith('zh') ? 'zh' : 'en';
|
|
22
|
-
window.__lang = savedLang && LANGS[savedLang] ? savedLang : browserLang;
|
|
23
|
-
document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
|
|
24
|
-
|
|
25
|
-
const T = {
|
|
26
|
-
en: {
|
|
27
|
-
title: 'Agim — Tasks',
|
|
28
|
-
h1: '🗂 Tasks & Schedules',
|
|
29
|
-
backToChat: '↩ Chat',
|
|
30
|
-
toReminders: 'Reminders',
|
|
31
|
-
toMemos: 'Memos',
|
|
32
|
-
toSettings: 'Settings',
|
|
33
|
-
tabsJobs: 'Jobs',
|
|
34
|
-
tabsBackground: 'Background',
|
|
35
|
-
tabsSubtasks: 'Subtasks',
|
|
36
|
-
tabsSchedules: 'Schedules',
|
|
37
|
-
tabsAudit: 'Audit',
|
|
38
|
-
tabsHealth: 'Health',
|
|
39
|
-
tabsApprovals: 'Approvals',
|
|
40
|
-
loading: 'Loading...',
|
|
41
|
-
// Health tab
|
|
42
|
-
healthBreaker: 'Breaker',
|
|
43
|
-
healthRate: 'Rate limit',
|
|
44
|
-
healthLatency: 'Latency',
|
|
45
|
-
healthInvocations: 'Invocations',
|
|
46
|
-
healthSuccessRate: 'Success rate',
|
|
47
|
-
healthCost: 'Cost',
|
|
48
|
-
healthCooldown: 'Cooldown',
|
|
49
|
-
healthEmpty: 'No agents registered yet.',
|
|
50
|
-
healthSparklineLabel: 'p95 latency over last 60 polls',
|
|
51
|
-
breakerClosed: 'Closed',
|
|
52
|
-
breakerOpen: 'Open',
|
|
53
|
-
breakerHalfOpen: 'Half-open',
|
|
54
|
-
// Approvals tab
|
|
55
|
-
approvalsEmpty: 'No pending approvals.',
|
|
56
|
-
approvalsCount: '{n} pending',
|
|
57
|
-
approvalsAge: 'Age',
|
|
58
|
-
approvalsTool: 'Tool',
|
|
59
|
-
approvalsThread: 'Thread',
|
|
60
|
-
approvalsAllow: 'Allow',
|
|
61
|
-
approvalsDeny: 'Deny',
|
|
62
|
-
approvalsAllowAll: 'Allow + auto',
|
|
63
|
-
approvalsResolveErr: 'Failed to resolve',
|
|
64
|
-
approvalsAutoMode: 'auto-allow grace mode',
|
|
65
|
-
statsTotal: 'Total',
|
|
66
|
-
statsPending: 'Pending',
|
|
67
|
-
statsRunning: 'Running',
|
|
68
|
-
statsCompleted: 'Completed',
|
|
69
|
-
statsFailed: 'Failed',
|
|
70
|
-
filterAll: 'All',
|
|
71
|
-
filterPending: 'Pending',
|
|
72
|
-
filterRunning: 'Running',
|
|
73
|
-
filterCompleted: 'Completed',
|
|
74
|
-
filterFailed: 'Failed',
|
|
75
|
-
filterCancelled: 'Cancelled',
|
|
76
|
-
refresh: 'Refresh',
|
|
77
|
-
autoRefresh: 'Auto-refresh',
|
|
78
|
-
newJob: 'New Job',
|
|
79
|
-
agent: 'Agent',
|
|
80
|
-
prompt: 'Prompt',
|
|
81
|
-
status: 'Status',
|
|
82
|
-
created: 'Created',
|
|
83
|
-
completed: 'Completed',
|
|
84
|
-
actions: 'Actions',
|
|
85
|
-
view: 'View',
|
|
86
|
-
run: 'Run',
|
|
87
|
-
cancel: 'Cancel',
|
|
88
|
-
details: 'Details',
|
|
89
|
-
result: 'Result',
|
|
90
|
-
error: 'Error',
|
|
91
|
-
close: 'Close',
|
|
92
|
-
empty: 'No tasks. Create one with /job create or via the New Job button.',
|
|
93
|
-
emptySchedules: 'No schedules. Create one in IM with /cron create.',
|
|
94
|
-
scheduleName: 'Name',
|
|
95
|
-
scheduleCron: 'Cron',
|
|
96
|
-
scheduleNext: 'Next run',
|
|
97
|
-
scheduleLast: 'Last run',
|
|
98
|
-
promptCreate: 'Prompt for the new job',
|
|
99
|
-
agentCreate: 'Agent (defaults to claude-code)',
|
|
100
|
-
bgRoot: 'Source',
|
|
101
|
-
bgName: 'Name',
|
|
102
|
-
bgStarted: 'Started',
|
|
103
|
-
bgEnded: 'Ended',
|
|
104
|
-
bgPid: 'PID',
|
|
105
|
-
bgExit: 'Exit',
|
|
106
|
-
bgRestart: 'Gen',
|
|
107
|
-
bgCommand: 'Command',
|
|
108
|
-
bgWorkdir: 'Workdir',
|
|
109
|
-
bgOutDir: 'Output',
|
|
110
|
-
bgLogTail: 'Log tail (last 200 lines)',
|
|
111
|
-
bgEmpty: 'No background jobs in this source. Use bgjob start to create one.',
|
|
112
|
-
subParent: 'Parent thread',
|
|
113
|
-
subAgent: 'Agent',
|
|
114
|
-
subPrompt: 'Prompt',
|
|
115
|
-
subCreated: 'Created',
|
|
116
|
-
subEmpty: 'No subtasks recorded. Use /job switch <id> in IM to spawn one.',
|
|
117
|
-
auditDays: 'Days',
|
|
118
|
-
auditTime: 'Time',
|
|
119
|
-
auditUser: 'User',
|
|
120
|
-
auditPlatform: 'Platform',
|
|
121
|
-
auditIntent: 'Intent',
|
|
122
|
-
auditDuration: 'Duration',
|
|
123
|
-
auditCost: 'Cost',
|
|
124
|
-
auditOk: 'OK',
|
|
125
|
-
auditError: 'Error',
|
|
126
|
-
auditEmpty: 'No invocations recorded yet for this filter.',
|
|
127
|
-
// Files tab — read-only browser of ~/.im-hub-workspaces/<agent>/
|
|
128
|
-
tabsFiles: 'Files',
|
|
129
|
-
filesAgent: 'Agent',
|
|
130
|
-
filesPath: 'Path',
|
|
131
|
-
filesEmpty: 'Empty directory.',
|
|
132
|
-
filesNoAgent: 'No agents registered yet.',
|
|
133
|
-
filesUp: '⬆ up',
|
|
134
|
-
filesRoot: '(root)',
|
|
135
|
-
filesSize: 'Size',
|
|
136
|
-
filesMtime: 'Modified',
|
|
137
|
-
filesBinary: 'Binary file — content shown as base64.',
|
|
138
|
-
filesTruncated: 'File too large — showing first 1 MiB.',
|
|
139
|
-
filesNotFound: 'Not found.',
|
|
140
|
-
filesLoad: 'Load',
|
|
141
|
-
filesEdit: 'Edit',
|
|
142
|
-
filesEditing: 'editing…',
|
|
143
|
-
filesSave: 'Save',
|
|
144
|
-
filesSaving: 'Saving…',
|
|
145
|
-
filesCancel: 'Cancel',
|
|
146
|
-
filesSaved: 'Saved.',
|
|
147
|
-
filesSaveFailed: 'Save failed',
|
|
148
|
-
// Jobs batch toolbar
|
|
149
|
-
jobsSelectAll: 'Select all',
|
|
150
|
-
jobsBatchCancel: 'Cancel selected',
|
|
151
|
-
jobsBatchRun: 'Run selected',
|
|
152
|
-
jobsBatchEmpty: 'No jobs selected.',
|
|
153
|
-
jobsBatchResult: 'Batch result: {ok} ok, {fail} failed',
|
|
154
|
-
// v1.1.2 / v1.1.3 — new tabs + columns
|
|
155
|
-
tabsOutbox: 'Outbox',
|
|
156
|
-
tabsA2A: 'A2A',
|
|
157
|
-
kindCol: 'Kind',
|
|
158
|
-
parentCol: 'Parent',
|
|
159
|
-
depthCol: 'Depth',
|
|
160
|
-
filterAllKinds: 'All kinds',
|
|
161
|
-
filterKindJob: 'job (explicit /job)',
|
|
162
|
-
filterKindInline: 'inline (auto-tracked)',
|
|
163
|
-
outboxEmpty: 'No outbox rows.',
|
|
164
|
-
outboxStatusPending: '⏳ Pending',
|
|
165
|
-
outboxStatusDelivered: '✅ Delivered',
|
|
166
|
-
outboxStatusGivingUp: '💀 Giving up',
|
|
167
|
-
outboxRetry: 'Retry',
|
|
168
|
-
outboxColPlatform: 'Platform',
|
|
169
|
-
outboxColKind: 'Kind',
|
|
170
|
-
outboxColPri: 'Pri',
|
|
171
|
-
outboxColAttempts: 'Attempts',
|
|
172
|
-
outboxColPayload: 'Payload',
|
|
173
|
-
outboxColError: 'Error',
|
|
174
|
-
outboxColCreated: 'Created',
|
|
175
|
-
a2aEmpty: 'No A2A calls yet.',
|
|
176
|
-
a2aRecent: 'Recent A2A calls',
|
|
177
|
-
a2aCallerCallee: 'Caller→Callee',
|
|
178
|
-
a2aTreeRoot: 'Call tree (root #{id})',
|
|
179
|
-
a2aTreeInputJob: 'job id for tree…',
|
|
180
|
-
a2aBtnTree: 'Tree',
|
|
181
|
-
a2aBtnViewParent: 'View parent tree',
|
|
182
|
-
a2aStatTotal: 'Total',
|
|
183
|
-
a2aStat24h: '24h',
|
|
184
|
-
a2aStatMaxDepth: 'Max depth',
|
|
185
|
-
a2aStatTop: 'Top:',
|
|
186
|
-
modalParent: 'Parent',
|
|
187
|
-
modalDepth: 'Depth',
|
|
188
|
-
modalThread: 'Thread',
|
|
189
|
-
modalReplaced: 'Replaced by',
|
|
190
|
-
modalLastOutbox: 'Last outbox',
|
|
191
|
-
modalArtifacts: 'Artifacts',
|
|
192
|
-
modalArtifactsInputs: 'Inputs:',
|
|
193
|
-
modalArtifactsOutputs: 'Outputs:',
|
|
194
|
-
modalDelivered: 'Delivered',
|
|
195
|
-
helpClose: 'Close',
|
|
196
|
-
// Help tooltips (concept glossary). Used by the (?) icons next to
|
|
197
|
-
// jargon. Keys are short ids; values are { title, body } in the
|
|
198
|
-
// 'help' namespace below.
|
|
199
|
-
help: {
|
|
200
|
-
outbox: {
|
|
201
|
-
title: 'Outbox · 投递队列',
|
|
202
|
-
body: 'Every outbound IM message is first written to a SQLite outbox, then a background worker drains it with exponential backoff. If the messenger glitches or the user is briefly offline, the row stays "pending" and gets retried (1s → 5s → 30s → 5min → 30min → 2h). After 6 failed attempts the row transitions to "giving_up" and stops retrying until you click Retry. Visible in: /tasks Outbox tab, IM command /outbox status.',
|
|
203
|
-
},
|
|
204
|
-
inline: {
|
|
205
|
-
title: 'Inline job · 内联任务',
|
|
206
|
-
body: 'An inline job is the row agim auto-creates for every inbound IM message that triggers an agent. It tracks the full lifecycle (pending → running → completed → delivered) so a crash or restart never loses work. Distinct from kind=job rows which come from explicit /job create. Retention: 24h (vs 30d for kind=job).',
|
|
207
|
-
},
|
|
208
|
-
a2a: {
|
|
209
|
-
title: 'A2A · Agent-to-Agent',
|
|
210
|
-
body: 'When the active agent calls another agent via mcp__imhub__call_agent (e.g. claude saying "用 codex 跑 git status"), agim spawns the callee as a fresh inline job whose parent_id points back at the caller. Guardrails enforced by agim: depth ceiling (default 3), self-call ban, workspace whitelist, shared per-user budget. The tree view shows the full caller→callee chain.',
|
|
211
|
-
},
|
|
212
|
-
artifacts: {
|
|
213
|
-
title: 'Artifacts · 共享文件',
|
|
214
|
-
body: 'A2A Layer 2 lets agents exchange files instead of cramming everything into prompts. Each A2A inline job gets a dedicated ~/.agim/artifacts/<jobId>/_agim-{input,output}/ directory. Caller drops files via `inputs[]`; callee writes results to _agim-output/; caller reads them through this UI or with the agent\'s native Read tool. Retention follows the inline job (24h).',
|
|
215
|
-
},
|
|
216
|
-
callDepth: {
|
|
217
|
-
title: 'Call depth · 调用深度',
|
|
218
|
-
body: 'How deep this row sits in an A2A chain. 0 = user-originated message. +1 per nested mcp__imhub__call_agent invocation. Configurable max via IMHUB_A2A_MAX_DEPTH (default 3) — exceeding it rejects the call before any tokens are spent.',
|
|
219
|
-
},
|
|
220
|
-
parent: {
|
|
221
|
-
title: 'Parent · 父任务',
|
|
222
|
-
body: 'For A2A callees, the inline-job id of the row that fired mcp__imhub__call_agent. NULL on user-originated rows. Click to open the parent\'s detail view.',
|
|
223
|
-
},
|
|
224
|
-
replacedBy: {
|
|
225
|
-
title: 'Replaced by · 已被替换为',
|
|
226
|
-
body: 'For interrupted inline jobs the user retried via "1 重发" after a restart, this points at the new replacement row that ran instead. The old row stays for audit purposes.',
|
|
227
|
-
},
|
|
228
|
-
givingUp: {
|
|
229
|
-
title: 'Giving up · 已放弃',
|
|
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
|
-
},
|
|
232
|
-
},
|
|
233
|
-
// ── v1.2 Cost & Health tab ──
|
|
234
|
-
tabsCost: '💰 Cost & Health',
|
|
235
|
-
tabsMemory: '🧠 Memory',
|
|
236
|
-
tabsSkills: '🛠 Skills',
|
|
237
|
-
skillsH2: '🛠 Installed Skills',
|
|
238
|
-
skillsIntro: 'Read-only inventory of skills installed for claude-code / opencode on this host. Click a row for the full description. To install a new skill, send <code>/find-skills</code> in IM, or browse the skillhub.cn hot list below.',
|
|
239
|
-
skillsSearchPh: 'Search (name / description)',
|
|
240
|
-
skillsCatAll: 'All categories',
|
|
241
|
-
skillsRemoteH2: 'Hot on skillhub.cn',
|
|
242
|
-
skillsRemoteOpen: 'Open skillhub.cn ↗',
|
|
243
|
-
skillsRemoteIntro: 'Live top-50 hot downloads from skillhub.cn (5-min cache). Click any entry for the description + install instructions. agim never auto-runs installs — you copy the command and paste into shell or IM.',
|
|
244
|
-
skillsRemoteCached: 'cache hit',
|
|
245
|
-
skillsRemoteStale: 'upstream down, serving cached',
|
|
246
|
-
skillsCol: 'slug',
|
|
247
|
-
skillsName: 'name / description',
|
|
248
|
-
skillsStars: '⭐ stars',
|
|
249
|
-
skillsInstalls: '⬇ installs',
|
|
250
|
-
skillsHowInstall: 'Install (pick one; agim never auto-runs)',
|
|
251
|
-
skillsHostHint: 'The first two require skillhub CLI on the host. See docs/skills.md or skillhub.cn.',
|
|
252
|
-
costH2: '💰 Cost & Health',
|
|
253
|
-
costWindow: 'Window',
|
|
254
|
-
costDays1: '1 day',
|
|
255
|
-
costDays7: '7 days',
|
|
256
|
-
costDays30: '30 days',
|
|
257
|
-
costDays90: '90 days',
|
|
258
|
-
costRefresh: 'Refresh',
|
|
259
|
-
costDailyTrend: 'Daily trend',
|
|
260
|
-
costMetricCalls: 'Calls',
|
|
261
|
-
costMetricCost: 'Cost ($)',
|
|
262
|
-
costMetricErrors: 'Errors',
|
|
263
|
-
costMetricLatency: 'Avg latency (ms)',
|
|
264
|
-
costTopUser: 'Top by User',
|
|
265
|
-
costTopAgent: 'Top by Agent',
|
|
266
|
-
costTopPlatform: 'Top by Platform',
|
|
267
|
-
// ── v1.5/1.6 Memory tab ──
|
|
268
|
-
memoryH2: '🧠 Long-term memory',
|
|
269
|
-
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.',
|
|
270
|
-
memoryUser: 'User',
|
|
271
|
-
memoryUserLoading: '— loading —',
|
|
272
|
-
memoryReloadUsers: '🔄 Reload users',
|
|
273
|
-
memoryExportUser: '📦 Export user JSON',
|
|
274
|
-
memoryPersonaCard: 'Persona summary',
|
|
275
|
-
memoryPersonaPlaceholder: '(No persona for this user yet — will be auto-generated at next consolidation, or write one manually.)',
|
|
276
|
-
memoryPersonaSave: 'Save persona',
|
|
277
|
-
memoryPersonaDelete: 'Delete persona',
|
|
278
|
-
memoryPersonaConsolidate: '🔁 Rebuild persona now',
|
|
279
|
-
memoryFactsCard: 'Facts',
|
|
280
|
-
memoryFactsSearch: 'Search (FTS5)',
|
|
281
|
-
memoryFactsCatAll: 'All types',
|
|
282
|
-
memoryFactsQuery: 'Query',
|
|
283
|
-
memoryFactsSelectUser: '(Select a user)',
|
|
284
|
-
memoryFactsPrev: '← Prev',
|
|
285
|
-
memoryFactsNext: 'Next →',
|
|
286
|
-
memoryFactsClearLowConf: '🗑 Clear low-confidence',
|
|
287
|
-
memoryFactsClearAll: '⚠️ Clear all',
|
|
288
|
-
// ── v1.6 Vector card ──
|
|
289
|
-
vecTitle: '🔍 Vector retrieval',
|
|
290
|
-
vecSubtitle: '(optional enhancement · default off)',
|
|
291
|
-
vecWhyHeading: 'Why add vectors?',
|
|
292
|
-
vecWhyDefault: 'Default (FTS5 keyword): only literal hits — "Tencent" → facts containing "Tencent".',
|
|
293
|
-
vecWhyAdded: 'With vectors: also recall synonyms / cross-lingual / conceptual queries:',
|
|
294
|
-
vecWhyEx1: '▸ "investment preference" → recalls "user prefers low-drawdown, steady"',
|
|
295
|
-
vecWhyEx2: '▸ "Beijing" → recalls "user works in 北京"',
|
|
296
|
-
vecWhyEx3: '▸ "my son\'s birthday" → recalls "Tom born 2018-03-15"',
|
|
297
|
-
vecWhenHeading: 'When to enable',
|
|
298
|
-
vecWhenBody: '50+ facts stored AND agent often "forgets" what the user said. Stock install does not need it — FTS5 is enough.',
|
|
299
|
-
vecBackend: 'Backend',
|
|
300
|
-
vecBackendOff: 'Off (FTS5 only)',
|
|
301
|
-
vecBackendLocal: 'Local BGE (one-time download ~250MB)',
|
|
302
|
-
vecBackendOpenai: 'Remote OpenAI-compatible (baseUrl + key)',
|
|
303
|
-
vecLocalModel: 'Local model',
|
|
304
|
-
vecLocalSmall: 'Small (small, ~100MB, speed-first)',
|
|
305
|
-
vecLocalBase: 'Medium (base, ~250MB, recommended)',
|
|
306
|
-
vecLocalLarge: 'Large (large, ~500MB, accuracy-first)',
|
|
307
|
-
vecLocalCustom: 'Custom…',
|
|
308
|
-
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.',
|
|
309
|
-
vecDownloadBtn: '📥 Download model (first time 5–10 min, WiFi recommended)',
|
|
310
|
-
vecOpenaiKey: 'API Key',
|
|
311
|
-
vecOpenaiReveal: '🔓 Reveal & edit',
|
|
312
|
-
vecOpenaiKeyHint: 'Masked by default; if you don\'t click reveal, save will not overwrite the existing value.',
|
|
313
|
-
vecTestBtn: '🧪 Test connection (10s timeout)',
|
|
314
|
-
vecSaveBtn: 'Save config',
|
|
315
|
-
vecBackfillBtn: '🔄 Backfill all',
|
|
316
|
-
vecClearBtn: '🗑 Clear vector index',
|
|
317
|
-
vecMemoryDisabled: '○ Memory not enabled (turn on in Settings → automation)',
|
|
318
|
-
},
|
|
319
|
-
zh: {
|
|
320
|
-
title: 'Agim — 任务',
|
|
321
|
-
h1: '🗂 任务与定时',
|
|
322
|
-
backToChat: '↩ 对话',
|
|
323
|
-
toReminders: '提醒',
|
|
324
|
-
toMemos: '备忘',
|
|
325
|
-
toSettings: '设置',
|
|
326
|
-
tabsAudit: '审计',
|
|
327
|
-
tabsJobs: '任务',
|
|
328
|
-
tabsBackground: '后台脚本',
|
|
329
|
-
tabsSubtasks: '子任务',
|
|
330
|
-
tabsSchedules: '定时',
|
|
331
|
-
tabsHealth: '健康',
|
|
332
|
-
tabsApprovals: '审批',
|
|
333
|
-
loading: '加载中...',
|
|
334
|
-
// Health tab
|
|
335
|
-
healthBreaker: '断路器',
|
|
336
|
-
healthRate: '限流',
|
|
337
|
-
healthLatency: '延迟',
|
|
338
|
-
healthInvocations: '调用次数',
|
|
339
|
-
healthSuccessRate: '成功率',
|
|
340
|
-
healthCost: '成本',
|
|
341
|
-
healthCooldown: '冷却',
|
|
342
|
-
healthEmpty: '尚未注册任何 Agent。',
|
|
343
|
-
healthSparklineLabel: '最近 60 次轮询的 p95 延迟',
|
|
344
|
-
breakerClosed: '正常',
|
|
345
|
-
breakerOpen: '熔断',
|
|
346
|
-
breakerHalfOpen: '半开',
|
|
347
|
-
// Approvals tab
|
|
348
|
-
approvalsEmpty: '当前无待审批请求。',
|
|
349
|
-
approvalsCount: '待审批 {n} 条',
|
|
350
|
-
approvalsAge: '等待',
|
|
351
|
-
approvalsTool: '工具',
|
|
352
|
-
approvalsThread: '会话',
|
|
353
|
-
approvalsAllow: '批准',
|
|
354
|
-
approvalsDeny: '拒绝',
|
|
355
|
-
approvalsAllowAll: '批准并自动放行',
|
|
356
|
-
approvalsResolveErr: '处理失败',
|
|
357
|
-
approvalsAutoMode: '自动放行宽限期',
|
|
358
|
-
statsTotal: '总计',
|
|
359
|
-
statsPending: '待执行',
|
|
360
|
-
statsRunning: '运行中',
|
|
361
|
-
statsCompleted: '已完成',
|
|
362
|
-
statsFailed: '失败',
|
|
363
|
-
filterAll: '全部',
|
|
364
|
-
filterPending: '待执行',
|
|
365
|
-
filterRunning: '运行中',
|
|
366
|
-
filterCompleted: '已完成',
|
|
367
|
-
filterFailed: '失败',
|
|
368
|
-
filterCancelled: '已取消',
|
|
369
|
-
refresh: '刷新',
|
|
370
|
-
autoRefresh: '自动刷新',
|
|
371
|
-
newJob: '新建任务',
|
|
372
|
-
agent: 'Agent',
|
|
373
|
-
prompt: '内容',
|
|
374
|
-
status: '状态',
|
|
375
|
-
created: '创建',
|
|
376
|
-
completed: '完成',
|
|
377
|
-
actions: '操作',
|
|
378
|
-
view: '查看',
|
|
379
|
-
run: '运行',
|
|
380
|
-
cancel: '取消',
|
|
381
|
-
details: '详情',
|
|
382
|
-
result: '结果',
|
|
383
|
-
error: '错误',
|
|
384
|
-
close: '关闭',
|
|
385
|
-
empty: '暂无任务。可在 IM 内 /job create 或点"新建任务"。',
|
|
386
|
-
emptySchedules: '暂无定时任务。在 IM 内 /cron create 创建。',
|
|
387
|
-
scheduleName: '名称',
|
|
388
|
-
scheduleCron: 'Cron',
|
|
389
|
-
scheduleNext: '下次',
|
|
390
|
-
scheduleLast: '上次',
|
|
391
|
-
promptCreate: '输入任务内容',
|
|
392
|
-
agentCreate: 'Agent (默认 claude-code)',
|
|
393
|
-
bgRoot: '来源',
|
|
394
|
-
bgName: '名称',
|
|
395
|
-
bgStarted: '启动',
|
|
396
|
-
bgEnded: '结束',
|
|
397
|
-
bgPid: 'PID',
|
|
398
|
-
bgExit: '退出码',
|
|
399
|
-
bgRestart: '代次',
|
|
400
|
-
bgCommand: '命令',
|
|
401
|
-
bgWorkdir: '工作目录',
|
|
402
|
-
bgOutDir: '输出目录',
|
|
403
|
-
bgLogTail: '日志尾部(最后 200 行)',
|
|
404
|
-
bgEmpty: '该来源下暂无后台任务。可在终端 bgjob start 启动。',
|
|
405
|
-
subParent: '所属会话',
|
|
406
|
-
subAgent: 'Agent',
|
|
407
|
-
subPrompt: '内容',
|
|
408
|
-
subCreated: '创建',
|
|
409
|
-
subEmpty: '暂无子任务。在 IM 内 /job switch <id> 可派生。',
|
|
410
|
-
auditDays: '天数',
|
|
411
|
-
auditTime: '时间',
|
|
412
|
-
auditUser: '用户',
|
|
413
|
-
auditPlatform: '平台',
|
|
414
|
-
auditIntent: '意图',
|
|
415
|
-
auditDuration: '耗时',
|
|
416
|
-
auditCost: '成本',
|
|
417
|
-
auditOk: '成功',
|
|
418
|
-
auditError: '错误',
|
|
419
|
-
auditEmpty: '当前过滤下暂无调用记录。',
|
|
420
|
-
// Files tab — read-only browser of ~/.im-hub-workspaces/<agent>/
|
|
421
|
-
tabsFiles: '工作区',
|
|
422
|
-
filesAgent: 'Agent',
|
|
423
|
-
filesPath: '路径',
|
|
424
|
-
filesEmpty: '空目录。',
|
|
425
|
-
filesNoAgent: '尚未注册任何 Agent。',
|
|
426
|
-
filesUp: '⬆ 上一级',
|
|
427
|
-
filesRoot: '(根)',
|
|
428
|
-
filesSize: '大小',
|
|
429
|
-
filesMtime: '修改时间',
|
|
430
|
-
filesBinary: '二进制文件 — 以 base64 显示。',
|
|
431
|
-
filesTruncated: '文件过大 — 仅显示前 1 MiB。',
|
|
432
|
-
filesNotFound: '未找到。',
|
|
433
|
-
filesLoad: '加载',
|
|
434
|
-
filesEdit: '编辑',
|
|
435
|
-
filesEditing: '编辑中…',
|
|
436
|
-
filesSave: '保存',
|
|
437
|
-
filesSaving: '保存中…',
|
|
438
|
-
filesCancel: '取消',
|
|
439
|
-
filesSaved: '已保存。',
|
|
440
|
-
filesSaveFailed: '保存失败',
|
|
441
|
-
// Jobs batch toolbar
|
|
442
|
-
jobsSelectAll: '全选',
|
|
443
|
-
jobsBatchCancel: '批量取消',
|
|
444
|
-
jobsBatchRun: '批量运行',
|
|
445
|
-
jobsBatchEmpty: '未选择任何任务。',
|
|
446
|
-
jobsBatchResult: '批量结果:成功 {ok},失败 {fail}',
|
|
447
|
-
// v1.1.2 / v1.1.3 — 新 Tab 与列
|
|
448
|
-
tabsOutbox: '投递队列',
|
|
449
|
-
tabsA2A: 'A2A 调用',
|
|
450
|
-
kindCol: '类型',
|
|
451
|
-
parentCol: '父任务',
|
|
452
|
-
depthCol: '深度',
|
|
453
|
-
filterAllKinds: '全部类型',
|
|
454
|
-
filterKindJob: 'job(显式 /job 创建)',
|
|
455
|
-
filterKindInline: 'inline(自动跟踪)',
|
|
456
|
-
outboxEmpty: '投递队列为空。',
|
|
457
|
-
outboxStatusPending: '⏳ 待发',
|
|
458
|
-
outboxStatusDelivered: '✅ 已送达',
|
|
459
|
-
outboxStatusGivingUp: '💀 已放弃',
|
|
460
|
-
outboxRetry: '重试',
|
|
461
|
-
outboxColPlatform: '平台',
|
|
462
|
-
outboxColKind: '类型',
|
|
463
|
-
outboxColPri: '优先级',
|
|
464
|
-
outboxColAttempts: '尝试次数',
|
|
465
|
-
outboxColPayload: '消息内容',
|
|
466
|
-
outboxColError: '错误',
|
|
467
|
-
outboxColCreated: '创建时间',
|
|
468
|
-
a2aEmpty: '暂无 A2A 调用记录。',
|
|
469
|
-
a2aRecent: '最近 A2A 调用',
|
|
470
|
-
a2aCallerCallee: '调用方→被调方',
|
|
471
|
-
a2aTreeRoot: '调用链树(根 #{id})',
|
|
472
|
-
a2aTreeInputJob: '输入 job id 看调用链…',
|
|
473
|
-
a2aBtnTree: '查看树',
|
|
474
|
-
a2aBtnViewParent: '查看父任务调用链',
|
|
475
|
-
a2aStatTotal: '总数',
|
|
476
|
-
a2aStat24h: '24小时',
|
|
477
|
-
a2aStatMaxDepth: '最大深度',
|
|
478
|
-
a2aStatTop: '热门:',
|
|
479
|
-
modalParent: '父任务',
|
|
480
|
-
modalDepth: '深度',
|
|
481
|
-
modalThread: '会话',
|
|
482
|
-
modalReplaced: '替换为',
|
|
483
|
-
modalLastOutbox: '最近投递',
|
|
484
|
-
modalArtifacts: '文件附件',
|
|
485
|
-
modalArtifactsInputs: '输入:',
|
|
486
|
-
modalArtifactsOutputs: '输出:',
|
|
487
|
-
modalDelivered: '送达时间',
|
|
488
|
-
helpClose: '关闭',
|
|
489
|
-
help: {
|
|
490
|
-
outbox: {
|
|
491
|
-
title: 'Outbox · 投递队列',
|
|
492
|
-
body: '每条要发到 IM 的消息都先写入 SQLite 投递队列,后台 worker 按指数退避节奏拉走真发:失败时 1s→5s→30s→5min→30min→2h 重试,连续 6 次失败转为「已放弃」直到你手动点重试。这样 IM 抖动 / 短暂断网都不会丢消息。可在 IM 内用 /outbox status 查询。',
|
|
493
|
-
},
|
|
494
|
-
inline: {
|
|
495
|
-
title: 'Inline 任务 · 自动跟踪',
|
|
496
|
-
body: '每条进入 agim 触发 Agent 的 IM 消息,会自动建一条 inline job 跟踪完整生命周期(待执行 → 运行中 → 已完成 → 已送达)。agim 崩溃 / 重启时绝不丢任务。区别于 kind=job 行(来自显式 /job create)。inline 保留 24 小时,job 保留 30 天。',
|
|
497
|
-
},
|
|
498
|
-
a2a: {
|
|
499
|
-
title: 'A2A · Agent 互调',
|
|
500
|
-
body: '当前 Agent 可以通过 mcp__imhub__call_agent 工具调用另一个 Agent(比如 claude 说"用 codex 跑 git status")。agim 会为被调 Agent 建一条新 inline job,parent_id 指向调用方。护栏由 agim 强制:调用深度上限(默认 3)、禁止自调、工作区白名单、按人共享预算。调用树视图展示完整调用链。',
|
|
501
|
-
},
|
|
502
|
-
artifacts: {
|
|
503
|
-
title: 'Artifacts · 共享文件',
|
|
504
|
-
body: 'A2A Layer 2 让 Agent 之间传文件而非把内容塞进 prompt。每条 A2A 任务有独立工作目录 ~/.agim/artifacts/<jobId>/_agim-{input,output}/。调用方通过 inputs[] 放文件,被调方写到 _agim-output/,调用方在这里点击下载或用 Read 工具读。保留期跟 inline 任务一致(24 小时)。',
|
|
505
|
-
},
|
|
506
|
-
callDepth: {
|
|
507
|
-
title: '调用深度',
|
|
508
|
-
body: '该任务在 A2A 链中的层数。0 = 用户发起的消息,每多一层 mcp__imhub__call_agent 嵌套 +1。可由 IMHUB_A2A_MAX_DEPTH 配置最大值(默认 3),超过则在花费 token 前就拒绝调用。',
|
|
509
|
-
},
|
|
510
|
-
parent: {
|
|
511
|
-
title: '父任务',
|
|
512
|
-
body: 'A2A 被调方任务的 parent_id 指向触发此次调用的任务行。用户原发的任务为空。点击可打开父任务详情。',
|
|
513
|
-
},
|
|
514
|
-
replacedBy: {
|
|
515
|
-
title: '已被替换为',
|
|
516
|
-
body: '服务重启后被中断、用户回复"1 重发"重新发起的任务,其 replaced_by 字段指向新行 id。旧行保留用于审计。',
|
|
517
|
-
},
|
|
518
|
-
givingUp: {
|
|
519
|
-
title: '已放弃',
|
|
520
|
-
body: '投递队列中连续 6 次失败(累计退避约 3 小时)的行进入此状态,不再自动重试。点击"重试"会将其重新入队、退避计数清零。',
|
|
521
|
-
},
|
|
522
|
-
},
|
|
523
|
-
// ── v1.2 Cost & Health tab ──
|
|
524
|
-
tabsCost: '💰 成本与健康',
|
|
525
|
-
tabsMemory: '🧠 记忆',
|
|
526
|
-
tabsSkills: '🛠 技能',
|
|
527
|
-
skillsH2: '🛠 已安装的技能',
|
|
528
|
-
skillsIntro: '本机已安装的 claude / opencode skills 索引。点击行查看完整描述。要安装新 skill 请在 IM 里发 <code>/find-skills</code>,或浏览下方 skillhub.cn 热门榜。',
|
|
529
|
-
skillsSearchPh: '搜索(名称 / 描述)',
|
|
530
|
-
skillsCatAll: '所有类别',
|
|
531
|
-
skillsRemoteH2: 'skillhub.cn 热门',
|
|
532
|
-
skillsRemoteOpen: '前往 skillhub.cn ↗',
|
|
533
|
-
skillsRemoteIntro: '实时拉取 skillhub.cn top 50 热门下载(5 分钟缓存)。点击任意条目查看描述 + 安装命令。agim 不会自动跑安装——命令复制后由你贴去 shell 或 IM 触发。',
|
|
534
|
-
skillsRemoteCached: '缓存命中',
|
|
535
|
-
skillsRemoteStale: '上游不可达,用旧数据',
|
|
536
|
-
skillsCol: 'slug',
|
|
537
|
-
skillsName: '名称 / 描述',
|
|
538
|
-
skillsStars: '⭐ stars',
|
|
539
|
-
skillsInstalls: '⬇ installs',
|
|
540
|
-
skillsHowInstall: '安装方式(任选一个;agim 不会自动跑)',
|
|
541
|
-
skillsHostHint: '前两条要先在 host 上跑 skillhub install.sh。详见 docs/skills.md 或 skillhub.cn。',
|
|
542
|
-
costH2: '💰 成本与健康',
|
|
543
|
-
costWindow: '窗口',
|
|
544
|
-
costDays1: '1 天',
|
|
545
|
-
costDays7: '7 天',
|
|
546
|
-
costDays30: '30 天',
|
|
547
|
-
costDays90: '90 天',
|
|
548
|
-
costRefresh: '刷新',
|
|
549
|
-
costDailyTrend: '每日趋势',
|
|
550
|
-
costMetricCalls: '调用数',
|
|
551
|
-
costMetricCost: '成本($)',
|
|
552
|
-
costMetricErrors: '错误数',
|
|
553
|
-
costMetricLatency: '平均耗时(ms)',
|
|
554
|
-
costTopUser: '用户排行',
|
|
555
|
-
costTopAgent: 'Agent 排行',
|
|
556
|
-
costTopPlatform: '平台排行',
|
|
557
|
-
// ── v1.5/1.6 Memory tab ──
|
|
558
|
-
memoryH2: '🧠 长期记忆管理',
|
|
559
|
-
memoryIntro: 'agent 自动从对话中提取的事实 + 记忆画像(persona summary)。选用户 → 查看 / 编辑 / 删除。全部数据存在本机 ~/.agim/memory.db,不会上传到任何外部服务。',
|
|
560
|
-
memoryUser: '用户',
|
|
561
|
-
memoryUserLoading: '— 加载中 —',
|
|
562
|
-
memoryReloadUsers: '🔄 重载用户列表',
|
|
563
|
-
memoryExportUser: '📦 导出此用户 JSON',
|
|
564
|
-
memoryPersonaCard: '记忆画像',
|
|
565
|
-
memoryPersonaPlaceholder: '(此用户暂无记忆画像 — 等下次 consolidation 自动生成,或手动写入)',
|
|
566
|
-
memoryPersonaSave: '保存记忆画像',
|
|
567
|
-
memoryPersonaDelete: '删除记忆画像',
|
|
568
|
-
memoryPersonaConsolidate: '🔁 立刻重建画像',
|
|
569
|
-
memoryFactsCard: 'Facts',
|
|
570
|
-
memoryFactsSearch: '搜索(FTS5)',
|
|
571
|
-
memoryFactsCatAll: '所有类型',
|
|
572
|
-
memoryFactsQuery: '查询',
|
|
573
|
-
memoryFactsSelectUser: '(请选择用户)',
|
|
574
|
-
memoryFactsPrev: '← 上一页',
|
|
575
|
-
memoryFactsNext: '下一页 →',
|
|
576
|
-
memoryFactsClearLowConf: '🗑 清低置信度',
|
|
577
|
-
memoryFactsClearAll: '⚠️ 清空所有',
|
|
578
|
-
// ── v1.6 Vector card ──
|
|
579
|
-
vecTitle: '🔍 向量召回',
|
|
580
|
-
vecSubtitle: '(可选增强 · 默认关闭)',
|
|
581
|
-
vecWhyHeading: '为什么加向量?',
|
|
582
|
-
vecWhyDefault: '默认(FTS5 关键词):只能命中字面 — 「腾讯」→ 含「腾讯」字样的事实。',
|
|
583
|
-
vecWhyAdded: '加向量后:召回同义 / 跨语言 / 概念性查询:',
|
|
584
|
-
vecWhyEx1: '▸ 「投资偏好」→ 召回「用户偏好低回撤、稳健」',
|
|
585
|
-
vecWhyEx2: '▸ 「Beijing」→ 召回「用户在北京工作」',
|
|
586
|
-
vecWhyEx3: '▸ 「我儿子生日」→ 召回「Tom 2018-03-15 出生」',
|
|
587
|
-
vecWhenHeading: '何时启用',
|
|
588
|
-
vecWhenBody: '积累 50+ 条事实、agent 经常"忘了用户说过 X"。初装不需要 — FTS5 已经够用。',
|
|
589
|
-
vecBackend: '后端',
|
|
590
|
-
vecBackendOff: '关闭(仅 FTS5)',
|
|
591
|
-
vecBackendLocal: '本地 BGE(一次性下载 ~250MB)',
|
|
592
|
-
vecBackendOpenai: '远程 OpenAI 兼容(baseUrl + key)',
|
|
593
|
-
vecLocalModel: '本地模型',
|
|
594
|
-
vecLocalSmall: '小(small,~100MB,速度优先)',
|
|
595
|
-
vecLocalBase: '中(base,~250MB,推荐)',
|
|
596
|
-
vecLocalLarge: '大(large,~500MB,精度优先)',
|
|
597
|
-
vecLocalCustom: '自定义…',
|
|
598
|
-
vecLocalHint: '三个预设都是 BGE 中文模型(智源研究院开源,HuggingFace 上 Xenova 提供 ONNX 量化版)。首次下载到 ~/.agim/cache/transformers',
|
|
599
|
-
vecDownloadBtn: '📥 下载模型(首次 5-10 min,建议 WiFi)',
|
|
600
|
-
vecOpenaiKey: 'API Key',
|
|
601
|
-
vecOpenaiReveal: '🔓 显示并编辑',
|
|
602
|
-
vecOpenaiKeyHint: '默认掩码显示;保存时如未点开「显示」,原值不会被覆盖。',
|
|
603
|
-
vecTestBtn: '🧪 测试连接(10s 超时)',
|
|
604
|
-
vecSaveBtn: '保存配置',
|
|
605
|
-
vecBackfillBtn: '🔄 回填全部',
|
|
606
|
-
vecClearBtn: '🗑 清空向量索引',
|
|
607
|
-
vecMemoryDisabled: '○ 记忆未启用(在 Settings → 自动化记忆开启)',
|
|
608
|
-
},
|
|
609
|
-
};
|
|
610
|
-
window.__t = T[window.__lang];
|
|
611
|
-
})();
|
|
612
|
-
</script>
|
|
613
|
-
<style>
|
|
614
|
-
/* Three-state theming. `:root` is the light default; explicit
|
|
615
|
-
data-theme="light"|"dark" forces a mode regardless of OS pref;
|
|
616
|
-
`prefers-color-scheme: dark` only applies when the attribute is
|
|
617
|
-
absent (i.e. mode === 'system' in _app.js). */
|
|
618
|
-
:root {
|
|
619
|
-
color-scheme: light dark;
|
|
620
|
-
--bg: #fafafa;
|
|
621
|
-
--fg: #222;
|
|
622
|
-
--muted: #666;
|
|
623
|
-
--border: #ddd;
|
|
624
|
-
--primary: #0066cc;
|
|
625
|
-
--success: #28a745;
|
|
626
|
-
--warning: #ffc107;
|
|
627
|
-
--danger: #dc3545;
|
|
628
|
-
--info: #17a2b8;
|
|
629
|
-
--card: #fff;
|
|
630
|
-
}
|
|
631
|
-
:root[data-theme="dark"] {
|
|
632
|
-
--bg: #0e0e10;
|
|
633
|
-
--fg: #e6e6e6;
|
|
634
|
-
--muted: #888;
|
|
635
|
-
--border: #2a2a2e;
|
|
636
|
-
--card: #1a1a1d;
|
|
637
|
-
}
|
|
638
|
-
@media (prefers-color-scheme: dark) {
|
|
639
|
-
:root:not([data-theme]) {
|
|
640
|
-
--bg: #0e0e10;
|
|
641
|
-
--fg: #e6e6e6;
|
|
642
|
-
--muted: #888;
|
|
643
|
-
--border: #2a2a2e;
|
|
644
|
-
--card: #1a1a1d;
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
* { box-sizing: border-box; }
|
|
648
|
-
body {
|
|
649
|
-
margin: 0;
|
|
650
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
651
|
-
background: var(--bg);
|
|
652
|
-
color: var(--fg);
|
|
653
|
-
font-size: 14px;
|
|
654
|
-
}
|
|
655
|
-
header {
|
|
656
|
-
display: flex;
|
|
657
|
-
align-items: center;
|
|
658
|
-
gap: 16px;
|
|
659
|
-
padding: 14px 24px;
|
|
660
|
-
border-bottom: 1px solid var(--border);
|
|
661
|
-
background: var(--card);
|
|
662
|
-
}
|
|
663
|
-
header h1 { margin: 0; font-size: 18px; flex: 1; }
|
|
664
|
-
header a, header button {
|
|
665
|
-
color: var(--primary);
|
|
666
|
-
text-decoration: none;
|
|
667
|
-
font-size: 14px;
|
|
668
|
-
background: none;
|
|
669
|
-
border: 1px solid var(--border);
|
|
670
|
-
padding: 6px 12px;
|
|
671
|
-
border-radius: 4px;
|
|
672
|
-
cursor: pointer;
|
|
673
|
-
}
|
|
674
|
-
header a:hover, header button:hover { border-color: var(--primary); }
|
|
675
|
-
main { padding: 20px 24px; max-width: 1200px; margin: 0 auto; }
|
|
676
|
-
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
|
677
|
-
.tab {
|
|
678
|
-
padding: 8px 16px;
|
|
679
|
-
cursor: pointer;
|
|
680
|
-
border: none;
|
|
681
|
-
background: none;
|
|
682
|
-
color: var(--muted);
|
|
683
|
-
font-size: 14px;
|
|
684
|
-
border-bottom: 2px solid transparent;
|
|
685
|
-
}
|
|
686
|
-
.tab.active { color: var(--fg); border-bottom-color: var(--primary); }
|
|
687
|
-
.stats {
|
|
688
|
-
display: grid;
|
|
689
|
-
grid-template-columns: repeat(5, 1fr);
|
|
690
|
-
gap: 12px;
|
|
691
|
-
margin-bottom: 16px;
|
|
692
|
-
}
|
|
693
|
-
.stat {
|
|
694
|
-
background: var(--card);
|
|
695
|
-
padding: 12px 16px;
|
|
696
|
-
border-radius: 6px;
|
|
697
|
-
border: 1px solid var(--border);
|
|
698
|
-
}
|
|
699
|
-
.stat-label { color: var(--muted); font-size: 12px; }
|
|
700
|
-
.stat-val { font-size: 22px; font-weight: 600; margin-top: 4px; }
|
|
701
|
-
.toolbar {
|
|
702
|
-
display: flex;
|
|
703
|
-
gap: 8px;
|
|
704
|
-
align-items: center;
|
|
705
|
-
margin-bottom: 12px;
|
|
706
|
-
flex-wrap: wrap;
|
|
707
|
-
}
|
|
708
|
-
.toolbar select, .toolbar button, .toolbar label {
|
|
709
|
-
font-size: 13px;
|
|
710
|
-
padding: 5px 10px;
|
|
711
|
-
border: 1px solid var(--border);
|
|
712
|
-
background: var(--card);
|
|
713
|
-
color: var(--fg);
|
|
714
|
-
border-radius: 4px;
|
|
715
|
-
}
|
|
716
|
-
.toolbar button { cursor: pointer; }
|
|
717
|
-
.toolbar button.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
|
718
|
-
.toolbar label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
719
|
-
table {
|
|
720
|
-
width: 100%;
|
|
721
|
-
border-collapse: collapse;
|
|
722
|
-
background: var(--card);
|
|
723
|
-
border-radius: 6px;
|
|
724
|
-
overflow: hidden;
|
|
725
|
-
border: 1px solid var(--border);
|
|
726
|
-
}
|
|
727
|
-
th, td {
|
|
728
|
-
text-align: left;
|
|
729
|
-
padding: 10px 12px;
|
|
730
|
-
font-size: 13px;
|
|
731
|
-
border-bottom: 1px solid var(--border);
|
|
732
|
-
}
|
|
733
|
-
th {
|
|
734
|
-
background: var(--bg);
|
|
735
|
-
color: var(--muted);
|
|
736
|
-
font-weight: 500;
|
|
737
|
-
font-size: 12px;
|
|
738
|
-
text-transform: uppercase;
|
|
739
|
-
}
|
|
740
|
-
tbody tr:last-child td { border-bottom: none; }
|
|
741
|
-
.pill {
|
|
742
|
-
display: inline-block;
|
|
743
|
-
padding: 2px 8px;
|
|
744
|
-
border-radius: 999px;
|
|
745
|
-
font-size: 11px;
|
|
746
|
-
font-weight: 500;
|
|
747
|
-
}
|
|
748
|
-
.pill.pending { background: rgba(255, 193, 7, 0.18); color: #b89000; }
|
|
749
|
-
.pill.running { background: rgba(23, 162, 184, 0.18); color: #0d8898; }
|
|
750
|
-
.pill.completed { background: rgba(40, 167, 69, 0.18); color: #1e7c34; }
|
|
751
|
-
.pill.delivered { background: rgba(40, 167, 69, 0.28); color: #155724; font-weight: 500; }
|
|
752
|
-
.pill.failed { background: rgba(220, 53, 69, 0.18); color: #b32433; }
|
|
753
|
-
.pill.cancelled { background: rgba(108, 117, 125, 0.18); color: #5a6268; }
|
|
754
|
-
.pill.interrupted { background: rgba(255, 152, 0, 0.22); color: #b35900; }
|
|
755
|
-
.pill.replaced { background: rgba(94, 53, 177, 0.18); color: #4527a0; }
|
|
756
|
-
.pill.abandoned { background: rgba(96, 125, 139, 0.18); color: #455a64; }
|
|
757
|
-
.pill.kind-inline { background: rgba(63, 81, 181, 0.12); color: #3949ab; font-size: 11px; }
|
|
758
|
-
.pill.kind-job { background: rgba(141, 110, 99, 0.18); color: #5d4037; font-size: 11px; }
|
|
759
|
-
/* Mobile-friendly: wide tables (Jobs / Outbox / A2A / Audit) get a
|
|
760
|
-
horizontal scrollbar inside their pane instead of overflowing the
|
|
761
|
-
viewport. WeChat/Telegram in-app WebView is the common entry. */
|
|
762
|
-
#jobs-list, #outbox-list, #a2a-list, #audit-list, #subtasks-list, #bg-list, #schedules-list {
|
|
763
|
-
overflow-x: auto;
|
|
764
|
-
-webkit-overflow-scrolling: touch;
|
|
765
|
-
}
|
|
766
|
-
#jobs-list table, #outbox-list table, #a2a-list table, #audit-list table {
|
|
767
|
-
min-width: 720px; /* allow horizontal scroll instead of squishing cells */
|
|
768
|
-
}
|
|
769
|
-
/* Tab bar should also scroll horizontally on narrow screens rather
|
|
770
|
-
than wrapping (10 tabs don't fit on a phone otherwise). */
|
|
771
|
-
.tabs {
|
|
772
|
-
overflow-x: auto;
|
|
773
|
-
-webkit-overflow-scrolling: touch;
|
|
774
|
-
white-space: nowrap;
|
|
775
|
-
flex-wrap: nowrap;
|
|
776
|
-
}
|
|
777
|
-
.tabs .tab { flex-shrink: 0; }
|
|
778
|
-
/* Help-tooltip button — small (?) next to jargon, opens centered modal */
|
|
779
|
-
.help-btn {
|
|
780
|
-
display: inline-flex; align-items: center; justify-content: center;
|
|
781
|
-
width: 18px; height: 18px; padding: 0; border-radius: 50%;
|
|
782
|
-
font-size: 11px; font-weight: 700; line-height: 1;
|
|
783
|
-
background: var(--surface-2, rgba(0,0,0,0.06)); color: var(--text-muted, #666);
|
|
784
|
-
border: 1px solid var(--border, rgba(0,0,0,0.12));
|
|
785
|
-
cursor: pointer; transition: all .15s;
|
|
786
|
-
}
|
|
787
|
-
.help-btn:hover { background: var(--primary, #3b82f6); color: #fff; border-color: var(--primary, #3b82f6); }
|
|
788
|
-
.help-modal h2 { margin: 0 0 .5em; font-size: 18px; }
|
|
789
|
-
.help-modal p { line-height: 1.7; color: var(--text, #333); white-space: pre-wrap; }
|
|
790
|
-
.row-actions button {
|
|
791
|
-
font-size: 12px;
|
|
792
|
-
padding: 3px 8px;
|
|
793
|
-
margin-right: 4px;
|
|
794
|
-
cursor: pointer;
|
|
795
|
-
border: 1px solid var(--border);
|
|
796
|
-
background: var(--card);
|
|
797
|
-
color: var(--fg);
|
|
798
|
-
border-radius: 4px;
|
|
799
|
-
}
|
|
800
|
-
.row-actions button:hover { border-color: var(--primary); color: var(--primary); }
|
|
801
|
-
.row-actions button.danger:hover { border-color: var(--danger); color: var(--danger); }
|
|
802
|
-
.empty { text-align: center; padding: 40px; color: var(--muted); }
|
|
803
|
-
.modal-bg {
|
|
804
|
-
position: fixed; inset: 0;
|
|
805
|
-
background: rgba(0,0,0,0.5);
|
|
806
|
-
display: none;
|
|
807
|
-
align-items: center;
|
|
808
|
-
justify-content: center;
|
|
809
|
-
z-index: 100;
|
|
810
|
-
}
|
|
811
|
-
.modal-bg.show { display: flex; }
|
|
812
|
-
.modal {
|
|
813
|
-
background: var(--card);
|
|
814
|
-
border-radius: 8px;
|
|
815
|
-
padding: 20px;
|
|
816
|
-
max-width: 720px;
|
|
817
|
-
width: 90%;
|
|
818
|
-
max-height: 80vh;
|
|
819
|
-
overflow-y: auto;
|
|
820
|
-
}
|
|
821
|
-
.modal h2 { margin-top: 0; }
|
|
822
|
-
.modal pre {
|
|
823
|
-
background: var(--bg);
|
|
824
|
-
padding: 12px;
|
|
825
|
-
border-radius: 6px;
|
|
826
|
-
overflow-x: auto;
|
|
827
|
-
max-height: 400px;
|
|
828
|
-
white-space: pre-wrap;
|
|
829
|
-
font-size: 12px;
|
|
830
|
-
}
|
|
831
|
-
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
|
832
|
-
.modal input, .modal textarea, .modal select {
|
|
833
|
-
width: 100%;
|
|
834
|
-
padding: 8px;
|
|
835
|
-
margin: 4px 0 12px;
|
|
836
|
-
background: var(--bg);
|
|
837
|
-
color: var(--fg);
|
|
838
|
-
border: 1px solid var(--border);
|
|
839
|
-
border-radius: 4px;
|
|
840
|
-
font: inherit;
|
|
841
|
-
}
|
|
842
|
-
.modal textarea { min-height: 100px; resize: vertical; }
|
|
843
|
-
code { font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; }
|
|
844
|
-
.truncate { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
845
|
-
|
|
846
|
-
/* PR-B: Health tab — per-agent operational card */
|
|
847
|
-
.health-card {
|
|
848
|
-
background: var(--card);
|
|
849
|
-
border: 1px solid var(--border);
|
|
850
|
-
border-radius: 8px;
|
|
851
|
-
padding: 14px 16px;
|
|
852
|
-
margin-bottom: 12px;
|
|
853
|
-
}
|
|
854
|
-
.health-head {
|
|
855
|
-
display: flex;
|
|
856
|
-
align-items: center;
|
|
857
|
-
gap: 10px;
|
|
858
|
-
margin-bottom: 12px;
|
|
859
|
-
}
|
|
860
|
-
.health-agent { font-size: 14px; font-weight: 600; }
|
|
861
|
-
.health-grid {
|
|
862
|
-
display: grid;
|
|
863
|
-
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
864
|
-
gap: 12px 20px;
|
|
865
|
-
}
|
|
866
|
-
.health-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; }
|
|
867
|
-
.health-val { font-size: 13px; margin-top: 3px; font-variant-numeric: tabular-nums; }
|
|
868
|
-
.health-spark { margin-top: 3px; }
|
|
869
|
-
|
|
870
|
-
/* PR-B: Approvals tab — global pending list */
|
|
871
|
-
.approval-item {
|
|
872
|
-
background: var(--card);
|
|
873
|
-
border: 1px solid var(--border);
|
|
874
|
-
border-left: 3px solid var(--primary);
|
|
875
|
-
border-radius: 6px;
|
|
876
|
-
padding: 12px 14px;
|
|
877
|
-
margin-bottom: 10px;
|
|
878
|
-
}
|
|
879
|
-
.approval-head {
|
|
880
|
-
display: flex;
|
|
881
|
-
align-items: center;
|
|
882
|
-
flex-wrap: wrap;
|
|
883
|
-
gap: 8px;
|
|
884
|
-
margin-bottom: 8px;
|
|
885
|
-
}
|
|
886
|
-
.approval-head code { font-size: 13px; font-weight: 600; }
|
|
887
|
-
.approval-meta {
|
|
888
|
-
margin-left: auto;
|
|
889
|
-
font-size: 12px;
|
|
890
|
-
color: var(--muted);
|
|
891
|
-
}
|
|
892
|
-
.approval-input {
|
|
893
|
-
background: var(--bg);
|
|
894
|
-
border: 1px solid var(--border);
|
|
895
|
-
border-radius: 4px;
|
|
896
|
-
padding: 8px 10px;
|
|
897
|
-
font: 12px/1.5 'SF Mono', Menlo, Consolas, monospace;
|
|
898
|
-
max-height: 180px;
|
|
899
|
-
overflow: auto;
|
|
900
|
-
margin: 6px 0 10px;
|
|
901
|
-
white-space: pre-wrap;
|
|
902
|
-
word-break: break-word;
|
|
903
|
-
}
|
|
904
|
-
.approval-actions { display: flex; gap: 8px; }
|
|
905
|
-
.approval-actions button {
|
|
906
|
-
border: 1px solid var(--border);
|
|
907
|
-
background: var(--card);
|
|
908
|
-
color: var(--fg);
|
|
909
|
-
padding: 5px 14px;
|
|
910
|
-
border-radius: 4px;
|
|
911
|
-
cursor: pointer;
|
|
912
|
-
font-size: 13px;
|
|
913
|
-
}
|
|
914
|
-
.approval-actions button:hover { border-color: var(--primary); }
|
|
915
|
-
.approval-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
916
|
-
.approval-actions .btn-allow { background: var(--success); border-color: var(--success); color: #fff; }
|
|
917
|
-
.approval-actions .btn-deny { background: var(--danger); border-color: var(--danger); color: #fff; }
|
|
918
|
-
</style>
|
|
919
|
-
</head>
|
|
920
|
-
<body>
|
|
921
|
-
<header>
|
|
922
|
-
<h1 id="page-title"></h1>
|
|
923
|
-
<button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
|
|
924
|
-
<select id="langSelect" title="Language / 语言">
|
|
925
|
-
<option value="en">EN</option>
|
|
926
|
-
<option value="zh">中文</option>
|
|
927
|
-
</select>
|
|
928
|
-
<a href="/" id="lnk-chat"></a>
|
|
929
|
-
<a href="/reminders" id="lnk-reminders"></a>
|
|
930
|
-
<a href="/memos" id="lnk-memos"></a>
|
|
931
|
-
<a href="/settings" id="lnk-settings"></a>
|
|
932
|
-
</header>
|
|
933
|
-
<main>
|
|
934
|
-
<div class="tabs">
|
|
935
|
-
<button type="button" class="tab active" data-tab="jobs" id="tab-jobs"></button>
|
|
936
|
-
<button type="button" class="tab" data-tab="background" id="tab-background"></button>
|
|
937
|
-
<button type="button" class="tab" data-tab="subtasks" id="tab-subtasks"></button>
|
|
938
|
-
<button type="button" class="tab" data-tab="schedules" id="tab-schedules"></button>
|
|
939
|
-
<button type="button" class="tab" data-tab="approvals" id="tab-approvals"></button>
|
|
940
|
-
<button type="button" class="tab" data-tab="health" id="tab-health"></button>
|
|
941
|
-
<button type="button" class="tab" data-tab="files" id="tab-files"></button>
|
|
942
|
-
<button type="button" class="tab" data-tab="audit" id="tab-audit"></button>
|
|
943
|
-
<button type="button" class="tab" data-tab="outbox" id="tab-outbox">Outbox</button>
|
|
944
|
-
<button type="button" class="tab" data-tab="a2a" id="tab-a2a">A2A</button>
|
|
945
|
-
<button type="button" class="tab" data-tab="cost" id="tab-cost">💰 Cost & Health</button>
|
|
946
|
-
<button type="button" class="tab" data-tab="memory" id="tab-memory">🧠 Memory</button>
|
|
947
|
-
<button type="button" class="tab" data-tab="skills" id="tab-skills">🛠 Skills</button>
|
|
948
|
-
</div>
|
|
949
|
-
|
|
950
|
-
<section id="jobs-pane">
|
|
951
|
-
<div class="stats" id="stats"></div>
|
|
952
|
-
<div class="toolbar">
|
|
953
|
-
<select id="filter-status">
|
|
954
|
-
<option value=""></option>
|
|
955
|
-
<option value="pending"></option>
|
|
956
|
-
<option value="running"></option>
|
|
957
|
-
<option value="completed"></option>
|
|
958
|
-
<option value="failed"></option>
|
|
959
|
-
<option value="cancelled"></option>
|
|
960
|
-
<option value="delivered">delivered</option>
|
|
961
|
-
<option value="interrupted">interrupted</option>
|
|
962
|
-
<option value="replaced">replaced</option>
|
|
963
|
-
<option value="abandoned">abandoned</option>
|
|
964
|
-
</select>
|
|
965
|
-
<select id="filter-kind" title="kind filter" data-i18n-attr="title:kindCol">
|
|
966
|
-
<option value="" data-i18n="filterAllKinds">All kinds</option>
|
|
967
|
-
<option value="job" data-i18n="filterKindJob">job (explicit /job)</option>
|
|
968
|
-
<option value="inline" data-i18n="filterKindInline">inline (auto-tracked)</option>
|
|
969
|
-
</select>
|
|
970
|
-
<select id="jobs-agent-filter" data-agent-filter>
|
|
971
|
-
<option value="">All agents</option>
|
|
972
|
-
<option value="claude-code">claude-code</option>
|
|
973
|
-
<option value="opencode">opencode</option>
|
|
974
|
-
<option value="codex">codex</option>
|
|
975
|
-
</select>
|
|
976
|
-
<button type="button" id="btn-refresh"></button>
|
|
977
|
-
<label><input type="checkbox" id="auto-refresh" checked> <span id="lbl-auto"></span></label>
|
|
978
|
-
<button type="button" id="btn-new" class="primary"></button>
|
|
979
|
-
</div>
|
|
980
|
-
<div class="toolbar" id="batch-toolbar" style="display:none">
|
|
981
|
-
<span id="batch-summary" style="color:var(--muted);font-size:13px"></span>
|
|
982
|
-
<button type="button" id="btn-batch-run"></button>
|
|
983
|
-
<button type="button" id="btn-batch-cancel" class="danger"></button>
|
|
984
|
-
</div>
|
|
985
|
-
<div id="jobs-list"></div>
|
|
986
|
-
</section>
|
|
987
|
-
|
|
988
|
-
<section id="background-pane" hidden>
|
|
989
|
-
<div class="toolbar">
|
|
990
|
-
<select id="bg-root-filter"></select>
|
|
991
|
-
<button type="button" id="btn-bg-refresh"></button>
|
|
992
|
-
<label><input type="checkbox" id="bg-auto-refresh" checked> <span id="lbl-bg-auto"></span></label>
|
|
993
|
-
</div>
|
|
994
|
-
<div id="bg-list"></div>
|
|
995
|
-
</section>
|
|
996
|
-
|
|
997
|
-
<section id="subtasks-pane" hidden>
|
|
998
|
-
<div class="toolbar">
|
|
999
|
-
<select id="subtasks-agent-filter" data-agent-filter>
|
|
1000
|
-
<option value="">All agents</option>
|
|
1001
|
-
<option value="claude-code">claude-code</option>
|
|
1002
|
-
<option value="opencode">opencode</option>
|
|
1003
|
-
<option value="codex">codex</option>
|
|
1004
|
-
</select>
|
|
1005
|
-
<button type="button" id="btn-sub-refresh"></button>
|
|
1006
|
-
</div>
|
|
1007
|
-
<div id="sub-list"></div>
|
|
1008
|
-
</section>
|
|
1009
|
-
|
|
1010
|
-
<section id="schedules-pane" hidden>
|
|
1011
|
-
<div class="toolbar">
|
|
1012
|
-
<select id="schedules-agent-filter" data-agent-filter>
|
|
1013
|
-
<option value="">All agents</option>
|
|
1014
|
-
<option value="claude-code">claude-code</option>
|
|
1015
|
-
<option value="opencode">opencode</option>
|
|
1016
|
-
<option value="codex">codex</option>
|
|
1017
|
-
</select>
|
|
1018
|
-
<button type="button" id="btn-sched-refresh">Refresh</button>
|
|
1019
|
-
</div>
|
|
1020
|
-
<div id="schedules-list"></div>
|
|
1021
|
-
</section>
|
|
1022
|
-
|
|
1023
|
-
<section id="approvals-pane" hidden>
|
|
1024
|
-
<div class="toolbar">
|
|
1025
|
-
<button type="button" id="btn-approvals-refresh">Refresh</button>
|
|
1026
|
-
<label><input type="checkbox" id="approvals-auto-refresh" checked> <span id="lbl-approvals-auto"></span></label>
|
|
1027
|
-
<span id="approvals-summary" style="margin-left:auto;color:var(--muted);font-size:13px"></span>
|
|
1028
|
-
</div>
|
|
1029
|
-
<div id="approvals-list"></div>
|
|
1030
|
-
</section>
|
|
1031
|
-
|
|
1032
|
-
<section id="health-pane" hidden>
|
|
1033
|
-
<div class="toolbar">
|
|
1034
|
-
<button type="button" id="btn-health-refresh">Refresh</button>
|
|
1035
|
-
<label><input type="checkbox" id="health-auto-refresh" checked> <span id="lbl-health-auto"></span></label>
|
|
1036
|
-
<span id="health-uptime" style="margin-left:auto;color:var(--muted);font-size:13px"></span>
|
|
1037
|
-
</div>
|
|
1038
|
-
<div id="health-list"></div>
|
|
1039
|
-
</section>
|
|
1040
|
-
|
|
1041
|
-
<section id="files-pane" hidden>
|
|
1042
|
-
<div class="toolbar">
|
|
1043
|
-
<label><span id="lbl-files-agent"></span>
|
|
1044
|
-
<select id="files-agent" data-agent-filter></select>
|
|
1045
|
-
</label>
|
|
1046
|
-
<button type="button" id="btn-files-up"></button>
|
|
1047
|
-
<code id="files-current" style="color:var(--muted);font-size:13px"></code>
|
|
1048
|
-
<button type="button" id="btn-files-refresh" style="margin-left:auto"></button>
|
|
1049
|
-
</div>
|
|
1050
|
-
<div class="files-layout" style="display:flex;gap:12px;align-items:flex-start">
|
|
1051
|
-
<div id="files-tree" style="flex:0 0 320px;max-height:60vh;overflow:auto"></div>
|
|
1052
|
-
<div id="files-content" style="flex:1 1 auto;min-width:0"></div>
|
|
1053
|
-
</div>
|
|
1054
|
-
</section>
|
|
1055
|
-
|
|
1056
|
-
<section id="audit-pane" hidden>
|
|
1057
|
-
<div class="toolbar">
|
|
1058
|
-
<select id="audit-agent-filter" data-agent-filter>
|
|
1059
|
-
<option value="">All agents</option>
|
|
1060
|
-
<option value="claude-code">claude-code</option>
|
|
1061
|
-
<option value="opencode">opencode</option>
|
|
1062
|
-
<option value="codex">codex</option>
|
|
1063
|
-
</select>
|
|
1064
|
-
<select id="audit-days-filter">
|
|
1065
|
-
<option value="1">1d</option>
|
|
1066
|
-
<option value="7" selected>7d</option>
|
|
1067
|
-
<option value="30">30d</option>
|
|
1068
|
-
<option value="90">90d</option>
|
|
1069
|
-
</select>
|
|
1070
|
-
<input type="text" id="audit-user-filter" placeholder="user id (optional)" style="min-width:160px">
|
|
1071
|
-
<button type="button" id="btn-audit-refresh">Refresh</button>
|
|
1072
|
-
</div>
|
|
1073
|
-
<div class="stats" id="audit-stats"></div>
|
|
1074
|
-
<div id="audit-list"></div>
|
|
1075
|
-
</section>
|
|
1076
|
-
|
|
1077
|
-
<!-- Outbox tab (v1.1.2) — persistent IM delivery queue -->
|
|
1078
|
-
<section id="outbox-pane" hidden>
|
|
1079
|
-
<h2 style="margin-top:0;display:flex;align-items:center;gap:.5em">
|
|
1080
|
-
<span id="hdr-outbox">Outbox</span>
|
|
1081
|
-
<button type="button" class="help-btn" data-help="outbox" title="?">?</button>
|
|
1082
|
-
</h2>
|
|
1083
|
-
<div class="toolbar">
|
|
1084
|
-
<select id="outbox-status-filter">
|
|
1085
|
-
<option value="" data-i18n="filterAll">All</option>
|
|
1086
|
-
<option value="pending" data-i18n="outboxStatusPending">⏳ Pending</option>
|
|
1087
|
-
<option value="delivered" data-i18n="outboxStatusDelivered">✅ Delivered</option>
|
|
1088
|
-
<option value="giving_up" data-i18n="outboxStatusGivingUp">💀 Giving up</option>
|
|
1089
|
-
</select>
|
|
1090
|
-
<select id="outbox-limit">
|
|
1091
|
-
<option value="20">20</option>
|
|
1092
|
-
<option value="50" selected>50</option>
|
|
1093
|
-
<option value="200">200</option>
|
|
1094
|
-
</select>
|
|
1095
|
-
<button type="button" id="btn-outbox-refresh" data-i18n="refresh">Refresh</button>
|
|
1096
|
-
</div>
|
|
1097
|
-
<div class="stats" id="outbox-stats"></div>
|
|
1098
|
-
<div id="outbox-list"></div>
|
|
1099
|
-
</section>
|
|
1100
|
-
|
|
1101
|
-
<!-- A2A tab (v1.1.3) — agent-to-agent call observability + artifacts viewer -->
|
|
1102
|
-
<section id="a2a-pane" hidden>
|
|
1103
|
-
<h2 style="margin-top:0;display:flex;align-items:center;gap:.5em">
|
|
1104
|
-
<span id="hdr-a2a">A2A</span>
|
|
1105
|
-
<button type="button" class="help-btn" data-help="a2a" title="?">?</button>
|
|
1106
|
-
<button type="button" class="help-btn" data-help="artifacts" title="?" style="margin-left:-4px" id="hdr-a2a-art-help">📎?</button>
|
|
1107
|
-
</h2>
|
|
1108
|
-
<div class="toolbar">
|
|
1109
|
-
<input type="number" id="a2a-tree-id" min="1" style="width:160px" data-i18n-attr="placeholder:a2aTreeInputJob">
|
|
1110
|
-
<button type="button" id="btn-a2a-tree" data-i18n="a2aBtnTree">Tree</button>
|
|
1111
|
-
<button type="button" id="btn-a2a-refresh" data-i18n="refresh">Refresh</button>
|
|
1112
|
-
</div>
|
|
1113
|
-
<div class="stats" id="a2a-stats"></div>
|
|
1114
|
-
<div id="a2a-list"></div>
|
|
1115
|
-
<div id="a2a-tree"></div>
|
|
1116
|
-
</section>
|
|
1117
|
-
|
|
1118
|
-
<!-- v1.3 — Cost & Health: aggregated metrics from the audit-log -->
|
|
1119
|
-
<section id="cost-pane" hidden>
|
|
1120
|
-
<h2 style="margin-top:0" data-i18n="costH2">💰 Cost & Health</h2>
|
|
1121
|
-
<div class="toolbar" style="margin-bottom:12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
|
1122
|
-
<label><span data-i18n="costWindow">窗口</span>:
|
|
1123
|
-
<select id="cost-days">
|
|
1124
|
-
<option value="1" data-i18n="costDays1">1 天</option>
|
|
1125
|
-
<option value="7" selected data-i18n="costDays7">7 天</option>
|
|
1126
|
-
<option value="30" data-i18n="costDays30">30 天</option>
|
|
1127
|
-
<option value="90" data-i18n="costDays90">90 天</option>
|
|
1128
|
-
</select>
|
|
1129
|
-
</label>
|
|
1130
|
-
<button type="button" id="btn-cost-refresh" data-i18n="costRefresh">刷新</button>
|
|
1131
|
-
<span class="muted" id="cost-range" style="font-size:12px"></span>
|
|
1132
|
-
</div>
|
|
1133
|
-
|
|
1134
|
-
<!-- KPI cards -->
|
|
1135
|
-
<div id="cost-kpi" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:16px"></div>
|
|
1136
|
-
|
|
1137
|
-
<!-- Daily trendline -->
|
|
1138
|
-
<div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px;margin-bottom:16px">
|
|
1139
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
1140
|
-
<strong data-i18n="costDailyTrend">每日趋势</strong>
|
|
1141
|
-
<div style="font-size:12px" class="muted">
|
|
1142
|
-
<label><input type="radio" name="cost-trend-metric" value="calls" checked> <span data-i18n="costMetricCalls">调用数</span></label>
|
|
1143
|
-
<label style="margin-left:8px"><input type="radio" name="cost-trend-metric" value="cost"> <span data-i18n="costMetricCost">成本($)</span></label>
|
|
1144
|
-
<label style="margin-left:8px"><input type="radio" name="cost-trend-metric" value="errors"> <span data-i18n="costMetricErrors">错误数</span></label>
|
|
1145
|
-
<label style="margin-left:8px"><input type="radio" name="cost-trend-metric" value="avgLatencyMs"> <span data-i18n="costMetricLatency">平均耗时(ms)</span></label>
|
|
1146
|
-
</div>
|
|
1147
|
-
</div>
|
|
1148
|
-
<div style="position:relative;height:280px"><canvas id="cost-trend"></canvas></div>
|
|
1149
|
-
</div>
|
|
1150
|
-
|
|
1151
|
-
<!-- Top-N tables -->
|
|
1152
|
-
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:12px">
|
|
1153
|
-
<div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px">
|
|
1154
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
1155
|
-
<strong data-i18n="costTopUser">Top by User</strong>
|
|
1156
|
-
<select class="cost-topn-by" data-target="user">
|
|
1157
|
-
<option value="cost">cost</option>
|
|
1158
|
-
<option value="calls" selected>calls</option>
|
|
1159
|
-
<option value="errors">errors</option>
|
|
1160
|
-
<option value="avg_latency">avg latency</option>
|
|
1161
|
-
</select>
|
|
1162
|
-
</div>
|
|
1163
|
-
<div id="cost-topn-user"></div>
|
|
1164
|
-
</div>
|
|
1165
|
-
<div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px">
|
|
1166
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
1167
|
-
<strong data-i18n="costTopAgent">Top by Agent</strong>
|
|
1168
|
-
<select class="cost-topn-by" data-target="agent">
|
|
1169
|
-
<option value="cost">cost</option>
|
|
1170
|
-
<option value="calls" selected>calls</option>
|
|
1171
|
-
<option value="errors">errors</option>
|
|
1172
|
-
<option value="avg_latency">avg latency</option>
|
|
1173
|
-
</select>
|
|
1174
|
-
</div>
|
|
1175
|
-
<div id="cost-topn-agent"></div>
|
|
1176
|
-
</div>
|
|
1177
|
-
<div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px">
|
|
1178
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
1179
|
-
<strong data-i18n="costTopPlatform">Top by Platform</strong>
|
|
1180
|
-
<select class="cost-topn-by" data-target="platform">
|
|
1181
|
-
<option value="cost">cost</option>
|
|
1182
|
-
<option value="calls" selected>calls</option>
|
|
1183
|
-
<option value="errors">errors</option>
|
|
1184
|
-
<option value="avg_latency">avg latency</option>
|
|
1185
|
-
</select>
|
|
1186
|
-
</div>
|
|
1187
|
-
<div id="cost-topn-platform"></div>
|
|
1188
|
-
</div>
|
|
1189
|
-
</div>
|
|
1190
|
-
</section>
|
|
1191
|
-
|
|
1192
|
-
<!-- v1.5 — Memory admin tab: per-user persona + facts inspection / edit -->
|
|
1193
|
-
<section id="memory-pane" hidden>
|
|
1194
|
-
<h2 style="margin-top:0" data-i18n="memoryH2">🧠 长期记忆管理</h2>
|
|
1195
|
-
<p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px" data-i18n="memoryIntro">
|
|
1196
|
-
agent 自动从对话中提取的事实 + 记忆画像(persona summary)。选用户 → 查看 / 编辑 / 删除。
|
|
1197
|
-
全部数据存在本机 ~/.agim/memory.db,不会上传到任何外部服务。
|
|
1198
|
-
</p>
|
|
1199
|
-
<div class="toolbar" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
|
|
1200
|
-
<label><span data-i18n="memoryUser">用户</span>:
|
|
1201
|
-
<select id="mem-user-select" style="min-width:280px">
|
|
1202
|
-
<option value="" data-i18n="memoryUserLoading">— 加载中 —</option>
|
|
1203
|
-
</select>
|
|
1204
|
-
</label>
|
|
1205
|
-
<button type="button" id="btn-mem-refresh-users" data-i18n="memoryReloadUsers">🔄 重载用户列表</button>
|
|
1206
|
-
<a id="btn-mem-export" href="#" target="_blank" style="display:none" data-i18n="memoryExportUser">📦 导出此用户 JSON</a>
|
|
1207
|
-
</div>
|
|
1208
|
-
|
|
1209
|
-
<!-- v1.6 — Vector retrieval (optional enhancement) -->
|
|
1210
|
-
<div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px;margin-bottom:16px">
|
|
1211
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
1212
|
-
<strong><span data-i18n="vecTitle">🔍 向量召回</span> <span class="muted" style="font-weight:400;font-size:12px" data-i18n="vecSubtitle">(可选增强 · 默认关闭)</span></strong>
|
|
1213
|
-
<span class="muted" id="mem-vec-status-badge" style="font-size:12px">—</span>
|
|
1214
|
-
</div>
|
|
1215
|
-
<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">
|
|
1216
|
-
<strong style="color:var(--fg,#1f2328)" data-i18n="vecWhyHeading">为什么加向量?</strong><br>
|
|
1217
|
-
<span data-i18n="vecWhyDefault">默认(FTS5 关键词):只能命中字面 — 「腾讯」→ 含「腾讯」字样的事实。</span><br>
|
|
1218
|
-
<span data-i18n="vecWhyAdded">加向量后:召回同义 / 跨语言 / 概念性查询:</span><br>
|
|
1219
|
-
<span data-i18n="vecWhyEx1">▸ 「投资偏好」→ 召回「用户偏好低回撤、稳健」</span><br>
|
|
1220
|
-
<span data-i18n="vecWhyEx2">▸ 「Beijing」→ 召回「用户在北京工作」</span><br>
|
|
1221
|
-
<span data-i18n="vecWhyEx3">▸ 「我儿子生日」→ 召回「Tom 2018-03-15 出生」</span><br>
|
|
1222
|
-
<strong style="color:var(--fg,#1f2328)" data-i18n="vecWhenHeading">何时启用</strong>:<span data-i18n="vecWhenBody">积累 50+ 条事实、agent 经常"忘了用户说过 X"。初装不需要 — FTS5 已经够用。</span>
|
|
1223
|
-
</div>
|
|
1224
|
-
|
|
1225
|
-
<label data-i18n="vecBackend">后端</label>
|
|
1226
|
-
<select id="mem-vec-backend" style="max-width:280px">
|
|
1227
|
-
<option value="off" data-i18n="vecBackendOff">关闭(仅 FTS5)</option>
|
|
1228
|
-
<option value="local" data-i18n="vecBackendLocal">本地 BGE(一次性下载 ~250MB)</option>
|
|
1229
|
-
<option value="openai" data-i18n="vecBackendOpenai">远程 OpenAI 兼容(baseUrl + key)</option>
|
|
1230
|
-
</select>
|
|
1231
|
-
|
|
1232
|
-
<!-- Local config (shown when backend === 'local') -->
|
|
1233
|
-
<div id="mem-vec-local-cfg" style="display:none;margin-top:10px;padding:10px;border:1px dashed var(--border,#d0d7de);border-radius:6px">
|
|
1234
|
-
<label data-i18n="vecLocalModel">本地模型</label>
|
|
1235
|
-
<select id="mem-vec-local-preset" style="max-width:400px">
|
|
1236
|
-
<option value="Xenova/bge-small-zh-v1.5" data-i18n="vecLocalSmall">小(small,~100MB,速度优先)</option>
|
|
1237
|
-
<option value="Xenova/bge-base-zh-v1.5" selected data-i18n="vecLocalBase">中(base,~250MB,推荐)</option>
|
|
1238
|
-
<option value="Xenova/bge-large-zh-v1.5" data-i18n="vecLocalLarge">大(large,~500MB,精度优先)</option>
|
|
1239
|
-
<option value="__custom__" data-i18n="vecLocalCustom">自定义…</option>
|
|
1240
|
-
</select>
|
|
1241
|
-
<input type="text" id="mem-vec-local-model" placeholder="Xenova/your-model" style="max-width:400px;margin-top:6px;display:none" />
|
|
1242
|
-
<div class="muted" style="font-size:11px;margin-top:4px" data-i18n="vecLocalHint">三个预设都是 BGE 中文模型(智源研究院开源,HuggingFace 上 Xenova 提供 ONNX 量化版)。首次下载到 ~/.agim/cache/transformers</div>
|
|
1243
|
-
<div style="margin-top:8px">
|
|
1244
|
-
<button type="button" id="btn-mem-vec-download" class="btn" data-i18n="vecDownloadBtn">📥 下载模型(首次 5-10 min,建议 WiFi)</button>
|
|
1245
|
-
<span id="mem-vec-download-progress" class="muted" style="font-size:12px;margin-left:8px"></span>
|
|
1246
|
-
</div>
|
|
1247
|
-
</div>
|
|
1248
|
-
|
|
1249
|
-
<!-- OpenAI config (shown when backend === 'openai') -->
|
|
1250
|
-
<div id="mem-vec-openai-cfg" style="display:none;margin-top:10px;padding:10px;border:1px dashed var(--border,#d0d7de);border-radius:6px">
|
|
1251
|
-
<label>Base URL</label>
|
|
1252
|
-
<input type="text" id="mem-vec-openai-url" placeholder="https://api.openai.com/v1" style="max-width:400px" />
|
|
1253
|
-
<label style="margin-top:6px">Model</label>
|
|
1254
|
-
<input type="text" id="mem-vec-openai-model" placeholder="text-embedding-3-small" style="max-width:400px" />
|
|
1255
|
-
<label style="margin-top:6px" data-i18n="vecOpenaiKey">API Key</label>
|
|
1256
|
-
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1257
|
-
<input type="password" id="mem-vec-openai-key" placeholder="sk-..." style="max-width:400px;background:#f6f8fa;color:#57606a" autocomplete="off" readonly />
|
|
1258
|
-
<button type="button" id="btn-mem-vec-openai-reveal" class="btn" style="font-size:12px;padding:2px 8px" data-i18n="vecOpenaiReveal">🔓 显示并编辑</button>
|
|
1259
|
-
</div>
|
|
1260
|
-
<div class="muted" style="font-size:11px;margin-top:4px" data-i18n="vecOpenaiKeyHint">默认掩码显示;保存时如未点开「显示」,原值不会被覆盖。</div>
|
|
1261
|
-
<div style="margin-top:8px">
|
|
1262
|
-
<button type="button" id="btn-mem-vec-test" class="btn" data-i18n="vecTestBtn">🧪 测试连接(10s 超时)</button>
|
|
1263
|
-
<span id="mem-vec-test-result" class="muted" style="font-size:12px;margin-left:8px"></span>
|
|
1264
|
-
</div>
|
|
1265
|
-
</div>
|
|
1266
|
-
|
|
1267
|
-
<div class="actions" style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
|
|
1268
|
-
<button type="button" id="btn-mem-vec-save" class="btn btn-primary" data-i18n="vecSaveBtn">保存配置</button>
|
|
1269
|
-
<button type="button" id="btn-mem-vec-backfill" class="btn" data-i18n="vecBackfillBtn">🔄 回填全部</button>
|
|
1270
|
-
<button type="button" id="btn-mem-vec-clear" class="btn" data-i18n="vecClearBtn">🗑 清空向量索引</button>
|
|
1271
|
-
</div>
|
|
1272
|
-
<p class="muted" id="mem-vec-status-detail" style="margin-top:8px;font-size:12px"></p>
|
|
1273
|
-
</div>
|
|
1274
|
-
|
|
1275
|
-
<!-- 记忆画像 display + editor -->
|
|
1276
|
-
<div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px;margin-bottom:16px">
|
|
1277
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
1278
|
-
<strong data-i18n="memoryPersonaCard">记忆画像</strong>
|
|
1279
|
-
<span class="muted" id="mem-persona-meta" style="font-size:12px"></span>
|
|
1280
|
-
</div>
|
|
1281
|
-
<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>
|
|
1282
|
-
<div class="actions" style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap">
|
|
1283
|
-
<button type="button" id="btn-mem-persona-save" class="btn btn-primary" data-i18n="memoryPersonaSave">保存记忆画像</button>
|
|
1284
|
-
<button type="button" id="btn-mem-persona-delete" class="btn" data-i18n="memoryPersonaDelete">删除记忆画像</button>
|
|
1285
|
-
<button type="button" id="btn-mem-persona-consolidate" class="btn" title="立刻跑一次 consolidation 重建画像,不等 24h 周期" data-i18n="memoryPersonaConsolidate">🔁 立刻重建画像</button>
|
|
1286
|
-
</div>
|
|
1287
|
-
<p class="muted" id="mem-persona-status" style="margin-top:6px;font-size:12px"></p>
|
|
1288
|
-
</div>
|
|
1289
|
-
|
|
1290
|
-
<!-- Facts table + filters -->
|
|
1291
|
-
<div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px;margin-bottom:16px">
|
|
1292
|
-
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px">
|
|
1293
|
-
<strong><span data-i18n="memoryFactsCard">Facts</span> <span id="mem-facts-total" class="muted" style="font-weight:400;font-size:12px"></span></strong>
|
|
1294
|
-
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
|
1295
|
-
<input type="text" id="mem-facts-query" data-i18n-attr="placeholder:memoryFactsSearch" placeholder="搜索(FTS5)" style="width:200px;font-size:12px" />
|
|
1296
|
-
<select id="mem-facts-category" style="font-size:12px">
|
|
1297
|
-
<option value="" data-i18n="memoryFactsCatAll">所有类型</option>
|
|
1298
|
-
<option value="fact">fact</option>
|
|
1299
|
-
<option value="preference">preference</option>
|
|
1300
|
-
<option value="goal">goal</option>
|
|
1301
|
-
<option value="history">history</option>
|
|
1302
|
-
<option value="profile">profile</option>
|
|
1303
|
-
</select>
|
|
1304
|
-
<button type="button" id="btn-mem-facts-refresh" style="font-size:12px" data-i18n="memoryFactsQuery">查询</button>
|
|
1305
|
-
</div>
|
|
1306
|
-
</div>
|
|
1307
|
-
<div id="mem-facts-list" data-i18n="memoryFactsSelectUser">(请选择用户)</div>
|
|
1308
|
-
<div style="display:flex;justify-content:space-between;margin-top:8px;font-size:12px;align-items:center">
|
|
1309
|
-
<div class="muted">
|
|
1310
|
-
<button type="button" id="btn-mem-facts-prev" data-i18n="memoryFactsPrev">← 上一页</button>
|
|
1311
|
-
<span id="mem-facts-pagelabel">—</span>
|
|
1312
|
-
<button type="button" id="btn-mem-facts-next" data-i18n="memoryFactsNext">下一页 →</button>
|
|
1313
|
-
</div>
|
|
1314
|
-
<div>
|
|
1315
|
-
<button type="button" id="btn-mem-facts-clear-lowconf" class="btn" title="删除 confidence ≤ 0.4 的所有事实" data-i18n="memoryFactsClearLowConf">🗑 清低置信度</button>
|
|
1316
|
-
<button type="button" id="btn-mem-facts-clear-all" class="btn" style="color:#cf222e" title="慎用:清空此用户的所有事实" data-i18n="memoryFactsClearAll">⚠️ 清空所有</button>
|
|
1317
|
-
</div>
|
|
1318
|
-
</div>
|
|
1319
|
-
<p class="muted" id="mem-facts-status" style="margin-top:6px;font-size:12px"></p>
|
|
1320
|
-
</div>
|
|
1321
|
-
</section>
|
|
1322
|
-
|
|
1323
|
-
<!-- v1.2.3 — Skills browser: read-only inventory of installed claude /
|
|
1324
|
-
opencode skills + a remote "hot on skillhub.cn" panel. -->
|
|
1325
|
-
<section id="skills-pane" hidden>
|
|
1326
|
-
<h2 style="margin-top:0" data-i18n="skillsH2">🛠 已安装的 Skills</h2>
|
|
1327
|
-
<p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px" data-i18n="skillsIntro">
|
|
1328
|
-
本机已安装的 claude / opencode skills 索引。点击行查看完整描述。
|
|
1329
|
-
要安装新 skill 请在 IM 里发 <code>/find-skills</code>,或浏览下方 skillhub.cn 热门榜。
|
|
1330
|
-
</p>
|
|
1331
|
-
<div class="toolbar" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
|
|
1332
|
-
<input type="text" id="skills-search" placeholder="搜索(名称 / 描述)" data-i18n-attr="placeholder:skillsSearchPh" style="width:240px;font-size:13px" />
|
|
1333
|
-
<select id="skills-cat-filter" style="font-size:13px;min-width:160px">
|
|
1334
|
-
<option value="" data-i18n="skillsCatAll">所有类别</option>
|
|
1335
|
-
</select>
|
|
1336
|
-
<button type="button" id="btn-skills-refresh" data-i18n="refresh">刷新</button>
|
|
1337
|
-
<span id="skills-stats" class="muted" style="font-size:12px;margin-left:8px"></span>
|
|
1338
|
-
</div>
|
|
1339
|
-
<div id="skills-list">…</div>
|
|
1340
|
-
|
|
1341
|
-
<!-- Remote: skillhub.cn hot showcase -->
|
|
1342
|
-
<div style="margin-top:32px;border-top:1px solid var(--border,#d0d7de);padding-top:16px">
|
|
1343
|
-
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
|
1344
|
-
<h2 style="margin:0">🔥 <span data-i18n="skillsRemoteH2">skillhub.cn 热门</span>
|
|
1345
|
-
<span id="skills-remote-meta" class="muted" style="font-weight:400;font-size:12px"></span>
|
|
1346
|
-
</h2>
|
|
1347
|
-
<div>
|
|
1348
|
-
<a href="https://skillhub.cn" target="_blank" rel="noopener" class="btn" data-i18n="skillsRemoteOpen">前往 skillhub.cn ↗</a>
|
|
1349
|
-
<button type="button" id="btn-skills-remote-refresh" class="btn" data-i18n="refresh" style="margin-left:6px">刷新</button>
|
|
1350
|
-
</div>
|
|
1351
|
-
</div>
|
|
1352
|
-
<p class="muted" style="font-size:12px;margin:0 0 12px" data-i18n="skillsRemoteIntro">
|
|
1353
|
-
实时拉取 skillhub.cn top 50 热门下载(5 分钟缓存)。点击任意条目查看描述。
|
|
1354
|
-
安装提示见弹窗末端 — 用 skillhub CLI 或在 IM 里告诉 agent 通过 skillhub 安装。
|
|
1355
|
-
</p>
|
|
1356
|
-
<div id="skills-remote-list"><p class="muted">…</p></div>
|
|
1357
|
-
</div>
|
|
1358
|
-
</section>
|
|
1359
|
-
</main>
|
|
1360
|
-
|
|
1361
|
-
<div class="modal-bg" id="modal-bg">
|
|
1362
|
-
<div class="modal" id="modal"></div>
|
|
1363
|
-
</div>
|
|
1364
|
-
|
|
1365
|
-
<script>
|
|
1366
|
-
const T = window.__t;
|
|
1367
|
-
|
|
1368
|
-
// Theme toggle (light / dark / system). _app.js applied the theme
|
|
1369
|
-
// synchronously in <head>; here we wire the button so clicks cycle
|
|
1370
|
-
// and the label re-renders.
|
|
1371
|
-
if (window.imhub) imhub.theme.bindToggle(document.getElementById('theme-toggle'));
|
|
1372
|
-
|
|
1373
|
-
// i18n string fills
|
|
1374
|
-
document.title = T.title;
|
|
1375
|
-
document.getElementById('page-title').textContent = T.h1;
|
|
1376
|
-
document.getElementById('lnk-chat').textContent = T.backToChat;
|
|
1377
|
-
document.getElementById('lnk-reminders').textContent = T.toReminders;
|
|
1378
|
-
document.getElementById('lnk-memos').textContent = T.toMemos;
|
|
1379
|
-
document.getElementById('lnk-settings').textContent = T.toSettings;
|
|
1380
|
-
document.getElementById('tab-jobs').textContent = T.tabsJobs;
|
|
1381
|
-
document.getElementById('tab-background').textContent = T.tabsBackground;
|
|
1382
|
-
document.getElementById('tab-subtasks').textContent = T.tabsSubtasks;
|
|
1383
|
-
document.getElementById('tab-schedules').textContent = T.tabsSchedules;
|
|
1384
|
-
document.getElementById('tab-audit').textContent = T.tabsAudit;
|
|
1385
|
-
document.getElementById('tab-approvals').textContent = T.tabsApprovals;
|
|
1386
|
-
document.getElementById('tab-health').textContent = T.tabsHealth;
|
|
1387
|
-
document.getElementById('tab-files').textContent = T.tabsFiles;
|
|
1388
|
-
document.getElementById('tab-outbox').textContent = T.tabsOutbox;
|
|
1389
|
-
document.getElementById('tab-a2a').textContent = T.tabsA2A;
|
|
1390
|
-
if (T.tabsCost) document.getElementById('tab-cost').textContent = T.tabsCost;
|
|
1391
|
-
if (T.tabsMemory) document.getElementById('tab-memory').textContent = T.tabsMemory;
|
|
1392
|
-
if (T.tabsSkills) document.getElementById('tab-skills').textContent = T.tabsSkills;
|
|
1393
|
-
document.getElementById('hdr-outbox').textContent = T.tabsOutbox;
|
|
1394
|
-
document.getElementById('hdr-a2a').textContent = T.tabsA2A;
|
|
1395
|
-
|
|
1396
|
-
// data-i18n / data-i18n-attr — sweep static markers so placeholders +
|
|
1397
|
-
// option labels pick up the active language. Same shape as _app.js's
|
|
1398
|
-
// applyI18n but inline here (tasks.html runs before _app.js applies
|
|
1399
|
-
// its sweep to the rest of the doc).
|
|
1400
|
-
(function applyStaticI18n() {
|
|
1401
|
-
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
|
1402
|
-
const k = el.getAttribute('data-i18n');
|
|
1403
|
-
if (k && T[k] != null) el.textContent = T[k];
|
|
1404
|
-
});
|
|
1405
|
-
document.querySelectorAll('[data-i18n-attr]').forEach((el) => {
|
|
1406
|
-
const spec = el.getAttribute('data-i18n-attr') || '';
|
|
1407
|
-
for (const pair of spec.split(';')) {
|
|
1408
|
-
const [attr, key] = pair.split(':').map(s => s && s.trim());
|
|
1409
|
-
if (attr && key && T[key] != null) el.setAttribute(attr, T[key]);
|
|
1410
|
-
}
|
|
1411
|
-
});
|
|
1412
|
-
})();
|
|
1413
|
-
|
|
1414
|
-
// Help-tooltip system — every (?) button opens a centered modal whose
|
|
1415
|
-
// body comes from T.help[<key>]. Reuses the existing modal-bg overlay
|
|
1416
|
-
// so a single Escape press dismisses everything.
|
|
1417
|
-
document.addEventListener('click', (ev) => {
|
|
1418
|
-
const btn = ev.target.closest && ev.target.closest('.help-btn');
|
|
1419
|
-
if (!btn) return;
|
|
1420
|
-
ev.preventDefault();
|
|
1421
|
-
const key = btn.getAttribute('data-help');
|
|
1422
|
-
const def = (T.help && T.help[key]) || null;
|
|
1423
|
-
if (!def) { console.warn('no help def for', key); return; }
|
|
1424
|
-
const m = document.getElementById('modal');
|
|
1425
|
-
m.classList.add('help-modal');
|
|
1426
|
-
m.innerHTML = `
|
|
1427
|
-
<h2>${def.title}</h2>
|
|
1428
|
-
<p>${def.body}</p>
|
|
1429
|
-
<div class="modal-actions">
|
|
1430
|
-
<button type="button" id="help-modal-close">${T.helpClose}</button>
|
|
1431
|
-
</div>
|
|
1432
|
-
`;
|
|
1433
|
-
document.getElementById('modal-bg').classList.add('show');
|
|
1434
|
-
document.getElementById('help-modal-close').onclick = () => {
|
|
1435
|
-
document.getElementById('modal-bg').classList.remove('show');
|
|
1436
|
-
m.classList.remove('help-modal');
|
|
1437
|
-
};
|
|
1438
|
-
});
|
|
1439
|
-
// Language switcher: persist + reload so the IIFE in <head> reads the
|
|
1440
|
-
// new preference and rebuilds T.
|
|
1441
|
-
(function setupLangSwitcher() {
|
|
1442
|
-
const sel = document.getElementById('langSelect');
|
|
1443
|
-
if (!sel) return;
|
|
1444
|
-
sel.value = window.__lang;
|
|
1445
|
-
sel.addEventListener('change', () => {
|
|
1446
|
-
const newLang = sel.value;
|
|
1447
|
-
if (newLang === window.__lang) return;
|
|
1448
|
-
localStorage.setItem('im-hub-lang', newLang);
|
|
1449
|
-
window.location.reload();
|
|
1450
|
-
});
|
|
1451
|
-
})();
|
|
1452
|
-
document.getElementById('btn-bg-refresh').textContent = T.refresh;
|
|
1453
|
-
document.getElementById('btn-sub-refresh').textContent = T.refresh;
|
|
1454
|
-
document.getElementById('btn-approvals-refresh').textContent = T.refresh;
|
|
1455
|
-
document.getElementById('btn-health-refresh').textContent = T.refresh;
|
|
1456
|
-
document.getElementById('lbl-bg-auto').textContent = T.autoRefresh;
|
|
1457
|
-
document.getElementById('lbl-approvals-auto').textContent = T.autoRefresh;
|
|
1458
|
-
document.getElementById('lbl-health-auto').textContent = T.autoRefresh;
|
|
1459
|
-
document.getElementById('btn-refresh').textContent = T.refresh;
|
|
1460
|
-
document.getElementById('btn-new').textContent = T.newJob;
|
|
1461
|
-
document.getElementById('lbl-auto').textContent = T.autoRefresh;
|
|
1462
|
-
document.getElementById('lbl-files-agent').textContent = T.filesAgent;
|
|
1463
|
-
document.getElementById('btn-files-up').textContent = T.filesUp;
|
|
1464
|
-
document.getElementById('btn-files-refresh').textContent = T.refresh;
|
|
1465
|
-
document.getElementById('btn-batch-run').textContent = T.jobsBatchRun;
|
|
1466
|
-
document.getElementById('btn-batch-cancel').textContent = T.jobsBatchCancel;
|
|
1467
|
-
{
|
|
1468
|
-
const fs = document.getElementById('filter-status');
|
|
1469
|
-
fs.options[0].textContent = T.filterAll;
|
|
1470
|
-
fs.options[1].textContent = T.filterPending;
|
|
1471
|
-
fs.options[2].textContent = T.filterRunning;
|
|
1472
|
-
fs.options[3].textContent = T.filterCompleted;
|
|
1473
|
-
fs.options[4].textContent = T.filterFailed;
|
|
1474
|
-
fs.options[5].textContent = T.filterCancelled;
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
// Tab switching
|
|
1478
|
-
document.querySelectorAll('.tab').forEach(t => {
|
|
1479
|
-
t.onclick = () => {
|
|
1480
|
-
document.querySelectorAll('.tab').forEach(x => { x.classList.toggle('active', x === t); });
|
|
1481
|
-
const tab = t.dataset.tab;
|
|
1482
|
-
document.getElementById('jobs-pane').hidden = tab !== 'jobs';
|
|
1483
|
-
document.getElementById('background-pane').hidden = tab !== 'background';
|
|
1484
|
-
document.getElementById('subtasks-pane').hidden = tab !== 'subtasks';
|
|
1485
|
-
document.getElementById('schedules-pane').hidden = tab !== 'schedules';
|
|
1486
|
-
document.getElementById('audit-pane').hidden = tab !== 'audit';
|
|
1487
|
-
document.getElementById('approvals-pane').hidden = tab !== 'approvals';
|
|
1488
|
-
document.getElementById('health-pane').hidden = tab !== 'health';
|
|
1489
|
-
document.getElementById('files-pane').hidden = tab !== 'files';
|
|
1490
|
-
document.getElementById('outbox-pane').hidden = tab !== 'outbox';
|
|
1491
|
-
document.getElementById('a2a-pane').hidden = tab !== 'a2a';
|
|
1492
|
-
document.getElementById('cost-pane').hidden = tab !== 'cost';
|
|
1493
|
-
document.getElementById('memory-pane').hidden = tab !== 'memory';
|
|
1494
|
-
document.getElementById('skills-pane').hidden = tab !== 'skills';
|
|
1495
|
-
// Lazy-load on first activation; auto-refresh hooks below kick in too.
|
|
1496
|
-
if (tab === 'schedules') loadSchedules();
|
|
1497
|
-
if (tab === 'background') { ensureBgRootsLoaded().then(loadBgjobs); }
|
|
1498
|
-
if (tab === 'subtasks') loadSubtasks();
|
|
1499
|
-
if (tab === 'audit') loadAudit();
|
|
1500
|
-
if (tab === 'approvals') loadApprovals();
|
|
1501
|
-
if (tab === 'health') loadHealth();
|
|
1502
|
-
if (tab === 'files') ensureFilesAgentLoaded().then(() => loadFiles(filesPath));
|
|
1503
|
-
if (tab === 'outbox') loadOutbox();
|
|
1504
|
-
if (tab === 'a2a') loadA2A();
|
|
1505
|
-
if (tab === 'cost') loadCost();
|
|
1506
|
-
if (tab === 'memory') {
|
|
1507
|
-
loadMemoryUsers();
|
|
1508
|
-
// P1-9: only load vector card + poll status when memory is
|
|
1509
|
-
// actually enabled. Otherwise the dashboard would burn requests
|
|
1510
|
-
// and confuse users on a stock install where memory is off.
|
|
1511
|
-
checkMemoryEnabledThen(() => { loadMemVecConfig(); loadMemVecStatus(); });
|
|
1512
|
-
}
|
|
1513
|
-
if (tab !== 'memory') stopMemVecPolling();
|
|
1514
|
-
if (tab === 'skills') loadSkills();
|
|
1515
|
-
// Pause/resume auto-refresh so hidden tabs don't poll.
|
|
1516
|
-
setupBgAutoRefresh();
|
|
1517
|
-
setupApprovalsAutoRefresh();
|
|
1518
|
-
setupHealthAutoRefresh();
|
|
1519
|
-
};
|
|
1520
|
-
});
|
|
1521
|
-
|
|
1522
|
-
// API helper
|
|
1523
|
-
async function api(path, init) {
|
|
1524
|
-
const headers = { 'Content-Type': 'application/json', ...(init?.headers) };
|
|
1525
|
-
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' });
|
|
1526
|
-
if (!res.ok) {
|
|
1527
|
-
// v1.5 — attach status + try to surface server-provided error so
|
|
1528
|
-
// callers can branch on `err.status === 404` etc. Falls back to a
|
|
1529
|
-
// plain statusText when the body isn't JSON.
|
|
1530
|
-
let msg = `${res.status} ${res.statusText}`;
|
|
1531
|
-
try {
|
|
1532
|
-
const j = await res.json();
|
|
1533
|
-
if (j && j.error) msg = `${res.status} ${j.error}`;
|
|
1534
|
-
} catch { /* not JSON; keep statusText */ }
|
|
1535
|
-
const err = new Error(msg);
|
|
1536
|
-
err.status = res.status;
|
|
1537
|
-
throw err;
|
|
1538
|
-
}
|
|
1539
|
-
return res.json();
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
function fmtTime(s) {
|
|
1543
|
-
if (!s) return '-';
|
|
1544
|
-
// SQLite datetime('now') produces "2026-05-15 14:02:50" — space-separated
|
|
1545
|
-
// and no zone. Safari + iOS WebView refuse that string (Invalid Date);
|
|
1546
|
-
// Chrome happens to parse it. Normalize to RFC3339 by swapping space for
|
|
1547
|
-
// T and forcing UTC. Also accept already-ISO strings unchanged.
|
|
1548
|
-
let iso = String(s).trim();
|
|
1549
|
-
if (iso && !iso.includes('T')) iso = iso.replace(' ', 'T');
|
|
1550
|
-
if (iso && !/[Z+]/.test(iso.slice(-6))) iso = iso + 'Z';
|
|
1551
|
-
const d = new Date(iso);
|
|
1552
|
-
if (Number.isNaN(d.getTime())) return s; // fall back to raw if still bad
|
|
1553
|
-
return d.toLocaleString();
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
function statusPill(status) {
|
|
1557
|
-
return `<span class="pill ${esc(status)}">${T[`filter${status.charAt(0).toUpperCase()}${status.slice(1)}`] || esc(status)}</span>`;
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
function renderStats(stats) {
|
|
1561
|
-
const el = document.getElementById('stats');
|
|
1562
|
-
const fields = [
|
|
1563
|
-
['Total', stats.total],
|
|
1564
|
-
['Pending', stats.pending],
|
|
1565
|
-
['Running', stats.running],
|
|
1566
|
-
['Completed', stats.completed],
|
|
1567
|
-
['Failed', stats.failed],
|
|
1568
|
-
];
|
|
1569
|
-
el.innerHTML = fields.map(([k, v]) =>
|
|
1570
|
-
`<div class="stat"><div class="stat-label">${T[`stats${k}`]}</div><div class="stat-val">${v}</div></div>`
|
|
1571
|
-
).join('');
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
// Selection state for batch ops. Lives outside renderJobs so a refresh
|
|
1575
|
-
// after a successful batch run/cancel can reconcile against still-visible
|
|
1576
|
-
// rows without forgetting the user's prior selection.
|
|
1577
|
-
const jobSelection = new Set();
|
|
1578
|
-
|
|
1579
|
-
function renderJobs(jobs) {
|
|
1580
|
-
const el = document.getElementById('jobs-list');
|
|
1581
|
-
if (!jobs.length) {
|
|
1582
|
-
el.innerHTML = `<div class="empty">${T.empty}</div>`;
|
|
1583
|
-
updateBatchToolbar();
|
|
1584
|
-
return;
|
|
1585
|
-
}
|
|
1586
|
-
// Drop ids that disappeared since last render so the toolbar count
|
|
1587
|
-
// stays accurate even after the server sweeps completed jobs.
|
|
1588
|
-
const visible = new Set(jobs.map(j => j.id));
|
|
1589
|
-
for (const id of Array.from(jobSelection)) {
|
|
1590
|
-
if (!visible.has(id)) jobSelection.delete(id);
|
|
1591
|
-
}
|
|
1592
|
-
const allSelected = jobs.length > 0 && jobs.every(j => jobSelection.has(j.id));
|
|
1593
|
-
el.innerHTML = `
|
|
1594
|
-
<table>
|
|
1595
|
-
<thead>
|
|
1596
|
-
<tr>
|
|
1597
|
-
<th><input type="checkbox" id="job-select-all" ${allSelected ? 'checked' : ''} title="${T.jobsSelectAll}"></th>
|
|
1598
|
-
<th>#</th>
|
|
1599
|
-
<th>${T.kindCol} <button type="button" class="help-btn" data-help="inline" title="?">?</button></th>
|
|
1600
|
-
<th>${T.agent}</th>
|
|
1601
|
-
<th>${T.parentCol} <button type="button" class="help-btn" data-help="parent" title="?">?</button></th>
|
|
1602
|
-
<th>${T.depthCol} <button type="button" class="help-btn" data-help="callDepth" title="?">?</button></th>
|
|
1603
|
-
<th>${T.prompt}</th>
|
|
1604
|
-
<th>${T.status}</th>
|
|
1605
|
-
<th>${T.created}</th>
|
|
1606
|
-
<th>${T.actions}</th>
|
|
1607
|
-
</tr>
|
|
1608
|
-
</thead>
|
|
1609
|
-
<tbody>
|
|
1610
|
-
${jobs.map(j => {
|
|
1611
|
-
const kind = j.kind || 'job';
|
|
1612
|
-
const kindPill = `<span class="pill kind-${kind}">${esc(kind)}</span>`;
|
|
1613
|
-
const parentLink = (j.parent_id != null && j.parent_id > 0)
|
|
1614
|
-
? `<a href="#" data-act="parent" data-pid="${j.parent_id}" title="View parent">#${j.parent_id}</a>` : '';
|
|
1615
|
-
const depth = (typeof j.call_depth === 'number' && j.call_depth > 0)
|
|
1616
|
-
? `<span style="font-size:11px;opacity:0.8">↳${j.call_depth}</span>` : '';
|
|
1617
|
-
return `
|
|
1618
|
-
<tr data-id="${j.id}">
|
|
1619
|
-
<td><input type="checkbox" data-sel="${j.id}" ${jobSelection.has(j.id) ? 'checked' : ''}></td>
|
|
1620
|
-
<td>#${j.id}</td>
|
|
1621
|
-
<td>${kindPill}</td>
|
|
1622
|
-
<td><code>${esc(j.agent)}</code></td>
|
|
1623
|
-
<td>${parentLink}</td>
|
|
1624
|
-
<td>${depth}</td>
|
|
1625
|
-
<td class="truncate">${esc(j.prompt)}</td>
|
|
1626
|
-
<td>${statusPill(j.status)}</td>
|
|
1627
|
-
<td>${fmtTime(j.created_at)}</td>
|
|
1628
|
-
<td class="row-actions">
|
|
1629
|
-
<button type="button" data-act="view">${T.view}</button>
|
|
1630
|
-
${j.status === 'pending' || j.status === 'completed' || j.status === 'failed'
|
|
1631
|
-
? `<button type="button" data-act="run">${T.run}</button>` : ''}
|
|
1632
|
-
${j.status === 'pending' || j.status === 'running'
|
|
1633
|
-
? `<button type="button" data-act="cancel" class="danger">${T.cancel}</button>` : ''}
|
|
1634
|
-
</td>
|
|
1635
|
-
</tr>`;
|
|
1636
|
-
}).join('')}
|
|
1637
|
-
</tbody>
|
|
1638
|
-
</table>
|
|
1639
|
-
`;
|
|
1640
|
-
el.querySelectorAll('tr[data-id]').forEach(tr => {
|
|
1641
|
-
const id = parseInt(tr.dataset.id, 10);
|
|
1642
|
-
tr.querySelector('[data-act="view"]')?.addEventListener('click', () => showJob(id));
|
|
1643
|
-
tr.querySelector('[data-act="run"]')?.addEventListener('click', () => runJob(id));
|
|
1644
|
-
tr.querySelector('[data-act="cancel"]')?.addEventListener('click', () => cancelJob(id));
|
|
1645
|
-
tr.querySelector('[data-act="parent"]')?.addEventListener('click', (e) => {
|
|
1646
|
-
e.preventDefault();
|
|
1647
|
-
const pid = parseInt(e.currentTarget.getAttribute('data-pid'), 10);
|
|
1648
|
-
if (pid > 0) showJob(pid);
|
|
1649
|
-
});
|
|
1650
|
-
tr.querySelector('[data-sel]')?.addEventListener('change', (e) => {
|
|
1651
|
-
if (e.target.checked) jobSelection.add(id);
|
|
1652
|
-
else jobSelection.delete(id);
|
|
1653
|
-
updateBatchToolbar();
|
|
1654
|
-
});
|
|
1655
|
-
});
|
|
1656
|
-
const sa = el.querySelector('#job-select-all');
|
|
1657
|
-
if (sa) {
|
|
1658
|
-
sa.addEventListener('change', () => {
|
|
1659
|
-
if (sa.checked) for (const j of jobs) jobSelection.add(j.id);
|
|
1660
|
-
else for (const j of jobs) jobSelection.delete(j.id);
|
|
1661
|
-
renderJobs(jobs);
|
|
1662
|
-
});
|
|
1663
|
-
}
|
|
1664
|
-
updateBatchToolbar();
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
function updateBatchToolbar() {
|
|
1668
|
-
const bar = document.getElementById('batch-toolbar');
|
|
1669
|
-
const summary = document.getElementById('batch-summary');
|
|
1670
|
-
const n = jobSelection.size;
|
|
1671
|
-
bar.style.display = n > 0 ? '' : 'none';
|
|
1672
|
-
summary.textContent = n > 0 ? `${n} selected` : '';
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
async function batchRun() {
|
|
1676
|
-
const ids = Array.from(jobSelection);
|
|
1677
|
-
if (!ids.length) { alert(T.jobsBatchEmpty); return; }
|
|
1678
|
-
const r = await api('/api/jobs/batch-run', { method: 'POST', body: JSON.stringify({ ids }) });
|
|
1679
|
-
const ok = r.results.filter(x => x.ok).length;
|
|
1680
|
-
const fail = r.results.length - ok;
|
|
1681
|
-
alert(T.jobsBatchResult.replace('{ok}', ok).replace('{fail}', fail));
|
|
1682
|
-
jobSelection.clear();
|
|
1683
|
-
loadJobs();
|
|
1684
|
-
}
|
|
1685
|
-
async function batchCancel() {
|
|
1686
|
-
const ids = Array.from(jobSelection);
|
|
1687
|
-
if (!ids.length) { alert(T.jobsBatchEmpty); return; }
|
|
1688
|
-
const r = await api('/api/jobs/batch-cancel', { method: 'POST', body: JSON.stringify({ ids }) });
|
|
1689
|
-
const ok = r.results.filter(x => x.ok).length;
|
|
1690
|
-
const fail = r.results.length - ok;
|
|
1691
|
-
alert(T.jobsBatchResult.replace('{ok}', ok).replace('{fail}', fail));
|
|
1692
|
-
jobSelection.clear();
|
|
1693
|
-
loadJobs();
|
|
1694
|
-
}
|
|
1695
|
-
document.getElementById('btn-batch-run').onclick = batchRun;
|
|
1696
|
-
document.getElementById('btn-batch-cancel').onclick = batchCancel;
|
|
1697
|
-
|
|
1698
|
-
function esc(s) {
|
|
1699
|
-
return String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
async function loadJobs() {
|
|
1703
|
-
const status = document.getElementById('filter-status').value;
|
|
1704
|
-
const agent = document.getElementById('jobs-agent-filter').value;
|
|
1705
|
-
const kind = document.getElementById('filter-kind')?.value || '';
|
|
1706
|
-
const qs = new URLSearchParams({ limit: '100' });
|
|
1707
|
-
if (status) qs.set('status', status);
|
|
1708
|
-
if (agent) qs.set('agent', agent);
|
|
1709
|
-
if (kind) qs.set('kind', kind);
|
|
1710
|
-
try {
|
|
1711
|
-
const { jobs, stats } = await api(`/api/jobs?${qs.toString()}`);
|
|
1712
|
-
renderStats(stats);
|
|
1713
|
-
renderJobs(jobs);
|
|
1714
|
-
} catch (e) {
|
|
1715
|
-
document.getElementById('jobs-list').innerHTML =
|
|
1716
|
-
`<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
document.getElementById('filter-kind')?.addEventListener('change', loadJobs);
|
|
1720
|
-
|
|
1721
|
-
async function showJob(id) {
|
|
1722
|
-
const { job } = await api(`/api/jobs/${id}`);
|
|
1723
|
-
// Fetch artifacts in parallel — non-fatal if 404 / empty.
|
|
1724
|
-
let arts = { inputs: [], outputs: [], totalBytes: 0 };
|
|
1725
|
-
try {
|
|
1726
|
-
const r = await fetch(`/api/artifacts/${id}`);
|
|
1727
|
-
if (r.ok) arts = await r.json();
|
|
1728
|
-
} catch { /* ignore — modal still renders */ }
|
|
1729
|
-
const m = document.getElementById('modal');
|
|
1730
|
-
const kind = job.kind || 'job';
|
|
1731
|
-
const kindPill = `<span class="pill kind-${kind}">${esc(kind)}</span>`;
|
|
1732
|
-
const parentLine = (job.parent_id != null && job.parent_id > 0)
|
|
1733
|
-
? `<p><strong>${T.modalParent}:</strong> <a href="#" data-act="modal-parent" data-pid="${job.parent_id}">#${job.parent_id}</a> · <strong>${T.modalDepth}:</strong> ${job.call_depth || 0}</p>` : '';
|
|
1734
|
-
const threadLine = job.thread_key
|
|
1735
|
-
? `<p><strong>${T.modalThread}:</strong> <code>${esc(job.thread_key)}</code></p>` : '';
|
|
1736
|
-
const replacedLine = (job.replaced_by != null && job.replaced_by > 0)
|
|
1737
|
-
? `<p><strong>${T.modalReplaced}:</strong> <a href="#" data-act="modal-parent" data-pid="${job.replaced_by}">#${job.replaced_by}</a></p>` : '';
|
|
1738
|
-
const outboxLine = (job.last_outbox_id != null && job.last_outbox_id > 0)
|
|
1739
|
-
? `<p><strong>${T.modalLastOutbox}:</strong> #${job.last_outbox_id}</p>` : '';
|
|
1740
|
-
const fmt = (n) => n < 1024 ? `${n} B` : n < 1024*1024 ? `${(n/1024).toFixed(1)} KB` : `${(n/1024/1024).toFixed(1)} MB`;
|
|
1741
|
-
const artsBlock = (arts.outputs.length > 0 || arts.inputs.length > 0) ? `
|
|
1742
|
-
<h3>📎 ${T.modalArtifacts} (${fmt(arts.totalBytes || 0)}) <button type="button" class="help-btn" data-help="artifacts" title="?">?</button></h3>
|
|
1743
|
-
${arts.inputs.length > 0 ? `<p style="font-size:13px;color:var(--muted)">${T.modalArtifactsInputs}</p><ul>${arts.inputs.map(f => `<li><a href="/api/artifacts/${id}/file/${encodeURIComponent(f.name)}" target="_blank">${esc(f.name)}</a> (${fmt(f.bytes)})</li>`).join('')}</ul>` : ''}
|
|
1744
|
-
${arts.outputs.length > 0 ? `<p style="font-size:13px;color:var(--muted)">${T.modalArtifactsOutputs}</p><ul>${arts.outputs.map(f => `<li><a href="/api/artifacts/${id}/file/${encodeURIComponent(f.name)}" target="_blank">${esc(f.name)}</a> (${fmt(f.bytes)})</li>`).join('')}</ul>` : ''}
|
|
1745
|
-
` : '';
|
|
1746
|
-
m.innerHTML = `
|
|
1747
|
-
<h2>${T.details} #${job.id} ${kindPill}</h2>
|
|
1748
|
-
<p><strong>${T.agent}:</strong> <code>${esc(job.agent)}</code> · <strong>${T.status}:</strong> ${statusPill(job.status)}</p>
|
|
1749
|
-
${parentLine}
|
|
1750
|
-
${threadLine}
|
|
1751
|
-
${replacedLine}
|
|
1752
|
-
${outboxLine}
|
|
1753
|
-
<p><strong>${T.created}:</strong> ${fmtTime(job.created_at)}${job.completed_at ? ` · <strong>${T.completed}:</strong> ${fmtTime(job.completed_at)}` : ''}${job.delivered_at ? ` · <strong>${T.modalDelivered}:</strong> ${fmtTime(job.delivered_at)}` : ''}</p>
|
|
1754
|
-
<h3>${T.prompt}</h3>
|
|
1755
|
-
<pre>${esc(job.prompt)}</pre>
|
|
1756
|
-
${job.result ? `<h3>${T.result}</h3><pre>${esc(job.result)}</pre>` : ''}
|
|
1757
|
-
${job.error ? `<h3>${T.error}</h3><pre>${esc(job.error)}</pre>` : ''}
|
|
1758
|
-
${artsBlock}
|
|
1759
|
-
<div class="modal-actions">
|
|
1760
|
-
<button type="button" id="modal-close">${T.close}</button>
|
|
1761
|
-
</div>
|
|
1762
|
-
`;
|
|
1763
|
-
document.getElementById('modal-bg').classList.add('show');
|
|
1764
|
-
document.getElementById('modal-close').onclick = () =>
|
|
1765
|
-
document.getElementById('modal-bg').classList.remove('show');
|
|
1766
|
-
m.querySelectorAll('[data-act="modal-parent"]').forEach((a) => {
|
|
1767
|
-
a.addEventListener('click', (e) => {
|
|
1768
|
-
e.preventDefault();
|
|
1769
|
-
const pid = parseInt(e.currentTarget.getAttribute('data-pid'), 10);
|
|
1770
|
-
if (pid > 0) showJob(pid);
|
|
1771
|
-
});
|
|
1772
|
-
});
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
async function runJob(id) {
|
|
1776
|
-
await api(`/api/jobs/${id}/run`, { method: 'POST' });
|
|
1777
|
-
loadJobs();
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
async function cancelJob(id) {
|
|
1781
|
-
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
|
1782
|
-
loadJobs();
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
// New job modal
|
|
1786
|
-
document.getElementById('btn-new').onclick = async () => {
|
|
1787
|
-
const agentsRes = await fetch('/api/agents/status', {
|
|
1788
|
-
credentials: 'same-origin',
|
|
1789
|
-
}).then(r => r.json());
|
|
1790
|
-
const agents = Object.keys(agentsRes);
|
|
1791
|
-
|
|
1792
|
-
const m = document.getElementById('modal');
|
|
1793
|
-
m.innerHTML = `
|
|
1794
|
-
<h2>${T.newJob}</h2>
|
|
1795
|
-
<label>${T.agentCreate}<select id="ji-agent">
|
|
1796
|
-
${agents.map(a => `<option value="${esc(a)}">${esc(a)}</option>`).join('')}
|
|
1797
|
-
</select></label>
|
|
1798
|
-
<label>${T.promptCreate}<textarea id="ji-prompt"></textarea></label>
|
|
1799
|
-
<div class="modal-actions">
|
|
1800
|
-
<button type="button" id="modal-close">${T.close}</button>
|
|
1801
|
-
<button type="button" class="primary" id="modal-create" style="background:var(--primary);color:#fff;border-color:var(--primary);">${T.newJob}</button>
|
|
1802
|
-
</div>
|
|
1803
|
-
`;
|
|
1804
|
-
document.getElementById('modal-bg').classList.add('show');
|
|
1805
|
-
document.getElementById('modal-close').onclick = () =>
|
|
1806
|
-
document.getElementById('modal-bg').classList.remove('show');
|
|
1807
|
-
document.getElementById('modal-create').onclick = async () => {
|
|
1808
|
-
const agent = document.getElementById('ji-agent').value;
|
|
1809
|
-
const prompt = document.getElementById('ji-prompt').value.trim();
|
|
1810
|
-
if (!prompt) return;
|
|
1811
|
-
await api('/api/jobs', { method: 'POST', body: JSON.stringify({ agent, prompt }) });
|
|
1812
|
-
document.getElementById('modal-bg').classList.remove('show');
|
|
1813
|
-
loadJobs();
|
|
1814
|
-
};
|
|
1815
|
-
};
|
|
1816
|
-
|
|
1817
|
-
document.getElementById('btn-refresh').onclick = loadJobs;
|
|
1818
|
-
document.getElementById('filter-status').onchange = loadJobs;
|
|
1819
|
-
|
|
1820
|
-
// Auto-refresh every 5s when checkbox is on
|
|
1821
|
-
let timer = null;
|
|
1822
|
-
function setupAutoRefresh() {
|
|
1823
|
-
const on = document.getElementById('auto-refresh').checked;
|
|
1824
|
-
if (timer) { clearInterval(timer); timer = null; }
|
|
1825
|
-
if (on) timer = setInterval(loadJobs, 5000);
|
|
1826
|
-
}
|
|
1827
|
-
document.getElementById('auto-refresh').onchange = setupAutoRefresh;
|
|
1828
|
-
setupAutoRefresh();
|
|
1829
|
-
|
|
1830
|
-
// Schedules tab
|
|
1831
|
-
async function loadSchedules() {
|
|
1832
|
-
const el = document.getElementById('schedules-list');
|
|
1833
|
-
el.innerHTML = `<div class="empty">${T.loading}</div>`;
|
|
1834
|
-
const agent = document.getElementById('schedules-agent-filter').value;
|
|
1835
|
-
const qs = agent ? `?agent=${encodeURIComponent(agent)}` : '';
|
|
1836
|
-
try {
|
|
1837
|
-
const { schedules } = await api(`/api/schedules${qs}`);
|
|
1838
|
-
if (!schedules.length) { el.innerHTML = `<div class="empty">${T.emptySchedules}</div>`; return; }
|
|
1839
|
-
el.innerHTML = `
|
|
1840
|
-
<table>
|
|
1841
|
-
<thead><tr>
|
|
1842
|
-
<th>#</th><th>${T.scheduleName}</th><th>${T.agent}</th><th>${T.scheduleCron}</th>
|
|
1843
|
-
<th>${T.status}</th><th>${T.scheduleNext}</th><th>${T.scheduleLast}</th>
|
|
1844
|
-
</tr></thead>
|
|
1845
|
-
<tbody>
|
|
1846
|
-
${schedules.map(s => `
|
|
1847
|
-
<tr>
|
|
1848
|
-
<td>#${s.id}</td>
|
|
1849
|
-
<td>${esc(s.name)}</td>
|
|
1850
|
-
<td><code>${esc(s.agent)}</code></td>
|
|
1851
|
-
<td><code>${esc(s.cron)}</code></td>
|
|
1852
|
-
<td>${s.enabled ? '✅' : '⏸️'}</td>
|
|
1853
|
-
<td>${fmtTime(s.next_run)}</td>
|
|
1854
|
-
<td>${fmtTime(s.last_run)}</td>
|
|
1855
|
-
</tr>
|
|
1856
|
-
`).join('')}
|
|
1857
|
-
</tbody>
|
|
1858
|
-
</table>
|
|
1859
|
-
`;
|
|
1860
|
-
} catch (e) {
|
|
1861
|
-
el.innerHTML = `<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
// ============================================
|
|
1866
|
-
// Background jobs (Claude / opencode / Codex bgjob)
|
|
1867
|
-
// ============================================
|
|
1868
|
-
|
|
1869
|
-
let bgRoots = null; // [{id, label, path}]
|
|
1870
|
-
|
|
1871
|
-
async function ensureBgRootsLoaded() {
|
|
1872
|
-
if (bgRoots) return bgRoots;
|
|
1873
|
-
const res = await api('/api/bgjobs');
|
|
1874
|
-
bgRoots = res.roots || [];
|
|
1875
|
-
const sel = document.getElementById('bg-root-filter');
|
|
1876
|
-
sel.innerHTML = bgRoots.map(r =>
|
|
1877
|
-
`<option value="${esc(r.id)}">${esc(r.label)} (${esc(r.path)})</option>`
|
|
1878
|
-
).join('');
|
|
1879
|
-
sel.onchange = loadBgjobs;
|
|
1880
|
-
return bgRoots;
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
function bgStatusPill(status) {
|
|
1884
|
-
// Reuse pill colors heuristically — running/completed/failed are common,
|
|
1885
|
-
// bgjob also emits 'killed', 'unknown', etc. Anything we don't know
|
|
1886
|
-
// becomes the muted 'cancelled' style.
|
|
1887
|
-
const known = ['pending', 'running', 'completed', 'failed', 'cancelled'];
|
|
1888
|
-
const cls = known.includes(status) ? status : 'cancelled';
|
|
1889
|
-
return `<span class="pill ${cls}">${esc(status)}</span>`;
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
function shortId(id) { return String(id).slice(0, 24); }
|
|
1893
|
-
|
|
1894
|
-
async function loadBgjobs() {
|
|
1895
|
-
await ensureBgRootsLoaded();
|
|
1896
|
-
const sel = document.getElementById('bg-root-filter');
|
|
1897
|
-
const rootId = sel.value || (bgRoots[0]?.id);
|
|
1898
|
-
if (!rootId) {
|
|
1899
|
-
document.getElementById('bg-list').innerHTML = `<div class="empty">${T.bgEmpty}</div>`;
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
try {
|
|
1903
|
-
const { jobs } = await api(`/api/bgjobs?root=${encodeURIComponent(rootId)}`);
|
|
1904
|
-
const el = document.getElementById('bg-list');
|
|
1905
|
-
if (!jobs.length) { el.innerHTML = `<div class="empty">${T.bgEmpty}</div>`; return; }
|
|
1906
|
-
el.innerHTML = `
|
|
1907
|
-
<table>
|
|
1908
|
-
<thead>
|
|
1909
|
-
<tr>
|
|
1910
|
-
<th>${T.bgName}</th>
|
|
1911
|
-
<th>ID</th>
|
|
1912
|
-
<th>${T.status}</th>
|
|
1913
|
-
<th>${T.bgPid}</th>
|
|
1914
|
-
<th>${T.bgStarted}</th>
|
|
1915
|
-
<th>${T.bgEnded}</th>
|
|
1916
|
-
<th>${T.bgExit}</th>
|
|
1917
|
-
<th>${T.actions}</th>
|
|
1918
|
-
</tr>
|
|
1919
|
-
</thead>
|
|
1920
|
-
<tbody>
|
|
1921
|
-
${jobs.map(j => `
|
|
1922
|
-
<tr data-id="${esc(j.id)}">
|
|
1923
|
-
<td>${esc(j.name)}</td>
|
|
1924
|
-
<td><code title="${esc(j.id)}">${esc(shortId(j.id))}</code></td>
|
|
1925
|
-
<td>${bgStatusPill(j.status)}</td>
|
|
1926
|
-
<td>${j.pid ?? '-'}</td>
|
|
1927
|
-
<td>${fmtTime(j.started_at)}</td>
|
|
1928
|
-
<td>${fmtTime(j.ended_at)}</td>
|
|
1929
|
-
<td>${j.exit_code === null || j.exit_code === undefined ? '-' : j.exit_code}</td>
|
|
1930
|
-
<td class="row-actions">
|
|
1931
|
-
<button type="button" data-act="bg-view">${T.view}</button>
|
|
1932
|
-
</td>
|
|
1933
|
-
</tr>
|
|
1934
|
-
`).join('')}
|
|
1935
|
-
</tbody>
|
|
1936
|
-
</table>
|
|
1937
|
-
`;
|
|
1938
|
-
el.querySelectorAll('tr[data-id]').forEach(tr => {
|
|
1939
|
-
const id = tr.dataset.id;
|
|
1940
|
-
tr.querySelector('[data-act="bg-view"]').addEventListener('click', () => showBgjob(rootId, id));
|
|
1941
|
-
});
|
|
1942
|
-
} catch (e) {
|
|
1943
|
-
document.getElementById('bg-list').innerHTML =
|
|
1944
|
-
`<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1945
|
-
}
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
async function showBgjob(rootId, id) {
|
|
1949
|
-
try {
|
|
1950
|
-
const { job } = await api(`/api/bgjobs/${encodeURIComponent(id)}?root=${encodeURIComponent(rootId)}&tail=200`);
|
|
1951
|
-
const m = document.getElementById('modal');
|
|
1952
|
-
m.innerHTML = `
|
|
1953
|
-
<h2>${T.details}</h2>
|
|
1954
|
-
<p><strong>${T.bgName}:</strong> <code>${esc(job.name)}</code> · <strong>${T.status}:</strong> ${bgStatusPill(job.status)}</p>
|
|
1955
|
-
<p><strong>ID:</strong> <code>${esc(job.id)}</code></p>
|
|
1956
|
-
<p><strong>${T.bgRoot}:</strong> <code>${esc(job.rootId)}</code> · <strong>${T.bgPid}:</strong> ${job.pid ?? '-'} · <strong>${T.bgRestart}:</strong> ${job.restart_generation}</p>
|
|
1957
|
-
<p><strong>${T.bgStarted}:</strong> ${fmtTime(job.started_at)}${job.ended_at ? ` · <strong>${T.bgEnded}:</strong> ${fmtTime(job.ended_at)}` : ''}${job.exit_code !== null && job.exit_code !== undefined ? ` · <strong>${T.bgExit}:</strong> ${job.exit_code}` : ''}</p>
|
|
1958
|
-
${job.cmd?.length ? `<h3>${T.bgCommand}</h3><pre>${esc(job.cmd.join(' '))}</pre>` : ''}
|
|
1959
|
-
${job.workdir ? `<p><strong>${T.bgWorkdir}:</strong> <code>${esc(job.workdir)}</code></p>` : ''}
|
|
1960
|
-
${job.out_dir ? `<p><strong>${T.bgOutDir}:</strong> <code>${esc(job.out_dir)}</code></p>` : ''}
|
|
1961
|
-
${job.log_tail !== null && job.log_tail !== undefined
|
|
1962
|
-
? `<h3>${T.bgLogTail}</h3><pre>${esc(job.log_tail) || '(empty)'}</pre>`
|
|
1963
|
-
: ''}
|
|
1964
|
-
<div class="modal-actions">
|
|
1965
|
-
<button type="button" id="modal-close">${T.close}</button>
|
|
1966
|
-
</div>
|
|
1967
|
-
`;
|
|
1968
|
-
document.getElementById('modal-bg').classList.add('show');
|
|
1969
|
-
document.getElementById('modal-close').onclick = () =>
|
|
1970
|
-
document.getElementById('modal-bg').classList.remove('show');
|
|
1971
|
-
} catch (e) {
|
|
1972
|
-
alert(`${T.error}: ${e.message}`);
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
document.getElementById('btn-bg-refresh').onclick = loadBgjobs;
|
|
1977
|
-
|
|
1978
|
-
let bgTimer = null;
|
|
1979
|
-
function setupBgAutoRefresh() {
|
|
1980
|
-
const onTab = !document.getElementById('background-pane').hidden;
|
|
1981
|
-
const enabled = document.getElementById('bg-auto-refresh').checked && onTab;
|
|
1982
|
-
if (bgTimer) { clearInterval(bgTimer); bgTimer = null; }
|
|
1983
|
-
if (enabled) bgTimer = setInterval(loadBgjobs, 5000);
|
|
1984
|
-
}
|
|
1985
|
-
document.getElementById('bg-auto-refresh').onchange = setupBgAutoRefresh;
|
|
1986
|
-
|
|
1987
|
-
// ============================================
|
|
1988
|
-
// Subtasks (flattened from session.subtasks)
|
|
1989
|
-
// ============================================
|
|
1990
|
-
|
|
1991
|
-
async function loadSubtasks() {
|
|
1992
|
-
const el = document.getElementById('sub-list');
|
|
1993
|
-
el.innerHTML = `<div class="empty">${T.loading}</div>`;
|
|
1994
|
-
const agent = document.getElementById('subtasks-agent-filter').value;
|
|
1995
|
-
const qs = agent ? `?agent=${encodeURIComponent(agent)}` : '';
|
|
1996
|
-
try {
|
|
1997
|
-
const { subtasks } = await api(`/api/subtasks${qs}`);
|
|
1998
|
-
if (!subtasks.length) { el.innerHTML = `<div class="empty">${T.subEmpty}</div>`; return; }
|
|
1999
|
-
el.innerHTML = `
|
|
2000
|
-
<table>
|
|
2001
|
-
<thead>
|
|
2002
|
-
<tr>
|
|
2003
|
-
<th>#</th>
|
|
2004
|
-
<th>${T.subAgent}</th>
|
|
2005
|
-
<th>${T.subPrompt}</th>
|
|
2006
|
-
<th>${T.status}</th>
|
|
2007
|
-
<th>${T.subParent}</th>
|
|
2008
|
-
<th>${T.subCreated}</th>
|
|
2009
|
-
</tr>
|
|
2010
|
-
</thead>
|
|
2011
|
-
<tbody>
|
|
2012
|
-
${subtasks.map(s => `
|
|
2013
|
-
<tr>
|
|
2014
|
-
<td>#${s.id}</td>
|
|
2015
|
-
<td><code>${esc(s.agent || '')}</code></td>
|
|
2016
|
-
<td class="truncate">${esc(s.prompt || '')}</td>
|
|
2017
|
-
<td>${statusPill(s.status || 'pending')}</td>
|
|
2018
|
-
<td><code title="${esc(`${s.platform}:${s.channelId}:${s.threadId}`)}">${esc(s.platform)}/${esc(s.threadId).slice(0, 12)}</code></td>
|
|
2019
|
-
<td>${fmtTime(s.createdAt)}</td>
|
|
2020
|
-
</tr>
|
|
2021
|
-
`).join('')}
|
|
2022
|
-
</tbody>
|
|
2023
|
-
</table>
|
|
2024
|
-
`;
|
|
2025
|
-
} catch (e) {
|
|
2026
|
-
el.innerHTML = `<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
document.getElementById('btn-sub-refresh').onclick = loadSubtasks;
|
|
2031
|
-
|
|
2032
|
-
// ============================================
|
|
2033
|
-
// Audit tab — invocations history with agent / days / user filter
|
|
2034
|
-
// ============================================
|
|
2035
|
-
|
|
2036
|
-
async function loadAudit() {
|
|
2037
|
-
const el = document.getElementById('audit-list');
|
|
2038
|
-
el.innerHTML = `<div class="empty">${T.loading}</div>`;
|
|
2039
|
-
const agent = document.getElementById('audit-agent-filter').value;
|
|
2040
|
-
const days = document.getElementById('audit-days-filter').value || '7';
|
|
2041
|
-
const user = document.getElementById('audit-user-filter').value.trim();
|
|
2042
|
-
const qs = new URLSearchParams({ days, limit: '200' });
|
|
2043
|
-
if (agent) qs.set('agent', agent);
|
|
2044
|
-
if (user) qs.set('user', user);
|
|
2045
|
-
try {
|
|
2046
|
-
const { invocations, stats } = await api(`/api/audit?${qs.toString()}`);
|
|
2047
|
-
// Stats strip
|
|
2048
|
-
const statsEl = document.getElementById('audit-stats');
|
|
2049
|
-
if (stats && typeof stats.total === 'number') {
|
|
2050
|
-
const top = Object.entries(stats.byAgent || {})
|
|
2051
|
-
.sort((a, b) => b[1] - a[1]).slice(0, 4)
|
|
2052
|
-
.map(([a, n]) => `${esc(a)}=${n}`).join(' · ');
|
|
2053
|
-
statsEl.innerHTML = `Total: <strong>${stats.total}</strong> · ` +
|
|
2054
|
-
`Cost: $${(stats.totalCost || 0).toFixed(4)} · By agent: ${top || '—'}`;
|
|
2055
|
-
} else {
|
|
2056
|
-
statsEl.innerHTML = '';
|
|
2057
|
-
}
|
|
2058
|
-
if (!invocations.length) {
|
|
2059
|
-
el.innerHTML = `<div class="empty">${T.auditEmpty}</div>`;
|
|
2060
|
-
return;
|
|
2061
|
-
}
|
|
2062
|
-
el.innerHTML = `
|
|
2063
|
-
<table>
|
|
2064
|
-
<thead><tr>
|
|
2065
|
-
<th>${T.auditTime}</th>
|
|
2066
|
-
<th>${T.agent}</th>
|
|
2067
|
-
<th>${T.auditPlatform}</th>
|
|
2068
|
-
<th>${T.auditUser}</th>
|
|
2069
|
-
<th>${T.auditIntent}</th>
|
|
2070
|
-
<th>${T.auditDuration}</th>
|
|
2071
|
-
<th>${T.auditCost}</th>
|
|
2072
|
-
<th>${T.status}</th>
|
|
2073
|
-
</tr></thead>
|
|
2074
|
-
<tbody>
|
|
2075
|
-
${invocations.map(r => `
|
|
2076
|
-
<tr title="trace: ${esc(r.trace_id || '')}${r.error ? `\n${esc(r.error)}` : ''}">
|
|
2077
|
-
<td>${fmtTime(r.ts)}</td>
|
|
2078
|
-
<td><code>${esc(r.agent)}</code></td>
|
|
2079
|
-
<td>${esc(r.platform)}</td>
|
|
2080
|
-
<td><code>${esc(r.user_id || '-')}</code></td>
|
|
2081
|
-
<td>${esc(r.intent || '-')}</td>
|
|
2082
|
-
<td>${(r.duration_ms / 1000).toFixed(2)}s</td>
|
|
2083
|
-
<td>${r.cost ? `$${Number(r.cost).toFixed(4)}` : '-'}</td>
|
|
2084
|
-
<td>${r.success ? '✅' : '❌'}</td>
|
|
2085
|
-
</tr>
|
|
2086
|
-
`).join('')}
|
|
2087
|
-
</tbody>
|
|
2088
|
-
</table>
|
|
2089
|
-
`;
|
|
2090
|
-
} catch (e) {
|
|
2091
|
-
el.innerHTML = `<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
// Agent-filter change handlers per tab. Each dropdown re-runs its tab's
|
|
2096
|
-
// loader; tabs that haven't been opened yet still hold the selection.
|
|
2097
|
-
document.getElementById('jobs-agent-filter').addEventListener('change', loadJobs);
|
|
2098
|
-
document.getElementById('subtasks-agent-filter').addEventListener('change', loadSubtasks);
|
|
2099
|
-
document.getElementById('schedules-agent-filter').addEventListener('change', loadSchedules);
|
|
2100
|
-
document.getElementById('audit-agent-filter').addEventListener('change', loadAudit);
|
|
2101
|
-
document.getElementById('audit-days-filter').addEventListener('change', loadAudit);
|
|
2102
|
-
// User filter is debounced — refresh on blur / Enter, not on every keystroke.
|
|
2103
|
-
document.getElementById('audit-user-filter').addEventListener('change', loadAudit);
|
|
2104
|
-
document.getElementById('btn-audit-refresh').onclick = loadAudit;
|
|
2105
|
-
|
|
2106
|
-
// Schedules tab also gets an explicit refresh button now that it has a
|
|
2107
|
-
// toolbar (was previously bare-list); keeps parity with other tabs.
|
|
2108
|
-
const btnSchedRefresh = document.getElementById('btn-sched-refresh');
|
|
2109
|
-
if (btnSchedRefresh) btnSchedRefresh.onclick = loadSchedules;
|
|
2110
|
-
|
|
2111
|
-
// ============================================================
|
|
2112
|
-
// Health tab (PR-B)
|
|
2113
|
-
// ============================================================
|
|
2114
|
-
// Per-agent operational snapshot. Polled every 5 s when the tab is
|
|
2115
|
-
// visible (auto-refresh checkbox controls). The sparkline buffer is
|
|
2116
|
-
// kept client-side — each poll appends current p95 to a 60-sample
|
|
2117
|
-
// ring per agent, rendered as a tiny inline SVG. Stays in memory
|
|
2118
|
-
// only; no localStorage persistence.
|
|
2119
|
-
|
|
2120
|
-
const HEALTH_POLL_MS = 5_000;
|
|
2121
|
-
const SPARKLINE_LEN = 60;
|
|
2122
|
-
const sparkBuffers = new Map(); // agent → number[] (p95 history)
|
|
2123
|
-
let healthTimer = null;
|
|
2124
|
-
|
|
2125
|
-
function pushSpark(agent, value) {
|
|
2126
|
-
let buf = sparkBuffers.get(agent);
|
|
2127
|
-
if (!buf) { buf = []; sparkBuffers.set(agent, buf); }
|
|
2128
|
-
buf.push(Number(value) || 0);
|
|
2129
|
-
while (buf.length > SPARKLINE_LEN) buf.shift();
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
function renderSparkline(values, w = 120, h = 28) {
|
|
2133
|
-
if (!values.length) return '';
|
|
2134
|
-
const max = Math.max(1, ...values);
|
|
2135
|
-
const step = w / Math.max(1, SPARKLINE_LEN - 1);
|
|
2136
|
-
const points = values.map((v, i) =>
|
|
2137
|
-
`${(i * step).toFixed(1)},${(h - (v / max) * h * 0.92 - 1).toFixed(1)}`
|
|
2138
|
-
).join(' ');
|
|
2139
|
-
const last = values[values.length - 1];
|
|
2140
|
-
const dotX = ((values.length - 1) * step).toFixed(1);
|
|
2141
|
-
const dotY = (h - (last / max) * h * 0.92 - 1).toFixed(1);
|
|
2142
|
-
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
2143
|
-
<polyline fill="none" stroke="var(--primary)" stroke-width="1.5" points="${points}" />
|
|
2144
|
-
<circle cx="${dotX}" cy="${dotY}" r="2" fill="var(--primary)" />
|
|
2145
|
-
</svg>`;
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
function breakerPill(phase) {
|
|
2149
|
-
const map = {
|
|
2150
|
-
closed: { label: T.breakerClosed, bg: 'var(--success)' },
|
|
2151
|
-
open: { label: T.breakerOpen, bg: 'var(--danger)' },
|
|
2152
|
-
'half-open': { label: T.breakerHalfOpen, bg: 'var(--warning)' },
|
|
2153
|
-
};
|
|
2154
|
-
const o = map[phase] || { label: phase, bg: 'var(--muted)' };
|
|
2155
|
-
return `<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;
|
|
2156
|
-
background:${o.bg};color:#fff">${o.label}</span>`;
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
function renderHealth(payload) {
|
|
2160
|
-
const list = document.getElementById('health-list');
|
|
2161
|
-
const uptime = document.getElementById('health-uptime');
|
|
2162
|
-
const days = Math.floor(payload.uptimeSec / 86400);
|
|
2163
|
-
const hours = Math.floor((payload.uptimeSec % 86400) / 3600);
|
|
2164
|
-
const mins = Math.floor((payload.uptimeSec % 3600) / 60);
|
|
2165
|
-
uptime.textContent = `uptime ${days > 0 ? `${days}d ` : ''}${hours}h ${mins}m`;
|
|
2166
|
-
if (!payload.agents.length) {
|
|
2167
|
-
list.innerHTML = `<div class="empty">${T.healthEmpty}</div>`;
|
|
2168
|
-
return;
|
|
2169
|
-
}
|
|
2170
|
-
list.innerHTML = payload.agents.map((a) => {
|
|
2171
|
-
const inv = a.invocations;
|
|
2172
|
-
const p95 = inv ? inv.p95Ms : 0;
|
|
2173
|
-
pushSpark(a.agent, p95);
|
|
2174
|
-
const buf = sparkBuffers.get(a.agent) || [];
|
|
2175
|
-
const cooldownLine = a.breaker.phase !== 'closed'
|
|
2176
|
-
? `<div class="ac-row"><b>${T.healthCooldown}:</b> ${(a.breaker.cooldownRemainingMs / 1000).toFixed(0)}s</div>`
|
|
2177
|
-
: '';
|
|
2178
|
-
const successPct = inv && inv.total > 0 ? `${(inv.successRate * 100).toFixed(1)}%` : '—';
|
|
2179
|
-
const cost = inv?.costSum ? `$${inv.costSum.toFixed(4)}` : '—';
|
|
2180
|
-
return `
|
|
2181
|
-
<div class="health-card">
|
|
2182
|
-
<div class="health-head">
|
|
2183
|
-
<code class="health-agent">${esc(a.agent)}</code>
|
|
2184
|
-
${breakerPill(a.breaker.phase)}
|
|
2185
|
-
</div>
|
|
2186
|
-
<div class="health-grid">
|
|
2187
|
-
<div>
|
|
2188
|
-
<div class="health-label">${T.healthBreaker}</div>
|
|
2189
|
-
<div class="health-val">${esc(a.breaker.phase)}${a.breaker.failures > 0 ? ` · ${a.breaker.failures} fail` : ''}</div>
|
|
2190
|
-
</div>
|
|
2191
|
-
<div>
|
|
2192
|
-
<div class="health-label">${T.healthRate}</div>
|
|
2193
|
-
<div class="health-val">${a.rate.remaining}/${a.rate.rate}/${a.rate.intervalSec}s</div>
|
|
2194
|
-
</div>
|
|
2195
|
-
<div>
|
|
2196
|
-
<div class="health-label">${T.healthLatency} p50/p95/p99</div>
|
|
2197
|
-
<div class="health-val">${inv ? `${inv.p50Ms}/${inv.p95Ms}/${inv.p99Ms}ms` : '—'}</div>
|
|
2198
|
-
</div>
|
|
2199
|
-
<div>
|
|
2200
|
-
<div class="health-label">${T.healthInvocations}</div>
|
|
2201
|
-
<div class="health-val">${inv ? inv.total : 0} · ${T.healthSuccessRate} ${successPct}</div>
|
|
2202
|
-
</div>
|
|
2203
|
-
<div>
|
|
2204
|
-
<div class="health-label">${T.healthCost}</div>
|
|
2205
|
-
<div class="health-val">${cost}</div>
|
|
2206
|
-
</div>
|
|
2207
|
-
<div title="${T.healthSparklineLabel}">
|
|
2208
|
-
<div class="health-label">p95 (${buf.length}/${SPARKLINE_LEN})</div>
|
|
2209
|
-
<div class="health-spark">${renderSparkline(buf)}</div>
|
|
2210
|
-
</div>
|
|
2211
|
-
</div>
|
|
2212
|
-
${cooldownLine}
|
|
2213
|
-
</div>
|
|
2214
|
-
`;
|
|
2215
|
-
}).join('');
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
|
-
async function loadHealth() {
|
|
2219
|
-
try {
|
|
2220
|
-
const data = await api('/api/agent-health');
|
|
2221
|
-
renderHealth(data);
|
|
2222
|
-
} catch (e) {
|
|
2223
|
-
document.getElementById('health-list').innerHTML =
|
|
2224
|
-
`<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
2227
|
-
|
|
2228
|
-
function setupHealthAutoRefresh() {
|
|
2229
|
-
const isHealth = !document.getElementById('health-pane').hidden;
|
|
2230
|
-
const on = document.getElementById('health-auto-refresh').checked && isHealth;
|
|
2231
|
-
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
|
|
2232
|
-
if (on) healthTimer = setInterval(loadHealth, HEALTH_POLL_MS);
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
document.getElementById('btn-health-refresh').onclick = loadHealth;
|
|
2236
|
-
document.getElementById('health-auto-refresh').onchange = setupHealthAutoRefresh;
|
|
2237
|
-
|
|
2238
|
-
// ============================================================
|
|
2239
|
-
// Approvals tab (PR-B)
|
|
2240
|
-
// ============================================================
|
|
2241
|
-
// Global view of every currently-pending HITL approval. Operator can
|
|
2242
|
-
// resolve from here without needing access to the original IM thread
|
|
2243
|
-
// — useful when an approval lands while the requester is offline. The
|
|
2244
|
-
// server validates the reqId still exists; if not (already resolved /
|
|
2245
|
-
// timed out) we get a 404 + refresh.
|
|
2246
|
-
|
|
2247
|
-
const APPROVALS_POLL_MS = 3_000;
|
|
2248
|
-
let approvalsTimer = null;
|
|
2249
|
-
|
|
2250
|
-
function fmtAge(ms) {
|
|
2251
|
-
const s = Math.max(0, Math.floor(ms / 1000));
|
|
2252
|
-
if (s < 60) return `${s}s`;
|
|
2253
|
-
const m = Math.floor(s / 60);
|
|
2254
|
-
if (m < 60) return `${m}m ${s % 60}s`;
|
|
2255
|
-
const h = Math.floor(m / 60);
|
|
2256
|
-
return `${h}h ${m % 60}m`;
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
function renderApprovals(payload) {
|
|
2260
|
-
const list = document.getElementById('approvals-list');
|
|
2261
|
-
const summary = document.getElementById('approvals-summary');
|
|
2262
|
-
const pending = payload.pending || [];
|
|
2263
|
-
summary.textContent = T.approvalsCount.replace('{n}', pending.length);
|
|
2264
|
-
if (!pending.length) {
|
|
2265
|
-
list.innerHTML = `<div class="empty">${T.approvalsEmpty}</div>`;
|
|
2266
|
-
return;
|
|
2267
|
-
}
|
|
2268
|
-
list.innerHTML = pending.map((p) => {
|
|
2269
|
-
let inputPreview = '';
|
|
2270
|
-
try { inputPreview = JSON.stringify(p.input, null, 2); } catch { inputPreview = '(unserializable)'; }
|
|
2271
|
-
if (inputPreview.length > 600) inputPreview = `${inputPreview.slice(0, 600)}\n...`;
|
|
2272
|
-
const autoTag = p.autoAllow
|
|
2273
|
-
? `<span style="color:var(--warning);font-size:11px;margin-left:6px">⏱ ${T.approvalsAutoMode}</span>`
|
|
2274
|
-
: '';
|
|
2275
|
-
return `
|
|
2276
|
-
<div class="approval-item" data-req-id="${esc(p.reqId)}">
|
|
2277
|
-
<div class="approval-head">
|
|
2278
|
-
<code>${esc(p.toolName)}</code>${autoTag}
|
|
2279
|
-
<span class="approval-meta">${esc(p.platform)} · ${T.approvalsThread}: ${esc(String(p.threadId).slice(0, 24))} · ${T.approvalsAge}: ${fmtAge(p.ageMs)}</span>
|
|
2280
|
-
</div>
|
|
2281
|
-
<pre class="approval-input">${esc(inputPreview)}</pre>
|
|
2282
|
-
<div class="approval-actions">
|
|
2283
|
-
<button type="button" data-act="allow" class="btn-allow">${T.approvalsAllow}</button>
|
|
2284
|
-
<button type="button" data-act="deny" class="btn-deny">${T.approvalsDeny}</button>
|
|
2285
|
-
<button type="button" data-act="allowAll">${T.approvalsAllowAll}</button>
|
|
2286
|
-
</div>
|
|
2287
|
-
</div>
|
|
2288
|
-
`;
|
|
2289
|
-
}).join('');
|
|
2290
|
-
list.querySelectorAll('.approval-item').forEach((item) => {
|
|
2291
|
-
const reqId = item.dataset.reqId;
|
|
2292
|
-
item.querySelector('[data-act="allow"]').onclick = () => resolveApproval(reqId, 'allow', false);
|
|
2293
|
-
item.querySelector('[data-act="deny"]').onclick = () => resolveApproval(reqId, 'deny', false);
|
|
2294
|
-
item.querySelector('[data-act="allowAll"]').onclick = () => resolveApproval(reqId, 'allow', true);
|
|
2295
|
-
});
|
|
2296
|
-
}
|
|
2297
|
-
|
|
2298
|
-
async function resolveApproval(reqId, behavior, autoAllowFurther) {
|
|
2299
|
-
const item = document.querySelector(`.approval-item[data-req-id="${reqId}"]`);
|
|
2300
|
-
if (item) item.querySelectorAll('button').forEach((b) => { b.disabled = true; });
|
|
2301
|
-
try {
|
|
2302
|
-
await api(`/api/approvals/${encodeURIComponent(reqId)}/resolve`, {
|
|
2303
|
-
method: 'POST',
|
|
2304
|
-
body: JSON.stringify({ behavior, autoAllowFurther }),
|
|
2305
|
-
});
|
|
2306
|
-
loadApprovals();
|
|
2307
|
-
} catch (e) {
|
|
2308
|
-
if (item) {
|
|
2309
|
-
const err = document.createElement('div');
|
|
2310
|
-
err.style.cssText = 'color:var(--danger);font-size:12px;margin-top:6px';
|
|
2311
|
-
err.textContent = `${T.approvalsResolveErr}: ${e.message}`;
|
|
2312
|
-
item.appendChild(err);
|
|
2313
|
-
item.querySelectorAll('button').forEach((b) => { b.disabled = false; });
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
2316
|
-
}
|
|
2317
|
-
|
|
2318
|
-
async function loadApprovals() {
|
|
2319
|
-
try {
|
|
2320
|
-
const data = await api('/api/approvals');
|
|
2321
|
-
renderApprovals(data);
|
|
2322
|
-
} catch (e) {
|
|
2323
|
-
document.getElementById('approvals-list').innerHTML =
|
|
2324
|
-
`<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
2325
|
-
}
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
|
-
function setupApprovalsAutoRefresh() {
|
|
2329
|
-
const isApprovals = !document.getElementById('approvals-pane').hidden;
|
|
2330
|
-
const on = document.getElementById('approvals-auto-refresh').checked && isApprovals;
|
|
2331
|
-
if (approvalsTimer) { clearInterval(approvalsTimer); approvalsTimer = null; }
|
|
2332
|
-
if (on) approvalsTimer = setInterval(loadApprovals, APPROVALS_POLL_MS);
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
document.getElementById('btn-approvals-refresh').onclick = loadApprovals;
|
|
2336
|
-
document.getElementById('approvals-auto-refresh').onchange = setupApprovalsAutoRefresh;
|
|
2337
|
-
|
|
2338
|
-
// ============================================================
|
|
2339
|
-
// Files tab (PR-D) — read-only browser of ~/.im-hub-workspaces/<agent>/
|
|
2340
|
-
// ============================================================
|
|
2341
|
-
// Pure pull model: list directory or peek a file via /api/workspace-files.
|
|
2342
|
-
// No write/edit affordances by design; ops use ssh.
|
|
2343
|
-
|
|
2344
|
-
let filesAgent = ''; // currently-selected agent
|
|
2345
|
-
let filesPath = ''; // current relative path under that agent's root
|
|
2346
|
-
let filesAgentsLoaded = false;
|
|
2347
|
-
|
|
2348
|
-
async function ensureFilesAgentLoaded() {
|
|
2349
|
-
if (filesAgentsLoaded) return;
|
|
2350
|
-
filesAgentsLoaded = true;
|
|
2351
|
-
try {
|
|
2352
|
-
const agentsRes = await fetch('/api/agents/status', {
|
|
2353
|
-
credentials: 'same-origin',
|
|
2354
|
-
}).then(r => r.json());
|
|
2355
|
-
const names = Object.keys(agentsRes);
|
|
2356
|
-
const sel = document.getElementById('files-agent');
|
|
2357
|
-
sel.innerHTML = names.length
|
|
2358
|
-
? names.map(n => `<option value="${esc(n)}">${esc(n)}</option>`).join('')
|
|
2359
|
-
: '';
|
|
2360
|
-
if (!names.length) {
|
|
2361
|
-
document.getElementById('files-tree').innerHTML = `<div class="empty">${T.filesNoAgent}</div>`;
|
|
2362
|
-
return;
|
|
2363
|
-
}
|
|
2364
|
-
filesAgent = sel.value = names[0];
|
|
2365
|
-
} catch {
|
|
2366
|
-
filesAgentsLoaded = false; // allow retry next time
|
|
2367
|
-
}
|
|
2368
|
-
}
|
|
2369
|
-
|
|
2370
|
-
function fmtBytes(n) {
|
|
2371
|
-
if (n == null) return '-';
|
|
2372
|
-
if (n < 1024) return `${n} B`;
|
|
2373
|
-
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
2374
|
-
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
async function loadFiles(path) {
|
|
2378
|
-
filesPath = path || '';
|
|
2379
|
-
const tree = document.getElementById('files-tree');
|
|
2380
|
-
const content = document.getElementById('files-content');
|
|
2381
|
-
const cur = document.getElementById('files-current');
|
|
2382
|
-
cur.textContent = filesPath || T.filesRoot;
|
|
2383
|
-
if (!filesAgent) { tree.innerHTML = `<div class="empty">${T.filesNoAgent}</div>`; return; }
|
|
2384
|
-
tree.innerHTML = `<div class="empty">${T.loading}</div>`;
|
|
2385
|
-
try {
|
|
2386
|
-
const qs = new URLSearchParams({ agent: filesAgent, path: filesPath });
|
|
2387
|
-
const data = await api(`/api/workspace-files?${qs.toString()}`);
|
|
2388
|
-
if (data.type === 'dir') {
|
|
2389
|
-
if (!data.entries.length) {
|
|
2390
|
-
tree.innerHTML = `<div class="empty">${T.filesEmpty}</div>`;
|
|
2391
|
-
} else {
|
|
2392
|
-
tree.innerHTML = `
|
|
2393
|
-
<table>
|
|
2394
|
-
<thead><tr><th>${T.filesPath}</th><th>${T.filesSize}</th><th>${T.filesMtime}</th></tr></thead>
|
|
2395
|
-
<tbody>
|
|
2396
|
-
${data.entries.map(e => `
|
|
2397
|
-
<tr data-name="${esc(e.name)}" data-isdir="${e.isDir ? '1' : '0'}" style="cursor:pointer">
|
|
2398
|
-
<td>${e.isDir ? '📁 ' : '📄 '}${esc(e.name)}${e.broken ? ' ⚠' : ''}</td>
|
|
2399
|
-
<td>${e.isDir ? '-' : fmtBytes(e.size)}</td>
|
|
2400
|
-
<td>${e.mtime ? fmtTime(e.mtime) : '-'}</td>
|
|
2401
|
-
</tr>
|
|
2402
|
-
`).join('')}
|
|
2403
|
-
</tbody>
|
|
2404
|
-
</table>
|
|
2405
|
-
`;
|
|
2406
|
-
tree.querySelectorAll('tr[data-name]').forEach(tr => {
|
|
2407
|
-
tr.addEventListener('click', () => {
|
|
2408
|
-
const name = tr.dataset.name;
|
|
2409
|
-
const isDir = tr.dataset.isdir === '1';
|
|
2410
|
-
const next = filesPath ? `${filesPath}/${name}` : name;
|
|
2411
|
-
if (isDir) loadFiles(next);
|
|
2412
|
-
else loadFiles(next); // file path → server returns type:'file'
|
|
2413
|
-
});
|
|
2414
|
-
});
|
|
2415
|
-
}
|
|
2416
|
-
content.innerHTML = '';
|
|
2417
|
-
} else if (data.type === 'file') {
|
|
2418
|
-
// Re-render directory listing for the parent so the user keeps
|
|
2419
|
-
// context after clicking a file. Cheap: same path -1 segment.
|
|
2420
|
-
const parent = filesPath.includes('/') ? filesPath.slice(0, filesPath.lastIndexOf('/')) : '';
|
|
2421
|
-
// Don't recurse — load parent listing without overwriting filesPath.
|
|
2422
|
-
await loadDirOnly(parent);
|
|
2423
|
-
renderFileContent(data);
|
|
2424
|
-
} else {
|
|
2425
|
-
tree.innerHTML = `<div class="empty">${T.filesNotFound}</div>`;
|
|
2426
|
-
content.innerHTML = '';
|
|
2427
|
-
}
|
|
2428
|
-
} catch (e) {
|
|
2429
|
-
tree.innerHTML = `<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
2430
|
-
}
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
// Load a parent dir listing into the tree without changing filesPath
|
|
2434
|
-
// (which we want to stay on the file the user opened so the breadcrumb
|
|
2435
|
-
// and the up-button still target the right place).
|
|
2436
|
-
async function loadDirOnly(path) {
|
|
2437
|
-
const tree = document.getElementById('files-tree');
|
|
2438
|
-
try {
|
|
2439
|
-
const qs = new URLSearchParams({ agent: filesAgent, path });
|
|
2440
|
-
const data = await api(`/api/workspace-files?${qs.toString()}`);
|
|
2441
|
-
if (data.type !== 'dir') return;
|
|
2442
|
-
tree.innerHTML = `
|
|
2443
|
-
<table>
|
|
2444
|
-
<thead><tr><th>${T.filesPath}</th><th>${T.filesSize}</th><th>${T.filesMtime}</th></tr></thead>
|
|
2445
|
-
<tbody>
|
|
2446
|
-
${data.entries.map(e => `
|
|
2447
|
-
<tr data-name="${esc(e.name)}" data-isdir="${e.isDir ? '1' : '0'}" style="cursor:pointer">
|
|
2448
|
-
<td>${e.isDir ? '📁 ' : '📄 '}${esc(e.name)}${e.broken ? ' ⚠' : ''}</td>
|
|
2449
|
-
<td>${e.isDir ? '-' : fmtBytes(e.size)}</td>
|
|
2450
|
-
<td>${e.mtime ? fmtTime(e.mtime) : '-'}</td>
|
|
2451
|
-
</tr>
|
|
2452
|
-
`).join('')}
|
|
2453
|
-
</tbody>
|
|
2454
|
-
</table>
|
|
2455
|
-
`;
|
|
2456
|
-
tree.querySelectorAll('tr[data-name]').forEach(tr => {
|
|
2457
|
-
tr.addEventListener('click', () => {
|
|
2458
|
-
const name = tr.dataset.name;
|
|
2459
|
-
const next = path ? `${path}/${name}` : name;
|
|
2460
|
-
loadFiles(next);
|
|
2461
|
-
});
|
|
2462
|
-
});
|
|
2463
|
-
} catch { /* keep prior tree */ }
|
|
2464
|
-
}
|
|
2465
|
-
|
|
2466
|
-
function renderFileContent(data) {
|
|
2467
|
-
const content = document.getElementById('files-content');
|
|
2468
|
-
const banner = data.truncated ? `<div class="empty">${T.filesTruncated}</div>` : '';
|
|
2469
|
-
const binary = data.encoding === 'base64' ? `<div class="empty">${T.filesBinary}</div>` : '';
|
|
2470
|
-
// Editable iff text + not truncated (truncated edits would discard
|
|
2471
|
-
// bytes past the 1 MiB cap, which is a footgun). Both checks here.
|
|
2472
|
-
const canEdit = data.encoding === 'utf-8' && !data.truncated;
|
|
2473
|
-
content.innerHTML = `
|
|
2474
|
-
<div style="margin-bottom:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2475
|
-
<code>${esc(data.path)}</code>
|
|
2476
|
-
<span style="color:var(--muted);font-size:12px"> · ${fmtBytes(data.size)} · ${fmtTime(data.mtime)}</span>
|
|
2477
|
-
${canEdit ? `<button type="button" class="primary" id="btn-file-edit" style="margin-left:auto;background:var(--primary);color:#fff;border-color:var(--primary)">${T.filesEdit}</button>` : ''}
|
|
2478
|
-
</div>
|
|
2479
|
-
${banner}${binary}
|
|
2480
|
-
<pre id="file-pre" style="max-height:55vh;overflow:auto;background:var(--card);border:1px solid var(--border);padding:8px;border-radius:6px">${esc(data.content)}</pre>
|
|
2481
|
-
`;
|
|
2482
|
-
if (canEdit) {
|
|
2483
|
-
document.getElementById('btn-file-edit').onclick = () => enterFileEditMode(data);
|
|
2484
|
-
}
|
|
2485
|
-
}
|
|
2486
|
-
|
|
2487
|
-
function enterFileEditMode(data) {
|
|
2488
|
-
const content = document.getElementById('files-content');
|
|
2489
|
-
// Stash original so Cancel can roll back without re-fetching.
|
|
2490
|
-
content.innerHTML = `
|
|
2491
|
-
<div style="margin-bottom:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
2492
|
-
<code>${esc(data.path)}</code>
|
|
2493
|
-
<span style="color:var(--muted);font-size:12px"> · ${T.filesEditing}</span>
|
|
2494
|
-
<span style="margin-left:auto;display:flex;gap:6px">
|
|
2495
|
-
<button type="button" id="btn-file-cancel">${T.filesCancel}</button>
|
|
2496
|
-
<button type="button" id="btn-file-save" class="primary" style="background:var(--primary);color:#fff;border-color:var(--primary)">${T.filesSave}</button>
|
|
2497
|
-
</span>
|
|
2498
|
-
</div>
|
|
2499
|
-
<textarea id="file-editor" spellcheck="false"
|
|
2500
|
-
style="width:100%;min-height:55vh;font-family:'SF Mono',Menlo,Consolas,monospace;font-size:13px;background:var(--card);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px;outline:none;resize:vertical"></textarea>
|
|
2501
|
-
`;
|
|
2502
|
-
const ta = document.getElementById('file-editor');
|
|
2503
|
-
ta.value = data.content;
|
|
2504
|
-
ta.focus();
|
|
2505
|
-
document.getElementById('btn-file-cancel').onclick = () => renderFileContent(data);
|
|
2506
|
-
document.getElementById('btn-file-save').onclick = async () => {
|
|
2507
|
-
const newContent = ta.value;
|
|
2508
|
-
const saveBtn = document.getElementById('btn-file-save');
|
|
2509
|
-
saveBtn.disabled = true;
|
|
2510
|
-
saveBtn.textContent = T.filesSaving;
|
|
2511
|
-
try {
|
|
2512
|
-
// Bypass api() helper here so we can surface the server's
|
|
2513
|
-
// structured error body (e.g. "Content exceeds 1 MiB cap")
|
|
2514
|
-
// rather than just the HTTP statusText.
|
|
2515
|
-
const qs = new URLSearchParams({ agent: data.agent, path: data.path });
|
|
2516
|
-
const res = await fetch(`/api/workspace-files?${qs.toString()}`, {
|
|
2517
|
-
method: 'PUT',
|
|
2518
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2519
|
-
credentials: 'same-origin',
|
|
2520
|
-
body: JSON.stringify({ content: newContent }),
|
|
2521
|
-
});
|
|
2522
|
-
if (!res.ok) {
|
|
2523
|
-
let msg = `${res.status} ${res.statusText}`;
|
|
2524
|
-
try { const j = await res.json(); if (j?.error) msg = j.error; } catch {}
|
|
2525
|
-
throw new Error(msg);
|
|
2526
|
-
}
|
|
2527
|
-
const r = await res.json();
|
|
2528
|
-
// Re-render from the server's authoritative reply (size/mtime
|
|
2529
|
-
// change on success) rather than trusting the local string.
|
|
2530
|
-
renderFileContent({
|
|
2531
|
-
...data,
|
|
2532
|
-
content: newContent,
|
|
2533
|
-
size: r.size,
|
|
2534
|
-
mtime: r.mtime,
|
|
2535
|
-
});
|
|
2536
|
-
alert(T.filesSaved);
|
|
2537
|
-
} catch (e) {
|
|
2538
|
-
saveBtn.disabled = false;
|
|
2539
|
-
saveBtn.textContent = T.filesSave;
|
|
2540
|
-
alert(`${T.filesSaveFailed}: ${e?.message ? e.message : e}`);
|
|
2541
|
-
}
|
|
2542
|
-
};
|
|
2543
|
-
}
|
|
2544
|
-
|
|
2545
|
-
document.getElementById('files-agent').addEventListener('change', (e) => {
|
|
2546
|
-
filesAgent = e.target.value;
|
|
2547
|
-
filesPath = '';
|
|
2548
|
-
loadFiles('');
|
|
2549
|
-
});
|
|
2550
|
-
document.getElementById('btn-files-refresh').onclick = () => loadFiles(filesPath);
|
|
2551
|
-
document.getElementById('btn-files-up').onclick = () => {
|
|
2552
|
-
if (!filesPath) return;
|
|
2553
|
-
const parent = filesPath.includes('/') ? filesPath.slice(0, filesPath.lastIndexOf('/')) : '';
|
|
2554
|
-
loadFiles(parent);
|
|
2555
|
-
};
|
|
2556
|
-
|
|
2557
|
-
// ============================================================
|
|
2558
|
-
// /events SSE consumer (PR-C)
|
|
2559
|
-
// ============================================================
|
|
2560
|
-
// Server pushes audit / approval / job / metrics events as they
|
|
2561
|
-
// happen. The dashboard previously polled each tab independently
|
|
2562
|
-
// every 3-5 s; now it just refreshes the visible tab when a relevant
|
|
2563
|
-
// event arrives, and keeps a small "Live" status pill in the header.
|
|
2564
|
-
//
|
|
2565
|
-
// We intentionally KEEP the existing setInterval auto-refresh as a
|
|
2566
|
-
// fallback: if SSE fails (proxy stripping, network blip, server
|
|
2567
|
-
// restart), the page still updates eventually. SSE just makes the
|
|
2568
|
-
// refresh near-instant when it works.
|
|
2569
|
-
|
|
2570
|
-
let evtSource = null;
|
|
2571
|
-
function setupSSE() {
|
|
2572
|
-
try {
|
|
2573
|
-
if (evtSource) try { evtSource.close(); } catch {}
|
|
2574
|
-
evtSource = new EventSource('/events', { withCredentials: true });
|
|
2575
|
-
} catch (err) {
|
|
2576
|
-
console.warn('[sse] init failed', err);
|
|
2577
|
-
return;
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
evtSource.addEventListener('hello', () => {
|
|
2581
|
-
console.debug?.('[sse] connected');
|
|
2582
|
-
});
|
|
2583
|
-
|
|
2584
|
-
// When an audit lands and the Audit tab is visible, refresh it.
|
|
2585
|
-
// Same for the other types — no point repainting hidden tabs.
|
|
2586
|
-
const refreshIfVisible = (paneId, loader) => {
|
|
2587
|
-
const pane = document.getElementById(paneId);
|
|
2588
|
-
if (pane && !pane.hidden) loader();
|
|
2589
|
-
};
|
|
2590
|
-
|
|
2591
|
-
evtSource.addEventListener('audit', () => {
|
|
2592
|
-
refreshIfVisible('audit-pane', loadAudit);
|
|
2593
|
-
});
|
|
2594
|
-
evtSource.addEventListener('approval', (e) => {
|
|
2595
|
-
refreshIfVisible('approvals-pane', loadApprovals);
|
|
2596
|
-
// Approval count is also surfaced as a small badge on the tab
|
|
2597
|
-
// (best-effort — silently skip if any field is missing).
|
|
2598
|
-
try {
|
|
2599
|
-
const data = JSON.parse(e.data);
|
|
2600
|
-
if (data.phase === 'requested') flashTabBadge('tab-approvals');
|
|
2601
|
-
} catch {}
|
|
2602
|
-
});
|
|
2603
|
-
evtSource.addEventListener('job', () => {
|
|
2604
|
-
refreshIfVisible('jobs-pane', loadJobs);
|
|
2605
|
-
});
|
|
2606
|
-
evtSource.addEventListener('metrics', () => {
|
|
2607
|
-
refreshIfVisible('health-pane', loadHealth);
|
|
2608
|
-
});
|
|
2609
|
-
|
|
2610
|
-
evtSource.onerror = () => {
|
|
2611
|
-
console.warn('[sse] connection error — EventSource will auto-reconnect');
|
|
2612
|
-
};
|
|
2613
|
-
}
|
|
2614
|
-
|
|
2615
|
-
/**
|
|
2616
|
-
* Pulse a tab's text briefly to draw the eye to a new event there.
|
|
2617
|
-
* Cheap CSS-class flash, auto-clears after ~1.2 s. Uses a
|
|
2618
|
-
* dynamically-injected <style> on first call so we don't need to
|
|
2619
|
-
* touch the page-level <style> block.
|
|
2620
|
-
*/
|
|
2621
|
-
let _flashStyleInjected = false;
|
|
2622
|
-
function flashTabBadge(tabId) {
|
|
2623
|
-
const el = document.getElementById(tabId);
|
|
2624
|
-
if (!el) return;
|
|
2625
|
-
if (!_flashStyleInjected) {
|
|
2626
|
-
const s = document.createElement('style');
|
|
2627
|
-
s.textContent = `
|
|
2628
|
-
@keyframes imhubFlash {
|
|
2629
|
-
0% { background: var(--primary); color: #fff; }
|
|
2630
|
-
100% { background: transparent; color: inherit; }
|
|
2631
|
-
}
|
|
2632
|
-
.imhub-flash { animation: imhubFlash 1.2s ease-out; }
|
|
2633
|
-
`;
|
|
2634
|
-
document.head.appendChild(s);
|
|
2635
|
-
_flashStyleInjected = true;
|
|
2636
|
-
}
|
|
2637
|
-
el.classList.remove('imhub-flash');
|
|
2638
|
-
// Force reflow so the animation re-triggers on rapid events.
|
|
2639
|
-
void el.offsetWidth;
|
|
2640
|
-
el.classList.add('imhub-flash');
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
setupSSE();
|
|
2644
|
-
|
|
2645
|
-
// ============================================
|
|
2646
|
-
// Outbox tab (v1.1.2)
|
|
2647
|
-
// ============================================
|
|
2648
|
-
|
|
2649
|
-
function escapeHtml(s) {
|
|
2650
|
-
return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
2651
|
-
}
|
|
2652
|
-
|
|
2653
|
-
async function loadOutbox() {
|
|
2654
|
-
const statusEl = document.getElementById('outbox-status-filter');
|
|
2655
|
-
const limitEl = document.getElementById('outbox-limit');
|
|
2656
|
-
const statusFilter = statusEl ? statusEl.value : '';
|
|
2657
|
-
const limit = limitEl ? limitEl.value : '50';
|
|
2658
|
-
try {
|
|
2659
|
-
const statsResp = await fetch('/api/outbox/stats');
|
|
2660
|
-
const stats = await statsResp.json();
|
|
2661
|
-
const statsEl = document.getElementById('outbox-stats');
|
|
2662
|
-
if (statsEl) {
|
|
2663
|
-
statsEl.innerHTML =
|
|
2664
|
-
`<span class="stat">${T.outboxStatusPending} <b>${stats.pending ?? 0}</b></span>` +
|
|
2665
|
-
`<span class="stat">${T.outboxStatusDelivered} <b>${stats.delivered ?? 0}</b></span>` +
|
|
2666
|
-
`<span class="stat">${T.outboxStatusGivingUp} <b>${stats.giving_up ?? 0}</b></span> ` +
|
|
2667
|
-
`<button type="button" class="help-btn" data-help="givingUp" title="?">?</button>`;
|
|
2668
|
-
}
|
|
2669
|
-
const qs = new URLSearchParams({ limit });
|
|
2670
|
-
if (statusFilter) qs.set('status', statusFilter);
|
|
2671
|
-
const listResp = await fetch('/api/outbox?' + qs);
|
|
2672
|
-
const { rows = [] } = await listResp.json();
|
|
2673
|
-
const listEl = document.getElementById('outbox-list');
|
|
2674
|
-
if (!rows.length) { listEl.innerHTML = `<p style="color:var(--text-muted)">${T.outboxEmpty}</p>`; return; }
|
|
2675
|
-
const html = ['<table class="data-table"><thead><tr>',
|
|
2676
|
-
`<th>#</th><th>${T.status}</th><th>${T.outboxColPlatform}</th><th>${T.outboxColKind}</th><th>${T.outboxColPri}</th>`,
|
|
2677
|
-
`<th>${T.outboxColAttempts}</th><th>${T.outboxColPayload}</th><th>${T.outboxColError}</th><th>${T.outboxColCreated}</th><th></th>`,
|
|
2678
|
-
'</tr></thead><tbody>'];
|
|
2679
|
-
for (const r of rows) {
|
|
2680
|
-
const icon = r.status === 'delivered' ? '✅' : r.status === 'giving_up' ? '💀' : '⏳';
|
|
2681
|
-
const preview = (r.payload || '').slice(0, 60).replace(/\n/g, ' ');
|
|
2682
|
-
const errCell = r.last_error ? `<span title="${escapeHtml(r.last_error)}">${escapeHtml(r.last_error.slice(0, 40))}…</span>` : '';
|
|
2683
|
-
const ts = fmtTime(r.created_at);
|
|
2684
|
-
const retryBtn = r.status === 'giving_up'
|
|
2685
|
-
? `<button class="btn-link" data-retry="${r.id}">${T.outboxRetry}</button>` : '';
|
|
2686
|
-
html.push(`<tr>
|
|
2687
|
-
<td>#${r.id}</td>
|
|
2688
|
-
<td>${icon} ${escapeHtml(r.status)}</td>
|
|
2689
|
-
<td>${escapeHtml(r.platform)}</td>
|
|
2690
|
-
<td>${escapeHtml(r.kind)}</td>
|
|
2691
|
-
<td>${escapeHtml(r.priority)}</td>
|
|
2692
|
-
<td>${r.attempts}</td>
|
|
2693
|
-
<td title="${escapeHtml(r.payload || '')}">${escapeHtml(preview)}…</td>
|
|
2694
|
-
<td>${errCell}</td>
|
|
2695
|
-
<td>${ts}</td>
|
|
2696
|
-
<td>${retryBtn}</td>
|
|
2697
|
-
</tr>`);
|
|
2698
|
-
}
|
|
2699
|
-
html.push('</tbody></table>');
|
|
2700
|
-
listEl.innerHTML = html.join('');
|
|
2701
|
-
listEl.querySelectorAll('[data-retry]').forEach((btn) => {
|
|
2702
|
-
btn.addEventListener('click', async () => {
|
|
2703
|
-
const id = btn.getAttribute('data-retry');
|
|
2704
|
-
const r = await fetch(`/api/outbox/${id}/retry`, { method: 'POST' });
|
|
2705
|
-
const j = await r.json();
|
|
2706
|
-
if (j.ok) { loadOutbox(); }
|
|
2707
|
-
else alert('Retry failed: ' + (j.error || 'unknown'));
|
|
2708
|
-
});
|
|
2709
|
-
});
|
|
2710
|
-
} catch (err) {
|
|
2711
|
-
document.getElementById('outbox-list').innerHTML = `<p style="color:#d00">Failed: ${escapeHtml(String(err))}</p>`;
|
|
2712
|
-
}
|
|
2713
|
-
}
|
|
2714
|
-
document.getElementById('btn-outbox-refresh')?.addEventListener('click', loadOutbox);
|
|
2715
|
-
document.getElementById('outbox-status-filter')?.addEventListener('change', loadOutbox);
|
|
2716
|
-
document.getElementById('outbox-limit')?.addEventListener('change', loadOutbox);
|
|
2717
|
-
|
|
2718
|
-
// ============================================
|
|
2719
|
-
// A2A tab (v1.1.3)
|
|
2720
|
-
// ============================================
|
|
2721
|
-
|
|
2722
|
-
async function loadA2A() {
|
|
2723
|
-
try {
|
|
2724
|
-
const [statsResp, recentResp] = await Promise.all([
|
|
2725
|
-
fetch('/api/a2a/stats'),
|
|
2726
|
-
fetch('/api/a2a/recent?limit=20'),
|
|
2727
|
-
]);
|
|
2728
|
-
const stats = await statsResp.json();
|
|
2729
|
-
const recent = (await recentResp.json()).rows || [];
|
|
2730
|
-
const statsEl = document.getElementById('a2a-stats');
|
|
2731
|
-
if (statsEl) {
|
|
2732
|
-
const byStatus = (stats.byStatus || []).map(s => `${s.status}:${s.n}`).join(' · ');
|
|
2733
|
-
const byAgent = (stats.byAgent || []).map(s => `${s.agent} <b>${s.n}</b>`).join(' / ');
|
|
2734
|
-
statsEl.innerHTML =
|
|
2735
|
-
`<span class="stat">${T.a2aStatTotal} <b>${stats.total ?? 0}</b></span>` +
|
|
2736
|
-
`<span class="stat">${T.a2aStat24h} <b>${stats.recent24h ?? 0}</b></span>` +
|
|
2737
|
-
`<span class="stat">${T.a2aStatMaxDepth} <b>${stats.maxDepth ?? 0}</b></span>` +
|
|
2738
|
-
(byStatus ? `<span class="stat">${byStatus}</span>` : '') +
|
|
2739
|
-
(byAgent ? `<span class="stat">${T.a2aStatTop} ${byAgent}</span>` : '');
|
|
2740
|
-
}
|
|
2741
|
-
const listEl = document.getElementById('a2a-list');
|
|
2742
|
-
if (!recent.length) { listEl.innerHTML = `<p style="color:var(--text-muted)">${T.a2aEmpty}</p>`; return; }
|
|
2743
|
-
const html = [`<h3 style="margin-top:1em">${T.a2aRecent}</h3>`,
|
|
2744
|
-
'<table class="data-table"><thead><tr>',
|
|
2745
|
-
`<th>#</th><th>${T.a2aCallerCallee}</th><th>${T.depthCol}</th><th>${T.status}</th>`,
|
|
2746
|
-
`<th>${T.prompt}</th><th>${T.created}</th><th>${T.modalDelivered}</th><th></th>`,
|
|
2747
|
-
'</tr></thead><tbody>'];
|
|
2748
|
-
for (const r of recent) {
|
|
2749
|
-
const icon = r.status === 'delivered' || r.status === 'completed' ? '✅'
|
|
2750
|
-
: r.status === 'failed' ? '❌'
|
|
2751
|
-
: r.status === 'running' ? '🔄' : '⏳';
|
|
2752
|
-
const preview = (r.prompt || '').slice(0, 50).replace(/\n/g, ' ');
|
|
2753
|
-
const started = fmtTime(r.started_at);
|
|
2754
|
-
const done = fmtTime(r.completed_at || r.delivered_at);
|
|
2755
|
-
html.push(`<tr>
|
|
2756
|
-
<td>#${r.id}</td>
|
|
2757
|
-
<td>←#${r.parent_id ?? '?'} → <b>${escapeHtml(r.agent)}</b></td>
|
|
2758
|
-
<td>${r.call_depth}</td>
|
|
2759
|
-
<td>${icon} ${escapeHtml(r.status)}</td>
|
|
2760
|
-
<td title="${escapeHtml(r.prompt || '')}">${escapeHtml(preview)}…</td>
|
|
2761
|
-
<td>${started}</td>
|
|
2762
|
-
<td>${done}</td>
|
|
2763
|
-
<td><button class="btn-link" data-tree="${r.parent_id}">${T.a2aBtnViewParent}</button></td>
|
|
2764
|
-
</tr>`);
|
|
2765
|
-
}
|
|
2766
|
-
html.push('</tbody></table>');
|
|
2767
|
-
listEl.innerHTML = html.join('');
|
|
2768
|
-
listEl.querySelectorAll('[data-tree]').forEach((btn) => {
|
|
2769
|
-
btn.addEventListener('click', () => {
|
|
2770
|
-
const id = btn.getAttribute('data-tree');
|
|
2771
|
-
if (!id || id === 'null') return;
|
|
2772
|
-
document.getElementById('a2a-tree-id').value = id;
|
|
2773
|
-
loadA2ATree(id);
|
|
2774
|
-
});
|
|
2775
|
-
});
|
|
2776
|
-
} catch (err) {
|
|
2777
|
-
document.getElementById('a2a-list').innerHTML = `<p style="color:#d00">Failed: ${escapeHtml(String(err))}</p>`;
|
|
2778
|
-
}
|
|
2779
|
-
}
|
|
2780
|
-
|
|
2781
|
-
async function loadA2ATree(rootId) {
|
|
2782
|
-
const treeEl = document.getElementById('a2a-tree');
|
|
2783
|
-
if (!rootId) return;
|
|
2784
|
-
treeEl.innerHTML = `<p style="color:var(--text-muted)">${T.loading}</p>`;
|
|
2785
|
-
try {
|
|
2786
|
-
const resp = await fetch(`/api/a2a/tree/${rootId}`);
|
|
2787
|
-
const j = await resp.json();
|
|
2788
|
-
if (!resp.ok) { treeEl.innerHTML = `<p style="color:#d00">${escapeHtml(j.error || 'failed')}</p>`; return; }
|
|
2789
|
-
const lines = [`<h3 style="margin-top:1em">🌳 ${T.a2aTreeRoot.replace('{id}', String(rootId))}</h3>`];
|
|
2790
|
-
function render(node, depth) {
|
|
2791
|
-
const indent = ' '.repeat(depth * 4);
|
|
2792
|
-
const icon = node.status === 'delivered' || node.status === 'completed' ? '✅'
|
|
2793
|
-
: node.status === 'failed' ? '❌'
|
|
2794
|
-
: node.status === 'running' ? '🔄' : '⏳';
|
|
2795
|
-
const preview = (node.prompt || '').slice(0, 70).replace(/\n/g, ' ');
|
|
2796
|
-
lines.push(`<div>${indent}${icon} <b>#${node.id}</b> ${escapeHtml(node.agent)} [${escapeHtml(node.status)}] ${escapeHtml(preview)}…</div>`);
|
|
2797
|
-
for (const f of (node.outputs || [])) {
|
|
2798
|
-
const fmt = (n) => n < 1024 ? `${n} B` : n < 1024*1024 ? `${(n/1024).toFixed(1)} KB` : `${(n/1024/1024).toFixed(1)} MB`;
|
|
2799
|
-
lines.push(`<div>${indent} 📎 <a href="/api/artifacts/${node.id}/file/${encodeURIComponent(f.name)}" target="_blank">${escapeHtml(f.name)}</a> (${fmt(f.bytes)})</div>`);
|
|
2800
|
-
}
|
|
2801
|
-
for (const c of (node.children || [])) render(c, depth + 1);
|
|
2802
|
-
}
|
|
2803
|
-
render(j.tree, 0);
|
|
2804
|
-
treeEl.innerHTML = `<div style="font-family:monospace;font-size:13px;line-height:1.6">${lines.join('')}</div>`;
|
|
2805
|
-
} catch (err) {
|
|
2806
|
-
treeEl.innerHTML = `<p style="color:#d00">Tree failed: ${escapeHtml(String(err))}</p>`;
|
|
2807
|
-
}
|
|
2808
|
-
}
|
|
2809
|
-
|
|
2810
|
-
document.getElementById('btn-a2a-refresh')?.addEventListener('click', loadA2A);
|
|
2811
|
-
document.getElementById('btn-a2a-tree')?.addEventListener('click', () => {
|
|
2812
|
-
const id = document.getElementById('a2a-tree-id').value;
|
|
2813
|
-
if (id) loadA2ATree(id);
|
|
2814
|
-
});
|
|
2815
|
-
|
|
2816
|
-
// ─── v1.3 — Cost & Health tab ────────────────────────────────────
|
|
2817
|
-
let costChart = null;
|
|
2818
|
-
let costChartJsLoaded = null;
|
|
2819
|
-
|
|
2820
|
-
function ensureChartJs() {
|
|
2821
|
-
if (window.Chart) return Promise.resolve();
|
|
2822
|
-
if (costChartJsLoaded) return costChartJsLoaded;
|
|
2823
|
-
// v1.2.3 — try the vendored copy first (no CDN dependency, works in
|
|
2824
|
-
// CN where jsdelivr is often blocked). Fall back to jsdelivr if the
|
|
2825
|
-
// vendor file is missing (e.g. dev hot-reload before build copied it).
|
|
2826
|
-
const tryLoad = (src) => new Promise((resolve, reject) => {
|
|
2827
|
-
const s = document.createElement('script');
|
|
2828
|
-
s.src = src;
|
|
2829
|
-
s.crossOrigin = 'anonymous';
|
|
2830
|
-
s.onload = () => resolve();
|
|
2831
|
-
s.onerror = () => reject(new Error('failed: ' + src));
|
|
2832
|
-
document.head.appendChild(s);
|
|
2833
|
-
});
|
|
2834
|
-
costChartJsLoaded = tryLoad('/vendor/chart.umd.min.js')
|
|
2835
|
-
.catch(() => tryLoad('https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js'));
|
|
2836
|
-
return costChartJsLoaded;
|
|
2837
|
-
}
|
|
2838
|
-
|
|
2839
|
-
function fmtCost(n) { return '$' + (Math.round(n * 10000) / 10000).toFixed(4); }
|
|
2840
|
-
function fmtMs(n) {
|
|
2841
|
-
if (n < 1000) return n + 'ms';
|
|
2842
|
-
if (n < 60000) return (n / 1000).toFixed(1) + 's';
|
|
2843
|
-
return (n / 60000).toFixed(1) + 'm';
|
|
2844
|
-
}
|
|
2845
|
-
function fmtPct(n) { return (n * 100).toFixed(2) + '%'; }
|
|
2846
|
-
|
|
2847
|
-
async function loadCost() {
|
|
2848
|
-
const days = parseInt(document.getElementById('cost-days').value, 10) || 7;
|
|
2849
|
-
try {
|
|
2850
|
-
const data = await api('/api/health/summary?days=' + days);
|
|
2851
|
-
renderCostKpis(data);
|
|
2852
|
-
renderCostRange(data);
|
|
2853
|
-
let chartLoadErr = null;
|
|
2854
|
-
await ensureChartJs().catch((e) => { chartLoadErr = e; });
|
|
2855
|
-
if (chartLoadErr) {
|
|
2856
|
-
// Visible hint when both the vendored copy AND CDN fail
|
|
2857
|
-
// (cached browser, blocked CDN, etc). Without this, the chart
|
|
2858
|
-
// area is empty + silent.
|
|
2859
|
-
const c = document.getElementById('cost-trend');
|
|
2860
|
-
if (c && c.parentElement) {
|
|
2861
|
-
c.parentElement.innerHTML = `<p style="color:#d00;font-size:12px">
|
|
2862
|
-
chart.js 加载失败:${escapeHtmlSafe(String(chartLoadErr.message || chartLoadErr))}
|
|
2863
|
-
(检查 /vendor/chart.umd.min.js 是否存在,或浏览器是否屏蔽了 jsdelivr)
|
|
2864
|
-
</p>`;
|
|
2865
|
-
}
|
|
2866
|
-
} else {
|
|
2867
|
-
renderCostTrend(data);
|
|
2868
|
-
}
|
|
2869
|
-
} catch (err) {
|
|
2870
|
-
document.getElementById('cost-kpi').innerHTML = '<div class="error">加载失败: ' + (err.message || err) + '</div>';
|
|
2871
|
-
}
|
|
2872
|
-
// Load three top-N tables in parallel.
|
|
2873
|
-
for (const dim of ['user', 'agent', 'platform']) {
|
|
2874
|
-
const sel = document.querySelector('.cost-topn-by[data-target="' + dim + '"]');
|
|
2875
|
-
loadTopN(dim, sel ? sel.value : 'calls');
|
|
2876
|
-
}
|
|
2877
|
-
}
|
|
2878
|
-
|
|
2879
|
-
function renderCostKpis(data) {
|
|
2880
|
-
const t = data.totals;
|
|
2881
|
-
const card = (label, value, sub) => `
|
|
2882
|
-
<div style="border:1px solid var(--border,#d0d7de);border-radius:8px;padding:12px">
|
|
2883
|
-
<div style="font-size:11px;color:var(--muted,#656d76);text-transform:uppercase;letter-spacing:.5px">${label}</div>
|
|
2884
|
-
<div style="font-size:22px;font-weight:600;margin-top:4px">${value}</div>
|
|
2885
|
-
${sub ? '<div style="font-size:11px;color:var(--muted,#656d76);margin-top:2px">' + sub + '</div>' : ''}
|
|
2886
|
-
</div>`;
|
|
2887
|
-
document.getElementById('cost-kpi').innerHTML = [
|
|
2888
|
-
card('调用数', t.calls.toLocaleString()),
|
|
2889
|
-
card('成本', fmtCost(t.cost), '近 ' + data.days + ' 天'),
|
|
2890
|
-
card('错误率', fmtPct(t.errorRate), t.errors + ' / ' + t.calls),
|
|
2891
|
-
card('平均耗时', fmtMs(t.avgLatencyMs), 'p95: ' + fmtMs(t.p95LatencyMs)),
|
|
2892
|
-
].join('');
|
|
2893
|
-
}
|
|
2894
|
-
|
|
2895
|
-
function renderCostRange(data) {
|
|
2896
|
-
document.getElementById('cost-range').textContent = data.since + ' → ' + data.until;
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
function renderCostTrend(data) {
|
|
2900
|
-
const canvas = document.getElementById('cost-trend');
|
|
2901
|
-
if (!canvas || !window.Chart) return;
|
|
2902
|
-
const metric = (document.querySelector('input[name="cost-trend-metric"]:checked')?.value) || 'calls';
|
|
2903
|
-
const labels = data.byDay.map(d => d.date);
|
|
2904
|
-
const values = data.byDay.map(d => d[metric]);
|
|
2905
|
-
const labelMap = { calls: '调用数', cost: '成本($)', errors: '错误数', avgLatencyMs: '平均耗时(ms)' };
|
|
2906
|
-
if (costChart) costChart.destroy();
|
|
2907
|
-
costChart = new Chart(canvas, {
|
|
2908
|
-
type: 'line',
|
|
2909
|
-
data: {
|
|
2910
|
-
labels,
|
|
2911
|
-
datasets: [{
|
|
2912
|
-
label: labelMap[metric] || metric,
|
|
2913
|
-
data: values,
|
|
2914
|
-
borderColor: '#2563eb',
|
|
2915
|
-
backgroundColor: 'rgba(37,99,235,0.10)',
|
|
2916
|
-
tension: 0.25,
|
|
2917
|
-
fill: true,
|
|
2918
|
-
}],
|
|
2919
|
-
},
|
|
2920
|
-
options: {
|
|
2921
|
-
responsive: true,
|
|
2922
|
-
maintainAspectRatio: false,
|
|
2923
|
-
plugins: { legend: { display: false } },
|
|
2924
|
-
scales: { y: { beginAtZero: true } },
|
|
2925
|
-
},
|
|
2926
|
-
});
|
|
2927
|
-
}
|
|
2928
|
-
|
|
2929
|
-
async function loadTopN(dim, by) {
|
|
2930
|
-
const wrap = document.getElementById('cost-topn-' + dim);
|
|
2931
|
-
if (!wrap) return;
|
|
2932
|
-
wrap.innerHTML = '<div class="muted">加载中…</div>';
|
|
2933
|
-
const days = parseInt(document.getElementById('cost-days').value, 10) || 7;
|
|
2934
|
-
try {
|
|
2935
|
-
const data = await api('/api/health/topn?dim=' + dim + '&by=' + by + '&days=' + days + '&limit=10');
|
|
2936
|
-
if (!data.items.length) { wrap.innerHTML = '<div class="muted">(无数据)</div>'; return; }
|
|
2937
|
-
wrap.innerHTML = '<table style="width:100%;font-size:12px;border-collapse:collapse">' +
|
|
2938
|
-
'<thead><tr style="border-bottom:1px solid var(--border,#d0d7de)">' +
|
|
2939
|
-
'<th style="text-align:left;padding:4px 6px;font-weight:600">key</th>' +
|
|
2940
|
-
'<th style="text-align:right;padding:4px 6px">calls</th>' +
|
|
2941
|
-
'<th style="text-align:right;padding:4px 6px">cost</th>' +
|
|
2942
|
-
'<th style="text-align:right;padding:4px 6px">err</th>' +
|
|
2943
|
-
'<th style="text-align:right;padding:4px 6px">avg ms</th>' +
|
|
2944
|
-
'</tr></thead><tbody>' +
|
|
2945
|
-
data.items.map(it => `<tr style="border-bottom:1px solid var(--border,#d0d7de)">
|
|
2946
|
-
<td style="padding:4px 6px;word-break:break-all;font-family:ui-monospace,monospace">${escapeHtmlSafe(it.key).slice(0, 50)}</td>
|
|
2947
|
-
<td style="text-align:right;padding:4px 6px">${it.calls}</td>
|
|
2948
|
-
<td style="text-align:right;padding:4px 6px">${fmtCost(it.cost)}</td>
|
|
2949
|
-
<td style="text-align:right;padding:4px 6px">${it.errors}</td>
|
|
2950
|
-
<td style="text-align:right;padding:4px 6px">${fmtMs(it.avgLatencyMs)}</td>
|
|
2951
|
-
</tr>`).join('') + '</tbody></table>';
|
|
2952
|
-
} catch (err) {
|
|
2953
|
-
wrap.innerHTML = '<div class="error">加载失败: ' + (err.message || err) + '</div>';
|
|
2954
|
-
}
|
|
2955
|
-
}
|
|
2956
|
-
|
|
2957
|
-
function escapeHtmlSafe(s) {
|
|
2958
|
-
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2959
|
-
}
|
|
2960
|
-
|
|
2961
|
-
document.getElementById('btn-cost-refresh')?.addEventListener('click', loadCost);
|
|
2962
|
-
document.getElementById('cost-days')?.addEventListener('change', loadCost);
|
|
2963
|
-
document.querySelectorAll('input[name="cost-trend-metric"]').forEach(el => {
|
|
2964
|
-
el.addEventListener('change', () => loadCost());
|
|
2965
|
-
});
|
|
2966
|
-
document.querySelectorAll('.cost-topn-by').forEach(el => {
|
|
2967
|
-
el.addEventListener('change', () => loadTopN(el.dataset.target, el.value));
|
|
2968
|
-
});
|
|
2969
|
-
|
|
2970
|
-
// ─── v1.5 — Memory admin tab ──────────────────────────────────────
|
|
2971
|
-
let memCurrentUser = '';
|
|
2972
|
-
let memFactsPage = { offset: 0, limit: 20, total: 0 };
|
|
2973
|
-
|
|
2974
|
-
function fmtMemDate(unix) {
|
|
2975
|
-
if (!unix) return '—';
|
|
2976
|
-
return new Date(unix * 1000).toISOString().slice(0, 19).replace('T', ' ');
|
|
2977
|
-
}
|
|
2978
|
-
|
|
2979
|
-
// ─── v1.6 vector backend UI ────────────────────────────────────
|
|
2980
|
-
let memVecPollTimer = null;
|
|
2981
|
-
|
|
2982
|
-
function showVecBackendCfg(backend) {
|
|
2983
|
-
const local = document.getElementById('mem-vec-local-cfg');
|
|
2984
|
-
const openai = document.getElementById('mem-vec-openai-cfg');
|
|
2985
|
-
if (local) local.style.display = backend === 'local' ? 'block' : 'none';
|
|
2986
|
-
if (openai) openai.style.display = backend === 'openai' ? 'block' : 'none';
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
async function loadMemVecStatus() {
|
|
2990
|
-
try {
|
|
2991
|
-
const data = await api('/api/memory/vector/status' + (memCurrentUser ? '?user_key=' + encodeURIComponent(memCurrentUser) : ''));
|
|
2992
|
-
const s = data.status || {};
|
|
2993
|
-
const cov = data.coverage || { total: 0, withEmbedding: 0 };
|
|
2994
|
-
const badge = document.getElementById('mem-vec-status-badge');
|
|
2995
|
-
if (badge) {
|
|
2996
|
-
if (s.ready) {
|
|
2997
|
-
badge.textContent = `✓ ${s.backend} · dim ${s.dims} · 索引 ${cov.withEmbedding}/${cov.total}`;
|
|
2998
|
-
badge.style.color = '#16a34a';
|
|
2999
|
-
} else if (s.downloaded) {
|
|
3000
|
-
// Model files on disk but pipeline not loaded yet — happens
|
|
3001
|
-
// after a service restart while the auto-warm finishes.
|
|
3002
|
-
badge.textContent = `↻ ${s.backend} · 模型已下载,加载中…`;
|
|
3003
|
-
badge.style.color = '#d97706';
|
|
3004
|
-
} else {
|
|
3005
|
-
badge.textContent = `○ ${s.backend} · 未就绪${s.lastError ? ' (' + s.lastError.slice(0, 60) + ')' : ''}`;
|
|
3006
|
-
badge.style.color = 'var(--muted, #656d76)';
|
|
3007
|
-
}
|
|
3008
|
-
}
|
|
3009
|
-
const detail = document.getElementById('mem-vec-status-detail');
|
|
3010
|
-
if (detail) {
|
|
3011
|
-
const lines = [];
|
|
3012
|
-
if (s.detail && s.detail.progress) {
|
|
3013
|
-
const p = s.detail.progress;
|
|
3014
|
-
if (p.phase === 'downloading') {
|
|
3015
|
-
const pct = p.bytesTotal > 0 ? Math.round(p.bytesDone / p.bytesTotal * 100) : 0;
|
|
3016
|
-
lines.push(`📥 下载中:${p.filesDone}/${p.filesTotal} 文件,${(p.bytesDone / 1024 / 1024).toFixed(1)} / ${(p.bytesTotal / 1024 / 1024).toFixed(1)} MB (${pct}%)`);
|
|
3017
|
-
} else if (p.phase === 'ready') {
|
|
3018
|
-
lines.push(`✓ 模型就绪(耗时 ${((p.finishedAt - p.startedAt) / 1000).toFixed(1)}s)`);
|
|
3019
|
-
} else if (p.phase === 'failed') {
|
|
3020
|
-
lines.push(`❌ 下载失败:${escapeHtmlSafe(p.error || '未知错误')}`);
|
|
3021
|
-
}
|
|
3022
|
-
}
|
|
3023
|
-
if (data.jobs && data.jobs.length > 0) {
|
|
3024
|
-
data.jobs.forEach(j => {
|
|
3025
|
-
const elapsed = ((j.finishedAt || Date.now()) - j.startedAt) / 1000;
|
|
3026
|
-
// Note: lines already contain escaped fragments — joining
|
|
3027
|
-
// raw avoids the double-escape that mangled `<`/`&` chars
|
|
3028
|
-
// in error messages.
|
|
3029
|
-
lines.push(`[${escapeHtmlSafe(j.kind)}] ${escapeHtmlSafe(j.phase)} · ${elapsed.toFixed(1)}s · ${escapeHtmlSafe(j.message)}`);
|
|
3030
|
-
});
|
|
3031
|
-
}
|
|
3032
|
-
detail.innerHTML = lines.join('<br>') || '';
|
|
3033
|
-
}
|
|
3034
|
-
// Update download button if a job is in flight.
|
|
3035
|
-
const dlBtn = document.getElementById('btn-mem-vec-download');
|
|
3036
|
-
if (dlBtn) {
|
|
3037
|
-
const hasDownloading = (data.jobs || []).some(j => j.kind === 'download' && j.phase === 'running');
|
|
3038
|
-
dlBtn.disabled = hasDownloading || s.ready || s.downloaded;
|
|
3039
|
-
if (s.ready) dlBtn.textContent = '✓ 已就绪';
|
|
3040
|
-
else if (s.downloaded) dlBtn.textContent = '✓ 已下载(加载中)';
|
|
3041
|
-
else if (hasDownloading) dlBtn.textContent = '⏳ 下载中…';
|
|
3042
|
-
else dlBtn.textContent = '📥 下载模型(首次 5-10 min,建议 WiFi)';
|
|
3043
|
-
}
|
|
3044
|
-
return data;
|
|
3045
|
-
} catch (err) {
|
|
3046
|
-
const badge = document.getElementById('mem-vec-status-badge');
|
|
3047
|
-
if (badge) badge.textContent = '状态加载失败: ' + (err.message || err);
|
|
3048
|
-
}
|
|
3049
|
-
}
|
|
3050
|
-
|
|
3051
|
-
function startMemVecPolling() {
|
|
3052
|
-
stopMemVecPolling();
|
|
3053
|
-
memVecPollTimer = setInterval(async () => {
|
|
3054
|
-
const data = await loadMemVecStatus();
|
|
3055
|
-
// P1-8: auto-stop once there's nothing to watch. Without this the
|
|
3056
|
-
// 2 s poll keeps firing forever and floods the server log.
|
|
3057
|
-
const s = (data && data.status) || {};
|
|
3058
|
-
const running = ((data && data.jobs) || []).some(j => j.phase === 'running');
|
|
3059
|
-
if (!running && s.ready) stopMemVecPolling();
|
|
3060
|
-
if (!running && !s.ready && s.backend === 'off') stopMemVecPolling();
|
|
3061
|
-
}, 2000);
|
|
3062
|
-
}
|
|
3063
|
-
function stopMemVecPolling() {
|
|
3064
|
-
if (memVecPollTimer) { clearInterval(memVecPollTimer); memVecPollTimer = null; }
|
|
3065
|
-
}
|
|
3066
|
-
|
|
3067
|
-
async function checkMemoryEnabledThen(fn) {
|
|
3068
|
-
try {
|
|
3069
|
-
const data = await api('/api/env');
|
|
3070
|
-
const env = data.env || {};
|
|
3071
|
-
const raw = String(env.IMHUB_MEMORY_ENABLED || '').toLowerCase();
|
|
3072
|
-
const enabled = raw === '1' || raw === 'true' || raw === 'yes';
|
|
3073
|
-
if (enabled) {
|
|
3074
|
-
fn();
|
|
3075
|
-
return;
|
|
3076
|
-
}
|
|
3077
|
-
// Render a disabled state in the vector card area so the user
|
|
3078
|
-
// understands why nothing else loads.
|
|
3079
|
-
const badge = document.getElementById('mem-vec-status-badge');
|
|
3080
|
-
if (badge) {
|
|
3081
|
-
badge.textContent = (T && T.vecMemoryDisabled) || '○ 记忆未启用(在 Settings → 自动化记忆开启)';
|
|
3082
|
-
badge.style.color = 'var(--muted, #656d76)';
|
|
3083
|
-
}
|
|
3084
|
-
} catch { /* fall through silently */ }
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
async function loadMemVecConfig() {
|
|
3088
|
-
try {
|
|
3089
|
-
// P1-7: do NOT auto-reveal secrets on tab open. Load masked; user
|
|
3090
|
-
// explicitly clicks 「🔓 显示」 to unmask the OpenAI key when they
|
|
3091
|
-
// want to edit it.
|
|
3092
|
-
const data = await api('/api/env');
|
|
3093
|
-
const env = data.env || {};
|
|
3094
|
-
const backend = (env['IMHUB_MEMORY_VECTOR_BACKEND'] || 'off').toLowerCase();
|
|
3095
|
-
document.getElementById('mem-vec-backend').value = ['off', 'local', 'openai'].includes(backend) ? backend : 'off';
|
|
3096
|
-
const lmPreset = document.getElementById('mem-vec-local-preset');
|
|
3097
|
-
const lmInput = document.getElementById('mem-vec-local-model');
|
|
3098
|
-
const presetVals = ['Xenova/bge-small-zh-v1.5','Xenova/bge-base-zh-v1.5','Xenova/bge-large-zh-v1.5'];
|
|
3099
|
-
const customLm = (env['IMHUB_MEMORY_VECTOR_LOCAL_MODEL'] || '').trim();
|
|
3100
|
-
if (customLm && presetVals.includes(customLm)) {
|
|
3101
|
-
lmPreset.value = customLm;
|
|
3102
|
-
lmInput.style.display = 'none';
|
|
3103
|
-
lmInput.value = '';
|
|
3104
|
-
} else if (customLm) {
|
|
3105
|
-
lmPreset.value = '__custom__';
|
|
3106
|
-
lmInput.value = customLm;
|
|
3107
|
-
lmInput.style.display = 'block';
|
|
3108
|
-
} else {
|
|
3109
|
-
lmPreset.value = 'Xenova/bge-base-zh-v1.5';
|
|
3110
|
-
lmInput.style.display = 'none';
|
|
3111
|
-
lmInput.value = '';
|
|
3112
|
-
}
|
|
3113
|
-
document.getElementById('mem-vec-openai-url').value = env['IMHUB_MEMORY_VECTOR_OPENAI_BASE_URL'] || '';
|
|
3114
|
-
document.getElementById('mem-vec-openai-model').value = env['IMHUB_MEMORY_VECTOR_OPENAI_MODEL'] || '';
|
|
3115
|
-
document.getElementById('mem-vec-openai-key').value = env['IMHUB_MEMORY_VECTOR_OPENAI_API_KEY'] || '';
|
|
3116
|
-
showVecBackendCfg(document.getElementById('mem-vec-backend').value);
|
|
3117
|
-
} catch { /* ignore */ }
|
|
3118
|
-
}
|
|
3119
|
-
|
|
3120
|
-
document.getElementById('mem-vec-backend')?.addEventListener('change', (e) => {
|
|
3121
|
-
showVecBackendCfg(e.target.value);
|
|
3122
|
-
});
|
|
3123
|
-
document.getElementById('mem-vec-local-preset')?.addEventListener('change', (e) => {
|
|
3124
|
-
const lm = document.getElementById('mem-vec-local-model');
|
|
3125
|
-
if (e.target.value === '__custom__') {
|
|
3126
|
-
lm.style.display = 'block';
|
|
3127
|
-
lm.focus();
|
|
3128
|
-
} else {
|
|
3129
|
-
lm.style.display = 'none';
|
|
3130
|
-
}
|
|
3131
|
-
});
|
|
3132
|
-
document.getElementById('btn-mem-vec-openai-reveal')?.addEventListener('click', async () => {
|
|
3133
|
-
const input = document.getElementById('mem-vec-openai-key');
|
|
3134
|
-
try {
|
|
3135
|
-
const data = await api('/api/env?reveal=1');
|
|
3136
|
-
input.value = (data.env && data.env['IMHUB_MEMORY_VECTOR_OPENAI_API_KEY']) || '';
|
|
3137
|
-
input.readOnly = false;
|
|
3138
|
-
input.style.background = '';
|
|
3139
|
-
input.style.color = '';
|
|
3140
|
-
input.type = 'text';
|
|
3141
|
-
input.focus();
|
|
3142
|
-
const btn = document.getElementById('btn-mem-vec-openai-reveal');
|
|
3143
|
-
if (btn) btn.style.display = 'none';
|
|
3144
|
-
} catch (err) {
|
|
3145
|
-
alert('显示失败: ' + (err.message || err));
|
|
3146
|
-
}
|
|
3147
|
-
});
|
|
3148
|
-
// True when the OpenAI key field holds the actual key (user clicked
|
|
3149
|
-
// 「显示」). False when it's still the masked default — save handler
|
|
3150
|
-
// omits the field in that case so the server doesn't overwrite.
|
|
3151
|
-
function openAiKeyWasRevealed() {
|
|
3152
|
-
const input = document.getElementById('mem-vec-openai-key');
|
|
3153
|
-
return input && !input.readOnly;
|
|
3154
|
-
}
|
|
3155
|
-
function resolveLocalModel() {
|
|
3156
|
-
const preset = document.getElementById('mem-vec-local-preset').value;
|
|
3157
|
-
if (preset === '__custom__') {
|
|
3158
|
-
return document.getElementById('mem-vec-local-model').value.trim() || null;
|
|
3159
|
-
}
|
|
3160
|
-
if (preset === 'Xenova/bge-base-zh-v1.5') return null; // default
|
|
3161
|
-
return preset;
|
|
3162
|
-
}
|
|
3163
|
-
document.getElementById('btn-mem-vec-save')?.addEventListener('click', async () => {
|
|
3164
|
-
const backend = document.getElementById('mem-vec-backend').value;
|
|
3165
|
-
const updates = {
|
|
3166
|
-
IMHUB_MEMORY_VECTOR_BACKEND: backend,
|
|
3167
|
-
IMHUB_MEMORY_VECTOR_LOCAL_MODEL: resolveLocalModel(),
|
|
3168
|
-
IMHUB_MEMORY_VECTOR_OPENAI_BASE_URL: document.getElementById('mem-vec-openai-url').value.trim() || null,
|
|
3169
|
-
IMHUB_MEMORY_VECTOR_OPENAI_MODEL: document.getElementById('mem-vec-openai-model').value.trim() || null,
|
|
3170
|
-
};
|
|
3171
|
-
// Only send the API key when the user actually revealed + edited it.
|
|
3172
|
-
// Otherwise the input still holds the masked value; the server-side
|
|
3173
|
-
// guard would skip it anyway, but omitting client-side avoids the
|
|
3174
|
-
// round-trip and makes intent explicit.
|
|
3175
|
-
if (openAiKeyWasRevealed()) {
|
|
3176
|
-
updates.IMHUB_MEMORY_VECTOR_OPENAI_API_KEY =
|
|
3177
|
-
document.getElementById('mem-vec-openai-key').value.trim() || null;
|
|
3178
|
-
}
|
|
3179
|
-
const detail = document.getElementById('mem-vec-status-detail');
|
|
3180
|
-
try {
|
|
3181
|
-
await api('/api/env', { method: 'PUT', body: JSON.stringify({ updates }) });
|
|
3182
|
-
if (detail) detail.innerHTML = '✓ 已保存。切换后端后注意:embedding 模型变了,旧的向量索引不可比,建议「清空向量索引」+「回填全部」。';
|
|
3183
|
-
await loadMemVecStatus();
|
|
3184
|
-
} catch (err) {
|
|
3185
|
-
if (detail) detail.innerHTML = '保存失败: ' + escapeHtmlSafe(err.message || String(err));
|
|
3186
|
-
}
|
|
3187
|
-
});
|
|
3188
|
-
// Auto-save the current dropdown selection to env before the action,
|
|
3189
|
-
// so users who pick "local" / "openai" and immediately click download
|
|
3190
|
-
// / test don't get a "backend is off" 400 from the previous saved state.
|
|
3191
|
-
async function ensureVecBackendApplied(requiredBackend) {
|
|
3192
|
-
const cur = document.getElementById('mem-vec-backend').value;
|
|
3193
|
-
if (cur !== requiredBackend) {
|
|
3194
|
-
document.getElementById('mem-vec-backend').value = requiredBackend;
|
|
3195
|
-
showVecBackendCfg(requiredBackend);
|
|
3196
|
-
}
|
|
3197
|
-
const updates = {
|
|
3198
|
-
IMHUB_MEMORY_VECTOR_BACKEND: requiredBackend,
|
|
3199
|
-
IMHUB_MEMORY_VECTOR_LOCAL_MODEL: resolveLocalModel(),
|
|
3200
|
-
IMHUB_MEMORY_VECTOR_OPENAI_BASE_URL: document.getElementById('mem-vec-openai-url').value.trim() || null,
|
|
3201
|
-
IMHUB_MEMORY_VECTOR_OPENAI_MODEL: document.getElementById('mem-vec-openai-model').value.trim() || null,
|
|
3202
|
-
};
|
|
3203
|
-
if (openAiKeyWasRevealed()) {
|
|
3204
|
-
updates.IMHUB_MEMORY_VECTOR_OPENAI_API_KEY =
|
|
3205
|
-
document.getElementById('mem-vec-openai-key').value.trim() || null;
|
|
3206
|
-
}
|
|
3207
|
-
await api('/api/env', { method: 'PUT', body: JSON.stringify({ updates }) });
|
|
3208
|
-
}
|
|
3209
|
-
|
|
3210
|
-
document.getElementById('btn-mem-vec-download')?.addEventListener('click', async () => {
|
|
3211
|
-
try {
|
|
3212
|
-
await ensureVecBackendApplied('local');
|
|
3213
|
-
await api('/api/memory/vector/download', { method: 'POST' });
|
|
3214
|
-
startMemVecPolling();
|
|
3215
|
-
} catch (err) {
|
|
3216
|
-
alert('启动下载失败: ' + (err.message || err));
|
|
3217
|
-
}
|
|
3218
|
-
});
|
|
3219
|
-
document.getElementById('btn-mem-vec-test')?.addEventListener('click', async () => {
|
|
3220
|
-
const resultEl = document.getElementById('mem-vec-test-result');
|
|
3221
|
-
if (resultEl) resultEl.textContent = '⏳ 测试中…';
|
|
3222
|
-
try {
|
|
3223
|
-
await ensureVecBackendApplied('openai');
|
|
3224
|
-
const r = await api('/api/memory/vector/test', { method: 'POST' });
|
|
3225
|
-
if (r.ok) {
|
|
3226
|
-
if (resultEl) resultEl.textContent = `✓ 通过 · dim ${r.dims} · ${r.latencyMs}ms`;
|
|
3227
|
-
} else {
|
|
3228
|
-
if (resultEl) resultEl.textContent = '❌ ' + (r.error || '未知错误');
|
|
3229
|
-
}
|
|
3230
|
-
} catch (err) {
|
|
3231
|
-
if (resultEl) resultEl.textContent = '❌ ' + (err.message || err);
|
|
3232
|
-
}
|
|
3233
|
-
});
|
|
3234
|
-
document.getElementById('btn-mem-vec-backfill')?.addEventListener('click', async () => {
|
|
3235
|
-
if (!confirm('回填所有未索引事实的 embedding?(按当前后端模型)')) return;
|
|
3236
|
-
try {
|
|
3237
|
-
await api('/api/memory/vector/backfill' + (memCurrentUser ? '?user_key=' + encodeURIComponent(memCurrentUser) : ''), {
|
|
3238
|
-
method: 'POST',
|
|
3239
|
-
body: JSON.stringify({}),
|
|
3240
|
-
});
|
|
3241
|
-
startMemVecPolling();
|
|
3242
|
-
} catch (err) {
|
|
3243
|
-
alert('启动回填失败: ' + (err.message || err));
|
|
3244
|
-
}
|
|
3245
|
-
});
|
|
3246
|
-
document.getElementById('btn-mem-vec-clear')?.addEventListener('click', async () => {
|
|
3247
|
-
if (!confirm('清空所有 embedding?(不会删 fact 本身;下次回填可重建)')) return;
|
|
3248
|
-
try {
|
|
3249
|
-
const r = await api('/api/memory/vector/clear' + (memCurrentUser ? '?user_key=' + encodeURIComponent(memCurrentUser) : ''), {
|
|
3250
|
-
method: 'POST',
|
|
3251
|
-
});
|
|
3252
|
-
alert(`已清空 ${r.cleared} 条 embedding`);
|
|
3253
|
-
await loadMemVecStatus();
|
|
3254
|
-
} catch (err) {
|
|
3255
|
-
alert('失败: ' + (err.message || err));
|
|
3256
|
-
}
|
|
3257
|
-
});
|
|
3258
|
-
|
|
3259
|
-
async function loadMemoryUsers() {
|
|
3260
|
-
const select = document.getElementById('mem-user-select');
|
|
3261
|
-
if (!select) return;
|
|
3262
|
-
try {
|
|
3263
|
-
const data = await api('/api/memory/users');
|
|
3264
|
-
const prev = memCurrentUser || select.value;
|
|
3265
|
-
select.innerHTML = '<option value="">— 选择用户 —</option>' +
|
|
3266
|
-
data.users.map(u =>
|
|
3267
|
-
`<option value="${escapeHtmlSafe(u.user_key)}">${escapeHtmlSafe(u.user_key)} · ${u.fact_count} 条事实 · ${u.has_persona ? '✓ 记忆画像' : '无记忆画像'}</option>`
|
|
3268
|
-
).join('');
|
|
3269
|
-
if (prev && data.users.some(u => u.user_key === prev)) {
|
|
3270
|
-
select.value = prev;
|
|
3271
|
-
memCurrentUser = prev;
|
|
3272
|
-
loadMemoryUserDetail();
|
|
3273
|
-
}
|
|
3274
|
-
} catch (err) {
|
|
3275
|
-
select.innerHTML = '<option value="">加载失败: ' + (err.message || err) + '</option>';
|
|
3276
|
-
}
|
|
3277
|
-
}
|
|
3278
|
-
|
|
3279
|
-
async function loadMemoryUserDetail() {
|
|
3280
|
-
if (!memCurrentUser) return;
|
|
3281
|
-
// Update export link
|
|
3282
|
-
const exportLink = document.getElementById('btn-mem-export');
|
|
3283
|
-
if (exportLink) {
|
|
3284
|
-
exportLink.href = '/api/memory/export?user_key=' + encodeURIComponent(memCurrentUser);
|
|
3285
|
-
exportLink.style.display = 'inline';
|
|
3286
|
-
}
|
|
3287
|
-
// Persona
|
|
3288
|
-
const personaText = document.getElementById('mem-persona-text');
|
|
3289
|
-
const personaMeta = document.getElementById('mem-persona-meta');
|
|
3290
|
-
try {
|
|
3291
|
-
const p = await api('/api/memory/persona?user_key=' + encodeURIComponent(memCurrentUser));
|
|
3292
|
-
if (personaText) personaText.value = p.summary || '';
|
|
3293
|
-
if (personaMeta) personaMeta.textContent = 'updated ' + fmtMemDate(p.updated_at);
|
|
3294
|
-
} catch (err) {
|
|
3295
|
-
if (err.status === 404) {
|
|
3296
|
-
if (personaText) personaText.value = '';
|
|
3297
|
-
if (personaMeta) personaMeta.textContent = '(暂无记忆画像)';
|
|
3298
|
-
} else {
|
|
3299
|
-
if (personaMeta) personaMeta.textContent = '加载失败: ' + (err.message || err);
|
|
3300
|
-
}
|
|
3301
|
-
}
|
|
3302
|
-
// Facts
|
|
3303
|
-
memFactsPage = { offset: 0, limit: 20, total: 0 };
|
|
3304
|
-
await loadMemoryFacts();
|
|
3305
|
-
}
|
|
3306
|
-
|
|
3307
|
-
async function loadMemoryFacts() {
|
|
3308
|
-
if (!memCurrentUser) return;
|
|
3309
|
-
const listEl = document.getElementById('mem-facts-list');
|
|
3310
|
-
const totalEl = document.getElementById('mem-facts-total');
|
|
3311
|
-
const pageLabel = document.getElementById('mem-facts-pagelabel');
|
|
3312
|
-
if (listEl) listEl.innerHTML = '<span class="muted">加载中…</span>';
|
|
3313
|
-
const query = document.getElementById('mem-facts-query')?.value?.trim() || '';
|
|
3314
|
-
const category = document.getElementById('mem-facts-category')?.value || '';
|
|
3315
|
-
const qs = new URLSearchParams({
|
|
3316
|
-
user_key: memCurrentUser,
|
|
3317
|
-
limit: String(memFactsPage.limit),
|
|
3318
|
-
offset: String(memFactsPage.offset),
|
|
3319
|
-
});
|
|
3320
|
-
if (query) qs.append('query', query);
|
|
3321
|
-
if (category) qs.append('category', category);
|
|
3322
|
-
try {
|
|
3323
|
-
const data = await api('/api/memory/facts?' + qs.toString());
|
|
3324
|
-
memFactsPage.total = data.total;
|
|
3325
|
-
if (totalEl) totalEl.textContent = `(共 ${data.total} 条)`;
|
|
3326
|
-
if (pageLabel) {
|
|
3327
|
-
const start = data.total === 0 ? 0 : data.offset + 1;
|
|
3328
|
-
const end = Math.min(data.offset + data.limit, data.total);
|
|
3329
|
-
pageLabel.textContent = `${start}-${end} / ${data.total}`;
|
|
3330
|
-
}
|
|
3331
|
-
if (data.facts.length === 0) {
|
|
3332
|
-
listEl.innerHTML = '<div class="muted">(无匹配事实)</div>';
|
|
3333
|
-
return;
|
|
3334
|
-
}
|
|
3335
|
-
listEl.innerHTML = '<table style="width:100%;font-size:12px;border-collapse:collapse">' +
|
|
3336
|
-
'<thead><tr style="border-bottom:1px solid var(--border,#d0d7de)">' +
|
|
3337
|
-
'<th style="text-align:left;padding:4px 6px;width:50px">id</th>' +
|
|
3338
|
-
'<th style="text-align:left;padding:4px 6px">what</th>' +
|
|
3339
|
-
'<th style="text-align:left;padding:4px 6px;width:90px">category</th>' +
|
|
3340
|
-
'<th style="text-align:right;padding:4px 6px;width:60px">conf</th>' +
|
|
3341
|
-
'<th style="text-align:left;padding:4px 6px;width:140px">created</th>' +
|
|
3342
|
-
'<th style="text-align:center;padding:4px 6px;width:60px">删除</th>' +
|
|
3343
|
-
'</tr></thead><tbody>' +
|
|
3344
|
-
data.facts.map(f => `<tr style="border-bottom:1px solid var(--border,#d0d7de)" data-id="${f.id}">
|
|
3345
|
-
<td style="padding:4px 6px;font-family:ui-monospace,monospace">${f.id}</td>
|
|
3346
|
-
<td style="padding:4px 6px">${escapeHtmlSafe(f.what)}${f.who ? ' <span class="muted">(' + escapeHtmlSafe(f.who) + ')</span>' : ''}</td>
|
|
3347
|
-
<td style="padding:4px 6px"><code>${escapeHtmlSafe(f.category)}</code></td>
|
|
3348
|
-
<td style="text-align:right;padding:4px 6px">${(f.confidence * 100).toFixed(0)}%</td>
|
|
3349
|
-
<td style="padding:4px 6px;font-size:11px">${escapeHtmlSafe(String(f.created_at).replace('T', ' ').slice(0, 19))}</td>
|
|
3350
|
-
<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>
|
|
3351
|
-
</tr>`).join('') +
|
|
3352
|
-
'</tbody></table>';
|
|
3353
|
-
// Wire delete buttons.
|
|
3354
|
-
listEl.querySelectorAll('.btn-mem-del').forEach(b => {
|
|
3355
|
-
b.addEventListener('click', async () => {
|
|
3356
|
-
const id = parseInt(b.dataset.id, 10);
|
|
3357
|
-
if (!confirm(`删除事实 #${id}?`)) return;
|
|
3358
|
-
try {
|
|
3359
|
-
await api('/api/memory/facts/' + id + '?user_key=' + encodeURIComponent(memCurrentUser), { method: 'DELETE' });
|
|
3360
|
-
await loadMemoryFacts();
|
|
3361
|
-
} catch (err) {
|
|
3362
|
-
alert('删除失败: ' + (err.message || err));
|
|
3363
|
-
}
|
|
3364
|
-
});
|
|
3365
|
-
});
|
|
3366
|
-
} catch (err) {
|
|
3367
|
-
listEl.innerHTML = '<div class="error">加载失败: ' + (err.message || err) + '</div>';
|
|
3368
|
-
}
|
|
3369
|
-
}
|
|
3370
|
-
|
|
3371
|
-
document.getElementById('mem-user-select')?.addEventListener('change', (e) => {
|
|
3372
|
-
memCurrentUser = e.target.value;
|
|
3373
|
-
if (memCurrentUser) loadMemoryUserDetail();
|
|
3374
|
-
});
|
|
3375
|
-
document.getElementById('btn-mem-refresh-users')?.addEventListener('click', loadMemoryUsers);
|
|
3376
|
-
document.getElementById('btn-mem-facts-refresh')?.addEventListener('click', () => {
|
|
3377
|
-
memFactsPage.offset = 0;
|
|
3378
|
-
loadMemoryFacts();
|
|
3379
|
-
});
|
|
3380
|
-
document.getElementById('mem-facts-query')?.addEventListener('keypress', (e) => {
|
|
3381
|
-
if (e.key === 'Enter') { memFactsPage.offset = 0; loadMemoryFacts(); }
|
|
3382
|
-
});
|
|
3383
|
-
document.getElementById('mem-facts-category')?.addEventListener('change', () => {
|
|
3384
|
-
memFactsPage.offset = 0;
|
|
3385
|
-
loadMemoryFacts();
|
|
3386
|
-
});
|
|
3387
|
-
document.getElementById('btn-mem-facts-prev')?.addEventListener('click', () => {
|
|
3388
|
-
if (memFactsPage.offset > 0) {
|
|
3389
|
-
memFactsPage.offset = Math.max(0, memFactsPage.offset - memFactsPage.limit);
|
|
3390
|
-
loadMemoryFacts();
|
|
3391
|
-
}
|
|
3392
|
-
});
|
|
3393
|
-
document.getElementById('btn-mem-facts-next')?.addEventListener('click', () => {
|
|
3394
|
-
if (memFactsPage.offset + memFactsPage.limit < memFactsPage.total) {
|
|
3395
|
-
memFactsPage.offset += memFactsPage.limit;
|
|
3396
|
-
loadMemoryFacts();
|
|
3397
|
-
}
|
|
3398
|
-
});
|
|
3399
|
-
document.getElementById('btn-mem-persona-save')?.addEventListener('click', async () => {
|
|
3400
|
-
if (!memCurrentUser) return;
|
|
3401
|
-
const summary = document.getElementById('mem-persona-text')?.value?.trim() || '';
|
|
3402
|
-
if (!summary) { alert('记忆画像内容不能为空(要删除请用"删除记忆画像"按钮)'); return; }
|
|
3403
|
-
const status = document.getElementById('mem-persona-status');
|
|
3404
|
-
try {
|
|
3405
|
-
await api('/api/memory/persona?user_key=' + encodeURIComponent(memCurrentUser), {
|
|
3406
|
-
method: 'PUT',
|
|
3407
|
-
body: JSON.stringify({ summary }),
|
|
3408
|
-
});
|
|
3409
|
-
if (status) status.textContent = '✓ 已保存';
|
|
3410
|
-
await loadMemoryUserDetail();
|
|
3411
|
-
} catch (err) {
|
|
3412
|
-
if (status) status.textContent = '保存失败: ' + (err.message || err);
|
|
3413
|
-
}
|
|
3414
|
-
});
|
|
3415
|
-
document.getElementById('btn-mem-persona-delete')?.addEventListener('click', async () => {
|
|
3416
|
-
if (!memCurrentUser) return;
|
|
3417
|
-
if (!confirm('删除此用户的记忆画像?(事实保留,下次 consolidation 会重新生成)')) return;
|
|
3418
|
-
const status = document.getElementById('mem-persona-status');
|
|
3419
|
-
try {
|
|
3420
|
-
await api('/api/memory/persona?user_key=' + encodeURIComponent(memCurrentUser), { method: 'DELETE' });
|
|
3421
|
-
if (status) status.textContent = '✓ 已删除';
|
|
3422
|
-
await loadMemoryUserDetail();
|
|
3423
|
-
} catch (err) {
|
|
3424
|
-
if (status) status.textContent = '删除失败: ' + (err.message || err);
|
|
3425
|
-
}
|
|
3426
|
-
});
|
|
3427
|
-
document.getElementById('btn-mem-persona-consolidate')?.addEventListener('click', async () => {
|
|
3428
|
-
const status = document.getElementById('mem-persona-status');
|
|
3429
|
-
const btn = document.getElementById('btn-mem-persona-consolidate');
|
|
3430
|
-
if (btn) btn.disabled = true;
|
|
3431
|
-
if (status) status.textContent = '⏳ 已提交后台任务(每用户 1 次 LLM 调用,10–60 秒)…';
|
|
3432
|
-
let pollTimer = null;
|
|
3433
|
-
const stopPoll = () => { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } };
|
|
3434
|
-
try {
|
|
3435
|
-
// Async kickoff: server returns 202 + jobId immediately, then the
|
|
3436
|
-
// LLM call runs in the background. Polling avoids cloudflare /
|
|
3437
|
-
// nginx / browser fetch timeouts on long-held POSTs.
|
|
3438
|
-
const r = await api('/api/memory/consolidate', { method: 'POST' });
|
|
3439
|
-
const targetJobId = r.jobId;
|
|
3440
|
-
if (!targetJobId) throw new Error('server did not return jobId');
|
|
3441
|
-
const startedAt = Date.now();
|
|
3442
|
-
pollTimer = setInterval(async () => {
|
|
3443
|
-
try {
|
|
3444
|
-
const s = await api('/api/memory/consolidate/status');
|
|
3445
|
-
const job = (s.jobs || []).find(j => j.id === targetJobId);
|
|
3446
|
-
if (!job) return;
|
|
3447
|
-
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(0);
|
|
3448
|
-
if (job.phase === 'running') {
|
|
3449
|
-
if (status) status.textContent = `⏳ 重建中 (${elapsed}s)…`;
|
|
3450
|
-
return;
|
|
3451
|
-
}
|
|
3452
|
-
stopPoll();
|
|
3453
|
-
if (btn) btn.disabled = false;
|
|
3454
|
-
if (job.phase === 'done') {
|
|
3455
|
-
const u = (job.result && job.result.updated) || 0;
|
|
3456
|
-
const n = (job.result && job.result.users) || 0;
|
|
3457
|
-
if (status) status.textContent = `✓ 完成 (${elapsed}s) · 处理 ${n} 个用户、更新 ${u} 个画像`;
|
|
3458
|
-
await loadMemoryUserDetail();
|
|
3459
|
-
} else {
|
|
3460
|
-
const e = (job.result && job.result.error) || 'unknown error';
|
|
3461
|
-
if (status) status.textContent = '失败: ' + e;
|
|
3462
|
-
}
|
|
3463
|
-
} catch (pollErr) {
|
|
3464
|
-
// Poll failures are non-fatal; UI keeps trying. Stop after 5 min.
|
|
3465
|
-
if (Date.now() - startedAt > 300_000) {
|
|
3466
|
-
stopPoll();
|
|
3467
|
-
if (btn) btn.disabled = false;
|
|
3468
|
-
if (status) status.textContent = '轮询超时(>5min),请刷新查看实际结果';
|
|
3469
|
-
}
|
|
3470
|
-
}
|
|
3471
|
-
}, 2000);
|
|
3472
|
-
} catch (err) {
|
|
3473
|
-
if (status) status.textContent = '失败: ' + (err.message || err);
|
|
3474
|
-
if (btn) btn.disabled = false;
|
|
3475
|
-
stopPoll();
|
|
3476
|
-
}
|
|
3477
|
-
});
|
|
3478
|
-
document.getElementById('btn-mem-facts-clear-lowconf')?.addEventListener('click', async () => {
|
|
3479
|
-
if (!memCurrentUser) return;
|
|
3480
|
-
if (!confirm('删除 confidence ≤ 40% 的所有事实?')) return;
|
|
3481
|
-
const status = document.getElementById('mem-facts-status');
|
|
3482
|
-
try {
|
|
3483
|
-
const r = await api('/api/memory/facts?user_key=' + encodeURIComponent(memCurrentUser), {
|
|
3484
|
-
method: 'DELETE',
|
|
3485
|
-
body: JSON.stringify({ max_confidence: 0.4 }),
|
|
3486
|
-
});
|
|
3487
|
-
if (status) status.textContent = `✓ 删除了 ${r.deleted} 条低置信度事实`;
|
|
3488
|
-
await loadMemoryFacts();
|
|
3489
|
-
} catch (err) {
|
|
3490
|
-
if (status) status.textContent = '失败: ' + (err.message || err);
|
|
3491
|
-
}
|
|
3492
|
-
});
|
|
3493
|
-
document.getElementById('btn-mem-facts-clear-all')?.addEventListener('click', async () => {
|
|
3494
|
-
if (!memCurrentUser) return;
|
|
3495
|
-
if (!confirm('⚠️ 真的要清空此用户的所有事实吗?此操作不可撤销。')) return;
|
|
3496
|
-
if (!confirm('再次确认:所有事实将被永久删除(persona 不动)')) return;
|
|
3497
|
-
const status = document.getElementById('mem-facts-status');
|
|
3498
|
-
try {
|
|
3499
|
-
const r = await api('/api/memory/facts?user_key=' + encodeURIComponent(memCurrentUser), {
|
|
3500
|
-
method: 'DELETE',
|
|
3501
|
-
body: JSON.stringify({ confirm_clear: true }),
|
|
3502
|
-
});
|
|
3503
|
-
if (status) status.textContent = `✓ 清空了 ${r.deleted} 条事实`;
|
|
3504
|
-
await loadMemoryFacts();
|
|
3505
|
-
} catch (err) {
|
|
3506
|
-
if (status) status.textContent = '失败: ' + (err.message || err);
|
|
3507
|
-
}
|
|
3508
|
-
});
|
|
3509
|
-
|
|
3510
|
-
// ============================================
|
|
3511
|
-
// v1.2.3 — Skills tab
|
|
3512
|
-
// ============================================
|
|
3513
|
-
let skillsCache = null; // {total, byCategory, skills}
|
|
3514
|
-
async function loadSkills() {
|
|
3515
|
-
const listEl = document.getElementById('skills-list');
|
|
3516
|
-
if (listEl) listEl.innerHTML = '<p class="muted">' + (T.loading || 'Loading…') + '</p>';
|
|
3517
|
-
try {
|
|
3518
|
-
const data = await api('/api/skills');
|
|
3519
|
-
skillsCache = data;
|
|
3520
|
-
renderSkillsCatFilter(data);
|
|
3521
|
-
renderSkills();
|
|
3522
|
-
const stats = document.getElementById('skills-stats');
|
|
3523
|
-
if (stats) {
|
|
3524
|
-
const cats = Object.keys(data.byCategory || {}).length;
|
|
3525
|
-
stats.textContent = `· ${data.total} skills · ${cats} categories`;
|
|
3526
|
-
}
|
|
3527
|
-
} catch (err) {
|
|
3528
|
-
if (listEl) listEl.innerHTML = '<p style="color:#d00">' + escapeHtmlSafe(String(err.message || err)) + '</p>';
|
|
3529
|
-
}
|
|
3530
|
-
// Kick off remote hot panel in parallel — fire-and-forget; its own
|
|
3531
|
-
// error rendering is in loadSkillsRemote.
|
|
3532
|
-
loadSkillsRemote();
|
|
3533
|
-
}
|
|
3534
|
-
function renderSkillsCatFilter(data) {
|
|
3535
|
-
const sel = document.getElementById('skills-cat-filter');
|
|
3536
|
-
if (!sel) return;
|
|
3537
|
-
const prev = sel.value;
|
|
3538
|
-
// Keep first option (all), append discovered categories
|
|
3539
|
-
const allOpt = sel.options[0];
|
|
3540
|
-
sel.innerHTML = '';
|
|
3541
|
-
sel.appendChild(allOpt);
|
|
3542
|
-
const cats = Object.keys(data.byCategory || {}).sort();
|
|
3543
|
-
for (const c of cats) {
|
|
3544
|
-
const o = document.createElement('option');
|
|
3545
|
-
o.value = c;
|
|
3546
|
-
o.textContent = `${c} (${data.byCategory[c]})`;
|
|
3547
|
-
sel.appendChild(o);
|
|
3548
|
-
}
|
|
3549
|
-
sel.value = prev;
|
|
3550
|
-
}
|
|
3551
|
-
function renderSkills() {
|
|
3552
|
-
if (!skillsCache) return;
|
|
3553
|
-
const listEl = document.getElementById('skills-list');
|
|
3554
|
-
if (!listEl) return;
|
|
3555
|
-
const q = (document.getElementById('skills-search')?.value || '').toLowerCase().trim();
|
|
3556
|
-
const cat = document.getElementById('skills-cat-filter')?.value || '';
|
|
3557
|
-
const filtered = skillsCache.skills.filter(s => {
|
|
3558
|
-
if (cat && s.category !== cat) return false;
|
|
3559
|
-
if (!q) return true;
|
|
3560
|
-
return s.slug.toLowerCase().includes(q)
|
|
3561
|
-
|| (s.name || '').toLowerCase().includes(q)
|
|
3562
|
-
|| (s.description || '').toLowerCase().includes(q);
|
|
3563
|
-
});
|
|
3564
|
-
if (filtered.length === 0) {
|
|
3565
|
-
listEl.innerHTML = '<p class="muted">(no skills match)</p>';
|
|
3566
|
-
return;
|
|
3567
|
-
}
|
|
3568
|
-
// Group by category for clearer scan
|
|
3569
|
-
const byCat = {};
|
|
3570
|
-
for (const s of filtered) {
|
|
3571
|
-
if (!byCat[s.category]) byCat[s.category] = [];
|
|
3572
|
-
byCat[s.category].push(s);
|
|
3573
|
-
}
|
|
3574
|
-
const html = [];
|
|
3575
|
-
for (const cat of Object.keys(byCat).sort()) {
|
|
3576
|
-
html.push(`<h3 style="margin:18px 0 8px;font-size:14px;color:var(--muted,#656d76)">${escapeHtmlSafe(cat)} <span style="font-weight:400">(${byCat[cat].length})</span></h3>`);
|
|
3577
|
-
html.push('<table class="data-table"><tbody>');
|
|
3578
|
-
for (const s of byCat[cat]) {
|
|
3579
|
-
const agentBadge = s.agents.map(a => `<span style="display:inline-block;padding:1px 6px;margin-right:4px;border-radius:3px;background:${a==='claude'?'#fde68a':'#bfdbfe'};font-size:11px;color:#1f2328">${a}</span>`).join('');
|
|
3580
|
-
const descPreview = (s.description || '').slice(0, 140) + ((s.description || '').length > 140 ? '…' : '');
|
|
3581
|
-
html.push(`<tr style="cursor:pointer" data-skill="${escapeHtmlSafe(s.slug)}">
|
|
3582
|
-
<td style="width:240px;vertical-align:top"><code>${escapeHtmlSafe(s.slug)}</code></td>
|
|
3583
|
-
<td style="width:120px;vertical-align:top">${agentBadge}</td>
|
|
3584
|
-
<td style="vertical-align:top;color:var(--muted,#656d76)">${escapeHtmlSafe(descPreview)}</td>
|
|
3585
|
-
</tr>`);
|
|
3586
|
-
}
|
|
3587
|
-
html.push('</tbody></table>');
|
|
3588
|
-
}
|
|
3589
|
-
listEl.innerHTML = html.join('');
|
|
3590
|
-
listEl.querySelectorAll('[data-skill]').forEach(tr => {
|
|
3591
|
-
tr.addEventListener('click', () => openSkillModal(tr.getAttribute('data-skill')));
|
|
3592
|
-
});
|
|
3593
|
-
}
|
|
3594
|
-
async function openSkillModal(slug) {
|
|
3595
|
-
const m = document.getElementById('modal');
|
|
3596
|
-
const bg = document.getElementById('modal-bg');
|
|
3597
|
-
m.innerHTML = `<p class="muted">Loading ${escapeHtmlSafe(slug)}…</p>`;
|
|
3598
|
-
bg.classList.add('show');
|
|
3599
|
-
try {
|
|
3600
|
-
const data = await api('/api/skills/' + encodeURIComponent(slug));
|
|
3601
|
-
// Render the SKILL.md content. Strip the frontmatter block, then
|
|
3602
|
-
// run through marked() if available, otherwise plain <pre>.
|
|
3603
|
-
let body = data.content || '';
|
|
3604
|
-
if (body.startsWith('---')) {
|
|
3605
|
-
const end = body.indexOf('\n---', 3);
|
|
3606
|
-
if (end >= 0) body = body.slice(end + 4).trimStart();
|
|
3607
|
-
}
|
|
3608
|
-
const rendered = (window.marked && typeof marked.parse === 'function')
|
|
3609
|
-
? marked.parse(body)
|
|
3610
|
-
: `<pre style="white-space:pre-wrap;font-size:12px">${escapeHtmlSafe(body)}</pre>`;
|
|
3611
|
-
const agentBadge = data.agents.map(a => `<span style="display:inline-block;padding:1px 6px;margin-right:4px;border-radius:3px;background:${a==='claude'?'#fde68a':'#bfdbfe'};font-size:11px;color:#1f2328">${a}</span>`).join('');
|
|
3612
|
-
m.innerHTML = `
|
|
3613
|
-
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:8px">
|
|
3614
|
-
<h2 style="margin:0"><code>${escapeHtmlSafe(data.slug)}</code></h2>
|
|
3615
|
-
<button type="button" class="btn" onclick="document.getElementById('modal-bg').classList.remove('show')">${T.close || 'Close'}</button>
|
|
3616
|
-
</div>
|
|
3617
|
-
<p class="muted" style="font-size:12px;margin:0 0 8px">
|
|
3618
|
-
${agentBadge} · <strong>${escapeHtmlSafe(data.category)}</strong>
|
|
3619
|
-
· <code style="font-size:11px">${escapeHtmlSafe(data.path)}</code>
|
|
3620
|
-
</p>
|
|
3621
|
-
<p style="margin:0 0 16px"><strong>${escapeHtmlSafe(data.name)}</strong> — ${escapeHtmlSafe(data.description)}</p>
|
|
3622
|
-
<div class="markdown-body" style="border-top:1px solid var(--border,#d0d7de);padding-top:12px;max-height:60vh;overflow-y:auto">${rendered}</div>
|
|
3623
|
-
`;
|
|
3624
|
-
} catch (err) {
|
|
3625
|
-
m.innerHTML = `<p style="color:#d00">${escapeHtmlSafe(String(err.message || err))}</p>
|
|
3626
|
-
<button type="button" class="btn" onclick="document.getElementById('modal-bg').classList.remove('show')">${T.close || 'Close'}</button>`;
|
|
3627
|
-
}
|
|
3628
|
-
}
|
|
3629
|
-
document.getElementById('skills-search')?.addEventListener('input', renderSkills);
|
|
3630
|
-
document.getElementById('skills-cat-filter')?.addEventListener('change', renderSkills);
|
|
3631
|
-
document.getElementById('btn-skills-refresh')?.addEventListener('click', loadSkills);
|
|
3632
|
-
|
|
3633
|
-
// ─── skillhub.cn remote hot ───
|
|
3634
|
-
async function loadSkillsRemote() {
|
|
3635
|
-
const listEl = document.getElementById('skills-remote-list');
|
|
3636
|
-
const metaEl = document.getElementById('skills-remote-meta');
|
|
3637
|
-
if (listEl) listEl.innerHTML = '<p class="muted">' + (T.loading || 'Loading…') + '</p>';
|
|
3638
|
-
try {
|
|
3639
|
-
const data = await api('/api/skills/remote/hot');
|
|
3640
|
-
const skills = (data && data.skills) || [];
|
|
3641
|
-
if (metaEl) {
|
|
3642
|
-
const cacheStr = data.cached
|
|
3643
|
-
? ` · ${T.skillsRemoteCached || '缓存命中'}`
|
|
3644
|
-
: '';
|
|
3645
|
-
const staleStr = data.stale ? ` · ⚠️ ${T.skillsRemoteStale || '上游不可达,用旧数据'}` : '';
|
|
3646
|
-
metaEl.textContent = `(${skills.length}${cacheStr}${staleStr})`;
|
|
3647
|
-
}
|
|
3648
|
-
const html = ['<table class="data-table"><thead><tr>',
|
|
3649
|
-
`<th style="width:200px">${T.skillsCol || 'slug'}</th>`,
|
|
3650
|
-
`<th>${T.skillsName || '名称 / 描述'}</th>`,
|
|
3651
|
-
`<th style="width:100px;text-align:right">${T.skillsStars || '⭐ stars'}</th>`,
|
|
3652
|
-
`<th style="width:110px;text-align:right">${T.skillsInstalls || '⬇ installs'}</th>`,
|
|
3653
|
-
'</tr></thead><tbody>'];
|
|
3654
|
-
for (const s of skills) {
|
|
3655
|
-
const desc = (s.description || '').replace(/\n+/g, ' ').slice(0, 150);
|
|
3656
|
-
const more = (s.description || '').length > 150 ? '…' : '';
|
|
3657
|
-
html.push(`<tr style="cursor:pointer" data-remote-slug="${escapeHtmlSafe(s.slug)}">
|
|
3658
|
-
<td style="vertical-align:top"><code>${escapeHtmlSafe(s.slug)}</code><div class="muted" style="font-size:11px">${escapeHtmlSafe(s.category||'')}</div></td>
|
|
3659
|
-
<td style="vertical-align:top">
|
|
3660
|
-
<div><strong>${escapeHtmlSafe(s.name||s.slug)}</strong> <span class="muted" style="font-size:11px">v${escapeHtmlSafe(s.version||'?')}</span></div>
|
|
3661
|
-
<div class="muted" style="font-size:12px;margin-top:2px">${escapeHtmlSafe(desc)}${more}</div>
|
|
3662
|
-
</td>
|
|
3663
|
-
<td style="vertical-align:top;text-align:right">${(s.stars||0).toLocaleString()}</td>
|
|
3664
|
-
<td style="vertical-align:top;text-align:right">${(s.installs||0).toLocaleString()}</td>
|
|
3665
|
-
</tr>`);
|
|
3666
|
-
}
|
|
3667
|
-
html.push('</tbody></table>');
|
|
3668
|
-
if (skills.length === 0) listEl.innerHTML = '<p class="muted">(empty)</p>';
|
|
3669
|
-
else listEl.innerHTML = html.join('');
|
|
3670
|
-
listEl.querySelectorAll('[data-remote-slug]').forEach(tr => {
|
|
3671
|
-
tr.addEventListener('click', () => openRemoteSkillModal(tr.getAttribute('data-remote-slug'), skills));
|
|
3672
|
-
});
|
|
3673
|
-
} catch (err) {
|
|
3674
|
-
if (listEl) listEl.innerHTML = '<p style="color:#d00">' + escapeHtmlSafe(String(err.message || err)) + '</p>';
|
|
3675
|
-
}
|
|
3676
|
-
}
|
|
3677
|
-
function openRemoteSkillModal(slug, skills) {
|
|
3678
|
-
const s = skills.find(x => x.slug === slug);
|
|
3679
|
-
if (!s) return;
|
|
3680
|
-
const m = document.getElementById('modal');
|
|
3681
|
-
const bg = document.getElementById('modal-bg');
|
|
3682
|
-
const desc = escapeHtmlSafe(s.description || '');
|
|
3683
|
-
// Three install paths the user can pick. We never auto-execute —
|
|
3684
|
-
// operator's choice to copy + paste into shell or forward to agent.
|
|
3685
|
-
const installCmds = [
|
|
3686
|
-
{ label: 'CLI (host)', cmd: 'skillhub install ' + s.slug },
|
|
3687
|
-
{ label: 'CLI dry-run', cmd: 'skillhub search ' + s.slug },
|
|
3688
|
-
{ label: 'IM 让 agent 安装', cmd: '请用 skillhub 安装 ' + s.slug + ' 技能' },
|
|
3689
|
-
];
|
|
3690
|
-
const cmdRows = installCmds.map(c =>
|
|
3691
|
-
`<div style="margin-top:6px"><span class="muted" style="font-size:11px;display:inline-block;width:130px">${escapeHtmlSafe(c.label)}</span>
|
|
3692
|
-
<code style="font-size:12px">${escapeHtmlSafe(c.cmd)}</code>
|
|
3693
|
-
<button type="button" class="btn" style="font-size:11px;padding:1px 6px;margin-left:6px" onclick="navigator.clipboard.writeText('${c.cmd.replace(/'/g,"\\'")}').then(()=>{this.textContent='✓'; setTimeout(()=>{this.textContent='复制';},1500);})">复制</button>
|
|
3694
|
-
</div>`).join('');
|
|
3695
|
-
m.innerHTML = `
|
|
3696
|
-
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:8px">
|
|
3697
|
-
<h2 style="margin:0">${escapeHtmlSafe(s.name || s.slug)} <span class="muted" style="font-size:13px;font-weight:400">v${escapeHtmlSafe(s.version || '?')}</span></h2>
|
|
3698
|
-
<button type="button" class="btn" onclick="document.getElementById('modal-bg').classList.remove('show')">${T.close || 'Close'}</button>
|
|
3699
|
-
</div>
|
|
3700
|
-
<p class="muted" style="font-size:12px;margin:0 0 10px">
|
|
3701
|
-
<code>${escapeHtmlSafe(s.slug)}</code>
|
|
3702
|
-
${s.category ? ' · ' + escapeHtmlSafe(s.category) : ''}
|
|
3703
|
-
· ⭐ ${(s.stars||0).toLocaleString()}
|
|
3704
|
-
· ⬇ ${(s.installs||0).toLocaleString()} installs
|
|
3705
|
-
· ${(s.downloads||0).toLocaleString()} downloads
|
|
3706
|
-
</p>
|
|
3707
|
-
<div style="white-space:pre-wrap;border-top:1px solid var(--border,#d0d7de);padding-top:10px;font-size:13px">${desc}</div>
|
|
3708
|
-
<div style="margin-top:14px;padding-top:10px;border-top:1px solid var(--border,#d0d7de)">
|
|
3709
|
-
<strong style="font-size:13px">${T.skillsHowInstall || '安装方式(任选一个;agim 不会自动跑)'}</strong>
|
|
3710
|
-
${cmdRows}
|
|
3711
|
-
<p class="muted" style="font-size:11px;margin-top:8px">
|
|
3712
|
-
${T.skillsHostHint || '前两条要先在 host 上跑 skillhub install.sh。详见 docs/memory-and-vector.md 或 skillhub.cn。'}
|
|
3713
|
-
</p>
|
|
3714
|
-
</div>
|
|
3715
|
-
`;
|
|
3716
|
-
bg.classList.add('show');
|
|
3717
|
-
}
|
|
3718
|
-
document.getElementById('btn-skills-remote-refresh')?.addEventListener('click', loadSkillsRemote);
|
|
3719
|
-
|
|
3720
|
-
// Initial load
|
|
3721
|
-
loadJobs();
|
|
3722
|
-
</script>
|
|
3723
|
-
</body>
|
|
3724
|
-
</html>
|