| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import React, { useState, useEffect } from 'react';
|
| import { useTranslation } from 'react-i18next';
|
| import {
|
| Modal,
|
| Button,
|
| Typography,
|
| Card,
|
| List,
|
| Space,
|
| Input,
|
| Spin,
|
| Popconfirm,
|
| Tag,
|
| Empty,
|
| Row,
|
| Col,
|
| Progress,
|
| Checkbox,
|
| } from '@douyinfe/semi-ui';
|
| import {
|
| IconDownload,
|
| IconDelete,
|
| IconRefresh,
|
| IconSearch,
|
| IconPlus,
|
| } from '@douyinfe/semi-icons';
|
| import {
|
| API,
|
| authHeader,
|
| getUserIdFromLocalStorage,
|
| showError,
|
| showSuccess,
|
| } from '../../../../helpers';
|
|
|
| const { Text, Title } = Typography;
|
|
|
| const CHANNEL_TYPE_OLLAMA = 4;
|
|
|
| const parseMaybeJSON = (value) => {
|
| if (!value) return null;
|
| if (typeof value === 'object') return value;
|
| if (typeof value === 'string') {
|
| try {
|
| return JSON.parse(value);
|
| } catch (error) {
|
| return null;
|
| }
|
| }
|
| return null;
|
| };
|
|
|
| const resolveOllamaBaseUrl = (info) => {
|
| if (!info) {
|
| return '';
|
| }
|
|
|
| const direct = typeof info.base_url === 'string' ? info.base_url.trim() : '';
|
| if (direct) {
|
| return direct;
|
| }
|
|
|
| const alt =
|
| typeof info.ollama_base_url === 'string' ? info.ollama_base_url.trim() : '';
|
| if (alt) {
|
| return alt;
|
| }
|
|
|
| const parsed = parseMaybeJSON(info.other_info);
|
| if (parsed && typeof parsed === 'object') {
|
| const candidate =
|
| (typeof parsed.base_url === 'string' && parsed.base_url.trim()) ||
|
| (typeof parsed.public_url === 'string' && parsed.public_url.trim()) ||
|
| (typeof parsed.api_url === 'string' && parsed.api_url.trim());
|
| if (candidate) {
|
| return candidate;
|
| }
|
| }
|
|
|
| return '';
|
| };
|
|
|
| const normalizeModels = (items) => {
|
| if (!Array.isArray(items)) {
|
| return [];
|
| }
|
|
|
| return items
|
| .map((item) => {
|
| if (!item) {
|
| return null;
|
| }
|
|
|
| if (typeof item === 'string') {
|
| return {
|
| id: item,
|
| owned_by: 'ollama',
|
| };
|
| }
|
|
|
| if (typeof item === 'object') {
|
| const candidateId =
|
| item.id || item.ID || item.name || item.model || item.Model;
|
| if (!candidateId) {
|
| return null;
|
| }
|
|
|
| const metadata = item.metadata || item.Metadata;
|
| const normalized = {
|
| ...item,
|
| id: candidateId,
|
| owned_by: item.owned_by || item.ownedBy || 'ollama',
|
| };
|
|
|
| if (typeof item.size === 'number' && !normalized.size) {
|
| normalized.size = item.size;
|
| }
|
| if (metadata && typeof metadata === 'object') {
|
| if (typeof metadata.size === 'number' && !normalized.size) {
|
| normalized.size = metadata.size;
|
| }
|
| if (!normalized.digest && typeof metadata.digest === 'string') {
|
| normalized.digest = metadata.digest;
|
| }
|
| if (
|
| !normalized.modified_at &&
|
| typeof metadata.modified_at === 'string'
|
| ) {
|
| normalized.modified_at = metadata.modified_at;
|
| }
|
| if (metadata.details && !normalized.details) {
|
| normalized.details = metadata.details;
|
| }
|
| }
|
|
|
| return normalized;
|
| }
|
|
|
| return null;
|
| })
|
| .filter(Boolean);
|
| };
|
|
|
| const OllamaModelModal = ({
|
| visible,
|
| onCancel,
|
| channelId,
|
| channelInfo,
|
| onModelsUpdate,
|
| onApplyModels,
|
| }) => {
|
| const { t } = useTranslation();
|
| const [loading, setLoading] = useState(false);
|
| const [models, setModels] = useState([]);
|
| const [filteredModels, setFilteredModels] = useState([]);
|
| const [searchValue, setSearchValue] = useState('');
|
| const [pullModelName, setPullModelName] = useState('');
|
| const [pullLoading, setPullLoading] = useState(false);
|
| const [pullProgress, setPullProgress] = useState(null);
|
| const [eventSource, setEventSource] = useState(null);
|
| const [selectedModelIds, setSelectedModelIds] = useState([]);
|
|
|
| const handleApplyAllModels = () => {
|
| if (!onApplyModels || selectedModelIds.length === 0) {
|
| return;
|
| }
|
| onApplyModels({ mode: 'append', modelIds: selectedModelIds });
|
| };
|
|
|
| const handleToggleModel = (modelId, checked) => {
|
| if (!modelId) {
|
| return;
|
| }
|
| setSelectedModelIds((prev) => {
|
| if (checked) {
|
| if (prev.includes(modelId)) {
|
| return prev;
|
| }
|
| return [...prev, modelId];
|
| }
|
| return prev.filter((id) => id !== modelId);
|
| });
|
| };
|
|
|
| const handleSelectAll = () => {
|
| setSelectedModelIds(models.map((item) => item?.id).filter(Boolean));
|
| };
|
|
|
| const handleClearSelection = () => {
|
| setSelectedModelIds([]);
|
| };
|
|
|
|
|
| const fetchModels = async () => {
|
| const channelType = Number(channelInfo?.type ?? CHANNEL_TYPE_OLLAMA);
|
| const shouldTryLiveFetch = channelType === CHANNEL_TYPE_OLLAMA;
|
| const resolvedBaseUrl = resolveOllamaBaseUrl(channelInfo);
|
|
|
| setLoading(true);
|
| let liveFetchSucceeded = false;
|
| let fallbackSucceeded = false;
|
| let lastError = '';
|
| let nextModels = [];
|
|
|
| try {
|
| if (shouldTryLiveFetch && resolvedBaseUrl) {
|
| try {
|
| const payload = {
|
| base_url: resolvedBaseUrl,
|
| type: CHANNEL_TYPE_OLLAMA,
|
| key: channelInfo?.key || '',
|
| };
|
|
|
| const res = await API.post('/api/channel/fetch_models', payload, {
|
| skipErrorHandler: true,
|
| });
|
|
|
| if (res?.data?.success) {
|
| nextModels = normalizeModels(res.data.data);
|
| liveFetchSucceeded = true;
|
| } else if (res?.data?.message) {
|
| lastError = res.data.message;
|
| }
|
| } catch (error) {
|
| const message = error?.response?.data?.message || error.message;
|
| if (message) {
|
| lastError = message;
|
| }
|
| }
|
| } else if (shouldTryLiveFetch && !resolvedBaseUrl && !channelId) {
|
| lastError = t('请先填写 Ollama API 地址');
|
| }
|
|
|
| if ((!liveFetchSucceeded || nextModels.length === 0) && channelId) {
|
| try {
|
| const res = await API.get(`/api/channel/fetch_models/${channelId}`, {
|
| skipErrorHandler: true,
|
| });
|
|
|
| if (res?.data?.success) {
|
| nextModels = normalizeModels(res.data.data);
|
| fallbackSucceeded = true;
|
| lastError = '';
|
| } else if (res?.data?.message) {
|
| lastError = res.data.message;
|
| }
|
| } catch (error) {
|
| const message = error?.response?.data?.message || error.message;
|
| if (message) {
|
| lastError = message;
|
| }
|
| }
|
| }
|
|
|
| if (!liveFetchSucceeded && !fallbackSucceeded && lastError) {
|
| showError(`${t('获取模型列表失败')}: ${lastError}`);
|
| }
|
|
|
| const normalized = nextModels;
|
| setModels(normalized);
|
| setFilteredModels(normalized);
|
| setSelectedModelIds((prev) => {
|
| if (!normalized || normalized.length === 0) {
|
| return [];
|
| }
|
| if (!prev || prev.length === 0) {
|
| return normalized.map((item) => item.id).filter(Boolean);
|
| }
|
| const available = prev.filter((id) =>
|
| normalized.some((item) => item.id === id),
|
| );
|
| return available.length > 0
|
| ? available
|
| : normalized.map((item) => item.id).filter(Boolean);
|
| });
|
| } finally {
|
| setLoading(false);
|
| }
|
| };
|
|
|
|
|
| const pullModel = async () => {
|
| if (!pullModelName.trim()) {
|
| showError(t('请输入模型名称'));
|
| return;
|
| }
|
|
|
| setPullLoading(true);
|
| setPullProgress({ status: 'starting', completed: 0, total: 0 });
|
|
|
| let hasRefreshed = false;
|
| const refreshModels = async () => {
|
| if (hasRefreshed) return;
|
| hasRefreshed = true;
|
| await fetchModels();
|
| if (onModelsUpdate) {
|
| onModelsUpdate({ silent: true });
|
| }
|
| };
|
|
|
| try {
|
|
|
| if (eventSource) {
|
| eventSource.close();
|
| setEventSource(null);
|
| }
|
|
|
| const controller = new AbortController();
|
| const closable = {
|
| close: () => controller.abort(),
|
| };
|
| setEventSource(closable);
|
|
|
|
|
| const authHeaders = authHeader();
|
| const userId = getUserIdFromLocalStorage();
|
| const fetchHeaders = {
|
| 'Content-Type': 'application/json',
|
| Accept: 'text/event-stream',
|
| 'New-API-User': String(userId),
|
| ...authHeaders,
|
| };
|
|
|
| const response = await fetch('/api/channel/ollama/pull/stream', {
|
| method: 'POST',
|
| headers: fetchHeaders,
|
| body: JSON.stringify({
|
| channel_id: channelId,
|
| model_name: pullModelName.trim(),
|
| }),
|
| signal: controller.signal,
|
| });
|
|
|
| if (!response.ok) {
|
| throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| }
|
|
|
| const reader = response.body.getReader();
|
| const decoder = new TextDecoder();
|
| let buffer = '';
|
|
|
|
|
| const processStream = async () => {
|
| try {
|
| while (true) {
|
| const { done, value } = await reader.read();
|
|
|
| if (done) break;
|
|
|
| buffer += decoder.decode(value, { stream: true });
|
| const lines = buffer.split('\n');
|
| buffer = lines.pop() || '';
|
|
|
| for (const line of lines) {
|
| if (!line.startsWith('data: ')) {
|
| continue;
|
| }
|
|
|
| try {
|
| const eventData = line.substring(6);
|
| if (eventData === '[DONE]') {
|
| setPullLoading(false);
|
| setPullProgress(null);
|
| setEventSource(null);
|
| return;
|
| }
|
|
|
| const data = JSON.parse(eventData);
|
|
|
| if (data.status) {
|
|
|
| setPullProgress(data);
|
| } else if (data.error) {
|
|
|
| showError(data.error);
|
| setPullProgress(null);
|
| setPullLoading(false);
|
| setEventSource(null);
|
| return;
|
| } else if (data.message) {
|
|
|
| showSuccess(data.message);
|
| setPullModelName('');
|
| setPullProgress(null);
|
| setPullLoading(false);
|
| setEventSource(null);
|
| await fetchModels();
|
| if (onModelsUpdate) {
|
| onModelsUpdate({ silent: true });
|
| }
|
| await refreshModels();
|
| return;
|
| }
|
| } catch (e) {
|
| console.error('Failed to parse SSE data:', e);
|
| }
|
| }
|
| }
|
|
|
| setPullLoading(false);
|
| setPullProgress(null);
|
| setEventSource(null);
|
| await refreshModels();
|
| } catch (error) {
|
| if (error?.name === 'AbortError') {
|
| setPullProgress(null);
|
| setPullLoading(false);
|
| setEventSource(null);
|
| return;
|
| }
|
| console.error('Stream processing error:', error);
|
| showError(t('数据传输中断'));
|
| setPullProgress(null);
|
| setPullLoading(false);
|
| setEventSource(null);
|
| await refreshModels();
|
| }
|
| };
|
|
|
| await processStream();
|
| } catch (error) {
|
| if (error?.name !== 'AbortError') {
|
| showError(t('模型拉取失败: {{error}}', { error: error.message }));
|
| }
|
| setPullLoading(false);
|
| setPullProgress(null);
|
| setEventSource(null);
|
| await refreshModels();
|
| }
|
| };
|
|
|
|
|
| const deleteModel = async (modelName) => {
|
| try {
|
| const res = await API.delete('/api/channel/ollama/delete', {
|
| data: {
|
| channel_id: channelId,
|
| model_name: modelName,
|
| },
|
| });
|
|
|
| if (res.data.success) {
|
| showSuccess(t('模型删除成功'));
|
| await fetchModels();
|
| if (onModelsUpdate) {
|
| onModelsUpdate({ silent: true });
|
| }
|
| } else {
|
| showError(res.data.message || t('模型删除失败'));
|
| }
|
| } catch (error) {
|
| showError(t('模型删除失败: {{error}}', { error: error.message }));
|
| }
|
| };
|
|
|
|
|
| useEffect(() => {
|
| if (!searchValue) {
|
| setFilteredModels(models);
|
| } else {
|
| const filtered = models.filter((model) =>
|
| model.id.toLowerCase().includes(searchValue.toLowerCase()),
|
| );
|
| setFilteredModels(filtered);
|
| }
|
| }, [models, searchValue]);
|
|
|
| useEffect(() => {
|
| if (!visible) {
|
| setSelectedModelIds([]);
|
| setPullModelName('');
|
| setPullProgress(null);
|
| setPullLoading(false);
|
| }
|
| }, [visible]);
|
|
|
|
|
| useEffect(() => {
|
| if (!visible) {
|
| return;
|
| }
|
|
|
| if (channelId || Number(channelInfo?.type) === CHANNEL_TYPE_OLLAMA) {
|
| fetchModels();
|
| }
|
| }, [
|
| visible,
|
| channelId,
|
| channelInfo?.type,
|
| channelInfo?.base_url,
|
| channelInfo?.other_info,
|
| channelInfo?.ollama_base_url,
|
| ]);
|
|
|
|
|
| useEffect(() => {
|
| return () => {
|
| if (eventSource) {
|
| eventSource.close();
|
| }
|
| };
|
| }, [eventSource]);
|
|
|
| const formatModelSize = (size) => {
|
| if (!size) return '-';
|
| const gb = size / (1024 * 1024 * 1024);
|
| return gb >= 1
|
| ? `${gb.toFixed(1)} GB`
|
| : `${(size / (1024 * 1024)).toFixed(0)} MB`;
|
| };
|
|
|
| return (
|
| <Modal
|
| title={t('Ollama 模型管理')}
|
| visible={visible}
|
| onCancel={onCancel}
|
| width={720}
|
| style={{ maxWidth: '95vw' }}
|
| footer={
|
| <Button theme='solid' type='primary' onClick={onCancel}>
|
| {t('关闭')}
|
| </Button>
|
| }
|
| >
|
| <Space vertical spacing='medium' style={{ width: '100%' }}>
|
| <div>
|
| <Text type='tertiary' size='small'>
|
| {channelInfo?.name ? `${channelInfo.name} - ` : ''}
|
| {t('管理 Ollama 模型的拉取和删除')}
|
| </Text>
|
| </div>
|
|
|
| {/* 拉取新模型 */}
|
| <Card>
|
| <Title heading={6} className='m-0 mb-3'>
|
| {t('拉取新模型')}
|
| </Title>
|
|
|
| <Row gutter={12} align='middle'>
|
| <Col span={16}>
|
| <Input
|
| placeholder={t('请输入模型名称,例如: llama3.2, qwen2.5:7b')}
|
| value={pullModelName}
|
| onChange={(value) => setPullModelName(value)}
|
| onEnterPress={pullModel}
|
| disabled={pullLoading}
|
| showClear
|
| />
|
| </Col>
|
| <Col span={8}>
|
| <Button
|
| theme='solid'
|
| type='primary'
|
| onClick={pullModel}
|
| loading={pullLoading}
|
| disabled={!pullModelName.trim()}
|
| icon={<IconDownload />}
|
| block
|
| >
|
| {pullLoading ? t('拉取中...') : t('拉取模型')}
|
| </Button>
|
| </Col>
|
| </Row>
|
|
|
| {/* 进度条显示 */}
|
| {pullProgress &&
|
| (() => {
|
| const completedBytes = Number(pullProgress.completed) || 0;
|
| const totalBytes = Number(pullProgress.total) || 0;
|
| const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
|
| const safePercent = hasTotal
|
| ? Math.min(
|
| 100,
|
| Math.max(
|
| 0,
|
| Math.round((completedBytes / totalBytes) * 100),
|
| ),
|
| )
|
| : null;
|
| const percentText =
|
| hasTotal && safePercent !== null
|
| ? `${safePercent.toFixed(0)}%`
|
| : pullProgress.status || t('处理中');
|
|
|
| return (
|
| <div style={{ marginTop: 12 }}>
|
| <div className='flex items-center justify-between mb-2'>
|
| <Text strong>{t('拉取进度')}</Text>
|
| <Text type='tertiary' size='small'>
|
| {percentText}
|
| </Text>
|
| </div>
|
|
|
| {hasTotal && safePercent !== null ? (
|
| <div>
|
| <Progress
|
| percent={safePercent}
|
| showInfo={false}
|
| stroke='#1890ff'
|
| size='small'
|
| />
|
| <div className='flex justify-between mt-1'>
|
| <Text type='tertiary' size='small'>
|
| {(completedBytes / (1024 * 1024 * 1024)).toFixed(2)}{' '}
|
| GB
|
| </Text>
|
| <Text type='tertiary' size='small'>
|
| {(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
|
| </Text>
|
| </div>
|
| </div>
|
| ) : (
|
| <div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
|
| <Spin size='small' />
|
| <span>{t('准备中...')}</span>
|
| </div>
|
| )}
|
| </div>
|
| );
|
| })()}
|
|
|
| <Text type='tertiary' size='small' className='mt-2 block'>
|
| {t(
|
| '支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间',
|
| )}
|
| </Text>
|
| </Card>
|
|
|
| {/* 已有模型列表 */}
|
| <Card>
|
| <div className='flex items-center justify-between mb-3'>
|
| <div className='flex items-center gap-2'>
|
| <Title heading={6} className='m-0'>
|
| {t('已有模型')}
|
| </Title>
|
| {models.length > 0 ? (
|
| <Tag color='blue'>{models.length}</Tag>
|
| ) : null}
|
| </div>
|
| <Space wrap>
|
| <Input
|
| prefix={<IconSearch />}
|
| placeholder={t('搜索模型...')}
|
| value={searchValue}
|
| onChange={(value) => setSearchValue(value)}
|
| style={{ width: 200 }}
|
| showClear
|
| />
|
| <Button
|
| size='small'
|
| theme='light'
|
| onClick={handleSelectAll}
|
| disabled={models.length === 0}
|
| >
|
| {t('全选')}
|
| </Button>
|
| <Button
|
| size='small'
|
| theme='light'
|
| onClick={handleClearSelection}
|
| disabled={selectedModelIds.length === 0}
|
| >
|
| {t('清空')}
|
| </Button>
|
| <Button
|
| theme='solid'
|
| type='primary'
|
| icon={<IconPlus />}
|
| onClick={handleApplyAllModels}
|
| disabled={selectedModelIds.length === 0}
|
| size='small'
|
| >
|
| {t('加入渠道')}
|
| </Button>
|
| <Button
|
| theme='light'
|
| type='primary'
|
| onClick={fetchModels}
|
| loading={loading}
|
| icon={<IconRefresh />}
|
| size='small'
|
| >
|
| {t('刷新')}
|
| </Button>
|
| </Space>
|
| </div>
|
|
|
| <Spin spinning={loading}>
|
| {filteredModels.length === 0 ? (
|
| <Empty
|
| title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
|
| description={
|
| searchValue
|
| ? t('请尝试其他搜索关键词')
|
| : t('您可以在上方拉取需要的模型')
|
| }
|
| style={{ padding: '40px 0' }}
|
| />
|
| ) : (
|
| <List
|
| dataSource={filteredModels}
|
| split
|
| renderItem={(model) => (
|
| <List.Item key={model.id}>
|
| <div className='flex items-center justify-between w-full'>
|
| <div className='flex items-center flex-1 min-w-0 gap-3'>
|
| <Checkbox
|
| checked={selectedModelIds.includes(model.id)}
|
| onChange={(checked) =>
|
| handleToggleModel(model.id, checked)
|
| }
|
| />
|
| <div className='flex-1 min-w-0'>
|
| <Text strong className='block truncate'>
|
| {model.id}
|
| </Text>
|
| <div className='flex items-center space-x-2 mt-1'>
|
| <Tag color='cyan' size='small'>
|
| {model.owned_by || 'ollama'}
|
| </Tag>
|
| {model.size && (
|
| <Text type='tertiary' size='small'>
|
| {formatModelSize(model.size)}
|
| </Text>
|
| )}
|
| </div>
|
| </div>
|
| </div>
|
| <div className='flex items-center space-x-2 ml-4'>
|
| <Popconfirm
|
| title={t('确认删除模型')}
|
| content={t(
|
| '删除后无法恢复,确定要删除模型 "{{name}}" 吗?',
|
| { name: model.id },
|
| )}
|
| onConfirm={() => deleteModel(model.id)}
|
| okText={t('确认')}
|
| cancelText={t('取消')}
|
| >
|
| <Button
|
| theme='borderless'
|
| type='danger'
|
| size='small'
|
| icon={<IconDelete />}
|
| />
|
| </Popconfirm>
|
| </div>
|
| </div>
|
| </List.Item>
|
| )}
|
| />
|
| )}
|
| </Spin>
|
| </Card>
|
| </Space>
|
| </Modal>
|
| );
|
| };
|
|
|
| export default OllamaModelModal;
|
|
|