feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -143,17 +143,21 @@ export async function savePricing(p: Pricing): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 全局计费规则(token→积分汇率)——
|
// —— 全局计费规则(token→积分汇率 + 硬拦截开关)——
|
||||||
export async function getBillingConfig(): Promise<{ tokens_per_credit: number }> {
|
export async function getBillingConfig(): Promise<{ tokens_per_credit: number; credit_enforce: boolean }> {
|
||||||
const res = guard(await fetch(`${ADMIN}/billing-config`, { headers: authHeaders() }));
|
const res = guard(await fetch(`${ADMIN}/billing-config`, { headers: authHeaders() }));
|
||||||
if (!res.ok) throw new Error(`billing config failed: ${res.status}`);
|
if (!res.ok) throw new Error(`billing config failed: ${res.status}`);
|
||||||
const d = (await res.json()) as { tokens_per_credit?: string };
|
const d = (await res.json()) as { tokens_per_credit?: string; credit_enforce?: boolean };
|
||||||
return { tokens_per_credit: Number(d.tokens_per_credit) || 1000 }; // 空/未设 → 回退默认 1000
|
return { tokens_per_credit: Number(d.tokens_per_credit) || 1000, credit_enforce: !!d.credit_enforce }; // 空/未设 → 回退默认
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveBillingConfig(tokensPerCredit: number): Promise<void> {
|
export async function saveBillingConfig(tokensPerCredit: number, creditEnforce: boolean): Promise<void> {
|
||||||
const res = guard(
|
const res = guard(
|
||||||
await fetch(`${ADMIN}/billing-config`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ tokens_per_credit: tokensPerCredit }) }),
|
await fetch(`${ADMIN}/billing-config`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: authHeaders(true),
|
||||||
|
body: JSON.stringify({ tokens_per_credit: tokensPerCredit, credit_enforce: creditEnforce }),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
@@ -161,6 +165,16 @@ export async function saveBillingConfig(tokensPerCredit: number): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 充值/发放积分(正=充值,负=校正)——
|
||||||
|
export async function grantCredits(tenantId: string, credits: number, memo: string): Promise<number> {
|
||||||
|
const res = guard(
|
||||||
|
await fetch(`${ADMIN}/credits/grant`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ tenant_id: tenantId, credits, memo }) }),
|
||||||
|
);
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { balance_micro?: number; error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `grant failed: ${res.status}`);
|
||||||
|
return d.balance_micro ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
||||||
export async function gatewayOnline(): Promise<boolean> {
|
export async function gatewayOnline(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ interface Row {
|
|||||||
export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
const [rate, setRate] = useState(""); // tokens_per_credit
|
const [rate, setRate] = useState(""); // tokens_per_credit
|
||||||
const [rateDirty, setRateDirty] = useState(false);
|
const [enforce, setEnforce] = useState(false); // 硬拦截开关
|
||||||
const [rateSaving, setRateSaving] = useState(false);
|
const [cfgDirty, setCfgDirty] = useState(false);
|
||||||
const [rateMsg, setRateMsg] = useState("");
|
const [cfgSaving, setCfgSaving] = useState(false);
|
||||||
|
const [cfgMsg, setCfgMsg] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
|||||||
};
|
};
|
||||||
setRows([...chat.map(mk), ...emb.map(mk)]);
|
setRows([...chat.map(mk), ...emb.map(mk)]);
|
||||||
setRate(String(cfg.tokens_per_credit));
|
setRate(String(cfg.tokens_per_credit));
|
||||||
setRateDirty(false);
|
setEnforce(cfg.credit_enforce);
|
||||||
|
setCfgDirty(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -75,23 +77,23 @@ export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveRate = async () => {
|
const saveConfig = async () => {
|
||||||
const n = Number(rate);
|
const n = Number(rate);
|
||||||
if (!(n > 0)) {
|
if (!(n > 0)) {
|
||||||
setRateMsg("汇率必须 > 0");
|
setCfgMsg("汇率必须 > 0");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRateSaving(true);
|
setCfgSaving(true);
|
||||||
setRateMsg("");
|
setCfgMsg("");
|
||||||
try {
|
try {
|
||||||
await saveBillingConfig(n);
|
await saveBillingConfig(n, enforce);
|
||||||
setRateDirty(false);
|
setCfgDirty(false);
|
||||||
setRateMsg("✓ 已保存");
|
setCfgMsg("✓ 已保存");
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setRateMsg((e as Error).message);
|
setCfgMsg((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setRateSaving(false);
|
setCfgSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,8 +110,8 @@ export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
|||||||
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">配置端</span>
|
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">配置端</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 全局 token→积分 汇率 */}
|
{/* 全局:token→积分 汇率 + 硬拦截开关 */}
|
||||||
<div className="mb-5 flex flex-wrap items-end gap-3 rounded-lg border border-violet-100 bg-violet-50/40 p-3">
|
<div className="mb-5 flex flex-wrap items-end gap-5 rounded-lg border border-violet-100 bg-violet-50/40 p-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-gray-500">token → 积分 汇率</label>
|
<label className="block text-[11px] font-medium text-gray-500">token → 积分 汇率</label>
|
||||||
<div className="mt-1 flex items-center gap-2 text-sm text-gray-600">
|
<div className="mt-1 flex items-center gap-2 text-sm text-gray-600">
|
||||||
@@ -120,22 +122,38 @@ export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
|||||||
value={rate}
|
value={rate}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setRate(e.target.value);
|
setRate(e.target.value);
|
||||||
setRateDirty(true);
|
setCfgDirty(true);
|
||||||
setRateMsg("");
|
setCfgMsg("");
|
||||||
}}
|
}}
|
||||||
className="w-28 rounded border px-2 py-1 text-right font-mono text-xs focus:border-violet-500 focus:outline-none"
|
className="w-28 rounded border px-2 py-1 text-right font-mono text-xs focus:border-violet-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-400">token = 1 积分(设 1 即按 token 直计)</span>
|
<span className="text-xs text-gray-400">token = 1 积分(设 1 即按 token 直计)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium text-gray-500">余额硬拦截</label>
|
||||||
|
<label className="mt-1 flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enforce}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEnforce(e.target.checked);
|
||||||
|
setCfgDirty(true);
|
||||||
|
setCfgMsg("");
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 accent-violet-600"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-400">开启后:租户积分余额 ≤0 拒绝新任务(默认关=软扣)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => void saveRate()}
|
onClick={() => void saveConfig()}
|
||||||
disabled={!rateDirty || rateSaving}
|
disabled={!cfgDirty || cfgSaving}
|
||||||
className="rounded bg-violet-600 px-3 py-1 text-xs text-white hover:bg-violet-700 disabled:opacity-40"
|
className="rounded bg-violet-600 px-3 py-1 text-xs text-white hover:bg-violet-700 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{rateSaving ? "保存中…" : "保存汇率"}
|
{cfgSaving ? "保存中…" : "保存规则"}
|
||||||
</button>
|
</button>
|
||||||
{rateMsg && <span className={`text-[11px] ${rateMsg.startsWith("✓") ? "text-emerald-600" : "text-rose-600"}`}>{rateMsg}</span>}
|
{cfgMsg && <span className={`text-[11px] ${cfgMsg.startsWith("✓") ? "text-emerald-600" : "text-rose-600"}`}>{cfgMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 每模型单价 + 积分权重 */}
|
{/* 每模型单价 + 积分权重 */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { adminUsage, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||||
import { BillingRules } from "../components/BillingRules";
|
import { BillingRules } from "../components/BillingRules";
|
||||||
|
|
||||||
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
||||||
@@ -68,6 +68,23 @@ export function UsagePage() {
|
|||||||
|
|
||||||
const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]);
|
const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]);
|
||||||
|
|
||||||
|
const [granting, setGranting] = useState("");
|
||||||
|
const doGrant = async (tenantId: string, name: string) => {
|
||||||
|
const s = window.prompt(`给「${name}」充值积分(正=充值,负=扣减/校正)`, "100");
|
||||||
|
if (s === null) return;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!n) return;
|
||||||
|
setGranting(tenantId);
|
||||||
|
try {
|
||||||
|
await grantCredits(tenantId, n, "admin 手动充值");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setGranting("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className="text-sm text-gray-400">加载用量数据中…</div>;
|
if (loading) return <div className="text-sm text-gray-400">加载用量数据中…</div>;
|
||||||
if (err) return <div className="text-sm text-rose-500">用量加载失败:{err}</div>;
|
if (err) return <div className="text-sm text-rose-500">用量加载失败:{err}</div>;
|
||||||
if (!report) return null;
|
if (!report) return null;
|
||||||
@@ -170,7 +187,8 @@ export function UsagePage() {
|
|||||||
<th className="py-2 pr-3 text-right font-medium">Token</th>
|
<th className="py-2 pr-3 text-right font-medium">Token</th>
|
||||||
<th className="py-2 pr-3 text-right font-medium">成本</th>
|
<th className="py-2 pr-3 text-right font-medium">成本</th>
|
||||||
<th className="py-2 pr-3 text-right font-medium">运行数</th>
|
<th className="py-2 pr-3 text-right font-medium">运行数</th>
|
||||||
<th className="py-2 text-right font-medium">当前余额</th>
|
<th className="py-2 pr-3 text-right font-medium">当前余额</th>
|
||||||
|
<th className="py-2 text-right font-medium">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -185,7 +203,16 @@ export function UsagePage() {
|
|||||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.total_tok)}</td>
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.total_tok)}</td>
|
||||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{money(r.cost_micros)}</td>
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{money(r.cost_micros)}</td>
|
||||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.task_count)}</td>
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.task_count)}</td>
|
||||||
<td className={`py-2 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
|
<td className={`py-2 pr-3 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => void doGrant(r.tenant_id, r.name || r.tenant_id)}
|
||||||
|
disabled={granting === r.tenant_id}
|
||||||
|
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{granting === r.tenant_id ? "…" : "充值"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user