feat(market): skills/mcp 能力分类 + AI验证期自动识别 + 导入自动入座
This commit is contained in:
1 parent
2ae5d9f9b5
commit
6f3fb400a4
7 files changed
+1407
-1140
No files matched your search
@@ -14,10 +14,11 @@ import os
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import (
|
from ..models import (
|
||||||
User, MarketApp, AppPurchase, AppReview, AppInstallation, Order,
|
User, MarketApp, AppPurchase, AppReview, AppInstallation, Order,
|
||||||
AdBanner, MCPServer, gen_uuid
|
AdBanner, MCPServer, ModelProvider, gen_uuid
|
||||||
)
|
)
|
||||||
from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
|
from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
|
||||||
from ..middleware.log_service import LogService
|
from ..middleware.log_service import LogService
|
||||||
|
from ..capability_registry import CAPABILITY_TYPES, resolve_auto_model as _resolve_auto_model
|
||||||
from app.api.admin.mcp_admin import _test_mcp_server, parse_mcp_config
|
from app.api.admin.mcp_admin import _test_mcp_server, parse_mcp_config
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -206,6 +207,53 @@ def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dic
|
|||||||
return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}
|
return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 能力分类(AI 自动归类,任何失败都返回 None,不阻断导入) =====
|
||||||
|
def _classify_capability(name: str, description: Optional[str], content: Optional[str], db: Session) -> Optional[str]:
|
||||||
|
"""用平台文本模型把应用归类到 11 种能力之一;失败返回 None(不阻断导入)。"""
|
||||||
|
try:
|
||||||
|
text_model = _resolve_auto_model("text", db)
|
||||||
|
if not text_model:
|
||||||
|
return None
|
||||||
|
provider = None
|
||||||
|
if getattr(text_model, "provider_id", None):
|
||||||
|
provider = db.query(ModelProvider).filter(ModelProvider.id == text_model.provider_id).first()
|
||||||
|
api_key = text_model.api_key or (provider.api_key if provider else None)
|
||||||
|
if not api_key:
|
||||||
|
return None
|
||||||
|
api_url = text_model.endpoint or (
|
||||||
|
f"{provider.api_base_url}/v1/chat/completions" if provider and provider.api_base_url else None
|
||||||
|
)
|
||||||
|
if not api_url:
|
||||||
|
return None
|
||||||
|
options = "、".join(f"{c['id']}({c['label']})" for c in CAPABILITY_TYPES)
|
||||||
|
sys_prompt = (
|
||||||
|
"你是应用市场的能力分类器。根据应用名称、描述与内容,把它精确归类到下列能力之一:"
|
||||||
|
f"{options}。\n只返回一个 JSON 对象:{{\"capability\": \"<id>\"}},不要任何额外解释或 Markdown。"
|
||||||
|
)
|
||||||
|
user_text = f"名称:{name}\n描述:{description or ''}\n内容摘要:{(content or '')[:2000]}"
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": sys_prompt},
|
||||||
|
{"role": "user", "content": user_text},
|
||||||
|
]
|
||||||
|
proxy = _skill_http_proxy()
|
||||||
|
with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as c:
|
||||||
|
r = c.post(
|
||||||
|
api_url,
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
|
json={"model": text_model.name, "messages": messages, "max_tokens": 64, "temperature": 0, "enable_thinking": False},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = r.json()
|
||||||
|
text_out = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
m = re.search(r'"capability"\s*:\s*"([a-z_]+)"', text_out)
|
||||||
|
if m and m.group(1) in {c["id"] for c in CAPABILITY_TYPES}:
|
||||||
|
return m.group(1)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class VerifyAgentRequest(BaseModel):
|
class VerifyAgentRequest(BaseModel):
|
||||||
platform: Optional[str] = None
|
platform: Optional[str] = None
|
||||||
endpoint: str
|
endpoint: str
|
||||||
@@ -222,6 +270,25 @@ def verify_agent(
|
|||||||
return {"code": 0, "data": result, "message": "ok"}
|
return {"code": 0, "data": result, "message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
class DetectCapabilityRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
skill_content: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apps/detect-capability")
|
||||||
|
def detect_capability(
|
||||||
|
req: DetectCapabilityRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""导入前 AI 验证阶段:根据名称/描述/内容识别能力分类,供前台预填并允许用户修改。"""
|
||||||
|
cap = _classify_capability(req.name, req.description, req.skill_content, db)
|
||||||
|
label = next((c["label"] for c in CAPABILITY_TYPES if c["id"] == cap), None)
|
||||||
|
return {"code": 0, "data": {"capability": cap, "label": label}, "message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ads")
|
@router.get("/ads")
|
||||||
def list_public_ads(db: Session = Depends(get_db)):
|
def list_public_ads(db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
@@ -260,6 +327,7 @@ class UploadAppRequest(BaseModel):
|
|||||||
download_url: Optional[str] = None
|
download_url: Optional[str] = None
|
||||||
skill_content: Optional[str] = None
|
skill_content: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
capability: Optional[str] = None
|
||||||
tags: Optional[str] = None
|
tags: Optional[str] = None
|
||||||
is_member_free: Optional[bool] = False
|
is_member_free: Optional[bool] = False
|
||||||
join_promotion: Optional[bool] = False
|
join_promotion: Optional[bool] = False
|
||||||
@@ -281,7 +349,7 @@ class ReviewAppRequest(BaseModel):
|
|||||||
def list_apps(
|
def list_apps(
|
||||||
type: Optional[str] = Query(None),
|
type: Optional[str] = Query(None),
|
||||||
source: Optional[str] = Query(None),
|
source: Optional[str] = Query(None),
|
||||||
category: Optional[str] = Query(None),
|
capability: Optional[str] = Query(None),
|
||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
sort: str = Query(default="latest"),
|
sort: str = Query(default="latest"),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
@@ -295,8 +363,8 @@ def list_apps(
|
|||||||
query = query.filter(MarketApp.type == type)
|
query = query.filter(MarketApp.type == type)
|
||||||
if source:
|
if source:
|
||||||
query = query.filter(MarketApp.source == source)
|
query = query.filter(MarketApp.source == source)
|
||||||
if category:
|
if capability:
|
||||||
query = query.filter(MarketApp.type == category)
|
query = query.filter(MarketApp.capability == capability)
|
||||||
if search:
|
if search:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
and_(
|
and_(
|
||||||
@@ -346,6 +414,7 @@ def list_apps(
|
|||||||
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
|
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
|
||||||
"review_count": app.review_count or 0,
|
"review_count": app.review_count or 0,
|
||||||
"created_at": str(app.created_at) if app.created_at else None,
|
"created_at": str(app.created_at) if app.created_at else None,
|
||||||
|
"capability": app.capability or None,
|
||||||
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False,
|
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False,
|
||||||
}
|
}
|
||||||
result.append(item)
|
result.append(item)
|
||||||
@@ -400,8 +469,9 @@ def get_app(
|
|||||||
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
|
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
|
||||||
"review_count": app.review_count or 0,
|
"review_count": app.review_count or 0,
|
||||||
"publisher_id": app.publisher_id,
|
"publisher_id": app.publisher_id,
|
||||||
"download_url": app.download_url,
|
"download_url": getattr(app, "download_url", None),
|
||||||
"detail": app.detail,
|
"detail": getattr(app, "detail", None),
|
||||||
|
"capability": app.capability or None,
|
||||||
"created_at": str(app.created_at),
|
"created_at": str(app.created_at),
|
||||||
"purchased": purchased,
|
"purchased": purchased,
|
||||||
"installed": installed,
|
"installed": installed,
|
||||||
@@ -456,6 +526,9 @@ def upload_app(
|
|||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
updated_at=datetime.now(),
|
updated_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
# 能力分类:前端显式指定优先;否则导入阶段由 AI 自动归类;都不行则留空(不阻断导入)
|
||||||
|
_classify_content = req.skill_content or req.download_url or ""
|
||||||
|
app.capability = req.capability or _classify_capability(req.name, req.description, _classify_content, db) or None
|
||||||
db.add(app)
|
db.add(app)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(app)
|
db.refresh(app)
|
||||||
@@ -536,6 +609,7 @@ def upload_app(
|
|||||||
"code": 0,
|
"code": 0,
|
||||||
"data": {
|
"data": {
|
||||||
"id": app.id, "name": app.name, "status": app.status,
|
"id": app.id, "name": app.name, "status": app.status,
|
||||||
|
"capability": app.capability,
|
||||||
"created_at": str(app.created_at),
|
"created_at": str(app.created_at),
|
||||||
},
|
},
|
||||||
"message": "提交成功,等待审核",
|
"message": "提交成功,等待审核",
|
||||||
|
|||||||
+1124
-1123
File diff suppressed because it is too large.
Load diff
@@ -289,7 +289,7 @@ export const knowledgeAPI = {
|
|||||||
|
|
||||||
export const marketAPI = {
|
export const marketAPI = {
|
||||||
|
|
||||||
getApps: (params?: { type?: string; source?: string; search?: string; page?: number; page_size?: number }) => {
|
getApps: (params?: { type?: string; source?: string; capability?: string; search?: string; page?: number; page_size?: number }) => {
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
@@ -297,6 +297,8 @@ export const marketAPI = {
|
|||||||
|
|
||||||
if (params?.source) searchParams.set('source', params.source);
|
if (params?.source) searchParams.set('source', params.source);
|
||||||
|
|
||||||
|
if (params?.capability) searchParams.set('capability', params.capability);
|
||||||
|
|
||||||
if (params?.search) searchParams.set('search', params.search);
|
if (params?.search) searchParams.set('search', params.search);
|
||||||
|
|
||||||
if (params?.page) searchParams.set('page', String(params.page));
|
if (params?.page) searchParams.set('page', String(params.page));
|
||||||
@@ -315,6 +317,8 @@ export const marketAPI = {
|
|||||||
|
|
||||||
verifyAgent: (data: any) => post<any>('/market/apps/verify-agent', data),
|
verifyAgent: (data: any) => post<any>('/market/apps/verify-agent', data),
|
||||||
|
|
||||||
|
detectCapability: (data: any) => post<any>('/market/apps/detect-capability', data),
|
||||||
|
|
||||||
purchaseApp: (id: string) => post<any>(`/market/apps/${id}/purchase`),
|
purchaseApp: (id: string) => post<any>(`/market/apps/${id}/purchase`),
|
||||||
|
|
||||||
installApp: (id: string) => post<any>(`/market/apps/${id}/install`),
|
installApp: (id: string) => post<any>(`/market/apps/${id}/install`),
|
||||||
|
|||||||
@@ -1739,3 +1739,52 @@
|
|||||||
border: 1px solid var(--color-border-light);
|
border: 1px solid var(--color-border-light);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 能力分类筛选 & 徽章 ===== */
|
||||||
|
.capability-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-chip {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-chip:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-chip.active {
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import { marketAPI } from '@/api';
|
import { marketAPI } from '@/api';
|
||||||
import type { MarketApp, AppType, AppReview } from '@/types';
|
import type { MarketApp, AppType, AppReview } from '@/types';
|
||||||
import './MarketPage.css';
|
import './MarketPage.css';
|
||||||
@@ -182,6 +182,36 @@ const typeIcons: Record<AppType, React.ReactNode> = {
|
|||||||
mcp: <McpIcon />,
|
mcp: <McpIcon />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== 能力分类(与后端 11 种能力保持一致) =====
|
||||||
|
const CAPABILITIES: { id: string; label: string; color: string }[] = [
|
||||||
|
{ id: 'text', label: '文本对话', color: '#5B5FED' },
|
||||||
|
{ id: 'thinking', label: '推理思考', color: '#8B5CF6' },
|
||||||
|
{ id: 'vision', label: '视觉理解', color: '#219B5E' },
|
||||||
|
{ id: 'tool', label: '工具调用', color: '#3B82F6' },
|
||||||
|
{ id: 'image', label: '图像生成', color: '#D9383A' },
|
||||||
|
{ id: 'audio', label: '语音合成', color: '#E8922C' },
|
||||||
|
{ id: 'asr', label: '语音识别', color: '#0EA5E9' },
|
||||||
|
{ id: 'ocr', label: 'OCR识别', color: '#F59E0B' },
|
||||||
|
{ id: 'embedding', label: '嵌入向量', color: '#10B981' },
|
||||||
|
{ id: 'rerank', label: '重排序', color: '#EC4899' },
|
||||||
|
{ id: 'video', label: '视频生成', color: '#EF4444' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CapabilityBadge({ capability }: { capability?: string | null }) {
|
||||||
|
if (!capability) return null;
|
||||||
|
const cap = CAPABILITIES.find(c => c.id === capability);
|
||||||
|
if (!cap) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="capability-badge"
|
||||||
|
style={{ color: cap.color, background: cap.color + '1A', borderColor: cap.color + '40' }}
|
||||||
|
title={`能力分类:${cap.label}`}
|
||||||
|
>
|
||||||
|
{cap.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Hub Platforms (修改3 & 修改5) =====
|
// ===== Hub Platforms (修改3 & 修改5) =====
|
||||||
const skillsHubPlatforms = [
|
const skillsHubPlatforms = [
|
||||||
{ id: 'skills.sh', name: 'skills.sh', url: 'https://www.skills.sh' },
|
{ id: 'skills.sh', name: 'skills.sh', url: 'https://www.skills.sh' },
|
||||||
@@ -351,6 +381,7 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
|
|||||||
{typeIcons[app.type]}
|
{typeIcons[app.type]}
|
||||||
{typeLabels[app.type]}
|
{typeLabels[app.type]}
|
||||||
</span>
|
</span>
|
||||||
|
<CapabilityBadge capability={app.capability} />
|
||||||
<span className="modal-app-publisher">
|
<span className="modal-app-publisher">
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
{app.publisherName}
|
{app.publisherName}
|
||||||
@@ -470,6 +501,8 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
|
|||||||
const [mcpCode, setMcpCode] = useState('');
|
const [mcpCode, setMcpCode] = useState('');
|
||||||
const [mcpCodeVerifyResult, setMcpCodeVerifyResult] = useState<'idle' | 'checking' | 'pass' | 'fail'>('idle');
|
const [mcpCodeVerifyResult, setMcpCodeVerifyResult] = useState<'idle' | 'checking' | 'pass' | 'fail'>('idle');
|
||||||
const [mcpCodeVerifyMsg, setMcpCodeVerifyMsg] = useState('');
|
const [mcpCodeVerifyMsg, setMcpCodeVerifyMsg] = useState('');
|
||||||
|
// 能力分类:AI验证后自动识别,可手动修改;空字符串表示交由后端 AI 自动归类
|
||||||
|
const [capability, setCapability] = useState('');
|
||||||
|
|
||||||
const handleCheckFormat = () => {
|
const handleCheckFormat = () => {
|
||||||
const content = skillsFile || skillsContent;
|
const content = skillsFile || skillsContent;
|
||||||
@@ -483,6 +516,7 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
|
|||||||
if (hasMinLength && (hasMarkdownHeader || hasValidStructure)) {
|
if (hasMinLength && (hasMarkdownHeader || hasValidStructure)) {
|
||||||
setFormatCheckResult('pass');
|
setFormatCheckResult('pass');
|
||||||
setFormatCheckMsg('格式检测通过:符合Skills标准规范(工具定义 + 使用说明 + 参数配置)');
|
setFormatCheckMsg('格式检测通过:符合Skills标准规范(工具定义 + 使用说明 + 参数配置)');
|
||||||
|
detectCap(content);
|
||||||
} else {
|
} else {
|
||||||
setFormatCheckResult('fail');
|
setFormatCheckResult('fail');
|
||||||
setFormatCheckMsg('格式不符合Skills标准:请确保包含工具定义、功能描述、使用说明和参数配置');
|
setFormatCheckMsg('格式不符合Skills标准:请确保包含工具定义、功能描述、使用说明和参数配置');
|
||||||
@@ -500,6 +534,7 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
|
|||||||
if (hasMinLength && hasKeywords) {
|
if (hasMinLength && hasKeywords) {
|
||||||
setMcpCodeVerifyResult('pass');
|
setMcpCodeVerifyResult('pass');
|
||||||
setMcpCodeVerifyMsg('AI验证通过:代码包含必要的传输协议和端点配置');
|
setMcpCodeVerifyMsg('AI验证通过:代码包含必要的传输协议和端点配置');
|
||||||
|
detectCap(mcpCode);
|
||||||
} else {
|
} else {
|
||||||
setMcpCodeVerifyResult('fail');
|
setMcpCodeVerifyResult('fail');
|
||||||
setMcpCodeVerifyMsg('AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性');
|
setMcpCodeVerifyMsg('AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性');
|
||||||
@@ -519,6 +554,18 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 调用后端 detect-capability,AI 验证阶段自动识别能力分类并预填
|
||||||
|
const detectCap = (content: string) => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
marketAPI.detectCapability({ name: name.trim(), description: description.trim(), skill_content: content })
|
||||||
|
.then((res: any) => {
|
||||||
|
if (res && res.code === 0 && res.data && res.data.capability) {
|
||||||
|
setCapability(res.data.capability);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!name.trim() || !description.trim()) return;
|
if (!name.trim() || !description.trim()) return;
|
||||||
// Skills requires format check pass
|
// Skills requires format check pass
|
||||||
@@ -532,6 +579,7 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
|
|||||||
onSubmit({
|
onSubmit({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
type: appType,
|
type: appType,
|
||||||
|
capability: capability || undefined,
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
price: isPaid ? parseFloat(price) || 0 : 0,
|
price: isPaid ? parseFloat(price) || 0 : 0,
|
||||||
isMemberFree: !isPaid,
|
isMemberFree: !isPaid,
|
||||||
@@ -697,6 +745,19 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 能力分类(AI验证后自动识别,可手动修改) */}
|
||||||
|
{appType !== 'agent' && (
|
||||||
|
<div className="form-section">
|
||||||
|
<label className="form-label">能力分类 <span className="cap-hint">(AI验证后自动识别,可修改)</span></label>
|
||||||
|
<select className="form-input" value={capability} onChange={e => setCapability(e.target.value)}>
|
||||||
|
<option value="">未指定(由AI自动归类)</option>
|
||||||
|
{CAPABILITIES.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* MCP-specific: Transport Type (修改5) */}
|
{/* MCP-specific: Transport Type (修改5) */}
|
||||||
{appType === 'mcp' && (
|
{appType === 'mcp' && (
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
@@ -879,7 +940,7 @@ interface HubImportModalProps {
|
|||||||
appType: 'skill' | 'mcp';
|
appType: 'skill' | 'mcp';
|
||||||
platforms: typeof skillsHubPlatforms;
|
platforms: typeof skillsHubPlatforms;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImport: (platformName: string, url: string, skillName: string, price?: number, description?: string) => void;
|
onImport: (platformName: string, url: string, skillName: string, price?: number, description?: string, capability?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onClose, onImport }) => {
|
const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onClose, onImport }) => {
|
||||||
@@ -900,6 +961,10 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
|||||||
const [hubCodeVerifyMsgMap, setHubCodeVerifyMsgMap] = useState<Record<string, string>>({});
|
const [hubCodeVerifyMsgMap, setHubCodeVerifyMsgMap] = useState<Record<string, string>>({});
|
||||||
const [hubName, setHubName] = useState('');
|
const [hubName, setHubName] = useState('');
|
||||||
const [hubDescription, setHubDescription] = useState('');
|
const [hubDescription, setHubDescription] = useState('');
|
||||||
|
// 能力分类:AI验证后自动识别,可手动修改;用 ref 避免导入点击时的陈旧闭包
|
||||||
|
const [capability, setCapability] = useState('');
|
||||||
|
const capabilityRef = useRef('');
|
||||||
|
const setCap = (v: string) => { setCapability(v); capabilityRef.current = v; };
|
||||||
|
|
||||||
const handleVisit = (url: string) => {
|
const handleVisit = (url: string) => {
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
@@ -915,6 +980,7 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
|||||||
if (hasMinLength && hasKeywords) {
|
if (hasMinLength && hasKeywords) {
|
||||||
setHubCodeVerifyResult('pass');
|
setHubCodeVerifyResult('pass');
|
||||||
setHubCodeVerifyMsg('AI验证通过:代码包含必要的传输协议和端点配置,可以导入');
|
setHubCodeVerifyMsg('AI验证通过:代码包含必要的传输协议和端点配置,可以导入');
|
||||||
|
detectCap(hubName || 'MCP应用', hubCode);
|
||||||
} else {
|
} else {
|
||||||
setHubCodeVerifyResult('fail');
|
setHubCodeVerifyResult('fail');
|
||||||
setHubCodeVerifyMsg('AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性');
|
setHubCodeVerifyMsg('AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性');
|
||||||
@@ -934,6 +1000,7 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
|||||||
if (hasMinLength && hasKeywords) {
|
if (hasMinLength && hasKeywords) {
|
||||||
setHubCodeVerifyResultMap(prev => ({ ...prev, [platformId]: 'pass' }));
|
setHubCodeVerifyResultMap(prev => ({ ...prev, [platformId]: 'pass' }));
|
||||||
setHubCodeVerifyMsgMap(prev => ({ ...prev, [platformId]: 'AI验证通过:代码包含必要的传输协议和端点配置,可以导入' }));
|
setHubCodeVerifyMsgMap(prev => ({ ...prev, [platformId]: 'AI验证通过:代码包含必要的传输协议和端点配置,可以导入' }));
|
||||||
|
detectCap(hubName || 'MCP应用', trimmed);
|
||||||
} else {
|
} else {
|
||||||
setHubCodeVerifyResultMap(prev => ({ ...prev, [platformId]: 'fail' }));
|
setHubCodeVerifyResultMap(prev => ({ ...prev, [platformId]: 'fail' }));
|
||||||
setHubCodeVerifyMsgMap(prev => ({ ...prev, [platformId]: 'AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性' }));
|
setHubCodeVerifyMsgMap(prev => ({ ...prev, [platformId]: 'AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性' }));
|
||||||
@@ -941,6 +1008,17 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const detectCap = (name: string, content: string, description?: string) => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
marketAPI.detectCapability({ name: name.trim(), description: description || '', skill_content: content })
|
||||||
|
.then((res: any) => {
|
||||||
|
if (res && res.code === 0 && res.data && res.data.capability) {
|
||||||
|
setCap(res.data.capability);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
const handleImport = (platformId: string) => {
|
const handleImport = (platformId: string) => {
|
||||||
const url = importUrl[platformId];
|
const url = importUrl[platformId];
|
||||||
const isCustomImport = platformId === 'custom-import';
|
const isCustomImport = platformId === 'custom-import';
|
||||||
@@ -955,6 +1033,10 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
|||||||
}
|
}
|
||||||
if (!hubName.trim()) return;
|
if (!hubName.trim()) return;
|
||||||
}
|
}
|
||||||
|
// Skills Hub 导入无独立AI验证步骤:在导入时触发一次能力识别(异步,导入完成前会回填到 ref)
|
||||||
|
if (appType === 'skill' && !capabilityRef.current && url?.trim()) {
|
||||||
|
detectCap(hubName || extractSkillName(url, platformId), url, hubDescription);
|
||||||
|
}
|
||||||
setImportingId(platformId);
|
setImportingId(platformId);
|
||||||
setImportStatus('verifying');
|
setImportStatus('verifying');
|
||||||
setStatusMsg(appType === 'mcp' ? '正在验证代码...' : '正在验证URL链接...');
|
setStatusMsg(appType === 'mcp' ? '正在验证代码...' : '正在验证URL链接...');
|
||||||
@@ -976,7 +1058,7 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const name = appType === 'skill' ? (hubName || extractSkillName(url || '', platformId)) : hubName;
|
const name = appType === 'skill' ? (hubName || extractSkillName(url || '', platformId)) : hubName;
|
||||||
const importPayload = appType === 'mcp' ? platformCode : (url || '');
|
const importPayload = appType === 'mcp' ? platformCode : (url || '');
|
||||||
onImport(platformId, importPayload, name, importPrice, hubDescription);
|
onImport(platformId, importPayload, name, importPrice, hubDescription, capabilityRef.current);
|
||||||
setImportingId(null);
|
setImportingId(null);
|
||||||
setImportStatus('idle');
|
setImportStatus('idle');
|
||||||
setImportUrl(prev => ({ ...prev, [platformId]: '' }));
|
setImportUrl(prev => ({ ...prev, [platformId]: '' }));
|
||||||
@@ -1047,6 +1129,17 @@ const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onC
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 能力分类(AI验证后自动识别,可手动修改) */}
|
||||||
|
<div className="form-section">
|
||||||
|
<label className="form-label">能力分类 <span className="cap-hint">(AI验证后自动识别,可修改)</span></label>
|
||||||
|
<select className="form-input" value={capability} onChange={e => setCap(e.target.value)}>
|
||||||
|
<option value="">未指定(由AI自动归类)</option>
|
||||||
|
{CAPABILITIES.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* MCP Hub Import: Code Editor + AI验证 */}
|
{/* MCP Hub Import: Code Editor + AI验证 */}
|
||||||
{appType === 'mcp' && (
|
{appType === 'mcp' && (
|
||||||
<div className="hub-code-section">
|
<div className="hub-code-section">
|
||||||
@@ -1267,6 +1360,8 @@ interface AgentConnectModalProps {
|
|||||||
|
|
||||||
const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnect }) => {
|
const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnect }) => {
|
||||||
const [platform, setPlatform] = useState<string>('dify');
|
const [platform, setPlatform] = useState<string>('dify');
|
||||||
|
// 能力分类(可选,未指定时由后端 AI 自动归类)
|
||||||
|
const [capability, setCapability] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [intro, setIntro] = useState('');
|
const [intro, setIntro] = useState('');
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
@@ -1316,7 +1411,7 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
try {
|
try {
|
||||||
await onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
|
await onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim(), capability);
|
||||||
} finally {
|
} finally {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
}
|
}
|
||||||
@@ -1361,6 +1456,15 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
|||||||
onChange={e => setIntro(e.target.value)}
|
onChange={e => setIntro(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-section">
|
||||||
|
<label className="form-label">能力分类 <span className="cap-hint">(可选,未指定由AI自动归类)</span></label>
|
||||||
|
<select className="form-input" value={capability} onChange={e => setCapability(e.target.value)}>
|
||||||
|
<option value="">未指定(由AI自动归类)</option>
|
||||||
|
{CAPABILITIES.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<label className="form-label">API Endpoint(该 Agent 专属地址)<span className="required">*</span></label>
|
<label className="form-label">API Endpoint(该 Agent 专属地址)<span className="required">*</span></label>
|
||||||
<input
|
<input
|
||||||
@@ -1430,6 +1534,7 @@ const MarketPage: React.FC = () => {
|
|||||||
const [localApps, setLocalApps] = useState<MarketApp[]>([]);
|
const [localApps, setLocalApps] = useState<MarketApp[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [capabilityFilter, setCapabilityFilter] = useState<string | null>(null);
|
||||||
const [installToast, setInstallToast] = useState<string | null>(null);
|
const [installToast, setInstallToast] = useState<string | null>(null);
|
||||||
|
|
||||||
// Install toast
|
// Install toast
|
||||||
@@ -1441,7 +1546,7 @@ const MarketPage: React.FC = () => {
|
|||||||
// 从后端加载应用列表
|
// 从后端加载应用列表
|
||||||
const loadApps = (search?: string) => {
|
const loadApps = (search?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
marketAPI.getApps(search ? { search, page_size: 100 } : { page_size: 100 }).then((res: any) => {
|
marketAPI.getApps({ ...(search ? { search } : {}), ...(capabilityFilter ? { capability: capabilityFilter } : {}), page_size: 100 }).then((res: any) => {
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
const items = (res.data.items || []).map((item: any) => ({
|
const items = (res.data.items || []).map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -1479,8 +1584,11 @@ const MarketPage: React.FC = () => {
|
|||||||
let base = activeTab === 'all'
|
let base = activeTab === 'all'
|
||||||
? localApps
|
? localApps
|
||||||
: localApps.filter(a => a.type === activeTab);
|
: localApps.filter(a => a.type === activeTab);
|
||||||
|
if (capabilityFilter) {
|
||||||
|
base = base.filter(a => a.capability === capabilityFilter);
|
||||||
|
}
|
||||||
return base;
|
return base;
|
||||||
}, [activeTab, localApps]);
|
}, [activeTab, localApps, capabilityFilter]);
|
||||||
|
|
||||||
const tabs: { key: AppType | 'all'; label: string }[] = [
|
const tabs: { key: AppType | 'all'; label: string }[] = [
|
||||||
{ key: 'all', label: '全部' },
|
{ key: 'all', label: '全部' },
|
||||||
@@ -1524,7 +1632,7 @@ const MarketPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle hub import - 调用后端 API 创建应用
|
// Handle hub import - 调用后端 API 创建应用
|
||||||
const handleHubImport = async (platformName: string, url: string, skillName: string, price?: number, description?: string) => {
|
const handleHubImport = async (platformName: string, url: string, skillName: string, price?: number, description?: string, capability?: string) => {
|
||||||
try {
|
try {
|
||||||
const isMcp = showHubImport === 'mcp';
|
const isMcp = showHubImport === 'mcp';
|
||||||
const desc = description || `从 ${platformName} 导入的${isMcp ? 'MCP应用' : 'Skills工具'}${!isMcp ? ` (来源: ${url})` : ''}`;
|
const desc = description || `从 ${platformName} 导入的${isMcp ? 'MCP应用' : 'Skills工具'}${!isMcp ? ` (来源: ${url})` : ''}`;
|
||||||
@@ -1537,6 +1645,7 @@ const MarketPage: React.FC = () => {
|
|||||||
join_promotion: false,
|
join_promotion: false,
|
||||||
source: 'user',
|
source: 'user',
|
||||||
};
|
};
|
||||||
|
if (capability) payload.capability = capability;
|
||||||
if (isMcp && url) {
|
if (isMcp && url) {
|
||||||
payload.download_url = url;
|
payload.download_url = url;
|
||||||
}
|
}
|
||||||
@@ -1554,14 +1663,14 @@ const MarketPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle agent connect - 调用后端 API 创建应用(单个Agent方式接入)
|
// Handle agent connect - 调用后端 API 创建应用(单个Agent方式接入)
|
||||||
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string) => {
|
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string, capability?: string) => {
|
||||||
const platform = agentPlatforms.find(p => p.id === platformId);
|
const platform = agentPlatforms.find(p => p.id === platformId);
|
||||||
const finalName = (name && name.trim()) ? name.trim() : `${platform?.name || '外部'} Agent`;
|
const finalName = (name && name.trim()) ? name.trim() : `${platform?.name || '外部'} Agent`;
|
||||||
const finalDesc = (description && description.trim())
|
const finalDesc = (description && description.trim())
|
||||||
? description.trim()
|
? description.trim()
|
||||||
: `从 ${platform?.name || '外部平台'} 对接导入的智能Agent`;
|
: `从 ${platform?.name || '外部平台'} 对接导入的智能Agent`;
|
||||||
try {
|
try {
|
||||||
await marketAPI.createApp({
|
const payload: any = {
|
||||||
name: finalName,
|
name: finalName,
|
||||||
type: 'agent',
|
type: 'agent',
|
||||||
description: finalDesc,
|
description: finalDesc,
|
||||||
@@ -1572,7 +1681,9 @@ const MarketPage: React.FC = () => {
|
|||||||
agent_endpoint: config.endpoint || '',
|
agent_endpoint: config.endpoint || '',
|
||||||
api_key: config.apiKey || '',
|
api_key: config.apiKey || '',
|
||||||
platform: platformId || 'dify',
|
platform: platformId || 'dify',
|
||||||
});
|
};
|
||||||
|
if (capability) payload.capability = capability;
|
||||||
|
await marketAPI.createApp(payload);
|
||||||
// 重新加载列表
|
// 重新加载列表
|
||||||
loadApps();
|
loadApps();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -1688,6 +1799,22 @@ const MarketPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 能力分类筛选 */}
|
||||||
|
<div className="capability-filter">
|
||||||
|
<button
|
||||||
|
className={`cap-chip ${!capabilityFilter ? 'active' : ''}`}
|
||||||
|
onClick={() => setCapabilityFilter(null)}
|
||||||
|
>全部能力</button>
|
||||||
|
{CAPABILITIES.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
className={`cap-chip ${capabilityFilter === c.id ? 'active' : ''}`}
|
||||||
|
style={capabilityFilter === c.id ? { color: '#fff', background: c.color, borderColor: c.color } : { color: c.color, borderColor: c.color + '55' }}
|
||||||
|
onClick={() => setCapabilityFilter(capabilityFilter === c.id ? null : c.id)}
|
||||||
|
>{c.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)', padding: 'var(--space-md) 0', position: 'relative' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)', padding: 'var(--space-md) 0', position: 'relative' }}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'absolute', left: 10, zIndex: 1, pointerEvents: 'none' }}>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'absolute', left: 10, zIndex: 1, pointerEvents: 'none' }}>
|
||||||
@@ -1738,6 +1865,7 @@ const MarketPage: React.FC = () => {
|
|||||||
<OfficialBadge />
|
<OfficialBadge />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<CapabilityBadge capability={app.capability} />
|
||||||
</div>
|
</div>
|
||||||
<p className="market-card-publisher">
|
<p className="market-card-publisher">
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export interface MarketApp {
|
|||||||
reviewCount: number;
|
reviewCount: number;
|
||||||
installCount: number;
|
installCount: number;
|
||||||
installed?: boolean;
|
installed?: boolean;
|
||||||
|
capability?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 017: 为 market_apps 增加 capability 列(能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video)
|
||||||
|
-- 幂等:列已存在则跳过
|
||||||
|
SET @col = (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'market_apps' AND column_name = 'capability');
|
||||||
|
SET @sql = IF(@col = 0,
|
||||||
|
'ALTER TABLE market_apps ADD COLUMN capability VARCHAR(20) NULL COMMENT \'能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video\'',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
Reference in new issue
Block a user