Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0959777048 |
+1
-1
@@ -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
|
LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0-rc.10
|
||||||
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>
|
||||||
|
|||||||
@@ -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-26)
|
## 当前状态(2026-10-21)
|
||||||
|
|
||||||
| 组件 | 状态 |
|
| 组件 | 状态 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| LibreDesk v2.9.0 | ✅ 运行中 :9000(System 密码见 `.env`) |
|
| LibreDesk v2.9.0-rc.10 | ✅ 运行中 :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`(含 WhatsApp) |
|
| LibreDesk | http://localhost:9000 | `libredesk:v2.9.0-rc.10`(含 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.0 起支持**,需要 Meta Cloud API 凭据;当前运行 v2.9.0 正式版
|
- **WhatsApp 仅 v2.9 RC 支持**,需要 Meta Cloud API 凭据;RC 版供联调,不直接上生产
|
||||||
- **LibreDesk AI 仍是 beta**,建议低流量验证
|
- **LibreDesk AI 仍是 beta**,建议低流量验证
|
||||||
- 旧栈(Chatwoot/bridge/Twenty :3000)保持运行,可并行对照,互不影响
|
- 旧栈(Chatwoot/bridge/Twenty :3000)保持运行,可并行对照,互不影响
|
||||||
|
|||||||
+29
-7
@@ -24,6 +24,7 @@ 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');
|
||||||
|
|
||||||
@@ -107,7 +108,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: [] };
|
const stats = { llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, toolCalls: [], toolResults: [], llmMs: 0, toolMs: 0 };
|
||||||
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 || '') });
|
||||||
@@ -157,6 +158,8 @@ 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;
|
||||||
@@ -171,11 +174,13 @@ 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) preTool = byName.get('search_products');
|
else if (intent === INTENTS.PRODUCT_SEARCH && productKeyword) preTool = byName.get('search_products');
|
||||||
if (preTool) {
|
if (preTool) {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
preResult = await preTool.handler({ query: currentText, limit: 5 }, ctx);
|
preResult = await preTool.handler({ query: preTool.name === 'search_products' ? productKeyword : 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 }));
|
||||||
@@ -229,6 +234,7 @@ 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;
|
||||||
@@ -237,16 +243,17 @@ function createAgent(cfg, deps = {}) {
|
|||||||
}
|
}
|
||||||
// 兜底(旧 bridge 同款):product_search 意图下首轮 LLM 一个工具都没调时,
|
// 兜底(旧 bridge 同款):product_search 意图下首轮 LLM 一个工具都没调时,
|
||||||
// 丢弃它凭记忆编的回答,强制查一次真实商品结果再让它重答,避免幻觉商品/链接进入回复。
|
// 丢弃它凭记忆编的回答,强制查一次真实商品结果再让它重答,避免幻觉商品/链接进入回复。
|
||||||
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && !stats.toolCalls.length) {
|
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && productKeyword && !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: currentText, limit: 5 }, ctx);
|
const forced = await searchTool.handler({ query: productKeyword, 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: currentText, limit: 5 }) } }] });
|
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: '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;
|
||||||
@@ -274,6 +281,7 @@ 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);
|
||||||
@@ -512,13 +520,16 @@ 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: Date.now() - startedAt,
|
latencyMs,
|
||||||
llmCalls: stats.llmCalls,
|
llmCalls: stats.llmCalls,
|
||||||
promptTokens: stats.promptTokens,
|
promptTokens: stats.promptTokens,
|
||||||
completionTokens: stats.completionTokens,
|
completionTokens: stats.completionTokens,
|
||||||
@@ -529,6 +540,17 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,73 @@ function translateProductQuery(query, entries) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { translateProductQuery, normalize };
|
// ---- 商品关键词清洗(对齐旧 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 };
|
||||||
|
|||||||
+7
-3
@@ -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 } = require('./product-query');
|
const { translateProductQuery, extractProductKeyword } = 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,7 +406,9 @@ 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));
|
||||||
const rpcQuery = translation.query || query;
|
// 商品 RPC 对含数量/货币的长句极慢(实测 18s vs 0.6s),只把清洗后的 2-4 个商品词发给 RPC;
|
||||||
|
// 清洗为空(纯数量/纯交易词)时退回原 query,保持原有兜底行为。
|
||||||
|
const rpcQuery = extractProductKeyword(translation.query || query) || translation.query || query;
|
||||||
|
|
||||||
const absoluteUrl = (href) => {
|
const absoluteUrl = (href) => {
|
||||||
if (!href) return null;
|
if (!href) return null;
|
||||||
@@ -510,7 +512,9 @@ function createTools(cfg, deps = {}) {
|
|||||||
...(total != null ? { total } : {}),
|
...(total != null ? { total } : {}),
|
||||||
source,
|
source,
|
||||||
site: profile.site,
|
site: profile.site,
|
||||||
...(translation.translated ? { query_used: rpcQuery, translated_from: query, category: translation.category } : {}),
|
// 实际发给商品 RPC 的关键词(清洗后),便于测试服核对;原话仍留在 search_url
|
||||||
|
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'
|
||||||
|
|||||||
@@ -357,3 +357,41 @@ 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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 } = require('../src/product-query');
|
const { translateProductQuery, extractProductKeyword } = require('../src/product-query');
|
||||||
|
|
||||||
test('translateProductQuery: Chinese category alias -> English title', () => {
|
test('translateProductQuery: Chinese category alias -> English title', () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
@@ -20,3 +20,20 @@ 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(''), '');
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,6 +91,18 @@ 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
@@ -48,7 +48,7 @@ services:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- LibreDesk
|
# ---------------------------------------------------------------- LibreDesk
|
||||||
libredesk:
|
libredesk:
|
||||||
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0}
|
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0-rc.10}
|
||||||
container_name: crmnew-libredesk
|
container_name: crmnew-libredesk
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Chatwoot → LibreDesk 对照表
|
# Chatwoot → LibreDesk 对照表
|
||||||
|
|
||||||
迁移旧 `bridge` 时按此表逐项替换。字段/事件均已对照 LibreDesk 源码与文档核对(v2.8.0 / v2.9.0)。
|
迁移旧 `bridge` 时按此表逐项替换。字段/事件均已对照 LibreDesk 源码与文档核对(v2.8.0 / v2.9.0-rc)。
|
||||||
|
|
||||||
## 1. HTTP API
|
## 1. HTTP API
|
||||||
|
|
||||||
|
|||||||
@@ -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.0 起支持;当前镜像 `v2.9.0`。
|
\* WhatsApp 需 LibreDesk v2.9 RC;当前镜像 `v2.9.0-rc.10`。
|
||||||
|
|
||||||
## 2. 数据流
|
## 2. 数据流
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
| 组件 | 容器 | 宿主端口 | 状态 |
|
| 组件 | 容器 | 宿主端口 | 状态 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| LibreDesk v2.9.0(渠道/工作台) | `crmnew-libredesk` | `9000` | ✅ healthy |
|
| LibreDesk v2.9.0-rc.10(渠道/工作台) | `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` | `libredesk/libredesk:v2.9.0-**arm64**` | 宿主是 ARM64;`v2.9.0-arm64` 的 digest 与多架构 `v2.9.0` 的 arm64 子镜像一致,显式 arm64 tag 沿用原部署习惯 |
|
| `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_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,11 +389,3 @@ 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 变更,不需要恢复数据库。
|
|
||||||
|
|||||||
@@ -198,10 +198,10 @@ const payload = {
|
|||||||
|
|
||||||
接完后旧桥的 `bridge/mail/`(IMAP 轮询 + 自动回复)可以退役;邮件摘要改为定时调 LibreDesk 报表 API。
|
接完后旧桥的 `bridge/mail/`(IMAP 轮询 + 自动回复)可以退役;邮件摘要改为定时调 LibreDesk 报表 API。
|
||||||
|
|
||||||
## 8. WhatsApp(v2.9.0 已可用)
|
## 8. WhatsApp(当前 v2.9.0-rc.10 已可用)
|
||||||
|
|
||||||
> **版本现状**:本套已运行 `v2.9.0` 正式版(2026-10-22 发布,2026-10-26 从 rc.10 升级),`channels` 枚举为 `email / livechat / whatsapp`,`whatsapp_templates` 表已就绪。
|
> **版本现状**:本套已运行 `v2.9.0-rc.10`,`channels` 枚举为 `email / livechat / whatsapp`,`whatsapp_templates` 表已就绪。
|
||||||
> 升级前已 `pg_dump` 备份;本次无 DB migration,回滚见 `docs/runbook.md`「回滚」。
|
> RC 属于预发布版本,生产使用前评估稳定性;回滚见 README「回滚到稳定版」。
|
||||||
|
|
||||||
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` 后 `docker compose up -d --force-recreate libredesk`;升级前先 `pg_dump` 备份,并记录当前镜像 digest 便于回滚。
|
切换镜像:`.env` 里改 `LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0-rc.9`(若 Docker Hub 无该 tag,则按官方文档从 `v2.9.0-rc.9` 源码构建)。
|
||||||
|
|
||||||
## 9. 验收清单
|
## 9. 验收清单
|
||||||
|
|
||||||
|
|||||||
@@ -91,11 +91,11 @@
|
|||||||
| RAG 降级 | EU 语种召回下降 | 迁移后用 golden 问题抽检,必要时保留旧 RAG 服务 |
|
| RAG 降级 | EU 语种召回下降 | 迁移后用 golden 问题抽检,必要时保留旧 RAG 服务 |
|
||||||
| 单工作区 | 多站点只能多 inbox,联系人共享 | 用 JWT 的 site 属性 + 隐私口径评审 |
|
| 单工作区 | 多站点只能多 inbox,联系人共享 | 用 JWT 的 site 属性 + 隐私口径评审 |
|
||||||
|
|
||||||
## 7. 版本核实(2026-10-26 更新)
|
## 7. 版本核实(2026-10-21)
|
||||||
|
|
||||||
- LibreDesk 稳定版:**v2.9.0**(2026-10-22),渠道 `email/livechat/whatsapp`
|
- LibreDesk 稳定版:**v2.8.0**(2026-08-22),渠道 `email/livechat`
|
||||||
- 升级前为预发布 **v2.9.0-rc.10**(2026-10-21);rc.10 → v2.9.0 无 DB migration、无 config 变更
|
- 预发布:**v2.9.0-rc.10**(2026-10-21),新增 WhatsApp(Meta Cloud API + 模板)
|
||||||
- 本套环境已于 2026-10-26 升到 v2.9.0(升级前 `pg_dump` 备份,回滚只需切回 rc.10 镜像)
|
- 本套环境已切到 rc.10;稳定版 v2.8.0 仍无 WhatsApp;官方文档暂无 WhatsApp 配置页(仅 release notes / 权限表提及)
|
||||||
- AI Assistant / AI tools / verified contact 为 v2.8 已有能力,标注 beta
|
- AI Assistant / AI tools / verified contact 为 v2.8 已有能力,标注 beta
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+5
-14
@@ -160,29 +160,20 @@ 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. 记录升级前镜像(回滚基线;tag 可能被上游重推,务必留 digest/image ID)
|
# 2. 修改 .env 的 LIBREDESK_IMAGE 后重建
|
||||||
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
|
||||||
|
|
||||||
# 4. 容器启动命令会自动执行 --upgrade;确认日志
|
# 3. 容器启动命令会自动执行 --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 即可,无需恢复数据库。
|
# 切回稳定版
|
||||||
# 若要精确回滚,先确认本地仍缓存升级前镜像(image ID 见上方升级记录),再切 tag:
|
sed -i 's#^LIBREDESK_IMAGE=.*#LIBREDESK_IMAGE=libredesk/libredesk:latest#' .env
|
||||||
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 恢复数据库
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 端口冲突排查
|
## 端口冲突排查
|
||||||
|
|||||||
Reference in New Issue
Block a user