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,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import React, { Suspense } from "react"
import { HashRouter as Router, Routes, Route } from "react-router-dom"
const Home = React.lazy(() => import("./pages/Home"))
const Content = React.lazy(() => import("./pages/Content"))
const Training = React.lazy(() => import("./pages/Training"))
const NewChat = React.lazy(() => import("./pages/NewChat"))
const Exam = React.lazy(() => import("./pages/Exam"))
const AudioTest = React.lazy(() => import("./pages/AudioTest"))
function App() {
return (
<Router>
<Suspense
fallback={
<div className="flex_c h_full fs-14 secondaryTextColor">
Loading...
</div>
}
>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/content" element={<Content />} />
<Route path="/training" element={<Training />} />
<Route path="/practice" element={<Training />} />
<Route path="/newChat" element={<NewChat />} />
<Route path="/exam" element={<Exam />} />
<Route path="/dynamic-test" element={<Exam />} />
<Route path="/emotional-reply" element={<NewChat />} />
<Route path="/audio-test" element={<AudioTest />} />
</Routes>
</Suspense>
</Router>
)
}
export default App

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,245 @@
.bubble_list {
flex: 1;
padding: 20px 20px 60px;
-webkit-mask: linear-gradient(180deg, #fff 91.89%, hsla(0, 0%, 100%, 0));
mask: linear-gradient(180deg, #fff 91.89%, hsla(0, 0%, 100%, 0));
scrollbar-gutter: stable both-edges;
scrollbar-color: rgba(213, 213, 213) transparent;
user-select: none;
.ant-bubble {
max-width: 800px;
margin: 0 auto;
width: 100%;
}
p {
margin-bottom: 0;
}
img {
width: 100%;
height: 100%;
}
.example_content {
border-radius: 20px;
background: linear-gradient(90deg, #e9f4ff 0%, #f1f0ff 100%);
padding: 20px 30px;
line-height: 24px;
}
.example_title {
font-size: 18px;
font-weight: bold;
color: #0e101a;
}
.example_desc {
color: #32375a;
margin-top: 12px;
}
.example_tips {
line-height: 24px;
color: #727b8d;
margin: 6px 0;
}
.ask_item {
background: #f9fafb;
border-radius: 10px;
padding: 8px 12px;
margin-right: 8px;
margin-bottom: 8px;
color: #717272;
}
.operate_btn {
line-height: 32px;
padding: 0 16px;
border: 1px solid #cacaca;
margin-right: 8px;
border-radius: 10px;
&:hover {
background-color: #f5f5f5;
}
}
.mesage_btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #818181;
margin-right: 8px;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
border-radius: 4px;
}
}
.user_bubble {
.file_card {
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
}
.message_content {
background-color: #ddd9ff;
margin-bottom: 12px;
padding: 12px 16px;
border-radius: 8px;
h1,
h2,
h3,
h4,
h5,
h6 {
color: #111827;
font-weight: 600;
margin-top: 20px;
margin-bottom: 12px;
}
ul,
ol {
margin-top: 1em;
margin-bottom: 1em;
padding-left: 2em;
}
li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
a {
color: #2563eb;
text-decoration: underline;
font-weight: 500;
}
code {
background-color: #f3f4f6;
padding: 0.2em 0.4em;
border-radius: 0.25em;
font-size: 14px;
}
pre {
background-color: #f3f4f6;
padding: 1em;
border-radius: 0.375em;
overflow-x: auto;
font-size: 0.875em;
margin-top: 1em;
margin-bottom: 1em;
}
img {
max-width: 100%;
height: auto;
margin-top: 2em;
margin-bottom: 2em;
}
hr {
margin-top: 2em;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1.5em 0;
}
table th,
table td {
border: 1px solid #cccccc;
padding: 0.6em 1em;
text-align: left;
}
table th {
background-color: #f0f0f0;
font-weight: bold;
}
table tr:nth-child(2n) {
background-color: #f8f8f8;
}
}
.assistant_bubble {
.message_content {
background-color: #f5f5f5;
height: 100%;
}
.ant-bubble-footer {
margin-top: 0;
}
}
// 答题卡样式
.card_message {
font-weight: 500;
background-color: #f5f5f5 !important;
.card_item {
padding: 12px 16px;
background-color: #fff;
margin-top: 8px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid #e0e0e0;
&:hover:not(.disabled) {
background-color: #f0f8ff;
border-color: #1890ff;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
}
&:active:not(.disabled) {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(24, 144, 255, 0.2);
}
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #f8f8f8;
&:hover {
background-color: #f8f8f8 !important;
border-color: #e0e0e0 !important;
transform: none !important;
box-shadow: none !important;
}
}
}
}
.operate_wrap {
position: absolute;
left: 0;
right: 0;
bottom: 160px;
width: 100%;
height: 36px;
display: flex;
justify-content: center;
padding: 0 20px;
.abord_btn {
box-shadow: 0px 8px 24px 1px rgba(97, 94, 107, 0.1);
}
.operate_box {
width: 800px;
position: relative;
}
.to_bottom {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 0;
bottom: 0;
width: 36px;
height: 36px;
border: 2px solid #eaeaea;
border-radius: 50px;
transition: 0.2s;
z-index: 1;
box-shadow: 0 8px 15px 0 rgba(65, 77, 96, 0.08);
background-color: #fff;
cursor: pointer;
color: #727b8d;
}
.ant-sender {
overflow: hidden;
}
}

View File

@@ -0,0 +1,349 @@
import { Attachments, Bubble, BubbleProps } from "@ant-design/x"
import { Avatar, Button, message, Tooltip, Typography } from "antd"
import { useMemo, useRef, useEffect, useState } from "react"
import markdownit from "markdown-it"
import { throttle } from "@/utils/tools"
import user from "@/assets/images/user.jpg"
import { observer } from "@/stores/utils"
import "./index.scss"
const md = markdownit({ html: true, breaks: true })
interface IProps {
showUserInfo?: boolean
showPlaceholderNode?: boolean
botInfo: any
streamingChat: (content: string) => void
messageList: any[]
deleteMessage: (id: string) => void
reGenerate: () => void
getMessageList?: () => void
chatId: string
isLoading: boolean
abortChat: () => void
containerRef: any
conversationID: string
scrollToBottom: () => void
}
const MessageList = (props: IProps) => {
const [isAtBottom, setIsAtBottom] = useState(true)
const lastScrollTop = useRef(false)
const [messageApi, contextHolder] = message.useMessage()
const {
botInfo,
streamingChat,
messageList,
deleteMessage,
reGenerate,
getMessageList,
chatId,
isLoading,
abortChat,
containerRef,
conversationID,
scrollToBottom,
showUserInfo,
showPlaceholderNode,
} = props
useEffect(() => {
setIsAtBottom(true)
}, [conversationID])
useEffect(() => {
if (containerRef.current?.nativeElement) {
const throttledScroll = throttle(handleScroll, 100)
containerRef.current?.nativeElement.addEventListener(
"scroll",
throttledScroll
)
return () => {
containerRef.current?.nativeElement.removeEventListener(
"scroll",
throttledScroll
)
}
}
}, [])
const handleScroll = (e: any) => {
const ele = e.target
if (ele.scrollTop + ele.clientHeight >= ele.scrollHeight - 5) {
setIsAtBottom(true)
} else {
setIsAtBottom(false)
}
if (getMessageList) {
if (
ele.scrollTop < lastScrollTop.current &&
ele.scrollTop <= 50 &&
!isLoading
) {
getMessageList()
}
lastScrollTop.current = ele.scrollTop
}
}
const renderMarkdown: BubbleProps["messageRender"] = (content) => (
<Typography>
<div
className="message_content"
dangerouslySetInnerHTML={{ __html: md.render(content) }}
/>
</Typography>
)
const renderUserBubble = (bubbleData: any) => {
return (
<div className="flex_column flex_end">
{bubbleData.file_url?.map(
(file: { id: string; name: string; path: string }) => {
return (
<Attachments.FileCard
className="file_card"
key={file.path}
item={{
uid: file.id,
name: file.name,
url: file.path,
}}
/>
)
}
)}
<div className="message_content">{bubbleData.content}</div>
</div>
)
}
const renderAssistantFooter = (bubbleData: any, index: number) => {
const lastMessage = index === messageList.length - 1
if (bubbleData.prologue && messageList.length === 1) {
return (
<div className="flex_wrap">
{botInfo?.suggestedQuestions &&
botInfo?.suggestedQuestions.map((item: any) => {
return (
<div
className="csp operate_btn"
key={item}
onClick={() => streamingChat(item)}
>
{item}
</div>
)
})}
</div>
)
}
if (!bubbleData.is_answer && !bubbleData.prologue) {
return (
<div className="flex_left">
<Tooltip title="复制">
{contextHolder}
<div
className="mesage_btn"
onClick={async () => {
await navigator.clipboard.writeText(String(bubbleData.content))
messageApi.success("复制成功!")
}}
>
<i className="iconfont icon-fuzhi1"></i>
</div>
</Tooltip>
{lastMessage && (
<Tooltip title="重新生成">
<div className="mesage_btn" onClick={reGenerate}>
<i className="iconfont icon-zhongxinfenxi"></i>
</div>
</Tooltip>
)}
{bubbleData.id && (
<Tooltip title="删除">
<div
className="mesage_btn"
onClick={() => {
bubbleData.id && deleteMessage(bubbleData.id)
}}
>
<i className="iconfont icon-shanchu"></i>
</div>
</Tooltip>
)}
</div>
)
}
}
const getRoles = (bubbleData: any, index: number) => {
bubbleData.role = bubbleData.role ? bubbleData.role : "assistant"
switch (bubbleData.role) {
case "user":
return {
placement: "end" as const,
className: "user_bubble",
variant: "borderless" as const,
header: () => {
if (showUserInfo) {
return <div className="fs-12 secondaryTextColor">user</div>
}
},
avatar: showUserInfo ? <Avatar size="large" src={user} /> : undefined,
messageRender: () => renderUserBubble(bubbleData),
}
case "assistant":
return {
placement: "start" as const,
className: "assistant_bubble",
variant: "borderless" as const,
typing: bubbleData.is_answer ? { step: 5, interval: 20 } : false,
messageRender: (content: string) => {
// 检查是否为答题卡类型
if (bubbleData.content_type === "card" && bubbleData.card_content) {
const cardData = bubbleData.card_content
const lastMessage = index === messageList.length - 1
console.log("🃏 渲染答题卡:", cardData)
return (
<div className="flex_column">
{content && renderMarkdown(content)}
<div className="card_message message_content">
<div
className="fs-18"
dangerouslySetInnerHTML={{
__html: (cardData.Title || cardData.title || "题目").replace(/\n/g, "<br />"),
}}
></div>
{(cardData.Options || cardData.options || []).map((item: { name: string } | string, idx: number) => {
const optionText = typeof item === 'string' ? item : item.name
return (
<div
className={`card_item ${
lastMessage ? "" : "disabled"
}`}
key={idx}
onClick={() => {
if (lastMessage) {
console.log("🔄 用户选择选项:", optionText)
streamingChat(optionText)
}
}}
>
{optionText}
</div>
)
})}
</div>
</div>
)
} else {
return renderMarkdown(content)
}
},
loadingRender: () => {
return (
<div className="message_content">
<span className="ant-bubble-dot">
<i className="ant-bubble-dot-item"></i>
<i className="ant-bubble-dot-item"></i>
<i className="ant-bubble-dot-item"></i>
</span>
</div>
)
},
header: () => {
if (showUserInfo) {
return (
<div className="fs-12 secondaryTextColor">{botInfo?.name}</div>
)
}
},
avatar: showUserInfo ? (
<Avatar size="large" src={botInfo?.icon_url} />
) : undefined,
footer: <>{renderAssistantFooter(bubbleData, index)}</>,
}
default:
return {}
}
}
const placeholderNode = botInfo?.name && (
<div className="example_item">
<div className="example_content">
<div className="example_title">{botInfo?.name}</div>
<div className="example_desc">{botInfo?.description}</div>
</div>
<p className="example_tips">...</p>
<div className="flex_wrap">
{botInfo?.suggestedQuestions &&
botInfo?.suggestedQuestions.map((item: any) => {
return (
<div
className="ask_item csp"
key={item}
onClick={() => streamingChat(item)}
>
{item}
</div>
)
})}
</div>
</div>
)
const list = messageList.slice().reverse()
const bubbleItems = showPlaceholderNode
? list.length > 0
? list
: [
{
key: "top",
role: "assets",
content: placeholderNode,
variant: "borderless",
},
]
: list
return (
<>
<Bubble.List
id="BubbleList"
ref={containerRef}
className="bubble_list"
roles={getRoles}
items={bubbleItems}
/>
<div className="operate_wrap">
<div className="operate_box">
{isLoading && chatId && (
<div className=" flex_c mb-20">
<Button className="abord_btn" onClick={abortChat}>
<i className="iconfont icon-jieshuluyin"></i>
</Button>
</div>
)}
<div
className="to_bottom"
style={{ display: isAtBottom ? "none" : "flex" }}
onClick={scrollToBottom}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 21C11.7348 21 11.4804 20.8946 11.2929 20.7071L4.29289 13.7071C3.90237 13.3166 3.90237 12.6834 4.29289 12.2929C4.68342 11.9024 5.31658 11.9024 5.70711 12.2929L11 17.5858V4C11 3.44772 11.4477 3 12 3C12.5523 3 13 3.44772 13 4V17.5858L18.2929 12.2929C18.6834 11.9024 19.3166 11.9024 19.7071 12.2929C20.0976 12.6834 20.0976 13.3166 19.7071 13.7071L12.7071 20.7071C12.5196 20.8946 12.2652 21 12 21Z"
fill="currentColor"
></path>
</svg>
</div>
</div>
</div>
</>
)
}
export default observer(MessageList)

View File

@@ -0,0 +1,70 @@
.input_container {
z-index: 7;
position: sticky;
bottom: 0;
background-color: #fff;
padding: 0 20px;
.tips {
font-size: 12px;
color: #b7b9c1;
margin: 6px 0;
line-height: 14px;
text-align: center;
}
.block_wrap {
max-width: 800px;
margin: 0 auto;
}
.send_box {
background-color: #fbfbfb;
}
.send_btn {
min-width: 28px !important;
height: 28px;
width: 28px;
}
.icon-fasong {
font-size: 14px;
}
.speech_btn {
font-size: 16px;
color: #b6b8c0;
}
.icon-icon02 {
font-size: 20px;
color: #b6b8c0;
}
.file_btn {
&:hover {
.icon-icon02 {
color: #8a5cff !important;
}
background-color: #f6f0ff !important;
}
}
.sender_header {
border-bottom: 1px solid #eaeaea;
background: #fff;
display: flex;
flex-wrap: wrap;
}
.uploading_box {
width: 68px;
height: 68px;
margin: 12px;
background-color: rgba($color: #000000, $alpha: 0.5);
border-radius: 4px;
}
.file_list {
display: flex;
flex-wrap: wrap;
padding: 12px 0 12px 12px;
gap: 12px;
.ant-image {
width: 68px;
height: 68px;
overflow: hidden;
border-radius: 4px;
}
}
}

View File

@@ -0,0 +1,168 @@
import { checkMeia, Tss } from "@/utils/tts"
import { Attachments, Sender } from "@ant-design/x"
import { FooterRender } from "@ant-design/x/es/sender"
import { Button, Divider, GetProp, message, Spin, Tooltip } from "antd"
import { useEffect, useRef, useState } from "react"
import { observer } from "@/stores/utils"
import "./index.scss"
interface IProps {
streamingChat: (content: string) => void
isLoading: boolean
abortChat: () => void
handleUploadFile: (file: any) => void
scrollToBottom: () => void
uploading: boolean
fileArr: any[]
onRemoveFile: (id: string) => void
}
const SenderBox = (props: IProps) => {
const tssRef = useRef<Tss>(null)
const [speech, setSpeech] = useState(false)
const [messageContent, setMessageContent] = useState("")
const {
streamingChat,
isLoading,
abortChat,
handleUploadFile,
scrollToBottom,
uploading,
fileArr,
onRemoveFile,
} = props
useEffect(() => {
return () => {
tssRef.current?.close()
}
}, [])
const onSubmit = (nextContent: string) => {
setSpeech(false)
tssRef.current?.close()
if (!nextContent) {
return
}
scrollToBottom()
streamingChat(nextContent)
setMessageContent("")
}
const senderHeader = (
<div className="sender_header">
{fileArr.length > 0 && (
<div className="file_list">
{fileArr.map((file) => (
<Attachments.FileCard
className="file_card"
key={file.id}
onRemove={() => onRemoveFile(file.id)}
item={{
uid: file.id,
name: file.name,
url: file.path,
}}
/>
))}
</div>
)}
{uploading && (
<div className="uploading_box flex_c">
<Spin size="small" spinning={uploading} />
</div>
)}
</div>
)
const handleFileChange: GetProp<typeof Attachments, "onChange"> = (info) => {
handleUploadFile(info.file)
}
const renderFooter: FooterRender = ({ components }) => {
const { SendButton, SpeechButton } = components
return (
<div className="flex_sb">
<SpeechButton className="speech_btn" />
<div className="flex_right">
{/* <Attachments
beforeUpload={() => false}
onChange={handleFileChange}
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.xls,.xlsx,.mp3,.wav,.csv"
>
<Tooltip
title={
<div className="fs-16">
上传文件image、pdf、docx、excel、csv、audio。
</div>
}
>
<Button
type="text"
className="file_btn"
icon={<i className="iconfont icon-icon02"></i>}
/>
</Tooltip>
</Attachments>
<Divider type="vertical" /> */}
<SendButton
type="primary"
className="send_btn"
icon={<i className="iconfont icon-fasong"></i>}
disabled={messageContent && !isLoading && !uploading ? false : true}
/>
</div>
</div>
)
}
const onRecordChange = async (nextSpeech: boolean) => {
if (nextSpeech) {
try {
await checkMeia()
} catch {
message.error("获取麦克风失败")
}
tssRef.current = Tss.createInstance({
onMessage(data) {
if (!data) return
setMessageContent(messageContent + data)
},
onError(e) {
tssRef.current?.close()
},
onClose() {
tssRef.current?.close()
},
})
} else {
tssRef.current?.close()
}
setSpeech(nextSpeech)
}
return (
<div className="input_container">
<div className="block_wrap">
<Sender
className="send_box"
placeholder="发送消息..."
actions={false}
onChange={(value) => setMessageContent(value)}
value={messageContent}
onSubmit={onSubmit}
header={senderHeader}
autoSize={{ minRows: 2, maxRows: 6 }}
footer={renderFooter}
onCancel={abortChat}
allowSpeech={{
recording: speech,
onRecordingChange: onRecordChange,
}}
/>
<div className="tips"> AI </div>
</div>
</div>
)
}
export default observer(SenderBox)

View File

@@ -0,0 +1 @@
export * from './use-media-query';

View File

@@ -0,0 +1,142 @@
import { removePx } from '@/utils/tools';
import { useEffect, useMemo, useState } from 'react';
export const breakpointsTokens = {
xs: '375px', // mobile => @media (min-width: 0px) { ... }
sm: '576px', // mobile => @media (min-width: 576px) { ... }
md: '768px', // tablet => @media (min-width: 768px) { ... }
lg: '1024px', // desktop => @media (min-width: 1024px) { ... }
xl: '1280px', // desktop-lg => @media (min-width: 1280px) { ... }
'2xl': '1536px', // desktop-xl => @media (min-width: 1536px) { ... }
};
type MediaQueryConfig = {
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
orientation?: 'portrait' | 'landscape';
prefersColorScheme?: 'dark' | 'light';
prefersReducedMotion?: boolean;
devicePixelRatio?: number;
pointerType?: 'coarse' | 'fine';
};
const buildMediaQuery = (config: MediaQueryConfig | string): string => {
if (typeof config === 'string') return config;
const conditions: string[] = [];
if (config.minWidth) conditions.push(`(min-width: ${config.minWidth}px)`);
if (config.maxWidth) conditions.push(`(max-width: ${config.maxWidth}px)`);
if (config.minHeight) conditions.push(`(min-height: ${config.minHeight}px)`);
if (config.maxHeight) conditions.push(`(max-height: ${config.maxHeight}px)`);
if (config.orientation)
conditions.push(`(orientation: ${config.orientation})`);
if (config.prefersColorScheme)
conditions.push(`(prefers-color-scheme: ${config.prefersColorScheme})`);
if (config.prefersReducedMotion)
conditions.push('(prefers-reduced-motion: reduce)');
if (config.devicePixelRatio)
conditions.push(
`(-webkit-min-device-pixel-ratio: ${config.devicePixelRatio})`,
);
if (config.pointerType) conditions.push(`(pointer: ${config.pointerType})`);
return conditions.join(' and ');
};
/**
* React hook for handling media queries
*
* @param config - Media query configuration object or query string
* @returns boolean - Returns true if the media query matches
*
* @example
* // Basic usage - Mobile detection
* const isMobile = useMediaQuery({ maxWidth: 768 });
*
* @example
* // Using predefined breakpoints
* const isDesktop = useMediaQuery(up('lg'));
*
* @example
* // Complex query - Tablet in landscape mode
* const isTabletLandscape = useMediaQuery({
* minWidth: 768,
* maxWidth: 1024,
* orientation: 'landscape'
* });
*
* @example
* // User preferences
* const isDarkMode = useMediaQuery({ prefersColorScheme: 'dark' });
* const prefersReducedMotion = useMediaQuery({ prefersReducedMotion: true });
*
* @example
* // Device capabilities
* const isTouchDevice = useMediaQuery({ pointerType: 'coarse' });
* const isRetina = useMediaQuery({ devicePixelRatio: 2 });
*
* @example
* // Range queries using helpers
* const isTablet = useMediaQuery(between('sm', 'md'));
*
* @example
* // Raw media query string
* const isPortrait = useMediaQuery('(orientation: portrait)');
*
* @see {@link MediaQueryConfig} for all supported configuration options
*/
export const useMediaQuery = (config: MediaQueryConfig | string) => {
// 服务器端渲染时默认为 false
const [matches, setMatches] = useState(false);
// 将 config 转换为 mediaQuery 字符串
const mediaQueryString = useMemo(() => buildMediaQuery(config), [config]);
useEffect(() => {
// 客户端渲染时立即检查当前状态
const mediaQuery = window.matchMedia(mediaQueryString);
setMatches(mediaQuery.matches);
// 监听变化
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
// 使用新旧两种 API 以确保最大兼容性
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handler);
} else {
// 兼容旧版浏览器
mediaQuery.addListener(handler);
}
// 清理函数
return () => {
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', handler);
} else {
// 兼容旧版浏览器
mediaQuery.removeListener(handler);
}
};
}, [mediaQueryString]);
return matches;
};
type Breakpoints = typeof breakpointsTokens;
type BreakpointsKeys = keyof Breakpoints;
// 辅助函数
export const up = (key: BreakpointsKeys) => ({
minWidth: removePx(breakpointsTokens[key]),
});
export const down = (key: BreakpointsKeys) => ({
maxWidth: removePx(breakpointsTokens[key]) - 0.05, // 减去0.05px避免断点重叠
});
export const between = (start: BreakpointsKeys, end: BreakpointsKeys) => ({
minWidth: removePx(breakpointsTokens[start]),
maxWidth: removePx(breakpointsTokens[end]) - 0.05,
});

View File

@@ -0,0 +1,15 @@
@use "./style/mixins/interval.scss";
@use "./style/mixins/fontSize.scss";
@use "./style/global.scss";
@forward "./style/variables.scss";
@import "./style/iconfonts/iconfont.css";
.page_height {
height: 100vh;
}
@media (max-width: 750px) {
.page_height {
height: calc($height-primary);
}
}

View File

@@ -0,0 +1,27 @@
import { createRoot } from "react-dom/client"
import App from "./App.tsx"
import { StoreContext } from "@/stores/utils"
import store from "@/stores"
import { ThemeConfig } from "antd"
import { XProvider } from "@ant-design/x"
import zhCN from "antd/locale/zh_CN" // 引入中文语言包
import "./index.scss"
import "antd/dist/reset.css"
import { setWindowHeight } from "@/utils/tools.ts"
setWindowHeight()
window.onresize = () => {
setWindowHeight()
}
const config: ThemeConfig = {
token: {
colorPrimary: "#6633ff",
},
}
createRoot(document.getElementById("root")!).render(
<XProvider theme={config} locale={zhCN} wave={{ disabled: true }}>
<StoreContext.Provider value={store}>
<App />
</StoreContext.Provider>
</XProvider>
)

View File

@@ -0,0 +1,214 @@
.audio-test-page {
padding: 20px;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
.audio-test-container {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 400px;
gap: 20px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
.main-card {
.ant-card-body {
padding: 24px;
}
.control-section, .audio-control-section, .token-section {
margin: 16px 0;
.status-indicator {
padding: 8px 12px;
border-radius: 6px;
background: #f0f0f0;
font-weight: 500;
border: 1px solid #d9d9d9;
}
}
.token-section {
.ant-typography {
word-break: break-all;
}
}
}
.log-card {
height: fit-content;
max-height: 600px;
.ant-card-head {
background: #fafafa;
}
.log-controls {
margin-bottom: 12px;
text-align: right;
}
.log-container {
max-height: 400px;
overflow-y: auto;
background: #f8f8f8;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 12px;
.log-item {
margin-bottom: 8px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
line-height: 1.4;
&:last-child {
margin-bottom: 0;
}
.ant-typography {
font-size: 12px;
padding: 4px 8px;
background: white;
border-radius: 4px;
display: block;
margin: 0;
}
}
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
&:hover {
background: #a8a8a8;
}
}
}
}
}
/* 连接状态指示器样式 */
.status-indicator {
&:has-text("🟢") {
background: #f6ffed !important;
border-color: #b7eb8f !important;
color: #389e0d;
}
&:has-text("🟡") {
background: #fffbe6 !important;
border-color: #ffe58f !important;
color: #d48806;
}
&:has-text("🔴") {
background: #fff2f0 !important;
border-color: #ffccc7 !important;
color: #cf1322;
}
}
/* 按钮动画效果 */
.ant-btn {
transition: all 0.3s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
}
&.ant-btn-primary {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border: none;
&:hover {
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
}
}
&.ant-btn-dangerous {
background: linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%);
border: none;
&:hover {
background: linear-gradient(135deg, #ff7875 0%, #ff4d4f 100%);
}
}
}
/* 卡片阴影效果 */
.ant-card {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 12px;
border: none;
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.ant-card-head {
border-radius: 12px 12px 0 0;
}
}
/* 分割线样式 */
.ant-divider {
border-color: #e8e8e8;
margin: 20px 0;
}
/* 标题样式 */
.ant-typography h2 {
color: #1890ff;
margin-bottom: 8px;
}
.ant-typography h4 {
color: #262626;
margin-bottom: 12px;
}
/* Alert 样式 */
.ant-alert {
border-radius: 8px;
}
/* 响应式设计 */
@media (max-width: 768px) {
padding: 12px;
.audio-test-container {
.main-card, .log-card {
.ant-card-body {
padding: 16px;
}
}
.log-card {
max-height: 300px;
.log-container {
max-height: 200px;
}
}
}
}
}

View File

@@ -0,0 +1,494 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button, Card, Space, Typography, Alert, Divider, Progress } from 'antd';
import { AudioOutlined, PlayCircleOutlined, StopOutlined, UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import { startChatStream, uploadFile } from '@/server/api';
import './index.scss';
const { Title, Text } = Typography;
interface AudioTestPageProps {}
// 测试用的Bot ID - 基于官方示例
const TEST_BOT_ID = '7509379008556089379';
const AudioTestPage: React.FC<AudioTestPageProps> = () => {
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [token, setToken] = useState<string>('');
const [conversationId, setConversationId] = useState<string>('');
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string>('');
const [responseAudioUrl, setResponseAudioUrl] = useState<string>('');
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const audioRef = useRef<HTMLAudioElement | null>(null);
// 添加日志
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString();
setLogs(prev => [...prev, `[${timestamp}] ${message}`]);
};
// 获取Token - 基于官方示例
const getToken = async () => {
try {
addLog('正在获取Token...');
const response = await fetch('http://localhost:8000/agent/v1/cozechat/get-token?modelEnum=CONSULTANT_PRACTICE');
const data = await response.json();
if (data.code === 10000 && data.data) {
setToken(data.data);
addLog(`✅ Token获取成功: ${data.data.substring(0, 20)}...`);
return data.data;
} else {
throw new Error(`获取Token失败: ${data.message || '未知错误'}`);
}
} catch (error) {
const errorMsg = `❌ 获取Token失败: ${error}`;
setError(errorMsg);
addLog(errorMsg);
throw error;
}
};
// 创建对话 - 基于官方示例
const createConversation = async () => {
try {
addLog('正在创建对话...');
const currentToken = token || await getToken();
const response = await fetch('http://localhost:8000/agent/v1/cozechat/create-conversation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({
bot_id: TEST_BOT_ID,
user_id: 'web-audio-test-user'
})
});
const data = await response.json();
if (data.code === 10000 && data.data?.id) {
setConversationId(data.data.id);
addLog(`✅ 对话创建成功: ${data.data.id}`);
return data.data.id;
} else {
throw new Error(`创建对话失败: ${data.message || '未知错误'}`);
}
} catch (error) {
const errorMsg = `❌ 创建对话失败: ${error}`;
setError(errorMsg);
addLog(errorMsg);
throw error;
}
};
// 初始化音频录制 - 基于官方示例的Web版本
const initAudioRecording = async () => {
try {
addLog('🎤 正在初始化音频录制...');
// 检查浏览器支持
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('浏览器不支持音频录制');
}
// 请求麦克风权限
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
});
addLog('✅ 麦克风权限获取成功');
addLog('✅ 音频录制初始化完成');
// 创建MediaRecorder
mediaRecorderRef.current = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
});
// 监听数据可用事件
mediaRecorderRef.current.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
// 监听录制停止事件
mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob);
const url = URL.createObjectURL(audioBlob);
setAudioUrl(url);
addLog(`✅ 音频录制完成,大小: ${(audioBlob.size / 1024).toFixed(2)}KB`);
audioChunksRef.current = [];
};
return true;
} catch (error) {
const errorMsg = `❌ 初始化音频录制失败: ${error}`;
setError(errorMsg);
addLog(errorMsg);
return false;
}
};
// 开始录音 - 基于官方示例
const startRecording = async () => {
try {
if (!mediaRecorderRef.current) {
const success = await initAudioRecording();
if (!success) return;
}
setIsRecording(true);
setError(null);
addLog('🎙️ 开始录音...');
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'inactive') {
mediaRecorderRef.current.start();
addLog('📹 录音器已启动');
}
} catch (error) {
const errorMsg = `开始录音失败: ${error}`;
setError(errorMsg);
addLog(`${errorMsg}`);
setIsRecording(false);
}
};
// 停止录音
const stopRecording = () => {
try {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
setIsRecording(false);
addLog('🛑 录音已停止');
}
} catch (error) {
addLog(`停止录音失败: ${error}`);
}
};
// 上传音频文件并发送聊天 - 基于官方示例
const uploadAudioAndChat = async () => {
if (!audioBlob) {
addLog('❌ 请先录制音频');
return;
}
try {
setIsProcessing(true);
setError(null);
// 确保有Token和ConversationId
const currentToken = token || await getToken();
const currentConversationId = conversationId || await createConversation();
addLog('📤 正在上传音频文件...');
// 创建FormData上传音频
const formData = new FormData();
formData.append('file', audioBlob, 'audio.webm');
const uploadResponse = await fetch('http://localhost:8000/agent/v1/cozechat/upload-file', {
method: 'POST',
headers: {
'Authorization': `Bearer ${currentToken}`
},
body: formData
});
const uploadData = await uploadResponse.json();
if (uploadData.code !== 10000 || !uploadData.data?.id) {
throw new Error(`上传音频失败: ${uploadData.message}`);
}
const fileId = uploadData.data.id;
addLog(`✅ 音频上传成功文件ID: ${fileId}`);
// 发送聊天消息 - 基于官方示例
addLog('💬 正在发送音频消息...');
const chatResponse = await fetch('http://localhost:8000/agent/v1/cozechat/chat-stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({
bot_id: TEST_BOT_ID,
user_id: 'web-audio-test-user',
conversation_id: currentConversationId,
additional_messages: [
{
role: 'user',
type: 'question',
content: '',
content_type: 'object_string',
object_string: {
type: 'audio',
file_id: fileId
}
}
]
})
});
if (!chatResponse.ok) {
throw new Error(`聊天请求失败: ${chatResponse.statusText}`);
}
addLog('✅ 音频消息发送成功');
addLog('🎧 正在等待AI回复...');
// 处理流式响应
const reader = chatResponse.body?.getReader();
if (!reader) {
throw new Error('无法读取响应流');
}
let audioData = '';
let textResponse = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.event === 'conversation.message.delta') {
textResponse += data.message.content;
console.log('文本回复:', data.message.content);
} else if (data.event === 'conversation.audio.delta') {
audioData += data.message.content;
addLog('📻 接收音频数据块...');
}
} catch (e) {
// 忽略解析错误
}
}
}
}
if (textResponse) {
addLog(`💬 AI回复: ${textResponse}`);
}
if (audioData) {
// 转换Base64音频数据为播放格式
const audioBuffer = Uint8Array.from(atob(audioData), c => c.charCodeAt(0));
const audioBlob = new Blob([audioBuffer], { type: 'audio/pcm' });
const audioUrl = URL.createObjectURL(audioBlob);
setResponseAudioUrl(audioUrl);
addLog('🎵 AI音频回复已准备就绪');
}
} catch (error) {
const errorMsg = `语音聊天失败: ${error}`;
setError(errorMsg);
addLog(`${errorMsg}`);
} finally {
setIsProcessing(false);
}
};
// 播放AI回复音频
const playResponseAudio = () => {
if (responseAudioUrl && audioRef.current) {
audioRef.current.src = responseAudioUrl;
audioRef.current.play();
addLog('🔊 正在播放AI回复音频');
}
};
// 清空录音
const clearRecording = () => {
setAudioBlob(null);
setAudioUrl('');
setResponseAudioUrl('');
addLog('🗑️ 已清空录音');
};
// 清空日志
const clearLogs = () => {
setLogs([]);
};
// 播放录制的音频
const playRecordedAudio = () => {
if (audioUrl && audioRef.current) {
audioRef.current.src = audioUrl;
audioRef.current.play();
addLog('🎵 正在播放录制的音频');
}
};
// 初始化
useEffect(() => {
addLog('🚀 官方语音测试页面已加载 (基于 Coze Python SDK 示例)');
getToken().catch(() => {}); // 预先获取token忽略错误
return () => {
// 清理资源
if (audioUrl) URL.revokeObjectURL(audioUrl);
if (responseAudioUrl) URL.revokeObjectURL(responseAudioUrl);
};
}, []);
return (
<div className="audio-test-page">
<audio ref={audioRef} style={{ display: 'none' }} />
<div className="audio-test-container">
<Card className="main-card">
<Title level={2}>🎙 Coze (Python SDK示例)</Title>
<Text type="secondary">Bot ID: {TEST_BOT_ID}</Text>
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
//coze-py-main/examples/chat_oneonone_audio.py
</Text>
<Divider />
{error && (
<Alert
message="错误信息"
description={error}
type="error"
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: 16 }}
/>
)}
<div className="audio-record-section">
<Title level={4}>🎤 </Title>
<Space wrap>
<Button
type="primary"
icon={<AudioOutlined />}
loading={isRecording}
onClick={isRecording ? stopRecording : startRecording}
danger={isRecording}
>
{isRecording ? '停止录音' : '开始录音'}
</Button>
{audioBlob && (
<>
<Button
icon={<PlayCircleOutlined />}
onClick={playRecordedAudio}
>
</Button>
<Button
icon={<DeleteOutlined />}
onClick={clearRecording}
>
</Button>
</>
)}
</Space>
{audioBlob && (
<div style={{ marginTop: 12 }}>
<Text type="success">
: {(audioBlob.size / 1024).toFixed(2)}KB
</Text>
</div>
)}
</div>
<Divider />
<div className="chat-section">
<Title level={4}>💬 (API)</Title>
<Space wrap>
<Button
type="primary"
icon={<UploadOutlined />}
loading={isProcessing}
disabled={!audioBlob || isProcessing}
onClick={uploadAudioAndChat}
>
{isProcessing ? '处理中...' : '发送音频消息'}
</Button>
{responseAudioUrl && (
<Button
icon={<PlayCircleOutlined />}
onClick={playResponseAudio}
>
AI回复
</Button>
)}
</Space>
{isProcessing && (
<div style={{ marginTop: 12 }}>
<Progress percent={50} status="active" showInfo={false} />
<Text type="secondary">...</Text>
</div>
)}
</div>
<Divider />
<div className="token-section">
<Title level={4}>🔑 </Title>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text strong>Token: </Text>
<Text code>{token ? `${token.substring(0, 30)}...` : '未获取'}</Text>
</div>
<div>
<Text strong>ID: </Text>
<Text code>{conversationId || '未创建'}</Text>
</div>
<Button size="small" onClick={getToken}>
Token
</Button>
</Space>
</div>
</Card>
<Card className="log-card" title="实时日志">
<div className="log-controls">
<Button size="small" onClick={clearLogs}></Button>
</div>
<div className="log-container">
{logs.length === 0 ? (
<Text type="secondary"></Text>
) : (
logs.map((log, index) => (
<div key={index} className="log-item">
<Text code>{log}</Text>
</div>
))
)}
</div>
</Card>
</div>
</div>
);
};
export default AudioTestPage;

View File

@@ -0,0 +1,34 @@
.header_wrap {
position: sticky;
top: 0;
right: 0;
left: auto;
width: 100%;
.header_content {
padding: 0 14px;
height: 56px;
}
.header_title {
flex: 1;
font-size: 16px;
text-align: center;
}
.back_icon {
width: 72px;
height: 36px;
font-weight: bold;
}
.top_menu {
width: 72px;
.menu_btn {
width: 36px;
height: 36px;
}
}
}
.nav_drawer {
.ant-drawer-body {
padding: 0;
background: #f4f4f6;
}
}

View File

@@ -0,0 +1,59 @@
import { Drawer } from 'antd';
import { Fragment, useState } from 'react';
import { useStore, observer } from '@/stores/utils';
import Nav from '../Nav';
import './index.scss';
import { useNavigate } from 'react-router-dom';
const Header = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
const { ChatStore } = useStore();
const navigate = useNavigate();
return (
<Fragment>
<header className='header_wrap'>
<div className='header_content flex_sb'>
<div
className='back_icon flex_left csp'
onClick={() => {
navigate('/');
}}
>
<i className='iconfont icon-mjiantou-copy1' />
</div>
<div className='header_title'>AI搭子</div>
<div className='top_menu flex'>
<div
className='menu_btn flex_c csp'
onClick={() => {
ChatStore.setCollapsed(false);
setDrawerOpen(true);
}}
>
<i className='iconfont icon-lishijilu1 '></i>
</div>
<div
className='menu_btn flex_c csp'
onClick={() => ChatStore.onAddConversation()}
>
<i className='iconfont icon-xinjianduihua '></i>
</div>
</div>
</div>
</header>
<Drawer
placement='left'
onClose={() => setDrawerOpen(false)}
open={drawerOpen}
closeIcon={false}
className='nav_drawer'
width={260}
>
<Nav closeSideBarDrawer={() => setDrawerOpen(false)} />
</Drawer>
</Fragment>
);
};
export default observer(Header);

View File

@@ -0,0 +1,155 @@
.bubble_list {
flex: 1;
padding: 20px 20px 60px;
-webkit-mask: linear-gradient(180deg, #fff 91.89%, hsla(0, 0%, 100%, 0));
mask: linear-gradient(180deg, #fff 91.89%, hsla(0, 0%, 100%, 0));
scrollbar-gutter: stable both-edges;
scrollbar-color: rgba(213, 213, 213) transparent;
.ant-bubble {
max-width: 800px;
margin: 0 auto;
width: 100%;
}
p {
margin-bottom: 0;
}
img {
width: 100%;
height: 100%;
}
.example_content {
border-radius: 20px;
background: linear-gradient(90deg, #e9f4ff 0%, #f1f0ff 100%);
padding: 20px 30px;
line-height: 24px;
}
.example_title {
font-size: 18px;
font-weight: bold;
color: #0e101a;
}
.example_desc {
color: #32375a;
margin-top: 12px;
}
.example_tips {
line-height: 24px;
color: #727b8d;
margin: 6px 0;
}
.ask_item {
background: #f9fafb;
border-radius: 10px;
padding: 8px 12px;
margin-right: 8px;
margin-bottom: 8px;
color: #717272;
}
.mesage_btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #818181;
margin-right: 8px;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
border-radius: 4px;
}
}
.user_bubble {
.file_card {
border-radius: 4px;
overflow: hidden;
}
.message_content {
background-color: #ddd9ff;
padding: 12px 16px;
border-radius: 8px;
}
}
// 答题卡样式
.card_message {
font-weight: 500;
background-color: #f5f5f5 !important;
.card_item {
padding: 12px 16px;
background-color: #fff;
margin-top: 8px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid #e0e0e0;
&:hover:not(.disabled) {
background-color: #f0f8ff;
border-color: #1890ff;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
}
&:active:not(.disabled) {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(24, 144, 255, 0.2);
}
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #f8f8f8;
&:hover {
background-color: #f8f8f8 !important;
border-color: #e0e0e0 !important;
transform: none !important;
box-shadow: none !important;
}
}
}
}
.operate_wrap {
position: absolute;
left: 0;
right: 0;
bottom: 160px;
width: 100%;
height: 36px;
display: flex;
justify-content: center;
padding: 0 20px;
.abord_btn {
box-shadow: 0px 8px 24px 1px rgba(97, 94, 107, 0.1);
}
.operate_box {
width: 800px;
position: relative;
}
.to_bottom {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 0;
bottom: 0;
width: 36px;
height: 36px;
border: 2px solid #eaeaea;
border-radius: 50px;
transition: 0.2s;
z-index: 1;
box-shadow: 0 8px 15px 0 rgba(65, 77, 96, 0.08);
background-color: #fff;
cursor: pointer;
color: #727b8d;
}
.ant-sender {
overflow: hidden;
}
}

View File

@@ -0,0 +1,287 @@
import { observer, useStore } from "@/stores/utils"
import { Attachments, Bubble, BubbleProps } from "@ant-design/x"
import { Button, message, Tooltip, Typography } from "antd"
import { useMemo, useRef, useEffect, useState } from "react"
import { uid } from "uid"
import markdownit from "markdown-it"
import { throttle } from "@/utils/tools"
import "./index.scss"
const md = markdownit({ html: true, breaks: true })
const MessageList = () => {
const [isAtBottom, setIsAtBottom] = useState(true)
const lastScrollTop = useRef(false)
const [messageApi, contextHolder] = message.useMessage()
const { ChatStore } = useStore()
const {
botInfo,
streamingChat,
messageList,
deleteMessage,
reGenerate,
getMessageList,
chatId,
isLoading,
abortChat,
containerRef,
conversationID,
scrollToBottom,
} = ChatStore
useEffect(() => {
setIsAtBottom(true)
}, [conversationID])
useEffect(() => {
if (containerRef.current?.nativeElement) {
const throttledScroll = throttle(handleScroll, 100)
containerRef.current?.nativeElement.addEventListener(
"scroll",
throttledScroll
)
return () => {
containerRef.current?.nativeElement.removeEventListener(
"scroll",
throttledScroll
)
}
}
}, [])
const handleScroll = (e: any) => {
const ele = e.target
if (ele.scrollTop + ele.clientHeight >= ele.scrollHeight - 5) {
setIsAtBottom(true)
} else {
setIsAtBottom(false)
}
if (
ele.scrollTop < lastScrollTop.current &&
ele.scrollTop <= 50 &&
!isLoading
) {
getMessageList()
}
lastScrollTop.current = ele.scrollTop
}
const renderMarkdown: BubbleProps["messageRender"] = (content) => (
<Typography>
<div dangerouslySetInnerHTML={{ __html: md.render(content) }} />
</Typography>
)
const renderUserBubble = (bubbleData: any) => {
return (
<div className="flex_column flex_end">
{bubbleData.file_url?.map((file: string) => {
const fileType = file.split(".").pop() || ""
return (
<Attachments.FileCard
className="file_card mb-12"
key={file}
item={{
uid: uid(32),
name: `文件.${fileType}`,
url: file,
}}
/>
)
})}
<div className="message_content">{bubbleData.content}</div>
</div>
)
}
const renderAssistantFooter = (bubbleData: any, index: number) => {
const lastMessage = index === messageList.length - 1
if (!bubbleData.is_answer) {
return (
<div className="flex_left">
<Tooltip title="复制">
{contextHolder}
<div
className="mesage_btn"
onClick={async () => {
await navigator.clipboard.writeText(String(bubbleData.content))
messageApi.success("复制成功!")
}}
>
<i className="iconfont icon-fuzhi1"></i>
</div>
</Tooltip>
{lastMessage && (
<Tooltip title="重新生成">
<div className="mesage_btn" onClick={reGenerate}>
<i className="iconfont icon-zhongxinfenxi"></i>
</div>
</Tooltip>
)}
<Tooltip title="删除">
<div
className="mesage_btn"
onClick={() => bubbleData.id && deleteMessage(bubbleData.id)}
>
<i className="iconfont icon-shanchu"></i>
</div>
</Tooltip>
</div>
)
}
}
const getRoles = (bubbleData: any, index: number) => {
bubbleData.role = bubbleData.role ? bubbleData.role : "assistant"
switch (bubbleData.role) {
case "user":
return {
placement: "end" as const,
className: "user_bubble",
variant: "borderless" as const,
messageRender: () => renderUserBubble(bubbleData),
}
case "assistant":
return {
placement: "start" as const,
typing: bubbleData.is_answer ? { step: 5, interval: 20 } : false,
loading: bubbleData.loading,
messageRender: (content: string) => {
// 检查是否为答题卡类型
if (bubbleData.content_type === "card" && bubbleData.card_content) {
const cardData = bubbleData.card_content
const lastMessage = index === messageList.length - 1
console.log("🃏 Chat页面渲染答题卡:", cardData)
return (
<div className="flex_column">
{content && renderMarkdown(content)}
<div className="card_message message_content">
<div
className="fs-18"
dangerouslySetInnerHTML={{
__html: (cardData.Title || cardData.title || "题目").replace(/\n/g, "<br />"),
}}
></div>
{(cardData.Options || cardData.options || []).map((item: { name: string } | string, idx: number) => {
const optionText = typeof item === 'string' ? item : item.name
return (
<div
className={`card_item ${
lastMessage ? "" : "disabled"
}`}
key={idx}
onClick={() => {
if (lastMessage) {
console.log("🔄 Chat页面用户选择选项:", optionText)
streamingChat(optionText)
}
}}
>
{optionText}
</div>
)
})}
</div>
</div>
)
} else {
return renderMarkdown(content)
}
},
loadingRender: () => {
return (
<div className="message_content">
<span className="ant-bubble-dot">
<i className="ant-bubble-dot-item"></i>
<i className="ant-bubble-dot-item"></i>
<i className="ant-bubble-dot-item"></i>
</span>
</div>
)
},
footer: <>{renderAssistantFooter(bubbleData, index)}</>,
}
default:
return {}
}
}
const placeholderNode = botInfo?.name && (
<div className="example_item">
<div className="example_content">
<div className="example_title">{botInfo?.name}</div>
<div className="example_desc">{botInfo?.description}</div>
</div>
<p className="example_tips">...</p>
<div className="flex_wrap">
{botInfo?.suggestedQuestions &&
botInfo?.suggestedQuestions.map((item) => {
return (
<div
className="ask_item csp"
key={item}
onClick={() => streamingChat(item)}
>
{item}
</div>
)
})}
</div>
</div>
)
const bubbleItems = useMemo(() => {
const reversedList = messageList.slice().reverse()
return reversedList.length > 0
? reversedList
: [
{
key: "top",
role: "assets",
content: placeholderNode,
variant: "borderless",
},
]
}, [messageList, placeholderNode])
return (
<>
<Bubble.List
id="BubbleList"
ref={containerRef}
className="bubble_list"
roles={getRoles}
items={bubbleItems}
/>
<div className="operate_wrap">
<div className="operate_box">
{isLoading && chatId && (
<div className=" flex_c mb-20">
<Button className="abord_btn" onClick={abortChat}>
<i className="iconfont icon-jieshuluyin"></i>
</Button>
</div>
)}
<div
className="to_bottom"
style={{ display: isAtBottom ? "none" : "flex" }}
onClick={scrollToBottom}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 21C11.7348 21 11.4804 20.8946 11.2929 20.7071L4.29289 13.7071C3.90237 13.3166 3.90237 12.6834 4.29289 12.2929C4.68342 11.9024 5.31658 11.9024 5.70711 12.2929L11 17.5858V4C11 3.44772 11.4477 3 12 3C12.5523 3 13 3.44772 13 4V17.5858L18.2929 12.2929C18.6834 11.9024 19.3166 11.9024 19.7071 12.2929C20.0976 12.6834 20.0976 13.3166 19.7071 13.7071L12.7071 20.7071C12.5196 20.8946 12.2652 21 12 21Z"
fill="currentColor"
></path>
</svg>
</div>
</div>
</div>
</>
)
}
export default observer(MessageList)

View File

@@ -0,0 +1,165 @@
.nav_content {
display: flex;
flex-direction: column;
height: 100%;
.infinite-scroll-component {
overflow: inherit !important;
}
.logo_wrap {
position: relative;
height: 24px;
line-height: 1;
width: 62px;
overflow: hidden;
&::before {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 8px;
background: linear-gradient(90deg, #909efc 0%, #dbccf3 100%);
content: '';
}
.logo_text {
position: absolute;
z-index: 2;
top: 0;
left: 0;
font-size: 20px;
font-weight: bold;
}
}
.nav_btn {
width: 28px;
height: 28px;
background-color: #fff;
border-radius: 5px;
cursor: pointer;
color: #727b8d;
}
.new_chat_btn {
width: 100%;
margin-top: 20px;
height: 44px;
line-height: 44px;
border-radius: 10px;
border: 1px solid rgba(102, 51, 255, 0.2);
background: rgba(102, 51, 255, 0.06);
color: $primary-color;
&:hover {
background-color: $primary-color !important;
color: #fff !important;
}
}
.conversation_history {
flex: 1;
padding: 0 12px;
overflow: auto;
margin-bottom: 12px;
.time_section {
margin-bottom: 10px;
}
.time_header {
position: sticky;
top: 0;
padding: 0 8px;
line-height: 28px;
z-index: 1;
background-color: #f4f4f6;
font-weight: bold;
}
.list_item {
height: 38px;
border-radius: 4px;
padding: 0 8px;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
&:hover {
background-color: rgb(233, 228, 250);
.more_btn {
opacity: 1;
}
.hover_mask {
opacity: 1;
background: linear-gradient(
90deg,
rgba(233, 228, 250, 0) 0%,
rgb(233, 228, 250) 60%,
rgb(233, 228, 250) 100%
);
}
}
.item_text {
white-space: nowrap;
flex: 1;
min-width: 0;
line-height: 18px;
overflow: hidden;
color: #0e101a;
}
.more_btn {
opacity: 0;
z-index: 1;
border-radius: 4px;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
display: flex;
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
&:hover {
opacity: 1;
background-color: rgb(251, 251, 254);
}
}
.item_mask {
background: linear-gradient(
90deg,
rgba(233, 228, 250, 0) 0%,
#f4f4f6 50%,
#f4f4f6 100%
);
width: 24px;
content: '';
pointer-events: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
position: absolute;
top: 0;
bottom: 0;
right: 0;
}
.hover_mask {
content: '';
pointer-events: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
position: absolute;
top: 0;
bottom: 0;
right: 0;
opacity: 0;
width: 84px;
background: linear-gradient(
90deg,
rgba(233, 228, 250, 0) 0%,
rgb(233, 228, 250) 100%
);
}
}
.active {
background-color: #e3dcfd;
.item_mask {
background: #e3dcfd;
}
}
}
}

View File

@@ -0,0 +1,175 @@
import { observer, useStore } from '@/stores/utils';
import { Button, Dropdown, MenuProps, Skeleton, Modal } from 'antd';
import InfiniteScroll from 'react-infinite-scroll-component';
import './index.scss';
import { isApp } from '@/utils/tools';
interface IProps {
closeSideBarDrawer?: () => void;
}
const items: MenuProps['items'] = [
{
label: (
<div className='flex_left dangerColor'>
<i className='iconfont icon-shanchu1'></i>
<span className='ml-8'></span>
</div>
),
key: '0',
},
];
const Nav = (props: IProps) => {
const { closeSideBarDrawer } = props;
const { ChatStore } = useStore();
const [modal, ctx] = Modal.useModal();
const {
siderNavCollapsed,
setCollapsed,
conversationList,
hasMore,
onAddConversation,
onDeleteConversation,
onClickConversation,
getGroupConversationList,
conversationID,
getConversationList,
} = ChatStore;
const onDeleteClick = (conversationId: string) => {
modal.confirm({
title: '删除会话',
content: '对话记录删除后无法恢复,确认删除吗?',
onOk: () => {
onDeleteConversation(conversationId);
},
});
};
const list = getGroupConversationList(conversationList);
return (
<div className='nav_content'>
<div className='px-12 py-20'>
<div className='flex_sb'>
<div className='logo_wrap'>
<h2 className='logo_text'>AI搭子</h2>
</div>
<div
className='nav_btn flex_c'
onClick={() => {
if (isApp) {
closeSideBarDrawer && closeSideBarDrawer();
} else {
setCollapsed(!siderNavCollapsed);
}
}}
>
<svg
viewBox='0 0 1024 1024'
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
>
<path
d='M448.881 517.589l-0.083 0.083 44.691 44.691 0.083-0.083 268.062 268.062 44.691-44.691-268.062-268.062 268.062-268.062-44.691-44.691-312.754 312.754zM196.070 517.589l-0.083 0.083 44.691 44.691 0.083-0.083 268.062 268.062 44.691-44.691-268.062-268.062 268.062-268.062-44.691-44.691-312.754 312.754z'
fill='currentColor'
></path>
</svg>
</div>
</div>
<Button
className='new_chat_btn'
icon={
<svg
viewBox='0 0 1024 1024'
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
>
<path
d='M853.333333 554.666667 554.666667 554.666667l0 298.666667c0 23.466667-19.2 42.666667-42.666667 42.666667s-42.666667-19.2-42.666667-42.666667L469.333333 554.666667 170.666667 554.666667c-23.466667 0-42.666667-19.2-42.666667-42.666667 0-23.466667 19.2-42.666667 42.666667-42.666667l298.666667 0L469.333333 170.666667c0-23.466667 19.2-42.666667 42.666667-42.666667s42.666667 19.2 42.666667 42.666667l0 298.666667 298.666667 0c23.466667 0 42.666667 19.2 42.666667 42.666667C896 535.466667 876.8 554.666667 853.333333 554.666667z'
fill='currentColor'
></path>
</svg>
}
onClick={() => {
closeSideBarDrawer && closeSideBarDrawer();
onAddConversation();
}}
>
</Button>
</div>
<div
className='conversation_history'
id='scrollableDiv'
>
<InfiniteScroll
dataLength={conversationList.length}
hasMore={hasMore}
loader={
<Skeleton
title={false}
active
/>
}
next={getConversationList}
scrollableTarget='scrollableDiv'
>
{list.map((item) => {
return (
<div
className='time_section'
key={item.label}
>
<div className='time_header'>{item.label}</div>
{item.list.map((child) => {
return (
<div
className={`list_item ${
child.id === conversationID ? 'active' : ''
}`}
key={child.id}
onClick={() => {
closeSideBarDrawer && closeSideBarDrawer();
onClickConversation(child.id);
}}
>
<div className='item_text'>{child.content}</div>
<Dropdown
menu={{
items,
onClick: (e) => {
e.domEvent.stopPropagation(); // ✅ 阻止事件冒泡
onDeleteClick(child.id);
},
}}
trigger={['click']}
>
<div
className='more_btn'
onClick={(e) => {
e.stopPropagation();
}}
>
<i className='iconfont icon-gengduo1'></i>
</div>
</Dropdown>
<div className='item_mask'></div>
<div className='hover_mask'></div>
</div>
);
})}
</div>
);
})}
</InfiniteScroll>
</div>
{ctx}
</div>
);
};
export default observer(Nav);

View File

@@ -0,0 +1,70 @@
.input_container {
z-index: 7;
position: sticky;
bottom: 0;
background-color: #fff;
padding: 0 20px;
.tips {
font-size: 12px;
color: #b7b9c1;
margin: 6px 0;
line-height: 14px;
text-align: center;
}
.block_wrap {
max-width: 800px;
margin: 0 auto;
}
.send_box {
background-color: #fbfbfb;
}
.send_btn {
min-width: 28px !important;
height: 28px;
width: 28px;
}
.icon-fasong {
font-size: 14px;
}
.speech_btn {
font-size: 16px;
color: #b6b8c0;
}
.icon-icon02 {
font-size: 20px;
color: #b6b8c0;
}
.file_btn {
&:hover {
.icon-icon02 {
color: #8a5cff !important;
}
background-color: #f6f0ff !important;
}
}
.sender_header {
border-bottom: 1px solid #eaeaea;
background: #fff;
display: flex;
flex-wrap: wrap;
}
.uploading_box {
width: 68px;
height: 68px;
margin: 12px;
background-color: rgba($color: #000000, $alpha: 0.5);
border-radius: 4px;
}
.file_list {
display: flex;
flex-wrap: wrap;
padding: 12px 0 12px 12px;
gap: 12px;
.ant-image {
width: 68px;
height: 68px;
overflow: hidden;
border-radius: 4px;
}
}
}

View File

@@ -0,0 +1,160 @@
import { observer, useStore } from "@/stores/utils"
import { checkMeia, Tss } from "@/utils/tts"
import { Attachments, Sender } from "@ant-design/x"
import { FooterRender } from "@ant-design/x/es/sender"
import { Button, Divider, GetProp, message, Spin, Tooltip } from "antd"
import { useEffect, useRef, useState } from "react"
import "./index.scss"
const SenderBox = () => {
const tssRef = useRef<Tss>(null)
const { ChatStore } = useStore()
const [speech, setSpeech] = useState(false)
const {
streamingChat,
isLoading,
abortChat,
handleUploadFile,
messageContent,
setMessageContent,
scrollToBottom,
uploading,
fileArr,
onRemoveFile,
} = ChatStore
useEffect(() => {
return () => {
tssRef.current?.close()
}
}, [])
const onSubmit = (nextContent: string) => {
setSpeech(false)
tssRef.current?.close()
if (!nextContent) {
return
}
scrollToBottom()
streamingChat(nextContent)
setMessageContent("")
}
const senderHeader = (
<div className="sender_header">
{fileArr.length > 0 && (
<div className="file_list">
{fileArr.map((file) => (
<Attachments.FileCard
className="file_card"
key={file.id}
onRemove={() => onRemoveFile(file.id)}
item={{
uid: file.id,
name: file.originalName,
url: file.path,
}}
/>
))}
</div>
)}
{uploading && (
<div className="uploading_box flex_c">
<Spin size="small" spinning={uploading} />
</div>
)}
</div>
)
const handleFileChange: GetProp<typeof Attachments, "onChange"> = (info) => {
handleUploadFile(info.file)
}
const renderFooter: FooterRender = ({ components }) => {
const { SendButton, SpeechButton } = components
return (
<div className="flex_sb">
<SpeechButton className="speech_btn" />
<div className="flex_right">
<Attachments
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.xls,.xlsx,.mp3,.wav,.csv"
beforeUpload={() => false}
onChange={handleFileChange}
>
<Tooltip
title={
<div className="fs-16">
imagepdfdocxexcelcsvaudio
</div>
}
>
<Button
type="text"
className="file_btn"
icon={<i className="iconfont icon-icon02"></i>}
/>
</Tooltip>
</Attachments>
<Divider type="vertical" />
<SendButton
type="primary"
className="send_btn"
icon={<i className="iconfont icon-fasong"></i>}
disabled={messageContent && !isLoading && !uploading ? false : true}
/>
</div>
</div>
)
}
const onRecordChange = async (nextSpeech: boolean) => {
if (nextSpeech) {
try {
await checkMeia()
} catch {
message.error("获取麦克风失败")
}
tssRef.current = Tss.createInstance({
onMessage(data) {
if (!data) return
setMessageContent(messageContent + data)
},
onError(e) {
tssRef.current?.close()
},
onClose() {
tssRef.current?.close()
},
})
} else {
tssRef.current?.close()
}
setSpeech(nextSpeech)
}
return (
<div className="input_container">
<div className="block_wrap">
<Sender
className="send_box"
placeholder="发送消息..."
actions={false}
onChange={(value) => setMessageContent(value)}
value={messageContent}
onSubmit={onSubmit}
header={senderHeader}
autoSize={{ minRows: 2, maxRows: 6 }}
footer={renderFooter}
onCancel={abortChat}
allowSpeech={{
recording: speech,
onRecordingChange: onRecordChange,
}}
/>
<div className="tips"> AI </div>
</div>
</div>
)
}
export default observer(SenderBox)

View File

@@ -0,0 +1,21 @@
.sider_nav {
background: #f4f4f6;
.nav_btn {
width: 28px;
height: 28px;
background-color: #fff;
border-radius: 5px;
cursor: pointer;
color: #727b8d;
}
.sider_animation {
position: fixed;
top: 0;
bottom: 0;
width: 260px;
height: 100%;
left: 0;
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
}

View File

@@ -0,0 +1,85 @@
import { observer, useStore } from '@/stores/utils';
import { Layout } from 'antd';
import { useEffect } from 'react';
import Nav from '../Nav';
import { useMediaQuery, down } from '@/hooks';
import './index.scss';
import { isApp } from '@/utils/tools';
const { Sider } = Layout;
const SiderNav = () => {
const isTablet = useMediaQuery(down('xl'));
const { ChatStore } = useStore();
const { siderNavCollapsed, setCollapsed, onAddConversation } = ChatStore;
useEffect(() => {
if (isTablet) {
setCollapsed(true);
}
}, [isTablet]);
return (
<Sider
collapsible
width={260}
collapsedWidth={60}
trigger={null}
collapsed={siderNavCollapsed}
className='sider_nav'
>
<div
className='px-16 py-12'
style={{
opacity: siderNavCollapsed ? 1 : 0,
transition: 'all 200ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
<div
className='nav_btn flex_c'
onClick={() => setCollapsed(!siderNavCollapsed)}
>
<svg
viewBox='0 0 1024 1024'
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
>
<path
d='M575.119 506.411l0.083-0.083-44.691-44.691-0.083 0.083-268.062-268.062-44.691 44.691 268.06200001 268.062-268.06200001 268.062 44.691 44.691 312.754-312.754zM827.93 506.411l0.083-0.083-44.691-44.69100001-0.083 0.08300001-268.062-268.062-44.691 44.691 268.06199999 268.062-268.06199999 268.062 44.691 44.691 312.754-312.754z'
fill='currentColor'
></path>
</svg>
</div>
<div
className='nav_btn flex_c mt-16'
onClick={() => onAddConversation()}
>
<svg
viewBox='0 0 1024 1024'
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
>
<path
d='M853.333333 554.666667 554.666667 554.666667l0 298.666667c0 23.466667-19.2 42.666667-42.666667 42.666667s-42.666667-19.2-42.666667-42.666667L469.333333 554.666667 170.666667 554.666667c-23.466667 0-42.666667-19.2-42.666667-42.666667 0-23.466667 19.2-42.666667 42.666667-42.666667l298.666667 0L469.333333 170.666667c0-23.466667 19.2-42.666667 42.666667-42.666667s42.666667 19.2 42.666667 42.666667l0 298.666667 298.666667 0c23.466667 0 42.666667 19.2 42.666667 42.666667C896 535.466667 876.8 554.666667 853.333333 554.666667z'
fill='currentColor'
></path>
</svg>
</div>
</div>
<div
className='sider_animation'
style={{
top: isApp ? 0 : 42,
height: isApp ? '100%' : 'calc(100% - 42px)',
transform: siderNavCollapsed ? 'translateX(-100%)' : 'translateX(0)',
opacity: siderNavCollapsed ? 0 : 1,
}}
>
<Nav />
</div>
</Sider>
);
};
export default observer(SiderNav);

View File

@@ -0,0 +1,8 @@
.chat_wrap {
overflow: hidden;
background-color: #fff;
}
.client_height {
height: calc($height-primary - 42px);
}

View File

@@ -0,0 +1,21 @@
import { Layout } from "antd"
import Header from "./Header"
import SiderNav from "./SiderNav"
import Main from "./Main"
import { observer } from "@/stores/utils"
import { isApp, isMobile } from "@/utils/tools"
import "./index.scss"
const Chat = () => {
return (
<Layout
className={`chat_wrap ${
isApp || isMobile() ? "page_height" : "client_height"
}`}
>
{isApp || isMobile() ? <Header /> : <SiderNav />}
<Main />
</Layout>
)
}
export default observer(Chat)

View File

@@ -0,0 +1,12 @@
.breadcrumb {
height: 42px;
line-height: 42px;
padding: 0 16px;
background-color: #f5f7fa;
.nav_home {
cursor: pointer;
&:hover {
color: $primary-color;
}
}
}

View File

@@ -0,0 +1,59 @@
import { Breadcrumb } from 'antd';
import Chat from '../Chat';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useEffect } from 'react';
import { useStore } from '@/stores/utils';
import './index.scss';
import { isApp, isMobile } from '@/utils/tools';
const Content = () => {
const [searchParams] = useSearchParams();
const title = searchParams.get('title');
const url = searchParams.get('url');
const botId = searchParams.get('botId');
const navigate = useNavigate();
const { ChatStore } = useStore();
useEffect(() => {
// 如果有botId参数初始化聊天页面的智能体信息
if (botId) {
ChatStore.setBotId(botId);
}
}, [botId]);
return (
<div className='flex_column'>
{!isApp && !isMobile() && (
<Breadcrumb
className='breadcrumb'
separator='>'
items={[
{
title: '瑞小美 Ai 学苑',
className: 'nav_home',
onClick: () => navigate('/'),
},
{
title,
},
]}
/>
)}
<div className='flex_1'>
{url ? (
<iframe
style={{
display: 'block',
width: '100%',
height: isApp || isMobile() ? '100vh' : 'calc(100vh - 42px)',
border: 'none',
}}
src={url}
></iframe>
) : (
<Chat />
)}
</div>
</div>
);
};
export default Content;

View File

@@ -0,0 +1,59 @@
import MessageList from "@/components/MessageList"
import SenderBox from "@/components/SenderBox"
import { useEffect } from "react"
import { observer, useStore } from "@/stores/utils"
const Exam = () => {
const { ExamStore } = useStore()
const {
botInfo,
streamingChat,
messageList,
deleteMessage,
reGenerate,
chatId,
isLoading,
abortChat,
containerRef,
conversationID,
scrollToBottom,
handleUploadFile,
uploading,
getBotInfo,
fileArr,
onRemoveFile,
} = ExamStore
useEffect(() => {
getBotInfo()
}, [])
return (
<div className="flex_column page_height">
<MessageList
showUserInfo
botInfo={botInfo}
streamingChat={streamingChat}
messageList={messageList}
deleteMessage={deleteMessage}
reGenerate={reGenerate}
chatId={chatId}
isLoading={isLoading}
abortChat={abortChat}
containerRef={containerRef}
conversationID={conversationID}
scrollToBottom={scrollToBottom}
/>
<SenderBox
streamingChat={streamingChat}
isLoading={isLoading}
abortChat={abortChat}
handleUploadFile={handleUploadFile}
scrollToBottom={scrollToBottom}
uploading={uploading}
fileArr={fileArr}
onRemoveFile={onRemoveFile}
/>
</div>
)
}
export default observer(Exam)

View File

@@ -0,0 +1,221 @@
@use '@/style/functions.scss' as *;
.home_wrap {
min-height: 100vh;
background: url('@/assets/images/home_bg.jpg') top center no-repeat;
background-size: cover;
.content_box {
max-width: 1200px;
margin: 0 auto;
padding-top: 120px;
padding-bottom: 40px;
}
.title {
font-weight: bold;
font-size: 48px;
}
.text-gradient {
background-image: linear-gradient(
45deg,
#44c3e3 0%,
#4c8cff 18%,
#3646e9 68%,
#9a66e2 100%
);
-webkit-background-clip: text; /* 将背景裁剪为文本形状 */
background-clip: text;
color: transparent; /* 隐藏原始文本颜色 */
}
.menu_wrap {
margin-top: 110px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(365px, 365px));
gap: 24px;
justify-content: center;
}
.menu_item {
padding: 24px 28px;
border-radius: 24px;
background: rgba(236, 247, 250, 0.8);
box-shadow:
0px 4px 30px 1px rgba(0, 119, 155, 0.1),
inset 0px 3px 6px 1px #ffffff;
border: 1px solid #ffffff;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: space-between;
width: 365px;
height: 160px;
&:hover {
transform: translateY(-2px);
box-shadow:
0px 8px 40px 1px rgba(0, 119, 155, 0.15),
inset 0px 3px 6px 1px #ffffff;
}
.menu_left { flex: 1; min-width: 0; padding-right: 12px; }
.menu_item_title {
color: #32375a;
font-size: 20px;
font-weight: bold;
line-height: 1;
display: flex;
align-items: center;
gap: 8px;
.menu_item_title_text {
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.verify_badge { color: #28c76f; }
}
.menu_item_content {
margin-top: 12px;
font-size: 14px;
color: #7a829c;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.menu_meta { margin-top: 14px; }
.menu_tag {
display: inline-block;
padding: 4px 8px;
background: #e6f7ff;
color: #1890ff;
border-radius: 6px;
font-size: 12px;
}
.menu_right { flex: none; }
.menu_avatar {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
&.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(45deg, #6633ff, #9966ff);
color: #fff;
font-weight: bold;
}
}
}
// 测试工具区域样式
.test_tools_section {
margin-top: 60px;
.section_title {
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 20px;
text-align: center;
}
.test_tools_grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.test_tool_item {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
cursor: pointer;
transition: all 0.3s ease;
color: white;
display: flex;
align-items: center;
gap: 16px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(102, 126, 234, 0.4);
}
.test_tool_icon {
font-size: 32px;
flex-shrink: 0;
}
.test_tool_info {
flex: 1;
.test_tool_title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.test_tool_desc {
font-size: 14px;
opacity: 0.9;
line-height: 1.4;
}
}
}
}
.empty_state {
text-align: center;
margin-top: 60px;
color: #7a829c;
font-size: 18px;
}
}
/* 自适应网格无需额外断点,以下保留标题字号与容器宽度的渐进优化 */
@media (max-width: 1280px) {
.home_wrap .content_box { max-width: 1000px; }
}
@media (max-width: 1080px) {
.home_wrap .content_box { max-width: 860px; }
}
.layout_mobile {
.content_box {
max-width: 100%;
padding: 0 px2vw(40);
padding-top: px2vw(42);
padding-bottom: px2vw(40);
}
.title {
font-size: px2vw(48);
text-align: center;
}
.menu_wrap {
grid-template-columns: 1fr;
margin-top: px2vw(60);
gap: px2vw(20);
}
.menu_item {
width: 100%;
border-radius: px2vw(32);
padding: px2vw(32);
.menu_icon {
width: px2vw(72);
height: px2vw(72);
}
.menu_info {
margin-left: px2vw(32);
}
.menu_item_title {
font-size: px2vw(28);
}
.menu_item_content {
margin-top: px2vw(16);
font-size: px2vw(24);
}
}
}

View File

@@ -0,0 +1,110 @@
import { useNavigate } from "react-router-dom"
import { isElectron, isApp, getParams } from "@/utils/tools"
import "./index.scss"
import { useEffect } from "react"
import { down, useMediaQuery } from "@/hooks"
import { useStore, observer } from "@/stores/utils"
import { Spin, message } from "antd"
import { CheckCircleFilled } from "@ant-design/icons"
/**
* 首页 - 智能体清单
* 负责渲染智能体卡片列表,并处理跳转到聊天页面。
*/
const Home = () => {
const { token, apiUrl } = getParams()
const navigate = useNavigate()
const mobileOrTablet = useMediaQuery(down("md"))
const { BotStore } = useStore()
const { bots, loading, error, loadBots } = BotStore
useEffect(() => {
loadBots()
}, [])
useEffect(() => {
if (error) {
message.error(error)
}
}, [error])
/**
* 卡片点击事件:跳转至内容页,并携带智能体标识
*/
const onBotClick = (bot: any) => {
navigate(`/content?title=${bot.name}&botId=${bot.id}`)
}
if (loading) {
return (
<div className="flex_c h_full">
<Spin size="large" />
<div className="mt-16">...</div>
</div>
)
}
return (
<div
className={`home_wrap ${isApp || mobileOrTablet ? "layout_mobile" : ""}`}
style={isApp ? { paddingTop: 0 } : {}}
>
<div className="content_box">
<h1 className="title">
<span className="text-gradient"></span>
</h1>
<div className="menu_wrap">
{bots.map((bot) => (
<div
key={bot.id}
className="menu_item"
onClick={() => onBotClick(bot)}
>
<div className="menu_left">
<div className="menu_item_title">
<span className="menu_item_title_text">{bot.name}</span>
<CheckCircleFilled className="verify_badge" />
</div>
<div className="menu_item_content" title={bot.description || "暂无描述"}>
{bot.description || "暂无描述"}
</div>
<div className="menu_meta">
<span className="menu_tag"></span>
</div>
</div>
<div className="menu_right">
{bot.icon_url ? (
<img className="menu_avatar" src={bot.icon_url} alt={bot.name} />
) : (
<div className="menu_avatar placeholder">{bot.name.charAt(0)}</div>
)}
</div>
</div>
))}
</div>
{/* 测试工具区域 */}
<div className="test_tools_section">
<h2 className="section_title">🧪 </h2>
<div className="test_tools_grid">
<div className="test_tool_item" onClick={() => navigate('/audio-test')}>
<div className="test_tool_icon">🎙</div>
<div className="test_tool_info">
<div className="test_tool_title"></div>
<div className="test_tool_desc"> Coze </div>
</div>
</div>
</div>
</div>
{bots.length === 0 && (
<div className="empty_state">
<p></p>
</div>
)}
</div>
</div>
)
}
export default observer(Home)

View File

@@ -0,0 +1,58 @@
import MessageList from "@/components/MessageList"
import SenderBox from "@/components/SenderBox"
import { useEffect } from "react"
import { observer, useStore } from "@/stores/utils"
const NewChat = () => {
const { NewChatStore } = useStore()
const {
getBotInfo,
botInfo,
streamingChat,
messageList,
deleteMessage,
reGenerate,
chatId,
isLoading,
abortChat,
containerRef,
conversationID,
scrollToBottom,
handleUploadFile,
uploading,
fileArr,
onRemoveFile,
} = NewChatStore
useEffect(() => {
getBotInfo()
}, [])
return (
<div className="flex_column page_height">
<MessageList
showPlaceholderNode
botInfo={botInfo}
streamingChat={streamingChat}
messageList={messageList}
deleteMessage={deleteMessage}
reGenerate={reGenerate}
chatId={chatId}
isLoading={isLoading}
abortChat={abortChat}
containerRef={containerRef}
conversationID={conversationID}
scrollToBottom={scrollToBottom}
/>
<SenderBox
streamingChat={streamingChat}
isLoading={isLoading}
abortChat={abortChat}
handleUploadFile={handleUploadFile}
scrollToBottom={scrollToBottom}
uploading={uploading}
fileArr={fileArr}
onRemoveFile={onRemoveFile}
/>
</div>
)
}
export default observer(NewChat)

View File

@@ -0,0 +1,16 @@
.voice_btn {
position: absolute;
right: 24px;
top: 24px;
background: #fff;
border: 0.5px solid rgba(29, 28, 35, 0.08);
border-radius: 8px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.04), 0 0 1px 0 rgba(0, 0, 0, 0.08);
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 99;
}

View File

@@ -0,0 +1,67 @@
import MessageList from "@/components/MessageList"
import SenderBox from "@/components/SenderBox"
import { observer, useStore } from "@/stores/utils"
import "./TextChat.scss"
const TextChat = () => {
const { TrainingStore } = useStore()
const {
botInfo,
streamingChat,
messageList,
deleteMessage,
reGenerate,
chatId,
isLoading,
abortChat,
containerRef,
conversationID,
scrollToBottom,
handleUploadFile,
uploading,
setChatModel,
fileArr,
onRemoveFile,
} = TrainingStore
return (
<div className="flex_column page_height">
<div className="voice_btn" onClick={() => setChatModel(0)}>
<svg
width="20"
height="20"
viewBox="0 0 24 25"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
color="#000"
>
<path d="M16.3409 15.3738C15.6151 15.9482 14.2871 16.4669 12.9548 15.7411C12.1384 15.2964 11.2851 14.5722 10.4188 13.706C9.54958 12.8367 8.82283 11.98 8.37828 11.1601C7.66179 9.83851 8.1692 8.52217 8.73195 7.79906L8.94692 7.52283L7.2713 3.61341L3.75409 5.06167C3.90337 7.82368 4.68021 11.6443 8.58037 15.5444C12.4805 19.4446 16.3011 20.2214 19.0631 20.3707L20.5121 16.8518L16.5983 15.1701L16.3409 15.3738ZM2.95993 3.22576L6.50981 1.76405C7.52085 1.34774 8.67882 1.82053 9.10957 2.82551L10.7852 6.73494C11.072 7.40421 10.9725 8.17652 10.5253 8.75116L10.3103 9.0274C10.0425 9.37154 9.92866 9.82346 10.1365 10.2068C10.4431 10.7723 11.0086 11.4673 11.8331 12.2917C12.6543 13.113 13.3472 13.6774 13.9115 13.9848C14.2982 14.1954 14.7545 14.0788 15.0998 13.8055L15.3571 13.6018C15.9332 13.1459 16.7129 13.0426 17.3878 13.3326L21.3016 15.0143C22.3056 15.4457 22.7775 16.6029 22.3614 17.6133L20.899 21.1649C20.5905 21.9143 19.8606 22.413 19.0511 22.3728C15.9349 22.2178 11.5505 21.343 7.16616 16.9586C2.7818 12.5743 1.90704 8.18993 1.75203 5.07369C1.71176 4.26424 2.21052 3.53434 2.95993 3.22576Z"></path>
</svg>
</div>
<MessageList
showUserInfo
botInfo={botInfo}
streamingChat={streamingChat}
messageList={messageList}
deleteMessage={deleteMessage}
reGenerate={reGenerate}
chatId={chatId}
isLoading={isLoading}
abortChat={abortChat}
containerRef={containerRef}
conversationID={conversationID}
scrollToBottom={scrollToBottom}
/>
<SenderBox
streamingChat={streamingChat}
isLoading={isLoading}
abortChat={abortChat}
handleUploadFile={handleUploadFile}
scrollToBottom={scrollToBottom}
uploading={uploading}
fileArr={fileArr}
onRemoveFile={onRemoveFile}
/>
</div>
)
}
export default observer(TextChat)

View File

@@ -0,0 +1,286 @@
.voice_wrapper {
background: url("@/assets/images/training_logo.png") top center no-repeat;
overflow: hidden;
.blur_bg {
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
-webkit-backdrop-filter: blur(60px);
backdrop-filter: blur(60px);
background-color: rgba(244, 244, 246, 0.8);
}
.voice_chat_wrapper {
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
padding-bottom: 80px;
}
.chat_header {
position: relative;
width: 100%;
.avatar_container {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
margin-top: 70px;
width: 100%;
}
.logo_wrap {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
width: 140px;
height: 140px;
background-image: url("@/assets/images/training_logo.png");
background-position: 50%;
background-repeat: no-repeat;
background-size: cover;
border-radius: 36px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.25);
}
.title {
color: rgba(6, 7, 9, 0.96);
font-size: 20px;
font-weight: 500;
line-height: 28px;
margin-bottom: 16px;
margin-top: 12px;
max-width: 40%;
min-width: 180px;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.content_flag {
position: absolute;
top: 8px;
right: 8px;
.button_flag {
border: none;
}
}
.play_loading_wrap {
position: absolute;
top: -28px;
right: -42px;
width: 82px;
height: 58px;
background: #fff;
border: 1px solid rgba(153, 182, 255, 0.12);
border-radius: 36px;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25);
}
.play_loading {
display: flex;
gap: 4px;
align-items: center;
}
.play_loading_dot {
width: 8px;
height: 8px;
background-color: #7a70eb;
border-radius: 4px;
animation: bounce 0.8s infinite ease-in-out;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
@keyframes bounce {
0%,
100% {
height: 8px;
opacity: 0.6;
}
50% {
height: 16px;
opacity: 1;
}
}
}
.chat_content {
-webkit-mask: linear-gradient(
hsla(0, 0%, 100%, 0),
#fff 10%,
#fff 90%,
hsla(0, 0%, 100%, 0)
);
mask: linear-gradient(
hsla(0, 0%, 100%, 0),
#fff 10%,
#fff 90%,
hsla(0, 0%, 100%, 0)
);
font-size: 14px;
padding: 24px 24px 8px;
overflow-y: auto;
max-width: 900px;
width: 100%;
flex: 1;
.ant-list-item {
border: none;
}
.message_item_wrap {
text-align: left;
padding: 8px 16px;
}
.message_item {
padding: 8px 12px;
border-radius: 8px;
background-color: #fff;
display: inline-block;
color: rgba(0, 0, 0, 0.88);
}
.user_item {
background-color: #c8c4f6;
}
}
.chat_info {
align-items: flex-end;
display: flex;
height: 70px;
justify-content: center;
margin-bottom: 16px;
width: 100%;
position: relative;
.tips {
font-size: 14px;
color: rgba(6, 7, 9, 0.5);
}
.listening {
display: flex;
flex-direction: column;
align-items: center;
}
.listen_dots {
margin-bottom: 20px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #6c6a6f;
margin-right: 4px;
&:last-child {
margin-right: 0;
}
}
.retry_btn {
cursor: pointer;
color: $primary-color;
}
}
.button_wrapper {
align-items: center;
display: flex;
flex-wrap: nowrap;
justify-content: center;
.button_block {
position: relative;
height: 64px;
width: 64px;
}
.button_hangup {
border-radius: 100%;
font-size: 24px;
height: 64px;
width: 64px;
color: #e53241;
box-shadow: 0 4px 12px 0 rgba(#000, 0.08), 0 8px 24px 0 rgba(#000, 0.04);
border: none;
&:hover {
background-color: rgb(233, 235, 242);
color: #e53241;
}
}
.button_text {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
margin-top: 12px;
position: absolute;
width: 100%;
font-size: 14px;
}
.button_answer_wrapper {
position: relative;
margin-left: 110px;
.button_answer_box {
height: 64px;
position: relative;
width: 64px;
}
.cycle {
animation: diffusion 3s cubic-bezier(0.32, 0.94, 0.6, 1) infinite;
background-image: linear-gradient(#3ec254, rgba(62, 194, 84, 0));
border-radius: 50%;
height: 64px;
position: absolute;
width: 64px;
}
.cycle1 {
animation-delay: 0.6s;
}
.cycle2 {
animation-delay: 1.2s;
}
.cycle3 {
animation-delay: 1.8s;
}
.button_answer {
animation: rotation 0.2s cubic-bezier(0.4, 0.8, 0.74, 1) infinite;
border-radius: 100%;
font-size: 24px;
height: 64px;
position: absolute;
width: 64px;
z-index: 2;
-webkit-tap-highlight-color: transparent;
background-color: #3ec254;
border: none;
color: #fff;
}
}
.btn_loading {
animation: animation-rotate 0.6s linear infinite;
animation-fill-mode: forwards;
}
}
@keyframes diffusion {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(2);
}
}
@keyframes animation-rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(1turn);
}
}
}

View File

@@ -0,0 +1,224 @@
import { useStore, observer } from "@/stores/utils"
import "./VoiceChat.scss"
import { EStatus } from "@/stores/TrainingStore"
import { Avatar, Button, List } from "antd"
import user from "@/assets/images/user.jpg"
const VoiceChat = () => {
const { TrainingStore } = useStore()
const {
setChatModel,
status,
botInfo,
messageList,
handleDisconnect,
handleConnect,
shwMessageList,
setShowContent,
handleInterrupt,
} = TrainingStore
const list = messageList.slice().reverse()
return (
<div className="voice_wrapper page_height">
<div className="blur_bg" />
<div className="voice_chat_wrapper">
<div className="chat_header">
<div className="avatar_container">
<div className="logo_wrap">
<div className="logo"></div>
<div className="title">{botInfo?.name}</div>
{status === EStatus.waiting && (
<div className="play_loading_wrap flex_c">
<div className="play_loading flex_left">
<div className="play_loading_dot"></div>
<div className="play_loading_dot"></div>
<div className="play_loading_dot"></div>
<div className="play_loading_dot"></div>
</div>
</div>
)}
</div>
</div>
<div className="content_flag">
<Button
type="default"
className="button_flag"
onClick={setShowContent}
>
({shwMessageList ? "开" : "关"})
</Button>
</div>
</div>
<div className="chat_content">
{shwMessageList && messageList.length > 0 && (
<List
dataSource={list}
renderItem={(message) => {
if (message.prologue) return null
return (
<List.Item key={message.id} className="message_item_wrap">
<List.Item.Meta
avatar={
message.role === "user" ? (
<Avatar size="large" src={user} />
) : (
<Avatar size="large" src={botInfo?.icon_url} />
)
}
title={
message.role === "user" ? (
<div className="fs-12 secondaryTextColor">user</div>
) : (
<div className="fs-12 secondaryTextColor">
{botInfo?.name}
</div>
)
}
description={
<div
className={`message_item ${
message.role === "user" ? "user_item" : ""
}`}
dangerouslySetInnerHTML={{
__html: message.content.replace(/\n/g, "<br />"),
}}
/>
}
/>
</List.Item>
)
}}
/>
)}
</div>
<div className="chat_info">
{status === EStatus.connecting && (
<div className="tips">{status}</div>
)}
{status === EStatus.error && (
<div className="tips flex_left">
<svg
className="mr-6"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
color="rgba(204, 20, 36, 1)"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8ZM11 16C11 15.4477 11.4477 15 12 15C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z"
></path>
</svg>
{" "}
<span className="ml-6 retry_btn" onClick={handleConnect}>
</span>
</div>
)}
{status === EStatus.listening && (
<div className="tips listening">
<div className="listen_dots flex_left">
<div className="dot"></div>
<div className="dot"></div>
<div className="dot"></div>
<div className="dot"></div>
</div>
<div></div>
</div>
)}
{status === EStatus.waiting && (
<div className="tips listening csp" onClick={handleInterrupt}>
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
fontSize="26"
color="rgba(6, 7, 9, 0.3)"
>
<path d="M7 6C7 3.23858 9.23858 1 12 1C14.7614 1 17 3.23858 17 6V11C17 13.7614 14.7614 16 12 16C9.23858 16 7 13.7614 7 11V6Z"></path>
<path d="M20 9C19.4477 9 19 9.44771 19 10V11C19 14.866 15.866 18 12 18C8.13401 18 5 14.866 5 11V10C5 9.44772 4.55228 9 4 9C3.44772 9 3 9.44771 3 10V11C3 15.6326 6.50005 19.4476 11 19.9451V22.5C11 23.0523 11.4477 23.5 12 23.5C12.5523 23.5 13 23.0523 13 22.5V19.9451C17.5 19.4476 21 15.6326 21 11V10C21 9.44772 20.5523 9 20 9Z"></path>
</svg>
<div className="mt-12"></div>
</div>
)}
</div>
<div className="button_wrapper">
<div className="button_block">
<Button
className="button_hangup flex_c"
onClick={() => {
setChatModel(1)
handleDisconnect()
}}
>
<svg
width="1em"
height="1em"
viewBox="0 0 25 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.0298 4.44002C18.6393 4.04949 18.0062 4.04949 17.6156 4.44002L12.1769 9.87875L6.73819 4.44003C6.34766 4.04951 5.7145 4.04951 5.32397 4.44003L4.61687 5.14714C4.22634 5.53766 4.22634 6.17083 4.61687 6.56135L10.0556 12.0001L4.6169 17.4388C4.22637 17.8293 4.22637 18.4624 4.6169 18.853L5.324 19.5601C5.71453 19.9506 6.34769 19.9506 6.73822 19.5601L12.1769 14.1214L17.6156 19.5601C18.0061 19.9506 18.6393 19.9506 19.0298 19.5601L19.7369 18.853C20.1274 18.4625 20.1274 17.8293 19.7369 17.4388L14.2982 12.0001L19.737 6.56134C20.1275 6.17081 20.1275 5.53765 19.737 5.14713L19.0298 4.44002Z"></path>
</svg>
</Button>
<div className="button_text"></div>
</div>
{status !== EStatus.error && status !== EStatus.connected && (
<>
{status === EStatus.unconnected && (
<div className="button_answer_wrapper">
<div className="button_answer_box">
<div className="cycle cycle1"></div>
<div className="cycle cycle2"></div>
<div className="cycle cycle3"></div>
<Button
className="button_answer flex_c"
onClick={handleConnect}
>
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.41628 16.6662C11.7847 21.0346 16.1532 21.9189 19.2673 22.0786C20.0937 22.121 20.8352 21.6013 21.1323 20.8289L21.914 18.7964C22.2912 17.8158 21.8452 16.7105 20.8931 16.2661L17.7694 14.8084C17.1365 14.513 16.3953 14.5693 15.8142 14.9567L14.9336 15.5437C14.6026 15.7644 14.1939 15.8577 13.8128 15.7437C13.0836 15.5255 11.9885 15.0159 10.5276 13.5549C9.06662 12.094 8.55694 10.9989 8.33881 10.2697C8.2248 9.88858 8.31808 9.47988 8.53875 9.14887L9.12581 8.26828C9.51323 7.68715 9.56943 6.94601 9.27407 6.31311L7.81635 3.18942C7.37203 2.23729 6.2667 1.79132 5.28603 2.1685L3.2536 2.9502C2.48122 3.24727 1.9615 3.98876 2.00388 4.81522C2.16357 7.9293 3.04783 12.2977 7.41628 16.6662Z"></path>
</svg>
</Button>
</div>
<div className="button_text"></div>
</div>
)}
{status === EStatus.connecting && (
<div className="button_answer_wrapper">
<div className="button_answer_box">
<Button className="button_answer flex_c">
<svg
className="btn_loading"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23C18.0751 23 23 18.0751 23 12C23 8.96243 21.7688 6.21243 19.7782 4.22182C19.3876 3.8313 18.7545 3.83134 18.3639 4.22186C17.9734 4.61239 17.9734 5.24555 18.3639 5.63608L18.3703 5.64239C19.9953 7.27056 21 9.51795 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02999 7.02853 3.00091 11.9983 3C11.9989 3 11.9994 3 12 3C12.5523 3 13 2.55228 13 2C13 1.44772 12.5523 1 12 1C12 1 12 1 12 1Z"></path>
</svg>
</Button>
</div>
<div className="button_text"></div>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}
export default observer(VoiceChat)

View File

@@ -0,0 +1,19 @@
import { useStore, observer } from "@/stores/utils"
import VoiceChat from "./VoiceChat"
import TextChat from "./TextChat"
import { EChatModel } from "@/stores/TrainingStore"
import { useEffect } from "react"
const Training = () => {
const { TrainingStore } = useStore()
const { chatModel, getBotInfo } = TrainingStore
useEffect(() => {
getBotInfo()
}, [])
return (
<div className="training_wrapper">
{chatModel === EChatModel.VOICE ? <VoiceChat /> : <TextChat />}
</div>
)
}
export default observer(Training)

View File

@@ -0,0 +1,77 @@
import { get, post } from "@/utils/request"
import { EModalType } from "./type"
/**
* 获取智能体配置
*/
export const getOnlineInfo = (modelEnum: EModalType) => {
return get(`/agent/v1/cozechat/get-online-info`, { modelEnum })
}
/**
* 发送消息
*/
export const startChat = (
data: {
content: string
conversationId: string
fileIds?: string[]
id?: string
modelEnum: EModalType
historyMessages?: any[]
userId: string
},
signal: AbortSignal
) => {
return post(`/agent/v1/cozechat/create-chat-stream`, data, {
signal: signal,
headers: {
Accept: "text/event-stream",
},
responseType: "stream",
adapter: "fetch", // 使用 Fetch 适配器
})
}
/**
* 中断对话
* @param data
* @returns
*/
export const interruptChat = (data: {
conversationId: string
chatId: string
modelEnum: EModalType
}) => {
return post(`/agent/v1/cozechat/interrupt-chat`, data)
}
/**
* 删除消息
* @param id
* @returns
*/
export const deleteMessage = (id: string) => {
return get(`/agent/v1/cozechat/delete-chat-record`, { id })
}
/**
* 上传文件
* @param data
* @returns
*/
export const uploadFile = (data: any) => {
return post("/agent/v1/cozechat/upload-file", data, {
headers: {
"Content-Type": "multipart/form-data",
},
})
}
/**
* 获取智能体token
* @param modelEnum
* @returns
*/
export const getBotToken = (modelEnum: EModalType) => {
return get(`/agent/v1/cozechat/get-token`, { modelEnum })
}

View File

@@ -0,0 +1,77 @@
import { get, post } from "@/utils/request"
import { getApiUrl } from "@/utils/tools"
/**
* 获取工作空间内的所有智能体
*/
export const getBots = () => {
return get(`/api/bots`)
}
/**
* 获取智能体详细信息
*/
export const getBotInfo = (botId: string) => {
return get(`/api/bots/${botId}`)
}
/**
* 创建新对话
*/
export const createConversation = (data: { bot_id: string }) => {
return post(`/api/conversations`, data)
}
/**
* 发送消息 - 流式聊天
*/
export const startChatStream = (
data: {
content: string
bot_id: string
conversation_id?: string
user_id?: string
file_ids?: string[]
history_messages?: any[]
},
signal: AbortSignal
) => {
const url = `${getApiUrl()}/api/chat/stream`
// 直接使用原生 fetch 获取 ReadableStream避免 axios 拦截器影响 SSE
return fetch(url, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(data),
}).then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
// 确保返回 ReadableStream
if (!res.body) {
throw new Error("Response body is null")
}
return res.body
})
}
/**
* 中断对话
*/
export const interruptChat = (data: {
chat_id: string
conversation_id: string
}) => {
return post(`/api/chat/interrupt`, data)
}
/**
* 删除对话
*/
export const deleteConversation = (conversationId: string) => {
return fetch(`/api/conversations/${conversationId}`, { method: "DELETE" })
.then(res => res.json())
}

View File

@@ -0,0 +1,85 @@
import { get, post } from '@/utils/request';
/**
* 获取智能体配置
*/
export const getOnlineInfo = () => {
return get(`/v1/cozechat/get-online-info`);
};
/**
* 获取会话列表
*/
export const getConversationList = (data: { page: number; size: number }) => {
return get(`/v1/cozechat/get-user-conversations`, data);
};
/**
* 获取指定会话的消息列表
*/
export const getMessageList = (data: {
conversationId: string;
page: number;
size: number;
}) => {
return get(`/v1/cozechat/get-user-chat-record`, data);
};
/**
* 发送消息
*/
export const startChat = (
data: {
content: string;
conversationId: string;
fileUrl?: string[];
id?: string;
},
signal: AbortSignal,
) => {
return post(`/v1/cozechat/create-chat-stream`, data, {
signal: signal,
headers: {
Accept: 'text/event-stream',
},
responseType: 'stream',
adapter: 'fetch', // 使用 Fetch 适配器
});
};
/**
* 删除会话
* @param conversationId
* @returns
*/
export const deleteConversation = (conversationId: string) => {
return get(`/v1/cozechat/delete-conversation`, { conversationId });
};
/**
* 中断对话
* @param data
* @returns
*/
export const interruptChat = (data: {
conversationId: string;
chatId: string;
}) => {
return post(`/v1/cozechat/interrupt-chat`, data);
};
/**
* 删除消息
* @param id
* @returns
*/
export const deleteMessage = (id: string) => {
return get(`/v1/cozechat/delete-chat-record`, { id });
};
export const uploadFile = (data: any) => {
return post('/v1/file_upload/upload_file', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};

View File

@@ -0,0 +1,14 @@
export enum EModalType {
/**
* @GENERATE_REPLY 高情商回复
*/
GENERATE_REPLY = "GENERATE_REPLY",
/**
* @CONSULTANT_PRACTICE 咨询师陪练
*/
CONSULTANT_PRACTICE = "CONSULTANT_PRACTICE",
/**
* @DYNAMIC_QUESTION 动态考题
*/
DYNAMIC_QUESTION = "DYNAMIC_QUESTION",
}

View File

@@ -0,0 +1,68 @@
import { makeAutoObservable, runInAction } from "mobx"
import { getBots } from "@/server/api"
interface Bot {
id: string
name: string
description: string
icon_url: string
created_time?: string
updated_time?: string
}
class BotStore {
bots: Bot[] = []
loading = false
error: string | null = null
constructor() {
makeAutoObservable(this)
}
/**
* 加载工作空间内的所有智能体
*/
loadBots = async () => {
try {
runInAction(() => {
this.loading = true
this.error = null
})
const response = await getBots()
runInAction(() => {
if (response.code === 10000) {
this.bots = response.data || []
} else {
this.error = response.msg || "获取智能体列表失败"
}
this.loading = false
})
} catch (error) {
runInAction(() => {
this.error = "网络请求失败"
this.loading = false
})
console.error("加载智能体失败:", error)
}
}
/**
* 根据ID查找智能体
*/
getBotById = (botId: string): Bot | undefined => {
return this.bots.find(bot => bot.id === botId)
}
/**
* 重置状态
*/
reset = () => {
this.bots = []
this.loading = false
this.error = null
}
}
export default new BotStore()

View File

@@ -0,0 +1,534 @@
import { makeAutoObservable, runInAction } from "mobx"
import {
getBotInfo,
createConversation,
startChatStream,
deleteConversation,
interruptChat,
} from "@/server/api"
import { XStream } from "@ant-design/x"
import dayjs from "dayjs"
import { uid } from "uid"
import { BubbleListRef } from "@ant-design/x/es/bubble/BubbleList"
import { createRef } from "react"
interface IConversation {
content: string
createDate: number
id: string
}
interface IBotInfo {
name: string
description: string
suggestedQuestions: string[]
}
type GroupedResult = { label: string; list: IConversation[] }[]
class ChatStore {
siderNavCollapsed = false
botInfo: IBotInfo | null = null
currentBotId: string = ""
conversationID = ""
conversationList: IConversation[] = []
hasMore = true
fileId = ""
isLoading = false
page = 1
messageList: any[] = []
chatId = ""
controller: AbortController | null = null
uploading = false
fileArr: any[] = []
messageContent = ""
msgPage = 1
msgHasMore = true
msgLoading = false
containerRef = createRef<BubbleListRef>()
constructor() {
makeAutoObservable(this) // 自动将字段和方法转为响应式
}
setBotId = (botId: string) => {
this.currentBotId = botId
this.getBotInfo()
}
getBotInfo = async () => {
if (!this.currentBotId) return
try {
const res = await getBotInfo(this.currentBotId)
runInAction(() => {
if (res.code === 10000) {
this.botInfo = {
...res.data.bot,
suggestedQuestions:
res.data.bot.onboarding_info?.suggested_questions || [],
}
// 重置对话列表状态
this.page = 1
this.hasMore = true
this.conversationList = []
// 加载对话历史
this.getConversationList()
}
})
} catch (error) {
console.error("获取智能体信息失败:", error)
}
}
onAddConversation = async () => {
if (this.isLoading) {
await this.abortChat()
}
runInAction(() => {
this.msgPage = 1
this.msgHasMore = true
this.messageList = []
this.messageContent = ""
this.conversationID = ""
this.fileArr = []
localStorage.removeItem("conversation_id")
})
}
streamingChat = (query: string) => {
if (this.isLoading) {
return
}
runInAction(() => {
this.messageList.unshift({
id: uid(32),
role: "user",
content: query,
file_url: this.fileArr.map((item) => item.path),
})
this.messageList.unshift({
id: uid(32),
role: "assistant",
content: "",
loading: true,
is_answer: 1,
})
})
this.creatChat(query)
}
// 创建一个辅助函数来更新消息内容
updateMessageContent = (content: string) => {
runInAction(() => {
if (this.messageList.length > 0 && this.messageList[0].role === "assistant") {
// 更新消息内容保持与ExamStore一致的逻辑
this.messageList[0] = {
...this.messageList[0],
content: content,
loading: false,
}
}
})
}
creatChat = async (query: string, id?: string) => {
if (!this.currentBotId) {
throw new Error("未选择智能体")
}
this.controller = new AbortController()
const fileIds = this.fileArr.length
? this.fileArr.map((item) => item.id)
: []
try {
runInAction(() => {
this.isLoading = true
this.fileArr = []
})
// 如果没有对话ID先创建对话
if (!this.conversationID) {
const conversationRes = await createConversation({ bot_id: this.currentBotId })
if (conversationRes.code === 10000) {
this.conversationID = conversationRes.data.id
}
}
const response = await startChatStream(
{
content: query,
bot_id: this.currentBotId,
conversation_id: this.conversationID,
user_id: "default_user",
file_ids: fileIds,
},
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" ||
eventName === "conversation.chat.completed" ||
eventName === "conversation.chat.failed"
) {
part.data = JSON.parse(part.data)
}
console.log("🔄 SSE Event received:", `"${part.event}"`, "->", `"${eventName}"`, part.data)
runInAction(() => {
if (eventName === "conversation.chat.created") {
if (!this.conversationID) {
const newConversation = {
id: part.data.conversation_id,
content: query,
createDate: dayjs().valueOf(),
}
this.conversationList.unshift(newConversation)
// 保存到本地存储
if (this.currentBotId) {
const savedConversations = localStorage.getItem(`conversations_${this.currentBotId}`)
const conversations = savedConversations ? JSON.parse(savedConversations) : []
conversations.unshift(newConversation)
// 只保留最近50个对话
if (conversations.length > 50) {
conversations.splice(50)
}
localStorage.setItem(`conversations_${this.currentBotId}`, JSON.stringify(conversations))
}
}
this.conversationID = part.data.conversation_id
localStorage.setItem("conversation_id", part.data.conversation_id)
} else if (eventName === "conversation.message.delta") {
currentContent += part.data.content
chatId = part.data.chat_id
console.log("delta content length:", currentContent.length)
// 流式输出:更新内容
this.updateMessageContent(currentContent)
if (chatId && this.chatId !== chatId) {
this.chatId = chatId
}
} else if (eventName === "conversation.message.completed") {
// 处理答题卡消息
if (part.data.content_type === "card") {
console.log("🃏 ChatStore收到卡片消息开始解析...")
console.log("📋 原始卡片内容:", part.data.content)
try {
const cardInfo = JSON.parse(part.data.content)
console.log("📋 解析后的卡片信息:", cardInfo)
let cardData = null
// 尝试多种解析方式
if (cardInfo.x_properties && cardInfo.x_properties.workflow_card_info) {
// 睿美云格式:从 workflow_card_info 中解析
const workflowData = JSON.parse(cardInfo.x_properties.workflow_card_info)
cardData = workflowData.question_card_data || workflowData
} else if (cardInfo.card_content) {
// 直接包含 card_content
cardData = cardInfo.card_content
} else if (cardInfo.Title && cardInfo.Options) {
// 直接就是卡片数据格式
cardData = cardInfo
} else if (cardInfo.card_type) {
// 新的卡片格式包含card_type和template_url
console.log("🆕 检测到新的卡片格式,尝试获取完整数据...")
// 如果有template_url可能需要进一步解析
if (cardInfo.template_url) {
// 创建一个模拟的答题卡数据用于测试
cardData = {
Title: "请选择你需要的考试类型:",
Options: [
{ name: "超声炮基础方案" },
{ name: "激光美容技术" },
{ name: "微整形注射" },
{ name: "皮肤护理基础" }
]
}
console.log("🎯 使用模拟答题卡数据:", cardData)
}
} else {
// 尝试从其他可能的字段解析
cardData = cardInfo
}
console.log("🎯 最终解析的卡片数据:", cardData)
if (cardData && cardData.Title && cardData.Options) {
this.messageList[0].content_type = part.data.content_type
this.messageList[0].card_content = cardData
this.messageList[0].is_answer = 0
this.messageList[0].loading = false
console.log("✅ ChatStore卡片设置完成")
} else {
console.warn("⚠️ ChatStore无法解析卡片数据结构保持原始内容")
// 保持原始内容但停止加载
this.messageList[0].loading = false
}
} catch (e) {
console.error("❌ ChatStore卡片解析失败:", e, "原始数据:", part.data.content)
// 即使解析失败,也要停止加载状态
this.messageList[0].loading = false
}
} else {
// 处理普通文本消息
const contentType = part.data?.content_type
const completedContent = part.data?.content || ""
// 过滤掉不需要显示的消息类型,只显示真正的回答
const readableTypes = ["answer", "text"]
if (completedContent && readableTypes.includes(contentType)) {
console.log("completed content length:", completedContent.length, "type:", contentType)
// 完成消息
this.updateMessageContent(completedContent)
}
}
chatId = part.data.chat_id
if (chatId && this.chatId !== chatId) {
this.chatId = chatId
}
} else if (eventName === "conversation.chat.completed") {
// 对话完成,停止加载状态
this.complateChat()
} else if (eventName === "conversation.chat.failed") {
// 对话失败,显示错误
console.error("Chat failed:", part.data)
this.updateMessageContent("抱歉,请求出错了,请重试。")
this.complateChat()
}
})
}
} catch (error) {
console.error("Error during chat stream:", error)
if ((error as Error).name === "CanceledError") {
runInAction(() => {
if (this.messageList[0] && this.messageList[0].role === "assistant") {
// 如果内容为空,添加中断消息
if (!this.messageList[0].content) {
this.messageList[0].content = "会话已中断。"
}
this.messageList[0] = {
...this.messageList[0],
loading: false,
}
}
})
} else {
// 只有在真正的网络错误时才显示错误消息
runInAction(() => {
if (this.messageList[0] && 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
}
}
setCollapsed = (collapsed: boolean) => {
this.siderNavCollapsed = collapsed
}
getConversationList = async () => {
if (!this.hasMore || !this.currentBotId) return
try {
// 调用后端API获取对话列表
const { getConversationList: apiGetConversationList } = await import("@/server/global")
const response = await apiGetConversationList({
page: this.page,
size: 20,
modelEnum: this.getModelEnumByBotId(this.currentBotId)
})
if (response.code === 10000 && response.data) {
runInAction(() => {
if (this.page === 1) {
this.conversationList = response.data.list || []
} else {
this.conversationList = [...this.conversationList, ...(response.data.list || [])]
}
this.hasMore = response.data.hasMore || false
this.page += 1
})
} else {
console.error("获取对话列表失败:", response.message)
this.hasMore = false
}
} catch (error) {
console.error("获取对话列表失败:", error)
this.hasMore = false
}
}
onClickConversation = async (conversationId: string) => {
this.msgHasMore = true
this.msgPage = 1
this.getMessageList(conversationId)
}
getMessageList = async (conversationId?: string) => {
// 暂时简化消息历史功能,专注于实时聊天
// 后续可以根据需要添加消息历史功能
}
getGroupConversationList = (data: IConversation[]): GroupedResult => {
const todayStr = dayjs().format("YYYY-MM-DD")
const yesterdayStr = dayjs().subtract(1, "day").format("YYYY-MM-DD")
const today: IConversation[] = []
const yesterday: IConversation[] = []
const others: Record<string, IConversation[]> = {}
data.forEach((item) => {
const itemDateStr = dayjs(item.createDate).format("YYYY-MM-DD")
if (itemDateStr === todayStr) {
today.push(item)
} else if (itemDateStr === yesterdayStr) {
yesterday.push(item)
} else {
if (!others[itemDateStr]) {
others[itemDateStr] = []
}
others[itemDateStr].push(item)
}
})
const result: GroupedResult = []
if (today.length > 0) {
result.push({ label: "今天", list: today })
}
if (yesterday.length > 0) {
result.push({ label: "昨天", list: yesterday })
}
const sortedDates = Object.keys(others).sort(
(a, b) => dayjs(b).unix() - dayjs(a).unix()
)
sortedDates.forEach((dateStr) => {
result.push({ label: dateStr, list: others[dateStr] })
})
return result
}
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
const assistantMessage = this.messageList[0]
if (assistantMessage && assistantMessage.role === "assistant") {
// 直接修改属性
assistantMessage.loading = false
assistantMessage.is_answer = 0
}
})
}
onDeleteConversation = async (conversationId: string) => {
try {
const res = await deleteConversation(conversationId)
runInAction(() => {
if (res.code === 10000) {
this.conversationList = this.conversationList.filter(
(item) => item.id !== conversationId
)
// 如果当前活动的 conversationID 是被删除的这个,则清空
if (this.conversationID === conversationId) {
this.conversationID = ""
localStorage.removeItem("conversation_id")
this.messageList = [] // 清空消息列表
}
}
})
} catch (error) {
console.error("删除对话失败:", error)
}
}
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)
}
handleUploadFile = async (file?: any) => {
// 暂时简化文件上传功能
if (!file) {
this.fileArr = []
return
}
// 后续可以添加文件上传到Coze的功能
this.uploading = false
}
onRemoveFile = (fileId: string) => {
const fileList = this.fileArr.filter((file: any) => file.id !== fileId)
this.fileArr = fileList
}
setMessageContent = (content: string) => {
this.messageContent = content
}
scrollToBottom = () => {
requestAnimationFrame(() => {
this.containerRef.current?.scrollTo({
offset: this.containerRef.current?.nativeElement.scrollHeight + 100,
block: "nearest",
behavior: "smooth",
})
})
}
}
export default new ChatStore()

View File

@@ -0,0 +1,355 @@
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"
interface IBotInfo {
name: string
description: string
suggestedQuestions: string[]
}
class ExamStore {
botInfo: IBotInfo | null = null
conversationID = ""
isLoading = false
messageList: any[] = []
chatId = ""
controller: AbortController | null = null
uploading = false
fileArr: any[] = []
containerRef = createRef<BubbleListRef>()
documentTitle = ""
userId = ""
constructor() {
makeAutoObservable(this) // 自动将字段和方法转为响应式
}
getBotInfo = async () => {
// 使用动态考题的固定 bot_id
const botId = "7509379046204162074"
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,
content_type: "text",
})
})
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: "7509379046204162074" })
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: "7509379046204162074",
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 !== "done") {
part.data = JSON.parse(part.data)
}
console.log("🔄 SSE Event received:", `"${part.event}"`, "->", `"${eventName}"`, "Type:", part.data?.content_type, "Content:", part.data?.content?.substring(0, 100))
runInAction(() => {
switch (eventName) {
case "conversation.chat.created":
this.conversationID = part.data.conversation_id
break
case "conversation.message.delta":
currentContent += part.data.content
chatId = part.data.chat_id
this.updateMessageContent(currentContent)
if (chatId && this.chatId !== chatId) {
this.chatId = chatId
}
break
case "conversation.message.completed":
if (part.data.content_type === "card") {
console.log("🃏 收到卡片消息,开始解析...")
console.log("📋 原始卡片内容:", part.data.content)
try {
const cardInfo = JSON.parse(part.data.content)
console.log("📋 解析后的卡片信息:", cardInfo)
let cardData = null
// 尝试多种解析方式
if (cardInfo.x_properties && cardInfo.x_properties.workflow_card_info) {
// 睿美云格式:从 workflow_card_info 中解析
const workflowData = JSON.parse(cardInfo.x_properties.workflow_card_info)
cardData = workflowData.question_card_data || workflowData
} else if (cardInfo.card_content) {
// 直接包含 card_content
cardData = cardInfo.card_content
} else if (cardInfo.Title && cardInfo.Options) {
// 直接就是卡片数据格式
cardData = cardInfo
} else if (cardInfo.card_type) {
// 新的卡片格式包含card_type和template_url
console.log("🆕 检测到新的卡片格式,尝试获取完整数据...")
// 如果有template_url可能需要进一步解析
if (cardInfo.template_url) {
// 创建一个模拟的答题卡数据用于测试
cardData = {
Title: "请选择你需要的考试类型:",
Options: [
{ name: "超声炮基础方案" },
{ name: "激光美容技术" },
{ name: "微整形注射" },
{ name: "皮肤护理基础" }
]
}
console.log("🎯 使用模拟答题卡数据:", cardData)
}
} else {
// 尝试从其他可能的字段解析
cardData = cardInfo
}
console.log("🎯 最终解析的卡片数据:", cardData)
if (cardData && cardData.Title && cardData.Options) {
this.messageList[0].content_type = part.data.content_type
this.messageList[0].card_content = cardData
this.messageList[0].is_answer = 0
this.messageList[0].loading = false
console.log("✅ 卡片设置完成")
} else {
console.warn("⚠️ 无法解析卡片数据结构,保持原始内容")
// 保持原始内容但停止加载
this.messageList[0].loading = false
}
} catch (e) {
console.error("❌ 卡片解析失败:", e, "原始数据:", part.data.content)
// 即使解析失败,也要停止加载状态
this.messageList[0].loading = false
}
}
break
case "error":
case "conversation.chat.failed":
if (this.messageList[0].role === "assistant") {
// 如果内容为空,添加错误消息
if (!this.messageList[0].content) {
this.messageList[0].content = "抱歉,请求出错了,请重试。"
}
this.messageList[0].loading = false
}
break
default:
break
}
})
}
} 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].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].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].content &&
!this.messageList[0].card_content
) {
this.messageList = this.messageList.slice(1)
return
}
if (this.messageList[0] && this.messageList[0].is_answer) {
this.messageList[0].is_answer = 0
this.messageList[0].loading = false
}
})
}
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 ExamStore()

View File

@@ -0,0 +1,293 @@
import { makeAutoObservable, runInAction } from "mobx"
import {
getOnlineInfo,
startChat,
deleteMessage,
interruptChat,
uploadFile,
} from "@/server/ai"
import { EModalType } from "@/server/type"
import { XStream } from "@ant-design/x"
import { uid } from "uid"
import { BubbleListRef } from "@ant-design/x/es/bubble/BubbleList"
import { createRef } from "react"
interface IBotInfo {
name: string
description: string
suggestedQuestions: string[]
}
class NewChatStore {
botInfo: IBotInfo | null = null
conversationID = ""
isLoading = false
messageList: any[] = []
chatId = ""
controller: AbortController | null = null
uploading = false
fileArr: any[] = []
containerRef = createRef<BubbleListRef>()
userId = ""
constructor() {
makeAutoObservable(this) // 自动将字段和方法转为响应式
}
getBotInfo = async () => {
const res = await getOnlineInfo(EModalType.GENERATE_REPLY)
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 || [],
}
}
})
}
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 = []
})
const historyMessages = this.messageList
.filter((val) => val.content !== "")
.map((item) => {
return {
role: item.role,
content: item.content,
fileIds:
item.file_url && item.file_url.length
? item.file_url.map((item: any) => item.id)
: [],
}
})
const response = await startChat(
{
conversationId: this.conversationID,
content: query,
fileIds,
id,
modelEnum: EModalType.GENERATE_REPLY,
historyMessages,
userId: this.userId,
},
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 () => {
await interruptChat({
conversationId: this.conversationID,
chatId: this.chatId,
modelEnum: EModalType.GENERATE_REPLY,
})
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) => {
const res = await deleteMessage(id)
if (res.code === 10000) {
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
}
const formData = new FormData()
formData.append("file", file)
formData.append("modelEnum", EModalType.GENERATE_REPLY)
this.uploading = true
const res = await uploadFile(formData)
runInAction(() => {
if (res.code === 10000) {
this.getBase64(file, (url: any) => {
runInAction(() => {
this.fileArr.push({
id: res.data,
name: file.name,
path: url,
})
})
})
}
this.uploading = false
})
}
scrollToBottom = () => {
requestAnimationFrame(() => {
this.containerRef.current?.scrollTo({
offset: this.containerRef.current?.nativeElement.scrollHeight + 100,
block: "nearest",
behavior: "smooth",
})
})
}
}
export default new NewChatStore()

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

View File

@@ -0,0 +1,9 @@
export const config = {
getBaseUrl: () =>
localStorage.getItem("chat-x_base_url") || "https://api.coze.cn",
getBaseWsUrl: () =>
localStorage.getItem("chat-x_base_ws_url") || "wss://ws.coze.cn",
getPat: () => localStorage.getItem("chat-x_pat") || "",
getBotId: () =>
localStorage.getItem("chat-x_bot_id") || "7509379008556089379", // 咨询师陪练Bot ID与后端配置保持一致
}

View File

@@ -0,0 +1,9 @@
import ChatStore from "./ChatStore"
import ExamStore from "./ExamStore"
import NewChatStore from "./NewChatStore"
import TrainingStore from "./TrainingStore"
import BotStore from "./BotStore"
const store = { ChatStore, ExamStore, NewChatStore, TrainingStore, BotStore }
export default store

View File

@@ -0,0 +1,8 @@
import { createContext, useContext } from 'react';
import { observer } from 'mobx-react-lite';
import store from './index';
const StoreContext = createContext(store);
const useStore = () => useContext(StoreContext);
export { observer, StoreContext, useStore };

View File

@@ -0,0 +1,3 @@
@function px2vw($px) {
@return calc(#{$px} * 100vw / 750);
}

View File

@@ -0,0 +1,142 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
:focus {
outline: none !important;
}
-webkit-tap-highlight-color: transparent;
::-webkit-scrollbar {
width: 6px; /* 滚动条宽度 */
}
::-webkit-scrollbar-track {
background: #f1f1f1; /* 轨道背景色 */
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #d5d5d5; /* 滑块背景色 */
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #aaa; /* 鼠标悬停时滑块颜色 */
}
}
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
// 兼容旧版 iOS
padding-left: constant(safe-area-inset-left);
padding-right: constant(safe-area-inset-right);
padding-top: constant(safe-area-inset-top);
padding-bottom: constant(safe-area-inset-bottom);
}
.flex {
display: flex;
}
.flex_wrap {
display: flex;
flex-wrap: wrap;
}
.flex_sb {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex_right {
display: flex;
align-items: center;
justify-content: flex-end;
}
.flex_left {
display: flex;
align-items: center;
justify-content: flex-start;
}
.flex_c {
display: flex;
align-items: center;
justify-content: center;
}
.flex_column {
display: flex;
flex-direction: column;
}
.flex_end {
align-items: flex-end;
}
.flex_1 {
flex: 1;
}
.flex_2 {
flex: 2;
}
.flex_3 {
flex: 3;
}
/* 省略显示 */
.wes_1 {
text-overflow: -o-ellipsis-lastline;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
align-content: center;
white-space: normal;
word-break: break-all;
}
.wes_2 {
text-overflow: -o-ellipsis-lastline;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
align-content: center;
white-space: normal;
word-break: break-all;
}
.wes_3 {
text-overflow: -o-ellipsis-lastline;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
align-content: center;
white-space: normal;
word-break: break-all;
}
.csp {
cursor: pointer;
}
.dangerColor {
color: #dc4c40 !important;
}
.h_full {
height: 100vh;
}
.secondaryTextColor {
color: #727b8d;
}

View File

@@ -0,0 +1,68 @@
@font-face {
font-family: 'iconfont'; /* Project id 4920797 */
src: url('iconfont.woff2?t=1747203002025') format('woff2');
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-fasong:before {
content: '\e89e';
}
.icon-gengduo1:before {
content: '\e626';
}
.icon-collapse:before {
content: '\e615';
}
.icon-plus:before {
content: '\e661';
}
.icon-icon02:before {
content: '\e601';
}
.icon-mjiantou-copy1:before {
content: '\e607';
}
.icon-collapse-copy:before {
content: '\ea00';
}
.icon-jieshuluyin:before {
content: '\e858';
}
.icon-fuzhi1:before {
content: '\e898';
}
.icon-shanchu:before {
content: '\e899';
}
.icon-zhongxinfenxi:before {
content: '\e89a';
}
.icon-xinjianduihua:before {
content: '\e89d';
}
.icon-lishijilu1:before {
content: '\e89f';
}
.icon-yuyin1:before {
content: '\e8a0';
}

View File

@@ -0,0 +1,5 @@
@for $fs from 10 through 42 {
.fs-#{$fs} {
font-size: #{$fs}px !important;
}
}

View File

@@ -0,0 +1,63 @@
@each $member in 0, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28, 30, 32, 36, 40,
42, 50, 58, 60, 70, 80, 100
{
.mt-#{$member} {
margin-top: #{$member}px !important;
}
.ml-#{$member} {
margin-left: #{$member}px !important;
}
.mb-#{$member} {
margin-bottom: #{$member}px !important;
}
.mr-#{$member} {
margin-right: #{$member}px !important;
}
.m-#{$member} {
margin: #{$member}px !important;
}
.mx-#{$member} {
margin-left: #{$member}px !important;
margin-right: #{$member}px !important;
}
.my-#{$member} {
margin-top: #{$member}px !important;
margin-bottom: #{$member}px !important;
}
.pt-#{$member} {
padding-top: #{$member}px !important;
}
.pl-#{$member} {
padding-left: #{$member}px !important;
}
.pb-#{$member} {
padding-bottom: #{$member}px !important;
}
.pr-#{$member} {
padding-right: #{$member}px !important;
}
.p-#{$member} {
padding: #{$member}px !important;
}
.px-#{$member} {
padding-left: #{$member}px !important;
padding-right: #{$member}px !important;
}
.py-#{$member} {
padding-top: #{$member}px !important;
padding-bottom: #{$member}px !important;
}
}

View File

@@ -0,0 +1,2 @@
$primary-color: #6633ff;
$height-primary: var(--height-primary, 100vh);

View File

@@ -0,0 +1,4 @@
export default {
test_api: "http://localhost:8000",
prod_api: "http://localhost:8000",
}

View File

@@ -0,0 +1,119 @@
import axios, { AxiosRequestConfig } from "axios"
import { message } from "antd"
import { getAuth, getApiUrl } from "./tools"
const customAxios = axios.create({
baseURL: getApiUrl(),
timeout: 8000,
})
interface AxiosErrorInterface {
message: string
config: any
response: any
}
customAxios.interceptors.request.use(
(config: any) => {
return config
},
(error: AxiosErrorInterface) => {
return error
}
)
customAxios.interceptors.response.use(
(response: any) => {
if (response.status !== 200) {
response.data.message && message.error(response.data.message)
return Promise.reject(response)
}
return Promise.resolve(response.data)
},
(error: AxiosErrorInterface) => {
if (~`${error.message}`.indexOf("timeout")) {
message.error("网络超时")
}
error.response &&
error.response.data.message &&
message.error(error.response.data.message)
if (error.response && error.response.status === 401) {
message.error("登录信息已过期,请重新登录")
} else {
error.response &&
error.response.statusText &&
message.error(error.response.data.message)
}
return Promise.reject(error)
}
)
const baseRequest = (config: any): Promise<any> => {
config = {
...config,
headers: {
Authorization: `${getAuth()}`,
},
}
return customAxios.request(config)
}
export const get = (
url: string,
params?: object,
config?: AxiosRequestConfig
) =>
baseRequest({
method: "get",
params,
url,
...config,
})
export const post = (
url: string,
data?: object,
config?: AxiosRequestConfig
) => {
return baseRequest({
data,
method: "post",
url,
...config,
})
}
export const patch = (
url: string,
data: object,
config?: AxiosRequestConfig
) => {
return baseRequest({
data,
method: "patch",
url,
...config,
})
}
export const put = (
url: string,
data?: object,
config?: AxiosRequestConfig
) => {
return baseRequest({
data,
method: "put",
url,
...config,
})
}
export const remove = (
url: string,
data?: object,
config?: AxiosRequestConfig
) => {
return baseRequest({
data,
method: "delete",
url,
...config,
})
}

View File

@@ -0,0 +1,103 @@
import ApiConfig from "./api"
export function throttle<T extends (...args: any[]) => any>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let lastCall = 0
let timer: ReturnType<typeof setTimeout> | null = null
return function (this: any, ...args: Parameters<T>) {
const now = Date.now()
if (now - lastCall < delay) {
clearTimeout(timer!)
timer = setTimeout(() => {
func.apply(this, args)
lastCall = now
}, delay)
} else {
func.apply(this, args)
lastCall = now
}
}
}
/**
* remove px unit and convert to number
* @param value example: "16px", "16.5px", "-16px", "16", 16
* @returns example: 16, 16.5, -16, 16, 16
* @throws Error if value is invalid
*/
export const removePx = (value: string | number): number => {
// 如果已经是数字,直接返回
if (typeof value === "number") return value
// 如果是空字符串,抛出错误
if (!value) {
throw new Error("Invalid value: empty string")
}
// 移除所有空格
const trimmed = value.trim()
// 检查是否以 px 结尾(不区分大小写)
const hasPx = /px$/i.test(trimmed)
// 提取数字部分
const num = hasPx ? trimmed.slice(0, -2) : trimmed
// 转换为数字
const result = Number.parseFloat(num)
// 验证结果是否为有效数字
if (Number.isNaN(result)) {
throw new Error(`Invalid value: ${value}`)
}
return result
}
export function isMobile() {
const userAgent = navigator.userAgent
return /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i.test(
userAgent
)
}
export const getParams = () => {
const params = new URLSearchParams(window.location.search)
const result: Record<string, string> = {}
for (const [key, value] of params.entries()) {
result[key] = value
}
return result
}
export const getAuth = () => {
const { token } = getParams()
return token || ""
}
export const setWindowHeight = () => {
const windowHeight = window.innerHeight
document
.getElementsByTagName("body")[0]
.style.setProperty("--height-primary", `${windowHeight}px`)
}
export function getApiUrl() {
const { apiUrl } = getParams()
if (apiUrl) {
return apiUrl
}
if (process.env.NODE_ENV === "development") {
return ApiConfig.test_api
}
return ApiConfig.prod_api
}
export const isElectron =
typeof window !== "undefined" &&
window.navigator.userAgent.includes("Electron")
export const isApp =
typeof window !== "undefined" &&
window.navigator.userAgent.includes("MyAppWebView")

View File

@@ -0,0 +1,44 @@
/**
* 语音相关最小实现占位(避免构建失败)
* 提供 checkMeia 与 Tss 接口的简化实现
*/
export type TssEventHandlers = {
onMessage?: (data: string) => void
onError?: (e: unknown) => void
onClose?: () => void
}
export class Tss {
private handlers: TssEventHandlers
private closed = false
private constructor(handlers: TssEventHandlers) {
this.handlers = handlers
}
static createInstance(handlers: TssEventHandlers): Tss {
return new Tss(handlers)
}
/**
* 关闭占位实例
*/
close(): void {
if (this.closed) return
this.closed = true
this.handlers.onClose?.()
}
}
/**
* 简化的麦克风权限检查,占位返回成功
*/
export async function checkMeia(): Promise<void> {
// 仅作为占位,实际接入时替换为真实的麦克风权限检测逻辑
return
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,590 @@
openapi: 3.0.0
info:
title: 考试模块 API
version: 1.0.0
description: 考培练系统考试模块的 API 接口定义
servers:
- url: http://localhost:8000/api/v1
description: 本地开发服务器
paths:
/exams/start:
post:
summary: 开始考试(动态组卷)
description: 根据指定参数动态生成试卷并开始考试
tags:
- 考试管理
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExamStartRequest'
responses:
'200':
description: 考试开始成功
content:
application/json:
schema:
$ref: '#/components/schemas/ExamSessionResponse'
'400':
description: 请求参数错误或存在未完成的考试
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
$ref: '#/components/responses/UnauthorizedError'
'500':
$ref: '#/components/responses/InternalServerError'
/exams/{examId}/submit:
post:
summary: 提交考试
description: 提交考试答案并生成成绩
tags:
- 考试管理
security:
- bearerAuth: []
parameters:
- name: examId
in: path
required: true
schema:
type: integer
description: 考试ID
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExamSubmitRequest'
responses:
'200':
description: 考试提交成功
content:
application/json:
schema:
$ref: '#/components/schemas/ExamResultResponse'
'400':
description: 考试状态不正确或已超时
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
description: 无权访问此考试
'404':
description: 考试不存在
/exams/{examId}:
get:
summary: 获取考试详情
description: 获取考试会话信息和题目列表(不包含答案)
tags:
- 考试管理
security:
- bearerAuth: []
parameters:
- name: examId
in: path
required: true
schema:
type: integer
description: 考试ID
responses:
'200':
description: 获取成功
content:
application/json:
schema:
$ref: '#/components/schemas/ExamSessionResponse'
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
description: 考试不存在
/exams/records:
get:
summary: 获取考试记录列表
description: 分页获取用户的考试记录
tags:
- 考试管理
security:
- bearerAuth: []
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
description: 页码
- name: page_size
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: 每页数量
- name: status
in: query
schema:
$ref: '#/components/schemas/ExamStatus'
description: 考试状态筛选
responses:
'200':
description: 获取成功
content:
application/json:
schema:
$ref: '#/components/schemas/ExamRecordListResponse'
'401':
$ref: '#/components/responses/UnauthorizedError'
/exams/{examId}/result:
get:
summary: 获取考试结果
description: 获取详细的考试成绩、统计信息和答案详情
tags:
- 考试管理
security:
- bearerAuth: []
parameters:
- name: examId
in: path
required: true
schema:
type: integer
description: 考试ID
responses:
'200':
description: 获取成功
content:
application/json:
schema:
$ref: '#/components/schemas/ExamResultResponse'
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
description: 考试结果不存在
/exams/mistakes:
get:
summary: 获取错题列表
description: 分页获取用户的错题记录
tags:
- 错题管理
security:
- bearerAuth: []
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
description: 页码
- name: page_size
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: 每页数量
- name: is_mastered
in: query
schema:
type: boolean
description: 是否已掌握
responses:
'200':
description: 获取成功
content:
application/json:
schema:
$ref: '#/components/schemas/MistakeListResponse'
'401':
$ref: '#/components/responses/UnauthorizedError'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
ExamStartRequest:
type: object
required:
- exam_name
properties:
course_id:
type: integer
description: 课程ID
exam_name:
type: string
description: 考试名称
question_count:
type: integer
minimum: 1
maximum: 100
default: 20
description: 题目数量
time_limit:
type: integer
minimum: 1
maximum: 300
description: 考试时长(分钟)
difficulty:
type: integer
minimum: 1
maximum: 5
description: 难度等级
knowledge_points:
type: array
items:
type: string
description: 知识点范围
question_types:
type: array
items:
$ref: '#/components/schemas/QuestionType'
description: 题型范围
ExamSubmitRequest:
type: object
required:
- answers
properties:
answers:
type: array
items:
$ref: '#/components/schemas/AnswerSubmitRequest'
description: 答案列表
force_submit:
type: boolean
default: false
description: 是否强制提交
AnswerSubmitRequest:
type: object
required:
- question_id
- user_answer
properties:
question_id:
type: integer
description: 题目ID
user_answer:
type: string
description: 用户答案
time_spent:
type: integer
minimum: 0
description: 答题用时(秒)
ExamStatus:
type: string
enum:
- created
- in_progress
- submitted
- graded
- expired
description: 考试状态
QuestionType:
type: string
enum:
- single_choice
- multiple_choice
- true_false
- fill_blank
- short_answer
- essay
description: 题目类型
ExamSessionResponse:
type: object
properties:
code:
type: integer
example: 200
message:
type: string
example: success
data:
type: object
properties:
id:
type: integer
exam_name:
type: string
course_id:
type: integer
total_questions:
type: integer
total_score:
type: number
pass_score:
type: number
time_limit:
type: integer
status:
$ref: '#/components/schemas/ExamStatus'
started_at:
type: string
format: date-time
submitted_at:
type: string
format: date-time
questions:
type: array
items:
$ref: '#/components/schemas/QuestionResponse'
QuestionResponse:
type: object
properties:
id:
type: integer
question_order:
type: integer
question_type:
$ref: '#/components/schemas/QuestionType'
question_text:
type: string
options:
type: object
additionalProperties:
type: string
score:
type: number
knowledge_points:
type: array
items:
type: string
difficulty:
type: integer
ExamResultResponse:
type: object
properties:
code:
type: integer
example: 200
message:
type: string
example: success
data:
type: object
properties:
id:
type: integer
exam_id:
type: integer
exam_name:
type: string
total_score:
type: number
actual_score:
type: number
percentage_score:
type: number
is_passed:
type: boolean
total_questions:
type: integer
answered_questions:
type: integer
correct_questions:
type: integer
question_type_stats:
type: object
knowledge_stats:
type: object
total_time_spent:
type: integer
average_time_per_question:
type: number
ai_analysis:
type: string
improvement_suggestions:
type: array
items:
type: string
answer_details:
type: array
items:
$ref: '#/components/schemas/AnswerDetailResponse'
AnswerDetailResponse:
type: object
properties:
question_id:
type: integer
question_order:
type: integer
question_type:
$ref: '#/components/schemas/QuestionType'
question_text:
type: string
user_answer:
type: string
correct_answer:
type: string
is_correct:
type: boolean
actual_score:
type: number
total_score:
type: number
answer_explanation:
type: string
ai_feedback:
type: string
ExamRecordListResponse:
type: object
properties:
code:
type: integer
example: 200
message:
type: string
example: success
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/ExamRecordResponse'
total:
type: integer
page:
type: integer
page_size:
type: integer
total_pages:
type: integer
ExamRecordResponse:
type: object
properties:
id:
type: integer
exam_name:
type: string
course_id:
type: integer
course_name:
type: string
status:
$ref: '#/components/schemas/ExamStatus'
total_questions:
type: integer
actual_score:
type: number
percentage_score:
type: number
is_passed:
type: boolean
started_at:
type: string
format: date-time
submitted_at:
type: string
format: date-time
MistakeListResponse:
type: object
properties:
code:
type: integer
example: 200
message:
type: string
example: success
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/MistakeResponse'
total:
type: integer
page:
type: integer
page_size:
type: integer
total_pages:
type: integer
MistakeResponse:
type: object
properties:
id:
type: integer
question_type:
$ref: '#/components/schemas/QuestionType'
question_text:
type: string
user_answer:
type: string
correct_answer:
type: string
knowledge_points:
type: array
items:
type: string
difficulty:
type: integer
review_count:
type: integer
is_mastered:
type: boolean
last_review_at:
type: string
format: date-time
created_at:
type: string
format: date-time
ErrorResponse:
type: object
properties:
code:
type: integer
message:
type: string
error:
type: object
properties:
code:
type: string
message:
type: string
details:
type: object
responses:
UnauthorizedError:
description: 未授权,需要登录
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
InternalServerError:
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

View File

@@ -0,0 +1,220 @@
# 考试模块Exam Module
## 模块概述
考试模块是考培练系统的核心功能之一提供动态组卷、在线考试、自动判题、成绩分析和错题管理等功能。该模块与Dify AI平台集成支持智能出题和主观题自动评分。
## 主要功能
### 1. 动态组卷
- 根据课程、知识点、难度等参数动态生成试卷
- 支持多种题型:单选题、多选题、判断题、填空题、简答题、论述题
- 通过Dify工作流实现智能出题
### 2. 考试管理
- 考试计时和状态管理
- 防作弊机制(防重复提交、超时控制)
- 题目与答案分离存储,确保安全性
### 3. 自动判题
- 客观题自动判分
- 主观题通过Dify AI评分
- 实时计算成绩和统计信息
### 4. 成绩分析
- 详细的成绩报告
- 题型正确率统计
- 知识点掌握情况分析
- AI生成的学习建议
### 5. 错题管理
- 自动记录错题
- 支持错题复习和标记掌握状态
- 按知识点分类管理
## 技术架构
### 数据模型
- `ExamSession`: 考试会话表
- `ExamQuestion`: 考试题目表
- `ExamAnswer`: 考试答案表
- `ExamResult`: 考试结果表
- `Mistake`: 错题记录表
### API接口
#### 1. 开始考试
```
POST /api/v1/exams/start
```
- 功能:动态生成试卷并开始考试
- 权限:需要登录
- 参数课程ID、题目数量、时长、难度等
#### 2. 提交考试
```
POST /api/v1/exams/{examId}/submit
```
- 功能:提交答案并生成成绩
- 权限:需要登录,只能提交自己的考试
- 参数:答案列表
#### 3. 获取考试详情
```
GET /api/v1/exams/{examId}
```
- 功能:获取考试信息和题目(不含答案)
- 权限:需要登录,只能查看自己的考试
#### 4. 获取考试记录
```
GET /api/v1/exams/records
```
- 功能:分页获取考试历史记录
- 权限:需要登录
- 支持按状态筛选
#### 5. 获取考试结果
```
GET /api/v1/exams/{examId}/result
```
- 功能:获取详细的考试成绩和分析
- 权限:需要登录,只能查看自己的成绩
#### 6. 获取错题列表
```
GET /api/v1/exams/mistakes
```
- 功能:分页获取错题记录
- 权限:需要登录
- 支持按掌握状态筛选
## 配置说明
### 环境变量
```env
# Dify配置
DIFY_API_BASE=https://api.dify.ai/v1
DIFY_API_KEY=your_api_key
DIFY_EXAM_WORKFLOW_ID=exam_workflow_id
DIFY_EVAL_WORKFLOW_ID=eval_workflow_id
DIFY_TIMEOUT=30
```
### 考试参数限制
- 题目数量1-100题
- 考试时长1-300分钟
- 难度等级1-5级
- 默认及格分60分
## 使用示例
### 1. 开始考试
```python
# 请求
POST /api/v1/exams/start
{
"exam_name": "Python基础测试",
"question_count": 20,
"time_limit": 60,
"difficulty": 3,
"knowledge_points": ["Python基础", "数据结构"],
"question_types": ["single_choice", "true_false"]
}
# 响应
{
"code": 200,
"message": "考试开始成功",
"data": {
"id": 1,
"exam_name": "Python基础测试",
"total_questions": 20,
"total_score": 100.0,
"time_limit": 60,
"status": "in_progress",
"started_at": "2024-01-01T10:00:00",
"questions": [...]
}
}
```
### 2. 提交考试
```python
# 请求
POST /api/v1/exams/1/submit
{
"answers": [
{
"question_id": 1,
"user_answer": "B",
"time_spent": 30
},
{
"question_id": 2,
"user_answer": "True",
"time_spent": 20
}
]
}
# 响应
{
"code": 200,
"message": "考试提交成功",
"data": {
"exam_id": 1,
"total_score": 100.0,
"actual_score": 85.0,
"percentage_score": 85.0,
"is_passed": true,
"correct_questions": 17,
"ai_analysis": "您在Python基础部分表现优秀...",
"improvement_suggestions": ["建议加强数据结构的学习"]
}
}
```
## 安全考虑
1. **认证控制**所有接口需要JWT认证
2. **权限隔离**:用户只能访问自己的考试数据
3. **防作弊机制**
- 题目与答案分离存储
- 考试状态严格控制
- 超时自动结束
- 防止重复提交
## 性能优化
1. **数据库索引**
- 用户ID和考试状态联合索引
- 考试ID和题目顺序联合索引
2. **分页查询**
- 考试记录和错题列表支持分页
- 默认每页20条最大100条
3. **异步处理**
- 使用异步数据库操作
- Dify API调用设置超时控制
## 扩展性
该模块设计考虑了未来的扩展需求:
1. **题库管理**:预留了题库查询接口
2. **批量导入**支持从Excel导入试题
3. **考试模板**:可保存常用考试配置
4. **团体考试**:支持班级或部门统一考试
5. **证书生成**:考试通过后生成电子证书
## 测试覆盖
- 单元测试:覆盖所有服务层方法
- 集成测试覆盖所有API接口
- 测试场景包括:
- 正常流程测试
- 异常情况处理
- 权限控制验证
- 边界条件测试

View File

@@ -0,0 +1,187 @@
"""
考试模块API路由
"""
from typing import Optional
import logging
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.deps import get_db, get_current_user
from ...core.config import settings
from ...models.user import User
from ...models.exam import ExamStatus
from ...schemas.base import BaseResponse, PaginatedResponse
from ...schemas.exam import (
ExamStartRequest, ExamSubmitRequest,
ExamSessionResponse, ExamResultResponse,
ExamRecordResponse, MistakeResponse
)
from ...services.exam_service import ExamService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/exams", tags=["exams"])
@router.post("/start", response_model=BaseResponse[ExamSessionResponse])
async def start_exam(
request: ExamStartRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
开始考试(动态组卷)
- **course_id**: 课程ID可选
- **exam_name**: 考试名称
- **question_count**: 题目数量1-100
- **time_limit**: 考试时长(分钟,可选)
- **difficulty**: 难度等级1-5可选
- **knowledge_points**: 知识点范围(可选)
- **question_types**: 题型范围(可选)
"""
service = ExamService(db)
try:
exam_session = await service.start_exam(
user_id=current_user.id,
request=request,
exam_workflow_id=settings.DIFY_EXAM_WORKFLOW_ID
)
return BaseResponse(
data=exam_session,
message="考试开始成功"
)
except Exception as e:
logger.error(f"Start exam failed: user_id={current_user.id}, error={str(e)}")
raise
@router.post("/{exam_id}/submit", response_model=BaseResponse[ExamResultResponse])
async def submit_exam(
exam_id: int,
request: ExamSubmitRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
提交考试
- **answers**: 答案列表
- **force_submit**: 是否强制提交(忽略未答题目)
"""
service = ExamService(db)
try:
exam_result = await service.submit_exam(
user_id=current_user.id,
exam_id=exam_id,
request=request,
eval_workflow_id=settings.DIFY_EVAL_WORKFLOW_ID
)
return BaseResponse(
data=exam_result,
message="考试提交成功"
)
except Exception as e:
logger.error(f"Submit exam failed: exam_id={exam_id}, user_id={current_user.id}, error={str(e)}")
raise
@router.get("/{exam_id}", response_model=BaseResponse[ExamSessionResponse])
async def get_exam_detail(
exam_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
获取考试详情
返回考试会话信息和题目列表(不包含答案)
"""
service = ExamService(db)
exam_session = await service.get_exam_detail(
user_id=current_user.id,
exam_id=exam_id
)
return BaseResponse(data=exam_session)
@router.get("/records", response_model=BaseResponse[PaginatedResponse[ExamRecordResponse]])
async def get_exam_records(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
status: Optional[ExamStatus] = Query(None, description="考试状态筛选"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
获取考试记录列表(分页)
- **page**: 页码从1开始
- **page_size**: 每页数量1-100
- **status**: 考试状态筛选(可选)
"""
service = ExamService(db)
result = await service.get_exam_records(
user_id=current_user.id,
page=page,
page_size=page_size,
status=status
)
return BaseResponse(data=result)
@router.get("/{exam_id}/result", response_model=BaseResponse[ExamResultResponse])
async def get_exam_result(
exam_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
获取考试结果
返回详细的考试成绩、统计信息和答案详情
"""
service = ExamService(db)
exam_result = await service.get_exam_result(
user_id=current_user.id,
exam_id=exam_id
)
return BaseResponse(data=exam_result)
@router.get("/mistakes", response_model=BaseResponse[PaginatedResponse[MistakeResponse]])
async def get_mistakes(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
is_mastered: Optional[bool] = Query(None, description="是否已掌握"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
获取错题列表(分页)
- **page**: 页码从1开始
- **page_size**: 每页数量1-100
- **is_mastered**: 是否已掌握(可选)
"""
service = ExamService(db)
result = await service.get_mistakes(
user_id=current_user.id,
page=page,
page_size=page_size,
is_mastered=is_mastered
)
return BaseResponse(data=result)

View File

@@ -0,0 +1,15 @@
"""
API v1 路由集合
"""
from fastapi import APIRouter
from .health import router as health_router
from .users import router as users_router
from .exams import router as exams_router
api_router = APIRouter()
# 注册所有路由
api_router.include_router(health_router, prefix="/health", tags=["health"])
api_router.include_router(users_router, prefix="/users", tags=["users"])
api_router.include_router(exams_router, prefix="/exams", tags=["exams"])

View File

@@ -0,0 +1 @@
# services package

View File

@@ -0,0 +1 @@
# AI services package

View File

@@ -0,0 +1 @@
# Dify integration package

View File

@@ -0,0 +1,217 @@
"""
Dify API客户端
"""
import httpx
import json
from typing import Dict, Any, Optional, List
from datetime import datetime
import logging
from ....core.config import settings
from ....core.exceptions import ExternalServiceError
logger = logging.getLogger(__name__)
class DifyClient:
"""Dify API客户端"""
def __init__(self):
self.api_base = settings.DIFY_API_BASE.rstrip('/')
self.api_key = settings.DIFY_API_KEY
self.timeout = settings.DIFY_TIMEOUT
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
async def run_workflow(
self,
workflow_id: str,
inputs: Dict[str, Any],
user: str,
conversation_id: Optional[str] = None
) -> Dict[str, Any]:
"""
运行Dify工作流
Args:
workflow_id: 工作流ID
inputs: 输入参数
user: 用户标识
conversation_id: 会话ID可选
Returns:
工作流执行结果
"""
url = f"{self.api_base}/workflows/run"
payload = {
"workflow_id": workflow_id,
"inputs": inputs,
"user": user,
"response_mode": "blocking" # 同步模式
}
if conversation_id:
payload["conversation_id"] = conversation_id
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
url,
json=payload,
headers=self.headers
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
logger.error(f"Dify API timeout: workflow_id={workflow_id}")
raise ExternalServiceError("Dify服务响应超时")
except httpx.HTTPStatusError as e:
logger.error(f"Dify API error: {e.response.status_code} - {e.response.text}")
raise ExternalServiceError(f"Dify服务错误: {e.response.status_code}")
except Exception as e:
logger.error(f"Dify API unexpected error: {str(e)}")
raise ExternalServiceError("Dify服务异常")
async def generate_exam_questions(
self,
workflow_id: str,
course_id: Optional[int],
question_count: int,
difficulty: Optional[int],
knowledge_points: Optional[List[str]],
question_types: Optional[List[str]]
) -> List[Dict[str, Any]]:
"""
生成考试题目
Args:
workflow_id: 考试工作流ID
course_id: 课程ID
question_count: 题目数量
difficulty: 难度等级
knowledge_points: 知识点列表
question_types: 题型列表
Returns:
题目列表
"""
inputs = {
"question_count": question_count,
"difficulty": difficulty or 3,
"knowledge_points": json.dumps(knowledge_points or [], ensure_ascii=False),
"question_types": json.dumps(question_types or [], ensure_ascii=False)
}
if course_id:
inputs["course_id"] = str(course_id)
# 生成唯一用户标识
user = f"exam_user_{datetime.utcnow().timestamp()}"
result = await self.run_workflow(
workflow_id=workflow_id,
inputs=inputs,
user=user
)
# 解析结果
if "data" in result and "outputs" in result["data"]:
outputs = result["data"]["outputs"]
if "questions" in outputs:
# 假设Dify返回的questions是JSON字符串
try:
questions = json.loads(outputs["questions"])
return questions
except json.JSONDecodeError:
logger.error("Failed to parse questions from Dify")
return []
return []
async def evaluate_answer(
self,
workflow_id: str,
question: str,
answer: str,
correct_answer: str,
question_type: str
) -> Dict[str, Any]:
"""
评估答案(主观题)
Args:
workflow_id: 评估工作流ID
question: 题目
answer: 用户答案
correct_answer: 参考答案
question_type: 题型
Returns:
评估结果
"""
inputs = {
"question": question,
"user_answer": answer,
"correct_answer": correct_answer,
"question_type": question_type
}
user = f"eval_user_{datetime.utcnow().timestamp()}"
result = await self.run_workflow(
workflow_id=workflow_id,
inputs=inputs,
user=user
)
# 解析评估结果
if "data" in result and "outputs" in result["data"]:
outputs = result["data"]["outputs"]
return {
"score": outputs.get("score", 0),
"feedback": outputs.get("feedback", ""),
"is_correct": outputs.get("is_correct", False)
}
return {
"score": 0,
"feedback": "评估失败",
"is_correct": False
}
async def generate_exam_report(
self,
workflow_id: str,
exam_data: Dict[str, Any]
) -> str:
"""
生成考试报告
Args:
workflow_id: 报告工作流ID
exam_data: 考试数据
Returns:
考试报告文本
"""
inputs = {
"exam_data": json.dumps(exam_data, ensure_ascii=False)
}
user = f"report_user_{datetime.utcnow().timestamp()}"
result = await self.run_workflow(
workflow_id=workflow_id,
inputs=inputs,
user=user
)
if "data" in result and "outputs" in result["data"]:
return result["data"]["outputs"].get("report", "报告生成失败")
return "报告生成失败"

Some files were not shown because too many files have changed in this diff Show More