Refine memory settings and admin usage stats
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user