AIchat/src/pages/index/index.vue
2025-05-20 16:57:33 +08:00

1195 lines
34 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">
<!-- 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 ',
]"
>
<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"
:key="fileIdx"
class="relative rounded-md overflow-hidden mr-1"
: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,
' h-15 flex-grow-0 flex-shrink-0 basis-10': msg.content.length >= 4,
}"
>
<view
v-if="showImageMask && fileIdx === 4"
class="absolute top-0 left-0 right-0 bottom-0 flex items-center justify-center"
:class="{ 'bg-black bg-opacity-70': showImageMask && fileIdx === 4 }"
>
+{{ msg.content.length - 4 }}
</view>
<image
v-if="file.uploadFileType === uploadFileTypeEm.image"
:src="file.url || file.tempFilePath"
:class="{
'w-100% h-100%': msg.content.length == 1,
'w-15 h-15': msg.content.length == 2,
'w-40 h-15 ': msg.content.length == 3,
'w-40 h-15 object-cover': msg.content.length >= 4,
}"
/>
</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"
@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-60' : 'h-40') : 'h-20',
]"
>
<!-- 上传列表 -->
<div v-if="uploadList.length" class="flex px-4 py-2 overflow-x-auto space-x-3 bg-transparent">
<div
v-for="item in uploadList"
:key="item.id"
style="flex: 0 0 4rem"
class="relative w-16 h-16 rounded overflow-hidden"
>
<!-- 预览图,成功后用后端返回的 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
@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"
@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-20 bg-white border-t border-t-solid border-[#E7E7E7]',
showActions ? (uploadList.length ? 'pt-1' : 'pt-1') : '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 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
}
/** 1. 创建聊天会话 */
const listUuid = ref('')
async function createChatSession() {
try {
const createResp: any = await uni.request({
url: `${baseUrl}/chat/create`,
method: 'POST',
data: {
gptModel: 'gpt-4-vision-preview',
},
header: {
Authorization: token.value,
},
})
// 如果后台返回新的会话信息,可以在这里处理,比如拿到 listUuid 等
console.log('createChatSession →', createResp)
listUuid.value = createResp.data.data.listUuid
} catch (err) {
console.error('createChatSession error:', err)
}
}
// ------------------
// 拉取历史记录接口(替换为你自己的 API
// ------------------
/** 2. 拉取历史记录列表 */
async function fetchHistoryList() {
try {
const resp: any = await uni.request({
url: `${baseUrl}/chat/list`,
method: 'POST',
data: {
page: 1,
pageSize: 30,
},
header: {
Authorization: token.value,
},
})
if (resp.data && resp.data.data) {
rawList.value = resp.data.data.data
console.log('fetchHistoryList →', rawList.value)
}
} catch (err) {
console.error('fetchHistoryList error:', err)
}
}
/** 3. 拉取历史记录详情 */
async function fetchHistoryDiets(value) {
try {
const resp: any = await uni.request({
url: `${baseUrl}/chat/detail`,
method: 'POST',
data: {
listUuid: value,
gptModel: 'gpt-4-vision-preview',
},
header: {
Authorization: token.value,
},
})
if (resp.data) {
rawList.value = resp.data
console.log('fetchHistoryLisssst →', rawList.value)
}
} catch (err) {
console.error('fetchHistoryList error:', err)
}
}
const token = ref<string>('')
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()
// // 2. 如果在 Plus 环境里,等 plusready
// if (window.plus && plus.webview) {
// document.addEventListener('plusready', init, false)
// // plusready 可能已经触发过,直接再调用一次以防万一
// if (plus.webview.currentWebview()) {
// init()
// }
// }
// // 3. 普通 H5 调试,直接从 storage/SystemInfo 拿
// else {
// token.value = uni.getStorageSync('token') || import.meta.env.VITE_DEV_TOKEN
// userInfo.value = uni.getStorageSync('userInfo')
// refreshToken.value = uni.getStorageSync('refreshToken')
// statusBarHeight.value = uni.getSystemInfoSync().statusBarHeight
// createChatSession().then(fetchHistoryList)
// }
})
function scrollToBottom() {
const el = scrollEl.value!
nextTick(() => {
// el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
console.log(el.scrollHeight, 'el.scrollHeight')
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() {
showActions.value = !showActions.value
rotation.value += 45
scrollToBottom()
}
// 上传文件formData类型
const uploadFileTypeEm = {
image: 'image',
video: 'video',
file: 'file',
}
// 上传配置
const uploadConfig = reactive({
url: 'http://114.218.158.24:9020/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] })
}
// 预览文件
const previewFile = (url: string) => {
//跳转到webview打开
uni.navigateTo({
url: '/pages/webview/index?link=' + encodeURIComponent(url),
})
}
const msgLoading = ref(true)
// 发送消息
async function sendText(msgData) {
msgLoading.value = true
const text = inputText.value.trim()
if (!text) {
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({
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 {
//更新上下文消息
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)
// resds.value = historyUserMsgs
const body: IGptRequestBody = {
model: 'gpt-4-vision-preview',
max_tokens: 1000,
temperature: 1,
listUuid: listUuid.value,
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
messages: 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 parts = buffer.split('data: ')
buffer = parts.pop()!
for (const part of parts) {
scrollToBottom()
console.log('1', aiMsg.content)
const chunk = part.trim()
if (chunk === '[DONE]') {
done = true
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 {}
}
}
}
//更新上下文消息
historyUserMsgs.push(aiMsg)
scrollToBottom()
} catch (err) {
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 = historyUserMsgs[historyUserMsgs.length - 2]
// resds.value = lastUserMsg
sendText(lastUserMsg)
}
const knowledgeOpen = ref(false)
function toggleKnowledge() {
knowledgeOpen.value = !knowledgeOpen.value
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: 100%;
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;
}
</style>