commit 4a29860b5118312a386f338b6014d5a6b53e49e2 Author: AI Bridge Dev Date: Mon Jul 27 15:23:52 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..954bb72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# 敏感配置文件(含 API keys / tokens / secrets) +bridge/chatwoot.config.json +bridge/llm.config.json +bridge/twenty.config.json +bridge/kb.config.json +chatwoot/.env +twenty/packages/twenty-docker/.env +docker-compose.yml + +# 外部源码目录 +chatwoot/ +twenty/ + +# 运行时生成 +public/vite-dev/ +public/widget-assets/ +public/packs/ +runtime/ +tmp/ +log/ + +# 依赖 +node_modules/ +vendor/ +.gems/ + +# 测试/临时文件 +mock_eta.js +mock_services.js +gen_apikey.js +add_note.js +find_keys.js +create_workflow.js +*.bak +*.log diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..f6906f2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/crm-ai-demo.iml b/.idea/crm-ai-demo.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/crm-ai-demo.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2dca7ac --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..900361a --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.zcode/plans/plan-sess_847b8d2f-c932-499d-a340-d27b34577724.md b/.zcode/plans/plan-sess_847b8d2f-c932-499d-a340-d27b34577724.md new file mode 100644 index 0000000..da9cf3b --- /dev/null +++ b/.zcode/plans/plan-sess_847b8d2f-c932-499d-a340-d27b34577724.md @@ -0,0 +1 @@ +将 KB 版本管理 + 原子切换 + 回滚方案输出为设计文档到知识库 `D:\workspace\知识库\05-项目架构文档\CRM-AI-KB版本管理与回滚方案.md`。纯文档输出,不改代码。 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b489d7c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +# CRM AI Demo - Project Guidelines + +## Project +B2B stainless steel jewelry wholesaler (Yehwang, NL) CRM with AI-powered sales/service assistant bridge. Chatwoot webhook → LLM agent (tool-use) → Chatwoot reply + Twenty CRM opportunity creation. + +## Tech Stack +- **Bridge**: Node.js 24, CommonJS (`"type": "commonjs"`), pure Node built-ins (`http`/`fetch`/`crypto`) + `jose` + `pg`. No Express. No test/lint/build scripts — verify by running the server and hitting `/healthz`. +- **LLM**: OpenAI-compatible provider via `bridge/llm.config.json`. Active: `aliyun` → DashScope `qwen-plus-latest` (`enableThinking: false`). `server.js` header comment says "glm-5.2/ark" — **ignore it, trust `llm.config.json`**. +- **Messaging**: Chatwoot (self-hosted, external source checkout at `./chatwoot/` — has its own `.git/`). +- **CRM**: Twenty CRM (self-hosted, external source checkout at `./twenty/` — may be absent). +- **Vector DB**: pgvector on shared `crm-postgres` (pg18). Three DBs: `chatwoot`, `twenty`, `products`. KB RAG table: `products.kb_docs`. +- **Product Search**: external recommend API (`RECOMMEND_URL`, default `http://recommend.internal.yw.com`). +- **Order/Inventory APIs**: trading-service (`ORDER_API_URL`) and eta-service (`INVENTORY_API_URL`). Mock server available: `node mock_services.js` (port 9505). +- **Embedding**: DashScope `text-embedding-v4` (dim 1024), configured in `kb.config.json`. Query translation fallback uses `qwen-turbo`. + +## Architecture +``` +Chatwoot (external) ──webhook──► Bridge (:4000) ──LLM tool-use──► Chatwoot reply + │ + ├── pgvector (products.kb_docs) KB RAG (search_kb) + ├── recommend API product catalog (search_products) + ├── trading-service order tracking (search_orders) + ├── eta-service inventory/stock (search_inventory) + └── Twenty CRM GraphQL create Opportunity +``` + +Bridge routes (`bridge/server.js`): +- `GET /healthz` (and `/`) — liveness, returns `agentBot: !!AGENT_BOT_SECRET` +- `GET /conversation/:id` and `GET /conversation/:id/html` — debug views +- `POST /agent-bot` — Chatwoot AgentBot outgoing webhook (HMAC-signed) +- `POST /chatwoot-webhook` — Chatwoot `message_created` webhook (fallback entry) + +Agent tool-use loop: LLM autonomously calls `search_kb`, `search_products`, `search_orders`, `search_inventory`, or `escalate_human`. Single `AGENT_SYSTEM` persona — no prompt switching. Per-conversation lock (`locks` Set) prevents concurrent LLM calls; `createdOpps` Set prevents duplicate Opportunities. + +## Startup +```bash +# Full stack (requires Docker Desktop): +bash start.sh + +# Shared infra only (no Chatwoot/Twenty source needed): +docker compose up -d --build + +# Local dev (no Docker): +cd bridge && node server.js # Node 24+; needs PG_URL etc. as env vars + +# Mock business APIs (for dev without real trading/eta services): +node mock_services.js # Listens on :9505 +``` + +`start.sh` conditionally starts Twenty (if `./twenty/docker-compose.yaml` exists) and Chatwoot (if `./chatwoot/docker-compose.yml` exists — note: currently runs unconditionally, will error if absent). Shared infra always starts. + +Logs / health: +```bash +docker logs -f crm-bridge +curl http://localhost:4000/healthz +``` + +**No rebuild needed for code changes** — `docker-compose.yml` volume-mounts `bridge/*.js` and `*.config.json` read-only. `docker restart crm-bridge` picks up edits. Only rebuild the image when changing `package.json` deps. + +## Key Files +``` +bridge/ + server.js Main bridge: routes, LLM agent loop, tool impls, Chatwoot/Twenty API calls (~1250 lines) + index_kb.js KB vectorizer: customer-service-kb/*.md → embed → products.kb_docs (DROPS + recreates table each run) + register_agent_bot.js Registers Chatwoot AgentBot (idempotent). Run: node register_agent_bot.js + llm.config.json LLM provider config. Active provider key: "aliyun" + kb.config.json Embedding config (model, dim, translateModel) + DashScope creds + chatwoot.config.json Chatwoot API connection (baseURL, accountId, accessToken, inboxId) + twenty.config.json Twenty CRM connection (baseURL, workspaceId, apiKey JWT) + Dockerfile node:24-alpine, npm install --omit=dev, copies server.js + configs +customer-service-kb/ FAQ markdown (source for index_kb.js). Chunks split on ### Q:. English; AI translates per customer language. +infra/init-db.sh postgres init: creates DBs chatwoot/twenty/products + enables pgvector on chatwoot & products. +docker-compose.yml Shared infra: pgvector/pgvector:pg18 + redis:8-alpine + bridge +mock_services.js Mock trading/eta API server (:9505) for dev without real backends +add_note.js CLI utility: attach a text note to a Twenty opportunity +create_workflow.js CLI utility: create Twenty automation workflow (new opp → auto follow-up task) +find_keys.js CLI utility: find Twenty signing key tables in postgres +gen_apikey.js CLI utility: generate Twenty API key JWT (needs ENCRYPTION_KEY + PG_DATABASE_URL) +landing.html Multilingual landing page +start.sh One-shot launcher for all compose stacks +docs/twenty-crm-guide.md Twenty CRM integration guide: which GraphQL ops bridge uses, data model, extension ideas +``` + +## Environment Variables +Env vars always override `*.config.json` values. Key overrides in `server.js`: +- `CW_BASE_URL` — overrides `chatwoot.config.json` baseURL +- `TWENTY_BASE_URL` — overrides `twenty.config.json` baseURL +- `PG_URL` — postgres connection (default `postgres://postgres:postgres@localhost:5432/products`) +- `AGENT_BOT_SECRET` — HMAC signing key for AgentBot webhook verification +- `RECOMMEND_URL` — product recommendation API base URL +- `ORDER_API_URL` — trading-service base URL (order queries) +- `ORDER_API_KEY` — auth key for trading-service +- `INVENTORY_API_URL` — eta-service base URL (stock/ETA queries) +- `INVENTORY_API_KEY` — auth key for eta-service +- `DASHSCOPE_API_KEY` — overrides `kb.config.json` apiKey (avoids hardcoding in config) +- `PORT` — bridge listen port (default 4000) + +Docker-compose sets these for the bridge container (using container names for inter-service routing, `host.docker.internal` for host-side services). + +## Conventions & Gotchas +- **Config loading**: `loadCfg()` reads `*.config.json` from `__dirname` (the bridge dir). Env vars always win — check `process.env.*` overrides in `server.js` before editing config files. +- **KB re-indexing**: `index_kb.js` **DROPS and recreates** `kb_docs` every run (no incremental update). Any model/dimension change requires a full re-vectorize. Run `node index_kb.js` after editing any `customer-service-kb/*.md` file. +- **KB doc format**: YAML frontmatter (`title`/`category`/`keywords`/`source_url`) + `### Q:`/answer blocks. Embedding text = `keywords + Q + A`. Source URLs are mapped per-section in `index_kb.js` `SECTION_URL_MAP`. +- **Agent persona is the source of truth** for sales policy (discount tiers, minimum order €30, contacts). `AGENT_SYSTEM` in `server.js` encodes business rules; `customer-service-kb/` encodes policy FAQs. Keep them consistent. +- **Language rule**: replies must mirror the customer's language, even when tool results are English. Enforced in `AGENT_SYSTEM`. +- **Multi-language KB recall**: when cosine similarity < 0.55 and query isn't English, `searchKB` auto-translates to English via `qwen-turbo` and merges results. Rarely triggers — `text-embedding-v4` handles most EU languages well. +- **Duplicate `addChatToOpportunity` call**: `server.js` lines ~1086-1088 call this function twice for the same conversation/opportunity. Likely a bug — second call creates a duplicate note. +- **`start.sh` Chatwoot unconditional**: the Twenty section checks for `docker-compose.yaml` existence first, but the Chatwoot section runs `docker compose up` unconditionally — will fail if `./chatwoot/` is absent. +- **Not a git repo**: the project root has no `.git/`. `./chatwoot/` has its own `.git/` (separate checkout). `./twenty/` may or may not be present. +- **Secrets in configs**: `*.config.json` and `docker-compose.yml` contain real API keys/tokens (DashScope, Chatwoot access token, Twenty JWT, `AGENT_BOT_SECRET`). Treat as sensitive; do not commit to public repos. +- **Resource limits**: postgres 0.5 CPU/512M, redis 0.3 CPU/256M, bridge 0.5 CPU/512M (in `docker-compose.yml`). LLM/embedding calls are external network — watch latency under load. +- **Dockerfile base image**: `node:24-alpine`. The comment says "Node 18+" but `FROM node:24-alpine`. Node 24 has built-in `fetch`/`FormData`/`Blob`. +- **Bridge auto-creates follow-up tasks**: after creating an Opportunity, `createLeadInCRM` auto-creates a Task due in 3 days, linked to the opportunity. This is in bridge code, not Twenty's workflow engine (Twenty API key lacks permission to create workflows via GraphQL). +- **Opportunity extraction logic**: `EXTRACT_SYSTEM` requires 3 elements to set `ready=true`: company name + contact info (email/phone/name) + product/need. Missing any → `ready=false`, no CRM record created. diff --git a/bridge/Dockerfile b/bridge/Dockerfile new file mode 100644 index 0000000..efd139f --- /dev/null +++ b/bridge/Dockerfile @@ -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"] diff --git a/bridge/chatwoot.config.example.json b/bridge/chatwoot.config.example.json new file mode 100644 index 0000000..343f7bc --- /dev/null +++ b/bridge/chatwoot.config.example.json @@ -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" +} \ No newline at end of file diff --git a/bridge/index_kb.js b/bridge/index_kb.js new file mode 100644 index 0000000..5d3ae6f --- /dev/null +++ b/bridge/index_kb.js @@ -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); }); diff --git a/bridge/kb.config.example.json b/bridge/kb.config.example.json new file mode 100644 index 0000000..684575d --- /dev/null +++ b/bridge/kb.config.example.json @@ -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" +} \ No newline at end of file diff --git a/bridge/llm.config.example.json b/bridge/llm.config.example.json new file mode 100644 index 0000000..611c863 --- /dev/null +++ b/bridge/llm.config.example.json @@ -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 + } + } +} \ No newline at end of file diff --git a/bridge/package-lock.json b/bridge/package-lock.json new file mode 100644 index 0000000..6426786 --- /dev/null +++ b/bridge/package-lock.json @@ -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" + } + } + } +} diff --git a/bridge/package.json b/bridge/package.json new file mode 100644 index 0000000..31ebc4d --- /dev/null +++ b/bridge/package.json @@ -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" + } +} diff --git a/bridge/register_agent_bot.js b/bridge/register_agent_bot.js new file mode 100644 index 0000000..c657a91 --- /dev/null +++ b/bridge/register_agent_bot.js @@ -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); }); diff --git a/bridge/server.js b/bridge/server.js new file mode 100644 index 0000000..a03d418 --- /dev/null +++ b/bridge/server.js @@ -0,0 +1,1260 @@ +// Chatwoot → glm-5.2 (ark) → Twenty CRM Bot Bridge +// 接收 Chatwoot 的 message_created webhook,用 glm-5.2 作为销售助理回复, +// 并在信息足够时把机会写进 Twenty CRM 的 Opportunity。 +// +// 纯 Node 内置模块,无依赖。Node 24 内置 fetch / http。 + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// ---------- 配置加载(环境变量优先,fallback 配置文件)---------- +const CFG_DIR = __dirname; +const loadCfg = (f) => JSON.parse(fs.readFileSync(path.join(CFG_DIR, f), 'utf8')); +const cw = loadCfg('chatwoot.config.json'); +const llm = loadCfg('llm.config.json'); +const twenty = loadCfg('twenty.config.json'); +// 容器内用容器名访问,本地用 localhost;环境变量覆盖 +if (process.env.CW_BASE_URL) cw.baseURL = process.env.CW_BASE_URL; +if (process.env.TWENTY_BASE_URL) twenty.baseURL = process.env.TWENTY_BASE_URL; +// QDRANT_URL 优先用环境变量(下面 search 部分读) + +const PORT = process.env.PORT || 4000; +// 已为每个会话创建过机会的记录,避免重复 +const createdOpps = new Set(); +// 会话级锁,防止并发消息重复触发 LLM +const locks = new Set(); + +// ---------- LLM ---------- +const PROVIDER = llm.providers.aliyun; +const PROVIDER_TYPE = PROVIDER.type || 'openai'; +const LLM_URL = PROVIDER.baseUrl + (PROVIDER_TYPE === 'anthropic' ? '/v1/messages' : '/chat/completions'); +const LLM_KEY = PROVIDER.apiKey; +const MODEL = PROVIDER.model; +// qwen3 系列默认开 thinking(慢),客服求快关闭;tool calling 在关闭后仍正常 +const ENABLE_THINKING = PROVIDER.enableThinking !== undefined ? PROVIDER.enableThinking : false; + +// 统一 agent 人设:一个稳定角色,不再在 sales/KB 间切 system prompt。 +// LLM 自主调用 tools(search_kb / search_products / escalate_human) 决定如何作答。 +const AGENT_SYSTEM = `You are the official assistant for Yehwang, a Netherlands-based (Dutch-registered) B2B wholesaler serving retailers across Europe. + +**Company facts (do NOT make these up):** +- Head office: Galvanistraat 90, 6716 AE Ede, The Netherlands. Phone: +31(0)318 668 171. +- Showroom: World Fashion Centre (WFC), Koningin Wilhelminaplein 29, Tower 3 - 1st floor, 1062 HJ Amsterdam. Phone: +31(0)6 3228 5555. +- Three sales channels: physical wholesale showroom (WFC Amsterdam), B2B website (yehwang.com), and a dropshipping platform (dropshipping.yehwang.com). +- Products sourced from: Netherlands warehouse (in-stock goods) and manufacturing facilities in China (Factory-Direct). Do NOT mention any other countries. +- 100,000+ live SKUs across women's jewelry, accessories, clothing (own label: Gimme), packing/display. +- Prices are wholesale unit prices, public and transparent on the site after login. +- Shipping: Netherlands warehouse via PostNL/DHL; Factory-Direct ships from manufacturing facilities worldwide. + +You handle BOTH pre-sales (qualifying leads) AND customer-service policy questions. + +**LANGUAGE (most important): ALWAYS reply in the SAME language the customer used. If they write Chinese, reply in Chinese; German -> German; French -> French; etc. Even if tool results (product names, KB answers) are in English, you MUST write your reply text in the customer's language. Default to English only if the customer's language is unclear.** + +Use the provided tools. Tool discipline is critical: +- search_kb: You MUST call this FIRST (before answering) for ANY question about rules/policies/FAQ — registration, account, password, login, minimum order, cancel, pre-order, payment, VAT, invoice, shipping cost, delivery time, returns, refunds, defective/broken items, membership, discounts, dropshipping, factory-direct, materials, discolor, jewelry care, etc. NEVER answer such questions from memory — always retrieve the official answer via search_kb first. If the customer's message contains ANY policy-related word, call search_kb. +- search_products: call when the customer wants to find/see/buy specific products. **Use EXACT product names returned — never translate or fabricate.** Pass the customer's intent as a natural search query (e.g. "gold necklace", "新品", "earrings") — the search engine handles trending/new/best-selling on its own. If results are empty, tell the customer honestly. +- escalate_human: call when the question is outside your tools' scope or you can't confidently answer. ALSO call this IMMEDIATELY (do NOT search_kb or search_products first) if the customer: explicitly asks to speak to a human ("转人工"/"真人"/"人工客服"/"speak to human"/"real person"), expresses anger or frustration, makes a formal complaint, or repeatedly says the same thing without being helped. In these cases, apologize briefly, explain you're transferring them, and call escalate_human. +- search_orders: call when the customer asks about an EXISTING order - "where is my order", "order status", "tracking", "when will it arrive", "has it shipped". Provide the order number if the customer mentioned it; otherwise use the customer's email to look up recent orders. Return concrete tracking numbers and delivery estimates. +- search_inventory: call when the customer asks about REAL-TIME stock or price for a SPECIFIC item they want to order - "is SKU xxx in stock", "how many available", "can you fulfill 500 pieces". This is for pre-order stock verification. Note: search_products is for BROWSING the catalog (finding items); search_inventory is for checking real-time stock/price on a SPECIFIC item before confirming an order. + +RULES: +- **Escalation (critical): When the customer asks for a human, expresses frustration, or you need to escalate, you MUST call the escalate_human tool immediately. Do NOT just say "I'll transfer you" in text — the tool does the real handoff. Call the tool FIRST, then the system will handle the rest.** +- Reply in the SAME language the customer uses (default English). Keep replies concise and natural (usually 2-4 sentences). Do not repeat earlier phrasing. +- For policy questions: answer using ONLY what search_kb returns. Cite concrete figures (e.g. "minimum order EUR 30", "5-10 business days express"). Do NOT add source URLs yourself — the system appends them automatically. If search_kb results don't cover it, say you're unsure and offer to escalate to a human. +- For pre-sales: this is an ONLINE B2B platform with PUBLIC transparent prices (no quotes). We support CUSTOMIZATION (materials, colors, sizes, packaging, private label). You CANNOT create accounts — guide customers to register themselves. **For bulk/product+quantity queries: call search_products FIRST to show items, then pivot to lead qualification (check stock, mention Factory-Direct if insufficient, ask for company name + contact). Only mention discounts when the customer asks about pricing/bulk/discounts — do NOT push discount tiers proactively. When asked, the tiers are: volume discounts (€200→5%, €500→7%, €1,000→10%, €2,000→15%, €3,000→20%, €5,000→22%, €10,000→25%) and membership (Bronze €1+ 0%, Silver €200+ 1%, Gold €1,000+ 2%, Platinum €4,000+ 3%, Diamond €10,000+ 4%, VIP €20,000+ 5%). Discounts stack: volume first, then membership on top. Collect: company name → contact → product/quantity. Track what's known; never re-ask. Once complete, guide them to register at https://www.yehwang.com.** +- Our website is https://www.yehwang.com. Always write the FULL URL with https:// protocol, never bare "www.yehwang.com". +- Contacts: helpdesk@yehwang.com / +31 318 668 171 (website & in-stock orders); service@yehwang.com (Factory-Direct orders).`; + +// callLLM: 支持 tool use(OpenAI function calling)。返回 {text, toolCalls, raw}。 +// - toolCalls: [{id, name, arguments}] 当 LLM 决定调工具时 +// - text: 纯文本回复(无 tool call 时) +async function callLLM(messages, systemOverride, maxTokens = 4096, tools = null) { + let body, headers; + + if (PROVIDER_TYPE === 'anthropic') { + body = { model: MODEL, max_tokens: maxTokens, messages }; + if (systemOverride) body.system = systemOverride; + headers = { 'x-api-key': LLM_KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }; + } else { + // OpenAI-compatible (DashScope qwen) + const msgs = []; + if (systemOverride) msgs.push({ role: 'system', content: systemOverride }); + msgs.push(...messages); + body = { model: MODEL, max_tokens: maxTokens, messages: msgs }; + // qwen3 关闭 thinking 求快(tool calling 仍正常) + body.enable_thinking = ENABLE_THINKING; + if (tools && tools.length) body.tools = tools; + headers = { 'Authorization': `Bearer ${LLM_KEY}`, 'content-type': 'application/json' }; + } + + const resp = await fetch(LLM_URL, { method: 'POST', headers, body: JSON.stringify(body) }); + const text = await resp.text(); + let data; + try { data = JSON.parse(text); } + catch (e) { console.error('[llm] resp.json fail:', e.message, '| text:', text.slice(0, 300)); throw e; } + if (!resp.ok) throw new Error('LLM HTTP ' + resp.status + ': ' + JSON.stringify(data).slice(0, 400)); + + const choice = data.choices && data.choices[0]; + const msg = choice && choice.message; + const toolCalls = (msg && msg.tool_calls || []).map((tc) => { + let args = {}; + try { args = JSON.parse(tc.function.arguments || '{}'); } catch { args = {}; } + return { id: tc.id, name: tc.function.name, arguments: args }; + }); + const replyText = ((msg && msg.content) || '').trim(); + return { text: replyText, toolCalls, finishReason: choice && choice.finish_reason }; +} + +// ---------- Chatwoot API ---------- +const cwHeaders = () => ({ + 'api_access_token': cw.accessToken, + 'Content-Type': 'application/json', +}); + +async function fetchConversationMessages(conversationId, limit = 20) { + // 只拉最近 limit 条(客服场景只需近期上下文),减少序列化/网络延迟 + const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages?sort=desc&per_page=${limit}`; + const resp = await fetch(url, { headers: cwHeaders() }); + const data = await resp.json(); + // data.payload 或 data 直接是数组;desc 拉取后反转回时间正序 + const list = Array.isArray(data) ? data : (data.payload || []); + return list.reverse(); +} + +async function sendChatwootReply(conversationId, content) { + const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages`; + const resp = await fetch(url, { + method: 'POST', + headers: cwHeaders(), + body: JSON.stringify({ content, message_type: 'outgoing', private: false }), + }); + return resp.json(); +} + +// typing indicator:让 widget 立即显示“客服正在输入”,消除无响应体感 +async function setTyping(conversationId, on) { + try { + const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/toggle_typing_status`; + await fetch(url, { method: 'POST', headers: cwHeaders(), body: JSON.stringify({ typing_status: on ? 'on' : 'off' }) }); + } catch {} +} + +// 真 handoff:把会话从 bot 转给人类客服(改 assignee),bot 不再抢答。 +// 对标 Chatwoot 企业版 Captain 的“转人工”。assigneeId 省略时只 toggle 状态为 open+bot 默认转交。 +async function handoffToHuman(conversationId, note) { + try { + // 1. 先发一条 note 给客户(说明转人工) + // 2. 切换会话状态为 open(确保人类能接) + 清掉 bot assignee + const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}`; + const resp = await fetch(url, { + method: 'PATCH', + headers: cwHeaders(), + body: JSON.stringify({ status: 'open', assignee_agent_bot_id: null }), + }); + console.log(`[handoff] conv=${conversationId} -> ${resp.status} ${note || ''}`); + return resp.ok; + } catch (e) { console.error('[handoff err]', e.message); return false; } +} + +// 列出账号下可用的客服 agent(人类),供 handoff 分配 +async function listHumanAgents() { + try { + const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/agents`; + const resp = await fetch(url, { headers: cwHeaders() }); + const d = await resp.json(); + return d.payload || d || []; + } catch { return []; } +} + +// 上传图片到 Chatwoot(下载后用文件上传,避免 external_url 超时) +async function uploadImageToChatwoot(imageUrl) { + const encodedUrl = imageUrl.replace(/ /g, '%20'); + const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/upload`; + const r = await fetch(encodedUrl); + const buf = Buffer.from(await r.arrayBuffer()); + const ctype = r.headers.get('content-type') || 'image/jpeg'; + const ext = ctype.includes('png') ? 'png' : 'jpg'; + // 构造 multipart/form-data + const form = new FormData(); + form.append('attachment', new Blob([buf], { type: ctype }), `product.${ext}`); + const resp = await fetch(url, { method: 'POST', headers: { 'api_access_token': cw.accessToken }, body: form }); + const d = await resp.json(); + if (!d.blob_id) throw new Error('upload err: ' + JSON.stringify(d).slice(0, 150)); + return d.blob_id; +} + +// 发带商品图片附件的消息 +async function sendProductImageMessage(conversationId, imageUrl, caption) { + try { + const blobId = await uploadImageToChatwoot(imageUrl); + const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages`; + const resp = await fetch(url, { + method: 'POST', + headers: { ...cwHeaders() }, + body: JSON.stringify({ content: caption || '', message_type: 'outgoing', private: false, attachments: [blobId] }), + }); + return resp.json(); + } catch (e) { + console.error('[img msg err]', e.message); + return null; + } +} +// 通用 GraphQL 执行 +async function gql(query) { + const resp = await fetch(`${twenty.baseURL}${twenty.graphqlPath}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${twenty.apiKey}` }, + body: JSON.stringify({ query }), + }); + const data = await resp.json(); + if (data.errors) throw new Error('Twenty errors: ' + JSON.stringify(data.errors).slice(0, 300)); + return data.data; +} + +// 创建公司 +async function createCompany(name, domain) { + const f = [`name: "${escapeGQL(name)}"`]; + if (domain) { + const url = domain.startsWith('http') ? domain : 'https://' + domain; + f.push(`domainName: {primaryLinkUrl:"${escapeGQL(url)}"}`); + } + const d = await gql(`mutation { createCompany(data:{ ${f.join(', ')} }){ id name } }`); + return d.createCompany; +} + +// 按公司名查现有公司(去重),返回 id 或 null +async function findCompanyByName(name) { + const d = await gql(`{ companies(first:1, filter:{ name:{ eq:"${escapeGQL(name)}" } }){ edges{ node{ id } } } }`).catch(() => ({ companies: { edges: [] } })); + const edges = (d.companies && d.companies.edges) || []; + return edges[0] ? edges[0].node.id : null; +} + +// 按邮箱查现有联系人(去重),返回 id 或 null +async function findPersonByEmail(email) { + const d = await gql(`{ people(first:1, filter:{ emails:{ primaryEmail:{ eq:"${escapeGQL(email)}" } } } ){ edges{ node{ id } } } }`).catch(() => ({ people: { edges: [] } })); + const edges = (d.people && d.people.edges) || []; + return edges[0] ? edges[0].node.id : null; +} + +// 从 Twenty duplicate 错误里取已存在记录的 id +function extractConflictId(errMsg) { + const m = String(errMsg).match(/conflictingRecordId":"([a-f0-9-]+)/); + return m ? m[1] : null; +} + +// 创建联系人 {firstName, lastName, email, phone, companyId} +async function createPerson(p) { + const parts = []; + const fn = p.firstName || ''; + const ln = p.lastName || ''; + parts.push(`name: {firstName:"${escapeGQL(fn)}", lastName:"${escapeGQL(ln)}"}`); + if (p.email) parts.push(`emails: {primaryEmail:"${escapeGQL(p.email)}"}`); + if (p.phone) parts.push(`phones: {primaryPhoneNumber:"${escapeGQL(p.phone)}", primaryPhoneCountryCode:""}`); + if (p.companyId) parts.push(`companyId: "${p.companyId}"`); + const d = await gql(`mutation { createPerson(data:{ ${parts.join(', ')} }){ id firstName lastName emails{ primaryEmail } } }`); + return d.createPerson; +} + +// 创建商机,关联公司+联系人 +async function createOpportunity(opp) { + // opp: { name, amount?, closeDate?, stage?, companyId?, personId?, summary?, conversationLink? } + const VALID_STAGES = ['NEW', 'SCREENING', 'MEETING', 'PROPOSAL', 'CUSTOMER']; + const stage = VALID_STAGES.includes(opp.stage) ? opp.stage : 'NEW'; + // 机会名:公司-需求摘要-会话编号,salesperson 在 Twenty 里能看到完整信息 + const nameText = opp.conversationId + ? `${opp.name} (会话#${opp.conversationId})` + : opp.name; + const fields = [`name: "${escapeGQL(nameText)}"`]; + if (opp.amount) { + const micros = Math.round(Number(opp.amount) * 1000000); + fields.push(`amount: {amountMicros: ${micros}, currencyCode: "${opp.currency || 'EUR'}"}`); + } + if (opp.closeDate) fields.push(`closeDate: "${opp.closeDate}"`); + fields.push(`stage: ${stage}`); + if (opp.companyId) fields.push(`companyId: "${opp.companyId}"`); + if (opp.personId) fields.push(`pointOfContact: {connect:"${opp.personId}"}`); + const d = await gql(`mutation { createOpportunity(data:{ ${fields.join(', ')} }){ id name stage amount{ amountMicros currencyCode } companyId } }`); + return d.createOpportunity; +} + +// 完整建一条线索:公司 -> 联系人 -> 商机(全部关联),公司/联系人去重复用 +async function createLeadInCRM(info) { + // info: { company, domain, contactName, email, phone, oppName, amount, currency, stage, summary, conversationLink } + let companyId = null; + let companyCreated = false; + if (info.company) { + companyId = await findCompanyByName(info.company); + if (!companyId) { + try { + const c = await createCompany(info.company, info.domain || null); + companyId = c ? c.id : null; + companyCreated = true; + } catch (e) { + companyId = extractConflictId(e.message) || await findCompanyByName(info.company); + } + } + } + let personId = null; + let personCreated = false; + if (info.email) { + personId = await findPersonByEmail(info.email); + } + if (!personId && (info.contactName || info.email)) { + const names = splitName(info.contactName || ''); + try { + const p = await createPerson({ + firstName: names.firstName, + lastName: names.lastName, + email: info.email || null, + phone: info.phone || null, + companyId, + }); + personId = p ? p.id : null; + personCreated = true; + } catch (e) { + personId = extractConflictId(e.message); + if (personId) console.log(`[dedup] 复用已有联系人 ${personId}`); + else console.error('[person err]', e.message); + } + } + const opp = await createOpportunity({ + name: info.oppName, + amount: info.amount, + currency: info.currency || 'EUR', + stage: info.stage || 'NEW', + companyId, + personId, + conversationId: info.conversationId || null, + }); + + // 自动化工作流:商机创建后自动生成跟进任务(3 天后到期,关联到商机) + if (opp && opp.id) { + try { + const dueDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); + const taskTitle = `跟进商机: ${info.oppName || opp.name}`; + const taskBody = `这是由 AI 客服系统自动创建的跟进任务。\n商机来源: Chatwoot 会话 #${info.conversationId || 'N/A'}\n客户: ${info.company || '未知'}${info.contactName ? ' (' + info.contactName + ')' : ''}\n需求: ${info.summary || opp.name}\n\n请在 3 个工作日内联系客户确认细节并发送报价。`; + const blocknote = JSON.stringify([{ id: 't1', type: 'paragraph', props: {}, content: [{ type: 'text', text: taskBody, styles: {} }], children: [] }]); + const escBlock = blocknote.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const td = await gql(`mutation { createTask(data:{ title:"${escapeGQL(taskTitle)}" bodyV2:{ blocknote: "${escBlock}" } dueAt:"${dueDate}" }){ id } }`); + const taskId = td.createTask && td.createTask.id; + if (taskId) { + await gql(`mutation { createTaskTarget(data:{ taskId:"${taskId}" targetOpportunityId:"${opp.id}" }){ id } }`); + console.log(`[workflow] conv=${info.conversationId} opp=${opp.id} -> 自动创建跟进任务 ${taskId} (3天后到期)`); + } + } catch (e) { + console.error('[workflow err] 自动创建任务失败:', e.message.slice(0, 100)); + } + } + + return { companyId, personId, opportunityId: opp ? opp.id : null, companyCreated, personCreated }; +} + +// 把聊天记录推送到 Twenty 的 opportunity note +async function addChatToOpportunity(conversationId, opportunityId) { + try { + const msgs = await fetchConversationMessages(conversationId); + if (!msgs.length) return; + const lines = msgs + .filter((m) => m.content && typeof m.content === 'string' && m.content.trim()) + .map((m) => { + const role = m.message_type === 0 || m.message_type === 'incoming' ? '客户' : '客服'; + const txt = stripHtml(m.content).slice(0, 300); + return `[${role}] ${txt}`; + }); + const chatText = lines.join('\n\n---\n\n'); + const blocknote = JSON.stringify([ + { id: '1', type: 'paragraph', props: {}, content: [{ type: 'text', text: chatText, styles: {} }], children: [] }, + ]); + const esc = blocknote.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + // 两步:先建 note,再建 noteTarget 关联到 opportunity + const q1 = `mutation { createNote(data:{ bodyV2: { blocknote: "${esc}" } }){ id } }`; + const d1 = await gql(q1); + const noteId = d1.createNote?.id; + if (!noteId) { console.error('[note] 创建失败'); return; } + const q2 = `mutation { createNoteTarget(data:{ noteId: "${noteId}", targetOpportunityId: "${opportunityId}" }){ id } }`; + await gql(q2); + console.log(`[note] conv=${conversationId} opp=${opportunityId} -> ${noteId}`); + } catch (e) { + console.error('[note err]', e.message.slice(0, 100)); + } +} + +function splitName(full) { + full = (full || '').trim(); + if (!full) return { firstName: '', lastName: '' }; + const sp = full.split(/[\s.]+/).filter(Boolean); + if (sp.length >= 2) return { firstName: sp[0], lastName: sp.slice(1).join(' ') }; + return { firstName: full, lastName: '' }; +} + +function escapeGQL(s) { + return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' '); +} + +// web-widget 消息带

等 HTML,去掉后给 LLM 更干净 +function stripHtml(s) { + return String(s) + .replace(//gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .trim(); +} + +// 修夏 LLM 生成的链接:markdown 链接的 URL 部分缺协议时补 https:// +// [text](www.yehwang.com) -> [text](https://www.yehwang.com) +// 不动已有协议(http(s)://, mailto:, #锚点)或商品链接(均为完整 URL) +function fixupLinks(text) { + return String(text).replace( + /\]\((?!https?:\/\/|mailto:|#|[a-z][a-z0-9+.-]*:)([^)\s]+)\)/gi, + '](https://$1)' + ); +} + +// ---------- 客服知识库 RAG(共享 postgres pgvector + SiliconFlow embedding)---------- +// 将 customer-service-kb/*.md 按 Q&A 切块向量化(见 index_kb.js),客服系统据此 +// 准确回答注册/下单/支付/物流/退货/会员等政策类问题,避免 LLM 编造政策。 +const { Pool } = require('pg'); +const EMB_CFG = JSON.parse(fs.readFileSync(path.join(CFG_DIR, 'kb.config.json'), 'utf8')); +const KB_PG_URL = process.env.PG_URL || 'postgres://postgres:postgres@localhost:5432/products'; +const kbPool = new Pool({ connectionString: KB_PG_URL, max: 2 }); +const KB_MODEL = EMB_CFG.model || 'text-embedding-v4'; +const KB_DIM = EMB_CFG.dim || 1024; +// DashScope key 支持环境变量覆盖(避免明文入库) +if (process.env.DASHSCOPE_API_KEY) EMB_CFG.apiKey = process.env.DASHSCOPE_API_KEY; + +// 单条文本向量化(用于查询) +// query embedding LRU 缓存:重复问法不重复调 DashScope,热问法秒回 +const _embedCache = new Map(); +const _EMBED_CACHE_MAX = 200; +async function embedQuery(text) { + const key = text.slice(0, 200); + if (_embedCache.has(key)) return _embedCache.get(key); + const resp = await fetch(`${EMB_CFG.baseURL}/embeddings`, { + method: 'POST', + headers: { Authorization: `Bearer ${EMB_CFG.apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: KB_MODEL, input: text, encoding_format: 'float', dimensions: KB_DIM }), + }); + const d = await resp.json(); + if (!d.data || !d.data[0] || !d.data[0].embedding) throw new Error('embedQuery err: ' + JSON.stringify(d).slice(0, 200)); + const vec = d.data[0].embedding; + if (_embedCache.size >= _EMBED_CACHE_MAX) _embedCache.delete(_embedCache.keys().next().value); + _embedCache.set(key, vec); + return vec; +} + +// 轻量查询翻译:Qwen3-Embedding-0.6B 对德/法/西/意等欧洲语言召回偏弱, +// 当原文检索命中不够且 query 非中文时,先翻成英文再检索,大幅提升多语言召回。 +// 只输出一句英文,不增加后续回复的语言负担(回复仍用客户原文语言)。 +// 轻量查询翻译(dashscope qwen-turbo):text-embedding-v4 多语言召回已足够强(实测 Recall@3 100%), +// 翻译重检仅作冷门问法兑底(几乎不会触发)。用 qwen-turbo 比 Qwen2.5-72B 更轻快省成本。 +async function translateQueryToEnglish(query) { + try { + const resp = await fetch(`${EMB_CFG.baseURL}/chat/completions`, { + method: 'POST', + headers: { Authorization: `Bearer ${EMB_CFG.apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: EMB_CFG.translateModel || 'qwen-turbo', + max_tokens: 80, + messages: [ + { role: 'system', content: 'Translate the user message into a concise English search query for a B2B jewelry wholesaler FAQ. Output ONLY the English translation, no explanation, no quotes.' }, + { role: 'user', content: query }, + ], + }), + }); + const d = await resp.json(); + const t = (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content || '').trim(); + return t.replace(/^["']|["']$/g, ''); + } catch (e) { + console.error('[kb translate err]', e.message); + return null; + } +} + +// 检索知识库,返回 [{question, answer, category, score}] +// 多语言查询召回不足时自动翻译成英文重检,合并两次结果。 +async function searchKB(query, topK = 4) { + try { + const vecStr = (v) => '[' + v.join(',') + ']'; + const runQ = async (q) => { + const vec = await embedQuery(q); + const r = await kbPool.query( + `SELECT question, answer, category, section, source_file, source_urls, 1 - (text_vec <=> $1::vector) AS score + FROM kb_docs ORDER BY text_vec <=> $1::vector LIMIT $2`, + [vecStr(vec), topK] + ); + return r.rows.map((row) => ({ question: row.question, answer: row.answer, category: row.category, section: row.section, source: row.source_file, sourceUrls: row.source_urls || [], score: Number(row.score) })); + }; + let hits = await runQ(query); + const topScore = hits.length ? hits[0].score : 0; + // 召回不足时翻译成英文重检(含中文:中文术语如"一件代发=dropshipping"翻译后召回更准)。 + // 仅跳过明显英文 query,避免无谓的 LLM 调用。 + const looksEnglish = /^[\x00-\x7F]+$/.test(query) && /\b(the|how|what|can|do|is|are|my|to|of|and|for|with|you|return|order|ship|pay|vat|invoice|register|account|discount|membership|dropship|refund|delivery)\b/i.test(query); + if (topScore < 0.55 && !looksEnglish && query.trim().length > 1) { + const en = await translateQueryToEnglish(query); + if (en && en.toLowerCase() !== query.toLowerCase()) { + const enHits = await runQ(en); + // 合并去重(按 question),保留较高 score + const map = new Map(); + for (const h of [...hits, ...enHits]) { + const prev = map.get(h.question); + if (!prev || h.score > prev.score) map.set(h.question, h); + } + hits = [...map.values()].sort((a, b) => b.score - a.score).slice(0, topK); + } + } + return hits; + } catch (e) { + console.error('[kb err]', e.message); + return []; + } +} + +// productKeywords / formatKBContext 已退役:tool-use 架构下 LLM 自主调 search_products/search_kb, +// 不再需要正则判断产品词,KB 片段拼接也移入 executeTool。 + +// ---------- 商品向量检索(共享 postgres pgvector + SiliconFlow embedding)---------- +const RECOMMEND_URL = process.env.RECOMMEND_URL || 'http://recommend.internal.yw.com'; + +async function searchRecommend(query, userId, topK = 5) { + try { + const params = "limit=" + topK + "&in_stock=true&site=eu&detail=true"; + const url = userId + ? RECOMMEND_URL + "/recommend/" + encodeURIComponent(userId) + "?" + params + : RECOMMEND_URL + "/search?q=" + encodeURIComponent(query) + "&" + params; + const resp = await fetch(url); + if (!resp.ok) return null; + const data = await resp.json(); + if (!data.recommendations || !data.recommendations.length) return null; + return { total: data.total, source: userId ? "personalized" : "search", items: data.recommendations }; + } catch (e) { + console.error("[recommend err]", e.message); + return null; + } +} + +// ---------- 订单/客户查询(对接 trading-service)---------- +// trading-service 是 Yehwang 的交易微服务(Hyperf/PHP,端口 9501/9512) +// 订单接口(ERP 端,无需登录态): +// GET admin/mall/api/trading/order/list?no=订单号 订单列表 +// GET admin/mall/api/trading/order/detail?orderId=X 订单详情 +// GET admin/mall/api/trading/order/getTrackPreSaleOrder?orderId=X 物流追踪 +// 返回格式: { code: 200, message: "success", data: {...} } +const ORDER_API_URL = process.env.ORDER_API_URL || ''; // trading-service base URL +const ORDER_API_KEY = process.env.ORDER_API_KEY || ''; + +// 查询订单状态/物流。args: {orderNumber?, customerEmail?, trackingNumber?} +async function searchOrders(args) { + if (!ORDER_API_URL) return { text: 'Order query API (trading-service) is not configured.', items: null }; + try { + const headers = { 'Content-Type': 'application/json' }; + if (ORDER_API_KEY) headers['Authorization'] = `Bearer ${ORDER_API_KEY}`; + + // 优先用订单号查询 + if (args.orderNumber) { + // 先查列表(用 no 过滤) + const listUrl = `${ORDER_API_URL}/admin/mall/api/trading/order/list?no=${encodeURIComponent(args.orderNumber)}&page=1&page_size=5`; + const listResp = await fetch(listUrl, { headers }); + if (!listResp.ok) return { text: `Order query failed (HTTP ${listResp.status}).`, items: null }; + const listData = await listResp.json(); + const orders = (listData.data && listData.data.list) || listData.data || []; + if (!orders.length) return { text: `No order found with number "${args.orderNumber}".`, items: null }; + + // 对第一个匹配的订单查详情 + const orderId = orders[0].orderId || orders[0].order_id || orders[0].id; + return await fetchOrderDetail(ORDER_API_URL, orderId, headers, orders[0]); + } + + // 用客户邮箱查询(ERP 列表接口支持 customerEmail 参数) + if (args.customerEmail) { + const listUrl = `${ORDER_API_URL}/admin/mall/api/trading/order/list?customerEmail=${encodeURIComponent(args.customerEmail)}&page=1&page_size=5`; + const listResp = await fetch(listUrl, { headers }); + if (!listResp.ok) return { text: `Order query failed (HTTP ${listResp.status}).`, items: null }; + const listData = await listResp.json(); + const orders = (listData.data && listData.data.list) || listData.data || []; + if (!orders.length) return { text: `No orders found for email "${args.customerEmail}".`, items: null }; + + // 返回最近订单列表(不逐一查详情,避免太多请求) + const lines = orders.slice(0, 5).map((o, i) => { + const no = o.orderNo || o.order_no || o.no || '?'; + const status = o.orderStatus || o.order_status || o.status || 'unknown'; + const total = o.totalPrice || o.total_price || o.total || ''; + const date = o.dateAdded || o.date_added || o.createdAt || ''; + return `${i + 1}. Order #${no} | Status: ${status}${total ? ` | Total: ${total}` : ''}${date ? ` | Date: ${date}` : ''}`; + }); + return { text: `Found ${orders.length} order(s) for ${args.customerEmail}:\n${lines.join('\n')}`, items: orders }; + } + + return { text: 'Please provide an order number or customer email to look up the order.', items: null }; + } catch (e) { + console.error("[orders err]", e.message); + return { text: 'Order query encountered an error (trading-service). Please try again or contact helpdesk@yehwang.com.', items: null }; + } +} + +// 查询单个订单详情 + 物流追踪 +async function fetchOrderDetail(baseUrl, orderId, headers, listInfo) { + // 并行查详情 + 物流 + const [detailRes, trackRes] = await Promise.allSettled([ + fetch(`${baseUrl}/admin/mall/api/trading/order/detail?orderId=${encodeURIComponent(orderId)}`, { headers }), + fetch(`${baseUrl}/admin/mall/api/trading/order/getTrackPreSaleOrder?orderId=${encodeURIComponent(orderId)}`, { headers }), + ]); + + let detail = null, tracking = null; + if (detailRes.status === 'fulfilled' && detailRes.value.ok) { + const dj = await detailRes.value.json(); + detail = dj.data || dj; + } + if (trackRes.status === 'fulfilled' && trackRes.value.ok) { + const tj = await trackRes.value.json(); + tracking = tj.data || tj; + } + + if (!detail && !listInfo) return { text: `No order detail found for order ID ${orderId}.`, items: null }; + + const d = detail || listInfo; + const lines = []; + lines.push(`Order #${d.orderNo || d.order_no || d.no || orderId}`); + lines.push(` Status: ${d.orderStatus || d.order_status || d.status || 'unknown'}`); + lines.push(` Shipping status: ${d.shippingStatus || d.shipping_status || 'N/A'}`); + if (d.totalPrice || d.total_price) lines.push(` Total: ${d.totalPrice || d.total_price}`); + if (d.dateAdded || d.date_added) lines.push(` Date: ${d.dateAdded || d.date_added}`); + if (d.shippingMethod || d.shipping_method) lines.push(` Shipping method: ${d.shippingMethod || d.shipping_method}`); + + // 物流信息 + if (tracking) { + const parcels = Array.isArray(tracking) ? tracking : (tracking.list || [tracking]); + parcels.forEach((p, i) => { + const tn = p.trackingNumber || p.tracking_number || ''; + const sc = p.shippingCode || p.shipping_code || ''; + const sm = p.shippingMethod || p.shipping_method || ''; + const sa = p.shippingAt || p.shipping_at || ''; + if (tn || sm) { + lines.push(` Parcel ${i + 1}: ${sm} ${tn}${sa ? ` (shipped: ${sa})` : ''}`); + } + }); + } + + // 商品明细 + const items = d.products || d.items || []; + if (items.length) { + lines.push(' Items:'); + items.slice(0, 5).forEach(it => { + lines.push(` - ${it.name || it.productName || 'item'} (SKU: ${it.sku || it.model || '?'}) x${it.quantity || 1} @ ${it.price || ''}`); + }); + } + + return { text: lines.join('\n'), items: { detail, tracking, listInfo } }; +} + +// ---------- 库存/价格查询(对接 eta-service)---------- +// eta-service 是 Yehwang 的库存微服务(Hyperf/PHP,端口 9501/9701) +// 接口:GET /eta/stock-summary?product_id=X 库存汇总 +// GET /eta/query?product_id=X ETA + 可用数量 +const INVENTORY_API_URL = process.env.INVENTORY_API_URL || ''; // eta-service base URL +const INVENTORY_API_KEY = process.env.INVENTORY_API_KEY || ''; + +// 查询实时库存/ETA。args: {sku?, productName?} +// eta-service 用 product_id(数字),sku 可能有字母前缀,尝试解析数字部分 +async function searchInventory(args) { + if (!INVENTORY_API_URL) return { text: 'Inventory query API (eta-service) is not configured.', items: null }; + try { + const headers = { 'Content-Type': 'application/json' }; + if (INVENTORY_API_KEY) headers['Authorization'] = `Bearer ${INVENTORY_API_KEY}`; + + // 从 sku 或 productName 中提取 product_id + // eta-service 的 product_id 是数字;客户可能给 "NECK-001" 或纯数字 "12345" + let productId = null; + if (args.sku) { + const numMatch = String(args.sku).match(/\d+/); + if (numMatch) productId = numMatch[0]; + } + if (!productId && args.productName) { + const numMatch = String(args.productName).match(/\d+/); + if (numMatch) productId = numMatch[0]; + } + if (!productId) { + return { text: `Could not extract a product ID from "${args.sku || args.productName}". Please provide a valid product ID or SKU number.`, items: null }; + } + + // 并行查库存汇总 + ETA + const [stockRes, etaRes] = await Promise.allSettled([ + fetch(`${INVENTORY_API_URL}/eta/stock-summary?product_id=${encodeURIComponent(productId)}`, { headers }), + fetch(`${INVENTORY_API_URL}/eta/query?product_id=${encodeURIComponent(productId)}`, { headers }), + ]); + + // eta-service 返回: { code: 200, message: "success", data: {...} } + let stockData = null, etaData = null; + if (stockRes.status === 'fulfilled' && stockRes.value.ok) { + const sj = await stockRes.value.json(); + stockData = sj.data || sj; + } + if (etaRes.status === 'fulfilled' && etaRes.value.ok) { + const ej = await etaRes.value.json(); + etaData = ej.data || ej; + } + + if (!stockData && !etaData) { + return { text: `No inventory data found for product ID ${productId}.`, items: null }; + } + + // 格式化为 LLM 可读文本 + const lines = []; + if (stockData) { + const instant = stockData.instantStock != null ? stockData.instantStock : 'N/A'; + const inTransit = stockData.inTransitQuantity != null ? stockData.inTransitQuantity : 'N/A'; + const reserved = stockData.reservedQuantity != null ? stockData.reservedQuantity : 'N/A'; + const frozen = stockData.frozenQuantity != null ? stockData.frozenQuantity : 'N/A'; + const available = (typeof instant === 'number' && typeof reserved === 'number') ? Math.max(0, instant - reserved) : 'N/A'; + lines.push(`Stock Summary (product_id: ${productId}):`); + lines.push(` Instant stock: ${instant}`); + lines.push(` Available (instant - reserved): ${available}`); + lines.push(` In transit: ${inTransit}`); + lines.push(` Reserved: ${reserved}`); + lines.push(` Frozen: ${frozen}`); + } + if (etaData) { + lines.push(`ETA Info:`); + lines.push(` ETA date: ${etaData.etaDate || 'N/A'}`); + lines.push(` Available quantity: ${etaData.availableQuantity != null ? etaData.availableQuantity : 'N/A'}`); + lines.push(` Status: ${etaData.status || 'N/A'}`); + lines.push(` Confidence: ${etaData.confidence != null ? etaData.confidence : 'N/A'}`); + } + return { text: lines.join('\n'), items: { stock: stockData, eta: etaData } }; + } catch (e) { + console.error("[inventory err]", e.message); + return { text: 'Inventory query encountered an error (eta-service). Please try again or contact helpdesk@yehwang.com.', items: null }; + } +} + +const EXTRACT_SYSTEM = `You are an information extractor. Based on a customer service conversation (which may be in English, German, French, Spanish, Italian, Dutch, or Chinese), judge whether enough sales-lead info has been collected. + +Judge ready=true only if the conversation clearly contains a [company name] AND a [product/need of interest] AND [contact info — at least an email or phone, OR a contact person's name] — in ANY supported language. + +IMPORTANT for the "contact" field: If at any point the bot asked for a contact name (联系人姓名/联系人/your name) and the customer replied with a short word (like "tiema", "John", "张三", a nickname), THAT is the contact name — put it in the "contact" field. Do not leave it empty just because it doesn't look like a full name. + +For fields not present in the conversation, leave them empty — do NOT make anything up. +The "name" and "summary" fields should ALWAYS be in ENGLISH (regardless of customer language), so they can be used as CRM data. + +Output ONLY one JSON object, nothing else, no markdown code fences: +{ + "ready": true|false, + "opportunity": { + "name": "", + "company": "", + "domain": "", + "contact": "", + "email": "", + "phone": "", + "amount": , + "currency": "", + "closeDate": "", + "stage": "", + "summary": "" + } +}`; + +async function extractOpportunity(history) { + // history: [{role, content}] + const messages = [ + ...history, + { role: 'user', content: '请根据以上对话输出 JSON。' }, + ]; + const result = await callLLM(messages, EXTRACT_SYSTEM, 4096); + const cleaned = result.text.replace(/```json|```/g, '').trim(); + const m = cleaned.match(/\{[\s\S]*\}/); + if (!m) return { ready: false }; + try { + return JSON.parse(m[0]); + } catch { + return { ready: false }; + } +} + +// ---------- Agent tools (function calling) ---------- +// LLM 自主调用以下工具完成检索/转人工,bridge 执行后把结果回填,再由 LLM 生成最终回复。 +// 相比手写意图路由:路由决策交给 LLM,且工具返回在干净上下文生成,不受销售历史污染。 +const AGENT_TOOLS = [ + { + type: 'function', + function: { + name: 'search_kb', + description: 'Search the Yehwang official FAQ/knowledge base for policy answers (registration, account, password, minimum order, cancel, pre-order, payment, VAT, invoice, shipping, delivery, returns, refunds, membership, discounts, dropshipping, factory-direct, materials, jewelry care, etc.). Call this for ANY policy/FAQ question.', + parameters: { type: 'object', properties: { query: { type: 'string', description: 'the customer question, in any language' } }, required: ['query'] }, + }, + }, + { + type: 'function', + function: { + name: 'search_products', + description: 'Search the product catalog for items the customer wants to find/buy (jewelry, accessories, clothing). Returns product list with prices and images.', + parameters: { type: 'object', properties: { query: { type: 'string', description: 'product keywords from the customer' } }, required: ['query'] }, + }, + }, + { + type: 'function', + function: { + name: 'escalate_human', + description: 'Escalate to a human agent when the question is outside the knowledge base/product scope or you cannot confidently answer.', + parameters: { type: 'object', properties: { reason: { type: 'string' } }, required: ['reason'] }, + }, + }, + { + type: 'function', + function: { + name: 'search_orders', + description: 'Look up an existing order status, tracking number, shipment details, or delivery estimate. Call when the customer asks about their order ("where is my order", "order status", "tracking", "when will it arrive"). Provide order number if known, otherwise use customer email.', + parameters: { + type: 'object', + properties: { + orderNumber: { type: 'string', description: 'the order number, e.g. "12345" or "YW-2026-12345"' }, + customerEmail: { type: 'string', description: 'customer email, used to find recent orders when order number is unknown' }, + trackingNumber: { type: 'string', description: 'tracking number for direct shipment lookup' }, + }, + required: [], + }, + }, + }, + { + type: 'function', + function: { + name: 'search_inventory', + description: 'Check real-time stock and price for a specific SKU or product name. Call when the customer asks "is it in stock", "how many available", "what is the price of SKU xxx", "can I order 500 pieces" (to verify stock before confirming). Note: search_products is for browsing the catalog; search_inventory is for checking real-time stock/price on a specific item before placing an order.', + parameters: { + type: 'object', + properties: { + sku: { type: 'string', description: 'the product SKU/code' }, + productName: { type: 'string', description: 'product name to search inventory by name' }, + }, + required: [], + }, + }, + }, +]; + +// 商品图片 markdown(bridge 在最终回复后追加,因为 LLM 无法直接发图) +function buildProductListText(items) { + return '\n\n📷 点击查看商品详情:\n' + items.slice(0, 3).map((p) => { + const title = p.title || 'Fashion Accessory'; + const link = (p.product_url || '').replace(/ /g, '%20'); + const img = (p.image_url || '').replace(/ /g, '%20'); + const price = p.min_price != null ? '€' + p.min_price : 'View page'; + const stock = p.stock != null ? ' | stock: ' + p.stock : ''; + const imgMd = img ? '\n![' + title + '](' + img + ')' : ''; + return '[' + title + '](' + link + ') — ' + price + stock + imgMd; + }).join('\n\n'); +} + +// 格式化 KB 命中片段为 LLM 上下文(含来源 URL,供回复时附上详细资料链接) +function formatKB(hits) { + return hits.map((h, i) => { + const src = (h.sourceUrls && h.sourceUrls.length) ? h.sourceUrls.join(' / ') : ''; + return `[${i + 1}] (category: ${h.category})\nQ: ${h.question}\nA: ${h.answer}${src ? '\nSource: ' + src : ''}`; + }).join('\n\n---\n\n'); +} + +// 从 KB hits 收集去重的来源 URL(取前2个,供回复时附详细资料链接) +// 从 KB hits 收集来源 URL:只取最相关(top1) chunk 的来源,避免跨类目混清(如退货召回了 factory-direct chunk 会张冠李戴)。 +// 同一 chunk 可能有多个 URL(如 dropshipping 的 faq 分页),取前2个。 +function collectSources(hits) { + if (!hits.length) return []; + const top = hits[0]; + return (top.sourceUrls || []).slice(0, 2); +} + +// 执行单个 tool,返回文本结果(喂回 LLM) + 副产品(productItems 供 bridge 追加图片) +async function executeTool(name, args, ctx) { + if (name === 'search_kb') { + const hits = await searchKB(args.query, 4); + if (!hits.length) return { text: 'No relevant knowledge base entries found.', productItems: null, kbSources: [] }; + return { text: formatKB(hits.slice(0, 3)), productItems: null, kbSources: collectSources(hits) }; + } + if (name === 'search_products') { + const kw = (args.query || '').trim(); + // 新品/爆款/trending 等直接交给推荐引擎,它自己能排新/热的出来 + const searchUrl = 'https://www.yehwang.com/search?keyword=' + encodeURIComponent(kw); + const res = await searchRecommend(kw, null, 5); + if (!res || !res.items || !res.items.length) { + return { text: `No exact matches via internal search. Direct the customer to browse all results here: ${searchUrl}`, productItems: [] }; + } + const items = res.items; + const text = `INSTRUCTION: Show these products to the customer. Use EXACT names. If they are clearly irrelevant (e.g. "toy train" returned a handbag), only then say we don't carry it and skip the search link. Otherwise, present them naturally — don't start with "no exact match" if the products are reasonably related. Only mention discounts if asked.\n\nPRODUCTS:\n` + + items.slice(0, 3).map((p, i) => + `${i + 1}. ${p.title || 'item'} — ${p.min_price != null ? '€' + p.min_price : 'price on page'}${p.stock != null ? ' (stock ' + p.stock + ')' : ''} — ${p.product_url || ''}` + ).join('\n') + `\n\nFor more results, share this search link with the customer (browse all matches with public prices): ${searchUrl}`; + return { text, productItems: items }; + } + if (name === 'escalate_human') { + // 真 handoff:改会话状态为 open + 清 bot assignee,人类接管;bridge 后续不再抢答(靠 locks+assignee 判断) + const reason = args.reason || 'escalated by AI'; + if (ctx && ctx.conversationId) await handoffToHuman(ctx.conversationId, reason); + return { text: 'You have escalated this conversation to a human agent. The conversation status is now open and unassigned from the bot. Tell the customer a human teammate will take over shortly; for urgent matters they can also email helpdesk@yehwang.com / +31 318 668 171 (website & in-stock) or service@yehwang.com (Factory-Direct). Stop answering further - hand off now.', productItems: null, handoff: true }; + } + if (name === 'search_orders') { + const res = await searchOrders(args); + return { text: res.text, productItems: null }; + } + if (name === 'search_inventory') { + const res = await searchInventory(args); + return { text: res.text, productItems: null }; + } + return { text: 'Unknown tool.', productItems: null }; +} + +// Agent 主循环:LLM 决策调 tool -> 执行 -> 回填 -> 再生成,直到拿到纯文本回复或达到轮次上限。 +// 返回 { reply, productItems }。prefetchKb: 预先启动的 searchKB promise(收到消息即并行检索,省掉决策后串行等待)。 +async function runAgent(history, conversationId, prefetchKb = null) { + const messages = [...history]; + let productItems = null; + let kbSources = []; + let handoff = false; + const MAX_TURNS = 5; // 防止死循环 + for (let turn = 0; turn < MAX_TURNS; turn++) { + const res = await callLLM(messages, AGENT_SYSTEM, 768, AGENT_TOOLS); + console.log(`[agent] conv=${conversationId} turn=${turn} finish=${res.finishReason} tools=${res.toolCalls.map(t => t.name).join(',') || 'none'}`); + if (!res.toolCalls.length) { + // 纯文本回复 -> 结束 + return { reply: res.text, productItems, kbSources, handoff }; + } + // 把 assistant 的 tool_calls 消息加入上下文 + messages.push({ + role: 'assistant', + content: res.text || '', + tool_calls: res.toolCalls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } })), + }); + // 执行每个 tool 并回填 tool 角色消息 + for (const tc of res.toolCalls) { + let out; + // search_kb 且参数与预检索 query 一致 -> 复用预检索结果(已并行启动,此刻通常已就绪) + if (tc.name === 'search_kb' && prefetchKb) { + const hits = await prefetchKb; + out = hits.length + ? { text: formatKB(hits.slice(0, 3)), productItems: null, kbSources: collectSources(hits) } + : { text: 'No relevant knowledge base entries found.', productItems: null, kbSources: [] }; + } else { + out = await executeTool(tc.name, tc.arguments, { conversationId }); + } + console.log(`[agent] conv=${conversationId} tool=${tc.name} args=${JSON.stringify(tc.arguments).slice(0, 60)} -> ${out.text.slice(0, 80)}`); + if (out.productItems) productItems = out.productItems; // 保留最后一次商品检索结果 + if (out.kbSources && out.kbSources.length) kbSources = out.kbSources; // 保留 KB 来源 URL + messages.push({ role: 'tool', tool_call_id: tc.id, content: out.text }); + if (out.handoff) { handoff = true; break; } // 转人工后停止 agent 循环 + } + if (handoff) break; + } + // 轮次用尽,强制再要一次纯文本回复 + messages.push({ role: 'user', content: 'Please give the customer a final answer now based on the information gathered.' }); + const res = await callLLM(messages, AGENT_SYSTEM, 768, null); + return { reply: res.text || 'Sorry, I could not process that. Please email helpdesk@yehwang.com.', productItems, kbSources, handoff }; +} + +// ---------- 主处理流程 ---------- +async function handleIncomingMessage(conversationId, incomingText, imageUrls) { + if (locks.has(conversationId)) { + console.log(`[skip] conversation ${conversationId} 正在处理中`); + return; + } + locks.add(conversationId); + setTyping(conversationId, true); // 立即“正在输入”,消除无响应体感 + try { + // 1. 拉取会话历史 + const msgs = await fetchConversationMessages(conversationId); + // 映射为 LLM messages: incoming(客户)=user, outgoing(客服/bot)=assistant + // bot 回复里的商品 markdown 列表对 LLM 判断线索没用,只保留文案部分(第一段) + let history = msgs + .filter((m) => m.content && typeof m.content === 'string' && m.content.trim()) + .filter((m) => m.message_type !== 3 && m.content_type !== 'input_email' && m.content_type !== 'input_phone') // 过滤 Chatwoot 欢迎语/表单模板 + .map((m) => { + let content = stripHtml(m.content); + const isBot = !(m.message_type === 'incoming' || m.message_type === 0); + if (isBot) { + // bot 回复: 去掉 markdown 链接/图片行,只留自然语言文案 + content = content.split('\n').filter(l => + !l.match(/^\s*\[.*\]\(http/) && // markdown 链接 + !l.match(/^\s*!\[.*\]\(http/) && // markdown 图片 + !l.match(/^\s*\*\*.*\*\*\s*—\s*€/) // 商品价格行 + ).join(' ').trim(); + } + return { role: isBot ? 'assistant' : 'user', content }; + }) + .filter((m) => m.content); // 过滤掉清理后变空的 + // 合并连续相同 role,避免协议报错 + history = history.reduce((acc, m) => { + const last = acc[acc.length - 1]; + if (last && last.role === m.role) last.content += '\n' + m.content; + else acc.push(m); + return acc; + }, []); + // 如果 history 为空(新会话,消息还没入库),用当前消息作为第一条 + if (history.length === 0) { + const q = stripHtml(incomingText || ''); + if (q) history.push({ role: 'user', content: q }); + else { await sendChatwootReply(conversationId, 'Hello! How can I help you today?'); return; } + } + // 保证第一条是 user + if (history[0].role !== 'user') { + history.unshift({ role: 'user', content: 'Hello' }); + } + // 关键:确保当前用户消息在 history 末尾(fetch 拉的是已入库历史,当前消息可能还没入库, + // 否则 runAgent 拿到的末尾是旧 assistant 收尾,LLM 看不到客户新问题 -> 不调 tool) + const curQ = stripHtml(incomingText || ''); + if (curQ) { + const last = history[history.length - 1]; + if (!last || last.role !== 'user' || last.content !== curQ) { + history.push({ role: 'user', content: curQ }); + } + } + + // 2. Agent tool-use 循环:LLM 自主决定调 search_kb / search_products / escalate_human, + // 工具返回在干净上下文生成回复,不受销售历史污染。 + const query = stripHtml(incomingText || ''); + console.log('[agent start] conv=' + conversationId + ' query="' + query.slice(0, 50) + '"'); + // 预并行 KB 检索:收到消息即启动,LLM 决策调 search_kb 时直接复用(并行,不额外耗时) + const prefetchKb = query ? searchKB(query, 4).catch(() => []) : null; + let reply, productItems, kbSources = [], handoff = false; + try { + // 全走 agent tool-use:路由决策交给 LLM,不再用正则猜意图(脆、不可扩展、与 tool-use 架构矛盾)。 + ({ reply, productItems, kbSources, handoff } = await runAgent(history.slice(-6), conversationId, prefetchKb)); + console.log('[llm raw] ' + (reply || 'NULL')); + } catch (llmErr) { + console.error('[llm error] conv=' + conversationId + ':', llmErr.message); + } + + // 商品图片:若 agent 调了 search_products,bridge 在文案后追加商品 markdown(带图) + let finalReply = reply || 'Hello! How can I help you today?'; + if (handoff && !reply) { + finalReply = '已为您转接人工客服,稍后会有同事为您服务。紧急事项也可邮件 helpdesk@yehwang.com / service@yehwang.com。'; + } else if (productItems && productItems.length) { + // LLM 说没有匹配时不追加商品卡(如客户问火车玩具,推荐引擎仍返回商品,但 LLM 判断不匹配) + const saidNo = /(?:没有|不存在|未匹配|未找到|no matching|don.t carry|不在)/i.test(reply || ''); + if (!saidNo) { + const validItems = productItems.filter((p) => p.title && p.title !== 'Fashion Accessory'); + if (validItems.length) finalReply = (reply || 'Here are some options:') + '\n' + buildProductListText(productItems); + } + } else if (!reply) { + finalReply = 'Thanks for your message — our team will follow up shortly.'; + } + // 程序化追加“详细资料”来源 URL(去重;若 LLM 文案已含该 URL 则跳过,避免重复) + if (kbSources.length) { + const missing = kbSources.filter((u) => !finalReply.includes(u)); + if (missing.length) { + finalReply = finalReply.replace(/\s+$/, '') + '\n详见官方政策 / Details: ' + missing.map((u) => u).join(' · '); + } + } + await sendChatwootReply(conversationId, fixupLinks(finalReply)); + setTyping(conversationId, false); + console.log('[reply] conv=' + conversationId + ' -> ' + finalReply.slice(0, 200)); + + // CRM:异步启动不阻塞锁,下一条消息不会被 skip + setImmediate(() => { extractAndCreateCRM(history, query, finalReply, conversationId).catch(e => console.error('[CRM async err]', e.message)); }); + } catch (e) { + console.error(`[error] conv=${conversationId}:`, e.message); + try { + await sendChatwootReply(conversationId, 'Sorry, something went wrong on our side. Please try again in a moment.'); + } catch {} + } finally { + locks.delete(conversationId); + } +} + +// 后台异步抽取销售线索并写入 CRM(不阻塞锁,失败不影响主回复) +async function extractAndCreateCRM(history, query, finalReply, conversationId) { + try { + const preExtract = await extractOpportunity( + [...history.filter(m => m.content !== query), { role: 'user', content: query }] + ).catch(() => ({ ready: false })); + const ex = await extractOpportunity( + [...history.filter(m => m.content !== query), { role: 'user', content: query }, { role: 'assistant', content: finalReply }] + ).catch(() => preExtract); + console.log(`[extract] conv=${conversationId} ready=${ex.ready}`, ex.opportunity || ''); + if (!ex.ready || !ex.opportunity || createdOpps.has(conversationId)) return; + const o = ex.opportunity; + const lead = await createLeadInCRM({ + company: o.company || null, domain: o.domain || null, + contactName: o.contact || null, email: o.email || null, phone: o.phone || null, + oppName: o.name || 'Lead from website chat', amount: o.amount || null, + currency: o.currency || 'EUR', stage: o.stage || 'NEW', + conversationId, + }); + createdOpps.add(conversationId); + console.log(`[CRM✓] conv=${conversationId} 建线索:`, JSON.stringify(lead), + lead.companyCreated ? '(新公司)' : '(公司已存在)', + lead.personCreated ? '(新联系人)' : '(联系人已存在)'); + // 异步推聊天记录到 Twenty note + setImmediate(() => addChatToOpportunity(conversationId, lead.opportunityId).catch(e => console.error('[note err]', e.message.slice(0,100)))); + // 异步推聊天记录到 Twenty note(不阻塞) + setImmediate(() => addChatToOpportunity(conversationId, lead.opportunityId).catch(() => {})); + } catch (e) { + console.error(`[CRM✗] conv=${conversationId}`, e.message); + } +} + + +// AgentBot secret(HMAC 签名校验),注册 AgentBot 时生成,环境变量覆盖 +const AGENT_BOT_SECRET = process.env.AGENT_BOT_SECRET || ''; + +// 会话开场白:conversation_opened/webwidget_triggered 时 bot 主动问候(对标企业版 Captain) +const WELCOME = '您好!我是 Yehwang 智能客服,可帮您查询商品、下单/支付/物流/退货/会员等政策,也支持转人工。请问有什么可以帮您?'; +async function sendWelcome(conversationId) { + try { await sendChatwootReply(conversationId, WELCOME); } catch {} +} + +// 校验 AgentBot 的 HMAC 签名:X-Chatwoot-Signature = sha256=HMAC(secret, "{ts}.{body}") +function verifyAgentBotSignature(req, body) { + if (!AGENT_BOT_SECRET) return true; // 未配 secret 时跳过校验(本地开发) + // 本地开发:HMAC 校验体差异难排查,跳过(Docker 内网安全) + if (process.env.SKIP_AGENT_BOT_SIGNATURE === 'true') return true; + const sig = req.headers['x-chatwoot-signature']; + const ts = req.headers['x-chatwoot-timestamp']; + if (!sig || !ts) { console.error('[agent-bot] 缺少签名头 sig=', sig, 'ts=', ts); return false; } + const expected = 'sha256=' + crypto.createHmac('sha256', AGENT_BOT_SECRET).update(`${ts}.${body}`).digest('hex'); + const age = Math.abs(Date.now() / 1000 - Number(ts)); + if (age > 300) { console.error('[agent-bot] 时间偏移过大 age=', age); return false; } + if (sig !== expected) { + console.error('[agent-bot] HMAC不匹配'); + console.error('[agent-bot] 收到sig:', sig.slice(0, 16)); + console.error('[agent-bot] 期望sig:', expected.slice(0, 16)); + console.error('[agent-bot] body长度:', body.length, 'body前80字:', (body||'').slice(0, 80)); + } + return sig === expected; +} + +// ---------- HTTP 服务 ---------- +const server = http.createServer(async (req, res) => { + // 健康检查 + if (req.method === 'GET' && (req.url === '/healthz' || req.url === '/')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: true, service: 'chatwoot-bridge', agentBot: !!AGENT_BOT_SECRET })); + } + + // CRM 拉取会话记录接口(GET /conversation/:id) + const convMatch = req.url.match(/^\/conversation\/(\d+)$/); + if (req.method === 'GET' && convMatch) { + try { + const msgs = await fetchConversationMessages(parseInt(convMatch[1])); + const cleaned = msgs + .filter((m) => m.content && typeof m.content === 'string' && m.content.trim()) + .map((m) => ({ + role: m.message_type === 0 || m.message_type === 'incoming' ? 'customer' : 'bot', + content: stripHtml(m.content), + time: m.created_at || null, + })); + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ conversationId: parseInt(convMatch[1]), messages: cleaned })); + } catch (e) { + res.writeHead(500); + return res.end(JSON.stringify({ error: e.message })); + } + } + + // 会话 HTML 嵌入页(GET /conversation/:id/html — Twenty iframe 用) + const htmlMatch = req.url.match(/^\/conversation\/(\d+)\/html$/); + if (req.method === 'GET' && htmlMatch) { + try { + const msgs = await fetchConversationMessages(parseInt(htmlMatch[1])); + const items = msgs + .filter((m) => m.content && typeof m.content === 'string' && m.content.trim()) + .map((m) => { + const role = m.message_type === 0 || m.message_type === 'incoming' ? '客户' : '客服'; + return `

${role}: ${stripHtml(m.content).replace(//g,'>')}
`; + }) + .join(''); + const html = `${items}`; + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(html); + } catch (e) { + res.writeHead(500); + return res.end(e.message); + } + } + + // Chatwoot AgentBot outgoing 端点(官方机制,带 secret 签名,事件更全) + if (req.method === 'POST' && req.url === '/agent-bot') { + let body = ''; + for await (const chunk of req) body += chunk; + // 签名校验 + if (!verifyAgentBotSignature(req, body)) { + console.error('[agent-bot] 签名校验失败'); + res.writeHead(401); return res.end('invalid signature'); + } + let payload; + try { payload = JSON.parse(body || '{}'); } + catch (e) { res.writeHead(400); return res.end('bad json'); } + res.writeHead(200); res.end('ok'); // 先 200,异步处理 + + const event = payload.event; + const conv = payload.conversation || {}; + const conversationId = conv.id; + const msg = payload.message || {}; + console.log(`\n[agent-bot] event=${event} conv=${conversationId} status=${conv.status}`); + + if (event === 'conversation_opened' || event === 'webwidget_triggered') { + // 会话开启:主动问候(仅新会话/无历史消息时) + if (event === 'webwidget_triggered') sendWelcome(conversationId).catch(() => {}); + return; + } + if (event === 'message_created') { + const messageType = msg.message_type; + const senderType = msg.sender_type || (msg.sender && msg.sender && msg.sender.type); + // 只处理客户消息;bot 作 assignee 时才抢答(否则已 handoff 给人类,不介入) + if (messageType === 'incoming' || senderType === 'Contact') { + const imageUrls = (msg.attachments || []) + .filter(a => a.file_type === 'image' || (a.file_type || '').toLowerCase().includes('image')) + .map(a => a.data_url || a.file_url || (a.data && a.data.url)).filter(Boolean); + handleIncomingMessage(conversationId, msg.content, imageUrls).catch((e) => + console.error('[handle error]', e.message)); + } + return; + } + // conversation_resolved / status_changed 等事件:记录即可 + return; + } + + // Chatwoot webhook(兼容旧入口,未配 AgentBot 时仍可用) + if (req.method === 'POST' && req.url === '/chatwoot-webhook') { + let body = ''; + for await (const chunk of req) body += chunk; + let payload; + try { + payload = JSON.parse(body || '{}'); + } catch (e) { + console.error('[webhook] JSON parse err:', e.message, 'body:', body.slice(0, 200)); + res.writeHead(400); + return res.end('bad json'); + } + res.writeHead(200); res.end('ok'); // 先 200,异步处理 + + const msg = payload.message || payload; + const conversationId = (payload.conversation && payload.conversation.id) || msg.conversation_id; + const messageType = msg.message_type; // incoming=客户, outgoing=客服/bot + const senderType = msg.sender_type || (msg.sender && msg.sender.type); + + console.log(`\n[webhook] event=${payload.event || '?'} conv=${conversationId} type=${messageType} sender=${senderType} content="${(msg.content || '').slice(0, 40)}"`); + + // 提取客户发的图片附件(以图搜图用) + const imageUrls = (msg.attachments || []) + .filter(a => a.file_type === 'image' || (a.file_type || '').toLowerCase().includes('image')) + .map(a => a.data_url || a.file_url || (a.data && a.data.url)) + .filter(Boolean); + if (imageUrls.length) console.log(`[webhook] 客户发图片 ${imageUrls.length} 张,启用以图搜图`); + + // 只处理客户发来的消息(避免无限循环) + if (messageType === 'incoming' || senderType === 'Contact') { + handleIncomingMessage(conversationId, msg.content, imageUrls).catch((e) => + console.error('[handle error]', e.message) + ); + } + return; + } + + res.writeHead(404); + res.end('not found'); +}); +server.listen(PORT, () => { + console.log(`Chatwoot bridge listening on :${PORT}`); + console.log(` Chatwoot: ${cw.baseURL} (account ${cw.accountId})`); + console.log(` Twenty : ${twenty.baseURL} (workspace ${twenty.workspaceId})`); + console.log(` LLM : ${MODEL} @ ${PROVIDER.baseUrl}`); +}); diff --git a/bridge/twenty.config.example.json b/bridge/twenty.config.example.json new file mode 100644 index 0000000..b8d847d --- /dev/null +++ b/bridge/twenty.config.example.json @@ -0,0 +1,6 @@ +{ + "baseURL": "http://localhost:3000", + "graphqlPath": "/graphql", + "workspaceId": "70a1901c-45ab-4b95-a7f0-de74be819b2e", + "apiKey": "YOUR_TWENTY_API_KEY_JWT" +} \ No newline at end of file diff --git a/customer-service-kb/00-about-yehwang.md b/customer-service-kb/00-about-yehwang.md new file mode 100644 index 0000000..e810af1 --- /dev/null +++ b/customer-service-kb/00-about-yehwang.md @@ -0,0 +1,46 @@ +--- +title: About Yehwang — who we are, what we sell, how we ship +category: company +source: company-profile + catalog snapshot 2026-06-25 + register FAQ +source_url: + - https://www.yehwang.com/about-us.html + - https://www.yehwang.com/register.html + - "数仓表 wa_dim.dim_product_full (快照 2026-06-25)" + - "内部文档 公司介绍.md.txt" +last_updated: 2026-06-25 +keywords: [about, company, B2B, wholesale, jewelry, accessories, clothing, Netherlands, fulfillment, brands, Gimme] +--- + +# About Yehwang + +> 📎 来源 / Source: https://www.yehwang.com/about-us.html · 数据 `wa_dim.dim_product_full` · 抓取 2026-06-25 + +### Q: Who is Yehwang / what kind of company are you? +Yehwang is a Netherlands-based (Dutch-registered) **B2B wholesaler** serving the fashion, wellness & living industries. We supply jewelry, accessories, and clothing to retailers and online sellers, primarily across Europe. We sell through three channels: physical wholesale stores, our B2B wholesale website (yehwang.com), and a dropshipping platform (stock shipped from our Dutch warehouse on behalf of sellers). + +### Q: What products do you sell? +We carry a very broad assortment (100,000+ live products). The main categories are: +- **Women's jewelry** — earrings, pendants, necklaces, bracelets, rings, piercing jewelry, jewelry sets, and jewelry-making supplies (the largest category by far). +- **Accessories** — hair accessories, scarves, bag charms, glasses, phone accessories, bags, hats, makeup bags, socks, keychains. +- **Clothing** — women's tops, bottoms, knitwear, denim (our own clothing label is **Gimme**). +- **Packing & Display** — wholesale packaging materials and display solutions. +- Plus **High-end jewelry**, **Men's jewelry**, **Children's jewelry**, and **Beauty & makeup tools**. + +### Q: What price range are your products in? +Prices are wholesale unit prices. Most items fall between roughly **€1.40 and €4.70** per piece (median around €2.80), with clothing typically higher (around €14 median) and high-end jewelry higher still. Exact prices are visible on the website after you log in to your business account. + +### Q: Where do you ship from / what are your fulfillment models? +We operate two models: +1. **In-stock from the Netherlands** — products held in our Dutch warehouse are shipped across Europe via PostNL or DHL. Fast EU delivery. +2. **Factory-Direct** — selected products ship directly from our manufacturing facilities (China) to customers worldwide, removing warehousing/middleman cost. See [07-factory-direct](07-factory-direct.md). + +### Q: What languages and countries do you serve? +Our website and catalog are available in multiple European languages (English, Dutch, German, Spanish, French, Italian, Portuguese). Our customer base is concentrated in the Netherlands, France, Belgium, Germany, Spain, Italy, the UK, Portugal, and Greece, but we ship worldwide. + +### Q: What is Gimme? +Gimme is Yehwang's own clothing label, known for trendy, stylish designs aligned with the latest fashion trends, ranging from casual to chic. + +### Q: How do I contact customer service? +- General / website / self-operated orders: **helpdesk@yehwang.com**, phone **+31 318 668 171**. +- Factory-Direct orders: **service@yehwang.com**. +Our headquarters (and returns address) is in **Ede, The Netherlands**. diff --git a/customer-service-kb/01-registration-account.md b/customer-service-kb/01-registration-account.md new file mode 100644 index 0000000..7cbe8f1 --- /dev/null +++ b/customer-service-kb/01-registration-account.md @@ -0,0 +1,42 @@ +--- +title: Registration & Account +category: registration +source: yehwang.com/register.html (official FAQ, verbatim) +source_url: + - https://www.yehwang.com/register.html + - https://www.yehwang.com/other-questions-1987986538 +last_updated: 2026-06-25 +keywords: [register, account, sign up, VAT number, Chamber of Commerce, CoC, individual, password, reset, find a shop, newsletter, images] +--- + +# Registration & Account + +> 📎 来源 / Source: https://www.yehwang.com/register.html · 抓取 2026-06-25 + +### Q: Who can register as a customer at Yehwang? +Yehwang is a B2B wholesaler serving the fashion, wellness & living industries. A valid VAT number is required **only for Dutch-based businesses**. For all other countries, no VAT number is necessary to open an account. + +### Q: I do not have a company but I want to purchase your products. Is this possible? +Yes. A Chamber of Commerce (CoC) number is **only required for businesses based in the Netherlands**. For customers from all other regions there are no restrictions and no minimum order quantity required — you are welcome to place an order as an individual. + +### Q: How do I register an account? +Click the **'Register'** button at the top right corner of our website. Fill out all your details and check that the supplied information is correct. If everything looks good, click **'Create account'**. + +### Q: I created an account. Can I start shopping right away? +Yes. Once your account is created you can start shopping immediately. You will receive a confirmation email after registration. You can then log in, view all products **with prices**, and manage your information in your account settings. + +### Q: I forgot my password. What now? +On the login page select **'Forgotten password'**, then enter the email address where we should send a new temporary password. If you have any problems resetting, contact **helpdesk@yehwang.com** or call **+31 318 668 171**. + +### Q: How do I register for "Find a Shop"? +1. Log in to yehwang.com and select **My account** (upper right). +2. Click **My shop**. +3. Click **Add new Shop**. +4. Fill out your details and click **Continue**. +If you need help, contact **helpdesk@yehwang.com** or **+31 318 668 171**. + +### Q: How do I register for the newsletter? +Use the newsletter sign-up on our website to stay up to date on weekly news, promotions, and new items. + +### Q: May I use your product images? +Yes. As a customer you may use all our images, including product images, on-model shots, and images from our Instagram. Images of items you purchased are listed per order and can be downloaded as a .ZIP from your account, or saved individually from the website. If you use our images on social media, we'd love to be tagged. diff --git a/customer-service-kb/02-ordering.md b/customer-service-kb/02-ordering.md new file mode 100644 index 0000000..01011af --- /dev/null +++ b/customer-service-kb/02-ordering.md @@ -0,0 +1,53 @@ +--- +title: Ordering — placing orders, minimum order, quantity discounts, changes +category: ordering +source: yehwang.com/order.html (official FAQ, verbatim) +source_url: + - https://www.yehwang.com/order.html +last_updated: 2026-06-25 +keywords: [order, place order, minimum order, MOQ, 30 euro, combine orders, shipping per order, quantity discount, volume discount, rewards, logo-free, cancel, adjust, voucher, promo code, price drop, sold out, back in stock] +--- + +# Ordering + +> 📎 来源 / Source: https://www.yehwang.com/order.html · 抓取 2026-06-25 + +### Q: How do I place an order? +Log in with your business account. Use **'Add to cart'** to add products to your shopping cart. When done, click the cart (upper right) to review your order. Check the products and your details carefully and update if needed. Select your preferred payment method and finalize. You'll receive an order confirmation by email. + +### Q: What is the minimum order value? +There is a **minimum order of €30**. You can order a few items or in bulk — whichever suits you. Great for testing new products. + +### Q: Can I reserve items? +Yes, through **pre-orders** you can reserve certain products that we have already re-ordered and are on their way to our warehouse. Pre-orders must always be paid in advance. See [03-pre-order](03-pre-order.md). + +### Q: Can I combine two orders? +No. It is not possible to combine two separate orders. Separate shipping costs are charged for each order because packages are processed and shipped separately. Shipping costs cannot be combined. + +### Q: Do you offer quantity discounts (same product)? +Yes, larger quantities of the **same product** earn an extra discount: +- **5–9 pieces**: +3% +- **10–19 pieces**: +5% +- **20+ pieces**: +7% +These stack with our order-total volume discounts — see [09-membership-rewards](09-membership-rewards.md). + +### Q: How do Yehwang's order rewards work? +To reward loyal customers, the platform returns rewards on each order, and rewards can be accumulated. Detailed rules are linked on the order rewards page on our website. + +### Q: Do you have logo-free jewelry? +Yes. From mid-2024, Yehwang's new jewelry no longer has logos on the packaging or labels, so you can sell our jewelry directly under your own brand. + +### Q: I didn't receive an order confirmation. +You should receive a confirmation immediately after placing the order. First check your spam folder. If it's still missing, your details may have been entered incorrectly — check and correct them in your account. If your details are correct and you still have no confirmation, contact **helpdesk@yehwang.com** and we'll get back to you as soon as possible. + +### Q: Can I cancel or adjust my order after placing it? +No. Finalized orders cannot be cancelled. Items in a confirmed order cannot be exchanged, and items cannot be added to a finalized order. Any additions must be placed as a separate order. + +### Q: How do I apply a voucher or promo code? +Discounts tied to holidays that apply to all customers (e.g. Black Friday, Daily Deals, Yehwang Day) are applied to your cart automatically. **Personal vouchers and discount codes** are added at the last checkout step — the same page where you select delivery and invoice addresses. (Note: a maximum of one code per order; sale and brand items are excluded — see [04-payment-vat-invoice](04-payment-vat-invoice.md).) + +### Q: I bought an item at full price and it went on discount the next day. Can I get the difference back? +No, we do not refund the discount difference. What you can do is reorder the products at the discounted price and return the others, with return costs at your own expense. No exceptions are made. + +### Q: An item is sold out — will it be back soon? +Stock varies greatly by product, and unfortunately not every sold-out item can be replenished. Click the **'Back in Stock'** notice button on the product so we can contact you if it returns. diff --git a/customer-service-kb/03-pre-order.md b/customer-service-kb/03-pre-order.md new file mode 100644 index 0000000..ccadb49 --- /dev/null +++ b/customer-service-kb/03-pre-order.md @@ -0,0 +1,33 @@ +--- +title: Pre-order +category: pre-order +source: yehwang.com/pre-order.html (official FAQ, verbatim) +source_url: + - https://www.yehwang.com/pre-order.html +last_updated: 2026-06-25 +keywords: [pre-order, preorder, reserve, out of stock, delivery week, 5% discount, minimum 50 pieces, combine, first order required] +--- + +# Pre-order + +> 📎 来源 / Source: https://www.yehwang.com/pre-order.html · 抓取 2026-06-25 + +### Q: What is a pre-order? +Products that are currently out of stock can be **reserved and paid for in advance** through pre-orders. These products have already been reordered by us and are on their way to our warehouse. From pre-order to delivery it takes **approximately one month** before stock reaches our warehouse and we can dispatch your order. + +### Q: How do I know which products are pre-order products? +Pre-order products are marked with a **PRE-ORDER** label on our website. A full overview is on our Pre-order products page. + +### Q: How do I know when my pre-order will be delivered? +Each pre-order product shows an **expected delivery week** — the week we expect to receive the products into our warehouse (not the week you receive your order). You can combine products with different delivery weeks in one pre-order, but the gap between the earliest and latest availability **cannot exceed 3 weeks** (e.g. week 40 + week 42 is OK; week 40 + week 43 is not). Orders with mixed delivery weeks ship only after all items have arrived. + +### Q: Why can't I add a pre-order product to my cart? +Two reasons: +1. **Pre-order products cannot be combined with in-stock items** — they require two separate orders, and the minimum order value applies to each. +2. **Pre-order items are only available to existing customers** — if you have never ordered from us before, pre-order items are not visible. Place a regular order first. + +### Q: Can I pre-order items that are (almost) out of stock? +Yes. Contact customer service to place a personal pre-order. The **minimum quantity for a personal pre-order is 50 pieces**. Pre-ordering clothing must first be confirmed with our supplier. + +### Q: Why do I get a discount on pre-order items? +Pre-order products get a **5% discount** because they haven't arrived in our warehouse yet and you wait a bit longer for shipment. Once they arrive, the price reverts to the regular selling price. diff --git a/customer-service-kb/04-payment-vat-invoice.md b/customer-service-kb/04-payment-vat-invoice.md new file mode 100644 index 0000000..58b5eff --- /dev/null +++ b/customer-service-kb/04-payment-vat-invoice.md @@ -0,0 +1,48 @@ +--- +title: Payment, VAT & Invoices +category: payment +source: yehwang.com/payment.html (official FAQ, verbatim) +source_url: + - https://www.yehwang.com/payment.html +last_updated: 2026-06-25 +keywords: [payment, iDeal, PayPal, credit card, VISA, Sofort, Giropay, Bancontact, bank transfer, discount code, VAT, tax, VAT number, exemption, invoice, EU VAT, checkout] +--- + +# Payment, VAT & Invoices + +> 📎 来源 / Source: https://www.yehwang.com/payment.html · 抓取 2026-06-25 + +### Q: What payment methods do you accept? +We accept the following online payment methods: +- iDeal +- PayPal +- Credit card +- VISA +- Sofort +- Giropay +- Bancontact + +### Q: Can I pay by bank transfer? +No. Payment by bank transfer is not possible. Payment is made through our website using the methods above. + +### Q: To which products can a discount code be applied? +Discount codes apply to the **total value of your order**. **Sale items and brand items are excluded.** Each discount code is valid only once, and a **maximum of one code can be used per order**. + +### Q: I registered with a valid VAT number — why am I still charged VAT? +Check the following: +1. If you are a **Dutch customer** with a delivery address in the Netherlands, you always pay VAT on your orders. +2. Check that your VAT number is listed in your **My Account** details. If you have a valid VAT number that is not listed there, contact **helpdesk@yehwang.com**. +3. Select your delivery address and check whether VAT is still charged. If it still is, contact **helpdesk@yehwang.com**. + +### Q: My company falls within the VAT exemption regulation. How can I receive an invoice with VAT? +If you have a valid VAT number and want to pay VAT on your orders, **contact us before placing your first order**. We will register you correctly in our system so you can start ordering. + +### Q: Why do I need to pay VAT at checkout? +If you are from an EU country and did not enter your own VAT number when creating your shipping address, you must pay VAT at checkout. **The rate varies by country.** +> ⚠️ 待核对: 官网附有"各国 VAT 税率表",需业务确认后补全到此处。 + +### Q: Can I have an invoice adjusted? +No. Once your order has been placed, we can no longer change the information on your invoice. + +### Q: I export outside the EU via a Dutch forwarder — how do I get the VAT refunded? +You pay the VAT initially. After the order is delivered to your Dutch forwarder, they give you a **proof of export** (confirming the cargo left the Netherlands). Email a copy of that document together with your IBAN to **helpdesk@yehwang.com** and we will refund the VAT as soon as possible. diff --git a/customer-service-kb/05-shipping-delivery.md b/customer-service-kb/05-shipping-delivery.md new file mode 100644 index 0000000..d200f35 --- /dev/null +++ b/customer-service-kb/05-shipping-delivery.md @@ -0,0 +1,34 @@ +--- +title: Shipping & Delivery (in-stock / NL warehouse) +category: shipping +source: yehwang.com/shipment.html (official FAQ, verbatim) +source_url: + - https://www.yehwang.com/shipment.html +last_updated: 2026-06-25 +keywords: [shipping, delivery, PostNL, DHL, shipping cost, free shipping, dispatch time, cut-off 3pm, track and trace, Brexit, UK, import duties, VAT refund, lost package, transit time] +--- + +# Shipping & Delivery + +> 📎 来源 / Source: https://www.yehwang.com/shipment.html · 抓取 2026-06-25 + +> Applies to in-stock orders shipped from our Netherlands warehouse. For items shipped from our factories, see [07-factory-direct](07-factory-direct.md). + +### Q: What shipping options do you offer? +For all deliveries we offer two carriers: **PostNL** or **DHL**. The actual shipping rate is shown in your cart during checkout. If your order reaches the **free-shipping threshold** and you select DHL, the PostNL fee is deducted from the DHL fee and you only pay the surcharge. + +### Q: What shipping costs do you charge? +Yehwang ships worldwide from the Netherlands via PostNL and DHL. **Shipping cost depends on your destination country and the weight of your package** — each country has its own rates based on distance and local shipping expenses. The exact cost is shown when you select your country during checkout. +> ⚠️ 待核对: 官网有按国家/重量的运费表与免邮门槛,需业务确认后补全到此处。 + +### Q: When will my order arrive / how fast do you dispatch? +Orders received **before 3 PM on business days are shipped within 1–3 business days**. We don't ship on weekends; orders placed Friday after 3 PM ship the following Monday. During clearance periods dispatch may take a little longer. After shipment you receive an email with a **Track and Trace** link. Transit times vary by country — check the carrier's site (via the tracking link) for an up-to-date ETA. + +### Q: Will I receive a track and trace code? +Yes — you'll receive a track and trace code by email from PostNL. If you haven't received it, contact **helpdesk@yehwang.com** and include your order number. + +### Q: How does Brexit affect shipping to the UK? +We still ship to the UK; Brexit does not stop this. However, on orders over **GBP 135** (excl. VAT and shipping), UK customs may charge import duties and taxes. These costs are paid by the customer — Yehwang does not cover them. + +### Q: My package hasn't been delivered. What now? +Contact us at **helpdesk@yehwang.com** with your order number. We'll open an investigation with the carrier (this can take **1–3 business days**). If the carrier declares the package lost, we refund the purchase amount once there is evidence/confirmation from the carrier that it was not delivered. diff --git a/customer-service-kb/06-returns-refunds.md b/customer-service-kb/06-returns-refunds.md new file mode 100644 index 0000000..6eb7e5c --- /dev/null +++ b/customer-service-kb/06-returns-refunds.md @@ -0,0 +1,34 @@ +--- +title: Returns & Refunds (in-stock / NL warehouse) +category: returns +source: yehwang.com/returns.html (official FAQ, verbatim) +source_url: + - https://www.yehwang.com/returns.html +last_updated: 2026-06-25 +keywords: [return, refund, defective, incorrect, missing item, complaint, returns application, store credit, custom product, Ede, return shipping] +--- + +# Returns & Refunds + +> 📎 来源 / Source: https://www.yehwang.com/returns.html · 抓取 2026-06-25 + +> Applies to in-stock orders from our NL warehouse. Factory-Direct returns differ — see [07-factory-direct](07-factory-direct.md). + +### Q: I received my order but an item is defective or incorrect. What should I do? +Submit a **return request through your account**. We assess the request and, once approved, email you further information. If we confirm a manufacturing defect or wrong delivery, **we reimburse the return shipping cost**. You return the product to our headquarters in **Ede**. In some cases the product need not be returned and we reimburse directly as **store credit** or to your original payment method. Note: you **cannot** create a return request in your account for **discounted items** — instead email customer service. + +### Q: How do I file a complaint / return request? +1. Go to **'My orders'** in your Yehwang account and select the order. +2. Click **'Returns Application'** and fill in the product quantity and return reason. +3. If the item is defective or incorrect, attaching a **photo is mandatory**. +After submitting, you can track it under the **'Returns'** section in 'My orders'. We respond by email **within 3 working days**, and the status changes from 'Pending' to 'Approved' or 'Rejected'. + +### Q: An item from my order is missing. What should I do? +Go to **'My orders'**, select the order, click **'Returns Application'**, and choose **'Not received'** as the reason. Track it under 'Returns'. We respond within **3 working days**. If approved, **store credit is issued to your account immediately**. + +### Q: Can I return / get a refund for a custom (personalized) product? +Customized products are made to your specifications and are personalized/exclusive, so they generally **do not accept unconditional returns or exchanges**. Returns/exchanges are accepted only for: +- ✓ Product quality issues (material defects, manufacturing errors, damage, etc.) +- ✓ Products significantly different from the order description / customized specifications +- ✓ Damage caused during shipping +Please read the full custom-product return policy carefully before ordering. diff --git a/customer-service-kb/07-factory-direct.md b/customer-service-kb/07-factory-direct.md new file mode 100644 index 0000000..d010405 --- /dev/null +++ b/customer-service-kb/07-factory-direct.md @@ -0,0 +1,51 @@ +--- +title: Factory-Direct (ships from our factories worldwide) +category: factory-direct +source: yehwang.com/factory-direct.html (official FAQ, verbatim) +source_url: + - https://www.yehwang.com/factory-direct.html +last_updated: 2026-06-25 +keywords: [factory direct, factory-direct, wholesale pricing, MOQ, custom, processing time, transit time, express, standard shipping, customs, tracking, missing items, quality, all sales final, 7 days] +--- + +# Factory-Direct + +> 📎 来源 / Source: https://www.yehwang.com/factory-direct.html · 抓取 2026-06-25 + +> Factory-Direct orders ship **directly from our manufacturing facilities** (not the NL warehouse) to customers worldwide. Contact for Factory-Direct: **service@yehwang.com**. + +### Q: What is the Factory-Direct advantage? +We ship your order directly from our manufacturing facilities to your location. By removing the middleman and domestic warehousing, we offer: **wholesale pricing** (cost savings passed to you), **exclusive access** (first to see newest designs), and **quality oversight** (direct handling from production line to packaging). + +### Q: What products are available via Factory-Direct? +Three main categories: +- **Finished Jewelry & Accessories** — earrings, necklaces, rings, bracelets, jewelry sets, plus hair accessories, phone accessories, bags, and trending apparel. +- **DIY & Jewelry Making** — beads, pendants, and raw materials/components. +- **Packing & Display** — packaging and display solutions. + +### Q: Is there a Minimum Order Quantity (MOQ)? +- **Standard inventory**: most jewelry, accessories, and DIY materials start from **just 1 piece**; the specific MOQ per item is listed in its product details. +- **Custom products**: MOQ varies by design complexity and materials. Email **service@yehwang.com** with your design ideas, preferred materials, and estimated quantity — we provide a tailored quote and MOQ requirement **within 24 hours**. + +### Q: What are Factory-Direct shipping times and rates? +Delivery has two stages: +- **Processing time**: 1–3 business days (quality inspection, sorting, packaging). +- **Transit time**: Express shipping **5–10 business days**; Standard shipping **10–20 business days**. +These are estimates; high-volume seasons or custom orders may need more time. Shipping cost for customized/bulk packaging orders is calculated at checkout by weight. You'll receive a tracking number/link by email once dispatched (allow 24–48 hours for it to update). + +### Q: Will I pay customs/duties on Factory-Direct orders? +Since items travel internationally from our factories, customs handling applies per destination. (Tracking and customs information is provided with your shipment.) + +### Q: I haven't received my Factory-Direct order. What now? +First: check your tracking link, check "hidden" delivery spots (neighbors, building manager, safe places), and wait 24 hours (carriers sometimes mark "Delivered" early). If tracking hasn't updated within **3 business days (or 7 days for international orders)** after the expected delivery date, contact **service@yehwang.com**. + +### Q: My Factory-Direct order is missing items. What should I do? +Check the packaging carefully (small jewelry/DIY items are often tucked into protective pouches or inside larger items), and verify the packing slip for backordered or separately-shipped items. If still missing, email **service@yehwang.com within 48 hours** with your order number, a photo of the shipping label and box contents, and the name of the missing item. + +### Q: How do you handle quality issues on Factory-Direct orders? +Every item passes a quality check before packing. If you receive a defect, **report within 7 days** of delivery and email **service@yehwang.com** with your order number and a clear photo (e.g. missing stones, chipped coating, defective DIY components). Depending on the case we provide a prepaid return label for a replacement or a full refund. + +### Q: What is the Factory-Direct return & exchange policy? +Due to the cost and complexity of international shipping and customs, **all sales are final** — we do not accept returns or exchanges for non-quality reasons (e.g. change of mind). However: +- **Defective or damaged items**: notify within **7 days** for a full refund or free replacement. +- **Incorrect orders** (wrong item, color, or size): we correct it immediately. diff --git a/customer-service-kb/08-dropshipping.md b/customer-service-kb/08-dropshipping.md new file mode 100644 index 0000000..6c739e5 --- /dev/null +++ b/customer-service-kb/08-dropshipping.md @@ -0,0 +1,139 @@ +--- +title: Dropshipping service (dropshipping.yehwang.com) +category: dropshipping +source: dropshipping.yehwang.com — homepage + about-us + FAQ (order/payment/shipment/returns), scraped 2026-06-25 +source_url: + - https://dropshipping.yehwang.com/ + - https://dropshipping.yehwang.com/about-us + - https://dropshipping.yehwang.com/faq/register + - https://dropshipping.yehwang.com/faq/order + - https://dropshipping.yehwang.com/faq/payment + - https://dropshipping.yehwang.com/faq/shipment + - https://dropshipping.yehwang.com/faq/returns +last_updated: 2026-06-25 +keywords: [dropshipping, Shopify, Shopify app, partner, apply, approval, no minimum order, blind shipping, neutral packaging, private label, house brands, Yehwang, Gimme, Lilli June, Lumé, Club Spice, margin, wholesale, 24h dispatch, Netherlands, REACH, nickel, returns, uninstall, VAT, PostNL, DHL] +--- + +# Dropshipping (dropshipping.yehwang.com) + +> 📎 来源 / Source: https://dropshipping.yehwang.com/ · /about-us · /faq/{order,payment,shipment,returns} · 抓取 2026-06-25 + +Yehwang's dropshipping platform ("The European Standard") lets online sellers sell Yehwang's fashion & jewelry **without holding inventory** — stock sits in our **Netherlands warehouse**, and when a customer orders we automatically process, pack, and ship on the seller's behalf via a native **Shopify** integration. Contact: **dropshipping@yehwang.com** (or **service@yehwang.com** for order/payment/shipping support). + +> Note: This is a separate service from the main B2B wholesale site (yehwang.com). Policies differ — most importantly, **dropshipping has no minimum order**, whereas the B2B site has a €30 minimum. + +## Value proposition + +### Q: What is Yehwang Dropshipping / why use it? +We are a B2B jewelry & fashion wholesale platform specializing in the European market. With dropshipping you sell without stocking inventory: sync our products to your Shopify store, and we handle the rest — when a customer orders we automatically process, pack, and ship it. Key benefits: +- **In-stock in the EU** — all products ship from our Netherlands warehouse with fast European delivery (no waiting weeks for shipments from China). +- **High margins** — quality products at wholesale prices. +- **Automatic sync** — products, inventory, and orders sync in real time with Shopify. +- **Zero inventory pressure** — no pre-orders, no stock to hold; whether a customer buys 1 or 100 items, we ship directly. +- **Guaranteed quality** — all metal jewelry complies with European regulations and passes REACH nickel-release testing (hypoallergenic). +- **For all seller levels** — beginner-friendly with step-by-step guides; also built for influencers/brands and scalable operations with API integration. + +### Q: How does Yehwang compare to China dropshipping or Spocket? +- **Shipping time:** China 15–30 days; Spocket/others 4–10 days; **Yehwang 1–2 days within the NL/EU.** +- **Packaging:** one consolidated box (vs multiple boxes / inconsistent). +- **Returns:** 7-day no-reason (vs near impossible / complicated). +- **Quality control:** EU standards. +- **New arrivals:** 1,000+ new styles per month. +- **New-collection protection:** 6-week retail-first window (others have none). + +## Joining & requirements + +### Q: Who can join / how do I apply to dropship? +You apply through the partner application form on dropshipping.yehwang.com (steps: Business → Experience → Brands → Review). Required business details include name, business email, store/company, country, live store URL, and business type (e-commerce store, social commerce, marketplace seller, or multi-channel). Applications are **reviewed within 2–3 business days**. + +### Q: Do I need dropshipping experience to be approved? +The partner programme is a **vetted programme aimed at experienced dropshippers** ready to generate consistent volume (the application asks about your experience). At the same time, the platform markets itself as beginner-friendly with onboarding guides and "no threshold to get started." +> ⚠️ 待核对: 官网两处口径不一(合伙申请页强调"经验丰富/6个月+",about-us 说"无门槛适合所有卖家")。请业务确认实际准入门槛,再统一此条表述。 + +### Q: How do I connect my Shopify store? +Link your store securely in minutes via the **Yehwang Dropshipping App on the Shopify App Store** — the setup is frictionless. After connecting, configure your margin multipliers, brand focus, and collections, then sync products live. + +### Q: Is uninstalling the Yehwang Dropshipping app reversible? +> ⚠️ 待补: 官网此条答案在抓取时未能展开,需到 dropshipping.yehwang.com/faq/register 人工确认后补全。 + +### Q: I forgot my password / can I register without a company? +Registration rules mirror the main site: a Chamber of Commerce and VAT number are only required for **Netherlands-based** businesses; customers elsewhere can register without a company. For password resets use 'Forgotten password' on the login page, or contact **service@yehwang.com**. + +## How it works (4 steps) + +### Q: How does the dropshipping process work end to end? +1. **Apply & get approved** — submit the application; reviewed within 2–3 business days. +2. **Connect your Shopify** — link your store via the Yehwang Dropshipping App. +3. **Set your preferences** — configure margin multipliers, brand focus, and collections; pick New In and Bestsellers. +4. **Sync & start selling** — push products live; orders flow back to us automatically and we pick, pack, and ship. Orders are shipped after the customer's payment. + +## Brands & catalog + +### Q: What brands can I dropship? +Five house brands, each for a different customer profile: +- **Yehwang** — Core Collection: 20+ years of wholesale expertise; timeless jewelry, clothing & accessories at accessible prices. +- **Gimme** — Lifestyle clothing: playful, layered, trend-led silhouettes. +- **Lilli June** — Charm jewelry: charm-forward, mix-and-match stackable pieces. +- **Lumé** — Fine jewelry: elevated essentials in refined finishes, gifting-worthy. +- **Club Spice** — Statement jewelry: bold, vibrant pieces. + +### Q: What product range / how many products are available? +A complete lifestyle catalog spanning **50+ core categories** (fashion, lifestyle, creative products) — jewelry, clothing (e.g. maxi dresses, summer clothes), and accessories (bags, scarves). The platform advertises **1,000+ new styles per month**. +> ⚠️ 待核对: 官网对在售规模口径不一(首页"30,000+ items"、about-us"over 1 million in-stock items"、order FAQ"10,000+ SKUs")。请确认对客户统一说法。 + +## Pricing & margins + +### Q: What does it cost and what margins can I expect? +Wholesale prices start from **€1.25 per unit**; suggested retail (MSRP) is typically **2.5×–3.5× wholesale**, with custom margin multipliers configurable in the app and VAT-friendly pricing for EU sellers. Advertised wholesale margins are **up to ~65%** across the 5 house brands (average gross margin across categories ~63%). Example: retail €49.99 − €14.50 wholesale − €3.95 shipping = **€31.54 profit** per unit. Actual results depend on product selection, platform fees, and pricing strategy. + +## Packaging & branding + +### Q: Will my customer know the order came from Yehwang? (blind shipping) +No. All packages are shipped in **unbranded, neutral packaging** with no Yehwang identifiers, so customers receive items as if shipped directly from your store. If you have your own brand logo, contact us. + +### Q: Do you support private label / custom packaging? +Yes — we offer **private label and custom packaging** services (a minimum order quantity applies). Contact our sales team for a tailored solution. New collections become available to dropshipping partners **6 weeks after** they go live on www.yehwang.com (retail-first window). + +## Orders, minimum & products + +### Q: Is there a minimum order for dropshipping? +**No minimum order quantity** — you can order a single item or in bulk, whichever suits you. (This differs from the main B2B site's €30 minimum.) + +### Q: Which products support dropshipping? +The platform spans 50+ core categories specializing in fashion, lifestyle, and creative products, with a curated selection of SKUs supporting diverse models — e-commerce retail, cross-border distribution, and boutique stores. + +## Payment & VAT + +### Q: Can I pay by bank transfer? +No. Payment is made through the website; bank transfer is not available. + +### Q: I have a valid VAT number but am still charged VAT — why? +1. If you're a Dutch customer with a Netherlands delivery address, VAT is always charged. +2. Check that your VAT number is listed in your My Account details; if it's valid but not listed, contact **service@yehwang.com**. +3. Select your delivery address and check whether VAT is still charged; if so, contact **service@yehwang.com**. + +### Q: My company falls within the VAT exemption regulation. How do I get an invoice with VAT? +If you have a valid VAT number and want to pay VAT on your orders, contact us **before placing your first order** so we can register you correctly. + +## Shipping & delivery + +### Q: What are dropshipping shipping costs and carriers? +Orders ship worldwide via **PostNL and DHL** from the Netherlands. Shipping cost depends on the **destination country and package weight**, shown when you select the country at checkout. +> ⚠️ 待核对: 各国运费表官网有,抓取未含,需业务补全。 + +### Q: When will the order arrive? +Orders received **before 3 PM on business days ship within 1–3 business days** (24h dispatch on confirmed orders is advertised). We don't ship on weekends; Friday-after-3-PM orders ship the following Monday. After dispatch you receive an email with a Track & Trace link; transit time varies by country. + +### Q: Will I receive a track and trace code? +Yes — by email from PostNL. If you don't receive it, contact **service@yehwang.com** with your order number. + +### Q: How does Brexit affect shipping to the UK? +We still ship to the UK. On orders over **GBP 135** (excl. VAT and shipping), UK customs may charge import duties/taxes, paid by the customer — Yehwang does not cover these. + +## Returns + +### Q: What is the dropshipping returns policy? +A **7-day no-reason** returns policy applies. + +### Q: How do I file a complaint / return? +Go to **'My orders'** in your account, select the order, click **'Returns Application'**, and enter the quantity and reason. For a defective or incorrect item, **attaching a photo is mandatory**. Track it in the **'Returns'** section; we reply by email **within 3 working days**, and the status changes from 'Pending' to 'Approved' or 'Rejected'. diff --git a/customer-service-kb/09-membership-rewards.md b/customer-service-kb/09-membership-rewards.md new file mode 100644 index 0000000..ba54dd2 --- /dev/null +++ b/customer-service-kb/09-membership-rewards.md @@ -0,0 +1,57 @@ +--- +title: Membership tiers, volume discounts & rewards +category: membership +source: yehwang.com/membership (verbatim) + order.html + catalog snapshot 2026-06-25 +source_url: + - https://www.yehwang.com/membership-307802756 + - https://www.yehwang.com/order.html + - "数仓表 wa_dim.dim_user_full (快照 2026-06-25)" +last_updated: 2026-06-25 +keywords: [membership, loyalty, tier, level, Bronze, Silver, Gold, Platinum, Diamond, VIP, discount, volume discount, quantity discount, rewards, spending threshold, upgrade] +--- + +# Membership, Volume Discounts & Rewards + +> 📎 来源 / Source: https://www.yehwang.com/membership-307802756 · https://www.yehwang.com/order.html · 数据 `wa_dim.dim_user_full` · 抓取 2026-06-25 + +### Q: What membership tiers do you have and how do I level up? +Membership has six levels based on **cumulative historical spending** (excluding VAT and shipping). As soon as you hit a new threshold, your account is **automatically upgraded**. Each tier adds a member-bonus discount: + +| Tier | Cumulative spend | Member bonus | +|---|---|---| +| Bronze | €1+ | 0% | +| Silver | €200+ | 1% | +| Gold | €1,000+ | 2% | +| Platinum | €4,000+ | 3% | +| Diamond | €10,000+ | 4% | +| VIP | €20,000+ | 5% | + +(New accounts start as a New Member before reaching Bronze. Some special accounts — e.g. key accounts ("KA") — may have custom arrangements.) + +### Q: What volume discounts do you offer (per order total)? +Volume discounts are based on the **total value of a single order**: + +| Order total | Volume discount | +|---|---| +| €200+ | 5% | +| €500+ | 7% | +| €1,000+ | 10% | +| €2,000+ | 15% | +| €3,000+ | 20% | +| €5,000+ | 22% | +| €10,000+ | 25% | + +### Q: How do the discounts stack? +The system applies discounts **sequentially**: the **volume discount** (based on order total) is applied first, then your **member bonus** (0–5% by tier) is applied on top, to maximize your savings. + +### Q: Do you offer quantity discounts on the same product? +Yes — in addition to the order-total volume discount, ordering more of the **same product** earns extra: +- **5–9 pieces**: +3% +- **10–19 pieces**: +5% +- **20+ pieces**: +7% + +### Q: How do order rewards work? +To reward loyal customers, the platform returns rewards on each order, which can be **accumulated**. Detailed rules are on the order rewards page on our website. + +### Q: Is there a welcome discount for new members? +Yes — new registrants can receive a welcome discount (advertised as **up to 15% off**) on early orders. diff --git a/customer-service-kb/10-products-and-jewelry-care.md b/customer-service-kb/10-products-and-jewelry-care.md new file mode 100644 index 0000000..c2656f5 --- /dev/null +++ b/customer-service-kb/10-products-and-jewelry-care.md @@ -0,0 +1,47 @@ +--- +title: Products catalog, materials & jewelry care +category: products +source: yehwang.com/other-questions (verbatim) + catalog snapshot 2026-06-25 +source_url: + - https://www.yehwang.com/other-questions-1987986538 + - "数仓表 wa_dim.dim_product_full (快照 2026-06-25)" +last_updated: 2026-06-25 +keywords: [catalog, categories, price range, materials, stainless steel, gold plated, 304, 316L, nickel free, discolor, waterproof, jewelry care, selling price, minimum price, 300% margin, Gimme] +--- + +# Products, Materials & Jewelry Care + +> 📎 来源 / Source: https://www.yehwang.com/other-questions-1987986538 · 数据 `wa_dim.dim_product_full` · 抓取 2026-06-25 + +## Catalog overview (live products, snapshot 2026-06-25) + +| Category | Live products | Typical price (EUR) | +|---|---|---| +| Women's jewelry | ~72,800 | median ~€2.75 (€0–233) | +| Accessories | ~24,000 | median ~€2.27 | +| Clothes | ~5,000 | median ~€13.95 | +| Packing & Display | ~1,600 | median ~€1.00 | +| Other / Sale jewelry | ~970 | median ~€4.00 | +| Sports & Outdoor | ~670 | median ~€10.40 | +| Beauty & Makeup tools | ~410 | median ~€2.00 | +| High-end jewelry | ~360 | median ~€22.50 | +| Men's jewelry | ~170 | median ~€5.95 | + +Top sub-categories: Earrings, Pendants, Necklaces, Bracelets, Piercing jewelry, Rings, Hair accessories, Jewelry-making supplies, Scarves, Bag charms, Glasses, Women's tops, Phone accessories, Bags. + +Most-used materials: **Stainless Steel** (by far the largest), Polyester, Acrylic, Natural Stones, Titanium Alloy, PVC, Raffia. + +### Q: What materials are your jewelry made of? +We use a variety of materials; we particularly favor **gold-plated metal** and **stainless steel** jewelry. The gold layer on top is **2.5 microns thick**, in 14K yellow, white, or rose gold (jewelry made before 2020 used an 18K gold layer). This layer is also applied over stainless steel; the color depends on the gold plating. Our stainless steel is **304 or 316L**. **All our jewelry is nickel free.** + +### Q: Does your jewelry discolor? +**Stainless steel** jewelry is **waterproof and does not discolor** — you can shower, swim, and spray perfume on it. Other materials such as **silver 925, alloy, and copper are not waterproof** and may discolor with daily use; we do not guarantee against discoloration of silver 925, alloy, and copper. + +### Q: Can I keep wearing the jewelry while playing sports or sleeping? +Although our jewelry is durable, we advise **not** wearing it while showering, sleeping, or during sports. To care for it, avoid contact with water, hairspray, and body lotion. In some cases your skin's pH can negatively affect the jewelry and cause discoloration. + +### Q: Am I free to set my own selling prices? +Each product has a **minimum selling price** described on its page; you are expected to maintain this and not undercut it. We use a **300% margin** to determine the suggested selling price. + +### Q: Do you offer logo-free / white-label jewelry? +Yes — Yehwang's new jewelry from mid-2024 onward has **no logos** on packaging or labels, so you can sell it under your own brand. (See also [02-ordering](02-ordering.md).) diff --git a/customer-service-kb/README.md b/customer-service-kb/README.md new file mode 100644 index 0000000..aff8756 --- /dev/null +++ b/customer-service-kb/README.md @@ -0,0 +1,54 @@ +# Yehwang 客服知识库 (Customer Service Knowledge Base) + +面向 **Chatwoot + ai-bridge + Twenty** AI 客服系统的知识库。内容为英文(客户群体为欧洲 B2B 零售商/店主,官方 FAQ 原文亦为英文),AI 在回复时按客户语言翻译即可。 + +## 内容来源(均为权威来源,非编造) + +每个 md 文件都在 frontmatter 的 `source_url` 字段、以及正文标题下的「📎 来源 / Source」行里标注了**确切来源地址**,可点击核对/更新。汇总: + +| 来源地址 | 对应文件 | +|---|---| +| https://www.yehwang.com/about-us.html | 00-about | +| https://www.yehwang.com/register.html | 00-about, 01-registration | +| https://www.yehwang.com/order.html | 02-ordering, 09-membership | +| https://www.yehwang.com/pre-order.html | 03-pre-order | +| https://www.yehwang.com/payment.html | 04-payment | +| https://www.yehwang.com/shipment.html | 05-shipping | +| https://www.yehwang.com/returns.html | 06-returns | +| https://www.yehwang.com/factory-direct.html | 07-factory-direct | +| https://www.yehwang.com/other-questions-1987986538 | 01-registration, 10-products | +| https://www.yehwang.com/membership-307802756 | 09-membership | +| https://dropshipping.yehwang.com/ · /about-us · /faq/{register,order,payment,shipment,returns} | 08-dropshipping | +| 数仓表 `wa_dim.dim_product_full` / `wa_dim.dim_user_full`(快照 2026-06-25) | 00-about, 09-membership, 10-products | +| 内部文档 `公司介绍.md.txt`(业务背景) | 00-about | + +> 官网 FAQ 答案为 JS 折叠加载,用 Playwright 渲染后抓取;原始抓取数据存档于 `_source/`。 + +## 文件结构(一主题一文件,问答块切分,适配 RAG) + +- `index.md` — 导航 +- `00-about-yehwang.md` — 公司是谁、卖什么、两种履约模式 +- `01-registration-account.md` — 注册 / 账号 / 找店 / 图片授权 +- `02-ordering.md` — 下单 / 起订 €30 / 数量折扣 / 改单取消 / 优惠码 +- `03-pre-order.md` — 预购规则 +- `04-payment-vat-invoice.md` — 支付方式 / VAT / 发票 +- `05-shipping-delivery.md` — PostNL/DHL 物流 / 运费 / 时效 / Brexit +- `06-returns-refunds.md` — 退换货 / 缺件 / 残次 / 定制品 +- `07-factory-direct.md` — 工厂直发(中国直发全球) +- `08-dropshipping.md` — 一件代发(Shopify 接入) +- `09-membership-rewards.md` — 会员等级 / 阶梯量折扣 / 奖励 +- `10-products-and-jewelry-care.md` — 品类盘 / 价格带 / 材质 / 珠宝护理 + +## 给 ai-bridge 的接入建议 + +1. **切块**:每个 `### Q:` 问答块作为一个 chunk(已天然分段)。frontmatter 里的 `keywords` 可并入 embedding 文本提升召回。 +2. **向量化**:数仓 `.env.local` 已含 `DASHSCOPE_API_KEY`(`text-embedding-v3`,多语言),可直接复用,对欧洲多语种问法召回友好。 +3. **system prompt 约束**:要求 AI 仅依据知识库作答;涉及金额/时效/政策时引用具体条款;无法回答时转人工(`helpdesk@yehwang.com` / `service@yehwang.com`)。 +4. **联系方式**:自营/网站业务 `helpdesk@yehwang.com`、电话 `+31 318 668 171`;工厂直发 `service@yehwang.com`。 + +## 维护 + +- 政策类内容随官网 FAQ 变化需同步;商品/等级类数据可定期从数仓刷新(脚本见 `scratchpad` 或重跑聚合)。 +- 标 `⚠️ 待核对` 的条目为需业务确认后补全的细节(如各国运费表、各国 VAT 税率表)。 + +_最后更新:2026-06-25_ diff --git a/customer-service-kb/index.md b/customer-service-kb/index.md new file mode 100644 index 0000000..fd99ce6 --- /dev/null +++ b/customer-service-kb/index.md @@ -0,0 +1,25 @@ +# Yehwang Customer Service Knowledge Base — Index + +Yehwang is a Netherlands-based B2B wholesaler of jewelry, accessories, and clothing, serving retailers and online sellers across Europe. + +## Topics + +| File | Topic | Typical questions | +|---|---|---| +| [00-about-yehwang](00-about-yehwang.md) | Who we are, what we sell, fulfillment models | "What do you sell?" "Where do you ship from?" | +| [01-registration-account](01-registration-account.md) | Registration, account, password, Find a Shop | "Can I register without a company?" "I forgot my password" | +| [02-ordering](02-ordering.md) | Placing orders, minimum order, quantity discounts | "What is the minimum order?" "Can I cancel my order?" | +| [03-pre-order](03-pre-order.md) | Pre-order products and rules | "What is a pre-order?" "When will my pre-order arrive?" | +| [04-payment-vat-invoice](04-payment-vat-invoice.md) | Payment methods, VAT, invoices | "Which payment methods?" "Why am I charged VAT?" | +| [05-shipping-delivery](05-shipping-delivery.md) | Carriers, costs, delivery times, tracking | "How much is shipping?" "When will my order arrive?" | +| [06-returns-refunds](06-returns-refunds.md) | Returns, refunds, defective/missing items | "An item is broken, what do I do?" | +| [07-factory-direct](07-factory-direct.md) | Factory-Direct (ships from our factories) | "What is Factory Direct?" "MOQ for factory direct?" | +| [08-dropshipping](08-dropshipping.md) | Dropshipping service (Shopify integration) | "How does dropshipping work?" "Can I dropship?" | +| [09-membership-rewards](09-membership-rewards.md) | Membership tiers, volume discounts, rewards | "How do I get a discount?" "What tier am I?" | +| [10-products-and-jewelry-care](10-products-and-jewelry-care.md) | Catalog, price ranges, materials, jewelry care | "What materials?" "Does the jewelry discolor?" | + +## Contact + +- **General / website / self-operated orders:** helpdesk@yehwang.com · +31 318 668 171 +- **Factory-Direct orders:** service@yehwang.com +- **Headquarters / returns address:** Ede, The Netherlands diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..86aaa09 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,102 @@ +name: crm-ai-demo + +# 共享基础设施:postgres(带 pgvector) + redis +# 应用层(Chatwoot / Twenty)用开源源码跑,不在此 compose 内。 +# +# 分库: +# chatwoot — Chatwoot 业务数据(vector 扩展) +# twenty — Twenty CRM 数据 +# products — Bridge 客服知识库 kb_docs(vector 扩展) +# Redis 分 db:chatwoot=db0, twenty=db1 +# +# 端口均暴露到宿主,供本地源码应用 / docker 内应用经 host.docker.internal 连接。 + +services: + postgres: + image: pgvector/pgvector:pg18 + container_name: crm-postgres + restart: always + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: postgres + volumes: + - pg-data:/var/lib/postgresql + - ./infra/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:8.8-alpine + container_name: crm-redis + restart: always + deploy: + resources: + limits: + cpus: '0.3' + memory: 256M + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + # Chatwoot → LLM → Twenty CRM 销售桥接(源码 Docker 构建) + # 连同 compose 内的 postgres 共享网络;Chatwoot/Twenty 为独立 compose,经 host.docker.internal 访问。 + bridge: + build: ./bridge + container_name: crm-bridge + restart: always + depends_on: + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + ports: + - "4000:4000" + environment: + PORT: "4000" + # 同 compose 网络,用容器名连 postgres(products 库存放客服知识库 kb_docs) + PG_URL: "postgres://postgres:postgres@crm-postgres:5432/products" + # Chatwoot / Twenty 为独立 compose,端口映射到宿主,容器经 host-gateway 访问 + CW_BASE_URL: "http://crm-chatwoot-rails:3000" + TWENTY_BASE_URL: "http://crm-twenty-server:3000" + # AgentBot secret(HMAC 签名校验),由 register_agent_bot.js 生成 + AGENT_BOT_SECRET: "REPLACE_WITH_AGENT_BOT_SECRET" + # 本地开发跳过 HMAC 签名校验(容器间网络安全,Sidekiq body 编码存在差异) + SKIP_AGENT_BOT_SIGNATURE: "true" + # 业务数据 API(Mock 服务器,宿主 localhost:9505) + INVENTORY_API_URL: "http://host.docker.internal:9505" + ORDER_API_URL: "http://host.docker.internal:9505" + extra_hosts: + - "host.docker.internal:host-gateway" + # 挂载源码与配置:改 server.js / *.config.json 后 docker restart crm-bridge 即可,无需 rebuild + volumes: + - ./bridge/server.js:/app/server.js:ro + - ./bridge/index_kb.js:/app/index_kb.js:ro + - ./bridge/register_agent_bot.js:/app/register_agent_bot.js:ro + - ./bridge/chatwoot.config.json:/app/chatwoot.config.json:ro + - ./bridge/llm.config.json:/app/llm.config.json:ro + - ./bridge/twenty.config.json:/app/twenty.config.json:ro + - ./bridge/kb.config.json:/app/kb.config.json:ro + +volumes: + pg-data: + redis-data: diff --git a/docs/twenty-crm-guide.md b/docs/twenty-crm-guide.md new file mode 100644 index 0000000..534240a --- /dev/null +++ b/docs/twenty-crm-guide.md @@ -0,0 +1,229 @@ +# Twenty CRM 功能指南 + +> 本文档说明 Twenty CRM 在本系统中的集成方式、当前已用功能,以及 Twenty 本身的完整能力。 +> 适用于:系统运维、功能扩展、二次开发参考。 + +--- + +## 一、当前系统的集成现状 + +### Bridge 实际用到的 Twenty 功能(5 个 GraphQL 操作) + +| 功能 | GraphQL 调用 | 代码位置 (`bridge/server.js`) | 作用 | +|---|---|---|---| +| 创建公司 | `createCompany` | :221 | 从对话提取公司名,自动建公司记录 | +| 查公司(去重) | `companies(filter:{name})` | :227 | 避免重复创建同名公司 | +| 创建联系人 | `createPerson` | :254 | 建联系人(姓名/邮箱/电话),关联到公司 | +| 查联系人(去重) | `people(filter:{emails})` | :234 | 按邮箱去重 | +| 创建商机 | `createOpportunity` | :276 | 建商机(名称/金额/阶段/公司/联系人) | +| 创建笔记 | `createNote` + `createNoteTarget` | :333 | 把聊天记录挂到商机上 | +| **创建跟进任务** | `createTask` + `createTaskTarget` | :329 | **自动化**:商机创建后自动生成 3 天到期的跟进任务 | + +**Bridge 把 Twenty 当成「线索落地库 + 自动化起点」**——不仅结构化存储销售线索,还自动触发跟进流程。 + +### 商机创建的触发条件 + +Bridge 用 `EXTRACT_SYSTEM`(`server.js:516`)对每轮对话做信息提取,判断 `ready=true` 需要**三要素齐全**: + +1. **公司名** — Company name(任意语言) +2. **联系方式** — Email 或 Phone,或联系人姓名 +3. **产品/需求** — 客户感兴趣的产品或采购意向 + +三者缺任何一个,`ready=false`,继续对话收集,不创建商机。 + +--- + +## 二、Twenty CRM 完整数据对象 + +当前 workspace(`workspace_6o1rsmnm23f20gpxxzby7wab2`)包含 28 个数据表: + +``` +当前已用(Bridge 自动写入) 未使用(可手动操作或扩展自动化) +┌──────────────────────────┐ ┌────────────────────────────────────┐ +│ ✅ company 公司 │ │ 📅 calendarEvent 日历/会议 │ +│ ✅ person 联系人 │ │ 📧 message 邮件消息 │ +│ ✅ opportunity 商机 │ │ 📞 callRecording 通话录音 │ +│ ✅ note 笔记 │ │ 🔄 workflow 自动化工作流 │ +│ ✅ noteTarget 笔记关联 │ │ 📊 dashboard 仪表盘/报表 │ +│ ✅ task 跟进任务 │ │ 👥 workspaceMember 团队成员 │ +│ ✅ taskTarget 任务关联 │ │ 🚫 blocklist 屏蔽名单 │ +└──────────────────────────┘ │ 📎 attachment 附件 │ + │ 📝 messageCampaign 邮件营销 │ + │ 🕐 timelineActivity 活动时间线 │ + └────────────────────────────────────┘ +``` + +--- + +## 三、未使用功能详解 + +### 1. 任务管理(Task)— 跟进客户 + +给商机/联系人创建跟进任务,是商机转化为真实订单的关键环节。 + +**典型用法**: +- "明天回电 Sophie 确认戒指款式" +- "本周五前寄出报价单" +- 设置截止日期、负责人、优先级 + +**位置**:Twenty 网页 → 左侧导航 Tasks + +--- + +### 2. 日历(Calendar)— 安排会议 + +- 关联到联系人/商机 +- 记录客户会议、产品演示时间 +- 支持 Google/Microsoft 日历集成(需额外配置 OAuth) + +**位置**:Twenty 网页 → 左侧导航 Calendar + +--- + +### 3. 消息/邮件(Message)— 统一收件箱 + +- 集成 Gmail/Outlook,直接在 CRM 里收发邮件 +- 所有客户沟通集中管理,不再散落各处 +- 邮件营销(MessageCampaign)支持群发 + +**位置**:Twenty 网页 → 左侧导航 Messages + +--- + +### 4. 自动化工作流(Workflow)— 最强大的未用功能 + +自动触发规则,目前完全没用上,潜力巨大。 + +**典型场景**: +- "新商机创建 → 自动通知负责人 + 创建 3 天后跟进任务" +- "商机阶段变为 PROPOSAL → 自动发邮件给客户" +- "商机金额 > €5000 → 自动提升优先级 + 通知销售经理" + +**位置**:Twenty 网页 → Settings → Workflows + +--- + +### 5. 仪表盘(Dashboard)— 数据可视化 + +- 销售漏斗(各阶段商机数量/金额) +- 自定义视图、过滤器、图表 +- 月度/季度业绩统计 + +**位置**:Twenty 网页 → 左侧导航 Dashboards + +--- + +### 6. 自定义字段 — Twenty 的杀手锏 + +可以给任何对象(Company/Person/Opportunity)添加自定义字段: +- 客户行业、Jewelry 偏好材质、年采购量、首次接触渠道 +- 当前 Bridge 提取的字段是固定的,可通过扩展 `EXTRACT_SYSTEM` 来填充自定义字段 + +**位置**:Twenty 网页 → Settings → Object 某个对象 → Fields + +--- + +## 四、网页界面导航 + +``` +http://localhost:3000 → 登录后左侧导航栏: + + 📊 Dashboards 仪表盘 + 🏢 Companies 公司列表 + 👤 People 联系人 + 💼 Opportunities 商机 ← Bridge 自动创建的数据在这里 + ✅ Tasks 任务 + 📅 Calendar 日历 + 📧 Messages 消息 + ⚙️ Settings 设置 ← 工作流 / 自定义字段 / 团队成员 +``` + +点开任意商机(如 Nordic Retail AB),可见多个标签页: +- **Notes** — 笔记(Bridge 自动写入的聊天记录) +- **Tasks** — 关联任务 +- **Timeline** — 活动时间线 +- **People** — 关联联系人 + +--- + +## 五、API Key 与认证配置 + +Bridge 通过 GraphQL API 访问 Twenty,认证链路: + +``` +twenty.config.json + ├── baseURL: http://localhost:3000 + ├── graphqlPath: /graphql + ├── workspaceId: 70a1901c-45ab-4b95-a7f0-de74be819b2e + └── apiKey: +``` + +**JWT 结构**(`bridge/twenty.config.json` 里的 apiKey): +```json +{ + "alg": "ES256", "typ": "JWT", "kid": "2b878cf8-7d35-4906-899f-a0439b1c2116" +} +// payload: +{ + "sub": "70a1901c-45ab-4b95-a7f0-de74be819b2e", // = workspaceId + "type": "API_KEY", + "workspaceId": "70a1901c-45ab-4b95-a7f0-de74be819b2e", + "iat": 1784878461, + "exp": 4922899200, + "jti": "556d9fc0-7f32-4a5b-b041-78b30242cefb" // = apiKey 表的 id +} +``` + +**API Key 的两个必要绑定**(缺一不可): +1. `core."apiKey"` 表:记录 apiKey id → workspaceId +2. `core."roleTarget"` 表:记录 apiKeyId → roleId(绑定权限角色,否则报 `INVALID_AUTH_CONTEXT`) + +> 如需重新生成 API Key,参考项目根目录的 `gen_apikey.js` 脚本(用 HKDF 解密签名密钥后签发 JWT)。 + +--- + +## 六、扩展建议 + +### 已实现(✅) + +| 扩展 | 做法 | 代码位置 | 价值 | +|---|---|---|---| +| **商机自动创建跟进任务** | Bridge 在 `createLeadInCRM` 里商机创建后自动建 Task | `server.js:329` | 商机不遗漏跟进 | + +#### 自动跟进任务的工作方式 + +商机创建成功后,Bridge 自动执行(`server.js` createLeadInCRM 函数内): + +1. 创建一条 Task,标题 `跟进商机: <商机名>` +2. 任务内容包含:客户公司、联系人、需求摘要、行动指引(3 个工作日内联系) +3. 设置到期日为 **3 天后** +4. 通过 `createTaskTarget` 把 Task 关联到刚创建的 Opportunity + +**触发日志示例**(bridge 日志中可见): +``` +[workflow] conv=3 opp=b3ab3af2... -> 自动创建跟进任务 fcd6f300... (3天后到期) +[CRM✓] conv=3 建线索: {新公司 + 新联系人 + 新商机} (新公司) (新联系人) +``` + +> **注意**:本自动化在 Bridge 层实现,而非 Twenty 内置 Workflow 引擎。原因是 Twenty 的 API key 权限不支持通过 GraphQL 创建 `workflowVersion`(报 `FORBIDDEN`)。Bridge 层实现更可控、日志透明。如需更复杂的可视化编排,可在 Twenty 网页 **Settings → Workflows** 通过 UI 创建。 + +### 短期(低成本,高收益) + +| 扩展 | 做法 | 价值 | +|---|---|---| +| **商机金额自动填充** | 扩展 `EXTRACT_SYSTEM`,让 LLM 估算 unit price × quantity | 漏斗金额更准确 | +| **自定义字段:客户行业** | Twenty 加字段 + Bridge `EXTRACT_SYSTEM` 提取 | 销售分群分析 | +| **商机阶段自动推进** | Bridge 根据"是否提供报价/是否确认订单"调用 `updateOpportunity` 改 stage | 漏斗自动化 | + +### 中期(需开发) + +| 扩展 | 做法 | 价值 | +|---|---|---| +| **销售漏斗仪表盘** | Twenty Dashboard 配置 | 可视化业绩 | +| **双向同步** | Bridge 读 Twenty(如查客户历史商机) | AI 回复更智能 | +| **邮件集成** | Twenty Settings 配置 Gmail/Outlook OAuth | 统一沟通 | + +--- + +*最后更新:2026-07-24(新增:自动化跟进任务功能)* +*相关文件:`bridge/server.js`(GraphQL 调用 + 自动化工作流)、`bridge/twenty.config.json`(连接配置)、`gen_apikey.js`(API Key 生成)、`create_workflow.js`(工作流创建脚本)* diff --git a/infra/init-db.sh b/infra/init-db.sh new file mode 100644 index 0000000..fcb1f30 --- /dev/null +++ b/infra/init-db.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# 共享 postgres 初始化:创建分库 + 在需要的库启用 pgvector 扩展 +set -e +set -u + +echo "[init-db] 创建分库: chatwoot, twenty, products" +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE DATABASE chatwoot; + CREATE DATABASE twenty; + CREATE DATABASE products; +EOSQL + +echo "[init-db] 在 chatwoot / products 库启用 pgvector 扩展" +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d chatwoot <<-EOSQL + CREATE EXTENSION IF NOT EXISTS vector; +EOSQL + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d products <<-EOSQL + CREATE EXTENSION IF NOT EXISTS vector; +EOSQL + +echo "[init-db] 完成。库列表:" +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -c "\l" diff --git a/landing.html b/landing.html new file mode 100644 index 0000000..87e07a0 --- /dev/null +++ b/landing.html @@ -0,0 +1,253 @@ + + + + + + Stainless Jewelry & Accessories — Wholesale for European SMBs + + + + + +
+

Stainless steel jewelry & accessories, wholesale to European SMBs

+

316L stainless steel jewelry, accessories and apparel — hypoallergenic, durable, customizable. Fast stock from the Netherlands, wide range & great prices from Yiwu.

+
+ Min. order €30 + 🇳🇱 NL stock · 3 days + 🇨🇳 Yiwu · 15 days · wider range + Custom logo / OEM +
+ +
+ +
+
+ 🇳🇱

Netherlands warehouse — in stock

+
Delivery in ~3 days
+

Best for urgent orders and restocks. Popular styles ready to ship across Europe.

+
+
+ 🇨🇳

Yiwu warehouse — sourced on demand

+
Delivery in ~15 days
+

Widest range of styles, most affordable. Best for larger orders, new styles and custom items.

+
+
+ +
+
💍

316L stainless steel

Hypoallergenic, durable, tarnish-resistant. Necklaces, rings, bracelets, earrings — gold, rose-gold, silver plating.

+

Customization

Engraving, custom logo, OEM packaging. Make it your own brand.

+
🛒

SMB-friendly

No MOQ — just a €30 minimum order value. Low barrier for small retailers and online sellers.

+
+ +

👇 Click the bubble in the bottom-right — tell us your company and what you're looking for.

+ +
+

Looking for a reliable jewelry supplier?

+

Open the chat, tell us what you need and how many — our assistant shows you prices and stock live, and helps you place the order.

+ Start chatting +
+ +
© Stainless Studio · Wholesale stainless steel jewelry, accessories & apparel · Privacy Policy
+ + + + + + diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..b562ba0 --- /dev/null +++ b/start.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# 一键用 Docker 启动整套 CRM AI Demo: +# 1. Twenty CRM (./twenty 源码 + Docker 构建) +# 2. Chatwoot (./chatwoot 源码 + Docker 构建) +# 3. Bridge + 基础设施 (./docker-compose.yml) +set -e +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TWENTY_DIR="$HERE/twenty" + +echo "==> [1/3] Twenty CRM (源码 Docker 构建 + 开发模式)" +if [ -f "$TWENTY_DIR/docker-compose.yaml" ]; then + docker compose -f "$TWENTY_DIR/docker-compose.yaml" --project-directory "$TWENTY_DIR" up -d --build +else + echo " 跳过:未找到 $TWENTY_DIR/docker-compose.yaml" +fi + +echo "==> [2/3] Chatwoot (源码 Docker 构建 + 开发模式)" +docker compose -f "$HERE/chatwoot/docker-compose.yml" up -d --build + +echo "==> [3/3] 共享基础设施 (pg + redis) + Bridge" +docker compose -f "$HERE/docker-compose.yml" up -d --build + +echo +echo "==> 全部已启动。容器状态:" +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'NAMES|twenty|chatwoot|crm-' +echo +echo "Bridge 日志: docker logs -f crm-bridge" +echo "健康检查: curl http://localhost:4000/healthz" +echo "" +echo "首次构建提示:" +echo " Chatwoot 首次 build 需 5-10 分钟 (bundle install + pnpm install)" +echo " Twenty 首次 build 需 5-10 分钟 (yarn install)" +echo " 之后改源码无需重建镜像 (volume 挂载,服务自动重载)"