用户系统

This commit is contained in:
2026-04-15 00:21:43 +08:00
parent 97bbb3f306
commit 47c0863bab
23 changed files with 1640 additions and 56 deletions

View File

@@ -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-cffipwdlib 提供与 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**:
- SQLitedata/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 响应自动跳转 /loginXHR上传和 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 — 修复多会话滚动位置共享 bugper-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 记忆系统:滑动窗口 + 跨会话长期记忆
- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口
- **affected_files**:

View File

@@ -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] 修复多会话滚动位置共享 bugper-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 从对话历史幻觉旧模型

View File

@@ -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 — 修复多会话滚动位置共享 bugper-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

View File

@@ -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

View File

@@ -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

View 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} 已禁用"}

View 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)

View File

@@ -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"],

View 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

View 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()

View File

@@ -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")

View File

@@ -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

View File

@@ -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 (
<html lang="zh-CN">
<body className="antialiased">
<AuthProvider>
<AuthGuard>
<AppProvider>{children}</AppProvider>
</AuthGuard>
</AuthProvider>
</body>
</html>
);

View 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>
);
}

View File

@@ -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<ImageAsset[]>([]);
const [statusText, setStatusText] = useState("");
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(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) => {
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 (
<div className="h-screen flex flex-col">
<TopNav />
@@ -298,6 +351,24 @@ export default function Home() {
)}
</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} />
</main>

View File

@@ -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

View File

@@ -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<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 (
<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" />
{/* 全局搜索入口 — 移动端只显示图标 */}
{/* 用户菜单 */}
{user && (
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 px-2 md:px-3 py-1.5 rounded-md text-sm
text-[var(--text-secondary)] border border-[var(--border)]
bg-[var(--bg-tertiary)] hover:border-[var(--accent)]
transition-colors cursor-pointer"
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" />
<path d="M21 21l-4.35-4.35" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span className="hidden sm:inline"></span>
<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">
{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>
);
}

View File

@@ -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<string, string> {
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}`);
}

View File

@@ -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<Session[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
@@ -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 <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

View 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>;
}

View 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}</>;
}

View File

@@ -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<Tag[]>(STORAGE_KEYS.tags, []);
const custom = readJSON<Tag[]>(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<Session[]>(STORAGE_KEYS.sessions, []);
return readJSON<Session[]>(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<ImageAsset[]>(STORAGE_KEYS.assets, []);
return readJSON<ImageAsset[]>(storageKey("assets"), []);
}
export function saveAssets(assets: ImageAsset[]) {
writeJSON(STORAGE_KEYS.assets, assets);
writeJSON(storageKey("assets"), assets);
}
export function addAsset(asset: ImageAsset) {

View 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 ← 对话(需 tokenuser_id 注入 Mem0
│ /api/models ← 模型列表(需 token
│ /api/upload-* ← 上传(需 token
│ │
│ 存储: │
│ ├ data/epeekit.db ← SQLiteusers 表 │
│ └ 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 Loopuser_id 透传 Mem0
│ └── main.py ← 应用入口、路由注册、启动初始化
└── data/
├── epeekit.db ← 用户数据库
└── qdrant/ ← Mem0 向量存储
art-agent/frontend/src/
├── lib/
│ ├── auth-context.tsx ← AuthProvidertoken 管理、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 ← 顶部导航(含用户菜单 + 退出登录)
```