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

前端代码修复

parent 77a77a8f
This diff is collapsed.
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,6 +306,22 @@ export default function ShopPage() { ...@@ -305,6 +306,22 @@ 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) {
...@@ -324,6 +341,8 @@ export default function ShopPage() { ...@@ -324,6 +341,8 @@ export default function ShopPage() {
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,16 +352,33 @@ export default function ShopPage() { ...@@ -333,16 +352,33 @@ 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
.replace(/```(?:json)?\s*\{[\s\S]*?"(?:picks|intent)"[\s\S]*?\}\s*```/gi, '')
.replace(/\{\s*"(?:picks|intent)"\s*:[\s\S]*?\}\s*$/g, '')
.trim();
if (!aiContent) {
aiContent = intentCard.title || '收到您的消息';
if (intentCard.highlights && intentCard.highlights.length > 0) { if (intentCard.highlights && intentCard.highlights.length > 0) {
aiContent += '\n\n' + intentCard.highlights.map(h => `• ${h}`).join('\n'); 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 + ' 个商品';
...@@ -371,49 +407,38 @@ export default function ShopPage() { ...@@ -371,49 +407,38 @@ 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,22 +70,30 @@ function processDifyStreamLine(line: string, state: { fullContent: string; doneD ...@@ -68,22 +70,30 @@ 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,应静默跳过该行
// 只有业务层主动抛出的 Error 才向上传播(如 type==='error' 事件)
if (error instanceof SyntaxError) {
if (jsonStr && !jsonStr.startsWith('{')) {
state.fullContent += jsonStr; 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;
...@@ -104,21 +114,12 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -104,21 +114,12 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
}; };
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++) {
console.log(`🔄 尝试 ${attempt}/2...`);
try {
const invokePromise = fetch(`${SUPABASE_URL}/functions/v1/dify-chat-stream`, { const invokePromise = fetch(`${SUPABASE_URL}/functions/v1/dify-chat-stream`, {
method: 'POST', method: 'POST',
headers: { headers: {
...@@ -135,82 +136,82 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -135,82 +136,82 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
}); });
const response = await Promise.race([invokePromise, timeoutPromise]); const response = await Promise.race([invokePromise, timeoutPromise]);
if (!response.ok) { if (!response.ok) {
throw new Error(`Dify 请求失败 (${response.status}): ${await response.text()}`); const errorText = await response.text();
console.error('❌ [CHAT CLIENT] 请求失败:', response.status, errorText);
throw new Error(`请求失败 (${response.status}): ${errorText.substring(0, 200)}`);
} }
if (!response.body) { if (!response.body) {
throw new Error('响应体为空'); throw new Error('响应体为空');
} }
// ─── 读取 SSE 流 ───
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const onText = options.onText;
const state = { fullContent: '', doneData: null as StreamDonePayload | null }; const state = { fullContent: '', doneData: null as StreamDonePayload | null };
let buffer = ''; 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;
try {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { if (done) {
if (buffer.trim()) processDifyStreamLine(buffer, state); if (buffer.trim()) processStreamLine(buffer, state, batchedOnText);
break; break;
} }
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n'); const lines = buffer.split('\n');
buffer = lines.pop() || ''; buffer = lines.pop() || '';
for (const line of lines) processDifyStreamLine(line, state); for (const line of lines) {
processStreamLine(line, state, batchedOnText);
} }
data = {
...(state.doneData || {}),
title: state.doneData?.title || options.message,
answer: state.fullContent,
};
error = null;
lastError = null;
if (data) {
break;
} }
} finally {
lastError = error; // 确保 rAF 回调被取消、剩余文本被刷新,即使 processStreamLine 抛出异常也不会泄漏
if (rafId !== null) {
if (attempt === 1) { cancelAnimationFrame(rafId);
console.log('⏳ Edge Function 可能正在启动,2秒后重试...'); rafId = null;
await new Promise(resolve => setTimeout(resolve, 2000));
} }
} catch (e) { if (pendingText && onText) {
lastError = e; onText(pendingText);
console.error(`❌ 尝试 ${attempt} 失败:`, e); pendingText = '';
if (attempt === 1) {
await new Promise(resolve => setTimeout(resolve, 2000));
} }
} }
}
error = error || lastError;
if (error) {
console.error('❌ Dify Chat Functions 错误:', error);
const errorMessage = error.message || String(error); const rawData = { ...(state.doneData || {}), answer: state.fullContent };
let friendlyMessage = '抱歉,系统暂时繁忙';
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('NetworkError')) {
friendlyMessage = '网络连接不稳定,请重新发送消息试试';
} else if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
friendlyMessage = 'AI响应超时(65秒),请重新发送消息';
}
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,
...@@ -219,18 +220,7 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -219,18 +220,7 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
}; };
} }
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,
...@@ -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
...@@ -250,12 +240,6 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -250,12 +240,6 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
answer: rawData.answer answer: rawData.answer
}; };
console.log('📊 Dify 响应:', {
platform: cozeResponse.meta.platform,
picks: cozeResponse.picks.length,
conversation_id: cozeResponse.conversation_id || '(new)'
});
return { return {
data: cozeResponse, data: cozeResponse,
traceId: cozeResponse.meta.probe_id, traceId: cozeResponse.meta.probe_id,
...@@ -263,13 +247,24 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{ ...@@ -263,13 +247,24 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
}; };
} 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);
if (error instanceof DOMException && error.name === 'AbortError') {
callbacks.onError('AI响应超时(65秒),请重新发送消息');
} else {
callbacks.onError(error instanceof Error ? error.message : '流式请求失败'); 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('{')) {
try {
callbacks.onText(jsonStr); 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