AIchat/src/pages/index/index.vue

2201 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 分支(单条,直接塞 contenttype 也补成 '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>