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.
@@ -0,0 +1,39 @@
1
+ // Viewer config — env-driven settings for the long-message render router.
2
+ //
3
+ // Kept minimal & env-only for v1: the operator sets two variables (a public
4
+ // base URL + an enable flag), and the defaults handle everything else. No
5
+ // JSON-config schema dance, no on-disk state — change env, restart, done.
6
+ import { DEFAULT_THRESHOLDS } from './render-router.js';
7
+ function parsePositiveInt(raw, fallback) {
8
+ if (!raw)
9
+ return fallback;
10
+ const n = parseInt(raw, 10);
11
+ return Number.isFinite(n) && n > 0 ? n : fallback;
12
+ }
13
+ export function getViewerConfig() {
14
+ const enabledRaw = (process.env.IMHUB_VIEWER_ENABLED || '').toLowerCase();
15
+ const enabled = enabledRaw === '1' || enabledRaw === 'true' || enabledRaw === 'yes';
16
+ const publicBaseUrl = (process.env.IMHUB_VIEWER_PUBLIC_BASE_URL || '').replace(/\/$/, '');
17
+ return {
18
+ enabled,
19
+ publicBaseUrl,
20
+ thresholds: {
21
+ chars: parsePositiveInt(process.env.IMHUB_VIEWER_CHARS, DEFAULT_THRESHOLDS.chars),
22
+ lines: parsePositiveInt(process.env.IMHUB_VIEWER_LINES, DEFAULT_THRESHOLDS.lines),
23
+ codeLines: parsePositiveInt(process.env.IMHUB_VIEWER_CODE_LINES, DEFAULT_THRESHOLDS.codeLines),
24
+ },
25
+ };
26
+ }
27
+ /**
28
+ * Build the URL placed in the IM reply for a saved paste. Returns null if
29
+ * no public base URL is configured AND no fallback was provided. Caller
30
+ * should treat null as "viewer can't produce a usable link — degrade".
31
+ */
32
+ export function buildPasteUrl(id, fallbackBaseUrl) {
33
+ const cfg = getViewerConfig();
34
+ const base = cfg.publicBaseUrl || fallbackBaseUrl || '';
35
+ if (!base)
36
+ return null;
37
+ return `${base.replace(/\/$/, '')}/v/${id}`;
38
+ }
39
+ //# sourceMappingURL=viewer-config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer-config.js","sourceRoot":"","sources":["../../src/core/viewer-config.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,0EAA0E;AAE1E,OAAO,EAAE,kBAAkB,EAAyB,MAAM,oBAAoB,CAAA;AAgB9E,SAAS,gBAAgB,CAAC,GAAuB,EAAE,QAAgB;IACjE,IAAI,CAAC,GAAG;QAAE,OAAO,QAAQ,CAAA;IACzB,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IAC3B,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAA;AACnD,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,MAAM,UAAU,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAA;IACzE,MAAM,OAAO,GAAG,UAAU,KAAK,GAAG,IAAI,UAAU,KAAK,MAAM,IAAI,UAAU,KAAK,KAAK,CAAA;IACnF,MAAM,aAAa,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,4BAA4B,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;IACzF,OAAO;QACL,OAAO;QACP,aAAa;QACb,UAAU,EAAE;YACV,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,KAAK,CAAC;YACjF,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,KAAK,CAAC;YACjF,SAAS,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,uBAAuB,EAAE,kBAAkB,CAAC,SAAS,CAAC;SAC/F;KACF,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,EAAU,EAAE,eAAwB;IAChE,MAAM,GAAG,GAAG,eAAe,EAAE,CAAA;IAC7B,MAAM,IAAI,GAAG,GAAG,CAAC,aAAa,IAAI,eAAe,IAAI,EAAE,CAAA;IACvD,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAA;IACtB,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,EAAE,EAAE,CAAA;AAC7C,CAAC"}
@@ -0,0 +1,34 @@
1
+ export type PasteContentType = 'markdown' | 'plain';
2
+ export interface PasteRow {
3
+ id: string;
4
+ content: string;
5
+ content_type: PasteContentType;
6
+ title: string | null;
7
+ source: string | null;
8
+ job_id: number | null;
9
+ created_at: number;
10
+ view_count: number;
11
+ }
12
+ export interface SavePasteOpts {
13
+ content: string;
14
+ content_type?: PasteContentType;
15
+ title?: string;
16
+ source?: string;
17
+ job_id?: number;
18
+ }
19
+ /**
20
+ * Save a paste. Returns the new row's id (uuidv4). Returns null if the
21
+ * underlying DB is broken (caller should fall back to truncation).
22
+ */
23
+ export declare function savePaste(opts: SavePasteOpts): string | null;
24
+ export declare function getPaste(id: string): PasteRow | null;
25
+ export declare function bumpViewCount(id: string): void;
26
+ export interface ListOpts {
27
+ limit?: number;
28
+ offset?: number;
29
+ }
30
+ export declare function listPastes(opts?: ListOpts): PasteRow[];
31
+ export declare function deletePaste(id: string): boolean;
32
+ export declare function countPastes(): number;
33
+ export declare function closeViewerDb(): void;
34
+ //# sourceMappingURL=viewer-local.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer-local.d.ts","sourceRoot":"","sources":["../../src/core/viewer-local.ts"],"names":[],"mappings":"AA8CA,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,OAAO,CAAA;AAEnD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,EAAE,gBAAgB,CAAA;IAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,gBAAgB,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAWD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI,CA8C5D;AAED,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAQpD;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAQ9C;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,wBAAgB,UAAU,CAAC,IAAI,GAAE,QAAa,GAAG,QAAQ,EAAE,CAS1D;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAK/C;AAED,wBAAgB,WAAW,IAAI,MAAM,CAKpC;AAED,wBAAgB,aAAa,IAAI,IAAI,CAEpC"}
@@ -0,0 +1,140 @@
1
+ // Viewer-local — local SQLite store for long IM-message payloads that get
2
+ // rendered as web pages via /v/:id on the agim web console.
3
+ //
4
+ // Why local: privacy — content stays on the operator's machine, full stop.
5
+ // IM messages carry only a short link to the operator's own publicly-reachable
6
+ // agim web (cloudflared / caddy / tailscale at operator's discretion). The
7
+ // link resolves to a row in this SQLite, rendered by the web console.
8
+ //
9
+ // Permanence: rows have NO TTL. They are kept until the operator hits the
10
+ // per-instance cap (`IMHUB_VIEWER_MAX_PASTES`, default 10_000), at which point
11
+ // the oldest rows are pruned LRU. Rationale: this is the operator's own data,
12
+ // equivalent to their memos / job history — they should keep it unless they
13
+ // explicitly delete.
14
+ import { join } from 'node:path';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { logger as rootLogger } from './logger.js';
17
+ import { createSqliteHelper } from './sqlite-helper.js';
18
+ import { AGIM_HOME } from './agim-paths.js';
19
+ const VIEWER_DB = join(AGIM_HOME, 'viewer.db');
20
+ const SCHEMA = `
21
+ CREATE TABLE IF NOT EXISTS pastes (
22
+ id TEXT PRIMARY KEY,
23
+ content TEXT NOT NULL,
24
+ content_type TEXT NOT NULL DEFAULT 'markdown',
25
+ title TEXT,
26
+ source TEXT,
27
+ job_id INTEGER,
28
+ created_at INTEGER NOT NULL,
29
+ view_count INTEGER NOT NULL DEFAULT 0
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_pastes_created ON pastes(created_at DESC);
32
+ CREATE INDEX IF NOT EXISTS idx_pastes_job ON pastes(job_id);
33
+ `;
34
+ const viewerLog = rootLogger.child({ component: 'viewer-local' });
35
+ const helper = createSqliteHelper({
36
+ file: VIEWER_DB,
37
+ schema: SCHEMA,
38
+ logger: viewerLog,
39
+ component: 'viewer-local',
40
+ });
41
+ const DEFAULT_MAX_PASTES = 10_000;
42
+ function maxPastes() {
43
+ const raw = process.env.IMHUB_VIEWER_MAX_PASTES;
44
+ if (!raw)
45
+ return DEFAULT_MAX_PASTES;
46
+ const n = parseInt(raw, 10);
47
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PASTES;
48
+ }
49
+ /**
50
+ * Save a paste. Returns the new row's id (uuidv4). Returns null if the
51
+ * underlying DB is broken (caller should fall back to truncation).
52
+ */
53
+ export function savePaste(opts) {
54
+ const db = helper.get();
55
+ if (!db)
56
+ return null;
57
+ const id = randomUUID();
58
+ const now = Math.floor(Date.now() / 1000);
59
+ const ct = opts.content_type === 'plain' ? 'plain' : 'markdown';
60
+ try {
61
+ db.prepare(`
62
+ INSERT INTO pastes (id, content, content_type, title, source, job_id, created_at)
63
+ VALUES (?, ?, ?, ?, ?, ?, ?)
64
+ `).run(id, opts.content, ct, opts.title ?? null, opts.source ?? null, opts.job_id ?? null, now);
65
+ }
66
+ catch (err) {
67
+ viewerLog.warn({ event: 'viewer.save.failed', err: String(err) });
68
+ return null;
69
+ }
70
+ // LRU prune (cheap — only fires when over cap)
71
+ try {
72
+ const cap = maxPastes();
73
+ const total = db.prepare('SELECT COUNT(*) AS n FROM pastes').get();
74
+ if (total.n > cap) {
75
+ const over = total.n - cap;
76
+ const r = db.prepare(`
77
+ DELETE FROM pastes WHERE id IN (
78
+ SELECT id FROM pastes ORDER BY created_at ASC LIMIT ?
79
+ )
80
+ `).run(over);
81
+ if (r.changes > 0) {
82
+ viewerLog.info({ event: 'viewer.prune.lru', removed: r.changes, cap });
83
+ }
84
+ }
85
+ }
86
+ catch (err) {
87
+ viewerLog.warn({ event: 'viewer.prune.failed', err: String(err) });
88
+ }
89
+ return id;
90
+ }
91
+ export function getPaste(id) {
92
+ const db = helper.get();
93
+ if (!db)
94
+ return null;
95
+ const row = db.prepare(`
96
+ SELECT id, content, content_type, title, source, job_id, created_at, view_count
97
+ FROM pastes WHERE id = ?
98
+ `).get(id);
99
+ return row ?? null;
100
+ }
101
+ export function bumpViewCount(id) {
102
+ const db = helper.get();
103
+ if (!db)
104
+ return;
105
+ try {
106
+ db.prepare('UPDATE pastes SET view_count = view_count + 1 WHERE id = ?').run(id);
107
+ }
108
+ catch (err) {
109
+ viewerLog.warn({ event: 'viewer.bump.failed', err: String(err) });
110
+ }
111
+ }
112
+ export function listPastes(opts = {}) {
113
+ const db = helper.get();
114
+ if (!db)
115
+ return [];
116
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
117
+ const offset = Math.max(opts.offset ?? 0, 0);
118
+ return db.prepare(`
119
+ SELECT id, content, content_type, title, source, job_id, created_at, view_count
120
+ FROM pastes ORDER BY created_at DESC LIMIT ? OFFSET ?
121
+ `).all(limit, offset);
122
+ }
123
+ export function deletePaste(id) {
124
+ const db = helper.get();
125
+ if (!db)
126
+ return false;
127
+ const r = db.prepare('DELETE FROM pastes WHERE id = ?').run(id);
128
+ return r.changes > 0;
129
+ }
130
+ export function countPastes() {
131
+ const db = helper.get();
132
+ if (!db)
133
+ return 0;
134
+ const row = db.prepare('SELECT COUNT(*) AS n FROM pastes').get();
135
+ return row.n;
136
+ }
137
+ export function closeViewerDb() {
138
+ helper.close();
139
+ }
140
+ //# sourceMappingURL=viewer-local.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer-local.js","sourceRoot":"","sources":["../../src/core/viewer-local.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,4DAA4D;AAC5D,EAAE;AACF,2EAA2E;AAC3E,+EAA+E;AAC/E,2EAA2E;AAC3E,sEAAsE;AACtE,EAAE;AACF,0EAA0E;AAC1E,+EAA+E;AAC/E,8EAA8E;AAC9E,4EAA4E;AAC5E,qBAAqB;AAErB,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,aAAa,CAAA;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAE3C,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,EAAE,WAAW,CAAC,CAAA;AAE9C,MAAM,MAAM,GAAG;;;;;;;;;;;;;CAad,CAAA;AAED,MAAM,SAAS,GAAG,UAAU,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAA;AAEjE,MAAM,MAAM,GAAG,kBAAkB,CAAC;IAChC,IAAI,EAAE,SAAS;IACf,MAAM,EAAE,MAAM;IACd,MAAM,EAAE,SAAS;IACjB,SAAS,EAAE,cAAc;CAC1B,CAAC,CAAA;AAuBF,MAAM,kBAAkB,GAAG,MAAM,CAAA;AAEjC,SAAS,SAAS;IAChB,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,uBAAuB,CAAA;IAC/C,IAAI,CAAC,GAAG;QAAE,OAAO,kBAAkB,CAAA;IACnC,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IAC3B,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAA;AAC7D,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,IAAmB;IAC3C,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAA;IAEpB,MAAM,EAAE,GAAG,UAAU,EAAE,CAAA;IACvB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAA;IACzC,MAAM,EAAE,GAAqB,IAAI,CAAC,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,CAAA;IAEjF,IAAI,CAAC;QACH,EAAE,CAAC,OAAO,CAAC;;;KAGV,CAAC,CAAC,GAAG,CACJ,EAAE,EACF,IAAI,CAAC,OAAO,EACZ,EAAE,EACF,IAAI,CAAC,KAAK,IAAI,IAAI,EAClB,IAAI,CAAC,MAAM,IAAI,IAAI,EACnB,IAAI,CAAC,MAAM,IAAI,IAAI,EACnB,GAAG,CACJ,CAAA;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACjE,OAAO,IAAI,CAAA;IACb,CAAC;IAED,+CAA+C;IAC/C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,SAAS,EAAE,CAAA;QACvB,MAAM,KAAK,GAAG,EAAE,CAAC,OAAO,CAAC,kCAAkC,CAAC,CAAC,GAAG,EAAmB,CAAA;QACnF,IAAI,KAAK,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC;YAClB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,GAAG,GAAG,CAAA;YAC1B,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC;;;;OAIpB,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;YACZ,IAAI,CAAC,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC;gBAClB,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,CAAA;YACxE,CAAC;QACH,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IACpE,CAAC;IAED,OAAO,EAAE,CAAA;AACX,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,EAAU;IACjC,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAA;IACpB,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC;;;GAGtB,CAAC,CAAC,GAAG,CAAC,EAAE,CAAyB,CAAA;IAClC,OAAO,GAAG,IAAI,IAAI,CAAA;AACpB,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,EAAU;IACtC,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAM;IACf,IAAI,CAAC;QACH,EAAE,CAAC,OAAO,CAAC,4DAA4D,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IAClF,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IACnE,CAAC;AACH,CAAC;AAOD,MAAM,UAAU,UAAU,CAAC,OAAiB,EAAE;IAC5C,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,EAAE,CAAA;IAClB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAC1D,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IAC5C,OAAO,EAAE,CAAC,OAAO,CAAC;;;GAGjB,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAe,CAAA;AACrC,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,EAAU;IACpC,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,KAAK,CAAA;IACrB,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,iCAAiC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IAC/D,OAAO,CAAC,CAAC,OAAO,GAAG,CAAC,CAAA;AACtB,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,CAAA;IACjB,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,kCAAkC,CAAC,CAAC,GAAG,EAAmB,CAAA;IACjF,OAAO,GAAG,CAAC,CAAC,CAAA;AACd,CAAC;AAED,MAAM,UAAU,aAAa;IAC3B,MAAM,CAAC,KAAK,EAAE,CAAA;AAChB,CAAC"}
@@ -14,20 +14,34 @@
14
14
  document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
15
15
  const T = {
16
16
  en: {
17
- title: 'Agim — Memos', h1: 'Memos',
18
- backToChat: 'Chat', toTasks: 'Tasks', toReminders: 'Reminders', toSettings: 'Settings',
17
+ title: 'Agim — Memos', h1: '📋 Memos',
18
+ backToChat: 'Chat', toTasks: 'Tasks', toReminders: 'Reminders', toSettings: 'Settings',
19
19
  filterAll: 'All', filterGeo: 'With location', filterExpired: 'Expired',
20
20
  searchPlaceholder: 'search (what / memo / who / address …)',
21
- empty: 'No memos matching this filter.',
21
+ loading: 'Loading…',
22
+ loadFailed: 'Load failed: {err}',
23
+ emptyNoMatch: 'No memos match your search.',
24
+ emptyNone: 'No memos yet. Just tell an agent in IM "记下…" / "remember that…" and they will save it.',
22
25
  delete: 'Delete', edit: 'Edit',
26
+ confirmDelete: 'Really delete memo #{id}?',
27
+ deleted: 'Deleted #{id}',
28
+ deleteFailed: 'Delete failed: {err}',
29
+ mapBaidu: 'Baidu Map', mapAmap: 'Amap', mapGoogle: 'Google Maps',
23
30
  },
24
31
  zh: {
25
- title: 'Agim — 备忘', h1: '备忘',
26
- backToChat: '对话', toTasks: '任务', toReminders: '提醒', toSettings: '设置',
32
+ title: 'Agim — 备忘', h1: '📋 备忘',
33
+ backToChat: '对话', toTasks: '任务', toReminders: '提醒', toSettings: '设置',
27
34
  filterAll: '全部', filterGeo: '有位置', filterExpired: '已过期',
28
35
  searchPlaceholder: '搜索内容(what / memo / who / 地址 …)',
29
- empty: '当前过滤下暂无备忘。',
36
+ loading: '加载中…',
37
+ loadFailed: '加载失败:{err}',
38
+ emptyNoMatch: '没匹配的 memo',
39
+ emptyNone: '还没记下任何 memo。在 IM 里说「记下…」让 agent 帮你存。',
30
40
  delete: '删除', edit: '编辑',
41
+ confirmDelete: '确定删除 memo #{id}?',
42
+ deleted: '已删除 #{id}',
43
+ deleteFailed: '删除失败:{err}',
44
+ mapBaidu: '百度地图', mapAmap: '高德地图', mapGoogle: 'Google',
31
45
  },
32
46
  };
33
47
  window.__t = T[window.__lang];
@@ -49,16 +63,29 @@
49
63
  font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
50
64
  background: var(--bg); color: var(--fg); margin: 0; padding: 0;
51
65
  }
66
+ /* Header — kept in sync with tasks.html / reminders.html so all
67
+ dashboard pages share the same layout. */
52
68
  header {
53
- display: flex; align-items: center; justify-content: space-between;
54
- padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--card);
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 16px;
72
+ padding: 14px 24px;
73
+ border-bottom: 1px solid var(--border);
74
+ background: var(--card);
55
75
  }
56
- header h1 { margin: 0; font-size: 18px; font-weight: 600; }
57
- header nav a {
58
- color: var(--muted); text-decoration: none; padding: 4px 10px;
59
- border-radius: 4px; font-size: 13px; margin-left: 4px;
76
+ header h1 { margin: 0; font-size: 18px; font-weight: 600; flex: 1; }
77
+ header a, header button, header select {
78
+ color: var(--accent);
79
+ text-decoration: none;
80
+ font-size: 14px;
81
+ background: none;
82
+ border: 1px solid var(--border);
83
+ padding: 6px 12px;
84
+ border-radius: 4px;
85
+ cursor: pointer;
60
86
  }
61
- header nav a:hover { background: var(--bg); color: var(--fg); }
87
+ header select { color: var(--fg); }
88
+ header a:hover, header button:hover { border-color: var(--accent); }
62
89
  main { padding: 20px; max-width: 980px; margin: 0 auto; }
63
90
  .toolbar {
64
91
  display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap;
@@ -132,18 +159,16 @@
132
159
  </head>
133
160
  <body>
134
161
  <header>
135
- <h1>📋 <span id="page-title"></span></h1>
162
+ <h1 id="page-title"></h1>
136
163
  <button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
137
- <select id="langSelect" title="Language / 语言" style="margin-right:8px">
164
+ <select id="langSelect" title="Language / 语言">
138
165
  <option value="en">EN</option>
139
166
  <option value="zh">中文</option>
140
167
  </select>
141
- <nav>
142
- <a href="/" id="lnk-chat"></a>
143
- <a href="/tasks" id="lnk-tasks"></a>
144
- <a href="/reminders" id="lnk-reminders"></a>
145
- <a href="/settings" id="lnk-settings"></a>
146
- </nav>
168
+ <a href="/" id="lnk-chat"></a>
169
+ <a href="/tasks" id="lnk-tasks"></a>
170
+ <a href="/reminders" id="lnk-reminders"></a>
171
+ <a href="/settings" id="lnk-settings"></a>
147
172
  </header>
148
173
  <main>
149
174
  <div class="toolbar">
@@ -222,7 +247,7 @@
222
247
  }
223
248
 
224
249
  async function load() {
225
- $list.innerHTML = '<div class="empty">加载中…</div>'
250
+ $list.innerHTML = `<div class="empty">${T.loading}</div>`
226
251
  try {
227
252
  const params = new URLSearchParams()
228
253
  if (queryStr) params.set('query', queryStr)
@@ -237,16 +262,16 @@
237
262
  items = items.filter(m => m.expiresAt && new Date(m.expiresAt.replace(' ', 'T') + '+08:00') < now)
238
263
  }
239
264
  render(items)
240
- $stats.textContent = items.length ? `${items.length} 条` : ''
265
+ $stats.textContent = items.length ? (window.__lang === 'zh' ? `${items.length} 条` : `${items.length} memos`) : ''
241
266
  } catch (err) {
242
267
  $list.innerHTML = ''
243
- toast(`加载失败:${err.message}`, true)
268
+ toast(T.loadFailed.replace('{err}', err.message), true)
244
269
  }
245
270
  }
246
271
 
247
272
  function render(items) {
248
273
  if (!items.length) {
249
- $list.innerHTML = `<div class="empty">${queryStr ? '没匹配的 memo' : '还没记下任何 memo。在 IM 里说「记下…」让 agent 帮你存。'}</div>`
274
+ $list.innerHTML = `<div class="empty">${queryStr ? T.emptyNoMatch : T.emptyNone}</div>`
250
275
  return
251
276
  }
252
277
  $list.innerHTML = items.map(m => {
@@ -269,16 +294,16 @@
269
294
  let maps = ''
270
295
  if (m.mapUrls) {
271
296
  maps = `<div class="maps">
272
- <a href="${m.mapUrls.baidu}" target="_blank">百度地图</a>
273
- <a href="${m.mapUrls.amap}" target="_blank">高德地图</a>
274
- <a href="${m.mapUrls.google}" target="_blank">Google</a>
297
+ <a href="${m.mapUrls.baidu}" target="_blank">${T.mapBaidu}</a>
298
+ <a href="${m.mapUrls.amap}" target="_blank">${T.mapAmap}</a>
299
+ <a href="${m.mapUrls.google}" target="_blank">${T.mapGoogle}</a>
275
300
  </div>`
276
301
  }
277
302
 
278
303
  return `<div class="memo" data-id="${m.id}">
279
304
  <div class="body">${what}${original}${meta}${maps}</div>
280
305
  <div class="actions">
281
- <button type="button" class="btn danger" data-action="delete">删除</button>
306
+ <button type="button" class="btn danger" data-action="delete">${T.delete}</button>
282
307
  </div>
283
308
  </div>`
284
309
  }).join('')
@@ -292,12 +317,12 @@
292
317
  const id = card.dataset.id
293
318
  const action = btn.dataset.action
294
319
  if (action === 'delete') {
295
- if (!confirm(`确定删除 memo #${id}?`)) return
320
+ if (!confirm(T.confirmDelete.replace('{id}', id))) return
296
321
  try {
297
322
  await window.imhub.api(`/api/memos/${id}`, { method: 'DELETE' })
298
- toast(`已删除 #${id}`)
323
+ toast(T.deleted.replace('{id}', id))
299
324
  load()
300
- } catch (err) { toast(`删除失败:${err.message}`, true) }
325
+ } catch (err) { toast(T.deleteFailed.replace('{err}', err.message), true) }
301
326
  }
302
327
  })
303
328
 
@@ -15,22 +15,32 @@
15
15
  document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
16
16
  const T = {
17
17
  en: {
18
- title: 'Agim — Reminders', h1: 'Reminders',
19
- backToChat: 'Chat', toTasks: 'Tasks', toMemos: 'Memos', toSettings: 'Settings',
18
+ title: 'Agim — Reminders', h1: '🔔 Reminders',
19
+ backToChat: 'Chat', toTasks: 'Tasks', toMemos: 'Memos', toSettings: 'Settings',
20
20
  statusPending: 'Pending', statusFired: 'Fired', statusCancelled: 'Cancelled', statusFailed: 'Failed',
21
- empty: 'No reminders matching this filter.',
22
- searchPlaceholder: 'search…',
23
- cancel: 'Cancel', snooze: 'Snooze', delete: 'Delete',
21
+ loading: 'Loading…',
22
+ loadFailed: 'Load failed: {err}',
23
+ emptyStatus: 'No {status} reminders.',
24
+ cancel: 'Cancel', snooze: 'Snooze +5min', delete: 'Delete',
24
25
  fireAt: 'Fires at', recur: 'Repeat',
26
+ literalText: 'literal',
27
+ confirmCancel: 'Cancel reminder #{id}? (Recurring reminders will stop the whole loop.)',
28
+ cancelled: '✅ Cancelled #{id}',
29
+ snoozed: '⏰ Snoozed #{id} +5min',
25
30
  },
26
31
  zh: {
27
- title: 'Agim — 提醒', h1: '提醒',
28
- backToChat: '对话', toTasks: '任务', toMemos: '备忘', toSettings: '设置',
32
+ title: 'Agim — 提醒', h1: '🔔 提醒',
33
+ backToChat: '对话', toTasks: '任务', toMemos: '备忘', toSettings: '设置',
29
34
  statusPending: '待发', statusFired: '已发', statusCancelled: '已取消', statusFailed: '失败',
30
- empty: '当前过滤下暂无提醒。',
31
- searchPlaceholder: '搜索…',
32
- cancel: '取消', snooze: '推迟', delete: '删除',
35
+ loading: '加载中…',
36
+ loadFailed: '加载失败:{err}',
37
+ emptyStatus: '没有{status}的提醒',
38
+ cancel: '取消', snooze: '延 5 分钟', delete: '删除',
33
39
  fireAt: '触发时间', recur: '重复',
40
+ literalText: '字面文本',
41
+ confirmCancel: '取消提醒 #{id}?(循环提醒会终止整条循环)',
42
+ cancelled: '✅ 已取消 #{id}',
43
+ snoozed: '⏰ 已延后 #{id} 5 分钟',
34
44
  },
35
45
  };
36
46
  window.__t = T[window.__lang];
@@ -53,17 +63,30 @@
53
63
  background: var(--bg); color: var(--fg);
54
64
  margin: 0; padding: 0;
55
65
  }
66
+ /* Header — kept in sync with tasks.html so all dashboard pages share
67
+ the same layout. Flex with h1 flex:1 to push toggles + nav to the
68
+ right; uniform border-button look across <a> and <button>. */
56
69
  header {
57
- display: flex; align-items: center; justify-content: space-between;
58
- padding: 14px 20px; border-bottom: 1px solid var(--border);
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 16px;
73
+ padding: 14px 24px;
74
+ border-bottom: 1px solid var(--border);
59
75
  background: var(--card);
60
76
  }
61
- header h1 { margin: 0; font-size: 18px; font-weight: 600; }
62
- header nav a {
63
- margin-left: 12px; color: var(--muted); text-decoration: none;
64
- font-size: 13px;
77
+ header h1 { margin: 0; font-size: 18px; font-weight: 600; flex: 1; }
78
+ header a, header button, header select {
79
+ color: var(--accent);
80
+ text-decoration: none;
81
+ font-size: 14px;
82
+ background: none;
83
+ border: 1px solid var(--border);
84
+ padding: 6px 12px;
85
+ border-radius: 4px;
86
+ cursor: pointer;
65
87
  }
66
- header nav a:hover { color: var(--accent); }
88
+ header select { color: var(--fg); }
89
+ header a:hover, header button:hover { border-color: var(--accent); }
67
90
  main { max-width: 880px; margin: 24px auto; padding: 0 16px; }
68
91
  .filters {
69
92
  display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;
@@ -118,18 +141,16 @@
118
141
  </head>
119
142
  <body>
120
143
  <header>
121
- <h1>🔔 <span id="page-title"></span></h1>
144
+ <h1 id="page-title"></h1>
122
145
  <button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
123
- <select id="langSelect" title="Language / 语言" style="margin-right:8px">
146
+ <select id="langSelect" title="Language / 语言">
124
147
  <option value="en">EN</option>
125
148
  <option value="zh">中文</option>
126
149
  </select>
127
- <nav>
128
- <a href="/" id="lnk-chat"></a>
129
- <a href="/tasks" id="lnk-tasks"></a>
130
- <a href="/memos" id="lnk-memos"></a>
131
- <a href="/settings" id="lnk-settings"></a>
132
- </nav>
150
+ <a href="/" id="lnk-chat"></a>
151
+ <a href="/tasks" id="lnk-tasks"></a>
152
+ <a href="/memos" id="lnk-memos"></a>
153
+ <a href="/settings" id="lnk-settings"></a>
133
154
  </header>
134
155
  <main>
135
156
  <div class="filters" id="filters">
@@ -208,19 +229,19 @@
208
229
  }
209
230
 
210
231
  async function load() {
211
- $list.innerHTML = '<div class="empty">加载中…</div>'
232
+ $list.innerHTML = `<div class="empty">${T.loading}</div>`
212
233
  try {
213
234
  const data = await window.imhub.api(`/api/reminders?status=${encodeURIComponent(currentStatus)}`)
214
235
  render(data.reminders || [])
215
236
  } catch (err) {
216
237
  $list.innerHTML = ''
217
- toast(`加载失败:${err.message}`, true)
238
+ toast(T.loadFailed.replace('{err}', err.message), true)
218
239
  }
219
240
  }
220
241
 
221
242
  function render(items) {
222
243
  if (!items.length) {
223
- $list.innerHTML = `<div class="empty">没有${statusLabel(currentStatus)}的提醒</div>`
244
+ $list.innerHTML = `<div class="empty">${T.emptyStatus.replace('{status}', statusLabel(currentStatus))}</div>`
224
245
  return
225
246
  }
226
247
  $list.innerHTML = items.map((r) => {
@@ -228,14 +249,14 @@
228
249
  const recurMeta = r.recurrence_label
229
250
  ? `<span class="recur">↻ ${escapeHtml(r.recurrence_label)}</span>` : ''
230
251
  const literalMeta = r.prompt_mode === 'literal'
231
- ? '<span class="literal">字面文本</span>' : ''
252
+ ? `<span class="literal">${T.literalText}</span>` : ''
232
253
  const platformMeta = r.platform
233
254
  ? `<span>${escapeHtml(r.platform)}</span>` : ''
234
255
  const showActions = currentStatus === 'pending'
235
256
  const actions = showActions
236
257
  ? `<div class="actions">
237
- <button class="btn" data-act="snooze" data-id="${r.id}">延 5 分钟</button>
238
- <button class="btn danger" data-act="cancel" data-id="${r.id}">取消</button>
258
+ <button class="btn" data-act="snooze" data-id="${r.id}">${T.snooze}</button>
259
+ <button class="btn danger" data-act="cancel" data-id="${r.id}">${T.cancel}</button>
239
260
  </div>`
240
261
  : ''
241
262
  return `<div class="reminder">
@@ -262,7 +283,10 @@
262
283
  }
263
284
 
264
285
  function statusLabel(s) {
265
- return ({ pending: '待发', fired: '已发', cancelled: '已取消', failed: '失败' })[s] || s
286
+ return ({
287
+ pending: T.statusPending, fired: T.statusFired,
288
+ cancelled: T.statusCancelled, failed: T.statusFailed,
289
+ })[s] || s
266
290
  }
267
291
 
268
292
  $filters.addEventListener('click', (e) => {
@@ -281,19 +305,19 @@
281
305
  btn.disabled = true
282
306
  try {
283
307
  if (act === 'cancel') {
284
- if (!confirm(`取消提醒 #${id}?(循环提醒会终止整条循环)`)) return
308
+ if (!confirm(T.confirmCancel.replace('{id}', id))) return
285
309
  await window.imhub.api(`/api/reminders/${id}/cancel`, { method: 'POST' })
286
- toast(`✅ 已取消 #${id}`)
310
+ toast(T.cancelled.replace('{id}', id))
287
311
  } else if (act === 'snooze') {
288
312
  await window.imhub.api(`/api/reminders/${id}/snooze`, {
289
313
  method: 'POST',
290
314
  body: JSON.stringify({ duration: '5m' }),
291
315
  })
292
- toast(`✅ #${id} 已延后 5 分钟`)
316
+ toast(T.snoozed.replace('{id}', id))
293
317
  }
294
318
  await load()
295
319
  } catch (err) {
296
- toast(`操作失败:${err.message}`, true)
320
+ toast(T.loadFailed.replace('{err}', err.message), true)
297
321
  } finally {
298
322
  btn.disabled = false
299
323
  }