Skip to content
Draft
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions browser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions browser/data-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
248 changes: 100 additions & 148 deletions browser/data-browser/src/chunks/AI/AISetupPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetupStep>(() =>
getInitialStep(hasProvider),
);
const [pendingModel, setPendingModel] = useState<AIModelIdentifier>(() =>
getInitialPendingModel(
defaultChatModel,
openRouterAvailable,
isProviderAvailable,
),
getInitialStep(isAIAvailable),
);
const [pendingModel, setPendingModel] =
useState<AIModelIdentifier>(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 = () => {
Expand Down Expand Up @@ -174,7 +114,6 @@ export const AISetupPanel: React.FC = () => {
<ModelSelect
defaultModel={pendingModel}
onSelect={setPendingModel}
enforceToolSupport
/>
</Suspense>
<CheckboxRow>
Expand All @@ -192,10 +131,7 @@ export const AISetupPanel: React.FC = () => {
<Button subtle onClick={handleBack}>
Back
</Button>
<Button
onClick={handleStartChatting}
disabled={!isProviderAvailable(pendingModel.provider)}
>
<Button onClick={handleStartChatting} disabled={!isAIAvailable}>
Start chatting
</Button>
</ActionsRow>
Expand All @@ -209,57 +145,60 @@ export const AISetupPanel: React.FC = () => {
<Panel>
<Title>Connect a model to use Atomic Assistant</Title>
<Subtle>
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.
</Subtle>
<ProvidersGrid>
<OutlinedSection title='OpenRouter'>
<ProviderSection>
<ProviderStatus
connected={openRouterAvailable}
configured={Boolean(openRouterApiKey)}
/>
<CredentialsRow>
{!openRouterApiKey && (
<OpenRouterLoginGroup>
<OpenRouterLoginButton />
<OrText>or</OrText>
</OpenRouterLoginGroup>
)}
<ApiKeyField>
<InputStyled
type='password'
value={openRouterApiKey || ''}
onChange={e =>
setOpenRouterApiKey(e.target.value || undefined)
}
placeholder='Paste API key'
aria-label='OpenRouter API key'
/>
</ApiKeyField>
</CredentialsRow>
</ProviderSection>
</OutlinedSection>
<OutlinedSection title='Ollama'>
<ProviderSection>
<ProviderStatus
connected={ollamaAvailable}
configured={Boolean(ollamaUrl)}
checking={ollamaChecking}
<OutlinedSection title='Model endpoint'>
<ProviderSection>
<ProviderStatus
connected={reachable}
configured={configured}
checking={checking}
/>
<PresetRow>
{AI_ENDPOINT_PRESETS.map(p => (
<PresetChip
key={p.id}
type='button'
$active={preset?.id === p.id}
onClick={() => setAiBaseUrl(p.baseUrl)}
>
{p.label}
</PresetChip>
))}
</PresetRow>
<FullWidthField>
<InputStyled
type='url'
value={aiBaseUrl || ''}
onChange={e => setAiBaseUrl(e.target.value || undefined)}
placeholder='https://openrouter.ai/api/v1'
aria-label='Model endpoint base URL'
/>
<FullWidthField>
</FullWidthField>
<CredentialsRow>
{isOpenRouter && !aiApiKey && (
<OpenRouterLoginGroup>
<OpenRouterLoginButton />
<OrText>or</OrText>
</OpenRouterLoginGroup>
)}
<ApiKeyField>
<InputStyled
type='url'
value={ollamaUrl || ''}
onChange={e => 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'
/>
</FullWidthField>
</ProviderSection>
</OutlinedSection>
</ProvidersGrid>
<Button onClick={handleContinue} disabled={!hasProvider}>
</ApiKeyField>
</CredentialsRow>
</ProviderSection>
</OutlinedSection>
<Button onClick={handleContinue} disabled={!isAIAvailable}>
Continue
</Button>
</Panel>
Expand Down Expand Up @@ -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%;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading