316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useI18n } from '@/lib/hooks/use-i18n';
|
|
import { useSettingsStore } from '@/lib/store/settings';
|
|
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
|
|
import type { PDFProviderId } from '@/lib/pdf/types';
|
|
import { getFormatLabelsForProviders } from '@/lib/document/mime';
|
|
import { CheckCircle2, Eye, EyeOff, Loader2, Zap, XCircle } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Get display label for feature
|
|
*/
|
|
function getFeatureLabel(feature: string, t: (key: string) => string): string {
|
|
const labels: Record<string, string> = {
|
|
text: t('settings.featureText'),
|
|
images: t('settings.featureImages'),
|
|
tables: t('settings.featureTables'),
|
|
formulas: t('settings.featureFormulas'),
|
|
'layout-analysis': t('settings.featureLayoutAnalysis'),
|
|
metadata: t('settings.featureMetadata'),
|
|
};
|
|
return labels[feature] || feature;
|
|
}
|
|
|
|
interface PDFSettingsProps {
|
|
selectedProviderId: PDFProviderId;
|
|
}
|
|
|
|
export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
|
|
const { t } = useI18n();
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
|
|
const [testMessage, setTestMessage] = useState('');
|
|
|
|
const pdfProvidersConfig = useSettingsStore((state) => state.pdfProvidersConfig);
|
|
const setPDFProviderConfig = useSettingsStore((state) => state.setPDFProviderConfig);
|
|
|
|
const pdfProvider = PDF_PROVIDERS[selectedProviderId];
|
|
const isServerConfigured = !!pdfProvidersConfig[selectedProviderId]?.isServerConfigured;
|
|
const providerConfig = pdfProvidersConfig[selectedProviderId];
|
|
const hasApiKey = !!providerConfig?.apiKey;
|
|
const hasBaseUrl = !!providerConfig?.baseUrl;
|
|
|
|
const isCloud = selectedProviderId === 'mineru-cloud';
|
|
const isSelfHosted = selectedProviderId === 'mineru';
|
|
const needsRemoteConfig = isSelfHosted || isCloud;
|
|
|
|
// For cloud: test requires API key (user-entered or server-configured); for self-hosted: test requires base URL
|
|
const canTest = isCloud ? hasApiKey || isServerConfigured : hasBaseUrl || isServerConfigured;
|
|
|
|
// Reset state when provider changes
|
|
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
|
|
if (selectedProviderId !== prevSelectedProviderId) {
|
|
setPrevSelectedProviderId(selectedProviderId);
|
|
setShowApiKey(false);
|
|
setTestStatus('idle');
|
|
setTestMessage('');
|
|
}
|
|
|
|
const handleTestConnection = async () => {
|
|
setTestStatus('testing');
|
|
setTestMessage('');
|
|
|
|
try {
|
|
const response = await fetch('/api/verify-pdf-provider', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
providerId: selectedProviderId,
|
|
apiKey: providerConfig?.apiKey || '',
|
|
baseUrl: providerConfig?.baseUrl || '',
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setTestStatus('success');
|
|
setTestMessage(t('settings.connectionSuccess'));
|
|
} else {
|
|
setTestStatus('error');
|
|
setTestMessage(`${t('settings.connectionFailed')}: ${data.error}`);
|
|
}
|
|
} catch (err) {
|
|
setTestStatus('error');
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setTestMessage(`${t('settings.connectionFailed')}: ${message}`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-3xl">
|
|
{/* Server-configured notice */}
|
|
{isServerConfigured && (
|
|
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
|
|
{t('settings.serverConfiguredNotice')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Configuration section — hidden for managed providers, which are
|
|
admin-owned and not overridable from the client. */}
|
|
{!isServerConfigured && needsRemoteConfig && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* API Key — shown first for cloud, second for self-hosted */}
|
|
{isCloud && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">{t('settings.pdfApiKey')}</Label>
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Input
|
|
name={`pdf-api-key-${selectedProviderId}`}
|
|
type={showApiKey ? 'text' : 'password'}
|
|
autoComplete="new-password"
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
placeholder={t('settings.mineruCloudApiKeyPlaceholder')}
|
|
value={providerConfig?.apiKey || ''}
|
|
onChange={(e) =>
|
|
setPDFProviderConfig(selectedProviderId, { apiKey: e.target.value })
|
|
}
|
|
className="font-mono text-sm pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey(!showApiKey)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleTestConnection}
|
|
disabled={testStatus === 'testing' || !canTest}
|
|
className="gap-1.5 shrink-0"
|
|
>
|
|
{testStatus === 'testing' ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Zap className="h-3.5 w-3.5" />
|
|
{t('settings.testConnection')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Base URL */}
|
|
{(isSelfHosted || isCloud) && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">
|
|
{t('settings.pdfBaseUrl')}
|
|
{isCloud && (
|
|
<span className="text-muted-foreground ml-1 font-normal">
|
|
({t('settings.optional')})
|
|
</span>
|
|
)}
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
name={`pdf-base-url-${selectedProviderId}`}
|
|
autoComplete="off"
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
placeholder={isCloud ? 'https://mineru.net/api/v4' : 'http://localhost:8080'}
|
|
value={providerConfig?.baseUrl || ''}
|
|
onChange={(e) =>
|
|
setPDFProviderConfig(selectedProviderId, { baseUrl: e.target.value })
|
|
}
|
|
className="text-sm"
|
|
/>
|
|
{/* Test button for self-hosted (next to base URL) */}
|
|
{isSelfHosted && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleTestConnection}
|
|
disabled={testStatus === 'testing' || !canTest}
|
|
className="gap-1.5 shrink-0"
|
|
>
|
|
{testStatus === 'testing' ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Zap className="h-3.5 w-3.5" />
|
|
{t('settings.testConnection')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* API Key for self-hosted (optional, second column) */}
|
|
{isSelfHosted && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">
|
|
{t('settings.pdfApiKey')}
|
|
<span className="text-muted-foreground ml-1 font-normal">
|
|
({t('settings.optional')})
|
|
</span>
|
|
</Label>
|
|
<div className="relative">
|
|
<Input
|
|
name={`pdf-api-key-${selectedProviderId}`}
|
|
type={showApiKey ? 'text' : 'password'}
|
|
autoComplete="new-password"
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
placeholder={t('settings.enterApiKey')}
|
|
value={providerConfig?.apiKey || ''}
|
|
onChange={(e) =>
|
|
setPDFProviderConfig(selectedProviderId, { apiKey: e.target.value })
|
|
}
|
|
className="font-mono text-sm pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey(!showApiKey)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Test result message */}
|
|
{testMessage && (
|
|
<div
|
|
className={cn(
|
|
'rounded-lg p-3 text-sm',
|
|
testStatus === 'success' &&
|
|
'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800',
|
|
testStatus === 'error' &&
|
|
'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/30 dark:text-red-300 dark:border-red-800',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{testStatus === 'success' && <CheckCircle2 className="h-4 w-4 shrink-0" />}
|
|
{testStatus === 'error' && <XCircle className="h-4 w-4 shrink-0" />}
|
|
<span className="break-all">{testMessage}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Request URL Preview */}
|
|
{(() => {
|
|
if (isCloud) {
|
|
const base = providerConfig?.baseUrl || 'https://mineru.net/api/v4';
|
|
return (
|
|
<p className="text-xs text-muted-foreground break-all">
|
|
{t('settings.requestUrl')}: {base}/file-urls/batch
|
|
</p>
|
|
);
|
|
}
|
|
const effectiveBaseUrl = providerConfig?.baseUrl || '';
|
|
if (!effectiveBaseUrl) return null;
|
|
return (
|
|
<p className="text-xs text-muted-foreground break-all">
|
|
{t('settings.requestUrl')}: {effectiveBaseUrl}/file_parse
|
|
</p>
|
|
);
|
|
})()}
|
|
</>
|
|
)}
|
|
|
|
{/* Supported Formats */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">{t('settings.supportedFormats')}</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{getFormatLabelsForProviders([selectedProviderId]).map((format) => (
|
|
<Badge key={format} variant="secondary" className="font-normal">
|
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
|
{format}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{/* Self-hosted MinerU can under-deliver on PDF/image formats when the
|
|
server lacks the pipeline/core extras — warn against over-promising. */}
|
|
{isSelfHosted && (
|
|
<p className="text-xs text-muted-foreground">{t('settings.mineruSelfHostFormatsNote')}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Features List */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">{t('settings.pdfFeatures')}</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{pdfProvider.features.map((feature) => (
|
|
<Badge key={feature} variant="secondary" className="font-normal">
|
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
|
{getFeatureLabel(feature, t)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|