fauna-api 1.0.783 → 1.0.785

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.
@@ -1,28 +1,30 @@
1
1
  import { Credentials, ApiKey, Profile } from "../authentification.js";
2
- import { Function, Role, Tool } from "./generative.js";
2
+ import { Author, Function, Role, Text, Tool } from "./generative.js";
3
3
  import { Room, Presence, Authorization } from "../shared/server.js";
4
+ import { Parrot } from "./index.js";
4
5
  import { ChatTarget, AppendResult, Attachment } from "./share.js";
5
6
  import type { Repository } from "../termite/versioning.js";
6
7
  import type { Group } from "../groups.js";
7
8
  import type { Target } from "../view.js";
8
9
  export { Attachment } from "./share.js";
9
10
  /**
10
- * One durable message in a room transcript. Parts keep their file refs as
11
- * streaming `FaunaFile`s (no bytes over the socket) — `download()` them when you
12
- * need the bytes. `author` is the sender's public Profile (USER turns only).
11
+ * One durable message in a room transcript, as the room sends it: its parts as they're
12
+ * stored (file refs, no bytes over the socket), its author and model as on `Content`.
13
13
  */
14
14
  export type RoomMessage = {
15
15
  id: string;
16
16
  position: number;
17
17
  role: Role;
18
18
  parts: any[];
19
- author?: Profile;
19
+ author?: Author;
20
20
  replyTo?: string;
21
- /** LLM id that produced a MODEL turn (for UI avatar / rehydrate). */
22
- model?: string;
21
+ /** The model that wrote a MODEL turn. */
22
+ model?: Text;
23
23
  created: number;
24
- /** The name of the API key an agent posted this with, for its author. */
25
- via?: string;
24
+ };
25
+ export declare const RoomMessage: {
26
+ /** FILE parts arrive as `{ accessCode | path | ref }` and stream lazily through `FaunaFile`. */
27
+ fromJSON(json: any, authorization: Credentials | ApiKey, target: ChatTarget): RoomMessage;
26
28
  };
27
29
  /**
28
30
  * One buffered send in the shared room queue. When a member sends while the
@@ -1,9 +1,39 @@
1
- import { Role } from "./generative.js";
1
+ import { Author, Role } from "./generative.js";
2
+ import * as Drive from "../drive.js";
2
3
  import { Room, Presence, connect } from "../shared/server.js";
3
4
  import { Parrot } from "./index.js";
4
5
  import { appendRoomMessage, steerRoom, unqueueRoomMessage, pageMessages, setRoomRepository, listRoomAttachments, Attachment, } from "./share.js";
5
6
  import { File as FaunaFile } from "../drive.js";
6
7
  export { Attachment } from "./share.js";
8
+ export const RoomMessage = {
9
+ /** FILE parts arrive as `{ accessCode | path | ref }` and stream lazily through `FaunaFile`. */
10
+ fromJSON(json, authorization, target) {
11
+ const parts = (json.parts ?? []).map((part) => {
12
+ if (part.type !== "FILE")
13
+ return part;
14
+ const meta = { ...part, name: part.name ?? "file", contentType: part.contentType ?? "application/octet-stream" };
15
+ if (part.accessCode)
16
+ return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, accessCode: part.accessCode }) };
17
+ if (part.path)
18
+ return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, path: part.path, group: part.group }) };
19
+ if ("code" in target)
20
+ return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, accessCode: target.code }) };
21
+ if (part.ref)
22
+ return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, path: part.ref }) };
23
+ return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, accessCode: "" }) };
24
+ });
25
+ return {
26
+ id: String(json.id ?? ""),
27
+ position: Number(json.position ?? 0),
28
+ role: json.role,
29
+ parts,
30
+ author: Author.fromJSON(json.author),
31
+ replyTo: json.replyTo,
32
+ model: json.model ? json.model : undefined,
33
+ created: Number(json.created ?? 0),
34
+ };
35
+ },
36
+ };
7
37
  /**
8
38
  * A live, domain-agnostic chat room. `ChatRoom extends Room` and owns everything
9
39
  * chat-transcript-specific: a WINDOWED transcript (the last page hydrated on
@@ -208,19 +238,7 @@ export class ChatRoom extends Room {
208
238
  async loadOlder() {
209
239
  const page = await pageMessages(this.getAuthorization(), this.target, { cursor: this.messages.length, max: OLDER_PAGE });
210
240
  const known = new Set(this.messages.map((m) => m.position));
211
- const older = page
212
- .filter((m) => !known.has(m.position))
213
- .map((m) => ({
214
- id: m.id,
215
- position: m.position,
216
- role: m.role,
217
- parts: m.parts,
218
- author: m.author,
219
- replyTo: m.replyTo,
220
- model: m.model,
221
- created: m.created,
222
- ...(m.via ? { via: m.via } : {}),
223
- }));
241
+ const older = page.filter((m) => !known.has(m.position));
224
242
  this.messages = [...older, ...this.messages].sort((a, b) => a.position - b.position);
225
243
  this._hasMore = page.length === OLDER_PAGE;
226
244
  return older;
@@ -1,7 +1,8 @@
1
1
  import { Parrot } from "./index.js";
2
2
  import { Credentials, ApiKey, Profile } from "../authentification.js";
3
- import { Content, Function, Role } from "./generative.js";
3
+ import { Content, Function } from "./generative.js";
4
4
  import * as Drive from "../drive.js";
5
+ import { RoomMessage } from "./room.js";
5
6
  /**
6
7
  * Address a live room by a redeemable access code or by its globally-unique
7
8
  * reference (`parrot:chat:{chatId}` for a durable-bound room, `parrot:room:{uuid}`
@@ -35,24 +36,6 @@ export declare function openRoom(authorization: Credentials | ApiKey, options?:
35
36
  roomId: string;
36
37
  code?: string;
37
38
  }>;
38
- /** One transcript message on the wire (parts keep file refs as streaming
39
- * `FaunaFile`s). Matches the room's live `chat-message` shape. */
40
- export type WireMessage = {
41
- id: string;
42
- position: number;
43
- role: Role;
44
- parts: any[];
45
- author?: Profile;
46
- replyTo?: string;
47
- model?: string;
48
- created: number;
49
- /** The name of the API key an agent posted this with, for its author. */
50
- via?: string;
51
- };
52
- export declare const WireMessage: {
53
- /** FILE parts arrive as `{ accessCode | path | ref }` and stream lazily through `FaunaFile`. */
54
- fromJSON(json: any, authorization: Credentials | ApiKey, target: ChatTarget): WireMessage;
55
- };
56
39
  /**
57
40
  * Page older room transcript messages, newest first. `cursor` is how many
58
41
  * messages to skip from the newest.
@@ -60,7 +43,7 @@ export declare const WireMessage: {
60
43
  export declare function pageMessages(authorization: Credentials | ApiKey, target: ChatTarget, query: {
61
44
  cursor: number;
62
45
  max: number;
63
- }): Promise<WireMessage[]>;
46
+ }): Promise<RoomMessage[]>;
64
47
  /** Result of appending to a live room: either the durable transcript row (when
65
48
  * the room was idle) or a queued buffer row (when the model was mid-reply). */
66
49
  export type AppendResult = {
@@ -1,5 +1,6 @@
1
1
  import { Parrot } from "./index.js";
2
2
  import * as Drive from "../drive.js";
3
+ import { RoomMessage } from "./room.js";
3
4
  export async function moveChatToGroup(credentials, chatId, groupId) {
4
5
  const response = await fetch(`${Parrot.getURL()}/chat/move`, {
5
6
  method: "POST",
@@ -37,35 +38,6 @@ export async function openRoom(authorization, options = {}) {
37
38
  throw new Error(await response.text());
38
39
  return await response.json();
39
40
  }
40
- export const WireMessage = {
41
- /** FILE parts arrive as `{ accessCode | path | ref }` and stream lazily through `FaunaFile`. */
42
- fromJSON(json, authorization, target) {
43
- const parts = (json.parts ?? []).map((part) => {
44
- if (part.type !== "FILE")
45
- return part;
46
- const meta = { ...part, name: part.name ?? "file", contentType: part.contentType ?? "application/octet-stream" };
47
- if (part.accessCode)
48
- return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, accessCode: part.accessCode }) };
49
- if (part.path)
50
- return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, path: part.path, group: part.group }) };
51
- if ("code" in target)
52
- return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, accessCode: target.code }) };
53
- if (part.ref)
54
- return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, path: part.ref }) };
55
- return { type: "FILE", file: Drive.File.fromJSON(meta, { authorization, accessCode: "" }) };
56
- });
57
- return {
58
- id: String(json.id ?? ""),
59
- position: Number(json.position ?? 0),
60
- role: json.role,
61
- parts,
62
- author: json.author,
63
- replyTo: json.replyTo,
64
- model: json.model,
65
- created: Number(json.created ?? 0),
66
- };
67
- },
68
- };
69
41
  /**
70
42
  * Page older room transcript messages, newest first. `cursor` is how many
71
43
  * messages to skip from the newest.
@@ -82,7 +54,7 @@ export async function pageMessages(authorization, target, query) {
82
54
  if (!response.ok)
83
55
  throw new Error(await response.text());
84
56
  const json = await response.json();
85
- return (Array.isArray(json) ? json : []).map((m) => WireMessage.fromJSON(m, authorization, target));
57
+ return (Array.isArray(json) ? json : []).map((m) => RoomMessage.fromJSON(m, authorization, target));
86
58
  }
87
59
  /**
88
60
  * Append one message to a live room's single transcript. Any member may append;
@@ -1,9 +1,8 @@
1
1
  import { Fauna } from "../index.js";
2
2
  import { ChatRoom, type RoomHooks } from "./parrot/room.js";
3
- import { LastMessage } from "./parrot/chat.js";
3
+ import { Content } from "./parrot/generative.js";
4
4
  import { Authorization, Presence, Room, Hooks as ServerHooks } from "./shared/server.js";
5
5
  export declare function ticketChatAutoEnabled(emails: Iterable<string>, reporter: string): boolean;
6
- export type TicketLast = LastMessage;
7
6
  export type Ticket = {
8
7
  id: string;
9
8
  title: string;
@@ -13,7 +12,7 @@ export type Ticket = {
13
12
  roomReference?: string;
14
13
  createdAt: Date;
15
14
  updatedAt: Date;
16
- last: TicketLast | null;
15
+ last: Content | null;
17
16
  unread: boolean;
18
17
  };
19
18
  export declare const Ticket: {
@@ -1,6 +1,6 @@
1
1
  import { Fauna } from "../index.js";
2
2
  import { connectRoom } from "./parrot/room.js";
3
- import { LastMessage } from "./parrot/chat.js";
3
+ import { Content } from "./parrot/generative.js";
4
4
  import { Room, connect as connectServer } from "./shared/server.js";
5
5
  export function ticketChatAutoEnabled(emails, reporter) {
6
6
  const reporterEmail = reporter.trim().toLowerCase();
@@ -30,7 +30,7 @@ export const Ticket = {
30
30
  roomReference: json.roomReference || json.room_reference || undefined,
31
31
  createdAt: new Date(json.createdAt ?? json.created_at),
32
32
  updatedAt: new Date(json.updatedAt ?? json.updated_at),
33
- last: LastMessage.fromJSON(json.last),
33
+ last: json.last ? Content.fromJSON(json.last) : null,
34
34
  unread: Boolean(json.unread),
35
35
  };
36
36
  },
@@ -32,6 +32,10 @@ export type ChatMessage = {
32
32
  author?: Author;
33
33
  /** LLM id that produced a MODEL turn — drives the left avatar icon. */
34
34
  model?: string;
35
+ /** Where it sits, when the app decides (`side`). */
36
+ side?: 'left' | 'right';
37
+ /** A line under it, such as why it wasn't delivered (`note`). */
38
+ note?: string;
35
39
  };
36
40
  export type { ChatMessageBlock };
37
41
  export type StreamEvent = {
@@ -89,6 +93,8 @@ export type ChatInstance = {
89
93
  setReasoning: (reasoning: string | undefined) => void;
90
94
  setMode: (mode: ChatMode) => void;
91
95
  setModels: (models?: Fauna.Parrot.Generative.Text | Fauna.Parrot.Generative.Text[]) => void;
96
+ /** Reads every drawn message's `note` again, after what it depends on changed. */
97
+ refreshNotes: () => void;
92
98
  insertToken: (token: Token) => void;
93
99
  getParts: () => Part[];
94
100
  watchInput: (handler: (parts: Part[]) => void) => () => void;
@@ -164,6 +170,21 @@ export type ChatOptions = Partial<{
164
170
  onRepositoryChange?: (repository: number | null) => void;
165
171
  /** The app's own buttons at the chat's top right, beside the side panel's, in the same style. */
166
172
  actions?: ChatAction[];
173
+ /** Which side each message sits on, when the app decides. A model turn streaming in is
174
+ * asked as `{ role: MODEL }`. Omit for the default (see `mode`). */
175
+ side?: (message: {
176
+ role: Fauna.Parrot.Generative.Role;
177
+ author?: Fauna.Parrot.Generative.Author;
178
+ }) => 'left' | 'right';
179
+ /** A name and an icon for an outside address (a phone number or an email) a message is by. */
180
+ nameAddress?: (address: string) => {
181
+ name: string;
182
+ icon?: string;
183
+ } | undefined;
184
+ /** A line under a message, such as why it wasn't delivered. */
185
+ note?: (message: RoomMessage) => string | undefined;
186
+ /** Suggest how to finish a reply in a saved chat's room, as ghost text Tab takes. */
187
+ autocomplete?: boolean;
167
188
  }>;
168
189
  /** A button at the chat's top right: a Material Symbols icon, its label, and what it does. */
169
190
  export type ChatAction = {
@@ -12,12 +12,16 @@ import { ChatPanel, askForComputer, attachmentOf, buildAttachmentCard, chatToolL
12
12
  export { RepositoryControl, askForComputer, pickedRepository } from "./chatPanel.js";
13
13
  import { createTooltip } from "./hover.js";
14
14
  import { sendInformaticToast } from "./toast.js";
15
- import { buildAvatar, createSelector, stampModelAvatar, stampUserAvatar } from "./generative.js";
15
+ import { buildAvatar, buildModelAvatar, createSelector, stampModelAvatar, stampUserAvatar } from "./generative.js";
16
16
  import { authorColorSeed, colorFromSeed } from "./cursor.js";
17
17
  import { render as renderMarkdown } from "./markdown.js";
18
18
  // Re-export so apps can flatten serialized token markers (e.g. before sending
19
19
  // history to the LLM) without reaching into the tokenized-input module.
20
20
  export { toPlainText } from "./tokenized.js";
21
+ /** The person behind a message's author, when it's one. */
22
+ function personOf(author) {
23
+ return typeof author === 'object' && 'email' in author && typeof author.email === 'string' ? author : undefined;
24
+ }
21
25
  // ---- Message builders ----
22
26
  function buildThinkingIndicator() {
23
27
  const indicator = document.createElement('div');
@@ -231,6 +235,55 @@ function isIncomingAuthor(message, mode = 'assistant', selfEmail) {
231
235
  return false;
232
236
  return email !== selfEmail;
233
237
  }
238
+ /** Whether a message sits on the left: where the app put it, else the model and other
239
+ * people in a thread. */
240
+ function placedLeft(message, mode, selfEmail) {
241
+ if (message.side)
242
+ return message.side === 'left';
243
+ return message.role === 'model' || isIncomingAuthor(message, mode, selfEmail);
244
+ }
245
+ /** A model's icon on whichever side its turn sits. */
246
+ function stampModelAvatarOn(el, left, model, icons) {
247
+ if (left)
248
+ return stampModelAvatar(el, model, icons);
249
+ el.classList.add('chat-message-authored');
250
+ el.appendChild(buildModelAvatar(model, icons));
251
+ }
252
+ /** How a message's author shows: a person by their profile, an API key or an outside
253
+ * address by its name (an address named through `nameAddress`). */
254
+ function uiAuthor(author, nameAddress) {
255
+ const person = personOf(author);
256
+ if (person)
257
+ return { id: person.email, profile: person };
258
+ if (typeof author === 'object' && 'name' in author && typeof author.name === 'string')
259
+ return { id: `apikey:${author.name}`, profile: { email: '', displayName: author.name } };
260
+ if (typeof author === 'string' && author !== 'MODEL') {
261
+ const named = nameAddress?.(author);
262
+ return { id: author, profile: { email: author, displayName: named?.name ?? author, icon: named?.icon } };
263
+ }
264
+ return undefined;
265
+ }
266
+ /** An HTML body, drawn as it is in a frame that runs no scripts. */
267
+ function buildHtmlElement(html) {
268
+ const frame = document.createElement('iframe');
269
+ frame.className = 'chat-message-html';
270
+ frame.setAttribute('sandbox', 'allow-same-origin allow-popups allow-popups-to-escape-sandbox');
271
+ frame.srcdoc = `<base target="_blank">${html}`;
272
+ frame.addEventListener('load', () => {
273
+ const height = frame.contentDocument?.documentElement.scrollHeight;
274
+ if (height)
275
+ frame.style.height = `${height}px`;
276
+ });
277
+ return frame;
278
+ }
279
+ function appendNote(el, note) {
280
+ if (!note)
281
+ return;
282
+ const noteEl = document.createElement('div');
283
+ noteEl.className = 'chat-message-note';
284
+ noteEl.textContent = note;
285
+ el.appendChild(noteEl);
286
+ }
234
287
  function applyUserBubbleLayout(el, incoming, profile) {
235
288
  el.querySelector('.chat-message-model-avatar')?.remove();
236
289
  el.querySelector(':scope > .chat-message-avatar:not(.chat-message-model-avatar)')?.remove();
@@ -283,15 +336,19 @@ function appendTextBlock(el, text, message, onTokenClick) {
283
336
  el.appendChild(textEl);
284
337
  }
285
338
  async function buildMessageElement(message, iconMap, modelIcons, renderToolCall, mode, selfUserId, onTokenClick, fileCards, sessions) {
286
- const incoming = isIncomingAuthor(message, chatMode(mode), selfUserId);
339
+ const left = placedLeft(message, chatMode(mode), selfUserId);
287
340
  const el = document.createElement('div');
288
- el.className = (message.role === 'user' && !incoming) ? 'chat-message-user' : 'chat-message-model';
341
+ el.className = left ? 'chat-message-model' : 'chat-message-user';
289
342
  if (message.role === 'user' && message.author?.id)
290
343
  el.dataset.author = message.author.id;
344
+ if (message.side)
345
+ el.dataset.side = message.side;
291
346
  if (message.sequence?.length) {
292
347
  for (const block of message.sequence) {
293
348
  if (block.type === 'text')
294
349
  appendTextBlock(el, block.text, message, onTokenClick);
350
+ else if (block.type === 'html')
351
+ el.appendChild(buildHtmlElement(block.html));
295
352
  else if (block.type === 'tool')
296
353
  el.appendChild(renderToolCallElement(block.call, renderToolCall, sessions));
297
354
  else
@@ -315,15 +372,16 @@ async function buildMessageElement(message, iconMap, modelIcons, renderToolCall,
315
372
  if (message.text) {
316
373
  addCopyButton(el, () => toPlainText(message.text), message.createdAt);
317
374
  }
318
- if (incoming && message.author) {
375
+ appendNote(el, message.note);
376
+ if (message.role === 'model') {
377
+ stampModelAvatarOn(el, left, message.model, modelIcons);
378
+ }
379
+ else if (left && message.author) {
319
380
  stampIncomingAvatar(el, message.author.profile);
320
381
  }
321
- else if (message.role === 'user' && message.author) {
382
+ else if (message.author) {
322
383
  stampUserAvatar(el, message.author.profile);
323
384
  }
324
- if (message.role === 'model') {
325
- stampModelAvatar(el, message.model, modelIcons);
326
- }
327
385
  return el;
328
386
  }
329
387
  // ---- Streaming ----
@@ -510,6 +568,8 @@ function createStreamRenderer(modelEl, messagesEl, renderToolCall, sessions) {
510
568
  },
511
569
  };
512
570
  }
571
+ /** How long typing pauses before a reply is suggested. */
572
+ const AUTOCOMPLETE_IDLE_MS = 600;
513
573
  function chatAuthorization(options) {
514
574
  return options.authorization ?? options.room?.room.getAuthorization();
515
575
  }
@@ -768,6 +828,18 @@ class ChatEngine {
768
828
  writable: true,
769
829
  value: null
770
830
  });
831
+ Object.defineProperty(this, "autocompleteTimer", {
832
+ enumerable: true,
833
+ configurable: true,
834
+ writable: true,
835
+ value: void 0
836
+ });
837
+ Object.defineProperty(this, "autocompleteAsked", {
838
+ enumerable: true,
839
+ configurable: true,
840
+ writable: true,
841
+ value: 0
842
+ });
771
843
  this.sessions = new SessionPopout(refs.root, () => chatAuthorization(this.options));
772
844
  const locked = lockedModels(options.models);
773
845
  const start = options.model && (!locked || locked.includes(options.model)) ? options.model : Fauna.Parrot.Generative.Text.AUTO;
@@ -781,6 +853,7 @@ class ChatEngine {
781
853
  this.roomTypingHandler?.();
782
854
  for (const handler of this.inputWatchers)
783
855
  handler(parts);
856
+ this.scheduleAutocomplete();
784
857
  },
785
858
  onSubmit: () => {
786
859
  this.doSend(this.tokenizedInput.getText(), this.tokenizedInput.getParts(), this.runningParts.length > 0 ? [...this.runningParts] : undefined);
@@ -1042,11 +1115,13 @@ class ChatEngine {
1042
1115
  }
1043
1116
  // ── Send / Queue ──
1044
1117
  renderUserMessage(message) {
1045
- const incoming = isIncomingAuthor(message, chatMode(this.options.mode), this.options.room?.self.profile.email);
1118
+ const incoming = placedLeft(message, chatMode(this.options.mode), this.options.room?.self.profile.email);
1046
1119
  const userEl = document.createElement('div');
1047
1120
  userEl.className = incoming ? 'chat-message-model' : 'chat-message-user';
1048
1121
  if (message.role === 'user' && message.author?.id)
1049
1122
  userEl.dataset.author = message.author.id;
1123
+ if (message.side)
1124
+ userEl.dataset.side = message.side;
1050
1125
  if (message.text) {
1051
1126
  const textEl = document.createElement('div');
1052
1127
  textEl.className = 'chat-message-text';
@@ -1296,12 +1371,69 @@ class ChatEngine {
1296
1371
  role: isUser ? 'user' : 'model',
1297
1372
  createdAt: message.created ? new Date(message.created) : new Date(),
1298
1373
  model: isUser ? undefined : message.model,
1299
- author: isUser && message.author?.email
1300
- ? { id: message.author.email, profile: message.author }
1301
- : undefined,
1374
+ author: isUser ? uiAuthor(message.author, this.options.nameAddress) : undefined,
1375
+ side: this.options.side?.(message),
1376
+ note: this.options.note?.(message),
1302
1377
  ...roomPartsToChatFields(message.parts),
1303
1378
  };
1304
1379
  }
1380
+ /** Reads every drawn message's `note` again, after what it depends on changed. */
1381
+ refreshNotes() {
1382
+ const room = this.options.room?.room;
1383
+ const note = this.options.note;
1384
+ if (!room || !note)
1385
+ return;
1386
+ for (const message of room.getMessages()) {
1387
+ const el = this.paintedMessage(message.id);
1388
+ if (!el)
1389
+ continue;
1390
+ const text = note(message);
1391
+ const noteEl = el.querySelector(':scope > .chat-message-note');
1392
+ if (!text)
1393
+ noteEl?.remove();
1394
+ else if (noteEl)
1395
+ noteEl.textContent = text;
1396
+ else
1397
+ appendNote(el, text);
1398
+ }
1399
+ }
1400
+ /** After a pause in typing, asks for the rest of the reply and shows it as ghost text. */
1401
+ scheduleAutocomplete() {
1402
+ if (!this.options.autocomplete)
1403
+ return;
1404
+ clearTimeout(this.autocompleteTimer);
1405
+ const asked = ++this.autocompleteAsked;
1406
+ this.autocompleteTimer = setTimeout(() => void this.runAutocomplete(asked), AUTOCOMPLETE_IDLE_MS);
1407
+ }
1408
+ async runAutocomplete(asked) {
1409
+ const room = this.options.room?.room;
1410
+ const authorization = chatAuthorization(this.options);
1411
+ const chatId = room?.chatId;
1412
+ if (!room || !chatId || !authorization || !('email' in authorization || 'balance' in authorization))
1413
+ return;
1414
+ const editor = this.tokenizedInput.getElement();
1415
+ if (!editor.contains(document.activeElement) && document.activeElement !== editor)
1416
+ return;
1417
+ if (!this.tokenizedInput.atEnd())
1418
+ return;
1419
+ const newest = [...room.getMessages()].reverse().find((m) => !isSystemRoomMessage(m));
1420
+ if (!newest || personOf(newest.author)?.email === this.options.room?.self.profile.email)
1421
+ return;
1422
+ const text = this.tokenizedInput.getText();
1423
+ try {
1424
+ const rest = await Fauna.Parrot.Chat.autocomplete(authorization, chatId, text, room.group);
1425
+ if (asked !== this.autocompleteAsked || this.tokenizedInput.getText() !== text)
1426
+ return;
1427
+ this.tokenizedInput.setGhost(rest);
1428
+ }
1429
+ catch (err) {
1430
+ console.error('[autocomplete]', err instanceof Error ? err.message : err);
1431
+ }
1432
+ }
1433
+ /** Where a model turn sits while it streams in. */
1434
+ modelSide() {
1435
+ return this.options.side?.({ role: Fauna.Parrot.Generative.Role.MODEL }) ?? 'left';
1436
+ }
1305
1437
  // Insert a rendered message element at the position matching its durable
1306
1438
  // `position` in the transcript, so members render in a consistent order
1307
1439
  // regardless of event-arrival timing (fixes interleaving on multi-member
@@ -1377,7 +1509,8 @@ class ChatEngine {
1377
1509
  // A reaction arrives as its own room message; it never paints a bubble.
1378
1510
  // It folds into a row of emoji under the message at its target position.
1379
1511
  applyReaction(message, reaction) {
1380
- const email = message.author?.email;
1512
+ const person = personOf(message.author);
1513
+ const email = person?.email;
1381
1514
  if (!email)
1382
1515
  return;
1383
1516
  let byEmoji = this.reactions.get(reaction.position);
@@ -1393,7 +1526,7 @@ class ChatEngine {
1393
1526
  const latest = byPerson.get(email);
1394
1527
  if (latest && latest.at > message.position)
1395
1528
  return;
1396
- const name = message.author?.displayName ?? email.split('@')[0];
1529
+ const name = person?.displayName ?? email.split('@')[0];
1397
1530
  byPerson.set(email, { on: reaction.on, at: message.position, name });
1398
1531
  this.paintReactions(reaction.position);
1399
1532
  }
@@ -1677,7 +1810,8 @@ class ChatEngine {
1677
1810
  streamedEl.dataset.messageId = message.id;
1678
1811
  if (chat.model)
1679
1812
  streamedEl.dataset.model = chat.model;
1680
- stampModelAvatar(streamedEl, chat.model ?? this.currentModel, this.modelIcons);
1813
+ stampModelAvatarOn(streamedEl, chat.side ? chat.side === 'left' : true, chat.model ?? this.currentModel, this.modelIcons);
1814
+ appendNote(streamedEl, chat.note);
1681
1815
  this.enableReactions(streamedEl, message.position);
1682
1816
  this.history.push(chat);
1683
1817
  autoScroll(this.refs.messagesEl);
@@ -1686,6 +1820,7 @@ class ChatEngine {
1686
1820
  }
1687
1821
  this.renderRoomMessage(chat, message.id, message.position);
1688
1822
  this.options.room?.onRoomMessage?.(message);
1823
+ this.scheduleAutocomplete();
1689
1824
  },
1690
1825
  onQueue: (r) => {
1691
1826
  if (liveEnded || this.destroyed)
@@ -1725,10 +1860,11 @@ class ChatEngine {
1725
1860
  this.roomThinkingEl.remove();
1726
1861
  this.roomThinkingEl = null;
1727
1862
  }
1863
+ const left = this.modelSide() === 'left';
1728
1864
  const el = document.createElement('div');
1729
- el.className = 'chat-message-model';
1865
+ el.className = left ? 'chat-message-model' : 'chat-message-user';
1730
1866
  el.dataset.model = this.currentModel;
1731
- stampModelAvatar(el, this.currentModel, this.modelIcons);
1867
+ stampModelAvatarOn(el, left, this.currentModel, this.modelIcons);
1732
1868
  this.refs.messagesEl.appendChild(el);
1733
1869
  gsap.from(el, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
1734
1870
  this.roomStream = { turn, el, renderer: createStreamRenderer(el, this.refs.messagesEl, this.options.renderToolCall, this.sessions) };
@@ -1890,7 +2026,7 @@ class ChatEngine {
1890
2026
  return;
1891
2027
  if (thinking && !this.roomThinkingEl) {
1892
2028
  this.roomThinkingEl = document.createElement('div');
1893
- this.roomThinkingEl.className = 'chat-message-model';
2029
+ this.roomThinkingEl.className = this.modelSide() === 'left' ? 'chat-message-model' : 'chat-message-user';
1894
2030
  this.roomThinkingEl.appendChild(buildThinkingIndicator());
1895
2031
  this.refs.messagesEl.appendChild(this.roomThinkingEl);
1896
2032
  gsap.from(this.roomThinkingEl, { duration: 0.4, y: 40, opacity: 0, ease: 'power2.out' });
@@ -2111,7 +2247,7 @@ class ChatEngine {
2111
2247
  return;
2112
2248
  this.options.mode = mode;
2113
2249
  const selfEmail = this.options.room?.self.profile.email;
2114
- for (const el of this.refs.messagesEl.querySelectorAll('[data-author]')) {
2250
+ for (const el of this.refs.messagesEl.querySelectorAll('[data-author]:not([data-side])')) {
2115
2251
  const email = el.dataset.author;
2116
2252
  const incoming = mode === 'thread' && Boolean(email && selfEmail && email !== selfEmail);
2117
2253
  const profile = this.history.find((m) => m.author?.id === email)?.author?.profile;
@@ -2246,6 +2382,7 @@ export const Chat = forwardRef(function Chat({ options = {} }, ref) {
2246
2382
  setReasoning: (reasoning) => engineRef.current?.setReasoning(reasoning),
2247
2383
  setMode: (mode) => engineRef.current?.setMode(mode),
2248
2384
  setModels: (models) => engineRef.current?.setModels(models),
2385
+ refreshNotes: () => engineRef.current?.refreshNotes(),
2249
2386
  insertToken: (token) => engineRef.current?.insertToken(token),
2250
2387
  getParts: () => engineRef.current?.getParts() ?? [],
2251
2388
  watchInput: (handler) => engineRef.current?.watchInput(handler) ?? (() => { }),
@@ -2299,6 +2436,7 @@ export function createChat(container, options = {}) {
2299
2436
  setReasoning: (reasoning) => get()?.setReasoning(reasoning),
2300
2437
  setMode: (mode) => get()?.setMode(mode),
2301
2438
  setModels: (models) => get()?.setModels(models),
2439
+ refreshNotes: () => get()?.refreshNotes(),
2302
2440
  insertToken: (token) => get()?.insertToken(token),
2303
2441
  getParts: () => get()?.getParts() ?? [],
2304
2442
  watchInput: (handler) => get()?.watchInput(handler) ?? (() => { }),
@@ -23,6 +23,9 @@ export type ChatFile = {
23
23
  export type ChatMessageBlock = {
24
24
  type: 'text';
25
25
  text: string;
26
+ } | {
27
+ type: 'html';
28
+ html: string;
26
29
  } | {
27
30
  type: 'tool';
28
31
  call: ChatSequenceToolCall;
@@ -19,6 +19,9 @@ export function roomPartsToChatFields(parts) {
19
19
  if (part.text.trim())
20
20
  sequence.push({ type: 'text', text: part.text });
21
21
  }
22
+ else if (part?.type === 'HTML' && typeof part.html === 'string') {
23
+ sequence.push({ type: 'html', html: part.html });
24
+ }
22
25
  else if (part?.type === 'FILE') {
23
26
  const file = { mime: part.contentType ?? '', name: part.name ?? part.ref ?? 'file', part };
24
27
  files.push(file);
@@ -30,6 +30,11 @@ export declare function createTokenizedInput(container: HTMLElement, options?: P
30
30
  focus: () => void;
31
31
  getElement: () => HTMLElement;
32
32
  setPlaceholder: (text: string) => void;
33
+ /** Show a suggested ending after the caret: Tab takes it, Shift+Tab or Escape drops it,
34
+ * and typing clears it. Empty clears it. */
35
+ setGhost: (text: string) => void;
36
+ /** Whether the caret sits at the very end. */
37
+ atEnd: () => boolean;
33
38
  };
34
39
  /**
35
40
  * `@` mentions. By default it searches every Fauna user; pass `people` (a