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.
- package/community.css +21 -18
- package/dist/247420.css +107 -49
- package/dist/247420.js +23 -23
- package/package.json +1 -1
- package/scripts/lint-css.mjs +24 -10
- package/src/components/chat/message.js +130 -0
- package/src/components/chat/stats.js +36 -0
- package/src/components/chat/thread-scroll.js +42 -0
- package/src/components/freddie/pages-chat.js +149 -0
- package/src/components/freddie/pages-config.js +203 -0
- package/src/components/freddie/pages-infra.js +120 -0
- package/src/components/freddie/pages-overview.js +107 -0
- package/src/components/freddie/pages-runners.js +102 -0
- package/src/components/freddie/pages-telemetry.js +126 -0
- package/src/components/freddie/pages-workspace.js +176 -0
- package/src/components/freddie/shared.js +32 -0
- package/src/components/freddie/sse.js +85 -0
- package/src/components/freddie.js +21 -1064
- package/src/components/shell/app-shell.js +195 -0
- package/src/components/shell/atoms.js +165 -0
- package/src/components/shell/icons.js +123 -0
- package/src/components/shell/workspace-columns.js +179 -0
- package/src/components/shell/workspace-shell.js +181 -0
- package/src/components/shell.js +15 -805
- package/src/css/app-shell/chat-polish.css +18 -6
- package/src/css/app-shell/files.css +15 -4
- package/src/css/app-shell/hero-content.css +16 -1
- package/src/css/app-shell/kits-appended.css +26 -15
|
@@ -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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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 };
|