'use client';
|
|
import { useState, useEffect, useRef, useCallback, useContext, createContext } from 'react';
|
import Link from 'next/link';
|
import { useRouter } 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 [isLoaded, setIsLoaded] = useState(false);
|
const chartContainerRef = useRef<HTMLDivElement>(null);
|
const codeRef = useRef<string>(code);
|
const renderAttemptedRef = useRef(false);
|
|
// 更新代码引用
|
useEffect(() => {
|
codeRef.current = code;
|
}, [code]);
|
|
// 延迟初始化直到消息稳定
|
useEffect(() => {
|
// 为确保流式内容稳定,设置延迟
|
const timer = setTimeout(() => {
|
setIsLoaded(true);
|
}, 500);
|
|
return () => clearTimeout(timer);
|
}, []);
|
|
// 主要渲染逻辑 - 仅在组件标记为已加载后执行
|
useEffect(() => {
|
// 如果组件未标记为已加载或已经尝试过渲染,则退出
|
if (!isLoaded || renderAttemptedRef.current) return;
|
|
// 确保只在客户端执行
|
if (typeof window === 'undefined' || !chartContainerRef.current) return;
|
|
// 标记为已尝试渲染,防止重复渲染
|
renderAttemptedRef.current = true;
|
|
// 防止代码执行中的变量冲突
|
const safeCode = codeRef.current.replace(/window\.option/g, '_uniqueOptionVar');
|
|
// 等待DOM更新完成
|
setTimeout(() => {
|
let chartInstance: any = null;
|
|
const initChart = async () => {
|
try {
|
// 动态导入echarts
|
const echarts = await import('echarts');
|
|
if (!chartContainerRef.current) {
|
console.warn('图表容器已不存在');
|
return;
|
}
|
|
// 初始化图表
|
chartInstance = echarts.init(chartContainerRef.current);
|
|
// 使用Function构造器,但添加额外的保护
|
try {
|
const getFinalOption = new Function(`
|
"use strict";
|
let option;
|
let _uniqueOptionVar;
|
try {
|
${safeCode}
|
return option || _uniqueOptionVar;
|
} catch (e) {
|
console.error("ECharts内部执行错误:", e);
|
return null;
|
}
|
`);
|
|
const chartOption = getFinalOption();
|
|
if (chartOption) {
|
chartInstance.setOption(chartOption);
|
setError(null);
|
} else {
|
console.warn('未获取到有效的图表配置,使用备用配置');
|
useFallbackOption(chartInstance);
|
}
|
} catch (e) {
|
console.error('执行图表代码错误:', e);
|
setError(e instanceof Error ? e.message : '图表代码执行错误');
|
useFallbackOption(chartInstance);
|
}
|
|
// 添加响应式调整
|
const handleResize = () => {
|
chartInstance && chartInstance.resize();
|
};
|
|
window.addEventListener('resize', handleResize);
|
|
// 确保组件卸载时清理资源
|
return () => {
|
window.removeEventListener('resize', handleResize);
|
if (chartInstance) {
|
try {
|
chartInstance.dispose();
|
} catch (e) {
|
console.warn('图表实例销毁失败', e);
|
}
|
}
|
};
|
} catch (e) {
|
console.error('图表初始化失败:', e);
|
setError('加载图表库失败');
|
return () => {};
|
}
|
};
|
|
// 备用配置函数
|
const useFallbackOption = (instance: any) => {
|
try {
|
// 简单的备用配置
|
const fallbackOption = {
|
title: {
|
text: '数据可视化图表',
|
subtext: '(原始代码解析失败,显示备用图表)'
|
},
|
tooltip: {
|
trigger: 'axis'
|
},
|
legend: {
|
data: ['数据']
|
},
|
xAxis: {
|
type: 'category',
|
data: ['项目1', '项目2', '项目3', '项目4', '项目5']
|
},
|
yAxis: {
|
type: 'value'
|
},
|
series: [{
|
name: '数据',
|
type: 'bar',
|
data: [5, 20, 36, 10, 10]
|
}]
|
};
|
|
// 检测是否是热力图代码
|
if (safeCode.includes('heatmap') ||
|
safeCode.includes('visualMap') ||
|
safeCode.includes('风险')) {
|
(fallbackOption.series as any) = [{
|
type: 'heatmap',
|
name: '风险值', // 添加缺失的name属性
|
data: [
|
[0, 0, 5], [0, 1, 7], [0, 2, 3],
|
[1, 0, 7], [1, 1, 8], [1, 2, 6],
|
[2, 0, 9], [2, 1, 10], [2, 2, 8]
|
] as any[]
|
}];
|
|
fallbackOption.title.text = 'VIP客户订单交付风险热力图';
|
fallbackOption.title.subtext = '数据解析失败,显示示例热力图';
|
|
// 添加热力图所需的其他配置
|
Object.assign(fallbackOption, {
|
tooltip: {
|
position: 'top'
|
},
|
xAxis: {
|
type: 'category',
|
data: ['低', '中', '高'],
|
name: '延迟风险'
|
},
|
yAxis: {
|
type: 'category',
|
data: ['低', '中', '高'],
|
name: '违约成本'
|
},
|
visualMap: {
|
min: 1,
|
max: 10,
|
calculable: true,
|
orient: 'horizontal',
|
left: 'center',
|
bottom: '15%'
|
}
|
});
|
}
|
|
// 设置备用配置
|
instance.setOption(fallbackOption);
|
} catch (e) {
|
console.error('应用备用配置失败:', e);
|
}
|
};
|
|
// 初始化图表并获取清理函数
|
const cleanupPromise = initChart();
|
|
// 完全重写清理函数逻辑,避免使用Promise的不确定返回类型
|
// 初始化图表
|
initChart().then(cleanupFn => {
|
// 存储清理函数供以后使用
|
if (typeof cleanupFn === 'function') {
|
// 使用ref存储清理函数
|
const currentCleanup = cleanupFn;
|
// 组件卸载时执行
|
return () => currentCleanup();
|
}
|
}).catch(e => {
|
console.error('初始化图表失败:', e);
|
});
|
|
// 返回一个空的清理函数,避免类型错误
|
return () => {};
|
}, 100);
|
}, [isLoaded]);
|
|
// 渲染图表容器
|
return (
|
<div className="relative my-4">
|
{error && (
|
<div className="absolute top-0 left-0 right-0 bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded-md text-xs z-10">
|
{error}
|
<button
|
className="ml-2 text-red-700 hover:text-red-900"
|
onClick={() => setError(null)}
|
>
|
×
|
</button>
|
</div>
|
)}
|
<div
|
ref={chartContainerRef}
|
className="w-full bg-white border border-gray-200 rounded-lg overflow-hidden"
|
style={{ height: '400px' }}
|
/>
|
</div>
|
);
|
}
|
|
// 修改代码块渲染组件,添加更好的类型检测
|
function CodeBlockRenderer({ language, value }: { language: string; value: string }) {
|
// 判断是否是JavaScript代码,检测是否包含图表相关特征
|
const isEchartsCode = useCallback(() => {
|
if (language !== 'javascript') return false;
|
|
// 检查是否包含ECharts特有的配置项
|
return value.includes('option') &&
|
(value.includes('series') ||
|
value.includes('chart') ||
|
value.includes('echarts') ||
|
value.includes('xAxis') ||
|
value.includes('yAxis'));
|
}, [language, value]);
|
|
// 检查消息是否完整 - 通过父组件传递的isMessageComplete状态
|
const isComplete = useContext(MessageCompletionContext);
|
|
// 在消息未完成时显示加载动画
|
if (language === 'javascript' && !isComplete) {
|
return (
|
<div className="w-full bg-gray-50 rounded-md my-4 p-6 text-center">
|
<div className="flex flex-col items-center justify-center">
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-gray-400 mb-4"></div>
|
<p className="text-gray-500">代码加载中...</p>
|
<p className="text-xs text-gray-400 mt-2">等待消息完成后将渲染图表</p>
|
</div>
|
</div>
|
);
|
}
|
|
// 消息完成后,如果是图表代码则渲染为图表
|
if (isEchartsCode() && isComplete) {
|
return <EchartsRenderer code={value} />;
|
}
|
|
// 普通JavaScript代码或其他语言的代码块,直接显示代码
|
return (
|
<div className="bg-gray-800 rounded-md my-2 overflow-hidden">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-700">
|
<span className="text-xs text-gray-400">{language}</span>
|
</div>
|
<pre className="p-4 text-gray-300 overflow-x-auto">
|
<code>{value}</code>
|
</pre>
|
</div>
|
);
|
}
|
|
export default function Page() {
|
const router = useRouter();
|
const [apiKey, setApiKey] = useState<string>('');
|
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
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>>({});
|
|
// 在组件顶部添加显示/隐藏密码的状态
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const errorTimeoutRef = useRef<any>(null);
|
|
// 在组件顶部添加一个引用,用于跟踪组件是否已卸载
|
const isMountedRef = useRef(true);
|
|
useEffect(() => {
|
// 获取存储的API Key
|
const storedApiKey = localStorage.getItem('api-key');
|
if (storedApiKey) {
|
setApiKey(storedApiKey);
|
} else {
|
setShowApiKeyInput(true);
|
}
|
}, []);
|
|
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 handleApiKeySubmit = () => {
|
if (apiKey.trim()) {
|
localStorage.setItem('api-key', apiKey);
|
setShowApiKeyInput(false);
|
setError(null);
|
}
|
};
|
|
const handleSendMessage = async () => {
|
if (!message.trim() || !isMessageComplete) return;
|
if (!apiKey) {
|
showErrorMessage('请先设置API Key');
|
return;
|
}
|
|
setIsStreaming(true);
|
setIsMessageComplete(false);
|
|
// 创建新消息
|
const userMessage: Message = {
|
role: 'user',
|
content: message.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('');
|
|
let controller: AbortController | null = new AbortController();
|
|
try {
|
const response = await fetch(`${BASE_URL}/v1/chat-messages`, {
|
method: 'POST',
|
headers: {
|
'Authorization': `Bearer ${apiKey}`,
|
'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 isSendDisabled = isStreaming || !message.trim() || !isMessageComplete;
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
e.preventDefault();
|
handleSendMessage();
|
}
|
};
|
|
useEffect(() => {
|
// 页面加载后延迟显示消息列表,避免闪烁
|
const timer = setTimeout(() => {
|
setShowMessages(true);
|
}, 100);
|
return () => clearTimeout(timer);
|
}, []);
|
|
// 添加自定义CSS动画样式
|
useEffect(() => {
|
const style = document.createElement('style');
|
style.innerHTML = `
|
@keyframes dotBounce {
|
0%, 80%, 100% { transform: translateY(0); }
|
40% { transform: translateY(-6px); }
|
}
|
.dot-animation span:nth-child(1) { animation: dotBounce 1.4s -0.32s infinite ease-in-out; }
|
.dot-animation span:nth-child(2) { animation: dotBounce 1.4s -0.16s infinite ease-in-out; }
|
.dot-animation span:nth-child(3) { animation: dotBounce 1.4s 0s infinite ease-in-out; }
|
`;
|
document.head.appendChild(style);
|
|
return () => {
|
document.head.removeChild(style);
|
};
|
}, []);
|
|
// 添加处理思考内容的辅助函数
|
const parseMessageContent = (content: string | null) => {
|
if (!content) return { mainContent: '', thinkContent: null };
|
|
// 匹配<think>标签内容 (不区分大小写)
|
const thinkMatch = content.match(/<think>([\s\S]*?)<\/think>/i);
|
|
if (thinkMatch) {
|
// 提取思考内容
|
const thinkContent = thinkMatch[1].trim();
|
// 移除<think>标签,保留主要内容
|
const mainContent = content.replace(/<think>[\s\S]*?<\/think>/i, '').trim();
|
return { mainContent, thinkContent };
|
}
|
|
return { mainContent: content, thinkContent: null };
|
};
|
|
// 切换思考内容的显示/隐藏
|
const toggleThinkContent = (messageId: string) => {
|
setExpandedThinkMessages(prev => ({
|
...prev,
|
[messageId]: !prev[messageId]
|
}));
|
};
|
|
// 添加组件卸载时的清理函数
|
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">
|
{/* API Key Modal */}
|
{showApiKeyInput && (
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div className="bg-white rounded-2xl p-6 w-[400px] space-y-4 shadow-xl">
|
<div className="flex justify-between items-center">
|
<h2 className="text-xl font-semibold">设置 API Key</h2>
|
<button
|
onClick={() => setShowApiKeyInput(false)}
|
className="text-gray-400 hover:text-gray-600"
|
>
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
</svg>
|
</button>
|
</div>
|
<div className="space-y-2">
|
<div className="relative">
|
<input
|
type={showApiKey ? "text" : "password"}
|
value={apiKey}
|
onChange={(e) => setApiKey(e.target.value)}
|
placeholder="请输入您的 API Key"
|
className="w-full px-4 py-2 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-100/50"
|
/>
|
<button
|
type="button"
|
onClick={() => setShowApiKey(!showApiKey)}
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
>
|
{showApiKey ? (
|
// 眼睛关闭图标
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<path fillRule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clipRule="evenodd" />
|
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
|
</svg>
|
) : (
|
// 眼睛图标
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
|
</svg>
|
)}
|
</button>
|
</div>
|
</div>
|
<div className="flex justify-end gap-2">
|
<button
|
onClick={() => setShowApiKeyInput(false)}
|
className="px-4 py-2 text-gray-600 hover:text-gray-900"
|
>
|
取消
|
</button>
|
<button
|
onClick={handleApiKeySubmit}
|
className="px-4 py-2 bg-blue-500 text-white rounded-xl hover:bg-blue-600"
|
>
|
确定
|
</button>
|
</div>
|
</div>
|
</div>
|
)}
|
|
{/* 错误提示 */}
|
{error && (
|
<div
|
className={`fixed top-20 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>
|
)}
|
|
{/* API Key 按钮 */}
|
<button
|
onClick={() => setShowApiKeyInput(true)}
|
className="fixed top-24 right-6 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-white/80 hover:bg-white shadow-sm border border-gray-100 transition-colors"
|
title="设置API Key"
|
>
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
</svg>
|
</button>
|
|
{/* 聊天区域 */}
|
<div className="flex-1 flex flex-col h-screen">
|
<div className="flex-1 overflow-y-auto pt-20 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 items-center justify-center h-[200px] text-gray-400">
|
<p>发送消息开始对话</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`}>
|
|
{/* 调试信息 - 移除这部分 */}
|
{/*
|
{process.env.NODE_ENV !== 'production' && (
|
<div className="bg-gray-100 px-2 py-1 text-xs text-gray-500">
|
长度: {(msg.content || '').length} | 计数: {forceUpdateCounter}
|
</div>
|
)}
|
*/}
|
|
{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={isMessageComplete || msg.id !== currentMessageId}>
|
<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>
|
);
|
}
|
}}
|
>
|
{mainContent}
|
</ReactMarkdown>
|
</MessageCompletionContext.Provider>
|
) : (
|
msg.role === 'assistant' && !isMessageComplete ? '处理回复中...' : ''
|
)}
|
</div>
|
</div>
|
|
{/* 加载指示器 */}
|
{msg.role === 'assistant' && !isMessageComplete && (
|
<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>
|
|
{/* 输入区域 */}
|
<div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-white/95 pt-4 pb-6">
|
<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
|
value={message}
|
onChange={(e) => setMessage(e.target.value)}
|
onKeyDown={handleKeyPress}
|
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={handleSendMessage}
|
disabled={isSendDisabled}
|
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"
|
>
|
<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>
|
</div>
|
</div>
|
);
|
}
|