Keep track of API usage in user accounts, improve approval mechanism (#95)

This commit is contained in:
Brian Hackett 2025-04-04 14:40:10 -07:00 committed by GitHub
parent 4f5051dee5
commit 9389fb2afc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 190 additions and 120 deletions

View File

@ -11,6 +11,7 @@ export function ClientAuth() {
const [password, setPassword] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [usageData, setUsageData] = useState<{ peanuts_used: number; peanuts_refunded: number } | null>(null);
useEffect(() => {
async function getUser() {
@ -37,6 +38,30 @@ export function ClientAuth() {
};
}, []);
useEffect(() => {
async function updateUsageData() {
try {
const { data, error } = await getSupabase()
.from('profiles')
.select('peanuts_used, peanuts_refunded')
.eq('id', user?.id)
.single();
if (error) {
throw error;
}
setUsageData(data);
} catch (error) {
console.error('Error fetching usage data:', error);
}
}
if (showDropdown) {
updateUsageData();
}
}, [showDropdown]);
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setIsSigningIn(true);
@ -119,10 +144,16 @@ export function ClientAuth() {
</button>
{showDropdown && (
<div className="absolute right-0 mt-2 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded shadow-lg z-10">
<div className="absolute right-0 mt-2 py-2 whitespace-nowrap bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded shadow-lg z-10">
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
{user.email}
</div>
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
{`Peanuts used: ${usageData?.peanuts_used ?? '...'}`}
</div>
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
{`Peanuts refunded: ${usageData?.peanuts_refunded ?? '...'}`}
</div>
<button
onClick={handleSignOut}
className="block w-full text-left px-4 py-2 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"

View File

@ -9,7 +9,7 @@ import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client';
import { classNames } from '~/utils/classNames';
import { Messages } from './Messages.client';
import { getPreviousRepositoryId, type Message } from '~/lib/persistence/message';
import { type Message } from '~/lib/persistence/message';
import { SendButton } from './SendButton.client';
import * as Tooltip from '@radix-ui/react-tooltip';
@ -20,7 +20,6 @@ import FilePreview from './FilePreview';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import { ScreenshotStateManager } from './ScreenshotStateManager';
import type { RejectChangeData } from './ApproveChange';
import { assert } from '~/lib/replay/ReplayProtocolClient';
import ApproveChange from './ApproveChange';
export const TEXTAREA_MIN_HEIGHT = 76;
@ -46,10 +45,8 @@ interface BaseChatProps {
setUploadedFiles?: (files: File[]) => void;
imageDataList?: string[];
setImageDataList?: (dataList: string[]) => void;
onRewind?: (messageId: string) => void;
approveChangesMessageId?: string;
onApproveChange?: (messageId: string) => void;
onRejectChange?: (lastMessageId: string, data: RejectChangeData) => void;
onRejectChange?: (messageId: string, data: RejectChangeData) => void;
}
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@ -72,8 +69,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
imageDataList = [],
setImageDataList,
messages,
onRewind,
approveChangesMessageId,
onApproveChange,
onRejectChange,
},
@ -209,30 +204,21 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
}
};
const showApproveChange = (() => {
if (hasPendingMessage) {
return false;
const approveChangeMessageId = (() => {
if (hasPendingMessage || !messages) {
return undefined;
}
if (!messages?.length) {
return false;
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.repositoryId && message.peanuts) {
return message.approved ? undefined : message.id;
}
if (message.role == 'user') {
return undefined;
}
}
const lastMessage = messages[messages.length - 1];
if (!lastMessage.repositoryId) {
return false;
}
if (!getPreviousRepositoryId(messages, messages.length - 1)) {
return false;
}
if (lastMessage.id != approveChangesMessageId) {
return false;
}
return true;
return undefined;
})();
let messageInput;
@ -369,7 +355,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
Get what you want
</h1>
<p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
Build, test, and fix your app all from one prompt
Write, test, and fix your app all from one prompt
</p>
</div>
)}
@ -387,7 +373,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
messages={messages}
hasPendingMessage={hasPendingMessage}
pendingMessageStatus={pendingMessageStatus}
onRewind={onRewind}
/>
) : null;
}}
@ -444,24 +429,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
/>
)}
</ClientOnly>
{showApproveChange && (
{approveChangeMessageId && (
<ApproveChange
rejectFormOpen={rejectFormOpen}
setRejectFormOpen={setRejectFormOpen}
onApprove={() => {
if (onApproveChange && messages) {
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
onApproveChange(lastMessage.id);
}
}}
onReject={(data) => {
if (onRejectChange && messages) {
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
onRejectChange(lastMessage.id, data);
}
}}
onApprove={() => onApproveChange?.(approveChangeMessageId)}
onReject={(data) => onRejectChange?.(approveChangeMessageId, data)}
/>
)}
{!rejectFormOpen && messageInput}

View File

@ -34,6 +34,7 @@ import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '
import { useAuthStatus } from '~/lib/stores/auth';
import { debounce } from '~/utils/debounce';
import { supabaseSubmitFeedback } from '~/lib/supabase/feedback';
import { supabaseAddRefund } from '~/lib/supabase/peanuts';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@ -142,6 +143,22 @@ function mergeResponseMessage(msg: Message, messages: Message[]): Message[] {
return messages;
}
// Get the index of the last message that will be present after rewinding.
// This is the last message which is either a user message or has a repository change.
function getRewindMessageIndexAfterReject(messages: Message[], messageId: string): number {
for (let i = messages.length - 1; i >= 0; i--) {
const { id, role, repositoryId } = messages[i];
if (role == 'user') {
return i;
}
if (repositoryId && id != messageId) {
return i;
}
}
console.error('No rewind message found', messages, messageId);
return -1;
}
export const ChatImpl = memo((props: ChatProps) => {
const { initialMessages, resumeChat: initialResumeChat, storeMessageHistory } = props;
@ -150,7 +167,6 @@ export const ChatImpl = memo((props: ChatProps) => {
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [searchParams, setSearchParams] = useSearchParams();
const [approveChangesMessageId, setApproveChangesMessageId] = useState<string | undefined>(undefined);
const { isLoggedIn } = useAuthStatus();
// Input currently in the textarea.
@ -209,6 +225,7 @@ export const ChatImpl = memo((props: ChatProps) => {
gNumAborts++;
chatStore.aborted.set(true);
setPendingMessageId(undefined);
setPendingMessageStatus('');
setResumeChat(undefined);
const chatId = chatStore.currentChat.get()?.id;
@ -390,10 +407,7 @@ export const ChatImpl = memo((props: ChatProps) => {
textareaRef.current?.blur();
if (updatedRepository) {
const lastMessage = newMessages[newMessages.length - 1];
setApproveChangesMessageId(lastMessage.id);
} else {
if (!updatedRepository) {
simulationReset();
}
};
@ -482,33 +496,6 @@ export const ChatImpl = memo((props: ChatProps) => {
})();
}, [initialResumeChat]);
// Rewind far enough to erase the specified message.
const onRewind = async (messageId: string) => {
console.log('Rewinding', messageId);
const messageIndex = messages.findIndex((message) => message.id === messageId);
if (messageIndex < 0) {
toast.error('Rewind message not found');
return;
}
const previousRepositoryId = getPreviousRepositoryId(messages, messageIndex);
if (!previousRepositoryId) {
toast.error('No repository ID found for rewind');
return;
}
setMessages(messages.slice(0, messageIndex));
simulationRepositoryUpdated(previousRepositoryId);
pingTelemetry('RewindChat', {
numMessages: messages.length,
rewindIndex: messageIndex,
});
};
const flashScreen = async () => {
const flash = document.createElement('div');
flash.style.position = 'fixed';
@ -534,7 +521,17 @@ export const ChatImpl = memo((props: ChatProps) => {
const onApproveChange = async (messageId: string) => {
console.log('ApproveChange', messageId);
setApproveChangesMessageId(undefined);
setMessages(
messages.map((message) => {
if (message.id == messageId) {
return {
...message,
approved: true,
};
}
return message;
}),
);
await flashScreen();
@ -546,23 +543,31 @@ export const ChatImpl = memo((props: ChatProps) => {
const onRejectChange = async (messageId: string, data: RejectChangeData) => {
console.log('RejectChange', messageId, data);
setApproveChangesMessageId(undefined);
// Find the last message that will be present after rewinding. This is the
// last message which is either a user message or has a repository change.
const messageIndex = getRewindMessageIndexAfterReject(messages, messageId);
const message = messages.find((message) => message.id === messageId);
assert(message, 'Message not found');
assert(message == messages[messages.length - 1], 'Message must be the last message');
// Erase all messages since the last user message.
let rewindMessageId = message.id;
for (let i = messages.length - 2; i >= 0; i--) {
if (messages[i].role == 'user') {
break;
}
rewindMessageId = messages[i].id;
if (messageIndex < 0) {
toast.error('Rewind message not found');
return;
}
await onRewind(rewindMessageId);
const message = messages.find((m) => m.id == messageId);
if (!message) {
toast.error('Message not found');
return;
}
if (message.peanuts) {
await supabaseAddRefund(message.peanuts);
}
const previousRepositoryId = getPreviousRepositoryId(messages, messageIndex + 1);
setMessages(messages.slice(0, messageIndex + 1));
simulationRepositoryUpdated(previousRepositoryId);
let shareProjectSuccess = false;
@ -615,8 +620,6 @@ export const ChatImpl = memo((props: ChatProps) => {
setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList}
setImageDataList={setImageDataList}
onRewind={onRewind}
approveChangesMessageId={approveChangesMessageId}
onApproveChange={onApproveChange}
onRejectChange={onRejectChange}
/>

View File

@ -1,7 +1,7 @@
import React, { Suspense } from 'react';
import { classNames } from '~/utils/classNames';
import WithTooltip from '~/components/ui/Tooltip';
import { getPreviousRepositoryId, type Message } from '~/lib/persistence/message';
import type { Message } from '~/lib/persistence/message';
import { MessageContents } from './MessageContents';
interface MessagesProps {
@ -10,18 +10,16 @@ interface MessagesProps {
hasPendingMessage?: boolean;
pendingMessageStatus?: string;
messages?: Message[];
onRewind?: (messageId: string) => void;
}
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, hasPendingMessage = false, pendingMessageStatus = '', messages = [], onRewind } = props;
const { id, hasPendingMessage = false, pendingMessageStatus = '', messages = [] } = props;
return (
<div id={id} ref={ref} className={props.className}>
{messages.length > 0
? messages.map((message, index) => {
const { role, id: messageId, repositoryId } = message;
const previousRepositoryId = getPreviousRepositoryId(messages, index);
const { role, repositoryId } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
@ -51,16 +49,15 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
<div className="grid grid-col-1 w-full">
<MessageContents message={message} />
</div>
{previousRepositoryId && repositoryId && onRewind && (
{repositoryId && (
<div className="flex gap-2 flex-col lg:flex-row">
<WithTooltip tooltip="Undo changes in this message">
<WithTooltip tooltip="Start new chat from here">
<button
onClick={() => {
onRewind(messageId);
window.open(`/repository/${repositoryId}`, '_blank');
}}
key="i-ph:arrow-u-up-left"
className={classNames(
'i-ph:arrow-u-up-left',
'i-ph:git-fork',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>

View File

@ -25,7 +25,12 @@ export function Header() {
<img src="/logo-styled.svg" alt="logo" className="w-[40px] inline-block rotate-90" />
</a>
<Feedback />
<a href="https://www.replay.io/discord" className="text-bolt-elements-accent underline hover:no-underline" target="_blank" rel="noopener noreferrer">
<a
href="https://www.replay.io/discord"
className="text-bolt-elements-accent underline hover:no-underline"
target="_blank"
rel="noopener noreferrer"
>
<div className="i-ph:discord-logo-fill text-[1.3em]" />
</a>
</div>

View File

@ -8,6 +8,11 @@ interface MessageBase {
id: string;
role: MessageRole;
repositoryId?: string;
peanuts?: number;
// Not part of the protocol, indicates whether the user has explicitly approved
// the message. Once approved, the approve/reject UI is not shown again for the message.
approved?: boolean;
}
interface MessageText extends MessageBase {

View File

@ -56,20 +56,28 @@ class DevelopmentServerManager {
let gActiveDevelopmentServer: DevelopmentServerManager | undefined;
export async function updateDevelopmentServer(repositoryId: string) {
export async function updateDevelopmentServer(repositoryId: string | undefined) {
console.log('UpdateDevelopmentServer', new Date().toISOString(), repositoryId);
workbenchStore.showWorkbench.set(true);
workbenchStore.showWorkbench.set(repositoryId !== undefined);
workbenchStore.repositoryId.set(repositoryId);
workbenchStore.previewURL.set(undefined);
workbenchStore.previewError.set(false);
if (!repositoryId) {
return;
}
if (!gActiveDevelopmentServer) {
gActiveDevelopmentServer = new DevelopmentServerManager();
}
const url = await gActiveDevelopmentServer.setRepositoryContents(repositoryId);
if (workbenchStore.repositoryId.get() != repositoryId) {
return;
}
if (url) {
workbenchStore.previewURL.set(url);
} else {

View File

@ -11,6 +11,7 @@ import type { Message } from '~/lib/persistence/message';
import { database } from '~/lib/persistence/db';
import { chatStore } from '~/lib/stores/chat';
import { debounce } from '~/utils/debounce';
import { getSupabase } from '~/lib/supabase/client';
function createRepositoryIdPacket(repositoryId: string): SimulationPacket {
return {
@ -64,6 +65,15 @@ class ChatManager {
await this.client.initialize();
const {
data: { user },
} = await getSupabase().auth.getUser();
const userId = user?.id || null;
if (userId) {
await this.client.sendCommand({ method: 'Nut.setUserId', params: { userId } });
}
const { chatId } = (await this.client.sendCommand({ method: 'Nut.startChat', params: {} })) as { chatId: string };
console.log('ChatStarted', new Date().toISOString(), chatId);
@ -235,7 +245,7 @@ function startChat(repositoryId: string | undefined, pageData: SimulationData) {
* Called when the repository has changed. We'll start a new chat
* and update the remote development server.
*/
export const simulationRepositoryUpdated = debounce((repositoryId: string) => {
export const simulationRepositoryUpdated = debounce((repositoryId: string | undefined) => {
startChat(repositoryId, []);
updateDevelopmentServer(repositoryId);
}, 500);

View File

@ -0,0 +1,37 @@
import { getSupabase } from './client';
export async function supabaseAddRefund(peanuts: number) {
const supabase = getSupabase();
// Get the current user ID if available
const {
data: { user },
} = await supabase.auth.getUser();
const userId = user?.id || null;
const { data, error } = await supabase.from('profiles').select('peanuts_refunded').eq('id', userId).single();
if (error) {
console.error('AddPeanutsRefund:ErrorFetchingData', { error });
return;
}
const currentPeanutsRefunded = data.peanuts_refunded;
if (typeof currentPeanutsRefunded !== 'number') {
console.error('AddPeanutsRefund:InvalidPeanutsRefunded', { currentPeanutsRefunded });
return;
}
const newPeanutsRefunded = Math.round(currentPeanutsRefunded + peanuts);
// Note: this is not atomic.
// https://linear.app/replay/issue/PRO-1122/update-api-usage-atomically
const { error: updateError } = await supabase
.from('profiles')
.update({ peanuts_refunded: newPeanutsRefunded })
.eq('id', userId);
if (updateError) {
console.error('AddPeanutsRefund:ErrorUpdatingData', { updateError });
}
}

View File

@ -15,10 +15,9 @@ function AboutPage() {
<h1 className="text-4xl font-bold mb-8 text-gray-900 dark:text-gray-200">About Nut</h1>
<p className="mb-6">
Nut is an agentic app builder for reliably developing full stack apps using AI.
When you ask Nut to build or change an app, it will do its best to get the code
changes right the first time. Afterwards it will check the app to make sure it's
working as expected, writing tests and fixing problems those tests uncover.
Nut is an agentic app builder for reliably developing full stack apps using AI. When you ask Nut to build or
change an app, it will do its best to get the code changes right the first time. Afterwards it will check
the app to make sure it's working as expected, writing tests and fixing problems those tests uncover.
</p>
<p className="mb-6">
@ -45,9 +44,8 @@ function AboutPage() {
rel="noopener noreferrer"
>
Replay.io
</a>{' '} team.
We'd love to hear from you! Leave us some feedback at the top of the page,
join our{' '}
</a>{' '}
team. We'd love to hear from you! Leave us some feedback at the top of the page, join our{' '}
<a
href="https://www.replay.io/discord"
className="text-bolt-elements-accent underline hover:no-underline"
@ -56,10 +54,11 @@ function AboutPage() {
>
Discord
</a>{' '}
or reach us at {' '}
or reach us at{' '}
<a href="mailto:hi@replay.io" className="text-bolt-elements-accent underline hover:no-underline">
hi@replay.io
</a>.
</a>
.
</p>
</div>
</div>

View File

@ -6,7 +6,9 @@ CREATE TABLE IF NOT EXISTS public.profiles (
username TEXT UNIQUE,
full_name TEXT,
avatar_url TEXT,
is_admin BOOLEAN DEFAULT FALSE NOT NULL
is_admin BOOLEAN DEFAULT FALSE NOT NULL,
peanuts_used INTEGER DEFAULT 0 NOT NULL,
peanuts_refunded INTEGER DEFAULT 0 NOT NULL
);
-- Create a trigger to update the updated_at column