anentrypoint-design 0.0.383 → 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 +117 -49
- package/dist/247420.js +22 -22
- 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 +26 -1
- package/src/css/app-shell/kits-appended.css +26 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
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",
|
package/scripts/lint-css.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
+
});
|