Files
012-kaopeilian/知识库/参考代码/coze-chat-frontend/src/stores/TrainingStore.ts
111 998211c483 feat: 初始化考培练系统项目
- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
2026-01-24 19:33:28 +08:00

526 lines
14 KiB
TypeScript
Executable File
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.
import { makeAutoObservable, runInAction } from "mobx"
import {
getBotInfo,
createConversation,
startChatStream,
deleteConversation,
interruptChat,
} from "@/server/api"
import { XStream } from "@ant-design/x"
import { uid } from "uid"
import { BubbleListRef } from "@ant-design/x/es/bubble/BubbleList"
import { createRef } from "react"
import {
ClientEventType,
WsChatClient,
WsChatEventNames,
WsToolsUtils,
type WsChatEventData,
} from "@coze/api/ws-tools"
import {
type ConversationAudioTranscriptCompletedEvent,
WebsocketsEventType,
BotInfo,
} from "@coze/api"
import { config } from "@/stores/config"
import { message } from "antd"
import { getApiUrl } from "@/utils/tools"
interface IBotInfo extends BotInfo {
name: string
description: string
suggestedQuestions: string[]
}
export enum EChatModel {
VOICE,
TEXT,
}
export enum EStatus {
unconnected = "未连接",
connecting = "连接中...",
connected = "已连接",
disconnected = "已断开连接",
error = "连接错误",
waiting = "等待ai回复",
listening = "正在聆听",
}
class TrainingStore {
chatModel = EChatModel.VOICE
botInfo: IBotInfo | null = null
conversationID = ""
isLoading = false
messageList: any[] = []
chatId = ""
controller: AbortController | null = null
uploading = false
fileArr: any[] = []
containerRef = createRef<BubbleListRef>()
clientRef = createRef<WsChatClient>()
status: EStatus = EStatus.unconnected
isFirstDelta = true
shwMessageList = true
tempContent = ""
userId = ""
constructor() {
makeAutoObservable(this, { isFirstDelta: false, tempContent: false }) // 自动将字段和方法转为响应式
}
setChatModel = async (model: EChatModel) => {
if (model === EChatModel.VOICE) {
this.handleConnect()
}
this.chatModel = model
}
setShowContent = () => {
this.shwMessageList = !this.shwMessageList
}
initClient = async () => {
const permission = await WsToolsUtils.checkDevicePermission()
if (!permission.audio) {
throw new Error("需要麦克风访问权限")
}
if (!config.getPat()) {
await this.getBotToken()
}
// 确保token已获取
const token = config.getPat()
if (!token) {
throw new Error("无法获取Token")
}
const client = new WsChatClient({
token: token, // 直接使用token不加Bearer前缀
baseWsURL: config.getBaseWsUrl(),
allowPersonalAccessTokenInBrowser: true, // 浏览器环境下必须设置为true才能使用PAT
botId: config.getBotId(),
debug: true, // 启用调试模式
})
console.log("WebSocket配置:", {
token: `${token.substring(0, 10)}...`,
baseWsURL: config.getBaseWsUrl(),
botId: config.getBotId(),
})
// 监听连接事件
client.on(WsChatEventNames.CONNECTED, () => {
console.log("[chat] WebSocket connected")
runInAction(() => {
this.status = EStatus.connected
})
})
// 监听断开连接事件
client.on(WsChatEventNames.DISCONNECTED, () => {
console.log("[chat] WebSocket disconnected")
runInAction(() => {
this.status = EStatus.disconnected
})
})
// 监听服务器错误事件
client.on(WsChatEventNames.SERVER_ERROR, (_: string, event: unknown) => {
console.error("[chat] server error", event)
client.disconnect()
this.clientRef.current = null
runInAction(() => {
this.status = EStatus.error
})
})
// 监听所有消息事件
client.on(WsChatEventNames.ALL, this.handleMessageEvent)
this.clientRef.current = client
}
handleMessageEvent = (eventName: string, event: WsChatEventData) => {
if (eventName === WsChatEventNames.CONNECTED) {
this.messageList = []
return
}
if (!event) {
return
}
switch (event.event_type) {
case WebsocketsEventType.CONVERSATION_AUDIO_TRANSCRIPT_COMPLETED: {
const { content } = (event as ConversationAudioTranscriptCompletedEvent)
.data
runInAction(() => {
this.messageList.unshift({
id: uid(32),
role: "user",
content,
})
})
break
}
case WebsocketsEventType.CONVERSATION_MESSAGE_DELTA:
if (event.data.content) {
const content = this.tempContent + event.data.content
runInAction(() => {
this.tempContent = content
})
if (this.isFirstDelta) {
// 第一次增量,创建新消息
runInAction(() => {
this.messageList.unshift({
id: uid(32),
role: "assistant",
content: event.data.content,
})
this.isFirstDelta = false
})
} else {
this.updateMessageContent(content)
}
}
break
case WebsocketsEventType.CONVERSATION_MESSAGE_COMPLETED: {
// 收到完成事件,重置标记,下一次将创建新消息
runInAction(() => {
this.isFirstDelta = true
this.tempContent = ""
})
break
}
case WebsocketsEventType.CONVERSATION_AUDIO_DELTA: {
runInAction(() => {
this.status = EStatus.waiting
})
break
}
case ClientEventType.AUDIO_SENTENCE_PLAYBACK_ENDED: {
runInAction(() => {
this.status = EStatus.listening
})
break
}
default:
break
}
}
handleInterrupt = () => {
try {
this.clientRef.current?.interrupt()
} catch (error) {
message.error(`打断失败:${error}`)
}
}
handleConnect = async () => {
try {
this.status = EStatus.connecting
if (!this.clientRef.current) {
await this.initClient()
}
await this.clientRef.current?.connect()
runInAction(() => {
this.status = EStatus.connected
})
// 设置初始音量
if (this.clientRef.current) {
this.clientRef.current.setPlaybackVolume(1)
}
} catch (error) {
this.getBotToken()
console.error(`连接错误:${(error as Error).message}`)
this.status = EStatus.error
}
}
handleDisconnect = async () => {
try {
await this.clientRef.current?.disconnect()
this.clientRef.current = null
runInAction(() => {
this.status = EStatus.disconnected
})
} catch (error) {
message.error(`断开失败:${error}`)
}
}
getBotToken = async () => {
try {
// 仅用于本地开发:后端返回 PAT请求后端端口
const url = `${getApiUrl()}/agent/v1/cozechat/get-token?modelEnum=CONSULTANT_PRACTICE`
const resp = await fetch(url)
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`)
}
const data = await resp.json()
if (data && data.code === 10000 && data.data) {
localStorage.setItem("chat-x_pat", data.data)
console.log("[chat] Token获取成功")
} else {
console.error("[chat] Token获取失败响应数据:", data)
}
} catch (error) {
console.error("[chat] Token获取请求失败:", error)
}
}
getBotInfo = async () => {
// 使用咨询师陪练的 bot_id与后端配置保持一致
const botId = "7509379008556089379"
const res = await getBotInfo(botId)
runInAction(() => {
if (res.code === 10000) {
this.userId = uid(32)
document.title = res.data.bot.name
this.botInfo = {
...res.data.bot,
suggestedQuestions:
res.data.bot.onboarding_info?.suggested_questions || [],
}
this.messageList = [
{
id: uid(32),
content: res.data.bot.onboarding_info?.prologue || "你好我是你的AI助手有什么可以帮助你的吗",
role: "assistant",
prologue: "true",
},
]
}
})
}
streamingChat = (query: string) => {
if (this.isLoading) {
return
}
runInAction(() => {
this.messageList.unshift({
id: uid(32),
role: "user",
content: query,
file_url: this.fileArr,
})
this.messageList.unshift({
id: uid(32),
role: "assistant",
content: "",
loading: true,
is_answer: 1,
})
})
this.creatChat(query)
}
// 创建一个辅助函数来更新消息内容
updateMessageContent = (content: string) => {
runInAction(() => {
this.messageList[0] = {
...this.messageList[0],
content: content,
loading: false,
}
})
}
creatChat = async (query: string, id?: string) => {
this.controller = new AbortController()
const fileIds = this.fileArr.map((item) => item.id)
try {
runInAction(() => {
this.isLoading = true
this.fileArr = []
})
// 如果没有对话ID先创建对话
if (!this.conversationID) {
const conversationRes = await createConversation({ bot_id: "7509379008556089379" })
if (conversationRes.code === 10000) {
this.conversationID = conversationRes.data.id
}
}
const historyMessages = this.messageList
.filter((val) => val.content !== "" && !val.prologue)
.map((item) => {
return {
role: item.role,
content: item.content,
file_ids:
item.file_url && item.file_url.length
? item.file_url.map((item: any) => item.id)
: [],
}
})
const response = await startChatStream(
{
conversation_id: this.conversationID,
content: query,
bot_id: "7509379008556089379",
file_ids: fileIds,
user_id: this.userId,
history_messages: historyMessages,
},
this.controller.signal
)
let currentContent = ""
let chatId = ""
for await (const part of XStream({
readableStream: response,
})) {
if (this.controller?.signal.aborted) {
// Check if aborted during iteration
break
}
if (typeof part === "string") {
continue
}
// 去除事件名称前后的空格
const eventName = part.event?.trim() || ""
if (
eventName === "conversation.chat.created" ||
eventName === "conversation.message.delta" ||
eventName === "conversation.message.completed"
) {
part.data = JSON.parse(part.data)
}
console.log("🔄 SSE Event received:", `"${part.event}"`, "->", `"${eventName}"`, part.data)
runInAction(() => {
if (eventName === "conversation.chat.created") {
this.conversationID = part.data.conversation_id
} else if (eventName === "conversation.message.delta") {
currentContent += part.data.content
chatId = part.data.chat_id
this.updateMessageContent(currentContent)
if (chatId && this.chatId !== chatId) {
this.chatId = chatId
}
}
if (
eventName === "conversation.chat.failed" ||
eventName === "error"
) {
if (this.messageList[0].role === "assistant") {
// 如果内容为空,添加错误消息
if (!this.messageList[0].content) {
this.messageList[0].content = "抱歉,请求出错了,请重试。"
}
this.messageList[0] = {
...this.messageList[0],
loading: false,
}
}
}
})
}
} catch (error) {
if ((error as Error).name === "CanceledError") {
runInAction(() => {
if (this.messageList[0].role === "assistant") {
// 如果内容为空,添加错误消息
if (!this.messageList[0].content) {
this.messageList[0].content = "会话已中断。"
}
this.messageList[0] = {
...this.messageList[0],
loading: false,
}
}
})
} else {
console.error("Error during chat stream:", error)
// 在错误情况下更新UI状态
runInAction(() => {
if (this.messageList[0].role === "assistant") {
// 如果内容为空,添加错误消息
if (!this.messageList[0].content) {
this.messageList[0].content = "抱歉,请求出错了,请重试。"
}
this.messageList[0] = {
...this.messageList[0],
loading: false,
}
}
})
}
} finally {
this.complateChat()
this.controller = null
}
}
abortChat = async () => {
if (this.chatId && this.conversationID) {
await interruptChat({
chat_id: this.chatId,
conversation_id: this.conversationID,
})
}
this.controller?.abort()
this.complateChat()
}
complateChat = () => {
runInAction(() => {
this.isLoading = false
if (this.messageList[0] && this.messageList[0].is_answer) {
this.messageList[0] = {
...this.messageList[0],
is_answer: 0,
}
}
})
}
deleteMessage = async (id: string) => {
// 暂时简化消息删除功能
runInAction(() => {
if (this.messageList.length > 0) {
this.messageList = this.messageList.filter((item) => item.id !== id)
}
})
}
reGenerate = () => {
const content = this.messageList[1]?.content
const chatId = this.chatId || this.messageList[0]?.chat_id
this.messageList[0] = {
id: uid(32),
role: "assistant",
content: "",
loading: true,
is_answer: 1,
}
this.creatChat(content, chatId)
}
getBase64(img: any, callback: any) {
const reader = new FileReader()
reader.addEventListener("load", () => callback(reader.result))
reader.readAsDataURL(img)
}
onRemoveFile = (fileId: string) => {
const fileList = this.fileArr.filter((file: any) => file.id !== fileId)
this.fileArr = fileList
}
handleUploadFile = async (file?: any) => {
if (!file) {
this.fileArr = []
return
}
// 暂时简化文件上传功能
this.uploading = false
}
scrollToBottom = () => {
requestAnimationFrame(() => {
this.containerRef.current?.scrollTo({
offset: this.containerRef.current?.nativeElement.scrollHeight + 100,
block: "nearest",
behavior: "smooth",
})
})
}
}
export default new TrainingStore()