From 0d63d0e6d197cefc43d296b9e74b22dd6aff363f Mon Sep 17 00:00:00 2001 From: bdim404 Date: Fri, 13 Mar 2026 12:58:28 +0800 Subject: [PATCH] feat: add NSFW script generation feature and Grok API configuration --- qwen3-tts-backend/api/audiobook.py | 89 ++++++ qwen3-tts-backend/api/auth.py | 11 +- qwen3-tts-backend/api/users.py | 60 +++- qwen3-tts-backend/core/audiobook_service.py | 149 +++++++++- qwen3-tts-backend/core/llm_service.py | 55 +++- qwen3-tts-backend/db/crud.py | 14 +- qwen3-tts-backend/db/database.py | 1 + qwen3-tts-backend/db/models.py | 1 + qwen3-tts-backend/schemas/audiobook.py | 20 ++ qwen3-tts-backend/schemas/user.py | 3 + .../src/components/users/UserDialog.tsx | 25 ++ .../src/components/users/UserTable.tsx | 27 +- qwen3-tts-frontend/src/lib/api.ts | 20 ++ qwen3-tts-frontend/src/lib/api/audiobook.ts | 30 ++ qwen3-tts-frontend/src/lib/constants.ts | 2 + .../src/locales/en-US/settings.json | 6 +- .../src/locales/en-US/user.json | 3 + .../src/locales/ja-JP/settings.json | 6 +- .../src/locales/ja-JP/user.json | 3 + .../src/locales/ko-KR/settings.json | 6 +- .../src/locales/ko-KR/user.json | 3 + .../src/locales/zh-CN/settings.json | 6 +- .../src/locales/zh-CN/user.json | 3 + .../src/locales/zh-TW/settings.json | 6 +- .../src/locales/zh-TW/user.json | 3 + qwen3-tts-frontend/src/pages/Audiobook.tsx | 261 +++++++++++++++++- qwen3-tts-frontend/src/pages/Settings.tsx | 72 +++++ qwen3-tts-frontend/src/types/auth.ts | 1 + 28 files changed, 850 insertions(+), 36 deletions(-) diff --git a/qwen3-tts-backend/api/audiobook.py b/qwen3-tts-backend/api/audiobook.py index d48eca0..9cf7f8c 100644 --- a/qwen3-tts-backend/api/audiobook.py +++ b/qwen3-tts-backend/api/audiobook.py @@ -26,6 +26,8 @@ from schemas.audiobook import ( ScriptGenerationRequest, SynopsisGenerationRequest, ContinueScriptRequest, + NsfwSynopsisGenerationRequest, + NsfwScriptGenerationRequest, ) from core.config import settings @@ -33,6 +35,13 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/audiobook", tags=["audiobook"]) +async def require_nsfw(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + from db.crud import can_user_use_nsfw + if not can_user_use_nsfw(current_user): + raise HTTPException(status_code=403, detail="NSFW access not granted") + return current_user + + def _project_to_response(project) -> AudiobookProjectResponse: return AudiobookProjectResponse( id=project.id, @@ -268,6 +277,86 @@ async def continue_script( return {"message": f"Continuing script generation ({additional_chapters} chapters)", "project_id": project_id} +@router.post("/projects/generate-synopsis-nsfw") +async def generate_synopsis_nsfw( + data: NsfwSynopsisGenerationRequest, + current_user: User = Depends(require_nsfw), + db: Session = Depends(get_db), +): + from db.crud import get_system_setting + if not get_system_setting(db, "grok_api_key") or not get_system_setting(db, "grok_base_url"): + raise HTTPException(status_code=400, detail="Grok config not set. Please configure Grok API key first.") + + from core.audiobook_service import _get_grok_service + llm = _get_grok_service(db) + + system_prompt = ( + "你是一位专业的成人小说策划师,擅长根据创作参数生成引人入胜的故事简介。" + "请根据用户提供的类型、风格、主角、冲突等参数,生成一段200-400字的中文故事简介。" + "简介需涵盖:世界观背景、主角基本情况、核心矛盾冲突、故事基调。" + "直接输出简介正文,不要加任何前缀标题或说明文字。" + ) + parts = [f"类型:{data.genre}"] + if data.subgenre: + parts.append(f"子类型:{data.subgenre}") + if data.protagonist_type: + parts.append(f"主角类型:{data.protagonist_type}") + if data.tone: + parts.append(f"故事基调:{data.tone}") + if data.conflict_scale: + parts.append(f"冲突规模:{data.conflict_scale}") + parts.append(f"角色数量:约{data.num_characters}个主要角色") + parts.append(f"故事体量:约{data.num_chapters}章") + user_message = "\n".join(parts) + "\n\n请生成故事简介:" + + try: + synopsis = await llm.chat(system_prompt, user_message) + except Exception as e: + logger.error(f"NSFW synopsis generation failed: {e}") + raise HTTPException(status_code=500, detail=f"Grok generation failed: {str(e)}") + + return {"synopsis": synopsis} + + +@router.post("/projects/generate-script-nsfw", response_model=AudiobookProjectResponse, status_code=status.HTTP_201_CREATED) +async def create_nsfw_script_project( + data: NsfwScriptGenerationRequest, + current_user: User = Depends(require_nsfw), + db: Session = Depends(get_db), +): + from db.crud import get_system_setting + if not get_system_setting(db, "grok_api_key") or not get_system_setting(db, "grok_base_url"): + raise HTTPException(status_code=400, detail="Grok config not set. Please configure Grok API key first.") + + script_config = data.model_dump() + script_config["nsfw_mode"] = True + + project = crud.create_audiobook_project( + db=db, + user_id=current_user.id, + title=data.title, + source_type="ai_generated", + script_config=script_config, + ) + + from core.audiobook_service import generate_ai_script_nsfw + from core.database import SessionLocal + + project_id = project.id + user_id = current_user.id + + async def run(): + async_db = SessionLocal() + try: + db_user = crud.get_user_by_id(async_db, user_id) + await generate_ai_script_nsfw(project_id, db_user, async_db) + finally: + async_db.close() + + asyncio.create_task(run()) + return _project_to_response(project) + + @router.get("/projects/{project_id}", response_model=AudiobookProjectDetail) async def get_project( project_id: int, diff --git a/qwen3-tts-backend/api/auth.py b/qwen3-tts-backend/api/auth.py index 2abed3e..2b0f3d5 100644 --- a/qwen3-tts-backend/api/auth.py +++ b/qwen3-tts-backend/api/auth.py @@ -14,7 +14,7 @@ from core.security import ( decode_access_token ) from db.database import get_db -from db.crud import get_user_by_username, get_user_by_email, create_user, change_user_password, get_user_preferences, update_user_preferences, can_user_use_local_model, get_system_setting +from db.crud import get_user_by_username, get_user_by_email, create_user, change_user_password, get_user_preferences, update_user_preferences, can_user_use_local_model, can_user_use_nsfw, get_system_setting from schemas.user import User, UserCreate, Token, PasswordChange, AliyunKeyVerifyResponse, UserPreferences, UserPreferencesResponse from schemas.audiobook import LLMConfigResponse @@ -223,3 +223,12 @@ async def get_llm_config( model=get_system_setting(db, "llm_model"), has_key=bool(get_system_setting(db, "llm_api_key")), ) + + +@router.get("/nsfw-access") +@limiter.limit("30/minute") +async def get_nsfw_access( + request: Request, + current_user: Annotated[User, Depends(get_current_user)], +): + return {"has_access": can_user_use_nsfw(current_user)} diff --git a/qwen3-tts-backend/api/users.py b/qwen3-tts-backend/api/users.py index 450fb6d..52fab87 100644 --- a/qwen3-tts-backend/api/users.py +++ b/qwen3-tts-backend/api/users.py @@ -18,7 +18,7 @@ from db.crud import ( delete_user ) from schemas.user import User, UserCreateByAdmin, UserUpdate, UserListResponse, AliyunKeyUpdate, AliyunKeyVerifyResponse -from schemas.audiobook import LLMConfigUpdate, LLMConfigResponse +from schemas.audiobook import LLMConfigUpdate, LLMConfigResponse, NsfwSynopsisGenerationRequest, NsfwScriptGenerationRequest router = APIRouter(prefix="/users", tags=["users"]) limiter = Limiter(key_func=get_remote_address) @@ -147,7 +147,8 @@ async def update_user_info( hashed_password=hashed_password, is_active=user_data.is_active, is_superuser=user_data.is_superuser, - can_use_local_model=user_data.can_use_local_model + can_use_local_model=user_data.can_use_local_model, + can_use_nsfw=user_data.can_use_nsfw, ) if not user: @@ -290,3 +291,58 @@ async def delete_system_llm_config( delete_system_setting(db, "llm_base_url") delete_system_setting(db, "llm_model") return {"message": "LLM config deleted"} + + +@router.put("/system/grok-config") +@limiter.limit("10/minute") +async def set_system_grok_config( + request: Request, + config: LLMConfigUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_superuser) +): + from core.security import encrypt_api_key + from core.llm_service import GrokLLMService + from db.crud import set_system_setting + + api_key = config.api_key.strip() + base_url = config.base_url.strip() + model = config.model.strip() + grok = GrokLLMService(base_url=base_url, api_key=api_key, model=model) + try: + await grok.chat("You are a test assistant.", "Reply with 'ok'.") + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Grok API validation failed: {e}") + set_system_setting(db, "grok_api_key", encrypt_api_key(api_key)) + set_system_setting(db, "grok_base_url", base_url) + set_system_setting(db, "grok_model", model) + return {"message": "Grok config updated"} + + +@router.get("/system/grok-config", response_model=LLMConfigResponse) +@limiter.limit("30/minute") +async def get_system_grok_config( + request: Request, + db: Session = Depends(get_db), + _: User = Depends(require_superuser) +): + from db.crud import get_system_setting + return LLMConfigResponse( + base_url=get_system_setting(db, "grok_base_url"), + model=get_system_setting(db, "grok_model"), + has_key=bool(get_system_setting(db, "grok_api_key")), + ) + + +@router.delete("/system/grok-config") +@limiter.limit("10/minute") +async def delete_system_grok_config( + request: Request, + db: Session = Depends(get_db), + _: User = Depends(require_superuser) +): + from db.crud import delete_system_setting + delete_system_setting(db, "grok_api_key") + delete_system_setting(db, "grok_base_url") + delete_system_setting(db, "grok_model") + return {"message": "Grok config deleted"} diff --git a/qwen3-tts-backend/core/audiobook_service.py b/qwen3-tts-backend/core/audiobook_service.py index 2b68284..781439d 100644 --- a/qwen3-tts-backend/core/audiobook_service.py +++ b/qwen3-tts-backend/core/audiobook_service.py @@ -8,7 +8,7 @@ from typing import Optional from sqlalchemy.orm import Session from core.config import settings -from core.llm_service import LLMService +from core.llm_service import LLMService, GrokLLMService from core import progress_store as ps from db import crud from db.models import AudiobookProject, AudiobookCharacter, User @@ -44,6 +44,20 @@ def _get_llm_service(db: Session) -> LLMService: return LLMService(base_url=base_url, api_key=api_key, model=model) +def _get_grok_service(db: Session) -> GrokLLMService: + from core.security import decrypt_api_key + from db.crud import get_system_setting + api_key_encrypted = get_system_setting(db, "grok_api_key") + base_url = get_system_setting(db, "grok_base_url") + model = get_system_setting(db, "grok_model") or "grok-4" + if not api_key_encrypted or not base_url: + raise ValueError("Grok config not set. Please configure Grok API key and base URL in admin settings.") + api_key = decrypt_api_key(api_key_encrypted) + if not api_key: + raise ValueError("Failed to decrypt Grok API key.") + return GrokLLMService(base_url=base_url, api_key=api_key, model=model) + + def _get_gendered_instruct(gender: Optional[str], base_instruct: str) -> str: """Ensure the instruction sent to the TTS model has explicit gender cues if known.""" if not gender or gender == "未知": @@ -1472,3 +1486,136 @@ async def generate_character_preview(project_id: int, char_id: int, user: User, except Exception as e: logger.error(f"Failed to generate preview for char {char_id}: {e}") raise + + +async def generate_ai_script_nsfw(project_id: int, user: User, db: Session) -> None: + from core.database import SessionLocal + + project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first() + if not project or not project.script_config: + return + + key = str(project_id) + ps.reset(key) + cfg = project.script_config + + try: + crud.update_audiobook_project_status(db, project_id, "analyzing") + ps.append_line(key, f"[NSFW剧本] 项目「{project.title}」开始生成剧本") + + llm = _get_grok_service(db) + _llm_model = crud.get_system_setting(db, "grok_model") or "grok-4" + _user_id = user.id + + def _log_usage(prompt_tokens: int, completion_tokens: int) -> None: + log_db = SessionLocal() + try: + crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens, + model=_llm_model, context="nsfw_script_generate") + finally: + log_db.close() + + genre = cfg.get("genre", "") + subgenre = cfg.get("subgenre", "") + premise = cfg.get("premise", "") + style = cfg.get("style", "") + num_characters = cfg.get("num_characters", 5) + num_chapters = cfg.get("num_chapters", 8) + + ps.append_line(key, f"\n[Step 1] 生成 {num_characters} 个角色...\n") + ps.append_line(key, "") + + def on_token(token: str) -> None: + ps.append_token(key, token) + + characters_data = await llm.generate_story_characters( + genre=genre, subgenre=subgenre, premise=premise, style=style, + num_characters=num_characters, usage_callback=_log_usage, + ) + + has_narrator = any(c.get("name") in ("narrator", "旁白") for c in characters_data) + if not has_narrator: + characters_data.insert(0, { + "name": "旁白", + "gender": "未知", + "description": "第三人称旁白叙述者", + "instruct": ( + "音色信息:浑厚醇厚的男性中低音,嗓音饱满有力,带有传统说书人的磁性与感染力\n" + "身份背景:中国传统说书艺人,精通评书、章回小说叙述艺术,深谙故事节奏与听众心理\n" + "年龄设定:中年男性,四五十岁,声音历经岁月沉淀,成熟稳重而不失活力\n" + "外貌特征:面容沉稳,气度从容,台风大气,给人以可信赖的叙述者印象\n" + "性格特质:沉稳睿智,叙事冷静客观,情到深处能引发共鸣,不动声色间娓娓道来\n" + "叙事风格:语速适中偏慢,抑扬顿挫,擅长铺垫悬念,停顿恰到好处,语气庄重而生动,富有画面感" + ) + }) + + ps.append_line(key, f"\n\n[完成] 角色列表:{', '.join(c.get('name', '') for c in characters_data)}") + + crud.delete_audiobook_segments(db, project_id) + crud.delete_audiobook_characters(db, project_id) + + backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun" + + for char_data in characters_data: + name = char_data.get("name", "旁白") + if name == "narrator": + name = "旁白" + instruct = char_data.get("instruct", "") + description = char_data.get("description", "") + gender = char_data.get("gender") or ("未知" if name == "旁白" else None) + try: + voice_design = crud.create_voice_design( + db=db, + user_id=user.id, + name=f"[有声书] {project.title} - {name}", + instruct=instruct, + backend_type=backend_type, + preview_text=description[:100] if description else None, + ) + crud.create_audiobook_character( + db=db, + project_id=project_id, + name=name, + gender=gender, + description=description, + instruct=instruct, + voice_design_id=voice_design.id, + ) + except Exception as e: + logger.error(f"Failed to create char/voice for {name}: {e}") + + crud.update_audiobook_project_status(db, project_id, "characters_ready") + ps.append_line(key, f"\n[状态] 角色创建完成,请确认角色后继续生成剧本") + ps.mark_done(key) + + user_id = user.id + + async def _generate_all_previews(): + temp_db = SessionLocal() + try: + characters = crud.list_audiobook_characters(temp_db, project_id) + char_ids = [c.id for c in characters] + finally: + temp_db.close() + if not char_ids: + return + sem = asyncio.Semaphore(3) + async def _gen(char_id: int): + async with sem: + local_db = SessionLocal() + try: + db_user = crud.get_user_by_id(local_db, user_id) + await generate_character_preview(project_id, char_id, db_user, local_db) + except Exception as e: + logger.error(f"Background preview failed for char {char_id}: {e}") + finally: + local_db.close() + await asyncio.gather(*[_gen(cid) for cid in char_ids]) + + asyncio.create_task(_generate_all_previews()) + + except Exception as e: + logger.error(f"generate_ai_script_nsfw failed for project {project_id}: {e}", exc_info=True) + ps.append_line(key, f"\n[错误] {e}") + ps.mark_done(key) + crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e)) diff --git a/qwen3-tts-backend/core/llm_service.py b/qwen3-tts-backend/core/llm_service.py index f694f21..61b491c 100644 --- a/qwen3-tts-backend/core/llm_service.py +++ b/qwen3-tts-backend/core/llm_service.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import re from typing import Any, Callable, Dict, Optional import httpx @@ -8,6 +9,22 @@ import httpx logger = logging.getLogger(__name__) +def strip_grok_thinking(text: str) -> str: + lines = text.split('\n') + cleaned = [] + for line in lines: + if line.startswith('> '): + continue + cleaned.append(line) + result = [] + for line in cleaned: + if result and line and not line.startswith('【') and result[-1] != '': + result[-1] += line + else: + result.append(line) + return '\n'.join(result).strip() + + class LLMService: def __init__(self, base_url: str, api_key: str, model: str): self.base_url = base_url.rstrip("/") @@ -68,11 +85,15 @@ class LLMService: if not raw: raise ValueError("LLM returned empty response") if raw.startswith("```"): - lines = raw.split("\n") - inner = lines[1:] - if inner and inner[-1].strip().startswith("```"): - inner = inner[:-1] - raw = "\n".join(inner).strip() + m = re.search(r'^```[a-z]*\n?([\s\S]*?)```\s*$', raw) + if m: + raw = m.group(1).strip() + else: + lines = raw.split("\n") + inner = lines[1:] + if inner and inner[-1].strip().startswith("```"): + inner = inner[:-1] + raw = "\n".join(inner).strip() if not raw: raise ValueError("LLM returned empty JSON after stripping markdown") try: @@ -115,11 +136,15 @@ class LLMService: if not raw: raise ValueError("LLM returned empty response") if raw.startswith("```"): - lines = raw.split("\n") - inner = lines[1:] - if inner and inner[-1].strip().startswith("```"): - inner = inner[:-1] - raw = "\n".join(inner).strip() + m = re.search(r'^```[a-z]*\n?([\s\S]*?)```\s*$', raw) + if m: + raw = m.group(1).strip() + else: + lines = raw.split("\n") + inner = lines[1:] + if inner and inner[-1].strip().startswith("```"): + inner = inner[:-1] + raw = "\n".join(inner).strip() if not raw: raise ValueError("LLM returned empty JSON after stripping markdown") try: @@ -379,3 +404,13 @@ class LLMService: if isinstance(result, list): return result return [] + + +class GrokLLMService(LLMService): + async def stream_chat(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192, usage_callback=None) -> str: + full_text = await super().stream_chat(system_prompt, user_message, on_token, max_tokens=max_tokens, usage_callback=usage_callback) + return strip_grok_thinking(full_text) + + async def chat(self, system_prompt: str, user_message: str, usage_callback=None) -> str: + full_text = await super().chat(system_prompt, user_message, usage_callback=usage_callback) + return strip_grok_thinking(full_text) diff --git a/qwen3-tts-backend/db/crud.py b/qwen3-tts-backend/db/crud.py index 50f91a2..d7d2197 100644 --- a/qwen3-tts-backend/db/crud.py +++ b/qwen3-tts-backend/db/crud.py @@ -32,14 +32,16 @@ def create_user_by_admin( email: str, hashed_password: str, is_superuser: bool = False, - can_use_local_model: bool = False + can_use_local_model: bool = False, + can_use_nsfw: bool = False ) -> User: user = User( username=username, email=email, hashed_password=hashed_password, is_superuser=is_superuser, - can_use_local_model=can_use_local_model + can_use_local_model=can_use_local_model, + can_use_nsfw=can_use_nsfw ) db.add(user) db.commit() @@ -62,7 +64,8 @@ def update_user( hashed_password: Optional[str] = None, is_active: Optional[bool] = None, is_superuser: Optional[bool] = None, - can_use_local_model: Optional[bool] = None + can_use_local_model: Optional[bool] = None, + can_use_nsfw: Optional[bool] = None ) -> Optional[User]: user = get_user_by_id(db, user_id) if not user: @@ -80,6 +83,8 @@ def update_user( user.is_superuser = is_superuser if can_use_local_model is not None: user.can_use_local_model = can_use_local_model + if can_use_nsfw is not None: + user.can_use_nsfw = can_use_nsfw user.updated_at = datetime.utcnow() db.commit() @@ -273,6 +278,9 @@ def update_system_setting(db: Session, key: str, value: dict) -> SystemSettings: def can_user_use_local_model(user: User) -> bool: return user.is_superuser or user.can_use_local_model +def can_user_use_nsfw(user: User) -> bool: + return user.is_superuser or user.can_use_nsfw + def create_voice_design( db: Session, user_id: int, diff --git a/qwen3-tts-backend/db/database.py b/qwen3-tts-backend/db/database.py index 4fb0ac6..e9d6cf7 100644 --- a/qwen3-tts-backend/db/database.py +++ b/qwen3-tts-backend/db/database.py @@ -44,6 +44,7 @@ def init_db(): "ALTER TABLE audiobook_segments ADD COLUMN emo_text VARCHAR(20)", "ALTER TABLE audiobook_segments ADD COLUMN emo_alpha REAL", "ALTER TABLE audiobook_projects ADD COLUMN script_config JSON", + "ALTER TABLE users ADD COLUMN can_use_nsfw BOOLEAN DEFAULT FALSE NOT NULL", ]: try: conn.execute(__import__("sqlalchemy").text(col_def)) diff --git a/qwen3-tts-backend/db/models.py b/qwen3-tts-backend/db/models.py index bb28469..827a9f2 100644 --- a/qwen3-tts-backend/db/models.py +++ b/qwen3-tts-backend/db/models.py @@ -39,6 +39,7 @@ class User(Base): llm_base_url = Column(String(500), nullable=True) llm_model = Column(String(200), nullable=True) can_use_local_model = Column(Boolean, default=False, nullable=False) + can_use_nsfw = Column(Boolean, default=False, nullable=False) user_preferences = Column(JSON, nullable=True, default=lambda: {"default_backend": "aliyun", "onboarding_completed": False}) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/qwen3-tts-backend/schemas/audiobook.py b/qwen3-tts-backend/schemas/audiobook.py index 221a03c..822bb0f 100644 --- a/qwen3-tts-backend/schemas/audiobook.py +++ b/qwen3-tts-backend/schemas/audiobook.py @@ -131,3 +131,23 @@ class LLMConfigResponse(BaseModel): base_url: Optional[str] = None model: Optional[str] = None has_key: bool + + +class NsfwSynopsisGenerationRequest(BaseModel): + genre: str + subgenre: str = "" + protagonist_type: str = "" + tone: str = "" + conflict_scale: str = "" + num_characters: int = 5 + num_chapters: int = 8 + + +class NsfwScriptGenerationRequest(BaseModel): + title: str + genre: str + subgenre: str = "" + premise: str + style: str = "" + num_characters: int = 5 + num_chapters: int = 8 diff --git a/qwen3-tts-backend/schemas/user.py b/qwen3-tts-backend/schemas/user.py index 585e79c..0ca61b9 100644 --- a/qwen3-tts-backend/schemas/user.py +++ b/qwen3-tts-backend/schemas/user.py @@ -33,6 +33,7 @@ class User(UserBase): is_active: bool is_superuser: bool can_use_local_model: bool + can_use_nsfw: bool = False created_at: datetime model_config = ConfigDict(from_attributes=True) @@ -41,6 +42,7 @@ class UserCreateByAdmin(UserBase): password: str = Field(..., min_length=8, max_length=128) is_superuser: bool = False can_use_local_model: bool = False + can_use_nsfw: bool = False @field_validator('password') @classmethod @@ -60,6 +62,7 @@ class UserUpdate(BaseModel): is_active: Optional[bool] = None is_superuser: Optional[bool] = None can_use_local_model: Optional[bool] = None + can_use_nsfw: Optional[bool] = None @field_validator('username') @classmethod diff --git a/qwen3-tts-frontend/src/components/users/UserDialog.tsx b/qwen3-tts-frontend/src/components/users/UserDialog.tsx index 11c4ac1..60781b7 100644 --- a/qwen3-tts-frontend/src/components/users/UserDialog.tsx +++ b/qwen3-tts-frontend/src/components/users/UserDialog.tsx @@ -31,6 +31,7 @@ const createUserFormSchema = (t: (key: string) => string) => z.object({ is_active: z.boolean(), is_superuser: z.boolean(), can_use_local_model: z.boolean(), + can_use_nsfw: z.boolean(), }) interface UserDialogProps { @@ -63,6 +64,7 @@ export function UserDialog({ is_active: true, is_superuser: false, can_use_local_model: false, + can_use_nsfw: false, }, }) @@ -75,6 +77,7 @@ export function UserDialog({ is_active: user.is_active, is_superuser: user.is_superuser, can_use_local_model: user.can_use_local_model, + can_use_nsfw: user.can_use_nsfw ?? false, }) } else { form.reset({ @@ -84,6 +87,7 @@ export function UserDialog({ is_active: true, is_superuser: false, can_use_local_model: false, + can_use_nsfw: false, }) } }, [user, form]) @@ -206,6 +210,27 @@ export function UserDialog({ )} /> + ( + + + + +
+ {t('user:canUseNsfw')} +

+ {t('user:canUseNsfwDescription')} +

+
+
+ )} + /> + + + + {synopsis && ( +
+

故事简介(可编辑)

+