421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
ApiOutlined,
|
||
CheckCircleOutlined,
|
||
LockOutlined,
|
||
PlusOutlined,
|
||
RocketOutlined,
|
||
StarFilled
|
||
} from '@ant-design/icons';
|
||
import {
|
||
Button,
|
||
Modal,
|
||
Form,
|
||
Input,
|
||
Select,
|
||
Space,
|
||
Tag,
|
||
App as AntApp,
|
||
Empty,
|
||
Popconfirm,
|
||
Tooltip
|
||
} from 'antd';
|
||
import { LLMKind, LLMProvider, LLMProviderAPI } from '../api';
|
||
|
||
const KIND_OPTIONS: { value: LLMKind; label: string; baseUrl: string; hint?: string }[] = [
|
||
{ value: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1' },
|
||
{
|
||
value: 'openai-compatible',
|
||
label: 'OpenAI 兼容(GLM/通义/DeepSeek/腾讯 Token Plan)',
|
||
baseUrl: '',
|
||
hint: '智谱:https://open.bigmodel.cn/api/paas/v4 · 通义:https://dashscope.aliyuncs.com/compatible-mode/v1 · DeepSeek:https://api.deepseek.com'
|
||
},
|
||
{ value: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com' },
|
||
{ value: 'ollama', label: 'Ollama 本地', baseUrl: 'http://localhost:11434' }
|
||
];
|
||
|
||
export default function LLMProvidersPage() {
|
||
const { message } = AntApp.useApp();
|
||
const [list, setList] = useState<LLMProvider[]>([]);
|
||
const [editorOpen, setEditorOpen] = useState(false);
|
||
const [editing, setEditing] = useState<LLMProvider | null>(null);
|
||
const [form] = Form.useForm();
|
||
const [testing, setTesting] = useState<string | null>(null);
|
||
|
||
const load = async () => {
|
||
try {
|
||
setList(await LLMProviderAPI.list());
|
||
} catch (e: any) {
|
||
message.error('加载失败:' + (e?.message ?? e));
|
||
}
|
||
};
|
||
useEffect(() => {
|
||
load();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const openCreate = () => {
|
||
setEditing(null);
|
||
form.resetFields();
|
||
form.setFieldsValue({ kind: 'openai-compatible', enabled: true, isDefault: false });
|
||
setEditorOpen(true);
|
||
};
|
||
const openEdit = (p: LLMProvider) => {
|
||
setEditing(p);
|
||
form.setFieldsValue({
|
||
name: p.name,
|
||
kind: p.kind,
|
||
baseUrl: p.baseUrl,
|
||
models: p.models?.join(', ') || '',
|
||
defaultModel: p.defaultModel,
|
||
enabled: p.enabled,
|
||
isDefault: p.isDefault
|
||
});
|
||
setEditorOpen(true);
|
||
};
|
||
const onSave = async () => {
|
||
const v = await form.validateFields();
|
||
const payload = {
|
||
...v,
|
||
models: typeof v.models === 'string'
|
||
? v.models.split(/[,,]/).map((s: string) => s.trim()).filter(Boolean)
|
||
: v.models
|
||
};
|
||
try {
|
||
if (editing) {
|
||
await LLMProviderAPI.update(editing.id, payload);
|
||
message.success('已更新');
|
||
} else {
|
||
await LLMProviderAPI.create(payload);
|
||
message.success('已创建');
|
||
}
|
||
setEditorOpen(false);
|
||
load();
|
||
} catch (e: any) {
|
||
message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e));
|
||
}
|
||
};
|
||
const onDelete = async (p: LLMProvider) => {
|
||
await LLMProviderAPI.remove(p.id);
|
||
message.success('已删除');
|
||
load();
|
||
};
|
||
const onTest = async (p: LLMProvider) => {
|
||
setTesting(p.id);
|
||
try {
|
||
const r = await LLMProviderAPI.test(p.id, p.defaultModel);
|
||
if (r.ok) {
|
||
message.success(`✅ 连通:${r.model} · 用量 ${(r as any).usage?.TotalTokens ?? '?'} tokens`);
|
||
} else {
|
||
message.error('❌ 失败:' + (r.error || 'unknown'));
|
||
}
|
||
} catch (e: any) {
|
||
message.error('测试失败:' + (e?.response?.data?.error ?? e?.message ?? e));
|
||
} finally {
|
||
setTesting(null);
|
||
}
|
||
};
|
||
const onSetDefault = async (p: LLMProvider) => {
|
||
await LLMProviderAPI.setDefault(p.id);
|
||
message.success('已设为默认');
|
||
load();
|
||
};
|
||
|
||
return (
|
||
<div className="page-container" style={{ maxWidth: 1080 }}>
|
||
<div
|
||
style={{
|
||
borderRadius: 24,
|
||
padding: '30px 30px 26px',
|
||
background:
|
||
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
|
||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
||
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
|
||
marginBottom: 24
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
gap: 20,
|
||
flexWrap: 'wrap',
|
||
marginBottom: 20
|
||
}}
|
||
>
|
||
<div style={{ maxWidth: 620 }}>
|
||
<div
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '6px 12px',
|
||
borderRadius: 999,
|
||
background: 'rgba(255,255,255,0.78)',
|
||
border: '1px solid rgba(8, 145, 178, 0.10)',
|
||
color: 'var(--color-text-secondary)',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
marginBottom: 16
|
||
}}
|
||
>
|
||
<ApiOutlined style={{ color: 'var(--color-brand)' }} />
|
||
模型接入中心
|
||
</div>
|
||
<h1 className="page-title" style={{ marginBottom: 10 }}>LLM 提供商</h1>
|
||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||
把不同的模型能力接入到同一个工作台里。这里不只是存放接口配置,更是管理默认模型、候选模型与稳定连接状态的地方。
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
setEditing(null);
|
||
setEditorOpen(true);
|
||
form.resetFields();
|
||
form.setFieldsValue({ kind: 'openai', enabled: true });
|
||
}}
|
||
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
|
||
>
|
||
添加提供商
|
||
</Button>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
|
||
{[
|
||
{ label: '已接入提供商', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
||
{ label: '可用连接数', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
||
{ label: '默认模型源', value: list.filter((item) => item.isDefault).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
|
||
].map((item) => (
|
||
<div
|
||
key={item.label}
|
||
style={{
|
||
borderRadius: 18,
|
||
padding: '16px 18px',
|
||
background: 'rgba(255,255,255,0.72)',
|
||
border: '1px solid rgba(255,255,255,0.7)'
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
|
||
<span
|
||
style={{
|
||
borderRadius: 999,
|
||
padding: '4px 8px',
|
||
background: item.tone,
|
||
color: item.color,
|
||
fontSize: 12,
|
||
fontWeight: 600
|
||
}}
|
||
>
|
||
实时状态
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{list.length === 0 ? (
|
||
<div
|
||
style={{
|
||
borderRadius: 22,
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid var(--color-border)',
|
||
padding: '54px 24px'
|
||
}}
|
||
>
|
||
<Empty description="暂无 LLM 提供商,点击右上角添加第一个模型源" />
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', gap: 18 }}>
|
||
{list.map((p) => (
|
||
<div
|
||
key={p.id}
|
||
style={{
|
||
borderRadius: 20,
|
||
border: '1px solid var(--color-border)',
|
||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
||
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
||
padding: 20,
|
||
minHeight: 310,
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 6 }}>
|
||
<span style={{ fontWeight: 700, fontSize: 18, color: 'var(--color-text)' }}>{p.name}</span>
|
||
{p.isDefault && (
|
||
<Tag bordered={false} icon={<StarFilled />} style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0 }}>
|
||
默认
|
||
</Tag>
|
||
)}
|
||
{!p.enabled && (
|
||
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
|
||
已禁用
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>{p.baseUrl}</div>
|
||
</div>
|
||
|
||
<Space size={4}>
|
||
<Tooltip title="测试连通性(会发一条 ping 消息)">
|
||
<Button size="small" loading={testing === p.id} onClick={() => onTest(p)} style={{ borderRadius: 10 }}>
|
||
测试
|
||
</Button>
|
||
</Tooltip>
|
||
{!p.isDefault && (
|
||
<Tooltip title="设为我的默认 provider">
|
||
<Button size="small" onClick={() => onSetDefault(p)} style={{ borderRadius: 10 }}>
|
||
设为默认
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
</Space>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
|
||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>接入类型</div>
|
||
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0 }}>
|
||
{p.kind}
|
||
</Tag>
|
||
</div>
|
||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>默认模型</div>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>{p.defaultModel || '未设置'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
borderRadius: 16,
|
||
padding: '16px 16px 14px',
|
||
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
|
||
border: '1px solid rgba(148, 163, 184, 0.14)',
|
||
marginBottom: 16
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
||
<LockOutlined />
|
||
密钥状态
|
||
</div>
|
||
<div style={{ fontSize: 13.5, color: 'var(--color-text)' }}>
|
||
{p.hasApiKey ? (
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||
<CheckCircleOutlined style={{ color: 'var(--color-success)' }} />
|
||
已配置 {p.apiKeyMasked}
|
||
</span>
|
||
) : (
|
||
<span style={{ color: 'var(--color-danger)' }}>尚未配置 API Key</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 8, wordBreak: 'break-all' }}>{p.baseUrl}</div>
|
||
</div>
|
||
|
||
{p.models?.length > 0 && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>候选模型</div>
|
||
<Space size={6} wrap>
|
||
{p.models.map((m) => (
|
||
<Tag key={m} bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
|
||
{m}
|
||
</Tag>
|
||
))}
|
||
</Space>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
|
||
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
|
||
<span style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', flex: 1 }}>
|
||
{p.enabled ? '可用于聊天、工作流和测试连接' : '当前已暂停使用,需要重新启用'}
|
||
</span>
|
||
<Space size={4}>
|
||
<Button size="small" type="text" onClick={() => openEdit(p)} style={{ borderRadius: 10 }}>
|
||
编辑
|
||
</Button>
|
||
<Popconfirm title={`删除 ${p.name}?`} onConfirm={() => onDelete(p)}>
|
||
<Button size="small" type="text" danger style={{ borderRadius: 10 }}>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Modal
|
||
open={editorOpen}
|
||
title={editing ? '编辑 Provider' : '添加 Provider'}
|
||
onCancel={() => setEditorOpen(false)}
|
||
onOk={onSave}
|
||
width={600}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
destroyOnHidden
|
||
>
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
onValuesChange={(changed) => {
|
||
if (changed.kind) {
|
||
const opt = KIND_OPTIONS.find((o) => o.value === changed.kind);
|
||
if (opt?.baseUrl && !form.getFieldValue('baseUrl')) {
|
||
form.setFieldsValue({ baseUrl: opt.baseUrl });
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||
<Input placeholder="例如:我的 OpenAI" />
|
||
</Form.Item>
|
||
<Form.Item name="kind" label="类型" rules={[{ required: true }]}>
|
||
<Select>
|
||
{KIND_OPTIONS.map((k) => (
|
||
<Select.Option key={k.value} value={k.value}>
|
||
{k.label}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="baseUrl"
|
||
label="Base URL"
|
||
rules={[{ required: true }]}
|
||
tooltip={KIND_OPTIONS.find((k) => k.value === form.getFieldValue('kind'))?.hint}
|
||
>
|
||
<Input placeholder="https://api.openai.com/v1" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="apiKey"
|
||
label={editing ? 'API Key(留空保留原值)' : 'API Key'}
|
||
rules={editing ? [] : [{ required: true }]}
|
||
>
|
||
<Input.Password placeholder="sk-..." autoComplete="new-password" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="models"
|
||
label="可用模型(逗号分隔)"
|
||
tooltip="用于在 agent 编辑器中下拉选择"
|
||
>
|
||
<Input placeholder="gpt-4o, gpt-4o-mini" />
|
||
</Form.Item>
|
||
<Form.Item name="defaultModel" label="默认模型">
|
||
<Input placeholder="例如 gpt-4o-mini" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|