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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.384",
3
+ "version": "0.0.385",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -10,7 +10,15 @@
10
10
  // around runLintCss() below.
11
11
  //
12
12
  // Run: `node scripts/lint-css.mjs` (also wired as `npm run lint`, via lint.mjs).
13
- import { lintTokensOrThrow, lintRadiusOrThrow, lintSpacingOrThrow } from './lint-tokens.mjs';
13
+ import {
14
+ lintTokensOrThrow,
15
+ lintRadiusOrThrow,
16
+ lintSpacingOrThrow,
17
+ lintFontSizeOrThrow,
18
+ lintZIndexOrThrow,
19
+ lintTransitionAllOrThrow,
20
+ lintImportantOrThrow,
21
+ } from './lint-tokens.mjs';
14
22
  import { lintGlyphsOrThrow } from './lint-glyphs.mjs';
15
23
  import { lintNullChildrenOrThrow } from './lint-null-children.mjs';
16
24
  import { lintClassesOrThrow } from './lint-classes.mjs';
@@ -19,12 +27,24 @@ import { lintDuplicateSelectorsOrThrow } from './lint-duplicate-selectors.mjs';
19
27
  import { lintSwallowCommentsOrThrow } from './lint-swallow-comments.mjs';
20
28
 
21
29
  // Each entry: a human label for the report, and the check function to run.
22
- // lintSpacingOrThrow is report-only (never throws see lint-tokens.mjs), so
23
- // it is run but never counted as a failure; its own console.warn/log already
24
- // carries the detail. Every other check throws on violation.
30
+ // EVERY check here throws on violation and is counted in the pass/fail tally.
31
+ //
32
+ // The three token RATCHET gates (spacing, fontsize, important) throw only when
33
+ // the count EXCEEDS their frozen baseline — they carry inherited debt that a
34
+ // hard zero would demand fixing in one pass. They are otherwise ordinary
35
+ // members of this list: a ratchet that never throws is not a gate, and
36
+ // `spacing` was previously called outside the loop under a stale "report-only,
37
+ // never throws" comment, which meant a real spacing regression escaped as an
38
+ // unhandled exception out of runLintCss() instead of being reported as a
39
+ // failed check. Anything that can fail belongs in CHECKS.
25
40
  const CHECKS = [
26
41
  ['tokens', lintTokensOrThrow],
27
42
  ['radius', lintRadiusOrThrow],
43
+ ['zindex', lintZIndexOrThrow],
44
+ ['transition-all', lintTransitionAllOrThrow],
45
+ ['spacing', lintSpacingOrThrow],
46
+ ['fontsize', lintFontSizeOrThrow],
47
+ ['important', lintImportantOrThrow],
28
48
  ['glyphs', lintGlyphsOrThrow],
29
49
  ['null-children', lintNullChildrenOrThrow],
30
50
  ['classes', lintClassesOrThrow],
@@ -48,12 +68,6 @@ export function runLintCss() {
48
68
  }
49
69
  }
50
70
 
51
- // Spacing is report-only (logs its own [lint-spacing] REPORT/OK line
52
- // above, via console.warn/console.log inside lintSpacingOrThrow) — never
53
- // throws, so it is not part of the pass/fail tally, matching build.mjs's
54
- // existing non-fatal treatment of it.
55
- lintSpacingOrThrow();
56
-
57
71
  const failed = results.filter((r) => !r.ok);
58
72
  const passed = results.filter((r) => r.ok);
59
73
 
@@ -0,0 +1,130 @@
1
+ // ChatMessage — one conversational turn in every layout the surface supports:
2
+ // messenger bubbles (you/them), centered out-of-band roles (system/tool/
3
+ // thinking), and the flat full-width claude.ai/code turn. Owns the turn's
4
+ // notices (stopped / incomplete / error+retry), reactions, read receipt,
5
+ // meta line, and the hover-revealed per-message action row.
6
+
7
+ import * as webjsx from '../../../vendor/webjsx/index.js';
8
+ import { Icon } from '../shell.js';
9
+ import { t } from '../../i18n.js';
10
+ import { renderInline } from '../chat-message-parts.js';
11
+ import { avatarInitial } from '../content.js';
12
+ import { countMessage, renderPart } from './stats.js';
13
+
14
+ const h = webjsx.createElement;
15
+
16
+ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped, flat, error, onRetry }) {
17
+ countMessage();
18
+ // Support legacy 'who' prop, prefer 'role' with mapping:
19
+ // 'user' -> 'you' (right-aligned, accent bubble)
20
+ // 'assistant' -> 'them' (left-aligned, paper bubble)
21
+ // 'system' -> 'system' (centered, italic muted)
22
+ // 'tool' -> 'tool' (centered, collapsible card chrome)
23
+ // 'thinking' -> 'thinking' (centered, transient typing dots)
24
+ const resolvedWho = role
25
+ ? (role === 'user' ? 'you'
26
+ : role === 'assistant' ? 'them'
27
+ : (role === 'system' || role === 'tool' || role === 'thinking') ? role
28
+ : role)
29
+ : who;
30
+ const isCentered = resolvedWho === 'system' || resolvedWho === 'tool' || resolvedWho === 'thinking';
31
+ // Flat layout (Claude-Code-web): full-width, avatar-less turns with a role
32
+ // label above the content and a faint assistant background, instead of the
33
+ // messenger avatar-disc + colored-bubble layout (kept for the chat demo).
34
+ const isFlat = flat && !isCentered;
35
+ const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '') + (isFlat ? ' chat-msg-flat' : '');
36
+ const fallbackAvatar = avatar != null
37
+ ? avatar
38
+ : (resolvedWho === 'you' ? 'u' : avatarInitial(name));
39
+ const av = h('span', { class: 'chat-avatar' }, fallbackAvatar);
40
+ let bodyNodes;
41
+ if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
42
+ else if (parts && parts.length) bodyNodes = parts.map((p, i) => renderPart(p, i));
43
+ else bodyNodes = [h('div', { class: 'chat-bubble', key: 't' }, ...renderInline(text || ''))];
44
+ // A blinking caret at the stream head: while an assistant turn is streaming
45
+ // AND already shows content (so the inline typing dots have stopped), append
46
+ // a thin caret so the live edge reads as "still writing", not "done". Drawn as
47
+ // a CSS element, not a glyph character.
48
+ // Only append the caret as a sibling if the last part did not already embed
49
+ // it inline (streamingCaret flag on the last text/md part in parts array).
50
+ const lastPartHasCaret = parts && parts.length && parts[parts.length - 1] && parts[parts.length - 1].streamingCaret;
51
+ if (streaming && !typing && !lastPartHasCaret) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
52
+ // Out-of-band turn notices, plain copy in a NEUTRAL tone (not error red):
53
+ // stopped — the turn was cancelled (locally or remotely); truncated
54
+ // output must not read as a finished answer.
55
+ // incomplete — the connection dropped mid-turn and events were not
56
+ // replayed; the response may be missing content.
57
+ // Pass true for the default copy or a string to override it. Retry rides
58
+ // the existing per-message actions row.
59
+ if (stopped) bodyNodes = [...bodyNodes, h('div', { key: '_stopped', class: 'chat-msg-notice is-stopped', role: 'status' },
60
+ typeof stopped === 'string' ? stopped : 'stopped — this turn was cancelled before it finished')];
61
+ if (incomplete) bodyNodes = [...bodyNodes, h('div', { key: '_incomplete', class: 'chat-msg-notice is-incomplete', role: 'status' },
62
+ typeof incomplete === 'string' ? incomplete : 'connection dropped mid-turn — the response may be incomplete')];
63
+ // Inline per-turn error: unlike a global toast, this pins the failure to
64
+ // the specific turn that failed (docstudio pattern) with a retry action
65
+ // right there instead of forcing the user to hunt for what broke.
66
+ if (error) bodyNodes = [...bodyNodes, h('div', { key: '_error', class: 'chat-msg-notice is-error', role: 'alert' },
67
+ h('span', {}, typeof error === 'string' ? error : 'this turn failed'),
68
+ onRetry ? h('button', {
69
+ type: 'button', class: 'chat-msg-retry-btn',
70
+ onclick: (e) => { e.preventDefault(); onRetry(e); },
71
+ }, 'retry') : null)];
72
+ const reactionRow = reactions && reactions.length
73
+ ? h('div', { class: 'chat-reactions' },
74
+ ...reactions.map((r, i) => h('span', { class: 'rxn' + (r.you ? ' you' : ''), key: 'r' + i, 'aria-label': `${r.emoji} reaction (${String(r.count)} ${String(r.count) === '1' ? 'reaction' : 'reactions'})${r.you ? ' - you reacted' : ''}` },
75
+ h('span', { class: 'e', 'aria-hidden': 'true' }, r.emoji), h('span', { class: 'n', 'aria-hidden': 'true' }, String(r.count)))))
76
+ : null;
77
+ const tickNode = resolvedWho === 'you' && receipt
78
+ ? h('span', { class: 'tick' + (receipt === 'read' ? ' read' : ''), role: 'img', 'aria-label': receipt === 'read' ? 'message read' : 'message sent' }, Icon(receipt === 'read' ? 'check-check' : 'check', { size: 14 }))
79
+ : null;
80
+ const metaItems = [];
81
+ if (name && resolvedWho === 'them') metaItems.push(h('span', { class: 'who', key: 'w' }, name));
82
+ if (time) metaItems.push(h('span', { class: 't', key: 'ti' }, time));
83
+ if (tickNode) metaItems.push(tickNode);
84
+ const meta = metaItems.length ? h('div', { class: 'chat-meta' }, ...metaItems) : null;
85
+ // Per-message actions (copy / retry / edit) — a hover-revealed control row
86
+ // below the bubble, the way Claude-Desktop surfaces message-level actions.
87
+ // Each action is { label, icon, onClick, title }. Kept icon-only with an
88
+ // accessible name; no decorative glyphs (the Icon set is line-SVG).
89
+ const actionRow = (actions && actions.length)
90
+ ? h('div', { class: 'chat-msg-actions', role: 'group', 'aria-label': 'message actions' },
91
+ ...actions.filter(Boolean).map((a, i) => h('button', {
92
+ key: 'ma' + i, type: 'button', class: 'chat-msg-action',
93
+ title: a.title || a.label, 'aria-label': a.label || a.title,
94
+ onclick: (e) => {
95
+ e.preventDefault();
96
+ a.onClick && a.onClick(e);
97
+ // Copy is the highest-traffic per-message action and, unlike
98
+ // code-block/tool-result copy elsewhere in this file, had no
99
+ // self-contained visual feedback — a sighted/mouse user saw
100
+ // nothing happen. Flip the button's own label/icon the same
101
+ // way those sibling copy controls already do.
102
+ if (a.label === 'copy') {
103
+ const btn = e.currentTarget;
104
+ const labelEl = btn.querySelector('.chat-msg-action-label');
105
+ clearTimeout(btn._dsCopyTimer);
106
+ btn.classList.add('is-copied');
107
+ if (labelEl) labelEl.textContent = 'copied';
108
+ btn._dsCopyTimer = setTimeout(() => {
109
+ btn.classList.remove('is-copied');
110
+ if (labelEl) labelEl.textContent = 'copy';
111
+ }, 1600);
112
+ }
113
+ },
114
+ }, a.icon ? Icon(a.icon, { size: 14 }) : null,
115
+ a.label ? h('span', { class: 'chat-msg-action-label' }, a.label) : null)))
116
+ : null;
117
+ // Flat layout leads the turn with a small role label (You / agent name)
118
+ // above the content, the way claude.ai/code titles each turn.
119
+ const roleLabel = isFlat
120
+ ? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? t('chat.roleYou', 'You') : (name || t('chat.roleAssistant', 'Assistant')))
121
+ : null;
122
+ const stack = h('div', { class: 'chat-stack' }, roleLabel, ...bodyNodes, reactionRow, actionRow, meta);
123
+ // Centered roles (system/tool/thinking) skip the avatar column entirely so
124
+ // the bubble owns the full row — the chrome reads as out-of-band signal,
125
+ // not a participant turn.
126
+ if (isCentered) return h('div', { key, class: cls }, stack);
127
+ // Flat turns drop the avatar column entirely (full-width content).
128
+ if (isFlat) return h('div', { key, class: cls }, stack);
129
+ return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
130
+ }
@@ -0,0 +1,36 @@
1
+ // Chat-surface debug counters + the markdown/Prism cache warm-up, registered
2
+ // into the single `window.__debug` client-side registry. Kept in its own
3
+ // module so ChatMessage (which increments the counters) and the registration
4
+ // call in the barrel share one live object rather than two copies.
5
+
6
+ import { initializeCachesEagerly, getCacheStats } from '../../markdown-cache.js';
7
+ import { register } from '../../debug.js';
8
+ import { renderMessagePart as sharedRenderMessagePart } from '../chat-message-parts.js';
9
+
10
+ const _stats = { messages: 0, lastKindCounts: {} };
11
+ let _cacheInitialized = false;
12
+
13
+ // Eagerly warm the markdown + Prism caches on first chat-surface mount, once.
14
+ export function ensureCachesInit() {
15
+ if (_cacheInitialized) return;
16
+ _cacheInitialized = true;
17
+ initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
18
+ }
19
+
20
+ export function countMessage() { _stats.messages += 1; }
21
+
22
+ // Thin wrapper around the shared renderer that keeps this file's own debug
23
+ // counter (_stats.lastKindCounts, surfaced via register('chat', ...) below)
24
+ // wired exactly as before. sharedRenderMessagePart already applies the
25
+ // 'p' + key VElement key itself.
26
+ export function renderPart(p, key) {
27
+ return sharedRenderMessagePart(p, key, (kind) => {
28
+ _stats.lastKindCounts[kind] = (_stats.lastKindCounts[kind] || 0) + 1;
29
+ });
30
+ }
31
+
32
+ register('chat', () => ({
33
+ messages: _stats.messages,
34
+ lastKindCounts: { ..._stats.lastKindCounts },
35
+ cacheStats: getCacheStats(),
36
+ }));
@@ -0,0 +1,42 @@
1
+ // Thread auto-scroll: keep a scroll container pinned to the bottom as new
2
+ // messages arrive, but never while the user is mid-selection. Shared by Chat,
3
+ // AICat, and AgentChat.
4
+
5
+ // True when the user has a non-collapsed text selection anchored inside `el`.
6
+ // Used to pause auto-scroll (and by hosts to pause streaming re-renders) so
7
+ // select-and-copy from a still-streaming message is not wiped every frame.
8
+ export function hasSelectionInside(el) {
9
+ const sel = typeof document !== 'undefined' && document.getSelection ? document.getSelection() : null;
10
+ return !!(sel && !sel.isCollapsed && sel.anchorNode && el && el.contains(sel.anchorNode));
11
+ }
12
+
13
+ // Build a ref callback that keeps a scroll container pinned to the bottom when
14
+ // new messages arrive AND the user is already at the bottom (sentinel visible).
15
+ // `getCount` returns the current message count so the observer compares against
16
+ // live state. Shared by Chat, AICat, and AgentChat.
17
+ // CONTRACT: auto-scroll pauses while the user holds a non-collapsed selection
18
+ // inside the thread (hasSelectionInside) — the same guard hosts apply to their
19
+ // streaming re-render pass — and resumes once the selection collapses.
20
+ export function makeThreadAutoScroll(getCount) {
21
+ return (el) => {
22
+ if (!el) return;
23
+ let sentinel = el.querySelector('[data-scroll-sentinel]');
24
+ if (!sentinel) {
25
+ sentinel = document.createElement('div');
26
+ sentinel.setAttribute('data-scroll-sentinel', '');
27
+ sentinel.style.height = '1px';
28
+ el.appendChild(sentinel);
29
+ }
30
+ const obs = new IntersectionObserver((entries) => {
31
+ if (hasSelectionInside(el)) return; // don't fight an active selection
32
+ const count = String(getCount());
33
+ if (entries[0]?.isIntersecting && el.dataset.msgCount !== count) {
34
+ el.scrollTop = el.scrollHeight - el.clientHeight;
35
+ el.dataset.msgCount = count;
36
+ }
37
+ }, { root: el, threshold: 0 });
38
+ obs.observe(sentinel);
39
+ el.dataset.msgCount = String(getCount());
40
+ return () => obs.disconnect();
41
+ };
42
+ }
@@ -0,0 +1,149 @@
1
+ // Freddie conversational pages: the streaming `chat` page (SSE over POST,
2
+ // offline outbox queueing, abortable turns) and the `voice` backend probe.
3
+
4
+ import * as webjsx from '../../../vendor/webjsx/index.js';
5
+ import { makePage, api, loadingState, emptyState } from './runtime.js';
6
+ import { Table, PageHeader } from '../content.js';
7
+ import { Chip } from '../shell.js';
8
+ import { formatTime } from '../../locale.js';
9
+ import { queueMessage, watchReconnect, isOnline } from '../../idb-outbox.js';
10
+ import { AgentChat } from '../agent-chat.js';
11
+ import { section, noteAlert } from './shared.js';
12
+ import { parseSseStream, toolProgressPart, partsFromMessages } from './sse.js';
13
+
14
+ const h = webjsx.createElement;
15
+
16
+ export const chat = makePage((ctx) => {
17
+ Object.assign(ctx.state, { loading: false, messages: [], draft: '', busy: false, error: null, abort: null });
18
+
19
+ // Offline outbox: a prompt sent while genuinely offline queues to
20
+ // IndexedDB and auto-flushes on the real 'online' event, rather than
21
+ // surfacing a hard error the user can't act on. True offline LLM
22
+ // response generation is impossible by definition -- a queued message
23
+ // only gets a reply once connectivity actually returns. Reconnect-flush
24
+ // still goes through the single-shot JSON path (no live UI to stream
25
+ // into for a message sent while this page may not even be mounted).
26
+ async function sendQueuedToServer(body) {
27
+ const r = await api('/api/chat', { method: 'POST', body });
28
+ const reply = r.result || r.content || r.message || (r.messages && r.messages.at(-1)?.content) || JSON.stringify(r);
29
+ ctx.state.messages.push({ id: 'a' + Date.now(), role: 'assistant', content: String(reply), time: formatTime(Date.now()) });
30
+ ctx.rerender();
31
+ }
32
+ watchReconnect('chat', sendQueuedToServer);
33
+
34
+ async function send(text) {
35
+ const t = (typeof text === 'string' ? text : ctx.state.draft || '').trim();
36
+ if (!t || ctx.state.busy) return;
37
+ const userMsg = { id: 'u' + Date.now(), role: 'user', content: t, time: formatTime(Date.now()) };
38
+ const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: formatTime(Date.now()), parts: [] };
39
+ ctx.state.messages = [...ctx.state.messages, userMsg, curMsg];
40
+ ctx.set({ draft: '', busy: true, error: null });
41
+
42
+ if (!isOnline()) {
43
+ await queueMessage('chat', { prompt: t });
44
+ ctx.state.messages = ctx.state.messages.slice(0, -1);
45
+ ctx.state.messages.push({ id: curMsg.id, role: 'assistant', content: '(offline -- queued, will send when connection returns)', time: formatTime(Date.now()) });
46
+ ctx.set({ busy: false });
47
+ return;
48
+ }
49
+
50
+ const ctrl = new AbortController();
51
+ ctx.state.abort = ctrl;
52
+ const cur = ctx.state.messages[ctx.state.messages.length - 1];
53
+ try {
54
+ const res = await fetch('/api/chat', {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
57
+ body: JSON.stringify({ prompt: t, sessionId: ctx.state.sessionId || undefined }),
58
+ signal: ctrl.signal,
59
+ });
60
+ if (!res.ok || !res.body) {
61
+ const txt = await res.text().catch(() => '');
62
+ throw new Error(txt || ('HTTP ' + res.status));
63
+ }
64
+ let finalMessages = null;
65
+ for await (const { event, data } of parseSseStream(res)) {
66
+ if (ctrl.signal.aborted) break;
67
+ if (event === 'start') {
68
+ if (data && data.sessionId) ctx.state.sessionId = data.sessionId;
69
+ } else if (event === 'tool_progress') {
70
+ cur.parts.push(toolProgressPart(data || {}));
71
+ ctx.rerender();
72
+ } else if (event === 'message') {
73
+ // Buffered per-message events land right before `done` -- accumulate
74
+ // rather than rerender per-message; the final rebuild below is O(1)
75
+ // extra work and avoids a flurry of rerenders in the same tick.
76
+ (finalMessages || (finalMessages = [])).push(data);
77
+ } else if (event === 'error') {
78
+ cur.error = String((data && data.error) || 'stream error');
79
+ ctx.rerender();
80
+ } else if (event === 'done') {
81
+ if (finalMessages && finalMessages.length) {
82
+ cur.parts = partsFromMessages(finalMessages);
83
+ // Prefer the settled assistant text as plain content when the
84
+ // rebuilt parts carry exactly one md part (the common no-tool-call
85
+ // case) -- keeps AgentChat's md-vs-content dedup path simple.
86
+ cur.content = '';
87
+ } else if (data && data.result) {
88
+ cur.content = String(data.result);
89
+ }
90
+ ctx.rerender();
91
+ }
92
+ }
93
+ } catch (e) {
94
+ if (e && e.name === 'AbortError') {
95
+ cur.stopped = true;
96
+ } else {
97
+ cur.error = String(e && e.message || e);
98
+ }
99
+ } finally {
100
+ ctx.state.abort = null;
101
+ ctx.set({ busy: false });
102
+ }
103
+ }
104
+
105
+ function stop() {
106
+ if (ctx.state.abort) { try { ctx.state.abort.abort(); } catch { /* swallow: already settled */ } }
107
+ }
108
+
109
+ return () => {
110
+ const s = ctx.state;
111
+ return h('div', { class: 'fd-chat' },
112
+ AgentChat({
113
+ messages: s.messages,
114
+ busy: s.busy,
115
+ draft: s.draft,
116
+ status: s.busy ? 'streaming…' : 'ready',
117
+ agentName: 'freddie',
118
+ placeholder: s.busy ? 'waiting for reply…' : 'message…',
119
+ showMinimap: true,
120
+ banners: s.error ? [noteAlert({ kind: 'error', msg: s.error })] : [],
121
+ onInput: (v) => { s.draft = v; },
122
+ onSend: send,
123
+ onStop: stop,
124
+ onNewChat: () => ctx.set({ messages: [], draft: '', error: null, sessionId: null }),
125
+ }));
126
+ };
127
+ });
128
+
129
+ export const voice = makePage((ctx) => {
130
+ async function load() {
131
+ // Probe for a voice backend; the endpoint is optional, so a 404/!ok
132
+ // means "not wired" rather than an error to surface.
133
+ try { const v = await api('/api/voice').catch(() => null); ctx.set({ loading: false, voice: v, error: null }); }
134
+ catch (e) { ctx.set({ loading: false, error: e }); }
135
+ }
136
+ load();
137
+ return () => {
138
+ const s = ctx.state;
139
+ if (s.loading) return loadingState('loading voice config…');
140
+ const v = s.voice;
141
+ const enabled = v && (v.enabled || v.transcription || v.tts);
142
+ return [
143
+ PageHeader({ title: 'voice', lede: 'voice surfaces', right: enabled ? Chip({ tone: 'ok', children: 'enabled' }) : Chip({ tone: 'neutral', children: 'not configured' }) }),
144
+ enabled
145
+ ? 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' })]] }))
146
+ : section('status', emptyState('no voice backend wired in this build. configure a transcription/tts plugin to enable.')),
147
+ ];
148
+ };
149
+ });
@@ -0,0 +1,203 @@
1
+ // Freddie configuration pages: `models` (availability matrix + rebuild),
2
+ // `skills`, `plugins`, `config` (runtime settings + skin), and `env`
3
+ // (provider api keys, write-only, never echoed back).
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { makePage, api, loadingState, errorState, emptyState } from './runtime.js';
7
+ import { Row, Table, PageHeader, TextField, Select } from '../content.js';
8
+ import { Chip, Btn } from '../shell.js';
9
+ import { ModelsConfig } from '../models-config.js';
10
+ import { SkillsConfig } from '../skills-config.js';
11
+ import { PluginsConfig } from '../plugins-config.js';
12
+ import { section, noteAlert, liveRegion } from './shared.js';
13
+
14
+ const h = webjsx.createElement;
15
+
16
+ export const models = makePage((ctx) => {
17
+ Object.assign(ctx.state, { rebuilding: false, selectedProviderId: null, selectedModel: null });
18
+ // GET /api/models/availability — the real per-(provider x model x mode)
19
+ // availability matrix (plugins/gui-models-discover), per freddie's AGENTS.md
20
+ // "Model availability matrix" section. 404 with {error,hint} when the
21
+ // matrix file hasn't been built yet — ModelsConfig itself renders that
22
+ // as an empty state with a "build availability matrix" action.
23
+ async function load() {
24
+ try { ctx.set({ loading: false, data: await api('/api/models/availability'), error: null }); }
25
+ catch (e) { ctx.set({ loading: false, data: null, error: (e && e.body) || e }); }
26
+ }
27
+ async function rebuild() {
28
+ if (ctx.state.rebuilding) return;
29
+ ctx.set({ rebuilding: true, rebuildError: null });
30
+ try { await api('/api/models/availability/rebuild', { method: 'POST', body: {} }); await load(); }
31
+ catch (e) { ctx.set({ rebuildError: e }); }
32
+ ctx.set({ rebuilding: false });
33
+ }
34
+ load();
35
+ return () => {
36
+ const s = ctx.state;
37
+ return [
38
+ PageHeader({ title: 'models', lede: s.data ? (s.data.summary?.total_models ?? 0) + ' models across ' + (s.data.summary?.total_providers ?? 0) + ' providers' : 'model availability matrix' }),
39
+ ModelsConfig({
40
+ data: s.data, loading: s.loading, error: s.error,
41
+ selectedProviderId: s.selectedProviderId, onSelectProvider: (id) => ctx.set({ selectedProviderId: id, selectedModel: null }),
42
+ selectedModel: s.selectedModel, onSelectModel: (m) => ctx.set({ selectedModel: m }),
43
+ onRefresh: load, onRebuild: rebuild, rebuilding: s.rebuilding, rebuildError: s.rebuildError,
44
+ }),
45
+ ];
46
+ };
47
+ });
48
+
49
+ export const skills = makePage((ctx) => {
50
+ Object.assign(ctx.state, { selected: null, query: '', busyName: null });
51
+ async function load() { try { ctx.set({ loading: false, list: await api('/api/skills'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
52
+ load();
53
+ return () => {
54
+ const s = ctx.state;
55
+ // GET /api/skills returns {home:[...], bundled:[...], skillState} —
56
+ // two source lists (user ~/.freddie/skills vs bundled skills/ dirs)
57
+ // plus a per-skill enabled/disabled state map, not a flat array.
58
+ // Concat both sources (home overrides bundled on name collision,
59
+ // matching src/skills/index.js's own findSkill() precedence) and
60
+ // resolve enabled state from skillState (default true when absent).
61
+ const raw = s.list && typeof s.list === 'object' ? s.list : {};
62
+ const rawList = Array.isArray(raw) ? raw : [...(raw.bundled || []), ...(raw.home || [])];
63
+ const skillState = raw.skillState || {};
64
+ const mapped = rawList.map((sk) => ({
65
+ file: sk.file || sk.path || sk.name,
66
+ name: sk.name,
67
+ description: sk.description || (sk.frontmatter && sk.frontmatter.description) || '',
68
+ platforms: sk.platforms || (sk.frontmatter && sk.frontmatter.platforms),
69
+ enabled: skillState[sk.name] !== false,
70
+ }));
71
+ return [
72
+ PageHeader({ title: 'skills', lede: mapped.length + ' skills' }),
73
+ SkillsConfig({
74
+ skills: mapped, selected: s.selected, loading: s.loading, error: s.error,
75
+ busyName: s.busyName, query: s.query, onQuery: (q) => ctx.set({ query: q }),
76
+ onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
77
+ }),
78
+ ];
79
+ };
80
+ });
81
+
82
+ export const plugins = makePage((ctx) => {
83
+ Object.assign(ctx.state, { selected: null });
84
+ // GET /api/plugins — flat {name,version,surfaces,requires,source,enabled}
85
+ // list, per plugins/gui-plugins-list/plugin.js (distinct from
86
+ // /api/plugin-graph's D3 {nodes,edges} shape built for the dependency
87
+ // visualization, not a flat list UI).
88
+ async function load() { try { ctx.set({ loading: false, list: await api('/api/plugins'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
89
+ load();
90
+ return () => {
91
+ const s = ctx.state;
92
+ const list = Array.isArray(s.list) ? s.list : (s.list?.plugins || []);
93
+ return [
94
+ PageHeader({ title: 'plugins', lede: list.length + ' plugins loaded' }),
95
+ PluginsConfig({
96
+ plugins: list, selected: s.selected, loading: s.loading, error: s.error,
97
+ onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
98
+ onReload: load,
99
+ }),
100
+ ];
101
+ };
102
+ });
103
+
104
+ export const config = makePage((ctx) => {
105
+ Object.assign(ctx.state, { edited: {}, busy: false, note: null });
106
+ async function load() {
107
+ try {
108
+ const [cfg, skins] = await Promise.all([api('/api/config'), api('/api/skins').catch(() => null)]);
109
+ ctx.set({ loading: false, cfg, skins, error: null });
110
+ } catch (e) { ctx.set({ loading: false, error: e }); }
111
+ }
112
+ async function save() {
113
+ ctx.set({ busy: true, note: null });
114
+ try { await api('/api/config', { method: 'POST', body: ctx.state.edited }); ctx.state.edited = {}; await load(); ctx.set({ note: { kind: 'success', msg: 'saved' } }); }
115
+ catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
116
+ ctx.set({ busy: false });
117
+ }
118
+ async function setSkin(name) {
119
+ ctx.set({ busy: true, note: null });
120
+ try { await api('/api/config', { method: 'POST', body: { skin: name } }); await load(); ctx.set({ note: { kind: 'success', msg: 'skin -> ' + name } }); }
121
+ catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
122
+ ctx.set({ busy: false });
123
+ }
124
+ load();
125
+ return () => {
126
+ const s = ctx.state;
127
+ if (s.loading) return loadingState('loading config…');
128
+ if (s.error) return errorState(s.error, load);
129
+ const cfg = s.cfg || {};
130
+ const flat = Object.entries(cfg).filter(([, v]) => typeof v !== 'object' || v === null);
131
+ const nested = Object.entries(cfg).filter(([, v]) => typeof v === 'object' && v !== null);
132
+ const skinList = Array.isArray(s.skins) ? s.skins : (s.skins?.skins || s.skins?.available || []);
133
+ const activeSkin = cfg.skin || s.skins?.active || '';
134
+ return [
135
+ PageHeader({ title: 'config', lede: 'runtime configuration' }),
136
+ noteAlert(s.note),
137
+ liveRegion(s.busy ? 'saving configuration' : ''),
138
+ nested.length ? h('div', { class: 'ds-alert ds-alert-info', role: 'note' },
139
+ h('span', { class: 'ds-alert-icon' }, 'i'),
140
+ 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,
141
+ skinList.length ? section('skin',
142
+ Select({ label: 'active skin', value: activeSkin, options: skinList, onChange: (v) => setSkin(v) })
143
+ ) : null,
144
+ section('settings', flat.length ? flat.map(([k, v], i) =>
145
+ TextField({ key: i, label: k, value: String(ctx.state.edited[k] ?? v ?? ''), onInput: (val) => { ctx.state.edited[k] = val; } })
146
+ ) : emptyState('no scalar config keys')),
147
+ section('raw', h('pre', { class: 'fd-pre' }, JSON.stringify(cfg, null, 2))),
148
+ section('actions',
149
+ Btn({ variant: 'primary', disabled: s.busy || !Object.keys(s.edited).length, children: s.busy ? 'saving…' : 'save changes', onClick: save })),
150
+ ].filter(Boolean);
151
+ };
152
+ });
153
+
154
+ export const env = makePage((ctx) => {
155
+ Object.assign(ctx.state, { auth: null, vars: null, draft: {}, busy: '', note: null });
156
+ async function load() {
157
+ try {
158
+ const [auth, vars] = await Promise.all([api('/api/auth').catch(() => null), api('/api/env').catch(() => null)]);
159
+ ctx.set({ loading: false, auth, vars, error: null });
160
+ } catch (e) { ctx.set({ loading: false, error: e }); }
161
+ }
162
+ // Set a provider key through the dashboard (POST /api/auth). The key is sent
163
+ // once and never echoed back — GET /api/auth returns only a masked fingerprint.
164
+ async function setKey(provider) {
165
+ const key = (ctx.state.draft[provider] || '').trim();
166
+ if (!key) { ctx.set({ note: { kind: 'warn', msg: 'key required for ' + provider } }); return; }
167
+ ctx.set({ busy: provider, note: null });
168
+ try { await api('/api/auth', { method: 'POST', body: { provider, key } }); ctx.state.draft[provider] = ''; await load(); ctx.set({ note: { kind: 'success', msg: 'stored ' + provider } }); }
169
+ catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
170
+ ctx.set({ busy: '' });
171
+ }
172
+ async function removeKey(provider) {
173
+ ctx.set({ busy: provider, note: null });
174
+ try { await api('/api/auth/' + encodeURIComponent(provider), { method: 'DELETE' }); await load(); ctx.set({ note: { kind: 'success', msg: 'removed ' + provider } }); }
175
+ catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
176
+ ctx.set({ busy: '' });
177
+ }
178
+ load();
179
+ return () => {
180
+ const s = ctx.state;
181
+ if (s.loading) return loadingState('loading keys…');
182
+ if (s.error && !s.auth) return errorState(s.error, load);
183
+ const auth = Array.isArray(s.auth) ? s.auth : [];
184
+ const vars = Array.isArray(s.vars) ? s.vars : [];
185
+ // Non-provider env vars (platform tokens etc) stay a read-only presence table.
186
+ const providerEnvs = new Set(auth.map(a => a.env));
187
+ 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' })]);
188
+ return [
189
+ PageHeader({ title: 'keys', lede: 'provider api keys · stored locally, never displayed' }),
190
+ noteAlert(s.note),
191
+ section('provider keys',
192
+ auth.length ? auth.map((a, i) => Row({
193
+ key: i, title: a.provider, sub: a.env + (a.set ? ' · ' + a.source + (a.fingerprint ? ' · ' + a.fingerprint : '') : ''),
194
+ trailing: h('span', { class: 'fd-row-actions' },
195
+ a.set ? Chip({ tone: 'ok', children: 'set' }) : Chip({ tone: 'neutral', children: 'unset' }),
196
+ TextField({ type: 'password', value: s.draft[a.provider] || '', onInput: (v) => { s.draft[a.provider] = v; }, placeholder: 'paste key', 'aria-label': 'key for ' + a.provider }),
197
+ Btn({ variant: 'primary', disabled: s.busy === a.provider, children: s.busy === a.provider ? '…' : 'save', onClick: () => setKey(a.provider) }),
198
+ (a.set && a.source === 'stored') ? Btn({ variant: 'danger', disabled: s.busy === a.provider, children: 'remove', onClick: () => removeKey(a.provider) }) : null),
199
+ })) : emptyState('no providers')),
200
+ otherRows.length ? section('other environment', Table({ headers: ['key', 'status'], rows: otherRows })) : null,
201
+ ].filter(Boolean);
202
+ };
203
+ });