Files
dvcp_v2_webapp/ui/packages/ai/AiCopilot.vue

594 lines
18 KiB
Vue
Raw Permalink Normal View History

2024-06-04 18:16:51 +08:00
<script>
2024-06-06 18:38:05 +08:00
import ChatContent from "./components/chatContent.vue";
import ThinkingBar from "./components/thinkingBar.vue";
2024-07-04 17:54:30 +08:00
import {mapState} from "vuex";
2024-08-19 14:36:26 +08:00
import AiDrag from "../basic/AiDrag.vue";
import AiLocateDialog from "../tools/AiLocateDialog.vue";
2024-08-22 18:48:18 +08:00
import AiUploader from "../basic/AiUploader.vue";
2024-08-27 18:00:04 +08:00
import {$checkJson} from "../../lib/js/utils";
2024-06-06 18:38:05 +08:00
2024-06-04 18:16:51 +08:00
export default {
name: "AiCopilot",
props: {
2024-06-17 15:55:26 +08:00
http: Function,
2024-06-04 18:16:51 +08:00
title: {default: "Copilot小助理"}
},
data() {
return {
2024-08-26 18:08:29 +08:00
show: false,
2024-06-05 18:13:01 +08:00
expand: false,
2024-06-17 15:55:26 +08:00
loading: false,
2024-06-06 18:38:05 +08:00
prompt: "",
2024-07-10 09:39:27 +08:00
history: [],
2024-07-16 12:21:33 +08:00
apps: [],
2024-07-04 17:54:30 +08:00
filter: "",
2024-07-16 12:21:33 +08:00
conversations: [],
currentConversation: null,
2024-08-19 14:36:26 +08:00
app: {},
locate: false,
2024-08-22 18:48:18 +08:00
latlng: "",
files: []
2024-06-04 18:16:51 +08:00
}
},
computed: {
2024-07-04 17:54:30 +08:00
...mapState(["user"]),
profile: v => v.user.info || {},
expandBtn: v => v.expand ? "收起" : "展开",
isNeedPosition: v => ["1"].includes(v.app.ability),
2024-08-19 14:36:26 +08:00
btns: v => [
2024-08-23 16:26:00 +08:00
{label: "文件", icon: "https://cdn.sinoecare.com/i/2024/07/04/668663436e46e.png", click: v.handleUpload},
2024-08-22 18:48:18 +08:00
{label: "位置", icon: "https://cdn.sinoecare.com/i/2024/08/19/66c2f907bd444.png", hide: !v.isNeedPosition, click: () => v.locate = true}
].filter(e => e.hide !== true),
2024-07-16 12:21:33 +08:00
rowBtns: v => [
{icon: "https://cdn.sinoecare.com/i/2024/07/04/66866edc2910a.png", label: "置顶", click: row => 0},
{icon: "https://cdn.sinoecare.com/i/2024/07/04/66866ed734540.png", label: "编辑", click: row => 0},
2024-07-16 16:23:50 +08:00
{icon: "https://cdn.sinoecare.com/i/2024/07/04/66866eda99e4d.png", label: "删除", click: row => v.handleDeleteConversation(row.conversationId)},
2024-08-19 14:36:26 +08:00
],
dialogWidth: v => v.expand ? 960 : 468
2024-06-05 18:13:01 +08:00
},
2024-08-22 18:48:18 +08:00
components: {AiUploader, AiLocateDialog, AiDrag, ThinkingBar, ChatContent},
2024-06-05 18:13:01 +08:00
methods: {
2024-07-16 12:21:33 +08:00
getHistory(params) {
this.http.post("/app/appaicopilotinfo/list", null, {params}).then(res => {
2024-06-17 15:55:26 +08:00
if (res?.data) {
2024-07-16 12:21:33 +08:00
this.history = res.data.records
}
})
},
getApps() {
return this.http.post("/app/appaiconfiginfo/list", null, {
params: {
status: 1, size: 999
}
}).then(res => {
if (res?.data) {
return this.apps = res.data.records
}
})
},
getConversations() {
return this.http.post("/app/appaicopilotinfo/listHistory", null, {
params: {content: this.filter}
}).then(res => {
if (res?.data) {
return this.conversations = res.data.records || []
2024-06-17 15:55:26 +08:00
}
})
},
handleHotkey(evt) {
if (evt.ctrlKey && evt.key == "Enter") {
this.handleSend()
}
},
2024-06-05 18:13:01 +08:00
handleSend() {
2024-06-17 16:45:18 +08:00
if (!this.prompt.trim()) return this.$message.error("无法发送空白信息")
else if (this.isNeedPosition && !this.latlng) return this.$message.error("请先选择您所在的位置")
2024-06-17 15:55:26 +08:00
this.$debounce(() => {
2024-08-23 09:58:01 +08:00
const {currentConversation: conversationId, app, prompt: content, latlng, files} = this.$data
const {id: fileId, url: sdkFileUrl, name: fileName} = files[0] || {}
const message = {appType: "2", userType: 0, content, conversationId, ...app, supplementContent: latlng, fileId, sdkFileUrl, fileName}
2024-06-17 15:55:26 +08:00
this.history.push(message)
this.loading = true
this.clearCache()
2024-08-26 17:28:14 +08:00
this.sendMessage(message).finally(() => {
2024-06-17 15:55:26 +08:00
this.loading = false
2024-07-16 17:59:00 +08:00
this.getConversations()
2024-06-17 15:55:26 +08:00
})
}, 100)
2024-07-16 12:21:33 +08:00
},
handleDeleteConversation(conversationId) {
this.$confirm("是否要删除该会话历史?").then(() => {
this.http.post("/app/appaicopilotinfo/deleteConversation", null, {params: {conversationId}}).then(res => {
if (res?.code == '0') {
this.$message.success("删除成功!")
this.getConversations()
}
})
}).catch(() => 0)
},
handleChangeApp(item) {
const {appId, id: aiConfigId, ability} = item
this.handleChangeConversation({appId, aiConfigId, ability})
2024-07-16 12:21:33 +08:00
this.history = [{userType: 2, content: `当前应用已切换至【${item.appName}`}]
this.clearCache()
2024-07-16 12:21:33 +08:00
},
handleChangeConversation({conversationId, appId, aiConfigId, ability} = {}) {
2024-07-16 12:21:33 +08:00
this.currentConversation = conversationId
this.app = {appId, aiConfigId, ability}
2024-07-16 12:21:33 +08:00
},
getIcon(item = {}) {
const icon = item.appIconUrl || "https://cdn.sinoecare.com/i/2024/07/04/66864da1684ad.png"
return {
backgroundImage: `url(${icon})`
}
2024-08-19 14:36:26 +08:00
},
handleLocate(v) {
const {lat, lng} = v.location
this.latlng = [lat, lng].join(",")
this.locate = false
},
clearCache() {
//清理发送消息缓存
this.prompt = ""
this.files = []
2024-08-23 16:26:00 +08:00
this.$refs.uploader?.handleClear()
},
handleUpload() {
if (this.$refs.uploader.checkUpload()) this.$refs.uploadTrigger.click()
else this.$message.error("最多上传一个文件")
2024-08-26 17:28:14 +08:00
},
sendMessage(message, isSSE = false) {
2024-08-27 18:00:04 +08:00
if (isSSE || localStorage.getItem("isSSE")) {
const {content, sdkFileUrl, appId} = message
2024-08-26 17:28:14 +08:00
const body = {
inputs: {},
query: content,
response_mode: "streaming",
user: "kubbo",
}
if (sdkFileUrl) body.files = [{type: 'image', url: sdkFileUrl, transfer_method: "remote_url"}]
return new Promise(resolve => this.http.post("/sse/chat-messages", body, {
withoutToken: 1, headers: {
Accept: 'text/event-stream',
2024-08-27 18:00:04 +08:00
Authorization: `Bearer ${appId}`
2024-08-26 17:28:14 +08:00
},
onDownloadProgress: evt => {
2024-08-27 18:00:04 +08:00
evt.target.responseText.split(/(data|event):/).forEach(data => {
if ($checkJson(data.trim())) {
2024-08-26 17:28:14 +08:00
const res = JSON.parse(data.trim())
2024-08-27 18:00:04 +08:00
if (this.history.at(-1).userType != 1) {
this.history.push({userType: 1, content: "", workflow: []})
}
const last = this.history.at(-1)
if (res.event == "node_started") {
last.workflow = ["el-icon-loading", res.data.title]
} else if (res.event == "node_finished") {
last.workflow = ["el-icon-finished", res.data.title]
}
2024-08-26 17:28:14 +08:00
const chunk = res.answer?.trim()
if (!!chunk) {
2024-08-27 18:00:04 +08:00
if (!last.content?.includes(chunk)) last.content += chunk
}
if (res.event == "message_end") {
last.workflow = ""
resolve()
2024-08-26 17:28:14 +08:00
}
}
})
}
}))
} else {
const concatenateStr = (content, i = 0, target = this.history.at(-1)) => {
target.content += content.slice(i, i + 1)
if (++i < content.length) setTimeout(() => concatenateStr(content, i, target), 50)
}
return this.http.post("/app/appaicopilotinfo/add", message).then(res => {
if (res?.data?.length >= 2) {
const last = res.data.at(-1)
this.history.push({...last, content: ""})
return concatenateStr(last.content)
}
})
}
2024-07-16 12:21:33 +08:00
}
},
watch: {
currentConversation(v) {
v && this.getHistory({conversationId: v})
2024-08-22 15:23:07 +08:00
},
loading(v) {
!v && this.$nextTick(() => this.$refs.sendInput.focus())
},
dialogWidth(v) {
const dom = this.$el.querySelector(".vdr")
dom.style.minWidth = `${v}px`
2024-06-05 18:13:01 +08:00
}
2024-06-17 15:55:26 +08:00
},
created() {
2024-07-16 12:21:33 +08:00
Promise.all([this.getApps(), this.getConversations()]).then(() => {
const {appId, id: aiConfigId, ability} = this.apps.at(0)
this.handleChangeConversation(this.conversations.at(0) || {appId, aiConfigId, ability})
2024-07-16 12:21:33 +08:00
})
2024-06-04 18:16:51 +08:00
}
}
</script>
<template>
<section class="AiCopilot">
2024-08-23 11:39:07 +08:00
<ai-drag class="copilot" v-if="show" :w="dialogWidth" :h="600" :minHeight="600" :minWidth="dialogWidth" :x="-dialogWidth-12" :y="-542" dragHandle=".header">
2024-06-04 18:16:51 +08:00
<div class="flex header">
<b class="fill" v-text="title"/>
2024-06-05 16:10:13 +08:00
<div class="expandBtn pointer" v-text="expandBtn" @click="expand=!expand"/>
<div class="minimal pointer" v-text="'最小化'" @click="show=false"/>
2024-06-04 18:16:51 +08:00
</div>
2024-06-06 18:38:05 +08:00
<thinking-bar v-show="loading"/>
2024-06-04 18:16:51 +08:00
<div class="flex content">
2024-08-23 16:26:00 +08:00
<div class="left" :class="{expand}" v-loading="loading" element-loading-text="小助理正在思考中.."
2024-07-16 16:23:50 +08:00
element-loading-spinner="el-icon-loading" element-loading-background="rgba(255,255,255,0.8)">
2024-07-04 17:54:30 +08:00
<div class="profile">
<div v-text="profile.name"/>
<span v-text="profile.girdName"/>
</div>
2024-08-22 16:52:28 +08:00
<el-scrollbar>
<div class="apps">
<div v-for="(item,i) in apps" :key="i" class="app pointer" :class="{current:item.id==app.aiConfigId}" :style="getIcon(item)" v-text="item.appName" @click="handleChangeApp(item)"/>
</div>
<div class="conversation">
<el-input class="search" v-model="filter" placeholder="搜索历史对话记录" size="small" suffix-icon="el-icon-search" clearable @change="getConversations"/>
<div class="item pointer" v-for="item in conversations" :key="item.id" @click="currentConversation=item.conversationId" :class="{current:item.conversationId==currentConversation}">
{{ item.content }}
<div class="operation flex">
<div v-for="(btn,i) in rowBtns" :key="i" class="pointer" :style="{backgroundImage: `url(${btn.icon})`}" @click.stop="btn.click(item)"/>
</div>
2024-07-04 17:54:30 +08:00
</div>
</div>
2024-08-22 16:52:28 +08:00
</el-scrollbar>
2024-07-04 17:54:30 +08:00
</div>
2024-06-05 18:13:01 +08:00
<div class="right flex column gap-14">
2024-06-06 18:38:05 +08:00
<chat-content class="fill" :list="history"/>
2024-07-04 17:54:30 +08:00
<div class="sendBox">
<div class="topBar flex">
<div v-for="(btn,i) in btns" :key="i" class="btn pointer" :style="{backgroundImage: `url(${btn.icon})`}" v-text="btn.label" @click="btn.click"/>
</div>
<ai-uploader ref="uploader" v-model="files" :instance="http" :limit="1" show-loading fileType="file" accept-type=".doc,.docx,.pdf,.txt">
2024-08-22 18:48:18 +08:00
<div slot="trigger" ref="uploadTrigger"/>
</ai-uploader>
2024-07-04 17:54:30 +08:00
<div class="flex end">
2024-07-16 12:21:33 +08:00
<el-input type="textarea" class="fill input" autosize resize="none" v-model="prompt" placeholder="请输入..." :rows="5"
2024-08-22 15:23:07 +08:00
@keydown.native="handleHotkey" :disabled="loading" :placeholder="loading?'正在思考中...':'请输入'" ref="sendInput"/>
2024-07-04 17:54:30 +08:00
<div class="sendBtn" @click="handleSend"/>
</div>
2024-06-05 18:13:01 +08:00
</div>
</div>
2024-06-04 18:16:51 +08:00
</div>
</ai-drag>
2024-08-23 09:58:01 +08:00
<ai-locate-dialog dialogTitle="选择当前位置" v-model="locate" :ins="http" @confirm="v=>handleLocate(v)" :modal="false"/>
2024-08-22 16:42:08 +08:00
<img class="icon" src="https://cdn.sinoecare.com/i/2024/08/22/66c6dfdc3766a.png" @click="show=!show"/>
2024-06-04 18:16:51 +08:00
</section>
</template>
<style scoped lang="scss">
.AiCopilot {
position: fixed;
right: 20px;
bottom: 48px;
display: flex;
align-items: flex-end;
gap: 18px;
2024-07-16 15:33:54 +08:00
z-index: 1888;
2024-06-04 18:16:51 +08:00
.copilot {
:deep(.vdr) {
border-radius: 0 0 8px 8px;
height: 600px;
2024-08-22 16:42:08 +08:00
background-color: #FFFFFF;
box-shadow: 0 0 20px 1px #0a255c1a;
border: none;
2024-08-22 16:42:08 +08:00
background-image: url("https://cdn.sinoecare.com/i/2024/08/22/66c6f2d71b185.png");
background-size: 100% 100%;
.handle {
opacity: 0;
}
&:hover {
.handle {
opacity: 1;
}
}
}
2024-06-04 18:16:51 +08:00
.header {
height: 48px;
background-image: linear-gradient(90deg, #3577FD 0%, #216AFD 100%);
box-shadow: 0 2px 6px 0 #0a255c14;
border-radius: 8px 8px 0 0;
2024-07-16 15:33:54 +08:00
overflow: hidden;
2024-06-04 18:16:51 +08:00
padding: 0 8px 0 14px;
color: #fff;
font-size: 14px;
gap: 14px;
& > b {
font-size: 16px;
2024-06-05 16:10:13 +08:00
cursor: default;
2024-06-04 18:16:51 +08:00
}
.expandBtn, .minimal {
padding-left: 18px;
font-weight: normal;
background-position: left center;
background-repeat: no-repeat;
background-size: 14px 14px;
line-height: 20px;
}
.minimal {
background-image: url("https://cdn.sinoecare.com/i/2024/06/04/665ed2bd0a79e.png");
}
.expandBtn {
background-image: url("https://cdn.sinoecare.com/i/2024/06/04/665ed2bd8b021.png");
}
}
.content {
2024-08-22 11:42:37 +08:00
width: 100%;
2024-06-06 18:38:05 +08:00
height: calc(100% - 50px);
2024-08-19 14:36:26 +08:00
float: right;
2024-06-04 18:16:51 +08:00
.left {
width: 0;
2024-06-05 16:10:13 +08:00
height: 100%;
2024-07-04 17:54:30 +08:00
padding: 14px 0;
2024-08-22 16:52:28 +08:00
overflow: hidden;
2024-08-19 14:36:26 +08:00
flex-shrink: 0;
2024-07-04 17:54:30 +08:00
& > * {
margin: 0 14px;
}
2024-06-04 18:16:51 +08:00
&.expand {
2024-07-16 12:21:33 +08:00
width: 306px;
2024-06-05 16:10:13 +08:00
& + .right {
2024-06-06 18:42:57 +08:00
border-left-color: #ddd;
min-width: 660px;
2024-06-05 16:10:13 +08:00
}
2024-06-04 18:16:51 +08:00
}
2024-07-04 17:54:30 +08:00
.profile {
padding: 18px 14px;
height: 88px;
2024-08-22 16:42:08 +08:00
background: url("https://cdn.sinoecare.com/i/2024/08/22/66c6e78fb918c.png") no-repeat;
2024-07-16 12:21:33 +08:00
background-size: 100% 100%;
2024-07-04 17:54:30 +08:00
font-size: 14px;
color: #222222;
letter-spacing: 0;
line-height: 20px;
& > div {
font-weight: bold;
font-size: 20px;
line-height: 28px;
2024-08-22 16:46:59 +08:00
background: linear-gradient(180deg, #F57A00 0%, #FFAA00 100%);
background-clip: text;
-webkit-text-fill-color: transparent;
2024-07-04 17:54:30 +08:00
}
}
.apps {
color: #333;
font-size: 14px;
margin-top: 18px;
padding-top: 29px;
position: relative;
2024-07-18 09:22:22 +08:00
display: grid;
2024-07-18 10:11:54 +08:00
grid-template-columns: repeat(3, 1fr);
2024-07-18 09:22:22 +08:00
gap: 6px;
2024-07-04 17:54:30 +08:00
&:before {
content: "常用功能";
font-weight: bold;
position: absolute;
top: 0;
left: 0;
}
.app {
line-height: 18px;
text-align: center;
width: 72px;
height: 72px;
padding-top: 47px;
background-repeat: no-repeat;
background-position: center 7px;
background-size: 36px 36px;
border-radius: 4px;
2024-07-18 10:11:54 +08:00
font-size: 12px;
2024-07-04 17:54:30 +08:00
2024-07-18 09:39:04 +08:00
&:hover, &.current {
2024-07-04 17:54:30 +08:00
background-color: #286ffd14;
}
}
}
.conversation {
margin-top: 17px;
:deep(.search) {
margin-bottom: 10px;
input {
border-radius: 32px;
border: none;
background: #F4F6FA;
}
}
.item {
height: 32px;
border-radius: 16px;
font-size: 14px;
color: #666666;
line-height: 32px;
padding: 0 14px;
white-space: nowrap;
text-overflow: ellipsis;
position: relative;
2024-08-23 11:18:18 +08:00
margin-bottom: 2px;
2024-07-04 17:54:30 +08:00
.operation {
position: absolute;
top: 0;
right: 0;
height: 32px;
background-image: linear-gradient(90deg, #e1ebff00 0%, #E2EBFF 14%);
border-radius: 0 16px 16px 0;
gap: 10px;
padding: 0 12px 0 20px;
display: none;
.pointer {
width: 14px;
height: 14px;
background-repeat: no-repeat;
background-size: 100% 100%;
&:hover {
opacity: .8;
}
}
}
2024-07-16 12:21:33 +08:00
&:hover, &.current {
2024-07-04 17:54:30 +08:00
background: #2b71fd24;
.operation {
display: flex;
}
}
}
}
2024-08-22 16:52:28 +08:00
:deep(.el-scrollbar) {
height: calc(100% - 88px);
.el-scrollbar__wrap {
overflow-x: hidden;
}
}
2024-06-04 18:16:51 +08:00
}
.right {
width: 100%;
min-width: 468px;
2024-06-05 16:10:13 +08:00
height: 100%;
2024-06-17 15:55:26 +08:00
padding: 14px 0 14px 14px;
2024-06-06 18:38:05 +08:00
align-items: stretch;
2024-06-06 18:42:57 +08:00
border-left: 1px solid transparent;
2024-07-16 12:21:33 +08:00
transition: width 1s;
2024-06-05 18:13:01 +08:00
.sendBtn {
2024-07-04 17:54:30 +08:00
width: 36px;
height: 24px;
border-radius: 24px;
background: url("https://cdn.sinoecare.com/i/2024/07/04/668650935514e.png") no-repeat center;
2024-06-05 18:13:01 +08:00
background-size: 100% 100%;
cursor: pointer;
}
}
2024-06-06 18:38:05 +08:00
.chatPanel {
width: 100%;
}
2024-06-05 18:13:01 +08:00
:deep(.sendBox) {
2024-07-04 17:54:30 +08:00
width: calc(100% - 14px);
margin-right: 14px;
padding: 8px 14px;
background: #F4F6FA;
align-items: flex-end;
border-radius: 4px;
.topBar {
width: 100%;
2024-07-16 12:21:33 +08:00
height: 28px;
2024-07-04 17:54:30 +08:00
border-bottom: 1px solid #E0E0E0;
margin-bottom: 10px;
2024-07-16 12:21:33 +08:00
align-items: flex-start;
2024-08-19 14:36:26 +08:00
gap: 8px;
2024-07-04 17:54:30 +08:00
.btn {
font-size: 12px;
color: #525F7A;
padding-left: 16px;
background-repeat: no-repeat;
background-position: left center;
background-size: 14px 14px;
&:hover {
opacity: .8;
color: #0052F5;
2024-07-04 17:54:30 +08:00
}
}
}
2024-06-05 18:13:01 +08:00
.input > textarea {
width: 100%;
2024-07-04 17:54:30 +08:00
line-height: 20px;
padding: 0 5px 0 0;
border: none;
2024-06-05 18:13:01 +08:00
box-sizing: border-box;
2024-07-04 17:54:30 +08:00
background: transparent;
2024-07-16 12:21:33 +08:00
min-height: 73px !important;
2024-06-05 18:13:01 +08:00
}
2024-08-22 18:48:18 +08:00
.el-upload {
display: none;
}
.el-upload-list {
display: flex;
li {
width: auto;
padding: 6px 12px;
background: #0152f50f;
border-radius: 4px;
font-size: 12px;
line-height: normal;
margin-top: 0;
.el-upload-list__item-name {
margin-right: 0;
padding-left: 0;
color: #525F7A;
}
.el-icon-close {
right: 0;
top: 0;
transform: translate(2px, -2px);
background-color: #A4ADC2;
color: white;
border-radius: 50%;
&:hover {
background-color: #2970FF;
}
}
}
}
2024-06-04 18:16:51 +08:00
}
}
}
.icon {
width: 68px;
height: 58px;
cursor: pointer;
}
}
</style>