2201 lines
64 KiB
Vue
2201 lines
64 KiB
Vue
<route lang="json5" type="page">
|
||
{
|
||
layout: 'default',
|
||
style: {
|
||
navigationBarHidden: true,
|
||
},
|
||
}
|
||
</route>
|
||
|
||
<template>
|
||
<div class="flex flex-col h-screen bg-#ffffff tops">
|
||
<!-- Navigation Bar -->
|
||
<div
|
||
class="flex-none flex items-center justify-between px-5 py-3 bg-white shadow-md h-20 pt-10 z-999 fixed top-0 w-full box-border"
|
||
>
|
||
<image src="/static/aichat/black.png" class="w-3 h-4.5" @click="goBack" />
|
||
<div class="text-lg font-medium ml-12">小墨</div>
|
||
<div class="flex items-center space-x-3">
|
||
<!-- v-if="rawList.length > 0" -->
|
||
<image src="/static/aichat/time.png" class="w-5 h-5 mr-4" @click="openPopup" />
|
||
<image src="/static/aichat/new.png" class="w-5 h-5" @click="newChat" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 消息区 -->
|
||
<div
|
||
:class="[
|
||
'flex fixed top-0 w-full p-b-10 box-border mt-20',
|
||
showActions ? (uploadList.length ? 'h-118' : 'h-151') : 'h-171',
|
||
]"
|
||
>
|
||
<!-- 背景层 -->
|
||
<div
|
||
v-if="!messages.length"
|
||
class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none bg-#ffffff"
|
||
>
|
||
<image src="/static/aichat/logo.png" class="w-20 h-24 mb-4" @click="newChat" />
|
||
<view class="text-xl font-medium mb-1">嗨! 我是小墨</view>
|
||
<view class="text-gray-400">开启新的聊天吧</view>
|
||
</div>
|
||
|
||
<div
|
||
ref="scrollEl"
|
||
class="flex-1 overflow-y-auto bg-#ffffff"
|
||
:class="showActions ? 'pb-44' : 'pb-16'"
|
||
>
|
||
<div :class="['relative z-10 px-4 py-6', showActions ? 'mb--11 h-105' : 'mb--21']">
|
||
<template v-for="(msg, idx) in messages" :key="idx">
|
||
<view v-if="shouldShowTimestamp(idx)" class="text-center text-xs text-gray-500 mt--3">
|
||
{{ formatDayGroup(msg.timestamp) }}
|
||
</view>
|
||
<view
|
||
class="flex items-start"
|
||
:class="msg.role === 'assistant' ? 'justify-start mt-0.3' : 'justify-end mt-2 '"
|
||
>
|
||
<image
|
||
v-if="msg.role === 'assistant'"
|
||
src="/static/aichat/logo-message.png"
|
||
class="w-8 h-8 rounded-full mr-2 mt-1"
|
||
/>
|
||
<view
|
||
class="relative max-w-[76.5%] mt-5"
|
||
:class="idx === messages.length - 1 ? 'mb-20' : 'mb-3'"
|
||
>
|
||
<view
|
||
:class="[
|
||
'absolute -top-5 text-xs text-gray-400 w-20 text-right ',
|
||
msg.role === 'assistant' ? 'left--4' : 'right-0',
|
||
]"
|
||
>
|
||
{{ formatTimeShort(msg.timestamp) }}
|
||
</view>
|
||
<view
|
||
:class="[
|
||
'py-3 pl-3 rounded-md break-words mb-3 tracking-[2rpx] min-w-10 min-h-2 ',
|
||
msg.role === 'assistant'
|
||
? 'bg-[#f9f8fd] text-black shadow '
|
||
: 'bg-[#45299e] text-white ',
|
||
msg.type === 'text' ? 'pr-0' : 'pr-3',
|
||
]"
|
||
>
|
||
<wd-loading
|
||
v-show="msg.role === 'assistant' && msgLoading && idx === messages.length - 1"
|
||
:size="20"
|
||
color="#e3e3e3"
|
||
custom-class="loading-black"
|
||
:class="[
|
||
'absolute top-1.5 text-xs text-gray-400 w-20 text-right',
|
||
msg.role === 'assistant' ? 'left-1' : 'right-0',
|
||
]"
|
||
/>
|
||
<!-- 图片消息 -->
|
||
<scroll-view
|
||
scroll-x
|
||
v-if="msg.type === 'image'"
|
||
vif="msg.type === uploadFileTypeEm.image"
|
||
@scroll="onScroll"
|
||
>
|
||
<view class="flex pr-1">
|
||
<view
|
||
v-for="(file, fileIdx) in msg.content.slice(0, 4)"
|
||
:key="fileIdx"
|
||
class="relative rounded-md overflow-hidden ml-2"
|
||
:class="{
|
||
'w-60 h-60': msg.content.length == 1,
|
||
'w-15 h-15': msg.content.length == 2,
|
||
'w-30 h-15 ': msg.content.length == 3,
|
||
'w-80 h-15 ': msg.content.length > 3,
|
||
}"
|
||
>
|
||
<view
|
||
v-if="msg.content.length > 4 && fileIdx >= 3"
|
||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70 z-80"
|
||
@click="previewMoreImg(msg.content)"
|
||
>
|
||
+ {{ msg.content.length - 4 }}
|
||
</view>
|
||
<image
|
||
vif="file.uploadFileType === uploadFileTypeEm.image"
|
||
:src="file?.image_url?.url"
|
||
class="w-full h-full object-cover"
|
||
@click="handleImageClick(file.image_url.url)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 文件消息 -->
|
||
|
||
<scroll-view
|
||
scroll-x
|
||
v-else-if="msg.type === uploadFileTypeEm.file"
|
||
@scroll="onScroll"
|
||
>
|
||
<view class="flex pr-1">
|
||
<view
|
||
v-for="(file, fileIdx) in msg.content"
|
||
:key="fileIdx"
|
||
style="flex: 0 0 6rem"
|
||
class="relative text-xs h-12 px-2 py-2 rounded-md overflow-hidden mr-1 bg-white c-black"
|
||
:class="{
|
||
'w-25 h-15': msg.content.length == 1,
|
||
'w-50 h-15': msg.content.length == 2,
|
||
'w-30 h-15 ': msg.content.length == 3,
|
||
'h-15 flex-grow-0 flex-shrink-0 basis-10 mr-1': msg.content.length >= 4,
|
||
}"
|
||
@click="previewFile(file.content)"
|
||
>
|
||
<view>{{ file.name ? file.name : file.content }}</view>
|
||
<!-- <view>--{{ file }}--</view> -->
|
||
<!-- <view>{{ file }}</view> -->
|
||
<view>{{ calcFileSize(file.size) }}</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 视频 -->
|
||
<view v-else-if="msg.type === uploadFileTypeEm.video">
|
||
<view
|
||
v-for="(file, fileIdx) in msg.content"
|
||
:key="fileIdx"
|
||
style="flex: 0 0 6rem"
|
||
class="relative text-xs h-32 w-80 rounded-md overflow-hidden mr-1 c-black"
|
||
>
|
||
<!-- @click="previewVideo(msg.content)"-->
|
||
<video
|
||
:src="file?.video_url?.url"
|
||
class="w-60 h-30"
|
||
controls
|
||
:poster="file?.video_url?.poster"
|
||
></video>
|
||
<!-- <video
|
||
:src="file.tempFilePath"
|
||
class="w-60 h-30"
|
||
controls
|
||
:poster="file.url"
|
||
></video> -->
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 文本消息 -->
|
||
<view v-else class="pr-2">
|
||
{{ msg.content }}
|
||
</view>
|
||
</view>
|
||
<view
|
||
v-if="msg.role === 'assistant' && msg.type === 'text'"
|
||
class="absolute bottom--3.5 flex space-x-3 ml-1"
|
||
>
|
||
<image src="/static/aichat/copy.png" class="w-4 h-4" @click="copyText(msg)" />
|
||
<image src="/static/aichat/resect.png" class="w-4.3 h-4" @click="refreshText()" />
|
||
</view>
|
||
</view>
|
||
<image
|
||
v-if="msg.role === 'user'"
|
||
:src="userAvatar"
|
||
class="w-8 h-8 rounded-full ml-2 mt-1 bg-gray"
|
||
/>
|
||
</view>
|
||
</template>
|
||
</div>
|
||
<!-- 聊天信息区域 -->
|
||
<!-- <view class="" style="height: 72px"></view> -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部上传预览 + 输入区 -->
|
||
<div
|
||
:class="[
|
||
'fixed bottom-0 left-0 right-0 bg-white z-[80] overflow-hidden transition-all duration-300',
|
||
showActions ? (uploadList.length ? 'h-40' : 'h-40') : 'h-20',
|
||
]"
|
||
>
|
||
<!-- 上传列表 -->
|
||
<div
|
||
v-if="uploadList.length"
|
||
:class="[
|
||
'flex px-4 py-2 overflow-x-auto space-x-3 bg-transparent',
|
||
showActions
|
||
? uploadList.length
|
||
? ' fixed bottom-40'
|
||
: 'fixed bottom-19'
|
||
: 'fixed bottom-19',
|
||
]"
|
||
:style="{
|
||
transform: `rotate(0deg)`,
|
||
transition: 'transform 0.3s ease',
|
||
}"
|
||
>
|
||
<div class="flex overflow-x-auto space-x-3 py-2 w-88 flex-nowrap">
|
||
<div
|
||
v-for="item in uploadList"
|
||
:key="item.id"
|
||
class="relative w-20 h-16 rounded overflow-hidden flex-shrink-0"
|
||
>
|
||
<!-- 预览图,成功后用后端返回的 URL;上传中可以先用本地预览 -->
|
||
|
||
<!-- <img
|
||
v-if="videoFileType.includes(isFileType(item.name))"
|
||
src="/static/aichat/ic_video.png"
|
||
class="w-full h-full object-cover"
|
||
@click="handleImageClick(item.ori_url)"
|
||
/>
|
||
<img
|
||
v-else-if="officeFileTypeList.includes(isFileType(item.name))"
|
||
src="/static/aichat/word.png"
|
||
class="w-full h-full object-cover"
|
||
@click="handleImageClick(item.url)"
|
||
/>
|
||
<img
|
||
v-else-if="picFileType.includes(isFileType(item.name))"
|
||
:src="item.tempFilePath"
|
||
class="w-full h-full object-cover"
|
||
@click="handleImageClick(item.url)"
|
||
/> -->
|
||
<img
|
||
v-if="picFileType.includes(isFileType(item.name))"
|
||
:src="item.tempFilePath || item.url"
|
||
class="w-full h-full object-cover"
|
||
@click="handleImageClick(item.ori_url)"
|
||
/>
|
||
<view v-else class="text-xs text-gray-400 mt-1" @click="previewFile(item.ori_url)">
|
||
{{ item.name }}
|
||
</view>
|
||
|
||
<!-- 关闭按钮 -->
|
||
<div
|
||
class="absolute top-1 right-1 w-4 h-4 rounded-full bg-black bg-opacity-50 flex items-center justify-center cursor-pointer text-white text-xs z-5"
|
||
@click="removeImage(item.id)"
|
||
>
|
||
×
|
||
</div>
|
||
|
||
<!-- 重试 -->
|
||
<span
|
||
v-if="item.status === 'error'"
|
||
class="absolute w-full h-full bg-black bg-opacity-40 pt-1 left-0 top-0 text-center color-white text-3xl"
|
||
@click.stop="retry(item)"
|
||
>
|
||
↻
|
||
</span>
|
||
|
||
<!-- 进度 / 成功 / 失败 -->
|
||
<div
|
||
class="absolute bottom-0 left-0 w-full text-xs text-center py-1 bg-black bg-opacity-50"
|
||
:class="{
|
||
'text-black': item.status === 'uploading',
|
||
'text-green': item.status === 'success',
|
||
'text-red': item.status === 'error',
|
||
}"
|
||
>
|
||
<template v-if="item.status === 'uploading'">
|
||
<view class="text-white">{{ item.progress }}%</view>
|
||
</template>
|
||
<template v-else-if="item.status === 'success'">✔ 成功</template>
|
||
<template v-else>✖ 失败</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 知识库-->
|
||
<div
|
||
@click="toggleKnowledge"
|
||
v-if="!uploadList.length"
|
||
class="fixed left-[32rpx] right-0 z-[90] h-8 w-23 flex items-center justify-between px-3 box-border rounded-1 transition-all duration-300"
|
||
:class="[
|
||
knowledgeOpen ? 'bg-#eee9f8' : 'bg-[#F9F9F9]',
|
||
showActions ? (uploadList.length ? 'bottom-31' : 'bottom-41') : 'bottom-21',
|
||
]"
|
||
>
|
||
<image
|
||
:src="
|
||
knowledgeOpen
|
||
? '/static/aichat/Knowledge-open.png'
|
||
: '/static/aichat/Knowledge-close.png'
|
||
"
|
||
class="w-4 h-3.5 mt-1"
|
||
/>
|
||
<div
|
||
:class="['text-[26rpx] transition-colors', knowledgeOpen ? 'text-#46299D' : 'text-black']"
|
||
>
|
||
知识库
|
||
</div>
|
||
<!-- <button @click="reload()">refresh</button> -->
|
||
</div>
|
||
|
||
<!-- 输入 + 切换 -->
|
||
<view class="flex items-center px-4 py-2.5 border-t border-t-solid border-[#E7E7E7]">
|
||
<input
|
||
v-model="inputText"
|
||
@focus="onFocus"
|
||
@confirm="sendText"
|
||
placeholder="想对我说点什么~"
|
||
class="flex-1 h-10 px-3 border border-gray-100 bg-[#f9f9f9] rounded-1 focus:outline-none"
|
||
/>
|
||
<!-- 将keyup替换为confirm -->
|
||
<image
|
||
v-show="!knowledgeOpen"
|
||
src="/static/aichat/add-circle.png"
|
||
class="w-7 h-7 mx-3"
|
||
@click="toggleActions"
|
||
:style="{
|
||
transform: `rotate(${rotation}deg)`,
|
||
transition: 'transform 0.3s ease',
|
||
}"
|
||
/>
|
||
<image
|
||
v-if="sendTextLoading && inputText.length <= 0"
|
||
src="/static/aichat/enter-no.png"
|
||
class="w-7 h-7"
|
||
@click="sendText()"
|
||
:disabled="loading"
|
||
:class="[knowledgeOpen ? 'ml-2' : 'ml-0']"
|
||
/>
|
||
<image
|
||
v-else-if="sendTextLoading"
|
||
src="/static/aichat/enter.png"
|
||
class="w-7 h-7"
|
||
@click="sendText()"
|
||
:disabled="loading"
|
||
:class="[knowledgeOpen ? 'ml-2' : 'ml-0']"
|
||
/>
|
||
<image
|
||
v-else
|
||
src="/static/aichat/stop.png"
|
||
class="w-7 h-7"
|
||
@click="stopMsg()"
|
||
:class="[knowledgeOpen ? 'ml-2' : 'ml-0']"
|
||
/>
|
||
</view>
|
||
|
||
<!-- 操作面板 -->
|
||
<transition name="slide-up">
|
||
<view
|
||
v-show="showActions"
|
||
:class="[
|
||
'flex justify-around items-center h-10 bg-white border-t border-t-solid border-[#E7E7E7]',
|
||
showActions ? (uploadList.length ? 'pt-10' : 'pt-10') : 'pt-0',
|
||
]"
|
||
>
|
||
<view class="flex flex-col items-center">
|
||
<image src="/static/aichat/phone-img.png" class="w-13 h-13" @click="onPickImage" />
|
||
<span class="text-xs mt-1 text-gray-500">照片</span>
|
||
</view>
|
||
<view class="flex flex-col items-center">
|
||
<image src="/static/aichat/photo.png" class="w-13 h-13" @click="onPickVideo" />
|
||
<span class="text-xs mt-1 text-gray-500">视频</span>
|
||
</view>
|
||
<view class="flex flex-col items-center">
|
||
<image src="/static/aichat/files.png" class="w-13 h-13" @click="onPickFile" />
|
||
<span class="text-xs mt-1 text-gray-500">文件</span>
|
||
</view>
|
||
</view>
|
||
</transition>
|
||
</div>
|
||
|
||
<!-- 弹窗遮罩 + 容器 -->
|
||
<view v-if="showPopup" class="overlay" @click.self="closePopup">
|
||
<view :class="['popup', { fullscreen }]">
|
||
<!-- Header -->
|
||
<view class="popup-header">
|
||
<view>
|
||
<image src="/static/aichat/close.png" class="w4 h4" @click="closePopup" />
|
||
<image
|
||
:src="fullscreen ? '/static/aichat/shirk-cl.png' : '/static/aichat/shrink.png'"
|
||
class="w5 h5 ml-4"
|
||
@click="toggleFullscreen"
|
||
/>
|
||
</view>
|
||
|
||
<text class="title ml--14">历史记录</text>
|
||
</view>
|
||
<!-- 内容区 -->
|
||
<view v-if="!rawList.length" class="flex-1 flex flex-col items-center justify-center">
|
||
<image
|
||
src="/static/aichat/empty.png"
|
||
class="w-[400rpx] h-[400rpx] mb-[20rpx] opacity-50"
|
||
mode="widthFix"
|
||
/>
|
||
<text class="text-[28rpx] text-gray-500">暂无数据</text>
|
||
</view>
|
||
<scroll-view
|
||
v-else
|
||
scroll-y
|
||
class="popup-body"
|
||
@scrolltolower="scrolltolowerLoadData"
|
||
:scroll-top="scrollTop"
|
||
>
|
||
<template v-for="group in groups" :key="group.date">
|
||
<view class="date-label">{{ group.label }}</view>
|
||
<view
|
||
v-for="item in group.items"
|
||
:key="item.id"
|
||
class="history-item"
|
||
@click="goChat(item.listUuid)"
|
||
>
|
||
<text class="history-text">{{ item.title }}</text>
|
||
</view>
|
||
</template>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<view
|
||
v-if="showPreview"
|
||
class="fixed inset-0 bg-black bg-opacity-90 z-999 flex justify-center items-start pt-10"
|
||
@click="closePreview"
|
||
>
|
||
<view
|
||
class="absolute w-full max-w-full h-full mt-8 p-5 box-border flex justify-center items-start"
|
||
>
|
||
<image
|
||
v-if="previewUrl"
|
||
class="w-full max-h-[calc(100vh-90px)] object-contain"
|
||
:src="previewUrl"
|
||
mode="widthFix"
|
||
@click.stop
|
||
/>
|
||
<video
|
||
class="w-full max-h-[calc(100vh-90px)] object-contain"
|
||
v-else-if="previewVideoUrl"
|
||
:src="previewVideoUrl"
|
||
></video>
|
||
<view
|
||
class="absolute top--8 right-5 text-white text-3xl z-1000 w-10 h-10 text-center leading-10"
|
||
@click.stop="closePreview"
|
||
>
|
||
×
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, reactive, nextTick, watchEffect, watch } from 'vue'
|
||
import dayjs from 'dayjs'
|
||
import { useUserStore } from '@/store'
|
||
import { getEnvBaseUrl } from '@/utils'
|
||
import guid from '@/utils/guid.js'
|
||
import type { IGptRequestBody } from '@/service/index/foo'
|
||
import {
|
||
calcFileSize,
|
||
formatParams,
|
||
formatData,
|
||
readFile,
|
||
fileSuffix,
|
||
officeFileTypeList as fileType,
|
||
videoFileType as videoType,
|
||
picFileType as picType,
|
||
} from './utils/index'
|
||
import 'dayjs/locale/zh-cn'
|
||
import { showToastErr, showToastOk, time_format3 } from '@/utils/tools'
|
||
import { uploadFileChunk } from './utils/api.js'
|
||
// import { TOKEN, AVATAR } from './utils/test'
|
||
import { deepClone } from 'wot-design-uni/components/common/util'
|
||
import { log } from 'console'
|
||
|
||
dayjs.locale('zh-cn')
|
||
|
||
interface UploadFile {
|
||
id: string
|
||
formName: string
|
||
name: string
|
||
size: number
|
||
type: string
|
||
progress: number // 上传进度 0-100
|
||
status: 'uploading' | 'success' | 'error'
|
||
tempFilePath: string // 本地临时路径,用于预览
|
||
url?: string // 本地预览URL或服务器返回的URL
|
||
uploadFileType: 'image' | 'video' | 'file'
|
||
ori_url?: string // 本地视频
|
||
suffix: string
|
||
}
|
||
|
||
interface IMessage {
|
||
role: 'user' | 'assistant'
|
||
type: 'text' | 'image' | 'video' | 'file'
|
||
content: string | any[]
|
||
timestamp: Date
|
||
}
|
||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 5mb属于大文件
|
||
const FILE_SLICE_SIZE = 10 * 1024 * 1024 // 分片大小
|
||
const userAvatar = ref()
|
||
const chatMode = ref('qwen-vl-plus')
|
||
let isUserOk = false // 用户确认使用 tongyi-app
|
||
|
||
const baseUrl = getEnvBaseUrl()
|
||
const messages = reactive([])
|
||
//获取用户上下文
|
||
const historyUserMsgs = reactive<any[]>([])
|
||
const inputText = ref('')
|
||
const loading = ref(false)
|
||
const showActions = ref(false)
|
||
const scrollEl = ref<HTMLElement>()
|
||
//显示图片更多遮罩层
|
||
const showImageMask = ref(false)
|
||
//已上传文件列表
|
||
const uploadList = reactive<UploadFile[]>([])
|
||
// 上传队列
|
||
const uploadQueue = reactive([])
|
||
// 上传中文件数量
|
||
const uploadingCount = ref(0)
|
||
// 最大并行上传数
|
||
const MAX_CONCURRENT_UPLOADS = 5 // 6
|
||
|
||
// ------------------
|
||
// 控制弹窗显隐 & 全屏/底部模式
|
||
// ------------------
|
||
const showPopup = ref(false)
|
||
const fullscreen = ref(false)
|
||
|
||
const officeFileTypeList = ref(fileType)
|
||
const otherFileType = ref(['.txt']) // 不在使用
|
||
const videoFileType = ref(videoType)
|
||
const picFileType = ref(picType)
|
||
|
||
// ------------------
|
||
// 历史记录数据 & 分组逻辑
|
||
// ------------------
|
||
interface HistoryRecord {
|
||
id: number
|
||
listUuid: string
|
||
title: string
|
||
createdAt: number // Unix 时间戳(秒)
|
||
}
|
||
const rawList = ref<HistoryRecord[]>([])
|
||
|
||
const groups = computed(() => {
|
||
const map: Record<string, HistoryRecord[]> = {}
|
||
rawList.value.length > 0 &&
|
||
rawList.value.forEach((item) => {
|
||
const key = dayjs.unix(item.createdAt).format('YYYY-MM-DD')
|
||
;(map[key] ||= []).push(item)
|
||
})
|
||
return Object.keys(map)
|
||
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
|
||
.map((key) => {
|
||
const today = dayjs().format('YYYY-MM-DD')
|
||
const yesterday = dayjs().add(-1, 'day').format('YYYY-MM-DD')
|
||
let label = ''
|
||
if (key === today) label = '今天'
|
||
else if (key === yesterday) label = '昨天'
|
||
else label = dayjs(key).format('YYYY/MM/DD')
|
||
return { date: key, label, items: map[key] }
|
||
})
|
||
})
|
||
|
||
// 点击历史记录条目,跳转聊天页面
|
||
async function goChat(listUuid: string) {
|
||
await fetchHistoryDiets(listUuid)
|
||
showPopup.value = false
|
||
}
|
||
// qwen-vl-plus
|
||
/** 1. 创建聊天会话 */
|
||
const listUuid = ref('')
|
||
|
||
async function createChatSession() {
|
||
try {
|
||
const createResp: any = await uni.request({
|
||
url: `${baseUrl}/chat/app/create`,
|
||
method: 'POST',
|
||
data: {
|
||
gptModel: chatMode.value,
|
||
},
|
||
header: {
|
||
Authorization: token.value,
|
||
},
|
||
})
|
||
// 如果后台返回新的会话信息,可以在这里处理,比如拿到 listUuid 等
|
||
listUuid.value = createResp.data.data.listUuid
|
||
} catch (err) {
|
||
console.log(err)
|
||
}
|
||
}
|
||
// ------------------
|
||
// 拉取历史记录接口(替换为你自己的 API)
|
||
// ------------------
|
||
|
||
/** 2. 拉取历史记录列表 */
|
||
const state = reactive({
|
||
page: 0,
|
||
pageSize: 20,
|
||
total: null,
|
||
loading: false,
|
||
})
|
||
const scrollTop = ref(0)
|
||
async function fetchHistoryList() {
|
||
// if(state.page*state.pageSize>state.total && state.total!==null){
|
||
// return
|
||
// }
|
||
if (state.loading) {
|
||
return
|
||
}
|
||
state.loading = true
|
||
try {
|
||
const resp: any = await uni.request({
|
||
url: `${baseUrl}/chat/app/list`,
|
||
method: 'POST',
|
||
data: {
|
||
page: state.page,
|
||
pageSize: state.pageSize,
|
||
},
|
||
header: {
|
||
Authorization: token.value,
|
||
},
|
||
})
|
||
if (resp.data && resp.data.data) {
|
||
rawList.value = rawList.value.concat(resp.data.data.data)
|
||
state.total = resp.data.data.count //Math.ceil(resp.data.count/state.page)
|
||
// scrollTop.value+=60;
|
||
}
|
||
} catch (err) {
|
||
console.log('err: ', err)
|
||
} finally {
|
||
state.loading = false
|
||
}
|
||
}
|
||
const scrolltolowerLoadData = (e) => {
|
||
state.page++
|
||
}
|
||
watch(
|
||
() => state.page,
|
||
async () => {
|
||
await fetchHistoryList()
|
||
},
|
||
{ deep: true },
|
||
)
|
||
async function openPopup() {
|
||
state.page++
|
||
showPopup.value = true
|
||
}
|
||
function closePopup() {
|
||
showPopup.value = false
|
||
}
|
||
function toggleFullscreen() {
|
||
fullscreen.value = !fullscreen.value
|
||
}
|
||
/** 3. 拉取历史记录详情 */
|
||
|
||
async function fetchHistoryDiets(value) {
|
||
try {
|
||
const resp: any = await uni.request({
|
||
url: `${baseUrl}/chat/app/detail`,
|
||
method: 'POST',
|
||
data: {
|
||
listUuid: value,
|
||
gptModel: chatMode.value,
|
||
},
|
||
header: {
|
||
Authorization: token.value,
|
||
},
|
||
})
|
||
if (resp && resp.data && resp.data.data) {
|
||
const rawList = resp.data.data.detail // 假设后端直接返回消息数组
|
||
listUuid.value = resp.data.data.listUuid
|
||
// const newMessages = parseBackendMessages(JSON.parse(rawList))
|
||
// 用解析后的消息替换当前消息列表
|
||
messages.splice(0, messages.length, ...JSON.parse(rawList))
|
||
}
|
||
} catch (err) {
|
||
console.log('err: ', err)
|
||
}
|
||
}
|
||
function parseBackendMessages(chatList: any[]): IMessage[] {
|
||
console.log('chatList: ', chatList)
|
||
const chatHistory = []
|
||
const requestArrData = [] // 请求数组
|
||
chatList.forEach((item) => {
|
||
const arr = JSON.parse(item.message)
|
||
console.log('item.CreatedAt: ', item.UpdatedAt)
|
||
const date = time_format3(item.CreatedAt)
|
||
|
||
if (item.role === 'user') {
|
||
const videoContent = []
|
||
const imageContent = []
|
||
let textContent = {}
|
||
const requestData = {
|
||
// 单个请求对象
|
||
content: [],
|
||
role: item.user,
|
||
}
|
||
arr.forEach((chat) => {
|
||
if (chat.type === 'image_url') {
|
||
imageContent.push(chat)
|
||
requestData.content.push(chat)
|
||
} else if (chat.type === 'video_url') {
|
||
videoContent.push(chat)
|
||
requestData.content.push(chat)
|
||
} else {
|
||
// if(item.type==="text")
|
||
textContent.type = 'text'
|
||
textContent.text = chat.text
|
||
requestArrData.push({
|
||
content: chat.text,
|
||
role: 'user',
|
||
})
|
||
}
|
||
})
|
||
if (requestData.content.length > 0) {
|
||
requestArrData.push({
|
||
role: item.role,
|
||
content: requestData,
|
||
})
|
||
}
|
||
|
||
if (videoContent.length > 0) {
|
||
const videoMessage = {
|
||
role: item.role,
|
||
type: 'video',
|
||
content: videoContent, // string []
|
||
timestamp: date,
|
||
}
|
||
chatHistory.push(videoMessage)
|
||
}
|
||
if (imageContent.length > 0) {
|
||
const imageMessage = {
|
||
role: item.role,
|
||
type: 'image',
|
||
content: imageContent, // string []
|
||
timestamp: date,
|
||
}
|
||
chatHistory.push(imageMessage)
|
||
}
|
||
if (textContent.type === 'text') {
|
||
const msg = {
|
||
role: item.role,
|
||
type: 'text',
|
||
content: textContent.text, // string []
|
||
timestamp: date,
|
||
}
|
||
chatHistory.push(msg)
|
||
}
|
||
} else if (item.role === 'system') {
|
||
const content = []
|
||
const requestData = {
|
||
// 单个请求对象
|
||
content: '', // 文件链接
|
||
role: item.user,
|
||
}
|
||
arr.forEach((chat) => {
|
||
const isFile = officeFileTypeList.value.includes(fileSuffix(chat.text)) //检测是否属于文档类
|
||
if (chat.type === 'text' && !isFile) {
|
||
const textContent = {
|
||
role: 'user',
|
||
type: 'text',
|
||
text: chat.text,
|
||
}
|
||
chatHistory.push(textContent)
|
||
requestArrData.push({
|
||
content: chat.text,
|
||
role: 'user',
|
||
})
|
||
} else {
|
||
// 当content为文件链接时
|
||
content.push({
|
||
role: chat.role,
|
||
type: 'file',
|
||
content: chat.text, // string []
|
||
timestamp: date,
|
||
})
|
||
requestArrData.push({
|
||
content: chat.text,
|
||
name: chat.text,
|
||
role: 'system',
|
||
})
|
||
}
|
||
})
|
||
const message = {
|
||
role: item.role,
|
||
type: 'file',
|
||
content: content, // string []
|
||
timestamp: date,
|
||
}
|
||
chatHistory.push(message)
|
||
|
||
console.log('-----chatHistory: ', chatHistory)
|
||
} else if (item.role === 'assistant') {
|
||
arr.forEach((chat) => {
|
||
chatHistory.push({
|
||
role: 'assistant',
|
||
type: 'text',
|
||
content: chat.text,
|
||
timestamp: date,
|
||
})
|
||
requestArrData.push({
|
||
content: chat.text,
|
||
role: 'assistant',
|
||
type: 'text',
|
||
})
|
||
})
|
||
}
|
||
})
|
||
historyUserMsgs.splice(0, historyUserMsgs.length, ...requestArrData)
|
||
console.log('chatHistory: ', chatHistory)
|
||
return chatHistory
|
||
}
|
||
function parseBackendMessages1(rawList: any[]): IMessage[] {
|
||
const messageList: IMessage[] = []
|
||
|
||
rawList.forEach((item) => {
|
||
const rawContent: string = item.text ?? item.content ?? item.message ?? ''
|
||
// 先尝试 map[...] 的老逻辑
|
||
let parts: ParsedPart[] | null = tryParseMapFormat(rawContent)
|
||
|
||
// 如果没命中 map[...],再看看是不是一个标准的 JSON 数组字符串
|
||
if (!parts && rawContent.startsWith('[') && rawContent.endsWith(']')) {
|
||
try {
|
||
const arr = JSON.parse(rawContent) as Array<{ text: string; type: string }>
|
||
if (Array.isArray(arr)) {
|
||
parts = arr.map((el) => {
|
||
if (el.type === 'file_url') {
|
||
return {
|
||
type: 'video',
|
||
content: [{ url: el.text, uploadFileType: 'video' }],
|
||
}
|
||
} else if (el.type === 'image_url' || el.type === 'image') {
|
||
return {
|
||
type: 'image',
|
||
content: [{ url: el.text, uploadFileType: 'image' }],
|
||
}
|
||
} else if (el.type === 'text') {
|
||
return { type: 'text', content: el.text }
|
||
}
|
||
|
||
// 其他类型按需扩展
|
||
return { type: el.type, content: el.text }
|
||
})
|
||
}
|
||
} catch {
|
||
// 解析失败就继续下面的 fallback
|
||
}
|
||
}
|
||
|
||
if (parts) {
|
||
// 拆分成多条消息推入
|
||
parts.forEach((part) => {
|
||
messageList.push({
|
||
role: item.role,
|
||
type: part.type as any, // 'text' 或 'image'
|
||
content: part.content,
|
||
timestamp: (item.CreatedAt ?? 0) * 1000,
|
||
})
|
||
})
|
||
} else {
|
||
// 你原来的 fallback 分支(单条,直接塞 content,type 也补成 'text')
|
||
messageList.push({
|
||
role: item.role,
|
||
type: 'text',
|
||
content: rawContent,
|
||
timestamp: (item.CreatedAt ?? 0) * 1000,
|
||
})
|
||
}
|
||
})
|
||
|
||
return messageList
|
||
}
|
||
interface ParsedPart {
|
||
type: string
|
||
content: string | UploadFile[]
|
||
}
|
||
|
||
function tryParseMapFormat(str: string): ParsedPart[] | null {
|
||
if (!str || !str.startsWith('[') || !str.endsWith(']') || !str.includes('map[')) {
|
||
return null // 不符合 [map[...] ...] 格式
|
||
}
|
||
|
||
const result: ParsedPart[] = []
|
||
// 提取所有 map[...] 子串
|
||
const pattern = /map\[([^\]]+)\]/g
|
||
let match: RegExpExecArray | null
|
||
while ((match = pattern.exec(str)) !== null) {
|
||
const contentBlock = match[1] // 如 "text:这种 type:text"
|
||
const typeIndex = contentBlock.indexOf(' type:')
|
||
if (typeIndex === -1) {
|
||
continue // 安全检查,正常情况下一定有 ' type:' 分隔
|
||
}
|
||
// 拆分内容部分和类型部分
|
||
const contentPart = contentBlock.substring(0, typeIndex).trim() // "text:这种"
|
||
const typeValue = contentBlock.substring(typeIndex + 6).trim() // 跳过 " type:" 得到 "text" 或 "image_url"
|
||
// 解析内容键和值
|
||
const colonIndex = contentPart.indexOf(':')
|
||
if (colonIndex === -1) {
|
||
continue
|
||
}
|
||
const key = contentPart.substring(0, colonIndex).trim() // 键,比如 "text" 或 "image_url"
|
||
let value = contentPart.substring(colonIndex + 1).trim() // 值,比如 "这种" 或 "https://cdn.xx/xxx.jpg"
|
||
// 去掉包裹在值两端的引号(如果有的话)
|
||
if (
|
||
(value.startsWith('"') && value.endsWith('"')) ||
|
||
(value.startsWith("'") && value.endsWith("'"))
|
||
) {
|
||
value = value.substring(1, value.length - 1)
|
||
}
|
||
|
||
// 根据类型构造 ParsedPart 对象
|
||
if (typeValue === 'text' && key === 'text') {
|
||
result.push({
|
||
type: 'text',
|
||
content: value,
|
||
})
|
||
} else if (typeValue === 'image_url' && key === 'image_url') {
|
||
// 将图片URL封装为 UploadFile 对象数组
|
||
result.push({
|
||
type: 'image', // 前端统一用 'image' 作为图片消息类型
|
||
content: [{ url: value, uploadFileType: 'image' }],
|
||
})
|
||
} else {
|
||
// 其他类型的内容,可根据需要扩展处理
|
||
result.push({
|
||
type: typeValue,
|
||
content: value,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 如果成功解析出至少一个片段,则返回数组;否则返回 null
|
||
return result.length > 0 ? result : null
|
||
}
|
||
|
||
const token = ref<string>()
|
||
const userInfo = ref<any>({})
|
||
const refreshToken = ref<string>('')
|
||
const statusBarHeight = ref<number>(0)
|
||
const mask = ref('')
|
||
// ---- 页面初始化 ----
|
||
|
||
onMounted(async () => {
|
||
// 1. 定义一个 init 函数,拿 Extras 并依次调用接口
|
||
try {
|
||
const init = async () => {
|
||
const wv = plus.webview.currentWebview() // 获取当前页面所属的 Webview 对象。
|
||
token.value = wv.token || uni.getStorageSync('token') || import.meta.env.VITE_DEV_TOKEN
|
||
userInfo.value = JSON.parse(wv.userInfo) || {}
|
||
refreshToken.value = wv.refreshToken || uni.getStorageSync('refreshToken')
|
||
statusBarHeight.value = wv.statusBarHeight || uni.getSystemInfoSync().statusBarHeight
|
||
userAvatar.value = userInfo.value.Avatar
|
||
mask.value = userInfo.value.ID
|
||
await createChatSession()
|
||
}
|
||
init()
|
||
} catch (e) {
|
||
console.error('onMounted e: ', e)
|
||
} finally {
|
||
}
|
||
})
|
||
|
||
function scrollToBottom() {
|
||
const el = scrollEl.value!
|
||
nextTick(() => {
|
||
// el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||
})
|
||
}
|
||
|
||
//图片滚动事件
|
||
const onScroll = (e) => {
|
||
showImageMask.value = e.detail.scrollLeft <= 0
|
||
}
|
||
|
||
//查看更多图片
|
||
const previewMoreImg = (files) => {
|
||
uni.removeStorageSync('previewImages')
|
||
uni.removeStorageSync('previewVideos')
|
||
|
||
uni.setStorageSync('previewImages', files)
|
||
|
||
uni.navigateTo({
|
||
url: `/pages/preview/index`,
|
||
})
|
||
}
|
||
|
||
//查看视频
|
||
const previewVideo = (files) => {
|
||
uni.removeStorageSync('previewVideos')
|
||
uni.removeStorageSync('previewImages')
|
||
|
||
uni.setStorageSync('previewVideos', files)
|
||
|
||
uni.navigateTo({
|
||
url: `/pages/preview/index`,
|
||
})
|
||
}
|
||
|
||
function addMessage(msg) {
|
||
console.log('msg: ', msg)
|
||
messages.push(msg)
|
||
scrollToBottom()
|
||
}
|
||
|
||
const shouldShowTimestamp = (i: number) => {
|
||
if (i === 0) return true
|
||
return !dayjs(messages[i].timestamp).isSame(messages[i - 1].timestamp, 'day')
|
||
}
|
||
const formatDayGroup = (d: Date) => dayjs(d).format('YYYY/MM/DD HH:mm')
|
||
const formatTimeShort = (d: Date) => dayjs(d).format('MM/DD HH:mm')
|
||
|
||
function goBack() {
|
||
const wv = plus.webview.currentWebview()
|
||
wv.close('slide-out-right', 300) // 或者直接 wv.close()
|
||
}
|
||
function viewHistory() {
|
||
uni.navigateTo({ url: '/pages/history/history' })
|
||
}
|
||
async function newChat() {
|
||
messages.splice(0)
|
||
await createChatSession()
|
||
inputText.value = ''
|
||
}
|
||
const rotation = ref(0)
|
||
function toggleActions() {
|
||
uni.hideKeyboard()
|
||
|
||
rotation.value += 45
|
||
|
||
console.log(rotation.value, '2')
|
||
showActions.value = !showActions.value
|
||
|
||
scrollToBottom()
|
||
}
|
||
|
||
// 上传文件formData类型
|
||
const uploadFileTypeEm = {
|
||
image: 'image',
|
||
video: 'video',
|
||
file: 'file',
|
||
text: 'text',
|
||
}
|
||
// const url=baseUrl
|
||
// 上传配置
|
||
const uploadConfig = reactive({
|
||
url: `${baseUrl}/upload/img`,
|
||
formData: {
|
||
source: 'chat',
|
||
mask: mask.value,
|
||
type: uploadFileTypeEm.image,
|
||
},
|
||
image: {
|
||
maxSize: 10 * 1024 * 1024, // 图片最大10MB
|
||
limitMsg: '图片大小不能超过10MB',
|
||
supportType: ['.jpeg', '.png', '.jpg'],
|
||
},
|
||
video: {
|
||
maxSize: 100 * 1024 * 1024, // 视频最大100MB
|
||
limitMsg: '视频大小不能超过100MB',
|
||
supportType: ['.mp4', '.mov', '.wmv'],
|
||
},
|
||
file: {
|
||
maxSize: 100 * 1024 * 1024, // 文件最大100MB
|
||
limitMsg: '文件大小不能超过300MB',
|
||
supportType: ['.pdf', '.doc', '.xlsx', '.txt'],
|
||
},
|
||
})
|
||
|
||
//监听上传文件状态更新loading
|
||
watchEffect(() => {
|
||
const allSuccess = uploadList.every((item) => item.status === 'success')
|
||
loading.value = !allSuccess
|
||
})
|
||
|
||
//添加上传队列
|
||
const addUploadQueue = (tempFiles: any[], uploadFileType: string) => {
|
||
// 添加到上传队列
|
||
const files = tempFiles.map((item: any, index: number) => ({
|
||
id: guid.getGuid(),
|
||
tempFilePath: item.path,
|
||
progress: 0,
|
||
status: 'pending',
|
||
name: item.name,
|
||
size: item.size,
|
||
type: item.type,
|
||
uploadFileType: uploadFileType,
|
||
formName: 'file',
|
||
}))
|
||
|
||
//剔除掉大小超过限制的文件
|
||
const _files = []
|
||
files.forEach((file: any) => {
|
||
const size = file.size
|
||
const fileMaxSize = uploadConfig[uploadFileType].maxSize
|
||
const fileLimitMsg = uploadConfig[uploadFileType].limitMsg
|
||
|
||
if (size > fileMaxSize) {
|
||
uni.showToast({
|
||
title: fileLimitMsg,
|
||
icon: 'none',
|
||
})
|
||
return
|
||
}
|
||
|
||
_files.push(file)
|
||
})
|
||
|
||
// 添加到上传文件列表
|
||
uploadList.push(..._files)
|
||
|
||
// 添加到队列
|
||
uploadQueue.push(..._files)
|
||
|
||
// 更新上传文件类型
|
||
uploadConfig.formData.type = uploadFileType
|
||
|
||
// 开始上传
|
||
startUploads()
|
||
}
|
||
|
||
// 开始上传文件
|
||
const startUploads = () => {
|
||
// 获取待上传文件
|
||
const pendingFiles: Array<any> = uploadQueue.filter((file) => file.status === 'pending')
|
||
|
||
// 限制最大并行上传数
|
||
const filesToUpload = pendingFiles.slice(0, MAX_CONCURRENT_UPLOADS - uploadingCount.value)
|
||
|
||
filesToUpload.forEach((file) => {
|
||
// uploadFile(file);
|
||
let url = null
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
bigFileUpload(file)
|
||
.then((res) => {
|
||
console.log('全部成功 res: ', res)
|
||
// const length=res.length;
|
||
url = res.FullFileUrl
|
||
file.status = 'success'
|
||
uploadingCount.value--
|
||
})
|
||
.catch((rej) => {
|
||
console.log('失败rej: ', rej)
|
||
file.status = 'error'
|
||
// uploadingCount.value--
|
||
})
|
||
} else {
|
||
uploadFile(file)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 上传单个文件
|
||
const uploadFile = (file: UploadFile) => {
|
||
// 更新状态为上传中
|
||
file.status = 'uploading'
|
||
uploadingCount.value++
|
||
|
||
// 创建上传任务
|
||
const uploadTask = uni.uploadFile({
|
||
url: uploadConfig.url,
|
||
filePath: file.tempFilePath,
|
||
name: file.formName,
|
||
formData: uploadConfig.formData,
|
||
success: (res) => {
|
||
const result = JSON.parse(res.data)
|
||
|
||
if (res.statusCode === 200 && result.code === 0) {
|
||
// 更新文件状态
|
||
file.status = 'success'
|
||
file.progress = 100
|
||
file.url = result.data.cover_url || result.data.ori_url || file.tempFilePath
|
||
file.ori_url = result.data.ori_url || ''
|
||
uni.showToast({
|
||
title: res.msg || '文件上传成功',
|
||
icon: 'none',
|
||
})
|
||
} else {
|
||
file.status = 'error'
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
file.status = 'error'
|
||
if (err.errMsg === 'uploadFile:fail timeout') {
|
||
uni.showToast({
|
||
title: '上传文件超时',
|
||
icon: 'none',
|
||
duration: 3000,
|
||
})
|
||
}
|
||
},
|
||
complete: () => {
|
||
// 更新上传文件列表状态
|
||
uploadList.forEach((item) => {
|
||
if (item.id === file.id) {
|
||
item.status = file.status
|
||
item.url = file.url
|
||
item.progress = file.progress
|
||
}
|
||
})
|
||
|
||
uploadingCount.value--
|
||
|
||
// 检查是否还有待上传的文件
|
||
const remainingPendingFiles = uploadQueue.filter((file) => file.status === 'pending')
|
||
if (remainingPendingFiles.length > 0) {
|
||
startUploads()
|
||
} else if (uploadingCount.value === 0) {
|
||
// 所有文件上传完成
|
||
// uni.showToast({
|
||
// title: '全部上传完成',
|
||
// icon: 'success',
|
||
// })
|
||
loading.value = false
|
||
}
|
||
},
|
||
})
|
||
|
||
// 监听上传进度
|
||
uploadTask.onProgressUpdate((res) => {
|
||
uploadList.forEach((item) => {
|
||
if (item.id === file.id) {
|
||
item.progress = res.progress === 100 ? 99 : res.progress
|
||
}
|
||
})
|
||
})
|
||
}
|
||
function bigFileUpload(file) {
|
||
file.status = 'uploading'
|
||
uploadingCount.value++
|
||
|
||
console.log('file: ', file) //progress
|
||
return new Promise(async (resolve, reject) => {
|
||
const md5 = file.id
|
||
const fileName = file.name
|
||
const tempFilePath = file.tempFilePath
|
||
const maxSize = FILE_SLICE_SIZE
|
||
const chunksList = await readFile(file, maxSize)
|
||
const chunksLength = chunksList.length
|
||
const requestList = []
|
||
const maxConcurrency = 3 // 一次最多3个
|
||
let currentRunning = 0
|
||
let index = 0
|
||
let success = null
|
||
const errors = []
|
||
let isOver = false
|
||
const next = () => {
|
||
while (currentRunning < maxConcurrency && index < chunksLength) {
|
||
if (isOver) {
|
||
// 有一个失败就立刻取消之后的请求
|
||
return
|
||
}
|
||
const chunkIndex = index++
|
||
const chunk = chunksList[chunkIndex]
|
||
const params = {
|
||
FileMd5: md5,
|
||
Chunk: chunk,
|
||
ChunkFileName: `${fileName}_${chunkIndex}`,
|
||
Total: chunksLength,
|
||
UseType: 100,
|
||
FileName: fileName,
|
||
Source: 'aiChat',
|
||
}
|
||
currentRunning++
|
||
// send request
|
||
uploadFileChunk({ formData: params, tempFilePath })
|
||
.then((res) => {
|
||
if (res.statusCode === 200) {
|
||
file.progress = Math.ceil(((chunkIndex + 1) / chunksLength) * 100)
|
||
if (file.progress > 100) {
|
||
file.progress = 100
|
||
}
|
||
const data = JSON.parse(res.data)
|
||
if (data.status === 0) {
|
||
// success.push(data.data)
|
||
success = data.data
|
||
if (data.data && data.data.FullFileUrl) {
|
||
file.url = data.data.FullFileUrl
|
||
file.ori_url = data.data.FullFileUrl
|
||
} else if (data.data && data.data.CoverUrl) {
|
||
file.url = data.data.CoverUrl
|
||
file.ori_url = data.data.CoverUrl
|
||
}
|
||
} else if (data.status === 1 && data.code === 401) {
|
||
showToastErr(data.msg)
|
||
reject(data)
|
||
// throw Error(data.msg);
|
||
}
|
||
// console.log('res: ',data);
|
||
} else {
|
||
errors.push(res.data)
|
||
throw new Error('上传失败')
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
reject(err)
|
||
isOver = true
|
||
file.status = 'error'
|
||
// console.error(`第 ${chunkIndex} 片上传失败`, err)
|
||
errors.push(err)
|
||
})
|
||
.finally(() => {
|
||
currentRunning--
|
||
if (chunkIndex === chunksLength - 1) {
|
||
if (errors.length === 0) {
|
||
file.status = 'success'
|
||
resolve(success)
|
||
} else {
|
||
file.status = 'error'
|
||
reject(errors)
|
||
}
|
||
} else if (index < chunksLength) {
|
||
next()
|
||
}
|
||
})
|
||
}
|
||
}
|
||
next()
|
||
// const request = (chunks, i) => {
|
||
// const chunk = chunks.shift()
|
||
// const params = {
|
||
// FileMd5: md5,
|
||
// Chunk: chunk,
|
||
// ChunkFileName: `${fileName}_${i}`,
|
||
// Total: chunksLength,
|
||
// UseType: 100,
|
||
// FileName: fileName,
|
||
// Source: 'aiChat',
|
||
// }
|
||
|
||
// uploadFileChunk({ formData: params, tempFilePath: tempFilePath })
|
||
// .then((res) => {
|
||
// // requestList.push(Promise.resolve(res))
|
||
// if (res.status === 0) {
|
||
// file.progress = Math.ceil(((i + 1) / chunksLength) * 100)
|
||
// Promise.resolve(JSON.parse(res.data))
|
||
// } else {
|
||
// Promise.reject(res)
|
||
// }
|
||
// })
|
||
// .catch((rej) => {
|
||
// // requestList.push(Promise.resolve(rej))
|
||
// Promise.reject(rej)
|
||
// })
|
||
// .finally(() => {
|
||
// if (chunks.length > 0) {
|
||
// request(chunks, ++i)
|
||
// }
|
||
// })
|
||
// }
|
||
// console.log('success,next: ', success, errors);
|
||
// const promiseList=chunksList.map(request);
|
||
// request(chunksList, 0)
|
||
// return Promise.all(chunksList)
|
||
// .then((res) => {
|
||
// console.log('all res: ', res)
|
||
// })
|
||
// .catch((rej) => {
|
||
// console.log('all rej: ', rej)
|
||
// })
|
||
})
|
||
}
|
||
|
||
// 照片
|
||
const onPickImage = () => {
|
||
loading.value = true
|
||
uni.chooseImage({
|
||
count: 9, // 最多选择9张
|
||
sizeType: ['original', 'compressed'],
|
||
sourceType: ['album'],
|
||
// extension: ['.rar', '.png', '.jpg'],
|
||
// extension: ['jpeg', 'png', 'jpg'],
|
||
success: (res: any) => {
|
||
console.log(res)
|
||
|
||
// 开始上传
|
||
addUploadQueue(res.tempFiles, uploadFileTypeEm.image)
|
||
},
|
||
fail: (err) => {
|
||
uni.showToast({
|
||
title: '选择照片失败',
|
||
icon: 'none',
|
||
})
|
||
loading.value = false
|
||
},
|
||
})
|
||
}
|
||
|
||
// 视频
|
||
const onPickVideo = () => {
|
||
uni.chooseVideo({
|
||
sourceType: ['album', 'camera'],
|
||
compressed: true,
|
||
// extension: uploadConfig.video.supportType,
|
||
success: (res: any) => {
|
||
console.log(res)
|
||
|
||
const tempFile = res.tempFile
|
||
tempFile.path = res.tempFilePath
|
||
|
||
// 开始上传
|
||
addUploadQueue([tempFile], uploadFileTypeEm.video)
|
||
},
|
||
fail: (err) => {
|
||
uni.showToast({
|
||
title: '选取视频失败',
|
||
icon: 'none',
|
||
})
|
||
},
|
||
})
|
||
}
|
||
const fileInput = ref(null)
|
||
// extension: [
|
||
// 'txt', // 文本
|
||
// 'pdf', // PDF
|
||
// 'doc',
|
||
// 'docx', // Word
|
||
// 'xls',
|
||
// 'xlsx', // Excel
|
||
// 'ppt',
|
||
// 'pptx', // PowerPoint
|
||
// ],
|
||
// 文件
|
||
const onPickFile = () => {
|
||
uni.chooseFile({
|
||
count: 9,
|
||
type: 'all',
|
||
|
||
success: (res: any) => {
|
||
console.log(res)
|
||
// 开始上传
|
||
addUploadQueue(res.tempFiles, uploadFileTypeEm.file)
|
||
},
|
||
fail: (err) => {
|
||
uni.showToast({
|
||
title: '选择文件失败',
|
||
})
|
||
},
|
||
})
|
||
}
|
||
|
||
// 点击重试
|
||
function retry(item: UploadFile) {
|
||
item.status = 'uploading'
|
||
item.progress = 0
|
||
if (item.size > MAX_FILE_SIZE) {
|
||
bigFileUpload(item)
|
||
.then((res) => {
|
||
// const length=res.length;
|
||
// item.url=res.FullFileUrl;
|
||
// item.ori_url=res.FullFileUrl;
|
||
item.status = 'success'
|
||
})
|
||
.catch((rej) => {
|
||
item.status = 'error'
|
||
})
|
||
} else {
|
||
uploadFile(item) // 如果需要 detail,可缓存后传入
|
||
}
|
||
}
|
||
|
||
// 删除
|
||
function removeImage(id: string) {
|
||
const findIndex = uploadList.findIndex((item) => item.id === id)
|
||
if (findIndex > -1) {
|
||
uploadList.splice(findIndex, 1)
|
||
}
|
||
}
|
||
|
||
// 预览(可自行实现 uni.previewImage 等)
|
||
function previewImage(url: string) {
|
||
uni.previewImage({ urls: [url] })
|
||
}
|
||
|
||
// 预览文件
|
||
// 统一替换掉原来的 previewFile
|
||
const previewFile = (url: string) => {
|
||
if (typeof url !== 'string') {
|
||
return
|
||
}
|
||
if (typeof plus !== 'undefined') {
|
||
// 在 App 里直接用原生下载 + 打开
|
||
downloadAndOpenFile(url)
|
||
} else {
|
||
// H5 或者调试环境回退到 WebView
|
||
uni.navigateTo({
|
||
url: '/pages/webview/index?link=' + encodeURIComponent(url),
|
||
})
|
||
}
|
||
}
|
||
|
||
// 下载并打开文件
|
||
const downloadAndOpenFile = (downloadUrl: string) => {
|
||
uni.showLoading({ title: '加载中...', mask: true })
|
||
|
||
if (!downloadUrl) {
|
||
uni.hideLoading()
|
||
return uni.showToast({ title: '文件路径无效', icon: 'none' })
|
||
}
|
||
|
||
// 将文件存放到应用私有下载目录,保证权限可读可写
|
||
const options = {
|
||
// “_downloads/” 会自动映射到应用的 Documents/download 目录
|
||
filename: '_downloads/',
|
||
}
|
||
|
||
const dtask = plus.downloader.createDownload(downloadUrl, options, (d, status) => {
|
||
uni.hideLoading()
|
||
if (status === 200) {
|
||
const savedPath = d.filename
|
||
if (savedPath) {
|
||
// 用系统默认的方式打开任意类型文件(PDF/Word/Excel/图片/视频 都通用)
|
||
plus.runtime.openFile(savedPath, {}, () => {
|
||
// 打开失败的回调可选
|
||
})
|
||
} else {
|
||
uni.showToast({ title: '文件保存失败', icon: 'none' })
|
||
}
|
||
} else {
|
||
uni.showToast({ title: '下载失败', icon: 'error' })
|
||
}
|
||
})
|
||
|
||
dtask.start()
|
||
}
|
||
const msgLoading = ref(true)
|
||
interface EventTargetSendText {
|
||
detail: { value: string }
|
||
}
|
||
type EventTypeTarget = string | EventTargetSendText
|
||
const sendTextLoading = ref(true) // false可以发送消息,true不可以发送,因为上一次发送未结束
|
||
const refreshSend = ref(false)
|
||
let stopStreamMsg = false
|
||
const stopMsg = () => {
|
||
stopStreamMsg = true
|
||
}
|
||
async function sendText() {
|
||
console.log('uploadList: ', uploadList)
|
||
if (uploadList.length > 0) {
|
||
const isUpLoading = uploadList.some((file) => {
|
||
// return file.status==="error" || file.status==="pending"
|
||
return file.status === 'uploading' || file.status === 'pending'
|
||
})
|
||
const isError = uploadList.some((file) => {
|
||
// return file.status==="error" || file.status==="pending"
|
||
return file.status === 'error'
|
||
})
|
||
if (isUpLoading) {
|
||
return showToastErr('有文件正在上传')
|
||
} else if (isError) {
|
||
return showToastErr('有文件上传失败')
|
||
}
|
||
}
|
||
const msg = inputText.value.trim()
|
||
if (!msg && !refreshSend.value) {
|
||
return showToastErr('不可以发送空消息!')
|
||
}
|
||
// if (uploadList.length > 0) {
|
||
// return showToastErr('请等待文件上传完成!')
|
||
// }
|
||
if (!sendTextLoading.value) {
|
||
sendTextLoading.value = true
|
||
return showToastErr('正在接收消息请稍后')
|
||
}
|
||
// 开启加载状态
|
||
sendTextLoading.value = false
|
||
// 文本消息
|
||
|
||
// 先判断是否上传文件,若有文件在判断文件类型,视频+图片不能与文档类文件同时上传
|
||
|
||
// let body: IGptRequestBody;
|
||
let body = {}
|
||
let aiMsg = {}
|
||
if (uploadList.length > 0 && !isUserOk) {
|
||
let photo = [] // 媒体文件 参数中的content
|
||
let video = [] // 媒体文件 参数中的content
|
||
let fileList = [] // 文档文件
|
||
// 格式化请求参数
|
||
const { photoList, videoList, file } = formatParams(uploadList)
|
||
if ((photoList.length > 0 || videoList.length > 0) && file.length > 0) {
|
||
sendTextLoading.value = true
|
||
// 媒体文件与文档同时上传时
|
||
return showToastErr('视频或图片不能与文件同时上传')
|
||
}
|
||
addMessage({
|
||
role: 'user',
|
||
type: 'text',
|
||
content: msg,
|
||
timestamp: new Date(),
|
||
mask: 'new',
|
||
})
|
||
|
||
if (photoList.length > 0 && file.length <= 0) {
|
||
// 仅上传了媒体文件时
|
||
chatMode.value = 'qwen-vl-max'
|
||
aiMsg = {
|
||
role: 'assistant',
|
||
// role:"user",
|
||
type: 'text',
|
||
content: msg,
|
||
timestamp: new Date(),
|
||
}
|
||
addMessage({
|
||
role: 'user',
|
||
// role:"user",
|
||
type: 'image',
|
||
content: deepClone(photoList),
|
||
timestamp: new Date(),
|
||
mask: 'new',
|
||
})
|
||
|
||
photoList.push({
|
||
type: 'text',
|
||
text: msg,
|
||
})
|
||
const lastMsg = {
|
||
role: 'user',
|
||
// role: 'assistant',
|
||
type: 'image',
|
||
content: photoList,
|
||
mask: 'new',
|
||
}
|
||
historyUserMsgs.push(lastMsg)
|
||
body = {
|
||
model: chatMode.value, // 模型选择
|
||
max_tokens: 1000,
|
||
top_p: 1,
|
||
presence_penalty: 0,
|
||
frequency_penalty: 0,
|
||
messages: deepClone(historyUserMsgs), // text ? [aiMsg] : historyUserMsgs,
|
||
stream: true,
|
||
listUuid: listUuid.value, // "eff18a10-1719-4528-ad63-ee5c01d0a412"
|
||
}
|
||
// 开始发请求 api
|
||
}
|
||
if (videoList.length > 0 && file.length <= 0) {
|
||
chatMode.value = 'qwen-vl-max'
|
||
aiMsg = {
|
||
role: 'assistant',
|
||
// role:"user",
|
||
type: 'text',
|
||
content: msg,
|
||
timestamp: new Date(),
|
||
}
|
||
// let content = video;
|
||
addMessage({
|
||
role: 'user',
|
||
type: 'video',
|
||
content: deepClone(videoList),
|
||
timestamp: new Date(),
|
||
mask: 'new',
|
||
})
|
||
|
||
videoList.forEach((item) => {
|
||
// item.video_url.poster
|
||
if (item.type == 'video_url') {
|
||
delete item.video_url.poster
|
||
}
|
||
})
|
||
videoList.push({
|
||
type: 'text',
|
||
text: msg,
|
||
})
|
||
const lastMsg = {
|
||
role: 'user',
|
||
// role: 'assistant',
|
||
content: videoList,
|
||
mask: 'new',
|
||
}
|
||
historyUserMsgs.push(lastMsg)
|
||
body = {
|
||
model: chatMode.value, // 模型选择
|
||
max_tokens: 1000,
|
||
top_p: 1,
|
||
presence_penalty: 0,
|
||
frequency_penalty: 0,
|
||
messages: deepClone(historyUserMsgs), // text ? [aiMsg] : historyUserMsgs,
|
||
stream: true,
|
||
listUuid: listUuid.value,
|
||
// listUuid:"eff18a10-1719-4528-ad63-ee5c01d0a412"
|
||
}
|
||
} else if (photoList.length <= 0 && videoList.length <= 0 && file.length > 0) {
|
||
// 仅上传了文档类文件时
|
||
chatMode.value = 'qwen-long'
|
||
aiMsg = {
|
||
role: 'assistant',
|
||
// role:"user",
|
||
type: 'text',
|
||
content: msg,
|
||
timestamp: new Date(),
|
||
}
|
||
addMessage({
|
||
role: 'user',
|
||
type: 'file',
|
||
content: deepClone(file),
|
||
mask: 'new',
|
||
timestamp: new Date(),
|
||
})
|
||
file.push({
|
||
role: 'user',
|
||
content: msg,
|
||
mask: 'new',
|
||
})
|
||
historyUserMsgs.push(...file)
|
||
body = {
|
||
model: chatMode.value, // 模型选择
|
||
// max_tokens: 1000,
|
||
// top_p: 1,
|
||
// presence_penalty: 0,
|
||
// frequency_penalty: 0,
|
||
messages: deepClone(historyUserMsgs), // text ? [aiMsg] : historyUserMsgs,
|
||
stream: true,
|
||
listUuid: listUuid.value,
|
||
// listUuid:"eff18a10-1719-4528-ad63-ee5c01d0a412"
|
||
}
|
||
}
|
||
} else {
|
||
if (!refreshSend.value) {
|
||
//不重发时触发
|
||
addMessage({
|
||
role: 'user',
|
||
type: 'text',
|
||
content: msg,
|
||
timestamp: new Date(),
|
||
mask: 'new',
|
||
})
|
||
}
|
||
// 纯文本时发送
|
||
chatMode.value = 'tongyi-app' //'tongyi-app'; qwen-long
|
||
|
||
//更新上下文消息
|
||
historyUserMsgs.push({
|
||
role: 'user',
|
||
content: msg,
|
||
timestamp: new Date(),
|
||
})
|
||
aiMsg = {
|
||
role: 'assistant',
|
||
type: 'text',
|
||
content: msg,
|
||
timestamp: new Date(),
|
||
}
|
||
|
||
body = {
|
||
model: chatMode.value, // 模型选择
|
||
max_tokens: 1000,
|
||
top_p: 1,
|
||
presence_penalty: 0,
|
||
frequency_penalty: 0,
|
||
messages: deepClone(historyUserMsgs), // text ? [aiMsg] : historyUserMsgs,
|
||
stream: true,
|
||
listUuid: listUuid.value,
|
||
// listUuid:"eff18a10-1719-4528-ad63-ee5c01d0a412"
|
||
}
|
||
}
|
||
// 第一次发送纯文本消息,第二次发送图片+视频,第三次发送文档,此时因为历史消息都要一起发送给后端,
|
||
// 所以要想办法在遇到这种情况时,截断历史记录,主动为用户建立一个新的回话,但是不需要清空历史记录
|
||
|
||
console.log('message: ', messages)
|
||
uploadList.splice(0, uploadList.length) // 清空上传的文件
|
||
const list = formatData(messages)
|
||
|
||
body.messages = list
|
||
body.detail = JSON.stringify(messages)
|
||
aiMsg.content = ''
|
||
addMessage(aiMsg)
|
||
// return
|
||
// return
|
||
// 没有上传文件,仅文字消息
|
||
try {
|
||
// aiMsg.content = ''
|
||
// 发送问题到后端
|
||
inputText.value = ''
|
||
const controller = new AbortController()
|
||
const signal = controller.signal
|
||
const resp = await fetch(baseUrl + '/chat/app/completion', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: token.value },
|
||
body: JSON.stringify(body),
|
||
signal: signal,
|
||
})
|
||
|
||
const reader = resp.body!.getReader()
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
let done = false
|
||
|
||
while (!done) {
|
||
if (stopStreamMsg) {
|
||
// 立刻停下
|
||
// reader.cancel();
|
||
controller.abort()
|
||
stopStreamMsg = false
|
||
}
|
||
const { value, done: streamDone } = await reader.read()
|
||
done = streamDone
|
||
if (value) {
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const lines = buffer.split(/\r?\n/)
|
||
buffer = lines.pop()!
|
||
|
||
for (const line of lines) {
|
||
// 只处理以 "data: " 开头的行
|
||
if (!line.startsWith('data: ')) continue
|
||
const chunk = line.slice(6).trim()
|
||
|
||
if (chunk === '[DONE]') {
|
||
done = true
|
||
console.log('sss')
|
||
break
|
||
}
|
||
try {
|
||
const json = JSON.parse(chunk)
|
||
const delta = json.choices?.[0]?.delta?.content
|
||
if (delta) {
|
||
msgLoading.value = false
|
||
aiMsg.content += delta
|
||
//每次更新messages消息,实现流式输出
|
||
messages[messages.length - 1] = { ...aiMsg }
|
||
scrollToBottom()
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
//更新上下文消息
|
||
done && historyUserMsgs.push(aiMsg)
|
||
historyUserMsgs.forEach((item) => {
|
||
if (Array.isArray(item)) {
|
||
item.forEach((ele) => {
|
||
if (ele.mask === 'new') {
|
||
ele.mask = ''
|
||
}
|
||
})
|
||
} else if (item.role === 'system') {
|
||
item.mask = ''
|
||
} else {
|
||
item.mask = ''
|
||
}
|
||
})
|
||
messages.forEach((item) => {
|
||
if (item.mask === 'new') {
|
||
item.mask = ''
|
||
}
|
||
})
|
||
console.log('chunk------------------: ')
|
||
}
|
||
}
|
||
scrollToBottom()
|
||
} catch (err) {
|
||
// aiMsg.content = '请重新发送'
|
||
// //更新messages消息
|
||
// messages[messages.length - 1] = { ...aiMsg }
|
||
console.error(err)
|
||
} finally {
|
||
sendTextLoading.value = true
|
||
showActions.value = false
|
||
refreshSend.value = false // 重发已经结束 关闭重发
|
||
msgLoading.value = false
|
||
}
|
||
}
|
||
function copyText(msg: IMessage) {
|
||
if (typeof msg.content === 'string') {
|
||
uni.setClipboardData({
|
||
data: msg.content,
|
||
success() {
|
||
uni.showToast({ title: '已复制', icon: 'success' })
|
||
},
|
||
fail(err) {
|
||
console.error('复制失败', err)
|
||
uni.showToast({ title: '复制失败', icon: 'error' })
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
function refreshText() {
|
||
if (!sendTextLoading.value) {
|
||
// 正在接收消息,不可以重发
|
||
return
|
||
}
|
||
if (uploadList.length > 0) {
|
||
return
|
||
}
|
||
// 1. 找到最后两条用户消息(用于处理图文混合场景)
|
||
const userMessages = messages.filter((msg) => msg.role === 'user')
|
||
// const lastTwoUserMsgs = userMessages.slice(-2)
|
||
const [msg1, msg2] = deepClone(userMessages.slice(-2))
|
||
// let text=lastTwoUserMsgs[0] // lastTwoUserMsgs.every((msg)=>msg.type==="text" || msg.type==="image" || msg.type==="video" || msg.type==="file")
|
||
// let file=lastTwoUserMsgs[1] // lastTwoUserMsgs.every((msg)=>msg.type==="text" || msg.type==="image" || msg.type==="video" || msg.type==="file")
|
||
|
||
// 2. 提取文本内容和文件列表
|
||
let refreshText = null
|
||
const refreshFiles: UploadFile[] = []
|
||
if (msg1 && msg1.type === 'text' && msg2 && msg2.type !== 'text') {
|
||
msg1.mask = 'new'
|
||
msg2.mask = 'new'
|
||
refreshFiles.push(msg1)
|
||
refreshFiles.push(msg2)
|
||
} else if (msg1.type === 'text' && msg1.role === 'user' && !msg2) {
|
||
msg1.mask = 'new'
|
||
refreshFiles.push(msg1)
|
||
} else if (msg2.type === 'text' && msg2.role === 'user' && !msg1) {
|
||
msg2.mask = 'new'
|
||
refreshFiles.push(msg2)
|
||
} else {
|
||
msg2.mask = 'new'
|
||
refreshFiles.push(msg2)
|
||
}
|
||
|
||
// lastTwoUserMsgs.forEach((msg,i) => {
|
||
// console.log('msg: ',msg);
|
||
// if (msg.type === 'text' && msg.role==="user") {
|
||
// refreshText = msg.content // 总是取最新的文本
|
||
// refreshFiles.push({
|
||
// content:msg.content,
|
||
// type:"text",
|
||
// role:"user",
|
||
// timestamp:new Date(),
|
||
// mask:"new"
|
||
// })
|
||
// } else if(msg.type==="video"){
|
||
// msg.mask="new"
|
||
// refreshFiles.push(msg)
|
||
// // msg.content.forEach((file : any) => {
|
||
// // console.log('lastTwoUserMsgs file: ',file);
|
||
|
||
// // refreshFiles.push({
|
||
// // id: guid.getGuid(),
|
||
// // url: file.video_url.url,
|
||
// // status: 'success',
|
||
// // name: file.name || '未命名文件',
|
||
// // size: file.size || 0,
|
||
// // uploadFileType: file.uploadFileType || detectFileType(file.video_url.url),
|
||
// // })
|
||
// // })
|
||
// }else if(msg.type==="image"){
|
||
// msg.mask="new"
|
||
// refreshFiles.push(msg)
|
||
// // msg.content.forEach((file : any) => {
|
||
// // console.log('lastTwoUserMsgs file: ',file);
|
||
// // refreshFiles.push({
|
||
// // id: guid.getGuid(),
|
||
// // url: file.image_url.url,
|
||
// // status: 'success',
|
||
// // name: file.name || '未命名文件',
|
||
// // size: file.size || 0,
|
||
// // uploadFileType: file.uploadFileType || detectFileType(file.image_url.url),
|
||
// // })
|
||
// // })
|
||
// }else{
|
||
// msg.mask="new"
|
||
// refreshFiles.push(msg)
|
||
// // msg.content.forEach((file : any) => {
|
||
// // console.log('lastTwoUserMsgs file: ',file);
|
||
// // refreshFiles.push({
|
||
// // id: guid.getGuid(),
|
||
// // url: file.content,
|
||
// // status: 'success',
|
||
// // name: file.name || '未命名文件',
|
||
// // size: file.size || 0,
|
||
// // uploadFileType: file.uploadFileType || detectFileType(file.content),
|
||
// // })
|
||
// // })
|
||
// }
|
||
// })
|
||
|
||
// 3. 更新输入框和上传列表
|
||
// inputText.value = refreshText
|
||
// uploadList.splice(0, uploadList.length, ...refreshFiles)
|
||
refreshFiles.forEach((ele) => {
|
||
messages.push(ele)
|
||
})
|
||
refreshSend.value = true
|
||
// inputText.value = refreshText
|
||
// 4. 自动触发发送(模拟用户点击发送按钮)
|
||
setTimeout(() => {
|
||
sendText()
|
||
}, 100)
|
||
}
|
||
|
||
// 文件类型检测函数(根据URL后缀)
|
||
function detectFileType(url: string) {
|
||
console.log('url: ', url)
|
||
const ext = url.split('.').pop()?.toLowerCase()
|
||
if (['jpg', 'png', 'jpeg', 'gif'].includes(ext!)) return uploadFileTypeEm.image
|
||
if (['mp4', 'mov', 'avi'].includes(ext!)) return uploadFileTypeEm.video
|
||
return uploadFileTypeEm.file
|
||
}
|
||
const knowledgeOpen = ref(false)
|
||
async function toggleKnowledge() {
|
||
if (chatMode.value !== 'tongyi-app') {
|
||
chatMode.value = 'tongyi-app'
|
||
isUserOk = true // 用户确认 tongyi-app
|
||
await createChatSession()
|
||
messages.splice(0, messages.length)
|
||
} else {
|
||
chatMode.value = 'qwen-vl-plus'
|
||
isUserOk = false // 取消确认 tongyi-app
|
||
}
|
||
knowledgeOpen.value = !knowledgeOpen.value
|
||
showActions.value = false
|
||
rotation.value = 0
|
||
}
|
||
|
||
//收起键盘
|
||
const closeKeyboard = () => {
|
||
uni.hideKeyboard()
|
||
rotation.value = 0
|
||
}
|
||
|
||
//输入框聚焦事件
|
||
const onFocus = () => {
|
||
showActions.value = false
|
||
rotation.value = 0
|
||
}
|
||
|
||
const showPreview = ref(false)
|
||
const previewUrl = ref('') // 预览图片
|
||
const previewVideoUrl = ref('') // 预览视频
|
||
const previewTop = ref(30) // 距离顶部30px
|
||
const handleImageClick = (src) => {
|
||
// let fileList = officeFileTypeList.value
|
||
// officeFileTypeList=ref(['.docx','.doc','.excel','.pdf'])
|
||
// const otherFileType=ref(['.txt'])
|
||
// const otherFileType=ref(['.txt'])
|
||
if (picFileType.value.includes(isFileType(src))) {
|
||
previewUrl.value = src
|
||
showPreview.value = true
|
||
} else if (videoFileType.value.includes(isFileType(src))) {
|
||
previewVideoUrl.value = src
|
||
showPreview.value = true
|
||
} else if (officeFileTypeList.value.includes(isFileType(src))) {
|
||
previewFile(src)
|
||
}
|
||
}
|
||
|
||
const closePreview = () => {
|
||
showPreview.value = false
|
||
previewUrl.value = ''
|
||
previewVideoUrl.value = ''
|
||
}
|
||
const reload = () => {
|
||
const currentWebview = plus.webview.currentWebview()
|
||
currentWebview.reload(true)
|
||
}
|
||
|
||
const isFileType = (name: string) => {
|
||
let reg = /\.[\w\d]+$/
|
||
let match = name.match(reg)
|
||
return match ? match[0] : null
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.slide-up-enter-active,
|
||
.slide-up-leave-active {
|
||
transition: transform 0.3s ease-out;
|
||
}
|
||
|
||
.slide-up-enter-from,
|
||
.slide-up-leave-to {
|
||
transform: translateY(100%);
|
||
}
|
||
|
||
.slide-up-enter-to,
|
||
.slide-up-leave-from {
|
||
transform: translateY(0%);
|
||
}
|
||
|
||
.font-pf {
|
||
font-family: PingFangSC-Medium, sans-serif;
|
||
}
|
||
|
||
.page {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #f8f8f8;
|
||
}
|
||
/* 顶部导航 */
|
||
.nav-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
height: 88rpx;
|
||
padding: 0 24rpx;
|
||
background: #fff;
|
||
}
|
||
|
||
.nav-title {
|
||
font-size: 32rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.nav-icon {
|
||
width: 40rpx;
|
||
height: 40rpx;
|
||
}
|
||
/* 遮罩 */
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
}
|
||
/* 弹窗容器 */
|
||
.popup {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
height: 60%;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
border-radius: 12rpx 12rpx 0 0;
|
||
}
|
||
|
||
.popup.fullscreen {
|
||
height: 90%;
|
||
border-radius: 0;
|
||
}
|
||
/* Header */
|
||
.popup-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
height: 88rpx;
|
||
padding: 0 24rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
|
||
.popup-header .icon {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
}
|
||
|
||
.popup-header .title {
|
||
flex: 1;
|
||
font-size: 32rpx;
|
||
color: #333;
|
||
text-align: center;
|
||
}
|
||
/* 内容区 */
|
||
.popup-body {
|
||
flex: 1;
|
||
padding: 24rpx 0;
|
||
}
|
||
|
||
.date-label {
|
||
padding: 0 32rpx;
|
||
margin-top: 24rpx;
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.history-item {
|
||
padding: 24rpx 32rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
|
||
.history-text {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.tops {
|
||
padding-top: var(--status-bar-height);
|
||
}
|
||
.flex-i {
|
||
display: flex !important;
|
||
}
|
||
</style>
|