Files
hudi-service/service-web/client/src/pages/ai/Conversation.tsx
2025-06-04 18:52:28 +08:00

250 lines
7.9 KiB
TypeScript

import {ClearOutlined, FileOutlined, UserOutlined} from '@ant-design/icons'
import {Bubble, Sender, useXAgent, useXChat, Welcome} from '@ant-design/x'
import {fetchEventSource} from '@echofly/fetch-event-source'
import {useMount} from 'ahooks'
import {Button, Collapse, Flex, Popover, Radio, Typography} from 'antd'
import {isEqual, isStrBlank, trim} from 'licia'
import markdownIt from 'markdown-it'
import {useRef, useState} from 'react'
import styled from 'styled-components'
import {commonInfo} from '../../util/amis.tsx'
const md = markdownIt({html: true, breaks: true})
const ConversationDiv = styled.div`
height: calc(100vh - 76px);
display: flex;
flex-direction: column;
padding: 10px;
.conversation-welcome {
flex: 1;
width: 70%;
margin: 30px auto 30px;
}
.conversation-list {
flex: 1;
margin-bottom: 30px;
padding-left: 30px;
padding-right: 30px;
}
.conversation-sender {
height: 100px;
padding-left: 30px;
padding-right: 30px;
}
`
type ChatMessage = { role: string, content?: string, reason?: string }
function Conversation() {
const abortController = useRef<AbortController | null>(null)
const [input, setInput] = useState<string>('')
const [knowledge, setKnowledge] = useState<string>('0')
const [knowledgeList, setKnowledgeList] = useState<{ id: string, name: string }[]>([])
useMount(async () => {
let response = await fetch(`${commonInfo.baseAiKnowledgeUrl}/knowledge/list`, {
headers: commonInfo.authorizationHeaders,
})
let items = (await response.json()).data.items
setKnowledgeList(items.map((item: { id: string, name: string }) => ({id: item.id, name: item.name})))
})
const [agent] = useXAgent<ChatMessage>({
request: async (info, callbacks) => {
let requestUrl = `${commonInfo.baseAiChatUrl}/chat/async`
if (!isEqual('0', info.knowledge)) {
requestUrl = `${requestUrl}?knowledge_id=${info.knowledge}`
}
await fetchEventSource(requestUrl, {
method: 'POST',
headers: commonInfo.authorizationHeaders,
body: JSON.stringify(info.messages),
signal: abortController.current?.signal,
onmessage: ev => {
callbacks.onUpdate({
id: ev.id,
event: 'delta',
data: ev.data,
})
},
onclose: () => callbacks.onSuccess([]),
})
},
})
const {onRequest, messages, setMessages} = useXChat({
agent,
transformMessage: ({originMessage, chunk}) => {
let content = '', reason = ''
try {
if (chunk?.data) {
let map = JSON.parse(chunk.data)
if (map['content'])
content = map['content']
if (map['reason'])
reason = map['reason']
}
} catch (error) {
console.error(error)
}
return {
role: 'assistant',
content: (originMessage?.content || '') + content,
reason: (originMessage?.reason || '') + reason,
}
},
resolveAbortController: controller => {
abortController.current = controller
},
})
return (
<ConversationDiv>
{messages.length > 0
? (<Bubble.List
className="conversation-list"
roles={{
assistant: {
placement: 'start',
avatar: {
icon: <img src="icon.png" alt=""/>,
style: {
background: 'transparent',
},
},
messageRender: item => {
let content = '', reason = ''
if (!isStrBlank(item['reason'])) {
reason = `${trim(md.render(item['reason']))}`
}
content = trim(md.render(item['content']))
return (
<div>
{isStrBlank(reason)
? <span/>
: <Collapse
size="small"
defaultActiveKey={0}
items={[
{
key: 0,
label: '思考链',
children: (
<Typography>
<div dangerouslySetInnerHTML={{__html: reason}}/>
</Typography>
),
},
]}
/>}
<Typography>
<div dangerouslySetInnerHTML={{__html: content}}/>
</Typography>
</div>
)
},
},
user: {
placement: 'end',
avatar: {
icon: <UserOutlined/>,
},
messageRender: item => {
return (
<Typography>{trim(item['content'])}</Typography>
)
},
},
}}
items={messages.map(({id, message}) => {
return {
key: id,
role: message.role,
content: message,
}
})}
/>)
: (<div className="conversation-welcome">
<Welcome
variant="borderless"
icon={<img src="icon.png" alt="icon"/>}
title="你好,我是基于大模型深度思考技术构建的 AI 运营助手"
description="我可以帮你查询、检索Hudi 服务的运行情况,分析、处理 Hudi 服务的运营故障,输出、解读 Hudi 系统整体运营报告"
/>
</div>)}
<div className="conversation-sender">
<Sender
value={input}
onChange={setInput}
onSubmit={message => {
onRequest({
message: {
role: 'user',
content: message,
},
stream: true,
knowledge: knowledge,
})
setInput('')
}}
onCancel={() => abortController.current?.abort()}
footer={({components}) => {
const {SendButton, LoadingButton} = components
return (
<Flex justify="space-between" align="center">
<Flex gap="small" align="center">
<Popover
title="选择知识库"
trigger="hover"
content={<Radio.Group
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
disabled={agent.isRequesting()}
value={knowledge}
onChange={event => setKnowledge(event.target.value)}
options={[
{value: '0', label: '无'},
...knowledgeList.map(k => ({label: k.name, value: k.id})),
]}
/>}
>
<Button
icon={<FileOutlined/>}
type="text"
size="small"
>
</Button>
</Popover>
<Button
icon={<ClearOutlined/>}
type="text"
size="small"
onClick={() => setMessages([])}
>
</Button>
</Flex>
<Flex align="center">
{agent.isRequesting() ? (
<LoadingButton type="default"/>
) : (
<SendButton type="primary" disabled={false}/>
)}
</Flex>
</Flex>
)
}}
actions={false}
/>
</div>
</ConversationDiv>
)
}
export default Conversation