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[^>]*>(.*?)%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();