Compare commits

...

10 Commits

Author SHA1 Message Date
xjs de83d62659 feat: init 2025-11-14 14:56:53 +08:00
xjs 48fe17a4cb feat: 添加本地变量示范 2025-08-15 09:46:48 +08:00
xjs 45df1a6108 feat: 暂时隐藏收费 2025-07-09 15:57:02 +08:00
xjs ff67e95c99 feat: 文本修改 2025-07-09 15:57:02 +08:00
xjs 2c36ce528e feat: 计费功能 2025-07-09 15:57:02 +08:00
xjs 48ed954da0 feat: 计费功能 2025-07-09 15:57:02 +08:00
xjs d774630aea feat: 增加加载动画 2025-06-11 17:13:40 +08:00
xjs 84223d4c70 fix: android修复 2025-06-10 13:51:12 +08:00
xjs 86918a667e feat: 对话流采用策略设计模式重构 2025-05-29 17:50:05 +08:00
xjs 97d2ccc154 feat: AI对话流模式设计 2025-05-29 11:46:30 +08:00
28 changed files with 733 additions and 203 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
VITE_COZE_TOKEN=pat_JJU3h01pYFQrH1cGeauzKZz2dakpkQglohNTp2PeIIlKRtXhi8fGeaCZGtDLmDoq
VITE_COZE_BOT_ID=7456409430717480998
VITE_COZE_CONNECTOR_ID=1024
VITE_COZE_VOICE_ID=7426720361733144585

View File

@ -1,4 +1,4 @@
# 六维小助手
# 六维小助手 初中版本
六维小助手

BIN
public/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
public/icons/receive.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@ -6,7 +6,7 @@ export const fetchQuestions = async ({
options?: { signal?: AbortSignal,};
}) => {
const response = await getRequest(
"https://api.v3.ycymedu.com/api/zhiYuan/aigcquestionswords?",
"https://senior.ycymedu.com/api/zhiYuan/aigcquestionswords?",
{},
options
);

View File

@ -6,7 +6,7 @@ export const fetchUserToken = async ({
options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => {
const response = await getRequest(
"https://api.v3.ycymedu.com/api/sysOnlineUser/hasitexpired",
"https://senior.ycymedu.com/api/sysOnlineUser/hasitexpired",
{},
options
);
@ -26,7 +26,7 @@ export const fetchReport = async ({
options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => {
const response = await getRequest(
"https://api.v3.ycymedu.com/api/busScale/GetBusAIReportKeyWord",
"https://senior.ycymedu.com/api/busScale/GetBusAIReportKeyWord",
params,
options
);
@ -46,7 +46,7 @@ export const fetchFile = async ({
options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => {
const response = await getRequest(
"https://api.v3.ycymedu.com/api/volunTb/downloadpdfUrl",
"https://senior.ycymedu.com/api/volunTb/downloadpdfUrl",
params,
options
);
@ -63,10 +63,10 @@ export const fetchWishList = async ({
params,
options,
}: {
params: { locationCode: string; };
params: {};
options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => {
const response = await getRequest("https://api.v3.ycymedu.com/api/volunTb/v2/list", params, options);
const response = await getRequest("https://senior.ycymedu.com/api/busStudentMiddleFill/list", params, options);
if (response.code === 200) {
return { result: response.result };
@ -74,3 +74,40 @@ export const fetchWishList = async ({
return { result: [], message: response.message };
}
};
export const getCozeToken = async({
options,
}: {
options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => {
const response = await getRequest("https://senior.ycymedu.com/api/sysDictData/secrtToken", {Id:740405293269061}, options);
if (response.code === 200) {
return { result: response.result };
} else {
return { result: [], message: response.message };
}
}
export const fetchSpecialFile = async ({
params,
options,
}: {
params: { Type: string,UserId:string };
options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => {
const response = await getRequest(
"https://senior.ycymedu.com/api/volunTb/yITICreate",
params,
options
);
if (response.code === 200) {
return { result: response.result };
} else {
return { result: "", message: response.message };
}
};

View File

@ -1,11 +1,14 @@
import { lazy } from "react";
import { Loading } from "@/components/Loading";
import { lazy, Suspense } from "react";
const MainArea = lazy(() => import("@/app/MainArea/index"));
function App() {
return (
<div className="h-full bg-[#F4F6FA]">
<MainArea />
<Suspense fallback={<Loading/>}>
<MainArea />
</Suspense>
</div>
);
}

View File

@ -1,49 +1,50 @@
import AntechamberHeader from "@/components/AntechamberHeader";
import InvokeButton from "@/components/AntechamberButton";
import AntechamberScore from "@/components/AntechamberScore";
// import AntechamberScore from "@/components/AntechamberScore";
import { useContext, useEffect, useState } from "react";
import { RealtimeClientContext } from "@/components/Provider/RealtimeClientProvider";
import { useSearchParams } from "react-router-dom";
import { fetchUserToken } from "@/apis/user";
import { fetchUserToken } from "@/apis/user";
import { useToast } from "@/hooks/use-toast";
import { useAbortController } from "@/hooks/useAbortController";
import AntechamberFile from "@/components/AntechamberFile";
import AntechamberReport from "@/components/AntechamberReport";
import AntechamberWishList from "@/components/AntechamberWishList";
// import AntechamberWishList from "@/components/AntechamberWishList";
import { ReportContext } from "@/components/Provider/ReportResolveProvider";
// import ReceiveTime from "/icons/receive-time.png";
import { ReceiveDialog } from "@/components/ReceiveDialog";
import AntechamberTalentFile from "@/components/AntechamberTalentFile";
export default function Antechamber() {
const { handleConnect} = useContext(RealtimeClientContext);
const { handleConnect } = useContext(RealtimeClientContext);
const { hasHandledReport } = useContext(ReportContext);
const [searchParams] = useSearchParams();
const [disable,setDisable] = useState(true);
const [isLoading,setIsLoading] = useState(false);
const [disable, setDisable] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const token = searchParams.get("token") || "";
const token = searchParams.get("token") || '';
const { toast } = useToast();
const { getSignal } = useAbortController();
const getUserToken = async () => {
try {
const { result, message } = await fetchUserToken({
options: {
const { result, message } = await fetchUserToken({
options: {
signal: getSignal(),
headers: {
"Authorization": `Bearer ${encodeURIComponent(token)}`
}
}
Authorization: `Bearer ${encodeURIComponent(token)}`,
},
},
});
if (message) {
console.log(message);
} else {
const _result = result as {isExpired:boolean;msg:string};
const _result = result as { isExpired: boolean; msg: string };
setDisable(!_result.isExpired);
if(!_result.isExpired &&_result.msg){
if (!_result.isExpired && _result.msg) {
toast({
title: _result.msg,
description: "请重新登录",
@ -51,42 +52,61 @@ export default function Antechamber() {
}
}
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('获取用户令牌失败:', error);
if (error.name !== "AbortError") {
console.error("获取用户令牌失败:", error);
}
}
};
useEffect(() => {
getUserToken();
}, [token]);
const toRoom = (params:{initMessage?:string,fileUrl?:string}) => {
if(!hasHandledReport && (disable || isLoading)){
const toRoom = (params: { initMessage?: string; fileUrl?: string }) => {
if (!hasHandledReport && (disable || isLoading)) {
return;
}
setIsLoading(true)
setIsLoading(true);
handleConnect(params).then(() => {
setIsLoading(false);
});
};
const [isReceiveDialogOpen, setIsReceiveDialogOpen] = useState(false);
return (
<div className="flex flex-col items-center h-full overflow-y-auto relative">
<AntechamberHeader />
<AntechamberScore />
<AntechamberWishList handleLoading={setIsLoading}/>
{/* <AntechamberScore />
<AntechamberWishList handleLoading={setIsLoading} /> */}
<AntechamberFile handleLoading={setIsLoading} />
<AntechamberTalentFile handleLoading={setIsLoading}/>
<AntechamberReport handleLoading={setIsLoading} />
<InvokeButton disable={disable} onClick={() => toRoom({})} />
{
isLoading ? <div className="absolute w-full h-full bg-red"><div className="w-[108px] h-[108px] absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-black/60 rounded-[20px] flex flex-col items-center justify-center">
<img src="/icons/loading.gif" alt="loading" className="w-[68px] h-[68px]" />
<span className="text-[14px] text-[#fff]"></span>
</div></div> : <></>
}
{/* <img
src={ReceiveTime}
alt="receive-item"
className="w-[100px] h-[100px] absolute right-[12px] bottom-[80px]"
onClick={() => setIsReceiveDialogOpen(true)}
/> */}
{isLoading ? (
<div className="absolute w-full h-full bg-red">
<div className="w-[108px] h-[108px] absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-black/60 rounded-[20px] flex flex-col items-center justify-center">
<img
src="/icons/loading.gif"
alt="loading"
className="w-[68px] h-[68px]"
/>
<span className="text-[14px] text-[#fff]"></span>
</div>
</div>
) : (
<></>
)}
<ReceiveDialog
isOpen={isReceiveDialogOpen}
onOpenChange={setIsReceiveDialogOpen}
/>
</div>
);
}

View File

@ -6,20 +6,21 @@ import {
RealtimeClientProvider,
} from "@/components/Provider/RealtimeClientProvider";
import { ReportProvider } from "@/components/Provider/ReportResolveProvider";
import { RealtimeUtils } from "@coze/realtime-api";
// import { RealtimeUtils } from "@coze/realtime-api";
import { useLocation } from "react-router-dom";
import { CountdownProvider } from "@/components/Provider/CountdownProvider";
function MainContent() {
const { isConnected, handleDisconnect } = useContext(RealtimeClientContext);
const location = useLocation();
const handlePromise = async() => {
await RealtimeUtils.checkDevicePermission(false);
}
// const handlePromise = async() => {
// await RealtimeUtils.checkDevicePermission(false);
// }
useEffect(() => {
handlePromise();
}, []);
// useEffect(() => {
// handlePromise();
// }, []);
useEffect(() => {
if (isConnected) {
@ -28,9 +29,13 @@ function MainContent() {
}, [location.pathname]);
return (
<ReportProvider>
{isConnected ? <Room /> : <Antechamber />}
</ReportProvider>
<CountdownProvider>
<ReportProvider>
<>
{isConnected ? <Room /> : <Antechamber />}
</>
</ReportProvider>
</CountdownProvider>
);
}

View File

@ -39,8 +39,9 @@ export default function AntechamberFile({handleLoading}:Props) {
handleConnect({
fileInfo: {type: resp.type,url: resp.url,tableName: resp.tableName,provinceName: resp.provinceName,subjectClaim: resp.subjectClaim},
}).then(() => {
setHasHandledReport(true);
});
setHasHandledReport(true);
};

View File

@ -8,7 +8,7 @@ import styles from "./index.module.css";
import { fetchQuestions } from "@/apis/questions";
import { useAbortController } from "@/hooks/useAbortController";
import { RealtimeClientContext } from "../Provider/RealtimeClientProvider";
// import Countdown from "../Countdown";
export default function HeaderGroup() {
const [isRotating, setIsRotating] = useState(false);
@ -69,6 +69,7 @@ export default function HeaderGroup() {
return (
<div className={styles.headerWrapper}>
{/* <Countdown /> */}
<div className={styles.wrapper}>
<img className={styles.img} src={HelloGIF} alt="hello" />
<div className={styles.text}>Hey,AI</div>

View File

@ -20,7 +20,7 @@ export default function MyInput() {
const handleQuestion = async () => {
handleConnect({initMessage:`我的考地点在${provinceName},我选择的科目是${subjectGroup},我的高考分数为${expectedScore}分。帮我出一个科学的参考志愿表`});
handleConnect({initMessage:`我的考地点在${provinceName},我选择的科目是${subjectGroup},我的高考分数为${expectedScore}分。帮我出一个科学的参考志愿表`});
};
return (

View File

@ -0,0 +1,57 @@
import { fetchSpecialFile } from "@/apis/user";
import { useToast } from "@/hooks/use-toast";
import { useAbortController } from "@/hooks/useAbortController";
import { useContext, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { ReportContext } from "../Provider/ReportResolveProvider";
import { FileInfo, RealtimeClientContext } from "../Provider/RealtimeClientProvider";
type Props = {
handleLoading:(val:boolean) => void
}
export default function AntechamberFile({handleLoading}:Props) {
const [searchParams] = useSearchParams();
const talentTypeId = searchParams.get("talentTypeId") || "";
const userId = searchParams.get("userId") || "";
const token = searchParams.get("token") || "";
const { toast } = useToast();
const { getSignal } = useAbortController();
const { setHasHandledReport,hasHandledReport } = useContext(ReportContext);
const { handleConnect } = useContext(RealtimeClientContext);
const useFileFetch = async () => {
handleLoading(true)
const result = await fetchSpecialFile({
params: { Type:talentTypeId, UserId:userId},
options: {
signal: getSignal(),
headers: { Authorization: `Bearer ${token}` },
},
});
if (result.message) {
toast({
title: result.message,
});
}
let resp = result.result as FileInfo;
handleConnect({
fileInfo: {url: resp.url,tableName:"我的特长报告"},
}).then(() => {
setHasHandledReport(true);
});
};
useEffect(() => {
if (talentTypeId && userId && !hasHandledReport) {
useFileFetch();
}
}, [talentTypeId, userId,hasHandledReport]);
return <></>;
}

View File

@ -91,23 +91,17 @@ export default function AntechamberWishList({handleLoading}:Props) {
</DrawerTitle>
<div className="grid gap-[12px] px-[15px] pb-[15px] bg-[#F4F6FA] pt-[16px] max-h-[50vh] overflow-y-auto">
{wishList.map((item: any) => (
<div className="w-full bg-white" key={item.vId}>
<div className="w-full bg-white" key={item.id}>
<div className="py-[10px] pl-[16px] pr-[13px] rounded-[8px] flex items-center">
<div className="flex flex-col">
<div className="flex items-center mb-[8px]">
<span className="text-[15px] font-[600] text-[#303030] mr-[5px]">
{item.tableName}
{item.title}
</span>
<div className="text-[10px] px-[4px] py-[2px] rounded-[4px] text-[#636363] bg-[#fff]">
{item.type}
</div>
</div>
<div className="text-[#303030] text-[11px] flex items-center">
<span>
{item.locationName}·{item.score}
</span>
<span className="ml-[8px]">
{item.subjectClaim.split(",").join("/")}
{wishList[0].batchName}·{wishList[0].totalScore}
</span>
</div>
</div>
@ -148,18 +142,13 @@ export default function AntechamberWishList({handleLoading}:Props) {
<div className="flex flex-col">
<div className="flex items-center mb-[5px]">
<span className="text-[15px] font-[600] text-[#303030] mr-[5px]">
{wishList[0].tableName}
{wishList[0].title}
</span>
<div className="text-[10px] px-[4px] py-[2px] rounded-[4px] text-[#636363] bg-[#fff]">
{wishList[0].type}
</div>
</div>
<div className="text-[#303030] text-[11px] flex items-center">
<span>
{wishList[0].locationName}·{wishList[0].score}
</span>
<span className="ml-[8px]">
{wishList[0].subjectClaim.split(",").join("/")}
{wishList[0].batchName}·{wishList[0].totalScore}
</span>
</div>
</div>

View File

@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { useCountdown } from '../Provider/CountdownProvider';
/**
* "分钟秒"
* @param totalSeconds
* @returns "10分00秒"
*/
const formatTime = (totalSeconds: number): string => {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
// 确保秒数显示为两位数
const formattedSeconds = seconds < 10 ? `0${seconds}` : `${seconds}`;
return `${minutes}${formattedSeconds}`;
};
interface CountdownProps {
}
export default function Countdown({ }: CountdownProps) {
const { countdown, setCountdown } = useCountdown();
useEffect(() => {
// 只有当秒数大于0时才启动倒计时
if (countdown <= 0) return;
const timer = setInterval(() => {
setCountdown(countdown - 1)
}, 1000);
// 清理定时器
return () => clearInterval(timer);
}, [countdown]);
return (
<div className="w-max h-[23px] rounded-full bg-[#EAF0FE] flex items-center ml-auto px-[10px] py-[3px] mb-[5px]">
<div className="w-[6px] h-[6px] rounded-full bg-[#1580FF] mr-[5px]"></div>
<div className="text-[12px] text-[#000]">{formatTime(countdown)}</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
.custom-animation {
height: calc(100% - 12px);
will-change: width;
animation: moveBackAndForth 2s infinite linear;
}
@keyframes moveBackAndForth {
0% {
transform: translateX(0);
}
25% {
width: calc(40%);
transform: translateX(calc(50% - 12px));
}
50% {
width: calc(20%);
transform: translateX(calc(400% - 12px));
}
75% {
width: calc(40%);
transform: translateX(calc(50% - 12px));
}
100% {
transform: translateX(0);
}
}

View File

@ -0,0 +1,15 @@
import "./index.css";
import LogoSvg from "/icons/logo.png";
export const Loading = () => {
return (
<div className="h-screen w-full flex items-center justify-center">
<div className="flex mx-[12vw] rounded-full border-[3px] border-solid border-[#000] h-[54px] p-[6px] relative flex-1">
<div className="w-[calc(20%)] bg-black rounded-full custom-animation absolute left-0"></div>
<div className="w-full h-full flex items-center justify-center mix-blend-difference">
<img src={LogoSvg} className="w-[115px] h-[18px]" />
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,25 @@
import { createContext, useContext, useState } from "react"
export const CountdownContext = createContext<{
countdown: number;
setCountdown: (countdown: number) => void;
}>({
countdown: 0,
setCountdown: () => {}
})
export const useCountdown = () => {
const ctx = useContext(CountdownContext);
if (!ctx) throw new Error("useCountdown 必须在 CountdownProvider 内部使用");
return ctx;
}
export const CountdownProvider = ({ children }: { children: React.ReactNode }) => {
const [countdown, setCountdown] = useState(0);
return (
<CountdownContext.Provider value={{ countdown, setCountdown }}>
{children}
</CountdownContext.Provider>
)
}

View File

@ -14,6 +14,10 @@ import {
useState,
} from "react";
import { useToast } from "@/hooks/use-toast";
import { MessageHandlerStrategy } from "@/hooks/useRealtimeClient";
import { getCozeToken } from "@/apis/user";
import { useAbortController } from "@/hooks/useAbortController";
import { useSearchParams } from "react-router-dom";
type RoomInfo = {
appId: string;
@ -23,11 +27,11 @@ type RoomInfo = {
};
export type FileInfo = {
type: string;
type?: string;
url: string;
tableName: string;
provinceName: string;
subjectClaim: string;
tableName?: string;
provinceName?: string;
subjectClaim?: string;
};
export const RealtimeClientContext = createContext<{
@ -68,7 +72,7 @@ export const RealtimeClientProvider = ({
}: {
children: ReactNode;
}) => {
const token = import.meta.env.VITE_COZE_TOKEN;
let token = "";
const botId = import.meta.env.VITE_COZE_BOT_ID;
const voiceId = import.meta.env.VITE_COZE_VOICE_ID;
const connectorId = "1024";
@ -94,6 +98,12 @@ export const RealtimeClientProvider = ({
const { toast } = useToast();
const messageHandlerStrategy = useRef(new MessageHandlerStrategy());
const { getSignal } = useAbortController();
const [searchParams]= useSearchParams();
const userToken = searchParams.get("token") || '';
/** 初始化客户端并设置监听 */
const initClient = async ({
initMessage,
@ -102,16 +112,9 @@ export const RealtimeClientProvider = ({
initMessage?: string;
fileInfo?: FileInfo;
}) => {
const perm = await RealtimeUtils.checkDevicePermission(false);
const device = await RealtimeUtils.getAudioDevices();
if (!perm.audio) {
toast({ title: "连接错误", description: "需要麦克风访问权限" });
throw new Error("需要麦克风访问权限");
}
if (device.audioInputs.length === 0) {
toast({ title: "连接错误", description: "没有麦克风设备" });
throw new Error("没有麦克风设备");
if(!token){
const resp = await getCozeToken({options:{signal:getSignal(),headers: { Authorization: `Bearer ${userToken}` }}})
token = resp.result as string
}
const client = new RealtimeClient({
@ -122,7 +125,7 @@ export const RealtimeClientProvider = ({
allowPersonalAccessTokenInBrowser: true,
suppressStationaryNoise: true,
suppressNonStationaryNoise: true,
debug: false,
debug: true,
});
clientRef.current = client;
@ -146,6 +149,18 @@ export const RealtimeClientProvider = ({
connectingLockRef.current = false;
return;
}
const perm = await RealtimeUtils.checkDevicePermission(false);
const device = await RealtimeUtils.getAudioDevices();
if (!perm.audio) {
toast({ title: "连接错误", description: "需要麦克风访问权限" });
return;
// throw new Error("需要麦克风访问权限");
}
if (device.audioInputs.length === 0) {
toast({ title: "连接错误", description: "没有麦克风设备" });
return;
// throw new Error("没有麦克风设备");
}
try {
if (!clientRef.current) {
@ -223,7 +238,7 @@ export const RealtimeClientProvider = ({
content: JSON.stringify([
{
type: "text",
text: "帮我解读这个文件,结合当下的专业行情以及对该专业未来的发展趋势,简介的给出志愿建议",
text: "帮我解读这个文件,根据济南市初升高相关政策要求和情况,给出合理的升学规划建议",
},
{ type: "image", file_url: fileInfo.url },
]),
@ -244,101 +259,17 @@ export const RealtimeClientProvider = ({
client.on(EventNames.ALL, (_eventName, event: any) => {
// 交给状态机处理解析流程
// 普通消息流处理
if (
event.event_type !== ChatEventType.CONVERSATION_MESSAGE_DELTA &&
event.event_type !== ChatEventType.CONVERSATION_MESSAGE_COMPLETED
) {
// 处理conversation.created事件
if (
event.event_type === "conversation.created" &&
!opts.initMessage &&
!opts.fileInfo
) {
setMessageList((prev) => [
...prev,
{
content: event.data.prologue,
role: RoleType.Assistant,
event,
},
]);
}
return;
}
// 如果是assistant的completed消息或verbose类型直接返回
if (
(event.data.role === "assistant" &&
event.event_type === ChatEventType.CONVERSATION_MESSAGE_COMPLETED &&
event.data.type === "verbose") ||
(event.data.type === "answer" &&
event.event_type === ChatEventType.CONVERSATION_MESSAGE_COMPLETED)
event.event_type !== ChatEventType.CONVERSATION_MESSAGE_COMPLETED &&
event.event_type !== "conversation.created"
) {
return;
}
const content = event.data.content;
setMessageList((prev) => {
// 如果是工具调用相关的消息,不添加到消息列表
if (
event.data.type === "function_call" ||
event.data.type === "tool_response"
) {
const jsonContent = JSON.parse(event.data.content);
const lastMessage = prev[prev.length - 1];
if (jsonContent.name === "doc_reader-PDF_reader") {
return [
...prev,
{
content: "",
role: RoleType.Assistant,
fileInfo: opts.fileInfo,
fileParseStatus: 1,
event,
},
];
}
else if (
lastMessage.event.type === "function_call" &&
event.data.type === "tool_response"
) {
return [
...prev.slice(0, prev.length - 2),
{
content: "",
role: RoleType.Assistant,
fileInfo: opts.fileInfo,
fileParseStatus: 2,
event,
},
];
}else{
return [...prev]
}
}
if (
prev.length > 0 &&
prev[prev.length - 1].event?.event_type ===
ChatEventType.CONVERSATION_MESSAGE_DELTA &&
event.event_type === ChatEventType.CONVERSATION_MESSAGE_DELTA &&
prev[prev.length - 1].event.data.answer_id === event.data.answer_id
) {
return [
...prev.slice(0, -1),
{
content: prev[prev.length - 1].content + content,
role: prev[prev.length - 1].role,
event,
},
];
} else {
// 新消息追加
return [...prev, { content, role: event.data.role, event }];
}
});
setMessageList(prev =>
messageHandlerStrategy.current.process(event, prev, opts)
);
});
};

View File

@ -0,0 +1,3 @@
.custom-bg {
background: linear-gradient(0deg, #FFFFFF 30%,transparent 100%);
}

View File

@ -0,0 +1,56 @@
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import "./index.css";
export const ReceiveDialog = ({
isOpen = false,
onOpenChange,
onConfirm
}: {
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onConfirm?: () => void;
}) => {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md border-none bg-transparent p-0 shadow-none">
<DialogTitle className="sr-only"></DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
<DialogClose className="absolute right-[38px] top-2 z-10 rounded-full w-[28px] h-[28px] bg-[#acacad] flex items-center justify-center bg-[#FFFFFF33]">
<X className="w-[12px] h-[12px] text-white"/>
<span className="sr-only" onClick={() => onOpenChange?.(false)}></span>
</DialogClose>
<div className="relative flex flex-col items-center">
<img
src="/icons/receive.png"
alt="接收"
className="rounded-lg w-full h-[286px] px-[48px]"
/>
<div className="absolute top-1/2 left-0 right-0 flex flex-col items-center px-[20px] py-[17px] text-center mx-[48px] rounded-[40px] custom-bg">
<h3 className="font-[600] text-[18px] text-black mb-[7px]">AI</h3>
<p className="text-[13px] mb-6 text-[#666]">AI~</p>
<div className="flex space-x-4 w-full justify-center">
<Button
className="bg-[#1580FF] text-white w-full rounded-[50px] text-[16px] py-[11px]"
onClick={() => {
onConfirm?.();
onOpenChange?.(false);
}}
>
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -3,7 +3,7 @@ import { RealtimeClientContext } from "../Provider/RealtimeClientProvider";
import ReactMarkdown from "react-markdown";
import gfm from "remark-gfm";
import { RoleType } from "@coze/api";
import { Loader } from 'lucide-react';
import { Loader } from "lucide-react";
export default function RoomConversation() {
const { messageList } = useContext(RealtimeClientContext);
@ -54,7 +54,8 @@ export default function RoomConversation() {
: "bg-blue-500 text-white rounded-tr-none"
}`}
>
{typeof message.fileParseStatus === "undefined" && typeof message.fileInfo === 'undefined' ? (
{typeof message.fileParseStatus === "undefined" &&
typeof message.fileInfo === "undefined" ? (
<ReactMarkdown remarkPlugins={[gfm]}>
{message.content}
</ReactMarkdown>
@ -70,22 +71,36 @@ export default function RoomConversation() {
<span className="text-[15px] text-[#303030] mr-[8px] leading-[1]">
{message?.fileInfo?.tableName}
</span>
<div className="bg-[#F4F6FA] rounded-[4px] w-[48px] h-[16px] px-[4px] py-[2px] text-[10px]">
{message?.fileInfo?.type}
</div>
{message?.fileInfo?.type ? (
<div className="bg-[#F4F6FA] rounded-[4px] w-[48px] h-[16px] px-[4px] py-[2px] text-[10px]">
{message.fileInfo.type}
</div>
) : (
<></>
)}
</div>
<div className="text-[12px] text-[#303030] mt-[6px] flex items-center">
<span className="mr-[10px]">
{message?.fileInfo?.provinceName}·{message?.fileInfo?.score}
</span>
<span>
{message?.fileInfo?.subjectClaim?.split(",").join("/")}
</span>
{
message.fileParseStatus < 2 && (
<Loader className="w-[12px] h-[12px] animate-spin ml-[6px]" />
)
}
{message.fileInfo.provinceName ? (
<span className="mr-[10px]">
{message?.fileInfo?.provinceName}·
{message?.fileInfo?.score}
</span>
) : (
<></>
)}
{message?.fileInfo?.subjectClaim ? (
<span>
{message?.fileInfo?.subjectClaim
?.split(",")
.join("/")}
</span>
) : (
<></>
)}
{message.fileParseStatus < 2 && (
<Loader className="w-[12px] h-[12px] animate-spin ml-[6px]" />
)}
</div>
</div>
</div>
@ -95,7 +110,13 @@ export default function RoomConversation() {
))}
<div ref={messagesEndRef} />
</div>
<div className="text-[12px] text-[#999] font-[400] text-center mt-auto mb-[7px]">AI</div>
<div className="text-[12px] text-[#999] font-[400] text-center mt-auto mb-[7px]">
AI
</div>
{/* <div className="mx-[8px] rounded-full bg-[#ffeede] py-[7px] pl-[18px] pr-[3px] flex items-center justify-between">
<span className="text-[#FA8E23] text-[12px]">3~</span>
<button className="rounded-full bg-[#FA8E23] py-[6px] px-[13px] text-[13px] text-white font-[500]"></button>
</div> */}
</div>
);
}

View File

@ -0,0 +1,119 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
import { Cross2Icon } from "@radix-ui/react-icons"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -21,7 +21,7 @@ export const useSignalRConnection = (params: {
.withServerTimeout(30000)
.withAutomaticReconnect()
.withUrl(
`https://api.v3.ycymedu.com/hubs/weminpro?access_token=${params.access_token}&roomId=${params.roomId}`
`https://senior.ycymedu.com/hubs/weminpro?access_token=${params.access_token}&roomId=${params.roomId}`
)
.configureLogging(signalR.LogLevel.Information)
.build();

View File

@ -0,0 +1,175 @@
import { ChatEventType, RoleType } from "@coze/api";
interface MessageHandler {
canHandle(event: any, opts?: any, prevMessageList?: any[]): boolean;
handle(event: any, prevMessageList: any[], opts?: any): any[];
}
abstract class BaseMessageHandler implements MessageHandler {
abstract canHandle(event: any, opts: any, prevMessageList?: any[]): boolean;
abstract handle(event: any, prevMessageList: any[], opts: any): any[];
protected createMessage(content: string, role: RoleType, event: any, opts?: any) {
const fileParseStatus = opts && opts.fileInfo ? event.data.type === "function_call" ? 1 :
event.data.type === "tool_response" ? 2 : undefined:undefined
return {
content,
role,
fileInfo: (opts && opts.fileInfo) ? opts.fileInfo : undefined,
fileParseStatus: fileParseStatus,
event
};
}
}
// 普通消息处理 添加欢迎语
class ConversationHelloHandler extends BaseMessageHandler {
canHandle(event: any, opts: any, _prevMessageList?: any[]): boolean {
return event.event_type === "conversation.created" &&
!opts.initMessage &&
!opts.fileInfo;
}
handle(event: any, prevMessageList: any[], _opts: any): any[] {
return [
...prevMessageList,
this.createMessage(event.data.prologue, RoleType.Assistant, event,)
];
}
}
// 处理忽略的事件类型
class IgnoredEventHandler extends BaseMessageHandler {
canHandle(event: any, _opts: any, _prevMessageList?: any[]): boolean {
return (
(event.data.role === "assistant" &&
event.event_type === ChatEventType.CONVERSATION_MESSAGE_COMPLETED &&
event.data.type === "verbose") ||
(event.data.type === "answer" &&
event.event_type === ChatEventType.CONVERSATION_MESSAGE_COMPLETED)
);
}
handle(_event: any, prevMessageList: any[], _opts: any): any[] {
// 直接返回原消息列表,不做任何更改
return prevMessageList;
}
}
class UserMessageEventHandle extends BaseMessageHandler{
canHandle(event: any, _opts: any, _prevMessageList?: any[]): boolean {
return event.event_type === ChatEventType.CONVERSATION_MESSAGE_COMPLETED && event.data.role === RoleType.User
}
handle(event: any, prevMessageList: any[], _opts: any): any[] {
return [...prevMessageList,this.createMessage(event.data.content,event.data.role,event)]
}
}
class NormalMessageEventHandle extends BaseMessageHandler{
canHandle(event: any, _opts: any, prevMessageList?: any[]): boolean {
if(typeof prevMessageList === 'undefined'){
return false
}
let prevMessage = prevMessageList[prevMessageList.length - 1];
// 当前信息为增量,并且上一条信息为完成
if(prevMessage?.event.event_type === ChatEventType.CONVERSATION_MESSAGE_COMPLETED && event.event_type === ChatEventType.CONVERSATION_MESSAGE_DELTA){
return true
}else{
return false;
}
}
handle(event: any, prevMessageList: any[], _opts: any): any[] {
return [...prevMessageList,this.createMessage(event.data.content,event.data.role,event)]
}
}
class AppendMessageEventHandle extends BaseMessageHandler{
canHandle(event: any, _opts: any, prevMessageList?: any[]): boolean {
if(typeof prevMessageList === 'undefined' || prevMessageList.length === 0){
return false;
}
let prevMessage = prevMessageList[prevMessageList.length - 1];
if(prevMessage.event?.event_type === ChatEventType.CONVERSATION_MESSAGE_DELTA && event.event_type === ChatEventType.CONVERSATION_MESSAGE_DELTA){
return true
}else{
return false;
}
}
handle(event: any, prevMessageList: any[], _opts: any): any[] {
let prevMessage = prevMessageList[prevMessageList.length - 1];
return [...prevMessageList.slice(0,prevMessageList.length-1),{...prevMessage,content:prevMessage.content + event.data.content}]
}
}
class BottomMessageEventHandle extends BaseMessageHandler{
canHandle(_event: any, _opts: any, _prevMessageList?: any[]): boolean {
return true
}
handle(_event: any, _prevMessageList: any[], _opts: any): any[] {
return _prevMessageList
}
}
class FileFunctionCallEventHandle extends BaseMessageHandler{
canHandle(event: any, _opts: any, _prevMessageList?: any[]): boolean {
if(event.data.type === "function_call"){
let jsonData = JSON.parse(event.data.content)
return jsonData.name==='doc_reader-PDF_reader' && event.event_type === "conversation.message.completed"
}else{
return false
}
}
handle(event: any, prevMessageList: any[], opts: any): any[] {
return [...prevMessageList,this.createMessage('',event.data.role,event,opts)]
}
}
class FileFunctionResponseEventHandle extends BaseMessageHandler{
canHandle(event: any, _opts: any, prevMessageList?: any[]): boolean {
if(typeof prevMessageList === 'undefined'){
return false
}
let prevMessage = prevMessageList[prevMessageList.length - 1];
if( prevMessage?.event.data.type === "function_call" && event.data.type ==="tool_response"){
return true
}else {
return false
}
}
handle(event: any, prevMessageList: any[], opts: any): any[] {
return [...prevMessageList.slice(0,prevMessageList.length-1),this.createMessage('',event.data.role,event,opts)]
}
}
export class MessageHandlerStrategy {
private handlers: MessageHandler[];
constructor() {
this.handlers = [
new ConversationHelloHandler(),
new IgnoredEventHandler(),
new UserMessageEventHandle(),
new NormalMessageEventHandle(),
new AppendMessageEventHandle(),
new FileFunctionCallEventHandle(),
new FileFunctionResponseEventHandle(),
new BottomMessageEventHandle()
];
}
process(event: any, prevMessageList: any[], opts: any): any[] {
for (const handler of this.handlers) {
if (handler.canHandle(event, opts, prevMessageList)) {
return handler.handle(event, prevMessageList, opts);
}
}
return prevMessageList; // 理论上不会执行到这里
}
}

View File

@ -8,20 +8,17 @@ export const useWishList = () => {
const { getSignal } = useAbortController();
const [searchParams]= useSearchParams();
const locationCode = searchParams.get("locationCode") || '';
const token = searchParams.get("token") || '';
const getWishList = async () => {
const res = await fetchWishList({params:{locationCode:locationCode},options:{signal:getSignal(),headers: { Authorization: `Bearer ${token}` },}});
const res = await fetchWishList({params:{},options:{signal:getSignal(),headers: { Authorization: `Bearer ${token}` },}});
const _wishList = res.result as any[];
setWishList(_wishList);
};
useEffect(() => {
if(locationCode){
getWishList();
}
}, [locationCode]);
}, []);
return {
wishList,

View File

@ -3,7 +3,7 @@
# 服务器信息
SERVER_USER="root"
SERVER_HOST="106.14.30.150"
SERVER_PATH="/opt/1panel/apps/openresty/openresty/www/sites/chat.ycymedu.com/index"
SERVER_PATH="/opt/1panel/apps/openresty/openresty/www/sites/m.senior.ycymedu.com/index"
PRIVATE_KEY="ALIYUN.pem"
BACKUP_PATH="${SERVER_PATH}-backup-$(date +%Y%m%d%H%M%S).zip"
DINGDING_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=fca104958fea6273c9c7ef3f08b3d552645c214f929066785e8caf6e1885a5a6"