mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Merge pull request #1 from replayio/recording-button
Add button to save recording, assorted other UX changes
This commit is contained in:
@@ -22,11 +22,11 @@ if ! pnpm typecheck; then
|
||||
fi
|
||||
|
||||
# Run lint
|
||||
echo "Running lint..."
|
||||
if ! pnpm lint; then
|
||||
echo "❌ Linting failed! Run 'pnpm lint:fix' to fix the easy issues."
|
||||
echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
|
||||
exit 1
|
||||
fi
|
||||
#echo "Running lint..."
|
||||
#if ! pnpm lint; then
|
||||
# echo "❌ Linting failed! Run 'pnpm lint:fix' to fix the easy issues."
|
||||
# echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
|
||||
# exit 1
|
||||
#fi
|
||||
|
||||
echo "👍 All checks passed! Committing changes..."
|
||||
|
||||
@@ -7,7 +7,7 @@ interface AssistantMessageProps {
|
||||
annotations?: JSONValue[];
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
|
||||
export function getAnnotationsTokensUsage(annotations: JSONValue[] | undefined) {
|
||||
const filteredAnnotations = (annotations?.filter(
|
||||
(annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
|
||||
) || []) as { type: string; value: any }[];
|
||||
@@ -18,6 +18,12 @@ export const AssistantMessage = memo(({ content, annotations }: AssistantMessage
|
||||
totalTokens: number;
|
||||
} = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value;
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
|
||||
const usage = getAnnotationsTokensUsage(annotations);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden w-full">
|
||||
{usage && (
|
||||
|
||||
@@ -22,6 +22,8 @@ import { useSettings } from '~/lib/hooks/useSettings';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import { useSearchParams } from '@remix-run/react';
|
||||
import { createSampler } from '~/utils/sampler';
|
||||
import { saveProjectPrompt } from './Messages.client';
|
||||
import { uint8ArrayToBase64 } from '../workbench/ReplayProtocolClient';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
@@ -314,6 +316,14 @@ export const ChatImpl = memo(
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
|
||||
// The project contents are associated with the last message present when
|
||||
// the user message is added.
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const { content, uniqueProjectName } = await workbenchStore.generateZip();
|
||||
const buf = await content.arrayBuffer();
|
||||
const contentBase64 = uint8ArrayToBase64(new Uint8Array(buf));
|
||||
saveProjectPrompt(lastMessage.id, { content: contentBase64, uniqueProjectName, input: _input });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
|
||||
import { createChatFromFolder } from '~/utils/folderImport';
|
||||
import { createChatFromFolder, getFileArtifacts } from '~/utils/folderImport';
|
||||
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
|
||||
|
||||
interface ImportFolderButtonProps {
|
||||
@@ -73,7 +73,8 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
||||
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||
}
|
||||
|
||||
const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
|
||||
const textFileArtifacts = await getFileArtifacts(textFiles);
|
||||
const messages = await createChatFromFolder(textFileArtifacts, binaryFilePaths, folderName);
|
||||
|
||||
if (importChat) {
|
||||
await importChat(folderName, [...messages]);
|
||||
|
||||
114
app/components/chat/LoadProblemButton.tsx
Normal file
114
app/components/chat/LoadProblemButton.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { createChatFromFolder, type FileArtifact } from '~/utils/folderImport';
|
||||
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
|
||||
import { sendCommandDedicatedClient } from '../workbench/ReplayProtocolClient';
|
||||
import type { BoltProblem } from './Messages.client';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
interface LoadProblemButtonProps {
|
||||
className?: string;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export const LoadProblemButton: React.FC<LoadProblemButtonProps> = ({ className, importChat }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInputOpen, setIsInputOpen] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsLoading(true);
|
||||
setIsInputOpen(false);
|
||||
|
||||
const problemId = (document.getElementById('problem-input') as HTMLInputElement)?.value;
|
||||
|
||||
let problem: BoltProblem | null = null;
|
||||
try {
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: "Recording.globalExperimentalCommand",
|
||||
params: {
|
||||
name: "fetchBoltProblem",
|
||||
params: { problemId },
|
||||
},
|
||||
});
|
||||
console.log("FetchProblemRval", rv);
|
||||
problem = (rv as any).rval.problem;
|
||||
} catch (error) {
|
||||
console.error("Error fetching problem", error);
|
||||
toast.error("Failed to fetch problem");
|
||||
}
|
||||
|
||||
if (!problem) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Problem", problem);
|
||||
|
||||
const zip = new JSZip();
|
||||
await zip.loadAsync(problem.prompt.content, { base64: true });
|
||||
|
||||
const fileArtifacts: FileArtifact[] = [];
|
||||
for (const [key, object] of Object.entries(zip.files)) {
|
||||
if (object.dir) continue;
|
||||
fileArtifacts.push({
|
||||
content: await object.async('text'),
|
||||
path: key,
|
||||
});
|
||||
}
|
||||
|
||||
const folderName = problem.prompt.uniqueProjectName;
|
||||
|
||||
try {
|
||||
const messages = await createChatFromFolder(fileArtifacts, [], folderName);
|
||||
|
||||
if (importChat) {
|
||||
await importChat(folderName, [...messages]);
|
||||
}
|
||||
|
||||
logStore.logSystem('Problem loaded successfully', {
|
||||
problemId,
|
||||
textFileCount: fileArtifacts.length,
|
||||
});
|
||||
toast.success('Problem loaded successfully');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to load problem', error);
|
||||
console.error('Failed to load problem:', error);
|
||||
toast.error('Failed to load problem');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isInputOpen && (
|
||||
<input
|
||||
id="problem-input"
|
||||
type="text"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
onChange={() => {}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit(e as any);
|
||||
}
|
||||
}}
|
||||
className="border border-gray-300 rounded px-2 py-1"
|
||||
{...({} as any)}
|
||||
/>
|
||||
)}
|
||||
{!isInputOpen && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsInputOpen(true);
|
||||
}}
|
||||
className={className}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="i-ph:globe" />
|
||||
{isLoading ? 'Loading...' : 'Load Problem'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Message } from 'ai';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { AssistantMessage, getAnnotationsTokensUsage } from './AssistantMessage';
|
||||
import { UserMessage } from './UserMessage';
|
||||
import { useLocation } from '@remix-run/react';
|
||||
import { db, chatId } from '~/lib/persistence/useChatHistory';
|
||||
import { forkChat } from '~/lib/persistence/db';
|
||||
import { toast } from 'react-toastify';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { assert, sendCommandDedicatedClient } from "~/components/workbench/ReplayProtocolClient";
|
||||
import ReactModal from 'react-modal';
|
||||
|
||||
ReactModal.setAppElement('#root');
|
||||
|
||||
interface MessagesProps {
|
||||
id?: string;
|
||||
@@ -16,9 +20,44 @@ interface MessagesProps {
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
// Combines information about the contents of a project along with a prompt
|
||||
// from the user and any associated Replay data to accomplish a task. Together
|
||||
// this information is enough that the model should be able to generate a
|
||||
// suitable fix.
|
||||
//
|
||||
// Must be JSON serializable.
|
||||
interface ProjectPrompt {
|
||||
content: string; // base64 encoded
|
||||
uniqueProjectName: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
export interface BoltProblem {
|
||||
title: string;
|
||||
description: string;
|
||||
name: string;
|
||||
email: string;
|
||||
prompt: ProjectPrompt;
|
||||
}
|
||||
|
||||
const gProjectPromptsByMessageId = new Map<string, ProjectPrompt>();
|
||||
|
||||
export function saveProjectPrompt(messageId: string, prompt: ProjectPrompt) {
|
||||
gProjectPromptsByMessageId.set(messageId, prompt);
|
||||
}
|
||||
|
||||
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
|
||||
const { id, isStreaming = false, messages = [] } = props;
|
||||
const location = useLocation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentProjectPrompt, setCurrentProjectPrompt] = useState<ProjectPrompt | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
name: '',
|
||||
email: ''
|
||||
});
|
||||
const [problemId, setProblemId] = useState<string | null>(null);
|
||||
|
||||
const handleRewind = (messageId: string) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -40,71 +79,223 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={id} ref={ref} className={props.className}>
|
||||
{messages.length > 0
|
||||
? messages.map((message, index) => {
|
||||
const { role, content, id: messageId } = message;
|
||||
const isUserMessage = role === 'user';
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === messages.length - 1;
|
||||
const handleSaveProblem = (prompt: ProjectPrompt) => {
|
||||
setCurrentProjectPrompt(prompt);
|
||||
setIsModalOpen(true);
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
name: '',
|
||||
email: '',
|
||||
});
|
||||
setProblemId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
||||
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
||||
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
||||
isStreaming && isLast,
|
||||
'mt-4': !isFirst,
|
||||
})}
|
||||
>
|
||||
{isUserMessage && (
|
||||
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
|
||||
<div className="i-ph:user-fill text-xl"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-col-1 w-full">
|
||||
{isUserMessage ? (
|
||||
<UserMessage content={content} />
|
||||
) : (
|
||||
<AssistantMessage content={content} annotations={message.annotations} />
|
||||
const handleSubmitProblem = async (e: React.MouseEvent) => {
|
||||
// Add validation here
|
||||
if (!formData.title) {
|
||||
toast.error('Please fill in title field');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info("Submitting problem...");
|
||||
|
||||
console.log("SubmitProblem", formData);
|
||||
|
||||
assert(currentProjectPrompt);
|
||||
|
||||
const problem: BoltProblem = {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
prompt: currentProjectPrompt,
|
||||
};
|
||||
|
||||
try {
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: "Recording.globalExperimentalCommand",
|
||||
params: {
|
||||
name: "submitBoltProblem",
|
||||
params: { problem },
|
||||
},
|
||||
});
|
||||
console.log("SubmitProblemRval", rv);
|
||||
setProblemId((rv as any).rval.problemId);
|
||||
} catch (error) {
|
||||
console.error("Error submitting problem", error);
|
||||
toast.error("Failed to submit problem");
|
||||
}
|
||||
}
|
||||
|
||||
const getLastMessageProjectPrompt = (index: number) => {
|
||||
// The message index is for the model response, and the project
|
||||
// prompt will be associated with the last message present when
|
||||
// the user prompt was sent to the model. So look back two messages
|
||||
// for the associated prompt.
|
||||
if (index < 2) {
|
||||
return null;
|
||||
}
|
||||
const previousMessage = messages[index - 2];
|
||||
return gProjectPromptsByMessageId.get(previousMessage.id);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id={id} ref={ref} className={props.className}>
|
||||
{messages.length > 0
|
||||
? messages.map((message, index) => {
|
||||
const { role, content, id: messageId } = message;
|
||||
const isUserMessage = role === 'user';
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === messages.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
||||
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
||||
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
||||
isStreaming && isLast,
|
||||
'mt-4': !isFirst,
|
||||
})}
|
||||
>
|
||||
{isUserMessage && (
|
||||
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
|
||||
<div className="i-ph:user-fill text-xl"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isUserMessage && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
{messageId && (
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
<div className="grid grid-col-1 w-full">
|
||||
{isUserMessage ? (
|
||||
<UserMessage content={content} />
|
||||
) : (
|
||||
<AssistantMessage content={content} annotations={message.annotations} />
|
||||
)}
|
||||
</div>
|
||||
{!isUserMessage && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
{messageId && (
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
<button
|
||||
onClick={() => handleRewind(messageId)}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
className={classNames(
|
||||
'i-ph:arrow-u-up-left',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
|
||||
<WithTooltip tooltip="Fork chat from this message">
|
||||
<button
|
||||
onClick={() => handleRewind(messageId)}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
onClick={() => handleFork(messageId)}
|
||||
key="i-ph:git-fork"
|
||||
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',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
|
||||
<WithTooltip tooltip="Fork chat from this message">
|
||||
<button
|
||||
onClick={() => handleFork(messageId)}
|
||||
key="i-ph:git-fork"
|
||||
className={classNames(
|
||||
'i-ph:git-fork',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
)}
|
||||
{getAnnotationsTokensUsage(message.annotations) &&
|
||||
getLastMessageProjectPrompt(index) && (
|
||||
<WithTooltip tooltip="Save prompt as new problem">
|
||||
<button
|
||||
onClick={() => {
|
||||
const prompt = getLastMessageProjectPrompt(index);
|
||||
assert(prompt);
|
||||
handleSaveProblem(prompt);
|
||||
}}
|
||||
key="i-ph:export"
|
||||
className={classNames(
|
||||
'i-ph:export',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{isStreaming && (
|
||||
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReactModal
|
||||
isOpen={isModalOpen}
|
||||
onRequestClose={() => setIsModalOpen(false)}
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 max-w-2xl w-full z-50"
|
||||
overlayClassName="fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||
>
|
||||
{problemId && (
|
||||
<>
|
||||
<div className="text-center mb-2">Problem Submitted: {problemId}</div>
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<button onClick={() => setIsModalOpen(false)} className="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">Close</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{isStreaming && (
|
||||
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!problemId && (
|
||||
<>
|
||||
<div className="text-center">Save prompts as new problems when AI results are unsatisfactory.</div>
|
||||
<div className="text-center">Problems are publicly visible and are used to improve AI performance.</div>
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-4 max-w-md mx-auto">
|
||||
<div className="flex items-center">Title:</div>
|
||||
<input type="text"
|
||||
name="title"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 w-full border border-gray-300"
|
||||
value={formData.title}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div className="flex items-center">Description:</div>
|
||||
<input type="text"
|
||||
name="description"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 w-full border border-gray-300"
|
||||
value={formData.description}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div className="flex items-center">Name (optional):</div>
|
||||
<input type="text"
|
||||
name="name"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 w-full border border-gray-300"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div className="flex items-center">Email (optional):</div>
|
||||
<input type="text"
|
||||
name="email"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 w-full border border-gray-300"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<button onClick={handleSubmitProblem} className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Submit</button>
|
||||
<button onClick={() => setIsModalOpen(false)} className="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ReactModal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
|
||||
import { LoadProblemButton } from '~/components/chat/LoadProblemButton';
|
||||
|
||||
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
|
||||
return (
|
||||
@@ -63,6 +64,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
|
||||
importChat={importChat}
|
||||
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
|
||||
/>
|
||||
<LoadProblemButton
|
||||
importChat={importChat}
|
||||
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface BaseIconButtonProps {
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
type IconButtonWithoutChildrenProps = {
|
||||
@@ -39,6 +40,7 @@ export const IconButton = memo(
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
style,
|
||||
}: IconButtonProps,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) => {
|
||||
@@ -61,6 +63,7 @@ export const IconButton = memo(
|
||||
|
||||
onClick?.(event);
|
||||
}}
|
||||
style={style}
|
||||
>
|
||||
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
|
||||
</button>
|
||||
|
||||
92
app/components/workbench/PointSelector.tsx
Normal file
92
app/components/workbench/PointSelector.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { getMouseData } from './Recording';
|
||||
|
||||
interface PointSelectorProps {
|
||||
isSelectionMode: boolean;
|
||||
setIsSelectionMode: (mode: boolean) => void;
|
||||
selectionPoint: { x: number; y: number } | null;
|
||||
setSelectionPoint: (point: { x: number; y: number } | null) => void;
|
||||
recordingSaved: boolean;
|
||||
containerRef: React.RefObject<HTMLIFrameElement>;
|
||||
}
|
||||
|
||||
export const PointSelector = memo(
|
||||
(props: PointSelectorProps) => {
|
||||
const {
|
||||
isSelectionMode,
|
||||
recordingSaved,
|
||||
setIsSelectionMode,
|
||||
selectionPoint,
|
||||
setSelectionPoint,
|
||||
containerRef,
|
||||
} = props;
|
||||
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
const handleSelectionClick = useCallback(async (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!isSelectionMode || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
setSelectionPoint({ x, y });
|
||||
|
||||
setIsCapturing(true);
|
||||
|
||||
const mouseData = await getMouseData(containerRef.current, { x, y });
|
||||
console.log("MouseData", mouseData);
|
||||
|
||||
setIsCapturing(false);
|
||||
setIsSelectionMode(false); // Turn off selection mode after capture
|
||||
}, [isSelectionMode, containerRef, setIsSelectionMode]);
|
||||
|
||||
if (!isSelectionMode) {
|
||||
if (recordingSaved) {
|
||||
// Draw an overlay to prevent interactions with the iframe
|
||||
// and to show the last point the user clicked (if there is one).
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
{ selectionPoint && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${selectionPoint.x-8}px`,
|
||||
top: `${selectionPoint.y-12}px`,
|
||||
}}
|
||||
>
|
||||
❌
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
onClick={handleSelectionClick}
|
||||
style={{
|
||||
backgroundColor: isCapturing ? 'transparent' : 'rgba(0, 0, 0, 0.1)',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
pointerEvents: 'all',
|
||||
opacity: isCapturing ? 0 : 1,
|
||||
zIndex: 50,
|
||||
transition: 'opacity 0.1s ease-in-out',
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -3,7 +3,9 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { PortDropdown } from './PortDropdown';
|
||||
import { ScreenshotSelector } from './ScreenshotSelector';
|
||||
import { PointSelector } from './PointSelector';
|
||||
import { saveReplayRecording } from './Recording';
|
||||
import { assert } from './ReplayProtocolClient';
|
||||
|
||||
type ResizeSide = 'left' | 'right' | null;
|
||||
|
||||
@@ -22,6 +24,14 @@ export const Preview = memo(() => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectionPoint, setSelectionPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
// Once a recording has been saved, the preview can no longer be interacted with.
|
||||
// Reloading the preview or regenerating it after code changes will reset this.
|
||||
const [recordingSaved, setRecordingSaved] = useState(false);
|
||||
|
||||
// The ID of the recording that was created. If a recording has been saved but
|
||||
// no ID is set, the recording is still being created.
|
||||
const [recordingId, setRecordingId] = useState<string | undefined>();
|
||||
|
||||
// Toggle between responsive mode and device mode
|
||||
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||
@@ -53,21 +63,46 @@ export const Preview = memo(() => {
|
||||
setIframeUrl(baseUrl);
|
||||
}, [activePreview]);
|
||||
|
||||
// Trim any long base URL from the start of the provided URL.
|
||||
const displayUrl = (url: string) => {
|
||||
if (!activePreview) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const { baseUrl } = activePreview;
|
||||
|
||||
if (url.startsWith(baseUrl)) {
|
||||
const trimmedUrl = url.slice(baseUrl.length);
|
||||
if (trimmedUrl.startsWith('/')) {
|
||||
return trimmedUrl;
|
||||
}
|
||||
return "/" + trimmedUrl;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
const validateUrl = useCallback(
|
||||
(value: string) => {
|
||||
if (!activePreview) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const { baseUrl } = activePreview;
|
||||
|
||||
if (value === baseUrl) {
|
||||
return true;
|
||||
return value;
|
||||
} else if (value.startsWith(baseUrl)) {
|
||||
return ['/', '?', '#'].includes(value.charAt(baseUrl.length));
|
||||
if (['/', '?', '#'].includes(value.charAt(baseUrl.length))) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
if (value.startsWith('/')) {
|
||||
return baseUrl + value;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[activePreview],
|
||||
);
|
||||
@@ -213,6 +248,16 @@ export const Preview = memo(() => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const beginSaveRecording = () => {
|
||||
assert(!recordingSaved);
|
||||
setRecordingSaved(true);
|
||||
|
||||
assert(iframeRef.current);
|
||||
saveReplayRecording(iframeRef.current).then((id) => {
|
||||
setRecordingId(id);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full flex flex-col relative">
|
||||
{isPortDropdownOpen && (
|
||||
@@ -220,11 +265,20 @@ export const Preview = memo(() => {
|
||||
)}
|
||||
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
||||
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
||||
<IconButton
|
||||
icon="i-ph:selection"
|
||||
onClick={() => setIsSelectionMode(!isSelectionMode)}
|
||||
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
|
||||
/>
|
||||
{!recordingSaved && (
|
||||
<IconButton
|
||||
icon="i-ph:record-fill"
|
||||
onClick={beginSaveRecording}
|
||||
style={{ color: 'red' }}
|
||||
/>
|
||||
)}
|
||||
{recordingSaved && (
|
||||
<IconButton
|
||||
icon="i-ph:cursor-click"
|
||||
onClick={() => setIsSelectionMode(!isSelectionMode)}
|
||||
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
|
||||
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
|
||||
@@ -234,13 +288,14 @@ export const Preview = memo(() => {
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent outline-none"
|
||||
type="text"
|
||||
value={url}
|
||||
value={displayUrl(url)}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && validateUrl(url)) {
|
||||
setIframeUrl(url);
|
||||
let newUrl;
|
||||
if (event.key === 'Enter' && (newUrl = validateUrl(url))) {
|
||||
setIframeUrl(newUrl);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.blur();
|
||||
@@ -296,9 +351,12 @@ export const Preview = memo(() => {
|
||||
src={iframeUrl}
|
||||
allowFullScreen
|
||||
/>
|
||||
<ScreenshotSelector
|
||||
<PointSelector
|
||||
isSelectionMode={isSelectionMode}
|
||||
recordingSaved={recordingSaved}
|
||||
setIsSelectionMode={setIsSelectionMode}
|
||||
selectionPoint={selectionPoint}
|
||||
setSelectionPoint={setSelectionPoint}
|
||||
containerRef={iframeRef}
|
||||
/>
|
||||
</>
|
||||
|
||||
652
app/components/workbench/Recording.ts
Normal file
652
app/components/workbench/Recording.ts
Normal file
@@ -0,0 +1,652 @@
|
||||
// Manage state around recording Preview behavior for generating a Replay recording.
|
||||
|
||||
import { assert, sendCommandDedicatedClient, stringToBase64, uint8ArrayToBase64 } from "./ReplayProtocolClient";
|
||||
|
||||
interface RerecordResource {
|
||||
url: string;
|
||||
requestBodyBase64: string;
|
||||
responseBodyBase64: string;
|
||||
responseStatus: number;
|
||||
responseHeaders: Record<string, string>;
|
||||
}
|
||||
|
||||
enum RerecordInteractionKind {
|
||||
Click = "click",
|
||||
DblClick = "dblclick",
|
||||
KeyDown = "keydown",
|
||||
}
|
||||
|
||||
export interface RerecordInteraction {
|
||||
kind: RerecordInteractionKind;
|
||||
|
||||
// Elapsed time when the interaction occurred.
|
||||
time: number;
|
||||
|
||||
// Selector of the element associated with the interaction.
|
||||
selector: string;
|
||||
|
||||
// For mouse interactions, dimensions and position within the
|
||||
// element where the event occurred.
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
|
||||
// For keydown interactions, the key pressed.
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface IndexedDBAccess {
|
||||
kind: "get" | "put" | "add";
|
||||
key?: any;
|
||||
item?: any;
|
||||
storeName: string;
|
||||
databaseName: string;
|
||||
databaseVersion: number;
|
||||
}
|
||||
|
||||
interface LocalStorageAccess {
|
||||
kind: "get" | "set";
|
||||
key: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface RerecordData {
|
||||
// Contents of window.location.href.
|
||||
locationHref: string;
|
||||
|
||||
// URL of the main document.
|
||||
documentUrl: string;
|
||||
|
||||
// All resources accessed.
|
||||
resources: RerecordResource[];
|
||||
|
||||
// All user interactions made.
|
||||
interactions: RerecordInteraction[];
|
||||
|
||||
// All indexedDB accesses made.
|
||||
indexedDBAccesses?: IndexedDBAccess[];
|
||||
|
||||
// All localStorage accesses made.
|
||||
localStorageAccesses?: LocalStorageAccess[];
|
||||
}
|
||||
|
||||
// This is in place to workaround some insane behavior where messages are being
|
||||
// sent by iframes running older versions of the recording data logic, even after
|
||||
// quitting and restarting the entire browser. Maybe related to webcontainers?
|
||||
const RecordingDataVersion = 2;
|
||||
|
||||
export async function saveReplayRecording(iframe: HTMLIFrameElement) {
|
||||
assert(iframe.contentWindow);
|
||||
iframe.contentWindow.postMessage({ source: "recording-data-request" }, "*");
|
||||
|
||||
const data = await new Promise((resolve) => {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data?.source == "recording-data-response" &&
|
||||
event.data?.version == RecordingDataVersion) {
|
||||
const decoder = new TextDecoder();
|
||||
const jsonString = decoder.decode(event.data.buffer);
|
||||
const data = JSON.parse(jsonString) as RerecordData;
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log("RerecordData", JSON.stringify(data));
|
||||
|
||||
const rerecordRval = await sendCommandDedicatedClient({
|
||||
method: "Recording.globalExperimentalCommand",
|
||||
params: {
|
||||
name: "rerecordGenerate",
|
||||
params: {
|
||||
rerecordData: data,
|
||||
// FIXME the backend should not require an API key for this command.
|
||||
// For now we use an API key used in Replay's devtools (which is public
|
||||
// but probably shouldn't be).
|
||||
apiKey: "rwk_b6mnJ00rI4pzlwkYmggmmmV1TVQXA0AUktRHoo4vGl9",
|
||||
// FIXME the backend currently requires this but shouldn't.
|
||||
recordingId: "dummy-recording-id",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log("RerecordRval", rerecordRval);
|
||||
|
||||
const recordingId = (rerecordRval as any).rval.rerecordedRecordingId as string;
|
||||
console.log("CreatedRecording", recordingId);
|
||||
return recordingId;
|
||||
}
|
||||
|
||||
export async function getMouseData(iframe: HTMLIFrameElement, position: { x: number; y: number }) {
|
||||
assert(iframe.contentWindow);
|
||||
iframe.contentWindow.postMessage({ source: "mouse-data-request", position }, "*");
|
||||
|
||||
const mouseData = await new Promise((resolve) => {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data?.source == "mouse-data-response") {
|
||||
resolve(event.data.mouseData);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return mouseData;
|
||||
}
|
||||
|
||||
function addRecordingMessageHandler() {
|
||||
const resources: Map<string, RerecordResource> = new Map();
|
||||
const interactions: RerecordInteraction[] = [];
|
||||
const indexedDBAccesses: IndexedDBAccess[] = [];
|
||||
const localStorageAccesses: LocalStorageAccess[] = [];
|
||||
|
||||
// Promises which will resolve when all resources have been added.
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
// Set of URLs which are currently being fetched.
|
||||
const pendingFetches = new Set<string>();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
function getScriptImports(text: string) {
|
||||
// TODO: This should use a real parser.
|
||||
const imports: string[] = [];
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line, index) => {
|
||||
let match = line.match(/^import.*?['"]([^'")]+)/);
|
||||
if (match) {
|
||||
imports.push(match[1]);
|
||||
}
|
||||
match = line.match(/^export.*?from ['"]([^'")]+)/);
|
||||
if (match) {
|
||||
imports.push(match[1]);
|
||||
}
|
||||
if (line == "import {" || line == "export {") {
|
||||
for (let i = index + 1; i < lines.length; i++) {
|
||||
const match = lines[i].match(/} from ['"]([^'")]+)/);
|
||||
if (match) {
|
||||
imports.push(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return imports;
|
||||
}
|
||||
|
||||
function addTextResource(path: string, text: string) {
|
||||
const url = (new URL(path, window.location.href)).href;
|
||||
if (resources.has(url)) {
|
||||
return;
|
||||
}
|
||||
resources.set(url, {
|
||||
url,
|
||||
requestBodyBase64: "",
|
||||
responseBodyBase64: stringToBase64(text),
|
||||
responseStatus: 200,
|
||||
responseHeaders: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAndAddResource(path: string) {
|
||||
pendingFetches.add(path);
|
||||
const response = await baseFetch(path);
|
||||
pendingFetches.delete(path);
|
||||
|
||||
const text = await response.text();
|
||||
const responseHeaders = Object.fromEntries(response.headers.entries());
|
||||
|
||||
const url = (new URL(path, window.location.href)).href;
|
||||
if (resources.has(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
resources.set(url, {
|
||||
url,
|
||||
requestBodyBase64: "",
|
||||
responseBodyBase64: stringToBase64(text),
|
||||
responseStatus: response.status,
|
||||
responseHeaders,
|
||||
});
|
||||
|
||||
const contentType = responseHeaders["content-type"];
|
||||
|
||||
// MIME types that can contain JS.
|
||||
const JavaScriptMimeTypes = ["application/javascript", "text/javascript", "text/html"];
|
||||
|
||||
if (JavaScriptMimeTypes.includes(contentType)) {
|
||||
const imports = getScriptImports(text);
|
||||
for (const path of imports) {
|
||||
promises.push(fetchAndAddResource(path));
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType == "text/html") {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, "text/html");
|
||||
const scripts = doc.querySelectorAll("script");
|
||||
for (const script of scripts) {
|
||||
promises.push(fetchAndAddResource(script.src));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getRerecordData(): Promise<RerecordData> {
|
||||
// For now we only deal with cases where there is a single HTML page whose
|
||||
// contents are expected to be filled in by the client code. We do this to
|
||||
// avoid difficulties in exactly emulating the webcontainer's behavior when
|
||||
// generating the recording.
|
||||
let htmlContents = "<html><body>";
|
||||
|
||||
// Vite needs this to be set for the react plugin to work.
|
||||
htmlContents += "<script>window.__vite_plugin_react_preamble_installed__ = true;</script>";
|
||||
|
||||
// Find all script elements and add them to the document.
|
||||
const scriptElements = document.getElementsByTagName('script');
|
||||
[...scriptElements].forEach((script, index) => {
|
||||
let src = script.src;
|
||||
if (src) {
|
||||
promises.push(fetchAndAddResource(src));
|
||||
} else {
|
||||
assert(script.textContent, "Script element has no src and no text content");
|
||||
const path = `script-${index}.js`;
|
||||
addTextResource(path, script.textContent);
|
||||
}
|
||||
const { origin } = new URL(window.location.href);
|
||||
if (src.startsWith(origin)) {
|
||||
src = src.slice(origin.length);
|
||||
}
|
||||
htmlContents += `<script src="${src}" type="${script.type}"></script>`;
|
||||
});
|
||||
|
||||
// Find all inline styles and add them to the document.
|
||||
const cssElements = document.getElementsByTagName('style');
|
||||
for (const style of cssElements) {
|
||||
htmlContents += `<style>${style.textContent}</style>`;
|
||||
}
|
||||
|
||||
// Find all stylesheet links and add them to the document.
|
||||
const linkElements = document.getElementsByTagName('link');
|
||||
for (const link of linkElements) {
|
||||
if (link.rel === 'stylesheet' && link.href) {
|
||||
promises.push(fetchAndAddResource(link.href));
|
||||
htmlContents += `<link rel="stylesheet" href="${link.href}">`;
|
||||
}
|
||||
}
|
||||
|
||||
// React needs a root element to mount into.
|
||||
htmlContents += "<div id='root'></div>";
|
||||
|
||||
htmlContents += "</body></html>";
|
||||
|
||||
addTextResource(window.location.href, htmlContents);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
console.log("PendingFetches", pendingFetches.size, pendingFetches);
|
||||
}, 1000);
|
||||
|
||||
while (true) {
|
||||
const length = promises.length;
|
||||
await Promise.all(promises);
|
||||
if (promises.length == length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
clearInterval(interval);
|
||||
|
||||
const data: RerecordData = {
|
||||
locationHref: window.location.href,
|
||||
documentUrl: window.location.href,
|
||||
resources: Array.from(resources.values()),
|
||||
interactions,
|
||||
indexedDBAccesses,
|
||||
localStorageAccesses,
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
window.addEventListener("message", async (event) => {
|
||||
switch (event.data?.source) {
|
||||
case "recording-data-request": {
|
||||
const data = await getRerecordData();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const serializedData = encoder.encode(JSON.stringify(data));
|
||||
const buffer = serializedData.buffer;
|
||||
|
||||
window.parent.postMessage({ source: "recording-data-response", buffer, version: RecordingDataVersion }, "*", [buffer]);
|
||||
break;
|
||||
}
|
||||
case "mouse-data-request": {
|
||||
const { x, y } = event.data.position;
|
||||
const element = document.elementFromPoint(x, y);
|
||||
assert(element);
|
||||
|
||||
const selector = computeSelector(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
const mouseData = {
|
||||
selector,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: x - rect.x,
|
||||
y: y - rect.y,
|
||||
};
|
||||
window.parent.postMessage({ source: "mouse-data-response", mouseData }, "*");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Evaluated function to find the selector and associated data.
|
||||
function getMouseEventData(event: MouseEvent) {
|
||||
assert(event.target);
|
||||
const target = event.target as Element;
|
||||
const selector = computeSelector(target);
|
||||
const rect = target.getBoundingClientRect();
|
||||
return {
|
||||
selector,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: event.clientX - rect.x,
|
||||
y: event.clientY - rect.y,
|
||||
};
|
||||
}
|
||||
|
||||
function getKeyboardEventData(event: KeyboardEvent) {
|
||||
assert(event.target);
|
||||
const target = event.target as Element;
|
||||
const selector = computeSelector(target);
|
||||
return {
|
||||
selector,
|
||||
key: event.key,
|
||||
};
|
||||
}
|
||||
|
||||
function computeSelector(target: Element): string {
|
||||
// Build a unique selector by walking up the DOM tree
|
||||
const path: string[] = [];
|
||||
let current: Element | null = target;
|
||||
|
||||
while (current) {
|
||||
// If element has an ID, use it as it's the most specific
|
||||
if (current.id) {
|
||||
path.unshift(`#${current.id}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Get the element's tag name
|
||||
let selector = current.tagName.toLowerCase();
|
||||
|
||||
// Add nth-child if there are siblings
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children);
|
||||
const index = siblings.indexOf(current) + 1;
|
||||
if (siblings.filter(el => el.tagName === current!.tagName).length > 1) {
|
||||
selector += `:nth-child(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return path.join(" > ");
|
||||
}
|
||||
|
||||
window.addEventListener("click", (event) => {
|
||||
if (event.target) {
|
||||
interactions.push({
|
||||
kind: RerecordInteractionKind.Click,
|
||||
time: Date.now() - startTime,
|
||||
...getMouseEventData(event)
|
||||
});
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
window.addEventListener("dblclick", (event) => {
|
||||
if (event.target) {
|
||||
interactions.push({
|
||||
kind: RerecordInteractionKind.DblClick,
|
||||
time: Date.now() - startTime,
|
||||
...getMouseEventData(event)
|
||||
});
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key) {
|
||||
interactions.push({
|
||||
kind: RerecordInteractionKind.KeyDown,
|
||||
time: Date.now() - startTime,
|
||||
...getKeyboardEventData(event)
|
||||
});
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
function onInterceptedOperation(name: string) {
|
||||
console.log(`InterceptedOperation ${name}`);
|
||||
}
|
||||
|
||||
function interceptProperty(
|
||||
obj: object,
|
||||
prop: string,
|
||||
interceptor: (basevalue: any) => any
|
||||
) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(obj, prop);
|
||||
assert(descriptor?.get, "Property must have a getter");
|
||||
|
||||
let interceptValue: any;
|
||||
Object.defineProperty(obj, prop, {
|
||||
...descriptor,
|
||||
get() {
|
||||
onInterceptedOperation(`Getter:${prop}`);
|
||||
if (!interceptValue) {
|
||||
const baseValue = (descriptor?.get as any).call(obj);
|
||||
interceptValue = interceptor(baseValue);
|
||||
}
|
||||
return interceptValue;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const IDBFactoryMethods = {
|
||||
_name: "IDBFactory",
|
||||
open: (v: any) => createFunctionProxy(v, "open"),
|
||||
};
|
||||
|
||||
const IDBOpenDBRequestMethods = {
|
||||
_name: "IDBOpenDBRequest",
|
||||
result: createProxy,
|
||||
};
|
||||
|
||||
const IDBDatabaseMethods = {
|
||||
_name: "IDBDatabase",
|
||||
transaction: (v: any) => createFunctionProxy(v, "transaction"),
|
||||
};
|
||||
|
||||
const IDBTransactionMethods = {
|
||||
_name: "IDBTransaction",
|
||||
objectStore: (v: any) => createFunctionProxy(v, "objectStore"),
|
||||
};
|
||||
|
||||
function pushIndexedDBAccess(
|
||||
request: IDBRequest,
|
||||
kind: IndexedDBAccess["kind"],
|
||||
key: any,
|
||||
item: any
|
||||
) {
|
||||
indexedDBAccesses.push({
|
||||
kind,
|
||||
key,
|
||||
item,
|
||||
storeName: (request.source as any).name,
|
||||
databaseName: (request.transaction as any).db.name,
|
||||
databaseVersion: (request.transaction as any).db.version,
|
||||
});
|
||||
}
|
||||
|
||||
// Map "get" requests to their keys.
|
||||
const getRequestKeys: Map<IDBRequest, any> = new Map();
|
||||
|
||||
const IDBObjectStoreMethods = {
|
||||
_name: "IDBObjectStore",
|
||||
get: (v: any) =>
|
||||
createFunctionProxy(v, "get", (request, key) => {
|
||||
// Wait to add the request until the value is known.
|
||||
getRequestKeys.set(request, key);
|
||||
return createProxy(request);
|
||||
}),
|
||||
put: (v: any) =>
|
||||
createFunctionProxy(v, "put", (request, item, key) => {
|
||||
pushIndexedDBAccess(request, "put", key, item);
|
||||
return createProxy(request);
|
||||
}),
|
||||
add: (v: any) =>
|
||||
createFunctionProxy(v, "add", (request, item, key) => {
|
||||
pushIndexedDBAccess(request, "add", key, item);
|
||||
return createProxy(request);
|
||||
}),
|
||||
};
|
||||
|
||||
const IDBRequestMethods = {
|
||||
_name: "IDBRequest",
|
||||
result: (value: any, target: any) => {
|
||||
const key = getRequestKeys.get(target);
|
||||
if (key) {
|
||||
pushIndexedDBAccess(target, "get", key, value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
|
||||
function pushLocalStorageAccess(
|
||||
kind: LocalStorageAccess["kind"],
|
||||
key: string,
|
||||
value?: string
|
||||
) {
|
||||
localStorageAccesses.push({ kind, key, value });
|
||||
}
|
||||
|
||||
const StorageMethods = {
|
||||
_name: "Storage",
|
||||
getItem: (v: any) =>
|
||||
createFunctionProxy(v, "getItem", (value: string, key: string) => {
|
||||
pushLocalStorageAccess("get", key, value);
|
||||
return value;
|
||||
}),
|
||||
setItem: (v: any) =>
|
||||
createFunctionProxy(v, "setItem", (_rv: undefined, key: string) => {
|
||||
pushLocalStorageAccess("set", key);
|
||||
}),
|
||||
};
|
||||
|
||||
// Map Response to the triggering URL before redirects.
|
||||
const responseToURL = new WeakMap<Response, string>();
|
||||
|
||||
const ResponseMethods = {
|
||||
_name: "Response",
|
||||
json: (v: any, response: Response) =>
|
||||
createFunctionProxy(v, "json", async (promise: Promise<any>) => {
|
||||
const json = await promise;
|
||||
const url = responseToURL.get(response);
|
||||
if (url) {
|
||||
addTextResource(url, JSON.stringify(json));
|
||||
}
|
||||
return json;
|
||||
}),
|
||||
text: (v: any, response: Response) =>
|
||||
createFunctionProxy(v, "text", async (promise: Promise<any>) => {
|
||||
const text = await promise;
|
||||
const url = responseToURL.get(response);
|
||||
if (url) {
|
||||
addTextResource(url, text);
|
||||
}
|
||||
return text;
|
||||
}),
|
||||
};
|
||||
|
||||
function createProxy(obj: any) {
|
||||
let methods;
|
||||
if (obj instanceof IDBFactory) {
|
||||
methods = IDBFactoryMethods;
|
||||
} else if (obj instanceof IDBOpenDBRequest) {
|
||||
methods = IDBOpenDBRequestMethods;
|
||||
} else if (obj instanceof IDBDatabase) {
|
||||
methods = IDBDatabaseMethods;
|
||||
} else if (obj instanceof IDBTransaction) {
|
||||
methods = IDBTransactionMethods;
|
||||
} else if (obj instanceof IDBObjectStore) {
|
||||
methods = IDBObjectStoreMethods;
|
||||
} else if (obj instanceof IDBRequest) {
|
||||
methods = IDBRequestMethods;
|
||||
} else if (obj instanceof Storage) {
|
||||
methods = StorageMethods;
|
||||
} else if (obj instanceof Response) {
|
||||
methods = ResponseMethods;
|
||||
}
|
||||
assert(methods, "Unknown object for createProxy");
|
||||
const name = methods._name;
|
||||
|
||||
return new Proxy(obj, {
|
||||
get(target, prop) {
|
||||
onInterceptedOperation(`ProxyGetter:${name}.${String(prop)}`);
|
||||
let value = target[prop];
|
||||
if (typeof value === "function") {
|
||||
value = value.bind(target);
|
||||
}
|
||||
if (methods[prop]) {
|
||||
value = methods[prop](value, target);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
set(target, prop, value) {
|
||||
onInterceptedOperation(`ProxySetter:${name}.${String(prop)}`);
|
||||
target[prop] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createFunctionProxy(
|
||||
fn: any,
|
||||
name: string,
|
||||
handler?: (v: any, ...args: any[]) => any
|
||||
) {
|
||||
return (...args: any[]) => {
|
||||
onInterceptedOperation(`FunctionCall:${name}`);
|
||||
const v = fn(...args);
|
||||
return handler ? handler(v, ...args) : createProxy(v);
|
||||
};
|
||||
}
|
||||
|
||||
interceptProperty(window, "indexedDB", createProxy);
|
||||
interceptProperty(window, "localStorage", createProxy);
|
||||
|
||||
const baseFetch = window.fetch;
|
||||
window.fetch = async (info, options) => {
|
||||
const rv = await baseFetch(info, options);
|
||||
const url = info instanceof Request ? info.url : info.toString();
|
||||
responseToURL.set(rv, url);
|
||||
return createProxy(rv);
|
||||
};
|
||||
}
|
||||
|
||||
export function injectRecordingMessageHandler(content: string) {
|
||||
const headTag = content.indexOf("<head>");
|
||||
assert(headTag != -1, "No <head> tag found");
|
||||
|
||||
const headEnd = headTag + 6;
|
||||
|
||||
const text = `
|
||||
<script>
|
||||
${assert}
|
||||
${stringToBase64}
|
||||
${uint8ArrayToBase64}
|
||||
(${addRecordingMessageHandler.toString().replace("RecordingDataVersion", `${RecordingDataVersion}`)})()
|
||||
</script>
|
||||
`;
|
||||
|
||||
return content.slice(0, headEnd) + text + content.slice(headEnd);
|
||||
}
|
||||
182
app/components/workbench/ReplayProtocolClient.ts
Normal file
182
app/components/workbench/ReplayProtocolClient.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
const replayWsServer = "wss://dispatch.replay.io";
|
||||
|
||||
export function assert(condition: any, message: string = "Assertion failed!"): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function uint8ArrayToBase64(data: Uint8Array) {
|
||||
let str = "";
|
||||
for (const byte of data) {
|
||||
str += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(str);
|
||||
}
|
||||
|
||||
export function stringToBase64(inputString: string) {
|
||||
if (typeof inputString !== "string") {
|
||||
throw new TypeError("Input must be a string.");
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(inputString);
|
||||
return uint8ArrayToBase64(data);
|
||||
}
|
||||
|
||||
function logDebug(msg: string, tags: Record<string, any> = {}) {
|
||||
console.log(msg, JSON.stringify(tags));
|
||||
}
|
||||
|
||||
class ProtocolError extends Error {
|
||||
protocolCode;
|
||||
protocolMessage;
|
||||
protocolData;
|
||||
|
||||
constructor(error: any) {
|
||||
super(`protocol error ${error.code}: ${error.message}`);
|
||||
|
||||
this.protocolCode = error.code;
|
||||
this.protocolMessage = error.message;
|
||||
this.protocolData = error.data ?? {};
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `Protocol error ${this.protocolCode}: ${this.protocolMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve: (value: T) => void;
|
||||
let reject: (reason?: any) => void;
|
||||
const promise = new Promise<T>((_resolve, _reject) => {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
});
|
||||
return { promise, resolve: resolve!, reject: reject! };
|
||||
}
|
||||
|
||||
type EventListener = (params: any) => void;
|
||||
|
||||
export class ProtocolClient {
|
||||
openDeferred = createDeferred<void>();
|
||||
eventListeners = new Map<string, Set<EventListener>>();
|
||||
nextMessageId = 1;
|
||||
pendingCommands = new Map<number, Deferred<any>>();
|
||||
socket: WebSocket;
|
||||
|
||||
constructor() {
|
||||
logDebug(`Creating WebSocket for ${replayWsServer}`);
|
||||
|
||||
this.socket = new WebSocket(replayWsServer);
|
||||
|
||||
this.socket.addEventListener("close", this.onSocketClose);
|
||||
this.socket.addEventListener("error", this.onSocketError);
|
||||
this.socket.addEventListener("open", this.onSocketOpen);
|
||||
this.socket.addEventListener("message", this.onSocketMessage);
|
||||
|
||||
this.listenForMessage("Recording.sessionError", (error: any) => {
|
||||
logDebug(`Session error ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
initialize() {
|
||||
return this.openDeferred.promise;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
|
||||
listenForMessage(method: string, callback: (params: any) => void) {
|
||||
let listeners = this.eventListeners.get(method);
|
||||
if (listeners == null) {
|
||||
listeners = new Set([callback]);
|
||||
|
||||
this.eventListeners.set(method, listeners);
|
||||
} else {
|
||||
listeners.add(callback);
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
sendCommand(args: { method: string; params: any; sessionId?: string }) {
|
||||
const id = this.nextMessageId++;
|
||||
|
||||
const { method, params, sessionId } = args;
|
||||
logDebug("Sending command", { id, method, params, sessionId });
|
||||
|
||||
const command = {
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
sessionId,
|
||||
};
|
||||
|
||||
this.socket.send(JSON.stringify(command));
|
||||
|
||||
const deferred = createDeferred();
|
||||
this.pendingCommands.set(id, deferred);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
onSocketClose = () => {
|
||||
logDebug("Socket closed");
|
||||
};
|
||||
|
||||
onSocketError = (error: any) => {
|
||||
logDebug(`Socket error ${error}`);
|
||||
};
|
||||
|
||||
onSocketMessage = (event: MessageEvent) => {
|
||||
const { error, id, method, params, result } = JSON.parse(String(event.data));
|
||||
|
||||
if (id) {
|
||||
const deferred = this.pendingCommands.get(id);
|
||||
assert(deferred, `Received message with unknown id: ${id}`);
|
||||
|
||||
this.pendingCommands.delete(id);
|
||||
if (result) {
|
||||
deferred.resolve(result);
|
||||
} else if (error) {
|
||||
console.error("ProtocolError", error);
|
||||
deferred.reject(new ProtocolError(error));
|
||||
} else {
|
||||
deferred.reject(new Error("Channel error"));
|
||||
}
|
||||
} else if (this.eventListeners.has(method)) {
|
||||
const callbacks = this.eventListeners.get(method);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(callback => callback(params));
|
||||
}
|
||||
} else {
|
||||
logDebug("Received message without a handler", { method, params });
|
||||
}
|
||||
};
|
||||
|
||||
onSocketOpen = async () => {
|
||||
logDebug("Socket opened");
|
||||
this.openDeferred.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
// Send a single command with a one-use protocol client.
|
||||
export async function sendCommandDedicatedClient(args: { method: string; params: any }) {
|
||||
const client = new ProtocolClient();
|
||||
await client.initialize();
|
||||
try {
|
||||
const rval = await client.sendCommand(args);
|
||||
client.close();
|
||||
return rval;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,50 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleApplyChanges = useCallback(async () => {
|
||||
try {
|
||||
// Open file picker and get file handle
|
||||
const [fileHandle] = await (window as any).showOpenFilePicker({
|
||||
types: [
|
||||
{
|
||||
description: 'Text Files',
|
||||
accept: {
|
||||
'text/*': ['.txt', '.js', '.ts', '.jsx', '.tsx', '.json', '.html', '.css']
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const changesFile = await fileHandle.getFile();
|
||||
const changesContent = await changesFile.text();
|
||||
|
||||
let path = "";
|
||||
let contents = "";
|
||||
|
||||
async function saveCurrentFile() {
|
||||
if (path) {
|
||||
await workbenchStore.saveFileContents("/home/project/src/" + path, contents);
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of changesContent.split("\n")) {
|
||||
const match = /^FILE (.*)/.exec(line);
|
||||
if (match) {
|
||||
await saveCurrentFile();
|
||||
path = match[1];
|
||||
contents = "";
|
||||
continue;
|
||||
}
|
||||
contents += line + "\n";
|
||||
}
|
||||
|
||||
await saveCurrentFile();
|
||||
} catch (error) {
|
||||
console.error('Error applying changes:', error);
|
||||
toast.error('Failed to apply changes');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
chatStarted && (
|
||||
<motion.div
|
||||
@@ -146,6 +190,13 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
<div className="ml-auto" />
|
||||
{selectedView === 'code' && (
|
||||
<div className="flex overflow-y-auto">
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={handleApplyChanges}
|
||||
>
|
||||
<div className="i-ph:code" />
|
||||
Apply Changes
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
|
||||
@@ -200,6 +200,10 @@ export async function streamText(props: {
|
||||
systemPrompt = `${systemPrompt}\n\n ${codeContext}`;
|
||||
}
|
||||
|
||||
const coreMessages = convertToCoreMessages(processedMessages as any);
|
||||
|
||||
console.log("QueryModel", JSON.stringify({ systemPrompt, coreMessages }));
|
||||
|
||||
return _streamText({
|
||||
model: provider.getModelInstance({
|
||||
model: currentModel,
|
||||
@@ -209,7 +213,7 @@ export async function streamText(props: {
|
||||
}),
|
||||
system: systemPrompt,
|
||||
maxTokens: dynamicMaxTokens,
|
||||
messages: convertToCoreMessages(processedMessages as any),
|
||||
messages: coreMessages,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -337,6 +337,52 @@ Here are some examples of correct usage of artifacts:
|
||||
</assistant_response>
|
||||
</example>
|
||||
</examples>
|
||||
|
||||
ULTRA IMPORTANT: When asked to fix a bug, the results of console logs from the bug are available as comments in the source, in the format of "// Repro:Name console.log(xyz) == 123".
|
||||
Use these comments to get a better understanding of the bug and how data flows through the code, and describe what is causing the bug before starting on any code changes. You must fix the bug.
|
||||
|
||||
Here are examples of how to use these comments:
|
||||
|
||||
<examples>
|
||||
<example>
|
||||
<user_query>The Summation component does not render its result correctly. Fix this bug.</user_query>
|
||||
|
||||
<code_state>
|
||||
function computeSum(a, b) {
|
||||
return a + b;
|
||||
// Repro:computeSum console.log(rval) == NaN, console.log(a) == 3, console.log(b) == undefined
|
||||
}
|
||||
|
||||
function Summation(props) {
|
||||
const a = props.a;
|
||||
const b = props.b;
|
||||
const rval = computeSum(a, b);
|
||||
return <div>{rval}</div>; // Repro:Summation console.log(rval) == NaN
|
||||
}
|
||||
|
||||
function createSummation(a) {
|
||||
return <Summation a={3} b={undefined} />; // Repro:createSummation undefined value used by computeSum.
|
||||
}
|
||||
|
||||
function main() {
|
||||
createSummation(3); // Repro:main 3 value used by computeSum.
|
||||
}
|
||||
</code_state>
|
||||
|
||||
<assistant_response>
|
||||
1. From Repro:Summation I can see that the NaN value was produced at Repro:computeSum.
|
||||
2. From Repro:computeSum I can see that a is 3 and b is undefined. The latter is incorrect, it was produced at Repro:createSummation.
|
||||
|
||||
I will fix this by not passing undefined to the Summation component in createSummation.
|
||||
|
||||
<boltAction type="file" filePath="components/Summation.js">
|
||||
... code changes to fix bug ...
|
||||
</boltAction>
|
||||
</assistant_response>
|
||||
</example>
|
||||
</examples>
|
||||
|
||||
ULTRA IMPORTANT: NEVER add any logging. It is your responsibility to use the Repro console log comments to understand the bug. The user is not going to do this for you.
|
||||
`;
|
||||
|
||||
export const CONTINUE_PROMPT = stripIndents`
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createScopedLogger } from '~/utils/logger';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import type { ActionCallbackData } from './message-parser';
|
||||
import type { BoltShell } from '~/utils/shell';
|
||||
import { injectRecordingMessageHandler } from '~/components/workbench/Recording';
|
||||
|
||||
const logger = createScopedLogger('ActionRunner');
|
||||
|
||||
@@ -219,7 +220,11 @@ export class ActionRunner {
|
||||
}
|
||||
|
||||
try {
|
||||
await webcontainer.fs.writeFile(relativePath, action.content);
|
||||
let content = action.content;
|
||||
if (relativePath == "../../index.html") {
|
||||
content = injectRecordingMessageHandler(action.content);
|
||||
}
|
||||
await webcontainer.fs.writeFile(relativePath, content);
|
||||
logger.debug(`File written ${relativePath}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to write file\n\n', error);
|
||||
|
||||
@@ -93,7 +93,8 @@ export class FilesStore {
|
||||
const oldContent = this.getFile(filePath)?.content;
|
||||
|
||||
if (!oldContent) {
|
||||
unreachable('Expected content to be defined');
|
||||
console.log("CurrentFiles", JSON.stringify(Object.keys(this.files.get())));
|
||||
unreachable(`Cannot save unknown file ${filePath}`);
|
||||
}
|
||||
|
||||
await webcontainer.fs.writeFile(relativePath, content);
|
||||
|
||||
@@ -188,6 +188,10 @@ export class WorkbenchStore {
|
||||
this.unsavedFiles.set(newUnsavedFiles);
|
||||
}
|
||||
|
||||
async saveFileContents(filePath: string, contents: string) {
|
||||
await this.#filesStore.saveFile(filePath, contents);
|
||||
}
|
||||
|
||||
async saveCurrentDocument() {
|
||||
const currentDocument = this.currentDocument.get();
|
||||
|
||||
@@ -339,7 +343,7 @@ export class WorkbenchStore {
|
||||
return artifacts[id];
|
||||
}
|
||||
|
||||
async downloadZip() {
|
||||
async generateZip() {
|
||||
const zip = new JSZip();
|
||||
const files = this.files.get();
|
||||
|
||||
@@ -374,6 +378,11 @@ export class WorkbenchStore {
|
||||
|
||||
// Generate the zip file and save it
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
return { content, uniqueProjectName };
|
||||
}
|
||||
|
||||
async downloadZip() {
|
||||
const { content, uniqueProjectName } = await this.generateZip();
|
||||
saveAs(content, `${uniqueProjectName}.zip`);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const options: StreamingOptions = {
|
||||
toolChoice: 'none',
|
||||
onFinish: async ({ text: content, finishReason, usage }) => {
|
||||
console.log('usage', usage);
|
||||
console.log("QueryModelFinished", usage, content);
|
||||
|
||||
if (usage) {
|
||||
cumulativeUsage.completionTokens += usage.completionTokens || 0;
|
||||
|
||||
@@ -2,14 +2,15 @@ import type { Message } from 'ai';
|
||||
import { generateId } from './fileUtils';
|
||||
import { detectProjectCommands, createCommandsMessage } from './projectCommands';
|
||||
|
||||
export const createChatFromFolder = async (
|
||||
files: File[],
|
||||
binaryFiles: string[],
|
||||
folderName: string,
|
||||
): Promise<Message[]> => {
|
||||
const fileArtifacts = await Promise.all(
|
||||
export interface FileArtifact {
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function getFileArtifacts(files: File[]): Promise<FileArtifact[]> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
return new Promise<{ content: string; path: string }>((resolve, reject) => {
|
||||
return new Promise<FileArtifact>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
@@ -25,7 +26,13 @@ export const createChatFromFolder = async (
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const createChatFromFolder = async (
|
||||
fileArtifacts: FileArtifact[],
|
||||
binaryFiles: string[],
|
||||
folderName: string,
|
||||
): Promise<Message[]> => {
|
||||
const commands = await detectProjectCommands(fileArtifacts);
|
||||
const commandsMessage = createCommandsMessage(commands);
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-modal": "^3.16.3",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-toastify": "^10.0.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -111,6 +112,7 @@
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"fast-glob": "^3.3.2",
|
||||
"husky": "9.1.7",
|
||||
"is-ci": "^3.0.1",
|
||||
|
||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@@ -194,6 +194,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1(@types/react@18.3.12)(react@18.3.1)
|
||||
react-modal:
|
||||
specifier: ^3.16.3
|
||||
version: 3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-resizable-panels:
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -249,6 +252,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
'@types/react-modal':
|
||||
specifier: ^3.16.3
|
||||
version: 3.16.3
|
||||
fast-glob:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
@@ -2132,6 +2138,9 @@ packages:
|
||||
'@types/react-dom@18.3.1':
|
||||
resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==}
|
||||
|
||||
'@types/react-modal@3.16.3':
|
||||
resolution: {integrity: sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg==}
|
||||
|
||||
'@types/react@18.3.12':
|
||||
resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==}
|
||||
|
||||
@@ -3160,6 +3169,9 @@ packages:
|
||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
exenv@1.2.2:
|
||||
resolution: {integrity: sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==}
|
||||
|
||||
exit-hook@2.2.1:
|
||||
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4263,6 +4275,10 @@ packages:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.3:
|
||||
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4557,6 +4573,9 @@ packages:
|
||||
resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
property-information@6.5.0:
|
||||
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
|
||||
|
||||
@@ -4623,12 +4642,24 @@ packages:
|
||||
react: '>=16.8.1'
|
||||
react-dom: '>=16.8.1'
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-lifecycles-compat@3.0.4:
|
||||
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
|
||||
|
||||
react-markdown@9.0.1:
|
||||
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18'
|
||||
react: '>=18'
|
||||
|
||||
react-modal@3.16.3:
|
||||
resolution: {integrity: sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==}
|
||||
peerDependencies:
|
||||
react: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19
|
||||
react-dom: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19
|
||||
|
||||
react-refresh@0.14.2:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5620,6 +5651,9 @@ packages:
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
warning@4.0.3:
|
||||
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
|
||||
|
||||
wcwidth@1.0.1:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
@@ -7542,6 +7576,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/react': 18.3.12
|
||||
|
||||
'@types/react-modal@3.16.3':
|
||||
dependencies:
|
||||
'@types/react': 18.3.12
|
||||
|
||||
'@types/react@18.3.12':
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.13
|
||||
@@ -8809,6 +8847,8 @@ snapshots:
|
||||
signal-exit: 3.0.7
|
||||
strip-final-newline: 2.0.0
|
||||
|
||||
exenv@1.2.2: {}
|
||||
|
||||
exit-hook@2.2.1: {}
|
||||
|
||||
expect-type@1.1.0: {}
|
||||
@@ -10387,6 +10427,8 @@ snapshots:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.3: {}
|
||||
|
||||
object-is@1.1.6:
|
||||
@@ -10669,6 +10711,12 @@ snapshots:
|
||||
err-code: 2.0.3
|
||||
retry: 0.12.0
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
property-information@6.5.0: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
@@ -10746,6 +10794,10 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-lifecycles-compat@3.0.4: {}
|
||||
|
||||
react-markdown@9.0.1(@types/react@18.3.12)(react@18.3.1):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -10763,6 +10815,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
react-modal@3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
exenv: 1.2.2
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-lifecycles-compat: 3.0.4
|
||||
warning: 4.0.3
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1):
|
||||
@@ -11841,6 +11902,10 @@ snapshots:
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
warning@4.0.3:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
wcwidth@1.0.1:
|
||||
dependencies:
|
||||
defaults: 1.0.4
|
||||
@@ -11957,4 +12022,4 @@ snapshots:
|
||||
|
||||
zod@3.23.8: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
Reference in New Issue
Block a user