refactor: Restructure project into core, services, ui, and utils directories, moving existing modules and adding new UI tabs.

This commit is contained in:
tinix-ai
2026-03-17 14:54:16 +07:00
parent 6699c516cd
commit a40d8ef4a6
28 changed files with 1970 additions and 953 deletions
+29 -933
View File
@@ -16,23 +16,23 @@ from datetime import datetime
import os import os
# Import các module hiện có # Import các module hiện có
from api_client import APIClient, get_api_client, reinit_api_client from services.api_client import APIClient, get_api_client, reinit_api_client
from config import get_config, ConfigManager, Backend, GenerationConfig, API_PROVIDERS from core.config import get_config, ConfigManager, Backend, GenerationConfig, API_PROVIDERS
from novel_generator import ( from services.novel_generator import (
NovelGenerator, NovelProject, Chapter, OutlineParser, NovelGenerator, NovelProject, Chapter, OutlineParser,
get_preset_templates, get_generator, get_preset_templates, get_generator,
save_generation_cache, load_generation_cache, clear_generation_cache, save_generation_cache, load_generation_cache, clear_generation_cache,
list_generation_caches, get_cache_size list_generation_caches, get_cache_size
) )
from project_manager import ProjectManager from services.project_manager import ProjectManager
from config_api import ConfigAPIManager from core.config_api import ConfigAPIManager
from exporter import export_to_docx, export_to_txt, export_to_markdown, export_to_html from utils.exporter import export_to_docx, export_to_txt, export_to_markdown, export_to_html
from genre_manager import GenreManager from services.genre_manager import GenreManager
from sub_genre_manager import SubGenreManager from services.sub_genre_manager import SubGenreManager
from file_parser import parse_novel_file from utils.file_parser import parse_novel_file
from locales.i18n import t from locales.i18n import t
from database import get_db from core.database import get_db
from logger import get_logger from core.logger import get_logger
# ==================== Cấu hình Logging ==================== # ==================== 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") WEB_SHARE = os.getenv("NOVEL_TOOL_SHARE", "false").lower() in ("1", "true", "yes")
# ==================== Quản lý trạng thái ứng dụng ==================== from core.state import app_state
from services.project_manager import ProjectManager, list_project_titles
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 []
# ==================== Giao diện chính ==================== # ==================== Giao diện chính ====================
@@ -103,895 +73,21 @@ def create_main_ui():
""") """)
with gr.Tabs(): with gr.Tabs():
# ==================== Tab 1: Sáng tác từ đầu ==================== from ui.create_tab import build_create_tab
with gr.Tab(t("tabs.create")): from ui.continue_tab import build_continue_tab
gr.Markdown(f"### {t('create.header')}") from ui.rewrite_tab import build_rewrite_tab
from ui.polish_tab import build_polish_tab
with gr.Row(): from ui.export_tab import build_export_tab
with gr.Column(scale=1): from ui.projects_tab import build_projects_tab
genre_choices = GenreManager.get_genre_names() from ui.settings_tab import build_settings_tab
genre_dropdown = gr.Dropdown(
choices=genre_choices, build_create_tab()
value=genre_choices[0] if genre_choices else None, build_continue_tab()
label=t("create.genre_label"), build_rewrite_tab()
interactive=True build_polish_tab()
) build_export_tab()
sub_genre_choices = SubGenreManager.get_sub_genre_names() build_projects_tab()
sub_genre_dropdown = gr.Dropdown( build_settings_tab()
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])
# Footer # Footer
gr.Markdown(""" gr.Markdown("""
@@ -1023,7 +119,7 @@ def main():
# Khởi động # Khởi động
logger.info(t("app.gradio_start", port=WEB_PORT)) logger.info(t("app.gradio_start", port=WEB_PORT))
app.launch( app.queue(default_concurrency_limit=10).launch(
server_name=WEB_HOST, server_name=WEB_HOST,
server_port=WEB_PORT, server_port=WEB_PORT,
share=WEB_SHARE, share=WEB_SHARE,
View File
+2 -2
View File
@@ -12,7 +12,7 @@ from datetime import datetime
import hashlib import hashlib
import logging import logging
from locales.i18n import t from locales.i18n import t
from database import get_db from core.database import get_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -341,7 +341,7 @@ class ConfigManager:
self.last_modified = mod_row["value"] self.last_modified = mod_row["value"]
if rows or gen_row: if rows or gen_row:
logger.info("Config loaded from database") logger.info("Config loaded from core.database")
else: else:
logger.info("No config in database, using defaults") logger.info("No config in database, using defaults")
self._init_default() self._init_default()
+3 -3
View File
@@ -8,9 +8,9 @@ Tác giả: Huyễn Thành
import json import json
from typing import Dict, List, Tuple, Any from typing import Dict, List, Tuple, Any
from dataclasses import asdict from dataclasses import asdict
from config import Backend, get_config from core.config import Backend, get_config
from api_client import get_api_client, reinit_api_client from services.api_client import get_api_client, reinit_api_client
from logger import get_logger from core.logger import get_logger
from locales.i18n import t from locales.i18n import t
logger = get_logger("ConfigAPI") logger = get_logger("ConfigAPI")
View File
View File
+21
View File
@@ -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()
Binary file not shown.
Binary file not shown.
View File
+2 -2
View File
@@ -17,9 +17,9 @@ import logging
from openai import OpenAI, RateLimitError, APIError, AuthenticationError, APIConnectionError from openai import OpenAI, RateLimitError, APIError, AuthenticationError, APIConnectionError
import pickle import pickle
from config import get_config, Backend from core.config import get_config, Backend
from locales.i18n import t from locales.i18n import t
from database import get_db from core.database import get_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+150
View File
@@ -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."
}
]
+402
View File
@@ -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": ""
}
]
@@ -12,10 +12,10 @@ from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from api_client import get_api_client from services.api_client import get_api_client
from config import get_config from core.config import get_config
from locales.i18n import t from locales.i18n import t
from database import get_db from core.database import get_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -99,9 +99,9 @@ class OutlineParser:
chapter_count = 0 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 # 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 = [ 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'^(\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: for line in lines:
@@ -175,8 +175,8 @@ class OutlineParser:
return "\n".join(lines) return "\n".join(lines)
from genre_manager import GenreManager from services.genre_manager import GenreManager
from sub_genre_manager import SubGenreManager from services.sub_genre_manager import SubGenreManager
class NovelGenerator: class NovelGenerator:
"""máy phát điện mới""" """máy phát điện mới"""
@@ -442,6 +442,9 @@ class NovelGenerator:
context_prompt=context_prompt 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") sys_prompt = t("prompts.chapter_system")
messages = [ messages = [
@@ -522,6 +525,9 @@ class NovelGenerator:
context_prompt=context_prompt 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 = [ messages = [
{"role": "system", "content": t("prompts.chapter_system")}, {"role": "system", "content": t("prompts.chapter_system")},
{"role": "user", "content": prompt} {"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") return None, t("generator.cache_not_found")
cache_data = json.loads(row["data"]) 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") return cache_data, t("generator.cache_load_success")
except Exception as e: except Exception as e:
logger.error(f"Cache load failed: {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") 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] 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)) return summaries, t("generator.summary_load_done", count=len(summaries))
except Exception as e: except Exception as e:
logger.error(f"Load chapter summaries failed: {e}") logger.error(f"Load chapter summaries failed: {e}")
@@ -13,9 +13,9 @@ from typing import List, Dict, Optional, Tuple
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from novel_generator import NovelProject, Chapter from services.novel_generator import NovelProject, Chapter
from locales.i18n import t from locales.i18n import t
from database import get_db from core.database import get_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -194,7 +194,7 @@ class ProjectManager:
) )
project.chapters.append(chapter) 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") return project, t("project_manager.load_success")
except Exception as e: except Exception as e:
@@ -271,7 +271,7 @@ class ProjectManager:
if cursor.rowcount == 0: if cursor.rowcount == 0:
return False, t("project_manager.delete_not_found", id=project_id) 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") return True, t("project_manager.delete_success")
except Exception as e: except Exception as e:
@@ -329,3 +329,11 @@ class ProjectManager:
def get_project_manager() -> ProjectManager: def get_project_manager() -> ProjectManager:
"""Nhận phiên bản quản lý dự án""" """Nhận phiên bản quản lý dự án"""
return ProjectManager() 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 []
View File
+362
View File
@@ -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]
)
+400
View File
@@ -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]
)
+92
View File
@@ -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])
+61
View File
@@ -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])
+69
View File
@@ -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]
)
+52
View File
@@ -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])
+298
View File
@@ -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])
View File
View File