259 lines
7.2 KiB
Vue
259 lines
7.2 KiB
Vue
<script lang="ts" setup>
|
||
import type { AgentConfig, ModelConfig } from '../components/agent-config'
|
||
import type { AiMessage } from '@/store/ai'
|
||
import { useAiStore } from '@/store'
|
||
import {
|
||
defaultModelAgentConfig,
|
||
defaultModelConfig,
|
||
generateMessageId,
|
||
} from '../components/agent-config'
|
||
import { fetchRecommendQuestions, streamMessage } from '../components/agent-service'
|
||
import AgentHeader from '../components/AgentHeader.vue'
|
||
import AgentInput from '../components/AgentInput.vue'
|
||
import AgentMessage from '../components/AgentMessage.vue'
|
||
|
||
definePage({
|
||
style: {
|
||
navigationBarTitleText: 'AI助手',
|
||
},
|
||
})
|
||
|
||
const agentConfig = reactive<AgentConfig>({
|
||
// 当前页面默认走模型直连;如需切回 bot,改成 ...defaultAgentConfig
|
||
...defaultModelAgentConfig,
|
||
})
|
||
const modelConfig = reactive<ModelConfig>({ ...defaultModelConfig })
|
||
|
||
const aiStore = useAiStore()
|
||
|
||
const paging = ref<any>(null)
|
||
const messages = ref<AiMessage[]>([])
|
||
const loading = ref(false)
|
||
const initQuestionCapsules = computed(() => agentConfig.initQuestions.slice(0, 3))
|
||
const shouldShowInitQuestionCapsules = computed(() => aiStore.messages.length === 0 && initQuestionCapsules.value.length > 0)
|
||
|
||
// 用于停止流:在新一轮发送时把上一轮的 controller 标记为 cancelled
|
||
let cancelFlag = false
|
||
|
||
onLoad(() => {
|
||
// #ifdef MP-WEIXIN
|
||
if (typeof wx !== 'undefined' && wx.cloud) {
|
||
wx.cloud.init({
|
||
env: agentConfig.cloudEnvId,
|
||
traceUser: true,
|
||
})
|
||
}
|
||
// #endif
|
||
|
||
aiStore.ensureThreadId()
|
||
})
|
||
|
||
// z-paging 聊天模式:第一页直接把 store 里的历史灌给它
|
||
// 聊天模式数据约定:数组下标 0 = 视觉最底部(最新消息),所以要倒序传入
|
||
function queryList() {
|
||
const welcomeMsg = agentConfig.welcomeMsg?.trim()
|
||
|
||
if (aiStore.messages.length === 0 && welcomeMsg) {
|
||
aiStore.addMessage({
|
||
id: generateMessageId(),
|
||
role: 'assistant',
|
||
content: welcomeMsg,
|
||
createdAt: Date.now(),
|
||
})
|
||
}
|
||
paging.value?.complete(aiStore.messages.slice().reverse())
|
||
}
|
||
|
||
async function handleSend(text: string) {
|
||
if (loading.value) {
|
||
return
|
||
}
|
||
cancelFlag = false
|
||
loading.value = true
|
||
|
||
const userMsg: AiMessage = {
|
||
id: generateMessageId(),
|
||
role: 'user',
|
||
content: text,
|
||
createdAt: Date.now(),
|
||
}
|
||
const aiMsg: AiMessage = {
|
||
id: generateMessageId(),
|
||
role: 'assistant',
|
||
content: '',
|
||
pending: true,
|
||
// +1 防止与 userMsg 同毫秒下时间戳相等
|
||
createdAt: Date.now() + 1,
|
||
}
|
||
|
||
// store 顺序:旧 → 新;z-paging 聊天模式 addChatRecordData 内部会反转 + 前插,
|
||
// 最终 totalData = [aiMsg, userMsg, ...历史],下标 0 = 视觉最底,符合"用户问 → AI 答在下方"
|
||
aiStore.addMessage(userMsg)
|
||
aiStore.addMessage(aiMsg)
|
||
paging.value?.addChatRecordData([userMsg, aiMsg])
|
||
|
||
try {
|
||
await streamMessage({
|
||
chatMode: agentConfig.chatMode,
|
||
// bot 模式参数
|
||
botId: agentConfig.botId,
|
||
threadId: aiStore.ensureThreadId(),
|
||
// model 模式参数
|
||
modelProvider: modelConfig.modelProvider,
|
||
quickResponseModel: modelConfig.quickResponseModel,
|
||
// 倒数第二条之前是历史;当前 aiMsg 是占位,不传
|
||
history: aiStore.messages.slice(0, -1),
|
||
prompt: text,
|
||
onDelta: (delta) => {
|
||
if (cancelFlag) {
|
||
return
|
||
}
|
||
aiStore.appendDelta(aiMsg.id, delta)
|
||
},
|
||
onError: (msg) => {
|
||
aiStore.updateMessage(aiMsg.id, {
|
||
error: true,
|
||
pending: false,
|
||
content: msg,
|
||
})
|
||
},
|
||
})
|
||
|
||
aiStore.updateMessage(aiMsg.id, { pending: false })
|
||
|
||
// 当前 V2 agent 后端不支持 getRecommendQuestions(端点 404),先关掉这一调用。
|
||
// 等切换到 bot-xxx 旧版 agent 或后端补上对应路由再开启。
|
||
if (!cancelFlag) {
|
||
void loadRecommendQuestions(aiMsg.id, text)
|
||
}
|
||
}
|
||
catch (err) {
|
||
aiStore.updateMessage(aiMsg.id, {
|
||
pending: false,
|
||
error: true,
|
||
content: aiStore.messages.find(m => m.id === aiMsg.id)?.content
|
||
|| (err instanceof Error ? err.message : '生成失败'),
|
||
})
|
||
}
|
||
finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleStop() {
|
||
cancelFlag = true
|
||
loading.value = false
|
||
// 把仍在 pending 的消息收尾
|
||
const pending = aiStore.messages.find(m => m.pending)
|
||
if (pending) {
|
||
aiStore.updateMessage(pending.id, { pending: false })
|
||
}
|
||
}
|
||
|
||
function handleRetry(id: string) {
|
||
const failedIdx = aiStore.messages.findIndex(m => m.id === id)
|
||
if (failedIdx <= 0) {
|
||
return
|
||
}
|
||
const userMsg = aiStore.messages[failedIdx - 1]
|
||
if (!userMsg || userMsg.role !== 'user') {
|
||
return
|
||
}
|
||
aiStore.removeMessage(id)
|
||
handleSend(userMsg.content)
|
||
}
|
||
|
||
function handleClickQuestion(question: string) {
|
||
handleSend(question)
|
||
}
|
||
|
||
/** 请求追问建议,结果挂到对应 AI 消息的 recommendQuestions */
|
||
async function loadRecommendQuestions(aiMsgId: string, prompt: string) {
|
||
const last = aiStore.messages.slice(-2).map(m => ({
|
||
role: m.role === 'user' ? ('user' as const) : ('assistant' as const),
|
||
content: m.content,
|
||
}))
|
||
try {
|
||
const questions = await fetchRecommendQuestions({
|
||
botId: agentConfig.botId,
|
||
lastPair: last,
|
||
prompt,
|
||
max: 3,
|
||
onProgress: (qs) => {
|
||
aiStore.updateMessage(aiMsgId, { recommendQuestions: qs })
|
||
},
|
||
})
|
||
aiStore.updateMessage(aiMsgId, { recommendQuestions: questions })
|
||
}
|
||
catch (error) {
|
||
console.error('[recommend]', error)
|
||
}
|
||
}
|
||
|
||
function handleClear() {
|
||
aiStore.clear()
|
||
paging.value?.reload()
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<view class="h-screen w-full flex flex-col bg-[#F4F6FA]">
|
||
<!-- 消息列表(聊天模式) -->
|
||
<view class="min-h-0 flex-1">
|
||
<z-paging
|
||
ref="paging"
|
||
v-model="messages"
|
||
:default-page-size="50"
|
||
:auto-show-system-loading="false"
|
||
:loading-more-enabled="false"
|
||
:show-refresher-when-reload="false"
|
||
:show-loading-more-no-more-view="false"
|
||
use-chat-record-mode
|
||
auto-adjust-position-when-chat
|
||
auto-to-bottom-when-chat
|
||
:paging-style="{ backgroundColor: '#F4F6FA' }"
|
||
@query="queryList"
|
||
>
|
||
<!-- 首屏推荐问题 -->
|
||
<template v-if="shouldShowInitQuestionCapsules" #top>
|
||
<agent-header
|
||
:questions="initQuestionCapsules"
|
||
@pick="handleClickQuestion"
|
||
/>
|
||
</template>
|
||
|
||
<template #default>
|
||
<agent-message
|
||
v-for="item in messages"
|
||
:key="item.id"
|
||
:message="item"
|
||
:bot-name="agentConfig.botName"
|
||
@retry="handleRetry"
|
||
@pick-recommend="handleClickQuestion"
|
||
/>
|
||
</template>
|
||
|
||
<template #bottom>
|
||
<view>
|
||
<view class="text-[24rpx] text-[#999] text-center my-[16rpx]">AI建议仅供参考,请以官方政策为准</view>
|
||
<agent-input
|
||
:placeholder="agentConfig.placeholder"
|
||
:loading="loading"
|
||
@send="handleSend"
|
||
@stop="handleStop"
|
||
@clear="handleClear"
|
||
/>
|
||
</view>
|
||
</template>
|
||
<template #empty>
|
||
<view />
|
||
</template>
|
||
</z-paging>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
//
|
||
</style>
|