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,}; options?: { signal?: AbortSignal,};
}) => { }) => {
const response = await getRequest( const response = await getRequest(
"https://api.v3.ycymedu.com/api/zhiYuan/aigcquestionswords?", "https://senior.ycymedu.com/api/zhiYuan/aigcquestionswords?",
{}, {},
options options
); );

View File

@ -6,7 +6,7 @@ export const fetchUserToken = async ({
options?: { signal?: AbortSignal; headers?: Record<string, string> }; options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => { }) => {
const response = await getRequest( const response = await getRequest(
"https://api.v3.ycymedu.com/api/sysOnlineUser/hasitexpired", "https://senior.ycymedu.com/api/sysOnlineUser/hasitexpired",
{}, {},
options options
); );
@ -26,7 +26,7 @@ export const fetchReport = async ({
options?: { signal?: AbortSignal; headers?: Record<string, string> }; options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => { }) => {
const response = await getRequest( const response = await getRequest(
"https://api.v3.ycymedu.com/api/busScale/GetBusAIReportKeyWord", "https://senior.ycymedu.com/api/busScale/GetBusAIReportKeyWord",
params, params,
options options
); );
@ -46,7 +46,7 @@ export const fetchFile = async ({
options?: { signal?: AbortSignal; headers?: Record<string, string> }; options?: { signal?: AbortSignal; headers?: Record<string, string> };
}) => { }) => {
const response = await getRequest( const response = await getRequest(
"https://api.v3.ycymedu.com/api/volunTb/downloadpdfUrl", "https://senior.ycymedu.com/api/volunTb/downloadpdfUrl",
params, params,
options options
); );
@ -63,10 +63,10 @@ export const fetchWishList = async ({
params, params,
options, options,
}: { }: {
params: { locationCode: string; }; params: {};
options?: { signal?: AbortSignal; headers?: Record<string, string> }; 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) { if (response.code === 200) {
return { result: response.result }; return { result: response.result };
@ -74,3 +74,40 @@ export const fetchWishList = async ({
return { result: [], message: response.message }; 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")); const MainArea = lazy(() => import("@/app/MainArea/index"));
function App() { function App() {
return ( return (
<div className="h-full bg-[#F4F6FA]"> <div className="h-full bg-[#F4F6FA]">
<MainArea /> <Suspense fallback={<Loading/>}>
<MainArea />
</Suspense>
</div> </div>
); );
} }

View File

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

View File

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

View File

@ -39,8 +39,9 @@ export default function AntechamberFile({handleLoading}:Props) {
handleConnect({ handleConnect({
fileInfo: {type: resp.type,url: resp.url,tableName: resp.tableName,provinceName: resp.provinceName,subjectClaim: resp.subjectClaim}, 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 { fetchQuestions } from "@/apis/questions";
import { useAbortController } from "@/hooks/useAbortController"; import { useAbortController } from "@/hooks/useAbortController";
import { RealtimeClientContext } from "../Provider/RealtimeClientProvider"; import { RealtimeClientContext } from "../Provider/RealtimeClientProvider";
// import Countdown from "../Countdown";
export default function HeaderGroup() { export default function HeaderGroup() {
const [isRotating, setIsRotating] = useState(false); const [isRotating, setIsRotating] = useState(false);
@ -69,6 +69,7 @@ export default function HeaderGroup() {
return ( return (
<div className={styles.headerWrapper}> <div className={styles.headerWrapper}>
{/* <Countdown /> */}
<div className={styles.wrapper}> <div className={styles.wrapper}>
<img className={styles.img} src={HelloGIF} alt="hello" /> <img className={styles.img} src={HelloGIF} alt="hello" />
<div className={styles.text}>Hey,AI</div> <div className={styles.text}>Hey,AI</div>

View File

@ -20,7 +20,7 @@ export default function MyInput() {
const handleQuestion = async () => { const handleQuestion = async () => {
handleConnect({initMessage:`我的考地点在${provinceName},我选择的科目是${subjectGroup},我的高考分数为${expectedScore}分。帮我出一个科学的参考志愿表`}); handleConnect({initMessage:`我的考地点在${provinceName},我选择的科目是${subjectGroup},我的高考分数为${expectedScore}分。帮我出一个科学的参考志愿表`});
}; };
return ( 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> </DrawerTitle>
<div className="grid gap-[12px] px-[15px] pb-[15px] bg-[#F4F6FA] pt-[16px] max-h-[50vh] overflow-y-auto"> <div className="grid gap-[12px] px-[15px] pb-[15px] bg-[#F4F6FA] pt-[16px] max-h-[50vh] overflow-y-auto">
{wishList.map((item: any) => ( {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="py-[10px] pl-[16px] pr-[13px] rounded-[8px] flex items-center">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center mb-[8px]"> <div className="flex items-center mb-[8px]">
<span className="text-[15px] font-[600] text-[#303030] mr-[5px]"> <span className="text-[15px] font-[600] text-[#303030] mr-[5px]">
{item.tableName} {item.title}
</span> </span>
<div className="text-[10px] px-[4px] py-[2px] rounded-[4px] text-[#636363] bg-[#fff]">
{item.type}
</div>
</div> </div>
<div className="text-[#303030] text-[11px] flex items-center"> <div className="text-[#303030] text-[11px] flex items-center">
<span> <span>
{item.locationName}·{item.score} {wishList[0].batchName}·{wishList[0].totalScore}
</span>
<span className="ml-[8px]">
{item.subjectClaim.split(",").join("/")}
</span> </span>
</div> </div>
</div> </div>
@ -148,18 +142,13 @@ export default function AntechamberWishList({handleLoading}:Props) {
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center mb-[5px]"> <div className="flex items-center mb-[5px]">
<span className="text-[15px] font-[600] text-[#303030] mr-[5px]"> <span className="text-[15px] font-[600] text-[#303030] mr-[5px]">
{wishList[0].tableName} {wishList[0].title}
</span> </span>
<div className="text-[10px] px-[4px] py-[2px] rounded-[4px] text-[#636363] bg-[#fff]">
{wishList[0].type}
</div>
</div> </div>
<div className="text-[#303030] text-[11px] flex items-center"> <div className="text-[#303030] text-[11px] flex items-center">
<span> <span>
{wishList[0].locationName}·{wishList[0].score} {wishList[0].batchName}·{wishList[0].totalScore}
</span>
<span className="ml-[8px]">
{wishList[0].subjectClaim.split(",").join("/")}
</span> </span>
</div> </div>
</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, useState,
} from "react"; } from "react";
import { useToast } from "@/hooks/use-toast"; 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 = { type RoomInfo = {
appId: string; appId: string;
@ -23,11 +27,11 @@ type RoomInfo = {
}; };
export type FileInfo = { export type FileInfo = {
type: string; type?: string;
url: string; url: string;
tableName: string; tableName?: string;
provinceName: string; provinceName?: string;
subjectClaim: string; subjectClaim?: string;
}; };
export const RealtimeClientContext = createContext<{ export const RealtimeClientContext = createContext<{
@ -68,7 +72,7 @@ export const RealtimeClientProvider = ({
}: { }: {
children: ReactNode; children: ReactNode;
}) => { }) => {
const token = import.meta.env.VITE_COZE_TOKEN; let token = "";
const botId = import.meta.env.VITE_COZE_BOT_ID; const botId = import.meta.env.VITE_COZE_BOT_ID;
const voiceId = import.meta.env.VITE_COZE_VOICE_ID; const voiceId = import.meta.env.VITE_COZE_VOICE_ID;
const connectorId = "1024"; const connectorId = "1024";
@ -94,6 +98,12 @@ export const RealtimeClientProvider = ({
const { toast } = useToast(); const { toast } = useToast();
const messageHandlerStrategy = useRef(new MessageHandlerStrategy());
const { getSignal } = useAbortController();
const [searchParams]= useSearchParams();
const userToken = searchParams.get("token") || '';
/** 初始化客户端并设置监听 */ /** 初始化客户端并设置监听 */
const initClient = async ({ const initClient = async ({
initMessage, initMessage,
@ -102,16 +112,9 @@ export const RealtimeClientProvider = ({
initMessage?: string; initMessage?: string;
fileInfo?: FileInfo; fileInfo?: FileInfo;
}) => { }) => {
const perm = await RealtimeUtils.checkDevicePermission(false); if(!token){
const device = await RealtimeUtils.getAudioDevices(); const resp = await getCozeToken({options:{signal:getSignal(),headers: { Authorization: `Bearer ${userToken}` }}})
token = resp.result as string
if (!perm.audio) {
toast({ title: "连接错误", description: "需要麦克风访问权限" });
throw new Error("需要麦克风访问权限");
}
if (device.audioInputs.length === 0) {
toast({ title: "连接错误", description: "没有麦克风设备" });
throw new Error("没有麦克风设备");
} }
const client = new RealtimeClient({ const client = new RealtimeClient({
@ -122,7 +125,7 @@ export const RealtimeClientProvider = ({
allowPersonalAccessTokenInBrowser: true, allowPersonalAccessTokenInBrowser: true,
suppressStationaryNoise: true, suppressStationaryNoise: true,
suppressNonStationaryNoise: true, suppressNonStationaryNoise: true,
debug: false, debug: true,
}); });
clientRef.current = client; clientRef.current = client;
@ -146,6 +149,18 @@ export const RealtimeClientProvider = ({
connectingLockRef.current = false; connectingLockRef.current = false;
return; 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 { try {
if (!clientRef.current) { if (!clientRef.current) {
@ -223,7 +238,7 @@ export const RealtimeClientProvider = ({
content: JSON.stringify([ content: JSON.stringify([
{ {
type: "text", type: "text",
text: "帮我解读这个文件,结合当下的专业行情以及对该专业未来的发展趋势,简介的给出志愿建议", text: "帮我解读这个文件,根据济南市初升高相关政策要求和情况,给出合理的升学规划建议",
}, },
{ type: "image", file_url: fileInfo.url }, { type: "image", file_url: fileInfo.url },
]), ]),
@ -244,101 +259,17 @@ export const RealtimeClientProvider = ({
client.on(EventNames.ALL, (_eventName, event: any) => { client.on(EventNames.ALL, (_eventName, event: any) => {
// 交给状态机处理解析流程 // 交给状态机处理解析流程
// 普通消息流处理
if ( if (
event.event_type !== ChatEventType.CONVERSATION_MESSAGE_DELTA && event.event_type !== ChatEventType.CONVERSATION_MESSAGE_DELTA &&
event.event_type !== ChatEventType.CONVERSATION_MESSAGE_COMPLETED event.event_type !== ChatEventType.CONVERSATION_MESSAGE_COMPLETED &&
) { event.event_type !== "conversation.created"
// 处理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)
) { ) {
return; return;
} }
const content = event.data.content; setMessageList(prev =>
setMessageList((prev) => { messageHandlerStrategy.current.process(event, prev, opts)
// 如果是工具调用相关的消息,不添加到消息列表 );
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 }];
}
});
}); });
}; };

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 ReactMarkdown from "react-markdown";
import gfm from "remark-gfm"; import gfm from "remark-gfm";
import { RoleType } from "@coze/api"; import { RoleType } from "@coze/api";
import { Loader } from 'lucide-react'; import { Loader } from "lucide-react";
export default function RoomConversation() { export default function RoomConversation() {
const { messageList } = useContext(RealtimeClientContext); const { messageList } = useContext(RealtimeClientContext);
@ -54,7 +54,8 @@ export default function RoomConversation() {
: "bg-blue-500 text-white rounded-tr-none" : "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]}> <ReactMarkdown remarkPlugins={[gfm]}>
{message.content} {message.content}
</ReactMarkdown> </ReactMarkdown>
@ -70,22 +71,36 @@ export default function RoomConversation() {
<span className="text-[15px] text-[#303030] mr-[8px] leading-[1]"> <span className="text-[15px] text-[#303030] mr-[8px] leading-[1]">
{message?.fileInfo?.tableName} {message?.fileInfo?.tableName}
</span> </span>
<div className="bg-[#F4F6FA] rounded-[4px] w-[48px] h-[16px] px-[4px] py-[2px] text-[10px]"> {message?.fileInfo?.type ? (
{message?.fileInfo?.type} <div className="bg-[#F4F6FA] rounded-[4px] w-[48px] h-[16px] px-[4px] py-[2px] text-[10px]">
</div> {message.fileInfo.type}
</div>
) : (
<></>
)}
</div> </div>
<div className="text-[12px] text-[#303030] mt-[6px] flex items-center"> <div className="text-[12px] text-[#303030] mt-[6px] flex items-center">
<span className="mr-[10px]"> {message.fileInfo.provinceName ? (
{message?.fileInfo?.provinceName}·{message?.fileInfo?.score} <span className="mr-[10px]">
</span> {message?.fileInfo?.provinceName}·
<span> {message?.fileInfo?.score}
{message?.fileInfo?.subjectClaim?.split(",").join("/")} </span>
</span> ) : (
{ <></>
message.fileParseStatus < 2 && ( )}
<Loader className="w-[12px] h-[12px] animate-spin ml-[6px]" /> {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> </div>
</div> </div>
@ -95,7 +110,13 @@ export default function RoomConversation() {
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </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> </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) .withServerTimeout(30000)
.withAutomaticReconnect() .withAutomaticReconnect()
.withUrl( .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) .configureLogging(signalR.LogLevel.Information)
.build(); .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 { getSignal } = useAbortController();
const [searchParams]= useSearchParams(); const [searchParams]= useSearchParams();
const locationCode = searchParams.get("locationCode") || '';
const token = searchParams.get("token") || ''; const token = searchParams.get("token") || '';
const getWishList = async () => { 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[]; const _wishList = res.result as any[];
setWishList(_wishList); setWishList(_wishList);
}; };
useEffect(() => { useEffect(() => {
if(locationCode){
getWishList(); getWishList();
} }, []);
}, [locationCode]);
return { return {
wishList, wishList,

View File

@ -3,7 +3,7 @@
# 服务器信息 # 服务器信息
SERVER_USER="root" SERVER_USER="root"
SERVER_HOST="106.14.30.150" 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" PRIVATE_KEY="ALIYUN.pem"
BACKUP_PATH="${SERVER_PATH}-backup-$(date +%Y%m%d%H%M%S).zip" BACKUP_PATH="${SERVER_PATH}-backup-$(date +%Y%m%d%H%M%S).zip"
DINGDING_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=fca104958fea6273c9c7ef3f08b3d552645c214f929066785e8caf6e1885a5a6" DINGDING_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=fca104958fea6273c9c7ef3f08b3d552645c214f929066785e8caf6e1885a5a6"