diff --git a/backend/python/app/api/market.py b/backend/python/app/api/market.py index d18fa83..2972e09 100644 --- a/backend/python/app/api/market.py +++ b/backend/python/app/api/market.py @@ -40,24 +40,41 @@ def _skill_http_proxy(): def _sanitize_skill_content(text: str) -> str: - """仅当内容疑似有效 SKILL.md 时才接受,避免把无关网页/乱码写进 skill_content。""" + """接受疑似有效 SKILL.md / markdown 规范的内容;仅拒绝整页 HTML 源码。 + + 注意:此前校验过严(必须 # / --- 开头、长度 <50 直接返回空)导致大量合法 + SKILL.md 被误杀、写库为空(#681 根因之一)。此处放宽:只要不是整页 HTML 且 + 携带 markdown/skill 标记即接受。 + """ if not text: return "" t = text.strip() - if len(t) < 50: + if len(t) < 20: return "" low = t.lower() - # 拒绝整页 HTML(避免把网页源码当技能内容写库) - if low.startswith(" 3: + # 拒绝整页 HTML 文档(避免把网页源码当技能内容写库) + if low.startswith(" 示例的 markdown + if " 3: + return "" + # 接受携带任一 markdown / skill 标记的内容 + markers = ("#", "---", "name:", "description:", "```", "##") + if not any(m in t for m in markers): return "" return t +def _normalize_github_url(url: str) -> str: + """GitHub 渲染页(/blob/、/tree/)转 raw.githubusercontent.com 直链,便于直接抓取源文件。""" + u = (url or "").strip() + if "github.com" in u and ("/blob/" in u or "/tree/" in u): + m = re.sub(r"https?://github\.com/", "https://raw.githubusercontent.com/", u) + m = m.replace("/blob/", "/").replace("/tree/", "/") + return m + return u + + def _extract_skill_from_html(html: str, base_url: str) -> str: """从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。""" proxy = _skill_http_proxy() @@ -72,13 +89,23 @@ def _extract_skill_from_html(html: str, base_url: str) -> str: 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]: + # 3) 跳转到 raw SKILL.md 链接(最多试 5 个,含 GitHub ?raw=true / /raw/ 转换) + links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE) + seen = set() + for link in links[:5]: + if link in seen: + continue + seen.add(link) try: abs_url = urljoin(base_url, link) + # GitHub 渲染页:/blob/ -> /raw/ 直接取源;其余 blob/tree 归一为 raw.githubusercontent + if "github.com" in abs_url and "/blob/" in abs_url and "raw=true" not in abs_url: + abs_url = abs_url.replace("/blob/", "/raw/") + elif "github.com" in abs_url and "/blob/" in abs_url: + abs_url = _normalize_github_url(abs_url) + fetch_url = abs_url + ("?raw=true" if "github.com" in abs_url and "raw=true" not in abs_url else "") 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)"}) + rr = c.get(fetch_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: @@ -90,6 +117,8 @@ def _fetch_skill_content(url: str) -> str: """抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。""" if not url or not url.startswith("http"): return "" + # GitHub 渲染页归一为 raw 直链,提升抓取成功率(#681 根因之一) + url = _normalize_github_url(url) proxy = _skill_http_proxy() try: with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client: @@ -106,6 +135,52 @@ def _fetch_skill_content(url: str) -> str: return "" +def _category_guidance(cat: Optional[str]) -> str: + """按分类返回技能执行指引要点(用于兜底生成 SKILL.md)。""" + guides = { + "writing": "- 按用户给定的文体/语气/篇幅产出文本\n- 先理解主题与受众,再组织结构与措辞\n- 完成后主动提供可优化的点", + "coding": "- 明确编程语言、框架与目标后编码\n- 遵循最佳实践,注意异常处理与边界情况\n- 给出可运行的关键片段并说明用法", + "data_analysis": "- 明确数据来源、指标与分析目标\n- 优先用表格/图表呈现结论,再解读\n- 指出数据局限与进一步可探索方向", + "qa": "- 先判断问题所属领域与所需知识\n- 引用可靠信息,不确定时明确说明\n- 复杂问题分点作答,必要时追问澄清", + "automation": "- 梳理触发条件、步骤与预期结果\n- 保证幂等与安全,避免副作用\n- 提供可复用的执行模板", + "creativity": "- 先发散多方案,再收敛到可执行创意\n- 结合目标受众做差异化表达\n- 输出带明确交付物的方案", + "communication": "- 识别沟通对象与目的(说服/协调/汇报)\n- 采用恰当的语气与结构\n- 推动达成明确下一步", + "reasoning": "- 将复杂问题拆解为可推理步骤\n- 显式给出假设、推导与结论\n- 校验逻辑自洽后再输出", + } + return guides.get(cat or "other", "- 先理解用户真实意图,必要时澄清\n- 输出结构清晰、可直接使用的结果\n- 超出能力范围时主动说明并给替代方案") + + +def _generate_skill_content(name: str, description: Optional[str], category: Optional[str], capability: Optional[str]) -> str: + """当导入时无法获取真实 SKILL.md 内容时,基于名称/描述/分类生成一份可用的 SKILL.md, + 确保技能在对话中至少作为有效上下文被注入(而非空壳,#681 根因修复)。""" + desc = (description or "").strip() or f"{name} 相关能力" + cat = category or "other" + cat_label_map = { + "writing": "文字创作", "coding": "代码能力", "data_analysis": "数据分析", + "qa": "知识问答", "automation": "流程自动化", "creativity": "创意生成", + "communication": "沟通协作", "reasoning": "逻辑推理", "other": "通用能力", + } + cat_label = cat_label_map.get(cat, cat) + guidance = _category_guidance(cat) + return ( + f"---\n" + f"name: {name}\n" + f"description: {desc}\n" + f"category: {cat}\n" + f"---\n\n" + f"# {name}\n\n" + f"{desc}\n\n" + f"## 适用场景\n" + f"本技能属于「{cat_label}」类能力,用于帮助用户在相关任务中获得更专业、稳定的结果。\n\n" + f"## 执行指引\n" + f"{guidance}\n\n" + f"## 使用建议\n" + f"- 明确用户意图后再执行,必要时先向用户确认方向。\n" + f"- 输出结构清晰、可直接使用。\n" + f"- 如任务超出能力范围,主动说明并给出替代方案。\n" + ) + + # ===== Agent 接入前连通性 / 鉴权校验 ===== def _normalize_dify_url(endpoint: str) -> str: ep = endpoint.rstrip("/") @@ -586,6 +661,8 @@ def upload_app( icon=req.icon, type=req.type if req.type in ("skill","agent","mcp") else "skill", source=app_source, + category=req.category or "other", # 持久化分类(#679 修复:此前未落库导致分类丢失) + tags=req.tags, platform=req.platform, price=req.price, install_count=0, @@ -652,9 +729,12 @@ def upload_app( 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() + if not content: + # 兜底:抓取/粘贴均未提供内容时,基于名称/描述/分类生成可用 SKILL.md, + # 避免空壳技能(#681 根因修复:保证再次导入也不会内容为空) + content = _generate_skill_content(app.name, app.description, req.category, app.capability) + app.skill_content = content + db.commit() elif app.type == "agent": if req.skill_content: app.skill_content = req.skill_content diff --git a/backend/python/app/models.py b/backend/python/app/models.py index a20fdc5..b493f35 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -286,6 +286,10 @@ class MarketApp(Base): publisher_id = Column(String(36), ForeignKey("users.id"), default=None) publisher_name = Column(String(100), default=None) description = Column(Text, default=None) + # 分类与标签:DB 表 market_apps 已存在 category(varchar50)/tags(varchar500) 两列, + # 此前 ORM 漏声明导致后台读取分类恒为 None -> 回退 "other"(#679 根因)。此处补齐。 + category = Column(String(50), default="other", comment="技能/应用分类(9类: writing/coding/data_analysis/qa/automation/creativity/communication/reasoning/other)") + tags = Column(String(500), default=None, comment="标签,逗号分隔") icon = Column(String(500), default=None) tutorial = Column(Text, default=None) price = Column(DECIMAL(10, 2), nullable=False, default=0.00)