417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
import {useEffect, useState, useRef} from "react";
|
||
import {Textarea} from '@tarojs/components';
|
||
import Taro from '@tarojs/taro';
|
||
import {Button} from '@nutui/nutui-react-taro';
|
||
import {User, Home} from '@nutui/icons-react-taro';
|
||
import {sendAiMessage} from '@/api/ai';
|
||
import {createWebSocket} from '@/utils/websocket';
|
||
import {getAiToken} from '@/utils/aiToken';
|
||
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
||
// 显示html富文本
|
||
import {View, RichText} from '@tarojs/components'
|
||
import './index.scss';
|
||
import {WSS_API_URL} from "@/utils/server";
|
||
|
||
// 消息类型
|
||
interface Message {
|
||
id?: string;
|
||
type?: 'user' | 'ai';
|
||
query?: string;
|
||
timestamp?: number;
|
||
isTyping?: boolean;
|
||
user?: string;
|
||
responseMode?: string;
|
||
}
|
||
|
||
/**
|
||
* AI问答页面
|
||
*/
|
||
const AiChat = () => {
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
const [inputValue, setInputValue] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
// const [currentTaskId, setCurrentTaskId] = useState<string>('');
|
||
const [wsConnected, setWsConnected] = useState(false);
|
||
const [isInitialized, setIsInitialized] = useState(false);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const wsRef = useRef<any>(null);
|
||
|
||
// 快捷问题
|
||
const quickQuestions = [
|
||
"当兵的注意事项?",
|
||
"男兵应征报名对象:",
|
||
"当兵公务员考试?",
|
||
"请问您还有什么问题吗?"
|
||
];
|
||
|
||
// 检查并获取AI Token
|
||
const checkAiToken = () => {
|
||
return getAiToken();
|
||
};
|
||
|
||
// 调试函数:检查输入框状态
|
||
const debugInputStatus = () => {
|
||
console.log('输入框状态调试:', {
|
||
isInitialized,
|
||
isLoading,
|
||
wsConnected,
|
||
inputValue,
|
||
inputDisabled: !isInitialized || isLoading,
|
||
sendButtonDisabled: !isInitialized || !inputValue.trim()
|
||
});
|
||
};
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = () => {
|
||
// 检查并确保AI_TOKEN存在
|
||
checkAiToken();
|
||
|
||
setTimeout(() => {
|
||
messagesEndRef.current?.scrollIntoView({behavior: 'smooth'});
|
||
}, 100);
|
||
};
|
||
|
||
// 初始化WebSocket连接
|
||
const initWebSocket = () => {
|
||
const userToken = Taro.getStorageSync('AI_TOKEN') || 'anonymous';
|
||
console.log(WSS_API_URL + "/chat/" + userToken, 'wsUrl');
|
||
wsRef.current = createWebSocket(WSS_API_URL + "/chat/" + userToken);
|
||
|
||
wsRef.current.onMessage((data: any) => {
|
||
console.log('收到WebSocket消息:', data);
|
||
|
||
if (data.answer) {
|
||
if (data.answer === '__END__') {
|
||
// 消息结束,移除typing状态
|
||
setMessages(prev => prev.map(msg =>
|
||
msg.isTyping ? {...msg, isTyping: false} : msg
|
||
));
|
||
setIsLoading(false);
|
||
// setCurrentTaskId('');
|
||
} else {
|
||
// 实时更新AI回复消息
|
||
setMessages(prev => {
|
||
const newMessages = [...prev];
|
||
const lastMessage = newMessages[newMessages.length - 1];
|
||
|
||
if (lastMessage && lastMessage.type === 'ai' && lastMessage.isTyping) {
|
||
// 直接追加内容到现有AI消息
|
||
lastMessage.query = (lastMessage.query || '') + data.answer;
|
||
} else {
|
||
// 创建新的AI消息
|
||
newMessages.push({
|
||
id: Date.now().toString(),
|
||
type: 'ai',
|
||
query: data.answer,
|
||
timestamp: Date.now(),
|
||
isTyping: true
|
||
});
|
||
}
|
||
|
||
return newMessages;
|
||
});
|
||
|
||
if (data.taskId) {
|
||
// setCurrentTaskId(data.taskId);
|
||
}
|
||
}
|
||
|
||
// 实时滚动到底部
|
||
setTimeout(() => {
|
||
messagesEndRef.current?.scrollIntoView({behavior: 'smooth'});
|
||
}, 50);
|
||
}
|
||
});
|
||
|
||
wsRef.current.onOpen(() => {
|
||
console.log('WebSocket连接成功');
|
||
setWsConnected(true);
|
||
});
|
||
|
||
wsRef.current.onError((error: any) => {
|
||
console.error('WebSocket连接错误:', error);
|
||
setWsConnected(false);
|
||
// Taro.showToast({
|
||
// title: 'WebSocket连接失败',
|
||
// icon: 'none',
|
||
// duration: 2000
|
||
// });
|
||
});
|
||
|
||
wsRef.current.onClose(() => {
|
||
console.log('WebSocket连接关闭');
|
||
setWsConnected(false);
|
||
// 如果正在加载中,显示连接断开提示
|
||
if (isLoading) {
|
||
Taro.showToast({
|
||
title: '连接已断开,正在重连...',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
}
|
||
});
|
||
|
||
wsRef.current.connect().catch((error: any) => {
|
||
console.error('WebSocket连接失败:', error);
|
||
// Taro.showToast({
|
||
// title: 'WebSocket连接失败',
|
||
// icon: 'none',
|
||
// duration: 2000
|
||
// });
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
// 初始化时检查并生成AI Token
|
||
const token = checkAiToken();
|
||
console.log('AI Token初始化完成:', token);
|
||
|
||
// 检查userToken
|
||
const userToken = Taro.getStorageSync('AI_TOKEN');
|
||
console.log('当前UserToken:', userToken || 'anonymous');
|
||
|
||
// 初始化WebSocket
|
||
initWebSocket();
|
||
Taro.hideTabBar();
|
||
|
||
// 标记初始化完成
|
||
setIsInitialized(true);
|
||
|
||
// 延迟调试,确保状态更新完成
|
||
setTimeout(() => {
|
||
debugInputStatus();
|
||
}, 100);
|
||
|
||
return () => {
|
||
if (wsRef.current) {
|
||
wsRef.current.close();
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages]);
|
||
|
||
// 发送消息
|
||
const handleSendMessage = async (content: string) => {
|
||
if (!content.trim() || isLoading) return;
|
||
|
||
// 检查并确保AI Token存在
|
||
const aiToken = checkAiToken();
|
||
if (!aiToken) {
|
||
Taro.showToast({
|
||
title: '初始化失败,请重试',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 检查WebSocket连接状态
|
||
if (!wsConnected) {
|
||
Taro.showToast({
|
||
title: '连接已断开,请稍后重试',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
return;
|
||
}
|
||
|
||
const userMessage: Message = {
|
||
id: Date.now().toString(),
|
||
type: 'user',
|
||
query: content.trim(),
|
||
timestamp: Date.now(),
|
||
user: `${Taro.getStorageSync('AI_TOKEN') || 'anonymous'}`
|
||
};
|
||
|
||
// 立即添加用户消息
|
||
setMessages(prev => [...prev, userMessage]);
|
||
setInputValue('');
|
||
setIsLoading(true);
|
||
|
||
// 立即添加一个空的AI消息占位符,准备接收流式回复
|
||
const aiPlaceholder: Message = {
|
||
id: (Date.now() + 1).toString(),
|
||
type: 'ai',
|
||
query: '',
|
||
timestamp: Date.now(),
|
||
isTyping: true
|
||
};
|
||
setMessages(prev => [...prev, aiPlaceholder]);
|
||
|
||
try {
|
||
await sendAiMessage({
|
||
query: content.trim(),
|
||
user: `${Taro.getStorageSync('AI_TOKEN') || 'anonymous'}`,
|
||
responseMode: 'streaming',
|
||
aiToken: aiToken, // 包含AI Token
|
||
});
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error);
|
||
setIsLoading(false);
|
||
// 移除AI占位符消息
|
||
setMessages(prev => prev.filter(msg => msg.id !== aiPlaceholder.id));
|
||
|
||
// 添加错误消息
|
||
const errorMessage: Message = {
|
||
id: (Date.now() + 2).toString(),
|
||
type: 'ai',
|
||
query: '抱歉,发送消息时出现错误,请检查网络连接后重试。',
|
||
timestamp: Date.now(),
|
||
isTyping: false
|
||
};
|
||
setMessages(prev => [...prev, errorMessage]);
|
||
|
||
Taro.showToast({
|
||
title: '发送失败,请重试',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
}
|
||
};
|
||
|
||
// 停止AI回复
|
||
// const handleStopMessage = async () => {
|
||
// if (currentTaskId) {
|
||
// try {
|
||
// await stopAiMessage({taskId: currentTaskId});
|
||
// setIsLoading(false);
|
||
// setCurrentTaskId('');
|
||
// setMessages(prev => prev.map(msg =>
|
||
// msg.isTyping ? {...msg, isTyping: false} : msg
|
||
// ));
|
||
// } catch (error) {
|
||
// console.error('停止消息失败:', error);
|
||
// }
|
||
// }
|
||
// };
|
||
|
||
// 处理快捷问题点击
|
||
const handleQuickQuestion = (question: string) => {
|
||
if (!isInitialized) {
|
||
Taro.showToast({
|
||
title: '正在初始化,请稍候...',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
return;
|
||
}
|
||
handleSendMessage(question);
|
||
};
|
||
|
||
// 手动重连WebSocket
|
||
const handleReconnect = () => {
|
||
if (wsRef.current) {
|
||
wsRef.current.close();
|
||
}
|
||
initWebSocket();
|
||
};
|
||
|
||
return (
|
||
<View className="ai-chat">
|
||
{!wsConnected && (
|
||
<View className="connection-status">
|
||
<View className="status-indicator offline">
|
||
连接已断开,正在重连...
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
onClick={handleReconnect}
|
||
className="reconnect-button"
|
||
>
|
||
立即重连
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
)}
|
||
<View className="chat-container">
|
||
<View className="messages-container">
|
||
{messages.length === 0 ? (
|
||
<View className="empty-state">
|
||
<View className="empty-icon">🤖</View>
|
||
<View className="empty-title">您好!我是AI助手</View>
|
||
<View className="empty-desc">
|
||
我可以为您解答各种问题,请输入您想了解的内容
|
||
</View>
|
||
</View>
|
||
) : (
|
||
messages.map((message) => (
|
||
<View key={message.id} className={`message ${message.type}`}>
|
||
<View className={`avatar ${message.type}-avatar`}>
|
||
{message.type === 'user' ? <User size={20}/> : '🤖'}
|
||
</View>
|
||
<View className="message-content">
|
||
<View className="message-text">
|
||
<MarkdownRenderer
|
||
content={message.query || (message.isTyping && message.type === 'ai' ? '正在思考中...' : '')}
|
||
className={`message-markdown ${message.type === 'user' ? 'user-markdown' : 'ai-markdown'}`}
|
||
/>
|
||
</View>
|
||
{message.isTyping && message.type === 'ai' && message.query && (
|
||
<View className="typing-cursor">|</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
))
|
||
)}
|
||
<View ref={messagesEndRef}/>
|
||
</View>
|
||
|
||
{messages.length === 0 && (
|
||
<View className="quick-questions">
|
||
<View className="quick-title">
|
||
💡 您可以问我:
|
||
</View>
|
||
<View className="questions-list">
|
||
{quickQuestions.map((question, index) => (
|
||
<View
|
||
key={index}
|
||
className="question-item"
|
||
onClick={() => handleQuickQuestion(question)}
|
||
>
|
||
<RichText
|
||
nodes={question}
|
||
space="nbsp"
|
||
/>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
<View className="input-container">
|
||
<div className="input-wrapper flex justify-between items-center">
|
||
<Home size={26} className={'bg-white'} onClick={() => Taro.reLaunch({url: '/pages/index/index'})}/>
|
||
<div className={'w-full mx-2'}>
|
||
<Textarea
|
||
className="message-input"
|
||
value={inputValue}
|
||
placeholder={
|
||
!isInitialized ? "正在初始化..." :
|
||
isLoading ? "AI正在回复中..." :
|
||
"请输入您的问题..."
|
||
}
|
||
onInput={(e) => setInputValue(e.detail.value)}
|
||
onConfirm={() => handleSendMessage(inputValue)}
|
||
autoHeight
|
||
maxlength={500}
|
||
/>
|
||
</div>
|
||
<div
|
||
className={'flex justify-center items-center pr-1'}
|
||
onClick={() => handleSendMessage(inputValue)}
|
||
>
|
||
<img alt={'发送'} src={'https://oss.wsdns.cn/20250709/13424d78bb004352864051d61afe9f0e.png'} width={'30px'} />
|
||
</div>
|
||
</div>
|
||
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
export default AiChat;
|