Skip to content
Source
Sign inSign up

guestStream.logic.ts

259 lines10.1 KB

1import {
2 GUEST_ATTACHMENT_ROUTE,
3 GUEST_MEDIA_ROUTE,
4 GUEST_MESSAGE_MAX_ATTACHMENT_BYTES,
5 GUEST_MESSAGE_MAX_ATTACHMENTS,
6 GUEST_SHARE_PARAM,
7 GuestStreamEvent,
8 isProviderSendTurnSupportedImageMimeType,
9 type GuestMessageId,
10 type GuestShareView,
11 type OrchestrationV2ProjectedTurnItem,
12 type OrchestrationV2ThreadHistoryPage,
13 type OrchestrationV2ThreadProjection,
14 type OrchestrationV2TurnItem,
15 type ThreadGuestMessage,
16} from "@t3tools/contracts";
17import { applyOrchestrationV2ProjectionEvent } from "@t3tools/client-runtime/state/orchestration-v2-projection";
18import { mergeOlderHistoryIntoProjection } from "@t3tools/client-runtime/state/thread-history-merge";
19import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot";
20import * as Option from "effect/Option";
21import * as Schema from "effect/Schema";
22​
23export interface GuestStreamState {
24 readonly view: GuestShareView | null;
25 /** The thread as the owner's client holds it, built with the same reducer. */
26 readonly projection: OrchestrationV2ThreadProjection | null;
27 readonly sequence: number;
28 /** Set while the snapshot is a window of a longer history, as the owner's client tracks it. */
29 readonly partial: { readonly latestLocalTurnOrdinal: number | null } | null;
30 /** Where "Load earlier turns" continues, or null once the whole thread is loaded. */
31 readonly historyCursor: string | null;
32 readonly ended: boolean;
33 /**
34 * Messages this page saw waiting that have no transcript row yet. Survives reconnects, and a
35 * message leaves once its row arrives, so a later bounded snapshot cannot bring it back.
36 */
37 readonly awaitingRowIds: ReadonlySet<GuestMessageId>;
39​
40export const initialGuestStreamState: GuestStreamState = {
41 view: null,
42 projection: null,
43 sequence: 0,
44 partial: null,
45 historyCursor: null,
46 ended: false,
47 awaitingRowIds: new Set(),
48};
49​
50const guestMessageIdOf = (item: OrchestrationV2TurnItem) =>
51 item.type === "user_message" ? item.context?.guest?.guestMessageId : undefined;
52​
53function withPendingFrom(
54 awaiting: ReadonlySet<GuestMessageId>,
55 view: GuestShareView,
56): ReadonlySet<GuestMessageId> {
57 const pending = view.messages.filter((message) => message.status === "pending");
58 if (pending.every((message) => awaiting.has(message.id))) return awaiting;
59 return new Set([...awaiting, ...pending.map((message) => message.id)]);
61​
62function withoutPlaced(
63 awaiting: ReadonlySet<GuestMessageId>,
64 items: ReadonlyArray<OrchestrationV2TurnItem>,
65): ReadonlySet<GuestMessageId> {
66 const placed = items.flatMap((item) => {
67 const id = guestMessageIdOf(item);
68 return id !== undefined && awaiting.has(id) ? [id] : [];
69 });
70 if (placed.length === 0) return awaiting;
71 const next = new Set(awaiting);
72 for (const id of placed) next.delete(id);
73 return next;
75​
76const decodeGuestStreamEvent = Schema.decodeUnknownOption(
77 Schema.fromJsonString(Schema.toCodecJson(GuestStreamEvent)),
78);
79​
80/** Returns null for anything the server would never send, so a bad frame is skipped, not fatal. */
81export const parseGuestStreamEvent = (data: string): GuestStreamEvent | null =>
82 Option.getOrNull(decodeGuestStreamEvent(data));
83​
84/** A page of older turns, fetched from `cursor`. */
85export type GuestHistoryPageLoaded = {
86 readonly type: "history-page";
87 readonly cursor: string;
88 readonly page: OrchestrationV2ThreadHistoryPage;
89};
90​
91/**
92 * Applies the owner's thread stream the way the owner's client does (see
93 * EnvironmentThreadState in client-runtime): a snapshot replaces the thread, and later events
94 * pass through the same projection reducer, once each. Older pages merge in front, as they do in
95 * the owner's view.
96 */
97export function applyGuestStreamEvent(
98 state: GuestStreamState,
99 event: GuestStreamEvent | GuestHistoryPageLoaded,
100): GuestStreamState {
101 switch (event.type) {
102 case "history-page":
103 // A snapshot since the request replaced the thread and its cursor.
104 if (state.projection === null || state.historyCursor !== event.cursor) return state;
105 return {
106 ...state,
107 projection: mergeOlderHistoryIntoProjection(state.projection, event.page.items),
108 historyCursor: event.page.hasMoreHistory ? event.page.nextCursor : null,
109 };
110 case "view":
111 // The page follows the share it names; a view of another one means the link changed.
112 if (state.view !== null && state.view.shareId !== event.view.shareId) {
113 return { ...state, ended: true };
114 }
115 return {
116 ...state,
117 view: event.view,
118 awaitingRowIds: withPendingFrom(state.awaitingRowIds, event.view),
119 };
120 case "thread": {
121 const item = event.item;
122 if (item.kind === "snapshot") {
123 const projection = boundedSnapshotProjection(item);
124 return {
125 ...state,
126 projection,
127 sequence: item.snapshotSequence,
128 partial:
129 item.hasMoreHistory === true || (item.historyCursor ?? null) !== null
130 ? { latestLocalTurnOrdinal: item.latestLocalTurnOrdinal ?? null }
131 : null,
132 historyCursor: item.hasMoreHistory === true ? (item.historyCursor ?? null) : null,
133 awaitingRowIds: withoutPlaced(
134 state.awaitingRowIds,
135 projection.visibleTurnItems.map((row) => row.item),
136 ),
137 };
138 }
139 if (item.kind === "synchronized" || item.sequence <= state.sequence) return state;
140 if (item.kind === "unknown-event") return { ...state, sequence: item.sequence };
141 const projection =
142 applyOrchestrationV2ProjectionEvent(
143 state.projection,
144 item.event,
145 state.partial === null ? undefined : { partialTimeline: true, ...state.partial },
146 ) ?? state.projection;
147 const turnItem = item.event.type === "turn-item.updated" ? item.event.payload : null;
148 const watermark = state.partial?.latestLocalTurnOrdinal ?? null;
149 return {
150 ...state,
151 projection,
152 sequence: item.sequence,
153 partial:
154 state.partial !== null &&
155 turnItem !== null &&
156 (watermark === null || turnItem.ordinal > watermark)
157 ? { latestLocalTurnOrdinal: turnItem.ordinal }
158 : state.partial,
159 awaitingRowIds:
160 turnItem === null
161 ? state.awaitingRowIds
162 : withoutPlaced(state.awaitingRowIds, [turnItem]),
163 };
164 }
165 case "ended":
166 return { ...state, ended: true };
167 }
169​
170/**
171 * The guest's messages to show after the transcript, oldest first. Pending ones always show. A
172 * sent or dismissed one shows only while it awaits a row (see `awaitingRowIds`): a queued send, or
173 * a dismissal to report. The transcript is a bounded window, so any other resolved message is old
174 * history, not something to place at the end.
175 */
176export function unplacedGuestMessages(
177 messages: ReadonlyArray<ThreadGuestMessage>,
178 items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
179 awaitingRowIds: ReadonlySet<GuestMessageId>,
180): ReadonlyArray<ThreadGuestMessage> {
181 const placed = new Set(items.flatMap((row) => guestMessageIdOf(row.item) ?? []));
182 return messages
183 .filter(
184 (message) =>
185 !placed.has(message.id) && (message.status === "pending" || awaitingRowIds.has(message.id)),
186 )
187 .toSorted((a, b) => a.submittedAt.localeCompare(b.submittedAt));
189​
190/** "23h left" or "5m left", rounded down, for the guest page header. */
191export function formatGuestExpiry(expiresAt: string, nowMs: number): string {
192 const minutes = Math.floor((Date.parse(expiresAt) - nowMs) / 60_000);
193 if (minutes <= 0) return "Expired";
194 return minutes >= 60 ? `${Math.floor(minutes / 60)}h left` : `${minutes}m left`;
196​
197/**
198 * The share an invite (`<shareId>.<signature>`) opens. Only used to name the share in the page
199 * URL and to find this browser's session for it; the server checks the signature.
200 */
201export function inviteShareId(credential: string): string | null {
202 const [shareId, signature] = credential.split(".");
203 return shareId && signature ? shareId : null;
205​
206/** Where the guest page loads a file of the shared thread or of a waiting message. */
207export const guestAttachmentUrl = (shareId: string, attachmentId: string) =>
208 `${GUEST_ATTACHMENT_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&id=${encodeURIComponent(attachmentId)}`;
209​
210/** Where the guest page loads an image file on the host that an agent message embeds. */
211export const guestMediaUrl = (shareId: string, path: string) =>
212 `${GUEST_MEDIA_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&path=${encodeURIComponent(path)}`;
213​
214/**
215 * Why these files cannot go with one message, or null. The server checks the same limits; this
216 * only says so before the upload.
217 */
218export function guestFilesProblem(
219 files: ReadonlyArray<Pick<File, "type" | "size">>,
220): string | null {
221 if (files.length > GUEST_MESSAGE_MAX_ATTACHMENTS) {
222 return `Attach up to ${GUEST_MESSAGE_MAX_ATTACHMENTS} files to a message.`;
223 }
224 if (files.reduce((total, file) => total + file.size, 0) > GUEST_MESSAGE_MAX_ATTACHMENT_BYTES) {
225 return "Attached files can total up to 10 MB per message.";
226 }
227 const unsupported = files.some(
228 (file) =>
229 file.size === 0 ||
230 (file.type.toLowerCase().startsWith("image/") &&
231 !isProviderSendTurnSupportedImageMimeType(file.type)),
232 );
233 return unsupported
234 ? "This file cannot be attached. Images must be PNG, JPEG, GIF, or WebP."
235 : null;
237​
238/**
239 * Image URLs for the thread's messages, which the timeline shows as previews. The map keeps its
240 * identity while the rows do, because the timeline rebuilds its entries when it changes.
241 */
242export function createGuestAttachmentUrls(shareId: string) {
243 let previousItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> | null = null;
244 let previous: ReadonlyMap<string, string> = new Map();
245 return (items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>): ReadonlyMap<string, string> => {
246 if (items === previousItems) return previous;
247 previousItems = items;
248 previous = new Map(
249 items.flatMap(({ item }) =>
250 item.type === "user_message"
251 ? item.attachments.map(
252 (attachment) => [attachment.id, guestAttachmentUrl(shareId, attachment.id)] as const,
253 )
254 : [],
255 ),
256 );
257 return previous;
258 };