Author SHA1 Message Date
wangliang 3b5314b2a5 chore(libredesk): 升级到 v2.9.0 2026-10-26 13:12:38 +08:00
15 changed files with 52 additions and 197 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ POSTGRES_PASSWORD=<change-me>
POSTGRES_DB=<change-me> POSTGRES_DB=<change-me>
POSTGRES_IMAGE=pgvector/pgvector:pg18 POSTGRES_IMAGE=pgvector/pgvector:pg18
REDIS_IMAGE=redis:8.8-alpine REDIS_IMAGE=redis:8.8-alpine
LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0-rc.10 LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0
LIBREDESK_SYSTEM_USER_PASSWORD=<change-me> LIBREDESK_SYSTEM_USER_PASSWORD=<change-me>
LIBREDESK_ENC_KEY=<change-me> LIBREDESK_ENC_KEY=<change-me>
LIBREDESK_BASE_URL=<change-me> LIBREDESK_BASE_URL=<change-me>
+4 -4
View File
@@ -3,11 +3,11 @@
**完全新建**的一套 CRM 客服栈:LibreDesk(平台 + 内置 AI)+ Twenty(CRM)+ bridge(业务工具/集成)。 **完全新建**的一套 CRM 客服栈:LibreDesk(平台 + 内置 AI)+ Twenty(CRM)+ bridge(业务工具/集成)。
与旧项目 `/home/dev/code/crm/crm-ai-demo` 完全隔离:独立目录、独立 Postgres/Redis、独立端口,不迁旧数据。 与旧项目 `/home/dev/code/crm/crm-ai-demo` 完全隔离:独立目录、独立 Postgres/Redis、独立端口,不迁旧数据。
## 当前状态(2026-10-21) ## 当前状态(2026-10-26)
| 组件 | 状态 | | 组件 | 状态 |
|---|---| |---|---|
| LibreDesk v2.9.0-rc.10 | ✅ 运行中 :9000(System 密码见 `.env`) | | LibreDesk v2.9.0 | ✅ 运行中 :9000(System 密码见 `.env`) |
| Twenty v2.30.1(全新实例) | ✅ 运行中 :3100,数据库迁移完成(71 张表) | | Twenty v2.30.1(全新实例) | ✅ 运行中 :3100,数据库迁移完成(71 张表) |
| bridge | ✅ 运行中 :4100,4 个工具,单测 18/18 通过 | | bridge | ✅ 运行中 :4100,4 个工具,单测 18/18 通过 |
| Postgres / Redis | ✅ :5440 / :6390 healthy | | Postgres / Redis | ✅ :5440 / :6390 healthy |
@@ -53,7 +53,7 @@ Twenty :3100 ── 公司/联系人/商机/备注/任务
| 组件 | 地址 | 镜像/实现 | | 组件 | 地址 | 镜像/实现 |
|---|---|---| |---|---|---|
| LibreDesk | http://localhost:9000 | `libredesk:v2.9.0-rc.10`(含 WhatsApp) | | LibreDesk | http://localhost:9000 | `libredesk:v2.9.0`(含 WhatsApp) |
| Twenty | http://localhost:3100 | `twentycrm/twenty:v2.30.1`(全新实例) | | Twenty | http://localhost:3100 | `twentycrm/twenty:v2.30.1`(全新实例) |
| bridge | http://localhost:4100 | Node 24,自研 | | bridge | http://localhost:4100 | Node 24,自研 |
| Postgres | localhost:5440 | pgvector/pg18,库 `libredesk` / `twenty` / `bridge` | | Postgres | localhost:5440 | pgvector/pg18,库 `libredesk` / `twenty` / `bridge` |
@@ -220,6 +220,6 @@ cd bridge && npm test
## 关键约束 ## 关键约束
- **镜像直接使用 Docker Hub**:`libredesk/libredesk`、`pgvector/pgvector`、`redis`、`twentycrm/twenty`;如网络受限可在 Docker daemon 配自己的 registry mirror - **镜像直接使用 Docker Hub**:`libredesk/libredesk`、`pgvector/pgvector`、`redis`、`twentycrm/twenty`;如网络受限可在 Docker daemon 配自己的 registry mirror
- **WhatsApp 仅 v2.9 RC 支持**,需要 Meta Cloud API 凭据;RC 版供联调,不直接上生产 - **WhatsApp 自 v2.9.0 起支持**,需要 Meta Cloud API 凭据;当前运行 v2.9.0 正式版
- **LibreDesk AI 仍是 beta**,建议低流量验证 - **LibreDesk AI 仍是 beta**,建议低流量验证
- 旧栈(Chatwoot/bridge/Twenty :3000)保持运行,可并行对照,互不影响 - 旧栈(Chatwoot/bridge/Twenty :3000)保持运行,可并行对照,互不影响
+7 -29
View File
@@ -24,7 +24,6 @@ const { createEscalation } = require('./escalation');
const { createExperimentManager } = require('./experiments'); const { createExperimentManager } = require('./experiments');
const { createTraceWriter } = require('./trace-writer'); const { createTraceWriter } = require('./trace-writer');
const { extractLeadFields } = require('./lead-extract'); const { extractLeadFields } = require('./lead-extract');
const { extractProductKeyword } = require('./product-query');
const crypto = require('crypto'); const crypto = require('crypto');
const prom = require('./prom'); const prom = require('./prom');
@@ -108,7 +107,7 @@ function createAgent(cfg, deps = {}) {
async function runOnce(conv, webhookMessage) { async function runOnce(conv, webhookMessage) {
if (!llm.enabled) throw new Error('LLM is not configured'); if (!llm.enabled) throw new Error('LLM is not configured');
const startedAt = Date.now(); const startedAt = Date.now();
const stats = { llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, toolCalls: [], toolResults: [], llmMs: 0, toolMs: 0 }; const stats = { llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, toolCalls: [], toolResults: [] };
const traceSteps = []; const traceSteps = [];
const traceId = `${conv.uuid}:${webhookMessage.uuid || ''}`; const traceId = `${conv.uuid}:${webhookMessage.uuid || ''}`;
const profile = resolveSiteProfile(cfg, { inboxId: String(conv.inbox_id || '') }); const profile = resolveSiteProfile(cfg, { inboxId: String(conv.inbox_id || '') });
@@ -158,8 +157,6 @@ function createAgent(cfg, deps = {}) {
// 预检索:政策问题先查 KB,商品问题先查商品,避免模型跳过工具直接编。 // 预检索:政策问题先查 KB,商品问题先查商品,避免模型跳过工具直接编。
const intent = quickClassify(currentText); const intent = quickClassify(currentText);
// 商品预检索/强制检索只发清洗后的短关键词(旧 bridge 思路):原文含数量/货币会让商品 RPC 慢 10 倍以上。
const productKeyword = intent === INTENTS.PRODUCT_SEARCH ? extractProductKeyword(currentText) : '';
const emotion = detectEmotion(currentText); const emotion = detectEmotion(currentText);
const emotionStrategy = escalation.applyEmotionStrategy(emotion); const emotionStrategy = escalation.applyEmotionStrategy(emotion);
const maxSteps = emotionStrategy.maxToolTurns || MAX_TOOL_STEPS; const maxSteps = emotionStrategy.maxToolTurns || MAX_TOOL_STEPS;
@@ -174,13 +171,11 @@ function createAgent(cfg, deps = {}) {
let preResult = null; let preResult = null;
try { try {
let preTool = null; let preTool = null;
// 商品意图清洗后没有可用商品词(如纯数量/纯问候)→ 跳过预检索,交给 LLM 决定
if (intent === INTENTS.POLICY_FAQ) preTool = byName.get('search_kb'); if (intent === INTENTS.POLICY_FAQ) preTool = byName.get('search_kb');
else if (intent === INTENTS.PRODUCT_SEARCH && productKeyword) preTool = byName.get('search_products'); else if (intent === INTENTS.PRODUCT_SEARCH) preTool = byName.get('search_products');
if (preTool) { if (preTool) {
const started = Date.now(); const started = Date.now();
preResult = await preTool.handler({ query: preTool.name === 'search_products' ? productKeyword : currentText, limit: 5 }, ctx); preResult = await preTool.handler({ query: currentText, limit: 5 }, ctx);
stats.toolMs += Date.now() - started;
stats.toolCalls.push({ name: preTool.name, ms: Date.now() - started, ok: true, forced: true }); stats.toolCalls.push({ name: preTool.name, ms: Date.now() - started, ok: true, forced: true });
stats.toolResults.push(preResult); stats.toolResults.push(preResult);
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'tool', tool: preTool.name, forced: true, ms: Date.now() - started, conversation: conv.uuid, ok: true })); console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'tool', tool: preTool.name, forced: true, ms: Date.now() - started, conversation: conv.uuid, ok: true }));
@@ -234,7 +229,6 @@ function createAgent(cfg, deps = {}) {
maxTokens: (variant.overriddenParams && variant.overriddenParams.maxTokens) ?? 1200, maxTokens: (variant.overriddenParams && variant.overriddenParams.maxTokens) ?? 1200,
}); });
stats.llmCalls += 1; stats.llmCalls += 1;
stats.llmMs += Date.now() - llmStarted;
traceSteps.push({ step_type: 'llm', turn: step, model: llm.model, prompt_tokens: response.usage?.prompt_tokens, completion_tokens: response.usage?.completion_tokens, duration_ms: Date.now() - llmStarted, finish_reason: response.finishReason || null }); traceSteps.push({ step_type: 'llm', turn: step, model: llm.model, prompt_tokens: response.usage?.prompt_tokens, completion_tokens: response.usage?.completion_tokens, duration_ms: Date.now() - llmStarted, finish_reason: response.finishReason || null });
if (response.usage) { if (response.usage) {
stats.promptTokens += Number(response.usage.prompt_tokens) || 0; stats.promptTokens += Number(response.usage.prompt_tokens) || 0;
@@ -243,17 +237,16 @@ function createAgent(cfg, deps = {}) {
} }
// 兜底(旧 bridge 同款):product_search 意图下首轮 LLM 一个工具都没调时, // 兜底(旧 bridge 同款):product_search 意图下首轮 LLM 一个工具都没调时,
// 丢弃它凭记忆编的回答,强制查一次真实商品结果再让它重答,避免幻觉商品/链接进入回复。 // 丢弃它凭记忆编的回答,强制查一次真实商品结果再让它重答,避免幻觉商品/链接进入回复。
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && productKeyword && !stats.toolCalls.length) { if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && !stats.toolCalls.length) {
const searchTool = byName.get('search_products'); const searchTool = byName.get('search_products');
if (searchTool) { if (searchTool) {
const started = Date.now(); const started = Date.now();
try { try {
const forced = await searchTool.handler({ query: productKeyword, limit: 5 }, ctx); const forced = await searchTool.handler({ query: currentText, limit: 5 }, ctx);
stats.toolMs += Date.now() - started;
stats.toolCalls.push({ name: 'search_products', ms: Date.now() - started, ok: true, forced: true }); stats.toolCalls.push({ name: 'search_products', ms: Date.now() - started, ok: true, forced: true });
stats.toolResults.push(forced); stats.toolResults.push(forced);
traceSteps.push({ step_type: 'tool', turn: step, tool_name: 'search_products', duration_ms: Date.now() - started }); traceSteps.push({ step_type: 'tool', turn: step, tool_name: 'search_products', duration_ms: Date.now() - started });
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'forced-search-products', type: 'function', function: { name: 'search_products', arguments: JSON.stringify({ query: productKeyword, limit: 5 }) } }] }); messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'forced-search-products', type: 'function', function: { name: 'search_products', arguments: JSON.stringify({ query: currentText, limit: 5 }) } }] });
messages.push({ role: 'tool', tool_call_id: 'forced-search-products', content: markToolResult('search_products', JSON.stringify(forced).slice(0, 6000)) }); messages.push({ role: 'tool', tool_call_id: 'forced-search-products', content: markToolResult('search_products', JSON.stringify(forced).slice(0, 6000)) });
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'forced_search_products', conversation: conv.uuid, ok: true })); console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'forced_search_products', conversation: conv.uuid, ok: true }));
continue; continue;
@@ -281,7 +274,6 @@ function createAgent(cfg, deps = {}) {
result = { error: err.code || err.message || 'tool_failed', message: err.message || String(err) }; result = { error: err.code || err.message || 'tool_failed', message: err.message || String(err) };
} }
prom.inc('bridge_ai_tool_calls_total', { tool: name, ok: !(result && result.error) }); prom.inc('bridge_ai_tool_calls_total', { tool: name, ok: !(result && result.error) });
stats.toolMs += Date.now() - started;
traceSteps.push({ step_type: 'tool', turn: step, tool_name: name, duration_ms: Date.now() - started }); traceSteps.push({ step_type: 'tool', turn: step, tool_name: name, duration_ms: Date.now() - started });
stats.toolCalls.push({ name, ms: Date.now() - started, ok: !(result && result.error) }); stats.toolCalls.push({ name, ms: Date.now() - started, ok: !(result && result.error) });
stats.toolResults.push(result); stats.toolResults.push(result);
@@ -520,16 +512,13 @@ function createAgent(cfg, deps = {}) {
audit.uncovered_entities = citation.uncovered_entities; audit.uncovered_entities = citation.uncovered_entities;
audit.lead_followup = leadFollowup; audit.lead_followup = leadFollowup;
audit.output_guard = { blocked: guard.blocked, rewritten: guard.rewritten, reasons: guard.reasons, removed_urls: guard.removed_urls, evidence_missing: guard.evidence_missing }; audit.output_guard = { blocked: guard.blocked, rewritten: guard.rewritten, reasons: guard.reasons, removed_urls: guard.removed_urls, evidence_missing: guard.evidence_missing };
// 分阶段耗时(对齐旧 bridge 的 llmMs/toolMs):写入 audit(jsonb,无 DDL)并打一行日志
audit.timing = { llm_ms: stats.llmMs, tool_ms: stats.toolMs };
const latencyMs = Date.now() - startedAt;
await metrics.record({ await metrics.record({
conversationUuid: conv.uuid, conversationUuid: conv.uuid,
inboxId: conv.inbox_id, inboxId: conv.inbox_id,
channel: conv.channel || 'livechat', channel: conv.channel || 'livechat',
verified: ctx.verified, verified: ctx.verified,
model: llm.model, model: llm.model,
latencyMs, latencyMs: Date.now() - startedAt,
llmCalls: stats.llmCalls, llmCalls: stats.llmCalls,
promptTokens: stats.promptTokens, promptTokens: stats.promptTokens,
completionTokens: stats.completionTokens, completionTokens: stats.completionTokens,
@@ -540,17 +529,6 @@ function createAgent(cfg, deps = {}) {
}); });
prom.inc('bridge_ai_runs_total'); prom.inc('bridge_ai_runs_total');
await traceWriter.writeSteps(traceSteps, traceId, conv.uuid).catch(() => {}); await traceWriter.writeSteps(traceSteps, traceId, conv.uuid).catch(() => {});
console.log(JSON.stringify({
ts: new Date().toISOString(),
agent: 'metrics',
conversation: conv.uuid,
intent,
latency_ms: latencyMs,
llm_ms: stats.llmMs,
tool_ms: stats.toolMs,
llm_calls: stats.llmCalls,
reply_length: finalContent.length,
}));
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'replied', conversation: conv.uuid, length: finalContent.length, audit })); console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'replied', conversation: conv.uuid, length: finalContent.length, audit }));
return finalContent; return finalContent;
} }
+1 -70
View File
@@ -39,73 +39,4 @@ function translateProductQuery(query, entries) {
}; };
} }
// ---- 商品关键词清洗(对齐旧 bridge:不把客户整句原文丢给商品 RPC)---- module.exports = { translateProductQuery, normalize };
// 商品 RPC(/product/list、/product_new/autocomplete)对含数量/货币的长句极慢
// (实测 "500 stainless steel rings wholesale" 18s vs "stainless steel rings" 0.6s),
// 且结果会跑偏,所以调用前只保留 2-4 个商品/类目词。
const STOPWORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'for', 'to', 'of', 'in', 'on', 'with', 'at', 'by',
'do', 'does', 'did', 'is', 'are', 'was', 'be', 'am', 'how', 'what', 'which', 'who',
'can', 'could', 'would', 'should', 'you', 'your', 'yours', 'my', 'me', 'i', 'we', 'us', 'it', 'its',
'hi', 'hello', 'hey', 'please', 'thanks', 'thank', 'there', 'this', 'that', 'these', 'those',
'have', 'has', 'had', 'interested', 'interest', 'any', 'some', 'also', 'about', 'from', 'get',
]);
const TRANSACTION_WORDS = new Set([
'buy', 'buying', 'purchase', 'purchasing', 'order', 'orders', 'ordering', 'wholesale', 'wholesaler',
'bulk', 'supplier', 'suppliers', 'price', 'prices', 'pricing', 'cost', 'costs', 'cheap', 'cheapest',
'deal', 'deals', 'discount', 'discounts', 'qty', 'quantity', 'quantities', 'moq', 'quote', 'quotation',
'sample', 'samples', 'bestseller', 'bestsellers', 'trending', 'popular', 'hot', 'sale', 'catalog',
'catalogue', 'browse', 'recommend', 'recommendation', 'recommendations', 'available', 'availability',
'stock', 'under', 'below', 'above', 'over', 'between', 'less', 'more', 'than', 'each', 'per',
'min', 'max', 'minimum', 'maximum', 'fast', 'delivery', 'shipping', 'ship', 'send', 'need', 'needed',
'want', 'wanted', 'wanting', 'looking', 'find', 'search', 'show', 'tell', 'give', 'see', 'list',
'good', 'best', 'top', 'new', 'sale', 'fashion', 'style', 'cheapest', 'right', 'now',
// 售后/流程/联系类词:出现在商品问题里也不是商品词,留着只会拖慢商品 RPC
'return', 'returns', 'returned', 'exchange', 'exchanges', 'refund', 'refunds', 'policy', 'warranty',
'question', 'questions', 'ask', 'asking', 'inquiry', 'inquiries', 'enquiry', 'within', 'before',
'after', 'day', 'days', 'week', 'weeks', 'month', 'months', 'year', 'years', 'accept', 'accepting',
'provide', 'providing', 'offer', 'offering', 'allow', 'allowed', 'possible', 'support', 'service',
'services', 'customer', 'customers', 'contact', 'email', 'phone', 'address',
]);
const UNITS = new Set([
'pc', 'pcs', 'piece', 'pieces', 'unit', 'units', 'item', 'items', 'set', 'sets', 'pair', 'pairs',
'kg', 'kgs', 'kilo', 'kilos', 'gram', 'grams', 'g', 'ml', 'liter', 'liters', 'litre', 'litres',
'cm', 'mm', 'inch', 'inches', 'ft', 'box', 'boxes', 'carton', 'cartons', 'lot', 'lots',
'thousand', 'dozen', 'pack', 'packs', 'pcs.', '件', '条', '个', '只', '套', '对', '双', '箱', '打', '批', '台', '张',
]);
const MONEY = new Set([
'dollar', 'dollars', 'usd', 'eur', 'euro', 'euros', 'cent', 'cents', 'yuan', 'rmb', 'cny',
'元', '美元', '欧元', '人民币',
]);
// 数量/规格 token:500、10.5、500pcs、10cm、18k
const QUANTITY_TOKEN_RE = /^\d+(?:[.,]\d+)?(?:k|pcs|pc|pieces?|units?|sets?|pairs?|kg|g|cm|mm|ml|l|inch(?:es)?|ft)?$/;
const MIN_KEEP = 2;
const MAX_KEEP = 4;
/**
* 从客户原话里提取用于商品 RPC 的短关键词。
* 中文先交给 translateProductQuery 映射类目;没有可用词时返回空串。
* @param {string} value 客户原文(或已翻译的类目名)
* @returns {string}
*/
function extractProductKeyword(value) {
const q = normalize(value);
if (!q) return '';
const kept = [];
for (const token of q.split(/\s+/)) {
if (!token) continue;
if (QUANTITY_TOKEN_RE.test(token)) continue;
if (UNITS.has(token) || MONEY.has(token) || TRANSACTION_WORDS.has(token) || STOPWORDS.has(token)) continue;
if (token.length < MIN_KEEP && !CJK.test(token)) continue;
kept.push(token);
if (kept.length >= MAX_KEEP) break;
}
// 纯数量/纯交易词(如 "500 pcs wholesale")剥完是空的 → 交给上层决定是否退回原文
return kept.join(' ');
}
module.exports = { translateProductQuery, extractProductKeyword, normalize };
+3 -7
View File
@@ -6,7 +6,7 @@ const { ToolError } = require('./errors');
const { createTwentyClient } = require('./twenty'); const { createTwentyClient } = require('./twenty');
const { asList, asObject } = require('./rpc'); const { asList, asObject } = require('./rpc');
const { createLeadStore } = require('./leads'); const { createLeadStore } = require('./leads');
const { translateProductQuery, extractProductKeyword } = require('./product-query'); const { translateProductQuery } = require('./product-query');
const { createSiteMap } = require('./sitemap'); const { createSiteMap } = require('./sitemap');
const { createOpportunityService } = require('./opportunity'); const { createOpportunityService } = require('./opportunity');
const { createMailHistory } = require('./mail-history'); const { createMailHistory } = require('./mail-history');
@@ -406,9 +406,7 @@ function createTools(cfg, deps = {}) {
const profile = resolveSiteProfile(cfg, ctx); const profile = resolveSiteProfile(cfg, ctx);
// 中文/别名商品词先映射到英文类目,例如 简约不锈钢戒指 -> Rings // 中文/别名商品词先映射到英文类目,例如 简约不锈钢戒指 -> Rings
const translation = translateProductQuery(query, siteEntries(profile)); const translation = translateProductQuery(query, siteEntries(profile));
// 商品 RPC 对含数量/货币的长句极慢(实测 18s vs 0.6s),只把清洗后的 2-4 个商品词发给 RPC; const rpcQuery = translation.query || query;
// 清洗为空(纯数量/纯交易词)时退回原 query,保持原有兜底行为。
const rpcQuery = extractProductKeyword(translation.query || query) || translation.query || query;
const absoluteUrl = (href) => { const absoluteUrl = (href) => {
if (!href) return null; if (!href) return null;
@@ -512,9 +510,7 @@ function createTools(cfg, deps = {}) {
...(total != null ? { total } : {}), ...(total != null ? { total } : {}),
source, source,
site: profile.site, site: profile.site,
// 实际发给商品 RPC 的关键词(清洗后),便于测试服核对;原话仍留在 search_url ...(translation.translated ? { query_used: rpcQuery, translated_from: query, category: translation.category } : {}),
query_used: rpcQuery,
...(translation.translated ? { translated_from: query, category: translation.category } : {}),
...(categoryLinks.length && source === 'catalog' ? { category_links: categoryLinks } : {}), ...(categoryLinks.length && source === 'catalog' ? { category_links: categoryLinks } : {}),
search_url: profile.searchUrl + encodeURIComponent(query), search_url: profile.searchUrl + encodeURIComponent(query),
...(source === 'site-map' ...(source === 'site-map'
-38
View File
@@ -357,41 +357,3 @@ test('agent: extracts lead fields when the LLM skips collect_info (company menti
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1); assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Leadfix GmbH')); assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Leadfix GmbH'));
}); });
test('agent: product pre-retrieval sends cleaned keyword instead of the full sentence', async () => {
const queries = [];
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async (args) => {
queries.push(args.query);
return { products: [{ name: 'Steel Ring', price: '€2,50', stock: 10, url: 'https://www.yehwang.com/p/steel-ring.html' }], count: 1 };
},
}];
tools.leadStore = { get: async () => null, save: async () => null };
let sent = '';
let recorded = null;
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => ({ content: 'Steel Ring — €2,50,库存 10 件。', toolCalls: [], usage: null }),
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We need 500 stainless steel rings wholesale.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async (m) => { recorded = m; }, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
});
const conv = { uuid: 'conv-clean-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-clean-1', text_content: 'We need 500 stainless steel rings wholesale.', content: '...' });
assert.deepEqual(queries, ['stainless steel rings']);
assert.match(sent, /Steel Ring/);
assert.equal(typeof recorded.audit.timing.llm_ms, 'number');
assert.equal(typeof recorded.audit.timing.tool_ms, 'number');
});
+1 -18
View File
@@ -2,7 +2,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { translateProductQuery, extractProductKeyword } = require('../src/product-query'); const { translateProductQuery } = require('../src/product-query');
test('translateProductQuery: Chinese category alias -> English title', () => { test('translateProductQuery: Chinese category alias -> English title', () => {
const entries = [ const entries = [
@@ -20,20 +20,3 @@ test('translateProductQuery: English query unchanged', () => {
assert.equal(out.translated, false); assert.equal(out.translated, false);
assert.equal(out.query, 'hoop earrings'); assert.equal(out.query, 'hoop earrings');
}); });
test('extractProductKeyword: drops quantities/currency/transaction words', () => {
assert.equal(extractProductKeyword('We need 500 stainless steel rings wholesale under 10 dollars'), 'stainless steel rings');
assert.equal(extractProductKeyword('500 rings'), 'rings');
assert.equal(extractProductKeyword('do you have gold hoop earrings?'), 'gold hoop earrings');
assert.equal(extractProductKeyword('do you accept returns on stainless steel rings'), 'stainless steel rings');
});
test('extractProductKeyword: caps at 4 tokens, keeps CJK', () => {
assert.equal(extractProductKeyword('neo stainless steel rings 500 pcs'), 'neo stainless steel rings');
assert.equal(extractProductKeyword('简约不锈钢戒指'), '简约不锈钢戒指');
});
test('extractProductKeyword: pure quantity/transaction returns empty', () => {
assert.equal(extractProductKeyword('500 pcs wholesale'), '');
assert.equal(extractProductKeyword(''), '');
});
-12
View File
@@ -91,18 +91,6 @@ test('search_products: maps RPC items and caps results', async () => {
assert.equal(rpc.calls[0].method, '/product/list'); assert.equal(rpc.calls[0].method, '/product/list');
}); });
test('search_products: sends cleaned short keyword to catalog RPC for long queries', async () => {
const rpc = fakeRpc(() => ({ data: { list: [{ spuName: 'Steel Ring', minPriceWithCurrency: '€2,50', spuId: 1 }] } }));
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
const tool = tools.find((t) => t.name === 'search_products');
const out = await tool.handler({ query: 'We need 500 stainless steel rings wholesale under 10 dollars', limit: 3 }, { verified: false });
const cmd = rpc.calls[0].params[0];
assert.equal(cmd.keyword, 'stainless steel rings');
assert.equal(out.query_used, 'stainless steel rings');
// 给客户看的兜底搜索链接仍用原话
assert.match(out.search_url, /500%20stainless%20steel%20rings/);
});
test('search_products: falls back to site map when catalog returns empty', async () => { test('search_products: falls back to site map when catalog returns empty', async () => {
const tools = createTools(baseCfg, { const tools = createTools(baseCfg, {
rpc: fakeRpc(() => []), rpc: fakeRpc(() => []),
+1 -1
View File
@@ -48,7 +48,7 @@ services:
# ---------------------------------------------------------------- LibreDesk # ---------------------------------------------------------------- LibreDesk
libredesk: libredesk:
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0-rc.10} image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0}
container_name: crmnew-libredesk container_name: crmnew-libredesk
restart: unless-stopped restart: unless-stopped
ports: ports:
+1 -1
View File
@@ -1,6 +1,6 @@
# Chatwoot → LibreDesk 对照表 # Chatwoot → LibreDesk 对照表
迁移旧 `bridge` 时按此表逐项替换。字段/事件均已对照 LibreDesk 源码与文档核对(v2.8.0 / v2.9.0-rc)。 迁移旧 `bridge` 时按此表逐项替换。字段/事件均已对照 LibreDesk 源码与文档核对(v2.8.0 / v2.9.0)。
## 1. HTTP API ## 1. HTTP API
+1 -1
View File
@@ -12,7 +12,7 @@
| 业务 RPC | Hyperf JSON-RPC over TCP | 商品推荐、订单、库存/ETA、会员 | — | | 业务 RPC | Hyperf JSON-RPC over TCP | 商品推荐、订单、库存/ETA、会员 | — |
| imap-id-proxy | Node 24 | 给 LibreDesk 的 IMAP 连接注入 ID 命令,兼容 163 | 仅本地 POC | | imap-id-proxy | Node 24 | 给 LibreDesk 的 IMAP 连接注入 ID 命令,兼容 163 | 仅本地 POC |
\* WhatsApp 需 LibreDesk v2.9 RC;当前镜像 `v2.9.0-rc.10`。 \* WhatsApp 自 LibreDesk v2.9.0 起支持;当前镜像 `v2.9.0`。
## 2. 数据流 ## 2. 数据流
+10 -2
View File
@@ -11,7 +11,7 @@
| 组件 | 容器 | 宿主端口 | 状态 | | 组件 | 容器 | 宿主端口 | 状态 |
|---|---|---|---| |---|---|---|---|
| LibreDesk v2.9.0-rc.10(渠道/工作台) | `crmnew-libredesk` | `9000` | ✅ healthy | | LibreDesk v2.9.0(渠道/工作台) | `crmnew-libredesk` | `9000` | ✅ healthy |
| Twenty CRM v2.30.1(server) | `crmnew-twenty-server` | `3100` | ✅ healthy | | Twenty CRM v2.30.1(server) | `crmnew-twenty-server` | `3100` | ✅ healthy |
| Twenty worker | `crmnew-twenty-worker` | — | ✅ running | | Twenty worker | `crmnew-twenty-worker` | — | ✅ running |
| bridge(业务工具 / RPC 集成) | `crmnew-bridge` | `4100` | ✅ healthy | | bridge(业务工具 / RPC 集成) | `crmnew-bridge` | `4100` | ✅ healthy |
@@ -42,7 +42,7 @@ grep '^TOOLS_SHARED_KEY=' .env # bridge 工具密钥
| 项 | 仓库默认 | 本环境 | 原因 | | 项 | 仓库默认 | 本环境 | 原因 |
|---|---|---|---| |---|---|---|---|
| `LIBREDESK_IMAGE` | `libredesk/libredesk:v2.9.0-rc.10` | `libredesk/libredesk:v2.9.0-rc.10-**arm64**` | 宿主是 ARM64,`v2.9.0-rc.10` 只有 amd64;官方另发了 `-arm64` 同版本 tag,实测原生 `linux/arm64` 运行 | | `LIBREDESK_IMAGE` | `libredesk/libredesk:v2.9.0` | `libredesk/libredesk:v2.9.0-**arm64**` | 宿主是 ARM64;`v2.9.0-arm64` 的 digest 与多架构 `v2.9.0` 的 arm64 子镜像一致,显式 arm64 tag 沿用原部署习惯 |
| `LIBREDESK_BASE_URL` | `http://localhost:9000` | `https://wangliang-crm.dev.gaia888.com` | 走宿主 nginx 的 HTTPS | | `LIBREDESK_BASE_URL` | `http://localhost:9000` | `https://wangliang-crm.dev.gaia888.com` | 走宿主 nginx 的 HTTPS |
| `TWENTY_SERVER_URL` | `http://192.168.31.61:3100` | `https://wangliang-twenty.dev.gaia888.com` | 前端 `REACT_APP_SERVER_BASE_URL` 用的就是它(已核对页面内联配置) | | `TWENTY_SERVER_URL` | `http://192.168.31.61:3100` | `https://wangliang-twenty.dev.gaia888.com` | 前端 `REACT_APP_SERVER_BASE_URL` 用的就是它(已核对页面内联配置) |
| `RECOMMEND_URL` | 测试服地址 | `jsonrpc://wangliang-dev:9503` | 用 **本工作区** 的 product-service | | `RECOMMEND_URL` | 测试服地址 | `jsonrpc://wangliang-dev:9503` | 用 **本工作区** 的 product-service |
@@ -389,3 +389,11 @@ RAG_PG_URL="postgres://crmnew:${PW}@127.0.0.1:5440/bridge" node scripts/kb-versi
- agent1/agent2 的**邮件通知已关闭**(assignment/new_reply/SLA 等 email 渠道全部 `false`),仅保留站内通知。 - agent1/agent2 的**邮件通知已关闭**(assignment/new_reply/SLA 等 email 渠道全部 `false`),仅保留站内通知。
- 原因:`agent1@yehwang.com` / `agent2@yehwang.com` 在 `yehwang.com` 邮件服务器上被 550 拒绝,导致“New conversation assigned to you”退信。 - 原因:`agent1@yehwang.com` / `agent2@yehwang.com` 在 `yehwang.com` 邮件服务器上被 550 拒绝,导致“New conversation assigned to you”退信。
- OTP 邮件不受影响,bridge OTP 已实测可发送并送达 163 收件箱。 - OTP 邮件不受影响,bridge OTP 已实测可发送并送达 163 收件箱。
## 11. 2026-10-26 LibreDesk 升级记录(rc.10 → v2.9.0)
- 目标:`libredesk/libredesk:v2.9.0`(2026-10-22 正式版);本环境用 `libredesk/libredesk:v2.9.0-arm64`(digest 与多架构 `v2.9.0` 的 arm64 子镜像一致)。
- 升级前:`pg_dump -Fc libredesk` → `backups/libredesk-before-v2.9.0-2026-10-26.dump`(已验证可 `pg_restore -l`);记录旧镜像 image ID `sha256:03df145b…`(rc.10-arm64)。
- 变更范围:rc.10 → v2.9.0 无 DB migration、无 config 变更(仅 UI / 邮件模板 / widget / campaign 修复),数据库 schema 保持 57 张表。
- 操作:改 `.env` 的 `LIBREDESK_IMAGE` → `docker compose up -d --force-recreate libredesk`(启动链自动 `--install --upgrade`,预期打印 `no upgrades to run`)。
- 回滚:切回 `libredesk/libredesk:v2.9.0-rc.10-arm64` 并重建;无 schema 变更,不需要恢复数据库。
+4 -4
View File
@@ -198,10 +198,10 @@ const payload = {
接完后旧桥的 `bridge/mail/`(IMAP 轮询 + 自动回复)可以退役;邮件摘要改为定时调 LibreDesk 报表 API。 接完后旧桥的 `bridge/mail/`(IMAP 轮询 + 自动回复)可以退役;邮件摘要改为定时调 LibreDesk 报表 API。
## 8. WhatsApp(当前 v2.9.0-rc.10 已可用) ## 8. WhatsApp(v2.9.0 已可用)
> **版本现状**:本套已运行 `v2.9.0-rc.10`,`channels` 枚举为 `email / livechat / whatsapp`,`whatsapp_templates` 表已就绪。 > **版本现状**:本套已运行 `v2.9.0` 正式版(2026-10-22 发布,2026-10-26 从 rc.10 升级),`channels` 枚举为 `email / livechat / whatsapp`,`whatsapp_templates` 表已就绪。
> RC 属于预发布版本,生产使用前评估稳定性;回滚见 README「回滚到稳定版」。 > 升级前已 `pg_dump` 备份;本次无 DB migration,回滚见 `docs/runbook.md`「回滚」。
1. Meta 侧准备:Business Account、WABA ID、Phone Number ID、Permanent Access Token、App Secret、Webhook Verify Token 1. Meta 侧准备:Business Account、WABA ID、Phone Number ID、Permanent Access Token、App Secret、Webhook Verify Token
2. `Admin → Inboxes → New inbox → WhatsApp`,填上述凭据 2. `Admin → Inboxes → New inbox → WhatsApp`,填上述凭据
@@ -209,7 +209,7 @@ const payload = {
4. 模板:`whatsapp_templates` 表 + 后台模板管理;24 小时窗口外的主动消息必须用已审核模板 4. 模板:`whatsapp_templates` 表 + 后台模板管理;24 小时窗口外的主动消息必须用已审核模板
5. **AI 对 WhatsApp 的支持文档未覆盖**(文档只写 email + live chat),需实测 assistant 是否在 WhatsApp 会话触发 5. **AI 对 WhatsApp 的支持文档未覆盖**(文档只写 email + live chat),需实测 assistant 是否在 WhatsApp 会话触发
切换镜像:`.env` 里改 `LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0-rc.9`(若 Docker Hub 无该 tag,则按官方文档从 `v2.9.0-rc.9` 源码构建)。 切换镜像:`.env` 里改 `LIBREDESK_IMAGE` 后 `docker compose up -d --force-recreate libredesk`;升级前先 `pg_dump` 备份,并记录当前镜像 digest 便于回滚。
## 9. 验收清单 ## 9. 验收清单
+4 -4
View File
@@ -91,11 +91,11 @@
| RAG 降级 | EU 语种召回下降 | 迁移后用 golden 问题抽检,必要时保留旧 RAG 服务 | | RAG 降级 | EU 语种召回下降 | 迁移后用 golden 问题抽检,必要时保留旧 RAG 服务 |
| 单工作区 | 多站点只能多 inbox,联系人共享 | 用 JWT 的 site 属性 + 隐私口径评审 | | 单工作区 | 多站点只能多 inbox,联系人共享 | 用 JWT 的 site 属性 + 隐私口径评审 |
## 7. 版本核实(2026-10-21) ## 7. 版本核实(2026-10-26 更新)
- LibreDesk 稳定版:**v2.8.0**(2026-08-22),渠道 `email/livechat` - LibreDesk 稳定版:**v2.9.0**(2026-10-22),渠道 `email/livechat/whatsapp`
- 预发布:**v2.9.0-rc.10**(2026-10-21),新增 WhatsApp(Meta Cloud API + 模板) - 升级前为预发布 **v2.9.0-rc.10**(2026-10-21);rc.10 → v2.9.0 无 DB migration、无 config 变更
- 本套环境已切到 rc.10;稳定版 v2.8.0 仍无 WhatsApp;官方文档暂无 WhatsApp 配置页(仅 release notes / 权限表提及) - 本套环境已于 2026-10-26 升到 v2.9.0(升级前 `pg_dump` 备份,回滚只需切回 rc.10 镜像)
- AI Assistant / AI tools / verified contact 为 v2.8 已有能力,标注 beta - AI Assistant / AI tools / verified contact 为 v2.8 已有能力,标注 beta
+14 -5
View File
@@ -160,20 +160,29 @@ docker run --rm -v crm-new_twenty-data:/data -v "$PWD/backups":/backup alpine \
# 1. 备份 # 1. 备份
docker exec crmnew-postgres pg_dump -U crmnew -d libredesk > backups/libredesk-before-upgrade.sql docker exec crmnew-postgres pg_dump -U crmnew -d libredesk > backups/libredesk-before-upgrade.sql
# 2. 修改 .env 的 LIBREDESK_IMAGE 后重建 # 2. 记录升级前镜像(回滚基线;tag 可能被上游重推,务必留 digest/image ID)
docker image inspect libredesk/libredesk:<旧 tag> --format '{{.Id}} {{.RepoDigests}}'
# 3. 修改 .env 的 LIBREDESK_IMAGE 后重建
docker compose up -d --force-recreate libredesk docker compose up -d --force-recreate libredesk
# 3. 容器启动命令会自动执行 --upgrade;确认日志 # 4. 容器启动命令会自动执行 --upgrade;确认日志
docker compose logs --tail=50 libredesk docker compose logs --tail=50 libredesk
``` ```
> 2026-10-26 升级记录(rc.10 → v2.9.0,无 DB migration / 无 config 变更):
> 本机 amd64 升级前 image ID `sha256:abdc41e7…` → 升级后 `sha256:e7aa3bac…`;
> 测试服 arm64 升级前 image ID `sha256:03df145b…` → 升级后 `sha256:147d0081…`。
## 回滚 ## 回滚
```bash ```bash
# 切回稳定版 # 本次 rc.10 → v2.9.0 无 schema 变更:切回 tag 即可,无需恢复数据库。
sed -i 's#^LIBREDESK_IMAGE=.*#LIBREDESK_IMAGE=libredesk/libredesk:latest#' .env # 若要精确回滚,先确认本地仍缓存升级前镜像(image ID 见上方升级记录),再切 tag:
sed -i 's#^LIBREDESK_IMAGE=.*#LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0-rc.10#' .env
docker compose up -d --force-recreate libredesk docker compose up -d --force-recreate libredesk
# 跨大版本回滚数据库需恢复备份(升级前 pg_dump)
# 跨大版本回滚(有 migration 时)才需要按升级前 pg_dump 恢复数据库
``` ```
## 端口冲突排查 ## 端口冲突排查