fix(skills): 修复导入技能时 SKILL 内容丢失导致无法正常工作

- 前端 handleSubmit/handleCreateApp 发送 skillsContent;handleHubImport 对 skill 把目录 URL 作为 download_url
- 后端 upload_app 对 skill 类型写入 skill_content(手动粘贴或 Hub 抓取)
- 新增 _fetch_skill_content 按目录 URL 抓取真实 SKILL.md 回填(best-effort,仅接受有效 markdown)
- 清理 50 条空壳 skill 记录(skill_content 为空,不可恢复)
- 经 E2E 验证:手动粘贴/Hub 抓取/MCP 重导三条路径均能正确落库
This commit is contained in:
QihuAI Design committed 2026-07-17 10:46:47 +08:00
1 parent 47d38ca173
commit 2ae5d9f9b5
2 files changed
+100 -1

No files matched your search

+89 -1
View File
@@ -6,9 +6,10 @@ from typing import Optional
import uuid import uuid
from datetime import datetime from datetime import datetime
import httpx import httpx
from urllib.parse import urlparse from urllib.parse import urlparse, urljoin
import re import re
import json import json
import os
from ..database import get_db from ..database import get_db
from ..models import ( from ..models import (
@@ -30,6 +31,78 @@ def _extract_url_from_text(text: Optional[str]) -> Optional[str]:
return match.group(0) if match else None return match.group(0) if match else None
def _skill_http_proxy():
"""容器内走智能代理;显式返回供 httpx 使用(trust_env=False 避免误用系统代理导致 502)。"""
return os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None
def _sanitize_skill_content(text: str) -> str:
"""仅当内容疑似有效 SKILL.md 时才接受,避免把无关网页/乱码写进 skill_content。"""
if not text:
return ""
t = text.strip()
if len(t) < 50:
return ""
low = t.lower()
# 拒绝整页 HTML(避免把网页源码当技能内容写库)
if low.startswith("<!doctype") or low.startswith("<html") or "<script" in low or t.count("<div") > 3:
return ""
# 接受:YAML front matter 或 含 markdown 标题(# / ##)
has_front = t.startswith("---") and ("name:" in t or "description:" in t)
has_heading = t.startswith("#") or "\n#" in t or "##" in t
if not (has_front or has_heading):
return ""
return t
def _extract_skill_from_html(html: str, base_url: str) -> str:
"""从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。"""
proxy = _skill_http_proxy()
# 1) fenced code block
fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE)
for f in fences:
if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80:
return f.strip()
# 2) <pre>/<code> blocks
for tag in (r"pre", r"code"):
for b in re.findall(r"<%s[^>]*>(.*?)</%s>" % (tag, tag), html, re.DOTALL | re.IGNORECASE):
txt = re.sub(r"<[^>]+>", "", b)
if ("name:" in txt.lower() or txt.count("#") >= 2) and len(txt) > 80:
return txt.strip()
# 3) 跳转到 raw SKILL.md 链接(最多试 3 个)
links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE)
for link in links[:3]:
try:
abs_url = urljoin(base_url, link)
with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c:
rr = c.get(abs_url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
if rr.status_code == 200 and len(rr.text) > 80:
return rr.text.strip()
except Exception:
continue
return ""
def _fetch_skill_content(url: str) -> str:
"""抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。"""
if not url or not url.startswith("http"):
return ""
proxy = _skill_http_proxy()
try:
with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client:
r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
if r.status_code != 200:
return ""
raw = r.text
ctype = (r.headers.get("content-type") or "").lower()
u = url.rstrip("/")
if u.endswith(".md") or "text/plain" in ctype or "markdown" in ctype:
return _sanitize_skill_content(raw)
return _sanitize_skill_content(_extract_skill_from_html(raw, url))
except Exception:
return ""
# ===== Agent 接入前连通性 / 鉴权校验 ===== # ===== Agent 接入前连通性 / 鉴权校验 =====
def _normalize_dify_url(endpoint: str) -> str: def _normalize_dify_url(endpoint: str) -> str:
ep = endpoint.rstrip("/") ep = endpoint.rstrip("/")
@@ -185,6 +258,7 @@ class UploadAppRequest(BaseModel):
source: Optional[str] = None source: Optional[str] = None
price: float = Field(default=0, ge=0) price: float = Field(default=0, ge=0)
download_url: Optional[str] = None download_url: Optional[str] = None
skill_content: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
tags: Optional[str] = None tags: Optional[str] = None
is_member_free: Optional[bool] = False is_member_free: Optional[bool] = False
@@ -424,6 +498,20 @@ def upload_app(
# 导入后自动做一次连通性测试,状态会同步为 connected / error # 导入后自动做一次连通性测试,状态会同步为 connected / error
_test_mcp_server(server, db) _test_mcp_server(server, db)
# Skill / Agent:保存真实技能定义内容,否则对话里读不到 skill_content → 导入成功但失效
if app.type == "skill":
content = req.skill_content or ""
if not content and req.download_url:
# Hub 导入:按目录 URL 抓取真实 SKILL.md 内容回填
content = _fetch_skill_content(req.download_url) or ""
if content:
app.skill_content = content
db.commit()
elif app.type == "agent":
if req.skill_content:
app.skill_content = req.skill_content
db.commit()
# 用户创建/导入的应用自动标记为已安装 # 用户创建/导入的应用自动标记为已安装
existing_install = db.query(AppInstallation).filter( existing_install = db.query(AppInstallation).filter(
AppInstallation.user_id == current_user.id, AppInstallation.user_id == current_user.id,
+11
View File
@@ -544,6 +544,8 @@ const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onS
// 关键修复:把粘贴的 MCP 传输代码随提交一起发给后端,否则后端拿不到代码、URL 为空、永远连不上 // 关键修复:把粘贴的 MCP 传输代码随提交一起发给后端,否则后端拿不到代码、URL 为空、永远连不上
mcpCode: appType === 'mcp' ? mcpCode.trim() : undefined, mcpCode: appType === 'mcp' ? mcpCode.trim() : undefined,
mcpTransport: appType === 'mcp' ? mcpTransport : undefined, mcpTransport: appType === 'mcp' ? mcpTransport : undefined,
// 关键修复:把粘贴/上传的 SKILL 内容随提交一起发给后端,否则 skill_content 为空、技能在对话里失效
skillContent: appType === 'skill' ? (skillsContent.trim() || skillsFile || '') : undefined,
} as any); } as any);
}, 1500); }, 1500);
}, 2000); }, 2000);
@@ -1506,6 +1508,11 @@ const MarketPage: React.FC = () => {
payload.download_url = mcpCode; payload.download_url = mcpCode;
payload.mcp_transport = (data as any).mcpTransport || 'streamable_http'; payload.mcp_transport = (data as any).mcpTransport || 'streamable_http';
} }
// 关键修复:手动创建的 skill 把真实 SKILL 内容作为 skill_content 传给后端落库
const skillContent = (data as any).skillContent as string | undefined;
if (data.type === 'skill' && skillContent) {
payload.skill_content = skillContent;
}
await marketAPI.createApp(payload); await marketAPI.createApp(payload);
// 重新加载列表 // 重新加载列表
loadApps(); loadApps();
@@ -1533,6 +1540,10 @@ const MarketPage: React.FC = () => {
if (isMcp && url) { if (isMcp && url) {
payload.download_url = url; payload.download_url = url;
} }
// skill Hub 导入:把目录 URL 作为 download_url 发后端,后端自动抓取真实 SKILL.md 回填 skill_content
if (!isMcp && url) {
payload.download_url = url;
}
await marketAPI.createApp(payload); await marketAPI.createApp(payload);
// 重新加载列表 // 重新加载列表
loadApps(); loadApps();