Feat: Display agent operator call log #3221 (#8169)

### What problem does this PR solve?

Feat: Display agent operator call log #3221

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-06-11 09:22:07 +08:00
committed by GitHub
parent e6d36f3a3a
commit f0a3d91171
23 changed files with 1513 additions and 124 deletions

View File

@ -0,0 +1,51 @@
import { Form, Input, Modal } from 'antd';
import { IModalProps } from '@/interfaces/common';
import { IFeedbackRequestBody } from '@/interfaces/request/chat';
import { useCallback } from 'react';
type FieldType = {
feedback?: string;
};
const FeedbackModal = ({
visible,
hideModal,
onOk,
loading,
}: IModalProps<IFeedbackRequestBody>) => {
const [form] = Form.useForm();
const handleOk = useCallback(async () => {
const ret = await form.validateFields();
return onOk?.({ thumbup: false, feedback: ret.feedback });
}, [onOk, form]);
return (
<Modal
title="Feedback"
open={visible}
onOk={handleOk}
onCancel={hideModal}
confirmLoading={loading}
>
<Form
name="basic"
labelCol={{ span: 0 }}
wrapperCol={{ span: 24 }}
style={{ maxWidth: 600 }}
autoComplete="off"
form={form}
>
<Form.Item<FieldType>
name="feedback"
rules={[{ required: true, message: 'Please input your feedback!' }]}
>
<Input.TextArea rows={8} placeholder="Please input your feedback!" />
</Form.Item>
</Form>
</Modal>
);
};
export default FeedbackModal;

View File

@ -0,0 +1,220 @@
import { PromptIcon } from '@/assets/icon/Icon';
import CopyToClipboard from '@/components/copy-to-clipboard';
import { useSetModalState } from '@/hooks/common-hooks';
import { IRemoveMessageById } from '@/hooks/logic-hooks';
import { AgentChatContext } from '@/pages/agent/context';
import {
DeleteOutlined,
DislikeOutlined,
LikeOutlined,
PauseCircleOutlined,
SoundOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { Radio, Tooltip } from 'antd';
import { NotebookText } from 'lucide-react';
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import FeedbackModal from './feedback-modal';
import { useRemoveMessage, useSendFeedback, useSpeech } from './hooks';
import PromptModal from './prompt-modal';
interface IProps {
messageId: string;
content: string;
prompt?: string;
showLikeButton: boolean;
audioBinary?: string;
showLoudspeaker?: boolean;
}
export const AssistantGroupButton = ({
messageId,
content,
prompt,
audioBinary,
showLikeButton,
showLoudspeaker = true,
}: IProps) => {
const { visible, hideModal, showModal, onFeedbackOk, loading } =
useSendFeedback(messageId);
const {
visible: promptVisible,
hideModal: hidePromptModal,
showModal: showPromptModal,
} = useSetModalState();
const { t } = useTranslation();
const { handleRead, ref, isPlaying } = useSpeech(content, audioBinary);
const handleLike = useCallback(() => {
onFeedbackOk({ thumbup: true });
}, [onFeedbackOk]);
const { showLogSheet } = useContext(AgentChatContext);
const handleShowLogSheet = useCallback(() => {
showLogSheet(messageId);
}, [messageId, showLogSheet]);
return (
<>
<ToggleGroup
type={'single'}
size="sm"
variant="outline"
className="space-x-1"
>
<ToggleGroupItem value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</ToggleGroupItem>
{showLoudspeaker && (
<ToggleGroupItem value="b" onClick={handleRead}>
<Tooltip title={t('chat.read')}>
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
</Tooltip>
<audio src="" ref={ref}></audio>
</ToggleGroupItem>
)}
{showLikeButton && (
<>
<ToggleGroupItem value="c" onClick={handleLike}>
<LikeOutlined />
</ToggleGroupItem>
<ToggleGroupItem value="d" onClick={showModal}>
<DislikeOutlined />
</ToggleGroupItem>
</>
)}
{prompt && (
<Radio.Button value="e" onClick={showPromptModal}>
<PromptIcon style={{ fontSize: '16px' }} />
</Radio.Button>
)}
<ToggleGroupItem value="f" onClick={handleShowLogSheet}>
<NotebookText className="size-4" />
</ToggleGroupItem>
</ToggleGroup>
{visible && (
<FeedbackModal
visible={visible}
hideModal={hideModal}
onOk={onFeedbackOk}
loading={loading}
></FeedbackModal>
)}
{promptVisible && (
<PromptModal
visible={promptVisible}
hideModal={hidePromptModal}
prompt={prompt}
></PromptModal>
)}
</>
);
return (
<>
<Radio.Group size="small">
<Radio.Button value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</Radio.Button>
{showLoudspeaker && (
<Radio.Button value="b" onClick={handleRead}>
<Tooltip title={t('chat.read')}>
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
</Tooltip>
<audio src="" ref={ref}></audio>
</Radio.Button>
)}
{showLikeButton && (
<>
<Radio.Button value="c" onClick={handleLike}>
<LikeOutlined />
</Radio.Button>
<Radio.Button value="d" onClick={showModal}>
<DislikeOutlined />
</Radio.Button>
</>
)}
{prompt && (
<Radio.Button value="e" onClick={showPromptModal}>
<PromptIcon style={{ fontSize: '16px' }} />
</Radio.Button>
)}
<Radio.Button
value="f"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleShowLogSheet();
}}
>
<NotebookText className="size-4" />
</Radio.Button>
</Radio.Group>
{visible && (
<FeedbackModal
visible={visible}
hideModal={hideModal}
onOk={onFeedbackOk}
loading={loading}
></FeedbackModal>
)}
{promptVisible && (
<PromptModal
visible={promptVisible}
hideModal={hidePromptModal}
prompt={prompt}
></PromptModal>
)}
</>
);
};
interface UserGroupButtonProps extends Partial<IRemoveMessageById> {
messageId: string;
content: string;
regenerateMessage?: () => void;
sendLoading: boolean;
}
export const UserGroupButton = ({
content,
messageId,
sendLoading,
removeMessageById,
regenerateMessage,
}: UserGroupButtonProps) => {
const { onRemoveMessage, loading } = useRemoveMessage(
messageId,
removeMessageById,
);
const { t } = useTranslation();
return (
<Radio.Group size="small">
<Radio.Button value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</Radio.Button>
{regenerateMessage && (
<Radio.Button
value="b"
onClick={regenerateMessage}
disabled={sendLoading}
>
<Tooltip title={t('chat.regenerate')}>
<SyncOutlined spin={sendLoading} />
</Tooltip>
</Radio.Button>
)}
{removeMessageById && (
<Radio.Button value="c" onClick={onRemoveMessage} disabled={loading}>
<Tooltip title={t('common.delete')}>
<DeleteOutlined spin={loading} />
</Tooltip>
</Radio.Button>
)}
</Radio.Group>
);
};

View File

@ -0,0 +1,116 @@
import { useDeleteMessage, useFeedback } from '@/hooks/chat-hooks';
import { useSetModalState } from '@/hooks/common-hooks';
import { IRemoveMessageById, useSpeechWithSse } from '@/hooks/logic-hooks';
import { IFeedbackRequestBody } from '@/interfaces/request/chat';
import { hexStringToUint8Array } from '@/utils/common-util';
import { SpeechPlayer } from 'openai-speech-stream-player';
import { useCallback, useEffect, useRef, useState } from 'react';
export const useSendFeedback = (messageId: string) => {
const { visible, hideModal, showModal } = useSetModalState();
const { feedback, loading } = useFeedback();
const onFeedbackOk = useCallback(
async (params: IFeedbackRequestBody) => {
const ret = await feedback({
...params,
messageId: messageId,
});
if (ret === 0) {
hideModal();
}
},
[feedback, hideModal, messageId],
);
return {
loading,
onFeedbackOk,
visible,
hideModal,
showModal,
};
};
export const useRemoveMessage = (
messageId: string,
removeMessageById?: IRemoveMessageById['removeMessageById'],
) => {
const { deleteMessage, loading } = useDeleteMessage();
const onRemoveMessage = useCallback(async () => {
if (messageId) {
const code = await deleteMessage(messageId);
if (code === 0) {
removeMessageById?.(messageId);
}
}
}, [deleteMessage, messageId, removeMessageById]);
return { onRemoveMessage, loading };
};
export const useSpeech = (content: string, audioBinary?: string) => {
const ref = useRef<HTMLAudioElement>(null);
const { read } = useSpeechWithSse();
const player = useRef<SpeechPlayer>();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const initialize = useCallback(async () => {
player.current = new SpeechPlayer({
audio: ref.current!,
onPlaying: () => {
setIsPlaying(true);
},
onPause: () => {
setIsPlaying(false);
},
onChunkEnd: () => {},
mimeType: MediaSource.isTypeSupported('audio/mpeg')
? 'audio/mpeg'
: 'audio/mp4; codecs="mp4a.40.2"', // https://stackoverflow.com/questions/64079424/cannot-replay-mp3-in-firefox-using-mediasource-even-though-it-works-in-chrome
});
await player.current.init();
}, []);
const pause = useCallback(() => {
player.current?.pause();
}, []);
const speech = useCallback(async () => {
const response = await read({ text: content });
if (response) {
player?.current?.feedWithResponse(response);
}
}, [read, content]);
const handleRead = useCallback(async () => {
if (isPlaying) {
setIsPlaying(false);
pause();
} else {
setIsPlaying(true);
speech();
}
}, [setIsPlaying, speech, isPlaying, pause]);
useEffect(() => {
if (audioBinary) {
const units = hexStringToUint8Array(audioBinary);
if (units) {
try {
player.current?.feed(units);
} catch (error) {
console.warn(error);
}
}
}
}, [audioBinary]);
useEffect(() => {
initialize();
}, [initialize]);
return { ref, handleRead, isPlaying };
};

View File

@ -0,0 +1,63 @@
.messageItem {
padding: 24px 0;
.messageItemSection {
display: inline-block;
}
.messageItemSectionLeft {
width: 80%;
}
.messageItemContent {
display: inline-flex;
gap: 20px;
}
.messageItemContentReverse {
flex-direction: row-reverse;
}
.messageTextBase() {
padding: 6px 10px;
border-radius: 8px;
& > p {
margin: 0;
}
}
.messageText {
.chunkText();
.messageTextBase();
background-color: #e6f4ff;
word-break: break-word;
}
.messageTextDark {
.chunkText();
.messageTextBase();
background-color: #1668dc;
word-break: break-word;
:global(section.think) {
color: rgb(166, 166, 166);
border-left-color: rgb(78, 78, 86);
}
}
.messageUserText {
.chunkText();
.messageTextBase();
background-color: rgba(255, 255, 255, 0.3);
word-break: break-word;
text-align: justify;
}
.messageEmpty {
width: 300px;
}
.thumbnailImg {
max-width: 20px;
}
}
.messageItemLeft {
text-align: left;
}
.messageItemRight {
text-align: right;
}

View File

@ -0,0 +1,244 @@
import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg';
import { MessageType } from '@/constants/chat';
import { useSetModalState } from '@/hooks/common-hooks';
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import classNames from 'classnames';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import {
useFetchDocumentInfosByIds,
useFetchDocumentThumbnailsByIds,
} from '@/hooks/document-hooks';
import { IRegenerateMessage, IRemoveMessageById } from '@/hooks/logic-hooks';
import { IMessage } from '@/pages/chat/interface';
import MarkdownContent from '@/pages/chat/markdown-content';
import { getExtension, isImage } from '@/utils/document-util';
import { Avatar, Button, Flex, List, Space, Typography } from 'antd';
import FileIcon from '../file-icon';
import IndentedTreeModal from '../indented-tree/modal';
import NewDocumentLink from '../new-document-link';
import { useTheme } from '../theme-provider';
import { AssistantGroupButton, UserGroupButton } from './group-button';
import styles from './index.less';
const { Text } = Typography;
interface IProps extends Partial<IRemoveMessageById>, IRegenerateMessage {
item: IMessage;
reference: IReference;
loading?: boolean;
sendLoading?: boolean;
visibleAvatar?: boolean;
nickname?: string;
avatar?: string;
avatarDialog?: string | null;
clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void;
index: number;
showLikeButton?: boolean;
showLoudspeaker?: boolean;
}
const MessageItem = ({
item,
reference,
loading = false,
avatar,
avatarDialog,
sendLoading = false,
clickDocumentButton,
index,
removeMessageById,
regenerateMessage,
showLikeButton = true,
showLoudspeaker = true,
visibleAvatar = true,
}: IProps) => {
const { theme } = useTheme();
const isAssistant = item.role === MessageType.Assistant;
const isUser = item.role === MessageType.User;
const { data: documentList, setDocumentIds } = useFetchDocumentInfosByIds();
const { data: documentThumbnails, setDocumentIds: setIds } =
useFetchDocumentThumbnailsByIds();
const { visible, hideModal, showModal } = useSetModalState();
const [clickedDocumentId, setClickedDocumentId] = useState('');
const referenceDocumentList = useMemo(() => {
return reference?.doc_aggs ?? [];
}, [reference?.doc_aggs]);
const handleUserDocumentClick = useCallback(
(id: string) => () => {
setClickedDocumentId(id);
showModal();
},
[showModal],
);
const handleRegenerateMessage = useCallback(() => {
regenerateMessage?.(item);
}, [regenerateMessage, item]);
useEffect(() => {
const ids = item?.doc_ids ?? [];
if (ids.length) {
setDocumentIds(ids);
const documentIds = ids.filter((x) => !(x in documentThumbnails));
if (documentIds.length) {
setIds(documentIds);
}
}
}, [item.doc_ids, setDocumentIds, setIds, documentThumbnails]);
return (
<div
className={classNames(styles.messageItem, {
[styles.messageItemLeft]: item.role === MessageType.Assistant,
[styles.messageItemRight]: item.role === MessageType.User,
})}
>
<section
className={classNames(styles.messageItemSection, {
[styles.messageItemSectionLeft]: item.role === MessageType.Assistant,
[styles.messageItemSectionRight]: item.role === MessageType.User,
})}
>
<div
className={classNames(styles.messageItemContent, {
[styles.messageItemContentReverse]: item.role === MessageType.User,
})}
>
{visibleAvatar &&
(item.role === MessageType.User ? (
<Avatar size={40} src={avatar ?? '/logo.svg'} />
) : avatarDialog ? (
<Avatar size={40} src={avatarDialog} />
) : (
<AssistantIcon />
))}
<Flex vertical gap={8} flex={1}>
<Space>
{isAssistant ? (
index !== 0 && (
<AssistantGroupButton
messageId={item.id}
content={item.content}
prompt={item.prompt}
showLikeButton={showLikeButton}
audioBinary={item.audio_binary}
showLoudspeaker={showLoudspeaker}
></AssistantGroupButton>
)
) : (
<UserGroupButton
content={item.content}
messageId={item.id}
removeMessageById={removeMessageById}
regenerateMessage={
regenerateMessage && handleRegenerateMessage
}
sendLoading={sendLoading}
></UserGroupButton>
)}
{/* <b>{isAssistant ? '' : nickname}</b> */}
</Space>
<div
className={
isAssistant
? theme === 'dark'
? styles.messageTextDark
: styles.messageText
: styles.messageUserText
}
>
<MarkdownContent
loading={loading}
content={item.content}
reference={reference}
clickDocumentButton={clickDocumentButton}
></MarkdownContent>
</div>
{isAssistant && referenceDocumentList.length > 0 && (
<List
bordered
dataSource={referenceDocumentList}
renderItem={(item) => {
return (
<List.Item>
<Flex gap={'small'} align="center">
<FileIcon
id={item.doc_id}
name={item.doc_name}
></FileIcon>
<NewDocumentLink
documentId={item.doc_id}
documentName={item.doc_name}
prefix="document"
link={item.url}
>
{item.doc_name}
</NewDocumentLink>
</Flex>
</List.Item>
);
}}
/>
)}
{isUser && documentList.length > 0 && (
<List
bordered
dataSource={documentList}
renderItem={(item) => {
// TODO:
// const fileThumbnail =
// documentThumbnails[item.id] || documentThumbnails[item.id];
const fileExtension = getExtension(item.name);
return (
<List.Item>
<Flex gap={'small'} align="center">
<FileIcon id={item.id} name={item.name}></FileIcon>
{isImage(fileExtension) ? (
<NewDocumentLink
documentId={item.id}
documentName={item.name}
prefix="document"
>
{item.name}
</NewDocumentLink>
) : (
<Button
type={'text'}
onClick={handleUserDocumentClick(item.id)}
>
<Text
style={{ maxWidth: '40vw' }}
ellipsis={{ tooltip: item.name }}
>
{item.name}
</Text>
</Button>
)}
</Flex>
</List.Item>
);
}}
/>
)}
</Flex>
</div>
</section>
{visible && (
<IndentedTreeModal
visible={visible}
hideModal={hideModal}
documentId={clickedDocumentId}
></IndentedTreeModal>
)}
</div>
);
};
export default memo(MessageItem);

View File

@ -0,0 +1,30 @@
import { IModalProps } from '@/interfaces/common';
import { IFeedbackRequestBody } from '@/interfaces/request/chat';
import { Modal, Space } from 'antd';
import HightLightMarkdown from '../highlight-markdown';
import SvgIcon from '../svg-icon';
const PromptModal = ({
visible,
hideModal,
prompt,
}: IModalProps<IFeedbackRequestBody> & { prompt?: string }) => {
return (
<Modal
title={
<Space>
<SvgIcon name={`prompt`} width={18}></SvgIcon>
Prompt
</Space>
}
width={'80%'}
open={visible}
onCancel={hideModal}
footer={null}
>
<HightLightMarkdown>{prompt}</HightLightMarkdown>
</Modal>
);
};
export default PromptModal;