|
@@ -1,25 +1,32 @@
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
-import { ref, nextTick, onMounted, computed, onUnmounted, reactive, isReactive, watch } from 'vue'
|
|
|
|
|
|
+import { computed, isReactive, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
import { Local } from '/@/utils/storage'
|
|
import { Local } from '/@/utils/storage'
|
|
|
|
+import common from '/@/api/common/index'
|
|
import {
|
|
import {
|
|
- User,
|
|
|
|
|
|
+ ArrowDown,
|
|
ChatDotRound,
|
|
ChatDotRound,
|
|
- Delete,
|
|
|
|
- Edit,
|
|
|
|
Check,
|
|
Check,
|
|
Close,
|
|
Close,
|
|
- ArrowDown,
|
|
|
|
- Star,
|
|
|
|
- StarFilled,
|
|
|
|
- Search,
|
|
|
|
|
|
+ CopyDocument,
|
|
|
|
+ Delete,
|
|
Download,
|
|
Download,
|
|
- MoreFilled,
|
|
|
|
- Setting,
|
|
|
|
|
|
+ Edit,
|
|
Loading,
|
|
Loading,
|
|
|
|
+ MoreFilled,
|
|
|
|
+ Plus,
|
|
Promotion,
|
|
Promotion,
|
|
|
|
+ Search,
|
|
|
|
+ Setting,
|
|
|
|
+ Setting as EleSetting,
|
|
|
|
+ Star,
|
|
|
|
+ StarFilled,
|
|
|
|
+ User,
|
|
VideoPause,
|
|
VideoPause,
|
|
- CopyDocument,
|
|
|
|
- Plus,
|
|
|
|
|
|
+ Document,
|
|
|
|
+ Picture,
|
|
|
|
+ VideoPlay,
|
|
|
|
+ Headset,
|
|
|
|
+ Files,
|
|
} from '@element-plus/icons-vue'
|
|
} from '@element-plus/icons-vue'
|
|
import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
|
|
import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
|
|
import EChartsPlugin from '/@/components/markdown/plugins/echarts'
|
|
import EChartsPlugin from '/@/components/markdown/plugins/echarts'
|
|
@@ -29,7 +36,6 @@ import Markdown from '/@/components/markdown/Markdown.vue'
|
|
import assist from '/@/api/assist'
|
|
import assist from '/@/api/assist'
|
|
import { ChatResponse, LmConfigInfo, LmSession, Message, Prompt } from '/@/api/assist/type'
|
|
import { ChatResponse, LmConfigInfo, LmSession, Message, Prompt } from '/@/api/assist/type'
|
|
import { useLoading } from '/@/utils/loading-util'
|
|
import { useLoading } from '/@/utils/loading-util'
|
|
-import { Setting as EleSetting } from '@element-plus/icons-vue'
|
|
|
|
import { useRouter } from 'vue-router'
|
|
import { useRouter } from 'vue-router'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import StructDataPlugin from '/@/components/markdown/plugins/struct-data'
|
|
import StructDataPlugin from '/@/components/markdown/plugins/struct-data'
|
|
@@ -40,29 +46,28 @@ const plugins: Array<MarkdownPlugin<any>> = [EChartsPlugin(), ToolsLoadingPlugin
|
|
//聊天管理接口
|
|
//聊天管理接口
|
|
// 消息列表
|
|
// 消息列表
|
|
const messages = ref<Message[]>([])
|
|
const messages = ref<Message[]>([])
|
|
|
|
+const messagesContainer = ref<HTMLElement>()
|
|
|
|
|
|
// 输入框内容
|
|
// 输入框内容
|
|
const inputMessage = ref('')
|
|
const inputMessage = ref('')
|
|
-const {open,reset,files: attachments} = useFileDialog({
|
|
|
|
|
|
+const {
|
|
|
|
+ open,
|
|
|
|
+ reset,
|
|
|
|
+ files: attachments,
|
|
|
|
+} = useFileDialog({
|
|
multiple: true,
|
|
multiple: true,
|
|
})
|
|
})
|
|
-const messagesContainer = ref<HTMLElement>()
|
|
|
|
|
|
|
|
// 附件管理:使用本地列表以支持单个移除
|
|
// 附件管理:使用本地列表以支持单个移除
|
|
const selectedFiles = ref<File[]>([])
|
|
const selectedFiles = ref<File[]>([])
|
|
|
|
|
|
-// 监听文件选择并合并到本地列表(按 name+size+lastModified 去重)
|
|
|
|
watch(
|
|
watch(
|
|
attachments,
|
|
attachments,
|
|
(newFiles) => {
|
|
(newFiles) => {
|
|
- const incoming = Array.from(newFiles ?? [])
|
|
|
|
- const merged = [...selectedFiles.value, ...incoming]
|
|
|
|
- const map = new Map<string, File>()
|
|
|
|
- for (const f of merged) {
|
|
|
|
- const key = `${f.name}_${f.size}_${(f as any).lastModified ?? ''}`
|
|
|
|
- if (!map.has(key)) map.set(key, f)
|
|
|
|
- }
|
|
|
|
- selectedFiles.value = Array.from(map.values())
|
|
|
|
|
|
+ if (newFiles === null || newFiles.length === 0) return
|
|
|
|
+
|
|
|
|
+ selectedFiles.value = [...selectedFiles.value, ...newFiles]
|
|
|
|
+ reset()
|
|
},
|
|
},
|
|
{ immediate: true }
|
|
{ immediate: true }
|
|
)
|
|
)
|
|
@@ -204,8 +209,8 @@ const displayPromptList = computed(() => {
|
|
return r
|
|
return r
|
|
})
|
|
})
|
|
const selectPromptId = ref<number | undefined>(undefined)
|
|
const selectPromptId = ref<number | undefined>(undefined)
|
|
-watch(selectPromptId,(newVal)=>{
|
|
|
|
- inputMessage.value = displayPromptList.value.find(i=>i.id === newVal)?.placeholder ?? ''
|
|
|
|
|
|
+watch(selectPromptId, (newVal) => {
|
|
|
|
+ inputMessage.value = displayPromptList.value.find((i) => i.id === newVal)?.placeholder ?? ''
|
|
})
|
|
})
|
|
const promptLabel = computed(() => {
|
|
const promptLabel = computed(() => {
|
|
if (!loadingPromptList.value && selectPromptId.value === undefined) {
|
|
if (!loadingPromptList.value && selectPromptId.value === undefined) {
|
|
@@ -266,6 +271,10 @@ const { loading: loadingClearMessage, doLoading: clearMessage } = useLoading(asy
|
|
}
|
|
}
|
|
messages.value = []
|
|
messages.value = []
|
|
})
|
|
})
|
|
|
|
+
|
|
|
|
+const {loading:loadingUpload,doLoading: doUpload} = useLoading(async ()=> {
|
|
|
|
+ return selectedFiles.value.length === 0 ? undefined : (await common.upload.multi(selectedFiles.value,0)) as Message['files']
|
|
|
|
+})
|
|
// 发送消息
|
|
// 发送消息
|
|
const sendMessage = async () => {
|
|
const sendMessage = async () => {
|
|
if (!inputMessage.value.trim()) return
|
|
if (!inputMessage.value.trim()) return
|
|
@@ -281,7 +290,9 @@ const sendMessage = async () => {
|
|
render_content: inputMessage.value,
|
|
render_content: inputMessage.value,
|
|
content: inputMessage.value,
|
|
content: inputMessage.value,
|
|
timestamp: Date.now(),
|
|
timestamp: Date.now(),
|
|
|
|
+ files: await doUpload(),
|
|
})
|
|
})
|
|
|
|
+ selectedFiles.value = []
|
|
|
|
|
|
const rtn = reactive<Message>({
|
|
const rtn = reactive<Message>({
|
|
id: messages.value.length,
|
|
id: messages.value.length,
|
|
@@ -721,7 +732,7 @@ const getUserInfos = ref<{
|
|
}>(Local.get('userInfo') || {})
|
|
}>(Local.get('userInfo') || {})
|
|
|
|
|
|
const canSendMessage = computed(() => {
|
|
const canSendMessage = computed(() => {
|
|
- return !inputMessage.value.trim() || loadingModels.value || loadConversations.value || loadingMessage.value
|
|
|
|
|
|
+ return !inputMessage.value.trim() || loadingModels.value || loadConversations.value || loadingMessage.value || loadingUpload.value
|
|
})
|
|
})
|
|
|
|
|
|
const router = useRouter()
|
|
const router = useRouter()
|
|
@@ -855,6 +866,20 @@ const { loading: exportConversationLoading, doLoading: exportConversation } = us
|
|
const isBlank = (str: string) => {
|
|
const isBlank = (str: string) => {
|
|
return str == null || str.trim().length === 0
|
|
return str == null || str.trim().length === 0
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+// 打开文件
|
|
|
|
+const openFile = (fullPath: string) => {
|
|
|
|
+ window.open(fullPath, '_blank')
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 格式化文件大小
|
|
|
|
+const formatFileSize = (bytes: number): string => {
|
|
|
|
+ if (bytes === 0) return '0 B'
|
|
|
|
+ const k = 1024
|
|
|
|
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
|
|
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
|
|
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
|
|
+}
|
|
</script>
|
|
</script>
|
|
|
|
|
|
<template>
|
|
<template>
|
|
@@ -1061,6 +1086,25 @@ const isBlank = (str: string) => {
|
|
<div class="user-message-content">
|
|
<div class="user-message-content">
|
|
<div class="message-bubble user-bubble">
|
|
<div class="message-bubble user-bubble">
|
|
{{ message.render_content }}
|
|
{{ message.render_content }}
|
|
|
|
+ <div v-if="message.files !== undefined && message.files.length > 0" class="message-files">
|
|
|
|
+ <div
|
|
|
|
+ v-for="file in message.files"
|
|
|
|
+ :key="file.path"
|
|
|
|
+ class="file-item"
|
|
|
|
+ @click="openFile(file.full_path)"
|
|
|
|
+ :title="`点击打开: ${file.name}`"
|
|
|
|
+ >
|
|
|
|
+ <el-icon class="file-icon">
|
|
|
|
+ <Document v-if="file.type.includes('text') || file.type.includes('document')" />
|
|
|
|
+ <Picture v-else-if="file.type.includes('image')" />
|
|
|
|
+ <VideoPlay v-else-if="file.type.includes('video')" />
|
|
|
|
+ <Headset v-else-if="file.type.includes('audio')" />
|
|
|
|
+ <Files v-else />
|
|
|
|
+ </el-icon>
|
|
|
|
+ <span class="file-name">{{ file.name }}</span>
|
|
|
|
+ <span class="file-size">{{ formatFileSize(file.size) }}</span>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
</div>
|
|
</div>
|
|
<div class="user-message-actions">
|
|
<div class="user-message-actions">
|
|
<el-button
|
|
<el-button
|
|
@@ -1151,7 +1195,7 @@ const isBlank = (str: string) => {
|
|
<!-- 附件栏:紧贴输入框上方,位于容器内部 -->
|
|
<!-- 附件栏:紧贴输入框上方,位于容器内部 -->
|
|
<div class="attachments-inline">
|
|
<div class="attachments-inline">
|
|
<el-scrollbar>
|
|
<el-scrollbar>
|
|
- <div class="attachments-inline-scroll">
|
|
|
|
|
|
+ <div class="attachments-inline-scroll" v-if="selectedFiles.length > 0">
|
|
<div
|
|
<div
|
|
v-for="(file, fIdx) in selectedFiles"
|
|
v-for="(file, fIdx) in selectedFiles"
|
|
:key="file.name + '_' + file.size + '_' + (file as any).lastModified"
|
|
:key="file.name + '_' + file.size + '_' + (file as any).lastModified"
|
|
@@ -1170,7 +1214,6 @@ const isBlank = (str: string) => {
|
|
</el-scrollbar>
|
|
</el-scrollbar>
|
|
<button class="control-btn add-attachment-btn" @click="open">
|
|
<button class="control-btn add-attachment-btn" @click="open">
|
|
<el-icon :size="10"><Plus /></el-icon>
|
|
<el-icon :size="10"><Plus /></el-icon>
|
|
- <span>添加附件</span>
|
|
|
|
</button>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- 输入框 -->
|
|
<!-- 输入框 -->
|
|
@@ -1699,6 +1742,54 @@ const isBlank = (str: string) => {
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+/* 消息文件样式 */
|
|
|
|
+.message-files {
|
|
|
|
+ margin-top: 8px;
|
|
|
|
+ display: flex;
|
|
|
|
+ flex-direction: row;
|
|
|
|
+ gap: 6px;
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
+ overflow: visible;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.file-item {
|
|
|
|
+ display: flex;
|
|
|
|
+ align-items: center;
|
|
|
|
+ gap: 4px;
|
|
|
|
+ padding: 4px 6px;
|
|
|
|
+ background: rgba(255, 255, 255, 0.1);
|
|
|
|
+ border-radius: 4px;
|
|
|
|
+ cursor: pointer;
|
|
|
|
+ transition: all 0.2s ease;
|
|
|
|
+ font-size: 11px;
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
+ min-width: 0;
|
|
|
|
+
|
|
|
|
+ &:hover {
|
|
|
|
+ background: rgba(255, 255, 255, 0.2);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .file-icon {
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
+ font-size: 12px;
|
|
|
|
+ color: rgba(255, 255, 255, 0.8);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .file-name {
|
|
|
|
+ max-width: 80px;
|
|
|
|
+ overflow: hidden;
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
+ white-space: nowrap;
|
|
|
|
+ color: rgba(255, 255, 255, 0.9);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .file-size {
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
+ color: rgba(255, 255, 255, 0.6);
|
|
|
|
+ font-size: 10px;
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
.message-avatar {
|
|
.message-avatar {
|
|
flex-shrink: 0;
|
|
flex-shrink: 0;
|
|
margin-top: 2px;
|
|
margin-top: 2px;
|
|
@@ -1961,7 +2052,7 @@ const isBlank = (str: string) => {
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
- font-size: 10px!important;
|
|
|
|
|
|
+ font-size: 10px !important;
|
|
}
|
|
}
|
|
|
|
|
|
.attachment-name {
|
|
.attachment-name {
|
|
@@ -1973,7 +2064,7 @@ const isBlank = (str: string) => {
|
|
|
|
|
|
.add-attachment-btn {
|
|
.add-attachment-btn {
|
|
flex-shrink: 0;
|
|
flex-shrink: 0;
|
|
- font-size: 10px!important;
|
|
|
|
|
|
+ font-size: 10px !important;
|
|
}
|
|
}
|
|
|
|
|
|
.remove-attachment-icon {
|
|
.remove-attachment-icon {
|