freddie 0.0.73 → 0.0.75

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/src/web/app.js CHANGED
@@ -1,578 +1,80 @@
1
- import ds, { mount, installStyles, h, components, renderMarkdown, motion } from 'anentrypoint-design'
2
- const { AppShell, Topbar, Crumb, Side, Status, Panel, Row, Btn, Chip, Chat, ChatComposer, ChatMessage, AICat,
3
- Brand, EmptyState, RowLink, Receipt, Changelog, Hero, ConfirmDialog, Section, Install, Kpi, Table } = components
4
- const kpi = (items) => Kpi({ items })
5
- const table = (headers, rows, opts = {}) => Table({ headers, rows, onRowClick: opts.onRowClick })
1
+ import { createFreddieDashboard } from '/vendor/anentrypoint-design/desktop/freddie-dashboard.js';
2
+ import { installStyles } from 'anentrypoint-design';
6
3
 
7
- await installStyles()
4
+ await installStyles();
8
5
 
9
- if (!window.__debug) { try { window.__debug = {} } catch { Object.defineProperty(window, '__debug', { value: {}, writable: true, configurable: true }) } }
10
- window.__debug.dashboard = () => ({ booted: true, ts: Date.now(), framework: 'anentrypoint-design+webjsx', route: location.hash || '#/sessions' })
11
- window.__debug.agents = () => ({ registered: true, active: AppState.agents?.active || null, count: AppState.agents?.count || 0 })
6
+ const link = document.createElement('link');
7
+ link.rel = 'stylesheet';
8
+ link.href = '/vendor/anentrypoint-design/desktop/freddie-dashboard.css';
9
+ document.head.appendChild(link);
12
10
 
13
- const j = async (u, opts) => { try { const r = await fetch(u, opts); if (!r.ok) throw new Error(r.status + ' ' + r.statusText); return await r.json() } catch (e) { return { __error: String(e) } } }
11
+ const j = async (u, opts) => { const r = await fetch(u, opts); if (!r.ok) throw new Error(r.status + ' ' + r.statusText); return await r.json(); };
12
+ const post = (u, body) => j(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
14
13
 
15
- const ROUTES = [
16
- { path: '#/projects', label: 'Projects', glyph: '◆' },
17
- { path: '#/home', label: 'Home', glyph: '⌂' },
18
- { path: '#/chat', label: 'Chat', glyph: '⌨' },
19
- { path: '#/sessions', label: 'Sessions', glyph: '✉' },
20
- { path: '#/agents', label: 'Agents', glyph: '◈' },
21
- { path: '#/analytics', label: 'Analytics', glyph: '◉' },
22
- { path: '#/models', label: 'Models', glyph: '◎' },
23
- { path: '#/logs', label: 'Logs', glyph: '☰' },
24
- { path: '#/cron', label: 'Cron', glyph: '◷' },
25
- { path: '#/skills', label: 'Skills', glyph: '◈' },
26
- { path: '#/config', label: 'Config', glyph: '⚙' },
27
- { path: '#/env', label: 'Keys', glyph: '⚿' },
28
- { path: '#/tools', label: 'Tools', glyph: '⚒' },
29
- { path: '#/batch', label: 'Batch', glyph: '⊞' },
30
- { path: '#/gateway', label: 'Gateway', glyph: '⇌' },
31
- ]
32
- window.__debug.routes = () => ROUTES.map(r => r.path)
33
-
34
- const AppState = {
35
- hash: location.hash || '#/home',
36
- body: null,
37
- ts: new Date().toLocaleTimeString(),
38
- theme: localStorage.getItem('freddie-theme') || 'dark',
39
- search: { query: '', results: [] },
40
- sessionsFilter: '',
41
- chat: { messages: [], draft: '', streaming: false },
42
- batch: { results: null, running: false },
43
- agents: { count: 0, active: null },
44
- projects: { active: null, all: [] },
45
- }
46
- function applyTheme() { document.body.setAttribute('data-theme', AppState.theme) }
47
- applyTheme()
48
- window.__debug.state = () => AppState
49
-
50
- function pre(obj) { return h('pre', {}, typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2)) }
51
-
52
- function timeNow() {
53
- const d = new Date()
54
- return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
55
- }
56
-
57
- function toChatMsg(m, key) {
58
- const time = m.time || ''
59
- if (m.role === 'user') {
60
- return { who: 'you', avatar: 'u', time, receipt: 'delivered', key,
61
- parts: [{ kind: 'text', text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }] }
62
- }
63
- if (m.role === 'tool') {
64
- const body = typeof m.content === 'string' ? m.content : JSON.stringify(m.content, null, 2)
65
- return { who: 'them', avatar: '⚒', name: 'tool' + (m.tool_call_id ? ' · ' + String(m.tool_call_id).slice(0, 8) : ''), time, key,
66
- parts: [{ kind: 'code', lang: 'json', filename: 'tool result', code: body }] }
67
- }
68
- const parts = []
69
- const text = typeof m.content === 'string' ? m.content : ''
70
- if (text) parts.push({ kind: 'md', text })
71
- if (Array.isArray(m.tool_calls) && m.tool_calls.length) {
72
- for (const c of m.tool_calls) {
73
- parts.push({ kind: 'code', lang: 'json', filename: 'call · ' + (c.name || c.function?.name || '?'),
74
- code: JSON.stringify(c.arguments || c.function?.arguments || {}, null, 2) })
75
- }
76
- }
77
- if (parts.length === 0) parts.push({ kind: 'text', text: '' })
78
- return { who: 'them', avatar: '◉', name: 'freddie', time, key, parts }
79
- }
80
-
81
- const PAGES = {
82
- '#/projects': async () => {
83
- const data = await j('/api/projects')
84
- const all = data.projects || []
85
- const active = data.active || null
86
- AppState.projects = { active, all }
87
- return [
88
- Hero({ title: 'Projects', body: 'Each project is its own ~/.freddie home: separate sessions, agents, skills, config, env, cron, batches. Switch the active project to swap everything.', accent: active ? 'active · ' + active.name : 'no project active' }),
89
- kpi([[all.length, 'Projects'], [active?.name || '—', 'Active'], [active?.path?.length > 30 ? '…'+active.path.slice(-28) : (active?.path || '—'), 'Path']]),
90
- Panel({ title: 'Add a project', children: h('form', { class: 'row-form', onsubmit: async (ev) => {
91
- ev.preventDefault()
92
- const f = ev.target.elements
93
- const r = await j('/api/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: f.name.value, path: f.path.value }) })
94
- if (r.__error || r.error) { alert('Error: ' + (r.error || r.__error)); return }
95
- f.name.value = ''; f.path.value = ''; rerender()
96
- } },
97
- h('input', { name: 'name', placeholder: 'project name (e.g. penguins)', required: true }),
98
- h('input', { name: 'path', placeholder: 'absolute path (e.g. C:\\dev\\penguins)', required: true, style: 'flex:2' }),
99
- h('button', { type: 'submit', class: 'primary' }, 'Add')) }),
100
- Panel({ title: 'All projects', count: all.length, right: active ? Chip({ tone: 'ok', children: 'active: ' + active.name }) : null,
101
- children: h('div', {}, ...all.map(p => h('div', { class: 'row', style: 'cursor:pointer', onclick: async () => {
102
- if (p.name === active?.name) return
103
- const r = await j('/api/projects/active', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: p.name }) })
104
- if (r.__error || r.error) { alert('Switch failed: ' + (r.error || r.__error)); return }
105
- alert('Switched to project "' + p.name + '". Restart the dashboard to load its plugins/data, then reload this page.')
106
- rerender()
107
- } },
108
- h('span', { class: 'code' }, p.name === active?.name ? '●' : '○'),
109
- h('span', { class: 'title' }, p.name + (p.name === active?.name ? ' (active)' : '')),
110
- h('span', { class: 'meta' }, p.path),
111
- p.name !== 'default' ? h('button', { class: 'danger', style: 'margin-left:8px',
112
- onclick: async (ev) => { ev.stopPropagation(); if (!confirm('Remove project "'+p.name+'" from registry? Files on disk are kept.')) return; await fetch('/api/projects/' + p.name, { method: 'DELETE' }); rerender() } }, 'remove') : null,
113
- ))) }),
114
- Panel({ title: 'How encapsulation works', children: Receipt({ rows: [
115
- ['Sessions DB', '<project>/sessions.db'],
116
- ['Config', '<project>/config.json'],
117
- ['Skills', '<project>/skills/'],
118
- ['Plugins', '<project>/plugins/ + repo/plugins'],
119
- ['Cron jobs', '<project>/cron.db'],
120
- ['Batches', '<project>/batches/'],
121
- ['Logs', '<project>/logs/'],
122
- ['Auth credentials', '<project>/auth.json'],
123
- ]}) }),
124
- ]
125
- },
126
-
127
- '#/chat': async () => {
128
- const messages = AppState.chat.messages.map((m, i) => toChatMsg(m, 'm' + i))
129
- return AICat({
130
- name: 'freddie',
131
- status: AppState.chat.streaming ? 'thinking…' : 'online · live runTurn via SSE',
132
- messages,
133
- thinking: AppState.chat.streaming,
134
- composer: ChatComposer({
135
- value: AppState.chat.draft,
136
- placeholder: 'Ask freddie — runs through registered tools and the configured LLM…',
137
- disabled: AppState.chat.streaming,
138
- onInput: (v) => { AppState.chat.draft = v; rerender() },
139
- onSend: (text) => { AppState.chat.draft = ''; sendChat(text) },
140
- }),
141
- })
142
- },
143
-
144
- '#/home': async () => {
145
- const [sessions, tools, skills] = await Promise.all([j('/api/sessions'), j('/api/tools'), j('/api/skills')])
146
- const sessionCount = Array.isArray(sessions) ? sessions.length : 0
147
- const toolCount = Array.isArray(tools) ? tools.length : 0
148
- const skillCount = ((skills.home || []).length + (skills.bundled || []).length)
149
- return [
150
- Hero({ title: 'freddie', body: 'Open JS agent harness built on pi-mono, xstate, floosie, and anentrypoint-design.', accent: 'v0.0.1' }),
151
- kpi([[sessionCount, 'Sessions'], [toolCount, 'Tools'], [skillCount, 'Skills']]),
152
- Panel({ title: 'Quick start', children: Receipt({ rows: [
153
- ['Run interactive REPL', 'freddie run'],
154
- ['Start dashboard', 'freddie dashboard --port 3000'],
155
- ['List tools', 'freddie tools'],
156
- ['List skills', 'freddie skills'],
157
- ['Start gateway', 'freddie gateway --port 4000'],
158
- ]}) }),
159
- ]
160
- },
161
-
162
- '#/sessions': async () => {
163
- const sessions = await j('/api/sessions')
164
- const all = sessions.__error ? [] : sessions
165
- const q = AppState.sessionsFilter.toLowerCase()
166
- const filtered = q ? all.filter(s => JSON.stringify(s).toLowerCase().includes(q)) : all
167
- return [
168
- kpi([[all.length || 0, 'Total sessions'], [filtered.length, 'After filter']]),
169
- Panel({ title: 'Filter', children: h('div', { class: 'row-form' },
170
- h('input', { type: 'text', placeholder: 'filter by platform/title/model/id…', value: AppState.sessionsFilter,
171
- oninput: (ev) => { AppState.sessionsFilter = ev.target.value; rerender() } })) }),
172
- Panel({ title: 'Recent sessions (click row → detail)', count: filtered.length,
173
- children: filtered.length === 0
174
- ? EmptyState({ text: 'no sessions yet — start a chat', glyph: '✉' })
175
- : h('div', {}, ...filtered.map(s =>
176
- RowLink({ key: s.id, href: '#/session/' + s.id,
177
- code: s.id?.slice(0, 8), title: s.title || s.platform || 'untitled',
178
- sub: s.model || '', meta: new Date(s.updated_at || 0).toLocaleString() }))) }),
179
- ]
180
- },
181
-
182
- '#/agents': async () => {
183
- const agents = await j('/api/agents')
184
- const count = agents.__error ? 0 : (agents.count || 0)
185
- const active = agents.active || null
186
- return [
187
- kpi([[count || 0, 'Total agents'], [active ? 1 : 0, 'Active']]),
188
- Panel({ title: 'Agent overview', children: Receipt({ rows: [
189
- ['Total agent turns', String(agents.turns || 0)],
190
- ['Active agent', active || '(none)'],
191
- ['Last activity', agents.last_activity ? new Date(agents.last_activity).toLocaleString() : '—'],
192
- ]}) }),
193
- ]
194
- },
195
-
196
- '#/analytics': async () => {
197
- const [sessions, tools, debug] = await Promise.all([j('/api/sessions'), j('/api/tools'), j('/api/debug')])
198
- const all = Array.isArray(sessions) ? sessions : []
199
- const ts = Array.isArray(tools) ? tools : []
200
- const byPlatform = all.reduce((acc, s) => { const k = s.platform || 'unknown'; acc[k] = (acc[k] || 0) + 1; return acc }, {})
201
- const byModel = all.reduce((acc, s) => { const k = s.model || 'unknown'; acc[k] = (acc[k] || 0) + 1; return acc }, {})
202
- return [
203
- kpi([
204
- [all.length || 0, 'Sessions'],
205
- [ts.length || 0, 'Tools'],
206
- [Array.isArray(debug) ? debug.length : 0, 'Debug subsystems'],
207
- ]),
208
- Panel({ title: 'Sessions by platform', children: Object.keys(byPlatform).length === 0
209
- ? EmptyState({ text: 'no sessions yet', glyph: '◉' })
210
- : table(['platform', 'count'], Object.entries(byPlatform).sort((a,b) => b[1]-a[1])) }),
211
- Panel({ title: 'Sessions by model', children: Object.keys(byModel).length === 0
212
- ? EmptyState({ text: 'no sessions yet', glyph: '◎' })
213
- : table(['model', 'count'], Object.entries(byModel).sort((a,b) => b[1]-a[1])) }),
214
- Panel({ title: 'Tool distribution by toolset', children: table(['toolset', 'count', 'tools'],
215
- Object.entries(ts.reduce((acc, t) => { acc[t.toolset] = acc[t.toolset] || []; acc[t.toolset].push(t.name); return acc }, {}))
216
- .map(([k, v]) => [k, v.length, v.slice(0,4).join(', ') + (v.length > 4 ? '…' : '')])) }),
217
- ]
218
- },
219
-
220
- '#/models': async () => {
221
- const config = await j('/api/config')
222
- const agent = config.agent || {}
223
- return [
224
- kpi([[agent.provider || '—', 'Provider'], [agent.model || '—', 'Model']]),
225
- Panel({ title: 'Active model config', children: Receipt({ rows: [
226
- ['provider', agent.provider || '(not set)'],
227
- ['model', agent.model || '(not set)'],
228
- ['max_iterations', String(agent.max_iterations || '—')],
229
- ['max_tokens', String(agent.max_tokens || '—')],
230
- ['temperature', String(agent.temperature ?? '—')],
231
- ]}) }),
232
- Panel({ title: 'Change model', children: h('form', { class: 'row-form', onsubmit: async (ev) => {
233
- ev.preventDefault()
234
- const f = ev.target.elements
235
- await j('/api/config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'agent.model', value: f.model.value }) })
236
- await j('/api/config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'agent.provider', value: f.provider.value }) })
237
- rerender()
238
- } },
239
- h('input', { name: 'provider', placeholder: 'provider (anthropic / openai / groq)', value: agent.provider || '' }),
240
- h('input', { name: 'model', placeholder: 'model id (e.g. claude-opus-4-5)', value: agent.model || '' }),
241
- h('button', { type: 'submit', class: 'primary' }, 'Update')) }),
242
- ]
243
- },
244
-
245
- '#/logs': async () => {
246
- const subs = await j('/api/logs')
247
- const list = Array.isArray(subs) ? subs : []
248
- const first = list[0]
249
- const recent = first ? await j(`/api/logs/${first}?max=50`) : []
250
- return [
251
- kpi([[list.length, 'Log subsystems']]),
252
- Panel({ title: 'Subsystems', children: list.length === 0
253
- ? EmptyState({ text: 'no logs yet — run freddie and observe', glyph: '☰' })
254
- : h('div', {}, ...list.map(s => Row({ key: s, code: '☰', title: s, meta: '' }))) }),
255
- first
256
- ? Panel({ title: `Latest entries · ${first}`, children: pre(recent) })
257
- : null,
258
- ].filter(Boolean)
259
- },
260
-
261
- '#/cron': async () => {
262
- const jobs = await j('/api/cron')
263
- const list = Array.isArray(jobs) ? jobs : []
264
- return [
265
- kpi([[list.length, 'Cron jobs']]),
266
- Panel({ title: 'Add job', children: h('form', { class: 'row-form', onsubmit: async (ev) => {
267
- ev.preventDefault()
268
- const f = ev.target.elements
269
- await j('/api/cron', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cron: f.cron.value, prompt: f.prompt.value }) })
270
- f.cron.value = ''; f.prompt.value = ''; rerender()
271
- } },
272
- h('input', { name: 'cron', placeholder: 'cron expr (* * * * *)' }),
273
- h('input', { name: 'prompt', placeholder: 'prompt' }),
274
- h('button', { type: 'submit', class: 'primary' }, 'Create')) }),
275
- Panel({ title: 'Scheduled jobs', count: list.length, children: list.length === 0
276
- ? EmptyState({ text: 'no cron jobs — add one above', glyph: '◷' })
277
- : h('table', {},
278
- h('thead', {}, h('tr', {}, ...['id', 'cron', 'prompt', 'enabled', ''].map(c => h('th', {}, c)))),
279
- h('tbody', {}, ...list.map(job => h('tr', {},
280
- h('td', {}, String(job.id)),
281
- h('td', {}, job.cron),
282
- h('td', {}, (job.prompt || '').slice(0, 60)),
283
- h('td', {}, job.enabled ? 'yes' : 'no'),
284
- h('td', {}, h('button', {
285
- class: 'danger',
286
- onclick: async () => { await fetch('/api/cron/' + job.id, { method: 'DELETE' }); rerender() }
287
- }, 'delete')))))) }),
288
- ]
289
- },
290
-
291
- '#/skills': async () => {
292
- const data = await j('/api/skills')
293
- const home = data.home || []
294
- const bundled = data.bundled || []
295
- return [
296
- kpi([[home.length, 'User skills'], [bundled.length, 'Bundled skills']]),
297
- Panel({ title: 'User skills (~/.freddie/skills)', count: home.length,
298
- children: home.length === 0
299
- ? EmptyState({ text: 'drop SKILL.md files in ~/.freddie/skills/ to add', glyph: '◈' })
300
- : h('div', {}, ...home.map(s => Row({ key: s.name, code: '◈', title: s.name, sub: s.description || '', meta: '' }))) }),
301
- Panel({ title: 'Bundled skills', count: bundled.length,
302
- children: h('div', {}, ...bundled.map(s => Row({ key: s.name, code: '◈', title: s.name, sub: s.description || '', meta: '' }))) }),
303
- ]
304
- },
305
-
306
- '#/config': async () => {
307
- const config = await j('/api/config')
308
- const profiles = await j('/api/profiles')
309
- const commands = await j('/api/commands')
310
- return [
311
- kpi([
312
- [(profiles || []).length, 'Profiles'],
313
- [(commands || []).length, 'Commands'],
314
- [config._config_version || 0, 'Config version'],
315
- ]),
316
- Panel({ title: 'Set config value', children: h('form', { class: 'row-form', onsubmit: async (ev) => {
317
- ev.preventDefault()
318
- const f = ev.target.elements
319
- await j('/api/config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: f.key.value, value: f.value.value }) })
320
- f.value.value = ''; rerender()
321
- } },
322
- h('input', { name: 'key', placeholder: 'dotted.key (e.g. display.skin)' }),
323
- h('input', { name: 'value', placeholder: 'value' }),
324
- h('button', { type: 'submit', class: 'primary' }, 'Save')) }),
325
- Panel({ title: 'Profiles', count: (profiles || []).length,
326
- children: (profiles || []).length === 0
327
- ? EmptyState({ text: 'no profiles — using HOME', glyph: '◎' })
328
- : h('div', {}, ...(profiles || []).map(p => Row({ key: p, code: '◎', title: p, meta: '' }))) }),
329
- Panel({ title: 'Slash commands', count: (commands || []).length,
330
- children: table(['name', 'category', 'description'], (commands || []).map(c => [c.name, c.category || '', c.description || ''])) }),
331
- Panel({ title: 'Active config', children: pre(config) }),
332
- ]
333
- },
334
-
335
- '#/env': async () => {
336
- const keys = await j('/api/env')
337
- const list = Array.isArray(keys) ? keys : []
338
- const set = list.filter(k => k.set).length
339
- return [
340
- kpi([[set, 'Keys set'], [list.length - set, 'Keys missing'], [list.length, 'Total known']]),
341
- Panel({ title: 'Environment variables',
342
- right: h('span', {}, Chip({ tone: 'ok', children: set + ' set' }), ' ', Chip({ tone: 'miss', children: (list.length - set) + ' missing' })),
343
- children: h('div', { style: 'padding:8px 4px;display:flex;flex-wrap:wrap;gap:6px' },
344
- ...list.map(k => Chip({ tone: k.set ? 'ok' : 'miss', children: k.key + (k.set ? ' ✓' : ' ·') }))) }),
345
- ]
346
- },
347
-
348
- '#/tools': async () => {
349
- const tools = await j('/api/tools')
350
- const list = Array.isArray(tools) ? tools : []
351
- const byToolset = list.reduce((acc, t) => { (acc[t.toolset] = acc[t.toolset] || []).push(t); return acc }, {})
352
- return [
353
- kpi([[list.length, 'Total tools'], [Object.keys(byToolset).length, 'Toolsets']]),
354
- ...Object.entries(byToolset).map(([ts, ts_tools]) =>
355
- Panel({ title: 'Toolset · ' + ts, count: ts_tools.length,
356
- children: h('div', {}, ...ts_tools.map(t =>
357
- Row({ key: t.name, code: '⚒', title: t.name, sub: (t.schema?.description || '').slice(0, 80), meta: '' }))) }))
358
- ]
359
- },
360
-
361
- '#/batch': async () => {
362
- const results = AppState.batch.results
363
- const running = AppState.batch.running
364
- return [
365
- Section({ title: '// batch runner', children: [
366
- Panel({ title: 'Run prompts', children: h('div', {},
367
- h('p', { style: 'margin-bottom:12px;opacity:0.7' }, 'Submit multiple prompts in parallel. Results stream back as JSONL. Each prompt runs a full agent turn.'),
368
- h('form', { class: 'row-form', style: 'flex-direction:column;gap:8px', onsubmit: async (ev) => {
369
- ev.preventDefault()
370
- const f = ev.target.elements
371
- const prompts = f.prompts.value.split('\n').map(l => l.trim()).filter(Boolean)
372
- if (!prompts.length) return
373
- AppState.batch.running = true; AppState.batch.results = null; rerender()
374
- const res = await j('/api/batch', { method: 'POST', headers: { 'content-type': 'application/json' },
375
- body: JSON.stringify({ prompts, concurrency: Number(f.concurrency.value) || 4 }) })
376
- AppState.batch.results = res; AppState.batch.running = false; rerender()
377
- } },
378
- h('textarea', { name: 'prompts', rows: 5, placeholder: 'One prompt per line…', style: 'width:100%;font-family:monospace;resize:vertical' }),
379
- h('div', { style: 'display:flex;gap:8px;align-items:center' },
380
- h('label', { style: 'font-size:12px;opacity:0.6' }, 'concurrency'),
381
- h('input', { name: 'concurrency', type: 'number', value: '4', style: 'width:60px' }),
382
- h('button', { type: 'submit', class: 'primary', disabled: running }, running ? 'running…' : 'Run batch')))) }),
383
- running ? Panel({ title: 'Running…', children: EmptyState({ text: 'batch in progress', glyph: '⊞' }) }) : null,
384
- results ? Panel({ title: results.__error ? 'Error' : 'Results · ' + (results.results?.length || 0),
385
- children: results.__error
386
- ? h('p', { style: 'color:var(--error,red)' }, results.__error)
387
- : h('div', {}, ...(results.results || []).map((r, i) =>
388
- Row({ key: i, code: String(i+1), title: (r.prompt || '').slice(0, 60), sub: (r.output || r.error || '').slice(0, 100), meta: r.error ? 'error' : 'ok' }))) }) : null,
389
- Panel({ title: 'CLI usage', children: Receipt({ rows: [
390
- ['run batch file', 'freddie batch prompts.txt'],
391
- ['set concurrency', 'freddie batch prompts.txt --concurrency 8'],
392
- ['JSONL output', 'freddie batch prompts.txt > results.jsonl'],
393
- ]}) }),
394
- ].filter(Boolean) }),
395
- ]
396
- },
397
-
398
- '#/gateway': async () => {
399
- const data = await j('/api/gateway')
400
- const platforms = Array.isArray(data?.platforms) ? data.platforms : []
401
- const active = platforms.filter(p => p.enabled)
402
- return [
403
- kpi([[platforms.length, 'Platforms'], [active.length, 'Active']]),
404
- Panel({ title: 'Platforms', right: active.length > 0 ? Chip({ tone: 'ok', children: active.length + ' active' }) : Chip({ tone: 'miss', children: 'none active' }),
405
- children: h('div', {}, ...platforms.map(p =>
406
- Row({ key: p.name, code: p.enabled ? '●' : '○', title: p.name, sub: p.note || '', meta: p.enabled ? 'enabled' : '' }))) }),
407
- Panel({ title: 'Start gateway', children: Receipt({ rows: [
408
- ['webhook + api_server', 'freddie gateway --port 3000'],
409
- ['specific platform', 'TELEGRAM_BOT_TOKEN=xxx freddie gateway'],
410
- ['all platforms', 'set env vars per platform, then freddie gateway'],
411
- ]}) }),
412
- ]
413
- },
414
-
415
- }
416
-
417
- async function pageSessionDetail(id) {
418
- const messages = await j('/api/sessions/' + id + '/messages')
419
- const list = Array.isArray(messages) ? messages : []
420
- return [
421
- Panel({ title: 'Session ' + id.slice(0, 8), children: kpi([[list.length, 'messages']]) }),
422
- list.length === 0
423
- ? Panel({ title: 'Messages', children: EmptyState({ text: 'no messages in this session', glyph: '✉' }) })
424
- : Chat({ title: 'session ' + id.slice(0, 8), sub: 'replay', messages: list.map((m, i) => toChatMsg(m, 's' + i)) }),
425
- Panel({ title: 'Back', children: h('a', { href: '#/sessions' }, '← all sessions') }),
426
- ]
427
- }
428
-
429
- async function sendChat(prompt) {
430
- if (!prompt || !prompt.trim() || AppState.chat.streaming) return
431
- AppState.chat.messages.push({ role: 'user', content: prompt, time: timeNow() })
432
- AppState.chat.streaming = true
433
- rerender()
434
- try {
435
- const r = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prompt }) })
436
- const reader = r.body.getReader(), dec = new TextDecoder()
437
- let buf = ''
438
- while (true) {
439
- const { value, done } = await reader.read()
440
- if (done) break
441
- buf += dec.decode(value, { stream: true })
442
- let idx
443
- while ((idx = buf.indexOf('\n\n')) >= 0) {
444
- const block = buf.slice(0, idx); buf = buf.slice(idx + 2)
445
- const ev = (block.match(/^event: (.+)$/m) || [, ''])[1]
446
- const data = (block.match(/^data: (.+)$/m) || [, '{}'])[1]
447
- let parsed; try { parsed = JSON.parse(data) } catch { parsed = { raw: data } }
448
- if (ev === 'message') {
449
- if (parsed.role !== 'user') AppState.chat.messages.push({ ...parsed, time: timeNow() })
450
- } else if (ev === 'error') {
451
- AppState.chat.messages.push({ role: 'assistant', content: '**[error]** ' + parsed.error, time: timeNow() })
452
- }
453
- }
454
- }
455
- } catch (e) {
456
- AppState.chat.messages.push({ role: 'assistant', content: '**[network error]** ' + e.message, time: timeNow() })
457
- }
458
- AppState.chat.streaming = false
459
- rerender()
460
- }
461
-
462
- async function doSearch(q) {
463
- AppState.search.query = q
464
- if (!q.trim()) { AppState.search.results = []; rerender(); return }
465
- const r = await j('/api/search?q=' + encodeURIComponent(q))
466
- AppState.search.results = Array.isArray(r) ? r : []
467
- rerender()
14
+ function regFromArray(arr, keyFn = (x) => x.name) {
15
+ const m = new Map();
16
+ for (const it of arr || []) m.set(keyFn(it), it);
17
+ return { get: (k) => m.get(k), has: (k) => m.has(k), list: () => [...m.values()], values: () => m.values(), get size() { return m.size; } };
468
18
  }
469
19
 
470
- function buildSide(state) {
471
- const sections = [{
472
- group: 'NAVIGATION',
473
- items: ROUTES.map(r => ({
474
- glyph: r.glyph,
475
- label: r.label,
476
- href: r.path,
477
- active: !state.hash.startsWith('#/session/') && r.path === state.hash,
478
- onClick: (ev) => { ev.preventDefault(); location.hash = r.path },
479
- })),
480
- }]
481
- return Side({ sections })
482
- }
483
-
484
- function render(state) {
485
- let route = ROUTES.find(r => r.path === state.hash)
486
- const isSessionDetail = state.hash.startsWith('#/session/')
487
- if (!route && !isSessionDetail) route = ROUTES[0]
488
- const themeLabel = state.theme === 'dark' ? '☀ light' : '☾ dark'
489
- const themeBtn = h('button', {
490
- class: 'ghost',
491
- onclick: () => {
492
- AppState.theme = AppState.theme === 'dark' ? 'light' : 'dark'
493
- localStorage.setItem('freddie-theme', AppState.theme)
494
- applyTheme(); rerender()
20
+ async function fetchHost() {
21
+ const [tools, skillsResp, cron, projectsResp, env, gateway, health, commands] = await Promise.all([
22
+ j('/api/tools/detail').catch(() => []),
23
+ j('/api/skills').catch(() => ({ home: [], bundled: [] })),
24
+ j('/api/cron').catch(() => []),
25
+ j('/api/projects').catch(() => ({ active: null, projects: [] })),
26
+ j('/api/env').catch(() => []),
27
+ j('/api/gateway').catch(() => ({ platforms: [] })),
28
+ j('/api/health').catch(() => ({ ok: false })),
29
+ j('/api/commands').catch(() => []),
30
+ ]);
31
+ const skillList = [...(skillsResp.home || []), ...(skillsResp.bundled || [])];
32
+ const projList = projectsResp.projects || [];
33
+ return {
34
+ kind: 'freddie-web', version: 'web',
35
+ pi: {
36
+ tools: regFromArray(tools),
37
+ skills: regFromArray(skillList, (s) => s.name || s.id),
38
+ cli: regFromArray(commands),
39
+ projects: {
40
+ list: () => projList,
41
+ active: () => projectsResp.active,
42
+ create: ({ name, path }) => post('/api/projects', { name, path }).then(() => location.reload()),
43
+ remove: (name) => fetch('/api/projects/' + encodeURIComponent(name), { method: 'DELETE' }).then(() => location.reload()),
44
+ setActive: (name) => post('/api/projects/active', { name }).then(() => location.reload()),
45
+ },
46
+ sessions: {
47
+ list: () => j('/api/sessions').catch(() => []),
48
+ getMessages: (id) => j('/api/sessions/' + encodeURIComponent(id) + '/messages').catch(() => []),
49
+ search: (q) => j('/api/search?q=' + encodeURIComponent(q)).catch(() => []),
50
+ },
51
+ cron: {
52
+ list: () => Promise.resolve(cron),
53
+ create: (job) => post('/api/cron', job),
54
+ delete: (id) => fetch('/api/cron/' + id, { method: 'DELETE' }),
55
+ },
56
+ env: { list: () => env, isSet: (k) => (env.find(e => e.key === k) || {}).set || false },
57
+ gateway: { platforms: () => gateway.platforms || [] },
58
+ agents: () => j('/api/agents').catch(() => ({ count: 0, turns: 0, active: null })),
59
+ health: () => health,
60
+ config: {
61
+ load: () => j('/api/config').catch(() => ({})),
62
+ saveValue: (path, value) => post('/api/config', { path, value }),
63
+ },
64
+ chat: { send: (text) => post('/api/chat', { text }) },
65
+ batch: { run: (prompts, conc) => post('/api/batch', { prompts, concurrency: conc }) },
66
+ hooks: {},
495
67
  },
496
- style: 'font-size:12px;padding:4px 12px',
497
- }, themeLabel)
498
- const searchInput = h('input', {
499
- type: 'search',
500
- placeholder: 'search messages…',
501
- value: state.search.query,
502
- onkeydown: (ev) => { if (ev.key === 'Enter') doSearch(ev.target.value) },
503
- style: 'min-width:240px',
504
- })
505
- const projectPill = h('a', {
506
- href: '#/projects',
507
- class: 'project-pill',
508
- title: state.projects.active ? 'Active project: ' + state.projects.active.name + ' — ' + state.projects.active.path : 'No active project',
509
- style: 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:var(--panel-2);color:var(--panel-text);text-decoration:none;font-size:12px;font-weight:500',
510
- }, h('span', { style: 'opacity:0.6' }, '◆'), h('span', {}, state.projects.active?.name || 'default'))
511
- const topbarWithControls = h('header', { class: 'app-topbar' },
512
- Brand({ name: 'freddie', leaf: 'dashboard' }),
513
- projectPill,
514
- h('div', { style: 'flex:1' }),
515
- searchInput,
516
- themeBtn,
517
- )
518
- const crumbRight = state.search.results.length > 0
519
- ? h('span', { class: 'meta' }, state.search.results.length + ' hits')
520
- : null
521
- const crumb = Crumb({ trail: ['freddie'], leaf: isSessionDetail ? state.hash.replace('#/', '') : route.path.replace('#/', ''), right: crumbRight })
522
- const searchResults = state.search.results.length > 0
523
- ? Panel({ title: `search results · ${state.search.results.length}`, children: state.search.results.slice(0, 8).map((r, i) =>
524
- Row({ key: i, code: (r.session_id || '?').slice(0, 8), title: (r.content || '').slice(0, 80),
525
- meta: 'open', onClick: () => { location.hash = '#/session/' + r.session_id } })) })
526
- : null
527
- const main = [searchResults, state.body || EmptyState({ text: 'loading…' })].filter(Boolean)
528
- const status = Status({
529
- left: ['ds-247420 · webjsx · ' + ROUTES.length + ' routes', 'theme=' + state.theme],
530
- right: [state.ts],
531
- })
532
- return AppShell({ topbar: topbarWithControls, crumb, side: buildSide(state), main, status })
533
- }
534
-
535
- let _mount
536
-
537
- async function refreshProject() {
538
- const data = await j('/api/projects')
539
- if (!data.__error) AppState.projects = { active: data.active || null, all: data.projects || [] }
540
- }
541
-
542
- async function go() {
543
- AppState.hash = location.hash || '#/projects'
544
- AppState.ts = new Date().toLocaleTimeString()
545
- AppState.body = EmptyState({ text: 'loading…', glyph: '◌' })
546
- if (_mount) _mount()
547
- refreshProject()
548
- let body
549
- if (AppState.hash.startsWith('#/session/')) {
550
- body = await pageSessionDetail(AppState.hash.slice('#/session/'.length))
551
- } else {
552
- const page = PAGES[AppState.hash] || PAGES['#/home']
553
- body = await page()
554
- }
555
- AppState.body = body
556
- AppState.ts = new Date().toLocaleTimeString()
557
- if (_mount) _mount()
558
- window.__debug.lastRoute = AppState.hash
559
- requestAnimationFrame(() => motion.animateSelector('.app-main', 'fadeIn', { duration: 'var(--motion-base)' }))
560
- }
561
-
562
- function rerender() {
563
- AppState.ts = new Date().toLocaleTimeString()
564
- if (AppState.hash === '#/chat') {
565
- Promise.resolve(PAGES['#/chat']()).then(b => { AppState.body = b; if (_mount) _mount() })
566
- return
567
- }
568
- if (_mount) _mount()
68
+ };
569
69
  }
570
70
 
571
- window.addEventListener('hashchange', go)
572
- _mount = mount(document.getElementById('app'), () => render(AppState))
573
- go()
71
+ const root = document.getElementById('app');
72
+ root.textContent = 'loading…';
73
+ const host = await fetchHost();
74
+ root.innerHTML = '';
75
+ const inst = { id: 'web', fs: { list: () => Promise.resolve([]) }, host };
76
+ const { node } = createFreddieDashboard({ instance: inst });
77
+ root.appendChild(node);
574
78
 
575
- window.__debug.go = go
576
- window.__debug.sendChat = sendChat
577
- window.__debug.doSearch = doSearch
578
- window.__debug.chat = () => ({ messages: AppState.chat.messages.length, streaming: AppState.chat.streaming, draft: AppState.chat.draft })
79
+ window.__debug = window.__debug || {};
80
+ window.__debug.dashboard = () => ({ booted: true, mode: 'fetchHost', tools: host.pi.tools.size, skills: host.pi.skills.size });