110 lines
4.8 KiB
JavaScript
110 lines
4.8 KiB
JavaScript
'use strict';
|
||
|
||
// 旧 bridge(crm-ai-demo)的确定性商品链接兜底:LLM 回答后面由代码追加真实商品/类目链接。
|
||
// 新 bridge 把 Markdown 转成 HTML 发给 LibreDesk,所以这里同时支持 Markdown 与 HTML 两种模式。
|
||
|
||
const NO_MATCH_RE = /(?:no exact match|no matching|don'?t carry|do not carry|not available|out of stock|nicht verfügbar|pas disponible|niet beschikbaar|未找到|没有找到|暂无|无货|缺货)/i;
|
||
|
||
const LABELS = {
|
||
Chinese: { title: '可直接查看:', details: '商品详情', stock: (n) => `${n} 件` },
|
||
English: { title: 'Click to view:', details: 'Product details', stock: (n) => `${n} pcs` },
|
||
German: { title: 'Hier ansehen:', details: 'Produktdetails', stock: (n) => `${n} Stk.` },
|
||
French: { title: 'À voir ici :', details: 'Détails du produit', stock: (n) => `${n} pièces` },
|
||
Dutch: { title: 'Hier bekijken:', details: 'Productdetails', stock: (n) => `${n} stuks` },
|
||
Spanish: { title: 'Ver aquí:', details: 'Detalles del producto', stock: (n) => `${n} piezas` },
|
||
Italian: { title: 'Vedi qui:', details: 'Dettagli prodotto', stock: (n) => `${n} pezzi` },
|
||
};
|
||
|
||
const LANG_ALIASES = {
|
||
zh: 'Chinese', cn: 'Chinese', chinese: 'Chinese',
|
||
en: 'English', english: 'English',
|
||
de: 'German', german: 'German',
|
||
fr: 'French', french: 'French',
|
||
nl: 'Dutch', dutch: 'Dutch',
|
||
es: 'Spanish', spanish: 'Spanish',
|
||
it: 'Italian', italian: 'Italian',
|
||
};
|
||
|
||
function labelsFor(lang) {
|
||
const key = LANG_ALIASES[String(lang || '').toLowerCase()];
|
||
return LABELS[key] || LABELS.English;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return String(text || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function escapeMarkdownLabel(text) {
|
||
return String(text || '').replace(/[[\]\\]/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
/** 从工具结果里收集真实商品/类目链接(保持出现顺序,按 URL 去重)。 */
|
||
function collectProductLinks(toolResults) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
const push = (item) => {
|
||
if (!item || typeof item !== 'object') return;
|
||
const url = String(item.url || item.product_url || '').trim();
|
||
if (!/^https?:\/\//i.test(url) || seen.has(url)) return;
|
||
seen.add(url);
|
||
out.push({
|
||
name: String(item.name || item.title || '').trim() || null,
|
||
url,
|
||
price: item.price != null && item.price !== '' ? String(item.price) : null,
|
||
stock: item.stock != null && item.stock !== '' ? item.stock : null,
|
||
});
|
||
};
|
||
for (const result of toolResults || []) {
|
||
if (!result || typeof result !== 'object') continue;
|
||
if (Array.isArray(result.products)) result.products.forEach(push);
|
||
if (Array.isArray(result.category_links)) result.category_links.forEach(push);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 把工具结果里有、但回复里没写的真实链接补到末尾。
|
||
* - 回复已明确说“没有/缺货”时不补(避免自相矛盾,沿用旧 bridge 行为);
|
||
* - 已包含的 URL 自动去重;
|
||
* - html=true 时输出 <a>,否则输出 Markdown 链接(会再经 markdownToHtml 转 HTML)。
|
||
*/
|
||
function appendMissingProductLinks({ content, toolResults, lang = 'English', html = false, limit = 3 }) {
|
||
const base = String(content || '');
|
||
if (!base.trim() || NO_MATCH_RE.test(base)) return { content: base, appended: 0 };
|
||
const missing = collectProductLinks(toolResults).filter((item) => !base.includes(item.url));
|
||
if (!missing.length) return { content: base, appended: 0 };
|
||
|
||
const labels = labelsFor(lang);
|
||
// 有货的排前面(stock=0 的真实商品只在没有别的可选时才补),保持工具返回的相对顺序。
|
||
const stockRank = (item) => {
|
||
if (item.stock == null) return 1;
|
||
const n = Number(item.stock);
|
||
if (!Number.isFinite(n)) return 1;
|
||
return n > 0 ? 0 : 2;
|
||
};
|
||
const ordered = missing
|
||
.map((item, index) => ({ item, index }))
|
||
.sort((a, b) => stockRank(a.item) - stockRank(b.item) || a.index - b.index)
|
||
.map((x) => x.item);
|
||
const top = ordered.slice(0, Math.max(1, Number(limit) || 3));
|
||
const lines = top.map((item) => {
|
||
const text = item.name || labels.details;
|
||
const meta = [item.price, item.stock != null ? labels.stock(item.stock) : null].filter(Boolean).join(' | ');
|
||
if (html) {
|
||
const anchor = `<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
|
||
return `<li>${anchor}${meta ? ` — ${escapeHtml(meta)}` : ''}</li>`;
|
||
}
|
||
return `- [${escapeMarkdownLabel(text)}](${item.url})${meta ? ` — ${meta}` : ''}`;
|
||
});
|
||
const block = html
|
||
? `<p>${escapeHtml(labels.title)}</p><ul>${lines.join('')}</ul>`
|
||
: `${labels.title}\n${lines.join('\n')}`;
|
||
return { content: `${base.trimEnd()}\n\n${block}`, appended: top.length };
|
||
}
|
||
|
||
module.exports = { appendMissingProductLinks, collectProductLinks, labelsFor, NO_MATCH_RE };
|