Improve memory manager UI and auth persistence
This commit is contained in:
@@ -586,6 +586,9 @@ function AccountMenu({
|
|||||||
(sum, item) => sum + item.totalTokens,
|
(sum, item) => sum + item.totalTokens,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const selectedSkillMemory = selectedMemorySkill
|
||||||
|
? skillMemories.find((item) => item.skill === selectedMemorySkill)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||||
@@ -757,43 +760,90 @@ function AccountMenu({
|
|||||||
</PopoverPrimitive.Content>
|
</PopoverPrimitive.Content>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
<Dialog open={memoryDialogOpen} onOpenChange={setMemoryDialogOpen}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>个性化记忆</DialogTitle>
|
<DialogTitle>个性化记忆</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
这里展示当前账号的记忆内容和异步整理队列,保存后会影响后续对话注入。
|
用户记忆会全局注入;Skill 记忆按 Skill 文件保存,后续按启用的
|
||||||
|
Skill 注入。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid min-h-0 gap-4 md:grid-cols-[1fr_240px]">
|
<div className="grid min-h-0 gap-4 md:grid-cols-[220px_1fr_240px]">
|
||||||
<div className="grid min-h-0 gap-2">
|
<div className="min-h-0 rounded-md border bg-muted/20">
|
||||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
<div className="border-b px-3 py-2 font-medium text-xs">
|
||||||
|
记忆文件
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[520px] overflow-y-auto p-2">
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
className="mb-2 h-auto w-full justify-start px-2 py-2 text-left"
|
||||||
|
size="sm"
|
||||||
variant={selectedMemorySkill ? "outline" : "secondary"}
|
variant={selectedMemorySkill ? "outline" : "secondary"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMemorySkill("");
|
setSelectedMemorySkill("");
|
||||||
setMemoryDraft(userMemory?.content ?? "");
|
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>
|
</Button>
|
||||||
{skillMemories.map((item) => (
|
<div className="mb-1 px-1 text-muted-foreground text-xs">
|
||||||
<Button
|
Skill 记忆
|
||||||
key={item.skill}
|
</div>
|
||||||
size="xs"
|
{skillMemories.length ? (
|
||||||
variant={
|
<div className="grid gap-1">
|
||||||
selectedMemorySkill === item.skill
|
{skillMemories.map((item) => (
|
||||||
? "secondary"
|
<Button
|
||||||
: "outline"
|
key={item.skill}
|
||||||
}
|
className="h-auto w-full justify-start px-2 py-2 text-left"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
setSelectedMemorySkill(item.skill);
|
variant={
|
||||||
setMemoryDraft(item.content);
|
selectedMemorySkill === item.skill
|
||||||
}}
|
? "secondary"
|
||||||
>
|
: "ghost"
|
||||||
{item.skill}
|
}
|
||||||
</Button>
|
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>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-[420px] resize-none rounded-md border bg-background p-3 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"
|
||||||
@@ -802,10 +852,11 @@ function AccountMenu({
|
|||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-muted-foreground text-xs">
|
<span className="truncate text-muted-foreground text-xs">
|
||||||
当前编辑:
|
{selectedMemorySkill && selectedSkillMemory?.updated_at
|
||||||
{selectedMemorySkill
|
? `最近更新 ${formatShortTime(selectedSkillMemory.updated_at)}`
|
||||||
? `${selectedMemorySkill} 使用记忆`
|
: userMemory?.updated_at
|
||||||
: "用户记忆"}
|
? `最近更新 ${formatShortTime(userMemory.updated_at)}`
|
||||||
|
: "暂无更新时间"}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -925,6 +976,17 @@ function formatCompactNumber(value: number) {
|
|||||||
}).format(value);
|
}).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({
|
function AccountStat({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const AUTH_ROOT = path.join(
|
|||||||
const USERS_PATH = path.join(AUTH_ROOT, "users.json");
|
const USERS_PATH = path.join(AUTH_ROOT, "users.json");
|
||||||
const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json");
|
const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json");
|
||||||
const COOKIE_NAME = "claw_account_session";
|
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 = {
|
type UserRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,7 +25,10 @@ type UsersFile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SessionsFile = {
|
type SessionsFile = {
|
||||||
sessions: Record<string, { accountId: string; createdAt: string }>;
|
sessions: Record<
|
||||||
|
string,
|
||||||
|
{ accountId: string; createdAt: string; updatedAt?: string }
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountSession = {
|
export type AccountSession = {
|
||||||
@@ -87,6 +92,11 @@ export async function getCurrentAccount(): Promise<AccountSession | null> {
|
|||||||
const usersFile = await readUsers();
|
const usersFile = await readUsers();
|
||||||
const account = usersFile.users.find((user) => user.id === session.accountId);
|
const account = usersFile.users.find((user) => user.id === session.accountId);
|
||||||
if (!account) return null;
|
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 };
|
return { id: account.id, username: account.username };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,16 +142,22 @@ async function createSession(account: UserRecord) {
|
|||||||
sessionsFile.sessions[token] = {
|
sessionsFile.sessions[token] = {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await writeSessions(sessionsFile);
|
await writeSessions(sessionsFile);
|
||||||
|
|
||||||
|
await setSessionCookie(token);
|
||||||
|
return { id: account.id, username: account.username };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSessionCookie(token: string) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(COOKIE_NAME, token, {
|
cookieStore.set(COOKIE_NAME, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
return { id: account.id, username: account.username };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAccountDirectories(accountId: string) {
|
async function createAccountDirectories(accountId: string) {
|
||||||
@@ -207,3 +223,10 @@ function verifyPassword(password: string, passwordHash: string) {
|
|||||||
const expected = Buffer.from(expectedHash);
|
const expected = Buffer.from(expectedHash);
|
||||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
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