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:
1 parent
47d38ca173
commit
2ae5d9f9b5
2 files changed
+100
-1
No files matched your search
@@ -6,9 +6,10 @@ from typing import Optional
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
|
||||
from ..database import get_db
|
||||
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
|
||||
|
||||
|
||||
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 接入前连通性 / 鉴权校验 =====
|
||||
def _normalize_dify_url(endpoint: str) -> str:
|
||||
ep = endpoint.rstrip("/")
|
||||
@@ -185,6 +258,7 @@ class UploadAppRequest(BaseModel):
|
||||
source: Optional[str] = None
|
||||
price: float = Field(default=0, ge=0)
|
||||
download_url: Optional[str] = None
|
||||
skill_content: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
is_member_free: Optional[bool] = False
|
||||
@@ -424,6 +498,20 @@ def upload_app(
|
||||
# 导入后自动做一次连通性测试,状态会同步为 connected / error
|
||||
_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(
|
||||
AppInstallation.user_id == current_user.id,
|
||||
|
||||
Reference in new issue
Block a user