Merge branch 'main' into ui-background-rays
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
|
||||
interface APIKeyManagerProps {
|
||||
provider: string;
|
||||
provider: ProviderInfo;
|
||||
apiKey: string;
|
||||
setApiKey: (key: string) => void;
|
||||
getApiKeyLink?: string;
|
||||
labelForGetApiKey?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempKey, setTempKey] = useState(apiKey);
|
||||
@@ -17,15 +21,29 @@ export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-2 mb-2">
|
||||
<span className="text-sm text-bolt-elements-textSecondary">{provider} API Key:</span>
|
||||
<div className="flex items-start sm:items-center mt-2 mb-2 flex-col sm:flex-row">
|
||||
<div>
|
||||
<span className="text-sm text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
|
||||
{!isEditing && (
|
||||
<div className="flex items-center mb-4">
|
||||
<span className="flex-1 text-xs text-bolt-elements-textPrimary mr-2">
|
||||
{apiKey ? '••••••••' : 'Not set (will still work if set in .env file)'}
|
||||
</span>
|
||||
<IconButton onClick={() => setIsEditing(true)} title="Edit API Key">
|
||||
<div className="i-ph:pencil-simple" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<input
|
||||
type="password"
|
||||
value={tempKey}
|
||||
placeholder="Your API Key"
|
||||
onChange={(e) => setTempKey(e.target.value)}
|
||||
className="flex-1 p-1 text-sm rounded 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"
|
||||
className="flex-1 px-2 py-1 text-xs lg:text-sm rounded 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"
|
||||
/>
|
||||
<IconButton onClick={handleSave} title="Save API Key">
|
||||
<div className="i-ph:check" />
|
||||
@@ -33,15 +51,15 @@ export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey,
|
||||
<IconButton onClick={() => setIsEditing(false)} title="Cancel">
|
||||
<div className="i-ph:x" />
|
||||
</IconButton>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-bolt-elements-textPrimary">
|
||||
{apiKey ? '••••••••' : 'Not set (will still work if set in .env file)'}
|
||||
</span>
|
||||
<IconButton onClick={() => setIsEditing(true)} title="Edit API Key">
|
||||
<div className="i-ph:pencil-simple" />
|
||||
</IconButton>
|
||||
{provider?.getApiKeyLink && (
|
||||
<IconButton className="ml-auto" onClick={() => window.open(provider?.getApiKeyLink)} title="Edit API Key">
|
||||
<span className="mr-2 text-xs lg:text-sm">{provider?.labelForGetApiKey || 'Get API Key'}</span>
|
||||
<div className={provider?.icon || 'i-ph:key'} />
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ActionState } from '~/lib/runtime/action-runner';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
|
||||
const highlighterOptions = {
|
||||
langs: ['shell'],
|
||||
@@ -27,6 +28,7 @@ interface ArtifactProps {
|
||||
export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
const userToggledActions = useRef(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [allActionFinished, setAllActionFinished] = useState(false);
|
||||
|
||||
const artifacts = useStore(workbenchStore.artifacts);
|
||||
const artifact = artifacts[messageId];
|
||||
@@ -46,6 +48,14 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
if (actions.length && !showActions && !userToggledActions.current) {
|
||||
setShowActions(true);
|
||||
}
|
||||
|
||||
if (actions.length !== 0 && artifact.type === 'bundled') {
|
||||
const finished = !actions.find((action) => action.status !== 'complete');
|
||||
|
||||
if (finished != allActionFinished) {
|
||||
setAllActionFinished(finished);
|
||||
}
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
return (
|
||||
@@ -58,6 +68,18 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
workbenchStore.showWorkbench.set(!showWorkbench);
|
||||
}}
|
||||
>
|
||||
{artifact.type == 'bundled' && (
|
||||
<>
|
||||
<div className="p-4">
|
||||
{allActionFinished ? (
|
||||
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
|
||||
) : (
|
||||
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
</>
|
||||
)}
|
||||
<div className="px-5 p-3.5 w-full text-left">
|
||||
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
|
||||
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
|
||||
@@ -65,7 +87,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
</button>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
<AnimatePresence>
|
||||
{actions.length && (
|
||||
{actions.length && artifact.type !== 'bundled' && (
|
||||
<motion.button
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: 'auto' }}
|
||||
@@ -82,7 +104,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showActions && actions.length > 0 && (
|
||||
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
|
||||
<motion.div
|
||||
className="actions"
|
||||
initial={{ height: 0 }}
|
||||
@@ -91,6 +113,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
|
||||
|
||||
<div className="p-5 text-left bg-bolt-elements-actions-background">
|
||||
<ActionList actions={actions} />
|
||||
</div>
|
||||
@@ -129,6 +152,14 @@ const actionVariants = {
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
function openArtifactInWorkbench(filePath: any) {
|
||||
if (workbenchStore.currentView.get() !== 'code') {
|
||||
workbenchStore.currentView.set('code');
|
||||
}
|
||||
|
||||
workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
|
||||
}
|
||||
|
||||
const ActionList = memo(({ actions }: ActionListProps) => {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
@@ -169,7 +200,10 @@ const ActionList = memo(({ actions }: ActionListProps) => {
|
||||
{type === 'file' ? (
|
||||
<div>
|
||||
Create{' '}
|
||||
<code className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md">
|
||||
<code
|
||||
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
|
||||
onClick={() => openArtifactInWorkbench(action.filePath)}
|
||||
>
|
||||
{action.filePath}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -17,3 +17,107 @@
|
||||
.Chat {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.RayContainer {
|
||||
--gradient-opacity: 0.85;
|
||||
--ray-gradient: radial-gradient(rgba(83, 196, 255, var(--gradient-opacity)) 0%, rgba(43, 166, 255, 0) 100%);
|
||||
transition: opacity 0.25s linear;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LightRayOne {
|
||||
width: 480px;
|
||||
height: 680px;
|
||||
transform: rotate(80deg);
|
||||
top: -540px;
|
||||
left: 250px;
|
||||
filter: blur(110px);
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.LightRayTwo {
|
||||
width: 110px;
|
||||
height: 400px;
|
||||
transform: rotate(-20deg);
|
||||
top: -280px;
|
||||
left: 350px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.6;
|
||||
filter: blur(60px);
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.LightRayThree {
|
||||
width: 400px;
|
||||
height: 370px;
|
||||
top: -350px;
|
||||
left: 200px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.6;
|
||||
filter: blur(21px);
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.LightRayFour {
|
||||
position: absolute;
|
||||
width: 330px;
|
||||
height: 370px;
|
||||
top: -330px;
|
||||
left: 50px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.5;
|
||||
filter: blur(21px);
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.LightRayFive {
|
||||
position: absolute;
|
||||
width: 110px;
|
||||
height: 400px;
|
||||
transform: rotate(-40deg);
|
||||
top: -280px;
|
||||
left: -10px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.8;
|
||||
filter: blur(60px);
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.PromptEffectContainer {
|
||||
--prompt-container-offset: 50px;
|
||||
--prompt-line-stroke-width: 1px;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
inset: calc(var(--prompt-container-offset) / -2);
|
||||
width: calc(100% + var(--prompt-container-offset));
|
||||
height: calc(100% + var(--prompt-container-offset));
|
||||
}
|
||||
|
||||
.PromptEffectLine {
|
||||
width: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
|
||||
height: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
|
||||
x: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
|
||||
y: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
|
||||
rx: calc(8px - var(--prompt-line-stroke-width));
|
||||
fill: transparent;
|
||||
stroke-width: var(--prompt-line-stroke-width);
|
||||
stroke: url(#line-gradient);
|
||||
stroke-dasharray: 35px 65px;
|
||||
stroke-dashoffset: 10;
|
||||
}
|
||||
|
||||
.PromptShine {
|
||||
fill: url(#shine-gradient);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,30 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import type { Message } from 'ai';
|
||||
import React, { type RefCallback, useEffect } from 'react';
|
||||
import React, { type RefCallback, useEffect, useState } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { Menu } from '~/components/sidebar/Menu.client';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { Workbench } from '~/components/workbench/Workbench.client';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { MODEL_LIST, DEFAULT_PROVIDER } from '~/utils/constants';
|
||||
import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants';
|
||||
import { Messages } from './Messages.client';
|
||||
import { SendButton } from './SendButton.client';
|
||||
import { useState } from 'react';
|
||||
import { APIKeyManager } from './APIKeyManager';
|
||||
import Cookies from 'js-cookie';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
|
||||
import styles from './BaseChat.module.scss';
|
||||
import type { ProviderInfo } from '~/utils/types';
|
||||
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
|
||||
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
||||
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ text: 'Build a todo app in React using Tailwind' },
|
||||
{ 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?' },
|
||||
];
|
||||
|
||||
const providerList = [...new Set(MODEL_LIST.map((model) => model.provider))];
|
||||
|
||||
const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList }) => {
|
||||
return (
|
||||
<div className="mb-2 flex gap-2">
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => {
|
||||
setProvider(e.target.value);
|
||||
const firstModel = [...modelList].find((m) => m.provider == e.target.value);
|
||||
setModel(firstModel ? firstModel.name : '');
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{providerList.map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider}
|
||||
</option>
|
||||
))}
|
||||
<option key="Ollama" value="Ollama">
|
||||
Ollama
|
||||
</option>
|
||||
<option key="OpenAILike" value="OpenAILike">
|
||||
OpenAILike
|
||||
</option>
|
||||
<option key="LMStudio" value="LMStudio">
|
||||
LMStudio
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{[...modelList]
|
||||
.filter((e) => e.provider == provider && e.name)
|
||||
.map((modelOption) => (
|
||||
<option key={modelOption.name} value={modelOption.name}>
|
||||
{modelOption.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import FilePreview from './FilePreview';
|
||||
import { ModelSelector } from '~/components/chat/ModelSelector';
|
||||
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
@@ -80,17 +36,24 @@ interface BaseChatProps {
|
||||
chatStarted?: boolean;
|
||||
isStreaming?: boolean;
|
||||
messages?: Message[];
|
||||
description?: string;
|
||||
enhancingPrompt?: boolean;
|
||||
promptEnhanced?: boolean;
|
||||
input?: string;
|
||||
model?: string;
|
||||
setModel?: (model: string) => void;
|
||||
provider?: string;
|
||||
setProvider?: (provider: string) => void;
|
||||
provider?: ProviderInfo;
|
||||
setProvider?: (provider: ProviderInfo) => void;
|
||||
handleStop?: () => void;
|
||||
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
|
||||
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
enhancePrompt?: () => void;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
exportChat?: () => void;
|
||||
uploadedFiles?: File[];
|
||||
setUploadedFiles?: (files: File[]) => void;
|
||||
imageDataList?: string[];
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
}
|
||||
|
||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
@@ -102,45 +65,129 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
showChat = true,
|
||||
chatStarted = false,
|
||||
isStreaming = false,
|
||||
enhancingPrompt = false,
|
||||
promptEnhanced = false,
|
||||
messages,
|
||||
input = '',
|
||||
model,
|
||||
setModel,
|
||||
provider,
|
||||
setProvider,
|
||||
sendMessage,
|
||||
input = '',
|
||||
enhancingPrompt,
|
||||
handleInputChange,
|
||||
promptEnhanced,
|
||||
enhancePrompt,
|
||||
sendMessage,
|
||||
handleStop,
|
||||
importChat,
|
||||
exportChat,
|
||||
uploadedFiles = [],
|
||||
setUploadedFiles,
|
||||
imageDataList = [],
|
||||
setImageDataList,
|
||||
messages,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
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);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
|
||||
console.log(transcript);
|
||||
useEffect(() => {
|
||||
// Load API keys from cookies on component mount
|
||||
try {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
if (storedApiKeys) {
|
||||
const parsedKeys = JSON.parse(storedApiKeys);
|
||||
|
||||
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
|
||||
setApiKeys(parsedKeys);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading API keys from cookies:', error);
|
||||
|
||||
// Clear invalid cookie data
|
||||
Cookies.remove('apiKeys');
|
||||
}
|
||||
|
||||
initializeModelList().then((modelList) => {
|
||||
setModelList(modelList);
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const transcript = Array.from(event.results)
|
||||
.map((result) => result[0])
|
||||
.map((result) => result.transcript)
|
||||
.join('');
|
||||
|
||||
setTranscript(transcript);
|
||||
|
||||
if (handleInputChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: transcript },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
handleInputChange(syntheticEvent);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
console.error('Speech recognition error:', event.error);
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
setRecognition(recognition);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startListening = () => {
|
||||
if (recognition) {
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
if (recognition) {
|
||||
recognition.stop();
|
||||
setIsListening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
|
||||
if (sendMessage) {
|
||||
sendMessage(event, messageInput);
|
||||
|
||||
if (recognition) {
|
||||
recognition.abort(); // Stop current recognition
|
||||
setTranscript(''); // Clear transcript
|
||||
setIsListening(false);
|
||||
|
||||
// Clear the input by triggering handleInputChange with empty value
|
||||
if (handleInputChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: '' },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
handleInputChange(syntheticEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateApiKey = (provider: string, key: string) => {
|
||||
try {
|
||||
const updatedApiKeys = { ...apiKeys, [provider]: key };
|
||||
setApiKeys(updatedApiKeys);
|
||||
|
||||
// Save updated API keys to cookies with 30 day expiry and secure settings
|
||||
Cookies.set('apiKeys', JSON.stringify(updatedApiKeys), {
|
||||
expires: 30, // 30 days
|
||||
@@ -153,27 +200,86 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const handleFileUpload = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const baseChat = (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(styles.BaseChat, 'relative flex h-full w-full overflow-hidden')}
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<div className={classNames(styles.RayContainer)}>
|
||||
<div className={classNames(styles.LightRayOne)}></div>
|
||||
<div className={classNames(styles.LightRayTwo)}></div>
|
||||
<div className={classNames(styles.LightRayThree)}></div>
|
||||
<div className={classNames(styles.LightRayFour)}></div>
|
||||
<div className={classNames(styles.LightRayFive)}></div>
|
||||
</div>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className="flex overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}>
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[26vh] max-w-chat mx-auto text-center">
|
||||
<h1 className="text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
|
||||
<div id="intro" className="mt-[26vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
|
||||
Where ideas begin
|
||||
</h1>
|
||||
<p className="text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
|
||||
<p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
|
||||
Bring ideas to life in seconds or get help on existing projects.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('pt-6 px-6', {
|
||||
className={classNames('pt-6 px-2 sm:px-6', {
|
||||
'h-full flex flex-col': chatStarted,
|
||||
})}
|
||||
>
|
||||
@@ -182,7 +288,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
ref={messageRef}
|
||||
className="flex flex-col w-full flex-1 max-w-chat px-4 pb-6 mx-auto z-1"
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
@@ -190,31 +296,125 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
}}
|
||||
</ClientOnly>
|
||||
<div
|
||||
className={classNames('relative w-full max-w-chat mx-auto z-prompt', {
|
||||
'sticky bottom-0': chatStarted,
|
||||
})}
|
||||
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',
|
||||
{
|
||||
'sticky bottom-2': chatStarted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ModelSelector
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
modelList={MODEL_LIST}
|
||||
provider={provider}
|
||||
setProvider={setProvider}
|
||||
providerList={providerList}
|
||||
/>
|
||||
<APIKeyManager
|
||||
provider={provider}
|
||||
apiKey={apiKeys[provider] || ''}
|
||||
setApiKey={(key) => updateApiKey(provider, key)}
|
||||
<svg className={classNames(styles.PromptEffectContainer)}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="line-gradient"
|
||||
x1="20%"
|
||||
y1="0%"
|
||||
x2="-14%"
|
||||
y2="10%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="rotate(-45)"
|
||||
>
|
||||
<stop offset="0%" stopColor="#1488fc" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#1488fc" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#1488fc" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="#1488fc" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine-gradient">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#8adaff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#8adaff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect className={classNames(styles.PromptEffectLine)} pathLength="100" strokeLinecap="round"></rect>
|
||||
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
|
||||
</svg>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<FilePreview
|
||||
files={uploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
onRemove={(index) => {
|
||||
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
|
||||
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
'shadow-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden transition-all',
|
||||
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`w-full pl-4 pt-4 pr-16 focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent transition-all`}
|
||||
className={classNames(
|
||||
'w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
|
||||
'transition-all duration-200',
|
||||
'hover:border-bolt-elements-focus',
|
||||
)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
@@ -223,13 +423,19 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
sendMessage?.(event);
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event);
|
||||
}
|
||||
}}
|
||||
value={input}
|
||||
onChange={(event) => {
|
||||
handleInputChange?.(event);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
style={{
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
@@ -240,7 +446,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<SendButton
|
||||
show={input.length > 0 || isStreaming}
|
||||
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
|
||||
isStreaming={isStreaming}
|
||||
onClick={(event) => {
|
||||
if (isStreaming) {
|
||||
@@ -248,21 +454,28 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage?.(event);
|
||||
if (input.length > 0 || uploadedFiles.length > 0) {
|
||||
handleSendMessage?.(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
|
||||
<div className="i-ph:paperclip text-xl"></div>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
title="Enhance prompt"
|
||||
disabled={input.length === 0 || enhancingPrompt}
|
||||
className={classNames('transition-all', {
|
||||
'opacity-100!': enhancingPrompt,
|
||||
'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
|
||||
promptEnhanced,
|
||||
})}
|
||||
className={classNames(
|
||||
'transition-all',
|
||||
enhancingPrompt ? 'opacity-100' : '',
|
||||
promptEnhanced ? 'text-bolt-elements-item-contentAccent' : '',
|
||||
promptEnhanced ? 'pr-1.5' : '',
|
||||
promptEnhanced ? 'enabled:hover:bg-bolt-elements-item-backgroundAccent' : '',
|
||||
)}
|
||||
onClick={() => enhancePrompt?.()}
|
||||
>
|
||||
{enhancingPrompt ? (
|
||||
@@ -277,43 +490,42 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</>
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<SpeechRecognitionButton
|
||||
isListening={isListening}
|
||||
onStart={startListening}
|
||||
onStop={stopListening}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
|
||||
</div>
|
||||
{input.length > 3 ? (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">
|
||||
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
|
||||
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> for
|
||||
a new line
|
||||
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a
|
||||
new line
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-1 pb-6">{/* Ghost Element */}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!chatStarted && (
|
||||
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
|
||||
<div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
|
||||
{EXAMPLE_PROMPTS.map((examplePrompt, index) => {
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(event) => {
|
||||
sendMessage?.(event, examplePrompt.text);
|
||||
}}
|
||||
className="group flex items-center w-full gap-2 justify-center bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-theme"
|
||||
>
|
||||
{examplePrompt.text}
|
||||
<div className="i-ph:arrow-bend-down-left" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!chatStarted && ImportButtons(importChat)}
|
||||
{!chatStarted &&
|
||||
ExamplePrompts((event, messageInput) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event, messageInput);
|
||||
})}
|
||||
</div>
|
||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
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 { useChatHistory } from '~/lib/persistence';
|
||||
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 } 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',
|
||||
@@ -27,11 +30,20 @@ const logger = createScopedLogger('Chat');
|
||||
export function Chat() {
|
||||
renderLogger.trace('Chat');
|
||||
|
||||
const { ready, initialMessages, storeMessageHistory } = useChatHistory();
|
||||
const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
|
||||
const title = useStore(description);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
|
||||
{ready && (
|
||||
<ChatImpl
|
||||
description={title}
|
||||
initialMessages={initialMessages}
|
||||
exportChat={exportChat}
|
||||
storeMessageHistory={storeMessageHistory}
|
||||
importChat={importChat}
|
||||
/>
|
||||
)}
|
||||
<ToastContainer
|
||||
closeButton={({ closeToast }) => {
|
||||
return (
|
||||
@@ -66,213 +78,281 @@ export function Chat() {
|
||||
interface ChatProps {
|
||||
initialMessages: Message[];
|
||||
storeMessageHistory: (messages: Message[]) => Promise<void>;
|
||||
importChat: (description: string, messages: Message[]) => Promise<void>;
|
||||
exportChat: () => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => {
|
||||
useShortcuts();
|
||||
export const ChatImpl = memo(
|
||||
({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
|
||||
useShortcuts();
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
||||
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
|
||||
|
||||
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
||||
const [model, setModel] = useState(() => {
|
||||
const savedModel = Cookies.get('selectedModel');
|
||||
return savedModel || DEFAULT_MODEL;
|
||||
});
|
||||
const [provider, setProvider] = useState(() => {
|
||||
const savedProvider = Cookies.get('selectedProvider');
|
||||
return savedProvider || DEFAULT_PROVIDER;
|
||||
});
|
||||
const [model, setModel] = useState(() => {
|
||||
const savedModel = Cookies.get('selectedModel');
|
||||
return savedModel || DEFAULT_MODEL;
|
||||
});
|
||||
const [provider, setProvider] = useState(() => {
|
||||
const savedProvider = Cookies.get('selectedProvider');
|
||||
return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER;
|
||||
});
|
||||
|
||||
const { showChat } = useStore(chatStore);
|
||||
const { showChat } = useStore(chatStore);
|
||||
|
||||
const [animationScope, animate] = useAnimate();
|
||||
const [animationScope, animate] = useAnimate();
|
||||
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
|
||||
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
|
||||
api: '/api/chat',
|
||||
body: {
|
||||
apiKeys,
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Request failed\n\n', error);
|
||||
toast.error('There was an error processing your request');
|
||||
},
|
||||
onFinish: () => {
|
||||
logger.debug('Finished streaming');
|
||||
},
|
||||
initialMessages,
|
||||
});
|
||||
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
|
||||
api: '/api/chat',
|
||||
body: {
|
||||
apiKeys,
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Request failed\n\n', error);
|
||||
toast.error(
|
||||
'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'),
|
||||
);
|
||||
},
|
||||
onFinish: () => {
|
||||
logger.debug('Finished streaming');
|
||||
},
|
||||
initialMessages,
|
||||
initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '',
|
||||
});
|
||||
|
||||
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
|
||||
const { parsedMessages, parseMessages } = useMessageParser();
|
||||
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
|
||||
const { parsedMessages, parseMessages } = useMessageParser();
|
||||
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.setKey('started', initialMessages.length > 0);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
chatStore.setKey('started', initialMessages.length > 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
parseMessages(messages, isLoading);
|
||||
useEffect(() => {
|
||||
parseMessages(messages, isLoading);
|
||||
|
||||
if (messages.length > initialMessages.length) {
|
||||
storeMessageHistory(messages).catch((error) => toast.error(error.message));
|
||||
}
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
if (messages.length > initialMessages.length) {
|
||||
storeMessageHistory(messages).catch((error) => toast.error(error.message));
|
||||
}
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
|
||||
const scrollTextArea = () => {
|
||||
const textarea = textareaRef.current;
|
||||
const scrollTextArea = () => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
};
|
||||
if (textarea) {
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
stop();
|
||||
chatStore.setKey('aborted', true);
|
||||
workbenchStore.abortAllActions();
|
||||
};
|
||||
const abort = () => {
|
||||
stop();
|
||||
chatStore.setKey('aborted', true);
|
||||
workbenchStore.abortAllActions();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
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]);
|
||||
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;
|
||||
}
|
||||
const runAnimation = async () => {
|
||||
if (chatStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
|
||||
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
|
||||
]);
|
||||
await Promise.all([
|
||||
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
|
||||
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
|
||||
]);
|
||||
|
||||
chatStore.setKey('started', true);
|
||||
chatStore.setKey('started', true);
|
||||
|
||||
setChatStarted(true);
|
||||
};
|
||||
setChatStarted(true);
|
||||
};
|
||||
|
||||
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
|
||||
const _input = messageInput || input;
|
||||
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
|
||||
const _input = messageInput || input;
|
||||
|
||||
if (_input.length === 0 || isLoading) {
|
||||
return;
|
||||
}
|
||||
if (_input.length === 0 || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
|
||||
* many unsaved files. In that case we need to block user input and show an indicator
|
||||
* of some kind so the user is aware that something is happening. But I consider the
|
||||
* happy case to be no unsaved files and I would expect users to save their changes
|
||||
* before they send another message.
|
||||
*/
|
||||
await workbenchStore.saveAllFiles();
|
||||
|
||||
const fileModifications = workbenchStore.getFileModifcations();
|
||||
|
||||
chatStore.setKey('aborted', false);
|
||||
|
||||
runAnimation();
|
||||
|
||||
if (fileModifications !== undefined) {
|
||||
/**
|
||||
* If we have file modifications we append a new user message manually since we have to prefix
|
||||
* the user input with the file modifications and we don't want the new user input to appear
|
||||
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
|
||||
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
||||
* aren't relevant here.
|
||||
*/
|
||||
append({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
image: imageData,
|
||||
})),
|
||||
] as any, // Type assertion to bypass compiler check
|
||||
});
|
||||
|
||||
/**
|
||||
* After sending a new message we reset all modifications since the model
|
||||
* should now be aware of all the changes.
|
||||
*/
|
||||
workbenchStore.resetAllFileModifications();
|
||||
} else {
|
||||
append({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
image: imageData,
|
||||
})),
|
||||
] as any, // Type assertion to bypass compiler check
|
||||
});
|
||||
}
|
||||
|
||||
setInput('');
|
||||
Cookies.remove(PROMPT_COOKIE_KEY);
|
||||
|
||||
// Add file cleanup here
|
||||
setUploadedFiles([]);
|
||||
setImageDataList([]);
|
||||
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
};
|
||||
|
||||
/**
|
||||
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
|
||||
* many unsaved files. In that case we need to block user input and show an indicator
|
||||
* of some kind so the user is aware that something is happening. But I consider the
|
||||
* happy case to be no unsaved files and I would expect users to save their changes
|
||||
* before they send another message.
|
||||
* Handles the change event for the textarea and updates the input state.
|
||||
* @param event - The change event from the textarea.
|
||||
*/
|
||||
await workbenchStore.saveAllFiles();
|
||||
const onTextareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
handleInputChange(event);
|
||||
};
|
||||
|
||||
const fileModifications = workbenchStore.getFileModifcations();
|
||||
/**
|
||||
* 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),
|
||||
[],
|
||||
);
|
||||
|
||||
chatStore.setKey('aborted', false);
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
runAnimation();
|
||||
useEffect(() => {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
if (fileModifications !== undefined) {
|
||||
const diff = fileModificationsToHTML(fileModifications);
|
||||
if (storedApiKeys) {
|
||||
setApiKeys(JSON.parse(storedApiKeys));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* If we have file modifications we append a new user message manually since we have to prefix
|
||||
* the user input with the file modifications and we don't want the new user input to appear
|
||||
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
|
||||
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
||||
* aren't relevant here.
|
||||
*/
|
||||
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider}]\n\n${diff}\n\n${_input}` });
|
||||
const handleModelChange = (newModel: string) => {
|
||||
setModel(newModel);
|
||||
Cookies.set('selectedModel', newModel, { expires: 30 });
|
||||
};
|
||||
|
||||
/**
|
||||
* After sending a new message we reset all modifications since the model
|
||||
* should now be aware of all the changes.
|
||||
*/
|
||||
workbenchStore.resetAllFileModifications();
|
||||
} else {
|
||||
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider}]\n\n${_input}` });
|
||||
}
|
||||
const handleProviderChange = (newProvider: ProviderInfo) => {
|
||||
setProvider(newProvider);
|
||||
Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
|
||||
};
|
||||
|
||||
setInput('');
|
||||
return (
|
||||
<BaseChat
|
||||
ref={animationScope}
|
||||
textareaRef={textareaRef}
|
||||
input={input}
|
||||
showChat={showChat}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isLoading}
|
||||
enhancingPrompt={enhancingPrompt}
|
||||
promptEnhanced={promptEnhanced}
|
||||
sendMessage={sendMessage}
|
||||
model={model}
|
||||
setModel={handleModelChange}
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={(e) => {
|
||||
onTextareaChange(e);
|
||||
debouncedCachePrompt(e);
|
||||
}}
|
||||
handleStop={abort}
|
||||
description={description}
|
||||
importChat={importChat}
|
||||
exportChat={exportChat}
|
||||
messages={messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
};
|
||||
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
useEffect(() => {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
if (storedApiKeys) {
|
||||
setApiKeys(JSON.parse(storedApiKeys));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleModelChange = (newModel: string) => {
|
||||
setModel(newModel);
|
||||
Cookies.set('selectedModel', newModel, { expires: 30 });
|
||||
};
|
||||
|
||||
const handleProviderChange = (newProvider: string) => {
|
||||
setProvider(newProvider);
|
||||
Cookies.set('selectedProvider', newProvider, { expires: 30 });
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseChat
|
||||
ref={animationScope}
|
||||
textareaRef={textareaRef}
|
||||
input={input}
|
||||
showChat={showChat}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isLoading}
|
||||
enhancingPrompt={enhancingPrompt}
|
||||
promptEnhanced={promptEnhanced}
|
||||
sendMessage={sendMessage}
|
||||
model={model}
|
||||
setModel={handleModelChange}
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={handleInputChange}
|
||||
handleStop={abort}
|
||||
messages={messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
})}
|
||||
enhancePrompt={() => {
|
||||
enhancePrompt(
|
||||
input,
|
||||
(input) => {
|
||||
setInput(input);
|
||||
scrollTextArea();
|
||||
},
|
||||
model,
|
||||
provider,
|
||||
apiKeys,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
})}
|
||||
enhancePrompt={() => {
|
||||
enhancePrompt(
|
||||
input,
|
||||
(input) => {
|
||||
setInput(input);
|
||||
scrollTextArea();
|
||||
},
|
||||
model,
|
||||
provider,
|
||||
apiKeys,
|
||||
);
|
||||
}}
|
||||
uploadedFiles={uploadedFiles}
|
||||
setUploadedFiles={setUploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
setImageDataList={setImageDataList}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
36
app/components/chat/ExamplePrompts.tsx
Normal file
36
app/components/chat/ExamplePrompts.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ text: 'Build a todo app in React using Tailwind' },
|
||||
{ text: 'Build a simple blog using Astro' },
|
||||
{ text: 'Create a cookie consent form using Material UI' },
|
||||
{ text: 'Make a space invaders game' },
|
||||
{ text: 'Make a Tic Tac Toe game in html, css and js only' },
|
||||
];
|
||||
|
||||
export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) {
|
||||
return (
|
||||
<div id="examples" className="relative flex flex-col gap-9 w-full max-w-3xl mx-auto flex justify-center mt-6">
|
||||
<div
|
||||
className="flex flex-wrap justify-center gap-2"
|
||||
style={{
|
||||
animation: '.25s ease-out 0s 1 _fade-and-move-in_g2ptj_1 forwards',
|
||||
}}
|
||||
>
|
||||
{EXAMPLE_PROMPTS.map((examplePrompt, index: number) => {
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(event) => {
|
||||
sendMessage?.(event, examplePrompt.text);
|
||||
}}
|
||||
className="border border-bolt-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary px-3 py-1 text-xs transition-theme"
|
||||
>
|
||||
{examplePrompt.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
app/components/chat/FilePreview.tsx
Normal file
35
app/components/chat/FilePreview.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FilePreviewProps {
|
||||
files: File[];
|
||||
imageDataList: string[];
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemove }) => {
|
||||
if (!files || files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row overflow-x-auto -mt-2">
|
||||
{files.map((file, index) => (
|
||||
<div key={file.name + file.size} className="mr-2 relative">
|
||||
{imageDataList[index] && (
|
||||
<div className="relative pt-4 pr-4">
|
||||
<img src={imageDataList[index]} alt={file.name} className="max-h-20" />
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="absolute top-1 right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<div className="i-ph:x w-3 h-3 text-gray-200" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
164
app/components/chat/ImportFolderButton.tsx
Normal file
164
app/components/chat/ImportFolderButton.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import ignore from 'ignore';
|
||||
|
||||
interface ImportFolderButtonProps {
|
||||
className?: string;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// Common patterns to ignore, similar to .gitignore
|
||||
const IGNORE_PATTERNS = [
|
||||
'node_modules/**',
|
||||
'.git/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'.cache/**',
|
||||
'.vscode/**',
|
||||
'.idea/**',
|
||||
'**/*.log',
|
||||
'**/.DS_Store',
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
];
|
||||
|
||||
const ig = ignore().add(IGNORE_PATTERNS);
|
||||
const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||
|
||||
const isBinaryFile = async (file: File): Promise<boolean> => {
|
||||
const chunkSize = 1024; // Read the first 1 KB of the file
|
||||
const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer());
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
|
||||
return true; // Found a binary character
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
|
||||
const shouldIncludeFile = (path: string): boolean => {
|
||||
return !ig.ignores(path);
|
||||
};
|
||||
|
||||
const createChatFromFolder = async (files: File[], binaryFiles: string[]) => {
|
||||
const fileArtifacts = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const content = reader.result as string;
|
||||
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
|
||||
resolve(
|
||||
`<boltAction type="file" filePath="${relativePath}">
|
||||
${content}
|
||||
</boltAction>`,
|
||||
);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const binaryFilesMessage =
|
||||
binaryFiles.length > 0
|
||||
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const message: Message = {
|
||||
role: 'assistant',
|
||||
content: `I'll help you set up these files.${binaryFilesMessage}
|
||||
|
||||
<boltArtifact id="imported-files" title="Imported Files" type="bundled">
|
||||
${fileArtifacts.join('\n\n')}
|
||||
</boltArtifact>`,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
id: generateId(),
|
||||
content: 'Import my files',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`;
|
||||
|
||||
if (importChat) {
|
||||
await importChat(description, [userMessage, message]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
id="folder-import"
|
||||
className="hidden"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
onChange={async (e) => {
|
||||
const allFiles = Array.from(e.target.files || []);
|
||||
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
|
||||
|
||||
if (filteredFiles.length === 0) {
|
||||
toast.error('No files found in the selected folder');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileChecks = await Promise.all(
|
||||
filteredFiles.map(async (file) => ({
|
||||
file,
|
||||
isBinary: await isBinaryFile(file),
|
||||
})),
|
||||
);
|
||||
|
||||
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
|
||||
const binaryFilePaths = fileChecks
|
||||
.filter((f) => f.isBinary)
|
||||
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
toast.error('No text files found in the selected folder');
|
||||
return;
|
||||
}
|
||||
|
||||
if (binaryFilePaths.length > 0) {
|
||||
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||
}
|
||||
|
||||
await createChatFromFolder(textFiles, binaryFilePaths);
|
||||
} catch (error) {
|
||||
console.error('Failed to import folder:', error);
|
||||
toast.error('Failed to import folder');
|
||||
}
|
||||
|
||||
e.target.value = ''; // Reset file input
|
||||
}}
|
||||
{...({} as any)} // if removed webkitdirectory will throw errors as unknow attribute
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const input = document.getElementById('folder-import');
|
||||
input?.click();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<div className="i-ph:upload-simple" />
|
||||
Import Folder
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
app/components/chat/Markdown.spec.ts
Normal file
48
app/components/chat/Markdown.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { AssistantMessage } 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';
|
||||
|
||||
interface MessagesProps {
|
||||
id?: string;
|
||||
@@ -13,12 +18,33 @@ interface MessagesProps {
|
||||
|
||||
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
|
||||
const { id, isStreaming = false, messages = [] } = props;
|
||||
const location = useLocation();
|
||||
|
||||
const handleRewind = (messageId: string) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
searchParams.set('rewindTo', messageId);
|
||||
window.location.search = searchParams.toString();
|
||||
};
|
||||
|
||||
const handleFork = async (messageId: string) => {
|
||||
try {
|
||||
if (!db || !chatId.get()) {
|
||||
toast.error('Chat persistence is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const urlId = await forkChat(db, chatId.get()!, messageId);
|
||||
window.location.href = `/chat/${urlId}`;
|
||||
} catch (error) {
|
||||
toast.error('Failed to fork chat: ' + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={id} ref={ref} className={props.className}>
|
||||
{messages.length > 0
|
||||
? messages.map((message, index) => {
|
||||
const { role, content } = message;
|
||||
const { role, content, id: messageId } = message;
|
||||
const isUserMessage = role === 'user';
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === messages.length - 1;
|
||||
@@ -41,6 +67,33 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
<div className="grid grid-col-1 w-full">
|
||||
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
|
||||
</div>
|
||||
{!isUserMessage && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
{messageId && (
|
||||
<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={() => 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
63
app/components/chat/ModelSelector.tsx
Normal file
63
app/components/chat/ModelSelector.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import type { ModelInfo } from '~/utils/types';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
model?: string;
|
||||
setModel?: (model: string) => void;
|
||||
provider?: ProviderInfo;
|
||||
setProvider?: (provider: ProviderInfo) => void;
|
||||
modelList: ModelInfo[];
|
||||
providerList: ProviderInfo[];
|
||||
apiKeys: Record<string, string>;
|
||||
}
|
||||
|
||||
export const ModelSelector = ({
|
||||
model,
|
||||
setModel,
|
||||
provider,
|
||||
setProvider,
|
||||
modelList,
|
||||
providerList,
|
||||
}: ModelSelectorProps) => {
|
||||
return (
|
||||
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
value={provider?.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
|
||||
|
||||
if (newProvider && setProvider) {
|
||||
setProvider(newProvider);
|
||||
}
|
||||
|
||||
const firstModel = [...modelList].find((m) => m.provider === e.target.value);
|
||||
|
||||
if (firstModel && setModel) {
|
||||
setModel(firstModel.name);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{providerList.map((provider: ProviderInfo) => (
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
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%]"
|
||||
>
|
||||
{[...modelList]
|
||||
.filter((e) => e.provider == provider?.name && e.name)
|
||||
.map((modelOption) => (
|
||||
<option key={modelOption.name} value={modelOption.name}>
|
||||
{modelOption.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,11 +4,12 @@ interface SendButtonProps {
|
||||
show: boolean;
|
||||
isStreaming?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onImagesSelected?: (images: File[]) => void;
|
||||
}
|
||||
|
||||
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||
|
||||
export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
|
||||
export const SendButton = ({ show, isStreaming, onClick }: SendButtonProps) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show ? (
|
||||
@@ -30,4 +31,4 @@ export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
28
app/components/chat/SpeechRecognition.tsx
Normal file
28
app/components/chat/SpeechRecognition.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import React from 'react';
|
||||
|
||||
export const SpeechRecognitionButton = ({
|
||||
isListening,
|
||||
onStart,
|
||||
onStop,
|
||||
disabled,
|
||||
}: {
|
||||
isListening: boolean;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<IconButton
|
||||
title={isListening ? 'Stop listening' : 'Start speech recognition'}
|
||||
disabled={disabled}
|
||||
className={classNames('transition-all', {
|
||||
'text-bolt-elements-item-contentAccent': isListening,
|
||||
})}
|
||||
onClick={isListening ? onStop : onStart}
|
||||
>
|
||||
{isListening ? <div className="i-ph:microphone-slash text-xl" /> : <div className="i-ph:microphone text-xl" />}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,53 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
import { modificationsRegex } from '~/utils/diff';
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||
import { Markdown } from './Markdown';
|
||||
|
||||
interface UserMessageProps {
|
||||
content: string;
|
||||
content: string | Array<{ type: string; text?: string; image?: string }>;
|
||||
}
|
||||
|
||||
export function UserMessage({ content }: UserMessageProps) {
|
||||
if (Array.isArray(content)) {
|
||||
const textItem = content.find((item) => item.type === 'text');
|
||||
const textContent = sanitizeUserMessage(textItem?.text || '');
|
||||
const images = content.filter((item) => item.type === 'image' && item.image);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden pt-[4px]">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<Markdown limitedMarkdown>{textContent}</Markdown>
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<div className="flex-shrink-0 w-[160px]">
|
||||
{images.map((item, index) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={`Uploaded image ${index + 1}`}
|
||||
className="w-full h-[160px] rounded-lg object-cover border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const textContent = sanitizeUserMessage(content);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden pt-[4px]">
|
||||
<Markdown limitedMarkdown>{sanitizeUserMessage(content)}</Markdown>
|
||||
<Markdown limitedMarkdown>{textContent}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeUserMessage(content: string) {
|
||||
return content.replace(modificationsRegex, '').replace(MODEL_REGEX, 'Using: $1').replace(PROVIDER_REGEX, ' ($1)\n\n').trim();
|
||||
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||
}
|
||||
|
||||
13
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal file
13
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import React from 'react';
|
||||
|
||||
export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => {
|
||||
return (
|
||||
<WithTooltip tooltip="Export Chat">
|
||||
<IconButton title="Export Chat" onClick={() => exportChat?.()}>
|
||||
<div className="i-ph:download-simple text-xl"></div>
|
||||
</IconButton>
|
||||
</WithTooltip>
|
||||
);
|
||||
};
|
||||
71
app/components/chat/chatExportAndImport/ImportButtons.tsx
Normal file
71
app/components/chat/chatExportAndImport/ImportButtons.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import React from 'react';
|
||||
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
|
||||
|
||||
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 p-4">
|
||||
<input
|
||||
type="file"
|
||||
id="chat-import"
|
||||
className="hidden"
|
||||
accept=".json"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file && importChat) {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (!Array.isArray(data.messages)) {
|
||||
toast.error('Invalid chat file format');
|
||||
}
|
||||
|
||||
await importChat(data.description, data.messages);
|
||||
toast.success('Chat imported successfully');
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error('Failed to parse chat file: ' + error.message);
|
||||
} else {
|
||||
toast.error('Failed to parse chat file');
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.onerror = () => toast.error('Failed to read chat file');
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to import chat');
|
||||
}
|
||||
e.target.value = ''; // Reset file input
|
||||
} else {
|
||||
toast.error('Something went wrong');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-4 max-w-2xl text-center">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const input = document.getElementById('chat-import');
|
||||
input?.click();
|
||||
}}
|
||||
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 className="i-ph:upload-simple" />
|
||||
Import Chat
|
||||
</button>
|
||||
<ImportFolderButton
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -21,17 +21,19 @@ export function Header() {
|
||||
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
|
||||
</a>
|
||||
</div>
|
||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
||||
</span>
|
||||
{chat.started && (
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<div className="mr-1">
|
||||
<HeaderActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</ClientOnly>
|
||||
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
|
||||
<>
|
||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
||||
</span>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<div className="mr-1">
|
||||
<HeaderActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
@@ -9,6 +10,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const { showChat } = useStore(chatStore);
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
|
||||
return (
|
||||
@@ -16,7 +19,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
||||
<Button
|
||||
active={showChat}
|
||||
disabled={!canHideChat}
|
||||
disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's not needed
|
||||
onClick={() => {
|
||||
if (canHideChat) {
|
||||
chatStore.setKey('showChat', !showChat);
|
||||
|
||||
@@ -1,64 +1,127 @@
|
||||
import { useParams } from '@remix-run/react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { type ChatHistoryItem } from '~/lib/persistence';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useEditChatDescription } from '~/lib/hooks';
|
||||
|
||||
interface HistoryItemProps {
|
||||
item: ChatHistoryItem;
|
||||
onDelete?: (event: React.UIEvent) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
exportChat: (id?: string) => void;
|
||||
}
|
||||
|
||||
export function HistoryItem({ item, onDelete }: HistoryItemProps) {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const hoverRef = useRef<HTMLDivElement>(null);
|
||||
export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
|
||||
const { id: urlId } = useParams();
|
||||
const isActiveChat = urlId === item.urlId;
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
|
||||
useEditChatDescription({
|
||||
initialDescription: item.description,
|
||||
customChatId: item.id,
|
||||
syncWithGlobalStore: isActiveChat,
|
||||
});
|
||||
|
||||
function mouseEnter() {
|
||||
setHovering(true);
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function mouseLeave() {
|
||||
setHovering(false);
|
||||
}
|
||||
|
||||
hoverRef.current?.addEventListener('mouseenter', mouseEnter);
|
||||
hoverRef.current?.addEventListener('mouseleave', mouseLeave);
|
||||
|
||||
return () => {
|
||||
hoverRef.current?.removeEventListener('mouseenter', mouseEnter);
|
||||
hoverRef.current?.removeEventListener('mouseleave', mouseLeave);
|
||||
};
|
||||
}, []);
|
||||
const renderDescriptionForm = (
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2"
|
||||
autoFocus
|
||||
value={currentDescription}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||
onMouseDown={handleSubmit}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hoverRef}
|
||||
className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1"
|
||||
className={classNames(
|
||||
'group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1',
|
||||
{ '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat },
|
||||
)}
|
||||
>
|
||||
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||
{item.description}
|
||||
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
|
||||
{hovering && (
|
||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
|
||||
{editing ? (
|
||||
renderDescriptionForm
|
||||
) : (
|
||||
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||
{currentDescription}
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 box-content pl-3 to-transparent w-10 flex justify-end group-hover:w-22 group-hover:from-99%',
|
||||
{ 'from-bolt-elements-background-depth-3 w-10 ': isActiveChat },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChatActionButton
|
||||
toolTipContent="Export chat"
|
||||
icon="i-ph:download-simple"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
exportChat(item.id);
|
||||
}}
|
||||
/>
|
||||
{onDuplicate && (
|
||||
<ChatActionButton
|
||||
toolTipContent="Duplicate chat"
|
||||
icon="i-ph:copy"
|
||||
onClick={() => onDuplicate?.(item.id)}
|
||||
/>
|
||||
)}
|
||||
<ChatActionButton
|
||||
toolTipContent="Rename chat"
|
||||
icon="i-ph:pencil-fill"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
toggleEditMode();
|
||||
}}
|
||||
/>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
className="i-ph:trash scale-110"
|
||||
<ChatActionButton
|
||||
toolTipContent="Delete chat"
|
||||
icon="i-ph:trash"
|
||||
className="[&&]:hover:text-bolt-elements-button-danger-text"
|
||||
onClick={(event) => {
|
||||
// we prevent the default so we don't trigger the anchor above
|
||||
event.preventDefault();
|
||||
onDelete?.(event);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChatActionButton = ({
|
||||
toolTipContent,
|
||||
icon,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
toolTipContent: string;
|
||||
icon: string;
|
||||
className?: string;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
btnTitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<WithTooltip tooltip={toolTipContent}>
|
||||
<button
|
||||
type="button"
|
||||
className={`scale-110 mr-2 hover:text-bolt-elements-item-contentAccent ${icon} ${className ? className : ''}`}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</WithTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,13 @@ import { motion, type Variants } from 'framer-motion';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
|
||||
import { db, deleteById, getAll, chatId, type ChatHistoryItem } from '~/lib/persistence';
|
||||
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { logger } from '~/utils/logger';
|
||||
import { HistoryItem } from './HistoryItem';
|
||||
import { binDates } from './date-binning';
|
||||
import { useSearchFilter } from '~/lib/hooks/useSearchFilter';
|
||||
|
||||
const menuVariants = {
|
||||
closed: {
|
||||
@@ -33,12 +33,18 @@ const menuVariants = {
|
||||
|
||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
||||
|
||||
export function Menu() {
|
||||
export const Menu = () => {
|
||||
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
||||
|
||||
const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({
|
||||
items: list,
|
||||
searchFields: ['description'],
|
||||
});
|
||||
|
||||
const loadEntries = useCallback(() => {
|
||||
if (db) {
|
||||
getAll(db)
|
||||
@@ -99,17 +105,27 @@ export function Menu() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
|
||||
event.preventDefault();
|
||||
setDialogContent({ type: 'delete', item });
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id: string) => {
|
||||
await duplicateCurrentChat(id);
|
||||
loadEntries(); // Reload the list after duplication
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
initial="closed"
|
||||
animate={open ? 'open' : 'closed'}
|
||||
variants={menuVariants}
|
||||
className="flex flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
|
||||
className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
|
||||
>
|
||||
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
|
||||
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="p-4 select-none">
|
||||
<a
|
||||
href="/"
|
||||
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
||||
@@ -118,17 +134,38 @@ export function Menu() {
|
||||
Start new chat
|
||||
</a>
|
||||
</div>
|
||||
<div className="pl-4 pr-4 my-2">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
onChange={handleSearchChange}
|
||||
aria-label="Search chats"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
|
||||
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
|
||||
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
|
||||
<div className="flex-1 overflow-auto pl-4 pr-5 pb-5">
|
||||
{filteredList.length === 0 && (
|
||||
<div className="pl-2 text-bolt-elements-textTertiary">
|
||||
{list.length === 0 ? 'No previous conversations' : 'No matches found'}
|
||||
</div>
|
||||
)}
|
||||
<DialogRoot open={dialogContent !== null}>
|
||||
{binDates(list).map(({ category, items }) => (
|
||||
{binDates(filteredList).map(({ category, items }) => (
|
||||
<div key={category} className="mt-4 first:mt-0 space-y-1">
|
||||
<div className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1">
|
||||
{category}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<HistoryItem key={item.id} item={item} onDelete={() => setDialogContent({ type: 'delete', item })} />
|
||||
<HistoryItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
exportChat={exportChat}
|
||||
onDelete={(event) => handleDeleteClick(event, item)}
|
||||
onDuplicate={() => handleDuplicate(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -169,4 +206,4 @@ export function Menu() {
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
73
app/components/ui/Tooltip.tsx
Normal file
73
app/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
|
||||
interface TooltipProps {
|
||||
tooltip: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
sideOffset?: number;
|
||||
className?: string;
|
||||
arrowClassName?: string;
|
||||
tooltipStyle?: React.CSSProperties;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
maxWidth?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const WithTooltip = ({
|
||||
tooltip,
|
||||
children,
|
||||
sideOffset = 5,
|
||||
className = '',
|
||||
arrowClassName = '',
|
||||
tooltipStyle = {},
|
||||
position = 'top',
|
||||
maxWidth = 250,
|
||||
delay = 0,
|
||||
}: TooltipProps) => {
|
||||
return (
|
||||
<Tooltip.Root delayDuration={delay}>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side={position}
|
||||
className={`
|
||||
z-[2000]
|
||||
px-2.5
|
||||
py-1.5
|
||||
max-h-[300px]
|
||||
select-none
|
||||
rounded-md
|
||||
bg-bolt-elements-background-depth-3
|
||||
text-bolt-elements-textPrimary
|
||||
text-sm
|
||||
leading-tight
|
||||
shadow-lg
|
||||
animate-in
|
||||
fade-in-0
|
||||
zoom-in-95
|
||||
data-[state=closed]:animate-out
|
||||
data-[state=closed]:fade-out-0
|
||||
data-[state=closed]:zoom-out-95
|
||||
${className}
|
||||
`}
|
||||
sideOffset={sideOffset}
|
||||
style={{
|
||||
maxWidth,
|
||||
...tooltipStyle,
|
||||
}}
|
||||
>
|
||||
<div className="break-words">{tooltip}</div>
|
||||
<Tooltip.Arrow
|
||||
className={`
|
||||
fill-bolt-elements-background-depth-3
|
||||
${arrowClassName}
|
||||
`}
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default WithTooltip;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import {
|
||||
CodeMirrorEditor,
|
||||
type EditorDocument,
|
||||
@@ -9,20 +9,17 @@ import {
|
||||
type OnSaveCallback as OnEditorSave,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { PanelHeader } from '~/components/ui/PanelHeader';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import { shortcutEventEmitter } from '~/lib/hooks';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { logger, renderLogger } from '~/utils/logger';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { isMobile } from '~/utils/mobile';
|
||||
import { FileBreadcrumb } from './FileBreadcrumb';
|
||||
import { FileTree } from './FileTree';
|
||||
import { Terminal, type TerminalRef } from './terminal/Terminal';
|
||||
import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
|
||||
interface EditorPanelProps {
|
||||
files?: FileMap;
|
||||
@@ -37,8 +34,6 @@ interface EditorPanelProps {
|
||||
onFileReset?: () => void;
|
||||
}
|
||||
|
||||
const MAX_TERMINALS = 3;
|
||||
const DEFAULT_TERMINAL_SIZE = 25;
|
||||
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
|
||||
|
||||
const editorSettings: EditorSettings = { tabSize: 2 };
|
||||
@@ -61,13 +56,6 @@ export const EditorPanel = memo(
|
||||
const theme = useStore(themeStore);
|
||||
const showTerminal = useStore(workbenchStore.showTerminal);
|
||||
|
||||
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
|
||||
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const terminalToggledByShortcut = useRef(false);
|
||||
|
||||
const [activeTerminal, setActiveTerminal] = useState(0);
|
||||
const [terminalCount, setTerminalCount] = useState(1);
|
||||
|
||||
const activeFileSegments = useMemo(() => {
|
||||
if (!editorDocument) {
|
||||
return undefined;
|
||||
@@ -80,48 +68,6 @@ export const EditorPanel = memo(
|
||||
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
|
||||
}, [editorDocument, unsavedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
|
||||
terminalToggledByShortcut.current = true;
|
||||
});
|
||||
|
||||
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
|
||||
for (const ref of Object.values(terminalRefs.current)) {
|
||||
ref?.reloadStyles();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromEventEmitter();
|
||||
unsubscribeFromThemeStore();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { current: terminal } = terminalPanelRef;
|
||||
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCollapsed = terminal.isCollapsed();
|
||||
|
||||
if (!showTerminal && !isCollapsed) {
|
||||
terminal.collapse();
|
||||
} else if (showTerminal && isCollapsed) {
|
||||
terminal.resize(DEFAULT_TERMINAL_SIZE);
|
||||
}
|
||||
|
||||
terminalToggledByShortcut.current = false;
|
||||
}, [showTerminal]);
|
||||
|
||||
const addTerminal = () => {
|
||||
if (terminalCount < MAX_TERMINALS) {
|
||||
setTerminalCount(terminalCount + 1);
|
||||
setActiveTerminal(terminalCount);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
|
||||
@@ -180,116 +126,7 @@ export const EditorPanel = memo(
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel
|
||||
ref={terminalPanelRef}
|
||||
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
|
||||
minSize={10}
|
||||
collapsible
|
||||
onExpand={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(true);
|
||||
}
|
||||
}}
|
||||
onCollapse={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-full">
|
||||
<div className="bg-bolt-elements-terminals-background h-full flex flex-col">
|
||||
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
|
||||
{Array.from({ length: terminalCount + 1 }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
|
||||
return (
|
||||
<>
|
||||
{index == 0 ? (
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary':
|
||||
isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Bolt Terminal
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Terminal {terminalCount > 1 && index}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
|
||||
<IconButton
|
||||
className="ml-auto"
|
||||
icon="i-ph:caret-down"
|
||||
title="Close"
|
||||
size="md"
|
||||
onClick={() => workbenchStore.toggleTerminal(false)}
|
||||
/>
|
||||
</div>
|
||||
{Array.from({ length: terminalCount + 1 }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
if (index == 0) {
|
||||
logger.info('Starting bolt terminal');
|
||||
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<TerminalTabs />
|
||||
</PanelGroup>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -111,7 +111,7 @@ export const FileTree = memo(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('text-sm', className)}>
|
||||
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
|
||||
{filteredFileList.map((fileOrFolder) => {
|
||||
switch (fileOrFolder.kind) {
|
||||
case 'file': {
|
||||
|
||||
@@ -4,11 +4,16 @@ import { IconButton } from '~/components/ui/IconButton';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { PortDropdown } from './PortDropdown';
|
||||
|
||||
type ResizeSide = 'left' | 'right' | null;
|
||||
|
||||
export const Preview = memo(() => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const hasSelectedPreview = useRef(false);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
@@ -16,6 +21,23 @@ export const Preview = memo(() => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||
|
||||
// Toggle between responsive mode and device mode
|
||||
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||
|
||||
// Use percentage for width
|
||||
const [widthPercent, setWidthPercent] = useState<number>(37.5); // 375px assuming 1000px window width initially
|
||||
|
||||
const resizingState = useRef({
|
||||
isResizing: false,
|
||||
side: null as ResizeSide,
|
||||
startX: 0,
|
||||
startWidthPercent: 37.5,
|
||||
windowWidth: window.innerWidth,
|
||||
});
|
||||
|
||||
// Define the scaling factor
|
||||
const SCALING_FACTOR = 2; // Adjust this value to increase/decrease sensitivity
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePreview) {
|
||||
setUrl('');
|
||||
@@ -25,10 +47,9 @@ export const Preview = memo(() => {
|
||||
}
|
||||
|
||||
const { baseUrl } = activePreview;
|
||||
|
||||
setUrl(baseUrl);
|
||||
setIframeUrl(baseUrl);
|
||||
}, [activePreview, iframeUrl]);
|
||||
}, [activePreview]);
|
||||
|
||||
const validateUrl = useCallback(
|
||||
(value: string) => {
|
||||
@@ -56,14 +77,13 @@ export const Preview = memo(() => {
|
||||
[],
|
||||
);
|
||||
|
||||
// when previews change, display the lowest port if user hasn't selected a preview
|
||||
// When previews change, display the lowest port if user hasn't selected a preview
|
||||
useEffect(() => {
|
||||
if (previews.length > 1 && !hasSelectedPreview.current) {
|
||||
const minPortIndex = previews.reduce(findMinPortIndex, 0);
|
||||
|
||||
setActivePreviewIndex(minPortIndex);
|
||||
}
|
||||
}, [previews]);
|
||||
}, [previews, findMinPortIndex]);
|
||||
|
||||
const reloadPreview = () => {
|
||||
if (iframeRef.current) {
|
||||
@@ -71,13 +91,134 @@ export const Preview = memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!isFullscreen && containerRef.current) {
|
||||
await containerRef.current.requestFullscreen();
|
||||
} else if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleDeviceMode = () => {
|
||||
setIsDeviceModeOn((prev) => !prev);
|
||||
};
|
||||
|
||||
const startResizing = (e: React.MouseEvent, side: ResizeSide) => {
|
||||
if (!isDeviceModeOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent text selection
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
resizingState.current.isResizing = true;
|
||||
resizingState.current.side = side;
|
||||
resizingState.current.startX = e.clientX;
|
||||
resizingState.current.startWidthPercent = widthPercent;
|
||||
resizingState.current.windowWidth = window.innerWidth;
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
e.preventDefault(); // Prevent any text selection on mousedown
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!resizingState.current.isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = e.clientX - resizingState.current.startX;
|
||||
const windowWidth = resizingState.current.windowWidth;
|
||||
|
||||
// Apply scaling factor to increase sensitivity
|
||||
const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR;
|
||||
|
||||
let newWidthPercent = resizingState.current.startWidthPercent;
|
||||
|
||||
if (resizingState.current.side === 'right') {
|
||||
newWidthPercent = resizingState.current.startWidthPercent + dxPercent;
|
||||
} else if (resizingState.current.side === 'left') {
|
||||
newWidthPercent = resizingState.current.startWidthPercent - dxPercent;
|
||||
}
|
||||
|
||||
// Clamp the width between 10% and 90%
|
||||
newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
|
||||
|
||||
setWidthPercent(newWidthPercent);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
resizingState.current.isResizing = false;
|
||||
resizingState.current.side = null;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Restore text selection
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
// Handle window resize to ensure widthPercent remains valid
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => {
|
||||
/*
|
||||
* Optional: Adjust widthPercent if necessary
|
||||
* For now, since widthPercent is relative, no action is needed
|
||||
*/
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A small helper component for the handle's "grip" icon
|
||||
const GripIcon = () => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: 'rgba(0,0,0,0.5)',
|
||||
fontSize: '10px',
|
||||
lineHeight: '5px',
|
||||
userSelect: 'none',
|
||||
marginLeft: '1px',
|
||||
}}
|
||||
>
|
||||
••• •••
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div ref={containerRef} className="w-full h-full flex flex-col relative">
|
||||
{isPortDropdownOpen && (
|
||||
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
|
||||
)}
|
||||
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
||||
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
||||
|
||||
<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"
|
||||
@@ -101,6 +242,7 @@ export const Preview = memo(() => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{previews.length > 1 && (
|
||||
<PortDropdown
|
||||
activePreviewIndex={activePreviewIndex}
|
||||
@@ -111,13 +253,93 @@ export const Preview = memo(() => {
|
||||
previews={previews}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Device mode toggle button */}
|
||||
<IconButton
|
||||
icon="i-ph:devices"
|
||||
onClick={toggleDeviceMode}
|
||||
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
|
||||
/>
|
||||
|
||||
{/* Fullscreen toggle button */}
|
||||
<IconButton
|
||||
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 border-t border-bolt-elements-borderColor">
|
||||
{activePreview ? (
|
||||
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} />
|
||||
) : (
|
||||
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
|
||||
<div
|
||||
style={{
|
||||
width: isDeviceModeOn ? `${widthPercent}%` : '100%',
|
||||
height: '100%', // Always full height
|
||||
overflow: 'visible',
|
||||
background: '#fff',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{activePreview ? (
|
||||
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} allowFullScreen />
|
||||
) : (
|
||||
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
||||
)}
|
||||
|
||||
{isDeviceModeOn && (
|
||||
<>
|
||||
{/* Left handle */}
|
||||
<div
|
||||
onMouseDown={(e) => startResizing(e, 'left')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '15px',
|
||||
marginLeft: '-15px',
|
||||
height: '100%',
|
||||
cursor: 'ew-resize',
|
||||
background: 'rgba(255,255,255,.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
|
||||
title="Drag to resize width"
|
||||
>
|
||||
<GripIcon />
|
||||
</div>
|
||||
|
||||
{/* Right handle */}
|
||||
<div
|
||||
onMouseDown={(e) => startResizing(e, 'right')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '15px',
|
||||
marginRight: '-15px',
|
||||
height: '100%',
|
||||
cursor: 'ew-resize',
|
||||
background: 'rgba(255,255,255,.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
|
||||
title="Drag to resize width"
|
||||
>
|
||||
<GripIcon />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
import useViewport from '~/lib/hooks';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
@@ -65,6 +66,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
const files = useStore(workbenchStore.files);
|
||||
const selectedView = useStore(workbenchStore.currentView);
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const setSelectedView = (view: WorkbenchViewType) => {
|
||||
workbenchStore.currentView.set(view);
|
||||
};
|
||||
@@ -128,18 +131,20 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
className={classNames(
|
||||
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
|
||||
{
|
||||
'w-full': isSmallViewport,
|
||||
'left-0': showWorkbench && isSmallViewport,
|
||||
'left-[var(--workbench-left)]': showWorkbench,
|
||||
'left-[100%]': !showWorkbench,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 px-6">
|
||||
<div className="absolute inset-0 px-2 lg:px-6">
|
||||
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
|
||||
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
||||
<div className="ml-auto" />
|
||||
{selectedView === 'code' && (
|
||||
<>
|
||||
<div className="flex overflow-y-auto">
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
@@ -165,29 +170,37 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
const repoName = prompt("Please enter a name for your new GitHub repository:", "bolt-generated-project");
|
||||
const repoName = prompt(
|
||||
'Please enter a name for your new GitHub repository:',
|
||||
'bolt-generated-project',
|
||||
);
|
||||
|
||||
if (!repoName) {
|
||||
alert("Repository name is required. Push to GitHub cancelled.");
|
||||
alert('Repository name is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
const githubUsername = prompt("Please enter your GitHub username:");
|
||||
|
||||
const githubUsername = prompt('Please enter your GitHub username:');
|
||||
|
||||
if (!githubUsername) {
|
||||
alert("GitHub username is required. Push to GitHub cancelled.");
|
||||
alert('GitHub username is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
const githubToken = prompt("Please enter your GitHub personal access token:");
|
||||
|
||||
const githubToken = prompt('Please enter your GitHub personal access token:');
|
||||
|
||||
if (!githubToken) {
|
||||
alert("GitHub token is required. Push to GitHub cancelled.");
|
||||
alert('GitHub token is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
|
||||
|
||||
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:github-logo" />
|
||||
Push to GitHub
|
||||
</PanelHeaderButton>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon="i-ph:x-circle"
|
||||
|
||||
@@ -16,71 +16,74 @@ export interface TerminalProps {
|
||||
className?: string;
|
||||
theme: Theme;
|
||||
readonly?: boolean;
|
||||
id: string;
|
||||
onTerminalReady?: (terminal: XTerm) => void;
|
||||
onTerminalResize?: (cols: number, rows: number) => void;
|
||||
}
|
||||
|
||||
export const Terminal = memo(
|
||||
forwardRef<TerminalRef, TerminalProps>(({ className, theme, readonly, onTerminalReady, onTerminalResize }, ref) => {
|
||||
const terminalElementRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm>();
|
||||
forwardRef<TerminalRef, TerminalProps>(
|
||||
({ className, theme, readonly, id, onTerminalReady, onTerminalResize }, ref) => {
|
||||
const terminalElementRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm>();
|
||||
|
||||
useEffect(() => {
|
||||
const element = terminalElementRef.current!;
|
||||
useEffect(() => {
|
||||
const element = terminalElementRef.current!;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
disableStdin: readonly,
|
||||
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
|
||||
fontSize: 12,
|
||||
fontFamily: 'Menlo, courier-new, courier, monospace',
|
||||
});
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
disableStdin: readonly,
|
||||
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
|
||||
fontSize: 12,
|
||||
fontFamily: 'Menlo, courier-new, courier, monospace',
|
||||
});
|
||||
|
||||
terminalRef.current = terminal;
|
||||
terminalRef.current = terminal;
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(element);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(element);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
onTerminalResize?.(terminal.cols, terminal.rows);
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
onTerminalResize?.(terminal.cols, terminal.rows);
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
logger.info('Attach terminal');
|
||||
logger.debug(`Attach [${id}]`);
|
||||
|
||||
onTerminalReady?.(terminal);
|
||||
onTerminalReady?.(terminal);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
terminal.dispose();
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
terminal.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current!;
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current!;
|
||||
|
||||
// we render a transparent cursor in case the terminal is readonly
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
// we render a transparent cursor in case the terminal is readonly
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
|
||||
terminal.options.disableStdin = readonly;
|
||||
}, [theme, readonly]);
|
||||
terminal.options.disableStdin = readonly;
|
||||
}, [theme, readonly]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
reloadStyles: () => {
|
||||
const terminal = terminalRef.current!;
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
reloadStyles: () => {
|
||||
const terminal = terminalRef.current!;
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className={className} ref={terminalElementRef} />;
|
||||
}),
|
||||
return <div className={className} ref={terminalElementRef} />;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
186
app/components/workbench/terminal/TerminalTabs.tsx
Normal file
186
app/components/workbench/terminal/TerminalTabs.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import { Panel, type ImperativePanelHandle } from 'react-resizable-panels';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { shortcutEventEmitter } from '~/lib/hooks';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Terminal, type TerminalRef } from './Terminal';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('Terminal');
|
||||
|
||||
const MAX_TERMINALS = 3;
|
||||
export const DEFAULT_TERMINAL_SIZE = 25;
|
||||
|
||||
export const TerminalTabs = memo(() => {
|
||||
const showTerminal = useStore(workbenchStore.showTerminal);
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
|
||||
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const terminalToggledByShortcut = useRef(false);
|
||||
|
||||
const [activeTerminal, setActiveTerminal] = useState(0);
|
||||
const [terminalCount, setTerminalCount] = useState(1);
|
||||
|
||||
const addTerminal = () => {
|
||||
if (terminalCount < MAX_TERMINALS) {
|
||||
setTerminalCount(terminalCount + 1);
|
||||
setActiveTerminal(terminalCount);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { current: terminal } = terminalPanelRef;
|
||||
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCollapsed = terminal.isCollapsed();
|
||||
|
||||
if (!showTerminal && !isCollapsed) {
|
||||
terminal.collapse();
|
||||
} else if (showTerminal && isCollapsed) {
|
||||
terminal.resize(DEFAULT_TERMINAL_SIZE);
|
||||
}
|
||||
|
||||
terminalToggledByShortcut.current = false;
|
||||
}, [showTerminal]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
|
||||
terminalToggledByShortcut.current = true;
|
||||
});
|
||||
|
||||
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
|
||||
for (const ref of Object.values(terminalRefs.current)) {
|
||||
ref?.reloadStyles();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromEventEmitter();
|
||||
unsubscribeFromThemeStore();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
ref={terminalPanelRef}
|
||||
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
|
||||
minSize={10}
|
||||
collapsible
|
||||
onExpand={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(true);
|
||||
}
|
||||
}}
|
||||
onCollapse={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-full">
|
||||
<div className="bg-bolt-elements-terminals-background h-full flex flex-col">
|
||||
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
|
||||
{Array.from({ length: terminalCount + 1 }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{index == 0 ? (
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary':
|
||||
isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Bolt Terminal
|
||||
</button>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Terminal {terminalCount > 1 && index}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
|
||||
<IconButton
|
||||
className="ml-auto"
|
||||
icon="i-ph:caret-down"
|
||||
title="Close"
|
||||
size="md"
|
||||
onClick={() => workbenchStore.toggleTerminal(false)}
|
||||
/>
|
||||
</div>
|
||||
{Array.from({ length: terminalCount + 1 }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
|
||||
logger.debug(`Starting bolt terminal [${index}]`);
|
||||
|
||||
if (index == 0) {
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
id={`terminal_${index}`}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
id={`terminal_${index}`}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export default async function handleRequest(
|
||||
.read()
|
||||
.then(({ done, value }) => {
|
||||
if (done) {
|
||||
controller.enqueue(new Uint8Array(new TextEncoder().encode(`</div></body></html>`)));
|
||||
controller.enqueue(new Uint8Array(new TextEncoder().encode('</div></body></html>')));
|
||||
controller.close();
|
||||
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import { env } from 'node:process';
|
||||
|
||||
export function getAPIKey(cloudflareEnv: Env, provider: string, userApiKeys?: Record<string, string>) {
|
||||
@@ -23,34 +25,47 @@ export function getAPIKey(cloudflareEnv: Env, provider: string, userApiKeys?: Re
|
||||
return env.GOOGLE_GENERATIVE_AI_API_KEY || cloudflareEnv.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
case 'Groq':
|
||||
return env.GROQ_API_KEY || cloudflareEnv.GROQ_API_KEY;
|
||||
case 'HuggingFace':
|
||||
return env.HuggingFace_API_KEY || cloudflareEnv.HuggingFace_API_KEY;
|
||||
case 'OpenRouter':
|
||||
return env.OPEN_ROUTER_API_KEY || cloudflareEnv.OPEN_ROUTER_API_KEY;
|
||||
case 'Deepseek':
|
||||
return env.DEEPSEEK_API_KEY || cloudflareEnv.DEEPSEEK_API_KEY
|
||||
return env.DEEPSEEK_API_KEY || cloudflareEnv.DEEPSEEK_API_KEY;
|
||||
case 'Mistral':
|
||||
return env.MISTRAL_API_KEY || cloudflareEnv.MISTRAL_API_KEY;
|
||||
case "OpenAILike":
|
||||
return env.MISTRAL_API_KEY || cloudflareEnv.MISTRAL_API_KEY;
|
||||
case 'OpenAILike':
|
||||
return env.OPENAI_LIKE_API_KEY || cloudflareEnv.OPENAI_LIKE_API_KEY;
|
||||
case "xAI":
|
||||
case 'Together':
|
||||
return env.TOGETHER_API_KEY || cloudflareEnv.TOGETHER_API_KEY;
|
||||
case 'xAI':
|
||||
return env.XAI_API_KEY || cloudflareEnv.XAI_API_KEY;
|
||||
case 'Cohere':
|
||||
return env.COHERE_API_KEY;
|
||||
case 'AzureOpenAI':
|
||||
return env.AZURE_OPENAI_API_KEY;
|
||||
default:
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getBaseURL(cloudflareEnv: Env, provider: string) {
|
||||
switch (provider) {
|
||||
case 'Together':
|
||||
return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL || 'https://api.together.xyz/v1';
|
||||
case 'OpenAILike':
|
||||
return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL;
|
||||
case 'LMStudio':
|
||||
return env.LMSTUDIO_API_BASE_URL || cloudflareEnv.LMSTUDIO_API_BASE_URL || "http://localhost:1234";
|
||||
case 'Ollama':
|
||||
let baseUrl = env.OLLAMA_API_BASE_URL || cloudflareEnv.OLLAMA_API_BASE_URL || "http://localhost:11434";
|
||||
if (env.RUNNING_IN_DOCKER === 'true') {
|
||||
baseUrl = baseUrl.replace("localhost", "host.docker.internal");
|
||||
}
|
||||
return baseUrl;
|
||||
return env.LMSTUDIO_API_BASE_URL || cloudflareEnv.LMSTUDIO_API_BASE_URL || 'http://localhost:1234';
|
||||
case 'Ollama': {
|
||||
let baseUrl = env.OLLAMA_API_BASE_URL || cloudflareEnv.OLLAMA_API_BASE_URL || 'http://localhost:11434';
|
||||
|
||||
if (env.RUNNING_IN_DOCKER === 'true') {
|
||||
baseUrl = baseUrl.replace('localhost', 'host.docker.internal');
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import { getAPIKey, getBaseURL } from '~/lib/.server/llm/api-key';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { ollama } from 'ollama-ai-provider';
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { mistral } from '@ai-sdk/mistral';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { createMistral } from '@ai-sdk/mistral';
|
||||
import { createCohere } from '@ai-sdk/cohere';
|
||||
import type { LanguageModelV1 } from 'ai';
|
||||
|
||||
export function getAnthropicModel(apiKey: string, model: string) {
|
||||
export const DEFAULT_NUM_CTX = process.env.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768;
|
||||
|
||||
type OptionalApiKey = string | undefined;
|
||||
|
||||
export function getAnthropicModel(apiKey: OptionalApiKey, model: string) {
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return anthropic(model);
|
||||
}
|
||||
export function getOpenAILikeModel(baseURL:string,apiKey: string, model: string) {
|
||||
export function getOpenAILikeModel(baseURL: string, apiKey: OptionalApiKey, model: string) {
|
||||
const openai = createOpenAI({
|
||||
baseURL,
|
||||
apiKey,
|
||||
@@ -24,7 +31,16 @@ export function getOpenAILikeModel(baseURL:string,apiKey: string, model: string)
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
export function getOpenAIModel(apiKey: string, model: string) {
|
||||
|
||||
export function getCohereAIModel(apiKey: OptionalApiKey, model: string) {
|
||||
const cohere = createCohere({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return cohere(model);
|
||||
}
|
||||
|
||||
export function getOpenAIModel(apiKey: OptionalApiKey, model: string) {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
});
|
||||
@@ -32,23 +48,23 @@ export function getOpenAIModel(apiKey: string, model: string) {
|
||||
return openai(model);
|
||||
}
|
||||
|
||||
export function getMistralModel(apiKey: string, model: string) {
|
||||
export function getMistralModel(apiKey: OptionalApiKey, model: string) {
|
||||
const mistral = createMistral({
|
||||
apiKey
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return mistral(model);
|
||||
}
|
||||
|
||||
export function getGoogleModel(apiKey: string, model: string) {
|
||||
const google = createGoogleGenerativeAI(
|
||||
export function getGoogleModel(apiKey: OptionalApiKey, model: string) {
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
);
|
||||
});
|
||||
|
||||
return google(model);
|
||||
}
|
||||
|
||||
export function getGroqModel(apiKey: string, model: string) {
|
||||
export function getGroqModel(apiKey: OptionalApiKey, model: string) {
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://api.groq.com/openai/v1',
|
||||
apiKey,
|
||||
@@ -57,16 +73,26 @@ export function getGroqModel(apiKey: string, model: string) {
|
||||
return openai(model);
|
||||
}
|
||||
|
||||
export function getOllamaModel(baseURL: string, model: string) {
|
||||
let Ollama = ollama(model, {
|
||||
numCtx: 32768,
|
||||
export function getHuggingFaceModel(apiKey: OptionalApiKey, model: string) {
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://api-inference.huggingface.co/v1/',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
Ollama.config.baseURL = `${baseURL}/api`;
|
||||
return Ollama;
|
||||
return openai(model);
|
||||
}
|
||||
|
||||
export function getDeepseekModel(apiKey: string, model: string){
|
||||
export function getOllamaModel(baseURL: string, model: string) {
|
||||
const ollamaInstance = ollama(model, {
|
||||
numCtx: DEFAULT_NUM_CTX,
|
||||
}) as LanguageModelV1 & { config: any };
|
||||
|
||||
ollamaInstance.config.baseURL = `${baseURL}/api`;
|
||||
|
||||
return ollamaInstance;
|
||||
}
|
||||
|
||||
export function getDeepseekModel(apiKey: OptionalApiKey, model: string) {
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://api.deepseek.com/beta',
|
||||
apiKey,
|
||||
@@ -75,9 +101,9 @@ export function getDeepseekModel(apiKey: string, model: string){
|
||||
return openai(model);
|
||||
}
|
||||
|
||||
export function getOpenRouterModel(apiKey: string, model: string) {
|
||||
export function getOpenRouterModel(apiKey: OptionalApiKey, model: string) {
|
||||
const openRouter = createOpenRouter({
|
||||
apiKey
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openRouter.chat(model);
|
||||
@@ -86,13 +112,13 @@ export function getOpenRouterModel(apiKey: string, model: string) {
|
||||
export function getLMStudioModel(baseURL: string, model: string) {
|
||||
const lmstudio = createOpenAI({
|
||||
baseUrl: `${baseURL}/v1`,
|
||||
apiKey: "",
|
||||
apiKey: '',
|
||||
});
|
||||
|
||||
return lmstudio(model);
|
||||
}
|
||||
|
||||
export function getXAIModel(apiKey: string, model: string) {
|
||||
export function getXAIModel(apiKey: OptionalApiKey, model: string) {
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://api.x.ai/v1',
|
||||
apiKey,
|
||||
@@ -100,8 +126,14 @@ export function getXAIModel(apiKey: string, model: string) {
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
|
||||
export function getModel(provider: string, model: string, env: Env, apiKeys?: Record<string, string>) {
|
||||
const apiKey = getAPIKey(env, provider, apiKeys);
|
||||
/*
|
||||
* let apiKey; // Declare first
|
||||
* let baseURL;
|
||||
*/
|
||||
|
||||
const apiKey = getAPIKey(env, provider, apiKeys); // Then assign
|
||||
const baseURL = getBaseURL(env, provider);
|
||||
|
||||
switch (provider) {
|
||||
@@ -111,20 +143,26 @@ export function getModel(provider: string, model: string, env: Env, apiKeys?: Re
|
||||
return getOpenAIModel(apiKey, model);
|
||||
case 'Groq':
|
||||
return getGroqModel(apiKey, model);
|
||||
case 'HuggingFace':
|
||||
return getHuggingFaceModel(apiKey, model);
|
||||
case 'OpenRouter':
|
||||
return getOpenRouterModel(apiKey, model);
|
||||
case 'Google':
|
||||
return getGoogleModel(apiKey, model);
|
||||
case 'OpenAILike':
|
||||
return getOpenAILikeModel(baseURL,apiKey, model);
|
||||
return getOpenAILikeModel(baseURL, apiKey, model);
|
||||
case 'Together':
|
||||
return getOpenAILikeModel(baseURL, apiKey, model);
|
||||
case 'Deepseek':
|
||||
return getDeepseekModel(apiKey, model);
|
||||
case 'Mistral':
|
||||
return getMistralModel(apiKey, model);
|
||||
return getMistralModel(apiKey, model);
|
||||
case 'LMStudio':
|
||||
return getLMStudioModel(baseURL, model);
|
||||
case 'xAI':
|
||||
return getXAIModel(apiKey, model);
|
||||
case 'Cohere':
|
||||
return getCohereAIModel(apiKey, model);
|
||||
default:
|
||||
return getOllamaModel(baseURL, model);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
|
||||
Example:
|
||||
|
||||
<${MODIFICATIONS_TAG_NAME}>
|
||||
<diff path="/home/project/src/main.js">
|
||||
<diff path="${WORK_DIR}/src/main.js">
|
||||
@@ -2,7 +2,10 @@
|
||||
return a + b;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
|
||||
+
|
||||
+console.log('The End');
|
||||
</diff>
|
||||
<file path="/home/project/package.json">
|
||||
<file path="${WORK_DIR}/package.json">
|
||||
// full file content here
|
||||
</file>
|
||||
</${MODIFICATIONS_TAG_NAME}>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
import { streamText as _streamText, convertToCoreMessages } from 'ai';
|
||||
import { convertToCoreMessages, streamText as _streamText } from 'ai';
|
||||
import { getModel } from '~/lib/.server/llm/model';
|
||||
import { MAX_TOKENS } from './constants';
|
||||
import { getSystemPrompt } from './prompts';
|
||||
import { MODEL_LIST, DEFAULT_MODEL, DEFAULT_PROVIDER, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER, getModelList, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||
|
||||
interface ToolResult<Name extends string, Args, Result> {
|
||||
toolCallId: string;
|
||||
@@ -25,32 +23,50 @@ export type Messages = Message[];
|
||||
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
|
||||
|
||||
function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
|
||||
// Extract model
|
||||
const modelMatch = message.content.match(MODEL_REGEX);
|
||||
const textContent = Array.isArray(message.content)
|
||||
? message.content.find((item) => item.type === 'text')?.text || ''
|
||||
: message.content;
|
||||
|
||||
const modelMatch = textContent.match(MODEL_REGEX);
|
||||
const providerMatch = textContent.match(PROVIDER_REGEX);
|
||||
|
||||
/*
|
||||
* Extract model
|
||||
* const modelMatch = message.content.match(MODEL_REGEX);
|
||||
*/
|
||||
const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
|
||||
|
||||
// Extract provider
|
||||
const providerMatch = message.content.match(PROVIDER_REGEX);
|
||||
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER;
|
||||
/*
|
||||
* Extract provider
|
||||
* const providerMatch = message.content.match(PROVIDER_REGEX);
|
||||
*/
|
||||
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER.name;
|
||||
|
||||
// Remove model and provider lines from content
|
||||
const cleanedContent = message.content
|
||||
.replace(MODEL_REGEX, '')
|
||||
.replace(PROVIDER_REGEX, '')
|
||||
.trim();
|
||||
const cleanedContent = Array.isArray(message.content)
|
||||
? message.content.map((item) => {
|
||||
if (item.type === 'text') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: item.text?.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, ''),
|
||||
};
|
||||
}
|
||||
|
||||
return item; // Preserve image_url and other types as is
|
||||
})
|
||||
: textContent.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||
|
||||
return { model, provider, content: cleanedContent };
|
||||
}
|
||||
|
||||
export function streamText(
|
||||
messages: Messages,
|
||||
env: Env,
|
||||
export async function streamText(
|
||||
messages: Messages,
|
||||
env: Env,
|
||||
options?: StreamingOptions,
|
||||
apiKeys?: Record<string, string>
|
||||
apiKeys?: Record<string, string>,
|
||||
) {
|
||||
let currentModel = DEFAULT_MODEL;
|
||||
let currentProvider = DEFAULT_PROVIDER;
|
||||
|
||||
let currentProvider = DEFAULT_PROVIDER.name;
|
||||
const MODEL_LIST = await getModelList(apiKeys || {});
|
||||
const processedMessages = messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
const { model, provider, content } = extractPropertiesFromMessage(message);
|
||||
@@ -64,14 +80,18 @@ export function streamText(
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
return message; // No changes for non-user messages
|
||||
return message;
|
||||
});
|
||||
|
||||
const modelDetails = MODEL_LIST.find((m) => m.name === currentModel);
|
||||
|
||||
const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
|
||||
|
||||
return _streamText({
|
||||
model: getModel(currentProvider, currentModel, env, apiKeys),
|
||||
model: getModel(currentProvider, currentModel, env, apiKeys) as any,
|
||||
system: getSystemPrompt(),
|
||||
maxTokens: MAX_TOKENS,
|
||||
messages: convertToCoreMessages(processedMessages),
|
||||
maxTokens: dynamicMaxTokens,
|
||||
messages: convertToCoreMessages(processedMessages as any),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,3 +2,5 @@ export * from './useMessageParser';
|
||||
export * from './usePromptEnhancer';
|
||||
export * from './useShortcuts';
|
||||
export * from './useSnapScroll';
|
||||
export * from './useEditChatDescription';
|
||||
export { default } from './useViewport';
|
||||
|
||||
163
app/lib/hooks/useEditChatDescription.ts
Normal file
163
app/lib/hooks/useEditChatDescription.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
chatId as chatIdStore,
|
||||
description as descriptionStore,
|
||||
db,
|
||||
updateChatDescription,
|
||||
getMessages,
|
||||
} from '~/lib/persistence';
|
||||
|
||||
interface EditChatDescriptionOptions {
|
||||
initialDescription?: string;
|
||||
customChatId?: string;
|
||||
syncWithGlobalStore?: boolean;
|
||||
}
|
||||
|
||||
type EditChatDescriptionHook = {
|
||||
editing: boolean;
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleBlur: () => Promise<void>;
|
||||
handleSubmit: (event: React.FormEvent) => Promise<void>;
|
||||
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
|
||||
currentDescription: string;
|
||||
toggleEditMode: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage the state and behavior for editing chat descriptions.
|
||||
*
|
||||
* Offers functions to:
|
||||
* - Switch between edit and view modes.
|
||||
* - Manage input changes, blur, and form submission events.
|
||||
* - Save updates to IndexedDB and optionally to the global application state.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.initialDescription - The current chat description.
|
||||
* @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
|
||||
* @param {boolean} options.syncWithGlobalStore - Flag to indicate global description store synchronization.
|
||||
* @returns {EditChatDescriptionHook} Methods and state for managing description edits.
|
||||
*/
|
||||
export function useEditChatDescription({
|
||||
initialDescription = descriptionStore.get()!,
|
||||
customChatId,
|
||||
syncWithGlobalStore,
|
||||
}: EditChatDescriptionOptions): EditChatDescriptionHook {
|
||||
const chatIdFromStore = useStore(chatIdStore);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [currentDescription, setCurrentDescription] = useState(initialDescription);
|
||||
|
||||
const [chatId, setChatId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
setChatId(customChatId || chatIdFromStore);
|
||||
}, [customChatId, chatIdFromStore]);
|
||||
useEffect(() => {
|
||||
setCurrentDescription(initialDescription);
|
||||
}, [initialDescription]);
|
||||
|
||||
const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentDescription(e.target.value);
|
||||
}, []);
|
||||
|
||||
const fetchLatestDescription = useCallback(async () => {
|
||||
if (!db || !chatId) {
|
||||
return initialDescription;
|
||||
}
|
||||
|
||||
try {
|
||||
const chat = await getMessages(db, chatId);
|
||||
return chat?.description || initialDescription;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest description:', error);
|
||||
return initialDescription;
|
||||
}
|
||||
}, [db, chatId, initialDescription]);
|
||||
|
||||
const handleBlur = useCallback(async () => {
|
||||
const latestDescription = await fetchLatestDescription();
|
||||
setCurrentDescription(latestDescription);
|
||||
toggleEditMode();
|
||||
}, [fetchLatestDescription, toggleEditMode]);
|
||||
|
||||
const isValidDescription = useCallback((desc: string): boolean => {
|
||||
const trimmedDesc = desc.trim();
|
||||
|
||||
if (trimmedDesc === initialDescription) {
|
||||
toggleEditMode();
|
||||
return false; // No change, skip validation
|
||||
}
|
||||
|
||||
const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
|
||||
const characterValid = /^[a-zA-Z0-9\s]+$/.test(trimmedDesc);
|
||||
|
||||
if (!lengthValid) {
|
||||
toast.error('Description must be between 1 and 100 characters.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!characterValid) {
|
||||
toast.error('Description can only contain alphanumeric characters and spaces.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isValidDescription(currentDescription)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!db) {
|
||||
toast.error('Chat persistence is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chatId) {
|
||||
toast.error('Chat Id is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateChatDescription(db, chatId, currentDescription);
|
||||
|
||||
if (syncWithGlobalStore) {
|
||||
descriptionStore.set(currentDescription);
|
||||
}
|
||||
|
||||
toast.success('Chat description updated successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update chat description: ' + (error as Error).message);
|
||||
}
|
||||
|
||||
toggleEditMode();
|
||||
},
|
||||
[currentDescription, db, chatId, initialDescription, customChatId],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
await handleBlur();
|
||||
}
|
||||
},
|
||||
[handleBlur],
|
||||
);
|
||||
|
||||
return {
|
||||
editing,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
handleKeyDown,
|
||||
currentDescription,
|
||||
toggleEditMode,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('usePromptEnhancement');
|
||||
@@ -13,54 +14,54 @@ export function usePromptEnhancer() {
|
||||
};
|
||||
|
||||
const enhancePrompt = async (
|
||||
input: string,
|
||||
input: string,
|
||||
setInput: (value: string) => void,
|
||||
model: string,
|
||||
provider: string,
|
||||
apiKeys?: Record<string, string>
|
||||
provider: ProviderInfo,
|
||||
apiKeys?: Record<string, string>,
|
||||
) => {
|
||||
setEnhancingPrompt(true);
|
||||
setPromptEnhanced(false);
|
||||
|
||||
|
||||
const requestBody: any = {
|
||||
message: input,
|
||||
model,
|
||||
provider,
|
||||
};
|
||||
|
||||
|
||||
if (apiKeys) {
|
||||
requestBody.apiKeys = apiKeys;
|
||||
}
|
||||
|
||||
|
||||
const response = await fetch('/api/enhancer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
|
||||
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) {
|
||||
@@ -70,10 +71,10 @@ export function usePromptEnhancer() {
|
||||
if (_error) {
|
||||
logger.error(_error);
|
||||
}
|
||||
|
||||
|
||||
setEnhancingPrompt(false);
|
||||
setPromptEnhanced(true);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setInput(_input);
|
||||
});
|
||||
|
||||
52
app/lib/hooks/useSearchFilter.ts
Normal file
52
app/lib/hooks/useSearchFilter.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import type { ChatHistoryItem } from '~/lib/persistence';
|
||||
|
||||
interface UseSearchFilterOptions {
|
||||
items: ChatHistoryItem[];
|
||||
searchFields?: (keyof ChatHistoryItem)[];
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export function useSearchFilter({
|
||||
items = [],
|
||||
searchFields = ['description'],
|
||||
debounceMs = 300,
|
||||
}: UseSearchFilterOptions) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const debouncedSetSearch = useCallback(debounce(setSearchQuery, debounceMs), []);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSetSearch(event.target.value);
|
||||
},
|
||||
[debouncedSetSearch],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
return items.filter((item) =>
|
||||
searchFields.some((field) => {
|
||||
const value = item[field];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}, [items, searchQuery, searchFields]);
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
filteredItems,
|
||||
handleSearchChange,
|
||||
};
|
||||
}
|
||||
18
app/lib/hooks/useViewport.ts
Normal file
18
app/lib/hooks/useViewport.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const useViewport = (threshold = 1024) => {
|
||||
const [isSmallViewport, setIsSmallViewport] = useState(window.innerWidth < threshold);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsSmallViewport(window.innerWidth < threshold);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [threshold]);
|
||||
|
||||
return isSmallViewport;
|
||||
};
|
||||
|
||||
export default useViewport;
|
||||
@@ -1,6 +1,68 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { description } from './useChatHistory';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useEditChatDescription } from '~/lib/hooks';
|
||||
import { description as descriptionStore } from '~/lib/persistence';
|
||||
|
||||
export function ChatDescription() {
|
||||
return useStore(description);
|
||||
const initialDescription = useStore(descriptionStore)!;
|
||||
|
||||
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
|
||||
useEditChatDescription({
|
||||
initialDescription,
|
||||
syncWithGlobalStore: true,
|
||||
});
|
||||
|
||||
if (!initialDescription) {
|
||||
// doing this to prevent showing edit button until chat description is set
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
{editing ? (
|
||||
<form onSubmit={handleSubmit} className="flex items-center justify-center">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2 w-fit"
|
||||
autoFocus
|
||||
value={currentDescription}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: `${Math.max(currentDescription.length * 8, 100)}px` }}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<WithTooltip tooltip="Save title">
|
||||
<div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent">
|
||||
<button
|
||||
type="submit"
|
||||
className="i-ph:check-bold scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||
onMouseDown={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</WithTooltip>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{currentDescription}
|
||||
<TooltipProvider>
|
||||
<WithTooltip tooltip="Rename chat">
|
||||
<div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent ml-2">
|
||||
<button
|
||||
type="button"
|
||||
className="i-ph:pencil-fill scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
toggleEditMode();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WithTooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ const logger = createScopedLogger('ChatHistory');
|
||||
|
||||
// this is used at the top level and never rejects
|
||||
export async function openDatabase(): Promise<IDBDatabase | undefined> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
console.error('indexedDB is not available in this environment.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open('boltHistory', 1);
|
||||
|
||||
@@ -47,17 +52,23 @@ export async function setMessages(
|
||||
messages: Message[],
|
||||
urlId?: string,
|
||||
description?: string,
|
||||
timestamp?: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('chats', 'readwrite');
|
||||
const store = transaction.objectStore('chats');
|
||||
|
||||
if (timestamp && isNaN(Date.parse(timestamp))) {
|
||||
reject(new Error('Invalid timestamp'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = store.put({
|
||||
id,
|
||||
messages,
|
||||
urlId,
|
||||
description,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: timestamp ?? new Date().toISOString(),
|
||||
});
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
@@ -158,3 +169,66 @@ async function getUrlIds(db: IDBDatabase): Promise<string[]> {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function forkChat(db: IDBDatabase, chatId: string, messageId: string): Promise<string> {
|
||||
const chat = await getMessages(db, chatId);
|
||||
|
||||
if (!chat) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
|
||||
// Find the index of the message to fork at
|
||||
const messageIndex = chat.messages.findIndex((msg) => msg.id === messageId);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
|
||||
// Get messages up to and including the selected message
|
||||
const messages = chat.messages.slice(0, messageIndex + 1);
|
||||
|
||||
return createChatFromMessages(db, chat.description ? `${chat.description} (fork)` : 'Forked chat', messages);
|
||||
}
|
||||
|
||||
export async function duplicateChat(db: IDBDatabase, id: string): Promise<string> {
|
||||
const chat = await getMessages(db, id);
|
||||
|
||||
if (!chat) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
|
||||
return createChatFromMessages(db, `${chat.description || 'Chat'} (copy)`, chat.messages);
|
||||
}
|
||||
|
||||
export async function createChatFromMessages(
|
||||
db: IDBDatabase,
|
||||
description: string,
|
||||
messages: Message[],
|
||||
): Promise<string> {
|
||||
const newId = await getNextId(db);
|
||||
const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat
|
||||
|
||||
await setMessages(
|
||||
db,
|
||||
newId,
|
||||
messages,
|
||||
newUrlId, // Use the new urlId
|
||||
description,
|
||||
);
|
||||
|
||||
return newUrlId; // Return the urlId instead of id for navigation
|
||||
}
|
||||
|
||||
export async function updateChatDescription(db: IDBDatabase, id: string, description: string): Promise<void> {
|
||||
const chat = await getMessages(db, id);
|
||||
|
||||
if (!chat) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
throw new Error('Description cannot be empty');
|
||||
}
|
||||
|
||||
await setMessages(db, id, chat.messages, chat.urlId, description, chat.timestamp);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useLoaderData, useNavigate } from '@remix-run/react';
|
||||
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { getMessages, getNextId, getUrlId, openDatabase, setMessages } from './db';
|
||||
import {
|
||||
getMessages,
|
||||
getNextId,
|
||||
getUrlId,
|
||||
openDatabase,
|
||||
setMessages,
|
||||
duplicateChat,
|
||||
createChatFromMessages,
|
||||
} from './db';
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
id: string;
|
||||
@@ -24,6 +32,7 @@ export const description = atom<string | undefined>(undefined);
|
||||
export function useChatHistory() {
|
||||
const navigate = useNavigate();
|
||||
const { id: mixedId } = useLoaderData<{ id?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
|
||||
const [ready, setReady] = useState<boolean>(false);
|
||||
@@ -34,7 +43,7 @@ export function useChatHistory() {
|
||||
setReady(true);
|
||||
|
||||
if (persistenceEnabled) {
|
||||
toast.error(`Chat persistence is unavailable`);
|
||||
toast.error('Chat persistence is unavailable');
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -44,12 +53,17 @@ export function useChatHistory() {
|
||||
getMessages(db, mixedId)
|
||||
.then((storedMessages) => {
|
||||
if (storedMessages && storedMessages.messages.length > 0) {
|
||||
setInitialMessages(storedMessages.messages);
|
||||
const rewindId = searchParams.get('rewindTo');
|
||||
const filteredMessages = rewindId
|
||||
? storedMessages.messages.slice(0, storedMessages.messages.findIndex((m) => m.id === rewindId) + 1)
|
||||
: storedMessages.messages;
|
||||
|
||||
setInitialMessages(filteredMessages);
|
||||
setUrlId(storedMessages.urlId);
|
||||
description.set(storedMessages.description);
|
||||
chatId.set(storedMessages.id);
|
||||
} else {
|
||||
navigate(`/`, { replace: true });
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
setReady(true);
|
||||
@@ -93,6 +107,59 @@ export function useChatHistory() {
|
||||
|
||||
await setMessages(db, chatId.get() as string, messages, urlId, description.get());
|
||||
},
|
||||
duplicateCurrentChat: async (listItemId: string) => {
|
||||
if (!db || (!mixedId && !listItemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newId = await duplicateChat(db, mixedId || listItemId);
|
||||
navigate(`/chat/${newId}`);
|
||||
toast.success('Chat duplicated successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to duplicate chat');
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
importChat: async (description: string, messages: Message[]) => {
|
||||
if (!db) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newId = await createChatFromMessages(db, description, messages);
|
||||
window.location.href = `/chat/${newId}`;
|
||||
toast.success('Chat imported successfully');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error('Failed to import chat: ' + error.message);
|
||||
} else {
|
||||
toast.error('Failed to import chat');
|
||||
}
|
||||
}
|
||||
},
|
||||
exportChat: async (id = urlId) => {
|
||||
if (!db || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = await getMessages(db, id);
|
||||
const chatData = {
|
||||
messages: chat.messages,
|
||||
description: chat.description,
|
||||
exportDate: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(chatData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chat-${new Date().toISOString()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -37,6 +38,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -96,6 +98,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -104,6 +107,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -112,6 +116,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -120,6 +125,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -128,6 +134,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": "bundled",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -136,6 +143,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": "bundled",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -144,6 +152,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -152,6 +161,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -160,6 +170,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -168,6 +179,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -176,6 +188,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -184,6 +197,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -192,6 +206,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -200,6 +215,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -208,6 +224,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -216,5 +233,6 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { WebContainer, type WebContainerProcess } from '@webcontainer/api';
|
||||
import { WebContainer } from '@webcontainer/api';
|
||||
import { atom, map, type MapStore } from 'nanostores';
|
||||
import * as nodePath from 'node:path';
|
||||
import type { BoltAction } from '~/types/actions';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import type { ActionCallbackData } from './message-parser';
|
||||
import type { ITerminal } from '~/types/terminal';
|
||||
import type { BoltShell } from '~/utils/shell';
|
||||
|
||||
const logger = createScopedLogger('ActionRunner');
|
||||
@@ -45,7 +44,6 @@ export class ActionRunner {
|
||||
constructor(webcontainerPromise: Promise<WebContainer>, getShellTerminal: () => BoltShell) {
|
||||
this.#webcontainer = webcontainerPromise;
|
||||
this.#shellTerminal = getShellTerminal;
|
||||
|
||||
}
|
||||
|
||||
addAction(data: ActionCallbackData) {
|
||||
@@ -86,10 +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 });
|
||||
@@ -101,6 +100,10 @@ export class ActionRunner {
|
||||
.catch((error) => {
|
||||
console.error('Action failed:', error);
|
||||
});
|
||||
|
||||
await this.#currentExecutionPromise;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async #executeAction(actionId: string, isStreaming: boolean = false) {
|
||||
@@ -119,12 +122,25 @@ export class ActionRunner {
|
||||
break;
|
||||
}
|
||||
case 'start': {
|
||||
await this.#runStartAction(action)
|
||||
break;
|
||||
// making the start app non blocking
|
||||
|
||||
this.#runStartAction(action)
|
||||
.then(() => this.#updateAction(actionId, { status: 'complete' }))
|
||||
.catch(() => this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }));
|
||||
|
||||
/*
|
||||
* adding a delay to avoid any race condition between 2 start actions
|
||||
* i am up for a better approach
|
||||
*/
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.#updateAction(actionId, { status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete' });
|
||||
this.#updateAction(actionId, {
|
||||
status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete',
|
||||
});
|
||||
} catch (error) {
|
||||
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
|
||||
logger.error(`[${action.type}]:Action failed\n\n`, error);
|
||||
@@ -138,16 +154,19 @@ export class ActionRunner {
|
||||
if (action.type !== 'shell') {
|
||||
unreachable('Expected shell action');
|
||||
}
|
||||
const shell = this.#shellTerminal()
|
||||
await shell.ready()
|
||||
|
||||
const shell = this.#shellTerminal();
|
||||
await shell.ready();
|
||||
|
||||
if (!shell || !shell.terminal || !shell.process) {
|
||||
unreachable('Shell terminal not found');
|
||||
}
|
||||
const resp = await shell.executeCommand(this.runnerId.get(), action.content)
|
||||
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`)
|
||||
if (resp?.exitCode != 0) {
|
||||
throw new Error("Failed To Execute Shell Command");
|
||||
|
||||
const resp = await shell.executeCommand(this.runnerId.get(), action.content);
|
||||
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);
|
||||
|
||||
if (resp?.exitCode != 0) {
|
||||
throw new Error('Failed To Execute Shell Command');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,21 +174,26 @@ export class ActionRunner {
|
||||
if (action.type !== 'start') {
|
||||
unreachable('Expected shell action');
|
||||
}
|
||||
|
||||
if (!this.#shellTerminal) {
|
||||
unreachable('Shell terminal not found');
|
||||
}
|
||||
const shell = this.#shellTerminal()
|
||||
await shell.ready()
|
||||
|
||||
const shell = this.#shellTerminal();
|
||||
await shell.ready();
|
||||
|
||||
if (!shell || !shell.terminal || !shell.process) {
|
||||
unreachable('Shell terminal not found');
|
||||
}
|
||||
const resp = await shell.executeCommand(this.runnerId.get(), action.content)
|
||||
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`)
|
||||
|
||||
const resp = await shell.executeCommand(this.runnerId.get(), action.content);
|
||||
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);
|
||||
|
||||
if (resp?.exitCode != 0) {
|
||||
throw new Error("Failed To Start Application");
|
||||
throw new Error('Failed To Start Application');
|
||||
}
|
||||
return resp
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
async #runFileAction(action: ActionState) {
|
||||
|
||||
@@ -59,7 +59,11 @@ describe('StreamingMessageParser', () => {
|
||||
},
|
||||
],
|
||||
[
|
||||
['Some text before <boltArti', 'fact', ' title="Some title" id="artifact_1">foo</boltArtifact> Some more text'],
|
||||
[
|
||||
'Some text before <boltArti',
|
||||
'fact',
|
||||
' title="Some title" id="artifact_1" type="bundled" >foo</boltArtifact> Some more text',
|
||||
],
|
||||
{
|
||||
output: 'Some text before Some more text',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
|
||||
@@ -55,7 +55,7 @@ interface MessageState {
|
||||
export class StreamingMessageParser {
|
||||
#messages = new Map<string, MessageState>();
|
||||
|
||||
constructor(private _options: StreamingMessageParserOptions = {}) { }
|
||||
constructor(private _options: StreamingMessageParserOptions = {}) {}
|
||||
|
||||
parse(messageId: string, input: string) {
|
||||
let state = this.#messages.get(messageId);
|
||||
@@ -120,20 +120,20 @@ export class StreamingMessageParser {
|
||||
i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length;
|
||||
} else {
|
||||
if ('type' in currentAction && currentAction.type === 'file') {
|
||||
let content = input.slice(i);
|
||||
const content = input.slice(i);
|
||||
|
||||
this._options.callbacks?.onActionStream?.({
|
||||
artifactId: currentArtifact.id,
|
||||
messageId,
|
||||
actionId: String(state.actionId - 1),
|
||||
action: {
|
||||
...currentAction as FileAction,
|
||||
...(currentAction as FileAction),
|
||||
content,
|
||||
filePath: currentAction.filePath,
|
||||
},
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -192,6 +192,7 @@ export class StreamingMessageParser {
|
||||
const artifactTag = input.slice(i, openTagEnd + 1);
|
||||
|
||||
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
|
||||
const type = this.#extractAttribute(artifactTag, 'type') as string;
|
||||
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
|
||||
|
||||
if (!artifactTitle) {
|
||||
@@ -207,6 +208,7 @@ export class StreamingMessageParser {
|
||||
const currentArtifact = {
|
||||
id: artifactId,
|
||||
title: artifactTitle,
|
||||
type,
|
||||
} satisfies BoltArtifactData;
|
||||
|
||||
state.currentArtifact = currentArtifact;
|
||||
@@ -272,7 +274,7 @@ export class StreamingMessageParser {
|
||||
}
|
||||
|
||||
(actionAttributes as FileAction).filePath = filePath;
|
||||
} else if (!(['shell', 'start'].includes(actionType))) {
|
||||
} else if (!['shell', 'start'].includes(actionType)) {
|
||||
logger.warn(`Unknown action type '${actionType}'`);
|
||||
}
|
||||
|
||||
|
||||
@@ -212,9 +212,5 @@ function isBinaryFile(buffer: Uint8Array | undefined) {
|
||||
* array buffer.
|
||||
*/
|
||||
function convertToBuffer(view: Uint8Array): Buffer {
|
||||
const buffer = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
|
||||
Object.setPrototypeOf(buffer, Buffer.prototype);
|
||||
|
||||
return buffer as Buffer;
|
||||
return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { coloredText } from '~/utils/terminal';
|
||||
export class TerminalStore {
|
||||
#webcontainer: Promise<WebContainer>;
|
||||
#terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = [];
|
||||
#boltTerminal = newBoltShellProcess()
|
||||
#boltTerminal = newBoltShellProcess();
|
||||
|
||||
showTerminal: WritableAtom<boolean> = import.meta.hot?.data.showTerminal ?? atom(true);
|
||||
|
||||
@@ -27,8 +27,8 @@ export class TerminalStore {
|
||||
}
|
||||
async attachBoltTerminal(terminal: ITerminal) {
|
||||
try {
|
||||
let wc = await this.#webcontainer
|
||||
await this.#boltTerminal.init(wc, terminal)
|
||||
const wc = await this.#webcontainer;
|
||||
await this.#boltTerminal.init(wc, terminal);
|
||||
} catch (error: any) {
|
||||
terminal.write(coloredText.red('Failed to spawn bolt shell\n\n') + error.message);
|
||||
return;
|
||||
|
||||
@@ -11,13 +11,15 @@ import { PreviewsStore } from './previews';
|
||||
import { TerminalStore } from './terminal';
|
||||
import JSZip from 'jszip';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { Octokit, type RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
|
||||
import * as nodePath from 'node:path';
|
||||
import type { WebContainerProcess } from '@webcontainer/api';
|
||||
import { extractRelativePath } from '~/utils/diff';
|
||||
import { description } from '~/lib/persistence';
|
||||
|
||||
export interface ArtifactState {
|
||||
id: string;
|
||||
title: string;
|
||||
type?: string;
|
||||
closed: boolean;
|
||||
runner: ActionRunner;
|
||||
}
|
||||
@@ -41,8 +43,7 @@ export class WorkbenchStore {
|
||||
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
||||
modifiedFiles = new Set<string>();
|
||||
artifactIdList: string[] = [];
|
||||
#boltTerminal: { terminal: ITerminal; process: WebContainerProcess } | undefined;
|
||||
|
||||
#globalExecutionQueue = Promise.resolve();
|
||||
constructor() {
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.artifacts = this.artifacts;
|
||||
@@ -52,6 +53,10 @@ export class WorkbenchStore {
|
||||
}
|
||||
}
|
||||
|
||||
addToExecutionQueue(callback: () => Promise<void>) {
|
||||
this.#globalExecutionQueue = this.#globalExecutionQueue.then(() => callback());
|
||||
}
|
||||
|
||||
get previews() {
|
||||
return this.#previewsStore.previews;
|
||||
}
|
||||
@@ -91,7 +96,6 @@ export class WorkbenchStore {
|
||||
this.#terminalStore.attachTerminal(terminal);
|
||||
}
|
||||
attachBoltTerminal(terminal: ITerminal) {
|
||||
|
||||
this.#terminalStore.attachBoltTerminal(terminal);
|
||||
}
|
||||
|
||||
@@ -227,7 +231,7 @@ export class WorkbenchStore {
|
||||
// TODO: what do we wanna do and how do we wanna recover from this?
|
||||
}
|
||||
|
||||
addArtifact({ messageId, title, id }: ArtifactCallbackData) {
|
||||
addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
|
||||
const artifact = this.#getArtifact(messageId);
|
||||
|
||||
if (artifact) {
|
||||
@@ -242,6 +246,7 @@ export class WorkbenchStore {
|
||||
id,
|
||||
title,
|
||||
closed: false,
|
||||
type,
|
||||
runner: new ActionRunner(webcontainer, () => this.boltTerminal),
|
||||
});
|
||||
}
|
||||
@@ -255,8 +260,12 @@ export class WorkbenchStore {
|
||||
|
||||
this.artifacts.setKey(messageId, { ...artifact, ...state });
|
||||
}
|
||||
addAction(data: ActionCallbackData) {
|
||||
this._addAction(data);
|
||||
|
||||
async addAction(data: ActionCallbackData) {
|
||||
// this.addToExecutionQueue(()=>this._addAction(data))
|
||||
}
|
||||
async _addAction(data: ActionCallbackData) {
|
||||
const { messageId } = data;
|
||||
|
||||
const artifact = this.#getArtifact(messageId);
|
||||
@@ -265,10 +274,17 @@ export class WorkbenchStore {
|
||||
unreachable('Artifact not found');
|
||||
}
|
||||
|
||||
artifact.runner.addAction(data);
|
||||
return artifact.runner.addAction(data);
|
||||
}
|
||||
|
||||
async runAction(data: ActionCallbackData, isStreaming: boolean = false) {
|
||||
runAction(data: ActionCallbackData, isStreaming: boolean = false) {
|
||||
if (isStreaming) {
|
||||
this._runAction(data, isStreaming);
|
||||
} else {
|
||||
this.addToExecutionQueue(() => this._runAction(data, isStreaming));
|
||||
}
|
||||
}
|
||||
async _runAction(data: ActionCallbackData, isStreaming: boolean = false) {
|
||||
const { messageId } = data;
|
||||
|
||||
const artifact = this.#getArtifact(messageId);
|
||||
@@ -276,16 +292,21 @@ export class WorkbenchStore {
|
||||
if (!artifact) {
|
||||
unreachable('Artifact not found');
|
||||
}
|
||||
|
||||
if (data.action.type === 'file') {
|
||||
let wc = await webcontainer
|
||||
const wc = await webcontainer;
|
||||
const fullPath = nodePath.join(wc.workdir, data.action.filePath);
|
||||
|
||||
if (this.selectedFile.value !== fullPath) {
|
||||
this.setSelectedFile(fullPath);
|
||||
}
|
||||
|
||||
if (this.currentView.value !== 'code') {
|
||||
this.currentView.set('code');
|
||||
}
|
||||
|
||||
const doc = this.#editorStore.documents.get()[fullPath];
|
||||
|
||||
if (!doc) {
|
||||
await artifact.runner.runAction(data, isStreaming);
|
||||
}
|
||||
@@ -293,11 +314,11 @@ export class WorkbenchStore {
|
||||
this.#editorStore.updateFile(fullPath, data.action.content);
|
||||
|
||||
if (!isStreaming) {
|
||||
this.resetCurrentDocument();
|
||||
await artifact.runner.runAction(data);
|
||||
this.resetAllFileModifications();
|
||||
}
|
||||
} else {
|
||||
artifact.runner.runAction(data);
|
||||
await artifact.runner.runAction(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,10 +331,16 @@ 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) {
|
||||
// remove '/home/project/' from the beginning of the path
|
||||
const relativePath = filePath.replace(/^\/home\/project\//, '');
|
||||
const relativePath = extractRelativePath(filePath);
|
||||
|
||||
// split the path into segments
|
||||
const pathSegments = relativePath.split('/');
|
||||
@@ -333,8 +360,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) {
|
||||
@@ -343,7 +371,7 @@ export class WorkbenchStore {
|
||||
|
||||
for (const [filePath, dirent] of Object.entries(files)) {
|
||||
if (dirent?.type === 'file' && !dirent.isBinary) {
|
||||
const relativePath = filePath.replace(/^\/home\/project\//, '');
|
||||
const relativePath = extractRelativePath(filePath);
|
||||
const pathSegments = relativePath.split('/');
|
||||
let currentHandle = targetHandle;
|
||||
|
||||
@@ -352,7 +380,9 @@ export class WorkbenchStore {
|
||||
}
|
||||
|
||||
// create or get the file
|
||||
const fileHandle = await currentHandle.getFileHandle(pathSegments[pathSegments.length - 1], { create: true });
|
||||
const fileHandle = await currentHandle.getFileHandle(pathSegments[pathSegments.length - 1], {
|
||||
create: true,
|
||||
});
|
||||
|
||||
// write the file content
|
||||
const writable = await fileHandle.createWritable();
|
||||
@@ -367,7 +397,6 @@ export class WorkbenchStore {
|
||||
}
|
||||
|
||||
async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) {
|
||||
|
||||
try {
|
||||
// Get the GitHub auth token from environment variables
|
||||
const githubToken = ghToken;
|
||||
@@ -382,10 +411,11 @@ export class WorkbenchStore {
|
||||
const octokit = new Octokit({ auth: githubToken });
|
||||
|
||||
// Check if the repository already exists before creating it
|
||||
let repo: RestEndpointMethodTypes["repos"]["get"]["response"]['data']
|
||||
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
|
||||
|
||||
try {
|
||||
let resp = await octokit.repos.get({ owner: owner, repo: repoName });
|
||||
repo = resp.data
|
||||
const resp = await octokit.repos.get({ owner, repo: repoName });
|
||||
repo = resp.data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error && error.status === 404) {
|
||||
// Repository doesn't exist, so create a new one
|
||||
@@ -403,6 +433,7 @@ export class WorkbenchStore {
|
||||
|
||||
// Get all files
|
||||
const files = this.files.get();
|
||||
|
||||
if (!files || Object.keys(files).length === 0) {
|
||||
throw new Error('No files found to push');
|
||||
}
|
||||
@@ -417,9 +448,11 @@ export class WorkbenchStore {
|
||||
content: Buffer.from(dirent.content).toString('base64'),
|
||||
encoding: 'base64',
|
||||
});
|
||||
return { path: filePath.replace(/^\/home\/project\//, ''), sha: blob.sha };
|
||||
return { path: extractRelativePath(filePath), sha: blob.sha };
|
||||
}
|
||||
})
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
|
||||
import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
|
||||
@@ -10,18 +8,42 @@ export async function action(args: ActionFunctionArgs) {
|
||||
return chatAction(args);
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader: string) {
|
||||
const cookies: any = {};
|
||||
|
||||
// Split the cookie string by semicolons and spaces
|
||||
const items = cookieHeader.split(';').map((cookie) => cookie.trim());
|
||||
|
||||
items.forEach((item) => {
|
||||
const [name, ...rest] = item.split('=');
|
||||
|
||||
if (name && rest) {
|
||||
// Decode the name and value, and join value parts in case it contains '='
|
||||
const decodedName = decodeURIComponent(name.trim());
|
||||
const decodedValue = decodeURIComponent(rest.join('=').trim());
|
||||
cookies[decodedName] = decodedValue;
|
||||
}
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const { messages, apiKeys } = await request.json<{
|
||||
messages: Messages,
|
||||
apiKeys: Record<string, string>
|
||||
const { messages } = await request.json<{
|
||||
messages: Messages;
|
||||
model: string;
|
||||
}>();
|
||||
|
||||
const cookieHeader = request.headers.get('Cookie');
|
||||
|
||||
// Parse the cookie's value (returns an object or null if no cookie exists)
|
||||
const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
|
||||
|
||||
const stream = new SwitchableStream();
|
||||
|
||||
try {
|
||||
const options: StreamingOptions = {
|
||||
toolChoice: 'none',
|
||||
apiKeys,
|
||||
onFinish: async ({ text: content, finishReason }) => {
|
||||
if (finishReason !== 'length') {
|
||||
return stream.close();
|
||||
@@ -38,7 +60,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
messages.push({ role: 'assistant', content });
|
||||
messages.push({ role: 'user', content: CONTINUE_PROMPT });
|
||||
|
||||
const result = await streamText(messages, context.cloudflare.env, options);
|
||||
const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
|
||||
|
||||
return stream.switchSource(result.toAIStream());
|
||||
},
|
||||
@@ -54,13 +76,13 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
|
||||
|
||||
if (error.message?.includes('API key')) {
|
||||
throw new Response('Invalid or missing API key', {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized'
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { StreamingTextResponse, parseStreamPart } from 'ai';
|
||||
import { streamText } from '~/lib/.server/llm/stream-text';
|
||||
import { stripIndents } from '~/utils/stripIndent';
|
||||
import type { StreamingOptions } from '~/lib/.server/llm/stream-text';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -12,25 +12,27 @@ export async function action(args: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||
const { message, model, provider, apiKeys } = await request.json<{
|
||||
const { message, model, provider, apiKeys } = await request.json<{
|
||||
message: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
provider: ProviderInfo;
|
||||
apiKeys?: Record<string, string>;
|
||||
}>();
|
||||
|
||||
// Validate 'model' and 'provider' fields
|
||||
const { name: providerName } = provider;
|
||||
|
||||
// validate 'model' and 'provider' fields
|
||||
if (!model || typeof model !== 'string') {
|
||||
throw new Response('Invalid or missing model', {
|
||||
status: 400,
|
||||
statusText: 'Bad Request'
|
||||
statusText: 'Bad Request',
|
||||
});
|
||||
}
|
||||
|
||||
if (!provider || typeof provider !== 'string') {
|
||||
if (!providerName || typeof providerName !== 'string') {
|
||||
throw new Response('Invalid or missing provider', {
|
||||
status: 400,
|
||||
statusText: 'Bad Request'
|
||||
statusText: 'Bad Request',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,10 +41,31 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: `[Model: ${model}]\n\n[Provider: ${provider}]\n\n` + stripIndents`
|
||||
content:
|
||||
`[Model: ${model}]\n\n[Provider: ${providerName}]\n\n` +
|
||||
stripIndents`
|
||||
You are a professional prompt engineer specializing in crafting precise, effective prompts.
|
||||
Your task is to enhance prompts by making them more specific, actionable, and effective.
|
||||
|
||||
I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags.
|
||||
|
||||
IMPORTANT: Only respond with the improved prompt and nothing else!
|
||||
For valid prompts:
|
||||
- Make instructions explicit and unambiguous
|
||||
- Add relevant context and constraints
|
||||
- Remove redundant information
|
||||
- Maintain the core intent
|
||||
- Ensure the prompt is self-contained
|
||||
- Use professional language
|
||||
|
||||
For invalid or unclear prompts:
|
||||
- Respond with a clear, professional guidance message
|
||||
- Keep responses concise and actionable
|
||||
- Maintain a helpful, constructive tone
|
||||
- Focus on what the user should provide
|
||||
- Use a standard template for consistency
|
||||
|
||||
IMPORTANT: Your response must ONLY contain the enhanced prompt text.
|
||||
Do not include any explanations, metadata, or wrapper tags.
|
||||
|
||||
<original_prompt>
|
||||
${message}
|
||||
@@ -52,29 +75,30 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||
],
|
||||
context.cloudflare.env,
|
||||
undefined,
|
||||
apiKeys
|
||||
apiKeys,
|
||||
);
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
const text = decoder.decode(chunk);
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = parseStreamPart(line);
|
||||
|
||||
if (parsed.type === 'text') {
|
||||
controller.enqueue(encoder.encode(parsed.value));
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON lines
|
||||
console.warn('Failed to parse stream part:', line);
|
||||
// skip invalid JSON lines
|
||||
console.warn('Failed to parse stream part:', line, e);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const transformedStream = result.toAIStream().pipeThrough(transformStream);
|
||||
const transformedStream = result.toDataStream().pipeThrough(transformStream);
|
||||
|
||||
return new StreamingTextResponse(transformedStream);
|
||||
} catch (error: unknown) {
|
||||
@@ -83,7 +107,7 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||
if (error instanceof Error && error.message?.includes('API key')) {
|
||||
throw new Response('Invalid or missing API key', {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized'
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../z-index';
|
||||
|
||||
[data-resize-handle] {
|
||||
position: relative;
|
||||
|
||||
@@ -8,7 +10,7 @@
|
||||
bottom: 0;
|
||||
left: -6px;
|
||||
right: -5px;
|
||||
z-index: $zIndexMax;
|
||||
z-index: z-index.$zIndexMax;
|
||||
}
|
||||
|
||||
&[data-panel-group-direction='vertical']:after {
|
||||
@@ -18,7 +20,7 @@
|
||||
right: 0;
|
||||
top: -5px;
|
||||
bottom: -6px;
|
||||
z-index: $zIndexMax;
|
||||
z-index: z-index.$zIndexMax;
|
||||
}
|
||||
|
||||
&[data-resize-handle-state='hover']:after,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@import './variables.scss';
|
||||
@import './z-index.scss';
|
||||
@import './animations.scss';
|
||||
@import './components/terminal.scss';
|
||||
@import './components/resize-handle.scss';
|
||||
@import './components/code.scss';
|
||||
@import './components/editor.scss';
|
||||
@import './components/toast.scss';
|
||||
@use 'variables.scss';
|
||||
@use 'z-index.scss';
|
||||
@use 'animations.scss';
|
||||
@use 'components/terminal.scss';
|
||||
@use 'components/resize-handle.scss';
|
||||
@use 'components/code.scss';
|
||||
@use 'components/editor.scss';
|
||||
@use 'components/toast.scss';
|
||||
|
||||
html,
|
||||
body {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface BoltArtifactData {
|
||||
id: string;
|
||||
title: string;
|
||||
type?: string | undefined;
|
||||
}
|
||||
|
||||
2
app/types/global.d.ts
vendored
2
app/types/global.d.ts
vendored
@@ -1,3 +1,5 @@
|
||||
interface Window {
|
||||
showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
|
||||
webkitSpeechRecognition: typeof SpeechRecognition;
|
||||
SpeechRecognition: typeof SpeechRecognition;
|
||||
}
|
||||
|
||||
10
app/types/model.ts
Normal file
10
app/types/model.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ModelInfo } from '~/utils/types';
|
||||
|
||||
export type ProviderInfo = {
|
||||
staticModels: ModelInfo[];
|
||||
name: string;
|
||||
getDynamicModels?: (apiKeys?: Record<string, string>) => Promise<ModelInfo[]>;
|
||||
getApiKeyLink?: string;
|
||||
labelForGetApiKey?: string;
|
||||
icon?: string;
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
|
||||
export const WORK_DIR_NAME = 'project';
|
||||
export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
|
||||
@@ -6,54 +8,351 @@ 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 DEFAULT_PROVIDER = 'Anthropic';
|
||||
export const PROMPT_COOKIE_KEY = 'cachedPrompt';
|
||||
|
||||
const staticModels: ModelInfo[] = [
|
||||
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI' },
|
||||
{ name: 'anthropic/claude-3.5-sonnet', label: 'Anthropic: Claude 3.5 Sonnet (OpenRouter)', provider: 'OpenRouter' },
|
||||
{ name: 'anthropic/claude-3-haiku', label: 'Anthropic: Claude 3 Haiku (OpenRouter)', provider: 'OpenRouter' },
|
||||
{ name: 'deepseek/deepseek-coder', label: 'Deepseek-Coder V2 236B (OpenRouter)', provider: 'OpenRouter' },
|
||||
{ name: 'google/gemini-flash-1.5', label: 'Google Gemini Flash 1.5 (OpenRouter)', provider: 'OpenRouter' },
|
||||
{ name: 'google/gemini-pro-1.5', label: 'Google Gemini Pro 1.5 (OpenRouter)', provider: 'OpenRouter' },
|
||||
{ name: 'x-ai/grok-beta', label: "xAI Grok Beta (OpenRouter)", provider: 'OpenRouter' },
|
||||
{ name: 'mistralai/mistral-nemo', label: 'OpenRouter Mistral Nemo (OpenRouter)', provider: 'OpenRouter' },
|
||||
{ name: 'qwen/qwen-110b-chat', label: 'OpenRouter Qwen 110b Chat (OpenRouter)', provider: 'OpenRouter' },
|
||||
{ name: 'cohere/command', label: 'Cohere Command (OpenRouter)', provider: 'OpenRouter' },
|
||||
{ name: 'gemini-1.5-flash-latest', label: 'Gemini 1.5 Flash', provider: 'Google' },
|
||||
{ name: 'gemini-1.5-pro-latest', label: 'Gemini 1.5 Pro', provider: 'Google' },
|
||||
{ name: 'llama-3.1-70b-versatile', label: 'Llama 3.1 70b (Groq)', provider: 'Groq' },
|
||||
{ name: 'llama-3.1-8b-instant', label: 'Llama 3.1 8b (Groq)', provider: 'Groq' },
|
||||
{ name: 'llama-3.2-11b-vision-preview', label: 'Llama 3.2 11b (Groq)', provider: 'Groq' },
|
||||
{ name: 'llama-3.2-3b-preview', label: 'Llama 3.2 3b (Groq)', provider: 'Groq' },
|
||||
{ name: 'llama-3.2-1b-preview', label: 'Llama 3.2 1b (Groq)', provider: 'Groq' },
|
||||
{ name: 'claude-3-5-sonnet-latest', label: 'Claude 3.5 Sonnet (new)', provider: 'Anthropic' },
|
||||
{ name: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet (old)', provider: 'Anthropic' },
|
||||
{ name: 'claude-3-5-haiku-latest', label: 'Claude 3.5 Haiku (new)', provider: 'Anthropic' },
|
||||
{ name: 'claude-3-opus-latest', label: 'Claude 3 Opus', provider: 'Anthropic' },
|
||||
{ name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic' },
|
||||
{ name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic' },
|
||||
{ name: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'OpenAI' },
|
||||
{ name: 'gpt-4-turbo', label: 'GPT-4 Turbo', provider: 'OpenAI' },
|
||||
{ name: 'gpt-4', label: 'GPT-4', provider: 'OpenAI' },
|
||||
{ name: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo', provider: 'OpenAI' },
|
||||
{ name: 'grok-beta', label: "xAI Grok Beta", provider: 'xAI' },
|
||||
{ name: 'deepseek-coder', label: 'Deepseek-Coder', provider: 'Deepseek'},
|
||||
{ name: 'deepseek-chat', label: 'Deepseek-Chat', provider: 'Deepseek'},
|
||||
{ name: 'open-mistral-7b', label: 'Mistral 7B', provider: 'Mistral' },
|
||||
{ name: 'open-mixtral-8x7b', label: 'Mistral 8x7B', provider: 'Mistral' },
|
||||
{ name: 'open-mixtral-8x22b', label: 'Mistral 8x22B', provider: 'Mistral' },
|
||||
{ name: 'open-codestral-mamba', label: 'Codestral Mamba', provider: 'Mistral' },
|
||||
{ name: 'open-mistral-nemo', label: 'Mistral Nemo', provider: 'Mistral' },
|
||||
{ name: 'ministral-8b-latest', label: 'Mistral 8B', provider: 'Mistral' },
|
||||
{ name: 'mistral-small-latest', label: 'Mistral Small', provider: 'Mistral' },
|
||||
{ name: 'codestral-latest', label: 'Codestral', provider: 'Mistral' },
|
||||
{ name: 'mistral-large-latest', label: 'Mistral Large Latest', provider: 'Mistral' },
|
||||
const PROVIDER_LIST: ProviderInfo[] = [
|
||||
{
|
||||
name: 'Anthropic',
|
||||
staticModels: [
|
||||
{
|
||||
name: 'claude-3-5-sonnet-latest',
|
||||
label: 'Claude 3.5 Sonnet (new)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-sonnet-20240620',
|
||||
label: 'Claude 3.5 Sonnet (old)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-haiku-latest',
|
||||
label: 'Claude 3.5 Haiku (new)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{ name: 'claude-3-opus-latest', label: 'Claude 3 Opus', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
||||
{ name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
||||
{ name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
||||
],
|
||||
getApiKeyLink: 'https://console.anthropic.com/settings/keys',
|
||||
},
|
||||
{
|
||||
name: 'Ollama',
|
||||
staticModels: [],
|
||||
getDynamicModels: getOllamaModels,
|
||||
getApiKeyLink: 'https://ollama.com/download',
|
||||
labelForGetApiKey: 'Download Ollama',
|
||||
icon: 'i-ph:cloud-arrow-down',
|
||||
},
|
||||
{
|
||||
name: 'OpenAILike',
|
||||
staticModels: [],
|
||||
getDynamicModels: getOpenAILikeModels,
|
||||
},
|
||||
{
|
||||
name: 'Cohere',
|
||||
staticModels: [
|
||||
{ name: 'command-r-plus-08-2024', label: 'Command R plus Latest', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-r-08-2024', label: 'Command R Latest', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-r-plus', label: 'Command R plus', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-r', label: 'Command R', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command', label: 'Command', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-nightly', label: 'Command Nightly', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-light', label: 'Command Light', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-light-nightly', label: 'Command Light Nightly', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'c4ai-aya-expanse-8b', label: 'c4AI Aya Expanse 8b', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'c4ai-aya-expanse-32b', label: 'c4AI Aya Expanse 32b', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
],
|
||||
getApiKeyLink: 'https://dashboard.cohere.com/api-keys',
|
||||
},
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
staticModels: [
|
||||
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
{
|
||||
name: 'anthropic/claude-3.5-sonnet',
|
||||
label: 'Anthropic: Claude 3.5 Sonnet (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic/claude-3-haiku',
|
||||
label: 'Anthropic: Claude 3 Haiku (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'deepseek/deepseek-coder',
|
||||
label: 'Deepseek-Coder V2 236B (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'google/gemini-flash-1.5',
|
||||
label: 'Google Gemini Flash 1.5 (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'google/gemini-pro-1.5',
|
||||
label: 'Google Gemini Pro 1.5 (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{ name: 'x-ai/grok-beta', label: 'xAI Grok Beta (OpenRouter)', provider: 'OpenRouter', maxTokenAllowed: 8000 },
|
||||
{
|
||||
name: 'mistralai/mistral-nemo',
|
||||
label: 'OpenRouter Mistral Nemo (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'qwen/qwen-110b-chat',
|
||||
label: 'OpenRouter Qwen 110b Chat (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{ name: 'cohere/command', label: 'Cohere Command (OpenRouter)', provider: 'OpenRouter', maxTokenAllowed: 4096 },
|
||||
],
|
||||
getDynamicModels: getOpenRouterModels,
|
||||
getApiKeyLink: 'https://openrouter.ai/settings/keys',
|
||||
},
|
||||
{
|
||||
name: 'Google',
|
||||
staticModels: [
|
||||
{ name: 'gemini-1.5-flash-latest', label: 'Gemini 1.5 Flash', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-flash-002', label: 'Gemini 1.5 Flash-002', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-flash-8b', label: 'Gemini 1.5 Flash-8b', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-pro-latest', label: 'Gemini 1.5 Pro', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-pro-002', label: 'Gemini 1.5 Pro-002', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-exp-1121', label: 'Gemini exp-1121', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
],
|
||||
getApiKeyLink: 'https://aistudio.google.com/app/apikey',
|
||||
},
|
||||
{
|
||||
name: 'Groq',
|
||||
staticModels: [
|
||||
{ name: 'llama-3.1-70b-versatile', label: 'Llama 3.1 70b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.1-8b-instant', label: 'Llama 3.1 8b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.2-11b-vision-preview', label: 'Llama 3.2 11b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.2-3b-preview', label: 'Llama 3.2 3b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.2-1b-preview', label: 'Llama 3.2 1b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
],
|
||||
getApiKeyLink: 'https://console.groq.com/keys',
|
||||
},
|
||||
{
|
||||
name: 'HuggingFace',
|
||||
staticModels: [
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
label: 'Qwen2.5-Coder-32B-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: '01-ai/Yi-1.5-34B-Chat',
|
||||
label: 'Yi-1.5-34B-Chat (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'codellama/CodeLlama-34b-Instruct-hf',
|
||||
label: 'CodeLlama-34b-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'NousResearch/Hermes-3-Llama-3.1-8B',
|
||||
label: 'Hermes-3-Llama-3.1-8B (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
label: 'Qwen2.5-Coder-32B-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-72B-Instruct',
|
||||
label: 'Qwen2.5-72B-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'meta-llama/Llama-3.1-70B-Instruct',
|
||||
label: 'Llama-3.1-70B-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'meta-llama/Llama-3.1-405B',
|
||||
label: 'Llama-3.1-405B (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: '01-ai/Yi-1.5-34B-Chat',
|
||||
label: 'Yi-1.5-34B-Chat (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'codellama/CodeLlama-34b-Instruct-hf',
|
||||
label: 'CodeLlama-34b-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'NousResearch/Hermes-3-Llama-3.1-8B',
|
||||
label: 'Hermes-3-Llama-3.1-8B (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
],
|
||||
getApiKeyLink: 'https://huggingface.co/settings/tokens',
|
||||
},
|
||||
|
||||
{
|
||||
name: 'OpenAI',
|
||||
staticModels: [
|
||||
{ name: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-4-turbo', label: 'GPT-4 Turbo', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-4', label: 'GPT-4', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
],
|
||||
getApiKeyLink: 'https://platform.openai.com/api-keys',
|
||||
},
|
||||
{
|
||||
name: 'xAI',
|
||||
staticModels: [{ name: 'grok-beta', label: 'xAI Grok Beta', provider: 'xAI', maxTokenAllowed: 8000 }],
|
||||
getApiKeyLink: 'https://docs.x.ai/docs/quickstart#creating-an-api-key',
|
||||
},
|
||||
{
|
||||
name: 'Deepseek',
|
||||
staticModels: [
|
||||
{ name: 'deepseek-coder', label: 'Deepseek-Coder', provider: 'Deepseek', maxTokenAllowed: 8000 },
|
||||
{ name: 'deepseek-chat', label: 'Deepseek-Chat', provider: 'Deepseek', maxTokenAllowed: 8000 },
|
||||
],
|
||||
getApiKeyLink: 'https://platform.deepseek.com/apiKeys',
|
||||
},
|
||||
{
|
||||
name: 'Mistral',
|
||||
staticModels: [
|
||||
{ name: 'open-mistral-7b', label: 'Mistral 7B', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'open-mixtral-8x7b', label: 'Mistral 8x7B', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'open-mixtral-8x22b', label: 'Mistral 8x22B', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'open-codestral-mamba', label: 'Codestral Mamba', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'open-mistral-nemo', label: 'Mistral Nemo', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'ministral-8b-latest', label: 'Mistral 8B', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'mistral-small-latest', label: 'Mistral Small', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'codestral-latest', label: 'Codestral', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'mistral-large-latest', label: 'Mistral Large Latest', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
],
|
||||
getApiKeyLink: 'https://console.mistral.ai/api-keys/',
|
||||
},
|
||||
{
|
||||
name: 'LMStudio',
|
||||
staticModels: [],
|
||||
getDynamicModels: getLMStudioModels,
|
||||
getApiKeyLink: 'https://lmstudio.ai/',
|
||||
labelForGetApiKey: 'Get LMStudio',
|
||||
icon: 'i-ph:cloud-arrow-down',
|
||||
},
|
||||
{
|
||||
name: 'Together',
|
||||
getDynamicModels: getTogetherModels,
|
||||
staticModels: [
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
label: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
provider: 'Together',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo',
|
||||
label: 'meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo',
|
||||
provider: 'Together',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
||||
label: 'Mixtral 8x7B Instruct',
|
||||
provider: 'Together',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
],
|
||||
getApiKeyLink: 'https://api.together.xyz/settings/api-keys',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_PROVIDER = PROVIDER_LIST[0];
|
||||
|
||||
const staticModels: ModelInfo[] = PROVIDER_LIST.map((p) => p.staticModels).flat();
|
||||
|
||||
export let MODEL_LIST: ModelInfo[] = [...staticModels];
|
||||
|
||||
export async function getModelList(apiKeys: Record<string, string>) {
|
||||
MODEL_LIST = [
|
||||
...(
|
||||
await Promise.all(
|
||||
PROVIDER_LIST.filter(
|
||||
(p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
|
||||
).map((p) => p.getDynamicModels(apiKeys)),
|
||||
)
|
||||
).flat(),
|
||||
...staticModels,
|
||||
];
|
||||
return MODEL_LIST;
|
||||
}
|
||||
|
||||
async function getTogetherModels(apiKeys?: Record<string, string>): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const baseUrl = import.meta.env.TOGETHER_API_BASE_URL || '';
|
||||
const provider = 'Together';
|
||||
|
||||
if (!baseUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
|
||||
|
||||
if (apiKeys && apiKeys[provider]) {
|
||||
apiKey = apiKeys[provider];
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
const res = (await response.json()) as any;
|
||||
const data: any[] = (res || []).filter((model: any) => model.type == 'chat');
|
||||
|
||||
return data.map((m: any) => ({
|
||||
name: m.id,
|
||||
label: `${m.display_name} - in:$${m.pricing.input.toFixed(
|
||||
2,
|
||||
)} out:$${m.pricing.output.toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
|
||||
provider,
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error getting OpenAILike models:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const getOllamaBaseUrl = () => {
|
||||
const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
|
||||
|
||||
// Check if we're in the browser
|
||||
if (typeof window !== 'undefined') {
|
||||
// Frontend always uses localhost
|
||||
@@ -63,72 +362,156 @@ const getOllamaBaseUrl = () => {
|
||||
// Backend: Check if we're running in Docker
|
||||
const isDocker = process.env.RUNNING_IN_DOCKER === 'true';
|
||||
|
||||
return isDocker
|
||||
? defaultBaseUrl.replace("localhost", "host.docker.internal")
|
||||
: defaultBaseUrl;
|
||||
return isDocker ? defaultBaseUrl.replace('localhost', 'host.docker.internal') : defaultBaseUrl;
|
||||
};
|
||||
|
||||
async function getOllamaModels(): Promise<ModelInfo[]> {
|
||||
/*
|
||||
* if (typeof window === 'undefined') {
|
||||
* return [];
|
||||
* }
|
||||
*/
|
||||
|
||||
try {
|
||||
const base_url = getOllamaBaseUrl();
|
||||
const response = await fetch(`${base_url}/api/tags`);
|
||||
const data = await response.json() as OllamaApiResponse;
|
||||
const baseUrl = getOllamaBaseUrl();
|
||||
const response = await fetch(`${baseUrl}/api/tags`);
|
||||
const data = (await response.json()) as OllamaApiResponse;
|
||||
|
||||
return data.models.map((model: OllamaModel) => ({
|
||||
name: model.name,
|
||||
label: `${model.name} (${model.details.parameter_size})`,
|
||||
provider: 'Ollama',
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error getting Ollama models:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getOpenAILikeModels(): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const base_url = import.meta.env.OPENAI_LIKE_API_BASE_URL || "";
|
||||
if (!base_url) {
|
||||
const baseUrl = import.meta.env.OPENAI_LIKE_API_BASE_URL || '';
|
||||
|
||||
if (!baseUrl) {
|
||||
return [];
|
||||
}
|
||||
const api_key = import.meta.env.OPENAI_LIKE_API_KEY ?? "";
|
||||
const response = await fetch(`${base_url}/models`, {
|
||||
|
||||
let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
|
||||
|
||||
const apikeys = JSON.parse(Cookies.get('apiKeys') || '{}');
|
||||
|
||||
if (apikeys && apikeys.OpenAILike) {
|
||||
apiKey = apikeys.OpenAILike;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${api_key}`,
|
||||
}
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
const res = await response.json() as any;
|
||||
const res = (await response.json()) as any;
|
||||
|
||||
return res.data.map((model: any) => ({
|
||||
name: model.id,
|
||||
label: model.id,
|
||||
provider: 'OpenAILike',
|
||||
}));
|
||||
} catch (e) {
|
||||
return []
|
||||
console.error('Error getting OpenAILike models:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type OpenRouterModelsResponse = {
|
||||
data: {
|
||||
name: string;
|
||||
id: string;
|
||||
context_length: number;
|
||||
pricing: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
async function getOpenRouterModels(): Promise<ModelInfo[]> {
|
||||
const data: OpenRouterModelsResponse = await (
|
||||
await fetch('https://openrouter.ai/api/v1/models', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return data.data
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((m) => ({
|
||||
name: m.id,
|
||||
label: `${m.name} - in:$${(m.pricing.prompt * 1_000_000).toFixed(
|
||||
2,
|
||||
)} out:$${(m.pricing.completion * 1_000_000).toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getLMStudioModels(): Promise<ModelInfo[]> {
|
||||
if (typeof window === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const base_url = import.meta.env.LMSTUDIO_API_BASE_URL || "http://localhost:1234";
|
||||
const response = await fetch(`${base_url}/v1/models`);
|
||||
const data = await response.json() as any;
|
||||
const baseUrl = import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234';
|
||||
const response = await fetch(`${baseUrl}/v1/models`);
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
return data.data.map((model: any) => ({
|
||||
name: model.id,
|
||||
label: model.id,
|
||||
provider: 'LMStudio',
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error getting LMStudio models:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeModelList(): Promise<ModelInfo[]> {
|
||||
let apiKeys: Record<string, string> = {};
|
||||
|
||||
async function initializeModelList(): Promise<void> {
|
||||
const ollamaModels = await getOllamaModels();
|
||||
const openAiLikeModels = await getOpenAILikeModels();
|
||||
const lmstudioModels = await getLMStudioModels();
|
||||
MODEL_LIST = [...ollamaModels,...openAiLikeModels, ...staticModels,...lmstudioModels,];
|
||||
try {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
if (storedApiKeys) {
|
||||
const parsedKeys = JSON.parse(storedApiKeys);
|
||||
|
||||
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
|
||||
apiKeys = parsedKeys;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn(`Failed to fetch apikeys from cookies:${error?.message}`);
|
||||
}
|
||||
MODEL_LIST = [
|
||||
...(
|
||||
await Promise.all(
|
||||
PROVIDER_LIST.filter(
|
||||
(p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
|
||||
).map((p) => p.getDynamicModels(apiKeys)),
|
||||
)
|
||||
).flat(),
|
||||
...staticModels,
|
||||
];
|
||||
|
||||
return MODEL_LIST;
|
||||
}
|
||||
initializeModelList().then();
|
||||
export { getOllamaModels,getOpenAILikeModels,getLMStudioModels,initializeModelList };
|
||||
|
||||
export {
|
||||
getOllamaModels,
|
||||
getOpenAILikeModels,
|
||||
getLMStudioModels,
|
||||
initializeModelList,
|
||||
getOpenRouterModels,
|
||||
PROVIDER_LIST,
|
||||
};
|
||||
|
||||
11
app/utils/diff.spec.ts
Normal file
11
app/utils/diff.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractRelativePath } from './diff';
|
||||
import { WORK_DIR } from './constants';
|
||||
|
||||
describe('Diff', () => {
|
||||
it('should strip out Work_dir', () => {
|
||||
const filePath = `${WORK_DIR}/index.js`;
|
||||
const result = extractRelativePath(filePath);
|
||||
expect(result).toBe('index.js');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTwoFilesPatch } from 'diff';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { MODIFICATIONS_TAG_NAME } from './constants';
|
||||
import { MODIFICATIONS_TAG_NAME, WORK_DIR } from './constants';
|
||||
|
||||
export const modificationsRegex = new RegExp(
|
||||
`^<${MODIFICATIONS_TAG_NAME}>[\\s\\S]*?<\\/${MODIFICATIONS_TAG_NAME}>\\s+`,
|
||||
@@ -75,6 +75,15 @@ export function diffFiles(fileName: string, oldFileContent: string, newFileConte
|
||||
return unifiedDiff;
|
||||
}
|
||||
|
||||
const regex = new RegExp(`^${WORK_DIR}\/`);
|
||||
|
||||
/**
|
||||
* Strips out the work directory from the file path.
|
||||
*/
|
||||
export function extractRelativePath(filePath: string) {
|
||||
return filePath.replace(regex, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the unified diff to HTML.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -52,67 +52,77 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer
|
||||
return process;
|
||||
}
|
||||
|
||||
|
||||
export type ExecutionResult = { output: string; exitCode: number } | undefined;
|
||||
|
||||
export class BoltShell {
|
||||
#initialized: (() => void) | undefined
|
||||
#readyPromise: Promise<void>
|
||||
#webcontainer: WebContainer | undefined
|
||||
#terminal: ITerminal | undefined
|
||||
#process: WebContainerProcess | undefined
|
||||
executionState = atom<{ sessionId: string, active: boolean, executionPrms?: Promise<any> } | undefined>()
|
||||
#outputStream: ReadableStreamDefaultReader<string> | undefined
|
||||
#shellInputStream: WritableStreamDefaultWriter<string> | undefined
|
||||
#initialized: (() => void) | undefined;
|
||||
#readyPromise: Promise<void>;
|
||||
#webcontainer: WebContainer | undefined;
|
||||
#terminal: ITerminal | undefined;
|
||||
#process: WebContainerProcess | undefined;
|
||||
executionState = atom<{ sessionId: string; active: boolean; executionPrms?: Promise<any> } | undefined>();
|
||||
#outputStream: ReadableStreamDefaultReader<string> | undefined;
|
||||
#shellInputStream: WritableStreamDefaultWriter<string> | undefined;
|
||||
|
||||
constructor() {
|
||||
this.#readyPromise = new Promise((resolve) => {
|
||||
this.#initialized = resolve
|
||||
})
|
||||
this.#initialized = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this.#readyPromise;
|
||||
}
|
||||
async init(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
this.#webcontainer = webcontainer
|
||||
this.#terminal = terminal
|
||||
let callback = (data: string) => {
|
||||
console.log(data)
|
||||
}
|
||||
let { process, output } = await this.newBoltShellProcess(webcontainer, terminal)
|
||||
this.#process = process
|
||||
this.#outputStream = output.getReader()
|
||||
await this.waitTillOscCode('interactive')
|
||||
this.#initialized?.()
|
||||
}
|
||||
get terminal() {
|
||||
return this.#terminal
|
||||
}
|
||||
get process() {
|
||||
return this.#process
|
||||
}
|
||||
async executeCommand(sessionId: string, command: string) {
|
||||
if (!this.process || !this.terminal) {
|
||||
return
|
||||
}
|
||||
let state = this.executionState.get()
|
||||
|
||||
//interrupt the current execution
|
||||
// this.#shellInputStream?.write('\x03');
|
||||
this.terminal.input('\x03');
|
||||
if (state && state.executionPrms) {
|
||||
await state.executionPrms
|
||||
async init(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
this.#webcontainer = webcontainer;
|
||||
this.#terminal = terminal;
|
||||
|
||||
const { process, output } = await this.newBoltShellProcess(webcontainer, terminal);
|
||||
this.#process = process;
|
||||
this.#outputStream = output.getReader();
|
||||
await this.waitTillOscCode('interactive');
|
||||
this.#initialized?.();
|
||||
}
|
||||
|
||||
get terminal() {
|
||||
return this.#terminal;
|
||||
}
|
||||
|
||||
get process() {
|
||||
return this.#process;
|
||||
}
|
||||
|
||||
async executeCommand(sessionId: string, command: string): Promise<ExecutionResult> {
|
||||
if (!this.process || !this.terminal) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const state = this.executionState.get();
|
||||
|
||||
/*
|
||||
* interrupt the current execution
|
||||
* this.#shellInputStream?.write('\x03');
|
||||
*/
|
||||
this.terminal.input('\x03');
|
||||
|
||||
if (state && state.executionPrms) {
|
||||
await state.executionPrms;
|
||||
}
|
||||
|
||||
//start a new execution
|
||||
this.terminal.input(command.trim() + '\n');
|
||||
|
||||
//wait for the execution to finish
|
||||
let executionPrms = this.getCurrentExecutionResult()
|
||||
this.executionState.set({ sessionId, active: true, executionPrms })
|
||||
const executionPromise = this.getCurrentExecutionResult();
|
||||
this.executionState.set({ sessionId, active: true, executionPrms: executionPromise });
|
||||
|
||||
let resp = await executionPrms
|
||||
this.executionState.set({ sessionId, active: false })
|
||||
return resp
|
||||
const resp = await executionPromise;
|
||||
this.executionState.set({ sessionId, active: false });
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
const args: string[] = [];
|
||||
|
||||
@@ -126,6 +136,7 @@ export class BoltShell {
|
||||
|
||||
const input = process.input.getWriter();
|
||||
this.#shellInputStream = input;
|
||||
|
||||
const [internalOutput, terminalOutput] = process.output.tee();
|
||||
|
||||
const jshReady = withResolvers<void>();
|
||||
@@ -162,34 +173,48 @@ export class BoltShell {
|
||||
|
||||
return { process, output: internalOutput };
|
||||
}
|
||||
async getCurrentExecutionResult() {
|
||||
let { output, exitCode } = await this.waitTillOscCode('exit')
|
||||
|
||||
async getCurrentExecutionResult(): Promise<ExecutionResult> {
|
||||
const { output, exitCode } = await this.waitTillOscCode('exit');
|
||||
return { output, exitCode };
|
||||
}
|
||||
|
||||
async waitTillOscCode(waitCode: string) {
|
||||
let fullOutput = '';
|
||||
let exitCode: number = 0;
|
||||
if (!this.#outputStream) return { output: fullOutput, exitCode };
|
||||
let tappedStream = this.#outputStream
|
||||
|
||||
if (!this.#outputStream) {
|
||||
return { output: fullOutput, exitCode };
|
||||
}
|
||||
|
||||
const tappedStream = this.#outputStream;
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await tappedStream.read();
|
||||
if (done) break;
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const text = value || '';
|
||||
fullOutput += text;
|
||||
|
||||
// Check if command completion signal with exit code
|
||||
const [, osc, , pid, code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
|
||||
const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
|
||||
|
||||
if (osc === 'exit') {
|
||||
exitCode = parseInt(code, 10);
|
||||
}
|
||||
|
||||
if (osc === waitCode) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { output: fullOutput, exitCode };
|
||||
}
|
||||
}
|
||||
|
||||
export function newBoltShellProcess() {
|
||||
return new BoltShell();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
interface OllamaModelDetails {
|
||||
parent_model: string;
|
||||
format: string;
|
||||
@@ -25,4 +24,14 @@ export interface ModelInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
maxTokenAllowed: number;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
staticModels: ModelInfo[];
|
||||
name: string;
|
||||
getDynamicModels?: () => Promise<ModelInfo[]>;
|
||||
getApiKeyLink?: string;
|
||||
labelForGetApiKey?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user