AIchat/src/pages/index/index.vue

1121 lines
31 KiB
Vue
Raw Normal View History

2025-05-16 01:38:20 +00:00
<route lang="json5" type="page">
2025-05-07 06:45:14 +00:00
{
2025-05-16 01:38:20 +00:00
layout: 'default',
2025-05-07 06:45:14 +00:00
style: {
2025-05-16 01:38:20 +00:00
navigationBarHidden: true,
2025-05-07 06:45:14 +00:00
},
}
</route>
2025-05-16 01:38:20 +00:00
2025-05-07 06:45:14 +00:00
<template>
2025-05-16 01:38:20 +00:00
<div class="flex flex-col h-screen bg-gray-50">
<!-- Navigation Bar -->
<div
class="flex-none flex items-center justify-between px-5 py-3 bg-white shadow-md h-10 pt-10 z-999"
>
2025-05-16 01:38:20 +00:00
<image src="/static/aichat/back.png" class="w-2 h-4" @click="goBack" />
<div class="text-lg font-medium ml-12">小墨</div>
2025-05-16 01:38:20 +00:00
<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" />
2025-05-16 01:38:20 +00:00
<image src="/static/aichat/new.png" class="w-5 h-5" @click="newChat" />
</div>
</div>
<!-- 消息区 -->
<div
:class="[
'flex relative p-b-10',
showActions ? (uploadList.length ? 'h-118' : 'h-137') : 'h-157',
]"
>
2025-05-16 01:38:20 +00:00
<!-- 背景层 -->
<div
v-if="!messages.length"
class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none"
>
<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-gray-50"
: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 my-2">
{{ formatDayGroup(msg.timestamp) }}
</view>
<view
class="flex items-start"
:class="msg.role === 'assistant' ? 'justify-start' : 'justify-end'"
>
<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-[54%] mt-4 mb-3">
<view
:class="[
'absolute -top-4 text-xs text-gray-400 w-20 text-right',
msg.role === 'assistant' ? 'left-0' : 'right-0',
]"
>
{{ formatTimeShort(msg.timestamp) }}
</view>
<view
:class="[
'py-2 pl-2 rounded-md break-words mt-1',
msg.role === 'assistant'
? 'bg-[#f9f8fd] text-black shadow pb-1'
: 'bg-[#45299e] text-white',
]"
>
<!-- 图片消息 -->
<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"
style="flex: 0 0 2.5rem"
class="relative h-10 rounded-md overflow-hidden mr-1"
>
<view
class="absolute top-0 left-0 right-0 bottom-0 flex items-center justify-center"
:class="{ 'bg-black bg-opacity-70': showImageMask && fileIdx === 3 }"
v-if="showImageMask && fileIdx === 3"
>
+{{ msg.content.length - 3 }}
</view>
<img
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"
@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-0 flex space-x-3 ml-1 mb--5.5"
>
<image src="/static/aichat/copy.png" class="w-4 h-4" @click="copyText(msg)" />
<image
src="/static/aichat/resect.png"
class="w-4 h-4"
@click="refreshText(msg)"
/>
</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-58' : 'h-40') : 'h-20',
]"
>
<!-- 上传列表 -->
<div v-if="uploadList.length" class="flex px-4 py-2 overflow-x-auto space-x-3 bg-transparent">
2025-05-16 01:38:20 +00:00
<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>
<!-- 输入 + 切换 -->
<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-full focus:outline-none"
/>
<image
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',
}"
/>
2025-05-16 01:38:20 +00:00
<image
src="/static/aichat/enter.png"
class="w-7 h-7"
@click="sendText"
:disabled="loading"
/>
</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]"
>
<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>
2025-05-07 06:45:14 +00:00
</view>
2025-05-16 01:38:20 +00:00
</div>
2025-05-07 06:45:14 +00:00
</template>
<script lang="ts" setup>
2025-05-16 01:38:20 +00:00
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('')
2025-05-16 01:38:20 +00:00
const baseUrl = getEnvBaseUrl()
// const token = useUserStore().userInfo.token || import.meta.env.VITE_DEV_TOKEN || ''
2025-05-16 01:38:20 +00:00
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()
2025-05-16 01:38:20 +00:00
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
2025-05-16 01:38:20 +00:00
}
/** 1. 创建聊天会话 */
const listUuid = ref('')
async function createChatSession() {
try {
const createResp: any = await uni.request({
url: `${baseUrl}/chat/create`,
method: 'POST',
data: {
gptModel: 'gpt-3.5-turbo',
},
header: {
Authorization: token,
},
})
// 如果后台返回新的会话信息,可以在这里处理,比如拿到 listUuid 等
console.log('createChatSession →', createResp)
listUuid.value = createResp.data.data.listUuid
} catch (err) {
console.error('createChatSession error:', err)
}
}
2025-05-16 01:38:20 +00:00
// ------------------
// 拉取历史记录接口(替换为你自己的 API
// ------------------
/** 2. 拉取历史记录列表 */
async function fetchHistoryList() {
2025-05-16 01:38:20 +00:00
try {
const resp: any = await uni.request({
url: `${baseUrl}/chat/list`,
method: 'POST',
data: {
page: 1,
pageSize: 30,
},
2025-05-16 01:38:20 +00:00
header: {
Authorization: token,
},
})
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-3.5-turbo',
},
header: {
Authorization: token,
},
})
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 () => {
// plusready 后才能用 plus.webview
const wv = plus.webview.currentWebview()
token.value = wv.token || uni.getStorageSync('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()
}
2025-05-07 06:45:14 +00:00
// 2. 如果在 Plus 环境里,等 plusready
if (window.plus && plus.webview) {
document.addEventListener('plusready', init, false)
// plusready 可能已经触发过,直接再调用一次以防万一
if (plus.webview.currentWebview()) {
init()
2025-05-16 01:38:20 +00:00
}
}
// 3. 普通 H5 调试,直接从 storage/SystemInfo 拿
else {
token.value = uni.getStorageSync('token')
userInfo.value = uni.getStorageSync('userInfo')
refreshToken.value = uni.getStorageSync('refreshToken')
statusBarHeight.value = uni.getSystemInfoSync().statusBarHeight
createChatSession().then(fetchHistoryList)
}
2025-05-07 06:45:14 +00:00
})
2025-05-16 01:38:20 +00:00
function scrollToBottom() {
const el = scrollEl.value!
nextTick(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
})
}
//图片滚动事件
const onScroll = (e) => {
showImageMask.value = e.detail.scrollLeft <= 0
}
//查看更多图片
const previewMoreImg = (files) => {
uni.setStorageSync('previewImages', files)
uni.navigateTo({
url: `/pages/preview/index`,
})
}
//查看视频
const previewVideo = (files) => {
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()
2025-05-16 01:38:20 +00:00
}
function viewHistory() {
uni.navigateTo({ url: '/pages/history/history' })
}
async function newChat() {
2025-05-16 01:38:20 +00:00
messages.splice(0)
await createChatSession()
2025-05-16 01:38:20 +00:00
inputText.value = ''
}
const rotation = ref(0)
2025-05-16 01:38:20 +00:00
function toggleActions() {
showActions.value = !showActions.value
rotation.value += 45
2025-05-16 01:38:20 +00:00
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,
2025-05-16 01:38:20 +00:00
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'],
},
2025-05-07 06:45:14 +00:00
})
2025-05-16 01:38:20 +00:00
//监听上传文件状态更新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,
extension: 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),
})
}
// 发送消息
async function sendText(msg) {
const text = inputText.value.trim()
if (!text || 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(),
}
//清除上传列表
uploadList.splice(0, uploadList.length)
const body: IGptRequestBody = {
model: 'gpt-4-vision-preview',
max_tokens: 1000,
temperature: 1,
listUuid: listUuid.value,
2025-05-16 01:38:20 +00:00
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
messages: historyUserMsgs,
stream: true,
}
try {
aiMsg.content = ''
2025-05-16 01:38:20 +00:00
const resp = await fetch(baseUrl + '/chat/completion', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token },
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)
2025-05-16 01:38:20 +00:00
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) {
aiMsg.content += delta
scrollToBottom()
console.log('2')
}
} catch {}
}
}
}
// 添加AI消息
addMessage(aiMsg)
//更新上下文消息
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' })
},
})
2025-05-16 01:38:20 +00:00
}
}
function refreshText(msg: IMessage) {
const lastUserMsg = historyUserMsgs[historyUserMsgs.length - 1]
sendText(lastUserMsg)
}
2025-05-07 06:45:14 +00:00
</script>
2025-05-16 01:38:20 +00:00
<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;
2025-05-07 06:45:14 +00:00
}
</style>