76 lines
5.1 KiB
TypeScript
76 lines
5.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { Check, LoaderCircle } from 'lucide-react';
|
||
import { api, errorText } from '../api';
|
||
import type { SettingsInfo } from '../types';
|
||
|
||
export function SettingsDialog({ open, onClose, onSaved }: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSaved: (message: string) => void;
|
||
}) {
|
||
const dialog = useRef<HTMLDialogElement>(null);
|
||
const [info, setInfo] = useState<SettingsInfo | null>(null);
|
||
const [provider, setProvider] = useState('deepseek');
|
||
const [model, setModel] = useState('deepseek-flash');
|
||
const [baseUrl, setBaseUrl] = useState('https://api.deepseek.com');
|
||
const [apiKey, setApiKey] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [pending, setPending] = useState(false);
|
||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||
|
||
useEffect(() => {
|
||
const element = dialog.current;
|
||
if (!element) return;
|
||
let active = true;
|
||
if (open && !element.open) {
|
||
element.showModal();
|
||
setError(''); setResult(null); setApiKey('');
|
||
api.get<SettingsInfo>('/settings').then(data => {
|
||
if (!active) return;
|
||
setInfo(data); setProvider(data.llm_provider); setModel(data.llm_model); setBaseUrl(data.llm_base_url);
|
||
}).catch(err => { if (active) setError(errorText(err)); });
|
||
} else if (!open) {
|
||
if (element.open) element.close();
|
||
setApiKey('');
|
||
}
|
||
return () => { active = false; };
|
||
}, [open]);
|
||
|
||
const available = info?.models_by_provider[provider] ?? (provider === 'deepseek' ? ['deepseek-flash', 'deepseek-v4-pro'] : ['qwen-vl-max']);
|
||
const models = available.includes(model) ? available : [model, ...available];
|
||
|
||
async function persist(): Promise<void> {
|
||
const body: Record<string, string> = { llm_provider: provider, llm_model: model, llm_base_url: baseUrl };
|
||
if (apiKey.trim()) body[provider === 'qwen' ? 'dashscope_api_key' : 'llm_api_key'] = apiKey.trim();
|
||
await api.put('/settings', body);
|
||
setInfo(await api.get<SettingsInfo>('/settings'));
|
||
setApiKey('');
|
||
onSaved('模型配置已保存');
|
||
}
|
||
async function save(test: boolean): Promise<void> {
|
||
setPending(true); setError(''); setResult(null);
|
||
try {
|
||
await persist();
|
||
if (test) {
|
||
const status = await api.post<{ ok: boolean; message?: string; detail?: string; model?: string }>('/settings/test', {});
|
||
setResult({ ok: status.ok, message: status.message || status.detail || (status.ok ? `连接成功,模型 ${status.model || model}` : '连接未通过,请检查配置。') });
|
||
} else { onClose(); }
|
||
} catch (err) { setError(errorText(err)); } finally { setPending(false); }
|
||
}
|
||
|
||
return <dialog ref={dialog} className="settings-dialog" onClose={onClose} aria-label="模型设置">
|
||
<form method="dialog" onSubmit={event => { event.preventDefault(); void save(false); }}>
|
||
<h2>模型设置</h2>
|
||
<label>模型提供商<select value={provider} disabled={pending} onChange={event => { const next = event.target.value; setProvider(next); setModel(info?.models_by_provider[next]?.[0] || (next === 'deepseek' ? 'deepseek-flash' : 'qwen-vl-max')); setBaseUrl(next === 'deepseek' ? 'https://api.deepseek.com' : 'https://dashscope.aliyuncs.com/compatible-mode/v1'); setResult(null); }}><option value="deepseek">DeepSeek</option><option value="qwen">千问 DashScope</option></select></label>
|
||
<label>API Key{info?.llm_api_key_set ? '(已保存,留空保持现有配置)' : '(尚未配置)'}<input type="password" disabled={pending} autoComplete="off" spellCheck={false} placeholder="在这里填写 Key,无需发到聊天里" value={apiKey} onChange={event => setApiKey(event.target.value)} data-testid="deepseek-key-input" /></label>
|
||
<label>模型<select value={model} disabled={pending} onChange={event => { setModel(event.target.value); setResult(null); }}>{models.filter(Boolean).map(name => <option key={name} value={name}>{name}</option>)}</select></label>
|
||
<label>API Base URL<input type="url" disabled={pending} required value={baseUrl} onChange={event => setBaseUrl(event.target.value)} /></label>
|
||
<p className="muted">deepseek-flash 支持截图理解,适合浏览器探索;deepseek-v4-pro 用于文本推理。模型能力以实际连通测试为准。</p>
|
||
<p className="muted">Search / Fetch 使用同一个 DeepSeek Key,单独按请求计费,只有开启公开资料查询时才调用。</p>
|
||
{result && <div className={`settings-test-result${result.ok ? '' : ' failure'}`} role="status">{result.ok && <Check size={14} />} {result.message}</div>}
|
||
{error && <p className="form-error" role="alert">{error}</p>}
|
||
<div className="dialog-actions"><button type="button" className="text-action" disabled={pending} onClick={onClose}>关闭</button><button type="button" className="secondary-button" disabled={pending || !info} onClick={() => { void save(true); }}>{pending ? <LoaderCircle size={14} className="spin" /> : null}保存并测试</button><button type="submit" className="primary-button" disabled={pending || !info}>保存</button></div>
|
||
</form>
|
||
</dialog>;
|
||
}
|