Files
readest/apps/readest-app/src/components/settings/AIPanel.tsx
T
Huang Xin 6bc4a96b99 feat(reedy): Phase 1B — wire Reedy into the chat, settings, and Sources UI (#4296)
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter

Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.

The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.

Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.

Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.

Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle

Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.

AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.

Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:41:21 +02:00

831 lines
31 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 [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 });
}}
/>
<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;