初始化 LibreDesk 本地实例部署仓库

- docker compose 部署:app / db / redis / tls(nginx) / bridge-tools,宿主端口错开避免与本机服务冲突
- bridge-tools 内联进仓库(compose build 路径改为 ./bridge-tools),仓库自包含可部署
- 部署说明 README、crm-new 对照与 bridge-tools 方案文档、备份与隧道脚本
- .gitignore 排除 .env、config.toml、uploads/、tls 私钥/证书、backups/、logs/
This commit is contained in:
wangliang
2026-10-29 13:24:13 +08:00
commit 39986f10f2
26 changed files with 6345 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.env
config.toml
uploads/
tls/privkey.pem
tls/cert.pem
# 运行数据与备份(含明文密钥副本、数据库 dump,不入库)
backups/
logs/
*.dump
*.tar.gz
+184
View File
@@ -0,0 +1,184 @@
# LibreDesk 本地实例(yehwang-ai)
按官方文档 [docs.libredesk.io/getting-started/installation](https://docs.libredesk.io/getting-started/installation) 的 Docker 方式部署的独立实例,
与本机 `crm-new` 项目里那套 LibreDesk 完全隔离(独立容器、独立数据卷、独立端口)。
## 访问
| 项 | 值 |
| --- | --- |
| 后台地址 | **https://192.168.31.61:9443**(自签证书,首次需点“继续前往”;见下方「为什么会一直 Connecting」) |
| API / 直连 | http://localhost:9001(后台界面走这个地址会一直 Connecting,仅用于 API/调试) |
| 登录账号 | `System` |
| 登录密码 | `Ld@42d0ef50ac5d0efa`(也存在 `.env` 的 `LIBREDESK_SYSTEM_USER_PASSWORD`,登录后建议改掉) |
## 组成
| 服务 | 镜像 | 容器名 | 宿主端口 |
| --- | --- | --- | --- |
| app | libredesk/libredesk:**v2.9.1**(当前稳定版) | libredesk_app | **127.0.0.1**:9001 → 9000(仅本机/隧道) |
| db | pgvector/pgvector:pg18(本机已缓存;官方文档默认 postgres:17-alpine,本机拉取该镜像一直失败,见下方「已知偏差」) | libredesk_db | 127.0.0.1:5433 → 5432 |
| redis | redis:7-alpine | libredesk_redis | 127.0.0.1:6380 → 6379 |
| tls | nginx:alpine | libredesk_tls | 9443 → 443 |
| bridge-tools | 本仓库 `./bridge-tools`(Node 24,零依赖) | libredesk_bridge_tools | 127.0.0.1:4101 → 4100(LibreDesk 内部走 `bridge-tools:4100`) |
数据:`postgres-data`、`redis-data` 两个 Docker 卷;上传文件在 `./uploads`。
配置:`config.toml`(含加密密钥与 DB 密码,不入库)、`.env`(含容器密码,不入库)。
## 为什么会一直 “Connecting...”
现象:用 `http://192.168.31.61:9001` 打开后台,右上角一直显示 `Connecting...`,app 日志反复报:
```
error upgrading tcp connection ... error="websocket: request origin not allowed by FastHTTPUpgrader.CheckOrigin"
```
原因:libredesk v2.9.x 的 Agent 实时通道(`/ws`)在 `cmd/websocket.go` 里做了 Origin 校验,
**只接受 `https` 或 `localhost` 的 Origin**,`http://<局域网IP>:9001` 一定被 403 拒绝,前端就无限重连。
解决:compose 里加了 `tls`(nginx)容器做 HTTPS 终结,后台走 **https://192.168.31.61:9443**。
证书是自签的(SAN 含 `192.168.31.61`、`127.0.0.1`、`localhost`),首次访问浏览器会告警,点“高级 → 继续前往”即可;
想彻底不告警,把 [tls/cert.pem](tls/cert.pem) 导入你电脑的“受信任的根证书颁发机构”。
另一个等效办法(不动证书):`ssh -L 9001:127.0.0.1:9001 dev@192.168.31.61`,然后开 `http://localhost:9001`
(Origin 是 localhost,校验放行)。
## 常用操作
```bash
cd /home/dev/code/yehwang-ai/libredesk
docker compose ps # 状态
docker compose logs -f app # 日志
docker compose restart app # 重启
docker compose down # 停止(保留数据)
docker compose down -v # 停止并删除数据卷(会丢数据)
# 重设 System 用户密码(交互式)
docker exec -it libredesk_app ./libredesk --set-system-user-password --config /libredesk/config.toml
# 升级/切换版本:改 .env 的 LIBREDESK_IMAGE 后 recreate,启动命令会自动跑 --upgrade
docker compose pull && docker compose up -d
```
## 版本与升级
当前版本:**v2.9.1**(2026-10-28 发布的稳定版,替代 v2.9.0)。
版本演进:`v2.9.0`(初装)→ `v2.9.1-rc.1`(2026-10-28 15:06)→ `v2.9.1` 正式版(2026-10-28 15:42)。
每一次升级都只改 `.env` 的 `LIBREDESK_IMAGE` + `docker compose up -d app`,启动命令自动执行 `--install --idempotent-install` 与 `--upgrade`。
| 升级 | 迁移 | 说明 |
| --- | --- | --- |
| v2.9.0 → v2.9.1-rc.1 | 无(`no upgrades to run`) | 48 commits / 14 PR,config.sample.toml 逐字节一致 |
| v2.9.1-rc.1 → v2.9.1 | 无(`no upgrades to run`) | 仅 2 commits / 1 个前端文件(automation 帮助文案对齐),无 schema 变化 |
升级前的备份(都在 [backups/](backups)):
| 时间 | DB 备份 | 当时版本 |
| --- | --- | --- |
| 2026-10-28 15:02 | [libredesk-v2.9.0-20261028-1502.dump](backups/libredesk-v2.9.0-20261028-1502.dump)(633 TOC 条目) | v2.9.0 |
| 2026-10-28 15:40 | [libredesk-v2.9.1-rc.1-20261028-1540.dump](backups/libredesk-v2.9.1-rc.1-20261028-1540.dump) | v2.9.1-rc.1 |
**回滚**(到目前为止从没跑过迁移,回滚只是切镜像,不动数据):把 `.env` 的 `LIBREDESK_IMAGE` 改成目标版本(如 `libredesk/libredesk:v2.9.0`)再 `docker compose up -d app`。
只有当某次升级真的执行了迁移时才需要恢复 dump:
```bash
docker compose stop app
docker compose exec -T db psql -U libredesk -d libredesk -c 'drop schema public cascade; create schema public;'
docker compose exec -T db pg_restore -U libredesk -d libredesk --no-owner < backups/<对应版本>.dump
docker compose up -d app
```
v2.9.1 的主要变化:邮件主题/发件人 MIME 解码、IMAP 启动即扫描、`/health` 数据库不可用时返回非 200(已用于 compose 的 app healthcheck)、10 月安全审计的 5 个修复([PR #733](https://github.com/abhinavxd/libredesk/pull/733))、依赖漏洞升级([PR #742](https://github.com/abhinavxd/libredesk/pull/742))、AI 回复期间新消息继续接答、自动分配跳过 away 的 agent、日期筛选月份跳转、自定义属性可点击、automation 模板变量帮助等。详见 [release notes](https://github.com/abhinavxd/libredesk/releases/tag/v2.9.1)。
## 当前业务配置(2026-10-28)
| 项 | 状态 |
| --- | --- |
| AI completion | `qwen-max` @ DashScope(openai 兼容),temperature 0.3、max_tokens 800 |
| AI embedding | `text-embedding-v4`,1024 维;108 篇帮助文章已嵌入(启动日志 `loaded embeddings into memory count=108`) |
| 通知邮件 / OTP | 163 SMTP(smtp.163.com:465, TLS),发件人 `Yehwang Support <xx2227234215@163.com>` |
| 团队 | 「Wholesale Support」,Europe/Berlin,Round robin |
| 帮助中心 | `yehwang-help`(已启用,9 个分类 / 108 篇文章,已挂 livechat inbox 1) |
| AI Assistant | 「Yehwang Assistant」,friendly/balanced,max_turns 8,handoff + 引用开启,fallback team = Wholesale Support |
| AI Tools | `search_products` / `search_orders` / `search_inventory` / `get_customer_info`(后两个 requires_verification) |
| 渠道 inbox | **Website(livechat)** 已建:id=1,uuid `81b679b0-33fb-4796-9f83-dcc0f43231cc`;email / WhatsApp 待做 |
| 自动化 | 规则「Assign new conversations to AI Assistant」:新会话 → 分配给 Yehwang Assistant |
| 站点名 | `Yehwang Support` |
> 换 AI 模型时注意:`qwen-plus-latest` 在这套长 prompt 下不调用工具(会凭自身知识乱答并编造引用),已验证 `qwen-max` 正常。
## Live Chat 接入(widget)
```html
<script>
window.LibredeskSettings = {
baseURL: 'https://192.168.31.61:9443',
inboxID: '81b679b0-33fb-4796-9f83-dcc0f43231cc'
};
</script>
<script async src="https://192.168.31.61:9443/widget.js"></script>
```
匿名访客即可发起会话;已登录客户可用站点后端签 JWT(`POST /api/v1/widget/chat/auth/exchange`)做身份锚定。
## 运维:备份 / 访问控制 / 安全头
**备份(已定时)**
- 脚本:[scripts/backup.sh](scripts/backup.sh),产物在 [backups/](backups):`db-<ts>.dump`(pg_dump -Fc)、`uploads-<ts>.tar.gz`、`config.toml.<ts>`、`env.<ts>`;
- 校验:每个 dump 会用 `pg_restore -l` 读出 TOC(当前 629 条),失败则脚本非 0 退出;
- 保留 **14 天**(`find -mtime +14 -delete`);
- cron(dev 用户,已安装):`0 3 * * * cd .../libredesk && ./scripts/backup.sh >> logs/backup.log 2>&1`;
- 恢复演练(已验证过一次:57 张表 / 108 篇文章):
```bash
docker cp backups/db-<ts>.dump libredesk_db:/tmp/restore.dump
docker compose exec -T db createdb -U libredesk libredesk_restore_test
docker compose exec -T db pg_restore -U libredesk -d libredesk_restore_test --no-owner /tmp/restore.dump
docker compose exec -T db psql -U libredesk -d libredesk_restore_test -tAc "select count(*) from help_articles"
docker compose exec -T db dropdb -U libredesk libredesk_restore_test
```
**访问控制**
- `9001`(明文 app)只绑 `127.0.0.1`,局域网只能走 `https://192.168.31.61:9443`;本机/SSH 隧道调试不受影响;
- nginx 已加 `TLSv1.2/1.3`、`X-Content-Type-Options: nosniff`、`Referrer-Policy`(X-Frame-Options 由应用自己发,避免重复)。
### ⚠️ bridge-tools 依赖本机 DB 隧道
bridge-tools 的 4 个工具都要访问 yehwang 业务 RPC(`host.docker.internal:9632/9612/9509/9602`),
而这些服务依赖本机到 QA1 的 SSH 隧道(`172.18.0.1:3318` MySQL 等)。**隧道没起时**:
- `search_products`:`/product/list` 报 Internal Server Error → 工具回退 autocomplete,**只有商品名、没有价格/库存**;
- `search_orders` / `get_customer_info`:RPC 返回 `SQLSTATE[HY000] [2002] Connection refused`。
启动顺序:先 `yw-db-tunnel start`(`yw-db-tunnel status` 确认“已转发”),再 `docker compose up -d`。
隧道断线后工具会“半残”,排查时先看 `docker compose logs bridge-tools` 与 `testdb "select 1"`。
**隧道已配置自启 / 自愈**(dev 用户 crontab):
```
@reboot sleep 60; /home/dev/code/yehwang-ai/libredesk/scripts/ensure-tunnel.sh
*/5 * * * * /home/dev/code/yehwang-ai/libredesk/scripts/ensure-tunnel.sh
```
- [scripts/ensure-tunnel.sh](scripts/ensure-tunnel.sh) 幂等:隧道完整时静默退出,异常时 `yw-db-tunnel start` 并写 `logs/tunnel.log`;
- 依赖 cron 开机自启(已确认 enabled)和 `yw-devbox` 容器(restart=unless-stopped,已确认);
- 如需手动确认:`yw-db-tunnel status` + `testdb "select 1"`。
## 已知偏差(相对官方 quickstart)
- **DB 镜像**:官方 compose 用 `postgres:17-alpine`,本机从 docker.io / 各镜像源拉取该 tag 均失败(docker.io 长时间无进展、1panel 403、imgdb 层校验失败),
因此改用本机已缓存的 `pgvector/pgvector:pg18`(官方 `postgres` 镜像的 PG18 基础映像,`crm-new` 项目的 LibreDesk 已在用,兼容)。
PG18 的数据目录是 `/var/lib/postgresql`,compose 已按此挂载;如以后想换回 `postgres:17-alpine`,**不能直接复用数据卷**(18→17 需 dump/restore)。
- **System 用户密码强度**:libredesk 要求 10-72 位且含大小写+数字+特殊字符,纯随机 hex 会被拒绝(启动日志 `system user password is not strong`)。
## 注意事项
- `app.encryption_key` 首次启动后**不要改**,否则已保存的密钥(如收件箱密码)无法解密。
- `config.toml` 里 `disable_secure_cookies = true` 是给 http://localhost:9001 直连用的;如果只走 https,可以改回 `false` 更安全。
- 反代已按官方要求设置 `X-Client-IP $remote_addr`(限流/审计需要),见 [tls/nginx.conf](tls/nginx.conf)。
- 初始化时 compose 的 command 会执行 `--install --idempotent-install`,这是幂等的;**不要**对已有数据库手工跑 `--install`(官方文档警告它会删除已有数据)。
- 如需公网/域名访问,建议按官方文档加 Nginx 反向代理,并在 Cloudflare 场景下转发 `CF-Connecting-IP`。
+4
View File
@@ -0,0 +1,4 @@
.env
node_modules
test
*.log
+19
View File
@@ -0,0 +1,19 @@
# bridge-tools 环境变量(由 libredesk/docker-compose.yml 注入)
TOOLS_PORT=4100
TOOLS_PUBLIC_BASE_URL=http://bridge-tools:4100
# 与 LibreDesk 工具 Headers 里的 X-Tools-Key 保持一致
TOOLS_SHARED_KEY=change-me
# 仅本地联调可设 true(跳过 X-Libredesk-Contact-Verified 校验),生产必须 false
TOOLS_DEV_BYPASS=false
# yehwang 业务 RPC(容器内经 host.docker.internal 访问宿主机已发布端口)
RECOMMEND_URL=jsonrpc://host.docker.internal:9632
ORDER_API_URL=jsonrpc://host.docker.internal:9612
INVENTORY_API_URL=jsonrpc://host.docker.internal:9509
MEMBER_RPC_URL=jsonrpc://host.docker.internal:9602
RPC_TIMEOUT_MS=8000
PRODUCT_TIMEOUT_MS=30000
SITE_SEARCH_URL=https://www.yehwang.com/search.html?q=
DEFAULT_TENANT_ID=2
DEFAULT_LANGUAGE_ID=1
+21
View File
@@ -0,0 +1,21 @@
FROM node:24-alpine
WORKDIR /app
# 无第三方依赖(只用 Node 标准库),直接拷贝源码
COPY package.json ./
COPY src ./src
COPY config ./config
# 宿主机生成的文件可能是 600,复制后统一放开只读权限
RUN chmod -R a+rX /app
ENV NODE_ENV=production
USER node
EXPOSE 4100
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:4100/healthz || exit 1
CMD ["node", "src/server.js"]
+39
View File
@@ -0,0 +1,39 @@
# bridge-tools
LibreDesk 的**业务工具服务**(从 `crm-new/bridge` 裁剪):只保留 AI 助手需要的 4 个 HTTP 工具,
其余能力(AI 编排、RAG/知识库、OTP 验证、转人工、身份签发、质量飞轮)全部由 LibreDesk v2.9.1 内置实现。
## 工具
| 工具 | 需要验证 | 作用 |
| --- | --- | --- |
| `search_products` | 否 | 商品搜索(价格/库存/图片/product_id/URL),catalog 失败时退回 autocomplete 和站点地图类目链接 |
| `search_orders` | **是** | 订单列表/详情(状态、商品、金额、地址、物流);邮箱只取 LibreDesk 身份头 |
| `search_inventory` | 否 | 实时库存 + 预购 ETA |
| `get_customer_info` | **是** | 会员资料(等级、积分、余额、注册时间等) |
## 接口
- `GET /healthz` — 健康检查(公开)
- `GET /tools/manifest` — 工具清单(`X-Tools-Key`),可用来在 LibreDesk 批量注册
- `POST /tools/<name>` — 执行工具(`X-Tools-Key` + LibreDesk 注入的 `X-Libredesk-*` 身份头)
## 与 LibreDesk 的约定
- LibreDesk 调用自定义工具时会注入身份头:
`X-Libredesk-Contact-Id` / `-External-Id` / `-Type` / `-Email` / `-Verified`、`X-Libredesk-Conversation-UUID`、`X-Libredesk-Inbox-Id`;
- 邮箱、验证状态一律以这些头为准(不信任模型参数);
- `search_orders` / `get_customer_info` 在 LibreDesk 侧应设置 `requires_verification=true`,客户验证(内置 OTP)后才会被放行;
- 工具鉴权:在 LibreDesk 的工具 Headers 里配置 `X-Tools-Key`,值等于本服务的 `TOOLS_SHARED_KEY`。
## 本地运行 / 测试
```bash
node --test "test/*.test.js" # 单测(mock RPC,不需要外部服务)
node src/server.js # 本机启动(需环境变量,见 .env.example)
```
## 部署
作为服务并入 `../libredesk/docker-compose.yml`(服务名 `bridge-tools`),
与 LibreDesk 同一 Docker 网络,LibreDesk 侧工具 URL 用 `http://bridge-tools:4100/tools/<name>`。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"_comment": "LibreDesk inbox → site profile。inboxes 的 key 是 inbox 数字 ID;profiles 可按站点扩展。",
"defaultSite": "www",
"profiles": {
"www": {
"site": "www",
"tenantId": 2,
"currencyCode": "EUR",
"searchUrl": "https://www.yehwang.com/search.html?q=",
"siteMap": "site-map.json",
"language": "en"
},
"us": {
"site": "us",
"tenantId": 2,
"currencyCode": "USD",
"searchUrl": "https://us.yehwang.com/search.html?q=",
"siteMap": "site-map-us.json",
"language": "en"
},
"eu": {
"site": "www",
"tenantId": 2,
"currencyCode": "EUR",
"searchUrl": "https://www.yehwang.com/search.html?q=",
"siteMap": "site-map.json",
"language": "en"
}
},
"inboxes": {
"1": "www",
"2": "www",
"3": "www"
},
"languageIds": {
"en": 1,
"nl": 2,
"de": 3,
"es": 4,
"fr": 5,
"it": 6,
"tr": 7,
"cn": 10
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "bridge-tools",
"version": "0.1.0",
"private": true,
"description": "LibreDesk AI business tools only (products / orders / inventory / customer profile)",
"type": "commonjs",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test \"test/*.test.js\""
}
}
+23
View File
@@ -0,0 +1,23 @@
'use strict';
const crypto = require('crypto');
/**
* 工具服务入口鉴权:LibreDesk 在工具的 Headers 配置里带 X-Tools-Key。
* 未配置 key 时 fail-closed(返回 enabled=false,server 会直接 503)。
*/
function createAuther(expectedKey) {
const key = String(expectedKey || '');
const expected = Buffer.from(key);
function verify(value) {
if (!key) return false;
const given = Buffer.from(String(value || ''));
if (given.length !== expected.length) return false;
return crypto.timingSafeEqual(given, expected);
}
return { enabled: key.length > 0, verify };
}
module.exports = { createAuther };
+65
View File
@@ -0,0 +1,65 @@
'use strict';
const fs = require('fs');
const path = require('path');
// 多站点配置:inbox 数字 ID -> 站点 profile(tenant / 语言 / 类目映射 / 搜索地址)
function loadSites(env) {
const fallback = {
defaultSite: 'www',
profiles: {
www: { site: 'www', tenantId: null, languageId: null, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
},
inboxes: {},
};
const raw = env.SITES_JSON;
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
} catch {
// 配置坏了就用默认值
}
}
try {
const parsed = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config', 'sites.json'), 'utf8'));
if (parsed && typeof parsed === 'object') return parsed;
} catch {
// 没有配置文件时保持默认
}
return fallback;
}
/**
* bridge-tools 配置。全部来自环境变量。
* 密钥类字段不落默认值;缺配置时对应工具返回 503,而不是静默降级。
*/
function loadConfig(env = process.env) {
const pick = (k, d) => (env[k] === undefined || env[k] === '' ? d : env[k]);
const num = (k, d) => {
const n = Number(pick(k, ''));
return Number.isFinite(n) && n > 0 ? n : d;
};
return {
port: num('TOOLS_PORT', 4100),
toolsSharedKey: String(pick('TOOLS_SHARED_KEY', '')),
toolsPublicBaseUrl: String(pick('TOOLS_PUBLIC_BASE_URL', 'http://bridge-tools:4100')).replace(/\/+$/, ''),
// 仅本地联调用:跳过 LibreDesk 注入的 X-Libredesk-Contact-Verified 校验。
devBypassVerification: /^(1|true|yes)$/i.test(String(pick('TOOLS_DEV_BYPASS', 'false'))),
siteSearchUrl: String(pick('SITE_SEARCH_URL', 'https://www.yehwang.com/search.html?q=')),
recommendUrl: String(pick('RECOMMEND_URL', '')),
orderApiUrl: String(pick('ORDER_API_URL', '')),
inventoryApiUrl: String(pick('INVENTORY_API_URL', '')),
memberRpcUrl: String(pick('MEMBER_RPC_URL', '')),
rpcTimeoutMs: num('RPC_TIMEOUT_MS', 8000),
productTimeoutMs: num('PRODUCT_TIMEOUT_MS', 15000),
sites: loadSites(env),
defaultTenantId: num('DEFAULT_TENANT_ID', 2),
defaultLanguageId: num('DEFAULT_LANGUAGE_ID', 1),
};
}
module.exports = { loadConfig };
+17
View File
@@ -0,0 +1,17 @@
'use strict';
/**
* 工具执行错误。status 会直接映射为 HTTP 状态码:
* - 4xx:LibreDesk 视为“工具失败”,AI 通常会告知客户无法完成/转人工
* - 2xx:即使没有数据也应当返回 2xx(让模型能继续对话)
*/
class ToolError extends Error {
constructor(status, code, message) {
super(message);
this.name = 'ToolError';
this.status = status;
this.code = code;
}
}
module.exports = { ToolError };
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const CJK = /[\u3400-\u9fff]/;
function normalize(value) {
return String(value || '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// 把中文/别名商品词映射到 site-map 的英文类目,例如 戒指 -> Rings。
function translateProductQuery(query, entries) {
const original = String(query || '').trim();
const q = normalize(original);
if (!q || !Array.isArray(entries) || !entries.length) return { query: original, category: null, translated: false };
if (!CJK.test(q)) return { query: original, category: null, translated: false };
let best = null;
for (const entry of entries) {
const fields = [entry.title, String(entry.category || '').split('/').pop(), ...(entry.aliases || [])]
.map(normalize)
.filter((f) => f.length >= 2);
for (const field of fields) {
if (q.includes(field) || field.includes(q)) {
const score = field.length + (field === q ? 100 : 0);
if (!best || score > best.score) best = { entry, score, field };
}
}
}
if (!best) return { query: original, category: null, translated: false };
return {
query: best.entry.title || original,
category: { name: best.entry.title || null, url: best.entry.url || null },
translated: true,
matched_alias: best.field,
};
}
module.exports = { translateProductQuery, normalize };
+136
View File
@@ -0,0 +1,136 @@
'use strict';
// JSONRPC-TCP 客户端(Hyperf JSONRPC 协议)
// 协议:TCP + JSON 请求体 + "\r\n" 帧分隔;服务间内部通道,无鉴权。
// 支持 jsonrpc://host:port 与 http(s):// 双形态地址(后者降级走 HTTP)。
const net = require('net');
const DEFAULT_TIMEOUT = 8000;
/**
* 解析地址:jsonrpc://host:port 或 host:port 或 http(s)://host:port
* @returns {{protocol: 'jsonrpc'|'http', host: string, port: number, httpUrl: string|null}}
*/
function parseAddress(url) {
const s = String(url || '').trim();
if (s.startsWith('jsonrpc://')) {
const m = s.replace('jsonrpc://', '').match(/^([^:/]+):?(\d+)?/);
if (m) return { protocol: 'jsonrpc', host: m[1], port: m[2] ? parseInt(m[2], 10) : 9502, httpUrl: null };
}
if (/^https?:\/\//.test(s)) {
try {
const u = new URL(s);
return {
protocol: 'http',
host: u.hostname,
port: u.port ? parseInt(u.port, 10) : (u.protocol === 'https:' ? 443 : 80),
httpUrl: s,
};
} catch {
/* fallthrough */
}
}
const m = s.match(/^([^:/]+):?(\d+)?/);
if (m) return { protocol: 'jsonrpc', host: m[1], port: m[2] ? parseInt(m[2], 10) : 9502, httpUrl: null };
return { protocol: 'jsonrpc', host: s, port: 9502, httpUrl: null };
}
/**
* JSONRPC 调用(TCP)。
* @param {string} addr jsonrpc://host:port
* @param {string} method 如 /erp_order/getList
* @param {Array} [params] 位置参数数组
* @param {Array} [context] 请求上下文(可空)
*/
function jsonrpcCall(addr, method, params = [], context = [], timeout = DEFAULT_TIMEOUT) {
return new Promise((resolve, reject) => {
const { host, port } = parseAddress(addr);
const id = 'tools_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const payload = JSON.stringify({ jsonrpc: '2.0', method, params, id, context }) + '\r\n';
const sock = net.createConnection({ host, port });
let buf = '';
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
sock.destroy();
fn(value);
};
const timer = setTimeout(() => finish(reject, new Error(`JSONRPC timeout: ${method} @ ${host}:${port}`)), timeout);
sock.setTimeout(timeout);
sock.on('timeout', () => sock.destroy());
sock.on('connect', () => sock.write(payload));
sock.on('data', (chunk) => {
buf += chunk.toString('utf8');
const frames = buf.split('\r\n');
buf = frames.pop(); // 保留不完整尾帧
for (const frame of frames) {
if (!frame.trim()) continue;
let parsed;
try {
parsed = JSON.parse(frame);
} catch {
continue;
}
if (parsed && parsed.id === id) {
if (parsed.error) {
finish(reject, new Error(`JSONRPC error: ${method} -> ${parsed.error.message || JSON.stringify(parsed.error)}`));
} else {
finish(resolve, parsed.result);
}
return;
}
}
});
sock.on('error', (e) => finish(reject, new Error(`JSONRPC connect failed: ${host}:${port} (${e.message})`)));
sock.on('close', () => finish(reject, new Error(`JSONRPC connection closed before response: ${method}`)));
});
}
/**
* 统一执行入口:按地址协议分发(jsonrpc:// → TCP;http(s):// → fetch)。
*/
async function call(addr, method, params = [], httpOptions = {}, timeout = DEFAULT_TIMEOUT) {
const parsed = parseAddress(addr);
if (parsed.protocol === 'jsonrpc') return jsonrpcCall(addr, method, params, httpOptions.context || [], timeout);
const qs = httpOptions.query ? '?' + new URLSearchParams(httpOptions.query).toString() : '';
const resp = await fetch(parsed.httpUrl + method + qs, {
headers: httpOptions.headers || {},
signal: AbortSignal.timeout(timeout),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${method}`);
return resp.json();
}
/** 业务 RPC 返回体形态不一,统一摊平成数组。 */
function asList(data) {
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
for (const key of ['list', 'data', 'items', 'rows', 'result']) {
if (data[key] !== undefined) {
const inner = asList(data[key]);
if (inner.length) return inner;
}
}
return [];
}
/** 业务 RPC 返回体摊平成对象(取 list/data/result 的第一层对象)。 */
function asObject(data) {
if (!data || typeof data !== 'object') return null;
if (Array.isArray(data)) return data[0] || null;
for (const key of ['data', 'result', 'item']) {
if (data[key] && typeof data[key] === 'object' && !Array.isArray(data[key])) return data[key];
}
return data;
}
module.exports = { parseAddress, jsonrpcCall, call, asList, asObject };
+180
View File
@@ -0,0 +1,180 @@
'use strict';
// bridge-tools HTTP server:只做三件事
// GET /healthz 健康检查
// GET /tools/manifest 给 LibreDesk 注册工具用的清单
// POST /tools/<name> 执行工具(X-Tools-Key 鉴权 + X-Libredesk-* 身份头)
const http = require('http');
const { loadConfig } = require('./config');
const { createAuther } = require('./auth');
const { createTools, resolveSiteProfile } = require('./tools');
const { ToolError } = require('./errors');
const MAX_BODY_BYTES = 256 * 1024;
function readBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new ToolError(413, 'payload_too_large', 'request body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
reject(new ToolError(400, 'invalid_json', 'request body must be valid JSON'));
}
});
req.on('error', reject);
});
}
function sendJson(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(payload),
});
res.end(payload);
}
/** LibreDesk 在工具请求里注入的身份头;服务端必须以此为准,不能信任模型参数。 */
function contextFromHeaders(headers = {}) {
const h = (name) => String(headers[name] || '').trim();
return {
contactId: h('x-libredesk-contact-id'),
email: h('x-libredesk-contact-email').toLowerCase(),
externalId: h('x-libredesk-contact-external-id'),
contactType: h('x-libredesk-contact-type'),
conversationUuid: h('x-libredesk-conversation-uuid'),
inboxId: h('x-libredesk-inbox-id'),
verified: h('x-libredesk-contact-verified').toLowerCase() === 'true',
};
}
function createServer(options = {}) {
const cfg = options.config || loadConfig();
const auther = createAuther(cfg.toolsSharedKey);
const tools = options.tools || createTools(cfg);
const byName = new Map(tools.map((t) => [t.name, t]));
const startedAt = Date.now();
const server = http.createServer(async (req, res) => {
const url = (req.url || '').split('?')[0];
try {
if (req.method === 'GET' && url === '/healthz') {
return sendJson(res, 200, {
ok: true,
service: 'bridge-tools',
uptime_sec: Math.round((Date.now() - startedAt) / 1000),
tools: [...byName.keys()],
verification_bypass: cfg.devBypassVerification,
sites: Object.keys((cfg.sites && cfg.sites.profiles) || {}),
});
}
if (!auther.enabled) {
return sendJson(res, 503, {
ok: false,
error: 'tools_key_not_configured',
message: 'Set TOOLS_SHARED_KEY and use the same value in the LibreDesk tool headers.',
});
}
if (!auther.verify(req.headers['x-tools-key'])) {
return sendJson(res, 401, { ok: false, error: 'unauthorized', message: 'invalid or missing X-Tools-Key' });
}
if (req.method === 'GET' && url === '/tools/manifest') {
return sendJson(res, 200, {
base_url: cfg.toolsPublicBaseUrl,
tools: tools.map((t) => ({
name: t.name,
description: t.description,
method: 'POST',
url: `${cfg.toolsPublicBaseUrl}/tools/${t.name}`,
parameters: t.parameters,
requires_verification: t.requiresVerification,
auth: { headers: [{ key: 'X-Tools-Key', value: '<TOOLS_SHARED_KEY>' }] },
})),
});
}
const match = url.match(/^\/tools\/([a-z0-9_]+)$/);
if (req.method === 'POST' && match) {
const tool = byName.get(match[1]);
if (!tool) {
return sendJson(res, 404, { ok: false, error: 'unknown_tool', message: `unknown tool: ${match[1]}` });
}
const body = await readBody(req);
const ctx = contextFromHeaders(req.headers);
ctx.siteProfile = resolveSiteProfile(cfg, ctx);
// 仅本地联调可跳过验证;默认关闭。
if (cfg.devBypassVerification && !ctx.verified) {
ctx.verified = true;
console.warn(JSON.stringify({ ts: new Date().toISOString(), warn: 'verification bypassed by TOOLS_DEV_BYPASS', tool: tool.name }));
}
if (tool.requiresVerification && !ctx.verified) {
return sendJson(res, 403, {
ok: false,
error: 'verification_required',
message:
'Ask the customer to verify their email with the one-time code, then call this tool again. Do not ask for personal data before verification.',
});
}
const started = Date.now();
const result = await tool.handler(body, ctx);
console.log(JSON.stringify({
ts: new Date().toISOString(),
tool: tool.name,
ms: Date.now() - started,
verified: ctx.verified,
inbox: ctx.inboxId || null,
site: ctx.siteProfile.site,
ok: true,
}));
return sendJson(res, 200, { ok: true, tool: tool.name, ...result });
}
return sendJson(res, 404, { ok: false, error: 'not_found' });
} catch (err) {
if (err instanceof ToolError) {
console.warn(JSON.stringify({ ts: new Date().toISOString(), error: err.code, status: err.status, message: err.message }));
return sendJson(res, err.status, { ok: false, error: err.code, message: err.message });
}
console.error(JSON.stringify({ ts: new Date().toISOString(), error: 'internal_error', message: err && err.message }));
return sendJson(res, 500, { ok: false, error: 'internal_error', message: 'Tool execution failed' });
}
});
return server;
}
if (require.main === module) {
const cfg = loadConfig();
const server = createServer({ config: cfg });
server.listen(cfg.port, '0.0.0.0', () => {
console.log(`[bridge-tools] listening on :${cfg.port} — manifest: ${cfg.toolsPublicBaseUrl}/tools/manifest`);
if (!cfg.toolsSharedKey) console.warn('[bridge-tools] TOOLS_SHARED_KEY is not set — all /tools/* calls will return 503');
if (cfg.devBypassVerification) console.warn('[bridge-tools] TOOLS_DEV_BYPASS=true — verification checks are BYPASSED (local development only)');
});
const shutdown = () => server.close(() => process.exit(0));
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
module.exports = { createServer, contextFromHeaders };
+542
View File
@@ -0,0 +1,542 @@
'use strict';
// bridge-tools:只保留 LibreDesk AI 需要的业务工具(商品 / 订单 / 库存 / 客户资料)。
// 其余能力(AI 编排、RAG、OTP、转人工、身份签发、质量飞轮)已由 LibreDesk v2.9.1 内置实现。
// 来源:从 crm-new/bridge 裁剪,仅保留 RPC 业务工具链路。
const fs = require('fs');
const path = require('path');
const { ToolError } = require('./errors');
const { asList, asObject } = require('./rpc');
const { translateProductQuery } = require('./product-query');
// ---------------------------------------------------------------- 站点配置
const siteMapCache = new Map();
function loadSiteMapFile(file) {
const name = file || 'site-map.json';
if (siteMapCache.has(name)) return siteMapCache.get(name);
let entries = [];
try {
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config', name), 'utf8'));
entries = Array.isArray(raw.entries) ? raw.entries : [];
} catch {
entries = [];
}
siteMapCache.set(name, entries);
return entries;
}
// 语言 code -> RPC language_id(业务侧枚举)
const DEFAULT_LANGUAGE_IDS = { en: 1, nl: 2, de: 3, es: 4, fr: 5, it: 6, tr: 7, cn: 10 };
// inbox 数字 ID -> 站点 profile(tenant / 语言 / 站点地图 / 搜索地址)
function resolveSiteProfile(cfg, ctx) {
const sites = cfg.sites || {};
const inboxKey = String((ctx && ctx.inboxId) || '');
const key = (sites.inboxes && sites.inboxes[inboxKey]) || sites.defaultSite || 'www';
const profile = (sites.profiles && sites.profiles[key]) || {};
const languageIds = (sites.languageIds && Object.keys(sites.languageIds).length ? sites.languageIds : DEFAULT_LANGUAGE_IDS);
const languageId = profile.languageId != null
? profile.languageId
: (profile.language && languageIds[profile.language] != null ? languageIds[profile.language] : (cfg.defaultLanguageId != null ? cfg.defaultLanguageId : 1));
return {
key,
site: profile.site || key,
tenantId: profile.tenantId != null ? profile.tenantId : (cfg.defaultTenantId != null ? cfg.defaultTenantId : 1),
languageId,
currencyCode: profile.currencyCode || '',
searchUrl: profile.searchUrl || cfg.siteSearchUrl,
homeUrl: profile.homeUrl || (() => { try { return new URL(profile.searchUrl || cfg.siteSearchUrl).origin; } catch { return ''; } })(),
siteMap: profile.siteMap || 'site-map.json',
};
}
// Hyperf JSON-RPC 的 DTO 反序列化要求嵌套对象带 _class(URL 编码的 PHP 类名)。
const REQ_CONTEXT_CLASS = encodeURIComponent('Yw\\Common\\Dto\\ReqContext');
const DEVICE_INFO_CLASS = encodeURIComponent('Yw\\Member\\Client\\Dto\\DeviceInfoDto');
function rpcContextFor(profile) {
return {
_class: REQ_CONTEXT_CLASS,
tenantId: profile.tenantId,
languageId: profile.languageId,
currencyCode: profile.currencyCode || '',
defaultCurrencyCode: profile.currencyCode || '',
siteType: 1,
isoCode: '',
};
}
function withRpcContext(params, profile) {
if (!Array.isArray(params) || params.length === 0) return params;
const first = params[0];
if (!first || typeof first !== 'object' || Array.isArray(first)) return params;
return [{
...first,
reqContext: rpcContextFor(profile),
deviceInfo: {
_class: DEVICE_INFO_CLASS,
ip: '',
userAgent: '',
deviceToken: '',
deviceType: 'h5',
appVersion: '',
},
}, ...params.slice(1)];
}
// ---------------------------------------------------------------- 站点地图兜底匹配
function normalizeText(value) {
return String(value || '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const CJK_RE = /[\u3400-\u9fff]/;
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// 中文用子串匹配;英文用词边界,避免 "earrings" 误命中 "rings"。
function fieldMatches(field, token) {
if (!field || !token) return false;
if (CJK_RE.test(field) || CJK_RE.test(token)) return field.includes(token) || token.includes(field);
if (token.length < 2) return false;
return new RegExp(`(^|[^a-z0-9])${escapeRegExp(token)}([^a-z0-9]|$)`).test(field);
}
function matchSiteMap(entries, query, limit) {
const q = normalizeText(query);
if (!q) return [];
const tokens = q.split(' ').filter(Boolean);
const scored = [];
for (const entry of entries) {
const fields = [entry.title, String(entry.category || '').split('/').pop(), ...(entry.aliases || [])]
.map(normalizeText)
.filter(Boolean);
let score = 0;
for (const field of fields) {
if (field === q) score = Math.max(score, 100);
else if (fieldMatches(field, q) || (q.length >= 2 && fieldMatches(q, field))) score = Math.max(score, 80);
else if (tokens.every((t) => fieldMatches(field, t))) score = Math.max(score, 60);
}
if (!score && tokens.length) {
const hits = tokens.filter((t) => fields.some((f) => fieldMatches(f, t))).length;
if (hits === tokens.length) score = 50;
}
if (score > 0) scored.push({ entry, score });
}
scored.sort((a, b) =>
b.score - a.score ||
(a.entry.level || 9) - (b.entry.level || 9) ||
String(a.entry.url || '').length - String(b.entry.url || '').length
);
return scored.slice(0, limit).map(({ entry }) => ({
name: entry.title || null,
url: entry.url || null,
sku: null,
price: null,
}));
}
// ---------------------------------------------------------------- 工具定义
/**
* 工具定义(同步给 LibreDesk Admin → AI → Tools)。
* description / parameters 直接进入模型上下文,用英文写。
* requiresVerification: LibreDesk 侧同名开关(未验证联系人会被平台拦截);服务端仍二次校验(fail-closed)。
*/
const TOOL_DEFS = [
{
name: 'search_products',
description:
'Search the Yehwang product catalog by keyword. Returns product names, prices, images, stock, product ids and page URLs (falls back to category page links when the catalog index has no exact match). Call this for ANY product-finding request instead of answering from memory; always include returned URLs and prices in the reply.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'product keywords, e.g. "gold hoop earrings" or "stainless steel necklace"' },
limit: { type: 'integer', description: 'max results, default 5, max 5' },
},
required: ['query'],
},
requiresVerification: false,
},
{
name: 'search_orders',
description:
'Look up order status and details for the CURRENT verified customer only. Requires a verified contact. If the customer gives an order number, pass it as orderNumber to get full detail (items, totals, shipping address, tracking). Omit orderNumber to list the latest 5 orders. Never ask for or accept another person\'s email; the email is taken from platform identity headers. Always include the shipping method in the reply.',
parameters: {
type: 'object',
properties: {
orderNumber: { type: 'string', description: 'order number or order id; omit to list the latest 5 orders' },
},
required: [],
},
requiresVerification: true,
},
{
name: 'search_inventory',
description:
'Check real-time stock and pre-order ETA for a specific product. Pass product_id returned by search_products when available; otherwise a SKU/product id whose digits identify the product.',
parameters: {
type: 'object',
properties: {
product_id: { type: 'integer', description: 'product id from search_products (preferred)' },
sku: { type: 'string', description: 'SKU or product id' },
productName: { type: 'string', description: 'product name if SKU is unknown' },
},
required: [],
},
requiresVerification: false,
},
{
name: 'get_customer_info',
description:
'Look up the CURRENT verified customer\'s own account profile: display name, company, country, member grade, discount group, points, store credit, registration date and phone. Requires a verified contact; the identity always comes from platform headers, never from the customer message. Use it to personalise the reply or confirm account context. Never look up or reveal another person\'s profile.',
parameters: {
type: 'object',
properties: {},
required: [],
},
requiresVerification: true,
},
];
function digitsOf(value) {
const m = String(value || '').match(/\d+/);
return m ? parseInt(m[0], 10) : null;
}
function summarizeOrder(o) {
if (!o || typeof o !== 'object') return null;
return {
order_no: o.orderNo || o.order_no || o.order_id || o.orderId || null,
order_id: o.orderId || o.order_id || null,
status: o.statusText || o.orderStatus || o.status || null,
total: o.total || o.totalPrice || o.total_price || null,
date: o.dateAdded || o.date_added || null,
shipping_method: o.shippingMethod || o.shipping_method || null,
tracking_number: o.trackingNumber || o.tracking_number || null,
};
}
// ---------------------------------------------------------------- 工具实现
function createTools(cfg, deps = {}) {
const rpc = deps.rpc || require('./rpc');
const siteMapOverride = deps.siteMap || null;
const siteEntries = (profile) => siteMapOverride || loadSiteMapFile(profile.siteMap);
async function callRpc(addr, method, params, ctx, timeoutMs) {
if (!addr) throw new ToolError(503, 'not_configured', `RPC endpoint for ${method} is not configured`);
const profile = resolveSiteProfile(cfg, ctx || {});
return rpc.call(addr, method, withRpcContext(params, profile), {}, timeoutMs || cfg.rpcTimeoutMs);
}
async function searchProducts(args, ctx) {
const query = String(args.query || '').trim();
if (!query) throw new ToolError(400, 'invalid_arguments', 'query is required');
const limit = Math.min(Number(args.limit) || 5, 5);
const profile = resolveSiteProfile(cfg, ctx);
// 中文/别名商品词先映射到英文类目,例如 简约不锈钢戒指 -> Rings
const translation = translateProductQuery(query, siteEntries(profile));
const rpcQuery = translation.query || query;
const absoluteUrl = (href) => {
if (!href) return null;
if (/^https?:\/\//i.test(href)) return href;
return profile.homeUrl ? profile.homeUrl + (href.startsWith('/') ? '' : '/') + href : href;
};
let items = [];
let total = null;
let catalogError = null;
try {
const cmd = {
keyword: rpcQuery,
page: 1,
// 多取候选,下面按库存排序后再截断到 limit,避免缺货商品占满前几条
pageSize: limit * 4,
languageId: profile.languageId,
tenantId: profile.tenantId,
currencyCode: profile.currencyCode || '',
status: 20,
siteType: 1,
supplierCode: '',
tagIds: [],
tenantBaseCurrency: profile.currencyCode || '',
filterSpuList: [],
productSource: 1,
};
const raw = await callRpc(cfg.recommendUrl, '/product/list', [cmd], ctx, cfg.productTimeoutMs);
const result = (raw && raw.result) || raw || {};
const paging = result.data || result;
const list = Array.isArray(paging) ? paging : (paging.data || paging.list || []);
total = result.total != null ? result.total : (paging.total != null ? paging.total : null);
items = (Array.isArray(list) ? list : [])
.map((p) => {
const name = p.spuName || p.name || p.title || null;
const sku = p.sku || p.model || (Array.isArray(p.skus) && p.skus[0] ? p.skus[0].productId : null);
const stock = Array.isArray(p.skus)
? p.skus.reduce((sum, s) => sum + (Number(s.quantity) || 0), 0)
: null;
const href = p.href || p.url || p.product_url || null;
return {
name,
price: p.minPriceWithCurrency || p.priceWithCurrency || (p.price != null ? p.price : null),
url: absoluteUrl(href) || (name && profile.searchUrl ? profile.searchUrl + encodeURIComponent(name) : null),
sku,
product_id: p.spuId != null ? p.spuId : null,
image: p.masterImage || null,
stock,
mark_price: p.markPriceWithCurrency || null,
};
})
.filter((p) => p.name)
// 有库存优先:stock>0 → 0,未知 → 1,缺货 → 2;sort 稳定,保留 RPC 原顺序
.sort((a, b) => {
const rank = (p) => (p.stock > 0 ? 0 : p.stock === null ? 1 : 2);
return rank(a) - rank(b);
})
.slice(0, limit);
} catch (err) {
catalogError = err.message;
}
// 兜底 1:商品列表不可用时退回 autocomplete(只有名字)
if (!items.length) {
try {
const raw = await callRpc(cfg.recommendUrl, '/product_new/autocomplete', [
{ keyword: rpcQuery, page: 1, pageSize: limit },
], ctx, cfg.productTimeoutMs);
items = asList(raw)
.slice(0, limit)
.map((p) => {
const name = p.name || p.title || null;
const url = p.url || p.product_url || p.href ||
(name && profile.searchUrl ? profile.searchUrl + encodeURIComponent(name) : null);
return { name, price: null, url, sku: null };
})
.filter((p) => p.name);
if (items.length) catalogError = null;
} catch (err) {
catalogError = catalogError || err.message;
}
}
let source = 'catalog';
const categoryLinks = matchSiteMap(siteEntries(profile), query, 3);
if (!items.length && categoryLinks.length) {
items = categoryLinks;
source = 'site-map';
}
return {
products: items,
count: items.length,
...(total != null ? { total } : {}),
source,
site: profile.site,
...(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'
? { note: 'These are Yehwang category pages matched from the site map; include the links in the reply.' }
: {}),
...(source === 'catalog' && categoryLinks.length
? { note: 'Product names come from the live catalog; category_links are real category pages you can also share. Prices/SKUs are currently unavailable.' }
: {}),
...(catalogError ? { catalog_error: catalogError } : {}),
};
}
async function searchOrders(args, ctx) {
if (!ctx.verified) {
throw new ToolError(403, 'verification_required', 'Order lookup requires a verified contact. Ask the customer to verify their email with the one-time code first.');
}
const email = String(ctx.email || '').trim().toLowerCase();
if (!email) throw new ToolError(400, 'missing_email', 'The verified contact has no email on file.');
// 隐私门控:邮箱永远来自平台身份头,忽略模型/参数里传入的任何邮箱。
if (args.orderNumber) {
const wanted = String(args.orderNumber).trim().toLowerCase();
const own = asList(await callRpc(cfg.orderApiUrl, '/erp_order/getList', [
{ email, page: 1, pageSize: 50, transfer: 10000 },
], ctx));
const match = own.find((o) =>
[o.orderId, o.order_id, o.orderNo, o.order_no]
.filter((v) => v != null && v !== '')
.some((v) => String(v).toLowerCase() === wanted)
);
if (!match) {
// 0 结果也返回 2xx:让模型告知客户核对单号,而不是当成系统故障。
return { orders: [], count: 0, message: `No order "${args.orderNumber}" found for the verified customer.` };
}
return { orders: [await fetchOrderDetail(match, ctx)], count: 1 };
}
const orders = asList(await callRpc(cfg.orderApiUrl, '/erp_order/getList', [
{ email, page: 1, pageSize: 5, transfer: 10000 },
], ctx))
.slice(0, 5)
.map(summarizeOrder)
.filter(Boolean);
return {
orders,
count: orders.length,
...(orders.length ? {} : { message: 'No orders found for the verified customer.' }),
};
}
async function fetchOrderDetail(listItem, ctx) {
const orderId = parseInt(listItem.orderId || listItem.order_id, 10) || 0;
const [detailRes, trackRes] = await Promise.allSettled([
callRpc(cfg.orderApiUrl, '/erp_order/getDetail', [{ orderId }], ctx),
callRpc(cfg.orderApiUrl, '/erp_order/getTrackPreSaleOrder', [{ orderId, page: 1, pageSize: 5 }], ctx),
]);
const detail = detailRes.status === 'fulfilled' ? asObject(detailRes.value) : null;
const trackingRaw = trackRes.status === 'fulfilled' ? trackRes.value : null;
const d = detail || listItem;
const parcels = asList(trackingRaw)
.slice(0, 5)
.map((p) => ({
shipping_method: p.shippingMethod || p.shipping_method || p.shippingCode || p.shipping_code || p.carrier || null,
tracking_number: p.trackingNumber || p.tracking_number || p.tracking_code || p.trackingCode || null,
shipped_at: p.shippingAt || p.shipping_at || p.shipped_at || null,
}))
.filter((p) => p.shipping_method || p.tracking_number);
const address = [
[d.shippingFirstname, d.shippingLastname].filter(Boolean).join(' '),
d.shippingCompany,
[d.shippingAddress_1, d.shippingAddress_2].filter(Boolean).join(' '),
[d.shippingPostcode, d.shippingCity].filter(Boolean).join(' '),
d.shippingZone,
d.shippingCountry,
].filter(Boolean).join(', ');
const items = asList(d.products).slice(0, 10).map((p) => ({
name: p.name || null,
model: p.model || null,
quantity: p.quantity != null ? p.quantity : null,
price: p.price || null,
total: p.total || null,
can_return: p.canReturn != null ? Boolean(Number(p.canReturn)) : null,
})).filter((p) => p.name);
const totals = asList(d.totals).slice(0, 8).map((t) => ({
title: t.title || null,
value: t.value || null,
code: t.code || null,
})).filter((t) => t.title);
return {
...summarizeOrder(d),
order_id: d.orderId || orderId,
status: d.statusText || d.status || null,
payment_method: d.paymentMethod || d.paymentCode || null,
customer: {
name: [d.firstname, d.lastname].filter(Boolean).join(' ') || null,
email: d.email || null,
telephone: d.telephone || null,
},
shipping_address: address || null,
shipping_method: d.shippingMethod || d.shipping_method || d.tracking || null,
shipping_code: d.shippingCode || null,
tracking_number: d.trackingNumber || d.tracking_number || null,
shipping_cost: d.shippingCost != null ? String(d.shippingCost) : null,
items,
totals,
...(parcels.length ? { parcels } : {}),
email: d.email || null,
};
}
async function searchInventory(args, ctx) {
const productId = Number(args.product_id) || digitsOf(args.sku) || digitsOf(args.productName);
if (!productId) {
return { products: [], message: `Could not extract a product id from "${args.sku || args.productName || ''}". Ask the customer for the SKU.` };
}
const [stockRes, etaRes] = await Promise.allSettled([
callRpc(cfg.inventoryApiUrl, '/eta/queryStockSummary', [{ productId, warehouseId: 2, includeDetails: false }], ctx),
callRpc(cfg.inventoryApiUrl, '/eta/queryProductEta', [{ productId, warehouseId: 0, useCache: true, customerCode: '' }], ctx),
]);
const stock = stockRes.status === 'fulfilled' ? asObject(stockRes.value) : null;
// eta 服务对未知 productId 会返回默认值,只有 stockSummary 才是权威数据。
if (!stock || typeof stock.instantStock !== 'number') {
return { products: [], product_id: productId, message: `No inventory data found for product id ${productId}. Verify the SKU first.` };
}
const instant = stock.instantStock;
const reserved = stock.reservedQuantity != null ? stock.reservedQuantity : 0;
const eta = etaRes.status === 'fulfilled' ? asObject(etaRes.value) : null;
return {
product_id: productId,
instant_stock: instant,
available: Math.max(0, instant - reserved),
in_transit: stock.inTransitQuantity || 0,
reserved,
...(eta && (eta.etaDate || eta.status)
? { eta_date: eta.etaDate || null, eta_available: eta.availableQuantity != null ? eta.availableQuantity : null, eta_status: eta.status || null }
: {}),
};
}
async function getCustomerInfo(args, ctx) {
if (!ctx.verified) {
throw new ToolError(403, 'verification_required', 'Customer profile lookup requires a verified contact. Ask the customer to verify their email with the one-time code first.');
}
const email = String(ctx.email || '').trim().toLowerCase();
if (!email) throw new ToolError(400, 'missing_email', 'The verified contact has no email on file.');
if (!cfg.memberRpcUrl) throw new ToolError(503, 'not_configured', 'MEMBER_RPC_URL is not configured');
// 邮箱 → customerId(member-service 的 checkEmailExist:customerId=0 表示没有账号)
const check = asObject(await callRpc(cfg.memberRpcUrl, '/customer/checkEmailExist', [{ email }], ctx, cfg.rpcTimeoutMs)) || {};
const customerId = parseInt(check.customerId, 10) || 0;
if (!customerId) {
return { found: false, email, message: 'No customer account is registered with this email.' };
}
const p = asObject(await callRpc(cfg.memberRpcUrl, '/customer/getByCustomerId', [{ customerId }], ctx, cfg.rpcTimeoutMs)) || {};
const name = [p.firstname, p.lastname].map((v) => String(v || '').trim()).filter(Boolean).join(' ').trim();
const dial = String(p.dial || '').trim();
const phone = String(p.telephone || '').trim();
return {
found: true,
customer_id: customerId,
email: String(p.email || email).trim().toLowerCase(),
name: name || String(p.nickname || '').trim() || null,
company: String(p.company || '').trim() || null,
country: String(p.countryName || '').trim() || null,
phone: phone ? `${dial ? '+' + dial : ''}${phone}` : null,
member_grade: String(p.gradeName || '').trim() || null,
discount_group: String(p.groupName || '').trim() || null,
registered_at: p.dateAdded || null,
points: p.point != null ? p.point : null,
points_formatted: p.pointFormat || null,
store_credit: p.storeCredit != null ? p.storeCredit : null,
store_credit_formatted: p.storeCreditFormat || null,
mileage: p.mileage != null ? p.mileage : null,
account_status: p.status === 1 ? 'active' : (p.status === 0 ? 'inactive' : (p.status != null ? p.status : null)),
note: 'Profile data comes from the live customer account. Do not read out the email/phone unless the customer asks for it.',
};
}
const handlers = {
search_products: searchProducts,
search_orders: searchOrders,
search_inventory: searchInventory,
get_customer_info: getCustomerInfo,
};
return TOOL_DEFS.map((def) => ({
...def,
handler: (args, ctx) => handlers[def.name](args || {}, ctx),
}));
}
module.exports = { createTools, TOOL_DEFS, resolveSiteProfile };
+195
View File
@@ -0,0 +1,195 @@
'use strict';
// 单测:mock RPC,覆盖 4 个工具 + HTTP 层(鉴权/健康检查/身份头)
const { test } = require('node:test');
const assert = require('node:assert');
const { loadConfig } = require('../src/config');
const { createTools, resolveSiteProfile } = require('../src/tools');
const { createServer } = require('../src/server');
function baseConfig(env = {}) {
return loadConfig({
TOOLS_SHARED_KEY: 'test-key',
RECOMMEND_URL: 'jsonrpc://rpc:9632',
ORDER_API_URL: 'jsonrpc://rpc:9612',
INVENTORY_API_URL: 'jsonrpc://rpc:9509',
MEMBER_RPC_URL: 'jsonrpc://rpc:9602',
DEFAULT_TENANT_ID: '2',
DEFAULT_LANGUAGE_ID: '1',
...env,
});
}
function mockRpc(handlers = {}) {
const calls = [];
return {
calls,
async call(addr, method, params, opts, timeout) {
calls.push({ addr, method, params, timeout });
const h = handlers[method];
if (h === undefined) throw new Error(`unexpected rpc method: ${method}`);
return typeof h === 'function' ? h(params) : h;
},
};
}
const verifiedCtx = {
verified: true,
email: 'customer@example.com',
inboxId: '1',
conversationUuid: 'conv-1',
};
test('站点 profile:inbox 1 映射到 www / tenant 2 / EUR', () => {
const profile = resolveSiteProfile(baseConfig(), { inboxId: '1' });
assert.equal(profile.site, 'www');
assert.equal(profile.tenantId, 2);
assert.equal(profile.currencyCode, 'EUR');
assert.equal(profile.searchUrl, 'https://www.yehwang.com/search.html?q=');
});
test('工具清单只暴露 4 个业务工具', () => {
const tools = createTools(baseConfig(), { rpc: mockRpc() });
assert.deepEqual(tools.map((t) => t.name).sort(), ['get_customer_info', 'search_inventory', 'search_orders', 'search_products']);
assert.equal(tools.find((t) => t.name === 'search_orders').requiresVerification, true);
assert.equal(tools.find((t) => t.name === 'get_customer_info').requiresVerification, true);
});
test('search_products:catalog 命中,返回价格/库存/URL,并注入 RPC 上下文', async () => {
const rpc = mockRpc({
'/product/list': {
result: {
data: [{ spuName: 'Gold Hoop Earrings', href: '/p/gold-hoop.html', spuId: 11, minPriceWithCurrency: '€9.99', skus: [{ quantity: 2 }], masterImage: '/img/1.jpg' }],
total: 1,
},
},
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'search_products').handler({ query: 'gold hoop earrings' }, verifiedCtx);
assert.equal(out.count, 1);
assert.equal(out.source, 'catalog');
assert.equal(out.products[0].name, 'Gold Hoop Earrings');
assert.equal(out.products[0].price, '€9.99');
assert.equal(out.products[0].stock, 2);
assert.equal(out.products[0].product_id, 11);
assert.equal(out.products[0].url, 'https://www.yehwang.com/p/gold-hoop.html');
const sent = rpc.calls[0].params[0];
assert.equal(sent.reqContext.tenantId, 2);
assert.equal(sent.reqContext.languageId, 1);
assert.ok(sent.deviceInfo._class);
});
test('search_products:catalog 空时退回 site-map 类目链接', async () => {
const rpc = mockRpc({
'/product/list': { result: { data: [] } },
'/product_new/autocomplete': { result: [] },
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'search_products').handler({ query: 'rings' }, verifiedCtx);
assert.equal(out.count > 0, true);
assert.equal(out.source, 'site-map');
assert.ok(out.products[0].url);
});
test('search_orders:未验证直接 403,不触发 RPC', async () => {
const rpc = mockRpc({});
const tools = createTools(baseConfig(), { rpc });
await assert.rejects(
() => tools.find((t) => t.name === 'search_orders').handler({}, { ...verifiedCtx, verified: false }),
(err) => err.status === 403 && err.code === 'verification_required'
);
assert.equal(rpc.calls.length, 0);
});
test('search_orders:已验证返回订单摘要(邮箱只取身份头)', async () => {
const rpc = mockRpc({
'/erp_order/getList': { result: [{ orderId: 5, orderNo: 'YW1001', statusText: 'Shipped', total: '€42.00', dateAdded: '2026-10-01', shippingMethod: 'DHL' }] },
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'search_orders').handler({}, verifiedCtx);
assert.equal(out.count, 1);
assert.equal(out.orders[0].order_no, 'YW1001');
assert.equal(out.orders[0].status, 'Shipped');
assert.equal(rpc.calls[0].params[0].email, 'customer@example.com');
});
test('search_inventory:合并库存与 ETA', async () => {
const rpc = mockRpc({
'/eta/queryStockSummary': { result: { instantStock: 10, reservedQuantity: 2, inTransitQuantity: 3 } },
'/eta/queryProductEta': { result: { etaDate: '2026-11-01', availableQuantity: 5, status: 'in_transit' } },
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'search_inventory').handler({ product_id: 11 }, verifiedCtx);
assert.equal(out.product_id, 11);
assert.equal(out.instant_stock, 10);
assert.equal(out.available, 8);
assert.equal(out.in_transit, 3);
assert.equal(out.eta_date, '2026-11-01');
});
test('get_customer_info:邮箱查 customerId 后返回资料', async () => {
const rpc = mockRpc({
'/customer/checkEmailExist': { result: { customerId: 7 } },
'/customer/getByCustomerId': {
result: { firstname: 'Ada', lastname: 'Lovelace', email: 'customer@example.com', company: 'ACME', countryName: 'Netherlands', dial: '31', telephone: '612345678', gradeName: 'VIP', groupName: 'G1', status: 1, point: 100 },
},
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'get_customer_info').handler({}, verifiedCtx);
assert.equal(out.found, true);
assert.equal(out.customer_id, 7);
assert.equal(out.name, 'Ada Lovelace');
assert.equal(out.phone, '+31612345678');
assert.equal(out.member_grade, 'VIP');
});
test('HTTP:healthz 公开、无 key 401、有 key 可调工具、未验证 403', async () => {
const rpc = mockRpc({
'/erp_order/getList': { result: [{ orderId: 5, orderNo: 'YW1001', statusText: 'Shipped' }] },
});
const cfg = baseConfig();
const server = createServer({ config: cfg, tools: createTools(cfg, { rpc }) });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
try {
const health = await fetch(`${base}/healthz`);
assert.equal(health.status, 200);
const healthBody = await health.json();
assert.equal(healthBody.service, 'bridge-tools');
assert.equal(healthBody.tools.length, 4);
const noKey = await fetch(`${base}/tools/manifest`);
assert.equal(noKey.status, 401);
const manifest = await fetch(`${base}/tools/manifest`, { headers: { 'x-tools-key': 'test-key' } });
assert.equal(manifest.status, 200);
const manifestBody = await manifest.json();
assert.equal(manifestBody.tools.length, 4);
const unverified = await fetch(`${base}/tools/search_orders`, {
method: 'POST',
headers: { 'x-tools-key': 'test-key', 'content-type': 'application/json' },
body: '{}',
});
assert.equal(unverified.status, 403);
const verified = await fetch(`${base}/tools/search_orders`, {
method: 'POST',
headers: {
'x-tools-key': 'test-key',
'content-type': 'application/json',
'x-libredesk-contact-verified': 'true',
'x-libredesk-contact-email': 'customer@example.com',
'x-libredesk-inbox-id': '1',
},
body: '{}',
});
assert.equal(verified.status, 200);
const body = await verified.json();
assert.equal(body.tool, 'search_orders');
assert.equal(body.orders[0].order_no, 'YW1001');
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
+194
View File
@@ -0,0 +1,194 @@
[app]
# Log level: info, debug, warn, error, fatal
log_level = "debug"
# Environment: dev, prod.
# Setting to "dev" will enable color logging in terminal.
env = "dev"
# Whether to automatically check for application updates on start up, app updates are shown as a banner in the admin panel.
check_updates = true
# Encryption key. Generate using `openssl rand -hex 16` must be 32 characters long.
encryption_key = "your-32-char-random-string-here!"
# Path to a directory with custom static files and templates to override the defaults.
# The directory structure should mirror the built-in static/ directory.
# Only the files you provide will be replaced; the rest use built-in defaults.
# static_dir = "/path/to/custom/static"
# HTTP server.
[app.server]
# Address to bind the HTTP server to.
address = "0.0.0.0:9000"
# Unix socket path (leave empty to use TCP address instead)
socket = ""
# Do NOT disable secure cookies in production environment if you don't know exactly what you're doing!
disable_secure_cookies = false
# Session lifetime duration.
session_lifetime = "9h"
# Request read and write timeouts.
read_timeout = "60s"
write_timeout = "60s"
# Maximum request body size in bytes (100MB)
# If you are using proxy, you may need to configure them to allow larger request bodies.
max_body_size = 104857600
# Size of the read buffer for incoming requests (also limits max header size).
read_buffer_size = 65536
# Keepalive settings.
keepalive_timeout = "10s"
[auth]
# Allow users to sign in with their libredesk email and password.
# Disable this only after configuring and enabling at least one OIDC provider.
# libredesk automatically keeps local login available when no OIDC provider is enabled to prevent lockout.
# If an enabled provider is unavailable or misconfigured, set auth.local_login_enabled = true to restore password login.
local_login_enabled = true
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
# Filesystem provider.
[upload.fs]
# Directory where uploaded files are stored, make sure this directory exists and is writable by the application.
upload_path = 'uploads'
# Signed URL expiry duration (e.g., "30m", "1h"). Defaults to 1h if unset.
expiry = "1h"
# S3 provider.
[upload.s3]
# S3 endpoint URL (required only for non-AWS S3-compatible providers like MinIO).
# Leave empty to use default AWS endpoints.
url = ""
# AWS S3 credentials, keep empty to use attached IAM roles.
access_key = ""
secret_key = ""
# AWS region, e.g., "us-east-1", "eu-west-1", etc.
region = "ap-south-1"
# S3 bucket name where files will be stored.
bucket = "bucket-name"
# Optional prefix path within the S3 bucket where files will be stored.
# Example, if set to "uploads/media", files will be stored under that path.
# Useful for organizing files inside a shared bucket.
bucket_path = ""
# S3 signed URL expiry duration (e.g., "30m", "1h")
expiry = "30m"
# Postgres.
[db]
# If running locally, use `localhost`.
host = "db"
# Database port, default is 5432.
port = 5432
# Update the following values with your database credentials.
user = "libredesk"
password = "libredesk"
database = "libredesk"
ssl_mode = "disable"
# Maximum number of open database connections
max_open = 30
# Maximum number of idle connections in the pool
max_idle = 30
# Maximum time a connection can be reused before being closed
max_lifetime = "300s"
# Redis.
[redis]
# url parameter overrides other redis config options
# url = "redis://user:password@host:port/db"
# If running locally, use `localhost:6379`.
address = "redis:6379"
user = ""
password = ""
db = 0
[message]
# Number of workers processing outgoing message queue
outgoing_queue_workers = 10
# Number of workers processing incoming message queue
incoming_queue_workers = 10
# How often to scan for outgoing messages to process, keep it low to process messages quickly.
message_outgoing_scan_interval = "50ms"
# Maximum number of messages that can be queued for incoming processing
incoming_queue_size = 5000
# Maximum number of messages that can be queued for outgoing processing
outgoing_queue_size = 5000
[notification]
# Number of concurrent notification workers
concurrency = 2
# Maximum number of notifications that can be queued
queue_size = 2000
[automation]
# Number of workers processing automation rules
worker_count = 10
# Applies to conversation creation dates. Zero or negative values use 90 days.
time_trigger_lookback_days = 90
[ai_agent]
# Number of workers running autonomous AI assistant responses
worker_count = 10
# Maximum number of AI assistant response jobs that can be queued
queue_size = 1000
# Maximum number of tool calls the assistant can make while working on one reply. (1-20)
max_steps = 6
# Conversation messages sent to the model as history. (5-100)
max_history_messages = 30
[autoassigner]
# How often to run automatic conversation assignment
autoassign_interval = "5m"
[webhook]
# Number of webhook delivery workers
workers = 5
# Maximum number of webhook deliveries that can be queued
queue_size = 10000
# HTTP timeout for webhook requests
timeout = "15s"
# SSRF guard for admin-configured outbound URLs (webhooks, OIDC discovery, AI provider and tool calls).
[ssrf]
# Block outbound requests to private/loopback/link-local/cloud-metadata IPs.
# Leave false for self-hosted single-tenant deployments, where internal webhooks,
# OIDC providers and model endpoints on private IPs are legitimate.
# Set true for multi-tenant/hosted deployments where those URLs come from untrusted tenants.
enabled = false
# CIDR ranges allowed to bypass the guard when enabled, for reaching known internal hosts.
# Examples: ["10.0.0.0/8", "192.168.1.10/32", "172.16.0.0/12", "fd00::/8"]
allowed_cidrs = []
[conversation]
# How often to check for conversations to unsnooze
unsnooze_interval = "5m"
# How long to keep drafts before deleting them from the database. (e.g. "360h", "48h")
draft_retention_duration = "360h"
# How often to check for offline conversations in database to send continuity emails
continuity_scan_interval = "5m"
[sla]
# How often to evaluate SLA compliance for conversations
evaluation_interval = "5m"
# Per-IP rate limits (requests per minute) for unauthenticated endpoints.
# Setting enabled = false disables that rule entirely.
# Livechat widget APIs.
[rate_limit.widget]
enabled = true
requests_per_minute = 100
# Login, password reset and other auth endpoints.
[rate_limit.auth]
enabled = true
requests_per_minute = 30
# Public pages and APIs: help center, CSAT, robots.txt/sitemaps.
[rate_limit.public]
enabled = true
requests_per_minute = 100
# Public media downloads (uploaded files, avatars).
[rate_limit.media]
enabled = true
requests_per_minute = 300
+120
View File
@@ -0,0 +1,120 @@
# LibreDesk 独立实例(yehwang-ai 工作区)
# 基于官方 https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml
# 改动:镜像固定版本;宿主端口错开(app 9001 / pg 5433 / redis 6380),避免与本机已有服务冲突。
services:
# Libredesk app
app:
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0}
container_name: libredesk_app
restart: unless-stopped
ports:
- "127.0.0.1:9001:9000"
environment:
# 首次启动时设置 System 用户密码(后续可用 --set-system-user-password 修改)
LIBREDESK_SYSTEM_USER_PASSWORD: ${LIBREDESK_SYSTEM_USER_PASSWORD:-}
# v2.9.1 起 /health 会在数据库不可用时返回非 200
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9000/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- libredesk
depends_on:
- db
- redis
volumes:
- ./uploads:/libredesk/uploads:rw
- ./config.toml:/libredesk/config.toml:ro
command: [sh, -c, "./libredesk --install --idempotent-install --yes --config /libredesk/config.toml && ./libredesk --upgrade --yes --config /libredesk/config.toml && ./libredesk --config /libredesk/config.toml"]
# HTTPS 终止反代:libredesk 的 Agent WebSocket 只允许 https 或 localhost 的 Origin,
# 用 http://<局域网IP>:9001 访问后台会一直 “Connecting...”,故加这层 TLS。
tls:
image: nginx:alpine
container_name: libredesk_tls
restart: unless-stopped
ports:
- "9443:443"
volumes:
- ./tls/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./tls/cert.pem:/etc/nginx/certs/cert.pem:ro
- ./tls/privkey.pem:/etc/nginx/certs/privkey.pem:ro
depends_on:
- app
networks:
- libredesk
# 业务工具服务:从 crm-new/bridge 裁剪,只保留 4 个 AI 工具(商品/订单/库存/客户资料)
bridge-tools:
build: ./bridge-tools
container_name: libredesk_bridge_tools
restart: unless-stopped
environment:
TOOLS_PORT: 4100
TOOLS_PUBLIC_BASE_URL: http://bridge-tools:4100
TOOLS_SHARED_KEY: ${BRIDGE_TOOLS_KEY}
TOOLS_DEV_BYPASS: "false"
RECOMMEND_URL: ${RPC_RECOMMEND_URL}
ORDER_API_URL: ${RPC_ORDER_URL}
INVENTORY_API_URL: ${RPC_INVENTORY_URL}
MEMBER_RPC_URL: ${RPC_MEMBER_URL}
RPC_TIMEOUT_MS: 8000
PRODUCT_TIMEOUT_MS: 30000
SITE_SEARCH_URL: https://www.yehwang.com/search.html?q=
DEFAULT_TENANT_ID: 2
DEFAULT_LANGUAGE_ID: 1
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
# 仅本机调试用,LibreDesk 通过容器名 bridge-tools:4100 访问
- "127.0.0.1:4101:4100"
depends_on:
- app
networks:
- libredesk
# PostgreSQL database
db:
# 本机 docker.io 拉取 postgres:17-alpine 持续失败,改用已缓存的 PG18 基础镜像(与 crm-new 同款,libredesk 已验证兼容)
image: ${POSTGRES_IMAGE:-pgvector/pgvector:pg18}
container_name: libredesk_db
restart: unless-stopped
networks:
- libredesk
ports:
# 仅本机可连,避免与本机 5432 冲突
- "127.0.0.1:5433:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-libredesk}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk}
POSTGRES_DB: ${POSTGRES_DB:-libredesk}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredesk} -d ${POSTGRES_DB:-libredesk}"]
interval: 10s
timeout: 5s
retries: 6
volumes:
# PG18 官方镜像要求挂载 /var/lib/postgresql(数据在子目录)
- postgres-data:/var/lib/postgresql
# Redis
redis:
image: redis:7-alpine
container_name: libredesk_redis
restart: unless-stopped
networks:
- libredesk
ports:
# 仅本机可连,避免与本机 6379 冲突
- "127.0.0.1:6380:6379"
volumes:
- redis-data:/data
networks:
libredesk:
volumes:
postgres-data:
redis-data:
+133
View File
@@ -0,0 +1,133 @@
# crm-new vs 本实例(yehwang-ai/libredesk)差距与补充方案
> 生成时间:2026-10-28 对比基准:crm-new `docker-compose.yml` + 运行中的 `crmnew-*` 容器 + `crmnew-postgres/libredesk` 库实际数据
> 本实例:`/home/dev/code/yehwang-ai/libredesk`(LibreDesk v2.9.1 + PG18 + Redis + nginx TLS)
## 0. 一句话结论
本实例目前只是「**能登录的 LibreDesk 空壳**」(0 渠道、0 AI 助手、0 工具、0 知识库、0 自动化),
crm-new 是一条**完整跑通的 AI 客服业务链**(三渠道 + bridge 工具 + Twenty CRM + 公网入口 + 一大套配置脚本与上线文档)。
差的不是「几个开关」,而是**整条链的组件、配置、凭据和运维资产**。
## 1. 能力矩阵(均为实测,不是看代码猜的)
| 能力 | crm-new | 本实例 | 缺口 |
| --- | --- | --- | --- |
| 容器编排 | LibreDesk + Twenty(server/worker) + bridge + imap-proxy + public-gateway + cloudflared + PG + Redis(9 个) | LibreDesk + PG + Redis + nginx TLS(4 个) | Twenty、bridge、imap-proxy、public-gateway、cloudflared |
| 渠道 inbox | **3 个**:Website(livechat) / Email 163 / WhatsApp | **0** | 全部 |
| 邮件通知 | `notification.email.*`:smtp.163.com:465 TLS,已启用 | 无 | 全部 |
| Live chat | inbox + widget + 身份锚定方案(SITE_JWT / IDENTITY_*,bridge 侧) | 无 | 全部 |
| WhatsApp | inbox + 6 个模板 + Meta Cloud API 凭据 + 回调 | 无 | 全部 |
| AI provider | 2 个已配:completion=`qwen-plus-latest`(DashScope, openai 兼容)、embedding=`text-embedding-v4` | 2 行**空壳**默认记录,api_key 为空 | 填 key/模型/默认项 |
| AI assistant | 1 个(B2B 助手;max_turns=8;handoff 开;guardrails/expectation/tone) | 0 | 全部 |
| AI tools | **11 个** HTTP 工具:search_products / search_orders / search_inventory / search_kb / search_email_history / collect_info / send_verification_code / verify_email_code / handoff_to_human / create_opportunity / get_customer_info | 0 | 全部 |
| 知识库 | 帮助中心 1 个(`yehwang-help`)+ **108 篇已发布文章**;bridge 侧 pgvector RAG(快照 594 chunks) | 0 | 全部 |
| 自动化规则 | 3 条:AI 分配、邮件→Wholesale、WhatsApp→Wholesale | 0 | 全部 |
| 团队/坐席 | 1 个 team(Wholesale Support,时区 Asia/Shanghai)+ 3 agent + 2 角色;库内 27 用户(含 contact/visitor 历史) | 1 个用户(System)+ 2 个默认角色 | 团队、坐席、角色细分 |
| Webhook | 1 条:Bridge AI → `http://bridge:4100/libredesk/webhook` | 0 | 全部 |
| 公网入口 | public-gateway(只放 WhatsApp webhook + 帮助中心)+ cloudflared quick tunnel;root_url 指向临时 trycloudflare 域名(隧道重启即变,不可当长期入口) | nginx 自签 TLS(仅内网 9443);`root_url=http://localhost:9000` | 域名、证书、白名单反代 |
| 数据库 | PG 3 库(libredesk / twenty / bridge)+ pgvector | 单库 libredesk | 若接 Twenty/bridge 需建库 |
| 网络 | 额外接入外部网络 `spug-net`,bridge 直连 yehwang RPC(9503/9505/9506/9509) | 独立 bridge 网络 | 网络互联 |
| 配置脚本 | bootstrap.sh、render-config.js、configure-ai/email、add-email-inbox、add-whatsapp-inbox、register-tools、import-kb-helpcenter、set-public-url、set-wa-webhook、smoke.sh、e2e-*、eval-*、kb-* 等约 25 个 | 无 | 全部 |
| 文档 | architecture / api-mapping / environments / go-live / runbook / human-routing / libredesk-setup / migration-plan / plan-a / deploy-wangliang | 只有一个 README | 上线、运维、切流文档 |
| 备份 | runbook 有 pg_dump / Twenty 文件存储 / KB 快照命令 | 只有我这次升级时手工做的两次 dump,**无定时任务** | 定时备份 + 恢复演练 |
**基础配置本身就有错**(两边都有,但本实例更明显):
- 本实例 `app.root_url = http://localhost:9000`(邮件/帮助中心链接会指向 localhost)
- 本实例 `app.timezone = Africa/Cairo`(荷兰业务差 1 小时)
- crm-new 的 `app.timezone = ""`、`root_url` 是已失效的临时隧道域名
## 2. 建议补充方案(分 6 期,按依赖排序)
### P0 正确性与安全(约 0.5h,先做)
| 事项 | 说明 |
| --- | --- |
| root_url 改为 `https://192.168.31.61:9443` | 影响邮件通知、帮助中心、widget 链接 |
| timezone 改 `Europe/Berlin`(UI)或 `Europe/Amsterdam`(API) | 两者规则等价,见时区分析 |
| 建个人管理员账号,改掉 System 密码 | 避免共用超级账号 |
| 备份脚本落地(pg_dump + uploads,保留 7/30 天)+ 恢复演练一次 | 目前没有定时备份 |
| (可选)`9001` 只监听 `127.0.0.1` | 减少明文暴露面 |
验收:设置页不再出现 localhost/Cairo;备份能被 `pg_restore -l` 读出;个人账号可登录。
### P1 渠道最小闭环(约 1–2h)
1. **Live Chat inbox + widget**(Website 渠道),把这套的 widget 代码嵌到目标站点;
2. **Email inbox**:先定用哪个邮箱 —— 163 需要 imap-id-proxy(LibreDesk 不发 IMAP ID);建议换企业邮箱/自建 SMTP 省掉代理;
3. 配置 `notification.email.*`(OTP/通知邮件走同一 SMTP);
4. 建 **Team**(Wholesale Support)+ 分配规则(邮件/WhatsApp 转人工);
5. 建 **Help Center**,导入 crm-new 的 108 篇文章(可参考 `import-kb-helpcenter.js`)。
验收:widget 能落会话;邮件收发一轮打通;帮助中心可搜索;新会话按规则进人工团队。
### P2 AI 能力(约 2–4h)
1. 填 AI provider(复用 crm-new 的 DashScope key,或新申请),确认默认 provider;
2. 配 AI assistant(instructions / guardrails / handoff / languages / max_turns);
3. 知识库二选一:
- **A**:用 LibreDesk 内置 KB/snippets + embedding(新栈自包含,但要重新灌数据);
- **B**:沿用 bridge 的 pgvector RAG(数据现成,但依赖 bridge 运行);
4. Live Chat 开启 AI 接管,跑 preview/retrieval 验证与转人工。
验收:AI 回答能引用知识库;越界时按 guardrails 转人工;有可复跑的检索评测。
### P3 业务工具 + CRM(约 0.5–1d,取决于复用方式)
三条路线(需拍板):
- **A. 复用 crm-new 的 bridge**:新实例 webhook 指向同一个 `crmnew-bridge:4100`,工具注册表复制一份;改动最小,但两套环境共享 bridge/数据;
- **B. 新建 bridge 实例**:复制代码 + 独立 `.env`(RPC 地址、DashScope、SMTP、Redis 库分开),隔离好但维护两份;
- **C. 只接 Twenty CRM**:不做商品/订单工具,仅联系人/商机同步,工作量最小。
再补:注册 11 个工具(`register-tools.js` 适配新实例 API key)、OTP 邮件、输出护栏与质量指标(bridge 自带,复用即得)。
验收:搜商品/查订单/建商机一条 e2e 跑通。
### P4 公网渠道(约 0.5–1d,依赖域名/凭据)
1. 固定域名 + Let's Encrypt(推荐)或 Cloudflare 命名隧道;**quick tunnel 不可持续**(crm-new 的 root_url 就是临时域名,cloudflared 一重启就变);
2. WhatsApp:Meta Cloud API 凭据(WABA/phone number/token/verify token)+ 回调地址 + 模板;
3. 复用 public-gateway 白名单思路(只暴露 WhatsApp webhook + 帮助中心,其余 404);
4. root_url 与帮助中心域名统一。
验收:Meta 回调校验通过;公网只可达白名单路径;帮助中心/客服入口 HTTPS 正常。
### P5 运维与上线(约 2–4h)
- smoke.sh / e2e 脚本适配新实例;健康检查(已有 `/health`)+ 日志/告警;
- 定时备份 + 恢复演练纳入 runbook;
- 上线文档(范围、切流、验收、回滚)——可沿用 crm-new 的 go-live/runbook 结构;
- 版本策略:跟随稳定版(当前 v2.9.1),AI/WhatsApp 属较新功能,建议低流量验证。
## 3. 需要你拍板的决策点
1. **本实例定位**:独立演示/联调环境,还是将来生产?(决定域名、证书、备份等级)
2. **和 crm-new 的关系**:并存做隔离验证,还是最终替代它?
3. **邮件渠道**:继续 163(要 imap-proxy),还是换正式邮箱?
4. **AI key**:复用 crm-new 的 DashScope key,还是新申请?知识库走 LibreDesk 内置还是 bridge RAG?
5. **Twenty / bridge**:复用现有实例(A 路线),还是新建(B 路线),或只接 CRM(C 路线)?
6. **公网**:有没有可用域名?走 Cloudflare 隧道还是自有 nginx + 证书?
7. **多站点**:是否要搬 crm-new 的 `sites.json` 多站点路由(www / us + 商品搜索兜底)?
## 4. 不建议照搬的部分
- 旧栈 `crm/`(chatwoot / twenty:flywheel)不要动;
- crm-new 的 quick tunnel 与临时域名(不可持续);
- 全量商品页 sitemap(www 32,691 / us 22,625 URL)——除非要做商品搜索兜底;
- 27 个历史用户(含 visitor/contact 测试数据)——新实例只建必要坐席即可。
## 5. 风险提示
- **163 IMAP 限制**:必须靠 imap-id-proxy 才能 SELECT,换邮箱可去掉这层;
- **凭据复制**:把 crm-new 的 key/token 复制到新实例前先确认责任边界,建议新实例用独立凭据;
- **两套并存**:本机已跑 20+ 容器(内存 14G,当前用 6G),再加 Twenty 一套会明显吃资源,需先规划;
- **RC/beta 功能**:WhatsApp、AI 相关功能较新,上线前跑一轮 e2e;
- **公网入口**:没有固定域名则 WhatsApp 回调无法长期用。
## 6. 推荐路径
先做 **P0 + P1(半天内)** 得到「人工渠道 + 帮助中心」可用闭环;
确认本实例定位后,再决定 P2 AI 是否立刻上、P3/P4 是否复用 crm-new 资源。
不建议一上来复制整套栈——两套并存的维护成本和数据一致性风险不划算。
+40
View File
@@ -0,0 +1,40 @@
# Live Chat 端到端验证(2026-10-28)
环境:LibreDesk v2.9.1(https://192.168.31.61:9443)+ bridge-tools(4 个 AI 工具)
Inbox:Website(livechat,uuid `81b679b0-33fb-4796-9f83-dcc0f43231cc`)
自动化:新会话 → 分配给 Yehwang Assistant(user_id=2)
## 验证流程与结果
| 步骤 | 结果 |
| --- | --- |
| 1. widget 匿名发起会话(`POST /api/v1/widget/chat/conversations/init`,第一条消息=退货政策) | 200,会话创建 |
| 2. 自动化规则触发 | 会话 assigned_user_id=2(Yehwang Assistant) |
| 3. AI 自动回复 | 约 5 秒返回,内容与知识库一致(非质量问题不退,质量问题 7 天内),带 sources |
| 4. 访客问订单状态 | AI 要求邮箱验证 |
| 5. 访客给邮箱 | AI 调用 `set_contact_email` + `send_email_verification`(OTP 走 163 SMTP) |
| 6. 从 Redis 取码(`ai:otp:pending:<uuid>`)并回复 | AI 调用 `check_email_verification`,验证通过 |
| 7. 重试 `search_orders` | bridge-tools 日志:`tool=search_orders verified=true ok=true`;回复「没有找到该邮箱的订单」 |
| 8. 会话状态 | Open,contact email 已更新 |
## 发现的问题(已处理)
在第 4 步之前,模型曾自行声称「已把验证码发到 example@example.com」——这是一个**编造的邮箱和编造的动作**(当时联系人的邮箱为空)。
已在 assistant instructions 里加护栏:
> Never invent or assume the customer's email address: ask for it. Only say a verification code or email was sent after the send_email_verification tool succeeds.
仍建议上线前用真实客户跑一轮回归,重点看「无邮箱时是否会索要邮箱」以及「是否只在工具成功后声称已发送」。
## 复现命令
widget 匿名发起:
```bash
curl -sk -X POST https://localhost:9443/api/v1/widget/chat/conversations/init \
-H 'Host: 192.168.31.61:9443' -H 'Content-Type: application/json' \
-H 'X-Libredesk-Inbox-ID: 81b679b0-33fb-4796-9f83-dcc0f43231cc' \
-d '{"message":"What is your return policy?"}'
```
发送后续消息需 `Authorization: Bearer <session_token>` + `X-Libredesk-Inbox-ID`。
+147
View File
@@ -0,0 +1,147 @@
# 方案:bridge 只保留 tools,其余用 LibreDesk 内置能力
> 2026-10-28 评估。依据:LibreDesk v2.9.1 源码(`internal/aiagent/`、`internal/ai/tools.go`、`schema.sql`)+ crm-new 运行数据。
> 结论:**可行**,而且 LibreDesk 内置能力比预期多——bridge 可以从 11 个工具收缩到 4–6 个业务工具。
## 0. 已确认的决策(2026-10-28)
| 决策点 | 结论 |
| --- | --- |
| bridge-tools 部署 | **方案 A**:并入本实例 compose(服务名 `bridge-tools`,同 `libredesk` 网络) |
| `create_opportunity`(Twenty) | **先不留** |
| `search_email_history` / `collect_info` | **先删** |
| 程序化输出护栏 | **先接受降级**(后续用 LibreDesk webhook 外采质量指标) |
| `get_customer_info` | **保留**(业务需要) |
| 保留工具清单 | `search_products` / `search_orders` / `search_inventory` / `get_customer_info` |
## 1. 关键证据:LibreDesk v2.9.1 已经内置了什么
AI agent 每次回复时自动注册的内置工具(`internal/aiagent/worker.go:290-314`):
| 内置工具 | 作用 | 对应 crm-new bridge 的什么 |
| --- | --- | --- |
| `search_knowledge_base` | 检索 snippets + 帮助中心文章(向量),支持引用计数 | `search_kb`(bridge 的 pgvector RAG) |
| `handoff`(`handoff_enabled`) | 转人工(`fallback_team_id`) | `handoff_to_human` |
| `get_previous_conversations` | 拉该客户近 N 天其他会话 | `search_email_history`(部分覆盖) |
| `send_email_verification` | 发 OTP 到联系人邮箱(走通知邮箱 SMTP;Redis 存码,10 分钟有效,30 分钟验证态) | `send_verification_code` |
| `check_email_verification` | 校验 OTP,3 次尝试上限、重发上限 | `verify_email_code` |
| `set_contact_email` | 访客改邮箱后再发码 | (bridge 无对应,新增能力) |
| `resolve` | 结束时给会话打结果 | 无 |
其他内置:
- **工具调用上下文自动注入**(`internal/ai/tools.go:250-270`):`X-Libredesk-Contact-Id` / `-External-Id` / `-Type` / `-Email` / `-Verified` / `X-Libredesk-Conversation-UUID` / `X-Libredesk-Inbox-Id` —— bridge 的多站点路由(sites.json)和身份判断可以直接用这些头,不再需要自己发验证 token。
- **工具级验证开关** `ai_tools.requires_verification`:不验证的客户调私有数据工具会被拦,并引导模型走内置 OTP 流程(`tools.go:186`)。
- **工具鉴权** `ai_tools.auth`(自定义 header,支持加密存储):bridge 的 shared key 直接配在工具上。
- **AI 助手配置**:instructions / guardrails / expectation / tone / response_length / max_turns / fallback_team_id / handoff_enabled / citations_enabled / languages / enabled。
- **知识库**:`ai_knowledge_base`(snippet 类)+ 帮助中心文章都会作为 embedding 源(`internal/ai/embedsource.go`)。
- **Widget 身份**:inbox secret 的 JWT 校验(`cmd/chat.go`),站点后端签 JWT 即可,不需要 bridge 的 identity 服务。
- **通知邮件 / OTP 发信**:`notification.email.*`(crm-new 里就是 163 SMTP)。
## 1.5 落地进度
| 期 | 状态 | 说明 |
| --- | --- | --- |
| P0 root_url / timezone | ✅ 完成 | `root_url=https://192.168.31.61:9443`、`timezone=Europe/Berlin`(API 已写入并回读) |
| P0 SMTP / embedding key | ✅ 完成 | 复用 crm-new 的 163 SMTP(smtp.163.com:465 TLS)和 DashScope key;completion 实际用 **qwen-max**(见下) |
| P2 瘦身 bridge | ✅ 完成 | `bridge-tools/`(零第三方依赖,4 个工具,9 个单测通过),已并入 compose 并 healthy |
| P3 工具注册 | ✅ 完成 | LibreDesk `ai_tools` 已注册 4 个,`search_orders`/`get_customer_info` 带 `requires_verification` |
| P1 帮助中心/assistant | ✅ 完成 | Team「Wholesale Support」+ 帮助中心(108 篇,已嵌入)+ Assistant「Yehwang Assistant」(4 个工具、fallback team、handoff/citations 开);预览已能基于文章回答并返回 sources |
| P4 渠道 | 🟡 部分完成 | **Website livechat 已建 + 自动化分配到 Assistant + 端到端验证通过**(含 OTP → search_orders);email / WhatsApp 待做 |
### ⚠️ 模型选择的关键发现
`qwen-plus-latest` / `qwen-plus` 在 LibreDesk 这套较长的 system prompt 下**不会调用工具**(实测 tool_calls=0),直接凭模型自身知识作答,还会编造 `[[cite:45]]` 这种不存在的引用(第一次预览回答「30 天退货」,而知识库实际是「非质量问题不退,质量问题 7 天内」)。
换成 **`qwen-max`** 后工具调用正常(sources 命中 5 篇,score 0.33–0.61),回答与知识库一致。因此 completion 配置为 `qwen-max`;如果以后换模型,务必用「退货政策/预购时效」这类问题验证是否会调用 `search_knowledge_base`。
## 2. bridge 收缩后保留什么
| crm-new 现有工具 | 处理 | 说明 |
| --- | --- | --- |
| `search_products` | **保留** | 业务 RPC 商品搜索,LibreDesk 无替代 |
| `search_orders` | **保留**(`requires_verification=true`) | 私有数据,用内置 OTP 把关 |
| `search_inventory` | **保留** | 库存/ETA |
| `get_customer_info` | 保留(可选) | 若 Twenty 客户资料要查;也可并进 orders |
| `create_opportunity` | 保留(可选) | Twenty 商机写入,LibreDesk 无替代;不做 CRM 则去掉 |
| `search_kb` | **删除** | 用内置 `search_knowledge_base`;108 篇帮助文章已在 LibreDesk 里 |
| `search_email_history` | 先删 | 内置 `get_previous_conversations` 覆盖「本系统内历史」;旧邮件库不再接入 |
| `collect_info` | 先删(可选保留) | lead 槽位采集改为 assistant 指令 + 联系人自定义属性;要做销售漏斗再保留 |
| `handoff_to_human` | **删除** | 内置 handoff + fallback_team_id |
| `send_verification_code` | **删除** | 内置 OTP 工具 |
| `verify_email_code` | **删除** | 内置 OTP 工具 |
**结果:bridge = 一个只暴露 3–5 个 HTTP 工具的瘦服务**(+ 多站点配置、RPC 客户端、Twenty 客户端、shared key 鉴权、健康检查)。
## 3. 明确会被「降级」的能力(要接受或后补)
| 现有能力 | 用内置后的状态 | 建议 |
| --- | --- | --- |
| 程序化输出护栏(URL 白名单、价格/库存数字校验、幻觉重写) | **没有等价物**;只有 assistant 的 `guardrails` 提示词约束 | 先接受(低流量验证);需要时用 LibreDesk webhook → 独立质量服务补 |
| 质量飞轮 / 指标 / 日报 / 引用检查 / A-B | 无等价物;LibreDesk 只有基础报表 | 后续用 webhook 采集到外部(bridge 加一个 consumer,不做实时改回复) |
| 旧 bridge Hybrid RAG(parent-child + 向量 + 全文 + RRF + rerank) | 退化为向量 top-k(snippets + 文章) | 用真实问题跑检索评测对比;不满意可把 snippet 切细或补 FAQ |
| 邮件历史检索(旧 `kb_mail_docs`) | 只覆盖 LibreDesk 内会话 | 需要历史邮件时再做离线 ingest |
| lead 槽位(Redis 跨轮) | 没有 | 用 assistant instructions + 自定义属性;或保留 `collect_info` 工具 |
## 4. 实施分期
### P0 前置(0.5h)
- 配置 `notification.email.*`(OTP + 通知都依赖它,必须有可用 SMTP);
- AI provider:completion + **embedding**(内置 KB 检索依赖 embedding,必须配);
- 修 `root_url`、`timezone`(见时区方案)。
### P1 LibreDesk 内置能力配置(2–4h)
1. 帮助中心 + 108 篇文章(从 crm-new 导出后导入,或按原源重灌);
2. 需要时补 snippets(FAQ 短答案);
3. AI assistant:instructions / guardrails / tone / max_turns / languages / citations / fallback_team_id / handoff_enabled;
4. 团队(Wholesale Support)+ 自动化规则(AI 分配、邮件/WhatsApp 转人工)。
验收:AI 能基于帮助中心回答并带引用;越界/复杂问题按 fallback_team 转人工;OTP 流程能发码、验码(先用 livechat 访客验证)。
### P2 瘦身 bridge(0.5–1d)
- **新建目录 `bridge-tools/`(从 `crm-new/bridge` 裁剪,不改 crm-new 本身)**,保留:
`server.js`(只留工具路由 + healthz)、`tools.js`(裁剪)、`rpc.js`、`product-query.js`、`sitemap.js`、`opportunity.js` / `twenty.js`(若保留)、`redis.js`、`auth.js`、`config.js`、`security.js` + 对应测试;
- 删除:agent.js、prompts.js、llm*.js、rag.js、kb-*.js、embedding.js、reranker.js、verification.js、smtp.js、identity.js、jwt.js、libredesk-webhook.js、metrics/quality/citation-checker/output-guard/flywheel/audit/experiments/trace 等;
- 依赖精简:PG 可去掉(若不做 lead/质量库),Redis 保留(RPC 缓存/限流);
- 单测裁剪后必须 `npm test` 通过。
验收:`/healthz` 正常;5 个工具逐个用 curl + shared key 调通;容器内存/镜像明显变小。
### P3 工具注册到 LibreDesk(2–4h)
- 适配 `register-tools.js`:URL 指向 `bridge-tools:4100`,`auth` 带 shared key header;
- `search_orders` / `create_opportunity` 设 `requires_verification=true`;
- 绑定 assistant 的 tool allowlist;
- 多站点:`X-Libredesk-Inbox-Id` 映射 sites.json(不需要改协议,LibreDesk 已经带)。
验收:livechat 里问「查订单」→ 触发 OTP → 验码后返回订单;问商品 → 返回价格/库存/链接。
### P4 渠道(2–4h)
- Live Chat inbox + widget(站点用 inbox secret 签 JWT 做身份锚定);
- Email inbox(决定用 163 走 imap-proxy,还是换企业邮箱直连);
- WhatsApp(依赖公网域名/隧道,单独排期)。
### P5 公网/运维/质量(按需)
- 固定域名 + 证书 + public-gateway 白名单;
- 备份定时化、监控、smoke/e2e、上线文档;
- 若需要质量护栏:LibreDesk webhook → 轻量质量服务(不改 AI 回复,只记指标)。
## 5. 风险与前提
- **SMTP 必须可用**:内置 OTP 依赖通知邮箱,livechat 访客验证走它;没有就会卡住「查订单」这类工具。
- **embedding provider 必须配**:内置 KB 检索是向量检索,缺 key 时 `search_knowledge_base` 不可用。
- **RPC 网络**:bridge-tools 仍需要访问 yehwang RPC(crm-new 是靠外部网络 `spug-net`),新 compose 要接同一网络或走隧道。
- **内置 OTP 的边界**:只对「email 渠道会话」或「非 contact 类型联系人(访客)」提供;已存在且非访客的 contact 不会自动有 OTP 工具(`worker.go:308`),要确认业务场景。
- **检索质量下降**:内置 top-k vs 旧 hybrid;上线前用 20–30 个真实问题对比命中率。
- **两套并存**:bridge-tools 与 crm-new bridge 可共用 RPC/Redis 配置,但建议独立 redis db 和独立 shared key。
## 6. 需要拍板
1. `create_opportunity` / Twenty 商机要不要保留?(决定 bridge 留 3 个还是 5 个工具)
2. `search_email_history`、`collect_info` 先删还是保留?
3. 程序化输出护栏:先接受降级,还是一开始就按 webhook 方案补?
4. bridge-tools 部署位置:同一台机新 compose,还是并到本实例 compose?
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# LibreDesk 定时备份:Postgres dump + uploads + 密钥配置,保留 14 天。
# 用法:./scripts/backup.sh
# cron:0 3 * * * cd /home/dev/code/yehwang-ai/libredesk && ./scripts/backup.sh >> logs/backup.log 2>&1
set -euo pipefail
cd "$(dirname "$0")/.."
TS="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="backups"
LOG_PREFIX="[backup $(date -Is)]"
mkdir -p "$BACKUP_DIR" logs
DB_FILE="$BACKUP_DIR/db-$TS.dump"
UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tar.gz"
CONFIG_FILE="$BACKUP_DIR/config.toml.$TS"
ENV_FILE="$BACKUP_DIR/env.$TS"
POSTGRES_USER="${POSTGRES_USER:-libredesk}"
POSTGRES_DB="${POSTGRES_DB:-libredesk}"
echo "$LOG_PREFIX start"
# 1) 数据库(custom format,可直接 pg_restore)
docker compose exec -T db pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB" > "$DB_FILE"
# 2) 上传文件
tar -czf "$UPLOADS_FILE" -C . uploads
# 3) 配置文件(含加密密钥 / DB 密码,保持 600)
cp -p config.toml "$CONFIG_FILE"
cp -p .env "$ENV_FILE"
chmod 600 "$CONFIG_FILE" "$ENV_FILE"
# 4) 校验 dump:先看 PGDMP 头,再用 pg_restore 读出 TOC(容器内 bind mount,避免 stdin 不可 seek)
if [ "$(head -c 5 "$DB_FILE")" != "PGDMP" ]; then
echo "$LOG_PREFIX ERROR: dump 文件头不是 PGDMP:$DB_FILE" >&2
exit 1
fi
TOC_COUNT="$(docker run --rm -v "$PWD/$BACKUP_DIR:/b:ro" "${VERIFY_IMAGE:-pgvector/pgvector:pg18}" pg_restore -l "/b/$(basename "$DB_FILE")" | grep -cE '^[0-9]+;' || true)"
if [ "${TOC_COUNT:-0}" -lt 100 ]; then
echo "$LOG_PREFIX ERROR: dump 校验失败(TOC 条目=$TOC_COUNT),保留文件供排查:$DB_FILE" >&2
exit 1
fi
# 5) 保留 14 天
find "$BACKUP_DIR" -maxdepth 1 -type f \
\( -name 'db-*.dump' -o -name 'uploads-*.tar.gz' -o -name 'config.toml.*' -o -name 'env.*' \) \
-mtime +14 -delete
echo "$LOG_PREFIX ok db=$(du -h "$DB_FILE" | cut -f1) uploads=$(du -h "$UPLOADS_FILE" | cut -f1) toc=$TOC_COUNT"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# 确保 yw-dev DB 隧道在线(bridge-tools 依赖它)。
# 幂等:隧道已完整转发时直接退出;否则调用 yw-db-tunnel start 并记日志。
# cron:@reboot sleep 60; .../ensure-tunnel.sh
# */5 * * * * .../ensure-tunnel.sh
set -uo pipefail
export PATH="/home/dev/.local/bin:/usr/local/bin:/usr/bin:/bin"
TUNNEL="/home/dev/.local/bin/yw-db-tunnel"
LOG="/home/dev/code/yehwang-ai/libredesk/logs/tunnel.log"
mkdir -p "$(dirname "$LOG")"
status_out="$("$TUNNEL" status 2>/dev/null || true)"
if printf '%s' "$status_out" | grep -q '隧道进程: 运行中' \
&& ! printf '%s' "$status_out" | grep -q '未转发'; then
exit 0
fi
echo "[$(date -Is)] tunnel down, starting" >> "$LOG"
if "$TUNNEL" start >> "$LOG" 2>&1; then
echo "[$(date -Is)] tunnel ok" >> "$LOG"
exit 0
fi
echo "[$(date -Is)] tunnel start FAILED (see log above)" >> "$LOG"
exit 1
+44
View File
@@ -0,0 +1,44 @@
# LibreDesk 局域网 HTTPS 反代(9443)
# 原因:libredesk v2.9.x 的 Agent WebSocket 只接受 https 或 localhost 的 Origin,
# 用 http://<局域网IP>:9001 打开后台,WS 会被 403 拒绝,界面一直 “Connecting...”。
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
# 基础安全头(自签证书,不启用 HSTS)
ssl_protocols TLSv1.2 TLSv1.3;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# X-Frame-Options 由应用自身设置(SAMEORIGIN),避免重复
client_max_body_size 100M;
location / {
proxy_pass http://app:9000;
proxy_http_version 1.1;
# WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 保留原始 Host(含 9443 端口),libredesk 的 WS Origin 校验依赖它
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
# libredesk 用 X-Client-IP 做限流/审计,必须设置
proxy_set_header X-Client-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# 长连 WS 不要过早断开
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}