Skip to content

Commit 51054a9

Browse files
authoredFeb 29, 2024··
feat: RSC (#1008)
1 parent cc28a6f commit 51054a9

18 files changed

+1261
-17
lines changed
 

‎.changeset/nine-trainers-return.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': major
3+
---
4+
5+
add ai/rsc

‎packages/core/package.json

+17-4
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
"svelte/dist/**/*",
1313
"vue/dist/**/*",
1414
"solid/dist/**/*",
15-
"prompts/dist/**/*"
15+
"prompts/dist/**/*",
16+
"rsc/dist/**/*"
1617
],
1718
"scripts": {
1819
"build": "tsup && cat react/dist/index.server.d.ts >> react/dist/index.d.ts",
19-
"clean": "rm -rf dist && rm -rf react/dist && rm -rf svelte/dist && rm -rf vue/dist && rm -rf solid/dist",
20+
"clean": "rm -rf dist && rm -rf react/dist && rm -rf svelte/dist && rm -rf vue/dist && rm -rf solid/dist && rm -rf rsc/dist",
2021
"dev": "tsup --watch",
2122
"lint": "eslint \"./**/*.ts*\"",
2223
"type-check": "tsc --noEmit",
@@ -42,6 +43,12 @@
4243
"module": "./dist/index.mjs",
4344
"require": "./dist/index.js"
4445
},
46+
"./rsc": {
47+
"types": "./rsc/dist/rsc-types.d.ts",
48+
"react-server": "./rsc/dist/rsc-server.mjs",
49+
"import": "./rsc/dist/rsc-client.mjs",
50+
"module": "./rsc/dist/rsc-client.mjs"
51+
},
4552
"./prompts": {
4653
"types": "./prompts/dist/index.d.ts",
4754
"import": "./prompts/dist/index.mjs",
@@ -76,12 +83,14 @@
7683
},
7784
"dependencies": {
7885
"eventsource-parser": "1.0.0",
86+
"jsondiffpatch": "^0.6.0",
7987
"nanoid": "3.3.6",
8088
"solid-swr-store": "0.10.7",
8189
"sswr": "2.0.0",
8290
"swr": "2.2.0",
8391
"swr-store": "0.10.6",
84-
"swrv": "1.0.4"
92+
"swrv": "1.0.4",
93+
"zod-to-json-schema": "^3.22.4"
8594
},
8695
"devDependencies": {
8796
"@anthropic-ai/sdk": "0.12.0",
@@ -118,7 +127,8 @@
118127
"react": "^18.2.0",
119128
"solid-js": "^1.7.7",
120129
"svelte": "^3.0.0 || ^4.0.0",
121-
"vue": "^3.3.4"
130+
"vue": "^3.3.4",
131+
"zod": "^3.0.0"
122132
},
123133
"peerDependenciesMeta": {
124134
"react": {
@@ -132,6 +142,9 @@
132142
},
133143
"solid-js": {
134144
"optional": true
145+
},
146+
"zod": {
147+
"optional": true
135148
}
136149
},
137150
"engines": {

‎packages/core/rsc/ai-state.tsx

+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { AsyncLocalStorage } from 'async_hooks';
2+
import * as jsondiffpatch from 'jsondiffpatch';
3+
import { createResolvablePromise, isFunction } from './utils';
4+
import type {
5+
AIProvider,
6+
InternalAIStateStorageOptions,
7+
InferAIState,
8+
MutableAIState,
9+
ValueOrUpdater,
10+
} from './types';
11+
12+
// It is possible that multiple AI requests get in concurrently, for different
13+
// AI instances. So ALS is necessary here for a simpler API.
14+
const asyncAIStateStorage = new AsyncLocalStorage<{
15+
currentState: any;
16+
originalState: any;
17+
sealed: boolean;
18+
options: InternalAIStateStorageOptions;
19+
mutationDeltaPromise?: Promise<any>;
20+
mutationDeltaResolve?: (v: any) => void;
21+
}>();
22+
23+
function getAIStateStoreOrThrow(message: string) {
24+
const store = asyncAIStateStorage.getStore();
25+
if (!store) {
26+
throw new Error(message);
27+
}
28+
return store;
29+
}
30+
31+
export function withAIState<S, T>(
32+
{ state, options }: { state: S; options: InternalAIStateStorageOptions },
33+
fn: () => T,
34+
): T {
35+
return asyncAIStateStorage.run(
36+
{
37+
currentState: state,
38+
originalState: state,
39+
sealed: false,
40+
options,
41+
},
42+
fn,
43+
);
44+
}
45+
46+
export function getAIStateDeltaPromise() {
47+
const store = getAIStateStoreOrThrow('Internal error occurred.');
48+
return store.mutationDeltaPromise;
49+
}
50+
51+
// Internal method. This will be called after the AI Action has been returned
52+
// and you can no longer call `getMutableAIState()` inside any async callbacks
53+
// created by that Action.
54+
export function sealMutableAIState() {
55+
const store = getAIStateStoreOrThrow('Internal error occurred.');
56+
store.sealed = true;
57+
}
58+
59+
/**
60+
* Get the current AI state.
61+
* If `key` is provided, it will return the value of the specified key in the
62+
* AI state, if it's an object. If it's not an object, it will throw an error.
63+
*
64+
* @example const state = getAIState() // Get the entire AI state
65+
* @example const field = getAIState('key') // Get the value of the key
66+
*/
67+
function getAIState<AI extends AIProvider = any>(): InferAIState<AI, any>;
68+
function getAIState<AI extends AIProvider = any>(
69+
key: keyof InferAIState<AI, any>,
70+
): InferAIState<AI, any>[typeof key];
71+
function getAIState<AI extends AIProvider = any>(
72+
...args: [] | [key: keyof InferAIState<AI, any>]
73+
) {
74+
const store = getAIStateStoreOrThrow(
75+
'`getAIState` must be called within an AI Action.',
76+
);
77+
78+
if (args.length > 0) {
79+
const key = args[0];
80+
if (typeof store.currentState !== 'object') {
81+
throw new Error(
82+
`You can't get the "${String(
83+
key,
84+
)}" field from the AI state because it's not an object.`,
85+
);
86+
}
87+
return store.currentState[key as keyof typeof store.currentState];
88+
}
89+
90+
return store.currentState;
91+
}
92+
93+
/**
94+
* Get the mutable AI state. Note that you must call `.close()` when finishing
95+
* updating the AI state.
96+
*
97+
* @example
98+
* ```tsx
99+
* const state = getMutableAIState()
100+
* state.update({ ...state.get(), key: 'value' })
101+
* state.update((currentState) => ({ ...currentState, key: 'value' }))
102+
* state.done()
103+
* ```
104+
*
105+
* @example
106+
* ```tsx
107+
* const state = getMutableAIState()
108+
* state.done({ ...state.get(), key: 'value' }) // Done with a new state
109+
* ```
110+
*/
111+
function getMutableAIState<AI extends AIProvider = any>(): MutableAIState<
112+
InferAIState<AI, any>
113+
>;
114+
function getMutableAIState<AI extends AIProvider = any>(
115+
key: keyof InferAIState<AI, any>,
116+
): MutableAIState<InferAIState<AI, any>[typeof key]>;
117+
function getMutableAIState<AI extends AIProvider = any>(
118+
...args: [] | [key: keyof InferAIState<AI, any>]
119+
) {
120+
type AIState = InferAIState<AI, any>;
121+
type AIStateWithKey = typeof args extends [key: keyof AIState]
122+
? AIState[(typeof args)[0]]
123+
: AIState;
124+
type NewStateOrUpdater = ValueOrUpdater<AIStateWithKey>;
125+
126+
const store = getAIStateStoreOrThrow(
127+
'`getMutableAIState` must be called within an AI Action.',
128+
);
129+
130+
if (store.sealed) {
131+
throw new Error(
132+
"`getMutableAIState` must be called before returning from an AI Action. Please move it to the top level of the Action's function body.",
133+
);
134+
}
135+
136+
if (!store.mutationDeltaPromise) {
137+
const { promise, resolve } = createResolvablePromise();
138+
store.mutationDeltaPromise = promise;
139+
store.mutationDeltaResolve = resolve;
140+
}
141+
142+
function doUpdate(newState: NewStateOrUpdater, done: boolean) {
143+
if (args.length > 0) {
144+
if (typeof store.currentState !== 'object') {
145+
const key = args[0];
146+
throw new Error(
147+
`You can't modify the "${String(
148+
key,
149+
)}" field of the AI state because it's not an object.`,
150+
);
151+
}
152+
}
153+
154+
if (isFunction(newState)) {
155+
if (args.length > 0) {
156+
store.currentState[args[0]] = newState(store.currentState[args[0]]);
157+
} else {
158+
store.currentState = newState(store.currentState);
159+
}
160+
} else {
161+
if (args.length > 0) {
162+
store.currentState[args[0]] = newState;
163+
} else {
164+
store.currentState = newState;
165+
}
166+
}
167+
168+
store.options.onSetAIState?.({
169+
key: args.length > 0 ? args[0] : undefined,
170+
state: store.currentState,
171+
done,
172+
});
173+
}
174+
175+
const mutableState = {
176+
get: () => {
177+
if (args.length > 0) {
178+
const key = args[0];
179+
if (typeof store.currentState !== 'object') {
180+
throw new Error(
181+
`You can't get the "${String(
182+
key,
183+
)}" field from the AI state because it's not an object.`,
184+
);
185+
}
186+
return store.currentState[key];
187+
}
188+
189+
return store.currentState as AIState;
190+
},
191+
update: function update(newAIState: NewStateOrUpdater) {
192+
doUpdate(newAIState, false);
193+
},
194+
done: function done(...doneArgs: [] | [NewStateOrUpdater]) {
195+
if (doneArgs.length > 0) {
196+
doUpdate(doneArgs[0] as NewStateOrUpdater, true);
197+
}
198+
199+
const delta = jsondiffpatch.diff(store.originalState, store.currentState);
200+
store.mutationDeltaResolve!(delta);
201+
},
202+
};
203+
204+
return mutableState;
205+
}
206+
207+
export { getAIState, getMutableAIState };

‎packages/core/rsc/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const STREAMABLE_VALUE_TYPE = Symbol.for('ui.streamable.value');

‎packages/core/rsc/package.json

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"types": "./dist/rsc-types.d.ts",
3+
"exports": {
4+
"types": "./dist/rsc-types.d.ts",
5+
"react-server": "./dist/rsc-server.mjs",
6+
"import": "./dist/rsc-client.mjs",
7+
"module": "./dist/rsc-client.mjs"
8+
},
9+
"private": true,
10+
"peerDependencies": {
11+
"react": ">=18",
12+
"zod": ">=3"
13+
}
14+
}

‎packages/core/rsc/provider.tsx

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// This file provides the AI context to all AI Actions via AsyncLocalStorage.
2+
3+
import * as React from 'react';
4+
import { InternalAIProvider } from './rsc-shared';
5+
import {
6+
withAIState,
7+
getAIStateDeltaPromise,
8+
sealMutableAIState,
9+
} from './ai-state';
10+
import type {
11+
ServerWrappedActions,
12+
AIAction,
13+
AIActions,
14+
AIProvider,
15+
InternalAIStateStorageOptions,
16+
OnSetAIState,
17+
OnGetUIState,
18+
} from './types';
19+
20+
async function innerAction<T>(
21+
{
22+
action,
23+
options,
24+
}: { action: AIAction; options: InternalAIStateStorageOptions },
25+
state: T,
26+
...args: unknown[]
27+
) {
28+
'use server';
29+
return await withAIState(
30+
{
31+
state,
32+
options,
33+
},
34+
async () => {
35+
const result = await action(...args);
36+
sealMutableAIState();
37+
return [getAIStateDeltaPromise() as Promise<T>, result];
38+
},
39+
);
40+
}
41+
42+
function wrapAction<T = unknown>(
43+
action: AIAction,
44+
options: InternalAIStateStorageOptions,
45+
) {
46+
return innerAction.bind(null, { action, options }) as AIAction<T>;
47+
}
48+
49+
export function createAI<
50+
AIState = any,
51+
UIState = any,
52+
Actions extends AIActions = {},
53+
>({
54+
actions,
55+
initialAIState,
56+
initialUIState,
57+
58+
unstable_onSetAIState: onSetAIState,
59+
unstable_onGetUIState: onGetUIState,
60+
}: {
61+
actions: Actions;
62+
initialAIState?: AIState;
63+
initialUIState?: UIState;
64+
65+
unstable_onSetAIState?: OnSetAIState<AIState>;
66+
unstable_onGetUIState?: OnGetUIState<UIState>;
67+
}) {
68+
// Wrap all actions with our HoC.
69+
const wrappedActions: ServerWrappedActions = {};
70+
for (const name in actions) {
71+
wrappedActions[name] = wrapAction(actions[name], {
72+
onSetAIState,
73+
});
74+
}
75+
76+
const wrappedSyncUIState = onGetUIState
77+
? wrapAction(onGetUIState, {})
78+
: undefined;
79+
80+
async function AI(props: {
81+
children: React.ReactNode;
82+
initialAIState?: AIState;
83+
initialUIState?: UIState;
84+
}) {
85+
if ('useState' in React) {
86+
// This file must be running on the React Server layer.
87+
// Ideally we should be using `import "server-only"` here but we can have a
88+
// more customized error message with this implementation.
89+
throw new Error(
90+
'This component can only be used inside Server Components.',
91+
);
92+
}
93+
94+
let uiState = props.initialUIState ?? initialUIState;
95+
let aiState = props.initialAIState ?? initialAIState;
96+
let aiStateDelta = undefined;
97+
98+
if (wrappedSyncUIState) {
99+
const [newAIStateDelta, newUIState] = await wrappedSyncUIState(aiState);
100+
if (newUIState !== undefined) {
101+
aiStateDelta = newAIStateDelta;
102+
uiState = newUIState;
103+
}
104+
}
105+
106+
return (
107+
<InternalAIProvider
108+
wrappedActions={wrappedActions}
109+
wrappedSyncUIState={wrappedSyncUIState}
110+
initialUIState={uiState}
111+
initialAIState={aiState}
112+
initialAIStatePatch={aiStateDelta}
113+
>
114+
{props.children}
115+
</InternalAIProvider>
116+
);
117+
}
118+
119+
return AI as AIProvider<AIState, UIState, Actions>;
120+
}

‎packages/core/rsc/rsc-client.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export {
2+
useStreamableValue,
3+
useUIState,
4+
useAIState,
5+
useActions,
6+
useSyncUIState,
7+
} from './rsc-shared';

‎packages/core/rsc/rsc-server.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { getAIState, getMutableAIState } from './ai-state';
2+
export {
3+
createStreamableUI,
4+
createStreamableValue,
5+
render,
6+
} from './streamable';
7+
export { createAI } from './provider';

‎packages/core/rsc/rsc-shared.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
'use client';
2+
3+
export {
4+
useStreamableValue,
5+
useUIState,
6+
useAIState,
7+
useActions,
8+
useSyncUIState,
9+
InternalAIProvider,
10+
} from './shared-client';

‎packages/core/rsc/rsc-types.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type {
2+
getAIState,
3+
getMutableAIState,
4+
createStreamableUI,
5+
createStreamableValue,
6+
render,
7+
createAI,
8+
} from './rsc-server';
9+
export type {
10+
useStreamableValue,
11+
useUIState,
12+
useAIState,
13+
useActions,
14+
useSyncUIState,
15+
} from './rsc-client';
+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
5+
import * as jsondiffpatch from 'jsondiffpatch';
6+
import type {
7+
InternalAIProviderProps,
8+
AIProvider,
9+
InferAIState,
10+
ValueOrUpdater,
11+
InferActions,
12+
InferUIState,
13+
} from '../types';
14+
import { isFunction } from '../utils';
15+
16+
const InternalUIStateProvider = React.createContext<null | any>(null);
17+
const InternalAIStateProvider = React.createContext<undefined | any>(undefined);
18+
const InternalActionProvider = React.createContext<null | any>(null);
19+
const InternalSyncUIStateProvider = React.createContext<null | any>(null);
20+
21+
export function InternalAIProvider({
22+
children,
23+
initialUIState,
24+
initialAIState,
25+
initialAIStatePatch,
26+
wrappedActions,
27+
wrappedSyncUIState,
28+
}: InternalAIProviderProps) {
29+
if (!('use' in React)) {
30+
throw new Error('Unsupported React version.');
31+
}
32+
33+
const uiState = React.useState(initialUIState);
34+
const setUIState = uiState[1];
35+
36+
const resolvedInitialAIStatePatch = initialAIStatePatch
37+
? (React as any).use(initialAIStatePatch)
38+
: undefined;
39+
initialAIState = React.useMemo(() => {
40+
if (resolvedInitialAIStatePatch) {
41+
return jsondiffpatch.patch(
42+
jsondiffpatch.clone(initialAIState),
43+
resolvedInitialAIStatePatch,
44+
);
45+
}
46+
return initialAIState;
47+
}, [initialAIState, resolvedInitialAIStatePatch]);
48+
49+
const aiState = React.useState(initialAIState);
50+
const setAIState = aiState[1];
51+
const aiStateRef = React.useRef(aiState[0]);
52+
53+
React.useEffect(() => {
54+
aiStateRef.current = aiState[0];
55+
}, [aiState[0]]);
56+
57+
const clientWrappedActions = React.useMemo(
58+
() =>
59+
Object.fromEntries(
60+
Object.entries(wrappedActions).map(([key, action]) => [
61+
key,
62+
async (...args: any) => {
63+
const aiStateSnapshot = aiStateRef.current;
64+
const [aiStateDelta, result] = await action(
65+
aiStateSnapshot,
66+
...args,
67+
);
68+
(async () => {
69+
const delta = await aiStateDelta;
70+
if (delta !== undefined) {
71+
aiState[1](
72+
jsondiffpatch.patch(
73+
jsondiffpatch.clone(aiStateSnapshot),
74+
delta,
75+
),
76+
);
77+
}
78+
})();
79+
return result;
80+
},
81+
]),
82+
),
83+
[wrappedActions],
84+
);
85+
86+
const clientWrappedSyncUIStateAction = React.useMemo(() => {
87+
if (!wrappedSyncUIState) {
88+
return () => {};
89+
}
90+
91+
return async () => {
92+
const aiStateSnapshot = aiStateRef.current;
93+
const [aiStateDelta, uiState] = await wrappedSyncUIState!(
94+
aiStateSnapshot,
95+
);
96+
97+
if (uiState !== undefined) {
98+
setUIState(uiState);
99+
}
100+
101+
const delta = await aiStateDelta;
102+
if (delta !== undefined) {
103+
const patchedAiState = jsondiffpatch.patch(
104+
jsondiffpatch.clone(aiStateSnapshot),
105+
delta,
106+
);
107+
setAIState(patchedAiState);
108+
}
109+
};
110+
}, [wrappedSyncUIState]);
111+
112+
return (
113+
<InternalAIStateProvider.Provider value={aiState}>
114+
<InternalUIStateProvider.Provider value={uiState}>
115+
<InternalActionProvider.Provider value={clientWrappedActions}>
116+
<InternalSyncUIStateProvider.Provider
117+
value={clientWrappedSyncUIStateAction}
118+
>
119+
{children}
120+
</InternalSyncUIStateProvider.Provider>
121+
</InternalActionProvider.Provider>
122+
</InternalUIStateProvider.Provider>
123+
</InternalAIStateProvider.Provider>
124+
);
125+
}
126+
127+
export function useUIState<AI extends AIProvider = any>() {
128+
type T = InferUIState<AI, any>;
129+
130+
const state = React.useContext<
131+
[T, (v: T | ((v_: T) => T)) => void] | null | undefined
132+
>(InternalUIStateProvider);
133+
if (state === null) {
134+
throw new Error('`useUIState` must be used inside an <AI> provider.');
135+
}
136+
if (!Array.isArray(state)) {
137+
throw new Error('Invalid state');
138+
}
139+
if (state[0] === undefined) {
140+
throw new Error(
141+
'`initialUIState` must be provided to `createAI` or `<AI>`',
142+
);
143+
}
144+
return state;
145+
}
146+
147+
// TODO: How do we avoid causing a re-render when the AI state changes but you
148+
// are only listening to a specific key? We need useSES perhaps?
149+
function useAIState<AI extends AIProvider = any>(): [
150+
InferAIState<AI, any>,
151+
(newState: ValueOrUpdater<InferAIState<AI, any>>) => void,
152+
];
153+
function useAIState<AI extends AIProvider = any>(
154+
key: keyof InferAIState<AI, any>,
155+
): [
156+
InferAIState<AI, any>[typeof key],
157+
(newState: ValueOrUpdater<InferAIState<AI, any>[typeof key]>) => void,
158+
];
159+
function useAIState<AI extends AIProvider = any>(
160+
...args: [] | [keyof InferAIState<AI, any>]
161+
) {
162+
type T = InferAIState<AI, any>;
163+
164+
const state = React.useContext<
165+
[T, (newState: ValueOrUpdater<T>) => void] | null | undefined
166+
>(InternalAIStateProvider);
167+
if (state === null) {
168+
throw new Error('`useAIState` must be used inside an <AI> provider.');
169+
}
170+
if (!Array.isArray(state)) {
171+
throw new Error('Invalid state');
172+
}
173+
if (state[0] === undefined) {
174+
throw new Error(
175+
'`initialAIState` must be provided to `createAI` or `<AI>`',
176+
);
177+
}
178+
if (args.length >= 1 && typeof state[0] !== 'object') {
179+
throw new Error(
180+
'When using `useAIState` with a key, the AI state must be an object.',
181+
);
182+
}
183+
184+
const key = args[0];
185+
const setter = React.useCallback(
186+
typeof key === 'undefined'
187+
? state[1]
188+
: (newState: ValueOrUpdater<T>) => {
189+
if (isFunction(newState)) {
190+
return state[1](s => {
191+
return { ...s, [key]: newState(s[key]) };
192+
});
193+
} else {
194+
return state[1]({ ...state[0], [key]: newState });
195+
}
196+
},
197+
[key],
198+
);
199+
200+
if (args.length === 0) {
201+
return state;
202+
} else {
203+
return [state[0][args[0]], setter];
204+
}
205+
}
206+
207+
export function useActions<AI extends AIProvider = any>() {
208+
type T = InferActions<AI, any>;
209+
210+
const actions = React.useContext<T>(InternalActionProvider);
211+
return actions;
212+
}
213+
214+
export function useSyncUIState() {
215+
const syncUIState = React.useContext<() => Promise<void>>(
216+
InternalSyncUIStateProvider,
217+
);
218+
219+
if (syncUIState === null) {
220+
throw new Error('`useSyncUIState` must be used inside an <AI> provider.');
221+
}
222+
223+
return syncUIState;
224+
}
225+
226+
export { useAIState };
+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { STREAMABLE_VALUE_TYPE } from '../constants';
5+
6+
type StreamableValue = {
7+
curr?: any;
8+
next?: Promise<StreamableValue>;
9+
type: typeof STREAMABLE_VALUE_TYPE;
10+
};
11+
12+
export function useStreamableValue(streamableValue: StreamableValue) {
13+
if (
14+
typeof streamableValue !== 'object' ||
15+
!streamableValue ||
16+
streamableValue.type !== STREAMABLE_VALUE_TYPE
17+
) {
18+
throw new Error(
19+
'Invalid value: this hook only accepts values created via `createValueStream` from the server.',
20+
);
21+
}
22+
23+
const [currentValue, setCurrentValue] = useState(streamableValue.curr);
24+
const [currentError, setCurrentError] = useState<null | Error>(null);
25+
26+
useEffect(() => {
27+
// Just in case the passed-in streamableValue has changed.
28+
setCurrentValue(streamableValue.curr);
29+
setCurrentError(null);
30+
31+
let canceled = false;
32+
33+
async function readNext(streamable: StreamableValue) {
34+
if (!streamable.next) return;
35+
if (canceled) return;
36+
37+
try {
38+
const next = await streamable.next;
39+
if (canceled) return;
40+
41+
if ('curr' in next) {
42+
setCurrentValue(next.curr);
43+
}
44+
setCurrentError(null);
45+
readNext(next);
46+
} catch (e) {
47+
setCurrentError(e as Error);
48+
}
49+
}
50+
51+
readNext(streamableValue);
52+
53+
return () => {
54+
canceled = true;
55+
};
56+
}, [streamableValue]);
57+
58+
return [currentValue, currentError];
59+
}
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
'use client';
2+
3+
export { useStreamableValue } from './hooks';
4+
export {
5+
useUIState,
6+
useAIState,
7+
useActions,
8+
useSyncUIState,
9+
InternalAIProvider,
10+
} from './context';

‎packages/core/rsc/streamable.tsx

+344
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import type { ReactNode } from 'react';
2+
import type OpenAI from 'openai';
3+
import { z } from 'zod';
4+
import zodToJsonSchema from 'zod-to-json-schema';
5+
6+
// TODO: This needs to be externalized.
7+
import { OpenAIStream } from '../streams';
8+
9+
import { STREAMABLE_VALUE_TYPE } from './constants';
10+
import {
11+
createResolvablePromise,
12+
createSuspensedChunk,
13+
consumeStream,
14+
} from './utils';
15+
16+
/**
17+
* Create a piece of changable UI that can be streamed to the client.
18+
* On the client side, it can be rendered as a normal React node.
19+
*/
20+
export function createStreamableUI(initialValue?: React.ReactNode) {
21+
let currentValue = initialValue;
22+
let closed = false;
23+
let { row, resolve, reject } = createSuspensedChunk(initialValue);
24+
25+
function assertStream() {
26+
if (closed) {
27+
throw new Error('UI stream is already closed.');
28+
}
29+
}
30+
31+
return {
32+
value: row,
33+
update(value: React.ReactNode) {
34+
assertStream();
35+
36+
const resolvable = createResolvablePromise();
37+
resolve({ value, done: false, next: resolvable.promise });
38+
resolve = resolvable.resolve;
39+
reject = resolvable.reject;
40+
currentValue = value;
41+
},
42+
append(value: React.ReactNode) {
43+
assertStream();
44+
45+
const resolvable = createResolvablePromise();
46+
resolve({ value, done: false, next: resolvable.promise });
47+
resolve = resolvable.resolve;
48+
reject = resolvable.reject;
49+
if (typeof currentValue === 'string' && typeof value === 'string') {
50+
currentValue += value;
51+
} else {
52+
currentValue = (
53+
<>
54+
{currentValue}
55+
{value}
56+
</>
57+
);
58+
}
59+
},
60+
error(error: any) {
61+
assertStream();
62+
63+
closed = true;
64+
reject(error);
65+
},
66+
done(...args: any) {
67+
assertStream();
68+
69+
closed = true;
70+
if (args.length) {
71+
resolve({ value: args[0], done: true });
72+
return;
73+
}
74+
resolve({ value: currentValue, done: true });
75+
},
76+
};
77+
}
78+
79+
/**
80+
* Create a wrapped, changable value that can be streamed to the client.
81+
* On the client side, the value can be accessed via the useStreamableValue() hook.
82+
*/
83+
export function createStreamableValue<T = any>(initialValue?: T) {
84+
// let currentValue = initialValue
85+
let closed = false;
86+
let { promise, resolve, reject } = createResolvablePromise();
87+
88+
function assertStream() {
89+
if (closed) {
90+
throw new Error('Value stream is already closed.');
91+
}
92+
}
93+
94+
function createWrapped(val: T | undefined, initial?: boolean) {
95+
if (initial) {
96+
return {
97+
type: STREAMABLE_VALUE_TYPE,
98+
curr: val,
99+
next: promise,
100+
};
101+
}
102+
103+
return {
104+
curr: val,
105+
next: promise,
106+
};
107+
}
108+
109+
return {
110+
value: createWrapped(initialValue, true),
111+
update(value: T) {
112+
assertStream();
113+
114+
const resolvePrevious = resolve;
115+
const resolvable = createResolvablePromise();
116+
promise = resolvable.promise;
117+
resolve = resolvable.resolve;
118+
reject = resolvable.reject;
119+
120+
resolvePrevious(createWrapped(value));
121+
122+
// currentValue = value
123+
},
124+
error(error: any) {
125+
assertStream();
126+
127+
closed = true;
128+
reject(error);
129+
},
130+
done(...args: any) {
131+
assertStream();
132+
133+
closed = true;
134+
135+
if (args.length) {
136+
resolve({ curr: args[0] });
137+
return;
138+
}
139+
140+
resolve({});
141+
},
142+
};
143+
}
144+
145+
type Streamable = ReactNode | Promise<ReactNode>;
146+
type Renderer<T> = (
147+
props: T,
148+
) =>
149+
| Streamable
150+
| Generator<Streamable, Streamable, void>
151+
| AsyncGenerator<Streamable, Streamable, void>;
152+
153+
/**
154+
* `render` is a helper function to create a streamable UI from some LLMs.
155+
* Currently, it only supports OpenAI's GPT models with Function Calling and Assistants Tools.
156+
*/
157+
export function render<
158+
TS extends {
159+
[name: string]: z.Schema;
160+
} = {},
161+
FS extends {
162+
[name: string]: z.Schema;
163+
} = {},
164+
>(options: {
165+
/**
166+
* The model name to use. Currently the only models available are OpenAI's
167+
* GPT models (3.5/4) with Function Calling and Assistants Tools.
168+
*
169+
* @example "gpt-3.5-turbo"
170+
*/
171+
model: `gpt-${string}`;
172+
/**
173+
* The provider instance to use. Currently the only provider available is OpenAI.
174+
* This needs to match the model name.
175+
*/
176+
provider: OpenAI;
177+
messages: Parameters<
178+
typeof OpenAI.prototype.chat.completions.create
179+
>[0]['messages'];
180+
text?: Renderer<{ content: string; done: boolean }>;
181+
tools?: {
182+
[name in keyof TS]: {
183+
description?: string;
184+
parameters: TS[name];
185+
render: Renderer<z.infer<TS[name]>>;
186+
};
187+
};
188+
functions?: {
189+
[name in keyof FS]: {
190+
description?: string;
191+
parameters: FS[name];
192+
render: Renderer<z.infer<FS[name]>>;
193+
};
194+
};
195+
initial?: ReactNode;
196+
temperature?: number;
197+
}): ReactNode {
198+
const ui = createStreamableUI(options.initial);
199+
200+
const functions = options.functions
201+
? Object.entries(options.functions).map(
202+
([name, { description, parameters }]) => {
203+
return {
204+
name,
205+
description,
206+
parameters: zodToJsonSchema(parameters) as Record<string, unknown>,
207+
};
208+
},
209+
)
210+
: undefined;
211+
212+
const tools = options.tools
213+
? Object.entries(options.tools).map(
214+
([name, { description, parameters }]) => {
215+
return {
216+
type: 'function' as const,
217+
function: {
218+
name,
219+
description,
220+
parameters: zodToJsonSchema(parameters) as Record<
221+
string,
222+
unknown
223+
>,
224+
},
225+
};
226+
},
227+
)
228+
: undefined;
229+
230+
let finished: ReturnType<typeof createResolvablePromise> | undefined;
231+
232+
async function handleRender(
233+
args: any,
234+
renderer: undefined | Renderer<any>,
235+
res: ReturnType<typeof createStreamableUI>,
236+
) {
237+
if (!renderer) return;
238+
239+
if (finished) await finished.promise;
240+
finished = createResolvablePromise();
241+
const value = renderer(args);
242+
if (
243+
value instanceof Promise ||
244+
(value &&
245+
typeof value === 'object' &&
246+
'then' in value &&
247+
typeof value.then === 'function')
248+
) {
249+
const node = await (value as Promise<React.ReactNode>);
250+
res.update(node);
251+
finished?.resolve(void 0);
252+
} else if (
253+
value &&
254+
typeof value === 'object' &&
255+
Symbol.asyncIterator in value
256+
) {
257+
for await (const node of value as AsyncGenerator<
258+
React.ReactNode,
259+
React.ReactNode,
260+
void
261+
>) {
262+
res.update(node);
263+
}
264+
finished?.resolve(void 0);
265+
} else if (value && typeof value === 'object' && Symbol.iterator in value) {
266+
const it = value as Generator<React.ReactNode, React.ReactNode, void>;
267+
while (true) {
268+
const { done, value } = it.next();
269+
if (done) break;
270+
res.update(value);
271+
}
272+
finished?.resolve(void 0);
273+
} else {
274+
res.update(value);
275+
finished?.resolve(void 0);
276+
}
277+
}
278+
279+
(async () => {
280+
let hasFunction = false;
281+
let text = '';
282+
283+
consumeStream(
284+
OpenAIStream(
285+
(await options.provider.chat.completions.create({
286+
model: options.model,
287+
messages: options.messages,
288+
temperature: options.temperature,
289+
stream: true,
290+
...(functions
291+
? {
292+
functions,
293+
}
294+
: {}),
295+
...(tools
296+
? {
297+
tools,
298+
}
299+
: {}),
300+
})) as any,
301+
{
302+
async experimental_onFunctionCall(functionCallPayload) {
303+
hasFunction = true;
304+
handleRender(
305+
functionCallPayload.arguments,
306+
options.functions?.[functionCallPayload.name as any]?.render,
307+
ui,
308+
);
309+
},
310+
...(tools
311+
? {
312+
async experimental_onToolCall(toolCallPayload: any) {
313+
hasFunction = true;
314+
315+
// TODO: We might need Promise.all here?
316+
for (const tool of toolCallPayload.tools) {
317+
handleRender(
318+
tool.func.arguments,
319+
options.tools?.[tool.func.name as any]?.render,
320+
ui,
321+
);
322+
}
323+
},
324+
}
325+
: {}),
326+
onToken(token) {
327+
text += token;
328+
if (hasFunction) return;
329+
handleRender({ content: text, done: false }, options.text, ui);
330+
},
331+
async onFinal() {
332+
if (hasFunction) return;
333+
handleRender({ content: text, done: true }, options.text, ui);
334+
335+
await finished?.promise;
336+
ui.done();
337+
},
338+
},
339+
),
340+
);
341+
})();
342+
343+
return ui.value;
344+
}

‎packages/core/rsc/types.ts

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
export type JSONValue = string | number | boolean | JSONObject | JSONArray;
2+
3+
interface JSONObject {
4+
[x: string]: JSONValue;
5+
}
6+
7+
interface JSONArray extends Array<JSONValue> {}
8+
9+
export type AIAction<T = any, R = any> = (...args: T[]) => Promise<R>;
10+
export type AIActions<T = any, R = any> = Record<string, AIAction<T, R>>;
11+
12+
export type ServerWrappedAction<T = unknown> = (
13+
aiState: T,
14+
...args: unknown[]
15+
) => Promise<[Promise<T>, unknown]>;
16+
export type ServerWrappedActions<T = unknown> = Record<
17+
string,
18+
ServerWrappedAction<T>
19+
>;
20+
21+
export type InternalAIProviderProps<AIState = any, UIState = any> = {
22+
children: React.ReactNode;
23+
initialUIState: UIState;
24+
initialAIState: AIState;
25+
initialAIStatePatch: undefined | Promise<AIState>;
26+
wrappedActions: ServerWrappedActions<AIState>;
27+
wrappedSyncUIState?: ServerWrappedAction<AIState>;
28+
};
29+
30+
export type AIProviderProps<AIState = any, UIState = any> = {
31+
children: React.ReactNode;
32+
initialAIState?: AIState;
33+
initialUIState?: UIState;
34+
};
35+
36+
export type AIProvider<AIState = any, UIState = any, Actions = any> = (
37+
props: AIProviderProps<AIState, UIState>,
38+
) => Promise<React.ReactElement>;
39+
40+
export type InferAIState<T, Fallback> = T extends AIProvider<
41+
infer AIState,
42+
any,
43+
any
44+
>
45+
? AIState
46+
: Fallback;
47+
export type InferUIState<T, Fallback> = T extends AIProvider<
48+
any,
49+
infer UIState,
50+
any
51+
>
52+
? UIState
53+
: Fallback;
54+
export type InferActions<T, Fallback> = T extends AIProvider<
55+
any,
56+
any,
57+
infer Actions
58+
>
59+
? Actions
60+
: Fallback;
61+
62+
export type InternalAIStateStorageOptions = {
63+
onSetAIState?: OnSetAIState<any>;
64+
};
65+
66+
export type OnSetAIState<S> = ({
67+
key,
68+
state,
69+
done,
70+
}: {
71+
key: string | number | symbol | undefined;
72+
state: S;
73+
done: boolean;
74+
}) => void | Promise<void>;
75+
76+
export type OnGetUIState<S> = AIAction<void, S | undefined>;
77+
78+
export type ValueOrUpdater<T> = T | ((current: T) => T);
79+
80+
export type MutableAIState<AIState> = {
81+
get: () => AIState;
82+
update: (newState: ValueOrUpdater<AIState>) => void;
83+
done: ((newState: AIState) => void) | (() => void);
84+
};

‎packages/core/rsc/utils.tsx

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React, { Suspense } from 'react';
2+
3+
export function createResolvablePromise() {
4+
let resolve: (value: any) => void, reject: (error: any) => void;
5+
const promise = new Promise((res, rej) => {
6+
resolve = res;
7+
reject = rej;
8+
});
9+
return {
10+
promise,
11+
resolve: resolve!,
12+
reject: reject!,
13+
};
14+
}
15+
16+
export function createSuspensedChunk(initialValue: React.ReactNode) {
17+
const Row = (async ({
18+
current,
19+
next,
20+
}: {
21+
current: React.ReactNode;
22+
next: Promise<any>;
23+
}) => {
24+
const chunk = await next;
25+
if (chunk.done) {
26+
return chunk.value;
27+
}
28+
29+
if (chunk.append) {
30+
return (
31+
<>
32+
{current}
33+
<Suspense fallback={chunk.value}>
34+
<Row current={chunk.value} next={chunk.next} />
35+
</Suspense>
36+
</>
37+
);
38+
}
39+
40+
return (
41+
<Suspense fallback={chunk.value}>
42+
<Row current={chunk.value} next={chunk.next} />
43+
</Suspense>
44+
);
45+
}) /* Our React typings don't support async components */ as unknown as React.FC<{
46+
current: React.ReactNode;
47+
next: Promise<any>;
48+
}>;
49+
50+
const { promise, resolve, reject } = createResolvablePromise();
51+
52+
return {
53+
row: (
54+
<Suspense fallback={initialValue}>
55+
<Row current={initialValue} next={promise} />
56+
</Suspense>
57+
),
58+
resolve,
59+
reject,
60+
};
61+
}
62+
63+
export const isFunction = (x: unknown): x is Function =>
64+
typeof x === 'function';
65+
66+
export const consumeStream = async (stream: ReadableStream) => {
67+
const reader = stream.getReader();
68+
while (true) {
69+
const { done } = await reader.read();
70+
if (done) break;
71+
}
72+
};

‎packages/core/tsup.config.ts

+18
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,22 @@ export default defineConfig([
7070
dts: true,
7171
sourcemap: true,
7272
},
73+
// RSC APIs - shared client
74+
{
75+
entry: ['rsc/rsc-shared.ts'],
76+
outDir: 'rsc/dist',
77+
format: ['esm'],
78+
external: ['react', 'zod'],
79+
dts: true,
80+
sourcemap: true,
81+
},
82+
// RSC APIs - server, client, types
83+
{
84+
entry: ['rsc/rsc-server.ts', 'rsc/rsc-client.ts', 'rsc/rsc-types.ts'],
85+
outDir: 'rsc/dist',
86+
format: ['esm'],
87+
external: ['react', 'zod', /\/rsc-shared/],
88+
dts: true,
89+
sourcemap: true,
90+
},
7391
]);

‎pnpm-lock.yaml

+45-13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.