Browse Source
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>pull/11684/head
committed by
GitHub
27 changed files with 23514 additions and 44 deletions
File diff suppressed because it is too large
@ -0,0 +1,12 @@ |
|||
// src/pages/chatbot/data.d.ts
|
|||
|
|||
export interface ConversationItem { |
|||
key: string; |
|||
label: string; |
|||
group?: string; |
|||
isDraft?: boolean; |
|||
} |
|||
|
|||
export type ParsedMessage = |
|||
| { role: 'user'; content: string } |
|||
| { role: 'assistant'; content: string; thinkContent?: string }; |
|||
@ -0,0 +1,326 @@ |
|||
import { UserOutlined } from '@ant-design/icons'; |
|||
import { PageContainer } from '@ant-design/pro-components'; |
|||
import { Bubble, Conversations, Sender, Think, XProvider } from '@ant-design/x'; |
|||
import type { |
|||
BubbleItemType, |
|||
BubbleListProps, |
|||
} from '@ant-design/x/es/bubble/interface'; |
|||
import XMarkdown from '@ant-design/x-markdown'; |
|||
import { useXChat } from '@ant-design/x-sdk'; |
|||
import { Avatar, Card } from 'antd'; |
|||
import React, { useEffect, useMemo, useState } from 'react'; |
|||
|
|||
import type { ConversationItem, ParsedMessage } from './data'; |
|||
import { createChatProvider } from './service'; |
|||
import { useStyles } from './style'; |
|||
|
|||
const WELCOME_TEXT = '🤖 你好,有什么可以帮你?'; |
|||
|
|||
const TypewriterTitle: React.FC = () => { |
|||
const { styles } = useStyles(); |
|||
const [index, setIndex] = useState(0); |
|||
const done = index >= WELCOME_TEXT.length; |
|||
|
|||
useEffect(() => { |
|||
const timer = setInterval(() => { |
|||
setIndex((i) => { |
|||
if (i >= WELCOME_TEXT.length) { |
|||
clearInterval(timer); |
|||
return i; |
|||
} |
|||
return i + 1; |
|||
}); |
|||
}, 80); |
|||
return () => clearInterval(timer); |
|||
}, []); |
|||
|
|||
return ( |
|||
<> |
|||
{WELCOME_TEXT.slice(0, index)} |
|||
{!done && <span className={styles.cursor}>|</span>} |
|||
</> |
|||
); |
|||
}; |
|||
|
|||
const parser = (message: { content: string; role: string }): ParsedMessage => { |
|||
const { content, role } = message; |
|||
if (role !== 'assistant') return { role: 'user', content }; |
|||
|
|||
const trimmed = content.trimStart(); |
|||
|
|||
const fullMatch = trimmed.match(/^<think>([\s\S]*?)<\/think>([\s\S]*)$/); |
|||
if (fullMatch) { |
|||
return { |
|||
role: 'assistant', |
|||
thinkContent: fullMatch[1], |
|||
content: fullMatch[2].trimStart(), |
|||
}; |
|||
} |
|||
|
|||
const partialMatch = trimmed.match(/^<think>([\s\S]*)$/); |
|||
if (partialMatch) { |
|||
return { role: 'assistant', thinkContent: partialMatch[1], content: '' }; |
|||
} |
|||
|
|||
return { role: 'assistant', content }; |
|||
}; |
|||
|
|||
const STREAMING_ACTIVE = { hasNextChunk: true, enableAnimation: true }; |
|||
const STREAMING_IDLE = { hasNextChunk: false, enableAnimation: true }; |
|||
|
|||
const roleConfig: BubbleListProps['role'] = { |
|||
user: { |
|||
placement: 'end', |
|||
avatar: <Avatar icon={<UserOutlined />} />, |
|||
}, |
|||
ai: { |
|||
placement: 'start', |
|||
avatar: ( |
|||
<Avatar |
|||
style={{ |
|||
background: 'transparent', |
|||
fontSize: 22, |
|||
display: 'flex', |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}} |
|||
> |
|||
🤖 |
|||
</Avatar> |
|||
), |
|||
typing: { effect: 'typing', step: 2, interval: 20 }, |
|||
contentRender: ( |
|||
content: string, |
|||
info: { status?: string; loading?: boolean }, |
|||
) => { |
|||
if (info?.loading || !content) return undefined; |
|||
return ( |
|||
<XMarkdown |
|||
streaming={ |
|||
info?.status === 'updating' ? STREAMING_ACTIVE : STREAMING_IDLE |
|||
} |
|||
> |
|||
{content} |
|||
</XMarkdown> |
|||
); |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
const ChatbotPage: React.FC = () => { |
|||
const { styles } = useStyles(); |
|||
|
|||
const [conversations, setConversations] = useState<ConversationItem[]>([ |
|||
{ key: 'default', label: '💬 新对话', group: '今天', isDraft: true }, |
|||
{ |
|||
key: 'preset-1', |
|||
label: '🧩 Ant Design 的 Form 表单如何做联动校验?', |
|||
group: '今天', |
|||
}, |
|||
{ |
|||
key: 'preset-2', |
|||
label: '📋 ProTable 如何自定义工具栏按钮?', |
|||
group: '今天', |
|||
}, |
|||
{ |
|||
key: 'preset-3', |
|||
label: '🎨 如何用 antd-style 实现暗色主题切换?', |
|||
group: '昨天', |
|||
}, |
|||
{ |
|||
key: 'preset-4', |
|||
label: '🗂️ ProLayout 侧边菜单如何动态生成?', |
|||
group: '昨天', |
|||
}, |
|||
{ |
|||
key: 'preset-5', |
|||
label: '📊 Ant Design Charts 折线图数据格式', |
|||
group: '昨天', |
|||
}, |
|||
{ |
|||
key: 'preset-6', |
|||
label: '🚀 Ant Design Pro 如何接入后端权限系统?', |
|||
group: '更早', |
|||
}, |
|||
{ |
|||
key: 'preset-7', |
|||
label: '🔍 ProForm 中 Select 远程搜索怎么实现?', |
|||
group: '更早', |
|||
}, |
|||
{ |
|||
key: 'preset-8', |
|||
label: '⚙️ Ant Design Token 定制主题最佳实践', |
|||
group: '更早', |
|||
}, |
|||
]); |
|||
const [activeKey, setActiveKey] = useState<string>('default'); |
|||
const [inputValue, setInputValue] = useState(''); |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
const provider = useMemo(() => createChatProvider() as any, []); |
|||
const { onRequest, abort, isRequesting, parsedMessages } = useXChat< |
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
any, |
|||
ParsedMessage |
|||
>({ |
|||
provider, |
|||
conversationKey: activeKey, |
|||
parser, |
|||
requestPlaceholder: { role: 'assistant', content: '' }, |
|||
}); |
|||
|
|||
const sendMessage = (content: string) => { |
|||
setInputValue(''); |
|||
setConversations((prev) => |
|||
prev.map((c) => |
|||
c.key === activeKey && c.isDraft |
|||
? { ...c, label: content.slice(0, 20), isDraft: false } |
|||
: c, |
|||
), |
|||
); |
|||
onRequest({ messages: [{ role: 'user', content }] }); |
|||
}; |
|||
|
|||
const newChat = () => { |
|||
const key = crypto.randomUUID(); |
|||
setConversations((prev) => [ |
|||
{ key, label: '新对话', group: '今天', isDraft: true }, |
|||
...prev, |
|||
]); |
|||
setActiveKey(key); |
|||
}; |
|||
|
|||
const bubbleItems = useMemo<BubbleItemType[]>( |
|||
() => |
|||
parsedMessages.map((msg) => { |
|||
const parsed = msg.message as ParsedMessage; |
|||
const isAI = parsed.role === 'assistant'; |
|||
const thinkContent = |
|||
parsed.role === 'assistant' ? parsed.thinkContent : undefined; |
|||
|
|||
const item: BubbleItemType = { |
|||
key: msg.id, |
|||
role: isAI ? 'ai' : 'user', |
|||
content: parsed.content, |
|||
loading: isAI && msg.status === 'loading', |
|||
status: msg.status, |
|||
}; |
|||
|
|||
if (isAI && thinkContent) { |
|||
item.header = <Think>{thinkContent}</Think>; |
|||
} |
|||
|
|||
return item; |
|||
}), |
|||
[parsedMessages], |
|||
); |
|||
|
|||
const hasMessages = parsedMessages.length > 0; |
|||
|
|||
return ( |
|||
<PageContainer |
|||
ghost |
|||
childrenContentStyle={{ |
|||
paddingBlock: 0, |
|||
height: 'calc(100vh - 160px)', |
|||
display: 'flex', |
|||
flexDirection: 'column', |
|||
overflow: 'hidden', |
|||
}} |
|||
> |
|||
<Card |
|||
variant="borderless" |
|||
style={{ |
|||
height: '100%', |
|||
display: 'flex', |
|||
flexDirection: 'column', |
|||
overflow: 'hidden', |
|||
}} |
|||
styles={{ |
|||
body: { |
|||
flex: 1, |
|||
padding: 0, |
|||
overflow: 'hidden', |
|||
display: 'flex', |
|||
flexDirection: 'column', |
|||
}, |
|||
}} |
|||
> |
|||
<XProvider> |
|||
<div className={styles.layout}> |
|||
<div className={styles.sidebar}> |
|||
<Conversations |
|||
items={conversations} |
|||
activeKey={activeKey} |
|||
onActiveChange={setActiveKey} |
|||
groupable |
|||
menu={(conversation) => ({ |
|||
items: [{ key: 'delete', label: '删除', danger: true }], |
|||
onClick: ({ key }) => { |
|||
if (key === 'delete') { |
|||
setConversations((prev) => { |
|||
const next = prev.filter( |
|||
(c) => c.key !== conversation.key, |
|||
); |
|||
if (next.length === 0) { |
|||
const key = crypto.randomUUID(); |
|||
next.push({ |
|||
key, |
|||
label: '💬 新对话', |
|||
group: '今天', |
|||
isDraft: true, |
|||
}); |
|||
setActiveKey(key); |
|||
} else if (activeKey === conversation.key) { |
|||
setActiveKey(next[0]?.key ?? ''); |
|||
} |
|||
return next; |
|||
}); |
|||
} |
|||
}, |
|||
})} |
|||
creation={{ onClick: newChat, label: '新建对话' }} |
|||
/> |
|||
</div> |
|||
|
|||
<div className={styles.main}> |
|||
{hasMessages && ( |
|||
<div className={styles.messages}> |
|||
<Bubble.List |
|||
items={bubbleItems} |
|||
role={roleConfig} |
|||
autoScroll |
|||
styles={{ root: { maxWidth: 940 } }} |
|||
/> |
|||
</div> |
|||
)} |
|||
|
|||
<div |
|||
className={hasMessages ? styles.footer : styles.footerCenter} |
|||
> |
|||
{!hasMessages && ( |
|||
<div className={styles.welcomeTitle}> |
|||
<TypewriterTitle /> |
|||
</div> |
|||
)} |
|||
<Sender |
|||
value={inputValue} |
|||
onChange={setInputValue} |
|||
loading={isRequesting} |
|||
onSubmit={sendMessage} |
|||
onCancel={abort} |
|||
placeholder="输入消息,按 Enter 发送..." |
|||
autoSize={{ minRows: 4, maxRows: 8 }} |
|||
style={{ maxWidth: 940, width: '100%' }} |
|||
styles={{ input: { paddingBlock: 0 } }} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</XProvider> |
|||
</Card> |
|||
</PageContainer> |
|||
); |
|||
}; |
|||
|
|||
export default ChatbotPage; |
|||
@ -0,0 +1,18 @@ |
|||
// src/pages/chatbot/service.ts
|
|||
import { OpenAIChatProvider, XRequest } from '@ant-design/x-sdk'; |
|||
|
|||
export const CHAT_API_URL = |
|||
process.env.CHAT_API_URL ?? |
|||
'https://api.x.ant.design/api/big_model_glm-4.5-flash'; |
|||
|
|||
/** |
|||
* Factory — call once per component mount (wrap in useMemo). |
|||
* OpenAIChatProvider handles SSE parsing and history accumulation internally. |
|||
*/ |
|||
export const createChatProvider = () => |
|||
new OpenAIChatProvider({ |
|||
request: XRequest(CHAT_API_URL, { |
|||
manual: true, |
|||
params: { model: 'glm-4.5-flash', stream: true }, |
|||
}), |
|||
}); |
|||
@ -0,0 +1,75 @@ |
|||
// src/pages/chatbot/style.ts
|
|||
import { createStyles } from 'antd-style'; |
|||
|
|||
export const useStyles = createStyles(({ css, token }) => ({ |
|||
layout: css` |
|||
display: flex; |
|||
flex: 1; |
|||
overflow: hidden; |
|||
`,
|
|||
|
|||
sidebar: css` |
|||
width: 260px; |
|||
background: ${token.colorBgContainer}; |
|||
border-right: 1px solid ${token.colorBorderSecondary}; |
|||
display: flex; |
|||
flex-direction: column; |
|||
overflow: hidden; |
|||
`,
|
|||
|
|||
main: css` |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
overflow: hidden; |
|||
min-width: 0; |
|||
background: ${token.colorBgContainer}; |
|||
`,
|
|||
|
|||
messages: css` |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
padding: ${token.paddingMD}px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
|
|||
> * { |
|||
width: 100%; |
|||
} |
|||
`,
|
|||
|
|||
footer: css` |
|||
padding: ${token.paddingMD}px; |
|||
border-top: 1px solid ${token.colorBorderSecondary}; |
|||
display: flex; |
|||
justify-content: center; |
|||
`,
|
|||
|
|||
footerCenter: css` |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: ${token.paddingLG}px; |
|||
gap: 32px; |
|||
margin-top: -10%; |
|||
`,
|
|||
|
|||
welcomeTitle: css` |
|||
font-size: 32px; |
|||
font-weight: 600; |
|||
color: ${token.colorText}; |
|||
text-align: center; |
|||
`,
|
|||
|
|||
cursor: css` |
|||
animation: chatbot-blink 0.8s step-end infinite; |
|||
|
|||
@keyframes chatbot-blink { |
|||
0%, 100% { opacity: 1; } |
|||
50% { opacity: 0; } |
|||
} |
|||
`,
|
|||
})); |
|||
@ -0,0 +1 @@ |
|||
module.exports = {}; |
|||
Loading…
Reference in new issue