@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.
- package/build/index.d.ts +13 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +13 -0
- package/build/index.js.map +1 -0
- package/build/internal.d.ts +10 -0
- package/build/internal.d.ts.map +1 -0
- package/build/internal.js +26 -0
- package/build/internal.js.map +1 -0
- package/build/useCapabilities.d.ts +21 -0
- package/build/useCapabilities.d.ts.map +1 -0
- package/build/useCapabilities.js +42 -0
- package/build/useCapabilities.js.map +1 -0
- package/build/useChat.d.ts +27 -0
- package/build/useChat.d.ts.map +1 -0
- package/build/useChat.js +108 -0
- package/build/useChat.js.map +1 -0
- package/build/useGenerate.d.ts +23 -0
- package/build/useGenerate.d.ts.map +1 -0
- package/build/useGenerate.js +100 -0
- package/build/useGenerate.js.map +1 -0
- package/build/useObject.d.ts +20 -0
- package/build/useObject.d.ts.map +1 -0
- package/build/useObject.js +61 -0
- package/build/useObject.js.map +1 -0
- package/package.json +59 -0
- package/src/__tests__/useCapabilities.test.tsx +47 -0
- package/src/__tests__/useChat.test.tsx +103 -0
- package/src/__tests__/useGenerate.test.tsx +100 -0
- package/src/__tests__/useObject.test.tsx +67 -0
- package/src/index.ts +18 -0
- package/src/internal.ts +29 -0
- package/src/useCapabilities.ts +67 -0
- package/src/useChat.ts +150 -0
- package/src/useGenerate.ts +132 -0
- package/src/useObject.ts +83 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { clearAdapters, registerAdapter } from '@stewmore/expo-ai-core';
|
|
5
|
+
import { createMockAdapter } from '@stewmore/expo-ai-core/testing';
|
|
6
|
+
|
|
7
|
+
import { useCapabilities } from '../useCapabilities.js';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => clearAdapters());
|
|
10
|
+
afterEach(() => clearAdapters());
|
|
11
|
+
|
|
12
|
+
describe('useCapabilities', () => {
|
|
13
|
+
it('resolves capabilities, availability, and providers', async () => {
|
|
14
|
+
registerAdapter(createMockAdapter({ provider: 'apple-foundation-models' }));
|
|
15
|
+
|
|
16
|
+
const { result } = renderHook(() => useCapabilities());
|
|
17
|
+
expect(result.current.loading).toBe(true);
|
|
18
|
+
|
|
19
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
20
|
+
|
|
21
|
+
expect(result.current.capabilities?.provider).toBe('apple-foundation-models');
|
|
22
|
+
expect(result.current.availability?.available).toBe(true);
|
|
23
|
+
expect(result.current.providers?.map((p) => p.provider)).toContain('apple-foundation-models');
|
|
24
|
+
expect(result.current.error).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reports an unavailable runtime cleanly when nothing is registered', async () => {
|
|
28
|
+
const { result } = renderHook(() => useCapabilities());
|
|
29
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
30
|
+
|
|
31
|
+
expect(result.current.capabilities?.available).toBe(false);
|
|
32
|
+
expect(result.current.error).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('refresh() re-queries and picks up a newly registered provider', async () => {
|
|
36
|
+
const { result } = renderHook(() => useCapabilities());
|
|
37
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
38
|
+
expect(result.current.providers).toEqual([]);
|
|
39
|
+
|
|
40
|
+
registerAdapter(createMockAdapter({ provider: 'cloud' }));
|
|
41
|
+
result.current.refresh();
|
|
42
|
+
|
|
43
|
+
await waitFor(() =>
|
|
44
|
+
expect(result.current.providers?.map((p) => p.provider)).toContain('cloud'),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { clearAdapters, registerAdapter } from '@stewmore/expo-ai-core';
|
|
5
|
+
import { createMockAdapter } from '@stewmore/expo-ai-core/testing';
|
|
6
|
+
|
|
7
|
+
import { useChat } from '../useChat.js';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => clearAdapters());
|
|
10
|
+
afterEach(() => clearAdapters());
|
|
11
|
+
|
|
12
|
+
describe('useChat', () => {
|
|
13
|
+
it('appends a user turn and streams the assistant reply', async () => {
|
|
14
|
+
registerAdapter(
|
|
15
|
+
createMockAdapter({
|
|
16
|
+
provider: 'apple-foundation-models',
|
|
17
|
+
respondWith: 'I am well thanks',
|
|
18
|
+
supportsStreaming: true,
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const { result } = renderHook(() => useChat());
|
|
23
|
+
await act(async () => {
|
|
24
|
+
await result.current.append('how are you?');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result.current.messages).toHaveLength(2);
|
|
28
|
+
expect(result.current.messages[0]).toMatchObject({ role: 'user', content: 'how are you?' });
|
|
29
|
+
expect(result.current.messages[1]?.role).toBe('assistant');
|
|
30
|
+
expect(result.current.messages[1]?.content).toContain('well');
|
|
31
|
+
expect(result.current.isLoading).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('uses the current input when append is called with no argument', async () => {
|
|
35
|
+
registerAdapter(
|
|
36
|
+
createMockAdapter({ provider: 'cloud', respondWith: 'reply', supportsStreaming: true }),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const { result } = renderHook(() => useChat({ provider: 'cloud' }));
|
|
40
|
+
act(() => result.current.setInput('hello'));
|
|
41
|
+
await act(async () => {
|
|
42
|
+
await result.current.append();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.current.messages[0]?.content).toBe('hello');
|
|
46
|
+
expect(result.current.input).toBe(''); // cleared after sending
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('ignores an empty append', async () => {
|
|
50
|
+
registerAdapter(createMockAdapter({ provider: 'cloud', supportsStreaming: true }));
|
|
51
|
+
const { result } = renderHook(() => useChat({ provider: 'cloud' }));
|
|
52
|
+
|
|
53
|
+
await act(async () => {
|
|
54
|
+
await result.current.append(' ');
|
|
55
|
+
});
|
|
56
|
+
expect(result.current.messages).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reset() clears the transcript', async () => {
|
|
60
|
+
registerAdapter(
|
|
61
|
+
createMockAdapter({ provider: 'cloud', respondWith: 'reply', supportsStreaming: true }),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const { result } = renderHook(() => useChat({ provider: 'cloud' }));
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await result.current.append('hi');
|
|
67
|
+
});
|
|
68
|
+
expect(result.current.messages.length).toBeGreaterThan(0);
|
|
69
|
+
|
|
70
|
+
await act(async () => {
|
|
71
|
+
await result.current.reset();
|
|
72
|
+
});
|
|
73
|
+
expect(result.current.messages).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('surfaces a non-cancellation error and drops the empty assistant bubble', async () => {
|
|
77
|
+
registerAdapter(
|
|
78
|
+
createMockAdapter({ provider: 'cloud', throwError: { code: 'SAFETY_BLOCKED' } }),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const { result } = renderHook(() => useChat({ provider: 'cloud' }));
|
|
82
|
+
await act(async () => {
|
|
83
|
+
await result.current.append('hi');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await waitFor(() => expect(result.current.error?.code).toBe('SAFETY_BLOCKED'));
|
|
87
|
+
expect(result.current.isLoading).toBe(false);
|
|
88
|
+
// The user message stays; the never-filled assistant placeholder is removed.
|
|
89
|
+
expect(result.current.messages).toHaveLength(1);
|
|
90
|
+
expect(result.current.messages[0]).toMatchObject({ role: 'user', content: 'hi' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does not leave an empty assistant bubble when no provider is registered', async () => {
|
|
94
|
+
const { result } = renderHook(() => useChat());
|
|
95
|
+
await act(async () => {
|
|
96
|
+
await result.current.append('hi');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.current.error?.code).toBe('UNAVAILABLE');
|
|
100
|
+
expect(result.current.messages).toHaveLength(1);
|
|
101
|
+
expect(result.current.messages[0]?.role).toBe('user');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { type GenerateResult, clearAdapters, registerAdapter } from '@stewmore/expo-ai-core';
|
|
5
|
+
import { createMockAdapter } from '@stewmore/expo-ai-core/testing';
|
|
6
|
+
|
|
7
|
+
import { useGenerate } from '../useGenerate.js';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => clearAdapters());
|
|
10
|
+
afterEach(() => clearAdapters());
|
|
11
|
+
|
|
12
|
+
describe('useGenerate', () => {
|
|
13
|
+
it('generates one-shot text with provider metadata', async () => {
|
|
14
|
+
registerAdapter(
|
|
15
|
+
createMockAdapter({ provider: 'apple-foundation-models', respondWith: 'hello there' }),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const { result } = renderHook(() => useGenerate());
|
|
19
|
+
await act(async () => {
|
|
20
|
+
await result.current.generate({ prompt: 'hi' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.current.text).toBe('hello there');
|
|
24
|
+
expect(result.current.result?.provider).toBe('apple-foundation-models');
|
|
25
|
+
expect(result.current.result?.privacy.privacyMode).toBe('on-device');
|
|
26
|
+
expect(result.current.isLoading).toBe(false);
|
|
27
|
+
expect(result.current.error).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('accumulates deltas while streaming', async () => {
|
|
31
|
+
registerAdapter(
|
|
32
|
+
createMockAdapter({
|
|
33
|
+
provider: 'cloud',
|
|
34
|
+
respondWith: 'one two three',
|
|
35
|
+
supportsStreaming: true,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const { result } = renderHook(() => useGenerate());
|
|
40
|
+
await act(async () => {
|
|
41
|
+
await result.current.stream({ prompt: 'hi', provider: 'cloud' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.current.text).toBe('one two three');
|
|
45
|
+
expect(result.current.result?.provider).toBe('cloud');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('surfaces a non-cancellation error', async () => {
|
|
49
|
+
registerAdapter(
|
|
50
|
+
createMockAdapter({ provider: 'cloud', throwError: { code: 'SAFETY_BLOCKED' } }),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const { result } = renderHook(() => useGenerate());
|
|
54
|
+
await act(async () => {
|
|
55
|
+
await result.current.generate({ prompt: 'hi', provider: 'cloud' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result.current.error?.code).toBe('SAFETY_BLOCKED');
|
|
59
|
+
expect(result.current.isLoading).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('stop() cancels a stream without surfacing an error', async () => {
|
|
63
|
+
registerAdapter(
|
|
64
|
+
createMockAdapter({
|
|
65
|
+
provider: 'cloud',
|
|
66
|
+
respondWith: 'one two three four five',
|
|
67
|
+
supportsStreaming: true,
|
|
68
|
+
delayMs: 25,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const { result } = renderHook(() => useGenerate());
|
|
73
|
+
let streamPromise: Promise<GenerateResult | undefined> | undefined;
|
|
74
|
+
act(() => {
|
|
75
|
+
streamPromise = result.current.stream({ prompt: 'hi', provider: 'cloud' });
|
|
76
|
+
});
|
|
77
|
+
await waitFor(() => expect(result.current.isLoading).toBe(true));
|
|
78
|
+
|
|
79
|
+
act(() => result.current.stop());
|
|
80
|
+
await act(async () => {
|
|
81
|
+
await streamPromise;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.current.error).toBeNull();
|
|
85
|
+
expect(result.current.isLoading).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reset() clears text, result, and error', async () => {
|
|
89
|
+
registerAdapter(createMockAdapter({ provider: 'cloud', respondWith: 'hi' }));
|
|
90
|
+
const { result } = renderHook(() => useGenerate());
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await result.current.generate({ prompt: 'hi', provider: 'cloud' });
|
|
93
|
+
});
|
|
94
|
+
expect(result.current.text).toBe('hi');
|
|
95
|
+
|
|
96
|
+
act(() => result.current.reset());
|
|
97
|
+
expect(result.current.text).toBe('');
|
|
98
|
+
expect(result.current.result).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { type JSONSchema, clearAdapters, registerAdapter } from '@stewmore/expo-ai-core';
|
|
5
|
+
import { createMockAdapter } from '@stewmore/expo-ai-core/testing';
|
|
6
|
+
|
|
7
|
+
import { useObject } from '../useObject.js';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => clearAdapters());
|
|
10
|
+
afterEach(() => clearAdapters());
|
|
11
|
+
|
|
12
|
+
const personSchema: JSONSchema = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: { name: { type: 'string' }, age: { type: 'integer' } },
|
|
15
|
+
required: ['name', 'age'],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('useObject', () => {
|
|
19
|
+
it('streams partials and lands on the validated object', async () => {
|
|
20
|
+
registerAdapter(
|
|
21
|
+
createMockAdapter({
|
|
22
|
+
provider: 'apple-foundation-models',
|
|
23
|
+
respondWith: '{"name": "Ada", "age": 36}',
|
|
24
|
+
supportsStreaming: true,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const { result } = renderHook(() => useObject<{ name: string; age: number }>());
|
|
29
|
+
await act(async () => {
|
|
30
|
+
await result.current.submit({ prompt: 'a person', schema: personSchema });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result.current.object).toEqual({ name: 'Ada', age: 36 });
|
|
34
|
+
expect(result.current.isLoading).toBe(false);
|
|
35
|
+
expect(result.current.error).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('surfaces install guidance when no provider is registered', async () => {
|
|
39
|
+
const { result } = renderHook(() => useObject());
|
|
40
|
+
await act(async () => {
|
|
41
|
+
await result.current.submit({ prompt: 'a person', schema: personSchema });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.current.error?.code).toBe('UNAVAILABLE');
|
|
45
|
+
expect(result.current.error?.message).toContain('Install and import a provider package');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reset() clears the object and error', async () => {
|
|
49
|
+
registerAdapter(
|
|
50
|
+
createMockAdapter({
|
|
51
|
+
provider: 'cloud',
|
|
52
|
+
respondWith: '{"name": "Ada", "age": 36}',
|
|
53
|
+
supportsStreaming: true,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const { result } = renderHook(() => useObject<{ name: string; age: number }>());
|
|
58
|
+
await act(async () => {
|
|
59
|
+
await result.current.submit({ prompt: 'a person', schema: personSchema, provider: 'cloud' });
|
|
60
|
+
});
|
|
61
|
+
expect(result.current.object).not.toBeNull();
|
|
62
|
+
|
|
63
|
+
act(() => result.current.reset());
|
|
64
|
+
expect(result.current.object).toBeNull();
|
|
65
|
+
expect(result.current.error).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @stewmore/expo-ai-react
|
|
3
|
+
*
|
|
4
|
+
* React hooks for the Expo AI Runtime — `ai/react`-style ergonomics over the
|
|
5
|
+
* provider-agnostic {@link @stewmore/expo-ai-core} package. All hooks own an
|
|
6
|
+
* AbortController so `stop()` and unmount cancel cleanly, guard state updates
|
|
7
|
+
* after unmount, and treat cancellation as intentional (never an `error`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { useCapabilities, type UseCapabilitiesResult } from './useCapabilities.js';
|
|
11
|
+
export { useGenerate, type UseGenerateResult } from './useGenerate.js';
|
|
12
|
+
export { useObject, type UseObjectResult } from './useObject.js';
|
|
13
|
+
export {
|
|
14
|
+
useChat,
|
|
15
|
+
type UseChatResult,
|
|
16
|
+
type ChatMessage,
|
|
17
|
+
type ChatRole,
|
|
18
|
+
} from './useChat.js';
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small internals shared by the hooks. Kept dependency-free (react only) so the
|
|
3
|
+
* package stays testable under jsdom and usable on web.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useRef } from 'react';
|
|
6
|
+
|
|
7
|
+
import { ExpoAIError } from '@stewmore/expo-ai-core';
|
|
8
|
+
|
|
9
|
+
/** A ref that is `true` while the component is mounted, for post-await guards. */
|
|
10
|
+
export function useIsMounted(): { readonly current: boolean } {
|
|
11
|
+
const mounted = useRef(true);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
mounted.current = true;
|
|
14
|
+
return () => {
|
|
15
|
+
mounted.current = false;
|
|
16
|
+
};
|
|
17
|
+
}, []);
|
|
18
|
+
return mounted;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Normalize any thrown value to an ExpoAIError (provider unknown at this layer). */
|
|
22
|
+
export function toError(value: unknown): ExpoAIError {
|
|
23
|
+
return ExpoAIError.from(value, 'none');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Stopping a request is intentional, not an error to surface to the UI. */
|
|
27
|
+
export function isCancelled(error: ExpoAIError): boolean {
|
|
28
|
+
return error.code === 'CANCELLED';
|
|
29
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ExpoAI,
|
|
5
|
+
type ExpoAIAvailability,
|
|
6
|
+
type ExpoAICapabilities,
|
|
7
|
+
type ExpoAIError,
|
|
8
|
+
type ExpoAIProviderInfo,
|
|
9
|
+
} from '@stewmore/expo-ai-core';
|
|
10
|
+
|
|
11
|
+
import { toError, useIsMounted } from './internal.js';
|
|
12
|
+
|
|
13
|
+
export type UseCapabilitiesResult = {
|
|
14
|
+
/** Capabilities of the best currently-available provider, or null while loading. */
|
|
15
|
+
capabilities: ExpoAICapabilities | null;
|
|
16
|
+
/** Availability of the best currently-available provider. */
|
|
17
|
+
availability: ExpoAIAvailability | null;
|
|
18
|
+
/** Every registered provider and its capabilities. */
|
|
19
|
+
providers: ExpoAIProviderInfo[] | null;
|
|
20
|
+
loading: boolean;
|
|
21
|
+
error: ExpoAIError | null;
|
|
22
|
+
/** Re-query availability/capabilities (e.g. after the user enables a model). */
|
|
23
|
+
refresh: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type State = Omit<UseCapabilitiesResult, 'refresh'>;
|
|
27
|
+
|
|
28
|
+
const INITIAL: State = {
|
|
29
|
+
capabilities: null,
|
|
30
|
+
availability: null,
|
|
31
|
+
providers: null,
|
|
32
|
+
loading: true,
|
|
33
|
+
error: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Query what the runtime can do on this device. Resolves capabilities,
|
|
38
|
+
* availability, and the full provider list on mount; call `refresh()` to re-query
|
|
39
|
+
* (capabilities can change when the user toggles Apple Intelligence, finishes a
|
|
40
|
+
* model download, etc.).
|
|
41
|
+
*/
|
|
42
|
+
export function useCapabilities(): UseCapabilitiesResult {
|
|
43
|
+
const [state, setState] = useState<State>(INITIAL);
|
|
44
|
+
const mounted = useIsMounted();
|
|
45
|
+
const requestId = useRef(0);
|
|
46
|
+
|
|
47
|
+
const refresh = useCallback(() => {
|
|
48
|
+
const id = ++requestId.current;
|
|
49
|
+
setState((prev) => ({ ...prev, loading: true, error: null }));
|
|
50
|
+
Promise.all([ExpoAI.getCapabilities(), ExpoAI.getAvailability(), ExpoAI.listProviders()])
|
|
51
|
+
.then(([capabilities, availability, providers]) => {
|
|
52
|
+
// Ignore a resolution superseded by a newer refresh, or after unmount.
|
|
53
|
+
if (!mounted.current || id !== requestId.current) return;
|
|
54
|
+
setState({ capabilities, availability, providers, loading: false, error: null });
|
|
55
|
+
})
|
|
56
|
+
.catch((caught) => {
|
|
57
|
+
if (!mounted.current || id !== requestId.current) return;
|
|
58
|
+
setState((prev) => ({ ...prev, loading: false, error: toError(caught) }));
|
|
59
|
+
});
|
|
60
|
+
}, [mounted]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
refresh();
|
|
64
|
+
}, [refresh]);
|
|
65
|
+
|
|
66
|
+
return { ...state, refresh };
|
|
67
|
+
}
|
package/src/useChat.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ExpoAI,
|
|
5
|
+
type CreateSessionOptions,
|
|
6
|
+
type ExpoAIError,
|
|
7
|
+
type ExpoAISession,
|
|
8
|
+
} from '@stewmore/expo-ai-core';
|
|
9
|
+
|
|
10
|
+
import { isCancelled, toError, useIsMounted } from './internal.js';
|
|
11
|
+
|
|
12
|
+
export type ChatRole = 'user' | 'assistant';
|
|
13
|
+
|
|
14
|
+
export type ChatMessage = {
|
|
15
|
+
id: string;
|
|
16
|
+
role: ChatRole;
|
|
17
|
+
content: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type UseChatResult = {
|
|
21
|
+
messages: ChatMessage[];
|
|
22
|
+
input: string;
|
|
23
|
+
setInput: (value: string) => void;
|
|
24
|
+
/** Send a turn (defaults to the current `input`) and stream the assistant reply. */
|
|
25
|
+
append: (content?: string) => Promise<void>;
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
error: ExpoAIError | null;
|
|
28
|
+
/** Abort the in-flight reply. Cancellation is not surfaced as an error. */
|
|
29
|
+
stop: () => void;
|
|
30
|
+
/** Dispose the session and clear the transcript. */
|
|
31
|
+
reset: () => Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A streaming chat transcript over a cross-platform {@link ExpoAISession}. The
|
|
36
|
+
* session is created lazily on the first `append` and disposed on unmount.
|
|
37
|
+
* `options` is captured on first use; later changes are ignored.
|
|
38
|
+
*/
|
|
39
|
+
export function useChat(options?: CreateSessionOptions): UseChatResult {
|
|
40
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
41
|
+
const [input, setInput] = useState('');
|
|
42
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
43
|
+
const [error, setError] = useState<ExpoAIError | null>(null);
|
|
44
|
+
|
|
45
|
+
const sessionRef = useRef<ExpoAISession | null>(null);
|
|
46
|
+
const controllerRef = useRef<AbortController | null>(null);
|
|
47
|
+
const idRef = useRef(0);
|
|
48
|
+
const optionsRef = useRef(options);
|
|
49
|
+
const mounted = useIsMounted();
|
|
50
|
+
|
|
51
|
+
const nextId = useCallback((role: ChatRole) => `${role}-${++idRef.current}`, []);
|
|
52
|
+
|
|
53
|
+
const disposeSession = useCallback(() => {
|
|
54
|
+
const session = sessionRef.current;
|
|
55
|
+
sessionRef.current = null;
|
|
56
|
+
void session?.dispose().catch(() => {});
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Abort the in-flight reply and dispose the session on unmount.
|
|
60
|
+
useEffect(
|
|
61
|
+
() => () => {
|
|
62
|
+
controllerRef.current?.abort();
|
|
63
|
+
disposeSession();
|
|
64
|
+
},
|
|
65
|
+
[disposeSession],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const stop = useCallback(() => {
|
|
69
|
+
controllerRef.current?.abort();
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const append = useCallback(
|
|
73
|
+
async (content?: string): Promise<void> => {
|
|
74
|
+
const text = (content ?? input).trim();
|
|
75
|
+
if (text.length === 0) return;
|
|
76
|
+
|
|
77
|
+
controllerRef.current?.abort();
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
controllerRef.current = controller;
|
|
80
|
+
const isCurrent = () => controllerRef.current === controller;
|
|
81
|
+
|
|
82
|
+
const assistantId = nextId('assistant');
|
|
83
|
+
setMessages((prev) => [
|
|
84
|
+
...prev,
|
|
85
|
+
{ id: nextId('user'), role: 'user', content: text },
|
|
86
|
+
{ id: assistantId, role: 'assistant', content: '' },
|
|
87
|
+
]);
|
|
88
|
+
if (content === undefined) setInput('');
|
|
89
|
+
setError(null);
|
|
90
|
+
setIsLoading(true);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (!sessionRef.current) {
|
|
94
|
+
// createSession is not abortable; if we unmounted (or another append
|
|
95
|
+
// already created one) while it was pending, dispose this one.
|
|
96
|
+
const created = await ExpoAI.createSession(optionsRef.current);
|
|
97
|
+
if (!mounted.current || sessionRef.current) {
|
|
98
|
+
void created.dispose().catch(() => {});
|
|
99
|
+
if (!mounted.current) return;
|
|
100
|
+
} else {
|
|
101
|
+
sessionRef.current = created;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const session = sessionRef.current;
|
|
105
|
+
if (!session) return;
|
|
106
|
+
for await (const chunk of session.stream({ prompt: text, signal: controller.signal })) {
|
|
107
|
+
if (!isCurrent()) break;
|
|
108
|
+
if (chunk.type === 'delta' && mounted.current) {
|
|
109
|
+
setMessages((prev) =>
|
|
110
|
+
prev.map((message) =>
|
|
111
|
+
message.id === assistantId
|
|
112
|
+
? { ...message, content: message.content + chunk.text }
|
|
113
|
+
: message,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (caught) {
|
|
119
|
+
const normalized = toError(caught);
|
|
120
|
+
if (mounted.current && isCurrent()) {
|
|
121
|
+
// Drop the still-empty assistant placeholder; surface the error instead.
|
|
122
|
+
setMessages((prev) =>
|
|
123
|
+
prev.filter((message) => !(message.id === assistantId && message.content === '')),
|
|
124
|
+
);
|
|
125
|
+
if (!isCancelled(normalized)) setError(normalized);
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
if (isCurrent()) {
|
|
129
|
+
controllerRef.current = null;
|
|
130
|
+
if (mounted.current) setIsLoading(false);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[input, mounted, nextId],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const reset = useCallback(async (): Promise<void> => {
|
|
138
|
+
controllerRef.current?.abort();
|
|
139
|
+
const session = sessionRef.current;
|
|
140
|
+
sessionRef.current = null;
|
|
141
|
+
if (mounted.current) {
|
|
142
|
+
setMessages([]);
|
|
143
|
+
setError(null);
|
|
144
|
+
setIsLoading(false);
|
|
145
|
+
}
|
|
146
|
+
await session?.dispose().catch(() => {});
|
|
147
|
+
}, [mounted]);
|
|
148
|
+
|
|
149
|
+
return { messages, input, setInput, append, isLoading, error, stop, reset };
|
|
150
|
+
}
|