From 47c0863babf9cc61f2345bd74830ef5749ced306 Mon Sep 17 00:00:00 2001 From: "Shin@HOME" Date: Wed, 15 Apr 2026 00:21:43 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/changelog/changelog-full.md | 104 +++ .cursor/changelog/changelog-headlines.md | 7 + .cursor/changelog/changelog-recent.md | 35 + art-agent/backend/.env.example | 7 + art-agent/backend/app/agent/loop.py | 11 +- art-agent/backend/app/api/admin.py | 95 +++ art-agent/backend/app/api/auth.py | 99 +++ art-agent/backend/app/api/chat.py | 9 +- art-agent/backend/app/auth.py | 89 +++ art-agent/backend/app/db.py | 67 ++ art-agent/backend/app/main.py | 16 +- art-agent/backend/requirements.txt | 3 + art-agent/frontend/src/app/layout.tsx | 8 +- art-agent/frontend/src/app/login/page.tsx | 96 +++ art-agent/frontend/src/app/page.tsx | 95 ++- .../src/components/chat/model-selector.tsx | 11 +- .../src/components/layout/top-nav.tsx | 68 +- art-agent/frontend/src/lib/api.ts | 28 +- art-agent/frontend/src/lib/app-context.tsx | 19 +- art-agent/frontend/src/lib/auth-context.tsx | 161 +++++ art-agent/frontend/src/lib/auth-guard.tsx | 36 ++ art-agent/frontend/src/lib/store.ts | 34 +- docs/art-agent/USER-SYSTEM.md | 598 ++++++++++++++++++ 23 files changed, 1640 insertions(+), 56 deletions(-) create mode 100644 art-agent/backend/app/api/admin.py create mode 100644 art-agent/backend/app/api/auth.py create mode 100644 art-agent/backend/app/auth.py create mode 100644 art-agent/backend/app/db.py create mode 100644 art-agent/frontend/src/app/login/page.tsx create mode 100644 art-agent/frontend/src/lib/auth-context.tsx create mode 100644 art-agent/frontend/src/lib/auth-guard.tsx create mode 100644 docs/art-agent/USER-SYSTEM.md diff --git a/.cursor/changelog/changelog-full.md b/.cursor/changelog/changelog-full.md index f0ee8d9..5368f7d 100644 --- a/.cursor/changelog/changelog-full.md +++ b/.cursor/changelog/changelog-full.md @@ -4,6 +4,110 @@ ## 记录 +### [CL-20260414-2340] 2026-04-14 23:40 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题 +- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级 +- **affected_files**: + - art-agent/backend/app/auth.py + - art-agent/backend/requirements.txt + - docs/art-agent/USER-SYSTEM.md +- **what**: 将密码哈希从 passlib+bcrypt 替换为 pwdlib+Argon2id +- **why**: passlib 已停止维护,Python 3.13+ 不可用,且与 bcrypt 5.x 有兼容性问题。pwdlib 是 passlib 的现代替代,原生支持 bcrypt 5.x +- **decisions**: 选择 pwdlib[argon2,bcrypt] 而非纯 argon2-cffi:pwdlib 提供与 passlib 类似的统一接口,同时内置 BcryptHasher 兼容已有旧哈希。新密码一律 Argon2id,旧 bcrypt 哈希仍可验证(过渡期) +- **notes**: 已有 admin 用户的 bcrypt 哈希无需迁移,下次改密时自动升级为 Argon2id 格式。不再需要锁定 bcrypt 版本 +- **source_chat**: [替换 passlib 为 pwdlib](a33b7af6-a8c0-4e88-8372-ec4b5b9dd703) + +### [CL-20260414-2330] 2026-04-14 23:30 — 修复 passlib + bcrypt 5.x 兼容性问题,锁定 bcrypt<4.1 +- **tags**: bug修复, 依赖, bcrypt, passlib, 用户系统 +- **affected_files**: + - art-agent/backend/requirements.txt + - docs/art-agent/USER-SYSTEM.md +- **what**: 修复 passlib 1.7.4 与 bcrypt 5.0.0 不兼容导致后端启动失败 +- **why**: passlib 已停止维护,bcrypt 4.1+ 移除了 `__about__` 属性且改变了密码长度校验行为,导致 passlib 加载后端报 `AttributeError` + `ValueError` +- **decisions**: 降级 bcrypt 到 4.0.1 并锁定 `>=4.0.1,<4.1`,而非替换 passlib(改动最小) +- **notes**: 如果未来需要升级 bcrypt,需替换 passlib 为 bcrypt 直接调用或改用 argon2-cffi 等不依赖 passlib 的方案 +- **source_chat**: [修复 bcrypt 兼容性](a33b7af6-a8c0-4e88-8372-ec4b5b9dd703) + +### [CL-20260414-2300] 2026-04-14 23:00 — 用户系统:管理员邀请制 + JWT 认证 + 数据按用户隔离 +- **tags**: 用户系统, 认证, JWT, SQLite, 数据隔离, 多用户, 安全 +- **affected_files**: + - art-agent/backend/requirements.txt + - art-agent/backend/app/db.py(新建) + - art-agent/backend/app/auth.py(新建) + - art-agent/backend/app/api/auth.py(新建) + - art-agent/backend/app/api/admin.py(新建) + - art-agent/backend/app/api/chat.py + - art-agent/backend/app/main.py + - art-agent/backend/app/agent/loop.py + - art-agent/backend/.env.example + - art-agent/frontend/src/lib/auth-context.tsx(新建) + - art-agent/frontend/src/lib/auth-guard.tsx(新建) + - art-agent/frontend/src/lib/api.ts + - art-agent/frontend/src/lib/store.ts + - art-agent/frontend/src/lib/app-context.tsx + - art-agent/frontend/src/app/layout.tsx + - art-agent/frontend/src/app/login/page.tsx(新建) + - art-agent/frontend/src/components/layout/top-nav.tsx + - art-agent/frontend/src/components/chat/model-selector.tsx +- **what**: 为 EPEEKit 添加完整用户系统:管理员创建账号、JWT 登录、所有 API 鉴权、Mem0 记忆按用户隔离、前端 localStorage 按用户隔离 +- **why**: 之前无认证,所有访问者共享 `default_user` 的记忆,多人通过穿透使用时数据互相混杂 +- **decisions**: + - SQLite(data/epeekit.db)存用户表,零部署成本,与 Qdrant 数据目录在同一 data/ 下 + - JWT access token (30min) + refresh token (7d),passlib bcrypt 密码哈希 + - 不开放注册,管理员通过 /api/admin/users 创建账号 + - 首次启动自动创建 admin 账号(密码从 ADMIN_DEFAULT_PASSWORD 读取或随机生成打印到控制台) + - 前端 localStorage 键名加 user_id 前缀(如 `epeekit-{userId}-sessions`)实现数据隔离 + - Mem0 的 user_id 从 JWT 中解析的真实用户 ID 传入,替代硬编码 `default_user` + - AppProvider 未登录时返回 `<>{children}` 而非 null,确保 /login 页面可渲染 +- **notes**: + - 旧 Mem0 数据仍在 `default_user` 下,不自动迁移,新用户从零积累 + - 前端 401 响应自动跳转 /login,XHR(上传)和 fetch 均处理 + - TopNav 右侧新增用户菜单(显示名称 + 退出登录) + - 需在 .env 中配置 JWT_SECRET,否则启动时 RuntimeError + +### [CL-20260414-2230] 2026-04-14 22:30 — 对话区域新增"回到底部"浮动按钮 +- **tags**: 前端, UX, 滚动, 浮动按钮 +- **affected_files**: + - art-agent/frontend/src/app/page.tsx +- **what**: 当用户在对话中间位置时,显示一个浮动的向下箭头按钮,点击平滑滚到最新消息 +- **why**: 长对话中浏览历史消息后,缺少快速回到最新内容的入口 +- **decisions**: 复用已有的 scroll 事件监听器检测距底部距离(阈值 200px),按钮定位在输入框上方右侧。切换会话恢复位置后也主动检查按钮显示状态。使用已有的 `fadeIn` keyframes 动画 +- **notes**: 按钮使用 `absolute` 定位在 `main` 容器内,`bottom-16` 避开输入框 + +### [CL-20260414-2220] 2026-04-14 22:20 — 修复长对话切换回来滚动位置上移:scroll 事件实时保存取代 effect 延迟保存 +- **tags**: 前端, bug修复, 滚动, 多会话, UX, scroll 事件 +- **affected_files**: + - art-agent/frontend/src/app/page.tsx +- **what**: 修复长对话切换回来后滚动位置上移的 bug +- **why**: 之前在 useEffect 中保存旧会话的 scrollTop,但 effect 执行时 DOM 已经渲染了新会话(短对话)的内容,scrollHeight 变小,浏览器自动将 scrollTop 钳位(clamp)到 `scrollHeight - clientHeight`,导致保存的值远小于真实位置。最长的对话受影响最大,因为它的 scrollTop 差值最大 +- **decisions**: 改用 scroll 事件监听器实时保存当前会话的 scrollTop 到 Map 中。这样在任何 state 变更或 DOM 重渲染之前,最新的滚动位置已经被记录。切换期间(`isSwitching=true`)跳过 scroll handler 写入,避免恢复过程中的中间值覆盖正确值 +- **notes**: 这是 CL-20260414-2210 的后续修复。scroll 事件加了 `{ passive: true }` 避免影响滚动性能。cleanup 函数在 `activeSessionId` 变化时正确移除旧 listener + +### [CL-20260414-2210] 2026-04-14 22:10 — 修复会话切换时滚动位置跳动:解决时序竞争 + 抑制切换期间 smooth scroll +- **tags**: 前端, bug修复, 滚动, 多会话, UX, 时序 +- **affected_files**: + - art-agent/frontend/src/app/page.tsx +- **what**: 修复会话切换时滚动条频繁跳动的问题 +- **why**: 上一版修复(CL-20260414-2200)存在时序竞争:`useEffect` 中 `requestAnimationFrame` 执行时 React 可能还没将新会话的 messages 渲染到 DOM 中,导致 `scrollHeight` 是旧值。另外切换期间 `scrollToBottom(smooth)` 没有被抑制,smooth 动画与位置恢复互相干扰 +- **decisions**: + - 拆为两个 effect:第一个(依赖 `activeSessionId`)只做保存旧位置 + 设 `isSwitching` 标记;第二个(依赖 `activeSessionId` + `messages.length`)在 messages 内容更新后才恢复位置 + - 新增 `isSwitching` ref,切换期间抑制非 instant 的 `scrollToBottom` 调用,避免 smooth 动画干扰 + - instant 模式改为直接赋值 `el.scrollTop = ...`,不再走 setTimeout,消除额外异步 + - `messages` 声明提前到 effect 之前,确保 `messages.length` 可在 effect 依赖中使用 +- **notes**: 图片异步加载仍可能导致 scrollHeight 变化使恢复位置偏移,但这是更深层问题,当前改动先解决核心的切换跳动 + +### [CL-20260414-2200] 2026-04-14 22:00 — 修复多会话滚动位置共享 bug:per-session 独立记录 + 切换恢复 +- **tags**: 前端, bug修复, 滚动, 多会话, UX +- **affected_files**: + - art-agent/frontend/src/app/page.tsx +- **what**: 修复多会话切换时滚动条位置共享的 bug,实现 per-session 滚动位置独立记录与恢复 +- **why**: 所有会话共用同一个 scrollRef DOM 元素,切换会话时既不保存旧会话的 scrollTop,也不恢复目标会话的位置。导致:从长会话切到短会话可能看到空白底部,从短会话切到长会话不会停在最新消息处 +- **decisions**: 用 `useRef>` 在内存中记录每个会话的 scrollTop(而非 localStorage),因为滚动位置是临时状态,刷新页面后重新滚到底部是合理的默认行为 +- **notes**: + - 切换会话时,先保存离开会话的 scrollTop,再用 `requestAnimationFrame` 等 DOM 渲染完新消息后恢复目标位置 + - 无记录的会话(首次进入或新建)使用 `scrollToBottom(true)` 即时滚到底部 + - `scrollToBottom` 改为 `useCallback` 并新增 `instant` 参数,恢复位置时用 instant 避免视觉跳动,正常对话流用 smooth + - 将 `scrollToBottom` 加入 `handleSend` 的依赖数组,修复潜在的陈旧闭包问题 + ### [CL-20260413-2320] 2026-04-13 23:20 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆 - **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口 - **affected_files**: diff --git a/.cursor/changelog/changelog-headlines.md b/.cursor/changelog/changelog-headlines.md index 3846a80..2a017d8 100644 --- a/.cursor/changelog/changelog-headlines.md +++ b/.cursor/changelog/changelog-headlines.md @@ -2,6 +2,13 @@ 最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。 +- [CL-20260414-2340] 用 pwdlib (Argon2id) 替换 passlib (bcrypt):彻底解决兼容性问题,新密码 Argon2id,旧 bcrypt 哈希兼容验证 +- [CL-20260414-2330] 修复 passlib + bcrypt 5.x 兼容性:锁定 bcrypt>=4.0.1,<4.1,解决后端启动 AttributeError + ValueError +- [CL-20260414-2300] 用户系统:管理员邀请制 + JWT 认证 + SQLite 用户表 + Mem0/localStorage 按用户隔离,所有 API 鉴权,前端登录页 + AuthGuard +- [CL-20260414-2230] 对话区域新增"回到底部"浮动按钮:不在底部时显示向下箭头,点击平滑滚到最新消息 +- [CL-20260414-2220] 修复长对话切换回来滚动位置上移:scroll 事件实时保存替代 effect 延迟保存,避免 DOM 变更后 scrollTop 被浏览器 clamp +- [CL-20260414-2210] 修复会话切换时滚动位置跳动:拆 effect 解决时序竞争 + isSwitching 抑制 smooth scroll 干扰 + instant 同步赋值 +- [CL-20260414-2200] 修复多会话滚动位置共享 bug:per-session 独立记录滚动位置,切换会话时保存/恢复,无记录时滚到底部 - [CL-20260413-2320] 集成 Mem0 记忆系统:Mem0 OSS + DeepSeek 事实提取 + Ollama 本地 embedding + 滑动窗口,一次性解决上下文管理和跨会话长期记忆 - [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因,Replicate 非 Flux 官方模型需补全版本 hash - [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名:System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型 diff --git a/.cursor/changelog/changelog-recent.md b/.cursor/changelog/changelog-recent.md index 20ae5a3..a5df874 100644 --- a/.cursor/changelog/changelog-recent.md +++ b/.cursor/changelog/changelog-recent.md @@ -3,6 +3,41 @@ 最近 ~10 次改动的摘要记录,按时间倒序排列。 当 Agent 检测到当前任务与近期改动相关时自动读取。 +### [CL-20260414-2340] 2026-04-14 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题 +- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级 +- **affected_files**: auth.py, requirements.txt, docs/art-agent/USER-SYSTEM.md +- **summary**: passlib 已停止维护且与 bcrypt 5.x 不兼容。替换为 pwdlib[argon2,bcrypt],新密码用 Argon2id,旧 bcrypt 哈希仍可验证。不再需要锁定 bcrypt 版本。 + +### [CL-20260414-2330] 2026-04-14 — 修复 passlib + bcrypt 5.x 兼容性问题,锁定 bcrypt<4.1 +- **tags**: bug修复, 依赖, bcrypt, passlib, 用户系统 +- **affected_files**: requirements.txt, docs/art-agent/USER-SYSTEM.md +- **summary**: passlib 1.7.4 与 bcrypt 5.0.0 不兼容(`__about__` 移除 + 密码长度 ValueError),后端启动报错。降级 bcrypt 到 4.0.1 并在 requirements.txt 中锁定 `>=4.0.1,<4.1`。同时在用户系统文档故障排除中补充此问题。 + +### [CL-20260414-2300] 2026-04-14 — 用户系统:管理员邀请制 + JWT 认证 + 数据按用户隔离 +- **tags**: 用户系统, 认证, JWT, SQLite, 数据隔离, 多用户, 安全 +- **affected_files**: db.py, auth.py, api/auth.py, api/admin.py, chat.py, main.py, loop.py, auth-context.tsx, auth-guard.tsx, api.ts, store.ts, app-context.tsx, layout.tsx, login/page.tsx, top-nav.tsx, model-selector.tsx, requirements.txt, .env.example +- **summary**: 后端新增 SQLite 用户表 + JWT 认证(access+refresh)+ 管理员/普通用户路由,所有 API 加鉴权,Mem0 user_id 改为真实用户 ID。前端新增登录页 + AuthProvider/AuthGuard + 401 自动跳转 + localStorage 按用户 ID 隔离。TopNav 新增用户菜单。首次启动自动创建 admin。 + +### [CL-20260414-2230] 2026-04-14 — 对话区域新增"回到底部"浮动按钮 +- **tags**: 前端, UX, 滚动, 浮动按钮 +- **affected_files**: art-agent/frontend/src/app/page.tsx +- **summary**: 长对话中不在底部时,右下角显示向下箭头按钮,点击平滑滚到最新消息。复用 scroll 监听器检测距底部距离(>200px 显示),切换会话后也同步按钮状态。 + +### [CL-20260414-2220] 2026-04-14 — 修复长对话切换回来滚动位置上移:scroll 事件实时保存取代 effect 延迟保存 +- **tags**: 前端, bug修复, 滚动, 多会话, UX, scroll 事件 +- **affected_files**: art-agent/frontend/src/app/page.tsx +- **summary**: effect 中保存 scrollTop 时 DOM 已变为短对话内容,浏览器 clamp scrollTop 导致保存值偏小。改用 scroll 事件实时记录位置,确保在任何 DOM 变更之前数据已正确保存。 + +### [CL-20260414-2210] 2026-04-14 — 修复会话切换时滚动位置跳动:时序竞争 + 抑制切换期间 smooth scroll +- **tags**: 前端, bug修复, 滚动, 多会话, UX, 时序 +- **affected_files**: art-agent/frontend/src/app/page.tsx +- **summary**: CL-20260414-2200 的 rAF 恢复时序不可靠——React 可能还没渲染新会话内容,scrollHeight 是旧值。拆为两个 effect(保存+标记 / 等 messages 更新后恢复),新增 `isSwitching` 抑制切换期间的 smooth scroll 干扰,instant 模式改为同步赋值 `scrollTop`。 + +### [CL-20260414-2200] 2026-04-14 — 修复多会话滚动位置共享 bug:per-session 独立记录 + 切换恢复 +- **tags**: 前端, bug修复, 滚动, 多会话, UX +- **affected_files**: art-agent/frontend/src/app/page.tsx +- **summary**: 所有会话共用同一 scrollRef,切换时不保存/恢复 scrollTop。修复:新增 `scrollPositions` Map 按 sessionId 记录滚动位置,`useEffect` 监听 `activeSessionId` 变化时保存旧位置、恢复新位置(有记录则恢复,无记录则 scrollToBottom)。`scrollToBottom` 改为 useCallback + 支持 instant 模式。 + ### [CL-20260413-2320] 2026-04-13 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆 - **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口 - **affected_files**: app/memory.py, app/agent/loop.py, app/config.py, app/api/chat.py, requirements.txt, .env, frontend/src/lib/api.ts, frontend/src/app/page.tsx diff --git a/art-agent/backend/.env.example b/art-agent/backend/.env.example index 80099c6..331e0af 100644 --- a/art-agent/backend/.env.example +++ b/art-agent/backend/.env.example @@ -41,5 +41,12 @@ IMAGE_OUTPUT_FORMAT=png # HTTP_PROXY=http://127.0.0.1:7890 # HTTPS_PROXY=http://127.0.0.1:7890 +# ─── 用户认证 ───────────────────────────────────────────── +# JWT 签名密钥(必填,建议随机生成 32+ 位字符串) +JWT_SECRET=your-random-secret-key-here + +# 首次启动时自动创建的管理员密码(留空则随机生成并打印到控制台) +# ADMIN_DEFAULT_PASSWORD=changeme123 + # ─── 服务配置 ───────────────────────────────────────────── PORT=8000 diff --git a/art-agent/backend/app/agent/loop.py b/art-agent/backend/app/agent/loop.py index dc0b0fc..b8b2337 100644 --- a/art-agent/backend/app/agent/loop.py +++ b/art-agent/backend/app/agent/loop.py @@ -70,6 +70,7 @@ async def run_agent_loop( ref_image_url: Optional[str] = None, image_model: Optional[str] = None, session_id: Optional[str] = None, + user_id: str = "default_user", ) -> AsyncGenerator[dict, None]: """ 运行 Agent Loop,以 SSE 事件流形式 yield 结果。 @@ -98,7 +99,7 @@ async def run_agent_loop( break if last_user_content: - search_kwargs = {"query": last_user_content, "user_id": "default_user", "limit": 10} + search_kwargs = {"query": last_user_content, "user_id": user_id, "limit": 10} if session_id: search_kwargs["run_id"] = session_id relevant = memory.search(**search_kwargs) @@ -223,7 +224,7 @@ async def run_agent_loop( }) continue # 对话正常结束,异步存储记忆 - async for evt in _store_and_done(messages, session_id): + async for evt in _store_and_done(messages, session_id, user_id): yield evt return @@ -297,7 +298,7 @@ async def run_agent_loop( # 后续工具调用仍需参考图(InstantStyle 等模型必须有 style_image) # 迭代次数用尽,存储记忆后结束 - async for evt in _store_and_done(messages, session_id): + async for evt in _store_and_done(messages, session_id, user_id): yield evt @@ -305,13 +306,13 @@ async def run_agent_loop( async def _store_and_done( - messages: list[dict], session_id: Optional[str] + messages: list[dict], session_id: Optional[str], user_id: str = "default_user" ) -> AsyncGenerator[dict, None]: """触发 Mem0 异步存储后 yield done。存储失败时 yield warning 但不中断。""" try: memory = get_memory() recent = messages[-4:] if len(messages) >= 4 else messages - add_kwargs: dict = {"user_id": "default_user"} + add_kwargs: dict = {"user_id": user_id} if session_id: add_kwargs["run_id"] = session_id diff --git a/art-agent/backend/app/api/admin.py b/art-agent/backend/app/api/admin.py new file mode 100644 index 0000000..d7fd250 --- /dev/null +++ b/art-agent/backend/app/api/admin.py @@ -0,0 +1,95 @@ +""" +管理员路由:创建用户 / 用户列表 / 禁用或删除用户。 +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlmodel import Session, select + +from app.auth import hash_password, require_admin +from app.db import User, get_session + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +class CreateUserRequest(BaseModel): + username: str + password: str + display_name: str = "" + is_admin: bool = False + + +class UserOut(BaseModel): + id: str + username: str + display_name: str + is_admin: bool + is_active: bool + created_at: str + + +@router.post("/users", response_model=UserOut) +def create_user( + body: CreateUserRequest, + _admin: User = Depends(require_admin), + session: Session = Depends(get_session), +): + existing = session.exec(select(User).where(User.username == body.username)).first() + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在") + if len(body.password) < 6: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码至少 6 位") + + user = User( + username=body.username, + hashed_password=hash_password(body.password), + display_name=body.display_name or body.username, + is_admin=body.is_admin, + ) + session.add(user) + session.commit() + session.refresh(user) + return UserOut( + id=user.id, + username=user.username, + display_name=user.display_name, + is_admin=user.is_admin, + is_active=user.is_active, + created_at=user.created_at.isoformat(), + ) + + +@router.get("/users", response_model=list[UserOut]) +def list_users( + _admin: User = Depends(require_admin), + session: Session = Depends(get_session), +): + users = session.exec(select(User)).all() + return [ + UserOut( + id=u.id, + username=u.username, + display_name=u.display_name, + is_admin=u.is_admin, + is_active=u.is_active, + created_at=u.created_at.isoformat(), + ) + for u in users + ] + + +@router.delete("/users/{user_id}") +def disable_user( + user_id: str, + _admin: User = Depends(require_admin), + session: Session = Depends(get_session), +): + user = session.exec(select(User).where(User.id == user_id)).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + if user.id == _admin.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己") + user.is_active = False + session.add(user) + session.commit() + return {"message": f"用户 {user.username} 已禁用"} diff --git a/art-agent/backend/app/api/auth.py b/art-agent/backend/app/api/auth.py new file mode 100644 index 0000000..4ec72da --- /dev/null +++ b/art-agent/backend/app/api/auth.py @@ -0,0 +1,99 @@ +""" +认证路由:登录 / 刷新令牌 / 修改密码 / 当前用户信息。 +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlmodel import Session, select + +from app.auth import ( + create_access_token, + create_refresh_token, + decode_token, + get_current_user, + hash_password, + verify_password, +) +from app.db import User, get_session + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + user: dict + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +def _user_dict(u: User) -> dict: + return { + "id": u.id, + "username": u.username, + "display_name": u.display_name, + "is_admin": u.is_admin, + } + + +@router.post("/login", response_model=TokenResponse) +def login(body: LoginRequest, session: Session = Depends(get_session)): + user = session.exec(select(User).where(User.username == body.username)).first() + if not user or not verify_password(body.password, user.hashed_password): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用") + return TokenResponse( + access_token=create_access_token(user.id), + refresh_token=create_refresh_token(user.id), + user=_user_dict(user), + ) + + +@router.post("/refresh") +def refresh(body: RefreshRequest, session: Session = Depends(get_session)): + user_id = decode_token(body.refresh_token, expected_type="refresh") + user = session.exec(select(User).where(User.id == user_id)).first() + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用") + return { + "access_token": create_access_token(user.id), + "token_type": "bearer", + } + + +@router.post("/change-password") +def change_password( + body: ChangePasswordRequest, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session), +): + if not verify_password(body.old_password, current_user.hashed_password): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误") + if len(body.new_password) < 6: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="新密码至少 6 位") + # 重新获取以确保在同一 session 中 + user = session.exec(select(User).where(User.id == current_user.id)).first() + if user: + user.hashed_password = hash_password(body.new_password) + session.add(user) + session.commit() + return {"message": "密码已修改"} + + +@router.get("/me") +def me(current_user: User = Depends(get_current_user)): + return _user_dict(current_user) diff --git a/art-agent/backend/app/api/chat.py b/art-agent/backend/app/api/chat.py index bba048b..202e8a5 100644 --- a/art-agent/backend/app/api/chat.py +++ b/art-agent/backend/app/api/chat.py @@ -3,11 +3,13 @@ import uuid from pathlib import Path from typing import Optional -from fastapi import APIRouter, File, Form, UploadFile +from fastapi import APIRouter, Depends, File, Form, UploadFile from sse_starlette.sse import EventSourceResponse from app.agent.loop import run_agent_loop +from app.auth import get_current_user from app.config import get_image_models_list, get_default_image_model_id +from app.db import User router = APIRouter() @@ -27,6 +29,7 @@ async def _save_upload(file: UploadFile) -> str: @router.post("/upload-ref-image") async def upload_ref_image( file: UploadFile = File(...), + current_user: User = Depends(get_current_user), ): """ 独立的参考图上传端点。 @@ -38,7 +41,7 @@ async def upload_ref_image( @router.get("/models") -async def list_models(): +async def list_models(current_user: User = Depends(get_current_user)): """返回可用的图像生成模型列表。""" return { "models": get_image_models_list(), @@ -53,6 +56,7 @@ async def chat( ref_image_url: Optional[str] = Form(None), image_model: Optional[str] = Form(None), session_id: Optional[str] = Form(None), + current_user: User = Depends(get_current_user), ): """ 主对话端点。 @@ -78,6 +82,7 @@ async def chat( resolved_ref_url, image_model=image_model, session_id=session_id, + user_id=current_user.id, ): yield { "event": event["type"], diff --git a/art-agent/backend/app/auth.py b/art-agent/backend/app/auth.py new file mode 100644 index 0000000..2538d5c --- /dev/null +++ b/art-agent/backend/app/auth.py @@ -0,0 +1,89 @@ +""" +认证工具:密码哈希 + JWT 令牌 + FastAPI 依赖注入。 +""" + +import os +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pwdlib import PasswordHash +from pwdlib.hashers.argon2 import Argon2Hasher +from pwdlib.hashers.bcrypt import BcryptHasher +from sqlmodel import Session, select + +from app.db import User, get_session + +# Argon2 优先用于新密码,BcryptHasher 兼容旧版已有哈希 +pwd_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +REFRESH_TOKEN_EXPIRE_DAYS = 7 + + +def _get_secret() -> str: + secret = os.getenv("JWT_SECRET", "") + if not secret: + raise RuntimeError("JWT_SECRET 环境变量未设置") + return secret + + +def hash_password(plain: str) -> str: + return pwd_hash.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_hash.verify(plain, hashed) + + +def create_access_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + return jwt.encode( + {"sub": user_id, "exp": expire, "type": "access"}, + _get_secret(), + algorithm=ALGORITHM, + ) + + +def create_refresh_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + return jwt.encode( + {"sub": user_id, "exp": expire, "type": "refresh"}, + _get_secret(), + algorithm=ALGORITHM, + ) + + +def decode_token(token: str, expected_type: str = "access") -> str: + """解码 JWT,返回 user_id。无效时抛 HTTPException 401。""" + try: + payload = jwt.decode(token, _get_secret(), algorithms=[ALGORITHM]) + user_id: str = payload.get("sub", "") + token_type: str = payload.get("type", "") + if not user_id or token_type != expected_type: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌") + return user_id + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌已过期或无效") + + +def get_current_user( + token: str = Depends(oauth2_scheme), + session: Session = Depends(get_session), +) -> User: + """FastAPI 依赖:从 Bearer token 解析当前用户。""" + user_id = decode_token(token, "access") + user = session.exec(select(User).where(User.id == user_id)).first() + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用") + return user + + +def require_admin(user: User = Depends(get_current_user)) -> User: + """FastAPI 依赖:要求当前用户是管理员。""" + if not user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") + return user diff --git a/art-agent/backend/app/db.py b/art-agent/backend/app/db.py new file mode 100644 index 0000000..7ea52ee --- /dev/null +++ b/art-agent/backend/app/db.py @@ -0,0 +1,67 @@ +""" +数据库初始化 + User 模型。 +使用 SQLite(单文件),存放在 data/epeekit.db。 +""" + +import secrets +import uuid +from datetime import datetime +from pathlib import Path + +from sqlmodel import Field, Session, SQLModel, create_engine, select + +DATA_DIR = Path(__file__).parent.parent / "data" +DATA_DIR.mkdir(exist_ok=True) + +DATABASE_URL = f"sqlite:///{DATA_DIR / 'epeekit.db'}" + +engine = create_engine(DATABASE_URL, echo=False) + + +class User(SQLModel, table=True): + id: str = Field(default_factory=lambda: uuid.uuid4().hex, primary_key=True) + username: str = Field(index=True, unique=True) + hashed_password: str + display_name: str = "" + is_admin: bool = False + is_active: bool = True + created_at: datetime = Field(default_factory=datetime.utcnow) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +def get_session(): + with Session(engine) as session: + yield session + + +def ensure_default_admin(): + """如果 users 表为空,创建默认管理员账号。""" + import os + from app.auth import hash_password + + with Session(engine) as session: + user = session.exec(select(User).limit(1)).first() + if user is not None: + return + + password = os.getenv("ADMIN_DEFAULT_PASSWORD", "") + if not password: + password = secrets.token_urlsafe(12) + print(f"\n{'='*50}") + print(f" 默认管理员账号已创建") + print(f" 用户名: admin") + print(f" 密码: {password}") + print(f" 请登录后尽快修改密码!") + print(f"{'='*50}\n") + + admin = User( + username="admin", + hashed_password=hash_password(password), + display_name="管理员", + is_admin=True, + ) + session.add(admin) + session.commit() diff --git a/art-agent/backend/app/main.py b/art-agent/backend/app/main.py index 5b9deb2..d67082b 100644 --- a/art-agent/backend/app/main.py +++ b/art-agent/backend/app/main.py @@ -1,4 +1,5 @@ import os +from contextlib import asynccontextmanager from pathlib import Path from dotenv import load_dotenv @@ -9,8 +10,19 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from app.api.chat import router as chat_router +from app.api.auth import router as auth_router +from app.api.admin import router as admin_router +from app.db import create_db_and_tables, ensure_default_admin -app = FastAPI(title="EPEEKit API") + +@asynccontextmanager +async def lifespan(app: FastAPI): + create_db_and_tables() + ensure_default_admin() + yield + + +app = FastAPI(title="EPEEKit API", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -30,6 +42,8 @@ GENERATED_DIR.mkdir(exist_ok=True) app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") app.mount("/generated", StaticFiles(directory=str(GENERATED_DIR)), name="generated") +app.include_router(auth_router, prefix="/api") +app.include_router(admin_router, prefix="/api") app.include_router(chat_router, prefix="/api") diff --git a/art-agent/backend/requirements.txt b/art-agent/backend/requirements.txt index 8846f57..34bb58b 100644 --- a/art-agent/backend/requirements.txt +++ b/art-agent/backend/requirements.txt @@ -9,3 +9,6 @@ python-dotenv>=1.0.0 Pillow>=10.4.0 mem0ai ollama +sqlmodel>=0.0.22 +pwdlib[argon2,bcrypt]>=0.3.0 +python-jose[cryptography]>=3.3.0 diff --git a/art-agent/frontend/src/app/layout.tsx b/art-agent/frontend/src/app/layout.tsx index 2ca7b22..c6ec6a8 100644 --- a/art-agent/frontend/src/app/layout.tsx +++ b/art-agent/frontend/src/app/layout.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; +import { AuthProvider } from "@/lib/auth-context"; +import { AuthGuard } from "@/lib/auth-guard"; import { AppProvider } from "@/lib/app-context"; import "./globals.css"; @@ -22,7 +24,11 @@ export default function RootLayout({ return ( - {children} + + + {children} + + ); diff --git a/art-agent/frontend/src/app/login/page.tsx b/art-agent/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..81d6e4e --- /dev/null +++ b/art-agent/frontend/src/app/login/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth-context"; + +export default function LoginPage() { + const { login, isAuthenticated } = useAuth(); + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + if (isAuthenticated) { + router.replace("/"); + return null; + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!username.trim() || !password) return; + setError(""); + setLoading(true); + try { + await login(username.trim(), password); + router.replace("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "登录失败"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
🎨
+

EPEEKit

+

AI 美术资源生成工具

+
+ +
+
+ setUsername(e.target.value)} + autoFocus + autoComplete="username" + className="w-full px-4 py-3 rounded-lg + bg-[var(--bg-secondary)] border border-[var(--border)] + text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] + focus:outline-none focus:border-[var(--accent)] + transition-colors" + /> +
+
+ setPassword(e.target.value)} + autoComplete="current-password" + className="w-full px-4 py-3 rounded-lg + bg-[var(--bg-secondary)] border border-[var(--border)] + text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] + focus:outline-none focus:border-[var(--accent)] + transition-colors" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ); +} diff --git a/art-agent/frontend/src/app/page.tsx b/art-agent/frontend/src/app/page.tsx index 7e98568..a448367 100644 --- a/art-agent/frontend/src/app/page.tsx +++ b/art-agent/frontend/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ChatMessages } from "@/components/chat/chat-messages"; import { ChatInput } from "@/components/chat/chat-input"; import { Sidebar } from "@/components/sidebar/sidebar"; @@ -23,21 +23,76 @@ export default function Home() { setSidebarCollapsed, } = useApp(); + const messages = activeSession?.messages ?? []; + const [isLoading, setIsLoading] = useState(false); const [streamingText, setStreamingText] = useState(""); const [streamingImages, setStreamingImages] = useState([]); const [statusText, setStatusText] = useState(""); const [pendingAnnotation, setPendingAnnotation] = useState(null); const scrollRef = useRef(null); + const scrollPositions = useRef>(new Map()); + const prevSessionId = useRef(null); + const isSwitching = useRef(false); + const [showScrollBtn, setShowScrollBtn] = useState(false); - const scrollToBottom = () => { - setTimeout(() => { - scrollRef.current?.scrollTo({ - top: scrollRef.current.scrollHeight, - behavior: "smooth", - }); - }, 50); - }; + const scrollToBottom = useCallback((instant?: boolean) => { + if (isSwitching.current && !instant) return; + const el = scrollRef.current; + if (!el) return; + if (instant) { + el.scrollTop = el.scrollHeight; + } else { + setTimeout(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "smooth", + }); + }, 50); + } + }, []); + + // 实时记录当前会话的滚动位置 + 判断是否显示"回到底部"按钮 + useEffect(() => { + const el = scrollRef.current; + if (!el || !activeSessionId) return; + const handler = () => { + if (!isSwitching.current) { + scrollPositions.current.set(activeSessionId, el.scrollTop); + } + const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + setShowScrollBtn(distFromBottom > 200); + }; + el.addEventListener("scroll", handler, { passive: true }); + return () => el.removeEventListener("scroll", handler); + }, [activeSessionId]); + + // 会话切换:标记 switching,等 DOM 更新后恢复位置 + useEffect(() => { + if (!activeSessionId) return; + if (prevSessionId.current && prevSessionId.current !== activeSessionId) { + isSwitching.current = true; + } + prevSessionId.current = activeSessionId; + }, [activeSessionId]); + + useEffect(() => { + if (!activeSessionId || !isSwitching.current) return; + requestAnimationFrame(() => { + const el = scrollRef.current; + if (!el) return; + const saved = scrollPositions.current.get(activeSessionId); + if (saved !== undefined) { + el.scrollTop = saved; + } else { + el.scrollTop = el.scrollHeight; + } + const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + setShowScrollBtn(distFromBottom > 200); + isSwitching.current = false; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeSessionId, messages.length]); const handleSend = useCallback( async (text: string, refImageServerUrl: string | null, imageModel: string | null = null) => { @@ -198,15 +253,13 @@ export default function Home() { setStatusText(""); scrollToBottom(); }, - [activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation] + [activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation, scrollToBottom] ); const handleAnnotationComplete = useCallback((data: AnnotationData) => { setPendingAnnotation(data); }, []); - const messages = activeSession?.messages ?? []; - return (
@@ -298,6 +351,24 @@ export default function Home() { )}
+ {showScrollBtn && ( + + )} + diff --git a/art-agent/frontend/src/components/chat/model-selector.tsx b/art-agent/frontend/src/components/chat/model-selector.tsx index 53c1656..0607c10 100644 --- a/art-agent/frontend/src/components/chat/model-selector.tsx +++ b/art-agent/frontend/src/components/chat/model-selector.tsx @@ -4,7 +4,12 @@ import { useEffect, useRef, useState } from "react"; import type { ImageModelInfo } from "@/lib/types"; import { fetchModels } from "@/lib/api"; -const STORAGE_KEY = "epeekit-selected-image-model"; +import { getStoreUserId } from "@/lib/store"; + +function getModelStorageKey() { + const uid = getStoreUserId() || "_anonymous"; + return `epeekit-${uid}-selected-image-model`; +} interface ModelSelectorProps { value: string; @@ -20,7 +25,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) { fetchModels() .then(({ models: list, default: defaultId }) => { setModels(list); - const saved = localStorage.getItem(STORAGE_KEY); + const saved = localStorage.getItem(getModelStorageKey()); const validIds = new Set(list.map((m) => m.id)); if (saved && validIds.has(saved)) { onChange(saved); @@ -90,7 +95,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) { key={m.id} onClick={() => { onChange(m.id); - localStorage.setItem(STORAGE_KEY, m.id); + localStorage.setItem(getModelStorageKey(), m.id); setOpen(false); }} className={`w-full text-left px-3 py-2.5 flex flex-col gap-0.5 diff --git a/art-agent/frontend/src/components/layout/top-nav.tsx b/art-agent/frontend/src/components/layout/top-nav.tsx index a26aafe..af64631 100644 --- a/art-agent/frontend/src/components/layout/top-nav.tsx +++ b/art-agent/frontend/src/components/layout/top-nav.tsx @@ -1,8 +1,10 @@ "use client"; +import { useState, useRef, useEffect } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useApp } from "@/lib/app-context"; +import { useAuth } from "@/lib/auth-context"; const NAV_ITEMS = [ { href: "/", label: "对话" }, @@ -12,6 +14,20 @@ const NAV_ITEMS = [ export function TopNav() { const pathname = usePathname(); const { sidebarCollapsed, setSidebarCollapsed } = useApp(); + const { user, logout } = useAuth(); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + function handleClick(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [menuOpen]); return (
@@ -64,21 +80,43 @@ export function TopNav() {
- {/* 全局搜索入口 — 移动端只显示图标 */} - + {/* 用户菜单 */} + {user && ( +
+ + {menuOpen && ( +
+
+ {user.username} + {user.is_admin && (管理员)} +
+ +
+ )} +
+ )}
); } diff --git a/art-agent/frontend/src/lib/api.ts b/art-agent/frontend/src/lib/api.ts index af4b9d0..28d72a1 100644 --- a/art-agent/frontend/src/lib/api.ts +++ b/art-agent/frontend/src/lib/api.ts @@ -3,9 +3,15 @@ */ import type { ApiMessage, ImageModelInfo } from "./types"; +import { getStoredToken } from "./auth-context"; const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; +function authHeaders(): Record { + const token = getStoredToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + export interface UploadProgress { /** 0-100 */ percent: number; @@ -54,6 +60,9 @@ export function uploadRefImage( } catch { reject(new Error("解析上传响应失败")); } + } else if (xhr.status === 401) { + window.location.href = "/login"; + reject(new Error("登录已过期")); } else { reject(new Error(`上传失败: ${xhr.status}`)); } @@ -65,6 +74,11 @@ export function uploadRefImage( xhr.open("POST", `${API_URL}/api/upload-ref-image`); xhr.timeout = 120_000; + const token = getStoredToken(); + if (token) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`); + } + const formData = new FormData(); formData.append("file", file); xhr.send(formData); @@ -83,7 +97,13 @@ export async function fetchModels(): Promise<{ models: ImageModelInfo[]; default: string; }> { - const resp = await fetch(`${API_URL}/api/models`); + const resp = await fetch(`${API_URL}/api/models`, { + headers: authHeaders(), + }); + if (resp.status === 401) { + window.location.href = "/login"; + throw new Error("登录已过期"); + } if (!resp.ok) throw new Error(`获取模型列表失败: ${resp.status}`); return resp.json(); } @@ -121,8 +141,14 @@ export async function* sendChat( const response = await fetch(`${API_URL}/api/chat`, { method: "POST", body: formData, + headers: authHeaders(), }); + if (response.status === 401) { + window.location.href = "/login"; + throw new Error("登录已过期"); + } + if (!response.ok) { throw new Error(`API 请求失败: ${response.status}`); } diff --git a/art-agent/frontend/src/lib/app-context.tsx b/art-agent/frontend/src/lib/app-context.tsx index 5c2960b..8fa7a88 100644 --- a/art-agent/frontend/src/lib/app-context.tsx +++ b/art-agent/frontend/src/lib/app-context.tsx @@ -26,7 +26,9 @@ import { deleteAsset as storeDeleteAsset, toggleFavorite as storeToggleFavorite, generateId, + setStoreUserId, } from "./store"; +import { useAuth } from "./auth-context"; interface AppContextValue { // 会话 @@ -73,6 +75,8 @@ export function useApp(): AppContextValue { } export function AppProvider({ children }: { children: ReactNode }) { + const { user, isAuthenticated } = useAuth(); + const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [tags, setTags] = useState([]); @@ -85,13 +89,20 @@ export function AppProvider({ children }: { children: ReactNode }) { }); const [initialized, setInitialized] = useState(false); - // 初始化:从 localStorage 加载 + // 当用户变化时,切换 store 的 userId 并重新加载数据 useEffect(() => { + if (!isAuthenticated || !user) { + setInitialized(false); + return; + } + setStoreUserId(user.id); setSessions(loadSessions()); setTags(loadTags()); setAssets(loadAssets()); + setActiveSessionId(null); + setDetailImage(null); setInitialized(true); - }, []); + }, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps // 初始化后,如果没有会话则自动创建一个 useEffect(() => { @@ -181,7 +192,6 @@ export function AppProvider({ children }: { children: ReactNode }) { messages: [...s.messages, message], updatedAt: Date.now(), }; - // 用首条用户消息作为自动标题 if (message.role === "user" && s.messages.length === 0) { updated.title = message.content.slice(0, 30) + (message.content.length > 30 ? "…" : ""); } @@ -283,7 +293,8 @@ export function AppProvider({ children }: { children: ReactNode }) { ] ); - if (!initialized) return null; + // 未登录时(如 /login 页面)直接渲染 children,不注入 AppContext + if (!initialized) return <>{children}; return {children}; } diff --git a/art-agent/frontend/src/lib/auth-context.tsx b/art-agent/frontend/src/lib/auth-context.tsx new file mode 100644 index 0000000..b6f7119 --- /dev/null +++ b/art-agent/frontend/src/lib/auth-context.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +export interface AuthUser { + id: string; + username: string; + display_name: string; + is_admin: boolean; +} + +interface AuthContextValue { + user: AuthUser | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (username: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} + +const TOKEN_KEY = "epeekit-auth-token"; +const REFRESH_KEY = "epeekit-refresh-token"; +const USER_KEY = "epeekit-auth-user"; + +export function getStoredToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const saveAuth = useCallback((accessToken: string, refreshToken: string, userData: AuthUser) => { + localStorage.setItem(TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_KEY, refreshToken); + localStorage.setItem(USER_KEY, JSON.stringify(userData)); + setToken(accessToken); + setUser(userData); + }, []); + + const clearAuth = useCallback(() => { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_KEY); + localStorage.removeItem(USER_KEY); + setToken(null); + setUser(null); + }, []); + + // 尝试用 refresh token 获取新 access token + const tryRefresh = useCallback(async (): Promise => { + const refreshToken = localStorage.getItem(REFRESH_KEY); + if (!refreshToken) return false; + try { + const resp = await fetch(`${API_URL}/api/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + if (!resp.ok) return false; + const data = await resp.json(); + const savedUser = localStorage.getItem(USER_KEY); + if (savedUser && data.access_token) { + const userData = JSON.parse(savedUser) as AuthUser; + localStorage.setItem(TOKEN_KEY, data.access_token); + setToken(data.access_token); + setUser(userData); + return true; + } + return false; + } catch { + return false; + } + }, []); + + // 启动时验证 token + useEffect(() => { + async function init() { + const savedToken = localStorage.getItem(TOKEN_KEY); + if (!savedToken) { + setIsLoading(false); + return; + } + try { + const resp = await fetch(`${API_URL}/api/auth/me`, { + headers: { Authorization: `Bearer ${savedToken}` }, + }); + if (resp.ok) { + const userData = await resp.json(); + setToken(savedToken); + setUser(userData); + } else if (resp.status === 401) { + const refreshed = await tryRefresh(); + if (!refreshed) clearAuth(); + } else { + clearAuth(); + } + } catch { + // 网络错误时保留本地缓存的用户信息,允许离线使用 + const savedUser = localStorage.getItem(USER_KEY); + if (savedUser) { + setToken(savedToken); + setUser(JSON.parse(savedUser)); + } + } + setIsLoading(false); + } + init(); + }, [clearAuth, tryRefresh]); + + const login = useCallback(async (username: string, password: string) => { + const resp = await fetch(`${API_URL}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ detail: "登录失败" })); + throw new Error(err.detail || "登录失败"); + } + const data = await resp.json(); + saveAuth(data.access_token, data.refresh_token, data.user); + }, [saveAuth]); + + const logout = useCallback(() => { + clearAuth(); + }, [clearAuth]); + + const value = useMemo( + () => ({ + user, + token, + isAuthenticated: !!token && !!user, + isLoading, + login, + logout, + }), + [user, token, isLoading, login, logout] + ); + + return {children}; +} diff --git a/art-agent/frontend/src/lib/auth-guard.tsx b/art-agent/frontend/src/lib/auth-guard.tsx new file mode 100644 index 0000000..6ed5693 --- /dev/null +++ b/art-agent/frontend/src/lib/auth-guard.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useAuth } from "./auth-context"; + +const PUBLIC_PATHS = ["/login"]; + +export function AuthGuard({ children }: { children: ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + const pathname = usePathname(); + const router = useRouter(); + + const isPublic = PUBLIC_PATHS.includes(pathname); + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated && !isPublic) { + router.replace("/login"); + } + }, [isAuthenticated, isLoading, isPublic, router]); + + if (isLoading) { + return ( +
+
加载中...
+
+ ); + } + + if (!isAuthenticated && !isPublic) { + return null; + } + + return <>{children}; +} diff --git a/art-agent/frontend/src/lib/store.ts b/art-agent/frontend/src/lib/store.ts index 91b1d84..e7bd04a 100644 --- a/art-agent/frontend/src/lib/store.ts +++ b/art-agent/frontend/src/lib/store.ts @@ -1,15 +1,26 @@ /** * 基于 localStorage 的客户端持久化存储。 * 提供会话、标签、图片资源的 CRUD 操作。 + * 所有 key 按 user_id 隔离,确保多用户数据不混淆。 */ import type { Session, Tag, ImageAsset, ChatMessage } from "./types"; -const STORAGE_KEYS = { - sessions: "epeekit-sessions", - tags: "epeekit-tags", - assets: "epeekit-assets", -} as const; +// 当前登录用户的 ID,由 AppProvider 在初始化时设置 +let _userId = ""; + +export function setStoreUserId(id: string) { + _userId = id; +} + +export function getStoreUserId(): string { + return _userId; +} + +function storageKey(base: string): string { + const prefix = _userId || "_anonymous"; + return `epeekit-${prefix}-${base}`; +} // --------------- 内置标签 --------------- @@ -52,7 +63,7 @@ export function generateId(prefix = ""): string { // --------------- 标签 --------------- export function loadTags(): Tag[] { - const custom = readJSON(STORAGE_KEYS.tags, []); + const custom = readJSON(storageKey("tags"), []); const builtinIds = new Set(BUILTIN_TAGS.map((t) => t.id)); const merged = [...BUILTIN_TAGS, ...custom.filter((t) => !builtinIds.has(t.id))]; return merged; @@ -60,7 +71,7 @@ export function loadTags(): Tag[] { export function saveTags(tags: Tag[]) { const custom = tags.filter((t) => !t.builtin); - writeJSON(STORAGE_KEYS.tags, custom); + writeJSON(storageKey("tags"), custom); } export function addTag(name: string, color: string): Tag { @@ -78,11 +89,11 @@ export function deleteTag(tagId: string) { // --------------- 会话 --------------- export function loadSessions(): Session[] { - return readJSON(STORAGE_KEYS.sessions, []); + return readJSON(storageKey("sessions"), []); } export function saveSessions(sessions: Session[]) { - writeJSON(STORAGE_KEYS.sessions, sessions); + writeJSON(storageKey("sessions"), sessions); } export function createSession(): Session { @@ -114,7 +125,6 @@ export function updateSession(session: Session) { export function deleteSession(sessionId: string) { const sessions = loadSessions().filter((s) => s.id !== sessionId); saveSessions(sessions); - // 同时删除关联的图片资源 const assets = loadAssets().filter((a) => a.sessionId !== sessionId); saveAssets(assets); } @@ -122,11 +132,11 @@ export function deleteSession(sessionId: string) { // --------------- 图片资源 --------------- export function loadAssets(): ImageAsset[] { - return readJSON(STORAGE_KEYS.assets, []); + return readJSON(storageKey("assets"), []); } export function saveAssets(assets: ImageAsset[]) { - writeJSON(STORAGE_KEYS.assets, assets); + writeJSON(storageKey("assets"), assets); } export function addAsset(asset: ImageAsset) { diff --git a/docs/art-agent/USER-SYSTEM.md b/docs/art-agent/USER-SYSTEM.md new file mode 100644 index 0000000..4fa22aa --- /dev/null +++ b/docs/art-agent/USER-SYSTEM.md @@ -0,0 +1,598 @@ +# EPEEKit 用户系统 + +本文档覆盖用户系统的技术架构、API 参考和日常使用指南。 + +--- + +## 目录 + +- [架构概览](#架构概览) +- [快速开始](#快速开始) +- [环境配置](#环境配置) +- [API 参考](#api-参考) + - [认证接口](#认证接口) + - [管理员接口](#管理员接口) + - [业务接口鉴权](#业务接口鉴权) +- [前端认证流程](#前端认证流程) +- [数据隔离机制](#数据隔离机制) +- [用户管理操作手册](#用户管理操作手册) +- [安全说明](#安全说明) +- [故障排除](#故障排除) + +--- + +## 架构概览 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 前端 (Next.js) │ +│ │ +│ AuthProvider → AuthGuard → AppProvider → 页面组件 │ +│ │ │ │ │ +│ localStorage /login 白名单 按 user_id 隔离存储 │ +│ ├ epeekit-auth-token │ +│ ├ epeekit-refresh-token │ +│ └ epeekit-{userId}-sessions / tags / assets │ +└──────────────────────┬──────────────────────────────────┘ + │ Authorization: Bearer + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 后端 (FastAPI) │ +│ │ +│ /api/auth/* ← 登录、刷新、改密(无需 token) │ +│ /api/admin/* ← 用户管理(需管理员 token) │ +│ /api/chat ← 对话(需 token,user_id 注入 Mem0) │ +│ /api/models ← 模型列表(需 token) │ +│ /api/upload-* ← 上传(需 token) │ +│ │ +│ 存储: │ +│ ├ data/epeekit.db ← SQLite,users 表 │ +│ └ data/qdrant/ ← Mem0 向量库,按 user_id 隔离 │ +└─────────────────────────────────────────────────────────┘ +``` + +### 技术选型 + +| 组件 | 技术 | 说明 | +|---|---|---| +| 数据库 | SQLite (SQLModel) | 零部署,单文件 `data/epeekit.db` | +| 密码哈希 | Argon2id (pwdlib) | 密码哈希竞赛冠军,抗 GPU/ASIC,兼容旧 bcrypt 哈希 | +| 令牌 | JWT (python-jose) | 无状态认证,HS256 签名 | +| 前端状态 | React Context | AuthProvider + AuthGuard | + +### 令牌机制 + +- **Access Token**:有效期 30 分钟,每个 API 请求携带 +- **Refresh Token**:有效期 7 天,用于在 access token 过期后无感刷新 +- JWT Payload 结构:`{ sub: user_id, exp: timestamp, type: "access" | "refresh" }` + +--- + +## 快速开始 + +### 1. 配置环境变量 + +在 `art-agent/backend/.env` 中添加: + +```env +# 必填:JWT 签名密钥(随机字符串,建议 32+ 位) +JWT_SECRET=你的随机密钥 + +# 可选:首次启动时的管理员密码(不设则随机生成并打印到控制台) +# ADMIN_DEFAULT_PASSWORD=changeme123 +``` + +### 2. 安装依赖 + +```bash +cd art-agent/backend +pip install -r requirements.txt +``` + +### 3. 启动后端 + +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +首次启动时,控制台会输出: + +``` +================================================== + 默认管理员账号已创建 + 用户名: admin + 密码: <随机生成的密码> + 请登录后尽快修改密码! +================================================== +``` + +**请立即记录这个密码。** 如果设置了 `ADMIN_DEFAULT_PASSWORD`,则使用该值,不会打印。 + +### 4. 登录 + +打开前端 `http://localhost:3000`,会自动跳转到登录页,使用 admin 账号登录。 + +### 5. 创建团队成员账号 + +登录后,使用管理员 API 创建其他用户(见 [用户管理操作手册](#用户管理操作手册))。 + +--- + +## 环境配置 + +### 后端环境变量 + +| 变量 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `JWT_SECRET` | 是 | (无) | JWT 签名密钥,未设置时启动报错 | +| `ADMIN_DEFAULT_PASSWORD` | 否 | (随机) | 首次启动创建管理员时的密码 | + +### 数据文件 + +| 路径 | 说明 | +|---|---| +| `art-agent/backend/data/epeekit.db` | SQLite 数据库,包含 users 表 | +| `art-agent/backend/data/qdrant/` | Mem0 向量数据,按 user_id 隔离 | + +> **备份提示**:定期备份 `data/` 目录即可保全用户数据和记忆数据。 + +--- + +## API 参考 + +所有接口基础路径为 `http://localhost:8000`。 + +### 认证接口 + +#### POST /api/auth/login + +用户登录,获取令牌。 + +**请求体** (JSON): +```json +{ + "username": "admin", + "password": "your-password" +} +``` + +**成功响应** (200): +```json +{ + "access_token": "eyJ...", + "refresh_token": "eyJ...", + "token_type": "bearer", + "user": { + "id": "a1b2c3...", + "username": "admin", + "display_name": "管理员", + "is_admin": true + } +} +``` + +**错误响应**: +- `401` — 用户名或密码错误 +- `403` — 账号已禁用 + +--- + +#### POST /api/auth/refresh + +用 refresh token 换取新的 access token。 + +**请求体** (JSON): +```json +{ + "refresh_token": "eyJ..." +} +``` + +**成功响应** (200): +```json +{ + "access_token": "eyJ...", + "token_type": "bearer" +} +``` + +--- + +#### POST /api/auth/change-password + +修改当前用户密码。**需要 Bearer Token。** + +**请求体** (JSON): +```json +{ + "old_password": "current-password", + "new_password": "new-password-min-6" +} +``` + +**成功响应** (200): +```json +{ + "message": "密码已修改" +} +``` + +**错误响应**: +- `400` — 旧密码错误 / 新密码少于 6 位 + +--- + +#### GET /api/auth/me + +获取当前登录用户的信息。**需要 Bearer Token。** + +**成功响应** (200): +```json +{ + "id": "a1b2c3...", + "username": "admin", + "display_name": "管理员", + "is_admin": true +} +``` + +--- + +### 管理员接口 + +以下接口均需要**管理员**的 Bearer Token,普通用户调用返回 `403`。 + +#### POST /api/admin/users + +创建新用户。 + +**请求体** (JSON): +```json +{ + "username": "zhangsan", + "password": "123456", + "display_name": "张三", + "is_admin": false +} +``` + +**成功响应** (200): +```json +{ + "id": "d4e5f6...", + "username": "zhangsan", + "display_name": "张三", + "is_admin": false, + "is_active": true, + "created_at": "2026-04-14T23:00:00" +} +``` + +**错误响应**: +- `409` — 用户名已存在 +- `400` — 密码少于 6 位 + +--- + +#### GET /api/admin/users + +获取所有用户列表。 + +**成功响应** (200): +```json +[ + { + "id": "a1b2c3...", + "username": "admin", + "display_name": "管理员", + "is_admin": true, + "is_active": true, + "created_at": "2026-04-14T22:00:00" + }, + ... +] +``` + +--- + +#### DELETE /api/admin/users/{user_id} + +禁用指定用户(软删除,不物理删除)。 + +**成功响应** (200): +```json +{ + "message": "用户 zhangsan 已禁用" +} +``` + +**错误响应**: +- `404` — 用户不存在 +- `400` — 不能禁用自己 + +--- + +### 业务接口鉴权 + +以下已有接口现在需要 Bearer Token: + +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/api/chat` | 对话(user_id 自动从 token 注入) | +| POST | `/api/upload-ref-image` | 上传参考图 | +| GET | `/api/models` | 获取模型列表 | + +请求示例: +```bash +curl -X POST http://localhost:8000/api/chat \ + -H "Authorization: Bearer eyJ..." \ + -F "messages=[{\"role\":\"user\",\"content\":\"画一个图标\"}]" +``` + +未携带或令牌无效时返回 `401`。 + +--- + +## 前端认证流程 + +### 组件层级 + +``` + ← 管理 token、user 状态 + ← 路由保护,未登录跳 /login + ← 业务数据(按用户隔离的 localStorage) + {children} ← 页面内容 + + + +``` + +### 流程图 + +``` +页面加载 + │ + ├─ 有 token? + │ ├─ 调用 GET /api/auth/me 验证 + │ │ ├─ 200 → 认证通过,加载用户数据 + │ │ ├─ 401 → 尝试 refresh token + │ │ │ ├─ 成功 → 获得新 access token,认证通过 + │ │ │ └─ 失败 → 清除 token,跳转 /login + │ │ └─ 网络错误 → 保留本地缓存,允许离线查看 + │ └─ token 不存在 → 跳转 /login + │ + └─ /login 页面 + └─ 提交用户名+密码 + ├─ 成功 → 保存 token,跳转 / + └─ 失败 → 显示错误信息 +``` + +### Token 存储 + +| localStorage Key | 内容 | +|---|---| +| `epeekit-auth-token` | JWT access token | +| `epeekit-refresh-token` | JWT refresh token | +| `epeekit-auth-user` | 用户信息 JSON (id, username, display_name, is_admin) | + +### 401 自动处理 + +所有 API 请求(`fetch` 和 `XMLHttpRequest`)在收到 `401` 响应时,自动跳转到 `/login`。 + +--- + +## 数据隔离机制 + +### 后端(Mem0 记忆) + +Mem0 的 `user_id` 参数从 JWT 中解析的真实用户 ID 传入: + +```python +# 之前(所有人共享) +search_kwargs = {"user_id": "default_user", ...} + +# 现在(按用户隔离) +search_kwargs = {"user_id": current_user.id, ...} +``` + +每个用户的对话记忆完全独立,互不可见。 + +### 前端(localStorage) + +所有 localStorage 键名加上用户 ID 前缀: + +``` +之前: epeekit-sessions +现在: epeekit-{userId}-sessions +``` + +完整键名表: + +| Key 模式 | 内容 | +|---|---| +| `epeekit-{userId}-sessions` | 对话会话列表 | +| `epeekit-{userId}-tags` | 自定义标签 | +| `epeekit-{userId}-assets` | 图片资源元数据 | +| `epeekit-{userId}-selected-image-model` | 模型选择偏好 | + +登出时数据不清除,下次登录同一账号时自动恢复。切换用户时自动加载对应用户的数据。 + +--- + +## 用户管理操作手册 + +### 场景一:为新团队成员创建账号 + +1. 使用管理员账号登录获取 token: + +```bash +# 登录 +curl -X POST http://localhost:8000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "your-admin-password"}' + +# 记录返回的 access_token +``` + +2. 创建用户: + +```bash +curl -X POST http://localhost:8000/api/admin/users \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "username": "zhangsan", + "password": "initial-password", + "display_name": "张三" + }' +``` + +3. 将用户名和初始密码告知成员,建议登录后立即修改密码。 + +### 场景二:查看所有用户 + +```bash +curl http://localhost:8000/api/admin/users \ + -H "Authorization: Bearer " +``` + +### 场景三:禁用一个用户 + +```bash +curl -X DELETE http://localhost:8000/api/admin/users/ \ + -H "Authorization: Bearer " +``` + +被禁用的用户: +- 无法登录 +- 已有的 token 在下次请求时被拒绝 +- 数据保留(不物理删除) + +### 场景四:用户修改自己的密码 + +```bash +curl -X POST http://localhost:8000/api/auth/change-password \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "old_password": "current-password", + "new_password": "new-secure-password" + }' +``` + +### 场景五:使用 FastAPI 交互式文档 + +后端启动后,访问 `http://localhost:8000/docs` 可以看到 Swagger UI: +- 点击右上角 "Authorize" 按钮 +- 输入用户名和密码进行认证 +- 之后可以直接在页面上调试所有 API + +--- + +## 安全说明 + +### 已实施的安全措施 + +- **Argon2id 密码哈希**:密码哈希竞赛冠军算法,抗 GPU/ASIC 攻击,即使数据库泄露也无法直接获取密码 +- **JWT 签名**:令牌使用 HS256 签名,无法伪造 +- **令牌过期**:access token 30 分钟过期,refresh token 7 天过期 +- **不开放注册**:只有管理员可以创建账号 +- **软删除**:禁用用户而非删除,保留审计轨迹 + +### 当前限制 + +- **CORS 全开** (`allow_origins=["*"]`):适合内部使用和开发阶段,生产部署时应改为具体域名 +- **无速率限制**:登录接口没有防暴力破解的限流机制,内部网络可接受 +- **SQLite 单写**:高并发写入时可能遇到锁,内部团队规模不会触及 +- **无审计日志**:未记录登录/操作日志,可后续添加 + +### JWT_SECRET 安全 + +- 必须使用足够长的随机字符串(建议 32+ 字符) +- 不要提交到 Git(`.env` 文件应在 `.gitignore` 中) +- 更换 JWT_SECRET 会使所有已发放的令牌失效,所有用户需要重新登录 + +--- + +## 故障排除 + +### 启动报错 "JWT_SECRET 环境变量未设置" + +在 `.env` 中添加 `JWT_SECRET=<随机字符串>` 后重启。 + +### 忘记管理员密码 + +删除数据库文件后重启,会重新创建默认管理员: + +```bash +rm art-agent/backend/data/epeekit.db +# 重启后端,新密码会打印到控制台 +``` + +> 注意:这会丢失所有用户数据。如果只想重置管理员密码,可以用 Python 脚本直接更新: +> +> ```python +> from app.db import engine, User +> from app.auth import hash_password +> from sqlmodel import Session, select +> +> with Session(engine) as s: +> admin = s.exec(select(User).where(User.username == "admin")).first() +> admin.hashed_password = hash_password("new-password") +> s.add(admin) +> s.commit() +> ``` + +### 前端一直跳转到登录页 + +- 检查后端是否正常运行(`curl http://localhost:8000/health`) +- 检查浏览器控制台是否有 CORS 或网络错误 +- 清除浏览器 localStorage 中的 `epeekit-auth-*` 键后重试 + +### 登录后看不到之前的对话 + +用户系统上线后,localStorage 的键名格式变为 `epeekit-{userId}-sessions`。之前无用户系统时的数据存在旧键名 `epeekit-sessions` 下。如需迁移: + +1. 打开浏览器开发者工具 → Application → Local Storage +2. 找到 `epeekit-sessions` 的值并复制 +3. 创建新键 `epeekit-<你的userId>-sessions`,粘贴值 +4. 刷新页面 + +### 多人使用同一浏览器 + +每个用户登录后,对话数据按 user_id 隔离存储在 localStorage 中。切换账号时自动加载对应用户的数据,互不影响。退出登录不会清除数据,下次登录仍可恢复。 + +--- + +## 源码结构 + +``` +art-agent/backend/ +├── app/ +│ ├── db.py ← SQLite 连接、User 模型、建表、默认管理员 +│ ├── auth.py ← Argon2id 密码哈希、JWT 编解码、FastAPI 依赖 +│ ├── api/ +│ │ ├── auth.py ← 登录/刷新/改密/me 路由 +│ │ ├── admin.py ← 创建/列表/禁用用户路由 +│ │ └── chat.py ← 已有对话路由(已加鉴权) +│ ├── agent/ +│ │ └── loop.py ← Agent Loop(user_id 透传 Mem0) +│ └── main.py ← 应用入口、路由注册、启动初始化 +└── data/ + ├── epeekit.db ← 用户数据库 + └── qdrant/ ← Mem0 向量存储 + +art-agent/frontend/src/ +├── lib/ +│ ├── auth-context.tsx ← AuthProvider:token 管理、login/logout +│ ├── auth-guard.tsx ← AuthGuard:路由保护 +│ ├── app-context.tsx ← AppProvider:按用户加载业务数据 +│ ├── api.ts ← API 封装(自动带 Authorization 头) +│ └── store.ts ← localStorage 封装(按 user_id 隔离键名) +├── app/ +│ ├── layout.tsx ← AuthProvider → AuthGuard → AppProvider +│ └── login/ +│ └── page.tsx ← 登录页面 +└── components/layout/ + └── top-nav.tsx ← 顶部导航(含用户菜单 + 退出登录) +```