Refine memory settings and admin usage stats

This commit is contained in:
wuyang6
2026-05-13 14:42:22 +08:00
parent 7282a18116
commit ea56761a22
4 changed files with 299 additions and 77 deletions
+20
View File
@@ -1229,6 +1229,17 @@ def create_app(state: AgentState) -> FastAPI:
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/memory/events')
async def get_memory_events(account_id: str) -> dict[str, Any]:
try:
safe_account = _safe_account_id(account_id)
return {
'queue': state.memory_manager.queue_snapshot(safe_account),
'events': state.memory_manager.list_recent_events(safe_account, limit=20),
}
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/memory/skills/{skill_name}')
async def get_skill_memory(skill_name: str, account_id: str) -> dict[str, Any]:
try:
@@ -3561,6 +3572,7 @@ def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
total_tokens = 0
input_tokens = 0
output_tokens = 0
reasoning_tokens = 0
latest_mtime = 0.0
models: dict[str, int] = {}
for path in session_files:
@@ -3573,6 +3585,7 @@ def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
usage = data.get('usage') if isinstance(data.get('usage'), dict) else {}
input_tokens += int(usage.get('input_tokens') or 0)
output_tokens += int(usage.get('output_tokens') or 0)
reasoning_tokens += int(usage.get('reasoning_tokens') or 0)
total_tokens += int(
usage.get('total_tokens')
or (usage.get('input_tokens') or 0) + (usage.get('output_tokens') or 0)
@@ -3590,7 +3603,14 @@ def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
'tool_calls': tool_calls,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'reasoning_tokens': reasoning_tokens,
'total_tokens': total_tokens,
'average_tokens_per_session': (
round(total_tokens / session_count, 1) if session_count else 0
),
'average_tool_calls_per_session': (
round(tool_calls / session_count, 1) if session_count else 0
),
'latest_session_at': latest_mtime,
'models': models,
'user_memory_lines': _count_optional_lines(memory_dir / USER_MEMORY_FILENAME),
+77
View File
@@ -30,7 +30,10 @@ type AccountRow = {
tool_calls: number;
input_tokens: number;
output_tokens: number;
reasoning_tokens: number;
total_tokens: number;
average_tokens_per_session: number;
average_tool_calls_per_session: number;
latest_session_at: number;
models: Record<string, number>;
user_memory_lines: number;
@@ -331,6 +334,70 @@ function AdminDashboard({
/>
</div>
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
<div className="rounded-lg border bg-card">
<div className="border-b p-3 font-medium"></div>
<div className="grid gap-3 p-3 md:grid-cols-4">
<SmallStat
label="输入 token"
value={formatNumber(selectedAccount?.input_tokens ?? 0)}
/>
<SmallStat
label="输出 token"
value={formatNumber(selectedAccount?.output_tokens ?? 0)}
/>
<SmallStat
label="推理 token"
value={formatNumber(selectedAccount?.reasoning_tokens ?? 0)}
/>
<SmallStat
label="平均 token/会话"
value={formatNumber(
selectedAccount?.average_tokens_per_session ?? 0,
)}
/>
<SmallStat
label="平均工具/会话"
value={selectedAccount?.average_tool_calls_per_session ?? 0}
/>
<SmallStat
label="注册状态"
value={selectedAccount?.registered ? "已注册" : "仅目录"}
/>
<SmallStat
label="最近活跃"
value={formatTime(selectedAccount?.latest_session_at)}
/>
<SmallStat
label="模型数"
value={Object.keys(selectedAccount?.models ?? {}).length}
/>
</div>
</div>
<div className="rounded-lg border bg-card">
<div className="border-b p-3 font-medium"></div>
<div className="max-h-40 overflow-y-auto p-3 text-sm">
{Object.entries(selectedAccount?.models ?? {}).length ? (
Object.entries(selectedAccount?.models ?? {})
.sort((a, b) => b[1] - a[1])
.map(([model, count]) => (
<div
key={model}
className="mb-2 grid grid-cols-[1fr_auto] gap-3"
>
<span className="truncate text-muted-foreground">
{model}
</span>
<span>{count} </span>
</div>
))
) : (
<div className="text-muted-foreground"></div>
)}
</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
<div className="grid gap-4 content-start">
<div className="rounded-lg border bg-card">
@@ -463,3 +530,13 @@ function formatNumber(value: number) {
maximumFractionDigits: 1,
}).format(value);
}
function formatTime(value?: number) {
if (!value) return "暂无";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value * 1000));
}
@@ -0,0 +1,20 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET() {
const account = await getCurrentAccount();
if (!account)
return Response.json({ error: "unauthorized" }, { status: 401 });
const url = new URL(`${CLAW_API_URL}/api/memory/events`);
url.searchParams.set("account_id", account.id);
const response = await fetch(url, { cache: "no-store" });
const payload = await response.text();
return new Response(payload, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
}
@@ -101,6 +101,29 @@ type SkillMemoryPayload = {
updated_at?: string | null;
};
type MemoryQueuePayload = {
queue?: {
totals?: {
events?: number;
pending?: number;
processing?: number;
done?: number;
failed?: number;
};
};
events?: MemoryEventPayload[];
};
type MemoryEventPayload = {
id: string;
session_id: string;
status: string;
priority: number;
signals?: string[];
error?: string | null;
updated_at?: string;
};
type ReplaySessionPayload = Parameters<typeof toReplayRepository>[0];
type SidebarSession = ClawSessionSummary & {
@@ -439,9 +462,12 @@ function AccountMenu({
null,
);
const [modelUsageOpen, setModelUsageOpen] = useState(false);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryDialogOpen, setMemoryDialogOpen] = useState(false);
const [userMemory, setUserMemory] = useState<UserMemoryPayload | null>(null);
const [skillMemories, setSkillMemories] = useState<SkillMemoryPayload[]>([]);
const [memoryQueue, setMemoryQueue] = useState<MemoryQueuePayload | null>(
null,
);
const [memoryDraft, setMemoryDraft] = useState("");
const [selectedMemorySkill, setSelectedMemorySkill] = useState("");
const [memorySaving, setMemorySaving] = useState(false);
@@ -484,7 +510,7 @@ function AccountMenu({
}, [open]);
useEffect(() => {
if (!memoryOpen) return;
if (!open && !memoryDialogOpen) return;
Promise.all([
fetch("/api/claw/memory/user", { cache: "no-store" }).then((res) =>
res.ok ? res.json() : null,
@@ -492,18 +518,23 @@ function AccountMenu({
fetch("/api/claw/memory/skills", { cache: "no-store" }).then((res) =>
res.ok ? res.json() : [],
),
]).then(([userPayload, skillPayload]) => {
fetch("/api/claw/memory/events", { cache: "no-store" }).then((res) =>
res.ok ? res.json() : null,
),
]).then(([userPayload, skillPayload, queuePayload]) => {
const nextUser = userPayload as UserMemoryPayload | null;
setUserMemory(nextUser);
setMemoryDraft(nextUser?.content ?? "");
setSelectedMemorySkill("");
setSkillMemories(
Array.isArray(skillPayload)
? (skillPayload as SkillMemoryPayload[])
: [],
);
setMemoryQueue(queuePayload as MemoryQueuePayload | null);
if (memoryDialogOpen && !selectedMemorySkill) {
setMemoryDraft(nextUser?.content ?? "");
}
});
}, [memoryOpen]);
}, [open, memoryDialogOpen, selectedMemorySkill]);
async function logout() {
await fetch("/api/claw/auth/logout", { method: "POST" });
@@ -528,9 +559,11 @@ function AccountMenu({
if (selectedMemorySkill) {
const payload = (await response.json()) as SkillMemoryPayload;
setSkillMemories((current) =>
current.map((item) =>
current.some((item) => item.skill === payload.skill)
? current.map((item) =>
item.skill === payload.skill ? payload : item,
),
)
: [...current, payload],
);
setMemoryDraft(payload.content);
} else {
@@ -685,28 +718,55 @@ function AccountMenu({
) : null}
</div>
<div className="mt-3 rounded-md border bg-muted/30 p-2 text-xs">
<button
type="button"
className="flex w-full items-center justify-between gap-2 text-left"
onClick={() => setMemoryOpen((value) => !value)}
>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 font-medium">
<BrainIcon className="size-3.5" />
</span>
<span className="flex items-center gap-1 text-muted-foreground">
{userMemory?.line_count ?? 0} · Skill{" "}
{skillMemories.length}
<ChevronDownIcon
className={`size-3.5 transition-transform ${
memoryOpen ? "rotate-180" : ""
}`}
<Button
size="xs"
variant="outline"
onClick={() => {
setSelectedMemorySkill("");
setMemoryDraft(userMemory?.content ?? "");
setOpen(false);
setMemoryDialogOpen(true);
}}
>
</Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<AccountStat label="用户行" value={userMemory?.line_count ?? 0} />
<AccountStat label="Skill" value={skillMemories.length} />
<AccountStat
label="待处理"
value={memoryQueue?.queue?.totals?.pending ?? 0}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
<span className="text-muted-foreground text-xs">
</span>
</button>
{memoryOpen ? (
<div className="mt-2 grid gap-2">
<div className="flex gap-1 overflow-x-auto">
<div className="flex items-center gap-1">
<ClawThemeSwitcher />
<ClawLlmSettings />
</div>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
<Dialog open={memoryDialogOpen} onOpenChange={setMemoryDialogOpen}>
<DialogContent className="max-h-[86vh] overflow-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 gap-4 md:grid-cols-[1fr_240px]">
<div className="grid min-h-0 gap-2">
<div className="flex gap-1 overflow-x-auto pb-1">
<Button
size="xs"
variant={selectedMemorySkill ? "outline" : "secondary"}
@@ -736,39 +796,84 @@ function AccountMenu({
))}
</div>
<textarea
className="min-h-32 w-full resize-y rounded-md border bg-background p-2 font-mono text-xs outline-none"
className="min-h-[420px] resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none"
value={memoryDraft}
onChange={(event) => setMemoryDraft(event.target.value)}
/>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground">
Skill
{skillMemories.map((item) => item.skill).join("、") ||
"暂无"}
<span className="truncate text-muted-foreground text-xs">
{selectedMemorySkill
? `${selectedMemorySkill} 使用记忆`
: "用户记忆"}
</span>
<Button
size="xs"
size="sm"
onClick={saveUserMemory}
disabled={memorySaving}
>
<SaveIcon className="size-3" />
<SaveIcon className="size-3.5" />
{memorySaving ? "保存中..." : "保存"}
</Button>
</div>
</div>
<div className="grid content-start gap-3">
<div className="grid grid-cols-2 gap-2">
<AccountStat
label="用户记忆行"
value={userMemory?.line_count ?? 0}
/>
<AccountStat label="Skill 记忆" value={skillMemories.length} />
<AccountStat
label="待处理"
value={memoryQueue?.queue?.totals?.pending ?? 0}
/>
<AccountStat
label="失败"
value={memoryQueue?.queue?.totals?.failed ?? 0}
/>
</div>
<div className="rounded-md border bg-muted/30">
<div className="border-b px-2 py-2 font-medium text-xs">
</div>
<div className="max-h-[320px] overflow-y-auto p-2">
{memoryQueue?.events?.length ? (
memoryQueue.events.map((event) => (
<div
key={event.id}
className="mb-2 rounded-md border bg-background p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{event.status}</span>
<span className="text-muted-foreground">
P{event.priority}
</span>
</div>
<div className="mt-1 truncate text-muted-foreground">
{event.session_id}
</div>
<div className="mt-1 text-muted-foreground">
{event.signals?.join(" / ") || "无信号"}
</div>
{event.error ? (
<div className="mt-1 text-destructive">
{event.error}
</div>
) : null}
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
<span className="text-muted-foreground text-xs">
</span>
<div className="flex items-center gap-1">
<ClawThemeSwitcher />
<ClawLlmSettings />
))
) : (
<div className="py-6 text-center text-muted-foreground text-xs">
</div>
)}
</div>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</div>
</div>
</DialogContent>
</Dialog>
</PopoverPrimitive.Root>
);
}