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 loginPath = '/user/login';
|
||||
const chatPath = '/chat';
|
||||
|
||||
/**
|
||||
* @see https://umijs.org/docs/api/runtime-config#getinitialstate
|
||||
|
|
@ -79,7 +80,13 @@ export const layout: RunTimeLayoutConfig = ({
|
|||
waterMarkProps: {
|
||||
content: initialState?.currentUser?.name,
|
||||
},
|
||||
footerRender: () => <Footer />,
|
||||
footerRender: () => {
|
||||
const { location } = history;
|
||||
if (location.pathname?.startsWith(chatPath)) {
|
||||
return null;
|
||||
}
|
||||
return <Footer />
|
||||
},
|
||||
onPageChange: () => {
|
||||
const { location } = history;
|
||||
// 如果没有登录,重定向到 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 {
|
||||
.CenterCard {
|
||||
width: 50vw;
|
||||
width: 70vw;
|
||||
padding: 18px 18px 0 18px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@ import './index.less'
|
|||
import { FileSearchOutlined, GlobalOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { TweenOneGroup } from 'rc-tween-one';
|
||||
|
||||
export type InputBarDefaultValue = {
|
||||
query: string;
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
export type InputBarEvent = {
|
||||
query: string;
|
||||
tools: Object[];
|
||||
tools: string[];
|
||||
defaultValues?: InputBarDefaultValue;
|
||||
}
|
||||
|
||||
export type InputBarSubmitHandler = (event: InputBarEvent) => void;
|
||||
|
|
@ -59,16 +65,12 @@ const InputBar = (props: InputBarProps) => {
|
|||
if (shiftKey && key === "Enter") {
|
||||
const { current: { resizableTextArea: { textArea } } } = textAreaRef;
|
||||
e.preventDefault();
|
||||
const text = textArea?.value;
|
||||
console.log({
|
||||
query: text,
|
||||
tools: tools,
|
||||
});
|
||||
const text = textArea?.value + "";
|
||||
textArea.value = "";
|
||||
props?.onSubmit && props.onSubmit({
|
||||
query: text,
|
||||
tools: tools?.map((e:any) => e.key),
|
||||
})
|
||||
textArea.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -123,7 +125,7 @@ const InputBar = (props: InputBarProps) => {
|
|||
<div className='CenterCard'>
|
||||
<ProForm submitter={false}>
|
||||
<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>
|
||||
</Dropdown>
|
||||
<div className='Flex'>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useModel, history } from '@umijs/max';
|
||||
import { Flex, theme } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import './Welcome.less'
|
||||
|
|
@ -17,7 +17,7 @@ const Welcome: React.FC = () => {
|
|||
<Flex align='center' justify='center' vertical>
|
||||
<div className='PageHeader'></div>
|
||||
<InputBar onSubmit={e => {
|
||||
console.log(e);
|
||||
history.push("/chat");
|
||||
}}/>
|
||||
</Flex>
|
||||
</PageContainer>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
.ChatPage {
|
||||
.Flex {
|
||||
flex: 1;
|
||||
}
|
||||
.chatPage {
|
||||
|
||||
.CenterCard {
|
||||
width: 50vw;
|
||||
padding: 18px 18px 0 18px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding-bottom: 24px,
|
||||
}
|
||||
}
|
||||
|
||||
.chatPageFooter {
|
||||
padding-bottom: 35px;
|
||||
}
|
||||
|
||||
.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 './index.less';
|
||||
import { useState } from 'react';
|
||||
import styles from './index.less';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 () => {
|
||||
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
|
||||
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)} />}
|
||||
>
|
||||
<Flex vertical>
|
||||
<div className='Flex'></div>
|
||||
<div className='CenterCard'>
|
||||
<Flex vertical align='center'>
|
||||
<div className={styles.chatContent}>
|
||||
<ChatList messages={messages} />
|
||||
</div>
|
||||
</Flex>
|
||||
<div className={styles.messageEnd} />
|
||||
</PageContainer>
|
||||
}
|
||||
|
|
@ -3,12 +3,60 @@
|
|||
import { request } from '@umijs/max';
|
||||
|
||||
/** 获取当前的用户 GET /api/currentUser */
|
||||
export async function currentUser(options?: { [key: string]: any }) {
|
||||
return request<{
|
||||
data: API.CurrentUser;
|
||||
}>('/api/currentUser', {
|
||||
method: 'GET',
|
||||
...(options || {}),
|
||||
export async function currentUser(options?: { [key: string]: any }): Promise<{ data: API.CurrentUser }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve({
|
||||
data: {
|
||||
"name": "Serati Ma",
|
||||
"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