feat: initial commit

This commit is contained in:
Dominic Elm 2024-07-10 18:44:39 +02:00
commit 6927c07451
64 changed files with 12330 additions and 0 deletions

35
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,35 @@
name: CI
on:
push:
branches:
- master
pull_request:
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.20.3]
steps:
- name: Setup
uses: pnpm/action-setup@v4
with:
version: 9.4.0
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: pnpm install
- name: Run type check
run: pnpm run typecheck
- name: Run ESLint
run: pnpm run lint
- name: Run tests
run: pnpm run test

32
.github/workflows/semantic-pr.yaml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Semantic Pull Request
on:
pull_request_target:
types: [opened, reopened, edited, synchronize]
permissions:
pull-requests: read
jobs:
main:
name: Validate PR Title
runs-on: ubuntu-latest
steps:
# https://github.com/amannn/action-semantic-pull-request/releases/tag/v5.5.3
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
doesn't start with an uppercase character.
types: |
fix
feat
chore
build
ci
perf
docs
refactor
revert
test

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/launch.json
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
npm test

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
pnpm-lock.yaml
.astro

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"printWidth": 120,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true
}

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
nodejs 18.20.3
pnpm 9.4.0

16
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Current Test File",
"autoAttachChildProcesses": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["run", "${relativeFile}"],
"smartStep": true,
"console": "integratedTerminal"
}
]
}

49
README.md Normal file
View File

@ -0,0 +1,49 @@
# Bolt Monorepo
Welcome to the Bolt monorepo! This repository contains the codebase for Bolt, an AI assistant developed by StackBlitz.
## Repository Structure
Currently, this monorepo contains a single package:
- [`bolt`](packages/bolt): The main package containing the UI interface for Bolt as well as the server components.
As the project grows, additional packages may be added to this workspace.
## Getting Started
### Prerequisites
- Node.js (v18.20.3)
- pnpm (v9.4.0)
### Installation
1. Clone the repository:
```bash
git clone https://github.com/stackblitz/bolt.git
cd bolt
```
2. Install dependencies:
```bash
pnpm i
```
### Development
To start developing the Bolt UI:
1. Navigate to the bolt package:
```bash
cd packages/bolt
```
2. Start the development server:
```bash
pnpm run dev
```

26
eslint.config.mjs Normal file
View File

@ -0,0 +1,26 @@
import blitzPlugin from '@blitz/eslint-plugin';
import { getNamingConventionRule } from '@blitz/eslint-plugin/dist/configs/typescript.js';
export default [
{
ignores: ['**/dist', '**/node_modules'],
},
...blitzPlugin.configs.recommended(),
{
rules: {
'@blitz/catch-error-name': 'off',
},
},
{
files: ['**/*.tsx'],
rules: {
...getNamingConventionRule({}, true),
},
},
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/no-empty-object-type': 'off',
},
},
];

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"private": true,
"license": "MIT",
"packageManager": "pnpm@9.4.0",
"scripts": {
"playground:dev": "pnpm run --filter=playground dev",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"test": "pnpm run -r test",
"typecheck": "pnpm run -r typecheck",
"prepare": "husky"
},
"lint-staged": {},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint --edit $1"
}
},
"engines": {
"node": ">=18.18.0",
"pnpm": "9.4.0"
},
"devDependencies": {
"@blitz/eslint-plugin": "0.1.0",
"@commitlint/config-conventional": "^19.2.2",
"commitlint": "^19.3.0",
"husky": "^9.0.11",
"is-ci": "^3.0.1",
"prettier": "^3.3.2",
"vitest": "^2.0.1"
},
"resolutions": {
"@typescript-eslint/utils": "^8.0.0-alpha.30"
}
}

8
packages/bolt/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules
/.cache
/build
.env
*.vars
.wrangler
_worker.bundle

78
packages/bolt/README.md Normal file
View File

@ -0,0 +1,78 @@
# Bolt
Bolt is an AI assistant developed by StackBlitz. This package contains the UI interface for Bolt as well as the server components, built using [Remix Run](https://remix.run/).
## Prerequisites
Before you begin, ensure you have the following installed:
- Node.js (v18.20.3)
- pnpm (v9.4.0)
## Setup
1. Clone the repository (if you haven't already):
```bash
git clone https://github.com/stackblitz/bolt.git
cd bolt
```
2. Install dependencies:
```bash
pnpm install
```
3. Create a `.env.local` file in the root of the bolt package directory and add your Anthropic API key:
```
ANTHROPIC_API_KEY=XXX
```
Optionally, you an set the debug level:
```
VITE_LOG_LEVEL=debug
```
**Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
## Available Scripts
- `pnpm run dev`: Starts the development server.
- `pnpm run build`: Builds the project.
- `pnpm run start`: Runs the built application locally using Wrangler Pages. This script uses `bindings.sh` to set up necessary bindings so you don't have to duplicate environment variables.
- `pnpm run preview`: Builds the project and then starts it locally, useful for testing the production build. Note, HTTP streaming currently doesn't work as expected with `wrangler pages dev`.
- `pnpm test:` Runs the test suite using Vitest.
- `pnpm run typecheck`: Runs TypeScript type checking.
- `pnpm run typegen`: Generates TypeScript types using Wrangler.
- `pnpm run deploy`: Builds the project and deploys it to Cloudflare Pages.
## Development
To start the development server:
```bash
pnpm run dev
```
This will start the Remix Vite development server.
## Testing
Run the test suite with:
```bash
pnpm test
```
## Deployment
To deploy the application to Cloudflare Pages:
```bash
pnpm run deploy
```
Make sure you have the necessary permissions and Wrangler is correctly configured for your Cloudflare account.

View File

@ -0,0 +1,12 @@
import { IconButton } from './ui/IconButton';
export function Header() {
return (
<header className="flex items-center bg-white p-4 border-b border-gray-200">
<div className="flex items-center gap-2">
<div className="text-2xl font-semibold text-accent">Bolt</div>
</div>
<IconButton icon="i-ph:gear-duotone" className="ml-auto" />
</header>
);
}

View File

@ -0,0 +1,29 @@
import { useStore } from '@nanostores/react';
import { workspaceStore } from '~/lib/stores/workspace';
interface ArtifactProps {
messageId: string;
onClick?: () => void;
}
export function Artifact({ messageId, onClick }: ArtifactProps) {
const artifacts = useStore(workspaceStore.artifacts);
const artifact = artifacts[messageId];
return (
<button className="flex border rounded-lg overflow-hidden items-stretch bg-gray-50/25 w-full" onClick={onClick}>
<div className="border-r flex items-center px-6 bg-gray-50">
{!artifact?.closed ? (
<div className="i-svg-spinners:90-ring-with-bg scale-130"></div>
) : (
<div className="i-ph:code-bold scale-130 text-gray-600"></div>
)}
</div>
<div className="flex flex-col items-center px-4 p-2.5">
<div className="text-left w-full">{artifact?.title}</div>
<small className="w-full text-left">Click to open code</small>
</div>
</button>
);
}

View File

@ -0,0 +1,14 @@
import { memo } from 'react';
import { Markdown } from './Markdown';
interface AssistantMessageProps {
content: string;
}
export const AssistantMessage = memo(({ content }: AssistantMessageProps) => {
return (
<div className="overflow-hidden w-full">
<Markdown>{content}</Markdown>
</div>
);
});

View File

@ -0,0 +1,140 @@
import type { LegacyRef } from 'react';
import React from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { IconButton } from '~/components/ui/IconButton';
import { classNames } from '~/utils/classNames';
import { SendButton } from './SendButton.client';
interface BaseChatProps {
textareaRef?: LegacyRef<HTMLTextAreaElement> | undefined;
messagesSlot?: React.ReactNode;
workspaceSlot?: React.ReactNode;
chatStarted?: boolean;
enhancingPrompt?: boolean;
promptEnhanced?: boolean;
input?: string;
sendMessage?: () => void;
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
enhancePrompt?: () => void;
}
const EXAMPLES = [{ text: 'Example' }, { text: 'Example' }, { text: 'Example' }, { text: 'Example' }];
const TEXTAREA_MIN_HEIGHT = 72;
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
(
{
textareaRef,
chatStarted = false,
enhancingPrompt = false,
promptEnhanced = false,
messagesSlot,
workspaceSlot,
input = '',
sendMessage,
handleInputChange,
enhancePrompt,
},
ref,
) => {
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
return (
<div ref={ref} className="h-full flex w-full overflow-scroll px-6">
<div className="flex flex-col items-center w-full h-full">
<div id="chat" className="w-full">
{!chatStarted && (
<div id="intro" className="mt-[20vh] mb-14 max-w-2xl mx-auto">
<h2 className="text-4xl text-center font-bold text-slate-800 mb-2">Where ideas begin.</h2>
<p className="mb-14 text-center">Bring ideas to life in seconds or get help on existing projects.</p>
<div className="grid max-md:grid-cols-[repeat(2,1fr)] md:grid-cols-[repeat(2,minmax(200px,1fr))] gap-4">
{EXAMPLES.map((suggestion, index) => (
<button key={index} className="p-4 rounded-lg shadow-xs bg-white border border-gray-200 text-left">
{suggestion.text}
</button>
))}
</div>
</div>
)}
{messagesSlot}
</div>
<div
className={classNames('w-full md:max-w-[720px] mx-auto', {
'fixed bg-bolt-elements-app-backgroundColor bottom-0': chatStarted,
})}
>
<div
className={classNames(
'relative shadow-sm border border-gray-200 md:mb-6 bg-white rounded-lg overflow-hidden',
{
'max-md:rounded-none max-md:border-x-none': chatStarted,
},
)}
>
<textarea
ref={textareaRef}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return;
}
event.preventDefault();
sendMessage?.();
}
}}
value={input}
onChange={(event) => {
handleInputChange?.(event);
}}
className={`w-full pl-4 pt-4 pr-16 focus:outline-none resize-none`}
style={{
minHeight: TEXTAREA_MIN_HEIGHT,
maxHeight: TEXTAREA_MAX_HEIGHT,
}}
placeholder="How can Bolt help you today?"
translate="no"
/>
<ClientOnly>{() => <SendButton show={input.length > 0} onClick={sendMessage} />}</ClientOnly>
<div className="flex justify-between text-sm p-4 pt-2">
<div className="flex gap-1 items-center">
<IconButton icon="i-ph:microphone-duotone" className="-ml-1" />
<IconButton icon="i-ph:plus-circle-duotone" />
<IconButton
disabled={input.length === 0 || enhancingPrompt}
className={classNames({
'opacity-100!': enhancingPrompt,
'text-accent! pr-1.5': promptEnhanced,
})}
onClick={() => enhancePrompt?.()}
>
{enhancingPrompt ? (
<>
<div className="i-svg-spinners:90-ring-with-bg text-black text-xl"></div>
<div className="ml-1.5">Enhancing prompt...</div>
</>
) : (
<>
<div className="i-blitz:stars text-xl"></div>
{promptEnhanced && <div className="ml-1.5">Prompt enhanced</div>}
</>
)}
</IconButton>
</div>
{input.length > 3 ? (
<div className="text-xs">
Use <kbd className="bg-gray-100 p-1 rounded-md">Shift</kbd> +{' '}
<kbd className="bg-gray-100 p-1 rounded-md">Return</kbd> for a new line
</div>
) : null}
</div>
</div>
</div>
</div>
{workspaceSlot}
</div>
);
},
);

View File

@ -0,0 +1,123 @@
import { useChat } from 'ai/react';
import { cubicBezier, useAnimate } from 'framer-motion';
import { useEffect, useRef, useState } from 'react';
import { useMessageParser, usePromptEnhancer } from '~/lib/hooks';
import { createScopedLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat';
import { Messages } from './Messages';
const logger = createScopedLogger('Chat');
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
export function Chat() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(false);
const [animationScope, animate] = useAnimate();
const { messages, isLoading, input, handleInputChange, setInput, handleSubmit } = useChat({
api: '/api/chat',
onError: (error) => {
logger.error(error);
},
onFinish: () => {
logger.debug('Finished streaming');
},
});
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
const { parsedMessages, parseMessages } = useMessageParser();
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
useEffect(() => {
parseMessages(messages, isLoading);
}, [messages, isLoading, parseMessages]);
const scrollTextArea = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
};
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
}
}, [input, textareaRef]);
const runAnimation = async () => {
if (chatStarted) {
return;
}
await Promise.all([
animate('#chat', { height: '100%' }, { duration: 0.3, ease: customEasingFn }),
animate('#intro', { opacity: 0, display: 'none' }, { duration: 0.15, ease: customEasingFn }),
]);
setChatStarted(true);
};
const sendMessage = () => {
if (input.length === 0) {
return;
}
runAnimation();
handleSubmit();
resetEnhancer();
textareaRef.current?.blur();
};
return (
<BaseChat
ref={animationScope}
textareaRef={textareaRef}
input={input}
chatStarted={chatStarted}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
handleInputChange={handleInputChange}
messagesSlot={
chatStarted ? (
<Messages
classNames={{
root: 'h-full pt-10',
messagesContainer: 'max-w-2xl mx-auto max-md:pb-[calc(140px+1.5rem)] md:pb-[calc(140px+3rem)]',
}}
messages={messages.map((message, i) => {
if (message.role === 'user') {
return message;
}
return {
...message,
content: parsedMessages[i] || '',
};
})}
isLoading={isLoading}
/>
) : null
}
enhancePrompt={() => {
enhancePrompt(input, (input) => {
setInput(input);
scrollTextArea();
});
}}
></BaseChat>
);
}

View File

@ -0,0 +1,10 @@
.CopyButtonContainer {
button:before {
content: 'Copied';
font-size: 12px;
position: absolute;
left: -53px;
padding: 2px 6px;
height: 30px;
}
}

View File

@ -0,0 +1,82 @@
import { memo, useEffect, useState } from 'react';
import {
bundledLanguages,
codeToHtml,
isSpecialLang,
type BundledLanguage,
type BundledTheme,
type SpecialLanguage,
} from 'shiki';
import { classNames } from '~/utils/classNames';
import { createScopedLogger } from '~/utils/logger';
import styles from './CodeBlock.module.scss';
const logger = createScopedLogger('CodeBlock');
interface CodeBlockProps {
code: string;
language?: BundledLanguage;
theme?: BundledTheme | SpecialLanguage;
}
export const CodeBlock = memo(({ code, language, theme }: CodeBlockProps) => {
const [html, setHTML] = useState<string | undefined>(undefined);
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
if (copied) {
return;
}
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
useEffect(() => {
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
logger.warn(`Unsupported language '${language}'`);
}
logger.trace(`Language = ${language}`);
const processCode = async () => {
setHTML(await codeToHtml(code, { lang: language ?? 'plaintext', theme: theme ?? 'dark-plus' }));
};
processCode();
}, [code]);
return (
<div className="relative group">
<div
className={classNames(
styles.CopyButtonContainer,
'bg-white absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
{
'rounded-l-0 opacity-100': copied,
},
)}
>
<button
className={classNames(
'flex items-center p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300',
{
'before:opacity-0': !copied,
'before:opacity-100': copied,
},
)}
title="Copy Code"
onClick={() => copyToClipboard()}
>
<div className="i-ph:clipboard-text-duotone"></div>
</button>
</div>
<div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
</div>
);
});

View File

@ -0,0 +1,136 @@
$font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace,
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
$color-text: #333;
$color-heading: #2c3e50;
$color-link: #3498db;
$color-code-bg: #f8f8f8;
$color-blockquote-border: #dfe2e5;
.MarkdownContent {
line-height: 1.6;
color: $color-text;
> *:not(:last-child) {
margin-bottom: 16px;
}
:is(h1, h2, h3, h4, h5, h6) {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: $color-heading;
}
h1 {
font-size: 2em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: #6a737d;
}
p:not(:last-of-type) {
margin-top: 0;
margin-bottom: 16px;
}
a {
color: $color-link;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:not(pre) > code {
font-family: $font-mono;
font-size: 14px;
background-color: $color-code-bg;
border-radius: 6px;
padding: 0.2em 0.4em;
}
pre {
padding: 20px 16px;
border-radius: 6px;
}
pre:has(> code) {
font-family: $font-mono;
font-size: 14px;
background: transparent;
overflow-x: auto;
}
blockquote {
margin: 0;
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid $color-blockquote-border;
}
:is(ul, ol) {
padding-left: 2em;
margin-top: 0;
margin-bottom: 16px;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
img {
max-width: 100%;
box-sizing: border-box;
}
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
:is(th, td) {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
tr:nth-child(2n) {
background-color: #f6f8fa;
}
}
}

View File

@ -0,0 +1,66 @@
import { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import type { BundledLanguage } from 'shiki';
import { createScopedLogger } from '~/utils/logger';
import { rehypePlugins, remarkPlugins } from '~/utils/markdown';
import { Artifact } from './Artifact';
import { CodeBlock } from './CodeBlock';
import styles from './Markdown.module.scss';
const logger = createScopedLogger('MarkdownComponent');
interface MarkdownProps {
children: string;
}
export const Markdown = memo(({ children }: MarkdownProps) => {
logger.trace('Render');
return (
<ReactMarkdown
className={styles.MarkdownContent}
components={{
div: ({ className, children, node, ...props }) => {
if (className?.includes('__boltArtifact__')) {
const messageId = node?.properties.dataMessageId as string;
if (!messageId) {
logger.warn(`Invalud message id ${messageId}`);
}
return <Artifact messageId={messageId} />;
}
return (
<div className={className} {...props}>
{children}
</div>
);
},
pre: (props) => {
const { children, node, ...rest } = props;
const [firstChild] = node?.children ?? [];
if (
firstChild &&
firstChild.type === 'element' &&
firstChild.tagName === 'code' &&
firstChild.children[0].type === 'text'
) {
const { className, ...rest } = firstChild.properties;
const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
}
return <pre {...rest}>{children}</pre>;
},
}}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
>
{children}
</ReactMarkdown>
);
});

View File

@ -0,0 +1,55 @@
import type { Message } from 'ai';
import { useRef } from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
interface MessagesProps {
id?: string;
classNames?: { root?: string; messagesContainer?: string };
isLoading?: boolean;
messages?: Message[];
}
export function Messages(props: MessagesProps) {
const { id, isLoading, messages = [] } = props;
const containerRef = useRef<HTMLDivElement>(null);
return (
<div id={id} ref={containerRef} className={props.classNames?.root}>
<div className={classNames('flex flex-col', props.classNames?.messagesContainer)}>
{messages.length > 0
? messages.map((message, i) => {
const { role, content } = message;
const isUser = role === 'user';
const isFirst = i === 0;
return (
<div
key={message.id}
className={classNames('flex gap-4 border rounded-md p-6 bg-white/80 backdrop-blur-sm', {
'mt-4': !isFirst,
})}
>
<div
className={classNames(
'flex items-center justify-center min-w-[34px] min-h-[34px] text-gray-600 rounded-md p-1 self-start',
{
'bg-gray-100': role === 'user',
'bg-accent text-xl': role === 'assistant',
},
)}
>
<div className={role === 'user' ? 'i-ph:user-fill text-xl' : 'i-blitz:logo'}></div>
</div>
{isUser ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
</div>
);
})
: null}
{isLoading && <div className="text-center w-full i-svg-spinners:3-dots-fade text-4xl mt-4"></div>}
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
interface SendButtonProps {
show: boolean;
onClick?: VoidFunction;
}
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
export function SendButton({ show, onClick }: SendButtonProps) {
return (
<AnimatePresence>
{show ? (
<motion.button
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent hover:brightness-110 color-white rounded-md w-[34px] h-[34px]"
transition={{ ease: customEasingFn, duration: 0.17 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
onClick={(event) => {
event.preventDefault();
onClick?.();
}}
>
<div className="i-ph:arrow-right text-xl"></div>
</motion.button>
) : null}
</AnimatePresence>
);
}

View File

@ -0,0 +1,13 @@
import { Markdown } from './Markdown';
interface UserMessageProps {
content: string;
}
export function UserMessage({ content }: UserMessageProps) {
return (
<div className="overflow-hidden">
<Markdown>{content}</Markdown>
</div>
);
}

View File

@ -0,0 +1,70 @@
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
type IconSize = 'sm' | 'md' | 'xl';
interface BaseIconButtonProps {
size?: IconSize;
className?: string;
iconClassName?: string;
disabledClassName?: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
type IconButtonWithoutChildrenProps = {
icon: string;
children?: undefined;
} & BaseIconButtonProps;
type IconButtonWithChildrenProps = {
icon?: undefined;
children: string | JSX.Element | JSX.Element[];
} & BaseIconButtonProps;
type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
export const IconButton = memo(
({
icon,
size = 'xl',
className,
iconClassName,
disabledClassName,
disabled = false,
onClick,
children,
}: IconButtonProps) => {
return (
<button
className={classNames(
'flex items-center text-gray-600 enabled:hover:text-gray-900 rounded-md p-1 enabled:hover:bg-gray-200/80 disabled:cursor-not-allowed',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},
className,
)}
disabled={disabled}
onClick={(event) => {
if (disabled) {
return;
}
onClick?.(event);
}}
>
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
</button>
);
},
);
function getIconSize(size: IconSize) {
if (size === 'sm') {
return 'text-sm';
} else if (size === 'md') {
return 'text-md';
} else {
return 'text-xl';
}
}

View File

@ -0,0 +1,3 @@
export function Workspace() {
return <div>WORKSPACE PANEL</div>;
}

View File

@ -0,0 +1,12 @@
import { RemixBrowser } from '@remix-run/react';
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>,
);
});

View File

@ -0,0 +1,34 @@
import type { AppLoadContext, EntryContext } from '@remix-run/cloudflare';
import { RemixServer } from '@remix-run/react';
import { isbot } from 'isbot';
import { renderToReadableStream } from 'react-dom/server';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
_loadContext: AppLoadContext,
) {
const body = await renderToReadableStream(<RemixServer context={remixContext} url={request.url} />, {
signal: request.signal,
onError(error: unknown) {
console.error(error);
responseStatusCode = 500;
},
});
if (isbot(request.headers.get('user-agent') || '')) {
await body.allReady;
}
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set('Cross-Origin-Embedder-Policy', 'require-corp');
responseHeaders.set('Cross-Origin-Opener-Policy', 'same-origin');
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}

View File

@ -0,0 +1,9 @@
import { env } from 'node:process';
export function getAPIKey(cloudflareEnv: Env) {
/**
* The `cloudflareEnv` is only used when deployed or when previewing locally.
* In development the environment variables are available through `env`.
*/
return env.ANTHROPIC_API_KEY || cloudflareEnv.ANTHROPIC_API_KEY;
}

View File

@ -0,0 +1,9 @@
import { createAnthropic } from '@ai-sdk/anthropic';
export function getAnthropicModel(apiKey: string) {
const anthropic = createAnthropic({
apiKey,
});
return anthropic('claude-3-5-sonnet-20240620');
}

View File

@ -0,0 +1,185 @@
export const systemPrompt = `
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
<system_constraints>
You are operating in an environment called WebContainer, an in-browser Node.js runtime that emulates a Linux system to some degree. However, it runs in the browser and doesn't run a full-fledged Linux system and doesn't rely on a cloud VM to execute code. All code is executed in the browser. It does come with a shell that emulates zsh. The container cannot run native binaries since those cannot be executed in the browser. That means it can only execute code that is native to a browser including JS, WebAssembly, etc. The shell comes with a \`python\` binary but it CANNOT rely on 3rd party dependencies and doesn't have \`pip\` support nor networking support. It's LIMITED TO VANILLA Python. The assistant should keep that in mind.
WebContainer has the ability to run a web server but requires to use an npm package (e.g., Vite, servor, serve, http-server) or use the Node.js APIs to implement a web server.
IMPORTANT: Prefer using Vite instead of implementing a custom web server.
IMPORTANT: Git is NOT available.
Available shell commands: ['cat','chmod','cp','echo','hostname','kill','ln','ls','mkdir','mv','ps','pwd','rm','rmdir','xxd','alias','cd','clear','curl','env','false','getconf','head','sort','tail','touch','true','uptime','which','code','jq','loadenv','node','python3','wasm','xdg-open','command','exit','export','source']
</system_constraints>
<code_formatting_info>
Use 2 spaces for code indentation
</code_formatting_info>
<artifact_info>
Bolt creates a SINGLE, comprehensive artifact for each project. The artifact contains all necessary steps and components, including:
- Shell commands to run including dependencies to install using a package manager (NPM)
- Files to create and their contents
<artifact_instructions>
1. Think BEFORE creating an artifact.
2. Wrap the content in opening and closing \`<boltArtifact>\` tags. These tags contain more specific \`<boltAction>\` elements.
3. Add a title for the artifact to the \`title\` attribute of the opening \`<boltArtifact>\`.
3. Use \`<boltAction>\` tags to define specific actions to perform.
4. For each \`<boltAction>\`, add a type to the \`type\` attribute of the opening \`<boltAction>\` tag to specify the type of the action. Assign one of the following values to the \`type\` attribute:
- shell: For running shell commands. When Using \`npx\`, ALWAYS provide the \`--yes\` flag!
- file: For writing new files or updating existing files. For each file add a \`path\` attribute to the opening \`<boltArtifact>\` tag to specify the file path. The content of the the file artifact is the file contents.
4. The order of the actions is VERY IMPORTANT. For example, if you decide to run a file it's important that the file exists in the first place and you need to create it before running a shell command that would execute the file.
5. ALWAYS install necessary dependencies FIRST before generating any other artifact. If that requires a \`package.json\` then you should create that first!
IMPORTANT: Add all required dependencies to the \`package.json\` already and try to avoid \`npm i <pkg>\` if possible!
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
6. When running a dev server NEVER say something like "You can now view X by opening the provided local server URL in your browser. The preview will be opened automatically or by the user manually!
</artifact_instructions>
</artifact_info>
NEVER use the word "artifact". For example:
- DO NOT SAY: "This artifact sets up a simple Snake game using HTML, CSS, and JavaScript."
- INSTEAD SAY: "We set up a simple Snake game using HTML, CSS, and JavaScript."
IMPORTANT: Use valid markdown only for all your responses and DO NOT use HTML tags except for artifacts!
ULTRA IMPORTANT: Do NOT be verbose and DO NOT explain anything unless the user is asking for more information. That is VERY important.
ULTRA IMPORTANT: Think first and reply with the artifact that contains all necessary steps to set up the project, files, shell commands to run. It is SUPER IMPORTANT to respond with this first.
Here are some examples of correct usage of artifacts:
<examples>
<example>
<user_query>Can you help me create a JavaScript function to calculate the factorial of a number?</user_query>
<assistant_response>
Certainly, I can help you create a JavaScript function to calculate the factorial of a number.
<boltArtifact title="JavaScript Factorial Function">
<boltAction type="file" path="index.js">
function factorial(n) {
if (n === 0 || n === 1) {
return 1;
} else if (n < 0) {
return "Factorial is not defined for negative numbers";
} else {
return n * factorial(n - 1);
}
}
...
</boltAction>
<boltAction type="shell">
node index.js
</boltAction>
</boltArtifact>
</assistant_response>
</example>
<example>
<user_query>Build a snake game</user_query>
<assistant_response>
Certainly! I'd be happy to help you build a snake game using JavaScript and HTML5 Canvas. This will be a basic implementation that you can later expand upon. Let's create the game step by step.
<boltArtifact title="Snake Game in HTML and JavaScript">
<boltAction type="file" path="package.json">
{
"name": "snake",
"scripts": {
"dev": "vite"
}
...
}
</boltAction>
<boltAction type="shell">
npm install --save-dev vite
</boltAction>
<boltAction type="file" path="index.html">
...
</boltAction>
<boltAction type="shell">
npm run dev
</boltAction>
</boltArtifact>
Now you can play the Snake game by opening the provided local server URL in your browser. Use the arrow keys to control the snake. Eat the red food to grow and increase your score. The game ends if you hit the wall or your own tail.
</assistant_response>
</example>
<example>
<user_query>Make a bouncing ball with real gravity using React</user_query>
<assistant_response>
Certainly! I'll create a bouncing ball with real gravity using React. We'll use the react-spring library for physics-based animations.
<boltArtifact title="Bouncing Ball with Gravity in React">
<boltAction type="file" path="package.json">
{
"name": "bouncing-ball",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-spring": "^9.7.1"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.2.0"
}
}
</boltAction>
<boltAction type="file" path="index.html">
...
</boltAction>
<boltAction type="file" path="src/main.jsx">
...
</boltAction>
<boltAction type="file" path="src/index.css">
...
</boltAction>
<boltAction type="file" path="src/App.jsx">
...
</boltAction>
<boltAction type="shell">
npm run dev
</boltAction>
</boltArtifact>
You can now view the bouncing ball animation in the preview. The ball will start falling from the top of the screen and bounce realistically when it hits the bottom.
</assistant_response>
</example>
</examples>
`;

View File

@ -0,0 +1,2 @@
export * from './useMessageParser';
export * from './usePromptEnhancer';

View File

@ -0,0 +1,57 @@
import type { Message } from 'ai';
import { useCallback, useState } from 'react';
import { StreamingMessageParser } from '~/lib/runtime/message-parser';
import { workspaceStore } from '~/lib/stores/workspace';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('useMessageParser');
const messageParser = new StreamingMessageParser({
callbacks: {
onArtifactOpen: (messageId, { title }) => {
logger.debug('onArtifactOpen', title);
workspaceStore.updateArtifact(messageId, { title, closed: false });
},
onArtifactClose: (messageId) => {
logger.debug('onArtifactClose');
workspaceStore.updateArtifact(messageId, { closed: true });
},
onAction: (messageId, { type, path, content }) => {
console.log('ACTION', messageId, { type, path, content });
},
},
});
export function useMessageParser() {
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
const parseMessages = useCallback((messages: Message[], isLoading: boolean) => {
let reset = false;
if (import.meta.env.DEV && !isLoading) {
reset = true;
messageParser.reset();
}
for (const [index, message] of messages.entries()) {
if (message.role === 'assistant') {
/**
* In production, we only parse the last assistant message since previous messages can't change.
* During development they can change, e.g., if the parser gets modified.
*/
if (import.meta.env.PROD && index < messages.length - 1) {
continue;
}
const newParsedContent = messageParser.parse(message.id, message.content);
setParsedMessages((prevParsed) => ({
...prevParsed,
[index]: !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent,
}));
}
}
}, []);
return { parsedMessages, parseMessages };
}

View File

@ -0,0 +1,71 @@
import { useState } from 'react';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('usePromptEnhancement');
export function usePromptEnhancer() {
const [enhancingPrompt, setEnhancingPrompt] = useState(false);
const [promptEnhanced, setPromptEnhanced] = useState(false);
const resetEnhancer = () => {
setEnhancingPrompt(false);
setPromptEnhanced(false);
};
const enhancePrompt = async (input: string, setInput: (value: string) => void) => {
setEnhancingPrompt(true);
setPromptEnhanced(false);
const response = await fetch('/api/enhancer', {
method: 'POST',
body: JSON.stringify({
message: input,
}),
});
const reader = response.body?.getReader();
const originalInput = input;
if (reader) {
const decoder = new TextDecoder();
let _input = '';
let _error;
try {
setInput('');
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
_input += decoder.decode(value);
logger.trace('Set input', _input);
setInput(_input);
}
} catch (error) {
_error = error;
setInput(originalInput);
} finally {
if (_error) {
logger.error(_error);
}
setEnhancingPrompt(false);
setPromptEnhanced(true);
setTimeout(() => {
setInput(_input);
});
}
}
};
return { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer };
}

View File

@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { StreamingMessageParser } from './message-parser';
describe('StreamingMessageParser', () => {
it('should pass through normal text', () => {
const parser = new StreamingMessageParser();
expect(parser.parse('test_id', 'Hello, world!')).toBe('Hello, world!');
});
it('should allow normal HTML tags', () => {
const parser = new StreamingMessageParser();
expect(parser.parse('test_id', 'Hello <strong>world</strong>!')).toBe('Hello <strong>world</strong>!');
});
it.each([
['Foo bar', 'Foo bar'],
['Foo bar <', 'Foo bar '],
['Foo bar <p', 'Foo bar <p'],
['Foo bar <b', 'Foo bar '],
['Foo bar <ba', 'Foo bar <ba'],
['Foo bar <bol', 'Foo bar '],
['Foo bar <bolt', 'Foo bar '],
['Foo bar <bolta', 'Foo bar <bolta'],
['Foo bar <boltA', 'Foo bar '],
['Some text before <boltArtifact>foo</boltArtifact> Some more text', 'Some text before Some more text'],
[['Some text before <boltArti', 'fact>foo</boltArtifact> Some more text'], 'Some text before Some more text'],
[['Some text before <boltArti', 'fac', 't>foo</boltArtifact> Some more text'], 'Some text before Some more text'],
[['Some text before <boltArti', 'fact>fo', 'o</boltArtifact> Some more text'], 'Some text before Some more text'],
[
['Some text before <boltArti', 'fact>fo', 'o', '<', '/boltArtifact> Some more text'],
'Some text before Some more text',
],
[
['Some text before <boltArti', 'fact>fo', 'o<', '/boltArtifact> Some more text'],
'Some text before Some more text',
],
['Before <oltArtfiact>foo</boltArtifact> After', 'Before <oltArtfiact>foo</boltArtifact> After'],
['Before <boltArtifactt>foo</boltArtifact> After', 'Before <boltArtifactt>foo</boltArtifact> After'],
['Before <boltArtifact title="Some title">foo</boltArtifact> After', 'Before After'],
[
'Before <boltArtifact title="Some title"><boltAction type="shell">npm install</boltAction></boltArtifact> After',
'Before After',
[{ type: 'shell', content: 'npm install' }],
],
[
'Before <boltArtifact title="Some title"><boltAction type="shell">npm install</boltAction><boltAction type="file" path="index.js">some content</boltAction></boltArtifact> After',
'Before After',
[
{ type: 'shell', content: 'npm install' },
{ type: 'file', path: 'index.js', content: 'some content\n' },
],
],
])('should correctly parse chunks and strip out bolt artifacts', (input, expected, expectedActions = []) => {
let actionCounter = 0;
const testId = 'test_id';
const parser = new StreamingMessageParser({
artifactElement: '',
callbacks: {
onAction: (id, action) => {
expect(testId).toBe(id);
expect(action).toEqual(expectedActions[actionCounter]);
actionCounter++;
},
},
});
let message = '';
let result = '';
const chunks = Array.isArray(input) ? input : input.split('');
for (const chunk of chunks) {
message += chunk;
result += parser.parse(testId, message);
}
expect(actionCounter).toBe(expectedActions.length);
expect(result).toEqual(expected);
});
});

View File

@ -0,0 +1,172 @@
const ARTIFACT_TAG_OPEN = '<boltArtifact';
const ARTIFACT_TAG_CLOSE = '</boltArtifact>';
const ARTIFACT_ACTION_TAG_OPEN = '<boltAction';
const ARTIFACT_ACTION_TAG_CLOSE = '</boltAction>';
interface BoltArtifact {
title: string;
}
type ArtifactOpenCallback = (messageId: string, artifact: BoltArtifact) => void;
type ArtifactCloseCallback = (messageId: string) => void;
type ActionCallback = (messageId: string, action: BoltActionData) => void;
type ActionType = 'file' | 'shell';
export interface BoltActionData {
type?: ActionType;
path?: string;
content: string;
}
interface Callbacks {
onArtifactOpen?: ArtifactOpenCallback;
onArtifactClose?: ArtifactCloseCallback;
onAction?: ActionCallback;
}
type ElementFactory = () => string;
interface StreamingMessageParserOptions {
callbacks?: Callbacks;
artifactElement?: string | ElementFactory;
}
export class StreamingMessageParser {
#lastPositions = new Map<string, number>();
#insideArtifact = false;
#insideAction = false;
#currentAction: BoltActionData = { content: '' };
constructor(private _options: StreamingMessageParserOptions = {}) {}
parse(id: string, input: string) {
let output = '';
let i = this.#lastPositions.get(id) ?? 0;
let earlyBreak = false;
while (i < input.length) {
if (this.#insideArtifact) {
if (this.#insideAction) {
const closeIndex = input.indexOf(ARTIFACT_ACTION_TAG_CLOSE, i);
if (closeIndex !== -1) {
this.#currentAction.content += input.slice(i, closeIndex);
let content = this.#currentAction.content.trim();
if (this.#currentAction.type === 'file') {
content += '\n';
}
this.#currentAction.content = content;
this._options.callbacks?.onAction?.(id, this.#currentAction);
this.#insideAction = false;
this.#currentAction = { content: '' };
i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length;
} else {
break;
}
} else {
const actionOpenIndex = input.indexOf(ARTIFACT_ACTION_TAG_OPEN, i);
const artifactCloseIndex = input.indexOf(ARTIFACT_TAG_CLOSE, i);
if (actionOpenIndex !== -1 && (artifactCloseIndex === -1 || actionOpenIndex < artifactCloseIndex)) {
const actionEndIndex = input.indexOf('>', actionOpenIndex);
if (actionEndIndex !== -1) {
const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1);
this.#currentAction.type = this.#extractAttribute(actionTag, 'type') as ActionType;
this.#currentAction.path = this.#extractAttribute(actionTag, 'path');
this.#insideAction = true;
i = actionEndIndex + 1;
} else {
break;
}
} else if (artifactCloseIndex !== -1) {
this.#insideArtifact = false;
this._options.callbacks?.onArtifactClose?.(id);
i = artifactCloseIndex + ARTIFACT_TAG_CLOSE.length;
} else {
break;
}
}
} else if (input[i] === '<' && input[i + 1] !== '/') {
let j = i;
let potentialTag = '';
while (j < input.length && potentialTag.length < ARTIFACT_TAG_OPEN.length) {
potentialTag += input[j];
if (potentialTag === ARTIFACT_TAG_OPEN) {
const nextChar = input[j + 1];
if (nextChar && nextChar !== '>' && nextChar !== ' ') {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
const openTagEnd = input.indexOf('>', j);
if (openTagEnd !== -1) {
const artifactTag = input.slice(i, openTagEnd + 1);
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
this.#insideArtifact = true;
this._options.callbacks?.onArtifactOpen?.(id, { title: artifactTitle });
output += this._options.artifactElement ?? `<div class="__boltArtifact__" data-message-id="${id}"></div>`;
i = openTagEnd + 1;
} else {
earlyBreak = true;
}
break;
} else if (!ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
j++;
}
if (j === input.length && ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
break;
}
} else {
output += input[i];
i++;
}
if (earlyBreak) {
break;
}
}
this.#lastPositions.set(id, i);
return output;
}
reset() {
this.#lastPositions.clear();
this.#insideArtifact = false;
this.#insideAction = false;
this.#currentAction = { content: '' };
}
#extractAttribute(tag: string, attributeName: string): string | undefined {
const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i'));
return match ? match[1] : undefined;
}
}

View File

@ -0,0 +1,42 @@
import type { WebContainer } from '@webcontainer/api';
import { atom, map, type MapStore, type WritableAtom } from 'nanostores';
import { webcontainer } from '~/lib/webcontainer';
interface WorkspaceStoreOptions {
webcontainer: Promise<WebContainer>;
}
interface ArtifactState {
title: string;
closed: boolean;
actions: any /* TODO */;
}
export class WorkspaceStore {
#webcontainer: Promise<WebContainer>;
artifacts: MapStore<Record<string, ArtifactState>> = import.meta.hot?.data.artifacts ?? map({});
showWorkspace: WritableAtom<boolean> = import.meta.hot?.data.showWorkspace ?? atom(false);
constructor({ webcontainer }: WorkspaceStoreOptions) {
this.#webcontainer = webcontainer;
}
updateArtifact(id: string, state: Partial<ArtifactState>) {
const artifacts = this.artifacts.get();
const artifact = artifacts[id];
this.artifacts.setKey(id, { ...artifact, ...state });
}
runAction() {
// TODO
}
}
export const workspaceStore = new WorkspaceStore({ webcontainer });
if (import.meta.hot) {
import.meta.hot.data.artifacts = workspaceStore.artifacts;
import.meta.hot.data.showWorkspace = workspaceStore.showWorkspace;
}

View File

@ -0,0 +1,31 @@
import { WebContainer } from '@webcontainer/api';
interface WebContainerContext {
loaded: boolean;
}
export const webcontainerContext: WebContainerContext = import.meta.hot?.data.webcontainerContext ?? {
loaded: false,
};
if (import.meta.hot) {
import.meta.hot.data.webcontainerContext = webcontainerContext;
}
export let webcontainer: Promise<WebContainer> = new Promise(() => {
// noop for ssr
});
if (!import.meta.env.SSR) {
webcontainer =
import.meta.hot?.data.webcontainer ??
Promise.resolve()
.then(() => WebContainer.boot({ workdirName: 'project' }))
.then(() => {
webcontainerContext.loaded = true;
});
if (import.meta.hot) {
import.meta.hot.data.webcontainer = webcontainer;
}
}

View File

@ -0,0 +1,51 @@
import type { LinksFunction } from '@remix-run/cloudflare';
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
import reset from '@unocss/reset/tailwind.css?url';
import globalStyles from '~/styles/index.scss?url';
import 'virtual:uno.css';
export const links: LinksFunction = () => [
{
rel: 'icon',
href: '/favicon.svg',
type: 'image/svg+xml',
},
{ rel: 'stylesheet', href: reset },
{ rel: 'stylesheet', href: globalStyles },
{
rel: 'preconnect',
href: 'https://fonts.googleapis.com',
},
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}

View File

@ -0,0 +1,18 @@
import type { MetaFunction } from '@remix-run/cloudflare';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/Header';
export const meta: MetaFunction = () => {
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
};
export default function Index() {
return (
<div className="flex flex-col h-full w-full">
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
</div>
);
}

View File

@ -0,0 +1,43 @@
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { convertToCoreMessages, streamText } from 'ai';
import { getAPIKey } from '~/lib/.server/llm/api-key';
import { getAnthropicModel } from '~/lib/.server/llm/model';
import { systemPrompt } from '~/lib/.server/llm/prompts';
interface ToolResult<Name extends string, Args, Result> {
toolCallId: string;
toolName: Name;
args: Args;
result: Result;
}
interface Message {
role: 'user' | 'assistant';
content: string;
toolInvocations?: ToolResult<string, unknown, unknown>[];
}
export async function action({ context, request }: ActionFunctionArgs) {
const { messages } = await request.json<{ messages: Message[] }>();
try {
const result = await streamText({
model: getAnthropicModel(getAPIKey(context.cloudflare.env)),
messages: convertToCoreMessages(messages),
toolChoice: 'none',
onFinish: ({ finishReason, usage, warnings }) => {
console.log({ finishReason, usage, warnings });
},
system: systemPrompt,
});
return result.toAIStreamResponse();
} catch (error) {
console.log(error);
throw new Response(null, {
status: 500,
statusText: 'Internal Server Error',
});
}
}

View File

@ -0,0 +1,65 @@
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { StreamingTextResponse, convertToCoreMessages, parseStreamPart, streamText } from 'ai';
import { getAPIKey } from '~/lib/.server/llm/api-key';
import { getAnthropicModel } from '~/lib/.server/llm/model';
import { systemPrompt } from '~/lib/.server/llm/prompts';
import { stripIndents } from '~/utils/stripIndent';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export async function action({ context, request }: ActionFunctionArgs) {
const { message } = await request.json<{ message: string }>();
try {
const result = await streamText({
model: getAnthropicModel(getAPIKey(context.cloudflare.env)),
system: systemPrompt,
messages: convertToCoreMessages([
{
role: 'user',
content: stripIndents`
I want you to improve the following prompt.
IMPORTANT: Only respond with the improved prompt and nothing else!
<original_prompt>
${message}
</original_prompt>
`,
},
]),
});
if (import.meta.env.DEV) {
result.usage.then((usage) => {
console.log('Usage', usage);
});
}
const transformStream = new TransformStream({
transform(chunk, controller) {
const processedChunk = decoder
.decode(chunk)
.split('\n')
.filter((line) => line !== '')
.map(parseStreamPart)
.map((part) => part.value)
.join('');
controller.enqueue(encoder.encode(processedChunk));
},
});
const transformedStream = result.toAIStream().pipeThrough(transformStream);
return new StreamingTextResponse(transformedStream);
} catch (error) {
console.log(error);
throw new Response(null, {
status: 500,
statusText: 'Internal Server Error',
});
}
}

View File

@ -0,0 +1,31 @@
@import './variables.scss';
body {
--at-apply: bg-bolt-elements-app-backgroundColor;
font-family: 'Inter', sans-serif;
&:before {
--line: color-mix(in lch, canvasText, transparent 93%);
--size: 50px;
content: '';
height: 100vh;
mask: linear-gradient(-25deg, transparent 60%, white);
pointer-events: none;
position: fixed;
top: 0;
transform-style: flat;
width: 100vw;
z-index: -1;
background:
linear-gradient(90deg, var(--line) 1px, transparent 1px var(--size)) 50% 50% / var(--size) var(--size),
linear-gradient(var(--line) 1px, transparent 1px var(--size)) 50% 50% / var(--size) var(--size);
}
}
html,
body {
height: 100%;
width: 100%;
}

View File

@ -0,0 +1,21 @@
:root,
:root[data-theme='light'] {
/* Color Tokens */
--bolt-background-primary: theme('colors.gray.50');
}
:root,
:root[data-theme='dark'] {
/* Color Tokens */
--bolt-background-primary: theme('colors.gray.50');
}
/*
* Element Tokens
*
* Hierarchy: Element Token -> (Element Token | Color Tokens) -> Primitives
*/
:root {
/* App */
--bolt-elements-app-backgroundColor: var(--bolt-background-primary);
}

View File

@ -0,0 +1,61 @@
/**
* Copyright (c) 2018 Jed Watson.
* Licensed under the MIT License (MIT), see:
*
* @link http://jedwatson.github.io/classnames
*/
type ClassNamesArg = undefined | string | Record<string, boolean> | ClassNamesArg[];
/**
* A simple JavaScript utility for conditionally joining classNames together.
*
* @param args A series of classes or object with key that are class and values
* that are interpreted as boolean to decide whether or not the class
* should be included in the final class.
*/
export function classNames(...args: ClassNamesArg[]): string {
let classes = '';
for (const arg of args) {
classes = appendClass(classes, parseValue(arg));
}
return classes;
}
function parseValue(arg: ClassNamesArg) {
if (typeof arg === 'string' || typeof arg === 'number') {
return arg;
}
if (typeof arg !== 'object') {
return '';
}
if (Array.isArray(arg)) {
return classNames(...arg);
}
let classes = '';
for (const key in arg) {
if (arg[key]) {
classes = appendClass(classes, key);
}
}
return classes;
}
function appendClass(value: string, newClass: string | undefined) {
if (!newClass) {
return value;
}
if (value) {
return value + ' ' + newClass;
}
return value + newClass;
}

View File

@ -0,0 +1,87 @@
export type DebugLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
type LoggerFunction = (...messages: any[]) => void;
interface Logger {
trace: LoggerFunction;
debug: LoggerFunction;
info: LoggerFunction;
warn: LoggerFunction;
error: LoggerFunction;
setLevel: (level: DebugLevel) => void;
}
let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? 'warn';
export const logger: Logger = {
trace: (...messages: any[]) => log('trace', undefined, messages),
debug: (...messages: any[]) => log('debug', undefined, messages),
info: (...messages: any[]) => log('info', undefined, messages),
warn: (...messages: any[]) => log('warn', undefined, messages),
error: (...messages: any[]) => log('error', undefined, messages),
setLevel,
};
export function createScopedLogger(scope: string): Logger {
return {
trace: (...messages: any[]) => log('trace', scope, messages),
debug: (...messages: any[]) => log('debug', scope, messages),
info: (...messages: any[]) => log('info', scope, messages),
warn: (...messages: any[]) => log('warn', scope, messages),
error: (...messages: any[]) => log('error', scope, messages),
setLevel,
};
}
function setLevel(level: DebugLevel) {
if ((level === 'trace' || level === 'debug') && import.meta.env.PROD) {
return;
}
currentLevel = level;
}
function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
const levelOrder: DebugLevel[] = ['trace', 'debug', 'info', 'warn', 'error'];
if (levelOrder.indexOf(level) >= levelOrder.indexOf(currentLevel)) {
const labelBackgroundColor = getColorForLevel(level);
const labelTextColor = level === 'warn' ? 'black' : 'white';
const labelStyles = getLabelStyles(labelBackgroundColor, labelTextColor);
const scopeStyles = getLabelStyles('#77828D', 'white');
const styles = [labelStyles];
if (typeof scope === 'string') {
styles.push('', scopeStyles);
}
console.log(`%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`, ...styles, ...messages);
}
}
function getLabelStyles(color: string, textColor: string) {
return `background-color: ${color}; color: white; border: 4px solid ${color}; color: ${textColor};`;
}
function getColorForLevel(level: DebugLevel): string {
switch (level) {
case 'trace':
case 'debug': {
return '#77828D';
}
case 'info': {
return '#1389FD';
}
case 'warn': {
return '#FFDB6C';
}
case 'error': {
return '#EE4744';
}
default: {
return 'black';
}
}
}

View File

@ -0,0 +1,6 @@
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import type { PluggableList } from 'unified';
export const remarkPlugins = [remarkGfm] satisfies PluggableList;
export const rehypePlugins = [rehypeRaw] satisfies PluggableList;

View File

@ -0,0 +1,19 @@
export function withResolvers<T>(): PromiseWithResolvers<T> {
if (typeof Promise.withResolvers === 'function') {
return Promise.withResolvers();
}
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {
resolve,
reject,
promise,
};
}

View File

@ -0,0 +1,23 @@
export function stripIndents(value: string): string;
export function stripIndents(strings: TemplateStringsArray, ...values: any[]): string;
export function stripIndents(arg0: string | TemplateStringsArray, ...values: any[]) {
if (typeof arg0 !== 'string') {
const processedString = arg0.reduce((acc, curr, i) => {
acc += curr + (values[i] ?? '');
return acc;
}, '');
return _stripIndents(processedString);
}
return _stripIndents(arg0);
}
function _stripIndents(value: string) {
return value
.split('\n')
.map((line) => line.trim())
.join('\n')
.trimStart()
.replace(/[\r\n]$/, '');
}

16
packages/bolt/bindings.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
bindings=""
while IFS= read -r line || [ -n "$line" ]; do
if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then
name=$(echo "$line" | cut -d '=' -f 1)
value=$(echo "$line" | cut -d '=' -f 2-)
value=$(echo $value | sed 's/^"\(.*\)"$/\1/')
bindings+="--binding ${name}=${value} "
fi
done < .env.local
bindings=$(echo $bindings | sed 's/[[:space:]]*$//')
echo $bindings

View File

@ -0,0 +1,9 @@
import type { ServerBuild } from '@remix-run/cloudflare';
import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
// @ts-ignore because the server build file is generated by `remix vite:build`
import * as serverBuild from '../build/server';
export const onRequest = createPagesFunctionHandler({
build: serverBuild as unknown as ServerBuild,
});

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<rect width="16" height="16" rx="2" fill="#1389fd" />
<path d="M7.398 9.091h-3.58L10.364 2 8.602 6.909h3.58L5.636 14l1.762-4.909Z" fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M11.5 6a.5.5 0 0 1-.475-.342l-.263-.789a1 1 0 0 0-.632-.632l-.789-.263a.5.5 0 0 1 0-.949l.789-.263a1 1 0 0 0 .632-.632l.264-.79a.5.5 0 0 1 .948 0l.264.79a1 1 0 0 0 .632.632l.789.263a.5.5 0 0 1 0 .949l-.789.263a1 1 0 0 0-.632.632l-.264.789A.5.5 0 0 1 11.5 6ZM6 13a.667.667 0 0 1-.647-.505l-.23-.92a2.337 2.337 0 0 0-1.696-1.698l-.92-.23a.666.666 0 0 1 0-1.293l.92-.23a2.337 2.337 0 0 0 1.697-1.697l.23-.92a.667.667 0 0 1 1.293 0l.23.92a2.333 2.333 0 0 0 1.696 1.697l.92.23a.666.666 0 0 1 0 1.294l-.92.229a2.332 2.332 0 0 0-1.697 1.697l-.23.921A.667.667 0 0 1 6 13ZM13.5 23a.75.75 0 0 1-.72-.544l-.813-2.846a3.75 3.75 0 0 0-2.576-2.576l-2.846-.813a.75.75 0 0 1 0-1.442l2.846-.813a3.75 3.75 0 0 0 2.575-2.576l.814-2.846a.75.75 0 0 1 1.442 0l.813 2.846a3.75 3.75 0 0 0 2.575 2.576l2.846.813a.75.75 0 0 1 0 1.442l-2.846.813a3.749 3.749 0 0 0-2.575 2.576l-.813 2.846A.75.75 0 0 1 13.5 23Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,9 @@
import { type PlatformProxy } from 'wrangler';
type Cloudflare = Omit<PlatformProxy<Env>, 'dispose'>;
declare module '@remix-run/cloudflare' {
interface AppLoadContext {
cloudflare: Cloudflare;
}
}

View File

@ -0,0 +1,59 @@
{
"name": "bolt",
"description": "StackBlitz AI Agent",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"deploy": "npm run build && wrangler pages deploy",
"build": "remix vite:build",
"dev": "remix vite:dev",
"test": "vitest --run",
"start": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings",
"typecheck": "tsc",
"typegen": "wrangler types",
"preview": "pnpm run build && pnpm run start"
},
"dependencies": {
"@ai-sdk/anthropic": "^0.0.27",
"@iconify-json/ph": "^1.1.13",
"@iconify-json/svg-spinners": "^1.1.2",
"@nanostores/react": "^0.7.2",
"@remix-run/cloudflare": "^2.10.2",
"@remix-run/cloudflare-pages": "^2.10.2",
"@remix-run/react": "^2.10.2",
"@unocss/reset": "^0.61.0",
"@webcontainer/api": "^1.2.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"ai": "^3.2.16",
"framer-motion": "^11.2.12",
"isbot": "^4.1.0",
"nanostores": "^0.10.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remix-utils": "^7.6.0",
"shiki": "^1.9.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0",
"@remix-run/dev": "^2.10.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"fast-glob": "^3.3.2",
"typescript": "^5.5.2",
"unified": "^11.0.5",
"unocss": "^0.61.0",
"vite": "^5.3.1",
"vite-tsconfig-paths": "^4.3.2",
"wrangler": "^3.63.2",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18.18.0"
}
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<rect width="16" height="16" rx="2" fill="#1389fd" />
<path d="M7.398 9.091h-3.58L10.364 2 8.602 6.909h3.58L5.636 14l1.762-4.909Z" fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ESNext",
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
// vite takes care of building everything, not tsc
"noEmit": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/.server/**/*.ts",
"**/.server/**/*.tsx",
"**/.client/**/*.ts",
"**/.client/**/*.tsx"
]
}

134
packages/bolt/uno.config.ts Normal file
View File

@ -0,0 +1,134 @@
import { globSync } from 'fast-glob';
import fs from 'node:fs/promises';
import { basename } from 'node:path';
import { defineConfig, presetIcons, presetUno, transformerDirectives } from 'unocss';
const iconPaths = globSync('./icons/*.svg');
const collectionName = 'blitz';
const customIconCollection = iconPaths.reduce(
(acc, iconPath) => {
const [iconName] = basename(iconPath).split('.');
acc[collectionName] ??= {};
acc[collectionName][iconName] = async () => fs.readFile(iconPath, 'utf8');
return acc;
},
{} as Record<string, Record<string, () => Promise<string>>>,
);
const COLOR_PRIMITIVES = {
accent: {
DEFAULT: '#1389FD',
50: '#EEF9FF',
100: '#D8F1FF',
200: '#B9E7FF',
300: '#89DBFF',
400: '#52C5FF',
500: '#2AA7FF',
600: '#1389FD',
700: '#0C70E9',
800: '#115ABC',
900: '#144D94',
950: '#11305A',
},
gray: {
0: '#FFFFFF',
50: '#F6F8F9',
100: '#EEF0F1',
200: '#E4E6E9',
300: '#D2D5D9',
400: '#AAAFB6',
500: '#7C8085',
600: '#565A64',
700: '#414349',
800: '#31343B',
900: '#2B2D35',
950: '#232429',
1000: '#000000',
},
positive: {
50: '#EDFCF6',
100: '#CEFDEB',
200: '#A1F9DC',
300: '#64F1CB',
400: '#24E0B3',
500: '#02C79F',
600: '#00A282',
700: '#00826B',
800: '#006656',
900: '#005449',
950: '#223533',
},
negative: {
50: '#FEF2F3',
100: '#FDE6E7',
200: '#FBD0D4',
300: '#F7AAB1',
400: '#F06A78',
500: '#E84B60',
600: '#D42A48',
700: '#B21E3C',
800: '#951C38',
900: '#801B36',
950: '#45212A',
},
info: {
50: '#EFF9FF',
100: '#E5F6FF',
200: '#B6E9FF',
300: '#75DAFF',
400: '#2CC8FF',
500: '#00AEF2',
600: '#008ED4',
700: '#0071AB',
800: '#005F8D',
900: '#064F74',
950: '#17374A',
},
warning: {
50: '#FEFAEC',
100: '#FCF4D9',
200: '#F9E08E',
300: '#F6CA53',
400: '#ED9413',
500: '#D2700D',
600: '#AE4E0F',
700: '#AE4E0F',
800: '#8E3D12',
900: '#753212',
950: '#402C22',
},
};
export default defineConfig({
theme: {
colors: {
...COLOR_PRIMITIVES,
bolt: {
elements: {
app: {
backgroundColor: 'var(--bolt-elements-app-backgroundColor)',
},
},
},
},
},
transformers: [transformerDirectives()],
presets: [
presetUno({
dark: {
light: '[data-theme="light"]',
dark: '[data-theme="dark"]',
},
}),
presetIcons({
warn: true,
collections: {
...customIconCollection,
},
}),
],
});

View File

@ -0,0 +1,21 @@
import { cloudflareDevProxyVitePlugin as remixCloudflareDevProxy, vitePlugin as remixVitePlugin } from '@remix-run/dev';
import UnoCSS from 'unocss/vite';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig((config) => {
return {
plugins: [
config.mode !== 'test' && remixCloudflareDevProxy(),
remixVitePlugin({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
}),
UnoCSS(),
tsconfigPaths(),
],
};
});

View File

@ -0,0 +1,3 @@
interface Env {
ANTHROPIC_API_KEY: string;
}

View File

@ -0,0 +1,5 @@
#:schema node_modules/wrangler/config-schema.json
name = "bolt"
compatibility_flags = ["nodejs_compat"]
compatibility_date = "2024-07-01"
pages_build_output_dir = "./build/client"

9794
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
packages:
- 'packages/*'