Bläddra i källkod

支持在消息中显示上传的附件

kagg886 1 månad sedan
förälder
incheckning
3fee924134
4 ändrade filer med 164 tillägg och 58 borttagningar
  1. 9 7
      src/api/assist/type.ts
  2. 16 0
      src/api/common/index.ts
  3. 17 20
      src/utils/loading-util.ts
  4. 122 31
      src/views/assistant/index.vue

+ 9 - 7
src/api/assist/type.ts

@@ -33,13 +33,15 @@ export type Message = {
 	tool_calls?: FunctionCall[]
 
 	//仅role为user时需要
-	files?: {
-		size: number
-		path: string
-		name: string
-		type: string
-		full_path: string
-	}
+	files?: Array<UploadFile> | undefined
+}
+
+export type UploadFile = {
+	size: number
+	path: string
+	name: string
+	type: string
+	full_path: string
 }
 
 export type FunctionCall = {

+ 16 - 0
src/api/common/index.ts

@@ -0,0 +1,16 @@
+import {post} from '/@/utils/request'
+
+export default {
+	upload: {
+		single: (blob: File) => {
+			const data = new FormData()
+			data.append('file', blob)
+			return post('/common/singleFile', data)
+		},
+		multi: (blob: File[]) => {
+			const data = new FormData()
+			blob.forEach(file => data.append('file', file))
+			return post('/common/multipleFile', data)
+		}
+	}
+}

+ 17 - 20
src/utils/loading-util.ts

@@ -1,23 +1,20 @@
-import { Ref, ref } from 'vue'
+import { ref } from 'vue'
 
 // eslint-disable-next-line no-unused-vars
-export function useLoading<T extends (...param: Parameters<T>) => Promise<void>>(
-  // eslint-disable-next-line no-unused-vars
-  inner: T
-): {
-  loading: Ref<boolean>
-  // eslint-disable-next-line no-unused-vars
-  doLoading: (...param: Parameters<T>) => Promise<void>
-} {
-  const loading = ref(false)
+export function useLoading<T extends (...args: any[]) => Promise<any>>(inner: T) {
+	const loading = ref(false)
 
-  return {
-    loading,
-    doLoading: async (...param: Parameters<T>) => {
-      loading.value = true
-      return inner(...param).finally(() => {
-        loading.value = false
-      })
-    },
-  }
-}
+	async function doLoading(...args: Parameters<T>): Promise<Awaited<ReturnType<T>>> {
+		loading.value = true
+		try {
+			return await inner(...args)
+		} finally {
+			loading.value = false
+		}
+	}
+
+	return {
+		loading,
+		doLoading,
+	}
+}

+ 122 - 31
src/views/assistant/index.vue

@@ -1,25 +1,32 @@
 <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 common from '/@/api/common/index'
 import {
-	User,
+	ArrowDown,
 	ChatDotRound,
-	Delete,
-	Edit,
 	Check,
 	Close,
-	ArrowDown,
-	Star,
-	StarFilled,
-	Search,
+	CopyDocument,
+	Delete,
 	Download,
-	MoreFilled,
-	Setting,
+	Edit,
 	Loading,
+	MoreFilled,
+	Plus,
 	Promotion,
+	Search,
+	Setting,
+	Setting as EleSetting,
+	Star,
+	StarFilled,
+	User,
 	VideoPause,
-	CopyDocument,
-	Plus,
+	Document,
+	Picture,
+	VideoPlay,
+	Headset,
+	Files,
 } from '@element-plus/icons-vue'
 import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
 import EChartsPlugin from '/@/components/markdown/plugins/echarts'
@@ -29,7 +36,6 @@ import Markdown from '/@/components/markdown/Markdown.vue'
 import assist from '/@/api/assist'
 import { ChatResponse, LmConfigInfo, LmSession, Message, Prompt } from '/@/api/assist/type'
 import { useLoading } from '/@/utils/loading-util'
-import { Setting as EleSetting } from '@element-plus/icons-vue'
 import { useRouter } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import StructDataPlugin from '/@/components/markdown/plugins/struct-data'
@@ -40,29 +46,28 @@ const plugins: Array<MarkdownPlugin<any>> = [EChartsPlugin(), ToolsLoadingPlugin
 //聊天管理接口
 // 消息列表
 const messages = ref<Message[]>([])
+const messagesContainer = ref<HTMLElement>()
 
 // 输入框内容
 const inputMessage = ref('')
-const {open,reset,files: attachments} = useFileDialog({
+const {
+	open,
+	reset,
+	files: attachments,
+} = useFileDialog({
 	multiple: true,
 })
-const messagesContainer = ref<HTMLElement>()
 
 // 附件管理:使用本地列表以支持单个移除
 const selectedFiles = ref<File[]>([])
 
-// 监听文件选择并合并到本地列表(按 name+size+lastModified 去重)
 watch(
 	attachments,
 	(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 }
 )
@@ -204,8 +209,8 @@ const displayPromptList = computed(() => {
 	return r
 })
 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(() => {
 	if (!loadingPromptList.value && selectPromptId.value === undefined) {
@@ -266,6 +271,10 @@ const { loading: loadingClearMessage, doLoading: clearMessage } = useLoading(asy
 	}
 	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 () => {
 	if (!inputMessage.value.trim()) return
@@ -281,7 +290,9 @@ const sendMessage = async () => {
 		render_content: inputMessage.value,
 		content: inputMessage.value,
 		timestamp: Date.now(),
+		files: await doUpload(),
 	})
+	selectedFiles.value = []
 
 	const rtn = reactive<Message>({
 		id: messages.value.length,
@@ -721,7 +732,7 @@ const getUserInfos = ref<{
 }>(Local.get('userInfo') || {})
 
 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()
@@ -855,6 +866,20 @@ const { loading: exportConversationLoading, doLoading: exportConversation } = us
 const isBlank = (str: string) => {
 	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>
 
 <template>
@@ -1061,6 +1086,25 @@ const isBlank = (str: string) => {
 							<div class="user-message-content">
 								<div class="message-bubble user-bubble">
 									{{ 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 class="user-message-actions">
 									<el-button
@@ -1151,7 +1195,7 @@ const isBlank = (str: string) => {
 					<!-- 附件栏:紧贴输入框上方,位于容器内部 -->
 					<div class="attachments-inline">
 						<el-scrollbar>
-							<div class="attachments-inline-scroll">
+							<div class="attachments-inline-scroll" v-if="selectedFiles.length > 0">
 								<div
 									v-for="(file, fIdx) in selectedFiles"
 									:key="file.name + '_' + file.size + '_' + (file as any).lastModified"
@@ -1170,7 +1214,6 @@ const isBlank = (str: string) => {
 						</el-scrollbar>
 						<button class="control-btn add-attachment-btn" @click="open">
 							<el-icon :size="10"><Plus /></el-icon>
-							<span>添加附件</span>
 						</button>
 					</div>
 					<!-- 输入框 -->
@@ -1699,6 +1742,54 @@ const isBlank = (str: string) => {
 	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 {
 	flex-shrink: 0;
 	margin-top: 2px;
@@ -1961,7 +2052,7 @@ const isBlank = (str: string) => {
 	overflow: hidden;
 	text-overflow: ellipsis;
 
-	font-size: 10px!important;
+	font-size: 10px !important;
 }
 
 .attachment-name {
@@ -1973,7 +2064,7 @@ const isBlank = (str: string) => {
 
 .add-attachment-btn {
 	flex-shrink: 0;
-	font-size: 10px!important;
+	font-size: 10px !important;
 }
 
 .remove-attachment-icon {