feat: userChat
This commit is contained in:
parent
40766106d8
commit
40d4a6384b
|
|
@ -18,6 +18,7 @@ import '@ant-design/v5-patch-for-react-19';
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const loginPath = '/user/login';
|
const loginPath = '/user/login';
|
||||||
|
const chatPath = '/chat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://umijs.org/docs/api/runtime-config#getinitialstate
|
* @see https://umijs.org/docs/api/runtime-config#getinitialstate
|
||||||
|
|
@ -79,7 +80,13 @@ export const layout: RunTimeLayoutConfig = ({
|
||||||
waterMarkProps: {
|
waterMarkProps: {
|
||||||
content: initialState?.currentUser?.name,
|
content: initialState?.currentUser?.name,
|
||||||
},
|
},
|
||||||
footerRender: () => <Footer />,
|
footerRender: () => {
|
||||||
|
const { location } = history;
|
||||||
|
if (location.pathname?.startsWith(chatPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <Footer />
|
||||||
|
},
|
||||||
onPageChange: () => {
|
onPageChange: () => {
|
||||||
const { location } = history;
|
const { location } = history;
|
||||||
// 如果没有登录,重定向到 login
|
// 如果没有登录,重定向到 login
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import styles from './index.less';
|
||||||
|
import { ChatMessage } from './types'
|
||||||
|
|
||||||
|
type ChatItemProps = {
|
||||||
|
message: ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatItem({ message }: ChatItemProps) {
|
||||||
|
const isUser = message.role === 'user'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.chatItem} ${isUser ? styles.right : styles.left}`}>
|
||||||
|
<div className={styles.bubble}>
|
||||||
|
{message.content}
|
||||||
|
{message.streaming && <span className={styles.cursor}>▍</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import type { ChatMessage } from '../types'
|
||||||
|
import { random } from 'lodash'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
type UseChatOptions = {
|
||||||
|
api: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = (t: number) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
setTimeout(() => resolve(true), t)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChat({ api }: UseChatOptions) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// 用于中断流式请求
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息
|
||||||
|
*/
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
async (content: string) => {
|
||||||
|
if (!content.trim() || loading) return
|
||||||
|
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
id: nanoid(),
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantId = nanoid()
|
||||||
|
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
id: assistantId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
streaming: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMessage, assistantMessage])
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const mokeResult = "你好!有什麼我可以幫你的嗎?聽起來很有趣,請繼續說。我正在思考你的問題,請稍候...這是一個很棒的觀點!哈哈,真的嗎?太神奇了。抱歉,我剛才走神了,你能再說一遍嗎"
|
||||||
|
const count = random(0, 200)
|
||||||
|
let idx = 0;
|
||||||
|
while (idx < count) {
|
||||||
|
const tokenLength = random(2, 5)
|
||||||
|
console.log(tokenLength);
|
||||||
|
setMessages(prev =>
|
||||||
|
prev.map(msg =>
|
||||||
|
msg.id === assistantId
|
||||||
|
? { ...msg, content: msg.content + mokeResult.substring(idx % mokeResult.length, tokenLength) }
|
||||||
|
: msg
|
||||||
|
)
|
||||||
|
)
|
||||||
|
idx += tokenLength;
|
||||||
|
await wait(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev =>
|
||||||
|
prev.map(msg =>
|
||||||
|
msg.id === assistantId
|
||||||
|
? { ...msg, streaming: false }
|
||||||
|
: msg
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setLoading(false);
|
||||||
|
// abortRef.current = new AbortController()
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const res = await fetch(api, {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json',
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify({
|
||||||
|
// messages: [...messages, userMessage],
|
||||||
|
// stream: true,
|
||||||
|
// }),
|
||||||
|
// signal: abortRef.current.signal,
|
||||||
|
// })
|
||||||
|
|
||||||
|
// if (!res.body) {
|
||||||
|
// throw new Error('No response body')
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const reader = res.body.getReader()
|
||||||
|
// const decoder = new TextDecoder('utf-8')
|
||||||
|
|
||||||
|
// while (true) {
|
||||||
|
// const { value, done } = await reader.read()
|
||||||
|
// if (done) break
|
||||||
|
|
||||||
|
// const chunk = decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
// setMessages(prev =>
|
||||||
|
// prev.map(msg =>
|
||||||
|
// msg.id === assistantId
|
||||||
|
// ? { ...msg, content: msg.content + chunk }
|
||||||
|
// : msg
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// } catch (err: any) {
|
||||||
|
// if (err.name !== 'AbortError') {
|
||||||
|
// setMessages(prev =>
|
||||||
|
// prev.map(msg =>
|
||||||
|
// msg.id === assistantId
|
||||||
|
// ? { ...msg, content: '❌ 出错了,请重试', streaming: false }
|
||||||
|
// : msg
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// } finally {
|
||||||
|
// setMessages(prev =>
|
||||||
|
// prev.map(msg =>
|
||||||
|
// msg.id === assistantId
|
||||||
|
// ? { ...msg, streaming: false }
|
||||||
|
// : msg
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// setLoading(false)
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
[api, loading, messages]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止生成
|
||||||
|
*/
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
setMessages(prev =>
|
||||||
|
prev.map(msg =>
|
||||||
|
msg.streaming ? { ...msg, streaming: false } : msg
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空对话
|
||||||
|
*/
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
stop()
|
||||||
|
setMessages([])
|
||||||
|
}, [stop])
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
loading,
|
||||||
|
sendMessage,
|
||||||
|
stop,
|
||||||
|
clear,
|
||||||
|
setMessages, // 高级用法(如加载历史会话)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
.chatList {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatItem {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatItem.left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatItem.right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatItem.left .bubble {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatItem.right .bubble {
|
||||||
|
background: #1677ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor {
|
||||||
|
animation: chat-item-blink 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global {
|
||||||
|
@keyframes chat-item-blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { ChatItem } from './ChatItem';
|
||||||
|
import styles from './index.less';
|
||||||
|
import { ChatMessage } from './types';
|
||||||
|
|
||||||
|
type ChatListProps = {
|
||||||
|
messages: ChatMessage[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatList({ messages }: ChatListProps) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef?.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.chatList}>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id}>
|
||||||
|
<ChatItem message={msg} />
|
||||||
|
<div ref={msg.streaming ? bottomRef : null} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export type ChatRole = 'user' | 'assistant' | 'system'
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
id: string
|
||||||
|
role: ChatRole
|
||||||
|
content: string
|
||||||
|
createdAt?: number
|
||||||
|
streaming?: boolean
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
.InputBar {
|
.InputBar {
|
||||||
.CenterCard {
|
.CenterCard {
|
||||||
width: 50vw;
|
width: 70vw;
|
||||||
padding: 18px 18px 0 18px;
|
padding: 18px 18px 0 18px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,15 @@ import './index.less'
|
||||||
import { FileSearchOutlined, GlobalOutlined, PlusOutlined } from '@ant-design/icons';
|
import { FileSearchOutlined, GlobalOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { TweenOneGroup } from 'rc-tween-one';
|
import { TweenOneGroup } from 'rc-tween-one';
|
||||||
|
|
||||||
|
export type InputBarDefaultValue = {
|
||||||
|
query: string;
|
||||||
|
tools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type InputBarEvent = {
|
export type InputBarEvent = {
|
||||||
query: string;
|
query: string;
|
||||||
tools: Object[];
|
tools: string[];
|
||||||
|
defaultValues?: InputBarDefaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InputBarSubmitHandler = (event: InputBarEvent) => void;
|
export type InputBarSubmitHandler = (event: InputBarEvent) => void;
|
||||||
|
|
@ -59,16 +65,12 @@ const InputBar = (props: InputBarProps) => {
|
||||||
if (shiftKey && key === "Enter") {
|
if (shiftKey && key === "Enter") {
|
||||||
const { current: { resizableTextArea: { textArea } } } = textAreaRef;
|
const { current: { resizableTextArea: { textArea } } } = textAreaRef;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = textArea?.value;
|
const text = textArea?.value + "";
|
||||||
console.log({
|
textArea.value = "";
|
||||||
query: text,
|
|
||||||
tools: tools,
|
|
||||||
});
|
|
||||||
props?.onSubmit && props.onSubmit({
|
props?.onSubmit && props.onSubmit({
|
||||||
query: text,
|
query: text,
|
||||||
tools: tools?.map((e:any) => e.key),
|
tools: tools?.map((e:any) => e.key),
|
||||||
})
|
})
|
||||||
textArea.value = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +125,7 @@ const InputBar = (props: InputBarProps) => {
|
||||||
<div className='CenterCard'>
|
<div className='CenterCard'>
|
||||||
<ProForm submitter={false}>
|
<ProForm submitter={false}>
|
||||||
<Flex align='flex-end'>
|
<Flex align='flex-end'>
|
||||||
<Dropdown menu={{ items: InputBarToolItems, onClick: itemOnClick }} placement='topCenter' arrow>
|
<Dropdown menu={{ items: InputBarToolItems, onClick: itemOnClick }} placement='top' arrow>
|
||||||
<Button icon={<PlusOutlined />} type='text'></Button>
|
<Button icon={<PlusOutlined />} type='text'></Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<div className='Flex'>
|
<div className='Flex'>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { useModel } from '@umijs/max';
|
import { useModel, history } from '@umijs/max';
|
||||||
import { Flex, theme } from 'antd';
|
import { Flex, theme } from 'antd';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import './Welcome.less'
|
import './Welcome.less'
|
||||||
|
|
@ -17,7 +17,7 @@ const Welcome: React.FC = () => {
|
||||||
<Flex align='center' justify='center' vertical>
|
<Flex align='center' justify='center' vertical>
|
||||||
<div className='PageHeader'></div>
|
<div className='PageHeader'></div>
|
||||||
<InputBar onSubmit={e => {
|
<InputBar onSubmit={e => {
|
||||||
console.log(e);
|
history.push("/chat");
|
||||||
}}/>
|
}}/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
.ChatPage {
|
.chatPage {
|
||||||
.Flex {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CenterCard {
|
}
|
||||||
width: 50vw;
|
|
||||||
padding: 18px 18px 0 18px;
|
.chatPageFooter {
|
||||||
background-color: white;
|
padding-bottom: 35px;
|
||||||
border-radius: 8px;
|
}
|
||||||
padding-bottom: 24px,
|
|
||||||
}
|
.chatContent {
|
||||||
|
width: 70vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageEnd {
|
||||||
|
height: 60px;
|
||||||
|
width: 60px;
|
||||||
}
|
}
|
||||||
|
|
@ -1,22 +1,46 @@
|
||||||
import { PageContainer, ProForm, ProFormTextArea } from '@ant-design/pro-components';
|
import { nanoid, PageContainer } from '@ant-design/pro-components';
|
||||||
import type { ModelItem } from '@/components/Custom/HeaderModelSelect';
|
import type { ModelItem } from '@/components/Custom/HeaderModelSelect';
|
||||||
import './index.less';
|
import styles from './index.less';
|
||||||
import { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import HeaderModelSelect from '@/components/Custom/HeaderModelSelect';
|
import HeaderModelSelect from '@/components/Custom/HeaderModelSelect';
|
||||||
import { Button, Dropdown, Flex } from 'antd';
|
import InputBar from '@/components/Custom/InputBar';
|
||||||
|
import type { InputBarSubmitHandler, InputBarEvent } from '@/components/Custom/InputBar';
|
||||||
|
import { ChatMessage } from '@/components/Custom/ChatList/types';
|
||||||
|
import { ChatList } from '@/components/Custom/ChatList';
|
||||||
|
import { useChat } from '@/components/Custom/ChatList/hooks/useChat';
|
||||||
|
import { Flex } from 'antd';
|
||||||
|
|
||||||
|
const footerStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderTop: 'none',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const [model, setModel] = useState<ModelItem | undefined>(undefined);
|
const [model, setModel] = useState<ModelItem | undefined>(undefined);
|
||||||
|
const { messages, setMessages, loading, stop, sendMessage } = useChat({ api: '' });
|
||||||
|
const onSubmit: InputBarSubmitHandler = (e: InputBarEvent) => {
|
||||||
|
sendMessage(e.query);
|
||||||
|
}
|
||||||
|
|
||||||
return <PageContainer
|
return <PageContainer
|
||||||
className='ChatPage'
|
className={styles.chatPage}
|
||||||
|
fixedHeader
|
||||||
|
footerToolBarProps={{
|
||||||
|
style: footerStyle,
|
||||||
|
renderContent: (_, dom) => dom.props.children[1],
|
||||||
|
}}
|
||||||
|
footer={[<div key='submit' className={styles.chatPageFooter}>
|
||||||
|
<InputBar onSubmit={onSubmit}/>
|
||||||
|
</div>]}
|
||||||
title={<HeaderModelSelect value={model} onChange={val => setModel(val)} />}
|
title={<HeaderModelSelect value={model} onChange={val => setModel(val)} />}
|
||||||
>
|
>
|
||||||
<Flex vertical>
|
<Flex vertical align='center'>
|
||||||
<div className='Flex'></div>
|
<div className={styles.chatContent}>
|
||||||
<div className='CenterCard'>
|
<ChatList messages={messages} />
|
||||||
</div>
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<div className={styles.messageEnd} />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
}
|
}
|
||||||
|
|
@ -3,12 +3,60 @@
|
||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
/** 获取当前的用户 GET /api/currentUser */
|
/** 获取当前的用户 GET /api/currentUser */
|
||||||
export async function currentUser(options?: { [key: string]: any }) {
|
export async function currentUser(options?: { [key: string]: any }): Promise<{ data: API.CurrentUser }> {
|
||||||
return request<{
|
return new Promise((resolve, reject) => {
|
||||||
data: API.CurrentUser;
|
resolve({
|
||||||
}>('/api/currentUser', {
|
data: {
|
||||||
method: 'GET',
|
"name": "Serati Ma",
|
||||||
...(options || {}),
|
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png",
|
||||||
|
"userid": "00000001",
|
||||||
|
"email": "antdesign@alipay.com",
|
||||||
|
"signature": "海纳百川,有容乃大",
|
||||||
|
"title": "交互专家",
|
||||||
|
"group": "蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED",
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"key": "0",
|
||||||
|
"label": "很有想法的"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "1",
|
||||||
|
"label": "专注设计"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "2",
|
||||||
|
"label": "辣~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "3",
|
||||||
|
"label": "大长腿"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "4",
|
||||||
|
"label": "川妹子"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "5",
|
||||||
|
"label": "海纳百川"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notifyCount": 12,
|
||||||
|
"unreadCount": 11,
|
||||||
|
"country": "China",
|
||||||
|
"geographic": {
|
||||||
|
"province": {
|
||||||
|
"label": "浙江省",
|
||||||
|
"key": "330000"
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"label": "杭州市",
|
||||||
|
"key": "330100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"address": "西湖区工专路 77 号",
|
||||||
|
"phone": "0752-268888888"
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue