ChatInterface.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import React, { useState, useRef, useEffect } from 'react';
  2. import {
  3. PlusOutlined,
  4. CopyOutlined,
  5. LikeOutlined,
  6. DislikeOutlined,
  7. ArrowLeftOutlined,
  8. PaperClipOutlined,
  9. PhoneOutlined,
  10. } from '@ant-design/icons';
  11. import { Tooltip, message } from 'antd';
  12. import ReactMarkdown from 'react-markdown';
  13. import { useChatStore, Message } from '../store/chatStore';
  14. import { sendChatMessage } from '../api';
  15. import '../styles/index.scss';
  16. import aiIcon from '@/assets/public/aiIcon.png';
  17. export const ChatInterface: React.FC = () => {
  18. const {
  19. currentSessionId,
  20. sessions,
  21. selectedAppId,
  22. addMessage,
  23. addSession,
  24. updateCurrentSession,
  25. loading,
  26. setLoading,
  27. } = useChatStore();
  28. const [inputValue, setInputValue] = useState('');
  29. const [activeFeatures, setActiveFeatures] = useState<Set<string>>(new Set());
  30. const [abortController, setAbortController] = useState<AbortController | null>(null);
  31. const messagesEndRef = useRef<HTMLDivElement>(null);
  32. const inputRef = useRef<HTMLDivElement>(null);
  33. const currentSession = sessions.find((s) => s.id === currentSessionId);
  34. const messages = currentSession?.messages || [];
  35. useEffect(() => {
  36. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  37. }, [messages]);
  38. const handleSendMessage = async () => {
  39. if (!inputValue.trim() || loading) return;
  40. const userMessage: Message = {
  41. id: Date.now().toString(),
  42. role: 'user',
  43. content: inputValue.trim(),
  44. createTime: new Date().toISOString(),
  45. };
  46. let sessionId = currentSessionId;
  47. if (!sessionId) {
  48. const newSession = {
  49. id: Date.now().toString(),
  50. topic: inputValue.trim().slice(0, 20),
  51. appId: selectedAppId || undefined,
  52. messages: [userMessage],
  53. createTime: new Date().toISOString(),
  54. };
  55. addSession(newSession);
  56. sessionId = newSession.id;
  57. setInputValue('');
  58. return;
  59. }
  60. addMessage(sessionId, userMessage);
  61. const userText = inputValue.trim();
  62. setInputValue('');
  63. setLoading(true);
  64. const controller = new AbortController();
  65. setAbortController(controller);
  66. try {
  67. const assistantMessageId = (Date.now() + 1).toString();
  68. addMessage(sessionId, {
  69. id: assistantMessageId,
  70. role: 'assistant',
  71. content: '',
  72. createTime: new Date().toISOString(),
  73. });
  74. const response = await sendChatMessage({
  75. message: userText,
  76. appId: currentSession?.appId || selectedAppId || undefined,
  77. sessionId,
  78. });
  79. updateCurrentSession((session) => {
  80. const lastMsgIndex = session.messages.length - 1;
  81. if (lastMsgIndex >= 0 && session.messages[lastMsgIndex].id === assistantMessageId) {
  82. session.messages[lastMsgIndex].content = response || '抱歉,我暂时无法回答这个问题。';
  83. }
  84. });
  85. } catch (error: any) {
  86. if (error.name === 'AbortError') {
  87. message.info('请求已取消');
  88. } else {
  89. message.error('发送失败,请重试');
  90. }
  91. } finally {
  92. setLoading(false);
  93. setAbortController(null);
  94. }
  95. };
  96. const handleCancel = () => {
  97. abortController?.abort();
  98. setLoading(false);
  99. };
  100. const handleKeyDown = (e: React.KeyboardEvent) => {
  101. if (e.key === 'Enter' && !e.shiftKey) {
  102. e.preventDefault();
  103. handleSendMessage();
  104. }
  105. };
  106. const handleCopy = (content: string) => {
  107. navigator.clipboard.writeText(content);
  108. message.success('复制成功');
  109. };
  110. const toggleFeature = (feature: string) => {
  111. setActiveFeatures((prev) => {
  112. const next = new Set(prev);
  113. if (next.has(feature)) {
  114. next.delete(feature);
  115. } else {
  116. next.add(feature);
  117. }
  118. return next;
  119. });
  120. };
  121. const handleSuggestionClick = (text: string) => {
  122. setInputValue(text);
  123. inputRef.current?.focus();
  124. };
  125. const handleBack = () => {
  126. window.close();
  127. setTimeout(() => {
  128. window.location.href = '/appCenter';
  129. }, 100);
  130. };
  131. const features = [
  132. { key: 'deep-think', label: '深度思考/思维链', icon: '🔬' },
  133. { key: 'search', label: '联网搜索', icon: '🌐' },
  134. ];
  135. const suggestions = [
  136. '专业知识',
  137. '职能管理',
  138. '项目级应用',
  139. ];
  140. return (
  141. <div className="chat-main">
  142. {/* Back Button */}
  143. <button className="back-btn" onClick={handleBack}>
  144. <ArrowLeftOutlined /> 开放平台
  145. </button>
  146. {/* Chat Messages */}
  147. <div className="chat-messages">
  148. {messages.length === 0 ? (
  149. <WelcomeScreen
  150. features={features}
  151. activeFeatures={activeFeatures}
  152. toggleFeature={toggleFeature}
  153. suggestions={suggestions}
  154. onSuggestionClick={handleSuggestionClick}
  155. inputValue={inputValue}
  156. setInputValue={setInputValue}
  157. onSend={handleSendMessage}
  158. loading={loading}
  159. inputRef={inputRef}
  160. />
  161. ) : (
  162. <>
  163. {messages.map((msg) => (
  164. <div key={msg.id} className={`message-bubble ${msg.role}`}>
  165. <div className="message-avatar">
  166. {msg.role === 'assistant' ? '🤖' : '👤'}
  167. </div>
  168. <div className="bubble-content">
  169. <ReactMarkdown>{msg.content}</ReactMarkdown>
  170. {msg.role === 'assistant' && (
  171. <div style={{ display: 'flex', gap: '8px', marginTop: '8px', opacity: 0.6 }}>
  172. <button onClick={() => handleCopy(msg.content)} style={{ background: 'none', border: 'none', cursor: 'pointer' }}>
  173. <CopyOutlined style={{ fontSize: '14px' }} />
  174. </button>
  175. <button style={{ background: 'none', border: 'none', cursor: 'pointer' }}>
  176. <LikeOutlined style={{ fontSize: '14px' }} />
  177. </button>
  178. <button style={{ background: 'none', border: 'none', cursor: 'pointer' }}>
  179. <DislikeOutlined style={{ fontSize: '14px' }} />
  180. </button>
  181. </div>
  182. )}
  183. </div>
  184. </div>
  185. ))}
  186. {loading && (
  187. <div className="message-bubble assistant">
  188. <div className="message-avatar">🤖</div>
  189. <div className="bubble-content">
  190. <div className="loading-indicator">
  191. <div className="loading-dot"></div>
  192. <div className="loading-dot"></div>
  193. <div className="loading-dot"></div>
  194. </div>
  195. </div>
  196. </div>
  197. )}
  198. <div ref={messagesEndRef} />
  199. </>
  200. )}
  201. </div>
  202. {/* Footer */}
  203. <div className="chat-footer">
  204. <div className="footer-content">
  205. <span>内容由 AI 生成,请仔细甄别</span>
  206. <span className="footer-divider">|</span>
  207. <a className="footer-link" href="https://beian.miit.gov.cn/#/Integrated/index" target="_blank" rel="noopener noreferrer">
  208. 上海建科工程咨询有限公司
  209. </a>
  210. </div>
  211. </div>
  212. </div>
  213. );
  214. };
  215. // Welcome Screen Component
  216. interface WelcomeScreenProps {
  217. features: Array<{ key: string; label: string; icon: string; isNew?: boolean }>;
  218. activeFeatures: Set<string>;
  219. toggleFeature: (feature: string) => void;
  220. suggestions: string[];
  221. onSuggestionClick: (text: string) => void;
  222. inputValue: string;
  223. setInputValue: (value: string) => void;
  224. onSend: () => void;
  225. loading: boolean;
  226. inputRef: React.RefObject<HTMLDivElement>;
  227. }
  228. const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
  229. features,
  230. activeFeatures,
  231. toggleFeature,
  232. suggestions,
  233. onSuggestionClick,
  234. inputValue,
  235. setInputValue,
  236. onSend,
  237. loading,
  238. inputRef,
  239. }) => {
  240. return (
  241. <div className="welcome-screen">
  242. {/* Logo */}
  243. <div className="welcome-logo">
  244. <img src={aiIcon} alt="AI Icon" className="logo-icon" />
  245. <div className="logo-text">建科小智</div>
  246. <div className="logo-slogan">,智能问答</div>
  247. </div>
  248. {/* Input Container */}
  249. <div className="welcome-input-container">
  250. <div className="chat-input-wrapper">
  251. <div
  252. ref={inputRef}
  253. className="input-placeholder"
  254. contentEditable
  255. onInput={(e) => setInputValue(e.currentTarget.textContent || '')}
  256. onKeyDown={(e) => {
  257. if (e.key === 'Enter' && !e.shiftKey) {
  258. e.preventDefault();
  259. onSend();
  260. }
  261. }}
  262. suppressContentEditableWarning
  263. data-placeholder="请输入你的问题或需求"
  264. />
  265. {/* Input Actions - 包含功能按钮和发送按钮 */}
  266. <div className="input-actions">
  267. <div className="left-actions">
  268. {/* 附件按钮 */}
  269. <button className="action-btn" title="上传附件">
  270. <PaperClipOutlined />
  271. </button>
  272. {/* 功能按钮 */}
  273. {features.map((feature) => (
  274. <button
  275. key={feature.key}
  276. className={`feature-btn ${activeFeatures.has(feature.key) ? 'active' : ''}`}
  277. onClick={() => toggleFeature(feature.key)}
  278. title={feature.label}
  279. >
  280. <span className="feature-icon">{feature.icon}</span>
  281. <span className="feature-label">{feature.label}</span>
  282. {feature.isNew && <span className="new-tag">new</span>}
  283. </button>
  284. ))}
  285. </div>
  286. <div className="right-actions">
  287. <button className="voice-btn" title="语音输入">
  288. <PhoneOutlined />
  289. </button>
  290. <button
  291. className="send-btn"
  292. onClick={onSend}
  293. disabled={!inputValue.trim() || loading}
  294. title="发送"
  295. >
  296. <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
  297. <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
  298. </svg>
  299. </button>
  300. </div>
  301. </div>
  302. </div>
  303. </div>
  304. {/* Suggestion Tags */}
  305. <div className="suggestion-container">
  306. <div className="suggestion-title">我猜你在找...</div>
  307. <div className="suggestion-list">
  308. {suggestions.map((text, index) => (
  309. <div key={index} className="suggestion-item" onClick={() => onSuggestionClick(text)}>
  310. {text}
  311. </div>
  312. ))}
  313. </div>
  314. </div>
  315. </div>
  316. );
  317. };