feat: 布局初版

This commit is contained in:
changhuiping.chp 2023-06-25 18:09:53 +08:00
parent b198f8d326
commit 6a2df06f88
14 changed files with 5031 additions and 66 deletions

View File

@ -6,9 +6,8 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<Box sx={{ color: 'red' }}>
123
<>
{children}
</Box>
</>
)
}

View File

@ -1,8 +1,214 @@
"use client"
import ChatBoxComp from '@/components/chatBox';
import { Chart, LineAdvance, Interval, Tooltip, getTheme } from 'bizcharts';
import { Card, CardContent, Typography, Grid, styled, Sheet } from '@/lib/mui';
import { Stack } from '@mui/material';
import useAgentChat from '@/hooks/useAgentChat';
const Item = styled(Sheet)(({ theme }) => ({
...theme.typography.body2,
padding: theme.spacing(1),
textAlign: 'center',
borderRadius: 4,
color: theme.vars.palette.text.secondary,
}));
const Agents = () => {
const { handleChatSubmit, history } = useAgentChat({
queryAgentURL: `/api/agents/query`,
});
const data = [
{
month: "Jan",
city: "Tokyo",
temperature: 7
},
{
month: "Feb",
city: "Tokyo",
temperature: 13
},
{
month: "Mar",
city: "Tokyo",
temperature: 16.5
},
{
month: "Apr",
city: "Tokyo",
temperature: 14.5
},
{
month: "May",
city: "Tokyo",
temperature: 10
},
{
month: "Jun",
city: "Tokyo",
temperature: 7.5
},
{
month: "Jul",
city: "Tokyo",
temperature: 9.2
},
{
month: "Aug",
city: "Tokyo",
temperature: 14.5
},
{
month: "Sep",
city: "Tokyo",
temperature: 9.3
},
{
month: "Oct",
city: "Tokyo",
temperature: 8.3
},
{
month: "Nov",
city: "Tokyo",
temperature: 8.9
},
{
month: "Dec",
city: "Tokyo",
temperature: 5.6
},
];
const d1 = [
{ year: '1951 年', sales: 0 },
{ year: '1952 年', sales: 52 },
{ year: '1956 年', sales: 61 },
{ year: '1957 年', sales: 45 },
{ year: '1958 年', sales: 48 },
{ year: '1959 年', sales: 38 },
{ year: '1960 年', sales: 38 },
{ year: '1962 年', sales: 38 },
];
const topCard = [{
label: 'Revenue Won',
value: '$7,811,851'
}, {
label: 'Close %',
value: '37.7%'
}, {
label: 'AVG Days to Close',
value: '121'
}, {
label: 'Opportunities Won',
value: '526'
}];
export default function Home() {
return (
<div>
HHH
<div className='p-4 flex flex-row gap-6 min-h-full w-full'>
<div className='flex w-full'>
<Grid container spacing={2} sx={{ flexGrow: 1 }}>
<Grid xs={8}>
<Stack spacing={2} className='h-full'>
<Item>
<Grid container spacing={2}>
{topCard.map((item) => (
<Grid key={item.label} xs={3}>
<Card className="flex-1 h-full">
<CardContent className="justify-around">
<Typography gutterBottom variant="h5" component="div">
{item.label}
</Typography>
<Typography variant="body2">
{item.value}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Item>
<Item className='flex-1'>
<Card className='h-full'>
<CardContent className='h-full'>
<Typography gutterBottom variant="h5" component="div">
Revenue Won by Month
</Typography>
<div className='flex-1'>
<Chart padding={[10, 20, 50, 40]} autoFit data={data} >
<LineAdvance
shape="smooth"
point
area
position="month*temperature"
color="city"
/>
</Chart>
</div>
</CardContent>
</Card>
</Item>
<Item className='flex-1'>
<Grid container spacing={2} className='h-full'>
<Grid xs={4} className='h-full'>
<Card className='flex-1 h-full'>
<CardContent className='h-full'>
<Typography gutterBottom variant="h5" component="div">
Close % by Month
</Typography>
<div className='flex-1'>
<Chart autoFit data={d1} >
<Interval position="year*sales" style={{ lineWidth: 3, stroke: getTheme().colors10[0] }} />
<Tooltip shared />
</Chart>
</div>
</CardContent>
</Card>
</Grid>
<Grid xs={4} className='h-full'>
<Card className='flex-1 h-full'>
<CardContent className='h-full'>
<Typography gutterBottom variant="h5" component="div">
Close % by Month
</Typography>
<div className='flex-1'>
<Chart autoFit data={d1} >
<Interval position="year*sales" style={{ lineWidth: 3, stroke: getTheme().colors10[0] }} />
<Tooltip shared />
</Chart>
</div>
</CardContent>
</Card>
</Grid>
<Grid xs={4} className='h-full'>
<Card className='flex-1 h-full'>
<CardContent className='h-full'>
<Typography gutterBottom variant="h5" component="div">
Close % by Month
</Typography>
<div className='flex-1'>
<Chart autoFit data={d1} >
<Interval position="year*sales" style={{ lineWidth: 3, stroke: getTheme().colors10[0] }} />
<Tooltip shared />
</Chart>
</div>
</CardContent>
</Card>
</Grid>
</Grid>
</Item>
</Stack>
</Grid>
<Grid xs={4}>
<ChatBoxComp messages={history} onSubmit={handleChatSubmit}/>
</Grid>
</Grid>
</div>
</div>
)
}
export default Agents;

View File

@ -2,60 +2,62 @@ import { extendTheme } from '@mui/joy/styles';
import colors from '@mui/joy/colors';
export const joyTheme = extendTheme({
colorSchemes: {
light: {
palette: {
mode: 'dark',
primary: {
...colors.purple,
},
neutral: {
plainColor: '#25252D',
plainHoverColor: '#131318',
plainHoverBg: '#EBEBEF',
plainActiveBg: '#D8D8DF',
plainDisabledColor: '#B9B9C6'
},
background: {
body: '#fff'
},
text: {
primary: '#25252D'
},
colorSchemes: {
light: {
palette: {
mode: 'dark',
primary: {
...colors.purple,
},
},
dark: {
palette: {
mode: 'light',
primary: {
...colors.purple,
},
neutral: {
plainColor: '#D8D8DF',
plainHoverColor: '#F7F7F8',
plainHoverBg: '#25252D',
plainActiveBg: '#434356',
plainDisabledColor: '#434356'
},
text: {
primary: '#EBEBEF'
},
background: {
body: '#09090D'
}
neutral: {
plainColor: '#25252D',
plainHoverColor: '#131318',
plainHoverBg: '#EBEBEF',
plainActiveBg: '#D8D8DF',
plainDisabledColor: '#B9B9C6'
},
background: {
body: '#fff',
surface: '#fff'
},
text: {
primary: '#25252D'
},
},
},
fontFamily: {
body: 'Josefin Sans, sans-serif',
display: 'Josefin Sans, sans-serif',
},
typography: {
display1: {
background:
'linear-gradient(-30deg, var(--joy-palette-primary-900), var(--joy-palette-primary-400))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
dark: {
palette: {
mode: 'light',
primary: {
...colors.purple,
},
neutral: {
plainColor: '#D8D8DF',
plainHoverColor: '#F7F7F8',
plainHoverBg: '#25252D',
plainActiveBg: '#434356',
plainDisabledColor: '#434356'
},
text: {
primary: '#EBEBEF'
},
background: {
body: '#09090D',
surface: '#1e293b40'
}
},
},
});
},
fontFamily: {
body: 'Josefin Sans, sans-serif',
display: 'Josefin Sans, sans-serif',
},
typography: {
display1: {
background:
'linear-gradient(-30deg, var(--joy-palette-primary-900), var(--joy-palette-primary-400))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
},
},
});

View File

@ -0,0 +1,234 @@
import { zodResolver } from '@hookform/resolvers/zod';
import SendRoundedIcon from '@mui/icons-material/SendRounded';
import Button from '@mui/joy/Button';
import Card from '@mui/joy/Card';
import CircularProgress from '@mui/joy/CircularProgress';
import IconButton from '@mui/joy/IconButton';
import Input from '@mui/joy/Input';
import Stack from '@mui/joy/Stack';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { z } from 'zod';
import { Message } from '@/types';
type Props = {
messages: Message[];
onSubmit: (message: string) => Promise<any>;
messageTemplates?: string[];
initialMessage?: string;
readOnly?: boolean;
};
const Schema = z.object({ query: z.string().min(1) });
const ChatBoxComp = ({
messages,
onSubmit,
messageTemplates,
initialMessage,
readOnly,
}: Props) => {
const scrollableRef = React.useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [firstMsg, setFirstMsg] = useState<Message>();
const [hideTemplateMessages, setHideTemplateMessages] = useState(false);
console.log(messages, 'mmm');
const methods = useForm<z.infer<typeof Schema>>({
resolver: zodResolver(Schema),
defaultValues: {},
});
const submit = async ({ query }: z.infer<typeof Schema>) => {
try {
setIsLoading(true);
setHideTemplateMessages(true);
methods.reset();
await onSubmit(query);
} catch (err) {
} finally {
setIsLoading(false);
}
};
React.useEffect(() => {
if (!scrollableRef.current) {
return;
}
scrollableRef.current.scrollTo(0, scrollableRef.current.scrollHeight);
}, [messages?.length]);
React.useEffect(() => {
setTimeout(() => {
setFirstMsg(
initialMessage ? { from: 'agent', message: initialMessage } : undefined
);
}, 0);
}, [initialMessage]);
return (
<Stack
direction={'column'}
gap={2}
sx={{
display: 'flex',
flex: 1,
flexBasis: '100%',
maxWidth: '700px',
width: '100%',
height: '100%',
maxHeight: '100%',
minHeight: '100%',
mx: 'auto',
}}
>
<Stack
ref={scrollableRef}
direction={'column'}
gap={2}
sx={{
boxSizing: 'border-box',
maxWidth: '100%',
width: '100%',
mx: 'auto',
flex: 1,
maxHeight: '100%',
overflowY: 'auto',
p: 2,
border: '1px solid',
borderColor: 'var(--joy-palette-divider)'
}}
>
{firstMsg && (
<Card
size="sm"
variant={'outlined'}
color={'primary'}
className="message-agent"
sx={{
mr: 'auto',
ml: 'none',
whiteSpace: 'pre-wrap',
}}
>
{firstMsg?.message}
</Card>
)}
{messages.map((each, index) => (
<Stack
key={index}
sx={{
mr: each.from === 'agent' ? 'auto' : 'none',
ml: each.from === 'human' ? 'auto' : 'none',
}}
>
<Card
size="sm"
variant={'outlined'}
className={
each.from === 'agent' ? 'message-agent' : 'message-human'
}
color={each.from === 'agent' ? 'primary' : 'neutral'}
sx={(theme) => ({
px: 2,
'ol, ul': {
my: 0,
pl: 2,
},
ol: {
listStyle: 'numeric',
},
ul: {
listStyle: 'disc',
mb: 2,
},
li: {
my: 1,
},
a: {
textDecoration: 'underline',
},
})}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} linkTarget={'_blank'}>
{each.message}
</ReactMarkdown>
</Card>
</Stack>
))}
{isLoading && (
<CircularProgress
variant="soft"
color="neutral"
size="sm"
sx={{ mx: 'auto', my: 2 }}
/>
)}
</Stack>
{!readOnly && (
<form
style={{
maxWidth: '100%',
width: '100%',
position: 'relative',
display: 'flex',
marginTop: 'auto',
overflow: 'visible',
background: 'none',
justifyContent: 'center',
marginLeft: 'auto',
marginRight: 'auto',
}}
onSubmit={(e) => {
e.stopPropagation();
methods.handleSubmit(submit)(e);
}}
>
{!hideTemplateMessages && (messageTemplates?.length || 0) > 0 && (
<Stack
direction="row"
gap={1}
sx={{
position: 'absolute',
zIndex: 1,
transform: 'translateY(-100%)',
flexWrap: 'wrap',
mt: -1,
left: '0',
}}
>
{messageTemplates?.map((each, idx) => (
<Button
key={idx}
size="sm"
variant="soft"
onClick={() => submit({ query: each })}
>
{each}
</Button>
))}
</Stack>
)}
<Input
sx={{ width: '100%' }}
variant="outlined"
endDecorator={
<IconButton type="submit" disabled={isLoading}>
<SendRoundedIcon />
</IconButton>
}
{...methods.register('query')}
/>
</form>
)}
</Stack>
);
}
export default ChatBoxComp;

View File

@ -18,6 +18,7 @@ const Header = () => {
position: 'sticky',
top: 0,
zIndex: 1100,
background: 'var(--joy-palette-background-body)'
}}
>
<div className='flex items-center justify-center gap-3'>

View File

@ -0,0 +1,175 @@
import {
EventStreamContentType,
fetchEventSource,
} from '@microsoft/fetch-event-source';
import { ApiError, ApiErrorType } from '@/utils/api-error';
import useStateReducer from './useStateReducer';
import useVisitorId from './useVisitorId';
type Props = {
queryAgentURL: string;
queryHistoryURL?: string;
channel?: "dashboard" | "website" | "slack" | "crisp";
queryBody?: any;
};
const useAgentChat = ({
queryAgentURL,
queryHistoryURL,
channel,
queryBody,
}: Props) => {
const [state, setState] = useStateReducer({
history: [{
from: 'human',
message: 'hello',
}, {
from: 'agent',
message: 'Hello! How can I assist you today?',
}] as { from: 'human' | 'agent'; message: string; id?: string }[],
});
const { visitorId } = useVisitorId();
const handleChatSubmit = async (message: string) => {
if (!message) {
return;
}
const history = [...state.history, { from: 'human', message }];
const nextIndex = history.length;
setState({
history: history as any,
});
let answer = '';
let error = '';
try {
const ctrl = new AbortController();
let buffer = '';
class RetriableError extends Error {}
class FatalError extends Error {}
await fetchEventSource(queryAgentURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...queryBody,
streaming: true,
query: message,
visitorId: visitorId,
channel,
}),
signal: ctrl.signal,
async onopen(response) {
console.log('onopen', response);
if (
response.ok &&
response.headers.get('content-type') === EventStreamContentType
) {
return; // everything's good
} else if (
response.status >= 400 &&
response.status < 500 &&
response.status !== 429
) {
if (response.status === 402) {
throw new ApiError(ApiErrorType.USAGE_LIMIT);
}
throw new FatalError();
} else {
throw new RetriableError();
}
},
onclose() {
throw new RetriableError();
},
onerror(err) {
console.log('on error', err, Object.keys(err));
throw new Error(err);
// if (err instanceof FatalError) {
// ctrl.abort();
// throw new Error(); // rethrow to stop the operation
// } else if (err instanceof ApiError) {
// console.log('ApiError', ApiError);
// throw new Error();
// } else {
// throw new Error(err);
// }
},
onmessage: (event) => {
console.log(event, 'event');
if (event.data === '[DONE]') {
ctrl.abort();
} else if (event.data?.startsWith('[ERROR]')) {
ctrl.abort();
setState({
history: [
...history,
{
from: 'agent',
message: event.data.replace('[ERROR]', ''),
} as any,
],
});
} else {
buffer += decodeURIComponent(event.data) as string;
const h = [...history];
if (h?.[nextIndex]) {
h[nextIndex].message = `${buffer}`;
} else {
h.push({ from: 'agent', message: buffer });
}
setState({
history: h as any,
});
}
},
});
} catch (err) {
console.log('err', err);
setState({
history: [
...history,
{ from: 'agent', message: answer || '请求出错' as string },
] as any,
});
// if (err instanceof ApiError) {
// if (err?.message) {
// error = err?.message;
// if (error === ApiErrorType.USAGE_LIMIT) {
// answer =
// 'Usage limit reached. Please upgrade your plan to get higher usage.';
// } else {
// answer = `Error: ${error}`;
// }
// } else {
// answer = `Error: ${error}`;
// }
// setState({
// history: [
// ...history,
// { from: 'agent', message: answer as string },
// ] as any,
// });
// }
}
};
return {
handleChatSubmit,
history: state.history,
};
};
export default useAgentChat;

View File

@ -0,0 +1,17 @@
import { useReducer } from 'react';
const useStateReducer = <T>(initialState: T) => {
const methods = useReducer(
(state: T, newState: Partial<T>) => ({
...state,
...newState,
}),
{
...initialState,
}
);
return methods;
};
export default useStateReducer;

View File

@ -0,0 +1,28 @@
import React, { useEffect } from 'react';
import cuid from 'cuid';
const useVisitorId = () => {
const [visitorId, setVisitorId] = React.useState('');
useEffect(() => {
(async () => {
if (typeof window !== 'undefined') {
let id = localStorage.getItem('visitorId');
if (!id) {
id = cuid();
localStorage.setItem('visitorId', id);
}
setVisitorId(id);
}
})();
}, []);
return {
visitorId,
};
};
export default useVisitorId;

View File

@ -1,4 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
const nextConfig = {
experimental: {
esmExternals: 'loose'
}
}
module.exports = nextConfig

File diff suppressed because it is too large Load Diff

View File

@ -12,23 +12,34 @@
"@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@hookform/resolvers": "^3.0.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@mui/icons-material": "^5.11.16",
"@mui/joy": "5.0.0-alpha.72",
"@mui/lab": "5.0.0-alpha.124",
"@mui/material": "^5.11.14",
"@mui/utils": "^5.11.13",
"@prisma/client": "^4.12.0",
"@types/node": "20.3.1",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"autoprefixer": "10.4.14",
"axios": "^1.3.4",
"bizcharts": "^4.1.22",
"cuid": "^3.0.0",
"eslint": "8.43.0",
"eslint-config-next": "13.4.7",
"next": "13.4.7",
"next-auth": "^4.20.1",
"postcss": "8.4.24",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.8",
"react-markdown": "^8.0.7",
"remark-gfm": "^3.0.1",
"swr": "^2.1.1",
"tailwindcss": "3.3.2",
"typescript": "5.1.3"
"typescript": "5.1.3",
"zod": "^3.19.1"
}
}

View File

@ -0,0 +1,8 @@
import { NextApiRequest, NextPage } from 'next/types';
import { Session } from 'next-auth';
export type Message = { from: 'human' | 'agent'; message: string; createdAt?: Date };
export type AppNextApiRequest = NextApiRequest & {
session: Session;
};

View File

@ -0,0 +1,40 @@
export enum ApiErrorType {
UNAUTHORIZED = 'UNAUTHORIZED',
USAGE_LIMIT = 'USAGE_LIMIT',
NOT_FOUND = 'NOT_FOUND',
INVALID_REQUEST = 'INVALID_REQUEST',
WEBPAGE_IS_SITEMAP = 'WEBPAGE_IS_SITEMAP',
EMPTY_DATASOURCE = 'EMPTY_DATASOURCE',
}
export class ApiError extends Error {
constructor(message: ApiErrorType, public status?: number) {
super(message);
if (!status) {
switch (message) {
case ApiErrorType.UNAUTHORIZED:
this.status = 403;
break;
case ApiErrorType.USAGE_LIMIT:
this.status = 402;
break;
case ApiErrorType.NOT_FOUND:
this.status = 404;
break;
case ApiErrorType.INVALID_REQUEST:
this.status = 400;
break;
case ApiErrorType.EMPTY_DATASOURCE:
this.status = 400;
break;
default:
this.status = 500;
break;
}
}
Object.setPrototypeOf(this, ApiError.prototype);
}
}

View File

@ -0,0 +1,19 @@
import axios, { AxiosRequestConfig } from 'axios';
export const postFetcher = <T>(uri: string, { arg }: { arg: T }) =>
axios(uri, {
method: 'POST',
data: arg,
}).then((r) => r.data);
export const createFetcher =
(config: AxiosRequestConfig) =>
<T>(url: string, { arg }: { arg: T }) =>
axios({
url,
...config,
data: arg,
}).then((r) => r.data);
export const fetcher = (...args: Parameters<typeof axios>) =>
axios(...args).then((r) => r.data);