anentrypoint-design 0.0.164 → 0.0.166
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/chat.css +101 -2
- package/dist/247420.css +224 -2
- package/dist/247420.js +13 -13
- package/package.json +1 -1
- package/src/community-app.js +233 -0
- package/src/components/agent-chat.js +156 -0
- package/src/components.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.166",
|
|
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",
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// community-app — the full chat/community application GUI, owned by the design
|
|
2
|
+
// system. A consumer (e.g. zellous over Nostr) supplies an `adapter` that maps
|
|
3
|
+
// its own data + actions to the contract below; this module composes every
|
|
4
|
+
// surface (rail, chat, members, voice, user panel, overlays) and wires it to
|
|
5
|
+
// the adapter. The consumer never touches component internals — it only feeds
|
|
6
|
+
// data and receives action callbacks.
|
|
7
|
+
//
|
|
8
|
+
// Adapter contract (all fields optional; the app degrades when one is absent):
|
|
9
|
+
// adapter.get() -> snapshot {
|
|
10
|
+
// channels, categories, servers, currentChannel, currentServerId, homeMode,
|
|
11
|
+
// messages, chatInputValue, currentUser, userId,
|
|
12
|
+
// isConnected, voiceConnected, voiceChannelName, voiceConnectionState,
|
|
13
|
+
// voiceParticipants, micMuted, voiceDeafened,
|
|
14
|
+
// audioQueueItems, audioQueueCurrentId, audioQueuePaused,
|
|
15
|
+
// showAuthModal, settingsOpen, voiceSettingsOpen, replyTarget
|
|
16
|
+
// }
|
|
17
|
+
// adapter.subscribe(cb) -> unsubscribe // cb fires when any snapshot field changes
|
|
18
|
+
// adapter.actions = {
|
|
19
|
+
// switchChannel(ch), send(text, opts), toggleMic(), toggleDeafen(),
|
|
20
|
+
// leaveVoice(), toggleMembers(), openMobileMenu(), openSettings(),
|
|
21
|
+
// channelContext(id, x, y), serverContext(id, x, y), switchServer(id),
|
|
22
|
+
// goHome(), openServers(), memberMenu(id, name, x, y),
|
|
23
|
+
// replaySegment(id), skipSegment(), pauseQueue(), resumeQueue(),
|
|
24
|
+
// setInput(v), cancelReply()
|
|
25
|
+
// }
|
|
26
|
+
// adapter.helpers = { avatarColor(id), initial(name), formatTime(ts) }
|
|
27
|
+
//
|
|
28
|
+
// Returns { render, destroy }.
|
|
29
|
+
|
|
30
|
+
import * as webjsx from '../vendor/webjsx/index.js';
|
|
31
|
+
import { Icon } from './components/shell.js';
|
|
32
|
+
import { Chat, ChatComposer } from './components/chat.js';
|
|
33
|
+
import {
|
|
34
|
+
ServerRail, ChannelItem, MemberList, MobileHeader,
|
|
35
|
+
UserPanel, VoiceStrip, VoiceUser, ThreadPanel, ForumView, PageView, Banner,
|
|
36
|
+
} from './components/community.js';
|
|
37
|
+
import { VoiceControls, VoiceSettingsModal, AudioQueue, PttButton, VadMeter, WebcamPreview } from './components/voice.js';
|
|
38
|
+
import { ContextMenu } from './components/editor-primitives.js';
|
|
39
|
+
import { EmojiPicker, CommandPalette, AuthModal, BootOverlay, SettingsPopover, VideoLightbox } from './components/overlay-primitives.js';
|
|
40
|
+
|
|
41
|
+
const h = webjsx.createElement;
|
|
42
|
+
|
|
43
|
+
const CHANNEL_ICON = { voice: 'speaker', forum: 'forum', threaded: 'thread', announcement: 'megaphone', page: 'page', text: 'hash' };
|
|
44
|
+
|
|
45
|
+
export function mountCommunityApp(root, adapter = {}) {
|
|
46
|
+
if (!root) throw new Error('mountCommunityApp: root required');
|
|
47
|
+
const get = typeof adapter.get === 'function' ? adapter.get : () => ({});
|
|
48
|
+
const A = adapter.actions || {};
|
|
49
|
+
const H = adapter.helpers || {};
|
|
50
|
+
const avatarColor = H.avatarColor || (() => 'var(--accent)');
|
|
51
|
+
const initial = H.initial || ((n) => String(n || '?').slice(0, 1).toUpperCase());
|
|
52
|
+
const formatTime = H.formatTime || ((t) => new Date(t || Date.now()).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }));
|
|
53
|
+
|
|
54
|
+
// overlay state owned by the app module (imperative surfaces the consumer triggers)
|
|
55
|
+
let ctx = { open: false, x: 0, y: 0, items: [] };
|
|
56
|
+
let emoji = { open: false, x: 0, y: 0, onSelect: null };
|
|
57
|
+
let palette = { open: false, items: [], onSelect: null };
|
|
58
|
+
|
|
59
|
+
const railView = (s) => {
|
|
60
|
+
const out = [];
|
|
61
|
+
const channels = [...(s.channels || [])].sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
62
|
+
const text = channels.filter(c => c.type !== 'voice' && c.type !== 'threaded');
|
|
63
|
+
const voice = channels.filter(c => c.type === 'voice' || c.type === 'threaded');
|
|
64
|
+
const cur = s.currentChannel || {};
|
|
65
|
+
const servers = s.servers || [];
|
|
66
|
+
if (text.length) {
|
|
67
|
+
out.push(h('div', { class: 'group' }, 'rooms'));
|
|
68
|
+
for (const c of text) out.push(railPill(c, cur, false, s));
|
|
69
|
+
} else if (!servers.length) {
|
|
70
|
+
out.push(h('div', { class: 'group' }, 'rooms'));
|
|
71
|
+
out.push(h('div', { class: 'rail-empty' }, 'no channels yet'));
|
|
72
|
+
}
|
|
73
|
+
if (voice.length) {
|
|
74
|
+
out.push(h('div', { class: 'group' }, 'voice'));
|
|
75
|
+
for (const c of voice) out.push(railPill(c, cur, true, s));
|
|
76
|
+
}
|
|
77
|
+
if (servers.length) {
|
|
78
|
+
out.push(h('div', { class: 'group' }, 'servers'));
|
|
79
|
+
out.push(railServerPill({ name: 'home', _home: true }, s));
|
|
80
|
+
for (const sv of servers) out.push(railServerPill(sv, s));
|
|
81
|
+
}
|
|
82
|
+
return h('div', {}, ...out);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const railPill = (c, cur, isVoice, s) => {
|
|
86
|
+
const active = cur.id === c.id;
|
|
87
|
+
const inVoice = isVoice && s.voiceConnected && s.voiceChannelName === c.name;
|
|
88
|
+
const glyph = inVoice ? h('span', { class: 'glyph' }, '●')
|
|
89
|
+
: (c.type === 'threaded' ? h('span', { class: 'glyph' }, '◉')
|
|
90
|
+
: h('span', { class: 'glyph' }, Icon(CHANNEL_ICON[c.type] || 'hash', { size: 15 })));
|
|
91
|
+
return h('a', {
|
|
92
|
+
href: '#', class: active ? 'active' : '',
|
|
93
|
+
onclick: (e) => { e.preventDefault(); A.switchChannel && A.switchChannel(c); },
|
|
94
|
+
oncontextmenu: (e) => { e.preventDefault(); A.channelContext && A.channelContext(c.id, e.clientX, e.clientY); },
|
|
95
|
+
}, glyph, h('span', {}, c.name || c.id),
|
|
96
|
+
c.unreadCount ? h('span', { class: 'count' }, c.unreadCount > 99 ? '99+' : String(c.unreadCount)) : null);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const railServerPill = (sv, s) => {
|
|
100
|
+
const active = sv._home ? s.homeMode : (!s.homeMode && s.currentServerId === sv.id);
|
|
101
|
+
return h('a', {
|
|
102
|
+
href: '#', class: active ? 'active' : '',
|
|
103
|
+
onclick: (e) => { e.preventDefault(); sv._home ? (A.goHome && A.goHome()) : (A.switchServer && A.switchServer(sv.id)); },
|
|
104
|
+
oncontextmenu: sv._home ? null : (e) => { e.preventDefault(); A.serverContext && A.serverContext(sv.id, e.clientX, e.clientY); },
|
|
105
|
+
}, h('span', { class: 'glyph' }, sv._home ? '◆' : (sv.name || '?').slice(0, 1).toUpperCase()),
|
|
106
|
+
h('span', {}, sv.name || sv.id),
|
|
107
|
+
sv.unreadCount ? h('span', { class: 'count' }, sv.unreadCount > 99 ? '99+' : String(sv.unreadCount)) : null);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const CODE_FENCE_RE = /^```([a-zA-Z0-9_+-]*)\n([\s\S]*?)\n?```\s*$/;
|
|
111
|
+
const partsFromMessage = (m) => {
|
|
112
|
+
const parts = [];
|
|
113
|
+
if (m.replyTo) {
|
|
114
|
+
const who = m.replyTo.username || 'User';
|
|
115
|
+
const quoted = (m.replyTo.content || '').replace(/\n/g, ' ').slice(0, 120);
|
|
116
|
+
parts.push({ kind: 'md', text: '> **@' + who + ':** ' + quoted });
|
|
117
|
+
}
|
|
118
|
+
const content = m.content || '';
|
|
119
|
+
const fence = content.match(CODE_FENCE_RE);
|
|
120
|
+
if (m.type === 'code' || fence) parts.push({ kind: 'code', code: fence ? fence[2] : content, lang: fence ? fence[1] : (m.lang || '') });
|
|
121
|
+
else if (m.type === 'image') { const src = m.url || m.imageUrl || m.src; if (src) parts.push({ kind: 'image', src, alt: m.alt || '', caption: m.caption }); else if (content) parts.push({ kind: 'md', text: content }); }
|
|
122
|
+
else if (m.type === 'file') parts.push({ kind: 'file', src: m.url || m.fileUrl || m.src, name: m.name || m.filename || 'attachment', size: m.size });
|
|
123
|
+
else if (content) parts.push({ kind: 'md', text: content });
|
|
124
|
+
if (Array.isArray(m.attachments)) for (const a of m.attachments) {
|
|
125
|
+
if (!a) continue;
|
|
126
|
+
if (a.type === 'image' && (a.src || a.url)) parts.push({ kind: 'image', src: a.src || a.url, alt: a.alt || '', caption: a.caption });
|
|
127
|
+
else if ((a.src || a.url) && (a.name || a.filename)) parts.push({ kind: 'file', src: a.src || a.url, name: a.name || a.filename, size: a.size });
|
|
128
|
+
}
|
|
129
|
+
return parts;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const mapMessages = (s) => {
|
|
133
|
+
const chatMsgs = s.messages || [];
|
|
134
|
+
const selfId = s.userId;
|
|
135
|
+
return chatMsgs.map((m, i) => {
|
|
136
|
+
if (m.type === 'system') return { key: m.id || ('sys' + i), who: 'them', name: '', parts: [{ kind: 'md', text: '_' + (m.text || '') + '_' }] };
|
|
137
|
+
const username = (A.resolveProfile && A.resolveProfile(m.userId)) || m.username || 'User';
|
|
138
|
+
const isYou = selfId && String(m.userId) === String(selfId);
|
|
139
|
+
const reactions = Array.isArray(m.reactions) ? m.reactions.map(r => ({ emoji: r.emoji, count: r.count != null ? r.count : (r.users ? r.users.length : 1), you: !!(r.you || (r.users && selfId && r.users.includes(selfId))) })) : null;
|
|
140
|
+
return { key: m.id || ('m' + i), who: isYou ? 'you' : 'them', name: isYou ? null : username, avatar: initial(username), time: formatTime(m.timestamp), parts: partsFromMessage(m), reactions, receipt: isYou && m.read ? 'read' : (isYou && m.delivered ? 'delivered' : null) };
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const chatView = (s) => {
|
|
145
|
+
const ch = s.currentChannel || {};
|
|
146
|
+
const sub = ch.type === 'voice' ? 'voice' : ch.type === 'forum' ? 'forum' : ch.type === 'page' ? 'page' : ch.type === 'announcement' ? 'announcement' : 'public';
|
|
147
|
+
return Chat({
|
|
148
|
+
title: ch.name || 'general', sub, messages: mapMessages(s), header: null,
|
|
149
|
+
composer: ChatComposer({
|
|
150
|
+
value: s.chatInputValue || '',
|
|
151
|
+
placeholder: 'message #' + (ch.name || 'general') + '…',
|
|
152
|
+
onInput: (v) => A.setInput && A.setInput(v),
|
|
153
|
+
onSend: (v) => { const t = (v || '').trim(); if (t) A.send && A.send(t); },
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const voiceView = (s) => h('div', { class: 'vx-view' },
|
|
159
|
+
h('div', { class: 'vx-grid' }, ...(s.voiceParticipants || []).map((p, i) => VoiceUser({ ...p, key: p.identity || p.id || i }))),
|
|
160
|
+
s.webcamEnabled ? WebcamPreview({ videoStream: s.webcamStream, resolution: s.webcamResolution, fps: s.webcamFps, enabled: true }) : null,
|
|
161
|
+
s.pttUiMode === 'vad' ? VadMeter({ level: s.micRawLevel || 0, threshold: s.vadThreshold, onThresholdChange: (t) => A.setVadThreshold && A.setVadThreshold(t) }) : null,
|
|
162
|
+
s.pttUiMode === 'ptt' || s.pttUiMode == null ? PttButton({ state: s.isSpeaking ? 'live' : 'idle', mode: 'ptt', onHoldStart: () => A.pttStart && A.pttStart(), onHoldEnd: () => A.pttStop && A.pttStop() }) : null,
|
|
163
|
+
VoiceControls({
|
|
164
|
+
muted: !!s.micMuted, deafened: !!s.voiceDeafened,
|
|
165
|
+
onMic: () => A.toggleMic && A.toggleMic(),
|
|
166
|
+
onDeafen: () => A.toggleDeafen && A.toggleDeafen(),
|
|
167
|
+
onSettings: () => A.openVoiceSettings && A.openVoiceSettings(),
|
|
168
|
+
onLeave: () => A.leaveVoice && A.leaveVoice(),
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const view = () => {
|
|
173
|
+
const s = get();
|
|
174
|
+
const ch = s.currentChannel || {};
|
|
175
|
+
const inVoiceChannel = ch.type === 'voice';
|
|
176
|
+
const bodyMain = inVoiceChannel ? voiceView(s)
|
|
177
|
+
: ch.type === 'forum' ? ForumView({ posts: s.forumPosts || [], onSelect: (id) => A.openThread && A.openThread(id), onNewPost: () => A.newForumPost && A.newForumPost() })
|
|
178
|
+
: ch.type === 'page' ? PageView({ title: ch.name, html: s.pageHtml || '', isAdmin: !!s.canManage, onEdit: () => A.editPage && A.editPage() })
|
|
179
|
+
: chatView(s);
|
|
180
|
+
const showVoiceBanner = s.voiceConnected && s.voiceChannelName && !(inVoiceChannel && s.voiceChannelName === ch.name);
|
|
181
|
+
return h('div', { class: 'ca-app' },
|
|
182
|
+
// top bar (sole app chrome above the chat-head)
|
|
183
|
+
h('header', { class: 'app-topbar' },
|
|
184
|
+
h('span', { class: 'brand' }, 'zellous', h('span', { class: 'slash' }, ' / '), h('span', {}, ch.name || 'general')),
|
|
185
|
+
h('span', {}),
|
|
186
|
+
h('nav', {},
|
|
187
|
+
h('a', { href: '../', title: 'Home', onclick: (e) => { if (A.goHome) { e.preventDefault(); A.goHome(); } } }, 'home'),
|
|
188
|
+
h('a', { href: '#', title: 'Servers', onclick: (e) => { e.preventDefault(); A.openServers && A.openServers(); } }, 'servers'),
|
|
189
|
+
h('a', { href: 'https://github.com/AnEntrypoint/zellous', target: '_blank', rel: 'noopener' }, 'source ↗'),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
MobileHeader({ channelType: ch.type || 'text', channelName: ch.name || '', onMenu: () => A.openMobileMenu && A.openMobileMenu(), onMembers: () => A.toggleMembers && A.toggleMembers() }),
|
|
193
|
+
Banner({ tone: 'warning', message: 'No relay connected. Reconnecting…', visible: s.isConnected === false }),
|
|
194
|
+
Banner({ tone: 'success', visible: !!showVoiceBanner, message: showVoiceBanner ? ('In voice: ' + (s.voiceChannelName || '') + ' — click to return') : '', actionLabel: 'Leave', onAction: (e) => { if (e && e.stopPropagation) e.stopPropagation(); A.leaveVoice && A.leaveVoice(); }, onClick: () => A.returnToVoice && A.returnToVoice() }),
|
|
195
|
+
h('div', { class: 'app-body' },
|
|
196
|
+
h('aside', { class: 'app-side ca-rail' }, railView(s)),
|
|
197
|
+
h('main', { class: 'app-main' },
|
|
198
|
+
!inVoiceChannel && s.voiceConnected ? VoiceStrip({ channelName: s.voiceChannelName, status: s.voiceConnectionState || 'connected', muted: !!s.micMuted, deafened: !!s.voiceDeafened, onMute: () => A.toggleMic && A.toggleMic(), onDeafen: () => A.toggleDeafen && A.toggleDeafen(), onLeave: () => A.leaveVoice && A.leaveVoice(), open: true }) : null,
|
|
199
|
+
UserPanel({ name: (s.currentUser && (s.currentUser.displayName || s.currentUser.username || s.currentUser.name)) || 'You', tag: s.currentUser && s.currentUser.tag, color: avatarColor(s.userId), muted: !!s.micMuted, deafened: !!s.voiceDeafened, onMute: () => A.toggleMic && A.toggleMic(), onDeafen: () => A.toggleDeafen && A.toggleDeafen(), onSettings: () => A.openSettings && A.openSettings() }),
|
|
200
|
+
bodyMain,
|
|
201
|
+
),
|
|
202
|
+
MemberList({ categories: s.memberCategories || [], open: !!s.memberListOpen }),
|
|
203
|
+
),
|
|
204
|
+
// overlays
|
|
205
|
+
ctx.open ? ContextMenu({ items: ctx.items, anchor: { x: ctx.x, y: ctx.y }, onClose: () => { ctx = { ...ctx, open: false }; render(); } }) : null,
|
|
206
|
+
emoji.open ? EmojiPicker({ open: true, anchorX: emoji.x, anchorY: emoji.y, onSelect: (em) => { try { emoji.onSelect && emoji.onSelect(em); } catch (_) {} emoji = { ...emoji, open: false }; render(); }, onClose: () => { emoji = { ...emoji, open: false }; render(); } }) : null,
|
|
207
|
+
palette.open ? CommandPalette({ open: true, items: palette.items, onSelect: (it) => { try { palette.onSelect && palette.onSelect(it); } catch (_) {} palette = { ...palette, open: false }; render(); }, onClose: () => { palette = { ...palette, open: false }; render(); } }) : null,
|
|
208
|
+
// global overlays (visibility driven by adapter snapshot)
|
|
209
|
+
s.showAuthModal ? AuthModal({ open: true, mode: s.authMode || 'extension', error: s.authError || '', busy: !!s.authBusy, onModeChange: (m) => A.setAuthMode && A.setAuthMode(m), onConnectExtension: () => A.authExtension && A.authExtension(), onGenerate: () => A.authGenerate && A.authGenerate(), onImport: (k) => A.authImport && A.authImport(k), onClose: () => A.closeAuth && A.closeAuth() }) : null,
|
|
210
|
+
BootOverlay({ progress: s.bootProgress || 0, phase: s.bootPhase || '', errored: !!s.bootErrored, visible: !!s.bootVisible }),
|
|
211
|
+
s.settingsOpen ? SettingsPopover({ open: true, anchorX: (s.settingsAnchor && s.settingsAnchor.x) || 0, anchorY: (s.settingsAnchor && s.settingsAnchor.y) || 0, sections: s.settingsSections || [], onClose: () => A.openSettings && A.openSettings() }) : null,
|
|
212
|
+
s.voiceSettingsOpen ? VoiceSettingsModal({ open: true, mode: s.voiceMode || 'ptt', inputId: s.inputDeviceId, outputId: s.outputDeviceId, inputDevices: s.inputDevices || [], outputDevices: s.outputDevices || [], vadThreshold: s.vadThreshold, rnnoise: !!s.rnnoiseEnabled, autoGain: !!s.autoGainEnabled, forceTurn: !!s.forceTurnEnabled, bitrate: s.voiceBitrate, volume: s.masterVolume, onChange: (p) => A.voiceSettingsChange && A.voiceSettingsChange(p), onSave: () => A.voiceSettingsSave && A.voiceSettingsSave(), onCancel: () => A.voiceSettingsClose && A.voiceSettingsClose(), onClose: () => A.voiceSettingsClose && A.voiceSettingsClose() }) : null,
|
|
213
|
+
s.videoLightbox && s.videoLightbox.open ? VideoLightbox({ open: true, src: s.videoLightbox.src, label: s.videoLightbox.label, onClose: () => A.closeVideoLightbox && A.closeVideoLightbox() }) : null,
|
|
214
|
+
(s.audioQueueItems && s.audioQueueItems.length) ? AudioQueue({ segments: s.audioQueueItems, currentSegmentId: s.audioQueueCurrentId, paused: !!s.audioQueuePaused, onReplay: (id) => A.replaySegment && A.replaySegment(id), onSkip: () => A.skipSegment && A.skipSegment(), onResume: () => A.resumeQueue && A.resumeQueue(), onPause: () => A.pauseQueue && A.pauseQueue() }) : null,
|
|
215
|
+
s.threadPanelOpen ? ThreadPanel({ threads: s.threads || [], activeId: s.activeThreadId, onSelect: (id) => A.selectThread && A.selectThread(id), onCreate: () => A.createThread && A.createThread(), onClose: () => A.closeThreadPanel && A.closeThreadPanel() }) : null,
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const render = () => { webjsx.applyDiff(root, view()); };
|
|
220
|
+
|
|
221
|
+
// imperative overlay API the consumer (and its modules) can call
|
|
222
|
+
const api = {
|
|
223
|
+
contextMenu: { show: (items, x, y) => { ctx = { open: true, x: x | 0, y: y | 0, items: Array.isArray(items) ? items : [] }; render(); }, close: () => { ctx = { ...ctx, open: false }; render(); } },
|
|
224
|
+
emojiPicker: { show: (x, y, onSelect) => { emoji = { open: true, x: x || 200, y: y || 200, onSelect }; render(); }, close: () => { emoji = { ...emoji, open: false }; render(); } },
|
|
225
|
+
commandPalette: { show: (items, onSelect) => { palette = { open: true, items: items || [], onSelect }; render(); }, close: () => { palette = { ...palette, open: false }; render(); } },
|
|
226
|
+
render,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let unsub = null;
|
|
230
|
+
if (typeof adapter.subscribe === 'function') unsub = adapter.subscribe(render);
|
|
231
|
+
render();
|
|
232
|
+
return { render, api, destroy: () => { if (unsub) try { unsub(); } catch (_) {} } };
|
|
233
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// AgentChat — a reusable multi-agent orchestration chat surface.
|
|
2
|
+
//
|
|
3
|
+
// This kit takes the best of two surfaces: agentgui's orchestration chat
|
|
4
|
+
// (agent-then-model picker, streamed tool_use/tool_result parts, resume + cwd
|
|
5
|
+
// controls, error alerts) and the AICat chat thread (IntersectionObserver
|
|
6
|
+
// auto-scroll, a thinking indicator, polished head). It is a PURE component:
|
|
7
|
+
// props in, vnode out. It holds NO transport — every server interaction is a
|
|
8
|
+
// callback the host wires (WebSocket, fetch, SSE, whatever). That keeps the kit
|
|
9
|
+
// reusable by any app, not just agentgui.
|
|
10
|
+
//
|
|
11
|
+
// The host owns state; AgentChat renders it and calls back on intent.
|
|
12
|
+
|
|
13
|
+
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
14
|
+
import { ChatComposer, ChatMessage } from './chat.js';
|
|
15
|
+
import { Select } from './content.js';
|
|
16
|
+
import { Btn } from './shell.js';
|
|
17
|
+
|
|
18
|
+
const h = webjsx.createElement;
|
|
19
|
+
|
|
20
|
+
// Auto-scroll a thread to the bottom while the user is already near the bottom,
|
|
21
|
+
// via an IntersectionObserver on a sentinel — the AICat scroll behaviour, lifted
|
|
22
|
+
// so it works for any message list without a per-frame scrollTop write.
|
|
23
|
+
function threadRef(msgCount) {
|
|
24
|
+
return (el) => {
|
|
25
|
+
if (!el) return;
|
|
26
|
+
let sentinel = el.querySelector('[data-scroll-sentinel]');
|
|
27
|
+
if (!sentinel) {
|
|
28
|
+
sentinel = document.createElement('div');
|
|
29
|
+
sentinel.setAttribute('data-scroll-sentinel', '');
|
|
30
|
+
sentinel.style.height = '1px';
|
|
31
|
+
el.appendChild(sentinel);
|
|
32
|
+
}
|
|
33
|
+
const obs = new IntersectionObserver((entries) => {
|
|
34
|
+
if (entries[0]?.isIntersecting && el.dataset.msgCount !== String(msgCount)) {
|
|
35
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
36
|
+
el.dataset.msgCount = String(msgCount);
|
|
37
|
+
}
|
|
38
|
+
}, { root: el, threshold: 0 });
|
|
39
|
+
obs.observe(sentinel);
|
|
40
|
+
el.dataset.msgCount = String(msgCount);
|
|
41
|
+
return () => obs.disconnect();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The agent picker: agent-then-model, not a flat model list. Unavailable agents
|
|
46
|
+
// are disabled (unless installable via npx). Ordering is the host's concern.
|
|
47
|
+
function AgentControls({ agents, selectedAgent, models, selectedModel, busy, status,
|
|
48
|
+
onSelectAgent, onSelectModel, onNewChat, onStop }) {
|
|
49
|
+
const agentOptions = (agents || []).map((a) => ({
|
|
50
|
+
value: a.id,
|
|
51
|
+
label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
|
|
52
|
+
disabled: a.available === false && !a.npxInstallable,
|
|
53
|
+
}));
|
|
54
|
+
const showModels = (models || []).length > 0;
|
|
55
|
+
return h('div', { class: 'agentchat-controls' },
|
|
56
|
+
Select({
|
|
57
|
+
key: 'agentsel', value: selectedAgent, placeholder: '— agent —',
|
|
58
|
+
title: 'Select agent', options: agentOptions,
|
|
59
|
+
onChange: (v) => onSelectAgent && onSelectAgent(v),
|
|
60
|
+
}),
|
|
61
|
+
showModels
|
|
62
|
+
? Select({
|
|
63
|
+
key: 'modelsel', value: selectedModel, placeholder: '— model —',
|
|
64
|
+
title: 'Select model', options: (models || []).map((m) => ({ value: m.id, label: m.name || m.id })),
|
|
65
|
+
onChange: (v) => onSelectModel && onSelectModel(v),
|
|
66
|
+
})
|
|
67
|
+
: null,
|
|
68
|
+
busy
|
|
69
|
+
? Btn({ key: 'stop', onClick: () => onStop && onStop(), children: 'stop', title: 'Stop streaming' })
|
|
70
|
+
: Btn({ key: 'new', onClick: () => onNewChat && onNewChat(), children: 'new', title: 'New chat' }),
|
|
71
|
+
h('span', { key: 'st', class: 'agentchat-status', role: 'status', 'aria-live': 'polite' },
|
|
72
|
+
h('span', { class: 'status-dot-disc ' + (busy ? 'status-dot-live' : ''), 'aria-hidden': 'true' }),
|
|
73
|
+
h('span', {}, status || (busy ? 'streaming…' : 'ready'))),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A working-directory bar: shows where the agent will run, editable + clearable.
|
|
78
|
+
function CwdBar({ cwd, editing, draft, onEdit, onSave, onCancel, onClear, onDraft }) {
|
|
79
|
+
if (editing) {
|
|
80
|
+
return h('div', { class: 'agentchat-cwd agentchat-cwd-editing', role: 'group', 'aria-label': 'Set working directory' },
|
|
81
|
+
h('input', { class: 'agentchat-cwd-input', type: 'text', value: draft ?? cwd ?? '',
|
|
82
|
+
placeholder: 'absolute path (blank = server default)',
|
|
83
|
+
oninput: (e) => onDraft && onDraft(e.target.value) }),
|
|
84
|
+
Btn({ key: 'save', primary: true, onClick: () => onSave && onSave(), children: 'save' }),
|
|
85
|
+
Btn({ key: 'cancel', onClick: () => onCancel && onCancel(), children: 'cancel' }));
|
|
86
|
+
}
|
|
87
|
+
return h('div', { class: 'agentchat-cwd', role: 'group', 'aria-label': 'Working directory' },
|
|
88
|
+
h('span', { class: 'agentchat-cwd-text', title: cwd || 'server default working directory' },
|
|
89
|
+
'cwd: ' + (cwd || 'server default')),
|
|
90
|
+
h('button', { type: 'button', class: 'agentchat-cwd-btn', onclick: () => onEdit && onEdit() }, cwd ? 'change' : 'set'),
|
|
91
|
+
cwd ? h('button', { type: 'button', class: 'agentchat-cwd-btn', onclick: () => onClear && onClear() }, 'use default') : null);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// AgentChat — the composed surface.
|
|
95
|
+
// agents, selectedAgent, models, selectedModel : picker state
|
|
96
|
+
// messages : [{ id, role:'user'|'assistant', content, time, parts:[string] }]
|
|
97
|
+
// busy, draft, status : stream + composer state
|
|
98
|
+
// cwd, cwdEditing, cwdDraft : working-directory bar
|
|
99
|
+
// banners : array of pre-built Alert vnodes (errors, resume, unavailable)
|
|
100
|
+
// onSelectAgent/onSelectModel/onSend/onStop/onNewChat/onInput
|
|
101
|
+
// onCwdEdit/onCwdSave/onCwdCancel/onCwdClear/onCwdDraft
|
|
102
|
+
export function AgentChat(props = {}) {
|
|
103
|
+
const {
|
|
104
|
+
agents = [], selectedAgent = '', models = [], selectedModel = '',
|
|
105
|
+
messages = [], busy = false, draft = '', status, banners = [],
|
|
106
|
+
cwd = '', cwdEditing = false, cwdDraft,
|
|
107
|
+
agentName, placeholder,
|
|
108
|
+
onSelectAgent, onSelectModel, onSend, onStop, onNewChat, onInput,
|
|
109
|
+
onCwdEdit, onCwdSave, onCwdCancel, onCwdClear, onCwdDraft,
|
|
110
|
+
canSend = true,
|
|
111
|
+
} = props;
|
|
112
|
+
|
|
113
|
+
const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
|
|
114
|
+
const lastIdx = messages.length - 1;
|
|
115
|
+
const rows = messages.map((m, i) => {
|
|
116
|
+
const isAssistant = m.role === 'assistant';
|
|
117
|
+
const isStreaming = busy && i === lastIdx && isAssistant;
|
|
118
|
+
const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
|
|
119
|
+
const emptyStreaming = isStreaming && !m.content && !hasParts;
|
|
120
|
+
const parts = [];
|
|
121
|
+
if (m.content) parts.push({ kind: isAssistant ? 'md' : 'text', text: m.content });
|
|
122
|
+
if (hasParts) for (const p of m.parts) parts.push({ kind: 'text', text: p });
|
|
123
|
+
return ChatMessage({
|
|
124
|
+
key: m.id || String(i),
|
|
125
|
+
who: isAssistant ? 'them' : 'you',
|
|
126
|
+
aicat: isAssistant,
|
|
127
|
+
name: isAssistant ? name : 'you',
|
|
128
|
+
time: m.time || '',
|
|
129
|
+
typing: emptyStreaming,
|
|
130
|
+
parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const composer = ChatComposer({
|
|
135
|
+
value: draft,
|
|
136
|
+
disabled: !canSend,
|
|
137
|
+
placeholder: placeholder || (selectedAgent ? 'message…' : 'choose an agent first'),
|
|
138
|
+
onInput: (v) => onInput && onInput(v),
|
|
139
|
+
onSend: (v) => onSend && onSend(v),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return h('div', { class: 'agentchat' },
|
|
143
|
+
AgentControls({ agents, selectedAgent, models, selectedModel, busy, status,
|
|
144
|
+
onSelectAgent, onSelectModel, onNewChat, onStop }),
|
|
145
|
+
CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft,
|
|
146
|
+
onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
|
|
147
|
+
...(banners || []).filter(Boolean),
|
|
148
|
+
h('div', { class: 'agentchat-head', role: 'banner' },
|
|
149
|
+
h('h2', { class: 'agentchat-title' }, name + (selectedModel ? ' · ' + selectedModel : '')),
|
|
150
|
+
h('span', { class: 'agentchat-sub', 'aria-live': 'polite' },
|
|
151
|
+
busy ? 'streaming…' : (messages.length ? messages.length + (messages.length === 1 ? ' message' : ' messages') : ''))),
|
|
152
|
+
h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation' },
|
|
153
|
+
...rows),
|
|
154
|
+
composer,
|
|
155
|
+
);
|
|
156
|
+
}
|
package/src/components.js
CHANGED
|
@@ -24,6 +24,8 @@ export {
|
|
|
24
24
|
AICAT_FACE, AICatPortrait, AICat
|
|
25
25
|
} from './components/chat.js';
|
|
26
26
|
|
|
27
|
+
export { AgentChat } from './components/agent-chat.js';
|
|
28
|
+
|
|
27
29
|
export {
|
|
28
30
|
fileGlyph, fmtFileSize,
|
|
29
31
|
FileIcon, FileRow, FileGrid, FileToolbar,
|
|
@@ -86,3 +88,5 @@ export {
|
|
|
86
88
|
models, cron, skills, config, env, tools, batch, gateway, chains,
|
|
87
89
|
skillLabel, getRecentPaths, saveRecentPath, renderChatMessages
|
|
88
90
|
} from './components/freddie.js';
|
|
91
|
+
|
|
92
|
+
export { mountCommunityApp } from './community-app.js';
|