feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
28
知识库/参考代码/coze-chat-frontend/eslint.config.js
Executable 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
6012
知识库/参考代码/coze-chat-frontend/package-lock.json
generated
Normal file
35
知识库/参考代码/coze-chat-frontend/src/App.tsx
Executable 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
|
||||
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/home_bg.jpg
Executable file
|
After Width: | Height: | Size: 15 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/menu1.png
Executable file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/menu2.png
Executable file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/menu3.png
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/menu4.png
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/menu5.png
Executable file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/menu6.png
Executable file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/menu7.png
Executable file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/menu8.png
Executable file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/training_logo.png
Executable file
|
After Width: | Height: | Size: 43 KiB |
BIN
知识库/参考代码/coze-chat-frontend/src/assets/images/user.jpg
Executable file
|
After Width: | Height: | Size: 4.7 KiB |
245
知识库/参考代码/coze-chat-frontend/src/components/MessageList/index.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
349
知识库/参考代码/coze-chat-frontend/src/components/MessageList/index.tsx
Executable 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)
|
||||
70
知识库/参考代码/coze-chat-frontend/src/components/SenderBox/index.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
168
知识库/参考代码/coze-chat-frontend/src/components/SenderBox/index.tsx
Executable 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)
|
||||
1
知识库/参考代码/coze-chat-frontend/src/hooks/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export * from './use-media-query';
|
||||
142
知识库/参考代码/coze-chat-frontend/src/hooks/use-media-query.ts
Executable 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,
|
||||
});
|
||||
15
知识库/参考代码/coze-chat-frontend/src/index.scss
Executable 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);
|
||||
}
|
||||
}
|
||||
27
知识库/参考代码/coze-chat-frontend/src/main.tsx
Executable 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>
|
||||
)
|
||||
214
知识库/参考代码/coze-chat-frontend/src/pages/AudioTest/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
494
知识库/参考代码/coze-chat-frontend/src/pages/AudioTest/index.tsx
Normal 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;
|
||||
34
知识库/参考代码/coze-chat-frontend/src/pages/Chat/Header/index.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
59
知识库/参考代码/coze-chat-frontend/src/pages/Chat/Header/index.tsx
Executable 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);
|
||||
155
知识库/参考代码/coze-chat-frontend/src/pages/Chat/MessageList/index.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
287
知识库/参考代码/coze-chat-frontend/src/pages/Chat/MessageList/index.tsx
Executable 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)
|
||||
165
知识库/参考代码/coze-chat-frontend/src/pages/Chat/Nav/index.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
175
知识库/参考代码/coze-chat-frontend/src/pages/Chat/Nav/index.tsx
Executable 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);
|
||||
70
知识库/参考代码/coze-chat-frontend/src/pages/Chat/SenderBox/index.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
160
知识库/参考代码/coze-chat-frontend/src/pages/Chat/SenderBox/index.tsx
Executable 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">
|
||||
上传文件: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)
|
||||
21
知识库/参考代码/coze-chat-frontend/src/pages/Chat/SiderNav/index.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
85
知识库/参考代码/coze-chat-frontend/src/pages/Chat/SiderNav/index.tsx
Executable 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);
|
||||
8
知识库/参考代码/coze-chat-frontend/src/pages/Chat/index.scss
Executable file
@@ -0,0 +1,8 @@
|
||||
.chat_wrap {
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.client_height {
|
||||
height: calc($height-primary - 42px);
|
||||
}
|
||||
21
知识库/参考代码/coze-chat-frontend/src/pages/Chat/index.tsx
Executable 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)
|
||||
12
知识库/参考代码/coze-chat-frontend/src/pages/Content/index.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
知识库/参考代码/coze-chat-frontend/src/pages/Content/index.tsx
Executable 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;
|
||||
59
知识库/参考代码/coze-chat-frontend/src/pages/Exam/index.tsx
Executable 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)
|
||||
221
知识库/参考代码/coze-chat-frontend/src/pages/Home/index.scss
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
知识库/参考代码/coze-chat-frontend/src/pages/Home/index.tsx
Executable 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)
|
||||
58
知识库/参考代码/coze-chat-frontend/src/pages/NewChat/index.tsx
Executable 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)
|
||||
16
知识库/参考代码/coze-chat-frontend/src/pages/Training/TextChat.scss
Executable 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;
|
||||
}
|
||||
67
知识库/参考代码/coze-chat-frontend/src/pages/Training/TextChat.tsx
Executable 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)
|
||||
286
知识库/参考代码/coze-chat-frontend/src/pages/Training/VoiceChat.scss
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
224
知识库/参考代码/coze-chat-frontend/src/pages/Training/VoiceChat.tsx
Executable 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)
|
||||
19
知识库/参考代码/coze-chat-frontend/src/pages/Training/index.tsx
Executable 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)
|
||||
77
知识库/参考代码/coze-chat-frontend/src/server/ai.ts
Executable 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 })
|
||||
}
|
||||
77
知识库/参考代码/coze-chat-frontend/src/server/api.ts
Normal 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())
|
||||
}
|
||||
85
知识库/参考代码/coze-chat-frontend/src/server/global.ts
Executable 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',
|
||||
},
|
||||
});
|
||||
};
|
||||
14
知识库/参考代码/coze-chat-frontend/src/server/type.ts
Executable 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",
|
||||
}
|
||||
68
知识库/参考代码/coze-chat-frontend/src/stores/BotStore.ts
Normal 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()
|
||||
534
知识库/参考代码/coze-chat-frontend/src/stores/ChatStore.ts
Executable 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()
|
||||
355
知识库/参考代码/coze-chat-frontend/src/stores/ExamStore.ts
Executable 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()
|
||||
293
知识库/参考代码/coze-chat-frontend/src/stores/NewChatStore.ts
Executable 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()
|
||||
525
知识库/参考代码/coze-chat-frontend/src/stores/TrainingStore.ts
Executable file
@@ -0,0 +1,525 @@
|
||||
import { makeAutoObservable, runInAction } from "mobx"
|
||||
import {
|
||||
getBotInfo,
|
||||
createConversation,
|
||||
startChatStream,
|
||||
deleteConversation,
|
||||
interruptChat,
|
||||
} from "@/server/api"
|
||||
import { XStream } from "@ant-design/x"
|
||||
import { uid } from "uid"
|
||||
import { BubbleListRef } from "@ant-design/x/es/bubble/BubbleList"
|
||||
import { createRef } from "react"
|
||||
import {
|
||||
ClientEventType,
|
||||
WsChatClient,
|
||||
WsChatEventNames,
|
||||
WsToolsUtils,
|
||||
type WsChatEventData,
|
||||
} from "@coze/api/ws-tools"
|
||||
import {
|
||||
type ConversationAudioTranscriptCompletedEvent,
|
||||
WebsocketsEventType,
|
||||
BotInfo,
|
||||
} from "@coze/api"
|
||||
import { config } from "@/stores/config"
|
||||
import { message } from "antd"
|
||||
import { getApiUrl } from "@/utils/tools"
|
||||
|
||||
interface IBotInfo extends BotInfo {
|
||||
name: string
|
||||
description: string
|
||||
suggestedQuestions: string[]
|
||||
}
|
||||
export enum EChatModel {
|
||||
VOICE,
|
||||
TEXT,
|
||||
}
|
||||
export enum EStatus {
|
||||
unconnected = "未连接",
|
||||
connecting = "连接中...",
|
||||
connected = "已连接",
|
||||
disconnected = "已断开连接",
|
||||
error = "连接错误",
|
||||
waiting = "等待ai回复",
|
||||
listening = "正在聆听",
|
||||
}
|
||||
class TrainingStore {
|
||||
chatModel = EChatModel.VOICE
|
||||
botInfo: IBotInfo | null = null
|
||||
conversationID = ""
|
||||
isLoading = false
|
||||
messageList: any[] = []
|
||||
chatId = ""
|
||||
controller: AbortController | null = null
|
||||
uploading = false
|
||||
fileArr: any[] = []
|
||||
containerRef = createRef<BubbleListRef>()
|
||||
clientRef = createRef<WsChatClient>()
|
||||
status: EStatus = EStatus.unconnected
|
||||
isFirstDelta = true
|
||||
shwMessageList = true
|
||||
tempContent = ""
|
||||
userId = ""
|
||||
constructor() {
|
||||
makeAutoObservable(this, { isFirstDelta: false, tempContent: false }) // 自动将字段和方法转为响应式
|
||||
}
|
||||
|
||||
setChatModel = async (model: EChatModel) => {
|
||||
if (model === EChatModel.VOICE) {
|
||||
this.handleConnect()
|
||||
}
|
||||
this.chatModel = model
|
||||
}
|
||||
|
||||
setShowContent = () => {
|
||||
this.shwMessageList = !this.shwMessageList
|
||||
}
|
||||
|
||||
initClient = async () => {
|
||||
const permission = await WsToolsUtils.checkDevicePermission()
|
||||
if (!permission.audio) {
|
||||
throw new Error("需要麦克风访问权限")
|
||||
}
|
||||
if (!config.getPat()) {
|
||||
await this.getBotToken()
|
||||
}
|
||||
|
||||
// 确保token已获取
|
||||
const token = config.getPat()
|
||||
if (!token) {
|
||||
throw new Error("无法获取Token")
|
||||
}
|
||||
|
||||
const client = new WsChatClient({
|
||||
token: token, // 直接使用token,不加Bearer前缀
|
||||
baseWsURL: config.getBaseWsUrl(),
|
||||
allowPersonalAccessTokenInBrowser: true, // 浏览器环境下必须设置为true才能使用PAT
|
||||
botId: config.getBotId(),
|
||||
debug: true, // 启用调试模式
|
||||
})
|
||||
|
||||
console.log("WebSocket配置:", {
|
||||
token: `${token.substring(0, 10)}...`,
|
||||
baseWsURL: config.getBaseWsUrl(),
|
||||
botId: config.getBotId(),
|
||||
})
|
||||
|
||||
// 监听连接事件
|
||||
client.on(WsChatEventNames.CONNECTED, () => {
|
||||
console.log("[chat] WebSocket connected")
|
||||
runInAction(() => {
|
||||
this.status = EStatus.connected
|
||||
})
|
||||
})
|
||||
|
||||
// 监听断开连接事件
|
||||
client.on(WsChatEventNames.DISCONNECTED, () => {
|
||||
console.log("[chat] WebSocket disconnected")
|
||||
runInAction(() => {
|
||||
this.status = EStatus.disconnected
|
||||
})
|
||||
})
|
||||
|
||||
// 监听服务器错误事件
|
||||
client.on(WsChatEventNames.SERVER_ERROR, (_: string, event: unknown) => {
|
||||
console.error("[chat] server error", event)
|
||||
client.disconnect()
|
||||
this.clientRef.current = null
|
||||
runInAction(() => {
|
||||
this.status = EStatus.error
|
||||
})
|
||||
})
|
||||
|
||||
// 监听所有消息事件
|
||||
client.on(WsChatEventNames.ALL, this.handleMessageEvent)
|
||||
this.clientRef.current = client
|
||||
}
|
||||
|
||||
handleMessageEvent = (eventName: string, event: WsChatEventData) => {
|
||||
if (eventName === WsChatEventNames.CONNECTED) {
|
||||
this.messageList = []
|
||||
return
|
||||
}
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
switch (event.event_type) {
|
||||
case WebsocketsEventType.CONVERSATION_AUDIO_TRANSCRIPT_COMPLETED: {
|
||||
const { content } = (event as ConversationAudioTranscriptCompletedEvent)
|
||||
.data
|
||||
runInAction(() => {
|
||||
this.messageList.unshift({
|
||||
id: uid(32),
|
||||
role: "user",
|
||||
content,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case WebsocketsEventType.CONVERSATION_MESSAGE_DELTA:
|
||||
if (event.data.content) {
|
||||
const content = this.tempContent + event.data.content
|
||||
runInAction(() => {
|
||||
this.tempContent = content
|
||||
})
|
||||
if (this.isFirstDelta) {
|
||||
// 第一次增量,创建新消息
|
||||
runInAction(() => {
|
||||
this.messageList.unshift({
|
||||
id: uid(32),
|
||||
role: "assistant",
|
||||
content: event.data.content,
|
||||
})
|
||||
this.isFirstDelta = false
|
||||
})
|
||||
} else {
|
||||
this.updateMessageContent(content)
|
||||
}
|
||||
}
|
||||
break
|
||||
case WebsocketsEventType.CONVERSATION_MESSAGE_COMPLETED: {
|
||||
// 收到完成事件,重置标记,下一次将创建新消息
|
||||
runInAction(() => {
|
||||
this.isFirstDelta = true
|
||||
this.tempContent = ""
|
||||
})
|
||||
break
|
||||
}
|
||||
case WebsocketsEventType.CONVERSATION_AUDIO_DELTA: {
|
||||
runInAction(() => {
|
||||
this.status = EStatus.waiting
|
||||
})
|
||||
break
|
||||
}
|
||||
case ClientEventType.AUDIO_SENTENCE_PLAYBACK_ENDED: {
|
||||
runInAction(() => {
|
||||
this.status = EStatus.listening
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
handleInterrupt = () => {
|
||||
try {
|
||||
this.clientRef.current?.interrupt()
|
||||
} catch (error) {
|
||||
message.error(`打断失败:${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
handleConnect = async () => {
|
||||
try {
|
||||
this.status = EStatus.connecting
|
||||
if (!this.clientRef.current) {
|
||||
await this.initClient()
|
||||
}
|
||||
await this.clientRef.current?.connect()
|
||||
runInAction(() => {
|
||||
this.status = EStatus.connected
|
||||
})
|
||||
// 设置初始音量
|
||||
if (this.clientRef.current) {
|
||||
this.clientRef.current.setPlaybackVolume(1)
|
||||
}
|
||||
} catch (error) {
|
||||
this.getBotToken()
|
||||
console.error(`连接错误:${(error as Error).message}`)
|
||||
this.status = EStatus.error
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect = async () => {
|
||||
try {
|
||||
await this.clientRef.current?.disconnect()
|
||||
this.clientRef.current = null
|
||||
runInAction(() => {
|
||||
this.status = EStatus.disconnected
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(`断开失败:${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
getBotToken = async () => {
|
||||
try {
|
||||
// 仅用于本地开发:后端返回 PAT(请求后端端口)
|
||||
const url = `${getApiUrl()}/agent/v1/cozechat/get-token?modelEnum=CONSULTANT_PRACTICE`
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
const data = await resp.json()
|
||||
if (data && data.code === 10000 && data.data) {
|
||||
localStorage.setItem("chat-x_pat", data.data)
|
||||
console.log("[chat] Token获取成功")
|
||||
} else {
|
||||
console.error("[chat] Token获取失败,响应数据:", data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[chat] Token获取请求失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
getBotInfo = async () => {
|
||||
// 使用咨询师陪练的 bot_id,与后端配置保持一致
|
||||
const botId = "7509379008556089379"
|
||||
const res = await getBotInfo(botId)
|
||||
runInAction(() => {
|
||||
if (res.code === 10000) {
|
||||
this.userId = uid(32)
|
||||
document.title = res.data.bot.name
|
||||
this.botInfo = {
|
||||
...res.data.bot,
|
||||
suggestedQuestions:
|
||||
res.data.bot.onboarding_info?.suggested_questions || [],
|
||||
}
|
||||
this.messageList = [
|
||||
{
|
||||
id: uid(32),
|
||||
content: res.data.bot.onboarding_info?.prologue || "你好,我是你的AI助手,有什么可以帮助你的吗?",
|
||||
role: "assistant",
|
||||
prologue: "true",
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
streamingChat = (query: string) => {
|
||||
if (this.isLoading) {
|
||||
return
|
||||
}
|
||||
runInAction(() => {
|
||||
this.messageList.unshift({
|
||||
id: uid(32),
|
||||
role: "user",
|
||||
content: query,
|
||||
file_url: this.fileArr,
|
||||
})
|
||||
this.messageList.unshift({
|
||||
id: uid(32),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
loading: true,
|
||||
is_answer: 1,
|
||||
})
|
||||
})
|
||||
this.creatChat(query)
|
||||
}
|
||||
// 创建一个辅助函数来更新消息内容
|
||||
updateMessageContent = (content: string) => {
|
||||
runInAction(() => {
|
||||
this.messageList[0] = {
|
||||
...this.messageList[0],
|
||||
content: content,
|
||||
loading: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
creatChat = async (query: string, id?: string) => {
|
||||
this.controller = new AbortController()
|
||||
const fileIds = this.fileArr.map((item) => item.id)
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.isLoading = true
|
||||
this.fileArr = []
|
||||
})
|
||||
|
||||
// 如果没有对话ID,先创建对话
|
||||
if (!this.conversationID) {
|
||||
const conversationRes = await createConversation({ bot_id: "7509379008556089379" })
|
||||
if (conversationRes.code === 10000) {
|
||||
this.conversationID = conversationRes.data.id
|
||||
}
|
||||
}
|
||||
|
||||
const historyMessages = this.messageList
|
||||
.filter((val) => val.content !== "" && !val.prologue)
|
||||
.map((item) => {
|
||||
return {
|
||||
role: item.role,
|
||||
content: item.content,
|
||||
file_ids:
|
||||
item.file_url && item.file_url.length
|
||||
? item.file_url.map((item: any) => item.id)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
const response = await startChatStream(
|
||||
{
|
||||
conversation_id: this.conversationID,
|
||||
content: query,
|
||||
bot_id: "7509379008556089379",
|
||||
file_ids: fileIds,
|
||||
user_id: this.userId,
|
||||
history_messages: historyMessages,
|
||||
},
|
||||
this.controller.signal
|
||||
)
|
||||
let currentContent = ""
|
||||
let chatId = ""
|
||||
for await (const part of XStream({
|
||||
readableStream: response,
|
||||
})) {
|
||||
if (this.controller?.signal.aborted) {
|
||||
// Check if aborted during iteration
|
||||
break
|
||||
}
|
||||
if (typeof part === "string") {
|
||||
continue
|
||||
}
|
||||
// 去除事件名称前后的空格
|
||||
const eventName = part.event?.trim() || ""
|
||||
if (
|
||||
eventName === "conversation.chat.created" ||
|
||||
eventName === "conversation.message.delta" ||
|
||||
eventName === "conversation.message.completed"
|
||||
) {
|
||||
part.data = JSON.parse(part.data)
|
||||
}
|
||||
console.log("🔄 SSE Event received:", `"${part.event}"`, "->", `"${eventName}"`, part.data)
|
||||
runInAction(() => {
|
||||
if (eventName === "conversation.chat.created") {
|
||||
this.conversationID = part.data.conversation_id
|
||||
} else if (eventName === "conversation.message.delta") {
|
||||
currentContent += part.data.content
|
||||
chatId = part.data.chat_id
|
||||
this.updateMessageContent(currentContent)
|
||||
if (chatId && this.chatId !== chatId) {
|
||||
this.chatId = chatId
|
||||
}
|
||||
}
|
||||
if (
|
||||
eventName === "conversation.chat.failed" ||
|
||||
eventName === "error"
|
||||
) {
|
||||
if (this.messageList[0].role === "assistant") {
|
||||
// 如果内容为空,添加错误消息
|
||||
if (!this.messageList[0].content) {
|
||||
this.messageList[0].content = "抱歉,请求出错了,请重试。"
|
||||
}
|
||||
this.messageList[0] = {
|
||||
...this.messageList[0],
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "CanceledError") {
|
||||
runInAction(() => {
|
||||
if (this.messageList[0].role === "assistant") {
|
||||
// 如果内容为空,添加错误消息
|
||||
if (!this.messageList[0].content) {
|
||||
this.messageList[0].content = "会话已中断。"
|
||||
}
|
||||
this.messageList[0] = {
|
||||
...this.messageList[0],
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.error("Error during chat stream:", error)
|
||||
// 在错误情况下更新UI状态
|
||||
runInAction(() => {
|
||||
if (this.messageList[0].role === "assistant") {
|
||||
// 如果内容为空,添加错误消息
|
||||
if (!this.messageList[0].content) {
|
||||
this.messageList[0].content = "抱歉,请求出错了,请重试。"
|
||||
}
|
||||
this.messageList[0] = {
|
||||
...this.messageList[0],
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.complateChat()
|
||||
this.controller = null
|
||||
}
|
||||
}
|
||||
|
||||
abortChat = async () => {
|
||||
if (this.chatId && this.conversationID) {
|
||||
await interruptChat({
|
||||
chat_id: this.chatId,
|
||||
conversation_id: this.conversationID,
|
||||
})
|
||||
}
|
||||
this.controller?.abort()
|
||||
this.complateChat()
|
||||
}
|
||||
|
||||
complateChat = () => {
|
||||
runInAction(() => {
|
||||
this.isLoading = false
|
||||
if (this.messageList[0] && this.messageList[0].is_answer) {
|
||||
this.messageList[0] = {
|
||||
...this.messageList[0],
|
||||
is_answer: 0,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
deleteMessage = async (id: string) => {
|
||||
// 暂时简化消息删除功能
|
||||
runInAction(() => {
|
||||
if (this.messageList.length > 0) {
|
||||
this.messageList = this.messageList.filter((item) => item.id !== id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
reGenerate = () => {
|
||||
const content = this.messageList[1]?.content
|
||||
const chatId = this.chatId || this.messageList[0]?.chat_id
|
||||
this.messageList[0] = {
|
||||
id: uid(32),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
loading: true,
|
||||
is_answer: 1,
|
||||
}
|
||||
this.creatChat(content, chatId)
|
||||
}
|
||||
|
||||
getBase64(img: any, callback: any) {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("load", () => callback(reader.result))
|
||||
reader.readAsDataURL(img)
|
||||
}
|
||||
|
||||
onRemoveFile = (fileId: string) => {
|
||||
const fileList = this.fileArr.filter((file: any) => file.id !== fileId)
|
||||
this.fileArr = fileList
|
||||
}
|
||||
|
||||
handleUploadFile = async (file?: any) => {
|
||||
if (!file) {
|
||||
this.fileArr = []
|
||||
return
|
||||
}
|
||||
// 暂时简化文件上传功能
|
||||
this.uploading = false
|
||||
}
|
||||
|
||||
scrollToBottom = () => {
|
||||
requestAnimationFrame(() => {
|
||||
this.containerRef.current?.scrollTo({
|
||||
offset: this.containerRef.current?.nativeElement.scrollHeight + 100,
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
export default new TrainingStore()
|
||||
9
知识库/参考代码/coze-chat-frontend/src/stores/config.ts
Executable 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,与后端配置保持一致
|
||||
}
|
||||
9
知识库/参考代码/coze-chat-frontend/src/stores/index.ts
Executable 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
|
||||
8
知识库/参考代码/coze-chat-frontend/src/stores/utils.ts
Executable 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 };
|
||||
3
知识库/参考代码/coze-chat-frontend/src/style/functions.scss
Executable file
@@ -0,0 +1,3 @@
|
||||
@function px2vw($px) {
|
||||
@return calc(#{$px} * 100vw / 750);
|
||||
}
|
||||
142
知识库/参考代码/coze-chat-frontend/src/style/global.scss
Executable 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;
|
||||
}
|
||||
68
知识库/参考代码/coze-chat-frontend/src/style/iconfonts/iconfont.css
Executable 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';
|
||||
}
|
||||
BIN
知识库/参考代码/coze-chat-frontend/src/style/iconfonts/iconfont.woff2
Executable file
5
知识库/参考代码/coze-chat-frontend/src/style/mixins/fontSize.scss
Executable file
@@ -0,0 +1,5 @@
|
||||
@for $fs from 10 through 42 {
|
||||
.fs-#{$fs} {
|
||||
font-size: #{$fs}px !important;
|
||||
}
|
||||
}
|
||||
63
知识库/参考代码/coze-chat-frontend/src/style/mixins/interval.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
2
知识库/参考代码/coze-chat-frontend/src/style/variables.scss
Executable file
@@ -0,0 +1,2 @@
|
||||
$primary-color: #6633ff;
|
||||
$height-primary: var(--height-primary, 100vh);
|
||||
4
知识库/参考代码/coze-chat-frontend/src/utils/api.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
test_api: "http://localhost:8000",
|
||||
prod_api: "http://localhost:8000",
|
||||
}
|
||||
119
知识库/参考代码/coze-chat-frontend/src/utils/request.ts
Executable 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,
|
||||
})
|
||||
}
|
||||
103
知识库/参考代码/coze-chat-frontend/src/utils/tools.ts
Executable 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")
|
||||
44
知识库/参考代码/coze-chat-frontend/src/utils/tts.ts
Normal 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
1
知识库/参考代码/coze-chat-frontend/src/vite-env.d.ts
vendored
Executable file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
30
知识库/参考代码/coze-chat-frontend/tsconfig.app.json
Executable 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"]
|
||||
}
|
||||
7
知识库/参考代码/coze-chat-frontend/tsconfig.json
Executable file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
0
知识库/参考代码/python_dev_project/.env.example
Normal file
0
知识库/参考代码/python_dev_project/Makefile
Normal 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'
|
||||
220
知识库/参考代码/python_dev_project/docs/modules/exam_module.md
Normal 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接口
|
||||
- 测试场景包括:
|
||||
- 正常流程测试
|
||||
- 异常情况处理
|
||||
- 权限控制验证
|
||||
- 边界条件测试
|
||||
187
知识库/参考代码/python_dev_project/src/api/v1/exams.py
Normal 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)
|
||||
15
知识库/参考代码/python_dev_project/src/api/v1/router.py
Normal 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"])
|
||||
0
知识库/参考代码/python_dev_project/src/core/deps.py
Normal file
0
知识库/参考代码/python_dev_project/src/models/__init__.py
Normal file
0
知识库/参考代码/python_dev_project/src/models/base.py
Normal file
0
知识库/参考代码/python_dev_project/src/models/exam.py
Normal file
0
知识库/参考代码/python_dev_project/src/models/user.py
Normal file
0
知识库/参考代码/python_dev_project/src/schemas/__init__.py
Normal file
0
知识库/参考代码/python_dev_project/src/schemas/base.py
Normal file
0
知识库/参考代码/python_dev_project/src/schemas/exam.py
Normal file
1
知识库/参考代码/python_dev_project/src/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services package
|
||||
1
知识库/参考代码/python_dev_project/src/services/ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# AI services package
|
||||
@@ -0,0 +1 @@
|
||||
# Dify integration package
|
||||
217
知识库/参考代码/python_dev_project/src/services/ai/dify/client.py
Normal 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 "报告生成失败"
|
||||