expo-ai-core 1.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/dist/index.js ADDED
@@ -0,0 +1,1321 @@
1
+ import { useMemo, useState, useRef, useEffect, useCallback } from 'react';
2
+ import { StyleSheet, View, TextInput, Pressable, ActivityIndicator, Text, Animated, KeyboardAvoidingView, FlatList, Platform, Linking } from 'react-native';
3
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
+
5
+ // src/hooks/useAIChat.ts
6
+
7
+ // src/utils/helpers.ts
8
+ function createId(prefix = "msg") {
9
+ const randomPart = Math.random().toString(36).slice(2, 10);
10
+ const timePart = Date.now().toString(36);
11
+ if (typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto) {
12
+ return `${prefix}_${globalThis.crypto.randomUUID()}`;
13
+ }
14
+ return `${prefix}_${timePart}_${randomPart}`;
15
+ }
16
+ function trimContent(value) {
17
+ return value.trim();
18
+ }
19
+ function isNonEmptyString(value) {
20
+ return typeof value === "string" && value.trim().length > 0;
21
+ }
22
+ function toConversationMessages(messages, systemPrompt) {
23
+ const conversation = [];
24
+ if (systemPrompt && systemPrompt.trim()) {
25
+ conversation.push({ role: "system", content: systemPrompt.trim() });
26
+ }
27
+ for (const message of messages) {
28
+ conversation.push({ role: message.role, content: message.content });
29
+ }
30
+ return conversation;
31
+ }
32
+ function mergeAssistantText(previous, nextChunk) {
33
+ if (!nextChunk) {
34
+ return previous;
35
+ }
36
+ return `${previous}${nextChunk}`;
37
+ }
38
+ function safeJsonParse(input, fallback) {
39
+ try {
40
+ return JSON.parse(input);
41
+ } catch {
42
+ return fallback;
43
+ }
44
+ }
45
+ function clamp(value, min, max) {
46
+ return Math.min(Math.max(value, min), max);
47
+ }
48
+ function createLogger(enabled) {
49
+ const prefix = "[expo-ai-core]";
50
+ return {
51
+ log: (...args) => {
52
+ if (enabled) {
53
+ console.log(prefix, ...args);
54
+ }
55
+ },
56
+ warn: (...args) => {
57
+ if (enabled) {
58
+ console.warn(prefix, ...args);
59
+ }
60
+ },
61
+ error: (...args) => {
62
+ if (enabled) {
63
+ console.error(prefix, ...args);
64
+ }
65
+ }
66
+ };
67
+ }
68
+ function buildDefaultCacheKey(provider, model) {
69
+ return `expo-ai-core:${provider}:${model ?? "default"}`;
70
+ }
71
+ function toPlainText(value) {
72
+ if (typeof value === "string") {
73
+ return value;
74
+ }
75
+ if (Array.isArray(value)) {
76
+ return value.map(toPlainText).join("");
77
+ }
78
+ if (value && typeof value === "object") {
79
+ const record = value;
80
+ if (typeof record.text === "string") {
81
+ return record.text;
82
+ }
83
+ if (typeof record.content === "string") {
84
+ return record.content;
85
+ }
86
+ }
87
+ return "";
88
+ }
89
+
90
+ // src/providers/shared.ts
91
+ function createRequestController(signal, timeoutMs) {
92
+ const controller = new AbortController();
93
+ let timeoutId = null;
94
+ const abortFromExternal = () => {
95
+ if (!controller.signal.aborted) {
96
+ controller.abort(signal?.reason ?? new Error("Request aborted"));
97
+ }
98
+ };
99
+ if (signal) {
100
+ if (signal.aborted) {
101
+ abortFromExternal();
102
+ } else {
103
+ signal.addEventListener("abort", abortFromExternal, { once: true });
104
+ }
105
+ }
106
+ if (typeof timeoutMs === "number" && timeoutMs > 0) {
107
+ timeoutId = setTimeout(() => {
108
+ if (!controller.signal.aborted) {
109
+ controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));
110
+ }
111
+ }, timeoutMs);
112
+ }
113
+ return {
114
+ signal: controller.signal,
115
+ abort: (reason) => controller.abort(reason),
116
+ cleanup: () => {
117
+ if (timeoutId) {
118
+ clearTimeout(timeoutId);
119
+ }
120
+ if (signal) {
121
+ signal.removeEventListener("abort", abortFromExternal);
122
+ }
123
+ }
124
+ };
125
+ }
126
+ function getLogger(debug) {
127
+ return createLogger(debug);
128
+ }
129
+ function buildTextResult(content, raw) {
130
+ return { content, raw };
131
+ }
132
+ function createProvider(provider) {
133
+ return provider;
134
+ }
135
+ function readOpenAIContent(payload) {
136
+ const choice = payload?.choices?.[0];
137
+ return choice?.message?.content ?? choice?.delta?.content ?? "";
138
+ }
139
+ function readGeminiContent(payload) {
140
+ const candidate = payload?.candidates?.[0];
141
+ const parts = candidate?.content?.parts ?? [];
142
+ return parts.map((part) => typeof part?.text === "string" ? part.text : "").join("");
143
+ }
144
+ async function readStreamingBody(response, onChunk, signal) {
145
+ if (response.body && "getReader" in response.body) {
146
+ const reader = response.body.getReader();
147
+ const decoder = new TextDecoder();
148
+ while (true) {
149
+ if (signal?.aborted) {
150
+ await reader.cancel().catch(() => void 0);
151
+ break;
152
+ }
153
+ const { done, value } = await reader.read();
154
+ if (done) {
155
+ break;
156
+ }
157
+ if (value) {
158
+ onChunk(decoder.decode(value, { stream: true }));
159
+ }
160
+ }
161
+ const finalChunk = decoder.decode();
162
+ if (finalChunk) {
163
+ onChunk(finalChunk);
164
+ }
165
+ return;
166
+ }
167
+ const text = await response.text();
168
+ onChunk(text);
169
+ }
170
+ function consumeSseLines(chunk, buffer, onData) {
171
+ const combined = `${buffer}${chunk}`;
172
+ const lines = combined.split(/\r?\n/);
173
+ const nextBuffer = lines.pop() ?? "";
174
+ for (const line of lines) {
175
+ const trimmed = line.trim();
176
+ if (!trimmed) {
177
+ continue;
178
+ }
179
+ if (trimmed.startsWith("data:")) {
180
+ onData(trimmed.slice(5).trim());
181
+ continue;
182
+ }
183
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
184
+ onData(trimmed);
185
+ }
186
+ }
187
+ return nextBuffer;
188
+ }
189
+ function appendToken(current, token) {
190
+ return `${current}${token}`;
191
+ }
192
+
193
+ // src/providers/gemini.ts
194
+ var DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
195
+ var GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models";
196
+ function mapGeminiMessages(messages) {
197
+ return messages.map((message) => ({
198
+ role: message.role === "assistant" ? "model" : "user",
199
+ parts: [{ text: message.content }]
200
+ }));
201
+ }
202
+ async function requestGemini(options, request, handlers) {
203
+ const logger = getLogger(options.debug);
204
+ const controller = createRequestController(
205
+ request.signal,
206
+ request.timeoutMs ?? options.timeoutMs
207
+ );
208
+ const model = options.model ?? DEFAULT_GEMINI_MODEL;
209
+ const url = `${options.baseUrl ?? GEMINI_ENDPOINT}/${model}:${request.stream ? "streamGenerateContent" : "generateContent"}?key=${encodeURIComponent(options.apiKey)}`;
210
+ const contents = mapGeminiMessages(request.messages);
211
+ const body = {
212
+ ...options.systemPrompt ? { systemInstruction: { parts: [{ text: options.systemPrompt }] } } : null,
213
+ contents,
214
+ generationConfig: {
215
+ temperature: 0.7
216
+ }
217
+ };
218
+ try {
219
+ const response = await fetch(url, {
220
+ method: "POST",
221
+ signal: controller.signal,
222
+ headers: {
223
+ "Content-Type": "application/json"
224
+ },
225
+ body: JSON.stringify(body)
226
+ });
227
+ if (!response.ok) {
228
+ const errorText = await response.text().catch(() => "");
229
+ throw new Error(
230
+ errorText || `Gemini request failed with status ${response.status}`
231
+ );
232
+ }
233
+ if (!request.stream) {
234
+ const payload = await response.json();
235
+ const content = readGeminiContent(payload);
236
+ return buildTextResult(content, payload);
237
+ }
238
+ let collected = "";
239
+ let buffer = "";
240
+ await readStreamingBody(
241
+ response,
242
+ (chunk) => {
243
+ buffer = consumeSseLines(chunk, buffer, (data) => {
244
+ if (data === "[DONE]") {
245
+ return;
246
+ }
247
+ const payload = typeof data === "string" ? JSON.parse(data) : data;
248
+ const token = readGeminiContent(payload);
249
+ if (token) {
250
+ collected = appendToken(collected, token);
251
+ handlers?.onToken?.(token);
252
+ }
253
+ });
254
+ },
255
+ controller.signal
256
+ );
257
+ return buildTextResult(collected);
258
+ } catch (error) {
259
+ logger.error("Gemini request failed", error);
260
+ throw error;
261
+ } finally {
262
+ controller.cleanup();
263
+ }
264
+ }
265
+ function createGeminiProvider(options) {
266
+ return createProvider({
267
+ name: "gemini",
268
+ sendMessage(request) {
269
+ return requestGemini(options, { ...request, stream: false });
270
+ },
271
+ streamMessage(request) {
272
+ return requestGemini(
273
+ options,
274
+ { ...request, stream: true },
275
+ { onToken: request.onToken }
276
+ );
277
+ }
278
+ });
279
+ }
280
+
281
+ // src/providers/openai.ts
282
+ var DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
283
+ var OPENAI_ENDPOINT = "https://api.openai.com/v1/chat/completions";
284
+ async function requestOpenAI(options, request, handlers) {
285
+ const logger = getLogger(options.debug);
286
+ const controller = createRequestController(
287
+ request.signal,
288
+ request.timeoutMs ?? options.timeoutMs
289
+ );
290
+ const messages = options.systemPrompt ? [{ role: "system", content: options.systemPrompt }, ...request.messages] : request.messages;
291
+ const body = {
292
+ model: options.model ?? DEFAULT_OPENAI_MODEL,
293
+ messages,
294
+ stream: Boolean(request.stream)
295
+ };
296
+ try {
297
+ const response = await fetch(options.baseUrl ?? OPENAI_ENDPOINT, {
298
+ method: "POST",
299
+ signal: controller.signal,
300
+ headers: {
301
+ Authorization: `Bearer ${options.apiKey}`,
302
+ "Content-Type": "application/json"
303
+ },
304
+ body: JSON.stringify(body)
305
+ });
306
+ if (!response.ok) {
307
+ const errorText = await response.text().catch(() => "");
308
+ throw new Error(
309
+ errorText || `OpenAI request failed with status ${response.status}`
310
+ );
311
+ }
312
+ if (!request.stream) {
313
+ const payload = await response.json();
314
+ const content = readOpenAIContent(payload);
315
+ return buildTextResult(content, payload);
316
+ }
317
+ let collected = "";
318
+ let buffer = "";
319
+ await readStreamingBody(
320
+ response,
321
+ (chunk) => {
322
+ buffer = consumeSseLines(chunk, buffer, (data) => {
323
+ if (data === "[DONE]") {
324
+ return;
325
+ }
326
+ const payload = typeof data === "string" ? JSON.parse(data) : data;
327
+ const token = readOpenAIContent(payload);
328
+ if (token) {
329
+ collected = `${collected}${token}`;
330
+ handlers?.onToken?.(token);
331
+ }
332
+ });
333
+ },
334
+ controller.signal
335
+ );
336
+ return buildTextResult(collected);
337
+ } catch (error) {
338
+ logger.error("OpenAI request failed", error);
339
+ throw error;
340
+ } finally {
341
+ controller.cleanup();
342
+ }
343
+ }
344
+ function createOpenAIProvider(options) {
345
+ return createProvider({
346
+ name: "openai",
347
+ sendMessage(request) {
348
+ return requestOpenAI(options, { ...request, stream: false });
349
+ },
350
+ streamMessage(request) {
351
+ return requestOpenAI(
352
+ options,
353
+ { ...request, stream: true },
354
+ { onToken: request.onToken }
355
+ );
356
+ }
357
+ });
358
+ }
359
+
360
+ // src/utils/cache.ts
361
+ var memoryStore = /* @__PURE__ */ new Map();
362
+ var asyncStoragePromise = null;
363
+ async function resolveStorage() {
364
+ if (!asyncStoragePromise) {
365
+ asyncStoragePromise = import('@react-native-async-storage/async-storage').then((module) => module.default ?? module).catch(() => null);
366
+ }
367
+ return asyncStoragePromise;
368
+ }
369
+ async function readRaw(key) {
370
+ const storage = await resolveStorage();
371
+ if (storage) {
372
+ return storage.getItem(key);
373
+ }
374
+ return memoryStore.get(key) ?? null;
375
+ }
376
+ async function writeRaw(key, value) {
377
+ const storage = await resolveStorage();
378
+ if (storage) {
379
+ await storage.setItem(key, value);
380
+ return;
381
+ }
382
+ memoryStore.set(key, value);
383
+ }
384
+ async function deleteRaw(key) {
385
+ const storage = await resolveStorage();
386
+ if (storage) {
387
+ await storage.removeItem(key);
388
+ return;
389
+ }
390
+ memoryStore.delete(key);
391
+ }
392
+ async function loadCache(key) {
393
+ const raw = await readRaw(key);
394
+ if (!raw) {
395
+ return null;
396
+ }
397
+ return safeJsonParse(raw, null);
398
+ }
399
+ async function saveCache(key, snapshot) {
400
+ await writeRaw(key, JSON.stringify(snapshot));
401
+ }
402
+ async function clearCache(key) {
403
+ await deleteRaw(key);
404
+ }
405
+
406
+ // src/hooks/useAIChat.ts
407
+ function createChatProvider(options) {
408
+ if (options.provider === "gemini") {
409
+ return createGeminiProvider({
410
+ apiKey: options.apiKey,
411
+ model: options.model,
412
+ systemPrompt: options.systemPrompt,
413
+ timeoutMs: options.timeoutMs,
414
+ debug: options.debug
415
+ });
416
+ }
417
+ return createOpenAIProvider({
418
+ apiKey: options.apiKey,
419
+ model: options.model,
420
+ systemPrompt: options.systemPrompt,
421
+ timeoutMs: options.timeoutMs,
422
+ debug: options.debug
423
+ });
424
+ }
425
+ function useAIChat(options) {
426
+ const {
427
+ provider,
428
+ apiKey,
429
+ model,
430
+ systemPrompt,
431
+ cacheKey = buildDefaultCacheKey(provider, model),
432
+ initialMessages = [],
433
+ timeoutMs = 3e4,
434
+ enableCache = true,
435
+ debug
436
+ } = options;
437
+ const providerInstance = useMemo(
438
+ () => createChatProvider({
439
+ provider,
440
+ apiKey,
441
+ model,
442
+ systemPrompt,
443
+ timeoutMs,
444
+ debug
445
+ }),
446
+ [provider, apiKey, model, systemPrompt, timeoutMs, debug]
447
+ );
448
+ const [messages, setMessages] = useState(initialMessages);
449
+ const [input, setInput] = useState("");
450
+ const [isLoading, setIsLoading] = useState(false);
451
+ const [error, setError] = useState(null);
452
+ const messagesRef = useRef(messages);
453
+ const inputRef = useRef(input);
454
+ const abortRef = useRef(null);
455
+ const assistantIdRef = useRef(null);
456
+ const partialRef = useRef("");
457
+ const loadingRef = useRef(false);
458
+ const streamFlushScheduledRef = useRef(false);
459
+ useEffect(() => {
460
+ messagesRef.current = messages;
461
+ }, [messages]);
462
+ useEffect(() => {
463
+ inputRef.current = input;
464
+ }, [input]);
465
+ useEffect(() => {
466
+ let mounted = true;
467
+ async function restore() {
468
+ if (!enableCache) {
469
+ return;
470
+ }
471
+ const snapshot = await loadCache(cacheKey);
472
+ if (!mounted || !snapshot) {
473
+ return;
474
+ }
475
+ if (snapshot.messages.length > 0 && messagesRef.current.length === 0) {
476
+ setMessages(snapshot.messages);
477
+ }
478
+ if (typeof snapshot.input === "string" && !inputRef.current) {
479
+ setInput(snapshot.input);
480
+ }
481
+ }
482
+ void restore();
483
+ return () => {
484
+ mounted = false;
485
+ };
486
+ }, [cacheKey, enableCache]);
487
+ useEffect(() => {
488
+ if (!enableCache) {
489
+ return;
490
+ }
491
+ void saveCache(cacheKey, {
492
+ messages,
493
+ input,
494
+ updatedAt: Date.now()
495
+ });
496
+ }, [cacheKey, enableCache, messages, input]);
497
+ useEffect(() => {
498
+ return () => {
499
+ abortRef.current?.abort();
500
+ };
501
+ }, []);
502
+ const flushPartial = useCallback((value) => {
503
+ partialRef.current = value;
504
+ if (streamFlushScheduledRef.current) {
505
+ return;
506
+ }
507
+ streamFlushScheduledRef.current = true;
508
+ requestAnimationFrame(() => {
509
+ streamFlushScheduledRef.current = false;
510
+ const assistantId = assistantIdRef.current;
511
+ if (!assistantId) {
512
+ return;
513
+ }
514
+ setMessages(
515
+ (current) => current.map(
516
+ (message) => message.id === assistantId ? { ...message, content: partialRef.current, status: "streaming" } : message
517
+ )
518
+ );
519
+ });
520
+ }, []);
521
+ const stopGenerating = useCallback(() => {
522
+ abortRef.current?.abort();
523
+ abortRef.current = null;
524
+ loadingRef.current = false;
525
+ setIsLoading(false);
526
+ }, []);
527
+ const sendMessage = useCallback(
528
+ async (messageText) => {
529
+ const text = trimContent(messageText ?? inputRef.current);
530
+ if (!text || loadingRef.current) {
531
+ return null;
532
+ }
533
+ const userMessage = {
534
+ id: createId("user"),
535
+ role: "user",
536
+ content: text,
537
+ createdAt: Date.now(),
538
+ status: "sent"
539
+ };
540
+ const assistantMessage = {
541
+ id: createId("assistant"),
542
+ role: "assistant",
543
+ content: "",
544
+ createdAt: Date.now(),
545
+ status: "streaming"
546
+ };
547
+ assistantIdRef.current = assistantMessage.id;
548
+ partialRef.current = "";
549
+ loadingRef.current = true;
550
+ setError(null);
551
+ setInput("");
552
+ setIsLoading(true);
553
+ setMessages((current) => [
554
+ ...current,
555
+ userMessage,
556
+ assistantMessage
557
+ ]);
558
+ const controller = new AbortController();
559
+ abortRef.current = controller;
560
+ try {
561
+ const conversation = [...messagesRef.current, userMessage];
562
+ const result = await providerInstance.streamMessage({
563
+ messages: conversation.map((message) => ({
564
+ role: message.role,
565
+ content: message.content
566
+ })),
567
+ signal: controller.signal,
568
+ timeoutMs,
569
+ onToken: (token) => {
570
+ flushPartial(`${partialRef.current}${token}`);
571
+ }
572
+ });
573
+ const content = result.content || partialRef.current;
574
+ setMessages((current) => {
575
+ const assistantId = assistantIdRef.current;
576
+ if (!assistantId) {
577
+ return current;
578
+ }
579
+ return current.map(
580
+ (message) => message.id === assistantId ? { ...message, content, status: "sent" } : message
581
+ );
582
+ });
583
+ const completedAssistantMessage = {
584
+ ...assistantMessage,
585
+ content,
586
+ status: "sent"
587
+ };
588
+ return completedAssistantMessage;
589
+ } catch (caughtError) {
590
+ const message = caughtError instanceof Error ? caughtError.message : "Failed to send message";
591
+ setError(message);
592
+ setMessages((current) => {
593
+ const assistantId = assistantIdRef.current;
594
+ if (!assistantId) {
595
+ return current;
596
+ }
597
+ return current.map(
598
+ (item) => item.id === assistantId ? {
599
+ ...item,
600
+ content: partialRef.current,
601
+ status: "error",
602
+ error: message
603
+ } : item
604
+ );
605
+ });
606
+ return null;
607
+ } finally {
608
+ loadingRef.current = false;
609
+ setIsLoading(false);
610
+ abortRef.current = null;
611
+ assistantIdRef.current = null;
612
+ }
613
+ },
614
+ [flushPartial, providerInstance, timeoutMs]
615
+ );
616
+ const clearMessages = useCallback(() => {
617
+ setMessages([]);
618
+ setError(null);
619
+ if (enableCache) {
620
+ void clearCache(cacheKey);
621
+ }
622
+ }, [cacheKey, enableCache]);
623
+ const retryLastMessage = useCallback(async () => {
624
+ const lastUserEntry = [...messagesRef.current].map((message, index) => ({ message, index })).reverse().find((entry) => entry.message.role === "user");
625
+ if (!lastUserEntry) {
626
+ return null;
627
+ }
628
+ const lastUserIndex = lastUserEntry.index;
629
+ const nextMessages = messagesRef.current.slice(0, lastUserIndex);
630
+ const content = lastUserEntry.message.content;
631
+ setMessages(nextMessages);
632
+ messagesRef.current = nextMessages;
633
+ return sendMessage(content);
634
+ }, [sendMessage]);
635
+ return {
636
+ messages,
637
+ input,
638
+ setInput,
639
+ sendMessage,
640
+ isLoading,
641
+ error,
642
+ stopGenerating,
643
+ clearMessages,
644
+ retryLastMessage
645
+ };
646
+ }
647
+ function useAIVoice(options = {}) {
648
+ const [transcript, setTranscript] = useState("");
649
+ const [isListening, setIsListening] = useState(false);
650
+ const [recordingUri, setRecordingUri] = useState(null);
651
+ const [error, setError] = useState(null);
652
+ const recognitionRef = useRef(null);
653
+ const recordingRef = useRef(null);
654
+ const cleanupRef = useRef(null);
655
+ const clearTranscript = useCallback(() => {
656
+ setTranscript("");
657
+ setError(null);
658
+ }, []);
659
+ const speak = useCallback(
660
+ async (text) => {
661
+ if (!text.trim()) {
662
+ return;
663
+ }
664
+ try {
665
+ const speechModule = await import('expo-speech').catch(() => null);
666
+ const speech = speechModule?.default ?? speechModule;
667
+ if (speech?.speak) {
668
+ speech.speak(text, {
669
+ rate: options.speechRate ?? 1,
670
+ pitch: options.speechPitch ?? 1
671
+ });
672
+ return;
673
+ }
674
+ if (typeof globalThis !== "undefined" && "speechSynthesis" in globalThis) {
675
+ const utterance = new SpeechSynthesisUtterance(text);
676
+ utterance.rate = options.speechRate ?? 1;
677
+ utterance.pitch = options.speechPitch ?? 1;
678
+ globalThis.speechSynthesis.speak(utterance);
679
+ }
680
+ } catch (caughtError) {
681
+ setError(
682
+ caughtError instanceof Error ? caughtError.message : "Failed to speak text"
683
+ );
684
+ }
685
+ },
686
+ [options.speechPitch, options.speechRate]
687
+ );
688
+ const stopListening = useCallback(async () => {
689
+ const recognition = recognitionRef.current;
690
+ if (recognition) {
691
+ recognition.stop?.();
692
+ recognitionRef.current = null;
693
+ cleanupRef.current?.();
694
+ cleanupRef.current = null;
695
+ setIsListening(false);
696
+ return;
697
+ }
698
+ const recording = recordingRef.current;
699
+ if (recording) {
700
+ try {
701
+ await recording.stopAndUnloadAsync();
702
+ setRecordingUri(recording.getURI?.() ?? null);
703
+ } catch (caughtError) {
704
+ setError(
705
+ caughtError instanceof Error ? caughtError.message : "Failed to stop recording"
706
+ );
707
+ } finally {
708
+ recordingRef.current = null;
709
+ setIsListening(false);
710
+ }
711
+ }
712
+ }, []);
713
+ const startListening = useCallback(async () => {
714
+ setError(null);
715
+ clearTranscript();
716
+ const SpeechRecognition = globalThis.SpeechRecognition ?? globalThis.webkitSpeechRecognition ?? null;
717
+ if (SpeechRecognition) {
718
+ const recognition = new SpeechRecognition();
719
+ recognition.lang = options.language ?? "en-US";
720
+ recognition.continuous = options.continuous ?? false;
721
+ recognition.interimResults = options.interimResults ?? true;
722
+ recognition.maxAlternatives = 1;
723
+ recognition.onresult = (event) => {
724
+ let nextTranscript = "";
725
+ for (let index = 0; index < event.results.length; index += 1) {
726
+ const result = event.results[index];
727
+ nextTranscript += result[0]?.transcript ?? "";
728
+ }
729
+ setTranscript(nextTranscript.trim());
730
+ };
731
+ recognition.onerror = (event) => {
732
+ setError(
733
+ event?.error ? String(event.error) : "Speech recognition failed"
734
+ );
735
+ setIsListening(false);
736
+ };
737
+ recognition.onend = () => {
738
+ setIsListening(false);
739
+ recognitionRef.current = null;
740
+ };
741
+ recognitionRef.current = recognition;
742
+ recognition.start();
743
+ setIsListening(true);
744
+ return;
745
+ }
746
+ try {
747
+ const expoAv = await import('expo-av').catch(() => null);
748
+ const Audio = expoAv?.Audio ?? null;
749
+ if (!Audio) {
750
+ throw new Error("Speech recognition is unavailable on this platform");
751
+ }
752
+ const permission = await Audio.requestPermissionsAsync();
753
+ if (!permission.granted) {
754
+ throw new Error("Microphone permission is required");
755
+ }
756
+ await Audio.setAudioModeAsync({
757
+ allowsRecordingIOS: true,
758
+ playsInSilentModeIOS: true,
759
+ shouldDuckAndroid: true,
760
+ staysActiveInBackground: false
761
+ });
762
+ const recording = new Audio.Recording();
763
+ await recording.prepareToRecordAsync(
764
+ Audio.RecordingOptionsPresets.HIGH_QUALITY
765
+ );
766
+ await recording.startAsync();
767
+ recordingRef.current = recording;
768
+ setIsListening(true);
769
+ setError(
770
+ "Speech recognition is not available in Expo Go. Audio recording started instead."
771
+ );
772
+ } catch (caughtError) {
773
+ setError(
774
+ caughtError instanceof Error ? caughtError.message : "Failed to start listening"
775
+ );
776
+ setIsListening(false);
777
+ }
778
+ }, [
779
+ clearTranscript,
780
+ options.continuous,
781
+ options.interimResults,
782
+ options.language
783
+ ]);
784
+ useEffect(() => {
785
+ return () => {
786
+ recognitionRef.current?.stop?.();
787
+ cleanupRef.current?.();
788
+ const recording = recordingRef.current;
789
+ if (recording) {
790
+ void recording.stopAndUnloadAsync().catch(() => void 0);
791
+ }
792
+ };
793
+ }, []);
794
+ return {
795
+ startListening,
796
+ stopListening,
797
+ transcript,
798
+ isListening,
799
+ recordingUri,
800
+ error,
801
+ speak,
802
+ clearTranscript
803
+ };
804
+ }
805
+ var defaultTheme = {
806
+ surfaceColor: "#111827",
807
+ textColor: "#f9fafb",
808
+ textMutedColor: "#94a3b8",
809
+ borderColor: "#334155",
810
+ primaryColor: "#38bdf8"
811
+ };
812
+ function AIInput({
813
+ value,
814
+ onChangeText,
815
+ onSend,
816
+ placeholder = "Type a message",
817
+ disabled,
818
+ loading,
819
+ className,
820
+ style,
821
+ inputStyle,
822
+ buttonStyle,
823
+ buttonTextStyle,
824
+ multiline = true,
825
+ showSendIcon = true,
826
+ sendLabel = "Send"
827
+ }) {
828
+ const isDisabled = disabled || loading || value.trim().length === 0;
829
+ return /* @__PURE__ */ jsxs(View, { ...{ className }, style: [styles.container, style, { borderColor: defaultTheme.borderColor, backgroundColor: defaultTheme.surfaceColor }], children: [
830
+ /* @__PURE__ */ jsx(
831
+ TextInput,
832
+ {
833
+ value,
834
+ onChangeText,
835
+ placeholder,
836
+ placeholderTextColor: defaultTheme.textMutedColor,
837
+ editable: !disabled,
838
+ multiline,
839
+ style: [styles.input, { color: defaultTheme.textColor }, inputStyle]
840
+ }
841
+ ),
842
+ /* @__PURE__ */ jsx(
843
+ Pressable,
844
+ {
845
+ accessibilityRole: "button",
846
+ onPress: () => {
847
+ if (!isDisabled) {
848
+ void onSend();
849
+ }
850
+ },
851
+ style: ({ pressed }) => [
852
+ styles.button,
853
+ {
854
+ opacity: isDisabled ? 0.5 : pressed ? 0.85 : 1,
855
+ backgroundColor: defaultTheme.primaryColor
856
+ },
857
+ buttonStyle
858
+ ],
859
+ children: loading ? /* @__PURE__ */ jsx(ActivityIndicator, { color: "#ffffff" }) : /* @__PURE__ */ jsxs(Text, { style: [styles.buttonText, buttonTextStyle], children: [
860
+ showSendIcon ? "\u27A4 " : "",
861
+ sendLabel
862
+ ] })
863
+ }
864
+ )
865
+ ] });
866
+ }
867
+ var styles = StyleSheet.create({
868
+ container: {
869
+ borderRadius: 20,
870
+ borderWidth: StyleSheet.hairlineWidth,
871
+ flexDirection: "row",
872
+ gap: 10,
873
+ padding: 12,
874
+ alignItems: "flex-end"
875
+ },
876
+ input: {
877
+ flex: 1,
878
+ minHeight: 44,
879
+ maxHeight: 140,
880
+ fontSize: 15,
881
+ paddingVertical: 8
882
+ },
883
+ button: {
884
+ alignItems: "center",
885
+ borderRadius: 14,
886
+ minHeight: 42,
887
+ justifyContent: "center",
888
+ paddingHorizontal: 16,
889
+ paddingVertical: 10
890
+ },
891
+ buttonText: {
892
+ color: "#ffffff",
893
+ fontSize: 14,
894
+ fontWeight: "700"
895
+ }
896
+ });
897
+ function parseInline(content) {
898
+ const parts = [];
899
+ let cursor = 0;
900
+ const pattern = /\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`|\*\*([^*]+)\*\*/g;
901
+ for (; ; ) {
902
+ const match = pattern.exec(content);
903
+ if (!match) {
904
+ break;
905
+ }
906
+ if (match.index > cursor) {
907
+ parts.push({ kind: "text", value: content.slice(cursor, match.index) });
908
+ }
909
+ if (match[1] && match[2]) {
910
+ parts.push({ kind: "link", value: match[1], url: match[2] });
911
+ } else if (match[3]) {
912
+ parts.push({ kind: "code", value: match[3] });
913
+ } else if (match[4]) {
914
+ parts.push({ kind: "bold", value: match[4] });
915
+ }
916
+ cursor = pattern.lastIndex;
917
+ }
918
+ if (cursor < content.length) {
919
+ parts.push({ kind: "text", value: content.slice(cursor) });
920
+ }
921
+ return parts;
922
+ }
923
+ function renderInlineParts(parts, onLinkPress) {
924
+ return parts.map((part, index) => {
925
+ if (part.kind === "text") {
926
+ return part.value;
927
+ }
928
+ if (part.kind === "code") {
929
+ return /* @__PURE__ */ jsx(Text, { style: styles2.inlineCode, children: part.value }, `code-${index}`);
930
+ }
931
+ if (part.kind === "bold") {
932
+ return /* @__PURE__ */ jsx(Text, { style: styles2.bold, children: part.value }, `bold-${index}`);
933
+ }
934
+ return /* @__PURE__ */ jsx(
935
+ Text,
936
+ {
937
+ style: styles2.link,
938
+ onPress: () => {
939
+ if (onLinkPress) {
940
+ onLinkPress(part.url);
941
+ return;
942
+ }
943
+ Linking.openURL(part.url).catch(() => void 0);
944
+ },
945
+ children: part.value
946
+ },
947
+ `link-${index}`
948
+ );
949
+ });
950
+ }
951
+ function splitMarkdownBlocks(content) {
952
+ const blocks = [];
953
+ const regex = /```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g;
954
+ let cursor = 0;
955
+ for (; ; ) {
956
+ const match = regex.exec(content);
957
+ if (!match) {
958
+ break;
959
+ }
960
+ if (match.index > cursor) {
961
+ blocks.push({ type: "text", value: content.slice(cursor, match.index) });
962
+ }
963
+ blocks.push({ type: "code", value: match[2], language: match[1] });
964
+ cursor = regex.lastIndex;
965
+ }
966
+ if (cursor < content.length) {
967
+ blocks.push({ type: "text", value: content.slice(cursor) });
968
+ }
969
+ return blocks;
970
+ }
971
+ function MarkdownText({ content, textStyle, codeStyle, onLinkPress, selectable = true }) {
972
+ const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
973
+ return /* @__PURE__ */ jsx(View, { children: blocks.map((block, blockIndex) => {
974
+ if (block.type === "code") {
975
+ return /* @__PURE__ */ jsx(View, { style: styles2.codeBlock, children: /* @__PURE__ */ jsx(Text, { selectable, style: [styles2.codeText, codeStyle], children: block.value.trimEnd() }) }, `codeblock-${blockIndex}`);
976
+ }
977
+ const paragraphs = block.value.split(/\n{2,}/).filter(Boolean);
978
+ return paragraphs.map((paragraph, paragraphIndex) => /* @__PURE__ */ jsxs(
979
+ Text,
980
+ {
981
+ selectable,
982
+ style: textStyle,
983
+ children: [
984
+ renderInlineParts(parseInline(paragraph), onLinkPress),
985
+ paragraphIndex < paragraphs.length - 1 ? "\n\n" : ""
986
+ ]
987
+ },
988
+ `text-${blockIndex}-${paragraphIndex}`
989
+ ));
990
+ }) });
991
+ }
992
+ var styles2 = StyleSheet.create({
993
+ codeBlock: {
994
+ borderRadius: 14,
995
+ marginTop: 10,
996
+ marginBottom: 10,
997
+ overflow: "hidden"
998
+ },
999
+ codeText: {
1000
+ backgroundColor: "#111827",
1001
+ color: "#f9fafb",
1002
+ fontFamily: "Courier",
1003
+ fontSize: 13,
1004
+ lineHeight: 18,
1005
+ padding: 12
1006
+ },
1007
+ inlineCode: {
1008
+ backgroundColor: "#374151",
1009
+ borderRadius: 6,
1010
+ color: "#f9fafb",
1011
+ fontFamily: "Courier",
1012
+ fontSize: 13,
1013
+ paddingHorizontal: 4,
1014
+ paddingVertical: 1
1015
+ },
1016
+ bold: {
1017
+ fontWeight: "700"
1018
+ },
1019
+ link: {
1020
+ color: "#2563eb",
1021
+ textDecorationLine: "underline"
1022
+ }
1023
+ });
1024
+ var defaultTheme2 = {
1025
+ backgroundColor: "#0b1020",
1026
+ surfaceColor: "#111827",
1027
+ surfaceMutedColor: "#1f2937",
1028
+ textColor: "#f9fafb",
1029
+ textMutedColor: "#cbd5e1",
1030
+ borderColor: "#334155",
1031
+ primaryColor: "#38bdf8",
1032
+ userBubbleColor: "#1d4ed8",
1033
+ assistantBubbleColor: "#111827",
1034
+ codeBackgroundColor: "#0f172a",
1035
+ codeTextColor: "#e2e8f0",
1036
+ errorColor: "#ef4444"
1037
+ };
1038
+ function AIMessageBubble({
1039
+ message,
1040
+ theme,
1041
+ className,
1042
+ style,
1043
+ contentStyle,
1044
+ codeStyle,
1045
+ onLinkPress,
1046
+ showTimestamp
1047
+ }) {
1048
+ const colors = { ...defaultTheme2, ...theme };
1049
+ const isUser = message.role === "user";
1050
+ const bubbleColor = isUser ? colors.userBubbleColor : colors.assistantBubbleColor;
1051
+ const alignSelf = isUser ? "flex-end" : "flex-start";
1052
+ return /* @__PURE__ */ jsx(View, { ...{ className }, style: [styles3.wrapper, { alignSelf }, style], children: /* @__PURE__ */ jsxs(
1053
+ View,
1054
+ {
1055
+ style: [
1056
+ styles3.bubble,
1057
+ {
1058
+ backgroundColor: bubbleColor,
1059
+ borderColor: colors.borderColor
1060
+ }
1061
+ ],
1062
+ children: [
1063
+ /* @__PURE__ */ jsx(
1064
+ MarkdownText,
1065
+ {
1066
+ content: message.content || (message.status === "streaming" ? " " : ""),
1067
+ textStyle: [styles3.content, { color: colors.textColor }, contentStyle],
1068
+ codeStyle: [{ color: colors.codeTextColor }, codeStyle],
1069
+ onLinkPress
1070
+ }
1071
+ ),
1072
+ message.status === "streaming" || message.status === "sending" ? /* @__PURE__ */ jsx(Text, { style: [styles3.status, { color: colors.textMutedColor }], children: "Generating..." }) : null,
1073
+ message.status === "error" && message.error ? /* @__PURE__ */ jsx(Text, { style: [styles3.status, { color: colors.errorColor }], children: message.error }) : null,
1074
+ showTimestamp ? /* @__PURE__ */ jsx(Text, { style: [styles3.timestamp, { color: colors.textMutedColor }], children: new Date(message.createdAt).toLocaleTimeString() }) : null
1075
+ ]
1076
+ }
1077
+ ) });
1078
+ }
1079
+ var styles3 = StyleSheet.create({
1080
+ wrapper: {
1081
+ marginVertical: 6,
1082
+ maxWidth: "90%"
1083
+ },
1084
+ bubble: {
1085
+ borderRadius: 18,
1086
+ borderWidth: StyleSheet.hairlineWidth,
1087
+ paddingHorizontal: 14,
1088
+ paddingVertical: 12,
1089
+ shadowColor: "#000",
1090
+ shadowOpacity: 0.08,
1091
+ shadowRadius: 8,
1092
+ shadowOffset: {
1093
+ width: 0,
1094
+ height: 2
1095
+ },
1096
+ elevation: 1
1097
+ },
1098
+ content: {
1099
+ fontSize: 15,
1100
+ lineHeight: 21
1101
+ },
1102
+ status: {
1103
+ fontSize: 12,
1104
+ marginTop: 8,
1105
+ opacity: 0.85
1106
+ },
1107
+ timestamp: {
1108
+ fontSize: 11,
1109
+ marginTop: 6,
1110
+ opacity: 0.65,
1111
+ textAlign: "right"
1112
+ }
1113
+ });
1114
+ function AITypingIndicator({ color = "#38bdf8", style }) {
1115
+ const dotOne = useRef(new Animated.Value(0)).current;
1116
+ const dotTwo = useRef(new Animated.Value(0)).current;
1117
+ const dotThree = useRef(new Animated.Value(0)).current;
1118
+ const animValues = useMemo(() => [dotOne, dotTwo, dotThree], [dotOne, dotTwo, dotThree]);
1119
+ useEffect(() => {
1120
+ const animations = animValues.map(
1121
+ (value, index) => Animated.loop(
1122
+ Animated.sequence([
1123
+ Animated.delay(index * 140),
1124
+ Animated.timing(value, { toValue: 1, duration: 420, useNativeDriver: true }),
1125
+ Animated.timing(value, { toValue: 0, duration: 420, useNativeDriver: true })
1126
+ ])
1127
+ )
1128
+ );
1129
+ animations.forEach((animation) => animation.start());
1130
+ return () => {
1131
+ animations.forEach((animation) => animation.stop());
1132
+ };
1133
+ }, [animValues]);
1134
+ return /* @__PURE__ */ jsx(View, { style: [styles4.container, style], children: animValues.map((value, index) => /* @__PURE__ */ jsx(
1135
+ Animated.View,
1136
+ {
1137
+ style: [
1138
+ styles4.dot,
1139
+ {
1140
+ backgroundColor: color,
1141
+ opacity: value.interpolate({ inputRange: [0, 1], outputRange: [0.35, 1] }),
1142
+ transform: [
1143
+ {
1144
+ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [0, -4] })
1145
+ }
1146
+ ]
1147
+ }
1148
+ ]
1149
+ },
1150
+ index
1151
+ )) });
1152
+ }
1153
+ var styles4 = StyleSheet.create({
1154
+ container: {
1155
+ flexDirection: "row",
1156
+ gap: 6,
1157
+ paddingVertical: 6,
1158
+ alignItems: "center"
1159
+ },
1160
+ dot: {
1161
+ borderRadius: 999,
1162
+ height: 8,
1163
+ width: 8
1164
+ }
1165
+ });
1166
+ var defaultTheme3 = {
1167
+ backgroundColor: "#020617",
1168
+ surfaceColor: "#0f172a",
1169
+ surfaceMutedColor: "#1e293b",
1170
+ textColor: "#f8fafc",
1171
+ textMutedColor: "#94a3b8",
1172
+ borderColor: "#334155",
1173
+ primaryColor: "#38bdf8",
1174
+ userBubbleColor: "#1d4ed8",
1175
+ assistantBubbleColor: "#111827",
1176
+ codeBackgroundColor: "#0f172a",
1177
+ codeTextColor: "#e2e8f0",
1178
+ errorColor: "#f87171"
1179
+ };
1180
+ function AIChatView({
1181
+ messages,
1182
+ input,
1183
+ setInput,
1184
+ sendMessage,
1185
+ isLoading,
1186
+ error,
1187
+ title = "AI Chat",
1188
+ subtitle,
1189
+ emptyStateTitle = "Start a conversation",
1190
+ emptyStateDescription = "Send a message to begin chatting with the provider.",
1191
+ className,
1192
+ style,
1193
+ contentContainerStyle,
1194
+ headerStyle,
1195
+ footerStyle,
1196
+ theme,
1197
+ renderMessage,
1198
+ renderFooter,
1199
+ renderHeader,
1200
+ onPressRetry
1201
+ }) {
1202
+ const colors = useMemo(() => ({ ...defaultTheme3, ...theme }), [theme]);
1203
+ const listRef = useRef(null);
1204
+ useEffect(() => {
1205
+ requestAnimationFrame(() => {
1206
+ listRef.current?.scrollToEnd({ animated: true });
1207
+ });
1208
+ }, [messages.length, isLoading]);
1209
+ return /* @__PURE__ */ jsxs(
1210
+ KeyboardAvoidingView,
1211
+ {
1212
+ ...{ className },
1213
+ behavior: Platform.OS === "ios" ? "padding" : void 0,
1214
+ style: [styles5.root, { backgroundColor: colors.backgroundColor }, style],
1215
+ children: [
1216
+ /* @__PURE__ */ jsxs(View, { style: [styles5.header, headerStyle], children: [
1217
+ renderHeader ? renderHeader() : /* @__PURE__ */ jsxs(View, { children: [
1218
+ /* @__PURE__ */ jsx(Text, { style: [styles5.title, { color: colors.textColor }], children: title }),
1219
+ subtitle ? /* @__PURE__ */ jsx(Text, { style: [styles5.subtitle, { color: colors.textMutedColor }], children: subtitle }) : null
1220
+ ] }),
1221
+ onPressRetry ? /* @__PURE__ */ jsx(Pressable, { onPress: onPressRetry, style: [styles5.retryButton, { borderColor: colors.borderColor }], children: /* @__PURE__ */ jsx(Text, { style: [styles5.retryText, { color: colors.primaryColor }], children: "Retry" }) }) : null
1222
+ ] }),
1223
+ error ? /* @__PURE__ */ jsx(Text, { style: [styles5.error, { color: colors.errorColor }], children: error }) : null,
1224
+ /* @__PURE__ */ jsx(
1225
+ FlatList,
1226
+ {
1227
+ ref: listRef,
1228
+ data: messages,
1229
+ keyExtractor: (item) => item.id,
1230
+ contentContainerStyle: [styles5.listContent, contentContainerStyle, messages.length === 0 ? styles5.emptyList : null],
1231
+ renderItem: ({ item, index }) => /* @__PURE__ */ jsx(Fragment, { children: renderMessage ? renderMessage(item, index) : /* @__PURE__ */ jsx(AIMessageBubble, { message: item, theme }) }),
1232
+ ListEmptyComponent: /* @__PURE__ */ jsxs(View, { style: styles5.emptyState, children: [
1233
+ /* @__PURE__ */ jsx(Text, { style: [styles5.emptyTitle, { color: colors.textColor }], children: emptyStateTitle }),
1234
+ /* @__PURE__ */ jsx(Text, { style: [styles5.emptyDescription, { color: colors.textMutedColor }], children: emptyStateDescription })
1235
+ ] }),
1236
+ ListFooterComponent: /* @__PURE__ */ jsxs(View, { style: footerStyle, children: [
1237
+ isLoading ? /* @__PURE__ */ jsx(AITypingIndicator, { color: colors.primaryColor }) : null,
1238
+ renderFooter ? renderFooter() : null
1239
+ ] }),
1240
+ keyboardShouldPersistTaps: "handled"
1241
+ }
1242
+ ),
1243
+ /* @__PURE__ */ jsx(
1244
+ AIInput,
1245
+ {
1246
+ value: input,
1247
+ onChangeText: setInput,
1248
+ onSend: () => {
1249
+ void sendMessage();
1250
+ },
1251
+ loading: isLoading,
1252
+ style: styles5.input
1253
+ }
1254
+ )
1255
+ ]
1256
+ }
1257
+ );
1258
+ }
1259
+ var styles5 = StyleSheet.create({
1260
+ root: {
1261
+ flex: 1,
1262
+ padding: 16
1263
+ },
1264
+ header: {
1265
+ flexDirection: "row",
1266
+ alignItems: "flex-start",
1267
+ justifyContent: "space-between",
1268
+ marginBottom: 12
1269
+ },
1270
+ title: {
1271
+ fontSize: 26,
1272
+ fontWeight: "800"
1273
+ },
1274
+ subtitle: {
1275
+ fontSize: 14,
1276
+ marginTop: 4
1277
+ },
1278
+ retryButton: {
1279
+ borderRadius: 999,
1280
+ borderWidth: StyleSheet.hairlineWidth,
1281
+ paddingHorizontal: 14,
1282
+ paddingVertical: 8
1283
+ },
1284
+ retryText: {
1285
+ fontSize: 13,
1286
+ fontWeight: "700"
1287
+ },
1288
+ error: {
1289
+ fontSize: 13,
1290
+ marginBottom: 8
1291
+ },
1292
+ listContent: {
1293
+ flexGrow: 1,
1294
+ paddingBottom: 16
1295
+ },
1296
+ emptyList: {
1297
+ justifyContent: "center"
1298
+ },
1299
+ emptyState: {
1300
+ alignItems: "center",
1301
+ flex: 1,
1302
+ justifyContent: "center",
1303
+ paddingVertical: 48
1304
+ },
1305
+ emptyTitle: {
1306
+ fontSize: 20,
1307
+ fontWeight: "700"
1308
+ },
1309
+ emptyDescription: {
1310
+ fontSize: 14,
1311
+ marginTop: 8,
1312
+ textAlign: "center"
1313
+ },
1314
+ input: {
1315
+ marginTop: 12
1316
+ }
1317
+ });
1318
+
1319
+ export { AIChatView, AIInput, AIMessageBubble, AITypingIndicator, MarkdownText, buildDefaultCacheKey, clamp, clearCache, createGeminiProvider, createId, createLogger, createOpenAIProvider, createProvider, isNonEmptyString, loadCache, mergeAssistantText, safeJsonParse, saveCache, toConversationMessages, toPlainText, trimContent, useAIChat, useAIVoice };
1320
+ //# sourceMappingURL=index.js.map
1321
+ //# sourceMappingURL=index.js.map