feat: 添加chat聊天

This commit is contained in:
“huailei000”
2023-12-06 16:04:20 +08:00
committed by huailei
parent 3af6b1d1fe
commit 6b37c71b6d
23 changed files with 1328 additions and 1 deletions

View File

@@ -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'

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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>

View File

@@ -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",

View File

@@ -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を使用してリクエストヘッダに署名します。リクエストのヘッダごとに異なります。使用ドキュメントを参照してください",

View File

@@ -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
View 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

View 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
View 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

View File

@@ -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
View 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
}

View File

@@ -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
View 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)
}

View File

@@ -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>