@alia.onl/sdk 15.8.0 → 19.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +242 -13
- package/README.md +223 -151
- package/package.json +15 -5
- package/src/animations.ts +0 -1
- package/src/components/AliaAskProvider.tsx +127 -0
- package/src/components/AliaChatContent.tsx +604 -392
- package/src/components/AliaChatHeader.tsx +14 -38
- package/src/components/AliaChatScreen.tsx +56 -31
- package/src/components/AliaChatSheet.tsx +110 -36
- package/src/components/AliaComposer.tsx +1 -1
- package/src/components/AliaParticipants.tsx +202 -0
- package/src/components/AliaParticipantsHeader.tsx +60 -0
- package/src/components/AliaSharedConversation.tsx +86 -0
- package/src/components/AliaWelcomeMessage.tsx +18 -5
- package/src/components/Image.tsx +12 -0
- package/src/components/Markdown.tsx +498 -14
- package/src/components/PlanPreviewCard.tsx +7 -49
- package/src/components/ReplyMarkdown.tsx +359 -0
- package/src/components/RichBlocks.tsx +195 -0
- package/src/components/canvas/CanvasComponent.tsx +59 -0
- package/src/components/canvas/ChartRenderer.tsx +73 -0
- package/src/components/canvas/CodeRenderer.tsx +24 -0
- package/src/components/canvas/FormRenderer.tsx +101 -0
- package/src/components/canvas/MarkdownRenderer.tsx +14 -0
- package/src/components/canvas/TableRenderer.tsx +47 -0
- package/src/components/thought/FilesAndSources.tsx +120 -0
- package/src/components/thought/ThoughtPanel.tsx +417 -0
- package/src/components/thought/ToolStep.tsx +141 -0
- package/src/components/thread/AgentResultCard.tsx +125 -0
- package/src/components/thread/AgentTaskCard.tsx +127 -0
- package/src/components/thread/AliaThread.tsx +401 -0
- package/src/components/thread/MessageBlockBoundary.tsx +78 -0
- package/src/components/thread/NewConversationOffer.tsx +54 -0
- package/src/components/thread/ThreadRow.tsx +458 -0
- package/src/components/thread/ThreadSearch.tsx +129 -0
- package/src/components/thread/ToolResultCard.tsx +254 -0
- package/src/components/thread/TurnMenu.tsx +54 -0
- package/src/components/thread/TurnStatusLine.tsx +34 -0
- package/src/components/voice/AliaVoiceCall.tsx +21 -0
- package/src/components/voice/VoiceControls.tsx +2 -28
- package/src/components/voice/VoiceSession.tsx +39 -11
- package/src/hooks/useAliaActivity.ts +2 -2
- package/src/hooks/useAtBottom.ts +51 -0
- package/src/hooks/useAttachMenu.ts +94 -0
- package/src/hooks/useChatComposer.ts +299 -0
- package/src/hooks/useChatVoice.ts +129 -0
- package/src/hooks/useDocumentPicker.ts +3 -29
- package/src/hooks/useFailedTurnToast.ts +43 -0
- package/src/hooks/useMessageVote.ts +50 -0
- package/src/hooks/useOpenThought.ts +79 -0
- package/src/hooks/usePendingQuestions.ts +2 -2
- package/src/hooks/useReadAloud.ts +92 -0
- package/src/hooks/useShareHandler.ts +24 -0
- package/src/hooks/useSoundEffects.ts +1 -1
- package/src/hooks/useThoughtScope.ts +59 -0
- package/src/hooks/useThreadRows.ts +145 -0
- package/src/hooks/useThreadView.ts +68 -0
- package/src/hooks/useTimelineWindow.ts +256 -0
- package/src/hooks/useVoiceLauncher.ts +180 -0
- package/src/hooks/useVoiceRoom.ts +0 -3
- package/src/index.ts +43 -29
- package/src/lib/activity-store.ts +1 -1
- package/src/lib/add-menu.ts +7 -0
- package/src/lib/call-thread.ts +33 -0
- package/src/lib/citations.ts +79 -2
- package/src/lib/clipboard.ts +23 -0
- package/src/lib/content-utils.ts +10 -0
- package/src/lib/copy-en.ts +284 -0
- package/src/lib/copy.ts +32 -0
- package/src/lib/document-picker.ts +31 -0
- package/src/lib/flights.ts +22 -13
- package/src/lib/focus-return.ts +66 -0
- package/src/lib/keyboard.native.tsx +19 -0
- package/src/lib/keyboard.tsx +9 -0
- package/src/lib/labels.tsx +69 -17
- package/src/lib/participants.ts +86 -0
- package/src/lib/power-level-options.ts +66 -0
- package/src/lib/questions.ts +2 -2
- package/src/lib/task-labels.ts +4 -4
- package/src/lib/tool-call-status.ts +1 -1
- package/src/lib/tool-icons.ts +36 -0
- package/src/lib/tool-renderers.tsx +48 -0
- package/src/lib/turn-state.ts +2 -2
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +76 -34
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +32 -14
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +123 -0
- package/src/runtime/attachment-utils.ts +96 -0
- package/src/runtime/composer-draft-store.ts +270 -0
- package/src/runtime/config.tsx +186 -0
- package/src/runtime/conversation-store.ts +49 -0
- package/src/runtime/conversations.ts +557 -0
- package/src/runtime/error-utils.ts +104 -0
- package/src/runtime/host-tools.ts +140 -0
- package/src/runtime/index.ts +113 -0
- package/src/runtime/message-history.ts +124 -0
- package/src/runtime/model.ts +39 -0
- package/src/runtime/oxy-files.ts +37 -0
- package/src/runtime/power-levels.ts +66 -0
- package/src/runtime/query-keys.ts +34 -0
- package/src/runtime/random-uuid.ts +25 -0
- package/src/runtime/routes.ts +51 -0
- package/src/runtime/sharing.ts +165 -0
- package/src/runtime/sse-frame-reader.ts +88 -0
- package/src/runtime/stream-events.ts +146 -0
- package/src/runtime/stream-outcome.ts +21 -0
- package/src/runtime/thought-store.ts +103 -0
- package/src/runtime/thread/agent-activity.ts +261 -0
- package/src/runtime/thread/automation-labels.ts +79 -0
- package/src/runtime/thread/conversation-export.ts +135 -0
- package/src/runtime/thread/message-days.ts +120 -0
- package/src/runtime/thread/message-processor.ts +93 -0
- package/src/runtime/thread/task-utils.ts +11 -0
- package/src/runtime/thread/thought-utils.ts +752 -0
- package/src/runtime/thread/thread-history.ts +139 -0
- package/src/runtime/thread/timeline.ts +202 -0
- package/src/runtime/thread/title-tags.ts +8 -0
- package/src/runtime/thread/tool-cards.ts +383 -0
- package/src/runtime/turn-failure.ts +108 -0
- package/src/runtime/types.ts +148 -0
- package/src/runtime/usage-limit-error.ts +44 -0
- package/src/runtime/use-agent-activity.ts +80 -0
- package/src/runtime/use-conversation-stream.ts +1427 -0
- package/src/runtime/use-conversation-thread.ts +556 -0
- package/src/runtime/use-start-conversation.ts +107 -0
- package/src/runtime/use-thread-history.ts +64 -0
- package/src/runtime/use-thread-search.ts +95 -0
- package/src/runtime/use-turn-edit.ts +99 -0
- package/src/tools.ts +2 -2
- package/src/types.ts +18 -47
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/catalogue.ts +0 -236
- package/src/lib/chat-stream.ts +0 -655
- package/src/lib/chat-transport.ts +0 -98
- package/src/lib/delegation.ts +0 -34
- package/src/lib/work-log-copy.ts +0 -61
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Message } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The history of a thread: everything said before the stretch on screen.
|
|
5
|
+
*
|
|
6
|
+
* ## A thread is many conversations, and the screen only holds one
|
|
7
|
+
*
|
|
8
|
+
* `/@pepe` shows one continuous history, but underneath it is a run of ordinary
|
|
9
|
+
* conversations sharing an `agentId`, and the chat screen is streaming into the
|
|
10
|
+
* most recent one. `GET /agents/thread/:username/messages` is what crosses
|
|
11
|
+
* those seams; this module turns its pages into the list that goes above the
|
|
12
|
+
* live messages, and says where the joins are.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure. The fetching lives in `use-thread-history.ts` and
|
|
15
|
+
* `use-thread-search.ts`; the scrolling and the anchoring are Bloom's `AiChatThread`'s.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A message as the thread endpoint sends it.
|
|
20
|
+
*
|
|
21
|
+
* Two fields more than an ordinary message, and neither is decorative:
|
|
22
|
+
* `conversationId` is the only thing a client can draw a seam from — there is
|
|
23
|
+
* no table of breaks — and `cursor` addresses this exact message, which is what
|
|
24
|
+
* makes both paging and jumping to a search hit possible.
|
|
25
|
+
*
|
|
26
|
+
* `id` is OPTIONAL on the wire, because it is the CLIENT's id and a message
|
|
27
|
+
* written by the server never had one.
|
|
28
|
+
*/
|
|
29
|
+
export interface WireThreadMessage extends Omit<Message, 'id'> {
|
|
30
|
+
id?: string;
|
|
31
|
+
conversationId: string;
|
|
32
|
+
cursor: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What is behind the conversation on screen: the rest of an agent's thread. */
|
|
36
|
+
export interface ThreadHistory {
|
|
37
|
+
/** Everything said before the conversation on screen, oldest first. */
|
|
38
|
+
messages: ThreadMessage[];
|
|
39
|
+
/** A page is on its way; the reader is at the top waiting for it. */
|
|
40
|
+
loading: boolean;
|
|
41
|
+
/** Ask for the page above. Absent where nothing older remains, which also turns the anchor off. */
|
|
42
|
+
loadMore?: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One hit of a search in a thread. `messageId` is the client's id, which a message the server
|
|
47
|
+
* wrote has none of.
|
|
48
|
+
*/
|
|
49
|
+
export interface ThreadSearchHit {
|
|
50
|
+
messageId: string | null;
|
|
51
|
+
conversationId: string;
|
|
52
|
+
role: string;
|
|
53
|
+
snippet: string;
|
|
54
|
+
createdAt: string;
|
|
55
|
+
/** Where this message is, in the only form that can be asked for. */
|
|
56
|
+
cursor: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One page of history, oldest-first, with the cursor for the page above it. */
|
|
60
|
+
export interface ThreadPage {
|
|
61
|
+
messages: readonly WireThreadMessage[];
|
|
62
|
+
/** `null` when nothing older remains. Never inferred from a page's length. */
|
|
63
|
+
nextCursor: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A history message, once it is sure to have an id to be keyed and voted by. */
|
|
67
|
+
export interface ThreadMessage extends Message {
|
|
68
|
+
conversationId: string;
|
|
69
|
+
cursor: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The pages, flattened into the messages that go ABOVE the live conversation.
|
|
74
|
+
*
|
|
75
|
+
* Two orderings meet here and they run opposite ways: pages arrive newest-first
|
|
76
|
+
* (each one is the window before the last), while the messages inside a page
|
|
77
|
+
* are oldest-first. So the pages are walked backwards and their contents
|
|
78
|
+
* forwards, which is what puts the whole thing in reading order.
|
|
79
|
+
*
|
|
80
|
+
* **The active stretch is dropped.** Its messages are already on screen, held
|
|
81
|
+
* by the streaming hook, and they are the only ones that can still change —
|
|
82
|
+
* keeping the endpoint's copy too would show every one of them twice and freeze
|
|
83
|
+
* the duplicate at the state it was fetched in. Dropping them here is also what
|
|
84
|
+
* makes starting a new stretch work for free: the messages of the conversation
|
|
85
|
+
* that was active a moment ago stop matching, so they reappear as history
|
|
86
|
+
* without a second request.
|
|
87
|
+
*
|
|
88
|
+
* The cursor stands in for a missing id. A server-written message has no client
|
|
89
|
+
* id, `id` is what the list keys on and what the vote URL carries, and a cursor
|
|
90
|
+
* is unique to one message by construction.
|
|
91
|
+
*/
|
|
92
|
+
export function threadHistory(
|
|
93
|
+
pages: readonly ThreadPage[],
|
|
94
|
+
activeConversationId: string,
|
|
95
|
+
): ThreadMessage[] {
|
|
96
|
+
const history: ThreadMessage[] = [];
|
|
97
|
+
for (let page = pages.length - 1; page >= 0; page -= 1) {
|
|
98
|
+
for (const message of pages[page].messages) {
|
|
99
|
+
if (message.conversationId === activeConversationId) continue;
|
|
100
|
+
history.push({ ...message, id: message.id ?? message.cursor });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return history;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Which messages BEGIN a new conversation, keyed by id.
|
|
108
|
+
*
|
|
109
|
+
* A seam is deduced from the data — the conversation a message belongs to
|
|
110
|
+
* changed — and never from elapsed time. "More than N hours passed" is the
|
|
111
|
+
* tempting version and it lies the day somebody returns a week later to finish
|
|
112
|
+
* the same thought: the model is still being given that conversation, so the
|
|
113
|
+
* screen would draw a break where the context has none.
|
|
114
|
+
*
|
|
115
|
+
* The topmost loaded message never carries one, deliberately: whether a break
|
|
116
|
+
* sits above it is a fact about the page that has not been loaded yet. It
|
|
117
|
+
* appears when that page arrives, which is the only moment it is known.
|
|
118
|
+
*
|
|
119
|
+
* The live messages are one stretch, so at most one seam concerns them — above
|
|
120
|
+
* the first — and it is drawn only when there is history above to be separated
|
|
121
|
+
* from.
|
|
122
|
+
*/
|
|
123
|
+
export function threadSeamIds(
|
|
124
|
+
history: readonly ThreadMessage[],
|
|
125
|
+
live: readonly { id: string }[],
|
|
126
|
+
activeConversationId: string,
|
|
127
|
+
): Set<string> {
|
|
128
|
+
const seams = new Set<string>();
|
|
129
|
+
let previous: string | null = null;
|
|
130
|
+
for (const message of history) {
|
|
131
|
+
if (previous !== null && message.conversationId !== previous) seams.add(message.id);
|
|
132
|
+
previous = message.conversationId;
|
|
133
|
+
}
|
|
134
|
+
const firstLive = live[0];
|
|
135
|
+
if (firstLive !== undefined && previous !== null && previous !== activeConversationId) {
|
|
136
|
+
seams.add(firstLive.id);
|
|
137
|
+
}
|
|
138
|
+
return seams;
|
|
139
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { Message } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The chat timeline as data: which rows are mounted, which just arrived, and
|
|
5
|
+
* which facts about the list a streamed token can and cannot change.
|
|
6
|
+
*
|
|
7
|
+
* Everything here is pure. `hooks/useTimelineWindow.ts` holds the state
|
|
8
|
+
* and talks to the thread; `components/thread/AliaThread.tsx` draws the rows.
|
|
9
|
+
*
|
|
10
|
+
* ## Why a paged window and not virtualization
|
|
11
|
+
*
|
|
12
|
+
* Bloom's `AiChatThread` is a plain `ScrollView` on native and the document on
|
|
13
|
+
* web, with no virtualization and no `scrollToIndex` (#608, gap 3 in
|
|
14
|
+
* `docs/bloom-adoption.mdx`). A spacer-based virtualizer on top of it would
|
|
15
|
+
* swap estimated heights for measured ones ABOVE the reader, and the thread
|
|
16
|
+
* only compensates growth it asked for — so every estimate that was wrong
|
|
17
|
+
* would move the text being read. What the thread does anchor exactly is a
|
|
18
|
+
* page landing above after `onStartReached`, and that is the one move this
|
|
19
|
+
* window makes: it mounts the newest rows, and reveals older ones a page at a
|
|
20
|
+
* time when the reader comes near the top, through the same anchored growth
|
|
21
|
+
* history paging already uses. Rows are only ever dropped from the top while
|
|
22
|
+
* the reader is at the bottom, where nothing above them is on screen.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** How many rows a thread opens with, counted from the newest. */
|
|
26
|
+
export const INITIAL_ROWS = 40;
|
|
27
|
+
/** How many older rows each approach to the top reveals. */
|
|
28
|
+
export const PAGE_ROWS = 30;
|
|
29
|
+
/** Rows kept above a cursor-jump target, so it does not land on the window's edge. */
|
|
30
|
+
export const FOCUS_MARGIN = 5;
|
|
31
|
+
/**
|
|
32
|
+
* How far past its opening size the window may grow before it is folded back.
|
|
33
|
+
* Folding is only done at the bottom with no turn in flight, so a reader who
|
|
34
|
+
* went up a long way does not keep every row they passed mounted forever.
|
|
35
|
+
*/
|
|
36
|
+
export const COLLAPSE_AFTER = INITIAL_ROWS + 2 * PAGE_ROWS;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where the mounted range starts.
|
|
40
|
+
*
|
|
41
|
+
* - `tail`: the newest `INITIAL_ROWS`. Only held until the rows are known;
|
|
42
|
+
* the hook pins it to a row as soon as there are rows, so rows appended by
|
|
43
|
+
* a turn grow the window instead of sliding it.
|
|
44
|
+
* - `top`: from the first row loaded. Set when the reader has reached the top
|
|
45
|
+
* and asked for history, so the page that lands is shown.
|
|
46
|
+
* - `row`: from this row, by id — ids survive rows being added on either side.
|
|
47
|
+
*/
|
|
48
|
+
export type TimelineAnchor = { kind: 'tail' } | { kind: 'top' } | { kind: 'row'; id: string };
|
|
49
|
+
|
|
50
|
+
export const TAIL: TimelineAnchor = Object.freeze({ kind: 'tail' });
|
|
51
|
+
export const TOP: TimelineAnchor = Object.freeze({ kind: 'top' });
|
|
52
|
+
|
|
53
|
+
/** The index of the first mounted row. */
|
|
54
|
+
export function resolveStart(
|
|
55
|
+
anchor: TimelineAnchor,
|
|
56
|
+
ids: readonly string[],
|
|
57
|
+
focusIndex = -1,
|
|
58
|
+
): number {
|
|
59
|
+
let start: number;
|
|
60
|
+
if (anchor.kind === 'top') start = 0;
|
|
61
|
+
else if (anchor.kind === 'row') {
|
|
62
|
+
const index = ids.indexOf(anchor.id);
|
|
63
|
+
// A row that is gone (a refetched history, a replaced conversation) falls
|
|
64
|
+
// back to the newest page rather than to everything.
|
|
65
|
+
start = index === -1 ? tailStart(ids.length) : index;
|
|
66
|
+
} else start = tailStart(ids.length);
|
|
67
|
+
// A jump's target is always mounted, with a few rows of context above it.
|
|
68
|
+
if (focusIndex !== -1) start = Math.min(start, Math.max(0, focusIndex - FOCUS_MARGIN));
|
|
69
|
+
return start;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tailStart(count: number): number {
|
|
73
|
+
return Math.max(0, count - INITIAL_ROWS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The anchor that keeps exactly the rows mounted now, from the row at `start`. */
|
|
77
|
+
export function pinAnchor(ids: readonly string[], start: number): TimelineAnchor {
|
|
78
|
+
if (start <= 0 || ids.length === 0) return TOP;
|
|
79
|
+
return { kind: 'row', id: ids[start] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The reader came near the top: one more page of the rows already loaded, or,
|
|
84
|
+
* with none left above, `null` — the thread's own history is what comes next.
|
|
85
|
+
*/
|
|
86
|
+
export function revealAbove(ids: readonly string[], start: number): TimelineAnchor | null {
|
|
87
|
+
if (start <= 0) return null;
|
|
88
|
+
return pinAnchor(ids, Math.max(0, start - PAGE_ROWS));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Fold a window that grew back to its opening size, or `null` if it has not grown enough. */
|
|
92
|
+
export function collapseToTail(ids: readonly string[], start: number): TimelineAnchor | null {
|
|
93
|
+
if (ids.length - start <= COLLAPSE_AFTER) return null;
|
|
94
|
+
return pinAnchor(ids, tailStart(ids.length));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The facts about a message the timeline's derived data is built from — the
|
|
99
|
+
* day separators, the seams, the turn timings, which answer is the last of
|
|
100
|
+
* Alia's — and nothing a token changes.
|
|
101
|
+
*/
|
|
102
|
+
type Shape = Pick<Message, 'id' | 'role' | 'createdAt' | 'agentInfo'>;
|
|
103
|
+
|
|
104
|
+
function sameShape(a: Shape, b: Shape): boolean {
|
|
105
|
+
return (
|
|
106
|
+
a === b ||
|
|
107
|
+
(a.id === b.id &&
|
|
108
|
+
a.role === b.role &&
|
|
109
|
+
a.createdAt === b.createdAt &&
|
|
110
|
+
a.agentInfo?.id === b.agentInfo?.id)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether two versions of the list differ only in what their messages SAY.
|
|
116
|
+
*
|
|
117
|
+
* A streamed token replaces the last message object and the array around it,
|
|
118
|
+
* so identity alone says "changed" twenty times a second. This compares the
|
|
119
|
+
* fields the derived data reads; the per-element check is a pointer comparison
|
|
120
|
+
* for every message but the one being written, which is why it costs next to
|
|
121
|
+
* nothing where recomputing the derivations cost a pass over the whole thread.
|
|
122
|
+
*/
|
|
123
|
+
export function sameTimelineShape(previous: readonly Shape[], next: readonly Shape[]): boolean {
|
|
124
|
+
if (previous === next) return true;
|
|
125
|
+
if (previous.length !== next.length) return false;
|
|
126
|
+
for (let i = 0; i < next.length; i += 1) {
|
|
127
|
+
if (!sameShape(previous[i], next[i])) return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The ids that ARRIVED in this render — the rows that may animate in.
|
|
134
|
+
*
|
|
135
|
+
* Not "every id the last render did not have". Three things change the list
|
|
136
|
+
* without anything arriving, and each would otherwise replay every message as
|
|
137
|
+
* if it had just been said:
|
|
138
|
+
*
|
|
139
|
+
* - the first render of a screen: everything on it is already there;
|
|
140
|
+
* - switching conversation in place: the list is REPLACED, so nothing it
|
|
141
|
+
* shares with the last one is the tell;
|
|
142
|
+
* - a conversation's messages landing after its load: the list fills from
|
|
143
|
+
* empty in one batch, where a send adds a question and the answer's
|
|
144
|
+
* placeholder — never more than two.
|
|
145
|
+
*/
|
|
146
|
+
export function arrivedIds(
|
|
147
|
+
previous: readonly string[] | null,
|
|
148
|
+
previousLoading: boolean,
|
|
149
|
+
next: readonly { id: string }[],
|
|
150
|
+
): ReadonlySet<string> {
|
|
151
|
+
if (previous === null || previousLoading) return EMPTY_IDS;
|
|
152
|
+
const known = new Set(previous);
|
|
153
|
+
const arrived = next.filter((m) => !known.has(m.id));
|
|
154
|
+
if (arrived.length === 0) return EMPTY_IDS;
|
|
155
|
+
const replaced = previous.length > 0 && arrived.length === next.length;
|
|
156
|
+
const loaded = previous.length === 0 && arrived.length > 2;
|
|
157
|
+
if (replaced || loaded) return EMPTY_IDS;
|
|
158
|
+
return new Set(arrived.map((m) => m.id));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Where the reader was in a conversation, kept for the session so returning to
|
|
165
|
+
* it lands there instead of at whatever the scroller last held.
|
|
166
|
+
*
|
|
167
|
+
* `probeId` is a row that was mounted when this was saved. The position is only
|
|
168
|
+
* restored once that row is on screen again, because until then the list may
|
|
169
|
+
* still be the previous conversation's — the streaming hook swaps its messages
|
|
170
|
+
* in an effect, a render after the conversation id changes.
|
|
171
|
+
*/
|
|
172
|
+
export interface TimelinePosition {
|
|
173
|
+
anchor: TimelineAnchor;
|
|
174
|
+
/** px from the thread's top, in the frame `scrollToOffset` takes. */
|
|
175
|
+
offset: number;
|
|
176
|
+
atBottom: boolean;
|
|
177
|
+
probeId: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The session's saved positions, by conversation. Never persisted: a reload
|
|
182
|
+
* opens at the newest turn. Not partitioned by account either, because a
|
|
183
|
+
* conversation id belongs to one account and an offset says nothing about what
|
|
184
|
+
* the conversation contains.
|
|
185
|
+
*/
|
|
186
|
+
export class TimelinePositions {
|
|
187
|
+
private readonly positions = new Map<string, TimelinePosition>();
|
|
188
|
+
|
|
189
|
+
save(key: string, position: TimelinePosition): void {
|
|
190
|
+
this.positions.set(key, position);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
read(key: string): TimelinePosition | undefined {
|
|
194
|
+
return this.positions.get(key);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
clear(): void {
|
|
198
|
+
this.positions.clear();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const timelinePositions = new TimelinePositions();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Known translations of "TITLE" that LLMs may produce
|
|
2
|
+
const TAG = String.raw`ALIA_TITLE|TITLE|TÍTULO|TITRE|TITOLO|TITEL|ЗАГОЛОВОК`;
|
|
3
|
+
|
|
4
|
+
/** Matches complete [TITLE]...[/TITLE] and <TITLE>...</TITLE> tags (including translated variants) */
|
|
5
|
+
export const TITLE_STRIP_RE = new RegExp(
|
|
6
|
+
String.raw`\[(${TAG})\].*?\[\/\1\]|<(${TAG})>.*?<\/\2>`,
|
|
7
|
+
'gi',
|
|
8
|
+
);
|