From 2ae5d9f9b56445f63203b585a9bbff70f35c9085 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Fri, 17 Jul 2026 10:46:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(skills):=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=8A=80=E8=83=BD=E6=97=B6=20SKILL=20=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E4=B8=A2=E5=A4=B1=E5=AF=BC=E8=87=B4=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E5=B7=A5=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端 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 重导三条路径均能正确落库 --- backend/python/app/api/market.py | 90 ++++++++++++++++++++++++- frontend/src/pages/front/MarketPage.tsx | 11 +++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/backend/python/app/api/market.py b/backend/python/app/api/market.py index a222055..f4feb57 100644 --- a/backend/python/app/api/market.py +++ b/backend/python/app/api/market.py @@ -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(" 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)
/ blocks
+    for tag in (r"pre", r"code"):
+        for b in re.findall(r"<%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,
diff --git a/frontend/src/pages/front/MarketPage.tsx b/frontend/src/pages/front/MarketPage.tsx
index 385292f..f8363ac 100644
--- a/frontend/src/pages/front/MarketPage.tsx
+++ b/frontend/src/pages/front/MarketPage.tsx
@@ -544,6 +544,8 @@ const CreateToolModal: React.FC = ({ appType, onClose, onS
           // 关键修复:把粘贴的 MCP 传输代码随提交一起发给后端,否则后端拿不到代码、URL 为空、永远连不上
           mcpCode: appType === 'mcp' ? mcpCode.trim() : undefined,
           mcpTransport: appType === 'mcp' ? mcpTransport : undefined,
+          // 关键修复:把粘贴/上传的 SKILL 内容随提交一起发给后端,否则 skill_content 为空、技能在对话里失效
+          skillContent: appType === 'skill' ? (skillsContent.trim() || skillsFile || '') : undefined,
         } as any);
       }, 1500);
     }, 2000);
@@ -1506,6 +1508,11 @@ const MarketPage: React.FC = () => {
         payload.download_url = mcpCode;
         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);
       // 重新加载列表
       loadApps();
@@ -1533,6 +1540,10 @@ const MarketPage: React.FC = () => {
       if (isMcp && 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);
       // 重新加载列表
       loadApps();