From a40d8ef4a685c42650ba8bdc343bba394f463c94 Mon Sep 17 00:00:00 2001 From: tinix-ai Date: Tue, 17 Mar 2026 14:54:16 +0700 Subject: [PATCH] refactor: Restructure project into `core`, `services`, `ui`, and `utils` directories, moving existing modules and adding new UI tabs. --- app.py | 962 +----------------- core/__init__.py | 0 config.py => core/config.py | 4 +- config_api.py => core/config_api.py | 6 +- database.py => core/database.py | 0 logger.py => core/logger.py | 0 core/state.py | 21 + data/tinix_story.db-shm | Bin 32768 -> 32768 bytes data/tinix_story.db-wal | Bin 86552 -> 0 bytes services/__init__.py | 0 api_client.py => services/api_client.py | 4 +- services/data/genres.json | 150 +++ services/data/sub_genres.json | 402 ++++++++ genre_manager.py => services/genre_manager.py | 0 .../novel_generator.py | 24 +- .../project_manager.py | 16 +- .../sub_genre_manager.py | 0 ui/__init__.py | 0 ui/continue_tab.py | 362 +++++++ ui/create_tab.py | 400 ++++++++ ui/export_tab.py | 92 ++ ui/polish_tab.py | 61 ++ ui/projects_tab.py | 69 ++ ui/rewrite_tab.py | 52 + ui/settings_tab.py | 298 ++++++ utils/__init__.py | 0 exporter.py => utils/exporter.py | 0 file_parser.py => utils/file_parser.py | 0 28 files changed, 1970 insertions(+), 953 deletions(-) create mode 100644 core/__init__.py rename config.py => core/config.py (99%) rename config_api.py => core/config_api.py (98%) rename database.py => core/database.py (100%) rename logger.py => core/logger.py (100%) create mode 100644 core/state.py create mode 100644 services/__init__.py rename api_client.py => services/api_client.py (99%) create mode 100644 services/data/genres.json create mode 100644 services/data/sub_genres.json rename genre_manager.py => services/genre_manager.py (100%) rename novel_generator.py => services/novel_generator.py (98%) rename project_manager.py => services/project_manager.py (96%) rename sub_genre_manager.py => services/sub_genre_manager.py (100%) create mode 100644 ui/__init__.py create mode 100644 ui/continue_tab.py create mode 100644 ui/create_tab.py create mode 100644 ui/export_tab.py create mode 100644 ui/polish_tab.py create mode 100644 ui/projects_tab.py create mode 100644 ui/rewrite_tab.py create mode 100644 ui/settings_tab.py create mode 100644 utils/__init__.py rename exporter.py => utils/exporter.py (100%) rename file_parser.py => utils/file_parser.py (100%) diff --git a/app.py b/app.py index 77e5d07..17333ce 100644 --- a/app.py +++ b/app.py @@ -16,23 +16,23 @@ from datetime import datetime import os # Import các module hiện có -from api_client import APIClient, get_api_client, reinit_api_client -from config import get_config, ConfigManager, Backend, GenerationConfig, API_PROVIDERS -from novel_generator import ( +from services.api_client import APIClient, get_api_client, reinit_api_client +from core.config import get_config, ConfigManager, Backend, GenerationConfig, API_PROVIDERS +from services.novel_generator import ( NovelGenerator, NovelProject, Chapter, OutlineParser, get_preset_templates, get_generator, save_generation_cache, load_generation_cache, clear_generation_cache, list_generation_caches, get_cache_size ) -from project_manager import ProjectManager -from config_api import ConfigAPIManager -from exporter import export_to_docx, export_to_txt, export_to_markdown, export_to_html -from genre_manager import GenreManager -from sub_genre_manager import SubGenreManager -from file_parser import parse_novel_file +from services.project_manager import ProjectManager +from core.config_api import ConfigAPIManager +from utils.exporter import export_to_docx, export_to_txt, export_to_markdown, export_to_html +from services.genre_manager import GenreManager +from services.sub_genre_manager import SubGenreManager +from utils.file_parser import parse_novel_file from locales.i18n import t -from database import get_db -from logger import get_logger +from core.database import get_db +from core.logger import get_logger # ==================== Cấu hình Logging ==================== @@ -47,38 +47,8 @@ WEB_PORT = int(os.getenv("NOVEL_TOOL_PORT", os.getenv("PORT", "7860"))) WEB_SHARE = os.getenv("NOVEL_TOOL_SHARE", "false").lower() in ("1", "true", "yes") -# ==================== Quản lý trạng thái ứng dụng ==================== - -class AppState: - """Quản lý trạng thái ứng dụng""" - - def __init__(self): - self.is_generating = False - self.stop_requested = False - self.lock = threading.Lock() - self.current_project: Optional[NovelProject] = None - self.generator: Optional[NovelGenerator] = None - - def get_generator(self) -> NovelGenerator: - """Lấy hoặc tạo generator""" - if self.generator is None: - self.generator = get_generator() - return self.generator - - -# Trạng thái ứng dụng toàn cục -app_state = AppState() - - -# ==================== Hàm tiện ích ==================== - -def list_project_titles(): - """Lấy danh sách tiêu đề dự án""" - try: - projects = ProjectManager.list_projects() - return [p["title"] for p in projects] - except Exception: - return [] +from core.state import app_state +from services.project_manager import ProjectManager, list_project_titles # ==================== Giao diện chính ==================== @@ -103,895 +73,21 @@ def create_main_ui(): """) with gr.Tabs(): - # ==================== Tab 1: Sáng tác từ đầu ==================== - with gr.Tab(t("tabs.create")): - gr.Markdown(f"### {t('create.header')}") - - with gr.Row(): - with gr.Column(scale=1): - genre_choices = GenreManager.get_genre_names() - genre_dropdown = gr.Dropdown( - choices=genre_choices, - value=genre_choices[0] if genre_choices else None, - label=t("create.genre_label"), - interactive=True - ) - sub_genre_choices = SubGenreManager.get_sub_genre_names() - sub_genre_dropdown = gr.Dropdown( - choices=sub_genre_choices, - label=t("create.sub_genres_label"), - multiselect=True, - interactive=True - ) - - with gr.Column(scale=2): - title_input = gr.Textbox( - label=t("create.novel_title"), - placeholder=t("create.novel_title_default"), - interactive=True - ) - suggested_titles_radio = gr.Radio( - label=t("create.suggested_titles_radio") if t("create.suggested_titles_radio") != "create.suggested_titles_radio" else "Gợi ý tên truyện", - choices=[], - visible=False, - interactive=True - ) - with gr.Row(): - suggest_title_btn = gr.Button(t("create.suggest_title_btn"), variant="secondary", size="sm") - suggest_title_prompt = gr.Textbox( - label=t("create.custom_prompt_label"), - placeholder=t("create.custom_prompt_placeholder"), - lines=1, interactive=True, scale=3 - ) - - # Thiết lập nhân vật - with gr.Row(): - with gr.Column(): - character_input = gr.Textbox( - label=t("create.char_setting"), - placeholder=t("create.char_setting_placeholder"), - lines=5, interactive=True - ) - with gr.Row(): - suggest_char_btn = gr.Button(t("create.suggest_btn"), variant="secondary", size="sm") - num_main_chars = gr.Number(label=t("create.num_main_chars"), value=2, minimum=1, maximum=10, step=1, scale=1) - num_sub_chars = gr.Number(label=t("create.num_sub_chars"), value=3, minimum=0, maximum=20, step=1, scale=1) - suggest_char_prompt = gr.Textbox(label=t("create.custom_prompt_label"), placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=True, scale=2) - - with gr.Column(): - world_input = gr.Textbox( - label=t("create.world_setting"), - placeholder=t("create.world_setting_placeholder"), - lines=5, interactive=True - ) - with gr.Row(): - suggest_world_btn = gr.Button(t("create.suggest_btn"), variant="secondary", size="sm") - suggest_world_prompt = gr.Textbox(label=t("create.custom_prompt_label"), placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=True, scale=3) - - # Ý tưởng cốt truyện - plot_input = gr.Textbox( - label=t("create.plot_idea"), - placeholder=t("create.plot_idea_placeholder"), - lines=3, interactive=True - ) - with gr.Row(): - suggest_plot_btn = gr.Button(t("create.suggest_plot_btn"), variant="secondary", size="sm") - suggest_plot_prompt = gr.Textbox(label=t("create.custom_prompt_label"), placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=True, scale=3) - - # Số chương và tạo dàn ý - with gr.Row(): - total_chapters = gr.Number( - label=t("create.chapter_count"), value=20, minimum=1, maximum=200, step=1 - ) - generate_outline_btn = gr.Button(t("create.gen_outline_btn"), variant="primary", size="lg") - - outline_output = gr.Textbox(label=t("create.outline_display"), lines=15, interactive=True) - outline_status = gr.Textbox(label=t("create.gen_status"), interactive=False) - - # Tạo toàn bộ tiểu thuyết - gr.Markdown("---") - with gr.Row(): - auto_generate_btn = gr.Button(t("create.start_gen_btn"), variant="primary", size="lg") - stop_btn = gr.Button(t("create.pause_gen_btn"), variant="stop", size="lg") - - generation_progress = gr.Textbox(label=t("create.gen_status"), lines=10, interactive=False) - - # --- Sự kiện Tab Sáng tác --- - def on_suggest_title(genre, sub_genres, custom_prompt): - gen = app_state.get_generator() - content, msg = gen.suggest_title(genre, sub_genres, custom_prompt) - if content: - import json - import re - try: - # Tìm array JSON [...] trực tiếp để tránh rác text bao quanh - match = re.search(r'\[\s*\{[\s\S]*\}\s*\]', content) - if match: - data = {"suggestions": json.loads(match.group(0))} - else: - clean_content = re.sub(r'```(?:json)?\s*', '', content) - clean_content = re.sub(r'```\s*', '', clean_content) - data = json.loads(clean_content) - - if "suggestions" in data: - choices = [f"{item.get('title', '')} - {item.get('description', '')}" for item in data["suggestions"]] - return gr.update(choices=choices, visible=True) - except Exception as e: - logger.error(f"Failed to parse title JSON: {e}") - return gr.update(choices=[content], visible=True) - return gr.update(choices=[msg], visible=True) - - def on_title_select(selected): - if selected and " - " in selected: - return selected.split(" - ", 1)[0] - return selected - - def on_suggest_content(suggest_type, title, genre, sub_genres, char_setting, world_setting, custom_prompt, num_main=2, num_sub=3): - gen = app_state.get_generator() - content, msg = gen.suggest_content( - suggest_type, title, genre, sub_genres, - char_setting, world_setting, custom_prompt, - int(num_main), int(num_sub) - ) - return content if content else msg - - def on_generate_outline(title, genre, sub_genres, num_chapters, char_setting, world_setting, plot_idea): - gen = app_state.get_generator() - content, msg = gen.generate_outline( - title, genre, sub_genres or [], - int(num_chapters), char_setting, world_setting, plot_idea - ) - return content, msg - - def on_auto_generate(title, genre, sub_genres, char_setting, world_setting, plot_idea, outline_text, progress=gr.Progress()): - """Tự động tạo toàn bộ tiểu thuyết""" - gen = app_state.get_generator() - - chapters, parse_msg = OutlineParser.parse(outline_text) - if not chapters: - return f"❌ {parse_msg}" - - project, create_msg = ProjectManager.create_project( - title, genre, sub_genres or [], - char_setting, world_setting, plot_idea - ) - if not project: - return f"❌ {create_msg}" - - project.chapters = chapters - app_state.current_project = project - app_state.is_generating = True - app_state.stop_requested = False - - results = [f"📋 Đã phân tích {len(chapters)} chương", f"💾 {create_msg}"] - - for i, chapter in enumerate(chapters): - if app_state.stop_requested: - results.append("\n⚠️ Đã dừng sinh!") - break - - results.append(f"\n✍️ Đang sinh Chương {chapter.num}: {chapter.title}...") - progress((i + 1) / len(chapters)) - - prev_content = "" - if i > 0 and chapters[i-1].content: - prev_content = chapters[i-1].content[-2000:] - - content, msg = gen.generate_chapter( - chapter_num=chapter.num, chapter_title=chapter.title, - chapter_desc=chapter.desc, novel_title=title, - character_setting=char_setting, world_setting=world_setting, - plot_idea=plot_idea, genre=genre, sub_genres=sub_genres, - previous_content=prev_content - ) - - if content: - chapter.content = content - chapter.word_count = len(content) - chapter.generated_at = datetime.now().isoformat() - results.append(f"✅ Chương {chapter.num}: {len(content)} từ") - ProjectManager.save_project(project) - else: - results.append(f"❌ Chương {chapter.num}: {msg}") - - app_state.is_generating = False - total_words = sum(ch.word_count for ch in chapters if ch.content) - results.append(f"\n🎉 Hoàn thành! Tổng: {total_words} từ") - return "\n".join(results) - - def on_stop(): - app_state.stop_requested = True - return "⏸️ Đang dừng..." - - # Bind events - suggest_title_btn.click( - fn=on_suggest_title, - inputs=[genre_dropdown, sub_genre_dropdown, suggest_title_prompt], - outputs=[suggested_titles_radio] - ) - suggested_titles_radio.change( - fn=on_title_select, - inputs=[suggested_titles_radio], - outputs=[title_input] - ) - suggest_char_btn.click( - fn=lambda ti, g, sg, cp, nm, ns: on_suggest_content("char", ti, g, sg, "", "", cp, nm, ns), - inputs=[title_input, genre_dropdown, sub_genre_dropdown, suggest_char_prompt, num_main_chars, num_sub_chars], - outputs=[character_input] - ) - suggest_world_btn.click( - fn=lambda ti, g, sg, cp: on_suggest_content("world", ti, g, sg, "", "", cp), - inputs=[title_input, genre_dropdown, sub_genre_dropdown, suggest_world_prompt], - outputs=[world_input] - ) - suggest_plot_btn.click( - fn=lambda ti, g, sg, cs, ws, cp: on_suggest_content("plot", ti, g, sg, cs, ws, cp), - inputs=[title_input, genre_dropdown, sub_genre_dropdown, character_input, world_input, suggest_plot_prompt], - outputs=[plot_input] - ) - generate_outline_btn.click( - fn=on_generate_outline, - inputs=[title_input, genre_dropdown, sub_genre_dropdown, total_chapters, character_input, world_input, plot_input], - outputs=[outline_output, outline_status] - ) - auto_generate_btn.click( - fn=on_auto_generate, - inputs=[title_input, genre_dropdown, sub_genre_dropdown, character_input, world_input, plot_input, outline_output], - outputs=[generation_progress] - ) - stop_btn.click(fn=on_stop, outputs=[generation_progress]) - - # ==================== Tab 2: Viết tiếp dự án ==================== - with gr.Tab(t("tabs.continue_tab")): - gr.Markdown(f"### {t('continue_tab.header')}") - - project_choices = list_project_titles() - continue_project_selector = gr.Dropdown( - choices=project_choices, - label=t("continue_tab.select_project"), - interactive=True - ) - refresh_continue_btn = gr.Button(t("continue_tab.load_btn"), size="sm") - continue_project_info = gr.Markdown(t("continue_tab.no_project_loaded")) - - gr.Markdown("---") - with gr.Row(): - continue_chapter_num = gr.Number(label="Chương số", value=1, minimum=1) - continue_chapter_title = gr.Textbox(label="Tiêu đề chương", placeholder="Nhập tiêu đề chương...", scale=2) - - continue_chapter_desc = gr.Textbox( - label=t("continue_tab.outline_label"), - lines=5, placeholder="Nhập mô tả nội dung chương..." - ) - - with gr.Row(): - continue_target_words = gr.Number(label=t("rewrite.target_words"), value=3000, minimum=100, maximum=50000, step=100) - continue_custom_prompt = gr.Textbox(label=t("create.custom_prompt_label"), placeholder=t("create.custom_prompt_placeholder"), scale=2) - - continue_generate_btn = gr.Button(t("continue_tab.continue_gen_btn"), variant="primary", size="lg") - continue_status = gr.Textbox(label=t("continue_tab.gen_status"), interactive=False) - continue_output = gr.Textbox(label=t("continue_tab.novel_label"), lines=20, interactive=True) - - # Sự kiện - def on_refresh_continue(): - titles = list_project_titles() - return gr.update(choices=titles, value=None) - - def on_continue_project_select(project_title): - if not project_title: - return t("continue_tab.no_project_loaded"), 1 - try: - project_data = ProjectManager.get_project_by_title(project_title) - if not project_data: - return f"❌ {t('continue_tab.project_not_found')}", 1 - - project_id = project_data.get("id") - project, msg = ProjectManager.load_project(project_id) - if project: - app_state.current_project = project - completed = project.get_completed_count() - next_ch = completed + 1 - info = t("continue_tab.info_template").format( - title=project.title, - genre=project.genre, - completed=completed, - total=len(project.chapters), - words=project.get_total_words() - ) if isinstance(t("continue_tab.info_template"), str) and '{' in t("continue_tab.info_template") else f"""### 📖 {project.title} -**Thể loại**: {project.genre} -**Hoàn thành**: {completed}/{len(project.chapters)} chương -**Tổng số từ**: {project.get_total_words()} -💡 Chương tiếp theo: {next_ch}""" - return info, next_ch - return f"❌ {msg}", 1 - except Exception as e: - return f"❌ {str(e)}", 1 - - def on_continue_generate(project_title, ch_num, ch_title, ch_desc, target_words, custom_prompt): - if not app_state.current_project: - return f"❌ {t('continue_tab.no_project_selected')}", "" - - gen = app_state.get_generator() - project = app_state.current_project - - prev_content = "" - for ch in project.chapters: - if ch.num == int(ch_num) - 1 and ch.content: - prev_content = ch.content[-2000:] - - content, msg = gen.generate_chapter( - chapter_num=int(ch_num), chapter_title=ch_title, - chapter_desc=ch_desc, novel_title=project.title, - character_setting=project.character_setting, - world_setting=project.world_setting, - plot_idea=project.plot_idea, genre=project.genre, - sub_genres=project.sub_genres, - previous_content=prev_content, custom_prompt=custom_prompt - ) - - if content: - new_ch = Chapter( - num=int(ch_num), title=ch_title, desc=ch_desc, - content=content, word_count=len(content), - generated_at=datetime.now().isoformat() - ) - found = False - for i, ch in enumerate(project.chapters): - if ch.num == int(ch_num): - project.chapters[i] = new_ch - found = True - break - if not found: - project.chapters.append(new_ch) - project.chapters.sort(key=lambda x: x.num) - - ProjectManager.save_project(project) - return f"✅ Chương {int(ch_num)} đã sinh ({len(content)} từ)", content - else: - return f"❌ {msg}", "" - - refresh_continue_btn.click(fn=on_refresh_continue, outputs=[continue_project_selector]) - continue_project_selector.change( - fn=on_continue_project_select, - inputs=[continue_project_selector], - outputs=[continue_project_info, continue_chapter_num] - ) - continue_generate_btn.click( - fn=on_continue_generate, - inputs=[continue_project_selector, continue_chapter_num, continue_chapter_title, - continue_chapter_desc, continue_target_words, continue_custom_prompt], - outputs=[continue_status, continue_output] - ) - - # ==================== Tab 3: Viết lại ==================== - with gr.Tab(t("tabs.rewrite")): - gr.Markdown(f"### {t('rewrite.header')}") - - rewrite_file_input = gr.File(label=t("rewrite.upload_file"), file_types=[".txt", ".docx", ".md", ".pdf"]) - rewrite_input = gr.Textbox( - label=t("polish.original_text"), - lines=10, placeholder="Dán nội dung cần viết lại..." - ) - - preset_choices = list(get_preset_templates().keys()) - rewrite_style = gr.Dropdown( - choices=preset_choices, - label=t("rewrite.preset_style"), - value=preset_choices[0] if preset_choices else None, - interactive=True - ) - - rewrite_btn = gr.Button(t("rewrite.start_rewrite"), variant="primary") - rewrite_status = gr.Textbox(label=t("rewrite.parse_status"), interactive=False) - rewrite_output = gr.Textbox(label=t("rewrite.full_rewritten"), lines=15, interactive=True) - - def on_file_upload(file): - if file is None: - return "" - try: - content = parse_novel_file(file.name) - return content - except Exception as e: - return f"❌ {str(e)}" - - def on_rewrite(text, style): - instructions = get_preset_templates().get(style, "") - gen = app_state.get_generator() - content, msg = gen.rewrite_paragraph(text, instructions) - if content: - return f"✅ {msg}", content - return f"❌ {msg}", "" - - rewrite_file_input.change(fn=on_file_upload, inputs=[rewrite_file_input], outputs=[rewrite_input]) - rewrite_btn.click(fn=on_rewrite, inputs=[rewrite_input, rewrite_style], outputs=[rewrite_status, rewrite_output]) - - # ==================== Tab 4: Đánh bóng ==================== - with gr.Tab(t("tabs.polish")): - gr.Markdown(f"### {t('polish.header')}") - - polish_file_input = gr.File(label=t("polish.upload_file"), file_types=[".txt", ".docx", ".md", ".pdf"]) - polish_input = gr.Textbox( - label=t("polish.original_text"), - lines=10, placeholder="Dán nội dung cần đánh bóng..." - ) - - polish_custom_req = gr.Textbox( - label=t("polish.custom_req"), - placeholder=t("polish.custom_req_placeholder"), - lines=2 - ) - - with gr.Row(): - polish_btn = gr.Button(t("polish.start_polish"), variant="primary") - polish_suggest_btn = gr.Button(t("polish.polish_suggest_btn"), variant="secondary") - - polish_status = gr.Textbox(label=t("polish.polish_status"), interactive=False) - polish_output = gr.Textbox(label=t("polish.polished_text"), lines=15, interactive=True) - - def on_polish_file_upload(file): - if file is None: - return "" - try: - content = parse_novel_file(file.name) - return content - except Exception as e: - return f"❌ {str(e)}" - - def on_polish(text): - gen = app_state.get_generator() - content, msg = gen.polish_text(text) - if content: - return f"✅ {msg}", content - return f"❌ {msg}", "" - - def on_polish_suggest(text): - gen = app_state.get_generator() - content, msg = gen.polish_and_suggest(text) - if content: - return f"✅ {msg}", content - return f"❌ {msg}", "" - - polish_file_input.change(fn=on_polish_file_upload, inputs=[polish_file_input], outputs=[polish_input]) - polish_btn.click(fn=on_polish, inputs=[polish_input], outputs=[polish_status, polish_output]) - polish_suggest_btn.click(fn=on_polish_suggest, inputs=[polish_input], outputs=[polish_status, polish_output]) - - # ==================== Tab 5: Xuất file ==================== - with gr.Tab(t("tabs.export")): - gr.Markdown(f"### {t('export.header')}") - - export_project_choices = list_project_titles() - export_project_selector = gr.Dropdown( - choices=export_project_choices, - label=t("projects.select_project"), - interactive=True - ) - refresh_export_btn = gr.Button(t("projects.refresh_btn"), size="sm") - - export_format = gr.Radio( - choices=[ - t("create.export_format_word"), - t("create.export_format_txt"), - t("create.export_format_md"), - t("create.export_format_html") - ], - value=t("create.export_format_txt"), - label=t("projects.export_format"), - interactive=True - ) - export_btn = gr.Button(t("projects.export_btn"), variant="primary", size="lg") - export_status = gr.Textbox(label=t("projects.export_status"), interactive=False) - export_download = gr.File(label=t("projects.download_file"), interactive=False) - - def on_refresh_export(): - titles = list_project_titles() - return gr.update(choices=titles, value=None) - - def on_export(project_title, format_type): - if not project_title: - return f"❌ {t('projects.select_project_first')}", None - - try: - project_data = ProjectManager.get_project_by_title(project_title) - if not project_data: - return f"❌ {t('projects.project_not_found')}", None - - project_id = project_data.get("id") - project, msg = ProjectManager.load_project(project_id) - if not project: - return f"❌ {msg}", None - - 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: - return f"❌ {t('ui.no_content_export')}", None - - # Map format - format_map = { - t("create.export_format_word"): "docx", - t("create.export_format_txt"): "txt", - t("create.export_format_md"): "md", - t("create.export_format_html"): "html" - } - fmt = format_map.get(format_type, "txt") - - if fmt == "docx": - filepath, exp_msg = export_to_docx(full_text, project.title) - elif fmt == "txt": - filepath, exp_msg = export_to_txt(full_text, project.title) - elif fmt == "md": - filepath, exp_msg = export_to_markdown(full_text, project.title) - elif fmt == "html": - filepath, exp_msg = export_to_html(full_text, project.title) - else: - return f"❌ {t('ui.unsupported_format', format=fmt)}", None - - if filepath: - return f"✅ {exp_msg}", filepath - return f"❌ {exp_msg}", None - - except Exception as e: - logger.error(f"Export failed: {e}", exc_info=True) - return f"❌ {t('ui.export_failed', error=str(e))}", None - - refresh_export_btn.click(fn=on_refresh_export, outputs=[export_project_selector]) - export_btn.click(fn=on_export, inputs=[export_project_selector, export_format], outputs=[export_status, export_download]) - - # ==================== Tab 6: Quản lý dự án ==================== - with gr.Tab(t("tabs.projects")): - gr.Markdown(f"### {t('projects.header')}") - - projects_table = gr.Dataframe( - headers=["ID", t("ui.col_project_name"), t("ui.col_type"), t("ui.col_created_at"), t("ui.col_chapters")], - interactive=False - ) - with gr.Row(): - refresh_projects_btn = gr.Button(t("projects.refresh_btn")) - delete_project_btn = gr.Button(t("projects.delete_btn"), variant="stop") - - gr.Markdown(f"#### {t('projects.delete_header')}") - delete_project_selector = gr.Dropdown( - choices=list_project_titles(), - label=t("projects.delete_select_project"), - interactive=True - ) - project_manage_status = gr.Textbox(label=t("projects.status_label"), interactive=False) - - def on_refresh_projects(): - try: - projects = ProjectManager.list_projects() - table_data = [] - for p in projects: - table_data.append([ - p.get("id", ""), - p.get("title", ""), - p.get("genre", ""), - p.get("created_at", "")[:10] if p.get("created_at") else "", - f"{p.get('completed_chapters', 0)}/{p.get('chapter_count', 0)}" - ]) - titles = [p.get("title", "") for p in projects] - return table_data, gr.update(choices=titles, value=None) - except Exception as e: - logger.error(f"Refresh projects failed: {e}") - return [], gr.update() - - def on_delete_project(project_title): - if not project_title or not project_title.strip(): - return f"❌ {t('projects.select_project_first')}", gr.update(), gr.update() - - try: - project_data = ProjectManager.get_project_by_title(project_title) - if not project_data: - return f"❌ {t('projects.project_not_found')}", gr.update(), gr.update() - - project_id = project_data.get("id") - success, msg = ProjectManager.delete_project(project_id) - if success: - new_table, new_dropdown = on_refresh_projects() - return f"✅ {t('projects.delete_success')}", new_table, new_dropdown - return f"❌ {t('projects.delete_failed')}: {msg}", gr.update(), gr.update() - except Exception as e: - return f"❌ {str(e)}", gr.update(), gr.update() - - refresh_projects_btn.click(fn=on_refresh_projects, outputs=[projects_table, delete_project_selector]) - delete_project_btn.click( - fn=on_delete_project, - inputs=[delete_project_selector], - outputs=[project_manage_status, projects_table, delete_project_selector] - ) - - # ==================== Tab 7: Cài đặt hệ thống ==================== - with gr.Tab(t("tabs.settings")): - gr.Markdown(f"### {t('settings.header')}") - - with gr.Tabs(): - # Sub-tab: Quản lý giao diện API - with gr.Tab(t("settings.tab_backends")): - gr.Markdown(f"#### {t('settings.backends_header')}") - - backends_display = gr.Markdown("...") - - def get_backends_display(): - try: - result = ConfigAPIManager.list_backends() - if result["success"] and result["data"]: - lines = [f"### 📋 {len(result['data'])} backend(s)\n"] - for b in result["data"]: - enabled = "✅" if b.get("enabled") else "❌" - default = " ⭐" if b.get("is_default") else "" - lines.append(f"{enabled} **{b['name']}**{default}") - lines.append(f" - Model: `{b.get('model', '')}`") - lines.append(f" - URL: `{b.get('base_url', '')[:50]}...`") - lines.append(f" - Timeout: {b.get('timeout', 30)}s") - lines.append("") - return "\n".join(lines) - return t("app.no_backends_warning") - except Exception as e: - return f"❌ {str(e)}" - - gr.Markdown("---") - gr.Markdown(f"#### {t('settings.add_backend_header')}") - - provider_names = [API_PROVIDERS[k]["name"] for k in API_PROVIDERS] - - with gr.Row(): - api_provider_dropdown = gr.Dropdown( - choices=provider_names, - label=t("settings.provider_label"), - info=t("settings.provider_info"), - interactive=True - ) - - with gr.Row(): - api_name_input = gr.Textbox(label=t("settings.backend_name"), placeholder=t("settings.backend_name_placeholder")) - api_type_dropdown = gr.Dropdown( - choices=ConfigAPIManager.get_backend_types(), - value="openai", label=t("settings.backend_type"), interactive=True - ) - - with gr.Row(): - api_url_input = gr.Textbox(label=t("settings.base_url"), placeholder=t("settings.base_url_placeholder")) - api_key_input = gr.Textbox(label=t("settings.api_key"), placeholder=t("settings.api_key_placeholder"), type="password") - - with gr.Row(): - api_model_input = gr.Textbox(label=t("settings.model_name"), placeholder=t("settings.model_name_placeholder")) - api_timeout_input = gr.Slider(minimum=5, maximum=600, value=120, step=5, label=t("settings.timeout")) - - with gr.Row(): - api_save_btn = gr.Button(t("settings.add_btn"), variant="primary") - api_refresh_btn = gr.Button(t("settings.refresh_list"), size="sm") - - api_status = gr.Textbox(label=t("settings.operation_result"), interactive=False) - - gr.Markdown("---") - gr.Markdown(f"#### {t('settings.test_manage_header')}") - - with gr.Row(): - test_name_input = gr.Textbox(label=t("settings.test_backend_name"), placeholder=t("settings.test_backend_placeholder")) - api_test_btn = gr.Button(t("settings.test_btn"), variant="secondary") - - with gr.Row(): - delete_name_input = gr.Textbox(label=t("settings.delete_backend_name"), placeholder=t("settings.delete_backend_placeholder")) - api_delete_btn = gr.Button(t("settings.delete_btn"), variant="stop") - - test_result = gr.Textbox(label=t("settings.test_result"), interactive=False) - - def on_provider_select(provider_name): - for key, info in API_PROVIDERS.items(): - if info["name"] == provider_name: - return info.get("base_url", ""), info.get("default_model", ""), provider_name - return "", "", provider_name - - def on_api_test(name): - result = ConfigAPIManager.test_backend(name) - return result["message"] - - def on_api_save(name, btype, url, key, model, timeout): - result = ConfigAPIManager.add_backend(name, btype, url, key, model, int(timeout)) - reinit_api_client() - app_state.generator = None - return result["message"], get_backends_display() - - def on_api_delete(name): - result = ConfigAPIManager.delete_backend(name) - reinit_api_client() - app_state.generator = None - return result["message"], get_backends_display() - - api_provider_dropdown.change( - fn=on_provider_select, - inputs=[api_provider_dropdown], - outputs=[api_url_input, api_model_input, api_name_input] - ) - api_save_btn.click( - fn=on_api_save, - inputs=[api_name_input, api_type_dropdown, api_url_input, api_key_input, api_model_input, api_timeout_input], - outputs=[api_status, backends_display] - ) - api_test_btn.click(fn=on_api_test, inputs=[test_name_input], outputs=[test_result]) - api_delete_btn.click(fn=on_api_delete, inputs=[delete_name_input], outputs=[api_status, backends_display]) - api_refresh_btn.click(fn=lambda: get_backends_display(), outputs=[backends_display]) - - # Sub-tab: Tham số sinh - with gr.Tab(t("settings.tab_params")): - gr.Markdown(f"#### {t('settings.params_header')}") - - config = get_config() - gen_config = config.generation - - param_temperature = gr.Slider(minimum=0.1, maximum=2.0, value=gen_config.temperature, step=0.1, label=t("settings.temperature_label"), info=t("settings.temperature_info")) - param_top_p = gr.Slider(minimum=0.1, maximum=1.0, value=gen_config.top_p, step=0.05, label="Top P", info=t("settings.top_p_info")) - param_max_tokens = gr.Slider(minimum=100, maximum=100000, value=gen_config.max_tokens, step=100, label="Max Tokens", info=t("settings.max_tokens_info")) - param_chapter_words = gr.Slider(minimum=500, maximum=65536, value=gen_config.chapter_target_words, step=500, label=t("settings.chapter_target_words")) - - save_params_btn = gr.Button(t("settings.save_params_btn"), variant="primary") - params_status = gr.Textbox(label=t("settings.save_status"), interactive=False) - - def on_save_params(temp, top_p, max_tokens, chapter_words): - cfg = get_config() - success, msg = cfg.update_generation_config( - temperature=temp, top_p=top_p, - max_tokens=int(max_tokens), - chapter_target_words=int(chapter_words) - ) - if success: - app_state.generator = None - return f"✅ {msg}" - return f"❌ {msg}" - - save_params_btn.click( - fn=on_save_params, - inputs=[param_temperature, param_top_p, param_max_tokens, param_chapter_words], - outputs=[params_status] - ) - - # Sub-tab: Bộ nhớ đệm - with gr.Tab(t("settings.tab_cache")): - gr.Markdown(f"#### {t('settings.cache_header')}") - - cache_info_display = gr.Markdown(t("ui.no_cache")) - - def get_cache_info(): - try: - api_client = get_api_client() - stats = api_client.get_cache_stats() - gen_caches = list_generation_caches() - gen_size_val = get_cache_size() - - return f"""### Thống kê bộ nhớ đệm -- **API Cache**: {stats['total_entries']}/{stats['max_size']} ({stats['usage_rate']:.1f}%) -- **Generation Cache**: {len(gen_caches)} files ({gen_size_val / 1024:.1f} KB)""" - except Exception as e: - return f"❌ {str(e)}" - - with gr.Row(): - refresh_cache_btn = gr.Button(t("settings.refresh_cache"), size="sm") - clear_all_cache_btn = gr.Button(t("settings.clear_all"), variant="stop") - - cache_op_status = gr.Textbox(label=t("settings.cache_op_status"), interactive=False) - - def on_clear_all_cache(): - try: - api_client = get_api_client() - api_client.clear_cache() - clear_generation_cache() - return "✅ Đã xóa tất cả bộ nhớ đệm", get_cache_info() - except Exception as e: - return f"❌ {str(e)}", get_cache_info() - - refresh_cache_btn.click(fn=get_cache_info, outputs=[cache_info_display]) - clear_all_cache_btn.click(fn=on_clear_all_cache, outputs=[cache_op_status, cache_info_display]) - - # Sub-tab: Quản lý thể loại - with gr.Tab(t("settings.tab_genre")): - gr.Markdown(f"#### {t('settings.genre_desc')}") - - genre_select = gr.Dropdown( - choices=GenreManager.get_genre_names(), - label=t("settings.genre_select"), - interactive=True - ) - genre_name_input = gr.Textbox(label=t("settings.genre_name"), placeholder=t("settings.genre_name_placeholder")) - genre_desc_input = gr.Textbox(label=t("settings.genre_description"), placeholder=t("settings.genre_description_placeholder"), lines=3) - - with gr.Row(): - genre_add_btn = gr.Button(t("settings.genre_add_btn"), variant="primary") - genre_update_btn = gr.Button(t("settings.genre_update_btn"), variant="secondary") - genre_delete_btn = gr.Button(t("settings.genre_delete_btn"), variant="stop") - - genre_op_status = gr.Textbox(label=t("settings.genre_op_status"), interactive=False) - - def on_genre_select(name): - if name: - desc = GenreManager.get_genre_description(name) - return name, desc or "" - return "", "" - - def on_genre_add(name, desc): - if not name.strip(): - return t("settings.genre_err_name_empty"), gr.update() - success = GenreManager.add_genre(name.strip(), desc.strip()) - if success: - return t("settings.genre_add_success"), gr.update(choices=GenreManager.get_genre_names()) - return t("settings.genre_err_exists"), gr.update() - - def on_genre_update(old_name, new_name, desc): - if not old_name: - return t("settings.genre_err_none_selected"), gr.update() - success = GenreManager.update_genre(old_name, new_name.strip(), desc.strip()) - if success: - return t("settings.genre_update_success"), gr.update(choices=GenreManager.get_genre_names()) - return t("settings.genre_err_update"), gr.update() - - def on_genre_delete(name): - if not name: - return t("settings.genre_err_none_selected"), gr.update() - success = GenreManager.delete_genre(name) - if success: - return t("settings.genre_delete_success"), gr.update(choices=GenreManager.get_genre_names()) - return t("settings.genre_err_delete"), gr.update() - - genre_select.change(fn=on_genre_select, inputs=[genre_select], outputs=[genre_name_input, genre_desc_input]) - genre_add_btn.click(fn=on_genre_add, inputs=[genre_name_input, genre_desc_input], outputs=[genre_op_status, genre_select]) - genre_update_btn.click(fn=on_genre_update, inputs=[genre_select, genre_name_input, genre_desc_input], outputs=[genre_op_status, genre_select]) - genre_delete_btn.click(fn=on_genre_delete, inputs=[genre_select], outputs=[genre_op_status, genre_select]) - - # Sub-tab: Quản lý chủ đề con - with gr.Tab(t("settings.tab_sub_genre")): - gr.Markdown(f"#### {t('settings.sub_genre_desc')}") - - sub_genre_select = gr.Dropdown( - choices=SubGenreManager.get_sub_genre_names(), - label=t("settings.sub_genre_select"), - interactive=True - ) - sub_genre_name_input = gr.Textbox(label=t("settings.sub_genre_name"), placeholder=t("settings.sub_genre_name_placeholder")) - sub_genre_desc_input = gr.Textbox(label=t("settings.sub_genre_description"), placeholder=t("settings.sub_genre_description_placeholder"), lines=3) - - with gr.Row(): - sub_genre_add_btn = gr.Button(t("settings.sub_genre_add_btn"), variant="primary") - sub_genre_update_btn = gr.Button(t("settings.sub_genre_update_btn"), variant="secondary") - sub_genre_delete_btn = gr.Button(t("settings.sub_genre_delete_btn"), variant="stop") - - sub_genre_op_status = gr.Textbox(label=t("settings.sub_genre_op_status"), interactive=False) - - def on_sub_genre_select(name): - if name: - desc = SubGenreManager.get_sub_genre_description(name) - return name, desc or "" - return "", "" - - def on_sub_genre_add(name, desc): - if not name.strip(): - return t("settings.sub_genre_err_name_empty"), gr.update() - success = SubGenreManager.add_sub_genre(name.strip(), desc.strip()) - if success: - return t("settings.sub_genre_add_success"), gr.update(choices=SubGenreManager.get_sub_genre_names()) - return t("settings.sub_genre_err_exists"), gr.update() - - def on_sub_genre_update(old_name, new_name, desc): - if not old_name: - return t("settings.sub_genre_err_none_selected"), gr.update() - success = SubGenreManager.update_sub_genre(old_name, new_name.strip(), desc.strip()) - if success: - return t("settings.sub_genre_update_success"), gr.update(choices=SubGenreManager.get_sub_genre_names()) - return t("settings.sub_genre_err_update"), gr.update() - - def on_sub_genre_delete(name): - if not name: - return t("settings.sub_genre_err_none_selected"), gr.update() - success = SubGenreManager.delete_sub_genre(name) - if success: - return t("settings.sub_genre_delete_success"), gr.update(choices=SubGenreManager.get_sub_genre_names()) - return t("settings.sub_genre_err_delete"), gr.update() - - sub_genre_select.change(fn=on_sub_genre_select, inputs=[sub_genre_select], outputs=[sub_genre_name_input, sub_genre_desc_input]) - sub_genre_add_btn.click(fn=on_sub_genre_add, inputs=[sub_genre_name_input, sub_genre_desc_input], outputs=[sub_genre_op_status, sub_genre_select]) - sub_genre_update_btn.click(fn=on_sub_genre_update, inputs=[sub_genre_select, sub_genre_name_input, sub_genre_desc_input], outputs=[sub_genre_op_status, sub_genre_select]) - sub_genre_delete_btn.click(fn=on_sub_genre_delete, inputs=[sub_genre_select], outputs=[sub_genre_op_status, sub_genre_select]) + from ui.create_tab import build_create_tab + from ui.continue_tab import build_continue_tab + from ui.rewrite_tab import build_rewrite_tab + from ui.polish_tab import build_polish_tab + from ui.export_tab import build_export_tab + from ui.projects_tab import build_projects_tab + from ui.settings_tab import build_settings_tab + + build_create_tab() + build_continue_tab() + build_rewrite_tab() + build_polish_tab() + build_export_tab() + build_projects_tab() + build_settings_tab() # Footer gr.Markdown(""" @@ -1023,7 +119,7 @@ def main(): # Khởi động logger.info(t("app.gradio_start", port=WEB_PORT)) - app.launch( + app.queue(default_concurrency_limit=10).launch( server_name=WEB_HOST, server_port=WEB_PORT, share=WEB_SHARE, diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.py b/core/config.py similarity index 99% rename from config.py rename to core/config.py index ecab679..0552d32 100644 --- a/config.py +++ b/core/config.py @@ -12,7 +12,7 @@ from datetime import datetime import hashlib import logging from locales.i18n import t -from database import get_db +from core.database import get_db logger = logging.getLogger(__name__) @@ -341,7 +341,7 @@ class ConfigManager: self.last_modified = mod_row["value"] if rows or gen_row: - logger.info("Config loaded from database") + logger.info("Config loaded from core.database") else: logger.info("No config in database, using defaults") self._init_default() diff --git a/config_api.py b/core/config_api.py similarity index 98% rename from config_api.py rename to core/config_api.py index 42e3bf8..b1009da 100644 --- a/config_api.py +++ b/core/config_api.py @@ -8,9 +8,9 @@ Tác giả: Huyễn Thành import json from typing import Dict, List, Tuple, Any from dataclasses import asdict -from config import Backend, get_config -from api_client import get_api_client, reinit_api_client -from logger import get_logger +from core.config import Backend, get_config +from services.api_client import get_api_client, reinit_api_client +from core.logger import get_logger from locales.i18n import t logger = get_logger("ConfigAPI") diff --git a/database.py b/core/database.py similarity index 100% rename from database.py rename to core/database.py diff --git a/logger.py b/core/logger.py similarity index 100% rename from logger.py rename to core/logger.py diff --git a/core/state.py b/core/state.py new file mode 100644 index 0000000..7195908 --- /dev/null +++ b/core/state.py @@ -0,0 +1,21 @@ +import threading +from typing import Optional +from services.novel_generator import NovelGenerator, get_generator, NovelProject + +class AppState: + """Trạng thái ứng dụng dùng chung giữa các tab""" + def __init__(self): + self.is_generating = False + self.stop_requested = False + self.lock = threading.Lock() + self.current_project: Optional[NovelProject] = None + self.generator: Optional[NovelGenerator] = None + + def get_generator(self) -> NovelGenerator: + """Lấy hoặc tạo generator""" + if self.generator is None: + self.generator = get_generator() + return self.generator + +# Global app state singleton +app_state = AppState() diff --git a/data/tinix_story.db-shm b/data/tinix_story.db-shm index e8b3ed919ee0fed35d3473dcef5e00af2d8cca18..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 100644 GIT binary patch delta 80 zcmZo@U}|V!;+1%$%K!t66CX;8TChv7nNGgVi7p`mlYpuI4+Il)WHx@_aoG4F#~uK- C z+>Jt^agr}5GjlRCuB%*E4YS7*7%2e(?2PzAivBuZyKSuWslD%AR^>&1*_d=`aQHWG zb{m*xt4l%~`3}Be4;MWQgOb?|sSf2a9_S_GSaZEDZLl|Hx F;v04cP;LMK diff --git a/data/tinix_story.db-wal b/data/tinix_story.db-wal index 802482b4445e9173f51efdc964c29d1ca8902012..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 GIT binary patch literal 0 HcmV?d00001 literal 86552 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 F|1YkMAgurZ diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api_client.py b/services/api_client.py similarity index 99% rename from api_client.py rename to services/api_client.py index 2958451..37dd151 100644 --- a/api_client.py +++ b/services/api_client.py @@ -17,9 +17,9 @@ import logging from openai import OpenAI, RateLimitError, APIError, AuthenticationError, APIConnectionError import pickle -from config import get_config, Backend +from core.config import get_config, Backend from locales.i18n import t -from database import get_db +from core.database import get_db logger = logging.getLogger(__name__) diff --git a/services/data/genres.json b/services/data/genres.json new file mode 100644 index 0000000..b1ca313 --- /dev/null +++ b/services/data/genres.json @@ -0,0 +1,150 @@ +[ + { + "name": "Huyền huyễn tiên hiệp", + "description": "Thế giới tu tiên rộng lớn, sức mạnh siêu phàm, phân chia nhiều cảnh giới rõ rệt. Cốt truyện thường xoay quanh hành trình thăng cấp, cướp đoạt cơ duyên, phi thăng tiên giới. Văn phong cần kỳ ảo, chú trọng miêu tả công pháp, pháp bảo, linh thú." + }, + { + "name": "Đô thị ngôn tình", + "description": "Bối cảnh hiện đại, đời sống thành thị, xoay quanh các mối quan hệ tình cảm, gia đình, công sở. Tập trung vào tâm lý nhân vật, tình huống đời thường, lãng mạn hoặc ngược luyến. Lời thoại tự nhiên, gần gũi thực tế." + }, + { + "name": "Khoa học viễn tưởng", + "description": "Lấy bối cảnh tương lai, vũ trụ, hoặc các thế giới với trình độ công nghệ/khoa học vượt bậc (AI, robot, du hành thời gian). Đòi hỏi sự logic, xây dựng hệ thống quy tắc công nghệ/vũ trụ chặt chẽ mang tính thuyết phục cao." + }, + { + "name": "Võ hiệp", + "description": "Thế giới giang hồ, ân oán tình thù, võ công cái thế. Nhấn mạnh vào tinh thần trượng nghĩa, môn phái, các chiêu thức võ thuật võ lâm. Văn phong cổ trang, miêu tả chiêu thức hoa mỹ, tiết tấu nhanh." + }, + { + "name": "Trinh thám", + "description": "Xoay quanh các vụ án bí ẩn, tội phạm và quá trình đi tìm lời giải, phá án. Yêu cầu tính logic cực cao, chuỗi manh mối đan xen, gây cấn, hồi hộp, tạo bất ngờ ở phút chót (plot twist)." + }, + { + "name": "Lịch sử", + "description": "Bối cảnh dựa trên các triều đại lịch sử có thật hoặc hư cấu dựa trên bối cảnh lịch sử. Xoay quanh quyền mưu, tranh đoạt thiên hạ, chiến tranh giữa các quốc gia, xây dựng thế lực. Đòi hỏi kiến thức lịch sử, chính trị, văn phong trang trọng, mang đậm tính sử thi." + }, + { + "name": "Quân sự", + "description": "Tập trung vào các đề tài chiến tranh, quân đội, vũ khí, và các chiến dịch quân sự. Nhân vật chính thường là quân nhân, nhà chiến lược. Yêu cầu tính logic, am hiểu về chiến thuật, vũ khí thực tế, miêu tả các trận đánh hoành tráng, khốc liệt." + }, + { + "name": "Game", + "description": "Bối cảnh trong môi trường game thực tế ảo hoặc thế giới game kết hợp đời thực (Võng du). Nhân vật chính đánh quái, thăng cấp, cày đồ, lập guild, tham gia e-sports hoặc tranh bá. Cần hệ thống chỉ số, kỹ năng, trang bị rõ ràng, nhịp độ giải trí nhanh." + }, + { + "name": "Kinh dị", + "description": "Cốt truyện rùng rợn, khai thác các yếu tố siêu nhiên, tâm linh, quái vật hoặc tâm lý học vặn vẹo. Bầu không khí tăm tối, u ám, miêu tả cảm giác sợ hãi tột độ của nhân vật để gây rùng mình cho người đọc." + }, + { + "name": "Xuyên không - Trọng sinh", + "description": "Nhân vật chính du hành thời gian, không gian đến một thế giới khác hoặc sống lại kiếp trước. Thường mang theo kiến thức hiện đại hoặc bám sát ký ức kiếp trước để thay đổi số phận, vả mặt kẻ thù, xây dựng lại cuộc đời." + }, + { + "name": "Hệ thống", + "description": "Nhân vật chính sở hữu một 'Hệ thống' (như một trí tuệ nhân tạo trong não) giao nhiệm vụ, thưởng phạt, cung cấp cửa hàng đổi vật phẩm, kỹ năng. Văn phong mang tính giải trí cao, nhịp độ nhanh, tập trung thăng cấp." + }, + { + "name": "Đồng nhân", + "description": "Truyện dựa trên bối cảnh, nhân vật của một tác phẩm gốc có sẵn (như Naruto, Harry Potter, v.v.). Nhân vật chính thường xuyên không vào thế giới gốc, thay đổi cốt truyện hoặc tương tác với nhân vật gốc." + }, + { + "name": "Mạt thế", + "description": "Bối cảnh tận thế, thảm họa zombie, thiên tai, hoặc biến dị sinh học. Con người đấu tranh sinh tồn, thế giới xuất hiện các dị năng giả. Nhấn mạnh sự tàn khốc của nhân tính, thiếu thốn vật tư và xây dựng căn cứ." + }, + { + "name": "Điền văn - Hài hước", + "description": "Tập trung vào cuộc sống thường nhật, trồng trọt, chăn nuôi, làm giàu hoặc gia đình êm ấm. Nhịp độ chậm rãi (slow-burn), nhẹ nhàng, thư giãn, pha trộn nhiều tình huống hài hước, dở khóc dở cười." + }, + { + "name": "Cổ đại ngôn tình", + "description": "Bối cảnh phong kiến, xoay quanh tình yêu nam nữ. Khai thác gia đấu (những mâu thuẫn gia tộc), cung đấu (tranh giành quyền lực chốn hậu cung) hoặc quyền mưu quyền thần. Lời thoại cổ kính, trang nhã." + }, + { + "name": "Kỳ ảo phương Tây", + "description": "Bối cảnh phương Tây thời Trung Cổ, có hiệp sĩ, phép thuật, elf, rồng, ma cà rồng... Hệ thống ma pháp và thế giới quan mang đậm nét thần thoại hoặc fantasy (như D&D, Warcraft)." + }, + { + "name": "Nữ cường", + "description": "Nữ chính có tính cách kiên cường, thông minh, độc lập, hoặc sở hữu sức mạnh vượt trội. Truyện thường tập trung vào quá trình tự vươn lên, phá bỏ định kiến, đối mặt kẻ thù mà không dựa dẫm vào nam chính." + }, + { + "name": "Tổng tài", + "description": "Xa hoa, tập trung vào nam chính là những chủ tịch (tổng tài) giàu có, lạnh lùng, quyền lực, cùng nữ chính thường có xuất thân thấp hơn hoặc có vướng mắc tình cảm phức tạp. Yếu tố sủng ngọt hoặc ngược luyến tình thâm thường được đẩy mạnh." + }, + { + "name": "Thanh xuân vườn trường", + "description": "Bối cảnh trường học, thanh xuân rực rỡ. Khai thác tình yêu tuổi học trò trong sáng, nhiệt huyết thanh xuân, tình bạn, vượt qua áp lực thi cử và những rung động đầu đời." + }, + { + "name": "Cung đấu", + "description": "Bối cảnh mưu mô xảo quyệt chốn hậu cung phong kiến. Các phi tần, hoàng hậu, cung nữ dùng trí tuệ, mưu kế triệt hạ lẫn nhau để tranh giành sủng ái và quyền lực. Bầu không khí căng thẳng, máu lạnh." + }, + { + "name": "Gia đấu", + "description": "Bối cảnh trong những gia tộc lớn thời phong kiến. Đấu tranh, kèn cựa giữa mẹ chồng nàng dâu, các phòng, các chị em gái để bảo vệ lợi ích và vị thế trong gia đình. Đòi hỏi logic cao trị gia." + }, + { + "name": "Hồng hoang", + "description": "Dựa trên hệ thống thần thoại Trung Hoa cổ đại (Bàn Cổ khai thiên, Nữ Oa tạo nhân,...). Hệ thống sức mạnh cực kỳ khổng lồ, bối cảnh cấp bậc thần thánh vô lượng kiếp quy mô vũ trụ." + }, + { + "name": "Ngôn tình võng du", + "description": "Kết hợp game online và tình cảm đời thực. Tuyến tình cảm phát triển song song trong thế giới ảo và đời thực, có sự kiện offline, PK, đấu giải giữa các bang phái đầy thú vị." + }, + { + "name": "Đô thị dị năng", + "description": "Bối cảnh xã hội hiện đại nhưng đan xen những con người sở hữu năng lực đặc biệt (dị năng), tổ chức ngầm, hoặc yêu quái ẩn mình. Đòi hỏi sự kết hợp cân bằng giữa đời sống thực và thế giới huyền bí." + }, + { + "name": "Linh dị - Bí ẩn", + "description": "Xoay quanh tà ma, phong thủy, đạo sĩ trừ tà, hoặc những hiện tượng tâm linh không thể lý giải bằng khoa học. Không quá kinh dị tột độ mà chú trọng vào yếu tố huyền bí, hồi hộp khám phá sự thật." + }, + { + "name": "Đam mỹ", + "description": "Khai thác câu chuyện tình cảm sâu sắc, tinh tế hoặc ngang trái giữa hai nhân vật nam. Văn phong trau chuốt, chú trọng tâm lý, có thể lồng ghép mọi bối cảnh (cổ đại, hiện đại, mạt thế, tinh tế, v.v.)." + }, + { + "name": "Bách hợp", + "description": "Tập trung miêu tả tuyến tình cảm nhẹ nhàng, gắn bó hoặc mãnh liệt giữa hai nhân vật nữ. Duyên dáng, thiên về khai phá cảm xúc tinh tế, đồng cảm nội tâm, kết hợp nhiều bối cảnh khác nhau." + }, + { + "name": "Thám hiểm lăng mộ", + "description": "Hành trình trộm mộ, săn bảo vật ở các di tích cổ xưa chứa đầy cạm bẫy, cương thi, quái vật (như Đạo Mộ Bút Ký, Ma Thổi Đèn). Các chi tiết về đạo cụ, phong thủy, địa lý phải cực kỳ sống động và hấp dẫn." + }, + { + "name": "Dị giới đại lục", + "description": "Thế giới hoàn toàn hư cấu với bản đồ lục địa rộng lớn, có thể bao gồm kiếm thuật, ma pháp hoặc đấu khí. Tôn trọng luật rừng kẻ mạnh làm vua, mô phỏng các vương quốc, chủng tộc đa dạng tranh đấu." + }, + { + "name": "Cổ đại làm ruộng", + "description": "Một nhánh phụ của Điền văn nhưng nhấn mạnh vào bối cảnh cổ đại nghèo khó. Quá trình làm giàu chậm rãi từng bước từ hai bàn tay trắng, kinh doanh buôn bán, xây dựng gia đình no ấm." + }, + { + "name": "Không gian Tùy thân", + "description": "Nhân vật chính sở hữu một không gian bí mật (vòng tay, ngọc bội) có thể vào đó trồng trọt linh dược, chứa đồ, trữ nước thần, hoặc trốn tránh kẻ thù. Là bàn đạp lớn cho quá trình thăng cấp." + }, + { + "name": "ABO", + "description": "Bối cảnh Omegaverse (Alpha, Beta, Omega) với các đặc điểm sinh học và chất dẫn dụ đặc thù, thường lấy bối cảnh Tinh Tế (vũ trụ). Nhấn mạnh bản năng, sự kiểm soát, đánh dấu và các mối quan hệ tình cảm gai góc." + }, + { + "name": "Ma cà rồng", + "description": "Truyện xoay quanh sinh vật Ma cà rồng (Vampire), ma lang (Người sói), thợ săn. Thể hiện sự đấu tranh giữa bản năng khát máu và nhân tính, thường mang sắc thái lãng mạn tăm tối (Dark Romance)." + }, + { + "name": "Cạnh kỹ - Thể thao", + "description": "Nhiệt huyết thanh xuân, thi đấu e-sports hoặc các môn thể thao truyền thống (bóng rổ, điền kinh). Tôn vinh tinh thần đồng đội, nỗ lực luyện tập, vinh quang thi đấu, các chiến thuật đối kháng kịch tính." + }, + { + "name": "Đồng nhân Anime", + "description": "Viết dựa theo thế giới của các bộ Manga/Anime đình đám (One Piece, Pokemon, Bleach,...). Tương tác với các nhân vật được yêu thích, bổ sung những cái kết luyến tiếc hoặc tạo cuộc phiêu lưu hoàn toàn mới." + }, + { + "name": "Vô hạn lưu", + "description": "Nhân vật chính bị kéo vào một 'Không gian Chủ Thần', buộc phải xuyên qua nhiều thế giới (phim ảnh, game, ác mộng) để làm nhiệm vụ sinh tử, kiếm điểm nâng cấp. Nhịp độ dồn dập, hack não và nguy hiểm." + }, + { + "name": "Khác", + "description": "Các thể loại không nằm trong các phân loại chính, hoặc pha trộn nhiều yếu tố khác nhau (như Đồng nhân, Kỳ ảo phương Tây, Đam mỹ, Bách hợp, v.v.). AI cần linh hoạt kết hợp các yếu tố trong bối cảnh và yêu cầu riêng của tác giả để sáng tác cho phù hợp." + } +] \ No newline at end of file diff --git a/services/data/sub_genres.json b/services/data/sub_genres.json new file mode 100644 index 0000000..b4eec34 --- /dev/null +++ b/services/data/sub_genres.json @@ -0,0 +1,402 @@ +[ + { + "name": "Hậu cung", + "description": "" + }, + { + "name": "1v1", + "description": "" + }, + { + "name": "NP", + "description": "" + }, + { + "name": "Nữ phẫn nam trang", + "description": "" + }, + { + "name": "Nam phẫn nữ trang", + "description": "" + }, + { + "name": "Xuyên không", + "description": "" + }, + { + "name": "Xuyên sách", + "description": "" + }, + { + "name": "Trọng sinh", + "description": "" + }, + { + "name": "Hệ thống", + "description": "" + }, + { + "name": "Bàn tay vàng", + "description": "" + }, + { + "name": "Không gian tùy thân", + "description": "" + }, + { + "name": "Linh tuyền", + "description": "" + }, + { + "name": "Đọc tâm thuật", + "description": "" + }, + { + "name": "Vô địch lưu", + "description": "" + }, + { + "name": "Cẩu đạo", + "description": "" + }, + { + "name": "Nhiệt huyết", + "description": "" + }, + { + "name": "Hài hước", + "description": "" + }, + { + "name": "Sảng văn", + "description": "" + }, + { + "name": "Ngọt sủng", + "description": "" + }, + { + "name": "Ngược luyến", + "description": "" + }, + { + "name": "Gương vỡ lại lành", + "description": "" + }, + { + "name": "Cưới trước yêu sau", + "description": "" + }, + { + "name": "Oan gia ngõ hẹp", + "description": "" + }, + { + "name": "Thanh mai trúc mã", + "description": "" + }, + { + "name": "Hào môn thế gia", + "description": "" + }, + { + "name": "Tổng tài", + "description": "" + }, + { + "name": "Minh tinh", + "description": "" + }, + { + "name": "Giới giải trí", + "description": "" + }, + { + "name": "Vườn trường", + "description": "" + }, + { + "name": "Học bá", + "description": "" + }, + { + "name": "Võng du", + "description": "" + }, + { + "name": "E-sports", + "description": "" + }, + { + "name": "Livestream", + "description": "" + }, + { + "name": "Mỹ thực", + "description": "" + }, + { + "name": "Nông trại", + "description": "" + }, + { + "name": "Điền văn", + "description": "" + }, + { + "name": "Nuôi con", + "description": "" + }, + { + "name": "Làm giàu", + "description": "" + }, + { + "name": "Cung đấu", + "description": "" + }, + { + "name": "Gia đấu", + "description": "" + }, + { + "name": "Quyền mưu", + "description": "" + }, + { + "name": "Nữ cường", + "description": "" + }, + { + "name": "Nam cường", + "description": "" + }, + { + "name": "Song khiết", + "description": "" + }, + { + "name": "Phế Sài", + "description": "" + }, + { + "name": "Thiên tài", + "description": "" + }, + { + "name": "Từ hôn", + "description": "" + }, + { + "name": "Linh khí khôi phục", + "description": "" + }, + { + "name": "Mạt thế", + "description": "" + }, + { + "name": "Cơ giáp", + "description": "" + }, + { + "name": "Tinh tế", + "description": "" + }, + { + "name": "ABO", + "description": "" + }, + { + "name": "Người sói", + "description": "" + }, + { + "name": "Ma cà rồng", + "description": "" + }, + { + "name": "Tây huyễn", + "description": "" + }, + { + "name": "Ma pháp", + "description": "" + }, + { + "name": "Kiếm ma", + "description": "" + }, + { + "name": "Pháp sư", + "description": "" + }, + { + "name": "Giả heo ăn hổ", + "description": "" + }, + { + "name": "Bi kịch", + "description": "" + }, + { + "name": "Chữa lành", + "description": "" + }, + { + "name": "Hắc ám", + "description": "" + }, + { + "name": "Gothic", + "description": "" + }, + { + "name": "Tâm lý", + "description": "" + }, + { + "name": "Tâm thần phân liệt", + "description": "" + }, + { + "name": "Đa nhân cách", + "description": "" + }, + { + "name": "Trinh thám", + "description": "" + }, + { + "name": "Phá án", + "description": "" + }, + { + "name": "Đạo mộ", + "description": "" + }, + { + "name": "Cương thi", + "description": "" + }, + { + "name": "Quy tắc quái đàm", + "description": "" + }, + { + "name": "Vô hạn lưu", + "description": "" + }, + { + "name": "Mau xuyên", + "description": "" + }, + { + "name": "Thực dân", + "description": "" + }, + { + "name": "Chế tạo", + "description": "" + }, + { + "name": "Lĩnh chúa", + "description": "" + }, + { + "name": "Trồng trọt", + "description": "" + }, + { + "name": "Câu cá", + "description": "" + }, + { + "name": "Nuôi thú", + "description": "" + }, + { + "name": "Pokemon", + "description": "" + }, + { + "name": "Anime", + "description": "" + }, + { + "name": "Harry Potter", + "description": "" + }, + { + "name": "Marvel", + "description": "" + }, + { + "name": "Biển sao", + "description": "" + }, + { + "name": "Truyền thuyết đô thị", + "description": "" + }, + { + "name": "Cổ đại", + "description": "" + }, + { + "name": "Dân quốc", + "description": "" + }, + { + "name": "Thập niên 70", + "description": "" + }, + { + "name": "Thập niên 80", + "description": "" + }, + { + "name": "Thập niên 90", + "description": "" + }, + { + "name": "Dị thế", + "description": "" + }, + { + "name": "Đại lục", + "description": "" + }, + { + "name": "Bộ lạc", + "description": "" + }, + { + "name": "Nguyên thủy", + "description": "" + }, + { + "name": "Tu tiên", + "description": "" + }, + { + "name": "Trả thù", + "description": "" + }, + { + "name": "Pháo hôi", + "description": "" + }, + { + "name": "Vai ác", + "description": "" + }, + { + "name": "Tiên tri", + "description": "" + }, + { + "name": "Phép thuật", + "description": "" + } +] \ No newline at end of file diff --git a/genre_manager.py b/services/genre_manager.py similarity index 100% rename from genre_manager.py rename to services/genre_manager.py diff --git a/novel_generator.py b/services/novel_generator.py similarity index 98% rename from novel_generator.py rename to services/novel_generator.py index 1fe6fb3..af4bc92 100644 --- a/novel_generator.py +++ b/services/novel_generator.py @@ -12,10 +12,10 @@ from typing import List, Dict, Optional, Tuple from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from api_client import get_api_client -from config import get_config +from services.api_client import get_api_client +from core.config import get_config from locales.i18n import t -from database import get_db +from core.database import get_db logger = logging.getLogger(__name__) @@ -99,9 +99,9 @@ class OutlineParser: chapter_count = 0 # Hỗ trợ nhiều định dạng phác thảo phổ biến và thử nhiều biểu thức chính quy patterns = [ - r'第\s*(\d+)\s*章[::\s]*([^\-—–]+)[-—–]\s*(.+)', + r'(?:第\s*|Chương\s*|Chapter\s*|Hồi\s*|Phần\s*)(\d+)(?:\s*章)?[::\s]+([^\-—–]+)[-—–]\s*(.+)', r'^(\d+)[\).:\s]+([^\-—–]+)[-—–]\s*(.+)', - r'第\s*(\d+)\s*章[::]\s*(.+)', + r'(?:第\s*|Chương\s*|Chapter\s*|Hồi\s*|Phần\s*)(\d+)(?:\s*章)?[::\s]+(.+)', ] for line in lines: @@ -175,8 +175,8 @@ class OutlineParser: return "\n".join(lines) -from genre_manager import GenreManager -from sub_genre_manager import SubGenreManager +from services.genre_manager import GenreManager +from services.sub_genre_manager import SubGenreManager class NovelGenerator: """máy phát điện mới""" @@ -442,6 +442,9 @@ class NovelGenerator: context_prompt=context_prompt ) + if custom_prompt: + prompt += f"\n\n[Yêu cầu bổ sung của tác giả]:\n{custom_prompt}" + sys_prompt = t("prompts.chapter_system") messages = [ @@ -522,6 +525,9 @@ class NovelGenerator: context_prompt=context_prompt ) + if custom_prompt: + prompt += f"\n\n[Yêu cầu bổ sung của tác giả]:\n{custom_prompt}" + messages = [ {"role": "system", "content": t("prompts.chapter_system")}, {"role": "user", "content": prompt} @@ -1066,7 +1072,7 @@ def load_generation_cache(project_id: str) -> Tuple[Optional[Dict], str]: return None, t("generator.cache_not_found") cache_data = json.loads(row["data"]) - logger.info(f"Cache loaded from database: {project_id}") + logger.info(f"Cache loaded from core.database: {project_id}") return cache_data, t("generator.cache_load_success") except Exception as e: logger.error(f"Cache load failed: {e}") @@ -1244,7 +1250,7 @@ def load_chapter_summaries(project_id: str) -> Tuple[List[Dict], str]: return [], t("generator.summary_dir_not_found") summaries = [{"chapter_num": row["chapter_num"], "summary": row["summary"], "generated_at": row["generated_at"]} for row in rows] - logger.info(f"Loaded {len(summaries)} chapter summaries from database") + logger.info(f"Loaded {len(summaries)} chapter summaries from core.database") return summaries, t("generator.summary_load_done", count=len(summaries)) except Exception as e: logger.error(f"Load chapter summaries failed: {e}") diff --git a/project_manager.py b/services/project_manager.py similarity index 96% rename from project_manager.py rename to services/project_manager.py index 82fef72..705a66b 100644 --- a/project_manager.py +++ b/services/project_manager.py @@ -13,9 +13,9 @@ from typing import List, Dict, Optional, Tuple from datetime import datetime from pathlib import Path -from novel_generator import NovelProject, Chapter +from services.novel_generator import NovelProject, Chapter from locales.i18n import t -from database import get_db +from core.database import get_db logger = logging.getLogger(__name__) @@ -194,7 +194,7 @@ class ProjectManager: ) project.chapters.append(chapter) - logger.info(f"Project loaded from database: {project_id}") + logger.info(f"Project loaded from core.database: {project_id}") return project, t("project_manager.load_success") except Exception as e: @@ -271,7 +271,7 @@ class ProjectManager: if cursor.rowcount == 0: return False, t("project_manager.delete_not_found", id=project_id) - logger.info(f"Project deleted from database: {project_id}") + logger.info(f"Project deleted from core.database: {project_id}") return True, t("project_manager.delete_success") except Exception as e: @@ -329,3 +329,11 @@ class ProjectManager: def get_project_manager() -> ProjectManager: """Nhận phiên bản quản lý dự án""" return ProjectManager() + +def list_project_titles(): + """Lấy danh sách tiêu đề dự án""" + try: + projects = ProjectManager.list_projects() + return [p["title"] for p in projects] + except Exception: + return [] diff --git a/sub_genre_manager.py b/services/sub_genre_manager.py similarity index 100% rename from sub_genre_manager.py rename to services/sub_genre_manager.py diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/continue_tab.py b/ui/continue_tab.py new file mode 100644 index 0000000..1ebbca8 --- /dev/null +++ b/ui/continue_tab.py @@ -0,0 +1,362 @@ +import gradio as gr +from datetime import datetime +from locales.i18n import t +from services.project_manager import ProjectManager, list_project_titles +from services.novel_generator import Chapter +from core.state import app_state +import logging + +logger = logging.getLogger(__name__) + +def build_continue_tab(): + with gr.Tab(t("tabs.continue_tab")): + gr.Markdown(f"### {t('continue_tab.header')}") + + with gr.Accordion("📂 1. Quản lý dự án", open=True): + with gr.Row(): + with gr.Column(scale=4): + project_choices = list_project_titles() + continue_project_selector = gr.Dropdown( + choices=project_choices, + label=t("continue_tab.select_project"), + interactive=True + ) + with gr.Column(scale=1, min_width=100): + refresh_continue_btn = gr.Button(t("continue_tab.load_btn"), size="lg") + + with gr.Accordion("⚙️ 2. Thông tin & Cài đặt", open=False): + with gr.Row(): + with gr.Column(scale=1): + continue_project_info = gr.Markdown(t("continue_tab.no_project_loaded")) + with gr.Column(scale=2): + continue_outline_display = gr.Textbox( + label=t("continue_tab.outline_label"), + interactive=False, lines=6, max_lines=12, + value="Chưa tải dự án..." + ) + + with gr.Row(): + with gr.Column(scale=1): + memory_type = gr.Radio(label="Cách nhớ ngữ cảnh", choices=["Toàn văn", "Tóm tắt"], value="Toàn văn") + with gr.Column(scale=1): + memory_chapters = gr.Number(label="Số chương ghi nhớ", value=3, minimum=1, maximum=20, step=1) + + with gr.Row(): + continue_target_words = gr.Number(label=t("rewrite.target_words"), value=3000, minimum=100, maximum=50000, step=100) + continue_custom_prompt = gr.Textbox(label=t("create.custom_prompt_label"), placeholder=t("create.custom_prompt_placeholder"), scale=2) + + with gr.Accordion("🚀 3. Sáng tác tiếp", open=False): + with gr.Row(): + continue_chapter_num = gr.Number(label="Chương số", value=1, minimum=1, scale=1) + continue_chapter_title = gr.Textbox(label="Tiêu đề chương", placeholder="Nhập tiêu đề chương...", scale=2) + continue_chapter_desc = gr.Textbox( + label="Nội dung/Dàn ý chương này", + lines=3, placeholder="Nhập mô tả nội dung chương..." + ) + with gr.Row(): + continue_generate_btn = gr.Button(t("continue_tab.continue_gen_btn"), variant="primary", size="lg", scale=1) + continue_auto_btn = gr.Button("Tự động viết nốt các chương còn lại", variant="primary", size="lg", scale=2) + continue_stop_btn = gr.Button("Dừng tự động", variant="stop", size="lg", scale=1) + + with gr.Row(): + with gr.Column(scale=1): + continue_status = gr.Textbox(label=t("continue_tab.gen_status"), interactive=False, lines=15) + with gr.Column(scale=2): + continue_chapter_selector = gr.Dropdown(label="Danh sách chương đã tạo", choices=[], interactive=True) + continue_content_display = gr.Textbox(label="Nội dung chương", lines=15, interactive=False) + + def on_refresh_continue(current_title): + titles = list_project_titles() + if current_title in titles: + info, next_ch, outline_text, chapter_choices = on_continue_project_select(current_title) + return gr.update(choices=titles, value=current_title), info, next_ch, outline_text, gr.update(choices=chapter_choices, value=chapter_choices[-1] if chapter_choices else None) + else: + return gr.update(choices=titles, value=None), t("continue_tab.no_project_loaded"), 1, "Chưa tải dự án...", gr.update(choices=[], value=None) + + def on_continue_project_select(project_title): + if not project_title: + return t("continue_tab.no_project_loaded"), 1, "Chưa tải dự án...", [] + try: + project_data = ProjectManager.get_project_by_title(project_title) + if not project_data: + return f"❌ {t('continue_tab.project_not_found')}", 1, "Chưa tải dự án..." + + project_id = project_data.get("id") + project, msg = ProjectManager.load_project(project_id) + if project: + app_state.current_project = project + completed = project.get_completed_count() + total_chapters = len(project.chapters) + next_ch = completed + 1 + percent = f"{(completed / total_chapters * 100):.1f}" if total_chapters > 0 else "0.0" + + # Handle None values safely + char_str = project.character_setting or "" + world_str = project.world_setting or "" + plot_str = project.plot_idea or "" + + char_fmt = char_str[:100] + "..." if len(char_str) > 100 else char_str + world_fmt = world_str[:100] + "..." if len(world_str) > 100 else world_str + plot_fmt = plot_str[:100] + "..." if len(plot_str) > 100 else plot_str + + # Logic fallback + info_template = t("continue_tab.info_template") + if isinstance(info_template, str) and '{' in info_template: + info = info_template.format( + title=project.title, + genre=project.genre, + completed=completed, + total=total_chapters, + words=project.get_total_words(), + percent=percent, + char=char_fmt, + world=world_fmt, + plot=plot_fmt + ) + else: + info = f"### 📖 {project.title}\n**Thể loại**: {project.genre}\n**Tiến độ**: {percent}% ({completed}/{total_chapters} chương)\n**Tổng số từ**: {project.get_total_words()}\n💡 Chương tiếp theo: {next_ch}" + + # Format the outline list + outline_lines = [] + for ch in project.chapters: + status = "✅" if getattr(ch, 'content', None) else "⬜" + outline_lines.append(f"{status} Chương {ch.num}: {ch.title} - {ch.desc}") + outline_text = "\n".join(outline_lines) + if not outline_text: + outline_text = "Dự án này chưa có dàn ý chi tiết." + + # Generate chapter choices + chapter_choices = [f"Chương {ch.num}: {ch.title}" for ch in project.chapters if getattr(ch, 'content', None)] + + return info, next_ch, outline_text, chapter_choices + return f"❌ {msg}", 1, "Chưa tải dự án...", [] + except Exception as e: + return f"❌ {str(e)}", 1, "Lỗi khi tải dàn ý", [] + + def on_continue_chapter_select(chapter_title): + if not chapter_title or not app_state.current_project: + return "" + for ch in app_state.current_project.chapters: + if f"Chương {ch.num}: {ch.title}" == chapter_title: + return ch.content or "" + return "" + + def on_continue_generate(project_title, ch_num, ch_title, ch_desc, target_words, custom_prompt, mem_type, mem_chaps): + yield "⏳ Đang chuẩn bị dữ liệu...", "", gr.update(interactive=False), gr.update(), gr.update() + if not app_state.current_project: + yield f"❌ {t('continue_tab.no_project_selected')}", "", gr.update(interactive=True), gr.update(), gr.update() + return + + gen = app_state.get_generator() + project = app_state.current_project + + # Check if the requested chapter exists in the outline + if not any(int(ch.num) == int(ch_num) for ch in project.chapters): + yield f"❌ Lỗi: Chương {int(ch_num)} không có trong dàn ý. Tab này chỉ hỗ trợ hoàn thành nốt nội dung dàn ý đã có, không viết thêm chương mới.", "", gr.update(interactive=True), gr.update(), gr.update() + return + + # Get completed past chapters mapped linearly + all_past_chapters = [ch for ch in project.chapters if ch.num < int(ch_num) and getattr(ch, 'content', None)] + mem_ch = int(mem_chaps) if mem_chaps else 3 + past_chapters = all_past_chapters[-mem_ch:] if len(all_past_chapters) > mem_ch else all_past_chapters + + prev_content = "" + context_summary = "" + + if mem_type == "Toàn văn": + prev_texts = [c.content for c in past_chapters] + prev_content = "\n\n".join(prev_texts) + prev_content = prev_content[-4000:] + else: + summaries = [] + for c in past_chapters: + if not hasattr(c, 'summary') or not c.summary: + yield f"⏳ Đang tạo tóm tắt ngữ cảnh cho chương {c.num}...", "" + summ, _ = gen.generate_chapter_summary(c.content, c.title) + c.summary = summ + if c.summary: + summaries.append(f"Chương {c.num} - {c.title}: {c.summary}") + + if summaries: + context_summary = "\n".join(summaries) + + if all_past_chapters: + prev_content = all_past_chapters[-1].content[-1500:] + + yield f"⏳ Đang sinh Chương {int(ch_num)}...", "", gr.update(interactive=False), gr.update(), gr.update() + + content, msg = gen.generate_chapter( + chapter_num=int(ch_num), chapter_title=ch_title, + chapter_desc=ch_desc, novel_title=project.title, + character_setting=project.character_setting, + world_setting=project.world_setting, + plot_idea=project.plot_idea, genre=project.genre, + sub_genres=project.sub_genres, + previous_content=prev_content, context_summary=context_summary, custom_prompt=custom_prompt + ) + + if content: + new_ch = Chapter( + num=int(ch_num), title=ch_title, desc=ch_desc, + content=content, word_count=len(content), + generated_at=datetime.now().isoformat() + ) + found = False + for i, ch in enumerate(project.chapters): + if ch.num == int(ch_num): + project.chapters[i] = new_ch + found = True + break + if not found: + project.chapters.append(new_ch) + project.chapters.sort(key=lambda x: x.num) + + ProjectManager.save_project(project) + + # Format the outline list + outline_lines = [] + for ch in project.chapters: + status = "✅" if getattr(ch, 'content', None) else "⬜" + outline_lines.append(f"{status} Chương {ch.num}: {ch.title} - {ch.desc}") + outline_text = "\n".join(outline_lines) + + # Update choices + chapter_choices = [f"Chương {ch.num}: {ch.title}" for ch in project.chapters if getattr(ch, 'content', None)] + selected_choice = f"Chương {int(ch_num)}: {ch_title}" + + yield f"✅ Chương {int(ch_num)} đã sinh ({len(content)} từ)", content, gr.update(interactive=True), gr.update(value=outline_text), gr.update(choices=chapter_choices, value=selected_choice) + else: + yield f"❌ {msg}", "", gr.update(interactive=True), gr.update(), gr.update() + + def on_continue_auto_generate(project_title, target_words, custom_prompt, mem_type, mem_chaps, progress=gr.Progress()): + yield "⏳ Đang chuẩn bị dữ liệu...", "", gr.update(interactive=False), gr.update(interactive=False), gr.update(), gr.update() + if not app_state.current_project: + yield f"❌ {t('continue_tab.no_project_selected')}", "", gr.update(interactive=True), gr.update(interactive=True), gr.update(), gr.update() + return + + gen = app_state.get_generator() + project = app_state.current_project + app_state.is_generating = True + app_state.stop_requested = False + + blank_chapters = [ch for ch in project.chapters if not getattr(ch, 'content', None)] + if not blank_chapters: + yield "✅ Đã viết xong tất cả các chương trong dàn ý!", "", gr.update(interactive=True), gr.update(interactive=True), gr.update(), gr.update() + return + + results = [f"📋 Phát hiện {len(blank_chapters)} chương chưa viết. Bắt đầu tự động tạo..."] + yield "\n".join(results), "", gr.update(interactive=False), gr.update(interactive=False), gr.update(), gr.update() + + last_content = "" + + for i, ch in enumerate(blank_chapters): + if app_state.stop_requested: + results.append("\n⚠️ Đã dừng sinh tự động!") + yield "\n".join(results), last_content, gr.update(interactive=True), gr.update(interactive=True), gr.update(), gr.update() + break + + results.append(f"\n✍️ Đang sinh Chương {ch.num}: {ch.title}...") + progress((i + 1) / len(blank_chapters)) + yield "\n".join(results), last_content, gr.update(interactive=False), gr.update(interactive=False), gr.update(), gr.update() + + all_past_chapters = [past for past in project.chapters if past.num < ch.num and getattr(past, 'content', None)] + mem_ch = int(mem_chaps) if mem_chaps else 3 + past_chapters = all_past_chapters[-mem_ch:] if len(all_past_chapters) > mem_ch else all_past_chapters + + prev_content = "" + context_summary = "" + + if mem_type == "Toàn văn": + prev_texts = [c.content for c in past_chapters] + prev_content = "\n\n".join(prev_texts) + prev_content = prev_content[-4000:] + else: + summaries = [] + for c in past_chapters: + if not hasattr(c, 'summary') or not c.summary: + summ, _ = gen.generate_chapter_summary(c.content, c.title) + c.summary = summ + if c.summary: + summaries.append(f"Chương {c.num} - {c.title}: {c.summary}") + + if summaries: + context_summary = "\n".join(summaries) + + if all_past_chapters: + prev_content = all_past_chapters[-1].content[-1500:] + + content, msg = gen.generate_chapter( + chapter_num=int(ch.num), chapter_title=ch.title, + chapter_desc=ch.desc, novel_title=project.title, + character_setting=project.character_setting, + world_setting=project.world_setting, + plot_idea=project.plot_idea, genre=project.genre, + sub_genres=project.sub_genres, + previous_content=prev_content, context_summary=context_summary, custom_prompt=custom_prompt + ) + + if content: + ch.content = content + ch.word_count = len(content) + ch.generated_at = datetime.now().isoformat() + ProjectManager.save_project(project) + last_content = content + + # Format the outline list + outline_lines = [] + for pr_ch in project.chapters: + status = "✅" if getattr(pr_ch, 'content', None) else "⬜" + outline_lines.append(f"{status} Chương {pr_ch.num}: {pr_ch.title} - {pr_ch.desc}") + outline_text = "\n".join(outline_lines) + + # Update choices + chapter_choices = [f"Chương {ch.num}: {ch.title}" for ch in project.chapters if getattr(ch, 'content', None)] + selected_choice = f"Chương {ch.num}: {ch.title}" + + results.append(f"✅ Chương {ch.num} hoàn tất ({len(content)} từ)") + yield "\n".join(results), content, gr.update(interactive=False), gr.update(interactive=False), gr.update(value=outline_text), gr.update(choices=chapter_choices, value=selected_choice) + else: + results.append(f"❌ Lỗi ở chương {ch.num}: {msg}") + app_state.stop_requested = True + yield "\n".join(results), last_content, gr.update(interactive=True), gr.update(interactive=True), gr.update(), gr.update() + break + + app_state.is_generating = False + results.append("\n🎉 Hoàn thành chuỗi viết tự động!") + yield "\n".join(results), last_content, gr.update(interactive=True), gr.update(interactive=True), gr.update(), gr.update() + + def on_continue_stop(): + app_state.stop_requested = True + return "⏸️ Đang dừng..." + + refresh_continue_btn.click( + fn=on_refresh_continue, + inputs=[continue_project_selector], + outputs=[continue_project_selector, continue_project_info, continue_chapter_num, continue_outline_display, continue_chapter_selector] + ) + continue_project_selector.change( + fn=on_continue_project_select, + inputs=[continue_project_selector], + outputs=[continue_project_info, continue_chapter_num, continue_outline_display, continue_chapter_selector] + ) + continue_chapter_selector.change( + fn=on_continue_chapter_select, + inputs=[continue_chapter_selector], + outputs=[continue_content_display] + ) + continue_generate_btn.click( + fn=on_continue_generate, + inputs=[continue_project_selector, continue_chapter_num, continue_chapter_title, + continue_chapter_desc, continue_target_words, continue_custom_prompt, memory_type, memory_chapters], + outputs=[continue_status, continue_content_display, continue_generate_btn, continue_outline_display, continue_chapter_selector] + ) + continue_auto_btn.click( + fn=on_continue_auto_generate, + inputs=[continue_project_selector, continue_target_words, continue_custom_prompt, memory_type, memory_chapters], + outputs=[continue_status, continue_content_display, continue_auto_btn, continue_generate_btn, continue_outline_display, continue_chapter_selector], + show_progress="full" + ) + continue_stop_btn.click( + fn=on_continue_stop, + outputs=[continue_status] + ) diff --git a/ui/create_tab.py b/ui/create_tab.py new file mode 100644 index 0000000..dd93464 --- /dev/null +++ b/ui/create_tab.py @@ -0,0 +1,400 @@ +import gradio as gr +from datetime import datetime +import json +import re +import traceback + +from locales.i18n import t +from services.novel_generator import OutlineParser +from services.project_manager import ProjectManager +from services.genre_manager import GenreManager +from services.sub_genre_manager import SubGenreManager +from core.state import app_state +import logging + +logger = logging.getLogger(__name__) + +def build_create_tab(): + """Xây dựng giao diện Tab Sáng tác từ đầu""" + with gr.Tab(t("tabs.create")): + gr.Markdown(f"### 🪄 {t('create.header')}") + + with gr.Accordion("📌 1. Thông tin cơ bản", open=True): + with gr.Row(): + with gr.Column(scale=1): + genre_choices = GenreManager.get_genre_names() + genre_dropdown = gr.Dropdown( + choices=genre_choices, + value=genre_choices[0] if genre_choices else None, + label=t("create.genre_label"), + interactive=True + ) + sub_genre_choices = SubGenreManager.get_sub_genre_names() + sub_genre_dropdown = gr.Dropdown( + choices=sub_genre_choices, + label=t("create.sub_genres_label"), + multiselect=True, + interactive=True + ) + + with gr.Column(scale=2): + title_input = gr.Textbox( + label=t("create.novel_title"), + placeholder=t("create.novel_title_default"), + interactive=True + ) + with gr.Group(): + suggested_titles_radio = gr.Radio( + label=t("create.suggested_titles_radio") if t("create.suggested_titles_radio") != "create.suggested_titles_radio" else "Gợi ý tên truyện", + choices=[], + visible=False, + interactive=True + ) + with gr.Row(): + suggest_title_prompt = gr.Textbox( + show_label=False, + placeholder=t("create.custom_prompt_placeholder"), + lines=1, interactive=True, scale=4 + ) + suggest_title_btn = gr.Button(t("create.suggest_title_btn"), variant="secondary", size="sm", scale=1) + suggest_title_status = gr.Textbox(show_label=False, interactive=False, visible=False) + + with gr.Accordion("🎭 2. Thiết lập chi tiết", open=False): + with gr.Row(): + with gr.Column(): + character_input = gr.Textbox( + label=t("create.char_setting"), + placeholder=t("create.char_setting_placeholder"), + lines=5, interactive=False + ) + with gr.Group(): + with gr.Row(): + num_main_chars = gr.Number(label=t("create.num_main_chars"), value=2, minimum=1, maximum=10, step=1, scale=1) + num_sub_chars = gr.Number(label=t("create.num_sub_chars"), value=3, minimum=0, maximum=20, step=1, scale=1) + with gr.Row(): + suggest_char_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=False, scale=4) + suggest_char_btn = gr.Button(t("create.suggest_btn"), variant="secondary", size="sm", scale=1, interactive=False) + suggest_char_status = gr.Textbox(show_label=False, interactive=False, visible=False) + + with gr.Column(): + world_input = gr.Textbox( + label=t("create.world_setting"), + placeholder=t("create.world_setting_placeholder"), + lines=5, interactive=False + ) + with gr.Group(): + with gr.Row(): + suggest_world_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=False, scale=4) + suggest_world_btn = gr.Button(t("create.suggest_btn"), variant="secondary", size="sm", scale=1, interactive=False) + suggest_world_status = gr.Textbox(show_label=False, interactive=False, visible=False) + + plot_input = gr.Textbox( + label=t("create.plot_idea"), + placeholder=t("create.plot_idea_placeholder"), + lines=3, interactive=False + ) + with gr.Group(): + with gr.Row(): + suggest_plot_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=False, scale=4) + suggest_plot_btn = gr.Button(t("create.suggest_plot_btn"), variant="secondary", size="sm", scale=1, interactive=False) + suggest_plot_status = gr.Textbox(show_label=False, interactive=False, visible=False) + + with gr.Accordion("📝 3. Dàn ý truyện", open=False): + with gr.Row(): + total_chapters = gr.Number( + label=t("create.chapter_count"), value=20, minimum=1, maximum=200, step=1, scale=1, interactive=False + ) + generate_outline_btn = gr.Button(t("create.gen_outline_btn"), variant="primary", size="lg", scale=3, interactive=False) + + outline_output = gr.Textbox(label=t("create.outline_display"), lines=15, interactive=True) + outline_status = gr.Textbox(label=t("create.gen_status"), interactive=False) + + with gr.Accordion("🚀 4. Sáng tác tự động", open=False): + with gr.Row(): + with gr.Column(scale=1): + memory_type = gr.Radio(label="Cách nhớ ngữ cảnh", choices=["Toàn văn", "Tóm tắt"], value="Toàn văn") + with gr.Column(scale=1): + memory_chapters = gr.Number(label="Số chương ghi nhớ", value=3, minimum=1, maximum=20, step=1) + with gr.Row(): + auto_generate_btn = gr.Button(t("create.start_gen_btn"), variant="primary", size="lg", interactive=False) + stop_btn = gr.Button(t("create.pause_gen_btn"), variant="stop", size="lg", interactive=False) + + with gr.Row(): + with gr.Column(scale=1): + generation_progress = gr.Textbox(label=t("create.gen_status"), lines=15, interactive=False) + with gr.Column(scale=2): + chapter_selector = gr.Dropdown(label="Danh sách chương đã tạo", choices=[], interactive=True) + chapter_content_display = gr.Textbox(label="Nội dung chương", lines=15, interactive=False) + + def on_suggest_title(genre, sub_genres, custom_prompt): + yield gr.Radio(visible=False), gr.update(value="⏳ Đang gọi AI xử lý... Vui lòng chờ.", visible=True), gr.update(interactive=False) + try: + gen = app_state.get_generator() + content, msg = gen.suggest_title(genre, sub_genres, custom_prompt) + if content: + try: + match = re.search(r'\[\s*\{[\s\S]*\}\s*\]', content) + if match: + data = {"suggestions": json.loads(match.group(0))} + else: + clean_content = re.sub(r'```(?:json)?\s*', '', content) + clean_content = re.sub(r'```\s*', '', clean_content) + data = json.loads(clean_content) + + if "suggestions" in data: + choices = [f"{item.get('title', '')} - {item.get('description', '')}" for item in data["suggestions"]] + yield gr.Radio(choices=choices, visible=True), gr.update(visible=False), gr.update(interactive=True) + return + except Exception as e: + logger.error(f"Failed to parse title JSON: {e}") + yield gr.Radio(choices=[content, f"Parse error: {str(e)}"], visible=True), gr.update(visible=False), gr.update(interactive=True) + return + yield gr.Radio(choices=[msg], visible=True), gr.update(visible=False), gr.update(interactive=True) + except Exception as e: + traceback.print_exc() + yield gr.Radio(choices=[f"Lỗi: {str(e)}"], visible=True), gr.update(value=f"❌ Lỗi: {str(e)}", visible=True), gr.update(interactive=True) + + def on_title_select(selected): + if selected and " - " in selected: + return selected.split(" - ", 1)[0] + return selected + + def on_suggest_char(title, genre, sub_genres, custom_prompt, num_main, num_sub): + yield gr.update(), gr.update(value="⏳ Đang gọi AI xử lý... Vui lòng chờ.", visible=True), gr.update(interactive=False) + try: + gen = app_state.get_generator() + nm = int(num_main) if num_main else 2 + ns = int(num_sub) if num_sub else 3 + content, msg = gen.suggest_content("char", title, genre, sub_genres, "", "", custom_prompt, nm, ns) + yield gr.update(value=content if content else msg), gr.update(visible=False), gr.update(interactive=True) + except Exception as e: + traceback.print_exc() + yield gr.update(), gr.update(value=f"❌ Lỗi: {str(e)}", visible=True), gr.update(interactive=True) + + def on_suggest_world(title, genre, sub_genres, custom_prompt): + yield gr.update(), gr.update(value="⏳ Đang gọi AI xử lý... Vui lòng chờ.", visible=True), gr.update(interactive=False) + try: + gen = app_state.get_generator() + content, msg = gen.suggest_content("world", title, genre, sub_genres, "", "", custom_prompt) + yield gr.update(value=content if content else msg), gr.update(visible=False), gr.update(interactive=True) + except Exception as e: + traceback.print_exc() + yield gr.update(), gr.update(value=f"❌ Lỗi: {str(e)}", visible=True), gr.update(interactive=True) + + def on_suggest_plot(title, genre, sub_genres, char_setting, world_setting, custom_prompt): + yield gr.update(), gr.update(value="⏳ Đang gọi AI xử lý... Vui lòng chờ.", visible=True), gr.update(interactive=False) + try: + gen = app_state.get_generator() + content, msg = gen.suggest_content("plot", title, genre, sub_genres, char_setting, world_setting, custom_prompt) + yield gr.update(value=content if content else msg), gr.update(visible=False), gr.update(interactive=True) + except Exception as e: + traceback.print_exc() + yield gr.update(), gr.update(value=f"❌ Lỗi: {str(e)}", visible=True), gr.update(interactive=True) + + def on_generate_outline(title, genre, sub_genres, num_chapters, char_setting, world_setting, plot_idea, progress=gr.Progress()): + progress(0.1, desc="Đang gọi AI...") + gen = app_state.get_generator() + content, msg = gen.generate_outline( + title, genre, sub_genres or [], + int(num_chapters), char_setting, world_setting, plot_idea + ) + return content, msg + + def on_auto_generate(title, genre, sub_genres, char_setting, world_setting, plot_idea, outline_text, mem_type, mem_chaps, progress=gr.Progress()): + """Tự động tạo toàn bộ tiểu thuyết""" + gen = app_state.get_generator() + + chapters, parse_msg = OutlineParser.parse(outline_text) + if not chapters: + yield f"❌ {parse_msg}", gr.update() + return + + project, create_msg = ProjectManager.create_project( + title, genre, sub_genres or [], + char_setting, world_setting, plot_idea + ) + if not project: + yield f"❌ {create_msg}", gr.update() + return + + project.chapters = chapters + app_state.current_project = project + app_state.is_generating = True + app_state.stop_requested = False + + # Save immediately to ensure chapter titles are stored even if generation is stopped early + ProjectManager.save_project(project) + + results = [f"📋 Đã phân tích {len(chapters)} chương", f"💾 {create_msg}"] + yield "\n".join(results), gr.update(choices=[], value=None) + + generated_chapters = [] + + for i, chapter in enumerate(chapters): + if app_state.stop_requested: + results.append("\n⚠️ Đã dừng sinh!") + yield "\n".join(results), gr.update(choices=generated_chapters) + break + + results.append(f"\n✍️ Đang sinh Chương {chapter.num}: {chapter.title}...") + progress((i + 1) / len(chapters)) + yield "\n".join(results), gr.update(choices=generated_chapters) + + mem_ch = int(mem_chaps) if mem_chaps else 3 + start_idx = max(0, i - mem_ch) + past_chapters = chapters[start_idx:i] + + prev_content = "" + context_summary = "" + + if mem_type == "Toàn văn": + prev_texts = [c.content for c in past_chapters if hasattr(c, 'content') and c.content] + prev_content = "\n\n".join(prev_texts) + prev_content = prev_content[-4000:] + else: + summaries = [] + for c in past_chapters: + if not hasattr(c, 'content') or not c.content: + continue + if not hasattr(c, 'summary') or not c.summary: + summ, _ = gen.generate_chapter_summary(c.content, c.title) + c.summary = summ + if c.summary: + summaries.append(f"Chương {c.num} - {c.title}: {c.summary}") + + if summaries: + context_summary = "\n".join(summaries) + + if i > 0 and chapters[i-1].content: + prev_content = chapters[i-1].content[-1500:] + + content, msg = gen.generate_chapter( + chapter_num=chapter.num, chapter_title=chapter.title, + chapter_desc=chapter.desc, novel_title=title, + character_setting=char_setting, world_setting=world_setting, + plot_idea=plot_idea, genre=genre, sub_genres=sub_genres, + previous_content=prev_content, context_summary=context_summary + ) + + if content: + chapter.content = content + chapter.word_count = len(content) + chapter.generated_at = datetime.now().isoformat() + results.append(f"✅ Chương {chapter.num}: {len(content)} từ") + ProjectManager.save_project(project) + chapter_name = f"Chương {chapter.num}: {chapter.title}" + generated_chapters.append(chapter_name) + yield "\n".join(results), gr.update(choices=generated_chapters, value=chapter_name) + else: + results.append(f"❌ Chương {chapter.num}: {msg}") + yield "\n".join(results), gr.update(choices=generated_chapters) + + app_state.is_generating = False + total_words = sum(ch.word_count for ch in chapters if ch.content) + results.append(f"\n🎉 Hoàn thành! Tổng: {total_words} từ") + yield "\n".join(results), gr.update(choices=generated_chapters) + + def on_chapter_select(chapter_title): + if not app_state.current_project or not chapter_title: + return "" + for ch in app_state.current_project.chapters: + if f"Chương {ch.num}: {ch.title}" == chapter_title: + return ch.content or "" + return "" + + def on_stop(): + app_state.stop_requested = True + return "⏸️ Đang dừng..." + + # Bind events + suggest_title_btn.click( + fn=on_suggest_title, + inputs=[genre_dropdown, sub_genre_dropdown, suggest_title_prompt], + outputs=[suggested_titles_radio, suggest_title_status, suggest_title_btn] + ) + suggested_titles_radio.change( + fn=on_title_select, + inputs=[suggested_titles_radio], + outputs=[title_input] + ) + suggest_char_btn.click( + fn=on_suggest_char, + inputs=[title_input, genre_dropdown, sub_genre_dropdown, suggest_char_prompt, num_main_chars, num_sub_chars], + outputs=[character_input, suggest_char_status, suggest_char_btn] + ) + suggest_world_btn.click( + fn=on_suggest_world, + inputs=[title_input, genre_dropdown, sub_genre_dropdown, suggest_world_prompt], + outputs=[world_input, suggest_world_status, suggest_world_btn] + ) + suggest_plot_btn.click( + fn=on_suggest_plot, + inputs=[title_input, genre_dropdown, sub_genre_dropdown, character_input, world_input, suggest_plot_prompt], + outputs=[plot_input, suggest_plot_status, suggest_plot_btn] + ) + generate_outline_btn.click( + fn=on_generate_outline, + inputs=[title_input, genre_dropdown, sub_genre_dropdown, total_chapters, character_input, world_input, plot_input], + outputs=[outline_output, outline_status], + show_progress="full" + ) + auto_generate_btn.click( + fn=on_auto_generate, + inputs=[title_input, genre_dropdown, sub_genre_dropdown, character_input, world_input, plot_input, outline_output, memory_type, memory_chapters], + outputs=[generation_progress, chapter_selector] + ) + chapter_selector.change( + fn=on_chapter_select, + inputs=[chapter_selector], + outputs=[chapter_content_display] + ) + stop_btn.click(fn=on_stop, outputs=[generation_progress]) + + # Flow Enforcement Events + title_input.change( + fn=lambda t: [ + gr.update(interactive=bool(t)), + gr.update(interactive=bool(t)), + gr.update(interactive=bool(t)) + ], + inputs=[title_input], + outputs=[character_input, suggest_char_prompt, suggest_char_btn] + ) + + character_input.change( + fn=lambda c: [ + gr.update(interactive=bool(c)), + gr.update(interactive=bool(c)), + gr.update(interactive=bool(c)) + ], + inputs=[character_input], + outputs=[world_input, suggest_world_prompt, suggest_world_btn] + ) + + world_input.change( + fn=lambda w: [ + gr.update(interactive=bool(w)), + gr.update(interactive=bool(w)), + gr.update(interactive=bool(w)) + ], + inputs=[world_input], + outputs=[plot_input, suggest_plot_prompt, suggest_plot_btn] + ) + + plot_input.change( + fn=lambda p: [ + gr.update(interactive=bool(p)), + gr.update(interactive=bool(p)) + ], + inputs=[plot_input], + outputs=[total_chapters, generate_outline_btn] + ) + + outline_output.change( + fn=lambda o: [ + gr.update(interactive=bool(o)), + gr.update(interactive=bool(o)) + ], + inputs=[outline_output], + outputs=[auto_generate_btn, stop_btn] + ) diff --git a/ui/export_tab.py b/ui/export_tab.py new file mode 100644 index 0000000..4449fe3 --- /dev/null +++ b/ui/export_tab.py @@ -0,0 +1,92 @@ +import gradio as gr +from locales.i18n import t +from services.project_manager import ProjectManager, list_project_titles +from utils.exporter import export_to_docx, export_to_txt, export_to_markdown, export_to_html +import logging + +logger = logging.getLogger(__name__) + +def build_export_tab(): + with gr.Tab(t("tabs.export")): + gr.Markdown(f"### {t('export.header')}") + + export_project_choices = list_project_titles() + export_project_selector = gr.Dropdown( + choices=export_project_choices, + label=t("projects.select_project"), + interactive=True + ) + refresh_export_btn = gr.Button(t("projects.refresh_btn"), size="sm") + + export_format = gr.Radio( + choices=[ + t("create.export_format_word"), + t("create.export_format_txt"), + t("create.export_format_md"), + t("create.export_format_html") + ], + value=t("create.export_format_txt"), + label=t("projects.export_format"), + interactive=True + ) + export_btn = gr.Button(t("projects.export_btn"), variant="primary", size="lg") + export_status = gr.Textbox(label=t("projects.export_status"), interactive=False) + export_download = gr.File(label=t("projects.download_file"), interactive=False) + + def on_refresh_export(): + titles = list_project_titles() + return gr.update(choices=titles, value=None) + + def on_export(project_title, format_type): + if not project_title: + return f"❌ {t('projects.select_project_first')}", None + + try: + project_data = ProjectManager.get_project_by_title(project_title) + if not project_data: + return f"❌ {t('projects.project_not_found')}", None + + project_id = project_data.get("id") + project, msg = ProjectManager.load_project(project_id) + if not project: + return f"❌ {msg}", None + + 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: + return f"❌ {t('ui.no_content_export')}", None + + # Map format + format_map = { + t("create.export_format_word"): "docx", + t("create.export_format_txt"): "txt", + t("create.export_format_md"): "md", + t("create.export_format_html"): "html" + } + fmt = format_map.get(format_type, "txt") + + if fmt == "docx": + filepath, exp_msg = export_to_docx(full_text, project.title) + elif fmt == "txt": + filepath, exp_msg = export_to_txt(full_text, project.title) + elif fmt == "md": + filepath, exp_msg = export_to_markdown(full_text, project.title) + elif fmt == "html": + filepath, exp_msg = export_to_html(full_text, project.title) + else: + return f"❌ {t('ui.unsupported_format', format=fmt)}", None + + if filepath: + return f"✅ {exp_msg}", filepath + return f"❌ {exp_msg}", None + + except Exception as e: + logger.error(f"Export failed: {e}", exc_info=True) + return f"❌ {t('ui.export_failed', error=str(e))}", None + + refresh_export_btn.click(fn=on_refresh_export, outputs=[export_project_selector]) + export_btn.click(fn=on_export, inputs=[export_project_selector, export_format], outputs=[export_status, export_download]) diff --git a/ui/polish_tab.py b/ui/polish_tab.py new file mode 100644 index 0000000..b60a54e --- /dev/null +++ b/ui/polish_tab.py @@ -0,0 +1,61 @@ +import gradio as gr +from locales.i18n import t +from utils.file_parser import parse_novel_file +from core.state import app_state +import logging + +logger = logging.getLogger(__name__) + +def build_polish_tab(): + with gr.Tab(t("tabs.polish")): + gr.Markdown(f"### {t('polish.header')}") + + polish_file_input = gr.File(label=t("polish.upload_file"), file_types=[".txt", ".docx", ".md", ".pdf"]) + polish_input = gr.Textbox( + label=t("polish.original_text"), + lines=10, placeholder="Dán nội dung cần đánh bóng..." + ) + + polish_custom_req = gr.Textbox( + label=t("polish.custom_req"), + placeholder=t("polish.custom_req_placeholder"), + lines=2 + ) + + with gr.Row(): + polish_btn = gr.Button(t("polish.start_polish"), variant="primary") + polish_suggest_btn = gr.Button(t("polish.polish_suggest_btn"), variant="secondary") + + polish_status = gr.Textbox(label=t("polish.polish_status"), interactive=False) + polish_output = gr.Textbox(label=t("polish.polished_text"), lines=15, interactive=True) + + def on_polish_file_upload(file): + if file is None: + return "" + try: + content = parse_novel_file(file.name) + return content + except Exception as e: + return f"❌ {str(e)}" + + def on_polish(text, custom_req): + yield "⏳ Đang gọi AI xử lý... Vui lòng chờ.", gr.update(), gr.update(interactive=False) + gen = app_state.get_generator() + content, msg = gen.polish_text(text, custom_requirements=custom_req) + if content: + yield f"✅ {msg}", content, gr.update(interactive=True) + else: + yield f"❌ {msg}", gr.update(), gr.update(interactive=True) + + def on_polish_suggest(text, custom_req): + yield "⏳ Đang gọi AI xử lý... Vui lòng chờ.", gr.update(), gr.update(interactive=False) + gen = app_state.get_generator() + content, msg = gen.polish_and_suggest(text, custom_requirements=custom_req) + if content: + yield f"✅ {msg}", content, gr.update(interactive=True) + else: + yield f"❌ {msg}", gr.update(), gr.update(interactive=True) + + polish_file_input.change(fn=on_polish_file_upload, inputs=[polish_file_input], outputs=[polish_input]) + polish_btn.click(fn=on_polish, inputs=[polish_input, polish_custom_req], outputs=[polish_status, polish_output, polish_btn]) + polish_suggest_btn.click(fn=on_polish_suggest, inputs=[polish_input, polish_custom_req], outputs=[polish_status, polish_output, polish_suggest_btn]) diff --git a/ui/projects_tab.py b/ui/projects_tab.py new file mode 100644 index 0000000..e20a666 --- /dev/null +++ b/ui/projects_tab.py @@ -0,0 +1,69 @@ +import gradio as gr +from locales.i18n import t +from services.project_manager import ProjectManager, list_project_titles +import logging + +logger = logging.getLogger(__name__) + +def build_projects_tab(): + with gr.Tab(t("tabs.projects")): + gr.Markdown(f"### {t('projects.header')}") + + projects_table = gr.Dataframe( + headers=["ID", t("ui.col_project_name"), t("ui.col_type"), t("ui.col_created_at"), t("ui.col_chapters")], + interactive=False + ) + with gr.Row(): + refresh_projects_btn = gr.Button(t("projects.refresh_btn")) + delete_project_btn = gr.Button(t("projects.delete_btn"), variant="stop") + + gr.Markdown(f"#### {t('projects.delete_header')}") + delete_project_selector = gr.Dropdown( + choices=list_project_titles(), + label=t("projects.delete_select_project"), + interactive=True + ) + project_manage_status = gr.Textbox(label=t("projects.status_label"), interactive=False) + + def on_refresh_projects(): + try: + projects = ProjectManager.list_projects() + table_data = [] + for p in projects: + table_data.append([ + p.get("id", ""), + p.get("title", ""), + p.get("genre", ""), + p.get("created_at", "")[:10] if p.get("created_at") else "", + f"{p.get('completed_chapters', 0)}/{p.get('chapter_count', 0)}" + ]) + titles = [p.get("title", "") for p in projects] + return table_data, gr.update(choices=titles, value=None) + except Exception as e: + logger.error(f"Refresh projects failed: {e}") + return [], gr.update() + + def on_delete_project(project_title): + if not project_title or not project_title.strip(): + return f"❌ {t('projects.select_project_first')}", gr.update(), gr.update() + + try: + project_data = ProjectManager.get_project_by_title(project_title) + if not project_data: + return f"❌ {t('projects.project_not_found')}", gr.update(), gr.update() + + project_id = project_data.get("id") + success, msg = ProjectManager.delete_project(project_id) + if success: + new_table, new_dropdown = on_refresh_projects() + return f"✅ {t('projects.delete_success')}", new_table, new_dropdown + return f"❌ {t('projects.delete_failed')}: {msg}", gr.update(), gr.update() + except Exception as e: + return f"❌ {str(e)}", gr.update(), gr.update() + + refresh_projects_btn.click(fn=on_refresh_projects, outputs=[projects_table, delete_project_selector]) + delete_project_btn.click( + fn=on_delete_project, + inputs=[delete_project_selector], + outputs=[project_manage_status, projects_table, delete_project_selector] + ) diff --git a/ui/rewrite_tab.py b/ui/rewrite_tab.py new file mode 100644 index 0000000..8d09283 --- /dev/null +++ b/ui/rewrite_tab.py @@ -0,0 +1,52 @@ +import gradio as gr +from locales.i18n import t +from services.novel_generator import get_preset_templates +from utils.file_parser import parse_novel_file +from core.state import app_state +import logging + +logger = logging.getLogger(__name__) + +def build_rewrite_tab(): + with gr.Tab(t("tabs.rewrite")): + gr.Markdown(f"### {t('rewrite.header')}") + + rewrite_file_input = gr.File(label=t("rewrite.upload_file"), file_types=[".txt", ".docx", ".md", ".pdf"]) + rewrite_input = gr.Textbox( + label=t("polish.original_text"), + lines=10, placeholder="Dán nội dung cần viết lại..." + ) + + preset_choices = list(get_preset_templates().keys()) + rewrite_style = gr.Dropdown( + choices=preset_choices, + label=t("rewrite.preset_style"), + value=preset_choices[0] if preset_choices else None, + interactive=True + ) + + rewrite_btn = gr.Button(t("rewrite.start_rewrite"), variant="primary") + rewrite_status = gr.Textbox(label=t("rewrite.parse_status"), interactive=False) + rewrite_output = gr.Textbox(label=t("rewrite.full_rewritten"), lines=15, interactive=True) + + def on_file_upload(file): + if file is None: + return "" + try: + content = parse_novel_file(file.name) + return content + except Exception as e: + return f"❌ {str(e)}" + + def on_rewrite(text, style): + yield "⏳ Đang gọi AI xử lý... Vui lòng chờ.", gr.update(), gr.update(interactive=False) + instructions = get_preset_templates().get(style, "") + gen = app_state.get_generator() + content, msg = gen.rewrite_paragraph(text, instructions) + if content: + yield f"✅ {msg}", content, gr.update(interactive=True) + else: + yield f"❌ {msg}", gr.update(), gr.update(interactive=True) + + rewrite_file_input.change(fn=on_file_upload, inputs=[rewrite_file_input], outputs=[rewrite_input]) + rewrite_btn.click(fn=on_rewrite, inputs=[rewrite_input, rewrite_style], outputs=[rewrite_status, rewrite_output, rewrite_btn]) diff --git a/ui/settings_tab.py b/ui/settings_tab.py new file mode 100644 index 0000000..77c92e9 --- /dev/null +++ b/ui/settings_tab.py @@ -0,0 +1,298 @@ +import gradio as gr +from locales.i18n import t +from core.config import get_config, API_PROVIDERS +from core.config_api import ConfigAPIManager +from services.api_client import get_api_client, reinit_api_client +from services.novel_generator import get_cache_size, list_generation_caches, clear_generation_cache +from services.genre_manager import GenreManager +from services.sub_genre_manager import SubGenreManager +from core.state import app_state + +def build_settings_tab(): + with gr.Tab(t("tabs.settings")): + gr.Markdown(f"### {t('settings.header')}") + + with gr.Tabs(): + # Sub-tab: Quản lý giao diện API + with gr.Tab(t("settings.tab_backends")): + gr.Markdown(f"#### {t('settings.backends_header')}") + + backends_display = gr.Markdown("...") + + def get_backends_display(): + try: + result = ConfigAPIManager.list_backends() + if result["success"] and result["data"]: + lines = [f"### 📋 {len(result['data'])} backend(s)\n"] + for b in result["data"]: + enabled = "✅" if b.get("enabled") else "❌" + default = " ⭐" if b.get("is_default") else "" + lines.append(f"{enabled} **{b['name']}**{default}") + lines.append(f" - Model: `{b.get('model', '')}`") + lines.append(f" - URL: `{b.get('base_url', '')[:50]}...`") + lines.append(f" - Timeout: {b.get('timeout', 30)}s") + lines.append("") + return "\n".join(lines) + return t("app.no_backends_warning") + except Exception as e: + return f"❌ {str(e)}" + + gr.Markdown("---") + gr.Markdown(f"#### {t('settings.add_backend_header')}") + + provider_names = [API_PROVIDERS[k]["name"] for k in API_PROVIDERS] + + with gr.Row(): + api_provider_dropdown = gr.Dropdown( + choices=provider_names, + label=t("settings.provider_label"), + info=t("settings.provider_info"), + interactive=True + ) + + with gr.Row(): + api_name_input = gr.Textbox(label=t("settings.backend_name"), placeholder=t("settings.backend_name_placeholder")) + api_type_dropdown = gr.Dropdown( + choices=ConfigAPIManager.get_backend_types(), + value="openai", label=t("settings.backend_type"), interactive=True + ) + + with gr.Row(): + api_url_input = gr.Textbox(label=t("settings.base_url"), placeholder=t("settings.base_url_placeholder")) + api_key_input = gr.Textbox(label=t("settings.api_key"), placeholder=t("settings.api_key_placeholder"), type="password") + + with gr.Row(): + api_model_input = gr.Textbox(label=t("settings.model_name"), placeholder=t("settings.model_name_placeholder")) + api_timeout_input = gr.Slider(minimum=5, maximum=600, value=120, step=5, label=t("settings.timeout")) + + with gr.Row(): + api_save_btn = gr.Button(t("settings.add_btn"), variant="primary") + api_refresh_btn = gr.Button(t("settings.refresh_list"), size="sm") + + api_status = gr.Textbox(label=t("settings.operation_result"), interactive=False) + + gr.Markdown("---") + gr.Markdown(f"#### {t('settings.test_manage_header')}") + + with gr.Row(): + test_name_input = gr.Textbox(label=t("settings.test_backend_name"), placeholder=t("settings.test_backend_placeholder")) + api_test_btn = gr.Button(t("settings.test_btn"), variant="secondary") + + with gr.Row(): + delete_name_input = gr.Textbox(label=t("settings.delete_backend_name"), placeholder=t("settings.delete_backend_placeholder")) + api_delete_btn = gr.Button(t("settings.delete_btn"), variant="stop") + + test_result = gr.Textbox(label=t("settings.test_result"), interactive=False) + + def on_provider_select(provider_name): + for key, info in API_PROVIDERS.items(): + if info["name"] == provider_name: + return info.get("base_url", ""), info.get("default_model", ""), provider_name + return "", "", provider_name + + def on_api_test(name): + yield "⏳ Đang kiểm tra kết nối API... Vui lòng chờ." + result = ConfigAPIManager.test_backend(name) + yield result["message"] + + def on_api_save(name, btype, url, key, model, timeout): + result = ConfigAPIManager.add_backend(name, btype, url, key, model, int(timeout)) + reinit_api_client() + app_state.generator = None + return result["message"], get_backends_display() + + def on_api_delete(name): + result = ConfigAPIManager.delete_backend(name) + reinit_api_client() + app_state.generator = None + return result["message"], get_backends_display() + + api_provider_dropdown.change( + fn=on_provider_select, + inputs=[api_provider_dropdown], + outputs=[api_url_input, api_model_input, api_name_input] + ) + api_save_btn.click( + fn=on_api_save, + inputs=[api_name_input, api_type_dropdown, api_url_input, api_key_input, api_model_input, api_timeout_input], + outputs=[api_status, backends_display] + ) + api_test_btn.click(fn=on_api_test, inputs=[test_name_input], outputs=[test_result]) + api_delete_btn.click(fn=on_api_delete, inputs=[delete_name_input], outputs=[api_status, backends_display]) + api_refresh_btn.click(fn=lambda: get_backends_display(), outputs=[backends_display]) + + # Sub-tab: Tham số sinh + with gr.Tab(t("settings.tab_params")): + gr.Markdown(f"#### {t('settings.params_header')}") + + config = get_config() + gen_config = config.generation + + param_temperature = gr.Slider(minimum=0.1, maximum=2.0, value=gen_config.temperature, step=0.1, label=t("settings.temperature_label"), info=t("settings.temperature_info")) + param_top_p = gr.Slider(minimum=0.1, maximum=1.0, value=gen_config.top_p, step=0.05, label="Top P", info=t("settings.top_p_info")) + param_max_tokens = gr.Slider(minimum=100, maximum=100000, value=gen_config.max_tokens, step=100, label="Max Tokens", info=t("settings.max_tokens_info")) + param_chapter_words = gr.Slider(minimum=500, maximum=65536, value=gen_config.chapter_target_words, step=500, label=t("settings.chapter_target_words")) + + save_params_btn = gr.Button(t("settings.save_params_btn"), variant="primary") + params_status = gr.Textbox(label=t("settings.save_status"), interactive=False) + + def on_save_params(temp, top_p, max_tokens, chapter_words): + cfg = get_config() + success, msg = cfg.update_generation_config( + temperature=temp, top_p=top_p, + max_tokens=int(max_tokens), + chapter_target_words=int(chapter_words) + ) + if success: + app_state.generator = None + return f"✅ {msg}" + return f"❌ {msg}" + + save_params_btn.click( + fn=on_save_params, + inputs=[param_temperature, param_top_p, param_max_tokens, param_chapter_words], + outputs=[params_status] + ) + + # Sub-tab: Bộ nhớ đệm + with gr.Tab(t("settings.tab_cache")): + gr.Markdown(f"#### {t('settings.cache_header')}") + + cache_info_display = gr.Markdown(t("ui.no_cache")) + + def get_cache_info(): + try: + api_client = get_api_client() + stats = api_client.get_cache_stats() + gen_caches = list_generation_caches() + gen_size_val = get_cache_size() + + return f"### Thống kê bộ nhớ đệm\n- **API Cache**: {stats['total_entries']}/{stats['max_size']} ({stats['usage_rate']:.1f}%)\n- **Generation Cache**: {len(gen_caches)} files ({gen_size_val / 1024:.1f} KB)" + except Exception as e: + return f"❌ {str(e)}" + + with gr.Row(): + refresh_cache_btn = gr.Button(t("settings.refresh_cache"), size="sm") + clear_all_cache_btn = gr.Button(t("settings.clear_all"), variant="stop") + + cache_op_status = gr.Textbox(label=t("settings.cache_op_status"), interactive=False) + + def on_clear_all_cache(): + try: + api_client = get_api_client() + api_client.clear_cache() + clear_generation_cache() + return "✅ Đã xóa tất cả bộ nhớ đệm", get_cache_info() + except Exception as e: + return f"❌ {str(e)}", get_cache_info() + + refresh_cache_btn.click(fn=get_cache_info, outputs=[cache_info_display]) + clear_all_cache_btn.click(fn=on_clear_all_cache, outputs=[cache_op_status, cache_info_display]) + + # Sub-tab: Quản lý thể loại + with gr.Tab(t("settings.tab_genre")): + gr.Markdown(f"#### {t('settings.genre_desc')}") + + genre_select = gr.Dropdown( + choices=GenreManager.get_genre_names(), + label=t("settings.genre_select"), + interactive=True + ) + genre_name_input = gr.Textbox(label=t("settings.genre_name"), placeholder=t("settings.genre_name_placeholder")) + genre_desc_input = gr.Textbox(label=t("settings.genre_description"), placeholder=t("settings.genre_description_placeholder"), lines=3) + + with gr.Row(): + genre_add_btn = gr.Button(t("settings.genre_add_btn"), variant="primary") + genre_update_btn = gr.Button(t("settings.genre_update_btn"), variant="secondary") + genre_delete_btn = gr.Button(t("settings.genre_delete_btn"), variant="stop") + + genre_op_status = gr.Textbox(label=t("settings.genre_op_status"), interactive=False) + + def on_genre_select(name): + if name: + desc = GenreManager.get_genre_description(name) + return name, desc or "" + return "", "" + + def on_genre_add(name, desc): + if not name.strip(): + return t("settings.genre_err_name_empty"), gr.update() + success = GenreManager.add_genre(name.strip(), desc.strip()) + if success: + return t("settings.genre_add_success"), gr.update(choices=GenreManager.get_genre_names()) + return t("settings.genre_err_exists"), gr.update() + + def on_genre_update(old_name, new_name, desc): + if not old_name: + return t("settings.genre_err_none_selected"), gr.update() + success = GenreManager.update_genre(old_name, new_name.strip(), desc.strip()) + if success: + return t("settings.genre_update_success"), gr.update(choices=GenreManager.get_genre_names()) + return t("settings.genre_err_update"), gr.update() + + def on_genre_delete(name): + if not name: + return t("settings.genre_err_none_selected"), gr.update() + success = GenreManager.delete_genre(name) + if success: + return t("settings.genre_delete_success"), gr.update(choices=GenreManager.get_genre_names()) + return t("settings.genre_err_delete"), gr.update() + + genre_select.change(fn=on_genre_select, inputs=[genre_select], outputs=[genre_name_input, genre_desc_input]) + genre_add_btn.click(fn=on_genre_add, inputs=[genre_name_input, genre_desc_input], outputs=[genre_op_status, genre_select]) + genre_update_btn.click(fn=on_genre_update, inputs=[genre_select, genre_name_input, genre_desc_input], outputs=[genre_op_status, genre_select]) + genre_delete_btn.click(fn=on_genre_delete, inputs=[genre_select], outputs=[genre_op_status, genre_select]) + + # Sub-tab: Quản lý chủ đề con + with gr.Tab(t("settings.tab_sub_genre")): + gr.Markdown(f"#### {t('settings.sub_genre_desc')}") + + sub_genre_select = gr.Dropdown( + choices=SubGenreManager.get_sub_genre_names(), + label=t("settings.sub_genre_select"), + interactive=True + ) + sub_genre_name_input = gr.Textbox(label=t("settings.sub_genre_name"), placeholder=t("settings.sub_genre_name_placeholder")) + sub_genre_desc_input = gr.Textbox(label=t("settings.sub_genre_description"), placeholder=t("settings.sub_genre_description_placeholder"), lines=3) + + with gr.Row(): + sub_genre_add_btn = gr.Button(t("settings.sub_genre_add_btn"), variant="primary") + sub_genre_update_btn = gr.Button(t("settings.sub_genre_update_btn"), variant="secondary") + sub_genre_delete_btn = gr.Button(t("settings.sub_genre_delete_btn"), variant="stop") + + sub_genre_op_status = gr.Textbox(label=t("settings.sub_genre_op_status"), interactive=False) + + def on_sub_genre_select(name): + if name: + desc = SubGenreManager.get_sub_genre_description(name) + return name, desc or "" + return "", "" + + def on_sub_genre_add(name, desc): + if not name.strip(): + return t("settings.sub_genre_err_name_empty"), gr.update() + success = SubGenreManager.add_sub_genre(name.strip(), desc.strip()) + if success: + return t("settings.sub_genre_add_success"), gr.update(choices=SubGenreManager.get_sub_genre_names()) + return t("settings.sub_genre_err_exists"), gr.update() + + def on_sub_genre_update(old_name, new_name, desc): + if not old_name: + return t("settings.sub_genre_err_none_selected"), gr.update() + success = SubGenreManager.update_sub_genre(old_name, new_name.strip(), desc.strip()) + if success: + return t("settings.sub_genre_update_success"), gr.update(choices=SubGenreManager.get_sub_genre_names()) + return t("settings.sub_genre_err_update"), gr.update() + + def on_sub_genre_delete(name): + if not name: + return t("settings.sub_genre_err_none_selected"), gr.update() + success = SubGenreManager.delete_sub_genre(name) + if success: + return t("settings.sub_genre_delete_success"), gr.update(choices=SubGenreManager.get_sub_genre_names()) + return t("settings.sub_genre_err_delete"), gr.update() + + sub_genre_select.change(fn=on_sub_genre_select, inputs=[sub_genre_select], outputs=[sub_genre_name_input, sub_genre_desc_input]) + sub_genre_add_btn.click(fn=on_sub_genre_add, inputs=[sub_genre_name_input, sub_genre_desc_input], outputs=[sub_genre_op_status, sub_genre_select]) + sub_genre_update_btn.click(fn=on_sub_genre_update, inputs=[sub_genre_select, sub_genre_name_input, sub_genre_desc_input], outputs=[sub_genre_op_status, sub_genre_select]) + sub_genre_delete_btn.click(fn=on_sub_genre_delete, inputs=[sub_genre_select], outputs=[sub_genre_op_status, sub_genre_select]) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exporter.py b/utils/exporter.py similarity index 100% rename from exporter.py rename to utils/exporter.py diff --git a/file_parser.py b/utils/file_parser.py similarity index 100% rename from file_parser.py rename to utils/file_parser.py