Commit 99f78a49 authored by AI-甘富林's avatar AI-甘富林

前端代码修复

parent 77a77a8f
...@@ -37,7 +37,6 @@ import { AIAgent, AI_AGENTS } from "@/types/agents"; ...@@ -37,7 +37,6 @@ import { AIAgent, AI_AGENTS } from "@/types/agents";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { callCozeEdge } from "@/utils/cozeClient"; import { callCozeEdge } from "@/utils/cozeClient";
import { streamCozeChat } from "@/utils/cozeStreamClient";
import { streamDifyChat } from "@/utils/difyStreamClient"; import { streamDifyChat } from "@/utils/difyStreamClient";
import { queryCoupons, getWelcomeCoupons, type Coupon } from "@/utils/couponClient"; import { queryCoupons, getWelcomeCoupons, type Coupon } from "@/utils/couponClient";
import { searchMemory, addMemory } from "@/utils/mem0Client"; import { searchMemory, addMemory } from "@/utils/mem0Client";
...@@ -95,14 +94,11 @@ export default function ChatPage() { ...@@ -95,14 +94,11 @@ export default function ChatPage() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [checkoutOpen, setCheckoutOpen] = useState(false); const [checkoutOpen, setCheckoutOpen] = useState(false);
const [useStream, setUseStream] = useState(true); // 流式输出开关,默认开启 const [useStream, setUseStream] = useState(true); // 流式输出开关,默认开启
// 引擎策略:从后台 ai_provider_configs.consumer_chat 加载(admin 可在 /admin/ai-providers 切换) // AI 引擎模型名(从后台 ai_provider_configs.consumer_chat 加载)
// 可选值:'dify' | 'coze' | 'dify_with_coze_fallback' | 'coze_with_dify_fallback' const [chatModel, setChatModel] = useState<string>('agent-default');
const [chatEngineStrategy, setChatEngineStrategy] = useState<string>('dify_with_coze_fallback');
// 实际派发用的主引擎(由 strategy 推导)
const aiEngine: 'dify' | 'coze' = chatEngineStrategy.startsWith('coze') ? 'coze' : 'dify';
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null); // 当前流式消息ID const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null); // 当前流式消息ID
// 启动时从后台读取消费者聊天引擎策略 // 启动时从后台读取 AI 模型配置
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
...@@ -113,11 +109,11 @@ export default function ChatPage() { ...@@ -113,11 +109,11 @@ export default function ChatPage() {
.eq('purpose', 'consumer_chat') .eq('purpose', 'consumer_chat')
.maybeSingle(); .maybeSingle();
if (!cancelled && data && data.enabled !== false && data.model) { if (!cancelled && data && data.enabled !== false && data.model) {
setChatEngineStrategy(data.model); setChatModel(data.model);
console.log('[ChatPage] 已加载后台引擎策略:', data.model); console.log('[ChatPage] 已加载后台模型配置:', data.model);
} }
} catch (e) { } catch (e) {
console.warn('[ChatPage] 引擎策略加载失败,使用默认 dify_with_coze_fallback', e); console.warn('[ChatPage] 模型配置加载失败,使用默认', e);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
...@@ -324,14 +320,13 @@ export default function ChatPage() { ...@@ -324,14 +320,13 @@ export default function ChatPage() {
: ''; : '';
console.log('- 对话ID (from ref):', conversationIdFromDb || '(new conversation)'); console.log('- 对话ID (from ref):', conversationIdFromDb || '(new conversation)');
// ========== Step 2: 根据开关选择调用方式 ========== // ========== Step 2: 流式调用 AI ─────────
if (useStream) { if (useStream) {
const currentEngine = aiEngine; console.log('🚀 [STREAM] 使用流式输出模式');
console.log(`🚀 [STREAM] 使用 ${currentEngine} 流式输出模式`);
const streamMessageId = `ai_stream_${Date.now()}`; const streamMessageId = `ai_stream_${Date.now()}`;
setStreamingMessageId(streamMessageId); setStreamingMessageId(streamMessageId);
// 创建初始空消息 // 创建初始空消息
const streamMessage: ChatMessageType = { const streamMessage: ChatMessageType = {
id: streamMessageId, id: streamMessageId,
...@@ -345,50 +340,31 @@ export default function ChatPage() { ...@@ -345,50 +340,31 @@ export default function ChatPage() {
agent: currentAgent, agent: currentAgent,
metadata: {} metadata: {}
}; };
setMessages(prev => [...prev, streamMessage]); setMessages(prev => [...prev, streamMessage]);
let fullContent = ''; let fullContent = '';
let finalMetadata: any = {}; let finalMetadata: any = {};
let streamFailed = false;
const streamCallbacks = { const streamCallbacks = {
onText: (text: string) => { onText: (text: string) => {
fullContent += text; fullContent += text;
setMessages(prev => prev.map(msg => setMessages(prev => prev.map(msg =>
msg.id === streamMessageId msg.id === streamMessageId
? { ...msg, content: fullContent } ? { ...msg, content: fullContent }
: msg : msg
)); ));
}, },
onDone: async (data: any) => { onDone: async (data: any) => {
console.log(`✅ [STREAM/${currentEngine}] 流式响应完成:`, data); console.log('✅ [STREAM] 流式响应完成:', data);
const isStructuredDifyPayload =
data?.meta?.source === 'dify-structured' &&
typeof fullContent === 'string' &&
fullContent.trim().startsWith('{');
if (isStructuredDifyPayload) {
const readableParts = [
data.title ? `为您找到:${data.title}` : '',
Array.isArray(data.highlights) && data.highlights.length > 0
? data.highlights.join('\n')
: '',
].filter(Boolean);
fullContent = readableParts.join('\n\n') || '为您整理了以下推荐';
}
// 🧹 清洗:移除 AI 误把协议 JSON(picks/intent)当作正文输出的内容 // 🧹 清洗:移除 AI 误把协议 JSON(picks/intent)当作正文输出的内容
fullContent = fullContent fullContent = fullContent
// 移除 ```json ... ``` 代码块中包含 picks/intent 的部分
.replace(/```(?:json)?\s*\{[\s\S]*?"(?:picks|intent)"[\s\S]*?\}\s*```/gi, '') .replace(/```(?:json)?\s*\{[\s\S]*?"(?:picks|intent)"[\s\S]*?\}\s*```/gi, '')
// 移除裸 JSON(行首/列表项形式) {"picks":...} 或 {"intent":...}
.replace(/^\s*[\-*]?\s*\{\s*"(?:picks|intent)"[\s\S]*?\}\s*$/gim, '') .replace(/^\s*[\-*]?\s*\{\s*"(?:picks|intent)"[\s\S]*?\}\s*$/gim, '')
.replace(/\{\s*"(?:picks|intent)"\s*:[\s\S]*?\}\s*$/g, '') .replace(/\{\s*"(?:picks|intent)"\s*:[\s\S]*?\}\s*$/g, '')
.trim(); .trim();
// 若清洗后为空且无可推荐商品,给出友好兜底文案
if (!fullContent && !(Array.isArray(data.picks) && data.picks.length > 0)) { if (!fullContent && !(Array.isArray(data.picks) && data.picks.length > 0)) {
fullContent = '抱歉,目前店铺暂无符合您需求的商品。我们主要经营手机及配件,您可以试试问"推荐一款手机"~'; fullContent = '抱歉,目前店铺暂无符合您需求的商品。我们主要经营手机及配件,您可以试试问"推荐一款手机"~';
} }
...@@ -398,9 +374,8 @@ export default function ChatPage() { ...@@ -398,9 +374,8 @@ export default function ChatPage() {
highlights: data.highlights || [], highlights: data.highlights || [],
comparison: data.comparison || undefined comparison: data.comparison || undefined
}; };
if (Array.isArray(data.picks) && data.picks.length > 0) {
if (Array.isArray(data.picks) && data.picks.length > 0) {
const pickedIds = data.picks const pickedIds = data.picks
.map((p: any) => p.product_id) .map((p: any) => p.product_id)
.filter(Boolean); .filter(Boolean);
...@@ -417,7 +392,7 @@ export default function ChatPage() { ...@@ -417,7 +392,7 @@ export default function ChatPage() {
category: product.category || '未分类', category: product.category || '未分类',
stock_quantity: product.stock_quantity stock_quantity: product.stock_quantity
})); }));
fullContent += `\n\n为您推荐了 ${pickedProducts.length} 个商品`; fullContent += `\n\n为您推荐了 ${pickedProducts.length} 个商品`;
} else { } else {
console.warn('流式 picks 已返回,但未匹配到可渲染商品:', pickedIds); console.warn('流式 picks 已返回,但未匹配到可渲染商品:', pickedIds);
...@@ -437,21 +412,21 @@ export default function ChatPage() { ...@@ -437,21 +412,21 @@ export default function ChatPage() {
})); }));
} }
} }
// ✅ 保存 conversation_id:先写 ref(同步、零延迟),再持久化(登录用 DB / 访客用 localStorage) // ✅ 保存 conversation_id:先写 ref(同步、零延迟),再持久化
if (data.conversation_id && activeConversationId) { if (data.conversation_id && activeConversationId) {
difyConvIdMapRef.current[activeConversationId] = data.conversation_id; difyConvIdMapRef.current[activeConversationId] = data.conversation_id;
await updateDifyConversationId(activeConversationId, data.conversation_id); await updateDifyConversationId(activeConversationId, data.conversation_id);
} }
finalMetadata = responseMetadata; finalMetadata = responseMetadata;
setMessages(prev => prev.map(msg => setMessages(prev => prev.map(msg =>
msg.id === streamMessageId msg.id === streamMessageId
? { ...msg, content: fullContent, type: messageType, metadata: responseMetadata } ? { ...msg, content: fullContent, type: messageType, metadata: responseMetadata }
: msg : msg
)); ));
if (activeConversationId) { if (activeConversationId) {
await saveMessageLocal({ await saveMessageLocal({
id: streamMessageId, id: streamMessageId,
...@@ -466,22 +441,25 @@ export default function ChatPage() { ...@@ -466,22 +441,25 @@ export default function ChatPage() {
metadata: responseMetadata metadata: responseMetadata
}, activeConversationId); }, activeConversationId);
} }
addMemory(userId, userMessage, fullContent); addMemory(userId, userMessage, fullContent);
setStreamingMessageId(null); setStreamingMessageId(null);
setIsAiTyping(false); setIsAiTyping(false);
}, },
onError: (error: string) => { onError: (error: string) => {
console.error(`❌ [STREAM/${currentEngine}] 错误:`, error); console.error('❌ [STREAM] 错误:', error);
streamFailed = true; setMessages(prev => prev.map(msg =>
msg.id === streamMessageId
? { ...msg, content: '抱歉,AI助手暂时无法回复,请稍后再试。' }
: msg
));
setStreamingMessageId(null);
setIsAiTyping(false);
} }
}; };
// 选择引擎:Dify 优先,失败回退 Coze await streamDifyChat(
const streamFn = currentEngine === 'dify' ? streamDifyChat : streamCozeChat;
await streamFn(
{ {
message: userMessage, message: userMessage,
userId: user?.id || 'anonymous', userId: user?.id || 'anonymous',
...@@ -491,61 +469,13 @@ export default function ChatPage() { ...@@ -491,61 +469,13 @@ export default function ChatPage() {
}, },
streamCallbacks streamCallbacks
); );
// Dify 失败时回退到 Coze
if (streamFailed && currentEngine === 'dify') {
console.log('⚠️ [FALLBACK] Dify 失败,回退到 Coze...');
fullContent = '';
streamFailed = false;
setMessages(prev => prev.map(msg =>
msg.id === streamMessageId
? { ...msg, content: '正在切换到备用引擎...' }
: msg
));
await streamCozeChat(
{
message: userMessage,
userId: user?.id || 'anonymous',
conversationId: conversationIdFromDb,
userContext: userContext,
catalog
},
streamCallbacks
);
if (streamFailed) {
setMessages(prev => prev.map(msg =>
msg.id === streamMessageId
? { ...msg, content: '抱歉,AI助手暂时无法回复,请稍后再试。' }
: msg
));
setStreamingMessageId(null);
setIsAiTyping(false);
}
} else if (streamFailed) {
// Coze 也失败了
setMessages(prev => prev.map(msg =>
msg.id === streamMessageId
? { ...msg, content: '抱歉,AI助手暂时无法回复,请稍后再试。' }
: msg
));
setStreamingMessageId(null);
setIsAiTyping(false);
}
return; return;
} }
// ========== 非流式模式(Coze 非流式)========== // ========== 非流式模式 ─────────
console.log('📤 [三明治] Step 2: 调用 coze-chat (非流式)'); console.log('📤 Step 2: 非流式调用');
console.log('- 用户消息:', userMessage); const { data: intentCard, traceId, status, error: callError } = await callCozeEdge({
console.log('- 产品数量:', currentProducts.length);
console.log('- Mem0 上下文:', userContext ? '有' : '无');
console.log('- AI引擎: Coze (非流式回退)');
const apiCall = callCozeEdge;
const { data: intentCard, traceId, status, error: callError } = await apiCall({
message: userMessage, message: userMessage,
userId: user?.id || 'anonymous', userId: user?.id || 'anonymous',
conversationId: conversationIdFromDb, conversationId: conversationIdFromDb,
...@@ -558,15 +488,15 @@ export default function ChatPage() { ...@@ -558,15 +488,15 @@ export default function ChatPage() {
}, },
catalog catalog
}); });
console.log('📥 响应结果'); console.log('📥 响应结果');
console.log('- AI引擎: Coze'); console.log('- AI引擎: ai-hub');
console.log('- trace_id:', traceId); console.log('- trace_id:', traceId);
console.log('- status:', status); console.log('- status:', status);
console.log('- 原始 intentCard:', JSON.stringify(intentCard, null, 2)); console.log('- 原始 intentCard:', JSON.stringify(intentCard, null, 2));
if (callError || !intentCard) { if (callError || !intentCard) {
console.error('Coze API调用失败:', callError); console.error('AI API调用失败:', callError);
const errorMsg = callError || `系统繁忙,请稍后再试(追踪码:${traceId})`; const errorMsg = callError || `系统繁忙,请稍后再试(追踪码:${traceId})`;
toast.error("AI回复失败", { toast.error("AI回复失败", {
description: errorMsg, description: errorMsg,
...@@ -952,7 +882,7 @@ export default function ChatPage() { ...@@ -952,7 +882,7 @@ export default function ChatPage() {
} finally { } finally {
setIsAiTyping(false); setIsAiTyping(false);
} }
}, [currentAgent, products, orders, fetchProducts, fetchOrders, activeConversationId, saveMessageLocal, user, chatEngineStrategy, useStream]); }, [currentAgent, products, orders, fetchProducts, fetchOrders, activeConversationId, saveMessageLocal, user, chatModel, useStream]);
// 处理发送消息 // 处理发送消息
const handleSendMessage = useCallback(async (content: string) => { const handleSendMessage = useCallback(async (content: string) => {
......
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { flushSync } from "react-dom";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { ProductCard } from "@/components/chat/ProductCard"; import { ProductCard } from "@/components/chat/ProductCard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -305,16 +306,32 @@ export default function ShopPage() { ...@@ -305,16 +306,32 @@ export default function ShopPage() {
// AI response handler // AI response handler
const simulateAIResponse = useCallback(async (userMessage: string) => { const simulateAIResponse = useCallback(async (userMessage: string) => {
setIsAiTyping(true); setIsAiTyping(true);
const streamMsgId = `ai_${Date.now()}`;
// 先插入一个占位消息,后续流式更新
const placeholderMessage: ChatMessageType = {
id: streamMsgId,
content: '',
sender: "ai",
timestamp: new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
}),
type: "text",
agent: currentAgent,
};
setMessages(prev => [...prev, placeholderMessage]);
try { try {
let currentProducts = products; let currentProducts = products;
if (currentProducts.length === 0) { if (currentProducts.length === 0) {
currentProducts = await fetchProducts(); currentProducts = await fetchProducts();
} }
if (currentProducts.length === 0) { if (currentProducts.length === 0) {
throw new Error('产品数据加载失败'); throw new Error('产品数据加载失败');
} }
const catalog = currentProducts.map(p => ({ const catalog = currentProducts.map(p => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
...@@ -323,7 +340,9 @@ export default function ShopPage() { ...@@ -323,7 +340,9 @@ export default function ShopPage() {
category: p.category, category: p.category,
description: p.description description: p.description
})); }));
// ─── 流式接收 AI 回复文字 ───
let fullContent = '';
const { data: intentCard } = await callCozeEdge({ const { data: intentCard } = await callCozeEdge({
message: userMessage, message: userMessage,
userId: user?.id || 'anonymous', userId: user?.id || 'anonymous',
...@@ -333,21 +352,38 @@ export default function ShopPage() { ...@@ -333,21 +352,38 @@ export default function ShopPage() {
description: currentProducts[0].description || '', description: currentProducts[0].description || '',
price: Number(currentProducts[0].price) price: Number(currentProducts[0].price)
}, },
catalog catalog,
onText: (text: string) => {
fullContent += text;
flushSync(() => {
setMessages(prev => prev.map(msg =>
msg.id === streamMsgId
? { ...msg, content: fullContent }
: msg
));
});
}
}); });
if (!intentCard) throw new Error('系统繁忙,请稍后再试'); if (!intentCard) throw new Error('系统繁忙,请稍后再试');
let aiContent = intentCard.title || '收到您的消息'; // 清洗 JSON 代码块,避免裸露协议数据出现在正文
let aiContent = fullContent
if (intentCard.highlights && intentCard.highlights.length > 0) { .replace(/```(?:json)?\s*\{[\s\S]*?"(?:picks|intent)"[\s\S]*?\}\s*```/gi, '')
aiContent += '\n\n' + intentCard.highlights.map(h => `• ${h}`).join('\n'); .replace(/\{\s*"(?:picks|intent)"\s*:[\s\S]*?\}\s*$/g, '')
.trim();
if (!aiContent) {
aiContent = intentCard.title || '收到您的消息';
if (intentCard.highlights && intentCard.highlights.length > 0) {
aiContent += '\n\n' + intentCard.highlights.map(h => `• ${h}`).join('\n');
}
} }
if (intentCard.picks && intentCard.picks.length > 0) { if (intentCard.picks && intentCard.picks.length > 0) {
aiContent += '\n\n为您推荐了 ' + intentCard.picks.length + ' 个商品'; aiContent += '\n\n为您推荐了 ' + intentCard.picks.length + ' 个商品';
} }
let messageType: ChatMessageType['type'] = "text"; let messageType: ChatMessageType['type'] = "text";
let responseMetadata: ChatMessageType['metadata'] = {}; let responseMetadata: ChatMessageType['metadata'] = {};
...@@ -370,50 +406,39 @@ export default function ShopPage() { ...@@ -370,50 +406,39 @@ export default function ShopPage() {
})) }))
}; };
} }
const aiMessage: ChatMessageType = { // 更新占位消息为最终内容 + 类型 + 元数据
id: `ai_${Date.now()}`, setMessages(prev => prev.map(msg =>
content: aiContent, msg.id === streamMsgId
sender: "ai", ? { ...msg, content: aiContent, type: messageType, metadata: responseMetadata }
timestamp: new Date().toLocaleTimeString('zh-CN', { : msg
hour: '2-digit', ));
minute: '2-digit'
}),
type: messageType,
agent: currentAgent,
metadata: responseMetadata
};
setMessages(prev => [...prev, aiMessage]);
if (activeConversationId) { if (activeConversationId) {
await saveMessage({ await saveMessage({
content: aiMessage.content, content: aiContent,
sender_type: aiMessage.sender, sender_type: "ai",
message_type: aiMessage.type as DBMessage['message_type'], message_type: messageType as DBMessage['message_type'],
metadata: aiMessage.metadata metadata: responseMetadata
}, activeConversationId); }, activeConversationId);
} }
} catch (error) { } catch (error) {
console.error('AI回复失败:', error); console.error('AI回复失败:', error);
const errorMessage: ChatMessageType = { // 把占位消息替换为错误消息
id: `ai_error_${Date.now()}`, setMessages(prev => prev.map(msg =>
content: `抱歉,遇到了技术问题,请稍后再试。`, msg.id === streamMsgId
sender: "ai", ? { ...msg, content: '抱歉,遇到了技术问题,请稍后再试。' }
timestamp: new Date().toLocaleTimeString('zh-CN', { : msg
hour: '2-digit', ));
minute: '2-digit'
}),
type: "text",
agent: currentAgent
};
setMessages(prev => [...prev, errorMessage]);
} finally { } finally {
setIsAiTyping(false); setIsAiTyping(false);
} }
}, [currentAgent, products, activeConversationId, saveMessage, user]); }, [currentAgent, products, activeConversationId, saveMessage, user]);
const handleSendMessage = useCallback(async (content: string) => { const handleSendMessage = useCallback(async (content: string) => {
// 防止并发流式调用(如快速连点建议按钮)
if (isAiTyping) return;
const userMessage: ChatMessageType = { const userMessage: ChatMessageType = {
id: `user_${Date.now()}`, id: `user_${Date.now()}`,
content, content,
...@@ -437,7 +462,7 @@ export default function ShopPage() { ...@@ -437,7 +462,7 @@ export default function ShopPage() {
} }
await simulateAIResponse(content); await simulateAIResponse(content);
}, [simulateAIResponse, activeConversationId, saveMessage]); }, [simulateAIResponse, activeConversationId, saveMessage, isAiTyping]);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
......
/** /**
* Consumer Chat Edge Function Client * Consumer Chat Edge Function Client
* C端消费者聊天 - 统一调用入口 * C端消费者聊天 —— 直接大模型调用(AI Hub)
*
* 注意:callCozeEdge 是历史兼容名称;当前优先走 dify-chat-stream。
*/ */
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
...@@ -59,7 +57,11 @@ type StreamDonePayload = Partial<CozeResponse> & { ...@@ -59,7 +57,11 @@ type StreamDonePayload = Partial<CozeResponse> & {
type?: 'done'; type?: 'done';
}; };
function processDifyStreamLine(line: string, state: { fullContent: string; doneData: StreamDonePayload | null }) { function processStreamLine(
line: string,
state: { fullContent: string; doneData: StreamDonePayload | null },
onText?: (text: string) => void
) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith(':') || trimmedLine === 'data: [DONE]') return; if (!trimmedLine || trimmedLine.startsWith(':') || trimmedLine === 'data: [DONE]') return;
if (!trimmedLine.startsWith('data:')) return; if (!trimmedLine.startsWith('data:')) return;
...@@ -68,29 +70,37 @@ function processDifyStreamLine(line: string, state: { fullContent: string; doneD ...@@ -68,29 +70,37 @@ function processDifyStreamLine(line: string, state: { fullContent: string; doneD
try { try {
const event = JSON.parse(jsonStr); const event = JSON.parse(jsonStr);
if (event.type === 'text') { if (event.type === 'text') {
state.fullContent += event.content || ''; const content = event.content || '';
state.fullContent += content;
onText?.(content);
} else if (event.type === 'done') { } else if (event.type === 'done') {
state.doneData = event; state.doneData = event;
} else if (event.type === 'error') { } else if (event.type === 'error') {
throw new Error(event.message || 'Dify 流式响应失败'); throw new Error(event.message || 'AI 流式响应失败');
} }
} catch (error) { } catch (error) {
if (error instanceof Error && !jsonStr.startsWith('{')) { // JSON.parse 抛出 SyntaxError,应静默跳过该行
state.fullContent += jsonStr; // 只有业务层主动抛出的 Error 才向上传播(如 type==='error' 事件)
if (error instanceof SyntaxError) {
if (jsonStr && !jsonStr.startsWith('{')) {
state.fullContent += jsonStr;
}
return; return;
} }
throw error; throw error;
} }
} }
export async function callCozeEdge(options: CozeRequestOptions): Promise<{ export async function callCozeEdge(
options: CozeRequestOptions & { onText?: (text: string) => void }
): Promise<{
data: CozeResponse | null; data: CozeResponse | null;
traceId: string; traceId: string;
status: number; status: number;
error?: string; error?: string;
}> { }> {
const traceId = crypto.randomUUID(); const traceId = crypto.randomUUID();
const requestBody = { const requestBody = {
message: options.message, message: options.message,
conversationId: options.conversationId || '', conversationId: options.conversationId || '',
...@@ -102,115 +112,106 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -102,115 +112,106 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
catalog: options.catalog || [] catalog: options.catalog || []
} }
}; };
try { try {
console.log('🚀 [CHAT CLIENT] 开始调用 dify-chat-stream'); console.log('🚀 [CHAT CLIENT] 调用 ai-chat-stream');
console.log('📤 请求体:', JSON.stringify(requestBody, null, 2)); console.log('📤 消息:', options.message?.substring(0, 60));
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
console.log('🔐 用户登录状态:', session ? '已登录' : '未登录');
let lastError: any = null;
let data: any = null;
let error: any = null;
// 重试逻辑 // 单次调用 + 超时保护(65秒)
for (let attempt = 1; attempt <= 2; attempt++) { const invokePromise = fetch(`${SUPABASE_URL}/functions/v1/dify-chat-stream`, {
console.log(`🔄 尝试 ${attempt}/2...`); method: 'POST',
headers: {
try { 'Content-Type': 'application/json',
const invokePromise = fetch(`${SUPABASE_URL}/functions/v1/dify-chat-stream`, { 'Authorization': `Bearer ${session?.access_token || SUPABASE_ANON_KEY}`,
method: 'POST', 'X-Trace-Id': traceId,
headers: { 'X-User-Id': options.userId || 'guest'
'Content-Type': 'application/json', },
'Authorization': `Bearer ${session?.access_token || SUPABASE_ANON_KEY}`, body: JSON.stringify(requestBody)
'X-Trace-Id': traceId, });
'X-User-Id': options.userId || 'guest'
},
body: JSON.stringify(requestBody)
});
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('AI响应超时,请重新发送消息')), 65000);
});
const response = await Promise.race([invokePromise, timeoutPromise]);
if (!response.ok) {
throw new Error(`Dify 请求失败 (${response.status}): ${await response.text()}`);
}
if (!response.body) {
throw new Error('响应体为空');
}
const reader = response.body.getReader(); const timeoutPromise = new Promise<never>((_, reject) => {
const decoder = new TextDecoder(); setTimeout(() => reject(new Error('AI响应超时,请重新发送消息')), 65000);
const state = { fullContent: '', doneData: null as StreamDonePayload | null }; });
let buffer = '';
while (true) { const response = await Promise.race([invokePromise, timeoutPromise]);
const { done, value } = await reader.read();
if (done) {
if (buffer.trim()) processDifyStreamLine(buffer, state);
break;
}
buffer += decoder.decode(value, { stream: true }); if (!response.ok) {
const lines = buffer.split('\n'); const errorText = await response.text();
buffer = lines.pop() || ''; console.error('❌ [CHAT CLIENT] 请求失败:', response.status, errorText);
for (const line of lines) processDifyStreamLine(line, state); throw new Error(`请求失败 (${response.status}): ${errorText.substring(0, 200)}`);
} }
data = { if (!response.body) {
...(state.doneData || {}), throw new Error('响应体为空');
title: state.doneData?.title || options.message, }
answer: state.fullContent,
}; // ─── 读取 SSE 流 ───
error = null; const reader = response.body.getReader();
lastError = null; const decoder = new TextDecoder();
const onText = options.onText;
const state = { fullContent: '', doneData: null as StreamDonePayload | null };
let buffer = '';
// rAF 批量包装 onText:每帧最多触发一次,确保浏览器在帧之间有机会绘制
// 根因:await setTimeout(0) 从 Promise 延续中创建不触发 4ms 嵌套惩罚,
// 全部回调在 <1ms 内触发完毕,浏览器只在 VSync 边界绘制一次 → 用户看不到流式效果
let pendingText = '';
let rafId: number | null = null;
const batchedOnText = onText ? (text: string) => {
pendingText += text;
if (rafId === null) {
rafId = requestAnimationFrame(() => {
if (pendingText) {
onText(pendingText);
pendingText = '';
}
rafId = null;
});
}
} : undefined;
if (data) { try {
while (true) {
const { done, value } = await reader.read();
if (done) {
if (buffer.trim()) processStreamLine(buffer, state, batchedOnText);
break; break;
} }
lastError = error; buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
if (attempt === 1) { buffer = lines.pop() || '';
console.log('⏳ Edge Function 可能正在启动,2秒后重试...'); for (const line of lines) {
await new Promise(resolve => setTimeout(resolve, 2000)); processStreamLine(line, state, batchedOnText);
}
} catch (e) {
lastError = e;
console.error(`❌ 尝试 ${attempt} 失败:`, e);
if (attempt === 1) {
await new Promise(resolve => setTimeout(resolve, 2000));
} }
} }
} } finally {
// 确保 rAF 回调被取消、剩余文本被刷新,即使 processStreamLine 抛出异常也不会泄漏
error = error || lastError; if (rafId !== null) {
cancelAnimationFrame(rafId);
if (error) { rafId = null;
console.error('❌ Dify Chat Functions 错误:', error);
const errorMessage = error.message || String(error);
let friendlyMessage = '抱歉,系统暂时繁忙';
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('NetworkError')) {
friendlyMessage = '网络连接不稳定,请重新发送消息试试';
} else if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
friendlyMessage = 'AI响应超时(65秒),请重新发送消息';
} }
if (pendingText && onText) {
onText(pendingText);
pendingText = '';
}
}
const rawData = { ...(state.doneData || {}), answer: state.fullContent };
if (rawData.error) {
return { return {
data: null, data: null,
traceId, traceId: rawData.meta?.probe_id || traceId,
status: 0, status: 200,
error: friendlyMessage error: `系统繁忙,请稍后再试(追踪码:${rawData.meta?.probe_id || traceId}`
}; };
} }
if (!data) { if (!state.doneData && !state.fullContent) {
return { return {
data: null, data: null,
traceId, traceId,
...@@ -218,20 +219,9 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -218,20 +219,9 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
error: '响应数据为空' error: '响应数据为空'
}; };
} }
console.log('✅ 成功获取 Dify 响应'); console.log('✅ [CHAT CLIENT] 成功获取响应, picks:', rawData.picks?.length || 0);
const rawData = data as any;
if (rawData.error) {
return {
data: null,
traceId: rawData.meta?.probe_id || traceId,
status: 200,
error: `系统繁忙,请稍后再试(追踪码:${rawData.meta?.probe_id || traceId}`
};
}
const cozeResponse: CozeResponse = { const cozeResponse: CozeResponse = {
title: rawData.title || options.message, title: rawData.title || options.message,
intent: rawData.intent || "GENERAL", intent: rawData.intent || "GENERAL",
...@@ -241,7 +231,7 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -241,7 +231,7 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
comparison: rawData.comparison, comparison: rawData.comparison,
next: rawData.next || [], next: rawData.next || [],
meta: { meta: {
platform: rawData.meta?.platform || 'dify-stream', platform: rawData.meta?.platform || 'ai-hub',
user: rawData.meta?.user || options.userId, user: rawData.meta?.user || options.userId,
catalog_count: rawData.meta?.catalog_count ?? 0, catalog_count: rawData.meta?.catalog_count ?? 0,
probe_id: rawData.meta?.probe_id || traceId probe_id: rawData.meta?.probe_id || traceId
...@@ -249,27 +239,32 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -249,27 +239,32 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
conversation_id: rawData.conversation_id, conversation_id: rawData.conversation_id,
answer: rawData.answer answer: rawData.answer
}; };
console.log('📊 Dify 响应:', { return {
platform: cozeResponse.meta.platform, data: cozeResponse,
picks: cozeResponse.picks.length, traceId: cozeResponse.meta.probe_id,
conversation_id: cozeResponse.conversation_id || '(new)'
});
return {
data: cozeResponse,
traceId: cozeResponse.meta.probe_id,
status: 200 status: 200
}; };
} catch (error) { } catch (error) {
console.error('❌ 调用 Coze Edge Function 失败:', error); console.error('❌ [CHAT CLIENT] 调用失败:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
let friendlyMessage = '抱歉,系统暂时繁忙';
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('NetworkError')) {
friendlyMessage = '网络连接不稳定,请重新发送消息试试';
} else if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
friendlyMessage = 'AI响应超时,请重新发送消息';
} else if (errorMessage.includes('AI_HUB') || errorMessage.includes('LOVABLE')) {
friendlyMessage = 'AI服务未配置,请联系管理员';
}
return { return {
data: null, data: null,
traceId, traceId,
status: 0, status: 0,
error: '网络连接不稳定,请重新发送消息试试' error: friendlyMessage
}; };
} }
} }
...@@ -51,6 +51,9 @@ export async function streamDifyChat( ...@@ -51,6 +51,9 @@ export async function streamDifyChat(
try { try {
console.log('🚀 [DIFY STREAM] 开始流式调用'); console.log('🚀 [DIFY STREAM] 开始流式调用');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 65000);
const response = await fetch(`${SUPABASE_URL}/functions/v1/dify-chat-stream`, { const response = await fetch(`${SUPABASE_URL}/functions/v1/dify-chat-stream`, {
method: 'POST', method: 'POST',
headers: { headers: {
...@@ -60,8 +63,11 @@ export async function streamDifyChat( ...@@ -60,8 +63,11 @@ export async function streamDifyChat(
'X-User-Id': options.userId || 'guest', 'X-User-Id': options.userId || 'guest',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
signal: controller.signal,
}); });
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`Dify 请求失败 (${response.status}): ${errorText}`); throw new Error(`Dify 请求失败 (${response.status}): ${errorText}`);
...@@ -79,7 +85,7 @@ export async function streamDifyChat( ...@@ -79,7 +85,7 @@ export async function streamDifyChat(
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { if (done) {
if (buffer.trim()) { if (buffer.trim()) {
processSSELine(buffer, callbacks); await processSSELine(buffer, callbacks);
} }
break; break;
} }
...@@ -89,18 +95,22 @@ export async function streamDifyChat( ...@@ -89,18 +95,22 @@ export async function streamDifyChat(
buffer = lines.pop() || ''; buffer = lines.pop() || '';
for (const line of lines) { for (const line of lines) {
processSSELine(line, callbacks); await processSSELine(line, callbacks);
} }
} }
console.log('✅ [DIFY STREAM] 流式调用完成'); console.log('✅ [DIFY STREAM] 流式调用完成');
} catch (error) { } catch (error) {
console.error('❌ [DIFY STREAM] 错误:', error); console.error('❌ [DIFY STREAM] 错误:', error);
callbacks.onError(error instanceof Error ? error.message : '流式请求失败'); if (error instanceof DOMException && error.name === 'AbortError') {
callbacks.onError('AI响应超时(65秒),请重新发送消息');
} else {
callbacks.onError(error instanceof Error ? error.message : '流式请求失败');
}
} }
} }
function processSSELine(line: string, callbacks: CozeStreamCallbacks) { async function processSSELine(line: string, callbacks: CozeStreamCallbacks) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith(':')) return; if (!trimmedLine || trimmedLine.startsWith(':')) return;
if (trimmedLine === 'data: [DONE]') return; if (trimmedLine === 'data: [DONE]') return;
...@@ -114,15 +124,20 @@ function processSSELine(line: string, callbacks: CozeStreamCallbacks) { ...@@ -114,15 +124,20 @@ function processSSELine(line: string, callbacks: CozeStreamCallbacks) {
callbacks.onText(event.content); callbacks.onText(event.content);
break; break;
case 'done': case 'done':
callbacks.onDone(event); await callbacks.onDone(event);
break; break;
case 'error': case 'error':
callbacks.onError(event.message); callbacks.onError(event.message);
break; break;
} }
} catch (e) { } catch (e) {
// JSON.parse SyntaxError or callback rejection — safe fallback
if (jsonStr && !jsonStr.startsWith('{')) { if (jsonStr && !jsonStr.startsWith('{')) {
callbacks.onText(jsonStr); try {
callbacks.onText(jsonStr);
} catch {
// onText itself failed; nothing more we can do
}
} }
} }
} }
......
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