mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat): 购物推荐结果渲染为可点击商品卡片
- 聊天 markdown 渲染器解析 product-cards 围栏为卡片网格(图片/价格/平台/去购买按钮),整卡可点跳购买页 - DOMPurify afterSanitizeAttributes 钩子补回被 ALLOWED_URI_REGEXP 剥掉的 target/referrerpolicy,确保防盗链图片加载与新标签打开 - 后端在购物推荐工具结果尾部追加卡片渲染指令,保证模型稳定输出卡片而非表格 - 技能:购物意图优先调用参考价工具并约定 product-cards 输出格式
This commit is contained in:
parent
be7bbd9644
commit
3c87efdb15
@ -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<String, ToolCallback> 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
|
||||
|
||||
@ -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` 围栏。
|
||||
|
||||
## 其它字段处理
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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 <a>, 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
|
||||
================================================================ */
|
||||
|
||||
61
mateclaw-ui/src/composables/__tests__/product-cards.test.ts
Normal file
61
mateclaw-ui/src/composables/__tests__/product-cards.test.ts
Normal file
@ -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('<a class="product-card"')
|
||||
expect(html).toContain('href="https://union-click.jd.com/jdc?e=abc"')
|
||||
expect(html).toContain('target="_blank"')
|
||||
// Image survives DOMPurify with lazy-load + no-referrer.
|
||||
expect(html).toContain('src="https://img14.360buyimg.com/pop/jfs/t1/xxx.jpg"')
|
||||
expect(html).toContain('referrerpolicy="no-referrer"')
|
||||
// Price is formatted; original price shows struck-through because it is higher.
|
||||
expect(html).toContain('¥1,699')
|
||||
expect(html).toContain('product-card__price-was')
|
||||
expect(html).toContain('华为畅享 70X 尊享版')
|
||||
})
|
||||
|
||||
it('accepts an object wrapping a recommendations array', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@ -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 '<div class="product-cards product-cards--loading">'
|
||||
+ '<span class="product-cards__dot"></span>'
|
||||
+ '<span class="product-cards__dot"></span>'
|
||||
+ '<span class="product-cards__dot"></span>'
|
||||
+ '</div>'
|
||||
}
|
||||
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
|
||||
? `<div class="product-card__media"><img src="${escapeHtml(img)}" alt="${name}"></div>`
|
||||
: `<div class="product-card__media product-card__media--empty"></div>`
|
||||
const meta = [platform, shop].filter(Boolean).join(' · ')
|
||||
const priceLine = now
|
||||
? `<div class="product-card__price"><span class="product-card__price-now">${now}</span>`
|
||||
+ (was ? `<span class="product-card__price-was">${was}</span>` : '')
|
||||
+ `</div>`
|
||||
: ''
|
||||
// The whole card is the anchor, but a visible CTA makes the "tap to buy"
|
||||
// affordance explicit (an `<a>` can't legally wrap a `<button>`, so this is
|
||||
// a styled span). Only shown when there's a real buy URL.
|
||||
const platformWord = platform || '商家'
|
||||
const buyCta = href
|
||||
? `<span class="product-card__buy">去${platformWord}购买<span class="product-card__buy-arrow">→</span></span>`
|
||||
: ''
|
||||
const body = `<div class="product-card__body">`
|
||||
+ `<div class="product-card__name">${name}</div>`
|
||||
+ priceLine
|
||||
+ (meta ? `<div class="product-card__meta">${meta}</div>` : '')
|
||||
+ (low ? `<div class="product-card__low">历史最低 ${low}</div>` : '')
|
||||
+ (advice ? `<div class="product-card__advice">${advice}</div>` : '')
|
||||
+ buyCta
|
||||
+ `</div>`
|
||||
|
||||
if (href) {
|
||||
return `<a class="product-card" href="${escapeHtml(href)}">${media}${body}</a>`
|
||||
}
|
||||
return `<div class="product-card product-card--nolink">${media}${body}</div>`
|
||||
}).join('')
|
||||
|
||||
return `<div class="product-cards">${cards}</div>`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom renderer (marked v15 requires a plain object — class instances are
|
||||
// NOT dispatched).
|
||||
@ -209,6 +309,15 @@ const customRenderer = {
|
||||
return `<div class="echarts-block" data-echarts-option="${encodeURIComponent(rawCode)}"></div>`
|
||||
}
|
||||
|
||||
// Product cards: a ```product-cards fenced block carries a JSON array (or an
|
||||
// object wrapping `recommendations` / `products` / `items`) of shopping
|
||||
// recommendations. We render it inline as a clickable card grid — image,
|
||||
// name, price, platform — so price-comparison results show up as real cards
|
||||
// in the chat instead of a markdown list. Pure HTML, no post-mount step.
|
||||
if (infoStr === 'product-cards') {
|
||||
return renderProductCards(rawCode)
|
||||
}
|
||||
|
||||
const detectedLang = extractLang(infoStr)
|
||||
const hasLanguage = !!detectedLang && !!hljs.getLanguage(detectedLang)
|
||||
|
||||
@ -334,6 +443,35 @@ const purifyConfig = {
|
||||
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#|\/|\.\/|\.\.\/)/i,
|
||||
}
|
||||
|
||||
// The custom ALLOWED_URI_REGEXP above also vets non-URI attribute *values*, so
|
||||
// DOMPurify strips `target="_blank"`, `rel="noopener"`, `referrerpolicy="..."`
|
||||
// etc. (their values don't match the URL whitelist). For product cards we need
|
||||
// those back: the buy link must open in a new tab instead of navigating away
|
||||
// from the chat, and marketplace CDN thumbnails (e.g. 360buyimg) are hotlink-
|
||||
// protected and only load with `referrer-policy: no-referrer`. An
|
||||
// afterSanitizeAttributes hook re-applies them with fixed, safe values —
|
||||
// attributes set inside this hook are NOT re-validated, so this is the
|
||||
// canonical DOMPurify pattern. Scoped strictly to product-card nodes so no
|
||||
// other rendered markdown changes behaviour.
|
||||
let productCardHookRegistered = false
|
||||
function ensureProductCardHook(): void {
|
||||
if (productCardHookRegistered) return
|
||||
productCardHookRegistered = true
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node: Element) => {
|
||||
if (!node || typeof node.tagName !== 'string') return
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (tag === 'a' && node.classList?.contains('product-card')) {
|
||||
node.setAttribute('target', '_blank')
|
||||
node.setAttribute('rel', 'noopener noreferrer')
|
||||
} else if (tag === 'img' && typeof node.closest === 'function' && node.closest('.product-cards')) {
|
||||
node.setAttribute('referrerpolicy', 'no-referrer')
|
||||
node.setAttribute('loading', 'lazy')
|
||||
node.setAttribute('decoding', 'async')
|
||||
}
|
||||
})
|
||||
}
|
||||
ensureProductCardHook()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LRU render cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user