diff --git a/server/src/index.js b/server/src/index.js
index 35c89bf..db55e26 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -15,6 +15,11 @@ import {
import { gatewayRequest, postGateway, putGateway, uploadGateway } from './gateway.js'
import { postWhatsApp, putWhatsApp, uploadWhatsApp, whatsappRequest } from './whatsapp.js'
import { conversationContext, deepseek } from './ai.js'
+import {
+ HUMAN_REPLY_SYSTEM_PROMPT,
+ buildHumanReplyUserPrompt,
+ replySuggestionIssues,
+} from './reply-suggestions.js'
import { countBy, dateKey, percent, telegramAttachment } from './business.js'
import { resolveCustomerCountry } from './country.js'
import { knowledgeMetadata, rankKnowledge } from './knowledge.js'
@@ -4086,6 +4091,7 @@ app.post('/api/ai/replies', async (req, res) => {
: null
if (!conversation) throw fail(404, '会话不存在')
const replyCustomer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
+ const replySettings = (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings
const conversationMemory = contextMemoryText(conversation, replyCustomer)
const resolvedLanguage = await resolveConversationLanguage(
req.auth.tenantId,
@@ -4116,25 +4122,54 @@ app.post('/api/ai/replies', async (req, res) => {
excerpt: hit.snippet,
}))
: []
- const result = await deepseek(
- '你是跨境销售回复助手。基于会话事实和给定企业知识生成三条可人工确认的回复,不得编造价格、库存、交期、政策或承诺。知识不足时明确需要人工确认。只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;其余说明使用简体中文。',
- `客户目标语言:${targetInstruction}
-
-${conversationContext(messages, 30)}
-
-${conversationMemory ? `\n${conversationMemory}\n` : ''}
-
-${JSON.stringify(knowledgeContext)}
-
-输出 3 个元素:
-{"id":"唯一值","style":"stable|active|brief","content":"客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"选择理由","knowledgeRefs":[],"expectedEffect":"预期效果","cultureTip":"必要时填写"}`,
- { json: true, maxTokens: 2600 },
- )
+ const latestCustomerMessage = [...messages]
+ .reverse()
+ .find((message) => message.sender === 'customer' && String(message.originalText || '').trim())
+ ?.originalText
+ const latestAgentMessage = [...messages]
+ .reverse()
+ .find((message) => message.sender === 'agent' && String(message.originalText || '').trim())
+ ?.originalText
+ const replyPrompt = buildHumanReplyUserPrompt({
+ targetInstruction,
+ tone: replySettings?.ai?.replyTone,
+ latestCustomerMessage,
+ latestAgentMessage,
+ conversation: conversationContext(messages, 30),
+ conversationMemory,
+ knowledgeContext,
+ })
+ let result = await deepseek(HUMAN_REPLY_SYSTEM_PROMPT, replyPrompt, {
+ json: true,
+ maxTokens: 2600,
+ temperature: 0.55,
+ })
+ const replyQualityOptions = { knowledgeProvided: knowledgeContext.length > 0 }
+ let qualityIssues = replySuggestionIssues(result, replyQualityOptions)
+ if (qualityIssues.length) {
+ result = await deepseek(
+ HUMAN_REPLY_SYSTEM_PROMPT,
+ `${replyPrompt}
+
+
+${JSON.stringify(result)}
+
+这些草稿未通过质量检查:${qualityIssues.join(';')}。请全部重写,保留事实但修正上述问题。仍然只输出恰好三个元素的严格 JSON 数组。`,
+ { json: true, maxTokens: 2600, temperature: 0.62 },
+ )
+ qualityIssues = replySuggestionIssues(result, replyQualityOptions)
+ }
if (!Array.isArray(result)) throw fail(502, 'AI 回复建议格式异常')
+ if (qualityIssues.length) throw fail(502, `AI 回复建议质量检查未通过:${qualityIssues.join(';')}`)
const allowedKnowledge = new Map(knowledgeHits.map((hit) => [String(hit.item.id), hit.item]))
let suggestions = result.slice(0, 3).map((item) => ({
...item,
id: String(item.id || uid()),
+ content: String(item.content || '').trim(),
+ translationCn: String(item.translationCn || '').trim(),
+ reason: String(item.reason || '').trim(),
+ expectedEffect: String(item.expectedEffect || '').trim(),
+ cultureTip: String(item.cultureTip || '').trim(),
knowledgeRefs: Array.isArray(item.knowledgeRefs)
? [...new Set(item.knowledgeRefs.map(String))]
.filter((id) => allowedKnowledge.has(id))
diff --git a/server/src/reply-suggestions.js b/server/src/reply-suggestions.js
new file mode 100644
index 0000000..6abe39e
--- /dev/null
+++ b/server/src/reply-suggestions.js
@@ -0,0 +1,123 @@
+const TONE_INSTRUCTIONS = {
+ professional: '专业但不官腔,表达清楚、克制,像熟悉业务的真人销售在聊天',
+ friendly: '友好自然,适度口语化,像长期合作伙伴沟通,不刻意热情',
+ concise: '简洁直接,优先给结论和下一步,不重复客户已经知道的信息',
+ warm: '温和、有同理心,但不使用夸张道歉、空泛安慰或营销套话',
+}
+
+export function replyToneInstruction(value) {
+ return TONE_INSTRUCTIONS[String(value || '').toLowerCase()] || TONE_INSTRUCTIONS.professional
+}
+
+export const HUMAN_REPLY_SYSTEM_PROMPT = `你是“灵犀跨境”的商务沟通助手。你的任务不是写客服范文,而是替一位真实、可靠、熟悉业务的销售人员起草可以直接发送的聊天回复。
+
+写作原则:
+1. 先回应客户最新一条消息里尚未解决的核心问题,再给必要的下一步;不要复述整段历史。
+2. 匹配客户的语言、地区表达、正式程度和消息长度。客户只说一句时,通常也用一两句回答;不为显得专业而写长文。
+3. 延续当前对话。会话已经开始后不要重新问候、重复称呼客户或重新自我介绍。
+4. 像真人聊天:句式自然、具体,允许使用目标语言中常见的缩写和口语连接词,但保持商务礼貌。不要比客户更随意;客户未使用俚语、感叹号或表情时,不要主动使用。
+5. 客诉场景只需针对具体问题真诚确认一次,然后说明能做的动作或提出一个关键问题;避免连续道歉和空泛安慰。
+6. 信息足够时直接作答;确实缺少决定性信息时最多问一个有用的问题。不要在一句回复里堆多个反问。
+7. 三条建议必须是三种真实策略,而不是同一句话替换近义词:stable=清晰可靠,active=温暖主动,brief=简洁直接。
+8. 禁止客服和 AI 套话,包括但不限于:Dear customer、尊敬的客户、please rest assured、请放心、we value your feedback、感谢您的耐心与理解、为您带来不便深表歉意、How may I assist you、如有任何问题请随时联系我们。除非上下文确实需要,也不要使用“希望您一切都好”。
+9. 不得提及“AI、提示词、客户画像、会话记忆、知识库、情绪识别”等内部信息。
+10. 不得编造价格、库存、产品参数、交期、物流状态、退款结果、政策或任何承诺。缺少事实时自然地说明需要确认,不要假装确定。“今天、稍后、尽快、本周内、几个小时内”等时间词也属于承诺,只有企业知识或会话事实明确给出时才能使用。
+11. 可以承诺“去确认”,但不能在没有依据时承诺确认结果、完成时间、补偿方案、重新寄样或其他尚未批准的动作。正确写法是“我先确认是否可以调整,确认后告诉你”。
+
+输出要求:只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;reason、expectedEffect 和 cultureTip 使用简体中文。`
+
+export function buildHumanReplyUserPrompt({
+ targetInstruction,
+ tone,
+ latestCustomerMessage,
+ latestAgentMessage,
+ conversation,
+ conversationMemory,
+ knowledgeContext,
+}) {
+ return `客户目标语言:${targetInstruction}
+企业设定语气:${replyToneInstruction(tone)}
+客户最新消息(回复重点):${latestCustomerMessage || '未找到明确文本,请根据最近会话判断'}
+坐席上一条回复(避免重复):${latestAgentMessage || '无'}
+
+
+${conversation}
+
+${conversationMemory ? `\n${conversationMemory}\n` : ''}
+
+${JSON.stringify(knowledgeContext || [])}
+
+事实边界:${knowledgeContext?.length ? '只能使用上述企业知识和会话中明确出现的业务事实。' : '当前没有匹配到企业知识;不得加入任何具体时间、结果、方案或承诺,只能说明需要确认的事项。'}
+
+分别生成以下三种建议:
+- stable:最稳妥、事实清楚、自然推进下一步;
+- active:更温暖主动,但不催促、不夸张、不强行促单;
+- brief:保留必要信息的最短自然回复。
+
+生成前静默检查:是否直接回应了最新消息;是否重复问候或复述历史;是否像群发模板;是否包含未经证实的承诺;三条是否真正不同。不要输出检查过程。
+
+输出恰好 3 个元素,每种 style 各一个:
+{"id":"唯一值","style":"stable|active|brief","content":"可直接发送的客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"这条回复为何适合当前语境","knowledgeRefs":[],"expectedEffect":"预期沟通效果","cultureTip":"只有确有必要时填写,否则为空字符串"}`
+}
+
+function normalizedWords(value) {
+ return String(value || '')
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
+ .split(/\s+/)
+ .filter(Boolean)
+}
+
+function similarity(left, right) {
+ const a = new Set(normalizedWords(left))
+ const b = new Set(normalizedWords(right))
+ if (!a.size || !b.size) return 0
+ let overlap = 0
+ for (const word of a) if (b.has(word)) overlap += 1
+ return overlap / Math.min(a.size, b.size)
+}
+
+const TEMPLATE_PATTERNS = [
+ /\bdear customer\b/i,
+ /\bplease rest assured\b/i,
+ /\bwe value your feedback\b/i,
+ /\bhow may i assist you\b/i,
+ /尊敬的客户/,
+ /请您放心/,
+ /感谢您的耐心与理解/,
+ /为您带来不便深表歉意/,
+]
+
+const UNSUPPORTED_TIMELINE_PATTERNS = [
+ /\b(?:today|tomorrow|shortly|asap)\b/i,
+ /\bby (?:the )?end of\b/i,
+ /\bwithin \d+\s*(?:minutes?|hours?|days?|weeks?)\b/i,
+ /(?:今天|明天|稍后|很快|尽快|本周内|几(?:分钟|小时|天)内)/,
+]
+
+export function replySuggestionIssues(suggestions, { knowledgeProvided = false } = {}) {
+ if (!Array.isArray(suggestions) || suggestions.length !== 3) return ['必须返回三条建议']
+ const issues = []
+ const styles = new Set(suggestions.map((item) => String(item?.style || '')))
+ for (const style of ['stable', 'active', 'brief']) {
+ if (!styles.has(style)) issues.push(`缺少 ${style} 风格`)
+ }
+ suggestions.forEach((item, index) => {
+ const content = String(item?.content || '').trim()
+ if (!content) issues.push(`第 ${index + 1} 条没有正文`)
+ if (TEMPLATE_PATTERNS.some((pattern) => pattern.test(content))) {
+ issues.push(`第 ${index + 1} 条包含模板化客服话术`)
+ }
+ if (!knowledgeProvided && UNSUPPORTED_TIMELINE_PATTERNS.some((pattern) => pattern.test(content))) {
+ issues.push(`第 ${index + 1} 条包含无事实依据的时间承诺`)
+ }
+ })
+ for (let i = 0; i < suggestions.length; i += 1) {
+ for (let j = i + 1; j < suggestions.length; j += 1) {
+ if (similarity(suggestions[i]?.content, suggestions[j]?.content) >= 0.82) {
+ issues.push(`第 ${i + 1} 条和第 ${j + 1} 条过于相似`)
+ }
+ }
+ }
+ return issues
+}
diff --git a/server/test/business.test.js b/server/test/business.test.js
index 4ca0825..05c8d36 100644
--- a/server/test/business.test.js
+++ b/server/test/business.test.js
@@ -2,6 +2,12 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
+import {
+ HUMAN_REPLY_SYSTEM_PROMPT,
+ buildHumanReplyUserPrompt,
+ replySuggestionIssues,
+ replyToneInstruction,
+} from '../src/reply-suggestions.js'
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
assert.deepEqual(
@@ -54,3 +60,41 @@ test('Email 渠道凭据使用认证加密且可正确解密', () => {
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
assert.throws(() => decryptEmailPassword(tampered.join(':')))
})
+
+test('回复建议提示词突出最新消息、避免重复并遵循企业语气', () => {
+ const prompt = buildHumanReplyUserPrompt({
+ targetInstruction: '使用西班牙语',
+ tone: 'friendly',
+ latestCustomerMessage: '¿Cuándo pueden enviarlo?',
+ latestAgentMessage: 'I will check it.',
+ conversation: '客户: ¿Cuándo pueden enviarlo?',
+ conversationMemory: '客户关注交期',
+ knowledgeContext: [],
+ })
+ assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不是写客服范文/)
+ assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不要重新问候/)
+ assert.match(prompt, /¿Cuándo pueden enviarlo\?/)
+ assert.match(prompt, /坐席上一条回复(避免重复)/)
+ assert.match(prompt, /长期合作伙伴/)
+ assert.match(replyToneInstruction('concise'), /简洁直接/)
+})
+
+test('回复建议质量检查拦截模板话术和同质草稿', () => {
+ const valid = [
+ { style: 'stable', content: 'I can confirm the dispatch date with our warehouse and update you once I have it.' },
+ { style: 'active', content: "Let me check the warehouse schedule and come back to you with the exact date." },
+ { style: 'brief', content: "I'll verify the dispatch date and get back to you." },
+ ]
+ assert.deepEqual(replySuggestionIssues(valid), [])
+ const invalid = valid.map((item) => ({ ...item }))
+ invalid[0].content = 'Dear customer, please rest assured that we value your feedback.'
+ invalid[2] = { style: 'active', content: invalid[1].content }
+ const issues = replySuggestionIssues(invalid)
+ assert.ok(issues.some((item) => item.includes('模板化')))
+ assert.ok(issues.some((item) => item.includes('缺少 brief')))
+ assert.ok(issues.some((item) => item.includes('过于相似')))
+ assert.ok(replySuggestionIssues([
+ ...valid.slice(0, 2),
+ { style: 'brief', content: "I'll confirm it today." },
+ ]).some((item) => item.includes('时间承诺')))
+})
diff --git a/src/components/inbox/AiReplies.tsx b/src/components/inbox/AiReplies.tsx
index ee123ae..0bad748 100644
--- a/src/components/inbox/AiReplies.tsx
+++ b/src/components/inbox/AiReplies.tsx
@@ -10,9 +10,9 @@ import { toast, errorMessage } from '@/app/store/toast'
import { languageName } from '@/config/languages'
const STYLE_LABELS = {
- stable: { title: '稳健回复', color: 'bg-blue-100 text-blue-700', icon: '🛡️' },
- active: { title: '积极促单回复', color: 'bg-emerald-100 text-emerald-700', icon: '🔥' },
- brief: { title: '简洁快速回复', color: 'bg-gray-100 text-gray-600', icon: '⚡' },
+ stable: { title: '清晰可靠', color: 'bg-blue-100 text-blue-700', icon: '🛡️' },
+ active: { title: '温暖主动', color: 'bg-violet-100 text-violet-700', icon: '💬' },
+ brief: { title: '简洁直接', color: 'bg-gray-100 text-gray-600', icon: '⚡' },
}
export function AiReplies({