Files
sbnews/uniapp/src/pages/ai_assistant/ai_assistant.vue
T
2026-06-12 17:34:00 +08:00

941 lines
25 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.
<template>
<view class="assistant-page">
<view class="assistant-nav" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="assistant-nav__btn" @tap="goBack">
<u-icon name="arrow-left" size="34" color="#1f2937" />
</view>
<text class="assistant-nav__title">世博头条 AI 助手</text>
<view class="assistant-nav__actions">
<view class="assistant-nav__btn" @tap="toggleHistory">
<u-icon name="clock" size="32" color="#1f2937" />
</view>
<view class="assistant-nav__btn" @tap="newSession">
<u-icon name="plus" size="32" color="#1f2937" />
</view>
</view>
</view>
<view v-if="showHistory" class="history-panel">
<view class="history-panel__header">
<text>历史会话</text>
<text class="history-panel__clear" @tap="clearAllSessions">清空</text>
</view>
<scroll-view scroll-y class="history-panel__list">
<view v-if="!sessions.length" class="history-panel__empty">暂无历史会话</view>
<view v-for="item in sessions" :key="item.id" class="history-item" @tap="openSession(item)">
<text class="history-item__title">{{ item.title || '新的对话' }}</text>
<text class="history-item__desc">{{ item.last_message || item.last_answer }}</text>
</view>
</scroll-view>
</view>
<scroll-view
scroll-y
class="message-list"
:scroll-into-view="scrollIntoView"
scroll-with-animation
>
<view class="welcome">
<view class="welcome__badge">AI</view>
<view class="welcome__body">
<text class="welcome__title">你好我可以帮你查站内内容</text>
<text class="welcome__desc">可以问资讯赛事社区帖子彩票分析和加密行情</text>
</view>
</view>
<view class="quick-list">
<view v-for="item in quickQuestions" :key="item" class="quick-list__item" @tap="sendQuick(item)">
<text class="quick-list__text">{{ item }}</text>
</view>
</view>
<view
v-for="(item, index) in messages"
:id="'msg-' + index"
:key="item.id || index"
class="message-row"
:class="item.role === 'user' ? 'message-row--user' : 'message-row--assistant'"
>
<view v-if="item.role !== 'user'" class="message-row__avatar">AI</view>
<view class="message-row__body">
<view class="message-bubble">
<view v-if="item.pending" class="message-bubble__pending">
<view class="message-bubble__pulse" />
<text class="message-bubble__loading">正在整理站内资料...</text>
<text class="message-bubble__timer">分析用时 {{ analysisDurationText }}</text>
</view>
<text v-else-if="item.role === 'user'" class="message-bubble__text">{{ item.content }}</text>
<view v-else class="message-bubble__markdown">
<u-parse :html="renderAssistantMarkdown(item.content)" />
</view>
<view v-if="item.sources && item.sources.length" class="source-list">
<view v-for="source in item.sources" :key="source.type + source.source_id + source.title"
class="source-item" @tap="openSource(source)">
<view class="source-item__meta">{{ sourceTypeName(source.type) }}</view>
<text class="source-item__title">{{ source.title }}</text>
<text class="source-item__summary">{{ source.summary }}</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="input-panel">
<view class="input-bar">
<input
v-model="inputText"
class="input-bar__input"
type="text"
maxlength="500"
:cursor-spacing="18"
:disabled="sending"
placeholder="问问世博头条里的内容"
confirm-type="send"
@confirm="sendMessage"
/>
<view class="input-bar__send" :class="{ 'input-bar__send--disabled': !canSend }" @tap="sendMessage">
<u-icon name="arrow-upward" size="32" color="#fff" />
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
assistantChat,
assistantClear,
assistantMessages,
assistantSessions,
type AssistantMessage,
type AssistantSession,
type AssistantSource
} from '@/api/assistant'
import { renderMarkdownToHtml } from '@/utils/assistant-markdown'
const STORAGE_CLIENT_ID = 'sbnews_assistant_client_id'
const statusBarHeight = ref(0)
const clientId = ref('')
const sessionId = ref(0)
const inputText = ref('')
const sending = ref(false)
const showHistory = ref(false)
const scrollIntoView = ref('')
const messages = ref<AssistantMessage[]>([])
const sessions = ref<AssistantSession[]>([])
const analysisElapsedSeconds = ref(0)
let analysisTimer: ReturnType<typeof setInterval> | null = null
const quickQuestions = [
'今天有什么值得关注的赛事?',
'帮我总结最新世界杯资讯',
'旧澳六合最近有什么分析线索?',
'BTC 今天行情怎么样?'
]
const canSend = computed(() => inputText.value.trim().length > 0 && !sending.value)
const analysisDurationText = computed(() => {
const total = Math.max(0, Number(analysisElapsedSeconds.value || 0))
const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${minutes}m${seconds}s`
})
onLoad(async () => {
const sysInfo = uni.getSystemInfoSync()
// #ifdef APP-PLUS || MP
statusBarHeight.value = sysInfo.statusBarHeight || 44
// #endif
// #ifdef H5
statusBarHeight.value = 0
// #endif
clientId.value = getClientId()
await loadSessions()
})
const getClientId = () => {
const cached = uni.getStorageSync(STORAGE_CLIENT_ID)
if (cached) return String(cached)
const id = `c_${Date.now()}_${Math.random().toString(16).slice(2)}`
uni.setStorageSync(STORAGE_CLIENT_ID, id)
return id
}
const loadSessions = async () => {
try {
sessions.value = await assistantSessions({ client_id: clientId.value })
} catch (e) {
sessions.value = []
}
}
const openSession = async (session: AssistantSession) => {
try {
const res = await assistantMessages({ client_id: clientId.value, session_id: session.id })
sessionId.value = res.session?.id || session.id
messages.value = res.messages || []
showHistory.value = false
scrollToBottom()
} catch (e) {
uni.showToast({ title: '会话加载失败', icon: 'none' })
}
}
const newSession = () => {
sessionId.value = 0
messages.value = []
showHistory.value = false
inputText.value = ''
}
const toggleHistory = async () => {
showHistory.value = !showHistory.value
if (showHistory.value) {
await loadSessions()
}
}
const sendQuick = (text: string) => {
inputText.value = text
sendMessage()
}
const renderAssistantMarkdown = (content: string) => renderMarkdownToHtml(content)
const sendMessage = async () => {
const text = inputText.value.trim()
if (!text || sending.value) return
const userMessage: AssistantMessage = {
id: Date.now(),
session_id: sessionId.value,
role: 'user',
content: text
}
const pendingMessage: AssistantMessage = {
id: Date.now() + 1,
session_id: sessionId.value,
role: 'assistant',
content: '',
pending: true
}
messages.value.push(userMessage, pendingMessage)
inputText.value = ''
sending.value = true
startAnalysisTimer()
scrollToBottom()
try {
const res = await assistantChat({
message: text,
client_id: clientId.value,
session_id: sessionId.value || undefined
})
sessionId.value = res.session?.id || sessionId.value
const answer = res.message || {
role: 'assistant',
content: '我暂时没有生成成功,请稍后再试。',
sources: res.sources || []
}
messages.value.splice(messages.value.length - 1, 1, answer)
await loadSessions()
} catch (e: any) {
console.error('[ai_assistant] chat failed:', e)
const recovered = await recoverLatestAssistantReply(text)
if (recovered) return
messages.value.splice(messages.value.length - 1, 1, {
id: Date.now() + 2,
session_id: sessionId.value,
role: 'assistant',
content: getAssistantErrorMessage(e),
sources: []
})
} finally {
stopAnalysisTimer()
sending.value = false
scrollToBottom()
}
}
const recoverLatestAssistantReply = async (question: string) => {
try {
await loadSessions()
const matchedSession = sessions.value.find((item) => item.last_message === question)
if (!matchedSession?.id) return false
const res = await assistantMessages({ client_id: clientId.value, session_id: matchedSession.id })
const serverMessages = res.messages || []
const hasMatchedQuestion = serverMessages.some((item) => item.role === 'user' && item.content === question)
const hasAssistantReply = serverMessages.some((item) => item.role === 'assistant' && !item.pending)
if (!hasMatchedQuestion || !hasAssistantReply) return false
sessionId.value = res.session?.id || matchedSession.id
messages.value = serverMessages
await loadSessions()
scrollToBottom()
return true
} catch (e) {
console.error('[ai_assistant] recover latest reply failed:', e)
return false
}
}
const getRawAssistantErrorMessage = (error: any) => {
return typeof error === 'string'
? error
: String(error?.msg || error?.errMsg || error?.message || error?.data?.msg || '')
}
const getAssistantErrorMessage = (error: any) => {
const rawMessage = getRawAssistantErrorMessage(error)
if (/timeout|超时/i.test(rawMessage)) {
return 'AI助手响应时间较长,请稍后再试;当前请求已超时。'
}
if (/abort|cancel/i.test(rawMessage)) {
return 'AI助手请求已取消,请重新发送问题。'
}
if (/request:fail|network|网络/i.test(rawMessage)) {
return rawMessage
? `AI助手请求失败,请检查网络后重试。错误详情:${rawMessage}`
: 'AI助手请求失败,请检查网络后重试。'
}
return rawMessage || 'AI助手请求失败,请稍后重试。'
}
const startAnalysisTimer = () => {
stopAnalysisTimer()
analysisElapsedSeconds.value = 0
const startAt = Date.now()
analysisTimer = setInterval(() => {
analysisElapsedSeconds.value = Math.floor((Date.now() - startAt) / 1000)
}, 1000)
}
const stopAnalysisTimer = () => {
if (analysisTimer) {
clearInterval(analysisTimer)
analysisTimer = null
}
}
const clearAllSessions = () => {
uni.showModal({
title: '清空历史',
content: '确定清空所有助手会话?',
success: async (res) => {
if (!res.confirm) return
try {
await assistantClear({ client_id: clientId.value })
sessions.value = []
newSession()
} catch (e) {
uni.showToast({ title: '清空失败', icon: 'none' })
}
}
})
}
const scrollToBottom = () => {
nextTick(() => {
scrollIntoView.value = 'msg-' + Math.max(messages.value.length - 1, 0)
})
}
const openSource = (source: AssistantSource) => {
if (!source.path) return
if (source.path === '/pages/crypto/crypto' || source.path === '/pages/lottery_analysis/lottery_analysis') {
uni.switchTab({ url: source.path })
return
}
uni.navigateTo({ url: source.path })
}
const sourceTypeName = (type: string) => {
const map: Record<string, string> = {
article: '资讯',
post: '社区',
match: '赛事',
lottery: '彩票',
crypto: '行情',
web_news: '外网资讯'
}
return map[type] || '来源'
}
const goBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
uni.switchTab({ url: '/pages/index/index' })
}
onUnmounted(() => {
stopAnalysisTimer()
})
</script>
<style lang="scss" scoped>
.assistant-page {
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f3f6fa;
overflow: hidden;
}
.assistant-nav {
flex-shrink: 0;
min-height: 96rpx;
padding-left: 20rpx;
padding-right: 20rpx;
background: rgba(255, 255, 255, 0.96);
display: flex;
align-items: center;
border-bottom: 1rpx solid rgba(211, 220, 232, 0.8);
box-shadow: 0 8rpx 28rpx rgba(31, 41, 55, 0.05);
&__title {
flex: 1;
min-width: 0;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
&__actions {
display: flex;
align-items: center;
gap: 8rpx;
}
&__btn {
width: 64rpx;
height: 64rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__btn:active {
background: #eef3fb;
}
}
.history-panel {
flex-shrink: 0;
max-height: 46vh;
background: #fff;
border-bottom: 1rpx solid #dbe6f5;
box-shadow: 0 18rpx 36rpx rgba(38, 55, 77, 0.08);
&__header {
height: 80rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
color: #111827;
font-weight: 600;
}
&__clear {
color: #185dff;
font-weight: 500;
}
&__list {
max-height: calc(46vh - 80rpx);
}
&__empty {
padding: 48rpx 0;
text-align: center;
font-size: 24rpx;
color: #9ca3af;
}
}
.history-item {
padding: 22rpx 28rpx 24rpx;
border-top: 1rpx solid #f1f5f9;
background: #fff;
&__title {
display: block;
font-size: 28rpx;
color: #111827;
font-weight: 600;
}
&__desc {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.message-list {
flex: 1;
min-height: 0;
padding: 24rpx 24rpx 36rpx;
box-sizing: border-box;
}
.welcome {
display: flex;
gap: 18rpx;
padding: 24rpx 24rpx 26rpx;
background: #fff;
border: 1rpx solid rgba(214, 224, 237, 0.92);
border-radius: 16rpx;
box-shadow: 0 12rpx 28rpx rgba(41, 60, 87, 0.06);
&__badge {
width: 60rpx;
height: 60rpx;
border-radius: 14rpx;
background: #185dff;
color: #fff;
font-size: 24rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__body {
flex: 1;
min-width: 0;
}
&__title {
display: block;
font-size: 31rpx;
color: #111827;
font-weight: 700;
line-height: 1.35;
}
&__desc {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #6b7280;
line-height: 1.5;
}
}
.quick-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin: 22rpx 0 32rpx;
&__item {
min-height: 62rpx;
padding: 12rpx 18rpx;
border-radius: 14rpx;
background: #fff;
border: 1rpx solid rgba(207, 220, 238, 0.95);
color: #185dff;
font-size: 24rpx;
line-height: 1.35;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-shadow: 0 8rpx 20rpx rgba(24, 93, 255, 0.04);
}
&__text {
display: block;
max-width: 100%;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__item:active {
background: #edf4ff;
border-color: #b9cff7;
}
}
.message-row {
display: flex;
align-items: flex-start;
margin-bottom: 26rpx;
&--user {
justify-content: flex-end;
.message-row__body {
max-width: 82%;
align-items: flex-end;
}
.message-bubble {
background: #185dff;
border-color: #185dff;
color: #fff;
box-shadow: 0 12rpx 28rpx rgba(24, 93, 255, 0.22);
}
}
&--assistant {
justify-content: flex-start;
.message-row__body {
max-width: calc(100% - 78rpx);
align-items: flex-start;
}
.message-bubble {
background: #fff;
color: #111827;
border: 1rpx solid rgba(214, 224, 237, 0.95);
box-shadow: 0 12rpx 30rpx rgba(39, 55, 77, 0.07);
}
}
&__avatar {
width: 58rpx;
height: 58rpx;
margin-right: 16rpx;
border-radius: 14rpx;
background: #111827;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 10rpx 20rpx rgba(17, 24, 39, 0.14);
}
&__body {
min-width: 0;
display: flex;
flex-direction: column;
}
}
.message-bubble {
max-width: 100%;
padding: 20rpx 24rpx;
border-radius: 16rpx;
box-sizing: border-box;
&__pending {
display: flex;
flex-direction: column;
gap: 8rpx;
}
&__pulse {
width: 36rpx;
height: 8rpx;
border-radius: 8rpx;
background: #185dff;
opacity: 0.78;
}
&__text,
&__loading,
&__markdown {
font-size: 28rpx;
line-height: 1.72;
white-space: pre-wrap;
word-break: break-word;
}
&__markdown {
white-space: normal;
}
&__loading {
color: #6b7280;
}
&__timer {
font-size: 22rpx;
line-height: 1.4;
color: #0f7a5f;
font-weight: 600;
}
}
.message-bubble__markdown :deep(*) {
box-sizing: border-box;
}
.message-bubble__markdown :deep(p),
.message-bubble__markdown :deep(ul),
.message-bubble__markdown :deep(ol),
.message-bubble__markdown :deep(blockquote),
.message-bubble__markdown :deep(pre),
.message-bubble__markdown :deep(hr) {
margin: 0 0 18rpx;
}
.message-bubble__markdown :deep(h1),
.message-bubble__markdown :deep(h2),
.message-bubble__markdown :deep(h3),
.message-bubble__markdown :deep(h4),
.message-bubble__markdown :deep(h5),
.message-bubble__markdown :deep(h6) {
margin: 0 0 16rpx;
color: inherit;
font-weight: 700;
line-height: 1.45;
}
.message-bubble__markdown :deep(h1) {
font-size: 34rpx;
}
.message-bubble__markdown :deep(h2) {
font-size: 32rpx;
}
.message-bubble__markdown :deep(h3) {
font-size: 30rpx;
}
.message-bubble__markdown :deep(ul),
.message-bubble__markdown :deep(ol) {
padding-left: 34rpx;
}
.message-bubble__markdown :deep(li) {
margin-bottom: 10rpx;
line-height: 1.72;
}
.message-bubble__markdown :deep(blockquote) {
padding: 14rpx 18rpx;
border-left: 6rpx solid #bfd5ff;
border-radius: 0 12rpx 12rpx 0;
background: #f6f9ff;
color: #4b5563;
}
.message-bubble__markdown :deep(code) {
padding: 2rpx 10rpx;
border-radius: 8rpx;
background: #eff3f8;
color: #0f172a;
font-size: 24rpx;
}
.message-bubble__markdown :deep(pre) {
overflow-x: auto;
padding: 18rpx;
border-radius: 14rpx;
background: #0f172a;
}
.message-bubble__markdown :deep(pre code) {
display: block;
padding: 0;
background: transparent;
color: #f8fafc;
white-space: pre-wrap;
word-break: break-word;
}
.message-bubble__markdown :deep(a) {
color: #185dff;
text-decoration: underline;
word-break: break-all;
}
.message-bubble__markdown :deep(strong) {
font-weight: 700;
}
.message-bubble__markdown :deep(hr) {
border: 0;
border-top: 1rpx solid #dbe4f0;
}
.message-bubble__markdown :deep(img) {
max-width: 100%;
height: auto;
border-radius: 12rpx;
}
.message-bubble__markdown :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0 0 18rpx;
overflow: hidden;
border-radius: 12rpx;
background: #fff;
border: 1rpx solid #dbe4f0;
}
.message-bubble__markdown :deep(th),
.message-bubble__markdown :deep(td) {
padding: 12rpx 14rpx;
border: 1rpx solid #dbe4f0;
text-align: left;
vertical-align: top;
line-height: 1.6;
font-size: 24rpx;
}
.message-bubble__markdown :deep(th) {
background: #f6f9ff;
font-weight: 700;
}
.message-bubble__markdown :deep(p:last-child),
.message-bubble__markdown :deep(ul:last-child),
.message-bubble__markdown :deep(ol:last-child),
.message-bubble__markdown :deep(blockquote:last-child),
.message-bubble__markdown :deep(pre:last-child),
.message-bubble__markdown :deep(table:last-child),
.message-bubble__markdown :deep(hr:last-child),
.message-bubble__markdown :deep(h1:last-child),
.message-bubble__markdown :deep(h2:last-child),
.message-bubble__markdown :deep(h3:last-child),
.message-bubble__markdown :deep(h4:last-child),
.message-bubble__markdown :deep(h5:last-child),
.message-bubble__markdown :deep(h6:last-child) {
margin-bottom: 0;
}
.source-list {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.source-item {
position: relative;
padding: 18rpx 18rpx 18rpx 22rpx;
border-radius: 14rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 18rpx;
bottom: 18rpx;
left: 0;
width: 6rpx;
border-radius: 0 6rpx 6rpx 0;
background: #0f7a5f;
}
&__meta {
display: inline-flex;
align-items: center;
height: 34rpx;
padding: 0 12rpx;
border-radius: 6rpx;
background: #e8f6f1;
color: #0f7a5f;
font-size: 20rpx;
font-weight: 600;
}
&__title {
display: block;
margin-top: 10rpx;
font-size: 25rpx;
color: #111827;
font-weight: 600;
line-height: 1.4;
}
&__summary {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #6b7280;
line-height: 1.45;
}
}
.input-panel {
flex-shrink: 0;
padding-top: 16rpx;
padding-left: 22rpx;
padding-right: 22rpx;
padding-bottom: calc(18rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.98);
border-top: 1rpx solid rgba(211, 220, 232, 0.86);
box-shadow: 0 -10rpx 28rpx rgba(31, 41, 55, 0.05);
}
.input-bar {
display: flex;
align-items: center;
gap: 16rpx;
height: 64rpx;
overflow: hidden;
&__input {
flex: 1;
height: 64rpx;
min-height: 64rpx;
max-height: 64rpx;
padding: 0 20rpx;
box-sizing: border-box;
border-radius: 14rpx;
background: #eef3f8;
font-size: 28rpx;
line-height: 64rpx;
color: #111827;
}
&__send {
width: 64rpx;
height: 64rpx;
border-radius: 14rpx;
background: #185dff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 10rpx 20rpx rgba(24, 93, 255, 0.22);
&--disabled {
background: #b8c5d8;
box-shadow: none;
}
}
}
</style>