468 lines
15 KiB
TypeScript
468 lines
15 KiB
TypeScript
|
|
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;
|