diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index f818db4c..1f97a605 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -22,6 +22,7 @@ import { useSettings } from '~/lib/hooks/useSettings'; import type { ProviderInfo } from '~/types/model'; import { useSearchParams } from '@remix-run/react'; import { createSampler } from '~/utils/sampler'; +import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -116,9 +117,10 @@ export const ChatImpl = memo( const [uploadedFiles, setUploadedFiles] = useState([]); // Move here const [imageDataList, setImageDataList] = useState([]); // Move here const [searchParams, setSearchParams] = useSearchParams(); + const [fakeLoading, setFakeLoading] = useState(false); const files = useStore(workbenchStore.files); const actionAlert = useStore(workbenchStore.alert); - const { activeProviders, promptId, contextOptimizationEnabled } = useSettings(); + const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings(); const [model, setModel] = useState(() => { const savedModel = Cookies.get('selectedModel'); @@ -135,7 +137,7 @@ export const ChatImpl = memo( const [apiKeys, setApiKeys] = useState>({}); - const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ + const { messages, isLoading, input, handleInputChange, setInput, stop, append, setMessages, reload } = useChat({ api: '/api/chat', body: { apiKeys, @@ -266,6 +268,110 @@ export const ChatImpl = memo( runAnimation(); + if (!chatStarted && messageInput && autoSelectTemplate) { + setFakeLoading(true); + setMessages([ + { + id: `${new Date().getTime()}`, + 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 + }, + ]); + + // reload(); + + const template = await selectStarterTemplate({ + message: messageInput, + model, + provider, + }); + + if (template !== 'blank') { + const temResp = await getTemplates(template); + + if (temResp) { + const { assistantMessage, userMessage } = temResp; + + setMessages([ + { + id: `${new Date().getTime()}`, + role: 'user', + content: messageInput, + + // annotations: ['hidden'], + }, + { + id: `${new Date().getTime()}`, + role: 'assistant', + content: assistantMessage, + }, + { + id: `${new Date().getTime()}`, + role: 'user', + content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`, + annotations: ['hidden'], + }, + ]); + + reload(); + setFakeLoading(false); + + return; + } else { + setMessages([ + { + id: `${new Date().getTime()}`, + 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 + }, + ]); + reload(); + setFakeLoading(false); + + return; + } + } else { + setMessages([ + { + id: `${new Date().getTime()}`, + 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 + }, + ]); + reload(); + setFakeLoading(false); + + return; + } + } + if (fileModifications !== undefined) { /** * If we have file modifications we append a new user message manually since we have to prefix @@ -368,7 +474,7 @@ export const ChatImpl = memo( input={input} showChat={showChat} chatStarted={chatStarted} - isStreaming={isLoading} + isStreaming={isLoading || fakeLoading} enhancingPrompt={enhancingPrompt} promptEnhanced={promptEnhanced} sendMessage={sendMessage} diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index 3b619180..031503e7 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -1,5 +1,5 @@ import type { Message } from 'ai'; -import React from 'react'; +import React, { Fragment } from 'react'; import { classNames } from '~/utils/classNames'; import { AssistantMessage } from './AssistantMessage'; import { UserMessage } from './UserMessage'; @@ -44,10 +44,15 @@ export const Messages = React.forwardRef((props:
{messages.length > 0 ? messages.map((message, index) => { - const { role, content, id: messageId } = message; + const { role, content, id: messageId, annotations } = message; const isUserMessage = role === 'user'; const isFirst = index === 0; const isLast = index === messages.length - 1; + const isHidden = annotations?.includes('hidden'); + + if (isHidden) { + return ; + } return (
Use Main Branch -

+

Check for updates against the main branch instead of stable

+
+
+ Auto Select Code Template +

+ Let Bolt select the best starter template for your project. +

+
+ +
Use Context Optimization @@ -59,18 +70,22 @@ export default function FeaturesTab() {

Experimental Features

-

+

Disclaimer: Experimental features may be unstable and are subject to change.

- -
- Experimental Providers - +
+
+ Experimental Providers + +
+

+ Enable experimental providers such as Ollama, LMStudio, and OpenAILike. +

Prompt Library -

+

Choose a prompt from the library to use as the system prompt.

diff --git a/app/lib/hooks/useSettings.tsx b/app/lib/hooks/useSettings.tsx index 4596e26c..e12c7fe8 100644 --- a/app/lib/hooks/useSettings.tsx +++ b/app/lib/hooks/useSettings.tsx @@ -7,6 +7,7 @@ import { promptStore, providersStore, latestBranchStore, + autoSelectStarterTemplate, enableContextOptimizationStore, } from '~/lib/stores/settings'; import { useCallback, useEffect, useState } from 'react'; @@ -31,6 +32,7 @@ export function useSettings() { const promptId = useStore(promptStore); const isLocalModel = useStore(isLocalModelsEnabled); const isLatestBranch = useStore(latestBranchStore); + const autoSelectTemplate = useStore(autoSelectStarterTemplate); const [activeProviders, setActiveProviders] = useState([]); const contextOptimizationEnabled = useStore(enableContextOptimizationStore); @@ -121,6 +123,12 @@ export function useSettings() { latestBranchStore.set(savedLatestBranch === 'true'); } + const autoSelectTemplate = Cookies.get('autoSelectTemplate'); + + if (autoSelectTemplate) { + autoSelectStarterTemplate.set(autoSelectTemplate === 'true'); + } + const savedContextOptimizationEnabled = Cookies.get('contextOptimizationEnabled'); if (savedContextOptimizationEnabled) { @@ -187,6 +195,12 @@ export function useSettings() { Cookies.set('isLatestBranch', String(enabled)); }, []); + const setAutoSelectTemplate = useCallback((enabled: boolean) => { + autoSelectStarterTemplate.set(enabled); + logStore.logSystem(`Auto select template ${enabled ? 'enabled' : 'disabled'}`); + Cookies.set('autoSelectTemplate', String(enabled)); + }, []); + const enableContextOptimization = useCallback((enabled: boolean) => { enableContextOptimizationStore.set(enabled); logStore.logSystem(`Context optimization ${enabled ? 'enabled' : 'disabled'}`); @@ -207,6 +221,8 @@ export function useSettings() { setPromptId, isLatestBranch, enableLatestBranch, + autoSelectTemplate, + setAutoSelectTemplate, contextOptimizationEnabled, enableContextOptimization, }; diff --git a/app/lib/runtime/message-parser.ts b/app/lib/runtime/message-parser.ts index fa3b4a36..8f1ccd52 100644 --- a/app/lib/runtime/message-parser.ts +++ b/app/lib/runtime/message-parser.ts @@ -109,7 +109,6 @@ export class StreamingMessageParser { // Remove markdown code block syntax if present and file is not markdown if (!currentAction.filePath.endsWith('.md')) { content = cleanoutMarkdownSyntax(content); - console.log('content after cleanup', content); } content += '\n'; diff --git a/app/lib/stores/settings.ts b/app/lib/stores/settings.ts index c53a708a..72b89331 100644 --- a/app/lib/stores/settings.ts +++ b/app/lib/stores/settings.ts @@ -54,4 +54,5 @@ export const promptStore = atom('default'); export const latestBranchStore = atom(false); +export const autoSelectStarterTemplate = atom(true); export const enableContextOptimizationStore = atom(false); diff --git a/app/routes/api.llmcall.ts b/app/routes/api.llmcall.ts new file mode 100644 index 00000000..0fc3c85e --- /dev/null +++ b/app/routes/api.llmcall.ts @@ -0,0 +1,163 @@ +import { type ActionFunctionArgs } from '@remix-run/cloudflare'; + +//import { StreamingTextResponse, parseStreamPart } from 'ai'; +import { streamText } from '~/lib/.server/llm/stream-text'; +import type { IProviderSetting, ProviderInfo } from '~/types/model'; +import { generateText } from 'ai'; +import { getModelList, PROVIDER_LIST } from '~/utils/constants'; +import { MAX_TOKENS } from '~/lib/.server/llm/constants'; + +export async function action(args: ActionFunctionArgs) { + return llmCallAction(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 llmCallAction({ context, request }: ActionFunctionArgs) { + const { system, message, model, provider, streamOutput } = await request.json<{ + system: string; + message: string; + model: string; + provider: ProviderInfo; + streamOutput?: boolean; + }>(); + + 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', + }); + } + + if (!providerName || typeof providerName !== 'string') { + throw new Response('Invalid or missing provider', { + status: 400, + statusText: 'Bad Request', + }); + } + + 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 providerSettings: Record = JSON.parse( + parseCookies(cookieHeader || '').providers || '{}', + ); + + if (streamOutput) { + try { + const result = await streamText({ + options: { + system, + }, + messages: [ + { + role: 'user', + content: `${message}`, + }, + ], + env: context.cloudflare.env, + apiKeys, + providerSettings, + }); + + return new Response(result.textStream, { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); + } catch (error: unknown) { + console.log(error); + + if (error instanceof Error && error.message?.includes('API key')) { + throw new Response('Invalid or missing API key', { + status: 401, + statusText: 'Unauthorized', + }); + } + + throw new Response(null, { + status: 500, + statusText: 'Internal Server Error', + }); + } + } else { + try { + const MODEL_LIST = await getModelList({ apiKeys, providerSettings, serverEnv: context.cloudflare.env as any }); + const modelDetails = MODEL_LIST.find((m) => m.name === model); + + if (!modelDetails) { + throw new Error('Model not found'); + } + + const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS; + + const providerInfo = PROVIDER_LIST.find((p) => p.name === provider.name); + + if (!providerInfo) { + throw new Error('Provider not found'); + } + + const result = await generateText({ + system, + messages: [ + { + role: 'user', + content: `${message}`, + }, + ], + model: providerInfo.getModelInstance({ + model: modelDetails.name, + serverEnv: context.cloudflare.env as any, + apiKeys, + providerSettings, + }), + maxTokens: dynamicMaxTokens, + toolChoice: 'none', + }); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error: unknown) { + console.log(error); + + if (error instanceof Error && error.message?.includes('API key')) { + throw new Response('Invalid or missing API key', { + status: 401, + statusText: 'Unauthorized', + }); + } + + throw new Response(null, { + status: 500, + statusText: 'Internal Server Error', + }); + } + } +} diff --git a/app/utils/selectStarterTemplate.ts b/app/utils/selectStarterTemplate.ts new file mode 100644 index 00000000..6f0cb12d --- /dev/null +++ b/app/utils/selectStarterTemplate.ts @@ -0,0 +1,290 @@ +import ignore from 'ignore'; +import type { ProviderInfo } from '~/types/model'; +import type { Template } from '~/types/template'; +import { STARTER_TEMPLATES } from './constants'; + +const starterTemplateSelectionPrompt = (templates: Template[]) => ` +You are an experienced developer who helps people choose the best starter template for their projects. + +Available templates: + +${templates + .map( + (template) => ` + +`, + ) + .join('\n')} + +Response Format: + + {selected template name} + {brief explanation for the choice} + + +Examples: + + +User: I need to build a todo app +Response: + + react-basic-starter + Simple React setup perfect for building a todo application + + + + +User: Write a script to generate numbers from 1 to 100 +Response: + + blank + This is a simple script that doesn't require any template setup + + + +Instructions: +1. For trivial tasks and simple scripts, always recommend the blank template +2. For more complex projects, recommend templates from the provided list +3. Follow the exact XML format +4. Consider both technical requirements and tags +5. If no perfect match exists, recommend the closest option + +Important: Provide only the selection tags in your response, no additional text. +`; + +const templates: Template[] = STARTER_TEMPLATES.filter((t) => !t.name.includes('shadcn')); + +const parseSelectedTemplate = (llmOutput: string): string | null => { + try { + // Extract content between tags + const templateNameMatch = llmOutput.match(/(.*?)<\/templateName>/); + + if (!templateNameMatch) { + return null; + } + + return templateNameMatch[1].trim(); + } catch (error) { + console.error('Error parsing template selection:', error); + return null; + } +}; + +export const selectStarterTemplate = async (options: { message: string; model: string; provider: ProviderInfo }) => { + const { message, model, provider } = options; + const requestBody = { + message, + model, + provider, + system: starterTemplateSelectionPrompt(templates), + }; + const response = await fetch('/api/llmcall', { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const respJson: { text: string } = await response.json(); + console.log(respJson); + + const { text } = respJson; + const selectedTemplate = parseSelectedTemplate(text); + + if (selectedTemplate) { + return selectedTemplate; + } else { + console.log('No template selected, using blank template'); + + return 'blank'; + } +}; + +const getGitHubRepoContent = async ( + repoName: string, + path: string = '', +): Promise<{ name: string; path: string; content: string }[]> => { + const baseUrl = 'https://api.github.com'; + + try { + // Fetch contents of the path + const response = await fetch(`${baseUrl}/repos/${repoName}/contents/${path}`, { + headers: { + Accept: 'application/vnd.github.v3+json', + + // Add your GitHub token if needed + Authorization: 'token ' + import.meta.env.VITE_GITHUB_ACCESS_TOKEN, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: any = await response.json(); + + // If it's a single file, return its content + if (!Array.isArray(data)) { + if (data.type === 'file') { + // If it's a file, get its content + const content = atob(data.content); // Decode base64 content + return [ + { + name: data.name, + path: data.path, + content, + }, + ]; + } + } + + // Process directory contents recursively + const contents = await Promise.all( + data.map(async (item: any) => { + if (item.type === 'dir') { + // Recursively get contents of subdirectories + return await getGitHubRepoContent(repoName, item.path); + } else if (item.type === 'file') { + // Fetch file content + const fileResponse = await fetch(item.url, { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: 'token ' + import.meta.env.VITE_GITHUB_ACCESS_TOKEN, + }, + }); + const fileData: any = await fileResponse.json(); + const content = atob(fileData.content); // Decode base64 content + + return [ + { + name: item.name, + path: item.path, + content, + }, + ]; + } + + return []; + }), + ); + + // Flatten the array of contents + return contents.flat(); + } catch (error) { + console.error('Error fetching repo contents:', error); + throw error; + } +}; + +export async function getTemplates(templateName: string) { + const template = STARTER_TEMPLATES.find((t) => t.name == templateName); + + if (!template) { + return null; + } + + const githubRepo = template.githubRepo; + const files = await getGitHubRepoContent(githubRepo); + + let filteredFiles = files; + + /* + * ignoring common unwanted files + * exclude .git + */ + filteredFiles = filteredFiles.filter((x) => x.path.startsWith('.git') == false); + + // exclude lock files + const comminLockFiles = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']; + filteredFiles = filteredFiles.filter((x) => comminLockFiles.includes(x.name) == false); + + // exclude .bolt + filteredFiles = filteredFiles.filter((x) => x.path.startsWith('.bolt') == false); + + // check for ignore file in .bolt folder + const templateIgnoreFile = files.find((x) => x.path.startsWith('.bolt') && x.name == 'ignore'); + + const filesToImport = { + files: filteredFiles, + ignoreFile: filteredFiles, + }; + + if (templateIgnoreFile) { + // redacting files specified in ignore file + const ignorepatterns = templateIgnoreFile.content.split('\n').map((x) => x.trim()); + const ig = ignore().add(ignorepatterns); + + // filteredFiles = filteredFiles.filter(x => !ig.ignores(x.path)) + const ignoredFiles = filteredFiles.filter((x) => ig.ignores(x.path)); + + filesToImport.files = filteredFiles; + filesToImport.ignoreFile = ignoredFiles; + } + + const assistantMessage = ` + +${filesToImport.files + .map( + (file) => + ` +${file.content} +`, + ) + .join('\n')} + +`; + let userMessage = ``; + const templatePromptFile = files.filter((x) => x.path.startsWith('.bolt')).find((x) => x.name == 'prompt'); + + if (templatePromptFile) { + userMessage = ` +TEMPLATE INSTRUCTIONS: +${templatePromptFile.content} + +IMPORTANT: Dont Forget to install the dependencies before running the app +--- +`; + } + + if (filesToImport.ignoreFile.length > 0) { + userMessage = + userMessage + + ` +STRICT FILE ACCESS RULES - READ CAREFULLY: + +The following files are READ-ONLY and must never be modified: +${filesToImport.ignoreFile.map((file) => `- ${file.path}`).join('\n')} + +Permitted actions: +✓ Import these files as dependencies +✓ Read from these files +✓ Reference these files + +Strictly forbidden actions: +❌ Modify any content within these files +❌ Delete these files +❌ Rename these files +❌ Move these files +❌ Create new versions of these files +❌ Suggest changes to these files + +Any attempt to modify these protected files will result in immediate termination of the operation. + +If you need to make changes to functionality, create new files instead of modifying the protected ones listed above. +--- +`; + userMessage += ` +Now that the Template is imported please continue with my original request +`; + } + + return { + assistantMessage, + userMessage, + }; +}