volunteer-secondary/src/pages-ai/ai/index.vue

259 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>