@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.
Files changed (142) hide show
  1. package/CHANGELOG.md +262 -13
  2. package/README.md +223 -151
  3. package/package.json +16 -6
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/hooks/useAliaActivity.ts +2 -2
  43. package/src/hooks/useAtBottom.ts +51 -0
  44. package/src/hooks/useAttachMenu.ts +94 -0
  45. package/src/hooks/useChatComposer.ts +299 -0
  46. package/src/hooks/useChatVoice.ts +129 -0
  47. package/src/hooks/useDocumentPicker.ts +3 -29
  48. package/src/hooks/useFailedTurnToast.ts +43 -0
  49. package/src/hooks/useMessageVote.ts +50 -0
  50. package/src/hooks/useOpenThought.ts +79 -0
  51. package/src/hooks/usePendingQuestions.ts +2 -2
  52. package/src/hooks/useReadAloud.ts +92 -0
  53. package/src/hooks/useShareHandler.ts +24 -0
  54. package/src/hooks/useSoundEffects.ts +1 -1
  55. package/src/hooks/useThoughtScope.ts +59 -0
  56. package/src/hooks/useThreadRows.ts +145 -0
  57. package/src/hooks/useThreadView.ts +68 -0
  58. package/src/hooks/useTimelineWindow.ts +256 -0
  59. package/src/hooks/useVoiceLauncher.ts +180 -0
  60. package/src/hooks/useVoiceRoom.ts +4 -4
  61. package/src/index.ts +43 -29
  62. package/src/lib/activity-store.ts +1 -1
  63. package/src/lib/add-menu.ts +7 -0
  64. package/src/lib/call-thread.ts +33 -0
  65. package/src/lib/citations.ts +79 -2
  66. package/src/lib/clipboard.ts +23 -0
  67. package/src/lib/content-utils.ts +10 -0
  68. package/src/lib/copy-en.ts +284 -0
  69. package/src/lib/copy.ts +32 -0
  70. package/src/lib/document-picker.ts +31 -0
  71. package/src/lib/flights.ts +22 -13
  72. package/src/lib/focus-return.ts +66 -0
  73. package/src/lib/keyboard.native.tsx +19 -0
  74. package/src/lib/keyboard.tsx +9 -0
  75. package/src/lib/labels.tsx +69 -17
  76. package/src/lib/participants.ts +86 -0
  77. package/src/lib/power-level-options.ts +66 -0
  78. package/src/lib/questions.ts +2 -2
  79. package/src/lib/task-labels.ts +4 -4
  80. package/src/lib/tool-call-status.ts +1 -1
  81. package/src/lib/tool-icons.ts +36 -0
  82. package/src/lib/tool-renderers.tsx +48 -0
  83. package/src/lib/turn-state.ts +2 -2
  84. package/src/lib/voice-call-request.ts +49 -0
  85. package/src/lib/voice-call.ts +98 -35
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +34 -15
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +141 -0
  91. package/src/runtime/app-assertion.ts +53 -0
  92. package/src/runtime/attachment-utils.ts +96 -0
  93. package/src/runtime/composer-draft-store.ts +270 -0
  94. package/src/runtime/config.tsx +186 -0
  95. package/src/runtime/conversation-store.ts +49 -0
  96. package/src/runtime/conversations.ts +557 -0
  97. package/src/runtime/error-utils.ts +104 -0
  98. package/src/runtime/host-tools.ts +140 -0
  99. package/src/runtime/index.ts +113 -0
  100. package/src/runtime/message-history.ts +124 -0
  101. package/src/runtime/model.ts +39 -0
  102. package/src/runtime/oxy-files.ts +37 -0
  103. package/src/runtime/power-levels.ts +66 -0
  104. package/src/runtime/query-keys.ts +34 -0
  105. package/src/runtime/random-uuid.ts +25 -0
  106. package/src/runtime/routes.ts +51 -0
  107. package/src/runtime/sharing.ts +165 -0
  108. package/src/runtime/sse-frame-reader.ts +88 -0
  109. package/src/runtime/stream-events.ts +146 -0
  110. package/src/runtime/stream-outcome.ts +21 -0
  111. package/src/runtime/thought-store.ts +103 -0
  112. package/src/runtime/thread/agent-activity.ts +261 -0
  113. package/src/runtime/thread/automation-labels.ts +79 -0
  114. package/src/runtime/thread/conversation-export.ts +135 -0
  115. package/src/runtime/thread/message-days.ts +120 -0
  116. package/src/runtime/thread/message-processor.ts +93 -0
  117. package/src/runtime/thread/task-utils.ts +11 -0
  118. package/src/runtime/thread/thought-utils.ts +752 -0
  119. package/src/runtime/thread/thread-history.ts +139 -0
  120. package/src/runtime/thread/timeline.ts +202 -0
  121. package/src/runtime/thread/title-tags.ts +8 -0
  122. package/src/runtime/thread/tool-cards.ts +383 -0
  123. package/src/runtime/turn-failure.ts +108 -0
  124. package/src/runtime/types.ts +148 -0
  125. package/src/runtime/usage-limit-error.ts +44 -0
  126. package/src/runtime/use-agent-activity.ts +80 -0
  127. package/src/runtime/use-conversation-stream.ts +1433 -0
  128. package/src/runtime/use-conversation-thread.ts +556 -0
  129. package/src/runtime/use-start-conversation.ts +107 -0
  130. package/src/runtime/use-thread-history.ts +64 -0
  131. package/src/runtime/use-thread-search.ts +95 -0
  132. package/src/runtime/use-turn-edit.ts +99 -0
  133. package/src/tools.ts +2 -2
  134. package/src/types.ts +18 -47
  135. package/src/components/AliaChatMessageList.tsx +0 -532
  136. package/src/components/ThinkingBlock.tsx +0 -43
  137. package/src/hooks/useAliaChat.ts +0 -573
  138. package/src/lib/catalogue.ts +0 -236
  139. package/src/lib/chat-stream.ts +0 -655
  140. package/src/lib/chat-transport.ts +0 -98
  141. package/src/lib/delegation.ts +0 -34
  142. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,359 @@
1
+ import { CodeBlock } from '@oxy.so/bloom/code';
2
+ import { Text } from '@oxy.so/bloom/typography';
3
+ import * as Clipboard from 'expo-clipboard';
4
+ import * as WebBrowser from 'expo-web-browser';
5
+ import { useMemo } from 'react';
6
+ import { Platform, Pressable, View } from 'react-native';
7
+ import {
8
+ type CitationSource,
9
+ extractCitationSources,
10
+ linkifyCitations,
11
+ type ReferenceEntry,
12
+ splitReferences,
13
+ } from '../lib/citations';
14
+ import { useLabels } from '../lib/labels';
15
+ import type { ToolInvocation } from '../runtime/types';
16
+ import { AliaMarkdown, type RenderCodeBlock } from './Markdown';
17
+ import { Banner, CompactList, Comparison, Credibility, RichImage, Timeline } from './RichBlocks';
18
+
19
+ // Cheap pre-check: plain markdown (the overwhelmingly common case) skips the
20
+ // six per-pattern regex scans below with a single pass.
21
+ const SPECIAL_BLOCK_HINT_RE =
22
+ /\[(?:ALIA_)?(?:COMPACTLIST|BANNER|COMPARISON|TIMELINE|IMAGE|CREDIBILITY)/;
23
+
24
+ // Parse special blocks from content
25
+ function parseSpecialBlocks(
26
+ content: string,
27
+ ): Array<{ type: 'text' | 'block'; content: string; blockType?: string; data?: any }> {
28
+ if (!SPECIAL_BLOCK_HINT_RE.test(content)) {
29
+ return [{ type: 'text', content }];
30
+ }
31
+
32
+ const blocks: Array<{ type: 'text' | 'block'; content: string; blockType?: string; data?: any }> =
33
+ [];
34
+
35
+ const patterns = [
36
+ {
37
+ name: 'COMPACTLIST',
38
+ regex: /\[(?:ALIA_)?COMPACTLIST title="([^"]+)"\]([\s\S]*?)\[\/(?:ALIA_)?COMPACTLIST\]/g,
39
+ },
40
+ {
41
+ name: 'BANNER',
42
+ regex: /\[(?:ALIA_)?BANNER type="([^"]+)" title="([^"]+)"\]([\s\S]*?)\[\/(?:ALIA_)?BANNER\]/g,
43
+ },
44
+ {
45
+ name: 'COMPARISON',
46
+ regex: /\[(?:ALIA_)?COMPARISON title="([^"]+)"\]([\s\S]*?)\[\/(?:ALIA_)?COMPARISON\]/g,
47
+ },
48
+ {
49
+ name: 'TIMELINE',
50
+ regex: /\[(?:ALIA_)?TIMELINE title="([^"]+)"\]([\s\S]*?)\[\/(?:ALIA_)?TIMELINE\]/g,
51
+ },
52
+ {
53
+ name: 'IMAGE',
54
+ regex:
55
+ /\[(?:ALIA_)?IMAGE url="([^"]+)"(?:\s+title="([^"]*)")?\s*(?:caption="([^"]*)")?\s*\/\]/g,
56
+ },
57
+ {
58
+ name: 'CREDIBILITY',
59
+ regex: /\[(?:ALIA_)?CREDIBILITY level="(\d+)" source="([^"]+)"\s*\/\]/g,
60
+ },
61
+ ];
62
+
63
+ let lastIndex = 0;
64
+ const matches: Array<{ index: number; length: number; block: any }> = [];
65
+
66
+ // Find all matches
67
+ patterns.forEach(({ name, regex }) => {
68
+ let match;
69
+ const regexCopy = new RegExp(regex.source, regex.flags);
70
+ while ((match = regexCopy.exec(content)) !== null) {
71
+ matches.push({
72
+ index: match.index,
73
+ length: match[0].length,
74
+ block: { type: name, match, fullMatch: match[0] },
75
+ });
76
+ }
77
+ });
78
+
79
+ // Sort matches by index
80
+ matches.sort((a, b) => a.index - b.index);
81
+
82
+ // Build blocks array
83
+ matches.forEach((m) => {
84
+ // Add text before block
85
+ if (lastIndex < m.index) {
86
+ const textContent = content.substring(lastIndex, m.index).trim();
87
+ if (textContent) {
88
+ blocks.push({ type: 'text', content: textContent });
89
+ }
90
+ }
91
+
92
+ // Add block
93
+ blocks.push({
94
+ type: 'block',
95
+ content: m.block.fullMatch,
96
+ blockType: m.block.type,
97
+ data: parseBlockData(m.block.type, m.block.match),
98
+ });
99
+
100
+ lastIndex = m.index + m.length;
101
+ });
102
+
103
+ // Add remaining text
104
+ if (lastIndex < content.length) {
105
+ const textContent = content.substring(lastIndex).trim();
106
+ if (textContent) {
107
+ blocks.push({ type: 'text', content: textContent });
108
+ }
109
+ }
110
+
111
+ // If no blocks found, return all as text
112
+ if (blocks.length === 0) {
113
+ blocks.push({ type: 'text', content });
114
+ }
115
+
116
+ return blocks;
117
+ }
118
+
119
+ function parseBlockData(type: string, match: RegExpExecArray): any {
120
+ try {
121
+ switch (type) {
122
+ case 'COMPACTLIST': {
123
+ const title = match[1];
124
+ const itemsText = match[2];
125
+ const items = itemsText
126
+ .split('\n')
127
+ .filter((line) => line.trim().startsWith('-'))
128
+ .map((line) => {
129
+ try {
130
+ const jsonStr = line.trim().substring(1).trim();
131
+ return JSON.parse(jsonStr);
132
+ } catch {
133
+ return null;
134
+ }
135
+ })
136
+ .filter(Boolean);
137
+ return { title, items };
138
+ }
139
+ case 'BANNER': {
140
+ return {
141
+ type: match[1],
142
+ title: match[2],
143
+ content: match[3].trim(),
144
+ };
145
+ }
146
+ case 'COMPARISON': {
147
+ const title = match[1];
148
+ const content = match[2];
149
+ const leftMatch = content.match(/LEFT:\s*({.*?})/s);
150
+ const rightMatch = content.match(/RIGHT:\s*({.*?})/s);
151
+ const conclusionMatch = content.match(/CONCLUSION:\s*(.*?)$/s);
152
+
153
+ return {
154
+ title,
155
+ left: leftMatch ? JSON.parse(leftMatch[1]) : {},
156
+ right: rightMatch ? JSON.parse(rightMatch[1]) : {},
157
+ conclusion: conclusionMatch ? conclusionMatch[1].trim() : undefined,
158
+ };
159
+ }
160
+ case 'TIMELINE': {
161
+ const title = match[1];
162
+ const itemsText = match[2];
163
+ const items = itemsText
164
+ .split('\n')
165
+ .filter((line) => line.trim().startsWith('-'))
166
+ .map((line) => {
167
+ try {
168
+ const jsonStr = line.trim().substring(1).trim();
169
+ return JSON.parse(jsonStr);
170
+ } catch {
171
+ return null;
172
+ }
173
+ })
174
+ .filter(Boolean);
175
+ return { title, items };
176
+ }
177
+ case 'IMAGE': {
178
+ return {
179
+ url: match[1],
180
+ title: match[2] || undefined,
181
+ caption: match[3] || undefined,
182
+ };
183
+ }
184
+ case 'CREDIBILITY': {
185
+ return {
186
+ level: parseInt(match[1], 10),
187
+ source: match[2],
188
+ };
189
+ }
190
+ default:
191
+ return {};
192
+ }
193
+ } catch (e) {
194
+ console.error('Error parsing block data:', e);
195
+ return {};
196
+ }
197
+ }
198
+
199
+ function renderBlock(blockType: string, data: any, key: number) {
200
+ switch (blockType) {
201
+ case 'COMPACTLIST':
202
+ return <CompactList key={key} {...data} />;
203
+ case 'BANNER':
204
+ return <Banner key={key} {...data} />;
205
+ case 'COMPARISON':
206
+ return <Comparison key={key} {...data} />;
207
+ case 'TIMELINE':
208
+ return <Timeline key={key} {...data} />;
209
+ case 'IMAGE':
210
+ return <RichImage key={key} {...data} />;
211
+ case 'CREDIBILITY':
212
+ return <Credibility key={key} {...data} />;
213
+ default:
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /** Open a source the way the Sources tab does: a new tab on web, the in-app browser on native. */
219
+ function openSource(url: string): void {
220
+ if (Platform.OS === 'web') {
221
+ window.open(url, '_blank', 'noopener,noreferrer');
222
+ } else {
223
+ void WebBrowser.openBrowserAsync(url);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * The references section of a research answer, as links.
229
+ *
230
+ * The API now writes each entry as a Markdown link, and the SDK's renderer
231
+ * would make those pressable on its own — but not ACCESSIBLE: its link rule
232
+ * is a `Text` with `onPress`, which a screen reader reads as text and a
233
+ * keyboard cannot reach. So the section is cut off the Markdown and drawn
234
+ * here, one `accessibilityRole="link"` per entry — an `<a href>` on web, so
235
+ * Tab reaches it and Enter opens it — named "Source n: title". Older answers,
236
+ * whose entries were a title over a bare URL, split the same way.
237
+ */
238
+ function ReferenceList({
239
+ entries,
240
+ onCitationPress,
241
+ }: {
242
+ entries: ReferenceEntry[];
243
+ onCitationPress?: (source: CitationSource) => void;
244
+ }) {
245
+ const { t } = useLabels();
246
+ return (
247
+ <View className="mt-3 gap-1 border-t border-border pt-3" accessibilityRole="list">
248
+ <Text className="text-xs font-medium text-muted-foreground">{t('thought.references')}</Text>
249
+ {entries.map((entry) => {
250
+ const press = () => {
251
+ if (onCitationPress) onCitationPress(entry);
252
+ else openSource(entry.url);
253
+ };
254
+ // On web the anchor itself navigates (new tab, no opener); `onPress`
255
+ // is wired only when the caller wants the press instead of the
256
+ // navigation, and the default is then prevented.
257
+ const webAnchor =
258
+ Platform.OS === 'web'
259
+ ? ({
260
+ href: entry.url,
261
+ hrefAttrs: { target: '_blank', rel: 'noopener noreferrer' },
262
+ } as object)
263
+ : {};
264
+ const onPress =
265
+ Platform.OS === 'web'
266
+ ? onCitationPress
267
+ ? (e: { preventDefault?: () => void }) => {
268
+ e.preventDefault?.();
269
+ press();
270
+ }
271
+ : undefined
272
+ : press;
273
+ return (
274
+ <Pressable
275
+ key={entry.id}
276
+ accessibilityRole="link"
277
+ accessibilityLabel={t('thought.sourceLabel', { n: entry.id, title: entry.title })}
278
+ className="flex-row items-start gap-2 rounded-md py-0.5 active:opacity-70"
279
+ onPress={onPress}
280
+ {...webAnchor}
281
+ >
282
+ <Text className="text-sm text-muted-foreground">[{entry.id}]</Text>
283
+ <Text className="flex-1 text-sm text-primary underline" numberOfLines={2}>
284
+ {entry.title}
285
+ </Text>
286
+ <Text className="text-xs text-muted-foreground" numberOfLines={1}>
287
+ {entry.domain}
288
+ </Text>
289
+ </Pressable>
290
+ );
291
+ })}
292
+ </View>
293
+ );
294
+ }
295
+
296
+ /** Copy through Expo's clipboard, which Bloom's card needs on native (web has its own). */
297
+ const copyCode = async (code: string) => {
298
+ await Clipboard.setStringAsync(code);
299
+ };
300
+
301
+ /**
302
+ * Fenced code as Bloom's code card: the language chip, a copy button and
303
+ * numbered lines. Module-level so its identity is stable — it is part of the
304
+ * SDK renderer's rules memo.
305
+ */
306
+ const renderCodeBlock: RenderCodeBlock = (code, language) => (
307
+ <CodeBlock code={code} language={language} onCopy={copyCode} />
308
+ );
309
+
310
+ /**
311
+ * The chat's Markdown, plus what a research answer needs on top of it.
312
+ *
313
+ * `toolInvocations` (the message's persisted record) and `researchSources`
314
+ * (the live progress event, before persistence) are where the `[n]` markers
315
+ * resolve: a marker naming a known source becomes a link to it, titled
316
+ * "Source n: title", and the References section becomes the accessible list
317
+ * above. Markers the model spelled as `【n†…】` or `[n, m]` in an older answer
318
+ * are normalised first. A message with neither prop renders exactly as
319
+ * before.
320
+ *
321
+ * `onCitationPress` replaces opening the URL for the references list. The
322
+ * inline markers are rendered by the SDK's link rule, which opens the URL
323
+ * itself and offers no hook, so they do not reach it.
324
+ */
325
+ export function ReplyMarkdown({
326
+ content,
327
+ toolInvocations,
328
+ researchSources,
329
+ onCitationPress,
330
+ }: {
331
+ content: string;
332
+ toolInvocations?: ToolInvocation[];
333
+ researchSources?: Array<{ id: number; url: string; title: string }> | null;
334
+ onCitationPress?: (source: CitationSource) => void;
335
+ }) {
336
+ const sources = useMemo(
337
+ () => extractCitationSources(toolInvocations, researchSources),
338
+ [toolInvocations, researchSources],
339
+ );
340
+ const { body, references } = useMemo(() => splitReferences(content, sources), [content, sources]);
341
+ const linked = useMemo(() => linkifyCitations(body, sources), [body, sources]);
342
+ const blocks = useMemo(() => parseSpecialBlocks(linked), [linked]);
343
+
344
+ return (
345
+ <View>
346
+ {blocks.map((block, idx) => {
347
+ if (block.type === 'text') {
348
+ return (
349
+ <AliaMarkdown key={idx} content={block.content} renderCodeBlock={renderCodeBlock} />
350
+ );
351
+ } else if (block.type === 'block' && block.blockType) {
352
+ return renderBlock(block.blockType, block.data, idx);
353
+ }
354
+ return null;
355
+ })}
356
+ {references ? <ReferenceList entries={references} onCitationPress={onCitationPress} /> : null}
357
+ </View>
358
+ );
359
+ }
@@ -0,0 +1,195 @@
1
+ import { useLabels } from '../lib/labels';
2
+ import {
3
+ Admonition,
4
+ AdmonitionContent,
5
+ AdmonitionIcon,
6
+ AdmonitionRoot,
7
+ AdmonitionRow,
8
+ AdmonitionText,
9
+ } from '@oxy.so/bloom/admonition';
10
+ import { Card, CardBody, CardHeader, CardTitle } from '@oxy.so/bloom/card';
11
+ import { RiExternalLinkLine } from '@oxy.so/bloom/icons/RiExternalLinkLine';
12
+ import { Item } from '@oxy.so/bloom/item';
13
+ import { Rating } from '@oxy.so/bloom/rating';
14
+ import { Muted, Text } from '@oxy.so/bloom/typography';
15
+ import { Image } from './Image';
16
+ import { Linking, View } from 'react-native';
17
+
18
+ /**
19
+ * The rich blocks a reply can carry (```compactlist, ```banner, …), drawn with
20
+ * Bloom: a card per block, `Item` rows, `Admonition` for a banner and `Rating`
21
+ * for a source's credibility. `ReplyMarkdown.tsx` parses the fences.
22
+ */
23
+
24
+ type CompactListItem = {
25
+ title: string;
26
+ href?: string;
27
+ meta?: string;
28
+ image?: string;
29
+ };
30
+
31
+ export function CompactList({ title, items }: { title: string; items: CompactListItem[] }) {
32
+ return (
33
+ <Card appearance="outline" className="my-2">
34
+ <CardHeader>
35
+ <CardTitle>{title}</CardTitle>
36
+ </CardHeader>
37
+ <CardBody>
38
+ <View role="list">
39
+ {items.map((item, index) => (
40
+ <Item
41
+ key={index}
42
+ role="listitem"
43
+ title={item.title}
44
+ subtitle={item.meta}
45
+ leading={
46
+ item.image ? (
47
+ <Image
48
+ source={{ uri: item.image }}
49
+ className="h-10 w-10 rounded"
50
+ contentFit="cover"
51
+ />
52
+ ) : undefined
53
+ }
54
+ trailing={item.href ? <RiExternalLinkLine size="sm" /> : undefined}
55
+ onPress={item.href ? () => void Linking.openURL(item.href!) : undefined}
56
+ />
57
+ ))}
58
+ </View>
59
+ </CardBody>
60
+ </Card>
61
+ );
62
+ }
63
+
64
+ type BannerType = 'info' | 'success' | 'warning' | 'danger';
65
+
66
+ /** A block's tone, in Bloom's admonition vocabulary. */
67
+ const ADMONITION = { info: 'info', success: 'tip', warning: 'warning', danger: 'error' } as const;
68
+
69
+ export function Banner({
70
+ type = 'info',
71
+ title,
72
+ content,
73
+ }: {
74
+ type?: BannerType;
75
+ title: string;
76
+ content: string;
77
+ }) {
78
+ return (
79
+ <View className="my-2">
80
+ <AdmonitionRoot type={Object.hasOwn(ADMONITION, type) ? ADMONITION[type] : 'info'}>
81
+ <AdmonitionRow>
82
+ <AdmonitionIcon />
83
+ <AdmonitionContent>
84
+ <Text variant="body-medium">{title}</Text>
85
+ <AdmonitionText>{content}</AdmonitionText>
86
+ </AdmonitionContent>
87
+ </AdmonitionRow>
88
+ </AdmonitionRoot>
89
+ </View>
90
+ );
91
+ }
92
+
93
+ type ComparisonSide = {
94
+ title: string;
95
+ content: string;
96
+ source?: string;
97
+ };
98
+
99
+ function ComparisonSideCard({ side }: { side: ComparisonSide }) {
100
+ const { t } = useLabels();
101
+ return (
102
+ <Card appearance="outline">
103
+ <CardBody>
104
+ <Text variant="body-medium">{side.title}</Text>
105
+ <Muted>{side.content}</Muted>
106
+ {side.source ? <Muted>{t('chat.richBlocks.source', { source: side.source })}</Muted> : null}
107
+ </CardBody>
108
+ </Card>
109
+ );
110
+ }
111
+
112
+ export function Comparison({
113
+ title,
114
+ left,
115
+ right,
116
+ conclusion,
117
+ }: {
118
+ title: string;
119
+ left: ComparisonSide;
120
+ right: ComparisonSide;
121
+ conclusion?: string;
122
+ }) {
123
+ return (
124
+ <Card appearance="outline" className="my-2">
125
+ <CardHeader>
126
+ <CardTitle>{title}</CardTitle>
127
+ </CardHeader>
128
+ <CardBody>
129
+ <View className="gap-2">
130
+ <ComparisonSideCard side={left} />
131
+ <ComparisonSideCard side={right} />
132
+ {conclusion ? <Admonition type="tip">{conclusion}</Admonition> : null}
133
+ </View>
134
+ </CardBody>
135
+ </Card>
136
+ );
137
+ }
138
+
139
+ type TimelineItem = {
140
+ date: string;
141
+ title: string;
142
+ description?: string;
143
+ };
144
+
145
+ export function Timeline({ title, items }: { title: string; items: TimelineItem[] }) {
146
+ return (
147
+ <Card appearance="outline" className="my-2">
148
+ <CardHeader>
149
+ <CardTitle>{title}</CardTitle>
150
+ </CardHeader>
151
+ <CardBody>
152
+ <View role="list">
153
+ {items.map((item, index) => (
154
+ <Item
155
+ key={index}
156
+ role="listitem"
157
+ leading={<Muted>{item.date}</Muted>}
158
+ title={item.title}
159
+ subtitle={item.description}
160
+ />
161
+ ))}
162
+ </View>
163
+ </CardBody>
164
+ </Card>
165
+ );
166
+ }
167
+
168
+ export function RichImage({
169
+ url,
170
+ title,
171
+ caption,
172
+ }: {
173
+ url: string;
174
+ title?: string;
175
+ caption?: string;
176
+ }) {
177
+ return (
178
+ <View className="my-2 gap-1">
179
+ <Image source={{ uri: url }} className="aspect-video w-full rounded-lg" contentFit="cover" />
180
+ {title ? <Text variant="body-medium">{title}</Text> : null}
181
+ {caption ? <Muted>{caption}</Muted> : null}
182
+ </View>
183
+ );
184
+ }
185
+
186
+ /** How far a source can be trusted, on a five-point scale. */
187
+ export function Credibility({ level, source }: { level: number; source: string }) {
188
+ const { t } = useLabels();
189
+ return (
190
+ <View className="my-2 flex-row items-center gap-2">
191
+ <Rating value={Math.max(0, Math.min(5, level))} size="sm" />
192
+ <Muted>{t('chat.richBlocks.source', { source })}</Muted>
193
+ </View>
194
+ );
195
+ }
@@ -0,0 +1,59 @@
1
+ import { useLabels } from '../../lib/labels';
2
+ import { Muted, Text } from '@oxy.so/bloom/typography';
3
+ import { View } from 'react-native';
4
+ import { ChartRenderer } from './ChartRenderer';
5
+ import { CodeRenderer } from './CodeRenderer';
6
+ import { FormRenderer } from './FormRenderer';
7
+ import { MarkdownRenderer } from './MarkdownRenderer';
8
+ import { TableRenderer } from './TableRenderer';
9
+
10
+ interface CanvasComponentProps {
11
+ component: {
12
+ id: string;
13
+ type: string;
14
+ title: string;
15
+ data: any;
16
+ };
17
+ onFormSubmit?: (formData: Record<string, any>) => void;
18
+ }
19
+
20
+ export function CanvasComponent({ component, onFormSubmit }: CanvasComponentProps) {
21
+ const { t } = useLabels();
22
+
23
+ // Code carries its own header (the file name), and a chart card its own title.
24
+ if (component.type === 'code')
25
+ return <CodeRenderer data={component.data} filename={component.title} />;
26
+ if (component.type === 'artifact' && component.data.language)
27
+ return (
28
+ <CodeRenderer
29
+ data={{ language: component.data.language, code: component.data.content }}
30
+ filename={component.title}
31
+ />
32
+ );
33
+ if (component.type === 'chart')
34
+ return <ChartRenderer data={component.data} title={component.title} />;
35
+
36
+ const content = (() => {
37
+ switch (component.type) {
38
+ case 'table':
39
+ return <TableRenderer data={component.data} title={component.title} />;
40
+ case 'form':
41
+ return <FormRenderer data={component.data} onSubmit={onFormSubmit} />;
42
+ case 'image':
43
+ return <Muted>{component.data.alt || t('panels.canvas.image')}</Muted>;
44
+ case 'markdown':
45
+ return <MarkdownRenderer data={component.data} />;
46
+ case 'artifact':
47
+ return <MarkdownRenderer data={{ content: component.data.content }} />;
48
+ default:
49
+ return <Muted>{t('panels.canvas.unsupported', { type: component.type })}</Muted>;
50
+ }
51
+ })();
52
+
53
+ return (
54
+ <View className="gap-3">
55
+ <Text variant="body-semibold">{component.title}</Text>
56
+ {content}
57
+ </View>
58
+ );
59
+ }
@@ -0,0 +1,73 @@
1
+ import { useLabels } from '../../lib/labels';
2
+ import { BarListCard, LineChartCard } from '@oxy.so/bloom/chart-cards';
3
+
4
+ interface ChartData {
5
+ chartType: 'bar' | 'line' | 'pie';
6
+ labels: string[];
7
+ datasets: { label: string; values: number[] }[];
8
+ }
9
+
10
+ interface ChartRendererProps {
11
+ data: ChartData;
12
+ /** The canvas component's title, used as the chart's name. */
13
+ title?: string;
14
+ }
15
+
16
+ const NUMBER = new Intl.NumberFormat();
17
+ const format = (value: number) => NUMBER.format(value);
18
+
19
+ /**
20
+ * A generated chart on Bloom's chart cards, painted from Bloom's chart palette.
21
+ *
22
+ * - one `line` series → `LineChartCard`;
23
+ * - a `pie` → `BarListCard` in shares of the whole, which is what a pie says;
24
+ * - bars, or several series → `BarListCard` in raw values, one tab per series.
25
+ */
26
+ export function ChartRenderer({ data, title }: ChartRendererProps) {
27
+ const { t } = useLabels();
28
+ const { chartType, labels, datasets } = data;
29
+ const itemsOf = (values: number[]) =>
30
+ labels.map((label, i) => ({ label, value: values[i] || 0 }));
31
+
32
+ if (chartType === 'line' && datasets.length === 1) {
33
+ const series = datasets[0];
34
+ return (
35
+ <LineChartCard
36
+ title={series.label || title}
37
+ data={itemsOf(series.values)}
38
+ format={format}
39
+ formatAxisValue={format}
40
+ getPointTitle={(point) => point.label}
41
+ accessibilityLabel={title ?? series.label}
42
+ />
43
+ );
44
+ }
45
+
46
+ if (chartType === 'pie' || datasets.length <= 1) {
47
+ const series = datasets[0];
48
+ return (
49
+ <BarListCard
50
+ title={series?.label || title}
51
+ metricLabel={chartType === 'pie' ? t('panels.chart.share') : t('panels.chart.value')}
52
+ metric={chartType === 'pie' ? 'share' : 'value'}
53
+ format={format}
54
+ items={itemsOf(series?.values ?? [])}
55
+ limit={labels.length}
56
+ />
57
+ );
58
+ }
59
+
60
+ return (
61
+ <BarListCard
62
+ metricLabel={t('panels.chart.value')}
63
+ metric="value"
64
+ format={format}
65
+ tabs={datasets.map((series, i) => ({
66
+ id: `series-${i}`,
67
+ label: series.label,
68
+ items: itemsOf(series.values),
69
+ }))}
70
+ limit={labels.length}
71
+ />
72
+ );
73
+ }