chore: CRM-NEW baseline
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 配置 Email 渠道(163):
|
||||
* 1. 创建/更新 email inbox(IMAP 走 imap-id-proxy,绕开 163 的 "Unsafe Login")
|
||||
* 2. 创建/更新默认外发邮件模板(email_outgoing)
|
||||
* 3. 创建/更新 automation:email inbox 新会话 -> 分配 AI Assistant
|
||||
*
|
||||
* 依赖:docker compose 里的 imap-proxy 服务(services/imap-id-proxy)。
|
||||
* 用法:node scripts/add-email-inbox.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OLD_MAIL_CFG = '/home/dev/code/crm/crm-ai-demo/bridge/mail.config.json';
|
||||
const INBOX_NAME = 'Email 163';
|
||||
const RULE_NAME = 'Assign email conversations to AI Assistant';
|
||||
const ASSISTANT_NAME = 'Yehwang Assistant';
|
||||
const TEMPLATE_NAME = 'Default outgoing email';
|
||||
|
||||
const TEMPLATE_BODY = `<!DOCTYPE html>
|
||||
<html><body style="font-family: Arial, sans-serif; color: #1f2937; line-height: 1.6;">
|
||||
<p>Hello,</p>
|
||||
{{ template "content" . }}
|
||||
<p style="margin-top: 24px;">Best regards,<br>Yehwang Support</p>
|
||||
</body></html>`;
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createClient(baseURL) {
|
||||
const cookies = new Map();
|
||||
let csrf = '';
|
||||
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
cookies.set(k, v);
|
||||
if (k === 'csrf_token') csrf = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function req(method, p, body) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (cookies.size) headers.Cookie = cookieHeader();
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
|
||||
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
store(res);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 300)}`);
|
||||
return json;
|
||||
}
|
||||
return { post: (p, b) => req('POST', p, b), get: (p) => req('GET', p), put: (p, b) => req('PUT', p, b) };
|
||||
}
|
||||
|
||||
const data = (j) => (j && j.data !== undefined ? j.data : j);
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const old = JSON.parse(fs.readFileSync(OLD_MAIL_CFG, 'utf8'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
const c = createClient(base);
|
||||
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
|
||||
console.log('登录成功');
|
||||
|
||||
// 1. Email inbox
|
||||
const inboxes = data(await c.get('/api/v1/inboxes')) || [];
|
||||
let inbox = inboxes.find((i) => i.channel === 'email' && i.name === INBOX_NAME);
|
||||
const payload = {
|
||||
name: INBOX_NAME,
|
||||
channel: 'email',
|
||||
enabled: true,
|
||||
csat_enabled: false,
|
||||
prompt_tags_on_reply: false,
|
||||
reopen_window_hours: 0,
|
||||
from: old.smtp.auth.user,
|
||||
aliases: [],
|
||||
config: {
|
||||
auth_type: 'password',
|
||||
imap: [{
|
||||
host: 'imap-proxy',
|
||||
port: 1993,
|
||||
username: old.imap.auth.user,
|
||||
password: old.imap.auth.pass,
|
||||
mailbox: 'INBOX',
|
||||
read_interval: '1m',
|
||||
scan_inbox_since: '48h',
|
||||
tls_type: 'none',
|
||||
tls_skip_verify: false,
|
||||
}],
|
||||
smtp: [{
|
||||
host: old.smtp.host,
|
||||
port: old.smtp.port,
|
||||
username: old.smtp.auth.user,
|
||||
password: old.smtp.auth.pass,
|
||||
tls_type: 'tls',
|
||||
auth_protocol: 'plain',
|
||||
max_conns: 5,
|
||||
idle_timeout: '30s',
|
||||
pool_wait_timeout: '10s',
|
||||
max_msg_retries: 3,
|
||||
hello_hostname: '',
|
||||
}],
|
||||
reply_to: '',
|
||||
enable_plus_addressing: false,
|
||||
},
|
||||
};
|
||||
if (!inbox) {
|
||||
inbox = data(await c.post('/api/v1/inboxes', payload));
|
||||
console.log(`创建 Email Inbox #${inbox.id}`);
|
||||
} else {
|
||||
inbox = data(await c.put(`/api/v1/inboxes/${inbox.id}`, payload));
|
||||
console.log(`更新 Email Inbox #${inbox.id}`);
|
||||
}
|
||||
|
||||
// 2. 默认外发邮件模板
|
||||
const tpls = data(await c.get('/api/v1/templates?type=email_outgoing')) || [];
|
||||
let tpl = tpls.find((t) => t.is_default);
|
||||
if (!tpl) {
|
||||
tpl = data(await c.post('/api/v1/templates', {
|
||||
name: TEMPLATE_NAME,
|
||||
body: TEMPLATE_BODY,
|
||||
subject: '',
|
||||
is_default: true,
|
||||
type: 'email_outgoing',
|
||||
}));
|
||||
console.log(`创建默认外发模板 #${tpl.id}`);
|
||||
} else if (tpl.body !== TEMPLATE_BODY) {
|
||||
tpl = data(await c.put(`/api/v1/templates/${tpl.id}`, {
|
||||
name: tpl.name,
|
||||
body: TEMPLATE_BODY,
|
||||
subject: tpl.subject || '',
|
||||
is_default: true,
|
||||
type: 'email_outgoing',
|
||||
}));
|
||||
console.log(`更新默认外发模板 #${tpl.id}`);
|
||||
} else {
|
||||
console.log(`复用默认外发模板 #${tpl.id}`);
|
||||
}
|
||||
|
||||
// 3. Automation:email inbox -> assistant
|
||||
const assistants = data(await c.get('/api/v1/ai/assistants')) || [];
|
||||
const assistant = assistants.find((a) => a.name === ASSISTANT_NAME);
|
||||
if (!assistant) throw new Error(`未找到 Assistant "${ASSISTANT_NAME}",先运行 scripts/setup-defaults.js`);
|
||||
|
||||
const rulesRaw = data(await c.get('/api/v1/automations/rules?type=new_conversation')) || [];
|
||||
const rules = Array.isArray(rulesRaw) ? rulesRaw : (rulesRaw.rules || []);
|
||||
const rulePayload = {
|
||||
name: RULE_NAME,
|
||||
description: 'Route new email conversations to the AI assistant',
|
||||
type: 'new_conversation',
|
||||
events: [],
|
||||
enabled: true,
|
||||
weight: 0,
|
||||
rules: [{
|
||||
type: 'new_conversation',
|
||||
execution_mode: 'all',
|
||||
event: [],
|
||||
group_operator: 'AND',
|
||||
groups: [{
|
||||
logical_op: 'AND',
|
||||
rules: [{
|
||||
field: 'inbox',
|
||||
field_type: 'conversation',
|
||||
operator: 'equals',
|
||||
value: String(inbox.id),
|
||||
case_sensitive_match: false,
|
||||
}],
|
||||
}],
|
||||
actions: [{
|
||||
type: 'assign_user',
|
||||
value: [String(assistant.user_id)],
|
||||
display_value: [assistant.name],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
const existing = rules.find((r) => r.name === RULE_NAME);
|
||||
if (!existing) {
|
||||
const rule = data(await c.post('/api/v1/automations/rules', rulePayload));
|
||||
console.log(`创建 Automation #${rule.id}(inbox ${inbox.id} → user ${assistant.user_id})`);
|
||||
} else {
|
||||
const rule = data(await c.put(`/api/v1/automations/rules/${existing.id}`, rulePayload));
|
||||
console.log(`更新 Automation #${rule.id}(inbox ${inbox.id} → user ${assistant.user_id})`);
|
||||
}
|
||||
|
||||
console.log('\nEmail 渠道配置完成。IMAP 走 imap-proxy(自动注入 ID),SMTP 直连 smtp.163.com:465。');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[add-email-inbox]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 创建/更新 WhatsApp inbox,并配置「新会话 → AI Assistant」automation。
|
||||
*
|
||||
* .env 需要:
|
||||
* WA_PHONE_NUMBER_ID / WA_WABA_ID / WA_ACCESS_TOKEN / WA_APP_SECRET
|
||||
* WA_VERIFY_TOKEN / WA_INBOX_NAME(可选)
|
||||
* WA_API_VERSION(可选,默认 v26.0)
|
||||
*
|
||||
* 前提:LibreDesk app.root_url 已是公网 HTTPS(scripts/public-https.sh),
|
||||
* 否则 Meta 无法回调,自动订阅也会失败。
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const RULE_PREFIX = 'Assign WhatsApp conversations to AI Assistant';
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createClient(baseURL) {
|
||||
const cookies = new Map();
|
||||
let csrf = '';
|
||||
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
cookies.set(k, v);
|
||||
if (k === 'csrf_token') csrf = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function req(method, p, body) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (cookies.size) headers.Cookie = cookieHeader();
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
|
||||
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
store(res);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && (json.message || json.error)) || text.slice(0, 300)}`);
|
||||
return json;
|
||||
}
|
||||
return { get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), put: (p, b) => req('PUT', p, b) };
|
||||
}
|
||||
|
||||
const data = (j) => (j && j.data !== undefined ? j.data : j);
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
const required = ['WA_PHONE_NUMBER_ID', 'WA_WABA_ID', 'WA_ACCESS_TOKEN', 'WA_APP_SECRET', 'WA_VERIFY_TOKEN'];
|
||||
const missing = required.filter((k) => !env[k]);
|
||||
if (missing.length) throw new Error(`.env 缺少: ${missing.join(', ')}`);
|
||||
|
||||
const c = createClient(base);
|
||||
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
|
||||
console.log('登录成功');
|
||||
|
||||
const inboxName = env.WA_INBOX_NAME || 'WhatsApp US';
|
||||
const payload = {
|
||||
name: inboxName,
|
||||
channel: 'whatsapp',
|
||||
enabled: true,
|
||||
config: {
|
||||
phone_number_id: env.WA_PHONE_NUMBER_ID,
|
||||
waba_id: env.WA_WABA_ID,
|
||||
access_token: env.WA_ACCESS_TOKEN,
|
||||
app_secret: env.WA_APP_SECRET,
|
||||
webhook_verify_token: env.WA_VERIFY_TOKEN,
|
||||
api_version: env.WA_API_VERSION || 'v26.0',
|
||||
},
|
||||
};
|
||||
|
||||
const inboxes = data(await c.get('/api/v1/inboxes')) || [];
|
||||
let inbox = inboxes.find((i) => i.channel === 'whatsapp' && i.name === inboxName);
|
||||
if (!inbox) {
|
||||
inbox = data(await c.post('/api/v1/inboxes', payload));
|
||||
console.log(`创建 WhatsApp Inbox #${inbox.id}`);
|
||||
} else {
|
||||
inbox = data(await c.put(`/api/v1/inboxes/${inbox.id}`, payload));
|
||||
console.log(`更新 WhatsApp Inbox #${inbox.id}`);
|
||||
}
|
||||
|
||||
// automation: WhatsApp inbox 新会话 -> AI Assistant
|
||||
const assistants = data(await c.get('/api/v1/ai/assistants')) || [];
|
||||
const assistant = assistants.find((a) => a.name === (env.AI_ASSISTANT_NAME || 'Yehwang Assistant'));
|
||||
if (!assistant) throw new Error('未找到 AI Assistant,先运行 scripts/setup-defaults.js');
|
||||
|
||||
const ruleName = `${RULE_PREFIX} #${inbox.id}`;
|
||||
const rulesRaw = data(await c.get('/api/v1/automations/rules?type=new_conversation')) || [];
|
||||
const rules = Array.isArray(rulesRaw) ? rulesRaw : (rulesRaw.rules || []);
|
||||
const rulePayload = {
|
||||
name: ruleName,
|
||||
description: 'Route new WhatsApp conversations to the AI assistant',
|
||||
type: 'new_conversation',
|
||||
events: [],
|
||||
enabled: true,
|
||||
weight: 0,
|
||||
rules: [{
|
||||
type: 'new_conversation',
|
||||
execution_mode: 'all',
|
||||
event: [],
|
||||
group_operator: 'AND',
|
||||
groups: [{
|
||||
logical_op: 'AND',
|
||||
rules: [{ field: 'inbox', field_type: 'conversation', operator: 'equals', value: String(inbox.id), case_sensitive_match: false }],
|
||||
}],
|
||||
actions: [{ type: 'assign_user', value: [String(assistant.user_id)], display_value: [assistant.name] }],
|
||||
}],
|
||||
};
|
||||
const existing = rules.find((r) => r.name === ruleName);
|
||||
if (!existing) {
|
||||
const rule = data(await c.post('/api/v1/automations/rules', rulePayload));
|
||||
console.log(`创建 Automation #${rule.id}(inbox ${inbox.id} → user ${assistant.user_id})`);
|
||||
} else {
|
||||
const rule = data(await c.put(`/api/v1/automations/rules/${existing.id}`, rulePayload));
|
||||
console.log(`更新 Automation #${rule.id}(inbox ${inbox.id} → user ${assistant.user_id})`);
|
||||
}
|
||||
|
||||
const root = env.PUBLIC_BASE_URL || '<app.root_url>';
|
||||
console.log('\nWhatsApp Inbox 配置完成');
|
||||
console.log(`Inbox ID: ${inbox.id}`);
|
||||
console.log(`Callback URL: ${root}/webhooks/whatsapp/${inbox.id}`);
|
||||
console.log(`Verify Token: ${env.WA_VERIFY_TOKEN}`);
|
||||
console.log('Meta -> WhatsApp -> Configuration -> Webhook 里填上面两项,订阅 messages');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[add-whatsapp-inbox]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# crm-new 一键启动:生成 .env、渲染 LibreDesk 配置、启动全栈、等待健康检查。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "==> 生成 .env(随机密钥)"
|
||||
cp .env.example .env
|
||||
node - "$ROOT" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const p = path.join(process.argv[2], '.env');
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const set = (k, v) => {
|
||||
const re = new RegExp('^' + k + '=.*$', 'm');
|
||||
s = re.test(s) ? s.replace(re, `${k}=${v}`) : `${s}\n${k}=${v}`;
|
||||
};
|
||||
const strongPassword = () => `Yehwang${crypto.randomInt(1000, 9999)}!${crypto.randomBytes(4).toString('hex')}`;
|
||||
|
||||
set('POSTGRES_PASSWORD', crypto.randomBytes(12).toString('hex'));
|
||||
set('LIBREDESK_ENC_KEY', crypto.randomBytes(16).toString('hex'));
|
||||
set('LIBREDESK_SYSTEM_USER_PASSWORD', strongPassword());
|
||||
set('TOOLS_SHARED_KEY', crypto.randomBytes(16).toString('hex'));
|
||||
set('TWENTY_APP_SECRET', crypto.randomBytes(32).toString('hex'));
|
||||
set('TWENTY_ENCRYPTION_KEY', crypto.randomBytes(32).toString('base64'));
|
||||
set('TWENTY_FALLBACK_ENCRYPTION_KEY', crypto.randomBytes(32).toString('base64'));
|
||||
fs.writeFileSync(p, s, { mode: 0o600 });
|
||||
NODE
|
||||
echo " .env 已生成(含密钥,勿提交)"
|
||||
fi
|
||||
|
||||
echo "==> 渲染 LibreDesk 配置"
|
||||
node scripts/render-config.js
|
||||
|
||||
mkdir -p libredesk/uploads
|
||||
touch libredesk/uploads/.gitkeep
|
||||
|
||||
echo "==> 构建并启动全栈(首次 Twenty 迁移约 1-3 分钟)"
|
||||
docker compose up -d --build
|
||||
|
||||
wait_http() {
|
||||
local name="$1" url="$2" tries="${3:-150}"
|
||||
for i in $(seq 1 "$tries"); do
|
||||
if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then
|
||||
echo " ✓ ${name} (${i} 次探测)"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "!! ${name} 等待超时:$url" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> 等待服务就绪"
|
||||
wait_http "LibreDesk" "http://localhost:9000/health" 150
|
||||
wait_http "Twenty" "http://localhost:3100/healthz" 180
|
||||
wait_http "bridge" "http://localhost:4100/healthz" 60
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
set -a; . ./.env; set +a
|
||||
echo
|
||||
echo "==================== crm-new 已启动 ===================="
|
||||
echo " LibreDesk : http://localhost:9000 (System / ${LIBREDESK_SYSTEM_USER_PASSWORD})"
|
||||
echo " Twenty : http://localhost:3100 (首次访问需注册 workspace)"
|
||||
echo " bridge : http://localhost:4100 (/healthz, /tools/manifest)"
|
||||
echo " Postgres : 127.0.0.1:5440 (libredesk / twenty / bridge)"
|
||||
echo " Redis : 127.0.0.1:6390"
|
||||
echo
|
||||
echo " 下一步:"
|
||||
echo " 1. 打开 LibreDesk 配 AI Provider(DashScope)"
|
||||
echo " 2. 打开 Twenty 注册首个账号,创建 API key 填进 .env"
|
||||
echo " 3. 生成 LibreDesk System API key 填进 .env,然后 node scripts/register-tools.js"
|
||||
echo " 4. 建 Live chat / Email / WhatsApp inbox 与 AI Assistant"
|
||||
echo "======================================================="
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 抓取官网 sitemap.xml(www/us),把全量 URL 落到 bridge 库 bridge_site_map_urls。
|
||||
// 数据只在服务端匹配,不进 LLM/向量库;商品搜索无结果时返回真实商品页链接。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Client } = require('../bridge/node_modules/pg');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const env = {};
|
||||
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
|
||||
if (!line.includes('=') || line.trim().startsWith('#')) continue;
|
||||
const i = line.indexOf('=');
|
||||
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
const password = encodeURIComponent(env.POSTGRES_PASSWORD || '');
|
||||
const PG_URL = process.env.SITEMAP_PG_URL || `postgres://crmnew:${password}@127.0.0.1:5440/bridge`;
|
||||
|
||||
const SITES = {
|
||||
www: { base: 'https://www.yehwang.com', sitemap: 'https://www.yehwang.com/sitemap.xml' },
|
||||
us: { base: 'https://us.yehwang.com', sitemap: 'https://us.yehwang.com/sitemap.xml' },
|
||||
};
|
||||
|
||||
function tokensFor(pathPart) {
|
||||
return pathPart
|
||||
.replace(/\.html$/, '')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((seg) => seg.replace(/-?\d+$/, '').split('-').filter((w) => w && w.length > 1).join(' '))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
async function fetchSitemap(url) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; YehwangSiteMapBot/1.0)' },
|
||||
signal: AbortSignal.timeout(90000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`sitemap HTTP ${res.status}: ${url}`);
|
||||
const xml = await res.text();
|
||||
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function buildSite(client, name) {
|
||||
const cfg = SITES[name];
|
||||
const urls = await fetchSitemap(cfg.sitemap);
|
||||
const rows = [];
|
||||
const seenPaths = new Set();
|
||||
for (const url of urls) {
|
||||
if (!url.startsWith(cfg.base)) continue;
|
||||
const pathPart = url.slice(cfg.base.length).split('?')[0];
|
||||
if (!pathPart || pathPart === '/') continue;
|
||||
if (seenPaths.has(pathPart)) continue;
|
||||
seenPaths.add(pathPart);
|
||||
const isProduct = /\.html$/.test(pathPart);
|
||||
rows.push({
|
||||
path: pathPart,
|
||||
url: cfg.base + pathPart,
|
||||
slug_tokens: tokensFor(pathPart),
|
||||
is_product: isProduct,
|
||||
level: pathPart.split('/').filter(Boolean).length,
|
||||
});
|
||||
}
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM bridge_site_map_urls WHERE site = $1', [name]);
|
||||
for (let i = 0; i < rows.length; i += 1000) {
|
||||
const batch = rows.slice(i, i + 1000);
|
||||
const values = [];
|
||||
const params = [];
|
||||
batch.forEach((r, idx) => {
|
||||
const b = idx * 6;
|
||||
values.push(`($${b + 1},$${b + 2},$${b + 3},$${b + 4},$${b + 5},$${b + 6})`);
|
||||
params.push(name, r.path, r.url, r.slug_tokens, r.is_product, r.level);
|
||||
});
|
||||
await client.query(
|
||||
`INSERT INTO bridge_site_map_urls (site, path, url, slug_tokens, is_product, level) VALUES ${values.join(',')}`,
|
||||
params
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
const productCount = rows.filter((r) => r.is_product).length;
|
||||
console.log(`[site-map] ${name}: total=${rows.length}, products=${productCount}, nav=${rows.length - productCount}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = new Client({ connectionString: PG_URL });
|
||||
await client.connect();
|
||||
await client.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS bridge_site_map_urls (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
slug_tokens TEXT NOT NULL,
|
||||
is_product BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (site, path)
|
||||
)
|
||||
`);
|
||||
await client.query('CREATE INDEX IF NOT EXISTS bridge_site_map_urls_site_idx ON bridge_site_map_urls (site)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS bridge_site_map_urls_tokens_trgm_idx ON bridge_site_map_urls USING gin (slug_tokens gin_trgm_ops)');
|
||||
const targets = process.argv.slice(2).filter((s) => SITES[s]);
|
||||
for (const site of targets.length ? targets : ['www', 'us']) {
|
||||
await buildSite(client, site);
|
||||
}
|
||||
await client.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[build-site-map]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 用 System 会话配置 LibreDesk 的 AI Provider(Completion + Embedding)。
|
||||
* 默认复用旧项目 crm-ai-demo/bridge/kb.config.json 里的 DashScope key(可用 DASHSCOPE_API_KEY 覆盖)。
|
||||
*
|
||||
* 用法:node scripts/configure-ai.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OLD_KB_CFG = '/home/dev/code/crm/crm-ai-demo/bridge/kb.config.json';
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createClient(baseURL) {
|
||||
const cookies = new Map();
|
||||
let csrf = '';
|
||||
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
cookies.set(k, v);
|
||||
if (k === 'csrf_token') csrf = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function req(method, p, body) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (cookies.size) headers.Cookie = cookieHeader();
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
|
||||
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
store(res);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 300)}`);
|
||||
return json;
|
||||
}
|
||||
return { get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), put: (p, b) => req('PUT', p, b) };
|
||||
}
|
||||
|
||||
const mask = (k) => (k ? k.slice(0, 6) + '…' + k.slice(-4) : '(empty)');
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
const password = env.LIBREDESK_SYSTEM_USER_PASSWORD;
|
||||
const email = env.LIBREDESK_SYSTEM_EMAIL || 'System';
|
||||
|
||||
let apiKey = process.env.DASHSCOPE_API_KEY || '';
|
||||
let baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
let completionModel = 'qwen-plus-latest';
|
||||
let embeddingModel = 'text-embedding-v4';
|
||||
let dimensions = 1024;
|
||||
|
||||
if (!apiKey && fs.existsSync(OLD_KB_CFG)) {
|
||||
const kb = JSON.parse(fs.readFileSync(OLD_KB_CFG, 'utf8'));
|
||||
apiKey = kb.apiKey || '';
|
||||
baseURL = kb.baseURL || baseURL;
|
||||
embeddingModel = kb.model || embeddingModel;
|
||||
dimensions = kb.dim || dimensions;
|
||||
}
|
||||
if (!apiKey) throw new Error('没有可用的 DashScope key:设置 DASHSCOPE_API_KEY 或确认旧 kb.config.json 存在');
|
||||
|
||||
const c = createClient(base);
|
||||
await c.post('/api/v1/auth/login', { email, password });
|
||||
console.log(`登录成功:${email}`);
|
||||
|
||||
const completion = {
|
||||
provider: 'openai',
|
||||
base_url: baseURL,
|
||||
api_key: apiKey,
|
||||
model: completionModel,
|
||||
temperature: 0.3,
|
||||
max_tokens: 800,
|
||||
vision: false,
|
||||
};
|
||||
const embedding = {
|
||||
provider: 'openai',
|
||||
base_url: baseURL,
|
||||
api_key: apiKey,
|
||||
model: embeddingModel,
|
||||
dimensions,
|
||||
embedding_max_tokens: 8192,
|
||||
};
|
||||
|
||||
console.log(`Completion: ${completionModel} @ ${baseURL} key=${mask(apiKey)}`);
|
||||
console.log(`Embedding : ${embeddingModel} dim=${dimensions} key=${mask(apiKey)}`);
|
||||
|
||||
console.log('测试 Completion…');
|
||||
await c.post('/api/v1/ai/config/completion/test', completion);
|
||||
console.log(' 通过');
|
||||
|
||||
console.log('测试 Embedding…');
|
||||
await c.post('/api/v1/ai/config/embedding/test', embedding);
|
||||
console.log(' 通过');
|
||||
|
||||
await c.put('/api/v1/ai/config/completion', completion);
|
||||
console.log('已保存 Completion 配置');
|
||||
|
||||
await c.put('/api/v1/ai/config/embedding', embedding);
|
||||
console.log('已保存 Embedding 配置(会自动开始重建知识库向量)');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[configure-ai]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 配置 LibreDesk 通知邮件(SMTP)。默认使用旧项目的 163 授权码。
|
||||
* 修改后 LibreDesk 需要重启才能生效(脚本最后会重启容器)。
|
||||
*
|
||||
* 用法:node scripts/configure-email.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OLD_MAIL_CFG = '/home/dev/code/crm/crm-ai-demo/bridge/mail.config.json';
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createClient(baseURL) {
|
||||
const cookies = new Map();
|
||||
let csrf = '';
|
||||
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
cookies.set(k, v);
|
||||
if (k === 'csrf_token') csrf = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function req(method, p, body) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (cookies.size) headers.Cookie = cookieHeader();
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
|
||||
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
store(res);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 300)}`);
|
||||
return json;
|
||||
}
|
||||
return { post: (p, b) => req('POST', p, b), get: (p) => req('GET', p), put: (p, b) => req('PUT', p, b) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
|
||||
// 163 凭据:优先 .env 覆盖,否则读旧项目 mail.config.json
|
||||
let user = env.SMTP_USER;
|
||||
let pass = env.SMTP_PASS;
|
||||
let host = env.SMTP_HOST || 'smtp.163.com';
|
||||
let port = Number(env.SMTP_PORT || 465);
|
||||
let from = env.SMTP_FROM;
|
||||
if ((!user || !pass) && fs.existsSync(OLD_MAIL_CFG)) {
|
||||
const old = JSON.parse(fs.readFileSync(OLD_MAIL_CFG, 'utf8'));
|
||||
user = user || old.smtp?.auth?.user;
|
||||
pass = pass || old.smtp?.auth?.pass;
|
||||
host = host || old.smtp?.host;
|
||||
port = port || old.smtp?.port;
|
||||
from = from || old.smtp?.from;
|
||||
}
|
||||
if (!user || !pass) throw new Error('缺少 SMTP 凭据:设置 SMTP_USER/SMTP_PASS 或确认旧 mail.config.json 存在');
|
||||
|
||||
const c = createClient(base);
|
||||
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
|
||||
console.log('登录成功');
|
||||
|
||||
const payload = {
|
||||
'notification.email.username': user,
|
||||
'notification.email.host': host,
|
||||
'notification.email.port': port,
|
||||
'notification.email.password': pass,
|
||||
'notification.email.email_address': from,
|
||||
'notification.email.enabled': true,
|
||||
'notification.email.tls_type': 'tls',
|
||||
'notification.email.auth_protocol': 'plain',
|
||||
'notification.email.max_conns': 5,
|
||||
'notification.email.idle_timeout': '30s',
|
||||
'notification.email.wait_timeout': '10s',
|
||||
'notification.email.max_msg_retries': 3,
|
||||
'notification.email.tls_skip_verify': false,
|
||||
'notification.email.hello_hostname': '',
|
||||
};
|
||||
await c.put('/api/v1/settings/notifications/email', payload);
|
||||
console.log(`已保存 SMTP:${host}:${port} user=${user} from=${from}`);
|
||||
|
||||
console.log('重启 LibreDesk 使配置生效…');
|
||||
execFileSync('docker', ['compose', 'restart', 'libredesk'], { cwd: ROOT, stdio: 'inherit' });
|
||||
console.log('完成。');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[configure-email]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 匿名 Live chat:AI 问邮箱 -> 发 OTP -> 读 163 邮箱取码 -> 回码 -> 验证 -> 查订单
|
||||
const { execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const env = {};
|
||||
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
|
||||
if (!line.includes('=') || line.trim().startsWith('#')) continue;
|
||||
const i = line.indexOf('=');
|
||||
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
const BASE = (env.LIBREDESK_BASE_URL || '').replace(/\/+$/, '');
|
||||
const INBOX = env.LIBREDESK_INBOX_UUID;
|
||||
const EMAIL = process.env.OTP_EMAIL || env.SMTP_USER;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function adminCookie() {
|
||||
const r = await fetch(BASE + '/api/v1/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD }),
|
||||
});
|
||||
return r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
async function adminMessages(cookie, uuid) {
|
||||
const r = await fetch(`${BASE}/api/v1/conversations/${uuid}/messages`, { headers: { Cookie: cookie } });
|
||||
if (!r.ok) return [];
|
||||
const body = await r.json();
|
||||
const msgs = (body.data && (body.data.results || body.data.messages)) || [];
|
||||
return msgs.slice().sort((a, b) => Number(a.id) - Number(b.id));
|
||||
}
|
||||
|
||||
async function waitReply(cookie, uuid, afterId, timeoutMs = 90000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(3000);
|
||||
const msgs = await adminMessages(cookie, uuid);
|
||||
const replies = msgs.filter((m) => m.type === 'outgoing' && m.sender_type === 'agent' && !m.private && Number(m.id) > afterId);
|
||||
if (replies.length) return replies[replies.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendWidget(uuid, token, message) {
|
||||
const r = await fetch(`${BASE}/api/v1/widget/chat/conversations/${uuid}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'X-Libredesk-Inbox-ID': INBOX },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`widget send failed: ${r.status} ${(await r.text()).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
function readOtpaCode() {
|
||||
const py = String.raw`
|
||||
import json, imaplib, email, re
|
||||
from email.header import decode_header
|
||||
imaplib.Commands['ID']=('AUTH',)
|
||||
cfg=json.load(open('/home/dev/code/crm/crm-ai-demo/bridge/mail.config.json'))
|
||||
imap=cfg['imap']; auth=imap['auth']
|
||||
M=imaplib.IMAP4_SSL(imap['host'], imap['port']); M.login(auth['user'],auth['pass'])
|
||||
M._simple_command('ID','("name" "pycrm" "version" "1.0" "vendor" "python")'); M.select('INBOX')
|
||||
typ,data=M.search(None,'ALL'); ids=data[0].split()[-10:]
|
||||
for i in reversed(ids):
|
||||
typ,md=M.fetch(i,'(RFC822)'); msg=email.message_from_bytes(md[0][1])
|
||||
subj=str(email.header.make_header(decode_header(msg['Subject'] or '')))
|
||||
if 'verification code' in subj.lower():
|
||||
body=''
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type()=='text/plain': body=part.get_payload(decode=True).decode('utf-8','ignore'); break
|
||||
else: body=msg.get_payload(decode=True).decode('utf-8','ignore')
|
||||
m=re.search(r'\b(\d{6})\b', body)
|
||||
if m: print(m.group(1)); break
|
||||
`;
|
||||
return execFileSync('python3', ['-c', py], { encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cookie = await adminCookie();
|
||||
const init = await fetch(`${BASE}/api/v1/widget/chat/conversations/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Libredesk-Inbox-ID': INBOX },
|
||||
body: JSON.stringify({ message: 'I want to check my order status' }),
|
||||
});
|
||||
const j = await init.json();
|
||||
if (!init.ok) throw new Error(`init failed: ${JSON.stringify(j).slice(0, 200)}`);
|
||||
const uuid = j.data.conversation.uuid;
|
||||
const token = j.data.session_token;
|
||||
console.log('1) conversation', uuid);
|
||||
|
||||
let msgs = await adminMessages(cookie, uuid);
|
||||
let lastId = msgs.length ? Number(msgs[msgs.length - 1].id) : 0;
|
||||
const ask = await waitReply(cookie, uuid, lastId);
|
||||
console.log('2) AI:', ask && ask.text_content);
|
||||
|
||||
await sendWidget(uuid, token, EMAIL);
|
||||
console.log('3) sent email', EMAIL);
|
||||
msgs = await adminMessages(cookie, uuid);
|
||||
lastId = Number(msgs[msgs.length - 1].id);
|
||||
const sent = await waitReply(cookie, uuid, lastId);
|
||||
console.log('4) AI:', sent && sent.text_content);
|
||||
|
||||
const code = readOtpaCode();
|
||||
if (!code) throw new Error('OTP code not found in mailbox');
|
||||
console.log('5) code from mailbox', code);
|
||||
|
||||
await sendWidget(uuid, token, code);
|
||||
msgs = await adminMessages(cookie, uuid);
|
||||
lastId = Number(msgs[msgs.length - 1].id);
|
||||
const final = await waitReply(cookie, uuid, lastId, 120000);
|
||||
console.log('6) AI:', final && final.text_content);
|
||||
if (!final) throw new Error('no final reply');
|
||||
console.log('\nCONVERSATION', uuid);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('[e2e-anon-otp]', err.message); process.exit(1); });
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 端到端验证 widget JWT:
|
||||
* 站点身份 → bridge /identity 签 LibreDesk JWT → auth exchange → 带身份开聊
|
||||
* → AI 把该联系人视为已登录(可查订单/建商机)
|
||||
*
|
||||
* 用法:node scripts/e2e-widget-jwt.js "Can you check the status of my order SO-1?"
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const message = process.argv.slice(2).join(' ') || 'Can you check the status of my order SO-1?';
|
||||
const SITE_ORIGIN = 'http://192.168.31.61:9000';
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const lr = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
const bridge = (env.BRIDGE_BASE_URL || 'http://192.168.31.61:4100').replace(/\/+$/, '');
|
||||
const inboxUUID = env.LIBREDESK_INBOX_UUID;
|
||||
if (!inboxUUID) throw new Error('缺少 LIBREDESK_INBOX_UUID');
|
||||
|
||||
// 1) bridge /identity: 站点身份 -> widget JWT(dev payload;生产用 token)
|
||||
const idRes = await fetch(`${bridge}/identity`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: SITE_ORIGIN },
|
||||
body: JSON.stringify({
|
||||
email: process.env.IDENTITY_EMAIL || 'buyer@test-customer.nl',
|
||||
externalUserId: process.env.IDENTITY_EXTERNAL_ID || 'cust-1001',
|
||||
firstName: process.env.IDENTITY_FIRST || 'Jan',
|
||||
lastName: process.env.IDENTITY_LAST || 'Jansen',
|
||||
}),
|
||||
});
|
||||
const idJson = await idRes.json();
|
||||
if (!idRes.ok) throw new Error(`identity failed: ${JSON.stringify(idJson).slice(0, 200)}`);
|
||||
console.log(`1) userJWT 签发成功 (external_user_id=${idJson.user.external_user_id})`);
|
||||
|
||||
// 2) widget auth exchange
|
||||
const exRes = await fetch(`${lr}/api/v1/widget/chat/auth/exchange`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Libredesk-Inbox-ID': inboxUUID },
|
||||
body: JSON.stringify({ jwt: idJson.userJWT }),
|
||||
});
|
||||
const exJson = await exRes.json();
|
||||
if (!exRes.ok) throw new Error(`auth exchange failed: ${JSON.stringify(exJson).slice(0, 300)}`);
|
||||
const sessionToken = exJson.data.session_token;
|
||||
console.log(`2) auth exchange 成功 (contact user_id=${exJson.data.user.user_id})`);
|
||||
|
||||
// 3) 带 session 开聊
|
||||
const initRes = await fetch(`${lr}/api/v1/widget/chat/conversations/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sessionToken}`, 'X-Libredesk-Inbox-ID': inboxUUID },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
const initJson = await initRes.json();
|
||||
if (!initRes.ok) throw new Error(`chat init failed: ${JSON.stringify(initJson).slice(0, 300)}`);
|
||||
const convUUID = initJson.data.conversation.uuid;
|
||||
console.log(`3) 会话创建: ${convUUID}`);
|
||||
console.log(` visitor message: ${message}`);
|
||||
|
||||
// 4) admin 轮询回复
|
||||
const loginRes = await fetch(`${lr}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD }),
|
||||
});
|
||||
const adminCookie = loginRes.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
|
||||
|
||||
const deadline = Date.now() + 120000;
|
||||
let reply = null;
|
||||
let last = -1;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(3000);
|
||||
const res = await fetch(`${lr}/api/v1/conversations/${convUUID}/messages`, { headers: { Cookie: adminCookie } });
|
||||
if (!res.ok) continue;
|
||||
const body = await res.json();
|
||||
const msgs = ((body.data && (body.data.results || body.data.messages)) || []).slice().sort((a, b) => Number(a.id) - Number(b.id));
|
||||
if (msgs.length !== last) {
|
||||
last = msgs.length;
|
||||
const convRes = await fetch(`${lr}/api/v1/conversations/${convUUID}`, { headers: { Cookie: adminCookie } });
|
||||
const conv = (await convRes.json()).data || {};
|
||||
console.log(` messages=${msgs.length} assigned_user_id=${conv.assigned_user_id} status=${conv.status}`);
|
||||
}
|
||||
const ai = msgs.filter((m) => m.type === 'outgoing' && m.sender_type === 'agent' && !m.private).slice(-1)[0];
|
||||
if (ai) { reply = ai; break; }
|
||||
}
|
||||
|
||||
if (!reply) throw new Error('120 秒内没有 AI 回复');
|
||||
console.log('\n===== AI 回复 #1 =====');
|
||||
console.log(reply.content);
|
||||
|
||||
const followups = String(process.env.FOLLOWUPS || '').split('||').map((s) => s.trim()).filter(Boolean);
|
||||
let round = 1;
|
||||
for (const text of followups) {
|
||||
round += 1;
|
||||
console.log(`\n>> 发送 #${round}: ${text}`);
|
||||
const sendRes = await fetch(`${lr}/api/v1/widget/chat/conversations/${convUUID}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sessionToken}`, 'X-Libredesk-Inbox-ID': inboxUUID },
|
||||
body: JSON.stringify({ message: text }),
|
||||
});
|
||||
if (!sendRes.ok) throw new Error(`send follow-up failed: HTTP ${sendRes.status} ${(await sendRes.text()).slice(0, 200)}`);
|
||||
|
||||
const before = last;
|
||||
let next = null;
|
||||
const deadline2 = Date.now() + 120000;
|
||||
while (Date.now() < deadline2) {
|
||||
await sleep(3000);
|
||||
const res = await fetch(`${lr}/api/v1/conversations/${convUUID}/messages`, { headers: { Cookie: adminCookie } });
|
||||
if (!res.ok) continue;
|
||||
const body = await res.json();
|
||||
const msgs = ((body.data && (body.data.results || body.data.messages)) || []).slice().sort((a, b) => Number(a.id) - Number(b.id));
|
||||
const fresh = msgs.filter((m) => m.type === 'outgoing' && m.sender_type === 'agent' && !m.private);
|
||||
const latest = fresh.slice(-1)[0];
|
||||
if (msgs.length > before && latest && latest.id !== reply.id) { next = latest; break; }
|
||||
}
|
||||
if (!next) { console.log(' (本轮无新回复)'); continue; }
|
||||
reply = next;
|
||||
console.log(`===== AI 回复 #${round} =====`);
|
||||
console.log(next.content);
|
||||
}
|
||||
|
||||
console.log('\n提示:bridge 日志应出现 search_orders / create_opportunity 且 verified=true');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[e2e-widget-jwt]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 端到端验证:模拟 widget 访客发起对话 → Automation 分配给 AI Assistant → 等待 AI 回复。
|
||||
* 用法:node scripts/e2e-widget.js "What is the minimum order value?"
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const question = process.argv.slice(2).join(' ') || 'What is the minimum order value?';
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
|
||||
// 1. admin session: 找到 Website inbox 的 UUID
|
||||
const loginRes = await fetch(`${base}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD }),
|
||||
});
|
||||
const loginCookies = (loginRes.headers.getSetCookie ? loginRes.headers.getSetCookie() : [])
|
||||
.map((c) => c.split(';')[0]);
|
||||
const adminCookie = loginCookies.join('; ');
|
||||
const csrf = (loginCookies.find((c) => c.startsWith('csrf_token=')) || '').split('=')[1];
|
||||
if (!loginRes.ok) throw new Error(`admin login failed: HTTP ${loginRes.status}`);
|
||||
|
||||
const inboxesRes = await fetch(`${base}/api/v1/inboxes`, { headers: { Cookie: adminCookie } });
|
||||
const inboxes = (await inboxesRes.json()).data || [];
|
||||
const inbox = inboxes.find((i) => i.channel === 'livechat' && i.name === 'Website');
|
||||
if (!inbox) throw new Error('未找到 Website livechat inbox,先跑 scripts/setup-defaults.js');
|
||||
console.log(`inbox: #${inbox.id} ${inbox.uuid}`);
|
||||
|
||||
// 2. widget init(匿名访客,发第一条消息)
|
||||
const initRes = await fetch(`${base}/api/v1/widget/chat/conversations/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Libredesk-Inbox-ID': inbox.uuid },
|
||||
body: JSON.stringify({ message: question }),
|
||||
});
|
||||
const initText = await initRes.text();
|
||||
if (!initRes.ok) throw new Error(`widget init failed: HTTP ${initRes.status} ${initText.slice(0, 300)}`);
|
||||
const init = JSON.parse(initText).data;
|
||||
const token = init.session_token;
|
||||
const convUUID = init.conversation.uuid;
|
||||
console.log(`conversation: ${convUUID}`);
|
||||
console.log(`visitor message: ${question}`);
|
||||
|
||||
// 3. 轮询等待 AI 回复(用管理端 API 读消息,widget API 的返回结构不同)
|
||||
const deadline = Date.now() + 120000;
|
||||
let lastCount = 0;
|
||||
let reply = null;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(3000);
|
||||
const res = await fetch(`${base}/api/v1/conversations/${convUUID}/messages`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const body = await res.json();
|
||||
const messages = (body.data && (body.data.results || body.data.messages)) || [];
|
||||
if (messages.length !== lastCount) {
|
||||
lastCount = messages.length;
|
||||
const convRes = await fetch(`${base}/api/v1/conversations/${convUUID}`, { headers: { Cookie: adminCookie } });
|
||||
const conv = (await convRes.json()).data || {};
|
||||
console.log(` [${new Date().toLocaleTimeString()}] messages=${messages.length} assigned_user_id=${conv.assigned_user_id} status=${conv.status}`);
|
||||
}
|
||||
const aiMsg = messages
|
||||
.filter((m) => m.type === 'outgoing' && m.sender_type === 'agent' && !m.private)
|
||||
.slice(-1)[0];
|
||||
if (aiMsg) { reply = aiMsg; break; }
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
console.log('\n超时:120 秒内没有收到 AI 回复。检查 docker logs crmnew-libredesk | grep -i ai');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log('\n===== AI 回复 =====');
|
||||
console.log(reply.content || reply.text_content || '(empty)');
|
||||
console.log('\n===== 元信息 =====');
|
||||
console.log(JSON.stringify({
|
||||
message_id: reply.id,
|
||||
sender_type: reply.sender_type,
|
||||
type: reply.type,
|
||||
private: reply.private,
|
||||
meta: reply.meta,
|
||||
created_at: reply.created_at,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[e2e-widget]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 离线“真实模型”评测:用 fake LibreDesk 客户端跑 createAgent,不写回真实会话。
|
||||
// 依赖:LLM、旧 KB (127.0.0.1:5432/products)、商品 RPC 隧道。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadConfig } = require('../bridge/src/config');
|
||||
const { createAgent } = require('../bridge/src/agent');
|
||||
const { createTools } = require('../bridge/src/tools');
|
||||
const { createRetriever } = require('../bridge/src/rag');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const env = {};
|
||||
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
|
||||
if (!line.includes('=') || line.trim().startsWith('#')) continue;
|
||||
const i = line.indexOf('=');
|
||||
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
const password = encodeURIComponent(env.POSTGRES_PASSWORD || '');
|
||||
env.PG_URL = `postgres://crmnew:${password}@127.0.0.1:5440/bridge`;
|
||||
env.RAG_PG_URL = 'postgres://postgres:postgres@127.0.0.1:5432/products';
|
||||
const cfg = loadConfig({ ...process.env, ...env });
|
||||
|
||||
const CASES = [
|
||||
{ id: 'policy-min-order', message: 'What is the minimum order value?', contact: { type: 'contact', email: 'eval@example.com' }, expectTool: 'search_kb', contains: ['30'] },
|
||||
{ id: 'product-hoops', message: 'Do you have hoop earrings?', contact: { type: 'contact', email: 'eval@example.com' }, expectTool: 'search_products', contains: ['yehwang.com'] },
|
||||
{ id: 'product-zh-rings', message: '推荐一些简约不锈钢戒指', contact: { type: 'contact', email: 'eval@example.com' }, expectTool: 'search_products', contains: ['戒指'] },
|
||||
{ id: 'anon-order-gate', message: 'Where is my order?', contact: { type: 'visitor' }, containsAny: ['email', 'verify', 'verification', '邮箱', '验证'] },
|
||||
];
|
||||
|
||||
function fakeLeadStore() {
|
||||
const memory = new Map();
|
||||
return {
|
||||
get: async (uuid) => memory.get(uuid) || null,
|
||||
save: async (uuid, slots) => { memory.set(uuid, { slots }); return { slots }; },
|
||||
remove: async (uuid) => { memory.delete(uuid); },
|
||||
};
|
||||
}
|
||||
|
||||
async function runCase(c) {
|
||||
let reply = '';
|
||||
let metric = null;
|
||||
const tools = createTools(cfg, { retriever: createRetriever(cfg) });
|
||||
tools.leadStore = fakeLeadStore();
|
||||
const agent = createAgent(cfg, {
|
||||
tools,
|
||||
client: {
|
||||
getMessages: async () => [{ id: 1, text_content: c.message, sender_type: 'contact', private: false }],
|
||||
sendMessage: async (_uuid, content) => { reply = content; },
|
||||
getConversation: async () => ({}),
|
||||
},
|
||||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||||
metrics: { enabled: false, record: async (m) => { metric = m; } },
|
||||
contextManager: { compressHistory: async (_c, h) => h },
|
||||
llmCache: { enabled: false, get: async () => null, store: async () => false },
|
||||
});
|
||||
const conv = { uuid: `eval-${c.id}`, inbox_id: '1', channel: 'livechat', contact: c.contact };
|
||||
await agent.run(conv, { uuid: `msg-${c.id}`, text_content: c.message, content: c.message });
|
||||
return { reply, metric };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let failed = 0;
|
||||
for (const c of CASES) {
|
||||
try {
|
||||
const { reply, metric } = await runCase(c);
|
||||
const tools = (metric && metric.toolCalls || []).map((t) => t.name);
|
||||
const okTool = !c.expectTool || tools.includes(c.expectTool);
|
||||
const okContains = !c.contains || c.contains.every((s) => reply.includes(s));
|
||||
const okAny = !c.containsAny || c.containsAny.some((s) => reply.toLowerCase().includes(s.toLowerCase()));
|
||||
const ok = okTool && okContains && okAny;
|
||||
if (!ok) failed += 1;
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${c.id} tools=[${tools.join(',')}] reply=${reply.slice(0, 140).replace(/\n/g, ' ')}`);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.log(`FAIL ${c.id} error=${err.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${CASES.length - failed}/${CASES.length} passed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch((err) => { console.error('[eval-live]', err.message); process.exit(1); });
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 离线回归评测:不调用 LLM,覆盖意图/注入/商品词/lead/格式/引用审计等确定性逻辑。
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { quickClassify } = require('../bridge/src/intent');
|
||||
const { detectInjection } = require('../bridge/src/security');
|
||||
const { translateProductQuery } = require('../bridge/src/product-query');
|
||||
const { hasBuyingIntent, missingLeadContacts } = require('../bridge/src/lead-intent');
|
||||
const { markdownToHtml } = require('../bridge/src/markdown');
|
||||
const { auditAnswer } = require('../bridge/src/audit');
|
||||
|
||||
const ENTRIES = [
|
||||
{ title: 'Rings', category: 'jewelry/rings', url: 'https://www.yehwang.com/jewelry/rings', aliases: ['戒指', 'ringen'] },
|
||||
{ title: 'Earrings', category: 'jewelry/earrings', url: 'https://www.yehwang.com/jewelry/earrings', aliases: ['耳环'] },
|
||||
{ title: 'Necklaces', category: 'jewelry/necklaces', url: 'https://www.yehwang.com/jewelry/necklaces', aliases: ['项链'] },
|
||||
];
|
||||
const cases = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../bridge/eval/golden.json'), 'utf8'));
|
||||
|
||||
function run(c) {
|
||||
if (c.type === 'intent') return quickClassify(c.message);
|
||||
if (c.type === 'injection') return detectInjection(c.message).level;
|
||||
if (c.type === 'product_query') return translateProductQuery(c.message, ENTRIES).query;
|
||||
if (c.type === 'lead') {
|
||||
const missing = missingLeadContacts(c.slots || {});
|
||||
return missing.join(',');
|
||||
}
|
||||
if (c.type === 'markdown') return markdownToHtml(c.message);
|
||||
if (c.type === 'audit') {
|
||||
const audit = auditAnswer({
|
||||
finalContent: c.message,
|
||||
toolResults: [{ results: [{ source_urls: c.tool_urls || [] }] }],
|
||||
toolCalls: [],
|
||||
});
|
||||
return String(audit.unverified_urls.length);
|
||||
}
|
||||
throw new Error(`unknown case type: ${c.type}`);
|
||||
}
|
||||
|
||||
function check(c, actual) {
|
||||
if (c.type === 'lead') return actual === (c.expect_missing || []).join(',');
|
||||
if (c.type === 'markdown') {
|
||||
return (c.expect_contains || []).every((s) => actual.includes(s))
|
||||
&& (c.expect_not_contains || []).every((s) => !actual.includes(s));
|
||||
}
|
||||
if (c.type === 'audit') return actual === String(c.expect_unverified);
|
||||
return actual === c.expect;
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const c of cases) {
|
||||
const actual = run(c);
|
||||
const ok = check(c, actual);
|
||||
if (!ok) failed += 1;
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${c.id}${ok ? '' : ` expected=${JSON.stringify(c.expect || c.expect_missing || c.expect_contains)} actual=${JSON.stringify(actual)}`}`);
|
||||
}
|
||||
console.log(`\n${cases.length - failed}/${cases.length} passed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 把旧 customer-service-kb/*.md 导入 LibreDesk 帮助中心(文章 + ai_enabled)。
|
||||
*
|
||||
* 为什么用文章而不是 snippets:
|
||||
* - 只有帮助中心文章能被 AI 引用([[cite:id]])
|
||||
* - 会同时进入帮助中心站内搜索
|
||||
*
|
||||
* 鉴权:用 System 账号登录拿 session + CSRF(不依赖 API key)。
|
||||
* 用法:
|
||||
* node scripts/import-kb-helpcenter.js --dry-run
|
||||
* node scripts/import-kb-helpcenter.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const KB_DIR = '/home/dev/code/crm/crm-ai-demo/customer-service-kb';
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
// 文件 -> collection(跳过 site-map:导航类内容会污染 FAQ 向量检索)
|
||||
const COLLECTIONS = [
|
||||
{ file: '01-registration-account.md', slug: 'registration', name: 'Registration & Account' },
|
||||
{ file: '02-ordering.md', slug: 'ordering', name: 'Ordering' },
|
||||
{ file: '03-pre-order.md', slug: 'pre-order', name: 'Pre-order' },
|
||||
{ file: '04-payment-vat-invoice.md', slug: 'payment', name: 'Payment, VAT & Invoices' },
|
||||
{ file: '05-shipping-delivery.md', slug: 'shipping', name: 'Shipping & Delivery' },
|
||||
{ file: '06-returns-refunds.md', slug: 'returns', name: 'Returns & Refunds' },
|
||||
{ file: '07-factory-direct.md', slug: 'factory-direct', name: 'Factory-Direct' },
|
||||
{ file: '09-membership-rewards.md', slug: 'membership', name: 'Membership & Rewards' },
|
||||
{ file: '10-products-and-jewelry-care.md', slug: 'products', name: 'Products & Jewelry Care' },
|
||||
];
|
||||
|
||||
const HELP_CENTER = {
|
||||
name: 'Yehwang Help Center',
|
||||
slug: 'yehwang-help',
|
||||
page_title: 'Yehwang Help Center',
|
||||
meta_description: 'FAQ for Yehwang wholesale customers: ordering, payment, shipping, returns and more.',
|
||||
default_locale: 'en',
|
||||
allowed_locales: ['en'],
|
||||
template: 'classic',
|
||||
};
|
||||
|
||||
function loadEnv() {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- markdown -> html
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&(?!(amp|lt|gt|quot|#\d+);)/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function inline(s) {
|
||||
let t = escapeHtml(s);
|
||||
t = t.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
return t;
|
||||
}
|
||||
|
||||
function answerToHtml(md) {
|
||||
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
let para = [];
|
||||
let list = null; // { ordered, items }
|
||||
|
||||
const flushPara = () => {
|
||||
if (para.length) {
|
||||
blocks.push(`<p>${inline(para.join(' '))}</p>`);
|
||||
para = [];
|
||||
}
|
||||
};
|
||||
const flushList = () => {
|
||||
if (list) {
|
||||
const tag = list.ordered ? 'ol' : 'ul';
|
||||
blocks.push(`<${tag}>` + list.items.map((i) => `<li>${inline(i)}</li>`).join('') + `</${tag}>`);
|
||||
list = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line) {
|
||||
flushPara();
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
const ol = line.match(/^(\d+)[.)]\s+(.*)$/);
|
||||
const ul = line.match(/^[-*+]\s+(.*)$/);
|
||||
if (ol) {
|
||||
flushPara();
|
||||
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] }; }
|
||||
list.items.push(ol[2]);
|
||||
} else if (ul) {
|
||||
flushPara();
|
||||
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] }; }
|
||||
list.items.push(ul[1]);
|
||||
} else {
|
||||
flushList();
|
||||
para.push(line);
|
||||
}
|
||||
}
|
||||
flushPara();
|
||||
flushList();
|
||||
return blocks.join('\n');
|
||||
}
|
||||
|
||||
function parseFrontmatter(text) {
|
||||
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
|
||||
if (!m) return { meta: {}, body: text };
|
||||
const meta = {};
|
||||
for (const line of m[1].split('\n')) {
|
||||
const kv = line.match(/^([a-z_]+):\s*(.*)$/);
|
||||
if (kv) meta[kv[1]] = kv[2];
|
||||
}
|
||||
return { meta, body: text.slice(m[0].length) };
|
||||
}
|
||||
|
||||
function parseSections(body) {
|
||||
const parts = body.split(/^### Q:\s*/m).slice(1);
|
||||
return parts.map((p) => {
|
||||
const lines = p.split('\n');
|
||||
const question = lines.shift().trim();
|
||||
const answerMd = lines.join('\n').trim();
|
||||
return { question, answerMd };
|
||||
}).filter((s) => s.question && s.answerMd);
|
||||
}
|
||||
|
||||
function normalizeQ(q) {
|
||||
return q.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function plainText(html) {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HTTP client
|
||||
function createClient(baseURL) {
|
||||
const cookies = new Map();
|
||||
let csrf = '';
|
||||
|
||||
function cookieHeader() {
|
||||
return [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
function storeCookies(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function'
|
||||
? res.headers.getSetCookie()
|
||||
: [res.headers.get('set-cookie')].filter(Boolean);
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
cookies.set(k, v);
|
||||
if (k === 'csrf_token') csrf = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function req(method, urlPath, body) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (cookies.size) headers.Cookie = cookieHeader();
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (csrf && method !== 'GET' && method !== 'HEAD') headers['X-CSRFTOKEN'] = csrf;
|
||||
|
||||
const res = await fetch(baseURL + urlPath, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
storeCookies(res);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) {
|
||||
const msg = json && json.message ? json.message : text.slice(0, 300);
|
||||
throw new Error(`${method} ${urlPath} -> HTTP ${res.status}: ${msg}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
return { req, get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), put: (p, b) => req('PUT', p, b) };
|
||||
}
|
||||
|
||||
function asData(json) {
|
||||
if (!json) return null;
|
||||
return json.data !== undefined ? json.data : json;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
async function main() {
|
||||
const env = loadEnv();
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
const email = env.LIBREDESK_SYSTEM_EMAIL || 'System';
|
||||
const password = env.LIBREDESK_SYSTEM_USER_PASSWORD;
|
||||
if (!password) throw new Error('缺少 LIBREDESK_SYSTEM_USER_PASSWORD');
|
||||
|
||||
// 1. 解析 KB
|
||||
const plans = [];
|
||||
const seen = new Set();
|
||||
let totalQuestions = 0;
|
||||
for (const col of COLLECTIONS) {
|
||||
const file = path.join(KB_DIR, col.file);
|
||||
if (!fs.existsSync(file)) { console.warn(`跳过缺失文件 ${col.file}`); continue; }
|
||||
const { body } = parseFrontmatter(fs.readFileSync(file, 'utf8'));
|
||||
const sections = parseSections(body);
|
||||
const articles = [];
|
||||
for (const s of sections) {
|
||||
const key = normalizeQ(s.question);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const html = answerToHtml(s.answerMd);
|
||||
const text = plainText(html);
|
||||
articles.push({
|
||||
title: s.question.replace(/\s+/g, ' ').trim(),
|
||||
content: `<h2>${escapeHtml(s.question)}</h2>\n${html}`,
|
||||
excerpt: text.slice(0, 200),
|
||||
});
|
||||
}
|
||||
totalQuestions += articles.length;
|
||||
plans.push({ ...col, articles });
|
||||
}
|
||||
|
||||
console.log(`解析完成:${plans.length} 个 collection,${totalQuestions} 篇文章(已全局去重)`);
|
||||
for (const p of plans) console.log(` - ${p.name}: ${p.articles.length}`);
|
||||
if (DRY) return;
|
||||
|
||||
// 2. 登录
|
||||
const c = createClient(base);
|
||||
await c.post('/api/v1/auth/login', { email, password });
|
||||
console.log(`登录成功:${email}`);
|
||||
|
||||
// 3. 帮助中心
|
||||
const existingHC = asData(await c.get('/api/v1/help-centers')) || [];
|
||||
let hc = existingHC.find((x) => x.slug === HELP_CENTER.slug);
|
||||
if (!hc) {
|
||||
hc = asData(await c.post('/api/v1/help-centers', {
|
||||
...HELP_CENTER,
|
||||
allowed_locales: HELP_CENTER.allowed_locales,
|
||||
theme: {},
|
||||
}));
|
||||
console.log(`创建帮助中心 #${hc.id} ${hc.slug}`);
|
||||
} else {
|
||||
console.log(`复用帮助中心 #${hc.id} ${hc.slug}`);
|
||||
}
|
||||
|
||||
// 4. collections + articles
|
||||
const existingCols = asData(await c.get(`/api/v1/help-centers/${hc.id}/collections`)) || [];
|
||||
// 注意:服务端会用 name 生成 slug(重名时追加 -2),因此按 name 匹配而不是 slug。
|
||||
const normName = (s) => String(s || '').trim().toLowerCase();
|
||||
const colByName = new Map(existingCols.map((x) => [normName(x.name), x]));
|
||||
|
||||
// 现有文章标题(按 collection 分组,避免重复导入)
|
||||
const treeResp = asData(await c.get(`/api/v1/help-centers/${hc.id}/tree`)) || {};
|
||||
const titlesByColId = new Map();
|
||||
const walk = (nodes) => {
|
||||
for (const n of nodes || []) {
|
||||
const set = titlesByColId.get(n.id) || new Set();
|
||||
for (const a of n.articles || []) set.add(normalizeQ(a.title));
|
||||
titlesByColId.set(n.id, set);
|
||||
walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(treeResp.tree);
|
||||
|
||||
let createdCols = 0, createdArticles = 0, skipped = 0;
|
||||
let order = 0;
|
||||
for (const plan of plans) {
|
||||
order += 10;
|
||||
let col = colByName.get(normName(plan.name));
|
||||
if (!col) {
|
||||
col = asData(await c.post(`/api/v1/help-centers/${hc.id}/collections`, {
|
||||
slug: plan.slug,
|
||||
locale: 'en',
|
||||
name: plan.name,
|
||||
description: '',
|
||||
icon: '',
|
||||
sort_order: order,
|
||||
is_published: true,
|
||||
}));
|
||||
createdCols++;
|
||||
colByName.set(normName(plan.name), col);
|
||||
}
|
||||
|
||||
const existingTitles = titlesByColId.get(col.id) || new Set();
|
||||
let added = 0;
|
||||
let i = 0;
|
||||
for (const art of plan.articles) {
|
||||
i += 1;
|
||||
if (existingTitles.has(normalizeQ(art.title))) { skipped++; continue; }
|
||||
await c.post(`/api/v1/collections/${col.id}/articles`, {
|
||||
locale: 'en',
|
||||
title: art.title,
|
||||
content: art.content,
|
||||
excerpt: art.excerpt,
|
||||
sort_order: i * 10,
|
||||
status: 'published',
|
||||
ai_enabled: true,
|
||||
});
|
||||
added++;
|
||||
createdArticles++;
|
||||
}
|
||||
console.log(` ${plan.name}: +${added}`);
|
||||
}
|
||||
|
||||
console.log(`\n完成:新建 collection ${createdCols},文章 ${createdArticles},跳过已存在 ${skipped}`);
|
||||
console.log('注意:向量索引依赖 AI Provider 的 embedding 配置;配置后会自动 reindex。');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[import-kb]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# 启动/重启公网 HTTPS(Cloudflare Quick Tunnel),并把 LibreDesk root_url 指过去。
|
||||
# 注意:quick tunnel 每次重启 URL 会变,需要重新跑本脚本(Meta 里的 webhook 也要同步改)。
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
docker compose up -d public-gateway cloudflared
|
||||
|
||||
URL=""
|
||||
for _ in $(seq 1 30); do
|
||||
URL=$(docker logs crmnew-cloudflared 2>&1 | grep -oE 'https://[a-zA-Z0-9.-]+\.trycloudflare\.com' | tail -1 || true)
|
||||
[[ -n "$URL" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
if [[ -z "$URL" ]]; then
|
||||
echo "未拿到 cloudflared 公网地址,请检查: docker logs crmnew-cloudflared" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node scripts/set-public-url.js "$URL"
|
||||
|
||||
# 如果已经配置过 WhatsApp,同步更新 Meta 的 app 级 webhook(隧道 URL 变化时必须)
|
||||
if grep -q '^WA_APP_ID=' .env 2>/dev/null; then
|
||||
node scripts/set-wa-webhook.js "$URL"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
公网 HTTPS: $URL
|
||||
WhatsApp webhook 地址: $URL/webhooks/whatsapp/<inbox_id>
|
||||
|
||||
仅暴露:
|
||||
/webhooks/whatsapp/* (Meta 回调)
|
||||
/hc/* (帮助中心,AI 引用链接)
|
||||
/widget* /static/public/*
|
||||
|
||||
管理后台 / API 仍只在内网 http://192.168.31.61:9000
|
||||
EOF
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 质量告警:读 bridge_ai_metrics,阈值超限时发邮件。建议 cron 每 15-30 分钟跑一次。
|
||||
// 用法: node scripts/quality-alert.js [hours] [--dry-run]
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadConfig } = require('../bridge/src/config');
|
||||
const { createMetrics } = require('../bridge/src/metrics');
|
||||
const { evaluateQuality } = require('../bridge/src/quality');
|
||||
const { sendMail } = require('../bridge/src/smtp');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const env = {};
|
||||
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
|
||||
if (!line.includes('=') || line.trim().startsWith('#')) continue;
|
||||
const i = line.indexOf('=');
|
||||
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
const password = encodeURIComponent(env.POSTGRES_PASSWORD || '');
|
||||
env.PG_URL = process.env.QUALITY_PG_URL || `postgres://crmnew:${password}@127.0.0.1:5440/bridge`;
|
||||
const cfg = loadConfig({ ...process.env, ...env });
|
||||
const hours = Number(process.argv[2]) || 1;
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
(async () => {
|
||||
const metrics = createMetrics(cfg);
|
||||
const report = await metrics.report(hours);
|
||||
const result = evaluateQuality(report.summary);
|
||||
if (result.skipped) {
|
||||
console.log(JSON.stringify({ skipped: true, reason: result.reason, summary: report.summary }));
|
||||
await metrics.pool.end();
|
||||
return;
|
||||
}
|
||||
if (!result.alerts.length) {
|
||||
console.log(JSON.stringify({ ok: true, summary: report.summary }));
|
||||
await metrics.pool.end();
|
||||
return;
|
||||
}
|
||||
const text = [
|
||||
`Yehwang bridge AI 质量告警(最近 ${hours} 小时)`,
|
||||
'',
|
||||
...result.alerts.map((a) => `- ${a.message}`),
|
||||
'',
|
||||
'summary: ' + JSON.stringify(report.summary),
|
||||
'tools: ' + JSON.stringify(report.tools),
|
||||
].join('\n');
|
||||
console.log(text);
|
||||
if (dryRun) {
|
||||
console.log('[dry-run] not sending email');
|
||||
} else if (cfg.smtpHost && cfg.smtpUser && cfg.smtpPass) {
|
||||
const to = env.ALERT_EMAIL_TO || cfg.smtpUser;
|
||||
await sendMail(
|
||||
{ host: cfg.smtpHost, port: cfg.smtpPort, user: cfg.smtpUser, pass: cfg.smtpPass, from: cfg.smtpFrom },
|
||||
{ to, subject: 'Yehwang bridge AI 质量告警', text }
|
||||
);
|
||||
console.log(`[alert] email sent to ${to}`);
|
||||
} else {
|
||||
console.error('[alert] SMTP not configured; alert only logged');
|
||||
}
|
||||
await metrics.pool.end();
|
||||
})().catch((err) => { console.error('[quality-alert]', err.message); process.exit(1); });
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 质量日报:读取 bridge 库的 bridge_ai_metrics,输出最近 N 小时指标。
|
||||
const path = require('path');
|
||||
const { loadConfig } = require('../bridge/src/config');
|
||||
const { createMetrics } = require('../bridge/src/metrics');
|
||||
|
||||
const fs = require('fs');
|
||||
const envFile = path.resolve(__dirname, '..', '.env');
|
||||
const env = {};
|
||||
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
|
||||
if (!line.includes('=') || line.trim().startsWith('#')) continue;
|
||||
const i = line.indexOf('=');
|
||||
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
const password = encodeURIComponent(env.POSTGRES_PASSWORD || '');
|
||||
env.PG_URL = process.env.QUALITY_PG_URL || `postgres://crmnew:${password}@127.0.0.1:5440/bridge`;
|
||||
const cfg = loadConfig({ ...process.env, ...env });
|
||||
const hours = Number(process.argv[2]) || 24;
|
||||
|
||||
(async () => {
|
||||
const metrics = createMetrics(cfg);
|
||||
if (!metrics.enabled) throw new Error('PG_URL is not configured');
|
||||
const report = await metrics.report(hours);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
await metrics.pool.end();
|
||||
})().catch((err) => {
|
||||
console.error('[quality-report]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# 重建旧 KB 的 parent-child 索引(调用旧 bridge 容器里的 indexer),再回填 text_tsv。
|
||||
# 旧表会由 indexer 归档为 kb_docs_vN;额外先做一份 kb_docs_backup_<date> 备份。
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DATE="$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
docker exec crm-postgres psql -U postgres -d products -c \
|
||||
"CREATE TABLE IF NOT EXISTS kb_docs_backup_${DATE} AS SELECT * FROM kb_docs;" >/dev/null
|
||||
|
||||
docker exec crm-postgres psql -U postgres -d products <<'SQL' >/dev/null
|
||||
CREATE OR REPLACE FUNCTION kb_docs_tsv_update() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.text_tsv := to_tsvector('english',
|
||||
coalesce(NEW.question,'') || ' ' || coalesce(NEW.answer,'') || ' ' ||
|
||||
coalesce(array_to_string(NEW.keywords,' '),'') || ' ' ||
|
||||
coalesce(NEW.title,'') || ' ' || coalesce(NEW.section,''));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
SQL
|
||||
|
||||
docker exec crm-bridge node -e "
|
||||
const {loadConfig}=require('./config/loader');
|
||||
const {createEmbeddingClient}=require('./utils/embedding');
|
||||
const {createKbIndexer}=require('./faq/kbIndexer');
|
||||
(async()=>{
|
||||
const config=loadConfig();
|
||||
const emb=createEmbeddingClient(config.emb, config.PG_URL);
|
||||
const idx=createKbIndexer({pool:emb.pool, embeddingClient:emb, keepVersions:2});
|
||||
const res=await idx.indexNewVersion({skipIfUnchanged:false, note:'parent-child rebuild', aiGenerated:false});
|
||||
console.log(JSON.stringify(res));
|
||||
await emb.pool.end();
|
||||
})().catch(e=>{console.error(e.message);process.exit(1);});
|
||||
"
|
||||
|
||||
cd "$ROOT" && node scripts/upgrade-kb-schema.js
|
||||
echo "[rebuild-kb-index] done; backup=kb_docs_backup_${DATE}"
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 把 bridge 的工具 manifest 注册到 LibreDesk(Admin → AI → Tools)。
|
||||
*
|
||||
* 鉴权二选一:
|
||||
* 1. .env 配了 LIBREDESK_API_KEY/SECRET → 用 API key
|
||||
* 2. 否则用 System 账号登录(LIBREDESK_SYSTEM_USER_PASSWORD)拿 session + CSRF
|
||||
*
|
||||
* 用法:node scripts/register-tools.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createClient(baseURL) {
|
||||
const cookies = new Map();
|
||||
let csrf = '';
|
||||
let authHeader = '';
|
||||
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
cookies.set(k, v);
|
||||
if (k === 'csrf_token') csrf = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function req(method, p, body) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (authHeader) headers.Authorization = authHeader;
|
||||
else if (cookies.size) headers.Cookie = cookieHeader();
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (!authHeader && csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
|
||||
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
store(res);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 300)}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
return {
|
||||
setAuth: (h) => { authHeader = h; },
|
||||
login: (email, password) => req('POST', '/api/v1/auth/login', { email, password }),
|
||||
get: (p) => req('GET', p),
|
||||
post: (p, b) => req('POST', p, b),
|
||||
put: (p, b) => req('PUT', p, b),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
const toolsKey = env.TOOLS_SHARED_KEY;
|
||||
if (!toolsKey) throw new Error('TOOLS_SHARED_KEY 未配置');
|
||||
|
||||
const c = createClient(base);
|
||||
if (env.LIBREDESK_API_KEY && env.LIBREDESK_API_SECRET) {
|
||||
c.setAuth(`token ${env.LIBREDESK_API_KEY}:${env.LIBREDESK_API_SECRET}`);
|
||||
console.log('使用 API key 鉴权');
|
||||
} else {
|
||||
await c.login(env.LIBREDESK_SYSTEM_EMAIL || 'System', env.LIBREDESK_SYSTEM_USER_PASSWORD);
|
||||
console.log('使用 System 会话鉴权');
|
||||
}
|
||||
|
||||
const manifestRes = await fetch('http://127.0.0.1:4100/tools/manifest', { headers: { 'X-Tools-Key': toolsKey } });
|
||||
if (!manifestRes.ok) throw new Error(`bridge manifest 不可用(HTTP ${manifestRes.status}),先启动全栈`);
|
||||
const manifest = await manifestRes.json();
|
||||
|
||||
const listJson = await c.get('/api/v1/ai/tools');
|
||||
const existing = Array.isArray(listJson) ? listJson : (listJson.data || []);
|
||||
const byName = new Map(existing.map((t) => [t.name, t]));
|
||||
|
||||
let failed = 0;
|
||||
for (const tool of manifest.tools) {
|
||||
const body = {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
url: tool.url, // http://bridge:4100/tools/<name>(容器网络内可达)
|
||||
method: 'POST',
|
||||
auth: { headers: [{ key: 'X-Tools-Key', value: toolsKey }] },
|
||||
parameters: tool.parameters,
|
||||
enabled: true,
|
||||
copilot_enabled: true,
|
||||
generate_reply_enabled: true,
|
||||
requires_agent_approval: false,
|
||||
requires_verification: Boolean(tool.requires_verification),
|
||||
};
|
||||
const current = byName.get(tool.name);
|
||||
try {
|
||||
if (current) {
|
||||
await c.put(`/api/v1/ai/tools/${current.id}`, body);
|
||||
console.log(` ✓ ${tool.name} (updated)`);
|
||||
} else {
|
||||
await c.post('/api/v1/ai/tools', body);
|
||||
console.log(` ✓ ${tool.name} (created)`);
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.error(` ✗ ${tool.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error('\n部分工具注册失败,回退到 Admin → AI → Tools 手工创建。');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[register-tools]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 渲染 libredesk/config/config.toml:
|
||||
* - 读取顶层 .env(crm-new/.env)
|
||||
* - 替换模板中的 DB 用户/密码/加密密钥占位符
|
||||
* - 输出 600 权限的 config.toml(不入库)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const envPath = path.join(root, '.env');
|
||||
const tplPath = path.join(root, 'libredesk', 'config', 'config.toml.example');
|
||||
const outPath = path.join(root, 'libredesk', 'config', 'config.toml');
|
||||
|
||||
function parseEnv(text) {
|
||||
const out = {};
|
||||
for (const line of text.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = t.slice(0, eq).trim();
|
||||
let value = t.slice(eq + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
console.error('[render-config] .env 不存在,请先运行 scripts/bootstrap.sh');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = parseEnv(fs.readFileSync(envPath, 'utf8'));
|
||||
for (const key of ['POSTGRES_USER', 'POSTGRES_PASSWORD', 'LIBREDESK_ENC_KEY']) {
|
||||
if (!env[key]) {
|
||||
console.error(`[render-config] 缺少 ${key}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (env.LIBREDESK_ENC_KEY.length !== 32) {
|
||||
console.error('[render-config] LIBREDESK_ENC_KEY 必须是 32 字符(openssl rand -hex 16)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rendered = fs
|
||||
.readFileSync(tplPath, 'utf8')
|
||||
.replaceAll('__DB_USER__', env.POSTGRES_USER)
|
||||
.replaceAll('__DB_PASSWORD__', env.POSTGRES_PASSWORD)
|
||||
.replaceAll('__ENCRYPTION_KEY__', env.LIBREDESK_ENC_KEY);
|
||||
|
||||
fs.writeFileSync(outPath, rendered, { mode: 0o600 });
|
||||
console.log(`[render-config] wrote ${path.relative(root, outPath)} (mode 600)`);
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# 测试服 JSON-RPC 端口外网被封:在本机建 SSH 隧道,供 crm-new 的 bridge 访问。
|
||||
#
|
||||
# 默认只启动隧道并打印 .env 需要的 4 行配置;
|
||||
# 加 --apply 会自动更新 .env 里的 RPC URL 并重建 bridge。
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
APPLY=0
|
||||
[[ "${1:-}" == "--apply" ]] && APPLY=1
|
||||
|
||||
NET=$(docker network ls --format '{{.Name}}' | grep -E '^crm-new.*_default$' | head -1 || true)
|
||||
if [[ -z "$NET" ]]; then
|
||||
echo "找不到 crm-new 的 compose 网络,请先启动 stack(docker compose up -d)" >&2
|
||||
exit 1
|
||||
fi
|
||||
GW=$(docker network inspect "$NET" -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}')
|
||||
PORTS=(24503 24505 24506 24509)
|
||||
|
||||
ARGS=()
|
||||
for p in "${PORTS[@]}"; do
|
||||
if ss -ltn "sport = :$p" 2>/dev/null | grep -q "$GW"; then
|
||||
echo "端口 $p 已在 $GW 监听,跳过"
|
||||
else
|
||||
ARGS+=("-L" "$GW:$p:127.0.0.1:$p")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#ARGS[@]} -gt 0 ]]; then
|
||||
ssh -F ~/.ssh/config -fN \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
"${ARGS[@]}" yw-dev
|
||||
echo "隧道已建立:$GW -> yw-dev 127.0.0.1"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
bridge 需要以下配置(当前网关 $GW):
|
||||
RECOMMEND_URL=jsonrpc://$GW:24503
|
||||
ORDER_API_URL=jsonrpc://$GW:24505
|
||||
MEMBER_RPC_URL=jsonrpc://$GW:24506
|
||||
INVENTORY_API_URL=jsonrpc://$GW:24509
|
||||
EOF
|
||||
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
python3 - "$GW" <<'PY'
|
||||
import pathlib, sys
|
||||
gw = sys.argv[1]
|
||||
p = pathlib.Path('.env')
|
||||
lines = p.read_text().split('\n')
|
||||
mapping = {
|
||||
'RECOMMEND_URL': f'jsonrpc://{gw}:24503',
|
||||
'ORDER_API_URL': f'jsonrpc://{gw}:24505',
|
||||
'MEMBER_RPC_URL': f'jsonrpc://{gw}:24506',
|
||||
'INVENTORY_API_URL': f'jsonrpc://{gw}:24509',
|
||||
}
|
||||
for k, v in mapping.items():
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith(k + '='):
|
||||
lines[i] = f'{k}={v}'
|
||||
break
|
||||
else:
|
||||
lines.append(f'{k}={v}')
|
||||
p.write_text('\n'.join(lines))
|
||||
print('已更新 .env')
|
||||
PY
|
||||
docker compose up -d --force-recreate bridge
|
||||
echo "已重建 bridge"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "注意:网络重建/机器重启后网关 IP 可能变化,重跑本脚本即可。"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地静态测试页(widget JWT 演示)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../web"
|
||||
echo "http://192.168.31.61:${WEB_PORT:-8088}/demo.html"
|
||||
exec python3 -m http.server "${WEB_PORT:-8088}" --bind 0.0.0.0
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 把 LibreDesk 的 app.root_url 设置为公网 HTTPS 地址(WhatsApp webhook 必须)。
|
||||
* 用法:node scripts/set-public-url.js https://xxx.trycloudflare.com
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const publicURL = String(process.argv[2] || '').trim().replace(/\/+$/, '');
|
||||
if (!/^https:\/\//.test(publicURL)) {
|
||||
console.error('用法: node scripts/set-public-url.js https://<public-host>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
|
||||
const login = await fetch(base + '/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD }),
|
||||
});
|
||||
if (!login.ok) throw new Error(`login failed: HTTP ${login.status}`);
|
||||
const cookies = login.headers.getSetCookie().map((c) => c.split(';')[0]);
|
||||
const csrfCookie = cookies.find((c) => c.startsWith('csrf_token='));
|
||||
const csrf = csrfCookie.slice(csrfCookie.indexOf('=') + 1);
|
||||
|
||||
const keys = [
|
||||
'app.site_name', 'app.lang', 'app.max_file_upload_size', 'app.favicon_url', 'app.logo_url',
|
||||
'app.root_url', 'app.allowed_file_upload_extensions', 'app.timezone', 'app.business_hours_id',
|
||||
'app.show_conversation_subject',
|
||||
];
|
||||
const currentRes = await fetch(base + '/api/v1/settings/general', { headers: { Cookie: cookies.join('; ') } });
|
||||
const currentText = await currentRes.text();
|
||||
let current = {};
|
||||
try { current = JSON.parse(currentText).data || JSON.parse(currentText); } catch { /* ignore */ }
|
||||
const body = {};
|
||||
for (const k of keys) if (k in current) body[k] = current[k];
|
||||
body['app.root_url'] = publicURL;
|
||||
|
||||
const res = await fetch(base + '/api/v1/settings/general', {
|
||||
method: 'PUT',
|
||||
headers: { Cookie: cookies.join('; '), 'Content-Type': 'application/json', 'X-CSRFTOKEN': csrf },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`update failed: HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||
console.log(`root_url 已设置为: ${publicURL}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[set-public-url]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 配置 Meta App 级 WhatsApp webhook(cloudflared URL 变化后需要重跑)。
|
||||
* 用法:node scripts/set-wa-webhook.js [https://public-host]
|
||||
* 依赖 .env: WA_APP_ID / WA_APP_SECRET / WA_VERIFY_TOKEN / WA_INBOX_ID
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const publicURL = String(process.argv[2] || env.PUBLIC_BASE_URL || '').trim().replace(/\/+$/, '');
|
||||
const appId = env.WA_APP_ID;
|
||||
const appSecret = env.WA_APP_SECRET;
|
||||
const verify = env.WA_VERIFY_TOKEN;
|
||||
const inboxId = env.WA_INBOX_ID;
|
||||
const missing = ['WA_APP_ID', 'WA_APP_SECRET', 'WA_VERIFY_TOKEN', 'WA_INBOX_ID'].filter((k) => !env[k]);
|
||||
if (missing.length) throw new Error(`.env 缺少: ${missing.join(', ')}`);
|
||||
if (!/^https:\/\//.test(publicURL)) throw new Error('需要公网 HTTPS 地址');
|
||||
|
||||
const callback = `${publicURL}/webhooks/whatsapp/${inboxId}`;
|
||||
const appToken = `${appId}|${appSecret}`;
|
||||
const body = new URLSearchParams({
|
||||
object: 'whatsapp_business_account',
|
||||
callback_url: callback,
|
||||
verify_token: verify,
|
||||
fields: 'messages',
|
||||
access_token: appToken,
|
||||
});
|
||||
|
||||
const res = await fetch(`https://graph.facebook.com/v26.0/${appId}/subscriptions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`Meta webhook 配置失败: HTTP ${res.status} ${text.slice(0, 300)}`);
|
||||
console.log(`Meta WhatsApp webhook 已更新: ${callback}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[set-wa-webhook]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 建默认配置:Support 团队(可选)、AI Assistant、Live chat Inbox、Automation 规则。
|
||||
* 幂等:按名称复用已有对象。
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const NAME = {
|
||||
inbox: 'Website',
|
||||
assistant: 'Yehwang Assistant',
|
||||
rule: 'Assign new conversations to AI Assistant',
|
||||
};
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createClient(baseURL) {
|
||||
const cookies = new Map();
|
||||
let csrf = '';
|
||||
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
cookies.set(k, v);
|
||||
if (k === 'csrf_token') csrf = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function req(method, p, body) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (cookies.size) headers.Cookie = cookieHeader();
|
||||
if (body !== undefined && !(body instanceof FormData)) headers['Content-Type'] = 'application/json';
|
||||
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
|
||||
const res = await fetch(baseURL + p, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : (body instanceof FormData ? body : JSON.stringify(body)),
|
||||
});
|
||||
store(res);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 400)}`);
|
||||
return json;
|
||||
}
|
||||
return {
|
||||
get: (p) => req('GET', p),
|
||||
post: (p, b) => req('POST', p, b),
|
||||
put: (p, b) => req('PUT', p, b),
|
||||
};
|
||||
}
|
||||
|
||||
const data = (j) => (j && j.data !== undefined ? j.data : j);
|
||||
|
||||
const INSTRUCTIONS = `You are the Yehwang B2B customer assistant for stainless-steel jewelry wholesale.
|
||||
- Always reply in the customer's language.
|
||||
- For policy / FAQ questions, search the knowledge base first and answer from it.
|
||||
- For product questions, call search_products. Never invent catalog items, prices or stock.
|
||||
- For order questions, call search_orders. If it returns verification_required, ask the customer
|
||||
to verify their email with the one-time code, then retry the tool.
|
||||
- For stock / pre-order questions, call search_inventory with the SKU digits.
|
||||
- When you know company + contact name + product/need, call create_opportunity once.
|
||||
- If you cannot help, or the customer asks for a human, hand off to a human agent.
|
||||
- Never reveal another customer's data.`;
|
||||
|
||||
const GUARDRAILS = `Never invent prices, discounts, stock or delivery dates.
|
||||
Never share another customer's order or personal data.
|
||||
Never create an opportunity before the contact is verified.`;
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
const c = createClient(base);
|
||||
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
|
||||
console.log('登录成功');
|
||||
|
||||
// 1. Assistant
|
||||
const assistants = data(await c.get('/api/v1/ai/assistants')) || [];
|
||||
let assistant = assistants.find((a) => a.name === NAME.assistant);
|
||||
const tools = data(await c.get('/api/v1/ai/tools')) || [];
|
||||
const toolIds = tools.map((t) => t.id);
|
||||
if (!assistant) {
|
||||
const payload = {
|
||||
name: NAME.assistant,
|
||||
description: 'Yehwang sales & service assistant',
|
||||
instructions: INSTRUCTIONS,
|
||||
guardrails: GUARDRAILS,
|
||||
expectation: 'You are chatting with our AI assistant. I can help with products, orders and policies — and connect you to a human when needed.',
|
||||
tone: 'friendly',
|
||||
response_length: 'balanced',
|
||||
max_turns: 8,
|
||||
fallback_team_id: null,
|
||||
citations_enabled: true,
|
||||
handoff_enabled: true,
|
||||
languages: [],
|
||||
enabled: true,
|
||||
tool_ids: toolIds,
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append('data', JSON.stringify(payload));
|
||||
assistant = data(await c.post('/api/v1/ai/assistants', form));
|
||||
console.log(`创建 Assistant #${assistant.id} (user_id=${assistant.user_id}, tools=${toolIds.join(',')})`);
|
||||
} else {
|
||||
console.log(`复用 Assistant #${assistant.id} (user_id=${assistant.user_id})`);
|
||||
}
|
||||
|
||||
// 2. Live chat inbox
|
||||
const inboxPayload = {
|
||||
name: NAME.inbox,
|
||||
channel: 'livechat',
|
||||
enabled: true,
|
||||
csat_enabled: true,
|
||||
prompt_tags_on_reply: false,
|
||||
reopen_window_hours: 0,
|
||||
// 保留 widget JWT 签名用的 secret(更新时服务端会用请求值覆盖)
|
||||
...(env.LIBREDESK_INBOX_SECRET ? { secret: env.LIBREDESK_INBOX_SECRET } : {}),
|
||||
config: {
|
||||
brand_name: 'Yehwang',
|
||||
website_url: 'https://www.yehwang.com',
|
||||
language: 'en-US',
|
||||
fallback_language: 'en-US',
|
||||
theme: 'light',
|
||||
show_powered_by: false,
|
||||
direct_to_conversation: true,
|
||||
campaign_cooldown: '24h',
|
||||
session_duration: '4320h',
|
||||
launcher: { position: 'right', icon_scale: 100, spacing: { side: 24, bottom: 24 } },
|
||||
greeting_message: 'Hi! How can we help you today?',
|
||||
chat_introduction: 'Ask about products, orders, shipping or returns.',
|
||||
trusted_domains: ['192.168.31.61:8088', 'localhost:8088', '127.0.0.1:8088', '192.168.31.61', 'localhost', '127.0.0.1', 'www.yehwang.com', 'yehwang.com'],
|
||||
help: { help_center_id: 0 },
|
||||
users: { allow_start_conversation: true, prevent_multiple_conversations: false },
|
||||
visitors: { allow_start_conversation: true, prevent_multiple_conversations: false },
|
||||
branding: {
|
||||
light: {
|
||||
colors: { primary: '#1f6feb' },
|
||||
logo_url: '',
|
||||
launcher: { logo_url: '', color: '#1f6feb' },
|
||||
home_screen: { header_text_color: 'black', background: { type: 'solid', color: '#ffffff' }, fade_background: false },
|
||||
},
|
||||
dark: {
|
||||
colors: { primary: '#1f6feb' },
|
||||
logo_url: '',
|
||||
launcher: { logo_url: '', color: '#1f6feb' },
|
||||
home_screen: { header_text_color: 'white', background: { type: 'solid', color: '#111827' }, fade_background: false },
|
||||
},
|
||||
},
|
||||
features: { emoji: true, file_upload: true, transcript: true },
|
||||
},
|
||||
};
|
||||
|
||||
const inboxes = data(await c.get('/api/v1/inboxes')) || [];
|
||||
let inbox = inboxes.find((i) => i.name === NAME.inbox && i.channel === 'livechat');
|
||||
if (!inbox) {
|
||||
inbox = data(await c.post('/api/v1/inboxes', inboxPayload));
|
||||
console.log(`创建 Live chat Inbox #${inbox.id} uuid=${inbox.uuid}`);
|
||||
} else {
|
||||
inbox = data(await c.put(`/api/v1/inboxes/${inbox.id}`, inboxPayload));
|
||||
console.log(`更新 Live chat Inbox #${inbox.id} uuid=${inbox.uuid}`);
|
||||
}
|
||||
|
||||
// 3. Automation: new conversation in this inbox -> assign assistant user
|
||||
const rulesRaw = data(await c.get('/api/v1/automations/rules?type=new_conversation')) || [];
|
||||
const rules = Array.isArray(rulesRaw) ? rulesRaw : (rulesRaw.rules || []);
|
||||
const rulePayload = {
|
||||
name: NAME.rule,
|
||||
description: 'Route new live chat conversations to the AI assistant',
|
||||
type: 'new_conversation',
|
||||
events: [],
|
||||
enabled: true,
|
||||
weight: 0,
|
||||
// 注意:rules 必须是数组(引擎按 []Rule 反序列化)
|
||||
rules: [{
|
||||
type: 'new_conversation',
|
||||
execution_mode: 'all',
|
||||
event: [],
|
||||
group_operator: 'AND',
|
||||
groups: [{
|
||||
logical_op: 'AND',
|
||||
rules: [{
|
||||
field: 'inbox',
|
||||
field_type: 'conversation',
|
||||
operator: 'equals',
|
||||
value: String(inbox.id),
|
||||
case_sensitive_match: false,
|
||||
}],
|
||||
}],
|
||||
actions: [{
|
||||
type: 'assign_user',
|
||||
value: [String(assistant.user_id)],
|
||||
display_value: [assistant.name],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let rule = rules.find((r) => r.name === NAME.rule);
|
||||
if (!rule) {
|
||||
rule = data(await c.post('/api/v1/automations/rules', rulePayload));
|
||||
console.log(`创建 Automation #${rule.id} -> assign user ${assistant.user_id}`);
|
||||
} else {
|
||||
rule = data(await c.put(`/api/v1/automations/rules/${rule.id}`, rulePayload));
|
||||
console.log(`更新 Automation #${rule.id} -> assign user ${assistant.user_id}`);
|
||||
}
|
||||
|
||||
const snippet = `<script>
|
||||
window.LibredeskSettings = {
|
||||
baseURL: '${base}',
|
||||
inboxID: '${inbox.uuid}'
|
||||
};
|
||||
</script>
|
||||
<script async src="${base}/widget.js"></script>`;
|
||||
|
||||
console.log('\n===== Widget 安装代码 =====');
|
||||
console.log(snippet);
|
||||
console.log('\n(用户登录后可用 userJWT 绑定身份,见 docs/libredesk-setup.md 第 6 节)');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[setup-defaults]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# crm-new 冒烟:三组件健康 + bridge 鉴权/验证门控
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
set -a; . ./.env; set +a
|
||||
|
||||
say() { printf '\n== %s ==\n' "$1"; }
|
||||
code() { curl -s -o /tmp/crmnew-smoke.out -w '%{http_code}' "$@" || true; }
|
||||
|
||||
say "LibreDesk /health"
|
||||
echo "HTTP $(code http://localhost:9000/health) $(head -c 120 /tmp/crmnew-smoke.out 2>/dev/null)"
|
||||
|
||||
say "Twenty /healthz"
|
||||
echo "HTTP $(code http://localhost:3100/healthz)"
|
||||
|
||||
say "bridge /healthz"
|
||||
echo "HTTP $(code http://localhost:4100/healthz) $(head -c 200 /tmp/crmnew-smoke.out 2>/dev/null)"
|
||||
|
||||
say "bridge manifest"
|
||||
echo "HTTP $(code -H "X-Tools-Key: ${TOOLS_SHARED_KEY}" http://localhost:4100/tools/manifest)"
|
||||
python3 -c "import json;d=json.load(open('/tmp/crmnew-smoke.out'));print('tools:',[t['name'] for t in d['tools']])" 2>/dev/null || true
|
||||
|
||||
say "search_products(未验证,应 200)"
|
||||
echo "HTTP $(code -X POST http://localhost:4100/tools/search_products \
|
||||
-H "X-Tools-Key: ${TOOLS_SHARED_KEY}" -H 'Content-Type: application/json' \
|
||||
-d '{"query":"gold hoop earrings"}')"
|
||||
|
||||
say "search_orders(未验证,应 403)"
|
||||
echo "HTTP $(code -X POST http://localhost:4100/tools/search_orders \
|
||||
-H "X-Tools-Key: ${TOOLS_SHARED_KEY}" -H 'Content-Type: application/json' -d '{}')"
|
||||
|
||||
rm -f /tmp/crmnew-smoke.out
|
||||
echo
|
||||
echo "冒烟完成。"
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 在 Twenty 中创建初始用户 + Workspace,并直接在该库里生成 API key(不经过 UI)。
|
||||
*
|
||||
* 思路类比旧项目 gen_apikey.js:
|
||||
* - 不用浏览器注册,脚本调 GraphQL signUp / signUpInNewWorkspace 建好 workspace
|
||||
* - 再用 Twenty 自带命令 `workspace:generate-api-key` 在库内创建 apiKey + roleTarget,并签出 JWT
|
||||
* - 结果写回 .env(TWENTY_WORKSPACE_ID / TWENTY_API_KEY),重建 bridge
|
||||
*
|
||||
* 用法:node scripts/twenty-bootstrap.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const CONTAINER = 'crmnew-twenty-server';
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setEnv(file, kv) {
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||
for (const [k, v] of Object.entries(kv)) {
|
||||
const idx = lines.findIndex((l) => l.startsWith(`${k}=`));
|
||||
if (idx >= 0) lines[idx] = `${k}=${v}`;
|
||||
else lines.push(`${k}=${v}`);
|
||||
}
|
||||
fs.writeFileSync(file, lines.join('\n'), { mode: 0o600 });
|
||||
}
|
||||
|
||||
async function gql(base, query, variables, token) {
|
||||
// 认证类 mutation 在 core(/metadata)端点,不在 workspace 数据端点 /graphql
|
||||
const res = await fetch(`${base}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.errors && json.errors.length) {
|
||||
throw new Error(`GraphQL error: ${JSON.stringify(json.errors).slice(0, 400)}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
function psql(sql) {
|
||||
return execFileSync('docker', ['exec', 'crmnew-postgres', 'psql', '-U', 'crmnew', '-d', 'twenty', '-tAc', sql], {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.TWENTY_SERVER_URL || 'http://192.168.31.61:3100').replace(/\/+$/, '');
|
||||
const email = env.TWENTY_ADMIN_EMAIL;
|
||||
const password = env.TWENTY_ADMIN_PASSWORD;
|
||||
if (!email || !password) throw new Error('缺少 TWENTY_ADMIN_EMAIL / TWENTY_ADMIN_PASSWORD(写入 .env,不要硬编码)');
|
||||
|
||||
// 已存在 workspace 就跳过创建
|
||||
let workspaceId = env.TWENTY_WORKSPACE_ID || '';
|
||||
if (workspaceId) {
|
||||
const exists = psql(`select count(*) from core.workspace where id='${workspaceId}';`);
|
||||
if (exists === '1') console.log(`复用已有 workspace ${workspaceId}`);
|
||||
else workspaceId = '';
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
console.log('1) signUp…');
|
||||
const signUp = await gql(base, `
|
||||
mutation($email: String!, $password: String!) {
|
||||
signUp(email: $email, password: $password) {
|
||||
tokens { accessOrWorkspaceAgnosticToken { token } }
|
||||
}
|
||||
}
|
||||
`, { email, password });
|
||||
const agnostic = signUp.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
|
||||
|
||||
console.log('2) signUpInNewWorkspace…');
|
||||
const created = await gql(base, `
|
||||
mutation($input: SignUpInNewWorkspaceInput) {
|
||||
signUpInNewWorkspace(input: $input) {
|
||||
loginToken { token }
|
||||
workspace { id }
|
||||
}
|
||||
}
|
||||
`, { input: { displayName: 'Yehwang', subdomain: 'yehwang' } }, agnostic);
|
||||
workspaceId = created.signUpInNewWorkspace.workspace.id;
|
||||
const loginToken = created.signUpInNewWorkspace.loginToken.token;
|
||||
const workspaceOrigin = created.signUpInNewWorkspace.workspace.workspaceUrls?.subdomainUrl || base;
|
||||
console.log(` workspace: ${workspaceId}`);
|
||||
|
||||
// 自托管环境没有真实邮件服务:直接标记邮箱已验证,才能换取 workspace token
|
||||
psql(`update core."user" set "isEmailVerified"=true where email='${email}';`);
|
||||
|
||||
console.log('3) 交换 workspace token…');
|
||||
const exchanged = await gql(base, `
|
||||
mutation($loginToken: String!, $origin: String!) {
|
||||
getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) {
|
||||
tokens { accessOrWorkspaceAgnosticToken { token } }
|
||||
}
|
||||
}
|
||||
`, { loginToken, origin: base });
|
||||
const accessToken = exchanged.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token;
|
||||
|
||||
console.log('4) 激活 workspace(建 schema / 默认角色)…');
|
||||
await gql(base, `
|
||||
mutation($data: ActivateWorkspaceInput!) {
|
||||
activateWorkspace(data: $data) { id }
|
||||
}
|
||||
`, { data: { displayName: 'Yehwang' } }, accessToken);
|
||||
|
||||
setEnv(path.join(ROOT, '.env'), {
|
||||
TWENTY_WORKSPACE_ID: workspaceId,
|
||||
TWENTY_ADMIN_EMAIL: email,
|
||||
TWENTY_ADMIN_PASSWORD: password,
|
||||
});
|
||||
}
|
||||
|
||||
// 等 workspace 初始化完成(PENDING_CREATION -> ACTIVE/CREATED)
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
const status = psql(`select "activationStatus" from core.workspace where id='${workspaceId}';`);
|
||||
if (status === 'ACTIVE' || status === 'CREATED') {
|
||||
console.log(` workspace 状态: ${status}`);
|
||||
break;
|
||||
}
|
||||
if (i === 59) throw new Error(`workspace 未就绪,状态=${status}`);
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
// 3) 在库内生成 API key(Twenty 官方命令,dev/test 环境限制)
|
||||
console.log('5) 生成 API key(workspace:generate-api-key)…');
|
||||
const out = execFileSync('docker', [
|
||||
'exec', '-e', 'NODE_ENV=development', CONTAINER, 'sh', '-lc',
|
||||
`cd /app/packages/twenty-server && yarn command:prod workspace:generate-api-key -w ${workspaceId} -n crm-new`,
|
||||
], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
|
||||
const m = out.match(/TOKEN:(\S+)/);
|
||||
if (!m) throw new Error(`未解析到 TOKEN。输出尾部:\n${out.slice(-1200)}`);
|
||||
const apiKey = m[1];
|
||||
|
||||
setEnv(path.join(ROOT, '.env'), { TWENTY_API_KEY: apiKey });
|
||||
console.log(` API key 已写入 .env(${apiKey.slice(0, 24)}…)`);
|
||||
|
||||
// 4) 重建 bridge
|
||||
console.log('6) 重建 bridge…');
|
||||
execFileSync('docker', ['compose', 'up', '-d', '--force-recreate', 'bridge'], { cwd: ROOT, stdio: 'inherit' });
|
||||
await sleep(3000);
|
||||
|
||||
// 5) 验证
|
||||
console.log('7) 验证 API key…');
|
||||
const check = await fetch(`${base}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({ query: '{ companies(first: 1) { edges { node { id } } } }' }),
|
||||
});
|
||||
const checkJson = await check.json();
|
||||
if (!check.ok || checkJson.errors) throw new Error(`API key 验证失败:${JSON.stringify(checkJson).slice(0, 400)}`);
|
||||
console.log(' ✓ API key 可用(companies 查询成功)');
|
||||
console.log('\n完成。TWENTY_WORKSPACE_ID / TWENTY_API_KEY 已写入 .env');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[twenty-bootstrap]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 给旧 bridge 的 products.kb_docs 补 Hybrid RAG 所需字段并回填全文索引。
|
||||
// 幂等;不改动已有数据,只新增列/索引。
|
||||
const { Client } = require('../bridge/node_modules/pg');
|
||||
|
||||
const PG_URL = process.env.KB_OLD_PG_URL || 'postgres://postgres:postgres@127.0.0.1:5432/products';
|
||||
|
||||
async function main() {
|
||||
const client = new Client({ connectionString: PG_URL });
|
||||
await client.connect();
|
||||
await client.query('ALTER TABLE kb_docs ADD COLUMN IF NOT EXISTS parent_id INTEGER');
|
||||
await client.query("ALTER TABLE kb_docs ADD COLUMN IF NOT EXISTS chunk_type TEXT DEFAULT 'parent'");
|
||||
await client.query('ALTER TABLE kb_docs ADD COLUMN IF NOT EXISTS keywords_text TEXT');
|
||||
await client.query('ALTER TABLE kb_docs ADD COLUMN IF NOT EXISTS text_tsv tsvector');
|
||||
await client.query(`
|
||||
UPDATE kb_docs
|
||||
SET text_tsv = to_tsvector('english',
|
||||
coalesce(question, '') || ' ' || coalesce(answer, '') || ' ' ||
|
||||
coalesce(array_to_string(keywords, ' '), '') || ' ' ||
|
||||
coalesce(title, '') || ' ' || coalesce(section, ''))
|
||||
WHERE text_tsv IS NULL
|
||||
`);
|
||||
await client.query('CREATE INDEX IF NOT EXISTS kb_docs_tsv_idx ON kb_docs USING GIN (text_tsv)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS kb_docs_parent_idx ON kb_docs (parent_id) WHERE parent_id IS NOT NULL');
|
||||
const r = await client.query("SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE text_tsv IS NOT NULL)::int AS indexed FROM kb_docs");
|
||||
console.log(JSON.stringify(r.rows[0]));
|
||||
await client.end();
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('[upgrade-kb-schema]', err.message); process.exit(1); });
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 验证 RAG + 引用:创建临时 AI Assistant → 调 preview API 问一个问题 → 打印命中来源 → 删除。
|
||||
* 用法:node scripts/verify-rag.js "What is the minimum order value?"
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const question = process.argv.slice(2).join(' ') || 'What is the minimum order value?';
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#')) continue;
|
||||
const eq = t.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
let v = t.slice(eq + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
out[t.slice(0, eq).trim()] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createClient(baseURL) {
|
||||
const cookies = new Map();
|
||||
let csrf = '';
|
||||
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
cookies.set(k, v);
|
||||
if (k === 'csrf_token') csrf = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function req(method, p, body) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (cookies.size) headers.Cookie = cookieHeader();
|
||||
if (body !== undefined && !(body instanceof FormData)) headers['Content-Type'] = 'application/json';
|
||||
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
|
||||
const res = await fetch(baseURL + p, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : (body instanceof FormData ? body : JSON.stringify(body)),
|
||||
});
|
||||
store(res);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 400)}`);
|
||||
return json;
|
||||
}
|
||||
return { get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), del: (p) => req('DELETE', p) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
|
||||
const c = createClient(base);
|
||||
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
|
||||
|
||||
const data = {
|
||||
name: `KB Verify ${Date.now()}`,
|
||||
description: 'temporary assistant for RAG verification',
|
||||
instructions: 'Answer from the knowledge base. If the knowledge base has no answer, say you could not find it.',
|
||||
guardrails: '',
|
||||
expectation: '',
|
||||
tone: 'professional',
|
||||
response_length: 'balanced',
|
||||
max_turns: 5,
|
||||
fallback_team_id: null,
|
||||
citations_enabled: true,
|
||||
handoff_enabled: false,
|
||||
languages: [],
|
||||
enabled: true,
|
||||
tool_ids: [],
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append('data', JSON.stringify(data));
|
||||
const created = await c.post('/api/v1/ai/assistants', form);
|
||||
const assistant = created.data;
|
||||
console.log(`临时 Assistant #${assistant.id} 创建成功`);
|
||||
|
||||
try {
|
||||
const preview = await c.post(`/api/v1/ai/assistants/${assistant.id}/preview`, { message: question });
|
||||
const out = preview.data || {};
|
||||
console.log(`\n问题: ${question}`);
|
||||
console.log(`回复:\n${out.reply}\n`);
|
||||
console.log('命中来源:');
|
||||
for (const s of out.sources || []) {
|
||||
console.log(` - [${s.type}] #${s.id} score=${(s.score || 0).toFixed(3)} ${s.title || ''}${s.url ? ' ' + s.url : ''}`);
|
||||
}
|
||||
if (!(out.sources || []).length) console.log(' (无命中)');
|
||||
} finally {
|
||||
await c.del(`/api/v1/ai/assistants/${assistant.id}`);
|
||||
console.log(`\n临时 Assistant #${assistant.id} 已删除`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[verify-rag]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user