Improve memory manager UI and auth persistence
This commit is contained in:
@@ -586,6 +586,9 @@ function AccountMenu({
|
||||
(sum, item) => sum + item.totalTokens,
|
||||
0,
|
||||
);
|
||||
const selectedSkillMemory = selectedMemorySkill
|
||||
? skillMemories.find((item) => item.skill === selectedMemorySkill)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
@@ -757,43 +760,90 @@ function AccountMenu({
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
<Dialog open={memoryDialogOpen} onOpenChange={setMemoryDialogOpen}>
|
||||
<DialogContent className="max-h-[86vh] overflow-hidden sm:max-w-3xl">
|
||||
<DialogContent className="max-h-[88vh] overflow-hidden sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>个性化记忆</DialogTitle>
|
||||
<DialogDescription>
|
||||
这里展示当前账号的记忆内容和异步整理队列,保存后会影响后续对话注入。
|
||||
用户记忆会全局注入;Skill 记忆按 Skill 文件保存,后续按启用的
|
||||
Skill 注入。
|
||||
</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">
|
||||
<div className="grid min-h-0 gap-4 md:grid-cols-[220px_1fr_240px]">
|
||||
<div className="min-h-0 rounded-md border bg-muted/20">
|
||||
<div className="border-b px-3 py-2 font-medium text-xs">
|
||||
记忆文件
|
||||
</div>
|
||||
<div className="max-h-[520px] overflow-y-auto p-2">
|
||||
<Button
|
||||
size="xs"
|
||||
className="mb-2 h-auto w-full justify-start px-2 py-2 text-left"
|
||||
size="sm"
|
||||
variant={selectedMemorySkill ? "outline" : "secondary"}
|
||||
onClick={() => {
|
||||
setSelectedMemorySkill("");
|
||||
setMemoryDraft(userMemory?.content ?? "");
|
||||
}}
|
||||
>
|
||||
用户记忆
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">用户记忆</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{userMemory?.line_count ?? 0} 行
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{skillMemories.map((item) => (
|
||||
<Button
|
||||
key={item.skill}
|
||||
size="xs"
|
||||
variant={
|
||||
selectedMemorySkill === item.skill
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedMemorySkill(item.skill);
|
||||
setMemoryDraft(item.content);
|
||||
}}
|
||||
>
|
||||
{item.skill}
|
||||
</Button>
|
||||
))}
|
||||
<div className="mb-1 px-1 text-muted-foreground text-xs">
|
||||
Skill 记忆
|
||||
</div>
|
||||
{skillMemories.length ? (
|
||||
<div className="grid gap-1">
|
||||
{skillMemories.map((item) => (
|
||||
<Button
|
||||
key={item.skill}
|
||||
className="h-auto w-full justify-start px-2 py-2 text-left"
|
||||
size="sm"
|
||||
variant={
|
||||
selectedMemorySkill === item.skill
|
||||
? "secondary"
|
||||
: "ghost"
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedMemorySkill(item.skill);
|
||||
setMemoryDraft(item.content);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{item.skill}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{item.line_count ?? 0} 行
|
||||
{item.updated_at
|
||||
? ` · ${formatShortTime(item.updated_at)}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-3 text-muted-foreground text-xs">
|
||||
还没有 Skill 记忆。后续使用 Skill
|
||||
时,如果出现可沉淀经验,会按 Skill 单独生成文件。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid min-h-0 content-start gap-2">
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div className="font-medium text-sm">
|
||||
{selectedMemorySkill
|
||||
? `${selectedMemorySkill} 使用记忆`
|
||||
: "用户记忆"}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
{selectedMemorySkill
|
||||
? "仅在该 Skill 启用并进入模型可见列表时注入。"
|
||||
: "作为当前账号的长期偏好和通用经验注入。"}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-[420px] resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none"
|
||||
@@ -802,10 +852,11 @@ function AccountMenu({
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
当前编辑:
|
||||
{selectedMemorySkill
|
||||
? `${selectedMemorySkill} 使用记忆`
|
||||
: "用户记忆"}
|
||||
{selectedMemorySkill && selectedSkillMemory?.updated_at
|
||||
? `最近更新 ${formatShortTime(selectedSkillMemory.updated_at)}`
|
||||
: userMemory?.updated_at
|
||||
? `最近更新 ${formatShortTime(userMemory.updated_at)}`
|
||||
: "暂无更新时间"}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -925,6 +976,17 @@ function formatCompactNumber(value: number) {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatShortTime(value: string) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (Number.isNaN(timestamp)) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function AccountStat({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -10,6 +10,8 @@ const AUTH_ROOT = path.join(
|
||||
const USERS_PATH = path.join(AUTH_ROOT, "users.json");
|
||||
const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json");
|
||||
const COOKIE_NAME = "claw_account_session";
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||
const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000;
|
||||
|
||||
type UserRecord = {
|
||||
id: string;
|
||||
@@ -23,7 +25,10 @@ type UsersFile = {
|
||||
};
|
||||
|
||||
type SessionsFile = {
|
||||
sessions: Record<string, { accountId: string; createdAt: string }>;
|
||||
sessions: Record<
|
||||
string,
|
||||
{ accountId: string; createdAt: string; updatedAt?: string }
|
||||
>;
|
||||
};
|
||||
|
||||
export type AccountSession = {
|
||||
@@ -87,6 +92,11 @@ export async function getCurrentAccount(): Promise<AccountSession | null> {
|
||||
const usersFile = await readUsers();
|
||||
const account = usersFile.users.find((user) => user.id === session.accountId);
|
||||
if (!account) return null;
|
||||
if (shouldRefreshSession(session.updatedAt ?? session.createdAt)) {
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await writeSessions(sessionsFile);
|
||||
await setSessionCookie(token);
|
||||
}
|
||||
return { id: account.id, username: account.username };
|
||||
}
|
||||
|
||||
@@ -132,16 +142,22 @@ async function createSession(account: UserRecord) {
|
||||
sessionsFile.sessions[token] = {
|
||||
accountId: account.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeSessions(sessionsFile);
|
||||
|
||||
await setSessionCookie(token);
|
||||
return { id: account.id, username: account.username };
|
||||
}
|
||||
|
||||
async function setSessionCookie(token: string) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
});
|
||||
return { id: account.id, username: account.username };
|
||||
}
|
||||
|
||||
async function createAccountDirectories(accountId: string) {
|
||||
@@ -207,3 +223,10 @@ function verifyPassword(password: string, passwordHash: string) {
|
||||
const expected = Buffer.from(expectedHash);
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
function shouldRefreshSession(value?: string) {
|
||||
if (!value) return true;
|
||||
const timestamp = Date.parse(value);
|
||||
if (Number.isNaN(timestamp)) return true;
|
||||
return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user