fix: 修复 5 项确定 bug + Provider UX 重做 + 文档统一
Bug fixes: - fix(dao): AsyncSession.delete 补齐漏掉的 await(provider/user/individual 共 4 处) - fix(worker): result.data.output → result.output.output(pydantic-ai 1.x API 适配) - fix(api): 删除 create_worker_from_template 死端点(ORM 字段不匹配必崩) - fix(api): /provider/test 按 provider_type 分支适配 Anthropic/Gemini/OpenAI 三种协议 - fix(chat): SSE 流式聊天在 distributed 模式 fallback 到非流式,避免 asyncio.Queue 序列化崩溃 Features (previously unstaged): - feat(provider): Provider 管理页重做(品牌图标、5 种类型、Test Connection、编辑模式) - feat(provider): 新增 Gemini provider_type 支持 - feat(workflow): Finalize 节点输出 blackboard 摘要 + 失败原因;步骤完成/失败实时推送 SSE - feat(i18n): regulatory_node 提示词从路由模式改为直接对话模式(中英双语) - feat(consciousness): dynamic_prompt 支持 locale 国际化 - feat(logs): SystemLogsView 自动刷新 + 暂停按钮 Docs: - docs: README/README-EN 统一为"开源通用多 Agent 协作平台"口径 - docs: ROADMAP 按 v0.1.x / v0.2.x / v0.3.x 重组 - docs: project.md 重写为结构化项目介绍 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
interface ProviderIconProps {
|
||||
type: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProviderIcon({ type, size = 18, className = '' }: ProviderIconProps) {
|
||||
const props = {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor',
|
||||
className,
|
||||
};
|
||||
|
||||
switch (type?.toLowerCase()) {
|
||||
case 'openai':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'claude':
|
||||
case 'anthropic':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path d="M4.709 15.955l4.72-2.647.079-.23-.079-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.328h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'deepseek':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.846-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'gemini':
|
||||
case 'google':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'local':
|
||||
case 'ollama':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M7 20h10M9 16v4M15 16v4" />
|
||||
<circle cx="8" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="10" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
<path d="M9 9h6v6H9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDER_BRAND_COLORS: Record<string, string> = {
|
||||
openai: '#10a37f',
|
||||
claude: '#d97757',
|
||||
anthropic: '#d97757',
|
||||
deepseek: '#4d6bfe',
|
||||
gemini: '#4285f4',
|
||||
google: '#4285f4',
|
||||
local: '#8a8a8a',
|
||||
ollama: '#8a8a8a',
|
||||
};
|
||||
|
||||
export function getProviderBrandColor(type: string): string {
|
||||
return PROVIDER_BRAND_COLORS[type?.toLowerCase()] || '#8a8a8a';
|
||||
}
|
||||
@@ -1,17 +1,51 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Plus, X, Server, Loader2, Boxes } from 'lucide-react';
|
||||
import { Plus, X, Loader2, Boxes, Zap, ChevronDown, ChevronRight, Settings as SettingsIcon } from 'lucide-react';
|
||||
import type { Provider } from '../../types';
|
||||
import apiClient from '../../api/client';
|
||||
import { ProviderIcon, getProviderBrandColor } from './ProviderIcon';
|
||||
|
||||
interface ProviderTypeOption {
|
||||
id: string;
|
||||
iconKey: string;
|
||||
backendType: 'openai' | 'claude' | 'deepseek' | 'gemini';
|
||||
defaultUrl: string;
|
||||
descKey: string;
|
||||
nameKey: string;
|
||||
}
|
||||
|
||||
const PROVIDER_TYPES: ProviderTypeOption[] = [
|
||||
{ id: 'openai', iconKey: 'openai', backendType: 'openai', defaultUrl: 'https://api.openai.com/v1', nameKey: 'agent.providerTypeOpenai', descKey: 'agent.providerTypeOpenaiDesc' },
|
||||
{ id: 'openai_compat', iconKey: 'openai', backendType: 'openai', defaultUrl: '', nameKey: 'agent.providerTypeOpenaiCompat', descKey: 'agent.providerTypeOpenaiCompatDesc' },
|
||||
{ id: 'anthropic', iconKey: 'claude', backendType: 'claude', defaultUrl: 'https://api.anthropic.com', nameKey: 'agent.providerTypeAnthropic', descKey: 'agent.providerTypeAnthropicDesc' },
|
||||
{ id: 'gemini', iconKey: 'gemini', backendType: 'gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta', nameKey: 'agent.providerTypeGemini', descKey: 'agent.providerTypeGeminiDesc' },
|
||||
{ id: 'deepseek', iconKey: 'deepseek', backendType: 'deepseek', defaultUrl: 'https://api.deepseek.com/v1', nameKey: 'agent.providerTypeDeepseek', descKey: 'agent.providerTypeDeepseekDesc' },
|
||||
];
|
||||
|
||||
function detectTypeFromProvider(p: Provider): string {
|
||||
if (p.provider_type === 'openai') {
|
||||
return p.provider_url?.includes('api.openai.com') ? 'openai' : 'openai_compat';
|
||||
}
|
||||
if (p.provider_type === 'claude') return 'anthropic';
|
||||
return p.provider_type || 'openai';
|
||||
}
|
||||
|
||||
export function ProvidersSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({ provider_type: 'openai', provider_title: '', provider_url: '', provider_apikey: '' });
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||
const [selectedTypeId, setSelectedTypeId] = useState<string>('openai');
|
||||
const [formData, setFormData] = useState({ provider_title: '', provider_url: '', provider_apikey: '', custom_models: '' });
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; error?: string; model_count?: number } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
|
||||
const selectedType = PROVIDER_TYPES.find((p) => p.id === selectedTypeId) || PROVIDER_TYPES[0];
|
||||
|
||||
const fetchProviders = async () => {
|
||||
setLoading(true);
|
||||
@@ -19,7 +53,7 @@ export function ProvidersSettings() {
|
||||
const response = await apiClient.get('/api/v1/provider/list');
|
||||
setProviders(Object.values(response.data.provider_list || {}));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers", error);
|
||||
console.error('Failed to fetch providers', error);
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -28,6 +62,72 @@ export function ProvidersSettings() {
|
||||
|
||||
useEffect(() => { fetchProviders(); }, []);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingProvider(null);
|
||||
setSelectedTypeId('openai');
|
||||
setFormData({ provider_title: '', provider_url: PROVIDER_TYPES[0].defaultUrl, provider_apikey: '', custom_models: '' });
|
||||
setError('');
|
||||
setTestResult(null);
|
||||
setShowAdvanced(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (provider: Provider) => {
|
||||
const typeId = detectTypeFromProvider(provider);
|
||||
setEditingProvider(provider.provider_title);
|
||||
setSelectedTypeId(typeId);
|
||||
setFormData({
|
||||
provider_title: provider.provider_title,
|
||||
provider_url: provider.provider_url || '',
|
||||
provider_apikey: '',
|
||||
custom_models: '',
|
||||
});
|
||||
setError('');
|
||||
setTestResult(null);
|
||||
setShowAdvanced(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSelectType = (typeId: string) => {
|
||||
setSelectedTypeId(typeId);
|
||||
if (!editingProvider) {
|
||||
const tp = PROVIDER_TYPES.find((p) => p.id === typeId);
|
||||
if (tp) setFormData((prev) => ({ ...prev, provider_url: tp.defaultUrl }));
|
||||
}
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const buildPayload = () => {
|
||||
const customModels = formData.custom_models
|
||||
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const payload: any = {
|
||||
provider_type: selectedType.backendType,
|
||||
provider_title: formData.provider_title,
|
||||
provider_url: formData.provider_url,
|
||||
provider_apikey: formData.provider_apikey,
|
||||
};
|
||||
if (customModels.length > 0) payload.custom_models = customModels;
|
||||
return payload;
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!formData.provider_url || !formData.provider_apikey) {
|
||||
setError(t('agent.providerFillUrlAndKey'));
|
||||
return;
|
||||
}
|
||||
setTestLoading(true);
|
||||
setTestResult(null);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await apiClient.post('/api/v1/provider/test', buildPayload());
|
||||
setTestResult(resp.data);
|
||||
} catch {
|
||||
setTestResult({ success: false, error: 'Request failed' });
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.provider_title || !formData.provider_url || !formData.provider_apikey) {
|
||||
@@ -37,9 +137,13 @@ export function ProvidersSettings() {
|
||||
setSubmitLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post('/api/v1/provider', formData);
|
||||
if (editingProvider) {
|
||||
await apiClient.delete(`/api/v1/provider/${editingProvider}`);
|
||||
}
|
||||
await apiClient.post('/api/v1/provider', buildPayload());
|
||||
await fetchProviders();
|
||||
setIsModalOpen(false);
|
||||
setEditingProvider(null);
|
||||
} catch (err) {
|
||||
setError(t('agent.providerAddFailed'));
|
||||
} finally {
|
||||
@@ -54,7 +158,7 @@ export function ProvidersSettings() {
|
||||
<h3 className="text-lg font-bold text-text-primary">{t('agent.providerManagement')}</h3>
|
||||
<p className="text-sm text-text-muted mt-0.5">{t('agent.providerDesc')}</p>
|
||||
</div>
|
||||
<button onClick={() => { setFormData({ provider_type: 'openai', provider_title: '', provider_url: '', provider_apikey: '' }); setError(''); setIsModalOpen(true); }}
|
||||
<button onClick={openAddModal}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
|
||||
<Plus size={14} /> {t('agent.addProvider')}
|
||||
</button>
|
||||
@@ -76,25 +180,43 @@ export function ProvidersSettings() {
|
||||
<div key={i} className="bg-bg-card border border-border-primary rounded-2xl p-5 card-hover">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-bg-secondary border border-border-secondary flex items-center justify-center">
|
||||
<Box size={18} className="text-text-secondary" />
|
||||
<div className="w-9 h-9 rounded-xl bg-bg-secondary border border-border-secondary flex items-center justify-center" style={{ color: getProviderBrandColor(provider.provider_type) }}>
|
||||
<ProviderIcon type={provider.provider_type} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm text-text-primary">{provider.provider_title}</h4>
|
||||
<span className="text-[10px] text-text-muted font-mono uppercase">{provider.provider_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex items-center gap-1 text-[10px] font-medium px-2 py-1 rounded-lg border ${provider.status === 'Connected' ? 'bg-success-bg text-success border-success/20' : 'bg-bg-secondary text-text-muted border-border-primary'}`}>
|
||||
{provider.status === 'Connected' && <span className="w-1 h-1 rounded-full bg-success" />}
|
||||
{provider.status || t('common.unknown')}
|
||||
<span className={`flex items-center gap-1 text-[10px] font-medium px-2 py-1 rounded-lg border ${provider.status === 'Connected' || provider.provider_status === 'up' ? 'bg-success-bg text-success border-success/20' : 'bg-bg-secondary text-text-muted border-border-primary'}`}>
|
||||
{(provider.status === 'Connected' || provider.provider_status === 'up') && <span className="w-1 h-1 rounded-full bg-success" />}
|
||||
{provider.provider_status === 'up' ? 'Connected' : provider.status || t('common.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-bg-secondary rounded-lg px-3 py-2 mb-4">
|
||||
<div className="bg-bg-secondary rounded-lg px-3 py-2 mb-3">
|
||||
<p className="text-[10px] text-text-muted mb-0.5">{t('agent.endpoint')}</p>
|
||||
<p className="text-xs font-mono text-text-secondary truncate">{provider.provider_url || t('common.default')}</p>
|
||||
</div>
|
||||
{provider.provider_models && provider.provider_models.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={() => setExpandedProvider(expandedProvider === provider.provider_title ? null : provider.provider_title)}
|
||||
className="flex items-center gap-1.5 text-[11px] text-text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
{expandedProvider === provider.provider_title ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
{provider.provider_models.length} {t('agent.providerModels')}
|
||||
</button>
|
||||
{expandedProvider === provider.provider_title && (
|
||||
<div className="mt-2 bg-bg-secondary rounded-lg p-2 max-h-32 overflow-y-auto">
|
||||
{provider.provider_models.map((model: string) => (
|
||||
<div key={model} className="text-[11px] font-mono text-text-secondary py-0.5 px-1.5">{model}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="px-3 py-1.5 text-xs font-medium text-text-secondary bg-bg-secondary hover:bg-bg-hover rounded-lg transition-colors border border-border-primary">{t('common.edit')}</button>
|
||||
<button onClick={() => openEditModal(provider)} className="px-3 py-1.5 text-xs font-medium text-text-secondary bg-bg-secondary hover:bg-bg-hover rounded-lg transition-colors border border-border-primary">{t('common.edit')}</button>
|
||||
<button onClick={async () => {
|
||||
if (!confirm(t('agent.deleteProviderConfirm'))) return;
|
||||
try { await apiClient.delete(`/api/v1/provider/${provider.provider_title}`); fetchProviders(); } catch { alert(t('common.deleteFailed')); }
|
||||
@@ -105,46 +227,171 @@ export function ProvidersSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{/* Modal — 2 column layout */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-md overflow-hidden border border-border-primary animate-fade-in-scale">
|
||||
<div className="flex justify-between items-center p-5 border-b border-border-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={16} className="text-accent" />
|
||||
<h3 className="text-base font-bold text-text-primary">{t('agent.addNewProvider')}</h3>
|
||||
</div>
|
||||
<button onClick={() => setIsModalOpen(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={18} /></button>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden border border-border-primary animate-fade-in-scale flex flex-col max-h-[90vh]">
|
||||
<div className="flex justify-between items-center px-5 py-4 border-b border-border-primary shrink-0">
|
||||
<h3 className="text-base font-bold text-text-primary">
|
||||
{editingProvider ? t('agent.editProvider') : t('agent.addNewProvider')}
|
||||
</h3>
|
||||
<button onClick={() => { setIsModalOpen(false); setEditingProvider(null); }} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{error && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.providerType')}</label>
|
||||
<select name="provider_type" value={formData.provider_type} onChange={(e) => setFormData({...formData, provider_type: e.target.value})}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="local">Local</option>
|
||||
</select>
|
||||
</div>
|
||||
{['provider_title', 'provider_url', 'provider_apikey'].map((field) => (
|
||||
<div key={field}>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{field === 'provider_title' ? t('agent.providerTitle') : field === 'provider_url' ? t('agent.baseUrl') : t('agent.apiKey')}
|
||||
</label>
|
||||
<input type={field === 'provider_apikey' ? 'password' : field === 'provider_url' ? 'url' : 'text'}
|
||||
name={field} value={(formData as any)[field]}
|
||||
onChange={(e) => setFormData({...formData, [field]: e.target.value})}
|
||||
placeholder={field === 'provider_title' ? t('agent.providerTitlePlaceholder') : field === 'provider_url' ? t('agent.baseUrlPlaceholder') : t('agent.apiKeyPlaceholder')}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono" />
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 overflow-hidden">
|
||||
{/* Left: provider type list */}
|
||||
<div className="w-56 shrink-0 border-r border-border-primary bg-bg-secondary/40 overflow-y-auto p-3 space-y-1">
|
||||
<div className="text-[10px] font-bold text-text-muted uppercase tracking-wider px-2 py-1.5">
|
||||
{t('agent.providerType')}
|
||||
</div>
|
||||
{PROVIDER_TYPES.map((type) => {
|
||||
const active = selectedTypeId === type.id;
|
||||
return (
|
||||
<button
|
||||
key={type.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectType(type.id)}
|
||||
disabled={!!editingProvider}
|
||||
className={`w-full flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-left transition-all ${
|
||||
active
|
||||
? 'bg-bg-card border border-accent/40 shadow-sm'
|
||||
: 'border border-transparent hover:bg-bg-card/60'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg bg-bg-card border border-border-primary flex items-center justify-center shrink-0"
|
||||
style={{ color: getProviderBrandColor(type.iconKey) }}
|
||||
>
|
||||
<ProviderIcon type={type.iconKey} size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-xs font-semibold truncate ${active ? 'text-accent' : 'text-text-primary'}`}>
|
||||
{t(type.nameKey)}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted truncate">{t(type.descKey)}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: form */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{error && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>}
|
||||
{testResult && (
|
||||
<div className={`p-3 text-sm rounded-xl border ${testResult.success ? 'bg-success-bg text-success border-success/20' : 'bg-danger-bg text-danger border-danger/20'}`}>
|
||||
{testResult.success
|
||||
? `${t('agent.providerTestSuccess')} · ${testResult.model_count} ${t('agent.providerModels')}`
|
||||
: `${t('agent.providerTestFailed')}: ${testResult.error}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.providerTitle')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.provider_title}
|
||||
onChange={(e) => setFormData({ ...formData, provider_title: e.target.value })}
|
||||
placeholder={t('agent.providerTitlePlaceholder')}
|
||||
disabled={!!editingProvider}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.baseUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.provider_url}
|
||||
onChange={(e) => setFormData({ ...formData, provider_url: e.target.value })}
|
||||
placeholder={selectedType.defaultUrl || t('agent.baseUrlPlaceholder')}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono"
|
||||
/>
|
||||
{selectedType.defaultUrl && !editingProvider && (
|
||||
<p className="text-[10px] text-text-muted mt-1">
|
||||
{t('agent.baseUrlHint')}: <span className="font-mono">{selectedType.defaultUrl}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.apiKey')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.provider_apikey}
|
||||
onChange={(e) => setFormData({ ...formData, provider_apikey: e.target.value })}
|
||||
placeholder={editingProvider ? t('agent.apiKeyEditPlaceholder') : t('agent.apiKeyPlaceholder')}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 参数设置 — collapsible */}
|
||||
<div className="border border-border-primary rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-2.5 bg-bg-secondary/50 hover:bg-bg-secondary transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-text-secondary">
|
||||
<SettingsIcon size={13} />
|
||||
{t('agent.providerAdvanced')}
|
||||
</div>
|
||||
{showAdvanced ? <ChevronDown size={14} className="text-text-muted" /> : <ChevronRight size={14} className="text-text-muted" />}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="p-4 space-y-3 bg-bg-card">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.providerCustomModels')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.custom_models}
|
||||
onChange={(e) => setFormData({ ...formData, custom_models: e.target.value })}
|
||||
placeholder={t('agent.providerCustomModelsPlaceholder')}
|
||||
rows={3}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono resize-none"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-1">{t('agent.providerCustomModelsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={submitLoading} className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">{submitLoading ? t('common.saving') : t('agent.addProvider')}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-border-primary bg-bg-secondary/30 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-accent bg-accent-light hover:bg-accent/20 rounded-xl transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testLoading ? <Loader2 size={12} className="animate-spin" /> : <Zap size={12} />}
|
||||
{t('agent.testConnection')}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { setIsModalOpen(false); setEditingProvider(null); }} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitLoading ? t('common.saving') : editingProvider ? t('common.save') : t('agent.addProvider')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, Server, GitBranch, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { RefreshCw, Server, GitBranch, ChevronDown, ChevronRight, Pause, Play } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
interface EventLog {
|
||||
@@ -47,6 +47,7 @@ const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
export function SystemLogsView() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<'system' | 'workflow'>('system');
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
// System logs state
|
||||
const [logs, setLogs] = useState<EventLog[]>([]);
|
||||
@@ -63,7 +64,7 @@ export function SystemLogsView() {
|
||||
const [wfLoading, setWfLoading] = useState(false);
|
||||
const [expandedStep, setExpandedStep] = useState<number | null>(null);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -78,7 +79,7 @@ export function SystemLogsView() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [traceFilter, typeFilter, levelFilter]);
|
||||
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
@@ -106,7 +107,13 @@ export function SystemLogsView() {
|
||||
useEffect(() => {
|
||||
if (tab === 'system') fetchLogs();
|
||||
else fetchWorkflows();
|
||||
}, [tab]);
|
||||
}, [tab, fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'system' || !autoRefresh) return;
|
||||
const interval = setInterval(fetchLogs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [tab, autoRefresh, fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTrace) fetchWorkflowDetail(selectedTrace);
|
||||
@@ -143,6 +150,15 @@ export function SystemLogsView() {
|
||||
{t('agent.workflowLogs')}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{tab === 'system' && (
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={`p-2 rounded-lg transition-all ${autoRefresh ? 'text-accent bg-accent-light' : 'text-text-muted hover:text-accent hover:bg-accent-light'}`}
|
||||
title={autoRefresh ? t('agent.logPauseRefresh') : t('agent.logResumeRefresh')}
|
||||
>
|
||||
{autoRefresh ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => tab === 'system' ? fetchLogs() : fetchWorkflows()}
|
||||
disabled={loading || wfLoading}
|
||||
|
||||
@@ -151,8 +151,15 @@
|
||||
"addProvider": "Add Provider",
|
||||
"noProviders": "No providers configured yet",
|
||||
"providerFillAll": "Please fill in all fields.",
|
||||
"providerFillUrlAndKey": "Please fill in URL and API Key first.",
|
||||
"providerAddFailed": "Failed to add provider. Please check your inputs and try again.",
|
||||
"deleteProviderConfirm": "Delete this provider?",
|
||||
"testConnection": "Test Connection",
|
||||
"providerTestSuccess": "Connection successful",
|
||||
"providerTestFailed": "Connection failed",
|
||||
"providerModels": "models",
|
||||
"editProvider": "Edit Provider",
|
||||
"apiKeyEditPlaceholder": "Enter new API key (leave blank to keep current)",
|
||||
"providerType": "Type",
|
||||
"providerTitle": "Title",
|
||||
"baseUrl": "Base URL",
|
||||
@@ -184,6 +191,8 @@
|
||||
"logFilterAllTypes": "All event types",
|
||||
"logFilterAllLevels": "All levels",
|
||||
"logSearch": "Search",
|
||||
"logPauseRefresh": "Pause auto-refresh",
|
||||
"logResumeRefresh": "Resume auto-refresh",
|
||||
"logLevel": "Level",
|
||||
"logType": "Type",
|
||||
"logNode": "Node",
|
||||
@@ -224,7 +233,22 @@
|
||||
"customPrompt": "Custom Prompt",
|
||||
"customPromptPlaceholder": "Additional content appended after the default system prompt...",
|
||||
"displayName": "Display Name",
|
||||
"persona": "Persona"
|
||||
"persona": "Persona",
|
||||
"providerTypeOpenai": "OpenAI",
|
||||
"providerTypeOpenaiDesc": "Official OpenAI API",
|
||||
"providerTypeOpenaiCompat": "OpenAI-Compatible",
|
||||
"providerTypeOpenaiCompatDesc": "Self-hosted or third-party compatible",
|
||||
"providerTypeAnthropic": "Anthropic",
|
||||
"providerTypeAnthropicDesc": "Claude family models",
|
||||
"providerTypeGemini": "Gemini",
|
||||
"providerTypeGeminiDesc": "Google AI models",
|
||||
"providerTypeDeepseek": "DeepSeek",
|
||||
"providerTypeDeepseekDesc": "DeepSeek official API",
|
||||
"baseUrlHint": "Default",
|
||||
"providerAdvanced": "Parameter Settings",
|
||||
"providerCustomModels": "Custom Model List",
|
||||
"providerCustomModelsPlaceholder": "Comma separated, e.g. gpt-4o, gpt-4o-mini",
|
||||
"providerCustomModelsHint": "Optional. Leave empty to auto-fetch model list from provider."
|
||||
},
|
||||
"plugin": {
|
||||
"toolManagement": "Toolset Center",
|
||||
|
||||
@@ -151,8 +151,15 @@
|
||||
"addProvider": "添加供应商",
|
||||
"noProviders": "暂无已配置的供应商",
|
||||
"providerFillAll": "请填写所有字段。",
|
||||
"providerFillUrlAndKey": "请先填写 URL 和 API 密钥。",
|
||||
"providerAddFailed": "添加供应商失败,请检查输入后重试。",
|
||||
"deleteProviderConfirm": "确定要删除此供应商吗?",
|
||||
"testConnection": "测试连接",
|
||||
"providerTestSuccess": "连接成功",
|
||||
"providerTestFailed": "连接失败",
|
||||
"providerModels": "个模型",
|
||||
"editProvider": "编辑供应商",
|
||||
"apiKeyEditPlaceholder": "输入新密钥(留空保持现有密钥)",
|
||||
"providerType": "类型",
|
||||
"providerTitle": "名称",
|
||||
"baseUrl": "基础 URL",
|
||||
@@ -184,6 +191,8 @@
|
||||
"logFilterAllTypes": "所有事件类型",
|
||||
"logFilterAllLevels": "所有级别",
|
||||
"logSearch": "查询",
|
||||
"logPauseRefresh": "暂停自动刷新",
|
||||
"logResumeRefresh": "恢复自动刷新",
|
||||
"logLevel": "级别",
|
||||
"logType": "类型",
|
||||
"logNode": "节点",
|
||||
@@ -224,7 +233,22 @@
|
||||
"customPrompt": "附加人设",
|
||||
"customPromptPlaceholder": "在默认系统提示词之后追加的自定义内容...",
|
||||
"displayName": "显示名称",
|
||||
"persona": "人设"
|
||||
"persona": "人设",
|
||||
"providerTypeOpenai": "OpenAI",
|
||||
"providerTypeOpenaiDesc": "官方 OpenAI API",
|
||||
"providerTypeOpenaiCompat": "OpenAI 兼容",
|
||||
"providerTypeOpenaiCompatDesc": "自托管或第三方兼容服务",
|
||||
"providerTypeAnthropic": "Anthropic",
|
||||
"providerTypeAnthropicDesc": "Claude 系列模型",
|
||||
"providerTypeGemini": "Gemini",
|
||||
"providerTypeGeminiDesc": "Google AI 模型",
|
||||
"providerTypeDeepseek": "DeepSeek",
|
||||
"providerTypeDeepseekDesc": "DeepSeek 官方 API",
|
||||
"baseUrlHint": "默认地址",
|
||||
"providerAdvanced": "参数设置",
|
||||
"providerCustomModels": "自定义模型列表",
|
||||
"providerCustomModelsPlaceholder": "用逗号分隔,如:gpt-4o, gpt-4o-mini",
|
||||
"providerCustomModelsHint": "可选,留空则自动从供应商拉取模型清单"
|
||||
},
|
||||
"plugin": {
|
||||
"toolManagement": "工具集中心",
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface Provider {
|
||||
provider_url?: string;
|
||||
provider_owner?: string;
|
||||
provider_models?: string[];
|
||||
provider_status?: string;
|
||||
status?: string;
|
||||
model?: string;
|
||||
}
|
||||
@@ -84,6 +85,7 @@ export interface WorkflowStep {
|
||||
action: string;
|
||||
desc: string;
|
||||
status: string;
|
||||
output?: string;
|
||||
agent_id?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user