@harborgroup/my-team 0.1.0 → 0.3.0
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/README.md +27 -1
- package/dist/init.js +4 -20
- package/dist/npm-install.js +13 -0
- package/dist/own-package.js +9 -0
- package/dist/server/auth-check.js +25 -14
- package/dist/server/chat-session.js +45 -15
- package/dist/server/http-server.js +50 -5
- package/dist/server/logger.js +68 -0
- package/dist/server/profile.js +148 -0
- package/dist/server/start.js +12 -1
- package/dist/server/update-check.js +32 -0
- package/dist/web/app.js +683 -31
- package/dist/web/index.html +171 -30
- package/dist/web/style.css +752 -52
- package/package.json +1 -1
package/dist/web/app.js
CHANGED
|
@@ -6,13 +6,70 @@ const el = {
|
|
|
6
6
|
cliMissing: document.getElementById('onboarding-cli-missing'),
|
|
7
7
|
notAuthenticated: document.getElementById('onboarding-not-authenticated'),
|
|
8
8
|
checkAgain: document.getElementById('check-again'),
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
appShell: document.getElementById('app-shell'),
|
|
10
|
+
|
|
11
|
+
updateBanner: document.getElementById('update-banner'),
|
|
12
|
+
updateBannerText: document.getElementById('update-banner-text'),
|
|
13
|
+
updateBannerButton: document.getElementById('update-banner-button'),
|
|
14
|
+
updateBannerDismiss: document.getElementById('update-banner-dismiss'),
|
|
15
|
+
|
|
16
|
+
railHome: document.getElementById('rail-home'),
|
|
17
|
+
railMessages: document.getElementById('rail-messages'),
|
|
18
|
+
railProfile: document.getElementById('rail-profile'),
|
|
19
|
+
railSettings: document.getElementById('rail-settings'),
|
|
20
|
+
|
|
21
|
+
pageHome: document.getElementById('page-home'),
|
|
22
|
+
pageMessages: document.getElementById('page-messages'),
|
|
23
|
+
pageProfile: document.getElementById('page-profile'),
|
|
24
|
+
pageSettings: document.getElementById('page-settings'),
|
|
25
|
+
|
|
26
|
+
homeAvatar: document.getElementById('home-avatar'),
|
|
27
|
+
homeGreeting: document.getElementById('home-greeting'),
|
|
28
|
+
homeSub: document.getElementById('home-sub'),
|
|
29
|
+
homeGoMessages: document.getElementById('home-go-messages'),
|
|
30
|
+
|
|
31
|
+
workspaceName: document.getElementById('workspace-name'),
|
|
32
|
+
navGeneral: document.getElementById('nav-general'),
|
|
33
|
+
navCeo: document.getElementById('nav-ceo'),
|
|
34
|
+
ceoAvatar: document.getElementById('ceo-avatar'),
|
|
35
|
+
ceoNavName: document.getElementById('ceo-nav-name'),
|
|
36
|
+
ceoStatusDot: document.getElementById('ceo-status-dot'),
|
|
37
|
+
channelTitle: document.getElementById('channel-title'),
|
|
38
|
+
channelSubtitle: document.getElementById('channel-subtitle'),
|
|
39
|
+
channelGeneral: document.getElementById('channel-general'),
|
|
40
|
+
channelCeo: document.getElementById('channel-ceo'),
|
|
11
41
|
messages: document.getElementById('messages'),
|
|
42
|
+
typingIndicator: document.getElementById('typing-indicator'),
|
|
43
|
+
typingAvatar: document.getElementById('typing-avatar'),
|
|
12
44
|
form: document.getElementById('chat-form'),
|
|
13
45
|
input: document.getElementById('chat-input'),
|
|
46
|
+
sendButton: document.getElementById('chat-send'),
|
|
47
|
+
|
|
48
|
+
profileForm: document.getElementById('profile-form'),
|
|
49
|
+
profileCompany: document.getElementById('profile-company'),
|
|
50
|
+
profileMission: document.getElementById('profile-mission'),
|
|
51
|
+
profileCeoName: document.getElementById('profile-ceo-name'),
|
|
52
|
+
profileCeoPersonality: document.getElementById('profile-ceo-personality'),
|
|
53
|
+
profileDefaultModel: document.getElementById('profile-default-model'),
|
|
54
|
+
profileDefaultEffort: document.getElementById('profile-default-effort'),
|
|
55
|
+
profileSavedHint: document.getElementById('profile-saved-hint'),
|
|
56
|
+
|
|
57
|
+
chatModelSelect: document.getElementById('chat-model-select'),
|
|
58
|
+
chatEffortSelect: document.getElementById('chat-effort-select'),
|
|
59
|
+
|
|
60
|
+
settingsAccount: document.getElementById('settings-account'),
|
|
61
|
+
settingsLogPath: document.getElementById('settings-log-path'),
|
|
14
62
|
};
|
|
15
63
|
|
|
64
|
+
let lastProfile = null;
|
|
65
|
+
let lastAccountInfo = null;
|
|
66
|
+
let lastModels = [];
|
|
67
|
+
let onboardingKickedOff = false;
|
|
68
|
+
let currentPage = 'messages';
|
|
69
|
+
let currentChannel = 'ceo';
|
|
70
|
+
let statusState = 'offline';
|
|
71
|
+
let metaCache = null;
|
|
72
|
+
|
|
16
73
|
function api(path, options = {}) {
|
|
17
74
|
return fetch(path, {
|
|
18
75
|
...options,
|
|
@@ -20,55 +77,616 @@ function api(path, options = {}) {
|
|
|
20
77
|
});
|
|
21
78
|
}
|
|
22
79
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
80
|
+
function getCeoInitial() {
|
|
81
|
+
const name = lastProfile?.ceoName;
|
|
82
|
+
return name && name !== 'your AI CEO' ? name.trim()[0].toUpperCase() : '?';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderProfileUI(profile) {
|
|
86
|
+
el.workspaceName.textContent = profile?.companyName || 'my-team';
|
|
87
|
+
const ceoName = profile?.ceoName || 'your AI CEO';
|
|
88
|
+
el.ceoNavName.textContent = ceoName;
|
|
89
|
+
el.ceoAvatar.textContent = getCeoInitial();
|
|
90
|
+
if (currentPage === 'messages' && currentChannel === 'ceo') el.channelTitle.textContent = ceoName;
|
|
91
|
+
if (currentPage === 'home') renderHomePage();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function setStatus(state) {
|
|
95
|
+
statusState = state;
|
|
96
|
+
el.ceoStatusDot.classList.remove('online', 'typing');
|
|
97
|
+
if (state === 'online') el.ceoStatusDot.classList.add('online');
|
|
98
|
+
else if (state === 'typing') el.ceoStatusDot.classList.add('typing');
|
|
99
|
+
if (currentPage === 'messages' && currentChannel === 'ceo') {
|
|
100
|
+
el.channelSubtitle.textContent = state === 'typing' ? 'Typing…' : state === 'online' ? 'Active' : 'Offline';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function selectChannel(channel) {
|
|
105
|
+
currentChannel = channel;
|
|
106
|
+
el.navGeneral.classList.toggle('active', channel === 'general');
|
|
107
|
+
el.navCeo.classList.toggle('active', channel === 'ceo');
|
|
108
|
+
el.channelGeneral.hidden = channel !== 'general';
|
|
109
|
+
el.channelCeo.hidden = channel !== 'ceo';
|
|
110
|
+
if (channel === 'general') {
|
|
111
|
+
el.channelTitle.textContent = '# general';
|
|
112
|
+
el.channelSubtitle.textContent = '';
|
|
113
|
+
} else {
|
|
114
|
+
el.channelTitle.textContent = lastProfile?.ceoName || 'your AI CEO';
|
|
115
|
+
setStatus(statusState);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
el.navGeneral.addEventListener('click', () => selectChannel('general'));
|
|
120
|
+
el.navCeo.addEventListener('click', () => selectChannel('ceo'));
|
|
121
|
+
|
|
122
|
+
function modelDisplayName(value) {
|
|
123
|
+
return lastModels.find((m) => m.value === value)?.displayName ?? value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// No synthetic "use the default" placeholder here — the SDK's own
|
|
127
|
+
// supportedModels() list already includes a real "default" entry
|
|
128
|
+
// (displayName "Default (recommended)") that itself supports effort
|
|
129
|
+
// levels. A separate blank option would be a confusing near-duplicate and
|
|
130
|
+
// would incorrectly read as "no model supports effort" to the effort
|
|
131
|
+
// selector below, since '' never matches a real model.
|
|
132
|
+
function populateModelSelect(selectEl) {
|
|
133
|
+
selectEl.innerHTML = '';
|
|
134
|
+
for (const model of lastModels) {
|
|
135
|
+
const opt = document.createElement('option');
|
|
136
|
+
opt.value = model.value;
|
|
137
|
+
opt.textContent = model.displayName;
|
|
138
|
+
selectEl.appendChild(opt);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Effort options are constrained to what the selected model actually
|
|
143
|
+
// supports (confirmed via the SDK's supportedModels() data — e.g. Haiku
|
|
144
|
+
// supports no effort levels at all) so a silent downgrade shouldn't occur
|
|
145
|
+
// in practice; the UI simply never offers an invalid combination.
|
|
146
|
+
function populateEffortSelect(selectEl, modelValue, blankLabel) {
|
|
147
|
+
const info = lastModels.find((m) => m.value === modelValue);
|
|
148
|
+
selectEl.innerHTML = '';
|
|
149
|
+
if (!info?.supportsEffort) {
|
|
150
|
+
selectEl.disabled = true;
|
|
151
|
+
const opt = document.createElement('option');
|
|
152
|
+
opt.value = '';
|
|
153
|
+
opt.textContent = 'N/A';
|
|
154
|
+
selectEl.appendChild(opt);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
selectEl.disabled = false;
|
|
158
|
+
const blank = document.createElement('option');
|
|
159
|
+
blank.value = '';
|
|
160
|
+
blank.textContent = blankLabel;
|
|
161
|
+
selectEl.appendChild(blank);
|
|
162
|
+
for (const level of info.supportedEffortLevels ?? []) {
|
|
163
|
+
const opt = document.createElement('option');
|
|
164
|
+
opt.value = level;
|
|
165
|
+
opt.textContent = level;
|
|
166
|
+
selectEl.appendChild(opt);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function setupModelSelectors() {
|
|
171
|
+
populateModelSelect(el.chatModelSelect);
|
|
172
|
+
populateModelSelect(el.profileDefaultModel);
|
|
173
|
+
|
|
174
|
+
// Assigning select.value a string that matches no <option> clears the
|
|
175
|
+
// selection entirely (selectedIndex -1) rather than falling back to the
|
|
176
|
+
// first option — only assign when there's a real saved default to apply.
|
|
177
|
+
if (lastProfile?.defaultModel) el.chatModelSelect.value = lastProfile.defaultModel;
|
|
178
|
+
populateEffortSelect(el.chatEffortSelect, el.chatModelSelect.value, 'Default effort');
|
|
179
|
+
if (lastProfile?.defaultEffort) el.chatEffortSelect.value = lastProfile.defaultEffort;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
el.chatModelSelect.addEventListener('change', () => {
|
|
183
|
+
populateEffortSelect(el.chatEffortSelect, el.chatModelSelect.value, 'Default effort');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
el.profileDefaultModel.addEventListener('change', () => {
|
|
187
|
+
populateEffortSelect(el.profileDefaultEffort, el.profileDefaultModel.value, 'Default');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function populateProfileForm(profile) {
|
|
191
|
+
el.profileCompany.value = profile?.companyName ?? '';
|
|
192
|
+
el.profileMission.value = profile?.mission ?? '';
|
|
193
|
+
el.profileCeoName.value = profile?.ceoName ?? '';
|
|
194
|
+
el.profileCeoPersonality.value = profile?.ceoPersonality ?? '';
|
|
195
|
+
if (profile?.defaultModel) el.profileDefaultModel.value = profile.defaultModel;
|
|
196
|
+
populateEffortSelect(el.profileDefaultEffort, el.profileDefaultModel.value, 'Default');
|
|
197
|
+
if (profile?.defaultEffort) el.profileDefaultEffort.value = profile.defaultEffort;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderHomePage() {
|
|
201
|
+
el.homeAvatar.textContent = getCeoInitial();
|
|
202
|
+
const ceoName = lastProfile?.ceoName || 'your AI CEO';
|
|
203
|
+
const companyName = lastProfile?.companyName;
|
|
204
|
+
el.homeGreeting.textContent = companyName ? `Welcome to ${companyName}` : 'Welcome to my-team';
|
|
205
|
+
el.homeSub.textContent = lastProfile?.onboardingComplete
|
|
206
|
+
? `${ceoName} is ready and waiting in Messages.`
|
|
207
|
+
: `Head to Messages to finish setting up ${ceoName}.`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function renderSettingsPage() {
|
|
211
|
+
el.settingsAccount.textContent = lastAccountInfo
|
|
212
|
+
? `${lastAccountInfo.email ?? 'unknown'}${lastAccountInfo.subscriptionType ? ` (${lastAccountInfo.subscriptionType})` : ''}`
|
|
213
|
+
: 'unknown';
|
|
214
|
+
if (!metaCache) {
|
|
215
|
+
const res = await api('/api/meta');
|
|
216
|
+
metaCache = await res.json();
|
|
217
|
+
}
|
|
218
|
+
el.settingsLogPath.textContent = metaCache.logFilePath;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function checkForUpdateBanner(retriesLeft) {
|
|
222
|
+
const res = await api('/api/meta');
|
|
223
|
+
metaCache = await res.json();
|
|
224
|
+
|
|
225
|
+
if (metaCache.updateInfo?.updateAvailable) {
|
|
226
|
+
el.updateBannerText.textContent = `A new version of my-team is available (v${metaCache.updateInfo.latestVersion}).`;
|
|
227
|
+
el.updateBanner.hidden = false;
|
|
228
|
+
} else if (!metaCache.updateInfo && retriesLeft > 0) {
|
|
229
|
+
// The registry check runs in the background at server startup and may
|
|
230
|
+
// not have resolved yet — try once more shortly rather than never
|
|
231
|
+
// showing the banner this session.
|
|
232
|
+
setTimeout(() => checkForUpdateBanner(retriesLeft - 1), 3000);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
el.updateBannerButton.addEventListener('click', async () => {
|
|
237
|
+
el.updateBannerButton.disabled = true;
|
|
238
|
+
el.updateBannerButton.textContent = 'Updating…';
|
|
239
|
+
const res = await api('/api/update', { method: 'POST' });
|
|
240
|
+
const result = await res.json();
|
|
241
|
+
if (result.ok) {
|
|
242
|
+
el.updateBannerText.textContent = 'Updated — restart (stop and run "npm run team" again) to use the new version.';
|
|
243
|
+
el.updateBannerButton.hidden = true;
|
|
244
|
+
} else {
|
|
245
|
+
el.updateBannerButton.disabled = false;
|
|
246
|
+
el.updateBannerButton.textContent = 'Update';
|
|
247
|
+
el.updateBannerText.textContent = 'Update failed — check the terminal for details.';
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
el.updateBannerDismiss.addEventListener('click', () => {
|
|
252
|
+
el.updateBanner.hidden = true;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
function selectPage(page) {
|
|
256
|
+
currentPage = page;
|
|
257
|
+
el.railHome.classList.toggle('active', page === 'home');
|
|
258
|
+
el.railMessages.classList.toggle('active', page === 'messages');
|
|
259
|
+
el.railProfile.classList.toggle('active', page === 'profile');
|
|
260
|
+
el.railSettings.classList.toggle('active', page === 'settings');
|
|
261
|
+
el.pageHome.hidden = page !== 'home';
|
|
262
|
+
el.pageMessages.hidden = page !== 'messages';
|
|
263
|
+
el.pageProfile.hidden = page !== 'profile';
|
|
264
|
+
el.pageSettings.hidden = page !== 'settings';
|
|
265
|
+
|
|
266
|
+
if (page === 'home') renderHomePage();
|
|
267
|
+
else if (page === 'profile') populateProfileForm(lastProfile);
|
|
268
|
+
else if (page === 'settings') renderSettingsPage();
|
|
269
|
+
else if (page === 'messages') selectChannel(currentChannel);
|
|
27
270
|
}
|
|
28
271
|
|
|
272
|
+
el.railHome.addEventListener('click', () => selectPage('home'));
|
|
273
|
+
el.railMessages.addEventListener('click', () => selectPage('messages'));
|
|
274
|
+
el.railProfile.addEventListener('click', () => selectPage('profile'));
|
|
275
|
+
el.railSettings.addEventListener('click', () => selectPage('settings'));
|
|
276
|
+
el.homeGoMessages.addEventListener('click', () => selectPage('messages'));
|
|
277
|
+
|
|
29
278
|
async function checkStatus() {
|
|
30
279
|
el.loading.hidden = false;
|
|
31
280
|
el.onboarding.hidden = true;
|
|
32
|
-
el.
|
|
281
|
+
el.appShell.hidden = true;
|
|
282
|
+
|
|
33
283
|
const res = await api('/api/status');
|
|
34
284
|
const result = await res.json();
|
|
285
|
+
el.loading.hidden = true;
|
|
35
286
|
|
|
36
287
|
if (result.ok) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
288
|
+
lastAccountInfo = result.accountInfo;
|
|
289
|
+
lastProfile = result.profile;
|
|
290
|
+
lastModels = result.models ?? [];
|
|
291
|
+
renderProfileUI(lastProfile);
|
|
292
|
+
setupModelSelectors();
|
|
293
|
+
el.appShell.hidden = false;
|
|
294
|
+
setStatus('online');
|
|
295
|
+
selectPage('messages');
|
|
296
|
+
checkForUpdateBanner(1);
|
|
297
|
+
|
|
298
|
+
if (!lastProfile?.onboardingComplete && !onboardingKickedOff) {
|
|
299
|
+
onboardingKickedOff = true;
|
|
300
|
+
kickoffOnboarding();
|
|
301
|
+
}
|
|
40
302
|
return;
|
|
41
303
|
}
|
|
42
304
|
|
|
43
305
|
el.cliMissing.hidden = result.reason !== 'cli-missing';
|
|
44
306
|
el.notAuthenticated.hidden = result.reason !== 'not-authenticated';
|
|
45
|
-
|
|
307
|
+
el.onboarding.hidden = false;
|
|
46
308
|
}
|
|
47
309
|
|
|
48
310
|
el.checkAgain.addEventListener('click', checkStatus);
|
|
49
311
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
312
|
+
async function refreshProfileIfNeeded() {
|
|
313
|
+
if (lastProfile?.onboardingComplete) return;
|
|
314
|
+
const res = await api('/api/profile');
|
|
315
|
+
const { profile } = await res.json();
|
|
316
|
+
if (profile) {
|
|
317
|
+
lastProfile = profile;
|
|
318
|
+
renderProfileUI(profile);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
el.profileForm.addEventListener('submit', async (e) => {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
const companyName = el.profileCompany.value.trim();
|
|
325
|
+
const mission = el.profileMission.value.trim();
|
|
326
|
+
if (!companyName || !mission) return;
|
|
327
|
+
|
|
328
|
+
const res = await api('/api/profile', {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({
|
|
332
|
+
companyName,
|
|
333
|
+
mission,
|
|
334
|
+
ceoName: el.profileCeoName.value.trim(),
|
|
335
|
+
ceoPersonality: el.profileCeoPersonality.value.trim(),
|
|
336
|
+
defaultModel: el.profileDefaultModel.value,
|
|
337
|
+
defaultEffort: el.profileDefaultEffort.value,
|
|
338
|
+
}),
|
|
339
|
+
});
|
|
340
|
+
if (!res.ok) return;
|
|
341
|
+
|
|
342
|
+
const profileRes = await api('/api/profile');
|
|
343
|
+
const { profile } = await profileRes.json();
|
|
344
|
+
lastProfile = profile;
|
|
345
|
+
renderProfileUI(lastProfile);
|
|
346
|
+
|
|
347
|
+
el.profileSavedHint.hidden = false;
|
|
348
|
+
setTimeout(() => {
|
|
349
|
+
el.profileSavedHint.hidden = true;
|
|
350
|
+
}, 2000);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
function escapeHtml(str) {
|
|
354
|
+
return str.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Minimal, safe markdown rendering: escape first, then convert a handful of
|
|
358
|
+
// common patterns. Applied to the full accumulated text on every streamed
|
|
359
|
+
// delta, so an unclosed marker mid-stream self-corrects once the closing
|
|
360
|
+
// marker arrives a moment later.
|
|
361
|
+
function renderMarkdown(text) {
|
|
362
|
+
let html = escapeHtml(text);
|
|
363
|
+
html = html.replace(/```([\s\S]*?)```/g, (_, code) => `<pre><code>${code}</code></pre>`);
|
|
364
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
365
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
366
|
+
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
|
|
367
|
+
return html;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function appendMessageRow(kind, name, avatarText, text) {
|
|
371
|
+
const row = document.createElement('div');
|
|
372
|
+
row.className = `msg-row ${kind}`;
|
|
373
|
+
|
|
374
|
+
const avatar = document.createElement('span');
|
|
375
|
+
avatar.className = 'avatar';
|
|
376
|
+
avatar.textContent = avatarText;
|
|
377
|
+
|
|
378
|
+
const content = document.createElement('div');
|
|
379
|
+
content.className = 'msg-content';
|
|
380
|
+
|
|
381
|
+
const meta = document.createElement('div');
|
|
382
|
+
meta.className = 'msg-meta';
|
|
383
|
+
meta.textContent = name;
|
|
384
|
+
const time = document.createElement('span');
|
|
385
|
+
time.className = 'msg-time';
|
|
386
|
+
time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
387
|
+
meta.appendChild(time);
|
|
388
|
+
|
|
389
|
+
const textEl = document.createElement('div');
|
|
390
|
+
textEl.className = 'msg-text';
|
|
391
|
+
textEl.innerHTML = renderMarkdown(text);
|
|
392
|
+
|
|
393
|
+
content.appendChild(meta);
|
|
394
|
+
content.appendChild(textEl);
|
|
395
|
+
row.appendChild(avatar);
|
|
396
|
+
row.appendChild(content);
|
|
397
|
+
el.messages.appendChild(row);
|
|
398
|
+
el.messages.scrollTop = el.messages.scrollHeight;
|
|
399
|
+
return { row, textEl };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function appendUserMessage(text) {
|
|
403
|
+
appendMessageRow('user', 'You', 'Y', text);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let pendingWidgetCount = 0;
|
|
407
|
+
|
|
408
|
+
function setPendingWidget(delta) {
|
|
409
|
+
pendingWidgetCount = Math.max(0, pendingWidgetCount + delta);
|
|
410
|
+
el.input.disabled = pendingWidgetCount > 0;
|
|
411
|
+
el.sendButton.disabled = pendingWidgetCount > 0;
|
|
412
|
+
if (pendingWidgetCount === 0) el.input.focus();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const WIDGET_OPEN_FENCE = '```question-widget';
|
|
416
|
+
|
|
417
|
+
// Incremental parser: processes streamed text chunks as they arrive rather
|
|
418
|
+
// than re-parsing the full accumulated text on every delta. This matters for
|
|
419
|
+
// two reasons — (1) a widget block mid-stream would otherwise flash raw JSON
|
|
420
|
+
// before its closing fence arrives, and (2) naively re-rendering the whole
|
|
421
|
+
// message on every delta would wipe out a widget the user already answered
|
|
422
|
+
// if more text streams in afterward.
|
|
423
|
+
class AssistantContentRenderer {
|
|
424
|
+
constructor(containerEl) {
|
|
425
|
+
this.container = containerEl;
|
|
426
|
+
this.state = 'text';
|
|
427
|
+
this.buffer = '';
|
|
428
|
+
this.textSegmentEl = null;
|
|
429
|
+
this.textRaw = '';
|
|
430
|
+
this.widgetPlaceholderEl = null;
|
|
431
|
+
this.widgetRaw = '';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
push(deltaText) {
|
|
435
|
+
this.buffer += deltaText;
|
|
436
|
+
for (;;) {
|
|
437
|
+
if (this.state === 'text') {
|
|
438
|
+
const fenceIdx = this.buffer.indexOf(WIDGET_OPEN_FENCE);
|
|
439
|
+
if (fenceIdx === -1) {
|
|
440
|
+
// Hold back a tail as long as the marker minus one character —
|
|
441
|
+
// deltas arrive token-by-token, so the marker itself can easily
|
|
442
|
+
// be split across chunks (e.g. one delta ending "``" and the next
|
|
443
|
+
// starting "`question-widget"). Flushing eagerly would render the
|
|
444
|
+
// first half as plain text before the second half ever confirms
|
|
445
|
+
// it was a fence, and the marker would never be recognized.
|
|
446
|
+
const safeLen = Math.max(0, this.buffer.length - (WIDGET_OPEN_FENCE.length - 1));
|
|
447
|
+
const toFlush = this.buffer.slice(0, safeLen);
|
|
448
|
+
this.buffer = this.buffer.slice(safeLen);
|
|
449
|
+
if (toFlush) {
|
|
450
|
+
if (!this.textSegmentEl) {
|
|
451
|
+
this.textSegmentEl = document.createElement('div');
|
|
452
|
+
this.textSegmentEl.className = 'msg-text-segment';
|
|
453
|
+
this.container.appendChild(this.textSegmentEl);
|
|
454
|
+
}
|
|
455
|
+
this.textRaw += toFlush;
|
|
456
|
+
this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (fenceIdx > 0 || this.textRaw) {
|
|
461
|
+
if (!this.textSegmentEl) {
|
|
462
|
+
this.textSegmentEl = document.createElement('div');
|
|
463
|
+
this.textSegmentEl.className = 'msg-text-segment';
|
|
464
|
+
this.container.appendChild(this.textSegmentEl);
|
|
465
|
+
}
|
|
466
|
+
this.textRaw += this.buffer.slice(0, fenceIdx);
|
|
467
|
+
this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
|
|
468
|
+
}
|
|
469
|
+
this.textSegmentEl = null;
|
|
470
|
+
this.textRaw = '';
|
|
471
|
+
this.buffer = this.buffer.slice(fenceIdx + WIDGET_OPEN_FENCE.length);
|
|
472
|
+
this.state = 'in-widget';
|
|
473
|
+
this.widgetRaw = '';
|
|
474
|
+
this.widgetPlaceholderEl = document.createElement('div');
|
|
475
|
+
this.widgetPlaceholderEl.className = 'question-widget-placeholder';
|
|
476
|
+
this.widgetPlaceholderEl.textContent = 'Preparing a question…';
|
|
477
|
+
this.container.appendChild(this.widgetPlaceholderEl);
|
|
478
|
+
} else {
|
|
479
|
+
const closeIdx = this.buffer.indexOf('```');
|
|
480
|
+
if (closeIdx === -1) {
|
|
481
|
+
// Same tail-holding logic for the closing fence (3 chars).
|
|
482
|
+
const safeLen = Math.max(0, this.buffer.length - 2);
|
|
483
|
+
this.widgetRaw += this.buffer.slice(0, safeLen);
|
|
484
|
+
this.buffer = this.buffer.slice(safeLen);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
this.widgetRaw += this.buffer.slice(0, closeIdx);
|
|
488
|
+
this.buffer = this.buffer.slice(closeIdx + 3);
|
|
489
|
+
this.state = 'text';
|
|
490
|
+
this.renderWidget(this.widgetRaw);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
renderWidget(rawJson) {
|
|
496
|
+
let data;
|
|
497
|
+
try {
|
|
498
|
+
data = JSON.parse(rawJson);
|
|
499
|
+
} catch {
|
|
500
|
+
// Malformed JSON from the model — show the raw text rather than
|
|
501
|
+
// silently dropping it, so a parsing failure is at least visible.
|
|
502
|
+
this.widgetPlaceholderEl.textContent = rawJson.trim();
|
|
503
|
+
this.widgetPlaceholderEl.className = 'msg-text-segment';
|
|
504
|
+
this.widgetPlaceholderEl = null;
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
setPendingWidget(1);
|
|
508
|
+
let answered = false;
|
|
509
|
+
const widgetEl = createQuestionWidget(data, (messageText) => {
|
|
510
|
+
if (answered) return;
|
|
511
|
+
answered = true;
|
|
512
|
+
setPendingWidget(-1);
|
|
513
|
+
streamChat(messageText, { showUserBubble: false });
|
|
514
|
+
});
|
|
515
|
+
this.widgetPlaceholderEl.replaceWith(widgetEl);
|
|
516
|
+
this.widgetPlaceholderEl = null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
finish() {
|
|
520
|
+
// No more text is coming, so anything still held back as a possible
|
|
521
|
+
// partial fence match is definitely not one — flush it as plain text.
|
|
522
|
+
if (this.state === 'text' && this.buffer) {
|
|
523
|
+
if (!this.textSegmentEl) {
|
|
524
|
+
this.textSegmentEl = document.createElement('div');
|
|
525
|
+
this.textSegmentEl.className = 'msg-text-segment';
|
|
526
|
+
this.container.appendChild(this.textSegmentEl);
|
|
527
|
+
}
|
|
528
|
+
this.textRaw += this.buffer;
|
|
529
|
+
this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
|
|
530
|
+
this.buffer = '';
|
|
531
|
+
}
|
|
532
|
+
// A block left unterminated when the turn ends (shouldn't normally
|
|
533
|
+
// happen) — show what was received rather than leaving a permanent
|
|
534
|
+
// "Preparing a question…" placeholder with no way to proceed.
|
|
535
|
+
if (this.state === 'in-widget' && this.widgetPlaceholderEl) {
|
|
536
|
+
this.widgetPlaceholderEl.textContent = `${WIDGET_OPEN_FENCE}\n${this.widgetRaw}${this.buffer}`;
|
|
537
|
+
this.widgetPlaceholderEl.className = 'msg-text-segment';
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function createQuestionWidget(data, onAnswer) {
|
|
543
|
+
const card = document.createElement('div');
|
|
544
|
+
card.className = 'question-widget';
|
|
545
|
+
|
|
546
|
+
const questionEl = document.createElement('div');
|
|
547
|
+
questionEl.className = 'question-widget-question';
|
|
548
|
+
questionEl.textContent = data.question ?? 'Question';
|
|
549
|
+
card.appendChild(questionEl);
|
|
550
|
+
|
|
551
|
+
const body = document.createElement('div');
|
|
552
|
+
body.className = 'question-widget-body';
|
|
553
|
+
card.appendChild(body);
|
|
554
|
+
|
|
555
|
+
function submit(displayText, messageText) {
|
|
556
|
+
card.classList.add('answered');
|
|
557
|
+
body.innerHTML = '';
|
|
558
|
+
const answerEl = document.createElement('div');
|
|
559
|
+
answerEl.className = 'question-widget-answer';
|
|
560
|
+
answerEl.textContent = displayText;
|
|
561
|
+
body.appendChild(answerEl);
|
|
562
|
+
onAnswer(messageText ?? displayText);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function buildOtherRow(placeholder) {
|
|
566
|
+
const row = document.createElement('div');
|
|
567
|
+
row.className = 'question-widget-row question-widget-other';
|
|
568
|
+
const input = document.createElement('input');
|
|
569
|
+
input.type = 'text';
|
|
570
|
+
input.placeholder = placeholder;
|
|
571
|
+
const btn = document.createElement('button');
|
|
572
|
+
btn.type = 'button';
|
|
573
|
+
btn.textContent = 'Send';
|
|
574
|
+
row.appendChild(input);
|
|
575
|
+
row.appendChild(btn);
|
|
576
|
+
const trigger = () => {
|
|
577
|
+
const val = input.value.trim();
|
|
578
|
+
if (val) submit(val, val);
|
|
579
|
+
};
|
|
580
|
+
btn.addEventListener('click', trigger);
|
|
581
|
+
input.addEventListener('keydown', (e) => {
|
|
582
|
+
if (e.key === 'Enter') trigger();
|
|
583
|
+
});
|
|
584
|
+
return row;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const type = data.type;
|
|
588
|
+
const options = Array.isArray(data.options) ? data.options : [];
|
|
589
|
+
|
|
590
|
+
if (type === 'single_select') {
|
|
591
|
+
for (const opt of options) {
|
|
592
|
+
const btn = document.createElement('button');
|
|
593
|
+
btn.type = 'button';
|
|
594
|
+
btn.className = 'question-widget-option';
|
|
595
|
+
btn.textContent = opt;
|
|
596
|
+
btn.addEventListener('click', () => submit(opt, opt));
|
|
597
|
+
body.appendChild(btn);
|
|
598
|
+
}
|
|
599
|
+
body.appendChild(buildOtherRow('Other…'));
|
|
600
|
+
} else if (type === 'multi_select') {
|
|
601
|
+
const checkboxes = [];
|
|
602
|
+
for (const opt of options) {
|
|
603
|
+
const label = document.createElement('label');
|
|
604
|
+
label.className = 'question-widget-checkbox-label';
|
|
605
|
+
const cb = document.createElement('input');
|
|
606
|
+
cb.type = 'checkbox';
|
|
607
|
+
cb.value = opt;
|
|
608
|
+
checkboxes.push(cb);
|
|
609
|
+
label.appendChild(cb);
|
|
610
|
+
label.appendChild(document.createTextNode(opt));
|
|
611
|
+
body.appendChild(label);
|
|
612
|
+
}
|
|
613
|
+
const otherInput = document.createElement('input');
|
|
614
|
+
otherInput.type = 'text';
|
|
615
|
+
otherInput.placeholder = 'Other (optional)…';
|
|
616
|
+
otherInput.className = 'question-widget-other-inline';
|
|
617
|
+
body.appendChild(otherInput);
|
|
618
|
+
|
|
619
|
+
const submitBtn = document.createElement('button');
|
|
620
|
+
submitBtn.type = 'button';
|
|
621
|
+
submitBtn.className = 'question-widget-submit';
|
|
622
|
+
submitBtn.textContent = 'Submit';
|
|
623
|
+
submitBtn.addEventListener('click', () => {
|
|
624
|
+
const selected = checkboxes.filter((c) => c.checked).map((c) => c.value);
|
|
625
|
+
const other = otherInput.value.trim();
|
|
626
|
+
if (other) selected.push(other);
|
|
627
|
+
if (!selected.length) return;
|
|
628
|
+
const display = selected.join(', ');
|
|
629
|
+
submit(display, display);
|
|
630
|
+
});
|
|
631
|
+
body.appendChild(submitBtn);
|
|
632
|
+
} else {
|
|
633
|
+
// 'text', or an unrecognized type — a free-text prompt never silently drops the question.
|
|
634
|
+
body.appendChild(buildOtherRow('Type your answer…'));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return card;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function appendAssistantRow(turnMeta) {
|
|
641
|
+
const result = appendMessageRow('assistant', lastProfile?.ceoName || 'your AI CEO', getCeoInitial(), '');
|
|
642
|
+
if (turnMeta?.model) {
|
|
643
|
+
const badge = document.createElement('span');
|
|
644
|
+
badge.className = 'msg-model-badge';
|
|
645
|
+
const modelLabel = modelDisplayName(turnMeta.model);
|
|
646
|
+
badge.textContent = turnMeta.effort ? `${modelLabel} · ${turnMeta.effort}` : modelLabel;
|
|
647
|
+
result.row.querySelector('.msg-meta').appendChild(badge);
|
|
648
|
+
}
|
|
649
|
+
result.renderer = new AssistantContentRenderer(result.textEl);
|
|
650
|
+
return result;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function appendToolMessage(name) {
|
|
654
|
+
appendMessageRow('tool', 'System', '⚙', `Using tool: ${name}`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function appendErrorMessage(message) {
|
|
658
|
+
appendMessageRow('error', 'Error', '!', message);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function showTypingIndicator() {
|
|
662
|
+
el.typingAvatar.textContent = getCeoInitial();
|
|
663
|
+
el.typingIndicator.hidden = false;
|
|
55
664
|
el.messages.scrollTop = el.messages.scrollHeight;
|
|
56
|
-
return div;
|
|
57
665
|
}
|
|
58
666
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
667
|
+
function hideTypingIndicator() {
|
|
668
|
+
el.typingIndicator.hidden = true;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function streamChat(message, { showUserBubble }) {
|
|
672
|
+
if (showUserBubble) appendUserMessage(message);
|
|
673
|
+
showTypingIndicator();
|
|
674
|
+
setStatus('typing');
|
|
675
|
+
|
|
676
|
+
let assistantRenderer = null;
|
|
677
|
+
let sawContent = false;
|
|
678
|
+
let turnMeta = null;
|
|
63
679
|
|
|
64
680
|
const res = await api('/api/chat', {
|
|
65
681
|
method: 'POST',
|
|
66
682
|
headers: { 'Content-Type': 'application/json' },
|
|
67
|
-
body: JSON.stringify({ message }),
|
|
683
|
+
body: JSON.stringify({ message, model: el.chatModelSelect.value, effort: el.chatEffortSelect.value }),
|
|
68
684
|
});
|
|
69
685
|
|
|
70
686
|
if (!res.ok || !res.body) {
|
|
71
|
-
|
|
687
|
+
hideTypingIndicator();
|
|
688
|
+
appendErrorMessage(`Request failed (${res.status})`);
|
|
689
|
+
setStatus('offline');
|
|
72
690
|
return;
|
|
73
691
|
}
|
|
74
692
|
|
|
@@ -89,16 +707,43 @@ async function sendMessage(message) {
|
|
|
89
707
|
if (!line) continue;
|
|
90
708
|
const event = JSON.parse(line.slice('data: '.length));
|
|
91
709
|
|
|
92
|
-
if (event.type === '
|
|
93
|
-
|
|
94
|
-
|
|
710
|
+
if (event.type === 'meta') {
|
|
711
|
+
turnMeta = { model: event.model, effort: event.effort };
|
|
712
|
+
} else if (event.type === 'text-delta') {
|
|
713
|
+
if (!sawContent) {
|
|
714
|
+
hideTypingIndicator();
|
|
715
|
+
sawContent = true;
|
|
716
|
+
}
|
|
717
|
+
if (!assistantRenderer) {
|
|
718
|
+
assistantRenderer = appendAssistantRow(turnMeta).renderer;
|
|
719
|
+
}
|
|
720
|
+
assistantRenderer.push(event.text);
|
|
721
|
+
el.messages.scrollTop = el.messages.scrollHeight;
|
|
95
722
|
} else if (event.type === 'tool-use') {
|
|
96
|
-
|
|
723
|
+
if (!sawContent) {
|
|
724
|
+
hideTypingIndicator();
|
|
725
|
+
sawContent = true;
|
|
726
|
+
}
|
|
727
|
+
appendToolMessage(event.name);
|
|
97
728
|
} else if (event.type === 'error') {
|
|
98
|
-
|
|
729
|
+
if (!sawContent) {
|
|
730
|
+
hideTypingIndicator();
|
|
731
|
+
sawContent = true;
|
|
732
|
+
}
|
|
733
|
+
appendErrorMessage(event.message);
|
|
734
|
+
setStatus('offline');
|
|
99
735
|
}
|
|
100
736
|
}
|
|
101
737
|
}
|
|
738
|
+
|
|
739
|
+
assistantRenderer?.finish();
|
|
740
|
+
hideTypingIndicator();
|
|
741
|
+
if (statusState !== 'offline') setStatus('online');
|
|
742
|
+
await refreshProfileIfNeeded();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function kickoffOnboarding() {
|
|
746
|
+
return streamChat('Please begin the onboarding conversation.', { showUserBubble: false });
|
|
102
747
|
}
|
|
103
748
|
|
|
104
749
|
el.form.addEventListener('submit', async (e) => {
|
|
@@ -107,11 +752,18 @@ el.form.addEventListener('submit', async (e) => {
|
|
|
107
752
|
if (!message) return;
|
|
108
753
|
el.input.value = '';
|
|
109
754
|
el.input.disabled = true;
|
|
755
|
+
el.sendButton.disabled = true;
|
|
110
756
|
try {
|
|
111
|
-
await
|
|
757
|
+
await streamChat(message, { showUserBubble: true });
|
|
112
758
|
} finally {
|
|
113
|
-
|
|
114
|
-
|
|
759
|
+
// A widget the model asked as part of this same turn may still be
|
|
760
|
+
// unanswered — setPendingWidget already owns disabling in that case,
|
|
761
|
+
// so only re-enable here if nothing is pending.
|
|
762
|
+
if (pendingWidgetCount === 0) {
|
|
763
|
+
el.input.disabled = false;
|
|
764
|
+
el.sendButton.disabled = false;
|
|
765
|
+
el.input.focus();
|
|
766
|
+
}
|
|
115
767
|
}
|
|
116
768
|
});
|
|
117
769
|
|