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,2488 +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 — Settings</title>
|
|
7
|
-
<!-- Shared utilities: theme manager (applies before first paint), error
|
|
8
|
-
boundary (surfaces silent script failures), i18n + api helpers. -->
|
|
9
|
-
<script src="/_app.js"></script>
|
|
10
|
-
<script>
|
|
11
|
-
const LANGS = { en: 'English', zh: '中文' };
|
|
12
|
-
const savedLang = localStorage.getItem('im-hub-lang');
|
|
13
|
-
const browserLang = navigator.language.startsWith('zh') ? 'zh' : 'en';
|
|
14
|
-
window.__lang = savedLang && LANGS[savedLang] ? savedLang : browserLang;
|
|
15
|
-
document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
|
|
16
|
-
|
|
17
|
-
const T = {
|
|
18
|
-
en: {
|
|
19
|
-
title: 'Agim — Settings',
|
|
20
|
-
backToChat: 'Back to Chat',
|
|
21
|
-
settingsTitle: 'Agim Settings',
|
|
22
|
-
loading: 'Loading configuration...',
|
|
23
|
-
loadFailed: 'Failed to load config',
|
|
24
|
-
h1: 'Settings',
|
|
25
|
-
agents: 'Agents',
|
|
26
|
-
defaultAgent: 'Default Agent',
|
|
27
|
-
saveAgents: 'Save Agents',
|
|
28
|
-
messengers: 'Messengers (Channels)',
|
|
29
|
-
wechat: 'WeChat',
|
|
30
|
-
wechatHint: 'iLink QR scan login',
|
|
31
|
-
wechatBox: 'WeChat requires QR scan login. Run <code>agim config wechat</code> in terminal to set up.',
|
|
32
|
-
telegram: 'Telegram',
|
|
33
|
-
telegramHint: 'Bot token from @BotFather',
|
|
34
|
-
botToken: 'Bot Token',
|
|
35
|
-
channelId: 'Channel ID',
|
|
36
|
-
feishu: 'Feishu / Lark',
|
|
37
|
-
feishuHint: 'App ID and App Secret',
|
|
38
|
-
appId: 'App ID',
|
|
39
|
-
appSecret: 'App Secret',
|
|
40
|
-
saveMessengers: 'Save Messengers',
|
|
41
|
-
acpTitle: 'Remote Agents (ACP)',
|
|
42
|
-
acpNone: 'No remote agents configured',
|
|
43
|
-
name: 'Name',
|
|
44
|
-
endpoint: 'Endpoint',
|
|
45
|
-
auth: 'Auth',
|
|
46
|
-
enabled: 'Enabled',
|
|
47
|
-
test: 'Test',
|
|
48
|
-
del: 'Del',
|
|
49
|
-
addAgent: 'Add Remote Agent',
|
|
50
|
-
aliases: 'Aliases (comma-separated)',
|
|
51
|
-
endpointUrl: 'Endpoint URL',
|
|
52
|
-
authType: 'Auth Type',
|
|
53
|
-
none: 'None',
|
|
54
|
-
apikey: 'API Key',
|
|
55
|
-
bearer: 'Bearer Token',
|
|
56
|
-
token: 'Token',
|
|
57
|
-
testConn: 'Test Connection',
|
|
58
|
-
add: 'Add Agent',
|
|
59
|
-
general: 'General',
|
|
60
|
-
webPort: 'Web Chat Port',
|
|
61
|
-
save: 'Save',
|
|
62
|
-
saved: 'Saved',
|
|
63
|
-
testing: 'Testing...',
|
|
64
|
-
connected: 'Connected',
|
|
65
|
-
failed: 'Failed',
|
|
66
|
-
error: 'Error',
|
|
67
|
-
savedMsg: 'Saved',
|
|
68
|
-
saveFailed: 'Save failed',
|
|
69
|
-
enterEndpoint: 'Enter an endpoint URL',
|
|
70
|
-
nameRequired: 'Agent name is required',
|
|
71
|
-
endpointRequired: 'Endpoint URL is required',
|
|
72
|
-
installHint: 'npm i -g',
|
|
73
|
-
workspacesTitle: 'Workspaces',
|
|
74
|
-
workspacesNone: 'Only the default workspace exists. Add one to scope agents/rate-limits per team.',
|
|
75
|
-
workspaceId: 'ID',
|
|
76
|
-
workspaceName: 'Name',
|
|
77
|
-
workspaceAgents: 'Agents (comma)',
|
|
78
|
-
workspaceMembers: 'Members (comma userIds, blank = open)',
|
|
79
|
-
workspaceRateLimit: 'Rate limit',
|
|
80
|
-
workspaceRate: 'Rate / interval',
|
|
81
|
-
workspaceInterval: 'Interval (sec)',
|
|
82
|
-
workspaceBurst: 'Burst',
|
|
83
|
-
workspaceAdd: 'Add or update workspace',
|
|
84
|
-
workspaceEdit: 'Edit',
|
|
85
|
-
workspaceDelete: 'Delete',
|
|
86
|
-
workspaceReset: 'Reset',
|
|
87
|
-
workspaceConfirmDelete: 'Delete workspace "{id}"?',
|
|
88
|
-
workspaceSaved: 'Workspace saved',
|
|
89
|
-
workspaceDeleted: 'Workspace deleted',
|
|
90
|
-
workspaceIdHelp: 'Letters / digits / _ / - only',
|
|
91
|
-
workspaceDefaultLocked: 'default (locked)',
|
|
92
|
-
|
|
93
|
-
// ── messengers (extras for v1.0.5) ───────────────────────
|
|
94
|
-
wechatScan: 'Scan to log in',
|
|
95
|
-
wechatScanTitle: 'WeChat — scan to log in',
|
|
96
|
-
wechatGenerating: 'Generating QR code…',
|
|
97
|
-
wechatWaiting: 'Waiting for scan…',
|
|
98
|
-
wechatScanned: 'QR scanned — confirm on your phone…',
|
|
99
|
-
wechatConfirmed: '✓ Logged in (account {account})',
|
|
100
|
-
wechatExpired: 'QR code expired',
|
|
101
|
-
wechatRegen: 'Regenerate',
|
|
102
|
-
wechatClose: 'Close',
|
|
103
|
-
wechatFailed: 'Failed: {error}',
|
|
104
|
-
dingtalk: 'DingTalk',
|
|
105
|
-
dingtalkHint: 'Stream-mode app: Client ID + Client Secret',
|
|
106
|
-
dingtalkClientId: 'Client ID',
|
|
107
|
-
dingtalkClientSecret: 'Client Secret',
|
|
108
|
-
discord: 'Discord',
|
|
109
|
-
discordHint: 'Bot token from Discord Developer Portal',
|
|
110
|
-
discordToken: 'Bot Token',
|
|
111
|
-
discordGuilds: 'Allowed guilds (comma-separated IDs, blank = any)',
|
|
112
|
-
discordChannels: 'Allowed channels (comma-separated IDs, blank = any)',
|
|
113
|
-
configure: 'Configure',
|
|
114
|
-
reconfigure: 'Edit credentials',
|
|
115
|
-
|
|
116
|
-
// ── service control ──────────────────────────────────────
|
|
117
|
-
svcTitle: 'Service',
|
|
118
|
-
svcStateLoading: 'Checking…',
|
|
119
|
-
svcStateRunning: 'Running ({mode}, pid {pid}, up {uptime})',
|
|
120
|
-
svcStateRunningNoUp: 'Running ({mode}, pid {pid})',
|
|
121
|
-
svcStateNone: 'Not running',
|
|
122
|
-
svcStart: 'Start',
|
|
123
|
-
svcStop: 'Stop',
|
|
124
|
-
svcRestart: 'Restart',
|
|
125
|
-
svcConfirmStop: 'Stop Agim? The web console will go offline until you start it again from a terminal.',
|
|
126
|
-
svcConfirmRestart: 'Restart Agim? The web console will reconnect automatically in a few seconds.',
|
|
127
|
-
svcRestarting: 'Restarting — waiting for the service to come back…',
|
|
128
|
-
svcRestarted: '✓ Service restarted',
|
|
129
|
-
svcStopped: '✓ Service stopped (this page is now disconnected)',
|
|
130
|
-
svcFgWarning: 'Foreground service is running in another terminal — restart it there.',
|
|
131
|
-
|
|
132
|
-
// ── Safety / admin allowlist ─────────────────────────────
|
|
133
|
-
safetyTitle: 'Safety',
|
|
134
|
-
adminListTitle: 'Admin Allowlist',
|
|
135
|
-
adminListHint: 'Only these users can run /restart, /stop, and natural-language equivalents in IM. Edits persist to ~/.agim/env immediately — no restart needed.',
|
|
136
|
-
adminListLoading: 'Loading…',
|
|
137
|
-
adminListEmpty: '(no admins yet — IM service commands are disabled)',
|
|
138
|
-
adminUserIdPlaceholder: 'platform-specific user id (e.g. wxid_… / 123456 / ou_…)',
|
|
139
|
-
adminAdd: 'Add admin',
|
|
140
|
-
adminRemove: 'Remove',
|
|
141
|
-
adminConfirmRemove: 'Remove admin {p}:{u}?',
|
|
142
|
-
adminPublicBindWarn: '⚠️ Web is bound publicly — admin editor disabled to prevent random visitors from granting themselves access. Bind to 127.0.0.1 to enable.',
|
|
143
|
-
adminBootstrapHint: 'Bootstrap token exists at {path}. Run `cat` on the server, then send `/setup admin <token>` in IM to self-onboard.',
|
|
144
|
-
|
|
145
|
-
// ── Approval policy (timeout default) ─────────────────────
|
|
146
|
-
approvalPolicyTitle: 'Approval Policy',
|
|
147
|
-
approvalPolicyHint: 'What does Agim do when a tool-use approval times out (no IM reply within the budget)? Default deny is strict; switch to allow when you\'ll be away from IM and want the agent to keep moving.',
|
|
148
|
-
approvalTimeoutDefaultLabel: 'On approval timeout',
|
|
149
|
-
approvalTimeoutDeny: 'Deny (default — strict, human in the loop)',
|
|
150
|
-
approvalTimeoutAllow: 'Allow (user-away mode — agent proceeds silently)',
|
|
151
|
-
approvalPolicySave: 'Save',
|
|
152
|
-
approvalPolicySaved: 'Saved — applies to the next pending approval, no restart needed.',
|
|
153
|
-
approvalPolicyLoadFailed: 'Failed to load approval policy',
|
|
154
|
-
|
|
155
|
-
// ── Viewer (long-message web rendering) ────────────────────
|
|
156
|
-
viewerTitle: 'IM Long-Message Viewer',
|
|
157
|
-
viewerHint: 'When an agent reply is too long or contains a markdown table / large code block, Agim stores it locally in ~/.agim/viewer.db (permanent, never leaves this host) and sends the IM a short summary + a link. Set the public URL of your reverse-proxied Agim web here so the link is reachable from your phone.',
|
|
158
|
-
viewerEnabled: 'Enabled',
|
|
159
|
-
viewerDisabled: 'Disabled',
|
|
160
|
-
viewerEnabledLabel: 'Viewer mode',
|
|
161
|
-
viewerPublicUrl: 'Public base URL',
|
|
162
|
-
viewerPublicUrlHint: 'Your public URL pointing at this Agim web (e.g. https://agim.example.com). Use cloudflared / caddy / tailscale to expose port 3000.',
|
|
163
|
-
viewerChars: 'Char threshold',
|
|
164
|
-
viewerLines: 'Line threshold',
|
|
165
|
-
viewerCodeLines: 'Code-block line threshold',
|
|
166
|
-
viewerMaxPastes: 'Max stored pastes (LRU prune)',
|
|
167
|
-
viewerSave: 'Save',
|
|
168
|
-
viewerSaved: 'Saved — change takes effect on the next reply.',
|
|
169
|
-
viewerSaveFailed: 'Failed to save viewer settings',
|
|
170
|
-
viewerMissingUrl: '⚠️ Public URL is empty — Agim will fall back to inline text for long replies (no link can be built).',
|
|
171
|
-
viewerTunnelMode: 'Auto-tunnel (cloudflared)',
|
|
172
|
-
viewerTunnelHint: 'For users without a public domain. Agim launches a cloudflared quick tunnel on startup and auto-detects a temporary `*.trycloudflare.com` URL. Requires the `cloudflared` binary to be installed (`brew install cloudflared` / `apt install cloudflared`). URL changes per restart — old paste links die. Leave off when you have a static reverse proxy.',
|
|
173
|
-
viewerTunnelOff: 'Off (use Public URL above)',
|
|
174
|
-
viewerTunnelQuick: 'Quick tunnel (temporary URL, no domain needed)',
|
|
175
|
-
viewerTunnelStatus: 'Tunnel status',
|
|
176
|
-
viewerTunnelRunning: 'Running',
|
|
177
|
-
viewerTunnelNotRunning: 'Not running',
|
|
178
|
-
viewerTunnelBinaryMissing: 'cloudflared not installed',
|
|
179
|
-
viewerTunnelCurrentUrl: 'Current URL',
|
|
180
|
-
viewerTunnelRefresh: 'Refresh',
|
|
181
|
-
a2aNotifyTitle: 'A2A Notifications',
|
|
182
|
-
a2aNotifyHint: 'Push intermediate state to your IM thread when one agent calls another (e.g. Claude → Codex). Keeps you in the loop during long handoffs.',
|
|
183
|
-
a2aDefaultTimeout: 'Default A2A timeout (minutes)',
|
|
184
|
-
a2aDefaultTimeoutHint: 'How long agim waits for a child agent before timing out. Raised from 10 min to 30 min in v1.1.10 — long enough for Codex to do ~4 API pulls + write a report.',
|
|
185
|
-
a2aMaxTimeout: 'Max allowed timeout (minutes)',
|
|
186
|
-
a2aMaxTimeoutHint: 'Hard ceiling. Refuses caller-supplied timeoutMs larger than this so an agent can\'t block A2A forever.',
|
|
187
|
-
a2aNotifyMode: 'Notify mode',
|
|
188
|
-
a2aNotifyOff: 'Off',
|
|
189
|
-
a2aNotifyEssential: 'Essential (start + complete/timeout/failed)',
|
|
190
|
-
a2aNotifyVerbose: 'Verbose (add heartbeats on file changes)',
|
|
191
|
-
a2aMaxDepth: 'Max notify depth',
|
|
192
|
-
a2aMaxDepthHint: 'Only notify for callees up to this nesting depth (1 = direct child only). Avoids spamming on deep call chains.',
|
|
193
|
-
a2aHeartbeatMin: 'Heartbeat interval (minutes, verbose only)',
|
|
194
|
-
a2aHeartbeatHint: 'Checks the callee\'s _agim-output/ directory; pushes a note if files changed or a "still thinking" note otherwise.',
|
|
195
|
-
a2aSave: 'Save',
|
|
196
|
-
a2aSaved: 'Saved — applies to the next A2A invocation, no restart needed.',
|
|
197
|
-
a2aSaveFailed: 'Failed to save A2A settings',
|
|
198
|
-
memoryTitle: 'Long-term Memory',
|
|
199
|
-
memoryHint: 'After every reply, agim asynchronously extracts "facts worth remembering" (preferences / holdings / projects / …) from the exchange and stores them in ~/.agim/memory.db. Next turns get a small (~150-300 token) persona summary in the prompt header plus an on-demand memory_query MCP tool for specific facts. Adds one extra LLM call per turn using the same agent the user just talked to — watch the Cost & Health tab if you turn it on.',
|
|
200
|
-
memoryEnable: 'Enabled',
|
|
201
|
-
memoryDisable: 'Off',
|
|
202
|
-
memoryEnableLabel: 'Auto memory',
|
|
203
|
-
memorySave: 'Save',
|
|
204
|
-
memorySaved: 'Saved — fact accumulation begins after the next reply.',
|
|
205
|
-
memorySaveFailed: 'Failed to save memory settings',
|
|
206
|
-
},
|
|
207
|
-
zh: {
|
|
208
|
-
title: 'Agim — 设置',
|
|
209
|
-
backToChat: '返回对话',
|
|
210
|
-
settingsTitle: 'Agim 设置',
|
|
211
|
-
loading: '加载配置中...',
|
|
212
|
-
loadFailed: '加载配置失败',
|
|
213
|
-
h1: '设置',
|
|
214
|
-
agents: 'Agent',
|
|
215
|
-
defaultAgent: '默认 Agent',
|
|
216
|
-
saveAgents: '保存 Agent',
|
|
217
|
-
messengers: '消息通道 (Channels)',
|
|
218
|
-
wechat: '微信',
|
|
219
|
-
wechatHint: 'iLink 扫码登录',
|
|
220
|
-
wechatBox: '微信需要扫码登录。在终端运行 <code>agim config wechat</code> 进行配置。',
|
|
221
|
-
telegram: 'Telegram',
|
|
222
|
-
telegramHint: '从 @BotFather 获取 Bot Token',
|
|
223
|
-
botToken: 'Bot Token',
|
|
224
|
-
channelId: '频道 ID',
|
|
225
|
-
feishu: '飞书 / Lark',
|
|
226
|
-
feishuHint: 'App ID 和 App Secret',
|
|
227
|
-
appId: 'App ID',
|
|
228
|
-
appSecret: 'App Secret',
|
|
229
|
-
saveMessengers: '保存通道',
|
|
230
|
-
acpTitle: '远程 Agent (ACP)',
|
|
231
|
-
acpNone: '暂无远程 Agent',
|
|
232
|
-
name: '名称',
|
|
233
|
-
endpoint: '端点',
|
|
234
|
-
auth: '认证',
|
|
235
|
-
enabled: '启用',
|
|
236
|
-
test: '测试',
|
|
237
|
-
del: '删除',
|
|
238
|
-
addAgent: '添加远程 Agent',
|
|
239
|
-
aliases: '别名(逗号分隔)',
|
|
240
|
-
endpointUrl: '端点 URL',
|
|
241
|
-
authType: '认证方式',
|
|
242
|
-
none: '无',
|
|
243
|
-
apikey: 'API Key',
|
|
244
|
-
bearer: 'Bearer Token',
|
|
245
|
-
token: '令牌',
|
|
246
|
-
testConn: '测试连接',
|
|
247
|
-
add: '添加 Agent',
|
|
248
|
-
general: '通用',
|
|
249
|
-
webPort: 'Web 对话端口',
|
|
250
|
-
save: '保存',
|
|
251
|
-
saved: '已保存',
|
|
252
|
-
testing: '测试中...',
|
|
253
|
-
connected: '已连接',
|
|
254
|
-
failed: '失败',
|
|
255
|
-
error: '错误',
|
|
256
|
-
savedMsg: '已保存',
|
|
257
|
-
saveFailed: '保存失败',
|
|
258
|
-
enterEndpoint: '请输入端点 URL',
|
|
259
|
-
nameRequired: 'Agent 名称为必填项',
|
|
260
|
-
endpointRequired: '端点 URL 为必填项',
|
|
261
|
-
installHint: '安装命令:npm i -g',
|
|
262
|
-
workspacesTitle: '工作区',
|
|
263
|
-
workspacesNone: '仅有默认工作区。新增工作区可以为不同团队限定 agent 白名单与限流。',
|
|
264
|
-
workspaceId: 'ID',
|
|
265
|
-
workspaceName: '名称',
|
|
266
|
-
workspaceAgents: 'Agent(逗号分隔)',
|
|
267
|
-
workspaceMembers: '成员(逗号分隔 userId;空 = 开放)',
|
|
268
|
-
workspaceRateLimit: '限流',
|
|
269
|
-
workspaceRate: '速率 / 间隔',
|
|
270
|
-
workspaceInterval: '间隔(秒)',
|
|
271
|
-
workspaceBurst: '突发上限',
|
|
272
|
-
workspaceAdd: '新增 / 更新工作区',
|
|
273
|
-
workspaceEdit: '编辑',
|
|
274
|
-
workspaceDelete: '删除',
|
|
275
|
-
workspaceReset: '清空',
|
|
276
|
-
workspaceConfirmDelete: '确认删除工作区 "{id}" ?',
|
|
277
|
-
workspaceSaved: '工作区已保存',
|
|
278
|
-
workspaceDeleted: '工作区已删除',
|
|
279
|
-
workspaceIdHelp: '仅支持字母 / 数字 / _ / -',
|
|
280
|
-
workspaceDefaultLocked: '默认(不可改)',
|
|
281
|
-
|
|
282
|
-
// ── messengers (extras for v1.0.5) ───────────────────────
|
|
283
|
-
wechatScan: '扫码登录',
|
|
284
|
-
wechatScanTitle: '微信 — 扫码登录',
|
|
285
|
-
wechatGenerating: '生成二维码中…',
|
|
286
|
-
wechatWaiting: '等待扫描…',
|
|
287
|
-
wechatScanned: '已扫描,请在手机端确认…',
|
|
288
|
-
wechatConfirmed: '✓ 登录成功(账号 {account})',
|
|
289
|
-
wechatExpired: '二维码已过期',
|
|
290
|
-
wechatRegen: '重新生成',
|
|
291
|
-
wechatClose: '关闭',
|
|
292
|
-
wechatFailed: '失败:{error}',
|
|
293
|
-
dingtalk: '钉钉',
|
|
294
|
-
dingtalkHint: 'Stream 模式应用:Client ID + Client Secret',
|
|
295
|
-
dingtalkClientId: 'Client ID',
|
|
296
|
-
dingtalkClientSecret: 'Client Secret',
|
|
297
|
-
discord: 'Discord',
|
|
298
|
-
discordHint: '在 Discord Developer Portal 获取 Bot Token',
|
|
299
|
-
discordToken: 'Bot Token',
|
|
300
|
-
discordGuilds: '允许的服务器(逗号分隔 ID,空 = 不限)',
|
|
301
|
-
discordChannels: '允许的频道(逗号分隔 ID,空 = 不限)',
|
|
302
|
-
configure: '配置',
|
|
303
|
-
reconfigure: '修改凭据',
|
|
304
|
-
|
|
305
|
-
// ── service control ──────────────────────────────────────
|
|
306
|
-
svcTitle: '服务',
|
|
307
|
-
svcStateLoading: '检查中…',
|
|
308
|
-
svcStateRunning: '运行中({mode},pid {pid},已运行 {uptime})',
|
|
309
|
-
svcStateRunningNoUp: '运行中({mode},pid {pid})',
|
|
310
|
-
svcStateNone: '未运行',
|
|
311
|
-
svcStart: '启动',
|
|
312
|
-
svcStop: '停止',
|
|
313
|
-
svcRestart: '重启',
|
|
314
|
-
svcConfirmStop: '停止 Agim?停止后 web 控制台会离线,需要回终端用 `agim start` 再启动。',
|
|
315
|
-
svcConfirmRestart: '重启 Agim?web 控制台会在几秒后自动重连。',
|
|
316
|
-
svcRestarting: '重启中——等待服务恢复…',
|
|
317
|
-
svcRestarted: '✓ 服务已重启',
|
|
318
|
-
svcStopped: '✓ 服务已停止(页面已断开连接)',
|
|
319
|
-
svcFgWarning: '前台服务在另一个终端运行——回那个终端重启。',
|
|
320
|
-
|
|
321
|
-
// ── Safety / admin allowlist ─────────────────────────────
|
|
322
|
-
safetyTitle: '安全',
|
|
323
|
-
adminListTitle: '管理员白名单',
|
|
324
|
-
adminListHint: '只有列表里的用户能在 IM 里跑 /restart / /stop 或自然语言"重启服务"。改动会立即写入 ~/.agim/env,无需重启。',
|
|
325
|
-
adminListLoading: '加载中…',
|
|
326
|
-
adminListEmpty: '(还没有管理员 — IM 服务管理命令处于禁用状态)',
|
|
327
|
-
adminUserIdPlaceholder: '该平台的 user id(如 wxid_… / 123456 / ou_…)',
|
|
328
|
-
adminAdd: '添加管理员',
|
|
329
|
-
adminRemove: '移除',
|
|
330
|
-
adminConfirmRemove: '移除管理员 {p}:{u}?',
|
|
331
|
-
adminPublicBindWarn: '⚠️ web 控制台目前监听公开地址 — 为防止陌生访问者擅自给自己加 admin,此编辑器已禁用。绑回 127.0.0.1 后启用。',
|
|
332
|
-
adminBootstrapHint: '检测到一次性 token 文件在 {path}。在服务器上 `cat` 该文件取 token,然后在 IM 里发 /setup admin <token> 即可把自己设为 admin。',
|
|
333
|
-
|
|
334
|
-
// ── 审批策略 (超时默认决策) ───────────────────────────────
|
|
335
|
-
approvalPolicyTitle: '审批策略',
|
|
336
|
-
approvalPolicyHint: '当工具调用审批超时(IM 端没在限定时间内回复)时 Agim 该怎么办?默认 deny 严格、人在回路;切到 allow 是"出门模式",让 agent 继续跑、不卡你。',
|
|
337
|
-
approvalTimeoutDefaultLabel: '审批超时时',
|
|
338
|
-
approvalTimeoutDeny: '拒绝(默认 — 严格,人工把关)',
|
|
339
|
-
approvalTimeoutAllow: '放行(出门模式 — agent 静默继续)',
|
|
340
|
-
approvalPolicySave: '保存',
|
|
341
|
-
approvalPolicySaved: '已保存 — 下一个挂起的审批起就生效,无需重启。',
|
|
342
|
-
approvalPolicyLoadFailed: '加载审批策略失败',
|
|
343
|
-
|
|
344
|
-
// ── Viewer (长内容 web 渲染) ─────────────────────────────
|
|
345
|
-
viewerTitle: 'IM 长内容 Viewer',
|
|
346
|
-
viewerHint: 'Agent 回答过长或含 markdown 表格 / 大段代码时,Agim 把全文存到本机 ~/.agim/viewer.db(永久保存,绝不离开本机),IM 里只发短摘要 + 跳转链接。这里填你反代到本机 Agim web 的公网域名,链接才能让手机/微信打开。',
|
|
347
|
-
viewerEnabled: '开启',
|
|
348
|
-
viewerDisabled: '关闭',
|
|
349
|
-
viewerEnabledLabel: 'Viewer 状态',
|
|
350
|
-
viewerPublicUrl: '公网域名',
|
|
351
|
-
viewerPublicUrlHint: '指向本机 Agim web 的公网域名(例如 https://agim.example.com)。用 cloudflared / caddy / tailscale 把 3000 端口暴露出去即可。',
|
|
352
|
-
viewerChars: '字符阈值',
|
|
353
|
-
viewerLines: '行数阈值',
|
|
354
|
-
viewerCodeLines: '代码块行数阈值',
|
|
355
|
-
viewerMaxPastes: '本机最多保留条数(LRU 淘汰)',
|
|
356
|
-
viewerSave: '保存',
|
|
357
|
-
viewerSaved: '已保存 — 下一条回复生效。',
|
|
358
|
-
viewerSaveFailed: '保存 Viewer 配置失败',
|
|
359
|
-
viewerMissingUrl: '⚠️ 公网域名为空 — 长回复将退回到内联文本(无法构造跳转链接)。',
|
|
360
|
-
viewerTunnelMode: '自动 tunnel(cloudflared)',
|
|
361
|
-
viewerTunnelHint: '给没有公网域名的用户。Agim 启动时自动拉起 cloudflared quick tunnel,拿一个临时 `*.trycloudflare.com` URL。需要先装 `cloudflared`(`brew install cloudflared` / `apt install cloudflared`)。**重启后 URL 会变,旧 paste 链接打不开**。有反代域名的话保持关闭即可。',
|
|
362
|
-
viewerTunnelOff: '关闭(用上面的公网域名)',
|
|
363
|
-
viewerTunnelQuick: 'Quick tunnel(临时 URL,无需域名)',
|
|
364
|
-
viewerTunnelStatus: 'Tunnel 状态',
|
|
365
|
-
viewerTunnelRunning: '运行中',
|
|
366
|
-
viewerTunnelNotRunning: '未运行',
|
|
367
|
-
viewerTunnelBinaryMissing: '未安装 cloudflared',
|
|
368
|
-
viewerTunnelCurrentUrl: '当前 URL',
|
|
369
|
-
viewerTunnelRefresh: '刷新',
|
|
370
|
-
a2aNotifyTitle: 'A2A 中间状态通知',
|
|
371
|
-
a2aNotifyHint: '当一个 agent 调另一个 agent(例如 Claude → Codex)时,把开始/进度/完成/超时的状态推到你的 IM 会话,避免长任务"哑巴等"。',
|
|
372
|
-
a2aDefaultTimeout: 'A2A 默认超时(分钟)',
|
|
373
|
-
a2aDefaultTimeoutHint: 'agim 等子 agent 的时长上限。v1.1.10 从 10 min 提到 30 min — 足够 Codex 拉几次 API + 写报告。',
|
|
374
|
-
a2aMaxTimeout: 'A2A 最大允许超时(分钟)',
|
|
375
|
-
a2aMaxTimeoutHint: '硬上限。agent 调用时即使传更大的 timeoutMs 也会被截到这个值。防止 A2A 被永久卡住。',
|
|
376
|
-
a2aNotifyMode: '通知模式',
|
|
377
|
-
a2aNotifyOff: '关闭',
|
|
378
|
-
a2aNotifyEssential: '精简(仅 start + 终态)',
|
|
379
|
-
a2aNotifyVerbose: '详细(额外在文件变化时推 heartbeat)',
|
|
380
|
-
a2aMaxDepth: '最大通知深度',
|
|
381
|
-
a2aMaxDepthHint: '只对嵌套深度 ≤ 此值的子调用发通知(1 = 仅直接子调用)。避免深度嵌套刷屏。',
|
|
382
|
-
a2aHeartbeatMin: 'Heartbeat 间隔(分钟,详细模式生效)',
|
|
383
|
-
a2aHeartbeatHint: '每隔这么久检查一次子 agent 的 _agim-output/ 目录;有新文件就推一条,没变化就推"仍在 think"。',
|
|
384
|
-
a2aSave: '保存',
|
|
385
|
-
a2aSaved: '已保存 — 下次 A2A 调用生效,无需重启。',
|
|
386
|
-
a2aSaveFailed: '保存 A2A 配置失败',
|
|
387
|
-
memoryTitle: '长期记忆',
|
|
388
|
-
memoryHint: 'agent 在每次回复后异步从对话中提取"值得长期记住的事实"(用户偏好 / 持仓 / 项目等),存入本机 ~/.agim/memory.db;下一次对话自动注入一个 ~150-300 字符的 persona 摘要到提示头部,并通过 memory_query MCP 工具按需检索具体事实。开启后会有额外 LLM 调用(用当前对话使用的 agent,不强制 Claude),可在 Cost & Health 里观察成本。',
|
|
389
|
-
memoryEnable: '启用',
|
|
390
|
-
memoryDisable: '关闭',
|
|
391
|
-
memoryEnableLabel: '自动化记忆',
|
|
392
|
-
memorySave: '保存',
|
|
393
|
-
memorySaved: '已保存 — 下次回复后开始累积事实。',
|
|
394
|
-
memorySaveFailed: '保存记忆配置失败',
|
|
395
|
-
},
|
|
396
|
-
};
|
|
397
|
-
function t(key) { return T[window.__lang][key] || T.en[key] || key; }
|
|
398
|
-
document.addEventListener('DOMContentLoaded', () => { document.title = t('title'); });
|
|
399
|
-
</script>
|
|
400
|
-
<style>
|
|
401
|
-
/* Three-state theming. `:root` defaults to light; explicit
|
|
402
|
-
data-theme="dark" forces dark; `prefers-color-scheme: dark` only
|
|
403
|
-
applies when the attribute is absent (mode === 'system'). */
|
|
404
|
-
:root {
|
|
405
|
-
color-scheme: light dark;
|
|
406
|
-
/* light defaults */
|
|
407
|
-
--bg: #f8f9fb;
|
|
408
|
-
--surface: #ffffff;
|
|
409
|
-
--surface2: #f1f3f6;
|
|
410
|
-
--border: #e1e4e8;
|
|
411
|
-
--text: #1a1f2e;
|
|
412
|
-
--text-dim: #6b7280;
|
|
413
|
-
--accent: #6366f1;
|
|
414
|
-
--accent-dim: #818cf8;
|
|
415
|
-
--green: #16a34a;
|
|
416
|
-
--red: #dc2626;
|
|
417
|
-
--yellow: #ca8a04;
|
|
418
|
-
--radius: 8px;
|
|
419
|
-
}
|
|
420
|
-
:root[data-theme="dark"] {
|
|
421
|
-
--bg: #0a0a0a;
|
|
422
|
-
--surface: #141414;
|
|
423
|
-
--surface2: #1e1e1e;
|
|
424
|
-
--border: #2a2a2a;
|
|
425
|
-
--text: #e5e5e5;
|
|
426
|
-
--text-dim: #888;
|
|
427
|
-
--accent: #6366f1;
|
|
428
|
-
--accent-dim: #4f46e5;
|
|
429
|
-
--green: #22c55e;
|
|
430
|
-
--red: #ef4444;
|
|
431
|
-
--yellow: #eab308;
|
|
432
|
-
}
|
|
433
|
-
@media (prefers-color-scheme: dark) {
|
|
434
|
-
:root:not([data-theme]) {
|
|
435
|
-
--bg: #0a0a0a;
|
|
436
|
-
--surface: #141414;
|
|
437
|
-
--surface2: #1e1e1e;
|
|
438
|
-
--border: #2a2a2a;
|
|
439
|
-
--text: #e5e5e5;
|
|
440
|
-
--text-dim: #888;
|
|
441
|
-
--accent-dim: #4f46e5;
|
|
442
|
-
--green: #22c55e;
|
|
443
|
-
--red: #ef4444;
|
|
444
|
-
--yellow: #eab308;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
449
|
-
|
|
450
|
-
body {
|
|
451
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
452
|
-
background: var(--bg);
|
|
453
|
-
color: var(--text);
|
|
454
|
-
min-height: 100vh;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
.header {
|
|
458
|
-
display: flex;
|
|
459
|
-
align-items: center;
|
|
460
|
-
gap: 16px;
|
|
461
|
-
padding: 14px 28px;
|
|
462
|
-
border-bottom: 1px solid var(--border);
|
|
463
|
-
background: var(--surface);
|
|
464
|
-
position: sticky;
|
|
465
|
-
top: 0;
|
|
466
|
-
z-index: 5;
|
|
467
|
-
}
|
|
468
|
-
.header .brand {
|
|
469
|
-
display: flex;
|
|
470
|
-
align-items: baseline;
|
|
471
|
-
gap: 12px;
|
|
472
|
-
min-width: 0;
|
|
473
|
-
}
|
|
474
|
-
.header .brand a {
|
|
475
|
-
color: var(--text-dim);
|
|
476
|
-
font-size: 13px;
|
|
477
|
-
white-space: nowrap;
|
|
478
|
-
}
|
|
479
|
-
.header .brand a:hover { color: var(--accent); }
|
|
480
|
-
.header .brand .title {
|
|
481
|
-
font-weight: 600;
|
|
482
|
-
font-size: 15px;
|
|
483
|
-
color: var(--text);
|
|
484
|
-
letter-spacing: 0.2px;
|
|
485
|
-
white-space: nowrap;
|
|
486
|
-
}
|
|
487
|
-
.header .controls {
|
|
488
|
-
margin-left: auto;
|
|
489
|
-
display: flex;
|
|
490
|
-
align-items: center;
|
|
491
|
-
gap: 8px;
|
|
492
|
-
flex-shrink: 0;
|
|
493
|
-
}
|
|
494
|
-
.header .controls select,
|
|
495
|
-
.header .controls button {
|
|
496
|
-
width: auto;
|
|
497
|
-
min-width: 0;
|
|
498
|
-
margin: 0;
|
|
499
|
-
background: var(--surface2);
|
|
500
|
-
color: var(--text);
|
|
501
|
-
border: 1px solid var(--border);
|
|
502
|
-
border-radius: 6px;
|
|
503
|
-
padding: 6px 10px;
|
|
504
|
-
font-size: 12px;
|
|
505
|
-
cursor: pointer;
|
|
506
|
-
outline: none;
|
|
507
|
-
white-space: nowrap;
|
|
508
|
-
}
|
|
509
|
-
.header .controls select:hover,
|
|
510
|
-
.header .controls button:hover { border-color: var(--text-dim); }
|
|
511
|
-
|
|
512
|
-
.container {
|
|
513
|
-
max-width: 880px;
|
|
514
|
-
margin: 0 auto;
|
|
515
|
-
padding: 32px 28px 64px;
|
|
516
|
-
}
|
|
517
|
-
.container h1 {
|
|
518
|
-
font-size: 24px;
|
|
519
|
-
font-weight: 700;
|
|
520
|
-
margin-bottom: 24px;
|
|
521
|
-
letter-spacing: -0.2px;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/* Cards */
|
|
525
|
-
.card {
|
|
526
|
-
background: var(--surface);
|
|
527
|
-
border: 1px solid var(--border);
|
|
528
|
-
border-radius: 12px;
|
|
529
|
-
padding: 22px 24px;
|
|
530
|
-
margin-bottom: 18px;
|
|
531
|
-
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
|
|
532
|
-
}
|
|
533
|
-
:root[data-theme="dark"] .card { box-shadow: none; }
|
|
534
|
-
@media (prefers-color-scheme: dark) {
|
|
535
|
-
:root:not([data-theme]) .card { box-shadow: none; }
|
|
536
|
-
}
|
|
537
|
-
.card h2 {
|
|
538
|
-
font-size: 15px;
|
|
539
|
-
font-weight: 600;
|
|
540
|
-
margin-bottom: 18px;
|
|
541
|
-
display: flex;
|
|
542
|
-
align-items: center;
|
|
543
|
-
gap: 8px;
|
|
544
|
-
letter-spacing: -0.1px;
|
|
545
|
-
}
|
|
546
|
-
.card h2 .badge {
|
|
547
|
-
font-size: 11px;
|
|
548
|
-
padding: 2px 8px;
|
|
549
|
-
border-radius: 10px;
|
|
550
|
-
background: var(--surface2);
|
|
551
|
-
color: var(--text-dim);
|
|
552
|
-
font-weight: 400;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/* Form elements */
|
|
556
|
-
label {
|
|
557
|
-
display: block;
|
|
558
|
-
font-size: 12px;
|
|
559
|
-
color: var(--text-dim);
|
|
560
|
-
margin-bottom: 4px;
|
|
561
|
-
font-weight: 600;
|
|
562
|
-
}
|
|
563
|
-
input, select, textarea {
|
|
564
|
-
width: 100%;
|
|
565
|
-
background: var(--surface2);
|
|
566
|
-
color: var(--text);
|
|
567
|
-
border: 1px solid var(--border);
|
|
568
|
-
border-radius: 6px;
|
|
569
|
-
padding: 8px 12px;
|
|
570
|
-
font-size: 13px;
|
|
571
|
-
font-family: inherit;
|
|
572
|
-
outline: none;
|
|
573
|
-
margin-bottom: 12px;
|
|
574
|
-
}
|
|
575
|
-
input:focus, select:focus, textarea:focus {
|
|
576
|
-
border-color: var(--accent);
|
|
577
|
-
}
|
|
578
|
-
input::placeholder { color: #555; }
|
|
579
|
-
|
|
580
|
-
.row {
|
|
581
|
-
display: flex;
|
|
582
|
-
gap: 12px;
|
|
583
|
-
}
|
|
584
|
-
.row > * { flex: 1; }
|
|
585
|
-
|
|
586
|
-
/* Buttons */
|
|
587
|
-
.btn {
|
|
588
|
-
display: inline-flex;
|
|
589
|
-
align-items: center;
|
|
590
|
-
gap: 4px;
|
|
591
|
-
padding: 7px 14px;
|
|
592
|
-
border-radius: 6px;
|
|
593
|
-
font-size: 13px;
|
|
594
|
-
cursor: pointer;
|
|
595
|
-
border: 1px solid var(--border);
|
|
596
|
-
background: var(--surface2);
|
|
597
|
-
color: var(--text);
|
|
598
|
-
transition: all 0.15s;
|
|
599
|
-
}
|
|
600
|
-
.btn:hover { border-color: var(--text-dim); }
|
|
601
|
-
.btn-primary {
|
|
602
|
-
background: var(--accent);
|
|
603
|
-
border-color: var(--accent);
|
|
604
|
-
color: #fff;
|
|
605
|
-
}
|
|
606
|
-
.btn-primary:hover { background: var(--accent-dim); }
|
|
607
|
-
.btn-danger { color: var(--red); }
|
|
608
|
-
.btn-danger:hover { background: var(--surface2); border-color: var(--red); }
|
|
609
|
-
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
610
|
-
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
611
|
-
|
|
612
|
-
.actions { display: flex; gap: 8px; margin-top: 12px; }
|
|
613
|
-
|
|
614
|
-
/* Status dots */
|
|
615
|
-
.status { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
|
|
616
|
-
.dot {
|
|
617
|
-
width: 8px; height: 8px;
|
|
618
|
-
border-radius: 50%;
|
|
619
|
-
display: inline-block;
|
|
620
|
-
}
|
|
621
|
-
.dot-on { background: var(--green); }
|
|
622
|
-
.dot-off { background: var(--red); }
|
|
623
|
-
|
|
624
|
-
/* Agent list */
|
|
625
|
-
.agent-row {
|
|
626
|
-
display: flex;
|
|
627
|
-
align-items: center;
|
|
628
|
-
justify-content: space-between;
|
|
629
|
-
padding: 10px 0;
|
|
630
|
-
border-bottom: 1px solid var(--border);
|
|
631
|
-
}
|
|
632
|
-
.agent-row:last-child { border-bottom: none; }
|
|
633
|
-
.agent-row .left { display: flex; align-items: center; gap: 10px; }
|
|
634
|
-
.agent-row .name { font-weight: 600; font-size: 14px; }
|
|
635
|
-
.agent-row .hint { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
|
|
636
|
-
.agent-row .aliases { font-size: 11px; color: var(--text-dim); }
|
|
637
|
-
|
|
638
|
-
/* ACP table */
|
|
639
|
-
.acp-table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
|
640
|
-
.acp-table th {
|
|
641
|
-
text-align: left;
|
|
642
|
-
font-size: 11px;
|
|
643
|
-
color: var(--text-dim);
|
|
644
|
-
padding: 6px 8px;
|
|
645
|
-
border-bottom: 1px solid var(--border);
|
|
646
|
-
font-weight: 600;
|
|
647
|
-
}
|
|
648
|
-
.acp-table td {
|
|
649
|
-
padding: 8px;
|
|
650
|
-
font-size: 13px;
|
|
651
|
-
border-bottom: 1px solid var(--border);
|
|
652
|
-
vertical-align: middle;
|
|
653
|
-
}
|
|
654
|
-
.acp-table tr:last-child td { border-bottom: none; }
|
|
655
|
-
|
|
656
|
-
/* Toggle */
|
|
657
|
-
.toggle {
|
|
658
|
-
position: relative;
|
|
659
|
-
width: 36px;
|
|
660
|
-
height: 20px;
|
|
661
|
-
background: var(--border);
|
|
662
|
-
border-radius: 10px;
|
|
663
|
-
cursor: pointer;
|
|
664
|
-
transition: background 0.2s;
|
|
665
|
-
flex-shrink: 0;
|
|
666
|
-
}
|
|
667
|
-
.toggle.active { background: var(--green); }
|
|
668
|
-
.toggle::after {
|
|
669
|
-
content: '';
|
|
670
|
-
position: absolute;
|
|
671
|
-
top: 2px; left: 2px;
|
|
672
|
-
width: 16px; height: 16px;
|
|
673
|
-
background: #fff;
|
|
674
|
-
border-radius: 50%;
|
|
675
|
-
transition: transform 0.2s;
|
|
676
|
-
}
|
|
677
|
-
.toggle.active::after { transform: translateX(16px); }
|
|
678
|
-
|
|
679
|
-
/* Toast */
|
|
680
|
-
.toast {
|
|
681
|
-
position: fixed;
|
|
682
|
-
bottom: 24px;
|
|
683
|
-
right: 24px;
|
|
684
|
-
padding: 10px 18px;
|
|
685
|
-
border-radius: 8px;
|
|
686
|
-
font-size: 13px;
|
|
687
|
-
z-index: 999;
|
|
688
|
-
opacity: 0;
|
|
689
|
-
transform: translateY(10px);
|
|
690
|
-
transition: all 0.3s;
|
|
691
|
-
}
|
|
692
|
-
.toast.show { opacity: 1; transform: translateY(0); }
|
|
693
|
-
.toast.success { background: var(--surface); border: 1px solid var(--green); color: var(--green); }
|
|
694
|
-
.toast.error { background: var(--surface); border: 1px solid var(--red); color: var(--red); }
|
|
695
|
-
|
|
696
|
-
/* Section hint */
|
|
697
|
-
.hint-box {
|
|
698
|
-
background: var(--surface2);
|
|
699
|
-
border-radius: 6px;
|
|
700
|
-
padding: 10px 14px;
|
|
701
|
-
font-size: 12px;
|
|
702
|
-
color: var(--text-dim);
|
|
703
|
-
margin-bottom: 12px;
|
|
704
|
-
line-height: 1.5;
|
|
705
|
-
}
|
|
706
|
-
.hint-box code {
|
|
707
|
-
background: var(--bg);
|
|
708
|
-
padding: 1px 5px;
|
|
709
|
-
border-radius: 3px;
|
|
710
|
-
font-family: 'SF Mono', monospace;
|
|
711
|
-
font-size: 11px;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/* Divider */
|
|
715
|
-
.divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
|
716
|
-
|
|
717
|
-
/* Loading */
|
|
718
|
-
.loading {
|
|
719
|
-
text-align: center;
|
|
720
|
-
padding: 40px;
|
|
721
|
-
color: var(--text-dim);
|
|
722
|
-
}
|
|
723
|
-
</style>
|
|
724
|
-
</head>
|
|
725
|
-
<body>
|
|
726
|
-
<div class="header">
|
|
727
|
-
<div class="brand">
|
|
728
|
-
<a href="/" id="backToChat" aria-label="Back to chat"></a>
|
|
729
|
-
<span class="title" id="settingsTitle"></span>
|
|
730
|
-
</div>
|
|
731
|
-
<div class="controls">
|
|
732
|
-
<select id="langSelect">
|
|
733
|
-
<option value="en">EN</option>
|
|
734
|
-
<option value="zh">中文</option>
|
|
735
|
-
</select>
|
|
736
|
-
<button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
|
|
737
|
-
</div>
|
|
738
|
-
</div>
|
|
739
|
-
|
|
740
|
-
<div class="container" id="app">
|
|
741
|
-
<div class="loading">Loading configuration...</div>
|
|
742
|
-
</div>
|
|
743
|
-
|
|
744
|
-
<div class="toast" id="toast"></div>
|
|
745
|
-
|
|
746
|
-
<script>
|
|
747
|
-
function esc(s) {
|
|
748
|
-
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// Apply i18n to static elements
|
|
752
|
-
function applyLang() {
|
|
753
|
-
document.title = t('title');
|
|
754
|
-
document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
|
|
755
|
-
document.getElementById('backToChat').textContent = t('backToChat');
|
|
756
|
-
document.getElementById('settingsTitle').textContent = t('settingsTitle');
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Theme toggle (light / dark / system). _app.js applied theme already;
|
|
760
|
-
// here we wire the click handler so cycling re-renders the icon/label.
|
|
761
|
-
if (window.imhub) imhub.theme.bindToggle(document.getElementById('theme-toggle'));
|
|
762
|
-
|
|
763
|
-
// Language selector
|
|
764
|
-
const langSelect = document.getElementById('langSelect');
|
|
765
|
-
langSelect.value = window.__lang;
|
|
766
|
-
langSelect.addEventListener('change', () => {
|
|
767
|
-
window.__lang = langSelect.value;
|
|
768
|
-
localStorage.setItem('im-hub-lang', window.__lang);
|
|
769
|
-
applyLang();
|
|
770
|
-
render();
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
// State
|
|
774
|
-
let config = null;
|
|
775
|
-
/** Workspace list pulled from /api/workspaces?full=1. Null until first
|
|
776
|
-
* fetch completes; render falls through with empty rows in that case
|
|
777
|
-
* and refetch happens after init() completes. */
|
|
778
|
-
let workspaceList = null;
|
|
779
|
-
let agentStatus = {};
|
|
780
|
-
|
|
781
|
-
// DOM
|
|
782
|
-
const app = document.getElementById('app');
|
|
783
|
-
const toastEl = document.getElementById('toast');
|
|
784
|
-
|
|
785
|
-
// Known built-in agents with install info
|
|
786
|
-
const BUILTIN_AGENTS = {
|
|
787
|
-
'claude-code': { aliases: ['cc', 'claude'], pkg: '@anthropic-ai/claude-code', cmd: 'claude' },
|
|
788
|
-
'codex': { aliases: ['cx'], pkg: '@openai/codex', cmd: 'codex' },
|
|
789
|
-
'opencode': { aliases: ['oc'], pkg: 'opencode-ai', cmd: 'opencode' },
|
|
790
|
-
};
|
|
791
|
-
|
|
792
|
-
// Helper: API requests share the page origin so the session cookie
|
|
793
|
-
// (if any reverse-proxy added one) is sent automatically.
|
|
794
|
-
function authFetch(url, init = {}) {
|
|
795
|
-
const headers = { ...(init.headers || {}) };
|
|
796
|
-
// v1.1.10: attach access token (Bearer + Cookie both work, but
|
|
797
|
-
// setting Bearer ensures direct curl-style usage from devtools also
|
|
798
|
-
// succeeds). 401 → redirect to /login with `next` set to current URL.
|
|
799
|
-
if (!headers['Authorization']) {
|
|
800
|
-
try {
|
|
801
|
-
const tk = localStorage.getItem('agim_token');
|
|
802
|
-
if (tk) headers['Authorization'] = `Bearer ${tk}`;
|
|
803
|
-
} catch { /* ignore */ }
|
|
804
|
-
}
|
|
805
|
-
return fetch(url, { ...init, headers, credentials: 'same-origin' }).then(r => {
|
|
806
|
-
if (r.status === 401) {
|
|
807
|
-
try { localStorage.removeItem('agim_token'); } catch {}
|
|
808
|
-
const next = encodeURIComponent(location.pathname + location.search);
|
|
809
|
-
location.href = `/login?next=${next}`;
|
|
810
|
-
}
|
|
811
|
-
return r;
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// Fetch config on load
|
|
816
|
-
async function init() {
|
|
817
|
-
try {
|
|
818
|
-
const res = await authFetch('/api/config');
|
|
819
|
-
if (!res.ok) throw new Error(t('loadFailed'));
|
|
820
|
-
const data = await res.json();
|
|
821
|
-
config = data;
|
|
822
|
-
agentStatus = data.agentStatus || {};
|
|
823
|
-
// Workspaces come from a separate endpoint so the registry's
|
|
824
|
-
// runtime state (which may include extra/removed entries via
|
|
825
|
-
// REST CRUD since process start) is the source of truth.
|
|
826
|
-
try {
|
|
827
|
-
const wres = await authFetch('/api/workspaces?full=1');
|
|
828
|
-
if (wres.ok) workspaceList = (await wres.json()).workspaces || [];
|
|
829
|
-
} catch { /* fall through with workspaceList = null */ }
|
|
830
|
-
render();
|
|
831
|
-
} catch (err) {
|
|
832
|
-
app.innerHTML = `<div class="loading" style="color:var(--red)">${t('loadFailed')}: ${esc(err.message)}</div>`;
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
/** Refetch the workspace list and re-render just the workspaces card.
|
|
837
|
-
* Cheaper than a full settings re-render for create/update/delete. */
|
|
838
|
-
async function reloadWorkspaces() {
|
|
839
|
-
try {
|
|
840
|
-
const wres = await authFetch('/api/workspaces?full=1');
|
|
841
|
-
if (wres.ok) workspaceList = (await wres.json()).workspaces || [];
|
|
842
|
-
} catch { /* keep stale list, surface via toast on next save */ }
|
|
843
|
-
render();
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
function render() {
|
|
847
|
-
app.innerHTML = `
|
|
848
|
-
<h1>${t('h1')}</h1>
|
|
849
|
-
${renderServiceCard()}
|
|
850
|
-
${renderSafetyCard()}
|
|
851
|
-
${renderApprovalPolicyCard()}
|
|
852
|
-
${renderAgentsCard()}
|
|
853
|
-
${renderMessengersCard()}
|
|
854
|
-
${renderAcpCard()}
|
|
855
|
-
${renderWorkspacesCard()}
|
|
856
|
-
${renderGeneralCard()}
|
|
857
|
-
`;
|
|
858
|
-
bindEvents();
|
|
859
|
-
// Service status loads asynchronously; the card renders with a
|
|
860
|
-
// placeholder, populated by the first /api/service/status response.
|
|
861
|
-
void loadServiceStatus();
|
|
862
|
-
// Admin allowlist list — loads via /api/admin-allowlist.
|
|
863
|
-
void loadAdminList();
|
|
864
|
-
// Approval policy (IMHUB_TIMEOUT_DEFAULT) — loaded via /api/env.
|
|
865
|
-
void loadApprovalPolicy();
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// ==========================================
|
|
869
|
-
// Safety card — IM admin allowlist editor.
|
|
870
|
-
// The Claude --dangerously-skip-permissions toggle was removed in
|
|
871
|
-
// v1.0.12: Claude CLI refuses that flag when running as root, which
|
|
872
|
-
// is the default systemd deployment, so the toggle was a footgun.
|
|
873
|
-
// Operators who still want it can set IMHUB_DANGEROUSLY_SKIP_
|
|
874
|
-
// PERMISSIONS=1 in ~/.agim/env manually (non-root only).
|
|
875
|
-
// ==========================================
|
|
876
|
-
function renderSafetyCard() {
|
|
877
|
-
return `
|
|
878
|
-
<div class="card">
|
|
879
|
-
<h2>${t('safetyTitle')} ⚠️</h2>
|
|
880
|
-
<div style="font-size:13px;font-weight:600;margin-bottom:6px">${t('adminListTitle')}</div>
|
|
881
|
-
<div class="hint" style="margin-bottom:10px">${t('adminListHint')}</div>
|
|
882
|
-
<div id="admin-list" style="margin-bottom:10px">${t('adminListLoading')}</div>
|
|
883
|
-
<div class="row">
|
|
884
|
-
<div>
|
|
885
|
-
<label>Platform</label>
|
|
886
|
-
<input type="text" id="admin-platform" placeholder="wechat-ilink / telegram / feishu …">
|
|
887
|
-
</div>
|
|
888
|
-
<div>
|
|
889
|
-
<label>User ID</label>
|
|
890
|
-
<input type="text" id="admin-userid" placeholder="${t('adminUserIdPlaceholder')}">
|
|
891
|
-
</div>
|
|
892
|
-
</div>
|
|
893
|
-
<div class="actions">
|
|
894
|
-
<button type="button" class="btn btn-primary" id="admin-add">${t('adminAdd')}</button>
|
|
895
|
-
</div>
|
|
896
|
-
<div class="hint" id="admin-public-warn" style="margin-top:8px;display:none;color:var(--yellow)">
|
|
897
|
-
${t('adminPublicBindWarn')}
|
|
898
|
-
</div>
|
|
899
|
-
</div>
|
|
900
|
-
`;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// ─── Admin allowlist editor ──────────────────────────────────
|
|
904
|
-
async function loadAdminList() {
|
|
905
|
-
const listEl = document.getElementById('admin-list');
|
|
906
|
-
const warnEl = document.getElementById('admin-public-warn');
|
|
907
|
-
if (!listEl) return;
|
|
908
|
-
try {
|
|
909
|
-
const data = await authFetch('/api/admin-allowlist').then(r => r.json());
|
|
910
|
-
const admins = data.admins || [];
|
|
911
|
-
if (admins.length === 0) {
|
|
912
|
-
let inner = `<div class="hint" style="font-style:italic">${esc(t('adminListEmpty'))}</div>`;
|
|
913
|
-
if (data.bootstrapAvailable) {
|
|
914
|
-
inner += `<div class="hint" style="margin-top:6px;color:var(--yellow)">${esc(t('adminBootstrapHint').replace('{path}', data.bootstrapTokenPath || '~/.agim/admin-bootstrap-token'))}</div>`;
|
|
915
|
-
}
|
|
916
|
-
listEl.innerHTML = inner;
|
|
917
|
-
} else {
|
|
918
|
-
listEl.innerHTML = admins.map(a => `
|
|
919
|
-
<div class="agent-row" style="padding:6px 0">
|
|
920
|
-
<div class="left">
|
|
921
|
-
<div>
|
|
922
|
-
<div style="font-family:monospace;font-size:13px">${esc(a.platform)}:${esc(a.userId)}</div>
|
|
923
|
-
</div>
|
|
924
|
-
</div>
|
|
925
|
-
<button type="button" class="btn btn-sm btn-danger" data-admin-remove='${esc(JSON.stringify({platform: a.platform, userId: a.userId}))}'>${esc(t('adminRemove'))}</button>
|
|
926
|
-
</div>
|
|
927
|
-
`).join('');
|
|
928
|
-
// Wire the remove buttons
|
|
929
|
-
listEl.querySelectorAll('[data-admin-remove]').forEach(btn => {
|
|
930
|
-
btn.addEventListener('click', async () => {
|
|
931
|
-
const meta = JSON.parse(btn.getAttribute('data-admin-remove') || '{}');
|
|
932
|
-
if (!confirm(t('adminConfirmRemove').replace('{p}', meta.platform).replace('{u}', meta.userId))) return;
|
|
933
|
-
try {
|
|
934
|
-
const res = await authFetch('/api/admin-allowlist', {
|
|
935
|
-
method: 'DELETE',
|
|
936
|
-
headers: { 'Content-Type': 'application/json' },
|
|
937
|
-
body: JSON.stringify(meta),
|
|
938
|
-
});
|
|
939
|
-
if (!res.ok) {
|
|
940
|
-
const j = await res.json().catch(() => ({}));
|
|
941
|
-
throw new Error(j.error || res.statusText);
|
|
942
|
-
}
|
|
943
|
-
await loadAdminList();
|
|
944
|
-
} catch (err) {
|
|
945
|
-
toast(t('error') + ': ' + (err && err.message ? err.message : err), 'error');
|
|
946
|
-
}
|
|
947
|
-
});
|
|
948
|
-
});
|
|
949
|
-
}
|
|
950
|
-
} catch (err) {
|
|
951
|
-
listEl.textContent = t('error') + ': ' + (err && err.message ? err.message : err);
|
|
952
|
-
}
|
|
953
|
-
// The public-bind warning shows when /api/service/status reports a
|
|
954
|
-
// non-loopback bind. Re-use that endpoint instead of adding another.
|
|
955
|
-
try {
|
|
956
|
-
const status = await authFetch('/api/service/status').then(r => r.json());
|
|
957
|
-
if (warnEl && status.web && status.web.bind && status.web.bind !== '127.0.0.1' && status.web.bind !== 'localhost' && status.web.bind !== '::1') {
|
|
958
|
-
warnEl.style.display = 'block';
|
|
959
|
-
// Also disable the Add button
|
|
960
|
-
const addBtn = document.getElementById('admin-add');
|
|
961
|
-
if (addBtn) addBtn.disabled = true;
|
|
962
|
-
}
|
|
963
|
-
} catch { /* non-fatal */ }
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
// ==========================================
|
|
967
|
-
// Service control card
|
|
968
|
-
// ==========================================
|
|
969
|
-
function renderServiceCard() {
|
|
970
|
-
return `
|
|
971
|
-
<div class="card">
|
|
972
|
-
<h2>${t('svcTitle')}</h2>
|
|
973
|
-
<div class="status" id="svc-state" style="margin-bottom:14px">
|
|
974
|
-
<span class="dot dot-off"></span>${t('svcStateLoading')}
|
|
975
|
-
</div>
|
|
976
|
-
<div class="actions">
|
|
977
|
-
<button type="button" class="btn btn-primary" id="svc-restart">${t('svcRestart')}</button>
|
|
978
|
-
<button type="button" class="btn" id="svc-stop">${t('svcStop')}</button>
|
|
979
|
-
<button type="button" class="btn" id="svc-start" disabled>${t('svcStart')}</button>
|
|
980
|
-
</div>
|
|
981
|
-
</div>
|
|
982
|
-
`;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// ==========================================
|
|
986
|
-
// Approval Policy card — IMHUB_TIMEOUT_DEFAULT (deny|allow).
|
|
987
|
-
// Hot-reloads via process.env mutation in handlePutEnv, no restart.
|
|
988
|
-
// ==========================================
|
|
989
|
-
function renderApprovalPolicyCard() {
|
|
990
|
-
return `
|
|
991
|
-
<div class="card">
|
|
992
|
-
<h2>${t('approvalPolicyTitle')}</h2>
|
|
993
|
-
<div class="hint" style="margin-bottom:10px">${t('approvalPolicyHint')}</div>
|
|
994
|
-
<div style="margin-bottom:8px">
|
|
995
|
-
<label for="approval-timeout-default">${t('approvalTimeoutDefaultLabel')}</label>
|
|
996
|
-
<select id="approval-timeout-default">
|
|
997
|
-
<option value="deny">${t('approvalTimeoutDeny')}</option>
|
|
998
|
-
<option value="allow">${t('approvalTimeoutAllow')}</option>
|
|
999
|
-
</select>
|
|
1000
|
-
</div>
|
|
1001
|
-
<div class="actions">
|
|
1002
|
-
<button type="button" class="btn btn-primary" id="approval-policy-save">${t('approvalPolicySave')}</button>
|
|
1003
|
-
<span id="approval-policy-status" class="hint" style="margin-left:10px"></span>
|
|
1004
|
-
</div>
|
|
1005
|
-
</div>
|
|
1006
|
-
`;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
async function loadApprovalPolicy() {
|
|
1010
|
-
const sel = document.getElementById('approval-timeout-default');
|
|
1011
|
-
const status = document.getElementById('approval-policy-status');
|
|
1012
|
-
if (!sel) return;
|
|
1013
|
-
try {
|
|
1014
|
-
const res = await authFetch('/api/env?reveal=1');
|
|
1015
|
-
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
1016
|
-
const data = await res.json();
|
|
1017
|
-
const v = (data.env && typeof data.env.IMHUB_TIMEOUT_DEFAULT === 'string')
|
|
1018
|
-
? data.env.IMHUB_TIMEOUT_DEFAULT.toLowerCase()
|
|
1019
|
-
: 'deny';
|
|
1020
|
-
sel.value = v === 'allow' ? 'allow' : 'deny';
|
|
1021
|
-
} catch (err) {
|
|
1022
|
-
if (status) status.textContent = t('approvalPolicyLoadFailed');
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
async function saveApprovalPolicy() {
|
|
1027
|
-
const sel = document.getElementById('approval-timeout-default');
|
|
1028
|
-
const status = document.getElementById('approval-policy-status');
|
|
1029
|
-
if (!sel || !status) return;
|
|
1030
|
-
const v = sel.value === 'allow' ? 'allow' : 'deny';
|
|
1031
|
-
try {
|
|
1032
|
-
// authFetch is a thin fetch wrapper that doesn't check res.ok — a
|
|
1033
|
-
// 5xx from the server still resolves the promise. Without this guard
|
|
1034
|
-
// the UI would lie ("已保存") on a failed write.
|
|
1035
|
-
const res = await authFetch('/api/env', {
|
|
1036
|
-
method: 'PUT',
|
|
1037
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1038
|
-
body: JSON.stringify({ updates: { IMHUB_TIMEOUT_DEFAULT: v } }),
|
|
1039
|
-
});
|
|
1040
|
-
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
1041
|
-
status.textContent = t('approvalPolicySaved');
|
|
1042
|
-
setTimeout(() => { status.textContent = ''; }, 4000);
|
|
1043
|
-
} catch (err) {
|
|
1044
|
-
status.textContent = t('saveFailed');
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// ==========================================
|
|
1049
|
-
// Agents card
|
|
1050
|
-
// ==========================================
|
|
1051
|
-
function renderAgentsCard() {
|
|
1052
|
-
const agents = Object.keys(BUILTIN_AGENTS);
|
|
1053
|
-
const enabledAgents = config.agents || [];
|
|
1054
|
-
const defaultAgent = config.defaultAgent || 'claude-code';
|
|
1055
|
-
|
|
1056
|
-
const rows = agents.map(name => {
|
|
1057
|
-
const info = BUILTIN_AGENTS[name];
|
|
1058
|
-
const available = agentStatus[name];
|
|
1059
|
-
const enabled = enabledAgents.includes(name);
|
|
1060
|
-
|
|
1061
|
-
return `
|
|
1062
|
-
<div class="agent-row" data-agent="${name}">
|
|
1063
|
-
<div class="left">
|
|
1064
|
-
<span class="dot ${available ? 'dot-on' : 'dot-off'}"></span>
|
|
1065
|
-
<div>
|
|
1066
|
-
<div class="name">${name}</div>
|
|
1067
|
-
<div class="aliases">${info.aliases.map(a => `/${a}`).join(', ')}</div>
|
|
1068
|
-
${!available ? `<div class="hint">${t('installHint')} ${info.pkg}</div>` : ''}
|
|
1069
|
-
</div>
|
|
1070
|
-
</div>
|
|
1071
|
-
<div class="toggle ${enabled ? 'active' : ''}" data-toggle-agent="${name}"></div>
|
|
1072
|
-
</div>
|
|
1073
|
-
`;
|
|
1074
|
-
}).join('');
|
|
1075
|
-
|
|
1076
|
-
// Default-agent picker only lists agents that are BOTH enabled and
|
|
1077
|
-
// installed — defaulting to a missing binary or a disabled adapter
|
|
1078
|
-
// both break routing the first time someone sends a message.
|
|
1079
|
-
const eligibleDefaults = agents.filter(a => enabledAgents.includes(a) && agentStatus[a]);
|
|
1080
|
-
|
|
1081
|
-
return `
|
|
1082
|
-
<div class="card">
|
|
1083
|
-
<h2>${t('agents')} <span class="badge">${agents.length}</span></h2>
|
|
1084
|
-
${rows}
|
|
1085
|
-
<hr class="divider">
|
|
1086
|
-
<label>${t('defaultAgent')}</label>
|
|
1087
|
-
<select id="defaultAgent" ${eligibleDefaults.length === 0 ? 'disabled' : ''}>
|
|
1088
|
-
${eligibleDefaults.length === 0
|
|
1089
|
-
? `<option value="">— ${esc(t('agents'))} —</option>`
|
|
1090
|
-
: eligibleDefaults.map(a =>
|
|
1091
|
-
`<option value="${esc(a)}" ${a === defaultAgent ? 'selected' : ''}>${esc(a)}</option>`
|
|
1092
|
-
).join('')
|
|
1093
|
-
}
|
|
1094
|
-
</select>
|
|
1095
|
-
<div class="actions">
|
|
1096
|
-
<button type="button" class="btn btn-primary" id="saveAgents">${t('saveAgents')}</button>
|
|
1097
|
-
</div>
|
|
1098
|
-
</div>
|
|
1099
|
-
`;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// ==========================================
|
|
1103
|
-
// Messengers card
|
|
1104
|
-
// ==========================================
|
|
1105
|
-
function renderMessengersCard() {
|
|
1106
|
-
const messengers = config.messengers || [];
|
|
1107
|
-
const tg = config.telegram || {};
|
|
1108
|
-
const fs = config.feishu || {};
|
|
1109
|
-
const dt = config.dingtalk || {};
|
|
1110
|
-
const dc = config.discord || {};
|
|
1111
|
-
|
|
1112
|
-
const wechatEnabled = messengers.includes('wechat-ilink');
|
|
1113
|
-
const telegramEnabled = messengers.includes('telegram');
|
|
1114
|
-
const feishuEnabled = messengers.includes('feishu');
|
|
1115
|
-
const dingtalkEnabled = messengers.includes('dingtalk');
|
|
1116
|
-
const discordEnabled = messengers.includes('discord');
|
|
1117
|
-
|
|
1118
|
-
return `
|
|
1119
|
-
<div class="card">
|
|
1120
|
-
<h2>${t('messengers')} <span class="badge">${messengers.length}</span></h2>
|
|
1121
|
-
|
|
1122
|
-
<!-- WeChat -->
|
|
1123
|
-
<div style="margin-bottom:16px">
|
|
1124
|
-
<div class="agent-row">
|
|
1125
|
-
<div class="left">
|
|
1126
|
-
<div>
|
|
1127
|
-
<div class="name">${t('wechat')}</div>
|
|
1128
|
-
<div class="hint">${t('wechatHint')}</div>
|
|
1129
|
-
</div>
|
|
1130
|
-
</div>
|
|
1131
|
-
<div class="toggle ${wechatEnabled ? 'active' : ''}" data-toggle-messenger="wechat-ilink"></div>
|
|
1132
|
-
</div>
|
|
1133
|
-
${wechatEnabled ? `
|
|
1134
|
-
<div class="actions">
|
|
1135
|
-
<button type="button" class="btn" id="wechatScanBtn">${t('wechatScan')}</button>
|
|
1136
|
-
</div>
|
|
1137
|
-
` : ''}
|
|
1138
|
-
</div>
|
|
1139
|
-
|
|
1140
|
-
<!-- Telegram -->
|
|
1141
|
-
<div style="margin-bottom:16px">
|
|
1142
|
-
<div class="agent-row">
|
|
1143
|
-
<div class="left">
|
|
1144
|
-
<div>
|
|
1145
|
-
<div class="name">${t('telegram')}</div>
|
|
1146
|
-
<div class="hint">${t('telegramHint')}</div>
|
|
1147
|
-
</div>
|
|
1148
|
-
</div>
|
|
1149
|
-
<div class="toggle ${telegramEnabled ? 'active' : ''}" data-toggle-messenger="telegram"></div>
|
|
1150
|
-
</div>
|
|
1151
|
-
${telegramEnabled ? `
|
|
1152
|
-
<div class="row">
|
|
1153
|
-
<div>
|
|
1154
|
-
<label>${t('botToken')}</label>
|
|
1155
|
-
<input type="password" id="tgToken" value="${esc(tg.botToken || '')}" placeholder="123456:ABC-DEF...">
|
|
1156
|
-
</div>
|
|
1157
|
-
<div>
|
|
1158
|
-
<label>${t('channelId')}</label>
|
|
1159
|
-
<input type="text" id="tgChannel" value="${esc(tg.channelId || '')}" placeholder="default">
|
|
1160
|
-
</div>
|
|
1161
|
-
</div>
|
|
1162
|
-
` : ''}
|
|
1163
|
-
</div>
|
|
1164
|
-
|
|
1165
|
-
<!-- Feishu -->
|
|
1166
|
-
<div style="margin-bottom:16px">
|
|
1167
|
-
<div class="agent-row">
|
|
1168
|
-
<div class="left">
|
|
1169
|
-
<div>
|
|
1170
|
-
<div class="name">${t('feishu')}</div>
|
|
1171
|
-
<div class="hint">${t('feishuHint')}</div>
|
|
1172
|
-
</div>
|
|
1173
|
-
</div>
|
|
1174
|
-
<div class="toggle ${feishuEnabled ? 'active' : ''}" data-toggle-messenger="feishu"></div>
|
|
1175
|
-
</div>
|
|
1176
|
-
${feishuEnabled ? `
|
|
1177
|
-
<div class="row">
|
|
1178
|
-
<div>
|
|
1179
|
-
<label>${t('appId')}</label>
|
|
1180
|
-
<input type="text" id="fsAppId" value="${esc(fs.appId || '')}" placeholder="cli_xxx">
|
|
1181
|
-
</div>
|
|
1182
|
-
<div>
|
|
1183
|
-
<label>${t('appSecret')}</label>
|
|
1184
|
-
<input type="password" id="fsAppSecret" value="${esc(fs.appSecret || '')}" placeholder="Your app secret">
|
|
1185
|
-
</div>
|
|
1186
|
-
</div>
|
|
1187
|
-
` : ''}
|
|
1188
|
-
</div>
|
|
1189
|
-
|
|
1190
|
-
<!-- DingTalk -->
|
|
1191
|
-
<div style="margin-bottom:16px">
|
|
1192
|
-
<div class="agent-row">
|
|
1193
|
-
<div class="left">
|
|
1194
|
-
<div>
|
|
1195
|
-
<div class="name">${t('dingtalk')}</div>
|
|
1196
|
-
<div class="hint">${t('dingtalkHint')}</div>
|
|
1197
|
-
</div>
|
|
1198
|
-
</div>
|
|
1199
|
-
<div class="toggle ${dingtalkEnabled ? 'active' : ''}" data-toggle-messenger="dingtalk"></div>
|
|
1200
|
-
</div>
|
|
1201
|
-
${dingtalkEnabled ? `
|
|
1202
|
-
<div class="row">
|
|
1203
|
-
<div>
|
|
1204
|
-
<label>${t('dingtalkClientId')}</label>
|
|
1205
|
-
<input type="text" id="dtClientId" value="${esc(dt.clientId || '')}" placeholder="dingxxxxxx">
|
|
1206
|
-
</div>
|
|
1207
|
-
<div>
|
|
1208
|
-
<label>${t('dingtalkClientSecret')}</label>
|
|
1209
|
-
<input type="password" id="dtClientSecret" value="${esc(dt.clientSecret || '')}" placeholder="••••••••">
|
|
1210
|
-
</div>
|
|
1211
|
-
</div>
|
|
1212
|
-
` : ''}
|
|
1213
|
-
</div>
|
|
1214
|
-
|
|
1215
|
-
<!-- Discord -->
|
|
1216
|
-
<div>
|
|
1217
|
-
<div class="agent-row">
|
|
1218
|
-
<div class="left">
|
|
1219
|
-
<div>
|
|
1220
|
-
<div class="name">${t('discord')}</div>
|
|
1221
|
-
<div class="hint">${t('discordHint')}</div>
|
|
1222
|
-
</div>
|
|
1223
|
-
</div>
|
|
1224
|
-
<div class="toggle ${discordEnabled ? 'active' : ''}" data-toggle-messenger="discord"></div>
|
|
1225
|
-
</div>
|
|
1226
|
-
${discordEnabled ? `
|
|
1227
|
-
<div class="row">
|
|
1228
|
-
<div>
|
|
1229
|
-
<label>${t('discordToken')}</label>
|
|
1230
|
-
<input type="password" id="dcToken" value="${esc(dc.botToken || '')}" placeholder="••••••••">
|
|
1231
|
-
</div>
|
|
1232
|
-
<div>
|
|
1233
|
-
<label>${t('channelId')}</label>
|
|
1234
|
-
<input type="text" id="dcChannel" value="${esc(dc.channelId || '')}" placeholder="default">
|
|
1235
|
-
</div>
|
|
1236
|
-
</div>
|
|
1237
|
-
<div class="row">
|
|
1238
|
-
<div>
|
|
1239
|
-
<label>${t('discordGuilds')}</label>
|
|
1240
|
-
<input type="text" id="dcGuilds" value="${esc((dc.allowedGuilds || []).join(', '))}" placeholder="123, 456">
|
|
1241
|
-
</div>
|
|
1242
|
-
<div>
|
|
1243
|
-
<label>${t('discordChannels')}</label>
|
|
1244
|
-
<input type="text" id="dcChannels" value="${esc((dc.allowedChannels || []).join(', '))}" placeholder="789, 101112">
|
|
1245
|
-
</div>
|
|
1246
|
-
</div>
|
|
1247
|
-
` : ''}
|
|
1248
|
-
</div>
|
|
1249
|
-
|
|
1250
|
-
<div class="actions">
|
|
1251
|
-
<button type="button" class="btn btn-primary" id="saveMessengers">${t('saveMessengers')}</button>
|
|
1252
|
-
</div>
|
|
1253
|
-
</div>
|
|
1254
|
-
`;
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
// ==========================================
|
|
1258
|
-
// ACP Remote Agents card
|
|
1259
|
-
// ==========================================
|
|
1260
|
-
function renderAcpCard() {
|
|
1261
|
-
const agents = config.acpAgents || [];
|
|
1262
|
-
|
|
1263
|
-
const rows = agents.length > 0 ? agents.map((a, i) => `
|
|
1264
|
-
<tr data-acp-idx="${i}">
|
|
1265
|
-
<td><strong>${esc(a.name)}</strong><br><span style="font-size:11px;color:var(--text-dim)">${esc((a.aliases || []).join(', '))}</span></td>
|
|
1266
|
-
<td style="font-family:monospace;font-size:12px">${esc(a.endpoint)}</td>
|
|
1267
|
-
<td>${a.auth?.type || t('none')}</td>
|
|
1268
|
-
<td><div class="toggle ${a.enabled !== false ? 'active' : ''}" data-toggle-acp="${i}"></div></td>
|
|
1269
|
-
<td>
|
|
1270
|
-
<button type="button" class="btn btn-sm" data-test-acp="${i}">${t('test')}</button>
|
|
1271
|
-
<button type="button" class="btn btn-sm btn-danger" data-del-acp="${i}">${t('del')}</button>
|
|
1272
|
-
</td>
|
|
1273
|
-
</tr>
|
|
1274
|
-
`).join('') : `<tr><td colspan="5" style="color:var(--text-dim);text-align:center;padding:20px">${t('acpNone')}</td></tr>`;
|
|
1275
|
-
|
|
1276
|
-
return `
|
|
1277
|
-
<div class="card">
|
|
1278
|
-
<h2>${t('acpTitle')} <span class="badge">${agents.length}</span></h2>
|
|
1279
|
-
|
|
1280
|
-
<table class="acp-table">
|
|
1281
|
-
<thead>
|
|
1282
|
-
<tr><th>${t('name')}</th><th>${t('endpoint')}</th><th>${t('auth')}</th><th>${t('enabled')}</th><th></th></tr>
|
|
1283
|
-
</thead>
|
|
1284
|
-
<tbody>
|
|
1285
|
-
${rows}
|
|
1286
|
-
</tbody>
|
|
1287
|
-
</table>
|
|
1288
|
-
|
|
1289
|
-
<hr class="divider">
|
|
1290
|
-
<div style="font-size:13px;font-weight:600;margin-bottom:10px">${t('addAgent')}</div>
|
|
1291
|
-
<div class="row">
|
|
1292
|
-
<div>
|
|
1293
|
-
<label>${t('name')}</label>
|
|
1294
|
-
<input type="text" id="acpName" placeholder="my-agent">
|
|
1295
|
-
</div>
|
|
1296
|
-
<div>
|
|
1297
|
-
<label>${t('aliases')}</label>
|
|
1298
|
-
<input type="text" id="acpAliases" placeholder="ma, agent1">
|
|
1299
|
-
</div>
|
|
1300
|
-
</div>
|
|
1301
|
-
<div class="row">
|
|
1302
|
-
<div>
|
|
1303
|
-
<label>${t('endpointUrl')}</label>
|
|
1304
|
-
<input type="text" id="acpEndpoint" placeholder="http://localhost:8080">
|
|
1305
|
-
</div>
|
|
1306
|
-
<div>
|
|
1307
|
-
<label>${t('authType')}</label>
|
|
1308
|
-
<select id="acpAuthType">
|
|
1309
|
-
<option value="none">${t('none')}</option>
|
|
1310
|
-
<option value="apikey">${t('apikey')}</option>
|
|
1311
|
-
<option value="bearer">${t('bearer')}</option>
|
|
1312
|
-
</select>
|
|
1313
|
-
</div>
|
|
1314
|
-
</div>
|
|
1315
|
-
<div class="row" id="acpTokenRow" style="display:none">
|
|
1316
|
-
<div>
|
|
1317
|
-
<label>${t('token')}</label>
|
|
1318
|
-
<input type="password" id="acpToken" placeholder="Your auth token">
|
|
1319
|
-
</div>
|
|
1320
|
-
<div></div>
|
|
1321
|
-
</div>
|
|
1322
|
-
<div class="actions">
|
|
1323
|
-
<button type="button" class="btn" id="testAcpNew">${t('testConn')}</button>
|
|
1324
|
-
<button type="button" class="btn btn-primary" id="addAcp">${t('add')}</button>
|
|
1325
|
-
</div>
|
|
1326
|
-
</div>
|
|
1327
|
-
`;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// ==========================================
|
|
1331
|
-
// Workspaces card (PR-C)
|
|
1332
|
-
// ==========================================
|
|
1333
|
-
function renderWorkspacesCard() {
|
|
1334
|
-
const list = workspaceList || [];
|
|
1335
|
-
// Surface default at the top, locked. Custom workspaces follow.
|
|
1336
|
-
const sorted = list.slice().sort((a, b) => {
|
|
1337
|
-
if (a.id === 'default') return -1;
|
|
1338
|
-
if (b.id === 'default') return 1;
|
|
1339
|
-
return a.id.localeCompare(b.id);
|
|
1340
|
-
});
|
|
1341
|
-
|
|
1342
|
-
const escHtml = (s) => String(s || '').replace(/[&<>"']/g, (c) => (
|
|
1343
|
-
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
1344
|
-
));
|
|
1345
|
-
|
|
1346
|
-
const rows = sorted.length ? sorted.map((w) => {
|
|
1347
|
-
const isDefault = w.id === 'default';
|
|
1348
|
-
const rl = w.rateLimit
|
|
1349
|
-
? `${w.rateLimit.rate}/${w.rateLimit.intervalSec}s · burst ${w.rateLimit.burst}`
|
|
1350
|
-
: '—';
|
|
1351
|
-
const agents = (w.agents?.length) ? escHtml(w.agents.join(', ')) : '<span style="color:var(--text-dim)">*</span>';
|
|
1352
|
-
const members = (w.members?.length) ? w.members.length : `<span style="color:var(--text-dim)">*</span>`;
|
|
1353
|
-
return `
|
|
1354
|
-
<tr data-ws-id="${escHtml(w.id)}">
|
|
1355
|
-
<td>
|
|
1356
|
-
<strong>${escHtml(w.id)}</strong>
|
|
1357
|
-
${isDefault ? `<br><span class="badge">${t('workspaceDefaultLocked')}</span>` : ''}
|
|
1358
|
-
<br><span style="font-size:11px;color:var(--text-dim)">${escHtml(w.name || w.id)}</span>
|
|
1359
|
-
</td>
|
|
1360
|
-
<td style="font-family:monospace;font-size:12px;max-width:240px;word-break:break-word">${agents}</td>
|
|
1361
|
-
<td>${members}</td>
|
|
1362
|
-
<td style="font-size:12px">${rl}</td>
|
|
1363
|
-
<td>
|
|
1364
|
-
${isDefault ? '' : `
|
|
1365
|
-
<button type="button" class="btn btn-sm" data-edit-ws="${escHtml(w.id)}">${t('workspaceEdit')}</button>
|
|
1366
|
-
<button type="button" class="btn btn-sm btn-danger" data-del-ws="${escHtml(w.id)}">${t('workspaceDelete')}</button>
|
|
1367
|
-
`}
|
|
1368
|
-
</td>
|
|
1369
|
-
</tr>
|
|
1370
|
-
`;
|
|
1371
|
-
}).join('') : `<tr><td colspan="5" style="color:var(--text-dim);text-align:center;padding:20px">${t('workspacesNone')}</td></tr>`;
|
|
1372
|
-
|
|
1373
|
-
return `
|
|
1374
|
-
<div class="card">
|
|
1375
|
-
<h2>${t('workspacesTitle')} <span class="badge">${list.length}</span></h2>
|
|
1376
|
-
<table class="acp-table">
|
|
1377
|
-
<thead>
|
|
1378
|
-
<tr>
|
|
1379
|
-
<th>${t('workspaceId')}</th>
|
|
1380
|
-
<th>${t('workspaceAgents')}</th>
|
|
1381
|
-
<th>${t('workspaceMembers')}</th>
|
|
1382
|
-
<th>${t('workspaceRateLimit')}</th>
|
|
1383
|
-
<th></th>
|
|
1384
|
-
</tr>
|
|
1385
|
-
</thead>
|
|
1386
|
-
<tbody>${rows}</tbody>
|
|
1387
|
-
</table>
|
|
1388
|
-
|
|
1389
|
-
<hr class="divider">
|
|
1390
|
-
<div style="font-size:13px;font-weight:600;margin-bottom:10px">${t('workspaceAdd')}</div>
|
|
1391
|
-
<div class="row">
|
|
1392
|
-
<div>
|
|
1393
|
-
<label>${t('workspaceId')} <span style="font-weight:400;color:var(--text-dim);font-size:11px">(${t('workspaceIdHelp')})</span></label>
|
|
1394
|
-
<input type="text" id="wsId" placeholder="team-data">
|
|
1395
|
-
</div>
|
|
1396
|
-
<div>
|
|
1397
|
-
<label>${t('workspaceName')}</label>
|
|
1398
|
-
<input type="text" id="wsName" placeholder="Data team">
|
|
1399
|
-
</div>
|
|
1400
|
-
</div>
|
|
1401
|
-
<div class="row">
|
|
1402
|
-
<div>
|
|
1403
|
-
<label>${t('workspaceAgents')}</label>
|
|
1404
|
-
<input type="text" id="wsAgents" placeholder="opencode, claude-code">
|
|
1405
|
-
</div>
|
|
1406
|
-
<div>
|
|
1407
|
-
<label>${t('workspaceMembers')}</label>
|
|
1408
|
-
<input type="text" id="wsMembers" placeholder="user-1, user-2">
|
|
1409
|
-
</div>
|
|
1410
|
-
</div>
|
|
1411
|
-
<div class="row">
|
|
1412
|
-
<div>
|
|
1413
|
-
<label>${t('workspaceRate')}</label>
|
|
1414
|
-
<input type="number" id="wsRate" placeholder="10" min="1">
|
|
1415
|
-
</div>
|
|
1416
|
-
<div>
|
|
1417
|
-
<label>${t('workspaceInterval')}</label>
|
|
1418
|
-
<input type="number" id="wsInterval" placeholder="60" min="1">
|
|
1419
|
-
</div>
|
|
1420
|
-
<div>
|
|
1421
|
-
<label>${t('workspaceBurst')}</label>
|
|
1422
|
-
<input type="number" id="wsBurst" placeholder="15" min="1">
|
|
1423
|
-
</div>
|
|
1424
|
-
</div>
|
|
1425
|
-
<div class="actions">
|
|
1426
|
-
<button type="button" class="btn" id="resetWs">${t('workspaceReset')}</button>
|
|
1427
|
-
<button type="button" class="btn btn-primary" id="saveWs">${t('save')}</button>
|
|
1428
|
-
</div>
|
|
1429
|
-
</div>
|
|
1430
|
-
`;
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
// ==========================================
|
|
1434
|
-
// General card
|
|
1435
|
-
// ==========================================
|
|
1436
|
-
function renderGeneralCard() {
|
|
1437
|
-
return `
|
|
1438
|
-
<div class="card">
|
|
1439
|
-
<h2>${t('general')}</h2>
|
|
1440
|
-
<label>${t('webPort')}</label>
|
|
1441
|
-
<input type="number" id="webPort" value="${config.webPort || 3000}" min="1024" max="65535" style="max-width:200px">
|
|
1442
|
-
<div class="actions">
|
|
1443
|
-
<button type="button" class="btn btn-primary" id="saveGeneral">${t('save')}</button>
|
|
1444
|
-
</div>
|
|
1445
|
-
</div>
|
|
1446
|
-
|
|
1447
|
-
<!-- SMTP (env-file) — feeds /remind email delivery -->
|
|
1448
|
-
<div class="card">
|
|
1449
|
-
<h2>📨 SMTP</h2>
|
|
1450
|
-
<p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px">
|
|
1451
|
-
Email delivery for <code>/remind email me@x.com …</code>. Credentials live in <code>~/.im-hub/env</code> (chmod 600). Leave blank to disable.
|
|
1452
|
-
</p>
|
|
1453
|
-
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
|
1454
|
-
<div>
|
|
1455
|
-
<label>Host</label>
|
|
1456
|
-
<input type="text" id="smtpHost" placeholder="smtp.gmail.com" />
|
|
1457
|
-
</div>
|
|
1458
|
-
<div>
|
|
1459
|
-
<label>Port</label>
|
|
1460
|
-
<input type="number" id="smtpPort" placeholder="465" min="1" max="65535" />
|
|
1461
|
-
</div>
|
|
1462
|
-
<div>
|
|
1463
|
-
<label>User (login email)</label>
|
|
1464
|
-
<input type="text" id="smtpUser" placeholder="you@example.com" />
|
|
1465
|
-
</div>
|
|
1466
|
-
<div>
|
|
1467
|
-
<label>Password / app token</label>
|
|
1468
|
-
<input type="password" id="smtpPass" placeholder="••••••••" autocomplete="off" />
|
|
1469
|
-
</div>
|
|
1470
|
-
<div>
|
|
1471
|
-
<label>From (defaults to user)</label>
|
|
1472
|
-
<input type="text" id="smtpFrom" placeholder="" />
|
|
1473
|
-
</div>
|
|
1474
|
-
<div>
|
|
1475
|
-
<label>TLS mode</label>
|
|
1476
|
-
<select id="smtpSecure">
|
|
1477
|
-
<option value="auto">auto</option>
|
|
1478
|
-
<option value="true">true (SSL on connect, port 465)</option>
|
|
1479
|
-
<option value="false">false (STARTTLS, port 587)</option>
|
|
1480
|
-
</select>
|
|
1481
|
-
</div>
|
|
1482
|
-
</div>
|
|
1483
|
-
<div class="actions" style="margin-top:12px">
|
|
1484
|
-
<button type="button" class="btn btn-primary" id="saveSmtp">${t('save')}</button>
|
|
1485
|
-
<button type="button" class="btn" id="clearSmtp">Disable SMTP</button>
|
|
1486
|
-
<button type="button" class="btn" id="revealSmtp" title="Show the stored password">Reveal</button>
|
|
1487
|
-
</div>
|
|
1488
|
-
<p class="muted" id="smtpStatus" style="margin-top:8px;font-size:12px"></p>
|
|
1489
|
-
</div>
|
|
1490
|
-
|
|
1491
|
-
<!-- Viewer (env-file) — long-message web rendering -->
|
|
1492
|
-
<div class="card">
|
|
1493
|
-
<h2>📄 ${t('viewerTitle')}</h2>
|
|
1494
|
-
<p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px">${t('viewerHint')}</p>
|
|
1495
|
-
|
|
1496
|
-
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
|
|
1497
|
-
<label style="display:flex;gap:8px;align-items:center;font-weight:600">
|
|
1498
|
-
<input type="checkbox" id="viewerEnabled" />
|
|
1499
|
-
${t('viewerEnabledLabel')}
|
|
1500
|
-
</label>
|
|
1501
|
-
</div>
|
|
1502
|
-
|
|
1503
|
-
<label>${t('viewerPublicUrl')}</label>
|
|
1504
|
-
<input type="text" id="viewerPublicUrl" placeholder="https://agim.example.com" style="max-width:480px" />
|
|
1505
|
-
<p class="muted" style="margin-top:4px;font-size:12px">${t('viewerPublicUrlHint')}</p>
|
|
1506
|
-
|
|
1507
|
-
<label style="margin-top:12px;display:block">${t('viewerTunnelMode')}</label>
|
|
1508
|
-
<select id="viewerTunnelMode" style="max-width:480px">
|
|
1509
|
-
<option value="off">${t('viewerTunnelOff')}</option>
|
|
1510
|
-
<option value="quick">${t('viewerTunnelQuick')}</option>
|
|
1511
|
-
</select>
|
|
1512
|
-
<p class="muted" style="margin-top:4px;font-size:12px">${t('viewerTunnelHint')}</p>
|
|
1513
|
-
<div id="viewerTunnelStatusBox" style="margin-top:6px;padding:8px 10px;border:1px solid var(--border, #d0d7de);border-radius:6px;font-size:12px;display:none">
|
|
1514
|
-
<div><strong>${t('viewerTunnelStatus')}:</strong> <span id="viewerTunnelState">—</span></div>
|
|
1515
|
-
<div style="margin-top:4px"><strong>${t('viewerTunnelCurrentUrl')}:</strong> <code id="viewerTunnelCurrentUrl" style="word-break:break-all">—</code></div>
|
|
1516
|
-
<button type="button" class="btn" id="viewerTunnelRefreshBtn" style="margin-top:6px;padding:4px 10px;font-size:12px">${t('viewerTunnelRefresh')}</button>
|
|
1517
|
-
</div>
|
|
1518
|
-
|
|
1519
|
-
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:12px;max-width:720px">
|
|
1520
|
-
<div>
|
|
1521
|
-
<label>${t('viewerChars')}</label>
|
|
1522
|
-
<input type="number" id="viewerChars" min="100" max="10000" placeholder="500" />
|
|
1523
|
-
</div>
|
|
1524
|
-
<div>
|
|
1525
|
-
<label>${t('viewerLines')}</label>
|
|
1526
|
-
<input type="number" id="viewerLines" min="5" max="200" placeholder="12" />
|
|
1527
|
-
</div>
|
|
1528
|
-
<div>
|
|
1529
|
-
<label>${t('viewerCodeLines')}</label>
|
|
1530
|
-
<input type="number" id="viewerCodeLines" min="5" max="200" placeholder="10" />
|
|
1531
|
-
</div>
|
|
1532
|
-
<div>
|
|
1533
|
-
<label>${t('viewerMaxPastes')}</label>
|
|
1534
|
-
<input type="number" id="viewerMaxPastes" min="100" max="1000000" placeholder="10000" />
|
|
1535
|
-
</div>
|
|
1536
|
-
</div>
|
|
1537
|
-
|
|
1538
|
-
<div class="actions" style="margin-top:12px">
|
|
1539
|
-
<button type="button" class="btn btn-primary" id="saveViewer">${t('viewerSave')}</button>
|
|
1540
|
-
</div>
|
|
1541
|
-
<p class="muted" id="viewerStatus" style="margin-top:8px;font-size:12px"></p>
|
|
1542
|
-
</div>
|
|
1543
|
-
|
|
1544
|
-
<!-- A2A Notifications (env-file) — push intermediate state during agent-to-agent handoffs -->
|
|
1545
|
-
<div class="card">
|
|
1546
|
-
<h2>🔀 ${t('a2aNotifyTitle')}</h2>
|
|
1547
|
-
<p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px">${t('a2aNotifyHint')}</p>
|
|
1548
|
-
|
|
1549
|
-
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;max-width:720px">
|
|
1550
|
-
<div>
|
|
1551
|
-
<label>${t('a2aDefaultTimeout')}</label>
|
|
1552
|
-
<input type="number" id="a2aDefaultTimeout" min="1" max="60" placeholder="30" />
|
|
1553
|
-
<p class="muted" style="margin-top:4px;font-size:11px">${t('a2aDefaultTimeoutHint')}</p>
|
|
1554
|
-
</div>
|
|
1555
|
-
<div>
|
|
1556
|
-
<label>${t('a2aMaxTimeout')}</label>
|
|
1557
|
-
<input type="number" id="a2aMaxTimeout" min="1" max="180" placeholder="60" />
|
|
1558
|
-
<p class="muted" style="margin-top:4px;font-size:11px">${t('a2aMaxTimeoutHint')}</p>
|
|
1559
|
-
</div>
|
|
1560
|
-
</div>
|
|
1561
|
-
|
|
1562
|
-
<label style="margin-top:12px;display:block">${t('a2aNotifyMode')}</label>
|
|
1563
|
-
<select id="a2aNotifyMode" style="max-width:480px">
|
|
1564
|
-
<option value="off">${t('a2aNotifyOff')}</option>
|
|
1565
|
-
<option value="essential" selected>${t('a2aNotifyEssential')}</option>
|
|
1566
|
-
<option value="verbose">${t('a2aNotifyVerbose')}</option>
|
|
1567
|
-
</select>
|
|
1568
|
-
|
|
1569
|
-
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-top:12px;max-width:720px">
|
|
1570
|
-
<div>
|
|
1571
|
-
<label>${t('a2aMaxDepth')}</label>
|
|
1572
|
-
<input type="number" id="a2aMaxDepth" min="0" max="5" placeholder="1" />
|
|
1573
|
-
<p class="muted" style="margin-top:4px;font-size:11px">${t('a2aMaxDepthHint')}</p>
|
|
1574
|
-
</div>
|
|
1575
|
-
<div>
|
|
1576
|
-
<label>${t('a2aHeartbeatMin')}</label>
|
|
1577
|
-
<input type="number" id="a2aHeartbeatMin" min="1" max="60" placeholder="5" />
|
|
1578
|
-
<p class="muted" style="margin-top:4px;font-size:11px">${t('a2aHeartbeatHint')}</p>
|
|
1579
|
-
</div>
|
|
1580
|
-
</div>
|
|
1581
|
-
|
|
1582
|
-
<div class="actions" style="margin-top:12px">
|
|
1583
|
-
<button type="button" class="btn btn-primary" id="saveA2A">${t('a2aSave')}</button>
|
|
1584
|
-
</div>
|
|
1585
|
-
<p class="muted" id="a2aStatus" style="margin-top:8px;font-size:12px"></p>
|
|
1586
|
-
</div>
|
|
1587
|
-
|
|
1588
|
-
<!-- Long-term memory (env-file) — auto fact extraction + persona injection -->
|
|
1589
|
-
<div class="card">
|
|
1590
|
-
<h2>🧠 ${t('memoryTitle')}</h2>
|
|
1591
|
-
<p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px">${t('memoryHint')}</p>
|
|
1592
|
-
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
|
|
1593
|
-
<label style="display:flex;gap:8px;align-items:center;font-weight:600">
|
|
1594
|
-
<input type="checkbox" id="memoryEnabled" />
|
|
1595
|
-
${t('memoryEnableLabel')}
|
|
1596
|
-
</label>
|
|
1597
|
-
</div>
|
|
1598
|
-
<div class="actions" style="margin-top:12px">
|
|
1599
|
-
<button type="button" class="btn btn-primary" id="saveMemory">${t('memorySave')}</button>
|
|
1600
|
-
</div>
|
|
1601
|
-
<p class="muted" id="memoryStatus" style="margin-top:8px;font-size:12px"></p>
|
|
1602
|
-
</div>
|
|
1603
|
-
|
|
1604
|
-
<!-- Baidu Maps AK (env-file) — feeds /memo address geocoding -->
|
|
1605
|
-
<div class="card">
|
|
1606
|
-
<h2>🗺 Baidu Maps AK</h2>
|
|
1607
|
-
<p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px">
|
|
1608
|
-
Enables <code>/memo</code> address-to-coords lookup. Without it, /memo still works via raw coords or browser GPS share. Free AK: <a href="https://lbsyun.baidu.com/" target="_blank">lbsyun.baidu.com</a> → Console → 应用管理 → 创建应用 → 服务端 API.
|
|
1609
|
-
</p>
|
|
1610
|
-
<label>IMHUB_BAIDU_MAP_AK</label>
|
|
1611
|
-
<input type="text" id="baiduAk" placeholder="32-char AK starting with non-digit" style="max-width:480px" />
|
|
1612
|
-
<div class="actions" style="margin-top:12px">
|
|
1613
|
-
<button type="button" class="btn btn-primary" id="saveBaidu">${t('save')}</button>
|
|
1614
|
-
<button type="button" class="btn" id="clearBaidu">Clear</button>
|
|
1615
|
-
<button type="button" class="btn" id="revealBaidu" title="Show the stored AK">Reveal</button>
|
|
1616
|
-
</div>
|
|
1617
|
-
<p class="muted" id="baiduStatus" style="margin-top:8px;font-size:12px"></p>
|
|
1618
|
-
</div>
|
|
1619
|
-
`;
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
async function refreshViewerTunnelStatus() {
|
|
1623
|
-
const stateEl = document.getElementById('viewerTunnelState');
|
|
1624
|
-
const urlEl = document.getElementById('viewerTunnelCurrentUrl');
|
|
1625
|
-
if (!stateEl || !urlEl) return;
|
|
1626
|
-
stateEl.textContent = '…';
|
|
1627
|
-
urlEl.textContent = '—';
|
|
1628
|
-
try {
|
|
1629
|
-
const data = await authFetch('/api/viewer/tunnel').then(r => r.json());
|
|
1630
|
-
const tun = data && data.tunnel ? data.tunnel : {};
|
|
1631
|
-
if (!tun.binaryFound) {
|
|
1632
|
-
stateEl.textContent = t('viewerTunnelBinaryMissing');
|
|
1633
|
-
stateEl.style.color = '#c0392b';
|
|
1634
|
-
} else if (tun.running) {
|
|
1635
|
-
stateEl.textContent = '✓ ' + t('viewerTunnelRunning');
|
|
1636
|
-
stateEl.style.color = '#16a34a';
|
|
1637
|
-
} else {
|
|
1638
|
-
stateEl.textContent = t('viewerTunnelNotRunning');
|
|
1639
|
-
stateEl.style.color = '';
|
|
1640
|
-
}
|
|
1641
|
-
urlEl.textContent = tun.url || (data && data.effectivePublicUrl) || '—';
|
|
1642
|
-
} catch (err) {
|
|
1643
|
-
stateEl.textContent = 'error';
|
|
1644
|
-
urlEl.textContent = (err && err.message) || String(err);
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
async function loadEnvSection(reveal) {
|
|
1649
|
-
try {
|
|
1650
|
-
const url = reveal ? '/api/env?reveal=1' : '/api/env';
|
|
1651
|
-
const data = await authFetch(url).then(r => r.json());
|
|
1652
|
-
const env = data.env || {};
|
|
1653
|
-
const set = (id, key) => {
|
|
1654
|
-
const el = document.getElementById(id);
|
|
1655
|
-
if (el) el.value = env[key] != null ? env[key] : '';
|
|
1656
|
-
};
|
|
1657
|
-
set('smtpHost', 'IMHUB_SMTP_HOST');
|
|
1658
|
-
set('smtpPort', 'IMHUB_SMTP_PORT');
|
|
1659
|
-
set('smtpUser', 'IMHUB_SMTP_USER');
|
|
1660
|
-
set('smtpPass', 'IMHUB_SMTP_PASS');
|
|
1661
|
-
set('smtpFrom', 'IMHUB_SMTP_FROM');
|
|
1662
|
-
const sec = document.getElementById('smtpSecure');
|
|
1663
|
-
if (sec) sec.value = env['IMHUB_SMTP_SECURE'] || 'auto';
|
|
1664
|
-
set('baiduAk', 'IMHUB_BAIDU_MAP_AK');
|
|
1665
|
-
// Viewer card fields
|
|
1666
|
-
const vEnabled = document.getElementById('viewerEnabled');
|
|
1667
|
-
if (vEnabled) {
|
|
1668
|
-
const v = (env['IMHUB_VIEWER_ENABLED'] || '').toLowerCase();
|
|
1669
|
-
vEnabled.checked = v === '1' || v === 'true' || v === 'yes';
|
|
1670
|
-
}
|
|
1671
|
-
set('viewerPublicUrl', 'IMHUB_VIEWER_PUBLIC_BASE_URL');
|
|
1672
|
-
set('viewerChars', 'IMHUB_VIEWER_CHARS');
|
|
1673
|
-
set('viewerLines', 'IMHUB_VIEWER_LINES');
|
|
1674
|
-
set('viewerCodeLines', 'IMHUB_VIEWER_CODE_LINES');
|
|
1675
|
-
set('viewerMaxPastes', 'IMHUB_VIEWER_MAX_PASTES');
|
|
1676
|
-
// Memory card — single boolean toggle.
|
|
1677
|
-
const memoryEnabledEl = document.getElementById('memoryEnabled');
|
|
1678
|
-
if (memoryEnabledEl) {
|
|
1679
|
-
const v = (env['IMHUB_MEMORY_ENABLED'] || '').toLowerCase();
|
|
1680
|
-
memoryEnabledEl.checked = v === '1' || v === 'true' || v === 'yes';
|
|
1681
|
-
}
|
|
1682
|
-
const memoryStatus = document.getElementById('memoryStatus');
|
|
1683
|
-
if (memoryStatus) {
|
|
1684
|
-
memoryStatus.textContent = memoryEnabledEl?.checked
|
|
1685
|
-
? '✓ ' + t('memoryEnable')
|
|
1686
|
-
: t('memoryDisable');
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
// A2A card — values stored as ms / direct values; UI uses minutes for the timeouts.
|
|
1690
|
-
const a2aDt = document.getElementById('a2aDefaultTimeout');
|
|
1691
|
-
if (a2aDt) {
|
|
1692
|
-
const ms = parseInt(env['IMHUB_A2A_TIMEOUT_DEFAULT_MS'] || '', 10);
|
|
1693
|
-
a2aDt.value = Number.isFinite(ms) && ms > 0 ? String(Math.round(ms / 60000)) : '';
|
|
1694
|
-
}
|
|
1695
|
-
const a2aMt = document.getElementById('a2aMaxTimeout');
|
|
1696
|
-
if (a2aMt) {
|
|
1697
|
-
const ms = parseInt(env['IMHUB_A2A_MAX_TIMEOUT_MS'] || '', 10);
|
|
1698
|
-
a2aMt.value = Number.isFinite(ms) && ms > 0 ? String(Math.round(ms / 60000)) : '';
|
|
1699
|
-
}
|
|
1700
|
-
const a2aMode = document.getElementById('a2aNotifyMode');
|
|
1701
|
-
if (a2aMode) a2aMode.value = (env['IMHUB_A2A_NOTIFY_MODE'] || 'essential').toLowerCase();
|
|
1702
|
-
set('a2aMaxDepth', 'IMHUB_A2A_NOTIFY_MAX_DEPTH');
|
|
1703
|
-
set('a2aHeartbeatMin', 'IMHUB_A2A_HEARTBEAT_MIN');
|
|
1704
|
-
|
|
1705
|
-
const vTunnelMode = document.getElementById('viewerTunnelMode');
|
|
1706
|
-
if (vTunnelMode) {
|
|
1707
|
-
vTunnelMode.value = (env['IMHUB_VIEWER_TUNNEL_MODE'] || 'off').toLowerCase() === 'quick' ? 'quick' : 'off';
|
|
1708
|
-
}
|
|
1709
|
-
// Show tunnel status panel only when tunnel mode = quick.
|
|
1710
|
-
const tunnelBox = document.getElementById('viewerTunnelStatusBox');
|
|
1711
|
-
if (tunnelBox) {
|
|
1712
|
-
tunnelBox.style.display = (vTunnelMode && vTunnelMode.value === 'quick') ? 'block' : 'none';
|
|
1713
|
-
if (vTunnelMode && vTunnelMode.value === 'quick') void refreshViewerTunnelStatus();
|
|
1714
|
-
}
|
|
1715
|
-
const viewerStatus = document.getElementById('viewerStatus');
|
|
1716
|
-
if (viewerStatus) {
|
|
1717
|
-
if (!env['IMHUB_VIEWER_PUBLIC_BASE_URL']) {
|
|
1718
|
-
viewerStatus.textContent = t('viewerMissingUrl');
|
|
1719
|
-
} else if ((env['IMHUB_VIEWER_ENABLED'] || '').toLowerCase() === '1' ||
|
|
1720
|
-
(env['IMHUB_VIEWER_ENABLED'] || '').toLowerCase() === 'true') {
|
|
1721
|
-
viewerStatus.textContent = `✓ ${t('viewerEnabled')} → ${env['IMHUB_VIEWER_PUBLIC_BASE_URL']}`;
|
|
1722
|
-
} else {
|
|
1723
|
-
viewerStatus.textContent = `${t('viewerDisabled')} (${env['IMHUB_VIEWER_PUBLIC_BASE_URL']})`;
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
const smtpStatus = document.getElementById('smtpStatus');
|
|
1727
|
-
const baiduStatus = document.getElementById('baiduStatus');
|
|
1728
|
-
if (smtpStatus) smtpStatus.textContent = env['IMHUB_SMTP_HOST']
|
|
1729
|
-
? `Loaded${reveal ? ' (raw)' : ' (password masked)'}`
|
|
1730
|
-
: '(no SMTP credentials set)';
|
|
1731
|
-
if (baiduStatus) baiduStatus.textContent = env['IMHUB_BAIDU_MAP_AK']
|
|
1732
|
-
? `Loaded${reveal ? ' (raw)' : ' (masked)'}`
|
|
1733
|
-
: '(not set)';
|
|
1734
|
-
} catch (err) {
|
|
1735
|
-
const smtpStatus = document.getElementById('smtpStatus');
|
|
1736
|
-
if (smtpStatus) smtpStatus.textContent = 'Failed to load: ' + (err && err.message ? err.message : err);
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
// ==========================================
|
|
1742
|
-
// Event binding
|
|
1743
|
-
// ==========================================
|
|
1744
|
-
function bindEvents() {
|
|
1745
|
-
// Populate SMTP / Baidu env-backed fields. Values come in masked;
|
|
1746
|
-
// the Reveal buttons re-fetch with ?reveal=1 on demand.
|
|
1747
|
-
void loadEnvSection();
|
|
1748
|
-
|
|
1749
|
-
// Service control card
|
|
1750
|
-
document.getElementById('svc-restart')?.addEventListener('click', () => svcAction('restart'));
|
|
1751
|
-
document.getElementById('svc-stop')?.addEventListener('click', () => svcAction('stop'));
|
|
1752
|
-
document.getElementById('svc-start')?.addEventListener('click', () => svcAction('start'));
|
|
1753
|
-
|
|
1754
|
-
// Approval Policy card — IMHUB_TIMEOUT_DEFAULT toggle.
|
|
1755
|
-
document.getElementById('approval-policy-save')?.addEventListener('click', saveApprovalPolicy);
|
|
1756
|
-
|
|
1757
|
-
// Add admin (Safety card → Admin Allowlist).
|
|
1758
|
-
document.getElementById('admin-add')?.addEventListener('click', async () => {
|
|
1759
|
-
const platform = (document.getElementById('admin-platform')?.value || '').trim();
|
|
1760
|
-
const userId = (document.getElementById('admin-userid')?.value || '').trim();
|
|
1761
|
-
if (!platform || !userId) {
|
|
1762
|
-
toast(t('error') + ': platform + userId required', 'error');
|
|
1763
|
-
return;
|
|
1764
|
-
}
|
|
1765
|
-
try {
|
|
1766
|
-
const res = await authFetch('/api/admin-allowlist', {
|
|
1767
|
-
method: 'POST',
|
|
1768
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1769
|
-
body: JSON.stringify({ platform, userId }),
|
|
1770
|
-
});
|
|
1771
|
-
if (!res.ok) {
|
|
1772
|
-
const j = await res.json().catch(() => ({}));
|
|
1773
|
-
throw new Error(j.error || res.statusText);
|
|
1774
|
-
}
|
|
1775
|
-
toast(t('savedMsg'), 'success');
|
|
1776
|
-
// Clear inputs + reload list
|
|
1777
|
-
document.getElementById('admin-platform').value = '';
|
|
1778
|
-
document.getElementById('admin-userid').value = '';
|
|
1779
|
-
await loadAdminList();
|
|
1780
|
-
} catch (err) {
|
|
1781
|
-
toast(`${t('error')}: ${err && err.message ? err.message : err}`, 'error');
|
|
1782
|
-
}
|
|
1783
|
-
});
|
|
1784
|
-
|
|
1785
|
-
// Agent toggles — flip config.agents in memory + auto-manage
|
|
1786
|
-
// defaultAgent (promote / demote as needed), then re-render so the
|
|
1787
|
-
// default-agent dropdown reflects the new eligible set.
|
|
1788
|
-
document.querySelectorAll('[data-toggle-agent]').forEach(el => {
|
|
1789
|
-
el.addEventListener('click', () => {
|
|
1790
|
-
const id = el.getAttribute('data-toggle-agent');
|
|
1791
|
-
const set = new Set(config.agents || []);
|
|
1792
|
-
if (set.has(id)) {
|
|
1793
|
-
set.delete(id);
|
|
1794
|
-
// If we just removed the default, promote the next still-enabled
|
|
1795
|
-
// agent (or clear). saveConfig() will sync to disk on Save.
|
|
1796
|
-
if (config.defaultAgent === id) {
|
|
1797
|
-
config.defaultAgent = Array.from(set)[0] || '';
|
|
1798
|
-
}
|
|
1799
|
-
} else {
|
|
1800
|
-
set.add(id);
|
|
1801
|
-
// First enabled agent inherits default when nothing was set.
|
|
1802
|
-
if (!config.defaultAgent) config.defaultAgent = id;
|
|
1803
|
-
}
|
|
1804
|
-
config.agents = Array.from(set);
|
|
1805
|
-
render();
|
|
1806
|
-
});
|
|
1807
|
-
});
|
|
1808
|
-
|
|
1809
|
-
// Default-agent dropdown — keep in-memory config in sync so the
|
|
1810
|
-
// Save button persists what the user just picked.
|
|
1811
|
-
document.getElementById('defaultAgent')?.addEventListener('change', (e) => {
|
|
1812
|
-
config.defaultAgent = e.target.value;
|
|
1813
|
-
});
|
|
1814
|
-
|
|
1815
|
-
// Save Agents button — pushes the in-memory agents[] + defaultAgent
|
|
1816
|
-
// to /api/config PUT. Mirrors the saveMessengers pattern.
|
|
1817
|
-
document.getElementById('saveAgents')?.addEventListener('click', async () => {
|
|
1818
|
-
await saveConfig();
|
|
1819
|
-
});
|
|
1820
|
-
|
|
1821
|
-
// Messenger toggles — flip in-memory config.messengers, then re-render.
|
|
1822
|
-
document.querySelectorAll('[data-toggle-messenger]').forEach(el => {
|
|
1823
|
-
el.addEventListener('click', () => {
|
|
1824
|
-
const id = el.getAttribute('data-toggle-messenger');
|
|
1825
|
-
const set = new Set(config.messengers || []);
|
|
1826
|
-
if (set.has(id)) set.delete(id);
|
|
1827
|
-
else set.add(id);
|
|
1828
|
-
config.messengers = Array.from(set);
|
|
1829
|
-
render();
|
|
1830
|
-
});
|
|
1831
|
-
});
|
|
1832
|
-
|
|
1833
|
-
// Save messengers — pull credentials from any visible fields, then PUT.
|
|
1834
|
-
document.getElementById('saveMessengers')?.addEventListener('click', async () => {
|
|
1835
|
-
const get = (id) => document.getElementById(id)?.value?.trim() ?? '';
|
|
1836
|
-
const messengers = (config.messengers || []);
|
|
1837
|
-
|
|
1838
|
-
if (messengers.includes('telegram')) {
|
|
1839
|
-
const botToken = get('tgToken');
|
|
1840
|
-
const channelId = get('tgChannel') || 'default';
|
|
1841
|
-
if (botToken) config.telegram = { ...(config.telegram || {}), botToken, channelId };
|
|
1842
|
-
else if (config.telegram?.channelId !== channelId) config.telegram = { ...(config.telegram || {}), channelId };
|
|
1843
|
-
}
|
|
1844
|
-
if (messengers.includes('feishu')) {
|
|
1845
|
-
const appId = get('fsAppId');
|
|
1846
|
-
const appSecret = get('fsAppSecret');
|
|
1847
|
-
config.feishu = {
|
|
1848
|
-
...(config.feishu || {}),
|
|
1849
|
-
...(appId ? { appId } : {}),
|
|
1850
|
-
...(appSecret ? { appSecret } : {}),
|
|
1851
|
-
};
|
|
1852
|
-
}
|
|
1853
|
-
if (messengers.includes('dingtalk')) {
|
|
1854
|
-
const clientId = get('dtClientId');
|
|
1855
|
-
const clientSecret = get('dtClientSecret');
|
|
1856
|
-
config.dingtalk = {
|
|
1857
|
-
...(config.dingtalk || {}),
|
|
1858
|
-
...(clientId ? { clientId } : {}),
|
|
1859
|
-
...(clientSecret ? { clientSecret } : {}),
|
|
1860
|
-
};
|
|
1861
|
-
}
|
|
1862
|
-
if (messengers.includes('discord')) {
|
|
1863
|
-
const botToken = get('dcToken');
|
|
1864
|
-
const channelId = get('dcChannel') || 'default';
|
|
1865
|
-
const guildsRaw = get('dcGuilds');
|
|
1866
|
-
const channelsRaw = get('dcChannels');
|
|
1867
|
-
config.discord = {
|
|
1868
|
-
...(config.discord || {}),
|
|
1869
|
-
...(botToken ? { botToken } : {}),
|
|
1870
|
-
channelId,
|
|
1871
|
-
allowedGuilds: guildsRaw ? guildsRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
|
1872
|
-
allowedChannels: channelsRaw ? channelsRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
|
1873
|
-
};
|
|
1874
|
-
}
|
|
1875
|
-
await saveConfig();
|
|
1876
|
-
});
|
|
1877
|
-
|
|
1878
|
-
// WeChat — open QR modal on click.
|
|
1879
|
-
document.getElementById('wechatScanBtn')?.addEventListener('click', () => {
|
|
1880
|
-
openWechatQrModal();
|
|
1881
|
-
});
|
|
1882
|
-
|
|
1883
|
-
// ACP toggles
|
|
1884
|
-
document.querySelectorAll('[data-toggle-acp]').forEach(el => {
|
|
1885
|
-
el.addEventListener('click', () => el.classList.toggle('active'));
|
|
1886
|
-
});
|
|
1887
|
-
|
|
1888
|
-
// ACP delete buttons
|
|
1889
|
-
document.querySelectorAll('[data-del-acp]').forEach(btn => {
|
|
1890
|
-
btn.addEventListener('click', async () => {
|
|
1891
|
-
const idx = parseInt(btn.dataset.delAcp, 10);
|
|
1892
|
-
config.acpAgents.splice(idx, 1);
|
|
1893
|
-
await saveConfig();
|
|
1894
|
-
render();
|
|
1895
|
-
});
|
|
1896
|
-
});
|
|
1897
|
-
|
|
1898
|
-
// ACP test existing
|
|
1899
|
-
document.querySelectorAll('[data-test-acp]').forEach(btn => {
|
|
1900
|
-
btn.addEventListener('click', async () => {
|
|
1901
|
-
const idx = parseInt(btn.dataset.testAcp, 10);
|
|
1902
|
-
const agent = config.acpAgents[idx];
|
|
1903
|
-
btn.disabled = true;
|
|
1904
|
-
btn.textContent = t('testing');
|
|
1905
|
-
try {
|
|
1906
|
-
const res = await authFetch('/api/agents/acp/test', {
|
|
1907
|
-
method: 'POST',
|
|
1908
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1909
|
-
body: JSON.stringify({ endpoint: agent.endpoint, auth: agent.auth }),
|
|
1910
|
-
});
|
|
1911
|
-
const data = await res.json();
|
|
1912
|
-
if (data.ok) {
|
|
1913
|
-
toast(`${t('connected')}: ${data.name}${data.description ? ` — ${data.description}` : ''}`, 'success');
|
|
1914
|
-
} else {
|
|
1915
|
-
toast(`${t('failed')}: ${data.error}`, 'error');
|
|
1916
|
-
}
|
|
1917
|
-
} catch (err) {
|
|
1918
|
-
toast(`${t('error')}: ${err.message}`, 'error');
|
|
1919
|
-
}
|
|
1920
|
-
btn.disabled = false;
|
|
1921
|
-
btn.textContent = t('testConn');
|
|
1922
|
-
});
|
|
1923
|
-
});
|
|
1924
|
-
|
|
1925
|
-
// Add ACP
|
|
1926
|
-
document.getElementById('addAcp')?.addEventListener('click', async () => {
|
|
1927
|
-
const name = document.getElementById('acpName').value.trim();
|
|
1928
|
-
const endpoint = document.getElementById('acpEndpoint').value.trim();
|
|
1929
|
-
if (!name) { toast(t('nameRequired'), 'error'); return; }
|
|
1930
|
-
if (!endpoint) { toast(t('endpointRequired'), 'error'); return; }
|
|
1931
|
-
|
|
1932
|
-
const aliases = document.getElementById('acpAliases').value.split(',').map(s => s.trim()).filter(Boolean);
|
|
1933
|
-
const authType = document.getElementById('acpAuthType').value;
|
|
1934
|
-
const token = document.getElementById('acpToken')?.value.trim();
|
|
1935
|
-
const auth = authType === 'none' ? undefined : { type: authType, token };
|
|
1936
|
-
|
|
1937
|
-
if (!config.acpAgents) config.acpAgents = [];
|
|
1938
|
-
config.acpAgents.push({ name, aliases, endpoint, auth, enabled: true });
|
|
1939
|
-
await saveConfig();
|
|
1940
|
-
render();
|
|
1941
|
-
});
|
|
1942
|
-
|
|
1943
|
-
// Save general
|
|
1944
|
-
document.getElementById('saveGeneral')?.addEventListener('click', async () => {
|
|
1945
|
-
config.webPort = parseInt(document.getElementById('webPort').value, 10) || 3000;
|
|
1946
|
-
await saveConfig();
|
|
1947
|
-
});
|
|
1948
|
-
|
|
1949
|
-
// SMTP save: bundle all 6 fields into one /api/env PUT. Empty
|
|
1950
|
-
// host treats it as "skip" — caller can also use the Disable button
|
|
1951
|
-
// for an explicit clear of all 6 keys.
|
|
1952
|
-
document.getElementById('saveSmtp')?.addEventListener('click', async () => {
|
|
1953
|
-
const get = (id) => document.getElementById(id)?.value?.trim() ?? '';
|
|
1954
|
-
const host = get('smtpHost'); const port = get('smtpPort');
|
|
1955
|
-
const user = get('smtpUser'); const pass = get('smtpPass');
|
|
1956
|
-
const from = get('smtpFrom'); const secure = get('smtpSecure');
|
|
1957
|
-
if (!host) {
|
|
1958
|
-
alert('Host is required (use Disable button to clear instead).');
|
|
1959
|
-
return;
|
|
1960
|
-
}
|
|
1961
|
-
// pass === '' AND we have an existing masked value would erase the
|
|
1962
|
-
// password. Detect: if pass starts with masked chars (4 plain + ****
|
|
1963
|
-
// shape) we treat as "keep existing" by not sending the key.
|
|
1964
|
-
const updates = {
|
|
1965
|
-
IMHUB_SMTP_HOST: host,
|
|
1966
|
-
IMHUB_SMTP_PORT: port || '465',
|
|
1967
|
-
IMHUB_SMTP_USER: user,
|
|
1968
|
-
IMHUB_SMTP_FROM: from || user,
|
|
1969
|
-
IMHUB_SMTP_SECURE: secure || 'auto',
|
|
1970
|
-
};
|
|
1971
|
-
const looksMasked = pass && /\*{2,}/.test(pass);
|
|
1972
|
-
if (pass && !looksMasked) updates.IMHUB_SMTP_PASS = pass;
|
|
1973
|
-
else if (!pass) {
|
|
1974
|
-
// Explicit blank — refuse, unless user clicks Disable.
|
|
1975
|
-
alert('Password required (use Disable button to clear instead).');
|
|
1976
|
-
return;
|
|
1977
|
-
}
|
|
1978
|
-
try {
|
|
1979
|
-
await authFetch('/api/env', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates }) });
|
|
1980
|
-
alert('SMTP saved.');
|
|
1981
|
-
await loadEnvSection();
|
|
1982
|
-
} catch (err) {
|
|
1983
|
-
alert('Save failed: ' + (err && err.message ? err.message : err));
|
|
1984
|
-
}
|
|
1985
|
-
});
|
|
1986
|
-
document.getElementById('clearSmtp')?.addEventListener('click', async () => {
|
|
1987
|
-
if (!confirm('Disable SMTP? Email reminder delivery will stop working.')) return;
|
|
1988
|
-
const updates = {
|
|
1989
|
-
IMHUB_SMTP_HOST: null, IMHUB_SMTP_PORT: null, IMHUB_SMTP_USER: null,
|
|
1990
|
-
IMHUB_SMTP_PASS: null, IMHUB_SMTP_FROM: null, IMHUB_SMTP_SECURE: null,
|
|
1991
|
-
};
|
|
1992
|
-
try {
|
|
1993
|
-
await authFetch('/api/env', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates }) });
|
|
1994
|
-
await loadEnvSection();
|
|
1995
|
-
} catch (err) {
|
|
1996
|
-
alert('Disable failed: ' + (err && err.message ? err.message : err));
|
|
1997
|
-
}
|
|
1998
|
-
});
|
|
1999
|
-
document.getElementById('revealSmtp')?.addEventListener('click', () => loadEnvSection(true));
|
|
2000
|
-
|
|
2001
|
-
// Baidu AK
|
|
2002
|
-
document.getElementById('saveBaidu')?.addEventListener('click', async () => {
|
|
2003
|
-
const ak = document.getElementById('baiduAk')?.value?.trim() || '';
|
|
2004
|
-
if (!ak) { alert('AK required (use Clear button to remove instead).'); return; }
|
|
2005
|
-
const looksMasked = /\*{2,}/.test(ak);
|
|
2006
|
-
if (looksMasked) { alert('Looks like the masked placeholder — enter the real AK or Reveal first.'); return; }
|
|
2007
|
-
try {
|
|
2008
|
-
await authFetch('/api/env', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates: { IMHUB_BAIDU_MAP_AK: ak } }) });
|
|
2009
|
-
alert('Baidu AK saved.');
|
|
2010
|
-
await loadEnvSection();
|
|
2011
|
-
} catch (err) {
|
|
2012
|
-
alert('Save failed: ' + (err && err.message ? err.message : err));
|
|
2013
|
-
}
|
|
2014
|
-
});
|
|
2015
|
-
document.getElementById('clearBaidu')?.addEventListener('click', async () => {
|
|
2016
|
-
if (!confirm('Clear Baidu Maps AK? Address-based /memo lookups will stop.')) return;
|
|
2017
|
-
try {
|
|
2018
|
-
await authFetch('/api/env', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates: { IMHUB_BAIDU_MAP_AK: null } }) });
|
|
2019
|
-
await loadEnvSection();
|
|
2020
|
-
} catch (err) {
|
|
2021
|
-
alert('Clear failed: ' + (err && err.message ? err.message : err));
|
|
2022
|
-
}
|
|
2023
|
-
});
|
|
2024
|
-
document.getElementById('revealBaidu')?.addEventListener('click', () => loadEnvSection(true));
|
|
2025
|
-
|
|
2026
|
-
// Memory card — save toggle.
|
|
2027
|
-
document.getElementById('saveMemory')?.addEventListener('click', async () => {
|
|
2028
|
-
const on = document.getElementById('memoryEnabled')?.checked ? '1' : '0';
|
|
2029
|
-
const status = document.getElementById('memoryStatus');
|
|
2030
|
-
try {
|
|
2031
|
-
await authFetch('/api/env', {
|
|
2032
|
-
method: 'PUT',
|
|
2033
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2034
|
-
body: JSON.stringify({ updates: { IMHUB_MEMORY_ENABLED: on } }),
|
|
2035
|
-
});
|
|
2036
|
-
if (status) status.textContent = t('memorySaved');
|
|
2037
|
-
await loadEnvSection();
|
|
2038
|
-
} catch (err) {
|
|
2039
|
-
if (status) status.textContent = t('memorySaveFailed') + ': ' + (err && err.message ? err.message : err);
|
|
2040
|
-
}
|
|
2041
|
-
});
|
|
2042
|
-
|
|
2043
|
-
// A2A card — save timeouts (min → ms) + mode + depth + heartbeat.
|
|
2044
|
-
document.getElementById('saveA2A')?.addEventListener('click', async () => {
|
|
2045
|
-
const dtMin = parseInt(document.getElementById('a2aDefaultTimeout')?.value || '', 10);
|
|
2046
|
-
const mtMin = parseInt(document.getElementById('a2aMaxTimeout')?.value || '', 10);
|
|
2047
|
-
const mode = document.getElementById('a2aNotifyMode')?.value || 'essential';
|
|
2048
|
-
const depth = (document.getElementById('a2aMaxDepth')?.value || '').trim();
|
|
2049
|
-
const heartbeat = (document.getElementById('a2aHeartbeatMin')?.value || '').trim();
|
|
2050
|
-
const status = document.getElementById('a2aStatus');
|
|
2051
|
-
try {
|
|
2052
|
-
await authFetch('/api/env', {
|
|
2053
|
-
method: 'PUT',
|
|
2054
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2055
|
-
body: JSON.stringify({
|
|
2056
|
-
updates: {
|
|
2057
|
-
IMHUB_A2A_TIMEOUT_DEFAULT_MS: Number.isFinite(dtMin) && dtMin > 0 ? String(dtMin * 60000) : null,
|
|
2058
|
-
IMHUB_A2A_MAX_TIMEOUT_MS: Number.isFinite(mtMin) && mtMin > 0 ? String(mtMin * 60000) : null,
|
|
2059
|
-
IMHUB_A2A_NOTIFY_MODE: mode,
|
|
2060
|
-
IMHUB_A2A_NOTIFY_MAX_DEPTH: depth || null,
|
|
2061
|
-
IMHUB_A2A_HEARTBEAT_MIN: heartbeat || null,
|
|
2062
|
-
},
|
|
2063
|
-
}),
|
|
2064
|
-
});
|
|
2065
|
-
if (status) status.textContent = t('a2aSaved');
|
|
2066
|
-
await loadEnvSection();
|
|
2067
|
-
} catch (err) {
|
|
2068
|
-
if (status) status.textContent = t('a2aSaveFailed') + ': ' + (err && err.message ? err.message : err);
|
|
2069
|
-
}
|
|
2070
|
-
});
|
|
2071
|
-
|
|
2072
|
-
// Tunnel mode dropdown — show/hide status panel on change.
|
|
2073
|
-
document.getElementById('viewerTunnelMode')?.addEventListener('change', (e) => {
|
|
2074
|
-
const box = document.getElementById('viewerTunnelStatusBox');
|
|
2075
|
-
if (box) box.style.display = e.target.value === 'quick' ? 'block' : 'none';
|
|
2076
|
-
});
|
|
2077
|
-
document.getElementById('viewerTunnelRefreshBtn')?.addEventListener('click', () => refreshViewerTunnelStatus());
|
|
2078
|
-
|
|
2079
|
-
// Viewer card — save toggle + URL + thresholds in one round-trip.
|
|
2080
|
-
document.getElementById('saveViewer')?.addEventListener('click', async () => {
|
|
2081
|
-
const enabled = document.getElementById('viewerEnabled')?.checked ? '1' : '0';
|
|
2082
|
-
const url = (document.getElementById('viewerPublicUrl')?.value || '').trim();
|
|
2083
|
-
const chars = (document.getElementById('viewerChars')?.value || '').trim();
|
|
2084
|
-
const lines = (document.getElementById('viewerLines')?.value || '').trim();
|
|
2085
|
-
const codeLines = (document.getElementById('viewerCodeLines')?.value || '').trim();
|
|
2086
|
-
const maxPastes = (document.getElementById('viewerMaxPastes')?.value || '').trim();
|
|
2087
|
-
const tunnelMode = document.getElementById('viewerTunnelMode')?.value || 'off';
|
|
2088
|
-
const status = document.getElementById('viewerStatus');
|
|
2089
|
-
try {
|
|
2090
|
-
await authFetch('/api/env', {
|
|
2091
|
-
method: 'PUT',
|
|
2092
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2093
|
-
body: JSON.stringify({
|
|
2094
|
-
updates: {
|
|
2095
|
-
IMHUB_VIEWER_ENABLED: enabled,
|
|
2096
|
-
IMHUB_VIEWER_PUBLIC_BASE_URL: url || null,
|
|
2097
|
-
IMHUB_VIEWER_CHARS: chars || null,
|
|
2098
|
-
IMHUB_VIEWER_LINES: lines || null,
|
|
2099
|
-
IMHUB_VIEWER_CODE_LINES: codeLines || null,
|
|
2100
|
-
IMHUB_VIEWER_MAX_PASTES: maxPastes || null,
|
|
2101
|
-
IMHUB_VIEWER_TUNNEL_MODE: tunnelMode || 'off',
|
|
2102
|
-
},
|
|
2103
|
-
}),
|
|
2104
|
-
});
|
|
2105
|
-
if (status) status.textContent = t('viewerSaved');
|
|
2106
|
-
await loadEnvSection();
|
|
2107
|
-
} catch (err) {
|
|
2108
|
-
if (status) status.textContent = t('viewerSaveFailed') + ': ' + (err && err.message ? err.message : err);
|
|
2109
|
-
}
|
|
2110
|
-
});
|
|
2111
|
-
|
|
2112
|
-
// ==========================================
|
|
2113
|
-
// Workspaces (PR-C)
|
|
2114
|
-
// ==========================================
|
|
2115
|
-
|
|
2116
|
-
// Click "Edit" on a row → pre-fill the form. The same form's Save
|
|
2117
|
-
// button always POSTs, which the server-side handler treats as
|
|
2118
|
-
// upsert (workspaceRegistry.add overwrites by id).
|
|
2119
|
-
document.querySelectorAll('[data-edit-ws]').forEach((btn) => {
|
|
2120
|
-
btn.addEventListener('click', () => {
|
|
2121
|
-
const id = btn.getAttribute('data-edit-ws');
|
|
2122
|
-
const w = (workspaceList || []).find((x) => x.id === id);
|
|
2123
|
-
if (!w) return;
|
|
2124
|
-
const set = (elId, v) => { const el = document.getElementById(elId); if (el) el.value = v; };
|
|
2125
|
-
set('wsId', w.id);
|
|
2126
|
-
set('wsName', w.name || w.id);
|
|
2127
|
-
set('wsAgents', (w.agents || []).join(', '));
|
|
2128
|
-
set('wsMembers', (w.members || []).join(', '));
|
|
2129
|
-
set('wsRate', w.rateLimit?.rate ?? '');
|
|
2130
|
-
set('wsInterval', w.rateLimit?.intervalSec ?? '');
|
|
2131
|
-
set('wsBurst', w.rateLimit?.burst ?? '');
|
|
2132
|
-
// Lock the id field on edit so a typo can't accidentally create
|
|
2133
|
-
// a parallel row instead of updating the chosen one.
|
|
2134
|
-
const idInput = document.getElementById('wsId');
|
|
2135
|
-
if (idInput) idInput.readOnly = true;
|
|
2136
|
-
// Scroll the form into view so the user sees what was filled.
|
|
2137
|
-
document.getElementById('saveWs')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2138
|
-
});
|
|
2139
|
-
});
|
|
2140
|
-
|
|
2141
|
-
// Click "Delete" on a row → confirm + DELETE.
|
|
2142
|
-
document.querySelectorAll('[data-del-ws]').forEach((btn) => {
|
|
2143
|
-
btn.addEventListener('click', async () => {
|
|
2144
|
-
const id = btn.getAttribute('data-del-ws');
|
|
2145
|
-
if (!id) return;
|
|
2146
|
-
if (!confirm(t('workspaceConfirmDelete').replace('{id}', id))) return;
|
|
2147
|
-
try {
|
|
2148
|
-
const res = await authFetch(`/api/workspaces/${encodeURIComponent(id)}`, {
|
|
2149
|
-
method: 'DELETE',
|
|
2150
|
-
});
|
|
2151
|
-
if (!res.ok) {
|
|
2152
|
-
const data = await res.json().catch(() => ({}));
|
|
2153
|
-
throw new Error(data.error || res.statusText);
|
|
2154
|
-
}
|
|
2155
|
-
toast(t('workspaceDeleted'), 'success');
|
|
2156
|
-
await reloadWorkspaces();
|
|
2157
|
-
} catch (err) {
|
|
2158
|
-
toast(`${t('error')}: ${err.message}`, 'error');
|
|
2159
|
-
}
|
|
2160
|
-
});
|
|
2161
|
-
});
|
|
2162
|
-
|
|
2163
|
-
// Reset the form (clear inputs + unlock id field).
|
|
2164
|
-
document.getElementById('resetWs')?.addEventListener('click', () => {
|
|
2165
|
-
['wsId', 'wsName', 'wsAgents', 'wsMembers', 'wsRate', 'wsInterval', 'wsBurst'].forEach((id) => {
|
|
2166
|
-
const el = document.getElementById(id);
|
|
2167
|
-
if (el) {
|
|
2168
|
-
el.value = '';
|
|
2169
|
-
if (id === 'wsId') el.readOnly = false;
|
|
2170
|
-
}
|
|
2171
|
-
});
|
|
2172
|
-
});
|
|
2173
|
-
|
|
2174
|
-
// Save (create or upsert) workspace.
|
|
2175
|
-
document.getElementById('saveWs')?.addEventListener('click', async () => {
|
|
2176
|
-
const id = document.getElementById('wsId').value.trim();
|
|
2177
|
-
if (!id) { toast(`${t('workspaceId')} required`, 'error'); return; }
|
|
2178
|
-
if (id === 'default') { toast('"default" is reserved', 'error'); return; }
|
|
2179
|
-
const splitCsv = (s) => String(s || '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
2180
|
-
const payload = {
|
|
2181
|
-
id,
|
|
2182
|
-
name: document.getElementById('wsName').value.trim() || id,
|
|
2183
|
-
agents: splitCsv(document.getElementById('wsAgents').value),
|
|
2184
|
-
members: splitCsv(document.getElementById('wsMembers').value),
|
|
2185
|
-
};
|
|
2186
|
-
const rate = parseInt(document.getElementById('wsRate').value, 10);
|
|
2187
|
-
const intervalSec = parseInt(document.getElementById('wsInterval').value, 10);
|
|
2188
|
-
const burst = parseInt(document.getElementById('wsBurst').value, 10);
|
|
2189
|
-
if (Number.isFinite(rate) || Number.isFinite(intervalSec) || Number.isFinite(burst)) {
|
|
2190
|
-
payload.rateLimit = {
|
|
2191
|
-
rate: Number.isFinite(rate) ? rate : 10,
|
|
2192
|
-
intervalSec: Number.isFinite(intervalSec) ? intervalSec : 60,
|
|
2193
|
-
burst: Number.isFinite(burst) ? burst : 15,
|
|
2194
|
-
};
|
|
2195
|
-
}
|
|
2196
|
-
try {
|
|
2197
|
-
const res = await authFetch('/api/workspaces', {
|
|
2198
|
-
method: 'POST',
|
|
2199
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2200
|
-
body: JSON.stringify(payload),
|
|
2201
|
-
});
|
|
2202
|
-
if (!res.ok) {
|
|
2203
|
-
const data = await res.json().catch(() => ({}));
|
|
2204
|
-
throw new Error(data.error || res.statusText);
|
|
2205
|
-
}
|
|
2206
|
-
toast(t('workspaceSaved'), 'success');
|
|
2207
|
-
// Reset form for next use; reloadWorkspaces re-renders so the
|
|
2208
|
-
// new row appears immediately.
|
|
2209
|
-
document.getElementById('resetWs')?.click();
|
|
2210
|
-
await reloadWorkspaces();
|
|
2211
|
-
} catch (err) {
|
|
2212
|
-
toast(`${t('error')}: ${err.message}`, 'error');
|
|
2213
|
-
}
|
|
2214
|
-
});
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
function syncMessengerToggles() {
|
|
2218
|
-
render();
|
|
2219
|
-
}
|
|
2220
|
-
|
|
2221
|
-
// ==========================================
|
|
2222
|
-
// WeChat QR-login modal
|
|
2223
|
-
// ==========================================
|
|
2224
|
-
let wechatPollTimer = null;
|
|
2225
|
-
function closeWechatQrModal() {
|
|
2226
|
-
if (wechatPollTimer) { clearTimeout(wechatPollTimer); wechatPollTimer = null; }
|
|
2227
|
-
const m = document.getElementById('wechat-modal');
|
|
2228
|
-
if (m) m.remove();
|
|
2229
|
-
}
|
|
2230
|
-
async function openWechatQrModal() {
|
|
2231
|
-
// Tear down any prior modal first.
|
|
2232
|
-
closeWechatQrModal();
|
|
2233
|
-
const overlay = document.createElement('div');
|
|
2234
|
-
overlay.id = 'wechat-modal';
|
|
2235
|
-
overlay.style.cssText = 'position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.55)';
|
|
2236
|
-
overlay.innerHTML = `
|
|
2237
|
-
<div style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px 28px;max-width:380px;width:90%;text-align:center">
|
|
2238
|
-
<div style="font-weight:600;font-size:15px;margin-bottom:14px">${esc(t('wechatScanTitle'))}</div>
|
|
2239
|
-
<div id="wechat-qr-wrap" style="min-height:240px;display:flex;align-items:center;justify-content:center;background:var(--surface2);border-radius:8px;margin-bottom:12px">
|
|
2240
|
-
<div style="color:var(--text-dim);font-size:13px">${esc(t('wechatGenerating'))}</div>
|
|
2241
|
-
</div>
|
|
2242
|
-
<div id="wechat-qr-status" style="font-size:13px;color:var(--text-dim);margin-bottom:14px;min-height:18px"></div>
|
|
2243
|
-
<div style="display:flex;gap:8px;justify-content:center">
|
|
2244
|
-
<button type="button" class="btn" id="wechat-regen">${esc(t('wechatRegen'))}</button>
|
|
2245
|
-
<button type="button" class="btn" id="wechat-close">${esc(t('wechatClose'))}</button>
|
|
2246
|
-
</div>
|
|
2247
|
-
</div>
|
|
2248
|
-
`;
|
|
2249
|
-
document.body.appendChild(overlay);
|
|
2250
|
-
document.getElementById('wechat-close')?.addEventListener('click', closeWechatQrModal);
|
|
2251
|
-
document.getElementById('wechat-regen')?.addEventListener('click', () => { void startWechatQr(); });
|
|
2252
|
-
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeWechatQrModal(); });
|
|
2253
|
-
await startWechatQr();
|
|
2254
|
-
}
|
|
2255
|
-
|
|
2256
|
-
async function startWechatQr() {
|
|
2257
|
-
if (wechatPollTimer) { clearTimeout(wechatPollTimer); wechatPollTimer = null; }
|
|
2258
|
-
const wrap = document.getElementById('wechat-qr-wrap');
|
|
2259
|
-
const statusEl = document.getElementById('wechat-qr-status');
|
|
2260
|
-
if (!wrap || !statusEl) return;
|
|
2261
|
-
wrap.innerHTML = `<div style="color:var(--text-dim);font-size:13px">${esc(t('wechatGenerating'))}</div>`;
|
|
2262
|
-
statusEl.textContent = '';
|
|
2263
|
-
let qrToken;
|
|
2264
|
-
try {
|
|
2265
|
-
const res = await authFetch('/api/messengers/wechat/qr-start', { method: 'POST' });
|
|
2266
|
-
if (!res.ok) {
|
|
2267
|
-
const j = await res.json().catch(() => ({}));
|
|
2268
|
-
throw new Error(j.error || res.statusText);
|
|
2269
|
-
}
|
|
2270
|
-
const data = await res.json();
|
|
2271
|
-
qrToken = data.qrToken;
|
|
2272
|
-
// data.qrImage is a data: URL the server pre-rendered from the
|
|
2273
|
-
// WeChat short URL (iLink returns plain text, not an image).
|
|
2274
|
-
// data.qrUrl is the original short URL — only used as <img> alt
|
|
2275
|
-
// / debug fallback in case server-side rendering ever fails.
|
|
2276
|
-
const src = data.qrImage || data.qrUrl;
|
|
2277
|
-
wrap.innerHTML = `<img src="${esc(src)}" alt="QR code" style="width:280px;height:280px;border-radius:6px;background:#fff;padding:8px">`;
|
|
2278
|
-
statusEl.textContent = t('wechatWaiting');
|
|
2279
|
-
} catch (err) {
|
|
2280
|
-
wrap.innerHTML = `<div style="color:var(--red);font-size:13px">${esc(t('wechatFailed').replace('{error}', err && err.message ? err.message : err))}</div>`;
|
|
2281
|
-
return;
|
|
2282
|
-
}
|
|
2283
|
-
// Begin polling.
|
|
2284
|
-
const poll = async () => {
|
|
2285
|
-
try {
|
|
2286
|
-
const res = await authFetch('/api/messengers/wechat/qr-status?token=' + encodeURIComponent(qrToken));
|
|
2287
|
-
if (!res.ok) {
|
|
2288
|
-
const j = await res.json().catch(() => ({}));
|
|
2289
|
-
throw new Error(j.error || res.statusText);
|
|
2290
|
-
}
|
|
2291
|
-
const data = await res.json();
|
|
2292
|
-
if (data.status === 'wait') {
|
|
2293
|
-
statusEl.textContent = t('wechatWaiting');
|
|
2294
|
-
wechatPollTimer = setTimeout(poll, 1500);
|
|
2295
|
-
return;
|
|
2296
|
-
}
|
|
2297
|
-
if (data.status === 'scaned') {
|
|
2298
|
-
statusEl.textContent = t('wechatScanned');
|
|
2299
|
-
wechatPollTimer = setTimeout(poll, 1000);
|
|
2300
|
-
return;
|
|
2301
|
-
}
|
|
2302
|
-
if (data.status === 'confirmed') {
|
|
2303
|
-
const account = (data.credentials && data.credentials.accountId) || '';
|
|
2304
|
-
statusEl.innerHTML = '<span style="color:var(--green)">' + esc(t('wechatConfirmed').replace('{account}', account)) + '</span>';
|
|
2305
|
-
// Refresh the underlying config (server added 'wechat-ilink' to messengers).
|
|
2306
|
-
toast(t('savedMsg'), 'success');
|
|
2307
|
-
setTimeout(() => { closeWechatQrModal(); void init(); }, 1500);
|
|
2308
|
-
return;
|
|
2309
|
-
}
|
|
2310
|
-
if (data.status === 'expired') {
|
|
2311
|
-
statusEl.innerHTML = '<span style="color:var(--red)">' + esc(t('wechatExpired')) + '</span>';
|
|
2312
|
-
return;
|
|
2313
|
-
}
|
|
2314
|
-
// Unknown status — keep polling once, then stop.
|
|
2315
|
-
wechatPollTimer = setTimeout(poll, 1500);
|
|
2316
|
-
} catch (err) {
|
|
2317
|
-
statusEl.innerHTML = '<span style="color:var(--red)">' + esc(t('wechatFailed').replace('{error}', err && err.message ? err.message : err)) + '</span>';
|
|
2318
|
-
}
|
|
2319
|
-
};
|
|
2320
|
-
wechatPollTimer = setTimeout(poll, 1000);
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
// ==========================================
|
|
2324
|
-
// Service-control card (status + start/stop/restart)
|
|
2325
|
-
// ==========================================
|
|
2326
|
-
let svcPollTimer = null;
|
|
2327
|
-
async function loadServiceStatus() {
|
|
2328
|
-
const stateEl = document.getElementById('svc-state');
|
|
2329
|
-
const startBtn = document.getElementById('svc-start');
|
|
2330
|
-
const stopBtn = document.getElementById('svc-stop');
|
|
2331
|
-
const restartBtn = document.getElementById('svc-restart');
|
|
2332
|
-
if (!stateEl) return;
|
|
2333
|
-
try {
|
|
2334
|
-
const res = await authFetch('/api/service/status');
|
|
2335
|
-
if (!res.ok) throw new Error(res.statusText);
|
|
2336
|
-
const d = await res.json();
|
|
2337
|
-
if (d.mode === 'none') {
|
|
2338
|
-
stateEl.innerHTML = '<span class="dot dot-off"></span>' + esc(t('svcStateNone'));
|
|
2339
|
-
} else {
|
|
2340
|
-
const tpl = d.uptime ? t('svcStateRunning') : t('svcStateRunningNoUp');
|
|
2341
|
-
const label = tpl
|
|
2342
|
-
.replace('{mode}', d.mode || '?')
|
|
2343
|
-
.replace('{pid}', d.pid != null ? d.pid : '?')
|
|
2344
|
-
.replace('{uptime}', d.uptime || '');
|
|
2345
|
-
stateEl.innerHTML = '<span class="dot dot-on"></span>' + esc(label);
|
|
2346
|
-
}
|
|
2347
|
-
if (startBtn) startBtn.disabled = d.mode !== 'none';
|
|
2348
|
-
if (stopBtn) stopBtn.disabled = d.mode === 'none';
|
|
2349
|
-
if (restartBtn) restartBtn.disabled = d.mode === 'none' || d.mode === 'foreground';
|
|
2350
|
-
} catch (err) {
|
|
2351
|
-
stateEl.textContent = (t('error') + ': ' + (err && err.message ? err.message : err));
|
|
2352
|
-
}
|
|
2353
|
-
}
|
|
2354
|
-
async function svcAction(action) {
|
|
2355
|
-
const confirmKey = action === 'stop' ? 'svcConfirmStop' : action === 'restart' ? 'svcConfirmRestart' : null;
|
|
2356
|
-
if (confirmKey && !confirm(t(confirmKey))) return;
|
|
2357
|
-
const stateEl = document.getElementById('svc-state');
|
|
2358
|
-
|
|
2359
|
-
// v1.5 — Update UI BEFORE firing the fetch. The restart POST can
|
|
2360
|
-
// take 5-10s server-side (systemctl restart waits for stop+start)
|
|
2361
|
-
// and the previous code only updated state after that resolved —
|
|
2362
|
-
// making the button look frozen + tempting double clicks. Also
|
|
2363
|
-
// disable the action buttons so the user can't trigger a second
|
|
2364
|
-
// restart while one is in flight.
|
|
2365
|
-
const btns = ['svc-restart', 'svc-stop', 'svc-start']
|
|
2366
|
-
.map(id => document.getElementById(id))
|
|
2367
|
-
.filter(Boolean);
|
|
2368
|
-
btns.forEach(b => b.disabled = true);
|
|
2369
|
-
if (action === 'restart' && stateEl) {
|
|
2370
|
-
stateEl.innerHTML = '<span class="dot dot-off"></span>' + esc(t('svcRestarting'));
|
|
2371
|
-
} else if (action === 'stop' && stateEl) {
|
|
2372
|
-
stateEl.innerHTML = '<span class="dot dot-off"></span>' + esc(t('svcStopped'));
|
|
2373
|
-
}
|
|
2374
|
-
|
|
2375
|
-
try {
|
|
2376
|
-
const res = await authFetch('/api/service/' + action, { method: 'POST' });
|
|
2377
|
-
if (action === 'restart') {
|
|
2378
|
-
await waitForServiceBack(stateEl);
|
|
2379
|
-
if (stateEl) {
|
|
2380
|
-
// loadServiceStatus refreshes the badge to live state.
|
|
2381
|
-
await loadServiceStatus();
|
|
2382
|
-
toast(t('svcRestarted'), 'success');
|
|
2383
|
-
}
|
|
2384
|
-
return;
|
|
2385
|
-
}
|
|
2386
|
-
if (action === 'stop') {
|
|
2387
|
-
// Don't poll — the server is gone.
|
|
2388
|
-
return;
|
|
2389
|
-
}
|
|
2390
|
-
if (!res.ok) {
|
|
2391
|
-
const j = await res.json().catch(() => ({}));
|
|
2392
|
-
throw new Error(j.error || res.statusText);
|
|
2393
|
-
}
|
|
2394
|
-
await loadServiceStatus();
|
|
2395
|
-
} catch (err) {
|
|
2396
|
-
toast(t('error') + ': ' + (err && err.message ? err.message : err), 'error');
|
|
2397
|
-
} finally {
|
|
2398
|
-
// loadServiceStatus re-enables the buttons based on live state.
|
|
2399
|
-
// Restart re-enables them via the polling-back-up path; stop
|
|
2400
|
-
// leaves them disabled (server is gone).
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
async function waitForServiceBack(stateEl) {
|
|
2404
|
-
// v1.5 — wait for full readiness, not just HTTP up. /api/service/status
|
|
2405
|
-
// returns bootPhase; we treat 'ready' as done. While waiting, show
|
|
2406
|
-
// a phase-specific status text so the user sees real progress instead
|
|
2407
|
-
// of a blank "..." spinner.
|
|
2408
|
-
const phaseLabels = {
|
|
2409
|
-
starting: '⏳ 启动中…',
|
|
2410
|
-
messengers_loading: '⏳ 加载消息通道…',
|
|
2411
|
-
web_ready: '⏳ Web 已就绪,等待全部子系统…',
|
|
2412
|
-
ready: '✓ 完全就绪',
|
|
2413
|
-
};
|
|
2414
|
-
const start = Date.now();
|
|
2415
|
-
let lastPhaseShown = null;
|
|
2416
|
-
for (;;) {
|
|
2417
|
-
if (Date.now() - start > 60000) {
|
|
2418
|
-
if (stateEl) stateEl.innerHTML = '<span class="dot dot-off"></span>' + esc(t('error'));
|
|
2419
|
-
return;
|
|
2420
|
-
}
|
|
2421
|
-
try {
|
|
2422
|
-
const r = await fetch('/api/service/status', { credentials: 'same-origin' });
|
|
2423
|
-
if (r.ok) {
|
|
2424
|
-
const j = await r.json().catch(() => ({}));
|
|
2425
|
-
const phase = j.bootPhase || 'starting';
|
|
2426
|
-
if (phase === 'ready') return;
|
|
2427
|
-
if (phase !== lastPhaseShown && stateEl) {
|
|
2428
|
-
const label = phaseLabels[phase] || ('⏳ ' + phase);
|
|
2429
|
-
stateEl.innerHTML = '<span class="dot dot-off"></span>' + esc(label);
|
|
2430
|
-
lastPhaseShown = phase;
|
|
2431
|
-
}
|
|
2432
|
-
}
|
|
2433
|
-
} catch { /* not back yet */ }
|
|
2434
|
-
await new Promise(r => setTimeout(r, 500));
|
|
2435
|
-
}
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
// ==========================================
|
|
2439
|
-
// API helpers
|
|
2440
|
-
// ==========================================
|
|
2441
|
-
async function saveConfig() {
|
|
2442
|
-
try {
|
|
2443
|
-
const payload = {
|
|
2444
|
-
messengers: config.messengers,
|
|
2445
|
-
agents: config.agents,
|
|
2446
|
-
defaultAgent: config.defaultAgent,
|
|
2447
|
-
telegram: config.telegram,
|
|
2448
|
-
feishu: config.feishu,
|
|
2449
|
-
dingtalk: config.dingtalk,
|
|
2450
|
-
discord: config.discord,
|
|
2451
|
-
acpAgents: config.acpAgents,
|
|
2452
|
-
webPort: config.webPort,
|
|
2453
|
-
};
|
|
2454
|
-
|
|
2455
|
-
const res = await authFetch('/api/config', {
|
|
2456
|
-
method: 'PUT',
|
|
2457
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2458
|
-
body: JSON.stringify(payload),
|
|
2459
|
-
});
|
|
2460
|
-
|
|
2461
|
-
if (!res.ok) {
|
|
2462
|
-
const data = await res.json();
|
|
2463
|
-
throw new Error(data.error || t('saveFailed'));
|
|
2464
|
-
}
|
|
2465
|
-
|
|
2466
|
-
toast(t('savedMsg'), 'success');
|
|
2467
|
-
await init();
|
|
2468
|
-
} catch (err) {
|
|
2469
|
-
toast(`${t('saveFailed')}: ${err.message}`, 'error');
|
|
2470
|
-
}
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
let toastTimer;
|
|
2474
|
-
function toast(msg, type) {
|
|
2475
|
-
toastEl.textContent = msg;
|
|
2476
|
-
toastEl.className = `toast ${type} show`;
|
|
2477
|
-
clearTimeout(toastTimer);
|
|
2478
|
-
toastTimer = setTimeout(() => {
|
|
2479
|
-
toastEl.classList.remove('show');
|
|
2480
|
-
}, 3000);
|
|
2481
|
-
}
|
|
2482
|
-
|
|
2483
|
-
// Init
|
|
2484
|
-
applyLang();
|
|
2485
|
-
init();
|
|
2486
|
-
</script>
|
|
2487
|
-
</body>
|
|
2488
|
-
</html>
|