Files
MuMuAINovel/frontend/src/pages/Settings.tsx
T

1345 lines
55 KiB
TypeScript
Raw 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 { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Alert, Grid, Tabs, List, Tag, Popconfirm, Empty, Row, Col } from 'antd';
import { SettingOutlined, SaveOutlined, DeleteOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, ThunderboltOutlined, PlusOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons';
import { settingsApi } from '../services/api';
import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types';
const { Title, Text } = Typography;
const { Option } = Select;
const { useBreakpoint } = Grid;
const { TextArea } = Input;
export default function SettingsPage() {
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md; // md断点是768px
const [form] = Form.useForm();
const [modal, contextHolder] = Modal.useModal();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [hasSettings, setHasSettings] = useState(false);
const [isDefaultSettings, setIsDefaultSettings] = useState(false);
const [modelOptions, setModelOptions] = useState<Array<{ value: string; label: string; description: string }>>([]);
const [fetchingModels, setFetchingModels] = useState(false);
const [modelsFetched, setModelsFetched] = useState(false);
const [testingApi, setTestingApi] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
response_time_ms?: number;
response_preview?: string;
error?: string;
error_type?: string;
suggestions?: string[];
} | null>(null);
const [showTestResult, setShowTestResult] = useState(false);
// 预设相关状态
const [activeTab, setActiveTab] = useState('current');
const [presets, setPresets] = useState<APIKeyPreset[]>([]);
const [presetsLoading, setPresetsLoading] = useState(false);
const [activePresetId, setActivePresetId] = useState<string | undefined>();
const [editingPreset, setEditingPreset] = useState<APIKeyPreset | null>(null);
const [isPresetModalVisible, setIsPresetModalVisible] = useState(false);
const [testingPresetId, setTestingPresetId] = useState<string | null>(null);
const [presetForm] = Form.useForm();
useEffect(() => {
loadSettings();
if (activeTab === 'presets') {
loadPresets();
}
}, []);
useEffect(() => {
if (activeTab === 'presets') {
loadPresets();
}
}, [activeTab]);
const loadSettings = async () => {
setInitialLoading(true);
try {
const settings = await settingsApi.getSettings();
form.setFieldsValue(settings);
// 判断是否为默认设置(id='0'表示来自.env的默认配置)
if (settings.id === '0' || !settings.id) {
setIsDefaultSettings(true);
setHasSettings(false);
} else {
setIsDefaultSettings(false);
setHasSettings(true);
}
} catch (error: any) {
// 如果404表示还没有设置,使用默认值
if (error?.response?.status === 404) {
setHasSettings(false);
setIsDefaultSettings(true);
form.setFieldsValue({
api_provider: 'openai',
api_base_url: 'https://api.openai.com/v1',
llm_model: 'gpt-4',
temperature: 0.7,
max_tokens: 2000,
});
} else {
message.error('加载设置失败');
}
} finally {
setInitialLoading(false);
}
};
const handleSave = async (values: SettingsUpdate) => {
setLoading(true);
try {
await settingsApi.saveSettings(values);
message.success('设置已保存');
setHasSettings(true);
setIsDefaultSettings(false);
} catch (error) {
message.error('保存设置失败');
} finally {
setLoading(false);
}
};
const handleReset = () => {
modal.confirm({
title: '重置设置',
content: '确定要重置为默认值吗?',
centered: true,
okText: '确定',
cancelText: '取消',
onOk: () => {
form.setFieldsValue({
api_provider: 'openai',
api_key: '',
api_base_url: 'https://api.openai.com/v1',
llm_model: 'gpt-4',
temperature: 0.7,
max_tokens: 2000,
});
message.info('已重置为默认值,请点击保存');
},
});
};
const handleDelete = () => {
modal.confirm({
title: '删除设置',
content: '确定要删除所有设置吗?此操作不可恢复。',
centered: true,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
setLoading(true);
try {
await settingsApi.deleteSettings();
message.success('设置已删除');
setHasSettings(false);
form.resetFields();
} catch (error) {
message.error('删除设置失败');
} finally {
setLoading(false);
}
},
});
};
const apiProviders = [
{ value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
// { value: 'anthropic', label: 'Anthropic (Claude)', defaultUrl: 'https://api.anthropic.com' },
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
];
const handleProviderChange = (value: string) => {
const provider = apiProviders.find(p => p.value === value);
if (provider && provider.defaultUrl) {
form.setFieldValue('api_base_url', provider.defaultUrl);
}
// 清空模型列表,需要重新获取
setModelOptions([]);
setModelsFetched(false);
};
const handleFetchModels = async (silent: boolean = false) => {
const apiKey = form.getFieldValue('api_key');
const apiBaseUrl = form.getFieldValue('api_base_url');
const provider = form.getFieldValue('api_provider');
if (!apiKey || !apiBaseUrl) {
if (!silent) {
message.warning('请先填写 API 密钥和 API 地址');
}
return;
}
setFetchingModels(true);
try {
const response = await settingsApi.getAvailableModels({
api_key: apiKey,
api_base_url: apiBaseUrl,
provider: provider || 'openai'
});
setModelOptions(response.models);
setModelsFetched(true);
if (!silent) {
message.success(`成功获取 ${response.count || response.models.length} 个可用模型`);
}
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || '获取模型列表失败';
if (!silent) {
message.error(errorMsg);
}
setModelOptions([]);
setModelsFetched(true); // 即使失败也标记为已尝试,避免重复请求
} finally {
setFetchingModels(false);
}
};
const handleModelSelectFocus = () => {
// 如果还没有获取过模型列表,自动获取
if (!modelsFetched && !fetchingModels) {
handleFetchModels(true); // silent模式,不显示成功消息
}
};
const handleTestConnection = async () => {
const apiKey = form.getFieldValue('api_key');
const apiBaseUrl = form.getFieldValue('api_base_url');
const provider = form.getFieldValue('api_provider');
const modelName = form.getFieldValue('llm_model');
if (!apiKey || !apiBaseUrl || !provider || !modelName) {
message.warning('请先填写完整的配置信息');
return;
}
setTestingApi(true);
setTestResult(null);
try {
const result = await settingsApi.testApiConnection({
api_key: apiKey,
api_base_url: apiBaseUrl,
provider: provider,
llm_model: modelName
});
setTestResult(result);
setShowTestResult(true);
if (result.success) {
message.success(`测试成功!响应时间: ${result.response_time_ms}ms`);
} else {
message.error('API 测试失败,请查看详细信息');
}
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || '测试请求失败';
message.error(errorMsg);
setTestResult({
success: false,
message: '测试请求失败',
error: errorMsg,
error_type: 'RequestError',
suggestions: ['请检查网络连接', '请确认后端服务是否正常运行']
});
setShowTestResult(true);
} finally {
setTestingApi(false);
}
};
// ========== 预设管理函数 ==========
const loadPresets = async () => {
setPresetsLoading(true);
try {
const response = await settingsApi.getPresets();
setPresets(response.presets);
setActivePresetId(response.active_preset_id);
} catch (error) {
message.error('加载预设失败');
console.error(error);
} finally {
setPresetsLoading(false);
}
};
const showPresetModal = (preset?: APIKeyPreset) => {
if (preset) {
setEditingPreset(preset);
presetForm.setFieldsValue({
name: preset.name,
description: preset.description,
...preset.config,
});
} else {
setEditingPreset(null);
presetForm.resetFields();
presetForm.setFieldsValue({
api_provider: 'openai',
temperature: 0.7,
max_tokens: 2000,
});
}
setIsPresetModalVisible(true);
};
const handlePresetCancel = () => {
setIsPresetModalVisible(false);
setEditingPreset(null);
presetForm.resetFields();
};
const handlePresetSave = async () => {
try {
const values = await presetForm.validateFields();
const config: APIKeyPresetConfig = {
api_provider: values.api_provider,
api_key: values.api_key,
api_base_url: values.api_base_url,
llm_model: values.llm_model,
temperature: values.temperature,
max_tokens: values.max_tokens,
};
if (editingPreset) {
await settingsApi.updatePreset(editingPreset.id, {
name: values.name,
description: values.description,
config,
});
message.success('预设已更新');
} else {
const request: PresetCreateRequest = {
name: values.name,
description: values.description,
config,
};
await settingsApi.createPreset(request);
message.success('预设已创建');
}
handlePresetCancel();
loadPresets();
} catch (error) {
console.error('保存失败:', error);
}
};
const handlePresetDelete = async (presetId: string) => {
try {
await settingsApi.deletePreset(presetId);
message.success('预设已删除');
loadPresets();
} catch (error: any) {
message.error(error.response?.data?.detail || '删除失败');
console.error(error);
}
};
const handlePresetActivate = async (presetId: string, presetName: string) => {
try {
await settingsApi.activatePreset(presetId);
message.success(`已激活预设: ${presetName}`);
loadPresets();
loadSettings(); // 重新加载当前配置
} catch (error) {
message.error('激活失败');
console.error(error);
}
};
const handlePresetTest = async (presetId: string) => {
setTestingPresetId(presetId);
try {
const result = await settingsApi.testPreset(presetId);
if (result.success) {
modal.success({
title: '测试成功',
centered: true,
width: isMobile ? '90%' : 600,
content: (
<div style={{ padding: '8px 0' }}>
<div style={{ marginBottom: 24, padding: 16, background: 'var(--color-success-bg)', border: '1px solid var(--color-success-border)', borderRadius: 8 }}>
<Typography.Text strong style={{ color: 'var(--color-success)' }}>
API
</Typography.Text>
</div>
<div style={{
padding: 16,
background: 'var(--color-bg-layout)',
borderRadius: 8,
marginBottom: 16
}}>
<div style={{ marginBottom: 8, fontSize: 14 }}>
<Text type="secondary"></Text>
<Text strong>{result.provider?.toUpperCase() || 'N/A'}</Text>
</div>
<div style={{ marginBottom: 8, fontSize: 14 }}>
<Text type="secondary"></Text>
<Text strong>{result.model || 'N/A'}</Text>
</div>
{result.response_time_ms !== undefined && (
<div style={{ fontSize: 14 }}>
<Text type="secondary"></Text>
<Text strong>{result.response_time_ms}ms</Text>
</div>
)}
</div>
<Alert
message="预设配置测试通过,可以正常使用"
type="success"
showIcon
/>
</div>
),
});
} else {
modal.error({
title: '测试失败',
centered: true,
width: isMobile ? '90%' : 600,
content: (
<div style={{ padding: '8px 0' }}>
<div style={{ marginBottom: 16 }}>
<Alert
message={result.message || 'API 测试失败'}
type="error"
showIcon
/>
</div>
{result.error && (
<div style={{
padding: 16,
background: 'var(--color-error-bg)',
border: '1px solid var(--color-error-border)',
borderRadius: 8,
marginBottom: 16
}}>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 8 }}>:</Text>
<Text style={{ fontSize: 13, color: 'var(--color-error)', fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{result.error}
</Text>
</div>
)}
{result.suggestions && result.suggestions.length > 0 && (
<div style={{
padding: 16,
background: 'var(--color-warning-bg)',
border: '1px solid var(--color-warning-border)',
borderRadius: 8,
marginBottom: 16
}}>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 8 }}>💡 :</Text>
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 13 }}>
{result.suggestions.map((s, i) => (
<li key={i} style={{ marginBottom: 4 }}>{s}</li>
))}
</ul>
</div>
)}
<Alert
message="预设配置存在问题,请检查后重试"
type="warning"
showIcon
/>
</div>
),
});
}
} catch (error) {
message.error('测试失败');
console.error(error);
} finally {
setTestingPresetId(null);
}
};
const handleCreateFromCurrent = () => {
const currentConfig = form.getFieldsValue();
presetForm.setFieldsValue({
name: '',
description: '',
...currentConfig,
});
setEditingPreset(null);
setIsPresetModalVisible(true);
};
const getProviderColor = (provider: string) => {
switch (provider) {
case 'openai':
return 'blue';
// case 'anthropic':
// return 'purple';
case 'gemini':
return 'green';
default:
return 'default';
}
};
// ========== 渲染预设列表 ==========
const renderPresetsList = () => (
<Spin spinning={presetsLoading}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary">API配置预设</Text>
<Space>
<Button icon={<CopyOutlined />} onClick={handleCreateFromCurrent}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showPresetModal()}>
</Button>
</Space>
</div>
{presets.length === 0 ? (
<Empty
description="暂无预设配置"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ margin: '40px 0' }}
>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showPresetModal()}>
</Button>
</Empty>
) : (
<List
dataSource={presets}
renderItem={(preset) => {
const isActive = preset.id === activePresetId;
return (
<List.Item
key={preset.id}
style={{
background: isActive ? '#f0f5ff' : 'transparent',
padding: '16px',
marginBottom: '8px',
border: isActive ? '2px solid #1890ff' : '1px solid #f0f0f0',
borderRadius: '8px',
}}
actions={[
!isActive && (
<Button
type="link"
onClick={() => handlePresetActivate(preset.id, preset.name)}
>
</Button>
),
<Button
key="test"
type="link"
icon={<ThunderboltOutlined />}
loading={testingPresetId === preset.id}
onClick={() => handlePresetTest(preset.id)}
>
</Button>,
<Button
type="link"
icon={<EditOutlined />}
onClick={() => showPresetModal(preset)}
>
</Button>,
<Popconfirm
title="确定删除此预设吗?"
onConfirm={() => handlePresetDelete(preset.id)}
disabled={isActive}
okText="确定"
cancelText="取消"
>
<Button
type="link"
danger
icon={<DeleteOutlined />}
disabled={isActive}
>
</Button>
</Popconfirm>,
].filter(Boolean)}
>
<List.Item.Meta
avatar={
isActive && (
<CheckCircleOutlined
style={{ fontSize: '24px', color: '#52c41a' }}
/>
)
}
title={
<Space>
<span style={{ fontWeight: 'bold' }}>{preset.name}</span>
{isActive && <Tag color="success"></Tag>}
</Space>
}
description={
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{preset.description && (
<div style={{ color: '#666' }}>{preset.description}</div>
)}
<Space wrap>
<Tag color={getProviderColor(preset.config.api_provider)}>
{preset.config.api_provider.toUpperCase()}
</Tag>
<Tag>{preset.config.llm_model}</Tag>
<Tag>: {preset.config.temperature}</Tag>
<Tag>Tokens: {preset.config.max_tokens}</Tag>
</Space>
<div style={{ fontSize: '12px', color: '#999' }}>
: {new Date(preset.created_at).toLocaleString()}
</div>
</Space>
}
/>
</List.Item>
);
}}
/>
)}
</Space>
</Spin>
);
return (
<>
{contextHolder}
<div style={{
minHeight: '100vh',
background: 'linear-gradient(180deg, var(--color-bg-base) 0%, #EEF2F3 100%)',
padding: isMobile ? '20px 16px' : '40px 24px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{
maxWidth: 1400,
margin: '0 auto',
width: '100%',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}>
{/* 顶部导航卡片 */}
<Card
variant="borderless"
style={{
background: 'linear-gradient(135deg, var(--color-primary) 0%, #5A9BA5 50%, var(--color-primary-hover) 100%)',
borderRadius: isMobile ? 16 : 24,
boxShadow: '0 12px 40px rgba(77, 128, 136, 0.25), 0 4px 12px rgba(0, 0, 0, 0.06)',
marginBottom: isMobile ? 20 : 24,
border: 'none',
position: 'relative',
overflow: 'hidden'
}}
>
{/* 装饰性背景元素 */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255, 255, 255, 0.08)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: -40, left: '30%', width: 120, height: 120, borderRadius: '50%', background: 'rgba(255, 255, 255, 0.05)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', top: '50%', right: '15%', width: 80, height: 80, borderRadius: '50%', background: 'rgba(255, 255, 255, 0.06)', pointerEvents: 'none' }} />
<Row align="middle" justify="space-between" gutter={[16, 16]} style={{ position: 'relative', zIndex: 1 }}>
<Col xs={24} sm={12}>
<Space direction="vertical" size={4}>
<Title level={isMobile ? 3 : 2} style={{ margin: 0, color: '#fff', textShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
<SettingOutlined style={{ color: 'rgba(255,255,255,0.9)', marginRight: 8 }} />
AI API
</Title>
<Text style={{ fontSize: isMobile ? 12 : 14, color: 'rgba(255,255,255,0.85)', marginLeft: isMobile ? 40 : 48 }}>
AI接口参数API配置预设
</Text>
</Space>
</Col>
<Col xs={24} sm={12}>
<Space size={12} style={{ display: 'flex', justifyContent: isMobile ? 'flex-start' : 'flex-end', width: '100%' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{
borderRadius: 12,
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.3)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
color: '#fff',
backdropFilter: 'blur(10px)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
e.currentTarget.style.transform = 'none';
}}
>
</Button>
</Space>
</Col>
</Row>
</Card>
{/* 主内容卡片 */}
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: isMobile ? 12 : 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
flex: 1,
}}
styles={{
body: {
padding: isMobile ? '16px' : '24px'
}
}}
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'current',
label: '当前配置',
children: (
<Space direction="vertical" size={isMobile ? 'middle' : 'large'} style={{ width: '100%' }}>
{/* 默认配置提示 */}
{isDefaultSettings && (
<Alert
message="使用 .env 文件中的默认配置"
description={
<div style={{ fontSize: isMobile ? '12px' : '14px' }}>
<p style={{ margin: '8px 0' }}>
<code>.env</code>
</p>
<p style={{ margin: '8px 0 0 0' }}>
"保存设置" <code>.env</code>
</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: isMobile ? 12 : 16 }}
/>
)}
{/* 已保存配置提示 */}
{hasSettings && !isDefaultSettings && (
<Alert
message="使用已保存的个人配置"
type="success"
showIcon
style={{ marginBottom: isMobile ? 12 : 16 }}
/>
)}
{/* 表单 */}
<Spin spinning={initialLoading}>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
autoComplete="off"
>
<Form.Item
label={
<Space size={4}>
<span>API </span>
<InfoCircleOutlined
title="选择你的AI服务提供商"
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
/>
</Space>
}
name="api_provider"
rules={[{ required: true, message: '请选择API提供商' }]}
>
<Select size={isMobile ? 'middle' : 'large'} onChange={handleProviderChange}>
{apiProviders.map(provider => (
<Option key={provider.value} value={provider.value}>
{provider.label}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label={
<Space size={4}>
<span>API </span>
<InfoCircleOutlined
title="你的API密钥,将加密存储"
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
/>
</Space>
}
name="api_key"
rules={[{ required: true, message: '请输入API密钥' }]}
>
<Input.Password
size={isMobile ? 'middle' : 'large'}
placeholder="sk-..."
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
label={
<Space size={4}>
<span>API </span>
<InfoCircleOutlined
title="API的基础URL地址"
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
/>
</Space>
}
name="api_base_url"
rules={[
{ required: true, message: '请输入API地址' },
{ type: 'url', message: '请输入有效的URL' }
]}
>
<Input
size={isMobile ? 'middle' : 'large'}
placeholder="https://api.openai.com/v1"
/>
</Form.Item>
<Form.Item
label={
<Space size={4}>
<span></span>
<InfoCircleOutlined
title="AI模型的名称,如 gpt-4, gpt-3.5-turbo"
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
/>
</Space>
}
name="llm_model"
rules={[{ required: true, message: '请输入或选择模型名称' }]}
>
<Select
size={isMobile ? 'middle' : 'large'}
showSearch
placeholder={isMobile ? "选择模型" : "输入模型名称或点击获取"}
optionFilterProp="label"
loading={fetchingModels}
onFocus={handleModelSelectFocus}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase()) ||
(option?.description ?? '').toLowerCase().includes(input.toLowerCase())
}
dropdownRender={(menu) => (
<>
{menu}
{fetchingModels && (
<div style={{ padding: '8px 12px', color: 'var(--color-text-secondary)', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
<Spin size="small" /> ...
</div>
)}
{!fetchingModels && modelOptions.length === 0 && modelsFetched && (
<div style={{ padding: '8px 12px', color: '#ff4d4f', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
API
</div>
)}
{!fetchingModels && modelOptions.length === 0 && !modelsFetched && (
<div style={{ padding: '8px 12px', color: 'var(--color-text-secondary)', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
</div>
)}
</>
)}
notFoundContent={
fetchingModels ? (
<div style={{ padding: '8px 12px', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
<Spin size="small" /> ...
</div>
) : (
<div style={{ padding: '8px 12px', color: 'var(--color-text-secondary)', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
</div>
)
}
suffixIcon={
!isMobile ? (
<div
onClick={(e) => {
e.stopPropagation();
if (!fetchingModels) {
setModelsFetched(false);
handleFetchModels(false);
}
}}
style={{
cursor: fetchingModels ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
height: '100%',
marginRight: -8
}}
title="重新获取模型列表"
>
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
loading={fetchingModels}
style={{ pointerEvents: 'none' }}
>
</Button>
</div>
) : undefined
}
options={modelOptions.map(model => ({
value: model.value,
label: model.label,
description: model.description
}))}
optionRender={(option) => (
<div>
<div style={{ fontWeight: 500, fontSize: isMobile ? '13px' : '14px' }}>{option.data.label}</div>
{option.data.description && (
<div style={{ fontSize: isMobile ? '11px' : '12px', color: '#8c8c8c', marginTop: '2px' }}>
{option.data.description}
</div>
)}
</div>
)}
/>
</Form.Item>
<Form.Item
label={
<Space size={4}>
<span></span>
<InfoCircleOutlined
title="控制输出的随机性,值越高越随机(0.0-2.0)"
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
/>
</Space>
}
name="temperature"
>
<Slider
min={0}
max={2}
step={0.1}
marks={{
0: { style: { fontSize: isMobile ? '11px' : '12px' }, label: '0.0' },
0.7: { style: { fontSize: isMobile ? '11px' : '12px' }, label: '0.7' },
1: { style: { fontSize: isMobile ? '11px' : '12px' }, label: '1.0' },
2: { style: { fontSize: isMobile ? '11px' : '12px' }, label: '2.0' }
}}
/>
</Form.Item>
<Form.Item
label={
<Space size={4}>
<span> Token </span>
<InfoCircleOutlined
title="单次请求的最大token数量"
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
/>
</Space>
}
name="max_tokens"
rules={[
{ required: true, message: '请输入最大token数' },
{ type: 'number', min: 1, message: '请输入大于0的数字' }
]}
>
<InputNumber
size={isMobile ? 'middle' : 'large'}
style={{ width: '100%' }}
min={1}
placeholder="2000"
/>
</Form.Item>
<Form.Item
label={
<Space size={4}>
<span></span>
<InfoCircleOutlined
title="设置全局系统提示词,每次AI调用时都会自动使用。可用于设定AI的角色、语言风格等"
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
/>
</Space>
}
name="system_prompt"
>
<TextArea
rows={4}
placeholder="例如:你是一个专业的小说创作助手,请用生动、细腻的文字进行创作..."
maxLength={10000}
showCount
style={{ fontSize: isMobile ? '13px' : '14px' }}
/>
</Form.Item>
{/* 测试结果展示 */}
{showTestResult && testResult && (
<Alert
message={
<Space>
{testResult.success ? (
<CheckCircleOutlined style={{ color: 'var(--color-success)', fontSize: isMobile ? '16px' : '18px' }} />
) : (
<CloseCircleOutlined style={{ color: 'var(--color-error)', fontSize: isMobile ? '16px' : '18px' }} />
)}
<span style={{ fontSize: isMobile ? '14px' : '16px', fontWeight: 500 }}>
{testResult.message}
</span>
</Space>
}
description={
<div style={{ marginTop: 8 }}>
{testResult.success ? (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{testResult.response_time_ms && (
<div style={{ fontSize: isMobile ? '12px' : '14px' }}>
: <strong>{testResult.response_time_ms} ms</strong>
</div>
)}
{testResult.response_preview && (
<div style={{
fontSize: isMobile ? '12px' : '13px',
padding: '8px 12px',
background: '#f6ffed',
borderRadius: '4px',
border: '1px solid #b7eb8f',
marginTop: '8px'
}}>
<div style={{ marginBottom: '4px', fontWeight: 500 }}>AI :</div>
<div style={{ color: '#595959' }}>{testResult.response_preview}</div>
</div>
)}
<div style={{ color: 'var(--color-success)', fontSize: isMobile ? '12px' : '13px', marginTop: '4px' }}>
API 使
</div>
</Space>
) : (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{testResult.error && (
<div style={{
fontSize: isMobile ? '12px' : '13px',
padding: '8px 12px',
background: '#fff2e8',
borderRadius: '4px',
border: '1px solid #ffbb96',
color: '#d4380d'
}}>
<strong>:</strong> {testResult.error}
</div>
)}
{testResult.error_type && (
<div style={{ fontSize: isMobile ? '11px' : '12px', color: 'var(--color-text-secondary)' }}>
: {testResult.error_type}
</div>
)}
{testResult.suggestions && testResult.suggestions.length > 0 && (
<div style={{ marginTop: '8px' }}>
<div style={{ fontSize: isMobile ? '12px' : '13px', fontWeight: 500, marginBottom: '4px' }}>
💡 :
</div>
<ul style={{
margin: 0,
paddingLeft: isMobile ? '16px' : '20px',
fontSize: isMobile ? '12px' : '13px',
color: '#595959'
}}>
{testResult.suggestions.map((suggestion, index) => (
<li key={index} style={{ marginBottom: '4px' }}>{suggestion}</li>
))}
</ul>
</div>
)}
</Space>
)}
</div>
}
type={testResult.success ? 'success' : 'error'}
closable
onClose={() => setShowTestResult(false)}
style={{ marginBottom: isMobile ? 16 : 24 }}
/>
)}
{/* 操作按钮 */}
<Form.Item style={{ marginBottom: 0, marginTop: isMobile ? 24 : 32 }}>
{isMobile ? (
// 移动端:垂直堆叠布局
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Button
type="primary"
size="large"
icon={<SaveOutlined />}
htmlType="submit"
loading={loading}
block
style={{
background: 'var(--color-primary)',
border: 'none',
height: '44px'
}}
>
</Button>
<Button
size="large"
icon={<ThunderboltOutlined />}
onClick={handleTestConnection}
loading={testingApi}
block
style={{
borderColor: 'var(--color-success)',
color: 'var(--color-success)',
fontWeight: 500,
height: '44px'
}}
>
{testingApi ? '测试中...' : '测试连接'}
</Button>
<Space size="middle" style={{ width: '100%' }}>
<Button
size="large"
icon={<ReloadOutlined />}
onClick={handleReset}
style={{ flex: 1, height: '44px' }}
>
</Button>
{hasSettings && (
<Button
danger
size="large"
icon={<DeleteOutlined />}
onClick={handleDelete}
loading={loading}
style={{ flex: 1, height: '44px' }}
>
</Button>
)}
</Space>
</Space>
) : (
// 桌面端:删除在左边,测试、重置和保存在右边
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap'
}}>
{/* 左侧:删除按钮 */}
{hasSettings ? (
<Button
danger
size="large"
icon={<DeleteOutlined />}
onClick={handleDelete}
loading={loading}
style={{
minWidth: '100px'
}}
>
</Button>
) : (
<div /> // 占位符,保持右侧按钮位置
)}
{/* 右侧:测试、重置和保存按钮组 */}
<Space size="middle">
<Button
size="large"
icon={<ThunderboltOutlined />}
onClick={handleTestConnection}
loading={testingApi}
style={{
borderColor: 'var(--color-success)',
color: 'var(--color-success)',
fontWeight: 500,
minWidth: '100px'
}}
>
{testingApi ? '测试中...' : '测试'}
</Button>
<Button
size="large"
icon={<ReloadOutlined />}
onClick={handleReset}
style={{
minWidth: '100px'
}}
>
</Button>
<Button
type="primary"
size="large"
icon={<SaveOutlined />}
htmlType="submit"
loading={loading}
style={{
background: 'var(--color-primary)',
border: 'none',
minWidth: '120px',
fontWeight: 500
}}
>
</Button>
</Space>
</div>
)}
</Form.Item>
</Form>
</Spin>
</Space>
),
},
{
key: 'presets',
label: '配置预设',
children: renderPresetsList(),
},
]}
/>
</Card>
</div>
{/* 预设编辑对话框 */}
<Modal
title={editingPreset ? '编辑预设' : '创建预设'}
open={isPresetModalVisible}
onOk={handlePresetSave}
onCancel={handlePresetCancel}
width={isMobile ? '90%' : 600}
centered
okText="保存"
cancelText="取消"
>
<Form
form={presetForm}
layout="vertical"
>
<Form.Item
name="name"
label="预设名称"
rules={[
{ required: true, message: '请输入预设名称' },
{ max: 50, message: '名称不能超过50个字符' },
]}
>
<Input placeholder="例如:工作账号-GPT4" />
</Form.Item>
<Form.Item
name="description"
label="预设描述"
rules={[{ max: 200, message: '描述不能超过200个字符' }]}
>
<TextArea rows={2} placeholder="例如:用于日常写作任务(可选)" />
</Form.Item>
<Form.Item
name="api_provider"
label="API 提供商"
rules={[{ required: true, message: '请选择API提供商' }]}
>
<Select>
<Select.Option value="openai">OpenAI</Select.Option>
{/* <Select.Option value="anthropic">Anthropic (Claude)</Select.Option> */}
<Select.Option value="gemini">Google Gemini</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="api_key"
label="API Key"
rules={[{ required: true, message: '请输入API Key' }]}
>
<Input.Password placeholder="sk-..." />
</Form.Item>
<Form.Item name="api_base_url" label="API Base URL">
<Input placeholder="https://api.openai.com/v1(可选)" />
</Form.Item>
<Form.Item
name="llm_model"
label="模型名称"
rules={[{ required: true, message: '请输入模型名称' }]}
>
<Input placeholder="例如:gpt-4, claude-3-opus-20240229" />
</Form.Item>
<Form.Item
name="temperature"
label="温度参数"
rules={[{ required: true, message: '请输入温度参数' }]}
>
<InputNumber
min={0}
max={2}
step={0.1}
style={{ width: '100%' }}
placeholder="0.7"
/>
</Form.Item>
<Form.Item
name="max_tokens"
label="最大 Tokens"
rules={[{ required: true, message: '请输入最大tokens' }]}
>
<InputNumber
min={1}
max={100000}
style={{ width: '100%' }}
placeholder="2000"
/>
</Form.Item>
<Form.Item
name="system_prompt"
label="系统提示词"
>
<TextArea
rows={3}
placeholder="例如:你是一个专业的小说创作助手...(可选)"
maxLength={10000}
showCount
/>
</Form.Item>
</Form>
</Modal>
</div>
</>
);
}