@stewmore/expo-ai-react 0.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.
@@ -0,0 +1,132 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ import {
4
+ ExpoAI,
5
+ type ExpoAIError,
6
+ type GenerateOptions,
7
+ type GenerateResult,
8
+ } from '@stewmore/expo-ai-core';
9
+
10
+ import { isCancelled, toError, useIsMounted } from './internal.js';
11
+
12
+ export type UseGenerateResult = {
13
+ /** One-shot generation. Resolves the result, or undefined on error/cancel. */
14
+ generate: (options: GenerateOptions) => Promise<GenerateResult | undefined>;
15
+ /** Streamed generation. `text` accumulates as tokens arrive. */
16
+ stream: (options: GenerateOptions) => Promise<GenerateResult | undefined>;
17
+ /** Generated text (accumulates during `stream`, set whole by `generate`). */
18
+ text: string;
19
+ /** Final result with provider + privacy metadata, once complete. */
20
+ result: GenerateResult | null;
21
+ isLoading: boolean;
22
+ error: ExpoAIError | null;
23
+ /** Abort the in-flight request. Cancellation is not surfaced as an error. */
24
+ stop: () => void;
25
+ /** Clear text/result/error back to the initial state. */
26
+ reset: () => void;
27
+ };
28
+
29
+ /**
30
+ * Imperatively generate text — one-shot via `generate` or token-streamed via
31
+ * `stream`. Owns an AbortController so `stop()` (and unmount) cancel cleanly.
32
+ */
33
+ export function useGenerate(): UseGenerateResult {
34
+ const [text, setText] = useState('');
35
+ const [result, setResult] = useState<GenerateResult | null>(null);
36
+ const [isLoading, setIsLoading] = useState(false);
37
+ const [error, setError] = useState<ExpoAIError | null>(null);
38
+ const controllerRef = useRef<AbortController | null>(null);
39
+ const mounted = useIsMounted();
40
+
41
+ // Abort any in-flight request when the component unmounts.
42
+ useEffect(() => () => controllerRef.current?.abort(), []);
43
+
44
+ const stop = useCallback(() => {
45
+ controllerRef.current?.abort();
46
+ }, []);
47
+
48
+ const reset = useCallback(() => {
49
+ setText('');
50
+ setResult(null);
51
+ setError(null);
52
+ }, []);
53
+
54
+ const begin = useCallback(() => {
55
+ controllerRef.current?.abort();
56
+ const controller = new AbortController();
57
+ controllerRef.current = controller;
58
+ setText('');
59
+ setResult(null);
60
+ setError(null);
61
+ setIsLoading(true);
62
+ return controller;
63
+ }, []);
64
+
65
+ // Only the controller still owning the ref may write state — a superseded
66
+ // call (aborted by a newer begin()) must not clobber the live request.
67
+ const isCurrent = useCallback((controller: AbortController) => {
68
+ return controllerRef.current === controller;
69
+ }, []);
70
+
71
+ const finish = useCallback(
72
+ (controller: AbortController) => {
73
+ if (!isCurrent(controller)) return;
74
+ controllerRef.current = null;
75
+ if (mounted.current) setIsLoading(false);
76
+ },
77
+ [isCurrent, mounted],
78
+ );
79
+
80
+ const generate = useCallback(
81
+ async (options: GenerateOptions): Promise<GenerateResult | undefined> => {
82
+ const controller = begin();
83
+ try {
84
+ const generated = await ExpoAI.generate({ ...options, signal: controller.signal });
85
+ if (mounted.current && isCurrent(controller)) {
86
+ setResult(generated);
87
+ setText(generated.text);
88
+ }
89
+ return generated;
90
+ } catch (caught) {
91
+ const normalized = toError(caught);
92
+ if (mounted.current && isCurrent(controller) && !isCancelled(normalized)) {
93
+ setError(normalized);
94
+ }
95
+ return undefined;
96
+ } finally {
97
+ finish(controller);
98
+ }
99
+ },
100
+ [begin, finish, isCurrent, mounted],
101
+ );
102
+
103
+ const stream = useCallback(
104
+ async (options: GenerateOptions): Promise<GenerateResult | undefined> => {
105
+ const controller = begin();
106
+ let final: GenerateResult | undefined;
107
+ try {
108
+ for await (const chunk of ExpoAI.stream({ ...options, signal: controller.signal })) {
109
+ if (!isCurrent(controller)) break;
110
+ if (chunk.type === 'delta') {
111
+ if (mounted.current) setText((current) => current + chunk.text);
112
+ } else if (chunk.type === 'done') {
113
+ final = chunk.result;
114
+ if (mounted.current) setResult(chunk.result);
115
+ }
116
+ }
117
+ return final;
118
+ } catch (caught) {
119
+ const normalized = toError(caught);
120
+ if (mounted.current && isCurrent(controller) && !isCancelled(normalized)) {
121
+ setError(normalized);
122
+ }
123
+ return undefined;
124
+ } finally {
125
+ finish(controller);
126
+ }
127
+ },
128
+ [begin, finish, isCurrent, mounted],
129
+ );
130
+
131
+ return { generate, stream, text, result, isLoading, error, stop, reset };
132
+ }
@@ -0,0 +1,83 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ import {
4
+ ExpoAI,
5
+ type DeepPartial,
6
+ type ExpoAIError,
7
+ type StreamObjectOptions,
8
+ } from '@stewmore/expo-ai-core';
9
+
10
+ import { isCancelled, toError, useIsMounted } from './internal.js';
11
+
12
+ export type UseObjectResult<T> = {
13
+ /** Start streaming a structured object. Resolves the validated value, or undefined. */
14
+ submit: (options: StreamObjectOptions) => Promise<T | undefined>;
15
+ /** Best-effort partial snapshot, growing as tokens arrive; the final validated value last. */
16
+ object: DeepPartial<T> | null;
17
+ isLoading: boolean;
18
+ error: ExpoAIError | null;
19
+ /** Abort the in-flight stream. Cancellation is not surfaced as an error. */
20
+ stop: () => void;
21
+ /** Clear object/error back to the initial state. */
22
+ reset: () => void;
23
+ };
24
+
25
+ /**
26
+ * Stream a structured object into React state. `object` updates with each partial
27
+ * snapshot as tokens arrive, then becomes the validated (repaired) final value.
28
+ * Built on {@link ExpoAI.streamObject}.
29
+ */
30
+ export function useObject<T = unknown>(): UseObjectResult<T> {
31
+ const [object, setObject] = useState<DeepPartial<T> | null>(null);
32
+ const [isLoading, setIsLoading] = useState(false);
33
+ const [error, setError] = useState<ExpoAIError | null>(null);
34
+ const controllerRef = useRef<AbortController | null>(null);
35
+ const mounted = useIsMounted();
36
+
37
+ useEffect(() => () => controllerRef.current?.abort(), []);
38
+
39
+ const stop = useCallback(() => {
40
+ controllerRef.current?.abort();
41
+ }, []);
42
+
43
+ const reset = useCallback(() => {
44
+ setObject(null);
45
+ setError(null);
46
+ }, []);
47
+
48
+ const submit = useCallback(
49
+ async (options: StreamObjectOptions): Promise<T | undefined> => {
50
+ controllerRef.current?.abort();
51
+ const controller = new AbortController();
52
+ controllerRef.current = controller;
53
+ setObject(null);
54
+ setError(null);
55
+ setIsLoading(true);
56
+
57
+ const isCurrent = () => controllerRef.current === controller;
58
+ const handle = ExpoAI.streamObject<T>({ ...options, signal: controller.signal });
59
+ try {
60
+ for await (const partial of handle.partialObjectStream) {
61
+ if (!isCurrent()) break;
62
+ if (mounted.current) setObject(partial);
63
+ }
64
+ const final = await handle.object;
65
+ if (mounted.current && isCurrent()) setObject(final as DeepPartial<T>);
66
+ return final;
67
+ } catch (caught) {
68
+ const normalized = toError(caught);
69
+ // Only the live submit may write state — a superseded one must not clobber it.
70
+ if (mounted.current && isCurrent() && !isCancelled(normalized)) setError(normalized);
71
+ return undefined;
72
+ } finally {
73
+ if (isCurrent()) {
74
+ controllerRef.current = null;
75
+ if (mounted.current) setIsLoading(false);
76
+ }
77
+ }
78
+ },
79
+ [mounted],
80
+ );
81
+
82
+ return { submit, object, isLoading, error, stop, reset };
83
+ }