@ai-sdk/angular 2.0.45 → 2.0.46

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,77 @@
1
+ import { signal } from '@angular/core';
2
+ import {
3
+ type ChatState,
4
+ type ChatStatus,
5
+ type UIMessage,
6
+ type ChatInit,
7
+ AbstractChat,
8
+ } from 'ai';
9
+
10
+ export class Chat<
11
+ UI_MESSAGE extends UIMessage = UIMessage,
12
+ > extends AbstractChat<UI_MESSAGE> {
13
+ constructor(init: ChatInit<UI_MESSAGE>) {
14
+ super({
15
+ ...init,
16
+ state: new AngularChatState(init.messages),
17
+ });
18
+ }
19
+ }
20
+
21
+ class AngularChatState<UI_MESSAGE extends UIMessage = UIMessage>
22
+ implements ChatState<UI_MESSAGE>
23
+ {
24
+ readonly #messages = signal<UI_MESSAGE[]>([]);
25
+ readonly #status = signal<ChatStatus>('ready');
26
+ readonly #error = signal<Error | undefined>(undefined);
27
+
28
+ get messages(): UI_MESSAGE[] {
29
+ return this.#messages();
30
+ }
31
+
32
+ set messages(messages: UI_MESSAGE[]) {
33
+ this.#messages.set([...messages]);
34
+ }
35
+
36
+ get status(): ChatStatus {
37
+ return this.#status();
38
+ }
39
+
40
+ set status(status: ChatStatus) {
41
+ this.#status.set(status);
42
+ }
43
+
44
+ get error(): Error | undefined {
45
+ return this.#error();
46
+ }
47
+
48
+ set error(error: Error | undefined) {
49
+ this.#error.set(error);
50
+ }
51
+
52
+ constructor(initialMessages: UI_MESSAGE[] = []) {
53
+ this.#messages.set([...initialMessages]);
54
+ }
55
+
56
+ setMessages = (messages: UI_MESSAGE[]) => {
57
+ this.#messages.set([...messages]);
58
+ };
59
+
60
+ pushMessage = (message: UI_MESSAGE) => {
61
+ this.#messages.update(msgs => [...msgs, message]);
62
+ };
63
+
64
+ popMessage = () => {
65
+ this.#messages.update(msgs => msgs.slice(0, -1));
66
+ };
67
+
68
+ replaceMessage = (index: number, message: UI_MESSAGE) => {
69
+ this.#messages.update(msgs => {
70
+ const copy = [...msgs];
71
+ copy[index] = message;
72
+ return copy;
73
+ });
74
+ };
75
+
76
+ snapshot = <T>(thing: T): T => structuredClone(thing);
77
+ }
@@ -0,0 +1,170 @@
1
+ import {
2
+ createTestServer,
3
+ TestResponseController,
4
+ } from '@ai-sdk/test-server/with-vitest';
5
+ import { Completion } from './completion.ng';
6
+ import { beforeAll } from 'vitest';
7
+ import { describe, it, expect, vi } from 'vitest';
8
+
9
+ function formatStreamPart(part: object) {
10
+ return `data: ${JSON.stringify(part)}\n\n`;
11
+ }
12
+
13
+ const server = createTestServer({
14
+ '/api/completion': {},
15
+ });
16
+
17
+ describe('Completion', () => {
18
+ beforeAll(() => {
19
+ createTestServer({});
20
+ });
21
+
22
+ it('initialises', () => {
23
+ const completion = new Completion();
24
+ expect(completion.api).toBe('/api/completion');
25
+ expect(completion.completion).toBe('');
26
+ expect(completion.input).toBe('');
27
+ expect(completion.error).toBeUndefined();
28
+ expect(completion.loading).toBe(false);
29
+ expect(completion.id).toBeDefined();
30
+ });
31
+
32
+ it('should render a data stream', async () => {
33
+ server.urls['/api/completion'].response = {
34
+ type: 'stream-chunks',
35
+ chunks: [
36
+ formatStreamPart({ type: 'text-start', id: '0' }),
37
+ formatStreamPart({ type: 'text-delta', id: '0', delta: 'Hello' }),
38
+ formatStreamPart({ type: 'text-delta', id: '0', delta: ',' }),
39
+ formatStreamPart({ type: 'text-delta', id: '0', delta: ' world' }),
40
+ formatStreamPart({ type: 'text-delta', id: '0', delta: '.' }),
41
+ formatStreamPart({ type: 'text-end', id: '0' }),
42
+ ],
43
+ };
44
+ const completion = new Completion({
45
+ api: '/api/completion',
46
+ });
47
+ await completion.complete('hi');
48
+ expect(completion.completion).toBe('Hello, world.');
49
+ });
50
+
51
+ it('should render a text stream', async () => {
52
+ server.urls['/api/completion'].response = {
53
+ type: 'stream-chunks',
54
+ chunks: ['Hello', ',', ' world', '.'],
55
+ };
56
+
57
+ const completion = new Completion({ streamProtocol: 'text' });
58
+ await completion.complete('hi');
59
+ expect(completion.completion).toBe('Hello, world.');
60
+ });
61
+
62
+ it('should call `onFinish` callback', async () => {
63
+ server.urls['/api/completion'].response = {
64
+ type: 'stream-chunks',
65
+ chunks: [
66
+ formatStreamPart({ type: 'text-start', id: '0' }),
67
+ formatStreamPart({ type: 'text-delta', id: '0', delta: 'Hello' }),
68
+ formatStreamPart({ type: 'text-delta', id: '0', delta: ',' }),
69
+ formatStreamPart({ type: 'text-delta', id: '0', delta: ' world' }),
70
+ formatStreamPart({ type: 'text-delta', id: '0', delta: '.' }),
71
+ formatStreamPart({ type: 'text-end', id: '0' }),
72
+ ],
73
+ };
74
+
75
+ const onFinish = vi.fn();
76
+ const completion = new Completion({ onFinish });
77
+ await completion.complete('hi');
78
+ expect(onFinish).toHaveBeenCalledExactlyOnceWith('hi', 'Hello, world.');
79
+ });
80
+
81
+ it('should show loading state', async () => {
82
+ const controller = new TestResponseController();
83
+ server.urls['/api/completion'].response = {
84
+ type: 'controlled-stream',
85
+ controller,
86
+ };
87
+
88
+ const completion = new Completion();
89
+ const completionOperation = completion.complete('hi');
90
+ controller.write('0:"Hello"\n');
91
+ await vi.waitFor(() => expect(completion.loading).toBe(true));
92
+ controller.close();
93
+ await completionOperation;
94
+ expect(completion.loading).toBe(false);
95
+ });
96
+
97
+ describe('stop', () => {
98
+ it('should abort the stream and not consume any more data', async () => {
99
+ const controller = new TestResponseController();
100
+ server.urls['/api/completion'].response = {
101
+ type: 'controlled-stream',
102
+ controller,
103
+ };
104
+
105
+ const completion = new Completion();
106
+ const completionOperation = completion.complete('hi');
107
+ controller.write(
108
+ formatStreamPart({ type: 'text-delta', id: '0', delta: 'Hello' }),
109
+ );
110
+
111
+ await vi.waitFor(() => {
112
+ expect(completion.loading).toBe(true);
113
+ expect(completion.completion).toBe('Hello');
114
+ });
115
+
116
+ completion.stop();
117
+
118
+ await vi.waitFor(() => expect(completion.loading).toBe(false));
119
+
120
+ await expect(controller.write('0:", world"\n')).rejects.toThrow();
121
+ await expect(controller.close()).rejects.toThrow();
122
+ await completionOperation;
123
+
124
+ expect(completion.loading).toBe(false);
125
+ expect(completion.completion).toBe('Hello');
126
+ });
127
+ });
128
+
129
+ it('should reset loading state on error', async () => {
130
+ server.urls['/api/completion'].response = {
131
+ type: 'error',
132
+ status: 404,
133
+ body: 'Not found',
134
+ };
135
+
136
+ const completion = new Completion();
137
+ await completion.complete('hi');
138
+ expect(completion.error).toBeInstanceOf(Error);
139
+ expect(completion.loading).toBe(false);
140
+ });
141
+
142
+ it('should reset error state on subsequent completion', async () => {
143
+ server.urls['/api/completion'].response = [
144
+ {
145
+ type: 'error',
146
+ status: 404,
147
+ body: 'Not found',
148
+ },
149
+ {
150
+ type: 'stream-chunks',
151
+ chunks: [
152
+ formatStreamPart({ type: 'text-start', id: '0' }),
153
+ formatStreamPart({ type: 'text-delta', id: '0', delta: 'Hello' }),
154
+ formatStreamPart({ type: 'text-delta', id: '0', delta: ',' }),
155
+ formatStreamPart({ type: 'text-delta', id: '0', delta: ' world' }),
156
+ formatStreamPart({ type: 'text-delta', id: '0', delta: '.' }),
157
+ formatStreamPart({ type: 'text-end', id: '0' }),
158
+ ],
159
+ },
160
+ ];
161
+
162
+ const completion = new Completion();
163
+ await completion.complete('hi');
164
+ expect(completion.error).toBeInstanceOf(Error);
165
+ expect(completion.loading).toBe(false);
166
+ await completion.complete('hi');
167
+ expect(completion.error).toBe(undefined);
168
+ expect(completion.completion).toBe('Hello, world.');
169
+ });
170
+ });
@@ -0,0 +1,117 @@
1
+ import { signal } from '@angular/core';
2
+ import {
3
+ callCompletionApi,
4
+ generateId,
5
+ type CompletionRequestOptions,
6
+ type UseCompletionOptions,
7
+ } from 'ai';
8
+
9
+ export type CompletionOptions = Readonly<UseCompletionOptions>;
10
+
11
+ export class Completion {
12
+ readonly #options: CompletionOptions;
13
+
14
+ // Static config
15
+ readonly id: string;
16
+ readonly api: string;
17
+ readonly streamProtocol: 'data' | 'text';
18
+
19
+ // Reactive state
20
+ readonly #input = signal('');
21
+ readonly #completion = signal<string>('');
22
+ readonly #error = signal<Error | undefined>(undefined);
23
+ readonly #loading = signal<boolean>(false);
24
+
25
+ #abortController: AbortController | null = null;
26
+
27
+ constructor(options: CompletionOptions = {}) {
28
+ this.#options = options;
29
+ this.#completion.set(options.initialCompletion ?? '');
30
+ this.#input.set(options.initialInput ?? '');
31
+ this.api = options.api ?? '/api/completion';
32
+ this.id = options.id ?? generateId();
33
+ this.streamProtocol = options.streamProtocol ?? 'data';
34
+ }
35
+
36
+ /** Current value of the completion. Writable. */
37
+ get completion(): string {
38
+ return this.#completion();
39
+ }
40
+ set completion(value: string) {
41
+ this.#completion.set(value);
42
+ }
43
+
44
+ /** Current value of the input. Writable. */
45
+ get input(): string {
46
+ return this.#input();
47
+ }
48
+ set input(value: string) {
49
+ this.#input.set(value);
50
+ }
51
+
52
+ /** The error object of the API request */
53
+ get error(): Error | undefined {
54
+ return this.#error();
55
+ }
56
+
57
+ /** Flag that indicates whether an API request is in progress. */
58
+ get loading(): boolean {
59
+ return this.#loading();
60
+ }
61
+
62
+ /** Abort the current request immediately, keep the generated tokens if any. */
63
+ stop = () => {
64
+ try {
65
+ this.#abortController?.abort();
66
+ } catch {
67
+ // ignore
68
+ } finally {
69
+ this.#loading.set(false);
70
+ this.#abortController = null;
71
+ }
72
+ };
73
+
74
+ /** Send a new prompt to the API endpoint and update the completion state. */
75
+ complete = async (prompt: string, options?: CompletionRequestOptions) =>
76
+ this.#triggerRequest(prompt, options);
77
+
78
+ /** Form submission handler to automatically reset input and call the completion API */
79
+ handleSubmit = async (event?: { preventDefault?: () => void }) => {
80
+ event?.preventDefault?.();
81
+ if (this.#input()) {
82
+ await this.complete(this.#input());
83
+ }
84
+ };
85
+
86
+ #triggerRequest = async (
87
+ prompt: string,
88
+ options?: CompletionRequestOptions,
89
+ ) => {
90
+ return callCompletionApi({
91
+ api: this.api,
92
+ prompt,
93
+ credentials: this.#options.credentials,
94
+ headers: { ...this.#options.headers, ...options?.headers },
95
+ body: {
96
+ ...this.#options.body,
97
+ ...options?.body,
98
+ },
99
+ streamProtocol: this.streamProtocol,
100
+ fetch: this.#options.fetch,
101
+ setCompletion: (completion: string) => {
102
+ this.#completion.set(completion);
103
+ },
104
+ setLoading: (loading: boolean) => {
105
+ this.#loading.set(loading);
106
+ },
107
+ setError: (error: any) => {
108
+ this.#error.set(error);
109
+ },
110
+ setAbortController: abortController => {
111
+ this.#abortController = abortController ?? null;
112
+ },
113
+ onFinish: this.#options.onFinish,
114
+ onError: this.#options.onError,
115
+ });
116
+ };
117
+ }
@@ -0,0 +1,255 @@
1
+ import {
2
+ createTestServer,
3
+ TestResponseController,
4
+ } from '@ai-sdk/test-server/with-vitest';
5
+ import { z } from 'zod/v4';
6
+ import { StructuredObject } from './structured-object.ng';
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ const server = createTestServer({
10
+ '/api/object': {},
11
+ });
12
+
13
+ describe('text stream', () => {
14
+ const schema = z.object({ content: z.string() });
15
+ let structuredObject: StructuredObject<typeof schema>;
16
+
17
+ beforeEach(() => {
18
+ structuredObject = new StructuredObject({
19
+ api: '/api/object',
20
+ schema,
21
+ });
22
+ });
23
+
24
+ describe('when the API returns "Hello, world!"', () => {
25
+ beforeEach(async () => {
26
+ server.urls['/api/object'].response = {
27
+ type: 'stream-chunks',
28
+ chunks: ['{ ', '"content": "Hello, ', 'world', '!"', ' }'],
29
+ };
30
+ await structuredObject.submit('test-input');
31
+ });
32
+
33
+ it('should render the stream', () => {
34
+ expect(structuredObject.object).toEqual({ content: 'Hello, world!' });
35
+ });
36
+
37
+ it('should send the correct input to the API', async () => {
38
+ expect(await server.calls[0].requestBodyJson).toBe('test-input');
39
+ });
40
+
41
+ it('should not have an error', () => {
42
+ expect(structuredObject.error).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe('loading', () => {
47
+ it('should be true while loading', async () => {
48
+ const controller = new TestResponseController();
49
+ server.urls['/api/object'].response = {
50
+ type: 'controlled-stream',
51
+ controller,
52
+ };
53
+
54
+ controller.write('{"content": ');
55
+ const submitOperation = structuredObject.submit('test-input');
56
+
57
+ await vi.waitFor(() => {
58
+ expect(structuredObject.loading).toBe(true);
59
+ });
60
+
61
+ controller.write('"Hello, world!"}');
62
+ controller.close();
63
+ await submitOperation;
64
+
65
+ expect(structuredObject.loading).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe('stop', () => {
70
+ it('should abort the stream and not consume any more data', async () => {
71
+ const controller = new TestResponseController();
72
+ server.urls['/api/object'].response = {
73
+ type: 'controlled-stream',
74
+ controller,
75
+ };
76
+
77
+ controller.write('{"content": "h');
78
+ const submitOperation = structuredObject.submit('test-input');
79
+
80
+ await vi.waitFor(() => {
81
+ expect(structuredObject.loading).toBe(true);
82
+ expect(structuredObject.object).toStrictEqual({
83
+ content: 'h',
84
+ });
85
+ });
86
+
87
+ structuredObject.stop();
88
+
89
+ await vi.waitFor(() => {
90
+ expect(structuredObject.loading).toBe(false);
91
+ });
92
+
93
+ await expect(controller.write('ello, world!"}')).rejects.toThrow();
94
+ await expect(controller.close()).rejects.toThrow();
95
+ await submitOperation;
96
+
97
+ expect(structuredObject.loading).toBe(false);
98
+ expect(structuredObject.object).toStrictEqual({
99
+ content: 'h',
100
+ });
101
+ });
102
+
103
+ it('should stop and clear the object state after a call to submit then clear', async () => {
104
+ const controller = new TestResponseController();
105
+ server.urls['/api/object'].response = {
106
+ type: 'controlled-stream',
107
+ controller,
108
+ };
109
+
110
+ controller.write('{"content": "h');
111
+ const submitOperation = structuredObject.submit('test-input');
112
+
113
+ await vi.waitFor(() => {
114
+ expect(structuredObject.loading).toBe(true);
115
+ expect(structuredObject.object).toStrictEqual({
116
+ content: 'h',
117
+ });
118
+ });
119
+
120
+ structuredObject.clear();
121
+
122
+ await vi.waitFor(() => {
123
+ expect(structuredObject.loading).toBe(false);
124
+ });
125
+
126
+ await expect(controller.write('ello, world!"}')).rejects.toThrow();
127
+ await expect(controller.close()).rejects.toThrow();
128
+ await submitOperation;
129
+
130
+ expect(structuredObject.loading).toBe(false);
131
+ expect(structuredObject.error).toBeUndefined();
132
+ expect(structuredObject.object).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ describe('when the API returns a 404', () => {
137
+ it('should produce the correct error state', async () => {
138
+ server.urls['/api/object'].response = {
139
+ type: 'error',
140
+ status: 404,
141
+ body: 'Not found',
142
+ };
143
+
144
+ await structuredObject.submit('test-input');
145
+ expect(structuredObject.error).toBeInstanceOf(Error);
146
+ expect(structuredObject.error?.message).toBe('Not found');
147
+ expect(structuredObject.loading).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe('onFinish', () => {
152
+ it('should be called with an object when the stream finishes and the object matches the schema', async () => {
153
+ server.urls['/api/object'].response = {
154
+ type: 'stream-chunks',
155
+ chunks: ['{ ', '"content": "Hello, ', 'world', '!"', '}'],
156
+ };
157
+
158
+ const onFinish = vi.fn();
159
+ const structuredObjectWithOnFinish = new StructuredObject({
160
+ api: '/api/object',
161
+ schema: z.object({ content: z.string() }),
162
+ onFinish,
163
+ });
164
+ await structuredObjectWithOnFinish.submit('test-input');
165
+
166
+ expect(onFinish).toHaveBeenCalledExactlyOnceWith({
167
+ object: { content: 'Hello, world!' },
168
+ error: undefined,
169
+ });
170
+ });
171
+
172
+ it('should be called with an error when the stream finishes and the object does not match the schema', async () => {
173
+ server.urls['/api/object'].response = {
174
+ type: 'stream-chunks',
175
+ chunks: ['{ ', '"content-wrong": "Hello, ', 'world', '!"', '}'],
176
+ };
177
+
178
+ const onFinish = vi.fn();
179
+ const structuredObjectWithOnFinish = new StructuredObject({
180
+ api: '/api/object',
181
+ schema: z.object({ content: z.string() }),
182
+ onFinish,
183
+ });
184
+ await structuredObjectWithOnFinish.submit('test-input');
185
+
186
+ expect(onFinish).toHaveBeenCalledExactlyOnceWith({
187
+ object: undefined,
188
+ error: expect.any(Error),
189
+ });
190
+ });
191
+ });
192
+
193
+ it('should send custom headers', async () => {
194
+ server.urls['/api/object'].response = {
195
+ type: 'stream-chunks',
196
+ chunks: ['{ ', '"content": "Hello, ', 'world', '!"', '}'],
197
+ };
198
+
199
+ const structuredObjectWithCustomHeaders = new StructuredObject({
200
+ api: '/api/object',
201
+ schema: z.object({ content: z.string() }),
202
+ headers: {
203
+ Authorization: 'Bearer TEST_TOKEN',
204
+ 'X-Custom-Header': 'CustomValue',
205
+ },
206
+ });
207
+
208
+ await structuredObjectWithCustomHeaders.submit('test-input');
209
+
210
+ expect(server.calls[0].requestHeaders).toStrictEqual({
211
+ 'content-type': 'application/json',
212
+ authorization: 'Bearer TEST_TOKEN',
213
+ 'x-custom-header': 'CustomValue',
214
+ });
215
+ });
216
+
217
+ it('should send custom credentials', async () => {
218
+ server.urls['/api/object'].response = {
219
+ type: 'stream-chunks',
220
+ chunks: ['{ ', '"content": "Hello, ', 'world', '!"', '}'],
221
+ };
222
+
223
+ const structuredObjectWithCustomCredentials = new StructuredObject({
224
+ api: '/api/object',
225
+ schema: z.object({ content: z.string() }),
226
+ credentials: 'include',
227
+ });
228
+
229
+ await structuredObjectWithCustomCredentials.submit('test-input');
230
+
231
+ expect(server.calls[0].requestCredentials).toBe('include');
232
+ });
233
+
234
+ it('should clear the object state after a call to clear', async () => {
235
+ server.urls['/api/object'].response = {
236
+ type: 'stream-chunks',
237
+ chunks: ['{ ', '"content": "Hello, ', 'world', '!"', '}'],
238
+ };
239
+
240
+ const structuredObjectWithOnFinish = new StructuredObject({
241
+ api: '/api/object',
242
+ schema: z.object({ content: z.string() }),
243
+ });
244
+
245
+ await structuredObjectWithOnFinish.submit('test-input');
246
+
247
+ expect(structuredObjectWithOnFinish.object).toBeDefined();
248
+
249
+ structuredObjectWithOnFinish.clear();
250
+
251
+ expect(structuredObjectWithOnFinish.object).toBeUndefined();
252
+ expect(structuredObjectWithOnFinish.error).toBeUndefined();
253
+ expect(structuredObjectWithOnFinish.loading).toBe(false);
254
+ });
255
+ });