feat: release contextual omnichannel customer platform
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
This commit is contained in:
Generated
+7
@@ -14,6 +14,7 @@
|
||||
"helmet": "^8.1.0",
|
||||
"imapflow": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"libphonenumber-js": "^1.11.20",
|
||||
"mailparser": "^3.9.14",
|
||||
"mammoth": "^1.9.0",
|
||||
"nodemailer": "^7.0.5",
|
||||
@@ -891,6 +892,12 @@
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.11.20.tgz",
|
||||
"integrity": "sha512-/ipwAMvtSZRdiQBHqW1qxqeYiBMzncOQLVA+62MWYr7N4m7Q2jqpJ0WgT7zlOEOpyLRSqrMXidbJpC0J77AaKA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"helmet": "^8.1.0",
|
||||
"imapflow": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"libphonenumber-js": "^1.11.20",
|
||||
"mailparser": "^3.9.14",
|
||||
"mammoth": "^1.9.0",
|
||||
"nodemailer": "^7.0.5",
|
||||
|
||||
+21
-6
@@ -7,7 +7,11 @@ function parseJson(text) {
|
||||
return JSON.parse(cleaned)
|
||||
}
|
||||
|
||||
export async function deepseek(system, user, json = false) {
|
||||
export async function deepseek(system, user, jsonOrOptions = false) {
|
||||
const options = typeof jsonOrOptions === 'boolean'
|
||||
? { json: jsonOrOptions }
|
||||
: { ...(jsonOrOptions || {}) }
|
||||
const json = Boolean(options.json)
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
|
||||
const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
|
||||
@@ -17,7 +21,10 @@ export async function deepseek(system, user, json = false) {
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
Number(options.timeoutMs || process.env.AI_TIMEOUT_MS || 30000),
|
||||
)
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
@@ -29,8 +36,11 @@ export async function deepseek(system, user, json = false) {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
||||
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
|
||||
temperature: json ? 0 : 0.3,
|
||||
max_tokens: Number(options.maxTokens || process.env.AI_MAX_TOKENS || 4096),
|
||||
temperature: options.temperature ?? (json ? 0 : 0.3),
|
||||
thinking: options.thinking === true
|
||||
? { type: 'enabled' }
|
||||
: { type: 'disabled' },
|
||||
system,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||
}),
|
||||
@@ -47,6 +57,11 @@ export async function deepseek(system, user, json = false) {
|
||||
lastError = error
|
||||
prompt = `${user}\n\n上一次响应不是合法 JSON。请重新生成完整结果:只允许输出一个可被 JSON.parse 直接解析的 JSON 值;不得使用 Markdown;字符串中的双引号、换行和反斜杠必须正确转义。`
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw Object.assign(new Error('AI 响应超时,请稍后重试'), { status: 504 })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
@@ -54,8 +69,8 @@ export async function deepseek(system, user, json = false) {
|
||||
throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError })
|
||||
}
|
||||
|
||||
export function conversationContext(messages) {
|
||||
return messages.slice(-80).map((m) => {
|
||||
export function conversationContext(messages, limit = 30) {
|
||||
return messages.slice(-limit).map((m) => {
|
||||
const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户'
|
||||
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
|
||||
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : ''
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { parsePhoneNumberFromString } from 'libphonenumber-js'
|
||||
|
||||
const VALID_SOURCE = new Set(['manual', 'account-profile', 'phone-prefix', 'unknown'])
|
||||
|
||||
export function normalizeCountryCode(value) {
|
||||
const code = String(value || '').trim().toUpperCase()
|
||||
return /^[A-Z]{2}$/.test(code) ? code : ''
|
||||
}
|
||||
|
||||
export function inferCountryFromPhone(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
const normalized = raw.startsWith('+') ? raw : `+${raw.replace(/[^\d]/g, '')}`
|
||||
try {
|
||||
return normalizeCountryCode(parsePhoneNumberFromString(normalized)?.country)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCustomerCountry(input = {}, existing = {}, options = {}) {
|
||||
const manualCountry = normalizeCountryCode(input.country)
|
||||
if (options.manual === true) {
|
||||
return {
|
||||
country: manualCountry,
|
||||
countrySource: manualCountry ? 'manual' : 'unknown',
|
||||
countryConfidence: manualCountry ? 1 : 0,
|
||||
countryInferredAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const existingSource = VALID_SOURCE.has(existing.countrySource)
|
||||
? existing.countrySource
|
||||
: 'unknown'
|
||||
if (existingSource === 'manual' && normalizeCountryCode(existing.country)) {
|
||||
return {
|
||||
country: normalizeCountryCode(existing.country),
|
||||
countrySource: 'manual',
|
||||
countryConfidence: 1,
|
||||
countryInferredAt: existing.countryInferredAt || existing.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
const accountCountry = normalizeCountryCode(
|
||||
input.accountCountry || input.countryCode || input.profileCountry,
|
||||
)
|
||||
if (accountCountry) {
|
||||
return {
|
||||
country: accountCountry,
|
||||
countrySource: 'account-profile',
|
||||
countryConfidence: 0.98,
|
||||
countryInferredAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const phoneCountry = inferCountryFromPhone(input.phone || existing.phone)
|
||||
if (phoneCountry) {
|
||||
return {
|
||||
country: phoneCountry,
|
||||
countrySource: 'phone-prefix',
|
||||
countryConfidence: 0.92,
|
||||
countryInferredAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const preserved = normalizeCountryCode(input.country || existing.country)
|
||||
const preservedSource = VALID_SOURCE.has(input.countrySource)
|
||||
? input.countrySource
|
||||
: existingSource
|
||||
if (preserved && preservedSource !== 'unknown') {
|
||||
return {
|
||||
country: preserved,
|
||||
countrySource: preservedSource,
|
||||
countryConfidence: Number(input.countryConfidence ?? existing.countryConfidence ?? 0.7),
|
||||
countryInferredAt: input.countryInferredAt || existing.countryInferredAt,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
country: '',
|
||||
countrySource: 'unknown',
|
||||
countryConfidence: 0,
|
||||
countryInferredAt: null,
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -90,8 +90,11 @@ export async function deleteResource(tenantId, kind, id) {
|
||||
}
|
||||
|
||||
export async function audit(req, action, target, detail = {}) {
|
||||
// pg 会把顶层 JavaScript 数组编码成 PostgreSQL 数组文本(如 {ai}),
|
||||
// jsonb 无法解析该格式。显式 JSON 序列化可同时兼容对象、数组和标量。
|
||||
const serializedDetail = JSON.stringify(detail ?? {})
|
||||
await pool.query(
|
||||
'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)',
|
||||
[uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, detail, req.ip],
|
||||
[uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, serializedDetail, req.ip],
|
||||
)
|
||||
}
|
||||
|
||||
+866
-73
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
function normalize(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function knowledgeTokens(value) {
|
||||
const text = normalize(value)
|
||||
const tokens = new Set(text.match(/[a-z0-9][a-z0-9._+-]{1,}/g) || [])
|
||||
const chineseRuns = text.match(/[\u3400-\u9fff]+/g) || []
|
||||
for (const run of chineseRuns) {
|
||||
if (run.length === 1) tokens.add(run)
|
||||
for (let size = 2; size <= 3; size += 1) {
|
||||
for (let index = 0; index <= run.length - size; index += 1) {
|
||||
tokens.add(run.slice(index, index + size))
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...tokens].slice(0, 300)
|
||||
}
|
||||
|
||||
export function chunkKnowledge(content, maximum = 900, overlap = 120) {
|
||||
const text = String(content || '').replace(/\r\n/g, '\n').trim()
|
||||
if (!text) return []
|
||||
const paragraphs = text.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean)
|
||||
const chunks = []
|
||||
let current = ''
|
||||
const flush = () => {
|
||||
if (!current.trim()) return
|
||||
chunks.push(current.trim())
|
||||
current = current.slice(-overlap)
|
||||
}
|
||||
for (const paragraph of paragraphs.length ? paragraphs : [text]) {
|
||||
if (paragraph.length > maximum) {
|
||||
if (current) flush()
|
||||
for (let start = 0; start < paragraph.length; start += maximum - overlap) {
|
||||
chunks.push(paragraph.slice(start, start + maximum).trim())
|
||||
}
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
if (current && current.length + paragraph.length + 2 > maximum) flush()
|
||||
current = current ? `${current}\n\n${paragraph}` : paragraph
|
||||
}
|
||||
if (current.trim()) chunks.push(current.trim())
|
||||
return [...new Set(chunks)].filter(Boolean)
|
||||
}
|
||||
|
||||
function scoreChunk(item, chunk, queryTokens, normalizedQuery) {
|
||||
const chunkText = normalize(chunk)
|
||||
const titleText = normalize(`${item.title || ''} ${(item.tags || []).join(' ')}`)
|
||||
let matched = 0
|
||||
let weighted = 0
|
||||
for (const token of queryTokens) {
|
||||
if (chunkText.includes(token)) {
|
||||
matched += 1
|
||||
weighted += token.length >= 3 ? 1.35 : 1
|
||||
}
|
||||
if (titleText.includes(token)) weighted += 1.8
|
||||
}
|
||||
if (normalizedQuery.length >= 3 && chunkText.includes(normalizedQuery)) weighted += 4
|
||||
const coverage = queryTokens.length ? matched / queryTokens.length : 0
|
||||
return weighted + coverage * 3
|
||||
}
|
||||
|
||||
export function rankKnowledge(items, query, limit = 5) {
|
||||
const normalizedQuery = normalize(query)
|
||||
const queryTokens = knowledgeTokens(normalizedQuery)
|
||||
if (!normalizedQuery || !queryTokens.length) return []
|
||||
const candidates = []
|
||||
for (const item of items) {
|
||||
if (item.enabled === false || item.status === 'failed') continue
|
||||
const chunks = chunkKnowledge(item.content)
|
||||
chunks.forEach((chunk, index) => {
|
||||
const rawScore = scoreChunk(item, chunk, queryTokens, normalizedQuery)
|
||||
if (rawScore <= 0) return
|
||||
candidates.push({
|
||||
item,
|
||||
chunkIndex: index,
|
||||
snippet: chunk.slice(0, 560),
|
||||
rawScore,
|
||||
})
|
||||
})
|
||||
}
|
||||
candidates.sort((a, b) => b.rawScore - a.rawScore)
|
||||
const bestByItem = new Map()
|
||||
for (const candidate of candidates) {
|
||||
if (!bestByItem.has(candidate.item.id)) bestByItem.set(candidate.item.id, candidate)
|
||||
}
|
||||
const selected = [...bestByItem.values()].slice(0, Math.max(1, limit))
|
||||
const maximum = selected[0]?.rawScore || 1
|
||||
return selected.map((candidate) => ({
|
||||
...candidate,
|
||||
score: Math.max(0.01, Math.min(1, candidate.rawScore / maximum)),
|
||||
}))
|
||||
}
|
||||
|
||||
export function knowledgeMetadata(content) {
|
||||
const chunks = chunkKnowledge(content)
|
||||
return {
|
||||
characterCount: String(content || '').length,
|
||||
chunkCount: chunks.length,
|
||||
indexedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user