feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -9,6 +9,7 @@ import { MemoryView } from "./views/MemoryView";
|
|||||||
import { KbView } from "./views/KbView";
|
import { KbView } from "./views/KbView";
|
||||||
import { ReportView } from "./views/ReportView";
|
import { ReportView } from "./views/ReportView";
|
||||||
import { RunsView } from "./views/RunsView";
|
import { RunsView } from "./views/RunsView";
|
||||||
|
import { UsageView } from "./views/UsageView";
|
||||||
import { Home } from "./views/Home";
|
import { Home } from "./views/Home";
|
||||||
import { Placeholder } from "./views/Placeholder";
|
import { Placeholder } from "./views/Placeholder";
|
||||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||||
@@ -238,7 +239,7 @@ export default function App() {
|
|||||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||||
/>
|
/>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<TopBar user={user} tenant={tenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
|
<TopBar user={user} tenant={tenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||||
<ApprovalBar run={run} />
|
<ApprovalBar run={run} />
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<LeftNav active={view} onSelect={setView} />
|
<LeftNav active={view} onSelect={setView} />
|
||||||
@@ -255,6 +256,8 @@ export default function App() {
|
|||||||
<RunsView run={run} />
|
<RunsView run={run} />
|
||||||
) : view === "memory" ? (
|
) : view === "memory" ? (
|
||||||
<MemoryView identity={identity} />
|
<MemoryView identity={identity} />
|
||||||
|
) : view === "usage" ? (
|
||||||
|
<UsageView />
|
||||||
) : (
|
) : (
|
||||||
<Placeholder {...(PLACEHOLDERS[view] ?? { title: "模块", desc: "规划中。" })} />
|
<Placeholder {...(PLACEHOLDERS[view] ?? { title: "模块", desc: "规划中。" })} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -139,6 +139,48 @@ export async function tenantCurrent(): Promise<TenantCtx | null> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||||||
|
export interface UsageDay {
|
||||||
|
day: string; // YYYYMMDD
|
||||||
|
total_tok: number;
|
||||||
|
credits_micro: number;
|
||||||
|
cost_micros: number;
|
||||||
|
task_count: number;
|
||||||
|
}
|
||||||
|
export interface UsageRecent {
|
||||||
|
task_id: string;
|
||||||
|
model: string;
|
||||||
|
total_tok: number;
|
||||||
|
credits_micro: number;
|
||||||
|
cost_micros: number;
|
||||||
|
currency: string;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
export interface MyUsage {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
balance_micro: number;
|
||||||
|
credit_enforce: boolean;
|
||||||
|
trend: UsageDay[];
|
||||||
|
totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number };
|
||||||
|
recent: UsageRecent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function myUsage(days = 30): Promise<MyUsage> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/usage?days=${days}`, { headers: bearer() }));
|
||||||
|
if (!res.ok) throw new Error(`usage failed: ${res.status}`);
|
||||||
|
const d = (await res.json()) as Partial<MyUsage>;
|
||||||
|
return {
|
||||||
|
from: d.from ?? "",
|
||||||
|
to: d.to ?? "",
|
||||||
|
balance_micro: d.balance_micro ?? 0,
|
||||||
|
credit_enforce: !!d.credit_enforce,
|
||||||
|
trend: d.trend ?? [],
|
||||||
|
totals: d.totals ?? { total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 },
|
||||||
|
recent: d.recent ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。
|
// taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。
|
||||||
// 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。
|
// 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。
|
||||||
export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> {
|
export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> {
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import {
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
Boxes,
|
Boxes,
|
||||||
Settings,
|
Settings,
|
||||||
|
Coins,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../ui";
|
import { cn } from "../ui";
|
||||||
|
|
||||||
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "market" | "admin";
|
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "usage" | "market" | "admin";
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
key: ViewKey;
|
key: ViewKey;
|
||||||
@@ -28,6 +29,7 @@ const ITEMS: Item[] = [
|
|||||||
{ key: "report", label: "报告", icon: FileText, group: "BUILD", ready: true },
|
{ key: "report", label: "报告", icon: FileText, group: "BUILD", ready: true },
|
||||||
{ key: "runs", label: "运行", icon: Activity, group: "RUN", ready: true },
|
{ key: "runs", label: "运行", icon: Activity, group: "RUN", ready: true },
|
||||||
{ key: "memory", label: "记忆", icon: Bookmark, group: "MANAGE", ready: true },
|
{ key: "memory", label: "记忆", icon: Bookmark, group: "MANAGE", ready: true },
|
||||||
|
{ key: "usage", label: "用量", icon: Coins, group: "MANAGE", ready: true },
|
||||||
{ key: "market", label: "市场", icon: Boxes, group: "MANAGE" },
|
{ key: "market", label: "市场", icon: Boxes, group: "MANAGE" },
|
||||||
{ key: "admin", label: "管理", icon: Settings, group: "MANAGE" },
|
{ key: "admin", label: "管理", icon: Settings, group: "MANAGE" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,16 +20,17 @@ function Light({ on, label }: { on: boolean; label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 积分余额芯片:让用户在主产品里随时看到自己租户剩多少积分。
|
// 积分余额芯片:让用户在主产品里随时看到自己租户剩多少积分。点击进「用量」页。
|
||||||
// 硬拦截开启且余额≤0 → 红色「余额不足」;偏低 → 橙;否则常态。
|
// 硬拦截开启且余额≤0 → 红色「余额不足」;偏低 → 橙;否则常态。
|
||||||
function CreditChip({ tenant }: { tenant: TenantCtx }) {
|
function CreditChip({ tenant, onClick }: { tenant: TenantCtx; onClick?: () => void }) {
|
||||||
const credits = tenant.credit_balance_micro / 1_000_000;
|
const credits = tenant.credit_balance_micro / 1_000_000;
|
||||||
const empty = tenant.credit_enforce && credits <= 0;
|
const empty = tenant.credit_enforce && credits <= 0;
|
||||||
const low = !empty && credits > 0 && credits < 10;
|
const low = !empty && credits > 0 && credits < 10;
|
||||||
const tone = empty ? "border-danger/50 text-danger" : low ? "border-warn/50 text-warn" : "border-line text-slate-300";
|
const tone = empty ? "border-danger/50 text-danger" : low ? "border-warn/50 text-warn" : "border-line text-slate-300";
|
||||||
return (
|
return (
|
||||||
<span
|
<button
|
||||||
className={cn("flex items-center gap-1.5 rounded-md border bg-ink-800 px-2.5 py-1 text-xs", tone)}
|
onClick={onClick}
|
||||||
|
className={cn("flex items-center gap-1.5 rounded-md border bg-ink-800 px-2.5 py-1 text-xs transition hover:border-ink-600", tone)}
|
||||||
title={
|
title={
|
||||||
`租户 ${tenant.tenant?.name ?? ""} · 积分余额 ${credits.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}` +
|
`租户 ${tenant.tenant?.name ?? ""} · 积分余额 ${credits.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}` +
|
||||||
(tenant.credit_enforce ? "(余额≤0 将无法提交任务,请联系管理员充值)" : "(当前不拦截,仅计量)")
|
(tenant.credit_enforce ? "(余额≤0 将无法提交任务,请联系管理员充值)" : "(当前不拦截,仅计量)")
|
||||||
@@ -37,12 +38,12 @@ function CreditChip({ tenant }: { tenant: TenantCtx }) {
|
|||||||
>
|
>
|
||||||
<Coins className="h-3.5 w-3.5 opacity-70" />
|
<Coins className="h-3.5 w-3.5 opacity-70" />
|
||||||
{empty ? "余额不足" : credits.toLocaleString("zh-CN", { maximumFractionDigits: credits < 100 ? 1 : 0 })}
|
{empty ? "余额不足" : credits.toLocaleString("zh-CN", { maximumFractionDigits: credits < 100 ? 1 : 0 })}
|
||||||
</span>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
// 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||||
export function TopBar({ user, tenant, onLogout, onCommand }: { user: AuthUser; tenant?: TenantCtx | null; onLogout: () => void; onCommand?: () => void }) {
|
export function TopBar({ user, tenant, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
||||||
const h = useHealth();
|
const h = useHealth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -85,7 +86,7 @@ export function TopBar({ user, tenant, onLogout, onCommand }: { user: AuthUser;
|
|||||||
<Light on={h.neo4j} label="Neo4j" />
|
<Light on={h.neo4j} label="Neo4j" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||||
{tenant?.tenant && <CreditChip tenant={tenant} />}
|
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="flex items-center rounded-md border border-line bg-ink-800 px-2 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
|
className="flex items-center rounded-md border border-line bg-ink-800 px-2 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Coins, RefreshCw, Wallet, Cpu, Receipt, Activity } from "lucide-react";
|
||||||
|
import { myUsage, type MyUsage, type UsageDay } from "../lib/api";
|
||||||
|
import { Card, Panel, EmptyState, cn } from "../ui";
|
||||||
|
|
||||||
|
// 桌面端「用量」= 用户自己租户的计量观测:积分余额 + 消耗趋势 + 最近消耗。
|
||||||
|
// 数据来自 /api/v1/me/usage(面向用户口径,只看自己租户)。积分/金额为微单位(÷1e6)。
|
||||||
|
|
||||||
|
const MICRO = 1_000_000;
|
||||||
|
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||||
|
const money = (m: number) => (m / MICRO).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
const int = (n: number) => n.toLocaleString("zh-CN");
|
||||||
|
const mmdd = (s: string) => `${s.slice(4, 6)}-${s.slice(6, 8)}`;
|
||||||
|
|
||||||
|
function ymd(d: Date): string {
|
||||||
|
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
function fillDays(trend: UsageDay[], days: number): UsageDay[] {
|
||||||
|
const by = new Map(trend.map((d) => [d.day, d]));
|
||||||
|
const out: UsageDay[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(now.getDate() - i);
|
||||||
|
const key = ymd(d);
|
||||||
|
out.push(by.get(key) ?? { day: key, total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageView() {
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
const [data, setData] = useState<MyUsage | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
setData(await myUsage(days));
|
||||||
|
setErr("");
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
const series = useMemo(() => (data ? fillDays(data.trend, days) : []), [data, days]);
|
||||||
|
|
||||||
|
if (loading) return <div className="p-6 text-sm text-slate-500">加载用量中…</div>;
|
||||||
|
if (err) return <div className="p-6 text-sm text-danger">用量加载失败:{err}</div>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const bal = data.balance_micro / MICRO;
|
||||||
|
const empty = data.credit_enforce && bal <= 0;
|
||||||
|
const low = !empty && bal > 0 && bal < 10;
|
||||||
|
const t = data.totals;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-4 overflow-y-auto p-5">
|
||||||
|
{/* 顶栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100">用量 & 计费</h2>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{mmdd(data.from)} ~ {mmdd(data.to)} · 我的租户口径
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex overflow-hidden rounded-md border border-line text-xs">
|
||||||
|
{[7, 30].map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setDays(d)}
|
||||||
|
className={cn("px-3 py-1.5", days === d ? "bg-brand text-white" : "bg-ink-850 text-slate-400 hover:text-slate-200")}
|
||||||
|
>
|
||||||
|
近 {d} 天
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-line bg-ink-850 px-2.5 py-1.5 text-xs text-slate-400 hover:text-slate-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-3.5 w-3.5", refreshing && "animate-spin")} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 余额 hero + KPI */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||||
|
<Card className={cn("p-4", empty ? "border-danger/40" : low ? "border-warn/40" : "")}>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<Wallet className="h-3.5 w-3.5" /> 积分余额
|
||||||
|
</div>
|
||||||
|
<div className={cn("mt-1 text-3xl font-semibold tabular-nums", empty ? "text-danger" : low ? "text-warn" : "text-brand")}>
|
||||||
|
{empty ? "余额不足" : credits(data.balance_micro)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-slate-500">
|
||||||
|
{data.credit_enforce ? "余额≤0 将无法提交任务" : "当前不拦截,仅计量"}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Metric icon={Coins} label="积分消耗" value={credits(t.credits_micro)} sub={`区间合计 · ${int(t.task_count)} 次运行`} accent="text-accent" />
|
||||||
|
<Metric icon={Cpu} label="Token 用量" value={int(t.total_tok)} sub="prompt + completion(估算)" accent="text-slate-100" />
|
||||||
|
<Metric icon={Receipt} label="估算成本" value={money(t.cost_micros)} sub="按定价折算(配置币种)" accent="text-slate-100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消耗趋势 */}
|
||||||
|
<Panel title="积分消耗趋势" icon={Activity} className="min-h-[200px]">
|
||||||
|
<TrendBars series={series} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* 最近消耗 */}
|
||||||
|
<Panel title="最近消耗" icon={Coins} className="min-h-[160px]">
|
||||||
|
{data.recent.length === 0 ? (
|
||||||
|
<EmptyState icon={Coins} title="暂无消耗记录" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line text-left text-[11px] uppercase tracking-wide text-slate-500">
|
||||||
|
<th className="py-2 pr-3 font-medium">任务</th>
|
||||||
|
<th className="pr-3 font-medium">模型</th>
|
||||||
|
<th className="pr-3 text-right font-medium">Token</th>
|
||||||
|
<th className="pr-3 text-right font-medium">积分</th>
|
||||||
|
<th className="text-right font-medium">成本</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.recent.map((r) => (
|
||||||
|
<tr key={r.task_id} className="border-b border-line/50 last:border-0">
|
||||||
|
<td className="py-2 pr-3 font-mono text-xs text-slate-300">{r.task_id}</td>
|
||||||
|
<td className="pr-3 text-xs text-slate-400">{r.model || "—"}</td>
|
||||||
|
<td className="pr-3 text-right tabular-nums text-slate-400">{int(r.total_tok)}</td>
|
||||||
|
<td className="pr-3 text-right tabular-nums text-accent">{credits(r.credits_micro)}</td>
|
||||||
|
<td className="text-right tabular-nums text-slate-400">{money(r.cost_micros)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ icon: Icon, label, value, sub, accent }: { icon: typeof Coins; label: string; value: string; sub: string; accent: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<Icon className="h-3.5 w-3.5" /> {label}
|
||||||
|
</div>
|
||||||
|
<div className={cn("mt-1 text-2xl font-semibold tabular-nums", accent)}>{value}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-slate-500">{sub}</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendBars({ series }: { series: UsageDay[] }) {
|
||||||
|
const max = Math.max(1, ...series.map((d) => d.credits_micro));
|
||||||
|
if (series.every((d) => d.credits_micro === 0)) return <EmptyState icon={Activity} title="区间内暂无消耗" />;
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-end gap-1">
|
||||||
|
{series.map((d) => {
|
||||||
|
const h = (d.credits_micro / max) * 100;
|
||||||
|
return (
|
||||||
|
<div key={d.day} className="group relative flex flex-1 flex-col items-center justify-end" title={`${mmdd(d.day)} · ${credits(d.credits_micro)} 积分 · ${int(d.task_count)} 次`}>
|
||||||
|
<div className="w-full rounded-t bg-brand/70 transition-colors group-hover:bg-brand" style={{ height: `${Math.max(d.credits_micro > 0 ? 4 : 0, h)}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user