@alia.onl/sdk 15.8.0 → 19.1.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 +262 -13
- package/README.md +223 -151
- package/package.json +16 -6
- 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 +4 -4
- 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 +98 -35
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +34 -15
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +141 -0
- package/src/runtime/app-assertion.ts +53 -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 +1433 -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,92 @@
|
|
|
1
|
+
import { toast } from '@oxy.so/bloom/toast';
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { useLabels } from '../lib/labels';
|
|
4
|
+
import { useAliaRuntime } from '../runtime/config';
|
|
5
|
+
import { useDictationStore } from './useDictation';
|
|
6
|
+
import { useTTS } from './useTTS';
|
|
7
|
+
|
|
8
|
+
export type ReadAloudState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The screen whose player is sounding, if any.
|
|
12
|
+
*
|
|
13
|
+
* `useTTS` keeps its player PER HOOK INSTANCE while the playback state is one shared store — and
|
|
14
|
+
* a host can keep every visited chat mounted, each with its own thread and so its own instance.
|
|
15
|
+
* Starting a reply in one chat must stop a reply still playing in another, and only the instance
|
|
16
|
+
* that owns a player can stop it. So the owner registers its `stop` here, and anyone may call it.
|
|
17
|
+
*/
|
|
18
|
+
let owner: { stop: () => void } | null = null;
|
|
19
|
+
|
|
20
|
+
/** Silence whatever a thread is reading aloud, on any mounted screen. */
|
|
21
|
+
export function stopReadingAloud(): void {
|
|
22
|
+
const current = owner;
|
|
23
|
+
owner = null;
|
|
24
|
+
current?.stop();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read one reply aloud, as a toggle.
|
|
29
|
+
*
|
|
30
|
+
* - A press starts it; a second press on the same reply stops it — not `useTTS`'s pause, which
|
|
31
|
+
* would leave a half-read reply parked with nothing on screen saying so.
|
|
32
|
+
* - `blocked` is a call or a dictation holding the audio: the button is disabled, and anything
|
|
33
|
+
* already being read stops when either starts. Two voices at once, or the microphone recording
|
|
34
|
+
* Alia's own voice into the draft, is the thing this prevents.
|
|
35
|
+
* - A failure is a toast, and the button goes back to idle.
|
|
36
|
+
*
|
|
37
|
+
* It speaks with the runtime's `apiUrl` and the host's `speech` preferences.
|
|
38
|
+
*/
|
|
39
|
+
export function useReadAloud(blocked: boolean) {
|
|
40
|
+
const { t } = useLabels();
|
|
41
|
+
const { apiUrl, speech } = useAliaRuntime();
|
|
42
|
+
const { readAloud, stop, activeMessageId, playbackState } = useTTS({ apiUrl, ...speech });
|
|
43
|
+
const self = useRef<{ stop: () => void }>({ stop });
|
|
44
|
+
self.current.stop = stop;
|
|
45
|
+
const owns = owner === self.current && activeMessageId !== null;
|
|
46
|
+
|
|
47
|
+
// A call or a dictation began: whatever is being read stops, wherever.
|
|
48
|
+
const dictating = useDictationStore((s) => s.isRecording);
|
|
49
|
+
const busy = blocked || dictating;
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (busy) stopReadingAloud();
|
|
52
|
+
}, [busy]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!owns || playbackState !== 'error') return;
|
|
56
|
+
toast.error(t('chat.readAloudFailed'));
|
|
57
|
+
stopReadingAloud();
|
|
58
|
+
}, [owns, playbackState, t]);
|
|
59
|
+
|
|
60
|
+
// Leaving takes the player with it (`useTTS` releases it); the shared state has to go too, or
|
|
61
|
+
// every other screen would show a reply still playing.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const me = self.current;
|
|
64
|
+
return () => {
|
|
65
|
+
if (owner === me) stopReadingAloud();
|
|
66
|
+
};
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const active = useRef(activeMessageId);
|
|
70
|
+
active.current = activeMessageId;
|
|
71
|
+
const toggle = useCallback(
|
|
72
|
+
(messageId: string, text: string, conversationId?: string) => {
|
|
73
|
+
const wasThis = active.current === messageId;
|
|
74
|
+
stopReadingAloud();
|
|
75
|
+
if (wasThis) return;
|
|
76
|
+
owner = self.current;
|
|
77
|
+
// The route makes a clip once and answers every later press — here or after a reload — from
|
|
78
|
+
// storage. `conversationId` files it under the conversation, so deleting that deletes it;
|
|
79
|
+
// the caller passes it only for a message the server holds (`Message.unsaved`): for one it
|
|
80
|
+
// does not, the route answers 404 and nothing would be read.
|
|
81
|
+
void readAloud(messageId, text, conversationId);
|
|
82
|
+
},
|
|
83
|
+
[readAloud],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const stateOf = useCallback(
|
|
87
|
+
(messageId: string): ReadAloudState => (activeMessageId === messageId ? playbackState : 'idle'),
|
|
88
|
+
[activeMessageId, playbackState],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return { toggle, stateOf, blocked: busy };
|
|
92
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { toast } from '@oxy.so/bloom/toast';
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { useLabels } from '../lib/labels';
|
|
4
|
+
import { type AliaConversationShare, useShareConversation } from '../runtime/sharing';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the header's share button does: mint the conversation's read-only link, then hand it to
|
|
8
|
+
* the host, which builds its own URL and shows its own share sheet. `undefined` without a host
|
|
9
|
+
* `onShare`, so there is no button.
|
|
10
|
+
*/
|
|
11
|
+
export function useShareHandler(
|
|
12
|
+
onShare: ((share: AliaConversationShare) => void) | undefined,
|
|
13
|
+
): ((conversationId: string | undefined) => void) | undefined {
|
|
14
|
+
const { share } = useShareConversation();
|
|
15
|
+
const { t } = useLabels();
|
|
16
|
+
const handle = useCallback(
|
|
17
|
+
(conversationId: string | undefined) => {
|
|
18
|
+
if (conversationId === undefined || onShare === undefined) return;
|
|
19
|
+
void share(conversationId).then(onShare, () => toast.error(t('chat.shareFailed')));
|
|
20
|
+
},
|
|
21
|
+
[share, onShare, t],
|
|
22
|
+
);
|
|
23
|
+
return onShare === undefined ? undefined : handle;
|
|
24
|
+
}
|
|
@@ -91,7 +91,7 @@ export function useSoundEffects(enabled: boolean = false, sounds: SoundSources =
|
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Hook that automatically triggers sound effects based on voice state changes.
|
|
94
|
-
* Wire this into the conversation page
|
|
94
|
+
* Wire this into the conversation page from the launcher's state (`useVoiceLauncher`).
|
|
95
95
|
*/
|
|
96
96
|
export function useVoiceSoundEffects({
|
|
97
97
|
isVoiceActive,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useEffect, useMemo } from 'react';
|
|
3
|
+
import { aliaQueryKeys } from '../runtime/query-keys';
|
|
4
|
+
import { type ThoughtScope, useThoughtStore } from '../runtime/thought-store';
|
|
5
|
+
import type { FailedTurn } from '../runtime/turn-failure';
|
|
6
|
+
import type { Message } from '../runtime/types';
|
|
7
|
+
|
|
8
|
+
export interface UseThoughtScopeOptions {
|
|
9
|
+
/** The conversation being streamed into, or `undefined` for a new chat. */
|
|
10
|
+
conversationId: string | undefined;
|
|
11
|
+
messages: Message[];
|
|
12
|
+
conversationLoading: boolean;
|
|
13
|
+
isLoading: boolean;
|
|
14
|
+
failedTurn: FailedTurn | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* This conversation as the thought panel reads it: its messages, whether they are all here yet,
|
|
19
|
+
* and the turn in flight — kept in the thought store.
|
|
20
|
+
*
|
|
21
|
+
* Keyed on the conversation and its messages, NOT on the panel being open. Syncing only while
|
|
22
|
+
* the panel was open meant the first press on a tool row rendered against whatever the store held
|
|
23
|
+
* before — another conversation's messages, or none — and found nothing by id. The store accepts
|
|
24
|
+
* this only while the open selection belongs to the same conversation, so a new-chat screen
|
|
25
|
+
* mounted underneath cannot blank a panel opened here.
|
|
26
|
+
*
|
|
27
|
+
* A load that failed is read off the query cache at the moment the memo recomputes: the load's
|
|
28
|
+
* end is what flips `conversationLoading`, so the read is fresh exactly when it matters.
|
|
29
|
+
*/
|
|
30
|
+
export function useThoughtScope({
|
|
31
|
+
conversationId,
|
|
32
|
+
messages,
|
|
33
|
+
conversationLoading,
|
|
34
|
+
isLoading,
|
|
35
|
+
failedTurn,
|
|
36
|
+
}: UseThoughtScopeOptions): ThoughtScope {
|
|
37
|
+
const queryClient = useQueryClient();
|
|
38
|
+
const sync = useThoughtStore((s) => s.sync);
|
|
39
|
+
|
|
40
|
+
const scope = useMemo<ThoughtScope>(() => {
|
|
41
|
+
const loadFailed =
|
|
42
|
+
conversationId !== undefined &&
|
|
43
|
+
queryClient.getQueryState(aliaQueryKeys.conversations.detail(conversationId))?.status ===
|
|
44
|
+
'error';
|
|
45
|
+
return {
|
|
46
|
+
conversationId: conversationId ?? null,
|
|
47
|
+
messages,
|
|
48
|
+
status: conversationLoading ? 'loading' : loadFailed ? 'failed' : 'ready',
|
|
49
|
+
isLoading,
|
|
50
|
+
failedTurn,
|
|
51
|
+
};
|
|
52
|
+
}, [conversationId, messages, conversationLoading, isLoading, failedTurn, queryClient]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
sync(scope);
|
|
56
|
+
}, [scope, sync]);
|
|
57
|
+
|
|
58
|
+
return scope;
|
|
59
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { useLabels } from '../lib/labels';
|
|
3
|
+
import {
|
|
4
|
+
type Message,
|
|
5
|
+
type ThreadMessage,
|
|
6
|
+
arrivedIds,
|
|
7
|
+
daySeparators,
|
|
8
|
+
threadSeamIds,
|
|
9
|
+
turnTimings,
|
|
10
|
+
} from '../runtime/model';
|
|
11
|
+
import { useTimelineShape } from './useTimelineWindow';
|
|
12
|
+
|
|
13
|
+
/** The history of a conversation that has none, as ONE array: a fresh `[]` per render would break every memo. */
|
|
14
|
+
const NO_HISTORY: ThreadMessage[] = [];
|
|
15
|
+
|
|
16
|
+
/** For a row the timings map has never heard of: a shared frozen object keeps memoised rows still. */
|
|
17
|
+
const EMPTY_TIMING = Object.freeze({ startedAt: null, endedAt: null });
|
|
18
|
+
|
|
19
|
+
/** True for Alia's own replies (a delegated agent's are not). */
|
|
20
|
+
function isAliaReply(message: Message): boolean {
|
|
21
|
+
return message.role === 'assistant' && !message.agentInfo;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseThreadRowsOptions {
|
|
25
|
+
/** The conversation being streamed into. */
|
|
26
|
+
messages: readonly Message[];
|
|
27
|
+
/** Everything said in the thread before it, oldest first. Empty where nothing is behind it. */
|
|
28
|
+
history: readonly ThreadMessage[] | undefined;
|
|
29
|
+
/** The conversation being streamed into: where the seam between the history and the live rows falls. */
|
|
30
|
+
conversationId: string | undefined;
|
|
31
|
+
/** The conversation's messages are still on their way. */
|
|
32
|
+
conversationLoading: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the thread draws, derived once per change of its STRUCTURE.
|
|
37
|
+
*
|
|
38
|
+
* The live list as ids, roles, stamps and speakers is what a streamed token never changes, so
|
|
39
|
+
* everything derived from the whole thread (day lines, seams, turn timings, the last answer of
|
|
40
|
+
* Alia's, the window's ids) is keyed on that rather than on the messages: a token re-derives
|
|
41
|
+
* nothing, it re-renders the row it was written into and that is all.
|
|
42
|
+
*/
|
|
43
|
+
export function useThreadRows({
|
|
44
|
+
messages,
|
|
45
|
+
history: historyMessages,
|
|
46
|
+
conversationId,
|
|
47
|
+
conversationLoading,
|
|
48
|
+
}: UseThreadRowsOptions) {
|
|
49
|
+
const { t, locale } = useLabels();
|
|
50
|
+
const history = historyMessages ?? NO_HISTORY;
|
|
51
|
+
|
|
52
|
+
const live = useMemo(
|
|
53
|
+
() => messages.filter((m) => m.role === 'user' || m.role === 'assistant'),
|
|
54
|
+
[messages],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Everything on screen, in reading order: the history first, the conversation being streamed
|
|
59
|
+
* into after it. Every position below — which row is last, which is the last of Alia's — is an
|
|
60
|
+
* index into THIS, not into the live messages.
|
|
61
|
+
*/
|
|
62
|
+
const rows = useMemo<Message[]>(
|
|
63
|
+
() => (history.length === 0 ? live : [...history, ...live]),
|
|
64
|
+
[history, live],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const liveShape = useTimelineShape(live);
|
|
68
|
+
const shape = useMemo<readonly Message[]>(
|
|
69
|
+
() => (history.length === 0 ? liveShape : [...history, ...liveShape]),
|
|
70
|
+
[history, liveShape],
|
|
71
|
+
);
|
|
72
|
+
const ids = useMemo(() => shape.map((m) => m.id), [shape]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The live rows that arrived since the last change to the list, which are the only ones that
|
|
76
|
+
* animate in. A history row never does, and neither does a conversation that was loaded or
|
|
77
|
+
* switched to (`arrivedIds`).
|
|
78
|
+
*/
|
|
79
|
+
const previousLive = useRef<{ ids: readonly string[]; loading: boolean } | null>(null);
|
|
80
|
+
const arrived = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
arrivedIds(
|
|
83
|
+
previousLive.current?.ids ?? null,
|
|
84
|
+
previousLive.current?.loading ?? false,
|
|
85
|
+
liveShape,
|
|
86
|
+
),
|
|
87
|
+
[liveShape],
|
|
88
|
+
);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
previousLive.current = { ids: liveShape.map((m) => m.id), loading: conversationLoading };
|
|
91
|
+
}, [liveShape, conversationLoading]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every turn's start and end, computed once for the whole thread: asked per row it is O(n²), and
|
|
95
|
+
* this list re-renders roughly twenty times a second while an answer streams.
|
|
96
|
+
*/
|
|
97
|
+
const timings = useMemo(() => turnTimings(shape), [shape]);
|
|
98
|
+
|
|
99
|
+
/** The day each message starts, when it starts a new one: Bloom's inline date line above it. */
|
|
100
|
+
const dayLabels = useMemo(() => {
|
|
101
|
+
const labels = new Map<string, string>();
|
|
102
|
+
for (const { messageId, label } of daySeparators(shape, new Date(), locale)) {
|
|
103
|
+
labels.set(
|
|
104
|
+
messageId,
|
|
105
|
+
label.kind === 'today'
|
|
106
|
+
? t('chat.today')
|
|
107
|
+
: label.kind === 'yesterday'
|
|
108
|
+
? t('chat.yesterday')
|
|
109
|
+
: label.text,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return labels;
|
|
113
|
+
}, [shape, locale, t]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where one conversation of a thread ends and the next begins: the model was given a fresh
|
|
117
|
+
* context there, and everything above is out of its sight (`threadSeamIds`).
|
|
118
|
+
*/
|
|
119
|
+
const seamIds = useMemo(
|
|
120
|
+
() => threadSeamIds(history, liveShape, conversationId ?? ''),
|
|
121
|
+
[history, liveShape, conversationId],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const timingOf = useCallback(
|
|
125
|
+
(messageId: string) => timings.get(messageId) ?? EMPTY_TIMING,
|
|
126
|
+
[timings],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const lastReplyIndex = useMemo(
|
|
130
|
+
() => shape.reduce((acc, m, i) => (isAliaReply(m) ? i : acc), -1),
|
|
131
|
+
[shape],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
rows,
|
|
136
|
+
live,
|
|
137
|
+
history,
|
|
138
|
+
ids,
|
|
139
|
+
arrived,
|
|
140
|
+
dayLabels,
|
|
141
|
+
seamIds,
|
|
142
|
+
lastReplyIndex,
|
|
143
|
+
timingOf,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import type { ThreadHistory, ThreadSearchHit } from '../runtime/thread/thread-history';
|
|
3
|
+
import { useThreadHistory } from '../runtime/use-thread-history';
|
|
4
|
+
import { useThreadWindow } from '../runtime/use-thread-search';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the thread looks at: the present, or a moment in its past.
|
|
8
|
+
*
|
|
9
|
+
* An agent's thread has two states and they are not variations of one. At the present, the
|
|
10
|
+
* history runs into the conversation being streamed into. At a moment — a hit of the thread's
|
|
11
|
+
* search — the screen shows the stretch around one old message and there is nothing live beneath
|
|
12
|
+
* it. The way back is leaving, not scrolling: the endpoint pages BACKWARDS only, so walking
|
|
13
|
+
* forward from a jump would be a road that ends.
|
|
14
|
+
*
|
|
15
|
+
* `threadHandle` is the agent's handle; without it the chat is one conversation with nothing
|
|
16
|
+
* behind it and there is nothing to page into, search or jump to.
|
|
17
|
+
*/
|
|
18
|
+
export function useThreadView(
|
|
19
|
+
threadHandle: string | undefined,
|
|
20
|
+
conversationId: string | undefined,
|
|
21
|
+
) {
|
|
22
|
+
const present = useThreadHistory(threadHandle, conversationId);
|
|
23
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
24
|
+
/** The message the reader jumped to, by cursor, or `null` for the present. */
|
|
25
|
+
const [jumpedTo, setJumpedTo] = useState<string | null>(null);
|
|
26
|
+
const past = useThreadWindow(threadHandle, jumpedTo);
|
|
27
|
+
const jumped = jumpedTo !== null;
|
|
28
|
+
|
|
29
|
+
// Ask for the page above, unless one is already coming. Bloom fires `onStartReached` once per
|
|
30
|
+
// approach and re-arms when the reader leaves the zone, so the guards left are about the DATA.
|
|
31
|
+
const source = jumped
|
|
32
|
+
? { ...past, loading: past.isLoadingMore || past.isLoading }
|
|
33
|
+
: { ...present, loading: present.isLoadingMore };
|
|
34
|
+
const { messages, hasMore, loading, loadMore: fetchMore } = source;
|
|
35
|
+
const loadMore = useCallback(() => {
|
|
36
|
+
if (!loading) void fetchMore();
|
|
37
|
+
}, [loading, fetchMore]);
|
|
38
|
+
|
|
39
|
+
const history = useMemo<ThreadHistory | undefined>(
|
|
40
|
+
() =>
|
|
41
|
+
threadHandle === undefined
|
|
42
|
+
? undefined
|
|
43
|
+
: { messages, loading, ...(hasMore ? { loadMore } : {}) },
|
|
44
|
+
[threadHandle, messages, loading, hasMore, loadMore],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Open the thread around a hit, and close the search over it. The cursor rather than the
|
|
49
|
+
* message id: an id addresses a message, and only a cursor addresses a POSITION, which is what
|
|
50
|
+
* a window is asked for.
|
|
51
|
+
*/
|
|
52
|
+
const jump = useCallback((hit: ThreadSearchHit) => {
|
|
53
|
+
setJumpedTo(hit.cursor);
|
|
54
|
+
setSearchOpen(false);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
history,
|
|
59
|
+
/** The cursor jumped to, or `null` at the present. */
|
|
60
|
+
focusCursor: jumpedTo,
|
|
61
|
+
jumped,
|
|
62
|
+
jump,
|
|
63
|
+
backToLatest: useCallback(() => setJumpedTo(null), []),
|
|
64
|
+
searchOpen,
|
|
65
|
+
openSearch: useCallback(() => setSearchOpen(true), []),
|
|
66
|
+
closeSearch: useCallback(() => setSearchOpen(false), []),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Message,
|
|
3
|
+
TAIL,
|
|
4
|
+
TOP,
|
|
5
|
+
type TimelineAnchor,
|
|
6
|
+
type TimelinePosition,
|
|
7
|
+
collapseToTail,
|
|
8
|
+
pinAnchor,
|
|
9
|
+
resolveStart,
|
|
10
|
+
revealAbove,
|
|
11
|
+
sameTimelineShape,
|
|
12
|
+
timelinePositions,
|
|
13
|
+
} from '../runtime/model';
|
|
14
|
+
import type { AiChatThreadHandle } from '@oxy.so/bloom/ai-chat';
|
|
15
|
+
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
|
16
|
+
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The list, handed back as the SAME array for as long as only what its
|
|
20
|
+
* messages say has changed (`sameTimelineShape`).
|
|
21
|
+
*
|
|
22
|
+
* The array it returns may hold an older copy of the message being streamed
|
|
23
|
+
* into, so it is for deriving structure — ids, roles, stamps, speakers — and
|
|
24
|
+
* never for drawing text.
|
|
25
|
+
*/
|
|
26
|
+
export function useTimelineShape<
|
|
27
|
+
T extends Pick<Message, 'id' | 'role' | 'createdAt' | 'agentInfo'>,
|
|
28
|
+
>(list: readonly T[]): readonly T[] {
|
|
29
|
+
const shape = useRef(list);
|
|
30
|
+
if (!sameTimelineShape(shape.current, list)) shape.current = list;
|
|
31
|
+
return shape.current;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Options {
|
|
35
|
+
/** Every row's id, in reading order. Keep its identity stable across tokens. */
|
|
36
|
+
ids: readonly string[];
|
|
37
|
+
/**
|
|
38
|
+
* What a saved position belongs to — the conversation — or `null` for a view
|
|
39
|
+
* that is not remembered (a cursor jump's window of the past).
|
|
40
|
+
*/
|
|
41
|
+
scrollKey: string | null;
|
|
42
|
+
/** The conversation's messages have loaded; a restore waits for it. */
|
|
43
|
+
ready: boolean;
|
|
44
|
+
/** Index of a cursor jump's target row, or -1. It is always mounted. */
|
|
45
|
+
focusIndex: number;
|
|
46
|
+
/** A turn is streaming; the window is never folded under it. */
|
|
47
|
+
turnInFlight: boolean;
|
|
48
|
+
atBottomThreshold: number;
|
|
49
|
+
threadRef: RefObject<AiChatThreadHandle | null>;
|
|
50
|
+
/** Ask for the page of history above the first loaded row. */
|
|
51
|
+
onLoadHistory?: () => void;
|
|
52
|
+
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What is still to be done to the scroll once the rows it needs are laid out.
|
|
57
|
+
* A restore remembers the list it was set against: that list may still be the
|
|
58
|
+
* previous conversation's, and only a list that changed after it can say the
|
|
59
|
+
* saved row is really gone.
|
|
60
|
+
*/
|
|
61
|
+
type PendingScroll =
|
|
62
|
+
| { kind: 'end' }
|
|
63
|
+
| { kind: 'offset'; position: TimelinePosition; setAgainst: readonly string[] };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A restore is waiting for its conversation's rows: the row it was saved
|
|
67
|
+
* against is not on screen, and the list has not changed since it was set. A
|
|
68
|
+
* list that did change without bringing that row back means the row is gone
|
|
69
|
+
* (edited away, regenerated), and the restore falls back to the newest turn.
|
|
70
|
+
*/
|
|
71
|
+
function awaitingRows(pending: { current: PendingScroll | null }, ids: readonly string[]): boolean {
|
|
72
|
+
const todo = pending.current;
|
|
73
|
+
if (todo?.kind !== 'offset' || ids.includes(todo.position.probeId)) return false;
|
|
74
|
+
if (ids === todo.setAgainst) return true;
|
|
75
|
+
pending.current = { kind: 'end' };
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The state before the first key: nothing mounted yet, so nothing to remember. */
|
|
80
|
+
const MOUNTING = Symbol('mounting');
|
|
81
|
+
|
|
82
|
+
const nextFrame = (run: () => void): void => {
|
|
83
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
|
|
84
|
+
else setTimeout(run, 0);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which rows the thread mounts, and the scroll that goes with them: the window
|
|
89
|
+
* over a long history, and the position a conversation is returned to.
|
|
90
|
+
*
|
|
91
|
+
* ## The window
|
|
92
|
+
*
|
|
93
|
+
* The newest `INITIAL_ROWS` rows mount first. Near the top, Bloom's
|
|
94
|
+
* `onStartReached` reveals `PAGE_ROWS` more — or, with none left, asks for the
|
|
95
|
+
* thread's history — and `maintainStartPosition` holds the reader on the row
|
|
96
|
+
* they were reading while either lands above. The window folds back to its
|
|
97
|
+
* opening size only when the reader is at the bottom and nothing is streaming.
|
|
98
|
+
* See `runtime/thread/timeline.ts` for why it is this and not virtualization.
|
|
99
|
+
*
|
|
100
|
+
* ## The position
|
|
101
|
+
*
|
|
102
|
+
* Leaving a conversation — by navigating away or by switching the screen to
|
|
103
|
+
* another — saves the offset, whether the reader was at the bottom, and the
|
|
104
|
+
* window's anchor. Coming back restores the anchor first, so the same rows are
|
|
105
|
+
* mounted above the reader, and the offset once they have laid out. Coming
|
|
106
|
+
* back from a cursor jump does not restore: "back to latest" means the latest.
|
|
107
|
+
*/
|
|
108
|
+
export function useTimelineWindow({
|
|
109
|
+
ids,
|
|
110
|
+
scrollKey,
|
|
111
|
+
ready,
|
|
112
|
+
focusIndex,
|
|
113
|
+
turnInFlight,
|
|
114
|
+
atBottomThreshold,
|
|
115
|
+
threadRef,
|
|
116
|
+
onLoadHistory,
|
|
117
|
+
onScroll,
|
|
118
|
+
}: Options) {
|
|
119
|
+
const [state, setState] = useState<{ key: string | null; anchor: TimelineAnchor }>({
|
|
120
|
+
key: scrollKey,
|
|
121
|
+
anchor: TAIL,
|
|
122
|
+
});
|
|
123
|
+
// The render after a key change and before its effect: the new conversation
|
|
124
|
+
// starts from the newest rows until the effect says otherwise.
|
|
125
|
+
const anchor = state.key === scrollKey ? state.anchor : TAIL;
|
|
126
|
+
const start = useMemo(() => resolveStart(anchor, ids, focusIndex), [anchor, ids, focusIndex]);
|
|
127
|
+
|
|
128
|
+
const setAnchor = useCallback(
|
|
129
|
+
(next: TimelineAnchor) => setState((current) => ({ key: current.key, anchor: next })),
|
|
130
|
+
[],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
/** This render's facts, for callbacks and cleanups that outlive it. */
|
|
134
|
+
const latest = useRef({ ids, start, anchor, ready, turnInFlight, onLoadHistory });
|
|
135
|
+
useLayoutEffect(() => {
|
|
136
|
+
latest.current = { ids, start, anchor, ready, turnInFlight, onLoadHistory };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** The last position the thread reported for the current key. */
|
|
140
|
+
const lastOffset = useRef<number | null>(null);
|
|
141
|
+
const atBottom = useRef(true);
|
|
142
|
+
const pending = useRef<PendingScroll | null>(null);
|
|
143
|
+
const previousKey = useRef<string | null | typeof MOUNTING>(MOUNTING);
|
|
144
|
+
|
|
145
|
+
useLayoutEffect(() => {
|
|
146
|
+
const cameFrom = previousKey.current;
|
|
147
|
+
previousKey.current = scrollKey;
|
|
148
|
+
lastOffset.current = null;
|
|
149
|
+
atBottom.current = true;
|
|
150
|
+
// Returning from a jump is a request for the present, not for the past.
|
|
151
|
+
// A reader who left from the bottom comes back to the newest page, which
|
|
152
|
+
// is where they were; only a position mid-thread brings its rows back.
|
|
153
|
+
const read =
|
|
154
|
+
scrollKey !== null && cameFrom !== null ? timelinePositions.read(scrollKey) : undefined;
|
|
155
|
+
const saved = read?.atBottom === false ? read : undefined;
|
|
156
|
+
pending.current =
|
|
157
|
+
scrollKey === null
|
|
158
|
+
? null
|
|
159
|
+
: saved === undefined
|
|
160
|
+
? { kind: 'end' }
|
|
161
|
+
: { kind: 'offset', position: saved, setAgainst: ids };
|
|
162
|
+
setState({ key: scrollKey, anchor: saved?.anchor ?? TAIL });
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
if (scrollKey === null) return;
|
|
166
|
+
const { ids: rows, start: first, anchor: kept } = latest.current;
|
|
167
|
+
if (rows.length === 0) return;
|
|
168
|
+
timelinePositions.save(scrollKey, {
|
|
169
|
+
anchor: kept,
|
|
170
|
+
offset: lastOffset.current ?? 0,
|
|
171
|
+
atBottom: atBottom.current,
|
|
172
|
+
probeId: rows[first],
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
}, [scrollKey]);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pin the opening window to its first row once the rows exist, so a turn
|
|
179
|
+
* appended at the bottom grows the window rather than sliding it — a slide
|
|
180
|
+
* would unmount the top row under a reader who is mid-thread. Re-pinned the
|
|
181
|
+
* same way when the pinned row has gone (the list was replaced) — except
|
|
182
|
+
* while a restore waits for its conversation's rows: the rows on screen are
|
|
183
|
+
* still the last conversation's, and the anchor being restored is not in
|
|
184
|
+
* them yet.
|
|
185
|
+
*/
|
|
186
|
+
useLayoutEffect(() => {
|
|
187
|
+
if (ids.length === 0 || awaitingRows(pending, ids)) return;
|
|
188
|
+
const stale = anchor.kind === 'tail' || (anchor.kind === 'row' && !ids.includes(anchor.id));
|
|
189
|
+
if (!stale) return;
|
|
190
|
+
setState((current) =>
|
|
191
|
+
current.key === scrollKey && current.anchor === anchor
|
|
192
|
+
? { key: current.key, anchor: pinAnchor(ids, start) }
|
|
193
|
+
: current,
|
|
194
|
+
);
|
|
195
|
+
}, [ids, anchor, start, scrollKey]);
|
|
196
|
+
|
|
197
|
+
const handleStartReached = useCallback(() => {
|
|
198
|
+
const { ids: rows, start: first, onLoadHistory: loadHistory } = latest.current;
|
|
199
|
+
const next = revealAbove(rows, first);
|
|
200
|
+
if (next !== null) {
|
|
201
|
+
setAnchor(next);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
setAnchor(TOP);
|
|
205
|
+
loadHistory?.();
|
|
206
|
+
}, [setAnchor]);
|
|
207
|
+
|
|
208
|
+
const handleScroll = useCallback(
|
|
209
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
210
|
+
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
|
211
|
+
lastOffset.current = contentOffset.y;
|
|
212
|
+
const wasAtBottom = atBottom.current;
|
|
213
|
+
atBottom.current =
|
|
214
|
+
contentSize.height - contentOffset.y - layoutMeasurement.height <= atBottomThreshold;
|
|
215
|
+
if (atBottom.current && !wasAtBottom && !latest.current.turnInFlight) {
|
|
216
|
+
const { ids: rows, start: first } = latest.current;
|
|
217
|
+
const folded = collapseToTail(rows, first);
|
|
218
|
+
if (folded !== null) {
|
|
219
|
+
pending.current = { kind: 'end' };
|
|
220
|
+
setAnchor(folded);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
onScroll?.(event);
|
|
224
|
+
},
|
|
225
|
+
[atBottomThreshold, onScroll, setAnchor],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The rows have laid out: carry out whatever scroll was waiting for them.
|
|
230
|
+
* A frame later, so it lands after the thread's own reaction to the same
|
|
231
|
+
* layout (on web it follows to the end on its first one).
|
|
232
|
+
*/
|
|
233
|
+
const handleListLayout = useCallback(
|
|
234
|
+
(_event: LayoutChangeEvent) => {
|
|
235
|
+
const { ids: rows, ready: loaded } = latest.current;
|
|
236
|
+
if (pending.current === null || !loaded || rows.length === 0) return;
|
|
237
|
+
if (awaitingRows(pending, rows)) return;
|
|
238
|
+
const todo = pending.current;
|
|
239
|
+
pending.current = null;
|
|
240
|
+
nextFrame(() => {
|
|
241
|
+
if (todo.kind === 'end') threadRef.current?.scrollToEnd({ animated: false });
|
|
242
|
+
else threadRef.current?.scrollToOffset({ offset: todo.position.offset });
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
[threadRef],
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
/** Index of the first mounted row. */
|
|
250
|
+
start,
|
|
251
|
+
/** For `AiChatThread`: absent when there is nothing above to reveal or load. */
|
|
252
|
+
onStartReached: start > 0 || onLoadHistory !== undefined ? handleStartReached : undefined,
|
|
253
|
+
onScroll: handleScroll,
|
|
254
|
+
onListLayout: handleListLayout,
|
|
255
|
+
};
|
|
256
|
+
}
|