diff --git a/README.md b/README.md index 3d4460675..ad0e73b02 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ _Status: alpha. [Breaking changes](CHANGELOG.md) are expected until 1.0._ - 🔧 **Custom data models**: create your own classes, properties and schemas using the built-in Ontology Editor. All data is verified and the models are sharable using [Atomic Schema](https://docs.atomicdata.dev/schema/intro.html) - ⚙️ **Restful API**, with [JSON-AD](https://docs.atomicdata.dev/core/json-ad.html) responses. - 🔎 **Full-text search** with fuzzy search and various operators, often <3ms responses. Powered by [tantivy](https://github.com/quickwit-inc/tantivy). -- ✨ **AI** with [MCP](https://modelcontextprotocol.io/) support, use any model via OpenRouter or host your own with Ollama. +- ✨ **AI** with [MCP](https://modelcontextprotocol.io/) support — any OpenAI-compatible endpoint (OpenRouter, Ollama, Groq, …). - 🗄️ **Tables**, with strict schema validation, keyboard support, copy / paste support. Similar to Airtable. - 📄 **Documents**, collaborative, rich text, similar to Google Docs / Notion. - 💬 **Group chat**, performant and flexible message channels with attachments, search and replies. diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 7124adb46..b348ac7d1 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -4,6 +4,8 @@ This changelog covers all five packages, as they are (for now) updated as a whol ## UNRELEASED +- **One AI endpoint.** Chat and generative features use a single OpenAI-compatible base URL + API key. Presets cover OpenRouter, Ollama, OrcaRouter, Groq and OpenAI; anything else that speaks `/v1/chat/completions` works the same way. The old OpenRouter / Ollama provider split (and their separate SDKs) is gone. + ## [v0.41.0-beta.2] - 2026-08-01 ### Atomic Browser diff --git a/browser/data-browser/package.json b/browser/data-browser/package.json index 05018abfd..dd01d8410 100644 --- a/browser/data-browser/package.json +++ b/browser/data-browser/package.json @@ -5,6 +5,7 @@ "name": "Joep Meindertsma" }, "dependencies": { + "@ai-sdk/openai-compatible": "^2.0.63", "@ai-sdk/react": "^3.0.193", "@bugsnag/core": "^8.9.0", "@bugsnag/js": "^8.9.0", @@ -23,7 +24,6 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@noble/hashes": "^2.2.0", "@oddbird/css-anchor-positioning": "^0.9.0", - "@openrouter/ai-sdk-provider": "^2.9.0", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-tabs": "^1.1.13", @@ -67,7 +67,6 @@ "idb-keyval": "^6.2.4", "katex": "^0.16.45", "loro-crdt": "^1.12.1", - "ollama-ai-provider-v2": "^3.5.1", "polished": "^4.3.1", "prismjs": "^1.30.0", "quick-score": "^0.2.0", diff --git a/browser/data-browser/src/chunks/AI/AISetupPanel.tsx b/browser/data-browser/src/chunks/AI/AISetupPanel.tsx index 9b79eda78..c3da8ed72 100644 --- a/browser/data-browser/src/chunks/AI/AISetupPanel.tsx +++ b/browser/data-browser/src/chunks/AI/AISetupPanel.tsx @@ -2,151 +2,91 @@ import React, { Suspense, useEffect, useState } from 'react'; import styled from 'styled-components'; import { Column, Row } from '@components/Row'; import { useAISettings } from '@components/AI/AISettingsContext'; -import { AIProvider } from '@components/AI/aiContstants'; import { OpenRouterLoginButton } from '@components/AI/OpenRouterLoginButton'; import { InputStyled, InputWrapper } from '@components/forms/InputStyles'; import { OutlinedSection } from '@components/OutlinedSection'; -import { useIsOllamaUrlValid } from '@components/AI/useIsOllamaUrlValid'; import { ProviderStatus } from '@components/AI/ProviderStatus'; import { Button } from '@components/Button'; -import { effectFetch } from '@helpers/effectFetch'; -import { DEFAULT_CHAT_MODEL } from '@components/AI/AISettingsContext'; import type { AIModelIdentifier } from './types'; import { useLocalStorage } from '@hooks/useLocalStorage'; import { useAIAgentConfig } from './AgentConfig'; +import { + AI_ENDPOINT_PRESETS, + matchPreset, + OPENROUTER_BASE_URL, +} from './aiEndpoint'; +import { useAIModels } from './useAIModels'; const ModelSelect = React.lazy( () => import('@chunks/AI/ModelSelect/ModelSelect'), ); -type SetupStep = 'providers' | 'model'; +type SetupStep = 'endpoint' | 'model'; -type OllamaTag = { name: string; model: string }; - -const pickSuggestedOllamaModel = (models: OllamaTag[]): string | undefined => { - if (models.length === 0) { - return undefined; - } - - const preferred = models.find( - m => - /llama|qwen|mistral|gemma/i.test(m.model) && - !/embed|vision/i.test(m.model), - ); - - return (preferred ?? models[0]).model; -}; - -const getInitialPendingModel = ( - defaultChatModel: AIModelIdentifier, - openRouterAvailable: boolean, - isProviderAvailable: (provider: AIProvider) => boolean, -): AIModelIdentifier => { - if (isProviderAvailable(defaultChatModel.provider)) { - return defaultChatModel; - } - - if (openRouterAvailable) { - return DEFAULT_CHAT_MODEL; - } - - return defaultChatModel; -}; - -const getInitialStep = (hasProvider: boolean): SetupStep => { - if (sessionStorage.getItem('atomic.ai.openSetup') === 'true' && hasProvider) { +const getInitialStep = (configured: boolean): SetupStep => { + if (sessionStorage.getItem('atomic.ai.openSetup') === 'true' && configured) { return 'model'; } - return 'providers'; + return 'endpoint'; }; export const AISetupPanel: React.FC = () => { const { - openRouterApiKey, - setOpenRouterApiKey, - ollamaUrl, - setOllamaUrl, + aiBaseUrl, + setAiBaseUrl, + aiApiKey, + setAiApiKey, defaultChatModel, setDefaultChatModel, - isProviderAvailable, - availableProviders, - openRouterAvailable, - ollamaAvailable, + isAIAvailable, setGenFeaturesModel, } = useAISettings(); const { agents, saveAgents } = useAIAgentConfig(); + const { models, configured, reachable, checking } = useAIModels(); const [setupComplete, setSetupComplete] = useLocalStorage( 'atomic.ai.setupComplete', false, ); - const { checking: ollamaChecking } = useIsOllamaUrlValid(ollamaUrl); - const hasProvider = availableProviders.length > 0; const [step, setStep] = useState(() => - getInitialStep(hasProvider), - ); - const [pendingModel, setPendingModel] = useState(() => - getInitialPendingModel( - defaultChatModel, - openRouterAvailable, - isProviderAvailable, - ), + getInitialStep(isAIAvailable), ); + const [pendingModel, setPendingModel] = + useState(defaultChatModel); const [syncGenFeatures, setSyncGenFeatures] = useState(false); - useEffect(() => { - if (step !== 'model' || openRouterAvailable) { - return; - } + const preset = matchPreset(aiBaseUrl); + const isOpenRouter = + preset?.id === 'openrouter' || + aiBaseUrl?.replace(/\/+$/, '') === OPENROUTER_BASE_URL; - if (!ollamaAvailable || !ollamaUrl) { + useEffect(() => { + if (step !== 'model' || models.length === 0) { return; } - return effectFetch(`${ollamaUrl}/api/tags`)(data => { - const models = (data.models ?? []) as OllamaTag[]; - const suggestedId = pickSuggestedOllamaModel(models); - - if (!suggestedId) { - return; + setPendingModel(prev => { + if (models.some(m => m.id === prev.id)) { + return prev; } - setPendingModel(prev => { - if (isProviderAvailable(prev.provider)) { - return prev; - } - - return { id: suggestedId, provider: AIProvider.Ollama }; - }); + return { id: models[0].id }; }); - }, [ - step, - ollamaAvailable, - ollamaUrl, - openRouterAvailable, - isProviderAvailable, - ]); + }, [step, models]); if (setupComplete) { return null; } const handleContinue = () => { - setPendingModel( - getInitialPendingModel( - defaultChatModel, - openRouterAvailable, - isProviderAvailable, - ), - ); setStep('model'); sessionStorage.removeItem('atomic.ai.openSetup'); }; const handleBack = () => { - setStep('providers'); + setStep('endpoint'); }; const handleStartChatting = () => { @@ -174,7 +114,6 @@ export const AISetupPanel: React.FC = () => { @@ -192,10 +131,7 @@ export const AISetupPanel: React.FC = () => { - @@ -209,57 +145,60 @@ export const AISetupPanel: React.FC = () => { Connect a model to use Atomic Assistant - Use OpenRouter (cloud models) or Ollama (local models). At least one - provider must be connected before you can continue. + Point at any OpenAI-compatible endpoint (OpenRouter, Ollama, Groq, + OrcaRouter, …). You need a base URL before you can continue. - - - - - - {!openRouterApiKey && ( - - - or - - )} - - - setOpenRouterApiKey(e.target.value || undefined) - } - placeholder='Paste API key' - aria-label='OpenRouter API key' - /> - - - - - - - + + + + {AI_ENDPOINT_PRESETS.map(p => ( + setAiBaseUrl(p.baseUrl)} + > + {p.label} + + ))} + + + setAiBaseUrl(e.target.value || undefined)} + placeholder='https://openrouter.ai/api/v1' + aria-label='Model endpoint base URL' /> - + + + {isOpenRouter && !aiApiKey && ( + + + or + + )} + setOllamaUrl(e.target.value || undefined)} - placeholder='http://localhost:11434' - aria-label='Ollama URL' + type='password' + value={aiApiKey || ''} + onChange={e => setAiApiKey(e.target.value || undefined)} + placeholder={ + preset?.apiKeyPlaceholder ?? + (preset && !preset.requiresApiKey ? 'Optional' : 'API key') + } + aria-label='Model endpoint API key' /> - - - - - @@ -298,11 +237,6 @@ const ActionsRow = styled(Row)` flex-wrap: wrap; `; -const ProvidersGrid = styled(Column)` - gap: 1rem; -`; - -/** Full-width block inside OutlinedSection (its body is a horizontal flex row). */ const ProviderSection = styled(Column)` gap: 0.5rem; flex: 1 1 100%; @@ -350,6 +284,24 @@ const FullWidthField = styled(InputWrapper)` } `; +const PresetRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +`; + +const PresetChip = styled.button<{ $active: boolean }>` + appearance: none; + border: 1px solid + ${p => (p.$active ? p.theme.colors.main : p.theme.colors.bg2)}; + background: ${p => (p.$active ? p.theme.colors.main : p.theme.colors.bg)}; + color: ${p => (p.$active ? p.theme.colors.bg : p.theme.colors.text)}; + border-radius: ${p => p.theme.radius}; + padding: 0.2rem 0.5rem; + font-size: 0.75rem; + cursor: pointer; +`; + const Title = styled.h3` margin: 0; font-size: 1rem; diff --git a/browser/data-browser/src/chunks/AI/AgentConfigItem.tsx b/browser/data-browser/src/chunks/AI/AgentConfigItem.tsx index 47cd8e60b..33d11c8f5 100644 --- a/browser/data-browser/src/chunks/AI/AgentConfigItem.tsx +++ b/browser/data-browser/src/chunks/AI/AgentConfigItem.tsx @@ -49,12 +49,10 @@ export const AgentConfigItem: React.FC = ({ const theme = useTheme(); const { defaultAgentId, setDefaultAgentId } = useAIAgentConfig(); - const { isProviderAvailable, defaultChatModel } = useAISettings(); + const { isAIAvailable } = useAISettings(); const isDefault = defaultAgentId === agent.id; - const providerDisabled = agent.model - ? !isProviderAvailable(agent.model.provider) - : !isProviderAvailable(defaultChatModel.provider); + const providerDisabled = !isAIAvailable; const contextItems: DropdownItem[] = [ { @@ -121,7 +119,7 @@ export const AgentConfigItem: React.FC = ({ {providerDisabled && ( )} diff --git a/browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts b/browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts index 4734b3bb0..8c1392ac4 100644 --- a/browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts +++ b/browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts @@ -6,35 +6,27 @@ import { type ToolSet, type UIMessageChunk, } from 'ai'; -import { AIProvider } from '@components/AI/aiContstants'; import { type AIAgent, type AIModelIdentifier, type AtomicUIMessage, } from './types'; -import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { useRef } from 'react'; import { useStore } from '@tomic/react'; -import { createOllama } from 'ollama-ai-provider-v2'; -import { addFieldsIf } from '@helpers/addIf'; import { stringifyTree, useGetDriveStructure } from './useGetDriveStructure'; import { useSettings } from '@helpers/AppSettings'; import { shortenSubject } from '@helpers/subjectRefs'; import { getClassesOnDrive } from './atomicSchemaHelpers'; - -export type Modalities = 'text' | 'image'; +import { createEndpointModel, type AIEndpoint } from './aiEndpoint'; export interface ClientOnlyTransportOptions { - openRouterAPIKey?: string; - ollamaURL?: string; + endpoint: AIEndpoint; selectedAgent: AIAgent; model: AIModelIdentifier; tools: ToolSet; addContextToMessages: ( messages: AtomicUIMessage[], ) => Promise; - resolveOutputModalities: (modelId: string) => Modalities[]; - resolveParameterSupport: (modelId: string, parameter: string) => boolean; /** Appended after template substitution (e.g. skills instructions). */ additionalSystemPrompt?: string; } @@ -73,12 +65,12 @@ export class ClientOnlyTransport implements ChatTransport { const result = streamText({ messages: await convertToModelMessages(transformedMessages), - model: this.getModel(this.options.model), + model: createEndpointModel(this.options.model.id, this.options.endpoint), system: await this._prepareSystemPrompt(agent.systemPrompt), tools: this.options.tools, abortSignal, stopWhen: stepCountIs(1000), - ...this.getParameters(agent, this.options.model), + temperature: agent.temperature, }); const originalStream = result.toUIMessageStream({ @@ -102,67 +94,6 @@ export class ClientOnlyTransport implements ChatTransport { public async reconnectToStream(): Promise | null> { return null; } - - private getModel(model: AIModelIdentifier) { - if ( - model.provider === AIProvider.OpenRouter && - this.options.openRouterAPIKey - ) { - const modalities = this.options.resolveOutputModalities(model.id); - - const openRouter = createOpenRouter({ - apiKey: this.options.openRouterAPIKey, - compatibility: 'strict', - extraBody: { - modalities, - plugins: [{ id: 'context-compression' }], - }, - }); - - return openRouter(model.id); - } - - if (model.provider === AIProvider.Ollama && this.options.ollamaURL) { - const ollama = createOllama({ - baseURL: `${this.options.ollamaURL}/api`, - }); - - return ollama(model.id); - } - - throw new Error('Invalid model provider'); - } - - private getParameters(agent: AIAgent, model: AIModelIdentifier) { - if (model.provider === AIProvider.Ollama) { - // We can't check if Ollama supports specific parameters, so we just return all of them. - return { - temperature: agent.temperature, - }; - } - - if (model.provider === AIProvider.OpenRouter) { - return { - ...addFieldsIf( - this.options.resolveParameterSupport(model.id, 'temperature'), - { - temperature: agent.temperature, - }, - ), - ...addFieldsIf( - this.options.resolveParameterSupport(model.id, 'reasoning'), - { - reasoning: { - effort: 'low', - summary: 'auto', - }, - }, - ), - }; - } - - throw new Error('Invalid model provider'); - } } /** Returns messages starting from the last summary message (inclusive), or all messages if none. */ diff --git a/browser/data-browser/src/chunks/AI/ModelSelect/ModelSelect.tsx b/browser/data-browser/src/chunks/AI/ModelSelect/ModelSelect.tsx index 227c7a88a..fd754e028 100644 --- a/browser/data-browser/src/chunks/AI/ModelSelect/ModelSelect.tsx +++ b/browser/data-browser/src/chunks/AI/ModelSelect/ModelSelect.tsx @@ -1,81 +1,73 @@ import styled from 'styled-components'; -import { AIProvider } from '@components/AI/aiContstants'; import { type AIModelIdentifier } from '../types'; -import { OpenRouterModelSelector } from './OpenRouterModelSelector'; -import { TAB_PANEL_HAS_ERROR_CLASS, TabPanel, Tabs } from '@components/Tabs'; -import { OllamaModelSelector } from './OllamaModelSelector'; import { transition } from '@helpers/transition'; import { Link } from '@tanstack/react-router'; import { useAISettings } from '@components/AI/AISettingsContext'; +import { useAIModels } from '../useAIModels'; +import { ComboBox } from '@components/ComboBox'; +import { Column } from '@components/Row'; +import { useState } from 'react'; +import { ModelInfoLayout } from './ModelInfoLayout'; interface ModelSelectProps { onSelect?: (model: AIModelIdentifier) => void; defaultModel: AIModelIdentifier; + /** Kept for call-site compatibility; tool metadata is not exposed by most gateways. */ enforceToolSupport?: boolean; } -const PROVIDER_TABS = [ - { - label: 'OpenRouter', - value: AIProvider.OpenRouter, - }, - { - label: 'Ollama', - value: AIProvider.Ollama, - }, -]; +export const ModelSelect = ({ onSelect, defaultModel }: ModelSelectProps) => { + const { isAIAvailable, aiBaseUrl } = useAISettings(); + const { models } = useAIModels(); + const [selectedId, setSelectedId] = useState(defaultModel.id); -export const ModelSelect = ({ - onSelect, - defaultModel, - enforceToolSupport = false, -}: ModelSelectProps) => { - const { openRouterApiKey, ollamaUrl } = useAISettings(); + if (!isAIAvailable) { + return ( + + + + Model endpoint is not configured. Go to{' '} + Settings. + + + + ); + } + + const options = models.map(model => ({ + label: model.name ?? model.id, + searchLabel: (model.name ?? model.id).toLowerCase(), + value: model.id, + })); return ( - - - {openRouterApiKey ? ( - { - onSelect?.(model); - }} - defaultModel={defaultModel.id} - /> - ) : ( - - - OpenRouter API key is not configured. Go to{' '} - Settings. - - - )} - - - {ollamaUrl ? ( - { - onSelect?.(model); - }} - selectedModel={defaultModel} - /> - ) : ( - - - Ollama URL is not configured. Go to{' '} - Settings. - - - )} - - + + + {models.length} Models + { + const id = value ?? defaultModel.id; + setSelectedId(id); + onSelect?.({ id }); + }} + /> + + {selectedId ? ( + + Models from {aiBaseUrl ?? 'your endpoint'} via the + OpenAI-compatible API. + + } + /> + ) : ( + Select a model + )} + ); }; @@ -83,27 +75,30 @@ export const ModelSelect = ({ const Wrapper = styled.div` background-color: ${p => p.theme.colors.bg}; border-radius: ${p => p.theme.radius}; - border: 1px solid ${p => p.theme.colors.bg2}; ${transition('border-color')} - &:has(*.${TAB_PANEL_HAS_ERROR_CLASS}) { - border: 1px solid ${p => p.theme.colors.alert}; - } `; -const StyledTabPanel = styled(TabPanel)` +const Panel = styled.div` padding: ${p => p.theme.size()}; - padding-top: unset; `; const NotConfiguredMessage = styled.div` display: grid; place-items: center; - margin: -${p => p.theme.size()}; padding: ${p => p.theme.size()}; background-color: ${p => p.theme.colors.bgBody}; border-radius: ${p => p.theme.radius}; color: ${p => p.theme.colors.textLight}; `; +const ModelAmount = styled.div` + font-size: 0.8em; + color: ${p => p.theme.colors.textLight}; +`; + +const Subtle = styled.div` + color: ${p => p.theme.colors.textLight}; +`; + export default ModelSelect; diff --git a/browser/data-browser/src/chunks/AI/ModelSelect/OllamaModelSelector.tsx b/browser/data-browser/src/chunks/AI/ModelSelect/OllamaModelSelector.tsx deleted file mode 100644 index 1ebaba66c..000000000 --- a/browser/data-browser/src/chunks/AI/ModelSelect/OllamaModelSelector.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import styled from 'styled-components'; -import { ComboBox } from '@components/ComboBox'; -import { Column } from '@components/Row'; -import { useEffect, useState } from 'react'; -import { AIProvider } from '@components/AI/aiContstants'; -import { type AIModelIdentifier } from '../types'; -import { ModelInfoLayout } from './ModelInfoLayout'; -import { ErrorLook } from '@components/ErrorLook'; -import { TAB_PANEL_HAS_ERROR_CLASS } from '@components/Tabs'; -import { LoaderBlock } from '@components/Loader'; -import { effectFetch } from '@helpers/effectFetch'; -import { useAISettings } from '@components/AI/AISettingsContext'; - -type OllamaModel = { - name: string; - model: string; - size: number; - details: { - format: string; - parent_model: string; - family: string; - parameter_size: string; - quantization_level: string; - }; -}; - -interface OllamaModelSelectorProps { - onSelect: (model: AIModelIdentifier) => void; - selectedModel: AIModelIdentifier; -} - -let modelCache: OllamaModel[] = []; - -export const OllamaModelSelector: React.FC = ({ - onSelect, - selectedModel, -}) => { - const { ollamaUrl, isProviderAvailable } = useAISettings(); - - const [error, setError] = useState(); - const [loading, setLoading] = useState(modelCache.length === 0); - const [models, setModels] = useState(modelCache); - - const currentModel = models.find(m => m.model === selectedModel.id); - - const options = models.map(model => ({ - label: model.name, - searchLabel: model.model.toLowerCase(), - value: model.model, - })); - - useEffect(() => { - return effectFetch(`${ollamaUrl}/api/tags`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - })( - data => { - setError(undefined); - setModels(data.models); - setLoading(false); - modelCache = data.models; - }, - e => { - console.error(e); - setError( - 'Unable to connect to Ollama server. Check if the server is running and you configured the correct url in the settings', - ); - }, - ); - }, [ollamaUrl]); - - if (error) { - return {error}; - } - - if (loading) { - return Loading...; - } - - if (!isProviderAvailable(AIProvider.Ollama)) { - return ( - - Check your Ollama URL and that the server is running - - ); - } - - return ( - - - {models.length} Models - { - const newVal = { - id: value ?? selectedModel.id, - provider: AIProvider.Ollama, - }; - onSelect(newVal); - }} - /> - - {currentModel ? ( - - Format: - {currentModel.details.format} - Parent Model: - {currentModel.details.parent_model || '-'} - Family: - {currentModel.details.family} - Parameter Size: - {currentModel.details.parameter_size} - - } - /> - ) : ( - Select a model - )} - - ); -}; - -const ModelAmount = styled.div` - font-size: 0.8em; - color: ${p => p.theme.colors.textLight}; -`; - -const ModelDetailsTable = styled.div` - display: grid; - grid-template-columns: auto 1fr; - gap: ${p => p.theme.size()}; - row-gap: 0.2rem; -`; diff --git a/browser/data-browser/src/chunks/AI/ModelSelect/OpenRouterModelSelector.tsx b/browser/data-browser/src/chunks/AI/ModelSelect/OpenRouterModelSelector.tsx deleted file mode 100644 index 9d984af50..000000000 --- a/browser/data-browser/src/chunks/AI/ModelSelect/OpenRouterModelSelector.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import styled from 'styled-components'; -import { ComboBox } from '@components/ComboBox'; -import { Column, Row } from '@components/Row'; -import { useState } from 'react'; -import { useOpenRouterModels } from '../useOpenRouterModels'; -import { AIProvider } from '@components/AI/aiContstants'; -import { type AIModelIdentifier } from '../types'; -import { FaTriangleExclamation } from 'react-icons/fa6'; -import { ModelInfoLayout } from './ModelInfoLayout'; -import Markdown from '@components/datatypes/Markdown'; -import { useAISettings } from '@components/AI/AISettingsContext'; - -interface OpenRouterModelSelectorProps { - onSelect: (model: AIModelIdentifier) => void; - defaultModel: string; - enforceToolSupport?: boolean; -} - -const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, -}); - -export const OpenRouterModelSelector: React.FC< - OpenRouterModelSelectorProps -> = ({ onSelect, defaultModel, enforceToolSupport = false }) => { - const { models } = useOpenRouterModels(); - const { isProviderAvailable } = useAISettings(); - const [selectedId, setSelectedId] = useState(defaultModel); - const selectedModel = models.find(m => m.id === selectedId); - - const modelList = enforceToolSupport - ? models.filter(m => m.supported_parameters.includes('tools')) - : models; - - const showSupportWarning = - selectedModel && !modelList.includes(selectedModel); - - const options = modelList.map(model => ({ - label: model.name, - searchLabel: model.name.toLowerCase(), - value: model.id, - })); - - if (!isProviderAvailable(AIProvider.OpenRouter)) { - return ( - - Add an OpenRouter API key in settings - - ); - } - - return ( - - - {modelList.length} Models - { - const newVal = { - id: value ?? defaultModel, - provider: AIProvider.OpenRouter, - }; - setSelectedId(newVal.id); - onSelect?.(newVal); - }} - /> - {showSupportWarning && ( - - - The selected model does not support tool use. - - )} - - {selectedModel ? ( - - - {formatter.format(selectedModel?.pricing.prompt * 1000000)}/M - input tokens - - - {formatter.format(selectedModel?.pricing.completion * 1000000)} - /M output tokens - - {selectedModel.supported_parameters.includes( - 'web_search_options', - ) && ( - - {formatter.format(selectedModel?.pricing.web_search * 1000)} - /1K web search results - - )} - - } - About={} - /> - ) : ( - Select a model - )} - - ); -}; - -const ModelAmount = styled.div` - font-size: 0.8em; - color: ${p => p.theme.colors.textLight}; -`; - -const SupportWarning = styled(Row)` - color: ${p => p.theme.colors.warning}; -`; diff --git a/browser/data-browser/src/chunks/AI/RealAIChat.tsx b/browser/data-browser/src/chunks/AI/RealAIChat.tsx index 285cdd96f..2e4fd44c4 100644 --- a/browser/data-browser/src/chunks/AI/RealAIChat.tsx +++ b/browser/data-browser/src/chunks/AI/RealAIChat.tsx @@ -12,7 +12,6 @@ import { Button } from '@components/Button'; import { FaXmark, FaPaperclip, FaFile } from 'react-icons/fa6'; import { ChatMessagesContainer } from '@components/ChatMessagesContainer'; import { useStore, type Resource } from '@tomic/react'; -import { AIProvider } from '@components/AI/aiContstants'; import { AIAgent, type AIAtomicResourceMessageContext, @@ -27,23 +26,10 @@ import { AISettingsDialog } from './AISettingsDialog'; import { MessageContextItem } from './MessageContextItem'; import { ComboBox } from '@components/ComboBox'; -import { effectFetch } from '@helpers/effectFetch'; - -type OllamaModel = { - name: string; - model: string; - size: number; - details: { - format: string; - parent_model: string; - family: string; - parameter_size: string; - quantization_level: string; - }; -}; import { useProcessMessages } from './useProcessMessages'; import { AISetupPanel } from './AISetupPanel'; -import { useOpenRouterModels } from './useOpenRouterModels'; +import { useAIModels } from './useAIModels'; +import { matchPreset, normalizeModelIdValue } from './aiEndpoint'; import { getAutoCompactTokenThreshold, useModelContextLength, @@ -130,12 +116,17 @@ const RealAIChatInner: React.FC> = ({ }) => { const store = useStore(); const { - openRouterApiKey, + aiBaseUrl, + aiApiKey, showTokenUsage, showFollowUpPrompts, - ollamaUrl, - isProviderAvailable, + isAIAvailable, + defaultChatModel, + setDefaultChatModel, } = useAISettings(); + const { models: endpointModels } = useAIModels(); + const endpointLabel = + matchPreset(aiBaseUrl)?.label ?? aiBaseUrl ?? 'the model endpoint'; // useChat does not update it's options so we need to use a ref to make it use the latest value. const showFollowUpPromptsRef = useRef(showFollowUpPrompts); @@ -151,101 +142,32 @@ const RealAIChatInner: React.FC> = ({ setLastUsedSidebarAgent, } = useAIAgentConfig(); const getToolsForAgent = useTools(); - const { - checkORModelSupportsImageInput, - checkORModelSupport, - getOutputModalities, - models: openRouterModels, - } = useOpenRouterModels(); - - const [ollamaModels, setOllamaModels] = useState([]); - useEffect(() => { - if (!ollamaUrl) { - setOllamaModels([]); - - return; - } - - return effectFetch(`${ollamaUrl}/api/tags`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - })( - data => { - setOllamaModels(data.models || []); - }, - e => { - console.error('Failed to fetch Ollama models:', e); - setOllamaModels([]); - }, - ); - }, [ollamaUrl]); - - const currencyFormatter = useRef( - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - }), - ).current; - - // Most recently selected model values (`provider:id`), most-recent-first. + // Most recently selected model ids, most-recent-first. const [recentModelValues, setRecentModelValues] = useLocalStorage( 'atomic.ai.recentModels', [], ); const combinedModelOptions = useMemo(() => { - const openRouterOptions = openRouterModels.map(model => { - const promptPrice = - model.pricing?.prompt !== undefined - ? `${currencyFormatter.format(model.pricing.prompt * 1000000)}/M input` - : ''; - const completionPrice = - model.pricing?.completion !== undefined - ? `${currencyFormatter.format(model.pricing.completion * 1000000)}/M output` - : ''; - const pricingStr = [promptPrice, completionPrice] - .filter(Boolean) - .join(' • '); - - return { - label: model.name, - // Include the provider so searching "openrouter" surfaces all of them. - searchLabel: `${model.name.toLowerCase()} openrouter`, - // Show the provider in the subtitle alongside the cost. - description: ['OpenRouter', pricingStr].filter(Boolean).join(' • '), - value: `openrouter:${model.id}`, - }; - }); - - const ollamaOptions = ollamaModels.map(model => { - const details = [ - 'Ollama (local)', - model.details?.parameter_size - ? `Size: ${model.details.parameter_size}` + const all = endpointModels.map(model => ({ + label: model.name ?? model.id, + searchLabel: (model.name ?? model.id).toLowerCase(), + description: [ + endpointLabel, + model.context_length + ? `Context: ${model.context_length.toLocaleString()}` : '', - model.details?.format ? `Format: ${model.details.format}` : '', ] .filter(Boolean) - .join(' • '); - - return { - label: model.name, - // Include the provider so searching "ollama" surfaces all of them. - searchLabel: `${model.name.toLowerCase()} ollama`, - description: details, - value: `ollama:${model.model}`, - }; - }); - - const all = [...openRouterOptions, ...ollamaOptions]; + .join(' • '), + value: model.id, + })); + const normalizedRecent = recentModelValues.map(normalizeModelIdValue); // Surface recently used models at the top. With an empty query the ComboBox // shows the options in this order; once the user types, QuickScore re-ranks. - const recentRank = new Map(recentModelValues.map((value, i) => [value, i])); + const recentRank = new Map(normalizedRecent.map((value, i) => [value, i])); const recent = all .filter(option => recentRank.has(option.value)) .sort( @@ -255,7 +177,7 @@ const RealAIChatInner: React.FC> = ({ const rest = all.filter(option => !recentRank.has(option.value)); return [...recent, ...rest]; - }, [openRouterModels, ollamaModels, currencyFormatter, recentModelValues]); + }, [endpointModels, endpointLabel, recentModelValues]); const modelSelectContainerRef = useRef(null); @@ -265,12 +187,13 @@ const RealAIChatInner: React.FC> = ({ const [userInput, setUserInput] = useState(''); const [attachedFiles, setAttachedFiles] = useState([]); - const { defaultChatModel, setDefaultChatModel } = useAISettings(); const [selectedAgent, setSelectedAgent] = useState( getInitialAgent(!chatSubject, chatSubject), ); const [activeModel, setActiveModel] = useState(() => { - return selectedAgent.model ?? defaultChatModel; + const raw = selectedAgent.model ?? defaultChatModel; + + return { id: raw.id }; }); const modelContextLength = useModelContextLength(activeModel); const vectorIndexing = useVectorIndexStatus(); @@ -283,11 +206,8 @@ const RealAIChatInner: React.FC> = ({ }); const { reportAIEdit } = useAIChanges(); - // Whether the active model's provider can actually be reached. We NEVER use - // this to block typing (a user must always be able to compose a message, even - // while a provider is unreachable or still being set up) — only to disable the - // SEND and to surface a clear reason. - const canUseInput = isProviderAvailable(activeModel.provider); + // We NEVER use this to block typing — only to disable SEND and surface a reason. + const canUseInput = isAIAvailable; // Re-opens the provider setup overlay (`AISetupPanel` renders while // `atomic.ai.setupComplete` is false). @@ -305,21 +225,9 @@ const RealAIChatInner: React.FC> = ({ undefined, ); - /** Names the thing that didn't answer, since "no answer" alone helps nobody. */ - const providerLabel = - activeModel.provider === AIProvider.Ollama - ? `Ollama${ollamaUrl ? ` at ${ollamaUrl}` : ''}` - : activeModel.provider === AIProvider.OpenRouter - ? 'OpenRouter' - : 'the model provider'; - const providerNotice = canUseInput ? null - : activeModel.provider === AIProvider.Ollama - ? `Can't reach Ollama${ollamaUrl ? ` at ${ollamaUrl}` : ''}. Make sure it's running, or switch to a cloud model — you can keep typing in the meantime.` - : activeModel.provider === AIProvider.OpenRouter - ? 'No OpenRouter API key is set. Add one or switch to a local Ollama model — you can keep typing in the meantime.' - : 'No AI model provider is available. Set one up to send — you can keep typing in the meantime.'; + : 'No model endpoint is configured. Add a base URL (and API key if needed) — you can keep typing in the meantime.'; const [userSelectedContextItems, setUserSelectedContextItems] = useState< AIMessageContext[] @@ -339,8 +247,10 @@ const RealAIChatInner: React.FC> = ({ }); const transport = useClientOnlyTransport({ - openRouterAPIKey: openRouterApiKey, - ollamaURL: ollamaUrl, + endpoint: { + baseUrl: aiBaseUrl ?? '', + apiKey: aiApiKey, + }, selectedAgent, model: activeModel, additionalSystemPrompt: selectedAgent.skillsEnabled @@ -352,8 +262,6 @@ const RealAIChatInner: React.FC> = ({ ...(selectedAgent.skillsEnabled ? skillTools : {}), ...getToolsForAgent(selectedAgent), }, - resolveOutputModalities: getOutputModalities, - resolveParameterSupport: checkORModelSupport, addContextToMessages, }); @@ -495,14 +403,9 @@ const RealAIChatInner: React.FC> = ({ setUserSelectedContextItems(newContextItems); }; - const checkModelSupportsImageInput = (model: AIModelIdentifier) => { - if (model.provider === AIProvider.OpenRouter) { - return checkORModelSupportsImageInput(model.id); - } - - // We can't know if an ollama is multimodal so we'll just assume it is and have the model handle the failure case. - return true; - }; + // Most gateways don't advertise modalities on /models; let the model reject + // unsupported image parts rather than hiding the attach button. + const checkModelSupportsImageInput = (_model: AIModelIdentifier) => true; // Combine both context item lists when needed const allContextItems = [ @@ -738,7 +641,7 @@ const RealAIChatInner: React.FC> = ({ {requestError && ( - {`No answer from ${providerLabel}: ${requestError}`} + {`No answer from ${endpointLabel}: ${requestError}`}