Merge branch 'dev' into ui-glow

This commit is contained in:
Dustin Loring
2024-12-01 08:55:42 -05:00
committed by GitHub
13 changed files with 25750 additions and 69 deletions

View File

@@ -47,7 +47,7 @@ const ModelSelector = ({ model, setModel, provider, setProvider, modelList, prov
key={provider?.name}
value={model}
onChange={(e) => setModel(e.target.value)}
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[70%] "
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[70%]"
>
{[...modelList]
.filter((e) => e.provider == provider?.name && e.name)
@@ -116,6 +116,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [modelList, setModelList] = useState(MODEL_LIST);
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
useEffect(() => {
// Load API keys from cookies on component mount
@@ -206,7 +207,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</ClientOnly>
<div
className={classNames(
' bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt mb-6',
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt mb-6',
{
'sticky bottom-2': chatStarted,
},
@@ -238,24 +239,43 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<rect className={classNames(styles.PromptEffectLine)} pathLength="100" stroke-linecap="round"></rect>
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
</svg>
<ModelSelector
key={provider?.name + ':' + modelList.length}
model={model}
setModel={setModel}
modelList={modelList}
provider={provider}
setProvider={setProvider}
providerList={PROVIDER_LIST}
apiKeys={apiKeys}
/>
<div>
<div className="flex justify-between items-center mb-2">
<button
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
className={classNames('flex items-center gap-2 p-2 rounded-lg transition-all', {
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
isModelSettingsCollapsed,
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
!isModelSettingsCollapsed,
})}
>
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
<span>Model Settings</span>
</button>
</div>
{provider && (
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider.name] || ''}
setApiKey={(key) => updateApiKey(provider.name, key)}
/>
)}
<div className={isModelSettingsCollapsed ? 'hidden' : ''}>
<ModelSelector
key={provider?.name + ':' + modelList.length}
model={model}
setModel={setModel}
modelList={modelList}
provider={provider}
setProvider={setProvider}
providerList={PROVIDER_LIST}
apiKeys={apiKeys}
/>
{provider && (
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider.name] || ''}
setApiKey={(key) => updateApiKey(provider.name, key)}
/>
)}
</div>
</div>
<div
className={classNames(

View File

@@ -6,19 +6,20 @@ import { useStore } from '@nanostores/react';
import type { Message } from 'ai';
import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
import { description, useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
import { fileModificationsToHTML } from '~/utils/diff';
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROVIDER_LIST } from '~/utils/constants';
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat';
import Cookies from 'js-cookie';
import type { ProviderInfo } from '~/utils/types';
import { debounce } from '~/utils/debounce';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -120,6 +121,7 @@ export const ChatImpl = memo(
logger.debug('Finished streaming');
},
initialMessages,
initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '',
});
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
@@ -225,12 +227,33 @@ export const ChatImpl = memo(
}
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
resetEnhancer();
textareaRef.current?.blur();
};
/**
* Handles the change event for the textarea and updates the input state.
* @param event - The change event from the textarea.
*/
const onTextareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
handleInputChange(event);
};
/**
* Debounced function to cache the prompt in cookies.
* Caches the trimmed value of the textarea input after a delay to optimize performance.
*/
const debouncedCachePrompt = useCallback(
debounce((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const trimmedValue = event.target.value.trim();
Cookies.set(PROMPT_COOKIE_KEY, trimmedValue, { expires: 30 });
}, 1000),
[],
);
const [messageRef, scrollRef] = useSnapScroll();
useEffect(() => {
@@ -268,7 +291,10 @@ export const ChatImpl = memo(
setProvider={handleProviderChange}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={handleInputChange}
handleInputChange={(e) => {
onTextareaChange(e);
debouncedCachePrompt(e);
}}
handleStop={abort}
description={description}
importChat={importChat}

View File

@@ -5,7 +5,7 @@ const EXAMPLE_PROMPTS = [
{ text: 'Build a simple blog using Astro' },
{ text: 'Create a cookie consent form using Material UI' },
{ text: 'Make a space invaders game' },
{ text: 'How do I center a div?' },
{ text: 'Make a Tic Tac Toe game in html, css and js only' },
];
export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) {

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { stripCodeFenceFromArtifact } from './Markdown';
describe('stripCodeFenceFromArtifact', () => {
it('should remove code fences around artifact element', () => {
const input = "```xml\n<div class='__boltArtifact__'></div>\n```";
const expected = "\n<div class='__boltArtifact__'></div>\n";
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
});
it('should handle code fence with language specification', () => {
const input = "```typescript\n<div class='__boltArtifact__'></div>\n```";
const expected = "\n<div class='__boltArtifact__'></div>\n";
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
});
it('should not modify content without artifacts', () => {
const input = '```\nregular code block\n```';
expect(stripCodeFenceFromArtifact(input)).toBe(input);
});
it('should handle empty input', () => {
expect(stripCodeFenceFromArtifact('')).toBe('');
});
it('should handle artifact without code fences', () => {
const input = "<div class='__boltArtifact__'></div>";
expect(stripCodeFenceFromArtifact(input)).toBe(input);
});
it('should handle multiple artifacts but only remove fences around them', () => {
const input = [
'Some text',
'```typescript',
"<div class='__boltArtifact__'></div>",
'```',
'```',
'regular code',
'```',
].join('\n');
const expected = ['Some text', '', "<div class='__boltArtifact__'></div>", '', '```', 'regular code', '```'].join(
'\n',
);
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
});
});

View File

@@ -68,7 +68,51 @@ export const Markdown = memo(({ children, html = false, limitedMarkdown = false
remarkPlugins={remarkPlugins(limitedMarkdown)}
rehypePlugins={rehypePlugins(html)}
>
{children}
{stripCodeFenceFromArtifact(children)}
</ReactMarkdown>
);
});
/**
* Removes code fence markers (```) surrounding an artifact element while preserving the artifact content.
* This is necessary because artifacts should not be wrapped in code blocks when rendered for rendering action list.
*
* @param content - The markdown content to process
* @returns The processed content with code fence markers removed around artifacts
*
* @example
* // Removes code fences around artifact
* const input = "```xml\n<div class='__boltArtifact__'></div>\n```";
* stripCodeFenceFromArtifact(input);
* // Returns: "\n<div class='__boltArtifact__'></div>\n"
*
* @remarks
* - Only removes code fences that directly wrap an artifact (marked with __boltArtifact__ class)
* - Handles code fences with optional language specifications (e.g. ```xml, ```typescript)
* - Preserves original content if no artifact is found
* - Safely handles edge cases like empty input or artifacts at start/end of content
*/
export const stripCodeFenceFromArtifact = (content: string) => {
if (!content || !content.includes('__boltArtifact__')) {
return content;
}
const lines = content.split('\n');
const artifactLineIndex = lines.findIndex((line) => line.includes('__boltArtifact__'));
// Return original content if artifact line not found
if (artifactLineIndex === -1) {
return content;
}
// Check previous line for code fence
if (artifactLineIndex > 0 && lines[artifactLineIndex - 1]?.trim().match(/^```\w*$/)) {
lines[artifactLineIndex - 1] = '';
}
if (artifactLineIndex < lines.length - 1 && lines[artifactLineIndex + 1]?.trim().match(/^```$/)) {
lines[artifactLineIndex + 1] = '';
}
return lines.join('\n');
};

View File

@@ -84,11 +84,11 @@ export class ActionRunner {
}
if (action.executed) {
return;
return; // No return value here
}
if (isStreaming && action.type !== 'file') {
return;
return; // No return value here
}
this.#updateAction(actionId, { ...action, ...data.action, executed: !isStreaming });
@@ -100,7 +100,6 @@ export class ActionRunner {
.catch((error) => {
console.error('Action failed:', error);
});
return this.#currentExecutionPromise;
}
async #executeAction(actionId: string, isStreaming: boolean = false) {

View File

@@ -14,6 +14,7 @@ import { saveAs } from 'file-saver';
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
import * as nodePath from 'node:path';
import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
export interface ArtifactState {
id: string;
@@ -328,6 +329,13 @@ export class WorkbenchStore {
const zip = new JSZip();
const files = this.files.get();
// Get the project name from the description input, or use a default name
const projectName = (description.value ?? 'project').toLocaleLowerCase().split(' ').join('_');
// Generate a simple 6-character hash based on the current timestamp
const timestampHash = Date.now().toString(36).slice(-6);
const uniqueProjectName = `${projectName}_${timestampHash}`;
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent?.type === 'file' && !dirent.isBinary) {
const relativePath = extractRelativePath(filePath);
@@ -350,8 +358,9 @@ export class WorkbenchStore {
}
}
// Generate the zip file and save it
const content = await zip.generateAsync({ type: 'blob' });
saveAs(content, 'project.zip');
saveAs(content, `${uniqueProjectName}.zip`);
}
async syncFiles(targetHandle: FileSystemDirectoryHandle) {

View File

@@ -7,6 +7,7 @@ export const MODIFICATIONS_TAG_NAME = 'bolt_file_modifications';
export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/;
export const PROVIDER_REGEX = /\[Provider: (.*?)\]\n\n/;
export const DEFAULT_MODEL = 'claude-3-5-sonnet-latest';
export const PROMPT_COOKIE_KEY = 'cachedPrompt';
const PROVIDER_LIST: ProviderInfo[] = [
{
@@ -283,9 +284,11 @@ const getOllamaBaseUrl = () => {
};
async function getOllamaModels(): Promise<ModelInfo[]> {
//if (typeof window === 'undefined') {
//return [];
//}
/*
* if (typeof window === 'undefined') {
* return [];
* }
*/
try {
const baseUrl = getOllamaBaseUrl();

View File

@@ -11,7 +11,7 @@ interface Logger {
setLevel: (level: DebugLevel) => void;
}
let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info';
let currentLevel: DebugLevel = (import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV) ? 'debug' : 'info';
const isWorker = 'HTMLRewriter' in globalThis;
const supportsColor = !isWorker;