Merge pull request #136 from CNlaojing/main

增加角色库功能
This commit is contained in:
IdleCloud
2025-03-15 17:23:35 +08:00
committed by GitHub
18 changed files with 2130 additions and 138 deletions
+9
View File
@@ -18,6 +18,15 @@
> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事
2025-03-05 添加角色库功能
2025-03-09 添加字数显示
2025-03-13
1、新增闲云修改;
2、把本章指导改成内容指导;
3、在生成架构中的: 2. 角色动力学设定(角色弧光模型)、 3. 世界构建矩阵(三维度交织法)、 4. 情节架构(三幕式悬念)与生成目录的:5. 章节目录生成(悬念节奏曲线)加入引导词内容指导,以方便生成角色动力学时只以核心种子生成,导致生成的内容与实际需求不符。
4、在终端加回被删除的LLM提示词与LLM返回内容显示,以便复盘,参考修改提示词。
---
## 📑 目录导航
-2
View File
@@ -3,11 +3,9 @@
import logging
import traceback
from typing import List
import requests
from langchain_openai import AzureOpenAIEmbeddings, OpenAIEmbeddings
def ensure_openai_base_url_has_v1(url: str) -> str:
"""
若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'
+61 -18
View File
@@ -9,6 +9,7 @@ from azure.ai.inference import ChatCompletionsClient
from azure.core.credentials import AzureKeyCredential
from azure.ai.inference.models import SystemMessage, UserMessage
from openai import OpenAI
import requests
def check_base_url(url: str) -> str:
@@ -114,7 +115,8 @@ class GeminiAdapter(BaseLLMAdapter):
config = types.GenerateContentConfig(
max_output_tokens=self.max_tokens,
temperature=self.temperature,
)
),
timeout=self.timeout # 添加超时参数
)
if response and response.text:
return response.text
@@ -212,11 +214,15 @@ class MLStudioAdapter(BaseLLMAdapter):
)
def invoke(self, prompt: str) -> str:
response = self._client.invoke(prompt)
if not response:
logging.warning("No response from MLStudioAdapter.")
try:
response = self._client.invoke(prompt)
if not response:
logging.warning("No response from MLStudioAdapter.")
return ""
return response.content
except Exception as e:
logging.error(f"ML Studio API 调用超时或失败: {e}")
return ""
return response.content
class AzureAIAdapter(BaseLLMAdapter):
"""
@@ -279,24 +285,59 @@ class VolcanoEngineAIAdapter(BaseLLMAdapter):
self.timeout = timeout
self._client = OpenAI(
# 此为默认路径,您可根据业务所在地域进行配置
base_url=base_url,
# 从环境变量中获取您的 API Key
api_key=api_key
api_key=api_key,
timeout=timeout # 添加超时配置
)
def invoke(self, prompt: str) -> str:
response = self._client.chat.completions.create(
model=self.model_name, # bot-20250223190248-2bq5k 为您当前的智能体的ID,注意此处与Chat API存在差异。差异对比详见 SDK使用指南
messages=[
{"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"},
{"role": "user", "content": prompt},
],
try:
response = self._client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"},
{"role": "user", "content": prompt},
],
timeout=self.timeout # 添加超时参数
)
if not response:
logging.warning("No response from DeepSeekAdapter.")
return ""
return response.choices[0].message.content
except Exception as e:
logging.error(f"火山引擎API调用超时或失败: {e}")
return ""
class SiliconFlowAdapter(BaseLLMAdapter):
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
self.base_url = check_base_url(base_url)
self.api_key = api_key
self.model_name = model_name
self.max_tokens = max_tokens
self.temperature = temperature
self.timeout = timeout
self._client = OpenAI(
base_url=base_url,
api_key=api_key,
timeout=timeout # 添加超时配置
)
# response = self._client.invoke(prompt)
if not response:
logging.warning("No response from DeepSeekAdapter.")
def invoke(self, prompt: str) -> str:
try:
response = self._client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"},
{"role": "user", "content": prompt},
],
timeout=self.timeout # 添加超时参数
)
if not response:
logging.warning("No response from DeepSeekAdapter.")
return ""
return response.choices[0].message.content
except Exception as e:
logging.error(f"硅基流动API调用超时或失败: {e}")
return ""
return response.choices[0].message.content
def create_llm_adapter(
interface_format: str,
@@ -330,5 +371,7 @@ def create_llm_adapter(
return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
elif fmt == "火山引擎":
return VolcanoEngineAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
elif fmt == "硅基流动":
return SiliconFlowAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
else:
raise ValueError(f"Unknown interface_format: {interface_format}")
+13 -4
View File
@@ -55,6 +55,7 @@ def Novel_architecture_generate(
number_of_chapters: int,
word_number: int,
filepath: str,
user_guidance: str = "", # 新增参数
temperature: float = 0.7,
max_tokens: int = 2048,
timeout: int = 600
@@ -91,7 +92,8 @@ def Novel_architecture_generate(
topic=topic,
genre=genre,
number_of_chapters=number_of_chapters,
word_number=word_number
word_number=word_number,
user_guidance=user_guidance # 修复:添加内容指导
)
core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core)
if not core_seed_result.strip():
@@ -105,7 +107,10 @@ def Novel_architecture_generate(
# Step2: 角色动力学
if "character_dynamics_result" not in partial_data:
logging.info("Step2: Generating character_dynamics_prompt ...")
prompt_character = character_dynamics_prompt.format(core_seed=partial_data["core_seed_result"].strip())
prompt_character = character_dynamics_prompt.format(
core_seed=partial_data["core_seed_result"].strip(),
user_guidance=user_guidance
)
character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character)
if not character_dynamics_result.strip():
logging.warning("character_dynamics_prompt generation failed.")
@@ -135,7 +140,10 @@ def Novel_architecture_generate(
# Step3: 世界观
if "world_building_result" not in partial_data:
logging.info("Step3: Generating world_building_prompt ...")
prompt_world = world_building_prompt.format(core_seed=partial_data["core_seed_result"].strip())
prompt_world = world_building_prompt.format(
core_seed=partial_data["core_seed_result"].strip(),
user_guidance=user_guidance # 修复:添加用户指导
)
world_building_result = invoke_with_cleaning(llm_adapter, prompt_world)
if not world_building_result.strip():
logging.warning("world_building_prompt generation failed.")
@@ -151,7 +159,8 @@ def Novel_architecture_generate(
prompt_plot = plot_architecture_prompt.format(
core_seed=partial_data["core_seed_result"].strip(),
character_dynamics=partial_data["character_dynamics_result"].strip(),
world_building=partial_data["world_building_result"].strip()
world_building=partial_data["world_building_result"].strip(),
user_guidance=user_guidance # 修复:添加用户指导
)
plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot)
if not plot_arch_result.strip():
+28 -12
View File
@@ -5,7 +5,6 @@
"""
import os
import logging
from nltk import download
from llm_adapters import create_llm_adapter
from prompt_definitions import first_chapter_draft_prompt, next_chapter_draft_prompt, summarize_recent_chapters_prompt
from chapter_directory_parser import get_chapter_info_from_blueprint
@@ -59,7 +58,7 @@ def summarize_recent_chapters(
short_summary = ""
next_chapter_keywords = ""
for line in response_text.splitlines():
line = line.strip()
line = line.strip()
if line.startswith("短期摘要:"):
short_summary = line.replace("短期摘要:", "").strip()
elif line.startswith("下一章关键字:"):
@@ -91,7 +90,7 @@ def build_chapter_prompt(
timeout: int = 600
) -> str:
"""
构造当前章节的请求提示词,不调用 LLM,仅返回构造好的提示词字符串。
构造当前章节的请求提示词,新增对下一章节元数据的引用
"""
arch_file = os.path.join(filepath, "Novel_architecture.txt")
novel_architecture_text = read_file(arch_file)
@@ -102,6 +101,7 @@ def build_chapter_prompt(
character_state_file = os.path.join(filepath, "character_state.txt")
character_state_text = read_file(character_state_file)
# 获取当前章节信息
chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number)
chapter_title = chapter_info["chapter_title"]
chapter_role = chapter_info["chapter_role"]
@@ -111,6 +111,17 @@ def build_chapter_prompt(
plot_twist_level = chapter_info["plot_twist_level"]
chapter_summary = chapter_info["chapter_summary"]
# 新增:获取下一章节信息
next_chapter_number = novel_number + 1
next_chapter_info = get_chapter_info_from_blueprint(blueprint_text, next_chapter_number)
next_chapter_title = next_chapter_info.get("chapter_title", "(未命名)")
next_chapter_role = next_chapter_info.get("chapter_role", "过渡章节")
next_chapter_purpose = next_chapter_info.get("chapter_purpose", "承上启下")
next_chapter_suspense = next_chapter_info.get("suspense_level", "中等")
next_chapter_foreshadow = next_chapter_info.get("foreshadowing", "无特殊伏笔")
next_chapter_twist = next_chapter_info.get("plot_twist_level", "★☆☆☆☆")
next_chapter_summary = next_chapter_info.get("chapter_summary", "衔接过渡内容")
chapters_dir = os.path.join(filepath, "chapters")
os.makedirs(chapters_dir, exist_ok=True)
@@ -187,14 +198,24 @@ def build_chapter_prompt(
global_summary=global_summary_text,
character_state=character_state_text,
context_excerpt=relevant_context,
previous_chapter_excerpt=previous_chapter_excerpt
previous_chapter_excerpt=previous_chapter_excerpt,
# 新增下一章节参数
next_chapter_number=next_chapter_number,
next_chapter_title=next_chapter_title,
next_chapter_role=next_chapter_role,
next_chapter_purpose=next_chapter_purpose,
next_chapter_suspense_level=next_chapter_suspense,
next_chapter_foreshadowing=next_chapter_foreshadow,
next_chapter_plot_twist_level=next_chapter_twist,
next_chapter_summary=next_chapter_summary
)
return prompt_text
def generate_chapter_draft(
api_key: str,
base_url: str,
model_name: str,
model_name: str,
filepath: str,
novel_number: int,
word_number: int,
@@ -212,16 +233,11 @@ def generate_chapter_draft(
interface_format: str = "openai",
max_tokens: int = 2048,
timeout: int = 600,
custom_prompt_text: str = None # 新增参数,若不为 None,则使用用户编辑后的提示词
custom_prompt_text: str = None
) -> str:
"""
根据 novel_number 判断是否为第一章。
- 若是第一章,则使用 first_chapter_draft_prompt
- 否则使用 next_chapter_draft_prompt
若 custom_prompt_text 提供,则以此作为提示词进行生成。
最终将生成文本存入 chapters/chapter_{novel_number}.txt。
生成章节草稿,支持自定义提示词
"""
# 构造提示词:若用户提供了编辑后的提示词,则使用之;否则构造默认提示词
if custom_prompt_text is None:
prompt_text = build_chapter_prompt(
api_key=api_key,
+32 -14
View File
@@ -42,18 +42,36 @@ def debug_log(prompt: str, response_content: str):
f"\n[######################################### Response #########################################]\n{response_content}\n"
)
def invoke_with_cleaning(llm_adapter, prompt: str) -> str:
"""
调用 LLM,增加重试和清洗逻辑
如果多次失败,则返回空字符串以继续流程,而不是中断。
"""
def _invoke(prompt):
return llm_adapter.invoke(prompt)
def invoke_with_cleaning(llm_adapter, prompt: str, max_retries: int = 3) -> str:
"""调用 LLM 并清理返回结果"""
print("\n" + "="*50)
print("发送到 LLM 的提示词:")
print("-"*50)
print(prompt)
print("="*50 + "\n")
result = ""
retry_count = 0
while retry_count < max_retries:
try:
result = llm_adapter.invoke(prompt)
print("\n" + "="*50)
print("LLM 返回的内容:")
print("-"*50)
print(result)
print("="*50 + "\n")
# 清理结果中的特殊格式标记
result = result.replace("```", "").strip()
if result:
return result
retry_count += 1
except Exception as e:
print(f"调用失败 ({retry_count + 1}/{max_retries}): {str(e)}")
retry_count += 1
if retry_count >= max_retries:
raise e
return result
response = call_with_retry(func=_invoke, max_retries=3, fallback_return="", prompt=prompt)
if not response:
logging.warning("No response from model after retry. Return empty.")
return ""
cleaned_text = remove_think_tags(response)
debug_log(prompt, cleaned_text)
return cleaned_text.strip()
+134 -63
View File
@@ -40,8 +40,9 @@ core_seed_prompt = """\
# =============== 2. 角色动力学设定(角色弧光模型)===================
character_dynamics_prompt = """\
基于核心种子
{core_seed}
基于以下元素
- 内容指导:{user_guidance}
- 核心种子:{core_seed}
请设计3-6个具有动态变化潜力的核心角色,每个角色需包含:
特征:
@@ -68,7 +69,11 @@ character_dynamics_prompt = """\
# =============== 3. 世界构建矩阵(三维度交织法)===================
world_building_prompt = """\
为服务核心冲突"{core_seed}",请构建三维交织的世界观
基于以下元素
- 内容指导:{user_guidance}
- 核心冲突:"{core_seed}"
为服务上述内容,请构建三维交织的世界观:
1. 物理维度:
- 空间结构(地理×社会阶层分布图)
@@ -92,10 +97,11 @@ world_building_prompt = """\
# =============== 4. 情节架构(三幕式悬念)===================
plot_architecture_prompt = """\
基于以下元素构建三幕式悬念架构
核心种子:{core_seed}
角色体系:{character_dynamics}
世界观:{world_building}
基于以下元素:
- 内容指导:{user_guidance}
- 核心种子:{core_seed}
- 角色体系:{character_dynamics}
- 世界观:{world_building}
要求按以下结构设计:
第一幕(触发)
@@ -121,7 +127,9 @@ plot_architecture_prompt = """\
# =============== 5. 章节目录生成(悬念节奏曲线)===================
chapter_blueprint_prompt = """\
根据小说架构:\n
基于以下元素:
- 内容指导:{user_guidance}
- 小说架构:
{novel_architecture}
设计{number_of_chapters}章的节奏分布:
@@ -163,7 +171,9 @@ chapter_blueprint_prompt = """\
"""
chunked_chapter_blueprint_prompt = """\
根据小说架构:\n
基于以下元素:
- 内容指导:{user_guidance}
- 小说架构:
{novel_architecture}
需要生成总共{number_of_chapters}章的节奏分布,
@@ -232,40 +242,45 @@ create_character_state_prompt = """\
依据当前角色动力学设定:{character_dynamics}
请生成一个角色状态文档,内容格式:
角色A属性
李员外:
├──物品:
├──物品(若有初始物品则增加,没有则为暂无):描述
...
├──青衫:一件破损的青色长袍,带有暗红色的污渍
│ └──寒铁长剑:一柄断裂的铁剑,剑身上刻有古老的符文
├──能力
├──技能1(若有初始技能则增加,没有则为暂无):描述
...
├──技能1:强大的精神感知能力:能够察觉到周围人的心中活动
│ └──技能2:无形攻击:能够释放一种无法被视觉捕捉的精神攻击
├──状态
├──身体状态
──Buff/Debuff
├──心理状态:描述
├──身体状态: 身材挺拔,穿着华丽的铠甲,面色冷峻
──心理状态: 目前的心态比较平静,但内心隐藏着对柳溪镇未来掌控的野心和不安
├──主要角色间关系网
├──角色B:描述(初始有关联则增加,没有则为暂无关系)
├──角色C:描述(初始有关联则增加,没有则为暂无关系)
...
├──林婉儿:李员外从小就与她有关联,对她的成长一直保持关注
│ └──苏明远:两人之间有着复杂的过去,最近因一场冲突而让对方感到威胁
├──触发或加深的事件
├──暂无事件
...
├──村庄内突然出现不明符号:这个不明符号似乎在暗示柳溪镇即将发生重大事件
│ └──林婉儿被刺穿皮肤:这次事件让两人意识到对方的强大实力,促使他们迅速离开队伍
角色B属性
├──物品
├──...
角色
├──物品:
├──某物(道具):描述
│ └──XX长剑(武器):描述
│ ...
├──能力
├──...
├──技能1:描述
│ └──技能2:描述
│ ...
├──状态
├──...
├──身体状态:
│ └──心理状态:描述
├──主要角色间关系网
├──...
├──角色B:描述
│ └──角色C:描述
│ ...
├──触发或加深的事件
├──...
角色C属性:
......
├──事件1:描述
│ └──事件2:描述
...
新出场角色:
- (此处填写未来任何新增角色或临时出场人物的基本信息)
@@ -282,42 +297,46 @@ update_character_state_prompt = """\
{old_state}
请更新主要角色状态,内容格式:
角色A属性
李员外:
├──物品:
├──某物(道具):描述
├──XX长剑(武器):描述
...
├──青衫:一件破损的青色长袍,带有暗红色的污渍
│ └──寒铁长剑:一柄断裂的铁剑,剑身上刻有古老的符文
├──能力
├──技能1描述
──技能2描述
...
├──技能1强大的精神感知能力:能够察觉到周围人的心中活动
──技能2无形攻击:能够释放一种无法被视觉捕捉的精神攻击
├──状态
├──身体状态
──Buff/Debuff
├──心理状态:描述
├──身体状态: 身材挺拔,穿着华丽的铠甲,面色冷峻
──心理状态: 目前的心态比较平静,但内心隐藏着对柳溪镇未来掌控的野心和不安
├──主要角色间关系网
├──角色B:描述
├──角色C:描述
...
├──林婉儿:李员外从小就与她有关联,对她的成长一直保持关注
│ └──苏明远:两人之间有着复杂的过去,最近因一场冲突而让对方感到威胁
├──触发或加深的事件
├──事件1:描述
──事件2:描述
├──村庄内突然出现不明符号:这个不明符号似乎在暗示柳溪镇即将发生重大事件
──林婉儿被刺穿皮肤:这次事件让两人意识到对方的强大实力,促使他们迅速离开队伍
角色名:
├──物品:
│ ├──某物(道具):描述
│ └──XX长剑(武器):描述
│ ...
├──能力
│ ├──技能1:描述
│ └──技能2:描述
│ ...
├──状态
│ ├──身体状态:
│ └──心理状态:描述
├──主要角色间关系网
│ ├──角色B:描述
│ └──角色C:描述
│ ...
├──触发或加深的事件
│ ├──事件1:描述
│ └──事件2:描述
...
角色B属性:
├──物品
├──...
├──能力
├──...
├──状态
├──...
├──主要角色间关系网
├──...
├──触发或加深的事件
├──...
角色C属性:
......
新出场角色:
@@ -407,6 +426,16 @@ next_chapter_draft_prompt = """\
认知颠覆:{plot_twist_level}
本章简述:{chapter_summary}
下一章节介绍:
{next_chapter_number} 章《{next_chapter_title}
本章定位:{next_chapter_role}
核心作用:{next_chapter_purpose}
悬念密度:{next_chapter_suspense_level}
伏笔操作:{next_chapter_foreshadowing}
认知颠覆:{next_chapter_plot_twist_level}
本章简述:{next_chapter_summary}
参考下一章内容简介,避免情节脱节或冲突。
可用元素:
- 核心人物(可能未指定){characters_involved}
- 关键道具(可能未指定){key_items}
@@ -448,4 +477,46 @@ next_chapter_draft_prompt = """\
- 不要使用markdown格式。
额外指导(可能未指定){user_guidance}
"""
"""
Character_Import_Prompt = """\
根据以下文本内容,分析出所有角色及其属性信息,严格按照以下格式要求:
<<角色状态格式要求>>
1. 必须包含以下五个分类(按顺序):
● 物品 ● 能力 ● 状态 ● 主要角色间关系网 ● 触发或加深的事件
2. 每个属性条目必须用【名称: 描述】格式
例:├──青衫: 一件破损的青色长袍,带有暗红色的污渍
3. 状态必须包含:
● 身体状态: [当前身体状况]
● 心理状态: [当前心理状况]
4. 关系网格式:
● [角色名称]: [关系类型,如"竞争对手"/"盟友"]
5. 触发事件格式:
● [事件名称]: [简要描述及影响]
<<示例>>
李员外:
├──物品:
│ ├──青衫: 一件破损的青色长袍,带有暗红色污渍
│ └──寒铁长剑: 剑身有裂痕,刻有「青云」符文
├──能力:
│ ├──精神感知: 能感知半径30米内的生命体
│ └──剑气压制: 通过目光释放精神威压
├──状态:
│ ├──身体状态: 右臂有未愈合的刀伤
│ └──心理状态: 对苏明远的实力感到忌惮
├──主要角色间关系网:
│ ├──苏明远: 竞争对手,十年前的同僚
│ └──林婉儿: 暗中培养的继承人
├──触发或加深的事件:
│ ├──兵器库遇袭: 丢失三把传家宝剑,影响战力
│ └──匿名威胁信: 信纸带有檀香味,暗示内部泄密
请严格按上述格式分析以下内容:
<<待分析小说文本开始>>
{content}
<<待分析小说文本结束>>
"""
+13 -2
View File
@@ -34,11 +34,22 @@ def build_chapters_tab(self):
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")
refresh_btn.grid(row=0, column=5, padx=5, pady=5, sticky="e")
self.chapters_word_count_label = ctk.CTkLabel(top_frame, text="字数:0", font=("Microsoft YaHei", 12))
self.chapters_word_count_label.grid(row=0, column=4, padx=(0,10), sticky="e")
self.chapter_view_text = ctk.CTkTextbox(self.chapters_view_tab, wrap="word", font=("Microsoft YaHei", 12))
def update_word_count(event=None):
text = self.chapter_view_text.get("0.0", "end-1c")
text_length = len(text)
self.chapters_word_count_label.configure(text=f"字数:{text_length}")
self.chapter_view_text.bind("<KeyRelease>", update_word_count)
self.chapter_view_text.bind("<ButtonRelease>", update_word_count)
TextWidgetContextMenu(self.chapter_view_text)
self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=6)
self.chapters_list = []
refresh_chapters_list(self)
+13 -2
View File
@@ -15,12 +15,23 @@ def build_character_tab(self):
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")
self.character_wordcount_label = ctk.CTkLabel(self.character_tab, text="字数:0", font=("Microsoft YaHei", 12))
self.character_wordcount_label.grid(row=0, column=1, 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")
save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e")
self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12))
def update_word_count(event=None):
text = self.character_text.get("0.0", "end-1c")
text_length = len(text)
self.character_wordcount_label.configure(text=f"字数:{text_length}")
self.character_text.bind("<KeyRelease>", update_word_count)
self.character_text.bind("<ButtonRelease>", update_word_count)
TextWidgetContextMenu(self.character_text)
self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3)
def load_character_state(self):
filepath = self.filepath_var.get().strip()
+7 -1
View File
@@ -92,6 +92,9 @@ def build_ai_config_tab(self):
elif new_value == "阿里云百炼":
self.base_url_var.set("https://dashscope.aliyuncs.com/compatible-mode/v1")
self.model_name_var.set("qwen-plus")
elif new_value == "硅基流动":
self.base_url_var.set("https://api.siliconflow.cn/v1")
self.model_name_var.set("deepseek-ai/DeepSeek-V3")
for i in range(7):
self.ai_config_tab.grid_rowconfigure(i, weight=0)
@@ -111,7 +114,8 @@ def build_ai_config_tab(self):
# 3) 接口格式
create_label_with_help(self, 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_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")
@@ -197,7 +201,9 @@ def build_embeddings_config_tab(self):
# 2) Embedding 接口格式
create_label_with_help(self, 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","SiliconFlow"]
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")
+13 -2
View File
@@ -15,12 +15,23 @@ def build_directory_tab(self):
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")
self.directory_word_count_label = ctk.CTkLabel(self.directory_tab, text="字数:0", font=("Microsoft YaHei", 12))
self.directory_word_count_label.grid(row=0, column=1, 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")
save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e")
self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12))
def update_word_count(event=None):
text = self.directory_text.get("0.0", "end")
count = len(text) - 1
self.directory_word_count_label.configure(text=f"字数:{count}")
self.directory_text.bind("<KeyRelease>", update_word_count)
self.directory_text.bind("<ButtonRelease>", update_word_count)
TextWidgetContextMenu(self.directory_text)
self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3)
def load_chapter_blueprint(self):
filepath = self.filepath_var.get().strip()
+80 -3
View File
@@ -25,6 +25,11 @@ def generate_novel_architecture_ui(self):
return
def task():
confirm = messagebox.askyesno("确认", "确定要生成小说架构吗?")
if not confirm:
self.enable_button_safe(self.btn_generate_architecture)
return
self.disable_button_safe(self.btn_generate_architecture)
try:
interface_format = self.interface_format_var.get().strip()
@@ -39,6 +44,8 @@ def generate_novel_architecture_ui(self):
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)
# 获取内容指导
user_guidance = self.user_guide_text.get("0.0", "end").strip()
self.safe_log("开始生成小说架构...")
Novel_architecture_generate(
@@ -53,7 +60,8 @@ def generate_novel_architecture_ui(self):
filepath=filepath,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout_val
timeout=timeout_val,
user_guidance=user_guidance # 添加内容指导参数
)
self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。")
except Exception:
@@ -69,6 +77,9 @@ def generate_chapter_blueprint_ui(self):
return
def task():
if not messagebox.askyesno("确认", "确定要生成章节草稿吗?"):
self.enable_button_safe(self.btn_generate_chapter)
return
self.disable_button_safe(self.btn_generate_directory)
try:
interface_format = self.interface_format_var.get().strip()
@@ -168,7 +179,64 @@ def generate_chapter_draft_ui(self):
dialog.geometry("600x400")
text_box = ctk.CTkTextbox(dialog, wrap="word", font=("Microsoft YaHei", 12))
text_box.pack(fill="both", expand=True, padx=10, pady=10)
text_box.insert("0.0", prompt_text)
# 字数统计标签
wordcount_label = ctk.CTkLabel(dialog, text="字数:0", font=("Microsoft YaHei", 12))
wordcount_label.pack(side="left", padx=(10,0), pady=5)
# 插入角色内容
final_prompt = prompt_text
role_names = [name.strip() for name in self.char_inv_text.get("0.0", "end").strip().split(',') if name.strip()]
role_lib_path = os.path.join(filepath, "角色库")
role_contents = []
if os.path.exists(role_lib_path):
for root, dirs, files in os.walk(role_lib_path):
for file in files:
if file.endswith(".txt") and os.path.splitext(file)[0] in role_names:
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
role_contents.append(f.read().strip()) # 直接使用文件内容,不添加重复名字
except Exception as e:
self.safe_log(f"读取角色文件 {file} 失败: {str(e)}")
if role_contents:
role_content_str = "\n".join(role_contents)
# 更精确的替换逻辑,处理不同情况下的占位符
placeholder_variations = [
"核心人物(可能未指定){characters_involved}",
"核心人物:{characters_involved}",
"核心人物(可能未指定):{characters_involved}",
"核心人物:{characters_involved}"
]
for placeholder in placeholder_variations:
if placeholder in final_prompt:
final_prompt = final_prompt.replace(
placeholder,
f"核心人物:\n{role_content_str}"
)
break
else: # 如果没有找到任何已知占位符变体
lines = final_prompt.split('\n')
for i, line in enumerate(lines):
if "核心人物" in line and "" in line:
lines[i] = f"核心人物:\n{role_content_str}"
break
final_prompt = '\n'.join(lines)
text_box.insert("0.0", final_prompt)
# 更新字数函数
def update_word_count(event=None):
text = text_box.get("0.0", "end-1c")
text_length = len(text)
wordcount_label.configure(text=f"字数:{text_length}")
text_box.bind("<KeyRelease>", update_word_count)
text_box.bind("<ButtonRelease>", update_word_count)
update_word_count() # 初始化统计
button_frame = ctk.CTkFrame(dialog)
button_frame.pack(pady=10)
def on_confirm():
@@ -236,6 +304,10 @@ def finalize_chapter_ui(self):
return
def task():
if not messagebox.askyesno("确认", "确定要定稿当前章节吗?"):
self.enable_button_safe(self.btn_finalize_chapter)
return
self.disable_button_safe(self.btn_finalize_chapter)
try:
interface_format = self.interface_format_var.get().strip()
@@ -385,7 +457,12 @@ def import_knowledge_handler(self):
self.handle_exception("导入知识库时出错")
finally:
self.enable_button_safe(self.btn_import_knowledge)
threading.Thread(target=task, daemon=True).start()
try:
thread = threading.Thread(target=task, daemon=True)
thread.start()
except Exception as e:
self.enable_button_safe(self.btn_import_knowledge)
messagebox.showerror("错误", f"线程启动失败: {str(e)}")
def clear_vectorstore_handler(self):
filepath = self.filepath_var.get().strip()
+12 -2
View File
@@ -33,14 +33,24 @@ def build_left_layout(self):
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_label = ctk.CTkLabel(self.left_frame, text="本章内容可编辑 字数:0", font=("Microsoft YaHei", 12))
self.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))
def update_word_count(event=None):
text = self.chapter_result.get("0.0", "end")
count = len(text) - 1 # 减去最后一个换行符
self.chapter_label.configure(text=f"本章内容(可编辑) 字数:{count}")
self.chapter_result.bind("<KeyRelease>", update_word_count)
self.chapter_result.bind("<ButtonRelease>", update_word_count)
# 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)
+116
View File
@@ -7,6 +7,8 @@ import traceback
import customtkinter as ctk
import tkinter as tk
from tkinter import filedialog, messagebox
from .role_library import RoleLibrary
from llm_adapters import create_llm_adapter
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
@@ -222,6 +224,120 @@ class NovelGeneratorGUI:
if selected_dir:
self.filepath_var.set(selected_dir)
def show_character_import_window(self):
"""显示角色导入窗口"""
import_window = ctk.CTkToplevel(self.master)
import_window.title("导入角色信息")
import_window.geometry("600x500")
import_window.transient(self.master) # 设置为父窗口的临时窗口
import_window.grab_set() # 保持窗口在顶层
# 主容器
main_frame = ctk.CTkFrame(import_window)
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
# 滚动容器
scroll_frame = ctk.CTkScrollableFrame(main_frame)
scroll_frame.pack(fill="both", expand=True, padx=5, pady=5)
# 获取角色库路径
role_lib_path = os.path.join(self.filepath_var.get().strip(), "角色库")
self.selected_roles = [] # 存储选中的角色名称
# 动态加载角色分类
if os.path.exists(role_lib_path):
# 配置网格布局参数
scroll_frame.columnconfigure(0, weight=1)
max_roles_per_row = 4
current_row = 0
for category in os.listdir(role_lib_path):
category_path = os.path.join(role_lib_path, category)
if os.path.isdir(category_path):
# 创建分类容器
category_frame = ctk.CTkFrame(scroll_frame)
category_frame.grid(row=current_row, column=0, sticky="w", pady=(10,5), padx=5)
# 添加分类标签
category_label = ctk.CTkLabel(category_frame, text=f"{category}",
font=("Microsoft YaHei", 12, "bold"))
category_label.grid(row=0, column=0, padx=(0,10), sticky="w")
# 初始化角色排列参数
role_count = 0
row_num = 0
col_num = 1 # 从第1列开始(第0列是分类标签)
# 添加角色复选框
for role_file in os.listdir(category_path):
if role_file.endswith(".txt"):
role_name = os.path.splitext(role_file)[0]
if not any(name == role_name for _, name in self.selected_roles):
chk = ctk.CTkCheckBox(category_frame, text=role_name)
chk.grid(row=row_num, column=col_num, padx=5, pady=2, sticky="w")
self.selected_roles.append((chk, role_name))
# 更新行列位置
role_count += 1
col_num += 1
if col_num > max_roles_per_row:
col_num = 1
row_num += 1
# 如果没有角色,调整分类标签占满整行
if role_count == 0:
category_label.grid(columnspan=max_roles_per_row+1, sticky="w")
# 更新主布局的行号
current_row += 1
# 添加分隔线
separator = ctk.CTkFrame(scroll_frame, height=1, fg_color="gray")
separator.grid(row=current_row, column=0, sticky="ew", pady=5)
current_row += 1
# 底部按钮框架
btn_frame = ctk.CTkFrame(main_frame)
btn_frame.pack(fill="x", pady=10)
# 选择按钮
def confirm_selection():
selected = [name for chk, name in self.selected_roles if chk.get() == 1]
self.char_inv_text.delete("0.0", "end")
self.char_inv_text.insert("0.0", ", ".join(selected))
import_window.destroy()
btn_confirm = ctk.CTkButton(btn_frame, text="选择", command=confirm_selection)
btn_confirm.pack(side="left", padx=20)
# 取消按钮
btn_cancel = ctk.CTkButton(btn_frame, text="取消", command=import_window.destroy)
btn_cancel.pack(side="right", padx=20)
def show_role_library(self):
save_path = self.filepath_var.get().strip()
if not save_path:
messagebox.showwarning("警告", "请先设置保存路径")
return
# 初始化LLM适配器
llm_adapter = create_llm_adapter(
interface_format=self.interface_format_var.get(),
base_url=self.base_url_var.get(),
model_name=self.model_name_var.get(),
api_key=self.api_key_var.get(),
temperature=self.temperature_var.get(),
max_tokens=self.max_tokens_var.get(),
timeout=self.timeout_var.get()
)
# 传递LLM适配器实例到角色库
if hasattr(self, '_role_lib'):
if self._role_lib.window and self._role_lib.window.winfo_exists():
self._role_lib.window.destroy()
self._role_lib = RoleLibrary(self.master, save_path, llm_adapter) # 新增参数
# ----------------- 将导入的各模块函数直接赋给类方法 -----------------
generate_novel_architecture_ui = generate_novel_architecture_ui
generate_chapter_blueprint_ui = generate_chapter_blueprint_ui
+44 -9
View File
@@ -55,9 +55,9 @@ def build_novel_params_area(self, start_row=1):
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) 本章指导
# 6) 内容指导
row_user_guide = 5
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="本章指导:", tooltip_key="user_guidance", row=row_user_guide, column=0, font=("Microsoft YaHei", 12), sticky="ne")
create_label_with_help_for_novel_params(self, 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")
@@ -67,8 +67,24 @@ def build_novel_params_area(self, start_row=1):
# 7) 可选元素:核心人物/关键道具/空间坐标/时间压力
row_idx = 6
create_label_with_help_for_novel_params(self, 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")
# 核心人物输入框+按钮容器
char_inv_frame = ctk.CTkFrame(self.params_frame)
char_inv_frame.grid(row=row_idx, column=1, padx=5, pady=5, sticky="nsew")
char_inv_frame.columnconfigure(0, weight=1)
char_inv_frame.rowconfigure(0, weight=1)
# 三行文本输入框
self.char_inv_text = ctk.CTkTextbox(char_inv_frame, height=60, wrap="word", font=("Microsoft YaHei", 12))
self.char_inv_text.grid(row=0, column=0, padx=(0,5), pady=5, sticky="nsew")
if hasattr(self, 'characters_involved_var'):
self.char_inv_text.insert("0.0", self.characters_involved_var.get())
# 导入按钮
import_btn = ctk.CTkButton(char_inv_frame, text="导入", width=60,
command=self.show_character_import_window,
font=("Microsoft YaHei", 12))
import_btn.grid(row=0, column=1, padx=(0,5), pady=5, sticky="e")
row_idx += 1
create_label_with_help_for_novel_params(self, 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))
@@ -85,20 +101,39 @@ def build_novel_params_area(self, start_row=1):
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.optional_btn_frame.columnconfigure((0, 1, 2, 3, 4), 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 = ctk.CTkButton(
self.optional_btn_frame, text="一致性审校", command=self.do_consistency_check,
font=("Microsoft YaHei", 12), width=100 # 固定宽度
)
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 = ctk.CTkButton(
self.optional_btn_frame, text="导入知识库", command=self.import_knowledge_handler,
font=("Microsoft YaHei", 12), width=100
)
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 = ctk.CTkButton(
self.optional_btn_frame, text="清空向量库", fg_color="red",
command=self.clear_vectorstore_handler, font=("Microsoft YaHei", 12), width=100
)
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 = ctk.CTkButton(
self.optional_btn_frame, text="查看剧情要点", command=self.show_plot_arcs_ui,
font=("Microsoft YaHei", 12), width=100
)
self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew")
# 新增角色库按钮
self.role_library_btn = ctk.CTkButton(
self.optional_btn_frame, text="角色库", command=self.show_role_library,
font=("Microsoft YaHei", 12), width=100
)
self.role_library_btn.grid(row=0, column=4, padx=5, pady=5, sticky="ew")
def create_label_with_help_for_novel_params(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5):
frame = ctk.CTkFrame(parent)
frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky)
+1528
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -15,12 +15,23 @@ def build_setting_tab(self):
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")
self.setting_word_count_label = ctk.CTkLabel(self.setting_tab, text="字数:0", font=("Microsoft YaHei", 12))
self.setting_word_count_label.grid(row=0, column=1, 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")
save_btn.grid(row=0, column=2, 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)
self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3)
def update_word_count(event=None):
text = self.setting_text.get("0.0", "end")
count = len(text) - 1
self.setting_word_count_label.configure(text=f"字数:{count}")
self.setting_text.bind("<KeyRelease>", update_word_count)
self.setting_text.bind("<ButtonRelease>", update_word_count)
def load_novel_architecture(self):
filepath = self.filepath_var.get().strip()
+14 -2
View File
@@ -11,17 +11,29 @@ 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)
self.summary_tab.columnconfigure(1, weight=0)
self.summary_tab.columnconfigure(2, weight=0)
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")
self.word_count_label = ctk.CTkLabel(self.summary_tab, text="字数:0", font=("Microsoft YaHei", 12))
self.word_count_label.grid(row=0, column=1, 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")
save_btn.grid(row=0, column=2, 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)
self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3)
def update_word_count(event=None):
text = self.summary_text.get("0.0", "end")
count = len(text) - 1
self.word_count_label.configure(text=f"字数:{count}")
self.summary_text.bind("<KeyRelease>", update_word_count)
self.summary_text.bind("<ButtonRelease>", update_word_count)
def load_global_summary(self):
filepath = self.filepath_var.get().strip()
if not filepath: