feat: ionet integrate (#2105)

* wip ionet integrate

* wip ionet integrate

* wip ionet integrate

* ollama wip

* wip

* feat: ionet integration & ollama manage

* fix merge conflict

* wip

* fix: test conn cors

* wip

* fix ionet

* fix ionet

* wip

* fix model select

* refactor: Remove `pkg/ionet` test files and update related Go source and web UI model deployment components.

* feat: Enhance model deployment UI with styling improvements, updated text, and a new description component.

* Revert "feat: Enhance model deployment UI with styling improvements, updated text, and a new description component."

This reverts commit 8b75cb5bf0d1a534b339df8c033be9a6c7df7964.
This commit is contained in:
Seefs
2025-12-28 15:55:35 +08:00
committed by GitHub
parent 984ae32667
commit b10f1f7b85
51 changed files with 11895 additions and 369 deletions

View File

@@ -0,0 +1,52 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import DeploymentsTable from '../../components/table/model-deployments';
import DeploymentAccessGuard from '../../components/model-deployments/DeploymentAccessGuard';
import { useModelDeploymentSettings } from '../../hooks/model-deployments/useModelDeploymentSettings';
const ModelDeploymentPage = () => {
const {
loading,
isIoNetEnabled,
connectionLoading,
connectionOk,
connectionError,
apiKey,
testConnection,
} = useModelDeploymentSettings();
return (
<DeploymentAccessGuard
loading={loading}
isEnabled={isIoNetEnabled}
connectionLoading={connectionLoading}
connectionOk={connectionOk}
connectionError={connectionError}
onRetry={() => testConnection(apiKey)}
>
<div className='mt-[60px] px-2'>
<DeploymentsTable />
</div>
</DeploymentAccessGuard>
);
};
export default ModelDeploymentPage;

View File

@@ -0,0 +1,334 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin, Card, Typography } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
showError,
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { Server, Cloud, Zap, ArrowUpRight } from 'lucide-react';
const { Text } = Typography;
export default function SettingModelDeployment(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState({
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
});
const [testing, setTesting] = useState(false);
const testApiKey = async () => {
const apiKey = inputs['model_deployment.ionet.api_key'];
if (!apiKey || apiKey.trim() === '') {
showError(t('请先填写 API Key'));
return;
}
const getLocalizedMessage = (message) => {
switch (message) {
case 'invalid request payload':
return t('请求参数无效');
case 'api_key is required':
return t('请先填写 API Key');
case 'failed to validate api key':
return t('API Key 验证失败');
default:
return message;
}
};
setTesting(true);
try {
const response = await API.post(
'/api/deployments/test-connection',
{
api_key: apiKey.trim(),
},
{
skipErrorHandler: true,
},
);
if (response?.data?.success) {
showSuccess(t('API Key 验证成功!连接到 io.net 服务正常'));
} else {
const rawMessage = response?.data?.message;
const localizedMessage = rawMessage
? getLocalizedMessage(rawMessage)
: t('API Key 验证失败');
showError(localizedMessage);
}
} catch (error) {
console.error('io.net API test error:', error);
if (error?.code === 'ERR_NETWORK') {
showError(t('网络连接失败,请检查网络设置或稍后重试'));
} else {
const rawMessage =
error?.response?.data?.message ||
error?.message ||
'';
const localizedMessage = rawMessage
? getLocalizedMessage(rawMessage)
: t('未知错误');
showError(t('测试失败:') + localizedMessage);
}
} finally {
setTesting(false);
}
};
function onSubmit() {
// 前置校验:如果启用了 io.net 但没有填写 API Key
if (inputs['model_deployment.ionet.enabled'] &&
(!inputs['model_deployment.ionet.api_key'] || inputs['model_deployment.ionet.api_key'].trim() === '')) {
return showError(t('启用 io.net 部署时必须填写 API Key'));
}
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = String(inputs[item.key]);
return API.put('/api/option/', {
key: item.key,
value,
});
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError(t('部分保存失败,请重试'));
}
showSuccess(t('保存成功'));
// 更新 inputsRow 以反映已保存的状态
setInputsRow(structuredClone(inputs));
props.refresh();
})
.catch(() => {
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
}
useEffect(() => {
if (props.options) {
const defaultInputs = {
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
};
const currentInputs = {};
for (let key in defaultInputs) {
if (props.options.hasOwnProperty(key)) {
currentInputs[key] = props.options[key];
} else {
currentInputs[key] = defaultInputs[key];
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current?.setValues(currentInputs);
}
}, [props.options]);
return (
<>
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section
text={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{t('模型部署设置')}</span>
</div>
}
>
{/*<Text */}
{/* type="secondary" */}
{/* size="small"*/}
{/* style={{ */}
{/* display: 'block', */}
{/* marginBottom: '20px',*/}
{/* color: 'var(--semi-color-text-2)'*/}
{/* }}*/}
{/*>*/}
{/* {t('配置模型部署服务提供商的API密钥和启用状态')}*/}
{/*</Text>*/}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Cloud size={18} />
<span>io.net</span>
</div>
}
bodyStyle={{ padding: '20px' }}
style={{ marginBottom: '16px' }}
>
<Row gutter={24}>
<Col xs={24} lg={14}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<Form.Switch
label={t('启用 io.net 部署')}
field={'model_deployment.ionet.enabled'}
onChange={(value) =>
setInputs({
...inputs,
'model_deployment.ionet.enabled': value,
})
}
extraText={t('启用后可接入 io.net GPU 资源')}
/>
<Form.Input
label={t('API Key')}
field={'model_deployment.ionet.api_key'}
placeholder={t('请输入 io.net API Key')}
onChange={(value) =>
setInputs({
...inputs,
'model_deployment.ionet.api_key': value,
})
}
disabled={!inputs['model_deployment.ionet.enabled']}
extraText={t('请使用 Project 为 io.cloud 的密钥')}
mode="password"
/>
<div style={{ display: 'flex', gap: '12px' }}>
<Button
type="outline"
size="small"
icon={<Zap size={16} />}
onClick={testApiKey}
loading={testing}
disabled={
!inputs['model_deployment.ionet.enabled'] ||
!inputs['model_deployment.ionet.api_key'] ||
inputs['model_deployment.ionet.api_key'].trim() === ''
}
style={{
height: '32px',
fontSize: '13px',
borderRadius: '6px',
fontWeight: '500',
borderColor: testing
? 'var(--semi-color-primary)'
: 'var(--semi-color-border)',
color: testing
? 'var(--semi-color-primary)'
: 'var(--semi-color-text-0)',
}}
>
{testing ? t('连接测试中...') : t('测试连接')}
</Button>
</div>
</div>
</Col>
<Col xs={24} lg={10}>
<div
style={{
background: 'var(--semi-color-fill-0)',
padding: '16px',
borderRadius: '8px',
border: '1px solid var(--semi-color-border)',
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: '12px',
justifyContent: 'space-between',
}}
>
<div>
<Text strong style={{ display: 'block', marginBottom: '8px' }}>
{t('获取 io.net API Key')}
</Text>
<ul
style={{
margin: 0,
paddingLeft: '18px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
color: 'var(--semi-color-text-2)',
fontSize: '13px',
lineHeight: 1.6,
}}
>
<li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
<li>{t('创建或选择密钥时,将 Project 设置为 io.cloud')}</li>
<li>{t('复制生成的密钥并粘贴到此处')}</li>
</ul>
</div>
<Button
icon={<ArrowUpRight size={16} />}
type="primary"
theme="solid"
style={{ width: '100%' }}
onClick={() =>
window.open('https://ai.io.net/ai/api-keys', '_blank')
}
>
{t('前往 io.net API Keys')}
</Button>
</div>
</Col>
</Row>
</Card>
<Row>
<Button size='default' type="primary" onClick={onSubmit}>
{t('保存设置')}
</Button>
</Row>
</Form.Section>
</Form>
</Spin>
</>
);
}

View File

@@ -62,6 +62,7 @@ export default function SettingsSidebarModulesAdmin(props) {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
@@ -121,6 +122,7 @@ export default function SettingsSidebarModulesAdmin(props) {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
@@ -188,6 +190,7 @@ export default function SettingsSidebarModulesAdmin(props) {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
@@ -249,6 +252,7 @@ export default function SettingsSidebarModulesAdmin(props) {
modules: [
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
{ key: 'deployment', title: t('模型部署'), description: t('模型部署管理') },
{
key: 'redemption',
title: t('兑换码管理'),

View File

@@ -104,6 +104,7 @@ export default function SettingsSidebarModulesUser() {
enabled: true,
channel: isSidebarModuleAllowed('admin', 'channel'),
models: isSidebarModuleAllowed('admin', 'models'),
deployment: isSidebarModuleAllowed('admin', 'deployment'),
redemption: isSidebarModuleAllowed('admin', 'redemption'),
user: isSidebarModuleAllowed('admin', 'user'),
setting: isSidebarModuleAllowed('admin', 'setting'),

View File

@@ -32,6 +32,7 @@ import {
MessageSquare,
Palette,
CreditCard,
Server,
} from 'lucide-react';
import SystemSetting from '../../components/settings/SystemSetting';
@@ -45,6 +46,7 @@ import RatioSetting from '../../components/settings/RatioSetting';
import ChatsSetting from '../../components/settings/ChatsSetting';
import DrawingSetting from '../../components/settings/DrawingSetting';
import PaymentSetting from '../../components/settings/PaymentSetting';
import ModelDeploymentSetting from '../../components/settings/ModelDeploymentSetting';
const Setting = () => {
const { t } = useTranslation();
@@ -134,6 +136,16 @@ const Setting = () => {
content: <ModelSetting />,
itemKey: 'models',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<Server size={18} />
{t('模型部署设置')}
</span>
),
content: <ModelDeploymentSetting />,
itemKey: 'model-deployment',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>