feat: event log of bots (#1441)

* feat: basic arch of event log

* feat: complete event log framework

* fix: bad struct in bot log api

* feat: add event logging to all platform adapters

Co-Authored-By: wangcham233@gmail.com <651122857@qq.com>

* feat: add event logging to client classes

Co-Authored-By: wangcham233@gmail.com <651122857@qq.com>

* refactor: bot log getting api

* perf: logger for aiocqhttp and gewechat

* fix: add ignored logger in dingtalk

* fix: seq id bug in log getting

* feat: add logger in dingtalk,QQ official,Slack, wxoa

* feat: add logger for wecom

* feat: add logger for wecomcs

* perf(event logger): image processing

* 完成机器人日志的前端部分 (#1479)

* feat: webui  bot log framework done

* feat: bot log complete

* perf(bot-log): style

* chore: fix incompleted i18n

* feat: support message session copy

* fix: filter and badge text

* perf: styles

* feat: add bot toggle switch in bot card

* fix: linter errors

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: wangcham233@gmail.com <651122857@qq.com>
Co-authored-by: HYana <65863826+KaedeSAMA@users.noreply.github.com>
This commit is contained in:
Junyan Qin (Chin)
2025-05-27 22:36:50 +08:00
committed by GitHub
parent 8dfef1d118
commit f1e9f46af1
55 changed files with 1196 additions and 136 deletions

View File

@@ -0,0 +1,63 @@
import { httpClient } from '@/app/infra/http/HttpClient';
import {
BotLog,
GetBotLogsResponse,
} from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
export class BotLogManager {
private botId: string;
private callbacks: ((_: BotLog[]) => void)[] = [];
private intervalIds: number[] = [];
constructor(botId: string) {
this.botId = botId;
}
startListenServerPush() {
const timerNumber = setInterval(() => {
this.getLogList(-1, 50).then((response) => {
this.callbacks.forEach((callback) =>
callback(this.parseResponse(response)),
);
});
}, 3000);
this.intervalIds.push(Number(timerNumber));
}
stopServerPush() {
this.intervalIds.forEach((id) => clearInterval(id));
this.intervalIds = [];
}
subscribeLogPush(callback: (_: BotLog[]) => void) {
if (!this.callbacks.includes(callback)) {
this.callbacks.push(callback);
}
}
dispose() {
this.callbacks = [];
}
/**
* 获取日志页的基本信息
*/
private getLogList(next: number, count: number = 20) {
return httpClient.getBotLogs(this.botId, {
from_index: next,
max_count: count,
});
}
async loadFirstPage() {
return this.parseResponse(await this.getLogList(-1, 10));
}
async loadMore(position: number, total: number) {
return this.parseResponse(await this.getLogList(position, total));
}
private parseResponse(httpResponse: GetBotLogsResponse): BotLog[] {
return httpResponse.logs;
}
}

View File

@@ -0,0 +1,116 @@
'use client';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import styles from './botLog.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { PhotoProvider } from 'react-photo-view';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export function BotLogCard({ botLog }: { botLog: BotLog }) {
const { t } = useTranslation();
const baseURL = httpClient.getBaseUrl();
function formatTime(timestamp: number) {
const now = new Date();
const date = new Date(timestamp * 1000);
// 获取各个时间部分
const year = date.getFullYear();
const month = date.getMonth() + 1; // 月份从0开始需要+1
const day = date.getDate();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// 判断时间范围
const isToday = now.toDateString() === date.toDateString();
const isYesterday =
new Date(now.setDate(now.getDate() - 1)).toDateString() ===
date.toDateString();
const isThisYear = now.getFullYear() === year;
if (isToday) {
return `${hours}:${minutes}`; // 今天的消息:小时:分钟
} else if (isYesterday) {
return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息:昨天 小时:分钟
} else if (isThisYear) {
return t('bots.dateFormat', { month, day }); // 本年消息x月x日
} else {
return t('bots.earlier'); // 更早的消息:更久之前
}
}
function getSubChatId(str: string) {
const strArr = str.split('');
return strArr;
}
return (
<div className={`${styles.botLogCardContainer}`}>
{/* 头部标签,时间 */}
<div className={`${styles.cardTitleContainer}`}>
<div className={`flex flex-row gap-4`}>
<div className={`${styles.tag}`}>{botLog.level}</div>
{botLog.message_session_id && (
<div
className={`${styles.tag} ${styles.chatTag}`}
onClick={() => {
navigator.clipboard
.writeText(botLog.message_session_id)
.then(() => {
toast.success(t('common.copySuccess'));
});
}}
>
<svg
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1664"
width="20"
height="20"
fill="currentColor"
>
<path
d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"
p-id="1665"
fill="currentColor"
></path>
<path
d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z"
p-id="1666"
fill="currentColor"
></path>
<path
d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z"
p-id="1667"
fill="currentColor"
></path>
</svg>
{/* 会话ID */}
<span className={`${styles.chatId}`}>
{getSubChatId(botLog.message_session_id)}
</span>
</div>
)}
</div>
<div>{formatTime(botLog.timestamp)}</div>
</div>
<div className={`${styles.cardTitleContainer} ${styles.cardText}`}>
{botLog.text}
</div>
<PhotoProvider className={``}>
<div className={`w-50 mt-2`}>
{botLog.images.map((item) => (
<img
key={item}
src={`${baseURL}/api/v1/files/image/${item}`}
alt=""
/>
))}
</div>
</PhotoProvider>
</div>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { BotLogManager } from '@/app/home/bots/bot-log/BotLogManager';
import { useCallback, useEffect, useRef, useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { BotLogCard } from '@/app/home/bots/bot-log/view/BotLogCard';
import styles from './botLog.module.css';
import { Switch } from '@/components/ui/switch';
import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
export function BotLogListComponent({ botId }: { botId: string }) {
const { t } = useTranslation();
const manager = useRef(new BotLogManager(botId)).current;
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
const [autoFlush, setAutoFlush] = useState(true);
const listContainerRef = useRef<HTMLDivElement>(null);
const botLogListRef = useRef<BotLog[]>(botLogList);
useEffect(() => {
initComponent();
return () => {
onDestroy();
};
}, []);
useEffect(() => {
botLogListRef.current = botLogList;
}, [botLogList]);
// 观测自动刷新状态
useEffect(() => {
if (autoFlush) {
manager.startListenServerPush();
} else {
manager.stopServerPush();
}
return () => {
manager.stopServerPush();
};
}, [autoFlush]);
function initComponent() {
// 订阅日志推送
manager.subscribeLogPush(handleBotLogPush);
// 加载第一页日志
manager.loadFirstPage().then((response) => {
setBotLogList(response.reverse());
});
// 监听滚动
listenScroll();
}
function onDestroy() {
manager.dispose();
removeScrollListener();
}
function listenScroll() {
if (!listContainerRef.current) {
return;
}
const list = listContainerRef.current;
list.addEventListener('scroll', handleScroll);
}
function removeScrollListener() {
if (!listContainerRef.current) {
return;
}
const list = listContainerRef.current;
list.removeEventListener('scroll', handleScroll);
}
function loadMore() {
// 加载更多日志
const list = botLogListRef.current;
const lastSeq = list[list.length - 1].seq_id;
if (lastSeq === 0) {
return;
}
manager.loadMore(lastSeq - 1, 10).then((response) => {
setBotLogList([...list, ...response.reverse()]);
});
}
function handleBotLogPush(response: BotLog[]) {
setBotLogList(response.reverse());
}
const handleScroll = useCallback(
debounce(() => {
if (!listContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } =
listContainerRef.current;
const isBottom = scrollTop + clientHeight >= scrollHeight - 5;
const isTop = scrollTop === 0;
if (isBottom) {
setAutoFlush(false);
loadMore();
}
if (isTop) {
setAutoFlush(true);
}
if (!isTop && !isBottom) {
setAutoFlush(false);
}
}, 300), // 防抖延迟 300ms
[botLogList], // 依赖项为空
);
return (
<div
className={`${styles.botLogListContainer} px-6`}
ref={listContainerRef}
>
<div className={`${styles.listHeader}`}>
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />
</div>
{botLogList.map((botLog) => {
return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
})}
</div>
);
}

View File

@@ -0,0 +1,68 @@
.botLogListContainer {
width: 100%;
min-height: 10rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow-y: scroll;
}
.botLogCardContainer {
width: 100%;
background-color: #fff;
border-radius: 10px;
border: 1px solid #cbd5e1;
padding: 1.2rem;
margin-bottom: 1rem;
cursor: pointer;
}
.listHeader {
width: 100%;
height: 2.5rem;
display: flex;
flex-direction: row;
align-items: center;
}
.tag {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
height: 1.5rem;
padding: 0.5rem;
border-radius: 0.4rem;
background-color: #a5d8ff;
color: #ffffff;
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chatTag {
color: #626262;
background-color: #d1d1d1;
}
.chatId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardTitleContainer {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.cardText {
margin-top: 0.4rem;
color: #64748b;
}

View File

@@ -1,7 +1,34 @@
import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';
import styles from './botCard.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Switch } from '@/components/ui/switch';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export default function BotCard({
botCardVO,
clickLogIconCallback,
setBotEnableCallback,
}: {
botCardVO: BotCardVO;
clickLogIconCallback: (id: string) => void;
setBotEnableCallback: (id: string, enable: boolean) => void;
}) {
const { t } = useTranslation();
function onClickLogIcon() {
clickLogIconCallback(botCardVO.id);
}
function setBotEnable(enable: boolean) {
return httpClient.updateBot(botCardVO.id, {
name: botCardVO.name,
description: botCardVO.description,
adapter: botCardVO.adapter,
adapter_config: botCardVO.adapterConfig,
enable: enable,
});
}
export default function BotCard({ botCardVO }: { botCardVO: BotCardVO }) {
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.iconBasicInfoContainer}`}>
@@ -47,6 +74,44 @@ export default function BotCard({ botCardVO }: { botCardVO: BotCardVO }) {
</span>
</div>
</div>
<div className={`${styles.botOperationContainer}`}>
<Switch
checked={botCardVO.enable}
onCheckedChange={(e) => {
setBotEnable(e)
.then(() => {
setBotEnableCallback(botCardVO.id, e);
})
.catch((err) => {
console.error(err);
toast.error(t('bots.setBotEnableError'));
});
}}
onClick={(e) => {
e.stopPropagation();
}}
/>
<div
className={`${styles.botLogsIcon}`}
onClick={(e) => {
onClickLogIcon();
e.stopPropagation();
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-[24px] h-[24px] z-10"
>
<path
d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"
fill="#9A9A9A"
/>
</svg>
</div>
</div>
</div>
</div>
);

View File

@@ -3,8 +3,11 @@ export interface IBotCardVO {
iconURL: string;
name: string;
description: string;
adapter: string;
adapterLabel: string;
adapterConfig: object;
usePipelineName: string;
enable: boolean;
}
export class BotCardVO implements IBotCardVO {
@@ -12,15 +15,21 @@ export class BotCardVO implements IBotCardVO {
iconURL: string;
name: string;
description: string;
adapter: string;
adapterLabel: string;
adapterConfig: object;
usePipelineName: string;
enable: boolean;
constructor(props: IBotCardVO) {
this.id = props.id;
this.iconURL = props.iconURL;
this.name = props.name;
this.description = props.description;
this.adapter = props.adapter;
this.adapterConfig = props.adapterConfig;
this.adapterLabel = props.adapterLabel;
this.usePipelineName = props.usePipelineName;
this.enable = props.enable;
}
}

View File

@@ -19,7 +19,6 @@
flex-direction: row;
gap: 0.8rem;
user-select: none;
/* background-color: aqua; */
}
.iconImage {
@@ -30,10 +29,10 @@
}
.basicInfoContainer {
position: relative;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
width: 100%;
}
@@ -104,4 +103,14 @@
font-size: 1.4rem;
font-weight: bold;
max-width: 100%;
}
.botOperationContainer {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: 100%;
width: 3rem;
gap: 0.4rem;
}

View File

@@ -17,13 +17,18 @@ import {
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { i18nObj } from '@/i18n/I18nProvider';
import { BotLogListComponent } from '@/app/home/bots/bot-log/view/BotLogListComponent';
export default function BotConfigPage() {
const { t } = useTranslation();
// 编辑机器人的modal
const [modalOpen, setModalOpen] = useState<boolean>(false);
// 机器人日志的modal
const [logModalOpen, setLogModalOpen] = useState<boolean>(false);
const [botList, setBotList] = useState<BotCardVO[]>([]);
const [isEditForm, setIsEditForm] = useState(false);
const [nowSelectedBotUUID, setNowSelectedBotUUID] = useState<string>();
const [nowSelectedBotLog, setNowSelectedBotLog] = useState<string>();
useEffect(() => {
getBotList();
@@ -47,10 +52,13 @@ export default function BotConfigPage() {
iconURL: httpClient.getAdapterIconURL(bot.adapter),
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapterConfig: bot.adapter_config,
adapterLabel:
adapterList.find((item) => item.value === bot.adapter)?.label ||
bot.adapter.substring(0, 10),
usePipelineName: bot.use_pipeline_name || '',
enable: bot.enable || false,
});
});
setBotList(botList);
@@ -76,6 +84,11 @@ export default function BotConfigPage() {
setModalOpen(true);
}
function onClickLogIcon(botId: string) {
setNowSelectedBotLog(botId);
setLogModalOpen(true);
}
return (
<div className={styles.configPageContainer}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
@@ -107,6 +120,15 @@ export default function BotConfigPage() {
</DialogContent>
</Dialog>
<Dialog open={logModalOpen} onOpenChange={setLogModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t('bots.botLogTitle')}</DialogTitle>
</DialogHeader>
<BotLogListComponent botId={nowSelectedBotLog || ''} />
</DialogContent>
</Dialog>
{/* 注意其余的返回内容需要保持在Spin组件外部 */}
<div className={`${styles.botListContainer}`}>
<CreateCardComponent
@@ -123,7 +145,22 @@ export default function BotConfigPage() {
selectBot(cardVO.id);
}}
>
<BotCard botCardVO={cardVO} />
<BotCard
botCardVO={cardVO}
clickLogIconCallback={(id) => {
onClickLogIcon(id);
}}
setBotEnableCallback={(id, enable) => {
setBotList(
botList.map((bot) => {
if (bot.id === id) {
return { ...bot, enable: enable };
}
return bot;
}),
);
}}
/>
</div>
);
})}

View File

@@ -169,7 +169,6 @@ export default function LLMForm({
} else {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
});
}, []);

View File

@@ -30,6 +30,8 @@ import {
GetPipelineMetadataResponseData,
AsyncTask,
} from '@/app/infra/entities/api';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
type JSONValue = string | number | boolean | JSONObject | JSONArray | null;
interface JSONObject {
@@ -54,12 +56,14 @@ export let systemInfo: ApiRespSystemInfo | null = null;
class HttpClient {
private instance: AxiosInstance;
private disableToken: boolean = false;
private baseURL: string;
// 暂不需要SSR
// private ssrInstance: AxiosInstance | null = null
constructor(baseURL?: string, disableToken?: boolean) {
constructor(baseURL: string, disableToken?: boolean) {
this.baseURL = baseURL;
this.instance = axios.create({
baseURL: baseURL || this.getBaseUrl(),
baseURL: baseURL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
@@ -75,15 +79,9 @@ class HttpClient {
}
}
// 兜底URL如果使用未配置会走到这里
private getBaseUrl(): string {
// NOT IMPLEMENT
if (typeof window === 'undefined') {
// 服务端环境
return '';
}
// 客户端环境
return '';
// 外部获取baseURL的方法
getBaseUrl(): string {
return this.baseURL;
}
// 获取Session
@@ -345,6 +343,13 @@ class HttpClient {
return this.delete(`/api/v1/platform/bots/${uuid}`);
}
public getBotLogs(
botId: string,
request: GetBotLogsRequest,
): Promise<GetBotLogsResponse> {
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
}
// ============ Plugins API ============
public getPlugins(): Promise<ApiRespPlugins> {
return this.get('/api/v1/plugins');
@@ -450,9 +455,9 @@ class HttpClient {
}
}
// export const httpClient = new HttpClient("https://version-4.langbot.dev");
export const httpClient = new HttpClient('https://event-log.langbot.dev');
// export const httpClient = new HttpClient('http://localhost:5300');
export const httpClient = new HttpClient('/');
// export const httpClient = new HttpClient('/');
// 临时写法未来两种Client都继承自HttpClient父类不允许共享方法
export const spaceClient = new HttpClient('https://space.langbot.app');

View File

@@ -0,0 +1,4 @@
export interface GetBotLogsRequest {
from_index: number; // 从某索引开始往前找,-1代表结尾也就是拉取最新的
max_count: number; // 最大拉取数量
}

View File

@@ -0,0 +1,13 @@
export interface GetBotLogsResponse {
logs: BotLog[];
total_count: number;
}
export interface BotLog {
images: [];
level: string;
message_session_id: string;
seq_id: number;
text: string;
timestamp: number;
}

View File

@@ -1,4 +1,5 @@
import './global.css';
import 'react-photo-view/dist/react-photo-view.css';
import type { Metadata } from 'next';
import { Toaster } from '@/components/ui/sonner';
import I18nProvider from '@/i18n/I18nProvider';

View File

@@ -60,6 +60,7 @@ function DialogContent({
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
onInteractOutside={() => {}}
{...props}
>
{children}

View File

@@ -37,6 +37,7 @@ const enUS = {
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
addRound: 'Add Round',
copySuccess: 'Copy Successfully',
test: 'Test',
},
notFound: {
@@ -120,6 +121,13 @@ const enUS = {
adapterConfig: 'Adapter Configuration',
bindPipeline: 'Bind Pipeline',
selectPipeline: 'Select Pipeline',
botLogTitle: 'Bot Log',
enableAutoRefresh: 'Enable Auto Refresh',
session: 'Session',
yesterday: 'Yesterday',
earlier: 'Earlier',
dateFormat: '{{month}}/{{day}}',
setBotEnableError: 'Failed to set bot enable status',
},
plugins: {
title: 'Plugins',

View File

@@ -37,6 +37,7 @@ const zhHans = {
deleteSuccess: '删除成功',
deleteError: '删除失败:',
addRound: '添加回合',
copySuccess: '复制成功',
test: '测试',
},
notFound: {
@@ -118,6 +119,13 @@ const zhHans = {
adapterConfig: '适配器配置',
bindPipeline: '绑定流水线',
selectPipeline: '选择流水线',
botLogTitle: '机器人日志',
enableAutoRefresh: '开启自动刷新',
session: '会话',
yesterday: '昨天',
earlier: '更久之前',
dateFormat: '{{month}}月{{day}}日',
setBotEnableError: '设置机器人启用状态失败',
},
plugins: {
title: '插件管理',