aura-web/src/pages/LLMProvidersPage.tsx

421 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 · DeepSeekhttps://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>
);
}