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

客户端ai助手对话恢复

parent 489a5b3c
/**
* dify-chat-stream Edge Function
* C端消费者聊天 - Dify 流式输出版本
*
* 优先使用 Dify,失败时回退到 Coze
* ai-chat-stream Edge Function
* C端消费者聊天 - 直接大模型流式输出
*
* 通过 AI Hub(OpenAI 兼容 API)直接调用大模型,不再经过 Dify/Coze 中间层。
* 无 AI Hub 时回退到 Lovable Gateway。
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
......@@ -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 等字段)
function parseStructuredPayload(text: string): any | null {
const candidates: string[] = [];
......@@ -118,13 +166,7 @@ function detectIntent(message: string, answer: string): string {
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) => {
if (req.method === 'OPTIONS') {
......@@ -137,9 +179,9 @@ Deno.serve(async (req) => {
const body: ChatRequest = await req.json();
const { message, conversationId, inputs } = body;
console.log('📥 [dify-chat-stream] request:', {
console.log('📥 [ai-chat-stream] request:', {
trace_id: traceId,
message: message?.substring(0, 50),
message: message?.substring(0, 80),
catalogCount: inputs?.catalog?.length || 0,
conversationId: conversationId || '(new)',
});
......@@ -148,7 +190,7 @@ Deno.serve(async (req) => {
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseKey);
// 获取权威产品目录并与前端传入目录合并,避免前端截断导致真实 ID 被误过滤
// ─── 构建产品目录(合并前端传入 + 数据库查询)───
const inputCatalog = Array.isArray(inputs.catalog) ? inputs.catalog : [];
const { data: products } = await supabase
.from('products')
......@@ -163,60 +205,72 @@ Deno.serve(async (req) => {
});
const catalog = Array.from(catalogById.values());
const DIFY_API_KEY = Deno.env.get('DIFY_API_KEY');
const DIFY_BASE_URL = Deno.env.get('DIFY_BASE_URL') || 'https://api.dify.ai/v1';
if (!DIFY_API_KEY) {
throw new Error('DIFY_API_KEY not configured');
// ─── 确定 LLM API 端点 ───
let apiUrl: string;
let apiKey: string;
let model: string;
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 = {
inputs: {
user_context: inputs.user_context || '',
},
query: message,
response_mode: 'streaming',
user: inputs.user_id || 'anonymous',
};
// 如果有对话ID,传入以维持多轮对话
if (conversationId) {
difyBody.conversation_id = conversationId;
}
// ─── 构建系统提示词 ───
const systemPrompt = buildSystemPrompt(catalog, inputs.user_context || '');
console.log('🚀 [dify-chat-stream] Calling Dify API...');
// ─── 调用大模型流式 API ───
console.log('🚀 [ai-chat-stream] Calling LLM API...');
const difyResponse = await fetch(`${DIFY_BASE_URL}/chat-messages`, {
const llmResponse = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${DIFY_API_KEY}`,
'Authorization': `Bearer ${apiKey}`,
'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) {
const errorText = await difyResponse.text();
console.error('❌ [dify-chat-stream] Dify API error:', difyResponse.status, errorText);
throw new Error(`Dify API error: ${difyResponse.status} - ${errorText}`);
if (!llmResponse.ok) {
const errorText = await llmResponse.text();
console.error('❌ [ai-chat-stream] LLM API error:', llmResponse.status, errorText);
throw new Error(`LLM API error: ${llmResponse.status} - ${errorText.substring(0, 300)}`);
}
// 创建 SSE 流
// ─── 流式转换:OpenAI SSE → 自定义 SSE ───
const encoder = new TextEncoder();
let fullAnswer = '';
let difyConversationId = conversationId || '';
const chatConversationId = conversationId || crypto.randomUUID();
const stream = new ReadableStream({
async start(controller) {
try {
const reader = difyResponse.body?.getReader();
if (!reader) throw new Error('No response body');
const reader = llmResponse.body?.getReader();
if (!reader) throw new Error('LLM response body is empty');
const decoder = new TextDecoder();
let buffer = '';
let openaiDone = false;
while (true) {
while (!openaiDone) {
const { done, value } = await reader.read();
if (done) break;
......@@ -225,60 +279,45 @@ Deno.serve(async (req) => {
buffer = lines.pop() || '';
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;
if (line.startsWith('data:')) {
dataContent = line.slice(5).trim();
const dataContent = trimmed.slice(5).trim(); // 去掉 "data:" 前缀
if (dataContent === '[DONE]') {
openaiDone = true;
break;
}
if (!dataContent || dataContent === '[DONE]') continue;
if (!dataContent) continue; // 跳过空的 keepalive 行
try {
const parsed = JSON.parse(dataContent);
const event = parsed.event;
// Dify SSE 事件类型
if (event === 'message' || event === 'agent_message') {
const content = parsed.answer || '';
if (content) {
fullAnswer += content;
const sseData = JSON.stringify({ type: 'text', content });
controller.enqueue(encoder.encode(`data: ${sseData}\n\n`));
}
// 捕获 conversation_id
if (parsed.conversation_id) {
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;
const delta = parsed?.choices?.[0]?.delta;
if (delta?.content) {
fullAnswer += delta.content;
const sseData = JSON.stringify({ type: 'text', content: delta.content });
controller.enqueue(encoder.encode(`data: ${sseData}\n\n`));
}
} catch {
// 非 JSON 行,跳过(如注释行、空 data 等)
}
}
}
// 如果 Dify 没有返回内容,发送友好错误
// ─── 流结束,后处理 ───
// 如果 LLM 没有返回内容,发送友好提示
if (!fullAnswer) {
fullAnswer = '抱歉,AI助手暂时无法回复,请稍后再试。';
const sseData = JSON.stringify({ type: 'text', content: fullAnswer });
controller.enqueue(encoder.encode(`data: ${sseData}\n\n`));
}
// 打印 Dify 完整回复,便于调试是否包含商品ID
console.log('📝 [dify-chat-stream] Full answer from Dify:\n' + fullAnswer);
console.log('📝 [ai-chat-stream] Full answer length:', fullAnswer.length);
// 优先尝试解析 Dify 直接返回的结构化 JSON(包含 picks/intent/highlights/comparison/next)
// 优先尝试解析 LLM 直接返回的结构化 JSON
const structured = parseStructuredPayload(fullAnswer);
let picks: Array<{ product_id: string; note: string }> = [];
......@@ -289,7 +328,7 @@ Deno.serve(async (req) => {
let title: string | undefined;
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(防幻觉)
const rawPicks = Array.isArray(structured.picks) ? structured.picks : [];
const catalogIdSet = new Set(catalog.map((c: any) => c?.id).filter(Boolean));
......@@ -307,10 +346,9 @@ Deno.serve(async (req) => {
const accepted = 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) {
console.log('🚫 [dify-chat-stream] Rejected picks:', JSON.stringify(rejected, null, 2));
// 对每个被拒的 ID,再做一次精确比对说明(区分「不在 catalog」vs「字段问题」)
console.log('🚫 [ai-chat-stream] Rejected picks:', JSON.stringify(rejected, null, 2));
rejected.forEach(r => {
if (r.product_id && r.reasons.includes('not_in_catalog')) {
const prefix = r.product_id.slice(0, 8);
......@@ -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 || '' }));
intent = structured.intent || detectIntent(message, fullAnswer);
......@@ -337,19 +367,19 @@ Deno.serve(async (req) => {
next = Array.isArray(structured.next) ? structured.next : undefined;
title = typeof structured.title === 'string' ? structured.title : undefined;
} 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);
intent = detectIntent(message, fullAnswer);
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
.filter(c => fullAnswer.includes(c.id))
.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({
type: 'done',
intent,
......@@ -359,28 +389,28 @@ Deno.serve(async (req) => {
...(next ? { next } : {}),
...(title ? { title } : {}),
meta: {
platform: 'dify-stream',
platform: 'ai-hub',
user: inputs.user_id || 'anonymous',
catalog_count: catalog.length,
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: [DONE]\n\n'));
controller.close();
console.log('✅ [dify-chat-stream] Stream completed:', {
console.log('✅ [ai-chat-stream] Stream completed:', {
answerLength: fullAnswer.length,
picksCount: picks.length,
intent,
hasComparison: !!comparison,
conversation_id: difyConversationId,
conversation_id: chatConversationId,
});
} catch (error) {
console.error('❌ [dify-chat-stream] Stream error:', error);
console.error('❌ [ai-chat-stream] Stream error:', error);
const errorData = JSON.stringify({
type: 'error',
message: (error instanceof Error ? error.message : '流式响应失败'),
......@@ -401,9 +431,9 @@ Deno.serve(async (req) => {
},
});
} catch (error) {
console.error('❌ [dify-chat-stream] Fatal error:', error);
console.error('❌ [ai-chat-stream] Fatal error:', error);
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,
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