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() clientRef = createRef() 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()