Files
crm-ai-demo/bridge/server.js

1261 lines
66 KiB
JavaScript
Raw Normal View History

// Chatwoot → glm-5.2 (ark) → Twenty CRM Bot Bridge
// 接收 Chatwoot 的 message_created webhook用 glm-5.2 作为销售助理回复,
// 并在信息足够时把机会写进 Twenty CRM 的 Opportunity。
//
// 纯 Node 内置模块无依赖。Node 24 内置 fetch / http。
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ---------- 配置加载(环境变量优先,fallback 配置文件)----------
const CFG_DIR = __dirname;
const loadCfg = (f) => JSON.parse(fs.readFileSync(path.join(CFG_DIR, f), 'utf8'));
const cw = loadCfg('chatwoot.config.json');
const llm = loadCfg('llm.config.json');
const twenty = loadCfg('twenty.config.json');
// 容器内用容器名访问,本地用 localhost;环境变量覆盖
if (process.env.CW_BASE_URL) cw.baseURL = process.env.CW_BASE_URL;
if (process.env.TWENTY_BASE_URL) twenty.baseURL = process.env.TWENTY_BASE_URL;
// QDRANT_URL 优先用环境变量(下面 search 部分读)
const PORT = process.env.PORT || 4000;
// 已为每个会话创建过机会的记录,避免重复
const createdOpps = new Set();
// 会话级锁,防止并发消息重复触发 LLM
const locks = new Set();
// ---------- LLM ----------
const PROVIDER = llm.providers.aliyun;
const PROVIDER_TYPE = PROVIDER.type || 'openai';
const LLM_URL = PROVIDER.baseUrl + (PROVIDER_TYPE === 'anthropic' ? '/v1/messages' : '/chat/completions');
const LLM_KEY = PROVIDER.apiKey;
const MODEL = PROVIDER.model;
// qwen3 系列默认开 thinking(慢)客服求快关闭tool calling 在关闭后仍正常
const ENABLE_THINKING = PROVIDER.enableThinking !== undefined ? PROVIDER.enableThinking : false;
// 统一 agent 人设:一个稳定角色,不再在 sales/KB 间切 system prompt。
// LLM 自主调用 tools(search_kb / search_products / escalate_human) 决定如何作答。
const AGENT_SYSTEM = `You are the official assistant for Yehwang, a Netherlands-based (Dutch-registered) B2B wholesaler serving retailers across Europe.
**Company facts (do NOT make these up):**
- Head office: Galvanistraat 90, 6716 AE Ede, The Netherlands. Phone: +31(0)318 668 171.
- Showroom: World Fashion Centre (WFC), Koningin Wilhelminaplein 29, Tower 3 - 1st floor, 1062 HJ Amsterdam. Phone: +31(0)6 3228 5555.
- Three sales channels: physical wholesale showroom (WFC Amsterdam), B2B website (yehwang.com), and a dropshipping platform (dropshipping.yehwang.com).
- Products sourced from: Netherlands warehouse (in-stock goods) and manufacturing facilities in China (Factory-Direct). Do NOT mention any other countries.
- 100,000+ live SKUs across women's jewelry, accessories, clothing (own label: Gimme), packing/display.
- Prices are wholesale unit prices, public and transparent on the site after login.
- Shipping: Netherlands warehouse via PostNL/DHL; Factory-Direct ships from manufacturing facilities worldwide.
You handle BOTH pre-sales (qualifying leads) AND customer-service policy questions.
**LANGUAGE (most important): ALWAYS reply in the SAME language the customer used. If they write Chinese, reply in Chinese; German -> German; French -> French; etc. Even if tool results (product names, KB answers) are in English, you MUST write your reply text in the customer's language. Default to English only if the customer's language is unclear.**
Use the provided tools. Tool discipline is critical:
- search_kb: You MUST call this FIRST (before answering) for ANY question about rules/policies/FAQ registration, account, password, login, minimum order, cancel, pre-order, payment, VAT, invoice, shipping cost, delivery time, returns, refunds, defective/broken items, membership, discounts, dropshipping, factory-direct, materials, discolor, jewelry care, etc. NEVER answer such questions from memory always retrieve the official answer via search_kb first. If the customer's message contains ANY policy-related word, call search_kb.
- search_products: call when the customer wants to find/see/buy specific products. **Use EXACT product names returned never translate or fabricate.** Pass the customer's intent as a natural search query (e.g. "gold necklace", "新品", "earrings") the search engine handles trending/new/best-selling on its own. If results are empty, tell the customer honestly.
- escalate_human: call when the question is outside your tools' scope or you can't confidently answer. ALSO call this IMMEDIATELY (do NOT search_kb or search_products first) if the customer: explicitly asks to speak to a human ("转人工"/"真人"/"人工客服"/"speak to human"/"real person"), expresses anger or frustration, makes a formal complaint, or repeatedly says the same thing without being helped. In these cases, apologize briefly, explain you're transferring them, and call escalate_human.
- search_orders: call when the customer asks about an EXISTING order - "where is my order", "order status", "tracking", "when will it arrive", "has it shipped". Provide the order number if the customer mentioned it; otherwise use the customer's email to look up recent orders. Return concrete tracking numbers and delivery estimates.
- search_inventory: call when the customer asks about REAL-TIME stock or price for a SPECIFIC item they want to order - "is SKU xxx in stock", "how many available", "can you fulfill 500 pieces". This is for pre-order stock verification. Note: search_products is for BROWSING the catalog (finding items); search_inventory is for checking real-time stock/price on a SPECIFIC item before confirming an order.
RULES:
- **Escalation (critical): When the customer asks for a human, expresses frustration, or you need to escalate, you MUST call the escalate_human tool immediately. Do NOT just say "I'll transfer you" in text the tool does the real handoff. Call the tool FIRST, then the system will handle the rest.**
- Reply in the SAME language the customer uses (default English). Keep replies concise and natural (usually 2-4 sentences). Do not repeat earlier phrasing.
- For policy questions: answer using ONLY what search_kb returns. Cite concrete figures (e.g. "minimum order EUR 30", "5-10 business days express"). Do NOT add source URLs yourself the system appends them automatically. If search_kb results don't cover it, say you're unsure and offer to escalate to a human.
- For pre-sales: this is an ONLINE B2B platform with PUBLIC transparent prices (no quotes). We support CUSTOMIZATION (materials, colors, sizes, packaging, private label). You CANNOT create accounts guide customers to register themselves. **For bulk/product+quantity queries: call search_products FIRST to show items, then pivot to lead qualification (check stock, mention Factory-Direct if insufficient, ask for company name + contact). Only mention discounts when the customer asks about pricing/bulk/discounts do NOT push discount tiers proactively. When asked, the tiers are: volume discounts (2005%, 5007%, 1,00010%, 2,00015%, 3,00020%, 5,00022%, 10,00025%) and membership (Bronze 1+ 0%, Silver 200+ 1%, Gold 1,000+ 2%, Platinum 4,000+ 3%, Diamond 10,000+ 4%, VIP 20,000+ 5%). Discounts stack: volume first, then membership on top. Collect: company name contact product/quantity. Track what's known; never re-ask. Once complete, guide them to register at https://www.yehwang.com.**
- Our website is https://www.yehwang.com. Always write the FULL URL with https:// protocol, never bare "www.yehwang.com".
- Contacts: helpdesk@yehwang.com / +31 318 668 171 (website & in-stock orders); service@yehwang.com (Factory-Direct orders).`;
// callLLM: 支持 tool use(OpenAI function calling)。返回 {text, toolCalls, raw}。
// - toolCalls: [{id, name, arguments}] 当 LLM 决定调工具时
// - text: 纯文本回复(无 tool call 时)
async function callLLM(messages, systemOverride, maxTokens = 4096, tools = null) {
let body, headers;
if (PROVIDER_TYPE === 'anthropic') {
body = { model: MODEL, max_tokens: maxTokens, messages };
if (systemOverride) body.system = systemOverride;
headers = { 'x-api-key': LLM_KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' };
} else {
// OpenAI-compatible (DashScope qwen)
const msgs = [];
if (systemOverride) msgs.push({ role: 'system', content: systemOverride });
msgs.push(...messages);
body = { model: MODEL, max_tokens: maxTokens, messages: msgs };
// qwen3 关闭 thinking 求快(tool calling 仍正常)
body.enable_thinking = ENABLE_THINKING;
if (tools && tools.length) body.tools = tools;
headers = { 'Authorization': `Bearer ${LLM_KEY}`, 'content-type': 'application/json' };
}
const resp = await fetch(LLM_URL, { method: 'POST', headers, body: JSON.stringify(body) });
const text = await resp.text();
let data;
try { data = JSON.parse(text); }
catch (e) { console.error('[llm] resp.json fail:', e.message, '| text:', text.slice(0, 300)); throw e; }
if (!resp.ok) throw new Error('LLM HTTP ' + resp.status + ': ' + JSON.stringify(data).slice(0, 400));
const choice = data.choices && data.choices[0];
const msg = choice && choice.message;
const toolCalls = (msg && msg.tool_calls || []).map((tc) => {
let args = {};
try { args = JSON.parse(tc.function.arguments || '{}'); } catch { args = {}; }
return { id: tc.id, name: tc.function.name, arguments: args };
});
const replyText = ((msg && msg.content) || '').trim();
return { text: replyText, toolCalls, finishReason: choice && choice.finish_reason };
}
// ---------- Chatwoot API ----------
const cwHeaders = () => ({
'api_access_token': cw.accessToken,
'Content-Type': 'application/json',
});
async function fetchConversationMessages(conversationId, limit = 20) {
// 只拉最近 limit 条(客服场景只需近期上下文),减少序列化/网络延迟
const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages?sort=desc&per_page=${limit}`;
const resp = await fetch(url, { headers: cwHeaders() });
const data = await resp.json();
// data.payload 或 data 直接是数组desc 拉取后反转回时间正序
const list = Array.isArray(data) ? data : (data.payload || []);
return list.reverse();
}
async function sendChatwootReply(conversationId, content) {
const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages`;
const resp = await fetch(url, {
method: 'POST',
headers: cwHeaders(),
body: JSON.stringify({ content, message_type: 'outgoing', private: false }),
});
return resp.json();
}
// typing indicator让 widget 立即显示“客服正在输入”,消除无响应体感
async function setTyping(conversationId, on) {
try {
const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/toggle_typing_status`;
await fetch(url, { method: 'POST', headers: cwHeaders(), body: JSON.stringify({ typing_status: on ? 'on' : 'off' }) });
} catch {}
}
// 真 handoff把会话从 bot 转给人类客服(改 assignee)bot 不再抢答。
// 对标 Chatwoot 企业版 Captain 的“转人工”。assigneeId 省略时只 toggle 状态为 open+bot 默认转交。
async function handoffToHuman(conversationId, note) {
try {
// 1. 先发一条 note 给客户(说明转人工)
// 2. 切换会话状态为 open(确保人类能接) + 清掉 bot assignee
const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}`;
const resp = await fetch(url, {
method: 'PATCH',
headers: cwHeaders(),
body: JSON.stringify({ status: 'open', assignee_agent_bot_id: null }),
});
console.log(`[handoff] conv=${conversationId} -> ${resp.status} ${note || ''}`);
return resp.ok;
} catch (e) { console.error('[handoff err]', e.message); return false; }
}
// 列出账号下可用的客服 agent(人类),供 handoff 分配
async function listHumanAgents() {
try {
const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/agents`;
const resp = await fetch(url, { headers: cwHeaders() });
const d = await resp.json();
return d.payload || d || [];
} catch { return []; }
}
// 上传图片到 Chatwoot(下载后用文件上传,避免 external_url 超时)
async function uploadImageToChatwoot(imageUrl) {
const encodedUrl = imageUrl.replace(/ /g, '%20');
const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/upload`;
const r = await fetch(encodedUrl);
const buf = Buffer.from(await r.arrayBuffer());
const ctype = r.headers.get('content-type') || 'image/jpeg';
const ext = ctype.includes('png') ? 'png' : 'jpg';
// 构造 multipart/form-data
const form = new FormData();
form.append('attachment', new Blob([buf], { type: ctype }), `product.${ext}`);
const resp = await fetch(url, { method: 'POST', headers: { 'api_access_token': cw.accessToken }, body: form });
const d = await resp.json();
if (!d.blob_id) throw new Error('upload err: ' + JSON.stringify(d).slice(0, 150));
return d.blob_id;
}
// 发带商品图片附件的消息
async function sendProductImageMessage(conversationId, imageUrl, caption) {
try {
const blobId = await uploadImageToChatwoot(imageUrl);
const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages`;
const resp = await fetch(url, {
method: 'POST',
headers: { ...cwHeaders() },
body: JSON.stringify({ content: caption || '', message_type: 'outgoing', private: false, attachments: [blobId] }),
});
return resp.json();
} catch (e) {
console.error('[img msg err]', e.message);
return null;
}
}
// 通用 GraphQL 执行
async function gql(query) {
const resp = await fetch(`${twenty.baseURL}${twenty.graphqlPath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${twenty.apiKey}` },
body: JSON.stringify({ query }),
});
const data = await resp.json();
if (data.errors) throw new Error('Twenty errors: ' + JSON.stringify(data.errors).slice(0, 300));
return data.data;
}
// 创建公司
async function createCompany(name, domain) {
const f = [`name: "${escapeGQL(name)}"`];
if (domain) {
const url = domain.startsWith('http') ? domain : 'https://' + domain;
f.push(`domainName: {primaryLinkUrl:"${escapeGQL(url)}"}`);
}
const d = await gql(`mutation { createCompany(data:{ ${f.join(', ')} }){ id name } }`);
return d.createCompany;
}
// 按公司名查现有公司(去重),返回 id 或 null
async function findCompanyByName(name) {
const d = await gql(`{ companies(first:1, filter:{ name:{ eq:"${escapeGQL(name)}" } }){ edges{ node{ id } } } }`).catch(() => ({ companies: { edges: [] } }));
const edges = (d.companies && d.companies.edges) || [];
return edges[0] ? edges[0].node.id : null;
}
// 按邮箱查现有联系人(去重),返回 id 或 null
async function findPersonByEmail(email) {
const d = await gql(`{ people(first:1, filter:{ emails:{ primaryEmail:{ eq:"${escapeGQL(email)}" } } } ){ edges{ node{ id } } } }`).catch(() => ({ people: { edges: [] } }));
const edges = (d.people && d.people.edges) || [];
return edges[0] ? edges[0].node.id : null;
}
// 从 Twenty duplicate 错误里取已存在记录的 id
function extractConflictId(errMsg) {
const m = String(errMsg).match(/conflictingRecordId":"([a-f0-9-]+)/);
return m ? m[1] : null;
}
// 创建联系人 {firstName, lastName, email, phone, companyId}
async function createPerson(p) {
const parts = [];
const fn = p.firstName || '';
const ln = p.lastName || '';
parts.push(`name: {firstName:"${escapeGQL(fn)}", lastName:"${escapeGQL(ln)}"}`);
if (p.email) parts.push(`emails: {primaryEmail:"${escapeGQL(p.email)}"}`);
if (p.phone) parts.push(`phones: {primaryPhoneNumber:"${escapeGQL(p.phone)}", primaryPhoneCountryCode:""}`);
if (p.companyId) parts.push(`companyId: "${p.companyId}"`);
const d = await gql(`mutation { createPerson(data:{ ${parts.join(', ')} }){ id firstName lastName emails{ primaryEmail } } }`);
return d.createPerson;
}
// 创建商机,关联公司+联系人
async function createOpportunity(opp) {
// opp: { name, amount?, closeDate?, stage?, companyId?, personId?, summary?, conversationLink? }
const VALID_STAGES = ['NEW', 'SCREENING', 'MEETING', 'PROPOSAL', 'CUSTOMER'];
const stage = VALID_STAGES.includes(opp.stage) ? opp.stage : 'NEW';
// 机会名:公司-需求摘要-会话编号salesperson 在 Twenty 里能看到完整信息
const nameText = opp.conversationId
? `${opp.name} (会话#${opp.conversationId})`
: opp.name;
const fields = [`name: "${escapeGQL(nameText)}"`];
if (opp.amount) {
const micros = Math.round(Number(opp.amount) * 1000000);
fields.push(`amount: {amountMicros: ${micros}, currencyCode: "${opp.currency || 'EUR'}"}`);
}
if (opp.closeDate) fields.push(`closeDate: "${opp.closeDate}"`);
fields.push(`stage: ${stage}`);
if (opp.companyId) fields.push(`companyId: "${opp.companyId}"`);
if (opp.personId) fields.push(`pointOfContact: {connect:"${opp.personId}"}`);
const d = await gql(`mutation { createOpportunity(data:{ ${fields.join(', ')} }){ id name stage amount{ amountMicros currencyCode } companyId } }`);
return d.createOpportunity;
}
// 完整建一条线索:公司 -> 联系人 -> 商机(全部关联),公司/联系人去重复用
async function createLeadInCRM(info) {
// info: { company, domain, contactName, email, phone, oppName, amount, currency, stage, summary, conversationLink }
let companyId = null;
let companyCreated = false;
if (info.company) {
companyId = await findCompanyByName(info.company);
if (!companyId) {
try {
const c = await createCompany(info.company, info.domain || null);
companyId = c ? c.id : null;
companyCreated = true;
} catch (e) {
companyId = extractConflictId(e.message) || await findCompanyByName(info.company);
}
}
}
let personId = null;
let personCreated = false;
if (info.email) {
personId = await findPersonByEmail(info.email);
}
if (!personId && (info.contactName || info.email)) {
const names = splitName(info.contactName || '');
try {
const p = await createPerson({
firstName: names.firstName,
lastName: names.lastName,
email: info.email || null,
phone: info.phone || null,
companyId,
});
personId = p ? p.id : null;
personCreated = true;
} catch (e) {
personId = extractConflictId(e.message);
if (personId) console.log(`[dedup] 复用已有联系人 ${personId}`);
else console.error('[person err]', e.message);
}
}
const opp = await createOpportunity({
name: info.oppName,
amount: info.amount,
currency: info.currency || 'EUR',
stage: info.stage || 'NEW',
companyId,
personId,
conversationId: info.conversationId || null,
});
// 自动化工作流商机创建后自动生成跟进任务3 天后到期,关联到商机)
if (opp && opp.id) {
try {
const dueDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
const taskTitle = `跟进商机: ${info.oppName || opp.name}`;
const taskBody = `这是由 AI 客服系统自动创建的跟进任务。\n商机来源: Chatwoot 会话 #${info.conversationId || 'N/A'}\n客户: ${info.company || '未知'}${info.contactName ? ' (' + info.contactName + ')' : ''}\n需求: ${info.summary || opp.name}\n\n请在 3 个工作日内联系客户确认细节并发送报价。`;
const blocknote = JSON.stringify([{ id: 't1', type: 'paragraph', props: {}, content: [{ type: 'text', text: taskBody, styles: {} }], children: [] }]);
const escBlock = blocknote.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const td = await gql(`mutation { createTask(data:{ title:"${escapeGQL(taskTitle)}" bodyV2:{ blocknote: "${escBlock}" } dueAt:"${dueDate}" }){ id } }`);
const taskId = td.createTask && td.createTask.id;
if (taskId) {
await gql(`mutation { createTaskTarget(data:{ taskId:"${taskId}" targetOpportunityId:"${opp.id}" }){ id } }`);
console.log(`[workflow] conv=${info.conversationId} opp=${opp.id} -> 自动创建跟进任务 ${taskId} (3天后到期)`);
}
} catch (e) {
console.error('[workflow err] 自动创建任务失败:', e.message.slice(0, 100));
}
}
return { companyId, personId, opportunityId: opp ? opp.id : null, companyCreated, personCreated };
}
// 把聊天记录推送到 Twenty 的 opportunity note
async function addChatToOpportunity(conversationId, opportunityId) {
try {
const msgs = await fetchConversationMessages(conversationId);
if (!msgs.length) return;
const lines = msgs
.filter((m) => m.content && typeof m.content === 'string' && m.content.trim())
.map((m) => {
const role = m.message_type === 0 || m.message_type === 'incoming' ? '客户' : '客服';
const txt = stripHtml(m.content).slice(0, 300);
return `[${role}] ${txt}`;
});
const chatText = lines.join('\n\n---\n\n');
const blocknote = JSON.stringify([
{ id: '1', type: 'paragraph', props: {}, content: [{ type: 'text', text: chatText, styles: {} }], children: [] },
]);
const esc = blocknote.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 两步:先建 note再建 noteTarget 关联到 opportunity
const q1 = `mutation { createNote(data:{ bodyV2: { blocknote: "${esc}" } }){ id } }`;
const d1 = await gql(q1);
const noteId = d1.createNote?.id;
if (!noteId) { console.error('[note] 创建失败'); return; }
const q2 = `mutation { createNoteTarget(data:{ noteId: "${noteId}", targetOpportunityId: "${opportunityId}" }){ id } }`;
await gql(q2);
console.log(`[note] conv=${conversationId} opp=${opportunityId} -> ${noteId}`);
} catch (e) {
console.error('[note err]', e.message.slice(0, 100));
}
}
function splitName(full) {
full = (full || '').trim();
if (!full) return { firstName: '', lastName: '' };
const sp = full.split(/[\s.]+/).filter(Boolean);
if (sp.length >= 2) return { firstName: sp[0], lastName: sp.slice(1).join(' ') };
return { firstName: full, lastName: '' };
}
function escapeGQL(s) {
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ');
}
// web-widget 消息带 <p> 等 HTML,去掉后给 LLM 更干净
function stripHtml(s) {
return String(s)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.trim();
}
// 修夏 LLM 生成的链接markdown 链接的 URL 部分缺协议时补 https://
// [text](www.yehwang.com) -> [text](https://www.yehwang.com)
// 不动已有协议(http(s)://, mailto:, #锚点)或商品链接(均为完整 URL)
function fixupLinks(text) {
return String(text).replace(
/\]\((?!https?:\/\/|mailto:|#|[a-z][a-z0-9+.-]*:)([^)\s]+)\)/gi,
'](https://$1)'
);
}
// ---------- 客服知识库 RAG(共享 postgres pgvector + SiliconFlow embedding)----------
// 将 customer-service-kb/*.md 按 Q&A 切块向量化(见 index_kb.js),客服系统据此
// 准确回答注册/下单/支付/物流/退货/会员等政策类问题,避免 LLM 编造政策。
const { Pool } = require('pg');
const EMB_CFG = JSON.parse(fs.readFileSync(path.join(CFG_DIR, 'kb.config.json'), 'utf8'));
const KB_PG_URL = process.env.PG_URL || 'postgres://postgres:postgres@localhost:5432/products';
const kbPool = new Pool({ connectionString: KB_PG_URL, max: 2 });
const KB_MODEL = EMB_CFG.model || 'text-embedding-v4';
const KB_DIM = EMB_CFG.dim || 1024;
// DashScope key 支持环境变量覆盖(避免明文入库)
if (process.env.DASHSCOPE_API_KEY) EMB_CFG.apiKey = process.env.DASHSCOPE_API_KEY;
// 单条文本向量化(用于查询)
// query embedding LRU 缓存:重复问法不重复调 DashScope热问法秒回
const _embedCache = new Map();
const _EMBED_CACHE_MAX = 200;
async function embedQuery(text) {
const key = text.slice(0, 200);
if (_embedCache.has(key)) return _embedCache.get(key);
const resp = await fetch(`${EMB_CFG.baseURL}/embeddings`, {
method: 'POST',
headers: { Authorization: `Bearer ${EMB_CFG.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: KB_MODEL, input: text, encoding_format: 'float', dimensions: KB_DIM }),
});
const d = await resp.json();
if (!d.data || !d.data[0] || !d.data[0].embedding) throw new Error('embedQuery err: ' + JSON.stringify(d).slice(0, 200));
const vec = d.data[0].embedding;
if (_embedCache.size >= _EMBED_CACHE_MAX) _embedCache.delete(_embedCache.keys().next().value);
_embedCache.set(key, vec);
return vec;
}
// 轻量查询翻译Qwen3-Embedding-0.6B 对德/法/西/意等欧洲语言召回偏弱,
// 当原文检索命中不够且 query 非中文时,先翻成英文再检索,大幅提升多语言召回。
// 只输出一句英文,不增加后续回复的语言负担(回复仍用客户原文语言)。
// 轻量查询翻译(dashscope qwen-turbo)text-embedding-v4 多语言召回已足够强(实测 Recall@3 100%)
// 翻译重检仅作冷门问法兑底(几乎不会触发)。用 qwen-turbo 比 Qwen2.5-72B 更轻快省成本。
async function translateQueryToEnglish(query) {
try {
const resp = await fetch(`${EMB_CFG.baseURL}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${EMB_CFG.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: EMB_CFG.translateModel || 'qwen-turbo',
max_tokens: 80,
messages: [
{ role: 'system', content: 'Translate the user message into a concise English search query for a B2B jewelry wholesaler FAQ. Output ONLY the English translation, no explanation, no quotes.' },
{ role: 'user', content: query },
],
}),
});
const d = await resp.json();
const t = (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content || '').trim();
return t.replace(/^["']|["']$/g, '');
} catch (e) {
console.error('[kb translate err]', e.message);
return null;
}
}
// 检索知识库,返回 [{question, answer, category, score}]
// 多语言查询召回不足时自动翻译成英文重检,合并两次结果。
async function searchKB(query, topK = 4) {
try {
const vecStr = (v) => '[' + v.join(',') + ']';
const runQ = async (q) => {
const vec = await embedQuery(q);
const r = await kbPool.query(
`SELECT question, answer, category, section, source_file, source_urls, 1 - (text_vec <=> $1::vector) AS score
FROM kb_docs ORDER BY text_vec <=> $1::vector LIMIT $2`,
[vecStr(vec), topK]
);
return r.rows.map((row) => ({ question: row.question, answer: row.answer, category: row.category, section: row.section, source: row.source_file, sourceUrls: row.source_urls || [], score: Number(row.score) }));
};
let hits = await runQ(query);
const topScore = hits.length ? hits[0].score : 0;
// 召回不足时翻译成英文重检(含中文:中文术语如"一件代发=dropshipping"翻译后召回更准)。
// 仅跳过明显英文 query避免无谓的 LLM 调用。
const looksEnglish = /^[\x00-\x7F]+$/.test(query) && /\b(the|how|what|can|do|is|are|my|to|of|and|for|with|you|return|order|ship|pay|vat|invoice|register|account|discount|membership|dropship|refund|delivery)\b/i.test(query);
if (topScore < 0.55 && !looksEnglish && query.trim().length > 1) {
const en = await translateQueryToEnglish(query);
if (en && en.toLowerCase() !== query.toLowerCase()) {
const enHits = await runQ(en);
// 合并去重(按 question),保留较高 score
const map = new Map();
for (const h of [...hits, ...enHits]) {
const prev = map.get(h.question);
if (!prev || h.score > prev.score) map.set(h.question, h);
}
hits = [...map.values()].sort((a, b) => b.score - a.score).slice(0, topK);
}
}
return hits;
} catch (e) {
console.error('[kb err]', e.message);
return [];
}
}
// productKeywords / formatKBContext 已退役tool-use 架构下 LLM 自主调 search_products/search_kb
// 不再需要正则判断产品词KB 片段拼接也移入 executeTool。
// ---------- 商品向量检索(共享 postgres pgvector + SiliconFlow embedding)----------
const RECOMMEND_URL = process.env.RECOMMEND_URL || 'http://recommend.internal.yw.com';
async function searchRecommend(query, userId, topK = 5) {
try {
const params = "limit=" + topK + "&in_stock=true&site=eu&detail=true";
const url = userId
? RECOMMEND_URL + "/recommend/" + encodeURIComponent(userId) + "?" + params
: RECOMMEND_URL + "/search?q=" + encodeURIComponent(query) + "&" + params;
const resp = await fetch(url);
if (!resp.ok) return null;
const data = await resp.json();
if (!data.recommendations || !data.recommendations.length) return null;
return { total: data.total, source: userId ? "personalized" : "search", items: data.recommendations };
} catch (e) {
console.error("[recommend err]", e.message);
return null;
}
}
// ---------- 订单/客户查询(对接 trading-service)----------
// trading-service 是 Yehwang 的交易微服务Hyperf/PHP端口 9501/9512
// 订单接口ERP 端,无需登录态):
// GET admin/mall/api/trading/order/list?no=订单号 订单列表
// GET admin/mall/api/trading/order/detail?orderId=X 订单详情
// GET admin/mall/api/trading/order/getTrackPreSaleOrder?orderId=X 物流追踪
// 返回格式: { code: 200, message: "success", data: {...} }
const ORDER_API_URL = process.env.ORDER_API_URL || ''; // trading-service base URL
const ORDER_API_KEY = process.env.ORDER_API_KEY || '';
// 查询订单状态/物流。args: {orderNumber?, customerEmail?, trackingNumber?}
async function searchOrders(args) {
if (!ORDER_API_URL) return { text: 'Order query API (trading-service) is not configured.', items: null };
try {
const headers = { 'Content-Type': 'application/json' };
if (ORDER_API_KEY) headers['Authorization'] = `Bearer ${ORDER_API_KEY}`;
// 优先用订单号查询
if (args.orderNumber) {
// 先查列表(用 no 过滤)
const listUrl = `${ORDER_API_URL}/admin/mall/api/trading/order/list?no=${encodeURIComponent(args.orderNumber)}&page=1&page_size=5`;
const listResp = await fetch(listUrl, { headers });
if (!listResp.ok) return { text: `Order query failed (HTTP ${listResp.status}).`, items: null };
const listData = await listResp.json();
const orders = (listData.data && listData.data.list) || listData.data || [];
if (!orders.length) return { text: `No order found with number "${args.orderNumber}".`, items: null };
// 对第一个匹配的订单查详情
const orderId = orders[0].orderId || orders[0].order_id || orders[0].id;
return await fetchOrderDetail(ORDER_API_URL, orderId, headers, orders[0]);
}
// 用客户邮箱查询ERP 列表接口支持 customerEmail 参数)
if (args.customerEmail) {
const listUrl = `${ORDER_API_URL}/admin/mall/api/trading/order/list?customerEmail=${encodeURIComponent(args.customerEmail)}&page=1&page_size=5`;
const listResp = await fetch(listUrl, { headers });
if (!listResp.ok) return { text: `Order query failed (HTTP ${listResp.status}).`, items: null };
const listData = await listResp.json();
const orders = (listData.data && listData.data.list) || listData.data || [];
if (!orders.length) return { text: `No orders found for email "${args.customerEmail}".`, items: null };
// 返回最近订单列表(不逐一查详情,避免太多请求)
const lines = orders.slice(0, 5).map((o, i) => {
const no = o.orderNo || o.order_no || o.no || '?';
const status = o.orderStatus || o.order_status || o.status || 'unknown';
const total = o.totalPrice || o.total_price || o.total || '';
const date = o.dateAdded || o.date_added || o.createdAt || '';
return `${i + 1}. Order #${no} | Status: ${status}${total ? ` | Total: ${total}` : ''}${date ? ` | Date: ${date}` : ''}`;
});
return { text: `Found ${orders.length} order(s) for ${args.customerEmail}:\n${lines.join('\n')}`, items: orders };
}
return { text: 'Please provide an order number or customer email to look up the order.', items: null };
} catch (e) {
console.error("[orders err]", e.message);
return { text: 'Order query encountered an error (trading-service). Please try again or contact helpdesk@yehwang.com.', items: null };
}
}
// 查询单个订单详情 + 物流追踪
async function fetchOrderDetail(baseUrl, orderId, headers, listInfo) {
// 并行查详情 + 物流
const [detailRes, trackRes] = await Promise.allSettled([
fetch(`${baseUrl}/admin/mall/api/trading/order/detail?orderId=${encodeURIComponent(orderId)}`, { headers }),
fetch(`${baseUrl}/admin/mall/api/trading/order/getTrackPreSaleOrder?orderId=${encodeURIComponent(orderId)}`, { headers }),
]);
let detail = null, tracking = null;
if (detailRes.status === 'fulfilled' && detailRes.value.ok) {
const dj = await detailRes.value.json();
detail = dj.data || dj;
}
if (trackRes.status === 'fulfilled' && trackRes.value.ok) {
const tj = await trackRes.value.json();
tracking = tj.data || tj;
}
if (!detail && !listInfo) return { text: `No order detail found for order ID ${orderId}.`, items: null };
const d = detail || listInfo;
const lines = [];
lines.push(`Order #${d.orderNo || d.order_no || d.no || orderId}`);
lines.push(` Status: ${d.orderStatus || d.order_status || d.status || 'unknown'}`);
lines.push(` Shipping status: ${d.shippingStatus || d.shipping_status || 'N/A'}`);
if (d.totalPrice || d.total_price) lines.push(` Total: ${d.totalPrice || d.total_price}`);
if (d.dateAdded || d.date_added) lines.push(` Date: ${d.dateAdded || d.date_added}`);
if (d.shippingMethod || d.shipping_method) lines.push(` Shipping method: ${d.shippingMethod || d.shipping_method}`);
// 物流信息
if (tracking) {
const parcels = Array.isArray(tracking) ? tracking : (tracking.list || [tracking]);
parcels.forEach((p, i) => {
const tn = p.trackingNumber || p.tracking_number || '';
const sc = p.shippingCode || p.shipping_code || '';
const sm = p.shippingMethod || p.shipping_method || '';
const sa = p.shippingAt || p.shipping_at || '';
if (tn || sm) {
lines.push(` Parcel ${i + 1}: ${sm} ${tn}${sa ? ` (shipped: ${sa})` : ''}`);
}
});
}
// 商品明细
const items = d.products || d.items || [];
if (items.length) {
lines.push(' Items:');
items.slice(0, 5).forEach(it => {
lines.push(` - ${it.name || it.productName || 'item'} (SKU: ${it.sku || it.model || '?'}) x${it.quantity || 1} @ ${it.price || ''}`);
});
}
return { text: lines.join('\n'), items: { detail, tracking, listInfo } };
}
// ---------- 库存/价格查询(对接 eta-service)----------
// eta-service 是 Yehwang 的库存微服务Hyperf/PHP端口 9501/9701
// 接口GET /eta/stock-summary?product_id=X 库存汇总
// GET /eta/query?product_id=X ETA + 可用数量
const INVENTORY_API_URL = process.env.INVENTORY_API_URL || ''; // eta-service base URL
const INVENTORY_API_KEY = process.env.INVENTORY_API_KEY || '';
// 查询实时库存/ETA。args: {sku?, productName?}
// eta-service 用 product_id数字sku 可能有字母前缀,尝试解析数字部分
async function searchInventory(args) {
if (!INVENTORY_API_URL) return { text: 'Inventory query API (eta-service) is not configured.', items: null };
try {
const headers = { 'Content-Type': 'application/json' };
if (INVENTORY_API_KEY) headers['Authorization'] = `Bearer ${INVENTORY_API_KEY}`;
// 从 sku 或 productName 中提取 product_id
// eta-service 的 product_id 是数字;客户可能给 "NECK-001" 或纯数字 "12345"
let productId = null;
if (args.sku) {
const numMatch = String(args.sku).match(/\d+/);
if (numMatch) productId = numMatch[0];
}
if (!productId && args.productName) {
const numMatch = String(args.productName).match(/\d+/);
if (numMatch) productId = numMatch[0];
}
if (!productId) {
return { text: `Could not extract a product ID from "${args.sku || args.productName}". Please provide a valid product ID or SKU number.`, items: null };
}
// 并行查库存汇总 + ETA
const [stockRes, etaRes] = await Promise.allSettled([
fetch(`${INVENTORY_API_URL}/eta/stock-summary?product_id=${encodeURIComponent(productId)}`, { headers }),
fetch(`${INVENTORY_API_URL}/eta/query?product_id=${encodeURIComponent(productId)}`, { headers }),
]);
// eta-service 返回: { code: 200, message: "success", data: {...} }
let stockData = null, etaData = null;
if (stockRes.status === 'fulfilled' && stockRes.value.ok) {
const sj = await stockRes.value.json();
stockData = sj.data || sj;
}
if (etaRes.status === 'fulfilled' && etaRes.value.ok) {
const ej = await etaRes.value.json();
etaData = ej.data || ej;
}
if (!stockData && !etaData) {
return { text: `No inventory data found for product ID ${productId}.`, items: null };
}
// 格式化为 LLM 可读文本
const lines = [];
if (stockData) {
const instant = stockData.instantStock != null ? stockData.instantStock : 'N/A';
const inTransit = stockData.inTransitQuantity != null ? stockData.inTransitQuantity : 'N/A';
const reserved = stockData.reservedQuantity != null ? stockData.reservedQuantity : 'N/A';
const frozen = stockData.frozenQuantity != null ? stockData.frozenQuantity : 'N/A';
const available = (typeof instant === 'number' && typeof reserved === 'number') ? Math.max(0, instant - reserved) : 'N/A';
lines.push(`Stock Summary (product_id: ${productId}):`);
lines.push(` Instant stock: ${instant}`);
lines.push(` Available (instant - reserved): ${available}`);
lines.push(` In transit: ${inTransit}`);
lines.push(` Reserved: ${reserved}`);
lines.push(` Frozen: ${frozen}`);
}
if (etaData) {
lines.push(`ETA Info:`);
lines.push(` ETA date: ${etaData.etaDate || 'N/A'}`);
lines.push(` Available quantity: ${etaData.availableQuantity != null ? etaData.availableQuantity : 'N/A'}`);
lines.push(` Status: ${etaData.status || 'N/A'}`);
lines.push(` Confidence: ${etaData.confidence != null ? etaData.confidence : 'N/A'}`);
}
return { text: lines.join('\n'), items: { stock: stockData, eta: etaData } };
} catch (e) {
console.error("[inventory err]", e.message);
return { text: 'Inventory query encountered an error (eta-service). Please try again or contact helpdesk@yehwang.com.', items: null };
}
}
const EXTRACT_SYSTEM = `You are an information extractor. Based on a customer service conversation (which may be in English, German, French, Spanish, Italian, Dutch, or Chinese), judge whether enough sales-lead info has been collected.
Judge ready=true only if the conversation clearly contains a [company name] AND a [product/need of interest] AND [contact info at least an email or phone, OR a contact person's name] in ANY supported language.
IMPORTANT for the "contact" field: If at any point the bot asked for a contact name (联系人姓名/联系人/your name) and the customer replied with a short word (like "tiema", "John", "张三", a nickname), THAT is the contact name put it in the "contact" field. Do not leave it empty just because it doesn't look like a full name.
For fields not present in the conversation, leave them empty do NOT make anything up.
The "name" and "summary" fields should ALWAYS be in ENGLISH (regardless of customer language), so they can be used as CRM data.
Output ONLY one JSON object, nothing else, no markdown code fences:
{
"ready": true|false,
"opportunity": {
"name": "<opportunity name, format: Company - need short desc>",
"company": "<company name, may be empty>",
"domain": "<company website domain if mentioned, e.g. acme.com, else empty>",
"contact": "<contact person name — any word/nickname the customer gave when asked for their name, may be empty>",
"email": "<contact email if mentioned, else empty>",
"phone": "<contact phone if mentioned, else empty>",
"amount": <number, estimated order value = unit price × quantity if both are mentioned, else null>,
"currency": "<ISO currency based on the customer's country: CNY for China, EUR for Eurozone (DE/FR/ES/IT/NL...), GBP for UK, USD for US. Default EUR if unknown>",
"closeDate": "<YYYY-MM-DD or empty>",
"stage": "<one of NEW|SCREENING|MEETING|PROPOSAL|CUSTOMER, default NEW>",
"summary": "<one-sentence summary of customer need>"
}
}`;
async function extractOpportunity(history) {
// history: [{role, content}]
const messages = [
...history,
{ role: 'user', content: '请根据以上对话输出 JSON。' },
];
const result = await callLLM(messages, EXTRACT_SYSTEM, 4096);
const cleaned = result.text.replace(/```json|```/g, '').trim();
const m = cleaned.match(/\{[\s\S]*\}/);
if (!m) return { ready: false };
try {
return JSON.parse(m[0]);
} catch {
return { ready: false };
}
}
// ---------- Agent tools (function calling) ----------
// LLM 自主调用以下工具完成检索/转人工bridge 执行后把结果回填,再由 LLM 生成最终回复。
// 相比手写意图路由:路由决策交给 LLM且工具返回在干净上下文生成不受销售历史污染。
const AGENT_TOOLS = [
{
type: 'function',
function: {
name: 'search_kb',
description: 'Search the Yehwang official FAQ/knowledge base for policy answers (registration, account, password, minimum order, cancel, pre-order, payment, VAT, invoice, shipping, delivery, returns, refunds, membership, discounts, dropshipping, factory-direct, materials, jewelry care, etc.). Call this for ANY policy/FAQ question.',
parameters: { type: 'object', properties: { query: { type: 'string', description: 'the customer question, in any language' } }, required: ['query'] },
},
},
{
type: 'function',
function: {
name: 'search_products',
description: 'Search the product catalog for items the customer wants to find/buy (jewelry, accessories, clothing). Returns product list with prices and images.',
parameters: { type: 'object', properties: { query: { type: 'string', description: 'product keywords from the customer' } }, required: ['query'] },
},
},
{
type: 'function',
function: {
name: 'escalate_human',
description: 'Escalate to a human agent when the question is outside the knowledge base/product scope or you cannot confidently answer.',
parameters: { type: 'object', properties: { reason: { type: 'string' } }, required: ['reason'] },
},
},
{
type: 'function',
function: {
name: 'search_orders',
description: 'Look up an existing order status, tracking number, shipment details, or delivery estimate. Call when the customer asks about their order ("where is my order", "order status", "tracking", "when will it arrive"). Provide order number if known, otherwise use customer email.',
parameters: {
type: 'object',
properties: {
orderNumber: { type: 'string', description: 'the order number, e.g. "12345" or "YW-2026-12345"' },
customerEmail: { type: 'string', description: 'customer email, used to find recent orders when order number is unknown' },
trackingNumber: { type: 'string', description: 'tracking number for direct shipment lookup' },
},
required: [],
},
},
},
{
type: 'function',
function: {
name: 'search_inventory',
description: 'Check real-time stock and price for a specific SKU or product name. Call when the customer asks "is it in stock", "how many available", "what is the price of SKU xxx", "can I order 500 pieces" (to verify stock before confirming). Note: search_products is for browsing the catalog; search_inventory is for checking real-time stock/price on a specific item before placing an order.',
parameters: {
type: 'object',
properties: {
sku: { type: 'string', description: 'the product SKU/code' },
productName: { type: 'string', description: 'product name to search inventory by name' },
},
required: [],
},
},
},
];
// 商品图片 markdown(bridge 在最终回复后追加,因为 LLM 无法直接发图)
function buildProductListText(items) {
return '\n\n📷 点击查看商品详情:\n' + items.slice(0, 3).map((p) => {
const title = p.title || 'Fashion Accessory';
const link = (p.product_url || '').replace(/ /g, '%20');
const img = (p.image_url || '').replace(/ /g, '%20');
const price = p.min_price != null ? '€' + p.min_price : 'View page';
const stock = p.stock != null ? ' | stock: ' + p.stock : '';
const imgMd = img ? '\n![' + title + '](' + img + ')' : '';
return '[' + title + '](' + link + ') — ' + price + stock + imgMd;
}).join('\n\n');
}
// 格式化 KB 命中片段为 LLM 上下文(含来源 URL供回复时附上详细资料链接)
function formatKB(hits) {
return hits.map((h, i) => {
const src = (h.sourceUrls && h.sourceUrls.length) ? h.sourceUrls.join(' / ') : '';
return `[${i + 1}] (category: ${h.category})\nQ: ${h.question}\nA: ${h.answer}${src ? '\nSource: ' + src : ''}`;
}).join('\n\n---\n\n');
}
// 从 KB hits 收集去重的来源 URL(取前2个供回复时附详细资料链接)
// 从 KB hits 收集来源 URL只取最相关(top1) chunk 的来源,避免跨类目混清(如退货召回了 factory-direct chunk 会张冠李戴)。
// 同一 chunk 可能有多个 URL(如 dropshipping 的 faq 分页)取前2个。
function collectSources(hits) {
if (!hits.length) return [];
const top = hits[0];
return (top.sourceUrls || []).slice(0, 2);
}
// 执行单个 tool返回文本结果(喂回 LLM) + 副产品(productItems 供 bridge 追加图片)
async function executeTool(name, args, ctx) {
if (name === 'search_kb') {
const hits = await searchKB(args.query, 4);
if (!hits.length) return { text: 'No relevant knowledge base entries found.', productItems: null, kbSources: [] };
return { text: formatKB(hits.slice(0, 3)), productItems: null, kbSources: collectSources(hits) };
}
if (name === 'search_products') {
const kw = (args.query || '').trim();
// 新品/爆款/trending 等直接交给推荐引擎,它自己能排新/热的出来
const searchUrl = 'https://www.yehwang.com/search?keyword=' + encodeURIComponent(kw);
const res = await searchRecommend(kw, null, 5);
if (!res || !res.items || !res.items.length) {
return { text: `No exact matches via internal search. Direct the customer to browse all results here: ${searchUrl}`, productItems: [] };
}
const items = res.items;
const text = `INSTRUCTION: Show these products to the customer. Use EXACT names. If they are clearly irrelevant (e.g. "toy train" returned a handbag), only then say we don't carry it and skip the search link. Otherwise, present them naturally — don't start with "no exact match" if the products are reasonably related. Only mention discounts if asked.\n\nPRODUCTS:\n` +
items.slice(0, 3).map((p, i) =>
`${i + 1}. ${p.title || 'item'}${p.min_price != null ? '€' + p.min_price : 'price on page'}${p.stock != null ? ' (stock ' + p.stock + ')' : ''}${p.product_url || ''}`
).join('\n') + `\n\nFor more results, share this search link with the customer (browse all matches with public prices): ${searchUrl}`;
return { text, productItems: items };
}
if (name === 'escalate_human') {
// 真 handoff改会话状态为 open + 清 bot assignee人类接管bridge 后续不再抢答(靠 locks+assignee 判断)
const reason = args.reason || 'escalated by AI';
if (ctx && ctx.conversationId) await handoffToHuman(ctx.conversationId, reason);
return { text: 'You have escalated this conversation to a human agent. The conversation status is now open and unassigned from the bot. Tell the customer a human teammate will take over shortly; for urgent matters they can also email helpdesk@yehwang.com / +31 318 668 171 (website & in-stock) or service@yehwang.com (Factory-Direct). Stop answering further - hand off now.', productItems: null, handoff: true };
}
if (name === 'search_orders') {
const res = await searchOrders(args);
return { text: res.text, productItems: null };
}
if (name === 'search_inventory') {
const res = await searchInventory(args);
return { text: res.text, productItems: null };
}
return { text: 'Unknown tool.', productItems: null };
}
// Agent 主循环LLM 决策调 tool -> 执行 -> 回填 -> 再生成,直到拿到纯文本回复或达到轮次上限。
// 返回 { reply, productItems }。prefetchKb: 预先启动的 searchKB promise(收到消息即并行检索,省掉决策后串行等待)。
async function runAgent(history, conversationId, prefetchKb = null) {
const messages = [...history];
let productItems = null;
let kbSources = [];
let handoff = false;
const MAX_TURNS = 5; // 防止死循环
for (let turn = 0; turn < MAX_TURNS; turn++) {
const res = await callLLM(messages, AGENT_SYSTEM, 768, AGENT_TOOLS);
console.log(`[agent] conv=${conversationId} turn=${turn} finish=${res.finishReason} tools=${res.toolCalls.map(t => t.name).join(',') || 'none'}`);
if (!res.toolCalls.length) {
// 纯文本回复 -> 结束
return { reply: res.text, productItems, kbSources, handoff };
}
// 把 assistant 的 tool_calls 消息加入上下文
messages.push({
role: 'assistant',
content: res.text || '',
tool_calls: res.toolCalls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } })),
});
// 执行每个 tool 并回填 tool 角色消息
for (const tc of res.toolCalls) {
let out;
// search_kb 且参数与预检索 query 一致 -> 复用预检索结果(已并行启动,此刻通常已就绪)
if (tc.name === 'search_kb' && prefetchKb) {
const hits = await prefetchKb;
out = hits.length
? { text: formatKB(hits.slice(0, 3)), productItems: null, kbSources: collectSources(hits) }
: { text: 'No relevant knowledge base entries found.', productItems: null, kbSources: [] };
} else {
out = await executeTool(tc.name, tc.arguments, { conversationId });
}
console.log(`[agent] conv=${conversationId} tool=${tc.name} args=${JSON.stringify(tc.arguments).slice(0, 60)} -> ${out.text.slice(0, 80)}`);
if (out.productItems) productItems = out.productItems; // 保留最后一次商品检索结果
if (out.kbSources && out.kbSources.length) kbSources = out.kbSources; // 保留 KB 来源 URL
messages.push({ role: 'tool', tool_call_id: tc.id, content: out.text });
if (out.handoff) { handoff = true; break; } // 转人工后停止 agent 循环
}
if (handoff) break;
}
// 轮次用尽,强制再要一次纯文本回复
messages.push({ role: 'user', content: 'Please give the customer a final answer now based on the information gathered.' });
const res = await callLLM(messages, AGENT_SYSTEM, 768, null);
return { reply: res.text || 'Sorry, I could not process that. Please email helpdesk@yehwang.com.', productItems, kbSources, handoff };
}
// ---------- 主处理流程 ----------
async function handleIncomingMessage(conversationId, incomingText, imageUrls) {
if (locks.has(conversationId)) {
console.log(`[skip] conversation ${conversationId} 正在处理中`);
return;
}
locks.add(conversationId);
setTyping(conversationId, true); // 立即“正在输入”,消除无响应体感
try {
// 1. 拉取会话历史
const msgs = await fetchConversationMessages(conversationId);
// 映射为 LLM messages: incoming(客户)=user, outgoing(客服/bot)=assistant
// bot 回复里的商品 markdown 列表对 LLM 判断线索没用,只保留文案部分(第一段)
let history = msgs
.filter((m) => m.content && typeof m.content === 'string' && m.content.trim())
.filter((m) => m.message_type !== 3 && m.content_type !== 'input_email' && m.content_type !== 'input_phone') // 过滤 Chatwoot 欢迎语/表单模板
.map((m) => {
let content = stripHtml(m.content);
const isBot = !(m.message_type === 'incoming' || m.message_type === 0);
if (isBot) {
// bot 回复: 去掉 markdown 链接/图片行,只留自然语言文案
content = content.split('\n').filter(l =>
!l.match(/^\s*\[.*\]\(http/) && // markdown 链接
!l.match(/^\s*!\[.*\]\(http/) && // markdown 图片
!l.match(/^\s*\*\*.*\*\*\s*—\s*€/) // 商品价格行
).join(' ').trim();
}
return { role: isBot ? 'assistant' : 'user', content };
})
.filter((m) => m.content); // 过滤掉清理后变空的
// 合并连续相同 role避免协议报错
history = history.reduce((acc, m) => {
const last = acc[acc.length - 1];
if (last && last.role === m.role) last.content += '\n' + m.content;
else acc.push(m);
return acc;
}, []);
// 如果 history 为空(新会话,消息还没入库),用当前消息作为第一条
if (history.length === 0) {
const q = stripHtml(incomingText || '');
if (q) history.push({ role: 'user', content: q });
else { await sendChatwootReply(conversationId, 'Hello! How can I help you today?'); return; }
}
// 保证第一条是 user
if (history[0].role !== 'user') {
history.unshift({ role: 'user', content: 'Hello' });
}
// 关键:确保当前用户消息在 history 末尾(fetch 拉的是已入库历史,当前消息可能还没入库,
// 否则 runAgent 拿到的末尾是旧 assistant 收尾LLM 看不到客户新问题 -> 不调 tool)
const curQ = stripHtml(incomingText || '');
if (curQ) {
const last = history[history.length - 1];
if (!last || last.role !== 'user' || last.content !== curQ) {
history.push({ role: 'user', content: curQ });
}
}
// 2. Agent tool-use 循环LLM 自主决定调 search_kb / search_products / escalate_human
// 工具返回在干净上下文生成回复,不受销售历史污染。
const query = stripHtml(incomingText || '');
console.log('[agent start] conv=' + conversationId + ' query="' + query.slice(0, 50) + '"');
// 预并行 KB 检索收到消息即启动LLM 决策调 search_kb 时直接复用(并行,不额外耗时)
const prefetchKb = query ? searchKB(query, 4).catch(() => []) : null;
let reply, productItems, kbSources = [], handoff = false;
try {
// 全走 agent tool-use路由决策交给 LLM不再用正则猜意图(脆、不可扩展、与 tool-use 架构矛盾)。
({ reply, productItems, kbSources, handoff } = await runAgent(history.slice(-6), conversationId, prefetchKb));
console.log('[llm raw] ' + (reply || 'NULL'));
} catch (llmErr) {
console.error('[llm error] conv=' + conversationId + ':', llmErr.message);
}
// 商品图片:若 agent 调了 search_productsbridge 在文案后追加商品 markdown(带图)
let finalReply = reply || 'Hello! How can I help you today?';
if (handoff && !reply) {
finalReply = '已为您转接人工客服,稍后会有同事为您服务。紧急事项也可邮件 helpdesk@yehwang.com / service@yehwang.com。';
} else if (productItems && productItems.length) {
// LLM 说没有匹配时不追加商品卡(如客户问火车玩具,推荐引擎仍返回商品,但 LLM 判断不匹配)
const saidNo = /(?:没有|不存在|未匹配|未找到|no matching|don.t carry|不在)/i.test(reply || '');
if (!saidNo) {
const validItems = productItems.filter((p) => p.title && p.title !== 'Fashion Accessory');
if (validItems.length) finalReply = (reply || 'Here are some options:') + '\n' + buildProductListText(productItems);
}
} else if (!reply) {
finalReply = 'Thanks for your message — our team will follow up shortly.';
}
// 程序化追加“详细资料”来源 URL(去重;若 LLM 文案已含该 URL 则跳过,避免重复)
if (kbSources.length) {
const missing = kbSources.filter((u) => !finalReply.includes(u));
if (missing.length) {
finalReply = finalReply.replace(/\s+$/, '') + '\n详见官方政策 / Details: ' + missing.map((u) => u).join(' · ');
}
}
await sendChatwootReply(conversationId, fixupLinks(finalReply));
setTyping(conversationId, false);
console.log('[reply] conv=' + conversationId + ' -> ' + finalReply.slice(0, 200));
// CRM异步启动不阻塞锁下一条消息不会被 skip
setImmediate(() => { extractAndCreateCRM(history, query, finalReply, conversationId).catch(e => console.error('[CRM async err]', e.message)); });
} catch (e) {
console.error(`[error] conv=${conversationId}:`, e.message);
try {
await sendChatwootReply(conversationId, 'Sorry, something went wrong on our side. Please try again in a moment.');
} catch {}
} finally {
locks.delete(conversationId);
}
}
// 后台异步抽取销售线索并写入 CRM(不阻塞锁,失败不影响主回复)
async function extractAndCreateCRM(history, query, finalReply, conversationId) {
try {
const preExtract = await extractOpportunity(
[...history.filter(m => m.content !== query), { role: 'user', content: query }]
).catch(() => ({ ready: false }));
const ex = await extractOpportunity(
[...history.filter(m => m.content !== query), { role: 'user', content: query }, { role: 'assistant', content: finalReply }]
).catch(() => preExtract);
console.log(`[extract] conv=${conversationId} ready=${ex.ready}`, ex.opportunity || '');
if (!ex.ready || !ex.opportunity || createdOpps.has(conversationId)) return;
const o = ex.opportunity;
const lead = await createLeadInCRM({
company: o.company || null, domain: o.domain || null,
contactName: o.contact || null, email: o.email || null, phone: o.phone || null,
oppName: o.name || 'Lead from website chat', amount: o.amount || null,
currency: o.currency || 'EUR', stage: o.stage || 'NEW',
conversationId,
});
createdOpps.add(conversationId);
console.log(`[CRM✓] conv=${conversationId} 建线索:`, JSON.stringify(lead),
lead.companyCreated ? '(新公司)' : '(公司已存在)',
lead.personCreated ? '(新联系人)' : '(联系人已存在)');
// 异步推聊天记录到 Twenty note
setImmediate(() => addChatToOpportunity(conversationId, lead.opportunityId).catch(e => console.error('[note err]', e.message.slice(0,100))));
// 异步推聊天记录到 Twenty note(不阻塞)
setImmediate(() => addChatToOpportunity(conversationId, lead.opportunityId).catch(() => {}));
} catch (e) {
console.error(`[CRM✗] conv=${conversationId}`, e.message);
}
}
// AgentBot secret(HMAC 签名校验),注册 AgentBot 时生成,环境变量覆盖
const AGENT_BOT_SECRET = process.env.AGENT_BOT_SECRET || '';
// 会话开场白conversation_opened/webwidget_triggered 时 bot 主动问候(对标企业版 Captain)
const WELCOME = '您好!我是 Yehwang 智能客服,可帮您查询商品、下单/支付/物流/退货/会员等政策,也支持转人工。请问有什么可以帮您?';
async function sendWelcome(conversationId) {
try { await sendChatwootReply(conversationId, WELCOME); } catch {}
}
// 校验 AgentBot 的 HMAC 签名X-Chatwoot-Signature = sha256=HMAC(secret, "{ts}.{body}")
function verifyAgentBotSignature(req, body) {
if (!AGENT_BOT_SECRET) return true; // 未配 secret 时跳过校验(本地开发)
// 本地开发HMAC 校验体差异难排查跳过Docker 内网安全)
if (process.env.SKIP_AGENT_BOT_SIGNATURE === 'true') return true;
const sig = req.headers['x-chatwoot-signature'];
const ts = req.headers['x-chatwoot-timestamp'];
if (!sig || !ts) { console.error('[agent-bot] 缺少签名头 sig=', sig, 'ts=', ts); return false; }
const expected = 'sha256=' + crypto.createHmac('sha256', AGENT_BOT_SECRET).update(`${ts}.${body}`).digest('hex');
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (age > 300) { console.error('[agent-bot] 时间偏移过大 age=', age); return false; }
if (sig !== expected) {
console.error('[agent-bot] HMAC不匹配');
console.error('[agent-bot] 收到sig:', sig.slice(0, 16));
console.error('[agent-bot] 期望sig:', expected.slice(0, 16));
console.error('[agent-bot] body长度:', body.length, 'body前80字:', (body||'').slice(0, 80));
}
return sig === expected;
}
// ---------- HTTP 服务 ----------
const server = http.createServer(async (req, res) => {
// 健康检查
if (req.method === 'GET' && (req.url === '/healthz' || req.url === '/')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true, service: 'chatwoot-bridge', agentBot: !!AGENT_BOT_SECRET }));
}
// CRM 拉取会话记录接口GET /conversation/:id
const convMatch = req.url.match(/^\/conversation\/(\d+)$/);
if (req.method === 'GET' && convMatch) {
try {
const msgs = await fetchConversationMessages(parseInt(convMatch[1]));
const cleaned = msgs
.filter((m) => m.content && typeof m.content === 'string' && m.content.trim())
.map((m) => ({
role: m.message_type === 0 || m.message_type === 'incoming' ? 'customer' : 'bot',
content: stripHtml(m.content),
time: m.created_at || null,
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ conversationId: parseInt(convMatch[1]), messages: cleaned }));
} catch (e) {
res.writeHead(500);
return res.end(JSON.stringify({ error: e.message }));
}
}
// 会话 HTML 嵌入页GET /conversation/:id/html — Twenty iframe 用)
const htmlMatch = req.url.match(/^\/conversation\/(\d+)\/html$/);
if (req.method === 'GET' && htmlMatch) {
try {
const msgs = await fetchConversationMessages(parseInt(htmlMatch[1]));
const items = msgs
.filter((m) => m.content && typeof m.content === 'string' && m.content.trim())
.map((m) => {
const role = m.message_type === 0 || m.message_type === 'incoming' ? '客户' : '客服';
return `<div class="msg ${m.message_type === 0 ? 'cust' : 'bot'}"><b>${role}:</b> ${stripHtml(m.content).replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>`;
})
.join('');
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font:14px/1.6 sans-serif;margin:12px;background:#f8f8f8}.msg{padding:8px 12px;margin:6px 0;border-radius:8px;max-width:90%}.cust{background:#e3f2fd;margin-right:auto}.bot{background:#fff;margin-left:auto;text-align:right}</style></head><body>${items}</body></html>`;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(html);
} catch (e) {
res.writeHead(500);
return res.end(e.message);
}
}
// Chatwoot AgentBot outgoing 端点(官方机制,带 secret 签名,事件更全)
if (req.method === 'POST' && req.url === '/agent-bot') {
let body = '';
for await (const chunk of req) body += chunk;
// 签名校验
if (!verifyAgentBotSignature(req, body)) {
console.error('[agent-bot] 签名校验失败');
res.writeHead(401); return res.end('invalid signature');
}
let payload;
try { payload = JSON.parse(body || '{}'); }
catch (e) { res.writeHead(400); return res.end('bad json'); }
res.writeHead(200); res.end('ok'); // 先 200异步处理
const event = payload.event;
const conv = payload.conversation || {};
const conversationId = conv.id;
const msg = payload.message || {};
console.log(`\n[agent-bot] event=${event} conv=${conversationId} status=${conv.status}`);
if (event === 'conversation_opened' || event === 'webwidget_triggered') {
// 会话开启:主动问候(仅新会话/无历史消息时)
if (event === 'webwidget_triggered') sendWelcome(conversationId).catch(() => {});
return;
}
if (event === 'message_created') {
const messageType = msg.message_type;
const senderType = msg.sender_type || (msg.sender && msg.sender && msg.sender.type);
// 只处理客户消息bot 作 assignee 时才抢答(否则已 handoff 给人类,不介入)
if (messageType === 'incoming' || senderType === 'Contact') {
const imageUrls = (msg.attachments || [])
.filter(a => a.file_type === 'image' || (a.file_type || '').toLowerCase().includes('image'))
.map(a => a.data_url || a.file_url || (a.data && a.data.url)).filter(Boolean);
handleIncomingMessage(conversationId, msg.content, imageUrls).catch((e) =>
console.error('[handle error]', e.message));
}
return;
}
// conversation_resolved / status_changed 等事件:记录即可
return;
}
// Chatwoot webhook(兼容旧入口,未配 AgentBot 时仍可用)
if (req.method === 'POST' && req.url === '/chatwoot-webhook') {
let body = '';
for await (const chunk of req) body += chunk;
let payload;
try {
payload = JSON.parse(body || '{}');
} catch (e) {
console.error('[webhook] JSON parse err:', e.message, 'body:', body.slice(0, 200));
res.writeHead(400);
return res.end('bad json');
}
res.writeHead(200); res.end('ok'); // 先 200异步处理
const msg = payload.message || payload;
const conversationId = (payload.conversation && payload.conversation.id) || msg.conversation_id;
const messageType = msg.message_type; // incoming=客户, outgoing=客服/bot
const senderType = msg.sender_type || (msg.sender && msg.sender.type);
console.log(`\n[webhook] event=${payload.event || '?'} conv=${conversationId} type=${messageType} sender=${senderType} content="${(msg.content || '').slice(0, 40)}"`);
// 提取客户发的图片附件(以图搜图用)
const imageUrls = (msg.attachments || [])
.filter(a => a.file_type === 'image' || (a.file_type || '').toLowerCase().includes('image'))
.map(a => a.data_url || a.file_url || (a.data && a.data.url))
.filter(Boolean);
if (imageUrls.length) console.log(`[webhook] 客户发图片 ${imageUrls.length} 张,启用以图搜图`);
// 只处理客户发来的消息(避免无限循环)
if (messageType === 'incoming' || senderType === 'Contact') {
handleIncomingMessage(conversationId, msg.content, imageUrls).catch((e) =>
console.error('[handle error]', e.message)
);
}
return;
}
res.writeHead(404);
res.end('not found');
});
server.listen(PORT, () => {
console.log(`Chatwoot bridge listening on :${PORT}`);
console.log(` Chatwoot: ${cw.baseURL} (account ${cw.accountId})`);
console.log(` Twenty : ${twenty.baseURL} (workspace ${twenty.workspaceId})`);
console.log(` LLM : ${MODEL} @ ${PROVIDER.baseUrl}`);
});