Feat: Export Agent Logs. (#13658)

### What problem does this PR solve?
Feat: Export Agent Logs.

### Type of change


- [x] New Feature (non-breaking change which adds functionality)

---------

Co-authored-by: balibabu <assassin_cike@163.com>
This commit is contained in:
balibabu
2026-03-17 18:51:26 +08:00
committed by GitHub
parent fc4f1e2488
commit 6cae364ac2
13 changed files with 199 additions and 24 deletions

View File

@ -688,6 +688,8 @@ async def set_session(canvas_id):
session_id=get_uuid()
canvas = Canvas(cvs.dsl, tenant_id, canvas_id, canvas_id=cvs.id)
canvas.reset()
# Get the version title for this canvas (using latest, not necessarily released)
version_title = UserCanvasVersionService.get_latest_version_title(cvs.id, release_mode=False)
conv = {
"id": session_id,
"name": req.get("name", ""),
@ -697,7 +699,8 @@ async def set_session(canvas_id):
"message": [],
"source": "agent",
"dsl": cvs.dsl,
"reference": []
"reference": [],
"version_title": version_title
}
API4ConversationService.save(**conv)
return get_json_result(data=conv)

View File

@ -32,6 +32,7 @@ from api.db.services.api_service import API4ConversationService
from api.db.services.canvas_service import UserCanvasService, completion_openai
from api.db.services.canvas_service import completion as agent_completion
from api.db.services.conversation_service import ConversationService
from api.db.services.user_canvas_version import UserCanvasVersionService
from api.db.services.conversation_service import async_iframe_completion as iframe_completion
from api.db.services.conversation_service import async_completion as rag_completion
from api.db.services.dialog_service import DialogService, async_ask, async_chat, gen_mindmap
@ -104,8 +105,17 @@ async def create_agent_session(tenant_id, agent_id):
canvas.reset()
cvs.dsl = json.loads(str(canvas))
conv = {"id": session_id, "dialog_id": cvs.id, "user_id": user_id,
"message": [{"role": "assistant", "content": canvas.get_prologue()}], "source": "agent", "dsl": cvs.dsl}
# Get the version title based on release_mode
version_title = UserCanvasVersionService.get_latest_version_title(cvs.id, release_mode=release_mode)
conv = {
"id": session_id,
"dialog_id": cvs.id,
"user_id": user_id,
"message": [{"role": "assistant", "content": canvas.get_prologue()}],
"source": "agent",
"dsl": cvs.dsl,
"version_title": version_title
}
API4ConversationService.save(**conv)
conv["agent_id"] = conv.pop("dialog_id")
return get_result(data=conv)

View File

@ -1034,6 +1034,7 @@ class API4Conversation(DataBaseModel):
round = IntegerField(default=0, index=True)
thumb_up = IntegerField(default=0, index=True)
errors = TextField(null=True, help_text="errors")
version_title = CharField(max_length=255, null=True, help_text="canvas version title when session created", index=False)
class Meta:
db_table = "api_4_conversation"
@ -1632,6 +1633,7 @@ def migrate_db():
alter_db_add_column(migrator, "memory", "tenant_embd_id", IntegerField(null=True, help_text="id in tenant_llm", index=True))
alter_db_add_column(migrator, "memory", "tenant_llm_id", IntegerField(null=True, help_text="id in tenant_llm", index=True))
alter_db_add_column(migrator, "user_canvas_version", "release", BooleanField(null=False, help_text="is released", default=False, index=True))
alter_db_add_column(migrator, "api_4_conversation", "version_title", CharField(max_length=255, null=True, help_text="canvas version title when session created", index=False))
logging.disable(logging.NOTSET)
# this is after re-enabling logging to allow logging changed user emails
migrate_add_unique_email(migrator)

View File

@ -250,7 +250,9 @@ async def completion(tenant_id, agent_id, session_id=None, **kwargs):
session_id = get_uuid()
canvas = Canvas(dsl, tenant_id, agent_id, canvas_id=cvs.id, custom_header=custom_header)
canvas.reset()
conv = {"id": session_id, "dialog_id": cvs.id, "user_id": user_id, "message": [], "source": "agent", "dsl": dsl, "reference": []}
# Get the version title based on release_mode
version_title = UserCanvasVersionService.get_latest_version_title(cvs.id, release_mode=release_mode == "true")
conv = {"id": session_id, "dialog_id": cvs.id, "user_id": user_id, "message": [], "source": "agent", "dsl": dsl, "reference": [], "version_title": version_title}
API4ConversationService.save(**conv)
conv = API4Conversation(**conv)

View File

@ -91,15 +91,36 @@ class UserCanvasVersionService(CommonService):
@classmethod
@DB.connection_context()
def get_latest_released(cls, user_canvas_id):
def _get_latest_by_canvas_id(cls, user_canvas_id, only_released=False):
"""Get the latest version for a canvas, optionally filtered by release status."""
try:
return cls.model.select().where((cls.model.user_canvas_id == user_canvas_id) & (cls.model.release)).order_by(cls.model.create_time.desc()).first()
query = cls.model.select().where(cls.model.user_canvas_id == user_canvas_id)
if only_released:
query = query.where(cls.model.release)
return query.order_by(cls.model.create_time.desc()).first()
except DoesNotExist:
return None
except Exception as e:
logging.exception(e)
return None
@classmethod
def get_latest_released(cls, user_canvas_id):
"""Get the latest released version for a canvas."""
return cls._get_latest_by_canvas_id(user_canvas_id, only_released=True)
@classmethod
def get_latest_version_title(cls, user_canvas_id, release_mode=False):
"""Get the version title for a canvas based on release_mode.
Args:
user_canvas_id: The canvas ID
release_mode: If True, get the latest released version title;
If False, get the latest version title (regardless of release status)
"""
latest = cls._get_latest_by_canvas_id(user_canvas_id, only_released=release_mode)
return latest.title if latest else None
@classmethod
@DB.connection_context()
def save_or_replace_latest(cls, user_canvas_id, dsl, title=None, description=None, release=None):
@ -137,9 +158,9 @@ class UserCanvasVersionService(CommonService):
return None, True
# Normal case: update existing version
# DSL unchanged: do NOT update title to preserve version identity
# Only update dsl (for normalization consistency), description, and release
update_data = {"dsl": normalized_dsl}
if title is not None:
update_data["title"] = title
if description is not None:
update_data["description"] = description
if release is not None:

View File

@ -366,6 +366,7 @@ def _load_canvas_module(monkeypatch):
get_by_id=lambda *_args, **_kwargs: (True, None),
save_or_replace_latest=lambda *_args, **_kwargs: True,
build_version_title=lambda *_args, **_kwargs: "stub_version_title",
get_latest_version_title=lambda *_args, **_kwargs: "stub_version_title",
)
monkeypatch.setitem(sys.modules, "api.db.services.user_canvas_version", canvas_version_mod)

View File

@ -71,6 +71,8 @@ export const enum AgentApiAction {
CreateAgentSession = 'createAgentSession',
DeleteAgentSession = 'deleteAgentSession',
FetchSessionByIdManually = 'fetchSessionByIdManually',
FetchAgentLog = 'fetchAgentLog',
FetchFlowDetailSSE = 'flowDetailSSE',
}
export const useFetchAgentTemplates = () => {
@ -599,7 +601,7 @@ export const useFetchAgentAvatar = (): {
export const useFetchAgentLog = (searchParams: IAgentLogsRequest) => {
const { id } = useParams();
const { data, isFetching: loading } = useQuery<IAgentLogsResponse>({
queryKey: ['fetchAgentLog', id, searchParams],
queryKey: [AgentApiAction.FetchAgentLog, id, searchParams],
initialData: {} as IAgentLogsResponse,
gcTime: 0,
queryFn: async () => {
@ -803,7 +805,7 @@ export const useFetchFlowSSE = (): {
isFetching: loading,
refetch,
} = useQuery({
queryKey: ['flowDetailSSE'],
queryKey: [AgentApiAction.FetchFlowDetailSSE],
initialData: {} as IFlow,
refetchOnReconnect: false,
refetchOnMount: false,
@ -970,3 +972,21 @@ export function useFetchSessionManually() {
return { data, loading, fetchSessionManually: mutateAsync };
}
export const useExportAgentLog = () => {
const { id } = useParams();
const { mutateAsync, isPending: loading } = useMutation({
mutationKey: [AgentApiAction.FetchAgentLog, 'export', id],
mutationFn: async (searchParams: IAgentLogsRequest) => {
const { data } = await fetchAgentLogsByCanvasId(id as string, {
...searchParams,
page: 1,
page_size: 100000,
});
return data?.data?.sessions ?? [];
},
});
return { exportLogs: mutateAsync, loading };
};

View File

@ -97,10 +97,12 @@ export interface IFlowTemplate {
description: {
en: string;
zh: string;
de: string;
};
title: {
en: string;
zh: string;
de: string;
};
}
@ -264,6 +266,7 @@ export interface IAgentLogResponse {
dsl: string;
reference: IReference;
name: string;
version_title: string;
}
export interface IAgentLogsResponse {
total: number;

View File

@ -2420,6 +2420,15 @@ Important structured information may include: names, dates, locations, events, k
},
saveToMemory: 'Save to memory',
retrievalFrom: 'Retrieval from',
id: 'ID',
state: 'State',
number: 'Number',
latestDate: 'Latest date',
createDate: 'Create date',
noDataToExport: 'No data to export',
success: 'Success',
failed: 'Failed',
logTitle: 'Title',
},
llmTools: {
bad_calculator: {

View File

@ -1260,6 +1260,12 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
consumerApp: '消费者应用',
other: '其他',
agents: '智能体',
id: 'ID',
logTitle: '标题',
state: '状态',
number: '轮数',
latestDate: '最新日期',
createDate: '创建日期',
publishedAt: '发布于',
beginInput: '开始输入',
seconds: '秒',
@ -1352,6 +1358,7 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
version: {
details: '版本详情',
download: '下载',
version: '版本',
},
cite: '引用',
citeTip: '引用',

View File

@ -72,7 +72,7 @@ export function VersionDialog({
</DialogTitle>
</DialogHeader>
<section className="flex gap-8 relative">
<div className="w-1/3 max-h-[60vh] overflow-auto min-h-[40vh]">
<div className="w-72 max-h-[60vh] overflow-auto min-h-[40vh]">
{loading ? (
<Spin className="top-1/2"></Spin>
) : (
@ -100,7 +100,7 @@ export function VersionDialog({
) : (
<Card className="h-full">
<CardContent className="h-full p-5 flex flex-col">
<section className="flex justify-between">
<section className="flex justify-between pb-2">
<div>
<div className="flex">
<span className="pb-1 truncate">{agent?.title}</span>
@ -143,6 +143,9 @@ export function VersionDialog({
zoomOnDoubleClick={false}
preventScrolling={true}
minZoom={0.1}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
>
<AgentBackground></AgentBackground>
<Spotlight className="z-0" opcity={0.7} coverage={70} />

View File

@ -34,6 +34,7 @@ import {
} from '../../components/ui/table';
import { useFetchDataOnMount } from '../agent/hooks/use-fetch-data';
import { AgentLogDetailModal } from './agent-log-detail-modal';
import { useExportAgentLogToCSV } from './hooks/use-export-agent-log';
const getStartOfToday = (): Date => {
const today = new Date();
today.setHours(0, 0, 0, 0);
@ -64,18 +65,18 @@ const AgentLogPage: React.FC = () => {
const columns = [
{
title: 'ID',
title: t('flow.id'),
dataIndex: 'id',
key: 'id',
},
{
title: 'User ID',
title: t('flow.userId'),
dataIndex: 'user_id',
key: 'user_id',
render: (text: string) => <span>{text}</span>,
},
{
title: 'Title',
title: t('flow.logTitle'),
dataIndex: 'title',
key: 'title',
render: (_text: string, record: IAgentLogResponse) => (
@ -85,7 +86,7 @@ const AgentLogPage: React.FC = () => {
),
},
{
title: 'State',
title: t('flow.state'),
dataIndex: 'state',
key: 'state',
render: (_text: string, record: IAgentLogResponse) => (
@ -96,31 +97,32 @@ const AgentLogPage: React.FC = () => {
),
},
{
title: 'Number',
title: t('flow.number'),
dataIndex: 'round',
key: 'round',
},
{
title: 'Latest Date',
title: t('flow.latestDate'),
dataIndex: 'update_date',
key: 'update_date',
sortable: true,
},
{
title: 'Create Date',
title: t('flow.createDate'),
dataIndex: 'create_date',
key: 'create_date',
sortable: true,
},
{
title: 'Version',
dataIndex: 'version',
key: 'version',
title: t('flow.version.version'),
dataIndex: 'version_title',
key: 'version_title',
},
];
const { data: logData, loading } = useFetchAgentLog(searchParams);
const { sessions: data, total } = logData || {};
const { handleExport, loading: exportLoading } = useExportAgentLogToCSV();
const [currentDate, setCurrentDate] = useState<DateRange>({
from: searchParams.from_date,
to: searchParams.to_date,
@ -158,7 +160,6 @@ const AgentLogPage: React.FC = () => {
} | null>({ orderby: init.orderby, desc: init.desc ? true : false });
const handlePageChange = (current?: number, pageSize?: number) => {
console.log('current', current, 'pageSize', pageSize);
let page = current || 1;
if (pagination.pageSize !== pageSize) {
page = 1;
@ -219,6 +220,16 @@ const AgentLogPage: React.FC = () => {
}
};
const onExportClick = () => {
handleExport({
keywords: searchParams.keywords,
from_date: searchParams.from_date,
to_date: searchParams.to_date,
orderby: searchParams.orderby,
desc: searchParams.desc,
});
};
return (
<div className=" text-white">
<PageHeader>
@ -246,7 +257,9 @@ const AgentLogPage: React.FC = () => {
<div className="flex justify-end space-x-2 mb-4 text-foreground">
<div className="flex items-center space-x-2">
<Button>{t('flow.export')}</Button>
<Button onClick={onExportClick} loading={exportLoading}>
{t('flow.export')}
</Button>
<span>ID/Title</span>
<SearchInput
value={keywords}

View File

@ -0,0 +1,81 @@
import message from '@/components/ui/message';
import { useExportAgentLog } from '@/hooks/use-agent-request';
import { IAgentLogResponse } from '@/interfaces/database/agent';
import { downloadFileFromBlob } from '@/utils/file-util';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
interface ISearchParams {
keywords?: string;
from_date?: Date;
to_date?: Date;
orderby?: string;
desc?: boolean;
}
export const useExportAgentLogToCSV = () => {
const { t } = useTranslation();
const { id: canvasId } = useParams();
const { exportLogs, loading } = useExportAgentLog();
const convertToCSV = (data: IAgentLogResponse[]) => {
const headers = [
t('flow.id'),
t('flow.userId'),
t('flow.logTitle'),
t('flow.state'),
t('flow.number'),
t('flow.latestDate'),
t('flow.createDate'),
t('flow.version.version'),
];
const rows = data.map((item) => [
item.id,
item.user_id,
item.message?.length ? item.message[0]?.content : '',
item.errors ? t('flow.failed') : t('flow.success'),
item.round,
item.update_date,
item.create_date,
item.version_title,
]);
const csvContent = [
headers.join(','),
...rows.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','),
),
].join('\n');
return csvContent;
};
const handleExport = async (searchParams: ISearchParams) => {
const allData = await exportLogs({
keywords: searchParams.keywords,
from_date: searchParams.from_date,
to_date: searchParams.to_date,
orderby: searchParams.orderby,
desc: searchParams.desc,
});
if (allData.length === 0) {
message.warning(t('flow.noDataToExport'));
return;
}
const csvContent = convertToCSV(allData);
// Add BOM for Excel to correctly display UTF-8
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], {
type: 'text/csv;charset=utf-8;',
});
downloadFileFromBlob(
blob,
`agent-logs-${canvasId}-${new Date().toISOString().split('T')[0]}.csv`,
);
};
return { handleExport, loading };
};