@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
|
@@ -1,135 +1,235 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The conversation: header, thread and composer, with its voice call. The one screen of the
|
|
3
|
+
* bottom sheet, `AliaChatScreen` and the Alia app — they are frames around this.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* It is the top of three layers, each depending only on the one before it: the runtime
|
|
6
|
+
* (`useConversationThread`, the stream, the cache), the hooks that turn it into what a person does
|
|
7
|
+
* (`useChatComposer`, `useChatVoice`, `useThreadView`), and this, which draws it — the thread
|
|
8
|
+
* (`AliaThread`) and the agent's history behind it, the composer with its levels, suggestions and
|
|
9
|
+
* questions, and the voice call: the button that opens it, the bar that replaces the composer, the
|
|
10
|
+
* notices and the transcript in the thread. The call loads on the first call, so nothing here
|
|
11
|
+
* reaches its audio engine at module-evaluation time.
|
|
12
|
+
*
|
|
13
|
+
* What a host brings is data and places: the conversation to show, the chrome around it
|
|
14
|
+
* (`chrome`), its rows in the add menu, its words (`labels`), where the thought panel and a
|
|
15
|
+
* scheduled task open (`runtime`), and a few gates (`voice`, `onSignInRequired`,
|
|
16
|
+
* `renderUsageLimit`).
|
|
8
17
|
*/
|
|
9
18
|
|
|
10
|
-
import type React from 'react';
|
|
11
|
-
import { Suspense, useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
12
|
-
import { View, type TextInput } from 'react-native';
|
|
13
|
-
import type { SharedValue } from 'react-native-reanimated';
|
|
14
|
-
import { turnActivityState } from '../lib/turn-state';
|
|
15
|
-
import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
|
|
16
|
-
import { useTTS } from '../hooks/useTTS';
|
|
17
|
-
import { useFocusWhenPresented } from '../hooks/useFocusWhenPresented';
|
|
18
|
-
import { usePendingQuestions } from '../hooks/usePendingQuestions';
|
|
19
|
-
import { useImagePicker } from '../hooks/useImagePicker';
|
|
20
|
-
import { imageUriToDataUrl } from '../lib/image-data-url';
|
|
21
|
-
import { useAmbientWave } from '../hooks/useAmbientWave';
|
|
22
|
-
import { AmbientField } from './AmbientField';
|
|
23
19
|
import { getAccountDisplayName } from '@oxy.so/core';
|
|
24
20
|
import { useOxy } from '@oxy.so/services/ui/client';
|
|
25
|
-
import {
|
|
26
|
-
import { AliaFlightsProvider, ALIA_TO_PROFILE } from '../lib/flights';
|
|
27
|
-
import { AliaWelcomeMessage } from './AliaWelcomeMessage';
|
|
28
|
-
import { AliaComposer } from './AliaComposer';
|
|
29
|
-
import { useComposerSuggestions, useSuggestionQuery } from '../hooks/useComposerSuggestions';
|
|
30
|
-
import { AiChatContainer } from '@oxy.so/bloom/ai-chat';
|
|
21
|
+
import { AiChatContainer, type AiChatThreadHandle } from '@oxy.so/bloom/ai-chat';
|
|
31
22
|
import {
|
|
32
23
|
AvatarFlightLayer,
|
|
33
|
-
useAvatarFlights,
|
|
34
24
|
type AvatarFlights,
|
|
25
|
+
useAvatarFlights,
|
|
35
26
|
} from '@oxy.so/bloom/avatar-flight';
|
|
36
|
-
import type { ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
|
|
37
|
-
import { RiImageLine } from '@oxy.so/bloom/icons';
|
|
38
|
-
import { toast } from '@oxy.so/bloom/toast';
|
|
39
27
|
import { Button } from '@oxy.so/bloom/button';
|
|
28
|
+
import { ScrollToBottomButton } from '@oxy.so/bloom/chat-screen';
|
|
29
|
+
import { RiEditLine } from '@oxy.so/bloom/icons/RiEditLine';
|
|
30
|
+
import { toast } from '@oxy.so/bloom/toast';
|
|
31
|
+
import { Text } from '@oxy.so/bloom/typography';
|
|
32
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
33
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
34
|
+
import { View } from 'react-native';
|
|
35
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
36
|
+
import { useAmbientWave } from '../hooks/useAmbientWave';
|
|
37
|
+
import { useAtBottom } from '../hooks/useAtBottom';
|
|
38
|
+
import { type AliaTurn, useChatComposer } from '../hooks/useChatComposer';
|
|
39
|
+
import { type AliaVoiceOptions, useChatVoice } from '../hooks/useChatVoice';
|
|
40
|
+
import { useAttachMenu } from '../hooks/useAttachMenu';
|
|
41
|
+
import { usePendingQuestions } from '../hooks/usePendingQuestions';
|
|
42
|
+
import { useThreadView } from '../hooks/useThreadView';
|
|
43
|
+
import { useTTS } from '../hooks/useTTS';
|
|
44
|
+
import type { AliaAddMenu } from '../lib/add-menu';
|
|
45
|
+
import { AliaFlightsProvider } from '../lib/flights';
|
|
46
|
+
import { type AliaChatParticipants, leadOf } from '../lib/participants';
|
|
47
|
+
import { type AliaToolRenderers, AliaToolRenderersProvider } from '../lib/tool-renderers';
|
|
48
|
+
import { KeyboardSafeAreaFloor } from '../lib/keyboard';
|
|
40
49
|
import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
|
|
50
|
+
import { type DeviceModel, buildPowerLevelOptions } from '../lib/power-level-options';
|
|
51
|
+
import { ALIA_FEATURES, featureOffer, useAgentFeatures, useAgentProfile } from '../runtime/agents';
|
|
52
|
+
import { type AliaRuntimeHost, AliaRuntimeProvider, useAliaRuntime } from '../runtime/config';
|
|
53
|
+
import type { AliaHostTools } from '../runtime/host-tools';
|
|
54
|
+
import { useCreateConversation } from '../runtime/conversations';
|
|
55
|
+
import { aliaQueryKeys } from '../runtime/query-keys';
|
|
56
|
+
import type { Message } from '../runtime/types';
|
|
57
|
+
import { useAgentActivity } from '../runtime/use-agent-activity';
|
|
58
|
+
import { useConversationThread } from '../runtime/use-conversation-thread';
|
|
59
|
+
import { UsageLimitError } from '../runtime/usage-limit-error';
|
|
60
|
+
import type { AliaSuggestion } from '../types';
|
|
41
61
|
import type { AliaAvatarState } from './AliaAvatar';
|
|
62
|
+
import { AliaComposer } from './AliaComposer';
|
|
63
|
+
import { useParticipants } from './AliaParticipants';
|
|
64
|
+
import { AliaWelcomeMessage } from './AliaWelcomeMessage';
|
|
65
|
+
import { AmbientField } from './AmbientField';
|
|
66
|
+
import { AliaThread } from './thread/AliaThread';
|
|
67
|
+
import { ThreadSearch } from './thread/ThreadSearch';
|
|
42
68
|
import { VoiceModeIcon } from './VoiceModeIcon';
|
|
43
|
-
import
|
|
44
|
-
AliaAttachment,
|
|
45
|
-
AliaSuggestion,
|
|
46
|
-
ChatMessage,
|
|
47
|
-
VoiceMessage,
|
|
48
|
-
VoiceSessionComponent,
|
|
49
|
-
VoiceSessionState,
|
|
50
|
-
} from '../types';
|
|
69
|
+
import { AliaVoiceCall } from './voice/AliaVoiceCall';
|
|
51
70
|
|
|
52
|
-
/** The
|
|
53
|
-
|
|
54
|
-
const seen = new Map<string, AliaChatAgent>();
|
|
55
|
-
for (const { agentInfo } of messages) {
|
|
56
|
-
if (agentInfo && !seen.has(agentInfo.id)) seen.set(agentInfo.id, agentInfo);
|
|
57
|
-
}
|
|
58
|
-
return [...seen.values()];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** An agent that answered in the conversation, as the header shows it beside Alia. */
|
|
62
|
-
export interface AliaChatAgent {
|
|
71
|
+
/** The agent a chat is opened on: its lead instead of Alia. */
|
|
72
|
+
export interface AliaChatAgentRef {
|
|
63
73
|
id: string;
|
|
64
|
-
name: string;
|
|
65
|
-
/** Its look, as the server stores it (JSON); absent, the default look. */
|
|
66
|
-
character?: unknown;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
/** What the chat tells its
|
|
76
|
+
/** What the chat tells its chrome: whether there is a conversation to act on, who takes part, what the lead is doing. */
|
|
70
77
|
export interface AliaChatState {
|
|
78
|
+
/** The conversation open, or `undefined` before the first turn creates one. */
|
|
79
|
+
conversationId: string | undefined;
|
|
71
80
|
hasMessages: boolean;
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
/** Begin a new conversation. Nothing is deleted: the old one stays in the list. */
|
|
82
|
+
startOver: () => void;
|
|
83
|
+
/** Empty the open conversation, on the server and on screen. */
|
|
84
|
+
clearMessages: () => Promise<unknown>;
|
|
85
|
+
/** What the lead is doing; the header's avatar shows it. */
|
|
74
86
|
markState: AliaAvatarState;
|
|
75
|
-
/**
|
|
76
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Who is in the conversation — the lead, and the agents it consulted once they have joined.
|
|
89
|
+
* Draw it with `AliaParticipants` wherever the chrome puts the conversation's title.
|
|
90
|
+
*/
|
|
91
|
+
participants: AliaChatParticipants;
|
|
92
|
+
/** Search what was said in the agent's thread. Present only on a thread (`threadHandle`). */
|
|
93
|
+
openSearch: (() => void) | undefined;
|
|
94
|
+
/** The conversation as it stands now, for an export. Read when called, not when rendered. */
|
|
95
|
+
getMessages: () => readonly Message[];
|
|
77
96
|
}
|
|
78
97
|
|
|
79
|
-
/**
|
|
80
|
-
export interface
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
/** What the container draws around the thread. */
|
|
99
|
+
export interface AliaChatChrome {
|
|
100
|
+
/** The top bar. */
|
|
101
|
+
header?: ReactNode;
|
|
102
|
+
/** The crumb of the container's breadcrumb row. */
|
|
103
|
+
title?: string;
|
|
104
|
+
/** The project the chat is filed under, in the breadcrumb. */
|
|
105
|
+
project?: string;
|
|
106
|
+
/** The chat's own actions, at the end of the breadcrumb row. */
|
|
107
|
+
actions?: ReactNode;
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
export interface AliaChatContentProps {
|
|
111
|
+
// ── Which conversation ──
|
|
112
|
+
/**
|
|
113
|
+
* The conversation to show. Given, the host owns which one is open (a route) and learns of a new
|
|
114
|
+
* one through `onConversationChange`; omitted, the chat keeps its own — unless the host
|
|
115
|
+
* navigates (`runtime.openConversation`), which then decides: the chat starts empty and
|
|
116
|
+
* creates its conversation with the first turn, or the first call.
|
|
117
|
+
*/
|
|
118
|
+
conversationId?: string;
|
|
119
|
+
/**
|
|
120
|
+
* The agent this chat is with, any agent the person can reach: it leads the conversation — its
|
|
121
|
+
* greeting, name, character and starters, and the header's first avatar. Omitted, Alia.
|
|
122
|
+
*/
|
|
123
|
+
agent?: AliaChatAgentRef;
|
|
124
|
+
/**
|
|
125
|
+
* The handle of the agent's permanent thread, when this conversation is the active stretch of
|
|
126
|
+
* one: the conversations behind it are paged in above, and the thread can be searched. Omitted,
|
|
127
|
+
* the chat is one conversation with nothing behind it.
|
|
128
|
+
*/
|
|
129
|
+
threadHandle?: string;
|
|
130
|
+
/** The chat opened a conversation (a new one, from a first turn or a call) or left it (`undefined`). */
|
|
131
|
+
onConversationChange?: (conversationId: string | undefined) => void;
|
|
132
|
+
/** The host's side of the conversation runtime: navigation, artifacts, credits… */
|
|
133
|
+
runtime?: AliaRuntimeHost;
|
|
134
|
+
/**
|
|
135
|
+
* Nothing is stored: the first turn is sent without creating a conversation, so the whole chat
|
|
136
|
+
* lives on this screen (ghost mode).
|
|
137
|
+
*/
|
|
138
|
+
ephemeral?: boolean;
|
|
139
|
+
apiUrl?: string;
|
|
140
|
+
/** App context injected into the system prompt. */
|
|
86
141
|
clientContext?: string;
|
|
142
|
+
/**
|
|
143
|
+
* The UI effects this app's client performs for the lead (`docs/agent-embedding.md` §2). Declared
|
|
144
|
+
* with every turn; an action the stream asks for is performed once, by `execute`, and its
|
|
145
|
+
* outcome reaches the model with the next turn.
|
|
146
|
+
*/
|
|
147
|
+
hostTools?: AliaHostTools;
|
|
148
|
+
/** The app's own drawing of a catalogue tool's result, keyed `<app>:<tool>`, before the generic card. */
|
|
149
|
+
toolRenderers?: AliaToolRenderers;
|
|
150
|
+
/**
|
|
151
|
+
* The lead's feature gate refused something (`attachments.documents`…): the host shows its own
|
|
152
|
+
* upsell. Also called for a locked row of the attach menu.
|
|
153
|
+
*/
|
|
154
|
+
onFeatureLocked?: (key: string) => void;
|
|
155
|
+
// ── The power level ──
|
|
156
|
+
/**
|
|
157
|
+
* The power level (`auto`, `instant`, `medium`, `high`, `xhigh`, `pro`, `ultra`) or exact
|
|
158
|
+
* `publisher/model` every turn is sent with; omitted, the server's default.
|
|
159
|
+
*/
|
|
87
160
|
model?: string;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
161
|
+
/** Makes the level selectable in the composer: the person picked `model`. Omitted, no selector. */
|
|
162
|
+
onModelChange?: (model: string) => void;
|
|
163
|
+
/** Models on the person's own devices, offered after the levels in the selector. */
|
|
164
|
+
deviceModels?: readonly DeviceModel[];
|
|
165
|
+
// ── Words ──
|
|
166
|
+
/** Overrides for the chat's own copy; anything omitted is English. */
|
|
167
|
+
labels?: Partial<AliaChatLabels>;
|
|
168
|
+
/** The empty chat's greeting; omitted, the agent's own, or Alia greets the signed-in person by name (`labels`). */
|
|
92
169
|
welcomeGreeting?: string;
|
|
93
170
|
welcomeSubtitle?: string;
|
|
94
|
-
/** The lines offered over the composer on an empty chat. */
|
|
171
|
+
/** The lines offered over the composer on an empty chat; omitted, the agent's starters. */
|
|
95
172
|
welcomeSuggestions?: AliaSuggestion[];
|
|
96
173
|
/** What the host found for the typed text (see `onSuggestionQuery`). */
|
|
97
174
|
suggestionMatches?: AliaSuggestion[];
|
|
98
175
|
/** The typed text, once typing has settled, for the host to search suggestions with. */
|
|
99
176
|
onSuggestionQuery?: (query: string) => void;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
onThumbsDown?: (messageId: string) => void;
|
|
104
|
-
onApprovePlan?: (planId: string) => void;
|
|
105
|
-
onRejectPlan?: (planId: string) => void;
|
|
106
|
-
/** Override markdown renderer (app passes CustomMarkdown) */
|
|
107
|
-
renderMarkdown?: (content: string) => React.ReactNode;
|
|
177
|
+
/** A suggestion was picked (before it is sent, or put in the composer if it is a template). */
|
|
178
|
+
onSuggestionPicked?: (suggestion: AliaSuggestion) => void;
|
|
179
|
+
// ── The frame ──
|
|
108
180
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
181
|
+
* What the container draws around the thread, from what the chat knows (who took part, whether
|
|
182
|
+
* there is anything to clear, whether it is a searchable thread).
|
|
111
183
|
*/
|
|
112
|
-
|
|
184
|
+
chrome?: (state: AliaChatState) => AliaChatChrome;
|
|
185
|
+
/** The container draws its own surface (the card of a page), instead of leaving the fill to the frame around it. */
|
|
186
|
+
surface?: boolean;
|
|
113
187
|
/** Dark-mode flag — forwarded to the ambient wave overlay. */
|
|
114
188
|
isDarkMode?: boolean;
|
|
189
|
+
/** The ambient field's shape: the whole `screen` (default) or a `sheet`'s smaller one. */
|
|
190
|
+
layout?: 'screen' | 'sheet';
|
|
115
191
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* from this module. May be a `React.lazy` component.
|
|
192
|
+
* Whether the screen holding the chat is the one on show. A screen kept mounted under another
|
|
193
|
+
* pauses its ambient field, and a live call ends when it is not. Default `true`.
|
|
119
194
|
*/
|
|
120
|
-
|
|
195
|
+
isFocused?: boolean;
|
|
121
196
|
/**
|
|
122
|
-
* Space the composer keeps clear at the bottom edge, in px — the home indicator, when the
|
|
123
|
-
*
|
|
197
|
+
* Space the composer keeps clear at the bottom edge, in px — the home indicator, when the chat
|
|
198
|
+
* is flush with the screen. It gives way to the keyboard. The ambient background still runs
|
|
199
|
+
* behind it.
|
|
124
200
|
*/
|
|
125
201
|
bottomInset?: number;
|
|
202
|
+
// ── The composer ──
|
|
203
|
+
/** The host's rows in the add menu. Omitted, the menu offers photos and files, as the lead takes them. */
|
|
204
|
+
addMenu?: AliaAddMenu;
|
|
205
|
+
/** The tab on the composer's top edge (the project the chat is filed under). Replaced by the edit strip while a question is rewritten. */
|
|
206
|
+
composerStatus?: ReactNode;
|
|
207
|
+
/** Error handler (e.g. toast) */
|
|
208
|
+
onError?: (message: string) => void;
|
|
209
|
+
/**
|
|
210
|
+
* The person is signed out and sent a turn: the host asks them to sign in. The draft stays in
|
|
211
|
+
* the composer. Omitted, the turn is sent and the server's refusal is told.
|
|
212
|
+
*/
|
|
213
|
+
onSignInRequired?: () => void;
|
|
214
|
+
/**
|
|
215
|
+
* The turn was refused for the person's usage limit: the composer is closed until `dismiss`,
|
|
216
|
+
* and this draws what the host says about it (a dialog).
|
|
217
|
+
*/
|
|
218
|
+
renderUsageLimit?: (limit: { error: UsageLimitError; dismiss: () => void }) => ReactNode;
|
|
219
|
+
/**
|
|
220
|
+
* The chat's voice call. On by default; `false` makes the chat text-only. An object brings
|
|
221
|
+
* what only the host knows: the gate before a call (`canStartVoice`, `onVoiceBlocked`), what
|
|
222
|
+
* to refresh when one ends (`onCallEnded`), where its notices go (`onNotice`), the sounds it
|
|
223
|
+
* plays. The call always lives in the chat's conversation, made when it starts if the chat has
|
|
224
|
+
* none.
|
|
225
|
+
*/
|
|
226
|
+
voice?: false | AliaVoiceOptions;
|
|
227
|
+
// ── The sheet's ──
|
|
126
228
|
/**
|
|
127
229
|
* Hands the first turn of an empty chat to the host instead of sending it here — a sheet opens
|
|
128
230
|
* the conversation in a new sheet over this one. Omitted, the chat sends it itself.
|
|
129
231
|
*/
|
|
130
232
|
onStart?: (turn: AliaTurn) => void;
|
|
131
|
-
/** The agents the host knows, with their look: a delegation's thinking avatar is drawn from them. */
|
|
132
|
-
agents?: UseAliaChatOptions['agents'];
|
|
133
233
|
/**
|
|
134
234
|
* The avatar flights this chat takes part in, when a sheet shares them across its chats (the
|
|
135
235
|
* greeting's avatar flies into a header in another sheet). Omitted, the chat has its own.
|
|
@@ -145,67 +245,73 @@ export interface AliaChatContentProps {
|
|
|
145
245
|
presentedProgress?: SharedValue<number>;
|
|
146
246
|
/** Internal sheet lifecycle bridge: receives the live request canceller. */
|
|
147
247
|
onStopReady?: (stop: (() => void) | null) => void;
|
|
148
|
-
/** Overrides for the chat's own copy; anything omitted is English. */
|
|
149
|
-
labels?: Partial<AliaChatLabels>;
|
|
150
248
|
}
|
|
151
249
|
|
|
152
|
-
/**
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
toolInvocations: vm.toolInvocations,
|
|
172
|
-
createdAt: Date.now(),
|
|
173
|
-
source: 'voice' as const,
|
|
174
|
-
speaker: vm.speaker,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function AliaChatContentBody({
|
|
250
|
+
/** No live conversation while the thread looks at a moment of its past, as ONE array. */
|
|
251
|
+
const NO_MESSAGES: Message[] = [];
|
|
252
|
+
|
|
253
|
+
/** A host without renderers, as ONE object, so the thread's rows do not re-render for a new `{}`. */
|
|
254
|
+
const NO_RENDERERS: AliaToolRenderers = {};
|
|
255
|
+
|
|
256
|
+
/** The conversation view: everything of `AliaChatContentProps` that is not about which conversation is open. */
|
|
257
|
+
type ConversationViewProps = Omit<
|
|
258
|
+
AliaChatContentProps,
|
|
259
|
+
| 'labels'
|
|
260
|
+
| 'onConversationChange'
|
|
261
|
+
| 'runtime'
|
|
262
|
+
| 'conversationId'
|
|
263
|
+
| 'apiUrl'
|
|
264
|
+
| 'toolRenderers'
|
|
265
|
+
| 'onFeatureLocked'
|
|
266
|
+
> & { conversationId: string | undefined };
|
|
267
|
+
|
|
268
|
+
function AliaConversationView({
|
|
179
269
|
clientContext,
|
|
180
270
|
model,
|
|
181
|
-
|
|
271
|
+
onModelChange,
|
|
272
|
+
deviceModels,
|
|
273
|
+
conversationId,
|
|
274
|
+
agent,
|
|
275
|
+
hostTools,
|
|
276
|
+
threadHandle,
|
|
277
|
+
ephemeral = false,
|
|
182
278
|
onError,
|
|
183
279
|
welcomeGreeting,
|
|
184
280
|
welcomeSubtitle,
|
|
185
281
|
welcomeSuggestions,
|
|
186
282
|
suggestionMatches,
|
|
187
283
|
onSuggestionQuery,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
onApprovePlan,
|
|
192
|
-
onRejectPlan,
|
|
193
|
-
renderMarkdown,
|
|
194
|
-
header,
|
|
284
|
+
onSuggestionPicked,
|
|
285
|
+
chrome,
|
|
286
|
+
surface = false,
|
|
195
287
|
isDarkMode,
|
|
196
|
-
|
|
288
|
+
layout = 'screen',
|
|
289
|
+
isFocused = true,
|
|
197
290
|
bottomInset = 0,
|
|
291
|
+
addMenu,
|
|
292
|
+
composerStatus,
|
|
293
|
+
onSignInRequired,
|
|
294
|
+
renderUsageLimit,
|
|
295
|
+
voice: voiceOptions,
|
|
198
296
|
onStart,
|
|
199
|
-
agents: knownAgents,
|
|
200
297
|
flights: sharedFlights,
|
|
201
298
|
initialTurn,
|
|
202
299
|
presentedProgress,
|
|
203
300
|
onStopReady,
|
|
204
|
-
}:
|
|
301
|
+
}: ConversationViewProps) {
|
|
205
302
|
const ownFlights = useAvatarFlights();
|
|
206
303
|
const flights = sharedFlights ?? ownFlights;
|
|
207
304
|
const labels = useLabels();
|
|
208
|
-
const
|
|
305
|
+
const { t } = labels;
|
|
306
|
+
const runtime = useAliaRuntime();
|
|
307
|
+
const queryClient = useQueryClient();
|
|
308
|
+
const { user, isAuthenticated } = useOxy();
|
|
309
|
+
const agentId = agent?.id;
|
|
310
|
+
|
|
311
|
+
// ── Who leads: the agent the chat was opened on, or Alia ──
|
|
312
|
+
const { data: profile, isLoading: profileLoading } = useAgentProfile(agentId);
|
|
313
|
+
const lead = useMemo(() => leadOf(profile), [profile]);
|
|
314
|
+
const { data: features } = useAgentFeatures(agentId);
|
|
209
315
|
|
|
210
316
|
// A failure reaches the host's handler when it has one, and Bloom's toast otherwise.
|
|
211
317
|
const reportError = useCallback(
|
|
@@ -215,190 +321,118 @@ function AliaChatContentBody({
|
|
|
215
321
|
},
|
|
216
322
|
[onError],
|
|
217
323
|
);
|
|
218
|
-
// ── Chat ──
|
|
219
|
-
const chatOptions: UseAliaChatOptions = { apiUrl, model, clientContext, agents: knownAgents };
|
|
220
|
-
const { messages, setMessages, send, isStreaming, stop, clear, approvePlan, rejectPlan } =
|
|
221
|
-
useAliaChat(chatOptions);
|
|
222
|
-
const agents = useMemo(() => participatingAgents(messages), [messages]);
|
|
223
|
-
const asked = usePendingQuestions(messages, isStreaming);
|
|
224
|
-
|
|
225
|
-
// The first turn of an empty chat goes to the host when it asked for it; every other turn is sent here.
|
|
226
|
-
const startsElsewhere = onStart !== undefined && messages.length === 0;
|
|
227
|
-
const firstTurn = messages.length === 0;
|
|
228
|
-
const submitTurn = useCallback(
|
|
229
|
-
async (text: string, images?: ReadonlyArray<string>) => {
|
|
230
|
-
if (startsElsewhere) {
|
|
231
|
-
// The host's conversation sheet opens first so that the flight, drawn when it launches,
|
|
232
|
-
// is above it; this chat stays on the greeting, so the avatar can still be measured.
|
|
233
|
-
onStart({ text, images });
|
|
234
|
-
await flights.launch([ALIA_TO_PROFILE]);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
// The greeting leaves with the first turn: measure it before it does.
|
|
238
|
-
if (firstTurn) await flights.launch([ALIA_TO_PROFILE]);
|
|
239
|
-
send(text, images ? { images } : undefined);
|
|
240
|
-
},
|
|
241
|
-
[startsElsewhere, onStart, firstTurn, flights, send],
|
|
242
|
-
);
|
|
243
324
|
|
|
244
|
-
// The conversation
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
325
|
+
// ── The conversation ──
|
|
326
|
+
const thread = useConversationThread({
|
|
327
|
+
conversationId,
|
|
328
|
+
agentId,
|
|
329
|
+
model,
|
|
330
|
+
clientContext,
|
|
331
|
+
hostTools,
|
|
332
|
+
});
|
|
333
|
+
const {
|
|
334
|
+
messages,
|
|
335
|
+
setMessages,
|
|
336
|
+
isLoading,
|
|
337
|
+
conversationLoading,
|
|
338
|
+
error,
|
|
339
|
+
clearError,
|
|
340
|
+
stopGeneration: stop,
|
|
341
|
+
startOver,
|
|
342
|
+
clearConversation,
|
|
343
|
+
suggestedNewConversation,
|
|
344
|
+
dismissSuggestedNewConversation,
|
|
345
|
+
agentRun,
|
|
346
|
+
failedTurn,
|
|
347
|
+
retryFailedTurn,
|
|
348
|
+
} = thread;
|
|
349
|
+
const participants = useParticipants({ lead, messages, flights });
|
|
350
|
+
const asked = usePendingQuestions(messages, isLoading);
|
|
351
|
+
const view = useThreadView(threadHandle, conversationId);
|
|
352
|
+
const agentActivity = useAgentActivity(agentRun?.turnId ?? null, agentRun?.agentId);
|
|
353
|
+
|
|
354
|
+
// The turn was refused for the usage limit: the composer is closed until the host's dialog is
|
|
355
|
+
// dismissed. Name as well as class — Hermes can break `instanceof` for Error subclasses.
|
|
356
|
+
const usageLimit =
|
|
357
|
+
error instanceof UsageLimitError || error?.name === 'UsageLimitError'
|
|
358
|
+
? (error as UsageLimitError)
|
|
359
|
+
: null;
|
|
360
|
+
const closed = usageLimit !== null;
|
|
251
361
|
|
|
252
362
|
useEffect(() => {
|
|
253
363
|
onStopReady?.(stop);
|
|
254
364
|
return () => onStopReady?.(null);
|
|
255
365
|
}, [onStopReady, stop]);
|
|
256
366
|
|
|
257
|
-
// ──
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (!assets?.length) return;
|
|
269
|
-
setPhotos((prev) => [
|
|
270
|
-
...prev,
|
|
271
|
-
...assets.map((asset) => ({
|
|
272
|
-
id: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
273
|
-
type: 'image' as const,
|
|
274
|
-
name: asset.name,
|
|
275
|
-
uri: asset.uri,
|
|
276
|
-
size: asset.size,
|
|
277
|
-
mimeType: asset.mimeType,
|
|
278
|
-
})),
|
|
279
|
-
]);
|
|
280
|
-
},
|
|
281
|
-
[pickImage],
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
// Suggestions over the composer on an empty chat, as the Alia app has them.
|
|
285
|
-
const suggestionQuery = useSuggestionQuery(inputValue, messages.length === 0);
|
|
286
|
-
useEffect(() => {
|
|
287
|
-
onSuggestionQuery?.(suggestionQuery);
|
|
288
|
-
}, [suggestionQuery, onSuggestionQuery]);
|
|
289
|
-
const handlePickSuggestion = useCallback(
|
|
290
|
-
(suggestion: AliaSuggestion) => {
|
|
291
|
-
setInputValue('');
|
|
292
|
-
submitTurn(suggestion.text);
|
|
293
|
-
},
|
|
294
|
-
[submitTurn],
|
|
367
|
+
// ── What the person does: write, send, rewrite, call ──
|
|
368
|
+
// An agent's starters are its welcome suggestions, unless the host brought its own.
|
|
369
|
+
const starters = useMemo<AliaSuggestion[] | undefined>(
|
|
370
|
+
() =>
|
|
371
|
+
lead.starters.length === 0
|
|
372
|
+
? undefined
|
|
373
|
+
: lead.starters.map((starter, index) => ({
|
|
374
|
+
id: `starter-${index}`,
|
|
375
|
+
text: starter.prompt,
|
|
376
|
+
})),
|
|
377
|
+
[lead.starters],
|
|
295
378
|
);
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
379
|
+
const composer = useChatComposer({
|
|
380
|
+
conversationId,
|
|
381
|
+
thread,
|
|
382
|
+
ephemeral,
|
|
383
|
+
closed,
|
|
384
|
+
onWrite: view.backToLatest,
|
|
385
|
+
onStart,
|
|
386
|
+
flights,
|
|
387
|
+
lead,
|
|
388
|
+
initialTurn,
|
|
389
|
+
onSignInRequired,
|
|
390
|
+
reportError,
|
|
391
|
+
welcomeSuggestions: welcomeSuggestions ?? starters,
|
|
392
|
+
suggestionMatches,
|
|
393
|
+
onSuggestionQuery,
|
|
394
|
+
onSuggestionPicked,
|
|
395
|
+
presentedProgress,
|
|
302
396
|
});
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// ── Voice mode state ──
|
|
330
|
-
// `isVoiceActive` mounts the session; `voiceState` is what the session reports
|
|
331
|
-
// back, and stays null until the call is under way.
|
|
332
|
-
const [isVoiceActive, setIsVoiceActive] = useState(false);
|
|
333
|
-
const [voiceState, setVoiceState] = useState<VoiceSessionState | null>(null);
|
|
334
|
-
const textSnapshotRef = useRef<ChatMessage[]>([]);
|
|
335
|
-
|
|
336
|
-
// Merge voice messages into chat messages
|
|
337
|
-
const voiceMessages = voiceState?.messages;
|
|
338
|
-
useEffect(() => {
|
|
339
|
-
if (!voiceMessages) return;
|
|
340
|
-
setMessages([...textSnapshotRef.current, ...voiceMessages.map(adaptVoiceMessage)]);
|
|
341
|
-
}, [voiceMessages, setMessages]);
|
|
342
|
-
|
|
343
|
-
const messagesRef = useRef(messages);
|
|
344
|
-
messagesRef.current = messages;
|
|
345
|
-
const activateVoice = useCallback(() => {
|
|
346
|
-
if (isVoiceActive) return;
|
|
347
|
-
textSnapshotRef.current = [...messagesRef.current];
|
|
348
|
-
setIsVoiceActive(true);
|
|
349
|
-
}, [isVoiceActive]);
|
|
350
|
-
|
|
351
|
-
// Unmounting the session disconnects the room; the transcript merged above
|
|
352
|
-
// stays in the chat.
|
|
353
|
-
// A call that fails ends the same way a hang-up does, but the person is told why: a
|
|
354
|
-
// button that silently does nothing reads as broken.
|
|
355
|
-
const deactivateVoice = useCallback(
|
|
356
|
-
(error?: string) => {
|
|
357
|
-
setIsVoiceActive(false);
|
|
358
|
-
setVoiceState(null);
|
|
359
|
-
textSnapshotRef.current = [];
|
|
360
|
-
if (error) reportError(error, labels.voiceFailed);
|
|
361
|
-
},
|
|
362
|
-
[reportError, labels.voiceFailed],
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
// ── TTS read aloud handler ──
|
|
366
|
-
const { readAloud } = tts;
|
|
367
|
-
const handleReadAloud = useCallback(
|
|
368
|
-
(messageId: string, text: string) => {
|
|
369
|
-
readAloud(messageId, text);
|
|
370
|
-
},
|
|
371
|
-
[readAloud],
|
|
397
|
+
const voice = useChatVoice({
|
|
398
|
+
options: voiceOptions,
|
|
399
|
+
conversationId,
|
|
400
|
+
agentId,
|
|
401
|
+
isFocused,
|
|
402
|
+
ensureConversation: thread.ensureConversation,
|
|
403
|
+
messages,
|
|
404
|
+
setMessages,
|
|
405
|
+
storedMessages: thread.conversation?.messages,
|
|
406
|
+
isLoading,
|
|
407
|
+
});
|
|
408
|
+
// The default menu offers what the lead takes: Alia every attachment; an agent what its feature
|
|
409
|
+
// answer lists, nothing until it has answered.
|
|
410
|
+
const offers = useMemo(() => {
|
|
411
|
+
const states = agentId === undefined ? undefined : (features ?? []);
|
|
412
|
+
return {
|
|
413
|
+
images: featureOffer(states, ALIA_FEATURES.images),
|
|
414
|
+
documents: featureOffer(states, ALIA_FEATURES.documents),
|
|
415
|
+
};
|
|
416
|
+
}, [agentId, features]);
|
|
417
|
+
const attachMenu = useAttachMenu(composer.address, offers, runtime.host.onFeatureLocked);
|
|
418
|
+
const menu = addMenu ?? attachMenu;
|
|
419
|
+
const modes = useMemo(
|
|
420
|
+
() => (onModelChange ? buildPowerLevelOptions(t, deviceModels ?? []) : undefined),
|
|
421
|
+
[onModelChange, t, deviceModels],
|
|
372
422
|
);
|
|
373
423
|
|
|
374
|
-
// ──
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
if (voiceAgentState === 'speaking') return 'writing';
|
|
379
|
-
if (voiceAgentState === 'thinking') return 'thinking';
|
|
380
|
-
return 'idle';
|
|
381
|
-
}
|
|
382
|
-
return turnActivityState(messages, isStreaming);
|
|
383
|
-
}, [messages, isStreaming, isVoiceActive, voiceAgentState]);
|
|
384
|
-
|
|
385
|
-
const { user } = useOxy();
|
|
386
|
-
const greeting =
|
|
387
|
-
welcomeGreeting ?? (user ? labels.greetingNamed(getAccountDisplayName(user)) : labels.greeting);
|
|
388
|
-
const welcomeComponent = useMemo(() => {
|
|
389
|
-
if (messages.length > 0) return undefined;
|
|
390
|
-
return (
|
|
391
|
-
<View className="flex-1 items-center justify-center px-4 py-10">
|
|
392
|
-
<AliaWelcomeMessage greeting={greeting} subtitle={welcomeSubtitle} />
|
|
393
|
-
</View>
|
|
394
|
-
);
|
|
395
|
-
}, [greeting, welcomeSubtitle, messages.length]);
|
|
424
|
+
// ── The way back to the newest turn ──
|
|
425
|
+
const threadRef = useRef<AiChatThreadHandle | null>(null);
|
|
426
|
+
const { isAtBottom, onScroll } = useAtBottom();
|
|
427
|
+
const jumpToEnd = useCallback(() => threadRef.current?.scrollToEnd({ animated: false }), []);
|
|
396
428
|
|
|
397
|
-
// ──
|
|
429
|
+
// ── The ambient wave — one persistent overlay across idle, voice, read-aloud and dictation ──
|
|
430
|
+
const tts = useTTS({ apiUrl: runtime.apiUrl, ...runtime.speech });
|
|
431
|
+
const { state: voiceState } = voice;
|
|
398
432
|
const wave = useAmbientWave({
|
|
399
433
|
voice: voiceState
|
|
400
434
|
? {
|
|
401
|
-
isActive:
|
|
435
|
+
isActive: voice.isActive,
|
|
402
436
|
isConnected: voiceState.isConnected,
|
|
403
437
|
agentState: voiceState.agentState,
|
|
404
438
|
waveAmplitude: voiceState.waveAmplitude,
|
|
@@ -406,133 +440,311 @@ function AliaChatContentBody({
|
|
|
406
440
|
: undefined,
|
|
407
441
|
isTTSPlaying: tts.playbackState === 'playing',
|
|
408
442
|
ttsWaveAmplitude: tts.ttsWaveAmplitude,
|
|
409
|
-
isGenerating:
|
|
443
|
+
isGenerating: isLoading,
|
|
410
444
|
});
|
|
411
|
-
|
|
412
445
|
// The welcome and a call are the field's moments, so they get the intro's full strength; a
|
|
413
446
|
// conversation being read keeps the hook's subtle one, so the text stays easy to read.
|
|
414
|
-
const ambientIntensity =
|
|
415
|
-
|
|
416
|
-
// ── Voice activate button for empty submit (only if voice was provided) ──
|
|
417
|
-
const voiceActivateButton = useMemo(
|
|
418
|
-
() =>
|
|
419
|
-
VoiceSession ? (
|
|
420
|
-
<Button
|
|
421
|
-
iconOnly
|
|
422
|
-
tone="action"
|
|
423
|
-
size="md"
|
|
424
|
-
accessibilityLabel={labels.startVoice}
|
|
425
|
-
onPress={activateVoice}
|
|
426
|
-
leadingIcon={VoiceModeIcon}
|
|
427
|
-
/>
|
|
428
|
-
) : undefined,
|
|
429
|
-
[VoiceSession, activateVoice, labels.startVoice],
|
|
430
|
-
);
|
|
431
|
-
|
|
432
|
-
const composerRef = useRef<TextInput | null>(null);
|
|
433
|
-
useFocusWhenPresented(presentedProgress, () => composerRef.current?.focus());
|
|
434
|
-
|
|
435
|
-
const composer =
|
|
436
|
-
VoiceSession && isVoiceActive ? (
|
|
437
|
-
/* Suspense so the session may be handed to us as a React.lazy import. */
|
|
438
|
-
<Suspense fallback={null}>
|
|
439
|
-
<VoiceSession apiUrl={apiUrl} onStateChange={setVoiceState} onEnd={deactivateVoice} />
|
|
440
|
-
</Suspense>
|
|
441
|
-
) : (
|
|
442
|
-
<AliaComposer
|
|
443
|
-
apiUrl={apiUrl}
|
|
444
|
-
inputRef={composerRef}
|
|
445
|
-
disableKeyboardAvoidance
|
|
446
|
-
onKeyPress={suggestions.onKeyPress}
|
|
447
|
-
suggestions={{ ...suggestions, onPick: handlePickSuggestion }}
|
|
448
|
-
// Asked beside the field, not in the thread: the card docks above the composer.
|
|
449
|
-
questions={{
|
|
450
|
-
items: asked.questions,
|
|
451
|
-
onSubmit: (message) => send(message),
|
|
452
|
-
onDismiss: asked.dismiss,
|
|
453
|
-
}}
|
|
454
|
-
value={inputValue}
|
|
455
|
-
onValueChange={setInputValue}
|
|
456
|
-
onSubmit={() => void handleSubmit(inputValue)}
|
|
457
|
-
busy={isStreaming}
|
|
458
|
-
onStop={stop}
|
|
459
|
-
emptyAction={voiceActivateButton}
|
|
460
|
-
placeholder={labels.placeholder}
|
|
461
|
-
addMenu={addMenuGroups}
|
|
462
|
-
onAddMenuSelect={handleAddMenuSelect}
|
|
463
|
-
attachments={photos}
|
|
464
|
-
onAddAttachment={(photo) => setPhotos((prev) => [...prev, photo])}
|
|
465
|
-
onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
|
|
466
|
-
attachmentKinds={PHOTOS_ONLY}
|
|
467
|
-
onError={onError}
|
|
468
|
-
/>
|
|
469
|
-
);
|
|
470
|
-
|
|
471
|
-
const hasMessages = messages.length > 0;
|
|
472
|
-
const headerNode = useMemo(
|
|
473
|
-
() => header?.({ hasMessages, clear, markState, agents }),
|
|
474
|
-
[header, hasMessages, clear, markState, agents],
|
|
475
|
-
);
|
|
447
|
+
const ambientIntensity = voice.isActive || composer.firstTurn ? 1 : wave.intensity;
|
|
476
448
|
const background = useMemo(
|
|
477
449
|
() => (
|
|
478
450
|
<AmbientField
|
|
479
|
-
layout=
|
|
451
|
+
layout={layout}
|
|
480
452
|
waveAmplitude={wave.waveAmplitude}
|
|
481
453
|
agentState={wave.agentState}
|
|
482
454
|
intensity={ambientIntensity}
|
|
483
455
|
isDarkMode={isDarkMode}
|
|
456
|
+
// A screen kept mounted under another does not keep animating back there.
|
|
457
|
+
paused={!isFocused}
|
|
484
458
|
/>
|
|
485
459
|
),
|
|
486
|
-
[wave.waveAmplitude, wave.agentState, ambientIntensity, isDarkMode],
|
|
460
|
+
[layout, wave.waveAmplitude, wave.agentState, ambientIntensity, isDarkMode, isFocused],
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// ── The greeting of an empty chat ──
|
|
464
|
+
const greeting =
|
|
465
|
+
welcomeGreeting ??
|
|
466
|
+
lead.greeting ??
|
|
467
|
+
(isAuthenticated && user
|
|
468
|
+
? labels.greetingNamed(getAccountDisplayName(user, labels.locale))
|
|
469
|
+
: labels.greeting);
|
|
470
|
+
// An agent's chat greets as the agent: nothing, rather than Alia, until its profile is read.
|
|
471
|
+
const welcome = useMemo(
|
|
472
|
+
() =>
|
|
473
|
+
profileLoading ? null : (
|
|
474
|
+
<AliaWelcomeMessage greeting={greeting} subtitle={welcomeSubtitle} lead={lead} />
|
|
475
|
+
),
|
|
476
|
+
[profileLoading, greeting, welcomeSubtitle, lead],
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
// ── The agent's offer to start the next stretch fresh ──
|
|
480
|
+
const createConversation = useCreateConversation();
|
|
481
|
+
/**
|
|
482
|
+
* Take the agent up on its offer: start the next stretch of this thread.
|
|
483
|
+
*
|
|
484
|
+
* The agent cannot do this — its tool emits one frame and writes nothing — so the act is the
|
|
485
|
+
* person's, here. A new conversation with the same agent becomes the most recent, which is the
|
|
486
|
+
* one the thread calls active, so re-reading the thread is what moves the screen onto it. On a
|
|
487
|
+
* chat that is not a thread there is nothing to continue: the offer empties the chat, which is
|
|
488
|
+
* "start a new conversation" on a screen that has no thread.
|
|
489
|
+
*/
|
|
490
|
+
const acceptNewConversation = useCallback(() => {
|
|
491
|
+
dismissSuggestedNewConversation();
|
|
492
|
+
if (agentId === undefined || threadHandle === undefined) {
|
|
493
|
+
void startOver();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
createConversation.mutate(
|
|
497
|
+
{ agentId },
|
|
498
|
+
{
|
|
499
|
+
onSuccess: () => {
|
|
500
|
+
void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.threads.all(threadHandle) });
|
|
501
|
+
// And the history with it: the stretch that was live becomes history the moment a new
|
|
502
|
+
// one is active, and the pages held were fetched before its last turns existed.
|
|
503
|
+
void queryClient.invalidateQueries({
|
|
504
|
+
queryKey: aliaQueryKeys.threads.messages(threadHandle),
|
|
505
|
+
});
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
}, [
|
|
510
|
+
agentId,
|
|
511
|
+
threadHandle,
|
|
512
|
+
createConversation,
|
|
513
|
+
queryClient,
|
|
514
|
+
dismissSuggestedNewConversation,
|
|
515
|
+
startOver,
|
|
516
|
+
]);
|
|
517
|
+
const newConversationOffer = useMemo(
|
|
518
|
+
() =>
|
|
519
|
+
suggestedNewConversation === null
|
|
520
|
+
? undefined
|
|
521
|
+
: {
|
|
522
|
+
reason: suggestedNewConversation,
|
|
523
|
+
onAccept: acceptNewConversation,
|
|
524
|
+
onDismiss: dismissSuggestedNewConversation,
|
|
525
|
+
},
|
|
526
|
+
[suggestedNewConversation, acceptNewConversation, dismissSuggestedNewConversation],
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// ── The chrome ──
|
|
530
|
+
const latestMessages = useRef(messages);
|
|
531
|
+
latestMessages.current = messages;
|
|
532
|
+
const hasMessages = messages.length > 0;
|
|
533
|
+
const state = useMemo<AliaChatState>(
|
|
534
|
+
() => ({
|
|
535
|
+
conversationId,
|
|
536
|
+
hasMessages,
|
|
537
|
+
startOver,
|
|
538
|
+
clearMessages: clearConversation,
|
|
539
|
+
markState: voice.markState,
|
|
540
|
+
participants,
|
|
541
|
+
openSearch: threadHandle === undefined ? undefined : view.openSearch,
|
|
542
|
+
getMessages: () => latestMessages.current,
|
|
543
|
+
}),
|
|
544
|
+
[
|
|
545
|
+
conversationId,
|
|
546
|
+
hasMessages,
|
|
547
|
+
startOver,
|
|
548
|
+
clearConversation,
|
|
549
|
+
voice.markState,
|
|
550
|
+
participants,
|
|
551
|
+
threadHandle,
|
|
552
|
+
view.openSearch,
|
|
553
|
+
],
|
|
487
554
|
);
|
|
555
|
+
const frame = useMemo(() => chrome?.(state) ?? {}, [chrome, state]);
|
|
556
|
+
|
|
557
|
+
// ── The composer ──
|
|
558
|
+
const { editing, edit } = composer;
|
|
559
|
+
const voiceButton = voice.enabled ? (
|
|
560
|
+
<Button
|
|
561
|
+
iconOnly
|
|
562
|
+
tone="action"
|
|
563
|
+
size="md"
|
|
564
|
+
accessibilityLabel={labels.startVoice}
|
|
565
|
+
onPress={voice.activate}
|
|
566
|
+
leadingIcon={VoiceModeIcon}
|
|
567
|
+
/>
|
|
568
|
+
) : undefined;
|
|
569
|
+
const status: ReactNode =
|
|
570
|
+
editing !== undefined ? (
|
|
571
|
+
// What is happening while a question is rewritten, and the way out of it. Drawn as the
|
|
572
|
+
// status tab it replaces: the thread scrolls behind the composer, and a strip without the
|
|
573
|
+
// tab's surface printed its words over the transcript's.
|
|
574
|
+
<View className="mx-7 h-[34px] flex-row items-center gap-2 rounded-t-2xl bg-background-tertiary-default px-2 py-1">
|
|
575
|
+
<RiEditLine size="sm" />
|
|
576
|
+
<Text className="flex-1 text-xs text-muted-foreground" numberOfLines={1}>
|
|
577
|
+
{t('composer.editingMessage')}
|
|
578
|
+
</Text>
|
|
579
|
+
<Button appearance="subtle" tone="accent" size="xs" onPress={edit.cancel}>
|
|
580
|
+
{t('composer.cancelEdit')}
|
|
581
|
+
</Button>
|
|
582
|
+
</View>
|
|
583
|
+
) : (
|
|
584
|
+
composerStatus
|
|
585
|
+
);
|
|
488
586
|
|
|
587
|
+
const composerNode = (
|
|
588
|
+
<>
|
|
589
|
+
{voice.enabled && voice.isActive ? (
|
|
590
|
+
<AliaVoiceCall {...voice.launcher.sessionProps} />
|
|
591
|
+
) : (
|
|
592
|
+
<AliaComposer
|
|
593
|
+
// A different account is a different composer: reads in flight for the last one are
|
|
594
|
+
// aborted rather than landing here.
|
|
595
|
+
key={composer.address.account ?? ''}
|
|
596
|
+
apiUrl={runtime.apiUrl}
|
|
597
|
+
speechLanguage={runtime.speech.language}
|
|
598
|
+
inputRef={composer.inputRef}
|
|
599
|
+
disableKeyboardAvoidance
|
|
600
|
+
onKeyPress={composer.onKeyPress}
|
|
601
|
+
suggestions={composer.suggestions}
|
|
602
|
+
// Asked beside the field, not in the thread: the card docks above the composer.
|
|
603
|
+
questions={{
|
|
604
|
+
items: asked.questions,
|
|
605
|
+
onSubmit: (message) => void composer.sendTurn(message, undefined, composer.turnOptions),
|
|
606
|
+
onDismiss: asked.dismiss,
|
|
607
|
+
}}
|
|
608
|
+
value={composer.text}
|
|
609
|
+
onValueChange={composer.setText}
|
|
610
|
+
onSubmit={() => void composer.submit(composer.text)}
|
|
611
|
+
busy={isLoading}
|
|
612
|
+
disabled={closed}
|
|
613
|
+
onStop={stop}
|
|
614
|
+
emptyAction={voiceButton}
|
|
615
|
+
accessory={
|
|
616
|
+
<ScrollToBottomButton
|
|
617
|
+
visible={!isAtBottom}
|
|
618
|
+
onPress={jumpToEnd}
|
|
619
|
+
accessibilityLabel={t('chat.bloom.scrollToLatest')}
|
|
620
|
+
/>
|
|
621
|
+
}
|
|
622
|
+
placeholder={
|
|
623
|
+
closed
|
|
624
|
+
? t('usageLimit.inputDisabledPlaceholder')
|
|
625
|
+
: editing !== undefined
|
|
626
|
+
? t('composer.editPlaceholder')
|
|
627
|
+
: labels.placeholder(lead.name)
|
|
628
|
+
}
|
|
629
|
+
status={status}
|
|
630
|
+
// The mode selector is the power level: how much power this turn gets.
|
|
631
|
+
{...(modes === undefined
|
|
632
|
+
? {}
|
|
633
|
+
: { modes, mode: model ?? 'auto', onModeChange: onModelChange })}
|
|
634
|
+
addMenu={menu.groups}
|
|
635
|
+
onAddMenuSelect={menu.onSelect}
|
|
636
|
+
attachments={composer.photos}
|
|
637
|
+
onAddAttachment={composer.addAttachment}
|
|
638
|
+
onRemoveAttachment={composer.removeAttachment}
|
|
639
|
+
attachmentKinds={addMenu === undefined ? attachMenu.kinds : undefined}
|
|
640
|
+
onError={onError}
|
|
641
|
+
/>
|
|
642
|
+
)}
|
|
643
|
+
{bottomInset > 0 ? <KeyboardSafeAreaFloor inset={bottomInset} /> : null}
|
|
644
|
+
</>
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
const showsPast = view.jumped;
|
|
489
648
|
return (
|
|
490
649
|
<AliaFlightsProvider value={flights}>
|
|
491
650
|
<AiChatContainer
|
|
492
|
-
header={
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
// The thread scrolls behind the header and the composer, which fade in over it — as
|
|
501
|
-
// in the app's conversation. The wave is the container's background layer, between
|
|
502
|
-
// its surface and the turns.
|
|
651
|
+
header={frame.header}
|
|
652
|
+
title={frame.title}
|
|
653
|
+
project={frame.project}
|
|
654
|
+
actions={frame.actions}
|
|
655
|
+
composer={composerNode}
|
|
656
|
+
// The thread scrolls behind the header and the composer, which fade in over it. The wave
|
|
657
|
+
// is the container's background layer, between its surface and the turns.
|
|
503
658
|
floatingChrome
|
|
504
|
-
//
|
|
505
|
-
surface={
|
|
659
|
+
// A frame that owns the fill and the shape (a sheet, a screen) leaves the surface off.
|
|
660
|
+
surface={surface}
|
|
506
661
|
background={background}
|
|
507
662
|
>
|
|
508
|
-
<
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
663
|
+
<AliaThread
|
|
664
|
+
// Nothing live under a window: it is a view of the past, and the conversation being
|
|
665
|
+
// streamed into is not below it in the thread.
|
|
666
|
+
messages={showsPast ? NO_MESSAGES : messages}
|
|
667
|
+
conversationId={conversationId}
|
|
668
|
+
isLoading={isLoading}
|
|
669
|
+
conversationLoading={conversationLoading}
|
|
670
|
+
history={view.history}
|
|
671
|
+
focusCursor={view.focusCursor}
|
|
672
|
+
failedTurn={failedTurn}
|
|
673
|
+
onRetryTurn={retryFailedTurn}
|
|
674
|
+
onRegenerate={composer.regenerate}
|
|
675
|
+
onStartEdit={edit.start}
|
|
676
|
+
newConversationOffer={newConversationOffer}
|
|
677
|
+
agentActivity={agentRun === null ? null : agentActivity}
|
|
678
|
+
call={voice.isActive ? { agentState: voice.agentState } : undefined}
|
|
679
|
+
empty={welcome}
|
|
680
|
+
threadRef={threadRef}
|
|
681
|
+
onScroll={onScroll}
|
|
682
|
+
/>
|
|
683
|
+
{!showsPast ? null : (
|
|
684
|
+
<View className="absolute inset-x-0 top-16 z-10 items-center" pointerEvents="box-none">
|
|
685
|
+
<Button appearance="outline" tone="neutral" size="sm" onPress={view.backToLatest}>
|
|
686
|
+
{t('chat.backToLatest')}
|
|
687
|
+
</Button>
|
|
688
|
+
</View>
|
|
689
|
+
)}
|
|
690
|
+
{!view.searchOpen || threadHandle === undefined ? null : (
|
|
691
|
+
<ThreadSearch handle={threadHandle} onJump={view.jump} onClose={view.closeSearch} />
|
|
692
|
+
)}
|
|
525
693
|
</AiChatContainer>
|
|
694
|
+
{usageLimit === null ? null : renderUsageLimit?.({ error: usageLimit, dismiss: clearError })}
|
|
526
695
|
{sharedFlights ? null : <AvatarFlightLayer flights={ownFlights} />}
|
|
527
696
|
</AliaFlightsProvider>
|
|
528
697
|
);
|
|
529
698
|
}
|
|
530
699
|
|
|
531
|
-
/**
|
|
532
|
-
|
|
700
|
+
/**
|
|
701
|
+
* The chat: header, thread and composer, with the host's labels in reach of all of it.
|
|
702
|
+
*
|
|
703
|
+
* It is a conversation view over the shared runtime. The conversation open in it is the host's
|
|
704
|
+
* when `conversationId` is given, and its own otherwise.
|
|
705
|
+
*/
|
|
706
|
+
export function AliaChatContent({
|
|
707
|
+
labels,
|
|
708
|
+
conversationId: controlledId,
|
|
709
|
+
onConversationChange,
|
|
710
|
+
runtime,
|
|
711
|
+
apiUrl,
|
|
712
|
+
toolRenderers,
|
|
713
|
+
onFeatureLocked,
|
|
714
|
+
...props
|
|
715
|
+
}: AliaChatContentProps) {
|
|
716
|
+
const [ownId, setOwnId] = useState<string | undefined>(undefined);
|
|
717
|
+
const parent = useAliaRuntime();
|
|
718
|
+
// Who decides which conversation is open: a host that navigates (a route) does; with none, the
|
|
719
|
+
// chat does (the sheet). Either way the host hears of it first, then `onConversationChange`.
|
|
720
|
+
const hostOpens = runtime?.openConversation ?? parent.host.openConversation;
|
|
721
|
+
const hostCloses = runtime?.closeConversation ?? parent.host.closeConversation;
|
|
722
|
+
const hostNavigates = hostOpens !== undefined;
|
|
723
|
+
const host = useMemo<AliaRuntimeHost>(
|
|
724
|
+
() => ({
|
|
725
|
+
...runtime,
|
|
726
|
+
...(onFeatureLocked === undefined ? {} : { onFeatureLocked }),
|
|
727
|
+
openConversation: (id) => {
|
|
728
|
+
hostOpens?.(id);
|
|
729
|
+
if (!hostNavigates) setOwnId(id);
|
|
730
|
+
onConversationChange?.(id);
|
|
731
|
+
},
|
|
732
|
+
closeConversation: () => {
|
|
733
|
+
hostCloses?.();
|
|
734
|
+
if (!hostNavigates) setOwnId(undefined);
|
|
735
|
+
onConversationChange?.(undefined);
|
|
736
|
+
},
|
|
737
|
+
}),
|
|
738
|
+
[runtime, onFeatureLocked, hostOpens, hostCloses, hostNavigates, onConversationChange],
|
|
739
|
+
);
|
|
740
|
+
|
|
533
741
|
return (
|
|
534
742
|
<AliaChatLabelsProvider labels={labels}>
|
|
535
|
-
<
|
|
743
|
+
<AliaRuntimeProvider apiUrl={apiUrl ?? parent.apiUrl} host={host}>
|
|
744
|
+
<AliaToolRenderersProvider value={toolRenderers ?? NO_RENDERERS}>
|
|
745
|
+
<AliaConversationView {...props} conversationId={controlledId ?? ownId} />
|
|
746
|
+
</AliaToolRenderersProvider>
|
|
747
|
+
</AliaRuntimeProvider>
|
|
536
748
|
</AliaChatLabelsProvider>
|
|
537
749
|
);
|
|
538
750
|
}
|