'use client';
|
|
import { useState, useEffect, useRef, useCallback, useContext, createContext } from 'react';
|
import Link from 'next/link';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
import Image from 'next/image';
|
import ReactMarkdown from 'react-markdown';
|
import remarkGfm from 'remark-gfm';
|
import rehypeRaw from 'rehype-raw';
|
import rehypeSanitize from 'rehype-sanitize';
|
import dynamic from 'next/dynamic';
|
import { ReactNode } from 'react';
|
|
// 创建一个消息完成状态的Context
|
const MessageCompletionContext = createContext<boolean>(true);
|
|
// 动态导入 ECharts,确保它只在客户端渲染
|
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
|
|
interface Message {
|
role: 'user' | 'assistant';
|
content: string | null;
|
timestamp: number;
|
id: string;
|
conversation_id?: string;
|
feedback?: 'like' | 'dislike' | null;
|
metadata?: {
|
usage?: {
|
prompt_tokens: number;
|
completion_tokens: number;
|
total_tokens: number;
|
total_price: string;
|
};
|
};
|
}
|
|
const BASE_URL = 'http://121.43.139.99:7000';
|
|
// 默认用户头像
|
const DEFAULT_USER_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23999999'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/%3E%3C/svg%3E";
|
const AI_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%234F46E5'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 1c4.96 0 9 4.04 9 9s-4.04 9-9 9-9-4.04-9-9 4.04-9 9-9zm0 3.5c-2.48 0-4.5 2.02-4.5 4.5h1.5c0-1.66 1.34-3 3-3s3 1.34 3 3h1.5c0-2.48-2.02-4.5-4.5-4.5zM8 13h2v2H8v-2zm6 0h2v2h-2v-2z'/%3E%3C/svg%3E";
|
|
// 添加用于解析和渲染 ECharts 的组件
|
function EchartsRenderer({ code }: { code: string }) {
|
const [error, setError] = useState<string | null>(null);
|
const [isLoading, setIsLoading] = useState(true);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
const chartContainerRef = useRef<HTMLDivElement>(null);
|
const modalChartRef = useRef<HTMLDivElement>(null);
|
const hasRenderedRef = useRef<boolean>(false);
|
const chartInstanceRef = useRef<any>(null);
|
|
// 初始化DOM容器 - 提前设置固定高度
|
useEffect(() => {
|
// 确保容器准备好了并且有确定的高度
|
if (chartContainerRef.current) {
|
// 设置一个固定的高度,避免渲染后变化
|
const container = chartContainerRef.current;
|
container.style.width = '100%';
|
container.style.height = '400px'; // 固定高度400px
|
container.style.minHeight = '400px'; // 防止高度变小
|
|
// 添加临时内容,确保DOM渲染完成
|
container.innerHTML = '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#f5f7f9;"><span>正在准备图表...</span></div>';
|
}
|
}, []);
|
|
// 一次性加载图表,不进行动态更新
|
useEffect(() => {
|
// 如果已经渲染过,跳过
|
if (hasRenderedRef.current) {
|
return;
|
}
|
|
setIsLoading(true);
|
|
// 确保DOM元素已经完全挂载和渲染
|
const timer = setTimeout(() => {
|
// 确保DOM元素仍然存在
|
if (!chartContainerRef.current) {
|
console.error('图表容器不存在,跳过初始化');
|
setIsLoading(false);
|
return;
|
}
|
|
// 清除临时内容
|
chartContainerRef.current.innerHTML = '';
|
|
// 标记为已渲染
|
hasRenderedRef.current = true;
|
|
// 初始化图表
|
const initializeChart = async () => {
|
try {
|
const echarts = await import('echarts');
|
|
// 初始化图表
|
const chartInstance = echarts.init(chartContainerRef.current);
|
chartInstanceRef.current = chartInstance;
|
|
// 尝试解析和设置图表选项
|
try {
|
const safeCode = code.replace(/window\.option/g, 'option');
|
const safeFunc = new Function(`
|
"use strict";
|
let option;
|
try {
|
${safeCode}
|
return option;
|
} catch (e) {
|
console.error("图表代码执行错误:", e);
|
return null;
|
}
|
`);
|
|
const chartOption = safeFunc();
|
|
if (chartOption) {
|
// 禁用动画,避免渲染时的布局变化
|
chartOption.animation = false;
|
|
// 修改tooltip配置,确保显示在适当位置
|
if (chartOption.tooltip) {
|
chartOption.tooltip = {
|
...chartOption.tooltip,
|
confine: false, // 不限制在图表区域内
|
extraCssText: 'z-index:9999; pointer-events:auto; margin-top:0;'
|
};
|
}
|
|
chartInstance.setOption(chartOption);
|
setError(null);
|
} else {
|
throw new Error("无法获取图表配置");
|
}
|
} catch (e) {
|
console.error('图表代码执行错误:', e);
|
setError('图表配置错误');
|
|
// 设置一个默认图表以显示错误
|
chartInstance.setOption({
|
title: { text: '图表配置错误' },
|
xAxis: { type: 'category', data: ['错误'] },
|
yAxis: { type: 'value' },
|
series: [{ data: [0], type: 'bar' }]
|
});
|
}
|
|
// 添加窗口大小变化监听器
|
const handleResize = () => chartInstance.resize();
|
window.addEventListener('resize', handleResize);
|
|
// 清理函数
|
return () => {
|
window.removeEventListener('resize', handleResize);
|
chartInstance.dispose();
|
};
|
} catch (e) {
|
console.error('ECharts加载失败:', e);
|
setError('图表库加载失败');
|
} finally {
|
setIsLoading(false);
|
}
|
};
|
|
initializeChart();
|
}, 500); // 增加延迟,确保DOM完全渲染
|
|
return () => clearTimeout(timer);
|
}, [code]);
|
|
// 当全屏状态变化时重新调整图表大小
|
useEffect(() => {
|
if (chartInstanceRef.current) {
|
setTimeout(() => {
|
chartInstanceRef.current.resize();
|
}, 300); // 给DOM一些时间来更新
|
}
|
}, [isModalOpen]);
|
|
// 处理全屏切换
|
const toggleModal = useCallback(() => {
|
setIsModalOpen(prev => !prev);
|
}, []);
|
|
// 处理模态窗口的图表
|
useEffect(() => {
|
if (!isModalOpen || !modalChartRef.current) return;
|
|
const initModalChart = async () => {
|
try {
|
const echarts = await import('echarts');
|
|
// 初始化模态窗口中的图表
|
const modalChartInstance = echarts.init(modalChartRef.current);
|
|
// 如果主图表已经初始化,复用其配置
|
if (chartInstanceRef.current) {
|
const option = chartInstanceRef.current.getOption();
|
|
// 重新设置图表配置,确保在新容器中正确显示
|
modalChartInstance.setOption(option);
|
|
// 监听窗口大小变化
|
const handleResize = () => modalChartInstance.resize();
|
window.addEventListener('resize', handleResize);
|
|
// 清理函数
|
return () => {
|
window.removeEventListener('resize', handleResize);
|
modalChartInstance.dispose();
|
};
|
}
|
} catch (e) {
|
console.error('模态图表初始化失败:', e);
|
}
|
};
|
|
initModalChart();
|
}, [isModalOpen]);
|
|
return (
|
<>
|
<div className="w-full bg-gray-50 rounded-lg overflow-hidden border border-gray-200">
|
{/* 图表工具栏 */}
|
<div className="bg-white px-4 py-2 border-b border-gray-200 flex justify-between items-center">
|
<span className="text-sm font-medium text-gray-700">数据图表</span>
|
<div className="flex items-center space-x-2">
|
{error && (
|
<span className="text-xs text-red-500">{error}</span>
|
)}
|
<button
|
onClick={toggleModal}
|
className="p-1 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
title="全屏查看"
|
>
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
</svg>
|
</button>
|
</div>
|
</div>
|
|
{/* 图表容器 */}
|
<div className="relative">
|
<div
|
ref={chartContainerRef}
|
className="w-full"
|
style={{ height: '400px', minHeight: '400px' }}
|
/>
|
{isLoading && (
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-50/80">
|
<div className="flex items-center space-x-2 text-gray-500">
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent"></div>
|
<span className="text-sm">加载中...</span>
|
</div>
|
</div>
|
)}
|
</div>
|
</div>
|
|
{/* 全屏模态窗口 */}
|
{isModalOpen && (
|
<div className="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center p-4">
|
<div className="bg-white rounded-lg w-full h-full max-w-6xl max-h-[90vh] flex flex-col">
|
{/* 模态窗口头部 */}
|
<div className="flex justify-between items-center p-4 border-b border-gray-200">
|
<h3 className="text-lg font-semibold text-gray-900">图表详细视图</h3>
|
<button
|
onClick={toggleModal}
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
>
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
</svg>
|
</button>
|
</div>
|
|
{/* 模态窗口图表容器 */}
|
<div className="flex-1 p-4">
|
<div
|
ref={modalChartRef}
|
className="w-full h-full"
|
/>
|
</div>
|
</div>
|
</div>
|
)}
|
</>
|
);
|
}
|
|
function CodeBlockRenderer({ language, value }: { language: string; value: string }) {
|
// 检查是否是 ECharts 代码
|
if (language === 'echarts' || language === 'javascript' && value.includes('option')) {
|
return <EchartsRenderer code={value} />;
|
}
|
|
return (
|
<div className="relative bg-gray-50 rounded-lg overflow-hidden border border-gray-200">
|
<div className="bg-white px-4 py-2 border-b border-gray-200 flex justify-between items-center">
|
<span className="text-sm font-medium text-gray-700">{language || '代码'}</span>
|
<button
|
onClick={() => navigator.clipboard.writeText(value)}
|
className="p-1 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
title="复制代码"
|
>
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
</svg>
|
</button>
|
</div>
|
<pre className="p-4 overflow-x-auto">
|
<code className={`language-${language}`}>
|
{value}
|
</code>
|
</pre>
|
</div>
|
);
|
}
|
|
interface ChatInputProps {
|
onSendMessage: () => void;
|
isStreaming: boolean;
|
isMessageComplete: boolean;
|
}
|
|
function ChatInput({ onSendMessage, isStreaming, isMessageComplete }: ChatInputProps) {
|
// 内部状态,与外部完全隔离
|
const [inputText, setInputText] = useState('');
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
// 处理输入变化
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
setInputText(e.target.value);
|
};
|
|
// 处理按键事件
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
e.preventDefault();
|
handleSend();
|
}
|
};
|
|
// 处理发送
|
const handleSend = () => {
|
if (isStreaming || !inputText.trim() || !isMessageComplete) return;
|
|
// 通知父组件发送消息前更新消息内容
|
(window as any).messageToSend = inputText.trim();
|
|
// 清空输入
|
setInputText('');
|
|
// 调用父组件的发送方法
|
onSendMessage();
|
};
|
|
return (
|
<div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-white/95 pt-4 pb-6" style={{ zIndex: 50 }}>
|
<div className="max-w-4xl mx-auto px-6">
|
<div className="relative">
|
<div className="absolute -top-6 left-1/2 -translate-x-1/2 w-48 h-[1px] bg-gradient-to-r from-transparent via-gray-200 to-transparent"></div>
|
|
<div className="flex gap-4 items-start">
|
<div className="flex-1 relative">
|
<textarea
|
ref={inputRef}
|
value={inputText}
|
onChange={handleInputChange}
|
onKeyDown={handleKeyDown}
|
placeholder="请描述您的供应链场景或提出问题..."
|
disabled={isStreaming}
|
className="w-full resize-none rounded-xl border-0 bg-gray-50 px-4 py-3 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100/50 focus:bg-white min-h-[48px] max-h-32 shadow-inner transition-all duration-300 ease-in-out hover:bg-gray-100/70 disabled:opacity-50 disabled:cursor-not-allowed"
|
style={{ height: '48px' }}
|
/>
|
<div className="absolute right-4 bottom-2 text-xs text-gray-400 bg-gray-50 px-2">
|
按Enter发送,Shift+Enter换行
|
</div>
|
</div>
|
<button
|
onClick={handleSend}
|
disabled={isStreaming || !inputText.trim() || !isMessageComplete}
|
className="h-12 px-5 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-medium rounded-xl transition-all duration-300 flex items-center gap-2 shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none cursor-pointer"
|
>
|
<span>{isStreaming ? '回复中...' : (isMessageComplete ? '发送' : '处理中...')}</span>
|
<svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 transform rotate-45 ${isStreaming ? 'animate-pulse' : ''}`} viewBox="0 0 20 20" fill="currentColor">
|
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
|
</svg>
|
</button>
|
</div>
|
</div>
|
</div>
|
</div>
|
);
|
}
|
|
export default function SupplyChainChatPage() {
|
const router = useRouter();
|
const searchParams = useSearchParams();
|
const [apiKey, setApiKey] = useState<string>('');
|
const [message, setMessage] = useState('');
|
const [messages, setMessages] = useState<Message[]>([]);
|
const [isStreaming, setIsStreaming] = useState(false);
|
const [error, setError] = useState<string | null>(null);
|
const [showError, setShowError] = useState(false);
|
const [conversationId, setConversationId] = useState<string | null>(null);
|
const [isMessageComplete, setIsMessageComplete] = useState(true);
|
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
|
|
// 添加一个新的状态来控制消息的显示
|
const [showMessages, setShowMessages] = useState(false);
|
|
// 在组件顶部增加一个强制更新计数器
|
const [forceUpdateCounter, setForceUpdateCounter] = useState(0);
|
|
// 添加状态来控制思考内容的显示/隐藏
|
const [expandedThinkMessages, setExpandedThinkMessages] = useState<Record<string, boolean>>({});
|
|
// 添加API密钥加载状态
|
const [isLoadingApiKey, setIsLoadingApiKey] = useState(false);
|
|
// 获取URL参数
|
const keyParam = searchParams.get('key');
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const errorTimeoutRef = useRef<any>(null);
|
|
// 在组件顶部添加一个引用,用于跟踪组件是否已卸载
|
const isMountedRef = useRef(true);
|
|
// 分离消息输入状态,避免触发不必要的重渲染
|
const messageInputRef = useRef<HTMLTextAreaElement>(null);
|
const [localMessage, setLocalMessage] = useState('');
|
|
// 添加获取API密钥的函数
|
const fetchApiKey = async () => {
|
setIsLoadingApiKey(true);
|
try {
|
const response = await fetch('http://121.43.139.99:8080/api/secret-key', {
|
method: 'GET',
|
headers: {
|
'Content-Type': 'application/json',
|
},
|
});
|
|
if (!response.ok) {
|
throw new Error(`获取密钥失败: HTTP ${response.status}`);
|
}
|
|
const result = await response.json();
|
console.log('API响应:', result);
|
|
// 按照接口文档格式解析响应
|
if (result.code === 200 && result.data && result.data.key) {
|
const apiKeyValue = result.data.key;
|
setApiKey(apiKeyValue);
|
setError(null);
|
console.log('成功获取API密钥:', apiKeyValue);
|
return apiKeyValue;
|
} else {
|
throw new Error(result.message || '获取密钥失败:响应格式不正确');
|
}
|
} catch (err) {
|
console.error('获取API密钥失败:', err);
|
const errorMessage = err instanceof Error ? err.message : '获取密钥时发生未知错误';
|
showErrorMessage(`密钥获取失败: ${errorMessage}`);
|
return null;
|
} finally {
|
setIsLoadingApiKey(false);
|
}
|
};
|
|
const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
// 只更新本地状态,不触发全局重渲染
|
setLocalMessage(e.target.value);
|
};
|
|
// 仅在发送时更新全局消息状态
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
e.preventDefault();
|
// 更新全局状态并发送
|
setMessage(localMessage);
|
setTimeout(() => handleSendMessage(), 10);
|
}
|
};
|
|
// 点击发送按钮时
|
const handleSendButtonClick = () => {
|
// 检查消息是否为空
|
if (!localMessage.trim() || !isMessageComplete) return;
|
|
// 更新全局状态并发送
|
setMessage(localMessage);
|
setTimeout(() => handleSendMessage(), 10);
|
};
|
|
// 同步消息状态到本地状态
|
useEffect(() => {
|
setLocalMessage(message);
|
}, [message]);
|
|
useEffect(() => {
|
// 组件加载时从API获取密钥
|
const initializeApiKey = async () => {
|
await fetchApiKey();
|
};
|
|
initializeApiKey();
|
}, []);
|
|
useEffect(() => {
|
// 滚动到最新消息
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
}, [messages]);
|
|
// 处理错误显示和自动消失
|
const showErrorMessage = useCallback((message: string) => {
|
setError(message);
|
// 先设置为false确保动画能重新触发
|
setShowError(false);
|
// 使用 requestAnimationFrame 确保状态变化被正确渲染
|
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
setShowError(true);
|
});
|
});
|
|
if (errorTimeoutRef.current) {
|
clearTimeout(errorTimeoutRef.current);
|
}
|
|
errorTimeoutRef.current = setTimeout(() => {
|
setShowError(false);
|
setTimeout(() => {
|
setError(null);
|
}, 400);
|
}, 3000);
|
}, []);
|
|
// 组件卸载时清除定时器
|
useEffect(() => {
|
const cleanup = () => {
|
if (errorTimeoutRef.current) {
|
clearTimeout(errorTimeoutRef.current);
|
}
|
};
|
return cleanup;
|
}, []);
|
|
// 添加组件卸载时的清理工作
|
useEffect(() => {
|
// 组件挂载时,设置为true
|
isMountedRef.current = true;
|
|
// 组件卸载时,设置为false
|
return () => {
|
isMountedRef.current = false;
|
|
// 清除所有定时器
|
if (errorTimeoutRef.current) {
|
clearTimeout(errorTimeoutRef.current);
|
}
|
};
|
}, []);
|
|
const handleSendMessage = async () => {
|
// 从window对象获取消息内容
|
const inputMessage = (window as any).messageToSend || '';
|
if (!inputMessage.trim() || !isMessageComplete) return;
|
|
// 检查API密钥,如果没有则尝试获取
|
let currentApiKey = apiKey;
|
if (!currentApiKey) {
|
showErrorMessage('正在获取API密钥...');
|
currentApiKey = await fetchApiKey();
|
if (!currentApiKey) {
|
showErrorMessage('无法获取API密钥,请检查网络连接');
|
return;
|
}
|
}
|
|
setIsStreaming(true);
|
setIsMessageComplete(false);
|
|
// 创建新消息
|
const userMessage: Message = {
|
role: 'user',
|
content: inputMessage.trim(),
|
timestamp: Date.now(),
|
id: 'user-' + Date.now().toString()
|
};
|
|
const assistantMessage: Message = {
|
role: 'assistant',
|
content: '',
|
timestamp: Date.now(),
|
id: 'temp-' + Date.now().toString()
|
};
|
|
// 使用函数式更新确保状态更新的一致性
|
setMessages(prevMessages => {
|
const newMessages = [...prevMessages];
|
// 如果存在未完成的助手消息,先移除它
|
if (!isMessageComplete && currentMessageId) {
|
const lastMessage = newMessages[newMessages.length - 1];
|
if (lastMessage && lastMessage.role === 'assistant' && lastMessage.id === currentMessageId) {
|
newMessages.pop();
|
}
|
}
|
return [...newMessages, userMessage, assistantMessage];
|
});
|
|
setCurrentMessageId(assistantMessage.id);
|
setMessage('');
|
setShowMessages(true);
|
|
// 清除输入内容
|
(window as any).messageToSend = '';
|
|
let controller: AbortController | null = new AbortController();
|
|
try {
|
const response = await fetch(`${BASE_URL}/v1/chat-messages`, {
|
method: 'POST',
|
headers: {
|
'Authorization': `Bearer ${currentApiKey}`,
|
'Content-Type': 'application/json',
|
},
|
mode: 'cors',
|
body: JSON.stringify({
|
inputs: {},
|
query: userMessage.content,
|
response_mode: 'streaming',
|
conversation_id: conversationId || '',
|
user: 'abc-123',
|
files: []
|
}),
|
signal: controller.signal // 添加中止信号
|
});
|
|
console.log("API响应状态:", response.status, response.statusText);
|
|
if (!response.ok) {
|
const errorText = await response.text();
|
console.error("API错误响应:", errorText);
|
let errorMessage;
|
try {
|
const errorJson = JSON.parse(errorText);
|
errorMessage = errorJson.error || `错误: ${response.status}`;
|
} catch {
|
errorMessage = `错误: ${response.status}`;
|
}
|
throw new Error(errorMessage);
|
}
|
|
// 确保response.body存在
|
if (!response.body) {
|
throw new Error('响应没有提供数据流');
|
}
|
|
const reader = response.body.getReader();
|
const decoder = new TextDecoder();
|
let buffer = '';
|
|
while (true) {
|
try {
|
const { done, value } = await reader.read();
|
if (done) {
|
console.log("流式响应接收完毕");
|
setIsMessageComplete(true);
|
break;
|
}
|
|
const chunk = decoder.decode(value, { stream: true });
|
console.log("接收到数据块:", chunk);
|
buffer += chunk;
|
const lines = buffer.split('\n');
|
buffer = lines.pop() || '';
|
|
for (const line of lines) {
|
if (line.trim() === '') continue;
|
|
console.log("处理行数据:", line);
|
|
if (line.startsWith('data: ')) {
|
try {
|
const jsonStr = line.slice(6);
|
console.log("完整原始数据:", line);
|
console.log("解析JSON字符串:", jsonStr);
|
const data = JSON.parse(jsonStr);
|
console.log("解析后的数据对象:", data);
|
|
// 忽略ping事件
|
if (data.event === 'ping') {
|
console.log("忽略ping事件");
|
continue;
|
}
|
|
switch (data.event) {
|
case 'message':
|
console.log(`收到message事件:`, data);
|
|
// 提取message数据
|
const messageId = data.message_id;
|
const answerChunk = data.answer || '';
|
const convId = data.conversation_id;
|
|
// 如果不在循环中,直接调用更新函数
|
handleMessageChunk(messageId, answerChunk, convId);
|
break;
|
|
case 'message_end':
|
console.log('收到message_end事件:', data);
|
|
// 检查是否有最终答案
|
if (data.metadata && data.id) {
|
setIsMessageComplete(true);
|
setConversationId(data.conversation_id || null);
|
}
|
break;
|
|
case 'workflow_finished':
|
console.log('工作流完成:', data);
|
|
// 检查工作流输出是否有答案
|
if (data.data && data.data.outputs && data.data.outputs.answer) {
|
// 如果工作流有最终答案,更新消息内容
|
const finalAnswer = data.data.outputs.answer;
|
const messageId = data.message_id;
|
|
if (finalAnswer) {
|
console.log('从工作流获取最终答案:', finalAnswer);
|
handleFinalAnswer(messageId, finalAnswer, data.conversation_id);
|
}
|
}
|
break;
|
|
case 'node_finished':
|
// 检查节点类型是否为answer节点
|
if (data.data && data.data.node_type === 'answer' && data.data.outputs && data.data.outputs.answer) {
|
console.log('从answer节点获取答案:', data.data.outputs.answer);
|
const answer = data.data.outputs.answer;
|
if (answer) {
|
handleFinalAnswer(data.message_id, answer, data.conversation_id);
|
}
|
}
|
break;
|
|
// 处理其他工作流事件
|
case 'workflow_started':
|
case 'node_started':
|
// 这些事件只需记录,不需要更新UI
|
console.log(`工作流事件 ${data.event}:`, data);
|
break;
|
|
case 'error':
|
console.error('服务器返回错误事件:', data);
|
setIsMessageComplete(true);
|
throw new Error(data.message || '发送消息时出错');
|
}
|
} catch (e) {
|
console.error('解析SSE数据出错:', e, '原始行:', line);
|
setIsMessageComplete(true);
|
throw e;
|
}
|
}
|
}
|
} catch (err) {
|
console.error('处理流式响应时出错:', err);
|
throw err;
|
}
|
}
|
} catch (err) {
|
// 检查是否是中止错误
|
if (err instanceof Error && err.name === 'AbortError') {
|
console.log('请求被中止,可能是组件卸载导致的');
|
return; // 中止错误不需要显示给用户
|
}
|
|
console.error('聊天请求错误:', err);
|
let errorMsg = err instanceof Error ? err.message : '发送消息时出错';
|
|
// 只有在组件仍然挂载时才更新UI
|
if (isMountedRef.current) {
|
showErrorMessage(errorMsg);
|
|
setMessages(prev => {
|
const newMessages = [...prev];
|
const lastMessage = newMessages[newMessages.length - 1];
|
if (lastMessage?.role === 'assistant') {
|
lastMessage.content = `抱歉,无法获取回复: ${errorMsg}`;
|
}
|
return newMessages;
|
});
|
}
|
} finally {
|
// 清除控制器
|
controller = null;
|
|
// 只有在组件仍然挂载时才更新UI
|
if (isMountedRef.current) {
|
setIsStreaming(false);
|
setIsMessageComplete(true);
|
}
|
}
|
};
|
|
// 修改handleMessageChunk函数,添加组件挂载检查
|
const handleMessageChunk = useCallback((messageId: string, answerChunk: string, convId?: string) => {
|
// 如果组件已卸载,则不执行更新
|
if (!isMountedRef.current) return;
|
|
console.log(`处理消息片段: ID=${messageId}, 片段="${answerChunk}"`);
|
|
// 使用函数式更新确保获取最新状态
|
setMessages(prevMessages => {
|
// 如果组件已卸载,则不执行更新
|
if (!isMountedRef.current) return prevMessages;
|
|
// 创建消息数组的拷贝
|
const newMessages = [...prevMessages];
|
|
// 查找最后一条助手消息
|
const lastMessage = newMessages[newMessages.length - 1];
|
if (lastMessage?.role !== 'assistant') {
|
console.warn('找不到助手消息来更新');
|
return prevMessages; // 不需要更新
|
}
|
|
// 更新消息内容和ID
|
const updatedContent = (lastMessage.content || '') + answerChunk;
|
|
// 创建消息的新副本以确保React检测到变化
|
const updatedMessage = {
|
...lastMessage,
|
id: messageId,
|
content: updatedContent,
|
timestamp: Date.now()
|
};
|
|
if (convId) {
|
updatedMessage.conversation_id = convId;
|
}
|
|
// 更新当前正在处理的消息ID
|
if (isMountedRef.current) {
|
setCurrentMessageId(messageId);
|
}
|
|
// 替换最后一条消息
|
newMessages[newMessages.length - 1] = updatedMessage;
|
|
console.log(`更新后的内容长度: ${updatedContent.length}`);
|
|
// 触发强制更新
|
if (isMountedRef.current) {
|
setTimeout(() => {
|
if (isMountedRef.current) {
|
setForceUpdateCounter(count => count + 1);
|
}
|
}, 0);
|
}
|
|
return newMessages;
|
});
|
}, []);
|
|
// 修改handleFinalAnswer函数,添加组件挂载检查
|
const handleFinalAnswer = useCallback((messageId: string, answer: string, convId?: string) => {
|
// 如果组件已卸载,则不执行更新
|
if (!isMountedRef.current) return;
|
|
console.log(`设置最终答案: ID=${messageId}, 内容="${answer}"`);
|
|
setMessages(prevMessages => {
|
// 如果组件已卸载,则不执行更新
|
if (!isMountedRef.current) return prevMessages;
|
|
const newMessages = [...prevMessages];
|
const lastMessage = newMessages[newMessages.length - 1];
|
|
if (lastMessage?.role !== 'assistant') {
|
console.warn('找不到助手消息来更新最终答案');
|
return prevMessages;
|
}
|
|
// 创建消息的新副本,设置最终答案
|
const updatedMessage = {
|
...lastMessage,
|
id: messageId,
|
content: answer,
|
timestamp: Date.now()
|
};
|
|
if (convId) {
|
updatedMessage.conversation_id = convId;
|
}
|
|
// 更新状态
|
if (isMountedRef.current) {
|
setIsMessageComplete(true);
|
setCurrentMessageId(messageId);
|
}
|
|
// 替换最后一条消息
|
newMessages[newMessages.length - 1] = updatedMessage;
|
|
// 触发强制更新
|
if (isMountedRef.current) {
|
setTimeout(() => {
|
if (isMountedRef.current) {
|
setForceUpdateCounter(count => count + 1);
|
}
|
}, 0);
|
}
|
|
return newMessages;
|
});
|
}, []);
|
|
const parseMessageContent = (content: string | null) => {
|
if (!content) return { mainContent: null, thinkContent: null };
|
|
// 检查是否包含思考内容
|
const thinkMatch = content.match(/<think>([\s\S]*?)<\/think>/);
|
const thinkContent = thinkMatch ? thinkMatch[1].trim() : null;
|
const mainContent = content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
return { mainContent, thinkContent };
|
};
|
|
const toggleThinkContent = (messageId: string) => {
|
setExpandedThinkMessages(prev => ({
|
...prev,
|
[messageId]: !prev[messageId]
|
}));
|
};
|
|
useEffect(() => {
|
// 页面加载后延迟显示消息列表,避免闪烁
|
const timer = setTimeout(() => {
|
setShowMessages(true);
|
}, 100);
|
return () => clearTimeout(timer);
|
}, []);
|
|
useEffect(() => {
|
return () => {
|
// 组件卸载时的清理工作
|
console.log('供应链聊天组件卸载,清理资源');
|
};
|
}, []);
|
|
return (
|
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white text-gray-900 flex flex-col">
|
{/* 顶部导航栏 */}
|
<div className="fixed top-16 left-0 right-0 bg-white border-b border-gray-200 z-40">
|
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
<div className="flex items-center space-x-4">
|
<button
|
onClick={() => router.push('/ai-scene')}
|
className="group inline-flex items-center text-sm text-gray-500 hover:text-red-500 transition-all duration-300 cursor-pointer"
|
>
|
<svg
|
xmlns="http://www.w3.org/2000/svg"
|
className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-0.5"
|
fill="none"
|
viewBox="0 0 24 24"
|
stroke="currentColor"
|
>
|
<path
|
strokeLinecap="round"
|
strokeLinejoin="round"
|
strokeWidth={2}
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
/>
|
</svg>
|
<span className="ml-1.5">返回</span>
|
</button>
|
<div className="h-4 w-px bg-gray-200"></div>
|
<h1 className="text-xl font-semibold text-gray-900">
|
供应链全景洞察
|
</h1>
|
</div>
|
<div className="flex items-center space-x-4">
|
<div className="px-3 py-1 bg-green-100 text-green-700 text-sm rounded-full">
|
AI智能分析
|
</div>
|
{keyParam && (
|
<div className="text-xs text-gray-500">
|
ID: {keyParam}
|
</div>
|
)}
|
</div>
|
</div>
|
</div>
|
|
{/* 错误提示 */}
|
{error && (
|
<div
|
className={`fixed top-36 left-1/2 transform -translate-x-1/2
|
flex items-center gap-2 px-4 py-2 text-sm text-white
|
transition-all duration-400 ease-out
|
${showError
|
? 'opacity-100 translate-y-0 scale-100'
|
: 'opacity-0 -translate-y-2 scale-95'
|
}
|
before:content-[''] before:absolute before:inset-0 before:bg-red-500
|
before:rounded-lg before:opacity-90 before:-z-10
|
after:content-[''] after:absolute after:inset-0 after:bg-red-500/50
|
after:blur-md after:rounded-lg after:-z-20`}
|
>
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
</svg>
|
<span className="relative">{error}</span>
|
</div>
|
)}
|
|
{/* 聊天区域 */}
|
<div className="flex-1 flex flex-col h-screen">
|
<div className="flex-1 overflow-y-auto pt-36 pb-32">
|
<div className="max-w-4xl mx-auto px-6">
|
<div className={`space-y-6 ${showMessages ? 'opacity-100' : 'opacity-0'} transition-opacity duration-200`}>
|
{messages.length === 0 ? (
|
<div className="flex flex-col items-center justify-center h-[400px] text-center">
|
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-4">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2-2V7a2 2 0 012-2h2a2 2 0 002-2V3a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 002 2h2a2 2 0 012 2v2a2 2 0 00-2 2h-2a2 2 0 00-2 2v6a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
</svg>
|
</div>
|
<h3 className="text-lg font-medium text-gray-900 mb-2">供应链全景洞察</h3>
|
<p className="text-gray-500 max-w-md">
|
欢迎使用供应链全景洞察系统!您可以咨询插单影响分析、生产计划优化、供应链风险评估等问题。
|
</p>
|
</div>
|
) : (
|
messages.map((msg, index) => (
|
<div
|
key={msg.id}
|
className={`flex items-start gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
|
>
|
<div className="relative flex-shrink-0">
|
<div className="w-8 h-8 rounded-lg overflow-hidden shadow-inner bg-gray-50">
|
<Image
|
src={msg.role === 'assistant' ? "/images/logo.jpg" : DEFAULT_USER_AVATAR}
|
alt={msg.role === 'assistant' ? "AI助手" : "用户"}
|
width={32}
|
height={32}
|
className="w-full h-full object-cover"
|
/>
|
</div>
|
</div>
|
<div className={`flex-1 min-w-0 ${msg.role === 'user' ? 'text-right' : ''}`}>
|
<div className={`${
|
msg.role === 'assistant'
|
? 'bg-white rounded-xl shadow-sm border border-gray-100'
|
: 'bg-[#E8F4FF] rounded-xl'
|
} inline-block max-w-[85%] relative overflow-hidden`}>
|
|
{msg.role === 'assistant' && (
|
<>{(() => {
|
// 解析消息内容,提取思考部分
|
const { mainContent, thinkContent } = parseMessageContent(msg.content);
|
const isExpanded = expandedThinkMessages[msg.id] || false;
|
const thinkingDuration = 8; // 思考时间(秒)
|
|
return (
|
<>
|
{/* 思考内容区域 (如果存在) */}
|
{thinkContent && (
|
<div className="border-b border-gray-200">
|
<button
|
onClick={() => toggleThinkContent(msg.id)}
|
className="w-full flex items-center justify-between px-3 py-2 bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
|
>
|
<div className="flex items-center space-x-2">
|
<span>已深度思考 (用时 {thinkingDuration} 秒)</span>
|
</div>
|
<svg
|
className={`w-4 h-4 transform transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
fill="none"
|
stroke="currentColor"
|
viewBox="0 0 24 24"
|
>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
</svg>
|
</button>
|
|
{/* 可折叠的思考内容 */}
|
{isExpanded && (
|
<div className="bg-gray-50 px-4 py-3 text-sm text-gray-700 border-t border-gray-200 whitespace-pre-wrap">
|
{thinkContent}
|
</div>
|
)}
|
</div>
|
)}
|
|
{/* 主要内容 */}
|
<div className="p-3">
|
<div className="text-gray-800 leading-relaxed">
|
{mainContent ? (
|
<MessageCompletionContext.Provider
|
value={msg.id !== currentMessageId || isMessageComplete}
|
>
|
<ReactMarkdown
|
remarkPlugins={[remarkGfm]}
|
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
components={{
|
// @ts-ignore - ReactMarkdown 组件类型定义的兼容性问题
|
code: ({ node, inline, className, children, ...props }) => {
|
const match = /language-(\w+)/.exec(className || '');
|
const language = match ? match[1] : '';
|
const value = String(children).replace(/\n$/, '');
|
|
if (!inline && match) {
|
return <CodeBlockRenderer language={language} value={value} />;
|
}
|
|
return (
|
<code className={className} {...props}>
|
{children}
|
</code>
|
);
|
},
|
// 添加表格样式组件
|
table: ({ node, ...props }) => (
|
<div className="overflow-x-auto my-4 rounded-lg border border-gray-100 shadow-sm" style={{ width: '100%', minWidth: '100%' }}>
|
<table className="min-w-full divide-y divide-gray-100" style={{ tableLayout: 'auto', width: 'max-content', minWidth: '100%' }} {...props} />
|
</div>
|
),
|
thead: ({ node, ...props }) => (
|
<thead className="bg-gray-50/70" {...props} />
|
),
|
th: ({ node, children, ...props }) => (
|
<th className="px-4 py-3 text-sm font-semibold text-gray-700 border-b border-gray-100 text-center" style={{ whiteSpace: 'nowrap', minWidth: 'max-content' }} {...props}>
|
{children}
|
</th>
|
),
|
td: ({ node, ...props }) => (
|
<td className="px-4 py-3 text-sm text-gray-600 border-t border-gray-100 text-center" style={{ whiteSpace: 'nowrap' }} {...props} />
|
),
|
tr: ({ node, ...props }) => (
|
<tr className="hover:bg-gray-50/70 transition-colors duration-150" {...props} />
|
)
|
}}
|
>
|
{mainContent}
|
</ReactMarkdown>
|
</MessageCompletionContext.Provider>
|
) : (
|
msg.role === 'assistant' && !isMessageComplete ? '处理回复中...' : ''
|
)}
|
</div>
|
</div>
|
|
{/* 加载指示器 */}
|
{msg.role === 'assistant' &&
|
!isMessageComplete &&
|
msg.id === currentMessageId && ( // 只在当前处理的消息显示加载指示器
|
<div className="absolute bottom-1 right-2">
|
<div className="flex space-x-1">
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-0"></div>
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-150"></div>
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-300"></div>
|
</div>
|
</div>
|
)}
|
</>
|
);
|
})()}</>
|
)}
|
|
{/* 用户消息简单显示 */}
|
{msg.role === 'user' && (
|
<div className="p-3">
|
<div className="text-gray-800 leading-relaxed whitespace-pre-wrap">
|
{msg.content}
|
</div>
|
</div>
|
)}
|
</div>
|
<div className="mt-0.5 text-xs text-gray-400">
|
{msg.role === 'user' && new Date(msg.timestamp).toLocaleTimeString()}
|
</div>
|
</div>
|
</div>
|
))
|
)}
|
<div ref={messagesEndRef} />
|
</div>
|
</div>
|
</div>
|
|
{/* 输入区域 - 抽取为独立组件 */}
|
<ChatInput
|
onSendMessage={handleSendMessage}
|
isStreaming={isStreaming}
|
isMessageComplete={isMessageComplete}
|
/>
|
</div>
|
</div>
|
);
|
}
|