feat: add NSFW script generation feature and Grok API configuration
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="can_use_nsfw"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>{t('user:canUseNsfw')}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('user:canUseNsfwDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -63,9 +63,14 @@ export function UserTable({ users, isLoading, onEdit, onDelete }: UserTableProps
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{(user.is_superuser || user.can_use_local_model) && (
|
||||
<Badge variant="secondary">{t('user:localModelPermission')}</Badge>
|
||||
)}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(user.is_superuser || user.can_use_local_model) && (
|
||||
<Badge variant="secondary">{t('user:localModelPermission')}</Badge>
|
||||
)}
|
||||
{(user.is_superuser || user.can_use_nsfw) && (
|
||||
<Badge variant="destructive">{t('user:nsfwPermission')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{new Date(user.created_at).toLocaleString(i18n.language)}
|
||||
@@ -140,11 +145,17 @@ export function UserTable({ users, isLoading, onEdit, onDelete }: UserTableProps
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('common:actions')}:</span>
|
||||
{(user.is_superuser || user.can_use_local_model) ? (
|
||||
<Badge variant="secondary">{t('user:localModelPermission')}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('user:noPermission')}</span>
|
||||
)}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(user.is_superuser || user.can_use_local_model) && (
|
||||
<Badge variant="secondary">{t('user:localModelPermission')}</Badge>
|
||||
)}
|
||||
{(user.is_superuser || user.can_use_nsfw) && (
|
||||
<Badge variant="destructive">{t('user:nsfwPermission')}</Badge>
|
||||
)}
|
||||
{!user.is_superuser && !user.can_use_local_model && !user.can_use_nsfw && (
|
||||
<span className="text-xs text-muted-foreground">{t('user:noPermission')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('user:createdAt')}:</span>
|
||||
|
||||
@@ -224,6 +224,11 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNsfwAccess: async (): Promise<{ has_access: boolean }> => {
|
||||
const response = await apiClient.get<{ has_access: boolean }>(API_ENDPOINTS.AUTH.NSFW_ACCESS)
|
||||
return response.data
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export interface BackendStats {
|
||||
@@ -272,6 +277,21 @@ export const adminApi = {
|
||||
await apiClient.delete(API_ENDPOINTS.ADMIN.LLM_CONFIG)
|
||||
},
|
||||
|
||||
getGrokConfig: async (): Promise<{ base_url?: string; model?: string; has_key: boolean }> => {
|
||||
const response = await apiClient.get<{ base_url?: string; model?: string; has_key: boolean }>(
|
||||
API_ENDPOINTS.ADMIN.GROK_CONFIG
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
setGrokConfig: async (config: { base_url: string; api_key: string; model: string }): Promise<void> => {
|
||||
await apiClient.put(API_ENDPOINTS.ADMIN.GROK_CONFIG, config)
|
||||
},
|
||||
|
||||
deleteGrokConfig: async (): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.ADMIN.GROK_CONFIG)
|
||||
},
|
||||
|
||||
getUsage: async (dateFrom?: string, dateTo?: string): Promise<UserUsageStats[]> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (dateFrom) params.date_from = dateFrom
|
||||
|
||||
@@ -78,6 +78,26 @@ export interface LLMConfig {
|
||||
has_key: boolean
|
||||
}
|
||||
|
||||
export interface NsfwSynopsisGenerationRequest {
|
||||
genre: string
|
||||
subgenre?: string
|
||||
protagonist_type?: string
|
||||
tone?: string
|
||||
conflict_scale?: string
|
||||
num_characters?: number
|
||||
num_chapters?: number
|
||||
}
|
||||
|
||||
export interface NsfwScriptGenerationRequest {
|
||||
title: string
|
||||
genre: string
|
||||
subgenre?: string
|
||||
premise: string
|
||||
style?: string
|
||||
num_characters?: number
|
||||
num_chapters?: number
|
||||
}
|
||||
|
||||
export const audiobookApi = {
|
||||
generateSynopsis: async (data: SynopsisGenerationRequest): Promise<string> => {
|
||||
const response = await apiClient.post<{ synopsis: string }>('/audiobook/projects/generate-synopsis', data)
|
||||
@@ -89,6 +109,16 @@ export const audiobookApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
generateNsfwSynopsis: async (data: NsfwSynopsisGenerationRequest): Promise<string> => {
|
||||
const response = await apiClient.post<{ synopsis: string }>('/audiobook/projects/generate-synopsis-nsfw', data)
|
||||
return response.data.synopsis
|
||||
},
|
||||
|
||||
createNsfwScript: async (data: NsfwScriptGenerationRequest): Promise<AudiobookProject> => {
|
||||
const response = await apiClient.post<AudiobookProject>('/audiobook/projects/generate-script-nsfw', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createProject: async (data: {
|
||||
title: string
|
||||
source_type: string
|
||||
|
||||
@@ -6,6 +6,7 @@ export const API_ENDPOINTS = {
|
||||
PREFERENCES: '/auth/preferences',
|
||||
SET_ALIYUN_KEY: '/auth/aliyun-key',
|
||||
VERIFY_ALIYUN_KEY: '/auth/aliyun-key/verify',
|
||||
NSFW_ACCESS: '/auth/nsfw-access',
|
||||
},
|
||||
TTS: {
|
||||
LANGUAGES: '/tts/languages',
|
||||
@@ -32,6 +33,7 @@ export const API_ENDPOINTS = {
|
||||
SET_ALIYUN_KEY: '/users/system/aliyun-key',
|
||||
VERIFY_ALIYUN_KEY: '/users/system/aliyun-key/verify',
|
||||
LLM_CONFIG: '/users/system/llm-config',
|
||||
GROK_CONFIG: '/users/system/grok-config',
|
||||
},
|
||||
VOICE_DESIGNS: {
|
||||
LIST: '/voice-designs',
|
||||
|
||||
@@ -57,5 +57,9 @@
|
||||
"email": "Email",
|
||||
"changePassword": "Change Password",
|
||||
"passwordChangeSuccess": "Password changed successfully",
|
||||
"passwordChangeFailed": "Password change failed"
|
||||
"passwordChangeFailed": "Password change failed",
|
||||
"grokConfig": "Grok-4 Config (NSFW Mode)",
|
||||
"grokConfigDescription": "Configure Grok API for NSFW script generation, applies to users with NSFW permission",
|
||||
"grokApiKey": "Grok API Key",
|
||||
"grokModel": "Model (default: grok-4)"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"isSuperuser": "Super Administrator",
|
||||
"canUseLocalModel": "Local Model Permission",
|
||||
"canUseLocalModelDescription": "Allow user to use local TTS model",
|
||||
"canUseNsfw": "NSFW Script Permission",
|
||||
"canUseNsfwDescription": "Allow user to use NSFW content generation",
|
||||
"nsfwPermission": "NSFW",
|
||||
"saving": "Saving...",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
|
||||
@@ -57,5 +57,9 @@
|
||||
"email": "メールアドレス",
|
||||
"changePassword": "パスワード変更",
|
||||
"passwordChangeSuccess": "パスワードを変更しました",
|
||||
"passwordChangeFailed": "パスワードの変更に失敗しました"
|
||||
"passwordChangeFailed": "パスワードの変更に失敗しました",
|
||||
"grokConfig": "Grok-4 設定(NSFWモード)",
|
||||
"grokConfigDescription": "NSFWスクリプト生成用のGrok APIを設定します。NSFW権限を持つユーザーに適用されます",
|
||||
"grokApiKey": "Grok API Key",
|
||||
"grokModel": "モデル(デフォルト: grok-4)"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"isSuperuser": "スーパー管理者",
|
||||
"canUseLocalModel": "ローカルモデル権限",
|
||||
"canUseLocalModelDescription": "ユーザーにローカルTTSモデルの使用を許可",
|
||||
"canUseNsfw": "NSFWスクリプト権限",
|
||||
"canUseNsfwDescription": "ユーザーにNSFWコンテンツ生成を許可",
|
||||
"nsfwPermission": "NSFW",
|
||||
"saving": "保存中...",
|
||||
"active": "アクティブ",
|
||||
"inactive": "非アクティブ",
|
||||
|
||||
@@ -57,5 +57,9 @@
|
||||
"email": "이메일",
|
||||
"changePassword": "비밀번호 변경",
|
||||
"passwordChangeSuccess": "비밀번호가 변경되었습니다",
|
||||
"passwordChangeFailed": "비밀번호 변경에 실패했습니다"
|
||||
"passwordChangeFailed": "비밀번호 변경에 실패했습니다",
|
||||
"grokConfig": "Grok-4 설정 (NSFW 모드)",
|
||||
"grokConfigDescription": "NSFW 스크립트 생성을 위한 Grok API를 설정합니다. NSFW 권한이 있는 사용자에게 적용됩니다",
|
||||
"grokApiKey": "Grok API Key",
|
||||
"grokModel": "모델 (기본값: grok-4)"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"isSuperuser": "슈퍼 관리자",
|
||||
"canUseLocalModel": "로컬 모델 권한",
|
||||
"canUseLocalModelDescription": "사용자가 로컬 TTS 모델을 사용할 수 있도록 허용",
|
||||
"canUseNsfw": "NSFW 스크립트 권한",
|
||||
"canUseNsfwDescription": "사용자가 NSFW 콘텐츠 생성을 사용할 수 있도록 허용",
|
||||
"nsfwPermission": "NSFW",
|
||||
"saving": "저장 중...",
|
||||
"active": "활성",
|
||||
"inactive": "비활성",
|
||||
|
||||
@@ -57,5 +57,9 @@
|
||||
"email": "邮箱",
|
||||
"changePassword": "修改密码",
|
||||
"passwordChangeSuccess": "密码修改成功",
|
||||
"passwordChangeFailed": "密码修改失败"
|
||||
"passwordChangeFailed": "密码修改失败",
|
||||
"grokConfig": "Grok-4 配置(NSFW 模式)",
|
||||
"grokConfigDescription": "配置用于 NSFW 剧本生成的 Grok API,仅对有 NSFW 权限的用户生效",
|
||||
"grokApiKey": "Grok API Key",
|
||||
"grokModel": "模型(默认 grok-4)"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"isSuperuser": "超级管理员",
|
||||
"canUseLocalModel": "本地模型权限",
|
||||
"canUseLocalModelDescription": "允许用户使用本地 TTS 模型",
|
||||
"canUseNsfw": "NSFW 剧本权限",
|
||||
"canUseNsfwDescription": "允许用户使用 NSFW 内容生成功能",
|
||||
"nsfwPermission": "NSFW",
|
||||
"saving": "保存中...",
|
||||
"active": "活跃",
|
||||
"inactive": "停用",
|
||||
|
||||
@@ -57,5 +57,9 @@
|
||||
"email": "電子郵件",
|
||||
"changePassword": "修改密碼",
|
||||
"passwordChangeSuccess": "密碼修改成功",
|
||||
"passwordChangeFailed": "密碼修改失敗"
|
||||
"passwordChangeFailed": "密碼修改失敗",
|
||||
"grokConfig": "Grok-4 配置(NSFW 模式)",
|
||||
"grokConfigDescription": "配置用於 NSFW 劇本生成的 Grok API,僅對有 NSFW 權限的使用者生效",
|
||||
"grokApiKey": "Grok API Key",
|
||||
"grokModel": "模型(預設 grok-4)"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"isSuperuser": "超級管理員",
|
||||
"canUseLocalModel": "本機模型權限",
|
||||
"canUseLocalModelDescription": "允許使用者使用本機 TTS 模型",
|
||||
"canUseNsfw": "NSFW 劇本權限",
|
||||
"canUseNsfwDescription": "允許使用者使用 NSFW 內容生成功能",
|
||||
"nsfwPermission": "NSFW",
|
||||
"saving": "儲存中...",
|
||||
"active": "活躍",
|
||||
"inactive": "停用",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot } from 'lucide-react'
|
||||
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -10,9 +10,9 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Navbar } from '@/components/Navbar'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest } from '@/lib/api/audiobook'
|
||||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest, type NsfwScriptGenerationRequest } from '@/lib/api/audiobook'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import apiClient, { formatApiError, adminApi } from '@/lib/api'
|
||||
import apiClient, { formatApiError, adminApi, authApi } from '@/lib/api'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
function LazyAudioPlayer({ audioUrl, jobId }: { audioUrl: string; jobId: number }) {
|
||||
@@ -699,6 +699,240 @@ function AIScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose:
|
||||
)
|
||||
}
|
||||
|
||||
const NSFW_GENRE_CONFIGS: Record<string, { label: string; subgenres: Record<string, { protagonistTypes: string[]; tones: string[]; conflictScales: string[] }> }> = {
|
||||
'都市成人': {
|
||||
label: '都市成人',
|
||||
subgenres: {
|
||||
'办公室禁忌': { protagonistTypes: ['职场白领', '高管下属', '同事关系', '猎头顾问'], tones: ['压抑克制', '暧昧升温', '禁忌诱惑', '情欲失控'], conflictScales: ['办公室恩怨', '权力游戏', '职场暗战', '情感纠葛'] },
|
||||
'豪门虐恋': { protagonistTypes: ['普通女孩', '豪门公子', '隐婚妻子', '商业联姻'], tones: ['痴缠虐恋', '甜蜜救赎', '冷酷霸总', '深情告白'], conflictScales: ['门第悬殊', '家族势力', '商业角力', '情感博弈'] },
|
||||
'合约婚姻': { protagonistTypes: ['合约双方', '假戏真做', '情感觉醒', '身份反转'], tones: ['冷漠开局', '渐入佳境', '情不自禁', '破镜重圆'], conflictScales: ['合约终止', '真心暴露', '外敌干扰', '家族干涉'] },
|
||||
'姐弟恋': { protagonistTypes: ['成熟女性', '年轻男性', '职场前辈', '学生社会人'], tones: ['成熟风情', '反差萌甜', '年龄压制', '互相依赖'], conflictScales: ['年龄差距', '外界眼光', '情感成熟度', '未来规划'] },
|
||||
'高干文': { protagonistTypes: ['官员子弟', '平民女主', '政商联姻', '秘密保护'], tones: ['权力压迫', '低调奢华', '隐秘情感', '强势占有'], conflictScales: ['权力博弈', '政治婚姻', '身份暴露', '家族对决'] },
|
||||
},
|
||||
},
|
||||
'修仙双修': {
|
||||
label: '修仙双修',
|
||||
subgenres: {
|
||||
'双修秘法': { protagonistTypes: ['天才弟子', '神秘师尊', '炉鼎逆袭', '道侣缘定'], tones: ['禁忌师徒', '双修悟道', '情欲修炼', '天道孽缘'], conflictScales: ['门派规矩', '天道考验', '魔道诱惑', '飞升之争'] },
|
||||
'仙尊宠妻': { protagonistTypes: ['无名小修', '万年仙尊', '女弟子', '神女转世'], tones: ['宠溺无边', '万年等待', '强强联手', '情深似海'], conflictScales: ['外敌窥伺', '仙界规则', '劫数将临', '情缘注定'] },
|
||||
'妖族后宫': { protagonistTypes: ['妖王', '人族女修', '混血妖神', '后宫妃嫔'], tones: ['妖冶风情', '强势占有', '蛊惑人心', '情孽难断'], conflictScales: ['人妖对立', '族群战争', '后宫争宠', '妖皇之位'] },
|
||||
'天骄配天娇': { protagonistTypes: ['第一天骄', '绝世女主', '双强对决', '势均力敌'], tones: ['互相较劲', '惺惺相惜', '强强碰撞', '双向暗恋'], conflictScales: ['修炼竞争', '争夺机缘', '外敌来犯', '道路抉择'] },
|
||||
},
|
||||
},
|
||||
'奇幻异世': {
|
||||
label: '奇幻异世',
|
||||
subgenres: {
|
||||
'兽人联姻': { protagonistTypes: ['穿越女主', '兽人王者', '人兽混血', '部落首领'], tones: ['野性粗犷', '温柔呵护', '征服臣服', '异族情缘'], conflictScales: ['种族融合', '部落战争', '血脉觉醒', '异世生存'] },
|
||||
'魔君圣女': { protagonistTypes: ['圣女祭司', '堕落魔君', '神魔对立', '光暗守护者'], tones: ['圣洁污染', '光暗交融', '禁忌之恋', '救赎与沉沦'], conflictScales: ['神魔大战', '命运抗争', '信仰崩塌', '世界毁灭'] },
|
||||
'穿越迷情': { protagonistTypes: ['现代女性', '古代君王', '异世界人物', '时空旅者'], tones: ['时空错位', '身份迷失', '古今碰撞', '情不知所起'], conflictScales: ['回归困境', '历史改变', '时空悖论', '情感牵绊'] },
|
||||
'契约恋人': { protagonistTypes: ['契约双方', '灵魂契约', '主仆关系', '命运绑定'], tones: ['被迫相处', '情感萌生', '契约升温', '破除枷锁'], conflictScales: ['契约限制', '灵魂危机', '外界阻碍', '真心表达'] },
|
||||
},
|
||||
},
|
||||
'历史宫廷': {
|
||||
label: '历史宫廷',
|
||||
subgenres: {
|
||||
'帝王宠妃': { protagonistTypes: ['寒门女子', '九五之尊', '独宠专房', '贵妃娘娘'], tones: ['专宠霸道', '龙恩浩荡', '后宫独步', '帝心难测'], conflictScales: ['后宫争宠', '前朝牵制', '子嗣之争', '外戚干政'] },
|
||||
'宫廷秘史': { protagonistTypes: ['宫廷暗卫', '妃嫔密探', '皇室隐秘', '历史见证者'], tones: ['权谋算计', '忠义两难', '隐秘情愫', '乱世真情'], conflictScales: ['宫廷政变', '皇位继承', '异国入侵', '家族倾轧'] },
|
||||
'宅斗宫斗': { protagonistTypes: ['嫡女庶女', '正妻侍妾', '宫女妃嫔', '世家主母'], tones: ['心机算尽', '步步为营', '白莲花黑化', '大女主觉醒'], conflictScales: ['争夺家产', '嫁妆之战', '子嗣竞争', '家族延续'] },
|
||||
'后宫权谋': { protagonistTypes: ['权倾后宫者', '新晋宠妃', '太后皇后', '谋士幕僚'], tones: ['深宫寂寞', '权力欲望', '情与利的博弈', '最终胜者独孤'], conflictScales: ['废立皇后', '皇储之争', '派系对立', '外朝联动'] },
|
||||
},
|
||||
},
|
||||
'现代激情': {
|
||||
label: '现代激情',
|
||||
subgenres: {
|
||||
'一夜情缘': { protagonistTypes: ['陌生男女', '意外邂逅', '酒后乱性', '重逢旧恋'], tones: ['激情一夜', '难以忘怀', '意外怀孕', '命运纠缠'], conflictScales: ['身份悬殊', '情感纠缠', '意外结果', '关系定义'] },
|
||||
'前任回归': { protagonistTypes: ['前男友女友', '新欢旧爱', '情感疗愈者', '纠缠不清'], tones: ['旧情复燃', '爱恨交织', '放不下的执念', '破镜能否重圆'], conflictScales: ['感情空白期', '第三者介入', '过去伤害', '信任重建'] },
|
||||
'欲望都市': { protagonistTypes: ['都市白领', '成功商人', '独立女性', '富二代'], tones: ['纸醉金迷', '欲望横流', '都市孤独', '情欲游戏'], conflictScales: ['金钱利益', '情感空虚', '道德底线', '真爱追寻'] },
|
||||
'豪门宠婚': { protagonistTypes: ['灰姑娘女主', '豪门单身汉', '闪婚闪恋', '宠妻狂魔'], tones: ['宠溺日常', '霸道甜蜜', '嫉妒吃醋', '甜到发腻'], conflictScales: ['豪门规则', '家族考验', '外来破坏', '证明真爱'] },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function NSFWScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [genre, setGenre] = useState('')
|
||||
const [subgenre, setSubgenre] = useState('')
|
||||
const [protagonistType, setProtagonistType] = useState('')
|
||||
const [tone, setTone] = useState('')
|
||||
const [conflictScale, setConflictScale] = useState('')
|
||||
const [numCharacters, setNumCharacters] = useState(5)
|
||||
const [numChapters, setNumChapters] = useState(8)
|
||||
const [synopsis, setSynopsis] = useState('')
|
||||
const [generatingSynopsis, setGeneratingSynopsis] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const genreKeys = Object.keys(NSFW_GENRE_CONFIGS)
|
||||
const subgenreKeys = genre ? Object.keys(NSFW_GENRE_CONFIGS[genre]?.subgenres ?? {}) : []
|
||||
const subgenreConfig = (genre && subgenre) ? NSFW_GENRE_CONFIGS[genre]?.subgenres[subgenre] : null
|
||||
|
||||
const reset = () => {
|
||||
setTitle(''); setGenre(''); setSubgenre(''); setProtagonistType(''); setTone('')
|
||||
setConflictScale(''); setNumCharacters(5); setNumChapters(8); setSynopsis('')
|
||||
}
|
||||
|
||||
const handleGenreSelect = (g: string) => {
|
||||
setGenre(g); setSubgenre(''); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
|
||||
}
|
||||
const handleSubgenreSelect = (s: string) => {
|
||||
setSubgenre(s); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
|
||||
}
|
||||
|
||||
const handleGenerateSynopsis = async () => {
|
||||
if (!genre) { toast.error('请选择故事类型'); return }
|
||||
setGeneratingSynopsis(true)
|
||||
try {
|
||||
const result = await audiobookApi.generateNsfwSynopsis({
|
||||
genre: subgenre ? `${genre} - ${subgenre}` : genre,
|
||||
subgenre,
|
||||
protagonist_type: protagonistType,
|
||||
tone,
|
||||
conflict_scale: conflictScale,
|
||||
num_characters: numCharacters,
|
||||
num_chapters: numChapters,
|
||||
})
|
||||
setSynopsis(result)
|
||||
} catch (e: any) {
|
||||
toast.error(formatApiError(e))
|
||||
} finally {
|
||||
setGeneratingSynopsis(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title) { toast.error('请输入作品标题'); return }
|
||||
if (!synopsis) { toast.error('请先生成故事简介'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
await audiobookApi.createNsfwScript({
|
||||
title,
|
||||
genre: subgenre ? `${genre} - ${subgenre}` : genre,
|
||||
subgenre,
|
||||
premise: synopsis,
|
||||
style: tone,
|
||||
num_characters: numCharacters,
|
||||
num_chapters: numChapters,
|
||||
} as NsfwScriptGenerationRequest)
|
||||
toast.success('NSFW剧本生成任务已创建')
|
||||
reset()
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
toast.error(formatApiError(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => { if (!v) { reset(); onClose() } }}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Flame className="h-4 w-4 text-orange-500" />
|
||||
NSFW 生成剧本
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">作品标题</p>
|
||||
<Input placeholder="输入作品标题" value={title} onChange={e => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">故事类型</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{genreKeys.map(g => (
|
||||
<Chip key={g} label={NSFW_GENRE_CONFIGS[g].label} selected={genre === g} onClick={() => handleGenreSelect(g)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{genre && subgenreKeys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">子类型</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subgenreKeys.map(s => (
|
||||
<Chip key={s} label={s} selected={subgenre === s} onClick={() => handleSubgenreSelect(s)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subgenreConfig && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">主角类型</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subgenreConfig.protagonistTypes.map(p => (
|
||||
<Chip key={p} label={p} selected={protagonistType === p} onClick={() => setProtagonistType(protagonistType === p ? '' : p)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">故事基调</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subgenreConfig.tones.map(t => (
|
||||
<Chip key={t} label={t} selected={tone === t} onClick={() => setTone(tone === t ? '' : t)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">冲突规模</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subgenreConfig.conflictScales.map(c => (
|
||||
<Chip key={c} label={c} selected={conflictScale === c} onClick={() => setConflictScale(conflictScale === c ? '' : c)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<label className="flex-1 flex flex-col gap-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs">角色数量(2-10)</span>
|
||||
<Input type="number" min={2} max={10} value={numCharacters} onChange={e => setNumCharacters(Math.min(10, Math.max(2, Number(e.target.value))))} />
|
||||
</label>
|
||||
<label className="flex-1 flex flex-col gap-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs">章节数量(2-30)</span>
|
||||
<Input type="number" min={2} max={30} value={numChapters} onChange={e => setNumChapters(Math.min(30, Math.max(2, Number(e.target.value))))} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={!genre || generatingSynopsis}>
|
||||
{generatingSynopsis ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
{generatingSynopsis ? '生成中...' : '生成故事简介'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{synopsis && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">故事简介(可编辑)</p>
|
||||
<Textarea rows={6} value={synopsis} onChange={e => setSynopsis(e.target.value)} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 pt-3 shrink-0 border-t">
|
||||
<Button size="sm" variant="ghost" onClick={() => { reset(); onClose() }} disabled={loading}>取消</Button>
|
||||
<div className="flex gap-2">
|
||||
{synopsis && (
|
||||
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={generatingSynopsis}>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
重新生成
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleCreate} disabled={loading || !synopsis || !title}>
|
||||
{loading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
{loading ? '创建中...' : '生成剧本'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ContinueScriptDialog({ open, onClose, onConfirm }: { open: boolean; onClose: () => void; onConfirm: (n: number) => Promise<void> }) {
|
||||
const { t } = useTranslation('audiobook')
|
||||
const [count, setCount] = useState(4)
|
||||
@@ -746,22 +980,26 @@ function ProjectListSidebar({
|
||||
onSelect,
|
||||
onNew,
|
||||
onAIScript,
|
||||
onNSFWScript,
|
||||
onLLM,
|
||||
loading,
|
||||
collapsed,
|
||||
onToggle,
|
||||
isSuperuser,
|
||||
hasNsfwAccess,
|
||||
}: {
|
||||
projects: AudiobookProject[]
|
||||
selectedId: number | null
|
||||
onSelect: (id: number) => void
|
||||
onNew: () => void
|
||||
onAIScript: () => void
|
||||
onNSFWScript: () => void
|
||||
onLLM: () => void
|
||||
loading: boolean
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
isSuperuser?: boolean
|
||||
hasNsfwAccess?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('audiobook')
|
||||
return (
|
||||
@@ -785,6 +1023,11 @@ function ProjectListSidebar({
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasNsfwAccess && (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-orange-500 hover:text-orange-600" onClick={onNSFWScript} title="NSFW 生成剧本">
|
||||
<Flame className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onAIScript} title="AI 生成剧本">
|
||||
<Zap className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -1513,8 +1756,10 @@ export default function Audiobook() {
|
||||
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showAIScript, setShowAIScript] = useState(false)
|
||||
const [showNSFWScript, setShowNSFWScript] = useState(false)
|
||||
const [showContinueScript, setShowContinueScript] = useState(false)
|
||||
const [showLLM, setShowLLM] = useState(false)
|
||||
const [hasNsfwAccess, setHasNsfwAccess] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [charactersCollapsed, setCharactersCollapsed] = useState(false)
|
||||
const [scrollToChapterId, setScrollToChapterId] = useState<number | null>(null)
|
||||
@@ -1546,6 +1791,7 @@ export default function Audiobook() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
authApi.getNsfwAccess().then(r => setHasNsfwAccess(r.has_access)).catch(() => {})
|
||||
}, [fetchProjects])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1897,13 +2143,15 @@ export default function Audiobook() {
|
||||
setGeneratingChapterIndices(new Set())
|
||||
}
|
||||
}}
|
||||
onNew={() => { setShowCreate(v => !v); setShowLLM(false); setShowAIScript(false) }}
|
||||
onAIScript={() => { setShowAIScript(v => !v); setShowCreate(false); setShowLLM(false) }}
|
||||
onLLM={() => { setShowLLM(v => !v); setShowCreate(false); setShowAIScript(false) }}
|
||||
onNew={() => { setShowCreate(v => !v); setShowLLM(false); setShowAIScript(false); setShowNSFWScript(false) }}
|
||||
onAIScript={() => { setShowAIScript(v => !v); setShowCreate(false); setShowLLM(false); setShowNSFWScript(false) }}
|
||||
onNSFWScript={() => { setShowNSFWScript(v => !v); setShowCreate(false); setShowLLM(false); setShowAIScript(false) }}
|
||||
onLLM={() => { setShowLLM(v => !v); setShowCreate(false); setShowAIScript(false); setShowNSFWScript(false) }}
|
||||
loading={loading}
|
||||
collapsed={!sidebarOpen}
|
||||
onToggle={() => setSidebarOpen(v => !v)}
|
||||
isSuperuser={user?.is_superuser}
|
||||
hasNsfwAccess={hasNsfwAccess}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
|
||||
<Navbar />
|
||||
@@ -1911,6 +2159,7 @@ export default function Audiobook() {
|
||||
<LLMConfigDialog open={showLLM} onClose={() => setShowLLM(false)} />
|
||||
<CreateProjectDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={fetchProjects} />
|
||||
<AIScriptDialog open={showAIScript} onClose={() => setShowAIScript(false)} onCreated={() => { fetchProjects(); setShowAIScript(false) }} />
|
||||
<NSFWScriptDialog open={showNSFWScript} onClose={() => setShowNSFWScript(false)} onCreated={() => { fetchProjects(); setShowNSFWScript(false) }} />
|
||||
<ContinueScriptDialog open={showContinueScript} onClose={() => setShowContinueScript(false)} onConfirm={handleContinueScript} />
|
||||
{!selectedProject ? (
|
||||
<EmptyState />
|
||||
|
||||
@@ -42,6 +42,11 @@ export default function Settings() {
|
||||
const [llmModel, setLlmModel] = useState('')
|
||||
const [llmConfig, setLlmConfig] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null)
|
||||
const [isLlmLoading, setIsLlmLoading] = useState(false)
|
||||
const [grokBaseUrl, setGrokBaseUrl] = useState('')
|
||||
const [grokApiKey, setGrokApiKey] = useState('')
|
||||
const [grokModel, setGrokModel] = useState('grok-4')
|
||||
const [grokConfig, setGrokConfig] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null)
|
||||
const [isGrokLoading, setIsGrokLoading] = useState(false)
|
||||
|
||||
const apiKeySchema = createApiKeySchema(t)
|
||||
type ApiKeyFormValues = z.infer<typeof apiKeySchema>
|
||||
@@ -49,6 +54,7 @@ export default function Settings() {
|
||||
useEffect(() => {
|
||||
if (user?.is_superuser) {
|
||||
adminApi.getLLMConfig().then(setLlmConfig).catch(() => {})
|
||||
adminApi.getGrokConfig().then(setGrokConfig).catch(() => {})
|
||||
}
|
||||
}, [user?.is_superuser])
|
||||
|
||||
@@ -149,6 +155,39 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveGrokConfig = async () => {
|
||||
if (!grokBaseUrl || !grokApiKey || !grokModel) {
|
||||
toast.error('请填写完整的 Grok 配置')
|
||||
return
|
||||
}
|
||||
setIsGrokLoading(true)
|
||||
try {
|
||||
await adminApi.setGrokConfig({ base_url: grokBaseUrl, api_key: grokApiKey, model: grokModel })
|
||||
toast.success('Grok 配置已保存')
|
||||
setGrokApiKey('')
|
||||
const updated = await adminApi.getGrokConfig()
|
||||
setGrokConfig(updated)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Grok 配置保存失败')
|
||||
} finally {
|
||||
setIsGrokLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteGrokConfig = async () => {
|
||||
if (!confirm('确认删除 Grok 配置?')) return
|
||||
setIsGrokLoading(true)
|
||||
try {
|
||||
await adminApi.deleteGrokConfig()
|
||||
setGrokConfig(null)
|
||||
toast.success('Grok 配置已删除')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '删除失败')
|
||||
} finally {
|
||||
setIsGrokLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async (data: PasswordChangeRequest) => {
|
||||
try {
|
||||
setIsPasswordLoading(true)
|
||||
@@ -332,6 +371,39 @@ export default function Settings() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{user.is_superuser && (
|
||||
<Card>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<CardTitle className="text-lg sm:text-xl flex items-center gap-2">
|
||||
{t('settings:grokConfig')}
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">{t('settings:grokConfigDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 sm:p-6">
|
||||
{grokConfig && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
当前:{grokConfig.base_url || '未设置'} / {grokConfig.model || '未设置'} / {grokConfig.has_key ? 'API Key 已配置' : '未配置 API Key'}
|
||||
</div>
|
||||
)}
|
||||
<Input placeholder="Base URL (例: https://api.x.ai/v1)" value={grokBaseUrl} onChange={e => setGrokBaseUrl(e.target.value)} disabled={isGrokLoading} />
|
||||
<Input placeholder={t('settings:grokApiKey')} type="password" value={grokApiKey} onChange={e => setGrokApiKey(e.target.value)} disabled={isGrokLoading} />
|
||||
<Input placeholder={t('settings:grokModel')} value={grokModel} onChange={e => setGrokModel(e.target.value)} disabled={isGrokLoading} />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSaveGrokConfig} disabled={isGrokLoading} className="flex-1 sm:flex-initial">
|
||||
{isGrokLoading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
{grokConfig?.has_key && (
|
||||
<Button variant="destructive" onClick={handleDeleteGrokConfig} disabled={isGrokLoading} size="icon" className="sm:w-auto sm:px-4">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline sm:ml-2">删除</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<CardTitle className="text-lg sm:text-xl">{t('settings:accountInfo')}</CardTitle>
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface User {
|
||||
is_active: boolean
|
||||
is_superuser: boolean
|
||||
can_use_local_model: boolean
|
||||
can_use_nsfw: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user