Commit 2240e5ca authored by AI-甘富林's avatar AI-甘富林

多轮对话提交

parent 6c66cc8d
/** /**
* ai-chat-stream Edge Function * ai-chat-stream Edge Function
* C端消费者聊天 - 直接大模型流式输出 * C端消费者聊天 - 直接大模型流式输出(支持多轮对话记忆 + 主动澄清)
* *
* 通过 AI Hub(OpenAI 兼容 API)直接调用大模型,不再经过 Dify/Coze 中间层。 * 通过 AI Hub(OpenAI 兼容 API)直接调用大模型,不再经过 Dify/Coze 中间层。
* 无 AI Hub 时回退到 Lovable Gateway。 * 无 AI Hub 时回退到 Lovable Gateway。
*
* v2 更新:
* - 从 messages 表加载对话历史,注入 LLM 上下文(多轮记忆)
* - 系统提示词增强:意图模糊时主动追问澄清(CLARIFY intent)
* - 支持 localConversationId 定位本地会话
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
...@@ -16,7 +21,8 @@ const corsHeaders = { ...@@ -16,7 +21,8 @@ const corsHeaders = {
interface ChatRequest { interface ChatRequest {
message: string; message: string;
conversationId?: string; conversationId?: string; // AI 服务商的远程会话 ID
localConversationId?: string; // ★ 本地 conversations 表的主键 UUID
inputs: { inputs: {
user_id?: string; user_id?: string;
trace_id: string; trace_id: string;
...@@ -39,6 +45,9 @@ const AI_HUB_MODEL = Deno.env.get('AI_HUB_DEFAULT_CHAT_MODEL') || 'agent-default ...@@ -39,6 +45,9 @@ const AI_HUB_MODEL = Deno.env.get('AI_HUB_DEFAULT_CHAT_MODEL') || 'agent-default
const LOVABLE_KEY = Deno.env.get('LOVABLE_API_KEY') || ''; const LOVABLE_KEY = Deno.env.get('LOVABLE_API_KEY') || '';
const USE_AI_HUB = !!(AI_HUB_KEY && AI_HUB_BASE); const USE_AI_HUB = !!(AI_HUB_KEY && AI_HUB_BASE);
// ─── 对话历史类型 ───
type HistoryMessage = { role: 'user' | 'assistant'; content: string };
// ─── 系统提示词构建 ─── // ─── 系统提示词构建 ───
function buildSystemPrompt(catalog: any[], userContext: string): string { function buildSystemPrompt(catalog: any[], userContext: string): string {
const catalogSection = catalog.length > 0 const catalogSection = catalog.length > 0
...@@ -60,23 +69,90 @@ function buildSystemPrompt(catalog: any[], userContext: string): string { ...@@ -60,23 +69,90 @@ function buildSystemPrompt(catalog: any[], userContext: string): string {
2. 所有价格、库存以目录为准,不可臆造 2. 所有价格、库存以目录为准,不可臆造
3. 回复使用友好、专业、简洁的中文 3. 回复使用友好、专业、简洁的中文
4. 不透露你是AI 4. 不透露你是AI
5. 理解对话历史,记住用户之前说过的话——结合上下文做推荐和回复
${catalogSection}${contextSection} ${catalogSection}${contextSection}
## 回复末尾必须包含JSON(当有推荐时) ## 澄清规则(重要)
当用户意图模糊、信息不足时,你必须**主动追问澄清**,而不是盲目推荐。需要澄清的典型场景:
1. **购物意图模糊**:用户说"想买个手机"但没提预算、品牌偏好、用途 → 追问预算范围、偏好品牌/功能、主要用途
2. **对比请求缺标准**:用户说"哪个更好"但没说看重什么 → 追问用户最在意的因素(价格/性能/外观/售后)
3. **售后请求缺信息**:用户提退货/维修但没说订单号 → 友好索要订单号
4. **预算不明确**:用户只说"便宜的"/"好一点的" → 追问具体预算区间
5. **模糊指代**:用户说"刚才那个"、"太贵了有便宜的吗" → 结合对话历史定位之前推荐的产品
澄清要求:
- 每次最多问2-3个关键问题,不要连珠炮式提问
- 语气友好,先共情再追问(示例:"我理解您想找一款手机,为了给您更精准的推荐,想了解一下您的预算和品牌偏好?")
- 澄清后 JSON 中标记 intent: "CLARIFY"
- clarifies 字段列出具体追问点(数组,每个一条追问)
## 回复末尾必须包含JSON
\`\`\`json \`\`\`json
{"picks":[{"product_id":"从目录复制的真实UUID","note":"推荐理由"}],"intent":"SHOPPING|COMPARE|AFTERSALES|GENERAL","highlights":["要点1","要点2"],"title":"简短标题"} {"picks":[{"product_id":"从目录复制的真实UUID","note":"推荐理由"}],"intent":"SHOPPING|COMPARE|AFTERSALES|GENERAL|CLARIFY","highlights":["要点1","要点2"],"clarifies":["追问1","追问2"],"title":"简短标题"}
\`\`\` \`\`\`
## 规则 ## 规则
- picks 最多5个,product_id 必须从目录逐字符复制 - picks 最多5个,product_id 必须从目录逐字符复制;澄清模式下 picks 为空数组
- intent: SHOPPING(推荐购物)、COMPARE(对比)、AFTERSALES(售后)、GENERAL(一般) - intent: SHOPPING(推荐购物)、COMPARE(对比)、AFTERSALES(售后)、GENERAL(一般)、CLARIFY(主动澄清追问)
- highlights: 2-4个回复要点(各20-80字) - clarifies: 当 intent=CLARIFY 时必填,列出2-3个具体追问问题
- highlights: 2-4个回复要点(各20-80字);澄清模式下为对话摘要
- title: 简短对话标题(≤30字) - title: 简短对话标题(≤30字)
- 对比类询问使用Markdown表格 - 对比类询问使用Markdown表格
- 售后问题友好引导联系人工客服`; - 售后问题友好引导联系人工客服`;
} }
// ─── 对话历史加载 ───
/** 从 messages 表加载指定会话的历史消息 */
async function loadConversationHistory(
supabase: any,
conversationId: string,
userId: string,
maxMessages = 20
): Promise<HistoryMessage[]> {
try {
const { data, error } = await supabase
.from('messages')
.select('sender_type, content')
.eq('conversation_id', conversationId)
.eq('user_id', userId)
.order('created_at', { ascending: true })
.limit(maxMessages);
if (error) {
console.error('[ai-chat-stream] 加载历史消息失败:', error);
return [];
}
console.log(`📜 [ai-chat-stream] Loaded ${data?.length || 0} history messages for conversation ${conversationId}`);
return (data || []).map((m: any) => ({
role: m.sender_type === 'user' ? 'user' : 'assistant',
content: m.content,
}));
} catch (err) {
console.error('[ai-chat-stream] 加载历史消息异常:', err);
return [];
}
}
/** 按总字符数截断历史消息,保留最近的消息 */
function truncateHistory(history: HistoryMessage[], maxChars: number): HistoryMessage[] {
let total = 0;
const result: HistoryMessage[] = [];
// 从最新往最早遍历,保留最近的消息直到达到上限
for (let i = history.length - 1; i >= 0; i--) {
if (total + history[i].content.length > maxChars) {
if (result.length === 0) result.unshift(history[i]); // 至少保留最新一条
break;
}
total += history[i].content.length;
result.unshift(history[i]); // 保持时间顺序
}
return result;
}
// ─── 结构化输出解析 ─── // ─── 结构化输出解析 ───
// 尝试从文本中提取完整的结构化 JSON(包含 picks/intent/highlights/comparison/next 等字段) // 尝试从文本中提取完整的结构化 JSON(包含 picks/intent/highlights/comparison/next 等字段)
...@@ -95,7 +171,7 @@ function parseStructuredPayload(text: string): any | null { ...@@ -95,7 +171,7 @@ function parseStructuredPayload(text: string): any | null {
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
const parsed = JSON.parse(candidate); const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === 'object' && (parsed.picks || parsed.intent || parsed.highlights)) { if (parsed && typeof parsed === 'object' && (parsed.picks || parsed.intent || parsed.highlights || parsed.clarifies)) {
return parsed; return parsed;
} }
} catch { } catch {
...@@ -155,14 +231,17 @@ function parsePicksFromText(text: string, catalog: any[]): Array<{ product_id: s ...@@ -155,14 +231,17 @@ function parsePicksFromText(text: string, catalog: any[]): Array<{ product_id: s
return picks; return picks;
} }
// 检测意图 // 检测意图(兜底方案,LLM 返回结构化 JSON 时优先使用 LLM 的判断)
function detectIntent(message: string, answer: string): string { function detectIntent(message: string, answer: string): string {
const shoppingKeywords = ['买', '购', '推荐', '价格', '多少钱', '手机', '电脑']; const shoppingKeywords = ['买', '购', '推荐', '价格', '多少钱', '手机', '电脑'];
const afterSalesKeywords = ['退货', '换货', '售后', '维修']; const afterSalesKeywords = ['退货', '换货', '售后', '维修'];
const compareKeywords = ['对比', '比较', '哪个更好']; const compareKeywords = ['对比', '比较', '哪个更好'];
const clarifyKeywords = ['是吗', '什么', '哪个', '哪款', '多少', '怎样', '如何', '怎么', '能不能', '可以吗'];
if (compareKeywords.some(kw => message.includes(kw))) return 'COMPARE'; if (compareKeywords.some(kw => message.includes(kw))) return 'COMPARE';
if (shoppingKeywords.some(kw => message.includes(kw)) || answer.includes('picks')) return 'SHOPPING'; if (shoppingKeywords.some(kw => message.includes(kw)) || answer.includes('picks')) return 'SHOPPING';
if (afterSalesKeywords.some(kw => message.includes(kw))) return 'AFTERSALES'; if (afterSalesKeywords.some(kw => message.includes(kw))) return 'AFTERSALES';
// 如果消息很短且以问号结尾,可能是模糊询问,倾向 CLARIFY
if (message.length < 20 && clarifyKeywords.some(kw => message.includes(kw))) return 'CLARIFY';
return 'GENERAL'; return 'GENERAL';
} }
...@@ -177,13 +256,14 @@ Deno.serve(async (req) => { ...@@ -177,13 +256,14 @@ Deno.serve(async (req) => {
try { try {
const body: ChatRequest = await req.json(); const body: ChatRequest = await req.json();
const { message, conversationId, inputs } = body; const { message, conversationId, localConversationId, inputs } = body;
console.log('📥 [ai-chat-stream] request:', { console.log('📥 [ai-chat-stream] request:', {
trace_id: traceId, trace_id: traceId,
message: message?.substring(0, 80), message: message?.substring(0, 80),
catalogCount: inputs?.catalog?.length || 0, catalogCount: inputs?.catalog?.length || 0,
conversationId: conversationId || '(new)', conversationId: conversationId || '(new)',
localConversationId: localConversationId || '(not provided)',
}); });
const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
...@@ -224,6 +304,23 @@ Deno.serve(async (req) => { ...@@ -224,6 +304,23 @@ Deno.serve(async (req) => {
throw new Error('AI_HUB_API_KEY/AI_HUB_BASE_URL 和 LOVABLE_API_KEY 均未配置,无法调用大模型'); throw new Error('AI_HUB_API_KEY/AI_HUB_BASE_URL 和 LOVABLE_API_KEY 均未配置,无法调用大模型');
} }
// ─── 加载对话历史 ───
let historyMessages: HistoryMessage[] = [];
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const userId = (inputs.user_id && uuidRegex.test(inputs.user_id)) ? inputs.user_id : crypto.randomUUID();
const localConvId = localConversationId || crypto.randomUUID(); // 前端没传则后端生成,保障多轮记忆自给自足
if (localConvId && userId) {
historyMessages = await loadConversationHistory(supabase, localConvId, userId, 20);
historyMessages = truncateHistory(historyMessages, 6000);
console.log('📜 [ai-chat-stream] 历史消息:', {
loaded: historyMessages.length,
totalChars: historyMessages.reduce((s, m) => s + m.content.length, 0),
oldestRole: historyMessages[0]?.role || 'n/a',
newestRole: historyMessages[historyMessages.length - 1]?.role || 'n/a',
});
}
// ─── 构建系统提示词 ─── // ─── 构建系统提示词 ───
const systemPrompt = buildSystemPrompt(catalog, inputs.user_context || ''); const systemPrompt = buildSystemPrompt(catalog, inputs.user_context || '');
...@@ -240,6 +337,7 @@ Deno.serve(async (req) => { ...@@ -240,6 +337,7 @@ Deno.serve(async (req) => {
model, model,
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
...historyMessages,
{ role: 'user', content: message }, { role: 'user', content: message },
], ],
stream: true, stream: true,
...@@ -258,7 +356,6 @@ Deno.serve(async (req) => { ...@@ -258,7 +356,6 @@ Deno.serve(async (req) => {
// ─── 流式转换:OpenAI SSE → 自定义 SSE ─── // ─── 流式转换:OpenAI SSE → 自定义 SSE ───
const encoder = new TextEncoder(); const encoder = new TextEncoder();
let fullAnswer = ''; let fullAnswer = '';
const chatConversationId = conversationId || crypto.randomUUID();
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
...@@ -326,6 +423,7 @@ Deno.serve(async (req) => { ...@@ -326,6 +423,7 @@ Deno.serve(async (req) => {
let comparison: string | undefined; let comparison: string | undefined;
let next: string[] | undefined; let next: string[] | undefined;
let title: string | undefined; let title: string | undefined;
let clarifies: string[] | undefined;
if (structured) { if (structured) {
console.log('✅ [ai-chat-stream] Using structured payload from LLM'); console.log('✅ [ai-chat-stream] Using structured payload from LLM');
...@@ -366,6 +464,7 @@ Deno.serve(async (req) => { ...@@ -366,6 +464,7 @@ Deno.serve(async (req) => {
comparison = typeof structured.comparison === 'string' ? structured.comparison : undefined; comparison = typeof structured.comparison === 'string' ? structured.comparison : undefined;
next = Array.isArray(structured.next) ? structured.next : undefined; next = Array.isArray(structured.next) ? structured.next : undefined;
title = typeof structured.title === 'string' ? structured.title : undefined; title = typeof structured.title === 'string' ? structured.title : undefined;
clarifies = Array.isArray(structured.clarifies) ? structured.clarifies : undefined;
} else { } else {
console.log('⚠️ [ai-chat-stream] No structured payload, falling back to text parsing'); console.log('⚠️ [ai-chat-stream] No structured payload, falling back to text parsing');
picks = parsePicksFromText(fullAnswer, catalog); picks = parsePicksFromText(fullAnswer, catalog);
...@@ -379,6 +478,44 @@ Deno.serve(async (req) => { ...@@ -379,6 +478,44 @@ Deno.serve(async (req) => {
.map(c => ({ id: c.id, name: c.name })); .map(c => ({ id: c.id, name: c.name }));
console.log('🔎 [ai-chat-stream] Product IDs mentioned in text:', JSON.stringify(mentionedIds)); console.log('🔎 [ai-chat-stream] Product IDs mentioned in text:', JSON.stringify(mentionedIds));
// ─── 保存消息到数据库1(服务端写入,保障多轮记忆)───
if (localConvId && userId) {
const baseTime = new Date();
const userTs = baseTime.toISOString();
const aiTs = new Date(baseTime.getTime() + 1).toISOString(); // +1ms 保证顺序
// 确保 conversations 表有对应行(访客场景下前端只写 localStorage,FK 约束会拒绝 messages 写入)
const { error: upsertConvError } = await supabase.from('conversations').upsert({
id: localConvId,
user_id: userId,
}, { onConflict: 'id' });
if (upsertConvError) console.error('[ai-chat-stream] upsert conversations 失败:', upsertConvError);
// 保存用户消息
const { error: saveUserError } = await supabase.from('messages').insert({
conversation_id: localConvId,
user_id: userId,
content: message,
sender_type: 'user',
message_type: 'text',
created_at: userTs,
});
if (saveUserError) console.error('[ai-chat-stream] 保存用户消息失败:', saveUserError, { code: saveUserError?.code });
// 保存 AI 回复
const { error: saveAiError } = await supabase.from('messages').insert({
conversation_id: localConvId,
user_id: userId,
content: fullAnswer,
sender_type: 'ai',
message_type: 'text',
metadata: { picks, intent, highlights, title },
created_at: aiTs,
});
if (saveAiError) console.error('[ai-chat-stream] 保存 AI 回复失败:', saveAiError, { code: saveAiError?.code });
else console.log('💾 [ai-chat-stream] 消息已保存到 DB');
}
// ─── 发送最终元数据 ─── // ─── 发送最终元数据 ───
const finalData = JSON.stringify({ const finalData = JSON.stringify({
type: 'done', type: 'done',
...@@ -388,14 +525,17 @@ Deno.serve(async (req) => { ...@@ -388,14 +525,17 @@ Deno.serve(async (req) => {
...(comparison ? { comparison } : {}), ...(comparison ? { comparison } : {}),
...(next ? { next } : {}), ...(next ? { next } : {}),
...(title ? { title } : {}), ...(title ? { title } : {}),
...(clarifies ? { clarifies } : {}),
meta: { meta: {
platform: 'ai-hub', platform: 'ai-hub',
user: inputs.user_id || 'anonymous', user: userId,
catalog_count: catalog.length, catalog_count: catalog.length,
probe_id: traceId, probe_id: traceId,
source: structured ? 'llm-structured' : 'text-fallback', source: structured ? 'llm-structured' : 'text-fallback',
hasHistory: historyMessages.length > 0,
historyMessages: historyMessages.length,
}, },
conversation_id: chatConversationId, conversation_id: localConvId,
}); });
controller.enqueue(encoder.encode(`data: ${finalData}\n\n`)); controller.enqueue(encoder.encode(`data: ${finalData}\n\n`));
...@@ -407,7 +547,7 @@ Deno.serve(async (req) => { ...@@ -407,7 +547,7 @@ Deno.serve(async (req) => {
picksCount: picks.length, picksCount: picks.length,
intent, intent,
hasComparison: !!comparison, hasComparison: !!comparison,
conversation_id: chatConversationId, conversation_id: localConvId,
}); });
} catch (error) { } catch (error) {
console.error('❌ [ai-chat-stream] Stream error:', error); console.error('❌ [ai-chat-stream] Stream error:', error);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment