@alia.onl/sdk 15.7.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 +255 -12
- package/README.md +241 -155
- package/package.json +17 -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/csp.ts +50 -0
- package/src/hooks/useAliaActivity.ts +3 -3
- 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/useDictation.ts +2 -3
- 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/useTTS.ts +2 -3
- 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 +2 -6
- 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/character-runtime.ts +6 -4
- 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/origins.ts +15 -0
- 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-audio-engine.ts +14 -158
- 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/voice-worklets.ts +19 -0
- 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/worklets/voice-processors.js +152 -0
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/api-url.ts +0 -2
- 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,79 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { rememberOpener } from '../lib/focus-return';
|
|
3
|
+
import { useAliaRuntime } from '../runtime/config';
|
|
4
|
+
import { type ThoughtScope, type ThoughtTab, useThoughtStore } from '../runtime/thought-store';
|
|
5
|
+
import type { ThreadMessage } from '../runtime/thread/thread-history';
|
|
6
|
+
|
|
7
|
+
/** Open the thought panel on a turn; `opener` is the control pressed, to give focus back to. */
|
|
8
|
+
export type OpenThought = (messageId: string, tab?: ThoughtTab, opener?: unknown) => void;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The scope a press on a row hands the thought panel: the conversation the row belongs to, which
|
|
12
|
+
* is not always the one being streamed into.
|
|
13
|
+
*
|
|
14
|
+
* A live row belongs to the conversation on screen. A history row belongs to an earlier stretch
|
|
15
|
+
* of the thread — persisted and complete — so its scope is that stretch's messages and nothing
|
|
16
|
+
* about the live turn. Keyed by conversation and message: the panel never shows one
|
|
17
|
+
* conversation's turn under another's.
|
|
18
|
+
*/
|
|
19
|
+
export function thoughtScopeFor(
|
|
20
|
+
messageId: string,
|
|
21
|
+
live: ThoughtScope,
|
|
22
|
+
history: readonly ThreadMessage[],
|
|
23
|
+
): ThoughtScope {
|
|
24
|
+
if (live.messages.some((m) => m.id === messageId)) return live;
|
|
25
|
+
const past = history.find((m) => m.id === messageId);
|
|
26
|
+
if (past === undefined) return live;
|
|
27
|
+
return {
|
|
28
|
+
conversationId: past.conversationId,
|
|
29
|
+
messages: history.filter((m) => m.conversationId === past.conversationId),
|
|
30
|
+
status: 'ready',
|
|
31
|
+
isLoading: false,
|
|
32
|
+
failedTurn: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Open the thought panel on any turn — the one in flight or one long past — or `undefined` when
|
|
38
|
+
* the host has no panel to show it in (`AliaRuntimeHost.showThought`): the row then draws its
|
|
39
|
+
* "worked for" line as plain text.
|
|
40
|
+
*
|
|
41
|
+
* `runningTurnId` is the turn in flight once it has begun using tools. The first tool of a turn
|
|
42
|
+
* opens the panel on it unless the host declines (something else is open), once per turn: a
|
|
43
|
+
* reader who closes it keeps it closed.
|
|
44
|
+
*
|
|
45
|
+
* The returned function is stable: it is a prop of every memoised row, while the live scope
|
|
46
|
+
* changes per streamed token, so the scopes are read at press time through a ref.
|
|
47
|
+
*/
|
|
48
|
+
export function useOpenThought(
|
|
49
|
+
live: ThoughtScope,
|
|
50
|
+
history: readonly ThreadMessage[],
|
|
51
|
+
runningTurnId: string | null,
|
|
52
|
+
): OpenThought | undefined {
|
|
53
|
+
const { showThought } = useAliaRuntime().host;
|
|
54
|
+
const select = useThoughtStore((s) => s.open);
|
|
55
|
+
const latest = useRef({ live, history, showThought });
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
latest.current = { live, history, showThought };
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const open = useCallback<OpenThought>(
|
|
61
|
+
(messageId, tab, opener) => {
|
|
62
|
+
const { live: scope, history: past, showThought: show } = latest.current;
|
|
63
|
+
rememberOpener(opener);
|
|
64
|
+
select(messageId, thoughtScopeFor(messageId, scope, past), tab);
|
|
65
|
+
show?.('pressed');
|
|
66
|
+
},
|
|
67
|
+
[select],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const autoOpened = useRef<string | null>(null);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (runningTurnId === null || autoOpened.current === runningTurnId) return;
|
|
73
|
+
autoOpened.current = runningTurnId;
|
|
74
|
+
const { live: scope, showThought: show } = latest.current;
|
|
75
|
+
if (show?.('automatic') === true) select(runningTurnId, scope);
|
|
76
|
+
}, [runningTurnId, select]);
|
|
77
|
+
|
|
78
|
+
return showThought === undefined ? undefined : open;
|
|
79
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useMemo, useState } from 'react';
|
|
2
2
|
import { type AskedQuestion, pendingQuestions } from '../lib/questions';
|
|
3
|
-
import type {
|
|
3
|
+
import type { Message } from '../runtime/types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* What Alia asked in her last turn and the person has not answered: asked above the composer,
|
|
@@ -8,7 +8,7 @@ import type { ChatMessage } from '../types';
|
|
|
8
8
|
* puts the card away until she asks again.
|
|
9
9
|
*/
|
|
10
10
|
export function usePendingQuestions(
|
|
11
|
-
messages: readonly Pick<
|
|
11
|
+
messages: readonly Pick<Message, 'id' | 'role' | 'toolInvocations'>[],
|
|
12
12
|
isStreaming: boolean,
|
|
13
13
|
): { questions: AskedQuestion[]; dismiss: () => void } {
|
|
14
14
|
const asked = useMemo(() => pendingQuestions(messages, isStreaming), [messages, isStreaming]);
|
|
@@ -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,
|
package/src/hooks/useTTS.ts
CHANGED
|
@@ -6,8 +6,7 @@ import { errorMessage } from '../lib/utils';
|
|
|
6
6
|
import { create } from 'zustand';
|
|
7
7
|
import { createAudioLevelMeter } from '../lib/audio-level';
|
|
8
8
|
import { requestSpeechClip } from '../lib/speech-synthesis';
|
|
9
|
-
|
|
10
|
-
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
9
|
+
import { ALIA_API_URL } from '../lib/origins';
|
|
11
10
|
|
|
12
11
|
// ============== OPTIONS ==============
|
|
13
12
|
|
|
@@ -83,7 +82,7 @@ useTTSStore.subscribe((state, previous) => {
|
|
|
83
82
|
// ============== HOOK ==============
|
|
84
83
|
|
|
85
84
|
export function useTTS(options: UseTTSOptions = {}) {
|
|
86
|
-
const apiUrl = options.apiUrl ||
|
|
85
|
+
const apiUrl = options.apiUrl || ALIA_API_URL;
|
|
87
86
|
const voicePref = options.voice ?? 'female';
|
|
88
87
|
const voiceModel = options.model;
|
|
89
88
|
const tonePref = options.tone ?? 'default';
|
|
@@ -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
|
+
}
|