Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
# Get your API key from: https://console.anthropic.com/
ANTHROPIC_API_KEY=your_anthropic_api_key_here

# Cerebras (High-performance inference)
# Get your API key from: https://cloud.cerebras.ai/settings
CEREBRAS_API_KEY=your_cerebras_api_key_here

# Fireworks AI (Fast inference with FireAttention engine)
# Get your API key from: https://fireworks.ai/api-keys
FIREWORKS_API_KEY=your_fireworks_api_key_here

# OpenAI GPT models
# Get your API key from: https://platform.openai.com/api-keys
OPENAI_API_KEY=your_openai_api_key_here
Expand Down Expand Up @@ -59,6 +67,10 @@ XAI_API_KEY=your_xai_api_key_here
# Get your API key from: https://platform.moonshot.ai/console/api-keys
MOONSHOT_API_KEY=your_moonshot_api_key_here

# Z.AI (GLM models with JWT authentication)
# Get your API key from: https://open.bigmodel.cn/usercenter/apikeys
ZAI_API_KEY=your_zai_api_key_here

# Hugging Face
# Get your API key from: https://huggingface.co/settings/tokens
HuggingFace_API_KEY=your_huggingface_api_key_here
Expand Down
3 changes: 3 additions & 0 deletions app/components/chat/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ interface BaseChatProps {
selectedElement?: ElementInfo | null;
setSelectedElement?: (element: ElementInfo | null) => void;
addToolResult?: ({ toolCallId, result }: { toolCallId: string; result: any }) => void;
onWebSearchResult?: (result: string) => void;
}

export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
Expand Down Expand Up @@ -130,6 +131,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
addToolResult = () => {
throw new Error('addToolResult not implemented');
},
onWebSearchResult,
},
ref,
) => {
Expand Down Expand Up @@ -465,6 +467,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
setDesignScheme={setDesignScheme}
selectedElement={selectedElement}
setSelectedElement={setSelectedElement}
onWebSearchResult={onWebSearchResult}
/>
</div>
</StickToBottom>
Expand Down
15 changes: 15 additions & 0 deletions app/components/chat/Chat.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,20 @@ export const ChatImpl = memo(
Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
};

const handleWebSearchResult = useCallback(
(result: string) => {
const currentInput = input || '';
const newInput = currentInput.length > 0 ? `${result}\n\n${currentInput}` : result;

// Update the input via the same mechanism as handleInputChange
const syntheticEvent = {
target: { value: newInput },
} as React.ChangeEvent<HTMLTextAreaElement>;
handleInputChange(syntheticEvent);
},
[input, handleInputChange],
);

return (
<BaseChat
ref={animationScope}
Expand Down Expand Up @@ -664,6 +678,7 @@ export const ChatImpl = memo(
selectedElement={selectedElement}
setSelectedElement={setSelectedElement}
addToolResult={addToolResult}
onWebSearchResult={handleWebSearchResult}
/>
);
},
Expand Down
3 changes: 3 additions & 0 deletions app/components/chat/ChatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ColorSchemeDialog } from '~/components/ui/ColorSchemeDialog';
import type { DesignScheme } from '~/types/design-scheme';
import type { ElementInfo } from '~/components/workbench/Inspector';
import { McpTools } from './MCPTools';
import { WebSearch } from './WebSearch.client';

interface ChatBoxProps {
isModelSettingsCollapsed: boolean;
Expand Down Expand Up @@ -55,6 +56,7 @@ interface ChatBoxProps {
handleStop?: (() => void) | undefined;
enhancingPrompt?: boolean | undefined;
enhancePrompt?: (() => void) | undefined;
onWebSearchResult?: (result: string) => void;
chatMode?: 'discuss' | 'build';
setChatMode?: (mode: 'discuss' | 'build') => void;
designScheme?: DesignScheme;
Expand Down Expand Up @@ -265,6 +267,7 @@ export const ChatBox: React.FC<ChatBoxProps> = (props) => {
<IconButton title="Upload file" className="transition-all" onClick={() => props.handleFileUpload()}>
<div className="i-ph:paperclip text-xl"></div>
</IconButton>
<WebSearch onSearchResult={(result) => props.onWebSearchResult?.(result)} disabled={props.isStreaming} />
<IconButton
title="Enhance prompt"
disabled={props.input.length === 0 || props.enhancingPrompt}
Expand Down
85 changes: 78 additions & 7 deletions app/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';

// Fuzzy search utilities
const levenshteinDistance = (str1: string, str2: string): number => {
Expand Down Expand Up @@ -130,6 +131,32 @@ export const ModelSelector = ({
const providerDropdownRef = useRef<HTMLDivElement>(null);
const [showFreeModelsOnly, setShowFreeModelsOnly] = useState(false);

type ConnectionStatus = 'unknown' | 'connected' | 'disconnected';

const [localProviderStatus, setLocalProviderStatus] = useState<Record<string, ConnectionStatus>>({});

// Check connectivity of local providers when provider list changes
useEffect(() => {
const checkLocalProviders = async () => {
const statuses: Record<string, 'connected' | 'disconnected'> = {};

for (const p of providerList) {
if (!LOCAL_PROVIDERS.includes(p.name)) {
continue;
}

// If the provider has models loaded, it's connected
const hasModels = modelList.some((m) => m.provider === p.name);

statuses[p.name] = hasModels ? 'connected' : 'disconnected';
}

setLocalProviderStatus(statuses);
};

checkLocalProviders();
}, [providerList, modelList]);

// Debounce search queries
useEffect(() => {
const timer = setTimeout(() => {
Expand Down Expand Up @@ -440,7 +467,28 @@ export const ModelSelector = ({
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="truncate">{provider?.name || 'Select provider'}</div>
<div className="flex items-center gap-2 truncate">
{provider?.name && LOCAL_PROVIDERS.includes(provider.name) && (
<span
className={classNames(
'inline-block w-2 h-2 rounded-full flex-shrink-0',
localProviderStatus[provider.name] === 'connected'
? 'bg-green-500'
: localProviderStatus[provider.name] === 'disconnected'
? 'bg-red-400'
: 'bg-bolt-elements-textTertiary',
)}
title={
localProviderStatus[provider.name] === 'connected'
? `${provider.name} is running`
: localProviderStatus[provider.name] === 'disconnected'
? `${provider.name} is not reachable`
: 'Checking...'
}
/>
)}
{provider?.name || 'Select provider'}
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
Expand Down Expand Up @@ -559,11 +607,25 @@ export const ModelSelector = ({
}}
tabIndex={focusedProviderIndex === index ? 0 : -1}
>
<div
dangerouslySetInnerHTML={{
__html: (providerOption as any).highlightedName || providerOption.name,
}}
/>
<div className="flex items-center gap-2">
{LOCAL_PROVIDERS.includes(providerOption.name) && (
<span
className={classNames(
'inline-block w-2 h-2 rounded-full flex-shrink-0',
localProviderStatus[providerOption.name] === 'connected'
? 'bg-green-500'
: localProviderStatus[providerOption.name] === 'disconnected'
? 'bg-red-400'
: 'bg-bolt-elements-textTertiary',
)}
/>
)}
<span
dangerouslySetInnerHTML={{
__html: (providerOption as any).highlightedName || providerOption.name,
}}
/>
</div>
</div>
))
)}
Expand Down Expand Up @@ -717,8 +779,17 @@ export const ModelSelector = ({
? `No models match "${debouncedModelSearchQuery}"${showFreeModelsOnly ? ' (free only)' : ''}`
: showFreeModelsOnly
? 'No free models available'
: 'No models available'}
: provider?.name && LOCAL_PROVIDERS.includes(provider.name)
? `No models found — is ${provider.name} running?`
: 'No models available'}
</div>
{!debouncedModelSearchQuery && provider?.name && LOCAL_PROVIDERS.includes(provider.name) && (
<div className="text-xs text-bolt-elements-textTertiary mt-1">
Make sure {provider.name} is running and has at least one model loaded.
{provider.name === 'Ollama' && ' Try: ollama pull llama3.2'}
{provider.name === 'LMStudio' && ' Load a model in LM Studio first.'}
</div>
)}
{debouncedModelSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try searching for model names, context sizes (e.g., "128k", "1M"), or capabilities
Expand Down
163 changes: 163 additions & 0 deletions app/components/chat/WebSearch.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useState, useRef, useEffect } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';

interface WebSearchProps {
onSearchResult: (result: string) => void;
disabled?: boolean;
}

interface WebSearchData {
title: string;
description: string;
content: string;
sourceUrl: string;
}

interface WebSearchResponse {
success: boolean;
data?: WebSearchData;
error?: string;
}

function formatSearchResult(data: WebSearchData): string {
const parts: string[] = [`[Web content from ${data.sourceUrl}]`];

if (data.title) {
parts.push(`Title: ${data.title}`);
}

if (data.description) {
parts.push(`Description: ${data.description}`);
}

parts.push('', data.content);

return parts.join('\n');
}

export function WebSearch({ onSearchResult, disabled = false }: WebSearchProps) {
const [isOpen, setIsOpen] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [url, setUrl] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
}
}, [isOpen]);

useEffect(() => {
if (!isOpen) {
return undefined;
}

const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};

document.addEventListener('mousedown', handleClickOutside);

return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);

const handleFetch = async () => {
const trimmedUrl = url.trim();

if (!trimmedUrl) {
return;
}

setIsSearching(true);

try {
const response = await fetch('/api/web-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: trimmedUrl }),
});

const result = (await response.json()) as WebSearchResponse;

if (!response.ok || !result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch URL content');
}

onSearchResult(formatSearchResult(result.data));
toast.success('URL content fetched');
setUrl('');
setIsOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to fetch URL');
} finally {
setIsSearching(false);
}
};

return (
<div ref={containerRef} className="relative">
<IconButton
title="Fetch URL content"
disabled={disabled || isSearching}
onClick={() => setIsOpen(!isOpen)}
className="transition-all"
>
{isSearching ? (
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin" />
) : (
<div className="i-ph:globe text-xl" />
)}
</IconButton>
{isOpen && (
<div
className={classNames(
'absolute bottom-full left-0 mb-2 flex items-center gap-2',
'rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-2 shadow-lg',
)}
>
<input
ref={inputRef}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isSearching) {
handleFetch();
}

if (e.key === 'Escape') {
setIsOpen(false);
}
}}
placeholder="https://example.com"
disabled={isSearching}
className={classNames(
'w-[300px] px-3 py-1.5 text-sm rounded-md',
'border border-bolt-elements-borderColor',
'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary',
'placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
)}
/>
<button
onClick={handleFetch}
disabled={isSearching || !url.trim()}
className={classNames(
'px-3 py-1.5 rounded-md text-sm font-medium whitespace-nowrap',
'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{isSearching ? 'Fetching...' : 'Fetch'}
</button>
</div>
)}
</div>
);
}
Loading
Loading