mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
quick-crea
...
refactor/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f20d05bb7 |
@@ -148,9 +148,9 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "dashboard",
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
handle: { title: "Dashboard" },
|
||||
handle: { title: "Usage" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("paths.workspace() shape", () => {
|
||||
expect(new Set(parameterlessRoutes)).toEqual(
|
||||
new Set([
|
||||
"root",
|
||||
"dashboard",
|
||||
"usage",
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
@@ -36,7 +36,7 @@ describe("paths.workspace() shape", () => {
|
||||
// Check that none of the parameterless paths embed a leaked literal
|
||||
// and that their second URL segment matches the method name's kebab-case.
|
||||
const expectedSegments: Array<[string, string]> = [
|
||||
["dashboard", "dashboard"],
|
||||
["usage", "usage"],
|
||||
["issues", "issues"],
|
||||
["projects", "projects"],
|
||||
["autopilots", "autopilots"],
|
||||
|
||||
@@ -4,8 +4,8 @@ import { paths, isGlobalPath } from "./paths";
|
||||
describe("paths.workspace(slug)", () => {
|
||||
const ws = paths.workspace("acme");
|
||||
|
||||
it("builds dashboard paths with slug prefix", () => {
|
||||
expect(ws.dashboard()).toBe("/acme/dashboard");
|
||||
it("builds workspace paths with slug prefix", () => {
|
||||
expect(ws.usage()).toBe("/acme/usage");
|
||||
expect(ws.issues()).toBe("/acme/issues");
|
||||
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
|
||||
expect(ws.projects()).toBe("/acme/projects");
|
||||
|
||||
@@ -18,7 +18,7 @@ function workspaceScoped(slug: string) {
|
||||
const ws = `/${encode(slug)}`;
|
||||
return {
|
||||
root: () => `${ws}/issues`,
|
||||
dashboard: () => `${ws}/dashboard`,
|
||||
usage: () => `${ws}/usage`,
|
||||
issues: () => `${ws}/issues`,
|
||||
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
|
||||
projects: () => `${ws}/projects`,
|
||||
|
||||
@@ -70,7 +70,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"search",
|
||||
"members",
|
||||
|
||||
// Dashboard / workspace route segments
|
||||
// Workspace route segments
|
||||
// Reserving each segment name prevents `/{slug}/{view}` from being visually
|
||||
// ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two
|
||||
// things). `workspaces` covers the global `/workspaces/new` workspace-creation
|
||||
@@ -81,6 +81,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"agents",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"usage",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { LayoutDashboard, BarChart3 } from "lucide-react";
|
||||
import { BarChart3, FolderKanban } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
@@ -23,6 +23,7 @@ import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-sto
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { KpiCard } from "../../runtimes/components/shared";
|
||||
import { DailyCostChart } from "../../runtimes/components/charts";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { formatTokens } from "../../runtimes/utils";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -104,7 +105,7 @@ function Segmented<T extends string | number>({
|
||||
* and the runtime page using one pricing table.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const { t } = useT("dashboard");
|
||||
const { t } = useT("usage");
|
||||
const wsId = useWorkspaceId();
|
||||
const [days, setDays] = useState<TimeRange>(30);
|
||||
const [projectValue, setProjectValue] = useState<string>(ALL_PROJECTS);
|
||||
@@ -177,7 +178,7 @@ export function DashboardPage() {
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader className="justify-between px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="h-4 w-4 text-muted-foreground" />
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-medium">{t(($) => $.title)}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -244,8 +245,9 @@ export function DashboardPage() {
|
||||
{/* Daily cost chart — reuses the runtime DailyCostChart. */}
|
||||
<DailyCostBlock dailyCost={dailyCost} />
|
||||
|
||||
{/* By-agent combined list. */}
|
||||
<AgentList
|
||||
{/* Per-agent leaderboard — user picks the ranking metric;
|
||||
the progress bar and column emphasis follow the metric. */}
|
||||
<Leaderboard
|
||||
rows={agentRows}
|
||||
agents={agents}
|
||||
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
|
||||
@@ -263,16 +265,15 @@ function ProjectFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
projects: { id: string; title: string }[];
|
||||
projects: { id: string; title: string; icon: string | null }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useT("dashboard");
|
||||
const { t } = useT("usage");
|
||||
const allLabel = t(($) => $.filter.all_projects);
|
||||
const selected = projects.find((p) => p.id === value);
|
||||
const selectedTitle =
|
||||
value === ALL_PROJECTS
|
||||
? t(($) => $.filter.all_projects)
|
||||
: projects.find((p) => p.id === value)?.title ??
|
||||
t(($) => $.filter.all_projects);
|
||||
value === ALL_PROJECTS ? allLabel : selected?.title ?? allLabel;
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -280,13 +281,35 @@ function ProjectFilter({
|
||||
onValueChange={(v) => onChange(v ?? ALL_PROJECTS)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-[180px]">
|
||||
<SelectValue>{() => selectedTitle}</SelectValue>
|
||||
<SelectValue>
|
||||
{() => (
|
||||
<>
|
||||
{selected ? (
|
||||
<ProjectIcon project={selected} size="sm" />
|
||||
) : (
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{selectedTitle}</span>
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_PROJECTS}>{t(($) => $.filter.all_projects)}</SelectItem>
|
||||
{/* alignItemWithTrigger=false: the default aligns the *selected* item
|
||||
to the trigger, which pushes "All projects" above the trigger and
|
||||
clips it off-screen when the usage header sits at the top of the
|
||||
viewport. Anchor the dropdown to the bottom of the trigger so
|
||||
every entry stays reachable.
|
||||
max-h-72: cap the dropdown so a long project list scrolls instead
|
||||
of stretching to the bottom of the window. */}
|
||||
<SelectContent align="start" alignItemWithTrigger={false} className="max-h-72">
|
||||
<SelectItem value={ALL_PROJECTS}>
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{allLabel}</span>
|
||||
</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.title}
|
||||
<ProjectIcon project={p} size="sm" />
|
||||
<span className="truncate">{p.title}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -299,7 +322,7 @@ function DailyCostBlock({
|
||||
}: {
|
||||
dailyCost: ReturnType<typeof aggregateDailyCost>;
|
||||
}) {
|
||||
const { t } = useT("dashboard");
|
||||
const { t } = useT("usage");
|
||||
const total = dailyCost.reduce((sum, d) => sum + d.total, 0);
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
@@ -322,7 +345,19 @@ function DailyCostBlock({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentList({
|
||||
// Which metric ranks the leaderboard. Drives row order, progress bar
|
||||
// width, and which column header is emphasised — keeping the three in
|
||||
// lockstep so the user always sees what the ranking actually measures.
|
||||
type LeaderboardSort = "tokens" | "cost" | "time" | "tasks";
|
||||
|
||||
const SORT_METRIC: Record<LeaderboardSort, (r: AgentDashboardRow) => number> = {
|
||||
tokens: (r) => r.tokens,
|
||||
cost: (r) => r.cost,
|
||||
time: (r) => r.seconds,
|
||||
tasks: (r) => r.taskCount,
|
||||
};
|
||||
|
||||
function Leaderboard({
|
||||
rows,
|
||||
agents,
|
||||
lessThanMinuteLabel,
|
||||
@@ -331,35 +366,67 @@ function AgentList({
|
||||
agents: { id: string; name: string }[];
|
||||
lessThanMinuteLabel: string;
|
||||
}) {
|
||||
const { t } = useT("dashboard");
|
||||
const maxCost = rows.reduce((m, r) => Math.max(m, r.cost), 0);
|
||||
const { t } = useT("usage");
|
||||
const [sortBy, setSortBy] = useState<LeaderboardSort>("tokens");
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: "tokens" as const, label: t(($) => $.leaderboard.header_tokens) },
|
||||
{ value: "cost" as const, label: t(($) => $.leaderboard.header_cost) },
|
||||
{ value: "time" as const, label: t(($) => $.leaderboard.header_time) },
|
||||
{ value: "tasks" as const, label: t(($) => $.leaderboard.header_tasks) },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
// Re-rank when the metric changes; keep the merged input untouched so
|
||||
// upstream `mergeAgentDashboardRows`'s tiebreaker (run time desc) still
|
||||
// applies inside an equal-bucket.
|
||||
const sortedRows = useMemo(() => {
|
||||
const metric = SORT_METRIC[sortBy];
|
||||
return [...rows].sort((a, b) => metric(b) - metric(a));
|
||||
}, [rows, sortBy]);
|
||||
|
||||
const maxValue = useMemo(() => {
|
||||
const metric = SORT_METRIC[sortBy];
|
||||
return sortedRows.reduce((m, r) => Math.max(m, metric(r)), 0);
|
||||
}, [sortedRows, sortBy]);
|
||||
|
||||
// Active column gets foreground text; others stay muted. Helps the user
|
||||
// see "this is what the bar is measuring" at a glance.
|
||||
const colClass = (key: LeaderboardSort) =>
|
||||
`text-right ${sortBy === key ? "text-foreground" : "text-muted-foreground"}`;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 pt-4 pb-3">
|
||||
<h4 className="text-sm font-semibold">{t(($) => $.by_agent.title)}</h4>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.by_agent.caption, { count: rows.length })}
|
||||
</span>
|
||||
<h4 className="text-sm font-semibold">{t(($) => $.leaderboard.title)}</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.leaderboard.caption, { count: rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
{sortedRows.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.by_agent.no_data)}
|
||||
{t(($) => $.leaderboard.no_data)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_5rem_5rem_5rem_4rem] items-center gap-3 border-b px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t(($) => $.by_agent.header_agent)}</span>
|
||||
<span>{t(($) => $.leaderboard.header_agent)}</span>
|
||||
<span />
|
||||
<span className="text-right">{t(($) => $.by_agent.header_tokens)}</span>
|
||||
<span className="text-right">{t(($) => $.by_agent.header_cost)}</span>
|
||||
<span className="text-right">{t(($) => $.by_agent.header_time)}</span>
|
||||
<span className="text-right">{t(($) => $.by_agent.header_tasks)}</span>
|
||||
<span className={colClass("tokens")}>{t(($) => $.leaderboard.header_tokens)}</span>
|
||||
<span className={colClass("cost")}>{t(($) => $.leaderboard.header_cost)}</span>
|
||||
<span className={colClass("time")}>{t(($) => $.leaderboard.header_time)}</span>
|
||||
<span className={colClass("tasks")}>{t(($) => $.leaderboard.header_tasks)}</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{rows.map((row) => {
|
||||
{sortedRows.map((row) => {
|
||||
const agent = agents.find((a) => a.id === row.agentId);
|
||||
const pct = maxCost > 0 ? (row.cost / maxCost) * 100 : 0;
|
||||
const value = SORT_METRIC[sortBy](row);
|
||||
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={row.agentId}
|
||||
@@ -378,20 +445,28 @@ function AgentList({
|
||||
</div>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-chart-1"
|
||||
className="h-full rounded-full bg-chart-1 transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "tokens" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{formatTokens(row.tokens)}
|
||||
</div>
|
||||
<div className="text-right text-sm font-medium tabular-nums">
|
||||
<div
|
||||
className={`text-right tabular-nums ${sortBy === "cost" ? "text-sm font-medium" : "text-xs text-muted-foreground"}`}
|
||||
>
|
||||
${row.cost.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{formatDuration(row.seconds, lessThanMinuteLabel)}
|
||||
</div>
|
||||
<div className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.taskCount}
|
||||
</div>
|
||||
</div>
|
||||
@@ -415,7 +490,7 @@ function DashboardSkeleton() {
|
||||
}
|
||||
|
||||
function DashboardEmpty() {
|
||||
const { t } = useT("dashboard");
|
||||
const { t } = useT("usage");
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-lg border border-dashed py-12 text-center">
|
||||
<BarChart3 className="h-6 w-6 text-muted-foreground/40" />
|
||||
|
||||
@@ -19,7 +19,7 @@ import { isGlobalPath } from "@multica/core/paths";
|
||||
* as intentional. Only "/issues/..." style paths get auto-prefixed.
|
||||
*/
|
||||
const WORKSPACE_ROUTE_SEGMENTS = new Set([
|
||||
"dashboard",
|
||||
"usage",
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
|
||||
@@ -20,7 +20,7 @@ import type chat from "../locales/en/chat.json";
|
||||
import type modals from "../locales/en/modals.json";
|
||||
import type runtimes from "../locales/en/runtimes.json";
|
||||
import type layout from "../locales/en/layout.json";
|
||||
import type dashboard from "../locales/en/dashboard.json";
|
||||
import type usage from "../locales/en/usage.json";
|
||||
|
||||
// Module augmentation enables i18next v26 selector API across the monorepo:
|
||||
// `t($ => $.signin.title)` resolves to the value in en/auth.json.
|
||||
@@ -55,7 +55,7 @@ declare module "i18next" {
|
||||
modals: typeof modals;
|
||||
runtimes: typeof runtimes;
|
||||
layout: typeof layout;
|
||||
dashboard: typeof dashboard;
|
||||
usage: typeof usage;
|
||||
};
|
||||
enableSelector: true;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ vi.mock("@multica/core/paths", () => ({
|
||||
projects: () => "/acme/projects",
|
||||
autopilots: () => "/acme/autopilots",
|
||||
agents: () => "/acme/agents",
|
||||
dashboard: () => "/acme/dashboard",
|
||||
usage: () => "/acme/usage",
|
||||
runtimes: () => "/acme/runtimes",
|
||||
skills: () => "/acme/skills",
|
||||
settings: () => "/acme/settings",
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
SquarePen,
|
||||
CircleUser,
|
||||
FolderKanban,
|
||||
LayoutDashboard,
|
||||
BarChart3,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -107,7 +107,7 @@ type NavKey =
|
||||
| "projects"
|
||||
| "autopilots"
|
||||
| "agents"
|
||||
| "dashboard"
|
||||
| "usage"
|
||||
| "runtimes"
|
||||
| "skills"
|
||||
| "settings";
|
||||
@@ -120,7 +120,7 @@ type NavLabelKey =
|
||||
| "projects"
|
||||
| "autopilots"
|
||||
| "agents"
|
||||
| "dashboard"
|
||||
| "usage"
|
||||
| "runtimes"
|
||||
| "skills"
|
||||
| "settings";
|
||||
@@ -135,7 +135,7 @@ const workspaceNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[]
|
||||
{ key: "projects", labelKey: "projects", icon: FolderKanban },
|
||||
{ key: "autopilots", labelKey: "autopilots", icon: Zap },
|
||||
{ key: "agents", labelKey: "agents", icon: Bot },
|
||||
{ key: "dashboard", labelKey: "dashboard", icon: LayoutDashboard },
|
||||
{ key: "usage", labelKey: "usage", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const configureNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"projects": "Projects",
|
||||
"autopilots": "Autopilot",
|
||||
"agents": "Agents",
|
||||
"dashboard": "Dashboard",
|
||||
"usage": "Usage",
|
||||
"runtimes": "Runtimes",
|
||||
"skills": "Skills",
|
||||
"settings": "Settings"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Dashboard",
|
||||
"title": "Usage",
|
||||
"subtitle": "Token spend and agent activity across this workspace.",
|
||||
"filter": {
|
||||
"project_label": "Project",
|
||||
@@ -19,8 +19,8 @@
|
||||
"title": "Daily cost",
|
||||
"no_data": "No usage in this window."
|
||||
},
|
||||
"by_agent": {
|
||||
"title": "Cost & run time by agent",
|
||||
"leaderboard": {
|
||||
"title": "Leaderboard",
|
||||
"caption": "{{count}} agents",
|
||||
"header_agent": "Agent",
|
||||
"header_tokens": "Tokens",
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "No usage yet",
|
||||
"body": "Once agents start running tasks here, their token spend and run time will appear in this dashboard."
|
||||
"body": "Once agents start running tasks here, their token spend and run time will appear in this view."
|
||||
},
|
||||
"duration": {
|
||||
"less_than_minute": "<1m"
|
||||
@@ -20,7 +20,7 @@ import enChat from "./en/chat.json";
|
||||
import enModals from "./en/modals.json";
|
||||
import enRuntimes from "./en/runtimes.json";
|
||||
import enLayout from "./en/layout.json";
|
||||
import enDashboard from "./en/dashboard.json";
|
||||
import enUsage from "./en/usage.json";
|
||||
import zhHansCommon from "./zh-Hans/common.json";
|
||||
import zhHansAuth from "./zh-Hans/auth.json";
|
||||
import zhHansSettings from "./zh-Hans/settings.json";
|
||||
@@ -42,7 +42,7 @@ import zhHansChat from "./zh-Hans/chat.json";
|
||||
import zhHansModals from "./zh-Hans/modals.json";
|
||||
import zhHansRuntimes from "./zh-Hans/runtimes.json";
|
||||
import zhHansLayout from "./zh-Hans/layout.json";
|
||||
import zhHansDashboard from "./zh-Hans/dashboard.json";
|
||||
import zhHansUsage from "./zh-Hans/usage.json";
|
||||
|
||||
// Single source of truth for the resource bundle. Both apps (web layout +
|
||||
// desktop App.tsx) import from here so adding a locale or namespace happens
|
||||
@@ -70,7 +70,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
|
||||
modals: enModals,
|
||||
runtimes: enRuntimes,
|
||||
layout: enLayout,
|
||||
dashboard: enDashboard,
|
||||
usage: enUsage,
|
||||
},
|
||||
"zh-Hans": {
|
||||
common: zhHansCommon,
|
||||
@@ -94,6 +94,6 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
|
||||
modals: zhHansModals,
|
||||
runtimes: zhHansRuntimes,
|
||||
layout: zhHansLayout,
|
||||
dashboard: zhHansDashboard,
|
||||
usage: zhHansUsage,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"projects": "项目",
|
||||
"autopilots": "自动化",
|
||||
"agents": "智能体",
|
||||
"dashboard": "看板",
|
||||
"usage": "用量",
|
||||
"runtimes": "运行时",
|
||||
"skills": "Skill",
|
||||
"settings": "设置"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "看板",
|
||||
"title": "用量",
|
||||
"subtitle": "查看当前 Workspace 的 token 消耗和智能体运行情况。",
|
||||
"filter": {
|
||||
"project_label": "项目",
|
||||
@@ -19,8 +19,8 @@
|
||||
"title": "每日费用",
|
||||
"no_data": "所选时间范围内暂无消耗。"
|
||||
},
|
||||
"by_agent": {
|
||||
"title": "智能体维度的费用与运行时长",
|
||||
"leaderboard": {
|
||||
"title": "排行榜",
|
||||
"caption": "{{count}} 个智能体",
|
||||
"header_agent": "智能体",
|
||||
"header_tokens": "Token",
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无消耗数据",
|
||||
"body": "当智能体在这里开始执行任务后,它们的 token 消耗和运行时长将出现在该看板中。"
|
||||
"body": "当智能体在这里开始执行任务后,它们的 token 消耗和运行时长将出现在此处。"
|
||||
},
|
||||
"duration": {
|
||||
"less_than_minute": "<1分钟"
|
||||
@@ -63,7 +63,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Dashboard / workspace route segments",
|
||||
"label": "Workspace route segments",
|
||||
"description": "Reserving each segment name prevents `/{slug}/{view}` from being visually ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two things). `workspaces` covers the global `/workspaces/new` workspace-creation page; `teams` is reserved for future team management.",
|
||||
"slugs": [
|
||||
"issues",
|
||||
@@ -72,6 +72,7 @@
|
||||
"agents",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"usage",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
|
||||
Reference in New Issue
Block a user