Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default [
ignores: ["**/dist/**", "**/docs/**"],
},
{
files: ["packages/*/src/**/*.ts"],
files: ["packages/*/src/**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
},
Expand Down
30 changes: 30 additions & 0 deletions packages/platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,33 @@ the first, and the chat watches it while `appId` is `null`. A sent prompt shows
queue meanwhile. A question is a union on `kind` (`choice`, `secrets`, `approval`) and carries only the
actions its kind allows, and declining is always one of them. As in Base44's builder, only the
last assistant message makes the chat wait; a kind this release doesn't know is declined for you. Every shape has JSDoc in `src/react/chat.types.ts`.

### Provider and parts

`<Base44ChatProvider>` calls the hook once and shares the chat through context. Any component
below it reads the chat with `useChatState()` (`items`, `status`, `connection`, `error`) or
`useChatActions()` (`send`, `create`, `clearError`), and `<Message>` draws an item with your parts.

```tsx
import { Base44ChatProvider, Message, useChatState } from "@base44/platform/react";

<Base44ChatProvider appId={appId} backend={backend} onAppCreated={selectApp} components={{ question: { Choice, Secrets, Approval } }}>
<Messages />
</Base44ChatProvider>

function Messages() {
const { items } = useChatState();
return items.map((item) => <div key={item.id} className={item.role}><Message item={item} /></div>);
}
```

- **A part for every question kind is required.** TypeScript refuses a `components.question`
without `Choice`, `Secrets` and `Approval`, and a release that adds a kind adds a part, so an
upgrade tells you what to write. Until then the chat declines the new kind for you.
- **Message parts are optional.** `Text` and `Step` left out are drawn as plain HTML, with no
classes and a `data-base44` attribute for CSS.
- **A fixed order.** `<Message>` draws the text, then each step followed by its open question, if
it has one. It adds no element of its own. `<Question>` draws a question anywhere else below the provider.
- **Stable actions.** `useChatActions()` keeps its identity while the app stays the same, also with
an inline `backend` and `onAppCreated`, so a component that reads only actions doesn't re-render
when a message arrives.
81 changes: 81 additions & 0 deletions packages/platform/src/react/Base44ChatProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { Base44App } from "./chat.types.js";
import type { Base44ChatProviderProps } from "./components.types.js";
import { ActionsContext, ComponentsContext, StateContext, type ResolvedComponents } from "./context.js";
import { fallbackMessageComponents } from "./fallbacks.js";
import { useBase44Chat } from "./useBase44Chat.js";

/**
* Runs the AI chat for one app for the tree below it. It calls {@link useBase44Chat} once and
* shares the result through context, so any component below reads it with {@link useChatState}
* and {@link useChatActions}, and {@link Message} draws an item with your parts.
*
* Put it as high as the components that need the chat: a header that shows the status, a preview
* that waits for the build, and the message list all read the same chat. It renders nothing of its
* own.
*
* You pass a part for every question kind. The message parts, `Text` and `Step`, are optional:
* one left out is drawn as plain HTML with no classes and a `data-base44` attribute for CSS.
*
* @param props - The {@link useBase44Chat} options, the parts, and the children.
* @returns The children, inside the chat's contexts.
*
* @example
* ```tsx
* // The provider, a message list, and a composer of your own
* <Base44ChatProvider appId={appId} backend={backend} onAppCreated={selectApp} components={{ question: { Choice, Secrets, Approval } }}>
* <Messages />
* <Composer />
* </Base44ChatProvider>
*
* function Messages() {
* const { items } = useChatState();
* return items.map((item) => <div key={item.id} className={item.role}><Message item={item} /></div>);
* }
*
* function Composer() {
* const { status } = useChatState();
* const { send } = useChatActions();
* const [prompt, setPrompt] = useState("");
* return (
* <form onSubmit={(e) => { e.preventDefault(); send(prompt); setPrompt(""); }}>
* <input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
* <button disabled={status === "submitted" || status === "waiting"}>Send</button>
* </form>
* );
* }
* ```
*/
export function Base44ChatProvider({ components, children, appId, backend, onAppCreated }: Base44ChatProviderProps) {
// The hook reads the backend through a ref already. The callback goes through one here, so an
// inline `onAppCreated` keeps `create`, and every reader of the actions, stable.
const onAppCreatedRef = useRef(onAppCreated);
useEffect(() => {
onAppCreatedRef.current = onAppCreated;
});
const reportAppCreated = useCallback((app: Base44App) => onAppCreatedRef.current?.(app), []);

const { items, status, connection, error, send, create, clearError } = useBase44Chat({ appId, backend, onAppCreated: reportAppCreated });
const state = useMemo(() => ({ items, status, connection, error }), [items, status, connection, error]);
const actions = useMemo(() => ({ send, create, clearError }), [send, create, clearError]);

// Merged part by part, so an inline `components={{…}}` with the same parts keeps the same identity.
const { Text, Step } = components.message ?? {};
const { Choice, Secrets, Approval } = components.question;
const parts = useMemo<ResolvedComponents>(
() => ({
message: { Text: Text ?? fallbackMessageComponents.Text, Step: Step ?? fallbackMessageComponents.Step },
question: { Choice, Secrets, Approval },
}),
[Text, Step, Choice, Secrets, Approval],
);

return (
<ComponentsContext.Provider value={parts}>
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
</ComponentsContext.Provider>
);
}
67 changes: 67 additions & 0 deletions packages/platform/src/react/Message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";
import { Fragment } from "react";
import type { MessageProps, QuestionProps } from "./components.types.js";
import { useResolvedComponents } from "./context.js";

/**
* Draws one chat item with the parts from the nearest {@link Base44ChatProvider}, always in the
* same order: its text, then one `Step` per tool call, each followed by its open question, if it has
* one. Only the last assistant message carries questions.
*
* It adds no element of its own, so wrap it to lay messages out.
*
* @param props - The item to draw.
* @returns The item's parts.
* @throws {Error} When there is no provider above the component.
*
* @example
* ```tsx
* // Align by role in your own wrapper
* <div className={item.role === "user" ? "ml-auto" : ""}>
* <Message item={item} />
* </div>
* ```
*/
export function Message({ item }: MessageProps) {
const { Text, Step } = useResolvedComponents("Message").message;
return (
<>
{item.text && <Text text={item.text} role={item.role} pending={item.pending} />}
{item.steps.map(step => (
<Fragment key={step.id}>
<Step {...step} />
{step.question && <Question question={step.question} />}
</Fragment>
))}
</>
);
}

/**
* Draws an open question with the part for its kind, from the nearest {@link Base44ChatProvider}.
* The part receives the question with its bound actions. {@link Message} already draws a message's
* questions; use this to show one somewhere else.
*
* @param props - The question to draw.
* @returns The part for the question's kind.
* @throws {Error} When there is no provider above the component.
*
* @example
* ```tsx
* // The open questions, pinned above the composer
* const { items } = useChatState();
* const open = items.flatMap((item) => item.steps.flatMap((step) => (step.question ? [step.question] : [])));
* return open.map((question, i) => <Question key={i} question={question} />);
* ```
*/
export function Question({ question }: QuestionProps) {
const { Choice, Secrets, Approval } = useResolvedComponents("Question").question;
switch (question.kind) {
case "choice":
return <Choice {...question} />;
case "secrets":
return <Secrets {...question} />;
case "approval":
return <Approval {...question} />;
}
}
73 changes: 73 additions & 0 deletions packages/platform/src/react/components.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { ComponentType, ReactNode } from "react";
import type { ApprovalQuestion, Base44Chat, Base44ChatOptions, ChatItem, ChatQuestion, ChatStep, ChoiceQuestion, SecretsQuestion } from "./chat.types.js";

/** Props of the `Text` part: one message's text. */
export interface TextProps {
/** The message text, as raw markdown. Never empty: an item without text draws no `Text`. */
text: string;
/** Who wrote the message, to align or color it. */
role: ChatItem["role"];
/** Whether this is a prompt Base44 hasn't confirmed yet. */
pending: boolean;
}

/** Props of the `Step` part: one tool call of a message. */
export type StepProps = ChatStep;

/** The parts of a message's own content. Each one left out is drawn as plain, unstyled HTML. */
export interface MessageComponents {
/** Draws a message's text. */
Text: ComponentType<TextProps>;
/** Draws one tool call as a line of activity. */
Step: ComponentType<StepProps>;
}

/**
* One part per question kind, all required. Each receives the question itself: its content and the
* actions bound to it, so a part can only answer the question it draws.
*
* A release that adds a kind adds a part here, so upgrading tells you at compile time which part to
* write. Until you upgrade, the chat declines the new kind for you.
*/
export interface QuestionComponents {
/** Draws a pick between options. Call `answer` with one list of labels per question. */
Choice: ComponentType<ChoiceQuestion>;
/** Draws a form for values such as API keys. Call `submit` with the values keyed by field name. */
Secrets: ComponentType<SecretsQuestion>;
/** Draws a request to run an action. Call `approve` or `decline`. */
Approval: ComponentType<ApprovalQuestion>;
}

/** The parts you pass to {@link Base44ChatProvider}: every question part, and the message parts you replace. */
export interface ChatComponents {
/** Replacements for the message parts. */
message?: Partial<MessageComponents>;
/** A part for every question kind. */
question: QuestionComponents;
}

/** What {@link useChatState} returns: everything that changes as the chat runs. */
export type ChatState = Pick<Base44Chat, "items" | "status" | "connection" | "error">;

/** What {@link useChatActions} returns. The actions keep their identity while the app stays the same. */
export type ChatActions = Pick<Base44Chat, "send" | "create" | "clearError">;

/** Props of {@link Base44ChatProvider}: the {@link useBase44Chat} options, the parts, and the tree that reads them. */
export interface Base44ChatProviderProps extends Base44ChatOptions {
/** The parts that draw the chat. Read part by part, so an inline object works. */
components: ChatComponents;
/** The components that read the chat. */
children?: ReactNode;
}

/** Props of {@link Message}. */
export interface MessageProps {
/** The item to draw, one of the `items` from {@link useChatState}. */
item: ChatItem;
}

/** Props of {@link Question}. */
export interface QuestionProps {
/** The open question to draw, from {@link ChatStep.question}. */
question: ChatQuestion;
}
68 changes: 68 additions & 0 deletions packages/platform/src/react/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"use client";
import { createContext, useContext } from "react";
import type { ChatActions, ChatState, MessageComponents, QuestionComponents } from "./components.types.js";

/** @internal The parts with the fallbacks applied, as {@link Base44ChatProvider} shares them. */
export interface ResolvedComponents {
message: MessageComponents;
question: QuestionComponents;
}

/** @internal Filled by {@link Base44ChatProvider}. State changes on every event; actions and parts do not. */
export const StateContext = createContext<ChatState | null>(null);
/** @internal */
export const ActionsContext = createContext<ChatActions | null>(null);
/** @internal */
export const ComponentsContext = createContext<ResolvedComponents | null>(null);

/**
* Reads the chat's state from the nearest {@link Base44ChatProvider}: `items`, `status`,
* `connection` and `error`. A component that calls it re-renders on every live event.
*
* @returns The state of the chat the provider runs.
* @throws {Error} When there is no provider above the component.
*
* @example
* ```tsx
* // The message list
* function Messages() {
* const { items } = useChatState();
* return items.map((item) => <div key={item.id}><Message item={item} /></div>);
* }
* ```
*/
export function useChatState(): ChatState {
return inside(useContext(StateContext), "useChatState");
}

/**
* Reads the chat's actions from the nearest {@link Base44ChatProvider}: `send`, `create` and
* `clearError`. They keep their identity while the app stays the same, also with an inline
* `backend` or `onAppCreated`, so a component that reads only actions doesn't re-render when a
* message arrives.
*
* @returns The actions of the chat the provider runs.
* @throws {Error} When there is no provider above the component.
*
* @example
* ```tsx
* // A button outside the chat that starts a new app
* function NewAppButton({ prompt }: { prompt: string }) {
* const { create } = useChatActions();
* return <button onClick={() => create(prompt)}>Create</button>;
* }
* ```
*/
export function useChatActions(): ChatActions {
return inside(useContext(ActionsContext), "useChatActions");
}

/** @internal The parts from the nearest provider. */
export function useResolvedComponents(component: string): ResolvedComponents {
return inside(useContext(ComponentsContext), component);
}

function inside<T>(value: T | null, reader: string): T {
if (!value) throw new Error(`${reader} needs a <Base44ChatProvider> above it`);
return value;
}
25 changes: 25 additions & 0 deletions packages/platform/src/react/fallbacks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";
import type { MessageComponents, StepProps, TextProps } from "./components.types.js";

// The message parts a partner does not replace: plain HTML, no classes, so the library has no look
// of its own. Each carries a `data-base44` attribute for CSS to hook on. Questions have no
// fallback: the partner supplies a part for every kind.

function Text({ text, role, pending }: TextProps) {
return (
<p data-base44="text" data-role={role} data-pending={pending || undefined}>
{text}
</p>
);
}

function Step({ label, status }: StepProps) {
return (
<p data-base44="step" data-status={status}>
{label}
</p>
);
}

/** @internal */
export const fallbackMessageComponents: MessageComponents = { Text, Step };
9 changes: 8 additions & 1 deletion packages/platform/src/react/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/** The `@base44/platform/react` entry: the AI chat for one app, as a React hook. React is an optional peer, loaded only here. */
/** The `@base44/platform/react` entry: the AI chat for one app, as a hook and as a provider that draws it with your parts. React is an optional peer, loaded only here. */
export { useBase44Chat } from "./useBase44Chat.js";
export { Base44ChatProvider } from "./Base44ChatProvider.js";
export { useChatActions, useChatState } from "./context.js";
export { Message, Question } from "./Message.js";
export type {
ApprovalQuestion, Base44App, Base44Chat, Base44ChatOptions, Base44ChatBackend, ChatConnection, ChatError, ChatItem, ChatQuestion,
ChatStatus, ChatStep, ChoiceEntry, ChoiceQuestion, SecretField, SecretsQuestion, ToolCallAnswer,
} from "./chat.types.js";
export type {
Base44ChatProviderProps, ChatActions, ChatComponents, ChatState, MessageComponents, MessageProps, QuestionComponents, QuestionProps,
StepProps, TextProps,
} from "./components.types.js";
export type { SocketSession } from "../client/modules/builder.types.js";
Loading
Loading