306 lines
6.8 KiB
Vue
306 lines
6.8 KiB
Vue
<template>
|
|
<div class="chat-page">
|
|
<div ref="scrollRef" class="chat-messages" @scroll="onScroll">
|
|
<div v-if="loadingHistory" class="loading-history">
|
|
<van-loading size="16px">加载中...</van-loading>
|
|
</div>
|
|
<template v-for="msg in messages" :key="msg.id">
|
|
<div v-if="shouldShowTime(msg.timestamp)" class="message-time">
|
|
{{ formatTime(msg.timestamp) }}
|
|
</div>
|
|
<ChatBubble
|
|
:message="msg"
|
|
@retry="onRetry"
|
|
@delete="onDelete(msg)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<div class="clear-float" @click="onMoreAction">
|
|
<van-icon name="delete-o" />
|
|
</div>
|
|
<div class="chat-input-area">
|
|
<div class="input-row">
|
|
<van-field
|
|
v-model="inputValue"
|
|
type="textarea"
|
|
autosize
|
|
placeholder="请输入消息..."
|
|
maxlength="500"
|
|
show-word-limit
|
|
class="message-input"
|
|
@keydown.enter.exact.prevent="onSend"
|
|
/>
|
|
<van-button
|
|
type="primary"
|
|
size="small"
|
|
:loading="sending"
|
|
:disabled="!inputValue.trim() || isThinking"
|
|
@click="onSend"
|
|
>
|
|
发送
|
|
</van-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, nextTick, watch, onMounted, onUnmounted } from "vue"
|
|
import { useRouter } from "vue-router"
|
|
import { showToast, showFailToast, showConfirmDialog } from "vant"
|
|
import ChatBubble from "./components/ChatBubble.vue"
|
|
import type { ChatMessage } from "@/common/types/chat"
|
|
import { chatStorage, intro } from "@/common/utils/storage"
|
|
import { streamApi } from "@/common/apis/agent"
|
|
|
|
const router = useRouter()
|
|
const abortController = ref<AbortController>()
|
|
|
|
const agentName = ref("智能助手")
|
|
const inputValue = ref("")
|
|
const messages = ref<ChatMessage[]>([])
|
|
const isThinking = ref(false)
|
|
const sending = ref(false)
|
|
const loadingHistory = ref(false)
|
|
const scrollRef = ref<HTMLElement>()
|
|
const currentAiMsgId = ref<string>("")
|
|
|
|
onMounted(() => {
|
|
messages.value = chatStorage.load()
|
|
|
|
// 新对话时直接输出intro
|
|
if (messages.value.length === 0) {
|
|
const aiMsg: ChatMessage = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: "assistant",
|
|
content: intro,
|
|
timestamp: Date.now(),
|
|
status: "sent"
|
|
}
|
|
messages.value.push(aiMsg)
|
|
}
|
|
|
|
nextTick(() => {
|
|
scrollToBottom()
|
|
})
|
|
})
|
|
|
|
watch(messages, () => {
|
|
chatStorage.save(messages.value)
|
|
}, { deep: true })
|
|
|
|
const loadHistory = async () => {
|
|
if (loadingHistory.value) return
|
|
loadingHistory.value = true
|
|
try {
|
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
}
|
|
finally {
|
|
loadingHistory.value = false
|
|
}
|
|
}
|
|
|
|
const onSend = async () => {
|
|
const content = inputValue.value.trim()
|
|
if (!content || isThinking.value) return
|
|
|
|
const userMsg: ChatMessage = {
|
|
id: Date.now().toString(),
|
|
role: "user",
|
|
content,
|
|
timestamp: Date.now(),
|
|
status: "sent"
|
|
}
|
|
|
|
messages.value.push(userMsg)
|
|
inputValue.value = ""
|
|
sending.value = true
|
|
await scrollToBottom()
|
|
|
|
isThinking.value = true
|
|
sending.value = false
|
|
|
|
// 创建AI消息占位
|
|
const aiMsgId = (Date.now() + 1).toString()
|
|
currentAiMsgId.value = aiMsgId
|
|
const aiMsg: ChatMessage = {
|
|
id: aiMsgId,
|
|
role: "assistant",
|
|
content: "",
|
|
timestamp: Date.now(),
|
|
status: "pending"
|
|
}
|
|
messages.value.push(aiMsg)
|
|
|
|
// 创建 AbortController
|
|
abortController.value = new AbortController()
|
|
|
|
const thread = chatStorage.getThread()
|
|
const resource = chatStorage.getResource()
|
|
|
|
streamApi(
|
|
content,
|
|
{
|
|
onmessage(data) {
|
|
try {
|
|
const parsed = JSON.parse(data)
|
|
if (parsed.text) {
|
|
const msg = messages.value.find(m => m.id === aiMsgId)
|
|
if (msg) {
|
|
msg.status = "sent"
|
|
msg.content += parsed.text
|
|
scrollToBottom()
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.warn("解析SSE消息失败:", e)
|
|
}
|
|
},
|
|
onclose() {
|
|
const msg = messages.value.find(m => m.id === aiMsgId)
|
|
if (msg) {
|
|
msg.status = "sent"
|
|
}
|
|
isThinking.value = false
|
|
abortController.value = undefined
|
|
},
|
|
onerror(error) {
|
|
const msg = messages.value.find(m => m.id === aiMsgId)
|
|
if (msg) {
|
|
msg.status = "error"
|
|
}
|
|
showFailToast("连接失败,请重试")
|
|
isThinking.value = false
|
|
abortController.value = undefined
|
|
}
|
|
},
|
|
{ signal: abortController.value.signal, thread, resource }
|
|
)
|
|
}
|
|
|
|
const scrollToBottom = async () => {
|
|
await nextTick()
|
|
if (scrollRef.value) {
|
|
scrollRef.value.scrollTop = scrollRef.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
const onScroll = (e: Event) => {
|
|
const target = e.target as HTMLElement
|
|
if (target.scrollTop < 50 && !loadingHistory.value) {
|
|
loadHistory()
|
|
}
|
|
}
|
|
|
|
const shouldShowTime = (timestamp: number) => {
|
|
if (messages.value.length === 0) return true
|
|
const lastMsg = messages.value[messages.value.length - 1]
|
|
return timestamp - lastMsg.timestamp > 300000
|
|
}
|
|
|
|
const formatTime = (timestamp: number) => {
|
|
const date = new Date(timestamp)
|
|
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`
|
|
}
|
|
|
|
const onMoreAction = () => {
|
|
showConfirmDialog({
|
|
message: "确定清空对话历史?",
|
|
confirmButtonColor: "#f44"
|
|
}).then(() => {
|
|
messages.value = []
|
|
chatStorage.clear()
|
|
chatStorage.clearThread()
|
|
})
|
|
}
|
|
|
|
const onBack = () => {
|
|
router.back()
|
|
}
|
|
|
|
const onRetry = () => {
|
|
const lastUserMsg = messages.value.filter(m => m.role === "user").pop()
|
|
if (lastUserMsg) {
|
|
inputValue.value = lastUserMsg.content
|
|
onSend()
|
|
}
|
|
}
|
|
|
|
// 页面销毁时取消请求
|
|
onUnmounted(() => {
|
|
abortController.value?.abort()
|
|
})
|
|
|
|
const onDelete = (msg: ChatMessage) => {
|
|
const index = messages.value.findIndex(m => m.id === msg.id)
|
|
if (index > -1) {
|
|
messages.value.splice(index, 1)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.chat-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100vh - 50px);
|
|
background: #f7f8fa;
|
|
}
|
|
|
|
.chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
}
|
|
|
|
.message-time {
|
|
text-align: center;
|
|
color: #999;
|
|
font-size: 12px;
|
|
margin: 16px 0;
|
|
}
|
|
|
|
.loading-history {
|
|
text-align: center;
|
|
padding: 16px;
|
|
color: #999;
|
|
}
|
|
|
|
.chat-input-area {
|
|
background: #fff;
|
|
border-top: 1px solid #eee;
|
|
}
|
|
|
|
.input-row {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
padding: 12px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.message-input {
|
|
flex: 1;
|
|
background: #f7f8fa;
|
|
border-radius: 20px;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.clear-float {
|
|
position: fixed;
|
|
right: 20px;
|
|
bottom: 290px;
|
|
width: 40px;
|
|
height: 40px;
|
|
background: #fff;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
color: #666;
|
|
font-size: 20px;
|
|
z-index: 100;
|
|
}
|
|
</style>
|