- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
526 lines
14 KiB
TypeScript
Executable File
526 lines
14 KiB
TypeScript
Executable File
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()
|