feat(agent): 添加对持久化子智能体的支持并增强委派管理 添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。 新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。 同时增强了委派管理器的功能: - 添加了对本地委派、插件委派和本地回退的开关控制 - 实现了持久化子智能体任务的自动检测和本地执行保护 - 增加了对不同委派类型的权限验证机制 修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。 BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。 ```
533 lines
23 KiB
TypeScript
533 lines
23 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { useParams } from 'next/navigation';
|
||
import React from 'react';
|
||
import {
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
Boxes,
|
||
FolderOutput,
|
||
ListTree,
|
||
MessageSquare,
|
||
PanelRightOpen,
|
||
Siren,
|
||
Users,
|
||
} from 'lucide-react';
|
||
|
||
import {
|
||
OfficeStatusBadge,
|
||
formatOfficeDuration,
|
||
formatOfficeTime,
|
||
progressPercent,
|
||
} from '@/components/office/OfficeShared';
|
||
import { OfficePhaserCanvas } from '@/components/office/OfficePhaserCanvas';
|
||
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetDescription,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from '@/components/ui/sheet';
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import { buildOfficeView, isOfficeTaskTerminal } from '@/lib/office';
|
||
import { useChatStore } from '@/lib/store';
|
||
|
||
function PixelPanel({
|
||
title,
|
||
subtitle,
|
||
children,
|
||
icon: Icon,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
children: React.ReactNode;
|
||
icon?: React.ComponentType<{ className?: string }>;
|
||
}) {
|
||
return (
|
||
<div className="rounded-none border-4 border-[#0e1119] bg-[#141722] p-4 text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]">
|
||
<div className="flex items-center gap-2 font-mono text-sm font-bold uppercase tracking-[0.18em] text-[#fef3c7]">
|
||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||
{title}
|
||
</div>
|
||
{subtitle ? (
|
||
<div className="mt-2 text-xs text-slate-400">{subtitle}</div>
|
||
) : null}
|
||
<div className="mt-4">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BoardPanel({
|
||
icon: Icon,
|
||
title,
|
||
description,
|
||
children,
|
||
}: {
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
title: string;
|
||
description?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<Card className="rounded-none border-4 border-[#0e1119] bg-[#141722] text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]">
|
||
<CardHeader className="border-b border-[#262a3d] pb-3">
|
||
<CardTitle className="flex items-center gap-2 text-base text-[#fef3c7]">
|
||
<Icon className="h-4 w-4" />
|
||
{title}
|
||
</CardTitle>
|
||
{description ? <CardDescription className="text-slate-400">{description}</CardDescription> : null}
|
||
</CardHeader>
|
||
<CardContent>{children}</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export default function OfficeDetailPage() {
|
||
const params = useParams<{ taskId: string }>();
|
||
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
||
|
||
const sessions = useChatStore((state) => state.sessions);
|
||
const processRuns = useChatStore((state) => state.processRuns);
|
||
const processEvents = useChatStore((state) => state.processEvents);
|
||
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
||
|
||
const office = React.useMemo(
|
||
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }),
|
||
[processArtifacts, processEvents, processRuns, sessions, taskId]
|
||
);
|
||
|
||
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
|
||
const [detailOpen, setDetailOpen] = React.useState(false);
|
||
|
||
React.useEffect(() => {
|
||
setSelectedRunId(office?.rootRunId ?? null);
|
||
setDetailOpen(false);
|
||
}, [office?.rootRunId]);
|
||
|
||
const selectedTask = React.useMemo(
|
||
() => office?.tasks.find((task) => task.runId === selectedRunId) ?? office?.tasks[0] ?? null,
|
||
[office?.tasks, selectedRunId]
|
||
);
|
||
|
||
const selectedEvents = React.useMemo(
|
||
() => processEvents
|
||
.filter((event) => event.run_id === selectedTask?.runId)
|
||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||
.slice(0, 8),
|
||
[processEvents, selectedTask?.runId]
|
||
);
|
||
|
||
const selectedArtifacts = React.useMemo(
|
||
() => processArtifacts
|
||
.filter((artifact) => artifact.run_id === selectedTask?.runId)
|
||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()),
|
||
[processArtifacts, selectedTask?.runId]
|
||
);
|
||
|
||
const openRunDetail = React.useCallback((runId: string) => {
|
||
setSelectedRunId(runId);
|
||
setDetailOpen(true);
|
||
}, []);
|
||
|
||
if (!office) {
|
||
return (
|
||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||
<Button asChild variant="outline" className="w-fit">
|
||
<Link href="/office">
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
返回 Office 列表
|
||
</Link>
|
||
</Button>
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-16 text-center">
|
||
<h1 className="text-2xl font-semibold">任务不存在</h1>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
当前 store 中没有这个 task 的运行数据。先从对话页发起任务,或者回到 Office 列表查看当前可用任务。
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const progressValue = progressPercent(office.progress.value, office.progress.max);
|
||
|
||
return (
|
||
<div className="mx-auto max-w-[1720px] space-y-6 p-6">
|
||
<TaskManagementTabs />
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button asChild variant="outline" size="sm">
|
||
<Link href="/office">
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
返回 Office
|
||
</Link>
|
||
</Button>
|
||
<Button asChild variant="ghost" size="sm">
|
||
<Link href="/">
|
||
<MessageSquare className="mr-2 h-4 w-4" />
|
||
回到对话
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-5">
|
||
<div className="mx-auto max-w-[1280px] rounded-none border-4 border-[#0e1119] bg-[#141522] p-4 shadow-[0_0_0_2px_#241d36_inset]">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<h1 className="truncate font-mono text-3xl font-bold uppercase tracking-[0.18em] text-[#fef3c7]">
|
||
{office.title}
|
||
</h1>
|
||
<OfficeStatusBadge status={office.status} className="bg-black/20" />
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-xs uppercase tracking-[0.14em] text-slate-400">
|
||
<span>Lead: {office.rootActorName}</span>
|
||
<span>Session: {office.sourceSessionLabel}</span>
|
||
<span>Started: {formatOfficeTime(office.createdAt)}</span>
|
||
<span>Duration: {formatOfficeDuration(office.durationMs)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]">
|
||
<MetricTile label="运行实例" value={String(office.stats.totalRuns)} />
|
||
<MetricTile label="参与成员" value={String(office.stats.memberCount)} />
|
||
<MetricTile label="产物数量" value={String(office.stats.artifactCount)} />
|
||
<MetricTile label="告警数量" value={String(office.alerts.length)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mx-auto max-w-[1280px]">
|
||
<OfficePhaserCanvas
|
||
office={office}
|
||
selectedRunId={selectedTask?.runId ?? null}
|
||
onRunSelect={openRunDetail}
|
||
showMetaBar={false}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[390px_minmax(0,1fr)_390px]">
|
||
<PixelPanel
|
||
title="昨日小记"
|
||
subtitle="用任务摘要、告警和最近更新来替代原版 memo 区。"
|
||
>
|
||
<div className="space-y-3 text-sm leading-6 text-slate-300">
|
||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
|
||
{selectedTask?.summary || '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。'}
|
||
</div>
|
||
{office.alerts.slice(0, 2).map((alert) => (
|
||
<button
|
||
key={alert.id}
|
||
type="button"
|
||
disabled={!alert.runId}
|
||
onClick={() => alert.runId && openRunDetail(alert.runId)}
|
||
className="block w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default"
|
||
>
|
||
<div className="font-medium text-rose-200">{alert.title}</div>
|
||
{alert.description ? <div className="mt-1 text-xs text-slate-400">{alert.description}</div> : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</PixelPanel>
|
||
|
||
<PixelPanel
|
||
title="任务控制台"
|
||
subtitle="保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。"
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<MiniMetric label="当前阶段" value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
|
||
<MiniMetric label="活跃实例" value={String(office.stats.activeRuns)} />
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between gap-3 font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">
|
||
<span>{office.progress.label}</span>
|
||
<span>{progressValue}%</span>
|
||
</div>
|
||
<div className="h-4 rounded-none border-2 border-[#263144] bg-[#0f1420] p-[2px]">
|
||
<div
|
||
className="h-full bg-[linear-gradient(90deg,#22d3ee,#fde047,#fb7185)] transition-all"
|
||
style={{ width: `${progressValue}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedTask ? (
|
||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
|
||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">当前聚焦</div>
|
||
<div className="mt-2 text-sm font-semibold text-slate-100">{selectedTask.title}</div>
|
||
<div className="mt-1 text-xs text-slate-400">
|
||
{selectedTask.actorName} · {selectedTask.stageLabel ?? '无阶段标签'}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<Button
|
||
onClick={() => setDetailOpen(true)}
|
||
className="w-full rounded-none border-2 border-[#2f3b16] bg-[#78a340] text-[#f3ffe6] hover:bg-[#8fbe4a]"
|
||
>
|
||
打开详情
|
||
<PanelRightOpen className="ml-2 h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
asChild
|
||
variant="outline"
|
||
className="w-full rounded-none border-2 border-[#30364d] bg-[#171b29] text-slate-100 hover:bg-[#21283a]"
|
||
>
|
||
<Link href="/">
|
||
回到对话
|
||
<ArrowRight className="ml-2 h-4 w-4" />
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
{isOfficeTaskTerminal(office.status) ? (
|
||
<div className="rounded-none border-2 border-[#365443] bg-[#12221d] px-3 py-3 text-sm text-emerald-200">
|
||
任务已结束,办公室已解散,但现场记录仍可回看。
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</PixelPanel>
|
||
|
||
<PixelPanel
|
||
title="办公人员名单"
|
||
subtitle="原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。"
|
||
>
|
||
<div className="space-y-2">
|
||
{office.members.map((member) => (
|
||
<button
|
||
key={member.memberId}
|
||
type="button"
|
||
onClick={() => openRunDetail(member.currentRunId)}
|
||
className="flex w-full items-center justify-between gap-3 rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left transition-colors hover:border-[#64748b]"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium text-slate-100">{member.actorName}</div>
|
||
<div className="truncate text-xs text-slate-400">{member.currentTitle}</div>
|
||
</div>
|
||
<OfficeStatusBadge status={member.status} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</PixelPanel>
|
||
</div>
|
||
|
||
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[1.08fr_0.92fr]">
|
||
<BoardPanel
|
||
icon={ListTree}
|
||
title="任务看板"
|
||
description="当前 task 下所有 run 的结构化列表。"
|
||
>
|
||
<div className="space-y-3">
|
||
{office.tasks.map((task) => (
|
||
<button
|
||
key={task.runId}
|
||
type="button"
|
||
onClick={() => openRunDetail(task.runId)}
|
||
className={`w-full rounded-none border-2 px-4 py-3 text-left transition-colors ${
|
||
selectedTask?.runId === task.runId
|
||
? 'border-[#facc15] bg-[#201922]'
|
||
: 'border-[#2d3348] bg-[#0f1420] hover:border-[#64748b]'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate font-medium text-slate-100">{task.title}</span>
|
||
{task.isRoot ? (
|
||
<span className="rounded-none border border-[#4a3c17] bg-[#3b2f12] px-2 py-0.5 font-mono text-[10px] text-[#fef3c7]">
|
||
ROOT
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
|
||
<span>{task.actorName}</span>
|
||
<span>{formatOfficeTime(task.updatedAt)}</span>
|
||
<span>{task.artifactCount} 个产物</span>
|
||
</div>
|
||
</div>
|
||
<OfficeStatusBadge status={task.status} />
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</BoardPanel>
|
||
|
||
<div className="space-y-5">
|
||
<BoardPanel
|
||
icon={Boxes}
|
||
title="分工关系"
|
||
description="主 Agent 到子 Agent 的委派关系。"
|
||
>
|
||
<div className="space-y-2">
|
||
{office.assignments.length === 0 ? (
|
||
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
|
||
当前没有可见的子任务分工。
|
||
</div>
|
||
) : (
|
||
office.assignments.map((assignment) => (
|
||
<button
|
||
key={assignment.ownerRunId}
|
||
type="button"
|
||
onClick={() => openRunDetail(assignment.ownerRunId)}
|
||
className="w-full rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left text-sm transition-colors hover:border-[#64748b]"
|
||
>
|
||
<div className="font-medium text-slate-100">{assignment.label}</div>
|
||
<div className="mt-1 text-slate-400">{assignment.assigneeActorNames.join(' / ')}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</BoardPanel>
|
||
|
||
<BoardPanel
|
||
icon={Siren}
|
||
title="现场告警"
|
||
description="优先展示失败、阻塞和较高风险的任务信号。"
|
||
>
|
||
<div className="space-y-2">
|
||
{office.alerts.length === 0 ? (
|
||
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
|
||
当前没有高优先级告警。
|
||
</div>
|
||
) : (
|
||
office.alerts.map((alert) => (
|
||
<button
|
||
key={alert.id}
|
||
type="button"
|
||
disabled={!alert.runId}
|
||
onClick={() => alert.runId && openRunDetail(alert.runId)}
|
||
className="w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default"
|
||
>
|
||
<div className="font-medium text-rose-200">{alert.title}</div>
|
||
{alert.description ? <div className="mt-1 text-sm text-slate-400">{alert.description}</div> : null}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</BoardPanel>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||
<SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl">
|
||
<SheetHeader className="pr-8">
|
||
<SheetTitle>{selectedTask?.title ?? '任务详情'}</SheetTitle>
|
||
<SheetDescription>
|
||
{selectedTask
|
||
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? '无阶段标签'}`
|
||
: '当前没有选中的任务实例。'}
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
{!selectedTask ? (
|
||
<div className="mt-6 rounded-xl border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground">
|
||
当前没有可展示的任务详情。
|
||
</div>
|
||
) : (
|
||
<ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3">
|
||
<div className="space-y-4 pb-6">
|
||
<div className="rounded-xl border border-border/60 px-4 py-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium">{selectedTask.title}</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">{selectedTask.actorName}</div>
|
||
</div>
|
||
<OfficeStatusBadge status={selectedTask.status} />
|
||
</div>
|
||
<div className="mt-3 grid gap-2 text-sm">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-muted-foreground">开始时间</span>
|
||
<span>{formatOfficeTime(selectedTask.startedAt)}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-muted-foreground">最近更新</span>
|
||
<span>{formatOfficeTime(selectedTask.updatedAt)}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-muted-foreground">阶段</span>
|
||
<span>{selectedTask.stageLabel ?? '-'}</span>
|
||
</div>
|
||
</div>
|
||
{selectedTask.summary ? (
|
||
<div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
|
||
{selectedTask.summary}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
|
||
<div className="rounded-xl border border-border/60">
|
||
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">产物</div>
|
||
<div className="space-y-2 p-4">
|
||
{selectedArtifacts.length === 0 ? (
|
||
<div className="text-sm text-muted-foreground">当前没有产物。</div>
|
||
) : (
|
||
selectedArtifacts.map((artifact) => (
|
||
<div key={artifact.artifact_id} className="rounded-lg border border-border/60 px-3 py-3">
|
||
<div className="font-medium">{artifact.title}</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at)}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-border/60">
|
||
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">最近事件</div>
|
||
<div className="space-y-2 p-4">
|
||
{selectedEvents.length === 0 ? (
|
||
<div className="text-sm text-muted-foreground">当前没有事件。</div>
|
||
) : (
|
||
selectedEvents.map((event) => (
|
||
<div key={event.event_id} className="rounded-lg border border-border/60 px-3 py-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{event.kind}</div>
|
||
<div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at)}</div>
|
||
</div>
|
||
<div className="mt-2 text-sm text-foreground/90">
|
||
{event.text || '结构化更新'}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetricTile({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-4 py-4 text-slate-100">
|
||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div>
|
||
<div className="mt-2 text-xl font-semibold">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MiniMetric({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-slate-100">
|
||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div>
|
||
<div className="mt-2 text-sm font-semibold">{value}</div>
|
||
</div>
|
||
);
|
||
}
|