@alia.onl/sdk 10.1.0 → 11.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +24 -0
  3. package/package.json +5 -7
  4. package/src/components/AliaAvatar.tsx +66 -0
  5. package/src/components/AliaChatContent.tsx +235 -112
  6. package/src/components/AliaChatHeader.tsx +118 -0
  7. package/src/components/AliaChatMessageList.tsx +171 -260
  8. package/src/components/AliaChatScreen.tsx +23 -24
  9. package/src/components/AliaChatSheet.tsx +70 -316
  10. package/src/components/AliaWelcomeMessage.tsx +47 -50
  11. package/src/components/AmbientField.tsx +577 -0
  12. package/src/components/IdentityMark.tsx +10 -2
  13. package/src/components/Markdown.tsx +116 -40
  14. package/src/components/PlanPreviewCard.tsx +71 -55
  15. package/src/components/ThinkingBlock.tsx +47 -0
  16. package/src/components/VoiceModeIcon.tsx +10 -0
  17. package/src/components/voice/VoiceControls.tsx +22 -73
  18. package/src/components/voice/VoiceSession.tsx +2 -1
  19. package/src/hooks/useAliaChat.ts +22 -15
  20. package/src/hooks/useAmbientWave.ts +35 -5
  21. package/src/hooks/useSoundEffects.ts +14 -11
  22. package/src/hooks/useSpeechToText.ts +9 -2
  23. package/src/hooks/useTTS.ts +163 -156
  24. package/src/hooks/useVoiceRoom.ts +228 -170
  25. package/src/index.ts +18 -6
  26. package/src/lib/catalogue.ts +17 -4
  27. package/src/lib/chat-stream.ts +25 -7
  28. package/src/lib/chat-transport.ts +2 -1
  29. package/src/lib/citations.ts +63 -0
  30. package/src/lib/content-utils.ts +3 -2
  31. package/src/lib/image-data-url.ts +15 -0
  32. package/src/lib/keyboard.tsx +1 -1
  33. package/src/lib/labels.tsx +81 -0
  34. package/src/lib/speech-recognition.native.ts +15 -4
  35. package/src/lib/speech-recognition.ts +27 -7
  36. package/src/lib/task-labels.ts +67 -0
  37. package/src/lib/tool-call-status.ts +28 -0
  38. package/src/lib/tool-registry.ts +14 -14
  39. package/src/lib/voice-text.ts +12 -6
  40. package/src/lib/voice-turn.ts +4 -1
  41. package/src/lib/work-log-copy.ts +61 -0
  42. package/src/lib/work-log.ts +378 -0
  43. package/src/tools.ts +11 -0
  44. package/src/types.ts +5 -2
  45. package/src/voice.ts +12 -4
  46. package/src/components/Reasoning.tsx +0 -152
  47. package/src/components/ResearchProgressCard.tsx +0 -159
  48. package/src/components/ThinkingIndicator.tsx +0 -98
  49. package/src/components/ui/button.tsx +0 -104
  50. package/src/components/ui/chat-text-input.tsx +0 -185
  51. package/src/components/ui/dropdown-menu.tsx +0 -2
  52. package/src/components/ui/dropdown-menu.web.tsx +0 -383
  53. package/src/components/ui/prompt-input/actions.tsx +0 -28
  54. package/src/components/ui/prompt-input/add-menu.tsx +0 -83
  55. package/src/components/ui/prompt-input/attachments.tsx +0 -181
  56. package/src/components/ui/prompt-input/autocomplete.tsx +0 -168
  57. package/src/components/ui/prompt-input/context.tsx +0 -71
  58. package/src/components/ui/prompt-input/mic-button.tsx +0 -53
  59. package/src/components/ui/prompt-input/prompt-input.tsx +0 -279
  60. package/src/components/ui/prompt-input/submit-button.tsx +0 -50
  61. package/src/components/ui/prompt-input/textarea.tsx +0 -80
  62. package/src/components/ui/text.tsx +0 -29
  63. package/src/components/voice/AudioWaveVisualizer.tsx +0 -444
  64. package/src/components/voice/VoiceOverlay.tsx +0 -75
@@ -0,0 +1,378 @@
1
+ import {
2
+ RiBookOpenLine,
3
+ RiFileTextLine,
4
+ RiGlobalLine,
5
+ RiSearchLine,
6
+ RiTerminalBoxLine,
7
+ RiToolsLine,
8
+ } from '@oxy.so/bloom/icons';
9
+ import type { TaskListChip, TaskListStep, TaskListTask } from '@oxy.so/bloom/task-list';
10
+ import type { WebSearchBrand, WebSearchSource, WebSearchStep } from '@oxy.so/bloom/web-search';
11
+ import type { ToolInvocation } from '../types';
12
+ import { extractCitationSources } from './citations';
13
+ import { getToolDoneLabel, getToolPillLabel } from './task-labels';
14
+ import { toolCallStatus } from './tool-call-status';
15
+ import { englishWorkLog, type Translate } from './work-log-copy';
16
+
17
+ /**
18
+ * A turn's tool calls, as the two Bloom logs draw them.
19
+ *
20
+ * Where the turn WENT (searches, page visits, research) is a `WebSearch`
21
+ * trail; what it DID with every other tool is a `TaskList`. Calls that return
22
+ * a card (weather, market…) are neither — the caller filters them out first.
23
+ *
24
+ * Both logs count UNITS and are driven by `revealed`, never by their own
25
+ * ticker: a unit counts as revealed only when the event that produced it has
26
+ * arrived. A call whose arguments are still streaming (`partial-call`) is a
27
+ * step that is known but not yet revealed, so the log shimmers on it and shows
28
+ * its Working tail; a step's sources are a unit that exists only once the
29
+ * result carried them.
30
+ */
31
+
32
+ /** What a host can swap: the words, and where a site's icon is served from. */
33
+ export interface WorkLogOptions {
34
+ /** Translator for the log's words (see `work-log-copy.ts`). Default: English. */
35
+ t?: Translate;
36
+ /** The URL of a site's cached icon, or nothing for Bloom's quiet dot. */
37
+ faviconUrl?: (url: string) => string | null | undefined;
38
+ }
39
+
40
+ /** The result of a call, when it is an object (the tools return objects). */
41
+ function resultOf(inv: Pick<ToolInvocation, 'result'>): Record<string, unknown> | undefined {
42
+ const result = inv.result;
43
+ return result !== null && typeof result === 'object' && !Array.isArray(result)
44
+ ? (result as Record<string, unknown>)
45
+ : undefined;
46
+ }
47
+
48
+ /** The research the live stream reports before the answer is persisted. */
49
+ export interface LiveResearch {
50
+ currentQuery?: string;
51
+ sourcesFound?: number;
52
+ sources?: Array<{ id?: unknown; url?: unknown; title?: unknown }> | null;
53
+ }
54
+
55
+ /** Tools whose calls are a place the turn went, not a task it did. */
56
+ const WEB_TOOLS = new Set(['webSearch', 'browse', 'webScraper', 'deepResearch']);
57
+
58
+ export function isWebInvocation(inv: Pick<ToolInvocation, 'toolName'>): boolean {
59
+ return WEB_TOOLS.has(inv.toolName);
60
+ }
61
+
62
+ function str(value: unknown): string | undefined {
63
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
64
+ }
65
+
66
+ function domainOf(url: string): string {
67
+ try {
68
+ return new URL(url).hostname;
69
+ } catch {
70
+ return url;
71
+ }
72
+ }
73
+
74
+ /** The site marks Bloom draws, by registrable domain. Anything else is a quiet dot. */
75
+ const BRANDS: Array<[string, WebSearchBrand]> = [
76
+ ['github.com', 'github'],
77
+ ['gitlab.com', 'gitlab'],
78
+ ['bitbucket.org', 'bitbucket'],
79
+ ['x.com', 'x'],
80
+ ['twitter.com', 'x'],
81
+ ['reddit.com', 'reddit'],
82
+ ['facebook.com', 'facebook'],
83
+ ['instagram.com', 'instagram'],
84
+ ['linkedin.com', 'linkedin'],
85
+ ['tiktok.com', 'tiktok'],
86
+ ['spotify.com', 'spotify'],
87
+ ['twitch.tv', 'twitch'],
88
+ ['notion.so', 'notion'],
89
+ ['notion.site', 'notion'],
90
+ ['figma.com', 'figma'],
91
+ ['apple.com', 'apple'],
92
+ ['microsoft.com', 'microsoft'],
93
+ ['google.com', 'google'],
94
+ ['discord.com', 'discord'],
95
+ ['slack.com', 'slack'],
96
+ ['dropbox.com', 'dropbox'],
97
+ ['telegram.org', 'telegram'],
98
+ ['t.me', 'telegram'],
99
+ ['whatsapp.com', 'whatsapp'],
100
+ ['amazon.com', 'amazon'],
101
+ ];
102
+
103
+ function brandOf(domain: string): WebSearchBrand | undefined {
104
+ const host = domain.toLowerCase();
105
+ return BRANDS.find(([site]) => host === site || host.endsWith(`.${site}`))?.[1];
106
+ }
107
+
108
+ /**
109
+ * One source row. Its favicon is Clarity's copy of the site's icon: the one a
110
+ * search result carries, or the URL Clarity serves it at for anything else
111
+ * (a page Alia read). Bloom draws a brand mark over it, and the dot when
112
+ * Clarity has not fetched that site yet.
113
+ */
114
+ function source(
115
+ url: string,
116
+ options: WorkLogOptions,
117
+ title?: string,
118
+ faviconUrl?: string,
119
+ ): WebSearchSource {
120
+ const domain = domainOf(url);
121
+ const brand = brandOf(domain);
122
+ const favicon = faviconUrl ?? options.faviconUrl?.(url);
123
+ return {
124
+ title: title ?? domain,
125
+ domain,
126
+ href: url,
127
+ ...(brand ? { brand } : {}),
128
+ ...(favicon ? { faviconUrl: favicon } : {}),
129
+ };
130
+ }
131
+
132
+ function uniqueSources(list: WebSearchSource[]): WebSearchSource[] {
133
+ const seen = new Set<string>();
134
+ return list.filter((s) => {
135
+ const key = s.href ?? s.title;
136
+ if (seen.has(key)) return false;
137
+ seen.add(key);
138
+ return true;
139
+ });
140
+ }
141
+
142
+ function succeeded(inv: ToolInvocation): boolean {
143
+ return toolCallStatus(inv, true) === 'done';
144
+ }
145
+
146
+ function isSearch(inv: ToolInvocation): boolean {
147
+ if (inv.toolName === 'webSearch') return true;
148
+ const action = str(resultOf(inv)?.action) ?? str(inv.args?.action);
149
+ return inv.toolName === 'browse' && action === 'search';
150
+ }
151
+
152
+ /** One step, and whether the event that produced it has arrived. */
153
+ interface MappedStep {
154
+ step: WebSearchStep;
155
+ arrived: boolean;
156
+ }
157
+
158
+ function webStep(
159
+ inv: ToolInvocation,
160
+ live: LiveResearch | undefined,
161
+ options: WorkLogOptions,
162
+ ): MappedStep {
163
+ const t = options.t ?? englishWorkLog;
164
+ const arrived = inv.state !== 'partial-call';
165
+ const ok = succeeded(inv);
166
+ const res = resultOf(inv);
167
+
168
+ if (inv.toolName === 'deepResearch') {
169
+ const cited = extractCitationSources([inv], live?.sources);
170
+ const searches = typeof res?.totalSearches === 'number' ? res.totalSearches : 0;
171
+ return {
172
+ arrived,
173
+ step: {
174
+ label: t('chat.bloom.researched'),
175
+ query: str(inv.args?.query),
176
+ icon: RiBookOpenLine,
177
+ ...(searches > 0 ? { meta: t('chat.bloom.searchCount', { count: searches }) } : {}),
178
+ ...(cited.length > 0 ? { sources: cited.map((c) => source(c.url, options, c.title)) } : {}),
179
+ },
180
+ };
181
+ }
182
+
183
+ if (isSearch(inv)) {
184
+ const results: unknown[] = ok && Array.isArray(res?.results) ? res.results : [];
185
+ const sources = uniqueSources(
186
+ results.flatMap((r) => {
187
+ const url = str((r as { url?: unknown })?.url);
188
+ return url
189
+ ? [
190
+ source(
191
+ url,
192
+ options,
193
+ str((r as { title?: unknown }).title),
194
+ str((r as { faviconUrl?: unknown }).faviconUrl),
195
+ ),
196
+ ]
197
+ : [];
198
+ }),
199
+ );
200
+ const count = typeof res?.count === 'number' ? res.count : results.length;
201
+ return {
202
+ arrived,
203
+ step: {
204
+ label: t('chat.bloom.searchedWeb'),
205
+ query: str(inv.args?.query),
206
+ icon: RiSearchLine,
207
+ // Say what came back, including nothing: a search that found nothing or
208
+ // failed must not look like one whose sources are missing.
209
+ ...(inv.state !== 'result'
210
+ ? {}
211
+ : !ok
212
+ ? { meta: t('chat.bloom.searchFailed') }
213
+ : count === 0
214
+ ? { meta: t('chat.bloom.noResults') }
215
+ : { meta: t('chat.bloom.resultCount', { count }) }),
216
+ ...(sources.length > 0 ? { sources } : {}),
217
+ },
218
+ };
219
+ }
220
+
221
+ // A page visit: `webScraper`, or `browse` reading a URL.
222
+ const url = str(res?.url) ?? str(inv.args?.url);
223
+ return {
224
+ arrived,
225
+ step: {
226
+ label: t('chat.bloom.visited'),
227
+ query: url ? domainOf(url) : undefined,
228
+ icon: RiGlobalLine,
229
+ ...(ok && url && inv.state === 'result'
230
+ ? { sources: [source(url, options, str(res?.title))] }
231
+ : {}),
232
+ },
233
+ };
234
+ }
235
+
236
+ /** How many leading units have arrived. Units are revealed in order, so the first missing one stops the count. */
237
+ function arrivedUnits(mapped: MappedStep[]): number {
238
+ let revealed = 0;
239
+ for (const { step, arrived } of mapped) {
240
+ if (!arrived) break;
241
+ revealed += step.sources?.length ? 2 : 1;
242
+ }
243
+ return revealed;
244
+ }
245
+
246
+ export interface WebSearchLog {
247
+ steps: WebSearchStep[];
248
+ revealed: number;
249
+ }
250
+
251
+ /**
252
+ * The `WebSearch` trail of a turn: one step per search, page visit or
253
+ * research run, with the sources each surfaced.
254
+ *
255
+ * `research` is the live `researchProgress` of a research answer. Before the
256
+ * answer is saved there is no `deepResearch` invocation yet, so the progress
257
+ * is the step; once it is saved the invocation is, and the live sources only
258
+ * fill in what the persisted ones lack.
259
+ */
260
+ export function webSearchLog(
261
+ invocations: readonly ToolInvocation[] | undefined,
262
+ research: LiveResearch | undefined,
263
+ options: WorkLogOptions = {},
264
+ ): WebSearchLog {
265
+ const t = options.t ?? englishWorkLog;
266
+ const web = (invocations ?? []).filter(isWebInvocation);
267
+ const mapped = web.map((inv) => webStep(inv, research, options));
268
+
269
+ const hasResearchCall = web.some((inv) => inv.toolName === 'deepResearch');
270
+ if (research && !hasResearchCall) {
271
+ const cited = extractCitationSources(undefined, research.sources);
272
+ const found = typeof research.sourcesFound === 'number' ? research.sourcesFound : cited.length;
273
+ mapped.push({
274
+ arrived: true,
275
+ step: {
276
+ label: t('chat.bloom.researching'),
277
+ query: str(research.currentQuery),
278
+ icon: RiBookOpenLine,
279
+ ...(found > 0 ? { meta: t('chat.bloom.sourceCount', { count: found }) } : {}),
280
+ ...(cited.length > 0 ? { sources: cited.map((c) => source(c.url, options, c.title)) } : {}),
281
+ },
282
+ });
283
+ }
284
+
285
+ return { steps: mapped.map((m) => m.step), revealed: arrivedUnits(mapped) };
286
+ }
287
+
288
+ /** Argument keys worth a chip, in the order they are shown. */
289
+ const CHIP_KEYS = [
290
+ 'filename',
291
+ 'path',
292
+ 'file',
293
+ 'command',
294
+ 'url',
295
+ 'query',
296
+ 'name',
297
+ 'title',
298
+ 'subject',
299
+ 'to',
300
+ 'handle',
301
+ ];
302
+ const CHIP_MAX = 60;
303
+
304
+ function argChips(args: Record<string, unknown> | undefined): TaskListChip[] {
305
+ if (!args) return [];
306
+ const chips: TaskListChip[] = [];
307
+ for (const key of CHIP_KEYS) {
308
+ const value = str(args[key]);
309
+ if (value && value.length <= CHIP_MAX) chips.push({ label: value });
310
+ if (chips.length === 3) break;
311
+ }
312
+ return chips;
313
+ }
314
+
315
+ const TASK_ICONS: Record<string, NonNullable<TaskListTask['icon']>> = {
316
+ generateFile: RiFileTextLine,
317
+ fileEdit: RiFileTextLine,
318
+ shellExec: RiTerminalBoxLine,
319
+ codeInterpreter: RiTerminalBoxLine,
320
+ };
321
+
322
+ export interface TaskListLog {
323
+ tasks: TaskListTask[];
324
+ revealed: number;
325
+ }
326
+
327
+ /**
328
+ * The `TaskList` of a turn: one task per call that is neither a web step nor
329
+ * a card, titled from `task-utils.ts`.
330
+ *
331
+ * Steps come from what the call really carried: its arguments as chips once
332
+ * the call is made (the file name for `generateFile`), and how it ended once
333
+ * the result is in — an error, or a call the turn left behind (`live` false
334
+ * with no result). A successful call adds no "Done" row: the swap from the
335
+ * running title to the finished one already says it.
336
+ */
337
+ export function taskListLog(
338
+ invocations: readonly ToolInvocation[] | undefined,
339
+ live: boolean,
340
+ options: WorkLogOptions = {},
341
+ ): TaskListLog {
342
+ const t = options.t ?? englishWorkLog;
343
+ let revealed = 0;
344
+ let open = true;
345
+ const tasks = (invocations ?? [])
346
+ .filter((inv) => !isWebInvocation(inv))
347
+ .map((inv): TaskListTask => {
348
+ const status = toolCallStatus(inv, live);
349
+ const steps: TaskListStep[] = [];
350
+
351
+ if (inv.toolName === 'generateFile') {
352
+ const name = str(resultOf(inv)?.filename) ?? str(inv.args?.filename);
353
+ if (name)
354
+ steps.push({
355
+ label: t(status === 'done' ? 'chat.bloom.createdFile' : 'chat.bloom.writingFile'),
356
+ chips: [{ label: name }],
357
+ });
358
+ } else {
359
+ const chips = argChips(inv.args);
360
+ if (chips.length > 0) steps.push({ label: t('chat.bloom.taskInput'), chips });
361
+ }
362
+ if (status === 'error') steps.push({ label: t('chat.bloom.taskFailed') });
363
+ if (status === 'interrupted') steps.push({ label: t('chat.bloom.taskStopped') });
364
+
365
+ // Header and argument steps land with the call; the outcome with the result.
366
+ const arrived = inv.state !== 'partial-call';
367
+ if (open && arrived) revealed += 1 + steps.length;
368
+ else open = false;
369
+
370
+ return {
371
+ title: getToolDoneLabel(inv.toolName, t),
372
+ runningTitle: getToolPillLabel(inv.toolName, t),
373
+ icon: TASK_ICONS[inv.toolName] ?? RiToolsLine,
374
+ steps,
375
+ };
376
+ });
377
+ return { tasks, revealed };
378
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,11 @@
1
+ // @alia.onl/sdk/tools — what a turn's tool calls mean, without the UI.
2
+ // Pure functions: the status of a call, the numbered sources of a research answer, the
3
+ // words for a tool. They load no React Native, so a host's models and tests can use them.
4
+
5
+ export { extractCitationSources } from './lib/citations';
6
+ export type { CitationSource } from './lib/citations';
7
+ export { getToolDoneLabel, getToolPillLabel } from './lib/task-labels';
8
+ export { toolCallStatus } from './lib/tool-call-status';
9
+ export type { ToolCallStatus } from './lib/tool-call-status';
10
+ export { englishWorkLog } from './lib/work-log-copy';
11
+ export type { Translate } from './lib/work-log-copy';
package/src/types.ts CHANGED
@@ -115,8 +115,11 @@ export interface VoiceSessionProps {
115
115
  apiUrl?: string;
116
116
  /** Called on every room, agent, or transcript change. */
117
117
  onStateChange: (state: VoiceSessionState) => void;
118
- /** The call is over — the user ended it, it errored, or the room dropped. */
119
- onEnd: () => void;
118
+ /**
119
+ * The call is over — the user ended it, it errored, or the room dropped. When it
120
+ * failed, say why, so the shell can tell the person instead of silently going back.
121
+ */
122
+ onEnd: (error?: string) => void;
120
123
  }
121
124
 
122
125
  /**
package/src/voice.ts CHANGED
@@ -5,8 +5,6 @@
5
5
 
6
6
  // ── Voice components ──
7
7
  export { VoiceSession } from './components/voice/VoiceSession';
8
- export { AudioWaveVisualizer } from './components/voice/AudioWaveVisualizer';
9
- export { VoiceOverlay } from './components/voice/VoiceOverlay';
10
8
  export { VoiceControls } from './components/voice/VoiceControls';
11
9
  export { VOICE_CONTROLS_LABELS } from './components/voice/VoiceControls';
12
10
  export type { VoiceControlsLabels, VoiceControlsProps } from './components/voice/VoiceControls';
@@ -15,14 +13,24 @@ export type { VoiceControlsLabels, VoiceControlsProps } from './components/voice
15
13
  export { useVoiceRoom } from './hooks/useVoiceRoom';
16
14
  export type { UseVoiceRoomOptions } from './hooks/useVoiceRoom';
17
15
  export { createAliaVoiceTurnSender } from './lib/voice-turn';
18
- export type { VoiceTurn, VoiceTurnMessage, VoiceTurnSender, AliaVoiceTurnSenderOptions } from './lib/voice-turn';
16
+ export type {
17
+ VoiceTurn,
18
+ VoiceTurnMessage,
19
+ VoiceTurnSender,
20
+ AliaVoiceTurnSenderOptions,
21
+ } from './lib/voice-turn';
19
22
  export type { VoiceLevelSource } from './lib/voice-levels';
20
23
  export { VOICE_ERROR_MESSAGES } from './lib/speech-messages';
21
24
  export type { VoiceErrorCode } from './lib/speech-messages';
22
25
  export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
23
26
  export { useAudioLevels } from './hooks/useAudioLevels';
24
27
  export { useAmbientWave } from './hooks/useAmbientWave';
25
- export type { UseAmbientWaveOptions, UseAmbientWaveResult, AmbientWaveVoice, AmbientWaveMode } from './hooks/useAmbientWave';
28
+ export type {
29
+ UseAmbientWaveOptions,
30
+ UseAmbientWaveResult,
31
+ AmbientWaveVoice,
32
+ AmbientWaveMode,
33
+ } from './hooks/useAmbientWave';
26
34
  export { useSoundEffects, useVoiceSoundEffects } from './hooks/useSoundEffects';
27
35
  export type { SoundName, SoundSources } from './hooks/useSoundEffects';
28
36
 
@@ -1,152 +0,0 @@
1
- import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
2
- import { View, Text, Pressable, StyleSheet } from 'react-native';
3
- import Animated, {
4
- useAnimatedStyle,
5
- useSharedValue,
6
- withRepeat,
7
- withTiming,
8
- withSequence,
9
- cancelAnimation,
10
- } from 'react-native-reanimated';
11
-
12
-
13
- // Context
14
- type ReasoningContextType = {
15
- isStreaming: boolean;
16
- isOpen: boolean;
17
- setIsOpen: (open: boolean) => void;
18
- duration: number | undefined;
19
- };
20
-
21
- const ReasoningContext = createContext<ReasoningContextType | null>(null);
22
-
23
- function useReasoning() {
24
- const context = useContext(ReasoningContext);
25
- if (!context) throw new Error('useReasoning must be used within a Reasoning component');
26
- return context;
27
- }
28
-
29
- // Props
30
- interface ReasoningProps {
31
- isStreaming?: boolean;
32
- defaultOpen?: boolean;
33
- duration?: number;
34
- children: React.ReactNode;
35
- }
36
-
37
- interface ReasoningTriggerProps {
38
- onPress?: () => void;
39
- }
40
-
41
- interface ReasoningContentProps {
42
- children: string;
43
- }
44
-
45
- // Reasoning wrapper — manages open/close state with auto-open during streaming
46
- export function Reasoning({ isStreaming = false, defaultOpen = true, duration: externalDuration, children }: ReasoningProps) {
47
- const [isOpen, setIsOpen] = useState(defaultOpen);
48
- const [duration, setDuration] = useState<number | undefined>(externalDuration);
49
- const startTimeRef = useRef<number | null>(null);
50
- const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
51
-
52
- useEffect(() => {
53
- if (isStreaming) {
54
- setIsOpen(true);
55
- startTimeRef.current = Date.now();
56
- intervalRef.current = setInterval(() => {
57
- if (startTimeRef.current) {
58
- setDuration(Math.floor((Date.now() - startTimeRef.current) / 1000));
59
- }
60
- }, 1000);
61
- } else {
62
- if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
63
- if (startTimeRef.current !== null) {
64
- const t = setTimeout(() => setIsOpen(false), 500);
65
- return () => clearTimeout(t);
66
- }
67
- }
68
- return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
69
- }, [isStreaming]);
70
-
71
- useEffect(() => { if (externalDuration !== undefined) setDuration(externalDuration); }, [externalDuration]);
72
-
73
- return (
74
- <ReasoningContext.Provider value={{ isStreaming, isOpen, setIsOpen, duration }}>
75
- <View>{children}</View>
76
- </ReasoningContext.Provider>
77
- );
78
- }
79
-
80
- // Trigger — shows "Thinking for Xs..." label with pulsing icon
81
- export function ReasoningTrigger({ onPress }: ReasoningTriggerProps) {
82
- const { isStreaming, isOpen, setIsOpen, duration } = useReasoning();
83
- const pulseOpacity = useSharedValue(1);
84
-
85
- useEffect(() => {
86
- if (isStreaming) {
87
- pulseOpacity.value = withRepeat(withSequence(withTiming(0.4, { duration: 800 }), withTiming(1, { duration: 800 })), -1, false);
88
- } else {
89
- cancelAnimation(pulseOpacity);
90
- pulseOpacity.value = 1;
91
- }
92
- }, [isStreaming]);
93
-
94
- const pulseStyle = useAnimatedStyle(() => ({ opacity: pulseOpacity.value }));
95
-
96
- const message = isStreaming
97
- ? (duration ? `Thinking for ${duration}s...` : 'Thinking...')
98
- : (duration ? `Thought for ${duration} seconds` : 'Reasoning');
99
-
100
- const handlePress = onPress || (() => setIsOpen(!isOpen));
101
-
102
- return (
103
- <Pressable onPress={handlePress} style={styles.trigger}>
104
- <Animated.View style={isStreaming ? pulseStyle : undefined}>
105
- <Text style={{ color: '#a855f7', fontSize: 14 }}>{'\u2726'}</Text>
106
- </Animated.View>
107
- <Text style={[styles.triggerText, { color: '#a855f7' }]}>{message}</Text>
108
- <Text style={{ color: '#a855f7', fontSize: 14 }}>{onPress ? '\u203A' : (isOpen ? '\u25BE' : '\u25B8')}</Text>
109
- </Pressable>
110
- );
111
- }
112
-
113
- // Content — renders thinking text (collapsible)
114
- export function ReasoningContent({ children }: ReasoningContentProps) {
115
- const { isOpen, isStreaming } = useReasoning();
116
-
117
- if (!isOpen) return null;
118
-
119
- // Lazy import AliaMarkdown to avoid circular dependency
120
- const { AliaMarkdown } = require('./Markdown');
121
-
122
- return (
123
- <View style={[styles.contentWrapper, { backgroundColor: '#a855f710', borderColor: '#a855f730' }]}>
124
- <View style={{ opacity: isStreaming ? 0.8 : 1 }}>
125
- <AliaMarkdown content={children} />
126
- </View>
127
- </View>
128
- );
129
- }
130
-
131
- const styles = StyleSheet.create({
132
- trigger: {
133
- flexDirection: 'row',
134
- alignItems: 'center',
135
- gap: 8,
136
- paddingVertical: 8,
137
- paddingHorizontal: 12,
138
- borderRadius: 8,
139
- },
140
- triggerText: {
141
- fontSize: 14,
142
- fontWeight: '500',
143
- flex: 1,
144
- },
145
- contentWrapper: {
146
- borderRadius: 8,
147
- padding: 12,
148
- borderWidth: 1,
149
- marginHorizontal: 12,
150
- marginBottom: 12,
151
- },
152
- });