AIchat/src/pages/index/index1.vue
2025-05-16 09:38:20 +08:00

554 lines
16 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-gray-50">
<!-- Navigation Bar -->
<div class="flex-none flex items-center justify-between px-5 py-3 bg-white shadow-md h-10">
<image src="/static/aichat/back.png" class="w-2 h-4" @click="goBack" />
<div class="text-lg font-medium">小墨</div>
<div class="flex items-center space-x-3">
<image src="/static/aichat/time.png" class="w-5 h-5" @click="viewHistory" />
<image src="/static/aichat/new.png" class="w-5 h-5" @click="newChat" />
</div>
</div>
<!-- 消息区 -->
<div :class="['flex relative', showActions ? 'h-105' : 'h-130']">
<!-- 背景层 -->
<div 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>
z
<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="assistantAvatar"
class="w-8 h-8 rounded-full mr-2 mt-1"
/>
<view class="relative max-w-[70%] mt-4 mb-3">
<view
:class="[
'absolute -top-4 text-xs text-gray-400',
msg.role === 'assistant' ? 'left-0' : 'right-0',
]"
>
{{ formatTimeShort(msg.timestamp) }}
</view>
<view
:class="[
'py-2 px-3 rounded-lg break-words mt-1',
msg.role === 'assistant'
? 'bg-[#f9f8fd] text-black shadow'
: 'bg-[#45299e] text-white',
]"
>
{{ msg.content }}
</view>
<view
v-if="msg.role === 'assistant' && msg.type === 'text'"
class="absolute bottom-0 flex space-x-3"
>
<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 ? 'h-45' : 'h-20',
]"
>
<!-- 上传列表 -->
<div v-if="uploadList.length" class="flex px-4 py-2 overflow-x-auto space-x-3 bg-white">
<div
v-for="item in uploadList"
:key="item.id"
class="relative w-16 h-16 rounded overflow-hidden"
>
<!-- 预览图,成功后用后端返回的 URL上传中可以先用本地预览 -->
<img
:src="item.url || item.localPath"
class="w-full h-full object-cover"
@click="previewImage(item.url || item.localPath)"
/>
<!-- 关闭按钮 -->
<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"
@click="removeImage(item.id)"
>
×
</div>
<!-- 进度 / 成功 / 失败 -->
<div
class="absolute bottom-0 left-0 w-full text-xs text-center text-white py-1"
:class="{
'bg-black bg-opacity-50': item.status === 'uploading',
'bg-green-600 bg-opacity-50': item.status === 'success',
'bg-red-600 bg-opacity-50': item.status === 'fail',
}"
>
<template v-if="item.status === 'uploading'">{{ item.progress }}%</template>
<template v-else-if="item.status === 'success'">✔ 成功</template>
<template v-else>
✖ 失败
<span class="cursor-pointer" @click.stop="retry(item)">↻</span>
</template>
</div>
</div>
</div>
<!-- 输入 + 切换 -->
<view class="flex items-center px-4 py-2.5 border-t border-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" />
<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 border-t border-solid border-[#E7E7E7] bg-white"
>
<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="onTakePhoto" />
<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>
</div>
1
</template>
<script lang="ts" setup>
import { ref, reactive, nextTick } from 'vue'
import dayjs from 'dayjs'
import { useUserStore } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import type { IGptRequestBody } from '@/service/index/foo'
interface IUpload {
id: number
url: string
filePath: string
status: 'uploading' | 'success' | 'fail'
progress: number
detail: string
mask: string
}
interface IMessage {
role: 'user' | 'assistant'
type: 'text' | 'images'
content: string | string[]
timestamp: Date
}
interface UploadItem {
id: string
localPath: string // 本地临时路径,用于预览
url: string // 后端返回的在线 URL
status: 'uploading' | 'success' | 'fail'
progress: number // 上传进度 %
}
const assistantAvatar =
'https://dci-file-new.bj.bcebos.com/fonchain-main/test/runtime/image/avatar/40/b8ed6fea-6662-416d-8bb3-1fd8a8197061.jpg'
const userAvatar = assistantAvatar
const baseUrl = getEnvBaseUrl()
const token = useUserStore().userInfo.token || import.meta.env.VITE_DEV_TOKEN || ''
const messages = reactive<IMessage[]>([])
const inputText = ref('')
const loading = ref(false)
const showActions = ref(false)
const scrollEl = ref<HTMLElement>()
const uploadList = reactive<IUpload[]>([])
const uploadId = ref(0)
function scrollToBottom() {
const el = scrollEl.value!
nextTick(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
})
}
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() {
window.history.back()
}
function viewHistory() {
uni.navigateTo({ url: '/pages/history/history' })
}
function newChat() {
messages.splice(0)
inputText.value = ''
}
function toggleActions() {
showActions.value = !showActions.value
scrollToBottom()
}
// 相册
function onPickImage() {
uni.chooseImage({
count: 10,
success: (res: any) => {
res.tempFilePaths.forEach((path) => {
uploadId.value += 1
const id = uploadId.value
const upload: IUpload = { id, url: path, filePath: path, status: 'uploading', progress: 0 }
uploadList.push(upload)
uploadFile(path, upload)
})
},
})
}
// 拍照
function onTakePhoto() {
uni.chooseImage({
sourceType: ['camera'],
count: 1,
success: (res: any) => {
const path = res.tempFilePaths[0]
uploadId.value += 1
const id = uploadId.value
const upload: IUpload = { id, url: path, filePath: path, status: 'uploading', progress: 0 }
uploadList.push(upload)
uploadFile(path, upload)
},
})
}
// 文件
// 触发文件选择后,或拍照后,或其它入口都调用这个
function onPickFile(path: string, detail = '') {
const id = Date.now().toString()
const item: UploadItem = {
id,
localPath: path,
url: '',
status: 'uploading',
progress: 0,
}
uploadList.push(item)
startUpload(item, detail)
}
interface UploadFile {
uid: string
name: string
size: number
progress: number // 上传进度 0-100
status: 'uploading' | 'success' | 'error'
file: File
url?: string // 本地预览URL或服务器返回的URL
}
const filesList = ref<UploadFile[]>([])
async function startUpload(item: UploadItem, detail: string) {
const userStore = useUserStore()
// 标记开始上传
item.status = 'uploading'
item.progress = 0
// 发起上传 (不带 success/fail 回调)
// @ts-ignore: uni.uploadFile 返回 UploadTask 兼具 Promise 接口
const uploadTask: UniApp.UploadTask & Promise<UniApp.UploadFileRes> = uni.uploadFile({
// url: 'http://114.218.158.24:9020/upload/multi',
url: 'https://ukw0y1.laf.run/upload',
filePath: item.localPath,
name: 'k1', // 这里的 name 依然是 formData 里的字段名,后端会以此为 key
formData: {
source: 'chat',
mask: '2076',
detail,
type: 'image',
},
})
// 监听进度
uploadTask.onProgressUpdate((res: { progress: number }) => {
item.progress = res.progress
})
// 3s 超时自动 abort
const timeoutId = setTimeout(() => {
if (item.status === 'uploading') {
uploadTask.abort()
item.status = 'fail'
}
}, 3000)
try {
// 等待上传完成
const res = await uploadTask
clearTimeout(timeoutId)
// 解析后端 JSON
const resp = JSON.parse(res.data) as {
code: number
data: Record<string, string>
}
console.log(resp, 'resp')
if (resp.code === 0 && resp.data) {
// 遍历 data 对象,找第一个非空值
const urls = Object.values(resp.data).filter((u) => !!u)
if (urls.length > 0) {
item.url = urls[0]
item.status = 'success'
} else {
console.warn('没有拿到任何上传后的 URL')
item.status = 'fail'
}
} else {
console.warn('后端返回异常 code=', resp.code)
item.status = 'fail'
}
} catch (err) {
clearTimeout(timeoutId)
console.error('uploadFile 出错:', err)
item.status = 'fail'
}
}
// 点击重试
function retry(item: UploadItem) {
item.status = 'uploading'
item.progress = 0
startUpload(item, '') // 如果需要 detail可缓存后传入
}
// 删除
function removeImage(id: string) {
const idx = uploadList.findIndex((i) => i.id === id)
if (idx >= 0) uploadList.splice(idx, 1)
}
// 预览(可自行实现 uni.previewImage 等)
function previewImage(url: string) {
uni.previewImage({ urls: [url] })
}
// 上传使用 postUpload
async function uploadFile(path: string, upload: IUpload) {
// 标记开始
upload.status = 'uploading'
try {
// @ts-ignore uni.uploadFile 返回 Promise
const res: UniApp.UploadFileRes = await uni.uploadFile({
// url: 'http://114.218.158.24:9020/upload/multi',
url: 'https://ukw0y1.laf.run/upload',
filePath: path,
name: 'file', // 这个 name 依然是 formData key后端会把它用在 data.data 对象里
formData: {
source: 'chat',
mask: '2076',
type: 'image',
k1: 'xxxx.png',
k2: 'xxxx.png',
},
})
// 解析后端 JSON
const resp = JSON.parse(res.data) as {
code: number
data: Record<string, string>
}
if (resp.code === 0 && resp.data) {
// 找到 data 对象里第一个有值的字段并赋给 upload.url
let found = false
for (const key in resp.data) {
const url = resp.data[key]
if (url) {
upload.url = url
found = true
break
}
}
if (found) {
upload.status = 'success'
} else {
console.warn('没有取到任何上传后的 URL')
upload.status = 'fail'
}
} else {
console.warn('后端返回异常 code=', resp.code)
upload.status = 'fail'
}
} catch (err) {
console.error('uploadFile 出错:', err)
upload.status = 'fail'
}
}
async function sendText() {
const text = inputText.value.trim()
if (!text || loading.value) return
addMessage({ role: 'user', type: 'text', content: text, timestamp: new Date() })
inputText.value = ''
loading.value = true
const aiMsg: IMessage = {
role: 'assistant',
type: 'text',
content: '',
timestamp: new Date(),
}
addMessage(aiMsg)
const body: IGptRequestBody = {
model: 'gpt-4-vision-preview',
max_tokens: 1000,
temperature: 1,
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
messages: [{ role: 'user', content: [{ type: 'text', text }] }],
stream: true,
}
try {
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')
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 {}
}
}
}
scrollToBottom()
} catch (err) {
console.error(err)
} finally {
loading.value = false
showActions.value = false
}
}
function copyText(msg: IMessage) {
if (typeof msg.content === 'string') {
navigator.clipboard.writeText(msg.content)
alert('已复制')
}
}
function refreshText(msg: IMessage) {
if (typeof msg.content === 'string') {
inputText.value = msg.content
const idx = messages.indexOf(msg)
messages.splice(idx, 1)
sendText()
}
}
</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;
}
</style>