anentrypoint-design 0.0.384 → 0.0.385

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.
@@ -3,1072 +3,19 @@
3
3
  // gui-* plugin HTTP endpoints (/api/*). Consumers mount FREDDIE_PAGES[id]
4
4
  // through their thin router; no per-page wiring needed downstream. This is
5
5
  // the single maintenance point for freddie GUI per the dynamic-stack contract.
6
+ //
7
+ // This module is a barrel: every page lives in a single-responsibility
8
+ // submodule under ./freddie/, grouped by the surface it drives, and the
9
+ // public export surface here is unchanged — no consumer import needs to move.
6
10
 
7
- import * as webjsx from '../../vendor/webjsx/index.js';
8
- import { makePage, api, loadingState, errorState, emptyState, refreshError } from './freddie/runtime.js';
9
11
  import { getRecentPaths, saveRecentPath, skillLabel, renderChatMessages } from './freddie/helpers.js';
10
- import { Panel, Row, Table, Kpi, PageHeader, SearchInput, TextField, Select } from './content.js';
11
- import { Chip, Btn, Icon } from './shell.js';
12
- import { formatTime } from '../locale.js';
13
- import { register as registerDebug, unregister as unregisterDebug } from '../debug.js';
14
- import { queueMessage, watchReconnect, isOnline } from '../idb-outbox.js';
15
- import { ChatMessage, ChatComposer } from './chat.js';
16
- import { fmtTime, fmtAgo } from './sessions.js';
17
- import { ModelsConfig } from './models-config.js';
18
- import { SkillsConfig } from './skills-config.js';
19
- import { PluginsConfig } from './plugins-config.js';
20
- import { GitStatusPanel, GitDiffView } from './git-status.js';
21
- import { WorktreeSwitcher } from './worktree-switcher.js';
22
- import { AgentChat } from './agent-chat.js';
23
-
24
- const h = webjsx.createElement;
25
-
26
- // ---- shared bits -----------------------------------------------------------
27
-
28
- const section = (title, ...children) => Panel({ title, children: children.flat().filter(Boolean) });
29
- const noteAlert = (note) => note ? h('div', { class: 'ds-alert ds-alert-' + note.kind, role: 'alert' },
30
- h('span', { class: 'ds-alert-icon' }, '!'),
31
- h('div', { class: 'ds-alert-content' }, note.msg)) : null;
32
- // Manual refresh button for non-polling pages — parity with auto-refreshing ones.
33
- const refreshBtn = (onClick, busy) => Btn({ children: busy ? 'refreshing…' : [Icon('refresh'), ' refresh'], disabled: !!busy, onClick, 'aria-label': 'refresh' });
34
- // Polite live region announcing async busy/done state to screen readers.
35
- const liveRegion = (msg) => h('div', { class: 'fd-sr-live', role: 'status', 'aria-live': 'polite' }, msg || '');
36
- // Truncate with a title tooltip carrying the full text.
37
- const trunc = (s, n = 90) => { const str = String(s || ''); return str.length > n ? { text: str.slice(0, n) + '…', title: str } : { text: str, title: null }; };
38
- // Named truncation widths so list pages cap display consistently (and any
39
- // raw .slice(0,N) on user text routes through trunc() for ellipsis + tooltip).
40
- const TRUNC_TITLE = 60; // session/skill/tool titles
41
- const TRUNC_SUB = 80; // row sub-text (prompts, descriptions)
42
- const TRUNC_OUTPUT = 70; // batch output cells
43
- const TRUNC_DESC = 90; // long descriptions
44
- const TRUNC_PROMPT = 50; // batch prompt cells
45
- // Render trunc() result as a span carrying the full text in its title tooltip.
46
- const truncSpan = (s, n) => { const t = trunc(s, n); return h('span', { title: t.title }, t.text); };
47
- // Cap a raw JSON dump for an inline table cell without losing the data via tooltip.
48
- const truncJson = (v, n = TRUNC_TITLE) => truncSpan(JSON.stringify(v), n);
49
- // ---- home ------------------------------------------------------------------
50
-
51
- export const home = makePage((ctx) => {
52
- async function load() {
53
- try {
54
- // tools/skills counts come from the host when injected; otherwise
55
- // fall back to the same /api/* endpoints the tools/skills pages use
56
- // so the home KPIs never render an em-dash placeholder.
57
- const needTools = ctx.host?.pi?.tools?.size == null;
58
- const needSkills = ctx.host?.pi?.skills?.size == null;
59
- const [health, agents, sessions, toolsList, skillsList] = await Promise.all([
60
- api('/api/health').catch(() => null),
61
- api('/api/agents').catch(() => null),
62
- api('/api/sessions').catch((e) => ({ _err: e })),
63
- needTools ? api('/api/tools').catch(() => null) : Promise.resolve(null),
64
- needSkills ? api('/api/skills').catch(() => null) : Promise.resolve(null),
65
- ]);
66
- const toolsCount = needTools ? (Array.isArray(toolsList) ? toolsList.length : (toolsList?.tools?.length ?? null)) : null;
67
- const skillsCount = needSkills ? (Array.isArray(skillsList) ? skillsList.length : (skillsList?.skills?.length ?? null)) : null;
68
- const sessFailed = sessions && sessions._err;
69
- ctx.set({ loading: false, health, agents, sessions: Array.isArray(sessions) ? sessions : [], sessFailed, toolsCount, skillsCount, error: null });
70
- } catch (e) { ctx.set({ loading: false, error: e }); }
71
- }
72
- load();
73
- ctx.interval(load, 15000);
74
- return () => {
75
- const s = ctx.state;
76
- if (s.loading) return loadingState('loading dashboard…');
77
- if (s.error) return errorState(s.error, load);
78
- const sessions = s.sessions || [];
79
- const agents = s.agents || {};
80
- const tools = ctx.host?.pi?.tools?.size ?? s.toolsCount ?? '—';
81
- const skills = ctx.host?.pi?.skills?.size ?? s.skillsCount ?? '—';
82
- return [
83
- PageHeader({ title: 'dashboard', lede: 'agent harness · live overview' }),
84
- Kpi({ items: [
85
- [tools, 'tools'],
86
- [skills, 'skills'],
87
- [sessions.length, 'sessions'],
88
- [agents.count ?? 0, 'active agents'],
89
- ] }),
90
- section('recent sessions',
91
- s.sessFailed
92
- ? errorState(new Error('could not load sessions'))
93
- : sessions.length
94
- ? Table({
95
- headers: ['session', 'platform', 'updated'],
96
- rows: sessions.slice(0, 8).map(x => [truncSpan(x.title || x.id, TRUNC_TITLE), x.platform || '—', fmtAgo(x.updated_at)]),
97
- })
98
- : emptyState('no sessions yet')),
99
- section('health',
100
- s.health ? Table({ headers: ['check', 'status'], rows: Object.entries(s.health).map(([k, v]) => [k, typeof v === 'object' ? truncJson(v) : String(v)]) })
101
- : emptyState('health endpoint unavailable')),
102
- ];
103
- };
104
- });
105
-
106
- // ---- chat ------------------------------------------------------------------
107
-
108
- // Parse a fetch Response body as a Server-Sent-Events frame stream. There is
109
- // no EventSource-over-POST in browsers (EventSource only does GET, no custom
110
- // headers/body), so a POST-based SSE consumer has to manually decode the
111
- // ReadableStream and split on blank-line-terminated `event: X\ndata: Y\n\n`
112
- // frames. No existing SSE-parsing utility exists elsewhere in this SDK
113
- // (checked idb-outbox.js and grepped src/ for `text/event-stream`) -- this is
114
- // the first, generic enough (event name + JSON.parse'd data) to reuse for any
115
- // future SSE endpoint, not freddie-chat-specific in shape.
116
- async function* parseSseStream(response) {
117
- const reader = response.body.getReader();
118
- const decoder = new TextDecoder();
119
- let buf = '';
120
- try {
121
- for (;;) {
122
- const { done, value } = await reader.read();
123
- if (done) break;
124
- buf += decoder.decode(value, { stream: true });
125
- // Frames are separated by a blank line; a frame may itself contain
126
- // multiple `field: value` lines (event/data/id/retry) but this
127
- // server only ever emits one `event:` + one `data:` line per frame.
128
- let sep;
129
- while ((sep = buf.indexOf('\n\n')) !== -1) {
130
- const frame = buf.slice(0, sep);
131
- buf = buf.slice(sep + 2);
132
- let event = 'message', dataLines = [];
133
- for (const line of frame.split('\n')) {
134
- if (line.startsWith('event:')) event = line.slice(6).trim();
135
- else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
136
- }
137
- if (!dataLines.length) continue;
138
- let data;
139
- try { data = JSON.parse(dataLines.join('\n')); } catch { data = dataLines.join('\n'); }
140
- yield { event, data };
141
- }
142
- }
143
- } finally {
144
- try { reader.releaseLock(); } catch { /* swallow: stream already closed/errored */ }
145
- }
146
- }
147
-
148
- // Map a freddie tool_progress SSE payload ({name, args, partial}) to an
149
- // AgentChat tool part. There is no matching id to correlate a later
150
- // "done" state (freddie's stream has no discrete tool-start/tool-end pair --
151
- // tool_progress fires zero-or-more times per call while it runs, and the
152
- // authoritative role:'tool' result only arrives batched in the final
153
- // `message`/`done` events) so every progress part renders as 'running'; the
154
- // turn-settle pass below promotes matching parts to 'done'/'error' once the
155
- // real tool_call_id/content pairs are known.
156
- function toolProgressPart(payload) {
157
- return { kind: 'tool', name: payload.name || 'tool', args: payload.args || {}, status: 'running' };
158
- }
159
-
160
- // After `done`, freddie's persisted message list is the source of truth:
161
- // walk it and rebuild the assistant turn's parts as interleaved
162
- // text/tool/tool_result, replacing the provisional tool_progress-only parts
163
- // accumulated during streaming. assistant messages with tool_calls become
164
- // running tool parts (by call id); role:'tool' messages settle the matching
165
- // part to done/error by tool_call_id.
166
- function partsFromMessages(assistantAndToolMessages) {
167
- const parts = [];
168
- const byId = new Map();
169
- for (const m of assistantAndToolMessages) {
170
- if (m.role === 'assistant') {
171
- if (m.content) parts.push({ kind: 'md', text: m.content });
172
- for (const tc of (m.tool_calls || [])) {
173
- const part = { kind: 'tool', _id: tc.id, name: tc.name || tc.function?.name || 'tool', args: tc.arguments || tc.function?.arguments || {}, status: 'running' };
174
- parts.push(part);
175
- if (tc.id) byId.set(tc.id, part);
176
- }
177
- } else if (m.role === 'tool') {
178
- const target = m.tool_call_id ? byId.get(m.tool_call_id) : null;
179
- let content = m.content;
180
- let isError = false;
181
- try { const parsed = JSON.parse(content); if (parsed && parsed.error) { isError = true; } } catch { /* swallow: not JSON, leave as-is */ }
182
- if (target) { target.result = content; target.status = isError ? 'error' : 'done'; target.error = isError || undefined; }
183
- else parts.push({ kind: 'tool_result', name: 'result', result: content, error: isError || undefined, status: isError ? 'error' : 'done' });
184
- }
185
- }
186
- return parts;
187
- }
188
-
189
- export const chat = makePage((ctx) => {
190
- Object.assign(ctx.state, { loading: false, messages: [], draft: '', busy: false, error: null, abort: null });
191
-
192
- // Offline outbox: a prompt sent while genuinely offline queues to
193
- // IndexedDB and auto-flushes on the real 'online' event, rather than
194
- // surfacing a hard error the user can't act on. True offline LLM
195
- // response generation is impossible by definition -- a queued message
196
- // only gets a reply once connectivity actually returns. Reconnect-flush
197
- // still goes through the single-shot JSON path (no live UI to stream
198
- // into for a message sent while this page may not even be mounted).
199
- async function sendQueuedToServer(body) {
200
- const r = await api('/api/chat', { method: 'POST', body });
201
- const reply = r.result || r.content || r.message || (r.messages && r.messages.at(-1)?.content) || JSON.stringify(r);
202
- ctx.state.messages.push({ id: 'a' + Date.now(), role: 'assistant', content: String(reply), time: formatTime(Date.now()) });
203
- ctx.rerender();
204
- }
205
- watchReconnect('chat', sendQueuedToServer);
206
-
207
- async function send(text) {
208
- const t = (typeof text === 'string' ? text : ctx.state.draft || '').trim();
209
- if (!t || ctx.state.busy) return;
210
- const userMsg = { id: 'u' + Date.now(), role: 'user', content: t, time: formatTime(Date.now()) };
211
- const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: formatTime(Date.now()), parts: [] };
212
- ctx.state.messages = [...ctx.state.messages, userMsg, curMsg];
213
- ctx.set({ draft: '', busy: true, error: null });
214
-
215
- if (!isOnline()) {
216
- await queueMessage('chat', { prompt: t });
217
- ctx.state.messages = ctx.state.messages.slice(0, -1);
218
- ctx.state.messages.push({ id: curMsg.id, role: 'assistant', content: '(offline -- queued, will send when connection returns)', time: formatTime(Date.now()) });
219
- ctx.set({ busy: false });
220
- return;
221
- }
222
-
223
- const ctrl = new AbortController();
224
- ctx.state.abort = ctrl;
225
- const cur = ctx.state.messages[ctx.state.messages.length - 1];
226
- try {
227
- const res = await fetch('/api/chat', {
228
- method: 'POST',
229
- headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
230
- body: JSON.stringify({ prompt: t, sessionId: ctx.state.sessionId || undefined }),
231
- signal: ctrl.signal,
232
- });
233
- if (!res.ok || !res.body) {
234
- const txt = await res.text().catch(() => '');
235
- throw new Error(txt || ('HTTP ' + res.status));
236
- }
237
- let finalMessages = null;
238
- for await (const { event, data } of parseSseStream(res)) {
239
- if (ctrl.signal.aborted) break;
240
- if (event === 'start') {
241
- if (data && data.sessionId) ctx.state.sessionId = data.sessionId;
242
- } else if (event === 'tool_progress') {
243
- cur.parts.push(toolProgressPart(data || {}));
244
- ctx.rerender();
245
- } else if (event === 'message') {
246
- // Buffered per-message events land right before `done` -- accumulate
247
- // rather than rerender per-message; the final rebuild below is O(1)
248
- // extra work and avoids a flurry of rerenders in the same tick.
249
- (finalMessages || (finalMessages = [])).push(data);
250
- } else if (event === 'error') {
251
- cur.error = String((data && data.error) || 'stream error');
252
- ctx.rerender();
253
- } else if (event === 'done') {
254
- if (finalMessages && finalMessages.length) {
255
- cur.parts = partsFromMessages(finalMessages);
256
- // Prefer the settled assistant text as plain content when the
257
- // rebuilt parts carry exactly one md part (the common no-tool-call
258
- // case) -- keeps AgentChat's md-vs-content dedup path simple.
259
- cur.content = '';
260
- } else if (data && data.result) {
261
- cur.content = String(data.result);
262
- }
263
- ctx.rerender();
264
- }
265
- }
266
- } catch (e) {
267
- if (e && e.name === 'AbortError') {
268
- cur.stopped = true;
269
- } else {
270
- cur.error = String(e && e.message || e);
271
- }
272
- } finally {
273
- ctx.state.abort = null;
274
- ctx.set({ busy: false });
275
- }
276
- }
277
-
278
- function stop() {
279
- if (ctx.state.abort) { try { ctx.state.abort.abort(); } catch { /* swallow: already settled */ } }
280
- }
281
-
282
- return () => {
283
- const s = ctx.state;
284
- return h('div', { class: 'fd-chat' },
285
- AgentChat({
286
- messages: s.messages,
287
- busy: s.busy,
288
- draft: s.draft,
289
- status: s.busy ? 'streaming…' : 'ready',
290
- agentName: 'freddie',
291
- placeholder: s.busy ? 'waiting for reply…' : 'message…',
292
- showMinimap: true,
293
- banners: s.error ? [noteAlert({ kind: 'error', msg: s.error })] : [],
294
- onInput: (v) => { s.draft = v; },
295
- onSend: send,
296
- onStop: stop,
297
- onNewChat: () => ctx.set({ messages: [], draft: '', error: null, sessionId: null }),
298
- }));
299
- };
300
- });
301
-
302
- // ---- voice -----------------------------------------------------------------
303
-
304
- export const voice = makePage((ctx) => {
305
- async function load() {
306
- // Probe for a voice backend; the endpoint is optional, so a 404/!ok
307
- // means "not wired" rather than an error to surface.
308
- try { const v = await api('/api/voice').catch(() => null); ctx.set({ loading: false, voice: v, error: null }); }
309
- catch (e) { ctx.set({ loading: false, error: e }); }
310
- }
311
- load();
312
- return () => {
313
- const s = ctx.state;
314
- if (s.loading) return loadingState('loading voice config…');
315
- const v = s.voice;
316
- const enabled = v && (v.enabled || v.transcription || v.tts);
317
- return [
318
- PageHeader({ title: 'voice', lede: 'voice surfaces', right: enabled ? Chip({ tone: 'ok', children: 'enabled' }) : Chip({ tone: 'neutral', children: 'not configured' }) }),
319
- enabled
320
- ? section('backends', Table({ headers: ['capability', 'status'], rows: [['transcription', v.transcription ? Chip({ tone: 'ok', children: 'on' }) : Chip({ tone: 'neutral', children: 'off' })], ['tts', v.tts ? Chip({ tone: 'ok', children: 'on' }) : Chip({ tone: 'neutral', children: 'off' })]] }))
321
- : section('status', emptyState('no voice backend wired in this build. configure a transcription/tts plugin to enable.')),
322
- ];
323
- };
324
- });
325
-
326
- // ---- sessions --------------------------------------------------------------
327
-
328
- export const sessions = makePage((ctx) => {
329
- Object.assign(ctx.state, { q: '', selected: null, messages: [], msgLoading: false });
330
- async function load() {
331
- try { ctx.set({ loading: false, list: await api('/api/sessions'), error: null }); }
332
- catch (e) { ctx.set({ loading: false, error: e }); }
333
- }
334
- async function search(q) {
335
- if (!q) return load();
336
- try { ctx.set({ loading: false, list: await api('/api/search?q=' + encodeURIComponent(q)), error: null }); }
337
- catch (e) { ctx.set({ loading: false, error: e }); }
338
- }
339
- async function refresh() { ctx.set({ refreshing: true }); try { ctx.set({ list: await api('/api/sessions'), error: null }); } catch (e) { ctx.set({ error: e }); } ctx.set({ refreshing: false }); }
340
- async function open(id) {
341
- ctx.set({ selected: id, msgLoading: true });
342
- try { ctx.set({ messages: await api('/api/sessions/' + encodeURIComponent(id) + '/messages'), msgLoading: false }); }
343
- catch (e) { ctx.set({ messages: [], msgLoading: false, error: e }); }
344
- }
345
- load();
346
- return () => {
347
- const s = ctx.state;
348
- if (s.loading) return loadingState('loading sessions…');
349
- if (s.error && !s.list) return errorState(s.error, load);
350
- const list = Array.isArray(s.list) ? s.list : [];
351
- return [
352
- PageHeader({ title: 'sessions', lede: list.length + ' sessions', right: refreshBtn(refresh, s.refreshing) }),
353
- s.error && s.list ? refreshError(s.error) : null,
354
- SearchInput({ value: s.q, label: 'search sessions', placeholder: 'search messages…', onInput: (v) => { s.q = v; }, onSubmit: (v) => search(v) }),
355
- section('sessions',
356
- list.length
357
- ? Table({ headers: ['session', 'platform', 'updated'], onRowClick: (i) => open(list[i].id),
358
- rowLabels: list.map(x => x.title || x.id),
359
- rows: list.map(x => [truncSpan(x.title || x.id, TRUNC_TITLE), x.platform || '—', fmtAgo(x.updated_at)]) })
360
- : emptyState('no sessions match')),
361
- s.selected ? section('messages · ' + s.selected,
362
- s.msgLoading ? loadingState('loading messages…')
363
- : (s.messages || []).length ? (s.messages).map((m, i) => ChatMessage({ role: m.role, text: m.content || m.text || '', time: m.ts ? fmtTime(m.ts) : '', key: i }))
364
- : emptyState('no messages')) : null,
365
- ].filter(Boolean);
366
- };
367
- });
368
-
369
- // ---- projects --------------------------------------------------------------
370
-
371
- export const projects = makePage((ctx) => {
372
- Object.assign(ctx.state, { newName: '', newPath: '', busy: false, note: null });
373
- async function load() {
374
- try { ctx.set({ loading: false, data: await api('/api/projects'), error: null }); }
375
- catch (e) { ctx.set({ loading: false, error: e }); }
376
- }
377
- async function create() {
378
- const name = (ctx.state.newName || '').trim();
379
- if (!name) { ctx.set({ note: { kind: 'warn', msg: 'name required' } }); return; }
380
- ctx.set({ busy: true, note: null });
381
- try { await api('/api/projects', { method: 'POST', body: { name, path: ctx.state.newPath || undefined } }); ctx.state.newName = ''; ctx.state.newPath = ''; await load(); }
382
- catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
383
- ctx.set({ busy: false });
384
- }
385
- async function activate(name) { ctx.set({ busy: true }); try { await api('/api/projects/active', { method: 'POST', body: { name } }); await load(); } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); } ctx.set({ busy: false }); }
386
- async function del(name) { ctx.set({ busy: true }); try { await api('/api/projects/' + encodeURIComponent(name), { method: 'DELETE' }); await load(); } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); } ctx.set({ busy: false }); }
387
- load();
388
- return () => {
389
- const s = ctx.state;
390
- if (s.loading) return loadingState('loading projects…');
391
- if (s.error && !s.data) return errorState(s.error, load);
392
- const d = s.data || {}; const list = d.projects || [];
393
- const activeName = (d.active && d.active.name) || d.active || 'default';
394
- return [
395
- PageHeader({ title: 'projects', lede: 'isolated workspaces · active: ' + activeName }),
396
- noteAlert(s.note),
397
- section('projects',
398
- list.length ? list.map((p, i) => Row({
399
- key: i, code: h('span', { class: 'ds-dot ' + (p.name === activeName ? 'ds-dot-on' : 'ds-dot-off'), 'aria-hidden': 'true' }), title: p.name, sub: p.path || '',
400
- active: p.name === activeName,
401
- trailing: h('span', { class: 'fd-row-actions' },
402
- p.name !== activeName ? Btn({ children: 'activate', onClick: () => activate(p.name) }) : Chip({ tone: 'ok', children: 'active' }),
403
- p.name !== 'default' ? Btn({ variant: 'danger', children: 'delete', onClick: () => del(p.name) }) : null),
404
- })) : emptyState('no projects')),
405
- section('new project',
406
- TextField({ label: 'name', value: s.newName, onInput: (v) => { s.newName = v; }, placeholder: 'my-project' }),
407
- TextField({ label: 'path (optional)', value: s.newPath, onInput: (v) => { s.newPath = v; }, placeholder: 'C:/path/to/dir' }),
408
- Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'create', onClick: create })),
409
- ].filter(Boolean);
410
- };
411
- });
412
-
413
- // ---- agents ----------------------------------------------------------------
414
-
415
- export const agents = makePage((ctx) => {
416
- async function load() { try { ctx.set({ loading: false, data: await api('/api/agents'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
417
- load(); ctx.interval(load, 5000);
418
- return () => {
419
- const s = ctx.state;
420
- if (s.loading) return loadingState('loading agents…');
421
- if (s.error && !s.data) return errorState(s.error, load);
422
- const d = s.data || {};
423
- return [
424
- PageHeader({ title: 'agents', lede: 'live agent activity' }),
425
- s.error && s.data ? refreshError(s.error) : null,
426
- Kpi({ items: [[d.count ?? 0, 'active'], [d.turns ?? 0, 'total turns'], [d.last_activity ? fmtAgo(d.last_activity) : '—', 'last activity']] }),
427
- section('detail', Table({ headers: ['field', 'value'], rows: Object.entries(d).map(([k, v]) => [k, String(v)]) })),
428
- ].filter(Boolean);
429
- };
430
- });
431
-
432
- // ---- analytics -------------------------------------------------------------
433
-
434
- export const analytics = makePage((ctx) => {
435
- async function load() {
436
- try {
437
- const [sampler, avail] = await Promise.all([
438
- api('/api/models/sampler').catch(() => null),
439
- api('/api/models/availability/summary').catch(() => null),
440
- ]);
441
- ctx.set({ loading: false, sampler, avail, error: null });
442
- } catch (e) { ctx.set({ loading: false, error: e }); }
443
- }
444
- load(); ctx.interval(load, 15000);
445
- return () => {
446
- const s = ctx.state;
447
- if (s.loading) return loadingState('loading analytics…');
448
- if (s.error && !s.sampler && !s.avail) return errorState(s.error, load);
449
- const samp = s.sampler?.status ? Object.values(s.sampler.status) : [];
450
- const ok = samp.filter(x => x && x.available !== false).length;
451
- const sum = s.avail?.summary || {};
452
- return [
453
- PageHeader({ title: 'analytics', lede: 'provider availability & sampler health' }),
454
- s.error && (s.sampler || s.avail) ? refreshError(s.error) : null,
455
- Kpi({ items: [[ok + '/' + samp.length, 'providers up'], [sum.total_models ?? '—', 'models'], [sum.usable_in_any_mode ?? '—', 'usable']] }),
456
- section('sampler', samp.length ? Table({ headers: ['provider', 'available', 'fails'], rows: Object.entries(s.sampler.status).map(([k, v]) => [k, v.available === false ? 'no' : 'yes', String(v.failCount ?? 0)]) }) : emptyState('no sampler data')),
457
- ].filter(Boolean);
458
- };
459
- });
460
-
461
- // ---- models ----------------------------------------------------------------
462
-
463
- export const models = makePage((ctx) => {
464
- Object.assign(ctx.state, { rebuilding: false, selectedProviderId: null, selectedModel: null });
465
- // GET /api/models/availability — the real per-(provider x model x mode)
466
- // availability matrix (plugins/gui-models-discover), per freddie's AGENTS.md
467
- // "Model availability matrix" section. 404 with {error,hint} when the
468
- // matrix file hasn't been built yet — ModelsConfig itself renders that
469
- // as an empty state with a "build availability matrix" action.
470
- async function load() {
471
- try { ctx.set({ loading: false, data: await api('/api/models/availability'), error: null }); }
472
- catch (e) { ctx.set({ loading: false, data: null, error: (e && e.body) || e }); }
473
- }
474
- async function rebuild() {
475
- if (ctx.state.rebuilding) return;
476
- ctx.set({ rebuilding: true, rebuildError: null });
477
- try { await api('/api/models/availability/rebuild', { method: 'POST', body: {} }); await load(); }
478
- catch (e) { ctx.set({ rebuildError: e }); }
479
- ctx.set({ rebuilding: false });
480
- }
481
- load();
482
- return () => {
483
- const s = ctx.state;
484
- return [
485
- PageHeader({ title: 'models', lede: s.data ? (s.data.summary?.total_models ?? 0) + ' models across ' + (s.data.summary?.total_providers ?? 0) + ' providers' : 'model availability matrix' }),
486
- ModelsConfig({
487
- data: s.data, loading: s.loading, error: s.error,
488
- selectedProviderId: s.selectedProviderId, onSelectProvider: (id) => ctx.set({ selectedProviderId: id, selectedModel: null }),
489
- selectedModel: s.selectedModel, onSelectModel: (m) => ctx.set({ selectedModel: m }),
490
- onRefresh: load, onRebuild: rebuild, rebuilding: s.rebuilding, rebuildError: s.rebuildError,
491
- }),
492
- ];
493
- };
494
- });
495
-
496
- // ---- cron ------------------------------------------------------------------
497
-
498
- export const cron = makePage((ctx) => {
499
- Object.assign(ctx.state, { expr: '', prompt: '', busy: false, note: null });
500
- async function load() { try { ctx.set({ loading: false, list: await api('/api/cron'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
501
- async function add() {
502
- const expr = (ctx.state.expr || '').trim(); const prompt = (ctx.state.prompt || '').trim();
503
- if (!expr || !prompt) { ctx.set({ note: { kind: 'warn', msg: 'cron expression and prompt required' } }); return; }
504
- ctx.set({ busy: true, note: null });
505
- try { await api('/api/cron', { method: 'POST', body: { cron: expr, prompt } }); ctx.state.expr = ''; ctx.state.prompt = ''; await load(); }
506
- catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
507
- ctx.set({ busy: false });
508
- }
509
- async function del(id) { ctx.set({ busy: true }); try { await api('/api/cron/' + id, { method: 'DELETE' }); await load(); } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); } ctx.set({ busy: false }); }
510
- load();
511
- return () => {
512
- const s = ctx.state;
513
- if (s.loading) return loadingState('loading cron jobs…');
514
- if (s.error && !s.list) return errorState(s.error, load);
515
- const list = Array.isArray(s.list) ? s.list : [];
516
- return [
517
- PageHeader({ title: 'cron', lede: list.length + ' scheduled jobs' }),
518
- noteAlert(s.note),
519
- section('jobs', list.length ? list.map((j, i) => Row({
520
- key: i, code: j.enabled ? Icon('play') : Icon('pause'), title: j.cron, sub: trunc(j.prompt, TRUNC_SUB).text,
521
- trailing: Btn({ variant: 'danger', children: 'delete', onClick: () => del(j.id) }),
522
- })) : emptyState('no cron jobs')),
523
- section('new job',
524
- TextField({ label: 'cron expression', value: s.expr, onInput: (v) => { s.expr = v; }, placeholder: '0 9 * * *' }),
525
- TextField({ label: 'prompt', value: s.prompt, multiline: true, onInput: (v) => { s.prompt = v; }, placeholder: 'what to run…' }),
526
- Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'add job', onClick: add })),
527
- ];
528
- };
529
- });
530
-
531
- // ---- skills ----------------------------------------------------------------
532
-
533
- export const skills = makePage((ctx) => {
534
- Object.assign(ctx.state, { selected: null, query: '', busyName: null });
535
- async function load() { try { ctx.set({ loading: false, list: await api('/api/skills'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
536
- load();
537
- return () => {
538
- const s = ctx.state;
539
- // GET /api/skills returns {home:[...], bundled:[...], skillState} —
540
- // two source lists (user ~/.freddie/skills vs bundled skills/ dirs)
541
- // plus a per-skill enabled/disabled state map, not a flat array.
542
- // Concat both sources (home overrides bundled on name collision,
543
- // matching src/skills/index.js's own findSkill() precedence) and
544
- // resolve enabled state from skillState (default true when absent).
545
- const raw = s.list && typeof s.list === 'object' ? s.list : {};
546
- const rawList = Array.isArray(raw) ? raw : [...(raw.bundled || []), ...(raw.home || [])];
547
- const skillState = raw.skillState || {};
548
- const mapped = rawList.map((sk) => ({
549
- file: sk.file || sk.path || sk.name,
550
- name: sk.name,
551
- description: sk.description || (sk.frontmatter && sk.frontmatter.description) || '',
552
- platforms: sk.platforms || (sk.frontmatter && sk.frontmatter.platforms),
553
- enabled: skillState[sk.name] !== false,
554
- }));
555
- return [
556
- PageHeader({ title: 'skills', lede: mapped.length + ' skills' }),
557
- SkillsConfig({
558
- skills: mapped, selected: s.selected, loading: s.loading, error: s.error,
559
- busyName: s.busyName, query: s.query, onQuery: (q) => ctx.set({ query: q }),
560
- onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
561
- }),
562
- ];
563
- };
564
- });
565
-
566
- // ---- plugins -----------------------------------------------------------------
567
-
568
- export const plugins = makePage((ctx) => {
569
- Object.assign(ctx.state, { selected: null });
570
- // GET /api/plugins — flat {name,version,surfaces,requires,source,enabled}
571
- // list, per plugins/gui-plugins-list/plugin.js (distinct from
572
- // /api/plugin-graph's D3 {nodes,edges} shape built for the dependency
573
- // visualization, not a flat list UI).
574
- async function load() { try { ctx.set({ loading: false, list: await api('/api/plugins'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
575
- load();
576
- return () => {
577
- const s = ctx.state;
578
- const list = Array.isArray(s.list) ? s.list : (s.list?.plugins || []);
579
- return [
580
- PageHeader({ title: 'plugins', lede: list.length + ' plugins loaded' }),
581
- PluginsConfig({
582
- plugins: list, selected: s.selected, loading: s.loading, error: s.error,
583
- onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
584
- onReload: load,
585
- }),
586
- ];
587
- };
588
- });
589
-
590
- // ---- config ----------------------------------------------------------------
591
-
592
- export const config = makePage((ctx) => {
593
- Object.assign(ctx.state, { edited: {}, busy: false, note: null });
594
- async function load() {
595
- try {
596
- const [cfg, skins] = await Promise.all([api('/api/config'), api('/api/skins').catch(() => null)]);
597
- ctx.set({ loading: false, cfg, skins, error: null });
598
- } catch (e) { ctx.set({ loading: false, error: e }); }
599
- }
600
- async function save() {
601
- ctx.set({ busy: true, note: null });
602
- try { await api('/api/config', { method: 'POST', body: ctx.state.edited }); ctx.state.edited = {}; await load(); ctx.set({ note: { kind: 'success', msg: 'saved' } }); }
603
- catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
604
- ctx.set({ busy: false });
605
- }
606
- async function setSkin(name) {
607
- ctx.set({ busy: true, note: null });
608
- try { await api('/api/config', { method: 'POST', body: { skin: name } }); await load(); ctx.set({ note: { kind: 'success', msg: 'skin -> ' + name } }); }
609
- catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
610
- ctx.set({ busy: false });
611
- }
612
- load();
613
- return () => {
614
- const s = ctx.state;
615
- if (s.loading) return loadingState('loading config…');
616
- if (s.error) return errorState(s.error, load);
617
- const cfg = s.cfg || {};
618
- const flat = Object.entries(cfg).filter(([, v]) => typeof v !== 'object' || v === null);
619
- const nested = Object.entries(cfg).filter(([, v]) => typeof v === 'object' && v !== null);
620
- const skinList = Array.isArray(s.skins) ? s.skins : (s.skins?.skins || s.skins?.available || []);
621
- const activeSkin = cfg.skin || s.skins?.active || '';
622
- return [
623
- PageHeader({ title: 'config', lede: 'runtime configuration' }),
624
- noteAlert(s.note),
625
- liveRegion(s.busy ? 'saving configuration' : ''),
626
- nested.length ? h('div', { class: 'ds-alert ds-alert-info', role: 'note' },
627
- h('span', { class: 'ds-alert-icon' }, 'i'),
628
- h('div', { class: 'ds-alert-content' }, nested.length + ' nested config ' + (nested.length === 1 ? 'object is' : 'objects are') + ' read-only here (' + nested.map(([k]) => k).join(', ') + ') — edit via the config file or raw view below.')) : null,
629
- skinList.length ? section('skin',
630
- Select({ label: 'active skin', value: activeSkin, options: skinList, onChange: (v) => setSkin(v) })
631
- ) : null,
632
- section('settings', flat.length ? flat.map(([k, v], i) =>
633
- TextField({ key: i, label: k, value: String(ctx.state.edited[k] ?? v ?? ''), onInput: (val) => { ctx.state.edited[k] = val; } })
634
- ) : emptyState('no scalar config keys')),
635
- section('raw', h('pre', { class: 'fd-pre' }, JSON.stringify(cfg, null, 2))),
636
- section('actions',
637
- Btn({ variant: 'primary', disabled: s.busy || !Object.keys(s.edited).length, children: s.busy ? 'saving…' : 'save changes', onClick: save })),
638
- ].filter(Boolean);
639
- };
640
- });
641
-
642
- // ---- env -------------------------------------------------------------------
643
-
644
- export const env = makePage((ctx) => {
645
- Object.assign(ctx.state, { auth: null, vars: null, draft: {}, busy: '', note: null });
646
- async function load() {
647
- try {
648
- const [auth, vars] = await Promise.all([api('/api/auth').catch(() => null), api('/api/env').catch(() => null)]);
649
- ctx.set({ loading: false, auth, vars, error: null });
650
- } catch (e) { ctx.set({ loading: false, error: e }); }
651
- }
652
- // Set a provider key through the dashboard (POST /api/auth). The key is sent
653
- // once and never echoed back — GET /api/auth returns only a masked fingerprint.
654
- async function setKey(provider) {
655
- const key = (ctx.state.draft[provider] || '').trim();
656
- if (!key) { ctx.set({ note: { kind: 'warn', msg: 'key required for ' + provider } }); return; }
657
- ctx.set({ busy: provider, note: null });
658
- try { await api('/api/auth', { method: 'POST', body: { provider, key } }); ctx.state.draft[provider] = ''; await load(); ctx.set({ note: { kind: 'success', msg: 'stored ' + provider } }); }
659
- catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
660
- ctx.set({ busy: '' });
661
- }
662
- async function removeKey(provider) {
663
- ctx.set({ busy: provider, note: null });
664
- try { await api('/api/auth/' + encodeURIComponent(provider), { method: 'DELETE' }); await load(); ctx.set({ note: { kind: 'success', msg: 'removed ' + provider } }); }
665
- catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
666
- ctx.set({ busy: '' });
667
- }
668
- load();
669
- return () => {
670
- const s = ctx.state;
671
- if (s.loading) return loadingState('loading keys…');
672
- if (s.error && !s.auth) return errorState(s.error, load);
673
- const auth = Array.isArray(s.auth) ? s.auth : [];
674
- const vars = Array.isArray(s.vars) ? s.vars : [];
675
- // Non-provider env vars (platform tokens etc) stay a read-only presence table.
676
- const providerEnvs = new Set(auth.map(a => a.env));
677
- const otherRows = vars.filter(v => !providerEnvs.has(v.key)).map(v => [v.key, v.set ? Chip({ tone: 'ok', children: v.source || 'set' }) : Chip({ tone: 'neutral', children: 'unset' })]);
678
- return [
679
- PageHeader({ title: 'keys', lede: 'provider api keys · stored locally, never displayed' }),
680
- noteAlert(s.note),
681
- section('provider keys',
682
- auth.length ? auth.map((a, i) => Row({
683
- key: i, title: a.provider, sub: a.env + (a.set ? ' · ' + a.source + (a.fingerprint ? ' · ' + a.fingerprint : '') : ''),
684
- trailing: h('span', { class: 'fd-row-actions' },
685
- a.set ? Chip({ tone: 'ok', children: 'set' }) : Chip({ tone: 'neutral', children: 'unset' }),
686
- TextField({ type: 'password', value: s.draft[a.provider] || '', onInput: (v) => { s.draft[a.provider] = v; }, placeholder: 'paste key', 'aria-label': 'key for ' + a.provider }),
687
- Btn({ variant: 'primary', disabled: s.busy === a.provider, children: s.busy === a.provider ? '…' : 'save', onClick: () => setKey(a.provider) }),
688
- (a.set && a.source === 'stored') ? Btn({ variant: 'danger', disabled: s.busy === a.provider, children: 'remove', onClick: () => removeKey(a.provider) }) : null),
689
- })) : emptyState('no providers')),
690
- otherRows.length ? section('other environment', Table({ headers: ['key', 'status'], rows: otherRows })) : null,
691
- ].filter(Boolean);
692
- };
693
- });
694
-
695
- // ---- tools -----------------------------------------------------------------
696
-
697
- export const tools = makePage((ctx) => {
698
- Object.assign(ctx.state, { open: null, q: '' });
699
- async function load() { try { ctx.set({ loading: false, list: await api('/api/tools'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
700
- load();
701
- return () => {
702
- const s = ctx.state;
703
- if (s.loading) return loadingState('loading tools…');
704
- if (s.error) return errorState(s.error, load);
705
- let list = Array.isArray(s.list) ? s.list : (s.list?.tools || []);
706
- if (s.q) list = list.filter(t => (t.name || '').includes(s.q));
707
- const groups = {};
708
- for (const t of list) { const g = t.toolset || 'core'; (groups[g] = groups[g] || []).push(t); }
709
- return [
710
- PageHeader({ title: 'tools', lede: list.length + ' tools' }),
711
- SearchInput({ value: s.q, label: 'filter tools', placeholder: 'filter tools…', onInput: (v) => ctx.set({ q: v }) }),
712
- ...Object.entries(groups).map(([g, ts]) => section(g + ' · ' + ts.length, ts.map((t, i) => h('div', { key: i },
713
- Row({ title: t.name, sub: trunc(t.schema?.description || t.description, TRUNC_DESC).text, onClick: () => ctx.set({ open: ctx.state.open === t.name ? null : t.name }), active: ctx.state.open === t.name }),
714
- ctx.state.open === t.name ? h('pre', { class: 'fd-pre' }, JSON.stringify(t.schema || t, null, 2)) : null,
715
- )))),
716
- list.length ? null : emptyState('no tools match'),
717
- ].filter(Boolean);
718
- };
719
- });
720
-
721
- // ---- batch -----------------------------------------------------------------
722
-
723
- export const batch = makePage((ctx) => {
724
- Object.assign(ctx.state, { loading: false, prompts: '', concurrency: 4, busy: false, result: null, note: null });
725
- async function run() {
726
- const prompts = (ctx.state.prompts || '').split('\n').map(x => x.trim()).filter(Boolean);
727
- if (!prompts.length) { ctx.set({ note: { kind: 'warn', msg: 'enter at least one prompt (one per line)' } }); return; }
728
- ctx.set({ busy: true, note: null, result: null });
729
- try { const r = await api('/api/batch', { method: 'POST', body: { prompts, concurrency: Number(ctx.state.concurrency) || 4 } }); ctx.set({ result: r }); }
730
- catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
731
- ctx.set({ busy: false });
732
- }
733
- return () => {
734
- const s = ctx.state;
735
- return [
736
- PageHeader({ title: 'batch', lede: 'parallel prompt runner' }),
737
- noteAlert(s.note),
738
- section('prompts',
739
- TextField({ label: 'prompts (one per line)', value: s.prompts, multiline: true, rows: 6, onInput: (v) => { s.prompts = v; } }),
740
- TextField({ label: 'concurrency', type: 'number', min: 1, 'aria-label': 'batch concurrency', value: String(s.concurrency), onInput: (v) => { s.concurrency = v; } }),
741
- Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'running…' : 'run batch', onClick: run })),
742
- s.result ? section('result', (() => {
743
- const r = s.result;
744
- const items = Array.isArray(r.results) ? r.results : (Array.isArray(r) ? r : null);
745
- if (!items) return h('pre', { class: 'fd-pre' }, JSON.stringify(r, null, 2));
746
- return [
747
- Kpi({ items: [[items.length, 'prompts'], [items.filter(x => !x.error).length, 'ok'], [items.filter(x => x.error).length, 'errors']] }),
748
- Table({ headers: ['#', 'prompt', 'status', 'output'], rows: items.map((x, i) => {
749
- return [String(i + 1), truncSpan(x.prompt || x.input || '', TRUNC_PROMPT), x.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'ok' }), truncSpan(x.error || x.result || x.content || x.output || '', TRUNC_OUTPUT)];
750
- }) }),
751
- ];
752
- })()) : null,
753
- ].filter(Boolean);
754
- };
755
- });
756
-
757
- // ---- gateway ---------------------------------------------------------------
758
-
759
- export const gateway = makePage((ctx) => {
760
- async function load() { try { ctx.set({ loading: false, data: await api('/api/gateway'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
761
- load(); ctx.interval(load, 10000);
762
- return () => {
763
- const s = ctx.state;
764
- if (s.loading) return loadingState('loading gateway…');
765
- if (s.error && !s.data) return errorState(s.error, load);
766
- const d = s.data || {};
767
- const platforms = d.platforms || d;
768
- const rows = Object.entries(platforms).map(([k, v]) => [k, typeof v === 'object' ? (v.running || v.up ? Chip({ tone: 'ok', children: 'up' }) : Chip({ tone: 'miss', children: 'down' })) : String(v)]);
769
- return [
770
- PageHeader({ title: 'gateway', lede: 'messaging platform status' }),
771
- s.error && s.data ? refreshError(s.error) : null,
772
- section('platforms', rows.length ? Table({ headers: ['platform', 'status'], rows }) : emptyState('no platforms configured')),
773
- ].filter(Boolean);
774
- };
775
- });
776
-
777
- // ---- chains (acptoapi) -----------------------------------------------------
778
-
779
- export const chains = makePage((ctx) => {
780
- Object.assign(ctx.state, { name: '', links: '', busy: false, note: null });
781
- async function load() {
782
- try {
783
- const [health, list, cfg] = await Promise.all([
784
- api('/api/acptoapi/health').catch(() => null),
785
- api('/api/acptoapi/chains').catch(() => null),
786
- api('/api/acptoapi/config').catch(() => null),
787
- ]);
788
- ctx.set({ loading: false, health, list, cfg, error: null });
789
- } catch (e) { ctx.set({ loading: false, error: e }); }
790
- }
791
- async function create() {
792
- const name = (ctx.state.name || '').trim();
793
- const links = (ctx.state.links || '').split(',').map(x => x.trim()).filter(Boolean);
794
- if (!name || !links.length) { ctx.set({ note: { kind: 'warn', msg: 'name and comma-separated links required' } }); return; }
795
- ctx.set({ busy: true, note: null });
796
- try { await api('/api/acptoapi/chains', { method: 'POST', body: { name, links } }); ctx.state.name = ''; ctx.state.links = ''; await load(); }
797
- catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
798
- ctx.set({ busy: false });
799
- }
800
- async function del(name) { ctx.set({ busy: true }); try { await api('/api/acptoapi/chains/' + encodeURIComponent(name), { method: 'DELETE' }); await load(); } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); } ctx.set({ busy: false }); }
801
- load();
802
- return () => {
803
- const s = ctx.state;
804
- if (s.loading) return loadingState('loading chains…');
805
- if (s.error && !s.cfg && !s.health) return errorState(s.error, load);
806
- const chainsList = s.list?.chains || s.list || [];
807
- const up = s.health && (s.health.ok || s.health.status === 'ok' || s.health.healthy);
808
- return [
809
- PageHeader({ title: 'chains', lede: 'acptoapi fallback chains', right: up ? Chip({ tone: 'ok', children: 'acptoapi up' }) : Chip({ tone: 'miss', children: 'acptoapi down' }) }),
810
- noteAlert(s.note),
811
- section('chains', Array.isArray(chainsList) && chainsList.length ? chainsList.map((c, i) => Row({
812
- key: i, title: c.name || c, sub: Array.isArray(c.links) ? c.links.join(' -> ') : '',
813
- trailing: Btn({ variant: 'danger', children: 'delete', onClick: () => del(c.name || c) }),
814
- })) : emptyState('no chains defined')),
815
- section('new chain',
816
- TextField({ label: 'name', value: s.name, onInput: (v) => { s.name = v; } }),
817
- TextField({ label: 'links (comma-separated models)', value: s.links, onInput: (v) => { s.links = v; }, placeholder: 'mistral/large, openrouter/auto' }),
818
- Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'create chain', onClick: create })),
819
- s.cfg ? section('config', h('pre', { class: 'fd-pre' }, JSON.stringify(s.cfg, null, 2))) : null,
820
- ].filter(Boolean);
821
- };
822
- });
823
-
824
- // ---- machines --------------------------------------------------------------
825
-
826
- export const machines = makePage((ctx) => {
827
- async function load() { try { ctx.set({ loading: false, data: await api('/api/machines'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
828
- load(); ctx.interval(load, 8000);
829
- return () => {
830
- const s = ctx.state;
831
- if (s.loading) return loadingState('loading machines…');
832
- if (s.error && !s.data) return errorState(s.error, load);
833
- const d = s.data || {};
834
- const list = Array.isArray(d) ? d : (d.machines || Object.entries(d).map(([kind, v]) => ({ kind, ...(typeof v === 'object' ? v : { value: v }) })));
835
- return [
836
- PageHeader({ title: 'machines', lede: 'persisted xstate machine census' }),
837
- s.error && s.data ? refreshError(s.error) : null,
838
- section('machines', list.length ? Table({
839
- headers: ['kind', 'key', 'state'],
840
- rows: list.map(m => [m.kind || '—', m.key || m.machine_id || '—', m.state || m.value || truncJson(m)]),
841
- }) : emptyState('no live machines')),
842
- ].filter(Boolean);
843
- };
844
- });
845
-
846
- // ---- health ----------------------------------------------------------------
847
-
848
- export const health = makePage((ctx) => {
849
- async function load() {
850
- try {
851
- const [health, providers] = await Promise.all([
852
- api('/api/health').catch(() => null),
853
- api('/api/providers').catch(() => null),
854
- ]);
855
- ctx.set({ loading: false, health, providers, error: null });
856
- } catch (e) { ctx.set({ loading: false, error: e }); }
857
- }
858
- load(); ctx.interval(load, 15000);
859
- return () => {
860
- const s = ctx.state;
861
- if (s.loading) return loadingState('loading health…');
862
- if (s.error && !s.health && !s.providers) return errorState(s.error, load);
863
- const hd = s.health || {};
864
- const provs = Array.isArray(s.providers) ? s.providers : (s.providers?.providers || []);
865
- return [
866
- PageHeader({ title: 'health', lede: 'system & provider health', right: hd.ok ? Chip({ tone: 'ok', children: 'healthy' }) : Chip({ tone: 'miss', children: 'degraded' }) }),
867
- s.error && (s.health || s.providers) ? refreshError(s.error) : null,
868
- section('checks', Object.keys(hd).length ? Table({ headers: ['check', 'status'], rows: Object.entries(hd).map(([k, v]) => [k, typeof v === 'object' ? truncJson(v) : (v === true ? Chip({ tone: 'ok', children: 'ok' }) : v === false ? Chip({ tone: 'miss', children: 'no' }) : String(v))]) }) : emptyState('no health data')),
869
- provs.length ? section('providers', Table({ headers: ['provider', 'status'], rows: provs.map(p => { const n = typeof p === 'string' ? p : p.name || p.id; const ok = typeof p === 'object' ? (p.ok ?? p.available) : null; return [n, ok == null ? '—' : (ok ? Chip({ tone: 'ok', children: 'up' }) : Chip({ tone: 'miss', children: 'down' }))]; }) })) : null,
870
- ].filter(Boolean);
871
- };
872
- });
873
-
874
- // ---- debug -----------------------------------------------------------------
875
-
876
- // ---- logs --------------------------------------------------------------
877
-
878
- const LOG_SEVERITY_TONE = { error: 'error', warning: 'warning', info: 'accent', debug: 'muted' };
879
-
880
- export const logs = makePage((ctx) => {
881
- Object.assign(ctx.state, { lines: [], subsystems: [], activeSubsystem: '', activeSeverity: '', q: '', connected: false, wsError: null });
882
- const MAX_LINES = 500;
883
-
884
- async function loadSubsystems() {
885
- try { ctx.set({ subsystems: await api('/api/logs') }); }
886
- catch (e) { /* swallow: non-fatal, subsystem list is a filter convenience, not required for the stream */ }
887
- }
888
-
889
- let unmounted = false;
890
- let reconnectTimer = null;
891
- function connect() {
892
- if (unmounted) return;
893
- if (typeof WebSocket === 'undefined') { ctx.set({ wsError: new Error('WebSocket not available in this environment') }); return }
894
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
895
- const ws = new WebSocket(proto + '//' + location.host + '/api/logs/stream');
896
- ws.onopen = () => ctx.set({ connected: true, wsError: null });
897
- ws.onerror = () => ctx.set({ connected: false, wsError: new Error('log stream connection error') });
898
- ws.onclose = () => { ctx.set({ connected: false }); if (!unmounted) reconnectTimer = setTimeout(connect, 3000); };
899
- ws.onmessage = (ev) => {
900
- let rec; try { rec = JSON.parse(ev.data); } catch { return; }
901
- const next = [rec, ...ctx.state.lines].slice(0, MAX_LINES);
902
- ctx.set({ lines: next });
903
- };
904
- currentWs = ws;
905
- }
906
- let currentWs = null;
907
-
908
- loadSubsystems();
909
- connect();
910
- registerDebug('logs', () => ({ connected: ctx.state.connected, lineCount: ctx.state.lines.length, activeSubsystem: ctx.state.activeSubsystem, activeSeverity: ctx.state.activeSeverity }));
911
- ctx.onCleanup(() => {
912
- unmounted = true;
913
- if (reconnectTimer) clearTimeout(reconnectTimer);
914
- try { currentWs?.close(); } catch { /* swallow: teardown-only close, socket may already be closed/closing */ }
915
- unregisterDebug('logs');
916
- });
917
-
918
- function filtered() {
919
- const s = ctx.state;
920
- return s.lines.filter((l) => {
921
- if (s.activeSubsystem && l.subsystem !== s.activeSubsystem) return false;
922
- if (s.activeSeverity && l.severity !== s.activeSeverity) return false;
923
- if (s.q && !String(l.msg || '').toLowerCase().includes(s.q.toLowerCase())) return false;
924
- return true;
925
- });
926
- }
927
-
928
- return () => {
929
- const s = ctx.state;
930
- const rows = filtered();
931
- const severities = ['error', 'warning', 'info', 'debug'];
932
- return [
933
- PageHeader({
934
- title: 'logs', lede: 'live JSONL log tail — /api/logs/stream',
935
- right: s.connected ? Chip({ tone: 'ok', children: 'live' }) : Chip({ tone: 'miss', children: 'reconnecting…' }),
936
- }),
937
- s.wsError ? refreshError(s.wsError) : null,
938
- h('div', { class: 'ds-toolbar' },
939
- SearchInput({ value: s.q, placeholder: 'filter by message…', onInput: (v) => ctx.set({ q: v }), resultCount: rows.length }),
940
- Select({
941
- label: 'subsystem', value: s.activeSubsystem, placeholder: 'all subsystems',
942
- options: (s.subsystems || []).map((name) => ({ value: name, label: name })),
943
- onChange: (v) => ctx.set({ activeSubsystem: v }),
944
- }),
945
- Select({
946
- label: 'severity', value: s.activeSeverity, placeholder: 'all severities',
947
- options: severities.map((sv) => ({ value: sv, label: sv })),
948
- onChange: (v) => ctx.set({ activeSeverity: v }),
949
- }),
950
- ),
951
- rows.length ? section('lines · ' + rows.length,
952
- Table({
953
- headers: ['time', 'subsystem', 'severity', 'message'],
954
- rows: rows.map((l) => [
955
- formatTime(l.ts ? Date.parse(l.ts) : Date.now()),
956
- l.subsystem || '—',
957
- Chip({ tone: LOG_SEVERITY_TONE[l.severity] || 'dim', children: l.severity || 'info' }),
958
- truncSpan(l.msg, TRUNC_DESC),
959
- ]),
960
- }),
961
- ) : emptyState(s.connected ? 'no log lines yet — waiting for activity' : 'connecting to log stream…'),
962
- ].filter(Boolean);
963
- };
964
- });
965
-
966
- // ---- debug -----------------------------------------------------------------
967
-
968
- export const debug = makePage((ctx) => {
969
- Object.assign(ctx.state, { sub: null, logs: null });
970
- async function load() { try { ctx.set({ loading: false, data: await api('/api/debug'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
971
- async function loadLogs(name) {
972
- ctx.set({ sub: name });
973
- try { ctx.set({ logs: await api('/api/logs/' + encodeURIComponent(name)) }); }
974
- catch (e) { ctx.set({ logs: { error: String(e.message || e) } }); }
975
- }
976
- load();
977
- return () => {
978
- const s = ctx.state;
979
- if (s.loading) return loadingState('loading debug snapshots…');
980
- if (s.error) return errorState(s.error, load);
981
- const d = s.data || {};
982
- const subsystems = d.subsystems || Object.keys(d);
983
- return [
984
- PageHeader({ title: 'debug', lede: 'subsystem snapshots & logs' }),
985
- section('subsystems', subsystems.length ? subsystems.map((name, i) => Row({
986
- key: i, title: name, onClick: () => loadLogs(name), active: s.sub === name,
987
- })) : emptyState('no debug subsystems')),
988
- s.sub ? section('logs · ' + s.sub, h('pre', { class: 'fd-pre' }, JSON.stringify(s.logs, null, 2))) : null,
989
- ].filter(Boolean);
990
- };
991
- });
992
-
993
- // ---- git ---------------------------------------------------------------
994
-
995
- export const git = makePage((ctx) => {
996
- Object.assign(ctx.state, { cwd: null, status: null, log: null, worktrees: null, diff: null, activeFile: null, diffLoading: false, note: null });
997
- async function load() {
998
- try {
999
- const proj = await api('/api/projects').catch(() => null);
1000
- const active = proj && proj.active;
1001
- const cwd = (active && typeof active === 'object' ? active.path : null) || ctx.state.cwd || '';
1002
- const qs = '?cwd=' + encodeURIComponent(cwd);
1003
- const [status, log, worktrees] = await Promise.all([
1004
- api('/api/git/status' + qs).catch((e) => ({ _err: e })),
1005
- api('/api/git/log' + qs + '&limit=20').catch((e) => ({ _err: e })),
1006
- api('/api/worktree' + qs).catch((e) => ({ _err: e })),
1007
- ]);
1008
- ctx.set({ loading: false, cwd, status, log, worktrees, error: null });
1009
- } catch (e) { ctx.set({ loading: false, error: e }); }
1010
- }
1011
- async function openDiff(file) {
1012
- ctx.set({ activeFile: file.path, diffLoading: true, diff: null });
1013
- try {
1014
- const qs = '?cwd=' + encodeURIComponent(ctx.state.cwd || '') + '&file=' + encodeURIComponent(file.path);
1015
- const res = await api('/api/git/diff' + qs);
1016
- ctx.set({ diff: res, diffLoading: false });
1017
- } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) }, diffLoading: false }); }
1018
- }
1019
- async function createWorktree() {
1020
- const path = (ctx.state.newWtPath || '').trim();
1021
- const branch = (ctx.state.newWtBranch || '').trim();
1022
- if (!path) { ctx.set({ note: { kind: 'warn', msg: 'path required' } }); return; }
1023
- ctx.set({ busy: true, note: null });
1024
- try {
1025
- await api('/api/worktree', { method: 'POST', body: { cwd: ctx.state.cwd || '', path, branch: branch || undefined } });
1026
- ctx.state.newWtPath = ''; ctx.state.newWtBranch = '';
1027
- await load();
1028
- } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
1029
- ctx.set({ busy: false });
1030
- }
1031
- load();
1032
- return () => {
1033
- const s = ctx.state;
1034
- if (s.loading) return loadingState('loading git status…');
1035
- if (s.error && !s.status) return errorState(s.error, load);
1036
- const statusFailed = s.status && s.status._err;
1037
- const logFailed = s.log && s.log._err;
1038
- const wtFailed = s.worktrees && s.worktrees._err;
1039
- const files = statusFailed ? [] : (s.status && s.status.files) || [];
1040
- const commits = logFailed ? [] : (s.log && s.log.commits) || s.log || [];
1041
- const worktrees = wtFailed ? [] : (s.worktrees && s.worktrees.worktrees) || s.worktrees || [];
1042
- const current = Array.isArray(worktrees) ? (worktrees.find(w => w.path === s.cwd) || {}).path : undefined;
1043
- return [
1044
- PageHeader({ title: 'git', lede: s.cwd || 'active project' }),
1045
- noteAlert(s.note),
1046
- statusFailed ? refreshError(statusFailed) : null,
1047
- section('worktrees',
1048
- WorktreeSwitcher({
1049
- worktrees: Array.isArray(worktrees) ? worktrees : [],
1050
- current,
1051
- onSwitch: () => {},
1052
- onCreate: () => ctx.set({ showWtForm: !s.showWtForm }),
1053
- }),
1054
- s.showWtForm ? h('div', { class: 'fd-row-actions' },
1055
- TextField({ label: 'path', value: s.newWtPath, onInput: (v) => { s.newWtPath = v; }, placeholder: '/path/to/worktree' }),
1056
- TextField({ label: 'branch (optional)', value: s.newWtBranch, onInput: (v) => { s.newWtBranch = v; }, placeholder: 'feature/x' }),
1057
- Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'create', onClick: createWorktree })) : null),
1058
- section('changed files',
1059
- statusFailed ? errorState(statusFailed) : GitStatusPanel({ files, onFileClick: openDiff, active: s.activeFile })),
1060
- section('diff' + (s.activeFile ? ' · ' + s.activeFile : ''),
1061
- s.diffLoading ? loadingState('loading diff…')
1062
- : s.diff ? GitDiffView({ diff: s.diff.diff || s.diff, filename: s.activeFile })
1063
- : emptyState('select a file to view its diff')),
1064
- section('log',
1065
- logFailed ? errorState(logFailed)
1066
- : commits.length
1067
- ? Table({ headers: ['sha', 'message', 'author', 'date'], rows: commits.slice(0, 20).map(c => [String(c.sha || c.hash || '').slice(0, 8), truncSpan(c.message || c.subject, TRUNC_SUB), c.author || '', c.date ? fmtAgo(c.date) : ''])})
1068
- : emptyState('no commits')),
1069
- ].filter(Boolean);
1070
- };
1071
- });
12
+ import { home, agents, analytics } from './freddie/pages-overview.js';
13
+ import { chat, voice } from './freddie/pages-chat.js';
14
+ import { sessions, projects, git } from './freddie/pages-workspace.js';
15
+ import { models, skills, plugins, config, env } from './freddie/pages-config.js';
16
+ import { cron, tools, batch } from './freddie/pages-runners.js';
17
+ import { gateway, chains, machines, health } from './freddie/pages-infra.js';
18
+ import { logs, debug } from './freddie/pages-telemetry.js';
1072
19
 
1073
20
  // ---- registry --------------------------------------------------------------
1074
21
 
@@ -1078,4 +25,14 @@ export const FREDDIE_PAGES = {
1078
25
  machines, health, debug, logs, git,
1079
26
  };
1080
27
 
28
+ export {
29
+ home, agents, analytics,
30
+ chat, voice,
31
+ sessions, projects, git,
32
+ models, skills, plugins, config, env,
33
+ cron, tools, batch,
34
+ gateway, chains, machines, health,
35
+ logs, debug,
36
+ };
37
+
1081
38
  export { skillLabel, getRecentPaths, saveRecentPath, renderChatMessages };