Rules of AI.JSX
AI.JSX uses the familiar JSX syntax, but it's not React.
When you write an AI.JSX expression, you're declaratively specifying the shape of the string you want to be returned to the caller. The AI.JSX engine then evaluates your expression in parallel and streams the results.
function App() {
return (
<>
{/* Components can contain other components */}
<Foo />
{/* Components can have children */}
<Bar>
<Baz />
</Bar>
{/* You can put promises directly in the JSX; they will be awaited. */}
{fs.readFile('./my-data', 'utf8')}
{/* You can write raw strings */}
Raw string
{/* If you're putting in JS values, you'll want to serialize them. */}
{JSON.stringify(myData)}
</>
);
}
Async Components
Components can be async, or can return generators.
function App({ query }) {
return (
<ChatCompletion>
<SystemMessage>
Answer customer questions based on their data: <CustomerData />
Here's data about our company: <OrgData />
</SystemMessage>
<UserMessage>{query}</UserMessage>
</ChatCompletion>
);
}
async function CustomerData() {
const accountId = await getCustomerAccount();
return isLegacyAccount(accountId) ? fetchLegacy() : fetchModern();
}
function* OrgData() {
yield firstData;
yield secondData;
yield thirdData;
}
Component API
Components take props as the first argument and ComponentContext (packages/ai-jsx/src/index.ts
) as the second:
function MyComponent(props, componentContext) {}
componentContext
contains a render
method, which you can use to render other JSX components:
function App() {
return (
<JsonOutput>
<ChatCompletion>
<UserMessage>Give me a JSON object representing a character in a fantasy game.</UserMessage>
</ChatCompletion>
</JsonOutput>
);
}
/**
* Ensure the model's response is JSON.
*/
function ValidateJsonOutput({ children }, { render }): string {
const rendered = await render(children);
try {
JSON.parse(rendered);
return rendered;
} catch (e) {
throw new Error(`Could not parse model response as JSON: ${rendered}`);
}
}
In this example, JsonOutput
takes in a child, and returns a JSON result. To do that, it needs to know what the child renders to, so it uses render
.
Intermediate Results
If you'd like to see intermediate results of the render, you can pass a map
param to the render
method:
let frameCount = 0;
await render(<Component />, {
map: (frame) => console.log('got frame', frameCount++, frame);
})
If Component
ultimately resolved to hello world
, then the map
function might be called with:
got frame 0 h
got frame 1 hell
got frame 2 hello w
got frame 3 hello wor
got frame 4 hello world
(The exact chunking you'll get depends on the chunks emitted by the component you're rendering.)
You can also use the map
function to map the results as they're streaming.
Partial Rendering
By default, render
will render the entire tree down to a string. However, you can use partial rendering if you'd like to only render some of it.
The main reason you'd want to do this is when you're writing a parent component that has knowledge of its children. For example:
ChatCompletion
needs all its children to ultimately be aSystemMessage
,UserMessage,
orAssistantMessage
. To find those children, it uses partial rendering.NaturalLanguageRouter
needs to know what all theRoute
s are, so it uses partial rendering to find them.
To do partial rendering, pass a stop
argument to render
:
const messageChildren = await render(children, {
stop: (e) => e.tag == SystemMessage || e.tag == UserMessage || e.tag == AssistantMessage,
});
This approach means we can write the following, and ChatCompletion
will be able to find all the nested *Message
children:
function MyUserMessages() {
return (
<>
<UserMessage>first</UserMessage>
<UserMessage>second</UserMessage>
</>
);
}
<ChatCompletion>
<MyUserMessages />
<>
<UserMessage>third</UserMessage>
</>
</ChatCompletion>;
Context
Similar to React's context
, AI.JSX lets you set context to control values for parts of your tree.
// Create a context with a default value of 0.
const Temperature = LLMx.createContext(0.0);
// Create a component that reads the context
function CharacterGenerator(props: Record<string, never>, { getContext }: LLMx.RenderContext) {
return (
<Completion temperature={getContext(Temperature)}>
Create a bio for a character in an RPG game.
</Completion>
);
}
showInspector(
<>
{/* Set the value for temperature */}
<Temperature.Provider value={0.0}>
🥶🥶🥶:{'\n'}
<CharacterGenerator />
</Temperature.Provider>
{/* Set the value for temperature */}
<Temperature.Provider value={2.0}>
🔥🔥🔥:{'\n'}
<CharacterGenerator />
</Temperature.Provider>
</>
Each instance of CharacterGenerator
will use the context value set by its nearest Temperature.Provider
parent.
See also:
- API (
packages/ai-jsx/src/index.ts
) - Usage example (
packages/examples/src/context.tsx
)
Handling Errors
Use an Error Boundary (packages/ai-jsx/src/core/error-boundary.ts
) to provide fallback values when a component throws:
<ErrorBoundary fallback={'✅ Error was handled'}>
<FailingComponent />
</ErrorBoundary>
Error boundary example (packages/examples/src/errors.tsx
).
Memoization
Imagine you have the following:
const catName = (
<ChatCompletion>
<UserMessage>Give me a cat name</UserMessage>
</ChatCompletion>
);
<ChatCompletion>
<UserMessage>
Give me a story about these two cats:
{catName}
{catName}
</UserMessage>
</ChatCompletion>;
In this case, catName
will result in two separate model calls, so you'll get two different cat names.
If this is not desired, you can wrap the component in memo
:
const catName = memo(
<ChatCompletion>
<UserMessage>Give me a cat name</UserMessage>
</ChatCompletion>
);
<ChatCompletion>
<UserMessage>
I have a cat named {catName}. Tell me a story about {catName}.
</UserMessage>
</ChatCompletion>;
Now, catName
will result in a single model call, and its value will be reused everywhere that component appears in the tree.
- API (
packages/ai-jsx/src/core/memoize.tsx
)