Initial commit: CRM AI Demo bridge + KB knowledge base + infrastructure
- Bridge server with LLM agent tool-use (search_kb, search_products, search_orders, search_inventory, escalate_human) - pgvector RAG knowledge base (95 FAQ chunks) - Auto opportunity creation in Twenty CRM - Auto follow-up task workflow - Chatwoot AgentBot integration (HMAC, webhook) - Docker compose infrastructure (PG18, Redis 8.8, node24-alpine) - Configuration templates (example files) - real secrets excluded via .gitignore
This commit is contained in:
11
bridge/Dockerfile
Normal file
11
bridge/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
# Chatwoot -> LLM -> Twenty CRM 销售桥接
|
||||
# 纯 Node 内置模块 + sharp(图片缩放),需 Node 18+(内置 fetch / FormData / Blob)
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY server.js index_kb.js register_agent_bot.js ./
|
||||
COPY chatwoot.config.json llm.config.json twenty.config.json kb.config.json ./
|
||||
ENV PORT=4000
|
||||
EXPOSE 4000
|
||||
CMD ["node", "server.js"]
|
||||
9
bridge/chatwoot.config.example.json
Normal file
9
bridge/chatwoot.config.example.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"baseURL": "http://crm-chatwoot-rails:3000",
|
||||
"accountId": 1,
|
||||
"accessToken": "YOUR_CHATWOOT_ACCESS_TOKEN",
|
||||
"inboxId": 1,
|
||||
"websiteToken": "j2p6riinXmVWjtmL5dHotf3Z",
|
||||
"webhookId": 1,
|
||||
"webhookURL": "http://crm-bridge:4000/chatwoot-webhook"
|
||||
}
|
||||
206
bridge/index_kb.js
Normal file
206
bridge/index_kb.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// 客服知识库向量化:读取 customer-service-kb/*.md,按 "### Q:" 问答块切分,
|
||||
// 用 SiliconFlow Qwen3-Embedding-0.6B(1024维) 生成向量,存入共享 postgres
|
||||
// products 库的 kb_docs 表,供 bridge 做 RAG 检索回答客户政策类问题。
|
||||
//
|
||||
// 切块策略(遵循知识库 README 建议):
|
||||
// - 每个文件先解析 YAML frontmatter(title/category/keywords/source)
|
||||
// - 每个 "### Q: ..." 到下一个 "### Q:" 之间为一个 chunk(含问题+答案)
|
||||
// - embedding 文本 = keywords + Q + A,提升召回
|
||||
// - 额外保留 title/category 便于过滤/引用
|
||||
//
|
||||
// 运行: node index_kb.js (PG_URL 默认 postgres://postgres:postgres@localhost:5432/products)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const EMB = JSON.parse(fs.readFileSync(path.join(__dirname, 'kb.config.json'), 'utf8'));
|
||||
const MODEL = EMB.model || 'text-embedding-v4';
|
||||
const DIM = EMB.dim || 1024;
|
||||
const PG_URL = process.env.PG_URL || 'postgres://postgres:postgres@localhost:5432/products';
|
||||
const pool = new Pool({ connectionString: PG_URL });
|
||||
|
||||
const KB_DIR = path.join(__dirname, '..', 'customer-service-kb');
|
||||
|
||||
// ---------- 解析 frontmatter(支持多行 YAML 数组,如 source_url) ----------
|
||||
function parseFrontmatter(text) {
|
||||
const m = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, body: text };
|
||||
const meta = {};
|
||||
const fm = m[1].split('\n');
|
||||
for (let i = 0; i < fm.length; i++) {
|
||||
const kv = fm[i].match(/^(\w+):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
const key = kv[1];
|
||||
let v = kv[2].trim();
|
||||
if (v.startsWith('[')) {
|
||||
// 单行数组 [a, b, c]
|
||||
v = v.replace(/[\[\]]/g, '').split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
||||
} else if (v === '') {
|
||||
// 多行数组:后续缩进的 - 开头行
|
||||
const arr = [];
|
||||
while (i + 1 < fm.length && /^\s+-\s+/.test(fm[i + 1])) {
|
||||
i++;
|
||||
let item = fm[i].replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
|
||||
if (item) arr.push(item);
|
||||
}
|
||||
v = arr;
|
||||
} else {
|
||||
v = v.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
meta[key] = v;
|
||||
}
|
||||
return { meta, body: m[2] };
|
||||
}
|
||||
|
||||
// ---------- 按 Q: 切块 ----------
|
||||
function splitChunks(body) {
|
||||
// 去掉文件顶部的 "> 引用块"(章节说明),但保留答案里的内容
|
||||
const lines = body.split('\n');
|
||||
const chunks = [];
|
||||
let cur = null;
|
||||
let heading = null; // 文件内 "## " 一级标题(章节名)
|
||||
for (const line of lines) {
|
||||
const h2 = line.match(/^##\s+(.*)$/);
|
||||
if (h2) { heading = h2[1].trim(); continue; }
|
||||
const q = line.match(/^###\s+Q:\s*(.*)$/);
|
||||
if (q) {
|
||||
if (cur) chunks.push(cur);
|
||||
cur = { question: q[1].trim(), answer: '', section: heading || '' };
|
||||
} else if (cur) {
|
||||
cur.answer += line + '\n';
|
||||
}
|
||||
}
|
||||
if (cur) chunks.push(cur);
|
||||
// 清理答案首尾空白
|
||||
return chunks.map((c) => ({ ...c, answer: c.answer.trim() }));
|
||||
}
|
||||
|
||||
// 章节 → 来源 URL 映射:dropshipping KB 各章节对应不同 faq 子页,
|
||||
// 避免"物流"问题附上 dropshipping 首页/about-us 等无关链接。
|
||||
const SECTION_URL_MAP = {
|
||||
'Value proposition': ['https://dropshipping.yehwang.com/', 'https://dropshipping.yehwang.com/about-us'],
|
||||
'Joining & requirements': ['https://dropshipping.yehwang.com/faq/register'],
|
||||
'How it works (4 steps)': ['https://dropshipping.yehwang.com/'],
|
||||
'Brands & catalog': ['https://dropshipping.yehwang.com/'],
|
||||
'Pricing & margins': ['https://dropshipping.yehwang.com/'],
|
||||
'Packaging & branding': ['https://dropshipping.yehwang.com/'],
|
||||
'Orders, minimum & products': ['https://dropshipping.yehwang.com/faq/order'],
|
||||
'Payment & VAT': ['https://dropshipping.yehwang.com/faq/payment'],
|
||||
'Shipping & delivery': ['https://dropshipping.yehwang.com/faq/shipment'],
|
||||
'Returns': ['https://dropshipping.yehwang.com/faq/returns'],
|
||||
};
|
||||
|
||||
// ---------- embedding ----------
|
||||
async function embedText(text) {
|
||||
const resp = await fetch(`${EMB.baseURL}/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${EMB.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: MODEL, input: text, encoding_format: 'float', dimensions: DIM }),
|
||||
});
|
||||
const d = await resp.json();
|
||||
if (!d.data || !d.data[0] || !d.data[0].embedding) {
|
||||
throw new Error('embedText err: ' + JSON.stringify(d).slice(0, 200));
|
||||
}
|
||||
return d.data[0].embedding;
|
||||
}
|
||||
|
||||
const vecStr = (v) => `[${v.join(',')}]`;
|
||||
|
||||
async function main() {
|
||||
// 1. 建表
|
||||
await pool.query(`CREATE EXTENSION IF NOT EXISTS vector`);
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS kb_docs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_file TEXT,
|
||||
title TEXT,
|
||||
category TEXT,
|
||||
section TEXT,
|
||||
question TEXT,
|
||||
answer TEXT,
|
||||
keywords TEXT[],
|
||||
source_urls TEXT[],
|
||||
text_vec vector(${DIM})
|
||||
)
|
||||
`);
|
||||
// 重建表:模型/维度/向量空间变了(0.6B -> v4),旧向量不可复用,直接 DROP 重建
|
||||
await pool.query('DROP TABLE IF EXISTS kb_docs');
|
||||
await pool.query(`
|
||||
CREATE TABLE kb_docs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_file TEXT,
|
||||
title TEXT,
|
||||
category TEXT,
|
||||
section TEXT,
|
||||
question TEXT,
|
||||
answer TEXT,
|
||||
keywords TEXT[],
|
||||
source_urls TEXT[],
|
||||
text_vec vector(${DIM})
|
||||
)
|
||||
`);
|
||||
// HNSW 索引:小数据集召回质量优于 ivfflat,且无需调 lists 参数。
|
||||
// 检索用余弦距离(<=>),故显式指定 vector_cosine_ops。
|
||||
await pool.query(`CREATE INDEX kb_docs_vec_idx ON kb_docs USING hnsw (text_vec vector_cosine_ops)`);
|
||||
console.log(`建表完成, model=${MODEL}, dim=${DIM}`);
|
||||
|
||||
// 2. 收集文件(排除 README/index/_source)
|
||||
const files = fs.readdirSync(KB_DIR)
|
||||
.filter((f) => f.endsWith('.md') && /^\d+/.test(f))
|
||||
.sort();
|
||||
console.log(`发现 ${files.length} 个知识库文件: ${files.join(', ')}`);
|
||||
|
||||
let total = 0;
|
||||
for (const file of files) {
|
||||
const raw = fs.readFileSync(path.join(KB_DIR, file), 'utf8');
|
||||
const { meta, body } = parseFrontmatter(raw);
|
||||
const chunks = splitChunks(body);
|
||||
console.log(`\n[${file}] title="${meta.title}" category="${meta.category}" -> ${chunks.length} chunks`);
|
||||
|
||||
for (const ch of chunks) {
|
||||
const keywords = Array.isArray(meta.keywords) ? meta.keywords : [];
|
||||
// 来源 URL:优先用章节级映射(如 dropshipping 各章节对应不同 faq 子页),再兜底文件级
|
||||
let sourceUrls = [];
|
||||
if (ch.section && SECTION_URL_MAP[ch.section]) {
|
||||
sourceUrls = SECTION_URL_MAP[ch.section];
|
||||
} else if (Array.isArray(meta.source_url)) {
|
||||
sourceUrls = meta.source_url.filter((u) => /^https?:\/\//.test(u));
|
||||
}
|
||||
// embedding 文本:keywords + 问题 + 答案,最大化召回
|
||||
const embText = [
|
||||
keywords.length ? keywords.join(' ') : '',
|
||||
ch.question,
|
||||
ch.answer,
|
||||
].filter(Boolean).join('\n');
|
||||
const vec = await embedText(embText);
|
||||
await pool.query(
|
||||
`INSERT INTO kb_docs (source_file, title, category, section, question, answer, keywords, source_urls, text_vec)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::vector)`,
|
||||
[file, meta.title || '', meta.category || '', ch.section, ch.question, ch.answer, keywords, sourceUrls, vecStr(vec)]
|
||||
);
|
||||
total++;
|
||||
process.stdout.write(` [${total}] Q: ${ch.question.slice(0, 60)}${sourceUrls.length ? ' src=' + sourceUrls.length : ''}\n`);
|
||||
}
|
||||
}
|
||||
console.log(`\n上传完成,共 ${total} 个 chunk`);
|
||||
|
||||
// 3. 测试检索
|
||||
const tq = await embedText('What is the minimum order value? Can I pay by bank transfer?');
|
||||
const ts = await pool.query(
|
||||
`SELECT question, category, 1 - (text_vec <=> $1::vector) AS score
|
||||
FROM kb_docs ORDER BY text_vec <=> $1::vector LIMIT 5`, [vecStr(tq)]
|
||||
);
|
||||
console.log('\n检索 "What is the minimum order value? bank transfer":');
|
||||
ts.rows.forEach((r, i) => console.log(` ${i + 1}. [${r.category}] score=${Number(r.score).toFixed(3)} Q: ${r.question}`));
|
||||
|
||||
const tq2 = await embedText('meine Bestellung stornieren Rückgabe kaputt'); // 德语
|
||||
const ts2 = await pool.query(
|
||||
`SELECT question, category, 1 - (text_vec <=> $1::vector) AS score
|
||||
FROM kb_docs ORDER BY text_vec <=> $1::vector LIMIT 3`, [vecStr(tq2)]
|
||||
);
|
||||
console.log('\n检索(德语) "meine Bestellung stornieren Rückgabe kaputt":');
|
||||
ts2.rows.forEach((r, i) => console.log(` ${i + 1}. [${r.category}] score=${Number(r.score).toFixed(3)} Q: ${r.question}`));
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
main().catch((e) => { console.error('ERR', e.message); process.exit(1); });
|
||||
8
bridge/kb.config.example.json
Normal file
8
bridge/kb.config.example.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"provider": "dashscope",
|
||||
"baseURL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"apiKey": "YOUR_DASHSCOPE_API_KEY",
|
||||
"model": "text-embedding-v4",
|
||||
"dim": 1024,
|
||||
"translateModel": "qwen-turbo"
|
||||
}
|
||||
13
bridge/llm.config.example.json
Normal file
13
bridge/llm.config.example.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"providers": {
|
||||
"aliyun": {
|
||||
"id": "aliyun",
|
||||
"displayName": "aliyun",
|
||||
"type": "openai",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"apiKey": "YOUR_DASHSCOPE_API_KEY",
|
||||
"model": "qwen-plus-latest",
|
||||
"enableThinking": false
|
||||
}
|
||||
}
|
||||
}
|
||||
172
bridge/package-lock.json
generated
Normal file
172
bridge/package-lock.json
generated
Normal file
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"name": "bridge",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bridge",
|
||||
"version": "1.0.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"jose": "^6.2.3",
|
||||
"pg": "^8.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
bridge/package.json
Normal file
17
bridge/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "bridge",
|
||||
"version": "1.0.1",
|
||||
"description": "Chatwoot -> LLM -> Twenty CRM sales bridge",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"jose": "^6.2.4",
|
||||
"pg": "^8.22.0"
|
||||
}
|
||||
}
|
||||
50
bridge/register_agent_bot.js
Normal file
50
bridge/register_agent_bot.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// 注册 Chatwoot AgentBot:把 bridge 接成官方 AI(对标企业版 Captain)。
|
||||
// 1. 创建 AgentBot(name + outgoing_url 指向 bridge 的 /agent-bot)
|
||||
// 2. 关联到 website inbox(set_agent_bot)
|
||||
// 3. 打印 secret(用于 bridge 的 HMAC 签名校验,设到 AGENT_BOT_SECRET 环境变量)
|
||||
//
|
||||
// 运行: node register_agent_bot.js
|
||||
// 幂等:已存在同名 bot 则复用并更新 outgoing_url。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cw = JSON.parse(fs.readFileSync(path.join(__dirname, 'chatwoot.config.json'), 'utf8'));
|
||||
|
||||
const BOT_NAME = 'Yehwang AI Bridge';
|
||||
// Chatwoot 容器经 host.docker.internal 访问宿主上的 bridge;本地直连用 localhost
|
||||
const OUTGOING_URL = process.env.AGENT_BOT_OUTGOING_URL || 'http://host.docker.internal:4000/agent-bot';
|
||||
|
||||
const headers = () => ({ 'api_access_token': cw.accessToken, 'Content-Type': 'application/json' });
|
||||
const apiBase = `${cw.baseURL}/api/v1/accounts/${cw.accountId}`;
|
||||
|
||||
async function main() {
|
||||
// 1. 列出现有 agent_bots,找同名复用
|
||||
const listResp = await fetch(`${apiBase}/agent_bots`, { headers: headers() });
|
||||
const listData = await listResp.json();
|
||||
const existing = (listData.payload || listData || []).find((b) => b.name === BOT_NAME);
|
||||
let bot = existing;
|
||||
const payload = { name: BOT_NAME, description: 'AI sales+service bridge (RAG + tool-use)', outgoing_url: OUTGOING_URL, bot_type: 'webhook' };
|
||||
if (existing) {
|
||||
const r = await fetch(`${apiBase}/agent_bots/${existing.id}`, { method: 'PATCH', headers: headers(), body: JSON.stringify(payload) });
|
||||
bot = await r.json();
|
||||
console.log(`[update] 复用 AgentBot #${existing.id}`);
|
||||
} else {
|
||||
const r = await fetch(`${apiBase}/agent_bots`, { method: 'POST', headers: headers(), body: JSON.stringify(payload) });
|
||||
bot = await r.json();
|
||||
console.log(`[create] 新建 AgentBot #${bot.id}`);
|
||||
}
|
||||
console.log(' bot:', JSON.stringify({ id: bot.id, name: bot.name, outgoing_url: bot.outgoing_url, secret: bot.secret }));
|
||||
|
||||
// 2. 关联到 inbox(set_agent_bot)
|
||||
const inboxId = cw.inboxId;
|
||||
const setResp = await fetch(`${apiBase}/inboxes/${inboxId}/set_agent_bot`, {
|
||||
method: 'POST', headers: headers(), body: JSON.stringify({ agent_bot: bot.id }),
|
||||
});
|
||||
console.log(`[inbox] 关联 inbox ${inboxId} -> ${setResp.status} ${setResp.ok ? 'OK' : 'FAIL'}`);
|
||||
|
||||
// 3. 打印要配置的环境变量
|
||||
console.log('\n=== 配置 bridge 容器环境变量 ===');
|
||||
console.log(`AGENT_BOT_SECRET=${bot.secret}`);
|
||||
console.log('(写入 docker-compose.yml bridge.environment 或 .env,然后 docker compose up -d bridge)');
|
||||
console.log('\nChatwoot 后台「Inbox → 设置 → Bot」应能看到该 AgentBot 已关联。');
|
||||
}
|
||||
main().catch((e) => { console.error('ERR', e.message); process.exit(1); });
|
||||
1260
bridge/server.js
Normal file
1260
bridge/server.js
Normal file
File diff suppressed because it is too large
Load Diff
6
bridge/twenty.config.example.json
Normal file
6
bridge/twenty.config.example.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"baseURL": "http://localhost:3000",
|
||||
"graphqlPath": "/graphql",
|
||||
"workspaceId": "70a1901c-45ab-4b95-a7f0-de74be819b2e",
|
||||
"apiKey": "YOUR_TWENTY_API_KEY_JWT"
|
||||
}
|
||||
Reference in New Issue
Block a user