feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
525
知识库/参考代码/coze-chat-frontend/src/stores/TrainingStore.ts
Executable file
525
知识库/参考代码/coze-chat-frontend/src/stores/TrainingStore.ts
Executable file
@@ -0,0 +1,525 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user