AIchat/src/pages/index/index.vue
2025-05-21 19:54:21 +08:00

1424 lines
41 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" @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 h-135']">
<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 === uploadFileTypeEm.image"
@scroll="onScroll"
>
<view class="flex pr-1" @click="previewMoreImg(msg.content)">
<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 >= 4,
}"
>
<view
v-if="fileIdx >= 3"
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70 z-80"
>
+ {{ msg.content.length - 4 }}
</view>
<image
v-if="file.uploadFileType === uploadFileTypeEm.image"
:src="file.url || file.tempFilePath"
class="w-full h-full object-cover"
/>
</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.url)"
>
<view>{{ file.name }}</view>
<view>{{ 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-12 px-2 py-2 rounded-md overflow-hidden mr-1 bg-white c-black"
@click="previewVideo(msg.content)"
>
<img
v-if="file.uploadFileType === uploadFileTypeEm.image"
:src="file.url || file.tempFilePath"
class="w-full h-full object-cover"
/>
</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"
/>
</view>
</template>
</div>
</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-16 h-16 rounded overflow-hidden flex-shrink-0"
>
<!-- 预览图,成功后用后端返回的 URL上传中可以先用本地预览 -->
<img
v-if="item.uploadFileType !== uploadFileTypeEm.file"
:src="item.url || item.tempFilePath"
class="w-full h-full object-cover"
@click="previewImage(item.url)"
/>
<view v-else class="text-xs text-gray-400 mt-1" @click="previewFile(item.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>
</div>
<!-- 输入 + 切换 -->
<view class="flex items-center px-4 py-2.5 border-t border-t-solid border-[#E7E7E7]">
<input
v-model="inputText"
@focus="onFocus"
@keyup.enter="sendText('')"
placeholder="想对我说点什么~"
class="flex-1 h-10 px-3 border border-gray-100 bg-[#f9f9f9] rounded-1 focus:outline-none"
/>
<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
src="/static/aichat/enter.png"
class="w-7 h-7"
@click="sendText('')"
:disabled="loading"
: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">
<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>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, nextTick, watchEffect } 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 'dayjs/locale/zh-cn'
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'
}
interface IMessage {
role: 'user' | 'assistant'
type: 'text' | 'image' | 'video' | 'file'
content: string | any[]
timestamp: Date
}
const userAvatar = ref('')
const chatMode = ref('qwen-vl-plus')
const baseUrl = getEnvBaseUrl()
const messages = reactive<IMessage[]>([])
//获取用户上下文
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 = 6
// ------------------
// 控制弹窗显隐 & 全屏/底部模式
// ------------------
const showPopup = ref(false)
const fullscreen = ref(false)
async function openPopup() {
await fetchHistoryList()
showPopup.value = true
}
function closePopup() {
showPopup.value = false
}
function toggleFullscreen() {
fullscreen.value = !fullscreen.value
}
// ------------------
// 历史记录数据 & 分组逻辑
// ------------------
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/create`,
method: 'POST',
data: {
gptModel: chatMode.value,
},
header: {
Authorization: token.value,
},
})
// 如果后台返回新的会话信息,可以在这里处理,比如拿到 listUuid 等
listUuid.value = createResp.data.data.listUuid
} catch (err) {}
}
// ------------------
// 拉取历史记录接口(替换为你自己的 API
// ------------------
/** 2. 拉取历史记录列表 */
async function fetchHistoryList() {
try {
const resp: any = await uni.request({
url: `${baseUrl}/chat/list`,
method: 'POST',
data: {
page: 1,
pageSize: 9990,
},
header: {
Authorization: token.value,
},
})
if (resp.data && resp.data.data) {
rawList.value = resp.data.data.data
console.log('fetchHistoryList →', rawList.value)
}
} catch (err) {}
}
/** 3. 拉取历史记录详情 */
async function fetchHistoryDiets(value) {
try {
const resp: any = await uni.request({
url: `${baseUrl}/chat/detail`,
method: 'POST',
data: {
listUuid: value,
gptModel: chatMode.value,
},
header: {
Authorization: token.value,
},
})
if (resp && resp.data) {
const rawList = resp.data.data // 假设后端直接返回消息数组
listUuid.value = resp.data.data[0].listUuid
const newMessages = parseBackendMessages(rawList)
// 用解析后的消息替换当前消息列表
messages.splice(0, messages.length, ...newMessages)
}
} catch (err) {}
}
function parseBackendMessages(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>(
'79b5c732d96d2b27a48a99dfd4a5566c43aaa5796242e854ebe3ffc198d6876b9628e7b764d9af65ab5dbb2d517ced88170491b74b048c0ba827c0d3741462cb89dc59ed46653a449af837a8262941ca1430937103230a1e32a1715f569f3efdbe6f8cb8b7b8642bd679668081b9b08f693d1b5be6002d936ec51e1e3e0c4927de9e32ac99a109b326e5d2bda27ec87624bb416ec70d2a95a2e190feeba9f0d6bae8571b3dfe89c824712344759a8f2bff9d70747c52525cf6a5614f9c770bca461a9b9c247b6dca97bcf83bbaf99bb726752c4fe1e9a4aa7de5c4cf3e88a3e480801280d45cdc124f9d8221105d852945dc6ce10bc1647e4f09dff4d52ffdfc878a18b3738809e20de39acfd5430450f2bc3741057dd4ce71ccf64ea02f6a91fd001fa7bde90187008f19e848c70a002c37df28be05b4790e962f001a1361e90f1423dfc5b018ca9fac85ad2fafaef6',
)
const userInfo = ref<any>({})
const refreshToken = ref<string>('')
const statusBarHeight = ref<number>(0)
const mask = ref('')
// ---- 页面初始化 ----
onMounted(() => {
// 1. 定义一个 init 函数,拿 Extras 并依次调用接口
const init = async () => {
const wv = plus.webview.currentWebview()
// 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()
await fetchHistoryList()
}
init()
})
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: IMessage) {
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: 300 * 1024 * 1024, // 文件最大300MB
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 = uploadQueue.filter((file) => file.status === 'pending')
// 限制最大并行上传数
const filesToUpload = pendingFiles.slice(0, MAX_CONCURRENT_UPLOADS - uploadingCount.value)
filesToUpload.forEach((file) => {
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
} else {
file.status = 'error'
}
},
fail: (err) => {
file.status = 'error'
},
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
}
})
})
}
// 照片
const onPickImage = () => {
loading.value = true
uni.chooseImage({
count: 10, // 最多选择9张
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
extension: uploadConfig.image.supportType,
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 onPickFile = () => {
uni.chooseFile({
count: 10,
type: uploadConfig.file.supportType,
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
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 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)
// 发送消息
async function sendText(msgData) {
msgLoading.value = true
const text = inputText.value.trim()
const dataBlo = toRaw(msgData)
console.log(dataBlo)
if (!text && dataBlo == '') {
msgLoading.value = false
uni.showToast({ title: '请输入信息', icon: 'error' })
return
}
if (loading.value) return
//获取本次发送的消息和文件
const tempUploadList = Object.assign([], uploadList)
let fileList: any[] = []
if (tempUploadList.length > 0) {
fileList = tempUploadList.map((item: UploadFile) => {
const fileType = `${item.uploadFileType}_url`
return {
type: fileType,
[fileType]: item.url,
}
})
}
// 添加用户文本消息
addMessage(
msgData || {
role: 'user',
type: 'text',
content: text,
timestamp: new Date(),
},
)
//图片、视频、文件分开发送
if (tempUploadList.length > 0) {
Object.values(uploadFileTypeEm).forEach((item: any) => {
if (tempUploadList.find((v: UploadFile) => v.uploadFileType === item)) {
addMessage({
role: 'user',
type: item,
content: tempUploadList.filter((v) => v.uploadFileType === item),
timestamp: new Date(),
})
}
})
//更新上下文消息
historyUserMsgs.push({
role: 'user',
content: [{ type: 'text', text }, ...fileList],
timestamp: new Date(),
})
//显示更多图片遮罩
const showMoreImgMask =
tempUploadList.filter((v: UploadFile) => v.uploadFileType === uploadFileTypeEm.image).length >
4
showImageMask.value = showMoreImgMask
} else {
//更新上下文消息
!msgData &&
historyUserMsgs.push({
role: 'user',
content: text,
timestamp: new Date(),
})
}
inputText.value = ''
loading.value = true
// AI消息
const aiMsg: IMessage = {
role: 'assistant',
type: 'text',
content: text,
timestamp: new Date(),
}
addMessage(aiMsg)
//清除上传列表
uploadList.splice(0, uploadList.length)
const body: IGptRequestBody = {
model: chatMode.value,
max_tokens: 1000,
temperature: 1,
listUuid: listUuid.value,
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
messages: msgData ? [msgData] : historyUserMsgs,
stream: true,
}
try {
aiMsg.content = ''
const resp = await fetch(baseUrl + '/chat/completion', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token.value },
body: JSON.stringify(body),
})
const reader = resp.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let done = false
while (!done) {
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()
console.log('2')
}
} catch {}
}
//更新上下文消息
done && historyUserMsgs.push(aiMsg)
}
}
scrollToBottom()
} catch (err) {
aiMsg.content = '请重新发送'
//更新messages消息
messages[messages.length - 1] = { ...aiMsg }
console.error(err)
} finally {
loading.value = false
showActions.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() {
// 获取最后一条用户消息(保留在消息列表中的)
const lastUserMsg = messages.findLast((msg) => msg.role === 'user')
if (!lastUserMsg) return
// 克隆消息避免污染原始数据
const clonedMsg = {
...lastUserMsg,
timestamp: new Date(),
}
// 发送克隆后的消息
sendText(clonedMsg)
// // 获取最后一条用户消息(保留在消息列表中的)
// const lastUserMsg = messages.findLast((msg) => msg.role === 'user')
// if (!lastUserMsg) return
// // 克隆消息避免污染原始数据
// const newUserMsg = {
// ...lastUserMsg,
// timestamp: new Date(),
// }
// const historyToSend = [...messages, newUserMsg]
// // 发送克隆后的消息
// sendText(historyToSend)
}
const knowledgeOpen = ref(false)
function toggleKnowledge() {
console.error('44444', chatMode.value)
if (chatMode.value == 'qwen-vl-plus') {
chatMode.value = 'tongyi-app'
} else {
chatMode.value = 'qwen-vl-plus'
}
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
}
</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>