diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index f832af01..301b0ed7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -184,6 +184,44 @@ public class ToolExecutionExecutor { return StructuredTruncator.truncate(result, headLen, tailLen, marker); } + /** + * Appended to the cross-platform shopping recommendation tool's result so the + * model reliably renders products as chat cards instead of a markdown table. + * The tool's MCP schema is globally callable, so a model can invoke it without + * ever loading the skill's instructions — this puts the rendering contract + * where the model always sees it: in the tool result itself. + */ + private static final String PRODUCT_CARD_RENDER_DIRECTIVE = + "\n\n[Rendering directive] Render these recommendations for the user as a" + + " fenced code block whose language tag is exactly `product-cards`, containing a" + + " JSON array. Each element: {name, url, imageUrl, price, originalPrice, lowestPrice," + + " platformLabel, shopName, purchaseAdvice}. Copy `url` and `imageUrl` verbatim from" + + " the result above (never invent or alter them). The chat UI turns this block into" + + " clickable product cards with a buy button. Do NOT use a markdown table or inline" + + " image markdown for these products. You may add a short intro sentence and purchase" + + " tips around the block."; + + /** + * Returns {@code true} when the tool is the cross-platform shopping + * recommendation tool and its result actually carries product records + * (so timeouts / empty results fall through to the model's own fallback). + */ + static boolean shouldAppendProductCardDirective(String toolName, String result) { + if (toolName == null || result == null) return false; + if (!toolName.contains("ckjia_shopping_recom")) return false; + return result.contains("recommendations") + || result.contains("imageUrl") + || result.contains("priceTag") + || result.contains("markdownLink"); + } + + /** Appends {@link #PRODUCT_CARD_RENDER_DIRECTIVE} when applicable, else returns the result unchanged. */ + static String withProductCardDirective(String toolName, String result) { + return shouldAppendProductCardDirective(toolName, result) + ? result + PRODUCT_CARD_RENDER_DIRECTIVE + : result; + } + private final Map toolCallbackMap; /** * Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function` @@ -675,8 +713,10 @@ public class ToolExecutionExecutor { log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen, result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : ""); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true)); + // Append the card-rendering directive to the LLM-facing response only, + // leaving the broadcast tool-result panel unchanged. return new ToolResponseMessage.ToolResponse( - toolCall.id(), toolName, result != null ? result : ""); + toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : "")); } catch (Exception e) { log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage()); String safeError = isReturnDirect(callback) @@ -905,8 +945,10 @@ public class ToolExecutionExecutor { GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data()); streamTracker.updateRunningTool(pc.conversationId, null); } + // Append the card-rendering directive to the LLM-facing response only, + // leaving the broadcast tool-result panel unchanged. return new ToolResponseMessage.ToolResponse( - pc.toolCall.id(), toolName, result != null ? result : ""); + pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : "")); } catch (Exception e) { log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e); // RFC-052: for returnDirect tools, even the error message is diff --git a/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md b/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md index 7cdc57d5..341e9fea 100644 --- a/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md @@ -34,60 +34,61 @@ tags: 当用户询问"X 多少钱 / 哪里便宜 / 帮我推荐 X / 这个值不值买 / 拍照认一下这是什么"时使用本技能。 +## 优先级(最高优先级规则) + +只要用户的意图属于**购物 / 比价 / 选购 / 报价**(关键词:买、多少钱、价格、参考价、推荐、性价比、哪款好、值不值、京东/淘宝/天猫/拼多多……),**必须先调用 `ckjia_shopping_recommend` 拿到结构化商品数据**,再组织回答。 + +- ✅ 先 `ckjia_shopping_recommend` → 拿到带图片和价格的真实商品 +- ❌ 不要直接用网页搜索 / 凭记忆报价 / 编造型号和价格来回答购物类问题 +- 仅当 `ckjia_shopping_recommend` 多次超时或返回为空时,才退回到网页搜索,并明确告诉用户"参考价数据暂不可用,以下为网络估算" + ## 决策树 -1. **"推荐 / 帮我挑 / 性价比 / 想买 X"** → `ckjia_shopping_recommend(query, top_n=5)` +1. **"推荐 / 帮我挑 / 性价比 / 想买 X / 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 链路通 ## 输出格式(强制规则,零容忍) -每个 `ProductCard` 已经预渲染了两个开箱即用字段,**直接复制粘贴这两个字符串到回复**,不要自己拼装: - -- `markdownLink` —— 已经是 `[商品名](购买URL)` 格式,照抄即可 -- `priceTag` —— 已经是 `¥4099 ~~¥4499~~ (9% off)` 格式,照抄即可 +聊天界面能把商品渲染成**带图片和价格的可点击卡片**。要触发卡片,必须把推荐结果放进一个语言标记为 `product-cards` 的代码围栏里,围栏内是一个 JSON 数组,**每个对象的字段值直接从工具返回的 ProductCard 原样复制**(尤其 `url` / `imageUrl` 必须照抄,不能改写、不能编造)。 ### 必须遵守的输出模板 -```markdown -1. {{markdownLink}} - - 💰 {{priceTag}} - - 🛒 {{platformLabel}} · {{shopName}} - - 📊 评分 {{rating}} · 销量 {{salesCount}} - - 💡 历史最低 ¥{{lowestPrice}} - - {{purchaseAdvice}} +先用一两句话给出整体结论(预算区间、推荐方向),然后紧跟卡片围栏,最后补充选购提醒: + +````markdown +🎯 你的预算内我挑了这几款,优先看 1.5 匹 / 新一级能效: + +```product-cards +[ + { + "name": "格力空调 云佳pro 1.5匹 新一级能效", + "url": "https://union-click.jd.com/jdc?e=...", + "imageUrl": "https://img14.360buyimg.com/.../xxx.jpg", + "price": 3057, + "originalPrice": 3299, + "lowestPrice": 2999, + "platformLabel": "京东", + "shopName": "格力京东自营官方旗舰店", + "purchaseAdvice": "卧室够用,关注是否含基础安装" + } +] ``` -把双花括号 `{{xxx}}` 替换成 ProductCard 对应字段的值。**`markdownLink` 一定要原样输出**,不要把它拆开后用其它方式重组。 +提醒:空调到手价会受安装费 / 高空费 / 国补影响,下单前确认基础安装是否免费。 +```` -### 真实示例 - -工具返回: -```json -{ - "name": "格力空调 云佳pro 1.5匹...", - "url": "https://union-click.jd.com/jdc?e=...", - "markdownLink": "[格力空调 云佳pro 1.5匹...](https://union-click.jd.com/jdc?e=...)", - "priceTag": "¥3057", - "platformLabel": "京东", - ... -} -``` - -正确输出: -```markdown -1. [格力空调 云佳pro 1.5匹...](https://union-click.jd.com/jdc?e=...) - - 💰 ¥3057 - - 🛒 京东 -``` +每个对象建议带的字段(缺失就省略,不要填占位符):`name`、`url`、`imageUrl`、`price`、`originalPrice`、`lowestPrice`、`platformLabel`、`shopName`、`purchaseAdvice`。 ### 严禁的错误(出现任何一条都算回复失败) -- ❌ 不输出 `markdownLink` —— 用户没法点击购买 -- ❌ 把 markdownLink 拆开只取商品名 —— 等于丢弃链接 -- ❌ 编造任何不在 ProductCard 字段里的 URL -- ❌ 输出 `[商品名](url)` 但中间填占位符或省略号 +- ❌ 不输出 `product-cards` 围栏 —— 用户看不到卡片,也点不进购买页 +- ❌ 改写或编造 `url` / `imageUrl` —— 卡片会点开错误页面或图片裂开 +- ❌ 在围栏里填占位符、省略号或不完整 JSON —— 卡片会渲染失败 +- ❌ 把价格写进字符串而丢掉数字 —— 卡片无法对齐展示价格 + +> 兼容性:纯文本渠道(部分 IM)无法渲染卡片围栏,会退化成代码块。若当前对话明显是这类渠道,再退回到 `1. [商品名](url) — 价格` 的普通列表,并照抄 `markdownLink`。Web 聊天页一律用 `product-cards` 围栏。 ## 其它字段处理 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorProductCardDirectiveTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorProductCardDirectiveTest.java new file mode 100644 index 00000000..197bf42a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorProductCardDirectiveTest.java @@ -0,0 +1,54 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The cross-platform shopping recommendation tool is globally callable, so a + * model can invoke it without ever loading the skill's instructions. The + * executor appends a card-rendering directive to that tool's result so products + * render as chat cards rather than a markdown table — but only when the result + * actually carries product records (timeouts / empty results must fall through + * to the model's own fallback). + */ +class ToolExecutionExecutorProductCardDirectiveTest { + + private static final String SHOPPING_TOOL = "mcp_1000000903_ckjia_shopping_recom_w2ekrl"; + + @Test + @DisplayName("appends the directive when the shopping tool returns recommendations") + void appendsForShoppingResults() { + String result = "[{\"text\":\"{\\\"recommendations\\\":[{\\\"name\\\":\\\"X\\\",\\\"imageUrl\\\":\\\"https://i\\\"}]}\"}]"; + assertTrue(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, result)); + + String decorated = ToolExecutionExecutor.withProductCardDirective(SHOPPING_TOOL, result); + assertTrue(decorated.startsWith(result), "original payload must be preserved verbatim"); + assertTrue(decorated.contains("product-cards"), "directive names the fence language"); + assertTrue(decorated.contains("imageUrl"), "directive lists the card fields"); + } + + @Test + @DisplayName("does not append on a timeout / error result from the shopping tool") + void skipsForTimeout() { + String timeout = "Tool execution failed: java.util.concurrent.TimeoutException: Did not observe any item"; + assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, timeout)); + assertEquals(timeout, ToolExecutionExecutor.withProductCardDirective(SHOPPING_TOOL, timeout)); + } + + @Test + @DisplayName("does not append for unrelated tools even when the body looks product-ish") + void skipsForOtherTools() { + String body = "{\"recommendations\":[{\"imageUrl\":\"https://i\"}]}"; + assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective("web_search", body)); + assertEquals(body, ToolExecutionExecutor.withProductCardDirective("web_search", body)); + } + + @Test + @DisplayName("null-safe") + void nullSafe() { + assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(null, "x")); + assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, null)); + } +} diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index c96ec57d..1acc63c9 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -616,6 +616,145 @@ html.dark body::before { font-size: 13px; } +/* ================================================================ + Product cards (from ```product-cards fenced blocks — shopping / + price-comparison results rendered as a clickable card grid) + ================================================================ */ +.markdown-body .product-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 12px; + margin: 14px 0; +} +.markdown-body .product-card { + display: flex; + flex-direction: column; + border: 1px solid var(--mc-border-light); + border-radius: 12px; + overflow: hidden; + background: var(--mc-bg-elevated); + text-decoration: none; + color: inherit; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} +.markdown-body a.product-card:hover { + border-color: var(--mc-primary, #4f46e5); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} +/* The whole card is an , so the global `.markdown-body a:hover` underline + would streak across every line of card text. Suppress it — hover feedback + comes from the lift/shadow and the name turning primary instead. */ +.markdown-body a.product-card, +.markdown-body a.product-card:hover, +.markdown-body a.product-card:hover * { + text-decoration: none; +} +.markdown-body a.product-card:hover .product-card__name { + color: var(--mc-primary, #d96d46); +} +.markdown-body .product-card__media { + width: 100%; + aspect-ratio: 1 / 1; + background: var(--mc-bg-subtle, #f1f5f9); + overflow: hidden; +} +.markdown-body .product-card__media img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.markdown-body .product-card__body { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px 12px; +} +.markdown-body .product-card__name { + font-size: 13px; + line-height: 1.4; + color: var(--mc-text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.markdown-body .product-card__price { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 2px; +} +.markdown-body .product-card__price-now { + font-size: 17px; + font-weight: 700; + color: var(--mc-danger, #e11d48); +} +.markdown-body .product-card__price-was { + font-size: 12px; + color: var(--mc-text-tertiary); + text-decoration: line-through; +} +.markdown-body .product-card__meta { + font-size: 12px; + color: var(--mc-text-secondary); +} +.markdown-body .product-card__low { + font-size: 11px; + color: var(--mc-text-tertiary); +} +.markdown-body .product-card__advice { + font-size: 12px; + line-height: 1.45; + color: var(--mc-text-secondary); + margin-top: 2px; +} +.markdown-body .product-card__buy { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-top: 8px; + padding: 7px 12px; + border-radius: 8px; + background: var(--mc-primary, #d96d46); + color: #fff; + font-size: 13px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + transition: background 0.15s ease; +} +.markdown-body a.product-card:hover .product-card__buy { + background: var(--mc-primary-hover, #bb4f27); +} +.markdown-body .product-card__buy-arrow { + transition: transform 0.15s ease; +} +.markdown-body a.product-card:hover .product-card__buy-arrow { + transform: translateX(3px); +} +/* Streaming placeholder while the product-cards JSON is still incomplete. */ +.markdown-body .product-cards--loading { + display: flex; + gap: 6px; + padding: 16px 4px; +} +.markdown-body .product-cards__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mc-border, #cbd5e1); + animation: mc-product-pulse 1.4s ease-in-out infinite; +} +.markdown-body .product-cards__dot:nth-child(2) { animation-delay: 0.2s; } +.markdown-body .product-cards__dot:nth-child(3) { animation-delay: 0.4s; } +@keyframes mc-product-pulse { + 0%, 80%, 100% { opacity: 0.3; } + 40% { opacity: 1; } +} + /* ================================================================ Shared page shell ================================================================ */ diff --git a/mateclaw-ui/src/composables/__tests__/product-cards.test.ts b/mateclaw-ui/src/composables/__tests__/product-cards.test.ts new file mode 100644 index 00000000..62fc90c8 --- /dev/null +++ b/mateclaw-ui/src/composables/__tests__/product-cards.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import { useMarkdownRenderer } from '../useMarkdownRenderer' + +const { renderMarkdown } = useMarkdownRenderer() + +// A representative ckjia_shopping_recommend payload, trimmed to the fields the +// SKILL.md contract asks the model to emit inside a ```product-cards fence. +const SAMPLE = `\`\`\`product-cards +[ + { + "name": "华为畅享 70X 尊享版 256GB 曜金黑", + "url": "https://union-click.jd.com/jdc?e=abc", + "imageUrl": "https://img14.360buyimg.com/pop/jfs/t1/xxx.jpg", + "price": 1699, + "originalPrice": 1899, + "lowestPrice": 1619, + "platformLabel": "京东", + "shopName": "华为(HUAWEI)", + "purchaseAdvice": "长续航,预算内首选" + } +] +\`\`\`` + +describe('product-cards rendering', () => { + it('renders a fenced product-cards block as a clickable card grid', () => { + const html = renderMarkdown(SAMPLE) + expect(html).toContain('class="product-cards"') + // Whole card is an anchor to the buy URL. + expect(html).toContain(' { + const wrapped = '```product-cards\n{"recommendations":[{"name":"X","url":"https://x.test/p","price":10}]}\n```' + const html = renderMarkdown(wrapped) + expect(html).toContain('class="product-cards"') + expect(html).toContain('¥10') + }) + + it('shows a loading placeholder for incomplete (streaming) JSON', () => { + const partial = '```product-cards\n[{"name":"half' + const html = renderMarkdown(partial) + expect(html).toContain('product-cards--loading') + }) + + it('drops a javascript: url to a non-clickable card', () => { + const evil = '```product-cards\n[{"name":"bad","url":"javascript:alert(1)"}]\n```' + const html = renderMarkdown(evil) + expect(html).toContain('product-card--nolink') + expect(html).not.toContain('javascript:alert') + }) +}) diff --git a/mateclaw-ui/src/composables/useMarkdownRenderer.ts b/mateclaw-ui/src/composables/useMarkdownRenderer.ts index 3dd8e4d9..d632d384 100644 --- a/mateclaw-ui/src/composables/useMarkdownRenderer.ts +++ b/mateclaw-ui/src/composables/useMarkdownRenderer.ts @@ -168,6 +168,106 @@ function preprocessLatex(text: string): string { return out } +// --------------------------------------------------------------------------- +// Product cards +// --------------------------------------------------------------------------- +/** Shape the model is asked to emit inside a ```product-cards fence. */ +interface ProductCard { + name?: string + url?: string + imageUrl?: string + price?: number | string + originalPrice?: number | string + lowestPrice?: number | string + platformLabel?: string + shopName?: string + purchaseAdvice?: string +} + +/** Format a numeric/string amount as `¥1,234` (drops a trailing `.0`). */ +function formatPrice(v: number | string | undefined): string { + if (v === undefined || v === null || v === '') return '' + const n = typeof v === 'number' ? v : Number(String(v).replace(/[^\d.]/g, '')) + if (!Number.isFinite(n)) return '' + const s = Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.0+$/, '') + return '¥' + s.replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} + +/** + * Render a ```product-cards fenced JSON block into a clickable card grid. + * + * Accepts a bare array or an object wrapping the array under + * `recommendations` / `products` / `items`. While streaming, the JSON is + * frequently incomplete — we swallow the parse error and show a lightweight + * loading placeholder rather than dumping half a JSON blob into the bubble. + */ +function renderProductCards(rawCode: string): string { + let items: ProductCard[] = [] + try { + const parsed = JSON.parse(rawCode) + if (Array.isArray(parsed)) items = parsed + else if (parsed && typeof parsed === 'object') { + items = parsed.recommendations || parsed.products || parsed.items || [] + } + } catch { + return '
' + + '' + + '' + + '' + + '
' + } + if (!Array.isArray(items) || items.length === 0) return '' + + const cards = items.map((it) => { + const href = typeof it.url === 'string' && SAFE_LINK_RE.test(it.url) ? it.url : '' + const name = escapeHtml(String(it.name ?? '').trim()) || '商品' + const img = typeof it.imageUrl === 'string' && /^https?:/i.test(it.imageUrl) ? it.imageUrl : '' + const now = formatPrice(it.price) + const wasNum = typeof it.originalPrice === 'number' ? it.originalPrice : Number(it.originalPrice) + const nowNum = typeof it.price === 'number' ? it.price : Number(it.price) + const showWas = Number.isFinite(wasNum) && Number.isFinite(nowNum) && wasNum > nowNum + const was = showWas ? formatPrice(it.originalPrice) : '' + const low = formatPrice(it.lowestPrice) + const platform = escapeHtml(String(it.platformLabel ?? '').trim()) + const shop = escapeHtml(String(it.shopName ?? '').trim()) + const advice = escapeHtml(String(it.purchaseAdvice ?? '').trim()) + + // target/rel (anchor) and referrerpolicy/loading (img) are re-applied by the + // afterSanitizeAttributes hook — DOMPurify strips them here regardless. + const media = img + ? `
${name}
` + : `
` + const meta = [platform, shop].filter(Boolean).join(' · ') + const priceLine = now + ? `
${now}` + + (was ? `${was}` : '') + + `
` + : '' + // The whole card is the anchor, but a visible CTA makes the "tap to buy" + // affordance explicit (an `
` can't legally wrap a `