Skip to content
Merged
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
31 changes: 30 additions & 1 deletion examples/white-label-minimal/app/tiny.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
83 changes: 63 additions & 20 deletions examples/white-label-minimal/components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,68 @@
"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("");
}

return (
<div className="chat">
<StickToBottom className="chat-scroll" initial="instant">
<StickToBottom.Content className="chat-messages">
{messages.map((m) => (
<div key={m.id} className="chat-message">
{m.content && (
<p className={m.role === "user" ? "chat-user-text" : "chat-assistant-text"}>{m.content}</p>
{chat.items.map((item) => (
<div key={item.id} className="chat-message">
{item.text && item.role === "user" && <p className={userTextClass(item)}>{item.text}</p>}
{/* The AI answers in Markdown. */}
{item.text && item.role === "assistant" && (
<div className="chat-assistant-text">
<Markdown components={{ a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noreferrer" /> }}>{item.text}</Markdown>
</div>
)}
{m.tool_calls?.map((tool) =>
tool.status === "waiting_for_user_input" && app ? (
<ToolQuestion key={tool.id} appId={app.id} messageId={m.id!} tool={tool} />
{item.steps.map((step) =>
step.question ? (
<ToolQuestion key={step.id} question={step.question} />
) : (
<p key={tool.id} className="chat-tool-line">{toolLine(tool)}</p>
<div key={step.id}>
<p className="chat-tool-line">{stepIcon(step)} {step.label}</p>
{/* What the user answered stays after the question closes. */}
{step.answered?.map((answer, i) => (
<p key={i} className="chat-answer">{answer.question}: {[...answer.labels, answer.text].filter(Boolean).join(", ")}</p>
))}
</div>
),
)}
</div>
))}
{status && <p className="chat-status">{status}</p>}
<StatusLine chat={chat} hasApp={!!app} />
</StickToBottom.Content>
</StickToBottom>
<div className="chat-composer">
Expand All @@ -60,14 +78,39 @@ export default function Chat({ app, onAppCreated, onBuildingChange }: {
className="chat-input"
rows={1}
/>
<Button onClick={submit} disabled={!canSend}>Send</Button>
<Button onClick={submit} disabled={!canSend || !prompt.trim()}>Send</Button>
</div>
</div>
);
}

// 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 (
<p className="chat-status">
{text}
{chat.error && <Button size="sm" variant="ghost" onClick={chat.clearError}>Dismiss</Button>}
</p>
);
}
120 changes: 80 additions & 40 deletions examples/white-label-minimal/components/ToolQuestion.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<number, string[]>>({});
const [secrets, setSecrets] = useState<Record<string, string>>({});
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 <Choice question={question} />;
if (question.kind === "secrets") return <Secrets question={question} />;
return <Approval question={question} />;
}

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<string[][]>(question.questions.map(() => []));
const [texts, setTexts] = useState<string[]>([]);

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 (
<div className="question">
{kind === "approval" && <p>Allow {tool.name}? {tool.approval?.reason ?? tool.approval?.details?.summary}</p>}
{questions.map((q, i) => (
{question.questions.map((q, i) => (
<div key={i} className="question-item">
<p className="question-title">{q.question}</p>
<div className="question-options">
{(q.options ?? []).map((o) => (typeof o === "string" ? o : o.label ?? "")).map((label) => (
<Button key={label} size="sm" variant={picked[i]?.includes(label) ? "default" : "outline"} onClick={() => pick(i, label, q.multi_select)}>
{label}
</Button>
))}
</div>
<p className="question-title">{q.text}</p>
{q.type === "text_input" ? (
<Input onChange={(e) => write(i, e.target.value)} />
) : (
<div className="question-options">
{q.options.map((label) => (
<Button key={label} size="sm" variant={picked[i].includes(label) ? "default" : "outline"} onClick={() => pick(i, label, q.multi)}>
{label}
</Button>
))}
</div>
)}
</div>
))}
{fields.map(({ secretName = "" }) => (
<Input key={secretName} type="password" placeholder={secretName} onChange={(e) => setSecrets({ ...secrets, [secretName]: e.target.value })} />
<Actions submitLabel="Submit" onSubmit={() => question.answer(picked, texts)} onDecline={question.decline} />
</div>
);
}

function Secrets({ question }: { question: SecretsQuestion }) {
// Tiny passes these values to Base44 and keeps none.
const [values, setValues] = useState<Record<string, string>>({});

return (
<div className="question">
{question.fields.map(({ name }) => (
<Input key={name} type="password" placeholder={name} onChange={(e) => setValues({ ...values, [name]: e.target.value })} />
))}
<div className="question-actions">
<Button size="sm" onClick={submit} disabled={sent}>{kind === "approval" ? "Allow" : "Submit"}</Button>
<Button size="sm" variant="ghost" onClick={() => answer(false)} disabled={sent}>Decline</Button>
</div>
<Actions submitLabel="Submit" onSubmit={() => question.submit(values)} onDecline={question.decline} />
</div>
);
}

function Approval({ question }: { question: ApprovalQuestion }) {
return (
<div className="question">
<p>Allow {question.action}? {question.reason}</p>
<Actions submitLabel="Allow" onSubmit={question.approve} onDecline={question.decline} />
</div>
);
}

function Actions({ submitLabel, onSubmit, onDecline }: {
submitLabel: string;
onSubmit: () => Promise<void>;
onDecline: () => Promise<void>;
}) {
const [sent, setSent] = useState(false);

// A failed answer opens the question again, so the buttons come back.
async function send(action: () => Promise<void>) {
setSent(true);
await action();
setSent(false);
}

return (
<div className="question-actions">
<Button size="sm" onClick={() => send(onSubmit)} disabled={sent}>{submitLabel}</Button>
<Button size="sm" variant="ghost" onClick={() => send(onDecline)} disabled={sent}>Decline</Button>
</div>
);
}
2 changes: 1 addition & 1 deletion examples/white-label-minimal/components/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function Workspace() {
<div className="workspace">
<Header apps={apps} selectedAppId={appId} onSelectApp={setAppId} />
<AppPreview key={`preview-${appId}`} app={app} live={liveAppId === appId} />
<Chat key={`chat-${appId}`} app={app} onAppCreated={onAppCreated} onBuildingChange={onBuildingChange} />
<Chat app={app} onAppCreated={onAppCreated} onBuildingChange={onBuildingChange} />
</div>
);
}
Loading