agim-cli 1.1.4 → 1.1.6

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.
@@ -151,6 +151,23 @@
151
151
  approvalPolicySave: 'Save',
152
152
  approvalPolicySaved: 'Saved — applies to the next pending approval, no restart needed.',
153
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).',
154
171
  },
155
172
  zh: {
156
173
  title: 'Agim — 设置',
@@ -288,6 +305,23 @@
288
305
  approvalPolicySave: '保存',
289
306
  approvalPolicySaved: '已保存 — 下一个挂起的审批起就生效,无需重启。',
290
307
  approvalPolicyLoadFailed: '加载审批策略失败',
308
+
309
+ // ── Viewer (长内容 web 渲染) ─────────────────────────────
310
+ viewerTitle: 'IM 长内容 Viewer',
311
+ viewerHint: 'Agent 回答过长或含 markdown 表格 / 大段代码时,Agim 把全文存到本机 ~/.agim/viewer.db(永久保存,绝不离开本机),IM 里只发短摘要 + 跳转链接。这里填你反代到本机 Agim web 的公网域名,链接才能让手机/微信打开。',
312
+ viewerEnabled: '开启',
313
+ viewerDisabled: '关闭',
314
+ viewerEnabledLabel: 'Viewer 状态',
315
+ viewerPublicUrl: '公网域名',
316
+ viewerPublicUrlHint: '指向本机 Agim web 的公网域名(例如 https://agim.example.com)。用 cloudflared / caddy / tailscale 把 3000 端口暴露出去即可。',
317
+ viewerChars: '字符阈值',
318
+ viewerLines: '行数阈值',
319
+ viewerCodeLines: '代码块行数阈值',
320
+ viewerMaxPastes: '本机最多保留条数(LRU 淘汰)',
321
+ viewerSave: '保存',
322
+ viewerSaved: '已保存 — 下一条回复生效。',
323
+ viewerSaveFailed: '保存 Viewer 配置失败',
324
+ viewerMissingUrl: '⚠️ 公网域名为空 — 长回复将退回到内联文本(无法构造跳转链接)。',
291
325
  },
292
326
  };
293
327
  function t(key) { return T[window.__lang][key] || T.en[key] || key; }
@@ -1369,6 +1403,47 @@
1369
1403
  <p class="muted" id="smtpStatus" style="margin-top:8px;font-size:12px"></p>
1370
1404
  </div>
1371
1405
 
1406
+ <!-- Viewer (env-file) — long-message web rendering -->
1407
+ <div class="card">
1408
+ <h2>📄 ${t('viewerTitle')}</h2>
1409
+ <p class="muted" style="margin-top:-4px;margin-bottom:12px;font-size:13px">${t('viewerHint')}</p>
1410
+
1411
+ <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
1412
+ <label style="display:flex;gap:8px;align-items:center;font-weight:600">
1413
+ <input type="checkbox" id="viewerEnabled" />
1414
+ ${t('viewerEnabledLabel')}
1415
+ </label>
1416
+ </div>
1417
+
1418
+ <label>${t('viewerPublicUrl')}</label>
1419
+ <input type="text" id="viewerPublicUrl" placeholder="https://agim.example.com" style="max-width:480px" />
1420
+ <p class="muted" style="margin-top:4px;font-size:12px">${t('viewerPublicUrlHint')}</p>
1421
+
1422
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:12px;max-width:720px">
1423
+ <div>
1424
+ <label>${t('viewerChars')}</label>
1425
+ <input type="number" id="viewerChars" min="100" max="10000" placeholder="500" />
1426
+ </div>
1427
+ <div>
1428
+ <label>${t('viewerLines')}</label>
1429
+ <input type="number" id="viewerLines" min="5" max="200" placeholder="12" />
1430
+ </div>
1431
+ <div>
1432
+ <label>${t('viewerCodeLines')}</label>
1433
+ <input type="number" id="viewerCodeLines" min="5" max="200" placeholder="10" />
1434
+ </div>
1435
+ <div>
1436
+ <label>${t('viewerMaxPastes')}</label>
1437
+ <input type="number" id="viewerMaxPastes" min="100" max="1000000" placeholder="10000" />
1438
+ </div>
1439
+ </div>
1440
+
1441
+ <div class="actions" style="margin-top:12px">
1442
+ <button type="button" class="btn btn-primary" id="saveViewer">${t('viewerSave')}</button>
1443
+ </div>
1444
+ <p class="muted" id="viewerStatus" style="margin-top:8px;font-size:12px"></p>
1445
+ </div>
1446
+
1372
1447
  <!-- Baidu Maps AK (env-file) — feeds /memo address geocoding -->
1373
1448
  <div class="card">
1374
1449
  <h2>🗺 Baidu Maps AK</h2>
@@ -1404,6 +1479,28 @@
1404
1479
  const sec = document.getElementById('smtpSecure');
1405
1480
  if (sec) sec.value = env['IMHUB_SMTP_SECURE'] || 'auto';
1406
1481
  set('baiduAk', 'IMHUB_BAIDU_MAP_AK');
1482
+ // Viewer card fields
1483
+ const vEnabled = document.getElementById('viewerEnabled');
1484
+ if (vEnabled) {
1485
+ const v = (env['IMHUB_VIEWER_ENABLED'] || '').toLowerCase();
1486
+ vEnabled.checked = v === '1' || v === 'true' || v === 'yes';
1487
+ }
1488
+ set('viewerPublicUrl', 'IMHUB_VIEWER_PUBLIC_BASE_URL');
1489
+ set('viewerChars', 'IMHUB_VIEWER_CHARS');
1490
+ set('viewerLines', 'IMHUB_VIEWER_LINES');
1491
+ set('viewerCodeLines', 'IMHUB_VIEWER_CODE_LINES');
1492
+ set('viewerMaxPastes', 'IMHUB_VIEWER_MAX_PASTES');
1493
+ const viewerStatus = document.getElementById('viewerStatus');
1494
+ if (viewerStatus) {
1495
+ if (!env['IMHUB_VIEWER_PUBLIC_BASE_URL']) {
1496
+ viewerStatus.textContent = t('viewerMissingUrl');
1497
+ } else if ((env['IMHUB_VIEWER_ENABLED'] || '').toLowerCase() === '1' ||
1498
+ (env['IMHUB_VIEWER_ENABLED'] || '').toLowerCase() === 'true') {
1499
+ viewerStatus.textContent = `✓ ${t('viewerEnabled')} → ${env['IMHUB_VIEWER_PUBLIC_BASE_URL']}`;
1500
+ } else {
1501
+ viewerStatus.textContent = `${t('viewerDisabled')} (${env['IMHUB_VIEWER_PUBLIC_BASE_URL']})`;
1502
+ }
1503
+ }
1407
1504
  const smtpStatus = document.getElementById('smtpStatus');
1408
1505
  const baiduStatus = document.getElementById('baiduStatus');
1409
1506
  if (smtpStatus) smtpStatus.textContent = env['IMHUB_SMTP_HOST']
@@ -1704,6 +1801,37 @@
1704
1801
  });
1705
1802
  document.getElementById('revealBaidu')?.addEventListener('click', () => loadEnvSection(true));
1706
1803
 
1804
+ // Viewer card — save toggle + URL + thresholds in one round-trip.
1805
+ document.getElementById('saveViewer')?.addEventListener('click', async () => {
1806
+ const enabled = document.getElementById('viewerEnabled')?.checked ? '1' : '0';
1807
+ const url = (document.getElementById('viewerPublicUrl')?.value || '').trim();
1808
+ const chars = (document.getElementById('viewerChars')?.value || '').trim();
1809
+ const lines = (document.getElementById('viewerLines')?.value || '').trim();
1810
+ const codeLines = (document.getElementById('viewerCodeLines')?.value || '').trim();
1811
+ const maxPastes = (document.getElementById('viewerMaxPastes')?.value || '').trim();
1812
+ const status = document.getElementById('viewerStatus');
1813
+ try {
1814
+ await authFetch('/api/env', {
1815
+ method: 'PUT',
1816
+ headers: { 'Content-Type': 'application/json' },
1817
+ body: JSON.stringify({
1818
+ updates: {
1819
+ IMHUB_VIEWER_ENABLED: enabled,
1820
+ IMHUB_VIEWER_PUBLIC_BASE_URL: url || null,
1821
+ IMHUB_VIEWER_CHARS: chars || null,
1822
+ IMHUB_VIEWER_LINES: lines || null,
1823
+ IMHUB_VIEWER_CODE_LINES: codeLines || null,
1824
+ IMHUB_VIEWER_MAX_PASTES: maxPastes || null,
1825
+ },
1826
+ }),
1827
+ });
1828
+ if (status) status.textContent = t('viewerSaved');
1829
+ await loadEnvSection();
1830
+ } catch (err) {
1831
+ if (status) status.textContent = t('viewerSaveFailed') + ': ' + (err && err.message ? err.message : err);
1832
+ }
1833
+ });
1834
+
1707
1835
  // ==========================================
1708
1836
  // Workspaces (PR-C)
1709
1837
  // ==========================================
@@ -25,8 +25,10 @@
25
25
  const T = {
26
26
  en: {
27
27
  title: 'Agim — Tasks',
28
- h1: 'Tasks & Schedules',
29
- backToChat: 'Chat',
28
+ h1: '🗂 Tasks & Schedules',
29
+ backToChat: 'Chat',
30
+ toReminders: 'Reminders',
31
+ toMemos: 'Memos',
30
32
  toSettings: 'Settings',
31
33
  tabsJobs: 'Jobs',
32
34
  tabsBackground: 'Background',
@@ -231,8 +233,10 @@
231
233
  },
232
234
  zh: {
233
235
  title: 'Agim — 任务',
234
- h1: '任务与定时',
235
- backToChat: '对话',
236
+ h1: '🗂 任务与定时',
237
+ backToChat: '对话',
238
+ toReminders: '提醒',
239
+ toMemos: '备忘',
236
240
  toSettings: '设置',
237
241
  tabsAudit: '审计',
238
242
  tabsJobs: '任务',
@@ -747,14 +751,14 @@
747
751
  <header>
748
752
  <h1 id="page-title"></h1>
749
753
  <button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
750
- <select id="langSelect" title="Language / 语言" style="margin-right:8px">
754
+ <select id="langSelect" title="Language / 语言">
751
755
  <option value="en">EN</option>
752
756
  <option value="zh">中文</option>
753
757
  </select>
754
- <a href="/">↩ <span id="lbl-chat"></span></a>
755
- <a href="/reminders" id="lnk-reminders">Reminders</a>
756
- <a href="/memos" id="lnk-memos">Memos</a>
757
- <a href="/settings" aria-label="Settings"><span id="lbl-settings"></span></a>
758
+ <a href="/" id="lnk-chat"></a>
759
+ <a href="/reminders" id="lnk-reminders"></a>
760
+ <a href="/memos" id="lnk-memos"></a>
761
+ <a href="/settings" id="lnk-settings"></a>
758
762
  </header>
759
763
  <main>
760
764
  <div class="tabs">
@@ -954,8 +958,10 @@
954
958
  // i18n string fills
955
959
  document.title = T.title;
956
960
  document.getElementById('page-title').textContent = T.h1;
957
- document.getElementById('lbl-chat').textContent = T.backToChat;
958
- document.getElementById('lbl-settings').textContent = T.toSettings;
961
+ document.getElementById('lnk-chat').textContent = T.backToChat;
962
+ document.getElementById('lnk-reminders').textContent = T.toReminders;
963
+ document.getElementById('lnk-memos').textContent = T.toMemos;
964
+ document.getElementById('lnk-settings').textContent = T.toSettings;
959
965
  document.getElementById('tab-jobs').textContent = T.tabsJobs;
960
966
  document.getElementById('tab-background').textContent = T.tabsBackground;
961
967
  document.getElementById('tab-subtasks').textContent = T.tabsSubtasks;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAsDA,wBAAgB,iBAAiB,IAAI,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAOrE;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAsqB/C"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAsDA,wBAAgB,iBAAiB,IAAI,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAOrE;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CA+rB/C"}
@@ -106,6 +106,15 @@ export async function startWebServer(options) {
106
106
  if (url.pathname === '/loc' && req.method === 'GET') {
107
107
  return serveStatic(res, join(PUBLIC_DIR, 'loc.html'), 'text/html; charset=utf-8');
108
108
  }
109
+ // v1.2 — Viewer: rendered markdown for long IM messages. URL is
110
+ // unguessable uuidv4; same trust model as /loc (public-on-purpose,
111
+ // operator fronts the web with their reverse proxy / tunnel).
112
+ // Content stays in ~/.agim/viewer.db on this host — the public URL
113
+ // is just a proxy back to here. See src/core/viewer-local.ts.
114
+ const viewerMatch = url.pathname.match(/^\/v\/([0-9a-f-]{8,})$/i);
115
+ if (viewerMatch && req.method === 'GET') {
116
+ return handleViewerPage(res, viewerMatch[1]);
117
+ }
109
118
  // Short alias: /l/<token> serves the same H5 page. The page auto-detects
110
119
  // either ?t= query or /l/<token> path. Lets us issue ~38-char URLs
111
120
  // (vs 70+ for /loc?t=<32-hex>) which keeps WeChat chat cleaner.
@@ -396,6 +405,20 @@ export async function startWebServer(options) {
396
405
  if (artifactsFileMatch && req.method === 'GET') {
397
406
  return handleArtifactsFile(req, res, parseInt(artifactsFileMatch[1], 10), artifactsFileMatch[2]);
398
407
  }
408
+ // v1.2 — Viewer pastes (long markdown stash for IM short-link replies).
409
+ // GET /api/viewer — list pastes (newest first), for the web console.
410
+ // GET /api/viewer/:id — raw JSON of one paste.
411
+ // DELETE /api/viewer/:id — remove a paste (operator can clean up).
412
+ if (url.pathname === '/api/viewer' && req.method === 'GET') {
413
+ return handleViewerList(req, res, url);
414
+ }
415
+ const viewerIdMatch = url.pathname.match(/^\/api\/viewer\/([0-9a-f-]{8,})$/i);
416
+ if (viewerIdMatch && req.method === 'GET') {
417
+ return handleViewerGet(req, res, viewerIdMatch[1]);
418
+ }
419
+ if (viewerIdMatch && req.method === 'DELETE') {
420
+ return handleViewerDelete(req, res, viewerIdMatch[1]);
421
+ }
399
422
  // PR-B: agent health snapshot (circuit breaker + rate-limiter remaining
400
423
  // + latency p50/95/99) consumed by the Health tab in /tasks.
401
424
  if (url.pathname === '/api/agent-health' && req.method === 'GET') {
@@ -1608,6 +1631,15 @@ const ENV_EDITABLE_KEYS = [
1608
1631
  // Hot-reload: handlePutEnv mutates process.env so approval-bus picks up the
1609
1632
  // new value on the next timer fire, no restart needed.
1610
1633
  'IMHUB_TIMEOUT_DEFAULT',
1634
+ // v1.2 — IM long-message viewer. Operator sets the public URL pointing at
1635
+ // their reverse-proxied agim web port; thresholds tune when routing kicks
1636
+ // in. Hot-reload works because viewer-config reads process.env every call.
1637
+ 'IMHUB_VIEWER_ENABLED',
1638
+ 'IMHUB_VIEWER_PUBLIC_BASE_URL',
1639
+ 'IMHUB_VIEWER_CHARS',
1640
+ 'IMHUB_VIEWER_LINES',
1641
+ 'IMHUB_VIEWER_CODE_LINES',
1642
+ 'IMHUB_VIEWER_MAX_PASTES',
1611
1643
  ];
1612
1644
  const SECRET_KEYS = new Set(['IMHUB_SMTP_PASS', 'IMHUB_BAIDU_MAP_AK']);
1613
1645
  function maskSecret(v) {
@@ -2694,6 +2726,66 @@ async function handleArtifactsFile(_req, res, jobId, name) {
2694
2726
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2695
2727
  }
2696
2728
  }
2729
+ // ─── Viewer handlers ───
2730
+ // v1.2 — Render a stored paste as an HTML page. URL is unguessable uuidv4
2731
+ // (32 hex chars including dashes); we still cap the length match to a
2732
+ // reasonable max in the router to avoid pathological lookups.
2733
+ async function handleViewerPage(res, id) {
2734
+ const { getPaste, bumpViewCount } = await import('../core/viewer-local.js');
2735
+ const { renderPasteHtml, renderNotFoundHtml } = await import('./viewer-render.js');
2736
+ const row = getPaste(id);
2737
+ if (!row) {
2738
+ res.writeHead(404, {
2739
+ 'Content-Type': 'text/html; charset=utf-8',
2740
+ 'X-Robots-Tag': 'noindex, nofollow',
2741
+ });
2742
+ res.end(renderNotFoundHtml());
2743
+ return;
2744
+ }
2745
+ bumpViewCount(id);
2746
+ res.writeHead(200, {
2747
+ 'Content-Type': 'text/html; charset=utf-8',
2748
+ 'X-Robots-Tag': 'noindex, nofollow',
2749
+ 'Cache-Control': 'no-store',
2750
+ });
2751
+ res.end(renderPasteHtml(row));
2752
+ }
2753
+ async function handleViewerList(_req, res, url) {
2754
+ const { listPastes, countPastes } = await import('../core/viewer-local.js');
2755
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 1), 500);
2756
+ const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10) || 0, 0);
2757
+ const rows = listPastes({ limit, offset });
2758
+ // Strip content from list response to keep payload light; UI loads full row via GET /api/viewer/:id.
2759
+ const items = rows.map((r) => ({
2760
+ id: r.id,
2761
+ content_type: r.content_type,
2762
+ title: r.title,
2763
+ source: r.source,
2764
+ job_id: r.job_id,
2765
+ created_at: r.created_at,
2766
+ view_count: r.view_count,
2767
+ bytes: Buffer.byteLength(r.content, 'utf8'),
2768
+ }));
2769
+ sendJson(res, 200, { total: countPastes(), limit, offset, items });
2770
+ }
2771
+ async function handleViewerGet(_req, res, id) {
2772
+ const { getPaste } = await import('../core/viewer-local.js');
2773
+ const row = getPaste(id);
2774
+ if (!row) {
2775
+ sendJson(res, 404, { error: 'not_found' });
2776
+ return;
2777
+ }
2778
+ sendJson(res, 200, row);
2779
+ }
2780
+ async function handleViewerDelete(_req, res, id) {
2781
+ const { deletePaste } = await import('../core/viewer-local.js');
2782
+ const ok = deletePaste(id);
2783
+ if (!ok) {
2784
+ sendJson(res, 404, { error: 'not_found' });
2785
+ return;
2786
+ }
2787
+ sendJson(res, 200, { ok: true });
2788
+ }
2697
2789
  // ============================================
2698
2790
  // WebSocket chat handlers
2699
2791
  // ============================================