Files
template-10556/src/pages/ai/index.tsx
2025-07-11 13:45:26 +08:00

417 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;