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:
Mohammed Efaz
2026-01-24 16:38:48 +06:00
committed by GitHub
parent 224acd68b1
commit 5bbc5ceccc
79 changed files with 12716 additions and 4607 deletions
@@ -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}