tinywebui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Admin panel: the users the gateway (or login) has provisioned, with just
3
+ * enough control to approve, disable or promote them. Shown only to admins;
4
+ * the server enforces the same rule on every /api/admin call regardless.
5
+ */
6
+ import { $, el } from '../core/dom.js';
7
+ import { renderMarkdown } from '../core/md.js';
8
+ import { whoami } from '../core/access.js';
9
+ import { api } from '../core/api.js';
10
+
11
+ let me = null;
12
+ let adminLoadRequest = 0;
13
+ // Bumped by every admin view change; a response for an older view is dropped.
14
+ let adminViewRequest = 0;
15
+
16
+ /** Wires Log out for whoever is signed in (nav visibility is core/access.js's). */
17
+ export function initAdmin() {
18
+ me = whoami();
19
+ if (me.logoutUrl || me.authMode === 'single') {
20
+ const btn = $('logout');
21
+ btn.hidden = false;
22
+ btn.onclick = async () => {
23
+ try { await api.post('/api/auth/logout'); } catch { /* going anyway */ }
24
+ // Behind a gateway, the gateway owns the session; ending it is its job.
25
+ if (me.logoutUrl) location.href = me.logoutUrl; else location.reload();
26
+ };
27
+ }
28
+ }
29
+
30
+ const fmt = (ts) => (ts ? new Date(ts).toLocaleString() : 'never');
31
+
32
+ function select(value, options, label, onChange) {
33
+ const sel = el('select');
34
+ sel.setAttribute('aria-label', label);
35
+ for (const v of options) sel.appendChild(Object.assign(el('option'), { value: v, textContent: v }));
36
+ sel.value = value;
37
+ sel.onchange = () => onChange(sel);
38
+ return sel;
39
+ }
40
+
41
+ function row(user) {
42
+ const r = el('div', 'admin-row');
43
+ const who = el('div', 'admin-who');
44
+ who.appendChild(Object.assign(el('strong'), { textContent: user.name || user.email || user.id }));
45
+ who.appendChild(Object.assign(el('span', 'usage'), {
46
+ textContent: [user.email, `${user.chatCount ?? 0} chats`, `last seen ${fmt(user.lastLoginAt)}`].filter(Boolean).join(' · ')
47
+ }));
48
+ const err = el('div', 'admin-err');
49
+ const self = me?.user?.id === user.id;
50
+
51
+ const patch = async (sel, body, previous) => {
52
+ sel.disabled = true;
53
+ err.textContent = '';
54
+ try {
55
+ const out = await api.patch(`/api/admin/users/${encodeURIComponent(user.id)}`, body);
56
+ Object.assign(user, out.user);
57
+ } catch (e) {
58
+ sel.value = previous;
59
+ err.textContent = e.message;
60
+ } finally {
61
+ sel.disabled = self;
62
+ }
63
+ };
64
+
65
+ const status = select(user.status, ['approved', 'pending', 'disabled'], 'Status', (sel) => patch(sel, { status: sel.value }, user.status));
66
+ const role = select(user.role, ['user', 'admin'], 'Role', (sel) => patch(sel, { role: sel.value }, user.role));
67
+ // Your own account can't be demoted or disabled from here -- the server
68
+ // refuses too, but a locked control says so before you try.
69
+ if (self) { status.disabled = true; role.disabled = true; status.title = role.title = 'your own account'; }
70
+ if (user.pinnedInCode) {
71
+ status.disabled = true; role.disabled = true;
72
+ status.title = role.title = `declared in code (${user.pinnedInCode})`;
73
+ }
74
+ const view = Object.assign(el('button'), { type: 'button', textContent: 'chats' });
75
+ view.onclick = () => openUser(user);
76
+ r.append(who, status, role, view, err);
77
+ return r;
78
+ }
79
+
80
+ /* ---------- oversight: a user's chats, then one chat, read-only ---------- */
81
+
82
+ let pollTimer = null;
83
+ const stopPoll = () => { if (pollTimer) clearTimeout(pollTimer); pollTimer = null; };
84
+
85
+ function showView(title, meta) {
86
+ adminViewRequest++;
87
+ stopPoll();
88
+ $('adminUsers').parentElement.hidden = true;
89
+ $('adminView').hidden = false;
90
+ $('adminViewTitle').textContent = title;
91
+ $('adminViewMeta').textContent = meta || '';
92
+ const body = $('adminViewBody');
93
+ body.innerHTML = '';
94
+ return body;
95
+ }
96
+
97
+ async function openUser(user) {
98
+ const body = showView(user.name || user.email || user.id, 'loading…');
99
+ $('adminBack').onclick = () => loadAdmin();
100
+ const request = adminViewRequest;
101
+ let out;
102
+ try { out = await api.get(`/api/admin/users/${encodeURIComponent(user.id)}/chats`); }
103
+ catch (e) { if (request === adminViewRequest) $('adminViewMeta').textContent = e.message; return; }
104
+ if (request !== adminViewRequest) return;
105
+ const s = out.summary || {};
106
+ const cost = s.pricedRounds ? ` · $${Number(s.reportedCost || 0).toFixed(2)} reported` : '';
107
+ $('adminViewMeta').textContent = `${out.chats.length} chats · ${s.rounds || 0} model rounds${cost}`;
108
+ for (const c of out.chats) {
109
+ const b = el('button', 'admin-chat');
110
+ b.type = 'button';
111
+ b.append(Object.assign(el('span'), { textContent: c.title || c.id }));
112
+ if (c.running) b.append(Object.assign(el('span', 'admin-live'), { textContent: '● live' }));
113
+ b.append(Object.assign(el('span', 'usage'), { textContent: new Date(c.updated_at).toLocaleString() }));
114
+ b.onclick = () => openChat(c.id, user);
115
+ body.appendChild(b);
116
+ }
117
+ if (!out.chats.length) body.appendChild(Object.assign(el('div', 'empty'), { textContent: 'no chats' }));
118
+ }
119
+
120
+ function messageNode(m) {
121
+ const box = el('div', 'admin-msg');
122
+ const label = m.role === 'tool' ? 'tool result' : m.role;
123
+ box.appendChild(Object.assign(el('div', 'admin-msg-role'), { textContent: label + (m.compacted ? ' (compacted)' : '') }));
124
+ if (m.role === 'tool') {
125
+ const d = el('details');
126
+ d.appendChild(Object.assign(el('summary'), { textContent: `${String(m.content || '').length.toLocaleString()} chars` }));
127
+ d.appendChild(Object.assign(el('pre'), { textContent: m.content || '' }));
128
+ box.appendChild(d);
129
+ return box;
130
+ }
131
+ if (m.content) {
132
+ const b = el('div', 'admin-msg-body');
133
+ b.innerHTML = renderMarkdown(m.content); // renderMarkdown escapes its input
134
+ box.appendChild(b);
135
+ }
136
+ if (m.images?.length) box.appendChild(Object.assign(el('div', 'usage'), { textContent: `${m.images.length} image(s) attached` }));
137
+ for (const call of m.tool_calls || []) {
138
+ const d = el('details');
139
+ d.appendChild(Object.assign(el('summary'), { textContent: `calls ${call.function?.name}` }));
140
+ d.appendChild(Object.assign(el('pre'), { textContent: call.function?.arguments || '' }));
141
+ box.appendChild(d);
142
+ }
143
+ return box;
144
+ }
145
+
146
+ async function openChat(chatId, user) {
147
+ const body = showView('loading…');
148
+ $('adminBack').onclick = () => openUser(user);
149
+ const request = adminViewRequest;
150
+ let out;
151
+ try { out = await api.get(`/api/admin/chats/${encodeURIComponent(chatId)}`); }
152
+ catch (e) { if (request === adminViewRequest) $('adminViewTitle').textContent = e.message; return; }
153
+ if (request !== adminViewRequest) return;
154
+ $('adminViewTitle').textContent = out.title || out.id;
155
+ $('adminViewMeta').textContent = `${out.owner?.email || out.owner?.name || 'no owner'} · ${out.messages.length} messages`
156
+ + (out.running ? ' · live, refreshing' : '');
157
+ for (const doc of out.documents || []) {
158
+ const a = Object.assign(el('a', 'usage'), { textContent: `📎 ${doc.filename}`, href: '#' });
159
+ a.onclick = async (e) => {
160
+ e.preventDefault();
161
+ try {
162
+ const d = await api.get(`/api/admin/documents/${encodeURIComponent(doc.id)}`);
163
+ const w = window.open('', '_blank');
164
+ if (w) { w.document.title = d.filename; w.document.body.appendChild(Object.assign(w.document.createElement('pre'), { textContent: d.content })); }
165
+ } catch (err) { $('adminViewMeta').textContent = err.message; }
166
+ };
167
+ body.append(a, el('br'));
168
+ }
169
+ for (const m of out.messages) if (m.role !== 'system') body.appendChild(messageNode(m));
170
+ // Monitoring a turn in flight: re-read while it runs, and only while this
171
+ // chat is still the one on screen.
172
+ if (out.running) pollTimer = setTimeout(() => { if (request === adminViewRequest && $('admin').classList.contains('open') && !$('adminView').hidden) openChat(chatId, user); }, 4000);
173
+ }
174
+
175
+ export async function loadAdmin() {
176
+ const request = ++adminLoadRequest;
177
+ adminViewRequest++;
178
+ stopPoll();
179
+ $('adminView').hidden = true;
180
+ $('adminUsers').parentElement.hidden = false;
181
+ const box = $('adminUsers');
182
+ $('adminMsg').textContent = '';
183
+ let out;
184
+ try {
185
+ out = await api.get('/api/admin/users');
186
+ } catch (err) {
187
+ if (request === adminLoadRequest) {
188
+ box.innerHTML = '';
189
+ $('adminMsg').textContent = `Couldn't load users: ${err.message}`;
190
+ }
191
+ return;
192
+ }
193
+ if (request !== adminLoadRequest) return;
194
+ box.innerHTML = '';
195
+ $('adminCount').textContent = `${out.users.length} · config ${out.fingerprint}`;
196
+ $('adminCount').title = 'Config fingerprint: compare with `tinywebui fingerprint` on the checked-in files';
197
+ for (const u of out.users) box.appendChild(row(u));
198
+ if (!out.users.length) box.appendChild(Object.assign(el('div', 'empty'), { textContent: 'no users yet' }));
199
+ }
@@ -0,0 +1,464 @@
1
+ import { $, el } from '../core/dom.js';
2
+ import { openChat } from '../chat/chat.js';
3
+ import { renderMarkdown } from '../core/md.js';
4
+ import { loadChats } from '../chat/sidebar.js';
5
+ import { api } from '../core/api.js';
6
+ import { PANEL, closePanel } from './panels.js';
7
+
8
+ const panel = $('automations');
9
+ const list = $('automation-list');
10
+ const createSlot = $('automation-create-slot');
11
+ let snapshot = { automations: [], chats: [] };
12
+ let automationLoadRequest = 0;
13
+
14
+ const close = () => closePanel(PANEL.AUTOMATIONS);
15
+
16
+ /* ---------- schedule helpers ---------- */
17
+
18
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
19
+ const WEEK = [1, 2, 3, 4, 5, 6, 0]; // Monday-first, the way the chips read
20
+ const pad = (n) => String(n).padStart(2, '0');
21
+ const isInt = (v) => /^\d+$/.test(v);
22
+
23
+ function parseDays(dow) {
24
+ if (dow === '1-5') return [1, 2, 3, 4, 5];
25
+ if (/^[0-6](,[0-6])*$/.test(dow)) return [...new Set(dow.split(',').map(Number))];
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * Reads a cron back into the preset it came from, so the editor can show
31
+ * "Weekdays at 09:00" instead of five opaque fields. Anything else is custom.
32
+ */
33
+ function parseCron(cron) {
34
+ const f = String(cron || '').trim().split(/\s+/);
35
+ if (f.length !== 5) return { preset: 'custom' };
36
+ const [m, h, dom, mon, dow] = f;
37
+ if (!isInt(m)) return { preset: 'custom' };
38
+ if (h === '*' && dom === '*' && mon === '*' && dow === '*') return { preset: 'hourly', minute: +m };
39
+ if (!isInt(h) || mon !== '*') return { preset: 'custom' };
40
+ const time = `${pad(h)}:${pad(m)}`;
41
+ if (dom === '*' && dow === '*') return { preset: 'daily', time };
42
+ const days = dom === '*' ? parseDays(dow) : null;
43
+ if (days) return { preset: 'days', time, days };
44
+ if (isInt(dom) && dow === '*') return { preset: 'monthly', time, dom: +dom };
45
+ return { preset: 'custom' };
46
+ }
47
+
48
+ function buildCron({ preset, time = '09:00', days = [1, 2, 3, 4, 5], dom = 1, minute = 0 }) {
49
+ const [h, m] = time.split(':').map(Number);
50
+ switch (preset) {
51
+ case 'hourly': return `${minute} * * * *`;
52
+ case 'daily': return `${m} ${h} * * *`;
53
+ case 'days': {
54
+ const sorted = [...days].sort((a, b) => a - b);
55
+ if (sorted.length === 7) return `${m} ${h} * * *`;
56
+ const dow = sorted.join(',') === '1,2,3,4,5' ? '1-5' : sorted.join(',');
57
+ return `${m} ${h} * * ${dow}`;
58
+ }
59
+ case 'monthly': return `${m} ${h} ${dom} * *`;
60
+ default: return null;
61
+ }
62
+ }
63
+
64
+ function describeDays(days) {
65
+ const key = [...days].sort((a, b) => a - b).join(',');
66
+ if (key === '1,2,3,4,5') return 'Weekdays';
67
+ if (key === '0,6') return 'Weekends';
68
+ if (days.length === 1) return `Every ${DAYS[days[0]]}`;
69
+ return WEEK.filter((d) => days.includes(d)).map((d) => DAYS[d].slice(0, 3)).join(', ');
70
+ }
71
+
72
+ function describeCron(cron) {
73
+ const p = parseCron(cron);
74
+ switch (p.preset) {
75
+ case 'hourly': return `Every hour at :${pad(p.minute)}`;
76
+ case 'daily': return `Every day at ${p.time}`;
77
+ case 'days': return `${describeDays(p.days)} at ${p.time}`;
78
+ case 'monthly': return `Monthly on day ${p.dom} at ${p.time}`;
79
+ default: return `Cron ${cron}`;
80
+ }
81
+ }
82
+
83
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
84
+ function relative(iso) {
85
+ if (!iso) return null;
86
+ const diff = (new Date(iso).getTime() - Date.now()) / 1000;
87
+ const abs = Math.abs(diff);
88
+ const [value, unit] = abs < 60 ? [diff, 'second'] : abs < 3600 ? [diff / 60, 'minute']
89
+ : abs < 86400 ? [diff / 3600, 'hour'] : [diff / 86400, 'day'];
90
+ return rtf.format(Math.round(value), unit);
91
+ }
92
+ const absolute = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
93
+
94
+ let zones;
95
+ function timezoneList() {
96
+ if (!zones) {
97
+ zones = el('datalist'); zones.id = 'automation-zones';
98
+ const names = Intl.supportedValuesOf ? Intl.supportedValuesOf('timeZone') : [];
99
+ for (const z of names) { const o = el('option'); o.value = z; zones.appendChild(o); }
100
+ document.body.appendChild(zones);
101
+ }
102
+ return zones.id;
103
+ }
104
+ const localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
105
+
106
+ /* ---------- create / edit form (inline) ---------- */
107
+
108
+ function field(label, control) {
109
+ const wrap = el('label', 'automation-field');
110
+ const text = el('span', 'automation-label'); text.textContent = label;
111
+ wrap.append(text, control);
112
+ return wrap;
113
+ }
114
+
115
+ // Which form is open: 'new', an automation id, or null. The 30s refresh is
116
+ // skipped while one is, so an edit in progress never gets wiped.
117
+ let openForm = null;
118
+
119
+ /** Grows a textarea to fit its content, so long instructions are all visible. */
120
+ function autosize(t) {
121
+ t.style.height = 'auto';
122
+ t.style.height = `${Math.max(t.scrollHeight + 2, 320)}px`;
123
+ }
124
+
125
+ /**
126
+ * Schedule: a plain dropdown, the parts it needs, raw cron as the escape hatch.
127
+ * Returns the row plus the cron and timezone inputs the form submits, and
128
+ * `sync`, which fills cron from the dropdown and shows only the fields it uses.
129
+ */
130
+ function schedulePicker(initial) {
131
+ const parsed = parseCron(initial.cron || '0 9 * * 1-5');
132
+ let presetValue = parsed.preset;
133
+ if (presetValue === 'days') {
134
+ const key = [...parsed.days].sort().join(',');
135
+ presetValue = key === '1,2,3,4,5' ? 'weekdays' : parsed.days.length === 1 ? 'weekly' : 'custom';
136
+ }
137
+ const preset = el('select');
138
+ for (const [v, t] of [['hourly', 'Every hour'], ['daily', 'Every day'], ['weekdays', 'Weekdays'],
139
+ ['weekly', 'Once a week'], ['monthly', 'Once a month'], ['custom', 'Custom (cron)']]) {
140
+ const o = el('option'); o.value = v; o.textContent = t; preset.appendChild(o);
141
+ }
142
+ preset.value = presetValue;
143
+ const time = el('input'); time.type = 'time'; time.value = parsed.time || '09:00';
144
+ const minute = el('input'); minute.type = 'number'; minute.min = 0; minute.max = 59; minute.value = parsed.minute ?? 0;
145
+ const day = el('select');
146
+ for (const d of WEEK) { const o = el('option'); o.value = d; o.textContent = DAYS[d]; day.appendChild(o); }
147
+ day.value = parsed.days?.length === 1 ? parsed.days[0] : 1;
148
+ const dom = el('input'); dom.type = 'number'; dom.min = 1; dom.max = 28; dom.value = parsed.dom ?? 1;
149
+ const cron = el('input'); cron.required = true; cron.spellcheck = false; cron.className = 'mono';
150
+ cron.placeholder = '0 9 * * 1-5'; cron.value = initial.cron || '0 9 * * 1-5';
151
+ const zone = el('input'); zone.required = true; zone.spellcheck = false;
152
+ zone.setAttribute('list', timezoneList()); zone.value = initial.timezone || localZone();
153
+
154
+ const timeField = field('At', time);
155
+ const minuteField = field('Minute', minute);
156
+ const dayField = field('On', day);
157
+ const domField = field('Day', dom);
158
+ const cronField = field('Cron', cron);
159
+
160
+ function sync() {
161
+ const p = preset.value;
162
+ if (p !== 'custom') {
163
+ const t = time.value || '09:00';
164
+ cron.value = p === 'weekdays' ? buildCron({ preset: 'days', time: t, days: [1, 2, 3, 4, 5] })
165
+ : p === 'weekly' ? buildCron({ preset: 'days', time: t, days: [Number(day.value)] })
166
+ : buildCron({ preset: p, time: t, dom: dom.value || 1, minute: minute.value || 0 });
167
+ }
168
+ timeField.hidden = !['daily', 'weekdays', 'weekly', 'monthly'].includes(p);
169
+ minuteField.hidden = p !== 'hourly';
170
+ dayField.hidden = p !== 'weekly';
171
+ domField.hidden = p !== 'monthly';
172
+ cronField.hidden = p !== 'custom';
173
+ }
174
+ for (const c of [preset, time, minute, day, dom]) c.addEventListener('input', sync);
175
+
176
+ const when = el('div', 'automation-row');
177
+ when.append(field('Repeats', preset), timeField, minuteField, dayField, domField, cronField, field('Timezone', zone));
178
+ return { when, cron, zone, sync };
179
+ }
180
+
181
+ /** Builds the create/edit form: `existing` is the automation, or null to create. */
182
+ function automationForm(existing) {
183
+ const initial = existing || {};
184
+ const form = el('form', 'automation-editor');
185
+
186
+ const name = el('input'); name.required = true; name.maxLength = 120;
187
+ name.placeholder = 'e.g. Morning news digest'; name.value = initial.name || '';
188
+ const chat = el('select'); chat.required = true;
189
+ for (const c of snapshot.chats) {
190
+ const o = el('option'); o.value = c.id; o.textContent = c.title || c.id; chat.appendChild(o);
191
+ }
192
+ if (initial.chatId) chat.value = initial.chatId;
193
+ // Last option: have the server create a fresh chat, named here.
194
+ const NEW_CHAT = '__new__';
195
+ const newOpt = el('option'); newOpt.value = NEW_CHAT; newOpt.textContent = '+ New chat…';
196
+ chat.appendChild(newOpt);
197
+ if (!snapshot.chats.length) chat.value = NEW_CHAT;
198
+ const newChatName = el('input'); newChatName.maxLength = 120; newChatName.placeholder = 'Name for the new chat';
199
+ const newChatField = field('New chat name', newChatName);
200
+ const syncChat = () => {
201
+ const on = chat.value === NEW_CHAT;
202
+ newChatField.hidden = !on; newChatName.required = on;
203
+ if (on && !newChatName.value) newChatName.value = name.value.trim();
204
+ };
205
+ chat.addEventListener('change', () => { syncChat(); if (chat.value === NEW_CHAT) newChatName.select(); });
206
+ syncChat();
207
+
208
+ const { when, cron, zone, sync } = schedulePicker(initial);
209
+
210
+ const prompt = el('textarea'); prompt.required = true; prompt.maxLength = 12000;
211
+ prompt.placeholder = 'What should the assistant do each time this runs?';
212
+ prompt.value = initial.prompt || '';
213
+ prompt.addEventListener('input', () => autosize(prompt));
214
+
215
+ const enabled = el('input'); enabled.type = 'checkbox'; enabled.checked = initial.enabled ?? true;
216
+ const enabledRow = el('label', 'automation-enabled-row');
217
+ enabledRow.append(enabled, ' Enabled');
218
+
219
+ const top = el('div', 'automation-row');
220
+ top.append(field('Name', name), field('Post results to', chat), newChatField);
221
+
222
+ const error = el('div', 'automation-error'); error.setAttribute('role', 'alert');
223
+ const actions = el('div', 'automation-actions');
224
+ if (existing) {
225
+ const del = el('button', 'danger'); del.type = 'button'; del.textContent = 'Delete';
226
+ del.onclick = async () => {
227
+ if (!confirm(`Delete “${existing.name}”? This can't be undone.`)) return;
228
+ try { await api.del(url(existing)); } catch (err) { error.textContent = err.message; return; }
229
+ openForm = null; await load();
230
+ };
231
+ actions.appendChild(del);
232
+ }
233
+ actions.append(enabledRow, el('span', 'grow'));
234
+ const cancel = el('button'); cancel.type = 'button'; cancel.textContent = 'Cancel';
235
+ cancel.onclick = () => { openForm = null; rerender(); };
236
+ const submitLabel = existing ? 'Save' : 'Create';
237
+ const submit = el('button', 'primary'); submit.type = 'submit'; submit.textContent = submitLabel;
238
+ actions.append(cancel, submit);
239
+
240
+ form.append(top, when, field('Instructions', prompt), error, actions);
241
+ sync();
242
+ // Sized once it's in the document -- scrollHeight is 0 before that.
243
+ requestAnimationFrame(() => autosize(prompt));
244
+
245
+ form.addEventListener('keydown', (e) => {
246
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
247
+ if (e.key === 'Escape') { e.stopPropagation(); cancel.click(); }
248
+ });
249
+ form.onsubmit = async (event) => {
250
+ event.preventDefault();
251
+ error.textContent = '';
252
+ submit.disabled = true;
253
+ const data = {
254
+ name: name.value.trim(), cron: cron.value.trim(),
255
+ timezone: zone.value.trim(), prompt: prompt.value, enabled: enabled.checked
256
+ };
257
+ if (chat.value === NEW_CHAT) data.newChatTitle = newChatName.value.trim();
258
+ else data.chatId = chat.value;
259
+ try {
260
+ if (existing) await api.patch(url(existing), data); else await api.post('/api/automations', data);
261
+ } catch (err) {
262
+ error.textContent = err.message; return;
263
+ } finally {
264
+ submit.disabled = false; submit.textContent = submitLabel;
265
+ }
266
+ openForm = null; await load();
267
+ if (data.newChatTitle) loadChats(); // the new chat belongs in the sidebar too
268
+ };
269
+ return form;
270
+ }
271
+
272
+ /* ---------- api ---------- */
273
+
274
+ const url = (a, suffix = '') => `/api/automations/${encodeURIComponent(a.id)}${suffix}`;
275
+
276
+ /* ---------- rendering ---------- */
277
+
278
+ const STATUS_LABEL = { completed: 'succeeded', failed: 'failed', running: 'running', queued: 'queued' };
279
+ function statusPill(status) {
280
+ const known = STATUS_LABEL[status];
281
+ const pill = el('span', `automation-pill ${known ? `is-${status}` : status ? 'is-failed' : 'is-never'}`);
282
+ pill.textContent = known || (status ? status : 'never run');
283
+ if (status && !known) pill.title = status;
284
+ return pill;
285
+ }
286
+
287
+ function card(automation) {
288
+ const article = el('article', `automation-card${automation.enabled ? '' : ' is-paused'}`);
289
+
290
+ const head = el('div', 'automation-card-head');
291
+ const titleBox = el('div', 'automation-title');
292
+ const title = el('h3'); title.textContent = automation.name;
293
+ const schedule = el('p', 'automation-schedule-line');
294
+ schedule.textContent = `${describeCron(automation.cron)} · ${automation.timezone}`;
295
+ schedule.title = `cron: ${automation.cron}`;
296
+ titleBox.append(title, schedule);
297
+
298
+ // The switch saves on its own: pausing shouldn't require opening the editor.
299
+ const toggle = el('label', 'automation-switch');
300
+ toggle.title = automation.enabled ? 'Enabled — click to pause' : 'Paused — click to enable';
301
+ const box = el('input'); box.type = 'checkbox'; box.checked = automation.enabled;
302
+ box.setAttribute('aria-label', `${automation.name} enabled`);
303
+ box.onchange = async () => {
304
+ box.disabled = true;
305
+ try { await api.patch(url(automation), { enabled: box.checked }); } catch (err) { box.checked = !box.checked; alert(err.message); }
306
+ await load();
307
+ };
308
+ toggle.append(box, el('span', 'switch-track'));
309
+ head.append(titleBox, toggle);
310
+
311
+ const facts = el('div', 'automation-facts');
312
+ const next = el('span');
313
+ next.textContent = automation.enabled
314
+ ? (automation.nextRunAt ? `Next ${relative(automation.nextRunAt)}` : 'Not scheduled')
315
+ : 'Paused';
316
+ if (automation.nextRunAt) next.title = absolute(automation.nextRunAt);
317
+ const last = el('span', 'automation-last');
318
+ last.append('Last run ', statusPill(automation.lastStatus));
319
+ if (automation.lastRunAt) {
320
+ const when = el('span'); when.textContent = relative(automation.lastRunAt); when.title = absolute(automation.lastRunAt);
321
+ last.append(' ', when);
322
+ }
323
+ const chatLink = el('button', 'link'); chatLink.type = 'button';
324
+ chatLink.textContent = `→ ${automation.chatTitle || 'chat'}`;
325
+ chatLink.title = 'Open the chat this automation posts to';
326
+ chatLink.onclick = () => { close(); openChat(automation.chatId); };
327
+ facts.append(next, last, chatLink);
328
+
329
+ const preview = el('p', 'automation-preview'); preview.textContent = automation.prompt;
330
+ preview.title = 'Click to show the full instructions';
331
+ preview.onclick = () => {
332
+ if (getSelection().toString()) return; // selecting text shouldn't toggle it
333
+ preview.classList.toggle('expanded');
334
+ preview.title = preview.classList.contains('expanded') ? 'Click to collapse' : 'Click to show the full instructions';
335
+ };
336
+
337
+ const actions = el('div', 'automation-actions');
338
+ const run = el('button'); run.type = 'button'; run.textContent = 'Run now';
339
+ run.onclick = async () => {
340
+ run.disabled = true; run.textContent = 'Starting…';
341
+ try { await api.post(url(automation, '/trigger')); } catch (err) {
342
+ run.disabled = false; run.textContent = 'Run now'; alert(err.message); return;
343
+ }
344
+ close(); await openChat(automation.chatId);
345
+ };
346
+ const edit = el('button'); edit.type = 'button';
347
+ edit.textContent = 'Edit';
348
+ edit.onclick = () => { openForm = automation.id; rerender(); };
349
+ actions.append(run, edit);
350
+
351
+ const history = runHistory(automation);
352
+ actions.append(el('span', 'grow'), history);
353
+
354
+ article.append(head, facts);
355
+ if (openForm === automation.id) article.appendChild(automationForm(automation));
356
+ else article.append(preview, actions);
357
+ return article;
358
+ }
359
+
360
+ /** The collapsed "Run history" under a card; its runs are fetched on first open. */
361
+ function runHistory(automation) {
362
+ const history = el('details', 'automation-history');
363
+ const summary = el('summary'); summary.textContent = 'Run history';
364
+ history.appendChild(summary);
365
+ history.addEventListener('toggle', async () => {
366
+ if (!history.open || history.dataset.loaded) return;
367
+ history.dataset.loaded = '1';
368
+ let runs = [];
369
+ try {
370
+ ({ runs = [] } = await api.get(url(automation, '/runs')));
371
+ } catch (err) {
372
+ // Let the next expand retry instead of leaving the section empty for good.
373
+ delete history.dataset.loaded;
374
+ const p = el('p'); p.textContent = `Couldn't load runs (${err.message}).`; history.appendChild(p);
375
+ history.addEventListener('toggle', () => { if (!history.open) p.remove(); }, { once: true });
376
+ return;
377
+ }
378
+ if (!runs.length) { const p = el('p'); p.textContent = 'No runs yet.'; history.appendChild(p); return; }
379
+ const table = el('div', 'automation-runs');
380
+ for (const r of runs) {
381
+ // Each run expands to what it actually produced, rendered the way the
382
+ // chat would show it.
383
+ const row = el('details', 'automation-run');
384
+ const head = el('summary');
385
+ const when = el('span'); when.textContent = absolute(r.scheduled_at);
386
+ const kind = el('span', 'muted'); kind.textContent = r.trigger_type === 'manual' ? 'manual' : 'scheduled';
387
+ head.append(statusPill(r.status), when, kind);
388
+ if (r.started_at && r.finished_at) {
389
+ const took = el('span', 'muted'); took.textContent = `${Math.max(1, Math.round((r.finished_at - r.started_at) / 1000))}s`;
390
+ head.appendChild(took);
391
+ }
392
+ row.appendChild(head);
393
+ const body = el('div', 'automation-run-body');
394
+ if (r.error) { const e = el('p', 'automation-run-error'); e.textContent = r.error; body.appendChild(e); }
395
+ if (r.result) {
396
+ const out = el('div', 'automation-run-output md'); out.innerHTML = renderMarkdown(r.result);
397
+ body.appendChild(out);
398
+ } else if (!r.error) {
399
+ const p = el('p', 'muted'); p.textContent = r.status === 'running' || r.status === 'queued' ? 'Still running…' : 'No output recorded.';
400
+ body.appendChild(p);
401
+ }
402
+ const open = el('button', 'link'); open.type = 'button'; open.textContent = 'Open in chat →';
403
+ open.onclick = () => { close(); openChat(automation.chatId); };
404
+ body.appendChild(open);
405
+ row.appendChild(body);
406
+ table.appendChild(row);
407
+ }
408
+ history.appendChild(table);
409
+ });
410
+ return history;
411
+ }
412
+
413
+ function rerender() {
414
+ $('new-automation').hidden = openForm === 'new';
415
+ createSlot.replaceChildren();
416
+ if (openForm === 'new') {
417
+ const c = el('section', 'automation-card');
418
+ const h = el('h3'); h.textContent = 'New automation';
419
+ c.append(h, automationForm(null));
420
+ createSlot.appendChild(c);
421
+ }
422
+ list.replaceChildren();
423
+ if (!snapshot.automations.length) {
424
+ if (openForm === 'new') return;
425
+ const empty = el('div', 'automation-empty');
426
+ const h = el('p'); h.textContent = 'No automations yet';
427
+ const p = el('p', 'muted'); p.textContent = 'Run a prompt on a schedule and have the result posted into a chat.';
428
+ empty.append(h, p);
429
+ list.appendChild(empty);
430
+ return;
431
+ }
432
+ for (const automation of snapshot.automations) list.appendChild(card(automation));
433
+ }
434
+
435
+ async function load() {
436
+ const request = ++automationLoadRequest;
437
+ let next;
438
+ try {
439
+ next = await api.get('/api/automations');
440
+ } catch (err) {
441
+ if (request === automationLoadRequest && panel.classList.contains('open')) {
442
+ list.replaceChildren(Object.assign(el('div', 'automation-empty'), { textContent: `Couldn't load automations (${err.message}).` }));
443
+ }
444
+ return;
445
+ }
446
+ if (request !== automationLoadRequest) return;
447
+ snapshot = next;
448
+ if (openForm && openForm !== 'new' && !snapshot.automations.some((a) => a.id === openForm)) openForm = null;
449
+ rerender();
450
+ }
451
+
452
+ /** The panel's onOpen: the list is fetched fresh every time it is shown. */
453
+ export const loadAutomations = () => load();
454
+
455
+ export function initAutomations() {
456
+ $('new-automation').onclick = () => {
457
+ openForm = 'new'; rerender();
458
+ createSlot.querySelector('input')?.focus();
459
+ };
460
+ // Refresh the relative times and statuses, but never under an open form.
461
+ setInterval(() => {
462
+ if (panel.classList.contains('open') && !openForm) load();
463
+ }, 30000);
464
+ }