Просмотр исходного кода

增加了用户注册登录,用户管理

sequoia00 1 неделя назад
Родитель
Сommit
a9cc3c5ac0

+ 5 - 0
chatfast/__init__.py

@@ -0,0 +1,5 @@
+"""ChatFast backend utilities package."""
+
+from . import config, db, models  # noqa: F401
+
+__all__ = ["config", "db", "models"]

+ 7 - 0
chatfast/api/__init__.py

@@ -0,0 +1,7 @@
+"""FastAPI router modules for ChatFast."""
+
+from .auth import router as auth_router
+from .admin import router as admin_router
+from .exports import router as export_router
+
+__all__ = ["auth_router", "admin_router", "export_router"]

+ 63 - 0
chatfast/api/admin.py

@@ -0,0 +1,63 @@
+"""Administrative API routes."""
+
+from typing import Any, Dict, Optional
+
+from fastapi import APIRouter, Depends
+
+from chatfast.services.auth import (
+    AdminUserRequest,
+    AdminUserUpdateRequest,
+    UserInfo,
+    admin_create_user as admin_create_user_service,
+    admin_delete_user as admin_delete_user_service,
+    admin_get_user as admin_get_user_service,
+    admin_list_users as admin_list_users_service,
+    admin_update_user as admin_update_user_service,
+    require_admin,
+)
+from chatfast.services.chat import list_exports_admin
+
+router = APIRouter(prefix="/api/admin", tags=["admin"])
+
+
+@router.get("/users")
+async def api_admin_list_users(
+    keyword: Optional[str] = None,
+    page: int = 0,
+    page_size: int = 20,
+    admin: UserInfo = Depends(require_admin),
+) -> Dict[str, Any]:
+    return await admin_list_users_service(keyword, page, page_size)
+
+
+@router.post("/users")
+async def api_admin_create_user(payload: AdminUserRequest, admin: UserInfo = Depends(require_admin)) -> Dict[str, Any]:
+    return await admin_create_user_service(payload.username, payload.password)
+
+
+@router.get("/users/{user_id}")
+async def api_admin_get_user(user_id: int, admin: UserInfo = Depends(require_admin)) -> Dict[str, Any]:
+    return await admin_get_user_service(user_id)
+
+
+@router.put("/users/{user_id}")
+async def api_admin_update_user(
+    user_id: int,
+    payload: AdminUserUpdateRequest,
+    admin: UserInfo = Depends(require_admin),
+) -> Dict[str, Any]:
+    return await admin_update_user_service(user_id, payload)
+
+
+@router.delete("/users/{user_id}")
+async def api_admin_delete_user(user_id: int, admin: UserInfo = Depends(require_admin)) -> Dict[str, Any]:
+    return await admin_delete_user_service(user_id)
+
+
+@router.get("/exports")
+async def api_admin_exports(
+    keyword: Optional[str] = None,
+    admin: UserInfo = Depends(require_admin),
+) -> Dict[str, Any]:
+    items = await list_exports_admin(keyword)
+    return {"items": items}

+ 53 - 0
chatfast/api/auth.py

@@ -0,0 +1,53 @@
+"""Authentication related API routes."""
+
+from typing import Any, Dict
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials
+
+from chatfast.services.auth import (
+    AUTH_SCHEME,
+    LoginRequest,
+    RegisterRequest,
+    UserInfo,
+    create_auth_token,
+    get_current_user,
+    login_user,
+    register_user,
+    resolve_token,
+    revoke_token,
+)
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+
+@router.post("/register")
+async def api_register(payload: RegisterRequest) -> Dict[str, Any]:
+    user = await register_user(payload.username, payload.password)
+    token_data = await create_auth_token(user["id"])
+    return {"user": user, "token": token_data["token"], "expires_at": token_data["expires_at"]}
+
+
+@router.post("/login")
+async def api_login(payload: LoginRequest) -> Dict[str, Any]:
+    user = await login_user(payload.username, payload.password)
+    token_data = await create_auth_token(user["id"])
+    return {"user": user, "token": token_data["token"], "expires_at": token_data["expires_at"]}
+
+
+@router.post("/logout")
+async def api_logout(
+    credentials: HTTPAuthorizationCredentials = Depends(AUTH_SCHEME),
+) -> Dict[str, str]:
+    if not credentials:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
+    user = await resolve_token(credentials.credentials)
+    if not user:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效")
+    await revoke_token(credentials.credentials)
+    return {"status": "ok"}
+
+
+@router.get("/me")
+async def api_me(current_user: UserInfo = Depends(get_current_user)) -> UserInfo:
+    return current_user

+ 51 - 0
chatfast/api/exports.py

@@ -0,0 +1,51 @@
+"""Export related API routes."""
+
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.responses import FileResponse
+from pydantic import BaseModel
+
+from chatfast.db import MessageContent
+from chatfast.services.auth import UserInfo, get_current_user
+from chatfast.services.chat import (
+    export_message_to_blog,
+    get_export_record,
+    list_exports_for_user,
+    record_export_entry,
+)
+
+
+class ExportRequest(BaseModel):
+    content: MessageContent
+    session_id: Optional[int] = None
+
+
+router = APIRouter(prefix="/api", tags=["exports"])
+
+
+@router.post("/export")
+async def api_export_message(payload: ExportRequest, current_user: UserInfo = Depends(get_current_user)) -> Dict[str, Any]:
+    path = await export_message_to_blog(payload.content)
+    record = await record_export_entry(current_user.id, payload.session_id, path, payload.content)
+    return {"status": "ok", "path": path, "export": record}
+
+
+@router.get("/exports/me")
+async def api_my_exports(current_user: UserInfo = Depends(get_current_user)) -> Dict[str, Any]:
+    items = await list_exports_for_user(current_user.id)
+    return {"items": items}
+
+
+@router.get("/exports/{export_id}/download")
+async def api_download_export(export_id: int, current_user: UserInfo = Depends(get_current_user)) -> FileResponse:
+    record = await get_export_record(export_id)
+    if not record:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出记录不存在")
+    if record["user_id"] != current_user.id and current_user.role != "admin":
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权下载该内容")
+    file_path = Path(record["file_path"])
+    if not file_path.exists():
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出文件不存在")
+    return FileResponse(file_path, filename=record["filename"])

+ 50 - 0
chatfast/config.py

@@ -0,0 +1,50 @@
+"""Central configuration for the ChatFast backend."""
+
+from pathlib import Path
+import os
+from urllib.parse import quote_plus
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+DATA_DIR = BASE_DIR / "data"
+BACKUP_DIR = BASE_DIR / "data_bak"
+BLOG_DIR = BASE_DIR / "blog"
+UPLOAD_DIR = BASE_DIR / "uploads"
+STATIC_DIR = BASE_DIR / "static"
+
+MYSQL_HOST = os.getenv("CHATFAST_DB_HOST", "127.0.0.1")
+MYSQL_PORT = int(os.getenv("CHATFAST_DB_PORT", "3306"))
+MYSQL_USER = os.getenv("CHATFAST_DB_USER", "root")
+MYSQL_PASSWORD = os.getenv("CHATFAST_DB_PASSWORD", "792199Zhao*")
+DATABASE_NAME = os.getenv("CHATFAST_DB_NAME", "chat_fast")
+ENCODED_PASSWORD = quote_plus(MYSQL_PASSWORD)
+RAW_DATABASE_URL = (
+    f"mysql+pymysql://{MYSQL_USER}:{ENCODED_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/?charset=utf8mb4"
+)
+DATABASE_URL = (
+    f"mysql+pymysql://{MYSQL_USER}:{ENCODED_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/{DATABASE_NAME}?charset=utf8mb4"
+)
+
+TOKEN_TTL_HOURS = int(os.getenv("CHATFAST_TOKEN_TTL_HOURS", "72"))
+DEFAULT_ADMIN_USERNAME = os.getenv("CHATFAST_DEFAULT_ADMIN", "admin")
+DEFAULT_ADMIN_PASSWORD = os.getenv("CHATFAST_DEFAULT_ADMIN_PASSWORD", "Admin@123")
+
+# 默认上传文件下载地址,可通过环境变量覆盖
+DEFAULT_UPLOAD_BASE = os.getenv("UPLOAD_BASE_URL", "/download/")
+DOWNLOAD_BASE = DEFAULT_UPLOAD_BASE.rstrip("/") if DEFAULT_UPLOAD_BASE else ""
+
+# 模型配置
+default_key = "sk-re2NlaKIQn11ZNWzAbB6339cEbF94c6aAfC8B7Ab82879bEa"
+MODEL_KEYS = {
+    "grok-3": default_key,
+    "grok-4": default_key,
+    "gpt-5.1-2025-11-13": default_key,
+    "gpt-5-2025-08-07": default_key,
+    "gpt-4o-mini": default_key,
+    "o1-mini": default_key,
+    "o4-mini": default_key,
+    "deepseek-v3": default_key,
+    "deepseek-r1": default_key,
+    "gpt-4o-all": default_key,
+    "o3-mini-all": default_key,
+}
+API_URL = "https://yunwu.ai/v1"

+ 57 - 0
chatfast/db.py

@@ -0,0 +1,57 @@
+"""Database helpers shared across the backend."""
+
+from __future__ import annotations
+
+import asyncio
+import datetime
+from typing import Any, Callable, Dict, List, Optional, Union
+
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import Session, declarative_base, sessionmaker
+
+from . import config
+
+Base = declarative_base()
+ENGINE = None
+SessionLocal: Optional[sessionmaker] = None
+FILE_LOCK = asyncio.Lock()
+
+MessageContent = Union[str, List[Dict[str, Any]]]
+
+
+def now_utc() -> datetime.datetime:
+    return datetime.datetime.utcnow()
+
+
+def ensure_directories() -> None:
+    for path in [config.DATA_DIR, config.BACKUP_DIR, config.BLOG_DIR, config.UPLOAD_DIR, config.STATIC_DIR]:
+        path.mkdir(parents=True, exist_ok=True)
+
+
+def ensure_database_initialized() -> None:
+    global ENGINE, SessionLocal
+    if ENGINE is not None:
+        return
+
+    raw_engine = create_engine(config.RAW_DATABASE_URL, future=True, pool_pre_ping=True)
+    with raw_engine.connect() as connection:
+        connection.execute(
+            text(
+                f"CREATE DATABASE IF NOT EXISTS `{config.DATABASE_NAME}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
+            )
+        )
+    raw_engine.dispose()
+
+    ENGINE = create_engine(config.DATABASE_URL, future=True, pool_pre_ping=True)
+    SessionLocal = sessionmaker(bind=ENGINE, autoflush=False, expire_on_commit=False, future=True)
+    Base.metadata.create_all(bind=ENGINE)
+
+
+async def db_call(func: Callable[[Session], Any], *args: Any, **kwargs: Any) -> Any:
+    def wrapped() -> Any:
+        if SessionLocal is None:
+            raise RuntimeError("数据库尚未初始化")
+        with SessionLocal() as session:
+            return func(session, *args, **kwargs)
+
+    return await asyncio.to_thread(wrapped)

+ 77 - 0
chatfast/models.py

@@ -0,0 +1,77 @@
+"""ORM models for ChatFast."""
+
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import relationship
+
+from .db import Base, now_utc
+
+
+class User(Base):
+    __tablename__ = "users"
+
+    id = Column(Integer, primary_key=True)
+    username = Column(String(64), unique=True, nullable=False, index=True)
+    password_hash = Column(String(128), nullable=False)
+    salt = Column(String(32), nullable=False)
+    role = Column(String(16), nullable=False, default="user")
+    created_at = Column(DateTime, default=now_utc, nullable=False)
+    updated_at = Column(DateTime, default=now_utc, onupdate=now_utc, nullable=False)
+
+    sessions = relationship("ChatSession", back_populates="user", cascade="all, delete-orphan")
+
+
+class ChatSession(Base):
+    __tablename__ = "chat_sessions"
+
+    id = Column(Integer, primary_key=True)
+    user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+    user_session_no = Column(Integer, nullable=False, default=0)
+    title = Column(String(128), nullable=True)
+    archived = Column(Boolean, default=False, nullable=False)
+    created_at = Column(DateTime, default=now_utc, nullable=False)
+    updated_at = Column(DateTime, default=now_utc, nullable=False)
+
+    user = relationship("User", back_populates="sessions")
+    messages = relationship("ChatMessage", back_populates="session", cascade="all, delete-orphan")
+
+
+class ChatMessage(Base):
+    __tablename__ = "chat_messages"
+
+    id = Column(Integer, primary_key=True)
+    session_id = Column(
+        Integer,
+        ForeignKey("chat_sessions.id", ondelete="CASCADE"),
+        nullable=False,
+        index=True,
+    )
+    role = Column(String(16), nullable=False)
+    content = Column(Text, nullable=False)
+    created_at = Column(DateTime, default=now_utc, nullable=False)
+
+    session = relationship("ChatSession", back_populates="messages")
+
+
+class AuthToken(Base):
+    __tablename__ = "auth_tokens"
+
+    token = Column(String(128), primary_key=True)
+    user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+    expires_at = Column(DateTime, nullable=False)
+    created_at = Column(DateTime, default=now_utc, nullable=False)
+
+    user = relationship("User")
+
+
+class ExportedContent(Base):
+    __tablename__ = "exported_contents"
+
+    id = Column(Integer, primary_key=True)
+    user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+    source_session_id = Column(Integer, ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True)
+    filename = Column(String(255), nullable=False)
+    file_path = Column(String(500), nullable=False)
+    content_preview = Column(Text, nullable=True)
+    created_at = Column(DateTime, default=now_utc, nullable=False)
+
+    user = relationship("User")

+ 5 - 0
chatfast/services/__init__.py

@@ -0,0 +1,5 @@
+"""Service layer for ChatFast."""
+
+from . import auth, chat  # noqa: F401
+
+__all__ = ["auth", "chat"]

+ 287 - 0
chatfast/services/auth.py

@@ -0,0 +1,287 @@
+"""User and authentication related helpers."""
+
+from __future__ import annotations
+
+import datetime
+import secrets
+from typing import Any, Dict, Optional
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from .. import config
+from ..db import SessionLocal, db_call, now_utc
+from ..models import AuthToken, User
+
+AUTH_SCHEME = HTTPBearer(auto_error=False)
+
+
+class UserInfo(BaseModel):
+    id: int
+    username: str
+    role: str
+
+
+class RegisterRequest(BaseModel):
+    username: str
+    password: str
+
+
+class LoginRequest(BaseModel):
+    username: str
+    password: str
+
+
+class AdminUserRequest(BaseModel):
+    username: str
+    password: str
+
+
+class AdminUserUpdateRequest(BaseModel):
+    username: Optional[str] = None
+    password: Optional[str] = None
+
+
+def hash_password(password: str, salt: str) -> str:
+    import hashlib
+
+    return hashlib.sha256((password + salt).encode("utf-8")).hexdigest()
+
+
+def normalize_username(username: str) -> str:
+    normalized = (username or "").strip()
+    if len(normalized) < 3:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名至少需要 3 个字符")
+    return normalized
+
+
+def enforce_password_strength(password: str) -> str:
+    if not password or len(password) < 6:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码至少需要 6 位")
+    return password
+
+
+async def create_auth_token(user_id: int) -> Dict[str, Any]:
+    def creator(session: Session) -> Dict[str, Any]:
+        token_value = secrets.token_hex(32)
+        expires_at = now_utc() + datetime.timedelta(hours=config.TOKEN_TTL_HOURS)
+        token = AuthToken(token=token_value, user_id=user_id, expires_at=expires_at)
+        session.add(token)
+        session.commit()
+        return {"token": token_value, "expires_at": expires_at.isoformat()}
+
+    return await db_call(creator)
+
+
+async def revoke_token(token_value: str) -> None:
+    def remover(session: Session) -> None:
+        session.query(AuthToken).filter(AuthToken.token == token_value).delete()
+        session.commit()
+
+    await db_call(remover)
+
+
+async def resolve_token(token_value: str) -> Optional[UserInfo]:
+    def resolver(session: Session) -> Optional[UserInfo]:
+        token = session.execute(select(AuthToken).where(AuthToken.token == token_value)).scalar_one_or_none()
+        if not token:
+            return None
+        if token.expires_at < now_utc():
+            session.delete(token)
+            session.commit()
+            return None
+        user = session.get(User, token.user_id)
+        if not user:
+            session.delete(token)
+            session.commit()
+            return None
+        return UserInfo(id=user.id, username=user.username, role=user.role)
+
+    return await db_call(resolver)
+
+
+async def cleanup_expired_tokens() -> None:
+    def cleaner(session: Session) -> None:
+        session.query(AuthToken).filter(AuthToken.expires_at < now_utc()).delete()
+        session.commit()
+
+    await db_call(cleaner)
+
+
+def ensure_default_admin() -> None:
+    if SessionLocal is None:
+        return
+    with SessionLocal() as session:
+        existing = session.execute(select(User).where(User.role == "admin")).first()
+        if existing:
+            return
+        salt = secrets.token_hex(8)
+        admin = User(
+            username=config.DEFAULT_ADMIN_USERNAME,
+            salt=salt,
+            password_hash=hash_password(config.DEFAULT_ADMIN_PASSWORD, salt),
+            role="admin",
+        )
+        session.add(admin)
+        session.commit()
+
+
+async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(AUTH_SCHEME)) -> UserInfo:
+    if not credentials:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
+    user = await resolve_token(credentials.credentials)
+    if not user:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效,请重新登录")
+    return user
+
+
+async def require_admin(current_user: UserInfo = Depends(get_current_user)) -> UserInfo:
+    if current_user.role != "admin":
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
+    return current_user
+
+
+async def admin_list_users(keyword: Optional[str], page: int, page_size: int) -> Dict[str, Any]:
+    def lister(session: Session) -> Dict[str, Any]:
+        stmt = select(User).order_by(User.created_at.desc())
+        if keyword:
+            stmt = stmt.where(User.username.like(f"%{keyword.strip()}%"))
+        users = session.execute(stmt).scalars().all()
+        total = len(users)
+        start = max(page, 0) * page_size
+        end = start + page_size
+        subset = users[start:end]
+        items = [
+            {
+                "id": user.id,
+                "username": user.username,
+                "role": user.role,
+                "created_at": (user.created_at or now_utc()).isoformat(),
+            }
+            for user in subset
+        ]
+        return {"items": items, "total": total, "page": page, "page_size": page_size}
+
+    return await db_call(lister)
+
+
+async def admin_create_user(username: str, password: str) -> Dict[str, Any]:
+    username = normalize_username(username)
+    password = enforce_password_strength(password)
+
+    def creator(session: Session) -> Dict[str, Any]:
+        existing = session.execute(select(User).where(User.username == username)).scalar_one_or_none()
+        if existing:
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
+        salt = secrets.token_hex(8)
+        user = User(username=username, salt=salt, password_hash=hash_password(password, salt), role="user")
+        session.add(user)
+        session.commit()
+        session.refresh(user)
+        return {
+            "id": user.id,
+            "username": user.username,
+            "role": user.role,
+            "created_at": (user.created_at or now_utc()).isoformat(),
+        }
+
+    return await db_call(creator)
+
+
+async def admin_get_user(user_id: int) -> Dict[str, Any]:
+    def getter(session: Session) -> Dict[str, Any]:
+        user = session.get(User, user_id)
+        if not user:
+            raise HTTPException(status_code=404, detail="用户不存在")
+        return {
+            "id": user.id,
+            "username": user.username,
+            "role": user.role,
+            "created_at": (user.created_at or now_utc()).isoformat(),
+        }
+
+    return await db_call(getter)
+
+
+async def admin_update_user(user_id: int, payload: AdminUserUpdateRequest) -> Dict[str, Any]:
+    def updater(session: Session) -> Dict[str, Any]:
+        user = session.get(User, user_id)
+        if not user:
+            raise HTTPException(status_code=404, detail="用户不存在")
+        if user.role == "admin":
+            raise HTTPException(status_code=400, detail="无法修改管理员信息")
+        if payload.username:
+            new_username = normalize_username(payload.username)
+            conflict = (
+                session.execute(select(User).where(User.username == new_username, User.id != user_id)).scalar_one_or_none()
+            )
+            if conflict:
+                raise HTTPException(status_code=400, detail="用户名已被使用")
+            user.username = new_username
+        if payload.password:
+            enforce_password_strength(payload.password)
+            salt = secrets.token_hex(8)
+            user.salt = salt
+            user.password_hash = hash_password(payload.password, salt)
+        session.commit()
+        return {
+            "id": user.id,
+            "username": user.username,
+            "role": user.role,
+            "created_at": (user.created_at or now_utc()).isoformat(),
+        }
+
+    return await db_call(updater)
+
+
+async def admin_delete_user(user_id: int) -> Dict[str, Any]:
+    def deleter(session: Session) -> Dict[str, Any]:
+        user = session.get(User, user_id)
+        if not user:
+            raise HTTPException(status_code=404, detail="用户不存在")
+        if user.role == "admin":
+            raise HTTPException(status_code=400, detail="无法删除管理员")
+        session.delete(user)
+        session.commit()
+        return {"status": "ok"}
+
+    return await db_call(deleter)
+
+
+async def register_user(username: str, password: str) -> Dict[str, Any]:
+    username = normalize_username(username)
+    password = enforce_password_strength(password)
+
+    def creator(session: Session) -> Dict[str, Any]:
+        existing = session.execute(select(User).where(User.username == username)).scalar_one_or_none()
+        if existing:
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
+        salt = secrets.token_hex(8)
+        user = User(username=username, salt=salt, password_hash=hash_password(password, salt), role="user")
+        session.add(user)
+        session.commit()
+        session.refresh(user)
+        return {"id": user.id, "username": user.username, "role": user.role}
+
+    return await db_call(creator)
+
+
+async def login_user(username: str, password: str) -> Dict[str, Any]:
+    username = (username or "").strip()
+    if not username:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入用户名")
+    password = password or ""
+
+    def verifier(session: Session) -> Dict[str, Any]:
+        user = session.execute(select(User).where(User.username == username)).scalar_one_or_none()
+        if not user:
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名或密码错误")
+        hashed = hash_password(password, user.salt)
+        if hashed != user.password_hash:
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名或密码错误")
+        return {"id": user.id, "username": user.username, "role": user.role}
+
+    return await db_call(verifier)

+ 455 - 0
chatfast/services/chat.py

@@ -0,0 +1,455 @@
+"""Chat session helpers."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from fastapi import HTTPException, status
+from sqlalchemy import select, func, text
+from sqlalchemy.orm import Session
+
+from .. import config
+from .. import db as db_core
+from ..db import FILE_LOCK, MessageContent, db_call, now_utc
+from ..models import ChatMessage, ChatSession, ExportedContent, User
+
+
+def serialize_content(content: MessageContent) -> str:
+    return json.dumps(content, ensure_ascii=False)
+
+
+def deserialize_content(raw: str) -> MessageContent:
+    try:
+        return json.loads(raw)
+    except json.JSONDecodeError:
+        return raw
+
+
+def text_from_content(content: MessageContent) -> str:
+    if isinstance(content, str):
+        return content
+    if isinstance(content, list):
+        pieces: List[str] = []
+        for part in content:
+            if part.get("type") == "text":
+                pieces.append(part.get("text", ""))
+        return " ".join(pieces)
+    return str(content)
+
+
+def extract_history_title(messages: List[Dict[str, Any]]) -> str:
+    for message in messages:
+        if message.get("role") != "user":
+            continue
+        title = text_from_content(message.get("content", "")).strip()
+        if title:
+            return title[:10]
+
+    if messages:
+        fallback = text_from_content(messages[0].get("content", "")).strip()
+        if fallback:
+            return fallback[:10]
+
+    return "空的聊天"[:10]
+
+
+def history_backup_path(session_id: int) -> Path:
+    return config.BACKUP_DIR / f"chat_history_{session_id}.json"
+
+
+def build_download_url(filename: str) -> str:
+    base = config.DOWNLOAD_BASE or ""
+    return f"{base}/{filename}" if base else filename
+
+
+def ensure_session_numbering() -> None:
+    # make sure the database is ready before touching schema
+    db_core.ensure_database_initialized()
+    _ensure_session_number_column()
+    _backfill_session_numbers()
+
+
+def _ensure_session_number_column() -> None:
+    engine = db_core.ENGINE
+    if engine is None:
+        db_core.ensure_database_initialized()
+        engine = db_core.ENGINE
+    if engine is None:
+        return
+    with engine.begin() as connection:
+        exists = connection.execute(
+            text(
+                """
+                SELECT 1 FROM information_schema.columns
+                WHERE table_schema = :schema
+                  AND table_name = 'chat_sessions'
+                  AND column_name = 'user_session_no'
+                """
+            ),
+            {"schema": config.DATABASE_NAME},
+        ).first()
+        if exists:
+            return
+        connection.execute(text("ALTER TABLE chat_sessions ADD COLUMN user_session_no INT NOT NULL DEFAULT 0"))
+
+
+def _backfill_session_numbers() -> None:
+    session_factory = db_core.SessionLocal
+    if session_factory is None:
+        db_core.ensure_database_initialized()
+        session_factory = db_core.SessionLocal
+    if session_factory is None:
+        return
+    with session_factory() as session:
+        users = session.execute(select(User.id)).scalars().all()
+        for user_id in users:
+            sessions = (
+                session.execute(
+                    select(ChatSession)
+                    .where(ChatSession.user_id == user_id)
+                    .order_by(ChatSession.created_at, ChatSession.id)
+                )
+                .scalars()
+                .all()
+            )
+            dirty = False
+            for index, chat_session in enumerate(sessions, start=1):
+                if chat_session.user_session_no != index:
+                    chat_session.user_session_no = index
+                    dirty = True
+            if dirty:
+                session.commit()
+
+
+def _next_session_number(session: Session, user_id: int) -> int:
+    current = (
+        session.execute(select(func.max(ChatSession.user_session_no)).where(ChatSession.user_id == user_id)).scalar()
+        or 0
+    )
+    return current + 1
+
+
+async def get_session_payload(session_id: int, user_id: int, allow_archived: bool = False) -> Dict[str, Any]:
+    def loader(session: Session) -> Dict[str, Any]:
+        stmt = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == user_id)
+        chat_session = session.execute(stmt).scalar_one_or_none()
+        if not chat_session:
+            raise HTTPException(status_code=404, detail="会话不存在")
+        if chat_session.archived and not allow_archived:
+            raise HTTPException(status_code=404, detail="会话不存在")
+        messages = (
+            session.execute(
+                select(ChatMessage).where(ChatMessage.session_id == chat_session.id).order_by(ChatMessage.id)
+            )
+            .scalars()
+            .all()
+        )
+        payload = [{"role": msg.role, "content": deserialize_content(msg.content)} for msg in messages]
+        if not chat_session.user_session_no:
+            chat_session.user_session_no = _next_session_number(session, user_id)
+            session.commit()
+        return {
+            "session_id": chat_session.id,
+            "session_number": chat_session.user_session_no,
+            "messages": payload,
+            "archived": chat_session.archived,
+        }
+
+    return await db_call(loader)
+
+
+async def load_messages(session_id: int, user_id: int) -> List[Dict[str, Any]]:
+    payload = await get_session_payload(session_id, user_id, allow_archived=True)
+    return payload["messages"]
+
+
+async def ensure_active_session(session_id: Optional[int], user_id: int) -> Dict[str, Any]:
+    if session_id:
+        try:
+            payload = await get_session_payload(session_id, user_id, allow_archived=False)
+            return payload
+        except HTTPException as exc:
+            if exc.status_code != 404:
+                raise
+    return await create_chat_session(user_id)
+
+
+async def create_chat_session(user_id: int) -> Dict[str, Any]:
+    def creator(session: Session) -> Dict[str, Any]:
+        chat_session = ChatSession(user_id=user_id, user_session_no=_next_session_number(session, user_id))
+        session.add(chat_session)
+        session.commit()
+        session.refresh(chat_session)
+        return {"session_id": chat_session.id, "session_number": chat_session.user_session_no, "messages": []}
+
+    return await db_call(creator)
+
+
+async def get_latest_session(user_id: int) -> Dict[str, Any]:
+    def loader(session: Session) -> Dict[str, Any]:
+        stmt = (
+            select(ChatSession)
+            .where(ChatSession.user_id == user_id, ChatSession.archived.is_(False))
+            .order_by(ChatSession.updated_at.desc())
+        )
+        chat_session = session.execute(stmt).scalars().first()
+        if not chat_session:
+            chat_session = ChatSession(user_id=user_id)
+            session.add(chat_session)
+            session.commit()
+            session.refresh(chat_session)
+            messages: List[Dict[str, Any]] = []
+        else:
+            messages = (
+                session.execute(
+                    select(ChatMessage).where(ChatMessage.session_id == chat_session.id).order_by(ChatMessage.id)
+                )
+                .scalars()
+                .all()
+            )
+            messages = [{"role": msg.role, "content": deserialize_content(msg.content)} for msg in messages]
+        if not chat_session.user_session_no:
+            chat_session.user_session_no = _next_session_number(session, user_id)
+            session.commit()
+        return {"session_id": chat_session.id, "session_number": chat_session.user_session_no, "messages": messages}
+
+    return await db_call(loader)
+
+
+async def append_message(session_id: int, user_id: int, role: str, content: MessageContent) -> None:
+    def writer(session: Session) -> None:
+        stmt = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == user_id)
+        chat_session = session.execute(stmt).scalar_one_or_none()
+        if not chat_session:
+            raise HTTPException(status_code=404, detail="会话不存在")
+        serialized = serialize_content(content)
+        message = ChatMessage(session_id=chat_session.id, role=role, content=serialized)
+        session.add(message)
+        chat_session.updated_at = now_utc()
+        if role == "user" and (not chat_session.title or not chat_session.title.strip()):
+            candidate = text_from_content(content).strip()
+            if candidate:
+                chat_session.title = candidate[:30]
+        session.commit()
+
+    await db_call(writer)
+
+
+async def list_history(user_id: int, page: int, page_size: int) -> Dict[str, Any]:
+    def lister(session: Session) -> Dict[str, Any]:
+        stmt = (
+            select(ChatSession)
+            .where(ChatSession.user_id == user_id, ChatSession.archived.is_(False))
+            .order_by(ChatSession.updated_at.desc())
+        )
+        sessions = session.execute(stmt).scalars().all()
+        if not sessions:
+            fresh = ChatSession(user_id=user_id, user_session_no=_next_session_number(session, user_id))
+            session.add(fresh)
+            session.commit()
+            session.refresh(fresh)
+            sessions = [fresh]
+        total = len(sessions)
+        start = max(page, 0) * page_size
+        end = start + page_size
+        subset = sessions[start:end]
+        items: List[Dict[str, Any]] = []
+        for item in subset:
+            items.append(
+                {
+                    "session_id": item.id,
+                    "session_number": item.user_session_no or 0,
+                    "title": (item.title or f"会话 #{item.id}")[:30],
+                    "updated_at": (item.updated_at or now_utc()).isoformat(),
+                    "filename": f"session_{item.id}.json",
+                }
+            )
+        return {"page": page, "page_size": page_size, "total": total, "items": items}
+
+    return await db_call(lister)
+
+
+async def move_history_file(user_id: int, session_id: int) -> None:
+    def mark_archived(session: Session) -> List[Dict[str, Any]]:
+        stmt = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == user_id)
+        chat_session = session.execute(stmt).scalar_one_or_none()
+        if not chat_session:
+            raise HTTPException(status_code=404, detail="历史记录不存在")
+        messages = (
+            session.execute(
+                select(ChatMessage).where(ChatMessage.session_id == chat_session.id).order_by(ChatMessage.id)
+            )
+            .scalars()
+            .all()
+        )
+        payload = [{"role": msg.role, "content": deserialize_content(msg.content)} for msg in messages]
+        chat_session.archived = True
+        chat_session.updated_at = now_utc()
+        session.commit()
+        return payload
+
+    messages = await db_call(mark_archived)
+    backup_file = history_backup_path(session_id)
+    backup_file.parent.mkdir(parents=True, exist_ok=True)
+
+    def _write() -> None:
+        with backup_file.open("w", encoding="utf-8") as fp:
+            json.dump(messages, fp, ensure_ascii=False, indent=2)
+
+    async with FILE_LOCK:
+        await asyncio.to_thread(_write)  # type: ignore[name-defined]
+
+
+async def delete_history_file(user_id: int, session_id: int) -> None:
+    def deleter(session: Session) -> None:
+        stmt = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == user_id)
+        chat_session = session.execute(stmt).scalar_one_or_none()
+        if not chat_session:
+            raise HTTPException(status_code=404, detail="历史记录不存在")
+        session.delete(chat_session)
+        session.commit()
+
+    await db_call(deleter)
+
+
+async def export_message_to_blog(content: MessageContent) -> str:
+    processed = text_from_content(content)
+    processed = processed.replace("\r\n", "\n")
+    timestamp = now_utc().strftime("%m%d%H%M")
+    first_10 = (
+        processed[:10]
+        .replace(" ", "")
+        .replace("/", "")
+        .replace("\\", "")
+        .replace(":", "")
+        .replace("`", "")
+    )
+    filename = f"{timestamp}_{first_10 or 'export'}.txt"
+    path = config.BLOG_DIR / filename
+
+    def _write() -> None:
+        with path.open("w", encoding="utf-8") as fp:
+            fp.write(processed)
+
+    await asyncio.to_thread(_write)  # type: ignore[name-defined]
+    return str(path)
+
+
+async def record_export_entry(user_id: int, session_id: Optional[int], file_path: str, content: MessageContent) -> Dict[str, Any]:
+    def recorder(session: Session) -> Dict[str, Any]:
+        filename = Path(file_path).name
+        preview = text_from_content(content).strip()[:200]
+        export = ExportedContent(
+            user_id=user_id,
+            source_session_id=session_id,
+            filename=filename,
+            file_path=file_path,
+            content_preview=preview,
+        )
+        session.add(export)
+        session.commit()
+        session.refresh(export)
+        user = session.get(User, user_id)
+        username = user.username if user else ""
+        return {
+            "id": export.id,
+            "user_id": user_id,
+            "username": username,
+            "filename": filename,
+            "file_path": file_path,
+            "created_at": (export.created_at or now_utc()).isoformat(),
+            "content_preview": preview,
+        }
+
+    return await db_call(recorder)
+
+
+async def list_exports_for_user(user_id: int) -> List[Dict[str, Any]]:
+    def lister(session: Session) -> List[Dict[str, Any]]:
+        stmt = (
+            select(ExportedContent)
+            .where(ExportedContent.user_id == user_id)
+            .order_by(ExportedContent.created_at.desc())
+        )
+        exports = session.execute(stmt).scalars().all()
+        user = session.get(User, user_id)
+        username = user.username if user else ""
+        results: List[Dict[str, Any]] = []
+        for item in exports:
+            results.append(
+                {
+                    "id": item.id,
+                    "user_id": item.user_id,
+                    "username": username,
+                    "filename": item.filename,
+                    "file_path": item.file_path,
+                    "created_at": (item.created_at or now_utc()).isoformat(),
+                    "content_preview": (item.content_preview or "")[:200],
+                }
+            )
+        return results
+
+    return await db_call(lister)
+
+
+async def list_exports_admin(keyword: Optional[str] = None) -> List[Dict[str, Any]]:
+    def lister(session: Session) -> List[Dict[str, Any]]:
+        stmt = select(ExportedContent, User.username).join(User, ExportedContent.user_id == User.id)
+        if keyword:
+            stmt = stmt.where(User.username.like(f"%{keyword.strip()}%"))
+        stmt = stmt.order_by(ExportedContent.created_at.desc())
+        rows = session.execute(stmt).all()
+        results: List[Dict[str, Any]] = []
+        for export, username in rows:
+            results.append(
+                {
+                    "id": export.id,
+                    "user_id": export.user_id,
+                    "username": username,
+                    "filename": export.filename,
+                    "file_path": export.file_path,
+                    "created_at": (export.created_at or now_utc()).isoformat(),
+                    "content_preview": (export.content_preview or "")[:200],
+                }
+            )
+        return results
+
+    return await db_call(lister)
+
+
+async def get_export_record(export_id: int) -> Optional[Dict[str, Any]]:
+    def fetcher(session: Session) -> Optional[Dict[str, Any]]:
+        export = session.get(ExportedContent, export_id)
+        if not export:
+            return None
+        user = session.get(User, export.user_id)
+        username = user.username if user else ""
+        return {
+            "id": export.id,
+            "user_id": export.user_id,
+            "username": username,
+            "filename": export.filename,
+            "file_path": export.file_path,
+        }
+
+    return await db_call(fetcher)
+
+
+async def prepare_messages_for_completion(
+    messages: List[Dict[str, Any]],
+    user_content: MessageContent,
+    history_count: int,
+) -> List[Dict[str, Any]]:
+    if history_count > 0:
+        trimmed = messages[-history_count:]
+        if trimmed:
+            return trimmed
+    return [{"role": "user", "content": user_content}]
+
+
+async def save_assistant_message(session_id: int, user_id: int, messages: List[Dict[str, Any]], content: MessageContent) -> None:
+    messages.append({"role": "assistant", "content": content})
+    await append_message(session_id, user_id, "assistant", content)

Разница между файлами не показана из-за своего большого размера
+ 42 - 868
fastchat.py


+ 196 - 21
static/app.js

@@ -6,6 +6,7 @@
     const state = {
         config: null,
         sessionId: null,
+        sessionNumber: null,
         messages: [],
         expandedMessages: new Set(),
         historyPage: 0,
@@ -23,6 +24,8 @@
         myExports: [],
         adminUsers: [],
         adminExports: [],
+        activeAbortController: null,
+        userMenuOpen: false,
     };
 
     const dom = {};
@@ -87,6 +90,13 @@
         dom.sendButton = document.getElementById('send-btn');
         dom.fileInput = document.getElementById('file-input');
         dom.chatStatus = document.getElementById('chat-status');
+        dom.sessionIndicator = document.getElementById('session-indicator');
+        dom.userMenu = document.getElementById('user-menu');
+        dom.userMenuToggle = document.getElementById('user-menu-toggle');
+        dom.userMenuDropdown = document.getElementById('user-menu-dropdown');
+        dom.userAvatarInitials = document.getElementById('user-avatar-initials');
+        dom.stopButton = document.getElementById('end-chat-btn');
+        dom.adminRoleIndicator = document.getElementById('admin-role-indicator');
         dom.toast = document.getElementById('toast');
 
         if (dom.sendButton && !dom.sendButton.dataset.defaultText) {
@@ -114,6 +124,7 @@
         }
         if (dom.adminButton) {
             dom.adminButton.addEventListener('click', () => {
+                setUserMenuOpen(false);
                 void openAdminPanel();
             });
         }
@@ -139,12 +150,15 @@
         }
         if (dom.exportButton) {
             dom.exportButton.addEventListener('click', () => {
+                setUserMenuOpen(false);
                 void openExportPanel();
             });
         }
         if (dom.exportClose) {
             dom.exportClose.addEventListener('click', hideExportPanel);
         }
+        setupOverlayDismiss(dom.adminPanel, hideAdminPanel);
+        setupOverlayDismiss(dom.exportPanel, hideExportPanel);
         dom.modelSelect.addEventListener('change', () => {
             state.model = dom.modelSelect.value;
         });
@@ -190,7 +204,7 @@
             }
             try {
                 const data = await fetchJSON('/api/session/new', { method: 'POST' });
-                state.sessionId = data.session_id;
+                updateActiveSession(data.session_id, data.session_number, { updateUrl: true });
                 state.messages = [];
                 state.historyCount = 0;
                 state.searchQuery = '';
@@ -200,7 +214,6 @@
                 renderSidebar();
                 renderMessages();
                 renderHistory();
-                updateSessionInUrl(state.sessionId, { replace: false });
                 showToast('当前会话已清空。', 'success');
                 await loadHistory();
             } catch (err) {
@@ -222,10 +235,48 @@
         });
 
         window.addEventListener('popstate', handlePopState);
+
+        if (dom.stopButton) {
+            dom.stopButton.addEventListener('click', handleAbortConversation);
+        }
+        if (dom.userMenuToggle) {
+            dom.userMenuToggle.addEventListener('click', (event) => {
+                event.preventDefault();
+                setUserMenuOpen(!state.userMenuOpen);
+            });
+        }
+        document.addEventListener('click', (event) => {
+            if (!state.userMenuOpen || !dom.userMenu) {
+                return;
+            }
+            if (event.target instanceof Node && dom.userMenu.contains(event.target)) {
+                return;
+            }
+            setUserMenuOpen(false);
+        });
+        document.addEventListener('keydown', (event) => {
+            if (event.key === 'Escape') {
+                setUserMenuOpen(false);
+                hideAdminPanel();
+                hideExportPanel();
+            }
+        });
+    }
+
+    function setupOverlayDismiss(overlay, closeHandler) {
+        if (!overlay || typeof closeHandler !== 'function') {
+            return;
+        }
+        overlay.addEventListener('click', (event) => {
+            if (event.target === overlay) {
+                closeHandler();
+            }
+        });
     }
 
     function resetChatState() {
         state.sessionId = null;
+        state.sessionNumber = null;
         state.messages = [];
         state.expandedMessages = new Set();
         state.historyItems = [];
@@ -235,12 +286,15 @@
         state.myExports = [];
         state.adminUsers = [];
         state.adminExports = [];
+        state.activeAbortController = null;
+        setUserMenuOpen(false);
         renderSidebar();
         renderMessages();
         renderHistory();
         renderMyExports();
         renderAdminUsers();
         renderAdminExports();
+        updateSessionIndicator();
     }
 
     function showAuthView(mode = 'login') {
@@ -360,6 +414,23 @@
         if (dom.logoutButton) {
             dom.logoutButton.disabled = !state.token;
         }
+        if (dom.userMenu) {
+            dom.userMenu.classList.toggle('hidden', !state.token);
+        }
+        if (dom.userMenuToggle) {
+            dom.userMenuToggle.disabled = !state.token;
+        }
+        if (!state.token) {
+            setUserMenuOpen(false);
+        }
+        if (dom.userAvatarInitials) {
+            const initials = state.user ? extractInitials(state.user.username) : '';
+            dom.userAvatarInitials.textContent = initials;
+        }
+        if (dom.adminRoleIndicator) {
+            dom.adminRoleIndicator.classList.toggle('hidden', !isAdmin());
+        }
+        updateSessionIndicator();
     }
 
     function isAdmin() {
@@ -431,6 +502,7 @@
 
     async function handleLogout(event) {
         event.preventDefault();
+        setUserMenuOpen(false);
         if (!state.token) {
             showAuthView('login');
             return;
@@ -445,6 +517,8 @@
             resetChatState();
             showAuthView('login');
             updateUserUi();
+            hideAdminPanel();
+            hideExportPanel();
         }
     }
 
@@ -805,7 +879,7 @@
         }
         const { updateUrl = true, replaceUrl = false } = options;
         const data = await fetchJSON('/api/session/latest');
-        state.sessionId = typeof data.session_id === 'number' ? data.session_id : 0;
+        updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl });
         state.messages = Array.isArray(data.messages) ? data.messages : [];
         state.expandedMessages = new Set();
         state.historyCount = Math.min(state.historyCount, state.messages.length);
@@ -815,10 +889,6 @@
         renderSidebar();
         renderMessages();
         renderHistory();
-
-        if (updateUrl) {
-            updateSessionInUrl(state.sessionId, { replace: replaceUrl });
-        }
     }
 
     async function loadSession(sessionId, options = {}) {
@@ -836,7 +906,7 @@
 
         try {
             const data = await fetchJSON(`/api/session/${sessionId}`);
-            state.sessionId = data.session_id;
+            updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl });
             state.messages = Array.isArray(data.messages) ? data.messages : [];
             state.historyCount = Math.min(state.historyCount, state.messages.length);
             state.expandedMessages = new Set();
@@ -846,10 +916,6 @@
             renderMessages();
             renderHistory();
 
-            if (updateUrl) {
-                updateSessionInUrl(state.sessionId, { replace: replaceUrl });
-            }
-
             return true;
         } catch (err) {
             if (!silent) {
@@ -939,9 +1005,37 @@
         if (dom.newChatButton) {
             dom.newChatButton.disabled = active;
         }
+        if (dom.stopButton) {
+            dom.stopButton.setAttribute('aria-hidden', active ? 'false' : 'true');
+            dom.stopButton.title = '提前结束此次对话';
+        }
         if (active) {
             setStatus('正在生成回复…', 'running');
         }
+        if (!active) {
+            if (dom.stopButton) {
+                dom.stopButton.classList.add('hidden');
+                dom.stopButton.disabled = true;
+            }
+            state.activeAbortController = null;
+        } else if (dom.stopButton) {
+            dom.stopButton.classList.remove('hidden');
+            dom.stopButton.disabled = false;
+        }
+    }
+
+    function handleAbortConversation() {
+        if (!state.streaming || !state.activeAbortController) {
+            return;
+        }
+        try {
+            state.activeAbortController.abort();
+        } catch (err) {
+            console.warn('Abort failed', err);
+        }
+        if (dom.stopButton) {
+            dom.stopButton.disabled = true;
+        }
     }
 
     function renderHistory() {
@@ -983,7 +1077,12 @@
                 const loadLink = document.createElement('a');
                 loadLink.className = 'history-title-link';
                 loadLink.href = buildSessionUrl(item.session_id);
-                const displayTitle = (item.title && item.title.trim()) ? item.title.trim() : `会话 #${item.session_id}`;
+                const sessionNumber = Number.isFinite(Number(item.session_number))
+                    ? Number(item.session_number)
+                    : item.session_id;
+                const displayTitle = (item.title && item.title.trim())
+                    ? item.title.trim()
+                    : `会话 #${sessionNumber}`;
                 const primary = document.createElement('span');
                 primary.className = 'history-title-text';
                 primary.textContent = displayTitle;
@@ -991,7 +1090,7 @@
 
                 const subtitle = document.createElement('span');
                 subtitle.className = 'history-subtitle';
-                subtitle.textContent = item.filename ? item.filename : `会话 #${item.session_id}`;
+                subtitle.textContent = `会话 #${sessionNumber}`;
                 loadLink.appendChild(subtitle);
                 loadLink.title = `会话 #${item.session_id} · 点击加载`;
 
@@ -1017,12 +1116,16 @@
                 moveButton.addEventListener('click', async (event) => {
                     event.stopPropagation();
                     try {
+                        const isActive = item.session_id === state.sessionId;
                         await fetchJSON('/api/history/move', {
                             method: 'POST',
                             body: { session_id: item.session_id },
                         });
                         showToast('已移动到备份。', 'success');
                         await loadHistory();
+                        if (isActive) {
+                            await loadLatestSession({ updateUrl: true, replaceUrl: true });
+                        }
                     } catch (err) {
                         showToast(err.message || '移动失败', 'error');
                     }
@@ -1540,26 +1643,38 @@
             stream: state.outputMode === '流式输出 (Stream)',
         };
 
+        const controller = new AbortController();
+        state.activeAbortController = controller;
         setStreaming(true);
         try {
             if (payload.stream) {
-                await streamAssistantReply(payload, assistantMessage, assistantIndex);
+                await streamAssistantReply(payload, assistantMessage, assistantIndex, controller);
             } else {
                 const data = await fetchJSON('/api/chat', {
                     method: 'POST',
                     body: payload,
+                    signal: controller.signal,
                 });
+                if (Number.isFinite(Number(data.session_id))) {
+                    updateActiveSession(data.session_id, data.session_number);
+                }
                 assistantMessage.content = data.message || '';
                 updateMessageContent(assistantIndex, assistantMessage.content);
                 showToast('已生成回复', 'success');
                 setStatus('');
             }
         } catch (err) {
-            state.messages.splice(assistantIndex, 1);
-            renderMessages();
-            const message = err.message || '发送失败';
-            setStatus(message, 'error');
-            showToast(message, 'error');
+            const aborted = err && (err.name === 'AbortError');
+            if (!aborted) {
+                state.messages.splice(assistantIndex, 1);
+                renderMessages();
+                const message = err.message || '发送失败';
+                setStatus(message, 'error');
+                showToast(message, 'error');
+            } else {
+                setStatus('对话已提前结束', 'error');
+                showToast('当前对话已中止', 'error');
+            }
         } finally {
             try {
                 state.historyPage = 0;
@@ -1634,11 +1749,12 @@
         return { content: combined.trim() };
     }
 
-    async function streamAssistantReply(payload, assistantMessage, assistantIndex) {
+    async function streamAssistantReply(payload, assistantMessage, assistantIndex, controller) {
         const response = await fetch('/api/chat', {
             method: 'POST',
             headers: buildAuthHeaders({ 'Content-Type': 'application/json' }),
             body: JSON.stringify(payload),
+            signal: controller ? controller.signal : undefined,
         });
         if (response.status === 401) {
             handleUnauthorized();
@@ -1685,6 +1801,10 @@
             return;
         }
 
+        if (payload.type === 'meta') {
+            updateActiveSession(payload.session_id, payload.session_number);
+            return null;
+        }
         if (payload.type === 'delta') {
             if (typeof assistantMessage.content !== 'string') {
                 assistantMessage.content = '';
@@ -1847,4 +1967,59 @@
             toastTimer = setTimeout(() => dom.toast.classList.add('hidden'), 300);
         }, 2500);
     }
+    function extractInitials(name = '') {
+        if (!name) {
+            return '?';
+        }
+        const trimmed = name.trim();
+        if (!trimmed) {
+            return '?';
+        }
+        return trimmed.slice(0, 2).toUpperCase();
+    }
+
+    function setUserMenuOpen(open) {
+        const nextState = Boolean(open && state.token);
+        state.userMenuOpen = nextState;
+        if (dom.userMenuDropdown) {
+            dom.userMenuDropdown.classList.toggle('hidden', !nextState);
+        }
+        if (dom.userMenu) {
+            dom.userMenu.classList.toggle('open', nextState);
+        }
+        if (dom.userMenuToggle) {
+            dom.userMenuToggle.setAttribute('aria-expanded', nextState ? 'true' : 'false');
+        }
+    }
+
+    function updateSessionIndicator() {
+        if (!dom.sessionIndicator) {
+            return;
+        }
+        if (!state.token || state.sessionId === null) {
+            dom.sessionIndicator.textContent = '';
+            dom.sessionIndicator.classList.add('hidden');
+            return;
+        }
+        const displayNumber = Number.isInteger(state.sessionNumber) ? state.sessionNumber : state.sessionId;
+        dom.sessionIndicator.textContent = `会话 #${displayNumber}`;
+        dom.sessionIndicator.classList.remove('hidden');
+    }
+
+    function updateActiveSession(sessionId, sessionNumber, options = {}) {
+        const parsedId = Number(sessionId);
+        const parsedNumber = Number(sessionNumber);
+        const nextId = Number.isFinite(parsedId) ? parsedId : null;
+        const previousId = state.sessionId;
+        state.sessionId = nextId;
+        state.sessionNumber = Number.isFinite(parsedNumber) ? parsedNumber : null;
+        updateSessionIndicator();
+        if (options.updateUrl === false) {
+            return;
+        }
+        if (previousId === state.sessionId && !options.replace) {
+            return;
+        }
+        updateSessionInUrl(state.sessionId, { replace: Boolean(options.replace) });
+    }
 })();

+ 34 - 6
static/index.html

@@ -74,12 +74,31 @@
         </aside>
         <main class="main-panel">
             <header class="app-header">
-                <h1>ChatGPT-like Clone</h1>
+                <div class="header-title">
+                    <h1>ChatGPT-like Clone</h1>
+                    <span id="session-indicator" class="session-indicator hidden"></span>
+                </div>
                 <div class="header-actions">
+                    <div id="admin-role-indicator" class="admin-role-indicator hidden" title="管理员">
+                        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
+                            <path d="M12 2 4 6v6c0 5.25 3.4 10.74 8 12 4.6-1.26 8-6.75 8-12V6l-8-4zm0 2.18L18 7v4.99c0 4.38-2.84 8.98-6 10.1-3.16-1.12-6-5.72-6-10.1V7l6-2.82z" fill="currentColor"/>
+                            <path d="M12 7a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z" fill="currentColor"/>
+                        </svg>
+                    </div>
                     <span id="user-badge" class="user-badge"></span>
-                    <button id="export-btn" class="secondary-button" disabled>我的导出</button>
-                    <button id="admin-btn" class="secondary-button hidden">用户管理</button>
-                    <button id="logout-btn" class="secondary-button" disabled>退出登录</button>
+                    <div id="user-menu" class="user-menu hidden">
+                        <button type="button" id="user-menu-toggle" class="avatar-button" aria-haspopup="true" aria-expanded="false">
+                            <span id="user-avatar-initials" class="avatar-initials">A</span>
+                            <svg class="avatar-caret" viewBox="0 0 24 24" aria-hidden="true">
+                                <path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/>
+                            </svg>
+                        </button>
+                        <div id="user-menu-dropdown" class="user-menu-dropdown hidden" role="menu" aria-label="用户菜单">
+                            <button id="export-btn" class="menu-item" type="button" role="menuitem" disabled>我的导出</button>
+                            <button id="admin-btn" class="menu-item hidden" type="button" role="menuitem">用户管理</button>
+                            <button id="logout-btn" class="menu-item" type="button" role="menuitem" disabled>退出登录</button>
+                        </div>
+                    </div>
                 </div>
             </header>
             <section id="chat-messages" class="chat-messages"></section>
@@ -89,6 +108,7 @@
                     <label for="file-input" class="file-input-label">选择文件</label>
                     <input id="file-input" type="file" multiple accept=".jpg,.jpeg,.png,.txt,.pdf,.doc,.docx" class="file-input">
                     <button type="submit" id="send-btn" class="primary-button">发送</button>
+                    <button type="button" id="end-chat-btn" class="secondary-button danger hidden" title="提前结束此次对话">提前结束</button>
                 </div>
                 <div id="chat-status" class="chat-status" aria-live="polite"></div>
             </form>
@@ -98,7 +118,11 @@
         <div class="overlay-content">
             <div class="overlay-header">
                 <h2>用户管理</h2>
-                <button id="admin-close" class="icon-button" type="button">×</button>
+                <button id="admin-close" class="icon-button overlay-close" type="button" aria-label="关闭弹窗">
+                    <svg class="icon-close" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
+                        <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
+                    </svg>
+                </button>
             </div>
             <form id="admin-create-form" class="admin-form">
                 <h3>新增普通用户</h3>
@@ -128,7 +152,11 @@
         <div class="overlay-content">
             <div class="overlay-header">
                 <h2>我的导出</h2>
-                <button id="export-close" class="icon-button" type="button">×</button>
+                <button id="export-close" class="icon-button overlay-close" type="button" aria-label="关闭弹窗">
+                    <svg class="icon-close" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
+                        <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
+                    </svg>
+                </button>
             </div>
             <div id="export-list" class="admin-list"></div>
         </div>

+ 140 - 0
static/styles.css

@@ -83,6 +83,7 @@
     display: flex;
     align-items: center;
     gap: 8px;
+    float: right;
 }
 
 .user-badge {
@@ -90,6 +91,116 @@
     color: var(--secondary-text);
 }
 
+.header-title {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    width:300px;
+    float:left;
+}
+
+.session-indicator {
+    font-size: 14px;
+    color: var(--secondary-text);
+}
+
+.admin-role-indicator {
+    width: 36px;
+    height: 36px;
+    border-radius: 50%;
+    border: 1px solid var(--border-color);
+    background: #fff;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--primary);
+    box-shadow: var(--shadow);
+}
+
+.admin-role-indicator svg {
+    width: 18px;
+    height: 18px;
+}
+
+.user-menu {
+    position: relative;
+}
+
+.avatar-button {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    border: 1px solid var(--border-color);
+    border-radius: 999px;
+    padding: 6px 10px;
+    background: #fff;
+    cursor: pointer;
+    min-width: 0;
+}
+
+.avatar-initials {
+    width: 28px;
+    height: 28px;
+    border-radius: 50%;
+    background: var(--primary);
+    color: var(--primary-text);
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-weight: 600;
+}
+
+.avatar-caret {
+    width: 18px;
+    height: 18px;
+    color: var(--secondary-text);
+}
+
+.user-menu-dropdown {
+    position: absolute;
+    top: calc(100% + 8px);
+    right: 0;
+    min-width: 180px;
+    border-radius: 12px;
+    border: 1px solid var(--border-color);
+    background: #fff;
+    box-shadow: var(--shadow);
+    padding: 8px;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    opacity: 0;
+    transform: translateY(-6px);
+    pointer-events: none;
+    transition: opacity 0.2s ease, transform 0.2s ease;
+    z-index: 5;
+}
+
+.user-menu.open .user-menu-dropdown {
+    opacity: 1;
+    transform: translateY(0);
+    pointer-events: auto;
+}
+
+.menu-item {
+    border: none;
+    background: none;
+    padding: 8px 10px;
+    text-align: left;
+    border-radius: 8px;
+    font-size: 14px;
+    cursor: pointer;
+}
+
+.menu-item:hover:not(:disabled) {
+    background: #f1f3f5;
+}
+
+.menu-item:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+}
+
 .overlay {
     position: fixed;
     inset: 0;
@@ -128,6 +239,27 @@
     padding: 4px 8px;
 }
 
+.overlay-close {
+    border-radius: 50%;
+    border: 1px solid var(--border-color);
+    background: #f1f3f5;
+    width: 32px;
+    height: 32px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 18px;
+}
+
+.overlay-close:hover {
+    background: #e6e9ef;
+}
+
+.overlay-close .icon-close {
+    width: 16px;
+    height: 16px;
+}
+
 .admin-form {
     border: 1px solid var(--border-color);
     border-radius: 12px;
@@ -676,6 +808,14 @@ body {
     gap: 10px;
 }
 
+#send-btn {
+    margin-left: auto;
+}
+
+#end-chat-btn {
+    min-width: 110px;
+}
+
 .file-input-label {
     display: inline-flex;
     align-items: center;

Некоторые файлы не были показаны из-за большого количества измененных файлов