From 7f25dd09f6a028a5cf9033ded8ccb3f392ce9a6f Mon Sep 17 00:00:00 2001 From: bdim404 Date: Thu, 12 Mar 2026 16:30:24 +0800 Subject: [PATCH] feat: add admin usage statistics and LLM configuration management --- qwen3-tts-backend/api/admin.py | 22 ++ qwen3-tts-backend/api/auth.py | 153 ++----------- qwen3-tts-backend/api/tts.py | 57 +++-- qwen3-tts-backend/api/users.py | 115 +++++++++- qwen3-tts-backend/core/audiobook_service.py | 118 +++++++--- qwen3-tts-backend/core/llm_service.py | 42 ++-- qwen3-tts-backend/db/crud.py | 108 ++++++++- qwen3-tts-backend/db/models.py | 15 ++ qwen3-tts-backend/main.py | 3 +- qwen3-tts-frontend/src/App.tsx | 9 + qwen3-tts-frontend/src/components/Navbar.tsx | 9 +- qwen3-tts-frontend/src/lib/api.ts | 55 +++++ qwen3-tts-frontend/src/lib/constants.ts | 5 + qwen3-tts-frontend/src/pages/AdminStats.tsx | 107 +++++++++ qwen3-tts-frontend/src/pages/Audiobook.tsx | 21 +- qwen3-tts-frontend/src/pages/Settings.tsx | 218 ++++++++++++------- 16 files changed, 757 insertions(+), 300 deletions(-) create mode 100644 qwen3-tts-backend/api/admin.py create mode 100644 qwen3-tts-frontend/src/pages/AdminStats.tsx diff --git a/qwen3-tts-backend/api/admin.py b/qwen3-tts-backend/api/admin.py new file mode 100644 index 0000000..9ad2ef0 --- /dev/null +++ b/qwen3-tts-backend/api/admin.py @@ -0,0 +1,22 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from api.users import require_superuser +from db.database import get_db +from db.crud import get_usage_stats +from schemas.user import User + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/usage") +async def get_usage_statistics( + date_from: Optional[datetime] = Query(None), + date_to: Optional[datetime] = Query(None), + db: Session = Depends(get_db), + _: User = Depends(require_superuser), +): + return get_usage_stats(db, date_from=date_from, date_to=date_to) diff --git a/qwen3-tts-backend/api/auth.py b/qwen3-tts-backend/api/auth.py index 8b56f0d..2abed3e 100644 --- a/qwen3-tts-backend/api/auth.py +++ b/qwen3-tts-backend/api/auth.py @@ -14,9 +14,9 @@ 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, update_user_aliyun_key, get_user_preferences, update_user_preferences, can_user_use_local_model, update_user_llm_config -from schemas.user import User, UserCreate, Token, PasswordChange, AliyunKeyUpdate, AliyunKeyVerifyResponse, UserPreferences, UserPreferencesResponse -from schemas.audiobook import LLMConfigUpdate, LLMConfigResponse +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 schemas.user import User, UserCreate, Token, PasswordChange, AliyunKeyVerifyResponse, UserPreferences, UserPreferencesResponse +from schemas.audiobook import LLMConfigResponse router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -137,70 +137,6 @@ async def change_password( return user -@router.post("/aliyun-key", response_model=User) -@limiter.limit("5/minute") -async def set_aliyun_key( - request: Request, - key_data: AliyunKeyUpdate, - current_user: Annotated[User, Depends(get_current_user)], - db: Session = Depends(get_db) -): - from core.security import encrypt_api_key - from core.tts_service import AliyunTTSBackend - - api_key = key_data.api_key.strip() - - aliyun_backend = AliyunTTSBackend(api_key=api_key, region=settings.ALIYUN_REGION) - health = await aliyun_backend.health_check() - - if not health.get("available", False): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Aliyun API key. Please check your API key and try again." - ) - - encrypted_key = encrypt_api_key(api_key) - - user = update_user_aliyun_key( - db, - user_id=current_user.id, - encrypted_api_key=encrypted_key - ) - - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found" - ) - - return user - -@router.delete("/aliyun-key") -@limiter.limit("5/minute") -async def delete_aliyun_key( - request: Request, - current_user: Annotated[User, Depends(get_current_user)], - db: Session = Depends(get_db) -): - user = update_user_aliyun_key( - db, - user_id=current_user.id, - encrypted_api_key=None - ) - - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found" - ) - - prefs = get_user_preferences(db, current_user.id) - if prefs.get("default_backend") == "aliyun": - prefs["default_backend"] = "local" - update_user_preferences(db, current_user.id, prefs) - - return {"message": "Aliyun API key deleted", "preferences_updated": True} - @router.get("/aliyun-key/verify", response_model=AliyunKeyVerifyResponse) @limiter.limit("10/minute") async def verify_aliyun_key( @@ -211,33 +147,20 @@ async def verify_aliyun_key( from core.security import decrypt_api_key from core.tts_service import AliyunTTSBackend - if not current_user.aliyun_api_key: - return AliyunKeyVerifyResponse( - valid=False, - message="No Aliyun API key configured" - ) - - api_key = decrypt_api_key(current_user.aliyun_api_key) + encrypted = get_system_setting(db, "aliyun_api_key") + if not encrypted: + return AliyunKeyVerifyResponse(valid=False, message="No Aliyun API key configured") + api_key = decrypt_api_key(encrypted) if not api_key: - return AliyunKeyVerifyResponse( - valid=False, - message="Failed to decrypt API key" - ) + return AliyunKeyVerifyResponse(valid=False, message="Failed to decrypt API key") aliyun_backend = AliyunTTSBackend(api_key=api_key, region=settings.ALIYUN_REGION) health = await aliyun_backend.health_check() if health.get("available", False): - return AliyunKeyVerifyResponse( - valid=True, - message="Aliyun API key is valid and working" - ) - else: - return AliyunKeyVerifyResponse( - valid=False, - message="Aliyun API key is not working. Please check your API key." - ) + return AliyunKeyVerifyResponse(valid=True, message="Aliyun API key is valid and working") + return AliyunKeyVerifyResponse(valid=False, message="Aliyun API key is not working.") @router.get("/preferences", response_model=UserPreferencesResponse) @limiter.limit("30/minute") @@ -288,61 +211,15 @@ async def update_preferences( return {"message": "Preferences updated successfully"} -@router.put("/llm-config") -@limiter.limit("10/minute") -async def set_llm_config( - request: Request, - config: LLMConfigUpdate, - current_user: Annotated[User, Depends(get_current_user)], - db: Session = Depends(get_db) -): - from core.security import encrypt_api_key - from core.llm_service import LLMService - - api_key = config.api_key.strip() - base_url = config.base_url.strip() - model = config.model.strip() - - # Validate LLM config by sending a test request - llm = LLMService(base_url=base_url, api_key=api_key, model=model) - try: - await llm.chat("You are a test assistant.", "Reply with 'ok'.") - except Exception as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"LLM API validation failed: {e}" - ) - - encrypted_key = encrypt_api_key(api_key) - update_user_llm_config( - db, - user_id=current_user.id, - llm_api_key=encrypted_key, - llm_base_url=base_url, - llm_model=model, - ) - return {"message": "LLM config updated successfully"} - - @router.get("/llm-config", response_model=LLMConfigResponse) @limiter.limit("30/minute") async def get_llm_config( request: Request, current_user: Annotated[User, Depends(get_current_user)], -): - return LLMConfigResponse( - base_url=current_user.llm_base_url, - model=current_user.llm_model, - has_key=bool(current_user.llm_api_key), - ) - - -@router.delete("/llm-config") -@limiter.limit("10/minute") -async def delete_llm_config( - request: Request, - current_user: Annotated[User, Depends(get_current_user)], db: Session = Depends(get_db) ): - update_user_llm_config(db, user_id=current_user.id, clear=True) - return {"message": "LLM config deleted"} + return LLMConfigResponse( + base_url=get_system_setting(db, "llm_base_url"), + model=get_system_setting(db, "llm_model"), + has_key=bool(get_system_setting(db, "llm_api_key")), + ) diff --git a/qwen3-tts-backend/api/tts.py b/qwen3-tts-backend/api/tts.py index b82730e..ec853e7 100644 --- a/qwen3-tts-backend/api/tts.py +++ b/qwen3-tts-backend/api/tts.py @@ -72,9 +72,10 @@ async def process_custom_voice_job( user_api_key = None if backend_type == "aliyun": - user = db.query(User).filter(User.id == user_id).first() - if user and user.aliyun_api_key: - user_api_key = decrypt_api_key(user.aliyun_api_key) + from db.crud import get_system_setting + encrypted = get_system_setting(db, "aliyun_api_key") + if encrypted: + user_api_key = decrypt_api_key(encrypted) backend = await TTSServiceFactory.get_backend(backend_type, user_api_key) @@ -134,9 +135,10 @@ async def process_voice_design_job( user_api_key = None if backend_type == "aliyun": - user = db.query(User).filter(User.id == user_id).first() - if user and user.aliyun_api_key: - user_api_key = decrypt_api_key(user.aliyun_api_key) + from db.crud import get_system_setting + encrypted = get_system_setting(db, "aliyun_api_key") + if encrypted: + user_api_key = decrypt_api_key(encrypted) backend = await TTSServiceFactory.get_backend(backend_type, user_api_key) @@ -201,9 +203,10 @@ async def process_voice_clone_job( from core.security import decrypt_api_key user_api_key = None if backend_type == "aliyun": - user = db.query(User).filter(User.id == user_id).first() - if user and user.aliyun_api_key: - user_api_key = decrypt_api_key(user.aliyun_api_key) + from db.crud import get_system_setting + encrypted = get_system_setting(db, "aliyun_api_key") + if encrypted: + user_api_key = decrypt_api_key(encrypted) with open(ref_audio_path, 'rb') as f: ref_audio_data = f.read() @@ -352,11 +355,13 @@ async def create_custom_voice_job( detail="Local model is not available. Please contact administrator." ) - if backend_type == "aliyun" and not current_user.aliyun_api_key: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Aliyun API key not configured. Please set your API key in Settings." - ) + if backend_type == "aliyun": + from db.crud import get_system_setting + if not get_system_setting(db, "aliyun_api_key"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Aliyun API key not configured. Please contact administrator." + ) try: validate_text_length(req_data.text) @@ -459,11 +464,13 @@ async def create_voice_design_job( detail="Local model is not available. Please contact administrator." ) - if backend_type == "aliyun" and not current_user.aliyun_api_key: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Aliyun API key not configured. Please set your API key in Settings." - ) + if backend_type == "aliyun": + from db.crud import get_system_setting + if not get_system_setting(db, "aliyun_api_key"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Aliyun API key not configured. Please contact administrator." + ) try: validate_text_length(req_data.text) @@ -562,11 +569,13 @@ async def create_voice_clone_job( detail="Local model is not available. Please contact administrator." ) - if backend_type == "aliyun" and not current_user.aliyun_api_key: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Aliyun API key not configured. Please set your API key in Settings." - ) + if backend_type == "aliyun": + from db.crud import get_system_setting + if not get_system_setting(db, "aliyun_api_key"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Aliyun API key not configured. Please contact administrator." + ) ref_audio_data = None ref_audio_hash = None diff --git a/qwen3-tts-backend/api/users.py b/qwen3-tts-backend/api/users.py index 83e55d4..450fb6d 100644 --- a/qwen3-tts-backend/api/users.py +++ b/qwen3-tts-backend/api/users.py @@ -17,7 +17,8 @@ from db.crud import ( update_user, delete_user ) -from schemas.user import User, UserCreateByAdmin, UserUpdate, UserListResponse +from schemas.user import User, UserCreateByAdmin, UserUpdate, UserListResponse, AliyunKeyUpdate, AliyunKeyVerifyResponse +from schemas.audiobook import LLMConfigUpdate, LLMConfigResponse router = APIRouter(prefix="/users", tags=["users"]) limiter = Limiter(key_func=get_remote_address) @@ -177,3 +178,115 @@ async def delete_user_by_id( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) + + +@router.post("/system/aliyun-key") +@limiter.limit("5/minute") +async def set_system_aliyun_key( + request: Request, + key_data: AliyunKeyUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_superuser) +): + from core.security import encrypt_api_key + from core.tts_service import AliyunTTSBackend + from db.crud import set_system_setting + + api_key = key_data.api_key.strip() + aliyun_backend = AliyunTTSBackend(api_key=api_key, region=settings.ALIYUN_REGION) + health = await aliyun_backend.health_check() + if not health.get("available", False): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Aliyun API key.") + set_system_setting(db, "aliyun_api_key", encrypt_api_key(api_key)) + return {"message": "Aliyun API key updated"} + + +@router.delete("/system/aliyun-key") +@limiter.limit("5/minute") +async def delete_system_aliyun_key( + request: Request, + db: Session = Depends(get_db), + _: User = Depends(require_superuser) +): + from db.crud import delete_system_setting + delete_system_setting(db, "aliyun_api_key") + return {"message": "Aliyun API key deleted"} + + +@router.get("/system/aliyun-key/verify", response_model=AliyunKeyVerifyResponse) +@limiter.limit("10/minute") +async def verify_system_aliyun_key( + request: Request, + db: Session = Depends(get_db), + _: User = Depends(require_superuser) +): + from core.security import decrypt_api_key + from core.tts_service import AliyunTTSBackend + from db.crud import get_system_setting + + encrypted = get_system_setting(db, "aliyun_api_key") + if not encrypted: + return AliyunKeyVerifyResponse(valid=False, message="No Aliyun API key configured") + api_key = decrypt_api_key(encrypted) + if not api_key: + return AliyunKeyVerifyResponse(valid=False, message="Failed to decrypt API key") + aliyun_backend = AliyunTTSBackend(api_key=api_key, region=settings.ALIYUN_REGION) + health = await aliyun_backend.health_check() + if health.get("available", False): + return AliyunKeyVerifyResponse(valid=True, message="Aliyun API key is valid and working") + return AliyunKeyVerifyResponse(valid=False, message="Aliyun API key is not working.") + + +@router.put("/system/llm-config") +@limiter.limit("10/minute") +async def set_system_llm_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 LLMService + from db.crud import set_system_setting + + api_key = config.api_key.strip() + base_url = config.base_url.strip() + model = config.model.strip() + llm = LLMService(base_url=base_url, api_key=api_key, model=model) + try: + await llm.chat("You are a test assistant.", "Reply with 'ok'.") + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"LLM API validation failed: {e}") + set_system_setting(db, "llm_api_key", encrypt_api_key(api_key)) + set_system_setting(db, "llm_base_url", base_url) + set_system_setting(db, "llm_model", model) + return {"message": "LLM config updated"} + + +@router.get("/system/llm-config", response_model=LLMConfigResponse) +@limiter.limit("30/minute") +async def get_system_llm_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, "llm_base_url"), + model=get_system_setting(db, "llm_model"), + has_key=bool(get_system_setting(db, "llm_api_key")), + ) + + +@router.delete("/system/llm-config") +@limiter.limit("10/minute") +async def delete_system_llm_config( + request: Request, + db: Session = Depends(get_db), + _: User = Depends(require_superuser) +): + from db.crud import delete_system_setting + delete_system_setting(db, "llm_api_key") + delete_system_setting(db, "llm_base_url") + delete_system_setting(db, "llm_model") + return {"message": "LLM config deleted"} diff --git a/qwen3-tts-backend/core/audiobook_service.py b/qwen3-tts-backend/core/audiobook_service.py index 2cc90e2..b493767 100644 --- a/qwen3-tts-backend/core/audiobook_service.py +++ b/qwen3-tts-backend/core/audiobook_service.py @@ -26,14 +26,18 @@ def cancel_batch(project_id: int) -> None: logger.info(f"cancel_batch: project={project_id} cancellation signalled") -def _get_llm_service(user: User) -> LLMService: +def _get_llm_service(db: Session) -> LLMService: from core.security import decrypt_api_key - if not user.llm_api_key or not user.llm_base_url or not user.llm_model: - raise ValueError("LLM config not set. Please configure LLM API key, base URL, and model.") - api_key = decrypt_api_key(user.llm_api_key) + from db.crud import get_system_setting + api_key_encrypted = get_system_setting(db, "llm_api_key") + base_url = get_system_setting(db, "llm_base_url") + model = get_system_setting(db, "llm_model") + if not api_key_encrypted or not base_url or not model: + raise ValueError("LLM config not set. Please configure LLM API key, base URL, and model in admin settings.") + api_key = decrypt_api_key(api_key_encrypted) if not api_key: raise ValueError("Failed to decrypt LLM API key.") - return LLMService(base_url=user.llm_base_url, api_key=api_key, model=user.llm_model) + return LLMService(base_url=base_url, api_key=api_key, model=model) def _get_gendered_instruct(gender: Optional[str], base_instruct: str) -> str: @@ -167,7 +171,18 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool crud.update_audiobook_project_status(db, project_id, "analyzing") ps.append_line(key, f"[分析] 项目「{project.title}」开始角色分析") - llm = _get_llm_service(user) + llm = _get_llm_service(db) + _llm_model = crud.get_system_setting(db, "llm_model") + _user_id = user.id + + def _log_analyze_usage(prompt_tokens: int, completion_tokens: int) -> None: + from db.database import SessionLocal + log_db = SessionLocal() + try: + crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens, + model=_llm_model, context="audiobook_analyze") + finally: + log_db.close() if project.source_type == "epub" and project.source_path: ps.append_line(key, "[解析] 正在提取 EPUB 章节内容...") @@ -219,6 +234,7 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool on_token=on_token, on_sample=on_sample, turbo=turbo, + usage_callback=_log_analyze_usage, ) has_narrator = any(c.get("name") == "narrator" for c in characters_data) @@ -356,7 +372,19 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) -> try: crud.update_audiobook_chapter_status(db, chapter_id, "parsing") - llm = _get_llm_service(user) + llm = _get_llm_service(db) + _llm_model = crud.get_system_setting(db, "llm_model") + _user_id = user.id + + def _log_parse_usage(prompt_tokens: int, completion_tokens: int) -> None: + from db.database import SessionLocal + log_db = SessionLocal() + try: + crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens, + model=_llm_model, context="audiobook_parse") + finally: + log_db.close() + characters = crud.list_audiobook_characters(db, project_id) if not characters: raise ValueError("No characters found. Please analyze the project first.") @@ -383,7 +411,7 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) -> ps.append_token(key, token) try: - segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token) + segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token, usage_callback=_log_parse_usage) except Exception as e: logger.warning(f"Chapter {chapter_id} chunk {i} failed: {e}") ps.append_line(key, f"\n[回退] {e}") @@ -543,8 +571,11 @@ async def generate_project(project_id: int, user: User, db: Session, chapter_ind backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun" user_api_key = None - if backend_type == "aliyun" and user.aliyun_api_key: - user_api_key = decrypt_api_key(user.aliyun_api_key) + if backend_type == "aliyun": + from db.crud import get_system_setting + encrypted = get_system_setting(db, "aliyun_api_key") + if encrypted: + user_api_key = decrypt_api_key(encrypted) backend = await TTSServiceFactory.get_backend(backend_type, user_api_key) @@ -572,7 +603,23 @@ async def generate_project(project_id: int, user: User, db: Session, chapter_ind audio_filename = f"ch{seg.chapter_index:03d}_seg{seg.segment_index:04d}.wav" audio_path = output_base / audio_filename - if backend_type == "aliyun": + ref_audio_for_emo = design.ref_audio_path + if not ref_audio_for_emo: + preview_path = Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) / "previews" / f"char_{char.id}.wav" + if preview_path.exists(): + ref_audio_for_emo = str(preview_path) + + if seg.emo_text and ref_audio_for_emo and Path(ref_audio_for_emo).exists(): + from core.tts_service import IndexTTS2Backend + indextts2 = IndexTTS2Backend() + audio_bytes = await indextts2.generate( + text=seg.text, + spk_audio_prompt=ref_audio_for_emo, + output_path=str(audio_path), + emo_text=seg.emo_text, + emo_alpha=seg.emo_alpha if seg.emo_alpha is not None else 0.6, + ) + elif backend_type == "aliyun": if design.aliyun_voice_id: audio_bytes, _ = await backend.generate_voice_design( {"text": seg.text, "language": "zh"}, @@ -584,16 +631,6 @@ async def generate_project(project_id: int, user: User, db: Session, chapter_ind "language": "zh", "instruct": _get_gendered_instruct(char.gender, design.instruct), }) - elif char.use_indextts2 and design.ref_audio_path and Path(design.ref_audio_path).exists(): - from core.tts_service import IndexTTS2Backend - indextts2 = IndexTTS2Backend() - audio_bytes = await indextts2.generate( - text=seg.text, - spk_audio_prompt=design.ref_audio_path, - output_path=str(audio_path), - emo_text=seg.emo_text or None, - emo_alpha=seg.emo_alpha if seg.emo_text else 0.5, - ) else: if design.voice_cache_id: from core.cache_manager import VoiceCacheManager @@ -688,8 +725,11 @@ async def generate_single_segment(segment_id: int, user: User, db: Session) -> N backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun" user_api_key = None - if backend_type == "aliyun" and user.aliyun_api_key: - user_api_key = decrypt_api_key(user.aliyun_api_key) + if backend_type == "aliyun": + from db.crud import get_system_setting + encrypted = get_system_setting(db, "aliyun_api_key") + if encrypted: + user_api_key = decrypt_api_key(encrypted) backend = await TTSServiceFactory.get_backend(backend_type, user_api_key) @@ -709,7 +749,23 @@ async def generate_single_segment(segment_id: int, user: User, db: Session) -> N audio_filename = f"ch{seg.chapter_index:03d}_seg{seg.segment_index:04d}.wav" audio_path = output_base / audio_filename - if backend_type == "aliyun": + ref_audio_for_emo = design.ref_audio_path + if not ref_audio_for_emo: + preview_path = Path(settings.OUTPUT_DIR) / "audiobook" / str(seg.project_id) / "previews" / f"char_{char.id}.wav" + if preview_path.exists(): + ref_audio_for_emo = str(preview_path) + + if seg.emo_text and ref_audio_for_emo and Path(ref_audio_for_emo).exists(): + from core.tts_service import IndexTTS2Backend + indextts2 = IndexTTS2Backend() + audio_bytes = await indextts2.generate( + text=seg.text, + spk_audio_prompt=ref_audio_for_emo, + output_path=str(audio_path), + emo_text=seg.emo_text, + emo_alpha=seg.emo_alpha if seg.emo_alpha is not None else 0.6, + ) + elif backend_type == "aliyun": if design.aliyun_voice_id: audio_bytes, _ = await backend.generate_voice_design( {"text": seg.text, "language": "zh"}, @@ -721,16 +777,6 @@ async def generate_single_segment(segment_id: int, user: User, db: Session) -> N "language": "zh", "instruct": _get_gendered_instruct(char.gender, design.instruct), }) - elif char.use_indextts2 and design.ref_audio_path and Path(design.ref_audio_path).exists(): - from core.tts_service import IndexTTS2Backend - indextts2 = IndexTTS2Backend() - audio_bytes = await indextts2.generate( - text=seg.text, - spk_audio_prompt=design.ref_audio_path, - output_path=str(audio_path), - emo_text=seg.emo_text or None, - emo_alpha=seg.emo_alpha if seg.emo_text else 0.5, - ) else: if design.voice_cache_id: from core.cache_manager import VoiceCacheManager @@ -1070,7 +1116,9 @@ async def generate_character_preview(project_id: int, char_id: int, user: User, with open(audio_path, "wb") as f: f.write(audio_bytes) - + + design.ref_audio_path = str(audio_path) + db.commit() logger.info(f"Preview generated for char {char_id}: {audio_path}") except Exception as e: logger.error(f"Failed to generate preview for char {char_id}: {e}") diff --git a/qwen3-tts-backend/core/llm_service.py b/qwen3-tts-backend/core/llm_service.py index 985043a..9bf1800 100644 --- a/qwen3-tts-backend/core/llm_service.py +++ b/qwen3-tts-backend/core/llm_service.py @@ -1,7 +1,7 @@ import asyncio import json import logging -from typing import Any, Dict +from typing import Any, Callable, Dict, Optional import httpx @@ -14,7 +14,7 @@ class LLMService: self.api_key = api_key self.model = model - async def stream_chat(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192) -> str: + async def stream_chat(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192, usage_callback: Optional[Callable[[int, int], None]] = None) -> str: url = f"{self.base_url}/chat/completions" headers = { "Authorization": f"Bearer {self.api_key}", @@ -29,8 +29,10 @@ class LLMService: "temperature": 0.3, "max_tokens": max_tokens, "stream": True, + "stream_options": {"include_usage": True}, } full_text = "" + _usage = None timeout = httpx.Timeout(connect=10.0, read=90.0, write=10.0, pool=5.0) async with httpx.AsyncClient(timeout=timeout) as client: async with client.stream("POST", url, json=payload, headers=headers) as resp: @@ -46,6 +48,9 @@ class LLMService: break try: chunk = json.loads(data) + if chunk.get("usage"): + _usage = chunk["usage"] + continue delta = chunk["choices"][0]["delta"].get("content", "") if delta: full_text += delta @@ -53,10 +58,12 @@ class LLMService: on_token(delta) except (json.JSONDecodeError, KeyError, IndexError): continue + if _usage and usage_callback: + usage_callback(_usage.get("prompt_tokens", 0), _usage.get("completion_tokens", 0)) return full_text - async def stream_chat_json(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192): - raw = await self.stream_chat(system_prompt, user_message, on_token, max_tokens=max_tokens) + async def stream_chat_json(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192, usage_callback: Optional[Callable[[int, int], None]] = None): + raw = await self.stream_chat(system_prompt, user_message, on_token, max_tokens=max_tokens, usage_callback=usage_callback) raw = raw.strip() if not raw: raise ValueError("LLM returned empty response") @@ -74,7 +81,7 @@ class LLMService: logger.error(f"JSON parse failed. Raw (first 500): {raw[:500]}") raise - async def chat(self, system_prompt: str, user_message: str) -> str: + async def chat(self, system_prompt: str, user_message: str, usage_callback: Optional[Callable[[int, int], None]] = None) -> str: url = f"{self.base_url}/chat/completions" headers = { "Authorization": f"Bearer {self.api_key}", @@ -97,10 +104,13 @@ class LLMService: logger.error(f"LLM API error {resp.status_code}: {resp.text}") resp.raise_for_status() data = resp.json() + usage = data.get("usage", {}) + if usage and usage_callback: + usage_callback(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0)) return data["choices"][0]["message"]["content"] - async def chat_json(self, system_prompt: str, user_message: str) -> Any: - raw = await self.chat(system_prompt, user_message) + async def chat_json(self, system_prompt: str, user_message: str, usage_callback: Optional[Callable[[int, int], None]] = None) -> Any: + raw = await self.chat(system_prompt, user_message, usage_callback=usage_callback) raw = raw.strip() if not raw: raise ValueError("LLM returned empty response") @@ -118,7 +128,7 @@ class LLMService: logger.error(f"JSON parse failed. Raw response (first 500 chars): {raw[:500]}") raise - async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None, turbo: bool = False) -> list[Dict]: + async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None, turbo: bool = False, usage_callback: Optional[Callable[[int, int], None]] = None) -> list[Dict]: system_prompt = ( "你是一个专业的小说分析助手兼声音导演。请分析给定的小说文本,提取所有出现的角色(包括旁白narrator)。\n" "gender字段必须明确标注性别,只能取以下三个值之一:\"男\"、\"女\"、\"未知\"。\n" @@ -145,7 +155,7 @@ class LLMService: async def _extract_one(i: int, sample: str) -> list[Dict]: user_message = f"请分析以下小说文本并提取角色:\n\n{sample}" - result = await self.stream_chat_json(system_prompt, user_message, None) + result = await self.stream_chat_json(system_prompt, user_message, None, usage_callback=usage_callback) if on_sample: on_sample(i, len(text_samples)) return result.get("characters", []) @@ -160,14 +170,14 @@ class LLMService: logger.warning(f"Character extraction failed for sample {i+1}: {r}") else: raw_all.extend(r) - return await self.merge_characters(raw_all) + return await self.merge_characters(raw_all, usage_callback=usage_callback) raw_all: list[Dict] = [] for i, sample in enumerate(text_samples): logger.info(f"Extracting characters from sample {i+1}/{len(text_samples)}") user_message = f"请分析以下小说文本并提取角色:\n\n{sample}" try: - result = await self.stream_chat_json(system_prompt, user_message, on_token) + result = await self.stream_chat_json(system_prompt, user_message, on_token, usage_callback=usage_callback) raw_all.extend(result.get("characters", [])) except Exception as e: logger.warning(f"Character extraction failed for sample {i+1}: {e}") @@ -175,9 +185,9 @@ class LLMService: on_sample(i, len(text_samples)) if len(text_samples) == 1: return raw_all - return await self.merge_characters(raw_all) + return await self.merge_characters(raw_all, usage_callback=usage_callback) - async def merge_characters(self, raw_characters: list[Dict]) -> list[Dict]: + async def merge_characters(self, raw_characters: list[Dict], usage_callback: Optional[Callable[[int, int], None]] = None) -> list[Dict]: system_prompt = ( "你是一个专业的小说角色整合助手。你收到的是从同一本书不同段落中提取的角色列表,其中可能存在重复。\n" "请完成以下任务:\n" @@ -191,7 +201,7 @@ class LLMService: ) user_message = f"请整合以下角色列表:\n\n{json.dumps(raw_characters, ensure_ascii=False, indent=2)}" try: - result = await self.chat_json(system_prompt, user_message) + result = await self.chat_json(system_prompt, user_message, usage_callback=usage_callback) return result.get("characters", []) except Exception as e: logger.warning(f"Character merge failed, falling back to name-dedup: {e}") @@ -202,7 +212,7 @@ class LLMService: seen[name] = c return list(seen.values()) - async def parse_chapter_segments(self, chapter_text: str, character_names: list[str], on_token=None) -> list[Dict]: + async def parse_chapter_segments(self, chapter_text: str, character_names: list[str], on_token=None, usage_callback: Optional[Callable[[int, int], None]] = None) -> list[Dict]: names_str = "、".join(character_names) system_prompt = ( "你是一个专业的有声书制作助手。请将给定的章节文本解析为对话片段列表。" @@ -217,7 +227,7 @@ class LLMService: '{"character": "角色名", "text": "对话内容", "emo_text": "开心", "emo_alpha": 0.6}, ...]' ) user_message = f"请解析以下章节文本:\n\n{chapter_text}" - result = await self.stream_chat_json(system_prompt, user_message, on_token, max_tokens=16384) + result = await self.stream_chat_json(system_prompt, user_message, on_token, max_tokens=16384, usage_callback=usage_callback) if isinstance(result, list): return result return [] diff --git a/qwen3-tts-backend/db/crud.py b/qwen3-tts-backend/db/crud.py index d533b68..34ebfc8 100644 --- a/qwen3-tts-backend/db/crud.py +++ b/qwen3-tts-backend/db/crud.py @@ -1,9 +1,10 @@ import json from typing import Optional, List, Dict, Any from datetime import datetime +from sqlalchemy import func from sqlalchemy.orm import Session -from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookChapter, AudiobookCharacter, AudiobookSegment +from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookChapter, AudiobookCharacter, AudiobookSegment, UsageLog def get_user_by_username(db: Session, username: str) -> Optional[User]: return db.query(User).filter(User.username == username).first() @@ -385,6 +386,33 @@ def update_user_llm_config( return user +def get_system_setting(db: Session, key: str): + setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() + return setting.value if setting else None + + +def set_system_setting(db: Session, key: str, value) -> SystemSettings: + setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() + if setting: + setting.value = value + setting.updated_at = datetime.utcnow() + else: + setting = SystemSettings(key=key, value=value) + db.add(setting) + db.commit() + db.refresh(setting) + return setting + + +def delete_system_setting(db: Session, key: str) -> bool: + setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() + if setting: + db.delete(setting) + db.commit() + return True + return False + + def create_audiobook_project( db: Session, user_id: int, @@ -668,3 +696,81 @@ def delete_audiobook_segments(db: Session, project_id: int) -> None: def delete_audiobook_characters(db: Session, project_id: int) -> None: db.query(AudiobookCharacter).filter(AudiobookCharacter.project_id == project_id).delete() db.commit() + + +def create_usage_log( + db: Session, + user_id: int, + prompt_tokens: int, + completion_tokens: int, + model: Optional[str] = None, + context: Optional[str] = None, +) -> UsageLog: + log = UsageLog( + user_id=user_id, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + model=model, + context=context, + ) + db.add(log) + db.commit() + return log + + +def get_usage_stats( + db: Session, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, +) -> List[Dict]: + llm_query = db.query( + UsageLog.user_id, + func.sum(UsageLog.prompt_tokens).label("prompt_tokens"), + func.sum(UsageLog.completion_tokens).label("completion_tokens"), + ).group_by(UsageLog.user_id) + if date_from: + llm_query = llm_query.filter(UsageLog.created_at >= date_from) + if date_to: + llm_query = llm_query.filter(UsageLog.created_at <= date_to) + llm_rows = llm_query.all() + llm_map: Dict[int, Dict] = { + r.user_id: {"prompt_tokens": r.prompt_tokens or 0, "completion_tokens": r.completion_tokens or 0} + for r in llm_rows + } + + tts_query = db.query( + Job.user_id, + Job.backend_type, + func.count(Job.id).label("job_count"), + func.sum(func.coalesce(func.length(Job.input_data), 0)).label("char_count"), + ).filter(Job.status == "completed").group_by(Job.user_id, Job.backend_type) + if date_from: + tts_query = tts_query.filter(Job.created_at >= date_from) + if date_to: + tts_query = tts_query.filter(Job.created_at <= date_to) + tts_rows = tts_query.all() + tts_map: Dict[int, List] = {} + for r in tts_rows: + tts_map.setdefault(r.user_id, []).append({ + "backend_type": r.backend_type, + "job_count": r.job_count, + "char_count": r.char_count or 0, + }) + + user_ids = set(llm_map.keys()) | set(tts_map.keys()) + users = db.query(User).filter(User.id.in_(user_ids)).all() if user_ids else [] + user_info = {u.id: {"username": u.username, "email": u.email} for u in users} + + result = [] + for uid in sorted(user_ids): + info = user_info.get(uid, {"username": f"user_{uid}", "email": ""}) + llm = llm_map.get(uid, {"prompt_tokens": 0, "completion_tokens": 0}) + result.append({ + "user_id": uid, + "username": info["username"], + "email": info["email"], + "llm_prompt_tokens": llm["prompt_tokens"], + "llm_completion_tokens": llm["completion_tokens"], + "tts_backends": tts_map.get(uid, []), + }) + return result diff --git a/qwen3-tts-backend/db/models.py b/qwen3-tts-backend/db/models.py index b456c52..006ea18 100644 --- a/qwen3-tts-backend/db/models.py +++ b/qwen3-tts-backend/db/models.py @@ -47,6 +47,7 @@ class User(Base): voice_caches = relationship("VoiceCache", back_populates="user", cascade="all, delete-orphan") voice_designs = relationship("VoiceDesign", back_populates="user", cascade="all, delete-orphan") audiobook_projects = relationship("AudiobookProject", back_populates="user", cascade="all, delete-orphan") + usage_logs = relationship("UsageLog", back_populates="user", cascade="all, delete-orphan") class Job(Base): __tablename__ = "jobs" @@ -200,3 +201,17 @@ class AudiobookSegment(Base): __table_args__ = ( Index('idx_project_chapter', 'project_id', 'chapter_index', 'segment_index'), ) + + +class UsageLog(Base): + __tablename__ = "usage_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + prompt_tokens = Column(Integer, nullable=False, default=0) + completion_tokens = Column(Integer, nullable=False, default=0) + model = Column(String(200), nullable=True) + context = Column(String(100), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + user = relationship("User", back_populates="usage_logs") diff --git a/qwen3-tts-backend/main.py b/qwen3-tts-backend/main.py index bb06e2a..7e3fc75 100644 --- a/qwen3-tts-backend/main.py +++ b/qwen3-tts-backend/main.py @@ -15,7 +15,7 @@ from core.config import settings from core.database import init_db from core.model_manager import ModelManager from core.cleanup import run_scheduled_cleanup -from api import auth, jobs, tts, users, voice_designs, audiobook +from api import auth, jobs, tts, users, voice_designs, audiobook, admin from api.auth import get_current_user from schemas.user import User from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -152,6 +152,7 @@ app.include_router(tts.router) app.include_router(users.router) app.include_router(voice_designs.router) app.include_router(audiobook.router) +app.include_router(admin.router) @app.get("/health") async def health_check(): diff --git a/qwen3-tts-frontend/src/App.tsx b/qwen3-tts-frontend/src/App.tsx index ba2b008..787b592 100644 --- a/qwen3-tts-frontend/src/App.tsx +++ b/qwen3-tts-frontend/src/App.tsx @@ -17,6 +17,7 @@ const Settings = lazy(() => import('@/pages/Settings')) const UserManagement = lazy(() => import('@/pages/UserManagement')) const VoiceManagement = lazy(() => import('@/pages/VoiceManagement')) const Audiobook = lazy(() => import('@/pages/Audiobook')) +const AdminStats = lazy(() => import('@/pages/AdminStats')) function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth() @@ -118,6 +119,14 @@ function App() { } /> + + + + } + /> diff --git a/qwen3-tts-frontend/src/components/Navbar.tsx b/qwen3-tts-frontend/src/components/Navbar.tsx index 541752b..7866d6e 100644 --- a/qwen3-tts-frontend/src/components/Navbar.tsx +++ b/qwen3-tts-frontend/src/components/Navbar.tsx @@ -1,4 +1,4 @@ -import { Menu, LogOut, Users, Settings, Globe, Home, Mic, BookOpen } from 'lucide-react' +import { Menu, LogOut, Users, Settings, Globe, Home, Mic, BookOpen, BarChart2 } from 'lucide-react' import { Link, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' @@ -62,6 +62,13 @@ export function Navbar({ onToggleSidebar }: NavbarProps) { )} + {user?.is_superuser && ( + + + + )} + {isSuperuser && ( + + )} @@ -1066,6 +1071,7 @@ function ChaptersPanel({ export default function Audiobook() { const { t } = useTranslation('audiobook') + const { user } = useAuth() const [projects, setProjects] = useState([]) const [selectedProjectId, setSelectedProjectId] = useState(null) const [detail, setDetail] = useState(null) @@ -1447,6 +1453,7 @@ export default function Audiobook() { loading={loading} collapsed={!sidebarOpen} onToggle={() => setSidebarOpen(v => !v)} + isSuperuser={user?.is_superuser} />
diff --git a/qwen3-tts-frontend/src/pages/Settings.tsx b/qwen3-tts-frontend/src/pages/Settings.tsx index 77fe6d3..a9ce27b 100644 --- a/qwen3-tts-frontend/src/pages/Settings.tsx +++ b/qwen3-tts-frontend/src/pages/Settings.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import * as z from 'zod' import { toast } from 'sonner' import { useTranslation } from 'react-i18next' -import { Eye, EyeOff, Trash2, Check, X } from 'lucide-react' +import { Eye, EyeOff, Trash2, Check, X, ShieldCheck } from 'lucide-react' import { Navbar } from '@/components/Navbar' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -22,7 +22,7 @@ import { import { ChangePasswordDialog } from '@/components/users/ChangePasswordDialog' import { useAuth } from '@/contexts/AuthContext' import { useUserPreferences } from '@/contexts/UserPreferencesContext' -import { authApi } from '@/lib/api' +import { authApi, adminApi } from '@/lib/api' import type { PasswordChangeRequest } from '@/types/auth' const createApiKeySchema = (t: (key: string) => string) => z.object({ @@ -37,10 +37,21 @@ export default function Settings() { const [isLoading, setIsLoading] = useState(false) const [showPasswordDialog, setShowPasswordDialog] = useState(false) const [isPasswordLoading, setIsPasswordLoading] = useState(false) + const [llmBaseUrl, setLlmBaseUrl] = useState('') + const [llmApiKey, setLlmApiKey] = useState('') + const [llmModel, setLlmModel] = useState('') + const [llmConfig, setLlmConfig] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null) + const [isLlmLoading, setIsLlmLoading] = useState(false) const apiKeySchema = createApiKeySchema(t) type ApiKeyFormValues = z.infer + useEffect(() => { + if (user?.is_superuser) { + adminApi.getLLMConfig().then(setLlmConfig).catch(() => {}) + } + }, [user?.is_superuser]) + const form = useForm({ resolver: zodResolver(apiKeySchema), defaultValues: { @@ -60,7 +71,7 @@ export default function Settings() { const handleUpdateKey = async (data: ApiKeyFormValues) => { try { setIsLoading(true) - await authApi.setAliyunKey(data.api_key) + await adminApi.setAliyunKey(data.api_key) await refetchPreferences() form.reset() toast.success(t('settings:apiKeyUpdated')) @@ -74,7 +85,7 @@ export default function Settings() { const handleVerifyKey = async () => { try { setIsLoading(true) - const result = await authApi.verifyAliyunKey() + const result = await adminApi.verifyAliyunKey() if (result.valid) { toast.success(t('settings:apiKeySaved')) } else { @@ -95,7 +106,7 @@ export default function Settings() { try { setIsLoading(true) - await authApi.deleteAliyunKey() + await adminApi.deleteAliyunKey() await refetchPreferences() toast.success(t('settings:keyDeleted')) } catch (error: any) { @@ -105,6 +116,39 @@ export default function Settings() { } } + const handleSaveLlmConfig = async () => { + if (!llmBaseUrl || !llmApiKey || !llmModel) { + toast.error('请填写完整的 LLM 配置') + return + } + setIsLlmLoading(true) + try { + await adminApi.setLLMConfig({ base_url: llmBaseUrl, api_key: llmApiKey, model: llmModel }) + toast.success('LLM 配置已保存') + setLlmApiKey('') + const updated = await adminApi.getLLMConfig() + setLlmConfig(updated) + } catch (error: any) { + toast.error(error.message || 'LLM 配置保存失败') + } finally { + setIsLlmLoading(false) + } + } + + const handleDeleteLlmConfig = async () => { + if (!confirm('确认删除 LLM 配置?')) return + setIsLlmLoading(true) + try { + await adminApi.deleteLLMConfig() + setLlmConfig(null) + toast.success('LLM 配置已删除') + } catch (error: any) { + toast.error(error.message || '删除失败') + } finally { + setIsLlmLoading(false) + } + } + const handleChangePassword = async (data: PasswordChangeRequest) => { try { setIsPasswordLoading(true) @@ -175,8 +219,13 @@ export default function Settings() { - {t('settings:aliyunApiKey')} - {t('settings:apiKeyDescription')} + + {t('settings:aliyunApiKey')} + {user.is_superuser && } + + + {user.is_superuser ? t('settings:apiKeyDescription') : '由管理员统一配置,所有用户共用'} +
@@ -194,78 +243,95 @@ export default function Settings() { )}
-
- - ( - - {t('settings:apiKey')} - -
-
- - + {user.is_superuser && ( + + + ( + + {t('settings:apiKey')} + +
+
+ + +
-
- - - - )} - /> - -
- - {hasAliyunKey && ( - <> - - - - )} -
- - + + + + )} + /> +
+ + {hasAliyunKey && ( + <> + + + + )} +
+ + + )} + {user.is_superuser && ( + + + + LLM 配置 + + + 配置用于有声书角色分析和章节解析的 LLM API,对所有用户生效 + + + {llmConfig && ( +
+ 当前:{llmConfig.base_url || '未设置'} / {llmConfig.model || '未设置'} / {llmConfig.has_key ? 'API Key 已配置' : '未配置 API Key'} +
+ )} + setLlmBaseUrl(e.target.value)} disabled={isLlmLoading} /> + setLlmApiKey(e.target.value)} disabled={isLlmLoading} /> + setLlmModel(e.target.value)} disabled={isLlmLoading} /> +
+ + {llmConfig?.has_key && ( + + )} +
+
+
+ )} + {t('settings:accountInfo')}