mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6124d71534 | |||
| a766106d2b | |||
| 7c67d26735 |
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type ProfileView = 'config' | 'usage';
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user