feat(ai): AI reading assistant phase 2 (#3023)
* feat(ai): add dependencies * chore: bump zod version to default version * feat(ai): define types and model constants * feat(ai): ollama provider for local LLM * feat(ai): implement openrouter provider for cloud models * feat(settings): register ai settings panel in global dialog * refactor(ai): expose provider factory and service layer entry point * test(ai): add unit tests for the providers * test(ai): add unit tests for the providers * feat(ai): settings panel for ai configurations * refactor(ai): rewrite aipanel with autosave and greyed out disabled state * fix: remove unused onClose prop from aipanel * test(ai): update mock data * refactor(ai): remove models * refactor: use centralised defaults in system defaults * chore(ai): remove comments * fix(ai): merge default ai settings on load to prevent undefined values * refactor(ai): rewrite settings panel with autosave and model input * feat(ai): add ai tab with simplified highlighting * feat(sidebar): render AIAssistant for ai tab * feat(ai): add chat UI * feat(ai); add chat service with RAG context * feat(ai): temp debug logger * feat(ai): add RAG service * feat(ai): add text chunking utility * feat(ai): add structured method * feat(ai): add chatstructured method * feat(ai): add rag types nd structured output schema * feat(ai): add aistore, indexdb, bm25 * fix: update lock file * feat(ai): update types for AI SDK v5 * feat(ai): add placeholder gateway model constants * refactor(ai): update OllamaProvider for AI SDK * feat(ai): add native gateway provider * refactor(ai): update provider exports * refactor(ai): use streamText from AI SDK * refactor(ai): use embed from AI sdk * refactor(ai): update provider factory exports * feat(ai): add AI Elements and shadcn components * config: add shadcn component config * deps: add AI SDK and AI Elements dependencies * config: add ai packages to transpilePackages * refactor(ai): remove OpenRouterProvider and old tests * feat(ai):add assistant-ui components * feat(ai): add TauriChatAdapter for assistant-ui runtime * refactor(ai): remove ai-elements components * dep(ai): install assistant-ui and update next config * chore(ai): export adapters from service index * feat(ui): enhance ui components for assistant integration * feat(settings): migrate ai settings to gateway * feat(sidebar): integrate assistant-ui * feat: add ai settings toggle to sidebar content * feat: conditionally show ai tab in sidebar navigation * feat: update ai model constants for cheaper options * feat: add gateway provider with proxied embedding * feat: add timeouts to ollama provider health checks * feat: add retry logic to rag service embeddings * feat: add error recovery to ai store * feat: add ai feature tests * feat: add ai api endpoints * feat: add proxied gateway embedding provider * feat: add ai runtime utilities * feat: add ai retry utilities * feat: add tauri env example template * feat: add web env example template * chore: add env * feat(ai): update models and pricing, remove GLM-4.7-FlashX * feat(ai): improve system prompt with official headings and no numeric citations * feat(ai): optimize system prompt for tauri chat * feat(ui): refine ai chat UI and relocate sources * feat(ui): update ai settings panel with model pricing and custom model support * feat(ai): add custom model support to ai settings * test(ai): update constants tests for removed model * feat(api): implement ai chat proxy route * feat(api): implement ai embedding proxy route * feat(ai): implement ai gateway health check and proxy logic * feat(ai): simplify proxied embedding provider * feat(ui): improve markdown text rendering * feat(ui): add input group component * test(ai): update ai provider tests * feat(ai): add pageNumber to text chunk schema * feat(ai): implement page-based chunking with 1500 char formula * feat(ai): bump db to v2 and add store reset migration * feat(ai): transition rag pipeline to page level spoiler filtering * feat(ai): overhaul readest persona and antijailbreak prompt * feat(ai): update tauri adapter for page tracking and persona * chore(ai): export aiStore and logger from core index * feat(reader): integrate page tracking and manual index reset * feat(ui): add re-index action and reset logic to chat * chore: sync pnpm lockfile with ai dependencies * feat(utils): add browser-safe file utilities for web builds * refactor(utils): use dynamic tauri fs import to prevent web crashes * refactor(services): defer osType call to init() for web compatibility * refactor(services): import RemoteFile from file.web * refactor(services): import ClosableFile from file.web * fix(libs): cast Entry to any for getData access * fix(annotator): cast Overlayer to any for bubble access * refactor(ai): replace SparklesIcon with BookOpenIcon for index prompt * test(ai): add pageNumber to TextChunk mocks * test(ai): fix chunkSection signature in tests * chore: update files * fix(ai): prevent useLocalRuntime crash when adapter is null * refactor: optimize annotator overlay drawing * feat: stabilize AI assistant runtime and adapter * refactor: improve document zip loader type safety * feat: update tauri chat adapter for dynamic options * fix: restore architecture comments and refine platform properties * build: update lockfile with assistant-ui patch * fix(library): patch @assistant-ui/react for runtime initialization * build: update dependencies in readest-app * build: update root dependencies and patch configuration * fix(ai): patch @assistant-ui/react for thread deletion and runtime init * fix(ai): update assistant-ui patch with dist guards and deletion fallback * build: sync lockfile with assistant-ui patch updates * chore(env): update .gitignore by removing .env files from it * chore(env): update .gitignore by adding .env.local * chore(env): update .gitignore by adding .env*.local * fix: restore static osType import * chore: sync submodules with upstream/main * refactor: remove redundant file.web module and revert import * chore: update pnpm-lock.yaml * refactor: revert guards * refactor; remove deprecated codes and extract prompts.ts * refactor(ai): remove unused ragservice exports * refactor: remove unused ollama and embedding models * refactor: remove unused type * test: remove test for the now deleted constants * refactor: remove unused export * style: fix ui component formatting * style: fix core and style file formatting * test: fix broken ai provider import * fix: typescript error * fix: add eslint disable command * fix(deps): remove unused ai sdk provider util after v6 ai sdk migration * fix(patch): add lookbehind regex patch * feat(dep): upgrade vercel ai sdk to v6 and ai-sdk-ollama to v3 * chore: update lockfile for vercel ai sdk v6 * refactor(ai): remove EmbeddingModel generic for ai sdk v6 * refactor(ai): remove EmbeddingModel generic for ai sdk v6 * test(ai): update mock to use embeddingModel * fix(patch): add lookbehind regex patch for email autolinks in markdown * refactor(ai): use ai sdk v6 syntax * fix: prettier formatting * chore: revert cargo.lock * fix(ai): update proxied embedding model to v3 spec * feat(ai): add aiconversation types for chat persistence * feat(ai): add conversation/message indexeddb and crud operations * feat(ai): create aiChatStore zustand store for chat state management * feat(notebook): add notebookactivetab state for Notes/AI * refactor(ai): refine conversation and message types for persistence * feat(types): add notebookActiveTab to ReadSettings type * chore: update deps * feat: add notebookactive tab default value * feat: add hook for ai chat * feat: update left side panel with history/chat icon * feat: integrate ChatHistoryView into sidebar content * feat: create UI for managing AI chat history * feat: implement persistent history with assistant-ui adapter * feat: create tab navigation component for notes and AI * feat: add tab navigation and AI assistant view * feat: update header to display active tab title * fix: formatting * feat: remove title and update new chat button * fix: formatting * fix: revert tooltip and styling * feat: implement cross-platform ask dialog bridge * feat(ai): preserve history during ui clear & use native dialogs * fix: align notebook navigation height with sidebar tabs * fix(ai): add missing dependency to handleDeleteConversation hook * docs: update PROJECT.md with session highlights * chore: delete projectmd * chore: update package.json and lock file * chore: update package.json * chore: remove patch * chore: upgrade react types to 19 and show ai features only in development mode for now --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
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 { DEFAULT_AI_SETTINGS, GATEWAY_MODELS, MODEL_PRICING } from '@/services/ai/constants';
|
||||
import type { AISettings, AIProviderName } from '@/services/ai/types';
|
||||
|
||||
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 } = useEnv();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
|
||||
const aiSettings: AISettings = settings?.aiSettings ?? DEFAULT_AI_SETTINGS;
|
||||
|
||||
const [enabled, setEnabled] = useState(aiSettings.enabled);
|
||||
const [provider, setProvider] = useState<AIProviderName>(aiSettings.provider);
|
||||
const [ollamaUrl, setOllamaUrl] = useState(aiSettings.ollamaBaseUrl);
|
||||
const [ollamaModel, setOllamaModel] = useState(aiSettings.ollamaModel);
|
||||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||
const [fetchingModels, setFetchingModels] = useState(false);
|
||||
const [gatewayKey, setGatewayKey] = useState(aiSettings.aiGatewayApiKey ?? '');
|
||||
|
||||
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]);
|
||||
|
||||
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 (gatewayKey !== (aiSettings.aiGatewayApiKey ?? '')) {
|
||||
saveAiSetting('aiGatewayApiKey', gatewayKey);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gatewayKey]);
|
||||
|
||||
// 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,
|
||||
aiGatewayApiKey: gatewayKey,
|
||||
aiGatewayModel: effectiveModel,
|
||||
};
|
||||
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'>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('AI Assistant')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span>{_('Enable AI Assistant')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={enabled}
|
||||
onChange={() => setEnabled(!enabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx('w-full', disabledSection)}>
|
||||
<h2 className='mb-2 font-medium'>{_('Provider')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span>{_('Ollama (Local)')}</span>
|
||||
<input
|
||||
type='radio'
|
||||
name='ai-provider'
|
||||
className='radio'
|
||||
checked={provider === 'ollama'}
|
||||
onChange={() => setProvider('ollama')}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span>{_('AI Gateway (Cloud)')}</span>
|
||||
<input
|
||||
type='radio'
|
||||
name='ai-provider'
|
||||
className='radio'
|
||||
checked={provider === 'ai-gateway'}
|
||||
onChange={() => setProvider('ai-gateway')}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{provider === 'ollama' && (
|
||||
<div className={clsx('w-full', disabledSection)}>
|
||||
<h2 className='mb-2 font-medium'>{_('Ollama Configuration')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span>{_('Server URL')}</span>
|
||||
<button
|
||||
className='btn btn-ghost btn-xs'
|
||||
onClick={fetchOllamaModels}
|
||||
disabled={!enabled || fetchingModels}
|
||||
title={_('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='config-item !h-auto flex-col !items-start gap-2 py-3'>
|
||||
<span>{_('AI Model')}</span>
|
||||
<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>
|
||||
) : !fetchingModels ? (
|
||||
<div className='config-item'>
|
||||
<span className='text-warning text-sm'>{_('No models detected')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === 'ai-gateway' && (
|
||||
<div className={clsx('w-full', disabledSection)}>
|
||||
<h2 className='mb-2 font-medium'>{_('AI Gateway Configuration')}</h2>
|
||||
<p className='text-base-content/70 mb-3 text-sm'>
|
||||
{_(
|
||||
'Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting "Custom Model" below.',
|
||||
)}
|
||||
</p>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span>{_('API Key')}</span>
|
||||
<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='config-item !h-auto flex-col !items-start gap-2 py-3'>
|
||||
<span>{_('Model')}</span>
|
||||
<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='config-item !h-auto flex-col !items-start gap-2 py-3'>
|
||||
<span>{_('Custom Model ID')}</span>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx('w-full', disabledSection)}>
|
||||
<h2 className='mb-2 font-medium'>{_('Connection')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<button
|
||||
className='btn btn-outline btn-sm'
|
||||
onClick={handleTestConnection}
|
||||
disabled={!enabled || connectionStatus === 'testing'}
|
||||
>
|
||||
{_('Test Connection')}
|
||||
</button>
|
||||
{connectionStatus === 'success' && (
|
||||
<span className='text-success flex items-center gap-1 text-sm'>
|
||||
<PiCheckCircle />
|
||||
{_('Connected')}
|
||||
</span>
|
||||
)}
|
||||
{connectionStatus === 'error' && (
|
||||
<span className='text-error flex items-center gap-1 text-sm'>
|
||||
<PiWarningCircle />
|
||||
{errorMessage || _('Failed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIPanel;
|
||||
@@ -123,7 +123,7 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const handleInputFocus = (textareaRef: React.RefObject<HTMLTextAreaElement>) => {
|
||||
const handleInputFocus = (textareaRef: React.RefObject<HTMLTextAreaElement | null>) => {
|
||||
if (appService?.isAndroidApp) {
|
||||
setInputFocusInAndroid(true);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
value: string,
|
||||
error: string | null,
|
||||
saved: boolean,
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>,
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
|
||||
) => (
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium' aria-label={_(title)}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { RiFontSize } from 'react-icons/ri';
|
||||
import { RiDashboardLine, RiTranslate } from 'react-icons/ri';
|
||||
import { VscSymbolColor } from 'react-icons/vsc';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { PiDotsThreeVerticalBold, PiRobot } from 'react-icons/pi';
|
||||
import { LiaHandPointerSolid } from 'react-icons/lia';
|
||||
import { IoAccessibilityOutline } from 'react-icons/io5';
|
||||
import { MdArrowBackIosNew, MdArrowForwardIos, MdClose } from 'react-icons/md';
|
||||
@@ -21,8 +21,16 @@ import DialogMenu from './DialogMenu';
|
||||
import ControlPanel from './ControlPanel';
|
||||
import LangPanel from './LangPanel';
|
||||
import MiscPanel from './MiscPanel';
|
||||
import AIPanel from './AIPanel';
|
||||
|
||||
export type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Control' | 'Language' | 'Custom';
|
||||
export type SettingsPanelType =
|
||||
| 'Font'
|
||||
| 'Layout'
|
||||
| 'Color'
|
||||
| 'Control'
|
||||
| 'Language'
|
||||
| 'AI'
|
||||
| 'Custom';
|
||||
export type SettingsPanelPanelProp = {
|
||||
bookKey: string;
|
||||
onRegisterReset: (resetFn: () => void) => void;
|
||||
@@ -32,6 +40,7 @@ type TabConfig = {
|
||||
tab: SettingsPanelType;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
@@ -69,6 +78,12 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
icon: RiTranslate,
|
||||
label: _('Language'),
|
||||
},
|
||||
{
|
||||
tab: 'AI',
|
||||
icon: PiRobot,
|
||||
label: _('AI Assistant'),
|
||||
disabled: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
{
|
||||
tab: 'Custom',
|
||||
icon: IoAccessibilityOutline,
|
||||
@@ -98,6 +113,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
Color: null,
|
||||
Control: null,
|
||||
Language: null,
|
||||
AI: null,
|
||||
Custom: null,
|
||||
});
|
||||
|
||||
@@ -198,29 +214,31 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
)}
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
data-tab={tab}
|
||||
tabIndex={0}
|
||||
title={label}
|
||||
className={clsx(
|
||||
'btn btn-ghost text-base-content btn-sm gap-1 px-2 max-[350px]:px-1',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => handleSetActivePanel(tab)}
|
||||
>
|
||||
<Icon className='mr-0' />
|
||||
<span
|
||||
{tabConfig
|
||||
.filter((t) => !t.disabled)
|
||||
.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
data-tab={tab}
|
||||
tabIndex={0}
|
||||
title={label}
|
||||
className={clsx(
|
||||
window.innerWidth < 640 && 'hidden',
|
||||
!(showAllTabLabels || activePanel === tab) && 'hidden',
|
||||
'btn btn-ghost text-base-content btn-sm gap-1 px-2 max-[350px]:px-1',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => handleSetActivePanel(tab)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<Icon className='mr-0' />
|
||||
<span
|
||||
className={clsx(
|
||||
window.innerWidth < 640 && 'hidden',
|
||||
!(showAllTabLabels || activePanel === tab) && 'hidden',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex h-full items-center justify-end gap-x-2'>
|
||||
<Dropdown
|
||||
@@ -284,6 +302,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onRegisterReset={(fn) => registerResetFunction('Language', fn)}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'AI' && <AIPanel />}
|
||||
{activePanel === 'Custom' && (
|
||||
<MiscPanel
|
||||
bookKey={bookKey}
|
||||
|
||||
Reference in New Issue
Block a user