Commit 77a77a8f authored by AI-甘富林's avatar AI-甘富林

客户端ai助手对话恢复

parent 489a5b3c
/** /**
* dify-chat-stream Edge Function * ai-chat-stream Edge Function
* C端消费者聊天 - Dify 流式输出版本 * C端消费者聊天 - 直接大模型流式输出
* *
* 优先使用 Dify,失败时回退到 Coze * 通过 AI Hub(OpenAI 兼容 API)直接调用大模型,不再经过 Dify/Coze 中间层。
* 无 AI Hub 时回退到 Lovable Gateway。
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
...@@ -31,6 +32,53 @@ interface ChatRequest { ...@@ -31,6 +32,53 @@ interface ChatRequest {
}; };
} }
// ─── AI Hub / LLM 配置 ───
const AI_HUB_KEY = Deno.env.get('AI_HUB_API_KEY') || '';
const AI_HUB_BASE = (Deno.env.get('AI_HUB_BASE_URL') || '').replace(/\/+$/, '');
const AI_HUB_MODEL = Deno.env.get('AI_HUB_DEFAULT_CHAT_MODEL') || 'agent-default';
const LOVABLE_KEY = Deno.env.get('LOVABLE_API_KEY') || '';
const USE_AI_HUB = !!(AI_HUB_KEY && AI_HUB_BASE);
// ─── 系统提示词构建 ───
function buildSystemPrompt(catalog: any[], userContext: string): string {
const catalogSection = catalog.length > 0
? `\n\n## 可用产品目录(共${catalog.length}个,展示前30个)\n${
catalog.slice(0, 30).map(p =>
`• ID="${p.id}" | ${p.name} | ¥${p.price} | 库存:${p.stock_quantity ?? '未知'} | ${p.category || ''} | ${(p.description || '').substring(0, 60)}`
).join('\n')
}\n\n严格规则:product_id 必须从上面目录中逐字符精确复制,禁止编造、简化或截断UUID!`
: '\n\n(当前无可用产品目录)';
const contextSection = userContext
? `\n\n## 用户上下文\n${userContext}`
: '';
return `你是"小智",千江商城的智能购物顾问。
## 核心规则
1. 产品ID必须从产品目录中精确复制,严禁编造或生成
2. 所有价格、库存以目录为准,不可臆造
3. 回复使用友好、专业、简洁的中文
4. 不透露你是AI
${catalogSection}${contextSection}
## 回复末尾必须包含JSON(当有推荐时)
\`\`\`json
{"picks":[{"product_id":"从目录复制的真实UUID","note":"推荐理由"}],"intent":"SHOPPING|COMPARE|AFTERSALES|GENERAL","highlights":["要点1","要点2"],"title":"简短标题"}
\`\`\`
## 规则
- picks 最多5个,product_id 必须从目录逐字符复制
- intent: SHOPPING(推荐购物)、COMPARE(对比)、AFTERSALES(售后)、GENERAL(一般)
- highlights: 2-4个回复要点(各20-80字)
- title: 简短对话标题(≤30字)
- 对比类询问使用Markdown表格
- 售后问题友好引导联系人工客服`;
}
// ─── 结构化输出解析 ───
// 尝试从文本中提取完整的结构化 JSON(包含 picks/intent/highlights/comparison/next 等字段) // 尝试从文本中提取完整的结构化 JSON(包含 picks/intent/highlights/comparison/next 等字段)
function parseStructuredPayload(text: string): any | null { function parseStructuredPayload(text: string): any | null {
const candidates: string[] = []; const candidates: string[] = [];
...@@ -118,13 +166,7 @@ function detectIntent(message: string, answer: string): string { ...@@ -118,13 +166,7 @@ function detectIntent(message: string, answer: string): string {
return 'GENERAL'; return 'GENERAL';
} }
// 构建产品目录 prompt // ─── 主处理 ───
function buildCatalogPrompt(catalog: any[]): string {
if (catalog.length === 0) return '';
return `\n\n## 可用产品目录(共${catalog.length}个,请从这里选择推荐):\n${
catalog.slice(0, 30).map(p => `• ID="${p.id}" | ${p.name} | ¥${p.price} | 库存:${p.stock_quantity ?? '未知'}`).join('\n')
}\n\n当推荐产品时,必须在回复末尾包含:\n\`\`\`json\n{"picks": [{"product_id": "从上面目录复制的真实ID", "note": "推荐理由"}]}\n\`\`\`\n严格规则:product_id 必须从产品目录中复制,禁止编造!`;
}
Deno.serve(async (req) => { Deno.serve(async (req) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
...@@ -137,9 +179,9 @@ Deno.serve(async (req) => { ...@@ -137,9 +179,9 @@ Deno.serve(async (req) => {
const body: ChatRequest = await req.json(); const body: ChatRequest = await req.json();
const { message, conversationId, inputs } = body; const { message, conversationId, inputs } = body;
console.log('📥 [dify-chat-stream] request:', { console.log('📥 [ai-chat-stream] request:', {
trace_id: traceId, trace_id: traceId,
message: message?.substring(0, 50), message: message?.substring(0, 80),
catalogCount: inputs?.catalog?.length || 0, catalogCount: inputs?.catalog?.length || 0,
conversationId: conversationId || '(new)', conversationId: conversationId || '(new)',
}); });
...@@ -148,7 +190,7 @@ Deno.serve(async (req) => { ...@@ -148,7 +190,7 @@ Deno.serve(async (req) => {
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseKey); const supabase = createClient(supabaseUrl, supabaseKey);
// 获取权威产品目录并与前端传入目录合并,避免前端截断导致真实 ID 被误过滤 // ─── 构建产品目录(合并前端传入 + 数据库查询)───
const inputCatalog = Array.isArray(inputs.catalog) ? inputs.catalog : []; const inputCatalog = Array.isArray(inputs.catalog) ? inputs.catalog : [];
const { data: products } = await supabase const { data: products } = await supabase
.from('products') .from('products')
...@@ -163,60 +205,72 @@ Deno.serve(async (req) => { ...@@ -163,60 +205,72 @@ Deno.serve(async (req) => {
}); });
const catalog = Array.from(catalogById.values()); const catalog = Array.from(catalogById.values());
const DIFY_API_KEY = Deno.env.get('DIFY_API_KEY'); // ─── 确定 LLM API 端点 ───
const DIFY_BASE_URL = Deno.env.get('DIFY_BASE_URL') || 'https://api.dify.ai/v1'; let apiUrl: string;
let apiKey: string;
if (!DIFY_API_KEY) { let model: string;
throw new Error('DIFY_API_KEY not configured');
if (USE_AI_HUB) {
apiUrl = `${AI_HUB_BASE}/chat/completions`;
apiKey = AI_HUB_KEY;
model = AI_HUB_MODEL;
console.log('🤖 [ai-chat-stream] Using AI Hub:', { base: AI_HUB_BASE, model });
} else if (LOVABLE_KEY) {
apiUrl = 'https://ai.gateway.lovable.dev/v1/chat/completions';
apiKey = LOVABLE_KEY;
model = 'google/gemini-2.5-flash';
console.log('🤖 [ai-chat-stream] Using Lovable Gateway');
} else {
throw new Error('AI_HUB_API_KEY/AI_HUB_BASE_URL 和 LOVABLE_API_KEY 均未配置,无法调用大模型');
} }
// 构建 Dify 请求 - 只发送用户原始消息,不注入目录(让 Dify Agent 自行处理) // ─── 构建系统提示词 ───
const difyBody: any = { const systemPrompt = buildSystemPrompt(catalog, inputs.user_context || '');
inputs: {
user_context: inputs.user_context || '',
},
query: message,
response_mode: 'streaming',
user: inputs.user_id || 'anonymous',
};
// 如果有对话ID,传入以维持多轮对话 // ─── 调用大模型流式 API ───
if (conversationId) { console.log('🚀 [ai-chat-stream] Calling LLM API...');
difyBody.conversation_id = conversationId;
}
console.log('🚀 [dify-chat-stream] Calling Dify API...'); const llmResponse = await fetch(apiUrl, {
const difyResponse = await fetch(`${DIFY_BASE_URL}/chat-messages`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${DIFY_API_KEY}`, 'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(difyBody), body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: message },
],
stream: true,
stream_options: { include_usage: true },
temperature: 0.7,
max_tokens: 4096,
}),
}); });
if (!difyResponse.ok) { if (!llmResponse.ok) {
const errorText = await difyResponse.text(); const errorText = await llmResponse.text();
console.error('❌ [dify-chat-stream] Dify API error:', difyResponse.status, errorText); console.error('❌ [ai-chat-stream] LLM API error:', llmResponse.status, errorText);
throw new Error(`Dify API error: ${difyResponse.status} - ${errorText}`); throw new Error(`LLM API error: ${llmResponse.status} - ${errorText.substring(0, 300)}`);
} }
// 创建 SSE 流 // ─── 流式转换:OpenAI SSE → 自定义 SSE ───
const encoder = new TextEncoder(); const encoder = new TextEncoder();
let fullAnswer = ''; let fullAnswer = '';
let difyConversationId = conversationId || ''; const chatConversationId = conversationId || crypto.randomUUID();
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
try { try {
const reader = difyResponse.body?.getReader(); const reader = llmResponse.body?.getReader();
if (!reader) throw new Error('No response body'); if (!reader) throw new Error('LLM response body is empty');
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
let openaiDone = false;
while (true) { while (!openaiDone) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
...@@ -225,60 +279,45 @@ Deno.serve(async (req) => { ...@@ -225,60 +279,45 @@ Deno.serve(async (req) => {
buffer = lines.pop() || ''; buffer = lines.pop() || '';
for (const line of lines) { for (const line of lines) {
if (!line.trim() || line.startsWith(':')) continue; const trimmed = line.trim();
if (!trimmed) continue;
// OpenAI SSE 格式: "data: {...}" 或 "data: [DONE]"
if (!trimmed.startsWith('data:')) continue;
let dataContent = line; const dataContent = trimmed.slice(5).trim(); // 去掉 "data:" 前缀
if (line.startsWith('data:')) { if (dataContent === '[DONE]') {
dataContent = line.slice(5).trim(); openaiDone = true;
break;
} }
if (!dataContent || dataContent === '[DONE]') continue; if (!dataContent) continue; // 跳过空的 keepalive 行
try { try {
const parsed = JSON.parse(dataContent); const parsed = JSON.parse(dataContent);
const event = parsed.event; const delta = parsed?.choices?.[0]?.delta;
// Dify SSE 事件类型 if (delta?.content) {
if (event === 'message' || event === 'agent_message') { fullAnswer += delta.content;
const content = parsed.answer || ''; const sseData = JSON.stringify({ type: 'text', content: delta.content });
if (content) {
fullAnswer += content;
const sseData = JSON.stringify({ type: 'text', content });
controller.enqueue(encoder.encode(`data: ${sseData}\n\n`)); controller.enqueue(encoder.encode(`data: ${sseData}\n\n`));
} }
// 捕获 conversation_id } catch {
if (parsed.conversation_id) { // 非 JSON 行,跳过(如注释行、空 data 等)
difyConversationId = parsed.conversation_id;
}
} else if (event === 'message_end') {
if (parsed.conversation_id) {
difyConversationId = parsed.conversation_id;
}
console.log('✅ [dify-chat-stream] Dify message_end, conversation_id:', difyConversationId);
} else if (event === 'error') {
console.error('❌ [dify-chat-stream] Dify error event:', parsed);
throw new Error(parsed.message || 'Dify streaming error');
}
} catch (e) {
if (e instanceof SyntaxError) {
// 非 JSON,忽略
} else {
throw e;
}
} }
} }
} }
// 如果 Dify 没有返回内容,发送友好错误 // ─── 流结束,后处理 ───
// 如果 LLM 没有返回内容,发送友好提示
if (!fullAnswer) { if (!fullAnswer) {
fullAnswer = '抱歉,AI助手暂时无法回复,请稍后再试。'; fullAnswer = '抱歉,AI助手暂时无法回复,请稍后再试。';
const sseData = JSON.stringify({ type: 'text', content: fullAnswer }); const sseData = JSON.stringify({ type: 'text', content: fullAnswer });
controller.enqueue(encoder.encode(`data: ${sseData}\n\n`)); controller.enqueue(encoder.encode(`data: ${sseData}\n\n`));
} }
// 打印 Dify 完整回复,便于调试是否包含商品ID console.log('📝 [ai-chat-stream] Full answer length:', fullAnswer.length);
console.log('📝 [dify-chat-stream] Full answer from Dify:\n' + fullAnswer);
// 优先尝试解析 Dify 直接返回的结构化 JSON(包含 picks/intent/highlights/comparison/next) // 优先尝试解析 LLM 直接返回的结构化 JSON
const structured = parseStructuredPayload(fullAnswer); const structured = parseStructuredPayload(fullAnswer);
let picks: Array<{ product_id: string; note: string }> = []; let picks: Array<{ product_id: string; note: string }> = [];
...@@ -289,7 +328,7 @@ Deno.serve(async (req) => { ...@@ -289,7 +328,7 @@ Deno.serve(async (req) => {
let title: string | undefined; let title: string | undefined;
if (structured) { if (structured) {
console.log('✅ [dify-chat-stream] Using structured payload from Dify'); console.log('✅ [ai-chat-stream] Using structured payload from LLM');
// 校验 picks 中的 product_id 必须存在于 catalog(防幻觉) // 校验 picks 中的 product_id 必须存在于 catalog(防幻觉)
const rawPicks = Array.isArray(structured.picks) ? structured.picks : []; const rawPicks = Array.isArray(structured.picks) ? structured.picks : [];
const catalogIdSet = new Set(catalog.map((c: any) => c?.id).filter(Boolean)); const catalogIdSet = new Set(catalog.map((c: any) => c?.id).filter(Boolean));
...@@ -307,10 +346,9 @@ Deno.serve(async (req) => { ...@@ -307,10 +346,9 @@ Deno.serve(async (req) => {
const accepted = pickAudit.filter(a => a.accepted); const accepted = pickAudit.filter(a => a.accepted);
const rejected = pickAudit.filter(a => !a.accepted); const rejected = pickAudit.filter(a => !a.accepted);
console.log(`📊 [dify-chat-stream] Pick audit — total:${pickAudit.length} accepted:${accepted.length} rejected:${rejected.length} catalog_size:${catalogIdSet.size}`); console.log(`📊 [ai-chat-stream] Pick audit — total:${pickAudit.length} accepted:${accepted.length} rejected:${rejected.length} catalog_size:${catalogIdSet.size}`);
if (rejected.length > 0) { if (rejected.length > 0) {
console.log('🚫 [dify-chat-stream] Rejected picks:', JSON.stringify(rejected, null, 2)); console.log('🚫 [ai-chat-stream] Rejected picks:', JSON.stringify(rejected, null, 2));
// 对每个被拒的 ID,再做一次精确比对说明(区分「不在 catalog」vs「字段问题」)
rejected.forEach(r => { rejected.forEach(r => {
if (r.product_id && r.reasons.includes('not_in_catalog')) { if (r.product_id && r.reasons.includes('not_in_catalog')) {
const prefix = r.product_id.slice(0, 8); const prefix = r.product_id.slice(0, 8);
...@@ -321,14 +359,6 @@ Deno.serve(async (req) => { ...@@ -321,14 +359,6 @@ Deno.serve(async (req) => {
} }
}); });
} }
if (accepted.length > 0) {
console.log('✅ [dify-chat-stream] Accepted picks:', JSON.stringify(
accepted.map(a => {
const product = catalog.find((c: any) => c.id === a.product_id);
return { id: a.product_id, name: product?.name };
})
));
}
picks = accepted.map(a => ({ product_id: a.product_id as string, note: a.note || '' })); picks = accepted.map(a => ({ product_id: a.product_id as string, note: a.note || '' }));
intent = structured.intent || detectIntent(message, fullAnswer); intent = structured.intent || detectIntent(message, fullAnswer);
...@@ -337,19 +367,19 @@ Deno.serve(async (req) => { ...@@ -337,19 +367,19 @@ Deno.serve(async (req) => {
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;
} else { } else {
console.log('⚠️ [dify-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);
intent = detectIntent(message, fullAnswer); intent = detectIntent(message, fullAnswer);
highlights = fullAnswer.split(/[。!?\n]/).filter((s: string) => s.length > 10 && s.length < 100).slice(0, 3); highlights = fullAnswer.split(/[。!?\n]/).filter((s: string) => s.length > 10 && s.length < 100).slice(0, 3);
} }
console.log('🔍 [dify-chat-stream] Parsed picks:', JSON.stringify(picks)); console.log('🔍 [ai-chat-stream] Parsed picks:', JSON.stringify(picks));
const mentionedIds = catalog const mentionedIds = catalog
.filter(c => fullAnswer.includes(c.id)) .filter(c => fullAnswer.includes(c.id))
.map(c => ({ id: c.id, name: c.name })); .map(c => ({ id: c.id, name: c.name }));
console.log('🔎 [dify-chat-stream] Product IDs mentioned in text:', JSON.stringify(mentionedIds)); console.log('🔎 [ai-chat-stream] Product IDs mentioned in text:', JSON.stringify(mentionedIds));
// 发送最终元数据 // ─── 发送最终元数据 ───
const finalData = JSON.stringify({ const finalData = JSON.stringify({
type: 'done', type: 'done',
intent, intent,
...@@ -359,28 +389,28 @@ Deno.serve(async (req) => { ...@@ -359,28 +389,28 @@ Deno.serve(async (req) => {
...(next ? { next } : {}), ...(next ? { next } : {}),
...(title ? { title } : {}), ...(title ? { title } : {}),
meta: { meta: {
platform: 'dify-stream', platform: 'ai-hub',
user: inputs.user_id || 'anonymous', user: inputs.user_id || 'anonymous',
catalog_count: catalog.length, catalog_count: catalog.length,
probe_id: traceId, probe_id: traceId,
source: structured ? 'dify-structured' : 'text-fallback', source: structured ? 'llm-structured' : 'text-fallback',
}, },
conversation_id: difyConversationId, conversation_id: chatConversationId,
}); });
controller.enqueue(encoder.encode(`data: ${finalData}\n\n`)); controller.enqueue(encoder.encode(`data: ${finalData}\n\n`));
controller.enqueue(encoder.encode('data: [DONE]\n\n')); controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close(); controller.close();
console.log('✅ [dify-chat-stream] Stream completed:', { console.log('✅ [ai-chat-stream] Stream completed:', {
answerLength: fullAnswer.length, answerLength: fullAnswer.length,
picksCount: picks.length, picksCount: picks.length,
intent, intent,
hasComparison: !!comparison, hasComparison: !!comparison,
conversation_id: difyConversationId, conversation_id: chatConversationId,
}); });
} catch (error) { } catch (error) {
console.error('❌ [dify-chat-stream] Stream error:', error); console.error('❌ [ai-chat-stream] Stream error:', error);
const errorData = JSON.stringify({ const errorData = JSON.stringify({
type: 'error', type: 'error',
message: (error instanceof Error ? error.message : '流式响应失败'), message: (error instanceof Error ? error.message : '流式响应失败'),
...@@ -401,9 +431,9 @@ Deno.serve(async (req) => { ...@@ -401,9 +431,9 @@ Deno.serve(async (req) => {
}, },
}); });
} catch (error) { } catch (error) {
console.error('❌ [dify-chat-stream] Fatal error:', error); console.error('❌ [ai-chat-stream] Fatal error:', error);
return new Response( return new Response(
JSON.stringify({ error: (error instanceof Error ? error.message : 'Unknown error')}), JSON.stringify({ error: (error instanceof Error ? error.message : 'Unknown error') }),
{ {
status: 500, status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
......
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