diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 5f3db5de..4fb5989e 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -221,10 +221,10 @@ export const BaseChat = React.forwardRef( {!chatStarted && (

- Where ideas begin + Get unstuck

- Bring ideas to life in seconds or get help on existing projects. + Fix tough bugs and get your app working right.

)} @@ -369,7 +369,7 @@ export const BaseChat = React.forwardRef( minHeight: TEXTAREA_MIN_HEIGHT, maxHeight: TEXTAREA_MAX_HEIGHT, }} - placeholder="How can Bolt help you today?" + placeholder={chatStarted ? "How can we help you?" : "What do you want to build?"} translate="no" /> diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx index 471732bd..2a650dee 100644 --- a/app/components/header/Header.tsx +++ b/app/components/header/Header.tsx @@ -18,7 +18,7 @@ export function Header() {
- logo + logo
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started. diff --git a/app/lib/replay/SimulationPrompt.ts b/app/lib/replay/SimulationPrompt.ts index e76b94a8..b5ed02d1 100644 --- a/app/lib/replay/SimulationPrompt.ts +++ b/app/lib/replay/SimulationPrompt.ts @@ -1,10 +1,11 @@ -// Core logic for prompting the AI developer with the repository state and simulation data. +// Core logic for using simulation data from remote recording to enhance +// the AI developer prompt. // Currently the simulation prompt is sent from the server. import { type SimulationData, type MouseData } from './Recording'; -import { sendCommandDedicatedClient } from './ReplayProtocolClient'; -import { type ChatFileChange } from '~/utils/chatStreamController'; +import { assert, ProtocolClient, sendCommandDedicatedClient } from './ReplayProtocolClient'; +import JSZip from 'jszip'; // Data supplied by the client for a simulation prompt, separate from the chat input. export interface SimulationPromptClientData { @@ -13,51 +14,228 @@ export interface SimulationPromptClientData { mouseData?: MouseData; } -export interface SimulationChatMessage { - role: "user" | "assistant"; - content: string; +interface RerecordGenerateParams { + rerecordData: SimulationData; + repositoryContents: string; } -// Params format for the simulationPrompt command. -interface SimulationPrompt { - simulationData: SimulationData; - repositoryContents: string; // base64 encoded zip file - userPrompt: string; - chatHistory: SimulationChatMessage[]; - mouseData?: MouseData; - anthropicAPIKey: string; -} - -// Result format for the simulationPrompt command. -interface SimulationPromptResult { - message: string; - fileChanges: ChatFileChange[]; -} - -export async function performSimulationPrompt( - simulationClientData: SimulationPromptClientData, - userPrompt: string, - chatHistory: SimulationChatMessage[], - anthropicAPIKey: string, -): Promise { - const { simulationData, repositoryContents, mouseData } = simulationClientData; - - const prompt: SimulationPrompt = { - simulationData, +export async function getSimulationRecording( + simulationData: SimulationData, + repositoryContents: string +): Promise { + const params: RerecordGenerateParams = { + rerecordData: simulationData, repositoryContents, - userPrompt, - chatHistory, - mouseData, - anthropicAPIKey, }; - - const simulationRval = await sendCommandDedicatedClient({ + const rv = await sendCommandDedicatedClient({ method: "Recording.globalExperimentalCommand", params: { - name: "simulationPrompt", - params: prompt, + name: "rerecordGenerate", + params, }, }); - return (simulationRval as { rval: SimulationPromptResult }).rval; + return (rv as { rval: { rerecordedRecordingId: string } }).rval.rerecordedRecordingId; +} + +type ProtocolExecutionPoint = string; + +export interface URLLocation { + sourceId: string; + line: number; + column: number; + url: string; +} + +// A location within a recording and associated source contents. +export interface URLLocationWithSource extends URLLocation { + // Text from the application source indicating the location. + source: string; +} + +interface ExecutionDataEntry { + // Value from the application source which is being described. + value?: string; + + // Description of the contents of the value. If |value| is omitted + // this describes a control dependency for the location. + contents: string; + + // Any associated execution point. + associatedPoint?: ProtocolExecutionPoint; + + // Location in the recording of the associated execution point. + associatedLocation?: URLLocationWithSource; + + // Any expression for the value at the associated point which flows to this one. + associatedValue?: string; + + // Description of how data flows from the associated point to this one. + associatedDataflow?: string; +} + +interface ExecutionDataPoint { + // Associated point. + point: ProtocolExecutionPoint; + + // Location in the recording being described. + location: URLLocationWithSource; + + // Entries describing the point. + entries: ExecutionDataEntry[]; +} + +// Initial point for analysis that is an uncaught exception thrown +// from application code called by React, causing the app to unmount. +interface ExecutionDataInitialPointReactException { + kind: "ReactException"; + errorText: string; + + // Whether the exception was thrown by library code called at the point. + calleeFrame: boolean; +} + +// Initial point for analysis that is an exception logged to the console. +interface ExecutionDataInitialPointConsoleError { + kind: "ConsoleError"; + errorText: string; +} + +type BaseExecutionDataInitialPoint = + | ExecutionDataInitialPointReactException + | ExecutionDataInitialPointConsoleError; + +export type ExecutionDataInitialPoint = { + point: ProtocolExecutionPoint; +} & BaseExecutionDataInitialPoint; + +export interface ExecutionDataAnalysisResult { + // Points which were described. + points: ExecutionDataPoint[]; + + // If an expression was specified, the dataflow steps for that expression. + dataflow?: string[]; + + // The initial point which was analyzed. If no point was originally specified, + // another point will be picked based on any comments or other data in the recording. + point?: ProtocolExecutionPoint; + + // Any comment text associated with the point. + commentText?: string; + + // If the comment is on a React component, the name of the component. + reactComponentName?: string; + + // If no point or comment was available, describes the failure associated with the + // initial point of the analysis. + failureData?: ExecutionDataInitialPoint; +} + +function trimFileName(url: string): string { + const lastSlash = url.lastIndexOf('/'); + return url.slice(lastSlash + 1); +} + +async function getSourceText(repositoryContents: string, fileName: string): Promise { + const zip = new JSZip(); + const binaryData = Buffer.from(repositoryContents, 'base64'); + await zip.loadAsync(binaryData as any /* TS complains but JSZip works */); + for (const [path, file] of Object.entries(zip.files)) { + if (trimFileName(path) === fileName) { + return await file.async('string'); + } + } + for (const path of Object.keys(zip.files)) { + console.log("RepositoryPath", path); + } + throw new Error(`File ${fileName} not found in repository`); +} + +async function annotateSource(repositoryContents: string, fileName: string, source: string, annotation: string): Promise { + const sourceText = await getSourceText(repositoryContents, fileName); + const sourceLines = sourceText.split('\n'); + const lineIndex = sourceLines.findIndex(line => line.includes(source)); + if (lineIndex === -1) { + throw new Error(`Source text ${source} not found in ${fileName}`); + } + + let rv = ""; + for (let i = lineIndex - 3; i < lineIndex + 3; i++) { + if (i < 0 || i >= sourceLines.length) { + continue; + } + if (i === lineIndex) { + const leadingSpaces = sourceLines[i].match(/^\s*/)![0]; + rv += `${leadingSpaces}// ${annotation}\n`; + } + rv += `${sourceLines[i]}\n`; + } + return rv; +} + +async function enhancePromptFromFailureData( + failurePoint: ExecutionDataPoint, + failureData: ExecutionDataInitialPoint, + repositoryContents: string +): Promise { + const pointText = failurePoint.location.source.trim(); + const fileName = trimFileName(failurePoint.location.url); + + let prompt = ""; + let annotation; + + switch (failureData.kind) { + case "ReactException": + prompt += "An exception was thrown which causes React to unmount the application.\n"; + if (failureData.calleeFrame) { + annotation = `A function called from here is throwing the exception "${failureData.errorText}"`; + } else { + annotation = `This line is throwing the exception "${failureData.errorText}"`; + } + break; + case "ConsoleError": + prompt += "An exception was thrown and later logged to the console.\n"; + annotation = `This line is throwing the exception "${failureData.errorText}"`; + break; + default: + throw new Error(`Unknown failure kind: ${(failureData as any).kind}`); + } + + const annotatedSource = await annotateSource(repositoryContents, fileName, pointText, annotation); + + prompt += `Here is the affected code, in ${fileName}:\n\n`; + prompt += "```\n" + annotatedSource + "```\n"; + return prompt; +} + +export async function getSimulationEnhancedPrompt(recordingId: string, repositoryContents: string): Promise { + const client = new ProtocolClient(); + await client.initialize(); + try { + const createSessionRval = await client.sendCommand({ method: "Recording.createSession", params: { recordingId } }); + const sessionId = (createSessionRval as { sessionId: string }).sessionId; + + const { rval } = await client.sendCommand({ + method: "Session.experimentalCommand", + params: { + name: "analyzeExecutionPoint", + params: {}, + }, + sessionId, + }) as { rval: ExecutionDataAnalysisResult };; + + const { points, failureData } = rval; + assert(failureData, "No failure data"); + + const failurePoint = points.find(p => p.point === failureData.point); + assert(failurePoint, "No failure point"); + + console.log("FailureData", JSON.stringify(failureData, null, 2)); + + const prompt = await enhancePromptFromFailureData(failurePoint, failureData, repositoryContents); + console.log("Enhanced prompt", prompt); + return prompt; + } finally { + client.close(); + } } diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index dc1c8358..a4a63aa8 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -6,7 +6,7 @@ import { Header } from '~/components/header/Header'; import BackgroundRays from '~/components/ui/BackgroundRays'; export const meta: MetaFunction = () => { - return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }]; + return [{ title: 'Nut' }]; }; export const loader = () => json({}); diff --git a/app/routes/api.chat.ts b/app/routes/api.chat.ts index 0bface90..0ff041cc 100644 --- a/app/routes/api.chat.ts +++ b/app/routes/api.chat.ts @@ -1,5 +1,5 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare'; -import { type SimulationChatMessage, type SimulationPromptClientData, performSimulationPrompt } from '~/lib/replay/SimulationPrompt'; +import { type SimulationPromptClientData, getSimulationEnhancedPrompt, getSimulationRecording } from '~/lib/replay/SimulationPrompt'; import { ChatStreamController } from '~/utils/chatStreamController'; import { assert } from '~/lib/replay/ReplayProtocolClient'; import { getStreamTextArguments, type Messages } from '~/lib/.server/llm/stream-text'; @@ -9,34 +9,11 @@ export async function action(args: ActionFunctionArgs) { return chatAction(args); } -function extractMessageContent(baseContent: any): string { - let content = baseContent; - - if (content && typeof content == "object" && content.length) { - assert(content.length == 1, "Expected a single message"); - content = content[0]; - } - - if (content && typeof content == "object") { - assert(content.type == "text", `Expected "text" for type property, got ${content.type}`); - content = content.text; - } - - assert(typeof content == "string", `Expected string type, got ${typeof content}`); - - while (true) { - const artifactIndex = content.indexOf(" tag"); - content = content.slice(0, artifactIndex) + content.slice(artifactEnd + closeTag.length); - } - - return content; -} +// Directions given to the LLM when we have an enhanced prompt describing the bug to fix. +const EnhancedPromptPrefix = ` +ULTRA IMPORTANT: Below is a detailed description of the bug. +Focus specifically on fixing this bug. Do not guess about other problems. +`; async function chatAction({ context, request }: ActionFunctionArgs) { const { messages, files, promptId, simulationClientData } = await request.json<{ @@ -70,34 +47,44 @@ async function chatAction({ context, request }: ActionFunctionArgs) { async start(controller) { const chatController = new ChatStreamController(controller); - /* - chatController.writeText("Hello World\n"); - chatController.writeText("Hello World 2\n"); - chatController.writeText("Hello\n World 3\n"); - chatController.writeFileChanges("Rewrite Files", [{filePath: "src/services/llm.ts", contents: "FILE_CONTENTS_FIXME" }]); - chatController.writeAnnotation("usage", { completionTokens: 10, promptTokens: 20, totalTokens: 30 }); - */ + let recordingId: string | undefined; + if (simulationClientData) { + try { + const { simulationData, repositoryContents } = simulationClientData; + recordingId = await getSimulationRecording(simulationData, repositoryContents); + chatController.writeText(`[Recording of the bug](https://app.replay.io/recording/${recordingId})\n\n`); + } catch (e) { + console.error("Error creating recording", e); + chatController.writeText("Error creating recording."); + } + } + + let enhancedPrompt: string | undefined; + if (recordingId) { + try { + assert(simulationClientData, "SimulationClientData is required"); + enhancedPrompt = await getSimulationEnhancedPrompt(recordingId, simulationClientData.repositoryContents); + chatController.writeText(`Enhanced prompt: ${enhancedPrompt}\n\n`); + } catch (e) { + console.error("Error enhancing prompt", e); + chatController.writeText("Error enhancing prompt."); + } + } + + if (enhancedPrompt) { + const lastMessage = coreMessages[coreMessages.length - 1]; + assert(lastMessage.role == "user", "Last message must be a user message"); + assert(lastMessage.content.length > 0, "Last message must have content"); + const lastContent = lastMessage.content[0]; + assert(typeof lastContent == "object" && lastContent.type == "text", "Last message content must be text"); + lastContent.text += `\n\n${EnhancedPromptPrefix}\n\n${enhancedPrompt}`; + } try { - if (simulationClientData) { - const chatHistory: SimulationChatMessage[] = []; - for (const { role, content } of messages) { - chatHistory.push({ role, content: extractMessageContent(content) }); - } - const lastHistoryMessage = chatHistory.pop(); - assert(lastHistoryMessage?.role == "user", "Last message in chat history must be a user message"); - const userPrompt = lastHistoryMessage.content; - - const { message, fileChanges } = await performSimulationPrompt(simulationClientData, userPrompt, chatHistory, anthropicApiKey); - - chatController.writeText(message + "\n"); - chatController.writeFileChanges("Update Files", fileChanges); - } else { - await chatAnthropic(chatController, anthropicApiKey, system, coreMessages); - } - } catch (error: any) { - console.error(error); - chatController.writeText("Error: " + error.message); + await chatAnthropic(chatController, anthropicApiKey, system, coreMessages); + } catch (e) { + console.error(e); + chatController.writeText("Error chatting with Anthropic."); } controller.close(); diff --git a/app/styles/index.scss b/app/styles/index.scss index 91a4cf84..7052cd8f 100644 --- a/app/styles/index.scss +++ b/app/styles/index.scss @@ -15,10 +15,7 @@ body { :root { --gradient-opacity: 0.8; - --primary-color: rgba(158, 117, 240, var(--gradient-opacity)); - --secondary-color: rgba(138, 43, 226, var(--gradient-opacity)); - --accent-color: rgba(128, 59, 239, var(--gradient-opacity)); - // --primary-color: rgba(147, 112, 219, var(--gradient-opacity)); - // --secondary-color: rgba(138, 43, 226, var(--gradient-opacity)); - // --accent-color: rgba(180, 170, 220, var(--gradient-opacity)); + --primary-color: rgba(71, 181, 87, var(--gradient-opacity)); + --secondary-color: rgba(16, 87, 27, var(--gradient-opacity)); + --accent-color: rgba(25, 99, 45, var(--gradient-opacity)); } diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png deleted file mode 100644 index ef0af665..00000000 Binary files a/public/apple-touch-icon-precomposed.png and /dev/null differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png deleted file mode 100644 index ef0af665..00000000 Binary files a/public/apple-touch-icon.png and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico index 333e9d11..542b506c 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg index f8d2d72c..e2cb6d6a 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,4 +1,116 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo-dark.png b/public/logo-dark.png deleted file mode 100644 index 377fda33..00000000 Binary files a/public/logo-dark.png and /dev/null differ diff --git a/public/logo-light.png b/public/logo-light.png deleted file mode 100644 index 6b4513ef..00000000 Binary files a/public/logo-light.png and /dev/null differ diff --git a/public/logo.svg b/public/logo.svg index d3ae1ba3..e2cb6d6a 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,15 +1,116 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uno.config.ts b/uno.config.ts index d8ac5a98..57815a5c 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -35,17 +35,17 @@ const BASE_COLORS = { 950: '#0A0A0A', }, accent: { - 50: '#F8F5FF', - 100: '#F0EBFF', - 200: '#E1D6FF', - 300: '#CEBEFF', - 400: '#B69EFF', - 500: '#9C7DFF', - 600: '#8A5FFF', - 700: '#7645E8', - 800: '#6234BB', - 900: '#502D93', - 950: '#2D1959', + 50: '#F0FDF4', + 100: '#DCFCE7', + 200: '#BBF7D0', + 300: '#86EFAC', + 400: '#4ADE80', + 500: '#22C55E', + 600: '#16A34A', + 700: '#15803D', + 800: '#166534', + 900: '#14532D', + 950: '#052E16', }, green: { 50: '#F0FDF4',