Architech #1
@@ -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<Map<string, number>>` 在内存中记录每个会话的 scrollTop(而非 localStorage),因为滚动位置是临时状态,刷新页面后重新滚到底部是合理的默认行为
|
||||||
|
- **notes**:
|
||||||
|
- 切换会话时,先保存离开会话的 scrollTop,再用 `requestAnimationFrame` 等 DOM 渲染完新消息后恢复目标位置
|
||||||
|
- 无记录的会话(首次进入或新建)使用 `scrollToBottom(true)` 即时滚到底部
|
||||||
|
- `scrollToBottom` 改为 `useCallback` 并新增 `instant` 参数,恢复位置时用 instant 避免视觉跳动,正常对话流用 smooth
|
||||||
|
- 将 `scrollToBottom` 加入 `handleSend` 的依赖数组,修复潜在的陈旧闭包问题
|
||||||
|
|
||||||
### [CL-20260413-2320] 2026-04-13 23:20 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆
|
### [CL-20260413-2320] 2026-04-13 23:20 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆
|
||||||
- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口
|
- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口
|
||||||
- **affected_files**:
|
- **affected_files**:
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
最近 ~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-2320] 集成 Mem0 记忆系统:Mem0 OSS + DeepSeek 事实提取 + Ollama 本地 embedding + 滑动窗口,一次性解决上下文管理和跨会话长期记忆
|
||||||
- [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因,Replicate 非 Flux 官方模型需补全版本 hash
|
- [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因,Replicate 非 Flux 官方模型需补全版本 hash
|
||||||
- [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名:System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型
|
- [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名:System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型
|
||||||
|
|||||||
@@ -3,6 +3,41 @@
|
|||||||
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
||||||
当 Agent 检测到当前任务与近期改动相关时自动读取。
|
当 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 记忆系统:滑动窗口 + 跨会话长期记忆
|
### [CL-20260413-2320] 2026-04-13 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆
|
||||||
- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口
|
- **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
|
- **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
|
||||||
|
|||||||
@@ -41,5 +41,12 @@ IMAGE_OUTPUT_FORMAT=png
|
|||||||
# HTTP_PROXY=http://127.0.0.1:7890
|
# HTTP_PROXY=http://127.0.0.1:7890
|
||||||
# HTTPS_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
|
PORT=8000
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ async def run_agent_loop(
|
|||||||
ref_image_url: Optional[str] = None,
|
ref_image_url: Optional[str] = None,
|
||||||
image_model: Optional[str] = None,
|
image_model: Optional[str] = None,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
|
user_id: str = "default_user",
|
||||||
) -> AsyncGenerator[dict, None]:
|
) -> AsyncGenerator[dict, None]:
|
||||||
"""
|
"""
|
||||||
运行 Agent Loop,以 SSE 事件流形式 yield 结果。
|
运行 Agent Loop,以 SSE 事件流形式 yield 结果。
|
||||||
@@ -98,7 +99,7 @@ async def run_agent_loop(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if last_user_content:
|
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:
|
if session_id:
|
||||||
search_kwargs["run_id"] = session_id
|
search_kwargs["run_id"] = session_id
|
||||||
relevant = memory.search(**search_kwargs)
|
relevant = memory.search(**search_kwargs)
|
||||||
@@ -223,7 +224,7 @@ async def run_agent_loop(
|
|||||||
})
|
})
|
||||||
continue
|
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
|
yield evt
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -297,7 +298,7 @@ async def run_agent_loop(
|
|||||||
# 后续工具调用仍需参考图(InstantStyle 等模型必须有 style_image)
|
# 后续工具调用仍需参考图(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
|
yield evt
|
||||||
|
|
||||||
|
|
||||||
@@ -305,13 +306,13 @@ async def run_agent_loop(
|
|||||||
|
|
||||||
|
|
||||||
async def _store_and_done(
|
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]:
|
) -> AsyncGenerator[dict, None]:
|
||||||
"""触发 Mem0 异步存储后 yield done。存储失败时 yield warning 但不中断。"""
|
"""触发 Mem0 异步存储后 yield done。存储失败时 yield warning 但不中断。"""
|
||||||
try:
|
try:
|
||||||
memory = get_memory()
|
memory = get_memory()
|
||||||
recent = messages[-4:] if len(messages) >= 4 else messages
|
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:
|
if session_id:
|
||||||
add_kwargs["run_id"] = session_id
|
add_kwargs["run_id"] = session_id
|
||||||
|
|
||||||
|
|||||||
95
art-agent/backend/app/api/admin.py
Normal file
95
art-agent/backend/app/api/admin.py
Normal file
@@ -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} 已禁用"}
|
||||||
99
art-agent/backend/app/api/auth.py
Normal file
99
art-agent/backend/app/api/auth.py
Normal file
@@ -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)
|
||||||
@@ -3,11 +3,13 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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 sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.agent.loop import run_agent_loop
|
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.config import get_image_models_list, get_default_image_model_id
|
||||||
|
from app.db import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ async def _save_upload(file: UploadFile) -> str:
|
|||||||
@router.post("/upload-ref-image")
|
@router.post("/upload-ref-image")
|
||||||
async def upload_ref_image(
|
async def upload_ref_image(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
独立的参考图上传端点。
|
独立的参考图上传端点。
|
||||||
@@ -38,7 +41,7 @@ async def upload_ref_image(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/models")
|
@router.get("/models")
|
||||||
async def list_models():
|
async def list_models(current_user: User = Depends(get_current_user)):
|
||||||
"""返回可用的图像生成模型列表。"""
|
"""返回可用的图像生成模型列表。"""
|
||||||
return {
|
return {
|
||||||
"models": get_image_models_list(),
|
"models": get_image_models_list(),
|
||||||
@@ -53,6 +56,7 @@ async def chat(
|
|||||||
ref_image_url: Optional[str] = Form(None),
|
ref_image_url: Optional[str] = Form(None),
|
||||||
image_model: Optional[str] = Form(None),
|
image_model: Optional[str] = Form(None),
|
||||||
session_id: 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,
|
resolved_ref_url,
|
||||||
image_model=image_model,
|
image_model=image_model,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
user_id=current_user.id,
|
||||||
):
|
):
|
||||||
yield {
|
yield {
|
||||||
"event": event["type"],
|
"event": event["type"],
|
||||||
|
|||||||
89
art-agent/backend/app/auth.py
Normal file
89
art-agent/backend/app/auth.py
Normal file
@@ -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
|
||||||
67
art-agent/backend/app/db.py
Normal file
67
art-agent/backend/app/db.py
Normal file
@@ -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()
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -9,8 +10,19 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api.chat import router as chat_router
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -30,6 +42,8 @@ GENERATED_DIR.mkdir(exist_ok=True)
|
|||||||
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
|
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
|
||||||
app.mount("/generated", StaticFiles(directory=str(GENERATED_DIR)), name="generated")
|
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")
|
app.include_router(chat_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,6 @@ python-dotenv>=1.0.0
|
|||||||
Pillow>=10.4.0
|
Pillow>=10.4.0
|
||||||
mem0ai
|
mem0ai
|
||||||
ollama
|
ollama
|
||||||
|
sqlmodel>=0.0.22
|
||||||
|
pwdlib[argon2,bcrypt]>=0.3.0
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { AuthProvider } from "@/lib/auth-context";
|
||||||
|
import { AuthGuard } from "@/lib/auth-guard";
|
||||||
import { AppProvider } from "@/lib/app-context";
|
import { AppProvider } from "@/lib/app-context";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -22,7 +24,11 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<AppProvider>{children}</AppProvider>
|
<AuthProvider>
|
||||||
|
<AuthGuard>
|
||||||
|
<AppProvider>{children}</AppProvider>
|
||||||
|
</AuthGuard>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
96
art-agent/frontend/src/app/login/page.tsx
Normal file
96
art-agent/frontend/src/app/login/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
||||||
|
<div className="w-full max-w-sm mx-4">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="text-4xl mb-3">🎨</div>
|
||||||
|
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">EPEEKit</h1>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mt-1">AI 美术资源生成工具</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-400 bg-red-400/10 rounded-lg px-4 py-2.5">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !username.trim() || !password}
|
||||||
|
className="w-full py-3 rounded-lg font-medium
|
||||||
|
bg-[var(--accent)] text-white
|
||||||
|
hover:bg-[var(--accent-hover)]
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? "登录中..." : "登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ChatMessages } from "@/components/chat/chat-messages";
|
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||||
import { ChatInput } from "@/components/chat/chat-input";
|
import { ChatInput } from "@/components/chat/chat-input";
|
||||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||||
@@ -23,21 +23,76 @@ export default function Home() {
|
|||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
|
const messages = activeSession?.messages ?? [];
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [streamingText, setStreamingText] = useState("");
|
const [streamingText, setStreamingText] = useState("");
|
||||||
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
|
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
|
||||||
const [statusText, setStatusText] = useState("");
|
const [statusText, setStatusText] = useState("");
|
||||||
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollPositions = useRef<Map<string, number>>(new Map());
|
||||||
|
const prevSessionId = useRef<string | null>(null);
|
||||||
|
const isSwitching = useRef(false);
|
||||||
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = useCallback((instant?: boolean) => {
|
||||||
setTimeout(() => {
|
if (isSwitching.current && !instant) return;
|
||||||
scrollRef.current?.scrollTo({
|
const el = scrollRef.current;
|
||||||
top: scrollRef.current.scrollHeight,
|
if (!el) return;
|
||||||
behavior: "smooth",
|
if (instant) {
|
||||||
});
|
el.scrollTop = el.scrollHeight;
|
||||||
}, 50);
|
} 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(
|
const handleSend = useCallback(
|
||||||
async (text: string, refImageServerUrl: string | null, imageModel: string | null = null) => {
|
async (text: string, refImageServerUrl: string | null, imageModel: string | null = null) => {
|
||||||
@@ -198,15 +253,13 @@ export default function Home() {
|
|||||||
setStatusText("");
|
setStatusText("");
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
},
|
},
|
||||||
[activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation]
|
[activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation, scrollToBottom]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAnnotationComplete = useCallback((data: AnnotationData) => {
|
const handleAnnotationComplete = useCallback((data: AnnotationData) => {
|
||||||
setPendingAnnotation(data);
|
setPendingAnnotation(data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const messages = activeSession?.messages ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<div className="h-screen flex flex-col">
|
||||||
<TopNav />
|
<TopNav />
|
||||||
@@ -298,6 +351,24 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showScrollBtn && (
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToBottom()}
|
||||||
|
className="absolute bottom-16 right-4 z-10
|
||||||
|
w-8 h-8 rounded-full flex items-center justify-center
|
||||||
|
bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||||
|
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||||
|
hover:border-[var(--accent)] shadow-lg
|
||||||
|
transition-all duration-200 cursor-pointer
|
||||||
|
animate-[fadeIn_150ms_ease-out]"
|
||||||
|
title="回到底部"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<ChatInput onSend={handleSend} disabled={isLoading} />
|
<ChatInput onSend={handleSend} disabled={isLoading} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import type { ImageModelInfo } from "@/lib/types";
|
import type { ImageModelInfo } from "@/lib/types";
|
||||||
import { fetchModels } from "@/lib/api";
|
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 {
|
interface ModelSelectorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -20,7 +25,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
|||||||
fetchModels()
|
fetchModels()
|
||||||
.then(({ models: list, default: defaultId }) => {
|
.then(({ models: list, default: defaultId }) => {
|
||||||
setModels(list);
|
setModels(list);
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
const saved = localStorage.getItem(getModelStorageKey());
|
||||||
const validIds = new Set(list.map((m) => m.id));
|
const validIds = new Set(list.map((m) => m.id));
|
||||||
if (saved && validIds.has(saved)) {
|
if (saved && validIds.has(saved)) {
|
||||||
onChange(saved);
|
onChange(saved);
|
||||||
@@ -90,7 +95,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(m.id);
|
onChange(m.id);
|
||||||
localStorage.setItem(STORAGE_KEY, m.id);
|
localStorage.setItem(getModelStorageKey(), m.id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-3 py-2.5 flex flex-col gap-0.5
|
className={`w-full text-left px-3 py-2.5 flex flex-col gap-0.5
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/", label: "对话" },
|
{ href: "/", label: "对话" },
|
||||||
@@ -12,6 +14,20 @@ const NAV_ITEMS = [
|
|||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(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 (
|
return (
|
||||||
<header className="flex-shrink-0 h-12 border-b border-[var(--border)] bg-[var(--bg-secondary)] flex items-center px-3 md:px-4 gap-3 md:gap-6">
|
<header className="flex-shrink-0 h-12 border-b border-[var(--border)] bg-[var(--bg-secondary)] flex items-center px-3 md:px-4 gap-3 md:gap-6">
|
||||||
@@ -64,21 +80,43 @@ export function TopNav() {
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* 全局搜索入口 — 移动端只显示图标 */}
|
{/* 用户菜单 */}
|
||||||
<button
|
{user && (
|
||||||
className="flex items-center gap-2 px-2 md:px-3 py-1.5 rounded-md text-sm
|
<div className="relative" ref={menuRef}>
|
||||||
text-[var(--text-secondary)] border border-[var(--border)]
|
<button
|
||||||
bg-[var(--bg-tertiary)] hover:border-[var(--accent)]
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
transition-colors cursor-pointer"
|
className="flex items-center gap-2 px-2 md:px-3 py-1.5 rounded-md text-sm
|
||||||
title="搜索"
|
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||||
>
|
hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
>
|
||||||
<circle cx="11" cy="11" r="8" />
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M21 21l-4.35-4.35" />
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||||
</svg>
|
<circle cx="12" cy="7" r="4" />
|
||||||
<span className="hidden sm:inline">搜索</span>
|
</svg>
|
||||||
<kbd className="hidden md:inline text-xs text-[var(--text-secondary)] opacity-50 ml-2">⌘K</kbd>
|
<span className="hidden sm:inline max-w-[100px] truncate">
|
||||||
</button>
|
{user.display_name || user.username}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-44 rounded-lg
|
||||||
|
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||||
|
shadow-xl py-1 z-50">
|
||||||
|
<div className="px-3 py-2 text-xs text-[var(--text-secondary)] border-b border-[var(--border)]">
|
||||||
|
{user.username}
|
||||||
|
{user.is_admin && <span className="ml-1 text-[var(--accent)]">(管理员)</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); logout(); }}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
|
||||||
|
transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ApiMessage, ImageModelInfo } from "./types";
|
import type { ApiMessage, ImageModelInfo } from "./types";
|
||||||
|
import { getStoredToken } from "./auth-context";
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
const token = getStoredToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
export interface UploadProgress {
|
export interface UploadProgress {
|
||||||
/** 0-100 */
|
/** 0-100 */
|
||||||
percent: number;
|
percent: number;
|
||||||
@@ -54,6 +60,9 @@ export function uploadRefImage(
|
|||||||
} catch {
|
} catch {
|
||||||
reject(new Error("解析上传响应失败"));
|
reject(new Error("解析上传响应失败"));
|
||||||
}
|
}
|
||||||
|
} else if (xhr.status === 401) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
reject(new Error("登录已过期"));
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`上传失败: ${xhr.status}`));
|
reject(new Error(`上传失败: ${xhr.status}`));
|
||||||
}
|
}
|
||||||
@@ -65,6 +74,11 @@ export function uploadRefImage(
|
|||||||
xhr.open("POST", `${API_URL}/api/upload-ref-image`);
|
xhr.open("POST", `${API_URL}/api/upload-ref-image`);
|
||||||
xhr.timeout = 120_000;
|
xhr.timeout = 120_000;
|
||||||
|
|
||||||
|
const token = getStoredToken();
|
||||||
|
if (token) {
|
||||||
|
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
@@ -83,7 +97,13 @@ export async function fetchModels(): Promise<{
|
|||||||
models: ImageModelInfo[];
|
models: ImageModelInfo[];
|
||||||
default: string;
|
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}`);
|
if (!resp.ok) throw new Error(`获取模型列表失败: ${resp.status}`);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
@@ -121,8 +141,14 @@ export async function* sendChat(
|
|||||||
const response = await fetch(`${API_URL}/api/chat`, {
|
const response = await fetch(`${API_URL}/api/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("登录已过期");
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API 请求失败: ${response.status}`);
|
throw new Error(`API 请求失败: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
deleteAsset as storeDeleteAsset,
|
deleteAsset as storeDeleteAsset,
|
||||||
toggleFavorite as storeToggleFavorite,
|
toggleFavorite as storeToggleFavorite,
|
||||||
generateId,
|
generateId,
|
||||||
|
setStoreUserId,
|
||||||
} from "./store";
|
} from "./store";
|
||||||
|
import { useAuth } from "./auth-context";
|
||||||
|
|
||||||
interface AppContextValue {
|
interface AppContextValue {
|
||||||
// 会话
|
// 会话
|
||||||
@@ -73,6 +75,8 @@ export function useApp(): AppContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppProvider({ children }: { children: ReactNode }) {
|
export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { user, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
@@ -85,13 +89,20 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
// 初始化:从 localStorage 加载
|
// 当用户变化时,切换 store 的 userId 并重新加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
setInitialized(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStoreUserId(user.id);
|
||||||
setSessions(loadSessions());
|
setSessions(loadSessions());
|
||||||
setTags(loadTags());
|
setTags(loadTags());
|
||||||
setAssets(loadAssets());
|
setAssets(loadAssets());
|
||||||
|
setActiveSessionId(null);
|
||||||
|
setDetailImage(null);
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
}, []);
|
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 初始化后,如果没有会话则自动创建一个
|
// 初始化后,如果没有会话则自动创建一个
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,7 +192,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
messages: [...s.messages, message],
|
messages: [...s.messages, message],
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
// 用首条用户消息作为自动标题
|
|
||||||
if (message.role === "user" && s.messages.length === 0) {
|
if (message.role === "user" && s.messages.length === 0) {
|
||||||
updated.title = message.content.slice(0, 30) + (message.content.length > 30 ? "…" : "");
|
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 <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
161
art-agent/frontend/src/lib/auth-context.tsx
Normal file
161
art-agent/frontend/src/lib/auth-context.tsx
Normal file
@@ -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<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(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<AuthUser | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(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<boolean> => {
|
||||||
|
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<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: !!token && !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[user, token, isLoading, login, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
36
art-agent/frontend/src/lib/auth-guard.tsx
Normal file
36
art-agent/frontend/src/lib/auth-guard.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
||||||
|
<div className="text-[var(--text-secondary)] text-sm">加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated && !isPublic) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* 基于 localStorage 的客户端持久化存储。
|
* 基于 localStorage 的客户端持久化存储。
|
||||||
* 提供会话、标签、图片资源的 CRUD 操作。
|
* 提供会话、标签、图片资源的 CRUD 操作。
|
||||||
|
* 所有 key 按 user_id 隔离,确保多用户数据不混淆。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
|
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
// 当前登录用户的 ID,由 AppProvider 在初始化时设置
|
||||||
sessions: "epeekit-sessions",
|
let _userId = "";
|
||||||
tags: "epeekit-tags",
|
|
||||||
assets: "epeekit-assets",
|
export function setStoreUserId(id: string) {
|
||||||
} as const;
|
_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[] {
|
export function loadTags(): Tag[] {
|
||||||
const custom = readJSON<Tag[]>(STORAGE_KEYS.tags, []);
|
const custom = readJSON<Tag[]>(storageKey("tags"), []);
|
||||||
const builtinIds = new Set(BUILTIN_TAGS.map((t) => t.id));
|
const builtinIds = new Set(BUILTIN_TAGS.map((t) => t.id));
|
||||||
const merged = [...BUILTIN_TAGS, ...custom.filter((t) => !builtinIds.has(t.id))];
|
const merged = [...BUILTIN_TAGS, ...custom.filter((t) => !builtinIds.has(t.id))];
|
||||||
return merged;
|
return merged;
|
||||||
@@ -60,7 +71,7 @@ export function loadTags(): Tag[] {
|
|||||||
|
|
||||||
export function saveTags(tags: Tag[]) {
|
export function saveTags(tags: Tag[]) {
|
||||||
const custom = tags.filter((t) => !t.builtin);
|
const custom = tags.filter((t) => !t.builtin);
|
||||||
writeJSON(STORAGE_KEYS.tags, custom);
|
writeJSON(storageKey("tags"), custom);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addTag(name: string, color: string): Tag {
|
export function addTag(name: string, color: string): Tag {
|
||||||
@@ -78,11 +89,11 @@ export function deleteTag(tagId: string) {
|
|||||||
// --------------- 会话 ---------------
|
// --------------- 会话 ---------------
|
||||||
|
|
||||||
export function loadSessions(): Session[] {
|
export function loadSessions(): Session[] {
|
||||||
return readJSON<Session[]>(STORAGE_KEYS.sessions, []);
|
return readJSON<Session[]>(storageKey("sessions"), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSessions(sessions: Session[]) {
|
export function saveSessions(sessions: Session[]) {
|
||||||
writeJSON(STORAGE_KEYS.sessions, sessions);
|
writeJSON(storageKey("sessions"), sessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSession(): Session {
|
export function createSession(): Session {
|
||||||
@@ -114,7 +125,6 @@ export function updateSession(session: Session) {
|
|||||||
export function deleteSession(sessionId: string) {
|
export function deleteSession(sessionId: string) {
|
||||||
const sessions = loadSessions().filter((s) => s.id !== sessionId);
|
const sessions = loadSessions().filter((s) => s.id !== sessionId);
|
||||||
saveSessions(sessions);
|
saveSessions(sessions);
|
||||||
// 同时删除关联的图片资源
|
|
||||||
const assets = loadAssets().filter((a) => a.sessionId !== sessionId);
|
const assets = loadAssets().filter((a) => a.sessionId !== sessionId);
|
||||||
saveAssets(assets);
|
saveAssets(assets);
|
||||||
}
|
}
|
||||||
@@ -122,11 +132,11 @@ export function deleteSession(sessionId: string) {
|
|||||||
// --------------- 图片资源 ---------------
|
// --------------- 图片资源 ---------------
|
||||||
|
|
||||||
export function loadAssets(): ImageAsset[] {
|
export function loadAssets(): ImageAsset[] {
|
||||||
return readJSON<ImageAsset[]>(STORAGE_KEYS.assets, []);
|
return readJSON<ImageAsset[]>(storageKey("assets"), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveAssets(assets: ImageAsset[]) {
|
export function saveAssets(assets: ImageAsset[]) {
|
||||||
writeJSON(STORAGE_KEYS.assets, assets);
|
writeJSON(storageKey("assets"), assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addAsset(asset: ImageAsset) {
|
export function addAsset(asset: ImageAsset) {
|
||||||
|
|||||||
598
docs/art-agent/USER-SYSTEM.md
Normal file
598
docs/art-agent/USER-SYSTEM.md
Normal file
@@ -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 <token>
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 后端 (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`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端认证流程
|
||||||
|
|
||||||
|
### 组件层级
|
||||||
|
|
||||||
|
```
|
||||||
|
<AuthProvider> ← 管理 token、user 状态
|
||||||
|
<AuthGuard> ← 路由保护,未登录跳 /login
|
||||||
|
<AppProvider> ← 业务数据(按用户隔离的 localStorage)
|
||||||
|
{children} ← 页面内容
|
||||||
|
</AppProvider>
|
||||||
|
</AuthGuard>
|
||||||
|
</AuthProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 流程图
|
||||||
|
|
||||||
|
```
|
||||||
|
页面加载
|
||||||
|
│
|
||||||
|
├─ 有 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 <admin-access-token>" \
|
||||||
|
-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 <admin-access-token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景三:禁用一个用户
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:8000/api/admin/users/<user-id> \
|
||||||
|
-H "Authorization: Bearer <admin-access-token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
被禁用的用户:
|
||||||
|
- 无法登录
|
||||||
|
- 已有的 token 在下次请求时被拒绝
|
||||||
|
- 数据保留(不物理删除)
|
||||||
|
|
||||||
|
### 场景四:用户修改自己的密码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/auth/change-password \
|
||||||
|
-H "Authorization: Bearer <user-access-token>" \
|
||||||
|
-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 ← 顶部导航(含用户菜单 + 退出登录)
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user