Files
aistory-generate/main_api.py
T
2026-07-10 07:24:34 +07:00

702 lines
24 KiB
Python

"""
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, new_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")