diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java index 43dea5a6..0b6099c0 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java @@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** @@ -39,4 +40,20 @@ public class WebMvcConfig implements WebMvcConfigurer { .allowCredentials(true) .maxAge(3600); } + + /** + * Expose per-skill bundled assets (logos, screenshots, ...) at + * {@code /skill-assets//...}. + * + *

Source layout: {@code src/main/resources/skills//assets/}. + * Built-in skills can ship icons / hero images alongside SKILL.md without + * polluting {@code src/main/resources/static/} (gitignored — that path is + * the mateclaw-ui Vite build output). + */ + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/skill-assets/**") + .addResourceLocations("classpath:/skills/") + .setCachePeriod(86400); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index a03af2dc..e8de16a4 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -202,19 +202,29 @@ public class SkillRuntimeService { .map(packageResolver::resolve) .filter(SkillRuntimeService::passesActiveGate) .collect(Collectors.toList()); + // Track real skill names so a same-named bridged virtual skill is + // suppressed (real wins). Without this, a real SKILL.md packaged + // alongside a same-name MCP/ACP server produces two cards on the + // Skills page. + Set realNames = resolved.stream() + .map(ResolvedSkill::getName) + .collect(Collectors.toSet()); try { - // RFC-090 §3.2 — MCP-derived virtual skills go through the - // same active gate so a disconnected MCP server doesn't - // pollute the prompt enhancement. + // MCP-derived virtual skills go through the same active gate + // so a disconnected MCP server doesn't pollute the prompt + // enhancement. Same-name virtuals are suppressed by the real + // skill above. for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) { + if (realNames.contains(virt.getName())) continue; if (passesActiveGate(virt)) resolved.add(virt); } } catch (Exception e) { log.warn("MCP skill bridge active merge failed: {}", e.getMessage()); } try { - // RFC-090 §3.2 (parallel) — ACP-derived virtual skills. + // ACP-derived virtual skills. Same dedup as MCP. for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) { + if (realNames.contains(virt.getName())) continue; if (passesActiveGate(virt)) resolved.add(virt); } } catch (Exception e) { @@ -240,13 +250,25 @@ public class SkillRuntimeService { List resolved = allSkills.stream() .map(packageResolver::resolve) .collect(Collectors.toList()); + // Same dedup-by-name as refreshActiveSkills(): a real skill with + // the same name as a bridged virtual one suppresses the virtual, + // so the Skills admin page never shows two cards for the same name. + Set realNames = resolved.stream() + .map(ResolvedSkill::getName) + .collect(Collectors.toSet()); try { - resolved.addAll(mcpSkillBridge.listMcpDerivedResolvedSkills()); + for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) { + if (realNames.contains(virt.getName())) continue; + resolved.add(virt); + } } catch (Exception e) { log.warn("MCP skill bridge merge failed: {}", e.getMessage()); } try { - resolved.addAll(acpSkillBridge.listAcpDerivedResolvedSkills()); + for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) { + if (realNames.contains(virt.getName())) continue; + resolved.add(virt); + } } catch (Exception e) { log.warn("ACP skill bridge merge failed: {}", e.getMessage()); } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V85__ckjia_mcp_seed.sql b/mateclaw-server/src/main/resources/db/migration/h2/V85__ckjia_mcp_seed.sql new file mode 100644 index 00000000..074d94cf --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V85__ckjia_mcp_seed.sql @@ -0,0 +1,22 @@ +-- Seed default ckjia-shopping MCP server config (disabled by default). +-- Admin enables in Settings > MCP Connections after pointing url to their +-- ckjia instance and configuring CKJIA_MCP_KEY env var for authorization. +-- +-- Column name is `url` (not `endpoint`) per McpServerEntity. +-- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key +-- never lands in the database (parseHeaders expands env vars at request time). + +MERGE INTO mate_mcp_server ( + name, transport, url, headers_json, enabled, description, + connect_timeout_seconds, read_timeout_seconds, builtin, create_time, update_time, deleted +) KEY(name) +VALUES ( + 'ckjia-shopping', + 'sse', + 'http://localhost:8088/mcp/sse', + '{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}', + FALSE, + 'CKJIA cross-platform price comparison MCP server (Taobao/JD/Tmall/Pinduoduo).', + 30, 30, TRUE, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0 +); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V85__ckjia_mcp_seed.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V85__ckjia_mcp_seed.sql new file mode 100644 index 00000000..15f4e3a6 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V85__ckjia_mcp_seed.sql @@ -0,0 +1,22 @@ +-- Seed default ckjia-shopping MCP server config (disabled by default). +-- Admin enables in Settings > MCP Connections after pointing url to their +-- ckjia instance and configuring CKJIA_MCP_KEY env var for authorization. +-- +-- Column name is `url` (not `endpoint`) per McpServerEntity. +-- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key +-- never lands in the database (parseHeaders expands env vars at request time). + +INSERT INTO mate_mcp_server ( + name, transport, url, headers_json, enabled, description, + connect_timeout_seconds, read_timeout_seconds, builtin, create_time, update_time, deleted +) +SELECT 'ckjia-shopping', + 'sse', + 'http://localhost:8088/mcp/sse', + '{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}', + FALSE, + 'CKJIA cross-platform price comparison MCP server (Taobao/JD/Tmall/Pinduoduo).', + 30, 30, TRUE, + NOW(), NOW(), 0 +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM mate_mcp_server WHERE name = 'ckjia-shopping'); diff --git a/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md b/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md new file mode 100644 index 00000000..4e8c4c58 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md @@ -0,0 +1,45 @@ +--- +name: ckjia-shopping +version: "1.0.0" +icon: /skill-assets/ckjia-shopping/assets/ckjia_logo_new.png +description: "跨平台比价与购物推荐 / Cross-platform price comparison and shopping recommendation. 淘宝 / 京东 / 天猫 / 拼多多商品聚合搜索,拍图识物,AI 购物意图识别。Phase 1 ships shopping_recommend + image_recognize; unified_search / price_history / compare_skus 在后续迭代追加。" +category: data +type: mcp +dependencies: + tools: + - ckjia_shopping_recommend + - ckjia_image_recognize + - ckjia_ping +--- + +# 查价 · 比价购物 + +当用户询问"X 多少钱 / 哪里便宜 / 帮我推荐 X / 这个值不值买 / 拍照认一下这是什么"时使用本技能。 + +## 决策树 + +1. **"推荐 / 帮我挑 / 性价比 / 想买 X"** → `ckjia_shopping_recommend(query, top_n=5)` + - 想要 ckjia 顺便给出意图理解(用于澄清后续问句)→ `include_intent=true` +2. **附带图片 / 拍照识物** → `ckjia_image_recognize(image_url)` → 拿到 `suggested_query` 后再 `ckjia_shopping_recommend(suggested_query)` +3. **transport 健康自检** → `ckjia_ping("hello")`,验证 MCP 链路通 + +## 输出渲染建议 + +- `recommendations: ProductCard[]` 在 chat UI 自动渲染成卡片网格(移动端单列、桌面端多列) +- `intent` 字段是给 agent 自己看的"我理解对了吗"——拿到后若 `budgetRange` 与用户原话不符,应主动澄清 +- `needsClarification=true` 时,把 `clarificationQuestion` + `clarificationOptions` 直接抛给用户选 + +## 注意 + +- 同一个 query 不要在一次对话里反复调用 —— ckjia 侧已有缓存,重复调用浪费配额 +- 用户未登录时不必填 `user_id`;当前 Phase 1 所有调用以 API key owner 身份执行 +- API key 由管理员在 ckjia 控制台申请后填入 mateclaw `Settings ▸ MCP Connections` 的 `headers_json`,使用 `${CKJIA_MCP_KEY}` 环境变量占位符避免明文落库 +- 触发 429 `rate_limited` 时按 `Retry-After` 等待一次,再失败就汇总现有结果而不是无限重试 + +## 如何申请 API Key + +1. 访问 ckjia 控制台 `https://ckjia.com/console/mcp-keys`(自助申请页面 P2 落地后开放;Phase 1 需联系运维手工签发) +2. 选 `free` / `standard` tier 与勾选所需 scopes +3. 一次性获得明文 key(形如 `ckjia_mcp_live_5fK8j2nQ…`) +4. 在 mateclaw 部署环境配 `CKJIA_MCP_KEY=ckjia_mcp_live_xxx` +5. Settings ▸ MCP Connections 启用 `ckjia-shopping` 即可 diff --git a/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo.png b/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo.png new file mode 100644 index 00000000..692925e1 Binary files /dev/null and b/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo.png differ diff --git a/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo_dark.png b/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo_dark.png new file mode 100644 index 00000000..68ff58b0 Binary files /dev/null and b/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo_dark.png differ diff --git a/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo_new.png b/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo_new.png new file mode 100644 index 00000000..0df459ef Binary files /dev/null and b/mateclaw-server/src/main/resources/skills/ckjia-shopping/assets/ckjia_logo_new.png differ