feat: add NSFW script generation feature and Grok API configuration

This commit is contained in:
2026-03-13 12:58:28 +08:00
parent 424c3edf0b
commit 0d63d0e6d1
28 changed files with 850 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,9 @@
"isSuperuser": "スーパー管理者",
"canUseLocalModel": "ローカルモデル権限",
"canUseLocalModelDescription": "ユーザーにローカルTTSモデルの使用を許可",
"canUseNsfw": "NSFWスクリプト権限",
"canUseNsfwDescription": "ユーザーにNSFWコンテンツ生成を許可",
"nsfwPermission": "NSFW",
"saving": "保存中...",
"active": "アクティブ",
"inactive": "非アクティブ",

View File

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

View File

@@ -44,6 +44,9 @@
"isSuperuser": "슈퍼 관리자",
"canUseLocalModel": "로컬 모델 권한",
"canUseLocalModelDescription": "사용자가 로컬 TTS 모델을 사용할 수 있도록 허용",
"canUseNsfw": "NSFW 스크립트 권한",
"canUseNsfwDescription": "사용자가 NSFW 콘텐츠 생성을 사용할 수 있도록 허용",
"nsfwPermission": "NSFW",
"saving": "저장 중...",
"active": "활성",
"inactive": "비활성",

View File

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

View File

@@ -44,6 +44,9 @@
"isSuperuser": "超级管理员",
"canUseLocalModel": "本地模型权限",
"canUseLocalModelDescription": "允许用户使用本地 TTS 模型",
"canUseNsfw": "NSFW 剧本权限",
"canUseNsfwDescription": "允许用户使用 NSFW 内容生成功能",
"nsfwPermission": "NSFW",
"saving": "保存中...",
"active": "活跃",
"inactive": "停用",

View File

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

View File

@@ -44,6 +44,9 @@
"isSuperuser": "超級管理員",
"canUseLocalModel": "本機模型權限",
"canUseLocalModelDescription": "允許使用者使用本機 TTS 模型",
"canUseNsfw": "NSFW 劇本權限",
"canUseNsfwDescription": "允許使用者使用 NSFW 內容生成功能",
"nsfwPermission": "NSFW",
"saving": "儲存中...",
"active": "活躍",
"inactive": "停用",

View File

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

View File

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

View File

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