diff --git a/.env.development.example b/.env.development.example index 78ca8aaef..b7ca77488 100644 --- a/.env.development.example +++ b/.env.development.example @@ -22,4 +22,5 @@ VUE_APP_LOGOUT_PATH = '/core/auth/logout/' # Dev server for core proxy VUE_APP_CORE_HOST = 'http://localhost:8080' VUE_APP_CORE_WS = 'ws://localhost:8080' +VUE_APP_KAEL_WS = 'ws://localhost:8083' VUE_APP_ENV = 'development' diff --git a/package.json b/package.json index ec1ea2c12..3f634e159 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "devDependencies": { "@babel/core": "7.0.0", "@babel/register": "7.0.0", + "@traptitech/markdown-it-katex": "^3.6.0", "@vue/cli-plugin-babel": "3.6.0", "@vue/cli-plugin-eslint": "^3.9.1", "@vue/cli-plugin-unit-jest": "3.6.3", @@ -97,10 +98,13 @@ "eslint-plugin-vue": "5.2.2", "eslint-plugin-vue-i18n": "^0.3.0", "github-markdown-css": "^5.1.0", + "highlight.js": "^11.9.0", "html-webpack-plugin": "3.2.0", "husky": "^4.2.3", "less-loader": "^5.0.0", "lint-staged": "^10.1.2", + "markdown-it": "^13.0.2", + "markdown-it-link-attributes": "^4.0.1", "mockjs": "1.0.1-beta3", "runjs": "^4.3.2", "sass": "~1.32.6", diff --git a/src/assets/img/chat.png b/src/assets/img/chat.png new file mode 100644 index 000000000..05826b3c6 Binary files /dev/null and b/src/assets/img/chat.png differ diff --git a/src/assets/img/robot-assistant.png b/src/assets/img/robot-assistant.png new file mode 100644 index 000000000..c9d10321a Binary files /dev/null and b/src/assets/img/robot-assistant.png differ diff --git a/src/components/Apps/ChatAi/components/ChitChat/ChatInput.vue b/src/components/Apps/ChatAi/components/ChitChat/ChatInput.vue new file mode 100644 index 000000000..e915bbdf4 --- /dev/null +++ b/src/components/Apps/ChatAi/components/ChitChat/ChatInput.vue @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + diff --git a/src/components/Apps/ChatAi/components/ChitChat/ChatMessage.vue b/src/components/Apps/ChatAi/components/ChitChat/ChatMessage.vue new file mode 100644 index 000000000..aaf1cd0c8 --- /dev/null +++ b/src/components/Apps/ChatAi/components/ChitChat/ChatMessage.vue @@ -0,0 +1,184 @@ + + + + + + + + + {{ $moment(item.message.create_time).format('YYYY-MM-DD HH:mm:ss') }} + + + + + + {{ item.message.content }} + + + + + + + + + + + + + + + + {{ i.label }} + + + + + + + + + + + + + diff --git a/src/components/Apps/ChatAi/components/ChitChat/MessageText.vue b/src/components/Apps/ChatAi/components/ChitChat/MessageText.vue new file mode 100644 index 000000000..e829601a0 --- /dev/null +++ b/src/components/Apps/ChatAi/components/ChitChat/MessageText.vue @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + diff --git a/src/components/Apps/ChatAi/components/ChitChat/index.vue b/src/components/Apps/ChatAi/components/ChitChat/index.vue new file mode 100644 index 000000000..39edc03c0 --- /dev/null +++ b/src/components/Apps/ChatAi/components/ChitChat/index.vue @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + diff --git a/src/components/Apps/ChatAi/components/Sidebar/index.vue b/src/components/Apps/ChatAi/components/Sidebar/index.vue new file mode 100644 index 000000000..063be26d1 --- /dev/null +++ b/src/components/Apps/ChatAi/components/Sidebar/index.vue @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/components/Apps/ChatAi/index.vue b/src/components/Apps/ChatAi/index.vue new file mode 100644 index 000000000..bf552ee30 --- /dev/null +++ b/src/components/Apps/ChatAi/index.vue @@ -0,0 +1,112 @@ + + + + + {{ title }} + + + {{ $tc('common.NewChat') }} + + + + + + + + + + + + + + + + + diff --git a/src/components/Apps/ChatAi/useChat.js b/src/components/Apps/ChatAi/useChat.js new file mode 100644 index 000000000..a94e86213 --- /dev/null +++ b/src/components/Apps/ChatAi/useChat.js @@ -0,0 +1,81 @@ +import store from '@/store' +import { pageScroll } from '@/utils/common' + +export const getInputFocus = () => { + const dom = document.querySelector('.chat-input .el-textarea__inner') + setTimeout(() => dom?.focus(), 200) +} + +export function useChat() { + const chatStore = {} + + const setLoading = (loading) => { + store.commit('chat/setLoading', loading) + } + + const onNewChat = (name) => { + const data = { + name: name || `new chat`, + id: 1, + conversation_id: '', + chats: [] + } + store.commit('chat/addChatToStore', data) + } + + const addMessageToActiveChat = (chat) => { + store.commit('chat/addMessageToActiveChat', chat) + } + + const removeLoadingMessageInChat = () => { + store.commit('chat/removeLoadingMessageInChat') + } + + const addChatMessageById = (chat) => { + store.commit('chat/addMessageToActiveChat', chat) + if (chat?.conversation_id) { + store.commit('chat/setActiveChatConversationId', chat.conversation_id) + } + pageScroll('scrollRef') + } + + const addTemporaryLoadingToChat = () => { + const temporaryChat = { + message: { + content: 'loading', + role: 'assistant', + create_time: new Date() + } + } + addChatMessageById(temporaryChat) + } + + const newChatAndAddMessageById = (chat) => { + onNewChat(chat.message.content) + addChatMessageById(chat) + } + + const removeLoadingAndAddMessageToChat = (chat) => { + store.commit('chat/removeLoadingMessageInChat') + store.commit('chat/addMessageToActiveChat', chat) + } + + const updateChaMessageContentById = (id, content) => { + store.commit('chat/updateChaMessageContentById', { id, content }) + pageScroll('scrollRef') + } + + return { + chatStore, + setLoading, + onNewChat, + getInputFocus, + addMessageToActiveChat, + newChatAndAddMessageById, + removeLoadingMessageInChat, + removeLoadingAndAddMessageToChat, + addChatMessageById, + addTemporaryLoadingToChat, + updateChaMessageContentById + } +} diff --git a/src/components/Apps/DrawerPanel/index.vue b/src/components/Apps/DrawerPanel/index.vue new file mode 100644 index 000000000..b2fca80a9 --- /dev/null +++ b/src/components/Apps/DrawerPanel/index.vue @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/i18n/langs/en.json b/src/i18n/langs/en.json index 6cc160b2f..a80eb8d71 100644 --- a/src/i18n/langs/en.json +++ b/src/i18n/langs/en.json @@ -737,6 +737,8 @@ "Pending": "Pending", "ClickCopy": "Click copy", "SyncUser": "Sync User", + "Reconnect": "Reconnect", + "NewChat": "New Chat", "imExport": { "ExportAll": "Export all", "ExportOnlyFiltered": "Export only filtered", @@ -752,6 +754,8 @@ "hasImportErrorItemMsg": "There is an error item, click the x icon to view the details, and continue to import after editing", "ImportFail": "Import failed" }, + "ChatHello": "Hello! What help can I offer you?", + "ConnectionDropped": "Connection dropped", "isValid": "Is valid", "nav": { "TempPassword": "Temporary password", @@ -1722,6 +1726,7 @@ "MailSend": "Mail send", "LDAPServerInfo": "LDAP Server", "LDAPUser": "LDAP User", + "ChatAI": "ChatAI", "InsecureCommandAlert": "Insecure command alert", "helpText": { "TempPassword": "For a while, there is a period of 300 seconds, failure immediately after use", diff --git a/src/i18n/langs/ja.json b/src/i18n/langs/ja.json index 38d792309..bbb07fecb 100644 --- a/src/i18n/langs/ja.json +++ b/src/i18n/langs/ja.json @@ -738,6 +738,8 @@ "Receivers": "受取人", "ClickCopy": "クリックしてコピー", "SyncUser": "ユーザーを同期する", + "Reconnect": "再接続", + "NewChat": "新しいチャット", "imExport": { "ExportAll": "すべてをエクスポート", "ExportOnlyFiltered": "検索結果のみエクスポート", @@ -753,6 +755,8 @@ "dragUploadFileInfo": "ここにファイルをドラッグするか、ここをクリックしてアップロードしてください", "hasImportErrorItemMsg": "インポート失敗項目があります。左側のxをクリックして失敗原因を確認し、表をクリックして編集した後、失敗項目のインポートを続けることができます" }, + "ChatHello": "こんにちは!私はあなたを助けることができますか?", + "ConnectionDropped": "接続が切断されました", "fileType": "ファイルタイプ", "isValid": "有効", "nav": { @@ -1722,6 +1726,7 @@ "MailSend": "メール送信", "LDAPServerInfo": "LDAPサーバー", "LDAPUser": "LDAPユーザー", + "ChatAI": "ChatAI", "helpText": { "TempPassword": "一時パスワードの有効期間は300秒で、使用後すぐに失効します", "ApiKeyList": "Api keyを使用してリクエストヘッダに署名します。リクエストのヘッダごとに異なります。使用ドキュメントを参照してください", diff --git a/src/i18n/langs/zh.json b/src/i18n/langs/zh.json index 4b3da781c..cb7f43a9a 100644 --- a/src/i18n/langs/zh.json +++ b/src/i18n/langs/zh.json @@ -789,6 +789,8 @@ "Receivers": "接收人", "ClickCopy": "点击复制", "SyncUser": "同步用户", + "Reconnect": "重新连接", + "NewChat": "新聊天", "imExport": { "ExportAll": "导出所有", "ExportOnlyFiltered": "仅导出搜索结果", @@ -804,6 +806,8 @@ "dragUploadFileInfo": "将文件拖到此处,或点击此处上传", "hasImportErrorItemMsg": "存在导入失败项,点击左侧 x 查看失败原因,点击表格编辑后,可以继续导入失败项" }, + "ChatHello": "你好!我能为你提供什么帮助?", + "ConnectionDropped": "连接已断开", "fileType": "文件类型", "isValid": "有效", "nav": { @@ -1717,6 +1721,7 @@ "MailSend": "邮件发送", "LDAPServerInfo": "LDAP 服务器", "LDAPUser": "LDAP 用户", + "ChatAI": "ChatAI", "helpText": { "TempPassword": "临时密码有效期为 300 秒,使用后立刻失效", "ApiKeyList": "使用 Api key 签名请求头进行认证,每个请求的头部是不一样的, 相对于 Token 方式,更加安全,请查阅文档使用;为降低泄露风险,Secret 仅在生成时可以查看, 每个用户最多支持创建 10 个", diff --git a/src/icons/svg/chat.svg b/src/icons/svg/chat.svg new file mode 100644 index 000000000..b0efe1940 --- /dev/null +++ b/src/icons/svg/chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/svg/collapse.svg b/src/icons/svg/collapse.svg new file mode 100644 index 000000000..c533d08da --- /dev/null +++ b/src/icons/svg/collapse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/svg/copy.svg b/src/icons/svg/copy.svg new file mode 100644 index 000000000..506c965e5 --- /dev/null +++ b/src/icons/svg/copy.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/layout/components/AppMain.vue b/src/layout/components/AppMain.vue index 3439249df..b93e98d76 100644 --- a/src/layout/components/AppMain.vue +++ b/src/layout/components/AppMain.vue @@ -5,19 +5,47 @@ + + + diff --git a/src/store/modules/chat.js b/src/store/modules/chat.js new file mode 100644 index 000000000..b679c6825 --- /dev/null +++ b/src/store/modules/chat.js @@ -0,0 +1,53 @@ + +const state = { + loading: false, + tabNum: 0, + activeTab: 0, + chatsStore: [], + activeChat: {} +} + +const mutations = { + setLoading(state, loading) { + state.loading = loading + }, + + setActiveChatConversationId(state, id) { + state.activeChat.conversation_id = id + }, + + addChatToStore(state, data) { + state.activeChat = data + }, + + addMessageToActiveChat(state, chat) { + state.activeChat.chats?.push(chat) + }, + + removeLoadingMessageInChat(state) { + const { chats } = state.activeChat + const length = chats?.length + if (length > 0) { + const lastChat = chats[length - 1] + if (lastChat?.message?.content === 'loading') { + chats.pop() + } + } + }, + + updateChaMessageContentById(state, { id, content }) { + const chats = state.activeChat.chats || [] + const filterChat = chats.filter((chat) => chat.message.id === id)?.[0] || {} + filterChat.message.content = content + } +} + +const actions = { +} + +export default { + namespaced: true, + state, + mutations, + actions +} diff --git a/src/utils/common.js b/src/utils/common.js index 7c193a92a..0af50a3de 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -400,4 +400,11 @@ export function getQueryFromPath(path) { return Object.fromEntries(url.searchParams) } +export const pageScroll = _.throttle((id) => { + const dom = document.getElementById(id) + if (dom) { + dom.scrollTop = dom?.scrollHeight + } +}, 200) + export { BASE_URL } diff --git a/src/utils/socket.js b/src/utils/socket.js new file mode 100644 index 000000000..241edd4d5 --- /dev/null +++ b/src/utils/socket.js @@ -0,0 +1,104 @@ + +export let ws = null +let lockReconnect = false +const timeout = 10 * 1000 +let timeoutObj = null // 心跳心跳倒计时 +let serverTimeoutObj = null // 心跳倒计时 +let timeoutNum = null // 断开、重连倒计时 +let globalCallback = null // 监听服务端消息 +let globalUrl = null + +/** + * @param {String} url + * @param {Function} callback + */ +export function createWebSocket(url = globalUrl, callback = globalCallback) { + globalUrl = url + globalCallback = callback + + ws = new WebSocket(url) + ws.onopen = () => { + start() + } + ws.onmessage = onMessage + ws.onerror = onError + ws.onclose = onClose + ws.onsend = onSend +} + +// 发送消息 +export function onSend(message) { + if (typeof message !== 'string') { + message = JSON.stringify(message) + } + ws?.send(message) +} + +// 接受服务端消息 +export function onMessage(res) { + const msgData = res.data + if (typeof msgData !== 'object' && msgData !== 'Connect success' && msgData !== 'pong') { + let data = msgData.replace(/\ufeff/g, '') + try { + data = JSON.parse(data) + globalCallback(data) + } catch (error) { + console.log('返回心跳') + } + reset() + } +} + +// 连接失败 +export function onError() { + reconnect() +} + +// 连接关闭 +export function onClose() { +} + +// 断开关闭 +export function closeWebSocket() { + ws?.close() + ws = null + lockReconnect = false +} + +// 发送心跳 +export function start() { + timeoutObj && clearTimeout(timeoutObj) + serverTimeoutObj && clearTimeout(serverTimeoutObj) + timeoutObj = setTimeout(function() { + if (ws?.readyState === 1) { + ws.send('ping') + } else { + // reconnect() + } + serverTimeoutObj = setTimeout(function() { + // 连接超时 + // ws.close() + }, timeout) + }, timeout) +} + +// 重置心跳 +export function reset() { + clearTimeout(timeoutObj) + clearTimeout(serverTimeoutObj) + start() +} + +// 重新连接 +export function reconnect() { + if (lockReconnect) { + return + } + lockReconnect = true + // 设置延迟避免请求过多 + timeoutNum && clearTimeout(timeoutNum) + timeoutNum = setTimeout(function() { + createWebSocket() + lockReconnect = false + }, 10000) +} diff --git a/src/views/settings/Feature/Chat.vue b/src/views/settings/Feature/Chat.vue index 893386bcf..e746888fa 100644 --- a/src/views/settings/Feature/Chat.vue +++ b/src/views/settings/Feature/Chat.vue @@ -1,7 +1,7 @@ - + @@ -9,6 +9,7 @@