Files
ai-novel/src/pages/ModelSettings.tsx
2026-04-16 21:32:21 +08:00

468 lines
15 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 React, { useState, useEffect } from 'react';
import { Card, Form, Input, InputNumber, Button, Space, Divider, Select, Spin, Tag, App } from 'antd';
import { ApiOutlined, SaveOutlined, SyncOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { OllamaService } from '../utils/ollama';
import { useOllama } from '../contexts/OllamaContext';
const { Option } = Select;
interface ModelInfo {
name: string;
size?: number;
modified?: string;
}
const ModelSettings: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [detectingModels, setDetectingModels] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
const [currentModel, setCurrentModel] = useState<string>('');
const { status, refreshModels } = useOllama();
const { message: messageApi } = App.useApp();
useEffect(() => {
const loadConfig = async () => {
// 从 localStorage 加载配置
const configData = localStorage.getItem('ai_system_config');
const config = configData ? JSON.parse(configData) : {
ollamaUrl: 'http://localhost:11434',
model: '',
temperature: 0.7,
topP: 0.9,
maxTokens: 2000
};
form.setFieldsValue(config);
setCurrentModel(config.model || '');
// 如果已连接且有模型,自动显示当前状态
if (status.isConnected && status.availableModels.length > 0) {
setAvailableModels(status.availableModels);
setConnectionStatus('success');
}
};
loadConfig();
}, [form, status]);
const handleDetectModels = async () => {
setDetectingModels(true);
setConnectionStatus('unknown');
try {
const values = await form.validateFields(['ollamaUrl']);
const ollamaService = new OllamaService(values);
// 测试连接
const isConnected = await ollamaService.testConnection();
if (!isConnected) {
setConnectionStatus('error');
messageApi.open({
type: 'error',
content: '无法连接到 Ollama 服务,请检查服务地址和状态',
});
setAvailableModels([]);
return;
}
// 获取模型列表
const models = await ollamaService.getAvailableModelsWithInfo();
setAvailableModels(models);
setConnectionStatus('success');
if (models.length === 0) {
messageApi.open({
type: 'warning',
content: '未检测到已安装的模型,请先使用 ollama pull 命令安装模型',
});
} else {
messageApi.open({
type: 'success',
content: `成功检测到 ${models.length} 个已安装模型`,
});
// 如果当前模型不在列表中,清空选择
if (currentModel && !models.find(m => m.name === currentModel)) {
form.setFieldValue('model', undefined);
setCurrentModel('');
messageApi.open({
type: 'warning',
content: '当前选择的模型未在本地安装,请重新选择',
});
}
}
// 刷新全局状态
await refreshModels();
} catch (error) {
setConnectionStatus('error');
messageApi.open({
type: 'error',
content: '模型检测失败,请检查 Ollama 服务状态',
});
setAvailableModels([]);
} finally {
setDetectingModels(false);
}
};
const handleTestConnection = async () => {
setTesting(true);
setConnectionStatus('unknown');
try {
const values = await form.validateFields();
const ollamaService = new OllamaService(values);
const isConnected = await ollamaService.testConnection();
if (isConnected) {
setConnectionStatus('success');
messageApi.open({
type: 'success',
content: 'Ollama 服务连接成功!',
});
await refreshModels();
} else {
setConnectionStatus('error');
messageApi.open({
type: 'error',
content: 'Ollama 服务连接失败,请检查服务地址',
});
}
} catch (error) {
setConnectionStatus('error');
messageApi.open({
type: 'error',
content: '连接测试失败,请检查配置',
});
} finally {
setTesting(false);
}
};
const handleModelChange = (value: string) => {
setCurrentModel(value);
};
const handleSave = async () => {
setLoading(true);
try {
const values = await form.validateFields();
// 验证选择的模型是否可用
if (currentModel && availableModels.length > 0) {
const modelExists = availableModels.find(m => m.name === currentModel);
if (!modelExists) {
messageApi.open({
type: 'error',
content: '请选择本地已安装的模型,或点击"检测模型"刷新列表',
});
setLoading(false);
return;
}
}
// 确保包含当前选择的模型
const configToSave = {
...values,
model: currentModel || values.model
};
// 保存到 localStorage
localStorage.setItem('ai_system_config', JSON.stringify(configToSave));
// 显示保存成功提示
messageApi.open({
type: 'success',
content: '配置保存成功!',
});
// 更新当前模型显示
setCurrentModel(configToSave.model);
// 刷新全局状态
await refreshModels();
} catch (error) {
messageApi.open({
type: 'error',
content: '配置保存失败,请检查输入',
});
} finally {
setLoading(false);
}
};
const formatModelSize = (bytes?: number) => {
if (!bytes) return '未知';
const gb = bytes / (1024 * 1024 * 1024);
return `${gb.toFixed(2)} GB`;
};
return (
<div>
<div style={{ marginBottom: '24px' }}>
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#262626' }}></h2>
<p style={{ margin: '8px 0 0 0', color: '#8c8c8c', fontSize: '14px' }}> Ollama AI </p>
</div>
<div style={{
background: 'white',
borderRadius: '8px',
border: '1px solid #f0f0f0',
marginBottom: '16px',
overflow: 'hidden'
}}>
<div style={{
padding: '16px 24px',
background: '#fafafa',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: connectionStatus === 'success' ? '#52c41a' : connectionStatus === 'error' ? '#ff4d4f' : '#faad14'
}} />
<div>
<div style={{ fontWeight: 500, color: '#262626' }}>
{connectionStatus === 'success' ? '服务正常' : connectionStatus === 'error' ? '服务异常' : '未检测'}
</div>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
{availableModels.length > 0
? `已安装 ${availableModels.length} 个模型`
: connectionStatus === 'success'
? '未安装模型'
: '请先连接服务'}
</div>
</div>
</div>
</div>
<Card bordered={false} style={{ boxShadow: 'none' }}>
<Form
form={form}
layout="vertical"
initialValues={{
ollamaUrl: 'http://localhost:11434',
model: '',
temperature: 0.7,
topP: 0.9,
maxTokens: 2000
}}
>
<div style={{ marginBottom: '24px' }}>
<div style={{
fontSize: '14px',
fontWeight: 500,
color: '#262626',
marginBottom: '16px'
}}>
</div>
<Form.Item
label={<span style={{ color: '#595959' }}>Ollama </span>}
name="ollamaUrl"
rules={[{ required: true, message: '请输入Ollama服务地址' }]}
>
<Input
placeholder="http://localhost:11434"
style={{ borderRadius: '6px' }}
/>
</Form.Item>
<Space style={{ marginBottom: '16px' }}>
<Button
icon={<ApiOutlined />}
onClick={handleTestConnection}
loading={testing}
style={{ borderRadius: '6px' }}
>
</Button>
<Button
type="primary"
icon={<SyncOutlined />}
onClick={handleDetectModels}
loading={detectingModels}
style={{ borderRadius: '6px' }}
>
</Button>
</Space>
</div>
<div style={{ marginBottom: '24px' }}>
<div style={{
fontSize: '14px',
fontWeight: 500,
color: '#262626',
marginBottom: '16px'
}}>
</div>
<Form.Item
label={
<Space>
<span style={{ color: '#595959' }}>AI </span>
{availableModels.length > 0 && (
<Tag color="success">{availableModels.length} </Tag>
)}
</Space>
}
name="model"
rules={[{
required: true,
message: '请选择AI模型'
}]}
tooltip="只能选择本地已安装的模型"
>
<Select
placeholder={availableModels.length === 0 ? "请先点击检测模型" : "选择AI模型"}
showSearch
allowClear
onChange={handleModelChange}
notFoundContent={detectingModels ? <Spin size="small" /> : "未检测到可用模型"}
disabled={availableModels.length === 0}
style={{ borderRadius: '6px' }}
>
{availableModels.map(model => (
<Option key={model.name} value={model.name}>
<Space>
<span>{model.name}</span>
{model.size && (
<span style={{ color: '#8c8c8c', fontSize: '12px' }}>
({formatModelSize(model.size)})
</span>
)}
</Space>
</Option>
))}
</Select>
</Form.Item>
{currentModel && (
<div style={{
padding: '12px',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '6px',
marginBottom: '16px'
}}>
<Space>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span style={{ color: '#52c41a', fontSize: '14px' }}>
使: {currentModel}
</span>
</Space>
</div>
)}
</div>
<div style={{ marginBottom: '24px' }}>
<div style={{
fontSize: '14px',
fontWeight: 500,
color: '#262626',
marginBottom: '16px'
}}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
<Form.Item
label={<span style={{ color: '#595959' }}></span>}
name="temperature"
rules={[{ required: true, message: '请输入温度值' }]}
tooltip="控制生成文本的随机性"
>
<InputNumber
min={0}
max={2}
step={0.1}
precision={1}
style={{ width: '100%', borderRadius: '6px' }}
/>
</Form.Item>
<Form.Item
label={<span style={{ color: '#595959' }}>Top P</span>}
name="topP"
rules={[{ required: true, message: '请输入Top P值' }]}
tooltip="控制生成文本的多样性"
>
<InputNumber
min={0}
max={1}
step={0.1}
precision={1}
style={{ width: '100%', borderRadius: '6px' }}
/>
</Form.Item>
<Form.Item
label={<span style={{ color: '#595959' }}> Tokens</span>}
name="maxTokens"
rules={[{ required: true, message: '请输入最大生成长度' }]}
>
<InputNumber
min={100}
max={8000}
step={100}
style={{ width: '100%', borderRadius: '6px' }}
/>
</Form.Item>
</div>
</div>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={loading}
size="large"
style={{ borderRadius: '6px', minWidth: '120px' }}
>
</Button>
</Form.Item>
</Form>
<Divider style={{ margin: '32px 0' }} />
<div>
<div style={{
fontSize: '14px',
fontWeight: 500,
color: '#262626',
marginBottom: '16px'
}}>
使
</div>
<ul style={{
margin: 0,
paddingLeft: '20px',
color: '#595959',
fontSize: '14px',
lineHeight: '1.8'
}}>
<li> Ollama 11434</li>
<li>"测试连接" Ollama </li>
<li>"检测模型"</li>
<li>使</li>
<li>使ollama pull </li>
<li>qwen3:8b起步</li>
<li> 0.7</li>
</ul>
</div>
</Card>
</div>
);
};
export default ModelSettings;