Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5314b2a5 |
+1
-1
@@ -3,7 +3,7 @@ POSTGRES_PASSWORD=<change-me>
|
||||
POSTGRES_DB=<change-me>
|
||||
POSTGRES_IMAGE=pgvector/pgvector:pg18
|
||||
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_ENC_KEY=<change-me>
|
||||
LIBREDESK_BASE_URL=<change-me>
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
**完全新建**的一套 CRM 客服栈:LibreDesk(平台 + 内置 AI)+ Twenty(CRM)+ bridge(业务工具/集成)。
|
||||
与旧项目 `/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 张表) |
|
||||
| bridge | ✅ 运行中 :4100,4 个工具,单测 18/18 通过 |
|
||||
| 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`(全新实例) |
|
||||
| bridge | http://localhost:4100 | Node 24,自研 |
|
||||
| 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
|
||||
- **WhatsApp 仅 v2.9 RC 支持**,需要 Meta Cloud API 凭据;RC 版供联调,不直接上生产
|
||||
- **WhatsApp 自 v2.9.0 起支持**,需要 Meta Cloud API 凭据;当前运行 v2.9.0 正式版
|
||||
- **LibreDesk AI 仍是 beta**,建议低流量验证
|
||||
- 旧栈(Chatwoot/bridge/Twenty :3000)保持运行,可并行对照,互不影响
|
||||
|
||||
+7
-29
@@ -24,7 +24,6 @@ const { createEscalation } = require('./escalation');
|
||||
const { createExperimentManager } = require('./experiments');
|
||||
const { createTraceWriter } = require('./trace-writer');
|
||||
const { extractLeadFields } = require('./lead-extract');
|
||||
const { extractProductKeyword } = require('./product-query');
|
||||
const crypto = require('crypto');
|
||||
const prom = require('./prom');
|
||||
|
||||
@@ -108,7 +107,7 @@ function createAgent(cfg, deps = {}) {
|
||||
async function runOnce(conv, webhookMessage) {
|
||||
if (!llm.enabled) throw new Error('LLM is not configured');
|
||||
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 traceId = `${conv.uuid}:${webhookMessage.uuid || ''}`;
|
||||
const profile = resolveSiteProfile(cfg, { inboxId: String(conv.inbox_id || '') });
|
||||
@@ -158,8 +157,6 @@ function createAgent(cfg, deps = {}) {
|
||||
|
||||
// 预检索:政策问题先查 KB,商品问题先查商品,避免模型跳过工具直接编。
|
||||
const intent = quickClassify(currentText);
|
||||
// 商品预检索/强制检索只发清洗后的短关键词(旧 bridge 思路):原文含数量/货币会让商品 RPC 慢 10 倍以上。
|
||||
const productKeyword = intent === INTENTS.PRODUCT_SEARCH ? extractProductKeyword(currentText) : '';
|
||||
const emotion = detectEmotion(currentText);
|
||||
const emotionStrategy = escalation.applyEmotionStrategy(emotion);
|
||||
const maxSteps = emotionStrategy.maxToolTurns || MAX_TOOL_STEPS;
|
||||
@@ -174,13 +171,11 @@ function createAgent(cfg, deps = {}) {
|
||||
let preResult = null;
|
||||
try {
|
||||
let preTool = null;
|
||||
// 商品意图清洗后没有可用商品词(如纯数量/纯问候)→ 跳过预检索,交给 LLM 决定
|
||||
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) {
|
||||
const started = Date.now();
|
||||
preResult = await preTool.handler({ query: preTool.name === 'search_products' ? productKeyword : currentText, limit: 5 }, ctx);
|
||||
stats.toolMs += Date.now() - started;
|
||||
preResult = await preTool.handler({ query: currentText, limit: 5 }, ctx);
|
||||
stats.toolCalls.push({ name: preTool.name, ms: Date.now() - started, ok: true, forced: true });
|
||||
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 }));
|
||||
@@ -234,7 +229,6 @@ function createAgent(cfg, deps = {}) {
|
||||
maxTokens: (variant.overriddenParams && variant.overriddenParams.maxTokens) ?? 1200,
|
||||
});
|
||||
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 });
|
||||
if (response.usage) {
|
||||
stats.promptTokens += Number(response.usage.prompt_tokens) || 0;
|
||||
@@ -243,17 +237,16 @@ function createAgent(cfg, deps = {}) {
|
||||
}
|
||||
// 兜底(旧 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');
|
||||
if (searchTool) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const forced = await searchTool.handler({ query: productKeyword, limit: 5 }, ctx);
|
||||
stats.toolMs += Date.now() - started;
|
||||
const forced = await searchTool.handler({ query: currentText, limit: 5 }, ctx);
|
||||
stats.toolCalls.push({ name: 'search_products', ms: Date.now() - started, ok: true, forced: true });
|
||||
stats.toolResults.push(forced);
|
||||
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)) });
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'forced_search_products', conversation: conv.uuid, ok: true }));
|
||||
continue;
|
||||
@@ -281,7 +274,6 @@ function createAgent(cfg, deps = {}) {
|
||||
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) });
|
||||
stats.toolMs += 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.toolResults.push(result);
|
||||
@@ -520,16 +512,13 @@ function createAgent(cfg, deps = {}) {
|
||||
audit.uncovered_entities = citation.uncovered_entities;
|
||||
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 };
|
||||
// 分阶段耗时(对齐旧 bridge 的 llmMs/toolMs):写入 audit(jsonb,无 DDL)并打一行日志
|
||||
audit.timing = { llm_ms: stats.llmMs, tool_ms: stats.toolMs };
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
await metrics.record({
|
||||
conversationUuid: conv.uuid,
|
||||
inboxId: conv.inbox_id,
|
||||
channel: conv.channel || 'livechat',
|
||||
verified: ctx.verified,
|
||||
model: llm.model,
|
||||
latencyMs,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
llmCalls: stats.llmCalls,
|
||||
promptTokens: stats.promptTokens,
|
||||
completionTokens: stats.completionTokens,
|
||||
@@ -540,17 +529,6 @@ function createAgent(cfg, deps = {}) {
|
||||
});
|
||||
prom.inc('bridge_ai_runs_total');
|
||||
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 }));
|
||||
return finalContent;
|
||||
}
|
||||
|
||||
@@ -39,73 +39,4 @@ function translateProductQuery(query, entries) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 商品关键词清洗(对齐旧 bridge:不把客户整句原文丢给商品 RPC)----
|
||||
// 商品 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 };
|
||||
module.exports = { translateProductQuery, normalize };
|
||||
|
||||
+3
-7
@@ -6,7 +6,7 @@ const { ToolError } = require('./errors');
|
||||
const { createTwentyClient } = require('./twenty');
|
||||
const { asList, asObject } = require('./rpc');
|
||||
const { createLeadStore } = require('./leads');
|
||||
const { translateProductQuery, extractProductKeyword } = require('./product-query');
|
||||
const { translateProductQuery } = require('./product-query');
|
||||
const { createSiteMap } = require('./sitemap');
|
||||
const { createOpportunityService } = require('./opportunity');
|
||||
const { createMailHistory } = require('./mail-history');
|
||||
@@ -406,9 +406,7 @@ function createTools(cfg, deps = {}) {
|
||||
const profile = resolveSiteProfile(cfg, ctx);
|
||||
// 中文/别名商品词先映射到英文类目,例如 简约不锈钢戒指 -> Rings
|
||||
const translation = translateProductQuery(query, siteEntries(profile));
|
||||
// 商品 RPC 对含数量/货币的长句极慢(实测 18s vs 0.6s),只把清洗后的 2-4 个商品词发给 RPC;
|
||||
// 清洗为空(纯数量/纯交易词)时退回原 query,保持原有兜底行为。
|
||||
const rpcQuery = extractProductKeyword(translation.query || query) || translation.query || query;
|
||||
const rpcQuery = translation.query || query;
|
||||
|
||||
const absoluteUrl = (href) => {
|
||||
if (!href) return null;
|
||||
@@ -512,9 +510,7 @@ function createTools(cfg, deps = {}) {
|
||||
...(total != null ? { total } : {}),
|
||||
source,
|
||||
site: profile.site,
|
||||
// 实际发给商品 RPC 的关键词(清洗后),便于测试服核对;原话仍留在 search_url
|
||||
query_used: rpcQuery,
|
||||
...(translation.translated ? { translated_from: query, category: translation.category } : {}),
|
||||
...(translation.translated ? { query_used: rpcQuery, translated_from: query, category: translation.category } : {}),
|
||||
...(categoryLinks.length && source === 'catalog' ? { category_links: categoryLinks } : {}),
|
||||
search_url: profile.searchUrl + encodeURIComponent(query),
|
||||
...(source === 'site-map'
|
||||
|
||||
@@ -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.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');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const test = require('node:test');
|
||||
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', () => {
|
||||
const entries = [
|
||||
@@ -20,20 +20,3 @@ test('translateProductQuery: English query unchanged', () => {
|
||||
assert.equal(out.translated, false);
|
||||
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(''), '');
|
||||
});
|
||||
|
||||
@@ -91,18 +91,6 @@ test('search_products: maps RPC items and caps results', async () => {
|
||||
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 () => {
|
||||
const tools = createTools(baseCfg, {
|
||||
rpc: fakeRpc(() => []),
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ services:
|
||||
|
||||
# ---------------------------------------------------------------- LibreDesk
|
||||
libredesk:
|
||||
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0-rc.10}
|
||||
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0}
|
||||
container_name: crmnew-libredesk
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Chatwoot → LibreDesk 对照表
|
||||
|
||||
迁移旧 `bridge` 时按此表逐项替换。字段/事件均已对照 LibreDesk 源码与文档核对(v2.8.0 / v2.9.0-rc)。
|
||||
迁移旧 `bridge` 时按此表逐项替换。字段/事件均已对照 LibreDesk 源码与文档核对(v2.8.0 / v2.9.0)。
|
||||
|
||||
## 1. HTTP API
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 业务 RPC | Hyperf JSON-RPC over TCP | 商品推荐、订单、库存/ETA、会员 | — |
|
||||
| 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. 数据流
|
||||
|
||||
|
||||
@@ -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 worker | `crmnew-twenty-worker` | — | ✅ running |
|
||||
| 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 |
|
||||
| `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 |
|
||||
@@ -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@yehwang.com` / `agent2@yehwang.com` 在 `yehwang.com` 邮件服务器上被 550 拒绝,导致“New conversation assigned to you”退信。
|
||||
- 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 变更,不需要恢复数据库。
|
||||
|
||||
@@ -198,10 +198,10 @@ const payload = {
|
||||
|
||||
接完后旧桥的 `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` 表已就绪。
|
||||
> RC 属于预发布版本,生产使用前评估稳定性;回滚见 README「回滚到稳定版」。
|
||||
> **版本现状**:本套已运行 `v2.9.0` 正式版(2026-10-22 发布,2026-10-26 从 rc.10 升级),`channels` 枚举为 `email / livechat / whatsapp`,`whatsapp_templates` 表已就绪。
|
||||
> 升级前已 `pg_dump` 备份;本次无 DB migration,回滚见 `docs/runbook.md`「回滚」。
|
||||
|
||||
1. Meta 侧准备:Business Account、WABA ID、Phone Number ID、Permanent Access Token、App Secret、Webhook Verify Token
|
||||
2. `Admin → Inboxes → New inbox → WhatsApp`,填上述凭据
|
||||
@@ -209,7 +209,7 @@ const payload = {
|
||||
4. 模板:`whatsapp_templates` 表 + 后台模板管理;24 小时窗口外的主动消息必须用已审核模板
|
||||
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. 验收清单
|
||||
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
| RAG 降级 | EU 语种召回下降 | 迁移后用 golden 问题抽检,必要时保留旧 RAG 服务 |
|
||||
| 单工作区 | 多站点只能多 inbox,联系人共享 | 用 JWT 的 site 属性 + 隐私口径评审 |
|
||||
|
||||
## 7. 版本核实(2026-10-21)
|
||||
## 7. 版本核实(2026-10-26 更新)
|
||||
|
||||
- LibreDesk 稳定版:**v2.8.0**(2026-08-22),渠道 `email/livechat`
|
||||
- 预发布:**v2.9.0-rc.10**(2026-10-21),新增 WhatsApp(Meta Cloud API + 模板)
|
||||
- 本套环境已切到 rc.10;稳定版 v2.8.0 仍无 WhatsApp;官方文档暂无 WhatsApp 配置页(仅 release notes / 权限表提及)
|
||||
- LibreDesk 稳定版:**v2.9.0**(2026-10-22),渠道 `email/livechat/whatsapp`
|
||||
- 升级前为预发布 **v2.9.0-rc.10**(2026-10-21);rc.10 → v2.9.0 无 DB migration、无 config 变更
|
||||
- 本套环境已于 2026-10-26 升到 v2.9.0(升级前 `pg_dump` 备份,回滚只需切回 rc.10 镜像)
|
||||
- AI Assistant / AI tools / verified contact 为 v2.8 已有能力,标注 beta
|
||||
|
||||
|
||||
|
||||
+14
-5
@@ -160,20 +160,29 @@ docker run --rm -v crm-new_twenty-data:/data -v "$PWD/backups":/backup alpine \
|
||||
# 1. 备份
|
||||
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
|
||||
|
||||
# 3. 容器启动命令会自动执行 --upgrade;确认日志
|
||||
# 4. 容器启动命令会自动执行 --upgrade;确认日志
|
||||
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
|
||||
# 切回稳定版
|
||||
sed -i 's#^LIBREDESK_IMAGE=.*#LIBREDESK_IMAGE=libredesk/libredesk:latest#' .env
|
||||
# 本次 rc.10 → v2.9.0 无 schema 变更:切回 tag 即可,无需恢复数据库。
|
||||
# 若要精确回滚,先确认本地仍缓存升级前镜像(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
|
||||
# 跨大版本回滚数据库需恢复备份(升级前 pg_dump)
|
||||
|
||||
# 跨大版本回滚(有 migration 时)才需要按升级前 pg_dump 恢复数据库
|
||||
```
|
||||
|
||||
## 端口冲突排查
|
||||
|
||||
Reference in New Issue
Block a user