diff --git a/config/routes.js b/config/routes.js index e67e5f5..0b3b878 100644 --- a/config/routes.js +++ b/config/routes.js @@ -223,6 +223,12 @@ export default [ name: 'systemApplicationStatistics', component: './systemApplicationStatistics/TimeSheetList', }, + { + path: '/topnavbar00/hrefficiency/ChatConversation', + // icon: 'bank', + name: 'chatConversation', + component: './chatConversation/ChatConversation', + }, ], }, // 知识库管理 diff --git a/package.json b/package.json index 43762f9..83a1bb9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "react": "^18.2.0", "react-contexify": "^5.0.0", "react-dom": "^18.2.0", - "react-split-pane": "^0.1.92" + "react-markdown": "^10.1.0", + "react-split-pane": "^0.1.92", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/react": "^18.0.0", diff --git a/src/config/api.js b/src/config/api.js new file mode 100644 index 0000000..a513a9a --- /dev/null +++ b/src/config/api.js @@ -0,0 +1,111 @@ +// API配置文件 +export const API_CONFIG = { + // DeepSeek API配置 + DEEPSEEK: { + BASE_URL: 'https://api.deepseek.com/v1', + MODEL: 'deepseek-chat', + API_KEY: + process.env.UMI_APP_DEEPSEEK_API_KEY || + process.env.REACT_APP_DEEPSEEK_API_KEY || + 'your-api-key-here', + MAX_TOKENS: 2000, + TEMPERATURE: 0.7, + SYSTEM_PROMPT: '你是一个智能AI助手,请用简洁、专业、友好的方式回答用户的问题。' + }, + + // 其他API配置 + OPENAI: { + BASE_URL: 'https://api.openai.com/v1', + MODEL: 'gpt-3.5-turbo', + API_KEY: process.env.UMI_APP_OPENAI_API_KEY || '', + MAX_TOKENS: 2000, + TEMPERATURE: 0.7 + } +} + +// 调试:开发阶段临时查看是否拿到 +if (process.env.NODE_ENV === 'development') { + // 只显示是否存在,不打印具体 key + // eslint-disable-next-line no-console + console.log( + 'DeepSeek key loaded =', + !!process.env.UMI_APP_DEEPSEEK_API_KEY + ) +} + +// DeepSeek 调用 +export const callDeepSeekAPI = async (messages, options = {}) => { + const cfg = API_CONFIG.DEEPSEEK + const apiKey = cfg.API_KEY + if (!apiKey || apiKey === 'your-api-key-here') { + throw new Error('NO_API_KEY') + } + + // 组装消息(如果历史里已经含 system 就不重复) + const hasSystem = messages.some(m => m.role === 'system') + const finalMessages = hasSystem + ? messages + : [{ role: 'system', content: cfg.SYSTEM_PROMPT }, ...messages] + + const payload = { + model: cfg.MODEL, + messages: finalMessages.map(m => ({ role: m.role, content: m.content })), + temperature: options.temperature ?? cfg.TEMPERATURE, + max_tokens: options.maxTokens ?? cfg.MAX_TOKENS, + stream: false + } + + const resp = await fetch(`${cfg.BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify(payload) + }) + + const data = await resp.json().catch(() => ({})) + + if (!resp.ok) { + // 统一抛出,交给 handleAPIError + const msg = data?.error?.message || resp.statusText || 'API_ERROR' + const err = new Error(msg) + err.status = resp.status + err.code = data?.error?.code + throw err + } + + const content = data?.choices?.[0]?.message?.content?.trim() + if (!content) throw new Error('EMPTY_RESPONSE') + return content +} + + +// DeepSeek 流式调用 + + + + + + +// 错误处理函数 +export const handleAPIError = (error) => { + const raw = error?.message || '' + const status = error?.status + const code = error?.code + + if (raw === 'NO_API_KEY') return '未配置 API Key,请在 .env 里加 UMI_APP_DEEPSEEK_API_KEY 并重启。' + if (raw === 'EMPTY_RESPONSE') return '接口返回空结果,请重试。' + + // 典型鉴权问题 + if (status === 401 || /unauthorized|invalid api key/i.test(raw) || code === 'invalid_api_key') { + return 'API密钥无效,请检查是否正确。' + } + + if (status === 429 || /rate limit/i.test(raw)) return '请求过多,被限流。' + if (status === 500) return '服务端错误,请稍后再试。' + if (/timeout/i.test(raw)) return '请求超时,请检查网络。' + if (/certificate|SSL/i.test(raw)) return '证书/网络问题,请更换网络或代理。' + + return `调用失败:${raw || '未知错误'}` +} diff --git a/src/pages/chatConversation/ChatConversation.js b/src/pages/chatConversation/ChatConversation.js new file mode 100644 index 0000000..51a5e3d --- /dev/null +++ b/src/pages/chatConversation/ChatConversation.js @@ -0,0 +1,413 @@ +import React, { useState, useRef, useEffect } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { + Input, + Button, + Typography, + Spin, + message, + Layout, + Tooltip, + Input as AntInput, + Modal, + Upload, + Dropdown, + Space, + Select, +} from 'antd' +import { + ArrowUpOutlined, + LinuxOutlined, + RobotOutlined, + LoadingOutlined, + CopyOutlined, + ReloadOutlined, + PaperClipOutlined, +} from '@ant-design/icons' +import { callDeepSeekAPI, handleAPIError } from './models/api' +import { conversationStore } from '@/utils/pageConversationStore' +import './ChatConversation.less' + +const { Text } = Typography +const { TextArea } = Input +const { Sider, Content } = Layout + +function ChatConversation() { + // 会话相关状态 - 使用独立的存储 + const [conversations, setConversations] = useState(conversationStore.getConversations()) + const [currentConversationId, setCurrentConversationId] = useState(conversationStore.getCurrentConversationId()) + const [inputValue, setInputValue] = useState('') + const [loading, setLoading] = useState(false) + const [sidebarExpanded, setSidebarExpanded] = useState(false) + const [editModalVisible, setEditModalVisible] = useState(false) + const [editingConversation, setEditingConversation] = useState(null) + const [newTitle, setNewTitle] = useState('') + + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + + // 获取当前会话 + const currentConversation = conversations.find(conv => conv.id === currentConversationId) + const currentMessages = currentConversation?.messages || [] + // 自动滚动到底部 + const chatScrollRef = useRef(null); + useEffect(() => { + if (chatScrollRef.current) { + chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight; + } + }, [currentMessages, loading]); // currentMessages 是你的消息数组 + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + } + + + const handleChange = value => { + console.log(`selected ${value}`); + } + + + // 订阅对话状态变化 + useEffect(() => { + const unsubscribe = conversationStore.subscribe(({ conversations, currentConversationId }) => { + setConversations(conversations) + setCurrentConversationId(currentConversationId) + }) + + return unsubscribe + }, []) + + useEffect(() => { + scrollToBottom() + }, [currentMessages]) + + // 创建新对话 + const createNewConversation = () => { + conversationStore.createNewConversation() + setInputValue('') + } + + // 切换对话 + const switchConversation = (conversationId) => { + conversationStore.switchConversation(conversationId) + setInputValue('') + } + + // 删除对话 + const deleteConversation = (conversationId) => { + if (conversations.length <= 1) { + message.warning('至少需要保留一个对话') + return + } + + conversationStore.deleteConversation(conversationId) + } + + // 编辑对话标题 + const editConversationTitle = (conversation) => { + setEditingConversation(conversation) + setNewTitle(conversation.title) + setEditModalVisible(true) + } + + // 保存对话标题 + const saveConversationTitle = () => { + if (!newTitle.trim()) { + message.warning('标题不能为空') + return + } + + conversationStore.updateConversationTitle(editingConversation.id, newTitle.trim()) + + setEditModalVisible(false) + setEditingConversation(null) + setNewTitle('') + } + + // 发送消息 + const sendMessage = async () => { + const messageText = inputValue.trim() + if (!messageText || loading) return + + // 添加用户消息到独立存储 + conversationStore.addMessage('user', messageText) + + setInputValue('') + setLoading(true) + + try { + // 准备发送给API的消息历史 + const apiMessages = currentMessages.map(msg => ({ + role: msg.role, + content: msg.content + })).concat({ + role: 'user', + content: messageText + }) + + // 调用DeepSeek API + const assistantResponse = await callDeepSeekAPI(apiMessages) + + // 添加助手回复到独立存储 + conversationStore.addMessage('assistant', assistantResponse) + } catch (error) { + const errorMessage = handleAPIError(error) + message.error(errorMessage) + + // 添加错误消息到独立存储 + conversationStore.addMessage('assistant', `抱歉,我遇到了一个问题:${errorMessage}`) + } finally { + setLoading(false) + } + } + + // 处理回车键发送 + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendMessage() + } + } + + // 清空当前对话 + const clearCurrentChat = () => { + conversationStore.clearCurrentConversation() + } + + // 兼容剪贴板 + const copyToClipboard = async (text) => { + if (!text) return Promise.reject(new Error("exmpty text")) + + // 现代 API,需 HTTPS 或 localhost(window.isSecureContext) + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text) + return + } catch (e) { + // 若失败,继续使用回退方案 + console.warn('navigator.clipboard failed, fallback to execCommand', e) + } + } + + // 回退方案 + return new Promise((resolve, reject) => { + try { + const ta = document.createElement('textarea') + ta.value = text + ta.setAttribute('readonly', '') + ta.style.position = 'fixed' + ta.style.left = '-9999px' + document.body.appendChild(ta) + ta.select() + ta.setSelectionRange(0, ta.value.length) + const ok = document.execCommand('copy') + document.body.removeChild(ta) + if (ok) resolve() + else reject(new Error('execCommand failed')) + } catch (err) { + reject(err) + } + }) + } + + // 渲染单条消息 ================================== + const renderMessage = (msg, index) => { + const isUser = msg.role === 'user' + return ( +
+ {!isUser && ( +
+ +
+ )} + {isUser &&
} + +
+
+ {isUser ? '' : 'AI对话'} + + {msg.timestamp + ? (typeof msg.timestamp === 'string' + ? msg.timestamp + : msg.timestamp.toLocaleString()) + : ''} + + {!isUser && ( + + +
+ + {/* 回答框 */} +
+ + {msg.content || ''} + +
+
+
+ ) + } + // ================================================= + + return ( +
+
+
+ {currentMessages.length === 0 && !loading && ( + // 已有默认初始化对话,无需此处提示 +
+

开始对话吧

+

提出一个问题,比如:请帮我总结一段文本。

+
+ )} + + {/* 第一句话 */} + {currentMessages.map(renderMessage)} + + {loading && ( +
+
+ +
+
+
+ AI + 正在思考… +
+
+ } + size='small' + /> + + + +
+
+
+ )} +
+
+
+ + + {/* 问答输入框 */} +
+
+
+