feat: userChat

This commit is contained in:
李如威 2026-01-05 16:36:57 +08:00
parent 40766106d8
commit 40d4a6384b
12 changed files with 393 additions and 38 deletions

View File

@ -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

View File

@ -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>
)
}

View File

@ -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, // 高级用法(如加载历史会话)
}
}

View File

@ -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;
}
}
}

View File

@ -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>
</>
)
}

View File

@ -0,0 +1,9 @@
export type ChatRole = 'user' | 'assistant' | 'system'
export type ChatMessage = {
id: string
role: ChatRole
content: string
createdAt?: number
streaming?: boolean
}

View File

@ -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;

View File

@ -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'>

View File

@ -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>

View File

@ -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;
} }

View File

@ -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>
} }

View File

@ -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"
}
})
}); });
} }