assistant/renderer/components/SettingsDialog.tsx

76 lines
5.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
}