123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620 |
- <script setup>
- import { ref, unref, onMounted, onUnmounted, computed, watch } from 'vue';
- import { useMessage, NInput, NSwitch, NPopover, NScrollbar, NUpload, NTooltip, NProgress } from 'naive-ui';
- import { useUserStore } from '@/stores/modules/userStore';
- import { baseURL } from '@/utils/request';
- import { getFormatYesterDay } from '@/utils/format';
- import { helperApi } from '@/api/helper';
- import { TheArchival } from "@/components"
- import SvgIcon from '@/components/SvgIcon';
- import 'load-awesome/css/ball-running-dots.min.css';
- const props = defineProps({
- activeItem: {
- type: Object,
- default: () => ({})
- }
- });
- const emit = defineEmits(['onClick', 'onEnter']);
- const MAX_NUM = 5;
- const useStore = useUserStore();
- const modelLoading = defineModel('loading');
- const switchStatus = defineModel('switch');
- const message = useMessage();
- const inpVal = ref('');
- const inpRef = ref(null);
- const isFocusState = ref(false);
- const isOpen = ref(false);
- const highlightedIndex = ref(0);
- const selectedOption = ref(null);
- const helperList = ref([]);
- const popoverTriggerRef = ref(null);
- const popoverInnerRef = ref(null);
- const scrollRef = ref(null);
- const uploadFileList = ref([]);
- const uploadLoading = ref(false);
- const agentOptions = computed(() => helperList.value.filter(({ tools }) => tools));
- const lastFileListIndex = computed(() => uploadFileList.value.length == 0 ? 0 : uploadFileList.value.length - 1);
- const focusInput = _ => isFocusState.value = true;
- const blurInput = _ => isFocusState.value = false;
- watch(inpVal, (curVal) => {
- if (curVal === "@" && curVal.length === 1) {
- if ( !unref(agentOptions).length ) {
- return message.warning('当前未配置智能体');
- }
- if ( modelLoading.value ) {
- return message.warning('当前对话进行中');
- }
- isOpen.value = true;
- } else {
- isOpen.value = false;
- }
- })
- watch(() => props.activeItem, (curVal) => {
- selectedOption.value = curVal?.tools ? curVal : null;
- })
- const handleInpFocus = () => {
- inpRef.value?.focus();
- }
- const commonEmitEvent = (eventName) => {
- const val = unref(inpVal);
- const len = val.trim().length;
- if ( !len ) {
- return message.warning('请输入您的问题或需求');
- }
- if ( len > 2000 ) {
- return message.warning('问题限制2000个字以内');
- }
- if ( modelLoading.value ) {
- return message.warning('当前对话进行中');
- }
- if ( uploadLoading.value ) {
- return message.warning('文件上传中,请稍后');
- }
- emit(eventName, { showVal: val, question: val, selectedOption: selectedOption.value || {}, uploadFileList: uploadFileList.value });
- inpVal.value = '';
- uploadFileList.value = [];
- }
- // 回车事件
- const handleInpEnter = (event) => {
- if (event.key === 'Enter' && !event.shiftKey && inpVal.value) {
- event.preventDefault();
- commonEmitEvent('onEnter');
- inpRef.value?.blur();
- }
- }
- // 点击事件
- const handleBtnClick = () => {
- commonEmitEvent("onClick");
- }
- const clearInpVal = () => {
- inpVal.value = '';
- }
- // 键盘上键无限滚动
- const scrollKeyUp = (index) => {
- const itemLength = agentOptions.value.length;
- if ( itemLength - MAX_NUM > index ) {
- scrollRef.value.scrollBy({
- top: -40 ,
- behavior: 'smooth'
- })
- }
- if ( index == itemLength - 1 ) {
- scrollRef.value.scrollTo({
- top: itemLength * 40,
- behavior: 'smooth'
- })
- }
- }
- // 键盘下键无限滚动
- const scrollKeyDown = (index) => {
- if ( index >= MAX_NUM ) {
- scrollRef.value.scrollBy({
- top: 40,
- behavior: 'smooth'
- })
- }
- if ( index == 0 ) {
- scrollRef.value.scrollTo({
- top: 0,
- behavior: 'smooth'
- })
- }
- }
- // 键盘事件
- const handleKeyDown = (event) => {
- const len = unref(agentOptions).length;
- if ( !isOpen.value ) return;
- switch (event.key) {
- case 'ArrowUp':
- event.preventDefault();
- highlightedIndex.value = (unref(highlightedIndex) - 1 + len) % len;
- scrollKeyUp(unref(highlightedIndex), 1)
- break;
- case 'ArrowDown':
- event.preventDefault();
- highlightedIndex.value = (unref(highlightedIndex) + 1) % len;
- scrollKeyDown(unref(highlightedIndex))
- break;
- case 'Enter':
- event.preventDefault();
- selectOption(unref(highlightedIndex));
- break;
- default:
- break;
- }
- }
- const formatFileItem = (file) => {
- const { name } = file;
- return {
- name: name.substring(0, name.lastIndexOf('.')),
- url: "",
- size: (file.file.size / 1024).toFixed(2) + "KB",
- suffix: name.substring( name.lastIndexOf('.') + 1 ).toUpperCase(),
- originSuffix:name.substring( name.lastIndexOf('.') )
- }
- }
- // 处理点击空白处关闭
- const closePopoverOutside =(event) => {
- if (!isOpen.value) return;
- const triggerResult = popoverTriggerRef.value.contains(event.target);
- const innerResult = popoverInnerRef.value.contains(event.target);
- isOpen.value = triggerResult || innerResult;
- }
- // 选中选项
- const selectOption = (index) => {
- selectedOption.value = agentOptions.value[index];
- highlightedIndex.value = index;
- isOpen.value = false;
- inpVal.value = selectedOption.value.content;
- }
- const beforeUpload = ({ file }) => {
- if (( file.file?.size / ( 1024 * 1024 ) ) > 5) {
- message.warning("文件大小超过5MB,请重新选择");
- return false;
- }
- const index = unref(lastFileListIndex);
-
- uploadFileList.value[index] = {
- ...formatFileItem(file),
- percentage: 0
- }
- uploadLoading.value = true
- return true;
- }
- // 上传文件
- const handleUploadChange = ({ file }) => {
- if (file.status === 'uploading') {
- uploadFileList.value[lastFileListIndex.value].percentage = file.percentage;
- }
- }
- // 文件上传完成
- const handleFinish = ({ file, event }) => {
- try {
- const res = JSON.parse( (event?.target).response );
- if ( res.code == 200 ) {
- uploadFileList.value[lastFileListIndex.value] = {
- ...uploadFileList.value[lastFileListIndex.value],
- url: res.data,
- }
- uploadLoading.value = false
- }
- } catch (error) {
- console.log("上传完成, 但是存在错误", error);
- }
- }
- // 上传失败
- const handleUploadError = (error) => {
- uploadLoading.value = false;
- }
- // 删除文件
- const onRemoveFile = (i) => {
- // uploadFileList.value.splice(i, 1);
- uploadFileList.value = [];
- }
- const clearFileList = () => {
- uploadFileList.value = [];
- }
- onMounted(async () => {
- const { data } = await helperApi.getHelperList();
-
- const result = getFormatYesterDay(data)
- helperList.value = result;
- document.addEventListener('keydown', handleKeyDown);
- document.addEventListener('click', closePopoverOutside);
- })
- onUnmounted(() => {
- document.removeEventListener('keydown', handleKeyDown);
- document.removeEventListener('click', closePopoverOutside);
- })
- defineExpose({
- clearFileList,
- clearInpVal,
- handleInpFocus,
- inpVal,
- })
- </script>
- <template>
- <NPopover
- trigger="hover"
- width="trigger"
- display-directive="show"
- content-style="padding: 0;"
- :show-arrow="false"
- :show="isOpen"
- >
- <template #trigger>
- <div class="popover-trigger" ref="popoverTriggerRef">
- <div class="chat-inp-outer border-[1px]" :class="[{ 'border-[#2454FF]': isFocusState }]">
- <ul class="chat-tools-inner py-[10px] px-[10px] bg-[#fcfcfc]" v-show="selectedOption">
- <li class="tools-tips space-x-[10px]">
- <span>与</span>
- <p class="agent-name space-x-[5px]" @click="isOpen = true">
- <img src="https://static.fuxicarbon.com/userupload/db77ffe0cef843278a23b0d2db9505fa.png" alt="">
- <span>{{ selectedOption?.title }}</span>
- </p>
- <span>对话中</span>
- </li>
- <li class="tools-close" @click="selectedOption = null">
- <SvgIcon name="chat-icon-close-btn"></SvgIcon>
- </li>
- </ul>
- <ul class="file-list-wrapper" v-show="uploadFileList.length">
- <li class="file-item space-x-[14px]" v-for="(item, index) in uploadFileList" :key="index">
- <div class="file-icon"></div>
- <div class="file-info">
- <p class="title">{{ item.name }}</p>
- <p class="info space-x-[8px]">
- <span class="suffix">{{ item.suffix }}</span>
- <span class="size">{{ item.size }}</span>
- </p>
- </div>
- <span class="close" @click="onRemoveFile(i)" v-show="item.percentage == 100 && !uploadLoading">x</span>
- <div class="file-progress" v-if="uploadLoading">
- <NProgress
- type="line"
- color="#3153f5"
- :percentage="item.percentage"
- :show-indicator="false"
- :height="3"
- ></NProgress>
- </div>
- </li>
- </ul>
- <div class="chat-inp-inner">
- <div class="inp-wrapper flex-1" @click="handleInpFocus">
- <div class="upload-inner">
- <NUpload
- accept=".doc, .docx, .pdf, .txt"
- :disabled="uploadLoading"
- :show-file-list="false"
- :action="baseURL + '/qiniuyun/upLoadImage'"
- :headers="{
- 'Authorization': 'Bearer' + useStore.token,
- }"
- @on-error="handleUploadError"
- @change="handleUploadChange"
- @finish="handleFinish"
- @before-upload="beforeUpload"
- >
- <NTooltip trigger="hover">
- <template #trigger>
- <div class="upload-file-button"></div>
- </template>
- <span class="text-[12px]">支持上传文件(每次一个, 大小5MB以内)接受.doc、.docx、.pdf、.txt等格式的文件</span>
- </NTooltip>
- </NUpload>
- </div>
- <NInput
- class="flex-1"
- ref="inpRef"
- type="textarea"
- size="medium"
- placeholder="输入@,召唤智能体"
- v-model:value="inpVal"
- :autosize="{ minRows: 1, maxRows: 5 }"
- @focus="focusInput"
- @blur="blurInput"
- @keypress="handleInpEnter"
- />
- </div>
- <div class="submit-btn">
- <button class="btn bg-[#1A2029] hover:bg-[#3C4148]" @click="handleBtnClick">
- <SvgIcon name="tool-send-plane" size="22" v-show="!modelLoading"></SvgIcon>
- <div style="color: #fff" class="la-ball-running-dots la-sm" v-show="modelLoading">
- <div v-for="item in 5" :key="item"></div>
- </div>
- </button>
- </div>
- </div>
- </div>
- <div class="switch-inner pt-[8px] flex justify-between items-center">
- <div class="space-x-[6px]">
- <NSwitch size="small" v-model:value="switchStatus"></NSwitch>
- <span class="text-[12px] text-[#9E9E9E]">使用deepseek R1</span>
- </div>
- <div>
- <TheArchival></TheArchival>
- </div>
- </div>
- <div class="masking-inner text-center text-[#2454FF]"></div>
- </div>
- </template>
-
- <div class="popover-inner" ref="popoverInnerRef">
- <div class="header">
- <span>选择智能体</span>
- <p class="tools-close" @click="isOpen = false">
- <SvgIcon name="chat-icon-close-btn"></SvgIcon>
- </p>
- </div>
- <NScrollbar style="max-height: 200px;" ref="scrollRef" trigger="none">
- <div class="item" v-for="item, index in agentOptions" :class="['item', { active: highlightedIndex === index }]" @click="selectOption(index)">
- <p class="icon">
- <img :src="item.banner" alt="">
- </p>
- <p class="ml-[10px] space-x-[5px] text">
- <span class="text-[15px]">{{item.title}}</span>
- <!-- <span class="text-[#888] text-[14px]">这里可以补充个描述</span> -->
- </p>
- </div>
- </NScrollbar>
- </div>
- </NPopover>
- </template>
- <style scoped lang="scss">
- .chat-inp-outer {
- border-radius: 8px;
- overflow: hidden;
- box-shadow: 0px 3px 12px 0px #97D3FF40;
- .chat-tools-inner {
- @include flex(x, center, between);
- .tools-tips {
- @include flex(x, center, start);
- color: #666;
- font-size: 14px;
-
- .agent-name {
- @include flex(x, center, start);
- font-weight: bold;
- color: #333;
- cursor: pointer;
- img {
- width: 14px;
- height: 14px;
- }
- }
- }
- }
- .chat-inp-inner {
- position: relative;
- @include flex(x, center, between);
- background: #fff;
- .inp-wrapper {
- @include flex(x, start, center);
- padding: 17px 0px 17px 17px;
- .upload-inner {
- width: 30px;
- height: 30px;
- padding-top: 2px;
- .upload-file-button {
- width: 30px;
- height: 30px;
- background: url("@/assets/svgs/chat/icon-file-default.svg") center center no-repeat;
- cursor: pointer;
- &:hover {
- background: url("@/assets/svgs/chat/icon-file-active.svg") center center no-repeat;
- }
- }
- }
- }
- .submit-btn {
- @include flex(x, center, center);
- width: 84px;
- .btn {
- @include flex(x, center, center);
- width: 50px;
- height: 32px;
- border-radius: 32px;
- transition: all .3s;
- }
- }
- }
-
- .file-list-wrapper {
- padding: 10px;
- padding-bottom: 0px;
- background: #fff;
- .file-item {
- position: relative;
- @include flex(x, center, start);
- width: 30%;
- // height: 52px;
- padding: 10px;
- border-radius: 4px;
- background: #f5f5f5;
- .file-icon {
- flex-shrink: 0;
- width: 36px;
- height: 36px;
- background: url("@/assets/svgs/chat/icon-file.svg") center center no-repeat;
- }
- .file-info {
- flex: 1;
- @include flex(y, start, start);
- font-size: 12px;
- .title {
- width: 170px;
- color: #1a2029;
- text-overflow: ellipsis;
- overflow: hidden;
- word-break: break-all;
- white-space: nowrap;
- }
- .info {
- flex: 1;
- @include flex(x, center, between);
- color: #838a95;
- }
- }
- .file-progress {
- position: absolute;
- left: 0;
- bottom: 0;
- width: 100%;
- margin: 0;
- }
- .close {
- position: absolute;
- top: 0;
- right: 0;
- width: 16px;
- height: 16px;
- border: 1px solid #fff;
- border-radius: 100%;
- transform: translate(40%, -40%);
- background: #c8c8c8;
- font-size: 10px;
- text-align: center;
- line-height: 12px;
- cursor: pointer;
- color: #fff;
- &:hover {
- background: #b7b7b7;
- }
- }
- }
- }
- }
- .popover-inner {
- .header {
- @include flex(x, center, between);
- padding-bottom: 8px;
- font-size: 14px;
- color: #666;
- }
- .item {
- @include flex(x, center, start);
- padding: 8px 10px;
- cursor: pointer;
- &:hover {
- background: #f0fafe;
- }
- .icon {
- @include flex(x, center, center);
- width: 24px;
- height: 24px;
- border-radius: 100%;
- background: #e9eef8;
- img {
- width: 16px;
- height: 16px;
- }
- }
- .text {
- text-align: left;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- }
- .active {
- background: #f0fafe;
- }
- }
- .tools-close {
- @include flex(x, center, center);
- width: 28px;
- height: 28px;
- border-radius: 6px;
- background: #fff;
- cursor: pointer;
- &:hover {
- background: #e9eef8;
- }
- }
- .masking-inner {
- position: absolute;
- top: -30px;
- left: 0;
- width: 100%;
- height: 30px;
- background: linear-gradient(180deg, rgba(232, 241, 250, 0) 0%, #E7F0FA 95%);
- }
- </style>
|