feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

View 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()