Files
AI_NovelGenerator/ui.py
T
YILING0013 0be413236b 尝试使用更兼容的办法实现#109
使用了更具有扩展性的实现方法;但存在一个小问题:

新保存的配置不会立马生效,需要手动点击读取配置;

下次程序启动时会自动切换配置,无需手动点击读取;
2025-02-16 20:23:42 +08:00

1824 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ui.py
# -*- coding: utf-8 -*-
import logging
import os
import threading
import customtkinter as ctk
from tkinter import filedialog, messagebox
import tkinter as tk
import traceback
from config_manager import load_config, save_config, test_llm_config, test_embedding_config
from utils import read_file, save_string_to_txt, clear_file_content
from novel_generator import (
Novel_architecture_generate,
Chapter_blueprint_generate,
generate_chapter_draft,
finalize_chapter,
import_knowledge_file,
clear_vector_store,
get_last_n_chapters_text,
enrich_chapter_text
)
from consistency_checker import check_consistency
# ---- Import the tooltip texts ----
from tooltips import tooltips
# ----------------- 右键菜单功能封装 -----------------
class TextWidgetContextMenu:
"""
为 customtkinter.TextBox 或 tkinter.Text 提供右键复制/剪切/粘贴/全选的功能。
"""
def __init__(self, widget):
self.widget = widget
self.menu = tk.Menu(widget, tearoff=0)
self.menu.add_command(label="复制", command=self.copy)
self.menu.add_command(label="粘贴", command=self.paste)
self.menu.add_command(label="剪切", command=self.cut)
self.menu.add_separator()
self.menu.add_command(label="全选", command=self.select_all)
# 绑定右键事件
self.widget.bind("<Button-3>", self.show_menu)
def show_menu(self, event):
if isinstance(self.widget, ctk.CTkTextbox):
try:
self.menu.tk_popup(event.x_root, event.y_root)
finally:
self.menu.grab_release()
def copy(self):
try:
text = self.widget.get("sel.first", "sel.last")
self.widget.clipboard_clear()
self.widget.clipboard_append(text)
except tk.TclError:
pass # 没有选中文本时忽略错误
def paste(self):
try:
text = self.widget.clipboard_get()
self.widget.insert("insert", text)
except tk.TclError:
pass # 剪贴板为空时忽略错误
def cut(self):
try:
text = self.widget.get("sel.first", "sel.last")
self.widget.delete("sel.first", "sel.last")
self.widget.clipboard_clear()
self.widget.clipboard_append(text)
except tk.TclError:
pass # 没有选中文本时忽略错误
def select_all(self):
self.widget.tag_add("sel", "1.0", "end")
def log_error(message: str):
logging.error(f"{message}\n{traceback.format_exc()}")
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
class NovelGeneratorGUI:
"""
小说生成器的主GUI类,包含所有的界面布局、事件处理、与后端逻辑的交互等。
"""
def __init__(self, master):
self.master = master
self.master.title("Novel Generator GUI")
# 设置窗口图标
try:
if os.path.exists("icon.ico"):
self.master.iconbitmap("icon.ico")
except Exception:
pass
self.master.geometry("1350x840")
# --------------- 配置文件路径 ---------------
self.config_file = "config.json"
self.loaded_config = load_config(self.config_file)
# 根据配置加载上一次使用的接口类型及对应配置
if self.loaded_config:
last_llm = self.loaded_config.get("last_interface_format", "OpenAI")
last_embedding = self.loaded_config.get("last_embedding_interface_format", "OpenAI")
else:
last_llm = "OpenAI"
last_embedding = "OpenAI"
if self.loaded_config and "llm_configs" in self.loaded_config and last_llm in self.loaded_config["llm_configs"]:
llm_conf = self.loaded_config["llm_configs"][last_llm]
else:
llm_conf = {
"api_key": "",
"base_url": "https://api.openai.com/v1",
"model_name": "gpt-4o-mini",
"temperature": 0.7,
"max_tokens": 8192,
"timeout": 600
}
if self.loaded_config and "embedding_configs" in self.loaded_config and last_embedding in self.loaded_config["embedding_configs"]:
emb_conf = self.loaded_config["embedding_configs"][last_embedding]
else:
emb_conf = {
"api_key": "",
"base_url": "https://api.openai.com/v1",
"model_name": "text-embedding-ada-002",
"retrieval_k": 4
}
# --------------- 主要属性变量 ---------------
# -- LLM通用参数 --
self.api_key_var = ctk.StringVar(value=llm_conf.get("api_key", ""))
self.base_url_var = ctk.StringVar(value=llm_conf.get("base_url", "https://api.openai.com/v1"))
self.interface_format_var = ctk.StringVar(value=last_llm)
self.model_name_var = ctk.StringVar(value=llm_conf.get("model_name", "gpt-4o-mini"))
self.temperature_var = ctk.DoubleVar(value=llm_conf.get("temperature", 0.7))
self.max_tokens_var = ctk.IntVar(value=llm_conf.get("max_tokens", 8192))
self.timeout_var = ctk.IntVar(value=llm_conf.get("timeout", 600))
# -- Embedding相关 --
self.embedding_interface_format_var = ctk.StringVar(value=last_embedding)
self.embedding_api_key_var = ctk.StringVar(value=emb_conf.get("api_key", ""))
self.embedding_url_var = ctk.StringVar(value=emb_conf.get("base_url", "https://api.openai.com/v1"))
self.embedding_model_name_var = ctk.StringVar(value=emb_conf.get("model_name", "text-embedding-ada-002"))
self.embedding_retrieval_k_var = ctk.StringVar(value=str(emb_conf.get("retrieval_k", 4)))
# -- 小说参数相关 --
if self.loaded_config and "other_params" in self.loaded_config:
op = self.loaded_config["other_params"]
self.topic_default = op.get("topic", "")
self.genre_var = ctk.StringVar(value=op.get("genre", "玄幻"))
self.num_chapters_var = ctk.StringVar(value=str(op.get("num_chapters", 10)))
self.word_number_var = ctk.StringVar(value=str(op.get("word_number", 3000)))
self.filepath_var = ctk.StringVar(value=op.get("filepath", ""))
self.chapter_num_var = ctk.StringVar(value=str(op.get("chapter_num", "1")))
self.characters_involved_var = ctk.StringVar(value=op.get("characters_involved", ""))
self.key_items_var = ctk.StringVar(value=op.get("key_items", ""))
self.scene_location_var = ctk.StringVar(value=op.get("scene_location", ""))
self.time_constraint_var = ctk.StringVar(value=op.get("time_constraint", ""))
self.user_guidance_default = op.get("user_guidance", "")
else:
self.topic_default = ""
self.genre_var = ctk.StringVar(value="玄幻")
self.num_chapters_var = ctk.StringVar(value="10")
self.word_number_var = ctk.StringVar(value="3000")
self.filepath_var = ctk.StringVar(value="")
self.chapter_num_var = ctk.StringVar(value="1")
self.characters_involved_var = ctk.StringVar(value="")
self.key_items_var = ctk.StringVar(value="")
self.scene_location_var = ctk.StringVar(value="")
self.time_constraint_var = ctk.StringVar(value="")
self.user_guidance_default = ""
# --------------- 整体Tab布局 ---------------
self.tabview = ctk.CTkTabview(self.master)
self.tabview.pack(fill="both", expand=True)
# 创建各个标签页
self.main_tab = self.tabview.add("Main Functions")
self.setting_tab = self.tabview.add("Novel Architecture")
self.directory_tab = self.tabview.add("Chapter Blueprint")
self.character_tab = self.tabview.add("Character State")
self.summary_tab = self.tabview.add("Global Summary")
self.chapters_view_tab = self.tabview.add("Chapters Manage")
# 构建各个Tab的UI
self.build_main_tab()
self.build_setting_tab()
self.build_directory_tab()
self.build_character_tab()
self.build_summary_tab()
self.build_chapters_tab()
# ----------------- 通用辅助函数 -----------------
def show_tooltip(self, key: str):
"""显示自定义的tooltip文本。"""
info_text = tooltips.get(key, "暂无说明")
messagebox.showinfo("参数说明", info_text)
def safe_get_int(self, var, default=1):
"""从CTk的StringVar中安全地读取整数,若失败则返回default并重置变量值。"""
try:
val_str = str(var.get()).strip()
return int(val_str)
except:
var.set(str(default))
return default
def log(self, message: str):
"""在左侧日志框输出信息。"""
self.log_text.configure(state="normal")
self.log_text.insert("end", message + "\n")
self.log_text.see("end")
self.log_text.configure(state="disabled")
def safe_log(self, message: str):
"""线程安全地在日志框输出信息。"""
self.master.after(0, lambda: self.log(message))
def disable_button_safe(self, btn):
"""线程安全地禁用按钮。"""
self.master.after(0, lambda: btn.configure(state="disabled"))
def enable_button_safe(self, btn):
"""线程安全地启用按钮。"""
self.master.after(0, lambda: btn.configure(state="normal"))
def handle_exception(self, context: str):
"""在出现异常时,记录日志并输出到日志框。"""
full_message = f"{context}\n{traceback.format_exc()}"
logging.error(full_message)
self.safe_log(full_message)
# ----------------- 主Tab布局 -----------------
def build_main_tab(self):
"""
主Tab包含左侧的"本章内容"编辑框和输出日志,以及右侧的主要操作和参数设置区
"""
self.main_tab.rowconfigure(0, weight=1)
self.main_tab.columnconfigure(0, weight=1)
self.main_tab.columnconfigure(1, weight=0)
self.left_frame = ctk.CTkFrame(self.main_tab)
self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2)
self.right_frame = ctk.CTkFrame(self.main_tab)
self.right_frame.grid(row=0, column=1, sticky="nsew", padx=2, pady=2)
self.build_left_layout()
self.build_right_layout()
def build_left_layout(self):
"""
左侧区域:本章内容(可编辑) + Step流程按钮 + 输出日志(只读)
"""
self.left_frame.grid_rowconfigure(0, weight=0)
self.left_frame.grid_rowconfigure(1, weight=2)
self.left_frame.grid_rowconfigure(2, weight=0)
self.left_frame.grid_rowconfigure(3, weight=0)
self.left_frame.grid_rowconfigure(4, weight=1)
self.left_frame.columnconfigure(0, weight=1)
chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容 (可编辑)", font=("Microsoft YaHei", 12))
chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
# 章节文本编辑框
self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14))
TextWidgetContextMenu(self.chapter_result)
self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5))
# Step 按钮区域
self.step_buttons_frame = ctk.CTkFrame(self.left_frame)
self.step_buttons_frame.grid(row=2, column=0, sticky="ew", padx=5, pady=5)
self.step_buttons_frame.columnconfigure((0, 1, 2, 3), weight=1)
self.btn_generate_architecture = ctk.CTkButton(
self.step_buttons_frame,
text="Step1. 生成架构",
command=self.generate_novel_architecture_ui,
font=("Microsoft YaHei", 12)
)
self.btn_generate_architecture.grid(row=0, column=0, padx=5, pady=2, sticky="ew")
self.btn_generate_directory = ctk.CTkButton(
self.step_buttons_frame,
text="Step2. 生成目录",
command=self.generate_chapter_blueprint_ui,
font=("Microsoft YaHei", 12)
)
self.btn_generate_directory.grid(row=0, column=1, padx=5, pady=2, sticky="ew")
self.btn_generate_chapter = ctk.CTkButton(
self.step_buttons_frame,
text="Step3. 生成草稿",
command=self.generate_chapter_draft_ui,
font=("Microsoft YaHei", 12)
)
self.btn_generate_chapter.grid(row=0, column=2, padx=5, pady=2, sticky="ew")
self.btn_finalize_chapter = ctk.CTkButton(
self.step_buttons_frame,
text="Step4. 定稿章节",
command=self.finalize_chapter_ui,
font=("Microsoft YaHei", 12)
)
self.btn_finalize_chapter.grid(row=0, column=3, padx=5, pady=2, sticky="ew")
# 日志文本框
log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 12))
log_label.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w")
self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12))
TextWidgetContextMenu(self.log_text)
self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5))
self.log_text.configure(state="disabled")
def build_right_layout(self):
"""
右侧区域:配置区(tabview) + 小说主参数 + 可选功能按钮
"""
self.right_frame.grid_rowconfigure(0, weight=0)
self.right_frame.grid_rowconfigure(1, weight=1)
self.right_frame.grid_rowconfigure(2, weight=0)
self.right_frame.columnconfigure(0, weight=1)
# 配置区(AI/Embedding
self.config_frame = ctk.CTkFrame(self.right_frame, corner_radius=10, border_width=2, border_color="gray")
self.config_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
self.config_frame.columnconfigure(0, weight=1)
self.build_config_tabview()
# 小说参数(主题、类型、章节数、字数、保存路径、可选元素等)
self.build_novel_params_area(start_row=1)
# 可选功能按钮(一致性审校、导入知识库、清空向量库、查看剧情要点)
self.build_optional_buttons_area(start_row=2)
# ----------------- 配置TabView -----------------
def build_config_tabview(self):
"""
创建包含 LLM Model settings 和 Embedding settings 的选项卡。
"""
self.config_tabview = ctk.CTkTabview(self.config_frame)
self.config_tabview.grid(row=0, column=0, sticky="we", padx=5, pady=5)
self.ai_config_tab = self.config_tabview.add("LLM Model settings")
self.embeddings_config_tab = self.config_tabview.add("Embedding settings")
self.build_ai_config_tab()
self.build_embeddings_config_tab()
# 底部的"保存配置"和"加载配置"按钮
self.btn_frame_config = ctk.CTkFrame(self.config_frame)
self.btn_frame_config.grid(row=1, column=0, padx=5, pady=5, sticky="ew")
self.btn_frame_config.columnconfigure(0, weight=1)
self.btn_frame_config.columnconfigure(1, weight=1)
save_config_btn = ctk.CTkButton(self.btn_frame_config, text="保存当前选择接口配置到文件", command=self.save_config_btn, font=("Microsoft YaHei", 12))
save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="ew")
load_config_btn = ctk.CTkButton(self.btn_frame_config, text="加载当前选择接口配置到程序", command=self.load_config_btn, font=("Microsoft YaHei", 12))
load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
def create_label_with_help(self, parent, label_text, tooltip_key, row, column,
font=None, sticky="e", padx=5, pady=5):
"""
封装一个带"?"按钮的Label,用于展示提示信息。
"""
frame = ctk.CTkFrame(parent)
frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky)
frame.columnconfigure(0, weight=0)
label = ctk.CTkLabel(frame, text=label_text, font=font)
label.pack(side="left")
btn = ctk.CTkButton(
frame,
text="?",
width=22,
height=22,
font=("Microsoft YaHei", 10),
command=lambda: self.show_tooltip(tooltip_key)
)
btn.pack(side="left", padx=3)
return frame
# --------------- LLM 模型配置 ---------------
def build_ai_config_tab(self):
def on_interface_format_changed(new_value):
"""
当切换LLM接口格式时,自动设置base_url为对应的默认值,
并尝试加载该接口的已保存配置(若存在)。
"""
self.interface_format_var.set(new_value)
config_data = load_config(self.config_file)
if config_data:
config_data["last_interface_format"] = new_value
save_config(config_data, self.config_file)
# 尝试加载已保存的配置
if self.loaded_config and "llm_configs" in self.loaded_config and new_value in self.loaded_config["llm_configs"]:
llm_conf = self.loaded_config["llm_configs"][new_value]
self.api_key_var.set(llm_conf.get("api_key", ""))
self.base_url_var.set(llm_conf.get("base_url", self.base_url_var.get()))
self.model_name_var.set(llm_conf.get("model_name", ""))
self.temperature_var.set(llm_conf.get("temperature", 0.7))
self.max_tokens_var.set(llm_conf.get("max_tokens", 8192))
self.timeout_var.set(llm_conf.get("timeout", 600))
else:
match new_value:
case "Ollama":
self.base_url_var.set("http://localhost:11434/v1")
case "ML Studio":
self.base_url_var.set("http://localhost:1234/v1")
case "OpenAI":
self.base_url_var.set("https://api.openai.com/v1")
case "Azure OpenAI":
self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview")
case "DeepSeek":
self.base_url_var.set("https://api.deepseek.com/v1")
case "Gemini":
self.base_url_var.set("")
case "Azure AI":
self.base_url_var.set("https://<your-endpoint>.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview")
for i in range(7):
self.ai_config_tab.grid_rowconfigure(i, weight=0)
self.ai_config_tab.grid_columnconfigure(0, weight=0)
self.ai_config_tab.grid_columnconfigure(1, weight=1)
self.ai_config_tab.grid_columnconfigure(2, weight=0)
# 1) API Key
self.create_label_with_help(
parent=self.ai_config_tab,
label_text="LLM API Key:",
tooltip_key="api_key",
row=0,
column=0,
font=("Microsoft YaHei", 12)
)
api_key_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var, font=("Microsoft YaHei", 12))
api_key_entry.grid(row=0, column=1, padx=5, pady=5, columnspan=2, sticky="nsew")
# 2) Base URL
self.create_label_with_help(
parent=self.ai_config_tab,
label_text="LLM Base URL:",
tooltip_key="base_url",
row=1,
column=0,
font=("Microsoft YaHei", 12)
)
base_url_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.base_url_var, font=("Microsoft YaHei", 12))
base_url_entry.grid(row=1, column=1, padx=5, pady=5, columnspan=2, sticky="nsew")
# 3) 接口格式
label_frame = self.create_label_with_help(
parent=self.ai_config_tab,
label_text="LLM 接口格式:",
tooltip_key="interface_format",
row=2,
column=0,
font=("Microsoft YaHei", 12)
)
interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Azure AI", "Ollama", "ML Studio", "Gemini"]
interface_dropdown = ctk.CTkOptionMenu(
self.ai_config_tab,
values=interface_options,
variable=self.interface_format_var,
command=on_interface_format_changed,
font=("Microsoft YaHei", 12)
)
interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew")
# 4) Model Name
self.create_label_with_help(
parent=self.ai_config_tab,
label_text="Model Name:",
tooltip_key="model_name",
row=3,
column=0,
font=("Microsoft YaHei", 12)
)
model_name_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.model_name_var, font=("Microsoft YaHei", 12))
model_name_entry.grid(row=3, column=1, padx=5, pady=5, columnspan=2, sticky="nsew")
# 5) Temperature
temp_frame = self.create_label_with_help(
parent=self.ai_config_tab,
label_text="Temperature:",
tooltip_key="temperature",
row=4,
column=0,
font=("Microsoft YaHei", 12)
)
def update_temp_label(value):
self.temp_value_label.configure(text=f"{float(value):.2f}")
temp_scale = ctk.CTkSlider(
self.ai_config_tab,
from_=0.0, to=2.0,
number_of_steps=200,
command=update_temp_label,
variable=self.temperature_var
)
temp_scale.grid(row=4, column=1, padx=5, pady=5, sticky="we")
self.temp_value_label = ctk.CTkLabel(
self.ai_config_tab,
text=f"{self.temperature_var.get():.2f}",
font=("Microsoft YaHei", 12)
)
self.temp_value_label.grid(row=4, column=2, padx=5, pady=5, sticky="w")
# 6) Max Tokens
self.create_label_with_help(
parent=self.ai_config_tab,
label_text="Max Tokens:",
tooltip_key="max_tokens",
row=5,
column=0,
font=("Microsoft YaHei", 12)
)
def update_max_tokens_label(value):
self.max_tokens_value_label.configure(text=str(int(float(value))))
max_tokens_slider = ctk.CTkSlider(
self.ai_config_tab,
from_=0,
to=102400,
number_of_steps=100,
command=update_max_tokens_label,
variable=self.max_tokens_var
)
max_tokens_slider.grid(row=5, column=1, padx=5, pady=5, sticky="we")
self.max_tokens_value_label = ctk.CTkLabel(
self.ai_config_tab,
text=str(self.max_tokens_var.get()),
font=("Microsoft YaHei", 12)
)
self.max_tokens_value_label.grid(row=5, column=2, padx=5, pady=5, sticky="w")
# 7) Timeout (sec)
self.create_label_with_help(
parent=self.ai_config_tab,
label_text="Timeout (sec):",
tooltip_key="timeout",
row=6,
column=0,
font=("Microsoft YaHei", 12)
)
def update_timeout_label(value):
integer_val = int(float(value))
self.timeout_value_label.configure(text=str(integer_val))
timeout_slider = ctk.CTkSlider(
self.ai_config_tab,
from_=0,
to=3600,
number_of_steps=3600,
command=update_timeout_label,
variable=self.timeout_var
)
timeout_slider.grid(row=6, column=1, padx=5, pady=5, sticky="we")
self.timeout_value_label = ctk.CTkLabel(
self.ai_config_tab,
text=str(self.timeout_var.get()),
font=("Microsoft YaHei", 12)
)
self.timeout_value_label.grid(row=6, column=2, padx=5, pady=5, sticky="w")
# 添加测试按钮
test_btn = ctk.CTkButton(
self.ai_config_tab,
text="测试配置",
command=self.test_llm_config,
font=("Microsoft YaHei", 12)
)
test_btn.grid(row=7, column=0, columnspan=3, padx=5, pady=5, sticky="ew")
# --------------- Embedding 模型配置 ---------------
def build_embeddings_config_tab(self):
def on_embedding_interface_changed(new_value):
"""
当切换Embedding接口格式时,自动设置embedding_url为对应的默认值,
并尝试加载该接口的已保存配置(若存在)。
"""
self.embedding_interface_format_var.set(new_value)
# 自动更新配置文件中的 last_embedding_interface_format
config_data = load_config(self.config_file)
if config_data:
config_data["last_embedding_interface_format"] = new_value
save_config(config_data, self.config_file)
# 尝试加载已保存的配置
if self.loaded_config and "embedding_configs" in self.loaded_config and new_value in self.loaded_config["embedding_configs"]:
emb_conf = self.loaded_config["embedding_configs"][new_value]
self.embedding_api_key_var.set(emb_conf.get("api_key", ""))
self.embedding_url_var.set(emb_conf.get("base_url", self.embedding_url_var.get()))
self.embedding_model_name_var.set(emb_conf.get("model_name", ""))
self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4)))
else:
match new_value:
case "Ollama":
self.embedding_url_var.set("http://localhost:11434/api")
case "ML Studio":
self.embedding_url_var.set("http://localhost:1234/v1")
case "OpenAI":
self.embedding_url_var.set("https://api.openai.com/v1")
self.embedding_model_name_var.set("text-embedding-ada-002")
case "Azure OpenAI":
self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15")
case "DeepSeek":
self.embedding_url_var.set("https://api.deepseek.com/v1")
case "Gemini":
self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/")
self.embedding_model_name_var.set("models/text-embedding-004")
for i in range(5):
self.embeddings_config_tab.grid_rowconfigure(i, weight=0)
self.embeddings_config_tab.grid_columnconfigure(0, weight=0)
self.embeddings_config_tab.grid_columnconfigure(1, weight=1)
self.embeddings_config_tab.grid_columnconfigure(2, weight=0)
# 1) Embedding API Key
self.create_label_with_help(
parent=self.embeddings_config_tab,
label_text="Embedding API Key:",
tooltip_key="embedding_api_key",
row=0,
column=0,
font=("Microsoft YaHei", 12)
)
emb_api_key_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_api_key_var, font=("Microsoft YaHei", 12))
emb_api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew")
# 2) Embedding 接口格式
self.create_label_with_help(
parent=self.embeddings_config_tab,
label_text="Embedding 接口格式:",
tooltip_key="embedding_interface_format",
row=1,
column=0,
font=("Microsoft YaHei", 12)
)
emb_interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Gemini", "Ollama", "ML Studio"]
emb_interface_dropdown = ctk.CTkOptionMenu(
self.embeddings_config_tab,
values=emb_interface_options,
variable=self.embedding_interface_format_var,
command=on_embedding_interface_changed,
font=("Microsoft YaHei", 12)
)
emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew")
# 3) Embedding Base URL
self.create_label_with_help(
parent=self.embeddings_config_tab,
label_text="Embedding Base URL:",
tooltip_key="embedding_url",
row=2,
column=0,
font=("Microsoft YaHei", 12)
)
emb_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var, font=("Microsoft YaHei", 12))
emb_url_entry.grid(row=2, column=1, padx=5, pady=5, sticky="nsew")
# 4) Embedding Model Name
self.create_label_with_help(
parent=self.embeddings_config_tab,
label_text="Embedding Model Name:",
tooltip_key="embedding_model_name",
row=3,
column=0,
font=("Microsoft YaHei", 12)
)
emb_model_name_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_model_name_var, font=("Microsoft YaHei", 12))
emb_model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew")
# 5) Retrieval Top-K
self.create_label_with_help(
parent=self.embeddings_config_tab,
label_text="Retrieval Top-K:",
tooltip_key="embedding_retrieval_k",
row=4,
column=0,
font=("Microsoft YaHei", 12)
)
emb_retrieval_k_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_retrieval_k_var, font=("Microsoft YaHei", 12))
emb_retrieval_k_entry.grid(row=4, column=1, padx=5, pady=5, sticky="nsew")
# 添加测试按钮
test_btn = ctk.CTkButton(
self.embeddings_config_tab,
text="测试配置",
command=self.test_embedding_config,
font=("Microsoft YaHei", 12)
)
test_btn.grid(row=5, column=0, columnspan=2, padx=5, pady=5, sticky="ew")
# ----------------- 小说参数区 -----------------
def build_novel_params_area(self, start_row=1):
"""
在右侧 frame 中创建:主题、类型、章节数/字数、保存路径、本章指导、可选要素等参数输入区。
"""
self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical")
self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5)
self.params_frame.columnconfigure(1, weight=1)
# 1) 主题(Topic)
topic_label_frame = self.create_label_with_help(
parent=self.params_frame,
label_text="主题(Topic):",
tooltip_key="topic",
row=0,
column=0,
font=("Microsoft YaHei", 12),
sticky="ne"
)
self.topic_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12))
TextWidgetContextMenu(self.topic_text)
self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew")
if self.topic_default:
self.topic_text.insert("0.0", self.topic_default)
# 2) 类型(Genre)
self.create_label_with_help(
parent=self.params_frame,
label_text="类型(Genre):",
tooltip_key="genre",
row=1,
column=0,
font=("Microsoft YaHei", 12)
)
genre_entry = ctk.CTkEntry(self.params_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12))
genre_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
# 3) 章节数 & 每章字数
row_for_chapter_and_word = 2
self.create_label_with_help(
parent=self.params_frame,
label_text="章节数 & 每章字数:",
tooltip_key="num_chapters",
row=row_for_chapter_and_word,
column=0,
font=("Microsoft YaHei", 12)
)
chapter_word_frame = ctk.CTkFrame(self.params_frame)
chapter_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew")
chapter_word_frame.columnconfigure((0, 1, 2, 3), weight=0)
num_chapters_label = ctk.CTkLabel(chapter_word_frame, text="章节数:", font=("Microsoft YaHei", 12))
num_chapters_label.grid(row=0, column=0, padx=5, pady=5, sticky="e")
num_chapters_entry = ctk.CTkEntry(chapter_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12))
num_chapters_entry.grid(row=0, column=1, padx=5, pady=5, sticky="w")
word_number_label = ctk.CTkLabel(chapter_word_frame, text="每章字数:", font=("Microsoft YaHei", 12))
word_number_label.grid(row=0, column=2, padx=(15, 5), pady=5, sticky="e")
word_number_entry = ctk.CTkEntry(chapter_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12))
word_number_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w")
# 4) 保存路径
row_fp = 3
self.create_label_with_help(
parent=self.params_frame,
label_text="保存路径:",
tooltip_key="filepath",
row=row_fp,
column=0,
font=("Microsoft YaHei", 12)
)
self.filepath_frame = ctk.CTkFrame(self.params_frame)
self.filepath_frame.grid(row=row_fp, column=1, padx=5, pady=5, sticky="nsew")
self.filepath_frame.columnconfigure(0, weight=1)
filepath_entry = ctk.CTkEntry(self.filepath_frame, textvariable=self.filepath_var, font=("Microsoft YaHei", 12))
filepath_entry.grid(row=0, column=0, padx=5, pady=5, sticky="ew")
browse_btn = ctk.CTkButton(self.filepath_frame, text="浏览...", command=self.browse_folder, width=60, font=("Microsoft YaHei", 12))
browse_btn.grid(row=0, column=1, padx=5, pady=5, sticky="e")
# 5) 章节号
row_chap_num = 4
self.create_label_with_help(
parent=self.params_frame,
label_text="章节号:",
tooltip_key="chapter_num",
row=row_chap_num,
column=0,
font=("Microsoft YaHei", 12)
)
chapter_num_entry = ctk.CTkEntry(self.params_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12))
chapter_num_entry.grid(row=row_chap_num, column=1, padx=5, pady=5, sticky="w")
# 6) 本章指导
row_user_guide = 5
guide_label_frame = self.create_label_with_help(
parent=self.params_frame,
label_text="本章指导:",
tooltip_key="user_guidance",
row=row_user_guide,
column=0,
font=("Microsoft YaHei", 12),
sticky="ne"
)
self.user_guide_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12))
TextWidgetContextMenu(self.user_guide_text)
self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew")
if self.user_guidance_default:
self.user_guide_text.insert("0.0", self.user_guidance_default)
# 7) 可选元素:核心人物/关键道具/空间坐标/时间压力
row_idx = 6
self.create_label_with_help(
parent=self.params_frame,
label_text="核心人物:",
tooltip_key="characters_involved",
row=row_idx,
column=0,
font=("Microsoft YaHei", 12)
)
char_inv_entry = ctk.CTkEntry(self.params_frame, textvariable=self.characters_involved_var, font=("Microsoft YaHei", 12))
char_inv_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew")
row_idx += 1
self.create_label_with_help(
parent=self.params_frame,
label_text="关键道具:",
tooltip_key="key_items",
row=row_idx,
column=0,
font=("Microsoft YaHei", 12)
)
key_items_entry = ctk.CTkEntry(self.params_frame, textvariable=self.key_items_var, font=("Microsoft YaHei", 12))
key_items_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew")
row_idx += 1
self.create_label_with_help(
parent=self.params_frame,
label_text="空间坐标:",
tooltip_key="scene_location",
row=row_idx,
column=0,
font=("Microsoft YaHei", 12)
)
scene_loc_entry = ctk.CTkEntry(self.params_frame, textvariable=self.scene_location_var, font=("Microsoft YaHei", 12))
scene_loc_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew")
row_idx += 1
self.create_label_with_help(
parent=self.params_frame,
label_text="时间压力:",
tooltip_key="time_constraint",
row=row_idx,
column=0,
font=("Microsoft YaHei", 12)
)
time_const_entry = ctk.CTkEntry(self.params_frame, textvariable=self.time_constraint_var, font=("Microsoft YaHei", 12))
time_const_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew")
# ----------------- 可选功能按钮 -----------------
def build_optional_buttons_area(self, start_row=2):
"""
包含:一致性审校、导入知识库、清空向量库、查看剧情要点。
"""
self.optional_btn_frame = ctk.CTkFrame(self.right_frame)
self.optional_btn_frame.grid(row=start_row, column=0, sticky="ew", padx=5, pady=5)
self.optional_btn_frame.columnconfigure((0, 1, 2, 3), weight=1)
self.btn_check_consistency = ctk.CTkButton(
self.optional_btn_frame,
text="一致性审校",
command=self.do_consistency_check,
font=("Microsoft YaHei", 12)
)
self.btn_check_consistency.grid(row=0, column=0, padx=5, pady=5, sticky="ew")
self.btn_import_knowledge = ctk.CTkButton(
self.optional_btn_frame,
text="导入知识库",
command=self.import_knowledge_handler,
font=("Microsoft YaHei", 12)
)
self.btn_import_knowledge.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.btn_clear_vectorstore = ctk.CTkButton(
self.optional_btn_frame,
text="清空向量库",
fg_color="red",
command=self.clear_vectorstore_handler,
font=("Microsoft YaHei", 12)
)
self.btn_clear_vectorstore.grid(row=0, column=2, padx=5, pady=5, sticky="ew")
self.plot_arcs_btn = ctk.CTkButton(
self.optional_btn_frame,
text="查看剧情要点",
command=self.show_plot_arcs_ui,
font=("Microsoft YaHei", 12)
)
self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew")
# ----------------- 配置的加载与保存 -----------------
def load_config_btn(self):
"""
从 config.json 中加载配置,并更新界面显示
"""
cfg = load_config(self.config_file)
if cfg:
# 加载上次使用的接口类型
last_llm = cfg.get("last_interface_format", "OpenAI")
last_embedding = cfg.get("last_embedding_interface_format", "OpenAI")
self.interface_format_var.set(last_llm)
self.embedding_interface_format_var.set(last_embedding)
# 加载对应的LLM配置
llm_configs = cfg.get("llm_configs", {})
if last_llm in llm_configs:
llm_conf = llm_configs[last_llm]
self.api_key_var.set(llm_conf.get("api_key", ""))
self.base_url_var.set(llm_conf.get("base_url", "https://api.openai.com/v1"))
self.model_name_var.set(llm_conf.get("model_name", "gpt-4o-mini"))
self.temperature_var.set(llm_conf.get("temperature", 0.7))
self.max_tokens_var.set(llm_conf.get("max_tokens", 8192))
self.timeout_var.set(llm_conf.get("timeout", 600))
# 加载对应的Embedding配置
embedding_configs = cfg.get("embedding_configs", {})
if last_embedding in embedding_configs:
emb_conf = embedding_configs[last_embedding]
self.embedding_api_key_var.set(emb_conf.get("api_key", ""))
self.embedding_url_var.set(emb_conf.get("base_url", "https://api.openai.com/v1"))
self.embedding_model_name_var.set(emb_conf.get("model_name", "text-embedding-ada-002"))
self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4)))
# 加载其它参数
other_params = cfg.get("other_params", {})
self.topic_text.delete("0.0", "end")
self.topic_text.insert("0.0", other_params.get("topic", ""))
self.genre_var.set(other_params.get("genre", "玄幻"))
self.num_chapters_var.set(str(other_params.get("num_chapters", 10)))
self.word_number_var.set(str(other_params.get("word_number", 3000)))
self.filepath_var.set(other_params.get("filepath", ""))
self.chapter_num_var.set(str(other_params.get("chapter_num", "1")))
self.user_guide_text.delete("0.0", "end")
self.user_guide_text.insert("0.0", other_params.get("user_guidance", ""))
self.characters_involved_var.set(other_params.get("characters_involved", ""))
self.key_items_var.set(other_params.get("key_items", ""))
self.scene_location_var.set(other_params.get("scene_location", ""))
self.time_constraint_var.set(other_params.get("time_constraint", ""))
self.log("已加载配置。")
else:
messagebox.showwarning("提示", "未找到或无法读取配置文件。")
def save_config_btn(self):
"""
将当前界面的配置信息保存到 config.json
按照不同接口类型分别保存 LLM 与 Embedding 配置,确保各自独立。
"""
current_llm_interface = self.interface_format_var.get().strip()
current_embedding_interface = self.embedding_interface_format_var.get().strip()
# 构造当前LLM配置
llm_config = {
"api_key": self.api_key_var.get(),
"base_url": self.base_url_var.get(),
"model_name": self.model_name_var.get(),
"temperature": self.temperature_var.get(),
"max_tokens": self.max_tokens_var.get(),
"timeout": self.safe_get_int(self.timeout_var, 600)
}
# 构造当前Embedding配置
embedding_config = {
"api_key": self.embedding_api_key_var.get(),
"base_url": self.embedding_url_var.get(),
"model_name": self.embedding_model_name_var.get(),
"retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4)
}
# 构造其它参数
other_params = {
"topic": self.topic_text.get("0.0", "end").strip(),
"genre": self.genre_var.get(),
"num_chapters": self.safe_get_int(self.num_chapters_var, 10),
"word_number": self.safe_get_int(self.word_number_var, 3000),
"filepath": self.filepath_var.get(),
"chapter_num": self.chapter_num_var.get(),
"user_guidance": self.user_guide_text.get("0.0", "end").strip(),
"characters_involved": self.characters_involved_var.get(),
"key_items": self.key_items_var.get(),
"scene_location": self.scene_location_var.get(),
"time_constraint": self.time_constraint_var.get()
}
# 读取已有配置(若存在)
existing_config = load_config(self.config_file)
if not existing_config:
existing_config = {}
existing_config["last_interface_format"] = current_llm_interface
existing_config["last_embedding_interface_format"] = current_embedding_interface
if "llm_configs" not in existing_config:
existing_config["llm_configs"] = {}
existing_config["llm_configs"][current_llm_interface] = llm_config
if "embedding_configs" not in existing_config:
existing_config["embedding_configs"] = {}
existing_config["embedding_configs"][current_embedding_interface] = embedding_config
existing_config["other_params"] = other_params
if save_config(existing_config, self.config_file):
messagebox.showinfo("提示", "配置已保存至 config.json")
self.log("配置已保存。")
else:
messagebox.showerror("错误", "保存配置失败。")
def browse_folder(self):
selected_dir = filedialog.askdirectory()
if selected_dir:
self.filepath_var.set(selected_dir)
# ----------------- 生成与定稿的函数 -----------------
def generate_novel_architecture_ui(self):
"""
Step1:生成小说整体架构
"""
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先选择保存文件路径")
return
def task():
self.disable_button_safe(self.btn_generate_architecture)
try:
interface_format = self.interface_format_var.get().strip()
api_key = self.api_key_var.get().strip()
base_url = self.base_url_var.get().strip()
model_name = self.model_name_var.get().strip()
temperature = self.temperature_var.get()
max_tokens = self.max_tokens_var.get()
timeout_val = self.safe_get_int(self.timeout_var, 600)
topic = self.topic_text.get("0.0", "end").strip()
genre = self.genre_var.get().strip()
num_chapters = self.safe_get_int(self.num_chapters_var, 10)
word_number = self.safe_get_int(self.word_number_var, 3000)
self.safe_log("开始生成小说架构...")
Novel_architecture_generate(
interface_format=interface_format,
api_key=api_key,
base_url=base_url,
llm_model=model_name,
topic=topic,
genre=genre,
number_of_chapters=num_chapters,
word_number=word_number,
filepath=filepath,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout_val
)
self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。")
except Exception:
self.handle_exception("生成小说架构时出错")
finally:
self.enable_button_safe(self.btn_generate_architecture)
threading.Thread(target=task, daemon=True).start()
def generate_chapter_blueprint_ui(self):
"""
Step2:生成章节蓝图
"""
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先选择保存文件路径")
return
def task():
self.disable_button_safe(self.btn_generate_directory)
try:
interface_format = self.interface_format_var.get().strip()
api_key = self.api_key_var.get().strip()
base_url = self.base_url_var.get().strip()
model_name = self.model_name_var.get().strip()
number_of_chapters = self.safe_get_int(self.num_chapters_var, 10)
temperature = self.temperature_var.get()
max_tokens = self.max_tokens_var.get()
timeout_val = self.safe_get_int(self.timeout_var, 600)
self.safe_log("开始生成章节蓝图...")
Chapter_blueprint_generate(
interface_format=interface_format,
api_key=api_key,
base_url=base_url,
llm_model=model_name,
number_of_chapters=number_of_chapters,
filepath=filepath,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout_val
)
self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。")
except Exception:
self.handle_exception("生成章节蓝图时出错")
finally:
self.enable_button_safe(self.btn_generate_directory)
threading.Thread(target=task, daemon=True).start()
def generate_chapter_draft_ui(self):
"""
Step3:生成章节草稿
"""
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先配置保存文件路径。")
return
def task():
self.disable_button_safe(self.btn_generate_chapter)
try:
interface_format = self.interface_format_var.get().strip()
api_key = self.api_key_var.get().strip()
base_url = self.base_url_var.get().strip()
model_name = self.model_name_var.get().strip()
temperature = self.temperature_var.get()
max_tokens = self.max_tokens_var.get()
timeout_val = self.safe_get_int(self.timeout_var, 600)
chap_num = self.safe_get_int(self.chapter_num_var, 1)
word_number = self.safe_get_int(self.word_number_var, 3000)
user_guidance = self.user_guide_text.get("0.0", "end").strip()
char_inv = self.characters_involved_var.get().strip()
key_items = self.key_items_var.get().strip()
scene_loc = self.scene_location_var.get().strip()
time_constr = self.time_constraint_var.get().strip()
embedding_api_key = self.embedding_api_key_var.get().strip()
embedding_url = self.embedding_url_var.get().strip()
embedding_interface_format = self.embedding_interface_format_var.get().strip()
embedding_model_name = self.embedding_model_name_var.get().strip()
embedding_k = self.safe_get_int(self.embedding_retrieval_k_var, 4)
self.safe_log(f"开始生成第{chap_num}章草稿...")
draft_text = generate_chapter_draft(
api_key=api_key,
base_url=base_url,
model_name=model_name,
filepath=filepath,
novel_number=chap_num,
word_number=word_number,
temperature=temperature,
user_guidance=user_guidance,
characters_involved=char_inv,
key_items=key_items,
scene_location=scene_loc,
time_constraint=time_constr,
embedding_api_key=embedding_api_key,
embedding_url=embedding_url,
embedding_interface_format=embedding_interface_format,
embedding_model_name=embedding_model_name,
embedding_retrieval_k=embedding_k,
interface_format=interface_format,
max_tokens=max_tokens,
timeout=timeout_val
)
if draft_text:
self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。")
self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text))
else:
self.safe_log("⚠️ 本章草稿生成失败或无内容。")
except Exception:
self.handle_exception("生成章节草稿时出错")
finally:
self.enable_button_safe(self.btn_generate_chapter)
threading.Thread(target=task, daemon=True).start()
def show_chapter_in_textbox(self, text: str):
"""
将生成或读取到的章节文本内容显示到左侧文本框中。
"""
self.chapter_result.delete("0.0", "end")
self.chapter_result.insert("0.0", text)
self.chapter_result.see("end")
def finalize_chapter_ui(self):
"""
Step4:定稿章节,更新全局摘要、角色状态、向量库等。
"""
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先配置保存文件路径。")
return
def task():
self.disable_button_safe(self.btn_finalize_chapter)
try:
interface_format = self.interface_format_var.get().strip()
api_key = self.api_key_var.get().strip()
base_url = self.base_url_var.get().strip()
model_name = self.model_name_var.get().strip()
temperature = self.temperature_var.get()
max_tokens = self.max_tokens_var.get()
timeout_val = self.safe_get_int(self.timeout_var, 600)
embedding_api_key = self.embedding_api_key_var.get().strip()
embedding_url = self.embedding_url_var.get().strip()
embedding_interface_format = self.embedding_interface_format_var.get().strip()
embedding_model_name = self.embedding_model_name_var.get().strip()
chap_num = self.safe_get_int(self.chapter_num_var, 1)
word_number = self.safe_get_int(self.word_number_var, 3000)
self.safe_log(f"开始定稿第{chap_num}章...")
# 先读取用户在文本框中编辑好的内容
chapters_dir = os.path.join(filepath, "chapters")
os.makedirs(chapters_dir, exist_ok=True)
chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt")
edited_text = self.chapter_result.get("0.0", "end").strip()
# 如果字数不足70%,询问是否扩写
if len(edited_text) < 0.7 * word_number:
ask = messagebox.askyesno(
"字数不足",
f"当前章节字数 ({len(edited_text)}) 低于目标字数({word_number})的70%,是否要尝试扩写?"
)
if ask:
self.safe_log("正在扩写章节内容...")
enriched = enrich_chapter_text(
chapter_text=edited_text,
word_number=word_number,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
interface_format=interface_format,
max_tokens=max_tokens,
timeout=timeout_val
)
edited_text = enriched
self.master.after(0, lambda: self.chapter_result.delete("0.0", "end"))
self.master.after(0, lambda: self.chapter_result.insert("0.0", edited_text))
clear_file_content(chapter_file)
save_string_to_txt(edited_text, chapter_file)
finalize_chapter(
novel_number=chap_num,
word_number=word_number,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
filepath=filepath,
embedding_api_key=embedding_api_key,
embedding_url=embedding_url,
embedding_interface_format=embedding_interface_format,
embedding_model_name=embedding_model_name,
interface_format=interface_format,
max_tokens=max_tokens,
timeout=timeout_val
)
self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。")
final_text = read_file(chapter_file)
self.master.after(0, lambda: self.show_chapter_in_textbox(final_text))
except Exception:
self.handle_exception("定稿章节时出错")
finally:
self.enable_button_safe(self.btn_finalize_chapter)
threading.Thread(target=task, daemon=True).start()
# ----------------- 一致性审校 -----------------
def do_consistency_check(self):
"""
可选功能:对当前章节进行一致性审校,基于全局摘要和角色状态等进行检查。
"""
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先配置保存文件路径。")
return
def task():
self.disable_button_safe(self.btn_check_consistency)
try:
api_key = self.api_key_var.get().strip()
base_url = self.base_url_var.get().strip()
model_name = self.model_name_var.get().strip()
temperature = self.temperature_var.get()
interface_format = self.interface_format_var.get()
max_tokens = self.max_tokens_var.get()
timeout = self.timeout_var.get()
chap_num = self.safe_get_int(self.chapter_num_var, 1)
chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt")
chapter_text = read_file(chap_file)
if not chapter_text.strip():
self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。")
return
self.safe_log("开始一致性审校...")
result = check_consistency(
novel_setting="",
character_state=read_file(os.path.join(filepath, "character_state.txt")),
global_summary=read_file(os.path.join(filepath, "global_summary.txt")),
chapter_text=chapter_text,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
interface_format=interface_format,
max_tokens=max_tokens,
timeout=timeout,
plot_arcs=""
)
self.safe_log("审校结果:")
self.safe_log(result)
except Exception:
self.handle_exception("审校时出错")
finally:
self.enable_button_safe(self.btn_check_consistency)
threading.Thread(target=task, daemon=True).start()
# ----------------- 知识库操作 -----------------
def import_knowledge_handler(self):
"""
导入文本文件到本地知识库,供后续生成章节时的检索。
"""
selected_file = filedialog.askopenfilename(
title="选择要导入的知识库文件",
filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")]
)
if selected_file:
def task():
self.disable_button_safe(self.btn_import_knowledge)
try:
emb_api_key = self.embedding_api_key_var.get().strip()
emb_url = self.embedding_url_var.get().strip()
emb_format = self.embedding_interface_format_var.get().strip()
emb_model = self.embedding_model_name_var.get().strip()
self.safe_log(f"开始导入知识库文件: {selected_file}")
import_knowledge_file(
embedding_api_key=emb_api_key,
embedding_url=emb_url,
embedding_interface_format=emb_format,
embedding_model_name=emb_model,
file_path=selected_file,
filepath=self.filepath_var.get().strip()
)
self.safe_log("✅ 知识库文件导入完成。")
except Exception:
self.handle_exception("导入知识库时出错")
finally:
self.enable_button_safe(self.btn_import_knowledge)
threading.Thread(target=task, daemon=True).start()
def clear_vectorstore_handler(self):
"""
清空本地向量库文件夹。
"""
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先配置保存文件路径。")
return
first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!")
if first_confirm:
second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!")
if second_confirm:
if clear_vector_store(filepath):
self.log("已清空向量库。")
else:
self.log(f"未能清空向量库,请关闭程序后手动删除 {filepath} 下的 vectorstore 文件夹。")
def show_plot_arcs_ui(self):
"""
查看剧情要点/未解决冲突的记录(plot_arcs.txt)。
"""
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径")
return
plot_arcs_file = os.path.join(filepath, "plot_arcs.txt")
if not os.path.exists(plot_arcs_file):
messagebox.showinfo("剧情要点", "当前还未生成任何剧情要点或冲突记录。")
return
arcs_text = read_file(plot_arcs_file).strip()
if not arcs_text:
arcs_text = "当前没有记录的剧情要点或冲突。"
top = ctk.CTkToplevel(self.master)
top.title("剧情要点/未解决冲突")
top.geometry("600x400")
text_area = ctk.CTkTextbox(top, wrap="word", font=("Microsoft YaHei", 12))
text_area.pack(fill="both", expand=True, padx=10, pady=10)
text_area.insert("0.0", arcs_text)
text_area.configure(state="disabled")
# ----------------- Novel Architecture 标签页 -----------------
def build_setting_tab(self):
self.setting_tab.rowconfigure(0, weight=0)
self.setting_tab.rowconfigure(1, weight=1)
self.setting_tab.columnconfigure(0, weight=1)
load_btn = ctk.CTkButton(
self.setting_tab,
text="加载 Novel_architecture.txt",
command=self.load_novel_architecture,
font=("Microsoft YaHei", 12)
)
load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
save_btn = ctk.CTkButton(
self.setting_tab,
text="保存修改",
command=self.save_novel_architecture,
font=("Microsoft YaHei", 12)
)
save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e")
self.setting_text = ctk.CTkTextbox(self.setting_tab, wrap="word", font=("Microsoft YaHei", 12))
TextWidgetContextMenu(self.setting_text)
self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
def load_novel_architecture(self):
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先设置保存文件路径")
return
filename = os.path.join(filepath, "Novel_architecture.txt")
content = read_file(filename)
self.setting_text.delete("0.0", "end")
self.setting_text.insert("0.0", content)
self.log("已加载 Novel_architecture.txt 内容到编辑区。")
def save_novel_architecture(self):
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先设置保存文件路径。")
return
content = self.setting_text.get("0.0", "end").strip()
filename = os.path.join(filepath, "Novel_architecture.txt")
clear_file_content(filename)
save_string_to_txt(content, filename)
self.log("已保存对 Novel_architecture.txt 的修改。")
# ----------------- Chapter Blueprint 标签页 -----------------
def build_directory_tab(self):
self.directory_tab.rowconfigure(0, weight=0)
self.directory_tab.rowconfigure(1, weight=1)
self.directory_tab.columnconfigure(0, weight=1)
load_btn = ctk.CTkButton(
self.directory_tab,
text="加载 Novel_directory.txt",
command=self.load_chapter_blueprint,
font=("Microsoft YaHei", 12)
)
load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
save_btn = ctk.CTkButton(
self.directory_tab,
text="保存修改",
command=self.save_chapter_blueprint,
font=("Microsoft YaHei", 12)
)
save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e")
self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12))
TextWidgetContextMenu(self.directory_text)
self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
def load_chapter_blueprint(self):
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先设置保存文件路径")
return
filename = os.path.join(filepath, "Novel_directory.txt")
content = read_file(filename)
self.directory_text.delete("0.0", "end")
self.directory_text.insert("0.0", content)
self.log("已加载 Novel_directory.txt 内容到编辑区。")
def save_chapter_blueprint(self):
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先设置保存文件路径")
return
content = self.directory_text.get("0.0", "end").strip()
filename = os.path.join(filepath, "Novel_directory.txt")
clear_file_content(filename)
save_string_to_txt(content, filename)
self.log("已保存对 Novel_directory.txt 的修改。")
# ----------------- Character State 标签页 -----------------
def build_character_tab(self):
self.character_tab.rowconfigure(0, weight=0)
self.character_tab.rowconfigure(1, weight=1)
self.character_tab.columnconfigure(0, weight=1)
load_btn = ctk.CTkButton(
self.character_tab,
text="加载 character_state.txt",
command=self.load_character_state,
font=("Microsoft YaHei", 12)
)
load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
save_btn = ctk.CTkButton(
self.character_tab,
text="保存修改",
command=self.save_character_state,
font=("Microsoft YaHei", 12)
)
save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e")
self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12))
TextWidgetContextMenu(self.character_text)
self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
def load_character_state(self):
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先设置保存文件路径")
return
filename = os.path.join(filepath, "character_state.txt")
content = read_file(filename)
self.character_text.delete("0.0", "end")
self.character_text.insert("0.0", content)
self.log("已加载 character_state.txt 到编辑区。")
def save_character_state(self):
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先设置保存文件路径")
return
content = self.character_text.get("0.0", "end").strip()
filename = os.path.join(filepath, "character_state.txt")
clear_file_content(filename)
save_string_to_txt(content, filename)
self.log("已保存对 character_state.txt 的修改。")
# ----------------- Global Summary 标签页 -----------------
def build_summary_tab(self):
self.summary_tab.rowconfigure(0, weight=0)
self.summary_tab.rowconfigure(1, weight=1)
self.summary_tab.columnconfigure(0, weight=1)
load_btn = ctk.CTkButton(
self.summary_tab,
text="加载 global_summary.txt",
command=self.load_global_summary,
font=("Microsoft YaHei", 12)
)
load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
save_btn = ctk.CTkButton(
self.summary_tab,
text="保存修改",
command=self.save_global_summary,
font=("Microsoft YaHei", 12)
)
save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e")
self.summary_text = ctk.CTkTextbox(self.summary_tab, wrap="word", font=("Microsoft YaHei", 12))
TextWidgetContextMenu(self.summary_text)
self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
def load_global_summary(self):
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先设置保存文件路径")
return
filename = os.path.join(filepath, "global_summary.txt")
content = read_file(filename)
self.summary_text.delete("0.0", "end")
self.summary_text.insert("0.0", content)
self.log("已加载 global_summary.txt 到编辑区。")
def save_global_summary(self):
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先设置保存文件路径")
return
content = self.summary_text.get("0.0", "end").strip()
filename = os.path.join(filepath, "global_summary.txt")
clear_file_content(filename)
save_string_to_txt(content, filename)
self.log("已保存对 global_summary.txt 的修改。")
# ----------------- Chapters Manage 标签页 -----------------
def build_chapters_tab(self):
"""
章节浏览与管理:可选择某一章进行查看/编辑,并保存修改。
"""
self.chapters_view_tab.rowconfigure(0, weight=0)
self.chapters_view_tab.rowconfigure(1, weight=1)
self.chapters_view_tab.columnconfigure(0, weight=1)
top_frame = ctk.CTkFrame(self.chapters_view_tab)
top_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
top_frame.columnconfigure(0, weight=0)
top_frame.columnconfigure(1, weight=0)
top_frame.columnconfigure(2, weight=0)
top_frame.columnconfigure(3, weight=0)
top_frame.columnconfigure(4, weight=1)
prev_btn = ctk.CTkButton(top_frame, text="<< 上一章", command=self.prev_chapter, font=("Microsoft YaHei", 12))
prev_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
next_btn = ctk.CTkButton(top_frame, text="下一章 >>", command=self.next_chapter, font=("Microsoft YaHei", 12))
next_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w")
self.chapter_select_var = ctk.StringVar(value="")
self.chapter_select_menu = ctk.CTkOptionMenu(
top_frame,
values=[],
variable=self.chapter_select_var,
command=self.on_chapter_selected,
font=("Microsoft YaHei", 12)
)
self.chapter_select_menu.grid(row=0, column=2, padx=5, pady=5, sticky="w")
save_btn = ctk.CTkButton(top_frame, text="保存修改", command=self.save_current_chapter, font=("Microsoft YaHei", 12))
save_btn.grid(row=0, column=3, padx=5, pady=5, sticky="w")
refresh_btn = ctk.CTkButton(top_frame, text="刷新章节列表", command=self.refresh_chapters_list, font=("Microsoft YaHei", 12))
refresh_btn.grid(row=0, column=4, padx=5, pady=5, sticky="e")
self.chapter_view_text = ctk.CTkTextbox(self.chapters_view_tab, wrap="word", font=("Microsoft YaHei", 12))
TextWidgetContextMenu(self.chapter_view_text)
self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
self.chapters_list = []
self.refresh_chapters_list()
def refresh_chapters_list(self):
"""
刷新并获取当前保存路径下的所有章节txt文件,并更新下拉菜单。
"""
filepath = self.filepath_var.get().strip()
chapters_dir = os.path.join(filepath, "chapters")
if not os.path.exists(chapters_dir):
self.safe_log("尚未找到 chapters 文件夹,请先生成章节或检查保存路径。")
self.chapter_select_menu.configure(values=[])
return
all_files = os.listdir(chapters_dir)
chapter_nums = []
for f in all_files:
if f.startswith("chapter_") and f.endswith(".txt"):
number_part = f.replace("chapter_", "").replace(".txt", "")
if number_part.isdigit():
chapter_nums.append(number_part)
chapter_nums.sort(key=lambda x: int(x))
self.chapters_list = chapter_nums
self.chapter_select_menu.configure(values=self.chapters_list)
current_selected = self.chapter_select_var.get()
if current_selected not in self.chapters_list:
if self.chapters_list:
self.chapter_select_var.set(self.chapters_list[0])
self.load_chapter_content(self.chapters_list[0])
else:
self.chapter_select_var.set("")
self.chapter_view_text.delete("0.0", "end")
def on_chapter_selected(self, value):
"""
当下拉菜单选择变化时,加载对应章节内容到文本框。
"""
self.load_chapter_content(value)
def load_chapter_content(self, chapter_number_str):
if not chapter_number_str:
return
filepath = self.filepath_var.get().strip()
chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt")
if not os.path.exists(chapter_file):
self.safe_log(f"章节文件 {chapter_file} 不存在!")
return
content = read_file(chapter_file)
self.chapter_view_text.delete("0.0", "end")
self.chapter_view_text.insert("0.0", content)
def save_current_chapter(self):
"""
将当前查看的章节内容保存回对应文件。
"""
chapter_number_str = self.chapter_select_var.get()
if not chapter_number_str:
messagebox.showwarning("警告", "尚未选择章节,无法保存。")
return
filepath = self.filepath_var.get().strip()
if not filepath:
messagebox.showwarning("警告", "请先配置保存文件路径")
return
chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt")
content = self.chapter_view_text.get("0.0", "end").strip()
clear_file_content(chapter_file)
save_string_to_txt(content, chapter_file)
self.safe_log(f"已保存对第 {chapter_number_str} 章的修改。")
def prev_chapter(self):
"""
切换到上一章
"""
if not self.chapters_list:
return
current = self.chapter_select_var.get()
if current not in self.chapters_list:
return
idx = self.chapters_list.index(current)
if idx > 0:
new_idx = idx - 1
self.chapter_select_var.set(self.chapters_list[new_idx])
self.load_chapter_content(self.chapters_list[new_idx])
else:
messagebox.showinfo("提示", "已经是第一章了。")
def next_chapter(self):
"""
切换到下一章
"""
if not self.chapters_list:
return
current = self.chapter_select_var.get()
if current not in self.chapters_list:
return
idx = self.chapters_list.index(current)
if idx < len(self.chapters_list) - 1:
new_idx = idx + 1
self.chapter_select_var.set(self.chapters_list[new_idx])
self.load_chapter_content(self.chapters_list[new_idx])
else:
messagebox.showinfo("提示", "已经是最后一章了。")
def test_llm_config(self):
"""
测试当前的LLM配置是否可用
"""
interface_format = self.interface_format_var.get().strip()
api_key = self.api_key_var.get().strip()
base_url = self.base_url_var.get().strip()
model_name = self.model_name_var.get().strip()
temperature = self.temperature_var.get()
max_tokens = self.max_tokens_var.get()
timeout = self.timeout_var.get()
test_llm_config(
interface_format=interface_format,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
log_func=self.safe_log,
handle_exception_func=self.handle_exception
)
def test_embedding_config(self):
"""
测试当前的Embedding配置是否可用
"""
api_key = self.embedding_api_key_var.get().strip()
base_url = self.embedding_url_var.get().strip()
interface_format = self.embedding_interface_format_var.get().strip()
model_name = self.embedding_model_name_var.get().strip()
test_embedding_config(
api_key=api_key,
base_url=base_url,
interface_format=interface_format,
model_name=model_name,
log_func=self.safe_log,
handle_exception_func=self.handle_exception
)
# ----------------- 程序入口 -----------------
if __name__ == "__main__":
app = ctk.CTk()
gui = NovelGeneratorGUI(app)
app.mainloop()