forked from akai/readest
853 lines
32 KiB
TypeScript
853 lines
32 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { PiCheckCircle, PiWarningCircle, PiArrowsClockwise, PiSpinner } from 'react-icons/pi';
|
|
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { getAIProvider } from '@/services/ai/providers';
|
|
import {
|
|
fetchOpenRouterModels,
|
|
type OpenRouterModelInfo,
|
|
} from '@/services/ai/providers/OpenRouterProvider';
|
|
import { DEFAULT_AI_SETTINGS, GATEWAY_MODELS, MODEL_PRICING } from '@/services/ai/constants';
|
|
import type { AISettings, AIProviderName } from '@/services/ai/types';
|
|
import { exportReedyMetricsBundle } from '@/services/reedy/instrumentation';
|
|
import { isTauriAppPlatform } from '@/services/environment';
|
|
import { BoxedList, SettingLabel, SettingsRow, SettingsSwitchRow } from './primitives';
|
|
|
|
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
|
|
type CustomModelStatus = 'idle' | 'validating' | 'valid' | 'invalid';
|
|
|
|
const CUSTOM_MODEL_VALUE = '__custom__';
|
|
|
|
interface ModelOption {
|
|
id: string;
|
|
label: string;
|
|
inputCost: string;
|
|
outputCost: string;
|
|
}
|
|
|
|
const getModelOptions = (): ModelOption[] => [
|
|
{
|
|
id: GATEWAY_MODELS.GEMINI_FLASH_LITE,
|
|
label: 'Gemini 2.5 Flash Lite',
|
|
inputCost: MODEL_PRICING[GATEWAY_MODELS.GEMINI_FLASH_LITE]?.input ?? '?',
|
|
outputCost: MODEL_PRICING[GATEWAY_MODELS.GEMINI_FLASH_LITE]?.output ?? '?',
|
|
},
|
|
{
|
|
id: GATEWAY_MODELS.GPT_5_NANO,
|
|
label: 'GPT-5 Nano',
|
|
inputCost: MODEL_PRICING[GATEWAY_MODELS.GPT_5_NANO]?.input ?? '?',
|
|
outputCost: MODEL_PRICING[GATEWAY_MODELS.GPT_5_NANO]?.output ?? '?',
|
|
},
|
|
{
|
|
id: GATEWAY_MODELS.LLAMA_4_SCOUT,
|
|
label: 'Llama 4 Scout',
|
|
inputCost: MODEL_PRICING[GATEWAY_MODELS.LLAMA_4_SCOUT]?.input ?? '?',
|
|
outputCost: MODEL_PRICING[GATEWAY_MODELS.LLAMA_4_SCOUT]?.output ?? '?',
|
|
},
|
|
{
|
|
id: GATEWAY_MODELS.GROK_4_1_FAST,
|
|
label: 'Grok 4.1 Fast',
|
|
inputCost: MODEL_PRICING[GATEWAY_MODELS.GROK_4_1_FAST]?.input ?? '?',
|
|
outputCost: MODEL_PRICING[GATEWAY_MODELS.GROK_4_1_FAST]?.output ?? '?',
|
|
},
|
|
{
|
|
id: GATEWAY_MODELS.DEEPSEEK_V3_2,
|
|
label: 'DeepSeek V3.2',
|
|
inputCost: MODEL_PRICING[GATEWAY_MODELS.DEEPSEEK_V3_2]?.input ?? '?',
|
|
outputCost: MODEL_PRICING[GATEWAY_MODELS.DEEPSEEK_V3_2]?.output ?? '?',
|
|
},
|
|
{
|
|
id: GATEWAY_MODELS.QWEN_3_235B,
|
|
label: 'Qwen 3 235B',
|
|
inputCost: MODEL_PRICING[GATEWAY_MODELS.QWEN_3_235B]?.input ?? '?',
|
|
outputCost: MODEL_PRICING[GATEWAY_MODELS.QWEN_3_235B]?.output ?? '?',
|
|
},
|
|
];
|
|
|
|
const AIPanel: React.FC = () => {
|
|
const _ = useTranslation();
|
|
const { envConfig, appService } = useEnv();
|
|
const { settings, setSettings, saveSettings } = useSettingsStore();
|
|
|
|
const aiSettings: AISettings = settings?.aiSettings ?? DEFAULT_AI_SETTINGS;
|
|
|
|
const [enabled, setEnabled] = useState(aiSettings.enabled);
|
|
const [reedyEnabled, setReedyEnabled] = useState(aiSettings.reedy?.enabled ?? false);
|
|
const [reedyAgentRuntime, setReedyAgentRuntime] = useState(
|
|
(aiSettings.reedy?.runtime ?? 'mvp') === 'agent',
|
|
);
|
|
const [provider, setProvider] = useState<AIProviderName>(aiSettings.provider);
|
|
const [ollamaUrl, setOllamaUrl] = useState(aiSettings.ollamaBaseUrl);
|
|
const [ollamaModel, setOllamaModel] = useState(aiSettings.ollamaModel);
|
|
const [ollamaEmbeddingModel, setOllamaEmbeddingModel] = useState(aiSettings.ollamaEmbeddingModel);
|
|
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
|
const [fetchingModels, setFetchingModels] = useState(false);
|
|
const [gatewayKey, setGatewayKey] = useState(aiSettings.aiGatewayApiKey ?? '');
|
|
|
|
// ---- OpenRouter (OpenAI-compatible) state ----
|
|
const [openrouterKey, setOpenrouterKey] = useState(aiSettings.openrouterApiKey ?? '');
|
|
const [openrouterUrl, setOpenrouterUrl] = useState(
|
|
aiSettings.openrouterBaseUrl ?? DEFAULT_AI_SETTINGS.openrouterBaseUrl ?? '',
|
|
);
|
|
const [openrouterModel, setOpenrouterModel] = useState(aiSettings.openrouterModel ?? '');
|
|
const [openrouterEmbeddingModel, setOpenrouterEmbeddingModel] = useState(
|
|
aiSettings.openrouterEmbeddingModel ?? '',
|
|
);
|
|
const [openrouterModels, setOpenrouterModels] = useState<OpenRouterModelInfo[]>([]);
|
|
const [openrouterFetchingModels, setOpenrouterFetchingModels] = useState(false);
|
|
const [openrouterModelsError, setOpenrouterModelsError] = useState('');
|
|
|
|
const savedCustomModel = aiSettings.aiGatewayCustomModel ?? '';
|
|
const savedModel = aiSettings.aiGatewayModel ?? DEFAULT_AI_SETTINGS.aiGatewayModel ?? '';
|
|
const isCustomModelSaved = savedCustomModel.length > 0;
|
|
|
|
const [selectedModel, setSelectedModel] = useState(
|
|
isCustomModelSaved ? CUSTOM_MODEL_VALUE : savedModel,
|
|
);
|
|
const [customModelInput, setCustomModelInput] = useState(savedCustomModel);
|
|
const [customModelStatus, setCustomModelStatus] = useState<CustomModelStatus>(
|
|
isCustomModelSaved ? 'valid' : 'idle',
|
|
);
|
|
const [customModelPricing, setCustomModelPricing] = useState<{
|
|
input: string;
|
|
output: string;
|
|
} | null>(isCustomModelSaved ? { input: '?', output: '?' } : null);
|
|
const [customModelError, setCustomModelError] = useState('');
|
|
|
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle');
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
|
|
const isMounted = useRef(false);
|
|
const modelOptions = getModelOptions();
|
|
|
|
const settingsRef = useRef(settings);
|
|
useEffect(() => {
|
|
settingsRef.current = settings;
|
|
}, [settings]);
|
|
|
|
const saveAiSetting = useCallback(
|
|
async (key: keyof AISettings, value: AISettings[keyof AISettings]) => {
|
|
const currentSettings = settingsRef.current;
|
|
if (!currentSettings) return;
|
|
const currentAiSettings: AISettings = currentSettings.aiSettings ?? DEFAULT_AI_SETTINGS;
|
|
const newAiSettings: AISettings = { ...currentAiSettings, [key]: value };
|
|
const newSettings = { ...currentSettings, aiSettings: newAiSettings };
|
|
|
|
setSettings(newSettings);
|
|
await saveSettings(envConfig, newSettings);
|
|
},
|
|
[envConfig, setSettings, saveSettings],
|
|
);
|
|
|
|
const fetchOllamaModels = useCallback(async () => {
|
|
if (!ollamaUrl || !enabled) return;
|
|
|
|
setFetchingModels(true);
|
|
try {
|
|
const response = await fetch(`${ollamaUrl}/api/tags`);
|
|
if (!response.ok) throw new Error('Failed to fetch models');
|
|
const data = await response.json();
|
|
const models = data.models?.map((m: { name: string }) => m.name) || [];
|
|
|
|
setOllamaModels(models);
|
|
if (models.length > 0 && !models.includes(ollamaModel)) {
|
|
setOllamaModel(models[0]!);
|
|
}
|
|
} catch (_err) {
|
|
setOllamaModels([]);
|
|
} finally {
|
|
setFetchingModels(false);
|
|
}
|
|
}, [ollamaUrl, ollamaModel, enabled]);
|
|
|
|
useEffect(() => {
|
|
if (provider === 'ollama' && enabled) {
|
|
fetchOllamaModels();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [provider, enabled, ollamaUrl]);
|
|
|
|
// ---- OpenRouter: fetch /models list ----
|
|
const fetchOpenrouterModelList = useCallback(async () => {
|
|
if (!enabled || !openrouterUrl || !openrouterKey) {
|
|
setOpenrouterModels([]);
|
|
return;
|
|
}
|
|
setOpenrouterFetchingModels(true);
|
|
setOpenrouterModelsError('');
|
|
try {
|
|
const models = await fetchOpenRouterModels(openrouterUrl, openrouterKey);
|
|
// Sort by id for a stable picker. Keep raw entries — UI uses
|
|
// `name || id` so OpenRouter's friendly labels still show up.
|
|
models.sort((a, b) => a.id.localeCompare(b.id));
|
|
setOpenrouterModels(models);
|
|
if (models.length > 0 && !models.some((m) => m.id === openrouterModel)) {
|
|
setOpenrouterModel(models[0]!.id);
|
|
}
|
|
} catch (e) {
|
|
setOpenrouterModels([]);
|
|
setOpenrouterModelsError((e as Error).message || _('Failed to fetch models'));
|
|
} finally {
|
|
setOpenrouterFetchingModels(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [enabled, openrouterUrl, openrouterKey, openrouterModel]);
|
|
|
|
useEffect(() => {
|
|
if (provider === 'openrouter' && enabled && openrouterKey && openrouterUrl) {
|
|
fetchOpenrouterModelList();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [provider, enabled, openrouterKey, openrouterUrl]);
|
|
|
|
useEffect(() => {
|
|
isMounted.current = true;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (enabled !== aiSettings.enabled) {
|
|
saveAiSetting('enabled', enabled);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [enabled]);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (provider !== aiSettings.provider) {
|
|
saveAiSetting('provider', provider);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [provider]);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (ollamaUrl !== aiSettings.ollamaBaseUrl) {
|
|
saveAiSetting('ollamaBaseUrl', ollamaUrl);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ollamaUrl]);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (ollamaModel !== aiSettings.ollamaModel) {
|
|
saveAiSetting('ollamaModel', ollamaModel);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ollamaModel]);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (ollamaEmbeddingModel !== aiSettings.ollamaEmbeddingModel) {
|
|
saveAiSetting('ollamaEmbeddingModel', ollamaEmbeddingModel);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ollamaEmbeddingModel]);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (gatewayKey !== (aiSettings.aiGatewayApiKey ?? '')) {
|
|
saveAiSetting('aiGatewayApiKey', gatewayKey);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [gatewayKey]);
|
|
|
|
// ---- OpenRouter save effects ----
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (openrouterKey !== (aiSettings.openrouterApiKey ?? '')) {
|
|
saveAiSetting('openrouterApiKey', openrouterKey);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [openrouterKey]);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (openrouterUrl !== (aiSettings.openrouterBaseUrl ?? '')) {
|
|
saveAiSetting('openrouterBaseUrl', openrouterUrl);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [openrouterUrl]);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (openrouterModel !== (aiSettings.openrouterModel ?? '')) {
|
|
saveAiSetting('openrouterModel', openrouterModel);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [openrouterModel]);
|
|
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
if (openrouterEmbeddingModel !== (aiSettings.openrouterEmbeddingModel ?? '')) {
|
|
saveAiSetting('openrouterEmbeddingModel', openrouterEmbeddingModel);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [openrouterEmbeddingModel]);
|
|
|
|
// Get the effective model ID to use (either selected or custom)
|
|
const getEffectiveModelId = useCallback(() => {
|
|
if (selectedModel === CUSTOM_MODEL_VALUE && customModelStatus === 'valid') {
|
|
return customModelInput;
|
|
}
|
|
return selectedModel;
|
|
}, [selectedModel, customModelStatus, customModelInput]);
|
|
|
|
// Save model selection when it changes
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
const effectiveModel = getEffectiveModelId();
|
|
if (effectiveModel !== aiSettings.aiGatewayModel) {
|
|
saveAiSetting('aiGatewayModel', effectiveModel);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedModel, customModelStatus, customModelInput]);
|
|
|
|
// Save custom model separately
|
|
useEffect(() => {
|
|
if (!isMounted.current) return;
|
|
const customToSave =
|
|
selectedModel === CUSTOM_MODEL_VALUE && customModelStatus === 'valid' ? customModelInput : '';
|
|
if (customToSave !== (aiSettings.aiGatewayCustomModel ?? '')) {
|
|
saveAiSetting('aiGatewayCustomModel', customToSave);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedModel, customModelStatus, customModelInput]);
|
|
|
|
const handleModelChange = (value: string) => {
|
|
setSelectedModel(value);
|
|
if (value !== CUSTOM_MODEL_VALUE) {
|
|
setCustomModelStatus('idle');
|
|
setCustomModelError('');
|
|
setCustomModelPricing(null);
|
|
}
|
|
};
|
|
|
|
const validateCustomModel = async () => {
|
|
if (!customModelInput.trim()) {
|
|
setCustomModelError(_('Please enter a model ID'));
|
|
setCustomModelStatus('invalid');
|
|
return;
|
|
}
|
|
|
|
setCustomModelStatus('validating');
|
|
setCustomModelError('');
|
|
|
|
try {
|
|
// Simple validation: try to make a minimal request to verify model exists
|
|
// This uses the AI Gateway to check if the model is available
|
|
const testSettings: AISettings = {
|
|
...aiSettings,
|
|
provider: 'ai-gateway',
|
|
aiGatewayApiKey: gatewayKey,
|
|
aiGatewayModel: customModelInput.trim(),
|
|
};
|
|
|
|
const aiProvider = getAIProvider(testSettings);
|
|
const isAvailable = await aiProvider.isAvailable();
|
|
|
|
if (isAvailable) {
|
|
setCustomModelStatus('valid');
|
|
// Set unknown pricing for custom models
|
|
setCustomModelPricing({ input: '?', output: '?' });
|
|
} else {
|
|
setCustomModelStatus('invalid');
|
|
setCustomModelError(_('Model not available or invalid'));
|
|
}
|
|
} catch (_err) {
|
|
setCustomModelStatus('invalid');
|
|
setCustomModelError(_('Failed to validate model'));
|
|
}
|
|
};
|
|
|
|
const handleTestConnection = async () => {
|
|
if (!enabled) return;
|
|
setConnectionStatus('testing');
|
|
setErrorMessage('');
|
|
|
|
try {
|
|
const effectiveModel = getEffectiveModelId();
|
|
const testSettings: AISettings = {
|
|
...aiSettings,
|
|
provider,
|
|
ollamaBaseUrl: ollamaUrl,
|
|
ollamaModel,
|
|
ollamaEmbeddingModel,
|
|
aiGatewayApiKey: gatewayKey,
|
|
aiGatewayModel: effectiveModel,
|
|
openrouterApiKey: openrouterKey,
|
|
openrouterBaseUrl: openrouterUrl,
|
|
openrouterModel,
|
|
openrouterEmbeddingModel,
|
|
};
|
|
const aiProvider = getAIProvider(testSettings);
|
|
const isHealthy = await aiProvider.healthCheck();
|
|
if (isHealthy) {
|
|
setConnectionStatus('success');
|
|
} else {
|
|
setConnectionStatus('error');
|
|
setErrorMessage(
|
|
provider === 'ollama'
|
|
? _("Couldn't connect to Ollama. Is it running?")
|
|
: _('Invalid API key or connection failed'),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
setConnectionStatus('error');
|
|
setErrorMessage((error as Error).message || _('Connection failed'));
|
|
}
|
|
};
|
|
|
|
const disabledSection = !enabled ? 'opacity-50 pointer-events-none select-none' : '';
|
|
|
|
return (
|
|
<div className='my-4 w-full space-y-6'>
|
|
<BoxedList title={_('AI Assistant')}>
|
|
<SettingsSwitchRow
|
|
label={_('Enable AI Assistant')}
|
|
checked={enabled}
|
|
onChange={() => setEnabled(!enabled)}
|
|
/>
|
|
</BoxedList>
|
|
|
|
<BoxedList title={_('Provider')} className={disabledSection}>
|
|
<SettingsRow label={_('Ollama (Local)')} asLabel>
|
|
<input
|
|
type='radio'
|
|
name='ai-provider'
|
|
className='radio'
|
|
checked={provider === 'ollama'}
|
|
onChange={() => setProvider('ollama')}
|
|
disabled={!enabled}
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow label={_('AI Gateway (Cloud)')} asLabel>
|
|
<input
|
|
type='radio'
|
|
name='ai-provider'
|
|
className='radio'
|
|
checked={provider === 'ai-gateway'}
|
|
onChange={() => setProvider('ai-gateway')}
|
|
disabled={!enabled}
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow label={_('OpenAI Compatible')} asLabel>
|
|
<input
|
|
type='radio'
|
|
name='ai-provider'
|
|
className='radio'
|
|
checked={provider === 'openrouter'}
|
|
onChange={() => setProvider('openrouter')}
|
|
disabled={!enabled}
|
|
/>
|
|
</SettingsRow>
|
|
</BoxedList>
|
|
|
|
{provider === 'ollama' && (
|
|
<BoxedList title={_('Ollama Configuration')} className={disabledSection}>
|
|
{/* Stacked-content rows: label-on-top, input below — used when the
|
|
control is too wide to fit alongside the label (full-width text
|
|
inputs, long selects). Custom <div> rather than <SettingsRow>
|
|
since SettingsRow assumes label-left/control-right. */}
|
|
<div className='flex flex-col gap-2 py-3 pe-4'>
|
|
<div className='flex w-full items-center justify-between'>
|
|
<SettingLabel>{_('Server URL')}</SettingLabel>
|
|
<button
|
|
className='hover:bg-base-200 inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors duration-150'
|
|
onClick={fetchOllamaModels}
|
|
disabled={!enabled || fetchingModels}
|
|
title={_('Refresh Models')}
|
|
aria-label={_('Refresh Models')}
|
|
>
|
|
<PiArrowsClockwise className='size-4' />
|
|
</button>
|
|
</div>
|
|
<input
|
|
type='text'
|
|
className='input input-bordered input-sm w-full'
|
|
value={ollamaUrl}
|
|
onChange={(e) => setOllamaUrl(e.target.value)}
|
|
placeholder='http://127.0.0.1:11434'
|
|
disabled={!enabled}
|
|
/>
|
|
</div>
|
|
{ollamaModels.length > 0 ? (
|
|
<>
|
|
<div className='flex flex-col gap-2 py-3 pe-4'>
|
|
<SettingLabel>{_('AI Model')}</SettingLabel>
|
|
<select
|
|
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
|
|
value={ollamaModel}
|
|
onChange={(e) => setOllamaModel(e.target.value)}
|
|
disabled={!enabled}
|
|
>
|
|
{ollamaModels.map((model) => (
|
|
<option key={model} value={model}>
|
|
{model}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className='flex flex-col gap-2 py-3 pe-4'>
|
|
<SettingLabel>{_('Embedding Model')}</SettingLabel>
|
|
<select
|
|
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
|
|
value={ollamaEmbeddingModel}
|
|
onChange={(e) => setOllamaEmbeddingModel(e.target.value)}
|
|
disabled={!enabled}
|
|
>
|
|
{ollamaModels.map((model) => (
|
|
<option key={model} value={model}>
|
|
{model}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</>
|
|
) : !fetchingModels ? (
|
|
<SettingsRow
|
|
label={<span className='text-warning text-sm'>{_('No models detected')}</span>}
|
|
/>
|
|
) : null}
|
|
</BoxedList>
|
|
)}
|
|
|
|
{provider === 'ai-gateway' && (
|
|
<BoxedList
|
|
title={_('AI Gateway Configuration')}
|
|
description={_(
|
|
'Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting "Custom Model" below.',
|
|
)}
|
|
className={disabledSection}
|
|
>
|
|
<div className='flex flex-col gap-2 px-4 py-3'>
|
|
<div className='flex w-full items-center justify-between'>
|
|
<SettingLabel>{_('API Key')}</SettingLabel>
|
|
<a
|
|
href='https://vercel.com/docs/ai/ai-gateway'
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className={clsx('link text-xs', !enabled && 'pointer-events-none')}
|
|
>
|
|
{_('Get Key')}
|
|
</a>
|
|
</div>
|
|
<input
|
|
type='password'
|
|
className='input input-bordered input-sm w-full'
|
|
value={gatewayKey}
|
|
onChange={(e) => setGatewayKey(e.target.value)}
|
|
placeholder='vck_...'
|
|
disabled={!enabled}
|
|
/>
|
|
</div>
|
|
<div className='flex flex-col gap-2 px-4 py-3'>
|
|
<SettingLabel>{_('Model')}</SettingLabel>
|
|
<select
|
|
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
|
|
value={selectedModel}
|
|
onChange={(e) => handleModelChange(e.target.value)}
|
|
disabled={!enabled}
|
|
>
|
|
{modelOptions.map((opt) => (
|
|
<option key={opt.id} value={opt.id}>
|
|
{opt.label} — ${opt.inputCost}/M in, ${opt.outputCost}/M out
|
|
</option>
|
|
))}
|
|
<option value={CUSTOM_MODEL_VALUE}>{_('Custom Model...')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
{selectedModel === CUSTOM_MODEL_VALUE && (
|
|
<div className='flex flex-col gap-2 px-4 py-3'>
|
|
<SettingLabel>{_('Custom Model ID')}</SettingLabel>
|
|
<div className='flex w-full gap-2'>
|
|
<input
|
|
type='text'
|
|
className='input input-bordered input-sm flex-1'
|
|
value={customModelInput}
|
|
onChange={(e) => {
|
|
setCustomModelInput(e.target.value);
|
|
setCustomModelStatus('idle');
|
|
setCustomModelError('');
|
|
}}
|
|
placeholder='provider/model-name'
|
|
disabled={!enabled}
|
|
/>
|
|
<button
|
|
className='btn btn-outline btn-sm'
|
|
onClick={validateCustomModel}
|
|
disabled={!enabled || customModelStatus === 'validating'}
|
|
>
|
|
{customModelStatus === 'validating' ? (
|
|
<PiSpinner className='size-4 animate-spin' />
|
|
) : (
|
|
_('Validate')
|
|
)}
|
|
</button>
|
|
</div>
|
|
{customModelStatus === 'valid' && customModelPricing && (
|
|
<span className='text-success flex items-center gap-1 text-sm'>
|
|
<PiCheckCircle />
|
|
{_('Model available')} · ${customModelPricing.input}/M in, $
|
|
{customModelPricing.output}/M out
|
|
</span>
|
|
)}
|
|
{customModelStatus === 'invalid' && (
|
|
<span className='text-error text-sm'>{customModelError}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</BoxedList>
|
|
)}
|
|
|
|
{provider === 'openrouter' && (
|
|
<BoxedList
|
|
title={_('OpenAI Compatible Configuration')}
|
|
description={_(
|
|
'Bring your own API key for OpenAI or any OpenAI-compatible endpoint. Also works with Together / Groq / vLLM / OpenRouter and other OpenAI-compatible services. The model list is fetched live from the endpoint you configure.',
|
|
)}
|
|
className={disabledSection}
|
|
>
|
|
{/* API key */}
|
|
<div className='flex flex-col gap-2 px-4 py-3'>
|
|
<div className='flex w-full items-center justify-between'>
|
|
<SettingLabel>{_('API Key')}</SettingLabel>
|
|
<a
|
|
href='https://openrouter.ai/keys'
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className={clsx('link text-xs', !enabled && 'pointer-events-none')}
|
|
>
|
|
{_('Get Key')}
|
|
</a>
|
|
</div>
|
|
<input
|
|
type='password'
|
|
className='input input-bordered input-sm w-full'
|
|
value={openrouterKey}
|
|
onChange={(e) => setOpenrouterKey(e.target.value)}
|
|
placeholder='sk-or-...'
|
|
disabled={!enabled}
|
|
autoComplete='off'
|
|
/>
|
|
</div>
|
|
|
|
{/* Base URL + refresh */}
|
|
<div className='flex flex-col gap-2 px-4 py-3'>
|
|
<div className='flex w-full items-center justify-between'>
|
|
<SettingLabel>{_('Base URL')}</SettingLabel>
|
|
<button
|
|
className='hover:bg-base-200 inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors duration-150'
|
|
onClick={fetchOpenrouterModelList}
|
|
disabled={!enabled || openrouterFetchingModels || !openrouterKey}
|
|
title={_('Refresh Models')}
|
|
aria-label={_('Refresh Models')}
|
|
>
|
|
{openrouterFetchingModels ? (
|
|
<PiSpinner className='size-4 animate-spin' />
|
|
) : (
|
|
<PiArrowsClockwise className='size-4' />
|
|
)}
|
|
</button>
|
|
</div>
|
|
<input
|
|
type='text'
|
|
className='input input-bordered input-sm w-full'
|
|
value={openrouterUrl}
|
|
onChange={(e) => setOpenrouterUrl(e.target.value)}
|
|
placeholder='https://openrouter.ai/api/v1'
|
|
disabled={!enabled}
|
|
/>
|
|
</div>
|
|
|
|
{/* Model picker — populated from the endpoint's /models */}
|
|
<div className='flex flex-col gap-2 px-4 py-3'>
|
|
<SettingLabel>{_('LLM Model')}</SettingLabel>
|
|
{openrouterModels.length > 0 ? (
|
|
<select
|
|
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
|
|
value={openrouterModel}
|
|
onChange={(e) => setOpenrouterModel(e.target.value)}
|
|
disabled={!enabled}
|
|
>
|
|
{openrouterModels.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.name ? `${m.name} (${m.id})` : m.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
// Fallback: free-text input when /models isn't reachable yet,
|
|
// so the user isn't locked out before refreshing succeeds.
|
|
<input
|
|
type='text'
|
|
className='input input-bordered input-sm w-full'
|
|
value={openrouterModel}
|
|
onChange={(e) => setOpenrouterModel(e.target.value)}
|
|
placeholder='openai/gpt-4o-mini'
|
|
disabled={!enabled}
|
|
/>
|
|
)}
|
|
{openrouterModelsError && (
|
|
<span className='text-error text-xs'>{openrouterModelsError}</span>
|
|
)}
|
|
{!openrouterModelsError && !openrouterKey && (
|
|
<span className='text-base-content/60 text-xs'>
|
|
{_('Enter an API key, then refresh to load available models.')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Embedding model — same /models listing as the LLM picker.
|
|
OpenAI's /v1/models doesn't tag chat vs embedding, so the two
|
|
selects share one list and the user picks the right one.
|
|
Falls back to free text when the list isn't loaded yet, so
|
|
the user can still type a known ID before refreshing. */}
|
|
<div className='flex flex-col gap-2 px-4 py-3'>
|
|
<SettingLabel>{_('Embedding Model')}</SettingLabel>
|
|
{openrouterModels.length > 0 ? (
|
|
<select
|
|
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
|
|
value={openrouterEmbeddingModel}
|
|
onChange={(e) => setOpenrouterEmbeddingModel(e.target.value)}
|
|
disabled={!enabled}
|
|
>
|
|
<option value=''>{_('None (disable RAG)')}</option>
|
|
{openrouterModels.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.name ? `${m.name} (${m.id})` : m.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type='text'
|
|
className='input input-bordered input-sm w-full'
|
|
value={openrouterEmbeddingModel}
|
|
onChange={(e) => setOpenrouterEmbeddingModel(e.target.value)}
|
|
placeholder='openai/text-embedding-3-small'
|
|
disabled={!enabled}
|
|
/>
|
|
)}
|
|
<span className='text-base-content/60 text-xs'>
|
|
{_(
|
|
'Optional. Leave blank if your endpoint does not support embeddings — chat will still work but RAG features will be unavailable.',
|
|
)}
|
|
</span>
|
|
</div>
|
|
</BoxedList>
|
|
)}
|
|
|
|
<BoxedList
|
|
title={_('Reedy Retrieval (Beta)')}
|
|
className={disabledSection}
|
|
description={
|
|
isTauriAppPlatform()
|
|
? _(
|
|
'Uses Turso vector search + CFI-anchored citations. The model decides when to look up passages instead of getting them stuffed into the system prompt.',
|
|
)
|
|
: _('Reedy is desktop-only in this beta. Use the Readest desktop app to try it.')
|
|
}
|
|
>
|
|
<SettingsSwitchRow
|
|
label={_('Use Reedy retrieval')}
|
|
description={_(
|
|
'When on, indexing and chat go through Reedy. When off, the legacy IndexedDB pipeline is used.',
|
|
)}
|
|
checked={reedyEnabled}
|
|
disabled={!enabled || !isTauriAppPlatform()}
|
|
onChange={() => {
|
|
const next = !reedyEnabled;
|
|
setReedyEnabled(next);
|
|
saveAiSetting('reedy', {
|
|
enabled: next,
|
|
runtime: reedyAgentRuntime ? 'agent' : 'mvp',
|
|
});
|
|
}}
|
|
/>
|
|
<SettingsSwitchRow
|
|
label={_('Use agent runtime (experimental)')}
|
|
description={_(
|
|
'When on, the notebook AI tab uses the new custom agent runtime + thread UI. When off, the Phase 1B MVP path (legacy assistant-ui Thread) is used.',
|
|
)}
|
|
checked={reedyAgentRuntime}
|
|
disabled={!enabled || !reedyEnabled || !isTauriAppPlatform()}
|
|
onChange={() => {
|
|
const next = !reedyAgentRuntime;
|
|
setReedyAgentRuntime(next);
|
|
saveAiSetting('reedy', {
|
|
enabled: reedyEnabled,
|
|
runtime: next ? 'agent' : 'mvp',
|
|
});
|
|
}}
|
|
/>
|
|
<div className='flex min-h-14 items-center justify-between gap-3 ps-4 pe-4'>
|
|
<div className='flex min-w-0 flex-col gap-0.5'>
|
|
<SettingLabel>{_('Send Reedy feedback')}</SettingLabel>
|
|
<span className='text-base-content/60 text-xs'>
|
|
{_(
|
|
'Download the last 90 days of Reedy events as a JSON bundle (local-only by default). Paste into a GitHub issue or feedback form.',
|
|
)}
|
|
</span>
|
|
</div>
|
|
<button
|
|
className='btn btn-outline btn-sm'
|
|
disabled={!enabled || !isTauriAppPlatform() || !appService}
|
|
onClick={async () => {
|
|
if (!appService) return;
|
|
try {
|
|
const bundle = await exportReedyMetricsBundle(appService);
|
|
const blob = new Blob([bundle], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `reedy-feedback-${new Date().toISOString().slice(0, 10)}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error('[Reedy] feedback export failed', err);
|
|
}
|
|
}}
|
|
>
|
|
{_('Download')}
|
|
</button>
|
|
</div>
|
|
</BoxedList>
|
|
|
|
<BoxedList title={_('Connection')} className={disabledSection}>
|
|
<div className='flex min-h-14 items-center justify-between gap-3 pe-4'>
|
|
<button
|
|
className='btn btn-outline btn-sm'
|
|
onClick={handleTestConnection}
|
|
disabled={!enabled || connectionStatus === 'testing'}
|
|
>
|
|
{_('Test Connection')}
|
|
</button>
|
|
<div>
|
|
{connectionStatus === 'success' && (
|
|
<span className='text-success flex items-center gap-1 text-sm'>
|
|
<PiCheckCircle className='size-4 shrink-0' />
|
|
{_('Connected')}
|
|
</span>
|
|
)}
|
|
{connectionStatus === 'error' && (
|
|
<span className='text-error flex items-center gap-1 text-sm'>
|
|
<PiWarningCircle className='size-4 shrink-0' />
|
|
{errorMessage || _('Failed')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</BoxedList>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AIPanel;
|