fauna-api 0.0.0-stage → 1.0.679
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/dist/brands.d.ts +2 -0
- package/dist/brands.js +2 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +121 -0
- package/dist/src/ant/chat.d.ts +130 -0
- package/dist/src/ant/chat.js +467 -0
- package/dist/src/ant/format.d.ts +40 -0
- package/dist/src/ant/format.js +143 -0
- package/dist/src/ant/graph.d.ts +130 -0
- package/dist/src/ant/graph.js +449 -0
- package/dist/src/ant/index.d.ts +16 -0
- package/dist/src/ant/index.js +61 -0
- package/dist/src/ant/persona.d.ts +151 -0
- package/dist/src/ant/persona.js +136 -0
- package/dist/src/ant/timeline.d.ts +283 -0
- package/dist/src/ant/timeline.js +1025 -0
- package/dist/src/authentification.d.ts +45 -0
- package/dist/src/authentification.js +194 -0
- package/dist/src/billing.d.ts +135 -0
- package/dist/src/billing.js +128 -0
- package/dist/src/calendar.d.ts +83 -0
- package/dist/src/calendar.js +133 -0
- package/dist/src/chats.d.ts +113 -0
- package/dist/src/chats.js +100 -0
- package/dist/src/cicada/contacts.d.ts +31 -0
- package/dist/src/cicada/contacts.js +28 -0
- package/dist/src/cicada/conversations.d.ts +73 -0
- package/dist/src/cicada/conversations.js +236 -0
- package/dist/src/cicada/inboxes.d.ts +48 -0
- package/dist/src/cicada/inboxes.js +82 -0
- package/dist/src/cicada/index.d.ts +16 -0
- package/dist/src/cicada/index.js +61 -0
- package/dist/src/cicada/phone.d.ts +48 -0
- package/dist/src/cicada/phone.js +306 -0
- package/dist/src/drive.d.ts +80 -0
- package/dist/src/drive.js +472 -0
- package/dist/src/extensions/index.d.ts +4 -0
- package/dist/src/extensions/index.js +5 -0
- package/dist/src/extensions/web/index.d.ts +6 -0
- package/dist/src/extensions/web/index.js +7 -0
- package/dist/src/extensions/web/session.d.ts +108 -0
- package/dist/src/extensions/web/session.js +470 -0
- package/dist/src/extensions/web/tools.d.ts +13 -0
- package/dist/src/extensions/web/tools.js +72 -0
- package/dist/src/friends.d.ts +39 -0
- package/dist/src/friends.js +119 -0
- package/dist/src/groups.d.ts +82 -0
- package/dist/src/groups.js +269 -0
- package/dist/src/inbox.d.ts +11 -0
- package/dist/src/inbox.js +35 -0
- package/dist/src/integrations.d.ts +52 -0
- package/dist/src/integrations.js +205 -0
- package/dist/src/magpie/explorer.d.ts +14 -0
- package/dist/src/magpie/explorer.js +36 -0
- package/dist/src/magpie/index.d.ts +16 -0
- package/dist/src/magpie/index.js +61 -0
- package/dist/src/magpie/portfolio.d.ts +52 -0
- package/dist/src/magpie/portfolio.js +179 -0
- package/dist/src/magpie/quotes.d.ts +35 -0
- package/dist/src/magpie/quotes.js +47 -0
- package/dist/src/magpie/room.d.ts +61 -0
- package/dist/src/magpie/room.js +238 -0
- package/dist/src/maps.d.ts +159 -0
- package/dist/src/maps.js +171 -0
- package/dist/src/marmot/browser.d.ts +139 -0
- package/dist/src/marmot/browser.js +389 -0
- package/dist/src/marmot/index.d.ts +20 -0
- package/dist/src/marmot/index.js +65 -0
- package/dist/src/marmot/media.d.ts +56 -0
- package/dist/src/marmot/media.js +265 -0
- package/dist/src/marmot/research.d.ts +337 -0
- package/dist/src/marmot/research.js +1138 -0
- package/dist/src/marmot/scraper.d.ts +59 -0
- package/dist/src/marmot/scraper.js +94 -0
- package/dist/src/marmot/web.d.ts +402 -0
- package/dist/src/marmot/web.js +2586 -0
- package/dist/src/marmot/workspace.d.ts +81 -0
- package/dist/src/marmot/workspace.js +263 -0
- package/dist/src/parrot/agent.d.ts +53 -0
- package/dist/src/parrot/agent.js +93 -0
- package/dist/src/parrot/chat.d.ts +84 -0
- package/dist/src/parrot/chat.js +237 -0
- package/dist/src/parrot/format.d.ts +128 -0
- package/dist/src/parrot/format.js +639 -0
- package/dist/src/parrot/generative.d.ts +261 -0
- package/dist/src/parrot/generative.js +185 -0
- package/dist/src/parrot/index.d.ts +22 -0
- package/dist/src/parrot/index.js +67 -0
- package/dist/src/parrot/room.d.ts +225 -0
- package/dist/src/parrot/room.js +466 -0
- package/dist/src/parrot/routing.d.ts +90 -0
- package/dist/src/parrot/routing.js +78 -0
- package/dist/src/parrot/share.d.ts +126 -0
- package/dist/src/parrot/share.js +261 -0
- package/dist/src/parrot/socket.d.ts +41 -0
- package/dist/src/parrot/socket.js +478 -0
- package/dist/src/payments.d.ts +73 -0
- package/dist/src/payments.js +107 -0
- package/dist/src/private/cards.css +75 -0
- package/dist/src/rooms.d.ts +76 -0
- package/dist/src/rooms.js +168 -0
- package/dist/src/shared/schema.d.ts +24 -0
- package/dist/src/shared/schema.js +1 -0
- package/dist/src/shared/server.d.ts +65 -0
- package/dist/src/shared/server.js +302 -0
- package/dist/src/termite/environment.d.ts +110 -0
- package/dist/src/termite/environment.js +263 -0
- package/dist/src/termite/index.d.ts +14 -0
- package/dist/src/termite/index.js +59 -0
- package/dist/src/termite/os.d.ts +326 -0
- package/dist/src/termite/os.js +572 -0
- package/dist/src/termite/versioning.d.ts +94 -0
- package/dist/src/termite/versioning.js +217 -0
- package/dist/src/tickets.d.ts +43 -0
- package/dist/src/tickets.js +138 -0
- package/dist/src/travel.d.ts +85 -0
- package/dist/src/travel.js +44 -0
- package/dist/src/ui/authentification.d.ts +24 -0
- package/dist/src/ui/authentification.js +177 -0
- package/dist/src/ui/brands.css +11 -0
- package/dist/src/ui/brands.d.ts +7 -0
- package/dist/src/ui/brands.js +14 -0
- package/dist/src/ui/browser.d.ts +74 -0
- package/dist/src/ui/browser.js +470 -0
- package/dist/src/ui/chat.d.ts +183 -0
- package/dist/src/ui/chat.js +2379 -0
- package/dist/src/ui/chatPanel.d.ts +109 -0
- package/dist/src/ui/chatPanel.js +768 -0
- package/dist/src/ui/chatSequence.d.ts +36 -0
- package/dist/src/ui/chatSequence.js +42 -0
- package/dist/src/ui/crown.d.ts +19 -0
- package/dist/src/ui/crown.js +199 -0
- package/dist/src/ui/cursor.d.ts +73 -0
- package/dist/src/ui/cursor.js +175 -0
- package/dist/src/ui/device.d.ts +33 -0
- package/dist/src/ui/device.js +49 -0
- package/dist/src/ui/drive.d.ts +69 -0
- package/dist/src/ui/drive.js +1243 -0
- package/dist/src/ui/generative.d.ts +39 -0
- package/dist/src/ui/generative.js +313 -0
- package/dist/src/ui/hover.d.ts +6 -0
- package/dist/src/ui/hover.js +210 -0
- package/dist/src/ui/index.d.ts +42 -0
- package/dist/src/ui/index.js +87 -0
- package/dist/src/ui/integrations.d.ts +13 -0
- package/dist/src/ui/integrations.js +175 -0
- package/dist/src/ui/maps.d.ts +87 -0
- package/dist/src/ui/maps.js +547 -0
- package/dist/src/ui/roster.d.ts +32 -0
- package/dist/src/ui/roster.js +163 -0
- package/dist/src/ui/talk.d.ts +12 -0
- package/dist/src/ui/talk.js +560 -0
- package/dist/src/ui/tickets.d.ts +13 -0
- package/dist/src/ui/tickets.js +966 -0
- package/dist/src/ui/toast.d.ts +7 -0
- package/dist/src/ui/toast.js +258 -0
- package/dist/src/ui/tokenText.d.ts +18 -0
- package/dist/src/ui/tokenText.js +96 -0
- package/dist/src/ui/tokenized.d.ts +42 -0
- package/dist/src/ui/tokenized.js +622 -0
- package/dist/src/ui/ui.css +4763 -0
- package/dist/src/ui/view.d.ts +23 -0
- package/dist/src/ui/view.js +584 -0
- package/dist/src/view.d.ts +113 -0
- package/dist/src/view.js +386 -0
- package/dist/src/webhooks.d.ts +66 -0
- package/dist/src/webhooks.js +78 -0
- package/dist/ui.d.ts +1 -0
- package/dist/ui.js +1 -0
- package/package.json +87 -4
- package/README.md +0 -3
|
@@ -0,0 +1,2379 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { flushSync } from "react-dom";
|
|
5
|
+
import gsap from "gsap";
|
|
6
|
+
import { marked } from "marked";
|
|
7
|
+
import { Fauna } from "fauna-api";
|
|
8
|
+
import { mimeFromFileName } from "../marmot/media.js";
|
|
9
|
+
import { createTokenizedInput, parseRichText, toPlainText, hasTokens } from "./tokenized.js";
|
|
10
|
+
import { roomPartsToChatFields } from "./chatSequence.js";
|
|
11
|
+
import { ChatPanel, askForComputer, attachmentOf, buildAttachmentCard, chatToolLabel, createPanelBus, execOutput, mountRepositoryControl, openViewModal, paintPreview, roomHas, showPreview } from "./chatPanel.js";
|
|
12
|
+
export { RepositoryControl, askForComputer, pickedRepository } from "./chatPanel.js";
|
|
13
|
+
import { createTooltip } from "./hover.js";
|
|
14
|
+
import { sendInformaticToast } from "./toast.js";
|
|
15
|
+
import { buildAvatar, createSelector, stampModelAvatar, stampUserAvatar } from "./generative.js";
|
|
16
|
+
import { authorColorSeed, colorFromSeed } from "./cursor.js";
|
|
17
|
+
// Re-export so apps can flatten serialized token markers (e.g. before sending
|
|
18
|
+
// history to the LLM) without reaching into the tokenized-input module.
|
|
19
|
+
export { toPlainText } from "./tokenized.js";
|
|
20
|
+
// ---- Message builders ----
|
|
21
|
+
function buildThinkingIndicator() {
|
|
22
|
+
const indicator = document.createElement('div');
|
|
23
|
+
indicator.className = 'chat-thinking';
|
|
24
|
+
const icon = document.createElement('span');
|
|
25
|
+
icon.className = 'material-symbols-outlined';
|
|
26
|
+
icon.textContent = 'neurology';
|
|
27
|
+
indicator.appendChild(icon);
|
|
28
|
+
const label = document.createElement('span');
|
|
29
|
+
label.textContent = 'Thinking';
|
|
30
|
+
indicator.appendChild(label);
|
|
31
|
+
return indicator;
|
|
32
|
+
}
|
|
33
|
+
export function toolChip(call, icon, label) {
|
|
34
|
+
const chip = document.createElement('div');
|
|
35
|
+
chip.className = 'chat-tool-chip';
|
|
36
|
+
if (call.status)
|
|
37
|
+
chip.classList.add(`chat-tool-chip-${call.status}`);
|
|
38
|
+
const iconEl = document.createElement('span');
|
|
39
|
+
iconEl.className = 'material-symbols-outlined';
|
|
40
|
+
iconEl.textContent = icon;
|
|
41
|
+
chip.appendChild(iconEl);
|
|
42
|
+
const labelEl = document.createElement('span');
|
|
43
|
+
labelEl.className = 'chat-tool-chip-label';
|
|
44
|
+
labelEl.textContent = label;
|
|
45
|
+
chip.appendChild(labelEl);
|
|
46
|
+
return chip;
|
|
47
|
+
}
|
|
48
|
+
function webResults(call, query) {
|
|
49
|
+
const root = document.createElement('details');
|
|
50
|
+
root.className = 'chat-tool-web';
|
|
51
|
+
const summary = document.createElement('summary');
|
|
52
|
+
summary.appendChild(toolChip(call, 'travel_explore', `Searched the web for ${query}`));
|
|
53
|
+
root.appendChild(summary);
|
|
54
|
+
const list = document.createElement('div');
|
|
55
|
+
list.className = 'chat-tool-web-sources';
|
|
56
|
+
for (const result of JSON.parse(String(call.result))) {
|
|
57
|
+
const link = document.createElement('a');
|
|
58
|
+
link.className = 'chat-tool-web-source';
|
|
59
|
+
link.href = result.url;
|
|
60
|
+
link.target = '_blank';
|
|
61
|
+
link.rel = 'noopener';
|
|
62
|
+
const title = document.createElement('span');
|
|
63
|
+
title.className = 'chat-tool-web-source-title';
|
|
64
|
+
title.textContent = result.title || result.url;
|
|
65
|
+
const host = document.createElement('span');
|
|
66
|
+
host.className = 'chat-tool-web-source-host';
|
|
67
|
+
host.textContent = new URL(result.url).hostname;
|
|
68
|
+
link.append(title, host);
|
|
69
|
+
list.appendChild(link);
|
|
70
|
+
}
|
|
71
|
+
root.appendChild(list);
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
function renderTool(call) {
|
|
75
|
+
const { name, args, status } = call;
|
|
76
|
+
if (name === 'observe_memory') {
|
|
77
|
+
const observation = String(args.observation ?? '').trim().slice(0, 72);
|
|
78
|
+
const label = status === 'success'
|
|
79
|
+
? `Saved memory, ${observation}`
|
|
80
|
+
: status === 'error'
|
|
81
|
+
? `Memory was not saved, ${observation}`
|
|
82
|
+
: `Remembering ${observation}`;
|
|
83
|
+
return toolChip(call, 'psychology', label);
|
|
84
|
+
}
|
|
85
|
+
if (name === 'query_web') {
|
|
86
|
+
const query = String(args.query ?? '').trim();
|
|
87
|
+
if (status === 'success')
|
|
88
|
+
return webResults(call, query);
|
|
89
|
+
return toolChip(call, 'travel_explore', status === 'error' ? 'Web search failed' : `Searching the web for ${query}`);
|
|
90
|
+
}
|
|
91
|
+
const chat = chatToolLabel(call);
|
|
92
|
+
if (chat) {
|
|
93
|
+
const output = name === 'exec' && status !== 'running' ? execOutput(call.result) : status === 'error' ? String(call.result ?? '').replace(/^Error:\s*/, '') : '';
|
|
94
|
+
if (!output)
|
|
95
|
+
return toolChip(call, chat.icon, chat.label);
|
|
96
|
+
const root = document.createElement('details');
|
|
97
|
+
root.className = 'chat-tool-web chat-tool-output';
|
|
98
|
+
const summary = document.createElement('summary');
|
|
99
|
+
summary.appendChild(toolChip(call, chat.icon, chat.label));
|
|
100
|
+
const pre = document.createElement('pre');
|
|
101
|
+
pre.textContent = output.length > 20000 ? `${output.slice(0, 20000)}\n\u2026` : output;
|
|
102
|
+
root.append(summary, pre);
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
105
|
+
return toolChip(call, 'build', status === 'error' ? `${name} failed` : name);
|
|
106
|
+
}
|
|
107
|
+
function renderToolCallElement(call, override) {
|
|
108
|
+
return override?.(call) ?? renderTool(call);
|
|
109
|
+
}
|
|
110
|
+
function buildFileElement(file, iconMap, cards) {
|
|
111
|
+
const code = typeof file.part?.accessCode === 'string' ? file.part.accessCode : '';
|
|
112
|
+
const preview = code && cards?.authorization
|
|
113
|
+
? {
|
|
114
|
+
key: file.part.ref || code,
|
|
115
|
+
file: Fauna.Drive.fromAccessCode(cards.authorization, code, { name: file.name, contentType: file.mime, contentLength: Number(file.part.contentLength ?? 0) }),
|
|
116
|
+
}
|
|
117
|
+
: undefined;
|
|
118
|
+
const open = cards?.open;
|
|
119
|
+
const card = buildAttachmentCard(file.name, file.mime, open && file.part ? () => open(file) : undefined, iconMap, preview);
|
|
120
|
+
if (file.part?.ref)
|
|
121
|
+
card.dataset.ref = file.part.ref;
|
|
122
|
+
return card;
|
|
123
|
+
}
|
|
124
|
+
// A read-only token chip for rendered messages — mirrors the composer's
|
|
125
|
+
// .tokenized-token visually but without the editable/remove affordances.
|
|
126
|
+
function buildMessageToken(token, opts) {
|
|
127
|
+
const span = document.createElement('span');
|
|
128
|
+
span.className = 'chat-message-token';
|
|
129
|
+
if (opts?.onClick) {
|
|
130
|
+
span.style.cursor = 'pointer';
|
|
131
|
+
span.addEventListener('click', (e) => {
|
|
132
|
+
e.stopPropagation();
|
|
133
|
+
opts.onClick?.(token);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (token.icon) {
|
|
137
|
+
const icon = document.createElement('span');
|
|
138
|
+
icon.className = 'material-symbols-outlined chat-message-token-icon';
|
|
139
|
+
icon.textContent = token.icon;
|
|
140
|
+
if (opts?.color)
|
|
141
|
+
icon.style.color = opts.color;
|
|
142
|
+
span.appendChild(icon);
|
|
143
|
+
}
|
|
144
|
+
const label = document.createElement('span');
|
|
145
|
+
label.className = 'chat-message-token-label';
|
|
146
|
+
label.textContent = token.label || token.value;
|
|
147
|
+
span.appendChild(label);
|
|
148
|
+
return span;
|
|
149
|
+
}
|
|
150
|
+
// Render a (possibly token-bearing) message string into `textEl`. When the
|
|
151
|
+
// string carries no token markers we keep the original markdown rendering, so
|
|
152
|
+
// model output and plain user text are unchanged. When tokens are present we
|
|
153
|
+
// render text segments as plain text and hydrate each marker into a chip.
|
|
154
|
+
function renderRichText(textEl, text, opts) {
|
|
155
|
+
if (!hasTokens(text)) {
|
|
156
|
+
textEl.innerHTML = marked.parse(text);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
for (const part of parseRichText(text)) {
|
|
160
|
+
if (part.kind === 'token') {
|
|
161
|
+
textEl.appendChild(buildMessageToken(part.token, {
|
|
162
|
+
onClick: opts?.onTokenClick,
|
|
163
|
+
color: opts?.tokenColor,
|
|
164
|
+
}));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const lines = part.text.split('\n');
|
|
168
|
+
for (let i = 0; i < lines.length; i++) {
|
|
169
|
+
if (lines[i].length > 0)
|
|
170
|
+
textEl.appendChild(document.createTextNode(lines[i]));
|
|
171
|
+
if (i < lines.length - 1)
|
|
172
|
+
textEl.appendChild(document.createElement('br'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function getOrCreateActions(el) {
|
|
177
|
+
let actions = el.querySelector('.chat-message-actions');
|
|
178
|
+
if (!actions) {
|
|
179
|
+
actions = document.createElement('div');
|
|
180
|
+
actions.className = 'chat-message-actions';
|
|
181
|
+
el.appendChild(actions);
|
|
182
|
+
}
|
|
183
|
+
return actions;
|
|
184
|
+
}
|
|
185
|
+
function formatMessageTime(at) {
|
|
186
|
+
const d = at instanceof Date ? at : new Date(at);
|
|
187
|
+
const day = d.getDate();
|
|
188
|
+
const teen = day % 100;
|
|
189
|
+
const ones = day % 10;
|
|
190
|
+
const ord = teen >= 11 && teen <= 13 ? "th" : ones === 1 ? "st" : ones === 2 ? "nd" : ones === 3 ? "rd" : "th";
|
|
191
|
+
const month = d.toLocaleString(undefined, { month: "long" });
|
|
192
|
+
const year = d.getFullYear();
|
|
193
|
+
const time = d.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", hour12: true });
|
|
194
|
+
return `${month} ${day}${ord}, ${year} ${time}`;
|
|
195
|
+
}
|
|
196
|
+
function addCopyButton(el, getText, createdAt) {
|
|
197
|
+
const actions = getOrCreateActions(el);
|
|
198
|
+
if (!actions.querySelector(".chat-message-time")) {
|
|
199
|
+
const time = document.createElement("span");
|
|
200
|
+
time.className = "chat-message-time";
|
|
201
|
+
time.textContent = formatMessageTime(createdAt);
|
|
202
|
+
actions.appendChild(time);
|
|
203
|
+
}
|
|
204
|
+
const copyBtn = document.createElement('span');
|
|
205
|
+
copyBtn.className = 'material-symbols-outlined';
|
|
206
|
+
copyBtn.textContent = 'content_copy';
|
|
207
|
+
copyBtn.onclick = (e) => {
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
navigator.clipboard.writeText(getText());
|
|
210
|
+
copyBtn.textContent = 'check';
|
|
211
|
+
setTimeout(() => { copyBtn.textContent = 'content_copy'; }, 1500);
|
|
212
|
+
};
|
|
213
|
+
actions.appendChild(copyBtn);
|
|
214
|
+
}
|
|
215
|
+
function chatMode(mode) {
|
|
216
|
+
return mode === 'thread' ? 'thread' : 'assistant';
|
|
217
|
+
}
|
|
218
|
+
function isIncomingAuthor(message, mode = 'assistant', selfEmail) {
|
|
219
|
+
if (message.role !== 'user')
|
|
220
|
+
return false;
|
|
221
|
+
if (mode !== 'thread')
|
|
222
|
+
return false;
|
|
223
|
+
const email = message.author?.id;
|
|
224
|
+
if (!email || !selfEmail)
|
|
225
|
+
return false;
|
|
226
|
+
return email !== selfEmail;
|
|
227
|
+
}
|
|
228
|
+
function applyUserBubbleLayout(el, incoming, profile) {
|
|
229
|
+
el.querySelector('.chat-message-model-avatar')?.remove();
|
|
230
|
+
el.querySelector(':scope > .chat-message-avatar:not(.chat-message-model-avatar)')?.remove();
|
|
231
|
+
el.classList.remove('chat-message-user', 'chat-message-model', 'chat-message-authored', 'chat-message-model-authored');
|
|
232
|
+
if (incoming) {
|
|
233
|
+
el.classList.add('chat-message-model');
|
|
234
|
+
stampIncomingAvatar(el, profile);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
el.classList.add('chat-message-user');
|
|
238
|
+
stampUserAvatar(el, profile);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function lockedModels(models) {
|
|
242
|
+
if (models === undefined)
|
|
243
|
+
return undefined;
|
|
244
|
+
return Array.isArray(models) ? models : [models];
|
|
245
|
+
}
|
|
246
|
+
function isModelLocked(models) {
|
|
247
|
+
const list = lockedModels(models);
|
|
248
|
+
return Boolean(list && list.length === 1);
|
|
249
|
+
}
|
|
250
|
+
/** A people only room takes the layout ticket chats use once staff join:
|
|
251
|
+
* threaded bubbles and no model picker. */
|
|
252
|
+
function roomLayout(options) {
|
|
253
|
+
if (!options.room?.room.isHuman())
|
|
254
|
+
return options;
|
|
255
|
+
return { ...options, mode: 'thread', models: Fauna.Parrot.Generative.Text.AUTO };
|
|
256
|
+
}
|
|
257
|
+
const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉', '😮', '👎'];
|
|
258
|
+
function reactionOf(message) {
|
|
259
|
+
const part = message.parts?.[0];
|
|
260
|
+
return part?.type === 'REACTION' ? part : undefined;
|
|
261
|
+
}
|
|
262
|
+
function stampIncomingAvatar(el, profile) {
|
|
263
|
+
el.querySelector('.chat-message-model-avatar')?.remove();
|
|
264
|
+
el.classList.add('chat-message-model-authored');
|
|
265
|
+
const avatar = buildAvatar(profile);
|
|
266
|
+
avatar.classList.add('chat-message-model-avatar');
|
|
267
|
+
el.appendChild(avatar);
|
|
268
|
+
}
|
|
269
|
+
function appendTextBlock(el, text, message, onTokenClick) {
|
|
270
|
+
const textEl = document.createElement('div');
|
|
271
|
+
textEl.className = 'chat-message-text';
|
|
272
|
+
const authorSeed = authorColorSeed(message.author);
|
|
273
|
+
renderRichText(textEl, text, {
|
|
274
|
+
onTokenClick,
|
|
275
|
+
tokenColor: authorSeed ? colorFromSeed(authorSeed) : undefined,
|
|
276
|
+
});
|
|
277
|
+
el.appendChild(textEl);
|
|
278
|
+
}
|
|
279
|
+
async function buildMessageElement(message, iconMap, modelIcons, renderToolCall, mode, selfUserId, onTokenClick, fileCards) {
|
|
280
|
+
const incoming = isIncomingAuthor(message, chatMode(mode), selfUserId);
|
|
281
|
+
const el = document.createElement('div');
|
|
282
|
+
el.className = (message.role === 'user' && !incoming) ? 'chat-message-user' : 'chat-message-model';
|
|
283
|
+
if (message.role === 'user' && message.author?.id)
|
|
284
|
+
el.dataset.author = message.author.id;
|
|
285
|
+
if (message.sequence?.length) {
|
|
286
|
+
for (const block of message.sequence) {
|
|
287
|
+
if (block.type === 'text')
|
|
288
|
+
appendTextBlock(el, block.text, message, onTokenClick);
|
|
289
|
+
else if (block.type === 'tool')
|
|
290
|
+
el.appendChild(renderToolCallElement(block.call, renderToolCall));
|
|
291
|
+
else
|
|
292
|
+
el.appendChild(buildFileElement(block, iconMap, fileCards));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
if (message.text)
|
|
297
|
+
appendTextBlock(el, message.text, message, onTokenClick);
|
|
298
|
+
if (message.toolCalls) {
|
|
299
|
+
for (const call of message.toolCalls) {
|
|
300
|
+
el.appendChild(renderToolCallElement(call, renderToolCall));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (message.files) {
|
|
304
|
+
for (const file of message.files) {
|
|
305
|
+
el.appendChild(buildFileElement(file, iconMap, fileCards));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (message.text) {
|
|
310
|
+
addCopyButton(el, () => toPlainText(message.text), message.createdAt);
|
|
311
|
+
}
|
|
312
|
+
if (incoming && message.author) {
|
|
313
|
+
stampIncomingAvatar(el, message.author.profile);
|
|
314
|
+
}
|
|
315
|
+
else if (message.role === 'user' && message.author) {
|
|
316
|
+
stampUserAvatar(el, message.author.profile);
|
|
317
|
+
}
|
|
318
|
+
if (message.role === 'model') {
|
|
319
|
+
stampModelAvatar(el, message.model, modelIcons);
|
|
320
|
+
}
|
|
321
|
+
return el;
|
|
322
|
+
}
|
|
323
|
+
// ---- Streaming ----
|
|
324
|
+
function nearBottom(messagesEl) {
|
|
325
|
+
return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80;
|
|
326
|
+
}
|
|
327
|
+
/** Keeps the newest message in view when the reader was at the bottom. Pass
|
|
328
|
+
* `stick` measured before inserting, or a tall message reads as scrolled away. */
|
|
329
|
+
function autoScroll(messagesEl, stick = nearBottom(messagesEl)) {
|
|
330
|
+
if (stick) {
|
|
331
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function createStreamRenderer(modelEl, messagesEl, renderToolCall) {
|
|
335
|
+
// Text is rendered in *segments* — each run of text between tool calls
|
|
336
|
+
// gets its own .chat-message-text div. This way tool chips persist in the
|
|
337
|
+
// DOM between segments, and the visual paragraph break between pre- and
|
|
338
|
+
// post-tool-call text comes for free from the segment divs being separate
|
|
339
|
+
// block-level elements.
|
|
340
|
+
let currentBuffer = '';
|
|
341
|
+
let currentTextEl = null;
|
|
342
|
+
let dotsCleared = false;
|
|
343
|
+
let shown = 0; // chars of currentBuffer currently revealed (typewriter)
|
|
344
|
+
let rafId = null;
|
|
345
|
+
const toolCalls = [];
|
|
346
|
+
const toolElements = new Map();
|
|
347
|
+
const completedSegments = [];
|
|
348
|
+
function clearDots() {
|
|
349
|
+
if (!dotsCleared) {
|
|
350
|
+
// Keep a stamped model avatar (left badge) across the thinking→stream swap.
|
|
351
|
+
for (const child of [...modelEl.childNodes]) {
|
|
352
|
+
if (child.classList?.contains('chat-message-model-avatar'))
|
|
353
|
+
continue;
|
|
354
|
+
child.remove();
|
|
355
|
+
}
|
|
356
|
+
dotsCleared = true;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function ensureTextEl() {
|
|
360
|
+
if (!currentTextEl) {
|
|
361
|
+
currentTextEl = document.createElement('div');
|
|
362
|
+
currentTextEl.className = 'chat-message-text';
|
|
363
|
+
modelEl.appendChild(currentTextEl);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Smoothly reveal buffered text a little each frame so bursty network deltas
|
|
367
|
+
// flow in like a typewriter instead of landing in blocks. The step scales
|
|
368
|
+
// with the backlog: big chunks drain quickly while a steady stream stays
|
|
369
|
+
// just behind the incoming text.
|
|
370
|
+
function renderShown() {
|
|
371
|
+
if (currentTextEl)
|
|
372
|
+
currentTextEl.innerHTML = marked.parse(currentBuffer.slice(0, shown));
|
|
373
|
+
}
|
|
374
|
+
function stopReveal() {
|
|
375
|
+
if (rafId !== null) {
|
|
376
|
+
cancelAnimationFrame(rafId);
|
|
377
|
+
rafId = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function tick() {
|
|
381
|
+
rafId = null;
|
|
382
|
+
if (!modelEl.isConnected)
|
|
383
|
+
return; // message element was removed — stop.
|
|
384
|
+
const remaining = currentBuffer.length - shown;
|
|
385
|
+
if (remaining > 0) {
|
|
386
|
+
shown = Math.min(currentBuffer.length, shown + Math.max(2, Math.ceil(remaining / 4)));
|
|
387
|
+
renderShown();
|
|
388
|
+
autoScroll(messagesEl);
|
|
389
|
+
}
|
|
390
|
+
if (shown < currentBuffer.length)
|
|
391
|
+
rafId = requestAnimationFrame(tick);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
feedText(delta) {
|
|
395
|
+
currentBuffer += delta;
|
|
396
|
+
clearDots();
|
|
397
|
+
ensureTextEl();
|
|
398
|
+
if (rafId === null)
|
|
399
|
+
rafId = requestAnimationFrame(tick);
|
|
400
|
+
},
|
|
401
|
+
feedToolCall(id, name, args) {
|
|
402
|
+
this.syncToolCalls([{ id, name, args, status: 'running' }]);
|
|
403
|
+
},
|
|
404
|
+
feedToolProgress(name, progress) {
|
|
405
|
+
const stored = [...toolCalls].reverse().find((item) => item.name === name && item.status === 'running');
|
|
406
|
+
if (!stored)
|
|
407
|
+
return;
|
|
408
|
+
this.syncToolCalls([{ ...stored, progress }]);
|
|
409
|
+
},
|
|
410
|
+
syncToolCalls(calls) {
|
|
411
|
+
for (const call of calls) {
|
|
412
|
+
const id = call.id ?? `${call.name}:${JSON.stringify(call.args)}`;
|
|
413
|
+
const view = { ...call, id };
|
|
414
|
+
const existing = toolElements.get(id);
|
|
415
|
+
if (existing) {
|
|
416
|
+
// Room deltas resend every tool on each text chunk; re-rendering
|
|
417
|
+
// unchanged calls would collapse open sources and remount charts.
|
|
418
|
+
const stored = toolCalls.find((item) => item.id === id);
|
|
419
|
+
const changed = stored.status !== call.status
|
|
420
|
+
|| stored.result !== call.result
|
|
421
|
+
|| stored.session?.code !== call.session?.code
|
|
422
|
+
|| (call.status === 'running' && stored.progress !== call.progress);
|
|
423
|
+
if (!changed)
|
|
424
|
+
continue;
|
|
425
|
+
Object.assign(stored, call, { id });
|
|
426
|
+
if (call.status === 'error')
|
|
427
|
+
console.error('[chat] tool failed', call.name, call.result ?? '');
|
|
428
|
+
const el = renderToolCallElement(view, renderToolCall);
|
|
429
|
+
existing.replaceWith(el);
|
|
430
|
+
toolElements.set(id, el);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
stopReveal();
|
|
434
|
+
clearDots();
|
|
435
|
+
if (currentBuffer) {
|
|
436
|
+
shown = currentBuffer.length;
|
|
437
|
+
renderShown();
|
|
438
|
+
completedSegments.push(currentBuffer);
|
|
439
|
+
currentBuffer = '';
|
|
440
|
+
currentTextEl = null;
|
|
441
|
+
shown = 0;
|
|
442
|
+
}
|
|
443
|
+
const stored = { ...call, id };
|
|
444
|
+
toolCalls.push(stored);
|
|
445
|
+
const chip = renderToolCallElement(view, renderToolCall);
|
|
446
|
+
toolElements.set(id, chip);
|
|
447
|
+
modelEl.appendChild(chip);
|
|
448
|
+
gsap.from(chip, { opacity: 0, x: -10, duration: 0.3, ease: 'power2.out' });
|
|
449
|
+
}
|
|
450
|
+
autoScroll(messagesEl);
|
|
451
|
+
},
|
|
452
|
+
feedError(message) {
|
|
453
|
+
stopReveal();
|
|
454
|
+
clearDots();
|
|
455
|
+
ensureTextEl();
|
|
456
|
+
currentBuffer = message;
|
|
457
|
+
shown = message.length;
|
|
458
|
+
if (currentTextEl)
|
|
459
|
+
currentTextEl.textContent = message;
|
|
460
|
+
},
|
|
461
|
+
async finalize() {
|
|
462
|
+
stopReveal();
|
|
463
|
+
// Final markdown re-render of the trailing segment + lock it in.
|
|
464
|
+
if (currentBuffer) {
|
|
465
|
+
completedSegments.push(currentBuffer);
|
|
466
|
+
if (currentTextEl) {
|
|
467
|
+
currentTextEl.innerHTML = await marked.parse(currentBuffer);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
autoScroll(messagesEl);
|
|
471
|
+
return {
|
|
472
|
+
role: 'model',
|
|
473
|
+
text: completedSegments.join('\n\n'),
|
|
474
|
+
createdAt: new Date(),
|
|
475
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
getText() {
|
|
479
|
+
return [...completedSegments, currentBuffer].filter(Boolean).join('\n\n');
|
|
480
|
+
},
|
|
481
|
+
/** Prefer a longer persisted body over a short live buffer. */
|
|
482
|
+
syncText(full) {
|
|
483
|
+
if (!full)
|
|
484
|
+
return;
|
|
485
|
+
const live = this.getText();
|
|
486
|
+
if (full.length <= live.length)
|
|
487
|
+
return;
|
|
488
|
+
stopReveal();
|
|
489
|
+
clearDots();
|
|
490
|
+
const prefix = completedSegments.join('\n');
|
|
491
|
+
let trailing = full;
|
|
492
|
+
if (prefix && (full.startsWith(prefix) || full.startsWith(completedSegments.join('\n\n')))) {
|
|
493
|
+
trailing = full.slice(prefix.length).replace(/^\n+/, '');
|
|
494
|
+
}
|
|
495
|
+
else if (completedSegments.length === 0) {
|
|
496
|
+
trailing = full;
|
|
497
|
+
}
|
|
498
|
+
currentBuffer = trailing;
|
|
499
|
+
shown = trailing.length;
|
|
500
|
+
ensureTextEl();
|
|
501
|
+
if (currentTextEl)
|
|
502
|
+
currentTextEl.innerHTML = marked.parse(trailing);
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function chatAuthorization(options) {
|
|
507
|
+
return options.authorization ?? options.room?.room.getAuthorization();
|
|
508
|
+
}
|
|
509
|
+
function isSystemRoomMessage(message) {
|
|
510
|
+
return message.role === Fauna.Parrot.Generative.Role.SYSTEM || message.role === 'SYSTEM';
|
|
511
|
+
}
|
|
512
|
+
class ChatEngine {
|
|
513
|
+
get fileCards() {
|
|
514
|
+
return { open: this.openFile, authorization: chatAuthorization(this.options) };
|
|
515
|
+
}
|
|
516
|
+
/** File cards for a streamed reply's attached files, each once. */
|
|
517
|
+
paintStreamFiles(el, files) {
|
|
518
|
+
const room = this.options.room?.room;
|
|
519
|
+
if (!room)
|
|
520
|
+
return;
|
|
521
|
+
let added = false;
|
|
522
|
+
for (const attachment of files) {
|
|
523
|
+
if (!attachment.ref || el.querySelector(`[data-ref="${CSS.escape(attachment.ref)}"]`))
|
|
524
|
+
continue;
|
|
525
|
+
const part = { type: 'FILE', ref: attachment.ref, name: attachment.file.name, contentType: attachment.file.contentType, accessCode: attachment.file.authorization.accessCode };
|
|
526
|
+
const card = buildFileElement({ mime: attachment.file.contentType, name: attachment.file.name, part }, this.options.acceptedTypes, this.fileCards);
|
|
527
|
+
const actions = el.querySelector(':scope > .chat-message-actions');
|
|
528
|
+
if (actions)
|
|
529
|
+
el.insertBefore(card, actions);
|
|
530
|
+
else
|
|
531
|
+
el.appendChild(card);
|
|
532
|
+
added = true;
|
|
533
|
+
}
|
|
534
|
+
if (added) {
|
|
535
|
+
this.refs.panel?.added();
|
|
536
|
+
autoScroll(this.refs.messagesEl);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
constructor(refs, options) {
|
|
540
|
+
Object.defineProperty(this, "refs", {
|
|
541
|
+
enumerable: true,
|
|
542
|
+
configurable: true,
|
|
543
|
+
writable: true,
|
|
544
|
+
value: refs
|
|
545
|
+
});
|
|
546
|
+
Object.defineProperty(this, "options", {
|
|
547
|
+
enumerable: true,
|
|
548
|
+
configurable: true,
|
|
549
|
+
writable: true,
|
|
550
|
+
value: options
|
|
551
|
+
});
|
|
552
|
+
Object.defineProperty(this, "history", {
|
|
553
|
+
enumerable: true,
|
|
554
|
+
configurable: true,
|
|
555
|
+
writable: true,
|
|
556
|
+
value: []
|
|
557
|
+
});
|
|
558
|
+
Object.defineProperty(this, "queue", {
|
|
559
|
+
enumerable: true,
|
|
560
|
+
configurable: true,
|
|
561
|
+
writable: true,
|
|
562
|
+
value: []
|
|
563
|
+
});
|
|
564
|
+
Object.defineProperty(this, "generating", {
|
|
565
|
+
enumerable: true,
|
|
566
|
+
configurable: true,
|
|
567
|
+
writable: true,
|
|
568
|
+
value: false
|
|
569
|
+
});
|
|
570
|
+
Object.defineProperty(this, "generation", {
|
|
571
|
+
enumerable: true,
|
|
572
|
+
configurable: true,
|
|
573
|
+
writable: true,
|
|
574
|
+
value: 0
|
|
575
|
+
});
|
|
576
|
+
Object.defineProperty(this, "destroyed", {
|
|
577
|
+
enumerable: true,
|
|
578
|
+
configurable: true,
|
|
579
|
+
writable: true,
|
|
580
|
+
value: false
|
|
581
|
+
});
|
|
582
|
+
Object.defineProperty(this, "runningParts", {
|
|
583
|
+
enumerable: true,
|
|
584
|
+
configurable: true,
|
|
585
|
+
writable: true,
|
|
586
|
+
value: []
|
|
587
|
+
});
|
|
588
|
+
/** Object URLs for the images waiting to be sent, freed once they're sent or removed. */
|
|
589
|
+
Object.defineProperty(this, "localPreviews", {
|
|
590
|
+
enumerable: true,
|
|
591
|
+
configurable: true,
|
|
592
|
+
writable: true,
|
|
593
|
+
value: []
|
|
594
|
+
});
|
|
595
|
+
Object.defineProperty(this, "activeController", {
|
|
596
|
+
enumerable: true,
|
|
597
|
+
configurable: true,
|
|
598
|
+
writable: true,
|
|
599
|
+
value: null
|
|
600
|
+
});
|
|
601
|
+
Object.defineProperty(this, "roomToolAbort", {
|
|
602
|
+
enumerable: true,
|
|
603
|
+
configurable: true,
|
|
604
|
+
writable: true,
|
|
605
|
+
value: new AbortController()
|
|
606
|
+
});
|
|
607
|
+
Object.defineProperty(this, "steerTarget", {
|
|
608
|
+
enumerable: true,
|
|
609
|
+
configurable: true,
|
|
610
|
+
writable: true,
|
|
611
|
+
value: null
|
|
612
|
+
});
|
|
613
|
+
Object.defineProperty(this, "stopRequested", {
|
|
614
|
+
enumerable: true,
|
|
615
|
+
configurable: true,
|
|
616
|
+
writable: true,
|
|
617
|
+
value: false
|
|
618
|
+
});
|
|
619
|
+
Object.defineProperty(this, "currentModel", {
|
|
620
|
+
enumerable: true,
|
|
621
|
+
configurable: true,
|
|
622
|
+
writable: true,
|
|
623
|
+
value: void 0
|
|
624
|
+
});
|
|
625
|
+
Object.defineProperty(this, "currentReasoning", {
|
|
626
|
+
enumerable: true,
|
|
627
|
+
configurable: true,
|
|
628
|
+
writable: true,
|
|
629
|
+
value: void 0
|
|
630
|
+
});
|
|
631
|
+
/** Catalog icons keyed by text model id — used for MODEL bubble avatars. */
|
|
632
|
+
Object.defineProperty(this, "modelIcons", {
|
|
633
|
+
enumerable: true,
|
|
634
|
+
configurable: true,
|
|
635
|
+
writable: true,
|
|
636
|
+
value: {}
|
|
637
|
+
});
|
|
638
|
+
Object.defineProperty(this, "modelDropdown", {
|
|
639
|
+
enumerable: true,
|
|
640
|
+
configurable: true,
|
|
641
|
+
writable: true,
|
|
642
|
+
value: null
|
|
643
|
+
});
|
|
644
|
+
Object.defineProperty(this, "tokenizedInput", {
|
|
645
|
+
enumerable: true,
|
|
646
|
+
configurable: true,
|
|
647
|
+
writable: true,
|
|
648
|
+
value: void 0
|
|
649
|
+
});
|
|
650
|
+
Object.defineProperty(this, "inputWatchers", {
|
|
651
|
+
enumerable: true,
|
|
652
|
+
configurable: true,
|
|
653
|
+
writable: true,
|
|
654
|
+
value: new Set()
|
|
655
|
+
});
|
|
656
|
+
Object.defineProperty(this, "roomTypingHandler", {
|
|
657
|
+
enumerable: true,
|
|
658
|
+
configurable: true,
|
|
659
|
+
writable: true,
|
|
660
|
+
value: null
|
|
661
|
+
});
|
|
662
|
+
Object.defineProperty(this, "roomCleanup", {
|
|
663
|
+
enumerable: true,
|
|
664
|
+
configurable: true,
|
|
665
|
+
writable: true,
|
|
666
|
+
value: null
|
|
667
|
+
});
|
|
668
|
+
Object.defineProperty(this, "roomThinkingEl", {
|
|
669
|
+
enumerable: true,
|
|
670
|
+
configurable: true,
|
|
671
|
+
writable: true,
|
|
672
|
+
value: null
|
|
673
|
+
});
|
|
674
|
+
// Live streaming state for the room's model reply (server-owned turn).
|
|
675
|
+
Object.defineProperty(this, "roomStream", {
|
|
676
|
+
enumerable: true,
|
|
677
|
+
configurable: true,
|
|
678
|
+
writable: true,
|
|
679
|
+
value: null
|
|
680
|
+
});
|
|
681
|
+
// Originator-only tool progress (e.g. poll 0/n), keyed by tool name. The
|
|
682
|
+
// Parrot SDK does not carry chip/token UI — chat maps this onto chips.
|
|
683
|
+
Object.defineProperty(this, "toolProgress", {
|
|
684
|
+
enumerable: true,
|
|
685
|
+
configurable: true,
|
|
686
|
+
writable: true,
|
|
687
|
+
value: new Map()
|
|
688
|
+
});
|
|
689
|
+
// True while the server is streaming/thinking a room reply. Sends made now
|
|
690
|
+
// are buffered by the server into the shared queue (surfaced via onQueue),
|
|
691
|
+
// not the transcript. Cleared when the reply lands / thinking ends.
|
|
692
|
+
Object.defineProperty(this, "roomThinking", {
|
|
693
|
+
enumerable: true,
|
|
694
|
+
configurable: true,
|
|
695
|
+
writable: true,
|
|
696
|
+
value: false
|
|
697
|
+
});
|
|
698
|
+
/** The person said no to making a repository for computer use. Never saved, so reopening the chat asks again. */
|
|
699
|
+
Object.defineProperty(this, "declinedComputer", {
|
|
700
|
+
enumerable: true,
|
|
701
|
+
configurable: true,
|
|
702
|
+
writable: true,
|
|
703
|
+
value: false
|
|
704
|
+
});
|
|
705
|
+
Object.defineProperty(this, "repositoryControl", {
|
|
706
|
+
enumerable: true,
|
|
707
|
+
configurable: true,
|
|
708
|
+
writable: true,
|
|
709
|
+
value: null
|
|
710
|
+
});
|
|
711
|
+
// Queue chips for the shared server-side queue (buffered sends while the
|
|
712
|
+
// model replies), keyed by durable queue-row id, plus the "Steer all" header.
|
|
713
|
+
// Driven entirely by the room's onQueue hook — never by the local send path.
|
|
714
|
+
Object.defineProperty(this, "roomQueue", {
|
|
715
|
+
enumerable: true,
|
|
716
|
+
configurable: true,
|
|
717
|
+
writable: true,
|
|
718
|
+
value: new Map()
|
|
719
|
+
});
|
|
720
|
+
Object.defineProperty(this, "roomSteerAllEl", {
|
|
721
|
+
enumerable: true,
|
|
722
|
+
configurable: true,
|
|
723
|
+
writable: true,
|
|
724
|
+
value: null
|
|
725
|
+
});
|
|
726
|
+
// Reactions folded onto their target message: target position → emoji →
|
|
727
|
+
// reactor email → the latest state, by the reaction message's position.
|
|
728
|
+
Object.defineProperty(this, "reactions", {
|
|
729
|
+
enumerable: true,
|
|
730
|
+
configurable: true,
|
|
731
|
+
writable: true,
|
|
732
|
+
value: new Map()
|
|
733
|
+
});
|
|
734
|
+
/** Open a file from a message: a tab in the side panel when there is one, else a popup. */
|
|
735
|
+
Object.defineProperty(this, "openFile", {
|
|
736
|
+
enumerable: true,
|
|
737
|
+
configurable: true,
|
|
738
|
+
writable: true,
|
|
739
|
+
value: (file) => {
|
|
740
|
+
const room = this.options.room?.room;
|
|
741
|
+
if (!room || !file.part)
|
|
742
|
+
return;
|
|
743
|
+
const attachment = attachmentOf(room, file.part);
|
|
744
|
+
if (this.refs.panel)
|
|
745
|
+
this.refs.panel.open(attachment);
|
|
746
|
+
else
|
|
747
|
+
openViewModal(attachment);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
// ── Send button ──
|
|
751
|
+
Object.defineProperty(this, "sendTooltip", {
|
|
752
|
+
enumerable: true,
|
|
753
|
+
configurable: true,
|
|
754
|
+
writable: true,
|
|
755
|
+
value: null
|
|
756
|
+
});
|
|
757
|
+
const locked = lockedModels(options.models);
|
|
758
|
+
const start = options.model && (!locked || locked.includes(options.model)) ? options.model : Fauna.Parrot.Generative.Text.AUTO;
|
|
759
|
+
this.currentModel = locked?.length === 1 ? locked[0] : start;
|
|
760
|
+
this.currentReasoning = this.currentModel === start ? options.reasoning : undefined;
|
|
761
|
+
this.tokenizedInput = createTokenizedInput(refs.rowEl, {
|
|
762
|
+
placeholder: options.placeholder ?? 'Type a message...',
|
|
763
|
+
triggers: options.triggers,
|
|
764
|
+
multiline: false,
|
|
765
|
+
onInput: (parts) => {
|
|
766
|
+
this.roomTypingHandler?.();
|
|
767
|
+
for (const handler of this.inputWatchers)
|
|
768
|
+
handler(parts);
|
|
769
|
+
},
|
|
770
|
+
onSubmit: () => {
|
|
771
|
+
this.doSend(this.tokenizedInput.getText(), this.tokenizedInput.getParts(), this.runningParts.length > 0 ? [...this.runningParts] : undefined);
|
|
772
|
+
this.tokenizedInput.clear();
|
|
773
|
+
this.clearParts();
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
this.wireModelSelect();
|
|
777
|
+
this.syncModelSelectVisibility();
|
|
778
|
+
this.loadModelIcons();
|
|
779
|
+
this.wireAttach();
|
|
780
|
+
this.wireSend();
|
|
781
|
+
if (options.initialHistory) {
|
|
782
|
+
const gen = this.generation;
|
|
783
|
+
for (const message of options.initialHistory) {
|
|
784
|
+
this.history.push(message);
|
|
785
|
+
buildMessageElement(message, options.acceptedTypes, this.modelIcons, options.renderToolCall, options.mode, options.room?.self.profile.email, options.onTokenClick, this.fileCards).then((el) => {
|
|
786
|
+
if (this.destroyed || this.generation !== gen)
|
|
787
|
+
return;
|
|
788
|
+
refs.messagesEl.appendChild(el);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (options.room) {
|
|
793
|
+
this.setupRoomMode(options.room.room, options.room.self);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// ── Model selector (custom dropdown with provider icons) ──
|
|
797
|
+
syncModelSelectVisibility() {
|
|
798
|
+
const el = this.refs.modelSelect;
|
|
799
|
+
if (!el)
|
|
800
|
+
return;
|
|
801
|
+
el.style.display = isModelLocked(this.options.models) ? 'none' : '';
|
|
802
|
+
}
|
|
803
|
+
wireModelSelect() {
|
|
804
|
+
const container = this.refs.modelSelect;
|
|
805
|
+
if (isModelLocked(this.options.models) || !container || this.modelDropdown)
|
|
806
|
+
return;
|
|
807
|
+
this.modelDropdown = createSelector(container, {
|
|
808
|
+
value: this.currentModel,
|
|
809
|
+
reasoning: this.currentReasoning,
|
|
810
|
+
onChange: (model) => {
|
|
811
|
+
this.currentModel = model;
|
|
812
|
+
this.options.onModelChange?.(this.currentModel);
|
|
813
|
+
},
|
|
814
|
+
onReasoningChange: (reasoning) => {
|
|
815
|
+
this.currentReasoning = reasoning;
|
|
816
|
+
this.options.onReasoningChange?.(reasoning);
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
Fauna.Parrot.Routing.getAll()
|
|
820
|
+
.then((catalog) => {
|
|
821
|
+
const textRows = catalog.text ?? [];
|
|
822
|
+
this.rememberModelIcons(textRows);
|
|
823
|
+
const allowed = lockedModels(this.options.models);
|
|
824
|
+
const textModels = (allowed
|
|
825
|
+
? textRows.filter((l) => allowed.includes(l.model))
|
|
826
|
+
: textRows).map((l) => ({ value: l.model, name: l.name, icon: l.icon, reasoning: l.reasoning }));
|
|
827
|
+
this.modelDropdown?.setOptions(textModels);
|
|
828
|
+
this.modelDropdown?.setValue(this.currentModel);
|
|
829
|
+
this.modelDropdown?.setReasoning(this.currentReasoning);
|
|
830
|
+
})
|
|
831
|
+
.catch((err) => console.error('[chat] failed to load models:', err));
|
|
832
|
+
}
|
|
833
|
+
/** Ensure catalog icons are available even when the model picker is fixed. */
|
|
834
|
+
loadModelIcons() {
|
|
835
|
+
if (this.refs.modelSelect && !isModelLocked(this.options.models))
|
|
836
|
+
return;
|
|
837
|
+
Fauna.Parrot.Routing.getAll()
|
|
838
|
+
.then(({ text }) => this.rememberModelIcons(text ?? []))
|
|
839
|
+
.catch((err) => console.error('[chat] failed to load model icons:', err));
|
|
840
|
+
}
|
|
841
|
+
rememberModelIcons(llms) {
|
|
842
|
+
const next = {};
|
|
843
|
+
for (const l of llms)
|
|
844
|
+
next[l.model] = l.icon;
|
|
845
|
+
this.modelIcons = next;
|
|
846
|
+
// Re-stamp any already-rendered MODEL bubbles that were waiting on icons.
|
|
847
|
+
for (const el of this.refs.messagesEl.querySelectorAll('.chat-message-model')) {
|
|
848
|
+
const model = el.dataset.model || undefined;
|
|
849
|
+
if (model && this.modelIcons[model])
|
|
850
|
+
stampModelAvatar(el, model, this.modelIcons);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
// ── Attach button ──
|
|
854
|
+
wireAttach() {
|
|
855
|
+
const attachBtn = this.refs.attachBtn;
|
|
856
|
+
if (!attachBtn)
|
|
857
|
+
return;
|
|
858
|
+
attachBtn.onmouseenter = () => { gsap.to(attachBtn, { duration: 0.3, rotate: 180, color: 'var(--chat-text, #1a1a1a)', ease: 'power2.inOut' }); };
|
|
859
|
+
attachBtn.onmouseleave = () => { gsap.to(attachBtn, { duration: 0.3, rotate: 0, color: 'var(--chat-accent, #888)', ease: 'power2.inOut' }); };
|
|
860
|
+
attachBtn.onclick = () => {
|
|
861
|
+
if (!this.driveAuth()) {
|
|
862
|
+
this.options.callbacks?.onError?.('Guests cannot attach files.');
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const input = document.createElement('input');
|
|
866
|
+
input.type = 'file';
|
|
867
|
+
input.style.display = 'none';
|
|
868
|
+
const accepted = this.options.acceptedTypes ?? {};
|
|
869
|
+
if (Object.keys(accepted).length > 0) {
|
|
870
|
+
// Include extension globs so HEIC picks work when browsers omit MIME.
|
|
871
|
+
const accept = [
|
|
872
|
+
...Object.keys(accepted),
|
|
873
|
+
'.heic', '.heif',
|
|
874
|
+
];
|
|
875
|
+
input.accept = [...new Set(accept)].join(',');
|
|
876
|
+
}
|
|
877
|
+
input.addEventListener('change', () => {
|
|
878
|
+
if (!input.files)
|
|
879
|
+
return;
|
|
880
|
+
for (const file of input.files) {
|
|
881
|
+
const mime = file.type
|
|
882
|
+
|| mimeFromFileName(file.name)
|
|
883
|
+
|| '';
|
|
884
|
+
if (Object.keys(accepted).length > 0 && !accepted[mime])
|
|
885
|
+
continue;
|
|
886
|
+
const reader = new FileReader();
|
|
887
|
+
reader.onload = (e) => {
|
|
888
|
+
const buffer = e.target?.result;
|
|
889
|
+
void this.attachPart(mime, file.name, new Uint8Array(buffer));
|
|
890
|
+
};
|
|
891
|
+
reader.readAsArrayBuffer(file);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
document.body.appendChild(input);
|
|
895
|
+
input.click();
|
|
896
|
+
input.remove();
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
wireSend() {
|
|
900
|
+
const sendBtn = this.refs.sendBtn;
|
|
901
|
+
this.sendTooltip = createTooltip(sendBtn, 'Send');
|
|
902
|
+
// Rests pointing right (arrow_forward), tilts up on hover.
|
|
903
|
+
sendBtn.onmouseenter = () => { gsap.to(sendBtn, { duration: 0.3, rotate: (this.generating || this.roomThinking) ? 0 : -90, color: 'var(--chat-text, #1a1a1a)', ease: 'power2.inOut' }); };
|
|
904
|
+
sendBtn.onmouseleave = () => { gsap.to(sendBtn, { duration: 0.3, rotate: 0, color: 'var(--chat-accent, #888)', ease: 'power2.inOut' }); };
|
|
905
|
+
sendBtn.onclick = () => {
|
|
906
|
+
// While a reply is running, the button is a stop control. In room mode
|
|
907
|
+
// the model runs server-side, so "stop" means steer the room (cancel
|
|
908
|
+
// the in-flight reply); in solo mode it aborts the local stream.
|
|
909
|
+
if (this.options.room) {
|
|
910
|
+
// Stop control while a reply runs: queue present → steer-all
|
|
911
|
+
// (flush + re-answer). Empty queue → cancel and stay stopped.
|
|
912
|
+
if (this.roomThinking) {
|
|
913
|
+
if (this.roomQueue.size > 0)
|
|
914
|
+
this.steerAllRoom();
|
|
915
|
+
else
|
|
916
|
+
this.stopRoom();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
else if (this.generating) {
|
|
921
|
+
this.stopGeneration();
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
this.doSend(this.tokenizedInput.getText(), this.tokenizedInput.getParts(), this.runningParts.length > 0 ? [...this.runningParts] : undefined);
|
|
925
|
+
this.tokenizedInput.clear();
|
|
926
|
+
this.clearParts();
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
setComposerGenerating(on) {
|
|
930
|
+
const sendBtn = this.refs.sendBtn;
|
|
931
|
+
sendBtn.textContent = on ? 'stop' : 'arrow_forward';
|
|
932
|
+
this.sendTooltip?.setText(on ? 'Stop generating' : 'Send');
|
|
933
|
+
sendBtn.classList.toggle('chat-stop', on);
|
|
934
|
+
}
|
|
935
|
+
stopGeneration() {
|
|
936
|
+
this.steerTarget = null;
|
|
937
|
+
this.stopRequested = true;
|
|
938
|
+
this.activeController?.abort();
|
|
939
|
+
}
|
|
940
|
+
driveAuth() {
|
|
941
|
+
return chatAuthorization(this.options);
|
|
942
|
+
}
|
|
943
|
+
async uploadBytes(mime, name, data) {
|
|
944
|
+
const auth = this.driveAuth();
|
|
945
|
+
if (!auth) {
|
|
946
|
+
this.options.callbacks?.onError?.('Guests cannot attach files.');
|
|
947
|
+
return undefined;
|
|
948
|
+
}
|
|
949
|
+
const dest = `tmp/${crypto.randomUUID()}-${name}`;
|
|
950
|
+
const file = Fauna.Drive.fromBytes(auth, dest, mime, data);
|
|
951
|
+
try {
|
|
952
|
+
await file.upload();
|
|
953
|
+
return file;
|
|
954
|
+
}
|
|
955
|
+
catch (err) {
|
|
956
|
+
this.options.callbacks?.onError?.(err?.message ?? 'Could not upload the file.');
|
|
957
|
+
return undefined;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
// ── Attachment parts ──
|
|
961
|
+
async attachPart(mime, name, data) {
|
|
962
|
+
if (this.runningParts.some((p) => p.name === name || p.name.endsWith(`-${name}`)))
|
|
963
|
+
return;
|
|
964
|
+
const file = await this.uploadBytes(mime, name, data);
|
|
965
|
+
if (!file)
|
|
966
|
+
return;
|
|
967
|
+
if (this.runningParts.length === 0) {
|
|
968
|
+
gsap.to(this.refs.partsEl, { duration: 0.5, height: 120, opacity: 1, ease: 'power2.inOut' });
|
|
969
|
+
}
|
|
970
|
+
const partEl = document.createElement('div');
|
|
971
|
+
partEl.className = 'chat-input-part';
|
|
972
|
+
// Images show from the bytes being sent; browsers can't draw HEIC, so it waits for the server's preview like the rest.
|
|
973
|
+
const local = mime.startsWith('image/') && mime !== 'image/heic' && mime !== 'image/heif'
|
|
974
|
+
? URL.createObjectURL(new Blob([new Uint8Array(data)], { type: mime }))
|
|
975
|
+
: null;
|
|
976
|
+
if (local)
|
|
977
|
+
this.localPreviews.push(local);
|
|
978
|
+
const deleteBtn = document.createElement('span');
|
|
979
|
+
deleteBtn.className = 'material-symbols-outlined chat-input-part-delete';
|
|
980
|
+
deleteBtn.textContent = 'close';
|
|
981
|
+
partEl.appendChild(deleteBtn);
|
|
982
|
+
deleteBtn.onclick = async () => {
|
|
983
|
+
this.runningParts = this.runningParts.filter((p) => p !== file);
|
|
984
|
+
if (file.online) {
|
|
985
|
+
try {
|
|
986
|
+
await file.remove();
|
|
987
|
+
}
|
|
988
|
+
catch { /* already gone */ }
|
|
989
|
+
}
|
|
990
|
+
await new Promise((resolve) => {
|
|
991
|
+
gsap.to(partEl, { duration: 0.5, opacity: 0, ease: 'power2.inOut', onComplete: resolve });
|
|
992
|
+
});
|
|
993
|
+
if (local) {
|
|
994
|
+
URL.revokeObjectURL(local);
|
|
995
|
+
this.localPreviews = this.localPreviews.filter((url) => url !== local);
|
|
996
|
+
}
|
|
997
|
+
partEl.remove();
|
|
998
|
+
if (this.runningParts.length === 0)
|
|
999
|
+
this.clearParts();
|
|
1000
|
+
};
|
|
1001
|
+
const thumb = document.createElement('span');
|
|
1002
|
+
thumb.className = 'chat-file-thumb chat-input-part-thumb';
|
|
1003
|
+
const previewIcon = document.createElement('span');
|
|
1004
|
+
previewIcon.className = 'material-symbols-outlined chat-input-part-preview';
|
|
1005
|
+
previewIcon.textContent = (this.options.acceptedTypes ?? {})[mime] ?? 'description';
|
|
1006
|
+
thumb.appendChild(previewIcon);
|
|
1007
|
+
partEl.appendChild(thumb);
|
|
1008
|
+
if (local)
|
|
1009
|
+
paintPreview(thumb, local);
|
|
1010
|
+
else
|
|
1011
|
+
showPreview(thumb, { key: `upload:${file.authorization.path ?? name}`, file });
|
|
1012
|
+
const labelEl = document.createElement('a');
|
|
1013
|
+
labelEl.className = 'chat-input-part-label';
|
|
1014
|
+
labelEl.textContent = name;
|
|
1015
|
+
partEl.appendChild(labelEl);
|
|
1016
|
+
this.refs.partsEl.appendChild(partEl);
|
|
1017
|
+
gsap.from(partEl, { duration: 0.5, opacity: 0, ease: 'power2.inOut' });
|
|
1018
|
+
this.runningParts.push(file);
|
|
1019
|
+
}
|
|
1020
|
+
clearParts() {
|
|
1021
|
+
this.runningParts = [];
|
|
1022
|
+
for (const url of this.localPreviews.splice(0))
|
|
1023
|
+
URL.revokeObjectURL(url);
|
|
1024
|
+
while (this.refs.partsEl.firstChild)
|
|
1025
|
+
this.refs.partsEl.firstChild.remove();
|
|
1026
|
+
gsap.to(this.refs.partsEl, { duration: 0.5, height: 0, opacity: 0, ease: 'power2.inOut' });
|
|
1027
|
+
}
|
|
1028
|
+
// ── Send / Queue ──
|
|
1029
|
+
renderUserMessage(message) {
|
|
1030
|
+
const incoming = isIncomingAuthor(message, chatMode(this.options.mode), this.options.room?.self.profile.email);
|
|
1031
|
+
const userEl = document.createElement('div');
|
|
1032
|
+
userEl.className = incoming ? 'chat-message-model' : 'chat-message-user';
|
|
1033
|
+
if (message.role === 'user' && message.author?.id)
|
|
1034
|
+
userEl.dataset.author = message.author.id;
|
|
1035
|
+
if (message.text) {
|
|
1036
|
+
const textEl = document.createElement('div');
|
|
1037
|
+
textEl.className = 'chat-message-text';
|
|
1038
|
+
const authorSeed = authorColorSeed(message.author);
|
|
1039
|
+
renderRichText(textEl, message.text, {
|
|
1040
|
+
onTokenClick: this.options.onTokenClick,
|
|
1041
|
+
tokenColor: authorSeed ? colorFromSeed(authorSeed) : undefined,
|
|
1042
|
+
});
|
|
1043
|
+
userEl.appendChild(textEl);
|
|
1044
|
+
}
|
|
1045
|
+
if (message.files) {
|
|
1046
|
+
for (const file of message.files) {
|
|
1047
|
+
userEl.appendChild(buildFileElement(file, this.options.acceptedTypes, this.fileCards));
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (message.text) {
|
|
1051
|
+
addCopyButton(userEl, () => toPlainText(message.text), message.createdAt);
|
|
1052
|
+
}
|
|
1053
|
+
if (incoming && message.author) {
|
|
1054
|
+
stampIncomingAvatar(userEl, message.author.profile);
|
|
1055
|
+
}
|
|
1056
|
+
else if (message.author) {
|
|
1057
|
+
stampUserAvatar(userEl, message.author.profile);
|
|
1058
|
+
}
|
|
1059
|
+
return userEl;
|
|
1060
|
+
}
|
|
1061
|
+
doSend(text, parts, files) {
|
|
1062
|
+
if (!text.trim() && (!files || files.length === 0))
|
|
1063
|
+
return;
|
|
1064
|
+
// Room mode: the server owns turn-taking AND append-vs-queue. Just send;
|
|
1065
|
+
// the message either lands in the transcript (fans back as chat-message)
|
|
1066
|
+
// or is buffered into the shared queue (fans back as chat-queue-add). We
|
|
1067
|
+
// never render locally or add a chip here — the room's events are the sole
|
|
1068
|
+
// source of truth, so ordering + the queue stay consistent across members.
|
|
1069
|
+
if (this.options.room) {
|
|
1070
|
+
const roomParts = [];
|
|
1071
|
+
if (text.trim())
|
|
1072
|
+
roomParts.push({ type: 'TEXT', text: text.trim() });
|
|
1073
|
+
for (const file of files ?? [])
|
|
1074
|
+
roomParts.push({ type: 'FILE', file });
|
|
1075
|
+
this.options.room.self.setStatus('');
|
|
1076
|
+
if (this.options.room.room.isHuman()) {
|
|
1077
|
+
void this.options.room.room.sendMessage(Fauna.Parrot.Generative.Role.USER, roomParts)
|
|
1078
|
+
.catch((err) => { this.options.callbacks?.onError?.(err.message); });
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const toolsOpt = this.options.room.tools;
|
|
1082
|
+
const toolSchemas = toolsOpt && !Array.isArray(toolsOpt)
|
|
1083
|
+
? toolsOpt.function
|
|
1084
|
+
: [
|
|
1085
|
+
...(toolsOpt ?? []).map((t) => t.function),
|
|
1086
|
+
...(this.offersComputer() ? [Fauna.Termite.OS.desktopTool(null)] : []),
|
|
1087
|
+
];
|
|
1088
|
+
const sent = Array.isArray(toolSchemas) && toolSchemas.length === 0 ? undefined : toolSchemas;
|
|
1089
|
+
void this.options.room.room.sendMessage(Fauna.Parrot.Generative.Role.USER, roomParts, {
|
|
1090
|
+
model: this.currentModel,
|
|
1091
|
+
reasoning: this.currentReasoning,
|
|
1092
|
+
...(sent !== undefined ? { tools: sent } : {}),
|
|
1093
|
+
})
|
|
1094
|
+
.catch((err) => { this.options.callbacks?.onError?.(err.message); });
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const userMessage = {
|
|
1098
|
+
role: 'user',
|
|
1099
|
+
text: text.trim(),
|
|
1100
|
+
createdAt: new Date(),
|
|
1101
|
+
parts: parts,
|
|
1102
|
+
files: files?.map((f) => ({ mime: f.contentType, name: f.name }))
|
|
1103
|
+
};
|
|
1104
|
+
if (this.generating) {
|
|
1105
|
+
this.enqueueMessage(text.trim(), parts, files);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const userEl = this.renderUserMessage(userMessage);
|
|
1109
|
+
this.refs.messagesEl.appendChild(userEl);
|
|
1110
|
+
gsap.from(userEl, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
|
|
1111
|
+
this.history.push(userMessage);
|
|
1112
|
+
this.options.callbacks?.onMessage?.(userMessage);
|
|
1113
|
+
this.processGeneration(files);
|
|
1114
|
+
}
|
|
1115
|
+
enqueueMessage(text, parts, files) {
|
|
1116
|
+
const chip = document.createElement('div');
|
|
1117
|
+
chip.className = 'chat-queue-item';
|
|
1118
|
+
const icon = document.createElement('span');
|
|
1119
|
+
icon.className = 'material-symbols-outlined chat-queue-item-icon';
|
|
1120
|
+
icon.textContent = 'schedule';
|
|
1121
|
+
chip.appendChild(icon);
|
|
1122
|
+
const label = document.createElement('div');
|
|
1123
|
+
label.className = 'chat-queue-item-label';
|
|
1124
|
+
const preview = toPlainText(text).trim();
|
|
1125
|
+
const fileNote = files && files.length > 0 ? `${files.length} file${files.length > 1 ? 's' : ''}` : '';
|
|
1126
|
+
label.textContent = preview || fileNote || 'Attachment';
|
|
1127
|
+
if (preview && fileNote)
|
|
1128
|
+
label.title = `${preview} · ${fileNote}`;
|
|
1129
|
+
chip.appendChild(label);
|
|
1130
|
+
const actions = document.createElement('div');
|
|
1131
|
+
actions.className = 'chat-queue-item-actions';
|
|
1132
|
+
const steerBtn = document.createElement('span');
|
|
1133
|
+
steerBtn.className = 'material-symbols-outlined chat-queue-steer';
|
|
1134
|
+
steerBtn.textContent = 'bolt';
|
|
1135
|
+
createTooltip(steerBtn, 'Send now — stops the current response and runs this next');
|
|
1136
|
+
steerBtn.onclick = (e) => {
|
|
1137
|
+
e.stopPropagation();
|
|
1138
|
+
if (!this.queue.some((q) => q.element === chip))
|
|
1139
|
+
return;
|
|
1140
|
+
this.steerTarget = chip;
|
|
1141
|
+
if (this.generating)
|
|
1142
|
+
this.activeController?.abort();
|
|
1143
|
+
};
|
|
1144
|
+
actions.appendChild(steerBtn);
|
|
1145
|
+
const removeBtn = document.createElement('span');
|
|
1146
|
+
removeBtn.className = 'material-symbols-outlined chat-queue-remove';
|
|
1147
|
+
removeBtn.textContent = 'close';
|
|
1148
|
+
createTooltip(removeBtn, 'Remove from queue');
|
|
1149
|
+
removeBtn.onclick = (e) => {
|
|
1150
|
+
e.stopPropagation();
|
|
1151
|
+
const idx = this.queue.findIndex((q) => q.element === chip);
|
|
1152
|
+
if (idx >= 0) {
|
|
1153
|
+
if (this.steerTarget === chip)
|
|
1154
|
+
this.steerTarget = null;
|
|
1155
|
+
this.queue.splice(idx, 1);
|
|
1156
|
+
gsap.to(chip, {
|
|
1157
|
+
duration: 0.25, opacity: 0, height: 0, marginBottom: 0, ease: 'power2.in',
|
|
1158
|
+
onComplete: () => { chip.remove(); }
|
|
1159
|
+
});
|
|
1160
|
+
this.options.callbacks?.onQueueChange?.(this.queue.length);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
actions.appendChild(removeBtn);
|
|
1164
|
+
chip.appendChild(actions);
|
|
1165
|
+
this.refs.queueEl.appendChild(chip);
|
|
1166
|
+
gsap.from(chip, { duration: 0.3, opacity: 0, y: 8, ease: 'power2.out' });
|
|
1167
|
+
this.queue.push({ text, parts, files, element: chip });
|
|
1168
|
+
this.options.callbacks?.onQueueChange?.(this.queue.length);
|
|
1169
|
+
}
|
|
1170
|
+
runQueued(index) {
|
|
1171
|
+
const next = this.queue.splice(index, 1)[0];
|
|
1172
|
+
if (!next)
|
|
1173
|
+
return;
|
|
1174
|
+
this.options.callbacks?.onQueueChange?.(this.queue.length);
|
|
1175
|
+
next.element.remove();
|
|
1176
|
+
const queuedMessage = {
|
|
1177
|
+
role: 'user',
|
|
1178
|
+
text: next.text,
|
|
1179
|
+
createdAt: new Date(),
|
|
1180
|
+
parts: next.parts,
|
|
1181
|
+
files: next.files?.map((f) => ({ mime: f.contentType, name: f.name }))
|
|
1182
|
+
};
|
|
1183
|
+
const userEl = this.renderUserMessage(queuedMessage);
|
|
1184
|
+
this.refs.messagesEl.appendChild(userEl);
|
|
1185
|
+
gsap.from(userEl, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
|
|
1186
|
+
this.history.push(queuedMessage);
|
|
1187
|
+
this.options.callbacks?.onMessage?.(queuedMessage);
|
|
1188
|
+
this.processGeneration(next.files);
|
|
1189
|
+
}
|
|
1190
|
+
drainQueue() {
|
|
1191
|
+
if (this.steerTarget) {
|
|
1192
|
+
const target = this.steerTarget;
|
|
1193
|
+
this.steerTarget = null;
|
|
1194
|
+
const idx = this.queue.findIndex((q) => q.element === target);
|
|
1195
|
+
if (idx >= 0) {
|
|
1196
|
+
this.runQueued(idx);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (this.queue.length > 0)
|
|
1201
|
+
this.runQueued(0);
|
|
1202
|
+
}
|
|
1203
|
+
async processGeneration(files) {
|
|
1204
|
+
if (!this.options.generate)
|
|
1205
|
+
return;
|
|
1206
|
+
if (this.destroyed)
|
|
1207
|
+
return;
|
|
1208
|
+
this.generating = true;
|
|
1209
|
+
this.generation++;
|
|
1210
|
+
this.stopRequested = false;
|
|
1211
|
+
const gen = this.generation;
|
|
1212
|
+
this.setComposerGenerating(true);
|
|
1213
|
+
const controller = new AbortController();
|
|
1214
|
+
this.activeController = controller;
|
|
1215
|
+
const modelEl = document.createElement('div');
|
|
1216
|
+
modelEl.className = 'chat-message-model';
|
|
1217
|
+
modelEl.dataset.model = this.currentModel;
|
|
1218
|
+
modelEl.appendChild(buildThinkingIndicator());
|
|
1219
|
+
stampModelAvatar(modelEl, this.currentModel, this.modelIcons);
|
|
1220
|
+
this.refs.messagesEl.appendChild(modelEl);
|
|
1221
|
+
gsap.from(modelEl, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
|
|
1222
|
+
gsap.to(this.refs.messagesEl, { duration: 0.5, scrollTop: this.refs.messagesEl.scrollHeight, ease: 'power2.inOut' });
|
|
1223
|
+
const renderer = createStreamRenderer(modelEl, this.refs.messagesEl, this.options.renderToolCall);
|
|
1224
|
+
const isStale = () => this.destroyed || this.generation !== gen;
|
|
1225
|
+
const usedModel = this.currentModel;
|
|
1226
|
+
try {
|
|
1227
|
+
const stream = this.options.generate(usedModel, this.history, files ?? [], controller.signal, this.currentReasoning);
|
|
1228
|
+
for await (const event of stream) {
|
|
1229
|
+
if (isStale())
|
|
1230
|
+
break;
|
|
1231
|
+
if (event.event === 'text') {
|
|
1232
|
+
renderer.feedText(event.delta);
|
|
1233
|
+
}
|
|
1234
|
+
else if (event.event === 'tool_call') {
|
|
1235
|
+
renderer.feedToolCall(event.id, event.name, event.args);
|
|
1236
|
+
}
|
|
1237
|
+
else if (event.event === 'error') {
|
|
1238
|
+
renderer.feedError('Something went wrong. Please try again.');
|
|
1239
|
+
this.options.callbacks?.onError?.(event.message);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
catch (err) {
|
|
1244
|
+
if (!controller.signal.aborted) {
|
|
1245
|
+
console.error(err);
|
|
1246
|
+
if (!renderer.getText()) {
|
|
1247
|
+
renderer.feedError('Something went wrong. Please try again.');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (isStale())
|
|
1252
|
+
return;
|
|
1253
|
+
const aborted = controller.signal.aborted;
|
|
1254
|
+
const modelMessage = await renderer.finalize();
|
|
1255
|
+
modelMessage.model = usedModel;
|
|
1256
|
+
const hasContent = !!modelMessage.text || (modelMessage.toolCalls?.length ?? 0) > 0;
|
|
1257
|
+
if (aborted && !hasContent) {
|
|
1258
|
+
modelEl.remove();
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
if (modelMessage.text) {
|
|
1262
|
+
addCopyButton(modelEl, () => modelMessage.text, modelMessage.createdAt);
|
|
1263
|
+
}
|
|
1264
|
+
stampModelAvatar(modelEl, usedModel, this.modelIcons);
|
|
1265
|
+
this.history.push(modelMessage);
|
|
1266
|
+
this.options.callbacks?.onStreamComplete?.(modelMessage);
|
|
1267
|
+
}
|
|
1268
|
+
this.generating = false;
|
|
1269
|
+
this.activeController = null;
|
|
1270
|
+
this.setComposerGenerating(false);
|
|
1271
|
+
if (this.stopRequested) {
|
|
1272
|
+
this.stopRequested = false;
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
this.drainQueue();
|
|
1276
|
+
}
|
|
1277
|
+
// ── Room mode (multiplayer) ──
|
|
1278
|
+
roomMessageToChat(_room, _self, message) {
|
|
1279
|
+
const isUser = message.role === Fauna.Parrot.Generative.Role.USER;
|
|
1280
|
+
return {
|
|
1281
|
+
role: isUser ? 'user' : 'model',
|
|
1282
|
+
createdAt: message.created ? new Date(message.created) : new Date(),
|
|
1283
|
+
model: isUser ? undefined : message.model,
|
|
1284
|
+
author: isUser && message.author?.email
|
|
1285
|
+
? { id: message.author.email, profile: message.author }
|
|
1286
|
+
: undefined,
|
|
1287
|
+
...roomPartsToChatFields(message.parts),
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
// Insert a rendered message element at the position matching its durable
|
|
1291
|
+
// `position` in the transcript, so members render in a consistent order
|
|
1292
|
+
// regardless of event-arrival timing (fixes interleaving on multi-member
|
|
1293
|
+
// sends / queue releases). Elements carry `data-position`; we insert before
|
|
1294
|
+
// the first existing element with a greater position, else append.
|
|
1295
|
+
insertByPosition(el, position) {
|
|
1296
|
+
el.dataset.position = String(position);
|
|
1297
|
+
const siblings = this.refs.messagesEl.querySelectorAll('[data-position]');
|
|
1298
|
+
let before = null;
|
|
1299
|
+
for (const sib of siblings) {
|
|
1300
|
+
const p = Number(sib.dataset.position);
|
|
1301
|
+
if (p > position) {
|
|
1302
|
+
before = sib;
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (before)
|
|
1307
|
+
this.refs.messagesEl.insertBefore(el, before);
|
|
1308
|
+
else
|
|
1309
|
+
this.refs.messagesEl.appendChild(el);
|
|
1310
|
+
}
|
|
1311
|
+
paintedMessage(messageId) {
|
|
1312
|
+
if (!messageId)
|
|
1313
|
+
return null;
|
|
1314
|
+
const escaped = typeof CSS !== "undefined" && CSS.escape
|
|
1315
|
+
? CSS.escape(messageId)
|
|
1316
|
+
: messageId.replace(/["\\]/g, "");
|
|
1317
|
+
return this.refs.messagesEl.querySelector(`[data-message-id="${escaped}"]`);
|
|
1318
|
+
}
|
|
1319
|
+
renderRoomMessage(chat, messageId, position) {
|
|
1320
|
+
if (this.destroyed)
|
|
1321
|
+
return;
|
|
1322
|
+
if (this.paintedMessage(messageId))
|
|
1323
|
+
return;
|
|
1324
|
+
const gen = this.generation;
|
|
1325
|
+
const stick = nearBottom(this.refs.messagesEl);
|
|
1326
|
+
const el = chat.role === 'user' ? this.renderUserMessage(chat) : undefined;
|
|
1327
|
+
if (el) {
|
|
1328
|
+
if (messageId)
|
|
1329
|
+
el.dataset.messageId = messageId;
|
|
1330
|
+
if (position !== undefined)
|
|
1331
|
+
this.insertByPosition(el, position);
|
|
1332
|
+
else
|
|
1333
|
+
this.refs.messagesEl.appendChild(el);
|
|
1334
|
+
if (position !== undefined)
|
|
1335
|
+
this.enableReactions(el, position);
|
|
1336
|
+
gsap.from(el, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
|
|
1337
|
+
}
|
|
1338
|
+
else {
|
|
1339
|
+
buildMessageElement(chat, this.options.acceptedTypes, this.modelIcons, this.options.renderToolCall, this.options.mode, this.options.room?.self.profile.email, this.options.onTokenClick, this.fileCards).then((built) => {
|
|
1340
|
+
if (this.destroyed || this.generation !== gen)
|
|
1341
|
+
return;
|
|
1342
|
+
if (this.paintedMessage(messageId))
|
|
1343
|
+
return;
|
|
1344
|
+
if (messageId)
|
|
1345
|
+
built.dataset.messageId = messageId;
|
|
1346
|
+
if (chat.model)
|
|
1347
|
+
built.dataset.model = chat.model;
|
|
1348
|
+
if (position !== undefined)
|
|
1349
|
+
this.insertByPosition(built, position);
|
|
1350
|
+
else
|
|
1351
|
+
this.refs.messagesEl.appendChild(built);
|
|
1352
|
+
if (position !== undefined)
|
|
1353
|
+
this.enableReactions(built, position);
|
|
1354
|
+
gsap.from(built, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
|
|
1355
|
+
autoScroll(this.refs.messagesEl, stick);
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
this.history.push(chat);
|
|
1359
|
+
autoScroll(this.refs.messagesEl, stick);
|
|
1360
|
+
}
|
|
1361
|
+
// ── Reactions ──
|
|
1362
|
+
// A reaction arrives as its own room message; it never paints a bubble.
|
|
1363
|
+
// It folds into a row of emoji under the message at its target position.
|
|
1364
|
+
applyReaction(message, reaction) {
|
|
1365
|
+
const email = message.author?.email;
|
|
1366
|
+
if (!email)
|
|
1367
|
+
return;
|
|
1368
|
+
let byEmoji = this.reactions.get(reaction.position);
|
|
1369
|
+
if (!byEmoji) {
|
|
1370
|
+
byEmoji = new Map();
|
|
1371
|
+
this.reactions.set(reaction.position, byEmoji);
|
|
1372
|
+
}
|
|
1373
|
+
let byPerson = byEmoji.get(reaction.emoji);
|
|
1374
|
+
if (!byPerson) {
|
|
1375
|
+
byPerson = new Map();
|
|
1376
|
+
byEmoji.set(reaction.emoji, byPerson);
|
|
1377
|
+
}
|
|
1378
|
+
const latest = byPerson.get(email);
|
|
1379
|
+
if (latest && latest.at > message.position)
|
|
1380
|
+
return;
|
|
1381
|
+
const name = message.author?.displayName ?? email.split('@')[0];
|
|
1382
|
+
byPerson.set(email, { on: reaction.on, at: message.position, name });
|
|
1383
|
+
this.paintReactions(reaction.position);
|
|
1384
|
+
}
|
|
1385
|
+
/** Hover action to react, plus the folded row for any reactions already in. */
|
|
1386
|
+
enableReactions(el, position) {
|
|
1387
|
+
if (!this.options.room)
|
|
1388
|
+
return;
|
|
1389
|
+
const actions = getOrCreateActions(el);
|
|
1390
|
+
if (!actions.querySelector('.chat-react-button')) {
|
|
1391
|
+
const button = document.createElement('span');
|
|
1392
|
+
button.className = 'material-symbols-outlined chat-react-button';
|
|
1393
|
+
button.textContent = 'add_reaction';
|
|
1394
|
+
createTooltip(button, 'React');
|
|
1395
|
+
button.onclick = (e) => {
|
|
1396
|
+
e.stopPropagation();
|
|
1397
|
+
this.openReactionPicker(el, position);
|
|
1398
|
+
};
|
|
1399
|
+
actions.prepend(button);
|
|
1400
|
+
}
|
|
1401
|
+
this.paintReactions(position);
|
|
1402
|
+
}
|
|
1403
|
+
openReactionPicker(el, position) {
|
|
1404
|
+
el.querySelector('.chat-reaction-picker')?.remove();
|
|
1405
|
+
const picker = document.createElement('div');
|
|
1406
|
+
picker.className = 'chat-reaction-picker';
|
|
1407
|
+
for (const emoji of QUICK_REACTIONS) {
|
|
1408
|
+
const option = document.createElement('button');
|
|
1409
|
+
option.type = 'button';
|
|
1410
|
+
option.textContent = emoji;
|
|
1411
|
+
option.onclick = (e) => {
|
|
1412
|
+
e.stopPropagation();
|
|
1413
|
+
picker.remove();
|
|
1414
|
+
this.react(position, emoji, !this.reactedWith(position, emoji));
|
|
1415
|
+
};
|
|
1416
|
+
picker.appendChild(option);
|
|
1417
|
+
}
|
|
1418
|
+
el.insertBefore(picker, el.querySelector(':scope > .chat-message-actions'));
|
|
1419
|
+
gsap.from(picker, { duration: 0.2, scale: 0.85, opacity: 0, ease: 'back.out(2)' });
|
|
1420
|
+
const close = (e) => {
|
|
1421
|
+
if (picker.contains(e.target))
|
|
1422
|
+
return;
|
|
1423
|
+
picker.remove();
|
|
1424
|
+
document.removeEventListener('click', close);
|
|
1425
|
+
};
|
|
1426
|
+
setTimeout(() => document.addEventListener('click', close), 0);
|
|
1427
|
+
}
|
|
1428
|
+
reactedWith(position, emoji) {
|
|
1429
|
+
const self = this.options.room?.self.profile.email;
|
|
1430
|
+
return Boolean(self && this.reactions.get(position)?.get(emoji)?.get(self)?.on);
|
|
1431
|
+
}
|
|
1432
|
+
react(position, emoji, on) {
|
|
1433
|
+
void this.options.room?.room.react(position, emoji, on)
|
|
1434
|
+
.catch((err) => { this.options.callbacks?.onError?.(err.message); });
|
|
1435
|
+
}
|
|
1436
|
+
paintReactions(position) {
|
|
1437
|
+
const el = this.refs.messagesEl.querySelector(`[data-position="${position}"]`);
|
|
1438
|
+
if (!el)
|
|
1439
|
+
return;
|
|
1440
|
+
const self = this.options.room?.self.profile.email;
|
|
1441
|
+
const shown = [...(this.reactions.get(position) ?? new Map()).entries()]
|
|
1442
|
+
.map(([emoji, people]) => {
|
|
1443
|
+
const on = [...people.entries()].filter(([, state]) => state.on);
|
|
1444
|
+
return { emoji, names: on.map(([, state]) => state.name), mine: on.some(([email]) => email === self) };
|
|
1445
|
+
})
|
|
1446
|
+
.filter((entry) => entry.names.length > 0);
|
|
1447
|
+
let row = el.querySelector(':scope > .chat-reactions');
|
|
1448
|
+
if (shown.length === 0) {
|
|
1449
|
+
row?.remove();
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (!row) {
|
|
1453
|
+
row = document.createElement('div');
|
|
1454
|
+
row.className = 'chat-reactions';
|
|
1455
|
+
const actions = el.querySelector(':scope > .chat-message-actions');
|
|
1456
|
+
if (actions)
|
|
1457
|
+
el.insertBefore(row, actions);
|
|
1458
|
+
else
|
|
1459
|
+
el.appendChild(row);
|
|
1460
|
+
}
|
|
1461
|
+
const before = new Map([...row.children].map((chip) => [chip.dataset.emoji, chip.querySelector('.chat-reaction-count')?.textContent]));
|
|
1462
|
+
row.replaceChildren(...shown.map(({ emoji, names, mine }) => {
|
|
1463
|
+
const chip = document.createElement('button');
|
|
1464
|
+
chip.type = 'button';
|
|
1465
|
+
chip.className = mine ? 'chat-reaction mine' : 'chat-reaction';
|
|
1466
|
+
chip.dataset.emoji = emoji;
|
|
1467
|
+
chip.title = names.join(', ');
|
|
1468
|
+
const face = document.createElement('span');
|
|
1469
|
+
face.textContent = emoji;
|
|
1470
|
+
const count = document.createElement('span');
|
|
1471
|
+
count.className = 'chat-reaction-count';
|
|
1472
|
+
count.textContent = String(names.length);
|
|
1473
|
+
chip.append(face, count);
|
|
1474
|
+
chip.onclick = (e) => {
|
|
1475
|
+
e.stopPropagation();
|
|
1476
|
+
this.react(position, emoji, !mine);
|
|
1477
|
+
};
|
|
1478
|
+
if (before.get(emoji) !== count.textContent) {
|
|
1479
|
+
gsap.fromTo(chip, { scale: 0.7 }, { duration: 0.35, scale: 1, ease: 'back.out(3)' });
|
|
1480
|
+
}
|
|
1481
|
+
return chip;
|
|
1482
|
+
}));
|
|
1483
|
+
}
|
|
1484
|
+
/** Wrap client tools so optional evaluate(params, onProgress) callbacks stay
|
|
1485
|
+
* in this UI. The Parrot Tool type is still evaluate(params) only — Python /
|
|
1486
|
+
* Rust SDKs should not grow chip/token fields. */
|
|
1487
|
+
bindClientTools(tools) {
|
|
1488
|
+
if (!tools)
|
|
1489
|
+
return undefined;
|
|
1490
|
+
const list = Array.isArray(tools) ? tools : [tools];
|
|
1491
|
+
return list.map((tool) => ({
|
|
1492
|
+
function: tool.function,
|
|
1493
|
+
instruction: tool.instruction,
|
|
1494
|
+
evaluate: (params) => {
|
|
1495
|
+
const run = tool.evaluate;
|
|
1496
|
+
return run(params, (progress) => {
|
|
1497
|
+
this.toolProgress.set(tool.function.name, progress);
|
|
1498
|
+
this.roomStream?.renderer.feedToolProgress(tool.function.name, progress);
|
|
1499
|
+
}, this.roomToolAbort.signal);
|
|
1500
|
+
},
|
|
1501
|
+
}));
|
|
1502
|
+
}
|
|
1503
|
+
toolsWithProgress(tools) {
|
|
1504
|
+
return tools.map((t) => {
|
|
1505
|
+
const progress = this.toolProgress.get(t.name);
|
|
1506
|
+
return progress !== undefined ? { ...t, progress } : t;
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
/** Where the repository button and the computer the model asks for are bound: the chat room's own, or an app's holder. */
|
|
1510
|
+
repositoryHolder() {
|
|
1511
|
+
const room = this.options.room?.room;
|
|
1512
|
+
const option = this.options.repositories;
|
|
1513
|
+
if (!room || !option || room.isHuman() || !room.chatId)
|
|
1514
|
+
return null;
|
|
1515
|
+
return option === true ? room : option;
|
|
1516
|
+
}
|
|
1517
|
+
/** A chat with no repository still offers use_desktop, until the person turns it down here. */
|
|
1518
|
+
offersComputer() {
|
|
1519
|
+
const holder = this.repositoryHolder();
|
|
1520
|
+
return !!holder && holder.repository === null && !this.declinedComputer;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* use_desktop without a repository: asks the person to make one. A no lasts only
|
|
1524
|
+
* as long as this engine. A holder that isn't the room passes its repository on to
|
|
1525
|
+
* the room (the app does), so the reply waits until the room has it.
|
|
1526
|
+
*/
|
|
1527
|
+
computerTool(room, holder) {
|
|
1528
|
+
return {
|
|
1529
|
+
function: Fauna.Termite.OS.desktopTool(null),
|
|
1530
|
+
evaluate: async (args) => {
|
|
1531
|
+
if (holder.repository !== null)
|
|
1532
|
+
return JSON.stringify({ repository: holder.repository });
|
|
1533
|
+
const signal = this.roomToolAbort.signal;
|
|
1534
|
+
const repository = await askForComputer(holder, String(args?.name ?? ''), signal, this.options.computerAsker);
|
|
1535
|
+
if (repository) {
|
|
1536
|
+
if (holder !== room)
|
|
1537
|
+
await roomHas(room, repository.id, signal);
|
|
1538
|
+
return JSON.stringify({ repository: repository.id });
|
|
1539
|
+
}
|
|
1540
|
+
if (!signal.aborted)
|
|
1541
|
+
this.declinedComputer = true;
|
|
1542
|
+
return JSON.stringify({ declined: true });
|
|
1543
|
+
},
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
setupRoomMode(room, self) {
|
|
1547
|
+
const clientTools = this.bindClientTools(this.options.room?.tools) ?? [];
|
|
1548
|
+
const holder = this.repositoryHolder();
|
|
1549
|
+
if (holder)
|
|
1550
|
+
clientTools.push(this.computerTool(room, holder));
|
|
1551
|
+
room.setClientTools(clientTools);
|
|
1552
|
+
const roster = document.createElement('div');
|
|
1553
|
+
roster.className = 'chat-room-roster';
|
|
1554
|
+
this.refs.root.insertBefore(roster, this.refs.messagesEl);
|
|
1555
|
+
const typing = document.createElement('div');
|
|
1556
|
+
typing.className = 'chat-room-typing';
|
|
1557
|
+
this.refs.inputEl.insertBefore(typing, this.refs.queueEl);
|
|
1558
|
+
let repositoryHolder = null;
|
|
1559
|
+
if (holder) {
|
|
1560
|
+
repositoryHolder = document.createElement('div');
|
|
1561
|
+
repositoryHolder.className = 'chat-repository-holder';
|
|
1562
|
+
this.refs.optionsEl.insertBefore(repositoryHolder, this.refs.optionsEl.firstChild);
|
|
1563
|
+
this.repositoryControl = mountRepositoryControl(repositoryHolder, holder, this.options.onOpenRepository, this.options.onRepositoryChange);
|
|
1564
|
+
this.repositoryControl.setReplying(this.roomThinking);
|
|
1565
|
+
}
|
|
1566
|
+
const seen = new Set();
|
|
1567
|
+
const refreshRoster = () => {
|
|
1568
|
+
roster.innerHTML = '';
|
|
1569
|
+
const others = room.getPresences().filter((p) => p.id !== self.id);
|
|
1570
|
+
// Solo chat: hide the roster entirely (it would just show your own
|
|
1571
|
+
// avatar). Only surface it once there's company to attribute.
|
|
1572
|
+
roster.style.display = others.length > 0 ? 'flex' : 'none';
|
|
1573
|
+
if (others.length > 0) {
|
|
1574
|
+
// Show only OTHER members — never your own avatar.
|
|
1575
|
+
for (const presence of others) {
|
|
1576
|
+
const chip = document.createElement('div');
|
|
1577
|
+
chip.className = 'chat-room-member';
|
|
1578
|
+
chip.appendChild(buildAvatar(presence.profile));
|
|
1579
|
+
roster.appendChild(chip);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
const typers = room.typingPresences(self);
|
|
1583
|
+
if (typers.length === 0) {
|
|
1584
|
+
typing.textContent = '';
|
|
1585
|
+
typing.classList.remove('chat-room-typing-active');
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
const names = typers.map((p) => p.profile?.displayName ?? p.profile?.email?.split('@')[0] ?? 'Someone');
|
|
1589
|
+
typing.textContent = names.length === 1
|
|
1590
|
+
? `${names[0]} is typing…`
|
|
1591
|
+
: `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} are typing…`;
|
|
1592
|
+
typing.classList.add('chat-room-typing-active');
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
for (const message of room.getMessages()) {
|
|
1596
|
+
seen.add(message.id);
|
|
1597
|
+
if (isSystemRoomMessage(message))
|
|
1598
|
+
continue;
|
|
1599
|
+
const reaction = reactionOf(message);
|
|
1600
|
+
if (reaction) {
|
|
1601
|
+
this.applyReaction(message, reaction);
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
this.renderRoomMessage(this.roomMessageToChat(room, self, message), message.id, message.position);
|
|
1605
|
+
}
|
|
1606
|
+
requestAnimationFrame(() => {
|
|
1607
|
+
this.refs.messagesEl.scrollTop = this.refs.messagesEl.scrollHeight;
|
|
1608
|
+
});
|
|
1609
|
+
refreshRoster();
|
|
1610
|
+
this.syncRoomQueue(room);
|
|
1611
|
+
let liveEnded = false;
|
|
1612
|
+
room.setHooks({
|
|
1613
|
+
onMessage: (r, message) => {
|
|
1614
|
+
if (liveEnded || this.destroyed)
|
|
1615
|
+
return;
|
|
1616
|
+
if (seen.has(message.id) || this.paintedMessage(message.id))
|
|
1617
|
+
return;
|
|
1618
|
+
seen.add(message.id);
|
|
1619
|
+
if (isSystemRoomMessage(message))
|
|
1620
|
+
return;
|
|
1621
|
+
const reaction = reactionOf(message);
|
|
1622
|
+
if (reaction) {
|
|
1623
|
+
this.applyReaction(message, reaction);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const chat = this.roomMessageToChat(r, self, message);
|
|
1627
|
+
if (chat.files?.length)
|
|
1628
|
+
this.refs.panel?.added();
|
|
1629
|
+
// If this MODEL message finalizes the turn we've been streaming,
|
|
1630
|
+
// keep the already-rendered streamed element instead of building
|
|
1631
|
+
// a duplicate — just commit it to history.
|
|
1632
|
+
if (chat.role === 'model' && this.roomStream) {
|
|
1633
|
+
const streamedEl = this.roomStream.el;
|
|
1634
|
+
const streamedRenderer = this.roomStream.renderer;
|
|
1635
|
+
if (chat.text && chat.text.length > streamedRenderer.getText().length) {
|
|
1636
|
+
streamedRenderer.syncText(chat.text);
|
|
1637
|
+
}
|
|
1638
|
+
streamedRenderer.finalize?.();
|
|
1639
|
+
this.roomStream = null;
|
|
1640
|
+
this.toolProgress.clear();
|
|
1641
|
+
// The streamed element was rendered live, so it never went
|
|
1642
|
+
// through buildMessageElement — give it the same copy action
|
|
1643
|
+
// wrapper every other message gets.
|
|
1644
|
+
if (chat.text)
|
|
1645
|
+
addCopyButton(streamedEl, () => chat.text, chat.createdAt);
|
|
1646
|
+
streamedRenderer.syncToolCalls(chat.toolCalls ?? []);
|
|
1647
|
+
// Files the reply attached, from the landed row (the stream may have missed some).
|
|
1648
|
+
for (const file of chat.files ?? []) {
|
|
1649
|
+
if (file.part?.ref && streamedEl.querySelector(`[data-ref="${CSS.escape(file.part.ref)}"]`))
|
|
1650
|
+
continue;
|
|
1651
|
+
const card = buildFileElement(file, this.options.acceptedTypes, this.fileCards);
|
|
1652
|
+
const actions = streamedEl.querySelector(':scope > .chat-message-actions');
|
|
1653
|
+
if (actions)
|
|
1654
|
+
streamedEl.insertBefore(card, actions);
|
|
1655
|
+
else
|
|
1656
|
+
streamedEl.appendChild(card);
|
|
1657
|
+
}
|
|
1658
|
+
// Stamp its durable position so later inserts order correctly
|
|
1659
|
+
// relative to it (it was appended live before its row landed).
|
|
1660
|
+
streamedEl.dataset.position = String(message.position);
|
|
1661
|
+
if (message.id)
|
|
1662
|
+
streamedEl.dataset.messageId = message.id;
|
|
1663
|
+
if (chat.model)
|
|
1664
|
+
streamedEl.dataset.model = chat.model;
|
|
1665
|
+
stampModelAvatar(streamedEl, chat.model ?? this.currentModel, this.modelIcons);
|
|
1666
|
+
this.enableReactions(streamedEl, message.position);
|
|
1667
|
+
this.history.push(chat);
|
|
1668
|
+
autoScroll(this.refs.messagesEl);
|
|
1669
|
+
this.options.room?.onRoomMessage?.(message);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
this.renderRoomMessage(chat, message.id, message.position);
|
|
1673
|
+
this.options.room?.onRoomMessage?.(message);
|
|
1674
|
+
},
|
|
1675
|
+
onQueue: (r) => {
|
|
1676
|
+
if (liveEnded || this.destroyed)
|
|
1677
|
+
return;
|
|
1678
|
+
this.syncRoomQueue(r);
|
|
1679
|
+
},
|
|
1680
|
+
onDelta: (_r, turn, delta, _text, aborted, tools, files) => {
|
|
1681
|
+
if (liveEnded || this.destroyed)
|
|
1682
|
+
return;
|
|
1683
|
+
if (aborted) {
|
|
1684
|
+
this.abortRoomTools();
|
|
1685
|
+
this.toolProgress.clear();
|
|
1686
|
+
// Successful clear after a committed MODEL row is not abort.
|
|
1687
|
+
// Only drop the live bubble when this turn never wrote text.
|
|
1688
|
+
if (this.roomStream && !this.roomStream.renderer.getText()) {
|
|
1689
|
+
this.roomStream.el.remove();
|
|
1690
|
+
this.roomStream = null;
|
|
1691
|
+
}
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
// Leftover stream after hydrate: the finished MODEL row is already
|
|
1695
|
+
// painted. Do not grow a second bubble for that turn.
|
|
1696
|
+
const last = [...room.getMessages()].reverse().find((m) => !isSystemRoomMessage(m));
|
|
1697
|
+
const lastIsPaintedModel = Boolean(last
|
|
1698
|
+
&& last.role === Fauna.Parrot.Generative.Role.MODEL
|
|
1699
|
+
&& (seen.has(last.id) || this.paintedMessage(last.id)));
|
|
1700
|
+
// Starting (or continuing) a streamed model turn: drop the
|
|
1701
|
+
// "thinking" dots and grow a model message element live.
|
|
1702
|
+
if (!this.roomStream || this.roomStream.turn !== turn) {
|
|
1703
|
+
if (this.roomStream && this.roomStream.turn !== turn) {
|
|
1704
|
+
this.roomStream.el.remove();
|
|
1705
|
+
this.roomStream = null;
|
|
1706
|
+
}
|
|
1707
|
+
if (lastIsPaintedModel)
|
|
1708
|
+
return;
|
|
1709
|
+
if (this.roomThinkingEl) {
|
|
1710
|
+
this.roomThinkingEl.remove();
|
|
1711
|
+
this.roomThinkingEl = null;
|
|
1712
|
+
}
|
|
1713
|
+
const el = document.createElement('div');
|
|
1714
|
+
el.className = 'chat-message-model';
|
|
1715
|
+
el.dataset.model = this.currentModel;
|
|
1716
|
+
stampModelAvatar(el, this.currentModel, this.modelIcons);
|
|
1717
|
+
this.refs.messagesEl.appendChild(el);
|
|
1718
|
+
gsap.from(el, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
|
|
1719
|
+
this.roomStream = { turn, el, renderer: createStreamRenderer(el, this.refs.messagesEl, this.options.renderToolCall) };
|
|
1720
|
+
}
|
|
1721
|
+
this.roomStream.renderer.syncToolCalls(this.toolsWithProgress(tools));
|
|
1722
|
+
if (files?.length)
|
|
1723
|
+
this.paintStreamFiles(this.roomStream.el, files);
|
|
1724
|
+
if (delta)
|
|
1725
|
+
this.roomStream.renderer.feedText(delta);
|
|
1726
|
+
autoScroll(this.refs.messagesEl);
|
|
1727
|
+
},
|
|
1728
|
+
onOpenFile: (_r, attachment) => {
|
|
1729
|
+
if (liveEnded || this.destroyed)
|
|
1730
|
+
return;
|
|
1731
|
+
this.refs.panel?.point(attachment);
|
|
1732
|
+
},
|
|
1733
|
+
onThinking: (_r, thinking) => {
|
|
1734
|
+
if (liveEnded)
|
|
1735
|
+
return;
|
|
1736
|
+
this.setRoomThinking(thinking);
|
|
1737
|
+
this.options.room?.onThinking?.(thinking);
|
|
1738
|
+
},
|
|
1739
|
+
onRenamed: () => { },
|
|
1740
|
+
onClosed: () => {
|
|
1741
|
+
// Live room destroyed (host left / reap). Clear peer UI so we
|
|
1742
|
+
// don't sit on a ghost roster; reopen the panel to reconnect.
|
|
1743
|
+
liveEnded = true;
|
|
1744
|
+
roster.innerHTML = '';
|
|
1745
|
+
roster.style.display = 'none';
|
|
1746
|
+
typing.textContent = '';
|
|
1747
|
+
typing.classList.remove('chat-room-typing-active');
|
|
1748
|
+
if (!this.refs.messagesEl.querySelector('.chat-room-ended')) {
|
|
1749
|
+
const banner = document.createElement('div');
|
|
1750
|
+
banner.className = 'chat-room-ended';
|
|
1751
|
+
banner.textContent = 'Live session ended. Reopen the panel to reconnect.';
|
|
1752
|
+
this.refs.messagesEl.appendChild(banner);
|
|
1753
|
+
}
|
|
1754
|
+
this.setRoomThinking(false);
|
|
1755
|
+
this.roomQueue.clear();
|
|
1756
|
+
this.roomSteerAllEl?.remove();
|
|
1757
|
+
this.roomSteerAllEl = null;
|
|
1758
|
+
this.refs.queueEl.replaceChildren();
|
|
1759
|
+
},
|
|
1760
|
+
onPresence: () => {
|
|
1761
|
+
if (liveEnded)
|
|
1762
|
+
return;
|
|
1763
|
+
refreshRoster();
|
|
1764
|
+
},
|
|
1765
|
+
});
|
|
1766
|
+
room.replayReply();
|
|
1767
|
+
let typingTimer = null;
|
|
1768
|
+
this.roomTypingHandler = () => {
|
|
1769
|
+
self.setStatus('typing');
|
|
1770
|
+
if (typingTimer)
|
|
1771
|
+
clearTimeout(typingTimer);
|
|
1772
|
+
typingTimer = setTimeout(() => self.setStatus(''), 2500);
|
|
1773
|
+
};
|
|
1774
|
+
// Lazy backfill: when scrolled near the top and older pages remain, pull
|
|
1775
|
+
// one older page and PREPEND it, preserving the scroll offset so the view
|
|
1776
|
+
// doesn't jump. Guarded against overlapping loads.
|
|
1777
|
+
let loadingOlder = false;
|
|
1778
|
+
const onScroll = () => {
|
|
1779
|
+
if (loadingOlder || !room.hasMore())
|
|
1780
|
+
return;
|
|
1781
|
+
if (this.refs.messagesEl.scrollTop > 80)
|
|
1782
|
+
return;
|
|
1783
|
+
loadingOlder = true;
|
|
1784
|
+
const prevHeight = this.refs.messagesEl.scrollHeight;
|
|
1785
|
+
void room.loadOlder()
|
|
1786
|
+
.then((page) => {
|
|
1787
|
+
void page;
|
|
1788
|
+
if (liveEnded || this.destroyed)
|
|
1789
|
+
return;
|
|
1790
|
+
// loadOlder merged the older rows into room.getMessages();
|
|
1791
|
+
// render any not yet shown, in position order.
|
|
1792
|
+
for (const message of room.getMessages()) {
|
|
1793
|
+
if (seen.has(message.id) || this.paintedMessage(message.id))
|
|
1794
|
+
continue;
|
|
1795
|
+
seen.add(message.id);
|
|
1796
|
+
if (isSystemRoomMessage(message))
|
|
1797
|
+
continue;
|
|
1798
|
+
const reaction = reactionOf(message);
|
|
1799
|
+
if (reaction) {
|
|
1800
|
+
this.applyReaction(message, reaction);
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
this.renderRoomMessage(this.roomMessageToChat(room, self, message), message.id, message.position);
|
|
1804
|
+
}
|
|
1805
|
+
// Preserve scroll position across the prepend.
|
|
1806
|
+
this.refs.messagesEl.scrollTop += this.refs.messagesEl.scrollHeight - prevHeight;
|
|
1807
|
+
this.options.room?.onOlderLoaded?.();
|
|
1808
|
+
})
|
|
1809
|
+
.catch((err) => this.options.callbacks?.onError?.(err.message))
|
|
1810
|
+
.finally(() => { loadingOlder = false; });
|
|
1811
|
+
};
|
|
1812
|
+
this.refs.messagesEl.addEventListener('scroll', onScroll);
|
|
1813
|
+
const rosterPoll = setInterval(refreshRoster, 1000);
|
|
1814
|
+
this.roomCleanup = () => {
|
|
1815
|
+
liveEnded = true;
|
|
1816
|
+
room.setHooks({
|
|
1817
|
+
onMessage: () => { },
|
|
1818
|
+
onQueue: () => { },
|
|
1819
|
+
onDelta: () => { },
|
|
1820
|
+
onThinking: () => { },
|
|
1821
|
+
onRenamed: () => { },
|
|
1822
|
+
onClosed: () => { },
|
|
1823
|
+
onPresence: () => { },
|
|
1824
|
+
onOpenFile: () => { },
|
|
1825
|
+
});
|
|
1826
|
+
this.repositoryControl?.unmount();
|
|
1827
|
+
this.repositoryControl = null;
|
|
1828
|
+
repositoryHolder?.remove();
|
|
1829
|
+
clearInterval(rosterPoll);
|
|
1830
|
+
if (typingTimer)
|
|
1831
|
+
clearTimeout(typingTimer);
|
|
1832
|
+
this.refs.messagesEl.removeEventListener('scroll', onScroll);
|
|
1833
|
+
this.roomTypingHandler = null;
|
|
1834
|
+
// StrictMode remounts the engine; orphan roster/typing nodes would
|
|
1835
|
+
// stack (same bug as the tokenized composer) and show duplicate peers.
|
|
1836
|
+
try {
|
|
1837
|
+
roster.remove();
|
|
1838
|
+
}
|
|
1839
|
+
catch { /* noop */ }
|
|
1840
|
+
try {
|
|
1841
|
+
typing.remove();
|
|
1842
|
+
}
|
|
1843
|
+
catch { /* noop */ }
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
abortRoomTools() {
|
|
1847
|
+
if (!this.roomToolAbort.signal.aborted)
|
|
1848
|
+
this.roomToolAbort.abort();
|
|
1849
|
+
this.roomToolAbort = new AbortController();
|
|
1850
|
+
}
|
|
1851
|
+
stopRoom() {
|
|
1852
|
+
this.abortRoomTools();
|
|
1853
|
+
void this.options.room?.room.steer({ stop: true, model: this.currentModel, reasoning: this.currentReasoning })
|
|
1854
|
+
.catch((err) => {
|
|
1855
|
+
void sendInformaticToast(err.message || 'Stop failed', 'error');
|
|
1856
|
+
this.options.callbacks?.onError?.(err.message);
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
setRoomThinking(thinking) {
|
|
1860
|
+
this.roomThinking = thinking;
|
|
1861
|
+
if (thinking && this.roomToolAbort.signal.aborted)
|
|
1862
|
+
this.roomToolAbort = new AbortController();
|
|
1863
|
+
if (!thinking)
|
|
1864
|
+
this.abortRoomTools();
|
|
1865
|
+
// Mirror the stop affordance: while the server is replying, the send
|
|
1866
|
+
// button becomes a stop control (clicking it cancels the room reply).
|
|
1867
|
+
this.setComposerGenerating(thinking);
|
|
1868
|
+
this.repositoryControl?.setReplying(thinking);
|
|
1869
|
+
// Note: the shared queue is NOT cleared here — queued rows are released
|
|
1870
|
+
// one-per-reply by the server and each release fans out a chat-queue-remove
|
|
1871
|
+
// (handled by syncRoomQueue), so chips disappear exactly as they drain.
|
|
1872
|
+
// While a streamed turn is actively rendering, the growing message is the
|
|
1873
|
+
// indicator — don't also show the dots.
|
|
1874
|
+
if (thinking && this.roomStream)
|
|
1875
|
+
return;
|
|
1876
|
+
if (thinking && !this.roomThinkingEl) {
|
|
1877
|
+
this.roomThinkingEl = document.createElement('div');
|
|
1878
|
+
this.roomThinkingEl.className = 'chat-message-model';
|
|
1879
|
+
this.roomThinkingEl.appendChild(buildThinkingIndicator());
|
|
1880
|
+
this.refs.messagesEl.appendChild(this.roomThinkingEl);
|
|
1881
|
+
gsap.from(this.roomThinkingEl, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
|
|
1882
|
+
autoScroll(this.refs.messagesEl);
|
|
1883
|
+
}
|
|
1884
|
+
else if (!thinking && this.roomThinkingEl) {
|
|
1885
|
+
this.roomThinkingEl.remove();
|
|
1886
|
+
this.roomThinkingEl = null;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
// Reconcile the queue-chip UI against the room's shared queue. Driven by the
|
|
1890
|
+
// room's onQueue hook (and initial hydration): the server-side chat_queue is
|
|
1891
|
+
// the single source of truth, so we add chips for new rows, drop chips for
|
|
1892
|
+
// released/unqueued rows, and keep them in FIFO order. Each chip gets a bolt
|
|
1893
|
+
// (per-item steer: release just this row) and an x (unqueue: drop this row).
|
|
1894
|
+
syncRoomQueue(room) {
|
|
1895
|
+
const items = room.getQueue();
|
|
1896
|
+
const live = new Set(items.map((q) => q.id));
|
|
1897
|
+
// Drop chips whose queue row is gone (released into the transcript or
|
|
1898
|
+
// unqueued) — animate them out.
|
|
1899
|
+
for (const [id, chip] of [...this.roomQueue]) {
|
|
1900
|
+
if (!live.has(id)) {
|
|
1901
|
+
this.roomQueue.delete(id);
|
|
1902
|
+
gsap.to(chip, {
|
|
1903
|
+
duration: 0.25, opacity: 0, height: 0, marginBottom: 0, ease: 'power2.in',
|
|
1904
|
+
onComplete: () => { chip.remove(); },
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
// Add chips for new rows, in FIFO order (the queue is already sorted).
|
|
1909
|
+
for (const item of items) {
|
|
1910
|
+
if (this.roomQueue.has(item.id))
|
|
1911
|
+
continue;
|
|
1912
|
+
const chip = this.buildRoomQueueChip(item);
|
|
1913
|
+
this.roomQueue.set(item.id, chip);
|
|
1914
|
+
this.refs.queueEl.appendChild(chip);
|
|
1915
|
+
gsap.from(chip, { duration: 0.3, opacity: 0, y: 8, ease: 'power2.out' });
|
|
1916
|
+
}
|
|
1917
|
+
this.syncRoomSteerAll();
|
|
1918
|
+
this.options.callbacks?.onQueueChange?.(this.roomQueue.size);
|
|
1919
|
+
}
|
|
1920
|
+
// Build one queue chip for a shared-queue row. `bolt` releases just this row
|
|
1921
|
+
// into the transcript (per-item steer); `x` drops it (unqueue). Both are
|
|
1922
|
+
// room-global server operations — the resulting queue delta reconciles the UI.
|
|
1923
|
+
buildRoomQueueChip(item) {
|
|
1924
|
+
const chip = document.createElement('div');
|
|
1925
|
+
chip.className = 'chat-queue-item';
|
|
1926
|
+
const icon = document.createElement('span');
|
|
1927
|
+
icon.className = 'material-symbols-outlined chat-queue-item-icon';
|
|
1928
|
+
icon.textContent = 'schedule';
|
|
1929
|
+
chip.appendChild(icon);
|
|
1930
|
+
const text = item.parts.filter((p) => p.type === 'TEXT').map((p) => p.text).join('');
|
|
1931
|
+
const fileCount = item.parts.filter((p) => p.type === 'FILE').length;
|
|
1932
|
+
const label = document.createElement('div');
|
|
1933
|
+
label.className = 'chat-queue-item-label';
|
|
1934
|
+
const preview = toPlainText(text).trim();
|
|
1935
|
+
const fileNote = fileCount > 0 ? `${fileCount} file${fileCount > 1 ? 's' : ''}` : '';
|
|
1936
|
+
label.textContent = preview || fileNote || 'Attachment';
|
|
1937
|
+
if (preview && fileNote)
|
|
1938
|
+
label.title = `${preview} · ${fileNote}`;
|
|
1939
|
+
chip.appendChild(label);
|
|
1940
|
+
const actions = document.createElement('div');
|
|
1941
|
+
actions.className = 'chat-queue-item-actions';
|
|
1942
|
+
const steerBtn = document.createElement('span');
|
|
1943
|
+
steerBtn.className = 'material-symbols-outlined chat-queue-steer';
|
|
1944
|
+
steerBtn.textContent = 'bolt';
|
|
1945
|
+
createTooltip(steerBtn, 'Steer — stop the current reply and jump this message ahead');
|
|
1946
|
+
steerBtn.onclick = (e) => { e.stopPropagation(); this.steerRoom(item.id); };
|
|
1947
|
+
actions.appendChild(steerBtn);
|
|
1948
|
+
const removeBtn = document.createElement('span');
|
|
1949
|
+
removeBtn.className = 'material-symbols-outlined chat-queue-remove';
|
|
1950
|
+
removeBtn.textContent = 'close';
|
|
1951
|
+
createTooltip(removeBtn, 'Remove from queue');
|
|
1952
|
+
removeBtn.onclick = (e) => {
|
|
1953
|
+
e.stopPropagation();
|
|
1954
|
+
void this.options.room?.room.unqueue(item.id)
|
|
1955
|
+
.then(() => { void sendInformaticToast('Removed from queue', 'info', 2500); })
|
|
1956
|
+
.catch((err) => {
|
|
1957
|
+
void sendInformaticToast(err.message || 'Could not remove from queue', 'error');
|
|
1958
|
+
this.options.callbacks?.onError?.(err.message);
|
|
1959
|
+
});
|
|
1960
|
+
};
|
|
1961
|
+
actions.appendChild(removeBtn);
|
|
1962
|
+
chip.appendChild(actions);
|
|
1963
|
+
return chip;
|
|
1964
|
+
}
|
|
1965
|
+
// Steer the room. With no `queueId` (steer-all header path routes through
|
|
1966
|
+
// steerAllRoom) this stops the current reply and re-answers. With a `queueId`
|
|
1967
|
+
// it releases just that one queued row and re-answers. Either way the server
|
|
1968
|
+
// stops the current reply and restarts the normal debounce.
|
|
1969
|
+
steerRoom(queueId) {
|
|
1970
|
+
this.abortRoomTools();
|
|
1971
|
+
void this.options.room?.room.steer({ queueId, model: this.currentModel, reasoning: this.currentReasoning })
|
|
1972
|
+
.catch((err) => {
|
|
1973
|
+
void sendInformaticToast(err.message || 'Steer failed', 'error');
|
|
1974
|
+
this.options.callbacks?.onError?.(err.message);
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
// Steer-all: flush the whole queue into the transcript and stop the current
|
|
1978
|
+
// reply (one reply then answers the whole batch after the debounce).
|
|
1979
|
+
steerAllRoom() {
|
|
1980
|
+
this.abortRoomTools();
|
|
1981
|
+
void this.options.room?.room.steer({ all: true, model: this.currentModel, reasoning: this.currentReasoning })
|
|
1982
|
+
.then(() => { void sendInformaticToast('Steered all queued messages', 'info', 2500); })
|
|
1983
|
+
.catch((err) => {
|
|
1984
|
+
void sendInformaticToast(err.message || 'Steer all failed', 'error');
|
|
1985
|
+
this.options.callbacks?.onError?.(err.message);
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
// Show/refresh the "Steer all" header above the queue when >1 chip is waiting.
|
|
1989
|
+
syncRoomSteerAll() {
|
|
1990
|
+
if (this.roomQueue.size > 1 && !this.roomSteerAllEl) {
|
|
1991
|
+
const header = document.createElement('div');
|
|
1992
|
+
header.className = 'chat-queue-steer-all';
|
|
1993
|
+
const icon = document.createElement('span');
|
|
1994
|
+
icon.className = 'material-symbols-outlined';
|
|
1995
|
+
icon.textContent = 'bolt';
|
|
1996
|
+
header.appendChild(icon);
|
|
1997
|
+
const label = document.createElement('span');
|
|
1998
|
+
label.textContent = 'Steer all';
|
|
1999
|
+
header.appendChild(label);
|
|
2000
|
+
createTooltip(header, 'Stop the current reply and answer everything queued now');
|
|
2001
|
+
header.onclick = (e) => { e.stopPropagation(); this.steerAllRoom(); };
|
|
2002
|
+
this.refs.queueEl.insertBefore(header, this.refs.queueEl.firstChild);
|
|
2003
|
+
gsap.from(header, { duration: 0.3, opacity: 0, y: -6, ease: 'power2.out' });
|
|
2004
|
+
this.roomSteerAllEl = header;
|
|
2005
|
+
}
|
|
2006
|
+
else if (this.roomQueue.size <= 1 && this.roomSteerAllEl) {
|
|
2007
|
+
this.roomSteerAllEl.remove();
|
|
2008
|
+
this.roomSteerAllEl = null;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
// ── Public API (drives the ChatInstance handle) ──
|
|
2012
|
+
sendMessage(text, files) {
|
|
2013
|
+
if (files && files.length > 0) {
|
|
2014
|
+
void (async () => {
|
|
2015
|
+
const uploaded = [];
|
|
2016
|
+
for (const f of files) {
|
|
2017
|
+
const file = await this.uploadBytes(f.mime, f.name, f.data);
|
|
2018
|
+
if (!file)
|
|
2019
|
+
return;
|
|
2020
|
+
uploaded.push(file);
|
|
2021
|
+
}
|
|
2022
|
+
this.doSend(text, undefined, uploaded);
|
|
2023
|
+
})();
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
this.doSend(text);
|
|
2027
|
+
}
|
|
2028
|
+
appendMessage(role, text) {
|
|
2029
|
+
const message = {
|
|
2030
|
+
role,
|
|
2031
|
+
text,
|
|
2032
|
+
createdAt: new Date(),
|
|
2033
|
+
model: role === 'model' ? this.currentModel : undefined,
|
|
2034
|
+
};
|
|
2035
|
+
this.history.push(message);
|
|
2036
|
+
buildMessageElement(message, this.options.acceptedTypes, this.modelIcons, this.options.renderToolCall, this.options.mode, this.options.room?.self.profile.email, this.options.onTokenClick, this.fileCards).then((el) => {
|
|
2037
|
+
if (message.model)
|
|
2038
|
+
el.dataset.model = message.model;
|
|
2039
|
+
this.refs.messagesEl.appendChild(el);
|
|
2040
|
+
gsap.from(el, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
|
|
2041
|
+
autoScroll(this.refs.messagesEl);
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
removeQueuedMessage(index) {
|
|
2045
|
+
if (index < 0 || index >= this.queue.length)
|
|
2046
|
+
return;
|
|
2047
|
+
const removed = this.queue.splice(index, 1)[0];
|
|
2048
|
+
gsap.to(removed.element, {
|
|
2049
|
+
duration: 0.3, opacity: 0, scale: 0.9, ease: 'power2.in',
|
|
2050
|
+
onComplete: () => { removed.element.remove(); }
|
|
2051
|
+
});
|
|
2052
|
+
this.options.callbacks?.onQueueChange?.(this.queue.length);
|
|
2053
|
+
}
|
|
2054
|
+
getQueue() {
|
|
2055
|
+
return this.queue.map((q) => ({
|
|
2056
|
+
text: q.text,
|
|
2057
|
+
files: q.files?.map((f) => ({ mime: f.contentType, name: f.name })),
|
|
2058
|
+
}));
|
|
2059
|
+
}
|
|
2060
|
+
clearMessages() {
|
|
2061
|
+
this.generation++;
|
|
2062
|
+
this.generating = false;
|
|
2063
|
+
this.steerTarget = null;
|
|
2064
|
+
this.activeController?.abort();
|
|
2065
|
+
this.activeController = null;
|
|
2066
|
+
this.setComposerGenerating(false);
|
|
2067
|
+
this.history.length = 0;
|
|
2068
|
+
this.queue.length = 0;
|
|
2069
|
+
this.roomQueue.clear();
|
|
2070
|
+
this.roomSteerAllEl = null;
|
|
2071
|
+
while (this.refs.messagesEl.firstChild)
|
|
2072
|
+
this.refs.messagesEl.firstChild.remove();
|
|
2073
|
+
while (this.refs.queueEl.firstChild)
|
|
2074
|
+
this.refs.queueEl.firstChild.remove();
|
|
2075
|
+
this.options.callbacks?.onQueueChange?.(0);
|
|
2076
|
+
}
|
|
2077
|
+
getHistory() {
|
|
2078
|
+
return [...this.history];
|
|
2079
|
+
}
|
|
2080
|
+
getModel() {
|
|
2081
|
+
return this.currentModel;
|
|
2082
|
+
}
|
|
2083
|
+
setModel(model) {
|
|
2084
|
+
this.currentModel = model;
|
|
2085
|
+
this.modelDropdown?.setValue(model);
|
|
2086
|
+
}
|
|
2087
|
+
getReasoning() {
|
|
2088
|
+
return this.currentReasoning;
|
|
2089
|
+
}
|
|
2090
|
+
setReasoning(reasoning) {
|
|
2091
|
+
this.currentReasoning = reasoning;
|
|
2092
|
+
this.modelDropdown?.setReasoning(reasoning);
|
|
2093
|
+
}
|
|
2094
|
+
setMode(mode) {
|
|
2095
|
+
if (chatMode(this.options.mode) === mode)
|
|
2096
|
+
return;
|
|
2097
|
+
this.options.mode = mode;
|
|
2098
|
+
const selfEmail = this.options.room?.self.profile.email;
|
|
2099
|
+
for (const el of this.refs.messagesEl.querySelectorAll('[data-author]')) {
|
|
2100
|
+
const email = el.dataset.author;
|
|
2101
|
+
const incoming = mode === 'thread' && Boolean(email && selfEmail && email !== selfEmail);
|
|
2102
|
+
const profile = this.history.find((m) => m.author?.id === email)?.author?.profile;
|
|
2103
|
+
applyUserBubbleLayout(el, incoming, profile);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
setModels(models) {
|
|
2107
|
+
this.options.models = models;
|
|
2108
|
+
if (isModelLocked(models)) {
|
|
2109
|
+
this.modelDropdown?.destroy();
|
|
2110
|
+
this.modelDropdown = null;
|
|
2111
|
+
this.syncModelSelectVisibility();
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
this.syncModelSelectVisibility();
|
|
2115
|
+
if (!this.modelDropdown)
|
|
2116
|
+
this.wireModelSelect();
|
|
2117
|
+
else {
|
|
2118
|
+
this.modelDropdown.setValue(this.currentModel);
|
|
2119
|
+
this.modelDropdown.setReasoning(this.currentReasoning);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
insertToken(token) {
|
|
2123
|
+
this.tokenizedInput.insertToken(token);
|
|
2124
|
+
}
|
|
2125
|
+
getParts() {
|
|
2126
|
+
return this.tokenizedInput.getParts();
|
|
2127
|
+
}
|
|
2128
|
+
watchInput(handler) {
|
|
2129
|
+
this.inputWatchers.add(handler);
|
|
2130
|
+
return () => { this.inputWatchers.delete(handler); };
|
|
2131
|
+
}
|
|
2132
|
+
clearInput() {
|
|
2133
|
+
this.tokenizedInput.clear();
|
|
2134
|
+
}
|
|
2135
|
+
focusInput() {
|
|
2136
|
+
this.tokenizedInput.focus();
|
|
2137
|
+
}
|
|
2138
|
+
destroy() {
|
|
2139
|
+
this.destroyed = true;
|
|
2140
|
+
this.generation++;
|
|
2141
|
+
this.generating = false;
|
|
2142
|
+
this.steerTarget = null;
|
|
2143
|
+
this.activeController?.abort();
|
|
2144
|
+
this.activeController = null;
|
|
2145
|
+
this.roomCleanup?.();
|
|
2146
|
+
this.modelDropdown?.destroy();
|
|
2147
|
+
this.modelDropdown = null;
|
|
2148
|
+
// StrictMode remounts the engine; without clearing imperatively mounted
|
|
2149
|
+
// nodes, a second composer / roster stacks in the panel.
|
|
2150
|
+
this.inputWatchers.clear();
|
|
2151
|
+
try {
|
|
2152
|
+
this.tokenizedInput.getElement().remove();
|
|
2153
|
+
}
|
|
2154
|
+
catch { /* noop */ }
|
|
2155
|
+
this.refs.rowEl.replaceChildren();
|
|
2156
|
+
this.refs.messagesEl.replaceChildren();
|
|
2157
|
+
this.refs.queueEl.replaceChildren();
|
|
2158
|
+
this.history.length = 0;
|
|
2159
|
+
this.queue.length = 0;
|
|
2160
|
+
this.roomQueue.clear();
|
|
2161
|
+
this.roomSteerAllEl = null;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
// ---- React <Chat> component ----
|
|
2165
|
+
// Renders the chat shell (identical DOM structure/classes to the original
|
|
2166
|
+
// vanilla widget so ui.css and every animation apply unchanged) and wires the
|
|
2167
|
+
// imperative ChatEngine to the rendered nodes. Exposes the ChatInstance via
|
|
2168
|
+
// ref so both React consumers and the createChat bridge get the same handle.
|
|
2169
|
+
export const Chat = forwardRef(function Chat({ options = {} }, ref) {
|
|
2170
|
+
const rootRef = useRef(null);
|
|
2171
|
+
const mainRef = useRef(null);
|
|
2172
|
+
const messagesRef = useRef(null);
|
|
2173
|
+
// The side panel is set at mount, like every other option.
|
|
2174
|
+
const panelRoom = options.attachments && options.room && !options.room.room.isHuman() ? options.room.room : null;
|
|
2175
|
+
const bus = useMemo(() => (panelRoom ? createPanelBus() : null), [panelRoom]);
|
|
2176
|
+
const [panelOpen, setPanelOpen] = useState(false);
|
|
2177
|
+
const inputRef = useRef(null);
|
|
2178
|
+
const queueRef = useRef(null);
|
|
2179
|
+
const partsRef = useRef(null);
|
|
2180
|
+
const rowRef = useRef(null);
|
|
2181
|
+
const controlsRef = useRef(null);
|
|
2182
|
+
const optionsRef = useRef(null);
|
|
2183
|
+
const sendRef = useRef(null);
|
|
2184
|
+
const modelSelectRef = useRef(null);
|
|
2185
|
+
const attachRef = useRef(null);
|
|
2186
|
+
const engineRef = useRef(null);
|
|
2187
|
+
// Layout effect (not passive) so the engine is constructed during the commit
|
|
2188
|
+
// phase. Combined with the flushSync mount in `createChat`, this guarantees
|
|
2189
|
+
// the engine exists synchronously by the time createChat() returns, so a
|
|
2190
|
+
// caller may sendMessage() immediately (e.g. the landing send) without the
|
|
2191
|
+
// first message being dropped.
|
|
2192
|
+
useLayoutEffect(() => {
|
|
2193
|
+
if (!rootRef.current || !messagesRef.current || !inputRef.current || !queueRef.current ||
|
|
2194
|
+
!partsRef.current || !rowRef.current || !controlsRef.current || !optionsRef.current ||
|
|
2195
|
+
!sendRef.current)
|
|
2196
|
+
return;
|
|
2197
|
+
const engine = new ChatEngine({
|
|
2198
|
+
root: mainRef.current ?? rootRef.current,
|
|
2199
|
+
messagesEl: messagesRef.current,
|
|
2200
|
+
inputEl: inputRef.current,
|
|
2201
|
+
queueEl: queueRef.current,
|
|
2202
|
+
partsEl: partsRef.current,
|
|
2203
|
+
rowEl: rowRef.current,
|
|
2204
|
+
controlsEl: controlsRef.current,
|
|
2205
|
+
optionsEl: optionsRef.current,
|
|
2206
|
+
sendBtn: sendRef.current,
|
|
2207
|
+
modelSelect: modelSelectRef.current,
|
|
2208
|
+
attachBtn: attachRef.current,
|
|
2209
|
+
panel: bus,
|
|
2210
|
+
}, roomLayout(options));
|
|
2211
|
+
engineRef.current = engine;
|
|
2212
|
+
return () => {
|
|
2213
|
+
engine.destroy();
|
|
2214
|
+
engineRef.current = null;
|
|
2215
|
+
};
|
|
2216
|
+
// Options are read once at mount (matching the imperative widget's
|
|
2217
|
+
// one-time configuration); consumers rebuild the widget to change them.
|
|
2218
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2219
|
+
}, []);
|
|
2220
|
+
useImperativeHandle(ref, () => ({
|
|
2221
|
+
sendMessage: (text, files) => engineRef.current?.sendMessage(text, files),
|
|
2222
|
+
appendMessage: (role, text) => engineRef.current?.appendMessage(role, text),
|
|
2223
|
+
removeQueuedMessage: (index) => engineRef.current?.removeQueuedMessage(index),
|
|
2224
|
+
getQueue: () => engineRef.current?.getQueue() ?? [],
|
|
2225
|
+
clearMessages: () => engineRef.current?.clearMessages(),
|
|
2226
|
+
getHistory: () => engineRef.current?.getHistory() ?? [],
|
|
2227
|
+
getModel: () => engineRef.current?.getModel() ?? '',
|
|
2228
|
+
setModel: (model) => engineRef.current?.setModel(model),
|
|
2229
|
+
getReasoning: () => engineRef.current?.getReasoning(),
|
|
2230
|
+
setReasoning: (reasoning) => engineRef.current?.setReasoning(reasoning),
|
|
2231
|
+
setMode: (mode) => engineRef.current?.setMode(mode),
|
|
2232
|
+
setModels: (models) => engineRef.current?.setModels(models),
|
|
2233
|
+
insertToken: (token) => engineRef.current?.insertToken(token),
|
|
2234
|
+
getParts: () => engineRef.current?.getParts() ?? [],
|
|
2235
|
+
watchInput: (handler) => engineRef.current?.watchInput(handler) ?? (() => { }),
|
|
2236
|
+
clearInput: () => engineRef.current?.clearInput(),
|
|
2237
|
+
focusInput: () => engineRef.current?.focusInput(),
|
|
2238
|
+
getElement: () => rootRef.current,
|
|
2239
|
+
destroy: () => engineRef.current?.destroy(),
|
|
2240
|
+
}), []);
|
|
2241
|
+
const messages = _jsx("div", { className: "chat-messages", ref: messagesRef });
|
|
2242
|
+
const input = (_jsxs("div", { className: "chat-input", ref: inputRef, children: [_jsx("div", { className: "chat-queue", ref: queueRef }), _jsx("div", { className: "chat-input-parts", ref: partsRef }), _jsx("div", { className: "chat-input-row", ref: rowRef }), _jsxs("div", { className: "chat-input-controls", ref: controlsRef, children: [_jsx("div", { className: "gen-select", ref: modelSelectRef, style: isModelLocked(roomLayout(options).models) ? { display: 'none' } : undefined }), _jsxs("div", { className: "chat-input-options", ref: optionsRef, children: [chatAuthorization(options) && (_jsx("span", { className: "material-symbols-outlined", ref: attachRef, children: "attach_file" })), _jsx("span", { className: "material-symbols-outlined", ref: sendRef, children: "arrow_forward" })] })] })] }));
|
|
2243
|
+
const withPanel = Boolean(panelRoom && bus);
|
|
2244
|
+
const actions = options.actions ?? [];
|
|
2245
|
+
const actionRow = actions.length > 0 || (withPanel && !panelOpen)
|
|
2246
|
+
? (_jsxs("div", { className: "chat-actions", children: [actions.map((action) => (_jsx("button", { type: "button", className: "material-symbols-outlined chat-action", title: action.label, "aria-label": action.label, onClick: action.onClick, children: action.icon }, action.label))), withPanel && !panelOpen && (_jsx("button", { type: "button", className: "material-symbols-outlined chat-action", title: "Attachments", "aria-label": "Show attachments", onClick: () => setPanelOpen(true), children: "right_panel_open" }))] }))
|
|
2247
|
+
: null;
|
|
2248
|
+
if (!panelRoom || !bus) {
|
|
2249
|
+
return (_jsxs("div", { className: "chat-root", ref: rootRef, children: [actionRow, messages, input] }));
|
|
2250
|
+
}
|
|
2251
|
+
return (_jsxs("div", { className: "chat-root chat-root-panel", ref: rootRef, children: [_jsxs("div", { className: "chat-main", ref: mainRef, children: [actionRow, messages, input] }), _jsx(ChatPanel, { room: panelRoom, bus: bus, open: panelOpen, onOpenChange: setPanelOpen })] }));
|
|
2252
|
+
});
|
|
2253
|
+
// ---- createChat (imperative bridge, backwards compatible) ----
|
|
2254
|
+
// Mounts the React <Chat> into `container` and returns the same imperative
|
|
2255
|
+
// ChatInstance the vanilla widget exposed, so the existing vanilla apps keep
|
|
2256
|
+
// calling createChat(el, opts) unchanged.
|
|
2257
|
+
export function createChat(container, options = {}) {
|
|
2258
|
+
const root = createRoot(container);
|
|
2259
|
+
let instance = null;
|
|
2260
|
+
const ready = [];
|
|
2261
|
+
const handleRef = (inst) => {
|
|
2262
|
+
instance = inst;
|
|
2263
|
+
if (inst)
|
|
2264
|
+
ready.push(inst);
|
|
2265
|
+
};
|
|
2266
|
+
// React 19's `createRoot().render()` is asynchronous by default, so without
|
|
2267
|
+
// flushSync the engine wouldn't exist yet when this function returns and an
|
|
2268
|
+
// immediate sendMessage() (the landing send) would be silently dropped.
|
|
2269
|
+
// flushSync forces a synchronous commit + layout effects, so the engine
|
|
2270
|
+
// (mounted in a useLayoutEffect) is live before we return.
|
|
2271
|
+
flushSync(() => root.render(_jsx(Chat, { ref: handleRef, options: options })));
|
|
2272
|
+
const get = () => instance ?? ready[ready.length - 1] ?? null;
|
|
2273
|
+
return {
|
|
2274
|
+
sendMessage: (text, files) => get()?.sendMessage(text, files),
|
|
2275
|
+
appendMessage: (role, text) => get()?.appendMessage(role, text),
|
|
2276
|
+
removeQueuedMessage: (index) => get()?.removeQueuedMessage(index),
|
|
2277
|
+
getQueue: () => get()?.getQueue() ?? [],
|
|
2278
|
+
clearMessages: () => get()?.clearMessages(),
|
|
2279
|
+
getHistory: () => get()?.getHistory() ?? [],
|
|
2280
|
+
getModel: () => get()?.getModel() ?? '',
|
|
2281
|
+
setModel: (model) => get()?.setModel(model),
|
|
2282
|
+
getReasoning: () => get()?.getReasoning(),
|
|
2283
|
+
setReasoning: (reasoning) => get()?.setReasoning(reasoning),
|
|
2284
|
+
setMode: (mode) => get()?.setMode(mode),
|
|
2285
|
+
setModels: (models) => get()?.setModels(models),
|
|
2286
|
+
insertToken: (token) => get()?.insertToken(token),
|
|
2287
|
+
getParts: () => get()?.getParts() ?? [],
|
|
2288
|
+
watchInput: (handler) => get()?.watchInput(handler) ?? (() => { }),
|
|
2289
|
+
clearInput: () => get()?.clearInput(),
|
|
2290
|
+
focusInput: () => get()?.focusInput(),
|
|
2291
|
+
getElement: () => (get()?.getElement() ?? container),
|
|
2292
|
+
destroy: () => {
|
|
2293
|
+
get()?.destroy();
|
|
2294
|
+
root.unmount();
|
|
2295
|
+
},
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
// ---- createLog ----
|
|
2299
|
+
// A read-only transcript surface: renders history and can stream a single
|
|
2300
|
+
// model reply, with no composer. Mirrors the original LogInstance API.
|
|
2301
|
+
export function createLog(container, options = {}) {
|
|
2302
|
+
const history = [];
|
|
2303
|
+
let destroyed = false;
|
|
2304
|
+
const root = document.createElement('div');
|
|
2305
|
+
root.className = 'chat-root';
|
|
2306
|
+
const messagesEl = document.createElement('div');
|
|
2307
|
+
messagesEl.className = 'chat-messages';
|
|
2308
|
+
root.appendChild(messagesEl);
|
|
2309
|
+
container.appendChild(root);
|
|
2310
|
+
if (options.initialHistory) {
|
|
2311
|
+
for (const message of options.initialHistory) {
|
|
2312
|
+
history.push(message);
|
|
2313
|
+
buildMessageElement(message).then((el) => {
|
|
2314
|
+
messagesEl.appendChild(el);
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
return {
|
|
2319
|
+
appendMessage(role, text) {
|
|
2320
|
+
const message = { role, text, createdAt: new Date() };
|
|
2321
|
+
history.push(message);
|
|
2322
|
+
options.callbacks?.onMessage?.(message);
|
|
2323
|
+
buildMessageElement(message).then((el) => {
|
|
2324
|
+
messagesEl.appendChild(el);
|
|
2325
|
+
gsap.from(el, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
|
|
2326
|
+
autoScroll(messagesEl);
|
|
2327
|
+
});
|
|
2328
|
+
},
|
|
2329
|
+
async streamMessage(iterable) {
|
|
2330
|
+
if (destroyed)
|
|
2331
|
+
return;
|
|
2332
|
+
const modelEl = document.createElement('div');
|
|
2333
|
+
modelEl.className = 'chat-message-model';
|
|
2334
|
+
modelEl.appendChild(buildThinkingIndicator());
|
|
2335
|
+
messagesEl.appendChild(modelEl);
|
|
2336
|
+
gsap.from(modelEl, { duration: 0.5, y: 100, opacity: 0, ease: 'power2.inOut' });
|
|
2337
|
+
gsap.to(messagesEl, { duration: 0.5, scrollTop: messagesEl.scrollHeight, ease: 'power2.inOut' });
|
|
2338
|
+
const renderer = createStreamRenderer(modelEl, messagesEl);
|
|
2339
|
+
try {
|
|
2340
|
+
for await (const event of iterable) {
|
|
2341
|
+
if (destroyed)
|
|
2342
|
+
break;
|
|
2343
|
+
if (event.event === 'text')
|
|
2344
|
+
renderer.feedText(event.delta);
|
|
2345
|
+
else if (event.event === 'tool_call')
|
|
2346
|
+
renderer.feedToolCall(event.id, event.name, event.args);
|
|
2347
|
+
else if (event.event === 'error')
|
|
2348
|
+
renderer.feedError(event.message);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
catch (err) {
|
|
2352
|
+
console.error(err);
|
|
2353
|
+
if (!renderer.getText())
|
|
2354
|
+
renderer.feedError('Something went wrong.');
|
|
2355
|
+
}
|
|
2356
|
+
if (destroyed)
|
|
2357
|
+
return;
|
|
2358
|
+
const message = await renderer.finalize();
|
|
2359
|
+
history.push(message);
|
|
2360
|
+
options.callbacks?.onMessage?.(message);
|
|
2361
|
+
},
|
|
2362
|
+
clearMessages() {
|
|
2363
|
+
history.length = 0;
|
|
2364
|
+
while (messagesEl.firstChild)
|
|
2365
|
+
messagesEl.firstChild.remove();
|
|
2366
|
+
},
|
|
2367
|
+
getHistory() {
|
|
2368
|
+
return [...history];
|
|
2369
|
+
},
|
|
2370
|
+
getElement() {
|
|
2371
|
+
return root;
|
|
2372
|
+
},
|
|
2373
|
+
destroy() {
|
|
2374
|
+
destroyed = true;
|
|
2375
|
+
history.length = 0;
|
|
2376
|
+
root.remove();
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
}
|