diff --git a/examples/white-label-minimal/lean/components/chats/Chat.tsx b/examples/white-label-minimal/lean/components/chats/Chat.tsx index a8e6afa..af7755d 100644 --- a/examples/white-label-minimal/lean/components/chats/Chat.tsx +++ b/examples/white-label-minimal/lean/components/chats/Chat.tsx @@ -17,7 +17,7 @@ const versions = { chatscope: ChatscopeChat, shadcn: ShadcnChat, "assistant-ui": // For comparing: three chats that talk to Base44 by hand (without-sdk/), and one on the // headless library (sdk/). Pick one with the tabs. export default function Chat(props: ChatProps) { - const [version, setVersion] = useState("chatscope"); + const [version, setVersion] = useState("headless"); const Version = versions[version]; return ( diff --git a/examples/white-label-minimal/lean/components/chats/headless/HeadlessChat.tsx b/examples/white-label-minimal/lean/components/chats/headless/HeadlessChat.tsx index 5eb5de3..22939ea 100644 --- a/examples/white-label-minimal/lean/components/chats/headless/HeadlessChat.tsx +++ b/examples/white-label-minimal/lean/components/chats/headless/HeadlessChat.tsx @@ -1,35 +1,61 @@ "use client"; import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { Message, useBase44Chat } from "../../../sdk"; +import { Base44ChatProvider, Message, useChatActions, useChatState } from "../../../sdk"; import { server } from "./base44Server"; -import { chatParts } from "./chatParts"; +import { StatusLine, chatParts } from "./chatParts"; import type { ChatProps } from "../Chat"; -// Only markup: the hook holds all the Base44 logic, and chatParts gives it Tiny's look. +// Option B, the provider: it runs the chat for the tree below it, and draws each item +// with Tiny's parts. The list and the composer read the chat from context. export default function HeadlessChat({ app, onAppCreated }: ChatProps) { - const chat = useBase44Chat({ appId: app?.id ?? null, server }); - const [prompt, setPrompt] = useState(""); - return ( -
-
- {chat.items.map((item) => ( - - ))} - {chat.status &&

{chat.status}

} + +
+ +
-
{ - e.preventDefault(); - chat.send(prompt).then((created) => created && onAppCreated(created)); - setPrompt(""); - }} - className="m-3 flex gap-2 rounded-2xl border p-2" - > - setPrompt(e.target.value)} placeholder="Message…" className="flex-1 bg-transparent px-2 outline-none" /> - -
+
+ ); +} + +function Messages() { + const { items, phase, error } = useChatState(); + const { clearError } = useChatActions(); + return ( +
+ {items.map((item) => ( +
+ +
+ ))} +
); } + +function Composer({ hasApp }: { hasApp: boolean }) { + const { canSend } = useChatState(); + const { send, create } = useChatActions(); + const [prompt, setPrompt] = useState(""); + + function submit() { + // No app yet: the first prompt creates one. Otherwise it goes to the selected app. + if (hasApp) send(prompt); + else create(prompt); + setPrompt(""); + } + + return ( +
{ e.preventDefault(); submit(); }} className="m-3 flex gap-2 rounded-2xl border p-2"> + setPrompt(e.target.value)} + placeholder={hasApp ? "Ask for a change…" : "Describe your app…"} + className="flex-1 bg-transparent px-2 outline-none" + /> + {/* canSend is false while a question is open: Base44 drops a message sent into a stopped turn. */} + +
+ ); +} diff --git a/examples/white-label-minimal/lean/components/chats/headless/chatParts.tsx b/examples/white-label-minimal/lean/components/chats/headless/chatParts.tsx index 977acf5..b336958 100644 --- a/examples/white-label-minimal/lean/components/chats/headless/chatParts.tsx +++ b/examples/white-label-minimal/lean/components/chats/headless/chatParts.tsx @@ -2,18 +2,18 @@ import { useState } from "react"; import { Check, CircleHelp, Loader2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import type { ApprovalProps, ChoiceProps, InputProps, MessageComponents, StepProps, TextProps } from "../../../sdk"; +import type { ApprovalQuestion, ChatError, ChatPhase, ChoiceQuestion, InputQuestion, StepProps, TextProps, ChatComponentOverrides } from "../../../sdk"; -// Tiny's look for the library's message parts: shadcn buttons, Tiny's theme, lucide icons. -// Every part is optional; anything left out uses the library's default. +// Tiny's look: shadcn buttons, Tiny's theme, lucide icons. Each part takes exactly the props +// the library hands it, and a question part gets the question with its bound actions. -function Text({ text, role }: TextProps) { - return

{text}

; +export function Text({ text, role }: TextProps) { + return

{text}

; } const stepIcons = { running: Loader2, waiting: CircleHelp, done: Check, error: X }; -function Step({ label, status }: StepProps) { +export function Step({ label, status }: StepProps) { const Icon = stepIcons[status]; return (

@@ -22,21 +22,27 @@ function Step({ label, status }: StepProps) { ); } -function Choice({ questions, answer, decline }: ChoiceProps) { - const [picked, setPicked] = useState([]); +export function Choice({ questions, answer, decline }: ChoiceQuestion) { + // One list of picked labels per question, in the same order as `questions`. + const [picked, setPicked] = useState(questions.map(() => [])); function toggle(i: number, option: string, multi: boolean) { - const current = picked[i] ?? []; - const next = current.includes(option) ? current.filter((o) => o !== option) : multi ? [...current, option] : [option]; - setPicked(Object.assign([...picked], { [i]: next })); + setPicked((all) => + all.map((labels, j) => { + if (j !== i) return labels; + if (labels.includes(option)) return labels.filter((o) => o !== option); + return multi ? [...labels, option] : [option]; + }), + ); } return (

{questions.map((q, i) => (

{q.text}

+ {q.description &&

{q.description}

}
{q.options.map((option) => ( - ))} @@ -51,12 +57,19 @@ function Choice({ questions, answer, decline }: ChoiceProps) { ); } -function Input({ fields, submit, decline }: InputProps) { +export function Secrets({ fields, submit, decline }: InputQuestion) { const [values, setValues] = useState>({}); return (
{fields.map((field) => ( - setValues({ ...values, [field]: e.target.value })} className="rounded-md border px-2 py-1" /> + setValues({ ...values, [field.name]: e.target.value })} + className="rounded-md border px-2 py-1" + /> ))}
@@ -66,7 +79,7 @@ function Input({ fields, submit, decline }: InputProps) { ); } -function Approval({ action, reason, approve, decline }: ApprovalProps) { +export function Approval({ action, reason, approve, decline }: ApprovalQuestion) { return (

Allow {action}?

@@ -79,4 +92,26 @@ function Approval({ action, reason, approve, decline }: ApprovalProps) { ); } -export const chatParts: Partial = { Text, Step, Choice, Input, Approval }; +// The wording for each phase is Tiny's, not the library's. +const phaseText: Record = { + idle: "", + creating: "Creating app…", + loading: "Loading the conversation…", + waiting: "Waiting for your answer", + building: "Building…", +}; + +export function StatusLine({ phase, error, onDismiss }: { phase: ChatPhase; error: ChatError | null; onDismiss: () => void }) { + if (error) { + return ( +

+ {error.message} +

+ ); + } + return

{phaseText[phase]}

; +} + +// The same parts, grouped the way takes them. Unknown is left out, so the +// library's unstyled fallback draws it. +export const chatParts: ChatComponentOverrides = { message: { Text, Step }, question: { Choice, Input: Secrets, Approval } }; diff --git a/examples/white-label-minimal/lean/components/chats/without-sdk/ToolQuestion.tsx b/examples/white-label-minimal/lean/components/chats/without-sdk/ToolQuestion.tsx index 0efd977..befd2e4 100644 --- a/examples/white-label-minimal/lean/components/chats/without-sdk/ToolQuestion.tsx +++ b/examples/white-label-minimal/lean/components/chats/without-sdk/ToolQuestion.tsx @@ -11,7 +11,7 @@ export default function ToolQuestion({ appId, messageId, tool }: { appId: string const [sent, setSent] = useState<"approved" | "declined" | null>(null); const [secrets, setSecrets] = useState>({}); - function answer(approve: boolean, extraUserInput?: object) { + function answer(approve: boolean, extraUserInput?: Record) { setSent(approve ? "approved" : "declined"); submitToolCallInput(appId, { toolCallId: tool.id!, messageId, approve, extraUserInput }).catch(() => setSent(null)); } diff --git a/examples/white-label-minimal/lean/sdk/Base44ChatProvider.tsx b/examples/white-label-minimal/lean/sdk/Base44ChatProvider.tsx new file mode 100644 index 0000000..b0f96c1 --- /dev/null +++ b/examples/white-label-minimal/lean/sdk/Base44ChatProvider.tsx @@ -0,0 +1,74 @@ +"use client"; +import { useMemo } from "react"; +import type { Base44ChatProviderProps, ChatComponents } from "./components.types"; +import { ActionsContext, ComponentsContext, StateContext } from "./context"; +import { fallbackComponents } from "./fallbacks"; +import { useBase44Chat } from "./useBase44Chat"; + +/** + * Runs one app's builder chat 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} finds its parts. + * + * Put it as high as the components that need the chat: around a header that shows the phase, + * a preview that waits for the build, and the message list, they all read the same chat. + * Renders nothing of its own. + * + * @param props - The {@link useBase44Chat} options, the parts to replace, and the children. + * @returns The children, inside the chat's contexts. + * + * @example + * ```tsx + * // The provider, a message list, and a composer of your own + * + * + * + * + * + * function Messages() { + * const { items } = useChatState(); + * return items.map((item) =>
); + * } + * + * function Composer() { + * const { canSend } = useChatState(); + * const { send } = useChatActions(); + * const [prompt, setPrompt] = useState(""); + * return ( + *
{ e.preventDefault(); send(prompt); setPrompt(""); }}> + * setPrompt(e.target.value)} /> + * + *
+ * ); + * } + * ``` + */ +export function Base44ChatProvider({ components, children, ...options }: Base44ChatProviderProps) { + const { items, phase, error, canSend, messages, send, create, clearError } = useBase44Chat(options); + const state = useMemo(() => ({ items, phase, error, canSend, messages }), [items, phase, error, canSend, messages]); + 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, Input, Approval, Unknown } = components?.question ?? {}; + const parts = useMemo( + () => ({ + message: { Text: Text ?? fallbackComponents.message.Text, Step: Step ?? fallbackComponents.message.Step }, + question: { + Choice: Choice ?? fallbackComponents.question.Choice, + Input: Input ?? fallbackComponents.question.Input, + Approval: Approval ?? fallbackComponents.question.Approval, + Unknown: Unknown ?? fallbackComponents.question.Unknown, + }, + }), + [Text, Step, Choice, Input, Approval, Unknown], + ); + + return ( + + + {children} + + + ); +} diff --git a/examples/white-label-minimal/lean/sdk/Message.tsx b/examples/white-label-minimal/lean/sdk/Message.tsx index 447b9f7..1e5dbf8 100644 --- a/examples/white-label-minimal/lean/sdk/Message.tsx +++ b/examples/white-label-minimal/lean/sdk/Message.tsx @@ -1,44 +1,61 @@ "use client"; -import type { ComponentType } from "react"; -import { Question, defaultQuestionComponents, type QuestionComponents } from "./Question"; -import type { Base44Chat, ChatItem, ChatStep } from "./useBase44Chat"; +import type { MessageProps, QuestionProps } from "./components.types"; +import { useChatComponents } from "./context"; -export type TextProps = { text: string; role: ChatItem["role"] }; -export type StepProps = ChatStep; - -// Every part of a message, in one flat set. Replace any of them; the rest stay default. -export type MessageComponents = { - Text: ComponentType; - Step: ComponentType; -} & QuestionComponents; - -// Renders one chat item: its text, its steps, and its open question. -export function Message({ item, chat, components }: { item: ChatItem; chat: Base44Chat; components?: Partial }) { - const parts = { ...defaultMessageComponents, ...components }; - const { Text, Step } = parts; +/** + * Draws one chat item with the parts from the nearest {@link Base44ChatProvider}: its text, + * one `Step` per tool call, and its open question. It adds no element of its own, so wrap it + * to lay messages out. Outside a provider it uses the unstyled fallbacks. + * + * @param props - The item to draw. + * @returns The item's parts. + * + * @example + * ```tsx + * // Align by role in your own wrapper + *
+ * + *
+ * ``` + */ +export function Message({ item }: MessageProps) { + const { Text, Step } = useChatComponents().message; return ( -
+ <> {item.text && } - {item.steps.map((step) => ( + {item.steps.map(step => ( ))} - {item.question && } -
+ {item.question && } + ); } -function DefaultText({ text, role }: TextProps) { - return

{text}

; -} - -const stepIcons: Record = { running: "…", waiting: "?", done: "✓", error: "✗" }; - -function DefaultStep({ label, status }: StepProps) { - return ( -

- {stepIcons[status]} {label} -

- ); +/** + * 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 an item's question; use this to show it somewhere else. + * + * @param props - The question to draw. + * @returns The part for the question's kind. + * + * @example + * ```tsx + * // The open question pinned above the composer + * const open = items.find((item) => item.question)?.question; + * return open ? : null; + * ``` + */ +export function Question({ question }: QuestionProps) { + const { Choice, Input, Approval, Unknown } = useChatComponents().question; + switch (question.kind) { + case "choice": + return ; + case "input": + return ; + case "approval": + return ; + default: + return ; + } } - -export const defaultMessageComponents: MessageComponents = { Text: DefaultText, Step: DefaultStep, ...defaultQuestionComponents }; diff --git a/examples/white-label-minimal/lean/sdk/Question.tsx b/examples/white-label-minimal/lean/sdk/Question.tsx deleted file mode 100644 index c78c334..0000000 --- a/examples/white-label-minimal/lean/sdk/Question.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; -import { useState, type ComponentType } from "react"; -import { Button } from "./ui/button"; -import { Input as TextInput } from "./ui/input"; -import type { Base44Chat, Question as QuestionData } from "./useBase44Chat"; - -// One component per question kind. Each gets the question and the actions that answer it. -export type ChoiceProps = { questions: { text: string; options: string[]; multi: boolean }[]; answer: (picked: string[][]) => void; decline: () => void }; -export type InputProps = { fields: string[]; submit: (values: Record) => void; decline: () => void }; -export type ApprovalProps = { action: string; reason: string; approve: () => void; decline: () => void }; -export type UnknownProps = { action: string; decline: () => void }; // kinds Base44 adds later: decline only - -export type QuestionComponents = { - Choice: ComponentType; - Input: ComponentType; - Approval: ComponentType; - Unknown: ComponentType; -}; - -// Renders an open question. Pass components to replace any of the defaults. -export function Question({ question, chat, components }: { question: QuestionData; chat: Base44Chat; components?: Partial }) { - const { Choice, Input, Approval, Unknown } = { ...defaultQuestionComponents, ...components }; - switch (question.kind) { - case "choice": - return ; - case "input": - return ; - case "approval": - return ; - default: - return ; - } -} - -// The defaults are built from the library's own copies of shadcn's Button and Input (./ui), -// so they match a shadcn app without importing anything from it. -const card = "flex flex-col gap-3 rounded-xl border p-3 text-sm"; - -function DefaultChoice({ questions, answer, decline }: ChoiceProps) { - const [picked, setPicked] = useState([]); - function toggle(i: number, option: string, multi: boolean) { - const current = picked[i] ?? []; - const next = current.includes(option) ? current.filter((o) => o !== option) : multi ? [...current, option] : [option]; - setPicked(Object.assign([...picked], { [i]: next })); - } - return ( -
- {questions.map((q, i) => ( -
-

{q.text}

-
- {q.options.map((option) => ( - - ))} -
-
- ))} -
- - -
-
- ); -} - -function DefaultInput({ fields, submit, decline }: InputProps) { - const [values, setValues] = useState>({}); - return ( -
- {fields.map((field) => ( - setValues({ ...values, [field]: e.target.value })} /> - ))} -
- - -
-
- ); -} - -function DefaultApproval({ action, reason, approve, decline }: ApprovalProps) { - return ( -
-

Allow {action}?

- {reason &&

{reason}

} -
- - -
-
- ); -} - -function DefaultUnknown({ action, decline }: UnknownProps) { - return ( -
-

{action} is waiting for you.

- -
- ); -} - -export const defaultQuestionComponents: QuestionComponents = { - Choice: DefaultChoice, - Input: DefaultInput, - Approval: DefaultApproval, - Unknown: DefaultUnknown, -}; diff --git a/examples/white-label-minimal/lean/sdk/chat-items.ts b/examples/white-label-minimal/lean/sdk/chat-items.ts new file mode 100644 index 0000000..d00af2c --- /dev/null +++ b/examples/white-label-minimal/lean/sdk/chat-items.ts @@ -0,0 +1,80 @@ +import type { ChatMessage, ToolCall, ToolQuestionArguments, ToolSecretArguments } from "@base44/platform"; +import type { ChatItem, ChatStep, Question } from "./chat.types"; + +/** @internal Submits one reply for the tool call the question belongs to. */ +export type Reply = (approve: boolean, extraUserInput?: Record) => Promise; + +/** @internal Submits a reply for a given tool call in a given message. */ +export type Respond = (toolCallId: string, messageId: string, approve: boolean, extraUserInput?: Record) => Promise; + +/** @internal Events can arrive out of order, so messages are ordered by when they were created. */ +export function sortMessages(messages: ChatMessage[]): ChatMessage[] { + return [...messages].sort((a, b) => (a.metadata?.created_date ?? "").localeCompare(b.metadata?.created_date ?? "")); +} + +/** @internal Replaces the message with the same id in place, or adds it at the end. */ +export function mergeMessage(all: ChatMessage[], message: ChatMessage): ChatMessage[] { + return all.some(m => m.id === message.id) ? all.map(m => (m.id === message.id ? message : m)) : [...all, message]; +} + +/** @internal Turns sorted messages into items. A tool call that waits and is not in `answered` becomes the item's question. */ +export function toItems(sorted: ChatMessage[], answered: string[], respond: Respond): ChatItem[] { + const isOpen = (tool: ToolCall) => tool.status === "waiting_for_user_input" && !answered.includes(tool.id!); + return sorted.map(m => { + const waiting = m.tool_calls?.find(isOpen); + return { + id: m.id!, + role: m.role === "user" ? "user" : "assistant", + text: m.content ?? "", + steps: (m.tool_calls ?? []).map(tool => ({ + id: tool.id!, + label: [tool.name, ...(tool.display?.file_paths ?? [])].join(" "), + status: stepStatus(tool), + })), + question: waiting && bindQuestion(waiting, (approve, extra) => respond(waiting.id!, m.id!, approve, extra)), + }; + }); +} + +/** @internal */ +export function stepStatus(tool: ToolCall): ChatStep["status"] { + if (tool.status === "running") return "running"; + if (tool.status === "waiting_for_user_input") return "waiting"; + if (tool.status === "error") return "error"; + return "done"; +} + +/** @internal Base44's question formats, as one simple shape per kind, each bound to the reply that + * answers it. Base44 keys choice answers by position, so `picked` follows `questions`. */ +export function bindQuestion(tool: ToolCall, reply: Reply): Question { + const kind = tool.waiting_on?.kind; + const decline = () => reply(false); + if (kind === "choice") { + const questions = (tool.arguments as ToolQuestionArguments | undefined)?.questions ?? []; + return { + kind, + questions: questions.map(q => ({ + text: q.question ?? "", + description: q.description, + options: (q.options ?? []).map(o => (typeof o === "string" ? o : o.label ?? "")), + multi: !!q.multi_select, + })), + answer: picked => + reply(true, { answers: picked.map((labels, i) => ({ question_index: i, selected_labels: labels })).filter(a => a.selected_labels.length) }), + decline, + }; + } + if (kind === "input") { + const fields = (tool.arguments as ToolSecretArguments | undefined)?.secrets_schema ?? []; + return { + kind, + fields: fields.map(f => ({ name: f.secretName ?? "", description: f.description })), + submit: values => reply(true, { secrets: values }), + decline, + }; + } + if (kind === "approval") { + return { kind, action: tool.name ?? "", reason: tool.approval?.reason ?? tool.approval?.details?.summary ?? "", approve: () => reply(true), decline }; + } + return { kind: "unknown", action: tool.name ?? "", decline }; +} diff --git a/examples/white-label-minimal/lean/sdk/chat.types.ts b/examples/white-label-minimal/lean/sdk/chat.types.ts new file mode 100644 index 0000000..0789f75 --- /dev/null +++ b/examples/white-label-minimal/lean/sdk/chat.types.ts @@ -0,0 +1,194 @@ +import type { ChatMessage } from "@base44/platform"; + +/** An app the builder works on, as the chat needs it. */ +export interface Base44App { + /** App identifier: 24 lowercase hexadecimal characters. */ + id: string; + /** Display name. */ + name: string; +} + +/** What {@link Base44ChatServer.openLiveSession} returns: where the socket is and the token that joins it. */ +export interface LiveSession { + /** The socket server's origin, with no path. Your backend takes it from the session's `socket_url`. */ + serverUrl: string; + /** The session token, and nothing else: the workspace key never reaches the browser. */ + sessionToken: string; +} + +/** A reply to a question the builder waits on. Declining is `approve: false`. */ +export interface ToolCallAnswer { + /** The tool call being answered. */ + toolCallId: string; + /** The assistant message that holds the tool call. */ + messageId: string; + /** Whether to let the call run (`true`) or turn it down (`false`). */ + approve: boolean; + /** Content the question asked for: `{ answers }` for a choice, `{ secrets }` for an input. */ + extraUserInput?: Record; +} + +/** + * The four calls your backend provides. Each wraps one Base44 REST call with your + * credentials, so the hook never holds a workspace key or an access token, and the + * backend can be written in any language: + * + * - `createApp`: `POST /api/apps` with the first prompt as `initial_message`. + * - `openLiveSession`: `POST /api/service/socket-sessions` with `{ app_ids: [appId] }`. + * - `sendMessage`: `POST /api/apps/{id}/chat/message`. + * - `submitToolCallInput`: `POST /api/apps/{id}/chat/submit-tool-call-input`, with an + * `X-Request-ID` derived from the tool call id so a retry resumes the turn only once. + * + * A rejected promise from any of them lands in {@link Base44Chat.error}. + */ +export interface Base44ChatServer { + /** Creates an app from its first prompt. The build starts inside this call. */ + createApp(prompt: string): Promise; + /** Opens a read-only live-updates session for one app. */ + openLiveSession(appId: string): Promise; + /** Sends a prompt to an existing app. */ + sendMessage(appId: string, content: string): Promise; + /** Answers, or declines, a question the builder waits on. */ + submitToolCallInput(appId: string, answer: ToolCallAnswer): Promise; +} + +/** One message of the chat, in a shape made for rendering. */ +export interface ChatItem { + /** Message identifier. */ + id: string; + /** Who wrote it. */ + role: "user" | "assistant"; + /** The message text; empty when the message is only tool activity. */ + text: string; + /** The builder's tool calls in this message, in order. */ + steps: ChatStep[]; + /** The question this message waits on, bound to the actions that answer it. Absent otherwise. */ + question?: Question; +} + +/** One tool call, as a line of activity. */ +export interface ChatStep { + /** Tool call identifier. */ + id: string; + /** The tool's name followed by the file paths it touched, if any. */ + label: string; + /** Where the call is: `waiting` means it waits on the user. */ + status: "running" | "waiting" | "done" | "error"; +} + +/** + * An open question, with the actions that answer this question and no other. + * Each kind carries only the actions that make sense for it, and every kind can be declined. + * The actions never throw: a failure lands in {@link Base44Chat.error} and the question opens again. + */ +export type Question = ChoiceQuestion | InputQuestion | ApprovalQuestion | UnknownQuestion; + +/** One of the questions a `choice` asks. */ +export interface ChoiceEntry { + /** The question text. */ + text: string; + /** Optional supporting text. */ + description?: string; + /** The option labels to pick from. */ + options: string[]; + /** Whether more than one option may be picked. */ + multi: boolean; +} + +/** The builder offers options and wants a pick. */ +export interface ChoiceQuestion { + /** Discriminator. */ + kind: "choice"; + /** The questions asked, in order. */ + questions: ChoiceEntry[]; + /** Answers with the picked labels: one list per question, in the same order as `questions`. An empty list skips that question. */ + answer(picked: string[][]): Promise; + /** Declines: the builder continues without the answer. */ + decline(): Promise; +} + +/** A value the builder needs, such as an API key. */ +export interface InputField { + /** The field name, used as the key in `submit`. */ + name: string; + /** Optional explanation of where to obtain the value. */ + description?: string; +} + +/** The builder needs values, such as API keys. They go to Base44 and are never kept here. */ +export interface InputQuestion { + /** Discriminator, as the wire names it. */ + kind: "input"; + /** The fields to fill. */ + fields: InputField[]; + /** Submits the values, keyed by field name. */ + submit(values: Record): Promise; + /** Declines: the builder continues without the values. */ + decline(): Promise; +} + +/** The builder wants permission to run a proposed action. */ +export interface ApprovalQuestion { + /** Discriminator. */ + kind: "approval"; + /** The tool that wants to run. */ + action: string; + /** Why it was parked, when the guard says; empty otherwise. */ + reason: string; + /** Lets the action run. */ + approve(): Promise; + /** Declines: the action never runs and the builder continues. */ + decline(): Promise; +} + +/** A question kind Base44 added after this release. It can only be declined. */ +export interface UnknownQuestion { + /** Discriminator. */ + kind: "unknown"; + /** The tool that waits. */ + action: string; + /** Declines: the builder continues without it. */ + decline(): Promise; +} + +/** What the chat is doing. One phase at a time; an error is separate and stays until cleared. */ +export type ChatPhase = "idle" | "creating" | "loading" | "waiting" | "building"; + +/** A failure of a server call or of the live connection. */ +export interface ChatError { + /** What went wrong, for people. */ + message: string; + /** A `PlatformSocketErrorCode` for connection errors; absent for server-call errors. */ + code?: string; +} + +/** Options of {@link useBase44Chat}. */ +export interface Base44ChatOptions { + /** The app to watch, or `null` when no app is selected yet and there is nothing to connect to. */ + appId: string | null; + /** Your backend's four calls. Read on every action, so an inline object is fine and never reopens the session. */ + server: Base44ChatServer; + /** Called with the new app after {@link Base44Chat.create} succeeds. The latest one is called, so an inline arrow is fine. */ + onAppCreated?: (app: Base44App) => void; +} + +/** What {@link useBase44Chat} returns. */ +export interface Base44Chat { + /** The conversation, oldest first, with open questions bound to their actions. */ + items: ChatItem[]; + /** What the chat is doing now. */ + phase: ChatPhase; + /** The last failure, until {@link Base44Chat.clearError} or the next action clears it. */ + error: ChatError | null; + /** Whether the input should accept a prompt. False while an app is being created and while + * a question is open: Base44 drops a message sent into a stopped turn. */ + canSend: boolean; + /** Sends a prompt to the current app. Resolves after the request is accepted; the reply arrives through `items`. */ + send(prompt: string): Promise; + /** Creates a new app from its first prompt and reports it through `onAppCreated`. */ + create(prompt: string): Promise; + /** Clears {@link Base44Chat.error}. */ + clearError(): void; + /** Base44's raw messages, sorted, for UIs that map them themselves. */ + messages: ChatMessage[]; +} diff --git a/examples/white-label-minimal/lean/sdk/components.types.ts b/examples/white-label-minimal/lean/sdk/components.types.ts new file mode 100644 index 0000000..4d8493b --- /dev/null +++ b/examples/white-label-minimal/lean/sdk/components.types.ts @@ -0,0 +1,80 @@ +import type { ComponentType, ReactNode } from "react"; +import type { + ApprovalQuestion, Base44Chat, Base44ChatOptions, ChatItem, ChatStep, ChoiceQuestion, InputQuestion, Question, UnknownQuestion, +} from "./chat.types"; + +/** Props of the `Text` part: one message's text. */ +export interface TextProps { + /** The message text. Never empty: an item without text renders no `Text`. */ + text: string; + /** Who wrote it, to align or color it. */ + role: ChatItem["role"]; +} + +/** Props of the `Step` part: one tool call of a message. */ +export type StepProps = ChatStep; + +/** The parts of a message's own content. */ +export interface MessageComponents { + /** Draws a message's text. */ + Text: ComponentType; + /** Draws one tool call as a line of activity. */ + Step: ComponentType; +} + +/** + * One part per question kind. Each receives the question itself: its content and the actions + * bound to it, so a part can only ever answer the question it draws. + */ +export interface QuestionComponents { + /** Draws a pick between options. Call `answer` with one list of labels per question. */ + Choice: ComponentType; + /** Draws a form for values such as API keys. Call `submit` with the values keyed by field name. */ + Input: ComponentType; + /** Draws a request to run an action. Call `approve` or `decline`. */ + Approval: ComponentType; + /** Draws a kind added after this release. It can only `decline`. */ + Unknown: ComponentType; +} + +/** Every replaceable part, grouped the way the library draws them. */ +export interface ChatComponents { + /** The parts of a message. */ + message: MessageComponents; + /** The parts of an open question. */ + question: QuestionComponents; +} + +/** The parts you replace. Anything left out is drawn as plain, unstyled HTML. */ +export interface ChatComponentOverrides { + /** Replacements for message parts. */ + message?: Partial; + /** Replacements for question parts. */ + question?: Partial; +} + +/** What {@link useChatState} returns: everything that changes as the chat runs. */ +export type ChatState = Pick; + +/** What {@link useChatActions} returns: the actions, with the same identity for the life of the provider. */ +export type ChatActions = Pick; + +/** Props of {@link Base44ChatProvider}: the {@link useBase44Chat} options, the parts, and the tree that reads them. */ +export interface Base44ChatProviderProps extends Base44ChatOptions { + /** Parts to replace. Read part by part, so an inline object is fine. */ + components?: ChatComponentOverrides; + /** 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} or {@link useBase44Chat}. */ + item: ChatItem; +} + +/** Props of {@link Question}. */ +export interface QuestionProps { + /** The open question to draw, from {@link ChatItem.question}. */ + question: Question; +} diff --git a/examples/white-label-minimal/lean/sdk/context.ts b/examples/white-label-minimal/lean/sdk/context.ts new file mode 100644 index 0000000..2c46510 --- /dev/null +++ b/examples/white-label-minimal/lean/sdk/context.ts @@ -0,0 +1,77 @@ +"use client"; +import { createContext, useContext } from "react"; +import type { ChatActions, ChatComponents, ChatState } from "./components.types"; +import { fallbackComponents } from "./fallbacks"; + +/** @internal Filled by {@link Base44ChatProvider}. State changes on every event; actions and parts do not. */ +export const StateContext = createContext(null); +/** @internal */ +export const ActionsContext = createContext(null); +/** @internal */ +export const ComponentsContext = createContext(null); + +/** + * Reads the chat's state from the nearest {@link Base44ChatProvider}: the items, the phase, + * the error and `canSend`. 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) => ); + * } + * ``` + */ +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 for the life of the provider, so a component that + * reads only actions never re-renders because a message arrived. + * + * @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 ; + * } + * ``` + */ +export function useChatActions(): ChatActions { + return inside(useContext(ActionsContext), "useChatActions"); +} + +/** + * Reads the parts from the nearest {@link Base44ChatProvider}, merged with the unstyled + * fallbacks. Outside a provider it returns the fallbacks, so {@link Message} also works over + * {@link useBase44Chat} alone. + * + * @returns Every part, with the provider's replacements applied. + * + * @example + * ```tsx + * // A part that draws another part + * function MyStepList({ steps }: { steps: ChatStep[] }) { + * const { message: { Step } } = useChatComponents(); + * return steps.map((step) => ); + * } + * ``` + */ +export function useChatComponents(): ChatComponents { + return useContext(ComponentsContext) ?? fallbackComponents; +} + +function inside(value: T | null, hook: string): T { + if (!value) throw new Error(`${hook} needs a above it`); + return value; +} diff --git a/examples/white-label-minimal/lean/sdk/fallbacks.tsx b/examples/white-label-minimal/lean/sdk/fallbacks.tsx new file mode 100644 index 0000000..25f2733 --- /dev/null +++ b/examples/white-label-minimal/lean/sdk/fallbacks.tsx @@ -0,0 +1,91 @@ +"use client"; +import { useId, useState } from "react"; +import type { ChatComponents, StepProps, TextProps } from "./components.types"; +import type { ApprovalQuestion, ChoiceQuestion, InputQuestion, UnknownQuestion } from "./chat.types"; + +// The parts a partner does not replace: plain HTML, no classes, so the library has no look of +// its own and a question is never silently missing. Each carries a `data-base44` attribute for +// CSS to hook on. + +function Text({ text, role }: TextProps) { + return

{text}

; +} + +function Step({ label, status }: StepProps) { + return

{label}

; +} + +function Choice({ questions, answer, decline }: ChoiceQuestion) { + const name = useId(); + const [picked, setPicked] = useState(() => questions.map(() => [])); + function toggle(i: number, option: string, multi: boolean) { + setPicked(all => + all.map((labels, j) => { + if (j !== i) return labels; + if (!multi) return [option]; + return labels.includes(option) ? labels.filter(o => o !== option) : [...labels, option]; + }), + ); + } + return ( +
{ e.preventDefault(); void answer(picked); }}> + {questions.map((q, i) => ( +
+ {q.text} + {q.description &&

{q.description}

} + {q.options.map(option => ( + + ))} +
+ ))} + + +
+ ); +} + +function Input({ fields, submit, decline }: InputQuestion) { + const [values, setValues] = useState>({}); + return ( +
{ e.preventDefault(); void submit(values); }}> + {fields.map(field => ( + + ))} + + +
+ ); +} + +function Approval({ action, reason, approve, decline }: ApprovalQuestion) { + return ( +
+

Allow {action}?

+ {reason &&

{reason}

} + + +
+ ); +} + +function Unknown({ action, decline }: UnknownQuestion) { + return ( +
+

{action} is waiting for you.

+ +
+ ); +} + +/** @internal */ +export const fallbackComponents: ChatComponents = { + message: { Text, Step }, + question: { Choice, Input, Approval, Unknown }, +}; diff --git a/examples/white-label-minimal/lean/sdk/index.ts b/examples/white-label-minimal/lean/sdk/index.ts index 81e9f19..abc4377 100644 --- a/examples/white-label-minimal/lean/sdk/index.ts +++ b/examples/white-label-minimal/lean/sdk/index.ts @@ -1,20 +1,15 @@ -// The headless chat library: what a consumer imports in the browser. -export { - useBase44Chat, - type Base44App, - type Base44Chat, - type Base44ChatServer, - type ChatItem, - type ChatStep, - type ToolCallAnswer, -} from "./useBase44Chat"; -export { Message, defaultMessageComponents, type MessageComponents, type TextProps, type StepProps } from "./Message"; -export { - Question, - defaultQuestionComponents, - type QuestionComponents, - type ChoiceProps, - type InputProps, - type ApprovalProps, - type UnknownProps, -} from "./Question"; +// A copy of packages/platform/src/react from base44/javascript-sdk#314 (option B), +// until @base44/platform/react is published. Replace this folder with that import then. +/** The `@base44/platform/react` entry: one app's builder chat as a hook, and a provider with parts you replace. React is an optional peer, loaded only here. */ +export { useBase44Chat } from "./useBase44Chat"; +export { Base44ChatProvider } from "./Base44ChatProvider"; +export { useChatActions, useChatComponents, useChatState } from "./context"; +export { Message, Question } from "./Message"; +export type { + ApprovalQuestion, Base44App, Base44Chat, Base44ChatOptions, Base44ChatServer, ChatError, ChatItem, ChatPhase, ChatStep, + ChoiceEntry, ChoiceQuestion, InputField, InputQuestion, LiveSession, Question as ChatQuestion, ToolCallAnswer, UnknownQuestion, +} from "./chat.types"; +export type { + Base44ChatProviderProps, ChatActions, ChatComponentOverrides, ChatComponents, ChatState, MessageComponents, MessageProps, + QuestionComponents, QuestionProps, StepProps, TextProps, +} from "./components.types"; diff --git a/examples/white-label-minimal/lean/sdk/ui/button.tsx b/examples/white-label-minimal/lean/sdk/ui/button.tsx deleted file mode 100644 index 18407c9..0000000 --- a/examples/white-label-minimal/lean/sdk/ui/button.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" -import { Slot } from "radix-ui" - -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : "button" - - return ( - - ) -} - -export { Button, buttonVariants } diff --git a/examples/white-label-minimal/lean/sdk/ui/input.tsx b/examples/white-label-minimal/lean/sdk/ui/input.tsx deleted file mode 100644 index 7aa671b..0000000 --- a/examples/white-label-minimal/lean/sdk/ui/input.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react" -import { cn } from "cn" - -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ) -} - -export { Input } diff --git a/examples/white-label-minimal/lean/sdk/useBase44Chat.ts b/examples/white-label-minimal/lean/sdk/useBase44Chat.ts index a328197..dfd3a9a 100644 --- a/examples/white-label-minimal/lean/sdk/useBase44Chat.ts +++ b/examples/white-label-minimal/lean/sdk/useBase44Chat.ts @@ -1,192 +1,160 @@ "use client"; -import { useEffect, useState } from "react"; -import { Base44PlatformClient, type ChatMessage, type ToolCall, type ToolQuestionArguments, type ToolSecretArguments } from "@base44/platform"; - -// The client half of the library: one app's chat, headless. It keeps the live -// connection, turns Base44's messages into items, and gives you the actions. -// You render the items. - -export type Base44App = { id: string; name: string }; - -// An answer to a question the builder waits on. Declining is approve: false. -export type ToolCallAnswer = { toolCallId: string; messageId: string; approve: boolean; extraUserInput?: object }; - -// The four calls your server provides. Each wraps one Base44 REST call with your -// credentials, in any backend language: -// createApp POST /api/apps -// openLiveSession POST /api/service/socket-sessions (returns the socket URL and session token) -// sendMessage POST /api/apps/{id}/chat/message -// submitToolCallInput POST /api/apps/{id}/chat/submit-tool-call-input -export type Base44ChatServer = { - createApp(prompt: string): Promise; - openLiveSession(appId: string): Promise<{ serverUrl: string; sessionToken: string }>; - sendMessage(appId: string, content: string): Promise; - submitToolCallInput(appId: string, answer: ToolCallAnswer): Promise; -}; - -export type ChatItem = { - id: string; - role: "user" | "assistant"; - text: string; - steps: ChatStep[]; - question?: Question; // set while the builder waits on you -}; - -export type ChatStep = { id: string; label: string; status: "running" | "waiting" | "done" | "error" }; - -export type Question = - | { kind: "choice"; questions: { text: string; options: string[]; multi: boolean }[] } - | { kind: "input"; fields: string[] } - | { kind: "approval"; action: string; reason: string } - | { kind: "unknown"; action: string }; - -export function useBase44Chat({ appId, server }: { - appId: string | null; // null: no app yet, so the first send creates one - server: Base44ChatServer; -}) { +/* eslint-disable react-hooks/refs -- The actions read `server` and `onAppCreated` through refs so their + identity stays stable with inline props. The refs are read only when an action runs, never during + render, but the rule cannot follow the bound question actions that `toItems` creates. */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Base44PlatformClient } from "@base44/platform"; +import type { ChatMessage } from "@base44/platform"; +import { mergeMessage, sortMessages, toItems } from "./chat-items"; +import type { Base44Chat, Base44ChatOptions, ChatError, ChatPhase } from "./chat.types"; + +/** + * Runs one app's builder chat, headless: it keeps the live connection through + * the platform client, turns Base44's messages into {@link ChatItem | items}, and binds + * every open question to the actions that answer it. You render the items. + * + * All Base44 calls go through the {@link Base44ChatServer | server} you pass, so no + * credential reaches the browser. Actions never throw: a failure lands in + * {@link Base44Chat.error}, and a question whose reply failed opens again. + * + * Requires a browser environment. One live session is opened per app; switching + * `appId` closes the old one and starts over. + * + * @param options - The app to watch, your backend's calls, and what to do with a created app. + * @returns The conversation, its phase and error, and the actions `send`, `create` and `clearError`. + * + * @example + * ```typescript + * // Render the items, answer a question + * const chat = useBase44Chat({ appId, server, onAppCreated: selectApp }); + * return ( + * <> + * {chat.items.map((item) => ( + *
+ *

{item.text}

+ * {item.steps.map((step) => {step.label})} + * {item.question?.kind === "approval" && ( + * + * )} + *
+ * ))} + * {chat.error &&

{chat.error.message}

} + * + * + * ); + * ``` + */ +export function useBase44Chat({ appId, server, onAppCreated }: Base44ChatOptions): Base44Chat { const [messages, setMessages] = useState([]); const [building, setBuilding] = useState(false); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(!!appId); const [creating, setCreating] = useState(false); - const [error, setError] = useState(""); + const [error, setError] = useState(null); const [answered, setAnswered] = useState([]); - // Live updates: a snapshot on every connect, then events. - useEffect(() => { + // Switching apps starts over: the state resets in the same render that sees the new id. + const [shownAppId, setShownAppId] = useState(appId); + if (shownAppId !== appId) { + setShownAppId(appId); setMessages([]); setBuilding(false); - setError(""); - setAnswered([]); setLoading(!!appId); + setError(null); + setAnswered([]); + } + + // The server and the callback are read through refs, kept current after every render, so an + // inline `server={{…}}` or arrow never reopens the socket and never changes an action's identity. + const serverRef = useRef(server); + const onAppCreatedRef = useRef(onAppCreated); + useEffect(() => { + serverRef.current = server; + onAppCreatedRef.current = onAppCreated; + }); + + const fail = useCallback((e: { code?: string; message?: string }) => setError({ message: e.message || "Something went wrong", code: e.code }), []); + + // Live updates: a snapshot on every connect, then events. + useEffect(() => { if (!appId) return; let stopped = false; - let socket: { close(): void } | undefined; - const fail = (e: { code?: string; message?: string }) => setError(e.code || e.message || "Live updates failed"); + let session: { close(): void } | undefined; + const failLive = (e: { code?: string; message?: string }) => fail({ message: e.message || "Live updates failed", code: e.code }); - server + serverRef.current .openLiveSession(appId) .then(({ serverUrl, sessionToken }) => { if (stopped) return; - const builder = new Base44PlatformClient({ serverUrl, getSessionToken: () => sessionToken }).builder.init({ onError: fail }); - socket = builder; + const builder = new Base44PlatformClient({ serverUrl, getSessionToken: () => sessionToken }).builder.init({ onError: failLive }); + session = builder; builder.subscribe(appId, { - onSnapshot: (snapshot) => { + onSnapshot: snapshot => { setMessages(snapshot.messages); setLoading(false); setBuilding(snapshot.status?.state === "processing"); }, - onEvent: (event) => { - if (event.type === "message.updated") { - const message = event.data.message; - // Replace the message with the same id in place, or add it at the end. - setMessages((all) => (all.some((m) => m.id === message.id) ? all.map((m) => (m.id === message.id ? message : m)) : [...all, message])); - } + onEvent: event => { + if (event.type === "message.updated") setMessages(all => mergeMessage(all, event.data.message)); + if (event.type === "message.removed") setMessages(all => all.filter(m => m.id !== event.data.message_id)); if (event.type === "app.status_changed") setBuilding(event.data.status?.state === "processing"); }, - onError: fail, + onError: failLive, }); return builder.connect(); }) - .catch(fail); + .catch(failLive); return () => { stopped = true; - socket?.close(); + session?.close(); }; - }, [appId, server]); - - // Events can arrive out of order, so order messages by when they were created. - const sorted = [...messages].sort((a, b) => (a.metadata?.created_date ?? "").localeCompare(b.metadata?.created_date ?? "")); - const isOpen = (tool: ToolCall) => tool.status === "waiting_for_user_input" && !answered.includes(tool.id!); - - const items: ChatItem[] = sorted.map((m) => { - const waiting = m.tool_calls?.find(isOpen); - return { - id: m.id!, - role: m.role ?? "assistant", - text: m.content ?? "", - steps: (m.tool_calls ?? []).map((tool) => ({ - id: tool.id!, - label: [tool.name, ...(tool.display?.file_paths ?? [])].join(" "), - status: stepStatus(tool), - })), - question: waiting && toQuestion(waiting), - }; - }); - const pending = sorted.flatMap((m) => (m.tool_calls ?? []).filter(isOpen).map((tool) => ({ tool, messageId: m.id! })))[0]; - - let status = error; - if (!status && creating) status = "Creating app…"; - else if (!status && loading) status = "Loading the conversation…"; - else if (!status && pending) status = "Waiting for your answer"; - else if (!status && building) status = "Building…"; - - // The first prompt creates the app and returns it; every later one goes to it. - async function send(prompt: string): Promise { - if (appId) { - server.sendMessage(appId, prompt).catch(fail); - return; - } - setCreating(true); - try { - return await server.createApp(prompt); - } catch (e) { - fail(e as Error); - } finally { - setCreating(false); - } - } - - // Answers the open question. Declining is always an answer. - function reply(approve: boolean, extraUserInput?: object) { - if (!appId || !pending) return; - setAnswered((ids) => [...ids, pending.tool.id!]); - server.submitToolCallInput(appId, { toolCallId: pending.tool.id!, messageId: pending.messageId, approve, extraUserInput }).catch(fail); - } - - function fail(e: { message?: string }) { - setError(e.message || "Something went wrong"); - } - - return { - items, - status, - building, - canSend: !creating && !pending, - send, - answerChoices: (picked: string[][]) => - reply(true, { answers: picked.map((labels, i) => ({ question_index: i, selected_labels: labels })).filter((a) => a.selected_labels.length) }), - submitInputs: (values: Record) => reply(true, { secrets: values }), - approve: () => reply(true), - decline: () => reply(false), - messages: sorted, // Base44's raw messages, for UIs that map them themselves - }; -} - -export type Base44Chat = ReturnType; - -function stepStatus(tool: ToolCall): ChatStep["status"] { - if (tool.status === "running") return "running"; - if (tool.status === "waiting_for_user_input") return "waiting"; - if (tool.status === "error") return "error"; - return "done"; -} - -// Base44's question formats, as one simple shape per kind. -function toQuestion(tool: ToolCall): Question { - const kind = tool.waiting_on?.kind; - if (kind === "choice") { - const questions = (tool.arguments as ToolQuestionArguments)?.questions ?? []; - return { - kind, - questions: questions.map((q) => ({ - text: q.question ?? "", - options: (q.options ?? []).map((o) => (typeof o === "string" ? o : o.label ?? "")), - multi: !!q.multi_select, - })), - }; - } - if (kind === "input") return { kind, fields: ((tool.arguments as ToolSecretArguments)?.secrets_schema ?? []).map((f) => f.secretName ?? "") }; - if (kind === "approval") return { kind, action: tool.name ?? "", reason: tool.approval?.reason ?? tool.approval?.details?.summary ?? "" }; - return { kind: "unknown", action: tool.name ?? "" }; + }, [appId, fail]); + + // Answers one tool call. Its id leaves `answered` again if the submit fails, so the question comes back. + const respond = useCallback( + (toolCallId: string, messageId: string, approve: boolean, extraUserInput?: Record) => { + if (!appId) return Promise.resolve(); + setError(null); + setAnswered(ids => [...ids, toolCallId]); + return serverRef.current.submitToolCallInput(appId, { toolCallId, messageId, approve, extraUserInput }).catch((e: Error) => { + setAnswered(ids => ids.filter(id => id !== toolCallId)); + fail(e); + }); + }, + [appId, fail], + ); + + const sorted = useMemo(() => sortMessages(messages), [messages]); + const items = useMemo(() => toItems(sorted, answered, respond), [sorted, answered, respond]); + + const waiting = items.some(item => item.question); + const phase: ChatPhase = creating ? "creating" : loading ? "loading" : waiting ? "waiting" : building ? "building" : "idle"; + + const send = useCallback( + (prompt: string) => { + if (!appId) return Promise.resolve(); + setError(null); + return serverRef.current.sendMessage(appId, prompt).catch(fail); + }, + [appId, fail], + ); + + const create = useCallback( + async (prompt: string) => { + setError(null); + setCreating(true); + try { + onAppCreatedRef.current?.(await serverRef.current.createApp(prompt)); + } catch (e) { + fail(e as Error); + } finally { + setCreating(false); + } + }, + [fail], + ); + + const clearError = useCallback(() => setError(null), []); + + return { items, phase, error, canSend: !creating && !waiting, send, create, clearError, messages: sorted }; }