diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index fcf32cc..3fa79e5 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -36,8 +36,29 @@ scrollbar-color: rgba(255, 255, 255, 0.15) transparent; } -/* vitepress-openapi */ +/* ===== vitepress-oepnapi ===== */ + +/* Details */ +details { + padding: 10px 6px; + border-width: 1px; + border-radius: 6px; + border-color: transparent; + transition: all 0.3s ease; +} + +details[open] { + border-color: rgba(255, 255, 255, 0.3) +} + +details>summary { + text-align: center; + list-style: none; +} + +/* 底部 */ div.vitepress-openapi { + background: linear-gradient(45deg, hsla(58, 100%, 92%, 0.6), hsla(128, 100%, 75%, 0.5)); border: 1px solid #64ffc4; border-radius: 6px; margin: 48px auto 0; @@ -48,4 +69,4 @@ div.vitepress-openapi { div.vitepress-openapi p { line-height: 8px; -} \ No newline at end of file +} diff --git a/docs/dev/api-docs/[operationId].md b/docs/dev/api-docs/[operationId].md index 5797980..2f1c757 100644 --- a/docs/dev/api-docs/[operationId].md +++ b/docs/dev/api-docs/[operationId].md @@ -23,7 +23,7 @@ const operationId = route.data.params.operationId - \ No newline at end of file + diff --git a/src/nyahome/router/admin_router.py b/src/nyahome/router/admin_router.py index 598c1eb..faa4207 100644 --- a/src/nyahome/router/admin_router.py +++ b/src/nyahome/router/admin_router.py @@ -49,8 +49,17 @@ class VerifyEmail(BaseModel): verify_code: str -@admin_router.post("/login/name/") +@admin_router.post("/login/name/", name="用户登录") async def nyahome_login_name(user: UserLogin, session: Annotated[Session, Depends(get_session)]) -> ReturnDto: + """ + 使用用户名密码登录。 + + Raises: + HTTPException: 404 表示尝试登录的用户不存在。 + + Returns: + ReturnDto,其中 result 字段包含 `user_id` 和 `access_token` 两个字段。 + """ try: u: ModelUser = session.exec(select(ModelUser).where(ModelUser.name == user.username)).one() except NoResultFound: @@ -75,15 +84,28 @@ async def nyahome_login_name(user: UserLogin, session: Annotated[Session, Depend raise HTTPException(status_code=401, detail="验证失败,请检查用户名和密码是否正确") -@admin_router.get("/me/") +@admin_router.get("/me/", name="获取登录用户信息") async def nyahome_get_me(user: Annotated[ModelUser, Depends(verify_token)]) -> ModelUser: + """ + 获取当前登录的用户的详细信息。 + + Returns: + ModelUser + """ return user -@admin_router.post("/me/") +@admin_router.post("/me/", name="修改登录用户信息") async def nyahome_post_me( info: UserInfo, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)] ) -> ModelUser: + """ + 修改当前登录的用户的详细信息。 + 此端点可以修改除了用户密码、邮箱、手机号之外的大部分用户信息。 + + Returns: + ModelUser + """ user.name = info.name user.display_name = info.display_name user.avatar_url = info.avatar_url @@ -95,12 +117,21 @@ async def nyahome_post_me( return user -@admin_router.post("/me/password/") +@admin_router.post("/me/password/", name="修改用户密码") async def nyahome_change_password( change: ChangePassword, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)], ) -> ReturnDto: + """ + 修改用户密码。 + + Raises: + HTTPException: 400 提供的旧密码错误。 + + Returns: + 不重要的 ReturnDto,无异常本身即表示修改成功。 + """ if verify_password(change.old_password, user.password): user.password = save_password(change.new_password) change_ = SecureChange( @@ -116,12 +147,20 @@ async def nyahome_change_password( raise HTTPException(status_code=400, detail="修改密码需要提供旧的密码,但提供的旧密码错误。") from None -@admin_router.post("/me/email-verify/") +@admin_router.post("/me/email-verify/", name="验证并修改用户邮箱") async def nyahome_verify_email( to: VerifyEmail, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)], ) -> ReturnDto: + """ + 验证用户提供的邮箱以及验证码。 + 需要先通过 `/me/email-verify/send/` 发送验证码。验证码有五分钟有效期,为六位数字,需以字符串形式提供。 + + Returns: + ReturnDto,其中 success 字段表明是否成功。如果成功,则用户邮箱已被修改。 + 不返回完整的 ModelUser,WebUI 自行负责前端用户信息更新。 + """ success = await s_verify_email(user_id=user.id, address=to.to, verify_code=to.verify_code) if success: old_email = user.email @@ -141,23 +180,37 @@ async def nyahome_verify_email( return ReturnDto(success=success) -@admin_router.post("/me/email-verify/send/") +@admin_router.post("/me/email-verify/send/", name="发送修改邮箱验证码") async def nyahome_verify_email_send(to: SendEmail, user: Annotated[ModelUser, Depends(verify_token)]) -> ReturnDto: + """ + 请求对新的邮箱发送验证码。验证码有五分钟有效期,为六位数字。 + + Returns: + ReturnDto,其中 success 字段表明是否成功。 + """ success = await s_send_verify_email(user.id, to.to) return ReturnDto(success=success) -@admin_router.get("/me/secure_changes/") +@admin_router.get("/me/secure_changes/", name="获取用户安全变更记录") async def nyahome_get_secure_changes( user: Annotated[ModelUser, Depends(verify_token)], ) -> list[SecureChange]: + """ + 获取用户的安全变更记录。 + 安全变更记录包括:登录、修改密码、修改邮箱、修改手机号。 + + Returns: + SecureChange 列表。 + """ return json.loads(user.secure_changes) # type: ignore[no-any-return] -@admin_router.get("/site_config/") +@admin_router.get("/site_config/", name="获取 NyaHome 设置") async def get_site_config(user: Annotated[ModelUser, Depends(verify_token)]) -> dict[str, Any]: """ 获取 NyaHome 的设置。 + 需要管理员权限才能访问。 Raises: HTTPException: 403 表示请求用户非管理员。 @@ -170,13 +223,14 @@ async def get_site_config(user: Annotated[ModelUser, Depends(verify_token)]) -> return config_manager.get_config() -@admin_router.post("/site_config/") +@admin_router.post("/site_config/", name="修改 NyaHome 设置") async def set_site_config( user: Annotated[ModelUser, Depends(verify_token)], config_: dict[str, Any], ) -> dict[str, Any]: """ 设置 NyaHome 的设置。 + 需要管理员权限才能访问。 Raises: HTTPException: 403 表示请求用户非管理员。 @@ -191,7 +245,7 @@ async def set_site_config( return final_config -@admin_router.post("/email-test/") +@admin_router.post("/email-test/", name="测试邮件发送") async def nyahome_test_email(to: SendEmail, user: Annotated[ModelUser, Depends(verify_token)]) -> ReturnDto: """ NyaHome 管理员面板中的测试邮件端点。 diff --git a/src/nyahome/router/aii_router.py b/src/nyahome/router/aii_router.py index 74b18dc..59bd572 100644 --- a/src/nyahome/router/aii_router.py +++ b/src/nyahome/router/aii_router.py @@ -22,18 +22,37 @@ from .response_model import ReturnDto aii_router = APIRouter(tags=["Aii"], prefix="/aii") -@aii_router.get("/model/") +@aii_router.get("/model/", name="获取模型列表") async def get_all_model(session: Annotated[Session, Depends(get_session)]) -> ReturnDto: + """ + 获取 AI 模型列表。 + 此接口无需用户登录即可访问。 + + Returns: + 被 ReturnDto 包裹的 AiiModel 列表 + """ final_model_list = apply_get_models(session) return ReturnDto(result=final_model_list) -@aii_router.post("/model/") +@aii_router.post("/model/", name="添加模型") async def add_model( model: AiiModelPublic, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)], ) -> ReturnDto: + """ + 添加新的 AI 模型。需要基于已添加的模型提供商。 + 此接口需要管理员访问。 + 添加模型时不会进行可用性检查,因此 WebUI 在前端实现了检查按钮。此端点不会负责检查。 + + Raises: + HTTPException: 401 用户无权限管理模型(未登录或非管理员) + HTTPException: 404 模型提供商不存在 + + Returns: + 被 ReturnDto 包裹的、添加的 AiiModel + """ if not user.is_admin: raise HTTPException(status_code=401, detail="用户无权限管理模型。") from None @@ -53,18 +72,36 @@ async def add_model( return ReturnDto(result=z_aii_model(am)) -@aii_router.get("/provider/") +@aii_router.get("/provider/", name="获取提供商列表") async def get_all_provider(session: Annotated[Session, Depends(get_session)]) -> ReturnDto: + """ + 获取 AI 模型提供商列表。 + 此接口无需用户登录即可访问。 + + Returns: + 被 ReturnDto 包裹的 AiiProvider 列表 + """ aii_providers = session.exec(select(AiiProvider)).all() return ReturnDto(result=[z_aii_provider(ap) for ap in aii_providers]) -@aii_router.post("/provider/") +@aii_router.post("/provider/", name="添加提供商") async def add_provider( provider: AiiProviderPublic, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)], ) -> ReturnDto: + """ + 添加新的 AI 模型提供商。 + 此接口需要管理员才能访问。 + 添加提供商时不会进行可用性检查,因此 WebUI 在前端实现了检查按钮。此端点不会负责检查。 + + Raises: + HTTPException: 401 表示用户未登录或非管理员。 + + Returns: + 被 ReturnDto 包裹的、添加的 AiiProvider + """ if not user.is_admin: raise HTTPException(status_code=401, detail="用户无权限管理模型。") from None ap = AiiProvider(name=provider.name, base_url=provider.base_url, api_key=provider.api_key) @@ -74,10 +111,20 @@ async def add_provider( return ReturnDto(result=z_aii_provider(ap)) -@aii_router.get("/provider/{id_}/remote/models/") +@aii_router.get("/provider/{id_}/remote/models/", name="获取提供商远端模型") async def get_provider_remote_models( id_: int, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)] ) -> ReturnDto: + """ + 查看指定模型提供商提供的远端模型列表。并非添加到 NyaHome 的模型列表。 + 此接口需要管理员才能访问。 + + Raises: + HTTPException: 401 表示用户未登录或非管理员。 + + Returns: + 被 ReturnDto 包裹的、模型名称字符串列表 + """ if not user.is_admin: raise HTTPException(status_code=401, detail="用户无权限管理模型。") from None try: @@ -89,16 +136,12 @@ async def get_provider_remote_models( return ReturnDto(result=[m["id"] for m in models]) -@aii_router.get("/provider/{id_}/remote/model/{model_name}/") +@aii_router.get("/provider/{id_}/remote/model/{model_name}/", name="检查指定远端模型可用性") async def check_remote_provider_model( id_: int, model_name: str, session: Annotated[Session, Depends(get_session)] ) -> ReturnDto: """ - 检测指定提供商的指定名称模型是否可用。 - Args: - id_: 模型提供商 ID。 - model_name: 模型名称。 - session: 数据库连接对象。 + 检测指定提供商的指定名称远端模型是否可用。 Raises: HTTPException: 404 表明提供商 ID 未找到。 @@ -113,8 +156,14 @@ async def check_remote_provider_model( return ReturnDto(result=await s_check_remote_model(model_name, ap.base_url, ap.api_key)) -@aii_router.post("/remote/provider/check/") +@aii_router.post("/remote/provider/check/", name="检查指定提供商可用性") async def check_remote_provider(provider: AiiProviderPublic) -> ReturnDto: + """ + 检查指定提供商是否可用。会返回提供商提供的模型数量作为测试。 + + Returns: + ReturnDto,其中 success 字段为布尔值,表明可用状态;如果为真,result 字段是整型模型数量。 + """ try: count = len(await s_list_remote_provider_models(provider.base_url, provider.api_key)) return ReturnDto(result=count) diff --git a/src/nyahome/router/chatroom_router.py b/src/nyahome/router/chatroom_router.py index 2aa16c0..9203288 100644 --- a/src/nyahome/router/chatroom_router.py +++ b/src/nyahome/router/chatroom_router.py @@ -31,7 +31,7 @@ from .response_model import ReturnDto chatroom_router = APIRouter(tags=["Chatroom"], prefix="/chatroom") -@chatroom_router.get("/{id_}/") +@chatroom_router.get("/{id_}/", name="获取指定聊天室") async def get_chatroom( id_: int, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)] ) -> ReturnDto: @@ -54,7 +54,7 @@ async def get_chatroom( return ReturnDto(result=cr.model_dump()) -@chatroom_router.get("/") +@chatroom_router.get("/", name="获取聊天室列表") async def get_all_chatroom( user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)] ) -> ReturnDto: @@ -68,7 +68,7 @@ async def get_all_chatroom( return ReturnDto(result=[cr.model_dump(exclude={"content", "script"}) for cr in crs]) -@chatroom_router.post("/") +@chatroom_router.post("/", name="创建聊天室") async def create_chatroom( chatroom: ChatroomPublic, user: Annotated[ModelUser, Depends(verify_token)], @@ -96,7 +96,7 @@ async def create_chatroom( return ReturnDto(result=cr.model_dump()) -@chatroom_router.post("/{id_}/") +@chatroom_router.post("/{id_}/", name="修改指定聊天室") async def edit_chatroom( id_: int, chatroom: ChatroomPublic, @@ -131,13 +131,14 @@ async def edit_chatroom( cr.feature_image = chatroom.feature_image cr.script_template_id = chatroom.script_template_id cr.script_template_version = chatroom.script_template_version + cr.default_model_id = chatroom.default_model_id session.add(cr) session.commit() session.refresh(cr) return ReturnDto(result=cr.model_dump()) -@chatroom_router.post("/{id_}/script/") +@chatroom_router.post("/{id_}/script/", name="修改聊天室脚本") async def update_chatroom_script( id_: int, script: ChatScript, @@ -172,7 +173,7 @@ async def update_chatroom_script( return ReturnDto(result=script.model_dump()) -@chatroom_router.post("/{id_}/chat/") +@chatroom_router.post("/{id_}/chat/", name="聊天室发起模型创作") async def post_chatroom_chat( id_: int, chat: ChatroomChat, @@ -181,6 +182,7 @@ async def post_chatroom_chat( ) -> StreamingResponse: """ 在聊天室中发送新的用户消息,流式返回 AI 调用结果。 + 即:调用模型发起创作。 Args: id_: (路径参数)聊天室 ID @@ -203,7 +205,7 @@ async def post_chatroom_chat( raise e -@chatroom_router.post("/{id_}/chat/accept/") +@chatroom_router.post("/{id_}/chat/accept/", name="聊天室保存模型创作") async def accept_chatroom_chat( id_: int, accept: ChatroomChatAccept, @@ -212,6 +214,7 @@ async def accept_chatroom_chat( ) -> ReturnDto: """ 此端点不负责调用 AI 生成输出,而是用于保存一对用户消息和 AI 输出到聊天室 content 的最后。 + 需要提供用户消息、AI 消息和创作模式。 Raises: HTTPException: 404 表明未找到聊天室。 @@ -232,7 +235,7 @@ async def accept_chatroom_chat( return ReturnDto(result=cr.model_dump()) -@chatroom_router.post("/{id_}/chat/edit/") +@chatroom_router.post("/{id_}/chat/edit/", name="聊天室编辑消息") async def edit_chatroom_chat( id_: int, edit: ChatroomChatEdit, @@ -241,6 +244,7 @@ async def edit_chatroom_chat( ) -> ReturnDto: """ 此端点不负责调用 AI 生成输出,而是用于修改一条已经保存在聊天记录中的消息。 + 需要提供消息类型(用户/AI)、旧消息和新消息,以便进行替换。 Raises: HTTPException: 404 表明未找到聊天室,400 表明聊天记录匹配失败,未更新。 @@ -264,7 +268,7 @@ async def edit_chatroom_chat( raise HTTPException(status_code=400, detail=str(e)) from e -@chatroom_router.post("/{id_}/chat/delete/") +@chatroom_router.post("/{id_}/chat/delete/", name="聊天室删除消息") async def delete_chatroom_chat( id_: int, delete: ChatroomChatDelete, @@ -273,6 +277,7 @@ async def delete_chatroom_chat( ) -> ReturnDto: """ 此端点不负责调用 AI 生成输出,而是用于删除一条已经保存在聊天记录中的消息。关联的 user 或 aii 消息会一并删除。 + 需要提供消息和消息类型(用户/AI)。用户消息和 AI 消息是一对一成对的,所以总是会删除关联的一对(两条)消息。 Raises: HTTPException: 404 表明未找到聊天室,400 表明聊天记录匹配失败,未更新。 diff --git a/src/nyahome/router/file_router.py b/src/nyahome/router/file_router.py index f6e1937..d9d1f55 100644 --- a/src/nyahome/router/file_router.py +++ b/src/nyahome/router/file_router.py @@ -13,11 +13,17 @@ from .auth import verify_token file_router = APIRouter(tags=["File"], prefix="/file") -@file_router.get("/") +@file_router.get("/", name="获取文件列表") async def get_files( user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)], ) -> Sequence[ModelUploadFile]: + """ + 获取用户上传的文件列表。 + + Returns: + ModelUploadFile 列表。 + """ files: Sequence[ModelUploadFile] = session.exec( select(ModelUploadFile).where(ModelUploadFile.uploader_id == user.id) ).all() @@ -25,12 +31,29 @@ async def get_files( return files -@file_router.post("/upload/") +@file_router.post("/upload/", name="上传文件") async def file_upload( file: Annotated[UploadFile, File()], user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)], ) -> ModelUploadFile: + """ + 仅允许单文件上传。 + 文件存储在 `.nyahome/contents` 目录下,由 uuid4 重命名,保留原拓展名。 + 允许上传的文件拓展名由 NyaHome 设置 `allow_upload_file_extensions` 约束。 + 对于不允许上传的文件类型,将抛出 400 错误。 + + Args: + file: 文件对象 + user: 经验证的用户 + session: 数据库连接对象 + + Raises: + HTTPException: 400 表示上传的文件类型不允许。文件类型仅由拓展名判断,不检查 MIME。 + + Returns: + ModelUploadFile + """ try: safe_name = s_get_safe_filename(file.filename) # type: ignore[arg-type] dest_path = UPLOAD_DIR / safe_name diff --git a/src/nyahome/server.py b/src/nyahome/server.py index 39ac20a..87e20db 100644 --- a/src/nyahome/server.py +++ b/src/nyahome/server.py @@ -5,16 +5,10 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Any, AsyncGenerator -from dotenv import load_dotenv from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from nyahome.config import config_manager -from nyahome.core.otp_store import email_otp_memory_store -from nyahome.core.send_email import email_sender_queue -from nyahome.core.task import init_admin_user -from nyahome.database import create_db from nyahome.router import admin_router, aii_router, chatroom_router, file_router, webui_router logger = logging.getLogger(__name__) @@ -22,8 +16,15 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app_: FastAPI) -> AsyncGenerator[None, Any]: - load_dotenv(Path.cwd() / ".nyahome" / ".env") + # 在生命周期函数内先加载环境变量,再局部导入 nyahome 核心模块 logger.info("🚀 服务启动中...") + + from nyahome.config import config_manager + from nyahome.core.otp_store import email_otp_memory_store + from nyahome.core.send_email import email_sender_queue + from nyahome.core.task import init_admin_user + from nyahome.database import create_db + create_db() await asyncio.gather(init_admin_user(), config_manager.async_load_config()) email_sender_queue.start()