mirror of
https://github.com/jumpserver/lina.git
synced 2026-01-29 21:28:52 +00:00
feat: 添加chat聊天
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
src/assets/img/chat.png
Normal file
BIN
src/assets/img/chat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/img/robot-assistant.png
Normal file
BIN
src/assets/img/robot-assistant.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
130
src/components/Apps/ChatAi/components/ChitChat/ChatInput.vue
Normal file
130
src/components/Apps/ChatAi/components/ChitChat/ChatInput.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="value"
|
||||
type="textarea"
|
||||
:disabled="isLoading"
|
||||
@compositionstart="isIM = true"
|
||||
@compositionend="isIM = false"
|
||||
@keypress.native="onKeyEnter"
|
||||
/>
|
||||
<div class="input-action">
|
||||
<span class="right">
|
||||
<i class="fa fa-send" :class="{'active': value }" @click="onSendHandle" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
import { useChat } from '../../useChat.js'
|
||||
const { setLoading } = useChat()
|
||||
|
||||
export default {
|
||||
props: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isIM: false,
|
||||
value: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
isLoading: state => state.chat.loading
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
onKeyEnter(event) {
|
||||
if (!this.isIM) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
this.onSendHandle()
|
||||
}
|
||||
} else {
|
||||
if (event.key === 'Enter' && event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
this.onSendHandle()
|
||||
}
|
||||
}
|
||||
},
|
||||
onSendHandle() {
|
||||
if (!this.value) return
|
||||
|
||||
setLoading(true)
|
||||
this.$emit('send', this.value)
|
||||
this.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
.action {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
width: 100%;
|
||||
&>>> .el-select {
|
||||
.el-input__inner {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
border-radius: 16px;
|
||||
background-color: #f7f7f8;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 16px;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 12px;
|
||||
&:hover {
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
&>>> .el-textarea {
|
||||
height: 100%;
|
||||
.el-textarea__inner {
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
.el-textarea.is-disabled + .input-action {
|
||||
background-color: #F5F7FA;
|
||||
cursor: no-drop;
|
||||
i {
|
||||
cursor: no-drop;
|
||||
}
|
||||
}
|
||||
.input-action {
|
||||
overflow: hidden;
|
||||
padding: 0 16px 15px;
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
.right {
|
||||
float: right;
|
||||
.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
i {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
184
src/components/Apps/ChatAi/components/ChitChat/ChatMessage.vue
Normal file
184
src/components/Apps/ChatAi/components/ChitChat/ChatMessage.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="chat-item" :class="{'user-role': isUserRole}">
|
||||
<div class="avatar">
|
||||
<el-avatar :src="isUserRole ? userUrl : chatUrl" class="header-avatar" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="operational">
|
||||
<span class="date">
|
||||
{{ $moment(item.message.create_time).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="message">
|
||||
<div class="message-content">
|
||||
<span v-if="isSystemError" class="error">
|
||||
{{ item.message.content }}
|
||||
</span>
|
||||
<span v-else class="text">
|
||||
<MessageText :message="item.message" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="action">
|
||||
<el-tooltip
|
||||
v-if="isSystemError && isLoading"
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="$tc('common.Reconnect')"
|
||||
>
|
||||
<svg-icon icon-class="refresh" @click="onRefresh" />
|
||||
</el-tooltip>
|
||||
<el-dropdown v-else size="small" @command="handleCommand">
|
||||
<span class="el-dropdown-link">
|
||||
<i class="fa fa-ellipsis-v" />
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item v-for="i in dropdownOptions" :key="i.action" :command="i.action">
|
||||
{{ i.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MessageText from './MessageText.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import { copy } from '@/utils/common'
|
||||
import { useChat } from '../../useChat.js'
|
||||
import { reconnect } from '@/utils/socket'
|
||||
|
||||
const { setLoading, removeLoadingMessageInChat } = useChat()
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MessageText
|
||||
},
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chatUrl: require('@/assets/img/chat.png'),
|
||||
userUrl: '/api/v1/settings/logo/',
|
||||
dropdownOptions: [
|
||||
{
|
||||
action: 'copy',
|
||||
label: this.$t('common.Copy')
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
isLoading: state => state.chat.loading
|
||||
}),
|
||||
isUserRole() {
|
||||
return this.item.message?.role === 'user'
|
||||
},
|
||||
isSystemError() {
|
||||
return this.item.type === 'error' && this.item.message?.role === 'assistant'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onRefresh() {
|
||||
reconnect()
|
||||
removeLoadingMessageInChat()
|
||||
setLoading(false)
|
||||
},
|
||||
handleCommand(value) {
|
||||
if (value === 'copy') {
|
||||
copy(this.item.message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-item {
|
||||
display: flex;
|
||||
padding: 16px 14px 0;
|
||||
&:last-child {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 2px;
|
||||
.header-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
margin-left: 6px;
|
||||
overflow: hidden;
|
||||
.operational {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
.copy {
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.message {
|
||||
display: -webkit-box;
|
||||
.message-content {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 2px 12px 12px;
|
||||
background-color: #f0f1f5;
|
||||
}
|
||||
.action {
|
||||
.svg-icon {
|
||||
margin-top: 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 3px;
|
||||
}
|
||||
.el-dropdown {
|
||||
height: 32px;
|
||||
line-height: 37px;
|
||||
margin-left: 4px;
|
||||
font-size: 13px;
|
||||
.el-dropdown-link {
|
||||
i {
|
||||
font-size: 15px;
|
||||
color: #8d9091;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.user-role {
|
||||
flex-direction: row-reverse;
|
||||
.content {
|
||||
margin-right: 10px;
|
||||
.operational {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.message {
|
||||
flex-direction: row-reverse;
|
||||
.message-content {
|
||||
background-color: var(--menu-hover);
|
||||
border-radius: 12px 2px 12px 12px;
|
||||
}
|
||||
.el-dropdown {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
src/components/Apps/ChatAi/components/ChitChat/MessageText.vue
Normal file
161
src/components/Apps/ChatAi/components/ChitChat/MessageText.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<div ref="textRef" class="leading-relaxed break-words">
|
||||
<span v-if="message.content === 'loading'" class="loading-box">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<div v-else class="inline-block markdown-body" v-html="text" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdKatex from '@traptitech/markdown-it-katex'
|
||||
import mila from 'markdown-it-link-attributes'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/atom-one-dark.css'
|
||||
import { copy } from '@/utils/common'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
markdown: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
text() {
|
||||
const value = this.message?.content || ''
|
||||
if (value) {
|
||||
return this.markdown.render(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.init()
|
||||
},
|
||||
updated() {
|
||||
this.addCopyEvents()
|
||||
},
|
||||
destroyed() {
|
||||
this.removeCopyEvents()
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
const vm = this
|
||||
this.markdown = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
highlight(code, language) {
|
||||
const validLang = !!(language && hljs.getLanguage(language))
|
||||
if (validLang) {
|
||||
const lang = language || ''
|
||||
return vm.highlightBlock(hljs.highlight(lang, code, true).value, lang)
|
||||
}
|
||||
return vm.highlightBlock(hljs.highlightAuto(code).value, '')
|
||||
}
|
||||
})
|
||||
this.markdown.use(mila, { attrs: { target: '_blank', rel: 'noopener', class: 'link-style' }})
|
||||
this.markdown.use(mdKatex, { blockClass: 'katexmath-block rounded-md', errorColor: ' #cc0000' })
|
||||
},
|
||||
highlightBlock(str, lang) {
|
||||
return `<pre class="code-block-wrapper"><div class="code-block-header"><span class="code-block-header__lang"></span><span class="code-block-header__copy">${'Copy Code'}</span></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`
|
||||
},
|
||||
addCopyEvents() {
|
||||
const copyBtn = document.querySelectorAll('.code-block-header__copy')
|
||||
copyBtn.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const code = btn.parentElement?.nextElementSibling?.textContent
|
||||
if (code) {
|
||||
copy(code)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
removeCopyEvents() {
|
||||
if (this.$refs.textRef) {
|
||||
const copyBtn = this.$refs.textRef.querySelectorAll('.code-block-header__copy')
|
||||
copyBtn.forEach((btn) => {
|
||||
btn.removeEventListener('click', () => {})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.markdown-body {
|
||||
font-size: 13px;
|
||||
&>>> pre {
|
||||
padding: 10px;
|
||||
}
|
||||
background-color: transparent;
|
||||
&>>> .code-block-wrapper {
|
||||
.code-block-header {
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
.code-block-header__copy {
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: #6e747b;
|
||||
}
|
||||
}
|
||||
}
|
||||
.hljs.code-block-body.javascript {
|
||||
.hljs-comment {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
>>> .link-style {
|
||||
color: #487bf4;
|
||||
&:hover {
|
||||
color: #275ee3;
|
||||
}
|
||||
}
|
||||
.loading-box{
|
||||
margin-left: 6px;
|
||||
}
|
||||
.loading-box span{
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin-right: 5px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
background: rgb(182, 189, 198);
|
||||
animation: load 1.2s ease infinite;
|
||||
}
|
||||
.loading-box span:last-child{
|
||||
margin-right: 0px;
|
||||
}
|
||||
@keyframes load{
|
||||
0%{
|
||||
opacity: 1;
|
||||
}
|
||||
100%{
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.loading-box span:nth-child(1){
|
||||
animation-delay: 0.23s;
|
||||
}
|
||||
.loading-box span:nth-child(2){
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
.loading-box span:nth-child(3){
|
||||
animation-delay: 0.49s;
|
||||
}
|
||||
</style>
|
||||
159
src/components/Apps/ChatAi/components/ChitChat/index.vue
Normal file
159
src/components/Apps/ChatAi/components/ChitChat/index.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="chat-content">
|
||||
<div id="scrollRef" class="chat-list">
|
||||
<ChatMessage v-for="(item, index) in activeChat.chats" :key="index" :item="item" />
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<ChatInput @send="onSendHandle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChatInput from './ChatInput.vue'
|
||||
import ChatMessage from './ChatMessage.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import { createWebSocket, closeWebSocket, ws, onSend } from '@/utils/socket'
|
||||
import { getInputFocus, useChat } from '../../useChat.js'
|
||||
|
||||
const {
|
||||
setLoading,
|
||||
addChatMessageById,
|
||||
addMessageToActiveChat,
|
||||
newChatAndAddMessageById,
|
||||
removeLoadingMessageInChat,
|
||||
removeLoadingAndAddMessageToChat,
|
||||
updateChaMessageContentById,
|
||||
addTemporaryLoadingToChat
|
||||
} = useChat()
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInput,
|
||||
ChatMessage
|
||||
},
|
||||
props: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentConversationId: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
activeChat: state => state.chat.activeChat
|
||||
})
|
||||
},
|
||||
mounted() {
|
||||
this.initWebSocket()
|
||||
this.initChatMessage()
|
||||
},
|
||||
destroyed() {
|
||||
closeWebSocket()
|
||||
},
|
||||
methods: {
|
||||
initWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const path = `${protocol}://${window.location.host}/kael/chat/system/`
|
||||
const localPath = process.env.VUE_APP_KAEL_WS + '/kael/chat/system/'
|
||||
const url = process.env.NODE_ENV === 'development' ? localPath : path
|
||||
createWebSocket(url, this.onWebSocketMessage)
|
||||
},
|
||||
onWebSocketMessage(data) {
|
||||
if (data.type === 'message') {
|
||||
this.onChatMessage(data)
|
||||
}
|
||||
if (data.type === 'error') {
|
||||
this.onSystemMessage(data)
|
||||
}
|
||||
},
|
||||
onChatMessage(data) {
|
||||
if (!data.message.content && data.conversation_id) {
|
||||
setLoading(true)
|
||||
removeLoadingAndAddMessageToChat(data)
|
||||
this.currentConversationId = data.conversation_id
|
||||
} else {
|
||||
updateChaMessageContentById(data.message.id, data.message.content)
|
||||
}
|
||||
if (data.message?.type === 'finish') {
|
||||
setLoading(false)
|
||||
getInputFocus()
|
||||
}
|
||||
},
|
||||
onSystemMessage(data) {
|
||||
data.message = {
|
||||
content: data.system_message,
|
||||
role: 'assistant',
|
||||
create_time: new Date()
|
||||
}
|
||||
removeLoadingMessageInChat()
|
||||
addMessageToActiveChat(data)
|
||||
this.socketReadyStateSuccess = false
|
||||
setLoading(true)
|
||||
},
|
||||
initChatMessage() {
|
||||
const chat = {
|
||||
message: {
|
||||
content: this.$t('common.ChatHello'),
|
||||
role: 'assistant',
|
||||
create_time: new Date()
|
||||
}
|
||||
}
|
||||
newChatAndAddMessageById(chat)
|
||||
setLoading(false)
|
||||
},
|
||||
onSendHandle(value) {
|
||||
if (ws.readyState === 1) {
|
||||
this.socketReadyStateSuccess = true
|
||||
const chat = {
|
||||
message: {
|
||||
content: value,
|
||||
role: 'user',
|
||||
create_time: new Date()
|
||||
}
|
||||
}
|
||||
const message = {
|
||||
content: value,
|
||||
conversation_id: this.currentConversationId || ''
|
||||
}
|
||||
addChatMessageById(chat)
|
||||
onSend(message)
|
||||
addTemporaryLoadingToChat()
|
||||
} else {
|
||||
const chat = {
|
||||
message: {
|
||||
content: this.$t('common.ConnectionDropped'),
|
||||
role: 'assistant',
|
||||
create_time: new Date()
|
||||
},
|
||||
type: 'error'
|
||||
}
|
||||
addChatMessageById(chat)
|
||||
this.socketReadyStateSuccess = false
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
.chat-list {
|
||||
flex: 1;
|
||||
padding: 0 15px;
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
}
|
||||
.input-box {
|
||||
height: 154px;
|
||||
padding: 0 15px;
|
||||
margin-bottom: 15px;
|
||||
border-top: 1px solid #ececec;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
src/components/Apps/ChatAi/components/Sidebar/index.vue
Normal file
61
src/components/Apps/ChatAi/components/Sidebar/index.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="top">
|
||||
<svg-icon icon-class="collapse" @click="onClose" />
|
||||
</div>
|
||||
<el-tabs v-model="active" :tab-position="'right'" @tab-click="handleClick">
|
||||
<el-tab-pane v-for="(item) in submenu" :key="item.name" :label="item.label" :name="item.name">
|
||||
<span slot="label">
|
||||
<svg-icon :icon-class="item.icon" />
|
||||
</span>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
active: {
|
||||
type: String,
|
||||
default: 'chat'
|
||||
},
|
||||
submenu: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClick(tab, event) {
|
||||
this.$emit('tab-click', tab)
|
||||
},
|
||||
onClose() {
|
||||
this.$parent.onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f0f1f5;
|
||||
.top {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
padding: 14px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
>>> .el-tabs {
|
||||
.el-tabs__item {
|
||||
padding: 0 13px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
112
src/components/Apps/ChatAi/index.vue
Normal file
112
src/components/Apps/ChatAi/index.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="chat">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<span class="title">{{ title }}</span>
|
||||
<span class="new" @click="onNewChat">
|
||||
<i class="el-icon-plus" />
|
||||
<span>{{ $tc('common.NewChat') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<keep-alive>
|
||||
<component :is="active" ref="component" />
|
||||
</keep-alive>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar">
|
||||
<Sidebar :active.sync="active" :submenu="submenu" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Sidebar from './components/Sidebar/index.vue'
|
||||
import Chat from './components/ChitChat/index.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Chat,
|
||||
Sidebar
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Chat'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
active: 'chat',
|
||||
submenu: [
|
||||
{
|
||||
name: 'chat',
|
||||
label: 'chat',
|
||||
icon: 'chat'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$parent.show = false
|
||||
},
|
||||
onNewChat() {
|
||||
this.active = 'chat'
|
||||
this.$nextTick(() => {
|
||||
this.$refs.component.initWebSocket()
|
||||
this.$refs.component.initChatMessage()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
padding: 0 16px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #ececec;
|
||||
.title {
|
||||
display: inline-block;
|
||||
}
|
||||
.new {
|
||||
display: inline-block;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
border-radius: 16px;
|
||||
padding: 0 10px;
|
||||
transform: translateY(32%);
|
||||
color: var(--color-primary);
|
||||
background-color: #f7f7f8;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
&:hover {
|
||||
background-color: #ededed;
|
||||
}
|
||||
}
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.sidebar {
|
||||
height: 100%;
|
||||
width: 42px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
src/components/Apps/ChatAi/useChat.js
Normal file
81
src/components/Apps/ChatAi/useChat.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
206
src/components/Apps/DrawerPanel/index.vue
Normal file
206
src/components/Apps/DrawerPanel/index.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div ref="drawer" :class="{show: show}" class="drawer">
|
||||
<div class="modal" :style="{'background-color': modal ? 'rgba(0, 0, 0, .3)' : 'transparent'}" />
|
||||
<div class="drawer-panel" :style="{'width': width}">
|
||||
<div ref="dragBox" class="handle-button">
|
||||
<i v-if="icon.startsWith('fa') || icon.startsWith('el')" :class="show ? 'el-icon-close': icon" />
|
||||
<img v-else :src="icon" alt="">
|
||||
</div>
|
||||
<div class="drawer-panel-item">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'DrawerPanel',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'el-icon-setting'
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '440px'
|
||||
},
|
||||
modal: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
clickNotClose: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(value) {
|
||||
if (value && !this.clickNotClose) {
|
||||
this.addEventClick()
|
||||
}
|
||||
this.$emit('toggle', this.show)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.insertToBody()
|
||||
},
|
||||
beforeDestroy() {
|
||||
const element = this.$refs.drawer
|
||||
element.remove()
|
||||
window.removeEventListener('click', this.closeSidebar)
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.$nextTick(() => {
|
||||
const dragBox = this.$refs.dragBox
|
||||
const clientOffset = {}
|
||||
dragBox.addEventListener('mousedown', (event) => {
|
||||
const offsetX = dragBox.getBoundingClientRect().left
|
||||
const offsetY = dragBox.getBoundingClientRect().top
|
||||
const innerX = event.clientX - offsetX
|
||||
const innerY = event.clientY - offsetY
|
||||
|
||||
clientOffset.clientX = event.clientX
|
||||
clientOffset.clientY = event.clientY
|
||||
document.onmousemove = function(event) {
|
||||
dragBox.style.left = event.clientX - innerX + 'px'
|
||||
dragBox.style.top = event.clientY - innerY + 'px'
|
||||
const dragDivTop = window.innerHeight - dragBox.getBoundingClientRect().height
|
||||
const dragDivLeft = window.innerWidth - dragBox.getBoundingClientRect().width
|
||||
dragBox.style.left = dragDivLeft + 'px'
|
||||
dragBox.style.left = '-48px'
|
||||
if (dragBox.getBoundingClientRect().top <= 0) {
|
||||
dragBox.style.top = '0px'
|
||||
}
|
||||
if (dragBox.getBoundingClientRect().top >= dragDivTop) {
|
||||
dragBox.style.top = dragDivTop + 'px'
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
document.onmouseup = function() {
|
||||
document.onmousemove = null
|
||||
document.onmouseup = null
|
||||
}
|
||||
}, false)
|
||||
dragBox.addEventListener('mouseup', (event) => {
|
||||
const clientX = event.clientX
|
||||
const clientY = event.clientY
|
||||
if (this.isDifferenceWithinThreshold(clientX, clientOffset.clientX) && this.isDifferenceWithinThreshold(clientY, clientOffset.clientY)) {
|
||||
this.show = !this.show
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
isDifferenceWithinThreshold(num1, num2, threshold = 5) {
|
||||
const difference = Math.abs(num1 - num2)
|
||||
return difference <= threshold
|
||||
},
|
||||
addEventClick() {
|
||||
window.addEventListener('click', this.closeSidebar)
|
||||
},
|
||||
closeSidebar(evt) {
|
||||
const parent = evt.target.closest('.drawer-panel')
|
||||
if (!parent && evt.target.className === 'modal') {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
insertToBody() {
|
||||
const element = this.$refs.drawer
|
||||
const body = document.querySelector('body')
|
||||
body.insertBefore(element, body.firstChild)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity .3s cubic-bezier(.7, .3, .1, 1);
|
||||
background: rgba(0, 0, 0, .3);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 260px;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
box-shadow: 0 0 15px 0 rgba(0, 0, 0, .05);
|
||||
transition: transform .25s cubic-bezier(.7, .3, .1, 1);
|
||||
box-shadow: 0 0 8px 4px #00000014;
|
||||
transform: translate(100%);
|
||||
background: #FFFFFF;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.drawer-panel-item {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.drawer-panel-item::-webkit-scrollbar-track {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.show {
|
||||
transition: all .3s cubic-bezier(.7, .3, .1, 1);
|
||||
}
|
||||
|
||||
.show .modal {
|
||||
z-index: 1003;
|
||||
opacity: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.show .drawer-panel {
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.handle-button {
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
left: -48px;
|
||||
width: 48px;
|
||||
height: 45px;
|
||||
line-height: 45px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
border-radius: 20px 0 0 20px;
|
||||
z-index: 0;
|
||||
pointer-events: auto;
|
||||
color: #fff;
|
||||
background-color: #FFFFFF;
|
||||
box-shadow: -3px 0px 10px 1px #00000014;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: rgba(182, 181, 186, .9);
|
||||
}
|
||||
i {
|
||||
font-size: 20px;
|
||||
line-height: 45px;
|
||||
}
|
||||
img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
transform: translateY(10%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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",
|
||||
|
||||
@@ -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を使用してリクエストヘッダに署名します。リクエストのヘッダごとに異なります。使用ドキュメントを参照してください",
|
||||
|
||||
@@ -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 方式,更加安全,请查阅文档使用;<br>为降低泄露风险,Secret 仅在生成时可以查看, 每个用户最多支持创建 10 个",
|
||||
|
||||
1
src/icons/svg/chat.svg
Normal file
1
src/icons/svg/chat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
|
||||
|
After Width: | Height: | Size: 284 B |
1
src/icons/svg/collapse.svg
Normal file
1
src/icons/svg/collapse.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" role="img" class="octicon octicon-sidebar-collapse" viewBox="0 0 16 16" width="14" height="14" fill="currentColor" style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible;"><path d="M6.823 7.823a.25.25 0 0 1 0 .354l-2.396 2.396A.25.25 0 0 1 4 10.396V5.604a.25.25 0 0 1 .427-.177Z"></path><path d="M1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25V1.75C0 .784.784 0 1.75 0ZM1.5 1.75v12.5c0 .138.112.25.25.25H9.5v-13H1.75a.25.25 0 0 0-.25.25ZM11 14.5h3.25a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25H11Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 648 B |
4
src/icons/svg/copy.svg
Normal file
4
src/icons/svg/copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
@@ -5,19 +5,47 @@
|
||||
<router-view :key="key" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
<DrawerPanel v-if="chatAiEnabled" :icon="robotUrl" :modal="false" @toggle="onToggle">
|
||||
<ChatGPT />
|
||||
</DrawerPanel>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DrawerPanel from '@/components/Apps/DrawerPanel'
|
||||
import ChatGPT from '@/components/Apps/ChatAi'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { getInputFocus } from '@/components/Apps/ChatAi/useChat'
|
||||
|
||||
export default {
|
||||
name: 'AppMain',
|
||||
components: {
|
||||
ChatGPT,
|
||||
DrawerPanel
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'publicSettings'
|
||||
]),
|
||||
robotUrl() {
|
||||
return require('../../assets/img/robot-assistant.png')
|
||||
},
|
||||
chatAiEnabled() {
|
||||
return this.publicSettings?.CHAT_AI_ENABLED
|
||||
},
|
||||
key() {
|
||||
return this.$route.path
|
||||
},
|
||||
cachedViews() {
|
||||
return this.$store.state.tagsView.cachedViews
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onToggle(status) {
|
||||
if (status) {
|
||||
getInputFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
53
src/store/modules/chat.js
Normal file
53
src/store/modules/chat.js
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
104
src/utils/socket.js
Normal file
104
src/utils/socket.js
Normal file
@@ -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)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<IBox>
|
||||
<GenericCreateUpdateForm v-bind="$data" />
|
||||
<GenericCreateUpdateForm v-bind="$data" @submitSuccess="submitSuccess" />
|
||||
</IBox>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@
|
||||
<script>
|
||||
import { GenericCreateUpdateForm } from '@/layout/components'
|
||||
import IBox from '@/components/IBox/index.vue'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -54,6 +55,20 @@ export default {
|
||||
return 'patch'
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'publicSettings'
|
||||
])
|
||||
},
|
||||
methods: {
|
||||
submitSuccess(res) {
|
||||
const setting = { ...this.publicSettings, ...res }
|
||||
this.$store.dispatch('settings/changeSetting', {
|
||||
key: 'publicSettings',
|
||||
value: setting || {}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user