@harborgroup/my-team 0.1.0 → 0.2.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 +23 -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 +125 -0
- package/dist/server/start.js +12 -1
- package/dist/server/update-check.js +32 -0
- package/dist/web/app.js +437 -27
- package/dist/web/index.html +171 -30
- package/dist/web/style.css +627 -52
- package/package.json +1 -1
package/dist/web/app.js
CHANGED
|
@@ -6,13 +6,69 @@ 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
|
+
|
|
47
|
+
profileForm: document.getElementById('profile-form'),
|
|
48
|
+
profileCompany: document.getElementById('profile-company'),
|
|
49
|
+
profileMission: document.getElementById('profile-mission'),
|
|
50
|
+
profileCeoName: document.getElementById('profile-ceo-name'),
|
|
51
|
+
profileCeoPersonality: document.getElementById('profile-ceo-personality'),
|
|
52
|
+
profileDefaultModel: document.getElementById('profile-default-model'),
|
|
53
|
+
profileDefaultEffort: document.getElementById('profile-default-effort'),
|
|
54
|
+
profileSavedHint: document.getElementById('profile-saved-hint'),
|
|
55
|
+
|
|
56
|
+
chatModelSelect: document.getElementById('chat-model-select'),
|
|
57
|
+
chatEffortSelect: document.getElementById('chat-effort-select'),
|
|
58
|
+
|
|
59
|
+
settingsAccount: document.getElementById('settings-account'),
|
|
60
|
+
settingsLogPath: document.getElementById('settings-log-path'),
|
|
14
61
|
};
|
|
15
62
|
|
|
63
|
+
let lastProfile = null;
|
|
64
|
+
let lastAccountInfo = null;
|
|
65
|
+
let lastModels = [];
|
|
66
|
+
let onboardingKickedOff = false;
|
|
67
|
+
let currentPage = 'messages';
|
|
68
|
+
let currentChannel = 'ceo';
|
|
69
|
+
let statusState = 'offline';
|
|
70
|
+
let metaCache = null;
|
|
71
|
+
|
|
16
72
|
function api(path, options = {}) {
|
|
17
73
|
return fetch(path, {
|
|
18
74
|
...options,
|
|
@@ -20,55 +76,382 @@ function api(path, options = {}) {
|
|
|
20
76
|
});
|
|
21
77
|
}
|
|
22
78
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
79
|
+
function getCeoInitial() {
|
|
80
|
+
const name = lastProfile?.ceoName;
|
|
81
|
+
return name && name !== 'your AI CEO' ? name.trim()[0].toUpperCase() : '?';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderProfileUI(profile) {
|
|
85
|
+
el.workspaceName.textContent = profile?.companyName || 'my-team';
|
|
86
|
+
const ceoName = profile?.ceoName || 'your AI CEO';
|
|
87
|
+
el.ceoNavName.textContent = ceoName;
|
|
88
|
+
el.ceoAvatar.textContent = getCeoInitial();
|
|
89
|
+
if (currentPage === 'messages' && currentChannel === 'ceo') el.channelTitle.textContent = ceoName;
|
|
90
|
+
if (currentPage === 'home') renderHomePage();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setStatus(state) {
|
|
94
|
+
statusState = state;
|
|
95
|
+
el.ceoStatusDot.classList.remove('online', 'typing');
|
|
96
|
+
if (state === 'online') el.ceoStatusDot.classList.add('online');
|
|
97
|
+
else if (state === 'typing') el.ceoStatusDot.classList.add('typing');
|
|
98
|
+
if (currentPage === 'messages' && currentChannel === 'ceo') {
|
|
99
|
+
el.channelSubtitle.textContent = state === 'typing' ? 'Typing…' : state === 'online' ? 'Active' : 'Offline';
|
|
100
|
+
}
|
|
27
101
|
}
|
|
28
102
|
|
|
103
|
+
function selectChannel(channel) {
|
|
104
|
+
currentChannel = channel;
|
|
105
|
+
el.navGeneral.classList.toggle('active', channel === 'general');
|
|
106
|
+
el.navCeo.classList.toggle('active', channel === 'ceo');
|
|
107
|
+
el.channelGeneral.hidden = channel !== 'general';
|
|
108
|
+
el.channelCeo.hidden = channel !== 'ceo';
|
|
109
|
+
if (channel === 'general') {
|
|
110
|
+
el.channelTitle.textContent = '# general';
|
|
111
|
+
el.channelSubtitle.textContent = '';
|
|
112
|
+
} else {
|
|
113
|
+
el.channelTitle.textContent = lastProfile?.ceoName || 'your AI CEO';
|
|
114
|
+
setStatus(statusState);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
el.navGeneral.addEventListener('click', () => selectChannel('general'));
|
|
119
|
+
el.navCeo.addEventListener('click', () => selectChannel('ceo'));
|
|
120
|
+
|
|
121
|
+
function modelDisplayName(value) {
|
|
122
|
+
return lastModels.find((m) => m.value === value)?.displayName ?? value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// No synthetic "use the default" placeholder here — the SDK's own
|
|
126
|
+
// supportedModels() list already includes a real "default" entry
|
|
127
|
+
// (displayName "Default (recommended)") that itself supports effort
|
|
128
|
+
// levels. A separate blank option would be a confusing near-duplicate and
|
|
129
|
+
// would incorrectly read as "no model supports effort" to the effort
|
|
130
|
+
// selector below, since '' never matches a real model.
|
|
131
|
+
function populateModelSelect(selectEl) {
|
|
132
|
+
selectEl.innerHTML = '';
|
|
133
|
+
for (const model of lastModels) {
|
|
134
|
+
const opt = document.createElement('option');
|
|
135
|
+
opt.value = model.value;
|
|
136
|
+
opt.textContent = model.displayName;
|
|
137
|
+
selectEl.appendChild(opt);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Effort options are constrained to what the selected model actually
|
|
142
|
+
// supports (confirmed via the SDK's supportedModels() data — e.g. Haiku
|
|
143
|
+
// supports no effort levels at all) so a silent downgrade shouldn't occur
|
|
144
|
+
// in practice; the UI simply never offers an invalid combination.
|
|
145
|
+
function populateEffortSelect(selectEl, modelValue, blankLabel) {
|
|
146
|
+
const info = lastModels.find((m) => m.value === modelValue);
|
|
147
|
+
selectEl.innerHTML = '';
|
|
148
|
+
if (!info?.supportsEffort) {
|
|
149
|
+
selectEl.disabled = true;
|
|
150
|
+
const opt = document.createElement('option');
|
|
151
|
+
opt.value = '';
|
|
152
|
+
opt.textContent = 'N/A';
|
|
153
|
+
selectEl.appendChild(opt);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
selectEl.disabled = false;
|
|
157
|
+
const blank = document.createElement('option');
|
|
158
|
+
blank.value = '';
|
|
159
|
+
blank.textContent = blankLabel;
|
|
160
|
+
selectEl.appendChild(blank);
|
|
161
|
+
for (const level of info.supportedEffortLevels ?? []) {
|
|
162
|
+
const opt = document.createElement('option');
|
|
163
|
+
opt.value = level;
|
|
164
|
+
opt.textContent = level;
|
|
165
|
+
selectEl.appendChild(opt);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setupModelSelectors() {
|
|
170
|
+
populateModelSelect(el.chatModelSelect);
|
|
171
|
+
populateModelSelect(el.profileDefaultModel);
|
|
172
|
+
|
|
173
|
+
// Assigning select.value a string that matches no <option> clears the
|
|
174
|
+
// selection entirely (selectedIndex -1) rather than falling back to the
|
|
175
|
+
// first option — only assign when there's a real saved default to apply.
|
|
176
|
+
if (lastProfile?.defaultModel) el.chatModelSelect.value = lastProfile.defaultModel;
|
|
177
|
+
populateEffortSelect(el.chatEffortSelect, el.chatModelSelect.value, 'Default effort');
|
|
178
|
+
if (lastProfile?.defaultEffort) el.chatEffortSelect.value = lastProfile.defaultEffort;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
el.chatModelSelect.addEventListener('change', () => {
|
|
182
|
+
populateEffortSelect(el.chatEffortSelect, el.chatModelSelect.value, 'Default effort');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
el.profileDefaultModel.addEventListener('change', () => {
|
|
186
|
+
populateEffortSelect(el.profileDefaultEffort, el.profileDefaultModel.value, 'Default');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
function populateProfileForm(profile) {
|
|
190
|
+
el.profileCompany.value = profile?.companyName ?? '';
|
|
191
|
+
el.profileMission.value = profile?.mission ?? '';
|
|
192
|
+
el.profileCeoName.value = profile?.ceoName ?? '';
|
|
193
|
+
el.profileCeoPersonality.value = profile?.ceoPersonality ?? '';
|
|
194
|
+
if (profile?.defaultModel) el.profileDefaultModel.value = profile.defaultModel;
|
|
195
|
+
populateEffortSelect(el.profileDefaultEffort, el.profileDefaultModel.value, 'Default');
|
|
196
|
+
if (profile?.defaultEffort) el.profileDefaultEffort.value = profile.defaultEffort;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderHomePage() {
|
|
200
|
+
el.homeAvatar.textContent = getCeoInitial();
|
|
201
|
+
const ceoName = lastProfile?.ceoName || 'your AI CEO';
|
|
202
|
+
const companyName = lastProfile?.companyName;
|
|
203
|
+
el.homeGreeting.textContent = companyName ? `Welcome to ${companyName}` : 'Welcome to my-team';
|
|
204
|
+
el.homeSub.textContent = lastProfile?.onboardingComplete
|
|
205
|
+
? `${ceoName} is ready and waiting in Messages.`
|
|
206
|
+
: `Head to Messages to finish setting up ${ceoName}.`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function renderSettingsPage() {
|
|
210
|
+
el.settingsAccount.textContent = lastAccountInfo
|
|
211
|
+
? `${lastAccountInfo.email ?? 'unknown'}${lastAccountInfo.subscriptionType ? ` (${lastAccountInfo.subscriptionType})` : ''}`
|
|
212
|
+
: 'unknown';
|
|
213
|
+
if (!metaCache) {
|
|
214
|
+
const res = await api('/api/meta');
|
|
215
|
+
metaCache = await res.json();
|
|
216
|
+
}
|
|
217
|
+
el.settingsLogPath.textContent = metaCache.logFilePath;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function checkForUpdateBanner(retriesLeft) {
|
|
221
|
+
const res = await api('/api/meta');
|
|
222
|
+
metaCache = await res.json();
|
|
223
|
+
|
|
224
|
+
if (metaCache.updateInfo?.updateAvailable) {
|
|
225
|
+
el.updateBannerText.textContent = `A new version of my-team is available (v${metaCache.updateInfo.latestVersion}).`;
|
|
226
|
+
el.updateBanner.hidden = false;
|
|
227
|
+
} else if (!metaCache.updateInfo && retriesLeft > 0) {
|
|
228
|
+
// The registry check runs in the background at server startup and may
|
|
229
|
+
// not have resolved yet — try once more shortly rather than never
|
|
230
|
+
// showing the banner this session.
|
|
231
|
+
setTimeout(() => checkForUpdateBanner(retriesLeft - 1), 3000);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
el.updateBannerButton.addEventListener('click', async () => {
|
|
236
|
+
el.updateBannerButton.disabled = true;
|
|
237
|
+
el.updateBannerButton.textContent = 'Updating…';
|
|
238
|
+
const res = await api('/api/update', { method: 'POST' });
|
|
239
|
+
const result = await res.json();
|
|
240
|
+
if (result.ok) {
|
|
241
|
+
el.updateBannerText.textContent = 'Updated — restart (stop and run "npm run team" again) to use the new version.';
|
|
242
|
+
el.updateBannerButton.hidden = true;
|
|
243
|
+
} else {
|
|
244
|
+
el.updateBannerButton.disabled = false;
|
|
245
|
+
el.updateBannerButton.textContent = 'Update';
|
|
246
|
+
el.updateBannerText.textContent = 'Update failed — check the terminal for details.';
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
el.updateBannerDismiss.addEventListener('click', () => {
|
|
251
|
+
el.updateBanner.hidden = true;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
function selectPage(page) {
|
|
255
|
+
currentPage = page;
|
|
256
|
+
el.railHome.classList.toggle('active', page === 'home');
|
|
257
|
+
el.railMessages.classList.toggle('active', page === 'messages');
|
|
258
|
+
el.railProfile.classList.toggle('active', page === 'profile');
|
|
259
|
+
el.railSettings.classList.toggle('active', page === 'settings');
|
|
260
|
+
el.pageHome.hidden = page !== 'home';
|
|
261
|
+
el.pageMessages.hidden = page !== 'messages';
|
|
262
|
+
el.pageProfile.hidden = page !== 'profile';
|
|
263
|
+
el.pageSettings.hidden = page !== 'settings';
|
|
264
|
+
|
|
265
|
+
if (page === 'home') renderHomePage();
|
|
266
|
+
else if (page === 'profile') populateProfileForm(lastProfile);
|
|
267
|
+
else if (page === 'settings') renderSettingsPage();
|
|
268
|
+
else if (page === 'messages') selectChannel(currentChannel);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
el.railHome.addEventListener('click', () => selectPage('home'));
|
|
272
|
+
el.railMessages.addEventListener('click', () => selectPage('messages'));
|
|
273
|
+
el.railProfile.addEventListener('click', () => selectPage('profile'));
|
|
274
|
+
el.railSettings.addEventListener('click', () => selectPage('settings'));
|
|
275
|
+
el.homeGoMessages.addEventListener('click', () => selectPage('messages'));
|
|
276
|
+
|
|
29
277
|
async function checkStatus() {
|
|
30
278
|
el.loading.hidden = false;
|
|
31
279
|
el.onboarding.hidden = true;
|
|
32
|
-
el.
|
|
280
|
+
el.appShell.hidden = true;
|
|
281
|
+
|
|
33
282
|
const res = await api('/api/status');
|
|
34
283
|
const result = await res.json();
|
|
284
|
+
el.loading.hidden = true;
|
|
35
285
|
|
|
36
286
|
if (result.ok) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
287
|
+
lastAccountInfo = result.accountInfo;
|
|
288
|
+
lastProfile = result.profile;
|
|
289
|
+
lastModels = result.models ?? [];
|
|
290
|
+
renderProfileUI(lastProfile);
|
|
291
|
+
setupModelSelectors();
|
|
292
|
+
el.appShell.hidden = false;
|
|
293
|
+
setStatus('online');
|
|
294
|
+
selectPage('messages');
|
|
295
|
+
checkForUpdateBanner(1);
|
|
296
|
+
|
|
297
|
+
if (!lastProfile?.onboardingComplete && !onboardingKickedOff) {
|
|
298
|
+
onboardingKickedOff = true;
|
|
299
|
+
kickoffOnboarding();
|
|
300
|
+
}
|
|
40
301
|
return;
|
|
41
302
|
}
|
|
42
303
|
|
|
43
304
|
el.cliMissing.hidden = result.reason !== 'cli-missing';
|
|
44
305
|
el.notAuthenticated.hidden = result.reason !== 'not-authenticated';
|
|
45
|
-
|
|
306
|
+
el.onboarding.hidden = false;
|
|
46
307
|
}
|
|
47
308
|
|
|
48
309
|
el.checkAgain.addEventListener('click', checkStatus);
|
|
49
310
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
311
|
+
async function refreshProfileIfNeeded() {
|
|
312
|
+
if (lastProfile?.onboardingComplete) return;
|
|
313
|
+
const res = await api('/api/profile');
|
|
314
|
+
const { profile } = await res.json();
|
|
315
|
+
if (profile) {
|
|
316
|
+
lastProfile = profile;
|
|
317
|
+
renderProfileUI(profile);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
el.profileForm.addEventListener('submit', async (e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
const companyName = el.profileCompany.value.trim();
|
|
324
|
+
const mission = el.profileMission.value.trim();
|
|
325
|
+
if (!companyName || !mission) return;
|
|
326
|
+
|
|
327
|
+
const res = await api('/api/profile', {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: { 'Content-Type': 'application/json' },
|
|
330
|
+
body: JSON.stringify({
|
|
331
|
+
companyName,
|
|
332
|
+
mission,
|
|
333
|
+
ceoName: el.profileCeoName.value.trim(),
|
|
334
|
+
ceoPersonality: el.profileCeoPersonality.value.trim(),
|
|
335
|
+
defaultModel: el.profileDefaultModel.value,
|
|
336
|
+
defaultEffort: el.profileDefaultEffort.value,
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
if (!res.ok) return;
|
|
340
|
+
|
|
341
|
+
const profileRes = await api('/api/profile');
|
|
342
|
+
const { profile } = await profileRes.json();
|
|
343
|
+
lastProfile = profile;
|
|
344
|
+
renderProfileUI(lastProfile);
|
|
345
|
+
|
|
346
|
+
el.profileSavedHint.hidden = false;
|
|
347
|
+
setTimeout(() => {
|
|
348
|
+
el.profileSavedHint.hidden = true;
|
|
349
|
+
}, 2000);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
function escapeHtml(str) {
|
|
353
|
+
return str.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Minimal, safe markdown rendering: escape first, then convert a handful of
|
|
357
|
+
// common patterns. Applied to the full accumulated text on every streamed
|
|
358
|
+
// delta, so an unclosed marker mid-stream self-corrects once the closing
|
|
359
|
+
// marker arrives a moment later.
|
|
360
|
+
function renderMarkdown(text) {
|
|
361
|
+
let html = escapeHtml(text);
|
|
362
|
+
html = html.replace(/```([\s\S]*?)```/g, (_, code) => `<pre><code>${code}</code></pre>`);
|
|
363
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
364
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
365
|
+
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
|
|
366
|
+
return html;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function appendMessageRow(kind, name, avatarText, text) {
|
|
370
|
+
const row = document.createElement('div');
|
|
371
|
+
row.className = `msg-row ${kind}`;
|
|
372
|
+
|
|
373
|
+
const avatar = document.createElement('span');
|
|
374
|
+
avatar.className = 'avatar';
|
|
375
|
+
avatar.textContent = avatarText;
|
|
376
|
+
|
|
377
|
+
const content = document.createElement('div');
|
|
378
|
+
content.className = 'msg-content';
|
|
379
|
+
|
|
380
|
+
const meta = document.createElement('div');
|
|
381
|
+
meta.className = 'msg-meta';
|
|
382
|
+
meta.textContent = name;
|
|
383
|
+
const time = document.createElement('span');
|
|
384
|
+
time.className = 'msg-time';
|
|
385
|
+
time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
386
|
+
meta.appendChild(time);
|
|
387
|
+
|
|
388
|
+
const textEl = document.createElement('div');
|
|
389
|
+
textEl.className = 'msg-text';
|
|
390
|
+
textEl.innerHTML = renderMarkdown(text);
|
|
391
|
+
|
|
392
|
+
content.appendChild(meta);
|
|
393
|
+
content.appendChild(textEl);
|
|
394
|
+
row.appendChild(avatar);
|
|
395
|
+
row.appendChild(content);
|
|
396
|
+
el.messages.appendChild(row);
|
|
55
397
|
el.messages.scrollTop = el.messages.scrollHeight;
|
|
56
|
-
return
|
|
398
|
+
return { row, textEl };
|
|
57
399
|
}
|
|
58
400
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
401
|
+
function appendUserMessage(text) {
|
|
402
|
+
appendMessageRow('user', 'You', 'Y', text);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function appendAssistantRow(turnMeta) {
|
|
406
|
+
const result = appendMessageRow('assistant', lastProfile?.ceoName || 'your AI CEO', getCeoInitial(), '');
|
|
407
|
+
if (turnMeta?.model) {
|
|
408
|
+
const badge = document.createElement('span');
|
|
409
|
+
badge.className = 'msg-model-badge';
|
|
410
|
+
const modelLabel = modelDisplayName(turnMeta.model);
|
|
411
|
+
badge.textContent = turnMeta.effort ? `${modelLabel} · ${turnMeta.effort}` : modelLabel;
|
|
412
|
+
result.row.querySelector('.msg-meta').appendChild(badge);
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function appendToolMessage(name) {
|
|
418
|
+
appendMessageRow('tool', 'System', '⚙', `Using tool: ${name}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function appendErrorMessage(message) {
|
|
422
|
+
appendMessageRow('error', 'Error', '!', message);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function showTypingIndicator() {
|
|
426
|
+
el.typingAvatar.textContent = getCeoInitial();
|
|
427
|
+
el.typingIndicator.hidden = false;
|
|
428
|
+
el.messages.scrollTop = el.messages.scrollHeight;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function hideTypingIndicator() {
|
|
432
|
+
el.typingIndicator.hidden = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function streamChat(message, { showUserBubble }) {
|
|
436
|
+
if (showUserBubble) appendUserMessage(message);
|
|
437
|
+
showTypingIndicator();
|
|
438
|
+
setStatus('typing');
|
|
439
|
+
|
|
440
|
+
let assistantTextEl = null;
|
|
62
441
|
let assistantText = '';
|
|
442
|
+
let sawContent = false;
|
|
443
|
+
let turnMeta = null;
|
|
63
444
|
|
|
64
445
|
const res = await api('/api/chat', {
|
|
65
446
|
method: 'POST',
|
|
66
447
|
headers: { 'Content-Type': 'application/json' },
|
|
67
|
-
body: JSON.stringify({ message }),
|
|
448
|
+
body: JSON.stringify({ message, model: el.chatModelSelect.value, effort: el.chatEffortSelect.value }),
|
|
68
449
|
});
|
|
69
450
|
|
|
70
451
|
if (!res.ok || !res.body) {
|
|
71
|
-
|
|
452
|
+
hideTypingIndicator();
|
|
453
|
+
appendErrorMessage(`Request failed (${res.status})`);
|
|
454
|
+
setStatus('offline');
|
|
72
455
|
return;
|
|
73
456
|
}
|
|
74
457
|
|
|
@@ -89,16 +472,43 @@ async function sendMessage(message) {
|
|
|
89
472
|
if (!line) continue;
|
|
90
473
|
const event = JSON.parse(line.slice('data: '.length));
|
|
91
474
|
|
|
92
|
-
if (event.type === '
|
|
475
|
+
if (event.type === 'meta') {
|
|
476
|
+
turnMeta = { model: event.model, effort: event.effort };
|
|
477
|
+
} else if (event.type === 'text-delta') {
|
|
478
|
+
if (!sawContent) {
|
|
479
|
+
hideTypingIndicator();
|
|
480
|
+
sawContent = true;
|
|
481
|
+
}
|
|
482
|
+
if (!assistantTextEl) {
|
|
483
|
+
assistantTextEl = appendAssistantRow(turnMeta).textEl;
|
|
484
|
+
}
|
|
93
485
|
assistantText += event.text;
|
|
94
|
-
|
|
486
|
+
assistantTextEl.innerHTML = renderMarkdown(assistantText);
|
|
487
|
+
el.messages.scrollTop = el.messages.scrollHeight;
|
|
95
488
|
} else if (event.type === 'tool-use') {
|
|
96
|
-
|
|
489
|
+
if (!sawContent) {
|
|
490
|
+
hideTypingIndicator();
|
|
491
|
+
sawContent = true;
|
|
492
|
+
}
|
|
493
|
+
appendToolMessage(event.name);
|
|
97
494
|
} else if (event.type === 'error') {
|
|
98
|
-
|
|
495
|
+
if (!sawContent) {
|
|
496
|
+
hideTypingIndicator();
|
|
497
|
+
sawContent = true;
|
|
498
|
+
}
|
|
499
|
+
appendErrorMessage(event.message);
|
|
500
|
+
setStatus('offline');
|
|
99
501
|
}
|
|
100
502
|
}
|
|
101
503
|
}
|
|
504
|
+
|
|
505
|
+
hideTypingIndicator();
|
|
506
|
+
if (statusState !== 'offline') setStatus('online');
|
|
507
|
+
await refreshProfileIfNeeded();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function kickoffOnboarding() {
|
|
511
|
+
return streamChat('Please begin the onboarding conversation.', { showUserBubble: false });
|
|
102
512
|
}
|
|
103
513
|
|
|
104
514
|
el.form.addEventListener('submit', async (e) => {
|
|
@@ -108,7 +518,7 @@ el.form.addEventListener('submit', async (e) => {
|
|
|
108
518
|
el.input.value = '';
|
|
109
519
|
el.input.disabled = true;
|
|
110
520
|
try {
|
|
111
|
-
await
|
|
521
|
+
await streamChat(message, { showUserBubble: true });
|
|
112
522
|
} finally {
|
|
113
523
|
el.input.disabled = false;
|
|
114
524
|
el.input.focus();
|