Compare commits

...

3 Commits

Author SHA1 Message Date
Arvin Xu 6124d71534 💄 style(agent): move stats switcher to profile header 2026-06-09 00:26:49 +08:00
Arvin Xu a766106d2b feat(agent): add profile operation stats tab 2026-06-08 23:59:56 +08:00
Arvin Xu 7c67d26735 🐛 fix(ui): import Button from root UI package 2026-06-08 23:59:38 +08:00
13 changed files with 981 additions and 48 deletions
+29
View File
@@ -333,11 +333,40 @@
"heterogeneousStatus.command.edit": "Edit command",
"heterogeneousStatus.command.label": "Launch Command",
"heterogeneousStatus.command.placeholder": "Command name or absolute path",
"heterogeneousStatus.config.tabLabel": "Configuration",
"heterogeneousStatus.desktop.tabLabel": "Desktop",
"heterogeneousStatus.detecting": "Detecting {{name}} CLI...",
"heterogeneousStatus.plan.label": "Plan",
"heterogeneousStatus.redetect": "Re-detect",
"heterogeneousStatus.unavailable": "{{name}} CLI not found. Please install or configure it.",
"heterogeneousStatus.usage.callsSub": "{{llm}} LLM calls · {{tools}} tool calls",
"heterogeneousStatus.usage.chartTitle": "Daily usage",
"heterogeneousStatus.usage.chartTooltip": "{{date}} · {{tokens}} tokens · {{runs}} runs · {{cost}} · input {{input}} · output {{output}}",
"heterogeneousStatus.usage.columns.cost": "Cost",
"heterogeneousStatus.usage.columns.duration": "Duration",
"heterogeneousStatus.usage.columns.model": "Model",
"heterogeneousStatus.usage.columns.run": "Run",
"heterogeneousStatus.usage.columns.tokens": "Tokens",
"heterogeneousStatus.usage.cost": "Cost · 30d",
"heterogeneousStatus.usage.costSub": "{{count}} runs in 30d",
"heterogeneousStatus.usage.duration": "Avg Duration",
"heterogeneousStatus.usage.durationSub": "{{steps}} steps/run",
"heterogeneousStatus.usage.empty": "No agent operations in the last 30 days",
"heterogeneousStatus.usage.error": "Unable to load operation stats",
"heterogeneousStatus.usage.input": "Input",
"heterogeneousStatus.usage.output": "Output",
"heterogeneousStatus.usage.recentTitle": "Recent runs",
"heterogeneousStatus.usage.runs": "Runs · 30d",
"heterogeneousStatus.usage.runsSub": "{{successRate}} success · {{failed}} failed",
"heterogeneousStatus.usage.status.done": "Completed",
"heterogeneousStatus.usage.status.error": "Failed",
"heterogeneousStatus.usage.status.interrupted": "Interrupted",
"heterogeneousStatus.usage.status.running": "Running",
"heterogeneousStatus.usage.status.waiting_for_async_tool": "Waiting for async tool",
"heterogeneousStatus.usage.status.waiting_for_human": "Waiting for human",
"heterogeneousStatus.usage.tabLabel": "Operation Stats",
"heterogeneousStatus.usage.tokens": "Tokens · 30d",
"heterogeneousStatus.usage.tokensSub": "Input {{input}} · Output {{output}}",
"hotkey.clearBinding": "Clear binding",
"hotkey.conflicts": "Conflicts with existing hotkeys",
"hotkey.errors.CONFLICT": "Hotkey conflict: This hotkey is already assigned to another function",
+29
View File
@@ -333,11 +333,40 @@
"heterogeneousStatus.command.edit": "编辑指令",
"heterogeneousStatus.command.label": "启动指令",
"heterogeneousStatus.command.placeholder": "指令名称或绝对路径",
"heterogeneousStatus.config.tabLabel": "配置",
"heterogeneousStatus.desktop.tabLabel": "桌面端",
"heterogeneousStatus.detecting": "正在检测 {{name}} CLI…",
"heterogeneousStatus.plan.label": "方案",
"heterogeneousStatus.redetect": "重新检测",
"heterogeneousStatus.unavailable": "未检测到 {{name}} CLI,请先安装或配置",
"heterogeneousStatus.usage.callsSub": "{{llm}} 次 LLM 调用 · {{tools}} 次工具调用",
"heterogeneousStatus.usage.chartTitle": "每日用量",
"heterogeneousStatus.usage.chartTooltip": "{{date}} · {{tokens}} tokens · {{runs}} 次运行 · {{cost}} · 输入 {{input}} · 输出 {{output}}",
"heterogeneousStatus.usage.columns.cost": "费用",
"heterogeneousStatus.usage.columns.duration": "耗时",
"heterogeneousStatus.usage.columns.model": "模型",
"heterogeneousStatus.usage.columns.run": "运行",
"heterogeneousStatus.usage.columns.tokens": "Tokens",
"heterogeneousStatus.usage.cost": "费用 · 30 天",
"heterogeneousStatus.usage.costSub": "30 天内 {{count}} 次运行",
"heterogeneousStatus.usage.duration": "平均耗时",
"heterogeneousStatus.usage.durationSub": "平均 {{steps}} 步/次",
"heterogeneousStatus.usage.empty": "最近 30 天暂无 agent 调用记录",
"heterogeneousStatus.usage.error": "无法加载调用统计",
"heterogeneousStatus.usage.input": "输入",
"heterogeneousStatus.usage.output": "输出",
"heterogeneousStatus.usage.recentTitle": "最近运行",
"heterogeneousStatus.usage.runs": "运行 · 30 天",
"heterogeneousStatus.usage.runsSub": "{{successRate}} 成功 · {{failed}} 次失败",
"heterogeneousStatus.usage.status.done": "已完成",
"heterogeneousStatus.usage.status.error": "失败",
"heterogeneousStatus.usage.status.interrupted": "已中断",
"heterogeneousStatus.usage.status.running": "运行中",
"heterogeneousStatus.usage.status.waiting_for_async_tool": "等待异步工具",
"heterogeneousStatus.usage.status.waiting_for_human": "等待人工",
"heterogeneousStatus.usage.tabLabel": "调用统计",
"heterogeneousStatus.usage.tokens": "Tokens · 30 天",
"heterogeneousStatus.usage.tokensSub": "输入 {{input}} · 输出 {{output}}",
"hotkey.clearBinding": "清除绑定",
"hotkey.conflicts": "与现有快捷键冲突",
"hotkey.errors.CONFLICT": "快捷键冲突:该快捷键已被其他功能占用",
@@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { agentOperations, users } from '../../schemas';
import { agentOperations, agents, users } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { AgentOperationModel } from '../agentOperation';
@@ -268,4 +268,132 @@ describe('AgentOperationModel', () => {
expect(result).toBe(0);
});
});
describe('getProfileStats', () => {
it('aggregates recent operations for one agent and pads daily buckets', async () => {
const model = new AgentOperationModel(serverDB, userId);
const agentId = 'agent-operation-stats-agent';
const otherAgentId = 'agent-operation-stats-other-agent';
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const oldDate = new Date(now.getTime() - 40 * 24 * 60 * 60 * 1000);
await serverDB.insert(agents).values([
{ id: agentId, title: 'Stats agent', userId },
{ id: otherAgentId, title: 'Other agent', userId },
{
id: 'agent-operation-stats-cross-user-agent',
title: 'Cross user agent',
userId: otherUserId,
},
]);
await serverDB.insert(agentOperations).values([
{
agentId,
completedAt: now,
completionReason: 'done',
createdAt: now,
id: 'op-stats-today',
llmCalls: 2,
processingTimeMs: 1000,
startedAt: now,
status: 'done',
stepCount: 4,
toolCalls: 3,
totalCost: 0.2,
totalInputTokens: 60,
totalOutputTokens: 40,
totalTokens: 100,
userId,
},
{
agentId,
completedAt: yesterday,
completionReason: 'done',
createdAt: yesterday,
id: 'op-stats-yesterday',
llmCalls: 3,
processingTimeMs: 3000,
startedAt: yesterday,
status: 'done',
stepCount: 6,
toolCalls: 1,
totalCost: 0.3,
totalInputTokens: 100,
totalOutputTokens: 100,
totalTokens: 200,
userId,
},
{
agentId,
completedAt: now,
completionReason: 'error',
createdAt: now,
error: { message: 'failed run' },
id: 'op-stats-error',
processingTimeMs: 200,
startedAt: now,
status: 'error',
userId,
},
{
agentId: otherAgentId,
completedAt: now,
createdAt: now,
id: 'op-stats-other-agent',
status: 'done',
totalCost: 9,
totalTokens: 9000,
userId,
},
{
agentId,
completedAt: oldDate,
createdAt: oldDate,
id: 'op-stats-old',
status: 'done',
totalCost: 7,
totalTokens: 7000,
userId,
},
{
agentId,
completedAt: now,
createdAt: now,
id: 'op-stats-other-user',
status: 'done',
totalCost: 8,
totalTokens: 8000,
userId: otherUserId,
},
]);
const result = await model.getProfileStats({ agentId, days: 3, recentLimit: 2 });
expect(result.summary).toMatchObject({
completedOperations: 2,
failedOperations: 1,
interruptedOperations: 0,
llmCalls: 5,
operationCount: 3,
toolCalls: 4,
totalInputTokens: 160,
totalOutputTokens: 140,
totalTokens: 300,
});
expect(result.summary.totalCost).toBeCloseTo(0.5, 6);
expect(result.summary.totalDurationMs).toBe(4200);
expect(result.summary.successRate).toBeCloseTo(2 / 3, 6);
expect(result.daily).toHaveLength(3);
expect(result.daily.reduce((sum, item) => sum + item.operationCount, 0)).toBe(3);
expect(result.daily.reduce((sum, item) => sum + item.totalTokens, 0)).toBe(300);
expect(result.recentOperations).toHaveLength(2);
expect(result.recentOperations.map((item) => item.id)).toContain('op-stats-error');
expect(
result.recentOperations.find((item) => item.id === 'op-stats-error')?.errorMessage,
).toBe('failed run');
});
});
});
+228 -1
View File
@@ -1,5 +1,5 @@
import type { VerifyCheckItem } from '@lobechat/types';
import { and, eq, gte, isNotNull, isNull, sql } from 'drizzle-orm';
import { and, desc, eq, gte, isNotNull, isNull, sql } from 'drizzle-orm';
import { today } from '@/utils/time';
@@ -77,6 +77,66 @@ export interface RecordOperationCompletionParams {
usage?: Record<string, unknown> | null;
}
export interface AgentOperationProfileStatsParams {
agentId: string;
days?: number;
recentLimit?: number;
}
export interface AgentOperationDailyStats {
date: string;
llmCalls: number;
operationCount: number;
processingTimeMs: number;
toolCalls: number;
totalCost: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
}
export interface AgentOperationRecentItem {
completedAt: Date | null;
completionReason: string | null;
createdAt: Date;
errorMessage?: string;
id: string;
llmCalls: number;
model: string | null;
processingTimeMs: number;
provider: string | null;
startedAt: Date | null;
status: string;
stepCount: number;
toolCalls: number;
totalCost: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
trigger: string | null;
}
export interface AgentOperationProfileStats {
daily: AgentOperationDailyStats[];
recentOperations: AgentOperationRecentItem[];
summary: {
averageDurationMs: number;
averageStepCount: number;
completedOperations: number;
failedOperations: number;
interruptedOperations: number;
llmCalls: number;
operationCount: number;
successRate: number;
toolCalls: number;
totalCost: number;
totalDurationMs: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
};
}
export class AgentOperationModel {
private readonly db: LobeChatDatabase;
private readonly userId: string;
@@ -163,6 +223,173 @@ export class AgentOperationModel {
return row ?? null;
}
async getProfileStats({
agentId,
days = 30,
recentLimit = 8,
}: AgentOperationProfileStatsParams): Promise<AgentOperationProfileStats> {
const safeDays = Math.min(Math.max(Math.trunc(days) || 30, 1), 90);
const safeRecentLimit = Math.min(Math.max(Math.trunc(recentLimit) || 8, 1), 20);
const startDate = today()
.subtract(safeDays - 1, 'day')
.startOf('day')
.toDate();
const where = and(
eq(agentOperations.userId, this.userId),
eq(agentOperations.agentId, agentId),
gte(agentOperations.createdAt, startDate),
);
const [summaryRow] = await this.db
.select({
averageDurationMs:
sql<number>`COALESCE(AVG(${agentOperations.processingTimeMs}), 0)`.mapWith(Number),
averageStepCount: sql<number>`COALESCE(AVG(${agentOperations.stepCount}), 0)`.mapWith(
Number,
),
completedOperations:
sql<number>`COUNT(*) FILTER (WHERE ${agentOperations.status} = 'done')::int`.mapWith(
Number,
),
failedOperations:
sql<number>`COUNT(*) FILTER (WHERE ${agentOperations.status} = 'error')::int`.mapWith(
Number,
),
interruptedOperations:
sql<number>`COUNT(*) FILTER (WHERE ${agentOperations.status} = 'interrupted')::int`.mapWith(
Number,
),
llmCalls: sql<number>`COALESCE(SUM(${agentOperations.llmCalls}), 0)`.mapWith(Number),
operationCount: sql<number>`COUNT(*)::int`.mapWith(Number),
toolCalls: sql<number>`COALESCE(SUM(${agentOperations.toolCalls}), 0)`.mapWith(Number),
totalCost: sql<number>`COALESCE(SUM(${agentOperations.totalCost}), 0)`.mapWith(Number),
totalDurationMs: sql<number>`COALESCE(SUM(${agentOperations.processingTimeMs}), 0)`.mapWith(
Number,
),
totalInputTokens:
sql<number>`COALESCE(SUM(${agentOperations.totalInputTokens}), 0)`.mapWith(Number),
totalOutputTokens:
sql<number>`COALESCE(SUM(${agentOperations.totalOutputTokens}), 0)`.mapWith(Number),
totalTokens: sql<number>`COALESCE(SUM(${agentOperations.totalTokens}), 0)`.mapWith(Number),
})
.from(agentOperations)
.where(where);
const dayExpr = sql<string>`to_char(${agentOperations.createdAt}, 'YYYY-MM-DD')`;
const dailyRows = await this.db
.select({
date: dayExpr,
llmCalls: sql<number>`COALESCE(SUM(${agentOperations.llmCalls}), 0)`.mapWith(Number),
operationCount: sql<number>`COUNT(*)::int`.mapWith(Number),
processingTimeMs:
sql<number>`COALESCE(SUM(${agentOperations.processingTimeMs}), 0)`.mapWith(Number),
toolCalls: sql<number>`COALESCE(SUM(${agentOperations.toolCalls}), 0)`.mapWith(Number),
totalCost: sql<number>`COALESCE(SUM(${agentOperations.totalCost}), 0)`.mapWith(Number),
totalInputTokens:
sql<number>`COALESCE(SUM(${agentOperations.totalInputTokens}), 0)`.mapWith(Number),
totalOutputTokens:
sql<number>`COALESCE(SUM(${agentOperations.totalOutputTokens}), 0)`.mapWith(Number),
totalTokens: sql<number>`COALESCE(SUM(${agentOperations.totalTokens}), 0)`.mapWith(Number),
})
.from(agentOperations)
.where(where)
.groupBy(dayExpr)
.orderBy(dayExpr);
const recentRows = await this.db
.select({
completedAt: agentOperations.completedAt,
completionReason: agentOperations.completionReason,
createdAt: agentOperations.createdAt,
error: agentOperations.error,
id: agentOperations.id,
llmCalls: agentOperations.llmCalls,
model: agentOperations.model,
processingTimeMs: agentOperations.processingTimeMs,
provider: agentOperations.provider,
startedAt: agentOperations.startedAt,
status: agentOperations.status,
stepCount: agentOperations.stepCount,
toolCalls: agentOperations.toolCalls,
totalCost: agentOperations.totalCost,
totalInputTokens: agentOperations.totalInputTokens,
totalOutputTokens: agentOperations.totalOutputTokens,
totalTokens: agentOperations.totalTokens,
trigger: agentOperations.trigger,
})
.from(agentOperations)
.where(where)
.orderBy(desc(agentOperations.createdAt))
.limit(safeRecentLimit);
const dailyByDate = new Map(dailyRows.map((row) => [row.date, row]));
const daily = Array.from({ length: safeDays }, (_, index) => {
const date = today()
.subtract(safeDays - 1 - index, 'day')
.format('YYYY-MM-DD');
const row = dailyByDate.get(date);
return {
date,
llmCalls: Number(row?.llmCalls ?? 0),
operationCount: Number(row?.operationCount ?? 0),
processingTimeMs: Number(row?.processingTimeMs ?? 0),
toolCalls: Number(row?.toolCalls ?? 0),
totalCost: Number(row?.totalCost ?? 0),
totalInputTokens: Number(row?.totalInputTokens ?? 0),
totalOutputTokens: Number(row?.totalOutputTokens ?? 0),
totalTokens: Number(row?.totalTokens ?? 0),
};
});
const operationCount = Number(summaryRow?.operationCount ?? 0);
const completedOperations = Number(summaryRow?.completedOperations ?? 0);
return {
daily,
recentOperations: recentRows.map((row) => ({
completedAt: row.completedAt,
completionReason: row.completionReason,
createdAt: row.createdAt,
errorMessage:
typeof row.error?.message === 'string' && row.error.message.trim()
? row.error.message
: undefined,
id: row.id,
llmCalls: Number(row.llmCalls ?? 0),
model: row.model,
processingTimeMs: Number(row.processingTimeMs ?? 0),
provider: row.provider,
startedAt: row.startedAt,
status: row.status,
stepCount: Number(row.stepCount ?? 0),
toolCalls: Number(row.toolCalls ?? 0),
totalCost: Number(row.totalCost ?? 0),
totalInputTokens: Number(row.totalInputTokens ?? 0),
totalOutputTokens: Number(row.totalOutputTokens ?? 0),
totalTokens: Number(row.totalTokens ?? 0),
trigger: row.trigger,
})),
summary: {
averageDurationMs: Number(summaryRow?.averageDurationMs ?? 0),
averageStepCount: Number(summaryRow?.averageStepCount ?? 0),
completedOperations,
failedOperations: Number(summaryRow?.failedOperations ?? 0),
interruptedOperations: Number(summaryRow?.interruptedOperations ?? 0),
llmCalls: Number(summaryRow?.llmCalls ?? 0),
operationCount,
successRate: operationCount > 0 ? completedOperations / operationCount : 0,
toolCalls: Number(summaryRow?.toolCalls ?? 0),
totalCost: Number(summaryRow?.totalCost ?? 0),
totalDurationMs: Number(summaryRow?.totalDurationMs ?? 0),
totalInputTokens: Number(summaryRow?.totalInputTokens ?? 0),
totalOutputTokens: Number(summaryRow?.totalOutputTokens ?? 0),
totalTokens: Number(summaryRow?.totalTokens ?? 0),
},
};
}
/**
* Longest single operation (agent run) wall-clock execution time over the last
* year, in seconds. Wall clock (`completedAt - startedAt`) is the most faithful
@@ -1,9 +1,8 @@
'use client';
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { Flexbox, Icon } from '@lobehub/ui';
import { Button, Flexbox, Icon } from '@lobehub/ui';
import {
Button,
createModal,
type ImperativeModalProps,
ModalFooter,
@@ -1,8 +1,8 @@
'use client';
import type { TaskTemplate } from '@lobechat/const';
import { ActionIcon, Flexbox, Icon, Markdown, Text } from '@lobehub/ui';
import { Button, createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui';
import { ActionIcon, Button, Flexbox, Icon, Markdown, Text } from '@lobehub/ui';
import { createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui';
import { Divider } from 'antd';
import { cssVar } from 'antd-style';
import { Clock, X } from 'lucide-react';
+30
View File
@@ -212,10 +212,40 @@ export default {
'heterogeneousStatus.command.edit': 'Edit command',
'heterogeneousStatus.command.label': 'Launch Command',
'heterogeneousStatus.command.placeholder': 'Command name or absolute path',
'heterogeneousStatus.config.tabLabel': 'Configuration',
'heterogeneousStatus.detecting': 'Detecting {{name}} CLI...',
'heterogeneousStatus.plan.label': 'Plan',
'heterogeneousStatus.redetect': 'Re-detect',
'heterogeneousStatus.unavailable': '{{name}} CLI not found. Please install or configure it.',
'heterogeneousStatus.usage.callsSub': '{{llm}} LLM calls · {{tools}} tool calls',
'heterogeneousStatus.usage.chartTitle': 'Daily usage',
'heterogeneousStatus.usage.chartTooltip':
'{{date}} · {{tokens}} tokens · {{runs}} runs · {{cost}} · input {{input}} · output {{output}}',
'heterogeneousStatus.usage.columns.cost': 'Cost',
'heterogeneousStatus.usage.columns.duration': 'Duration',
'heterogeneousStatus.usage.columns.model': 'Model',
'heterogeneousStatus.usage.columns.run': 'Run',
'heterogeneousStatus.usage.columns.tokens': 'Tokens',
'heterogeneousStatus.usage.cost': 'Cost · 30d',
'heterogeneousStatus.usage.costSub': '{{count}} runs in 30d',
'heterogeneousStatus.usage.duration': 'Avg Duration',
'heterogeneousStatus.usage.durationSub': '{{steps}} steps/run',
'heterogeneousStatus.usage.empty': 'No agent operations in the last 30 days',
'heterogeneousStatus.usage.error': 'Unable to load operation stats',
'heterogeneousStatus.usage.input': 'Input',
'heterogeneousStatus.usage.output': 'Output',
'heterogeneousStatus.usage.recentTitle': 'Recent runs',
'heterogeneousStatus.usage.runs': 'Runs · 30d',
'heterogeneousStatus.usage.runsSub': '{{successRate}} success · {{failed}} failed',
'heterogeneousStatus.usage.status.done': 'Completed',
'heterogeneousStatus.usage.status.error': 'Failed',
'heterogeneousStatus.usage.status.interrupted': 'Interrupted',
'heterogeneousStatus.usage.status.running': 'Running',
'heterogeneousStatus.usage.status.waiting_for_async_tool': 'Waiting for async tool',
'heterogeneousStatus.usage.status.waiting_for_human': 'Waiting for human',
'heterogeneousStatus.usage.tabLabel': 'Operation Stats',
'heterogeneousStatus.usage.tokens': 'Tokens · 30d',
'heterogeneousStatus.usage.tokensSub': 'Input {{input}} · Output {{output}}',
// Heterogeneous agent — Cloud tab (web environment config)
'heterogeneousStatus.cloud.tabLabel': 'Cloud',
@@ -1,5 +1,5 @@
import { isDesktop } from '@lobechat/const';
import { ActionIcon, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
import { ActionIcon, DropdownMenu, Flexbox, Icon, Segmented } from '@lobehub/ui';
import { confirmModal } from '@lobehub/ui/base-ui';
import { ShapesUploadIcon } from '@lobehub/ui/icons';
import isEqual from 'fast-deep-equal';
@@ -22,6 +22,7 @@ import { systemStatusSelectors } from '@/store/global/selectors';
import { useHomeStore } from '@/store/home';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
import type { ProfileView } from '../../types';
import { useProfileStore } from '../store';
import AgentForkTag from './AgentForkTag';
import ForkConfirmModal from './AgentPublishButton/ForkConfirmModal';
@@ -36,6 +37,12 @@ type HeaderTranslation = TFunction<
undefined
>;
interface HeaderProps {
onProfileViewChange?: (view: ProfileView) => void;
profileView?: ProfileView;
showOperationStatsSwitcher?: boolean;
}
const buildAgentProfileMarkdown = (params: {
description?: string;
model?: string;
@@ -85,7 +92,11 @@ const buildAgentProfileMarkdown = (params: {
return `${sections.join('\n\n')}\n`;
};
const Header = memo(() => {
const Header = ({
profileView = 'config',
showOperationStatsSwitcher,
onProfileViewChange,
}: HeaderProps) => {
const { t } = useTranslation(['setting', 'marketAuth', 'chat', 'file', 'common']);
const navigate = useNavigate();
@@ -290,8 +301,25 @@ const Header = memo(() => {
<>
<NavHeader
left={
<Flexbox horizontal gap={8}>
<Flexbox horizontal align={'center'} gap={8}>
<AutoSaveHint />
{showOperationStatsSwitcher && (
<Segmented
size="small"
value={profileView}
options={[
{
label: t('heterogeneousStatus.config.tabLabel', { ns: 'setting' }),
value: 'config',
},
{
label: t('heterogeneousStatus.usage.tabLabel', { ns: 'setting' }),
value: 'usage',
},
]}
onChange={(value) => onProfileViewChange?.(value as ProfileView)}
/>
)}
<AgentStatusTag />
<AgentVersionReviewTag />
<AgentForkTag />
@@ -334,6 +362,6 @@ const Header = memo(() => {
/>
</>
);
});
};
export default Header;
export default memo(Header);
@@ -0,0 +1,412 @@
'use client';
import { formatPrice, formatShortenNumber, formatTime } from '@lobechat/utils/format';
import { Center, Empty, Flexbox, Icon, Skeleton, Tag, Text, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import {
Activity,
Bot,
CircleDollarSign,
Clock3,
ListChecks,
type LucideIcon,
Zap,
} from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { lambdaQuery } from '@/libs/trpc/client';
import { useAgentStore } from '@/store/agent';
const STATUS_COLOR: Record<string, string> = {
done: 'success',
error: 'error',
interrupted: 'warning',
running: 'processing',
waiting_for_async_tool: 'processing',
waiting_for_human: 'warning',
};
const styles = createStaticStyles(({ css }) => ({
bar: css`
overflow: hidden;
display: flex;
flex-direction: column-reverse;
width: 100%;
min-width: 12px;
border-radius: 5px 5px 2px 2px;
background: ${cssVar.colorFillQuaternary};
`,
barInput: css`
background: ${cssVar.colorPrimary};
`,
barOutput: css`
background: ${cssVar.colorInfo};
`,
card: css`
padding: 16px;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorBgContainer};
`,
chart: css`
display: grid;
gap: 8px;
align-items: end;
height: 180px;
padding-block: 8px 4px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
chartColumn: css`
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: flex-end;
min-width: 0;
height: 100%;
`,
chartLabel: css`
overflow: hidden;
width: 100%;
font-size: 10px;
line-height: 1;
color: ${cssVar.colorTextQuaternary};
text-align: center;
text-overflow: clip;
white-space: nowrap;
`,
chartLegend: css`
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
`,
chartWrap: css`
overflow-x: auto;
`,
emptyCard: css`
min-height: 260px;
`,
legendDotInput: css`
width: 8px;
height: 8px;
border-radius: 999px;
background: ${cssVar.colorPrimary};
`,
legendDotOutput: css`
width: 8px;
height: 8px;
border-radius: 999px;
background: ${cssVar.colorInfo};
`,
recentHeader: css`
display: grid;
grid-template-columns: minmax(120px, 1.2fr) minmax(100px, 1fr) 90px 90px 90px;
gap: 12px;
padding-block: 0 8px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
font-size: 11px;
color: ${cssVar.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.04em;
`,
recentRow: css`
display: grid;
grid-template-columns: minmax(120px, 1.2fr) minmax(100px, 1fr) 90px 90px 90px;
gap: 12px;
align-items: center;
min-height: 44px;
padding-block: 8px;
& + & {
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
}
`,
statCard: css`
flex: 1;
min-width: 160px;
padding: 14px;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorBgContainer};
`,
statIcon: css`
color: ${cssVar.colorTextTertiary};
`,
statLabel: css`
font-size: 11px;
color: ${cssVar.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.04em;
`,
statSub: css`
overflow: hidden;
min-height: 18px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
text-overflow: ellipsis;
white-space: nowrap;
`,
statValue: css`
font-size: 28px;
font-feature-settings: 'tnum';
font-weight: 700;
line-height: 1.1;
color: ${cssVar.colorText};
`,
title: css`
font-size: 15px;
font-weight: 600;
`,
}));
interface StatCardProps {
icon: LucideIcon;
label: string;
sub?: string;
value: string;
}
const StatCard = memo<StatCardProps>(({ icon, label, sub, value }) => (
<div className={styles.statCard}>
<Flexbox gap={10}>
<Flexbox horizontal align="center" gap={8}>
<Icon className={styles.statIcon} icon={icon} size={15} />
<span className={styles.statLabel}>{label}</span>
</Flexbox>
<div className={styles.statValue}>{value}</div>
{sub && <div className={styles.statSub}>{sub}</div>}
</Flexbox>
</div>
));
StatCard.displayName = 'AgentOperationStatCard';
const formatDateTime = (value: Date | string | null) => {
if (!value) return '';
const date = new Date(value);
return date.toLocaleString(undefined, {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
month: 'short',
});
};
const AgentOperationStats = memo(() => {
const { t } = useTranslation('setting');
const agentId = useAgentStore((s) => s.activeAgentId);
const { data, error, isLoading } = lambdaQuery.agent.getOperationStats.useQuery(
{ agentId: agentId || '', days: 30 },
{ enabled: !!agentId },
);
const maxTokens = useMemo(
() => Math.max(1, ...(data?.daily.map((item) => item.totalTokens) ?? [])),
[data?.daily],
);
if (isLoading) {
return (
<Flexbox className={styles.card} gap={16}>
<Skeleton active paragraph={{ rows: 8 }} title={false} />
</Flexbox>
);
}
if (error) {
return (
<Center className={`${styles.card} ${styles.emptyCard}`}>
<Empty description={t('heterogeneousStatus.usage.error')} icon={Activity} />
</Center>
);
}
if (!data || data.summary.operationCount === 0) {
return (
<Center className={`${styles.card} ${styles.emptyCard}`}>
<Empty description={t('heterogeneousStatus.usage.empty')} icon={Activity} />
</Center>
);
}
const { summary } = data;
const successRate = `${Math.round(summary.successRate * 100)}%`;
return (
<Flexbox gap={16}>
<Flexbox horizontal gap={12} style={{ flexWrap: 'wrap' }}>
<StatCard
icon={CircleDollarSign}
label={t('heterogeneousStatus.usage.cost')}
value={`$${formatPrice(summary.totalCost, 2)}`}
sub={t('heterogeneousStatus.usage.costSub', {
count: formatShortenNumber(summary.operationCount),
})}
/>
<StatCard
icon={Zap}
label={t('heterogeneousStatus.usage.tokens')}
value={String(formatShortenNumber(summary.totalTokens))}
sub={t('heterogeneousStatus.usage.tokensSub', {
input: formatShortenNumber(summary.totalInputTokens),
output: formatShortenNumber(summary.totalOutputTokens),
})}
/>
<StatCard
icon={ListChecks}
label={t('heterogeneousStatus.usage.runs')}
value={String(formatShortenNumber(summary.operationCount))}
sub={t('heterogeneousStatus.usage.runsSub', {
failed: formatShortenNumber(summary.failedOperations),
successRate,
})}
/>
<StatCard
icon={Clock3}
label={t('heterogeneousStatus.usage.duration')}
value={formatTime(summary.averageDurationMs / 1000)}
sub={t('heterogeneousStatus.usage.durationSub', {
steps: summary.averageStepCount.toFixed(1),
})}
/>
</Flexbox>
<Flexbox className={styles.card} gap={14}>
<Flexbox horizontal align="center" justify="space-between">
<span className={styles.title}>{t('heterogeneousStatus.usage.chartTitle')}</span>
<div className={styles.chartLegend}>
<Flexbox horizontal align="center" gap={5}>
<span className={styles.legendDotInput} />
{t('heterogeneousStatus.usage.input')}
</Flexbox>
<Flexbox horizontal align="center" gap={5}>
<span className={styles.legendDotOutput} />
{t('heterogeneousStatus.usage.output')}
</Flexbox>
</div>
</Flexbox>
<div className={styles.chartWrap}>
<div
className={styles.chart}
style={{ gridTemplateColumns: `repeat(${data.daily.length}, minmax(14px, 1fr))` }}
>
{data.daily.map((item) => {
const height =
item.totalTokens > 0
? Math.max(2, Math.round((item.totalTokens / maxTokens) * 150))
: 0;
const inputHeight =
item.totalTokens > 0
? Math.max(1, Math.round((item.totalInputTokens / item.totalTokens) * height))
: 0;
const outputHeight = Math.max(0, height - inputHeight);
const label = item.date.slice(5).replace('-', '/');
return (
<Tooltip
key={item.date}
title={t('heterogeneousStatus.usage.chartTooltip', {
cost: `$${formatPrice(item.totalCost, 4)}`,
date: item.date,
input: formatShortenNumber(item.totalInputTokens),
output: formatShortenNumber(item.totalOutputTokens),
runs: item.operationCount,
tokens: formatShortenNumber(item.totalTokens),
})}
>
<div className={styles.chartColumn}>
<div className={styles.bar} style={{ height }}>
{outputHeight > 0 && (
<div className={styles.barOutput} style={{ height: outputHeight }} />
)}
{inputHeight > 0 && (
<div className={styles.barInput} style={{ height: inputHeight }} />
)}
</div>
<div className={styles.chartLabel}>{label}</div>
</div>
</Tooltip>
);
})}
</div>
</div>
</Flexbox>
<Flexbox className={styles.card} gap={12}>
<Flexbox horizontal align="center" justify="space-between">
<span className={styles.title}>{t('heterogeneousStatus.usage.recentTitle')}</span>
<Text type="secondary">
{t('heterogeneousStatus.usage.callsSub', {
llm: formatShortenNumber(summary.llmCalls),
tools: formatShortenNumber(summary.toolCalls),
})}
</Text>
</Flexbox>
<div className={styles.recentHeader}>
<span>{t('heterogeneousStatus.usage.columns.run')}</span>
<span>{t('heterogeneousStatus.usage.columns.model')}</span>
<span>{t('heterogeneousStatus.usage.columns.tokens')}</span>
<span>{t('heterogeneousStatus.usage.columns.cost')}</span>
<span>{t('heterogeneousStatus.usage.columns.duration')}</span>
</div>
<div>
{data.recentOperations.map((item) => (
<div className={styles.recentRow} key={item.id}>
<Flexbox gap={4} style={{ minWidth: 0 }}>
<Flexbox horizontal align="center" gap={6}>
<Tag
color={STATUS_COLOR[item.status] ?? 'default'}
style={{ marginInlineEnd: 0 }}
>
{t(`heterogeneousStatus.usage.status.${item.status}`, {
defaultValue: item.status,
})}
</Tag>
<Text code ellipsis fontSize={12} title={item.id}>
{item.id}
</Text>
</Flexbox>
<Text fontSize={12} type="secondary">
{formatDateTime(item.createdAt)}
</Text>
</Flexbox>
<Flexbox horizontal align="center" gap={6} style={{ minWidth: 0 }}>
<Icon icon={Bot} size={13} style={{ color: cssVar.colorTextTertiary }} />
<Text ellipsis fontSize={13}>
{[item.provider, item.model].filter(Boolean).join(' / ') || '-'}
</Text>
</Flexbox>
<Text fontSize={13}>{formatShortenNumber(item.totalTokens)}</Text>
<Text fontSize={13}>{`$${formatPrice(item.totalCost, 4)}`}</Text>
<Text fontSize={13}>{formatTime(item.processingTimeMs / 1000)}</Text>
</div>
))}
</div>
</Flexbox>
</Flexbox>
);
});
AgentOperationStats.displayName = 'AgentOperationStats';
export default AgentOperationStats;
@@ -12,15 +12,21 @@ import ModelSelect from '@/features/ModelSelect';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import type { ProfileView } from '../../types';
import AgentSettings from '../AgentSettings';
import EditorCanvas from '../EditorCanvas';
import AgentHeader from './AgentHeader';
import AgentOperationStats from './AgentOperationStats';
import AgentTool from './AgentTool';
import CloudHeterogeneousConfig from './CloudHeterogeneousConfig';
import HeterogeneousAgentStatusCard from './HeterogeneousAgentStatusCard';
import RemoteAgentConfigCard from './RemoteAgentConfigCard';
const ProfileEditor = memo(() => {
interface ProfileEditorProps {
profileView?: ProfileView;
}
const ProfileEditor = memo<ProfileEditorProps>(({ profileView = 'config' }) => {
const { t } = useTranslation('setting');
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
const updateConfig = useAgentStore((s) => s.updateAgentConfig);
@@ -65,42 +71,52 @@ const ProfileEditor = memo(() => {
{/* Header: Avatar + Name + Description */}
<AgentHeader />
{isRemoteHetero && heterogeneousProvider ? (
// Remote platform agents (openclaw / hermes): show device config panel
// Remote platform agents (openclaw / hermes): show device config and operation stats.
<Flexbox paddingBlock={'8px 0'}>
<RemoteAgentConfigCard
provider={heterogeneousProvider}
onBoundDeviceChange={updateBoundDeviceId}
/>
{profileView === 'usage' ? (
<AgentOperationStats />
) : (
<RemoteAgentConfigCard
provider={heterogeneousProvider}
onBoundDeviceChange={updateBoundDeviceId}
/>
)}
</Flexbox>
) : isHeterogeneous && heterogeneousProvider ? (
// Local CLI agents (claude-code, codex): tabs for cloud (web) and desktop environments
<Tabs
defaultActiveKey={isDesktop ? 'desktop' : 'cloud'}
size="small"
items={[
{
key: 'cloud',
label: t('heterogeneousStatus.cloud.tabLabel'),
children: (
<CloudHeterogeneousConfig
provider={heterogeneousProvider}
onEnvChange={updateHeterogeneousEnv}
/>
),
},
{
key: 'desktop',
label: t('heterogeneousStatus.desktop.tabLabel'),
disabled: !isDesktop,
children: (
<HeterogeneousAgentStatusCard
provider={heterogeneousProvider}
onCommandChange={updateHeterogeneousCommand}
/>
),
},
]}
/>
<Flexbox paddingBlock={'8px 0'}>
{profileView === 'usage' ? (
<AgentOperationStats />
) : (
// Local CLI agents (claude-code, codex): tabs for cloud (web) and desktop environments
<Tabs
defaultActiveKey={isDesktop ? 'desktop' : 'cloud'}
size="small"
items={[
{
key: 'cloud',
label: t('heterogeneousStatus.cloud.tabLabel'),
children: (
<CloudHeterogeneousConfig
provider={heterogeneousProvider}
onEnvChange={updateHeterogeneousEnv}
/>
),
},
{
key: 'desktop',
label: t('heterogeneousStatus.desktop.tabLabel'),
disabled: !isDesktop,
children: (
<HeterogeneousAgentStatusCard
provider={heterogeneousProvider}
onCommandChange={updateHeterogeneousCommand}
/>
),
},
]}
/>
)}
</Flexbox>
) : (
<>
{/* Config Bar: Model Selector */}
+21 -3
View File
@@ -1,8 +1,9 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { type FC } from 'react';
import { memo, Suspense } from 'react';
import { memo, Suspense, useEffect, useState } from 'react';
import Loading from '@/components/Loading/BrandTextLoading';
import AgentBuilder from '@/features/AgentBuilder';
@@ -16,6 +17,7 @@ import ProfileEditor from './features/ProfileEditor';
import ProfileHydration from './features/ProfileHydration';
import ProfileProvider from './features/ProfileProvider';
import { useProfileStore } from './features/store';
import type { ProfileView } from './types';
const styles = StyleSheet.create({
contentWrapper: {
@@ -32,6 +34,18 @@ const styles = StyleSheet.create({
const ProfileArea = memo(() => {
const editor = useProfileStore((s) => s.editor);
const isAgentConfigLoading = useAgentStore(agentSelectors.isAgentConfigLoading);
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
const [profileView, setProfileView] = useState<ProfileView>('config');
const showOperationStatsSwitcher =
isHeterogeneous && !!config.agencyConfig?.heterogeneousProvider;
useEffect(() => {
if (!showOperationStatsSwitcher && profileView !== 'config') {
setProfileView('config');
}
}, [profileView, showOperationStatsSwitcher]);
return (
<>
@@ -40,7 +54,11 @@ const ProfileArea = memo(() => {
<Loading debugId="ProfileArea" />
) : (
<>
<Header />
<Header
profileView={profileView}
showOperationStatsSwitcher={showOperationStatsSwitcher}
onProfileViewChange={setProfileView}
/>
<Flexbox
horizontal
height={'100%'}
@@ -55,7 +73,7 @@ const ProfileArea = memo(() => {
}}
>
<WideScreenContainer>
<ProfileEditor />
<ProfileEditor profileView={showOperationStatsSwitcher ? profileView : 'config'} />
</WideScreenContainer>
</Flexbox>
</>
+1
View File
@@ -0,0 +1 @@
export type ProfileView = 'config' | 'usage';
+16
View File
@@ -4,6 +4,7 @@ import { KnowledgeType } from '@lobechat/types';
import { z } from 'zod';
import { AgentModel } from '@/database/models/agent';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { ChatGroupModel } from '@/database/models/chatGroup';
import { FileModel } from '@/database/models/file';
import { KnowledgeBaseModel } from '@/database/models/knowledgeBase';
@@ -19,6 +20,7 @@ const agentProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
return opts.next({
ctx: {
agentModel: new AgentModel(ctx.serverDB, ctx.userId),
agentOperationModel: new AgentOperationModel(ctx.serverDB, ctx.userId),
agentService: new AgentService(ctx.serverDB, ctx.userId),
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId),
fileModel: new FileModel(ctx.serverDB, ctx.userId),
@@ -276,6 +278,20 @@ export const agentRouter = router({
];
}),
getOperationStats: agentProcedure
.input(
z.object({
agentId: z.string(),
days: z.number().min(1).max(90).optional(),
}),
)
.query(async ({ input, ctx }) => {
return ctx.agentOperationModel.getProfileStats({
agentId: input.agentId,
days: input.days,
});
}),
/**
* Query non-virtual agents with optional keyword filter.
* Returns agents with minimal info (id, title, description, avatar, backgroundColor).