diff --git a/examples/white-label-minimal/app/tiny.css b/examples/white-label-minimal/app/tiny.css index 8db50de..27ab0b7 100644 --- a/examples/white-label-minimal/app/tiny.css +++ b/examples/white-label-minimal/app/tiny.css @@ -52,8 +52,34 @@ .chat-user-text { @apply ml-auto max-w-[85%] rounded-lg bg-secondary px-3 py-2; } +.chat-pending { + @apply opacity-60; +} .chat-assistant-text { - @apply whitespace-pre-wrap; + @apply flex flex-col gap-2; +} +.chat-assistant-text ul { + @apply list-disc pl-5; +} +.chat-assistant-text ol { + @apply list-decimal pl-5; +} +.chat-assistant-text h1, +.chat-assistant-text h2, +.chat-assistant-text h3 { + @apply font-semibold; +} +.chat-assistant-text code { + @apply rounded bg-secondary px-1 font-mono text-xs; +} +.chat-assistant-text pre { + @apply overflow-x-auto rounded bg-secondary p-2; +} +.chat-assistant-text pre code { + @apply bg-transparent p-0; +} +.chat-assistant-text a { + @apply underline; } .chat-tool-line { @apply text-xs text-muted-foreground; @@ -80,6 +106,9 @@ .question-options { @apply flex flex-wrap gap-2; } +.chat-answer { + @apply ml-4 text-xs text-muted-foreground; +} .question-actions { @apply flex gap-2; } diff --git a/examples/white-label-minimal/components/Chat.tsx b/examples/white-label-minimal/components/Chat.tsx index b852d17..ca8aa46 100644 --- a/examples/white-label-minimal/components/Chat.tsx +++ b/examples/white-label-minimal/components/Chat.tsx @@ -1,28 +1,36 @@ "use client"; import { useEffect, useState } from "react"; -import type { ToolCall } from "@base44/platform"; +import { useChat, type UseChatResult, type ChatBackend, type ChatItem, type ChatStep } from "@base44/platform/react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import Markdown from "react-markdown"; import { StickToBottom } from "use-stick-to-bottom"; -import type { App } from "../server/base44"; +import { createApp, openLiveSession, sendMessage, submitToolCallInput, type App } from "../server/base44"; import ToolQuestion from "./ToolQuestion"; -import { useLiveChat } from "./useLiveChat"; + +// The four Base44 calls, run on the server. The hook calls them, so the browser never holds a key. +const backend: ChatBackend = { createApp, openLiveSession, sendMessage, submitToolCallInput }; export default function Chat({ app, onAppCreated, onBuildingChange }: { app: App | null; onAppCreated: (app: App) => void; onBuildingChange: (building: boolean) => void; }) { - const { messages, status, building, canSend, send } = useLiveChat({ app, onAppCreated }); + const chat = useChat({ appId: app?.id ?? null, backend, onAppCreated }); const [prompt, setPrompt] = useState(""); + const building = chat.status === "building"; + // Send only when the AI can take a prompt now. After a failed build, a prompt can ask for the fix. + const canSend = chat.status === "ready" || chat.status === "error"; useEffect(() => { onBuildingChange(building); }, [building, onBuildingChange]); + // The first prompt creates the app; every later one goes to it. function submit() { - if (!prompt.trim() || !canSend) return; - send(prompt); + if (!canSend || !prompt.trim()) return; + if (app) chat.send(prompt); + else chat.create(prompt); setPrompt(""); } @@ -30,21 +38,31 @@ export default function Chat({ app, onAppCreated, onBuildingChange }: {
- {messages.map((m) => ( -
- {m.content && ( -

{m.content}

+ {chat.items.map((item) => ( +
+ {item.text && item.role === "user" &&

{item.text}

} + {/* The AI answers in Markdown. */} + {item.text && item.role === "assistant" && ( + )} - {m.tool_calls?.map((tool) => - tool.status === "waiting_for_user_input" && app ? ( - + {item.steps.map((step) => + step.question ? ( + ) : ( -

{toolLine(tool)}

+
+

{stepIcon(step)} {step.label}

+ {/* What the user answered stays after the question closes. */} + {step.answered?.map((answer, i) => ( +

{answer.question}: {[...answer.labels, answer.text].filter(Boolean).join(", ")}

+ ))} +
), )}
))} - {status &&

{status}

} +
@@ -60,14 +78,39 @@ export default function Chat({ app, onAppCreated, onBuildingChange }: { className="chat-input" rows={1} /> - +
); } -// One tool step as a short line, e.g. "✓ write_file src/pages/Home.jsx". -function toolLine(tool: ToolCall) { - const icon = tool.status === "running" ? "…" : tool.status === "error" ? "✗" : "✓"; - return `${icon} ${tool.name} ${tool.display?.file_paths?.join(", ") ?? ""}`; +// A prompt Base44 hasn't confirmed yet shows faded. +function userTextClass(item: ChatItem) { + if (item.pending) return "chat-user-text chat-pending"; + return "chat-user-text"; +} + +function stepIcon(step: ChatStep) { + if (step.status === "running") return "…"; + if (step.status === "waiting") return "?"; + if (step.status === "error") return "✗"; + if (step.status === "stopped") return "–"; + return "✓"; +} + +// Tiny's wording for what the hook reports. +function StatusLine({ chat, hasApp }: { chat: UseChatResult; hasApp: boolean }) { + let text = ""; + if (chat.error) text = chat.error.message; + else if (chat.status === "submitted") text = hasApp ? "Sending…" : "Creating app…"; + else if (chat.status === "waiting") text = "Waiting for your answer"; + else if (chat.status === "building") text = "Building…"; + else if (hasApp && chat.connection === "connecting") text = "Loading the conversation…"; + if (!text) return null; + return ( +

+ {text} + {chat.error && } +

+ ); } diff --git a/examples/white-label-minimal/components/ToolQuestion.tsx b/examples/white-label-minimal/components/ToolQuestion.tsx index d215f0d..b1f962b 100644 --- a/examples/white-label-minimal/components/ToolQuestion.tsx +++ b/examples/white-label-minimal/components/ToolQuestion.tsx @@ -1,60 +1,100 @@ "use client"; import { useState } from "react"; -import type { ToolCall, ToolQuestionArguments, ToolSecretArguments } from "@base44/platform"; +import type { ApprovalQuestion, ChatQuestion, ChoiceQuestion, SecretsQuestion } from "@base44/platform/react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { submitToolCallInput } from "../server/base44"; -// A tool step the builder waits on: a choice, values to enter (such as API keys), or an -// approval. Declining is always an answer. -export default function ToolQuestion({ appId, messageId, tool }: { appId: string; messageId: string; tool: ToolCall }) { - const [sent, setSent] = useState(false); - const [picked, setPicked] = useState>({}); - const [secrets, setSecrets] = useState>({}); - const kind = tool.waiting_on?.kind; - const questions = kind === "choice" ? (tool.arguments as ToolQuestionArguments)?.questions ?? [] : []; - // Tiny passes these values to Base44 and keeps none. - const fields = kind === "input" ? (tool.arguments as ToolSecretArguments)?.secrets_schema ?? [] : []; +// A question the AI waits on: a choice (options, or free text), secrets to enter (such as API +// keys), or an approval. Its actions are already bound to its tool call, and declining is always +// an answer. +export default function ToolQuestion({ question }: { question: ChatQuestion }) { + if (question.kind === "choice") return ; + if (question.kind === "secrets") return ; + return ; +} - function answer(approve: boolean, extraUserInput?: object) { - setSent(true); - submitToolCallInput(appId, { toolCallId: tool.id!, messageId, approve, extraUserInput }).catch(() => setSent(false)); - } +function Choice({ question }: { question: ChoiceQuestion }) { + const [picked, setPicked] = useState(question.questions.map(() => [])); + const [texts, setTexts] = useState([]); - function pick(index: number, label: string, multi?: boolean) { - const current = picked[index] ?? []; - const next = !multi ? [label] : current.includes(label) ? current.filter((l) => l !== label) : [...current, label]; - setPicked({ ...picked, [index]: next }); + function pick(index: number, label: string, multi: boolean) { + const current = picked[index]; + let next = [label]; + if (multi && current.includes(label)) next = current.filter((l) => l !== label); + else if (multi) next = [...current, label]; + setPicked(picked.map((labels, i) => (i === index ? next : labels))); } - function submit() { - if (kind === "choice") answer(true, { answers: questions.map((_, i) => ({ question_index: i, selected_labels: picked[i] ?? [] })) }); - else if (kind === "input") answer(true, { secrets }); - else answer(true); + function write(index: number, text: string) { + const next = [...texts]; + next[index] = text; + setTexts(next); } return (
- {kind === "approval" &&

Allow {tool.name}? {tool.approval?.reason ?? tool.approval?.details?.summary}

} - {questions.map((q, i) => ( + {question.questions.map((q, i) => (
-

{q.question}

-
- {(q.options ?? []).map((o) => (typeof o === "string" ? o : o.label ?? "")).map((label) => ( - - ))} -
+

{q.text}

+ {q.type === "text_input" ? ( + write(i, e.target.value)} /> + ) : ( +
+ {q.options.map((label) => ( + + ))} +
+ )}
))} - {fields.map(({ secretName = "" }) => ( - setSecrets({ ...secrets, [secretName]: e.target.value })} /> + question.answer(picked, texts)} onDecline={question.decline} /> +
+ ); +} + +function Secrets({ question }: { question: SecretsQuestion }) { + // Tiny passes these values to Base44 and keeps none. + const [values, setValues] = useState>({}); + + return ( +
+ {question.fields.map(({ name }) => ( + setValues({ ...values, [name]: e.target.value })} /> ))} -
- - -
+ question.submit(values)} onDecline={question.decline} /> +
+ ); +} + +function Approval({ question }: { question: ApprovalQuestion }) { + return ( +
+

Allow {question.action}? {question.reason}

+ +
+ ); +} + +function Actions({ submitLabel, onSubmit, onDecline }: { + submitLabel: string; + onSubmit: () => Promise; + onDecline: () => Promise; +}) { + const [sent, setSent] = useState(false); + + // A failed answer opens the question again, so the buttons come back. + async function send(action: () => Promise) { + setSent(true); + await action(); + setSent(false); + } + + return ( +
+ +
); } diff --git a/examples/white-label-minimal/components/Workspace.tsx b/examples/white-label-minimal/components/Workspace.tsx index 734ab99..62f772f 100644 --- a/examples/white-label-minimal/components/Workspace.tsx +++ b/examples/white-label-minimal/components/Workspace.tsx @@ -32,7 +32,7 @@ export default function Workspace() {
- +
); } diff --git a/examples/white-label-minimal/components/useLiveChat.ts b/examples/white-label-minimal/components/useLiveChat.ts deleted file mode 100644 index 1cd7022..0000000 --- a/examples/white-label-minimal/components/useLiveChat.ts +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; -import { useEffect, useState } from "react"; -import { Base44PlatformClient, type ChatMessage } from "@base44/platform"; -import { createApp, openLiveSession, sendMessage, type App } from "../server/base44"; - -// The chat's state: the live connection to Base44, message ordering, create-or-send, -// and the status line. -export function useLiveChat({ app, onAppCreated }: { - app: App | null; - onAppCreated: (app: App) => void; -}) { - const appId = app?.id ?? null; - const [messages, setMessages] = useState([]); - const [building, setBuilding] = useState(false); - const [loading, setLoading] = useState(!!appId); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(""); - - // 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"); - - openLiveSession(appId) - .then(({ serverUrl, sessionToken }) => { - if (stopped) return; - const builder = new Base44PlatformClient({ serverUrl, getSessionToken: () => sessionToken }).builder.init({ onError: fail }); - socket = builder; - builder.subscribe(appId, { - 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])); - } - if (event.type === "app.status_changed") setBuilding(event.data.status?.state === "processing"); - }, - onError: fail, - }); - return builder.connect(); - }) - .catch(fail); - - return () => { - stopped = true; - socket?.close(); - }; - }, [appId]); - - // 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 ?? "")); - // Lock the message box while a question is open or a build runs. - const waiting = sorted.some((m) => m.tool_calls?.some((tool) => tool.status === "waiting_for_user_input")); - - let status = error; - if (!status && creating) status = "Creating app…"; - else if (!status && loading) status = "Loading the conversation…"; - else if (!status && waiting) status = "Waiting for your answer"; - else if (!status && building) status = "Building…"; - - // The first prompt creates the app; every later one goes to it. - async function send(prompt: string) { - if (appId) { - setBuilding(true); - sendMessage(appId, prompt).catch((e) => { - setBuilding(false); - setError(e.message); - }); - return; - } - setCreating(true); - try { - onAppCreated(await createApp(prompt)); - } catch (e) { - setError((e as Error).message); - } - setCreating(false); - } - - return { messages: sorted, status, building, canSend: !creating && !waiting && !building, send }; -} diff --git a/examples/white-label-minimal/netlify.toml b/examples/white-label-minimal/netlify.toml index bb91561..084491b 100644 --- a/examples/white-label-minimal/netlify.toml +++ b/examples/white-label-minimal/netlify.toml @@ -7,7 +7,7 @@ [build.environment] NODE_VERSION = "24" - # This example pins @base44/platform 0.1.0, outside the root's 0.3.0 range, so - # npm installs it under the example's own node_modules, which the secrets - # scanner would otherwise read. Installed packages are never served. + # When this example's @base44/platform range leaves the root's, npm installs it + # under the example's own node_modules, which the secrets scanner would + # otherwise read. Installed packages are never served. SECRETS_SCAN_OMIT_PATHS = "examples/white-label-minimal/node_modules/**" diff --git a/examples/white-label-minimal/package.json b/examples/white-label-minimal/package.json index d3226cd..372ad2a 100644 --- a/examples/white-label-minimal/package.json +++ b/examples/white-label-minimal/package.json @@ -9,7 +9,7 @@ "typecheck": "next typegen && tsc --noEmit" }, "dependencies": { - "@base44/platform": "^0.1.0", + "@base44/platform": "^0.5.0", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", "lucide-react": "^0.475.0", @@ -17,6 +17,7 @@ "radix-ui": "^1.6.7", "react": "19.2.8", "react-dom": "19.2.8", + "react-markdown": "^9.0.1", "shadcn": "^4.21.0", "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.6" diff --git a/examples/white-label-minimal/server/base44.ts b/examples/white-label-minimal/server/base44.ts index d32af4b..12c8261 100644 --- a/examples/white-label-minimal/server/base44.ts +++ b/examples/white-label-minimal/server/base44.ts @@ -1,4 +1,5 @@ "use server"; +import type { SocketSession } from "@base44/platform/react"; export type App = { id: string; name: string }; export type ToolCallAnswer = { toolCallId: string; messageId: string; approve: boolean; extraUserInput?: object }; @@ -29,17 +30,18 @@ export async function listApps(): Promise { .map(({ id, name }) => ({ id, name })); } -export async function openLiveSession(appId: string) { - const session = await call("/api/v1/socket-sessions", { body: { app_ids: [appId] } }); - return { serverUrl: new URL(session.socket_url).origin, sessionToken: session.session_token as string }; +// The body holds only a session token, never the access token, so it goes to the browser unchanged. +export async function openLiveSession(appId: string): Promise { + return call("/api/v1/socket-sessions", { body: { app_ids: [appId] } }); } export async function sendMessage(appId: string, content: string) { - await call(`/api/apps/${appId}/chat/message`, { body: { content } }); + const sent = request(`/api/apps/${appId}/chat/message`, { body: { content } }); + await waitForRefusal("/chat/message", sent); } export async function submitToolCallInput(appId: string, answer: ToolCallAnswer) { - await call(`/api/apps/${appId}/chat/submit-tool-call-input`, { + const sent = request(`/api/apps/${appId}/chat/submit-tool-call-input`, { body: { tool_call_id: answer.toolCallId, message_id: answer.messageId, @@ -48,6 +50,7 @@ export async function submitToolCallInput(appId: string, answer: ToolCallAnswer) }, headers: { ...accessToken, "X-Request-ID": `submit-${answer.toolCallId}` }, }); + await waitForRefusal("/chat/submit-tool-call-input", sent); } // A new app has no latest preview until its first build gives it a slug: Base44 answers 400 @@ -64,6 +67,24 @@ export async function getPreviewUrl(appId: string, target: "latest_preview" | "l return data.embed_url as string; } +// Base44 holds a chat request open for the whole turn, and Next.js runs one server action at a time, +// so waiting for it would hold every later call. Wait only long enough to catch a refusal. +async function waitForRefusal(name: string, sent: Promise) { + const timeout = new Promise((resolve) => setTimeout(resolve, 3000, null)); + const response = await Promise.race([sent, timeout]); + if (response === null) { + // The caller has moved on, so a later failure can only be logged. + sent.then( + (late) => { + if (!late.ok) console.error(`Base44 ${name} failed: ${late.status}`); + }, + (error) => console.error(`Base44 ${name} failed:`, error), + ); + return; + } + if (!response.ok) throw new Error(`Base44 ${name} failed: ${response.status}`); +} + function request(path: string, { body, headers = accessToken }: Options = {}) { return fetch(`${host}${path}`, { method: body ? "POST" : "GET", diff --git a/package-lock.json b/package-lock.json index 59375e0..f90754b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "examples/white-label-minimal" ], "dependencies": { - "@base44/platform": "^0.3.0", + "@base44/platform": "^0.5.0", "@hello-pangea/dnd": "^18.0.1", "@prisma/client": "^6.19.3", "@radix-ui/react-checkbox": "^1.1.4", @@ -51,41 +51,11 @@ "typescript": "^5" } }, - "examples/tiny-sunny": { - "name": "@base44/tiny-sunny", - "version": "0.1.0", - "extraneous": true, - "dependencies": { - "@assistant-ui/react": "^0.15.21", - "@assistant-ui/react-markdown": "^0.14.16", - "@base44/platform": "^0.1.0", - "@chatscope/chat-ui-kit-react": "^2.1.1", - "@chatscope/chat-ui-kit-styles": "^1.4.0", - "class-variance-authority": "^0.7.1", - "cn": "^0.4.0", - "lucide-react": "^0.475.0", - "next": "16.3.0", - "radix-ui": "^1.6.7", - "react": "19.2.8", - "react-dom": "19.2.8", - "shadcn": "^4.21.0", - "tw-animate-css": "^1.4.0", - "tw-shimmer": "^0.4.13" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.3.3", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "tailwindcss": "^4.3.3", - "typescript": "^5" - } - }, "examples/white-label-minimal": { "name": "@base44/white-label-minimal", "version": "0.1.0", "dependencies": { - "@base44/platform": "^0.1.0", + "@base44/platform": "^0.5.0", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", "lucide-react": "^0.475.0", @@ -93,6 +63,7 @@ "radix-ui": "^1.6.7", "react": "19.2.8", "react-dom": "19.2.8", + "react-markdown": "^9.0.1", "shadcn": "^4.21.0", "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.6" @@ -109,15 +80,6 @@ "node": "^22 || ^24 || >=26" } }, - "examples/white-label-minimal/node_modules/@base44/platform": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.1.0.tgz", - "integrity": "sha512-6InHb3mM2RPLqU42atFprwkkjR1Osu0CFQpAmloD1sAv7EfDg0h4rGMuUtSB5EZJLJiz4f6DaQ44dP/lR7mjtQ==", - "license": "MIT", - "dependencies": { - "socket.io-client": "^4.8.3" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -575,9 +537,9 @@ } }, "node_modules/@base44/platform": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.3.0.tgz", - "integrity": "sha512-BlBWUIEdYLH0po4LFMzKPo1mIOKUv8bo9z3LmyABSEv/2AXGP/fIN4KxZl5DBYGlgnVf8h3aQeY36XT4eHnWVQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.5.0.tgz", + "integrity": "sha512-Mn6t1YjQFpHPtXLbl1h+c6aJQVg6n1CQQkWpQ0Ygzx7dQ+XNKZYzMjjhy0CYIDmfugRiV0xJ9foF/vVRgmBZGQ==", "license": "MIT", "dependencies": { "socket.io-client": "^4.8.3" diff --git a/package.json b/package.json index e099722..25ceb4c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "minimal:typecheck": "npm run typecheck --workspace @base44/white-label-minimal" }, "dependencies": { - "@base44/platform": "^0.3.0", + "@base44/platform": "^0.5.0", "@hello-pangea/dnd": "^18.0.1", "@prisma/client": "^6.19.3", "@radix-ui/react-checkbox": "^1.1.4",