""" TiniX Story API Server FastAPI wrapper cho toàn bộ service Python hiện có """ from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, FileResponse from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any import json import logging import uvicorn import os from pathlib import Path from datetime import datetime # Import existing services from services.api_client import get_api_client, reinit_api_client from core.config import get_config, GenerationConfig, API_PROVIDERS from core.config_api import ConfigAPIManager from services.novel_generator import ( NovelGenerator, NovelProject, Chapter, OutlineParser, get_preset_templates, get_generator, get_cache_size, list_generation_caches, clear_generation_cache ) from services.project_manager import ProjectManager from services.genre_manager import GenreManager from services.sub_genre_manager import SubGenreManager from services.style_manager import StyleManager from utils.exporter import export_to_docx, export_to_txt, export_to_markdown, export_to_html from locales.i18n import t from core.database import get_db from core.state import app_state from core.task_manager import task_manager, TaskStatus logger = logging.getLogger(__name__) app = FastAPI(title="TiniX Story API", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ==================== Request/Response Models ==================== class ProjectCreateReq(BaseModel): title: str genre: str sub_genres: List[str] = [] character_setting: str = "" world_setting: str = "" plot_idea: str = "" class SuggestionReq(BaseModel): type: str # 'title', 'char', 'world', 'plot' genre: str = "" sub_genres: List[str] = [] title: str = "" character_setting: str = "" world_setting: str = "" custom_prompt: str = "" num_main_chars: int = 2 num_sub_chars: int = 3 class OutlineReq(BaseModel): title: str genre: str sub_genres: List[str] = [] total_chapters: int = 20 character_setting: str world_setting: str plot_idea: str custom_outline_prompt: str = "" class ChapterGenReq(BaseModel): use_reflection: bool = False class BulkGenReq(BaseModel): project_id: str chapter_nums: List[int] custom_prompt: str = "" use_reflection: bool = False class SaveChapterReq(BaseModel): project_id: str chapter_num: int content: str class RewriteReq(BaseModel): text: str style_template: str = "" use_reflection: bool = False class PolishReq(BaseModel): text: str polish_type: str = "general" custom_requirements: str = "" use_reflection: bool = False class SummaryReq(BaseModel): text: str max_length: int = 200 class ExportReq(BaseModel): project_id: str format: str = "txt" # txt, docx, md, html class GenParamsReq(BaseModel): temperature: Optional[float] = None top_p: Optional[float] = None max_tokens: Optional[int] = None chapter_target_words: Optional[int] = None writing_style: Optional[str] = None writing_tone: Optional[str] = None character_development: Optional[str] = None plot_complexity: Optional[str] = None class BackendReq(BaseModel): name: str type: str = "openai" base_url: str = "" api_key: str = "" model: str = "" timeout: int = 120 retry_times: int = 3 enabled: bool = True class GenreReq(BaseModel): name: str description: str = "" class StyleReq(BaseModel): name: str description: str = "" class UpdateOutlineReq(BaseModel): project_id: str outline_text: str # ==================== Helpers ==================== def _project_to_dict(project: NovelProject) -> Dict: """Serialize NovelProject to JSON-safe dict""" return { "id": getattr(project, 'id', ''), "title": project.title, "genre": project.genre, "sub_genres": project.sub_genres if isinstance(project.sub_genres, list) else [], "character_setting": project.character_setting or "", "world_setting": project.world_setting or "", "plot_idea": project.plot_idea or "", "created_at": project.created_at, "updated_at": project.updated_at, "chapters": [ { "num": ch.num, "title": ch.title, "desc": ch.desc, "content": ch.content or "", "word_count": ch.word_count, "generated_at": ch.generated_at } for ch in project.chapters ], "completed_count": project.get_completed_count(), "total_words": project.get_total_words(), } def _get_generator() -> NovelGenerator: return app_state.get_generator() # ==================== Health ==================== @app.get("/health") async def health_check(): return {"status": "ok", "version": "1.0.0"} # ==================== Projects ==================== @app.get("/projects") async def list_projects(): return ProjectManager.list_projects() @app.get("/projects/{project_id}") async def get_project(project_id: str): project, msg = ProjectManager.load_project(project_id) if not project: raise HTTPException(status_code=404, detail=msg) return _project_to_dict(project) @app.post("/projects") async def create_project(req: ProjectCreateReq): project, msg = ProjectManager.create_project( req.title, req.genre, req.sub_genres, req.character_setting, req.world_setting, req.plot_idea ) if not project: raise HTTPException(status_code=400, detail=msg) ProjectManager.save_project(project) return _project_to_dict(project) @app.put("/projects/{project_id}") async def update_project(project_id: str, req: ProjectCreateReq): project, msg = ProjectManager.load_project(project_id) if not project: raise HTTPException(status_code=404, detail=msg) project.title = req.title project.genre = req.genre project.sub_genres = req.sub_genres project.character_setting = req.character_setting project.world_setting = req.world_setting project.plot_idea = req.plot_idea ProjectManager.save_project(project) return _project_to_dict(project) @app.delete("/projects/{project_id}") async def delete_project(project_id: str): success, msg = ProjectManager.delete_project(project_id) if not success: raise HTTPException(status_code=404, detail=msg) return {"message": msg} @app.post("/projects/update-outline") async def update_outline(req: UpdateOutlineReq): project, msg = ProjectManager.load_project(req.project_id) if not project: raise HTTPException(status_code=404, detail=msg) chapters, parse_msg = OutlineParser.parse(req.outline_text) if not chapters: raise HTTPException(status_code=400, detail=parse_msg) # Preserve existing content for chapters that already have it old_content = {ch.num: ch for ch in project.chapters} for ch in chapters: if ch.num in old_content and old_content[ch.num].content: ch.content = old_content[ch.num].content ch.word_count = old_content[ch.num].word_count ch.generated_at = old_content[ch.num].generated_at project.chapters = chapters ProjectManager.save_project(project) return _project_to_dict(project) # ==================== AI Suggestions ==================== @app.post("/suggest") async def suggest(req: SuggestionReq): gen = _get_generator() if req.type == "title": content, msg = gen.suggest_title(req.genre, req.sub_genres, req.custom_prompt) else: content, msg = gen.suggest_content( req.type, req.title, req.genre, req.sub_genres, req.character_setting, req.world_setting, req.custom_prompt, req.num_main_chars, req.num_sub_chars ) return {"content": content, "message": msg} @app.post("/generate-outline") async def generate_outline(req: OutlineReq): gen = _get_generator() content, msg = gen.generate_outline( req.title, req.genre, req.sub_genres, req.total_chapters, req.character_setting, req.world_setting, req.plot_idea, req.custom_outline_prompt ) return {"content": content, "message": msg} @app.post("/parse-outline") async def parse_outline(data: Dict[str, str]): text = data.get("text", "") chapters, msg = OutlineParser.parse(text) return {"chapters": [c.to_dict() for c in chapters], "message": msg} # ==================== Chapter Generation ==================== @app.post("/generate-chapter") async def generate_chapter(req: ChapterGenReq): project, msg = ProjectManager.load_project(req.project_id) if not project: raise HTTPException(status_code=404, detail=msg) chapter = next((c for c in project.chapters if c.num == req.chapter_num), None) if not chapter: raise HTTPException(status_code=404, detail=f"Chapter {req.chapter_num} not found") prev_chapters = [c for c in project.chapters if c.num < req.chapter_num and c.content] previous_content = "" if prev_chapters: sorted_prev = sorted(prev_chapters, key=lambda x: x.num) previous_content = sorted_prev[-1].content[-3000:] if sorted_prev[-1].content else "" gen = _get_generator() content, gen_msg = gen.generate_chapter( req.chapter_num, chapter.title, chapter.desc, project.title, project.character_setting, project.world_setting, project.plot_idea, project.genre, project.sub_genres, previous_content, custom_prompt=req.custom_prompt, use_reflection=req.use_reflection ) if content: chapter.content = content chapter.word_count = len(content) chapter.generated_at = datetime.now().isoformat() ProjectManager.save_project(project) return {"content": content, "message": gen_msg, "word_count": len(content) if content else 0} @app.post("/generate-chapter-stream") async def generate_chapter_stream(req: ChapterGenReq): project, msg = ProjectManager.load_project(req.project_id) if not project: raise HTTPException(status_code=404, detail=msg) chapter = next((c for c in project.chapters if c.num == req.chapter_num), None) if not chapter: raise HTTPException(status_code=404, detail=f"Chapter {req.chapter_num} not found") prev_chapters = [c for c in project.chapters if c.num < req.chapter_num and c.content] previous_content = "" if prev_chapters: sorted_prev = sorted(prev_chapters, key=lambda x: x.num) previous_content = sorted_prev[-1].content[-3000:] if sorted_prev[-1].content else "" gen = _get_generator() async def event_generator(): full_content = "" for success, chunk in gen.generate_chapter_stream( req.chapter_num, chapter.title, chapter.desc, project.title, project.character_setting, project.world_setting, project.plot_idea, project.genre, project.sub_genres, previous_content, custom_prompt=req.custom_prompt, use_reflection=req.use_reflection ): if success: full_content += chunk yield f"data: {json.dumps({'chunk': chunk})}\n\n" else: yield f"data: {json.dumps({'error': chunk})}\n\n" # Auto-save after streaming completes if full_content: chapter.content = full_content chapter.word_count = len(full_content) chapter.generated_at = datetime.now().isoformat() ProjectManager.save_project(project) yield "data: [DONE]\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") @app.post("/save-chapter") async def save_chapter(req: SaveChapterReq): project, msg = ProjectManager.load_project(req.project_id) if not project: raise HTTPException(status_code=404, detail=msg) found = False for ch in project.chapters: if ch.num == req.chapter_num: ch.content = req.content ch.word_count = len(req.content) ch.generated_at = datetime.now().isoformat() found = True break if not found: raise HTTPException(status_code=404, detail=f"Chapter {req.chapter_num} not found") ProjectManager.save_project(project) return {"message": "Chapter saved", "word_count": len(req.content)} # ==================== Writing Tools ==================== @app.post("/rewrite") async def rewrite(req: RewriteReq): gen = _get_generator() content, msg = gen.rewrite_paragraph(req.text, req.style_template, req.use_reflection) return {"content": content, "message": msg} @app.post("/polish") async def polish(req: PolishReq): gen = _get_generator() content, msg = gen.polish_text(req.text, req.polish_type, req.custom_requirements, req.use_reflection) return {"content": content, "message": msg} @app.post("/summary") async def summary(req: SummaryReq): gen = _get_generator() content, msg = gen.generate_summary(req.text, req.max_length) return {"content": content, "message": msg} @app.post("/export") async def export_project(req: ExportReq): project, msg = ProjectManager.load_project(req.project_id) if not project: raise HTTPException(status_code=404, detail=msg) full_text = f"# {project.title}\n\n" for ch in project.chapters: if ch.content: full_text += f"## Chương {ch.num}: {ch.title}\n\n" full_text += ch.content + "\n\n" if len(full_text.strip()) < 50: raise HTTPException(status_code=400, detail="No content to export") export_map = { "docx": export_to_docx, "txt": export_to_txt, "md": export_to_markdown, "html": export_to_html, } exporter = export_map.get(req.format) if not exporter: raise HTTPException(status_code=400, detail=f"Unsupported format: {req.format}") filepath, exp_msg = exporter(full_text, project.title) if not filepath: raise HTTPException(status_code=500, detail=exp_msg) return FileResponse(filepath, filename=os.path.basename(filepath), media_type="application/octet-stream") # ==================== Background Task Coroutines ==================== async def generate_bulk_task(task, req: BulkGenReq): project, msg = ProjectManager.load_project(req.project_id) if not project: task.update(status=TaskStatus.FAILED, message=msg) return gen = _get_generator() total = len(req.chapter_nums) for i, ch_num in enumerate(req.chapter_nums): if task.is_cancelled(): break task.update(message=f"Generating chapter {ch_num} ({i+1}/{total})", progress=(i / total) * 100) chapter = next((c for c in project.chapters if c.num == ch_num), None) if not chapter: logger.error(f"Chapter {ch_num} not found in project {req.project_id}") continue # Get context from previous chapters prev_chapters = [c for c in project.chapters if c.num < ch_num and c.content] previous_content = "" if prev_chapters: sorted_prev = sorted(prev_chapters, key=lambda x: x.num) previous_content = sorted_prev[-1].content[-3000:] if sorted_prev[-1].content else "" # Run generation # Note: NovelGenerator.generate_chapter is currently sync. # In a real async app we should make it async, but for now we run it in a thread if needed. # Since this is already in a background task, it's okay for now. content, gen_msg = await asyncio.to_thread( gen.generate_chapter, ch_num, chapter.title, chapter.desc, project.title, project.character_setting, project.world_setting, project.plot_idea, project.genre, project.sub_genres, previous_content, custom_prompt=req.custom_prompt, use_reflection=req.use_reflection ) if content: chapter.content = content chapter.word_count = len(content) chapter.generated_at = datetime.now().isoformat() ProjectManager.save_project(project) else: logger.error(f"Failed to generate chapter {ch_num}: {gen_msg}") if not task.is_cancelled(): task.update(status=TaskStatus.COMPLETED, progress=100.0, message=f"Successfully generated {total} chapters") # ==================== Projects/Tasks Endpoints Extensions ==================== @app.post("/tasks/generate-bulk") async def start_bulk_generation(req: BulkGenReq): task = await task_manager.create_task( name=f"Bulk Generation for {req.project_id}", task_type="generate_bulk", metadata={"project_id": req.project_id, "chapters": req.chapter_nums} ) # Start the task in background asyncio.create_task(task_manager.run_task(task.id, generate_bulk_task, req)) return {"task_id": task.id, "message": "Bulk generation started"} # ==================== Genres ==================== @app.get("/genres") async def list_genres(): return GenreManager.list_genres() @app.post("/genres") async def add_genre(req: GenreReq): success = GenreManager.add_genre(req.name, req.description) if not success: raise HTTPException(status_code=400, detail="Genre already exists or error") return {"message": "Genre added", "name": req.name} @app.put("/genres/{name}") async def update_genre(name: str, req: GenreReq): success = GenreManager.update_genre(name, req.name, req.description) if not success: raise HTTPException(status_code=400, detail="Update failed") return {"message": "Genre updated"} @app.delete("/genres/{name}") async def delete_genre(name: str): success = GenreManager.delete_genre(name) if not success: raise HTTPException(status_code=400, detail="Delete failed") return {"message": "Genre deleted"} # ==================== Sub-Genres ==================== @app.get("/sub-genres") async def list_all_sub_genres(): return SubGenreManager.get_sub_genre_names() @app.get("/sub-genres/by-genre/{genre}") async def list_sub_genres_by_genre(genre: str): return SubGenreManager.get_sub_genres_by_genre(genre) @app.post("/sub-genres") async def add_sub_genre(req: GenreReq): success = SubGenreManager.add_sub_genre(req.name, req.description) if not success: raise HTTPException(status_code=400, detail="Sub-genre already exists or error") return {"message": "Sub-genre added", "name": req.name} @app.put("/sub-genres/{name}") async def update_sub_genre(name: str, req: GenreReq): success = SubGenreManager.update_sub_genre(name, req.name, req.description) if not success: raise HTTPException(status_code=400, detail="Update failed") return {"message": "Sub-genre updated"} @app.delete("/sub-genres/{name}") async def delete_sub_genre(name: str): success = SubGenreManager.delete_sub_genre(name) if not success: raise HTTPException(status_code=400, detail="Delete failed") return {"message": "Sub-genre deleted"} # ==================== Styles ==================== @app.get("/styles") async def list_styles(): return StyleManager.get_style_names() @app.get("/styles/all") async def list_styles_full(): return StyleManager.load_styles() @app.post("/styles") async def add_style(req: StyleReq): success = StyleManager.add_style(req.name, req.description) if not success: raise HTTPException(status_code=400, detail="Style already exists or error") return {"message": "Style added", "name": req.name} @app.put("/styles/{name}") async def update_style(name: str, req: StyleReq): success = StyleManager.update_style(name, req.name, req.description) if not success: raise HTTPException(status_code=400, detail="Update failed") return {"message": "Style updated"} @app.delete("/styles/{name}") async def delete_style(name: str): success = StyleManager.delete_style(name) if not success: raise HTTPException(status_code=400, detail="Delete failed") return {"message": "Style deleted"} # ==================== Config / Settings ==================== @app.get("/config/backends") async def config_list_backends(): return ConfigAPIManager.list_backends() @app.post("/config/backends") async def config_add_backend(req: BackendReq): result = ConfigAPIManager.add_backend( req.name, req.type, req.base_url, req.api_key, req.model, req.timeout, req.retry_times, req.enabled ) if result["success"]: reinit_api_client() app_state.generator = None return result @app.put("/config/backends/{name}") async def config_update_backend(name: str, req: BackendReq): result = ConfigAPIManager.update_backend( name, name=req.name, type=req.type, base_url=req.base_url, api_key=req.api_key, model=req.model, timeout=req.timeout ) if result["success"]: reinit_api_client() app_state.generator = None return result @app.delete("/config/backends/{name}") async def config_delete_backend(name: str): result = ConfigAPIManager.delete_backend(name) if result["success"]: reinit_api_client() app_state.generator = None return result @app.post("/config/backends/{name}/test") async def config_test_backend(name: str): return ConfigAPIManager.test_backend(name) @app.get("/config/generation") async def config_get_generation(): cfg = get_config() gen = cfg.generation return { "temperature": gen.temperature, "top_p": gen.top_p, "max_tokens": gen.max_tokens, "chapter_target_words": gen.chapter_target_words, "writing_style": gen.writing_style, "writing_tone": gen.writing_tone, "character_development": gen.character_development, "plot_complexity": gen.plot_complexity, } @app.put("/config/generation") async def config_update_generation(req: GenParamsReq): cfg = get_config() params = {k: v for k, v in req.model_dump().items() if v is not None} success, msg = cfg.update_generation_config(**params) if success: app_state.generator = None return {"success": success, "message": msg} @app.get("/config/providers") async def config_list_providers(): return API_PROVIDERS # ==================== Cache ==================== @app.get("/cache/stats") async def cache_stats(): try: api_client = get_api_client() stats = api_client.get_cache_stats() gen_caches = list_generation_caches() gen_size = get_cache_size() return { "api_cache": stats, "generation_cache_count": len(gen_caches), "generation_cache_size_kb": round(gen_size / 1024, 1) } except Exception as e: return {"error": str(e)} @app.delete("/cache") async def clear_cache(): try: api_client = get_api_client() api_client.clear_cache() clear_generation_cache() return {"message": "All caches cleared"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ==================== Task Management ==================== @app.get("/tasks") async def list_tasks(): return task_manager.list_tasks() @app.get("/tasks/{task_id}") async def get_task_status(task_id: str): task = task_manager.get_task(task_id) if not task: raise HTTPException(status_code=404, detail="Task not found") return task.to_dict() @app.delete("/tasks/{task_id}") async def cancel_task(task_id: str): task = task_manager.get_task(task_id) if not task: raise HTTPException(status_code=404, detail="Task not found") task.cancel() return {"message": "Task cancellation requested"} # ==================== Entry ==================== if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")