@alia.onl/sdk 15.8.0 → 19.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +242 -13
- package/README.md +223 -151
- package/package.json +15 -5
- package/src/animations.ts +0 -1
- package/src/components/AliaAskProvider.tsx +127 -0
- package/src/components/AliaChatContent.tsx +604 -392
- package/src/components/AliaChatHeader.tsx +14 -38
- package/src/components/AliaChatScreen.tsx +56 -31
- package/src/components/AliaChatSheet.tsx +110 -36
- package/src/components/AliaComposer.tsx +1 -1
- package/src/components/AliaParticipants.tsx +202 -0
- package/src/components/AliaParticipantsHeader.tsx +60 -0
- package/src/components/AliaSharedConversation.tsx +86 -0
- package/src/components/AliaWelcomeMessage.tsx +18 -5
- package/src/components/Image.tsx +12 -0
- package/src/components/Markdown.tsx +498 -14
- package/src/components/PlanPreviewCard.tsx +7 -49
- package/src/components/ReplyMarkdown.tsx +359 -0
- package/src/components/RichBlocks.tsx +195 -0
- package/src/components/canvas/CanvasComponent.tsx +59 -0
- package/src/components/canvas/ChartRenderer.tsx +73 -0
- package/src/components/canvas/CodeRenderer.tsx +24 -0
- package/src/components/canvas/FormRenderer.tsx +101 -0
- package/src/components/canvas/MarkdownRenderer.tsx +14 -0
- package/src/components/canvas/TableRenderer.tsx +47 -0
- package/src/components/thought/FilesAndSources.tsx +120 -0
- package/src/components/thought/ThoughtPanel.tsx +417 -0
- package/src/components/thought/ToolStep.tsx +141 -0
- package/src/components/thread/AgentResultCard.tsx +125 -0
- package/src/components/thread/AgentTaskCard.tsx +127 -0
- package/src/components/thread/AliaThread.tsx +401 -0
- package/src/components/thread/MessageBlockBoundary.tsx +78 -0
- package/src/components/thread/NewConversationOffer.tsx +54 -0
- package/src/components/thread/ThreadRow.tsx +458 -0
- package/src/components/thread/ThreadSearch.tsx +129 -0
- package/src/components/thread/ToolResultCard.tsx +254 -0
- package/src/components/thread/TurnMenu.tsx +54 -0
- package/src/components/thread/TurnStatusLine.tsx +34 -0
- package/src/components/voice/AliaVoiceCall.tsx +21 -0
- package/src/components/voice/VoiceControls.tsx +2 -28
- package/src/components/voice/VoiceSession.tsx +39 -11
- package/src/hooks/useAliaActivity.ts +2 -2
- package/src/hooks/useAtBottom.ts +51 -0
- package/src/hooks/useAttachMenu.ts +94 -0
- package/src/hooks/useChatComposer.ts +299 -0
- package/src/hooks/useChatVoice.ts +129 -0
- package/src/hooks/useDocumentPicker.ts +3 -29
- package/src/hooks/useFailedTurnToast.ts +43 -0
- package/src/hooks/useMessageVote.ts +50 -0
- package/src/hooks/useOpenThought.ts +79 -0
- package/src/hooks/usePendingQuestions.ts +2 -2
- package/src/hooks/useReadAloud.ts +92 -0
- package/src/hooks/useShareHandler.ts +24 -0
- package/src/hooks/useSoundEffects.ts +1 -1
- package/src/hooks/useThoughtScope.ts +59 -0
- package/src/hooks/useThreadRows.ts +145 -0
- package/src/hooks/useThreadView.ts +68 -0
- package/src/hooks/useTimelineWindow.ts +256 -0
- package/src/hooks/useVoiceLauncher.ts +180 -0
- package/src/hooks/useVoiceRoom.ts +0 -3
- package/src/index.ts +43 -29
- package/src/lib/activity-store.ts +1 -1
- package/src/lib/add-menu.ts +7 -0
- package/src/lib/call-thread.ts +33 -0
- package/src/lib/citations.ts +79 -2
- package/src/lib/clipboard.ts +23 -0
- package/src/lib/content-utils.ts +10 -0
- package/src/lib/copy-en.ts +284 -0
- package/src/lib/copy.ts +32 -0
- package/src/lib/document-picker.ts +31 -0
- package/src/lib/flights.ts +22 -13
- package/src/lib/focus-return.ts +66 -0
- package/src/lib/keyboard.native.tsx +19 -0
- package/src/lib/keyboard.tsx +9 -0
- package/src/lib/labels.tsx +69 -17
- package/src/lib/participants.ts +86 -0
- package/src/lib/power-level-options.ts +66 -0
- package/src/lib/questions.ts +2 -2
- package/src/lib/task-labels.ts +4 -4
- package/src/lib/tool-call-status.ts +1 -1
- package/src/lib/tool-icons.ts +36 -0
- package/src/lib/tool-renderers.tsx +48 -0
- package/src/lib/turn-state.ts +2 -2
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +76 -34
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +32 -14
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +123 -0
- package/src/runtime/attachment-utils.ts +96 -0
- package/src/runtime/composer-draft-store.ts +270 -0
- package/src/runtime/config.tsx +186 -0
- package/src/runtime/conversation-store.ts +49 -0
- package/src/runtime/conversations.ts +557 -0
- package/src/runtime/error-utils.ts +104 -0
- package/src/runtime/host-tools.ts +140 -0
- package/src/runtime/index.ts +113 -0
- package/src/runtime/message-history.ts +124 -0
- package/src/runtime/model.ts +39 -0
- package/src/runtime/oxy-files.ts +37 -0
- package/src/runtime/power-levels.ts +66 -0
- package/src/runtime/query-keys.ts +34 -0
- package/src/runtime/random-uuid.ts +25 -0
- package/src/runtime/routes.ts +51 -0
- package/src/runtime/sharing.ts +165 -0
- package/src/runtime/sse-frame-reader.ts +88 -0
- package/src/runtime/stream-events.ts +146 -0
- package/src/runtime/stream-outcome.ts +21 -0
- package/src/runtime/thought-store.ts +103 -0
- package/src/runtime/thread/agent-activity.ts +261 -0
- package/src/runtime/thread/automation-labels.ts +79 -0
- package/src/runtime/thread/conversation-export.ts +135 -0
- package/src/runtime/thread/message-days.ts +120 -0
- package/src/runtime/thread/message-processor.ts +93 -0
- package/src/runtime/thread/task-utils.ts +11 -0
- package/src/runtime/thread/thought-utils.ts +752 -0
- package/src/runtime/thread/thread-history.ts +139 -0
- package/src/runtime/thread/timeline.ts +202 -0
- package/src/runtime/thread/title-tags.ts +8 -0
- package/src/runtime/thread/tool-cards.ts +383 -0
- package/src/runtime/turn-failure.ts +108 -0
- package/src/runtime/types.ts +148 -0
- package/src/runtime/usage-limit-error.ts +44 -0
- package/src/runtime/use-agent-activity.ts +80 -0
- package/src/runtime/use-conversation-stream.ts +1427 -0
- package/src/runtime/use-conversation-thread.ts +556 -0
- package/src/runtime/use-start-conversation.ts +107 -0
- package/src/runtime/use-thread-history.ts +64 -0
- package/src/runtime/use-thread-search.ts +95 -0
- package/src/runtime/use-turn-edit.ts +99 -0
- package/src/tools.ts +2 -2
- package/src/types.ts +18 -47
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/catalogue.ts +0 -236
- package/src/lib/chat-stream.ts +0 -655
- package/src/lib/chat-transport.ts +0 -98
- package/src/lib/delegation.ts +0 -34
- package/src/lib/work-log-copy.ts +0 -61
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FairCoinCardData,
|
|
3
|
+
type MarketCardData,
|
|
4
|
+
type PriceChart,
|
|
5
|
+
type ScheduledTaskCardData,
|
|
6
|
+
type TemperatureUnit,
|
|
7
|
+
type ToolCard,
|
|
8
|
+
type WeatherCardData,
|
|
9
|
+
degrees,
|
|
10
|
+
faircoinChart,
|
|
11
|
+
forecastWeekday,
|
|
12
|
+
formatPrice,
|
|
13
|
+
marketChart,
|
|
14
|
+
triggerLabel,
|
|
15
|
+
} from '../../runtime/model';
|
|
16
|
+
import { useLabels } from '../../lib/labels';
|
|
17
|
+
import { useAliaRuntime } from '../../runtime/config';
|
|
18
|
+
import { AiChatMessageLine } from '@oxy.so/bloom/ai-chat';
|
|
19
|
+
import { Badge } from '@oxy.so/bloom/badge';
|
|
20
|
+
import { Card, CardBody, CardHeader } from '@oxy.so/bloom/card';
|
|
21
|
+
import { LineChartCard } from '@oxy.so/bloom/chart-cards';
|
|
22
|
+
import { IconCircle } from '@oxy.so/bloom/icon-circle';
|
|
23
|
+
import { RiArrowRightSLine } from '@oxy.so/bloom/icons/RiArrowRightSLine';
|
|
24
|
+
import { RiCalendarScheduleLine } from '@oxy.so/bloom/icons/RiCalendarScheduleLine';
|
|
25
|
+
import { RiCloudLine } from '@oxy.so/bloom/icons/RiCloudLine';
|
|
26
|
+
import { RiDropLine } from '@oxy.so/bloom/icons/RiDropLine';
|
|
27
|
+
import { RiSnowflakeLine } from '@oxy.so/bloom/icons/RiSnowflakeLine';
|
|
28
|
+
import { RiSunFoggyLine } from '@oxy.so/bloom/icons/RiSunFoggyLine';
|
|
29
|
+
import { RiSunLine } from '@oxy.so/bloom/icons/RiSunLine';
|
|
30
|
+
import { Item } from '@oxy.so/bloom/item';
|
|
31
|
+
import {
|
|
32
|
+
SegmentedControl,
|
|
33
|
+
SegmentedControlItem,
|
|
34
|
+
SegmentedControlItemText,
|
|
35
|
+
} from '@oxy.so/bloom/segmented-control';
|
|
36
|
+
import { Muted, Text } from '@oxy.so/bloom/typography';
|
|
37
|
+
import { useState } from 'react';
|
|
38
|
+
import { View } from 'react-native';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A tool result drawn inside the assistant turn, each card one block line of
|
|
42
|
+
* the reply (`AiChatMessageLine block`) so it reveals with the turn.
|
|
43
|
+
*
|
|
44
|
+
* - market, FairCoin → Bloom `LineChartCard`, one range per stored period
|
|
45
|
+
* - weather → `Card` with an `IconCircle` and the forecast as `Item`s
|
|
46
|
+
* - scheduled task → `Card` with an `Item` that opens the automation
|
|
47
|
+
*/
|
|
48
|
+
export function ToolResultCard({ card }: { card: ToolCard }) {
|
|
49
|
+
switch (card.type) {
|
|
50
|
+
case 'market':
|
|
51
|
+
return <MarketResult data={card.data} />;
|
|
52
|
+
case 'faircoin':
|
|
53
|
+
return <FairCoinResult data={card.data} />;
|
|
54
|
+
case 'weather':
|
|
55
|
+
return <WeatherResult data={card.data} />;
|
|
56
|
+
case 'scheduled-task':
|
|
57
|
+
return <ScheduledTaskResult data={card.data} />;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function PriceLineChart({ chart }: { chart: PriceChart }) {
|
|
62
|
+
return (
|
|
63
|
+
<LineChartCard
|
|
64
|
+
title={chart.title}
|
|
65
|
+
ranges={chart.ranges}
|
|
66
|
+
defaultRange={chart.defaultRange}
|
|
67
|
+
rangesLabel={chart.title}
|
|
68
|
+
format={chart.format}
|
|
69
|
+
formatAxisValue={chart.formatAxisValue}
|
|
70
|
+
accessibilityLabel={chart.title}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Notes({ lines }: { lines: readonly string[] }) {
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
{lines.map((line) => (
|
|
79
|
+
<AiChatMessageLine key={line} tone="secondary">
|
|
80
|
+
{line}
|
|
81
|
+
</AiChatMessageLine>
|
|
82
|
+
))}
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A quote with no line to draw still says its price, in words. */
|
|
88
|
+
function PriceInWords({ title, price, hint }: { title: string; price: string; hint?: string }) {
|
|
89
|
+
return (
|
|
90
|
+
<Card>
|
|
91
|
+
<CardBody>
|
|
92
|
+
<Muted>{title}</Muted>
|
|
93
|
+
<Text variant="title-1-medium">{price}</Text>
|
|
94
|
+
{hint === undefined ? null : <Muted>{hint}</Muted>}
|
|
95
|
+
</CardBody>
|
|
96
|
+
</Card>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function MarketResult({ data }: { data: MarketCardData }) {
|
|
101
|
+
const { t, locale } = useLabels();
|
|
102
|
+
const chart = marketChart(data, t, locale);
|
|
103
|
+
return (
|
|
104
|
+
<>
|
|
105
|
+
<AiChatMessageLine block>
|
|
106
|
+
{chart !== null ? (
|
|
107
|
+
<PriceLineChart chart={chart} />
|
|
108
|
+
) : (
|
|
109
|
+
<PriceInWords
|
|
110
|
+
title={`${data.name} · ${data.symbol}`}
|
|
111
|
+
price={
|
|
112
|
+
typeof data.price === 'number'
|
|
113
|
+
? formatPrice(data.price, data.currency, locale)
|
|
114
|
+
: t('market.noPrice')
|
|
115
|
+
}
|
|
116
|
+
hint={t('market.noChart')}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
</AiChatMessageLine>
|
|
120
|
+
{chart === null ? null : <Notes lines={chart.notes} />}
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function FairCoinResult({ data }: { data: FairCoinCardData }) {
|
|
126
|
+
const { t, locale } = useLabels();
|
|
127
|
+
const { chart, notes } = faircoinChart(data, t, locale);
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<AiChatMessageLine block>
|
|
131
|
+
{chart !== null ? (
|
|
132
|
+
<PriceLineChart chart={chart} />
|
|
133
|
+
) : (
|
|
134
|
+
<PriceInWords
|
|
135
|
+
title={`${t('faircoin.name')} · ${t('faircoin.symbol')}`}
|
|
136
|
+
price={
|
|
137
|
+
typeof data.price === 'number' && Number.isFinite(data.price)
|
|
138
|
+
? formatPrice(data.price, 'USD', locale)
|
|
139
|
+
: t('faircoin.noPrice')
|
|
140
|
+
}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
</AiChatMessageLine>
|
|
144
|
+
<Notes lines={notes} />
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const CONDITION_ICON: Record<string, typeof RiSunLine> = {
|
|
150
|
+
clear: RiSunLine,
|
|
151
|
+
'partly-cloudy': RiSunFoggyLine,
|
|
152
|
+
cloudy: RiCloudLine,
|
|
153
|
+
fog: RiSunFoggyLine,
|
|
154
|
+
drizzle: RiDropLine,
|
|
155
|
+
rain: RiDropLine,
|
|
156
|
+
showers: RiDropLine,
|
|
157
|
+
snow: RiSnowflakeLine,
|
|
158
|
+
'snow-showers': RiSnowflakeLine,
|
|
159
|
+
thunderstorm: RiCloudLine,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const conditionIcon = (condition: string) => CONDITION_ICON[condition] ?? RiCloudLine;
|
|
163
|
+
|
|
164
|
+
function WeatherResult({ data }: { data: WeatherCardData }) {
|
|
165
|
+
const { t, locale } = useLabels();
|
|
166
|
+
/** View state, deliberately not persisted: nobody reopens a chat for its unit. */
|
|
167
|
+
const [unit, setUnit] = useState<TemperatureUnit>('C');
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<AiChatMessageLine block>
|
|
171
|
+
<Card>
|
|
172
|
+
<CardHeader>
|
|
173
|
+
<View className="flex-row items-center gap-3">
|
|
174
|
+
<IconCircle icon={conditionIcon(data.current.condition)} size="lg" />
|
|
175
|
+
<View className="min-w-0 flex-1">
|
|
176
|
+
<Muted>{data.place}</Muted>
|
|
177
|
+
<Text variant="title-1-medium">{degrees(data.current.temperature, unit)}</Text>
|
|
178
|
+
<Muted>{t(`weather.condition.${data.current.condition}`)}</Muted>
|
|
179
|
+
</View>
|
|
180
|
+
<SegmentedControl
|
|
181
|
+
label={t('weather.toggleUnit')}
|
|
182
|
+
type="radio"
|
|
183
|
+
value={unit}
|
|
184
|
+
onValueChange={(next: string) => setUnit(next === 'F' ? 'F' : 'C')}
|
|
185
|
+
>
|
|
186
|
+
<SegmentedControlItem value="C">
|
|
187
|
+
<SegmentedControlItemText>°C</SegmentedControlItemText>
|
|
188
|
+
</SegmentedControlItem>
|
|
189
|
+
<SegmentedControlItem value="F">
|
|
190
|
+
<SegmentedControlItemText>°F</SegmentedControlItemText>
|
|
191
|
+
</SegmentedControlItem>
|
|
192
|
+
</SegmentedControl>
|
|
193
|
+
</View>
|
|
194
|
+
</CardHeader>
|
|
195
|
+
{data.daily.length === 0 ? null : (
|
|
196
|
+
<CardBody>
|
|
197
|
+
{data.daily.map((day) => {
|
|
198
|
+
const Icon = conditionIcon(day.condition);
|
|
199
|
+
return (
|
|
200
|
+
<Item
|
|
201
|
+
key={day.date}
|
|
202
|
+
density="compact"
|
|
203
|
+
role="listitem"
|
|
204
|
+
leading={<Icon size="sm" />}
|
|
205
|
+
title={forecastWeekday(day.date, locale)}
|
|
206
|
+
subtitle={t(`weather.condition.${day.condition}`)}
|
|
207
|
+
trailing={
|
|
208
|
+
<Text variant="body-medium">
|
|
209
|
+
{`${degrees(day.high, unit)} / ${degrees(day.low, unit)}`}
|
|
210
|
+
</Text>
|
|
211
|
+
}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
215
|
+
</CardBody>
|
|
216
|
+
)}
|
|
217
|
+
</Card>
|
|
218
|
+
</AiChatMessageLine>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function ScheduledTaskResult({ data }: { data: ScheduledTaskCardData }) {
|
|
223
|
+
const { t } = useLabels();
|
|
224
|
+
const { openAutomation } = useAliaRuntime().host;
|
|
225
|
+
return (
|
|
226
|
+
<AiChatMessageLine block>
|
|
227
|
+
<Card>
|
|
228
|
+
<Item
|
|
229
|
+
leading={<RiCalendarScheduleLine size="md" />}
|
|
230
|
+
title={data.objective}
|
|
231
|
+
subtitle={triggerLabel(data.trigger, t)}
|
|
232
|
+
trailing={
|
|
233
|
+
<View className="flex-row items-center gap-2">
|
|
234
|
+
<Badge
|
|
235
|
+
variant="subtle"
|
|
236
|
+
color={data.enabled ? 'success' : 'default'}
|
|
237
|
+
content={t(data.enabled ? 'chat.toolCard.scheduled' : 'chat.toolCard.paused')}
|
|
238
|
+
/>
|
|
239
|
+
{openAutomation === undefined ? null : <RiArrowRightSLine size="sm" />}
|
|
240
|
+
</View>
|
|
241
|
+
}
|
|
242
|
+
// Pressable only where the host has a place to show the task.
|
|
243
|
+
{...(openAutomation === undefined
|
|
244
|
+
? {}
|
|
245
|
+
: {
|
|
246
|
+
accessibilityRole: 'link' as const,
|
|
247
|
+
accessibilityLabel: t('chat.toolCard.openTask', { objective: data.objective }),
|
|
248
|
+
onPress: () => openAutomation(data.id),
|
|
249
|
+
})}
|
|
250
|
+
/>
|
|
251
|
+
</Card>
|
|
252
|
+
</AiChatMessageLine>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AiChatTurnAction } from '@oxy.so/bloom/ai-chat';
|
|
2
|
+
import {
|
|
3
|
+
ContextMenu,
|
|
4
|
+
ContextMenuContent,
|
|
5
|
+
ContextMenuItem,
|
|
6
|
+
ContextMenuTrigger,
|
|
7
|
+
} from '@oxy.so/bloom/context-menu';
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { Platform } from 'react-native';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The turn's actions as a long-press menu, on native.
|
|
13
|
+
*
|
|
14
|
+
* What a turn can do besides being read — copy, read aloud, regenerate, edit — is drawn by the
|
|
15
|
+
* row with Bloom's turn actions: the feedback row under a reply, the action row under a question.
|
|
16
|
+
* A phone has no hover, and those rows are small targets under a long reply; a long press anywhere
|
|
17
|
+
* on the turn is where people look for them. Web keeps the rows alone: there a right-click on a
|
|
18
|
+
* reply is the browser's own menu, with the text selection in it, and taking that away to show
|
|
19
|
+
* the same buttons again would be a loss.
|
|
20
|
+
*/
|
|
21
|
+
const STRETCH = { alignSelf: 'stretch' } as const;
|
|
22
|
+
|
|
23
|
+
export function TurnMenu({
|
|
24
|
+
actions,
|
|
25
|
+
label,
|
|
26
|
+
children,
|
|
27
|
+
}: {
|
|
28
|
+
actions: ReadonlyArray<AiChatTurnAction>;
|
|
29
|
+
label: string;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
if (Platform.OS === 'web' || actions.length === 0) return <>{children}</>;
|
|
33
|
+
return (
|
|
34
|
+
<ContextMenu>
|
|
35
|
+
{/* Stretched: Bloom's trigger hugs its content by default, and a turn measures the column
|
|
36
|
+
it is in (a question is half of it). */}
|
|
37
|
+
<ContextMenuTrigger label={label} style={STRETCH}>
|
|
38
|
+
{children}
|
|
39
|
+
</ContextMenuTrigger>
|
|
40
|
+
<ContextMenuContent>
|
|
41
|
+
{actions.map(({ key, label: rowLabel, icon: Icon, onPress, disabled }) => (
|
|
42
|
+
<ContextMenuItem
|
|
43
|
+
key={key}
|
|
44
|
+
onPress={onPress}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
leading={<Icon size="sm" />}
|
|
47
|
+
>
|
|
48
|
+
{rowLabel}
|
|
49
|
+
</ContextMenuItem>
|
|
50
|
+
))}
|
|
51
|
+
</ContextMenuContent>
|
|
52
|
+
</ContextMenu>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AiChatMessageLine } from '@oxy.so/bloom/ai-chat';
|
|
2
|
+
import { Text } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A turn's status line — "Worked for 5m 32s", "Reasoning" — in the template's
|
|
6
|
+
* secondary tone, and the way into that turn's thought panel.
|
|
7
|
+
*
|
|
8
|
+
* The press target is a nested `Text`, so the line keeps Bloom's paragraph
|
|
9
|
+
* (its reveal, its tone) and only the words become the control. It works for
|
|
10
|
+
* any turn, not only the one in flight: the panel is opened on this message,
|
|
11
|
+
* in the conversation it belongs to.
|
|
12
|
+
*/
|
|
13
|
+
export function TurnStatusLine({
|
|
14
|
+
label,
|
|
15
|
+
hint,
|
|
16
|
+
onPress,
|
|
17
|
+
}: {
|
|
18
|
+
label: string;
|
|
19
|
+
/** What pressing it does, for assistive technology. */
|
|
20
|
+
hint?: string;
|
|
21
|
+
onPress?: () => void;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<AiChatMessageLine tone="secondary" selectable={false}>
|
|
25
|
+
{onPress === undefined ? (
|
|
26
|
+
label
|
|
27
|
+
) : (
|
|
28
|
+
<Text accessibilityRole="button" accessibilityHint={hint} onPress={onPress}>
|
|
29
|
+
{label}
|
|
30
|
+
</Text>
|
|
31
|
+
)}
|
|
32
|
+
</AiChatMessageLine>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call of a conversation screen: its bar, while a call is open.
|
|
3
|
+
*
|
|
4
|
+
* The call and its audio engine load on the first call, not with the chat, so a screen that
|
|
5
|
+
* never calls never evaluates `react-native-audio-api`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { lazy, Suspense } from 'react';
|
|
9
|
+
import type { VoiceSessionProps } from '../../types';
|
|
10
|
+
|
|
11
|
+
const VoiceSession = lazy(() =>
|
|
12
|
+
import('./VoiceSession').then((module) => ({ default: module.VoiceSession })),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export function AliaVoiceCall(props: VoiceSessionProps) {
|
|
16
|
+
return (
|
|
17
|
+
<Suspense fallback={null}>
|
|
18
|
+
<VoiceSession {...props} />
|
|
19
|
+
</Suspense>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -8,36 +8,10 @@ import { Loading } from '@oxy.so/bloom/loading';
|
|
|
8
8
|
import { Text } from '@oxy.so/bloom/typography';
|
|
9
9
|
import { VoiceControls as BloomVoiceControls, type VoiceState } from '@oxy.so/bloom/voice';
|
|
10
10
|
import { View } from 'react-native';
|
|
11
|
+
import { VOICE_CONTROLS_LABELS, type VoiceControlsLabels } from '../../lib/voice-controls-labels';
|
|
11
12
|
import type { RoomState, AgentState, VoiceCallState } from '../../types';
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
export interface VoiceControlsLabels {
|
|
15
|
-
connecting: string;
|
|
16
|
-
reconnecting: string;
|
|
17
|
-
connected: string;
|
|
18
|
-
listening: string;
|
|
19
|
-
muted: string;
|
|
20
|
-
thinking: string;
|
|
21
|
-
usingTool: string;
|
|
22
|
-
speaking: string;
|
|
23
|
-
mute: string;
|
|
24
|
-
unmute: string;
|
|
25
|
-
end: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const VOICE_CONTROLS_LABELS: VoiceControlsLabels = {
|
|
29
|
-
connecting: 'Connecting...',
|
|
30
|
-
reconnecting: 'Reconnecting...',
|
|
31
|
-
connected: 'Connected',
|
|
32
|
-
listening: 'Listening...',
|
|
33
|
-
muted: 'Muted',
|
|
34
|
-
thinking: 'Thinking...',
|
|
35
|
-
usingTool: 'Working on it...',
|
|
36
|
-
speaking: 'Speaking...',
|
|
37
|
-
mute: 'Mute',
|
|
38
|
-
unmute: 'Unmute',
|
|
39
|
-
end: 'End',
|
|
40
|
-
};
|
|
14
|
+
export { VOICE_CONTROLS_LABELS, type VoiceControlsLabels };
|
|
41
15
|
|
|
42
16
|
export interface VoiceControlsProps {
|
|
43
17
|
roomState: RoomState;
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A live voice call, mounted only while voice mode is on.
|
|
3
3
|
*
|
|
4
|
-
* The chat shell receives it as the `voiceSession` prop rather than importing
|
|
5
|
-
* it, so a text-only consumer never compiles the call, its audio engine or
|
|
6
|
-
* the call UI — which is the reason the `@alia.onl/sdk/voice` entry exists.
|
|
7
|
-
*
|
|
8
4
|
* Mounting starts the call; unmounting ends it (`useVoiceRoom` stops listening
|
|
9
|
-
* and speaking on unmount), so the
|
|
5
|
+
* and speaking on unmount), so the screen only has to decide whether to render it.
|
|
6
|
+
*
|
|
7
|
+
* It also says what happened, in the chat's language (`labels`): a failure, a call moved to
|
|
8
|
+
* another device or cut by the server, the time limit near. A hang-up says nothing. Every
|
|
9
|
+
* surface that offers a call gets this one, so they cannot disagree.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { useEffect, useRef } from 'react';
|
|
13
13
|
import { useVoiceRoom } from '../../hooks/useVoiceRoom';
|
|
14
|
-
import {
|
|
14
|
+
import { toastNotice } from '../../hooks/useVoiceLauncher';
|
|
15
15
|
import { useAudioLevelMonitor } from '../../hooks/useAudioLevelMonitor';
|
|
16
16
|
import { useAudioLevels } from '../../hooks/useAudioLevels';
|
|
17
|
+
import { useLabels } from '../../lib/labels';
|
|
17
18
|
import { VoiceControls } from './VoiceControls';
|
|
18
19
|
import type { VoiceSessionProps } from '../../types';
|
|
19
20
|
|
|
@@ -23,16 +24,21 @@ export function VoiceSession({
|
|
|
23
24
|
agentId,
|
|
24
25
|
locale,
|
|
25
26
|
voice,
|
|
27
|
+
onMessagesSaved,
|
|
28
|
+
onNotice = toastNotice,
|
|
26
29
|
onStateChange,
|
|
27
30
|
onEnd,
|
|
28
31
|
}: VoiceSessionProps) {
|
|
32
|
+
const labels = useLabels();
|
|
29
33
|
const {
|
|
30
34
|
room,
|
|
31
35
|
callState,
|
|
32
36
|
roomState,
|
|
33
37
|
agentState,
|
|
34
38
|
isMuted,
|
|
35
|
-
|
|
39
|
+
errorCode,
|
|
40
|
+
endedReason,
|
|
41
|
+
endingSoonMs,
|
|
36
42
|
messages,
|
|
37
43
|
isConnected,
|
|
38
44
|
connect,
|
|
@@ -43,6 +49,7 @@ export function VoiceSession({
|
|
|
43
49
|
...(agentId === undefined ? {} : { agentId }),
|
|
44
50
|
...(locale === undefined ? {} : { lang: locale }),
|
|
45
51
|
...(voice === undefined ? {} : { voicePreference: voice }),
|
|
52
|
+
...(onMessagesSaved === undefined ? {} : { onMessagesSaved }),
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
const { captureLevel, playbackLevel } = useAudioLevelMonitor(room, isConnected);
|
|
@@ -66,18 +73,37 @@ export function VoiceSession({
|
|
|
66
73
|
onStateChange({ isConnected, agentState, waveAmplitude, messages });
|
|
67
74
|
}, [isConnected, agentState, waveAmplitude, messages, onStateChange]);
|
|
68
75
|
|
|
76
|
+
// The call is about to reach its time limit: said once, and shown in the bar.
|
|
77
|
+
const notice = endingSoonMs === null ? null : labels.voiceEndingSoon;
|
|
78
|
+
const warnedRef = useRef(false);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (notice === null || warnedRef.current) return;
|
|
81
|
+
warnedRef.current = true;
|
|
82
|
+
onNotice({ kind: 'warning', text: notice });
|
|
83
|
+
}, [notice, onNotice]);
|
|
84
|
+
|
|
69
85
|
// End the call on failure, or once a call we actually reached ends. The
|
|
70
86
|
// "reached" flag is what keeps the pre-connect `disconnected` state — which
|
|
71
87
|
// every session starts in — from ending the call before it begins.
|
|
72
88
|
const reachedRoomRef = useRef(false);
|
|
89
|
+
const finishedRef = useRef(false);
|
|
73
90
|
useEffect(() => {
|
|
91
|
+
if (finishedRef.current) return;
|
|
74
92
|
if (roomState !== 'disconnected') reachedRoomRef.current = true;
|
|
75
|
-
if (
|
|
76
|
-
|
|
93
|
+
if (errorCode !== null || roomState === 'error') {
|
|
94
|
+
finishedRef.current = true;
|
|
95
|
+
onNotice({ kind: 'error', text: labels.voiceError(errorCode ?? 'realtime-failed') });
|
|
96
|
+
onEnd();
|
|
77
97
|
return;
|
|
78
98
|
}
|
|
79
|
-
if (roomState === 'disconnected' && reachedRoomRef.current)
|
|
80
|
-
|
|
99
|
+
if (roomState === 'disconnected' && reachedRoomRef.current) {
|
|
100
|
+
finishedRef.current = true;
|
|
101
|
+
// Moved to another device, the time limit, the credits: information. A hang-up: nothing.
|
|
102
|
+
const text = endedReason === null ? null : labels.voiceEnded(endedReason);
|
|
103
|
+
if (text !== null) onNotice({ kind: 'info', text });
|
|
104
|
+
onEnd();
|
|
105
|
+
}
|
|
106
|
+
}, [errorCode, endedReason, roomState, onEnd, onNotice, labels]);
|
|
81
107
|
|
|
82
108
|
return (
|
|
83
109
|
<VoiceControls
|
|
@@ -86,7 +112,9 @@ export function VoiceSession({
|
|
|
86
112
|
isMuted={isMuted}
|
|
87
113
|
onToggleMute={toggleMute}
|
|
88
114
|
onEnd={onEnd}
|
|
115
|
+
notice={notice}
|
|
89
116
|
callState={callState}
|
|
117
|
+
labels={labels.voiceControls}
|
|
90
118
|
/>
|
|
91
119
|
);
|
|
92
120
|
}
|
|
@@ -6,7 +6,7 @@ import { acquireActivitySocket } from '../lib/activity-socket';
|
|
|
6
6
|
import { ALIA_API_URL } from '../lib/origins';
|
|
7
7
|
|
|
8
8
|
export interface UseAliaActivityOptions {
|
|
9
|
-
/** The API the activity socket connects to. Defaults
|
|
9
|
+
/** The API the activity socket connects to. Defaults to the runtime's `apiUrl`. */
|
|
10
10
|
apiUrl?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ export interface UseAliaActivityOptions {
|
|
|
14
14
|
* What Alia is doing right now, for any surface that wants to show it — an "Ask Alia" button's
|
|
15
15
|
* avatar, a status dot. It is the busiest of:
|
|
16
16
|
*
|
|
17
|
-
* - a turn running in this app (`
|
|
17
|
+
* - a turn running in this app (`useConversationThread`, so every chat drives every button), and
|
|
18
18
|
* - her runs for the signed-in person elsewhere, reported by the server over one shared socket
|
|
19
19
|
* (background work, another device), connected only while someone is signed in AND using this.
|
|
20
20
|
*/
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/** How near the end still counts as being at the bottom, px. */
|
|
5
|
+
export const AT_BOTTOM_THRESHOLD = 50;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether the reader is at the end of the thread, for the jump-to-present
|
|
9
|
+
* button.
|
|
10
|
+
*
|
|
11
|
+
* ## What this replaced
|
|
12
|
+
*
|
|
13
|
+
* `use-scroll-to-bottom.ts`, 183 lines, which owned four things at once: the
|
|
14
|
+
* sticky follow, the upward pagination trigger, the anchor that held the
|
|
15
|
+
* reader's position while a page landed above them, and this. Bloom's
|
|
16
|
+
* `AiChatThread` owns the first three now — `followThreshold`,
|
|
17
|
+
* `onStartReached` and `maintainStartPosition`, added in 3.3.0 for exactly
|
|
18
|
+
* this adoption — and it implements them the same way, measuring the threshold
|
|
19
|
+
* against the height BEFORE the growth and adding a landed page's delta to the
|
|
20
|
+
* offset.
|
|
21
|
+
*
|
|
22
|
+
* What Bloom does NOT answer is this question, because it is the host's: a
|
|
23
|
+
* thread has no opinion about whether something should float over it offering
|
|
24
|
+
* to jump. So the one piece that did not move is the one that was never
|
|
25
|
+
* Bloom's.
|
|
26
|
+
*
|
|
27
|
+
* ## Why it is a ref plus a coarse boolean
|
|
28
|
+
*
|
|
29
|
+
* `onScroll` fires at 16ms while an answer streams, and this feeds a button
|
|
30
|
+
* that has exactly two appearances. Setting state on every event would
|
|
31
|
+
* re-render the whole screen sixty times a second to change nothing; the ref
|
|
32
|
+
* holds the live number and the state changes only when the ANSWER changes.
|
|
33
|
+
*/
|
|
34
|
+
export function useAtBottom(threshold: number = AT_BOTTOM_THRESHOLD) {
|
|
35
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
36
|
+
const atBottom = useRef(true);
|
|
37
|
+
|
|
38
|
+
const onScroll = useCallback(
|
|
39
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
40
|
+
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
|
41
|
+
const distance = contentSize.height - contentOffset.y - layoutMeasurement.height;
|
|
42
|
+
const next = distance <= threshold;
|
|
43
|
+
if (next === atBottom.current) return;
|
|
44
|
+
atBottom.current = next;
|
|
45
|
+
setIsAtBottom(next);
|
|
46
|
+
},
|
|
47
|
+
[threshold],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return { isAtBottom, onScroll };
|
|
51
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { RiFileTextLine } from '@oxy.so/bloom/icons/RiFileTextLine';
|
|
2
|
+
import { RiImageLine } from '@oxy.so/bloom/icons/RiImageLine';
|
|
3
|
+
import { useCallback, useMemo } from 'react';
|
|
4
|
+
import type { AliaAddMenu } from '../lib/add-menu';
|
|
5
|
+
import type { IntakeKind } from '../lib/attachment-intake';
|
|
6
|
+
import { useLabels } from '../lib/labels';
|
|
7
|
+
import { ALIA_FEATURES, type FeatureOffer } from '../runtime/agents';
|
|
8
|
+
import { type DraftAddress, useComposerDraftStore } from '../runtime/composer-draft-store';
|
|
9
|
+
import type { AliaAttachment } from '../types';
|
|
10
|
+
import { useImagePicker } from './useImagePicker';
|
|
11
|
+
|
|
12
|
+
/** What the chat offers to attach: each attachment feature's offer for this lead and person. */
|
|
13
|
+
export interface AttachOffers {
|
|
14
|
+
images: FeatureOffer;
|
|
15
|
+
documents: FeatureOffer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const attachmentId = (prefix: string) =>
|
|
19
|
+
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The add menu a host that brings none gets: photos and files, each only where the lead offers it
|
|
23
|
+
* (`featureOffer`). A row the lead's feature gate keeps locked is still shown, and pressing it
|
|
24
|
+
* hands the feature to the host's upsell (`onFeatureLocked`) instead of opening a picker.
|
|
25
|
+
*/
|
|
26
|
+
export function useAttachMenu(
|
|
27
|
+
address: DraftAddress,
|
|
28
|
+
offers: AttachOffers,
|
|
29
|
+
onFeatureLocked: ((key: string) => void) | undefined,
|
|
30
|
+
): AliaAddMenu & { kinds: readonly IntakeKind[] } {
|
|
31
|
+
const labels = useLabels();
|
|
32
|
+
const { pickImage } = useImagePicker();
|
|
33
|
+
|
|
34
|
+
const groups = useMemo(() => {
|
|
35
|
+
const rows = [
|
|
36
|
+
...(offers.images === 'absent'
|
|
37
|
+
? []
|
|
38
|
+
: [{ id: 'photos', label: labels.addPhotos, icon: RiImageLine }]),
|
|
39
|
+
...(offers.documents === 'absent'
|
|
40
|
+
? []
|
|
41
|
+
: [{ id: 'documents', label: labels.addDocuments, icon: RiFileTextLine }]),
|
|
42
|
+
];
|
|
43
|
+
return rows.length === 0 ? [] : [{ label: labels.addMenuGroup, rows }];
|
|
44
|
+
}, [offers.images, offers.documents, labels.addMenuGroup, labels.addPhotos, labels.addDocuments]);
|
|
45
|
+
|
|
46
|
+
/** Dropped and pasted files are held to the same offer: only an available kind is taken. */
|
|
47
|
+
const kinds = useMemo<IntakeKind[]>(
|
|
48
|
+
() => [
|
|
49
|
+
...(offers.images === 'available' ? (['image'] as const) : []),
|
|
50
|
+
...(offers.documents === 'available' ? (['document'] as const) : []),
|
|
51
|
+
],
|
|
52
|
+
[offers.images, offers.documents],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const onSelect = useCallback(
|
|
56
|
+
async (rowId: string) => {
|
|
57
|
+
const kind = rowId === 'photos' ? 'images' : rowId === 'documents' ? 'documents' : null;
|
|
58
|
+
if (kind === null) return;
|
|
59
|
+
if (offers[kind] === 'locked') {
|
|
60
|
+
onFeatureLocked?.(ALIA_FEATURES[kind]);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const store = useComposerDraftStore.getState();
|
|
64
|
+
const picked: AliaAttachment[] =
|
|
65
|
+
kind === 'images'
|
|
66
|
+
? ((await pickImage()) ?? []).map((asset) => ({
|
|
67
|
+
id: attachmentId('img'),
|
|
68
|
+
type: 'image' as const,
|
|
69
|
+
name: asset.name,
|
|
70
|
+
uri: asset.uri,
|
|
71
|
+
size: asset.size,
|
|
72
|
+
mimeType: asset.mimeType,
|
|
73
|
+
}))
|
|
74
|
+
: // The picker loads with the first document: a chat that never attaches one never does.
|
|
75
|
+
((await (await import('../lib/document-picker')).pickDocuments()) ?? []).map(
|
|
76
|
+
(asset) => ({
|
|
77
|
+
id: attachmentId('doc'),
|
|
78
|
+
type: 'document' as const,
|
|
79
|
+
name: asset.name,
|
|
80
|
+
uri: asset.uri,
|
|
81
|
+
size: asset.size,
|
|
82
|
+
mimeType: asset.mimeType,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
for (const attachment of picked) store.addAttachment(address, attachment);
|
|
86
|
+
},
|
|
87
|
+
[offers, onFeatureLocked, pickImage, address],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return useMemo(
|
|
91
|
+
() => ({ groups, kinds, onSelect: (rowId: string) => void onSelect(rowId) }),
|
|
92
|
+
[groups, kinds, onSelect],
|
|
93
|
+
);
|
|
94
|
+
}
|