From c99601fe1048807ba9cacfc32cd7909c10bbcb01 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 29 Jan 2025 20:33:20 +0800 Subject: [PATCH 001/142] Initial commit --- .gitattributes | 2 + AI_novel/Novel_directory.txt | 101 ++++++ AI_novel/Novel_setting.txt | 36 ++ AI_novel/chapter.txt | 72 ++++ AI_novel/character_state.txt | 0 AI_novel/global_summary.txt | 0 AI_novel/lastchapter.txt | 71 ++++ __pycache__/config_manager.cpython-310.pyc | Bin 0 -> 969 bytes .../consistency_checker.cpython-310.pyc | Bin 0 -> 1421 bytes __pycache__/novel_generator.cpython-310.pyc | Bin 0 -> 9107 bytes .../prompt_definitions.cpython-310.pyc | Bin 0 -> 5291 bytes __pycache__/ui.cpython-310.pyc | Bin 0 -> 10499 bytes __pycache__/utils.cpython-310.pyc | Bin 0 -> 2480 bytes config.json | 10 + config_manager.py | 21 ++ consistency_checker.py | 51 +++ main.py | 11 + novel_generator.py | 317 +++++++++++++++++ prompt_definitions.py | 129 +++++++ ui.py | 329 ++++++++++++++++++ utils.py | 51 +++ 21 files changed, 1201 insertions(+) create mode 100644 .gitattributes create mode 100644 AI_novel/Novel_directory.txt create mode 100644 AI_novel/Novel_setting.txt create mode 100644 AI_novel/chapter.txt create mode 100644 AI_novel/character_state.txt create mode 100644 AI_novel/global_summary.txt create mode 100644 AI_novel/lastchapter.txt create mode 100644 __pycache__/config_manager.cpython-310.pyc create mode 100644 __pycache__/consistency_checker.cpython-310.pyc create mode 100644 __pycache__/novel_generator.cpython-310.pyc create mode 100644 __pycache__/prompt_definitions.cpython-310.pyc create mode 100644 __pycache__/ui.cpython-310.pyc create mode 100644 __pycache__/utils.cpython-310.pyc create mode 100644 config.json create mode 100644 config_manager.py create mode 100644 consistency_checker.py create mode 100644 main.py create mode 100644 novel_generator.py create mode 100644 prompt_definitions.py create mode 100644 ui.py create mode 100644 utils.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/AI_novel/Novel_directory.txt b/AI_novel/Novel_directory.txt new file mode 100644 index 0000000..9d27345 --- /dev/null +++ b/AI_novel/Novel_directory.txt @@ -0,0 +1,101 @@ + +第1章 :意外的穿越 +第2章 :提瓦特的初印象 +第3章 :神秘的元素之力 +第4章 :初识伙伴 +第5章 :探索孤独的心灵 +第6章 :冲突的种子 +第7章 :寻找归属感 +第8章 :揭开旧生活的面纱 +第9章 :伊尔的内心战争 +第10章 :莱娜的隐秘之谜 +第11章 :不同理念的碰撞 +第12章 :信任的考验 +第13章 :元素的传承 +第14章 :国家的危机 +第15章 :历史的回响 +第16章 :离别的痛苦 +第17章 :意外的救助 +第18章 :二人的矛盾升级 +第19章 :莱娜的秘密身份 +第20章 :勇气与脆弱 +第21章 :信仰的丧失 +第22章 :敌人的阴影 +第23章 :团结的力量 +第24章 :心灵的契约 +第25章 :遭遇的考验 +第26章 :寻找真相的旅程 +第27章 :伊尔的困惑与苦痛 +第28章 :希望的曙光 +第29章 :道德的抉择 +第30章 :揭露阴谋的关键 +第31章 :危机的加深 +第32章 :莱娜的征途 +第33章 :生与死的抉择 +第34章 :攀登的决心 +第35章 :遭遇叛徒 +第36章 :难忘的过往 +第37章 :勇敢面对内心 +第38章 :追寻信念的光芒 +第39章 :重聚的温暖 +第40章 :绝境中的选择 +第41章 :心灵的感召 +第42章 :勇气的火焰 +第43章 :愈合的力量 +第44章 :两个世界的裂痕 +第45章 :莱娜的使命 +第46章 :面对过去的抉择 +第47章 :背后的黑手 +第48章 :团结的契机 +第49章 :意外的力量觉醒 +第50章 :发现真相的第一步 +第51章 :感情的纠葛 +第52章 :信任重建 +第53章 :旧敌的再现 +第54章 :希望与绝望 +第55章 :勇敢的寡言者 +第56章 :天理的试炼 +第57章 :未知的恐惧 +第58章 :内心的挣扎 +第59章 :辉煌的重现 +第60章 :绝地反击 +第61章 :宿命的交汇 +第62章 :与过去的和解 +第63章 :冲突的顶点 +第64章 :理想与现实的抉择 +第65章 :命运的交错 +第66章 :成长的代价 +第67章 :星的决定 +第68章 :失去的痛苦 +第69章 :最后的希望 +第70章 :勇气之源 +第71章 :荣耀之战 +第72章 :爱的牺牲 +第73章 :背负随行 +第74章 :重塑信仰 +第75章 :群星的聚集 +第76章 :命运的逆转 +第77章 :决战的前夜 +第78章 :承载的力量 +第79章 :归者的使命 +第80章 :能否拯救一切 +第81章 :灵魂的救赎 +第82章 :心灵的碰撞 +第83章 :胜利与失败 +第84章 :被遗忘的记忆 +第85章 :阳光再现 +第86章 :信念的拥抱 +第87章 :重生的希望 +第88章 :抉择的时刻 +第89章 :命运的启示 +第90章 :最后的决策 +第91章 :逆光而行 +第92章 :双重的身份 +第93章 :献出所有的希望 +第94章 :心的归宿 +第95章 :重聚与亲密 +第96章 :与敌的对峙 +第97章 :星的觉醒 +第98章 :平衡的重现 +第99章 :团结的结晶 +第100章 :新世界的曙光 \ No newline at end of file diff --git a/AI_novel/Novel_setting.txt b/AI_novel/Novel_setting.txt new file mode 100644 index 0000000..84c7cd1 --- /dev/null +++ b/AI_novel/Novel_setting.txt @@ -0,0 +1,36 @@ + +小说设定 + +小说名称: 星陨提瓦特 + +总字数走向: 约40万字,分为100章,每章约4000字。整体走向为主角经历成长、探索与冲突,逐渐揭开提瓦特大陆的秘密与危机,最终实现拯救与情感的圆满。 + +小说类型与基调: 本作为玄幻、穿越与冒险题材,主线围绕丰富的爱恨情仇展展开。基调相对轻松,同时穿插挑战与深思,旨在营造出一种既有趣又引人深思的氛围。 + +写作风格: 采用细腻、抒情的写作风格,重点描绘角色内心的探索与情感。叙事视角主要为第三人称全知视角,辅以第一人称的内心独白,以增强角色深度。 + +整体世界观: 故事设定在提瓦特大陆,这是一个拥有七个不同国度和独特元素力量的神秘幻想世界。历史与传说丰富,比如“天理之战”及各个神祇的传奇故事。科技与魔法并存,元素魔法高度发达,社会结构以王国与神明为核心,复杂的势力交错使人民生活受其影响。 + +核心内容梗概: 故事采用英雄之旅结构,主角星因意外穿越到提瓦特,面临神明失去信仰、元素失衡,国家动荡的空前危机。在寻找回家的过程中,星结识了来自不同国家的伙伴,体验各地人文风情,并逐渐揭示穿越的真相。最终,星与伙伴们合力对抗共同的敌人,重塑世界的平衡,也收获真挚的情感。 + +情节安排设想: 主线围绕星的成长与拯救提瓦特的冒险分为三个阶段:第一阶段是星的穿越与初识,以及角色冲突的引入;第二阶段是与伙伴的深入互动,逐步揭露敌人阴谋及情感发展;第三阶段是最终决战与角色命运交织。副线聚焦其他角色的故事,展示他们与星的命运交织,为故事营造丰富的世界感。 + +角色关系与主要角色设定: + +1. 星(主角): 一名勇敢、好奇且善良的年轻人,满怀探索未知的欲望,面临对归属感的挣扎。在拯救提瓦特的过程中,她渴望成为真正的英雄,同时也处理与伙伴间复杂的情感关系。 + +2. 伊尔(冷静的魔法师): 理智、谨慎,拥有强大师法的他在经历失败后变得冷淡,内心深处隐藏痛苦。希望能通过知识与能力解决危机,努力解脱自己的内心痛楚。与星的理想与现实相悖,易引发冲突。 + +3. 莱娜(隐秘的元素使者): 神秘而强大的她深谙元素魔法,在寻找与星的关系中,探索自己的身份之谜。与伊尔之间存在信任危机,随着故事发展,二人将面临信任与情感的考验。 + +暗线与伏笔构思: + +1. 星的过往: 星对旧生活的怀念与痛苦逐渐显现,推动她在救助他人和回归原生活之间的抉择,进而引发与伙伴之间的深入讨论。 + +2. 伊尔的秘密: 伊尔对过去的隐瞒以及与敌对势力的牵连,在关键时刻暴露,导致团队关系动摇,让星更深刻地理解他的痛苦。 + +3. 莱娜的使命: 莱娜的神秘身份及意图逐步显露,最终必须面对身份的真实,仅在关键时刻,有赖于星与伊尔的支持,坚定她内心的归属感。 + +4. 星与伊尔的理念冲突: 星与伊尔在道德取向上发生摩擦,在选择重大事件时,两人的冲突达到顶峰,最终通过事件的解决促进彼此之间的理解与成长。 + +在这些设定中,角色的动机与暗线及伏笔相互交织,为整个故事增添了复杂性与紧张感,确保每一位角色的成长与情感发展贯穿始终,丰富了提瓦特大陆的冒险传奇。 \ No newline at end of file diff --git a/AI_novel/chapter.txt b/AI_novel/chapter.txt new file mode 100644 index 0000000..075f280 --- /dev/null +++ b/AI_novel/chapter.txt @@ -0,0 +1,72 @@ + +星缓缓睁开眼睛,意识到自己正躺在一片陌生的土地上。四周是葱茏的树木,阳光透过树叶洒下斑驳的光影,微风轻拂,带来一丝清新的气息。她试图站起身来,身体却感到无力,仿佛刚经历了一场翻天覆地的梦境。迷茫与恐惧瞬间涌上心头——她究竟身处何地? + +“别 panicking,星。”她自言自语,努力让自己冷静下来。可无论她怎么深呼吸,心中的慌乱与不安依旧如影随形。刚才她还在自己的家中,思绪被一扇打开的窗户吸引,目光追随着飞过的鸟儿,突然遭遇了一道神秘的光芒,她的意识就在那瞬间被撕扯了开来。而现在,她却身处于这个陌生的世界,这一切都让她感到无比的恐慌。 + +尝试坐起身来,星环顾四周,发现这片森林似乎与她的记忆截然相反。树木高耸入云,树干粗壮而苍翠,叶子则在微风中轻轻摇晃,发出沙沙的声响。然而,这些美丽的自然景象并没有为她带来一丝安慰——相反,她觉得一种不安的气息弥漫在空气中,仿佛这片森林隐藏着某种不为人知的秘密。 + +星的心中掠过一丝奇异的感觉,似乎这片土地上有着某种她无法理解的力量在流动。她强迫自己站起身,决定探索这个世界。若想办法找到回家的路,先得了解这个地方。她开始慢慢前行,脚下的土地干燥而坚硬,偶尔踩到几根枯枝,发出清脆的声响。 + +随着深入森林,她注意到周围的植物似乎散发着微弱的光芒,仿佛在回应着她的存在。星试图触摸那些植物,感受那股能量,却发现它们在她的手指间划过时变成了莹莹的光点,瞬间就消散了。她心中一紧,这显然不是普通的植物,她的直觉告诉她,自己可能身处于一个充满魔法的地方。 + +就在这时,她的思绪被一阵低语声打断。星停下脚步,四处张望,试图找到声音的来源。声音似乎从四面八方传来,但却又似乎是从某个深渊中回荡而出,朦胧而模糊。 + +“是谁在说话?”星喃喃自语,然而没有人回应。她心中浮现出一丝不安,迫切想要找到声音的源头。正当她准备继续探索时,眼前突然闪过一道亮光。她的心一颤,抬头看去,透过树木的缝隙,她隐约看到一个身影,似乎在远处的空旷地带移动。 + +出于好奇,星忍不住向前走去,穿越过一片茂密的灌木丛。终于,她走出林间,来到了一个开阔的空地上。眼前的一幕让她惊讶不已——一座宏伟的法阵正镶嵌于地面,阵法复杂而美丽,闪烁着微光,似乎在向她召唤。 + +“这是什么?”星自言自语,心中充满了疑惑与好奇。她走到法阵边缘,感觉到一股温暖的气流从阵法中透出。星的手指微微颤动,不由自主地伸向了那个法阵,触碰到了那些复杂的符文。瞬间,她感到一阵剧烈的震动,法阵光芒大盛,星的身体被卷入其中,意识在瞬间模糊而又清晰。 + +“啊!”她惊呼,仿佛被一股无形的力量撕扯着。整个世界都在颠覆,光芒刺眼,四周的景象如同狂风中的飘絮般不断变换,星感到自己像是随风而舞的叶子,无法控制。就在意识即将消散的瞬间,她听到了那个低语声,竟然清晰而响亮:“命运的选择。” + +然后,星失去了意识。 + +当她再次睁开眼睛时,发现自己坐在一块石头上,周围是一片开阔的草地,远处是连绵起伏的山脉,天空湛蓝,阳光温暖,仿佛一切都是那么美好。可在她心中,却依然残留着那股恐惧与不安。 + +“这是哪里?”她自言自语,四处打量。就在这时,一个阴影笼罩了她,星抬头一看,面前站着一个高大的男子,身穿长袍,手中握着法杖,面容冷峻,眼神中透着智慧与审视。 + +“你是谁?”星有些警惕地问道,虽然心中已经有了几分预感。 + +“我是伊尔,一个魔法师。”男子的声音低沉而有力,似乎带着某种压迫感,“你应该是从那个法阵中被传送过来的吧?” + +星点点头,虽然内心依旧充满困惑,但她知道自己必须与眼前这个陌生人好好交流。“是的,我…我不知道自己为什么来到这里。” + +伊尔微微皱眉,目光在她身上扫视,似乎在打量她的言语是否可信。“这片土地并不安全,你的到来可不是什么偶然。元素的平衡正在崩溃,神明的意志也逐渐淡去。” + +“神明的意志?”星困惑地问道,心中浮现出关于提瓦特的传说与故事。她从小就听说过这个富饶而神秘的大陆,但从未想过自己会置身其中。 + +“没错,”伊尔坚定地点头,“提瓦特大陆上,七位神明各自守护着一个国度,而你在这里的出现,可能与某种更大的命运息息相关。” + +“我只想回家。”星低声说道,心中依然渴望着熟悉的生活,尽管她也感受到这个新的世界是如此吸引人。 + +“回家并不是那么简单。”伊尔的声音中透着冷静,他的眼神中闪烁着某种复杂的情感,“在这片土地上,你必须要面对许多你无法想象的挑战。” + +星感到了一丝绝望,她并不想成为一名英雄,自己不过是一名普通的年轻人。“我……我能做些什么?” + +“或许我们可以一起探索这个世界。”伊尔的语气缓和了一些,似乎对她产生了一丝信任,“我对魔法的研究或许能帮助你找到线索。” + +“谢谢你,伊尔。”星感激地点头,虽然她对眼前的陌生人依然有些顾虑,但她知道自己必须要有人陪伴,才能在这个陌生的世界中生存下去。 + +就在这时,草地的另一侧,一道神秘的光芒闪过,随即出现了另一个身影。那是一位少女,长发披肩,身穿轻盈的衣袍,眼神深邃而神秘,她似乎在默默地注视着星与伊尔。 + +“你是……”星愣了一下,有些惊讶。 + +“我叫莱娜。”少女轻声说道,她的声音如同空灵的风铃,给人一种奇特的亲切感,“我感觉到了你的到来,希望能和你们一起探索这个世界。” + +“你也感知到了?”伊尔微微皱眉,似乎对莱娜的出现有些意外,却又没有质疑的意思。 + +“是的。”莱娜的眼神透着某种不易察觉的情感,“我对自然元素有着强烈的感应,能够感受到世界的变化。” + +星心中一震,莱娜的出现让她感到了一丝安慰,也增添了对这个世界的探索欲望。“或许我们可以一起合作,找出这个世界的秘密。” + +莱娜微微一笑,仿佛认可了星的提议。“我相信,我们的命运将会交织在一起。” + +三人之间逐渐形成了一种微妙的默契。尽管星对这个新世界的未来仍感到模糊不清,内心的惶恐也未完全消散,但在伊尔与莱娜的陪伴下,她逐渐意识到自己不再孤单。接下来,等待她的将是未知的冒险,她必须做好面对的准备。 + +正当星准备问伊尔关于这个世界的更多事情时,突然,一股强烈的能量波动猛然袭来,她耳边仿佛听到了那道低语的声音再度浮现,清晰而坚定:“命运的选择,终于来临!” + +星的心脏骤然加速,四周的空气似乎在那一瞬间凝固。她抬头,看到远处的天空中,一片阴云迅速聚集而来,预示着某种危险的逼近。一个模糊的影像在天际闪现,似乎正暗示着未来的冲突与挑战,而她所要面对的,绝不仅仅是对自我归属的探索。 + +“我们要走!”伊尔的声音打断了星的思绪,他的目光变得凝重,“不可忽视的危机即将降临,我们必须立刻找到安全的地方!” + +星与莱娜对视一眼,心中都涌起一股紧迫感。对即将来临的挑战,三人并肩而立,准备迎接未知的未来。此刻,她们的命运已经交织在了一起,故事的篇章才刚刚开启,而未来的旅程充满了未知与悬念。 \ No newline at end of file diff --git a/AI_novel/character_state.txt b/AI_novel/character_state.txt new file mode 100644 index 0000000..e69de29 diff --git a/AI_novel/global_summary.txt b/AI_novel/global_summary.txt new file mode 100644 index 0000000..e69de29 diff --git a/AI_novel/lastchapter.txt b/AI_novel/lastchapter.txt new file mode 100644 index 0000000..8b609e7 --- /dev/null +++ b/AI_novel/lastchapter.txt @@ -0,0 +1,71 @@ +星缓缓睁开眼睛,意识到自己正躺在一片陌生的土地上。四周是葱茏的树木,阳光透过树叶洒下斑驳的光影,微风轻拂,带来一丝清新的气息。她试图站起身来,身体却感到无力,仿佛刚经历了一场翻天覆地的梦境。迷茫与恐惧瞬间涌上心头——她究竟身处何地? + +“别 panicking,星。”她自言自语,努力让自己冷静下来。可无论她怎么深呼吸,心中的慌乱与不安依旧如影随形。刚才她还在自己的家中,思绪被一扇打开的窗户吸引,目光追随着飞过的鸟儿,突然遭遇了一道神秘的光芒,她的意识就在那瞬间被撕扯了开来。而现在,她却身处于这个陌生的世界,这一切都让她感到无比的恐慌。 + +尝试坐起身来,星环顾四周,发现这片森林似乎与她的记忆截然相反。树木高耸入云,树干粗壮而苍翠,叶子则在微风中轻轻摇晃,发出沙沙的声响。然而,这些美丽的自然景象并没有为她带来一丝安慰——相反,她觉得一种不安的气息弥漫在空气中,仿佛这片森林隐藏着某种不为人知的秘密。 + +星的心中掠过一丝奇异的感觉,似乎这片土地上有着某种她无法理解的力量在流动。她强迫自己站起身,决定探索这个世界。若想办法找到回家的路,先得了解这个地方。她开始慢慢前行,脚下的土地干燥而坚硬,偶尔踩到几根枯枝,发出清脆的声响。 + +随着深入森林,她注意到周围的植物似乎散发着微弱的光芒,仿佛在回应着她的存在。星试图触摸那些植物,感受那股能量,却发现它们在她的手指间划过时变成了莹莹的光点,瞬间就消散了。她心中一紧,这显然不是普通的植物,她的直觉告诉她,自己可能身处于一个充满魔法的地方。 + +就在这时,她的思绪被一阵低语声打断。星停下脚步,四处张望,试图找到声音的来源。声音似乎从四面八方传来,但却又似乎是从某个深渊中回荡而出,朦胧而模糊。 + +“是谁在说话?”星喃喃自语,然而没有人回应。她心中浮现出一丝不安,迫切想要找到声音的源头。正当她准备继续探索时,眼前突然闪过一道亮光。她的心一颤,抬头看去,透过树木的缝隙,她隐约看到一个身影,似乎在远处的空旷地带移动。 + +出于好奇,星忍不住向前走去,穿越过一片茂密的灌木丛。终于,她走出林间,来到了一个开阔的空地上。眼前的一幕让她惊讶不已——一座宏伟的法阵正镶嵌于地面,阵法复杂而美丽,闪烁着微光,似乎在向她召唤。 + +“这是什么?”星自言自语,心中充满了疑惑与好奇。她走到法阵边缘,感觉到一股温暖的气流从阵法中透出。星的手指微微颤动,不由自主地伸向了那个法阵,触碰到了那些复杂的符文。瞬间,她感到一阵剧烈的震动,法阵光芒大盛,星的身体被卷入其中,意识在瞬间模糊而又清晰。 + +“啊!”她惊呼,仿佛被一股无形的力量撕扯着。整个世界都在颠覆,光芒刺眼,四周的景象如同狂风中的飘絮般不断变换,星感到自己像是随风而舞的叶子,无法控制。就在意识即将消散的瞬间,她听到了那个低语声,竟然清晰而响亮:“命运的选择。” + +然后,星失去了意识。 + +当她再次睁开眼睛时,发现自己坐在一块石头上,周围是一片开阔的草地,远处是连绵起伏的山脉,天空湛蓝,阳光温暖,仿佛一切都是那么美好。可在她心中,却依然残留着那股恐惧与不安。 + +“这是哪里?”她自言自语,四处打量。就在这时,一个阴影笼罩了她,星抬头一看,面前站着一个高大的男子,身穿长袍,手中握着法杖,面容冷峻,眼神中透着智慧与审视。 + +“你是谁?”星有些警惕地问道,虽然心中已经有了几分预感。 + +“我是伊尔,一个魔法师。”男子的声音低沉而有力,似乎带着某种压迫感,“你应该是从那个法阵中被传送过来的吧?” + +星点点头,虽然内心依旧充满困惑,但她知道自己必须与眼前这个陌生人好好交流。“是的,我…我不知道自己为什么来到这里。” + +伊尔微微皱眉,目光在她身上扫视,似乎在打量她的言语是否可信。“这片土地并不安全,你的到来可不是什么偶然。元素的平衡正在崩溃,神明的意志也逐渐淡去。” + +“神明的意志?”星困惑地问道,心中浮现出关于提瓦特的传说与故事。她从小就听说过这个富饶而神秘的大陆,但从未想过自己会置身其中。 + +“没错,”伊尔坚定地点头,“提瓦特大陆上,七位神明各自守护着一个国度,而你在这里的出现,可能与某种更大的命运息息相关。” + +“我只想回家。”星低声说道,心中依然渴望着熟悉的生活,尽管她也感受到这个新的世界是如此吸引人。 + +“回家并不是那么简单。”伊尔的声音中透着冷静,他的眼神中闪烁着某种复杂的情感,“在这片土地上,你必须要面对许多你无法想象的挑战。” + +星感到了一丝绝望,她并不想成为一名英雄,自己不过是一名普通的年轻人。“我……我能做些什么?” + +“或许我们可以一起探索这个世界。”伊尔的语气缓和了一些,似乎对她产生了一丝信任,“我对魔法的研究或许能帮助你找到线索。” + +“谢谢你,伊尔。”星感激地点头,虽然她对眼前的陌生人依然有些顾虑,但她知道自己必须要有人陪伴,才能在这个陌生的世界中生存下去。 + +就在这时,草地的另一侧,一道神秘的光芒闪过,随即出现了另一个身影。那是一位少女,长发披肩,身穿轻盈的衣袍,眼神深邃而神秘,她似乎在默默地注视着星与伊尔。 + +“你是……”星愣了一下,有些惊讶。 + +“我叫莱娜。”少女轻声说道,她的声音如同空灵的风铃,给人一种奇特的亲切感,“我感觉到了你的到来,希望能和你们一起探索这个世界。” + +“你也感知到了?”伊尔微微皱眉,似乎对莱娜的出现有些意外,却又没有质疑的意思。 + +“是的。”莱娜的眼神透着某种不易察觉的情感,“我对自然元素有着强烈的感应,能够感受到世界的变化。” + +星心中一震,莱娜的出现让她感到了一丝安慰,也增添了对这个世界的探索欲望。“或许我们可以一起合作,找出这个世界的秘密。” + +莱娜微微一笑,仿佛认可了星的提议。“我相信,我们的命运将会交织在一起。” + +三人之间逐渐形成了一种微妙的默契。尽管星对这个新世界的未来仍感到模糊不清,内心的惶恐也未完全消散,但在伊尔与莱娜的陪伴下,她逐渐意识到自己不再孤单。接下来,等待她的将是未知的冒险,她必须做好面对的准备。 + +正当星准备问伊尔关于这个世界的更多事情时,突然,一股强烈的能量波动猛然袭来,她耳边仿佛听到了那道低语的声音再度浮现,清晰而坚定:“命运的选择,终于来临!” + +星的心脏骤然加速,四周的空气似乎在那一瞬间凝固。她抬头,看到远处的天空中,一片阴云迅速聚集而来,预示着某种危险的逼近。一个模糊的影像在天际闪现,似乎正暗示着未来的冲突与挑战,而她所要面对的,绝不仅仅是对自我归属的探索。 + +“我们要走!”伊尔的声音打断了星的思绪,他的目光变得凝重,“不可忽视的危机即将降临,我们必须立刻找到安全的地方!” + +星与莱娜对视一眼,心中都涌起一股紧迫感。对即将来临的挑战,三人并肩而立,准备迎接未知的未来。此刻,她们的命运已经交织在了一起,故事的篇章才刚刚开启,而未来的旅程充满了未知与悬念。 \ No newline at end of file diff --git a/__pycache__/config_manager.cpython-310.pyc b/__pycache__/config_manager.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c8224eae26bcaf179ade542453c1ea2acf21ca28 GIT binary patch literal 969 zcmY*Y&rcIU6rP!#E-MABXhPyad(cA8Wcv~FE?o0(ZaQWB$} z1gUZ`5y2mjsDT5>k)VXTf5Kkr(sJ_70i4;kh;Q@WzV~MG-rMhc>$bPI0x64K3;Ev& zfL#@w6kP^4rSe{B2q4e~srnnHAVCxnjcPeeFo{g!6ec>=8reurCoRN~S)ITEkT|t3 zD}baQ&ALv`%4c$xO?{nF;V~x*)mcO27{HKvTPlAjjQ|C>gi2r;vhMM?&`NR)A{&>r zw;dBu;H5|jF2f;!0Kphw@Ir%N2n>L0fL|Pi5UA&Y)FEKWY%^BA(nQ4TmrW`^-|Wr4 z3f32@3zP8^BgTW7rP}uP{#3cTz5eIdZ0+^8%4RuOn-4y&1k)dCJMV(U&(+nfVC{V{ zwYmH3xewVv#1pyh>#NW=sFQVx<>ZqI-A69>anTfyeVvY3T*$~RQpd*y?n-OBCP^Ts zKHrDARlrV5@1Q5!lfK6(J~#MABntRLdqW#1v*$4bc=$I>&t6m%;dD0ZQJzz=Gtbgwz@ky^Iysq~#wM#0c5A@IMIUjn|e}s$aME=6?ip-}k2Hf|)NNC!;JPOEb03eftWF z1WL70hkJ}>OrEtYU$-1W9g!SNXe_Gkwn^h-;tdyNMj;nc!#b3wsHvNnx)tT-2yFnZ z&O@LPiotWR^+-ptLmlZ*hpbDUC0fGDOhM{vTrkt?{;k6Sj!e(I~qj%UP To=tD4|Angx1~6bJI^X&i*J=vE literal 0 HcmV?d00001 diff --git a/__pycache__/consistency_checker.cpython-310.pyc b/__pycache__/consistency_checker.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a88e555f7ab805fbbc93d311037f85ba990955e0 GIT binary patch literal 1421 zcmZux%}*Og6yF`MKUh9Os(z^_j`g9v^j1|#DXl6Fg{Y*5Ql!xVCBf@n%Vi(`HuhX=mrnd%xfNSfjO7 z7GUkZk&Ba{AUx)q#?=m+4?)b2Kxsn1potnviYe5OL_rJm2Q&#wS`Z`z5(bHYG=az< z%~-nFuC-{b<5GW6YwHuDr!sTkNM@DA+WJGfp6iTb-D1wFw_C36-K*usA31qiDzZY+ zKY8S=9#v*(d7tGSwzOTlTkx)rd50@rakf@+X!a)DsO~x5+)>_EFuNsjq$yq3HI!SKjeT2eg!{?#(rVV#{T^utD!F)BF}&o@2`g&rnP)Rrj*T z7_0j;bbXu?+8AT`Y4A^{ZqdnyN0prXPDi72{^e`kxQvsEi7hLk$46u^@ixElrXSR< z|HPcpqskQU@~-|uXD8^w-TGt}s8tV(Y_xn-nf4vT234ZQEKC&BQZ4Mepc7m4zQb-T zgSBrIPZ}3g2$M;rR5I+_H1eX?CTMB@xw|xXX5lLq*l`kT`D4ca=L#%=A7l%Xaa391 zxtaQwe>q&FD{D|?cB{~sC#W>9h1>c9YxmTDIJj*u;&ofV;9pd3SWPFCAw29xE~q9} zG9>BBDMN!a>S_wRA%d+8(PLp=@n?(oe+Z-t#5@Da5-y3hV2d`g1DcqXM#2`dL$+iG zHKYY#AF(5afE_N1BTaUb`IaX233k&ZsSqp*(BZ+%SKvhMfaPsptu1b`ts-5Up`*E% zbnNpM6ojs4qapW|uHK%$?!NP%^mKixocp}@ zvvcR&&_#o!REr?)D3s7I8$;L)#|+(q$#sLKMH1<#{4z)<07#R6IZcdI+9J(-6Cv14 z8@h>a3xp$jayItTzhTgsvFyM^FNS2E`2ez?N#Zl`2NBG-K}kZ3Bq4Mf;{J^g a`U?fnpGZPaQ4swBXr7=j`W;2ue)|W)Vpy;M literal 0 HcmV?d00001 diff --git a/__pycache__/novel_generator.cpython-310.pyc b/__pycache__/novel_generator.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ad9ee7b5b20126c33296ccf8eaf312bd9c013b7 GIT binary patch literal 9107 zcmb7JYmgk(b)J6AV|yMuJNwj=F&M!JkZr)R1F{T8gpgFN5`;<8ku!|u_O3>n7q@%# z*z{Jd&;xED@BPl>o^!gEOll$gwvU`1|M#zjLLX71^EZITL%7VHQYgeiOe%+Ty31v$ zBI~k5b)~FSR9zKywH&TQ^hhPDM=LQsCTQVuPes!;QIC}4m4u$CB=uw^rKc*rdas~I z%jrs=-dD-!nM%LjUm4H`Dueo9Wk?^Y4C}*{5q+exOkXDY#>&epEA$nj-cw##S*5QM zb*;R*a*KWo>Tx~$ScoN9a({>=4SAoUe}$!tTUjrzG_F2e8C?CwZEOJ7pmFP1nhmkx z{i^;|(R0MO-MDpGh%GZ#jYZgUwgOMqh$lo**-Ewwlsg1vHAbuK7M2C&PIix=-HO?7 z!_3R@EXKadZr>l)b8HQ$ci{5ZawqCJ_x)lWYtg0=1MDt#H)dPQq|Kq+y6eP|(VXP; zJh`WCu#Kgng@*RXwt}^(Zd4zBJf}F?W~*QskMTl%n-kvr#+bFPEx@>63=XO4UNSG=ZrM%c8lw(L>eRPNSS> zC2kb08sFp7Q)Z)5DYVoquxE{-Fw!DmOc zx#4804eS8B9wT4~0Mm)@s&STgAE1MP*9U!T!8Dw{4h&spI9i)yPQPGPe!)Pew~I}q zxrh_X=c|Q^k}-iw|u1zKPXu*ANkqqKp?j4Chy6SlbP!0z4ly80fz9MjNc zlBv={F=^pHA&DMNv`{bQcNlw|7@?4F@UqiWE>~#fMwzdmwXCFK6&0&dI5AR{dcoRO zjG^iOhEWOQBJP{Nf>E?Wwrq!NiAhJ5=V2HvX-s}DG$t{HsrwU?u?|X@ML_B4phQ^= z6s?2O!!%Ih9h5jrfReB<^FC=ZX(y)@mb7CtFutOmvU`GhudN03v>gxXeRjgDt5%pL z{W?psR8UW`-k{#g(m_4V`ht3&74hoPnb2g)R&3P{+Yy#It4{XXsp**AJ0ne|?X>x# z)x-MtNifC(n?rVbT4RH3Xhxpw6ZK(H&)9wZXLiQCfW9MsUn{;nF`cxdY#Dm@2d(93 z4cL9tDYk;f;=WSMF=%HN(T1#Ed(iIR2ZT@?vInR}>&UHYYy#i3EPk`U`*7~13(a$< zTW_3i{bujng~K0TJ2LmuPg{phWQElEwrU-WUUv5KF^?M9Dx!;-V12``7~PG@`8_C{ zu&_@>$Vc!aiHYFq$7TK*3M;f-vT>v?3!#)nsfbd=!4p2JPD+PD)3PlAQQ4Nwl`O)d zB^(GWV$0i=X>}$L7v>47M8;~oQm~vzsk*ba!*HU$-N*8I zseV!6-w@qAYaQc;S%(fA7bX66JaAR`F6uJq>7g8FSZ{v~BOI3>a|If6D3LQ;J z6W?)Rc6B;{HSf=~Zhg5{ER@X+>wLfG-Et8K*T}bwmZVDuo1xSWR z1wJ?olJ26sZSX8+lKmhO_CY7Z?{AqQUp+23P|>NmT`{$P*oW8^Psf`?E{ zJou%z)ShM0UdZqgi%puk&Eih=Vd_z(i`~l@46k5!cmcbkF1uas()M$T;D4LIZw1u{ zrI+eA3(8R7@hp&}CoqSZK@lj#=UUif{)U@HYU!yLfu-U#0;~s6=?04_N98V8%@d_3#g!aH->xvM{Hy?S=`@|C%ZSAw&ldG77` zch7$`^{j`=eKZ9j(Ri#qg?o&+T|d5ddhXhl=B1O_B|NKa>(Hre7YLYpqR-u~XMgCV zndo;(0air(RJzTP*5=ZjY@hIRIB{K%6&QR#cs6d$VB-cS-Qjfpkd?30;oArU=7j5T z?kz_dH!Mf4nT|>>kE3p{m8wpZ8})LbXz(74%MJ73eB*@Ik_QZe$j}Y3*2Ci>Kv58X z=!sIb-as6I@S$Ql5u7cJvL(bzAMAuG*s-1{dJ80IOusTxdQ z)%h@(+kfoZZehiR0blK+-t~syjJh4W75=}RlqLE{KF>rliA)s#cuY%3!Cww)QcRW= zuQwfrat!7_f#>A7s!~cxNdxkLq{%6XleYq=A7Pq6$ZGIvQ3lQb5mV_RK{iSK;Elqg zz=mu?KfYg8<0y9{qlws7*nK&rM%{sTNn6&^&maS=m>FVj5qO7r!@%_FCp z@4q+y;Y-akug+ecn!WrBH#lmZIC=fKMN% znfaL`&EFgW5kf5Hn?HR1`q`=ZpYLrRf2;Y{`&qG-Y;*7F=Anbl>GVw6480ckasEn^VoBKZ2vy`t^_eIWOYysv&+Yl=7Zazrgkp zK7`3;T;>ppabWU;=%fT}5ThNHPRh^7*tLXt67|A3qDnW~<>cGvWJg~2bw{#XNoXJC z+WKvFj+h9gQeLS6;~QzkTKGihfz+)DvqYV!A;KuFyzM?)glF*xd?w8kN97pa3aQTKyUKD zB}xD~Mhhf$qIRQIt|4*K$>JIGGASxd;C#}cG>C)b#=0J{6%PE^U$RLW@wi<%r4AcIo3RJZ0_nCt=FdJ-u+qg__Om<`+Q8H zuC3|!TSwn)zWM6)mk&UPTW>$>sjaWA=-YbZ95i?K>Vetco%K}p+?Dww!89&wIH@9- zKp+XJa&aF)Q%}-S)s|Kg6kn?U0d)$bpi`XqqDKi|^zCL)zn2~AWYI4%K)4by_*V%T zY?6slVAa!ja-Fo-2|v>?_#Q{vQ6%R0KRP1=DJS>dW0qnou9Vvf;x@^Kg@q4=+|IO2 zo)c`XO12i!TG$Rl>LZPxW3XV&>{USRnaI}bj&y5O@129ekY1rh;k^W5umSKc9Gdhq4e8?VvmAHRpu@Llkzbz!FY z>fzS$6SE(jX`Oi&A@A(v7iKRXMmK*x;l?4VD`Lftq%G2V!bjLs@?C{J4Nn4t?IG8e zx(pFqmid!n4%Wpa{uIdnj7u2KOlaZ(I2?Iyl#QK*s^ywsBqN3C>*V8-)>ILqr1zPn zN~v5xZf_5Q^#U($;}m#6(u6K=MP2NO=TXyDNRFw;V8OQ{McBPQXU)y6=NfGxfr8`; zr!~%MRpR3yI4Rdl@Ei`)8FbrSzJsHQT!BcQISH?e$m2Qjaseldo5?GZ%jN$_Qz#}Q zHkijGA(F5Ow!$O`q56D?j`wK=i5LZGlAfTYqNN3`Fk11Tg+vaLN~+yMNhRDh+*2%y z^iT{}j};z^h%}bQ;z&RB+Og@#3_NBVH9G}cm9ENmYBDWGBo>T7QVApayb)V$)a)ci zWH2H*nX#f!9VD1o9~_Z>mciJ5Tm!fUtr#2f=1PdS53>=>HDC|GMrQ0Zl1=^l!jppw zMlHjLY+W#+Gl|25vZ;HW0cPgg&#PskB)BWID{ z#Q`lcax!c@od#(e$2Cp^k&A;E$fIy0Dj4IW7OoK|S|7+u1;{{vM(`>Xe@X>~>)m;z zLGOfHIPJ&i`3X5~DUm`VSA@)}NREqdCE}aNu9CG4o+&<;v~B5<(>Xl?8R~gMNO%pe zYj2dB)C_Yv7QSeA)a}N*8(RA7&gV3UqnLQ`%WSGkW*TIy5yxDg`Jnmy>#cnUc!B0+ zRBS-uXf(hDh}Ve_BXOZmC{7ZA7ccmSQ<0(y{})AtJOz>y)AbT4n@e&s3{LnM!F)AD`f8en_^Gb zlupzmIO;B7T_iC5AGy{9umJblsI-+3z=mCLZ58l_CnEyh2;hy{QFB;8_>V5hZ=EeKAywFAoq8?aa%Gv1Y4g>8zPm$%J>0CY3Gt^!< z5q}2n)H4(UbUm=>LCTB0{29{St|gzKZek(+XJS4{q(Bncb5oS^*(D)oxYgE6d6^MKB007UI@hMI3O!k^KLRk@L$K_R~;8wwsoQu z3Biu!U&Nh`lX^MfhE*z?u8=Gg2tDI|^T{&<`LdGubSu6Yx!*s?8RflHq^S^xn>Yi6 z6(aS~SGU&(m%GxyBzx8cvAHQiKs~!uC)fQKai8gCL{G}|72PIBQkoQgUz4GVfhboME+Ec%Kt0JwBIj(CKJzSEBi!#@C$m%zn6RD K|BUu>VI{%%*hhO1m_$T<~?&|GwpXxp3KHYoTeWv$} zySumBeYW?k`<>o*+~<1FxzG2WcfZ^FuG{g)t_$yt{uWalt(AaY-WJ=7a_&fmG?@y? zc*5AIn#Jg6fAD$l4vk!RU61HTO}!d3YO8u#GYT0a5fG0edb1$sDzCH&$LQbAbak1< zL%CLw(SlyCwbmD}jg5GR`fojWrHvaqHSu`*+Snb>pwFWoJYSQ^(zUU{(YrT2K9~2l z>(-z9hDJQTVft1U_jliYqH>T%~! zoxH^B`ifBtnEo1`7zIu4kK>69Ka!JvJQ0O;GmFSpM0`oFPMg_)nSF#ktOI@C<7Dvk z^1P9ZsG&i*aL`)VHzy-S7SLEa#b^=F@YLiYGD)JwCb;GDlQ5yVvt+|5K(&nL^|6g=0r&p^+=qUyndo>UN z3*o!~*9`446R|pf1S|DM6npR(5!f}Jffw^U*UH2%dz?6nC>`PK=46B7ov&DQOa){u zgZXk|SA?<@Y+XZ{f(6GXbx_$wLWV!9m&0bWgkX$`idozuavaTysVY&gfg+CrRJk^V zq5|!~YK)X+zg$wakP5Z5nt7!72nk42iL1)~7Ze7>MD>fqU1M%l1`~+!yY)vkHa6(} zi)X+!>=_y9AH4H`Lem>-T(Mk_oNr2Gxgf$%WpGBXdWX0GbxXF!&As2qr51zLh9R(hH%W+uYWNG*%1 zGC~z`G%n+D0B6^EoeH~i8l|L>5~&;oPn4xJA{oX?0dSHQgmW@grTHjYyQ;qJ0uACflFSiv{ke>9 zL!&7&MN`_FN;)zHTsj$vxt9U0iw{B<*GV6DED@8b)t0twHm9tRGsF)Ptxy*H5b;G3 znJ3V+OQ95}T|gWW zO~}m2*e2D4-J_|z6p>hQFos%3B8VW+G^jpQajMG$h$b z`W2U=+;D%71G~NV2BC}SF!;t;*+&3Y+UBQZD$gfWDNiHimfcfMi}U*Sq>kP0AM6`& zok-jR2Pk1TuIW~K9j9SFKvzX0DRUW_h7X{I<9wS@bV36N0X3S^kchy*7?}dqih5|| zw}^SlD%ypzP1`6-bnGA)QdR1x4T}+N{p$>eh+*XOm$9`Ie}CZ|@q9DlyiU)qy!ouB zr-z=uE%Y~sszzDf1n@Q>)X6yy7W~x4G(IsKSe3asG2J|wJR}!xOl9axD$3@PTrA4@ zQoD0w4JfBn>AdC`vwXLU4njOv<5}tnDSuAvXmVx|{Qr!b4~T6%tzN*?I|JUE$m;Ou z-Mf9h`!pE}Kr#|F5Bw02%a<-)g43X7LQ&`rx(cOnJruE3$R#_@w%(A^palJ!yd1Wt`O0UinFw!dyrsoksa>j`9Zexg4-vDd zZb9(LW~H9dWkZRcLh~%T;Qxar7p@ULknnRrKl9`}fO-equHlis5$Kt6b?W4u^9a6( zUry(Vhv;Se9jY8C;GASjXUSl~M~X0__Ed*bp*J|itoLDPn zG?b~TNWoW1Wo)L+;y9Nlt0(rMBb{viMlB1^BI-%lTAJ+OLg1?q&LSxyt*zm64j^zW!E?r|tB zE|rrSV~I59+UDi51qY`Myn^kzx$-GCA8})e+ zKg1x&5F}K2bL%2a;;BeR<_|aBQh=KV;BqR_>Ku%*P6TZhGThPtF{%ZZWuv-_)s?GK zrHk2bTy>ZXuj{Mo3O)M_&WH(st0db;dD+ zH^`32SdN;YGSZL^UetDrt3*1UNhMx7f_H-Y*!F4F;o#N=9$W@fuHfe?em)ntlGEKu z`9XF6Ct{82l<+e3C?wo0ZUPTR3-3$d(2g_Qe#h`1Q{1k^|B_=m_5VmRd3}X=I|*$e z_l}N`X|d!uumqHk2>~fqb_A6Ta$C9MmT9f0Ub;ZSah1ZA&pFol_QlEfMoJ}#{MNX2 zQ4a>FqpVHgr4Q`6+!w$V+`T#7;0mj_*~rT<1*ohU?l{y=#rSe- zwkjS!r{%u7;COk?<#Ky(xm+(DaEY!V^#bhWhsSkjJ^AR^9omCFRVJtZuO9pUYk%P*22Xam zJvdAMNWXV*xM%48%ky8{^}0s~Jl9bNUBe&aS9e$UJKZ02JMjMl{Qq9}N9RBMH%{~4 Am;e9( literal 0 HcmV?d00001 diff --git a/__pycache__/ui.cpython-310.pyc b/__pycache__/ui.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c305f65fc4d6a1155a7f72d77661bd2f49e98e6 GIT binary patch literal 10499 zcmb7K>vtPvde_Y8GLlBJd`aSj5EKX@T8bfDcBz+c8yc3*G-!_~YKl^7s-ybOB{ih)F7kJDXi0;#+qAxApQd&k{ zE=naOt(5#}e<_d-l!ED?BrkNc#Ml1VMvK=i}vU(<0DUapH?WmdEXE>RZWQUo7I%0%|TShATjAEwD zsh(#>&Z@BK%(ziDSk^M^tJCg-Y=1%}p4&y~Nwho0d_|P3LI7 z-hb{+GL#y=w50p6gOV;E^raPD(fxS)^?)A4J0RAd4(ee&f_zAi>Kfjv9y7vYvL4ra z4*Js(J)!raG^+RM{djBofW8jzn7&@$fOlLU)HmYYqi@0vUS;?4HQpb`=nA?eb*ghP^1B3lZ|hy>nogtrEoA*f=y%D3g?!yvd zBQs9;vgc4VT$^0i9Y$`(^nI*5!>wru}s#hQ31 zC8zy3`=T8*_D^QZ`Z6tl0j;Kt{P=`bi|)K7+5V5tp~kmQTQ>AZ@@2g;W&0`2iVvod2m ztzaZO%4il&N$+SiU(_?aO))!NsakfQC=#tiSxT|}rcoSY{TK^ZE}JiBurg!8$C4=d zVkkGf=V8-e=AQj|qnO{b+c5W9mB~H3?#pxv#-3_^XmZ+CGnsrjZ)Gw)=xq*y1SL%h zDw-5m;*zE$qy!#S(d62uP6jxWp?2pSZCLvs?QsYX7qKsqAmJ)NxO7=pNGzPZ+>z(E zMATbK!B1^;+!P3$RF?_?^p;5E=+4Xqz(eZbC>6Z@gM|>p6}Jk42Qkj5;HJ7fv^fQ0 z+a4pv#~`5&U_PB=$Q@(ItHzM|7`Uf%m@mX1qTe|R7wE(YqwQgRM0!E08pzV6=bMK= zY@K|q`N_!z$@X(x^OsFyYB_-FRKQjk1SpuR6sx7O9WE#E|ZkdpM zLAC=^dEJ_@gA-h^7Zi2_%GnMgDG)wn>&zRAzi%|3Jk&h*LE1lBtQskW-Aqkx0ZB*3 zD$E#Xm1QKs z@)rVBM(8*v7lLRh3y}rhL1?WI@DM`Mh>&Q`%epp4xEV!1jr!q<>M=b&r|`ah-nYl; z+vc5cc>CAn-K+Pxykj2TF_$;Zn66Sc?|z5(z?!_*>FZtIaS!jf%X?rA-Wwd=gAVVF z&boRE3BG4BeUp9_zplN=_hJVIJ2|10c5XB69BCo;uhg#5hh6Q51|nV5-lMCXu{N!_ zjxG8%?m7lM>lkp?v8i)(mv_?P9a_bEoioF&`n4|a^&Z~q_3NCo4Xwd@o5TD1HF zZ*X~U@bKQ?^1glz-a8!Lsa3q&GWji!Ob&W@54wAjTE%-K)-35af^O3Pgv)IT21hKN~)?p-Z!?t)R-vY6k+c4raG$QV166qwYAGF;7%g9;V?fM;a zFoD5U^!PSfZFPEFi}ba;$H4#5<2z_|ozr6*(%X2C!PPyu3b}J+nRJHGBcwvOGO4M% z9=I?017muaT}NfxL2SuL1w7#YEvmSY2&n-(l%33H_8QZ+5B6+eUluxGIf)7w#~Q)& zzAVdUM~enm3$`>>3*VhJjpV~WzJJ(ON0H1_S+O=aVOf*r@Xa@a+fa5q4>NiwSH5}Q z&RX<`72PN%M_}#@FLQG!QlgSf86~yo_@s5y?UkEKFgdDm414+gi_5=ydB;PQ$$Ty~ z%n~$3xoj+x24O>#3&42E=Xm4Aw=On+@$?Q@h0I6|+W{Es%&3wbw)o7!<_8z6qza+n zmX021z4+#^t(B{#wjpn_n^3t-*j|l+w9X$VRe1I!wOr=5Ty(0!^bGop8f^LfsMGNB zC$BWmzP$9_`R1jA!^>pW*eZDwlUZw`8YWKvczo&gBSS+&DVc4f>QJsyD#6xdJCWmh zSMXRqG`INiIq16;%}j^GTQrY0mOlPiv{)vtp0zZOzc*YZ!))hJa`A;6+R9nBNJUwZwyrKjFr zJo;{H_VHAe43*o4l9%6~T{?WBHT!z=+(a5fnlFFd8t&0vw>tT2K*QllT+qe(0McN|ymCb0oOuDTC5*+l8Dm z_yTU3>XgCzaLd9@85~`=EaH?!Jbj}LO^;f!LY&MK%q`mJp&Xx|#@q4oMnaEq*BcJ7 z1(Sw+f9lcpAHIuqvb%`vB0}aVyO+pc5g}+}KOk~Hkr9wDl$}Gju zrTV$!%hsU_dj!N*Ov7S7q3lnIki4+JBl7n|vP4FS{4IzbgeoxLQ9nc_KO?e-iWM0D zjBIa2Zzd{JQ(0C9b^uVhYoO%D#=GTn42_?{W0IQ^fdme8{1TAWxSWt{TUX0h*X>;; zTm-+u{zn2whPcQ)y=CXEIB!42ODHljFE>KGh3eFVop;1}N9nE0XVe+V(kR|ih&>^* z?^^Lj55-i3Xg~x4t_Z{%z2xk=BA|}23TBg18JC0=A)piXNCFoEI)63^Ql(og4iEz1 zXtKlaBt6j+E*)J>aLin*F2C^erSs>jbktrA)_nD?r3Mu111!~ zRY{fl@Q7dV zk6Hu)4e81P(S`4;1VRB@E)Yp1EG`ZlYvNoefHm!7xhVw3apZx!uI0!%@dW1&Z%hA#BEFP0A7L_Bc|Et= zE@1Auw87_Klds{Yvi53H@`x=(ph>=iF8rQ)P8GKv0aboK**>aIx`cb_bb{~TG0Bn} zz){rJt)4q>{i=z!H~VukLJ;KfSv<5eM{!Aj!CiOX zf6wlPHgG^Uc1_HV>v@w5+{`G~`0rzs&uN1&!{q82b~avfvTPb0SPcZ`U7gYdaCUIy zM?e1IuKU?zRFZTFZIB%^%ARRlq{>;4D;;r1%MR(J&1yfNlks`jH%4(X`5-|J1r!jp zos-B}Zpb{q>PLVzP`wIGg(=y5^Z3&F3ta|f4%O~IfR zxh+EVkoh+pI1z*)xqSpb%qW3Y?ma}l7qE)?S$%a1aLaY2?ym>x!8!uhbE-r=0I<~J z5np+e-)HqtACP=AsHw$yU8t^}_PxTbhev!rh3L(wkE!Ljcuz@FR~Yq*^Kj=i1@IOi z&arjr>~EUS9_0YNbpE~Op(D$)Pqm(25I8Het?)Ujbga#fW}B}+z52wO&m9vk0J$F? z-GFFXv6SJaG`9h`hDi4b-zHNTTk(oqg;6Eg{`AbDq*o$b{?5?nj=}o?^0$8Z+Txj~ z#eK;@|69=3L)LyPi4IPw3w%^g8igE@PhUCaxG>&PTt}vL@?C^Pp>~FosR8y6=)m$s z$j%U2rb?+5JEK$8Tv5jm@G*BRQPLa)nW8pkOb@{i50)g zehWzdPsp0PK{TK>MI+;|Na9iiKUl~zl_q*4|0zq|zu+I^k+`&3QfK9_o#Y!$)G`}}QGe8tM2*n6TYh*#O!`1|S`ohVigAef}hfQWJh=?nD z5_3fdUM0}!5K8!p*aT9S@R&gmP1d$*QtkS$HKjH2i9G>Uz@8uB)qM3->+EYnsD#7* z*+-`qFJ5RJeYJV$x6QX^ThG3{^qa?T^tz?E6s`GRfA-O_=80#Q4Mslsy7K%w%GEVD zzn<3uJIg?6%v$r{=!WTk29CxY97RyL9_zx<*kdt{qjTXFPbmD@kTX;G73iV?=s!d7$Nqp(-&GVy_q7I58LrF|9Q4ymz3?NvV$P0c-Ky{=ctN1S)@Jf{?6mzUx(A1u6QQA z&hDihZ>wPOG5G|se3r=b#8UHygBZyy}6m1v>?-mj!Eq6uZkgmalFSl_?I6Ram8Ka>ytw_l?e5B(yte>RR74cAAr35DyUJ4Q%)?)Cgt!H}zuyp!$J zNbE3XH^QEx86P8(B(kDr3dml@(^fOLL(SaqHAnh?6%Fa)%O8KT_`+|SJbt+g6SkV? zKWLqq#a$ADw)DyArTJrv=VlQKb;F&I(B_kGFaGXv=onZ!tF#LVPCw0B@76A_)~0}% z-%z^pU1ApWZn$dW@N;pp`=z)L0fiGZTN4Ly%tU8!uKyv&D>AMcAE9QyDvs*414g z4h(rPj_MKG{%~>OL!fl))B!}hn#a$JvtB%PPzVOW?wL0TpD6M!5Ze_HAx{LxAv@M_ z`|*jqbB1=jb9U_KVAlmGLMd<=UA*{w>lbHW4DqN7!J-(;3xx>*g(YINre(}g@C9n} zB9WJfbm1QvNoyb+2e*@`PY-mhN1$7O=b{9Ly-fU05jjnSf-&h%sN=!yp!n{;CaPV7 zOJ!0Va|P9aW*pI6Oar!YH0z7}M>m=U|0fbg`!;@knFw(Fx_vdOTq&IX&ETtuSzb-k zb*$tR;A{KW8)zhzpg+B#uVR^u9m$ZhRz*}IV{4hrqt$Ft)Ua!5E9p-j7{&6~7!iYr z4uaUL#e_!V&e8#@Ak&=@urivSK-{F8+Da)6gv?|SD?@lm-KcI*|61)=d*eYhpep!n z*Z!2ZL%^5B$PQV1^JQE-p(kZxqY&{E2@p97l8!r{7w`cLf1bxgu$Zihbf9YGizXMs zbdR_+js~eP?vXLV)pS1?wEwzJeAfUME~+%^D=o)X?-HB$JsgG4q_~laH4P&6mw?-q JkTidv{J)>nvqt~` literal 0 HcmV?d00001 diff --git a/__pycache__/utils.cpython-310.pyc b/__pycache__/utils.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b7873fe48beffed7229bad092971f2c07f4e358 GIT binary patch literal 2480 zcmai0?QaxC7@wKjdu@C41%e>rp+P*2Cin@__!6WBKO8aD5KGDByxEq;WA`#UOJ8zF z2rq3#DlsWV+Jdc#O;MnYCgg-F|Hpo%d%b?@cRqm6GkZO+FX&!wcJ`TlX6ByX%beBJ z6eDoWZ6EI$4HNPhAL_3VJbVOS+k}P_PIIKwztc|2na+4XXWbgtc<`du8Q>v^gjFQM zqY%;Xoo>csyaAqqyb;DRqVXmkhe(KT(ZiSl^F+EsyqUK^G|FiQNwzM-nk3~$x{aJ} zW=`vFNa)fLW|rbW_0r&uzu^#k?O|vJ5|R{PryS-S30#dAX@O4BX;Ngz2-h^CkqZGz z3h>lH_7i(YFQo)OU%}UpYDBXBH&&xO*a0~*(0_=MB4iH4rN%>rzy!I;Em zBi`K~y~)|~@R+x7-Yb>7;Tx4lqu#`=rP~j@yVsZQ%q*6Ee|+w|OU09*BfH-^aGSaj z-OO6tFndxiagwmyaiM2;8Y^}pQNMKg`hD-}4@;vzEsx%)%*`E4B!jMI<#p55FvD$W zhm@2h+bzfBhlQ|&8$H~Y)$`J@OgESphAESpV6cR%;q-1eYnf6vrAzhOM6|%bll|F) zU0>R|u)F#UJ!f=%qTAm{E8q3;k#x#Bqvt-=OjjK=O9D6F-a079v_sO)h(wRK`0`5j}L+N}*p3LF~SFbhH_#UDK zA{q*03dk`{?FLQ@WIHL6fjtr^f^^{;5N1n}71$IjkZA=j53W)M=mP))W%kxn#*Zq3 zwEqV|f;EDuUX>tSuqnh`v7YV=<Sj1BU^Z(5<}wr#Q`zGq`@ zoJA;t-bi127H7LC0R2q72m@<`9)Lhi=n@_VcC8V5tVrtxj(vsJwsmMh6wspm?*dv3 zPrOVQBEDTp>2A3wryulkQ3*Q0?0*#~|2b|`( zojIeYZ3$l1gYE$noJjcdRTa3wBsIVKJ8-r}Y^s#UlKmeJ8&`W3vT8W2Vf$_vaW$Ti zndbv>4&E8qy9S81B9rtKcvS&-%7Z*KqTzwW!*!k&Q7YKI4iJbc&-$|WKX)@+A%NB^P8vuH#LrNK#Q2MHZYh dict: + """从指定的 config_file 加载配置,若不存在则返回空字典。""" + if os.path.exists(config_file): + try: + with open(config_file, 'r', encoding='utf-8') as f: + return json.load(f) + except: + pass + return {} + +def save_config(config_data: dict, config_file: str) -> bool: + """将 config_data 保存到 config_file 中,返回 True/False 表示是否成功。""" + try: + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config_data, f, ensure_ascii=False, indent=4) + return True + except: + return False diff --git a/consistency_checker.py b/consistency_checker.py new file mode 100644 index 0000000..2cf6afe --- /dev/null +++ b/consistency_checker.py @@ -0,0 +1,51 @@ +""" +演示多Agent思路中的“审校Agent”,对最新章节进行简单的一致性或逻辑冲突检查。 +可根据需要进行扩展。 +""" +from langchain_openai import ChatOpenAI + +CONSISTENCY_PROMPT = """\ +请检查下面的小说设定与最新章节是否存在明显冲突或不一致之处,如有请列出: +- 小说设定: +{novel_setting} + +- 角色状态(可能包含重要信息): +{character_state} + +- 全局摘要: +{global_summary} + +- 最新章节内容: +{chapter_text} + +如果存在冲突或不一致,请说明;否则请返回“无明显冲突”。 +""" + +def check_consistency( + novel_setting: str, + character_state: str, + global_summary: str, + chapter_text: str, + api_key: str, + base_url: str, + model_name: str +) -> str: + """ + 调用模型做简单的一致性检查。可扩展更多提示或校验规则。 + """ + prompt = CONSISTENCY_PROMPT.format( + novel_setting=novel_setting, + character_state=character_state, + global_summary=global_summary, + chapter_text=chapter_text + ) + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=base_url, + temperature=0.3 + ) + response = model.invoke(prompt) + if not response: + return "审校Agent无回复" + return response.content.strip() diff --git a/main.py b/main.py new file mode 100644 index 0000000..2639413 --- /dev/null +++ b/main.py @@ -0,0 +1,11 @@ +import tkinter as tk +from ui import NovelGeneratorGUI + +def main(): + root = tk.Tk() + root.title("Novel Generator - Innovative Flow") + app = NovelGeneratorGUI(root) + root.mainloop() + +if __name__ == "__main__": + main() diff --git a/novel_generator.py b/novel_generator.py new file mode 100644 index 0000000..744ac66 --- /dev/null +++ b/novel_generator.py @@ -0,0 +1,317 @@ +import os +from typing_extensions import TypedDict +from langchain_openai import ChatOpenAI +from langgraph.graph import StateGraph, START, END +from typing import Dict + +from utils import ( + read_file, append_text_to_file, clear_file_content, save_string_to_txt +) +from prompt_definitions import ( + set_prompt, character_prompt, dark_lines_prompt, + finalize_setting_prompt, novel_directory_prompt, + summary_prompt, update_character_state_prompt, + chapter_outline_prompt, chapter_write_prompt +) + +# 向量检索相关 (以Chroma为例),需要安装 langchain, chromadb 等 +from langchain.embeddings.openai import OpenAIEmbeddings +from langchain.vectorstores import Chroma +from langchain.docstore.document import Document + +# 默认用此目录存放向量库 +VECTOR_STORE_DIR = "vectorstore" + +# =============== 多步生成:设置 & 目录 =============== +class OverallState(TypedDict): + topic: str + genre: str + number_of_chapters: int + word_number: int + novel_setting_base: str + character_setting: str + dark_lines: str + final_novel_setting: str + novel_directory: str + +def Novel_novel_directory_generate( + api_key: str, + base_url: str, + llm_model: str, + topic: str, + genre: str, + number_of_chapters: int, + word_number: int, + filepath: str +): + """ + 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt + """ + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=base_url + ) + + def generate_base_setting(state: OverallState): + prompt = set_prompt.format( + topic=state["topic"], + genre=state["genre"], + number_of_chapters=state["number_of_chapters"], + word_number=state["word_number"], + ) + response = model.invoke(prompt) + if not response: + return {"novel_setting_base": ""} + return {"novel_setting_base": response.content.strip()} + + def generate_character_setting(state: OverallState): + prompt = character_prompt.format(novel_setting=state["novel_setting_base"]) + response = model.invoke(prompt) + if not response: + return {"character_setting": ""} + return {"character_setting": response.content.strip()} + + def generate_dark_lines(state: OverallState): + prompt = dark_lines_prompt.format(character_info=state["character_setting"]) + response = model.invoke(prompt) + if not response: + return {"dark_lines": ""} + return {"dark_lines": response.content.strip()} + + def finalize_novel_setting(state: OverallState): + prompt = finalize_setting_prompt.format( + novel_setting_base=state["novel_setting_base"], + character_setting=state["character_setting"], + dark_lines=state["dark_lines"] + ) + response = model.invoke(prompt) + if not response: + return {"final_novel_setting": ""} + return {"final_novel_setting": response.content.strip()} + + def generate_novel_directory(state: OverallState): + prompt = novel_directory_prompt.format( + final_novel_setting=state["final_novel_setting"], + number_of_chapters=state["number_of_chapters"] + ) + response = model.invoke(prompt) + if not response: + return {"novel_directory": ""} + return {"novel_directory": response.content.strip()} + + graph = StateGraph(OverallState) + graph.add_node("generate_base_setting", generate_base_setting) + graph.add_node("generate_character_setting", generate_character_setting) + graph.add_node("generate_dark_lines", generate_dark_lines) + graph.add_node("finalize_novel_setting", finalize_novel_setting) + graph.add_node("generate_novel_directory", generate_novel_directory) + + graph.add_edge(START, "generate_base_setting") + graph.add_edge("generate_base_setting", "generate_character_setting") + graph.add_edge("generate_character_setting", "generate_dark_lines") + graph.add_edge("generate_dark_lines", "finalize_novel_setting") + graph.add_edge("finalize_novel_setting", "generate_novel_directory") + graph.add_edge("generate_novel_directory", END) + + app = graph.compile() + + input_params = { + "topic": topic, + "genre": genre, + "number_of_chapters": number_of_chapters, + "word_number": word_number + } + result = app.invoke(input_params) + + if not result: + print("⚠️ invoke() 结果为空,生成失败。") + return + + final_novel_setting = result.get("final_novel_setting", "") + final_novel_directory = result.get("novel_directory", "") + if not final_novel_setting or not final_novel_directory: + print("⚠️ 生成失败:缺少 final_novel_setting 或 novel_directory。") + return + + # 写入文件 + filename_set = os.path.join(filepath, "Novel_setting.txt") + filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") + + final_novel_setting_cleaned = final_novel_setting.replace('#', '').replace('*', '') + final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') + + append_text_to_file(final_novel_setting_cleaned, filename_set) + append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) + + +# =============== 生成章节(含角色状态 & 全局摘要 & 向量检索) =============== + +def init_vector_store(api_key: str, texts: list[str]) -> Chroma: + """ + 初始化并返回一个Chroma向量库,将传入的文本进行嵌入。 + 若需要可对 texts 做分句或分块处理;这里只演示简单用法。 + """ + embeddings = OpenAIEmbeddings(openai_api_key=api_key) + documents = [Document(page_content=t) for t in texts] + vectorstore = Chroma.from_documents(documents, embedding=embeddings, persist_directory=VECTOR_STORE_DIR) + vectorstore.persist() + return vectorstore + +def load_vector_store(api_key: str) -> Chroma: + """ + 读取已存在的向量库。若不存在则返回None或新建一个空的。 + """ + if not os.path.exists(VECTOR_STORE_DIR): + return None + embeddings = OpenAIEmbeddings(openai_api_key=api_key) + return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) + + +def update_vector_store(api_key: str, new_chapter: str): + """ + 将最新章节文本插入到向量库里,用于后续检索参考。 + 可根据实际需求做分块处理。此处仅作简单示范。 + """ + store = load_vector_store(api_key) + if not store: + # 如果vector store不存在,先初始化 + store = init_vector_store(api_key, [new_chapter]) + return + + embeddings = OpenAIEmbeddings(openai_api_key=api_key) + new_doc = Document(page_content=new_chapter) + store.add_documents([new_doc]) + store.persist() + + +def get_relevant_context_from_vector_store(api_key: str, query: str, k: int=2) -> str: + """ + 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 + 用于在生成大纲或写正文时,为大模型提供更多上下文。 + """ + store = load_vector_store(api_key) + if not store: + return "" + docs = store.similarity_search(query, k=k) + # 简单拼接 + combined = "\n".join([d.page_content for d in docs]) + return combined + +def generate_chapter_with_state( + novel_settings: str, + novel_novel_directory: str, + api_key: str, + base_url: str, + model_name: str, + novel_number: int, + filepath: str, + word_number: int, + lastchapter: str +) -> str: + """ + 多步流程: + 1) 更新/创建全局摘要 + 2) 更新/生成角色状态文档 + 3) 根据向量检索获取往期章节相关内容 + 4) 大纲 -> 正文 + 5) 更新向量库 + 最终写入 chapter.txt、lastchapter.txt、character_state.txt、global_summary.txt + """ + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=base_url, + temperature=0.9 + ) + + # --- 文件名定义 --- + character_state_file = os.path.join(filepath, "character_state.txt") + global_summary_file = os.path.join(filepath, "global_summary.txt") + chapter_file = os.path.join(filepath, "chapter.txt") + lastchapter_file = os.path.join(filepath, "lastchapter.txt") + + # --- 读取现有文档(可能为空) --- + old_char_state = read_file(character_state_file) + old_global_summary = read_file(global_summary_file) + + # --- 1) 更新全局摘要 (若上一章文本不为空) --- + def update_global_summary(chapter_text: str, old_summary: str) -> str: + prompt = summary_prompt.format(chapter_text=chapter_text, global_summary=old_summary) + response = model.invoke(prompt) + if not response: + return old_summary + return response.content.strip() + + if lastchapter.strip(): + # 用上一章内容更新全局摘要 + new_global_summary = update_global_summary(lastchapter, old_global_summary) + else: + new_global_summary = old_global_summary + + # --- 2) 更新角色状态文档 --- + def update_character_state(chapter_text: str, old_state: str) -> str: + prompt = update_character_state_prompt.format(chapter_text=chapter_text, old_state=old_state) + response = model.invoke(prompt) + if not response: + return old_state + return response.content.strip() + + if lastchapter.strip(): + new_char_state = update_character_state(lastchapter, old_char_state) + else: + new_char_state = old_char_state + + # --- 3) 从向量库检索相关上下文,用来帮助生成新的大纲 --- + # 例如,可以根据“角色状态”或“本章关键词”来查询。 + # 简单示范:以 "回顾剧情" 作为检索Query + relevant_context = get_relevant_context_from_vector_store(api_key, "回顾剧情", k=2) + + # --- 4) 大纲 -> 正文 --- + def outline_chapter(novel_setting: str, char_state: str, global_summary: str, chap_num: int, extra_context: str) -> str: + prompt = chapter_outline_prompt.format( + novel_setting=novel_setting, + character_state=char_state + "\n\n【历史上下文】\n" + extra_context, + global_summary=global_summary, + novel_number=chap_num + ) + response = model.invoke(prompt) + if not response: + return "" + return response.content.strip() + + chap_outline = outline_chapter(novel_settings, new_char_state, new_global_summary, novel_number, relevant_context) + + def write_chapter(novel_setting: str, char_state: str, global_summary: str, outline: str, wnum: int, extra_context: str) -> str: + prompt = chapter_write_prompt.format( + novel_setting=novel_setting, + character_state=char_state + "\n\n【历史上下文】\n" + extra_context, + global_summary=global_summary, + chapter_outline=outline, + word_number=wnum + ) + response = model.invoke(prompt) + if not response: + return "" + return response.content.strip() + + chapter_content = write_chapter(novel_settings, new_char_state, new_global_summary, chap_outline, word_number, relevant_context) + + if chapter_content: + # --- 写入 chapter.txt 与 lastchapter.txt --- + append_text_to_file(chapter_content, chapter_file) + + clear_file_content(lastchapter_file) + save_string_to_txt(chapter_content, lastchapter_file) + + # --- 更新全局摘要、角色状态到文件 --- + clear_file_content(character_state_file) + save_string_to_txt(new_char_state, character_state_file) + + clear_file_content(global_summary_file) + save_string_to_txt(new_global_summary, global_summary_file) + + # --- 5) 更新向量检索库 --- + update_vector_store(api_key, chapter_content) + + return chapter_content diff --git a/prompt_definitions.py b/prompt_definitions.py new file mode 100644 index 0000000..b1d9af3 --- /dev/null +++ b/prompt_definitions.py @@ -0,0 +1,129 @@ +""" +集中存放所有提示词(Prompt),便于统一管理和修改。 +""" + +# =============== 提示词:设定 & 目录 =================== + +set_prompt = """\ +请根据主题:{topic}、类型:{genre}、章数:{number_of_chapters}、每章字数:{word_number}来完善小说整体设定。 +需要包含以下信息: +1. 小说名称、总字数走向(大致范围即可)。 +2. 小说类型与基调(如:都市、穿越、战争等类型,以及轻松、爆笑、暗黑等基调)。 +3. 写作风格(正式 / 轻松;细腻 / 简洁;抒情 / 客观;叙事视角等)。 +4. 整体世界观(时间背景、地理环境、社会结构、科技或魔法水平、重要历史传说或事件等)。 +5. 核心内容梗概(可以使用常见叙事结构,如三幕结构、英雄之旅等)。 +6. 初步的情节安排设想(主线、副线、交织等关键思路)。 +7. 初步的人物关系与主要角色设定(角色定位、主要冲突或关系)。 +8. 结尾可能的方向(圆满、悲剧、开放式等)。 + +请按照上述要点详细输出,但不用标数字。要清晰、有逻辑、有条理。 +""" + +character_prompt = """\ +基于已生成的小说整体设定: +{novel_setting} +请你完善以下内容,帮助我们更好地维持人物形象和成长轨迹: +1. 列出核心角色(至少3个),并对每个角色进行详细性格特征描述。 +2. 强调每个角色的潜在内心冲突、目标与动机。 +3. 为每个角色添加至少一个“暗线”或隐藏秘密,以及在故事进行中如何可能被揭示的思路。 +4. 指出主要角色之间的关键关系和冲突点,为后续情节埋下伏笔。 +""" + +dark_lines_prompt = """\ +在当前设定中已出现以下角色与背景: +{character_info} +请帮助我们构思若干暗线、伏笔或隐藏冲突,以便在后续章节中逐渐揭示并影响故事走向。要求: +1. 每个暗线至少说明其最初的表现、发展走向,以及揭示或爆发的条件。 +2. 这些暗线可以与角色背景、世界观、关键事件等有关。 +3. 需注意保留悬念,与已知设定不冲突。 +4. 在后续创作中可多次提及这些暗线,并在中后期通过角色行为或剧情变化逐步揭示。 +""" + +finalize_setting_prompt = """\ +请基于以下信息,整合并输出最终的《小说设定》: +1. 之前的“整体设定”: +{novel_setting_base} +2. 扩充的“角色设定”: +{character_setting} +3. 暗线与伏笔构思: +{dark_lines} + +要求: +1. 结构清晰,将以上内容融合为一个完整的设定说明。 +2. 着重强调角色与暗线的衔接、世界观与角色动机的结合,方便后续写作保持前后一致。 +3. 语言通畅,不使用Markdown格式,直接输出文本内容。 +""" + +novel_directory_prompt = """\ +根据以下最终《小说设定》: +{final_novel_setting} +并按照下面的小说目录模板生成 {number_of_chapters} 章的目录,同时确保目录符合小说设定中的叙事结构、角色发展及暗线伏笔。 +目录模板: +第1章 :< text > +第2章 :< text > +... +第{number_of_chapters}章 :< text > + +请严格按照上述格式输出每一章的名称,且勿使用Markdown语法。 +""" + +# =============== 提示词:章节+角色状态流程 =================== + +summary_prompt = """\ +这是新生成的章节文本: +{chapter_text} + +这是当前的全局摘要(可能为空): +{global_summary} + +请在不超过1000字的前提下,基于当前全局摘要和本章新增剧情,更新全局摘要。 +保留原有重要信息,并融入本章的新内容。 +不要透露结局,不要过度展开未来剧情。 +""" + +update_character_state_prompt = """\ +这是新生成的章节文本: +{chapter_text} + +这是当前角色状态文档(可能为空): +{old_state} + +请更新角色状态,包括: +1. 角色持有的物品或能力变化。 +2. 角色间关系、冲突或合作的新动向。 +3. 正在发生的重要事件列表,有无进展或新事件产生。 +4. 任意新增角色或出场人物等。 +5. 请保证结构完整,能在后续章节继续引用。 + +使用简洁、易读的方式描述,可用条目或段落表示。保持与旧文档风格一致。 +""" + +chapter_outline_prompt = """\ +以下是当前小说设定与角色状态信息: +- 小说设定:{novel_setting} +- 角色状态:{character_state} +- 全局摘要:{global_summary} +- 本章节编号:第 {novel_number} 章 + +请为即将写作的 第{novel_number}章 设计一个简要大纲: +1. 本章的主要冲突或事件? +2. 哪些角色会出现?情感与目标变化? +3. 如何进一步暗示或推动暗线和角色冲突? +4. 如何结尾留下悬念? + +直接用1、2、3、4分点说明即可。 +""" + +chapter_write_prompt = """\ +下面是该章写作所需信息: +1. 小说设定:{novel_setting} +2. 角色状态:{character_state} +3. 全局摘要:{global_summary} +4. 本章大纲:{chapter_outline} + +请写出本章节的完整正文: +1. 确保本章字数不少于 {word_number} 字。 +2. 不要使用分节标题,直接整体输出正文。 +3. 可以着重描写人物心理、环境氛围等,以保证足够长度。 +4. 在结尾部分保留一定悬念或剧情转折,为下一章做铺垫。 +""" diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..6bbcaa6 --- /dev/null +++ b/ui.py @@ -0,0 +1,329 @@ +import os +import tkinter as tk +from tkinter import ttk, filedialog, scrolledtext, messagebox +import threading + +from config_manager import load_config, save_config +from utils import read_file +from novel_generator import ( + Novel_novel_directory_generate, + generate_chapter_with_state +) +from consistency_checker import check_consistency + +class NovelGeneratorGUI: + def __init__(self, master): + self.master = master + self.master.title("Novel Generator GUI - Advanced") + + # 配置持久化 + self.config_file = "config.json" + self.loaded_config = load_config(self.config_file) + + # 使用 PanedWindow 实现左右布局 + main_pane = ttk.PanedWindow(master, orient="horizontal") + main_pane.pack(fill="both", expand=True) + + # 左侧:显示区(上下分区) + self.left_frame = ttk.Frame(main_pane) + main_pane.add(self.left_frame, weight=3) + + # 右侧:参数输入区 + self.right_frame = ttk.Frame(main_pane, padding="10 10 10 10") + main_pane.add(self.right_frame, weight=1) + + # 左侧布局:日志区 + 章节内容 + self.build_left_layout() + # 右侧布局:参数输入区 + self.build_right_layout() + + def build_left_layout(self): + self.left_frame.rowconfigure(0, weight=1) + self.left_frame.rowconfigure(1, weight=1) + self.left_frame.columnconfigure(0, weight=1) + + # 日志区 + log_frame = ttk.LabelFrame(self.left_frame, text="输出日志") + log_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + log_frame.rowconfigure(0, weight=1) + log_frame.columnconfigure(0, weight=1) + + self.log_text = scrolledtext.ScrolledText(log_frame, width=80, height=10) + self.log_text.grid(row=0, column=0, sticky="nsew") + + # 章节内容区 + chapter_frame = ttk.LabelFrame(self.left_frame, text="本章内容") + chapter_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + chapter_frame.rowconfigure(0, weight=1) + chapter_frame.columnconfigure(0, weight=1) + + self.chapter_result = scrolledtext.ScrolledText(chapter_frame, width=80, height=10, foreground="blue") + self.chapter_result.grid(row=0, column=0, sticky="nsew") + + def build_right_layout(self, ): + # 行列配置 + for i in range(12): + self.right_frame.rowconfigure(i, weight=0) + self.right_frame.columnconfigure(1, weight=1) + + # 1. API Key + ttk.Label(self.right_frame, text="API Key:").grid(row=0, column=0, padx=5, pady=5, sticky="e") + self.api_key_var = tk.StringVar(value=self.loaded_config.get("api_key", "")) + ttk.Entry(self.right_frame, textvariable=self.api_key_var, width=32).grid(row=0, column=1, padx=5, pady=5, sticky="w") + + # 2. Base URL + ttk.Label(self.right_frame, text="Base URL:").grid(row=1, column=0, padx=5, pady=5, sticky="e") + self.base_url_var = tk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) + ttk.Entry(self.right_frame, textvariable=self.base_url_var, width=32).grid(row=1, column=1, padx=5, pady=5, sticky="w") + + # 3. Model Name + ttk.Label(self.right_frame, text="Model Name:").grid(row=2, column=0, padx=5, pady=5, sticky="e") + self.model_name_var = tk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) + ttk.Entry(self.right_frame, textvariable=self.model_name_var, width=32).grid(row=2, column=1, padx=5, pady=5, sticky="w") + + # 4. 主题(Topic) 多行输入 + ttk.Label(self.right_frame, text="主题(Topic):").grid(row=3, column=0, padx=5, pady=5, sticky="ne") + self.topic_text = scrolledtext.ScrolledText(self.right_frame, width=32, height=4) + self.topic_text.grid(row=3, column=1, padx=5, pady=5, sticky="w") + topic_default = self.loaded_config.get("topic", "") + if topic_default: + self.topic_text.insert(tk.END, topic_default) + + # 5. 类型(Genre) + ttk.Label(self.right_frame, text="类型(Genre):").grid(row=4, column=0, padx=5, pady=5, sticky="e") + self.genre_var = tk.StringVar(value=self.loaded_config.get("genre", "玄幻")) + ttk.Entry(self.right_frame, textvariable=self.genre_var, width=32).grid(row=4, column=1, padx=5, pady=5, sticky="w") + + # 6. 章节数 + ttk.Label(self.right_frame, text="章节数:").grid(row=5, column=0, padx=5, pady=5, sticky="e") + self.num_chapters_var = tk.IntVar(value=self.loaded_config.get("num_chapters", 10)) + ttk.Entry(self.right_frame, textvariable=self.num_chapters_var, width=8).grid(row=5, column=1, padx=5, pady=5, sticky="w") + + # 7. 每章字数 + ttk.Label(self.right_frame, text="每章字数:").grid(row=6, column=0, padx=5, pady=5, sticky="e") + self.word_number_var = tk.IntVar(value=self.loaded_config.get("word_number", 3000)) + ttk.Entry(self.right_frame, textvariable=self.word_number_var, width=8).grid(row=6, column=1, padx=5, pady=5, sticky="w") + + # 8. 文件保存路径 + ttk.Label(self.right_frame, text="保存路径:").grid(row=7, column=0, padx=5, pady=5, sticky="e") + self.filepath_var = tk.StringVar(value=self.loaded_config.get("filepath", "")) + ttk.Entry(self.right_frame, textvariable=self.filepath_var, width=32).grid(row=7, column=1, padx=5, pady=5, sticky="w") + ttk.Button(self.right_frame, text="浏览...", command=self.browse_folder).grid(row=7, column=2, padx=5, pady=5, sticky="w") + + # 保存/加载配置按钮 + config_frame = ttk.Frame(self.right_frame) + config_frame.grid(row=8, column=1, sticky="w") + ttk.Button(config_frame, text="保存配置", command=self.save_config_btn).grid(row=0, column=0, padx=5) + ttk.Button(config_frame, text="加载配置", command=self.load_config_btn).grid(row=0, column=1, padx=5) + + # 按钮区域 + row_base = 9 + ttk.Label(self.right_frame, text="章节号:").grid(row=row_base, column=0, sticky="e") + self.chapter_num_var = tk.IntVar(value=1) + ttk.Entry(self.right_frame, textvariable=self.chapter_num_var, width=6).grid(row=row_base, column=1, padx=5, pady=5, sticky="w") + + self.btn_generate_full = ttk.Button(self.right_frame, text="1. 生成设定 & 目录", command=self.generate_full_novel) + self.btn_generate_full.grid(row=row_base+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + self.btn_generate_chapter = ttk.Button(self.right_frame, text="2. 生成单章(含角色状态)", command=self.generate_chapter_text) + self.btn_generate_chapter.grid(row=row_base+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + # 可选:添加一个“一致性审校”按钮 + self.btn_check_consistency = ttk.Button(self.right_frame, text="3. 一致性审校", command=self.do_consistency_check) + self.btn_check_consistency.grid(row=row_base+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + + # -------------- 配置管理 -------------- + def load_config_btn(self): + cfg = load_config(self.config_file) + if cfg: + self.api_key_var.set(cfg.get("api_key", "")) + self.base_url_var.set(cfg.get("base_url", "")) + self.model_name_var.set(cfg.get("model_name", "")) + self.genre_var.set(cfg.get("genre", "")) + self.num_chapters_var.set(cfg.get("num_chapters", 10)) + self.word_number_var.set(cfg.get("word_number", 3000)) + self.filepath_var.set(cfg.get("filepath", "")) + + # 多行文本 + self.topic_text.delete("1.0", tk.END) + self.topic_text.insert(tk.END, cfg.get("topic", "")) + + self.log("已加载配置。") + else: + messagebox.showwarning("提示", "未找到或无法读取配置文件。") + + def save_config_btn(self): + config_data = { + "api_key": self.api_key_var.get(), + "base_url": self.base_url_var.get(), + "model_name": self.model_name_var.get(), + "topic": self.topic_text.get("1.0", tk.END).strip(), + "genre": self.genre_var.get(), + "num_chapters": self.num_chapters_var.get(), + "word_number": self.word_number_var.get(), + "filepath": self.filepath_var.get() + } + if save_config(config_data, 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 log(self, message: str): + self.log_text.insert(tk.END, message + "\n") + self.log_text.see(tk.END) + + # -------------- 核心功能按钮 -------------- + def disable_button(self, btn): + btn.config(state=tk.DISABLED) + + def enable_button(self, btn): + btn.config(state=tk.NORMAL) + + def generate_full_novel(self): + """生成小说设定 & 目录""" + def task(): + self.disable_button(self.btn_generate_full) + 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() + topic = self.topic_text.get("1.0", tk.END).strip() + genre = self.genre_var.get().strip() + num_chapters = self.num_chapters_var.get() + word_number = self.word_number_var.get() + filepath = self.filepath_var.get().strip() + + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + + self.log("开始生成小说设定和目录...") + Novel_novel_directory_generate( + 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 + ) + self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") + except Exception as e: + self.log(f"❌ 生成小说设定 & 目录时出错: {e}") + finally: + self.enable_button(self.btn_generate_full) + + thread = threading.Thread(target=task) + thread.start() + + def generate_chapter_text(self): + """多步生成章节:维护全局摘要+角色状态文档,向量检索辅助""" + def task(): + self.disable_button(self.btn_generate_chapter) + 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() + novel_number = self.chapter_num_var.get() + filepath = self.filepath_var.get().strip() + word_number = self.word_number_var.get() + + # 读取设定 & 目录 + novel_settings_file = os.path.join(filepath, "Novel_setting.txt") + novel_novel_directory_file = os.path.join(filepath, "Novel_directory.txt") + last_chapter_file = os.path.join(filepath, "lastchapter.txt") + + novel_settings = read_file(novel_settings_file) + novel_novel_directory = read_file(novel_novel_directory_file) + lastchapter = read_file(last_chapter_file) + + if not novel_settings.strip(): + self.log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") + return + if not novel_novel_directory.strip(): + self.log("⚠️ 未找到 Novel_directory.txt,请先生成目录。") + return + + self.log(f"开始生成第{novel_number}章内容(含角色状态文档更新)...") + chapter_text = generate_chapter_with_state( + novel_settings=novel_settings, + novel_novel_directory=novel_novel_directory, + api_key=api_key, + base_url=base_url, + model_name=model_name, + novel_number=novel_number, + filepath=filepath, + word_number=word_number, + lastchapter=lastchapter + ) + + if chapter_text: + self.log(f"✅ 第{novel_number}章内容生成完成。chapter.txt 已更新。") + self.chapter_result.delete("1.0", tk.END) + self.chapter_result.insert(tk.END, chapter_text) + self.chapter_result.see(tk.END) + else: + self.log("⚠️ 本章生成失败或无内容。") + + except Exception as e: + self.log(f"❌ 生成章节内容时出错: {e}") + finally: + self.enable_button(self.btn_generate_chapter) + + thread = threading.Thread(target=task) + thread.start() + + def do_consistency_check(self): + """使用审校Agent对最新章节进行简单一致性或冲突检查""" + def task(): + self.disable_button(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() + filepath = self.filepath_var.get().strip() + + # 读取关键文件 + novel_settings_file = os.path.join(filepath, "Novel_setting.txt") + character_state_file = os.path.join(filepath, "character_state.txt") + global_summary_file = os.path.join(filepath, "global_summary.txt") + last_chapter_file = os.path.join(filepath, "lastchapter.txt") + + novel_setting = read_file(novel_settings_file) + character_state = read_file(character_state_file) + global_summary = read_file(global_summary_file) + last_chapter_text = read_file(last_chapter_file) + + if not last_chapter_text.strip(): + self.log("⚠️ lastchapter.txt 为空,暂无可检查的章节文本。") + return + + self.log("开始一致性审校...") + result = check_consistency( + novel_setting=novel_setting, + character_state=character_state, + global_summary=global_summary, + chapter_text=last_chapter_text, + api_key=api_key, + base_url=base_url, + model_name=model_name + ) + self.log("审校结果:") + self.log(result) + + except Exception as e: + self.log(f"❌ 审校时出错: {e}") + finally: + self.enable_button(self.btn_check_consistency) + + thread = threading.Thread(target=task) + thread.start() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..2c21cfe --- /dev/null +++ b/utils.py @@ -0,0 +1,51 @@ +import os +import json + +def read_file(filename: str) -> str: + """读取文件的全部内容,若文件不存在或异常则返回空字符串。""" + try: + with open(filename, 'r', encoding='utf-8') as file: + content = file.read() + return content + except FileNotFoundError: + return "" + except Exception as e: + print(f"[read_file] 读取文件时发生错误: {e}") + return "" + +def append_text_to_file(text_to_append: str, file_path: str): + """在文件末尾追加文本(带换行)。若文本非空且无换行,则自动加换行。""" + if text_to_append and not text_to_append.startswith('\n'): + text_to_append = '\n' + text_to_append + + try: + with open(file_path, 'a', encoding='utf-8') as file: + file.write(text_to_append) + except IOError as e: + print(f"[append_text_to_file] 发生错误:{e}") + +def clear_file_content(filename: str): + """清空指定文件内容。""" + try: + with open(filename, 'w', encoding='utf-8') as file: + pass + except IOError as e: + print(f"[clear_file_content] 无法清空文件 '{filename}' 的内容:{e}") + +def save_string_to_txt(content: str, filename: str): + """将字符串保存为 txt 文件(覆盖写)。""" + try: + with open(filename, 'w', encoding='utf-8') as file: + file.write(content) + except Exception as e: + print(f"[save_string_to_txt] 保存文件时发生错误: {e}") + +def save_data_to_json(data: dict, file_path: str) -> bool: + """将数据保存到 JSON 文件。""" + try: + with open(file_path, 'w', encoding='utf-8') as json_file: + json.dump(data, json_file, ensure_ascii=False, indent=4) + return True + except Exception as e: + print(f"[save_data_to_json] 保存数据到JSON文件时出错: {e}") + return False From 11c1fb02d87bd5643df313f39d2c21e45eeb2b2e Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 29 Jan 2025 21:59:36 +0800 Subject: [PATCH 002/142] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=9A=E4=BF=AE=E5=A4=8D=E4=BA=86=E6=9C=AC=E5=9C=B0=E5=90=91?= =?UTF-8?q?=E9=87=8F=E5=8C=96=E7=9A=84=E9=94=99=E8=AF=AF=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=86embedding=E6=8E=A5=E5=8F=A3=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + AI_novel/Novel_directory.txt | 401 +++++++++++++----- AI_novel/Novel_setting.txt | 64 ++- AI_novel/chapter.txt | 72 ---- AI_novel/character_state.txt | 38 ++ AI_novel/global_summary.txt | 9 + AI_novel/lastchapter.txt | 72 ++-- __pycache__/config_manager.cpython-310.pyc | Bin 969 -> 969 bytes .../consistency_checker.cpython-310.pyc | Bin 1421 -> 1421 bytes __pycache__/novel_generator.cpython-310.pyc | Bin 9107 -> 11552 bytes .../prompt_definitions.cpython-310.pyc | Bin 5291 -> 5291 bytes __pycache__/ui.cpython-310.pyc | Bin 10499 -> 10499 bytes __pycache__/utils.cpython-310.pyc | Bin 2480 -> 2480 bytes config.json | 2 +- novel_generator.py | 299 ++++++++----- 15 files changed, 628 insertions(+), 333 deletions(-) create mode 100644 .gitignore delete mode 100644 AI_novel/chapter.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..099e0b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/__pycache__ +/AI_novel +/vectorstore +config.json diff --git a/AI_novel/Novel_directory.txt b/AI_novel/Novel_directory.txt index 9d27345..b059ce0 100644 --- a/AI_novel/Novel_directory.txt +++ b/AI_novel/Novel_directory.txt @@ -1,101 +1,302 @@ + 目录 +《星旅提瓦特》 -第1章 :意外的穿越 -第2章 :提瓦特的初印象 -第3章 :神秘的元素之力 -第4章 :初识伙伴 -第5章 :探索孤独的心灵 -第6章 :冲突的种子 -第7章 :寻找归属感 -第8章 :揭开旧生活的面纱 -第9章 :伊尔的内心战争 -第10章 :莱娜的隐秘之谜 -第11章 :不同理念的碰撞 -第12章 :信任的考验 -第13章 :元素的传承 -第14章 :国家的危机 -第15章 :历史的回响 -第16章 :离别的痛苦 -第17章 :意外的救助 -第18章 :二人的矛盾升级 -第19章 :莱娜的秘密身份 -第20章 :勇气与脆弱 -第21章 :信仰的丧失 -第22章 :敌人的阴影 -第23章 :团结的力量 -第24章 :心灵的契约 -第25章 :遭遇的考验 -第26章 :寻找真相的旅程 -第27章 :伊尔的困惑与苦痛 -第28章 :希望的曙光 -第29章 :道德的抉择 -第30章 :揭露阴谋的关键 -第31章 :危机的加深 -第32章 :莱娜的征途 -第33章 :生与死的抉择 -第34章 :攀登的决心 -第35章 :遭遇叛徒 -第36章 :难忘的过往 -第37章 :勇敢面对内心 -第38章 :追寻信念的光芒 -第39章 :重聚的温暖 -第40章 :绝境中的选择 -第41章 :心灵的感召 -第42章 :勇气的火焰 -第43章 :愈合的力量 -第44章 :两个世界的裂痕 -第45章 :莱娜的使命 -第46章 :面对过去的抉择 -第47章 :背后的黑手 -第48章 :团结的契机 -第49章 :意外的力量觉醒 -第50章 :发现真相的第一步 -第51章 :感情的纠葛 -第52章 :信任重建 -第53章 :旧敌的再现 -第54章 :希望与绝望 -第55章 :勇敢的寡言者 -第56章 :天理的试炼 -第57章 :未知的恐惧 -第58章 :内心的挣扎 -第59章 :辉煌的重现 -第60章 :绝地反击 -第61章 :宿命的交汇 -第62章 :与过去的和解 -第63章 :冲突的顶点 -第64章 :理想与现实的抉择 -第65章 :命运的交错 -第66章 :成长的代价 -第67章 :星的决定 -第68章 :失去的痛苦 -第69章 :最后的希望 -第70章 :勇气之源 -第71章 :荣耀之战 -第72章 :爱的牺牲 -第73章 :背负随行 -第74章 :重塑信仰 -第75章 :群星的聚集 -第76章 :命运的逆转 -第77章 :决战的前夜 -第78章 :承载的力量 -第79章 :归者的使命 -第80章 :能否拯救一切 -第81章 :灵魂的救赎 -第82章 :心灵的碰撞 -第83章 :胜利与失败 -第84章 :被遗忘的记忆 -第85章 :阳光再现 -第86章 :信念的拥抱 -第87章 :重生的希望 -第88章 :抉择的时刻 -第89章 :命运的启示 -第90章 :最后的决策 -第91章 :逆光而行 -第92章 :双重的身份 -第93章 :献出所有的希望 -第94章 :心的归宿 -第95章 :重聚与亲密 -第96章 :与敌的对峙 -第97章 :星的觉醒 -第98章 :平衡的重现 -第99章 :团结的结晶 -第100章 :新世界的曙光 \ No newline at end of file +1. 穿越之旅 + - 星在星穹铁道上的意外穿越,开启了提瓦特的新篇章。 + +2. 迷雾中的初遇 + - 星在蒙德初见琴,感受到这片土地的风与友善。 + +3. 蒙德的秘密 + - 星探索蒙德的文化,发现这里隐藏的历史与神秘。 + +4. 骑士团的使命 + - 琴向星介绍骑士团的职责,星逐渐卷入其中。 + +5. 甘雨的来信 + - 星收到来自璃月的信件,甘雨的温暖与关怀让她心生向往。 + +6. 璃月之旅 + - 星前往璃月,体验独特的岩之文化。 + +7. 千年的孤独 + - 星与甘雨初次相遇,分享各自的孤独与渴望。 + +8. 阿贝多的实验 + - 阿贝多邀请星参与炼金实验,引发了一系列搞笑而意外的事件。 + +9. 黑暗势力的影子 + - 黑暗势力开始在提瓦特活动,星感到前所未有的危机。 + +10. 命运的旋涡 + - 星与各国角色的命运交织,感受到身处漩涡中心的无奈。 + +11. 凯亚的神秘 + - 星与凯亚的互动越来越频繁,潜在的情感开始萌芽。 + +12. 面对过往 + - 星通过梦境回忆起自己在星穹铁道的生活与遗憾。 + +13. 稻妻的召唤 + - 星接到前往稻妻的邀请,了解这个国家的雷之文化。 + +14. 雷电将军的统治 + - 星初见雷电将军,感受到她的冷酷与威严。 + +15. 战斗与信念 + - 星卷入稻妻的内部纷争,为正义而战。 + +16. 情感的对峙 + - 琴、甘雨、凯亚之间的情感矛盾逐渐升级。 + +17. 背叛与和解 + - 星被误解与背叛,她努力修复人际关系。 + +18. 水之国的秘境 + - 星探访Fontaine,领略水元素的神秘魅力。 + +19. 纳特兰的热情 + - 前往Natlan,星感受到火的激情与热烈。 + +20. Sumeru的冰雪 + - 星走入Sumeru,迎接无尽的冰与寒冷。 + +21. 智慧的考验 + - 与阿贝多共同追寻智慧的真相,接受炼金术的挑战。 + +22. 情感的困境 + - 星在角色之间的情感纠葛中挣扎,难以选择。 + +23. 友谊的羁绊 + - 星与各国角色逐渐建立深厚的友谊,共同面对危机。 + +24. 黑暗势力的蔓延 + - 邻国的黑暗势力逐渐扩张,威胁提瓦特的和平。 + +25. 群星的呼唤 + - 星感受到神秘力量的召唤,探索自我本源。 + +26. 秘密的真相 + - 星发现自身的特殊能力与提瓦特的关联。 + +27. 智者的对话 + - 星与甘雨探讨信仰与价值观的碰撞。 + +28. 夜幕下的约定 + - 星与凯亚在月光下的对话,情感逐渐升温。 + +29. 战斗的号角 + - 黑暗势力全面进攻,星与伙伴们面对强敌。 + +30. 牺牲与成长 + - 在战斗中有人牺牲,星经历痛苦与成长。 + +31. 团结的力量 + - 各国联合奋起反抗,星成为他们的钱塘。 + +32. 情感的纠葛 + - 角色之间复杂的情感关系达到高潮。 + +33. 历史的回响 + - 星深入探讨提瓦特历史背后的秘密。 + +34. 迷失的自我 + - 星感到无比迷茫,寻找自己的价值与归属。 + +35. 命运的选择 + - 角色们面临各自的命运抉择,星更加坚定。 + +36. 友情的考验 + - 伴随战斗的加剧,友情经受着巨大的考验。 + +37. 爱的蜕变 + - 星与琴之间的感情逐渐升温,内心波动加剧。 + +38. 黑暗的深渊 + - 星与伙伴们深入敌后的冒险,面临生死考验。 + +39. 重拾信念 + - 在黑暗中,星与甘雨共同重拾对信念的坚定。 + +40. 最终的冲突 + - 星与黑暗势力的最终对决,真相逐渐揭晓。 + +41. 团结的胜利 + - 各国角色携手打败黑暗势力,提瓦特迎来光明。 + +42. 内心的归属 + - 星在战后寻找自我的归属,决定留在提瓦特。 + +43. 新的征程 + - 星与伙伴们共同规划下一步的冒险。 + +44. 与琴的告别 + - 雅致的情感告别,琴与星的羁绊愈加深厚。 + +45. 甘雨的承诺 + - 甘雨向星表达深厚的友情与支持。 + +46. 凯亚的反思 + - 凯亚在最终战斗后对自己的态度进行反思。 + +47. 雷电将军的转变 + - 雷电将军在星的影响下开始重新思考自己的信念。 + +48. 新的挑战 + - 星与伙伴们面对新的冒险与挑战,继续探索提瓦特的奥秘。 + +49. 互相扶持 + - 星与朋友在困难时互相扶持,建立更深的友谊。 + +50. 遗失的记忆 + - 星逐渐恢复穿越前的记忆,理解自身的命运。 + +51. 彼岸的希望 + - 星与伙伴们共同追寻实现梦想的希望。 + +52. 未来的选择 + - 角色们对未来进行深思,探索各自的可能。 + +53. 团体的温暖 + - 在冒险中重温团队间的温暖与支持。 + +54. 残留的悔恨 + - 星感受过去的悔恨与无奈,努力向前。 + +55. 神秘的力量 + - 星探索神秘力量与提瓦特的结合。 + +56. 爱的边界 + - 星思考情感的定义,突破爱的边界。 + +57. 全新的开始 + - 各角色在经历洗礼后,迎接全新的开始。 + +58. 不屈的信仰 + - 角色们坚定信仰,团结共同拯救提瓦特。 + +59. 重拾勇气 + - 在战斗与磨难中,星与伙伴们再次重拾勇气。 + +60. 未来的希冀 + - 星对未来设定新的希望与目标。 + +61. 历史的传承 + - 星逐渐明白提瓦特历史的传承与意义。 + +62. 迷失的难题 + - 星面临内心迷失的问题,努力找到解答。 + +63. 永恒的承诺 + - 角色们对彼此许下永恒的承诺与信任。 + +64. 心灵的感应 + - 星与伙伴们之间建立特殊的心灵感应。 + +65. 茫茫星海 + - 星思考自己所站的位置,在浩瀚星海中感到渺小。 + +66. 时间的流转 + - 在经历日月交替中,角色们逐渐成长。 + +67. 生活的真谛 + - 星领悟生活的真谛与意义,与角色们共同分享。 + +68. 团体的凝聚 + - 团队的凝聚力在冒险中进一步加强。 + +69. 信念的重构 + - 角色们对信念进行重构,理解不同的信仰。 + +70. 心灵的告白 + - 角色们在特定场合进行深情告白,情感达到了顶点。 + +71. 挑战的延续 + - 随着冒险的深入,角色们面对新的挑战与冒险。 + +72. 心灵的同行 + - 星与角色们沿途的心灵交流,增进理解与信任。 + +73. 困扰的阴影 + - 星面临来自内心阴影的困扰,努力寻求解决。 + +74. 踏上归途 + - 星决定在提瓦特长久地生活,而非返回原来的世界。 + +75. 爱的升华 + - 星与琴、甘雨的爱在困扰中升华出新层次。 + +76. 新的开始 + - 星与伙伴们共同踏上新的冒险之旅。 + +77. 艰难的抉择 + - 各角色面临自己的抉择,做出新的决定。 + +78. 追寻真相 + - 星深入探寻提瓦特的核心秘密,与朋友们并肩作战。 + +79. 心灵的重建 + - 各角色在情感的波折中重建自己的内心世界。 + +80. 无畏的冒险 + - 与伙伴们一起,不畏艰险继续冒险。 + +81. 承诺的反思 + - 分开与相聚中,角色们不断反思彼此的承诺。 + +82. 明亮的未来 + - 星展望稳定与和平的未来,与伙伴们共同祈愿。 + +83. 精神的力量 + - 星意识到精神力量在冒险过程中的重要性。 + +84. 合作的喜悦 + - 在面对挑战时,角色们愈加体会到合作的魅力。 + +85. 内心的力量 + - 星在冒险中探索内心的力量与可能性。 + +86. 失落的记忆 + - 星逐渐恢复关于提瓦特的重要记忆,涌现出久违的温暖。 + +87. 团结的信念 + - 在新的旅途中,各国团结一心,捍卫家园。 + +88. 挑战的巅峰 + - 对抗黑暗势力的最终巅峰,所有角色齐心协力。 + +89. 转折的温柔 + - 星与伙伴们在战斗中涌现脆弱之心,增进情感。 + +90. 爱的告白 + - 星为自己珍惜的情感付出,开启告白之旅。 + +91. 希望的光芒 + - 战斗之后,星与伙伴们共同追寻新的希望。 + +92. 信仰的纽带 + - 各角色在信仰上建立起新的纽带。 + +93. 永恒的牵绊 + - 星与伙伴们对彼此的承诺与牵绊更加坚定。 + +94. 最终的归宿 + - 星对自己在提瓦特的归宿有了清晰的认识。 + +95. 团体的梦想 + - 各角色分享对未来的梦想,愿景永不消逝。 + +96. 温暖的重聚 + - 在冒险后,星与伙伴们的重聚,感受到温暖。 + +97. 爱的升华 + - 星与众伙伴的情感达到新的高度,相守相伴。 + +98. 珍惜的时光 + - 彼此珍惜的时光让角色们的关系更加紧密。 + +99. 新的篇章 + - 星准备开启新的篇章,与伙伴们一同探索未知。 + +100. 与星同行 + - 星在提瓦特生活与冒险的美好结尾,成为真正的家园。 diff --git a/AI_novel/Novel_setting.txt b/AI_novel/Novel_setting.txt index 84c7cd1..e8a2b61 100644 --- a/AI_novel/Novel_setting.txt +++ b/AI_novel/Novel_setting.txt @@ -1,36 +1,54 @@ + 最终作品设定 -小说设定 + 小说名称:《星旅提瓦特》 -小说名称: 星陨提瓦特 + 主题 +在遥远的星空彼端,星穹铁道的主角星在一次意外中穿越到原神的提瓦特大陆,面对黑暗势力的威胁,她肩负起拯救这片土地的重任。在这个充满元素力量的新世界中,星与来自不同国家、不同背景的角色们建立起紧密的情感联系,爱恨交织,最终揭开了隐藏在历史背后的秘密。 -总字数走向: 约40万字,分为100章,每章约4000字。整体走向为主角经历成长、探索与冲突,逐渐揭开提瓦特大陆的秘密与危机,最终实现拯救与情感的圆满。 + 类型 +玄幻、冒险、爱情 -小说类型与基调: 本作为玄幻、穿越与冒险题材,主线围绕丰富的爱恨情仇展展开。基调相对轻松,同时穿插挑战与深思,旨在营造出一种既有趣又引人深思的氛围。 + 故事设定 +- 背景世界:提瓦特大陆分为七个国家,代表不同元素力量,包括蒙德(风)、璃月(岩)、稻妻(雷)、须弥(草)、Fontaine(水)、Natlan(火)和Sumeru(冰)。每个国家都有独特的文化、信仰和神明,星必须探索这些国家,寻找力量与盟友,以对抗威胁提瓦特的邪恶势力。 -写作风格: 采用细腻、抒情的写作风格,重点描绘角色内心的探索与情感。叙事视角主要为第三人称全知视角,辅以第一人称的内心独白,以增强角色深度。 +- 主角:星,一名20岁的年轻女性,星穷铁道的乘客,因意外穿越到提瓦特。性格坚韧、不屈,内心渴望寻求归属与理解。她拥有跨越宇宙的能力,能感应元素力量,随着故事的发展,她逐渐发现自己与提瓦特的元素力量有着密切联系。 -整体世界观: 故事设定在提瓦特大陆,这是一个拥有七个不同国度和独特元素力量的神秘幻想世界。历史与传说丰富,比如“天理之战”及各个神祇的传奇故事。科技与魔法并存,元素魔法高度发达,社会结构以王国与神明为核心,复杂的势力交错使人民生活受其影响。 +- 主要角色: + - 琴:25岁,蒙德骑士团副团长,勇敢且正义感强,对星的好奇与保护欲逐渐转化为深厚的情感,面临职责与情感的内心矛盾。 + + - 甘雨:外形20多岁,千岁的璃月半仙,聪慧温柔,与星结成伙伴关系,逐渐对星产生复杂的情感,因使命感而感到孤独。 + + - 凯亚:28岁,蒙德骑士,幽默而神秘,与星之间的互动充满火花,暗生好感,却因性格使然表现得轻佻。 + + - 雷电将军:未知年龄(看似30岁),稻妻的统治者,冷酷无情。星的出现让她开始反思自己的信念,内心对星逐渐产生吸引。 + + - 阿贝多:24岁,璃月的炼金术士,追求真理与知识。对星的独特吸引力使他更加关注她,两人之间的互动充满潜在的火花。 -核心内容梗概: 故事采用英雄之旅结构,主角星因意外穿越到提瓦特,面临神明失去信仰、元素失衡,国家动荡的空前危机。在寻找回家的过程中,星结识了来自不同国家的伙伴,体验各地人文风情,并逐渐揭示穿越的真相。最终,星与伙伴们合力对抗共同的敌人,重塑世界的平衡,也收获真挚的情感。 + 故事进展 +- 第一章:穿越之旅:星在乘坐星穹铁道时遭遇神秘现象,被传送到提瓦特大陆,初次感受到这里的魔法与危险。 +- 中段:星逐渐适应提瓦特生活,遇见各国角色并与他们建立关系,发展情感。同时,黑暗势力逐渐显现,开始捣乱。 +- 爱恨交错的情感:星与不同角色之间情感波动形成复杂的三角关系与误会,推动情节发展与冲突。 +- 高潮:各国联合对抗黑暗势力,星发现隐藏的真相,角色间的关系在生死考验中经历波折。 +- 结局:星经过艰辛战斗,拯救提瓦特,找到了内心的归属,决定留在提瓦特继续她的冒险。 -情节安排设想: 主线围绕星的成长与拯救提瓦特的冒险分为三个阶段:第一阶段是星的穿越与初识,以及角色冲突的引入;第二阶段是与伙伴的深入互动,逐步揭露敌人阴谋及情感发展;第三阶段是最终决战与角色命运交织。副线聚焦其他角色的故事,展示他们与星的命运交织,为故事营造丰富的世界感。 + 章节安排 +- 总章节数:100章,每章约4000字。章节围绕故事的重大事件、角色关系发展及情感冲突展开,穿插战斗场景与情感对话,逐渐推进主线与副线,构建深厚的世界观。 -角色关系与主要角色设定: + 预期效果 +通过这部小说,读者将体验星穹铁道与原神世界的交融,感受到精彩的冒险故事与复杂的人际关系中爱的珍贵与痛苦。 -1. 星(主角): 一名勇敢、好奇且善良的年轻人,满怀探索未知的欲望,面临对归属感的挣扎。在拯救提瓦特的过程中,她渴望成为真正的英雄,同时也处理与伙伴间复杂的情感关系。 +--- -2. 伊尔(冷静的魔法师): 理智、谨慎,拥有强大师法的他在经历失败后变得冷淡,内心深处隐藏痛苦。希望能通过知识与能力解决危机,努力解脱自己的内心痛楚。与星的理想与现实相悖,易引发冲突。 + 角色关系发展 +- 随着故事的发展,角色与星的关系在战斗与情感交流中不断变化,形成复杂的三人关系和误会,推动情节发展。每位角色在星的影响下找到了各自的定位与归属,形成更为团结的团队,共同守护提瓦特的未来。 -3. 莱娜(隐秘的元素使者): 神秘而强大的她深谙元素魔法,在寻找与星的关系中,探索自己的身份之谜。与伊尔之间存在信任危机,随着故事发展,二人将面临信任与情感的考验。 + 敏感线及处理限制 +在故事中,涉及到复杂情感关系与角色成长的敏感线须谨慎处理,以避免负面影响: +1. 情感纠葛:避免过于戏剧化的情感对立,确保角色情感发展符合逻辑。 +2. 信念冲突:尊重各角色的信念,展现多元化价值观,而非仅单一立场。 +3. 责任与牺牲:描绘牺牲时避免血腥或悲惨的过度描写,确保具有深刻意义。 +4. 文化差异:尊重各国文化,避免采用刻板印象,准确表现各国背景。 +5. 性别角色:展现女性角色的多维性与复杂性,避免单一化形象。 +6. 身份认同:积极探讨归属感与自我认同,保障角色内心旅程的积极性。 -暗线与伏笔构思: - -1. 星的过往: 星对旧生活的怀念与痛苦逐渐显现,推动她在救助他人和回归原生活之间的抉择,进而引发与伙伴之间的深入讨论。 - -2. 伊尔的秘密: 伊尔对过去的隐瞒以及与敌对势力的牵连,在关键时刻暴露,导致团队关系动摇,让星更深刻地理解他的痛苦。 - -3. 莱娜的使命: 莱娜的神秘身份及意图逐步显露,最终必须面对身份的真实,仅在关键时刻,有赖于星与伊尔的支持,坚定她内心的归属感。 - -4. 星与伊尔的理念冲突: 星与伊尔在道德取向上发生摩擦,在选择重大事件时,两人的冲突达到顶峰,最终通过事件的解决促进彼此之间的理解与成长。 - -在这些设定中,角色的动机与暗线及伏笔相互交织,为整个故事增添了复杂性与紧张感,确保每一位角色的成长与情感发展贯穿始终,丰富了提瓦特大陆的冒险传奇。 \ No newline at end of file +通过谨慎处理这些敏感线,小说将更加深刻和动人,吸引读者产生共鸣。 diff --git a/AI_novel/chapter.txt b/AI_novel/chapter.txt deleted file mode 100644 index 075f280..0000000 --- a/AI_novel/chapter.txt +++ /dev/null @@ -1,72 +0,0 @@ - -星缓缓睁开眼睛,意识到自己正躺在一片陌生的土地上。四周是葱茏的树木,阳光透过树叶洒下斑驳的光影,微风轻拂,带来一丝清新的气息。她试图站起身来,身体却感到无力,仿佛刚经历了一场翻天覆地的梦境。迷茫与恐惧瞬间涌上心头——她究竟身处何地? - -“别 panicking,星。”她自言自语,努力让自己冷静下来。可无论她怎么深呼吸,心中的慌乱与不安依旧如影随形。刚才她还在自己的家中,思绪被一扇打开的窗户吸引,目光追随着飞过的鸟儿,突然遭遇了一道神秘的光芒,她的意识就在那瞬间被撕扯了开来。而现在,她却身处于这个陌生的世界,这一切都让她感到无比的恐慌。 - -尝试坐起身来,星环顾四周,发现这片森林似乎与她的记忆截然相反。树木高耸入云,树干粗壮而苍翠,叶子则在微风中轻轻摇晃,发出沙沙的声响。然而,这些美丽的自然景象并没有为她带来一丝安慰——相反,她觉得一种不安的气息弥漫在空气中,仿佛这片森林隐藏着某种不为人知的秘密。 - -星的心中掠过一丝奇异的感觉,似乎这片土地上有着某种她无法理解的力量在流动。她强迫自己站起身,决定探索这个世界。若想办法找到回家的路,先得了解这个地方。她开始慢慢前行,脚下的土地干燥而坚硬,偶尔踩到几根枯枝,发出清脆的声响。 - -随着深入森林,她注意到周围的植物似乎散发着微弱的光芒,仿佛在回应着她的存在。星试图触摸那些植物,感受那股能量,却发现它们在她的手指间划过时变成了莹莹的光点,瞬间就消散了。她心中一紧,这显然不是普通的植物,她的直觉告诉她,自己可能身处于一个充满魔法的地方。 - -就在这时,她的思绪被一阵低语声打断。星停下脚步,四处张望,试图找到声音的来源。声音似乎从四面八方传来,但却又似乎是从某个深渊中回荡而出,朦胧而模糊。 - -“是谁在说话?”星喃喃自语,然而没有人回应。她心中浮现出一丝不安,迫切想要找到声音的源头。正当她准备继续探索时,眼前突然闪过一道亮光。她的心一颤,抬头看去,透过树木的缝隙,她隐约看到一个身影,似乎在远处的空旷地带移动。 - -出于好奇,星忍不住向前走去,穿越过一片茂密的灌木丛。终于,她走出林间,来到了一个开阔的空地上。眼前的一幕让她惊讶不已——一座宏伟的法阵正镶嵌于地面,阵法复杂而美丽,闪烁着微光,似乎在向她召唤。 - -“这是什么?”星自言自语,心中充满了疑惑与好奇。她走到法阵边缘,感觉到一股温暖的气流从阵法中透出。星的手指微微颤动,不由自主地伸向了那个法阵,触碰到了那些复杂的符文。瞬间,她感到一阵剧烈的震动,法阵光芒大盛,星的身体被卷入其中,意识在瞬间模糊而又清晰。 - -“啊!”她惊呼,仿佛被一股无形的力量撕扯着。整个世界都在颠覆,光芒刺眼,四周的景象如同狂风中的飘絮般不断变换,星感到自己像是随风而舞的叶子,无法控制。就在意识即将消散的瞬间,她听到了那个低语声,竟然清晰而响亮:“命运的选择。” - -然后,星失去了意识。 - -当她再次睁开眼睛时,发现自己坐在一块石头上,周围是一片开阔的草地,远处是连绵起伏的山脉,天空湛蓝,阳光温暖,仿佛一切都是那么美好。可在她心中,却依然残留着那股恐惧与不安。 - -“这是哪里?”她自言自语,四处打量。就在这时,一个阴影笼罩了她,星抬头一看,面前站着一个高大的男子,身穿长袍,手中握着法杖,面容冷峻,眼神中透着智慧与审视。 - -“你是谁?”星有些警惕地问道,虽然心中已经有了几分预感。 - -“我是伊尔,一个魔法师。”男子的声音低沉而有力,似乎带着某种压迫感,“你应该是从那个法阵中被传送过来的吧?” - -星点点头,虽然内心依旧充满困惑,但她知道自己必须与眼前这个陌生人好好交流。“是的,我…我不知道自己为什么来到这里。” - -伊尔微微皱眉,目光在她身上扫视,似乎在打量她的言语是否可信。“这片土地并不安全,你的到来可不是什么偶然。元素的平衡正在崩溃,神明的意志也逐渐淡去。” - -“神明的意志?”星困惑地问道,心中浮现出关于提瓦特的传说与故事。她从小就听说过这个富饶而神秘的大陆,但从未想过自己会置身其中。 - -“没错,”伊尔坚定地点头,“提瓦特大陆上,七位神明各自守护着一个国度,而你在这里的出现,可能与某种更大的命运息息相关。” - -“我只想回家。”星低声说道,心中依然渴望着熟悉的生活,尽管她也感受到这个新的世界是如此吸引人。 - -“回家并不是那么简单。”伊尔的声音中透着冷静,他的眼神中闪烁着某种复杂的情感,“在这片土地上,你必须要面对许多你无法想象的挑战。” - -星感到了一丝绝望,她并不想成为一名英雄,自己不过是一名普通的年轻人。“我……我能做些什么?” - -“或许我们可以一起探索这个世界。”伊尔的语气缓和了一些,似乎对她产生了一丝信任,“我对魔法的研究或许能帮助你找到线索。” - -“谢谢你,伊尔。”星感激地点头,虽然她对眼前的陌生人依然有些顾虑,但她知道自己必须要有人陪伴,才能在这个陌生的世界中生存下去。 - -就在这时,草地的另一侧,一道神秘的光芒闪过,随即出现了另一个身影。那是一位少女,长发披肩,身穿轻盈的衣袍,眼神深邃而神秘,她似乎在默默地注视着星与伊尔。 - -“你是……”星愣了一下,有些惊讶。 - -“我叫莱娜。”少女轻声说道,她的声音如同空灵的风铃,给人一种奇特的亲切感,“我感觉到了你的到来,希望能和你们一起探索这个世界。” - -“你也感知到了?”伊尔微微皱眉,似乎对莱娜的出现有些意外,却又没有质疑的意思。 - -“是的。”莱娜的眼神透着某种不易察觉的情感,“我对自然元素有着强烈的感应,能够感受到世界的变化。” - -星心中一震,莱娜的出现让她感到了一丝安慰,也增添了对这个世界的探索欲望。“或许我们可以一起合作,找出这个世界的秘密。” - -莱娜微微一笑,仿佛认可了星的提议。“我相信,我们的命运将会交织在一起。” - -三人之间逐渐形成了一种微妙的默契。尽管星对这个新世界的未来仍感到模糊不清,内心的惶恐也未完全消散,但在伊尔与莱娜的陪伴下,她逐渐意识到自己不再孤单。接下来,等待她的将是未知的冒险,她必须做好面对的准备。 - -正当星准备问伊尔关于这个世界的更多事情时,突然,一股强烈的能量波动猛然袭来,她耳边仿佛听到了那道低语的声音再度浮现,清晰而坚定:“命运的选择,终于来临!” - -星的心脏骤然加速,四周的空气似乎在那一瞬间凝固。她抬头,看到远处的天空中,一片阴云迅速聚集而来,预示着某种危险的逼近。一个模糊的影像在天际闪现,似乎正暗示着未来的冲突与挑战,而她所要面对的,绝不仅仅是对自我归属的探索。 - -“我们要走!”伊尔的声音打断了星的思绪,他的目光变得凝重,“不可忽视的危机即将降临,我们必须立刻找到安全的地方!” - -星与莱娜对视一眼,心中都涌起一股紧迫感。对即将来临的挑战,三人并肩而立,准备迎接未知的未来。此刻,她们的命运已经交织在了一起,故事的篇章才刚刚开启,而未来的旅程充满了未知与悬念。 \ No newline at end of file diff --git a/AI_novel/character_state.txt b/AI_novel/character_state.txt index e69de29..766fbc4 100644 --- a/AI_novel/character_state.txt +++ b/AI_novel/character_state.txt @@ -0,0 +1,38 @@ +### 更新后的角色信息 + +#### 星(主角) +- **年龄**: 20岁 +- **背景**: 原本生活在大都市的普通女性,因对归属感的渴望而选择乘坐星穹铁道,意外穿越到了提瓦特的异世界。性格内向而敏感,心中充满探索未知的渴望,也对勇气与坚定有着深深的向往。在穿越前,常感孤独,并在快节奏的生活中迷失。希望找到属于自己的位置。 +- **当前状态**: 刚经历第一次战斗,成功调动了风的元素力量,感受到力量带来的惊喜与恐惧。与琴建立了更深的信任关系,并对凯亚产生了好奇。内心对“失落者”的传闻感到不安,迫切想探索更多秘密。 +- **持有物品**: 发现并拥有一枚神秘的元素徽章,能够帮助她更好地感应周围的元素力量。 +- **能力变化**: 初步掌握了风的元素力量,能够调动周围空气并产生简单的风力效果。对元素力量的认知和控制有所提高,但仍感到不安。 + +#### 琴 +- **年龄**: 25岁(推测) +- **身份**: 蒙德骑士团副团长 +- **背景**: 作为一名优秀的骑士,琴责任心强,性格坚定而富有魅力。她总是尽力保护弱者,并热心助人。随着与星的互动加深,逐渐对星的潜力有了更深的认识,并愿意将自己的经验传授给她。 +- **当前状态**: 在与星的合作中,感受到自身责任感的加重,积极支持星的成长和探索。虽然因战斗受了些许伤,但依然充满信心。 +- **能力变化**: 通过指导星,她在观察中感受到自己的领导能力在逐渐增强,并对元素力量的理解也在加深。 + +#### 凯亚 +- **年龄**: 28岁(推测) +- **身份**: 蒙德骑士 +- **背景**: 个性幽默而神秘,内心深处隐藏着自己的故事与秘密。他对星产生了浓厚的兴趣,并希望给予帮助,打算在日后更多地与星互动。 +- **当前状态**: 在观察星与琴的互动中,感受到两者之间的信任逐渐加深,暗自思考如何以自己独特的方式帮助她。同时,他对于“失落者”的传闻也开始感到不安。 +- **能力变化**: 在星的成长过程中,他开始适时提供帮助,利用骑士团的资源,提升自己在团体中的影响力。 + +### 角色关系与动态 +- **星与琴**: 在共同战斗和探索中,两者的信任关系愈加深厚,星逐渐在琴身上看到勇气与责任感。琴将继续引导星应对未来的挑战。 +- **星与凯亚**: 凯亚对星的关注逐渐加深,可能会在未来向星提供帮助与指导,星对此感到好奇与期待。 +- **琴与凯亚**: 两者之间默契依旧,琴对凯亚的调侃逐渐演变为互相支持,形成了良好的团队氛围。 + +### 正在发生的重要事件 +1. **战斗事件**: 星首次参与战斗,成功调动元素力量击退风狼,展示出她在新环境中的适应能力与潜力。此战斗让星的力量得到了初步的验证与掌握。 +2. **探索蒙德城**: 星与琴一同游览蒙德城,了解提瓦特的文化,努力寻找归属感。在探索过程中,星的内心感受到归属的渴望逐渐增强。 +3. **黑暗势力的威胁**: 关于“失落者”的传言开始在城镇中流传,星的内心感到一丝不安,暗示着未来的挑战。星开始对这股黑暗势力的成因与目的产生疑问。 + +### 新增角色 +- **无**: 目前未新增重要角色。 + +### 总结 +在这一章节中,星与琴通过合作加深了彼此的理解与信任,而凯亚则为星的旅程增添了神秘与支持。星经历了首次战斗,展现了她的潜力与勇气,但同时也面临对力量无法完全掌控的恐惧。随着黑暗势力的威胁逐渐显露,星的冒险将进一步展开,她的使命、归属感以及与伙伴间的关系将随着故事的发展不断深化。 \ No newline at end of file diff --git a/AI_novel/global_summary.txt b/AI_novel/global_summary.txt index e69de29..c476b5e 100644 --- a/AI_novel/global_summary.txt +++ b/AI_novel/global_summary.txt @@ -0,0 +1,9 @@ +### 更新后的全局摘要: + +故事的主角星是一名二十岁的年轻女性,渴望寻找归属感。在一次神秘的星穹铁道旅行中,列车突然发生剧烈震动,星被一种强大的力量吸引,随即穿越时空,经历了奇异的景象。当她醒来时,发现自己身处一个生机勃勃的新世界——提瓦特。 + +在这个陌生的世界中,星感受到一种元素的力量,但也遭遇了受到黑暗力量影响的野兽。在危急时刻,一位名叫琴的骑士及时救下了她,并成为她在此世界的向导和首个伙伴。琴向星介绍了提瓦特的各国文化及其信仰,并带她游览蒙德城,逐渐让星感受到归属感。然而,星的直觉告诉她这片土地并不如表面般美好,暗影中的身影让她倍感不安。 + +在与琴的互动中,星对骑士的勇气与责任感心生向往,决心努力变得勇敢。在一次遭遇强大风狼的战斗中,琴展现了无畏与坚定,而星在琴的鼓励下,首次成功调动了元素力量,击退了风狼,增强了自信与能力。此时,另一位骑士凯亚的出现为她们的冒险增添了更多悬念与期待。 + +随着星、琴和凯亚之间的信任与默契加深,她逐渐意识到提瓦特的黑暗势力正在潜藏,威胁着这个美丽的世界。在战斗与温馨的互动之后,星发誓要找到自己来到这个世界的原因,并与新的伙伴们一起面对即将到来的挑战。她的冒险才刚刚开始,未来的旅程将充满挑战与奇迹,同时隐藏着更多未知的秘密等待她去探索与揭示。 \ No newline at end of file diff --git a/AI_novel/lastchapter.txt b/AI_novel/lastchapter.txt index 8b609e7..d947426 100644 --- a/AI_novel/lastchapter.txt +++ b/AI_novel/lastchapter.txt @@ -1,71 +1,69 @@ -星缓缓睁开眼睛,意识到自己正躺在一片陌生的土地上。四周是葱茏的树木,阳光透过树叶洒下斑驳的光影,微风轻拂,带来一丝清新的气息。她试图站起身来,身体却感到无力,仿佛刚经历了一场翻天覆地的梦境。迷茫与恐惧瞬间涌上心头——她究竟身处何地? +在刚刚经历过激烈战斗的蒙德城,傍晚的阳光透过云层洒下金色的光辉,为这座充满生机的城市增添了一层温暖的纱衣。星和琴并肩走回城镇,琴微微侧身,依然带着战斗后的兴奋与满足,星却感受到心中的复杂情绪在不断翻涌。她刚刚第一次施展出元素力量,尽管成功地击退了风狼,内心的雀跃却因为随之而来的恐惧而逐渐淡化。 -“别 panicking,星。”她自言自语,努力让自己冷静下来。可无论她怎么深呼吸,心中的慌乱与不安依旧如影随形。刚才她还在自己的家中,思绪被一扇打开的窗户吸引,目光追随着飞过的鸟儿,突然遭遇了一道神秘的光芒,她的意识就在那瞬间被撕扯了开来。而现在,她却身处于这个陌生的世界,这一切都让她感到无比的恐慌。 +“你真的做得很好,星。”琴的声音如同一阵温暖的微风,轻轻拂过星的脸庞,然而星却只能微微一笑,心中却难以平静。她的内心仿佛被一层迷雾笼罩,虽然在琴的鼓励下,她感受到了一丝勇气,但对力量的惧怕依然如影随形。 -尝试坐起身来,星环顾四周,发现这片森林似乎与她的记忆截然相反。树木高耸入云,树干粗壮而苍翠,叶子则在微风中轻轻摇晃,发出沙沙的声响。然而,这些美丽的自然景象并没有为她带来一丝安慰——相反,她觉得一种不安的气息弥漫在空气中,仿佛这片森林隐藏着某种不为人知的秘密。 +“谢谢你,琴……”星低声说道,目光游离在城镇的街道上。眼前的景象虽然美好,但她却感到一阵无形的压迫感,仿佛有一双无形的手在她的心头紧紧握住,无法呼吸。 -星的心中掠过一丝奇异的感觉,似乎这片土地上有着某种她无法理解的力量在流动。她强迫自己站起身,决定探索这个世界。若想办法找到回家的路,先得了解这个地方。她开始慢慢前行,脚下的土地干燥而坚硬,偶尔踩到几根枯枝,发出清脆的声响。 +正当她沉浸在思考之中时,琴突然停下了脚步,眼中闪过一丝困惑。“你听到了吗?城里似乎有些不同寻常。”琴的眉头微微皱起,耳边响起了低沉的低语声,似乎是从远处传来。星定神一听,果然在街道的尽头,有人群围着正在交谈,低语声隐约提到黑暗的力量与失踪的居民。 -随着深入森林,她注意到周围的植物似乎散发着微弱的光芒,仿佛在回应着她的存在。星试图触摸那些植物,感受那股能量,却发现它们在她的手指间划过时变成了莹莹的光点,瞬间就消散了。她心中一紧,这显然不是普通的植物,她的直觉告诉她,自己可能身处于一个充满魔法的地方。 +“我们去看看。”星的心中瞬间燃起一丝好奇与紧迫感,虽然对未知的事情感到害怕,但内心的探险欲望却推着她向前。她与琴一起穿过熙熙攘攘的人群,朝着传言的来源走去。 -就在这时,她的思绪被一阵低语声打断。星停下脚步,四处张望,试图找到声音的来源。声音似乎从四面八方传来,但却又似乎是从某个深渊中回荡而出,朦胧而模糊。 +当她们接近人群时,星注意到围着一名老者,他苍白的脸上满是忧愁,声音颤抖地讲述着黑暗势力的传说。围观的人们听得津津有味,不时低声讨论。星心中顿时一紧,这些黑暗势力的传闻正是她在穿越到这个世界后,心中深埋的恐惧。那种感觉就像是一场梦魇,时刻在她的背后徘徊。 -“是谁在说话?”星喃喃自语,然而没有人回应。她心中浮现出一丝不安,迫切想要找到声音的源头。正当她准备继续探索时,眼前突然闪过一道亮光。她的心一颤,抬头看去,透过树木的缝隙,她隐约看到一个身影,似乎在远处的空旷地带移动。 +“几日前,我的小儿子失踪了。”老者紧握着手中的拐杖,声音颤抖得如同风中摇曳的树叶。他的脸上充满了无助与绝望,似乎在周围人们的注视下,所有的悲伤和愤怒都化为一股无形的压迫感。 -出于好奇,星忍不住向前走去,穿越过一片茂密的灌木丛。终于,她走出林间,来到了一个开阔的空地上。眼前的一幕让她惊讶不已——一座宏伟的法阵正镶嵌于地面,阵法复杂而美丽,闪烁着微光,似乎在向她召唤。 +“听说是黑暗势力在作怪,他们要吞噬这个世界的光明,吞噬一切。”人群中有人附和着老者的话,众人开始小声议论,恐惧在空气中弥漫。星的心中一紧,一种不详的预感油然而生。 -“这是什么?”星自言自语,心中充满了疑惑与好奇。她走到法阵边缘,感觉到一股温暖的气流从阵法中透出。星的手指微微颤动,不由自主地伸向了那个法阵,触碰到了那些复杂的符文。瞬间,她感到一阵剧烈的震动,法阵光芒大盛,星的身体被卷入其中,意识在瞬间模糊而又清晰。 +“琴,我们应该调查一下。”星不自觉地握紧了拳头,虽然内心仍有惶恐,但她渴望了解真相,想要找到那些失踪的同胞,想要揭开这不可名状的恐怖面纱。 -“啊!”她惊呼,仿佛被一股无形的力量撕扯着。整个世界都在颠覆,光芒刺眼,四周的景象如同狂风中的飘絮般不断变换,星感到自己像是随风而舞的叶子,无法控制。就在意识即将消散的瞬间,她听到了那个低语声,竟然清晰而响亮:“命运的选择。” +“是的,我们必须对此保持警惕。”琴果断地点头,决心在星的眼前闪烁。她的重要性在于对星的支持,随着她们的旅行,身为副团长的琴也意识到,保护自己身边的伙伴同样是她的职责。 -然后,星失去了意识。 +二人跟随老者的讲述,逐渐吸引了周围人们的注意。就在这时,星的目光不由自主地朝人群的另一侧飘去,一名神秘的流浪者吸引了她的注意。他身材瘦削,裹着一件破旧的斗篷,脸上有些阴影,难以看清其真实的面容。他静静地站在一旁,似乎与周围的喧嚣格格不入。 -当她再次睁开眼睛时,发现自己坐在一块石头上,周围是一片开阔的草地,远处是连绵起伏的山脉,天空湛蓝,阳光温暖,仿佛一切都是那么美好。可在她心中,却依然残留着那股恐惧与不安。 +“你在找什么?”星不知为何,被他的身影吸引,向他们走去。流浪者的眼中闪烁着不同寻常的光芒,仿佛看透了一切,轻声说道:“我在寻找星星。”他的声音柔和,却透着一种诡异的神秘感。 -“这是哪里?”她自言自语,四处打量。就在这时,一个阴影笼罩了她,星抬头一看,面前站着一个高大的男子,身穿长袍,手中握着法杖,面容冷峻,眼神中透着智慧与审视。 +“星星?”星一愣,心中燃起了一种莫名的好奇。耳边传来的低语声似乎在召唤她,流浪者的言语如同钥匙,打开了她心中的那扇门。“你知道关于这片土地的事情吗?”她努力抑制着内心的热切,尽量让自己的声音听起来平静。 -“你是谁?”星有些警惕地问道,虽然心中已经有了几分预感。 +流浪者微微一笑,眼神中透出一种难以言喻的智慧,“这里的一切都是有联系的,尤其是你与那些失落者。”他的语气中透着一丝神秘,仿佛暗示着她与黑暗势力之间的某种联系。 -“我是伊尔,一个魔法师。”男子的声音低沉而有力,似乎带着某种压迫感,“你应该是从那个法阵中被传送过来的吧?” +“失落者……”星喃喃自语,心中涌起一阵不安。她的直觉告诉她,这个流浪者并不简单,或许他知道她所不知的真相。 -星点点头,虽然内心依旧充满困惑,但她知道自己必须与眼前这个陌生人好好交流。“是的,我…我不知道自己为什么来到这里。” +就在这时,琴从一旁走过来,皱眉看着流浪者,语气中透出一丝警惕:“你是谁?为什么要在这里说这些?你知道些什么吗?”琴的声音如同风暴前的宁静,隐含着不安与防备。 -伊尔微微皱眉,目光在她身上扫视,似乎在打量她的言语是否可信。“这片土地并不安全,你的到来可不是什么偶然。元素的平衡正在崩溃,神明的意志也逐渐淡去。” +“我只是个流浪者,听闻真相的人很多,但愿意讲述的人却少之又少。”流浪者微微一笑,似乎对琴的警惕不以为意。他的声音如同夜风,轻柔却充满力量。“然而,星,你的出现是命运的调和,而失落者的命运与整个提瓦特息息相关。” -“神明的意志?”星困惑地问道,心中浮现出关于提瓦特的传说与故事。她从小就听说过这个富饶而神秘的大陆,但从未想过自己会置身其中。 +星的表情微微一变,心中既兴奋又恐惧。她知道自己与这个世界的联系正在逐渐清晰,但流浪者的话却让她感到更深的困惑。“我与失落者有什么关系?”星忍不住问道,心中充满了疑惑。 -“没错,”伊尔坚定地点头,“提瓦特大陆上,七位神明各自守护着一个国度,而你在这里的出现,可能与某种更大的命运息息相关。” +流浪者并没有直接回答她,反而指向天空,目光中透露出一丝沉思:“星星在夜空中闪烁,它们承载着无数个故事和秘密,唯有那些能够倾听的人才能找到答案。”他的话语如同星辰般神秘,让星的思绪越发模糊。 -“我只想回家。”星低声说道,心中依然渴望着熟悉的生活,尽管她也感受到这个新的世界是如此吸引人。 +正当两人之间的对话逐渐深入时,突然一阵破坏性的力量让她们的谈话戛然而止。周围的空气瞬间变得沉重,众人开始慌乱地向后退去,气氛骤然冷却。琴的警觉立刻提升,迅速拉住星的手。她们感觉到了一股强大的黑暗力量正在逼近。 -“回家并不是那么简单。”伊尔的声音中透着冷静,他的眼神中闪烁着某种复杂的情感,“在这片土地上,你必须要面对许多你无法想象的挑战。” +“我们必须离开这里!”琴低声说道,眼中闪过一丝坚定。星的心中顿时涌起一阵不安,四周的低语声瞬间被一种阴冷的气息取代,仿佛整个世界都在彼此期待着什么。 -星感到了一丝绝望,她并不想成为一名英雄,自己不过是一名普通的年轻人。“我……我能做些什么?” +就在此时,几道黑影从人群中窜出,快速朝着星和琴的方向扑来。星的心跳骤然加速,目光中只看到那些黑影如同空中的阴影,凶狠而迅速。它们犹如夜幕中降临的猛兽,瞬间将周围人群的欢声笑语吞没。 -“或许我们可以一起探索这个世界。”伊尔的语气缓和了一些,似乎对她产生了一丝信任,“我对魔法的研究或许能帮助你找到线索。” +“准备战斗!”琴的声音坚定有力,手握武器,展现出骑士的果敢与领导力。星的心中燃起一丝勇气,尽管她对力量的掌控仍显薄弱,但此刻的她已经没有退缩的余地。 -“谢谢你,伊尔。”星感激地点头,虽然她对眼前的陌生人依然有些顾虑,但她知道自己必须要有人陪伴,才能在这个陌生的世界中生存下去。 +黑影迅速逼近,星努力保持冷静,将先前的战斗经验回想起。就在恐惧与期待交织的瞬间,她深吸一口气,心中默念着:“我要坚持下去,我不能让任何人受到伤害!” -就在这时,草地的另一侧,一道神秘的光芒闪过,随即出现了另一个身影。那是一位少女,长发披肩,身穿轻盈的衣袍,眼神深邃而神秘,她似乎在默默地注视着星与伊尔。 +当黑影终于逼近,她们再次面对眼前的敌人,那是几只被黑暗力量扭曲的怪物,形态狰狞,气息阴冷。星心头一紧,几乎无法控制自己的情绪,然而琴的身影在她的脑海中闪过,那份责任与勇气激励着她。她握紧元素徽章,试图感应大自然的力量。 -“你是……”星愣了一下,有些惊讶。 +“星,聚焦你的元素力量!”琴在她耳边坚定地鼓励着。星用力点头,努力将自己与周围的空气相连,再次感应那股温暖的气息。她心中不再是恐惧,而是一种前所未有的坚定与力量。她知道,她必须保护琴,也保护这个她逐渐爱上的世界。 -“我叫莱娜。”少女轻声说道,她的声音如同空灵的风铃,给人一种奇特的亲切感,“我感觉到了你的到来,希望能和你们一起探索这个世界。” +“风之力,听我号令!”星大声呼喊,周围的空气开始涌动,在她的指尖汇聚成风的漩涡。正当她准备施展力量时,那几只怪物已然冲到面前,凶猛地朝她们扑来。琴毫不犹豫地迎上前去,剑光闪烁,与怪物展开激烈的战斗。 -“你也感知到了?”伊尔微微皱眉,似乎对莱娜的出现有些意外,却又没有质疑的意思。 +随着琴的剑舞动,星感受到了那份热血沸腾的激情,心中劈开了恐惧的阴影。在这一刻,星的力量逐渐增强,她闭上双眼,努力与风的力量合为一体。她感受到周围的空气开始流动,仿佛有一种无形的力量正随着她的心跳而起舞。 -“是的。”莱娜的眼神透着某种不易察觉的情感,“我对自然元素有着强烈的感应,能够感受到世界的变化。” +“来吧!”星的声音透着一丝坚定,“我将不再恐惧!” -星心中一震,莱娜的出现让她感到了一丝安慰,也增添了对这个世界的探索欲望。“或许我们可以一起合作,找出这个世界的秘密。” +就在黑暗的怪物即将扑到她面前的瞬间,星指尖的一道旋风破空而出,直接轰向面前的敌人。风的力量如同洪流,撕裂着空气,瞬间将怪物击退,剧烈的撞击声响彻耳畔。惊愕的目光在怪物的脸上浮现,随之而来的则是无尽的绝望。 -莱娜微微一笑,仿佛认可了星的提议。“我相信,我们的命运将会交织在一起。” +“做得很好,星!”琴欣慰的声音如同春风,令她感到温暖。然而,星的心中却涌动着更多的疑问与不安。她能够在一瞬间调动起力量,但对力量的真正理解却依然如谜。 -三人之间逐渐形成了一种微妙的默契。尽管星对这个新世界的未来仍感到模糊不清,内心的惶恐也未完全消散,但在伊尔与莱娜的陪伴下,她逐渐意识到自己不再孤单。接下来,等待她的将是未知的冒险,她必须做好面对的准备。 +随着她的逐步掌控,战斗的局势逐渐扭转。星和琴一起奋力抵抗着来自黑暗势力的侵袭,双方的斗争愈演愈烈,空气中弥漫着紧张的气息。就在她们即将取得胜利之际,流浪者的身影在维持安静的角落里静静注视着这一切,似乎在观察着星的每一个举动。 -正当星准备问伊尔关于这个世界的更多事情时,突然,一股强烈的能量波动猛然袭来,她耳边仿佛听到了那道低语的声音再度浮现,清晰而坚定:“命运的选择,终于来临!” +“她并不知晓自己真正的命运。”流浪者低声自语,那话语依旧飘散在夜空中,吸引着星的目光。她的心中不断回响着他的声音,仿佛在暗示着一场更大的阴谋正在悄然逼近。 -星的心脏骤然加速,四周的空气似乎在那一瞬间凝固。她抬头,看到远处的天空中,一片阴云迅速聚集而来,预示着某种危险的逼近。一个模糊的影像在天际闪现,似乎正暗示着未来的冲突与挑战,而她所要面对的,绝不仅仅是对自我归属的探索。 +随着战斗的结束,星和琴在壮烈的胜利中保持沉默,心中却充满了未解的疑惑与不安。星的内心深处,她知道这场战斗仅仅是开始,真正的挑战还在后面等着她。而她与琴的关系也在这一回合战斗中愈发紧密,彼此的信任逐渐加深,却也孕育着新的未知。 -“我们要走!”伊尔的声音打断了星的思绪,他的目光变得凝重,“不可忽视的危机即将降临,我们必须立刻找到安全的地方!” - -星与莱娜对视一眼,心中都涌起一股紧迫感。对即将来临的挑战,三人并肩而立,准备迎接未知的未来。此刻,她们的命运已经交织在了一起,故事的篇章才刚刚开启,而未来的旅程充满了未知与悬念。 \ No newline at end of file +“琴,我们必须找到关于失落者的真相,我总觉得这与我的命运息息相关。”星凝视着远方的黑暗,心中暗自发誓将坚定地前行。在这个星空璀璨的世界,她即将走上新的旅程,揭开那隐藏在历史背后的秘密。 \ No newline at end of file diff --git a/__pycache__/config_manager.cpython-310.pyc b/__pycache__/config_manager.cpython-310.pyc index c8224eae26bcaf179ade542453c1ea2acf21ca28..62b3d078372ae3c5c701c3804bc1de822c574f60 100644 GIT binary patch delta 19 ZcmX@fev+LlpO=@50SF4^HgfG}1^_KT1WEt^ delta 19 ZcmX@fev+LlpO=@50SIP`Z{*s~3;-?|1Z@BS diff --git a/__pycache__/consistency_checker.cpython-310.pyc b/__pycache__/consistency_checker.cpython-310.pyc index a88e555f7ab805fbbc93d311037f85ba990955e0..ab16c36ee0a871aeb8328be08db63ef69bd8e32d 100644 GIT binary patch delta 19 YcmeC>?&apn=jG*M0D?Zbja<#F040wDy#N3J delta 19 YcmeC>?&apn=jG*M0D|q}8@ZZU0VZMt-v9sr diff --git a/__pycache__/novel_generator.cpython-310.pyc b/__pycache__/novel_generator.cpython-310.pyc index 1ad9ee7b5b20126c33296ccf8eaf312bd9c013b7..f18d8cd11b2562e8891d132ae1b62862e64d059f 100644 GIT binary patch literal 11552 zcmbVSX_OmPc2b7hx&8rf& zHPxB+hHdP&VOYmD&^>H68Nd(*jBVg=ek3RVa&qb~Ep_{3&fy0qnHdI5zWZv?;%<+L zbn4Yx?%VFY?{2S(ZEev2ejndVMrRtifaarw|Be0ZqsSROcwAUQ(sJtja>596@amwIMYm1b!SSv`Ya!yelaL=+E0CPTNlc1z`u(NV%4LRE_dh zIoFzxsj+;U+Ln*2ab6cfd%N1s^Wj`aKA|RfK9cLqcd1=G-;(Rj_ozMjUbQ#hr}pLh z)qY+d%`M0;R2SwKsf+T9)y4TG>XQ6`I*?zgF3l^dl3%7S%P&`#3jtl&wgS&JTB{bD z5Ye|yy;h6sE46k!9lE5iToBL_dSW=Db!uJvB=tJKHKEITVnJXyq;+dOXj$dA^rCM< z6VRtm>qkpcyV-ABh_*%g^%!&Ia8z5YE!iilH{2i42DGL70@_kNv^S{U=tC=jUWR8m zo)zeI4W4U}r_npLmD+U}{U&V{YLa-obzhJC4K5`0xRH8bzHaR%Z4f;MHDPlgdCNtj z_)s!vOY5^4)0Q5{8m1k2aMa8e3+Y@^uv;G5HL7dWh(dJT*0lNHs9spR5nZC2&9tfC z&(fn?ZF%!UYd1Y)2R96@2V92(vmyVOu4&oAh=ID$x~;63PoEa-$ogWYl-CPpQnFi^ zp4L*s*_>{7rAINcmNNC7X38u&m7STKo@P9s$`lJGdfA;u`UyQ{m<%Hmpt;jbw%Aca zH&dgSV$`%dGF#IuoiTOh)g-hu+n&m03%cP|^$lmS#cUZv>843zxxIS}#V7P!O3N}m zV;0#iuRd;+^7(YFyreX$0RgGnWCkI`1MfxsC@rB_GHGJ3zT2zc!LmMd`MPUYrHzc4 z&Fe`+`RTQ*ayr&fNK^4q<=Rzw-7wN4C?rK&=52OpxXAKp({6o&FVnzMbvv9M&8D{N zyX?qgX+uwySk9ISp@tn|x>;g{Opqw<{z7=mc-G-D?nGh+tbiqG!h|>$)I=?~FJOu5 z1AB!r$rQGUmShFZpe9WO$7D;Ml%{a3Eg5VimtIBZsDfYR;LDY_4p)x6Q2FGe+0UP= zoOosW?D+KAw;k$No_^-yfn$|(&whE~Naf6v(-$Ty``?&3zOVYif$GU$&whSlcIrsw zH%Cy3?$e*YTzTiE%E2>es+>GCbK>2~r_X;ezQ>v8%{|puPEMblAdr)%X5V|mX~)3R z7mm*!c)Rk{smj3jc#AEVrvq#y%3t4=S-s_9Luba8omoAX z-LhUcwwuM#Eo(QXhKS$p*9$sJgNR!k_8alagQL6b=-oLS%nX?Op6h8jw6@+r`PytD zYo;9f<3NA`2Jg-lGwGah4>Delq^P#&-chs@qgVr)YW$s)8EFF6ibRbM;|QfR_k
*AZyqvXw7G zwb746ltn=lLYLx_gg4KB8<8b}-Gs&r?cDnjD@%Ad`>sO93~Uo7MKd@h7!i_hro`D< z1UrkSYz8f`^aRmNDna7~X5TwsIrc*3;}0Aj0w4PP38tAn{D#k7m4m-@m{}Pr7IZWY zvE>*bDY7J9YNx;P)NrYQQ(P=0L$+8nY-u!YZnZ=DPGH<%M7tWUlfgO!-bhW&NXiu< z9P7z@2cV2Nl9=ct=Tb~!x1!2!E$BN)7?IFvIOfE~KAs*x!8zu8amcNp(PIKfAK}7I;modRMcq&eMN{GU9aJ{rIAlqtLz*jT1^7@vuED{%J` z5rEiIwg4eYe8t+3sBLLYtMNX&f%?dGM7j+Xw#?^N!-Q=xc{9Bj<;P1p+hq&eGej!y7e>Y>-F+zdq?0Vkh}TF*q_CuIU~wx* zOg<)S(xjxxS_m>+CK)~@jD@Ta2o^3K#6Vn%PM-%AT#-C|)}_Vt*`o?yi~@?zoH$$A z|1UTi%66st@{~hYkg$6ALiOkyAfaPV1+$K+xKt;t$JQd* zLKr3ADjJ9gjep1kg0Ne1xjfCN=NKJ%wvLkZlx(2p2${>IS27V4y&tL05FVnMaS4VD zDMKr)2^!QNM7s#dK!!_jB6tXn7wkCMl%N#lVG$0DbRaM+Xdx}UFE$pLr!}IrptWV5 z)~MEs*62K~F|7@)t@E_TwRW_|%#ay`t=VR^O@_4&D>8+3^L)Z;@$;Ql)X#TWt$x1S zin;lS*`jrLd96cB`1yp^>F43f`S~ub+s}8K-uSIkfw8z1gw-osA+6`6G}dm#Cu3GS zBx#4$VVpJFCgWN!DNd~q(zj!>UF+8tOo?L&o?poGomPVVtJP`z2ZvtdL4!ISlL^pg zG2pxW(h`)qwEv}5TdFDhq_G~W$4US*y?A%x-Dh@My;j#=qHCwsXLa*j+hmuv z%o{(sy!2mS0!PY!`Z+Xi<@Mv$ciyP}X7|jg!(0cyT0QtQ>>2WP+ygWSS4tuA@6`Io zf!EhteK>vg{Ka3rR1e0b zO*7cc`{yxcJ=o>aXtm;+JI^;vTPLDu)XK#30E2&0ojd^jS8W9LQ0dMOTeSMzXEV9$M01>+s9Y*5c$#E>PZ#1!xxtwap9{EvQprSvSpA44LXv<1Br<) z5)(f{!o>ulI}705Xb=TRgFKZG^^hl|G2vifQnbi?fCw>`nL(HpA<*$=Tywi*c(B&l__7>OOq z7M>_>*ST(G_fSpPix6P*EIWES$VhY9A=j^ucq77mc$kLq1+(2V=Q0iehmyv}?LuC1iHd7-Dn|DSic{u@UTwH;#g;YPdPGYILg^iD5p~LQ%e4U zk{VhU&Oh06fc%O~VvgJIqCp?4_kC}yUbd5YQOo`jGbVu?+FVjlR>t!1!*N-3dzec9UQ}suk$8#DF`2goH zne!KH0?uEd2L6gT{KfIe2qkc4`7z~OLlxy~{P%*F(aGnLe6JXY{1N zM$oa&^SBO0J4zw+0uQTSq;W+g-9q`cE2FTcylUOcckn)nyD>va7c|A+lH!q5Q;bq3 zgMjsLDVN(dnCxbQw2nwVyi!9plF9kS0PEOZGbQYo379^zJ z^P_Nmnxumrgzw94CNPS#uy#uBK$7gQbEp4~YUl8#*d}V2kKt5#gkNt2aW|pHvW3wS z?ik3>B;kkG)E_4PX!H5tIBE%)K$1#B&ced(rjNRjun-E|}x%tZdZ9C?IaM#~%&7~3e zc7vM42HtN0G875r{_bx*e>YU;@4_OYSQzx1bcjSU3|IJZGCZsnE}&a{Sf{N;3t93B zn6obj0TcCmzz-d3^-J(X;i-C~!B-q>Gi56VuPlyt8{X|&s~OVZ&1!9CXgI_@YsbTd zPdgc&3a~8}a#lM6u!(|bwT~rukB){O@Kxaxce*`F7IIb`J-W~%4v#UycL&c^>w%Hp zt@WaJAD(_Z3*Z4SbVrNv`4?%6F&g=i0jtYOz@zQnE06Uy^jd-*19d&)yeD~}v9UgQ znNH7MD~!IOv3{$c{kIvl`mHGUK3lEUy|71lO{-f2tnivH;D>zGR>BXjNgzixG1`UI zPvK_nA1~t+SPlvR&gw@1x(!~nQ@f&8o4lqp0m5@vsNSz#&1)3p)}&HB@gbtoH#>Jg zmHnqG?~hlXd1>~|-7d&&K8PdlXW#n8?4b{4p8BXdzMG<)Q?I#zw?4K+BWAL@-=&c{%m7dNv;; zeQ=|@W~Gkhsoov@PH(|}fVIK%c82o~n(xd~2>1L!xRBGIo~%5(d-mPWD(@cu^1?ws z4*TVWLwo|)fp=HcD4+-Z+f79FEyd! zqXV6;6fdZ!Nv%)i(`PG(Ccrulr;tC+FF7j5KCYjx$&q*0=lJlhSMPc9_1&6+1#Y)1 z#|-K?q_#{%ZorP?SX<@E{grn=0fqP-7MVVEe zg;FX;tx#D9m$lN=5sNOa?Ew-$4n*X+bW$lCCJ zWUtdhHv3m(EpV0KU4Dx^<{={H;oOi>%mXEeBt$r@pyO!66blZ)GT(vTcD!*tmtlos*;j`Z8g_oH z^v*rsy+8}IXhC;6s%CCgo23vSw!;WWG}Z7|uv{Y#dkZE)AJ z<_nU8;P{s+<n~V6G&7~ z?-=w=jbGv)t*IS#+RXW+jlTb?S7%ObSEHhEI>$-Q61XEp)J`fl04+vo4V`%sM?pemmc-Jp82kGbdyB>p`HFis+gE1rW12Kgo)i3`1A z9HH|#oW_W-Fc@i#36ki&ML3V4@A&sW-=W_nxSQXKlK2g3{wRjxUyEH}uX)?Wuf=fu z4`PqwqB^PcwG>5E3JxiM;Hbueoq{BFUXn!i6c&h>2}$kb%?=OUj=UW*ca7qj-U&{S zd&x+#F%r^t>&E60V0Hz?!^@2f{zeMiB2q=~WE0vfVi-0VXM;C(ILh_@Hv=()({SSN#Ay6~{}1^)jZ**s literal 9107 zcmb7JYmgk(b)J6AV|yMuJNwj=F&M!JkZr)R1F{T8gpgFN5`;<8ku!|u_O3>n7q@%# z*z{Jd&;xED@BPl>o^!gEOll$gwvU`1|M#zjLLX71^EZITL%7VHQYgeiOe%+Ty31v$ zBI~k5b)~FSR9zKywH&TQ^hhPDM=LQsCTQVuPes!;QIC}4m4u$CB=uw^rKc*rdas~I z%jrs=-dD-!nM%LjUm4H`Dueo9Wk?^Y4C}*{5q+exOkXDY#>&epEA$nj-cw##S*5QM zb*;R*a*KWo>Tx~$ScoN9a({>=4SAoUe}$!tTUjrzG_F2e8C?CwZEOJ7pmFP1nhmkx z{i^;|(R0MO-MDpGh%GZ#jYZgUwgOMqh$lo**-Ewwlsg1vHAbuK7M2C&PIix=-HO?7 z!_3R@EXKadZr>l)b8HQ$ci{5ZawqCJ_x)lWYtg0=1MDt#H)dPQq|Kq+y6eP|(VXP; zJh`WCu#Kgng@*RXwt}^(Zd4zBJf}F?W~*QskMTl%n-kvr#+bFPEx@>63=XO4UNSG=ZrM%c8lw(L>eRPNSS> zC2kb08sFp7Q)Z)5DYVoquxE{-Fw!DmOc zx#4804eS8B9wT4~0Mm)@s&STgAE1MP*9U!T!8Dw{4h&spI9i)yPQPGPe!)Pew~I}q zxrh_X=c|Q^k}-iw|u1zKPXu*ANkqqKp?j4Chy6SlbP!0z4ly80fz9MjNc zlBv={F=^pHA&DMNv`{bQcNlw|7@?4F@UqiWE>~#fMwzdmwXCFK6&0&dI5AR{dcoRO zjG^iOhEWOQBJP{Nf>E?Wwrq!NiAhJ5=V2HvX-s}DG$t{HsrwU?u?|X@ML_B4phQ^= z6s?2O!!%Ih9h5jrfReB<^FC=ZX(y)@mb7CtFutOmvU`GhudN03v>gxXeRjgDt5%pL z{W?psR8UW`-k{#g(m_4V`ht3&74hoPnb2g)R&3P{+Yy#It4{XXsp**AJ0ne|?X>x# z)x-MtNifC(n?rVbT4RH3Xhxpw6ZK(H&)9wZXLiQCfW9MsUn{;nF`cxdY#Dm@2d(93 z4cL9tDYk;f;=WSMF=%HN(T1#Ed(iIR2ZT@?vInR}>&UHYYy#i3EPk`U`*7~13(a$< zTW_3i{bujng~K0TJ2LmuPg{phWQElEwrU-WUUv5KF^?M9Dx!;-V12``7~PG@`8_C{ zu&_@>$Vc!aiHYFq$7TK*3M;f-vT>v?3!#)nsfbd=!4p2JPD+PD)3PlAQQ4Nwl`O)d zB^(GWV$0i=X>}$L7v>47M8;~oQm~vzsk*ba!*HU$-N*8I zseV!6-w@qAYaQc;S%(fA7bX66JaAR`F6uJq>7g8FSZ{v~BOI3>a|If6D3LQ;J z6W?)Rc6B;{HSf=~Zhg5{ER@X+>wLfG-Et8K*T}bwmZVDuo1xSWR z1wJ?olJ26sZSX8+lKmhO_CY7Z?{AqQUp+23P|>NmT`{$P*oW8^Psf`?E{ zJou%z)ShM0UdZqgi%puk&Eih=Vd_z(i`~l@46k5!cmcbkF1uas()M$T;D4LIZw1u{ zrI+eA3(8R7@hp&}CoqSZK@lj#=UUif{)U@HYU!yLfu-U#0;~s6=?04_N98V8%@d_3#g!aH->xvM{Hy?S=`@|C%ZSAw&ldG77` zch7$`^{j`=eKZ9j(Ri#qg?o&+T|d5ddhXhl=B1O_B|NKa>(Hre7YLYpqR-u~XMgCV zndo;(0air(RJzTP*5=ZjY@hIRIB{K%6&QR#cs6d$VB-cS-Qjfpkd?30;oArU=7j5T z?kz_dH!Mf4nT|>>kE3p{m8wpZ8})LbXz(74%MJ73eB*@Ik_QZe$j}Y3*2Ci>Kv58X z=!sIb-as6I@S$Ql5u7cJvL(bzAMAuG*s-1{dJ80IOusTxdQ z)%h@(+kfoZZehiR0blK+-t~syjJh4W75=}RlqLE{KF>rliA)s#cuY%3!Cww)QcRW= zuQwfrat!7_f#>A7s!~cxNdxkLq{%6XleYq=A7Pq6$ZGIvQ3lQb5mV_RK{iSK;Elqg zz=mu?KfYg8<0y9{qlws7*nK&rM%{sTNn6&^&maS=m>FVj5qO7r!@%_FCp z@4q+y;Y-akug+ecn!WrBH#lmZIC=fKMN% znfaL`&EFgW5kf5Hn?HR1`q`=ZpYLrRf2;Y{`&qG-Y;*7F=Anbl>GVw6480ckasEn^VoBKZ2vy`t^_eIWOYysv&+Yl=7Zazrgkp zK7`3;T;>ppabWU;=%fT}5ThNHPRh^7*tLXt67|A3qDnW~<>cGvWJg~2bw{#XNoXJC z+WKvFj+h9gQeLS6;~QzkTKGihfz+)DvqYV!A;KuFyzM?)glF*xd?w8kN97pa3aQTKyUKD zB}xD~Mhhf$qIRQIt|4*K$>JIGGASxd;C#}cG>C)b#=0J{6%PE^U$RLW@wi<%r4AcIo3RJZ0_nCt=FdJ-u+qg__Om<`+Q8H zuC3|!TSwn)zWM6)mk&UPTW>$>sjaWA=-YbZ95i?K>Vetco%K}p+?Dww!89&wIH@9- zKp+XJa&aF)Q%}-S)s|Kg6kn?U0d)$bpi`XqqDKi|^zCL)zn2~AWYI4%K)4by_*V%T zY?6slVAa!ja-Fo-2|v>?_#Q{vQ6%R0KRP1=DJS>dW0qnou9Vvf;x@^Kg@q4=+|IO2 zo)c`XO12i!TG$Rl>LZPxW3XV&>{USRnaI}bj&y5O@129ekY1rh;k^W5umSKc9Gdhq4e8?VvmAHRpu@Llkzbz!FY z>fzS$6SE(jX`Oi&A@A(v7iKRXMmK*x;l?4VD`Lftq%G2V!bjLs@?C{J4Nn4t?IG8e zx(pFqmid!n4%Wpa{uIdnj7u2KOlaZ(I2?Iyl#QK*s^ywsBqN3C>*V8-)>ILqr1zPn zN~v5xZf_5Q^#U($;}m#6(u6K=MP2NO=TXyDNRFw;V8OQ{McBPQXU)y6=NfGxfr8`; zr!~%MRpR3yI4Rdl@Ei`)8FbrSzJsHQT!BcQISH?e$m2Qjaseldo5?GZ%jN$_Qz#}Q zHkijGA(F5Ow!$O`q56D?j`wK=i5LZGlAfTYqNN3`Fk11Tg+vaLN~+yMNhRDh+*2%y z^iT{}j};z^h%}bQ;z&RB+Og@#3_NBVH9G}cm9ENmYBDWGBo>T7QVApayb)V$)a)ci zWH2H*nX#f!9VD1o9~_Z>mciJ5Tm!fUtr#2f=1PdS53>=>HDC|GMrQ0Zl1=^l!jppw zMlHjLY+W#+Gl|25vZ;HW0cPgg&#PskB)BWID{ z#Q`lcax!c@od#(e$2Cp^k&A;E$fIy0Dj4IW7OoK|S|7+u1;{{vM(`>Xe@X>~>)m;z zLGOfHIPJ&i`3X5~DUm`VSA@)}NREqdCE}aNu9CG4o+&<;v~B5<(>Xl?8R~gMNO%pe zYj2dB)C_Yv7QSeA)a}N*8(RA7&gV3UqnLQ`%WSGkW*TIy5yxDg`Jnmy>#cnUc!B0+ zRBS-uXf(hDh}Ve_BXOZmC{7ZA7ccmSQ<0(y{})AtJOz>y)AbT4n@e&s3{LnM!F)AD`f8en_^Gb zlupzmIO;B7T_iC5AGy{9umJblsI-+3z=mCLZ58l_CnEyh2;hy{QFB;8_>V5hZ=EeKAywFAoq8?aa%Gv1Y4g>8zPm$%J>0CY3Gt^!< z5q}2n)H4(UbUm=>LCTB0{29{St|gzKZek(+XJS4{q(Bncb5oS^*(D)oxYgE6d6^MKB007UI@hMI3O!k^KLRk@L$K_R~;8wwsoQu z3Biu!U&Nh`lX^MfhE*z?u8=Gg2tDI|^T{&<`LdGubSu6Yx!*s?8RflHq^S^xn>Yi6 z6(aS~SGU&(m%GxyBzx8cvAHQiKs~!uC)fQKai8gCL{G}|72PIBQkoQgUz4GVfhboME+Ec%Kt0JwBIj(CKJzSEBi!#@C$m%zn6RD K|Bja)x904Fh8@YaJ001yO1rq=O diff --git a/__pycache__/utils.cpython-310.pyc b/__pycache__/utils.cpython-310.pyc index 0b7873fe48beffed7229bad092971f2c07f4e358..e08e35cbe35e0c456973ca42e015f9c73d5badd0 100644 GIT binary patch delta 19 ZcmdlWyg`^NpO=@50SKDqHgYZF1OO|O1TO#p delta 19 ZcmdlWyg`^NpO=@50SMNMZ{%9W2>>j31XKV3 diff --git a/config.json b/config.json index 13cf58e..b31b991 100644 --- a/config.json +++ b/config.json @@ -4,7 +4,7 @@ "model_name": "gpt-4o-mini", "topic": "星穹铁道主角星穿越到原神提瓦特大陆,拯救提瓦特大陆,并与其中的角色展开爱恨情仇的小说设定", "genre": "玄幻", - "num_chapters": 100, + "num_chapters": 200, "word_number": 4000, "filepath": "C:/Users/xieli/Desktop/AI_NovelGenerator/AI_novel" } \ No newline at end of file diff --git a/novel_generator.py b/novel_generator.py index 744ac66..f088842 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -1,11 +1,20 @@ import os -from typing_extensions import TypedDict +import logging +from typing import Dict, List, Optional +try: + from typing import TypedDict # Python 3.8+ 直接可用;若是3.7可改用 typing_extensions +except ImportError: + from typing_extensions import TypedDict + from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, START, END -from typing import Dict +from langchain_openai import OpenAIEmbeddings +from langchain_community.vectorstores import Chroma +from langchain.docstore.document import Document from utils import ( - read_file, append_text_to_file, clear_file_content, save_string_to_txt + read_file, append_text_to_file, clear_file_content, + save_string_to_txt ) from prompt_definitions import ( set_prompt, character_prompt, dark_lines_prompt, @@ -14,15 +23,72 @@ from prompt_definitions import ( chapter_outline_prompt, chapter_write_prompt ) -# 向量检索相关 (以Chroma为例),需要安装 langchain, chromadb 等 -from langchain.embeddings.openai import OpenAIEmbeddings -from langchain.vectorstores import Chroma -from langchain.docstore.document import Document +# ============ 日志配置(可选) ============ +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + +# ============ 向量检索相关函数(Chroma) ============ -# 默认用此目录存放向量库 VECTOR_STORE_DIR = "vectorstore" -# =============== 多步生成:设置 & 目录 =============== +def init_vector_store(api_key: str, base_url: str, texts: List[str]) -> Chroma: + """ + 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 + 如果不存在该目录,会自动创建。 + """ + embeddings = OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=base_url # <-- 这里用传进来的 base_url + ) + documents = [Document(page_content=t) for t in texts] + vectorstore = Chroma.from_documents( + documents, + embedding=embeddings, + persist_directory=VECTOR_STORE_DIR + ) + vectorstore.persist() + return vectorstore + + +def load_vector_store(api_key: str, base_url: str) -> Optional[Chroma]: + """读取已存在的向量库。若不存在则返回 None。""" + if not os.path.exists(VECTOR_STORE_DIR): + return None + embeddings = OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=base_url # <-- 使用 base_url + ) + return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) + + +def update_vector_store(api_key: str, base_url: str, new_chapter: str) -> None: + """将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。""" + store = load_vector_store(api_key, base_url) + if not store: + logging.info("Vector store does not exist. Initializing a new one...") + init_vector_store(api_key, base_url, [new_chapter]) + return + + new_doc = Document(page_content=new_chapter) + store.add_documents([new_doc]) + store.persist() + + +def get_relevant_context_from_vector_store(api_key: str, base_url: str, query: str, k: int = 2) -> str: + """ + 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 + 若向量库不存在则返回空字符串。 + """ + store = load_vector_store(api_key, base_url) + if not store: + logging.warning("Vector store not found. Returning empty context.") + return "" + docs = store.similarity_search(query, k=k) + combined = "\n".join([d.page_content for d in docs]) + return combined + + +# ============ 多步生成:设置 & 目录 ============ + class OverallState(TypedDict): topic: str genre: str @@ -34,6 +100,7 @@ class OverallState(TypedDict): final_novel_setting: str novel_directory: str + def Novel_novel_directory_generate( api_key: str, base_url: str, @@ -43,43 +110,62 @@ def Novel_novel_directory_generate( number_of_chapters: int, word_number: int, filepath: str -): +) -> None: """ - 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt + 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 + + :param api_key: OpenAI API key + :param base_url: OpenAI API base url + :param llm_model: 所使用的 LLM 模型名称 + :param topic: 小说主题 + :param genre: 小说类型 + :param number_of_chapters: 章节数 + :param word_number: 单章目标字数 + :param filepath: 存放生成文件的目录路径 """ + # 确保文件夹存在 + os.makedirs(filepath, exist_ok=True) + model = ChatOpenAI( model=llm_model, api_key=api_key, base_url=base_url ) - def generate_base_setting(state: OverallState): + def generate_base_setting(state: OverallState) -> Dict[str, str]: prompt = set_prompt.format( topic=state["topic"], genre=state["genre"], number_of_chapters=state["number_of_chapters"], - word_number=state["word_number"], + word_number=state["word_number"] ) response = model.invoke(prompt) if not response: + logging.warning("generate_base_setting: No response.") return {"novel_setting_base": ""} return {"novel_setting_base": response.content.strip()} - def generate_character_setting(state: OverallState): - prompt = character_prompt.format(novel_setting=state["novel_setting_base"]) + def generate_character_setting(state: OverallState) -> Dict[str, str]: + prompt = character_prompt.format( + novel_setting=state["novel_setting_base"] + ) response = model.invoke(prompt) if not response: + logging.warning("generate_character_setting: No response.") return {"character_setting": ""} return {"character_setting": response.content.strip()} - def generate_dark_lines(state: OverallState): - prompt = dark_lines_prompt.format(character_info=state["character_setting"]) + def generate_dark_lines(state: OverallState) -> Dict[str, str]: + prompt = dark_lines_prompt.format( + character_info=state["character_setting"] + ) response = model.invoke(prompt) if not response: + logging.warning("generate_dark_lines: No response.") return {"dark_lines": ""} return {"dark_lines": response.content.strip()} - def finalize_novel_setting(state: OverallState): + def finalize_novel_setting(state: OverallState) -> Dict[str, str]: prompt = finalize_setting_prompt.format( novel_setting_base=state["novel_setting_base"], character_setting=state["character_setting"], @@ -87,19 +173,22 @@ def Novel_novel_directory_generate( ) response = model.invoke(prompt) if not response: + logging.warning("finalize_novel_setting: No response.") return {"final_novel_setting": ""} return {"final_novel_setting": response.content.strip()} - def generate_novel_directory(state: OverallState): + def generate_novel_directory(state: OverallState) -> Dict[str, str]: prompt = novel_directory_prompt.format( final_novel_setting=state["final_novel_setting"], number_of_chapters=state["number_of_chapters"] ) response = model.invoke(prompt) if not response: + logging.warning("generate_novel_directory: No response.") return {"novel_directory": ""} return {"novel_directory": response.content.strip()} + # 构建状态图 graph = StateGraph(OverallState) graph.add_node("generate_base_setting", generate_base_setting) graph.add_node("generate_character_setting", generate_character_setting) @@ -107,6 +196,7 @@ def Novel_novel_directory_generate( graph.add_node("finalize_novel_setting", finalize_novel_setting) graph.add_node("generate_novel_directory", generate_novel_directory) + # 注意修正此处节点名称 graph.add_edge(START, "generate_base_setting") graph.add_edge("generate_base_setting", "generate_character_setting") graph.add_edge("generate_character_setting", "generate_dark_lines") @@ -125,78 +215,35 @@ def Novel_novel_directory_generate( result = app.invoke(input_params) if not result: - print("⚠️ invoke() 结果为空,生成失败。") + logging.warning("Novel_novel_directory_generate: invoke() 结果为空,生成失败。") return final_novel_setting = result.get("final_novel_setting", "") final_novel_directory = result.get("novel_directory", "") + if not final_novel_setting or not final_novel_directory: - print("⚠️ 生成失败:缺少 final_novel_setting 或 novel_directory。") + logging.warning("生成失败:缺少 final_novel_setting 或 novel_directory。") return # 写入文件 filename_set = os.path.join(filepath, "Novel_setting.txt") filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") - final_novel_setting_cleaned = final_novel_setting.replace('#', '').replace('*', '') - final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') + # 清理文本(去除多余 # 或 * 等) + def clean_text(txt: str) -> str: + return txt.replace('#', '').replace('*', '') + final_novel_setting_cleaned = clean_text(final_novel_setting) + final_novel_directory_cleaned = clean_text(final_novel_directory) + + # 以追加方式保存;如果希望覆盖可改为 save_string_to_txt() append_text_to_file(final_novel_setting_cleaned, filename_set) append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) - -# =============== 生成章节(含角色状态 & 全局摘要 & 向量检索) =============== - -def init_vector_store(api_key: str, texts: list[str]) -> Chroma: - """ - 初始化并返回一个Chroma向量库,将传入的文本进行嵌入。 - 若需要可对 texts 做分句或分块处理;这里只演示简单用法。 - """ - embeddings = OpenAIEmbeddings(openai_api_key=api_key) - documents = [Document(page_content=t) for t in texts] - vectorstore = Chroma.from_documents(documents, embedding=embeddings, persist_directory=VECTOR_STORE_DIR) - vectorstore.persist() - return vectorstore - -def load_vector_store(api_key: str) -> Chroma: - """ - 读取已存在的向量库。若不存在则返回None或新建一个空的。 - """ - if not os.path.exists(VECTOR_STORE_DIR): - return None - embeddings = OpenAIEmbeddings(openai_api_key=api_key) - return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) + logging.info("Novel settings and directory generated successfully.") -def update_vector_store(api_key: str, new_chapter: str): - """ - 将最新章节文本插入到向量库里,用于后续检索参考。 - 可根据实际需求做分块处理。此处仅作简单示范。 - """ - store = load_vector_store(api_key) - if not store: - # 如果vector store不存在,先初始化 - store = init_vector_store(api_key, [new_chapter]) - return - - embeddings = OpenAIEmbeddings(openai_api_key=api_key) - new_doc = Document(page_content=new_chapter) - store.add_documents([new_doc]) - store.persist() - - -def get_relevant_context_from_vector_store(api_key: str, query: str, k: int=2) -> str: - """ - 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - 用于在生成大纲或写正文时,为大模型提供更多上下文。 - """ - store = load_vector_store(api_key) - if not store: - return "" - docs = store.similarity_search(query, k=k) - # 简单拼接 - combined = "\n".join([d.page_content for d in docs]) - return combined +# ============ 生成章节(每章独立文件) ============ def generate_chapter_with_state( novel_settings: str, @@ -213,11 +260,25 @@ def generate_chapter_with_state( 多步流程: 1) 更新/创建全局摘要 2) 更新/生成角色状态文档 - 3) 根据向量检索获取往期章节相关内容 + 3) 向量检索获取往期上下文 4) 大纲 -> 正文 - 5) 更新向量库 - 最终写入 chapter.txt、lastchapter.txt、character_state.txt、global_summary.txt + 5) 写入 chapter_{novel_number}.txt, 更新 lastchapter.txt + 6) 更新向量库 + + :param novel_settings: 最终的作品设定(字符串) + :param novel_novel_directory: 小说目录信息(此处暂时未使用,可根据需求做扩展) + :param api_key: OpenAI API Key + :param base_url: OpenAI Base URL + :param model_name: LLM 模型名称 + :param novel_number: 当前要生成的章节号 + :param filepath: 文件存放的目录 + :param word_number: 单章目标字数 + :param lastchapter: 上一章内容(若为空字符串,表示无上一章) + :return: 本章生成的正文内容 """ + # 确保文件夹存在 + os.makedirs(filepath, exist_ok=True) + model = ChatOpenAI( model=model_name, api_key=api_key, @@ -225,35 +286,44 @@ def generate_chapter_with_state( temperature=0.9 ) - # --- 文件名定义 --- + # --- 文件路径定义 --- + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) + + chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + lastchapter_file = os.path.join(filepath, "lastchapter.txt") character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") - chapter_file = os.path.join(filepath, "chapter.txt") - lastchapter_file = os.path.join(filepath, "lastchapter.txt") - # --- 读取现有文档(可能为空) --- old_char_state = read_file(character_state_file) old_global_summary = read_file(global_summary_file) - # --- 1) 更新全局摘要 (若上一章文本不为空) --- + # 1) 更新全局摘要 def update_global_summary(chapter_text: str, old_summary: str) -> str: - prompt = summary_prompt.format(chapter_text=chapter_text, global_summary=old_summary) + prompt = summary_prompt.format( + chapter_text=chapter_text, + global_summary=old_summary + ) response = model.invoke(prompt) if not response: + logging.warning("update_global_summary: No response.") return old_summary return response.content.strip() if lastchapter.strip(): - # 用上一章内容更新全局摘要 new_global_summary = update_global_summary(lastchapter, old_global_summary) else: new_global_summary = old_global_summary - # --- 2) 更新角色状态文档 --- + # 2) 更新角色状态文档 def update_character_state(chapter_text: str, old_state: str) -> str: - prompt = update_character_state_prompt.format(chapter_text=chapter_text, old_state=old_state) + prompt = update_character_state_prompt.format( + chapter_text=chapter_text, + old_state=old_state + ) response = model.invoke(prompt) if not response: + logging.warning("update_character_state: No response.") return old_state return response.content.strip() @@ -262,13 +332,19 @@ def generate_chapter_with_state( else: new_char_state = old_char_state - # --- 3) 从向量库检索相关上下文,用来帮助生成新的大纲 --- - # 例如,可以根据“角色状态”或“本章关键词”来查询。 - # 简单示范:以 "回顾剧情" 作为检索Query - relevant_context = get_relevant_context_from_vector_store(api_key, "回顾剧情", k=2) + # 3) 从向量库检索上下文 + relevant_context = get_relevant_context_from_vector_store( + api_key, base_url, "回顾剧情", k=2 # <-- 多传一个 base_url + ) - # --- 4) 大纲 -> 正文 --- - def outline_chapter(novel_setting: str, char_state: str, global_summary: str, chap_num: int, extra_context: str) -> str: + # 4) 生成大纲 + def outline_chapter( + novel_setting: str, + char_state: str, + global_summary: str, + chap_num: int, + extra_context: str + ) -> str: prompt = chapter_outline_prompt.format( novel_setting=novel_setting, character_state=char_state + "\n\n【历史上下文】\n" + extra_context, @@ -277,12 +353,23 @@ def generate_chapter_with_state( ) response = model.invoke(prompt) if not response: + logging.warning("outline_chapter: No response.") return "" return response.content.strip() - chap_outline = outline_chapter(novel_settings, new_char_state, new_global_summary, novel_number, relevant_context) + chap_outline = outline_chapter( + novel_settings, new_char_state, new_global_summary, novel_number, relevant_context + ) - def write_chapter(novel_setting: str, char_state: str, global_summary: str, outline: str, wnum: int, extra_context: str) -> str: + # 5) 生成正文 + def write_chapter( + novel_setting: str, + char_state: str, + global_summary: str, + outline: str, + wnum: int, + extra_context: str + ) -> str: prompt = chapter_write_prompt.format( novel_setting=novel_setting, character_state=char_state + "\n\n【历史上下文】\n" + extra_context, @@ -292,26 +379,38 @@ def generate_chapter_with_state( ) response = model.invoke(prompt) if not response: + logging.warning("write_chapter: No response.") return "" return response.content.strip() - chapter_content = write_chapter(novel_settings, new_char_state, new_global_summary, chap_outline, word_number, relevant_context) + chapter_content = write_chapter( + novel_settings, + new_char_state, + new_global_summary, + chap_outline, + word_number, + relevant_context + ) + # 写入文件并更新记录 if chapter_content: - # --- 写入 chapter.txt 与 lastchapter.txt --- - append_text_to_file(chapter_content, chapter_file) + save_string_to_txt(chapter_content, chapter_file) + # 更新 lastchapter.txt clear_file_content(lastchapter_file) save_string_to_txt(chapter_content, lastchapter_file) - # --- 更新全局摘要、角色状态到文件 --- + # 更新角色状态、全局摘要 clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) - # --- 5) 更新向量检索库 --- - update_vector_store(api_key, chapter_content) + # 6) 更新向量检索库 + update_vector_store(api_key, base_url, chapter_content) + logging.info(f"Chapter {novel_number} generated successfully.") + else: + logging.warning(f"Chapter {novel_number} generation failed.") return chapter_content From ee0f9436947295f5ec000628032953e82016850a Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 29 Jan 2025 22:00:22 +0800 Subject: [PATCH 003/142] 1 --- .gitignore | 1 + config.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 099e0b1..a7a8140 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /AI_novel /vectorstore config.json +config.json diff --git a/config.json b/config.json index b31b991..13cf58e 100644 --- a/config.json +++ b/config.json @@ -4,7 +4,7 @@ "model_name": "gpt-4o-mini", "topic": "星穹铁道主角星穿越到原神提瓦特大陆,拯救提瓦特大陆,并与其中的角色展开爱恨情仇的小说设定", "genre": "玄幻", - "num_chapters": 200, + "num_chapters": 100, "word_number": 4000, "filepath": "C:/Users/xieli/Desktop/AI_NovelGenerator/AI_novel" } \ No newline at end of file From 93af2e4f306cc5cd97c8948294e76bbb67417af8 Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Wed, 29 Jan 2025 22:04:59 +0800 Subject: [PATCH 004/142] Delete config.json --- config.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 config.json diff --git a/config.json b/config.json deleted file mode 100644 index 13cf58e..0000000 --- a/config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "api_key": "sk-TxjhfBwxVJ6jA7Px3reCDR2x3Ua6vVghoraX8LtwUBFntD4I", - "base_url": "https://api.agicto.cn/v1", - "model_name": "gpt-4o-mini", - "topic": "星穹铁道主角星穿越到原神提瓦特大陆,拯救提瓦特大陆,并与其中的角色展开爱恨情仇的小说设定", - "genre": "玄幻", - "num_chapters": 100, - "word_number": 4000, - "filepath": "C:/Users/xieli/Desktop/AI_NovelGenerator/AI_novel" -} \ No newline at end of file From fe9cedebeaeb25841c6b11e69ff4b7811990101e Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 29 Jan 2025 22:06:43 +0800 Subject: [PATCH 005/142] 1 --- .gitignore | 3 +- AI_novel/Novel_directory.txt | 302 ----------------------------------- AI_novel/Novel_setting.txt | 54 ------- AI_novel/character_state.txt | 38 ----- AI_novel/global_summary.txt | 9 -- AI_novel/lastchapter.txt | 69 -------- 6 files changed, 1 insertion(+), 474 deletions(-) delete mode 100644 AI_novel/Novel_directory.txt delete mode 100644 AI_novel/Novel_setting.txt delete mode 100644 AI_novel/character_state.txt delete mode 100644 AI_novel/global_summary.txt delete mode 100644 AI_novel/lastchapter.txt diff --git a/.gitignore b/.gitignore index a7a8140..9c9ae38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /__pycache__ /AI_novel /vectorstore -config.json -config.json +config.json \ No newline at end of file diff --git a/AI_novel/Novel_directory.txt b/AI_novel/Novel_directory.txt deleted file mode 100644 index b059ce0..0000000 --- a/AI_novel/Novel_directory.txt +++ /dev/null @@ -1,302 +0,0 @@ - 目录 -《星旅提瓦特》 - -1. 穿越之旅 - - 星在星穹铁道上的意外穿越,开启了提瓦特的新篇章。 - -2. 迷雾中的初遇 - - 星在蒙德初见琴,感受到这片土地的风与友善。 - -3. 蒙德的秘密 - - 星探索蒙德的文化,发现这里隐藏的历史与神秘。 - -4. 骑士团的使命 - - 琴向星介绍骑士团的职责,星逐渐卷入其中。 - -5. 甘雨的来信 - - 星收到来自璃月的信件,甘雨的温暖与关怀让她心生向往。 - -6. 璃月之旅 - - 星前往璃月,体验独特的岩之文化。 - -7. 千年的孤独 - - 星与甘雨初次相遇,分享各自的孤独与渴望。 - -8. 阿贝多的实验 - - 阿贝多邀请星参与炼金实验,引发了一系列搞笑而意外的事件。 - -9. 黑暗势力的影子 - - 黑暗势力开始在提瓦特活动,星感到前所未有的危机。 - -10. 命运的旋涡 - - 星与各国角色的命运交织,感受到身处漩涡中心的无奈。 - -11. 凯亚的神秘 - - 星与凯亚的互动越来越频繁,潜在的情感开始萌芽。 - -12. 面对过往 - - 星通过梦境回忆起自己在星穹铁道的生活与遗憾。 - -13. 稻妻的召唤 - - 星接到前往稻妻的邀请,了解这个国家的雷之文化。 - -14. 雷电将军的统治 - - 星初见雷电将军,感受到她的冷酷与威严。 - -15. 战斗与信念 - - 星卷入稻妻的内部纷争,为正义而战。 - -16. 情感的对峙 - - 琴、甘雨、凯亚之间的情感矛盾逐渐升级。 - -17. 背叛与和解 - - 星被误解与背叛,她努力修复人际关系。 - -18. 水之国的秘境 - - 星探访Fontaine,领略水元素的神秘魅力。 - -19. 纳特兰的热情 - - 前往Natlan,星感受到火的激情与热烈。 - -20. Sumeru的冰雪 - - 星走入Sumeru,迎接无尽的冰与寒冷。 - -21. 智慧的考验 - - 与阿贝多共同追寻智慧的真相,接受炼金术的挑战。 - -22. 情感的困境 - - 星在角色之间的情感纠葛中挣扎,难以选择。 - -23. 友谊的羁绊 - - 星与各国角色逐渐建立深厚的友谊,共同面对危机。 - -24. 黑暗势力的蔓延 - - 邻国的黑暗势力逐渐扩张,威胁提瓦特的和平。 - -25. 群星的呼唤 - - 星感受到神秘力量的召唤,探索自我本源。 - -26. 秘密的真相 - - 星发现自身的特殊能力与提瓦特的关联。 - -27. 智者的对话 - - 星与甘雨探讨信仰与价值观的碰撞。 - -28. 夜幕下的约定 - - 星与凯亚在月光下的对话,情感逐渐升温。 - -29. 战斗的号角 - - 黑暗势力全面进攻,星与伙伴们面对强敌。 - -30. 牺牲与成长 - - 在战斗中有人牺牲,星经历痛苦与成长。 - -31. 团结的力量 - - 各国联合奋起反抗,星成为他们的钱塘。 - -32. 情感的纠葛 - - 角色之间复杂的情感关系达到高潮。 - -33. 历史的回响 - - 星深入探讨提瓦特历史背后的秘密。 - -34. 迷失的自我 - - 星感到无比迷茫,寻找自己的价值与归属。 - -35. 命运的选择 - - 角色们面临各自的命运抉择,星更加坚定。 - -36. 友情的考验 - - 伴随战斗的加剧,友情经受着巨大的考验。 - -37. 爱的蜕变 - - 星与琴之间的感情逐渐升温,内心波动加剧。 - -38. 黑暗的深渊 - - 星与伙伴们深入敌后的冒险,面临生死考验。 - -39. 重拾信念 - - 在黑暗中,星与甘雨共同重拾对信念的坚定。 - -40. 最终的冲突 - - 星与黑暗势力的最终对决,真相逐渐揭晓。 - -41. 团结的胜利 - - 各国角色携手打败黑暗势力,提瓦特迎来光明。 - -42. 内心的归属 - - 星在战后寻找自我的归属,决定留在提瓦特。 - -43. 新的征程 - - 星与伙伴们共同规划下一步的冒险。 - -44. 与琴的告别 - - 雅致的情感告别,琴与星的羁绊愈加深厚。 - -45. 甘雨的承诺 - - 甘雨向星表达深厚的友情与支持。 - -46. 凯亚的反思 - - 凯亚在最终战斗后对自己的态度进行反思。 - -47. 雷电将军的转变 - - 雷电将军在星的影响下开始重新思考自己的信念。 - -48. 新的挑战 - - 星与伙伴们面对新的冒险与挑战,继续探索提瓦特的奥秘。 - -49. 互相扶持 - - 星与朋友在困难时互相扶持,建立更深的友谊。 - -50. 遗失的记忆 - - 星逐渐恢复穿越前的记忆,理解自身的命运。 - -51. 彼岸的希望 - - 星与伙伴们共同追寻实现梦想的希望。 - -52. 未来的选择 - - 角色们对未来进行深思,探索各自的可能。 - -53. 团体的温暖 - - 在冒险中重温团队间的温暖与支持。 - -54. 残留的悔恨 - - 星感受过去的悔恨与无奈,努力向前。 - -55. 神秘的力量 - - 星探索神秘力量与提瓦特的结合。 - -56. 爱的边界 - - 星思考情感的定义,突破爱的边界。 - -57. 全新的开始 - - 各角色在经历洗礼后,迎接全新的开始。 - -58. 不屈的信仰 - - 角色们坚定信仰,团结共同拯救提瓦特。 - -59. 重拾勇气 - - 在战斗与磨难中,星与伙伴们再次重拾勇气。 - -60. 未来的希冀 - - 星对未来设定新的希望与目标。 - -61. 历史的传承 - - 星逐渐明白提瓦特历史的传承与意义。 - -62. 迷失的难题 - - 星面临内心迷失的问题,努力找到解答。 - -63. 永恒的承诺 - - 角色们对彼此许下永恒的承诺与信任。 - -64. 心灵的感应 - - 星与伙伴们之间建立特殊的心灵感应。 - -65. 茫茫星海 - - 星思考自己所站的位置,在浩瀚星海中感到渺小。 - -66. 时间的流转 - - 在经历日月交替中,角色们逐渐成长。 - -67. 生活的真谛 - - 星领悟生活的真谛与意义,与角色们共同分享。 - -68. 团体的凝聚 - - 团队的凝聚力在冒险中进一步加强。 - -69. 信念的重构 - - 角色们对信念进行重构,理解不同的信仰。 - -70. 心灵的告白 - - 角色们在特定场合进行深情告白,情感达到了顶点。 - -71. 挑战的延续 - - 随着冒险的深入,角色们面对新的挑战与冒险。 - -72. 心灵的同行 - - 星与角色们沿途的心灵交流,增进理解与信任。 - -73. 困扰的阴影 - - 星面临来自内心阴影的困扰,努力寻求解决。 - -74. 踏上归途 - - 星决定在提瓦特长久地生活,而非返回原来的世界。 - -75. 爱的升华 - - 星与琴、甘雨的爱在困扰中升华出新层次。 - -76. 新的开始 - - 星与伙伴们共同踏上新的冒险之旅。 - -77. 艰难的抉择 - - 各角色面临自己的抉择,做出新的决定。 - -78. 追寻真相 - - 星深入探寻提瓦特的核心秘密,与朋友们并肩作战。 - -79. 心灵的重建 - - 各角色在情感的波折中重建自己的内心世界。 - -80. 无畏的冒险 - - 与伙伴们一起,不畏艰险继续冒险。 - -81. 承诺的反思 - - 分开与相聚中,角色们不断反思彼此的承诺。 - -82. 明亮的未来 - - 星展望稳定与和平的未来,与伙伴们共同祈愿。 - -83. 精神的力量 - - 星意识到精神力量在冒险过程中的重要性。 - -84. 合作的喜悦 - - 在面对挑战时,角色们愈加体会到合作的魅力。 - -85. 内心的力量 - - 星在冒险中探索内心的力量与可能性。 - -86. 失落的记忆 - - 星逐渐恢复关于提瓦特的重要记忆,涌现出久违的温暖。 - -87. 团结的信念 - - 在新的旅途中,各国团结一心,捍卫家园。 - -88. 挑战的巅峰 - - 对抗黑暗势力的最终巅峰,所有角色齐心协力。 - -89. 转折的温柔 - - 星与伙伴们在战斗中涌现脆弱之心,增进情感。 - -90. 爱的告白 - - 星为自己珍惜的情感付出,开启告白之旅。 - -91. 希望的光芒 - - 战斗之后,星与伙伴们共同追寻新的希望。 - -92. 信仰的纽带 - - 各角色在信仰上建立起新的纽带。 - -93. 永恒的牵绊 - - 星与伙伴们对彼此的承诺与牵绊更加坚定。 - -94. 最终的归宿 - - 星对自己在提瓦特的归宿有了清晰的认识。 - -95. 团体的梦想 - - 各角色分享对未来的梦想,愿景永不消逝。 - -96. 温暖的重聚 - - 在冒险后,星与伙伴们的重聚,感受到温暖。 - -97. 爱的升华 - - 星与众伙伴的情感达到新的高度,相守相伴。 - -98. 珍惜的时光 - - 彼此珍惜的时光让角色们的关系更加紧密。 - -99. 新的篇章 - - 星准备开启新的篇章,与伙伴们一同探索未知。 - -100. 与星同行 - - 星在提瓦特生活与冒险的美好结尾,成为真正的家园。 diff --git a/AI_novel/Novel_setting.txt b/AI_novel/Novel_setting.txt deleted file mode 100644 index e8a2b61..0000000 --- a/AI_novel/Novel_setting.txt +++ /dev/null @@ -1,54 +0,0 @@ - 最终作品设定 - - 小说名称:《星旅提瓦特》 - - 主题 -在遥远的星空彼端,星穹铁道的主角星在一次意外中穿越到原神的提瓦特大陆,面对黑暗势力的威胁,她肩负起拯救这片土地的重任。在这个充满元素力量的新世界中,星与来自不同国家、不同背景的角色们建立起紧密的情感联系,爱恨交织,最终揭开了隐藏在历史背后的秘密。 - - 类型 -玄幻、冒险、爱情 - - 故事设定 -- 背景世界:提瓦特大陆分为七个国家,代表不同元素力量,包括蒙德(风)、璃月(岩)、稻妻(雷)、须弥(草)、Fontaine(水)、Natlan(火)和Sumeru(冰)。每个国家都有独特的文化、信仰和神明,星必须探索这些国家,寻找力量与盟友,以对抗威胁提瓦特的邪恶势力。 - -- 主角:星,一名20岁的年轻女性,星穷铁道的乘客,因意外穿越到提瓦特。性格坚韧、不屈,内心渴望寻求归属与理解。她拥有跨越宇宙的能力,能感应元素力量,随着故事的发展,她逐渐发现自己与提瓦特的元素力量有着密切联系。 - -- 主要角色: - - 琴:25岁,蒙德骑士团副团长,勇敢且正义感强,对星的好奇与保护欲逐渐转化为深厚的情感,面临职责与情感的内心矛盾。 - - - 甘雨:外形20多岁,千岁的璃月半仙,聪慧温柔,与星结成伙伴关系,逐渐对星产生复杂的情感,因使命感而感到孤独。 - - - 凯亚:28岁,蒙德骑士,幽默而神秘,与星之间的互动充满火花,暗生好感,却因性格使然表现得轻佻。 - - - 雷电将军:未知年龄(看似30岁),稻妻的统治者,冷酷无情。星的出现让她开始反思自己的信念,内心对星逐渐产生吸引。 - - - 阿贝多:24岁,璃月的炼金术士,追求真理与知识。对星的独特吸引力使他更加关注她,两人之间的互动充满潜在的火花。 - - 故事进展 -- 第一章:穿越之旅:星在乘坐星穹铁道时遭遇神秘现象,被传送到提瓦特大陆,初次感受到这里的魔法与危险。 -- 中段:星逐渐适应提瓦特生活,遇见各国角色并与他们建立关系,发展情感。同时,黑暗势力逐渐显现,开始捣乱。 -- 爱恨交错的情感:星与不同角色之间情感波动形成复杂的三角关系与误会,推动情节发展与冲突。 -- 高潮:各国联合对抗黑暗势力,星发现隐藏的真相,角色间的关系在生死考验中经历波折。 -- 结局:星经过艰辛战斗,拯救提瓦特,找到了内心的归属,决定留在提瓦特继续她的冒险。 - - 章节安排 -- 总章节数:100章,每章约4000字。章节围绕故事的重大事件、角色关系发展及情感冲突展开,穿插战斗场景与情感对话,逐渐推进主线与副线,构建深厚的世界观。 - - 预期效果 -通过这部小说,读者将体验星穹铁道与原神世界的交融,感受到精彩的冒险故事与复杂的人际关系中爱的珍贵与痛苦。 - ---- - - 角色关系发展 -- 随着故事的发展,角色与星的关系在战斗与情感交流中不断变化,形成复杂的三人关系和误会,推动情节发展。每位角色在星的影响下找到了各自的定位与归属,形成更为团结的团队,共同守护提瓦特的未来。 - - 敏感线及处理限制 -在故事中,涉及到复杂情感关系与角色成长的敏感线须谨慎处理,以避免负面影响: -1. 情感纠葛:避免过于戏剧化的情感对立,确保角色情感发展符合逻辑。 -2. 信念冲突:尊重各角色的信念,展现多元化价值观,而非仅单一立场。 -3. 责任与牺牲:描绘牺牲时避免血腥或悲惨的过度描写,确保具有深刻意义。 -4. 文化差异:尊重各国文化,避免采用刻板印象,准确表现各国背景。 -5. 性别角色:展现女性角色的多维性与复杂性,避免单一化形象。 -6. 身份认同:积极探讨归属感与自我认同,保障角色内心旅程的积极性。 - -通过谨慎处理这些敏感线,小说将更加深刻和动人,吸引读者产生共鸣。 diff --git a/AI_novel/character_state.txt b/AI_novel/character_state.txt deleted file mode 100644 index 766fbc4..0000000 --- a/AI_novel/character_state.txt +++ /dev/null @@ -1,38 +0,0 @@ -### 更新后的角色信息 - -#### 星(主角) -- **年龄**: 20岁 -- **背景**: 原本生活在大都市的普通女性,因对归属感的渴望而选择乘坐星穹铁道,意外穿越到了提瓦特的异世界。性格内向而敏感,心中充满探索未知的渴望,也对勇气与坚定有着深深的向往。在穿越前,常感孤独,并在快节奏的生活中迷失。希望找到属于自己的位置。 -- **当前状态**: 刚经历第一次战斗,成功调动了风的元素力量,感受到力量带来的惊喜与恐惧。与琴建立了更深的信任关系,并对凯亚产生了好奇。内心对“失落者”的传闻感到不安,迫切想探索更多秘密。 -- **持有物品**: 发现并拥有一枚神秘的元素徽章,能够帮助她更好地感应周围的元素力量。 -- **能力变化**: 初步掌握了风的元素力量,能够调动周围空气并产生简单的风力效果。对元素力量的认知和控制有所提高,但仍感到不安。 - -#### 琴 -- **年龄**: 25岁(推测) -- **身份**: 蒙德骑士团副团长 -- **背景**: 作为一名优秀的骑士,琴责任心强,性格坚定而富有魅力。她总是尽力保护弱者,并热心助人。随着与星的互动加深,逐渐对星的潜力有了更深的认识,并愿意将自己的经验传授给她。 -- **当前状态**: 在与星的合作中,感受到自身责任感的加重,积极支持星的成长和探索。虽然因战斗受了些许伤,但依然充满信心。 -- **能力变化**: 通过指导星,她在观察中感受到自己的领导能力在逐渐增强,并对元素力量的理解也在加深。 - -#### 凯亚 -- **年龄**: 28岁(推测) -- **身份**: 蒙德骑士 -- **背景**: 个性幽默而神秘,内心深处隐藏着自己的故事与秘密。他对星产生了浓厚的兴趣,并希望给予帮助,打算在日后更多地与星互动。 -- **当前状态**: 在观察星与琴的互动中,感受到两者之间的信任逐渐加深,暗自思考如何以自己独特的方式帮助她。同时,他对于“失落者”的传闻也开始感到不安。 -- **能力变化**: 在星的成长过程中,他开始适时提供帮助,利用骑士团的资源,提升自己在团体中的影响力。 - -### 角色关系与动态 -- **星与琴**: 在共同战斗和探索中,两者的信任关系愈加深厚,星逐渐在琴身上看到勇气与责任感。琴将继续引导星应对未来的挑战。 -- **星与凯亚**: 凯亚对星的关注逐渐加深,可能会在未来向星提供帮助与指导,星对此感到好奇与期待。 -- **琴与凯亚**: 两者之间默契依旧,琴对凯亚的调侃逐渐演变为互相支持,形成了良好的团队氛围。 - -### 正在发生的重要事件 -1. **战斗事件**: 星首次参与战斗,成功调动元素力量击退风狼,展示出她在新环境中的适应能力与潜力。此战斗让星的力量得到了初步的验证与掌握。 -2. **探索蒙德城**: 星与琴一同游览蒙德城,了解提瓦特的文化,努力寻找归属感。在探索过程中,星的内心感受到归属的渴望逐渐增强。 -3. **黑暗势力的威胁**: 关于“失落者”的传言开始在城镇中流传,星的内心感到一丝不安,暗示着未来的挑战。星开始对这股黑暗势力的成因与目的产生疑问。 - -### 新增角色 -- **无**: 目前未新增重要角色。 - -### 总结 -在这一章节中,星与琴通过合作加深了彼此的理解与信任,而凯亚则为星的旅程增添了神秘与支持。星经历了首次战斗,展现了她的潜力与勇气,但同时也面临对力量无法完全掌控的恐惧。随着黑暗势力的威胁逐渐显露,星的冒险将进一步展开,她的使命、归属感以及与伙伴间的关系将随着故事的发展不断深化。 \ No newline at end of file diff --git a/AI_novel/global_summary.txt b/AI_novel/global_summary.txt deleted file mode 100644 index c476b5e..0000000 --- a/AI_novel/global_summary.txt +++ /dev/null @@ -1,9 +0,0 @@ -### 更新后的全局摘要: - -故事的主角星是一名二十岁的年轻女性,渴望寻找归属感。在一次神秘的星穹铁道旅行中,列车突然发生剧烈震动,星被一种强大的力量吸引,随即穿越时空,经历了奇异的景象。当她醒来时,发现自己身处一个生机勃勃的新世界——提瓦特。 - -在这个陌生的世界中,星感受到一种元素的力量,但也遭遇了受到黑暗力量影响的野兽。在危急时刻,一位名叫琴的骑士及时救下了她,并成为她在此世界的向导和首个伙伴。琴向星介绍了提瓦特的各国文化及其信仰,并带她游览蒙德城,逐渐让星感受到归属感。然而,星的直觉告诉她这片土地并不如表面般美好,暗影中的身影让她倍感不安。 - -在与琴的互动中,星对骑士的勇气与责任感心生向往,决心努力变得勇敢。在一次遭遇强大风狼的战斗中,琴展现了无畏与坚定,而星在琴的鼓励下,首次成功调动了元素力量,击退了风狼,增强了自信与能力。此时,另一位骑士凯亚的出现为她们的冒险增添了更多悬念与期待。 - -随着星、琴和凯亚之间的信任与默契加深,她逐渐意识到提瓦特的黑暗势力正在潜藏,威胁着这个美丽的世界。在战斗与温馨的互动之后,星发誓要找到自己来到这个世界的原因,并与新的伙伴们一起面对即将到来的挑战。她的冒险才刚刚开始,未来的旅程将充满挑战与奇迹,同时隐藏着更多未知的秘密等待她去探索与揭示。 \ No newline at end of file diff --git a/AI_novel/lastchapter.txt b/AI_novel/lastchapter.txt deleted file mode 100644 index d947426..0000000 --- a/AI_novel/lastchapter.txt +++ /dev/null @@ -1,69 +0,0 @@ -在刚刚经历过激烈战斗的蒙德城,傍晚的阳光透过云层洒下金色的光辉,为这座充满生机的城市增添了一层温暖的纱衣。星和琴并肩走回城镇,琴微微侧身,依然带着战斗后的兴奋与满足,星却感受到心中的复杂情绪在不断翻涌。她刚刚第一次施展出元素力量,尽管成功地击退了风狼,内心的雀跃却因为随之而来的恐惧而逐渐淡化。 - -“你真的做得很好,星。”琴的声音如同一阵温暖的微风,轻轻拂过星的脸庞,然而星却只能微微一笑,心中却难以平静。她的内心仿佛被一层迷雾笼罩,虽然在琴的鼓励下,她感受到了一丝勇气,但对力量的惧怕依然如影随形。 - -“谢谢你,琴……”星低声说道,目光游离在城镇的街道上。眼前的景象虽然美好,但她却感到一阵无形的压迫感,仿佛有一双无形的手在她的心头紧紧握住,无法呼吸。 - -正当她沉浸在思考之中时,琴突然停下了脚步,眼中闪过一丝困惑。“你听到了吗?城里似乎有些不同寻常。”琴的眉头微微皱起,耳边响起了低沉的低语声,似乎是从远处传来。星定神一听,果然在街道的尽头,有人群围着正在交谈,低语声隐约提到黑暗的力量与失踪的居民。 - -“我们去看看。”星的心中瞬间燃起一丝好奇与紧迫感,虽然对未知的事情感到害怕,但内心的探险欲望却推着她向前。她与琴一起穿过熙熙攘攘的人群,朝着传言的来源走去。 - -当她们接近人群时,星注意到围着一名老者,他苍白的脸上满是忧愁,声音颤抖地讲述着黑暗势力的传说。围观的人们听得津津有味,不时低声讨论。星心中顿时一紧,这些黑暗势力的传闻正是她在穿越到这个世界后,心中深埋的恐惧。那种感觉就像是一场梦魇,时刻在她的背后徘徊。 - -“几日前,我的小儿子失踪了。”老者紧握着手中的拐杖,声音颤抖得如同风中摇曳的树叶。他的脸上充满了无助与绝望,似乎在周围人们的注视下,所有的悲伤和愤怒都化为一股无形的压迫感。 - -“听说是黑暗势力在作怪,他们要吞噬这个世界的光明,吞噬一切。”人群中有人附和着老者的话,众人开始小声议论,恐惧在空气中弥漫。星的心中一紧,一种不详的预感油然而生。 - -“琴,我们应该调查一下。”星不自觉地握紧了拳头,虽然内心仍有惶恐,但她渴望了解真相,想要找到那些失踪的同胞,想要揭开这不可名状的恐怖面纱。 - -“是的,我们必须对此保持警惕。”琴果断地点头,决心在星的眼前闪烁。她的重要性在于对星的支持,随着她们的旅行,身为副团长的琴也意识到,保护自己身边的伙伴同样是她的职责。 - -二人跟随老者的讲述,逐渐吸引了周围人们的注意。就在这时,星的目光不由自主地朝人群的另一侧飘去,一名神秘的流浪者吸引了她的注意。他身材瘦削,裹着一件破旧的斗篷,脸上有些阴影,难以看清其真实的面容。他静静地站在一旁,似乎与周围的喧嚣格格不入。 - -“你在找什么?”星不知为何,被他的身影吸引,向他们走去。流浪者的眼中闪烁着不同寻常的光芒,仿佛看透了一切,轻声说道:“我在寻找星星。”他的声音柔和,却透着一种诡异的神秘感。 - -“星星?”星一愣,心中燃起了一种莫名的好奇。耳边传来的低语声似乎在召唤她,流浪者的言语如同钥匙,打开了她心中的那扇门。“你知道关于这片土地的事情吗?”她努力抑制着内心的热切,尽量让自己的声音听起来平静。 - -流浪者微微一笑,眼神中透出一种难以言喻的智慧,“这里的一切都是有联系的,尤其是你与那些失落者。”他的语气中透着一丝神秘,仿佛暗示着她与黑暗势力之间的某种联系。 - -“失落者……”星喃喃自语,心中涌起一阵不安。她的直觉告诉她,这个流浪者并不简单,或许他知道她所不知的真相。 - -就在这时,琴从一旁走过来,皱眉看着流浪者,语气中透出一丝警惕:“你是谁?为什么要在这里说这些?你知道些什么吗?”琴的声音如同风暴前的宁静,隐含着不安与防备。 - -“我只是个流浪者,听闻真相的人很多,但愿意讲述的人却少之又少。”流浪者微微一笑,似乎对琴的警惕不以为意。他的声音如同夜风,轻柔却充满力量。“然而,星,你的出现是命运的调和,而失落者的命运与整个提瓦特息息相关。” - -星的表情微微一变,心中既兴奋又恐惧。她知道自己与这个世界的联系正在逐渐清晰,但流浪者的话却让她感到更深的困惑。“我与失落者有什么关系?”星忍不住问道,心中充满了疑惑。 - -流浪者并没有直接回答她,反而指向天空,目光中透露出一丝沉思:“星星在夜空中闪烁,它们承载着无数个故事和秘密,唯有那些能够倾听的人才能找到答案。”他的话语如同星辰般神秘,让星的思绪越发模糊。 - -正当两人之间的对话逐渐深入时,突然一阵破坏性的力量让她们的谈话戛然而止。周围的空气瞬间变得沉重,众人开始慌乱地向后退去,气氛骤然冷却。琴的警觉立刻提升,迅速拉住星的手。她们感觉到了一股强大的黑暗力量正在逼近。 - -“我们必须离开这里!”琴低声说道,眼中闪过一丝坚定。星的心中顿时涌起一阵不安,四周的低语声瞬间被一种阴冷的气息取代,仿佛整个世界都在彼此期待着什么。 - -就在此时,几道黑影从人群中窜出,快速朝着星和琴的方向扑来。星的心跳骤然加速,目光中只看到那些黑影如同空中的阴影,凶狠而迅速。它们犹如夜幕中降临的猛兽,瞬间将周围人群的欢声笑语吞没。 - -“准备战斗!”琴的声音坚定有力,手握武器,展现出骑士的果敢与领导力。星的心中燃起一丝勇气,尽管她对力量的掌控仍显薄弱,但此刻的她已经没有退缩的余地。 - -黑影迅速逼近,星努力保持冷静,将先前的战斗经验回想起。就在恐惧与期待交织的瞬间,她深吸一口气,心中默念着:“我要坚持下去,我不能让任何人受到伤害!” - -当黑影终于逼近,她们再次面对眼前的敌人,那是几只被黑暗力量扭曲的怪物,形态狰狞,气息阴冷。星心头一紧,几乎无法控制自己的情绪,然而琴的身影在她的脑海中闪过,那份责任与勇气激励着她。她握紧元素徽章,试图感应大自然的力量。 - -“星,聚焦你的元素力量!”琴在她耳边坚定地鼓励着。星用力点头,努力将自己与周围的空气相连,再次感应那股温暖的气息。她心中不再是恐惧,而是一种前所未有的坚定与力量。她知道,她必须保护琴,也保护这个她逐渐爱上的世界。 - -“风之力,听我号令!”星大声呼喊,周围的空气开始涌动,在她的指尖汇聚成风的漩涡。正当她准备施展力量时,那几只怪物已然冲到面前,凶猛地朝她们扑来。琴毫不犹豫地迎上前去,剑光闪烁,与怪物展开激烈的战斗。 - -随着琴的剑舞动,星感受到了那份热血沸腾的激情,心中劈开了恐惧的阴影。在这一刻,星的力量逐渐增强,她闭上双眼,努力与风的力量合为一体。她感受到周围的空气开始流动,仿佛有一种无形的力量正随着她的心跳而起舞。 - -“来吧!”星的声音透着一丝坚定,“我将不再恐惧!” - -就在黑暗的怪物即将扑到她面前的瞬间,星指尖的一道旋风破空而出,直接轰向面前的敌人。风的力量如同洪流,撕裂着空气,瞬间将怪物击退,剧烈的撞击声响彻耳畔。惊愕的目光在怪物的脸上浮现,随之而来的则是无尽的绝望。 - -“做得很好,星!”琴欣慰的声音如同春风,令她感到温暖。然而,星的心中却涌动着更多的疑问与不安。她能够在一瞬间调动起力量,但对力量的真正理解却依然如谜。 - -随着她的逐步掌控,战斗的局势逐渐扭转。星和琴一起奋力抵抗着来自黑暗势力的侵袭,双方的斗争愈演愈烈,空气中弥漫着紧张的气息。就在她们即将取得胜利之际,流浪者的身影在维持安静的角落里静静注视着这一切,似乎在观察着星的每一个举动。 - -“她并不知晓自己真正的命运。”流浪者低声自语,那话语依旧飘散在夜空中,吸引着星的目光。她的心中不断回响着他的声音,仿佛在暗示着一场更大的阴谋正在悄然逼近。 - -随着战斗的结束,星和琴在壮烈的胜利中保持沉默,心中却充满了未解的疑惑与不安。星的内心深处,她知道这场战斗仅仅是开始,真正的挑战还在后面等着她。而她与琴的关系也在这一回合战斗中愈发紧密,彼此的信任逐渐加深,却也孕育着新的未知。 - -“琴,我们必须找到关于失落者的真相,我总觉得这与我的命运息息相关。”星凝视着远方的黑暗,心中暗自发誓将坚定地前行。在这个星空璀璨的世界,她即将走上新的旅程,揭开那隐藏在历史背后的秘密。 \ No newline at end of file From 2fb66ec2c3e96b6d3f193c421548402191286a14 Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Wed, 29 Jan 2025 22:07:46 +0800 Subject: [PATCH 006/142] Create README.md --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..96ab12b --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +当前程序实现了一个**自动小说生成工具**,具备以下主要功能: + +### **1. 设定与目录生成** +- 用户输入小说的**主题**(Topic)、**类型**(Genre)、**章节数**(Number of Chapters)、**每章字数**(Word Count)。 +- 通过 **OpenAI API** 生成**小说设定**(包括世界观、人物关系、剧情概述等)。 +- 生成 **小说目录**,确保章节结构合理,并存入 `Novel_setting.txt` 和 `Novel_directory.txt`。 + +### **2. 章节生成(多步流程)** +每个章节通过**多步生成**,保证前后一致性: +1. **检索前情**:读取上一章(`lastchapter.txt`),以及已生成的**全局摘要**、**角色状态**等信息。 +2. **向量检索**(Chroma):从已生成的章节中搜索相关上下文,提供更一致的内容生成。 +3. **章节大纲生成**:确定该章的**主要事件、冲突点、角色发展**等。 +4. **正文生成**:基于小说设定、全局摘要、角色状态和向量检索的内容,**生成完整章节**。 +5. **角色状态更新**:更新角色的物品、关系、心理变化等,存入 `character_state.txt`。 +6. **全局摘要更新**:确保小说发展合理,存入 `global_summary.txt`。 +7. **存储新章节**: + - 每章**独立存储**到 `chapters/` 目录下,例如 `chapter_3.txt`。 + - 最新章节也会更新到 `lastchapter.txt` 以供下一章参考。 + +### **3. 角色状态与伏笔追踪** +- 生成**角色设定**(性格、背景、隐藏秘密、目标等)。 +- **动态更新角色状态**(物品、关系、情感变化)。 +- 维护**暗线伏笔**,确保前后剧情连贯。 + +### **4. 向量检索(Chroma)** +- 通过 `Chroma` 向量数据库存储章节内容。 +- 生成新章节前,自动**搜索最相关的上下文**,保证情节前后衔接。 +- 避免模型遗忘重要事件或角色发展。 + +### **5. 一致性检查(审校Agent)** +- 生成章节后,调用 `check_consistency()`: + - 检查**角色行为是否前后矛盾**。 + - 确保伏笔、暗线的合理性。 + - 反馈可能的剧情冲突,方便修订。 + +### **6. GUI 界面** +- **直观操作**:可输入 API Key、选择模型、修改参数。 +- **实时查看**: + - **日志窗口**:显示生成进度与错误信息。 + - **章节窗口**:显示最新生成的章节内容。 +- **可存储配置**:可加载/保存 `config.json`,下次启动时恢复设定。 + +--- + +### **总结** +**核心功能:** +✅ **小说设定**(世界观、人物、剧情结构) +✅ **章节生成**(多步生成,确保逻辑连贯) +✅ **角色状态 & 伏笔管理**(追踪人物发展) +✅ **向量检索**(保证长篇小说上下文一致) +✅ **一致性检查**(防止剧情冲突) +✅ **GUI 友好交互**(可配置 & 直观操作) + +--- + +你的改动使系统更加**模块化、可维护**,并且**支持独立章节存储**,这是正向的优化! +**唯一的问题是 `persist()` 方法报错**,修复后就可以稳定运行。 From 71e3aeeacdf76d390ba041f9e1fec4748875b765 Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Wed, 29 Jan 2025 22:08:53 +0800 Subject: [PATCH 007/142] Update README.md --- README.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 96ab12b..78bc4f1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,12 @@ -当前程序实现了一个**自动小说生成工具**,具备以下主要功能: +**自动小说生成工具** + +**核心功能:** +✅ **小说设定**(世界观、人物、剧情结构) +✅ **章节生成**(多步生成,确保逻辑连贯) +✅ **角色状态 & 伏笔管理**(追踪人物发展) +✅ **向量检索**(保证长篇小说上下文一致) +✅ **一致性检查**(防止剧情冲突) +✅ **GUI 友好交互**(可配置 & 直观操作) ### **1. 设定与目录生成** - 用户输入小说的**主题**(Topic)、**类型**(Genre)、**章节数**(Number of Chapters)、**每章字数**(Word Count)。 @@ -42,16 +50,5 @@ --- -### **总结** -**核心功能:** -✅ **小说设定**(世界观、人物、剧情结构) -✅ **章节生成**(多步生成,确保逻辑连贯) -✅ **角色状态 & 伏笔管理**(追踪人物发展) -✅ **向量检索**(保证长篇小说上下文一致) -✅ **一致性检查**(防止剧情冲突) -✅ **GUI 友好交互**(可配置 & 直观操作) - ---- - 你的改动使系统更加**模块化、可维护**,并且**支持独立章节存储**,这是正向的优化! **唯一的问题是 `persist()` 方法报错**,修复后就可以稳定运行。 From 2fe97b6b8be65b6f47fac370970297f7d1d2939d Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Wed, 29 Jan 2025 22:09:15 +0800 Subject: [PATCH 008/142] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 78bc4f1..3427726 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ **自动小说生成工具** **核心功能:** + ✅ **小说设定**(世界观、人物、剧情结构) ✅ **章节生成**(多步生成,确保逻辑连贯) ✅ **角色状态 & 伏笔管理**(追踪人物发展) From f409b243cd7951a2150e9bb2e55d152c9b9b2a00 Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Wed, 29 Jan 2025 22:15:55 +0800 Subject: [PATCH 009/142] Update README.md --- README.md | 174 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 134 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 3427726..9c875cc 100644 --- a/README.md +++ b/README.md @@ -9,47 +9,141 @@ ✅ **一致性检查**(防止剧情冲突) ✅ **GUI 友好交互**(可配置 & 直观操作) -### **1. 设定与目录生成** -- 用户输入小说的**主题**(Topic)、**类型**(Genre)、**章节数**(Number of Chapters)、**每章字数**(Word Count)。 -- 通过 **OpenAI API** 生成**小说设定**(包括世界观、人物关系、剧情概述等)。 -- 生成 **小说目录**,确保章节结构合理,并存入 `Novel_setting.txt` 和 `Novel_directory.txt`。 +# **部署与使用指南** -### **2. 章节生成(多步流程)** -每个章节通过**多步生成**,保证前后一致性: -1. **检索前情**:读取上一章(`lastchapter.txt`),以及已生成的**全局摘要**、**角色状态**等信息。 -2. **向量检索**(Chroma):从已生成的章节中搜索相关上下文,提供更一致的内容生成。 -3. **章节大纲生成**:确定该章的**主要事件、冲突点、角色发展**等。 -4. **正文生成**:基于小说设定、全局摘要、角色状态和向量检索的内容,**生成完整章节**。 -5. **角色状态更新**:更新角色的物品、关系、心理变化等,存入 `character_state.txt`。 -6. **全局摘要更新**:确保小说发展合理,存入 `global_summary.txt`。 -7. **存储新章节**: - - 每章**独立存储**到 `chapters/` 目录下,例如 `chapter_3.txt`。 - - 最新章节也会更新到 `lastchapter.txt` 以供下一章参考。 - -### **3. 角色状态与伏笔追踪** -- 生成**角色设定**(性格、背景、隐藏秘密、目标等)。 -- **动态更新角色状态**(物品、关系、情感变化)。 -- 维护**暗线伏笔**,确保前后剧情连贯。 - -### **4. 向量检索(Chroma)** -- 通过 `Chroma` 向量数据库存储章节内容。 -- 生成新章节前,自动**搜索最相关的上下文**,保证情节前后衔接。 -- 避免模型遗忘重要事件或角色发展。 - -### **5. 一致性检查(审校Agent)** -- 生成章节后,调用 `check_consistency()`: - - 检查**角色行为是否前后矛盾**。 - - 确保伏笔、暗线的合理性。 - - 反馈可能的剧情冲突,方便修订。 - -### **6. GUI 界面** -- **直观操作**:可输入 API Key、选择模型、修改参数。 -- **实时查看**: - - **日志窗口**:显示生成进度与错误信息。 - - **章节窗口**:显示最新生成的章节内容。 -- **可存储配置**:可加载/保存 `config.json`,下次启动时恢复设定。 +## **1. 环境要求** +在开始之前,请确保你的系统满足以下要求: +- **Python 3.8+** +- **pip 已安装**(Python 包管理器) +- **API 访问权限**(支持OpenAI API方式的任何AI) --- -你的改动使系统更加**模块化、可维护**,并且**支持独立章节存储**,这是正向的优化! -**唯一的问题是 `persist()` 方法报错**,修复后就可以稳定运行。 +## **2. 安装依赖** +**手动安装以下依赖**: +```bash +pip install openai langchain chromadb langchain_openai langchain_chroma langgraph typing_extensions +``` + +--- + +## **3. 项目结构** +克隆或下载本项目后,你会看到如下结构: +```plaintext +. +├── main.py # 入口文件,运行 GUI +├── ui.py # 图形界面 +├── novel_generator.py # 章节生成核心逻辑 +├── consistency_checker.py # 一致性检查 (防止剧情冲突) +├── prompt_definitions.py # 预定义的 AI 提示词 +├── utils.py # 通用工具函数 (文件操作) +├── config_manager.py # 处理配置信息 (API Key, Base URL) +├── config.json # 用户配置文件 (可选) +└── vectorstore/ # (可选) 存储向量数据库 +``` + +--- + +## **4. 配置 API Key** +运行前,你需要**配置 API Key** 以便调用 OpenAI 或本地 LLM。 + +### **方式 1:手动修改 `config.json`** +在 `config.json` 文件中填入: +```json +{ + "api_key": "your_openai_api_key", + "base_url": "https://api.openai.com/v1", + "model_name": "gpt-4o", + "topic": "未来科技", + "genre": "科幻", + "num_chapters": 10, + "word_number": 3000, + "filepath": "output_directory" +} +``` + +### **方式 2:通过 GUI 直接输入** +- 启动程序后,在 GUI 中输入 `API Key` 并选择 `Base URL`,然后**点击 "保存配置"** 以存储到 `config.json`。 + +--- + +## **5. 运行程序** +### **方式 1:使用 `Python` 运行** +```bash +python main.py +``` +程序启动后,你会看到一个图形界面,方便用户交互。 + +### **方式 2:打包成可执行文件** +如果你希望**打包成可执行文件**(避免 Python 依赖),可以使用 `PyInstaller`: +```bash +pip install pyinstaller +pyinstaller --onefile --windowed main.py +``` +这样会在 `dist/` 目录下生成 `main.exe`(Windows)或 `main`(Linux/macOS)。 + +--- + +## **6. 使用指南** +### **步骤 1:设置小说参数** +在 GUI 界面: +1. **输入 API Key & Base URL**(或使用 `config.json`)。 +2. **选择模型**(如 `gpt-4o`)。 +3. **输入小说主题**(如 "未来世界中的 AI 革命")。 +4. **选择小说类型**(如 "科幻"、"奇幻"、"悬疑")。 +5. **设置章节数和每章字数**(如 10 章,每章 3000 字)。 +6. **选择存储路径**(建议创建 `novels/` 目录)。 + +### **步骤 2:生成小说设定 & 目录** +点击 **"1. 生成设定 & 目录"**,系统将: +- 生成**世界观设定**(`Novel_setting.txt`)。 +- 生成**章节目录**(`Novel_directory.txt`)。 + +### **步骤 3:生成章节** +点击 **"2. 生成单章"**,系统将: +- 读取**上一章节内容**(`lastchapter.txt`)。 +- 通过**向量检索**查找相关背景信息。 +- **动态调整角色状态**(`character_state.txt`)。 +- **生成完整章节**,并保存到 `chapters/chapter_X.txt`。 + +### **步骤 4:一致性检查(可选)** +点击 **"3. 一致性审校"**,系统将: +- 检查**角色行为、剧情逻辑**是否前后矛盾。 +- 识别是否有**未解伏笔**,保证故事合理性。 + +--- + +## **7. 生成文件管理** +所有生成的文件存储在你选择的目录下: +```plaintext +output_directory/ +├── Novel_setting.txt # 小说世界观 & 角色设定 +├── Novel_directory.txt # 章节目录 +├── lastchapter.txt # 最新章节 (供 AI 参考) +├── character_state.txt # 角色状态 (道具、情感、技能) +├── global_summary.txt # 小说摘要 +└── chapters/ # 所有章节 + ├── chapter_1.txt + ├── chapter_2.txt + ├── chapter_3.txt + └── ... +``` + +--- + +## **8. 可能遇到的问题** +### **1. `Chroma' object has no attribute 'persist'`** +**原因:** `Chroma` 版本问题。 +**解决方案:** +```bash +pip uninstall chromadb +pip install chromadb==0.3.21 # 或尝试其他版本 +``` +如果仍然报错,可以在 `novel_generator.py` 里**注释 `store.persist()`**。 + +--- + +### **2. 生成内容不符合预期** +**可能的原因:** +- 主题不够清晰,可以在 `topic` 字段中添加详细设定(如 `“废土世界 + AI 叛乱”`)。 +- 角色设定较少,可手动在 `Novel_setting.txt` 里补充。 From 5a3648345fc5d048a70fbb45c8e430cbf4c91c1a Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Wed, 29 Jan 2025 22:18:07 +0800 Subject: [PATCH 010/142] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9c875cc..dbea510 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 在开始之前,请确保你的系统满足以下要求: - **Python 3.8+** - **pip 已安装**(Python 包管理器) -- **API 访问权限**(支持OpenAI API方式的任何AI) +- **API 访问权限**(支持OpenAI API方式的任何AI,中文推荐使用DeepSeek) --- From d53d97b0e734ba4be01e8e8041393c85d33f5eb3 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 29 Jan 2025 23:05:28 +0800 Subject: [PATCH 011/142] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + novel_generator.py | 6 +++--- ui.py | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 9c9ae38..12635b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /__pycache__ /AI_novel /vectorstore +/.conda config.json \ No newline at end of file diff --git a/novel_generator.py b/novel_generator.py index f088842..2ac9aee 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -262,7 +262,7 @@ def generate_chapter_with_state( 2) 更新/生成角色状态文档 3) 向量检索获取往期上下文 4) 大纲 -> 正文 - 5) 写入 chapter_{novel_number}.txt, 更新 lastchapter.txt + 5) 写入 chapter_{novel_number}.txt, 更新 last_chapter.txt 6) 更新向量库 :param novel_settings: 最终的作品设定(字符串) @@ -291,7 +291,7 @@ def generate_chapter_with_state( os.makedirs(chapters_dir, exist_ok=True) chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - lastchapter_file = os.path.join(filepath, "lastchapter.txt") + lastchapter_file = os.path.join(filepath, "last_chapter.txt") character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") @@ -396,7 +396,7 @@ def generate_chapter_with_state( if chapter_content: save_string_to_txt(chapter_content, chapter_file) - # 更新 lastchapter.txt + # 更新 last_chapter.txt clear_file_content(lastchapter_file) save_string_to_txt(chapter_content, lastchapter_file) diff --git a/ui.py b/ui.py index 6bbcaa6..08b3768 100644 --- a/ui.py +++ b/ui.py @@ -240,7 +240,7 @@ class NovelGeneratorGUI: # 读取设定 & 目录 novel_settings_file = os.path.join(filepath, "Novel_setting.txt") novel_novel_directory_file = os.path.join(filepath, "Novel_directory.txt") - last_chapter_file = os.path.join(filepath, "lastchapter.txt") + last_chapter_file = os.path.join(filepath, "last_chapter.txt") novel_settings = read_file(novel_settings_file) novel_novel_directory = read_file(novel_novel_directory_file) @@ -296,7 +296,7 @@ class NovelGeneratorGUI: novel_settings_file = os.path.join(filepath, "Novel_setting.txt") character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") - last_chapter_file = os.path.join(filepath, "lastchapter.txt") + last_chapter_file = os.path.join(filepath, "last_chapter.txt") novel_setting = read_file(novel_settings_file) character_state = read_file(character_state_file) @@ -304,7 +304,7 @@ class NovelGeneratorGUI: last_chapter_text = read_file(last_chapter_file) if not last_chapter_text.strip(): - self.log("⚠️ lastchapter.txt 为空,暂无可检查的章节文本。") + self.log("⚠️ last_chapter.txt 为空,暂无可检查的章节文本。") return self.log("开始一致性审校...") From 04b5cb598ec7594bad48644d85166f0c26828aea Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 30 Jan 2025 10:21:38 +0800 Subject: [PATCH 012/142] =?UTF-8?q?=E6=9B=B4=E6=96=B0gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- __pycache__/novel_generator.cpython-310.pyc | Bin 11552 -> 11554 bytes __pycache__/ui.cpython-310.pyc | Bin 10499 -> 10501 bytes 3 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 12635b5..441ceb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /__pycache__ -/AI_novel +/Novel_Src /vectorstore /.conda config.json \ No newline at end of file diff --git a/__pycache__/novel_generator.cpython-310.pyc b/__pycache__/novel_generator.cpython-310.pyc index f18d8cd11b2562e8891d132ae1b62862e64d059f..b8aa27c00dc477672b67df880dee732d71c2c1ef 100644 GIT binary patch delta 47 zcmZ1wwJ3@=pO=@50SLOBXQdZx#%Z=R>r#03Cr CDG)dS delta 44 zcmZ1!wIGT&pO=@50SLAm%}USR$h%pNv3K%8xqF)z$p2zu<S?Oyx^1hX0< Date: Thu, 30 Jan 2025 23:13:55 +0800 Subject: [PATCH 013/142] =?UTF-8?q?=E6=9B=B4=E6=96=B0README=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=80=E4=BA=9B=E6=9D=82=E9=A1=B9=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- README.md | 2 +- icon.ico | Bin 0 -> 16958 bytes main.spec | 45 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 6 ++++++ 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 icon.ico create mode 100644 main.spec create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 441ceb5..47b5f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ -/__pycache__ /Novel_Src /vectorstore /.conda +/build +/dist +/.vscode +__pycache__ config.json \ No newline at end of file diff --git a/README.md b/README.md index dbea510..e758021 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ## **2. 安装依赖** **手动安装以下依赖**: ```bash -pip install openai langchain chromadb langchain_openai langchain_chroma langgraph typing_extensions +pip install openai langchain chromadb langchain_openai langchain_chroma langgraph typing_extensions langchain-community ``` --- diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d7491c7875d39feeebddff1880dc7f2cc25318e5 GIT binary patch literal 16958 zcmeHuX>?TQnWp0L$;|QO^z_N;Guw|&;>n~l(`lb+cN{x3v4O;njRD)NW-+V4h(&-v z>_|dF5+F7KLP%&u3))MyNwuj;CDo$RzDuQj7g|dDzT}zbUJ1u`5}V*S&73o`zxt|f z-FxeM-tS$W_kAx;PT%2w%a%Fu^@mPBUE$>PpPihXe#*f^PF@_saq(~2!u0=NpI-?y zH}-6?S9R@K?H>M(5xdn}=g`9$npo2=n%uy>!^+`_IoxpKc|4Ki>Lx z?t5I9`5!*L;_~4WOLu>~;l)37<;q_je$igjqwXJ=!@w}H3;0DD%t0BOJnOl8```Sj zYoEvCKD+mQ{P5D%VcGpq$ZiKB`SNAlI(?z6$WrQNudEMfsBOMpU)^{<-&DLd`d<9^ z%!c~F*48<+bHzn@w|@pPfgz_ax*UEUVgAuI`8e-?CHVNM*#{TA5aa8I z9H|0L_9l#sjA49i0<*Jon4O!&9LJzz0G8~M;l{dYG}vd++%k*aKH?vm$K&1^RM+-2 zo_4$T-9P$6&*?$e4I6%Z^WdTR02deN)1(*~9udbV@z2f(xaSrI4wko<3IU4*1(|4FZo@%xa{-Cg!eTQBTlDw zIfwgf+YXOi`!PH;g8n`Sx;nei+|&eOK|@^w6loS1RPE4b^rEVK5|1Cx@tkS2G)%){ z7(&RERAi*d)f&{{{Y5D$1yWCgK3VT!C`cF zc2g!TP$cIfSKEnV+Zc3;MwAsgP+v2Lrp8(NfiJ~`#n{kK|Jc~vU-J3i|F+)yA33rt z{NmZf&);9Gmb$7llf`4z<0DQS`)kHWM{CAMP&+XSw=H{dZtFp)l4W?@)$@D1e`5cO z^YrvILe88-%H6w=B@|KL2k8rD(NH^!rY7=V0sqK6`k%zaCo_3MX-TW2r?LB6kBuL! zT=CM4V~3hgI`0-zO7TyZDfK9{Y3CE;!p>F>^gkkg2!s2Nyw)c8dwOBt^0f%^4nTWr z2YvrIeg7BzpYXY-yBn9CcOdA<0fc*|LF@t-5cp8fz&LE?BHZ@$#o>?EBS)=8eOWb3C1q8V`?t!G|J#50k50!o zt>5jwb<@fB-+bLEQ>#t1Saat~igJ;kr$?SykGk4Y+H5)M>?J5FG{WoXA#D8RTX0^v z3ftaz13OnNLv?vMGSgDvy6z*mtXU1$jT^D$jn}bz=`V5kw<~b?-QVEEhL2#<7oo^H z1jq0knw$F|iOa^Gw^mT6-o&BRYjAAcdR#f|0+p?gof4SFP@Bj8^F3#?5Hy`TSH90vj=jGCG*E5dQqouh9&5f04Y$!)b ziHY$$4qJb<49Vd!IJR*Uw!Z#zoY=7)kvDH+)j#|Le*HgQ#`2ecgylc_A=bR|3bwrV zFK}M|7F1a zq#HQ+(T6=d|Mi!{XV$L9ZP)#XJm)?e<#qn!zZ80jvF^oq%VXzQYyYsyT#WGGAjs0w zpwXyc%QK;(stkoy<&dc}VK!&OY|_BvkPB{ldLz)|B6btomS6k}3N1E--@1(t{^=F` z=0`umvLE~aZ*jasnQVLgHSAfw46g6L3y6DQ zPTW6w9AU>#AwBp$&)tO`uf2vHZ~OvB-hCUsJGUY5(5HBC=EUgPJDF@%(o0ZdJk$IZ{fsL>$y{yp^dEcp1s0vmPTf(&g2Jog{Op_OZJ ze)~?iZQ71KE8fPAB};Mf(@#-ZSPF|S8#~`zinagvzwi#_u$<%VpZo-C|M^w8{CYW# zu6hqAH>|~jtHDsGm*d!nA7kIr<#1oK0atc@iYRvvBzWIIqPHKi($aDC-S@GJc6Ruk zx8d!)4*~o4;MNfr{@xy+XT>jEr>Yx#H?7iPm)4Cy#*tjshhE1B@Vnpf|KBl|58}bK ztH_Y1BTJ`3Lwy74dLE1TF7DWaOS|{smg_N`Ub_wl-hK}UR;+;Ax^?*E_1CfewSUIe zpTCOrulx)vfBF;r=70W}I`J|#P}f~QT8HynHskchb%+bPkG9q}l;#(qEUy5%G&$=7 z3h0v*Fi0|>h)+Rm;5~f0^jA3X{z_cfyaku`QU(Y1!+qPPFL0MznBY7)#rKosO(lrBdI!hfUj@INJ8|XU5uDhx*}iM_>Tety24|<2 zR@PLZsI&}~ZM~3}Jx1>Yxg2d^in&};QWEpl5d{1BVsKy(_Uc*@e^XT*Gzk(sIC23& zuBUNh7yZHBgSfi)5KhsLyRTja*Y|!4*Hx>rd)adATk#I|Ec*?%EqN2Wmj9aZb1Ra< zBT;TELa`+ujWzY;)WaBG#4pHVfO%vIV?a|)BZNGwwxSyGA)!bJ3m!aq$n9^RWxv0C zP4)O_rlqJ5rt0d?cuvLGsQ6x4kcaT7NF+*AkswJy=)Jq-1x09UY!=rltl1>jAndd+ zV%)tD?&^+vltsXvPvQH?P6#$~Zp(I@+xRiu)~v;$_g3R9_di#gg_)Tdac}FZ?5HEI z{2YrYUt!OMoT|O04MpaBl$2JYqOBc;jSa|DNny=VFu%%1Xwbbs$kD!x?dv|1*Zq{- zcHU&kLrzfvs>$&Lo+k|9eZW4--)Xds}2`0*8U}y;Q^h3g!n4Dx@U5PxC5;ax%Fz7YxgnY9<;=fWpiDjKGADeRS1B3>aU7#bWBWgy@eMtdvwminJ>&O-@xtGu`j1q}@trR@nmSojm> ziHQlyrU{l@CG53jj8l{0&x-z_Z}Ru9rc^AGly$U5=hzW#Y(h+K8^W^MaVMh{K^ZLw zO0yw2Bm!lF{g4>-NYSbgo05o!!NH&57v#`St}Ea!&7&PtUmGfFMgIF_f9F^a5p;cE zV1Q$gx;+I!4o~nq`W&$3SW#b9OFmSI!qyhi_9o}&zJOi$t&-wGVlPEQL#-f_KA!N@ zb%Ik@6yLp?(y~5C)1Mk?9T|R9F^!~Z`nScAP(FkE#P4|{1&4MYhx6MjahmZ>oo7Ws zM;oFO<8hX`j7A~E@GoYAWKd`TYc;2zMsR$(5Se_LapG5Z-0Vcz$Cmc z&MQP4ZM@`h7j3fxLE;%Z0=JiTwib;myJ{q6hM|L$t+U$p_QyWQaLAB?!LU?e6b z!qV6Pd5!`3^cBK>3TqeYekBD(&}1199~X|WkUQvV?c#T{T;mKnT02GTqpTGP{#&RM z2r>{b3)esKf#t=ODC_D#<yMmvzPobmr{pJ}zOxdazPAQ0t3Jd5`eT>XAK}3IZ8*B!1t(oE;P|m? z@bG(t>k)eR1ZLo2Y%26xDf2)XEctoJEh|M^d*^5C@9FG=N^5|H`)4v*7$5FHrBI@$ zvlk83b!cUr5d8TdZCC7I5Ql_EG05e*@@T6z^8C)W z$7pTtLMd%_pnsToft7g@ZMU=ng;pEv6*XcUbM$e)X!`<}9HtBey%6vVx+B!kdLH*e zAad^y>$PzUmvcVsHlp*3bfFUr0MdDUgepy0SqP3v~W+X2WdbmRzK*> zPKL>1ntl)x{Zu*7+rM7@(up&Hoi{?1@V%7;5C155T#kg-)mU7(5{olF;qdki#l?&F z;CbODwyfEV(w<&1_KAFe@vyMF1F?z8NR>!YTv7&&K9~7%#~0$kW8y8LJ#@8oi@s05 zIMCmZ{@y;ZKhQ{-R+d$f^S7g~w}hv8;m5|J=B z@P*fLADnePkInC`g^gH5F2MCvkSC7f6=*TB8ojKy&cg-gle&=uE!Z9!UJ#Tn; zN8r?%Ah;j8fK$${aNFsE^($6FT`u|uf=_G2}yJSR56~=CHO&p-y5owSUd{9 zhez?zP%zgv(f%{hOMj!#`LJq_N01j1Sz@Zr_O=y%43{Py9adox%uGI5f7Z( zdj#%o*Ks-A1pf%u>yBT+@m+^;aMNyluw*&q+$Zk4p|YGl*vfi-8Dbws!DKB!3;B7D zF`F?e8%^~sBCo5fZ9!2%3;G6U=>rFm8rjGGR|n;@&>Ir`UQtmAT3cF>MUI=ROGURW z5M8!g;;1uRgCr#aD&8|bT|%5A$YL!@BTYuOBpNxY6qK~pe}Sh{KmV*e$a&YNOFj3V zLV&wJF8f3v;7&R&)8~1gxrz(SA-7+BMvbC6Y4#`>!Xm6U%$XB%<d8sRdCn*t1M|q$+i_%TI%4k(QoqNMZ(`nD+ly=i`K_%L zS(>;myQle_!zBIu7&^OVXfLy@ zlg~2On`13yjyca59!92-`{-d*grTP-9KGdnXfKn&oKs9c$!A0gnCB6HeI1_}sjd10 z{EQRP&w}6gnCJIzxcRnTId~pHCw+1C&`DhQWH*iz|E?9k#j)LcVQHvGZoQrHE{*(U zgnK^#yWI|bRyGn6(r7zZ@h~(UhRk;6zcY+^v#bxZ&)qvK%D^!=gFg1W`zeo3_B({{ zDvD>2pF0b)b{1BhAcG;KvDPJLeXY4L9*%k?hT5!7w(z_Z{Nt_cpj|HGtY*=(TyHC#S?8s~TH#nH8!5F8wW8v0e?{;}yY$XLtl z;ob|i7?o0m z+S&m$H_WhxLmjZqL9gsYY zC~@>a$i?%T8lf#NhK~F?zvuBQ&x&8Tu6A4VgK8Cru9jSR0u(h}I z_w|q|^k`{pp^tAwTC$4$pF}7!)Cdf?iRfS*Ze5EMLgXtf$hB}r(l%fq;%o<1iL5f5&O zoUpXA1o~_(Y>b!5QYrN1r6?cjM^0)aFQW-O=@F<%6USnp?coXU5K^aOJ^lBN#hdWSU z$)Zf}lV_6;=H(zNG6b4z14??yd&v6)-K-iL@eek3IwJBqB?(nsM`KGn4yD?AzV}sl zzx>^us7&X!p1$#G_u}BX*Nr*w0qonf3y~5Xq~zowA&;Px8BkqP#lBrJEaqyYOXOU) z0Vzp2tc6a~R~Mk4HRD0{Z40c!2)ma8g)ARgSw-TA4pTy_%cm@gSidttlB#C^r=59K z7CDp_52HfyDB%%pN{zInN92m_^b$3@&PT>w1c923;l#04`U)IBN-AXlc@MO*oOx{ z&wG*+3kts%TiG)iX6xD?S5W(BaiGu3<&MYAJJ&B0BXV=gmQ>d?ZXa^yRVieYp&rFUX zmt3{1poabUBGw^Tqt0u9)l!YbM=6L)NJT_w1X9v;$jPZD$Ll4hiD!*w0h=UMhOp33 zD2Y9h@$wO`Nlca?ls(gQse(CwCgqgDYwpp$BcRJRB2$+|`$)#<=m_m1438LV{H#3dn~=gMW&dkzI_ z1`^_y%ZZ=4q*AFQ&PeL|L+Ayb&;0i;^Fc@d5bg#Bit@-Z8X=`_uuf0xqutfxW8ca% z5v->!>Feuf34RJyLeGIRsN!>48Xfzd1a~lMPAi#S;5`&G^KsZ?w^0bMef~w5w2;=xpyoc~Lp?X$Mu02VkP# zFE8yxO_flu=z>mD$h?eoO6qUm&Fj#K_1{#ar7!GlT&z$dP;q}%^!Zw7Sr5ukYk9ts z7%3BCS7c^Do{>S{AJ3dx#{1>M`}A3aMMNVaSRv7NJ#c}!km6>%ByAoL#hT`J;8PDw=y zhf1Bx8j*sWfc{LAh18UEB&DPwF;zkvNoL$rKy4tmBL1K|x5T}dXQ*)Qh$~dADcz4u zgwbNbIrfecQ>7^FfBdLsd|Z4!=bIz^>O0s+94}Um4TYCEdbuxJl(TMM-#5ZOTnl@0 zGvt9|%;8#4Mt@*nEki<17ET|o$RuZviXv7ebOz?QHAD0T^oIr2=*9PD7p78Vb#q}#UaOw0J#!Csj zy{|Yw-KzgGXO$MUywF(R^^5AtmKCi%1J}#S8^*KsCG5itF^BI#@8fyOV3Pizi*$mn;Tj8Ddo9YIn7RV1-_s--sC)r!IYaXCV6@q)7$B139jmV>anO*&6cZq!X;JXEurs$fA zKGr6x$AQbm~aP7oFc%F231l@@E#?Lo=vt9q=SC<#Joo#O!pKr7e(+^DZnFRK) zS>MbxRzR<}Qs)iyne0`vb|S>Tj0`?UO6)4ydIB@a6k6B{dZy4LN7Vxn`<|=HjWOrHc#<+=3)A}E>Q>cS;fqGEzD&#?yL=S5#Kkm!+{R`{R;-zK&{?`9HQ- zI}9z&lc=ufVLWW5{@3%lloHXONx9FN)PFVYU*NqN%wvS1(z2#ZpRXg=H*)XI)O}lF zA$^3+Xwcez_ecw14SvCDtlwEy*xfF~Loq)z+u6se6yv?XZw1Vm8V>cs{G`t- z+CAf=LZ&K;iAeqMqYpOyRruXkBYL)P4?FvRSDIwqrc;$@4Z8A4o3)WO%R1^e<3DxW zV6x7#-!rV$=Zq^dw1er=Oe=Y!@5QqLum0mdJ(q6o*#yC>-v0G_e|u#Ag*Wa7Bzzbh zqFkFCuUZoOQ2Afo4*Goey>~u*Y3Z+Ce{N#hGlTqp=tA*9p)b7*FMhuGC+6>n$A{MZ zJ8^Dt`tt0wcwGF#Guug=&v`A(XDJ-v2I+#3I1dT)sl}OhY7stv2fq_0;e;o%m+*!c ziZjm>J_ Date: Fri, 31 Jan 2025 13:50:07 +0800 Subject: [PATCH 014/142] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=9A=E8=87=AA=E5=AE=9A=E4=B9=89=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E3=80=81=E7=94=9F=E6=88=90=E6=8C=87=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许用户上传自己的知识库(建议在线版API,或上下文长的用户使用) 允许在生成下一章节时加入提前指导 模型Temperature参数支持 --- .gitignore | 4 +- README.md | 21 +- __pycache__/config_manager.cpython-310.pyc | Bin 969 -> 969 bytes .../consistency_checker.cpython-310.pyc | Bin 1421 -> 1403 bytes __pycache__/novel_generator.cpython-310.pyc | Bin 11554 -> 18027 bytes .../prompt_definitions.cpython-310.pyc | Bin 5291 -> 5294 bytes __pycache__/ui.cpython-310.pyc | Bin 10501 -> 12543 bytes __pycache__/utils.cpython-310.pyc | Bin 2480 -> 2480 bytes config_manager.py | 2 + consistency_checker.py | 18 +- main.py | 2 + main.spec | 18 +- novel_generator.py | 337 ++++++++++++++++-- prompt_definitions.py | 7 +- requirements.txt | 3 + ui.py | 127 +++++-- utils.py | 2 + 17 files changed, 461 insertions(+), 80 deletions(-) diff --git a/.gitignore b/.gitignore index 47b5f9f..f2bce34 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ /build /dist /.vscode -__pycache__ -config.json \ No newline at end of file +/__pycache__ +config.json diff --git a/README.md b/README.md index e758021..9699b88 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,22 @@ --- ## **2. 安装依赖** -**手动安装以下依赖**: + +**进入项目目录,执行**: ```bash -pip install openai langchain chromadb langchain_openai langchain_chroma langgraph typing_extensions langchain-community +pip install -r requirements.txt ``` +### 安装语句切分模型punkt(可选,默认程序运行后会自动加载) +**Python环境终端输入**: +```bash +python +import nltk +nltk.download('punkt') +``` + +等待下载完成(很小,下载很快的) + +**至此,环境配置完成。** --- @@ -82,6 +94,11 @@ pyinstaller --onefile --windowed main.py ``` 这样会在 `dist/` 目录下生成 `main.exe`(Windows)或 `main`(Linux/macOS)。 +或者使用提供的`main.spec`,执行以下打包指令: +```bash +pyinstaller main.spec +``` + --- ## **6. 使用指南** diff --git a/__pycache__/config_manager.cpython-310.pyc b/__pycache__/config_manager.cpython-310.pyc index 62b3d078372ae3c5c701c3804bc1de822c574f60..575cf01f9c3ac2e49de4b4df2c21b9b20b3a2332 100644 GIT binary patch delta 43 xcmX@fev+LxpO=@50SL|*%t`;Tk@qemBirWJj2VoK0+U0S^Vpd<7=@UmxBv<`3YGu> delta 43 xcmX@fev+LxpO=@50SF4^W~HCn$a|NOk!AC1#tcSAzR4lXdF%`vj6zIOTmbk%3G)B| diff --git a/__pycache__/consistency_checker.cpython-310.pyc b/__pycache__/consistency_checker.cpython-310.pyc index ab16c36ee0a871aeb8328be08db63ef69bd8e32d..792d4c92b1a4922621320a2cb554bcaeb84002f3 100644 GIT binary patch delta 650 zcmZuu&ubGw6rP!#ne0xIHPuqhrAD!b1)+l15S4)7rHB=jh)T#z)97Y*dAm7Sw&YO3 zQx38R4~it<#fumJ6g};=q~c#-K@Yy&7VN<{eBXO-=KbQ$tomC`!+Kp2lLD&cfp%7KWbGv}$jp!+IS@ny}o3BHL`y%2M`AZ5gU27jRSa8xSscw5cZMcNj^53e?>{a-&GxSRH}iq1#SvCCt#-TZRWg@fl_G*skYt;@ zNvqiwU*CY@)5-yI8I`S|jyJgt<|O$ufwk-ne>7dg>-ZWHuQPgb6em@uvcKf5?c^H|XKLUmi;3DN#oIoyi{IH*tX;5@JLl=u6yV=IA bO?=a#=*HKPYK$}13|-vD7Kju>VngR>+~Fk(_%VpO9{1}Z=w&<_j%RbWt~_aZFB!b>zR zvymAT9W5?HB2^r$IPI@?sa?OG)^dihHry}WqrHz#ed*9@*qxSWwg#R?zSpU$Fi%d5OK6;xsN^z>z#PKc5HofzrU~_esbQI#cNBvdegTS zRr`5YH1|cdbKJ1px8>7#@nqX)@2-0O-TM_>3zEAfg=@*lhgDZraReG0@t z^VmctHi;=QT%`*>gP4j*O_>oUf$cZ_P094N@It^0=ocBCL1ti{HsuzA7RbdrkVKsX zxH}J=l`XN=vNxaE<+@WFm2Sl(@N()2JP6|6(djD#{G5)`o}A4yEyKMExgVq#G_-6% z<4Gu7D~`d+Q2WHoroFytx2sX5)|W2TLUepu<^MV1EFTYeNtFW6XA1@odWN5CdLf_F zwQa=5YO^#}8i!?l5`=~bJ{`&*qMXfDg<^_T%rC&E+fk+GOC(r6l}Pj=nPe`VnoZ8- z68VCbOU`iuTX-0xC+P+sL~!pOLvr5p`me&(iy3VSd%se@3_?+eQbNwvV)6tT`Slle CHsL`4 diff --git a/__pycache__/novel_generator.cpython-310.pyc b/__pycache__/novel_generator.cpython-310.pyc index b8aa27c00dc477672b67df880dee732d71c2c1ef..0c40b7e000d701a7132903fe6f0db5b75319972f 100644 GIT binary patch literal 18027 zcmbV!S#%s#nr231F^m*oYM-oOc1>`-Nl%U}$+3j~c#Rae+ll2)YTv~!VfMA}## zIxTNl2Fo^JR@;R{*%()0%f%`$;65`wGpEn=IsG{2#KSxc%9M2Gp&sb!s;((uzW?5c z$i=b?I(6dS8+VWY{`bHCc9pNM4}0+Y=BeYot5P1%SM*Z(n~j%Mc=WK>>iiJgMN$Ws-;fcv(o4{lb%z zc2JIz31{onI$r9_M6&g2eKx8_vkhtk&-0_aQElYuK&C0%tTyv>Fw>H4Ra<#l%CxC% z=u679tL=zGnOWHmwS$+1GqbaE)Hys|mzkTLr_RgXpx%(3ug=fjsNTr)Bbiusfw~~O zP+gc^q%O)XRu^ZNs7tapsW)YBR&UNORhMStYFzT9q%F7LxmByzqQf%UHmJ90jj3f? z6Q1UjFSTs8M{7wn_XM?8t!=NOcDki4DSxVYwx`FhwQI9ba=Tm7fwnE0gf_FaIVib9 z)7-LoD7ztbC;D906V~QyH}3VRcRl9OV%mbe9&JG?u*a+39a^ZR+!l+_Vlkd2cy2<= zoAE3~I-a_RkJ75$qTPyK?oE5N+ceK>lD3S8ojkl3Bpy{LkJ(%RwH~wTZ8aX?XlM+^*-%!jNFpCvq#a^YJY=o zyFYY?)}#FtxldqTPugD=)Vj2%FsC1dp2oNIYJZFJXYe?XU1!&)@1XBlkN853UoXHtp*@e*4`^vEiSo)a zzqU#1M%fBYTIY#xnWkOR757@oBk69#Ql3cbh829O&q(KU$xK|bLhE<-r8FwUOL+C> zr14Z=D)-P@GzqUWl1Az=mh9VX`PQv}=$ZAFcTLwLsMjd!tjTUlX<9nht0T|9dNa#s zlV>C=_(;CHkWJ;N$Ch=7Q@QTcdX~)TJ$aT*F=RJ&=k;_hmC)1KbSBBt#?H86g;*-7 zC3@1Cl+}{#!z{Ffk$S;M7GiqTlBL;{t|xo(5|=F>FSGmv*`#6BZRgX}G1Zh6NcN=@TT?r& z;HIRWN)%Ye@)1CF%g<6qf#teEEZv_UDIcEIc=Y=a7#`DON}4n*4|p|M^X~PS@*|!- z(tu(}TVzu)y@pp)hP?wm(>J1wf?S$Dpq#JpE*ghp_!Wmv6i>fgJorZO!e>)gjuwyo zYGQnFV*ED(Q;J7kncjc6c=5I0Up`no|Kh~uk>b8nlke;;y|KS^{KKg$$EHRP7C$|R zOf;Xka-w+lt>Vym6cvx3pFDQ1_{HmA4el1>yuZ8jtK$>n!&K$?2UBCGL^(R1xctu4 z{@)Z|`k**;ta$07)8dS*M)Sb1WIAC3No}ZjCwO&B_zdSKBSFAfhv(~d1ib*xbAbT~ zK*ISlEWILclh=99NM|H9(uc)y5R^qw8S5l>-eF0`84tS!?|*)vd-=v^^%T=LzK~93 z(i06C_-^Pd5Cb|gU9!up?EQv+jDA2FhdGGAoX@ys0u!*~Y+$-;<>CoKTJ^f44 zxwMfG>yHfq1n69m$#*9+`bs367I9e(rJQYRHT7W*C@SN3+{fsoWpxPDXb-kfLbG?X zZZ-aF&Fb|}J(F06U`^tYwa-`qyMwr&#i-c=3Kk-;!tN-#8gjc}H&O+Hfe3D;s!dnT zoGR!Y2xOlu$&&xusG=a|>2HEQMPhfMu$#Wz`4K8BcsTklMZ)lGkw#?0J1XfxqJ@US z(OCvM%ZAVJnn3ActXW(sJb>y`W0#7D-za|mk-#C~p^KkDnyHshx#(3KdRriKtSg^O zp|FcBMh9`3bs|!m-49Rn6mr|!@#j`4s1 zR3MY@?L`$!N#{UB39nc^0;@i$;h1%BpWQ)id=-myKUMPavDE-U8+B_Jt;IeSDwpk1 zw9^|Agt2U4DJmh9BT__Sw4l?3)0S^rA;orD($;QTD(4qK!d<$D@!~F>Vk&yHymYh_ zrm_VnZh8sH2Yi|`qG&$N4;tanO!KD?C~lxL9XJp4yIcFO%VnCzzFdU%K1! z;q(JH)0`{d+yV#HI`fVVjl|NSwkpuek3Flmq%-<&jUi!5orcANyDKCf-i$!5XldMW+(?FO#y$0gA$^U zUkmJ24tTVnp=cpGJdrCyG*tF`_Demo7S`(aMg~IHERSgQC=Xw=JgPOIyzZLijan1R zBiAf%)>=?rZ}<%b{AtvTjs&z;Gc<~?;^{Us?55kzIyXJbjJW9zv))bzjgZ#rq)9Ax z(`{P2n}$&Are|p#Zo0#8#;P0j3^bTtaLPW@ugyNL3^bYzBN4L^)VIlO(*K81KN8jE z&|$011;uU}Y0&0rH;l>y%{)Dyr(4Ws_P@*){eSb?H#)Vkc8w!VSiKnPx4Lf&@Ybd+ z#L9*E$}Q3s?^Ooc&33aHkeP+J1#ySbY|b)U_t2_0n;m8wPt}jKXiJ>_<2Mz)0|zWl zhA*z*lrElpr*!sI>C;`4AH2-Z?RQHFW2Fy9i@$ogcw~6;^!dtmoB}zMfk1Qq@YL8R6XTbr-+QaF8mCaT)h2&;34K;p zyPAS6kJ&8J>s7;6kTvSHIQ%-QL8d8t(;K>si5X|tMN0%i|_Ml>q(sF3{@h(^^9Wq1ObYHf{2r815%dX5w%%L z?*rL$rJpJeola0!hN9!v@QgsbG(E?_JDvca4rHVpuPK#tGzABPudoW$A=#AinLmvDcsLkA^ph z^4Q9iE1!$8htX+&!v?2nY{iNd&&9gV$ozz6KSn9i&m&?)rz?m!ms&zvYEz*%0rv3_ zO1~z?tXXcC`tKCDUvr2gY!R+PD0ABr7YSVfTZAsEco-toM6g@%=){F^f(@#!S?z2mJVpaG^C&*&eL!4(T3Loro6>R+&h)T zLtQf+EN7INa{CuK-;>}Q(cb^dvGHRL5_GaBF=e(20hk#&<=A`#mOq`_p5K}hP=17R z0**EZfMDsqGhWt9wV$W5pwk`JB1O3g3fyDVYMXH)ub~nbAQj2){QV{DCq5?#Ry!*7 zKlpu@X~zDqS|(a5TBfh*SS~LUPFAUAQ9AgdaCFzelV7zc5V9&3#oxuD2$fmAz$hEi z{Y%|>UB%+mtU?!Ngx&8(2l*{~H4Pc%^O9wEd)A>&Tre%gv zKBTU61pN>E;O{Tb^{Nji(#Z({ao}YPt0V+m8&^#N=5uWGaS{M7?ka5aS7BRFU>k=i zBk({i!8b9-A}pD@@UaH8Pot^>+F6wT;W*bm^FW`YH=S0?-Pzv=`NiJXE59$y zXYT$guoGS9V5d*21v?k6s{uZj16<(%hroRmzy!0B^0AGYw!R1YhCx18f$tiG{Hjr0Q-vltuP6%E{)cO78 zcP1}iES`S_d@1Yz)dFD*Yz7odRb~Km+TSE*SHb%Bw}>ovv54=rya=k$3T!pk z;cI$EaHwnYsEio8_E8zfvC{QtX%SyqOS%R0KfqFbnPhh=F0mc-A)GpwNpzTuBROs` z`48%sxUE*H|E}wUu&k#jH}AiPw~5&wrpJ;wSnnJINX%JIEv6T`yJ3pxDP%G`JL9eF zZVYJ!v&pR~)YchsF6>zfNRVYTAr?TO21&D)gCr|bDXllff>cVvDlr4uKFG2)CzR!d zR)gI`)z(nZL;>+=@!2d!=>-)0f`S>uW2RC8F{{_aI@Q<9EnQxip43P>*H?fEiDVLP zF3}4agKtl@trA6Cu&5DY!?}(Ca8o)eTT zl1W7rL3xm;qTZ;~>?IxS z9_bO!b2mXP4$Ihf54+uvOfckR7t_33eQYV@e|f9IZZv%Z^0qKEEMC*s>bc*8UeE(_ z@NUYjo>?Ax3wcBxDmRHSR|>@Yb9tN$uyq@6R888y;fU<{VnL;?DCHDF}>65Ep* zOCfg5@*-JFsGyP)V1~68+iO$0yB?$K#2_d*UkJw>O_*PfD1IsGRlGsy%Gs+lY-`Pi z;uOz{h+JC}7>2DGCO94T+$!5g13DmF;R^{z3@qJ<2TB@mFP8K@*fr9$XkIMlL!=%b zkx+(MZ`&5ZatBNWC3g%24H>HSpy@{pWhV@vAv46*nxPS>*&%s_c?v8SZ(nD~BVNr< z3MlrVff(wSNKP>VB4yTL=_62p1_t1Nl9RU0HUsR*ZINw}UJn#nL9)PfaG7luJWpnM z_Q;sS5>j!QKA_qlVri?)bhCa0>NhhA$vK1`qq8AZpWs}`whg>2e62Dzq}7dtq5J}> zX%V1Xec}HCMjS85B*0Z3LVq9>{=hIotiwlOFDo6~Up)7YK&;u=l{pmcPsbZ{56R+FcPu?MD3@1i!sA~kZkc=@nXG43xc!WaK? z^1}}|>bET2uu*&N);LnAyJ0z4gXexi*`2q<7Z-?oaO;84fyRNnVMY@d4^E6900Eo0 zc(l0h?8Nw<;=bQbTsTo2-UZSFf`hGk&0ZH$ud9WzZliwd(v2D)zjp8&M78VGnDH}U zfvfFl=6Yi%YU(`-w!_zxN%rc3Hav(46z-*2Z1~~dzIc6s_TN{7N5t}eHTVhuULfKM zKm*L28Y7GAWdu)K2TzW^Oq#n)=v+Ltpap;yDrvw@5?=dQ7Yxy>A6mBtMgo>E^l=iw zevU3JAIC4tFC0lY&k%25uc3$?rQmg5i93NEq#LqWgN6a<+hyDx~N)Ql9F5+e%ObD4-GO1@SaaeRNLD08@JVV*QcmIuW# z&fX&stU_Q#1?6;@J*&;8q}6PT73PXRZUnLFGD-L!h?!dT1^6)}dJFJGf|pNs9r|>B z^N=9TfESP6Dcj5VsT|tC=*|7MU86~BhFziA42}dw zJ*>w>%4~wEyd@`_O#>~wO-oH1a-)k3wAyV3O{C05v}r?|#(_4Fbh66AdZ2YcVb^Yi zK;nJmNQZ-DmIm=rn}_EHJoAmPb|c~#9;ov8%o^>PEz}laln&_bJ!YHP0+QbjjeclP zU|@DtAB)j$$qfB8+x^_6-Hd+bm~-rYaQxUk1qbGubB{ zwoC9m6}E@vJSP^rGaf4)`vhJFw+r8%;=T`xzZ)#Q^48S*yX-1=xmAR;H+A~esRJKP zzVumXa2FkBqrbK*-W`t#Q?>9wu#cfH2TRA_ofvq%9s90+Uu6`h4=z+r|AC z(ZTe4CyFQesXKNVN2}e(edy!Gx8Yq8bLhw4SMoyxG`rhuV8v+jI&_8#>^|;yK;@e3 ztL!q>RKhiNkW;{=Axv!Ii{r)Dc1@l866^rY-?8jFleEn{GZL5Owp~Rq8G=jTvzpl^ z6cfJ+i_cH5YZ|Jq|1epHo_*$t$^pu>&HXSmx}IIA61?R$;$Q>%32bxOjpE_YD~GEw z*x2*K!oyZ$TW=hTO=YthqmbR~`nI78N?o2NEObC=&QQI$uXyeP){FZ$k@BQs;cS+y z^yY|Lzh*V7NWa^^K-uEhg-SNA26v%SDed{V^zrMJA6R3IvOiG3vQ)N7Ef>ylw*X?3WZgPeBsFHO*9P6G~xzBDKQzF+breW44Kjte}o% z3GD5}msjBBGbdnVvK_Ljv#WKgFjmb*+v{sm;|d>u zouQh~Q;?;A>t)JHxwaXH`y6ZaHQB?2+n(Rnt3vVx6Gt9Kw&vG?;Y{l{W5-sA1DaO4 z3Z*L{%zlG`%MfDc@Y=uF?yhEeuY$`Q`o`<3@*TKPx7UVC`^@A0Ga>O=n$QD31PC>= zE=14+e2u3^zbqa&UD~sck=()_qF^@yC{T&{XWI~1VQMTk2m38$@J&E2MOHmfmn9t^ zFDvRY;t41)3@hwXzf?DAqnsbkl8Ppe5T5h=EHed#AR7Y&1dVtd=PTGh7!t>G=)ej$mI4$IHL69q12SE^V z226eWIh+NEb8yMlSw}3U{{GO0uLy@Bw25vV*UVSK6(yWSAx*&8We9cQKad@Qw<#Wa zv3U3+mw}=};0K302~d@kgae;TX8kufKJutSGFCMf3c)bI=3Hl> zDENqik16;B0o=f=Jo0DU#tlv#dq!UYPj%Yma+D-2{>bvTP_TYWD$_8eGALR96s{ww z%-+`bd@qcy8ORFQ8J4mGDA`TQE9VzRg7A*x8J%Yalvo14IGzQn@C?|;xo`zGLf=4; zV_FcH2Gx}g8G*b*xycGXp>Pohnjst05IdOG#XiL->@y0;3I}gkT1qxf0l~K{72C7s zo@xDa#odPMt|p0!e`PhpwX1oN+FYWbc1dYK_*UANEM{EN`l?9 z{xKY>Skk+`*OFEZ)E`_ekUG}iaV<+qe~VKB7s6Oi5_ScxGv36_E6k+tCGsLHD`k5~ zBjs(S;4uo`qkt44jPQez7|#8R%f2n#?jN7WeoaLuDTq<9fPzyLa5V|Hn><0U?^AG^ z0y3jO?`yLSba%jgNF1 zVdh>38MYI>pnVfvQe}h*M*UH#U5-L?6D7TkG|wB1L?lIaVi~#}|94RzH^h8r)0b~# zMg9veM*Kh7FaKE%$p0Y+8~-HVh?V5b+nQ@#x195cdWjB=BfDR4E`V&|pIs z&3QI{ivXq$C|tnJ5n<7x;6NSW4g<3%^x|HWDo~T)h=ke%Mil*i)CrMp9SVdn<(rCg zC&6}Vhh`qF{HAZD&gA}1NJZ#Y0&j^U)xMt)EF8oX`Ef}{B|o{Q2M>@A+qrmT%VA@e zYi=tL6MyLx_;?5ckhUT4mfrkKs0*AE6H+^!xW_+ub}gA<4j-BL@>uEMf$3Mzl}?<3 z7aEOs_GEGY7ckE_Z3@IwO}=|-YV1Yabr6HQHy&{G6^PxcDR+)<64=mB|4*yD=D*)X z2sqZW*w$Qr2dGPLDwfXn<(Yxo5#7L5_eX3x+2GdUVM{2uiGm0MaM()`SyErEIC2}Z zHMC^Qkm!$#o)hj+e3zF6k@D4~0@bhm($7DFs8oFEUGNrIkE#71yXpyic)VT^MNS3p zMe7QOCYK&s?MV&J`rX*%T$k+L%({;n{Fs8fC|FLx9SEvC@Bfi8 zvq!0jxBwB{ij>thq`O2Om>R~BeWu{F5?>YjlRHfpdJnoBc8HTTF= zkJk;GuyDfa*<}Vsg3#`woVFi$3SJjna9}Qa+;%GrY(XtJ5;Czpc@17yj7qI|odDZX zTMskDCWjepHuSxIXeL2mU|rn}(h$J~r4a7B)SGo61Tbkyo^3g#gYfgV(-05Cg_8h{ zLjW~hhD^fpWOQy>`kSS3#H+6Q)6c*E*!uOn2F1m;T>yV zPdqXNuV&PQ9?Y#-UK>#frXhHyicV?Q^MP&EmVKB&19s?LT$QxvQdU>k)n-BhxDFp1 zpWcmIYkbapCLm%p^PGNbs5l5!A#PY6h5~}f&){X;!NI-A;$_Tv4Ej?dedj)%I`T#3 z%*tP}JOfGq$IkG!XeYOt@4u09^7~~9c z9Df-WYd}>fEnOv!fJ^bz7w#;ILvO+u$mgQ^`U<(N#(J_tZ7ZZxM*l3@g?*bDw0O&& z#J#~Mo?Q0Ceap7rRkmRF0{`i^>d#UPqTYW;G9Iy%T*la{2DSW-99^VS>vghO8}LKT z!Ai_%Qu!iy)1OeH!)82b@mk+m)wRlwcPyRQmNdu}O z>YlDW>%w34PReY;-REAAp)w&+n;h%8Tfdy;-XpZ6wCV*4;65hzPSR7obh}ztaiNeU z)rel$1P6SPS3{84f5E_8i2M+ZjMhUOfpLhAd4&_4W|@pbQMp-e#5?7JK!NEXKM(xL zikJ6bDWu1#(kT#`anJqrc)_-mHGZxOmx!$%wiJ#K@aCQc!b46HIL+w}CN4zmgYPm< zb{XWzH{u-?1VSO6z-g<8O>hHoxj=$N^;QMGP3}8g#!s=!9Q2|?dV1HD;=W;lufRfJ zs&m>-U3nX~4(X6~Fm_`6B2=goUkr#>fDr5Aiv0PW&q(fgnL=!GJ18k{OA#_JSIqEAKpE zm)5T4zo7joT212T62c?`Hh`@5TS(EmY1?=u^BM~R^3|8EUaNH&(tPSF~N6&PM4mD{m=D+gHPuY z;!ZUeSkwl}<_Bme54kf~+3}0tg;immaN5ETEK5!$J3GbAI)2qs7dMmVy45=AE*!t@ z?A+Dl*EiWeW2X2=A7EG2HfM7spwDss34zY{yxO5}rGF}r>&(KtEZwbl_9fG7M;bOw z+WVHTV5BoTL^V0aqvzrzi9l z^N>>O;5nb}UY*bNq;dOnZP%kui95m1QZ>$!a1Lb!y>dy83wDp=m5lOvn4))4(IE<6 zrQkgZh*#%K1mCzEBPy_nvn!la39f+E1J~GA;wbd!_)jPv591DKyjwpw}m7j$X{10vp+FmUnk=R3XkpEq-lm8$`!vE&^hdJxy OMY2@x%a6Wd5Bz@#`iOJ@ delta 5049 zcmb7IYiwM_6~1#{d-uKTeR#dL*J~$nvVOz~c>+}&NFWVDX(2qyC9S*hTstOvcg^f| z64JXY#cq=zoKg+65>1QKB7rLfL{n9zN`)%5QhxybQ&sI&sw$N@LeK(2>VvjP&zXJ1 zri7Nensd*bGiT<^nVIv=-M1bm-!-eHV9+PQ^VqJZC#P?eq(5P0`4flAPAKKy5J{pE zB?ZZ>zllkpE*E55F%?@iRa-MPA{{qCs(@5Cbt2s>6~n4zc^=9U34cOo)OGhs#VR?9 zBDLVPeWp*SwSwOcm;pOz2JMg;5;7h1VKXdvPa$GQ&8XmwLYo~kV}kb<;&#GJ*hw>K zx0~&D%1jBluh3zq&9vQVcG_KLm)&i4+Zi)s_n19)wrXbWUbENkGy8~Skq1^mSxx;k zFfW5~&|E`9RzD3xiCC)D-yzYc6`e3>8;#AW=GvAvYH3!qLz>WOoF+iCuBAzWZIlwQ zX{RaBRxf|g(@TbV%$PU&ph-?9ED+658 zoiZCl8*SOZ*P*p0{5CyB>nOHe?g*-5Gt)HhSSuzuD)HBSDYEtaCEs>JuIJgnwspdP z;g!r*qamRm>WZxc@jbj6*sG)4Y!iPyux|6!Flh=F;#G;Y#QSw``}d3TG1IF z2o84K4vj>Be40vpL?s~-2g-JyF9z=<9arNiI9x7v6OaS^ccJboay4(EV2{})Y8BYc zsNRKSHnO9E1 zad#A|IjW;M+Jp?JMm-@-5UN+HXD(1NuAw%l7qs4MXnoWVTHiIa0U89Y{~Fp54TCn| zRCPz0C6%BXT=39{Yn%no1s`?2Exyh5wfLCpZ}D+A(BKWnOCwF5MrgFfM`>G&hYZr< zV>FI@6$UeC+-VNrKPy#2uHve$=IS(YMy-V1&_cispCy%u8!7+T2`+?a@+^T=)xKYH zBMV`gq8(@DN>uP^!MC|l_Di>|{43GAv)LLJD6$ZRCF+9qF;Tk%tKCqI(~P6R3TYE6 z?V;H@wUTfXZWLmZgm)Xf+nqKy>BeSpZQI;-H!ir~LX7q{2gvo!{Pz6O=Ka ztYcGUXRLHMr_{9i?rQCEJZx~7STDa6@8)yqI8P-Yr}LpiW;^w`@&np}epZ^oEalzz zQJZp6{Vyp$oyb*hh26+*00KFo4f!r0HGQghq;%L~n^3X?C7$t8(Sc-uj?X*xhK-*TP{Bm-1bPtZ)m`n_LoDBOM zlGaSveONV-+>hi7Nb25lx4ozHNvOXc0V4dm;!}i2kRy)Y%+JNw@jte2_I?r6IT&>h z?@nER@Y7Ihj*3@-PdF-A)MBj|(L6gmR)D-!{tk9E4&?Za^?_5vAP zbRj=(pK}64`mcj2GDE$cJVRxe9Bd5pnnSQdDpUmtchxx0Soe|-- z*!;rx3@l`3_z>8a4*&_Z%W~uH@b8SC31)oo3mAa_Y5f#_xM!0`@sW_s9_06~-Bt}0 zOGm82SaHTaXtA2VkS{yqhw{^qw#L2CXY=U*2Ja^V2;Xi3p}q_V;dr|WRj4|zp2AI~ zEU1v(HAuT&vBkWtwsoBs&Vu%>szg2CR-louWditA@waM#Pyk`g?f^n6K}T}~kgG%R z4#GQ3{fg6FKn4!FR%;aLEXqU?q%QDG55i)zaRKn&2btzk0b z0_TRnBnBoSKoUdD4PGW4)4cQ|v9r=cZhrKqbHHp9P2%5`RMiaS@Uj zL@e+xRgh>OECMuiQ~OaIZb(;MmF8B@oL2Y;xu;SW zpMCDqPkxXc-jQ88_r0ZWobYAEvxR2|cag1}4xZln&V`dp=bpXz?A+4Rk1l=lM@whE z|He;Wx%A}o?_7BN;@4hWT6lSB{)IQsJhJrCqZhyY^wO!ti!VL(&V^I_57Ex^!J#?A zL+f`8?uXqr`P!@aygiyz>j;$rp}3}-e#f$>5u#?8#qS?^ko5A`M!Ms8?8J7%TN!~& zCIkFj9!W=Cr4F|Mw#q)pT}Szr4L4TXvA>T^RWR~VY;+6>o-+1%B+C#8s@tG3_Sgq4r)P2i7Y=R z?hwbEaSgQ1#{&!|M#Ss#P9Rr9-Y&T+6wC!OoD=P&v>>}0-0XB$FLyd=NDEYKHLAkZ zPH!!WwvV6Nl4F!0E3|sVZl_tA}m@xEUpTH z(JHzKj1GbExSn#CvobE8WpQDTLQn7|hoND!NN_eL25H&}K#Uqbvs?JfTY8%TN@K&1 z-`U281N0TN`6`l+k5cd`n}VxhBStpH2=}XMK#UR)B(d55>GR-57n}V$K0LC9U)Z{* z8exd9tbhbVw+uEBUf4b)CX$^*aE{QYQB$6Ef(_U!22pvTe6EE)id8($(I8%i(6Y&#w z4-u;p*;*i@1td??mlQNFGFjgD$%# diff --git a/__pycache__/prompt_definitions.cpython-310.pyc b/__pycache__/prompt_definitions.cpython-310.pyc index 3e98f89f20ffd35837428d4b5604ae519b8cef80..44af9b959844b16765b425e8480bcb518f4c74af 100644 GIT binary patch delta 74 zcmZ3jxlWTepO=@50SHVD=cETuwdqd bb8|m8C#NtI3mXeR3l|Xb10fHKAcp_|o>&$3 delta 72 zcmZ3dxmuGqpO=@50SKz(W~Iwam7gCXPJ|Uaa5ptYg~deN30x8Rt%(#Px0S Z6mE7-VFng97Je2kAmj%^9u`3k0RUem6jlHL diff --git a/__pycache__/ui.cpython-310.pyc b/__pycache__/ui.cpython-310.pyc index f3470fdbffe9c097647765e1a1f1afa4b76a6ea5..af2051ec73ac7222b967bcd9c1506bb51982b46d 100644 GIT binary patch literal 12543 zcmb7KYj9lUdERsO?ER{XZDEYPU;|z|78poFR0&!5gD-j(#HWF}3MVW!aMeZR9u zdy!hA)p^hPZtr(F@Atfy?-U}DpbLNhee>Cod;Z+z`ZXn*|J+F2j;r`6i0;yroGT^o zN=ijq%_-yVlzUuDY2%)hXWX0eDze;{@}bG#*Zc`Pq|;j7L+^ z@mMN09#6$N&ztKS?@o0quDe~jPxl{o>Ha6wRFAGa;7SITF$ULQQnTEqdC&?DXLE+0 z&EyIrR=7AM3OOWA7hb?I6Gp6ikU|YJCRhZfG|)- zjFN6^^I+i-BbUxI)w9AFG7DlVJ!0eykueSH^2SqoXf!in8X`TJHAmA$lM2^m$0rKH zOdre_CNYi?LvmHDuAxz5=pc1j%oa@}KQuK&(`f#47P*XE!i2v)nkXXR8WuWUC0gT-Fgr1VZB#hhkHa{ zuW!ITs`u%a;U3dB>X+jl*EeB-udup!3GS9NE{MAyymv@#8YvA_!*wUF;wg}dQgNkS z71x-ns_4p`f_JDQM>XBZQ=gzx&Gd|UD+*0xR-INW>X?tFqWfl5#btV`{)#$B^#Ujf zqE$fm>j7CdhB7^l2Otq>}PYWOO~7A6ZsVEg7}E;~BhfVR_7~nKLZATNVr9MHMST zf@w5FQ_wk!hW=1CuNNjQZ6Y&t(DK|RGUJ97%o)RGdYGTx=*bER8ik$Hb)b~Z z>1nQ$%S;tYrqwO8WG$HoA=ipVZdj~CU)YtIY(9;d8S^f26>>iB9~yZ0!J;9G4kcbXcT@?x<4PQt z-yK%V8(SIRo%A;v57B}({%DPTxLCx#zyk@_7=%k#bvKEHomN}Yyjx_s=^oRlj*cAz zfm15Vm$t}Uzk@c zjbTsA7mdkf4~ml>%Pk;4-l0OSG@iG-MKe2eaEb&>#oeguAStePi(#)!LIsYUe*lX$Nv8K&IGDRc-)D1&0g57!ie1UQY(a zCS+M*3_RGf-Z$NqjMzpnc z?#QKgeGR?qX|1B2Qp9emS?$C4E~{uQypQ;?ci}@~iCDa+eP@mHsJ8w>qUHca2hs54YI!+J$-fZN9#-%h8KOgl$bT*{1Kb z-@vYpnMiJbW^=pMUZu6=^lFUjYRu=>R!;hD(_Zu_*X4H9xrW9LxxN<9*TVKAB$In> zkABB2`~#=Fab8q5X#?*kgn)0@TSc;MKG%3ojQNb=Eq+l#xW+IzD zXiQly*p;3~GSHXH2^7E~36m;&BqOq!1380LsHIGn19xPKM&iNm-ZxELL$c2F#9uQ=zSs`;JYjK$W>bHIF?!RGp1jYPkKm6vlq?Ymwi^6!?QsEEL z>RA59=u_&XF=RL38`&zP|2E(%k`;gzm%2%TGz2A0_~2o7HyZG^W7+Wi5AxS&e zs2#5^ef+Vk(KMIZ$@d22aM^6EpEz1u_-J4mV{0Po=`9tMwqp^#ifG+ur3Cog(BM;>eGY50y>{vsORxQC>8W=X zkH1@=c`R8XnZL0=aq0b;rK1<>Gq2Uo&(*7QC6Z+ft9Inv;w$Ht7M`qq_)N0j@`y}+ z#1Ld5ikpahod|C+4_m9^8_2+BIA{ePFal=J~JZ^fE|EHa~Ehf}w-V`C)@FFjJq>a;Im)%ReG99T&YmvVO4#p(hx zC*r~d);gzNgC#fbZK-J&S-pCpIn;tpgy4&{j_P*AVXVH6n9V4*05I8N=Iy0PALERr zWK%&#mLk0&@fTQ-R7AcB-5AcmFG+!6#I^~y%I7tVE5O~7GU4~NCOX6XSO?h5B4;sO# z=d<(R137tqI}f(4lNYe_5G!)>f_5HzM|QsxJmx6Z zG?m$1?V*qwwP4NFJt+PRa~HP~p`{S}h}=cwZXyp7d58$Xw@4BBDyZE(^dfuuEA0)K!9yhFqv=jg85cej1l?7N5JlO{qtb86Mk zH3N1@(7uQ4dzkLJdNwevm=TI^jzynP#cgJ+8mAbhlo`l|$B~U#wVQ%Vj%@e`#Sx4u z8IU5YQ!XZnRn6@f-^=wisKl|gQg@qDD z_nI|#?bWxJE}SFz=wYc?RUqi)ue@~2Q-o@p5UYs^?S3g@Bo8S+$wM}e_|H?63{Vn! zq97W=(TIG)B+Il5$sshkfQyC1ulg1Gbu*<-B26b}hZbf5ufLi?$c9qP(ti?7)JKfi@QVw`6P~!0IwN ztlF^k=hZ=Sg!r%omgIe?#G|tOd+&W<&mH&e+dJRD5-97oQCU$vTO@BOeSmF}e?jZt z(&}J%iO%7*R$g*`n$Bi}TPf2A7Ffvom^TWYq5 zCaqNC`t3F(IGRMqJ$_&gbSa&d%qkRls;Ydlp}|-3l*sau_Hym5lS>O1+N|aw6gz)} zk7R6<3tQX^uk0AG7`_;beEHe(2%gy^t7>zuX)1w+o`WDEMUh14W;=zL*%-7k(9=H# zUCT8n<^^qQ*Puuy^ekFZL?O&iUOYvd8a||=obRvrir-@_MI4mG_aXc+Hj2tQ+Y#w* zAT2Bhv!_C5Olrkl(JG#bx8ke#5poYG)CQjqHrIOK<+H_QT^0*h>rV)s7rno_VVN z^t{Afsgb4cRiam1`)H>2+S8qHulA!8(&r&xtVv-Yq@5d2v!B3r0&9#^xAgJSh2a(N za7BUKg?>t8WBlsukwmkIIGx!2pFaVg3+kZ$%DKg}Ps^n4MOqkh19%m11m)%Ka`-rTmZ)v&2EkxKLcOVd_DZu}KM?vu( zkT5Wu&Ih8fUZP46KcbpS`SI@HZ&ao8=ly*&6je4U{u%X4^~<=Xez93C-`ELMj%Tyt z0jFBp$bc%$d3g>ZPGh`iz(o?ecxqc|k`<7D z!&UTxgw^oYuu{JID-CH)@DeYA6@ZB>z1pj%>u;Wu0w%*5pM7+C@#2O0@mFg{eqMWf zrvAehmwxux&Su9PyQDt%)6YISQ9Jd+<)bI-Kb={;@ROxaj?|9NQ|1TnE?#`5cIuei zAeTO!t3S3tqWtX7>&M=c5d}zO3+%-)5(<(q?ERq~_WrZ{4}h6J3ibeM1a@#in!pau zhYfZNf(U7#R$^^X9eXs!P>s6-)h%;eZPrz%2B11N___R;p&8i9JuL8Kx5JQtG8TCX6IdS)+MN&ut8(oPzCH{q&61cc%zgAXNvyuDQtV3OKzw7nG5yj-%64`30cuRf6QWId(m1a(~>%4X@g8F zY}ev|B0H??zv4?8EL0=X5^>@=^jQUaz9sSuNcqrE6U{?yQ#_48PN@r(q{eyg9XW7X zzwN}PvHW!EY&T~&MtZ<<1*kIlpDPMULXRwM)e@n>~SDDN%LDD0R5h2f6kcTRM43dnuEwp%zirD!}(GzJT ztEcsq@%JLBjjO&lF0;Ct#aDh_a*nwDjL6T4%oE>mW6>#kwwm5!?ST#JDvqK4irSM3 z4-}E4sABtQDmnD=tNj1L_bt7by46lKD095O)1rNJ(2ieF)n=lIJ+We(iMv@betnRH z^zNpQj`|(N*gWV~)hNzjl!M|Wn&2D}R^Ti8?os4@iL0US2B7b*{fa&PzZ!2Z7+?DM zQ>daEhm!YTz-DdXgZkMSY(6Q3OP`)unme(0eg;u#C-MyGtv&h9;_Ht=*}?p3QC=}H zB>`gPHPNoTUN27rSpP`nMe1TjflX^>0CqdToE4SFNs*%RIw?|AJ|~5U2b5U2;(?k9 z;O=Gh6=pRSLYmZAxDsZ?GzzH4CoxTm3AT8PT_2G)yBVtlSp9@;1%y-1S^qStsfg~U z_ViR~RvPiCm!UMGw$g~`fl9PZX+$55veLlaQ5r4%v(jkEK`%}kN+UQZeu@`R?t$1L z)J~uHvBOFbRqh_i6%J%_>0${dC1Q&5#H*-Xx&y?9tLCl9xOVCN4;SY@1kl${KaRj% z?c{=d@r$PqODQ4HKKlj%8J!?VH^Gq>DO?2Qek;;~3;3L-b%<89b$DU{?Aq~$07mK+ zmRgus^_S1WPUDCXfvD`T892Kna6U%dhKa0Zz%ypDiT*lO`2~?Th_s<)nDQ73W9>s@ z6r}NqH$hUpZPSx5*wZ>k33lhH(OX2`Ch`t7Z^cADFYwAwhSo&B+X?vyK30&A41I}u z4(cFkAo zr~}kv72De}<+73SWr}hAxunGu15I20$*WhLcA6$>R7@Tmo!AYqG z8l#~2BfzOGr#TI9@(h-)!007Jth%u9x7{oIO0EaK@5Gre=|(opS{GPysWsvO{Nfk} zS`S5=@gOMi;0Z94W)MZ0Rz(Kd=j^B_72b67YAWJHPU%&(o!YbgQ6iD1(Xx9D*E)=! zs(Ak=>gjN(Z|)TL_9MHreW176-a>$sggzC6pIw+R@SQ;pF}ra?yi5asg-Dgi91%7y zF3{6fB9DXQX{Z$J)L{B}74H|ChHqYD82D%99bbKDgUK%Xj+S~(r>$U`f<7fgI@4A- zo&J6)lanQ48}++|NP-CY+=8O#ViE-BJf@KE<5|jM+mt+hL1vJcAwpwrif8hScrxTL zlObN*1mZ`~+JBjUga2>*z5ec~*YEMW@!RgdEquM8nmA)3khgs1!E7EHQd++%vUoY= z=`=@bXdP2g`y><}7vPJ0A}zJlPejv!-5kbI?jz@_o| nygbMSE7m47q}66+`EQX6NgyfT6Pd1kI8=xuc7vl-Pq+HNggPR& delta 5103 zcma)AeQaCR6@T~n*?zX4zw_OsalYERrES^*g_aM6hC)XPtgR?@DZ%l5N$fgyxqhZi z^is2ymJeY{cUS0Wk(`Y`)XNa|hNSg$jrZF^$opYb_?4*sh zSnv1l$GPX;d+zU?b6>tsp4t=0`~6-C{$_W)F>&SYvw==Bb9UXKP9jl>y0W3Ea3V~k z2PEpI>T?oRPs)kN*%3dkHf(PaANrDF*^U)}1On9b}>D=g% zWKO>%u^9I$caz|IyObnxMWCC7`4#8n&}uBML5KqsHGu0E-}&Tw6#h$4dk3GaBG=@Tqp%+a`)b?^hO^pHU$>b43XI7QPaIk7ldOQ^`^-|xQOtmtM9rZ!4h9gY=M|U^i-|KQ=8nNa4b-B2Ta=TPpUo&m7 zh1RCGmB(tz-L;L>bvM^^_t0KjcZ;EvYPOX!Ewrzu+`WwQ{+jZEWtFd@ zt8L}24GV9zbq_4jUBaVBv#hBpkKdqt!76JTNYU1yyv?>se2H=;(=K$c1MHx;2+PH3 zQ={cdhEiF9LUUx+VWMgKKuM6UFFVAUwVMvQ0s1>@!)39&jSann(A#DA;<1Qcx@lIi zk7cv9Ia7hrYE8F0(+o3}ERwHm*w0EjU>yXAFFj>zKU-kRZk!Sb8G~{x<3ib13DjG= zyOrKncGWh3*1a91dTJwjq1-D*G_^{lWru79vA)`nekk{gA#FEKLM4b=cZ|$q%wYr} zjDW#uMjd)|SL`8udK>G*wp9RCqQ{+VAzt|r84Q7! zU%!6t%C>nI|6OpkbKVUdd_K71KtB|!?o@tiDw(5f6Ar>XT>bRTYnRW@pDKU(@w*T) z7glO5R7hNVsq)pwA6pXh9vp|UA7=XMr4!fAe6WpAhx&W$aS3lKpDj-13J}57P3Wu>RItqmTM%S`s&jX8OwY30uq+#gtDe0EOXnsYOET7iZH@`u;CsWH z-Aq{IcK%WL7-{FXMz(S@@*^+U86$d?r1^u+@1TYwaq^}ahnvm8g%8B+$i26|%lFEPpN&_^AJ%VJR5w;yx z!0tjALcoN>9zb{y0Z*Sjgs>Z71druA$EFQ~Y$re7yr&dpE^Kc`*bh*36m)|j3FEZsJf`0)gc@1!X_SbZP}S z)0CmpQJQ88=d_@cg5YJozy0|;w?avrcEiqqJ9TEl0E8tI%gDs4 zYvhqV4-f5T$FU`51FoU!*K-Y8KElpt`S6N!JxDh6jwn2ZJBTE`AXVVOJV>OOS7ws@ zo7f;<+j&pGbQgXo*aX+NIhlXj)Al3~NI^LxVoE?c*>sqS=`>xYn>x!XL0v#Gl-Muj zIuyx>PM;vsEcDC-M4#JKFGz2Sps`;%0M0$D9#?Y#>&<`OyH!C_@-1Blw>Pj7Zl>9( z(WyMRVV#si2JD*vpDmzr1!cb!H+m$&Fz#8Tbm7jyQeWJQWH1K|V$ z9xg*8Rz3G0OX)|9bUqjNEb<@_*eTrsSdUM2584JpFA)(UtTPkGr5MS=;om}6VVK9d zTVsl>5ru@p-fv{G^ym6E;N=Iq4-F?=`zD#5r08l%W?XVuT2JHRT!!efrZ@#Hma|Mk z=@Xm=!=WZ?J^Vl2JJgd@FEzi0T^L zLflp0f%N)!*9AEgLvTWhPM~qD!hHBr=rYBg)tyWz@pfGpb*r83)I9s zV0wjAQAmlnCZtMB#6-8tbXsBu8{W#d9Ni8lVS?; z8PQA=hD%Xz#+s?w^%l_!TrcIELWR~q{MPBufcX9z@dMOv`j-&jf7~yK5AUPo=uLKe zhx4i@Nw_9~NOsh67AB%Nva#yR zCJRRG`c%Y-{Kdsnu`l0wr#Q%;=q}I*FO05^$YJ`f9=X&pFJUR1wYrnZ?Mhs ztiw3ZvYs`8y&~!+`O1Oz5#cw&gE0Cqa1V(wgvCjf9mBTg0OEnAPW5B#vNpia3_O^9 z73*gaI6zff_+~ZqV+KP%V}kfGETNY%^o;B96mVVWU;**-0>b0Knw3CQ8FDiTb*Wdy zyCd=ye{@x|@-Ib;@bs#^gU`eG5%wY=dl?~yQ1`M7RIkFb;AOY+zpc8ToaCEVAM8Po zvg0pZ>QQga90iZs%s*f4Dj6z%TEb@tdX-|=(5sww4ZX@`*C6YI$N5YJd`gA4Q}~)s z_?#E&=yN{PC%h;HY|?1mi=>Lb(brx8uhOhoZUjtK_>a%>AnGo=XEiZgMap9d{GuN9 zgVX05$gfe)|3&^q)9;u1K<%QY#yYUyJHlS!(fE3Dh7ZLjiJSj2zA5xmJctGd;9ti# zmqHd_r?78Xz6z@T1VzpvyoP|E6IQ?%#Kl0k3^fHSUx)rg6R?ma;bOr?Au1dC=s16S zZTrFVIQ9*M3kY~&Nrce@7joAqvbO4CdZCzIp1s{bm;zpL>+B8io^@@l5%1SG5F$`URp=QfBVs=S5q6SrT1nbTw!CkFgZB>7uM%8e@r8-py{8njqGEIVueLTwW zT&iy4a5@KewIYU$8XX7sF9(B?XpOh!i8%`aHn85vbK7qD+& ZWYnFk$l1ip#K9=UB*eqVEyN+k0RV#p59$B_ delta 70 zcmdlWyg`^ZpO=@50SKDqW~EQr$Xn0E$g;VY=>sF9z~(xZOh!iO%`aHn85xx~7qD+& ZWYnIl$l1ipz`-cQB*eqVEyN+k0RVpV56=Jq diff --git a/config_manager.py b/config_manager.py index 892fa90..c3b45f3 100644 --- a/config_manager.py +++ b/config_manager.py @@ -1,3 +1,5 @@ +# config_manager.py +# -*- coding: utf-8 -*- import json import os diff --git a/consistency_checker.py b/consistency_checker.py index 2cf6afe..cf34349 100644 --- a/consistency_checker.py +++ b/consistency_checker.py @@ -1,7 +1,5 @@ -""" -演示多Agent思路中的“审校Agent”,对最新章节进行简单的一致性或逻辑冲突检查。 -可根据需要进行扩展。 -""" +# consistency_checker.py +# -*- coding: utf-8 -*- from langchain_openai import ChatOpenAI CONSISTENCY_PROMPT = """\ @@ -28,7 +26,8 @@ def check_consistency( chapter_text: str, api_key: str, base_url: str, - model_name: str + model_name: str, + temperature: float = 0.3 ) -> str: """ 调用模型做简单的一致性检查。可扩展更多提示或校验规则。 @@ -43,9 +42,16 @@ def check_consistency( model=model_name, api_key=api_key, base_url=base_url, - temperature=0.3 + temperature=temperature ) + # 调试日志 + print("\n[ConsistencyChecker] Prompt >>>", prompt) + response = model.invoke(prompt) if not response: return "审校Agent无回复" + + # 调试日志 + print("[ConsistencyChecker] Response <<<", response.content.strip()) + return response.content.strip() diff --git a/main.py b/main.py index 2639413..52e3da3 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +# main.py +# -*- coding: utf-8 -*- import tkinter as tk from ui import NovelGeneratorGUI diff --git a/main.spec b/main.spec index bfd34cb..57e1776 100644 --- a/main.spec +++ b/main.spec @@ -6,7 +6,19 @@ a = Analysis( pathex=[], binaries=[], datas=[], - hiddenimports=['typing_extensions', 'langchain-openai', 'langgraph', 'openai', 'chromadb','langchain-community','pydantic','pydantic.deprecated.decorator'], + hiddenimports=['typing_extensions', + 'langchain-openai', + 'langgraph', + 'openai', + 'chromadb', + 'nltk', + 'sentence_transformers', + 'scikit-learn', + 'langchain-community', + 'pydantic', + 'pydantic.deprecated.decorator', + 'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2' + ], hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -21,7 +33,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.0', + name='AI_NovelGenerator_V1.1', debug=False, bootloader_ignore_signals=False, strip=False, @@ -41,5 +53,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.0', + name='AI_NovelGenerator_V1.1', ) diff --git a/novel_generator.py b/novel_generator.py index 2ac9aee..35d07b6 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -1,5 +1,8 @@ +# novel_generator.py +# -*- coding: utf-8 -*- import os import logging +import re from typing import Dict, List, Optional try: from typing import TypedDict # Python 3.8+ 直接可用;若是3.7可改用 typing_extensions @@ -12,6 +15,12 @@ from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain.docstore.document import Document +# +import nltk +import math +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity + from utils import ( read_file, append_text_to_file, clear_file_content, save_string_to_txt @@ -23,7 +32,7 @@ from prompt_definitions import ( chapter_outline_prompt, chapter_write_prompt ) -# ============ 日志配置(可选) ============ +# ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") # ============ 向量检索相关函数(Chroma) ============ @@ -37,7 +46,7 @@ def init_vector_store(api_key: str, base_url: str, texts: List[str]) -> Chroma: """ embeddings = OpenAIEmbeddings( openai_api_key=api_key, - openai_api_base=base_url # <-- 这里用传进来的 base_url + openai_api_base=base_url ) documents = [Document(page_content=t) for t in texts] vectorstore = Chroma.from_documents( @@ -48,18 +57,16 @@ def init_vector_store(api_key: str, base_url: str, texts: List[str]) -> Chroma: vectorstore.persist() return vectorstore - def load_vector_store(api_key: str, base_url: str) -> Optional[Chroma]: """读取已存在的向量库。若不存在则返回 None。""" if not os.path.exists(VECTOR_STORE_DIR): return None embeddings = OpenAIEmbeddings( openai_api_key=api_key, - openai_api_base=base_url # <-- 使用 base_url + openai_api_base=base_url ) return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) - def update_vector_store(api_key: str, base_url: str, new_chapter: str) -> None: """将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。""" store = load_vector_store(api_key, base_url) @@ -72,7 +79,6 @@ def update_vector_store(api_key: str, base_url: str, new_chapter: str) -> None: store.add_documents([new_doc]) store.persist() - def get_relevant_context_from_vector_store(api_key: str, base_url: str, query: str, k: int = 2) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 @@ -86,7 +92,6 @@ def get_relevant_context_from_vector_store(api_key: str, base_url: str, query: s combined = "\n".join([d.page_content for d in docs]) return combined - # ============ 多步生成:设置 & 目录 ============ class OverallState(TypedDict): @@ -100,7 +105,6 @@ class OverallState(TypedDict): final_novel_setting: str novel_directory: str - def Novel_novel_directory_generate( api_key: str, base_url: str, @@ -109,7 +113,8 @@ def Novel_novel_directory_generate( genre: str, number_of_chapters: int, word_number: int, - filepath: str + filepath: str, + temperature: float = 0.7 ) -> None: """ 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 @@ -122,6 +127,7 @@ def Novel_novel_directory_generate( :param number_of_chapters: 章节数 :param word_number: 单章目标字数 :param filepath: 存放生成文件的目录路径 + :param temperature: 生成温度 """ # 确保文件夹存在 os.makedirs(filepath, exist_ok=True) @@ -129,9 +135,15 @@ def Novel_novel_directory_generate( model = ChatOpenAI( model=llm_model, api_key=api_key, - base_url=base_url + base_url=base_url, + temperature=temperature ) + def debug_log(prompt: str, response_content: str): + """在控制台打印或记录下每次Prompt与Response,[调试]""" + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response <<<] {response_content}\n") + def generate_base_setting(state: OverallState) -> Dict[str, str]: prompt = set_prompt.format( topic=state["topic"], @@ -143,6 +155,7 @@ def Novel_novel_directory_generate( if not response: logging.warning("generate_base_setting: No response.") return {"novel_setting_base": ""} + debug_log(prompt, response.content) return {"novel_setting_base": response.content.strip()} def generate_character_setting(state: OverallState) -> Dict[str, str]: @@ -153,6 +166,7 @@ def Novel_novel_directory_generate( if not response: logging.warning("generate_character_setting: No response.") return {"character_setting": ""} + debug_log(prompt, response.content) return {"character_setting": response.content.strip()} def generate_dark_lines(state: OverallState) -> Dict[str, str]: @@ -163,6 +177,7 @@ def Novel_novel_directory_generate( if not response: logging.warning("generate_dark_lines: No response.") return {"dark_lines": ""} + debug_log(prompt, response.content) return {"dark_lines": response.content.strip()} def finalize_novel_setting(state: OverallState) -> Dict[str, str]: @@ -175,6 +190,7 @@ def Novel_novel_directory_generate( if not response: logging.warning("finalize_novel_setting: No response.") return {"final_novel_setting": ""} + debug_log(prompt, response.content) return {"final_novel_setting": response.content.strip()} def generate_novel_directory(state: OverallState) -> Dict[str, str]: @@ -186,6 +202,7 @@ def Novel_novel_directory_generate( if not response: logging.warning("generate_novel_directory: No response.") return {"novel_directory": ""} + debug_log(prompt, response.content) return {"novel_directory": response.content.strip()} # 构建状态图 @@ -196,7 +213,6 @@ def Novel_novel_directory_generate( graph.add_node("finalize_novel_setting", finalize_novel_setting) graph.add_node("generate_novel_directory", generate_novel_directory) - # 注意修正此处节点名称 graph.add_edge(START, "generate_base_setting") graph.add_edge("generate_base_setting", "generate_character_setting") graph.add_edge("generate_character_setting", "generate_dark_lines") @@ -229,22 +245,132 @@ def Novel_novel_directory_generate( filename_set = os.path.join(filepath, "Novel_setting.txt") filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") - # 清理文本(去除多余 # 或 * 等) + # 清理文本(可根据需要去除多余字符) def clean_text(txt: str) -> str: return txt.replace('#', '').replace('*', '') final_novel_setting_cleaned = clean_text(final_novel_setting) final_novel_directory_cleaned = clean_text(final_novel_directory) - # 以追加方式保存;如果希望覆盖可改为 save_string_to_txt() append_text_to_file(final_novel_setting_cleaned, filename_set) append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) logging.info("Novel settings and directory generated successfully.") - # ============ 生成章节(每章独立文件) ============ +CHINESE_NUM_MAP = { + '零': 0, '○': 0, '〇': 0, + '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, + '六': 6, '七': 7, '八': 8, '九': 9, + '十': 10, '百': 100, '千': 1000, '万': 10000 +} + +def chinese_to_arabic(chinese_str: str) -> int: + """ + 只能处理到万(10000)以内的中文数字,正常小说章节应该够用了 + """ + total = 0 + current_unit = 1 # 记录当前单位 + tmp_val = 0 # 暂存本轮数字 + + for char in reversed(chinese_str): + if char in CHINESE_NUM_MAP: + val = CHINESE_NUM_MAP[char] + if val >= 10: + if val > current_unit: + # 如 100, 1000, 10000 + current_unit = val + else: + # 比如 “十二” -> 2 * 10 + 1 + # 如果 val <= current_unit, 那么相当于在这个单位下加 + total += tmp_val * val + tmp_val = 0 + else: + # 0~9 + tmp_val = tmp_val + val * current_unit + else: + # 非中文数字字符,视情况决定怎么处理,这里直接跳过 + pass + + total += tmp_val + return total + +def parse_chapter_title_from_directory(novel_directory_text: str, + novel_number: int, + range_size: int = 1) -> str: + """ + 从小说目录文本中,提取指定章节(以及前后几章)的目录信息。 + range_size=1,表示获取当前章节、前一章和后一章的目录信息(若存在)。 + 支持多种常见的章节格式。 + """ + + lines = novel_directory_text.splitlines() + + # 可以根据需求自行扩展,这里列举了几种常见的章节标题格式,如果模型实在不听话,可以适当调整 + # 每个pattern都应该捕获两个组: + # 1. chapter_num_str:章节数字(可能是中文也可能是阿拉伯数字) + # 2. chapter_title :章节标题(.*) + patterns = [ + # 1) 第12章 标题 + r"^第\s*([\d]+)\s*章[::]?\s*(.*)$", + # 2) 第十二章 标题(中文数字) + r"^第\s*([零○〇一二三四五六七八九十百千万]+)\s*章[::]?\s*(.*)$", + # 3) Chapter 12 标题 + r"^Chapter\s+(\d+)\s*[::]?\s*(.*)$", + # 4) Ch 12 标题 + r"^Ch\s+(\d+)\s*[::]?\s*(.*)$", + # 5) 第12节 标题 + r"^第\s*([\d]+)\s*节[::]?\s*(.*)$", + # 6) 第12话 标题 + r"^第\s*([\d]+)\s*话[::]?\s*(.*)$", + # ... 更多模式 ... + ] + + # 用来存储匹配结果: chapter_num -> title + directory_map = {} + + for line in lines: + line = line.strip() + if not line: + continue + + # 依次尝试每一种pattern + matched = False + for pat in patterns: + match = re.match(pat, line, flags=re.IGNORECASE) + if match: + chapter_num_str = match.group(1) + chapter_title = match.group(2).strip() + + # 如果是中文数字,需要转换 + # 如果是阿拉伯数字,直接转 int 即可 + if re.match(r"^[零○〇一二三四五六七八九十百千万]+$", chapter_num_str): + chapter_num = chinese_to_arabic(chapter_num_str) + else: + chapter_num = int(chapter_num_str) + + directory_map[chapter_num] = chapter_title + matched = True + break + + # 如果已经匹配到其中一个pattern,就不需要继续匹配剩余pattern + if matched: + continue + + # 收集需要的章节范围 + chapters_info = [] + for cnum in range(novel_number - range_size, novel_number + range_size + 1): + if cnum in directory_map: + if cnum == novel_number: + chapters_info.append(f"【当前】第{cnum}章:{directory_map[cnum]}") + else: + chapters_info.append(f"第{cnum}章:{directory_map[cnum]}") + + if chapters_info: + return "\n".join(chapters_info) + return "" + def generate_chapter_with_state( novel_settings: str, novel_novel_directory: str, @@ -254,19 +380,22 @@ def generate_chapter_with_state( novel_number: int, filepath: str, word_number: int, - lastchapter: str + lastchapter: str, + user_guidance: str = "", + temperature: float = 0.7 ) -> str: """ 多步流程: 1) 更新/创建全局摘要 2) 更新/生成角色状态文档 3) 向量检索获取往期上下文 - 4) 大纲 -> 正文 - 5) 写入 chapter_{novel_number}.txt, 更新 last_chapter.txt - 6) 更新向量库 + 4) 从Novel_directory.txt中获取当前(和前后几章)的目录信息 + 5) 大纲 -> 正文(可结合用户给出的额外指导) + 6) 写入 chapter_{novel_number}.txt, 更新 last_chapter.txt + 7) 更新向量库 :param novel_settings: 最终的作品设定(字符串) - :param novel_novel_directory: 小说目录信息(此处暂时未使用,可根据需求做扩展) + :param novel_novel_directory: 小说目录信息 :param api_key: OpenAI API Key :param base_url: OpenAI Base URL :param model_name: LLM 模型名称 @@ -274,6 +403,8 @@ def generate_chapter_with_state( :param filepath: 文件存放的目录 :param word_number: 单章目标字数 :param lastchapter: 上一章内容(若为空字符串,表示无上一章) + :param user_guidance: 用户对当前章节的额外指导或想法 + :param temperature: 生成温度 :return: 本章生成的正文内容 """ # 确保文件夹存在 @@ -283,9 +414,15 @@ def generate_chapter_with_state( model=model_name, api_key=api_key, base_url=base_url, - temperature=0.9 + temperature=temperature ) + # 调试输出函数 + def debug_log(prompt: str, response_content: str): + """在控制台打印或记录下每次的 Prompt 与 Response,便于观察生成过程。""" + logging.info(f"\n[Prompt >>>]\n{prompt}\n") + logging.info(f"[Response <<<]\n{response_content}\n") + # --- 文件路径定义 --- chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) @@ -308,6 +445,7 @@ def generate_chapter_with_state( if not response: logging.warning("update_global_summary: No response.") return old_summary + debug_log(prompt, response.content) return response.content.strip() if lastchapter.strip(): @@ -325,6 +463,7 @@ def generate_chapter_with_state( if not response: logging.warning("update_character_state: No response.") return old_state + debug_log(prompt, response.content) return response.content.strip() if lastchapter.strip(): @@ -334,53 +473,78 @@ def generate_chapter_with_state( # 3) 从向量库检索上下文 relevant_context = get_relevant_context_from_vector_store( - api_key, base_url, "回顾剧情", k=2 # <-- 多传一个 base_url + api_key, base_url, "回顾剧情", k=2 ) - # 4) 生成大纲 + # 4) 解析本章及前后章节目录信息 + this_and_related_chapters = parse_chapter_title_from_directory(novel_novel_directory, novel_number, range_size=1) + + # 5) 生成大纲 def outline_chapter( novel_setting: str, char_state: str, global_summary: str, chap_num: int, - extra_context: str + extra_context: str, + directory_hint: str, + user_guide: str ) -> str: - prompt = chapter_outline_prompt.format( + """ + 将目录提示以及用户额外指导内容一起放入 Prompt 中。 + """ + # 适度修改章节提纲提示词,以整合目录信息 & 用户指导 + outline_prompt = ( + chapter_outline_prompt + + "\n\n【目录参考】\n" + directory_hint + + "\n\n【用户指导】\n" + user_guide + ).format( novel_setting=novel_setting, character_state=char_state + "\n\n【历史上下文】\n" + extra_context, global_summary=global_summary, novel_number=chap_num ) - response = model.invoke(prompt) + + response = model.invoke(outline_prompt) if not response: logging.warning("outline_chapter: No response.") return "" + debug_log(outline_prompt, response.content) return response.content.strip() chap_outline = outline_chapter( - novel_settings, new_char_state, new_global_summary, novel_number, relevant_context + novel_settings, new_char_state, new_global_summary, novel_number, + relevant_context, this_and_related_chapters, user_guidance ) - # 5) 生成正文 + # 6) 生成正文 def write_chapter( novel_setting: str, char_state: str, global_summary: str, outline: str, wnum: int, - extra_context: str + extra_context: str, + directory_hint: str, + user_guide: str ) -> str: - prompt = chapter_write_prompt.format( + # 同理,整合目录信息和用户指导 + writing_prompt = ( + chapter_write_prompt + + "\n\n【目录参考】\n" + directory_hint + + "\n\n【用户指导】\n" + user_guide + ).format( novel_setting=novel_setting, character_state=char_state + "\n\n【历史上下文】\n" + extra_context, global_summary=global_summary, chapter_outline=outline, word_number=wnum ) - response = model.invoke(prompt) + + response = model.invoke(writing_prompt) if not response: logging.warning("write_chapter: No response.") return "" + debug_log(writing_prompt, response.content) return response.content.strip() chapter_content = write_chapter( @@ -389,7 +553,9 @@ def generate_chapter_with_state( new_global_summary, chap_outline, word_number, - relevant_context + relevant_context, + this_and_related_chapters, + user_guidance ) # 写入文件并更新记录 @@ -407,10 +573,117 @@ def generate_chapter_with_state( clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) - # 6) 更新向量检索库 + # 7) 更新向量检索库 update_vector_store(api_key, base_url, chapter_content) logging.info(f"Chapter {novel_number} generated successfully.") else: logging.warning(f"Chapter {novel_number} generation failed.") return chapter_content + +def import_knowledge_file(api_key: str, base_url: str, file_path: str) -> None: + """ + 将用户选定的文本文件导入到向量库,以便在写作时检索。 + 可以在UI中提供按钮来调用此函数。 + """ + + # 1. 检查文件路径是否有效 + if not os.path.exists(file_path): + logging.warning(f"知识库文件不存在: {file_path}") + return + + # 2. 读取文件内容 + content = read_file(file_path) + if not content.strip(): + logging.warning("知识库文件内容为空。") + return + + # 3. 对内容进行高级切分处理 + paragraphs = advanced_split_content(content) + + # 4. 加载或初始化向量存储 + store = load_vector_store(api_key, base_url) + if not store: + logging.info("Vector store does not exist. Initializing a new one for knowledge import...") + init_vector_store(api_key, base_url, paragraphs) + return + + # 5. 创建Document对象并更新到向量库 + docs = [Document(page_content=p) for p in paragraphs] + store.add_documents(docs) + store.persist() + logging.info("知识库文件已成功导入至向量库。") + + +def advanced_split_content(content: str, + similarity_threshold: float = 0.7, + max_length: int = 500) -> List[str]: + """ + 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 + + :param content: 原始文本内容 + :param similarity_threshold: 相邻句子合并的语义相似度阈值,小于此值则会开启新的段落 + :param max_length: 每个段落的最大长度(按字符数计算,超过则进一步拆分) + :return: 切分好的段落列表 + """ + + # 1. 按句子切分 + nltk.download('punkt', quiet=True) # 确保 punkt 数据可用 + sentences = nltk.sent_tokenize(content) + + if not sentences: + return [] + + # 2. 加载 SentenceTransformer 模型,用于计算语义相似度 + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + + # 3. 根据相邻句子的语义相似度合并段落 + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + # 语义相似则并入当前段落 + current_sentences.append(sentences[i]) + # 更新current_embedding为合并后的平均值(可选,也可只采用最后一句做比较) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + # 语义相似度不足,另起一个新段落 + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + + # 把最后一段加进去 + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + + # 4. 根据最大长度 max_length 做二次拆分,避免段落过长 + final_segments = [] + for para in merged_paragraphs: + # 如果段落长度超过max_length,进一步切分 + if len(para) > max_length: + sub_segments = split_by_length(para, max_length=max_length) + final_segments.extend(sub_segments) + else: + final_segments.append(para) + + # 返回最终段落列表 + return final_segments + + +def split_by_length(text: str, max_length: int = 500) -> List[str]: + """ + 将文本按照max_length进行拆分,以避免段落过长。 + 这里以字符数为单位进行简单的拆分,也可以改为按词数或token数等。 + """ + segments = [] + start_idx = 0 + while start_idx < len(text): + end_idx = min(start_idx + max_length, len(text)) + segment = text[start_idx:end_idx] + segments.append(segment.strip()) + start_idx = end_idx + return segments diff --git a/prompt_definitions.py b/prompt_definitions.py index b1d9af3..cd48375 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,3 +1,5 @@ +# prompt_definitions.py +# -*- coding: utf-8 -*- """ 集中存放所有提示词(Prompt),便于统一管理和修改。 """ @@ -10,7 +12,7 @@ set_prompt = """\ 1. 小说名称、总字数走向(大致范围即可)。 2. 小说类型与基调(如:都市、穿越、战争等类型,以及轻松、爆笑、暗黑等基调)。 3. 写作风格(正式 / 轻松;细腻 / 简洁;抒情 / 客观;叙事视角等)。 -4. 整体世界观(时间背景、地理环境、社会结构、科技或魔法水平、重要历史传说或事件等)。 +4. 整体世界观(时间背景、地理环境、社会结构、科技或魔法水平、重要历史事件等)。 5. 核心内容梗概(可以使用常见叙事结构,如三幕结构、英雄之旅等)。 6. 初步的情节安排设想(主线、副线、交织等关键思路)。 7. 初步的人物关系与主要角色设定(角色定位、主要冲突或关系)。 @@ -58,7 +60,7 @@ novel_directory_prompt = """\ 根据以下最终《小说设定》: {final_novel_setting} 并按照下面的小说目录模板生成 {number_of_chapters} 章的目录,同时确保目录符合小说设定中的叙事结构、角色发展及暗线伏笔。 -目录模板: +目录模板(示例): 第1章 :< text > 第2章 :< text > ... @@ -127,3 +129,4 @@ chapter_write_prompt = """\ 3. 可以着重描写人物心理、环境氛围等,以保证足够长度。 4. 在结尾部分保留一定悬念或剧情转折,为下一章做铺垫。 """ + diff --git a/requirements.txt b/requirements.txt index 693dcb1..d492909 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,6 @@ langgraph openai chromadb langchain-community +sentence_transformers +scikit-learn +nltk \ No newline at end of file diff --git a/ui.py b/ui.py index 08b3768..4af9c40 100644 --- a/ui.py +++ b/ui.py @@ -1,3 +1,5 @@ +# ui.py +# -*- coding: utf-8 -*- import os import tkinter as tk from tkinter import ttk, filedialog, scrolledtext, messagebox @@ -7,7 +9,8 @@ from config_manager import load_config, save_config from utils import read_file from novel_generator import ( Novel_novel_directory_generate, - generate_chapter_with_state + generate_chapter_with_state, + import_knowledge_file ) from consistency_checker import check_consistency @@ -60,9 +63,9 @@ class NovelGeneratorGUI: self.chapter_result = scrolledtext.ScrolledText(chapter_frame, width=80, height=10, foreground="blue") self.chapter_result.grid(row=0, column=0, sticky="nsew") - def build_right_layout(self, ): + def build_right_layout(self): # 行列配置 - for i in range(12): + for i in range(15): self.right_frame.rowconfigure(i, weight=0) self.right_frame.columnconfigure(1, weight=1) @@ -81,57 +84,77 @@ class NovelGeneratorGUI: self.model_name_var = tk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) ttk.Entry(self.right_frame, textvariable=self.model_name_var, width=32).grid(row=2, column=1, padx=5, pady=5, sticky="w") - # 4. 主题(Topic) 多行输入 - ttk.Label(self.right_frame, text="主题(Topic):").grid(row=3, column=0, padx=5, pady=5, sticky="ne") + # 4. Temperature + ttk.Label(self.right_frame, text="Temperature:").grid(row=3, column=0, padx=5, pady=5, sticky="e") + self.temperature_var = tk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) + self.temp_value_label = ttk.Label(self.right_frame, text=f"{self.temperature_var.get():.2f}") + self.temp_value_label.grid(row=3, column=2, padx=5, pady=5, sticky="w") + + temp_scale = ttk.Scale(self.right_frame, from_=0.0, to=1.0, orient=tk.HORIZONTAL, variable=self.temperature_var) + temp_scale.grid(row=3, column=1, padx=5, pady=5, sticky="we") + def update_temp_label(*args): + self.temp_value_label.config(text=f"{self.temperature_var.get():.2f}") + self.temperature_var.trace("w", update_temp_label) + + # 5. 主题(Topic) 多行输入 + ttk.Label(self.right_frame, text="主题(Topic):").grid(row=4, column=0, padx=5, pady=5, sticky="ne") self.topic_text = scrolledtext.ScrolledText(self.right_frame, width=32, height=4) - self.topic_text.grid(row=3, column=1, padx=5, pady=5, sticky="w") + self.topic_text.grid(row=4, column=1, padx=5, pady=5, sticky="w") topic_default = self.loaded_config.get("topic", "") if topic_default: self.topic_text.insert(tk.END, topic_default) - # 5. 类型(Genre) - ttk.Label(self.right_frame, text="类型(Genre):").grid(row=4, column=0, padx=5, pady=5, sticky="e") + # 6. 类型(Genre) + ttk.Label(self.right_frame, text="类型(Genre):").grid(row=5, column=0, padx=5, pady=5, sticky="e") self.genre_var = tk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - ttk.Entry(self.right_frame, textvariable=self.genre_var, width=32).grid(row=4, column=1, padx=5, pady=5, sticky="w") + ttk.Entry(self.right_frame, textvariable=self.genre_var, width=32).grid(row=5, column=1, padx=5, pady=5, sticky="w") - # 6. 章节数 - ttk.Label(self.right_frame, text="章节数:").grid(row=5, column=0, padx=5, pady=5, sticky="e") + # 7. 章节数 + ttk.Label(self.right_frame, text="章节数:").grid(row=6, column=0, padx=5, pady=5, sticky="e") self.num_chapters_var = tk.IntVar(value=self.loaded_config.get("num_chapters", 10)) - ttk.Entry(self.right_frame, textvariable=self.num_chapters_var, width=8).grid(row=5, column=1, padx=5, pady=5, sticky="w") + ttk.Entry(self.right_frame, textvariable=self.num_chapters_var, width=8).grid(row=6, column=1, padx=5, pady=5, sticky="w") - # 7. 每章字数 - ttk.Label(self.right_frame, text="每章字数:").grid(row=6, column=0, padx=5, pady=5, sticky="e") + # 8. 每章字数 + ttk.Label(self.right_frame, text="每章字数:").grid(row=7, column=0, padx=5, pady=5, sticky="e") self.word_number_var = tk.IntVar(value=self.loaded_config.get("word_number", 3000)) - ttk.Entry(self.right_frame, textvariable=self.word_number_var, width=8).grid(row=6, column=1, padx=5, pady=5, sticky="w") + ttk.Entry(self.right_frame, textvariable=self.word_number_var, width=8).grid(row=7, column=1, padx=5, pady=5, sticky="w") - # 8. 文件保存路径 - ttk.Label(self.right_frame, text="保存路径:").grid(row=7, column=0, padx=5, pady=5, sticky="e") + # 9. 文件保存路径 + ttk.Label(self.right_frame, text="保存路径:").grid(row=8, column=0, padx=5, pady=5, sticky="e") self.filepath_var = tk.StringVar(value=self.loaded_config.get("filepath", "")) - ttk.Entry(self.right_frame, textvariable=self.filepath_var, width=32).grid(row=7, column=1, padx=5, pady=5, sticky="w") - ttk.Button(self.right_frame, text="浏览...", command=self.browse_folder).grid(row=7, column=2, padx=5, pady=5, sticky="w") + ttk.Entry(self.right_frame, textvariable=self.filepath_var, width=32).grid(row=8, column=1, padx=5, pady=5, sticky="w") + ttk.Button(self.right_frame, text="浏览...", command=self.browse_folder).grid(row=8, column=2, padx=5, pady=5, sticky="w") # 保存/加载配置按钮 config_frame = ttk.Frame(self.right_frame) - config_frame.grid(row=8, column=1, sticky="w") + config_frame.grid(row=9, column=1, sticky="w") ttk.Button(config_frame, text="保存配置", command=self.save_config_btn).grid(row=0, column=0, padx=5) ttk.Button(config_frame, text="加载配置", command=self.load_config_btn).grid(row=0, column=1, padx=5) - # 按钮区域 - row_base = 9 - ttk.Label(self.right_frame, text="章节号:").grid(row=row_base, column=0, sticky="e") + # 10. 章节号 + ttk.Label(self.right_frame, text="章节号:").grid(row=10, column=0, sticky="e") self.chapter_num_var = tk.IntVar(value=1) - ttk.Entry(self.right_frame, textvariable=self.chapter_num_var, width=6).grid(row=row_base, column=1, padx=5, pady=5, sticky="w") + ttk.Entry(self.right_frame, textvariable=self.chapter_num_var, width=6).grid(row=10, column=1, padx=5, pady=5, sticky="w") + # 11. “用户指导” 多行输入 + ttk.Label(self.right_frame, text="本章指导:").grid(row=11, column=0, padx=5, pady=5, sticky="ne") + self.user_guide_text = scrolledtext.ScrolledText(self.right_frame, width=32, height=4) + self.user_guide_text.grid(row=11, column=1, padx=5, pady=5, sticky="w") + + # 按钮区域 + row_base = 12 self.btn_generate_full = ttk.Button(self.right_frame, text="1. 生成设定 & 目录", command=self.generate_full_novel) - self.btn_generate_full.grid(row=row_base+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_generate_full.grid(row=row_base, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_generate_chapter = ttk.Button(self.right_frame, text="2. 生成单章(含角色状态)", command=self.generate_chapter_text) - self.btn_generate_chapter.grid(row=row_base+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_generate_chapter.grid(row=row_base+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - # 可选:添加一个“一致性审校”按钮 self.btn_check_consistency = ttk.Button(self.right_frame, text="3. 一致性审校", command=self.do_consistency_check) - self.btn_check_consistency.grid(row=row_base+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_check_consistency.grid(row=row_base+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + # 增加一个按钮来导入自定义知识库文件 + self.btn_import_knowledge = ttk.Button(self.right_frame, text="导入知识库", command=self.import_knowledge_handler) + self.btn_import_knowledge.grid(row=row_base+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") # -------------- 配置管理 -------------- def load_config_btn(self): @@ -140,6 +163,7 @@ class NovelGeneratorGUI: self.api_key_var.set(cfg.get("api_key", "")) self.base_url_var.set(cfg.get("base_url", "")) self.model_name_var.set(cfg.get("model_name", "")) + self.temperature_var.set(cfg.get("temperature", 0.7)) self.genre_var.set(cfg.get("genre", "")) self.num_chapters_var.set(cfg.get("num_chapters", 10)) self.word_number_var.set(cfg.get("word_number", 3000)) @@ -158,6 +182,7 @@ class NovelGeneratorGUI: "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(), "topic": self.topic_text.get("1.0", tk.END).strip(), "genre": self.genre_var.get(), "num_chapters": self.num_chapters_var.get(), @@ -200,6 +225,7 @@ class NovelGeneratorGUI: num_chapters = self.num_chapters_var.get() word_number = self.word_number_var.get() filepath = self.filepath_var.get().strip() + temperature = self.temperature_var.get() if not filepath: messagebox.showwarning("警告", "请先选择保存文件路径") @@ -214,7 +240,8 @@ class NovelGeneratorGUI: genre=genre, number_of_chapters=num_chapters, word_number=word_number, - filepath=filepath + filepath=filepath, + temperature=temperature ) self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") except Exception as e: @@ -226,7 +253,7 @@ class NovelGeneratorGUI: thread.start() def generate_chapter_text(self): - """多步生成章节:维护全局摘要+角色状态文档,向量检索辅助""" + """多步生成章节:维护全局摘要+角色状态文档,向量检索辅助,并结合目录信息和用户指导。""" def task(): self.disable_button(self.btn_generate_chapter) try: @@ -236,6 +263,7 @@ class NovelGeneratorGUI: novel_number = self.chapter_num_var.get() filepath = self.filepath_var.get().strip() word_number = self.word_number_var.get() + temperature = self.temperature_var.get() # 读取设定 & 目录 novel_settings_file = os.path.join(filepath, "Novel_setting.txt") @@ -253,6 +281,9 @@ class NovelGeneratorGUI: self.log("⚠️ 未找到 Novel_directory.txt,请先生成目录。") return + # 用户对当前章节的指导 + user_guidance = self.user_guide_text.get("1.0", tk.END).strip() + self.log(f"开始生成第{novel_number}章内容(含角色状态文档更新)...") chapter_text = generate_chapter_with_state( novel_settings=novel_settings, @@ -263,11 +294,13 @@ class NovelGeneratorGUI: novel_number=novel_number, filepath=filepath, word_number=word_number, - lastchapter=lastchapter + lastchapter=lastchapter, + user_guidance=user_guidance, + temperature=temperature ) if chapter_text: - self.log(f"✅ 第{novel_number}章内容生成完成。chapter.txt 已更新。") + self.log(f"✅ 第{novel_number}章内容生成完成。chapter_{novel_number}.txt 已更新。") self.chapter_result.delete("1.0", tk.END) self.chapter_result.insert(tk.END, chapter_text) self.chapter_result.see(tk.END) @@ -291,6 +324,7 @@ class NovelGeneratorGUI: base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() filepath = self.filepath_var.get().strip() + temperature = self.temperature_var.get() # 读取关键文件 novel_settings_file = os.path.join(filepath, "Novel_setting.txt") @@ -315,7 +349,8 @@ class NovelGeneratorGUI: chapter_text=last_chapter_text, api_key=api_key, base_url=base_url, - model_name=model_name + model_name=model_name, + temperature=temperature ) self.log("审校结果:") self.log(result) @@ -327,3 +362,29 @@ class NovelGeneratorGUI: thread = threading.Thread(target=task) thread.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(self.btn_import_knowledge) + try: + self.log(f"开始导入知识库文件: {selected_file}") + import_knowledge_file( + api_key=self.api_key_var.get().strip(), + base_url=self.base_url_var.get().strip(), + file_path=selected_file + ) + self.log("✅ 知识库文件导入完成。") + except Exception as e: + self.log(f"❌ 导入知识库时出错: {e}") + finally: + self.enable_button(self.btn_import_knowledge) + + thread = threading.Thread(target=task) + thread.start() + diff --git a/utils.py b/utils.py index 2c21cfe..96b2e0b 100644 --- a/utils.py +++ b/utils.py @@ -1,3 +1,5 @@ +# utils.py +# -*- coding: utf-8 -*- import os import json From ccac24a3547fefe8f5c07ecd98ae2b42a6e32088 Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:05:20 +0800 Subject: [PATCH 015/142] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9699b88..f964de9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ ✅ **章节生成**(多步生成,确保逻辑连贯) ✅ **角色状态 & 伏笔管理**(追踪人物发展) ✅ **向量检索**(保证长篇小说上下文一致) +✅ **自定义知识库**(上传本地参考文档) ✅ **一致性检查**(防止剧情冲突) ✅ **GUI 友好交互**(可配置 & 直观操作) From a8c239aa1c043174623f9fa4731a5ae885e92b63 Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:05:57 +0800 Subject: [PATCH 016/142] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f964de9..a85c16f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ✅ **章节生成**(多步生成,确保逻辑连贯) ✅ **角色状态 & 伏笔管理**(追踪人物发展) ✅ **向量检索**(保证长篇小说上下文一致) -✅ **自定义知识库**(上传本地参考文档) +✅ **自定义知识库**(上传本地参考文档) ✅ **一致性检查**(防止剧情冲突) ✅ **GUI 友好交互**(可配置 & 直观操作) From 5a0db9b82be4cda63eeb9d48bb3d9c3f9507bcd4 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Fri, 31 Jan 2025 19:57:44 +0800 Subject: [PATCH 017/142] =?UTF-8?q?=E6=96=B0=E8=AE=BE=E6=83=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- main.spec | 16 +- novel_generator.py | 505 +++++++++++++++++++-------------------------- ui.py | 179 +++++++++++----- 4 files changed, 359 insertions(+), 345 deletions(-) diff --git a/.gitignore b/.gitignore index f2bce34..1780e96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ /Novel_Src -/vectorstore -/.conda +/.venv /build /dist /.vscode /__pycache__ +/vectorstore config.json diff --git a/main.spec b/main.spec index 57e1776..63fdbfd 100644 --- a/main.spec +++ b/main.spec @@ -1,11 +1,13 @@ # -*- mode: python ; coding: utf-8 -*- - +from PyInstaller.utils.hooks import collect_submodules a = Analysis( ['main.py'], pathex=[], binaries=[], - datas=[], + datas=[ + ('vectorstore', 'vectorstore') + ], hiddenimports=['typing_extensions', 'langchain-openai', 'langgraph', @@ -17,7 +19,15 @@ a = Analysis( 'langchain-community', 'pydantic', 'pydantic.deprecated.decorator', - 'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2' + *collect_submodules('chromadb'), + 'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2', + 'chromadb.telemetry.product.posthog', + 'chromadb.api.segment', + 'chromadb.db.impl', + 'chromadb.db.impl.sqlite', + 'chromadb.migrations', + 'chromadb.migrations.embeddings_queue' + ], hookspath=[], hooksconfig={}, diff --git a/novel_generator.py b/novel_generator.py index 35d07b6..47db654 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -15,7 +15,6 @@ from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain.docstore.document import Document -# import nltk import math from sentence_transformers import SentenceTransformer @@ -35,10 +34,25 @@ from prompt_definitions import ( # ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") -# ============ 向量检索相关函数(Chroma) ============ +# ============ 向量检索相关 ============ VECTOR_STORE_DIR = "vectorstore" +def clear_vector_store(): + """ + 清空本地向量库(删除 vectorstore 文件夹)。 + 需要在UI中加一个二次确认弹窗,防止误删。 + """ + if os.path.exists(VECTOR_STORE_DIR): + try: + import shutil + shutil.rmtree(VECTOR_STORE_DIR) + logging.info("Local vector store has been cleared.") + except Exception as e: + logging.warning(f"Failed to remove vector store: {e}") + else: + logging.info("No vector store found to clear.") + def init_vector_store(api_key: str, base_url: str, texts: List[str]) -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 @@ -118,16 +132,6 @@ def Novel_novel_directory_generate( ) -> None: """ 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 - - :param api_key: OpenAI API key - :param base_url: OpenAI API base url - :param llm_model: 所使用的 LLM 模型名称 - :param topic: 小说主题 - :param genre: 小说类型 - :param number_of_chapters: 章节数 - :param word_number: 单章目标字数 - :param filepath: 存放生成文件的目录路径 - :param temperature: 生成温度 """ # 确保文件夹存在 os.makedirs(filepath, exist_ok=True) @@ -142,7 +146,7 @@ def Novel_novel_directory_generate( def debug_log(prompt: str, response_content: str): """在控制台打印或记录下每次Prompt与Response,[调试]""" logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response <<<] {response_content}\n") + logging.info(f"[Response >>>] {response_content}\n") def generate_base_setting(state: OverallState) -> Dict[str, str]: prompt = set_prompt.format( @@ -257,159 +261,74 @@ def Novel_novel_directory_generate( logging.info("Novel settings and directory generated successfully.") -# ============ 生成章节(每章独立文件) ============ +# ============ 新增:获取最近N章内容,生成短期摘要 ============ -CHINESE_NUM_MAP = { - '零': 0, '○': 0, '〇': 0, - '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, - '六': 6, '七': 7, '八': 8, '九': 9, - '十': 10, '百': 100, '千': 1000, '万': 10000 -} - -def chinese_to_arabic(chinese_str: str) -> int: +def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: """ - 只能处理到万(10000)以内的中文数字,正常小说章节应该够用了 + 从指定文件夹中,读取最近 n 章的内容(如果存在),并按从旧到新的顺序返回文本列表。 + 不包含当前章,只拿之前的 n 章。 """ - total = 0 - current_unit = 1 # 记录当前单位 - tmp_val = 0 # 暂存本轮数字 + texts = [] + start_chap = max(1, current_chapter_num - n) + for c in range(start_chap, current_chapter_num): + chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") + if os.path.exists(chap_file): + text = read_file(chap_file).strip() + if text: + texts.append(text) + return texts - for char in reversed(chinese_str): - if char in CHINESE_NUM_MAP: - val = CHINESE_NUM_MAP[char] - if val >= 10: - if val > current_unit: - # 如 100, 1000, 10000 - current_unit = val - else: - # 比如 “十二” -> 2 * 10 + 1 - # 如果 val <= current_unit, 那么相当于在这个单位下加 - total += tmp_val * val - tmp_val = 0 - else: - # 0~9 - tmp_val = tmp_val + val * current_unit - else: - # 非中文数字字符,视情况决定怎么处理,这里直接跳过 - pass - - total += tmp_val - return total - -def parse_chapter_title_from_directory(novel_directory_text: str, - novel_number: int, - range_size: int = 1) -> str: +def summarize_recent_chapters(model: ChatOpenAI, chapters_text_list: List[str]) -> str: """ - 从小说目录文本中,提取指定章节(以及前后几章)的目录信息。 - range_size=1,表示获取当前章节、前一章和后一章的目录信息(若存在)。 - 支持多种常见的章节格式。 + 将最近几章的文本拼接后,通过模型生成一个相对详细的“短期内容摘要”。 """ + if not chapters_text_list: + return "" - lines = novel_directory_text.splitlines() + # 拼接这几章的内容 + combined_text = "\n".join(chapters_text_list) + # 在这里可以写一个更详细的提示 + prompt = f"""\ +这是最近几章的故事内容,请生成一份详细的短期内容摘要(不少于一章篇幅的细节),用于帮助后续创作时回顾细节。请着重强调发生的事件、角色的心理和关系变化、冲突或悬念等。 - # 可以根据需求自行扩展,这里列举了几种常见的章节标题格式,如果模型实在不听话,可以适当调整 - # 每个pattern都应该捕获两个组: - # 1. chapter_num_str:章节数字(可能是中文也可能是阿拉伯数字) - # 2. chapter_title :章节标题(.*) - patterns = [ - # 1) 第12章 标题 - r"^第\s*([\d]+)\s*章[::]?\s*(.*)$", - # 2) 第十二章 标题(中文数字) - r"^第\s*([零○〇一二三四五六七八九十百千万]+)\s*章[::]?\s*(.*)$", - # 3) Chapter 12 标题 - r"^Chapter\s+(\d+)\s*[::]?\s*(.*)$", - # 4) Ch 12 标题 - r"^Ch\s+(\d+)\s*[::]?\s*(.*)$", - # 5) 第12节 标题 - r"^第\s*([\d]+)\s*节[::]?\s*(.*)$", - # 6) 第12话 标题 - r"^第\s*([\d]+)\s*话[::]?\s*(.*)$", - # ... 更多模式 ... - ] +{combined_text} +""" + response = model.invoke(prompt) + if not response: + return "" + return response.content.strip() - # 用来存储匹配结果: chapter_num -> title - directory_map = {} +# ============ 生成章节草稿 & 定稿 ============ - for line in lines: - line = line.strip() - if not line: - continue - - # 依次尝试每一种pattern - matched = False - for pat in patterns: - match = re.match(pat, line, flags=re.IGNORECASE) - if match: - chapter_num_str = match.group(1) - chapter_title = match.group(2).strip() - - # 如果是中文数字,需要转换 - # 如果是阿拉伯数字,直接转 int 即可 - if re.match(r"^[零○〇一二三四五六七八九十百千万]+$", chapter_num_str): - chapter_num = chinese_to_arabic(chapter_num_str) - else: - chapter_num = int(chapter_num_str) - - directory_map[chapter_num] = chapter_title - matched = True - break - - # 如果已经匹配到其中一个pattern,就不需要继续匹配剩余pattern - if matched: - continue - - # 收集需要的章节范围 - chapters_info = [] - for cnum in range(novel_number - range_size, novel_number + range_size + 1): - if cnum in directory_map: - if cnum == novel_number: - chapters_info.append(f"【当前】第{cnum}章:{directory_map[cnum]}") - else: - chapters_info.append(f"第{cnum}章:{directory_map[cnum]}") - - if chapters_info: - return "\n".join(chapters_info) - return "" - -def generate_chapter_with_state( +def generate_chapter_draft( novel_settings: str, - novel_novel_directory: str, + global_summary: str, + character_state: str, + recent_chapters_summary: str, + user_guidance: str, api_key: str, base_url: str, model_name: str, novel_number: int, - filepath: str, word_number: int, - lastchapter: str, - user_guidance: str = "", - temperature: float = 0.7 + temperature: float, + novel_novel_directory: str, + filepath: str ) -> str: """ - 多步流程: - 1) 更新/创建全局摘要 - 2) 更新/生成角色状态文档 - 3) 向量检索获取往期上下文 - 4) 从Novel_directory.txt中获取当前(和前后几章)的目录信息 - 5) 大纲 -> 正文(可结合用户给出的额外指导) - 6) 写入 chapter_{novel_number}.txt, 更新 last_chapter.txt - 7) 更新向量库 + 仅生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 + 并将生成的内容写到 "chapter_{novel_number}.txt" 覆盖写入。 + 同时生成 "outline_{novel_number}.txt" 存储大纲内容。 - :param novel_settings: 最终的作品设定(字符串) - :param novel_novel_directory: 小说目录信息 - :param api_key: OpenAI API Key - :param base_url: OpenAI Base URL - :param model_name: LLM 模型名称 - :param novel_number: 当前要生成的章节号 - :param filepath: 文件存放的目录 - :param word_number: 单章目标字数 - :param lastchapter: 上一章内容(若为空字符串,表示无上一章) - :param user_guidance: 用户对当前章节的额外指导或想法 - :param temperature: 生成温度 - :return: 本章生成的正文内容 + recent_chapters_summary: 最近 3 章的“短期内容摘要” """ - # 确保文件夹存在 - os.makedirs(filepath, exist_ok=True) + # 1) 从向量库检索往期上下文 + relevant_context = get_relevant_context_from_vector_store( + api_key, base_url, "回顾剧情", k=2 + ) + + # 2) 生成大纲(增加 recent_chapters_summary) model = ChatOpenAI( model=model_name, api_key=api_key, @@ -417,25 +336,119 @@ def generate_chapter_with_state( temperature=temperature ) - # 调试输出函数 - def debug_log(prompt: str, response_content: str): - """在控制台打印或记录下每次的 Prompt 与 Response,便于观察生成过程。""" - logging.info(f"\n[Prompt >>>]\n{prompt}\n") - logging.info(f"[Response <<<]\n{response_content}\n") + # Prompt 拼接 + outline_prompt = ( + chapter_outline_prompt + + "\n\n【最近几章摘要】\n" + recent_chapters_summary + + "\n\n【用户指导】\n" + (user_guidance if user_guidance else "(无)") + ).format( + novel_setting=novel_settings, + character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, + global_summary=global_summary, + novel_number=novel_number + ) - # --- 文件路径定义 --- + response_outline = model.invoke(outline_prompt) + if not response_outline: + logging.warning("outline_chapter: No response.") + chapter_outline = "" + else: + chapter_outline = response_outline.content.strip() + + # 将大纲写到 outline_{novel_number}.txt + outlines_dir = os.path.join(filepath, "outlines") + os.makedirs(outlines_dir, exist_ok=True) + outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") + clear_file_content(outline_file) + save_string_to_txt(chapter_outline, outline_file) + + # 3) 生成正文草稿 + writing_prompt = ( + chapter_write_prompt + + "\n\n【最近几章摘要】\n" + recent_chapters_summary + + "\n\n【用户指导】\n" + (user_guidance if user_guidance else "(无)") + ).format( + novel_setting=novel_settings, + character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, + global_summary=global_summary, + chapter_outline=chapter_outline, + word_number=word_number + ) + + response_chapter = model.invoke(writing_prompt) + if not response_chapter: + logging.warning("write_chapter: No response.") + chapter_content = "" + else: + chapter_content = response_chapter.content.strip() + + # 4) 覆盖写到 chapter_{novel_number}.txt chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - lastchapter_file = os.path.join(filepath, "last_chapter.txt") + clear_file_content(chapter_file) + save_string_to_txt(chapter_content, chapter_file) + + logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") + return chapter_content + +def finalize_chapter( + novel_number: int, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + filepath: str +): + """ + 对当前章节进行定稿: + 1. 读取 chapter_{novel_number}.txt 的最终内容; + 2. 更新全局摘要、角色状态文件; + 3. 如果字数明显少于 word_number 的 80%,则自动调用 enrich_chapter_text 再次扩写; + 4. 更新向量库。 + + * 注意:实际应用中,用户也可以再次编辑 chapter_{n}.txt 后再点定稿,这里示例不做 GUI 级别的文本编辑逻辑。 + """ + # 读取当前章节内容 + chapters_dir = os.path.join(filepath, "chapters") + chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + chapter_text = read_file(chapter_file).strip() + if not chapter_text: + logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") + return + + # 读取角色状态 & 全局摘要 character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") old_char_state = read_file(character_state_file) old_global_summary = read_file(global_summary_file) - # 1) 更新全局摘要 + # 1) 先检查字数是否过少,若少于 80% 则调用 enrich 逻辑 + if len(chapter_text) < 0.8 * word_number: + logging.info("Chapter text seems shorter than 80% of desired length. Attempting to enrich content...") + chapter_text = enrich_chapter_text( + chapter_text=chapter_text, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature + ) + # 覆盖写回文件 + clear_file_content(chapter_file) + save_string_to_txt(chapter_text, chapter_file) + logging.info("Chapter text has been enriched and updated.") + + # 2) 更新全局摘要 + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature + ) + def update_global_summary(chapter_text: str, old_summary: str) -> str: prompt = summary_prompt.format( chapter_text=chapter_text, @@ -445,15 +458,11 @@ def generate_chapter_with_state( if not response: logging.warning("update_global_summary: No response.") return old_summary - debug_log(prompt, response.content) return response.content.strip() - if lastchapter.strip(): - new_global_summary = update_global_summary(lastchapter, old_global_summary) - else: - new_global_summary = old_global_summary + new_global_summary = update_global_summary(chapter_text, old_global_summary) - # 2) 更新角色状态文档 + # 3) 更新角色状态 def update_character_state(chapter_text: str, old_state: str) -> str: prompt = update_character_state_prompt.format( chapter_text=chapter_text, @@ -463,128 +472,57 @@ def generate_chapter_with_state( if not response: logging.warning("update_character_state: No response.") return old_state - debug_log(prompt, response.content) return response.content.strip() - if lastchapter.strip(): - new_char_state = update_character_state(lastchapter, old_char_state) - else: - new_char_state = old_char_state + new_char_state = update_character_state(chapter_text, old_char_state) - # 3) 从向量库检索上下文 - relevant_context = get_relevant_context_from_vector_store( - api_key, base_url, "回顾剧情", k=2 + # 4) 覆盖写入角色状态文件与全局摘要文件 + clear_file_content(character_state_file) + save_string_to_txt(new_char_state, character_state_file) + + clear_file_content(global_summary_file) + save_string_to_txt(new_global_summary, global_summary_file) + + # 5) 更新向量检索库 + update_vector_store(api_key, base_url, chapter_text) + + logging.info(f"Chapter {novel_number} has been finalized (summary & state updated, vector store updated).") + +def enrich_chapter_text( + chapter_text: str, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float +) -> str: + """ + 当章节篇幅不足时,调用此函数对章节文本进行二次扩写。 + 可以让模型补充场景描写、角色心理等,保证与现有文本风格一致。 + """ + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature ) + prompt = f"""\ +以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 - # 4) 解析本章及前后章节目录信息 - this_and_related_chapters = parse_chapter_title_from_directory(novel_novel_directory, novel_number, range_size=1) +原章节内容: +{chapter_text} +""" + response = model.invoke(prompt) + if not response: + logging.warning("enrich_chapter_text: No response.") + return chapter_text # 无响应时就返回原文 + return response.content.strip() - # 5) 生成大纲 - def outline_chapter( - novel_setting: str, - char_state: str, - global_summary: str, - chap_num: int, - extra_context: str, - directory_hint: str, - user_guide: str - ) -> str: - """ - 将目录提示以及用户额外指导内容一起放入 Prompt 中。 - """ - # 适度修改章节提纲提示词,以整合目录信息 & 用户指导 - outline_prompt = ( - chapter_outline_prompt - + "\n\n【目录参考】\n" + directory_hint - + "\n\n【用户指导】\n" + user_guide - ).format( - novel_setting=novel_setting, - character_state=char_state + "\n\n【历史上下文】\n" + extra_context, - global_summary=global_summary, - novel_number=chap_num - ) - - response = model.invoke(outline_prompt) - if not response: - logging.warning("outline_chapter: No response.") - return "" - debug_log(outline_prompt, response.content) - return response.content.strip() - - chap_outline = outline_chapter( - novel_settings, new_char_state, new_global_summary, novel_number, - relevant_context, this_and_related_chapters, user_guidance - ) - - # 6) 生成正文 - def write_chapter( - novel_setting: str, - char_state: str, - global_summary: str, - outline: str, - wnum: int, - extra_context: str, - directory_hint: str, - user_guide: str - ) -> str: - # 同理,整合目录信息和用户指导 - writing_prompt = ( - chapter_write_prompt - + "\n\n【目录参考】\n" + directory_hint - + "\n\n【用户指导】\n" + user_guide - ).format( - novel_setting=novel_setting, - character_state=char_state + "\n\n【历史上下文】\n" + extra_context, - global_summary=global_summary, - chapter_outline=outline, - word_number=wnum - ) - - response = model.invoke(writing_prompt) - if not response: - logging.warning("write_chapter: No response.") - return "" - debug_log(writing_prompt, response.content) - return response.content.strip() - - chapter_content = write_chapter( - novel_settings, - new_char_state, - new_global_summary, - chap_outline, - word_number, - relevant_context, - this_and_related_chapters, - user_guidance - ) - - # 写入文件并更新记录 - if chapter_content: - save_string_to_txt(chapter_content, chapter_file) - - # 更新 last_chapter.txt - clear_file_content(lastchapter_file) - save_string_to_txt(chapter_content, lastchapter_file) - - # 更新角色状态、全局摘要 - clear_file_content(character_state_file) - save_string_to_txt(new_char_state, character_state_file) - - clear_file_content(global_summary_file) - save_string_to_txt(new_global_summary, global_summary_file) - - # 7) 更新向量检索库 - update_vector_store(api_key, base_url, chapter_content) - logging.info(f"Chapter {novel_number} generated successfully.") - else: - logging.warning(f"Chapter {novel_number} generation failed.") - - return chapter_content +# ============ 导入外部知识文本 ============ def import_knowledge_file(api_key: str, base_url: str, file_path: str) -> None: """ 将用户选定的文本文件导入到向量库,以便在写作时检索。 - 可以在UI中提供按钮来调用此函数。 """ # 1. 检查文件路径是否有效 @@ -614,31 +552,21 @@ def import_knowledge_file(api_key: str, base_url: str, file_path: str) -> None: store.persist() logging.info("知识库文件已成功导入至向量库。") - def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: """ 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 - - :param content: 原始文本内容 - :param similarity_threshold: 相邻句子合并的语义相似度阈值,小于此值则会开启新的段落 - :param max_length: 每个段落的最大长度(按字符数计算,超过则进一步拆分) - :return: 切分好的段落列表 """ - - # 1. 按句子切分 nltk.download('punkt', quiet=True) # 确保 punkt 数据可用 sentences = nltk.sent_tokenize(content) if not sentences: return [] - # 2. 加载 SentenceTransformer 模型,用于计算语义相似度 model = SentenceTransformer('paraphrase-MiniLM-L6-v2') embeddings = model.encode(sentences) - # 3. 根据相邻句子的语义相似度合并段落 merged_paragraphs = [] current_sentences = [sentences[0]] current_embedding = embeddings[0] @@ -646,39 +574,28 @@ def advanced_split_content(content: str, for i in range(1, len(sentences)): sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] if sim >= similarity_threshold: - # 语义相似则并入当前段落 current_sentences.append(sentences[i]) - # 更新current_embedding为合并后的平均值(可选,也可只采用最后一句做比较) current_embedding = (current_embedding + embeddings[i]) / 2.0 else: - # 语义相似度不足,另起一个新段落 merged_paragraphs.append(" ".join(current_sentences)) current_sentences = [sentences[i]] current_embedding = embeddings[i] - # 把最后一段加进去 if current_sentences: merged_paragraphs.append(" ".join(current_sentences)) - # 4. 根据最大长度 max_length 做二次拆分,避免段落过长 + # 按最大长度二次拆分 final_segments = [] for para in merged_paragraphs: - # 如果段落长度超过max_length,进一步切分 if len(para) > max_length: sub_segments = split_by_length(para, max_length=max_length) final_segments.extend(sub_segments) else: final_segments.append(para) - # 返回最终段落列表 return final_segments - def split_by_length(text: str, max_length: int = 500) -> List[str]: - """ - 将文本按照max_length进行拆分,以避免段落过长。 - 这里以字符数为单位进行简单的拆分,也可以改为按词数或token数等。 - """ segments = [] start_idx = 0 while start_idx < len(text): diff --git a/ui.py b/ui.py index 4af9c40..aa8199c 100644 --- a/ui.py +++ b/ui.py @@ -9,8 +9,12 @@ from config_manager import load_config, save_config from utils import read_file from novel_generator import ( Novel_novel_directory_generate, - generate_chapter_with_state, - import_knowledge_file + generate_chapter_draft, + finalize_chapter, + import_knowledge_file, + clear_vector_store, + get_last_n_chapters_text, + summarize_recent_chapters ) from consistency_checker import check_consistency @@ -65,7 +69,7 @@ class NovelGeneratorGUI: def build_right_layout(self): # 行列配置 - for i in range(15): + for i in range(20): self.right_frame.rowconfigure(i, weight=0) self.right_frame.columnconfigure(1, weight=1) @@ -141,20 +145,32 @@ class NovelGeneratorGUI: self.user_guide_text = scrolledtext.ScrolledText(self.right_frame, width=32, height=4) self.user_guide_text.grid(row=11, column=1, padx=5, pady=5, sticky="w") - # 按钮区域 row_base = 12 + # ============ 功能按钮 ============ + + # (1) 生成设定 & 目录 self.btn_generate_full = ttk.Button(self.right_frame, text="1. 生成设定 & 目录", command=self.generate_full_novel) self.btn_generate_full.grid(row=row_base, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - self.btn_generate_chapter = ttk.Button(self.right_frame, text="2. 生成单章(含角色状态)", command=self.generate_chapter_text) + # (2) 生成章节草稿 + self.btn_generate_chapter = ttk.Button(self.right_frame, text="2. 生成章节草稿", command=self.generate_chapter_draft_ui) self.btn_generate_chapter.grid(row=row_base+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - self.btn_check_consistency = ttk.Button(self.right_frame, text="3. 一致性审校", command=self.do_consistency_check) - self.btn_check_consistency.grid(row=row_base+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + # (3) 定稿当前章节 + self.btn_finalize_chapter = ttk.Button(self.right_frame, text="3. 定稿当前章节", command=self.finalize_chapter_ui) + self.btn_finalize_chapter.grid(row=row_base+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - # 增加一个按钮来导入自定义知识库文件 + # (4) 一致性审校 + self.btn_check_consistency = ttk.Button(self.right_frame, text="4. 一致性审校", command=self.do_consistency_check) + self.btn_check_consistency.grid(row=row_base+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + # (5) 导入知识库文件 self.btn_import_knowledge = ttk.Button(self.right_frame, text="导入知识库", command=self.import_knowledge_handler) - self.btn_import_knowledge.grid(row=row_base+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_import_knowledge.grid(row=row_base+4, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + # (6) 清空向量库 + self.btn_clear_vectorstore = ttk.Button(self.right_frame, text="清空向量库", command=self.clear_vectorstore_handler) + self.btn_clear_vectorstore.grid(row=row_base+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") # -------------- 配置管理 -------------- def load_config_btn(self): @@ -205,7 +221,7 @@ class NovelGeneratorGUI: self.log_text.insert(tk.END, message + "\n") self.log_text.see(tk.END) - # -------------- 核心功能按钮 -------------- + # -------------- 功能 -------------- def disable_button(self, btn): btn.config(state=tk.DISABLED) @@ -252,69 +268,114 @@ class NovelGeneratorGUI: thread = threading.Thread(target=task) thread.start() - def generate_chapter_text(self): - """多步生成章节:维护全局摘要+角色状态文档,向量检索辅助,并结合目录信息和用户指导。""" + def generate_chapter_draft_ui(self): + """生成当前章节的草稿""" def task(): self.disable_button(self.btn_generate_chapter) 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() - novel_number = self.chapter_num_var.get() - filepath = self.filepath_var.get().strip() - word_number = self.word_number_var.get() temperature = self.temperature_var.get() + filepath = self.filepath_var.get().strip() - # 读取设定 & 目录 novel_settings_file = os.path.join(filepath, "Novel_setting.txt") - novel_novel_directory_file = os.path.join(filepath, "Novel_directory.txt") - last_chapter_file = os.path.join(filepath, "last_chapter.txt") - novel_settings = read_file(novel_settings_file) - novel_novel_directory = read_file(novel_novel_directory_file) - lastchapter = read_file(last_chapter_file) - if not novel_settings.strip(): self.log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") return - if not novel_novel_directory.strip(): - self.log("⚠️ 未找到 Novel_directory.txt,请先生成目录。") - return - # 用户对当前章节的指导 + character_state_file = os.path.join(filepath, "character_state.txt") + character_state = read_file(character_state_file) + global_summary_file = os.path.join(filepath, "global_summary.txt") + global_summary = read_file(global_summary_file) + novel_directory_file = os.path.join(filepath, "Novel_directory.txt") + novel_directory = read_file(novel_directory_file) + + chap_num = self.chapter_num_var.get() + word_number = self.word_number_var.get() user_guidance = self.user_guide_text.get("1.0", tk.END).strip() - self.log(f"开始生成第{novel_number}章内容(含角色状态文档更新)...") - chapter_text = generate_chapter_with_state( + # 获取最近3章文本,生成短期摘要 + chapters_dir = os.path.join(filepath, "chapters") + recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) + # 用当前模型生成一个较为详细的最近剧情摘要 + model_obj = self.get_llm_model(model_name, api_key, base_url, temperature) + recent_chapters_summary = summarize_recent_chapters(model_obj, recent_3_texts) + + self.log(f"开始生成第{chap_num}章草稿...") + draft_text = generate_chapter_draft( novel_settings=novel_settings, - novel_novel_directory=novel_novel_directory, + global_summary=global_summary, + character_state=character_state, + recent_chapters_summary=recent_chapters_summary, + user_guidance=user_guidance, api_key=api_key, base_url=base_url, model_name=model_name, - novel_number=novel_number, - filepath=filepath, + novel_number=chap_num, word_number=word_number, - lastchapter=lastchapter, - user_guidance=user_guidance, - temperature=temperature + temperature=temperature, + novel_novel_directory=novel_directory, + filepath=filepath ) - - if chapter_text: - self.log(f"✅ 第{novel_number}章内容生成完成。chapter_{novel_number}.txt 已更新。") + if draft_text: + self.log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看。") self.chapter_result.delete("1.0", tk.END) - self.chapter_result.insert(tk.END, chapter_text) + self.chapter_result.insert(tk.END, draft_text) self.chapter_result.see(tk.END) else: - self.log("⚠️ 本章生成失败或无内容。") + self.log("⚠️ 本章草稿生成失败或无内容。") except Exception as e: - self.log(f"❌ 生成章节内容时出错: {e}") + self.log(f"❌ 生成章节草稿时出错: {e}") finally: self.enable_button(self.btn_generate_chapter) thread = threading.Thread(target=task) thread.start() + def finalize_chapter_ui(self): + """定稿当前章节:更新全局摘要、角色状态、向量库等""" + def task(): + self.disable_button(self.btn_finalize_chapter) + 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() + filepath = self.filepath_var.get().strip() + + chap_num = self.chapter_num_var.get() + word_number = self.word_number_var.get() + + self.log(f"开始定稿第{chap_num}章...") + 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 + ) + self.log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") + + # 读取定稿后的文本显示 + chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") + final_text = read_file(chap_file) + self.chapter_result.delete("1.0", tk.END) + self.chapter_result.insert(tk.END, final_text) + self.chapter_result.see(tk.END) + + except Exception as e: + self.log(f"❌ 定稿章节时出错: {e}") + finally: + self.enable_button(self.btn_finalize_chapter) + + thread = threading.Thread(target=task) + thread.start() + def do_consistency_check(self): """使用审校Agent对最新章节进行简单一致性或冲突检查""" def task(): @@ -323,22 +384,25 @@ class NovelGeneratorGUI: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() - filepath = self.filepath_var.get().strip() temperature = self.temperature_var.get() + filepath = self.filepath_var.get().strip() # 读取关键文件 novel_settings_file = os.path.join(filepath, "Novel_setting.txt") character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") - last_chapter_file = os.path.join(filepath, "last_chapter.txt") novel_setting = read_file(novel_settings_file) character_state = read_file(character_state_file) global_summary = read_file(global_summary_file) - last_chapter_text = read_file(last_chapter_file) - if not last_chapter_text.strip(): - self.log("⚠️ last_chapter.txt 为空,暂无可检查的章节文本。") + # 获取当前章节文本 + chap_num = self.chapter_num_var.get() + chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") + chapter_text = read_file(chap_file) + + if not chapter_text.strip(): + self.log("⚠️ 当前章节文件为空或不存在,无法审校。") return self.log("开始一致性审校...") @@ -346,7 +410,7 @@ class NovelGeneratorGUI: novel_setting=novel_setting, character_state=character_state, global_summary=global_summary, - chapter_text=last_chapter_text, + chapter_text=chapter_text, api_key=api_key, base_url=base_url, model_name=model_name, @@ -388,3 +452,26 @@ class NovelGeneratorGUI: thread = threading.Thread(target=task) thread.start() + def clear_vectorstore_handler(self): + """ + 清空向量库按钮:弹出二次确认,若确认则执行 clear_vector_store()。 + """ + def confirmed_clear(): + # 再次确认 + second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") + if second_confirm: + clear_vector_store() + self.log("已清空向量库。") + + first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") + if first_confirm: + confirmed_clear() + + def get_llm_model(self, model_name, api_key, base_url, temperature): + from langchain_openai import ChatOpenAI + return ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature + ) From ffbb273c4b8cdba80706271b75ba921bc61cbc95 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Fri, 31 Jan 2025 20:39:05 +0800 Subject: [PATCH 018/142] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E7=9B=AE=E5=BD=95=E7=9A=84=E6=96=87=E7=AB=A0=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E7=BB=B4=E6=8A=A4=EF=BC=8C=E9=81=BF=E5=85=8D=E5=81=8F?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chapter_directory_parser.py | 50 ++++++++++++++++++++++++++ main.py | 2 +- novel_generator.py | 70 +++++++++++++++++++++++-------------- prompt_definitions.py | 37 +++++++++++++------- ui.py | 6 ++-- 5 files changed, 122 insertions(+), 43 deletions(-) create mode 100644 chapter_directory_parser.py diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py new file mode 100644 index 0000000..f9fc23b --- /dev/null +++ b/chapter_directory_parser.py @@ -0,0 +1,50 @@ +# chapter_directory_parser.py +# -*- coding: utf-8 -*- +import re + +def get_chapter_info_from_directory(novel_directory_content: str, chapter_number: int): + """ + 从给定的 novel_directory_content 文本中,解析 “第X章” 行,并提取本章的标题和可能的简述。 + 返回一个 dict: { + "chapter_title": <字符串>, + "chapter_brief": <字符串> (若没有则为空) + } + 注意:目录文本示例格式: + 第1章 :潮起 + 第2章 :阴影浮现 - 主要角色冲突爆发 + ... + 也可能没有简述,只有一个简单标题。 + """ + + # 将文本逐行拆分 + lines = novel_directory_content.splitlines() + + # 章节匹配:形如 “第5章 :xxx” or “第5章: xxx” or “第5章 xxx” + pattern = re.compile(r'^第\s*(\d+)\s*章\s*[::]?\s*(.*)$') + + for line in lines: + match = pattern.match(line.strip()) + if match: + chap_num = int(match.group(1)) + if chap_num == chapter_number: + # group(2) 可能是标题及简述的混合 + full_title = match.group(2).strip() + # 这里假设用 '-' 进一步区分“标题 - 简述”,也可能用户没写“ - ” + if ' - ' in full_title: + # 根据你的目录格式自由处理 + parts = full_title.split(' - ', 1) + return { + "chapter_title": parts[0].strip(), + "chapter_brief": parts[1].strip() + } + else: + return { + "chapter_title": full_title, + "chapter_brief": "" + } + + # 如果没有匹配到,返回默认 + return { + "chapter_title": f"第{chapter_number}章", + "chapter_brief": "" + } diff --git a/main.py b/main.py index 52e3da3..94f27b2 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ from ui import NovelGeneratorGUI def main(): root = tk.Tk() - root.title("Novel Generator - Innovative Flow") + root.title("Novel Generator") app = NovelGeneratorGUI(root) root.mainloop() diff --git a/novel_generator.py b/novel_generator.py index 47db654..898f8fd 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -31,9 +31,15 @@ from prompt_definitions import ( chapter_outline_prompt, chapter_write_prompt ) +# ============ 新增:导入 chapter_directory_parser ============ +from chapter_directory_parser import get_chapter_info_from_directory + # ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - +def debug_log(prompt: str, response_content: str): + """在控制台打印或记录下每次Prompt与Response,[调试]""" + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") # ============ 向量检索相关 ============ VECTOR_STORE_DIR = "vectorstore" @@ -143,10 +149,7 @@ def Novel_novel_directory_generate( temperature=temperature ) - def debug_log(prompt: str, response_content: str): - """在控制台打印或记录下每次Prompt与Response,[调试]""" - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") + def generate_base_setting(state: OverallState) -> Dict[str, str]: prompt = set_prompt.format( @@ -289,13 +292,15 @@ def summarize_recent_chapters(model: ChatOpenAI, chapters_text_list: List[str]) combined_text = "\n".join(chapters_text_list) # 在这里可以写一个更详细的提示 prompt = f"""\ -这是最近几章的故事内容,请生成一份详细的短期内容摘要(不少于一章篇幅的细节),用于帮助后续创作时回顾细节。请着重强调发生的事件、角色的心理和关系变化、冲突或悬念等。 +这是最近几章的故事内容,请生成一份详细的短期内容摘要(不少于一章篇幅的细节),用于帮助后续创作时回顾细节。 +请着重强调发生的事件、角色的心理和关系变化、冲突或悬念等。 {combined_text} """ response = model.invoke(prompt) if not response: return "" + debug_log(prompt, response.content) return response.content.strip() # ============ 生成章节草稿 & 定稿 ============ @@ -319,16 +324,20 @@ def generate_chapter_draft( 仅生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 并将生成的内容写到 "chapter_{novel_number}.txt" 覆盖写入。 同时生成 "outline_{novel_number}.txt" 存储大纲内容。 - recent_chapters_summary: 最近 3 章的“短期内容摘要” """ + # 0) 根据 novel_number 从 novel_novel_directory 中获取本章标题及简述 + chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) + chapter_title = chapter_info["chapter_title"] + chapter_brief = chapter_info["chapter_brief"] + # 1) 从向量库检索往期上下文 relevant_context = get_relevant_context_from_vector_store( api_key, base_url, "回顾剧情", k=2 ) - # 2) 生成大纲(增加 recent_chapters_summary) + # 2) 生成大纲 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -337,22 +346,26 @@ def generate_chapter_draft( ) # Prompt 拼接 - outline_prompt = ( - chapter_outline_prompt - + "\n\n【最近几章摘要】\n" + recent_chapters_summary - + "\n\n【用户指导】\n" + (user_guidance if user_guidance else "(无)") - ).format( + outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, global_summary=global_summary, - novel_number=novel_number + novel_number=novel_number, + chapter_title=chapter_title, + chapter_brief=chapter_brief ) - response_outline = model.invoke(outline_prompt) + # 在后面加上用户指导与最近章节摘要(可根据需要灵活组织) + outline_prompt_text += f"\n\n【本章目录标题与简述】\n标题:{chapter_title}\n简述:{chapter_brief}\n" + outline_prompt_text += f"\n【最近几章摘要】\n{recent_chapters_summary}" + outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" + + response_outline = model.invoke(outline_prompt_text) if not response_outline: - logging.warning("outline_chapter: No response.") + logging.warning("generate_chapter_draft: outline no response.") chapter_outline = "" else: + debug_log(outline_prompt_text, response_outline.content) chapter_outline = response_outline.content.strip() # 将大纲写到 outline_{novel_number}.txt @@ -363,23 +376,27 @@ def generate_chapter_draft( save_string_to_txt(chapter_outline, outline_file) # 3) 生成正文草稿 - writing_prompt = ( - chapter_write_prompt - + "\n\n【最近几章摘要】\n" + recent_chapters_summary - + "\n\n【用户指导】\n" + (user_guidance if user_guidance else "(无)") - ).format( + writing_prompt_text = chapter_write_prompt.format( novel_setting=novel_settings, character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, global_summary=global_summary, chapter_outline=chapter_outline, - word_number=word_number + word_number=word_number, + chapter_title=chapter_title, + chapter_brief=chapter_brief ) - response_chapter = model.invoke(writing_prompt) + # 同样插入用户指导和最近摘要 + writing_prompt_text += f"\n\n【本章目录标题与简述】\n标题:{chapter_title}\n简述:{chapter_brief}\n" + writing_prompt_text += f"\n【最近几章摘要】\n{recent_chapters_summary}" + writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" + + response_chapter = model.invoke(writing_prompt_text) if not response_chapter: - logging.warning("write_chapter: No response.") + logging.warning("generate_chapter_draft: writing no response.") chapter_content = "" else: + debug_log(writing_prompt_text, response_chapter.content) chapter_content = response_chapter.content.strip() # 4) 覆盖写到 chapter_{novel_number}.txt @@ -407,8 +424,6 @@ def finalize_chapter( 2. 更新全局摘要、角色状态文件; 3. 如果字数明显少于 word_number 的 80%,则自动调用 enrich_chapter_text 再次扩写; 4. 更新向量库。 - - * 注意:实际应用中,用户也可以再次编辑 chapter_{n}.txt 后再点定稿,这里示例不做 GUI 级别的文本编辑逻辑。 """ # 读取当前章节内容 chapters_dir = os.path.join(filepath, "chapters") @@ -458,6 +473,7 @@ def finalize_chapter( if not response: logging.warning("update_global_summary: No response.") return old_summary + debug_log(prompt, response.content) return response.content.strip() new_global_summary = update_global_summary(chapter_text, old_global_summary) @@ -472,6 +488,7 @@ def finalize_chapter( if not response: logging.warning("update_character_state: No response.") return old_state + debug_log(prompt, response.content) return response.content.strip() new_char_state = update_character_state(chapter_text, old_char_state) @@ -516,6 +533,7 @@ def enrich_chapter_text( if not response: logging.warning("enrich_chapter_text: No response.") return chapter_text # 无响应时就返回原文 + debug_log(prompt, response.content) return response.content.strip() # ============ 导入外部知识文本 ============ diff --git a/prompt_definitions.py b/prompt_definitions.py index cd48375..f9ee96b 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -27,7 +27,7 @@ character_prompt = """\ 请你完善以下内容,帮助我们更好地维持人物形象和成长轨迹: 1. 列出核心角色(至少3个),并对每个角色进行详细性格特征描述。 2. 强调每个角色的潜在内心冲突、目标与动机。 -3. 为每个角色添加至少一个“暗线”或隐藏秘密,以及在故事进行中如何可能被揭示的思路。 +3. 为每个角色添加至少一个“暗线”或隐藏秘密,以及在故事进行中可能如何被揭示。 4. 指出主要角色之间的关键关系和冲突点,为后续情节埋下伏笔。 """ @@ -66,7 +66,9 @@ novel_directory_prompt = """\ ... 第{number_of_chapters}章 :< text > -请严格按照上述格式输出每一章的名称,且勿使用Markdown语法。 +请严格按照上述格式输出每一章的名称,最好在重要情节标题后增加提示性简述, +若要加更详细的简述,用“ - ”分隔,如“第2章 :阴影浮现 - 主要角色冲突爆发”。 +请直接输出,不要使用Markdown语法。 """ # =============== 提示词:章节+角色状态流程 =================== @@ -78,7 +80,7 @@ summary_prompt = """\ 这是当前的全局摘要(可能为空): {global_summary} -请在不超过1000字的前提下,基于当前全局摘要和本章新增剧情,更新全局摘要。 +请在不超过3000字的前提下,基于当前全局摘要和本章新增剧情,更新全局摘要。 保留原有重要信息,并融入本章的新内容。 不要透露结局,不要过度展开未来剧情。 """ @@ -100,20 +102,25 @@ update_character_state_prompt = """\ 使用简洁、易读的方式描述,可用条目或段落表示。保持与旧文档风格一致。 """ +# ------------------ 新增占位符:chapter_title, chapter_brief ------------------ + chapter_outline_prompt = """\ 以下是当前小说设定与角色状态信息: - 小说设定:{novel_setting} - 角色状态:{character_state} - 全局摘要:{global_summary} -- 本章节编号:第 {novel_number} 章 -请为即将写作的 第{novel_number}章 设计一个简要大纲: -1. 本章的主要冲突或事件? -2. 哪些角色会出现?情感与目标变化? -3. 如何进一步暗示或推动暗线和角色冲突? -4. 如何结尾留下悬念? +现在要为第 {novel_number} 章进行大纲构思。 +本章标题:{chapter_title} +简述(若有):{chapter_brief} -直接用1、2、3、4分点说明即可。 +请围绕本章标题与简述,设计一个详细大纲: +1. 本章的主要冲突或事件?如何与标题呼应? +2. 哪些角色会出现?他们在此章的目标与动机是否有所变化? +3. 如何推动或暗示已存在的暗线、角色冲突或新的悬念? +4. 在结尾留下什么悬念或转折?(与本章标题或简述形成呼应或对比) + +请直接用 1、2、3、4 分点说明大纲要点即可。 """ chapter_write_prompt = """\ @@ -123,10 +130,14 @@ chapter_write_prompt = """\ 3. 全局摘要:{global_summary} 4. 本章大纲:{chapter_outline} +本章标题:{chapter_title} +简述:{chapter_brief} + 请写出本章节的完整正文: 1. 确保本章字数不少于 {word_number} 字。 -2. 不要使用分节标题,直接整体输出正文。 -3. 可以着重描写人物心理、环境氛围等,以保证足够长度。 -4. 在结尾部分保留一定悬念或剧情转折,为下一章做铺垫。 +2. 内容需与标题“{chapter_title}”相呼应,并尽量呼应简述中的核心要点。 +3. 不要使用分节标题,直接整体输出正文。 +4. 可以着重描写人物心理、环境氛围,以保证足够长度。 +5. 在结尾部分保留一定悬念或剧情转折,为下一章做铺垫。 """ diff --git a/ui.py b/ui.py index aa8199c..1abef20 100644 --- a/ui.py +++ b/ui.py @@ -21,7 +21,7 @@ from consistency_checker import check_consistency class NovelGeneratorGUI: def __init__(self, master): self.master = master - self.master.title("Novel Generator GUI - Advanced") + self.master.title("Novel Generator GUI") # 配置持久化 self.config_file = "config.json" @@ -93,7 +93,7 @@ class NovelGeneratorGUI: self.temperature_var = tk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) self.temp_value_label = ttk.Label(self.right_frame, text=f"{self.temperature_var.get():.2f}") self.temp_value_label.grid(row=3, column=2, padx=5, pady=5, sticky="w") - + temp_scale = ttk.Scale(self.right_frame, from_=0.0, to=1.0, orient=tk.HORIZONTAL, variable=self.temperature_var) temp_scale.grid(row=3, column=1, padx=5, pady=5, sticky="we") def update_temp_label(*args): @@ -299,6 +299,7 @@ class NovelGeneratorGUI: # 获取最近3章文本,生成短期摘要 chapters_dir = os.path.join(filepath, "chapters") recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) + # 用当前模型生成一个较为详细的最近剧情摘要 model_obj = self.get_llm_model(model_name, api_key, base_url, temperature) recent_chapters_summary = summarize_recent_chapters(model_obj, recent_3_texts) @@ -457,7 +458,6 @@ class NovelGeneratorGUI: 清空向量库按钮:弹出二次确认,若确认则执行 clear_vector_store()。 """ def confirmed_clear(): - # 再次确认 second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") if second_confirm: clear_vector_store() From 2a5602009be98f2292e1df1c0e0f869fdb66c564 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sat, 1 Feb 2025 00:23:00 +0800 Subject: [PATCH 019/142] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=89=93=E5=8C=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E6=96=87=E4=BB=B6=E3=80=81README=E3=80=81req?= =?UTF-8?q?uirements.txt=EF=BC=9B=E7=95=A5=E5=BE=AE=E6=94=B9=E5=8A=A8UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 264 +++++++++++++++++++++++---------------- main.spec | 55 ++++---- novel_generator.py | 15 ++- prompt_definitions.py | 2 +- requirements-default.txt | Bin 0 -> 392 bytes ui.py | 12 +- 6 files changed, 198 insertions(+), 150 deletions(-) create mode 100644 requirements-default.txt diff --git a/README.md b/README.md index a85c16f..fc85b5f 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,85 @@ -**自动小说生成工具** -**核心功能:** +# **自动小说生成工具** -✅ **小说设定**(世界观、人物、剧情结构) -✅ **章节生成**(多步生成,确保逻辑连贯) -✅ **角色状态 & 伏笔管理**(追踪人物发展) -✅ **向量检索**(保证长篇小说上下文一致) -✅ **自定义知识库**(上传本地参考文档) -✅ **一致性检查**(防止剧情冲突) -✅ **GUI 友好交互**(可配置 & 直观操作) +> 一款基于大模型的多功能小说生成器,帮助你快速生成连贯、可控、可审校的长篇故事。 -# **部署与使用指南** +
-## **1. 环境要求** -在开始之前,请确保你的系统满足以下要求: -- **Python 3.8+** -- **pip 已安装**(Python 包管理器) -- **API 访问权限**(支持OpenAI API方式的任何AI,中文推荐使用DeepSeek) +| 功能点 | 描述 | +|-------|-----| +| 🚀 **小说设定** | 世界观 / 人物 / 剧情结构 | +| 📝 **章节生成** | 多步生成,确保逻辑连贯 | +| 👥 **角色状态 & 伏笔管理** | 追踪人物发展、重要事件 | +| 🔍 **向量检索** | 保证长篇小说上下文一致 | +| 📁 **自定义知识库** | 可上传本地参考文档 | +| ✅ **一致性检查** | 自动识别剧情冲突或矛盾 | +| 🖥 **GUI 友好交互** | 所见即所得,配置 & 操作简便 | + +
--- -## **2. 安装依赖** - -**进入项目目录,执行**: -```bash -pip install -r requirements.txt -``` -### 安装语句切分模型punkt(可选,默认程序运行后会自动加载) -**Python环境终端输入**: -```bash -python -import nltk -nltk.download('punkt') -``` - -等待下载完成(很小,下载很快的) - -**至此,环境配置完成。** +## **目录** +1. [环境要求](#环境要求) +2. [安装依赖](#安装依赖) +3. [项目结构](#项目结构) +4. [配置 API Key](#配置-api-key) +5. [运行程序](#运行程序) +6. [使用指南](#使用指南) +7. [生成文件管理](#生成文件管理) +8. [常见问题](#常见问题) --- -## **3. 项目结构** -克隆或下载本项目后,你会看到如下结构: -```plaintext +## **环境要求** +在开始之前,请确保你的系统满足以下条件: +- 推荐**Python 3.10+** +- 已安装 **pip**(Python 包管理器) +- 拥有 **API Key**(如 OpenAI 或 DeepSeek)或 **支持OpenAI调用方式的本地接口** + +--- + +## **安装依赖** +1. 在项目根目录下,打开终端或命令行 +2. 执行以下命令安装所有依赖: + ```bash + pip install -r requirements.txt + ``` +3. (可选)若需要手动安装 `nltk` 的 `punkt` 数据包: + ```bash + python + >>> import nltk + >>> nltk.download('punkt') + ``` + +--- + +## **项目结构** +克隆或下载本项目后,你将看到如下目录结构: + +``` . -├── main.py # 入口文件,运行 GUI -├── ui.py # 图形界面 -├── novel_generator.py # 章节生成核心逻辑 -├── consistency_checker.py # 一致性检查 (防止剧情冲突) -├── prompt_definitions.py # 预定义的 AI 提示词 -├── utils.py # 通用工具函数 (文件操作) -├── config_manager.py # 处理配置信息 (API Key, Base URL) -├── config.json # 用户配置文件 (可选) -└── vectorstore/ # (可选) 存储向量数据库 +├── main.py # 入口文件, 运行 GUI +├── ui.py # 图形界面 +├── novel_generator.py # 章节生成核心逻辑 +├── consistency_checker.py # 一致性检查, 防止剧情冲突 +|—— chapter_directory_parser.py #格式化目录 +├── prompt_definitions.py # 定义 AI 提示词 +├── utils.py # 常用工具函数, 文件操作 +├── config_manager.py # 管理配置 (API Key, Base URL) +├── config.json # 用户配置文件 (可选) +└── vectorstore/ # (可选) 本地向量数据库存储 ``` +> `vectorstore/` 文件夹将在程序运行后自动生成,存储向量检索的缓存数据。 + --- -## **4. 配置 API Key** -运行前,你需要**配置 API Key** 以便调用 OpenAI 或本地 LLM。 +## **配置 API Key** +你可以通过以下方式指定或修改 API Key: -### **方式 1:手动修改 `config.json`** -在 `config.json` 文件中填入: +### 方式 1:**修改 `config.json`** +打开 `config.json`,将对应字段替换为你的配置: ```json { "api_key": "your_openai_api_key", @@ -75,93 +93,119 @@ nltk.download('punkt') } ``` -### **方式 2:通过 GUI 直接输入** -- 启动程序后,在 GUI 中输入 `API Key` 并选择 `Base URL`,然后**点击 "保存配置"** 以存储到 `config.json`。 +### 方式 2:**通过 GUI 输入** +1. 运行 `main.py` 后会弹出图形界面 +2. 在界面输入 `API Key`、`Base URL`、模型名称、Temperature 等 +3. 点击 **“保存配置”** 按钮,自动生成或更新 `config.json` --- -## **5. 运行程序** -### **方式 1:使用 `Python` 运行** +## **运行程序** + +### **方式 1:使用 Python 解释器** ```bash python main.py ``` -程序启动后,你会看到一个图形界面,方便用户交互。 +执行后,GUI 将会启动,你可以在图形界面中进行各项操作。 + +### **方式 2:打包为可执行文件** +如果你想在无 Python 环境的机器上使用本工具,可以使用 **PyInstaller** 进行打包: -### **方式 2:打包成可执行文件** -如果你希望**打包成可执行文件**(避免 Python 依赖),可以使用 `PyInstaller`: ```bash pip install pyinstaller -pyinstaller --onefile --windowed main.py -``` -这样会在 `dist/` 目录下生成 `main.exe`(Windows)或 `main`(Linux/macOS)。 - -或者使用提供的`main.spec`,执行以下打包指令: -```bash pyinstaller main.spec ``` +打包完成后,会在 `dist/` 目录下生成可执行文件(如 Windows 下的 `main.exe`)。 --- -## **6. 使用指南** -### **步骤 1:设置小说参数** -在 GUI 界面: -1. **输入 API Key & Base URL**(或使用 `config.json`)。 -2. **选择模型**(如 `gpt-4o`)。 -3. **输入小说主题**(如 "未来世界中的 AI 革命")。 -4. **选择小说类型**(如 "科幻"、"奇幻"、"悬疑")。 -5. **设置章节数和每章字数**(如 10 章,每章 3000 字)。 -6. **选择存储路径**(建议创建 `novels/` 目录)。 +## **使用指南** -### **步骤 2:生成小说设定 & 目录** -点击 **"1. 生成设定 & 目录"**,系统将: -- 生成**世界观设定**(`Novel_setting.txt`)。 -- 生成**章节目录**(`Novel_directory.txt`)。 +1. **启动后,先完成基本参数设置:** + - **API Key & Base URL**(如 `https://api.openai.com/v1`) + - **模型名称**(如 `gpt-3.5-turbo`、`gpt-4o` 等) + - **Temperature**(0~1,决定文字创意程度) + - **主题(Topic)**(如 “废土世界的 AI 叛乱”) + - **类型(Genre)**(如 “科幻”/“魔幻”/“都市幻想”) + - **章节数**、**每章字数**(如 10 章,每章约 3000 字) + - **保存路径**(建议创建一个新的输出文件夹) -### **步骤 3:生成章节** -点击 **"2. 生成单章"**,系统将: -- 读取**上一章节内容**(`lastchapter.txt`)。 -- 通过**向量检索**查找相关背景信息。 -- **动态调整角色状态**(`character_state.txt`)。 -- **生成完整章节**,并保存到 `chapters/chapter_X.txt`。 +2. **点击「1. 生成设定 & 目录」** + - 系统将基于主题、类型等生成: + - `Novel_setting.txt`:**世界观 & 整体设定** + - `Novel_directory.txt`:**章节目录**(含标题、简要提示) + - 可以在生成后的文件中查看、修改或补充世界观设定和目录标题。 -### **步骤 4:一致性检查(可选)** -点击 **"3. 一致性审校"**,系统将: -- 检查**角色行为、剧情逻辑**是否前后矛盾。 -- 识别是否有**未解伏笔**,保证故事合理性。 +3. **点击「2. 生成章节草稿」** + - 在生成章节之前,你可以: + - **设置章节号**(如要写第 1 章,就填 `1`) + - **在“本章指导”输入框**中,填写对本章剧情的任何期望或指导 + - 点击按钮后,系统将: + - 自动读取前文与 `Novel_directory.txt` 的标题与简述 + - 调用向量检索回顾剧情,确保上下文连贯 + - 生成本章大纲 (`outline_X.txt`) 及正文 (`chapter_X.txt`) + - 生成完成后,可在左侧查看本章草稿内容。 + +4. **手动检查 & 编辑**(可选) + - 你可以在文本编辑器中修改 `chapter_X.txt` 的内容,使之更符合个人审美或剧情要求。 + +5. **点击「3. 定稿当前章节」** + - 系统将: + - **更新全局摘要**(`global_summary.txt`) + - **更新角色状态**(`character_state.txt`,包含人物发展、物品变动、剧情线索等) + - **更新向量检索库**,保证后续章节能够调用最新信息。 + +6. **一致性检查(可选)** + - 点击「4. 一致性审校」进行冲突检测,比如**角色逻辑、剧情前后矛盾**等。 + - 若有冲突,会在日志区输出详细说明。 + +7. **重复第 3~5 步** 直到所有章节完成! --- -## **7. 生成文件管理** -所有生成的文件存储在你选择的目录下: -```plaintext +## **生成文件管理** + +在你指定的输出文件夹中,程序会自动生成并维护以下文件/目录: + +``` output_directory/ -├── Novel_setting.txt # 小说世界观 & 角色设定 -├── Novel_directory.txt # 章节目录 -├── lastchapter.txt # 最新章节 (供 AI 参考) -├── character_state.txt # 角色状态 (道具、情感、技能) -├── global_summary.txt # 小说摘要 -└── chapters/ # 所有章节 - ├── chapter_1.txt - ├── chapter_2.txt - ├── chapter_3.txt - └── ... +├── Novel_setting.txt # 世界观 & 整体设定 +├── Novel_directory.txt # 小说章节目录 +├── character_state.txt # 角色状态/物品/伏笔等追踪 +├── global_summary.txt # 整体剧情摘要 (用于后续上下文) +├── outlines/ # 存放各章大纲 (outline_1.txt 等) +├── chapters/ # 存放每一章成稿 (chapter_1.txt 等) +└── vectorstore/ # 向量检索数据库 (可清空重置) ``` +- **`character_state.txt`**:记录角色的动机、能力、持有物品等关键信息 +- **`global_summary.txt`**:每次定稿后都会更新,保证剧情可被后续章节引用 +- **`chapters/`**:每一章的正文会独立保存,方便你随时手动修改 + --- -## **8. 可能遇到的问题** -### **1. `Chroma' object has no attribute 'persist'`** -**原因:** `Chroma` 版本问题。 -**解决方案:** -```bash -pip uninstall chromadb -pip install chromadb==0.3.21 # 或尝试其他版本 -``` -如果仍然报错,可以在 `novel_generator.py` 里**注释 `store.persist()`**。 +## **常见问题** + +#### 1. **`Chroma' object has no attribute 'persist'` 错误** + +- **原因**:Chroma 库版本不匹配 +- **解决办法**: + ```bash + pip uninstall chromadb + pip install chromadb==0.3.21 + ``` + 或者在 `novel_generator.py` 中找到 `store.persist()` 并注释掉,如果你的版本不支持此方法。 --- -### **2. 生成内容不符合预期** -**可能的原因:** -- 主题不够清晰,可以在 `topic` 字段中添加详细设定(如 `“废土世界 + AI 叛乱”`)。 -- 角色设定较少,可手动在 `Novel_setting.txt` 里补充。 +#### 2. **生成内容与预期不符** + +- **可能原因**: + 1. 主题或类型过于宽泛,模型难以把握重点 + 2. 角色和世界观设定不够详细,可在 `Novel_setting.txt` 中增补更多细节 +- **建议**: + - 在 GUI 的“本章指导”输入框中填写更明确的剧情走向或重点描述,让模型更好地理解和发挥 + +--- + +若你还有其他问题或需求,欢迎在项目 Issues 中提出。 \ No newline at end of file diff --git a/main.spec b/main.spec index 63fdbfd..93ae127 100644 --- a/main.spec +++ b/main.spec @@ -1,34 +1,31 @@ # -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import collect_all + +datas = [] +binaries = [] +hiddenimports = ['typing_extensions', + 'langchain-openai', + 'langgraph', + 'openai', + 'nltk', + 'sentence_transformers', + 'scikit-learn', + 'langchain-community', + 'pydantic', + 'pydantic.deprecated.decorator', + 'tiktoken_ext.openai_public', + 'tiktoken_ext' + ] +tmp_ret = collect_all('chromadb') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + a = Analysis( ['main.py'], pathex=[], - binaries=[], - datas=[ - ('vectorstore', 'vectorstore') - ], - hiddenimports=['typing_extensions', - 'langchain-openai', - 'langgraph', - 'openai', - 'chromadb', - 'nltk', - 'sentence_transformers', - 'scikit-learn', - 'langchain-community', - 'pydantic', - 'pydantic.deprecated.decorator', - *collect_submodules('chromadb'), - 'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2', - 'chromadb.telemetry.product.posthog', - 'chromadb.api.segment', - 'chromadb.db.impl', - 'chromadb.db.impl.sqlite', - 'chromadb.migrations', - 'chromadb.migrations.embeddings_queue' - - ], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -43,8 +40,8 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.1', - debug=False, + name='AI_NovelGenerator_V1.2.3', + debug=True, bootloader_ignore_signals=False, strip=False, upx=True, @@ -63,5 +60,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.1', + name='AI_NovelGenerator_V1.2.3' ) diff --git a/novel_generator.py b/novel_generator.py index 898f8fd..dd02e31 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -42,20 +42,27 @@ def debug_log(prompt: str, response_content: str): logging.info(f"[Response >>>] {response_content}\n") # ============ 向量检索相关 ============ -VECTOR_STORE_DIR = "vectorstore" +VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") +if not os.path.exists(VECTOR_STORE_DIR): + os.makedirs(VECTOR_STORE_DIR) def clear_vector_store(): """ - 清空本地向量库(删除 vectorstore 文件夹)。 + 清空本地向量库(删除 vectorstore 文件夹内的内容)。 需要在UI中加一个二次确认弹窗,防止误删。 """ if os.path.exists(VECTOR_STORE_DIR): try: import shutil - shutil.rmtree(VECTOR_STORE_DIR) + for filename in os.listdir(VECTOR_STORE_DIR): + file_path = os.path.join(VECTOR_STORE_DIR, filename) + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) logging.info("Local vector store has been cleared.") except Exception as e: - logging.warning(f"Failed to remove vector store: {e}") + logging.warning(f"Failed to clear vector store: {e}") else: logging.info("No vector store found to clear.") diff --git a/prompt_definitions.py b/prompt_definitions.py index f9ee96b..00a8366 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -67,7 +67,7 @@ novel_directory_prompt = """\ 第{number_of_chapters}章 :< text > 请严格按照上述格式输出每一章的名称,最好在重要情节标题后增加提示性简述, -若要加更详细的简述,用“ - ”分隔,如“第2章 :阴影浮现 - 主要角色冲突爆发”。 +若要加更详细的简述,用“ - ”分隔,举例如“第n章 :< text > - 主要角色冲突爆发,角色A发生xx意外”。 请直接输出,不要使用Markdown语法。 """ diff --git a/requirements-default.txt b/requirements-default.txt new file mode 100644 index 0000000000000000000000000000000000000000..d4ff65e5770ec8914ecf9b70f6b97834685eaed8 GIT binary patch literal 392 zcmZ{g-3o$06oltG=ur%RBJiST5i`@Jbj3BOm#=1yC=`UK>+XCrb9}!lv>4&hp~M*{ z9C^jq;lMk?78~4n(xZWtB^=ig*CIOH`Q Date: Sat, 1 Feb 2025 10:20:40 +0800 Subject: [PATCH 020/142] =?UTF-8?q?#13=20=E4=BF=AE=E6=AD=A3=E8=AF=A5?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- novel_generator.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fc85b5f..41ec5bd 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ ```bash pip install -r requirements.txt ``` -3. (可选)若需要手动安装 `nltk` 的 `punkt` 数据包: +3. (可选)若需要手动安装 `nltk` 的 `punkt_tab` 数据包: ```bash python >>> import nltk - >>> nltk.download('punkt') + >>> nltk.download('punkt_tab') ``` --- diff --git a/novel_generator.py b/novel_generator.py index dd02e31..779b509 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -583,7 +583,7 @@ def advanced_split_content(content: str, """ 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 """ - nltk.download('punkt', quiet=True) # 确保 punkt 数据可用 + nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(content) if not sentences: From eb627fbec9d4c247d5949fe5d75e0d64c7024fc0 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sat, 1 Feb 2025 18:49:52 +0800 Subject: [PATCH 021/142] =?UTF-8?q?=E5=B0=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + consistency_checker.py | 11 ++++- novel_generator.py | 95 ++++++++++++++++++++++++++++++------------ prompt_definitions.py | 4 -- ui.py | 30 ++++++++++++- 5 files changed, 107 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 1780e96..6d519ce 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ /dist /.vscode /__pycache__ +/markdown /vectorstore config.json diff --git a/consistency_checker.py b/consistency_checker.py index cf34349..7e6abe3 100644 --- a/consistency_checker.py +++ b/consistency_checker.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- from langchain_openai import ChatOpenAI +# ============== 增加对“剧情要点/未解决冲突”进行检查的可选引导 ============== CONSISTENCY_PROMPT = """\ 请检查下面的小说设定与最新章节是否存在明显冲突或不一致之处,如有请列出: - 小说设定: @@ -13,10 +14,13 @@ CONSISTENCY_PROMPT = """\ - 全局摘要: {global_summary} +- 已记录的未解决冲突或剧情要点: +{plot_arcs} # 若为空可能不输出 + - 最新章节内容: {chapter_text} -如果存在冲突或不一致,请说明;否则请返回“无明显冲突”。 +如果存在冲突或不一致,请说明;如果在未解决冲突中有被忽略或需要推进的地方,也请提及;否则请返回“无明显冲突”。 """ def check_consistency( @@ -27,15 +31,18 @@ def check_consistency( api_key: str, base_url: str, model_name: str, - temperature: float = 0.3 + temperature: float = 0.3, + plot_arcs: str = "" # 新增参数,默认空字符串 ) -> str: """ 调用模型做简单的一致性检查。可扩展更多提示或校验规则。 + 新增: 会额外检查对“未解决冲突或剧情要点”(plot_arcs)的衔接情况。 """ prompt = CONSISTENCY_PROMPT.format( novel_setting=novel_setting, character_state=character_state, global_summary=global_summary, + plot_arcs=plot_arcs, chapter_text=chapter_text ) model = ChatOpenAI( diff --git a/novel_generator.py b/novel_generator.py index 779b509..a5d62bf 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -36,10 +36,12 @@ from chapter_directory_parser import get_chapter_info_from_directory # ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + def debug_log(prompt: str, response_content: str): - """在控制台打印或记录下每次Prompt与Response,[调试]""" - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") + """在控制台打印或记录下每次Prompt与Response,[调试]""" + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") + # ============ 向量检索相关 ============ VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") @@ -156,8 +158,6 @@ def Novel_novel_directory_generate( temperature=temperature ) - - def generate_base_setting(state: OverallState) -> Dict[str, str]: prompt = set_prompt.format( topic=state["topic"], @@ -271,7 +271,7 @@ def Novel_novel_directory_generate( logging.info("Novel settings and directory generated successfully.") -# ============ 新增:获取最近N章内容,生成短期摘要 ============ +# ============ 获取最近N章内容,生成短期摘要 ============ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: """ @@ -295,9 +295,7 @@ def summarize_recent_chapters(model: ChatOpenAI, chapters_text_list: List[str]) if not chapters_text_list: return "" - # 拼接这几章的内容 combined_text = "\n".join(chapters_text_list) - # 在这里可以写一个更详细的提示 prompt = f"""\ 这是最近几章的故事内容,请生成一份详细的短期内容摘要(不少于一章篇幅的细节),用于帮助后续创作时回顾细节。 请着重强调发生的事件、角色的心理和关系变化、冲突或悬念等。 @@ -310,6 +308,49 @@ def summarize_recent_chapters(model: ChatOpenAI, chapters_text_list: List[str]) debug_log(prompt, response.content) return response.content.strip() +# ============ 新增1:记录剧情要点/未解决冲突 ============ + +PLOT_ARCS_PROMPT = """\ +下面是新生成的章节内容: +{chapter_text} + +这里是已记录的剧情要点/未解决冲突(可能为空): +{old_plot_arcs} + +请基于新的章节内容,提炼出本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 +若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 +最终输出一份更新后的剧情要点列表,以帮助后续保持故事的整体一致性和悬念延续。 +""" + +def update_plot_arcs( + chapter_text: str, + old_plot_arcs: str, + api_key: str, + base_url: str, + model_name: str, + temperature: float +) -> str: + """ + 利用模型分析最新章节文本,提炼或更新“未解决冲突或剧情要点”。 + 并返回更新后的字符串。 + """ + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature + ) + prompt = PLOT_ARCS_PROMPT.format( + chapter_text=chapter_text, + old_plot_arcs=old_plot_arcs + ) + response = model.invoke(prompt) + if not response: + logging.warning("update_plot_arcs: No response.") + return old_plot_arcs + debug_log(prompt, response.content) + return response.content.strip() + # ============ 生成章节草稿 & 定稿 ============ def generate_chapter_draft( @@ -331,9 +372,7 @@ def generate_chapter_draft( 仅生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 并将生成的内容写到 "chapter_{novel_number}.txt" 覆盖写入。 同时生成 "outline_{novel_number}.txt" 存储大纲内容。 - recent_chapters_summary: 最近 3 章的“短期内容摘要” """ - # 0) 根据 novel_number 从 novel_novel_directory 中获取本章标题及简述 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] @@ -352,7 +391,6 @@ def generate_chapter_draft( temperature=temperature ) - # Prompt 拼接 outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, @@ -362,7 +400,6 @@ def generate_chapter_draft( chapter_brief=chapter_brief ) - # 在后面加上用户指导与最近章节摘要(可根据需要灵活组织) outline_prompt_text += f"\n\n【本章目录标题与简述】\n标题:{chapter_title}\n简述:{chapter_brief}\n" outline_prompt_text += f"\n【最近几章摘要】\n{recent_chapters_summary}" outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" @@ -375,7 +412,6 @@ def generate_chapter_draft( debug_log(outline_prompt_text, response_outline.content) chapter_outline = response_outline.content.strip() - # 将大纲写到 outline_{novel_number}.txt outlines_dir = os.path.join(filepath, "outlines") os.makedirs(outlines_dir, exist_ok=True) outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") @@ -393,7 +429,6 @@ def generate_chapter_draft( chapter_brief=chapter_brief ) - # 同样插入用户指导和最近摘要 writing_prompt_text += f"\n\n【本章目录标题与简述】\n标题:{chapter_title}\n简述:{chapter_brief}\n" writing_prompt_text += f"\n【最近几章摘要】\n{recent_chapters_summary}" writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" @@ -406,7 +441,6 @@ def generate_chapter_draft( debug_log(writing_prompt_text, response_chapter.content) chapter_content = response_chapter.content.strip() - # 4) 覆盖写到 chapter_{novel_number}.txt chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") @@ -430,7 +464,8 @@ def finalize_chapter( 1. 读取 chapter_{novel_number}.txt 的最终内容; 2. 更新全局摘要、角色状态文件; 3. 如果字数明显少于 word_number 的 80%,则自动调用 enrich_chapter_text 再次扩写; - 4. 更新向量库。 + 4. 更新向量库; + 5. 新增:更新剧情要点/未解决冲突 -> plot_arcs.txt """ # 读取当前章节内容 chapters_dir = os.path.join(filepath, "chapters") @@ -440,12 +475,14 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 读取角色状态 & 全局摘要 + # 读取角色状态 & 全局摘要 & 剧情要点 character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") # 新增文件 old_char_state = read_file(character_state_file) old_global_summary = read_file(global_summary_file) + old_plot_arcs = read_file(plot_arcs_file) # 1) 先检查字数是否过少,若少于 80% 则调用 enrich 逻辑 if len(chapter_text) < 0.8 * word_number: @@ -500,17 +537,30 @@ def finalize_chapter( new_char_state = update_character_state(chapter_text, old_char_state) - # 4) 覆盖写入角色状态文件与全局摘要文件 + # ============ 新增2: 更新剧情要点 ============= + new_plot_arcs = update_plot_arcs( + chapter_text=chapter_text, + old_plot_arcs=old_plot_arcs, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature + ) + + # 4) 覆盖写入角色状态文件、全局摘要文件、剧情要点文件 clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) + clear_file_content(plot_arcs_file) + save_string_to_txt(new_plot_arcs, plot_arcs_file) + # 5) 更新向量检索库 update_vector_store(api_key, base_url, chapter_text) - logging.info(f"Chapter {novel_number} has been finalized (summary & state updated, vector store updated).") + logging.info(f"Chapter {novel_number} has been finalized (summary & state updated, plot arcs updated, vector store updated).") def enrich_chapter_text( chapter_text: str, @@ -549,29 +599,23 @@ def import_knowledge_file(api_key: str, base_url: str, file_path: str) -> None: """ 将用户选定的文本文件导入到向量库,以便在写作时检索。 """ - - # 1. 检查文件路径是否有效 if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return - # 2. 读取文件内容 content = read_file(file_path) if not content.strip(): logging.warning("知识库文件内容为空。") return - # 3. 对内容进行高级切分处理 paragraphs = advanced_split_content(content) - # 4. 加载或初始化向量存储 store = load_vector_store(api_key, base_url) if not store: logging.info("Vector store does not exist. Initializing a new one for knowledge import...") init_vector_store(api_key, base_url, paragraphs) return - # 5. 创建Document对象并更新到向量库 docs = [Document(page_content=p) for p in paragraphs] store.add_documents(docs) store.persist() @@ -609,7 +653,6 @@ def advanced_split_content(content: str, if current_sentences: merged_paragraphs.append(" ".join(current_sentences)) - # 按最大长度二次拆分 final_segments = [] for para in merged_paragraphs: if len(para) > max_length: diff --git a/prompt_definitions.py b/prompt_definitions.py index 00a8366..b2859f8 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -5,7 +5,6 @@ """ # =============== 提示词:设定 & 目录 =================== - set_prompt = """\ 请根据主题:{topic}、类型:{genre}、章数:{number_of_chapters}、每章字数:{word_number}来完善小说整体设定。 需要包含以下信息: @@ -72,7 +71,6 @@ novel_directory_prompt = """\ """ # =============== 提示词:章节+角色状态流程 =================== - summary_prompt = """\ 这是新生成的章节文本: {chapter_text} @@ -102,8 +100,6 @@ update_character_state_prompt = """\ 使用简洁、易读的方式描述,可用条目或段落表示。保持与旧文档风格一致。 """ -# ------------------ 新增占位符:chapter_title, chapter_brief ------------------ - chapter_outline_prompt = """\ 以下是当前小说设定与角色状态信息: - 小说设定:{novel_setting} diff --git a/ui.py b/ui.py index fd75dbf..d5d79ca 100644 --- a/ui.py +++ b/ui.py @@ -172,6 +172,11 @@ class NovelGeneratorGUI: self.btn_clear_vectorstore = ttk.Button(self.right_frame, text="清空向量库", command=self.clear_vectorstore_handler) self.btn_clear_vectorstore.grid(row=row_base+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + # (7) 查看剧情要点 + ttk.Button(self.right_frame, text="[查看] 剧情要点", command=self.show_plot_arcs_ui).grid( + row=row_base+6, column=0, columnspan=2, padx=5, pady=5, sticky="ew" + ) + # -------------- 配置管理 -------------- def load_config_btn(self): cfg = load_config(self.config_file) @@ -360,7 +365,7 @@ class NovelGeneratorGUI: temperature=temperature, filepath=filepath ) - self.log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") + self.log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") # 读取定稿后的文本显示 chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") @@ -392,10 +397,12 @@ class NovelGeneratorGUI: novel_settings_file = os.path.join(filepath, "Novel_setting.txt") character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") # 新增 novel_setting = read_file(novel_settings_file) character_state = read_file(character_state_file) global_summary = read_file(global_summary_file) + plot_arcs = read_file(plot_arcs_file) # 新增 # 获取当前章节文本 chap_num = self.chapter_num_var.get() @@ -415,7 +422,8 @@ class NovelGeneratorGUI: api_key=api_key, base_url=base_url, model_name=model_name, - temperature=temperature + temperature=temperature, + plot_arcs=plot_arcs # 新增传入 ) self.log("审校结果:") self.log(result) @@ -467,6 +475,24 @@ class NovelGeneratorGUI: if first_confirm: confirmed_clear() + # =========== 新增:在 UI 中查看当前剧情要点 ============= + def show_plot_arcs_ui(self): + filepath = self.filepath_var.get().strip() + 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 = tk.Toplevel(self.master) + top.title("剧情要点/未解决冲突") + text_area = scrolledtext.ScrolledText(top, width=60, height=20) + text_area.pack(fill="both", expand=True) + text_area.insert(tk.END, arcs_text) + text_area.config(state=tk.DISABLED) + def get_llm_model(self, model_name, api_key, base_url, temperature): from langchain_openai import ChatOpenAI return ChatOpenAI( From bcd13b8ca388d49973f865d33d3fe6e5bd90c339 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 11:55:49 +0800 Subject: [PATCH 022/142] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=80=E4=B8=8B?= =?UTF-8?q?=EF=BC=8C=E4=BE=BF=E4=BA=8E=E5=BE=85=E4=BC=9A=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Src/Novel_directory.txt | 101 +++++++ Src/Novel_setting.txt | 33 +++ embedding_ollama.py | 45 +++ main.spec | 4 +- novel_generator.py | 53 ++-- ui.py | 621 +++++++++++++++++++++++++++++----------- 6 files changed, 664 insertions(+), 193 deletions(-) create mode 100644 Src/Novel_directory.txt create mode 100644 Src/Novel_setting.txt create mode 100644 embedding_ollama.py diff --git a/Src/Novel_directory.txt b/Src/Novel_directory.txt new file mode 100644 index 0000000..bdbd913 --- /dev/null +++ b/Src/Novel_directory.txt @@ -0,0 +1,101 @@ + +第1章 :星河坠落提瓦特 - 穿越与初遇派蒙,危机初现端倪 +第2章 :蒙德的初印象 - 初探蒙德城,与丽莎的首次相遇 +第3章 :酒馆的闲谈 - 了解提瓦特局势,派蒙展现惊人一面 +第4章 :丽莎的试探 - 星河展现科技实力,引来丽莎的警惕 +第5章 :委托与冒险 - 接受委托,初次展现星穹铁道科技的运用 +第6章 :风魔龙的挑战 - 运用科技对抗风魔龙,展现策略与智慧 +第7章 :派蒙的异常 - 派蒙出现记忆碎片,暗示其不寻常身份 +第8章 :丽莎的秘密魔法 - 丽莎使用禁忌魔法,展现其神秘一面 +第9章 :旅行者的出现 - 与原神旅行者初次相遇,产生摩擦与合作 +第10章 :蒙德的阴谋 - 发现蒙德隐藏的危机,与丽莎深入合作 +第11章 :追捕令的阴影 - 星河收到星穹铁道追捕令,内心挣扎 +第12章 :深入调查 - 调查蒙德的危机,发现与丽莎的秘密组织有关 +第13章 :钟离的现身 - 钟离初次出现,对星河进行暗示与引导 +第14章 :失落科技的线索 - 发现关于提瓦特失落科技的线索 +第15章 :前往璃月 - 踏上前往璃月的旅程,面对新的挑战 +第16章 :璃月港的繁华 - 体验璃月港的文化,了解璃月局势 +第17章 :往生堂的委托 - 接受往生堂的委托,深入了解璃月历史 +第18章 :岩王帝君的考验 - 钟离对星河进行考验,揭示其真实目的 +第19章 :地脉异常 - 发现璃月地脉异常,与旅行者合作调查 +第20章 :黄金屋的秘密 - 探索黄金屋的秘密,发现与失落科技有关 +第21章 :刻晴的质疑 - 刻晴对星河的科技持有怀疑态度 +第22章 :对抗岩龙蜥 - 运用科技与策略对抗强大的岩龙蜥 +第23章 :派蒙的回忆 - 派蒙回忆片段增多,身份逐渐清晰 +第24章 :丽莎的引导 - 丽莎引导星河学习提瓦特魔法 +第25章 :星河的抉择 - 星河面临道德困境,做出艰难选择 +第26章 :前往稻妻 - 前往稻妻,面对新的挑战与危机 +第27章 :稻妻的雷电 - 体验稻妻的文化与习俗,对抗雷电将军的威压 +第28章 :八重神子的试探 - 八重神子对星河进行试探,考验其能力 +第29章 :影向地的秘密 - 探索影向地的秘密,发现与提瓦特历史有关 +第30章 :反抗军与合作 - 与稻妻反抗军合作,对抗幕后黑手 +第31章 :永恒的执政官 - 与雷电将军正面交锋,展现智慧与实力 +第32章 :派蒙的过去 - 派蒙的过去逐渐揭开,与提瓦特古老文明有关 +第33章 :丽莎的组织 - 丽莎所属组织的真相逐渐浮出水面 +第34章 :前往须弥 - 前往须弥,面对新的挑战与文化 +第35章 :须弥的智慧 - 体验须弥的文化,了解须弥的智慧与科技 +第36章 :草神的大智慧 - 与草神进行交流,探讨生命的意义 +第37章 :森林的危机 - 须弥森林面临危机,星河运用科技解决问题 +第38章 :沙漠的秘密 - 探索须弥沙漠的秘密,发现远古文明的遗迹 +第39章 :散兵的阴谋 - 散兵的阴谋逐渐浮出水面,星河需要应对 +第40章 :与散兵的对抗 - 与散兵展开对抗,展现星穹铁道科技的优势 +第41章 :前往枫丹 - 前往枫丹,面对新的挑战与科技 +第42章 :枫丹的审判 - 体验枫丹的法律与秩序,面对复杂的案件 +第43章 :水神的神秘 - 与水神进行互动,了解枫丹的秘密 +第44章 :机械岛的危机 - 枫丹机械岛面临危机,星河运用科技解决问题 +第45章 :潜入深海 - 潜入深海,探索深海的秘密与科技 +第46章 :派蒙的预言 - 派蒙预言即将到来的巨大危机 +第47章 :丽莎的抉择 - 丽莎面临艰难的抉择,需要做出牺牲 +第48章 :前往纳塔 - 前往纳塔,面对新的挑战与文化 +第49章 :纳塔的冰雪 - 体验纳塔的严寒环境,与冰雪元素对抗 +第50章 :雪山之巅的秘密 - 探索雪山之巅的秘密,发现远古文明的遗迹 +第51章 :冰神的考验 - 冰神对星河进行考验,考验其心性与毅力 +第52章 :与旅行者的合作 - 与旅行者深入合作,对抗共同的敌人 +第53章 :深渊的威胁 - 深渊的威胁日益逼近,星河需要寻找应对之策 +第54章 :钟离的计划 - 钟离的计划逐渐显露,其真实目的更加清晰 +第55章 :星河的过去 - 星河过去的阴影再次浮现,影响其与他人的关系 +第56章 :派蒙的觉醒 - 派蒙觉醒,展现其强大的力量 +第57章 :丽莎的牺牲 - 丽莎为了保护星河做出牺牲 +第58章 :前往至冬 - 前往至冬,面对新的挑战与阴谋 +第59章 :至冬的阴谋 - 揭露至冬的阴谋,对抗幕后黑手 +第60章 :女皇的算计 - 女皇对星河进行算计,考验其智慧与勇气 +第61章 :机械生命的危机 - 至冬的机械生命失控,引发巨大的危机 +第62章 :星河的策略 - 星河运用策略,化解机械生命的危机 +第63章 :派蒙的秘密力量 - 派蒙展现其强大的秘密力量 +第64章 :丽莎的遗产 - 丽莎留下宝贵的遗产,帮助星河对抗危机 +第65章 :对抗深渊使徒 - 对抗强大的深渊使徒,展现星穹铁道科技的威力 +第66章 :旅行者的选择 - 旅行者面临艰难的选择,需要做出决定 +第67章 :钟离的帮助 - 钟离给予星河关键的帮助,但其目的依旧不明 +第68章 :星河的成长 - 星河在经历种种磨难后得到成长 +第69章 :提瓦特的命运 - 提瓦特的命运掌握在星河手中 +第70章 :最终决战的序幕 - 最终决战的序幕拉开,危机全面爆发 +第71章 :与幕后黑手的对抗 - 与幕后黑手展开激烈的对抗 +第72章 :星河的牺牲 - 星河为了拯救提瓦特做出牺牲 +第73章 :派蒙的付出 - 派蒙为了帮助星河付出一切 +第74章 :丽莎的回归 - 丽莎以另一种方式回归,继续守护蒙德 +第75章 :旅行者的抉择 - 旅行者做出最终的抉择 +第76章 :钟离的真相 - 钟离的真实目的最终揭晓 +第77章 :星河的复活 - 星河以意想不到的方式复活 +第78章 :新的危机 - 新的危机出现,提瓦特面临新的挑战 +第79章 :星河的抉择 - 星河需要做出新的抉择,选择自己的未来 +第80章 :派蒙的未来 - 派蒙的未来充满希望与未知 +第81章 :丽莎的守护 - 丽莎继续守护蒙德,为提瓦特贡献力量 +第82章 :旅行者的旅程 - 旅行者继续自己的旅程,探索提瓦特的奥秘 +第83章 :钟离的隐退 - 钟离选择隐退,默默守护提瓦特 +第84章 :星河的归宿 - 星河思考自己的归宿,是留下还是离开 +第85章 :与伙伴的告别 - 与伙伴们告别,回忆共同经历的冒险 +第86章 :返回星穹铁道? - 星河考虑返回星穹铁道,但心中仍有牵挂 +第87章 :提瓦特的未来 - 提瓦特的未来充满希望与挑战 +第88章 :科技与魔法的融合 - 星穹铁道科技与提瓦特魔法开始融合 +第89章 :新的时代 - 提瓦特进入新的时代,科技与魔法共存 +第90章 :星河的遗产 - 星河留下宝贵的遗产,影响着提瓦特的未来 +第91章 :派蒙的传承 - 派蒙将自己的智慧与经验传承下去 +第92章 :丽莎的教导 - 丽莎继续教导年轻一代,传承魔法的艺术 +第93章 :旅行者的故事 - 旅行者的故事继续,探索更多未知的领域 +第94章 :钟离的箴言 - 钟离留下意味深长的箴言,引导提瓦特的未来 +第95章 :星河的回归 - 星河最终做出决定,选择自己的归宿 +第96章 :告别的时刻 - 与提瓦特的伙伴们告别,心中充满不舍 +第97章 :新的旅程 - 星河踏上新的旅程,迎接新的挑战 +第98章 :回首往昔 - 回首在提瓦特的冒险,感慨万千 +第99章 :未来的希望 - 提瓦特的未来充满希望,光明与挑战并存 +第100章 :星河挽歌 - 故事的结尾,留下无限的遐想,开放式结局 \ No newline at end of file diff --git a/Src/Novel_setting.txt b/Src/Novel_setting.txt new file mode 100644 index 0000000..5767994 --- /dev/null +++ b/Src/Novel_setting.txt @@ -0,0 +1,33 @@ + +小说设定:《星河挽歌:提瓦特星轨》 + +小说类型:玄幻穿越,史诗级冒险,轻松幽默与细腻情感并存,整体偏正剧,但避免过于沉重。 + +字数:约400万字(100章 x 4000字/章) + +视角:第三人称,穿插主角第一人称内心独白。 + +世界观:基于原神世界线平行宇宙的提瓦特大陆,地理环境与原神基本一致,但部分区域势力分布及历史事件有所调整,以适应主角的到来和剧情发展。七国结构保留,但政治格局和社会风貌因主角介入而变。科技与魔法并存,魔法更为显著,主角的星穹铁道科技作为“外来力量”融入其中,引发冲突和变革。重要历史事件保留原神设定,并添加主角引发的新的历史事件。 + +核心内容梗概:主角星河在星穹铁道执行任务时意外穿越到提瓦特大陆,发现大陆面临史无前例的巨大危机。凭借星穹铁道科技力量及经验,星河成为对抗危机的关键人物。在拯救提瓦特过程中,他将与原神角色相遇,建立深厚情谊或产生冲突,最终完成使命,并决定自己的归宿。采用英雄之旅叙事结构,主线为拯救提瓦特,副线为主角与原神角色的情感纠葛。 + +主要角色: + +1. 星河:坚毅、责任感强、略带孤傲,内心温柔渴望归宿。拥有强大的星穹铁道科技力量,但更倾向于智慧和策略解决问题。拥有丰富战斗经验和冷静判断力,但也有过去任务的阴影和对未来命运的迷茫,偶尔冲动,也可能因自身能力产生优越感。他的目标是拯救提瓦特,找到归宿,弥补过去遗憾。他的隐藏秘密是:在星穹铁道的一次任务中,他导致一位重要伙伴牺牲,这个秘密影响着他与他人的关系,并在与原神角色建立深厚感情时成为关键转折点。 + +2. 派蒙:活泼、贪吃、略显成熟,更能理解星河内心世界,给予更深层次的支持。积极参与冒险,但鲁莽会带来麻烦,是重要的喜剧元素。对提瓦特知识渊博,但对星穹铁道科技一无所知。她的目标是帮助星河拯救提瓦特,探索自身存在秘密,与星河建立更深厚羁绊。她的隐藏秘密是:她并非简单的导航精灵,拥有远超常人的智慧和感知能力,与提瓦特大陆的过去有着神秘联系。这会在中段通过关键事件逐步揭示。 她的“记忆碎片”——偶尔失神,喃喃自语,对特定场景反应强烈,甚至出现幻觉,这些会逐渐清晰,暗示她与提瓦特古老文明甚至超越提瓦特的力量有关。 + +3. 丽莎:慵懒、智慧,强大的魔法能力,比原神设定中更成熟、更富有责任感。对星穹铁道科技好奇又警惕,将星河视为竞争对手和值得培养的对象。她会引导主角,但在关键时刻设置考验。她的目标是保护蒙德,培养星河,并寻求突破自身瓶颈。她的隐藏秘密是:她拥有不为人知的秘密身份,与某个神秘组织有关,该组织与即将到来的危机密切相关。她的“禁忌魔法”——偶尔使用威力远超平时能力的魔法,使用后疲惫甚至身体异样,她会刻意隐藏,这与她的神秘组织联系紧密。 + +4. 原神旅行者:与星河互动,产生冲突与合作。两人对提瓦特大陆的理解不同,可能会产生矛盾,但在对抗危机时携手合作。 + +5. 钟离:神秘幕后人物,引导星河完成使命,但目的和动机不明,会带来考验和帮助,也可能成为潜在敌人。他的“真实目的”——对星河的帮助看似无私,但始终保持距离,言行举止充满深意,他的行动与提瓦特危机有关,目的可能并非简单帮助主角,而是为了实现某种更宏大的计划。 + +角色关系与冲突:星河与派蒙(依赖与独立的矛盾),星河与丽莎(竞争与合作),星河与原神旅行者(身份认同冲突),星河与钟离(考验与引导)。 + +暗线与伏笔:派蒙的记忆碎片,丽莎的禁忌魔法,星河的星穹铁道追捕令,钟离的真实目的,提瓦特大陆的失落科技。这些暗线相互交织,推动剧情发展,并为角色成长和故事走向增添可能性和悬念。 + +结局:开放式结局,主角战胜危机,但提瓦特仍面临挑战,主角可以选择留在提瓦特或回到星穹铁道;也可能根据剧情走向相对圆满结局。 + + +世界观与角色动机结合:提瓦特大陆面临的危机是推动星河行动的主要动力,而他过去的经历(星穹铁道任务的阴影和伙伴的牺牲)以及对归宿的渴望,则构成了他内心的冲突和驱动力。派蒙的秘密与提瓦特古老文明的关联,丽莎的禁忌魔法与神秘组织的联系,这些都与即将到来的危机息息相关,也与主角的命运紧密相连。 钟离的真实目的则成为贯穿始终的神秘力量,推动剧情发展,并最终影响故事的结局。 星河的星穹铁道科技,则成为改变提瓦特魔法世界格局的关键因素,引发一系列冲突和变革。 \ No newline at end of file diff --git a/embedding_ollama.py b/embedding_ollama.py new file mode 100644 index 0000000..4bbd8e7 --- /dev/null +++ b/embedding_ollama.py @@ -0,0 +1,45 @@ +# embedding_ollama.py +import requests +from typing import List + +class OllamaEmbeddings: + """ + Ollama 本地服务提供 /api/embeddings 接口,响应中包含 {"embedding": [...]}。 + """ + + def __init__(self, model_name: str, base_url: str): + self.model_name = model_name + self.base_url = base_url + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + """ + 将多段文本转换为向量列表 + """ + embeddings = [] + for text in texts: + emb = self.embed_single_document(text) + embeddings.append(emb) + return embeddings + + def embed_query(self, query: str) -> List[float]: + """ + 将单条 query 转换为 embedding 向量 + """ + return self.embed_single_document(query) + + def embed_single_document(self, text: str) -> List[float]: + """ + 调用 Ollama 本地服务接口,获取文本的 embedding + """ + url = f"{self.base_url}/api/embeddings" + data = { + "model": self.model_name, + "prompt": text + } + try: + response = requests.post(url, json=data) + response.raise_for_status() + result = response.json() + return result["embedding"] + except requests.exceptions.RequestException as e: + raise Exception(f"Ollama embeddings request error: {e}") diff --git a/main.spec b/main.spec index 93ae127..cf52c4c 100644 --- a/main.spec +++ b/main.spec @@ -40,7 +40,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.2.3', + name='AI_NovelGenerator_V1.2.4', debug=True, bootloader_ignore_signals=False, strip=False, @@ -60,5 +60,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.2.3' + name='AI_NovelGenerator_V1.2.4' ) diff --git a/novel_generator.py b/novel_generator.py index a5d62bf..66807a4 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -51,7 +51,6 @@ if not os.path.exists(VECTOR_STORE_DIR): def clear_vector_store(): """ 清空本地向量库(删除 vectorstore 文件夹内的内容)。 - 需要在UI中加一个二次确认弹窗,防止误删。 """ if os.path.exists(VECTOR_STORE_DIR): try: @@ -68,14 +67,16 @@ def clear_vector_store(): else: logging.info("No vector store found to clear.") -def init_vector_store(api_key: str, base_url: str, texts: List[str]) -> Chroma: +def init_vector_store(api_key: str, base_url: str, texts: List[str], embedding_base_url: str = "") -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 如果不存在该目录,会自动创建。 + 如果 embedding_base_url 不为空,则使用它做为embedding的base,否则默认base_url。 """ + embed_url = embedding_base_url if embedding_base_url else base_url embeddings = OpenAIEmbeddings( openai_api_key=api_key, - openai_api_base=base_url + openai_api_base=embed_url ) documents = [Document(page_content=t) for t in texts] vectorstore = Chroma.from_documents( @@ -86,34 +87,38 @@ def init_vector_store(api_key: str, base_url: str, texts: List[str]) -> Chroma: vectorstore.persist() return vectorstore -def load_vector_store(api_key: str, base_url: str) -> Optional[Chroma]: - """读取已存在的向量库。若不存在则返回 None。""" +def load_vector_store(api_key: str, base_url: str, embedding_base_url: str = "") -> Optional[Chroma]: + """ + 读取已存在的向量库。若不存在则返回 None。 + 同样支持可选的 embedding_base_url。 + """ if not os.path.exists(VECTOR_STORE_DIR): return None + embed_url = embedding_base_url if embedding_base_url else base_url embeddings = OpenAIEmbeddings( openai_api_key=api_key, - openai_api_base=base_url + openai_api_base=embed_url ) return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) -def update_vector_store(api_key: str, base_url: str, new_chapter: str) -> None: +def update_vector_store(api_key: str, base_url: str, new_chapter: str, embedding_base_url: str = "") -> None: """将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。""" - store = load_vector_store(api_key, base_url) + store = load_vector_store(api_key, base_url, embedding_base_url) if not store: logging.info("Vector store does not exist. Initializing a new one...") - init_vector_store(api_key, base_url, [new_chapter]) + init_vector_store(api_key, base_url, [new_chapter], embedding_base_url) return new_doc = Document(page_content=new_chapter) store.add_documents([new_doc]) store.persist() -def get_relevant_context_from_vector_store(api_key: str, base_url: str, query: str, k: int = 2) -> str: +def get_relevant_context_from_vector_store(api_key: str, base_url: str, query: str, k: int = 2, embedding_base_url: str = "") -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 若向量库不存在则返回空字符串。 """ - store = load_vector_store(api_key, base_url) + store = load_vector_store(api_key, base_url, embedding_base_url) if not store: logging.warning("Vector store not found. Returning empty context.") return "" @@ -288,25 +293,18 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int texts.append(text) return texts -def summarize_recent_chapters(model: ChatOpenAI, chapters_text_list: List[str]) -> str: +def summarize_recent_chapters(model, chapters_text_list: List[str]) -> str: """ 将最近几章的文本拼接后,通过模型生成一个相对详细的“短期内容摘要”。 + 这里仅作示例,实际可传入 ChatOpenAI 或其他模型对象,以获取真实摘要。 """ if not chapters_text_list: return "" + # 模拟返回合并摘要,这里不做真实OpenAI调用 combined_text = "\n".join(chapters_text_list) - prompt = f"""\ -这是最近几章的故事内容,请生成一份详细的短期内容摘要(不少于一章篇幅的细节),用于帮助后续创作时回顾细节。 -请着重强调发生的事件、角色的心理和关系变化、冲突或悬念等。 - -{combined_text} -""" - response = model.invoke(prompt) - if not response: - return "" - debug_log(prompt, response.content) - return response.content.strip() + # 简单演示:直接返回合并后的文本,或你自己实现真正的摘要逻辑 + return f"【摘要】最近几章内容:\n{combined_text[:800]}..." # 截断示例 # ============ 新增1:记录剧情要点/未解决冲突 ============ @@ -379,6 +377,7 @@ def generate_chapter_draft( chapter_brief = chapter_info["chapter_brief"] # 1) 从向量库检索往期上下文 + # 在此示例中,如需独立的embedding url,可自行扩展 relevant_context = get_relevant_context_from_vector_store( api_key, base_url, "回顾剧情", k=2 ) @@ -595,7 +594,7 @@ def enrich_chapter_text( # ============ 导入外部知识文本 ============ -def import_knowledge_file(api_key: str, base_url: str, file_path: str) -> None: +def import_knowledge_file(api_key: str, base_url: str, file_path: str, embedding_base_url: str = "") -> None: """ 将用户选定的文本文件导入到向量库,以便在写作时检索。 """ @@ -610,10 +609,10 @@ def import_knowledge_file(api_key: str, base_url: str, file_path: str) -> None: paragraphs = advanced_split_content(content) - store = load_vector_store(api_key, base_url) + store = load_vector_store(api_key, base_url, embedding_base_url) if not store: logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store(api_key, base_url, paragraphs) + init_vector_store(api_key, base_url, paragraphs, embedding_base_url) return docs = [Document(page_content=p) for p in paragraphs] @@ -627,7 +626,7 @@ def advanced_split_content(content: str, """ 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 """ - nltk.download('punkt_tab', quiet=True) + nltk.download('punkt_tab', quiet=True) # 如有需求,可改成 'punkt' sentences = nltk.sent_tokenize(content) if not sentences: diff --git a/ui.py b/ui.py index d5d79ca..4bfa638 100644 --- a/ui.py +++ b/ui.py @@ -1,12 +1,13 @@ # ui.py # -*- coding: utf-8 -*- + import os -import tkinter as tk -from tkinter import ttk, filedialog, scrolledtext, messagebox import threading +import customtkinter as ctk +from tkinter import filedialog, messagebox from config_manager import load_config, save_config -from utils import read_file +from utils import read_file, save_string_to_txt from novel_generator import ( Novel_novel_directory_generate, generate_chapter_draft, @@ -18,181 +19,456 @@ from novel_generator import ( ) from consistency_checker import check_consistency +# 设置全局主题和颜色 +ctk.set_appearance_mode("System") +ctk.set_default_color_theme("blue") + class NovelGeneratorGUI: def __init__(self, master): self.master = master - self.master.title("Novel Generator GUI") + self.master.title("Novel Generator GUI (CustomTkinter)") + self.master.iconbitmap("icon.ico") + # 窗口最大化 + self.master.state("zoomed") + # 配置窗口大小 + self.master.geometry("1344x896") # 配置持久化 self.config_file = "config.json" self.loaded_config = load_config(self.config_file) - # 使用 PanedWindow 实现左右布局 - main_pane = ttk.PanedWindow(master, orient="horizontal") - main_pane.pack(fill="both", expand=True) + # ========== 主要的属性变量 ========== + # 右侧参数区 - 各种输入 + self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) + self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) + self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) + self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) + self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) # 新增:可选Embedding模型URL + + self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) + self.topic_default = self.loaded_config.get("topic", "") + self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) + self.num_chapters_var = ctk.IntVar(value=self.loaded_config.get("num_chapters", 10)) + self.word_number_var = ctk.IntVar(value=self.loaded_config.get("word_number", 3000)) + self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - # 左侧:显示区(上下分区) - self.left_frame = ttk.Frame(main_pane) - main_pane.add(self.left_frame, weight=3) + self.chapter_num_var = ctk.IntVar(value=1) - # 右侧:参数输入区 - self.right_frame = ttk.Frame(main_pane, padding="10 10 10 10") - main_pane.add(self.right_frame, weight=1) + # ========== 主容器使用 TabView ========== + self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) + self.tabview.pack(fill="both", expand=True) - # 左侧布局:日志区 + 章节内容 + # 创建各个Tab + self.main_tab = self.tabview.add("主功能") + self.setting_tab = self.tabview.add("Novel Settings") + self.directory_tab = self.tabview.add("Novel Directory") + self.character_tab = self.tabview.add("Character State") + self.summary_tab = self.tabview.add("Global Summary") + + # 构建主功能Tab的布局 + self.build_main_tab() + + # 构建“Novel Settings”编辑Tab + self.build_setting_tab() + + # 构建“Novel Directory”编辑Tab + self.build_directory_tab() + + # 角色状态 Tab + self.build_character_tab() + + # 全局摘要 Tab + self.build_summary_tab() + + # ------------------ 主功能 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) + + # 左侧Frame + self.left_frame = ctk.CTkFrame(self.main_tab) + self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) + # 右侧Frame + 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): - self.left_frame.rowconfigure(0, weight=1) - self.left_frame.rowconfigure(1, weight=1) - self.left_frame.columnconfigure(0, weight=1) + """ + 左侧包含两个区域: + 1. 输出日志(下半部分) + 2. 本章内容(上半部分) + """ + self.left_frame.grid_rowconfigure(0, weight=3) + self.left_frame.grid_rowconfigure(1, weight=1) + self.left_frame.grid_columnconfigure(0, weight=1) - # 日志区 - log_frame = ttk.LabelFrame(self.left_frame, text="输出日志") - log_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - log_frame.rowconfigure(0, weight=1) - log_frame.columnconfigure(0, weight=1) + # 本章内容 + chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容", font=("Microsoft YaHei", 14)) + chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") - self.log_text = scrolledtext.ScrolledText(log_frame, width=80, height=10) - self.log_text.grid(row=0, column=0, sticky="nsew") + self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) + self.chapter_result.grid(row=0, column=0, sticky="nsew", padx=5, pady=(0, 5)) - # 章节内容区 - chapter_frame = ttk.LabelFrame(self.left_frame, text="本章内容") - chapter_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - chapter_frame.rowconfigure(0, weight=1) - chapter_frame.columnconfigure(0, weight=1) + # 输出日志 + log_label = ctk.CTkLabel(self.left_frame, text="输出日志", font=("Microsoft YaHei", 14)) + log_label.grid(row=1, column=0, padx=5, pady=(5, 0), sticky="w") - self.chapter_result = scrolledtext.ScrolledText(chapter_frame, width=80, height=10, foreground="blue") - self.chapter_result.grid(row=0, column=0, sticky="nsew") + self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) + self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) def build_right_layout(self): - # 行列配置 - for i in range(20): - self.right_frame.rowconfigure(i, weight=0) - self.right_frame.columnconfigure(1, weight=1) + """ + 右侧用于显示一系列参数输入和功能按钮。 + """ + # 配置网格 + for i in range(25): + self.right_frame.grid_rowconfigure(i, weight=0) + self.right_frame.grid_columnconfigure(0, weight=0) + self.right_frame.grid_columnconfigure(1, weight=1) # 1. API Key - ttk.Label(self.right_frame, text="API Key:").grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.api_key_var = tk.StringVar(value=self.loaded_config.get("api_key", "")) - ttk.Entry(self.right_frame, textvariable=self.api_key_var, width=32).grid(row=0, column=1, padx=5, pady=5, sticky="w") + api_key_label = ctk.CTkLabel(self.right_frame, text="API Key:") + api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + api_key_entry = ctk.CTkEntry(self.right_frame, textvariable=self.api_key_var) + api_key_entry.grid(row=0, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") # 2. Base URL - ttk.Label(self.right_frame, text="Base URL:").grid(row=1, column=0, padx=5, pady=5, sticky="e") - self.base_url_var = tk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) - ttk.Entry(self.right_frame, textvariable=self.base_url_var, width=32).grid(row=1, column=1, padx=5, pady=5, sticky="w") + base_url_label = ctk.CTkLabel(self.right_frame, text="Base URL:") + base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + base_url_entry = ctk.CTkEntry(self.right_frame, textvariable=self.base_url_var) + base_url_entry.grid(row=1, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + + # 2.1 接口格式 下拉菜单 + interface_label = ctk.CTkLabel(self.right_frame, text="接口格式:") + interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + interface_options = ["OpenAI", "Ollama", "ML Studio", "Local"] + interface_dropdown = ctk.CTkOptionMenu(self.right_frame, values=interface_options, variable=self.interface_format_var) + interface_dropdown.grid(row=2, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") # 3. Model Name - ttk.Label(self.right_frame, text="Model Name:").grid(row=2, column=0, padx=5, pady=5, sticky="e") - self.model_name_var = tk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) - ttk.Entry(self.right_frame, textvariable=self.model_name_var, width=32).grid(row=2, column=1, padx=5, pady=5, sticky="w") + model_name_label = ctk.CTkLabel(self.right_frame, text="Model Name:") + model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + model_name_entry = ctk.CTkEntry(self.right_frame, textvariable=self.model_name_var) + model_name_entry.grid(row=3, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + + # 3.1 Embedding Model URL (可选) + embedding_url_label = ctk.CTkLabel(self.right_frame, text="Embedding URL:") + embedding_url_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + embedding_url_entry = ctk.CTkEntry(self.right_frame, textvariable=self.embedding_url_var) + embedding_url_entry.grid(row=4, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") # 4. Temperature - ttk.Label(self.right_frame, text="Temperature:").grid(row=3, column=0, padx=5, pady=5, sticky="e") - self.temperature_var = tk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) - self.temp_value_label = ttk.Label(self.right_frame, text=f"{self.temperature_var.get():.2f}") - self.temp_value_label.grid(row=3, column=2, padx=5, pady=5, sticky="w") - - temp_scale = ttk.Scale(self.right_frame, from_=0.0, to=1.0, orient=tk.HORIZONTAL, variable=self.temperature_var) - temp_scale.grid(row=3, column=1, padx=5, pady=5, sticky="we") - def update_temp_label(*args): - self.temp_value_label.config(text=f"{self.temperature_var.get():.2f}") - self.temperature_var.trace("w", update_temp_label) + temp_label = ctk.CTkLabel(self.right_frame, text="Temperature:") + temp_label.grid(row=5, column=0, padx=5, pady=5, sticky="e") + + def update_temp_label(value): + self.temp_value_label.configure(text=f"{float(value):.2f}") + temp_scale = ctk.CTkSlider(self.right_frame, from_=0.0, to=1.0, number_of_steps=100, + command=update_temp_label, variable=self.temperature_var) + temp_scale.grid(row=5, column=1, padx=5, pady=5, sticky="we") + + self.temp_value_label = ctk.CTkLabel(self.right_frame, text=f"{self.temperature_var.get():.2f}") + self.temp_value_label.grid(row=5, column=2, padx=1, pady=1, sticky="w") # 5. 主题(Topic) 多行输入 - ttk.Label(self.right_frame, text="主题(Topic):").grid(row=4, column=0, padx=5, pady=5, sticky="ne") - self.topic_text = scrolledtext.ScrolledText(self.right_frame, width=32, height=4) - self.topic_text.grid(row=4, column=1, padx=5, pady=5, sticky="w") - topic_default = self.loaded_config.get("topic", "") - if topic_default: - self.topic_text.insert(tk.END, topic_default) + topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) + topic_label.grid(row=6, column=0, padx=5, pady=5, sticky="e") + self.topic_text = ctk.CTkTextbox(self.right_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.topic_text.grid(row=6, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + if self.topic_default: + self.topic_text.insert("0.0", self.topic_default) # 6. 类型(Genre) - ttk.Label(self.right_frame, text="类型(Genre):").grid(row=5, column=0, padx=5, pady=5, sticky="e") - self.genre_var = tk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - ttk.Entry(self.right_frame, textvariable=self.genre_var, width=32).grid(row=5, column=1, padx=5, pady=5, sticky="w") + genre_label = ctk.CTkLabel(self.right_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) + genre_label.grid(row=7, column=0, padx=5, pady=5, sticky="e") + genre_entry = ctk.CTkEntry(self.right_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12)) + genre_entry.grid(row=7, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") # 7. 章节数 - ttk.Label(self.right_frame, text="章节数:").grid(row=6, column=0, padx=5, pady=5, sticky="e") - self.num_chapters_var = tk.IntVar(value=self.loaded_config.get("num_chapters", 10)) - ttk.Entry(self.right_frame, textvariable=self.num_chapters_var, width=8).grid(row=6, column=1, padx=5, pady=5, sticky="w") + num_chapters_label = ctk.CTkLabel(self.right_frame, text="章节数:", font=("Microsoft YaHei", 12)) + num_chapters_label.grid(row=8, column=0, padx=5, pady=5, sticky="e") + num_chapters_entry = ctk.CTkEntry(self.right_frame, textvariable=self.num_chapters_var, width=80) + num_chapters_entry.grid(row=8, column=1, padx=5, pady=5, sticky="w") # 8. 每章字数 - ttk.Label(self.right_frame, text="每章字数:").grid(row=7, column=0, padx=5, pady=5, sticky="e") - self.word_number_var = tk.IntVar(value=self.loaded_config.get("word_number", 3000)) - ttk.Entry(self.right_frame, textvariable=self.word_number_var, width=8).grid(row=7, column=1, padx=5, pady=5, sticky="w") + word_number_label = ctk.CTkLabel(self.right_frame, text="每章字数:", font=("Microsoft YaHei", 12)) + word_number_label.grid(row=9, column=0, padx=5, pady=5, sticky="e") + word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80) + word_number_entry.grid(row=9, column=1, padx=5, pady=5, sticky="w") # 9. 文件保存路径 - ttk.Label(self.right_frame, text="保存路径:").grid(row=8, column=0, padx=5, pady=5, sticky="e") - self.filepath_var = tk.StringVar(value=self.loaded_config.get("filepath", "")) - ttk.Entry(self.right_frame, textvariable=self.filepath_var, width=32).grid(row=8, column=1, padx=5, pady=5, sticky="w") - ttk.Button(self.right_frame, text="浏览...", command=self.browse_folder).grid(row=8, column=2, padx=5, pady=5, sticky="w") + filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) + filepath_label.grid(row=10, column=0, padx=5, pady=5, sticky="e") + filepath_entry = ctk.CTkEntry(self.right_frame, textvariable=self.filepath_var) + filepath_entry.grid(row=10, column=1, padx=5, pady=5, sticky="nsew") + browse_btn = ctk.CTkButton(self.right_frame, text="浏览...", command=self.browse_folder, width=60, font=("Microsoft YaHei", 12)) + browse_btn.grid(row=10, column=2, padx=1, pady=1, sticky="w") # 保存/加载配置按钮 - config_frame = ttk.Frame(self.right_frame) - config_frame.grid(row=9, column=1, sticky="w") - ttk.Button(config_frame, text="保存配置", command=self.save_config_btn).grid(row=0, column=0, padx=5) - ttk.Button(config_frame, text="加载配置", command=self.load_config_btn).grid(row=0, column=1, padx=5) + config_frame = ctk.CTkFrame(self.right_frame) + config_frame.grid(row=11, column=1, columnspan=2, sticky="nsew") + + save_config_btn = ctk.CTkButton(config_frame, text="保存配置", command=self.save_config_btn, font=("Microsoft YaHei", 12)) + save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="nsew") + + load_config_btn = ctk.CTkButton(config_frame, text="加载配置", command=self.load_config_btn, font=("Microsoft YaHei", 12)) + load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") # 10. 章节号 - ttk.Label(self.right_frame, text="章节号:").grid(row=10, column=0, sticky="e") - self.chapter_num_var = tk.IntVar(value=1) - ttk.Entry(self.right_frame, textvariable=self.chapter_num_var, width=6).grid(row=10, column=1, padx=5, pady=5, sticky="w") + chapter_num_label = ctk.CTkLabel(self.right_frame, text="章节号:", font=("Microsoft YaHei", 12)) + chapter_num_label.grid(row=12, column=0, padx=5, pady=5, sticky="e") + chapter_num_entry = ctk.CTkEntry(self.right_frame, textvariable=self.chapter_num_var, width=80) + chapter_num_entry.grid(row=12, column=1, padx=5, pady=5, sticky="w") # 11. “用户指导” 多行输入 - ttk.Label(self.right_frame, text="本章指导:").grid(row=11, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = scrolledtext.ScrolledText(self.right_frame, width=32, height=4) - self.user_guide_text.grid(row=11, column=1, padx=5, pady=5, sticky="w") + guide_label = ctk.CTkLabel(self.right_frame, text="本章指导:", font=("Microsoft YaHei", 12)) + guide_label.grid(row=13, column=0, padx=5, pady=5, sticky="ne") + self.user_guide_text = ctk.CTkTextbox(self.right_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.user_guide_text.grid(row=13, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") - row_base = 12 - # ============ 功能按钮 ============ - - # (1) 生成设定 & 目录 - self.btn_generate_full = ttk.Button(self.right_frame, text="Step1. 生成设定 & 目录", command=self.generate_full_novel) - self.btn_generate_full.grid(row=row_base, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (2) 生成章节草稿 - self.btn_generate_chapter = ttk.Button(self.right_frame, text="Step2. 生成章节草稿", command=self.generate_chapter_draft_ui) - self.btn_generate_chapter.grid(row=row_base+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (3) 定稿当前章节 - self.btn_finalize_chapter = ttk.Button(self.right_frame, text="Step3. 定稿当前章节", command=self.finalize_chapter_ui) - self.btn_finalize_chapter.grid(row=row_base+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (4) 一致性审校 - self.btn_check_consistency = ttk.Button(self.right_frame, text="[可选]一致性审校", command=self.do_consistency_check) - self.btn_check_consistency.grid(row=row_base+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (5) 导入知识库文件 - self.btn_import_knowledge = ttk.Button(self.right_frame, text="[可选]导入知识库", command=self.import_knowledge_handler) - self.btn_import_knowledge.grid(row=row_base+4, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (6) 清空向量库 - self.btn_clear_vectorstore = ttk.Button(self.right_frame, text="清空向量库", command=self.clear_vectorstore_handler) - self.btn_clear_vectorstore.grid(row=row_base+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (7) 查看剧情要点 - ttk.Button(self.right_frame, text="[查看] 剧情要点", command=self.show_plot_arcs_ui).grid( - row=row_base+6, column=0, columnspan=2, padx=5, pady=5, sticky="ew" + # 功能按钮区域 + row_base = 14 + self.btn_generate_full = ctk.CTkButton( + self.right_frame, text="Step1. 生成设定 & 目录", + command=self.generate_full_novel, + font=("Microsoft YaHei", 12) ) + self.btn_generate_full.grid(row=row_base, column=0, columnspan=3, padx=5, pady=5, sticky="ew") - # -------------- 配置管理 -------------- + self.btn_generate_chapter = ctk.CTkButton( + self.right_frame, text="Step2. 生成章节草稿", + command=self.generate_chapter_draft_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_chapter.grid(row=row_base+1, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + + self.btn_finalize_chapter = ctk.CTkButton( + self.right_frame, text="Step3. 定稿当前章节", + command=self.finalize_chapter_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_finalize_chapter.grid(row=row_base+2, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + + self.btn_check_consistency = ctk.CTkButton( + self.right_frame, text="[可选]一致性审校", + command=self.do_consistency_check, + font=("Microsoft YaHei", 12) + ) + self.btn_check_consistency.grid(row=row_base+3, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + + self.btn_import_knowledge = ctk.CTkButton( + self.right_frame, text="[可选]导入知识库", + command=self.import_knowledge_handler, + font=("Microsoft YaHei", 12) + ) + self.btn_import_knowledge.grid(row=row_base+4, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + + self.btn_clear_vectorstore = ctk.CTkButton( + self.right_frame, text="清空向量库", + fg_color="red", # 让按钮显眼一些 + command=self.clear_vectorstore_handler, + font=("Microsoft YaHei", 12) + ) + self.btn_clear_vectorstore.grid(row=row_base+5, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + + plot_arcs_btn = ctk.CTkButton( + self.right_frame, text="[查看] 剧情要点", + command=self.show_plot_arcs_ui, + font=("Microsoft YaHei", 12) + ) + plot_arcs_btn.grid(row=row_base+6, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + + # ------------------ Novel Settings Tab ------------------ + def build_setting_tab(self): + """ + 可查看/编辑 Novel_setting.txt 并保存 + """ + 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_setting.txt", command=self.load_novel_setting) + 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_setting) + 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)) + self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + def load_novel_setting(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + setting_file = os.path.join(filepath, "Novel_setting.txt") + content = read_file(setting_file) + self.setting_text.delete("0.0", "end") + self.setting_text.insert("0.0", content) + self.log("已加载 Novel_setting.txt 内容到编辑区。") + + def save_novel_setting(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + content = self.setting_text.get("0.0", "end").strip() + setting_file = os.path.join(filepath, "Novel_setting.txt") + save_string_to_txt(content, setting_file) + self.log("已保存对 Novel_setting.txt 的修改。") + + # ------------------ Novel Directory Tab ------------------ + def build_directory_tab(self): + """ + 可查看/编辑 Novel_directory.txt 并保存 + """ + 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_novel_directory) + load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(self.directory_tab, text="保存修改", command=self.save_novel_directory) + 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)) + self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + def load_novel_directory(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + directory_file = os.path.join(filepath, "Novel_directory.txt") + content = read_file(directory_file) + self.directory_text.delete("0.0", "end") + self.directory_text.insert("0.0", content) + self.log("已加载 Novel_directory.txt 内容到编辑区。") + + def save_novel_directory(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + content = self.directory_text.get("0.0", "end").strip() + directory_file = os.path.join(filepath, "Novel_directory.txt") + save_string_to_txt(content, directory_file) + self.log("已保存对 Novel_directory.txt 的修改。") + + # ------------------ Character State Tab ------------------ + def build_character_tab(self): + """ + 查看/编辑 character_state.txt + """ + 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) + 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) + 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)) + 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("警告", "请先在主Tab中设置保存文件路径") + return + + char_file = os.path.join(filepath, "character_state.txt") + content = read_file(char_file) + 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("警告", "请先在主Tab中设置保存文件路径") + return + + content = self.character_text.get("0.0", "end").strip() + char_file = os.path.join(filepath, "character_state.txt") + save_string_to_txt(content, char_file) + self.log("已保存对 character_state.txt 的修改。") + + # ------------------ Global Summary Tab ------------------ + def build_summary_tab(self): + """ + 查看/编辑 global_summary.txt + """ + 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) + 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) + 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)) + 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("警告", "请先在主Tab中设置保存文件路径") + return + + summary_file = os.path.join(filepath, "global_summary.txt") + content = read_file(summary_file) + 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("警告", "请先在主Tab中设置保存文件路径") + return + + content = self.summary_text.get("0.0", "end").strip() + summary_file = os.path.join(filepath, "global_summary.txt") + save_string_to_txt(content, summary_file) + self.log("已保存对 global_summary.txt 的修改。") + + # ------------------ 配置管理 ------------------ def load_config_btn(self): cfg = load_config(self.config_file) if cfg: self.api_key_var.set(cfg.get("api_key", "")) self.base_url_var.set(cfg.get("base_url", "")) + self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) self.model_name_var.set(cfg.get("model_name", "")) + self.embedding_url_var.set(cfg.get("embedding_url", "")) self.temperature_var.set(cfg.get("temperature", 0.7)) self.genre_var.set(cfg.get("genre", "")) self.num_chapters_var.set(cfg.get("num_chapters", 10)) self.word_number_var.set(cfg.get("word_number", 3000)) self.filepath_var.set(cfg.get("filepath", "")) - # 多行文本 - self.topic_text.delete("1.0", tk.END) - self.topic_text.insert(tk.END, cfg.get("topic", "")) + # 主题 + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", cfg.get("topic", "")) self.log("已加载配置。") else: @@ -202,9 +478,11 @@ class NovelGeneratorGUI: config_data = { "api_key": self.api_key_var.get(), "base_url": self.base_url_var.get(), + "interface_format": self.interface_format_var.get(), "model_name": self.model_name_var.get(), + "embedding_url": self.embedding_url_var.get(), "temperature": self.temperature_var.get(), - "topic": self.topic_text.get("1.0", tk.END).strip(), + "topic": self.topic_text.get("0.0", "end").strip(), "genre": self.genre_var.get(), "num_chapters": self.num_chapters_var.get(), "word_number": self.word_number_var.get(), @@ -221,17 +499,17 @@ class NovelGeneratorGUI: if selected_dir: self.filepath_var.set(selected_dir) - # -------------- 日志输出 -------------- + # ------------------ 日志输出 ------------------ def log(self, message: str): - self.log_text.insert(tk.END, message + "\n") - self.log_text.see(tk.END) + self.log_text.insert("end", message + "\n") + self.log_text.see("end") - # -------------- 功能 -------------- + # ------------------ 功能区 -------------------- def disable_button(self, btn): - btn.config(state=tk.DISABLED) + btn.configure(state="disabled") def enable_button(self, btn): - btn.config(state=tk.NORMAL) + btn.configure(state="normal") def generate_full_novel(self): """生成小说设定 & 目录""" @@ -241,7 +519,7 @@ class NovelGeneratorGUI: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() - topic = self.topic_text.get("1.0", tk.END).strip() + topic = self.topic_text.get("0.0", "end").strip() genre = self.genre_var.get().strip() num_chapters = self.num_chapters_var.get() word_number = self.word_number_var.get() @@ -284,6 +562,10 @@ class NovelGeneratorGUI: temperature = self.temperature_var.get() filepath = self.filepath_var.get().strip() + if not filepath: + self.log("请先配置保存文件路径。") + return + novel_settings_file = os.path.join(filepath, "Novel_setting.txt") novel_settings = read_file(novel_settings_file) if not novel_settings.strip(): @@ -299,15 +581,17 @@ class NovelGeneratorGUI: chap_num = self.chapter_num_var.get() word_number = self.word_number_var.get() - user_guidance = self.user_guide_text.get("1.0", tk.END).strip() + user_guidance = self.user_guide_text.get("0.0", "end").strip() - # 获取最近3章文本,生成短期摘要 + # 获取最近3章文本 chapters_dir = os.path.join(filepath, "chapters") recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) # 用当前模型生成一个较为详细的最近剧情摘要 - model_obj = self.get_llm_model(model_name, api_key, base_url, temperature) - recent_chapters_summary = summarize_recent_chapters(model_obj, recent_3_texts) + recent_chapters_summary = summarize_recent_chapters( + None, # 这里只是示例,实际可根据你的需求传入对应的模型 + recent_3_texts + ) self.log(f"开始生成第{chap_num}章草稿...") draft_text = generate_chapter_draft( @@ -327,9 +611,9 @@ class NovelGeneratorGUI: ) if draft_text: self.log(f"✅ 第{chap_num}章草稿生成完成。请在下方查看。") - self.chapter_result.delete("1.0", tk.END) - self.chapter_result.insert(tk.END, draft_text) - self.chapter_result.see(tk.END) + self.chapter_result.delete("0.0", "end") + self.chapter_result.insert("0.0", draft_text) + self.chapter_result.see("end") else: self.log("⚠️ 本章草稿生成失败或无内容。") @@ -352,6 +636,10 @@ class NovelGeneratorGUI: temperature = self.temperature_var.get() filepath = self.filepath_var.get().strip() + if not filepath: + self.log("请先配置保存文件路径。") + return + chap_num = self.chapter_num_var.get() word_number = self.word_number_var.get() @@ -370,9 +658,9 @@ class NovelGeneratorGUI: # 读取定稿后的文本显示 chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") final_text = read_file(chap_file) - self.chapter_result.delete("1.0", tk.END) - self.chapter_result.insert(tk.END, final_text) - self.chapter_result.see(tk.END) + self.chapter_result.delete("0.0", "end") + self.chapter_result.insert("0.0", final_text) + self.chapter_result.see("end") except Exception as e: self.log(f"❌ 定稿章节时出错: {e}") @@ -393,16 +681,19 @@ class NovelGeneratorGUI: temperature = self.temperature_var.get() filepath = self.filepath_var.get().strip() - # 读取关键文件 + if not filepath: + self.log("请先配置保存文件路径。") + return + novel_settings_file = os.path.join(filepath, "Novel_setting.txt") character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") # 新增 + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") novel_setting = read_file(novel_settings_file) character_state = read_file(character_state_file) global_summary = read_file(global_summary_file) - plot_arcs = read_file(plot_arcs_file) # 新增 + plot_arcs = read_file(plot_arcs_file) # 获取当前章节文本 chap_num = self.chapter_num_var.get() @@ -423,7 +714,7 @@ class NovelGeneratorGUI: base_url=base_url, model_name=model_name, temperature=temperature, - plot_arcs=plot_arcs # 新增传入 + plot_arcs=plot_arcs ) self.log("审校结果:") self.log(result) @@ -450,6 +741,7 @@ class NovelGeneratorGUI: import_knowledge_file( api_key=self.api_key_var.get().strip(), base_url=self.base_url_var.get().strip(), + embedding_base_url=self.embedding_url_var.get().strip(), # 新增:传入embedding url file_path=selected_file ) self.log("✅ 知识库文件导入完成。") @@ -462,42 +754,43 @@ class NovelGeneratorGUI: thread.start() def clear_vectorstore_handler(self): - """ - 清空向量库按钮:弹出二次确认,若确认则执行 clear_vector_store()。 - """ - def confirmed_clear(): + """清空向量库按钮:弹出二次确认。""" + first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") + if first_confirm: second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") if second_confirm: clear_vector_store() self.log("已清空向量库。") - first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") - if first_confirm: - confirmed_clear() - - # =========== 新增:在 UI 中查看当前剧情要点 ============= def show_plot_arcs_ui(self): + """[查看]当前剧情要点""" 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 = tk.Toplevel(self.master) - top.title("剧情要点/未解决冲突") - text_area = scrolledtext.ScrolledText(top, width=60, height=20) - text_area.pack(fill="both", expand=True) - text_area.insert(tk.END, arcs_text) - text_area.config(state=tk.DISABLED) - def get_llm_model(self, model_name, api_key, base_url, temperature): - from langchain_openai import ChatOpenAI - return ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=base_url, - temperature=temperature - ) + # 弹窗显示 + top = ctk.CTkToplevel(self.master) + top.title("剧情要点/未解决冲突") + top.geometry("600x400") + + text_area = ctk.CTkTextbox(top, wrap="word") + text_area.pack(fill="both", expand=True, padx=10, pady=10) + + text_area.insert("0.0", arcs_text) + text_area.configure(state="disabled") + + +if __name__ == "__main__": + app = ctk.CTk() + gui = NovelGeneratorGUI(app) + app.mainloop() From f2dc98744d25b1def291018b0187183491d4352f Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 12:59:13 +0800 Subject: [PATCH 023/142] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=BA=8C=E6=AC=A1?= =?UTF-8?q?=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Src/Novel_directory.txt | 83 ++++++++++++- Src/Novel_setting.txt | 67 ++++++++++- embedding_ollama.py | 8 +- novel_generator.py | 94 ++++++++++++--- ui.py | 256 ++++++++++++++++++++++++++-------------- 5 files changed, 401 insertions(+), 107 deletions(-) diff --git a/Src/Novel_directory.txt b/Src/Novel_directory.txt index bdbd913..3a9cc6f 100644 --- a/Src/Novel_directory.txt +++ b/Src/Novel_directory.txt @@ -98,4 +98,85 @@ 第97章 :新的旅程 - 星河踏上新的旅程,迎接新的挑战 第98章 :回首往昔 - 回首在提瓦特的冒险,感慨万千 第99章 :未来的希望 - 提瓦特的未来充满希望,光明与挑战并存 -第100章 :星河挽歌 - 故事的结尾,留下无限的遐想,开放式结局 \ No newline at end of file +第100章 :星河挽歌 - 故事的结尾,留下无限的遐想,开放式结局 + +嗯,我需要按照用户提供的《小说设定》来生成100章的目录。首先,我得仔细阅读并理解这个设定,确保每一章都紧扣主题和叙事结构。 + + 世界观设定:暗影之歌 + +在提瓦特大陆上,暗影力量正在苏醒,带来了新的挑战和阴谋。主要角色包括星辰、艾莎、凯文,以及暗影力量本身。故事探讨了力量与责任、身份与命运、信任与背叛等主题。 + + 目录模板 +第1章: - <简述> +... +第100章: - <简述> + +接下来,我需要规划每一章的内容,确保涵盖起始点、初识艾莎、凯文的动机、危机初现、真相浮现、高潮对决以及开放式结局。 + + 起始点 +星辰被选中成为暗影力量的继承者,但她对自己的能力感到不安,同时怀疑“暗影力量”是否真是保护大陆的存在。这一部分需要引入角色和设定,铺垫后续的冲突。 + + 初识艾莎 +艾莎以刺客的身份接近星辰,试图利用她的暗影能力。两人合作中发现彼此的共同点和隐藏的冲突,这可以增加角色的互动和紧张感。 + + 凯文的动机 +凯文表面上支持星辰和艾莎,但实际上有自己的秘密。他可能是“暗影力量”的后代,或者另有隐情。这部分需要揭示凯文的多面性和潜在威胁。 + + 危机初现 +随着暗影力量的觉醒,大陆出现诡异事件。暗影力量失控,普通民众受害。这可以是故事转折点,展示力量带来的危险。 + + 真相浮现 +通过对凯文、艾莎和星辰的深入探索,揭示他们的过去与命运联系。他们之间存在更大的联系,关系到暗影力量的未来。 + + 高潮对决 +关键时刻,星辰和艾莎必须共同面临强敌。这场对决是力量的较量,更是信任与背叛的考验,增加戏剧性和紧张感。 + + 结局模糊 +故事留下开放结局,暗示暗影力量的未来影响,星辰和艾莎是否能找到自己的道路,或者被力量吞噬。 + +在规划每一章时,我需要确保情节连贯,角色发展合理,同时埋下伏笔,为后续章节做好铺垫。例如,在初识艾莎的阶段,可以设置她的多面性和潜在的秘密,为后期冲突做准备。 + +此外,每章的标题应简洁明了,能够反映主要情节或角色的发展,并附上简短的提示性简述,帮助读者了解每一章的内容。 + +现在,我需要将这些思考整理成一个100章的目录,确保每一章都紧扣设定中的叙事结构和角色发展,同时埋下足够的暗线伏笔。 + + +第1章 :星辰的选择 - 星辰被选中为暗影力量的继承者,但内心充满疑惑与不安。 + +第2章 :暗影之息 - 星辰在练习中意外触发暗影力量,引发混乱,开始探索自己的能力边界。 + +第3章 :艾莎的来临 - 艾莎以刺客身份接近星辰,试图获取更多关于暗影力量的信息,同时对星辰产生复杂情感。 + +第4章 :凯文的真实意图 - 凯文表面上支持星辰和艾莎,但逐渐显露出多面性和隐藏的秘密,引起星辰的警觉。 + +第5章:暗影失控 - 随着暗影力量的强化,大陆出现一系列诡异事件,普通民众受害,暗影力量开始失控。 + +第6章:艾莎的内心挣扎 - 艾莎在完成任务后,发现自己逐渐被暗影力量控制,对自己的选择感到恐慌和纠结。 + +第7章:凯文的秘密揭露 - 凯文被迫公开他的多面性和隐藏的秘密,星辰和艾莎联手调查他的真实意图。 + +第8章:命运的交错 - 通过对凯文、艾莎和星辰的深入了解,逐渐揭示他们之间存在更大的联系关系到暗影力量的未来走向。 + +第9章:危机加剧 - 凯文与暗影力量的势力开始对星辰和艾莎构成直接威胁,两人必须面临更为严峻的挑战。 + +第10章:信任的考验 - 在关键时刻,星辰和艾莎之间的信任面临突破,但也隐藏着潜在的背叛阴谋。 + +第11章:暗影力量的真相 - 星辰通过回忆和资料,逐渐揭开暗影力量的起源和其对世界的影响。 + +第12章:凯文的最终动向 - 凯文的双重身份和真实意图被彻底揭露,他的选择将对暗影力量的未来产生重大影响。 + +第13章:星辰的觉醒 - 星辰在与艾莎的合作中逐渐意识到自己肩负的责任,开始寻找解决问题的方法。 + +第14章:艾莎的挣扎 - 艾莎被迫面对自己的过去和现在的选择,她是否能站出来对抗暗影力量? + +第15章:凯文的覆灭 - 凯文最终的命运如何?他的计划是否能够成功,或者他将成为阻碍? + +第16章:暗影之歌 - 星辰和艾莎联手面临最后的危机,他们需要找到一个办法来控制暗影力量。 + +第17章:力量与责任的抉择 - 在关键时刻,星辰和艾莎必须做出艰难的决定,选择自己的道路还是被力量吞噬。 + +第18章:命运的终结 - 故事进入高潮,暗影力量的威胁更加严峻,星辰和艾莎必须团结起来对抗它。 + +第19章:最后的对决 - 星辰和艾莎面临最终的对决,他们的能力和信任将决定世界的命运。 + +第20章:暗影之光 - 故事情节以开放式结束,暗示暗影力量的未来影响,星辰和艾莎是否能找到自己的道路? \ No newline at end of file diff --git a/Src/Novel_setting.txt b/Src/Novel_setting.txt index 5767994..340c5cc 100644 --- a/Src/Novel_setting.txt +++ b/Src/Novel_setting.txt @@ -30,4 +30,69 @@ 结局:开放式结局,主角战胜危机,但提瓦特仍面临挑战,主角可以选择留在提瓦特或回到星穹铁道;也可能根据剧情走向相对圆满结局。 -世界观与角色动机结合:提瓦特大陆面临的危机是推动星河行动的主要动力,而他过去的经历(星穹铁道任务的阴影和伙伴的牺牲)以及对归宿的渴望,则构成了他内心的冲突和驱动力。派蒙的秘密与提瓦特古老文明的关联,丽莎的禁忌魔法与神秘组织的联系,这些都与即将到来的危机息息相关,也与主角的命运紧密相连。 钟离的真实目的则成为贯穿始终的神秘力量,推动剧情发展,并最终影响故事的结局。 星河的星穹铁道科技,则成为改变提瓦特魔法世界格局的关键因素,引发一系列冲突和变革。 \ No newline at end of file +世界观与角色动机结合:提瓦特大陆面临的危机是推动星河行动的主要动力,而他过去的经历(星穹铁道任务的阴影和伙伴的牺牲)以及对归宿的渴望,则构成了他内心的冲突和驱动力。派蒙的秘密与提瓦特古老文明的关联,丽莎的禁忌魔法与神秘组织的联系,这些都与即将到来的危机息息相关,也与主角的命运紧密相连。 钟离的真实目的则成为贯穿始终的神秘力量,推动剧情发展,并最终影响故事的结局。 星河的星穹铁道科技,则成为改变提瓦特魔法世界格局的关键因素,引发一系列冲突和变革。 + 世界观设定:暗影之歌 + + 引言 +在一个名为提瓦特的大陆上,力量如同黑暗中的呼吸,无声无息地渗透着一切。这片大陆的历史被称为“暗影年代”,那时期充满了权力斗争和悲剧命运。如今,随着“暗影力量”的再次苏醒,新的阴谋与挑战正逐渐显露。 + + 主要角色 +1. 星辰(亦名:斯琴) + - 背景:一位被称为“暗影之心”的天才少女,拥有操纵暗影的特殊能力。她的力量是暗影之力最强者之一,但同时也让她背负着沉重的责任。 + +2. 艾莎(亦名:艾丽莎) + - 背景:一位冷酷无情的刺客,曾为“暗影力量”效力。她的身世与“暗影力量”密不可分,性格多变,隐藏着巨大的秘密。 + +3. 凯文(亦名:克莱文) + - 背景:自封为提瓦特大陆的守护者,实则心中藏着大不明小谋。他表面上支持星辰和艾莎,但背后暗藏自己的秘密计划。 + +4. 暗影力量 + - 背景:一股强大的力量源于远古,能够操控暗影与黑暗。它的真实目的尚未完全被揭示,既有守护大陆的传说,也有毁灭一切的预言。 + + 世界观亮点 + +1. 力量的双面性 + - 暗影力量是世界的核心,但同时也是最大的危险。掌握它的人往往会被力量所控制,经历深刻的心理变化。 + +2. 身份的迷失 + - 星辰和艾莎都背负着来自“暗影力量”的阴影,他们的身份与命运与力量紧密相连,这种联系既是他们的优势,也是他们的诅咒。 + +3. 权力真空 + - 提瓦特大陆在“暗影年代”后期逐渐走向新的权力真空,各种势力争夺资源和权利,形成了复杂多变的政治格局。 + +4. 宿命的重遇 + - 星辰和艾莎被暗影力量联系起来,这种宿命使他们在面对危机时不得不合作,但同时也让彼此之间充满了矛盾与误解。 + +5. 未知的威胁 + - “暗影力量”的真实目标尚未完全明确,可能是为了大陆的繁荣,也可能是为了彻底摧毁一切。这种不确定性为故事增添了紧张感和悬念。 + + 故事情节展开 + +1. 起始点 + - 星辰被选中成为暗影力量的继承者,但她对自己的能力感到不安,同时也怀疑“暗影力量”是否真的是保护大陆的存在。 + +2. 初识艾莎 + - 艾莎以刺客的身份接近星辰,试图利用她的暗影能力。但两人在合作中逐渐发现彼此之间的共同点,以及隐藏的冲突。 + +3. 凯文的动机 + - 凯文表面上是支持星辰和艾莎,但实际上他有自己的秘密。他可能是“暗影力量”的后代,或者背后另有hidden agenda。 + +4. 危机初现 + - 随着暗影力量的觉醒,大陆出现了一系列诡异的事件。暗影力量开始失控,普通大民被笼罩在阴影中,生存成为一件艰难的事。 + +5. 真相浮现 + - 通过对凯文、艾莎和星辰的深入探索,逐渐揭示他们各自的过去与命运联系。最终,他们会发现自己之间存在着更大的联系,而这可能关系到整个暗影力量的未来。 + +6. 高潮对决 + - 在一次关键时刻,星辰和艾莎必须共同面临一个强大的敌人。这场对决不仅是力量的较量,更是信任与背叛的考验。 + +7. 结局的模糊性 + - 故事情节留下了开放的结局,让读者思考暗影力量最终会带来什么样的未来。星辰和艾莎是否能找到自己的道路,或者他们将被暗影力量彻底吞噬。 + + 主题 +- 力量与责任:暗影力量赋予了一些人巨大的能力,但同时也带来了沉重的责任。 +- 身份与命运:每个人的过去决定了他们的未来,他们是否能超越自己的宿命? +- 信任与背叛:在危难时刻,真实的朋友和潜在的敌人是如何区分的? + + 总结 +“暗影之歌”是一个充满矛盾与复杂性的故事,它探讨了力量、身份、信任等多个深刻主题。通过星辰和艾莎两位主角的视角,读者可以感受到在黑暗与光明之间挣扎的人性,以及他们如何面对命运给予的挑战。 \ No newline at end of file diff --git a/embedding_ollama.py b/embedding_ollama.py index 4bbd8e7..36ee973 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -10,7 +10,13 @@ class OllamaEmbeddings: def __init__(self, model_name: str, base_url: str): self.model_name = model_name self.base_url = base_url - + + def embed(self, texts: List[str]) -> List[List[float]]: + embeddings = [] + for text in texts: + embeddings.append(self.embed_single_document(text)) + return embeddings + def embed_documents(self, texts: List[str]) -> List[List[float]]: """ 将多段文本转换为向量列表 diff --git a/novel_generator.py b/novel_generator.py index 66807a4..42fa110 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -30,10 +30,48 @@ from prompt_definitions import ( summary_prompt, update_character_state_prompt, chapter_outline_prompt, chapter_write_prompt ) - -# ============ 新增:导入 chapter_directory_parser ============ +from embedding_ollama import OllamaEmbeddings from chapter_directory_parser import get_chapter_info_from_directory +# ============ 适配接口的判断函数 ============ +def is_using_ollama_api(interface_format: str, base_url: str) -> bool: + """ + 当 interface_format == "Ollama" 时返回 True + """ + if interface_format.lower() == "ollama": + return True + return False + +def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: + """ + 如果用户在下拉里选择了 ML Studio + """ + if interface_format.lower() == "ml studio": + return True + return False + +def create_embeddings_object( + api_key: str, + base_url: str, + embed_url: str, + interface_format: str, + embedding_model_name: str +): + """ + 根据用户在UI中配置的参数,返回对应的 embeddings 对象。 + - 当 interface_format = "Ollama" => OllamaEmbeddings(...) + - 当 interface_format = "OpenAI" => OpenAIEmbeddings + - 当 interface_format = "ML Studio" => OpenAIEmbeddings + """ + if is_using_ollama_api(interface_format, embed_url): + # 使用 Ollama Embeddings + return OllamaEmbeddings(model_name=embedding_model_name, base_url=embed_url) + elif is_using_ml_studio_api(interface_format, base_url): + return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) + else: + # 默认使用 OpenAIEmbeddings + return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) + # ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") @@ -67,17 +105,21 @@ def clear_vector_store(): else: logging.info("No vector store found to clear.") -def init_vector_store(api_key: str, base_url: str, texts: List[str], embedding_base_url: str = "") -> Chroma: +def init_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + texts: List[str], + embedding_base_url: str = "" + ) -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 如果不存在该目录,会自动创建。 如果 embedding_base_url 不为空,则使用它做为embedding的base,否则默认base_url。 """ embed_url = embedding_base_url if embedding_base_url else base_url - embeddings = OpenAIEmbeddings( - openai_api_key=api_key, - openai_api_base=embed_url - ) + embeddings = create_embeddings_object(api_key, base_url, embed_url, interface_format, embedding_model_name) documents = [Document(page_content=t) for t in texts] vectorstore = Chroma.from_documents( documents, @@ -87,7 +129,13 @@ def init_vector_store(api_key: str, base_url: str, texts: List[str], embedding_b vectorstore.persist() return vectorstore -def load_vector_store(api_key: str, base_url: str, embedding_base_url: str = "") -> Optional[Chroma]: +def load_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str = "" + ) -> Optional[Chroma]: """ 读取已存在的向量库。若不存在则返回 None。 同样支持可选的 embedding_base_url。 @@ -95,30 +143,42 @@ def load_vector_store(api_key: str, base_url: str, embedding_base_url: str = "") if not os.path.exists(VECTOR_STORE_DIR): return None embed_url = embedding_base_url if embedding_base_url else base_url - embeddings = OpenAIEmbeddings( - openai_api_key=api_key, - openai_api_base=embed_url - ) + embeddings = create_embeddings_object(api_key, base_url, embed_url, interface_format, embedding_model_name) return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) -def update_vector_store(api_key: str, base_url: str, new_chapter: str, embedding_base_url: str = "") -> None: +def update_vector_store( + api_key: str, + base_url: str, + new_chapter: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str = "" + ) -> None: """将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。""" - store = load_vector_store(api_key, base_url, embedding_base_url) + store = load_vector_store(api_key, base_url,interface_format, embedding_model_name, embedding_base_url) if not store: logging.info("Vector store does not exist. Initializing a new one...") - init_vector_store(api_key, base_url, [new_chapter], embedding_base_url) + init_vector_store(api_key, base_url,interface_format, embedding_model_name, [new_chapter], embedding_base_url) return new_doc = Document(page_content=new_chapter) store.add_documents([new_doc]) store.persist() -def get_relevant_context_from_vector_store(api_key: str, base_url: str, query: str, k: int = 2, embedding_base_url: str = "") -> str: +def get_relevant_context_from_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + query: str, + k: int = 2, + embedding_base_url: str = "" + ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 若向量库不存在则返回空字符串。 """ - store = load_vector_store(api_key, base_url, embedding_base_url) + store = load_vector_store(api_key, base_url,interface_format, embedding_model_name, embedding_base_url) if not store: logging.warning("Vector store not found. Returning empty context.") return "" diff --git a/ui.py b/ui.py index 4bfa638..c485834 100644 --- a/ui.py +++ b/ui.py @@ -38,12 +38,14 @@ class NovelGeneratorGUI: self.loaded_config = load_config(self.config_file) # ========== 主要的属性变量 ========== - # 右侧参数区 - 各种输入 + # 注意:配置项较多,此处增加 embedding_model_name_var 以支持 Embedding 模型名称 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) - self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) # 新增:可选Embedding模型URL + + self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) + self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) self.topic_default = self.loaded_config.get("topic", "") @@ -83,7 +85,7 @@ class NovelGeneratorGUI: # ------------------ 主功能 Tab ------------------ def build_main_tab(self): """ - 主Tab: 左侧显示日志 / 本章内容, 右侧显示主要功能操作区 + 主Tab: 左侧显示章节草稿/日志, 右侧是功能区 & 配置区 """ self.main_tab.rowconfigure(0, weight=1) self.main_tab.columnconfigure(0, weight=1) @@ -104,8 +106,8 @@ class NovelGeneratorGUI: def build_left_layout(self): """ 左侧包含两个区域: - 1. 输出日志(下半部分) - 2. 本章内容(上半部分) + 1. 本章草稿内容(可编辑) + 2. 输出日志(只读) """ self.left_frame.grid_rowconfigure(0, weight=3) self.left_frame.grid_rowconfigure(1, weight=1) @@ -115,177 +117,250 @@ class NovelGeneratorGUI: chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容", font=("Microsoft YaHei", 14)) 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)) self.chapter_result.grid(row=0, column=0, sticky="nsew", padx=5, pady=(0, 5)) # 输出日志 - log_label = ctk.CTkLabel(self.left_frame, text="输出日志", font=("Microsoft YaHei", 14)) + log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 14)) log_label.grid(row=1, column=0, padx=5, pady=(5, 0), sticky="w") + # 日志:只读 self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + # 设置只读 + self.log_text.configure(state="disabled") + def build_right_layout(self): """ - 右侧用于显示一系列参数输入和功能按钮。 + 右侧用于显示配置与功能按钮。 + 其中配置被拆分到一个子 TabView:AI接口配置 和 Embedding配置。 + 下面再放与小说相关的输入(主题/类型/章节数等)与功能按钮。 + """ + # 第一行创建一个子 TabView 放置 AI 配置 & Embedding 配置 + self.config_tabview = ctk.CTkTabview(self.right_frame, width=350, height=300) + self.config_tabview.grid(row=0, column=0, columnspan=2, sticky="nsew", padx=5, pady=5) + + self.ai_config_tab = self.config_tabview.add("AI接口配置") + self.embeddings_config_tab = self.config_tabview.add("Embedding配置") + + # 分别构建这两个 Tab 的布局 + self.build_ai_config_tab() + self.build_embeddings_config_tab() + + # 下面再放其他通用参数(主题、类型等) & 功能按钮 + row_base = 1 + # row_base + 1 处构建剩余输入,如topic, genre, etc. + self.build_novel_params_area(start_row=row_base+1) + + # 最后放一些主功能按钮 + self.build_main_buttons_area(start_row=row_base+10) + + def build_ai_config_tab(self): + """ + 在 AI接口配置 子Tab 上放置: + - API Key + - Base URL + - 接口格式 + - 模型名称 + - Temperature """ # 配置网格 - for i in range(25): - self.right_frame.grid_rowconfigure(i, weight=0) - self.right_frame.grid_columnconfigure(0, weight=0) - self.right_frame.grid_columnconfigure(1, weight=1) + for i in range(6): + 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) # 1. API Key - api_key_label = ctk.CTkLabel(self.right_frame, text="API Key:") + api_key_label = ctk.CTkLabel(self.ai_config_tab, text="API Key:") api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - api_key_entry = ctk.CTkEntry(self.right_frame, textvariable=self.api_key_var) - api_key_entry.grid(row=0, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + api_key_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var) + api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") # 2. Base URL - base_url_label = ctk.CTkLabel(self.right_frame, text="Base URL:") + base_url_label = ctk.CTkLabel(self.ai_config_tab, text="Base URL:") base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - base_url_entry = ctk.CTkEntry(self.right_frame, textvariable=self.base_url_var) - base_url_entry.grid(row=1, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + base_url_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.base_url_var) + base_url_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") - # 2.1 接口格式 下拉菜单 - interface_label = ctk.CTkLabel(self.right_frame, text="接口格式:") + # 3. 接口格式 + interface_label = ctk.CTkLabel(self.ai_config_tab, text="接口格式:") interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") interface_options = ["OpenAI", "Ollama", "ML Studio", "Local"] - interface_dropdown = ctk.CTkOptionMenu(self.right_frame, values=interface_options, variable=self.interface_format_var) - interface_dropdown.grid(row=2, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + interface_dropdown = ctk.CTkOptionMenu( + self.ai_config_tab, + values=interface_options, + variable=self.interface_format_var + ) + interface_dropdown.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") - # 3. Model Name - model_name_label = ctk.CTkLabel(self.right_frame, text="Model Name:") + # 4. 模型名称 + model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:") model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") - model_name_entry = ctk.CTkEntry(self.right_frame, textvariable=self.model_name_var) - model_name_entry.grid(row=3, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + model_name_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.model_name_var) + model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") - # 3.1 Embedding Model URL (可选) - embedding_url_label = ctk.CTkLabel(self.right_frame, text="Embedding URL:") - embedding_url_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - embedding_url_entry = ctk.CTkEntry(self.right_frame, textvariable=self.embedding_url_var) - embedding_url_entry.grid(row=4, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + # 5. Temperature + temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:") + temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - # 4. Temperature - temp_label = ctk.CTkLabel(self.right_frame, text="Temperature:") - temp_label.grid(row=5, column=0, padx=5, pady=5, sticky="e") - def update_temp_label(value): self.temp_value_label.configure(text=f"{float(value):.2f}") - temp_scale = ctk.CTkSlider(self.right_frame, from_=0.0, to=1.0, number_of_steps=100, - command=update_temp_label, variable=self.temperature_var) - temp_scale.grid(row=5, column=1, padx=5, pady=5, sticky="we") - - self.temp_value_label = ctk.CTkLabel(self.right_frame, text=f"{self.temperature_var.get():.2f}") - self.temp_value_label.grid(row=5, column=2, padx=1, pady=1, sticky="w") - # 5. 主题(Topic) 多行输入 + temp_scale = ctk.CTkSlider( + self.ai_config_tab, + from_=0.0, to=1.0, + number_of_steps=100, + 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}") + self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") + + # 保存/加载配置按钮(与AI配置归一处) + config_frame = ctk.CTkFrame(self.ai_config_tab) + config_frame.grid(row=5, column=0, columnspan=3, sticky="nsew") + + save_config_btn = ctk.CTkButton(config_frame, text="保存配置", command=self.save_config_btn) + save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="nsew") + + load_config_btn = ctk.CTkButton(config_frame, text="加载配置", command=self.load_config_btn) + load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + + def build_embeddings_config_tab(self): + """ + 在 Embedding配置 子Tab 上放置: + - Embedding URL + - Embedding 模型名称 + """ + for i in range(2): + 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) + + # 1. Embedding URL + embedding_url_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding URL:") + embedding_url_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + embedding_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var) + embedding_url_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + + # 2. Embedding 模型名称 + emb_model_name_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Model Name:") + emb_model_name_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + emb_model_name_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_model_name_var) + emb_model_name_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + + def build_novel_params_area(self, start_row=2): + """ + 放置与小说本身相关的一些配置:主题、类型、章节数、文件路径等 + """ + # row = start_row + # 主题(Topic) topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) - topic_label.grid(row=6, column=0, padx=5, pady=5, sticky="e") + topic_label.grid(row=start_row, column=0, padx=5, pady=5, sticky="e") self.topic_text = ctk.CTkTextbox(self.right_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) - self.topic_text.grid(row=6, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + self.topic_text.grid(row=start_row, column=1, padx=5, pady=5, sticky="nsew") if self.topic_default: self.topic_text.insert("0.0", self.topic_default) - # 6. 类型(Genre) + # 类型(Genre) genre_label = ctk.CTkLabel(self.right_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) - genre_label.grid(row=7, column=0, padx=5, pady=5, sticky="e") + genre_label.grid(row=start_row+1, column=0, padx=5, pady=5, sticky="e") genre_entry = ctk.CTkEntry(self.right_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12)) - genre_entry.grid(row=7, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + genre_entry.grid(row=start_row+1, column=1, padx=5, pady=5, sticky="nsew") - # 7. 章节数 + # 章节数 num_chapters_label = ctk.CTkLabel(self.right_frame, text="章节数:", font=("Microsoft YaHei", 12)) - num_chapters_label.grid(row=8, column=0, padx=5, pady=5, sticky="e") + num_chapters_label.grid(row=start_row+2, column=0, padx=5, pady=5, sticky="e") num_chapters_entry = ctk.CTkEntry(self.right_frame, textvariable=self.num_chapters_var, width=80) - num_chapters_entry.grid(row=8, column=1, padx=5, pady=5, sticky="w") + num_chapters_entry.grid(row=start_row+2, column=1, padx=5, pady=5, sticky="w") - # 8. 每章字数 + # 每章字数 word_number_label = ctk.CTkLabel(self.right_frame, text="每章字数:", font=("Microsoft YaHei", 12)) - word_number_label.grid(row=9, column=0, padx=5, pady=5, sticky="e") + word_number_label.grid(row=start_row+3, column=0, padx=5, pady=5, sticky="e") word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80) - word_number_entry.grid(row=9, column=1, padx=5, pady=5, sticky="w") + word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w") - # 9. 文件保存路径 + # 保存路径 filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) - filepath_label.grid(row=10, column=0, padx=5, pady=5, sticky="e") + filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e") filepath_entry = ctk.CTkEntry(self.right_frame, textvariable=self.filepath_var) - filepath_entry.grid(row=10, column=1, padx=5, pady=5, sticky="nsew") - browse_btn = ctk.CTkButton(self.right_frame, text="浏览...", command=self.browse_folder, width=60, font=("Microsoft YaHei", 12)) - browse_btn.grid(row=10, column=2, padx=1, pady=1, sticky="w") + filepath_entry.grid(row=start_row+4, column=1, padx=5, pady=5, sticky="nsew") + browse_btn = ctk.CTkButton(self.right_frame, text="浏览...", command=self.browse_folder, width=60) + browse_btn.grid(row=start_row+4, column=2, padx=1, pady=1, sticky="w") - # 保存/加载配置按钮 - config_frame = ctk.CTkFrame(self.right_frame) - config_frame.grid(row=11, column=1, columnspan=2, sticky="nsew") - - save_config_btn = ctk.CTkButton(config_frame, text="保存配置", command=self.save_config_btn, font=("Microsoft YaHei", 12)) - save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="nsew") - - load_config_btn = ctk.CTkButton(config_frame, text="加载配置", command=self.load_config_btn, font=("Microsoft YaHei", 12)) - load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") - - # 10. 章节号 + # 章节号 chapter_num_label = ctk.CTkLabel(self.right_frame, text="章节号:", font=("Microsoft YaHei", 12)) - chapter_num_label.grid(row=12, column=0, padx=5, pady=5, sticky="e") + chapter_num_label.grid(row=start_row+5, column=0, padx=5, pady=5, sticky="e") chapter_num_entry = ctk.CTkEntry(self.right_frame, textvariable=self.chapter_num_var, width=80) - chapter_num_entry.grid(row=12, column=1, padx=5, pady=5, sticky="w") + chapter_num_entry.grid(row=start_row+5, column=1, padx=5, pady=5, sticky="w") - # 11. “用户指导” 多行输入 + # 用户指导 guide_label = ctk.CTkLabel(self.right_frame, text="本章指导:", font=("Microsoft YaHei", 12)) - guide_label.grid(row=13, column=0, padx=5, pady=5, sticky="ne") + guide_label.grid(row=start_row+6, column=0, padx=5, pady=5, sticky="ne") self.user_guide_text = ctk.CTkTextbox(self.right_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) - self.user_guide_text.grid(row=13, column=1, columnspan=2, padx=5, pady=5, sticky="nsew") + self.user_guide_text.grid(row=start_row+6, column=1, padx=5, pady=5, sticky="nsew") + + def build_main_buttons_area(self, start_row=10): + """ + 主要功能按钮 + """ + self.right_frame.grid_rowconfigure(start_row, weight=0) + self.right_frame.grid_columnconfigure(0, weight=0) + self.right_frame.grid_columnconfigure(1, weight=0) - # 功能按钮区域 - row_base = 14 self.btn_generate_full = ctk.CTkButton( self.right_frame, text="Step1. 生成设定 & 目录", command=self.generate_full_novel, font=("Microsoft YaHei", 12) ) - self.btn_generate_full.grid(row=row_base, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + self.btn_generate_full.grid(row=start_row, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_generate_chapter = ctk.CTkButton( self.right_frame, text="Step2. 生成章节草稿", command=self.generate_chapter_draft_ui, font=("Microsoft YaHei", 12) ) - self.btn_generate_chapter.grid(row=row_base+1, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + self.btn_generate_chapter.grid(row=start_row+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_finalize_chapter = ctk.CTkButton( self.right_frame, text="Step3. 定稿当前章节", command=self.finalize_chapter_ui, font=("Microsoft YaHei", 12) ) - self.btn_finalize_chapter.grid(row=row_base+2, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + self.btn_finalize_chapter.grid(row=start_row+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_check_consistency = ctk.CTkButton( self.right_frame, text="[可选]一致性审校", command=self.do_consistency_check, font=("Microsoft YaHei", 12) ) - self.btn_check_consistency.grid(row=row_base+3, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + self.btn_check_consistency.grid(row=start_row+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_import_knowledge = ctk.CTkButton( self.right_frame, text="[可选]导入知识库", command=self.import_knowledge_handler, font=("Microsoft YaHei", 12) ) - self.btn_import_knowledge.grid(row=row_base+4, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + self.btn_import_knowledge.grid(row=start_row+4, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_clear_vectorstore = ctk.CTkButton( self.right_frame, text="清空向量库", - fg_color="red", # 让按钮显眼一些 + fg_color="red", command=self.clear_vectorstore_handler, font=("Microsoft YaHei", 12) ) - self.btn_clear_vectorstore.grid(row=row_base+5, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + self.btn_clear_vectorstore.grid(row=start_row+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") plot_arcs_btn = ctk.CTkButton( self.right_frame, text="[查看] 剧情要点", command=self.show_plot_arcs_ui, font=("Microsoft YaHei", 12) ) - plot_arcs_btn.grid(row=row_base+6, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + plot_arcs_btn.grid(row=start_row+6, column=0, columnspan=2, padx=5, pady=5, sticky="ew") # ------------------ Novel Settings Tab ------------------ def build_setting_tab(self): @@ -460,6 +535,7 @@ class NovelGeneratorGUI: self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) self.model_name_var.set(cfg.get("model_name", "")) self.embedding_url_var.set(cfg.get("embedding_url", "")) + self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) self.temperature_var.set(cfg.get("temperature", 0.7)) self.genre_var.set(cfg.get("genre", "")) self.num_chapters_var.set(cfg.get("num_chapters", 10)) @@ -481,6 +557,7 @@ class NovelGeneratorGUI: "interface_format": self.interface_format_var.get(), "model_name": self.model_name_var.get(), "embedding_url": self.embedding_url_var.get(), + "embedding_model_name": self.embedding_model_name_var.get(), "temperature": self.temperature_var.get(), "topic": self.topic_text.get("0.0", "end").strip(), "genre": self.genre_var.get(), @@ -501,8 +578,12 @@ class NovelGeneratorGUI: # ------------------ 日志输出 ------------------ 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 disable_button(self, btn): @@ -587,9 +668,9 @@ class NovelGeneratorGUI: chapters_dir = os.path.join(filepath, "chapters") recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) - # 用当前模型生成一个较为详细的最近剧情摘要 + # 简易生成最近章节摘要(示例) recent_chapters_summary = summarize_recent_chapters( - None, # 这里只是示例,实际可根据你的需求传入对应的模型 + None, # 此处仅示例 recent_3_texts ) @@ -610,7 +691,7 @@ class NovelGeneratorGUI: filepath=filepath ) if draft_text: - self.log(f"✅ 第{chap_num}章草稿生成完成。请在下方查看。") + self.log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") self.chapter_result.delete("0.0", "end") self.chapter_result.insert("0.0", draft_text) self.chapter_result.see("end") @@ -741,7 +822,8 @@ class NovelGeneratorGUI: import_knowledge_file( api_key=self.api_key_var.get().strip(), base_url=self.base_url_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip(), # 新增:传入embedding url + # 传入 embedding_url + embedding_model_name + embedding_base_url=self.embedding_url_var.get().strip(), file_path=selected_file ) self.log("✅ 知识库文件导入完成。") From 3dd81ecb12fcaa8a0cc90a0d47902caea7a45261 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 14:39:30 +0800 Subject: [PATCH 024/142] again --- novel_generator.py | 243 ++++++++++++++++++++++++++------------------- ui.py | 165 +++++++++++++++++------------- 2 files changed, 238 insertions(+), 170 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index 42fa110..77b4b56 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -5,7 +5,7 @@ import logging import re from typing import Dict, List, Optional try: - from typing import TypedDict # Python 3.8+ 直接可用;若是3.7可改用 typing_extensions + from typing import TypedDict except ImportError: from typing_extensions import TypedDict @@ -33,7 +33,15 @@ from prompt_definitions import ( from embedding_ollama import OllamaEmbeddings from chapter_directory_parser import get_chapter_info_from_directory -# ============ 适配接口的判断函数 ============ +# ============ 日志配置 ============ +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + +def debug_log(prompt: str, response_content: str): + """打印Prompt与Response,可根据需要保留或去掉。""" + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") + +# ============ 接口判断函数 ============ def is_using_ollama_api(interface_format: str, base_url: str) -> bool: """ 当 interface_format == "Ollama" 时返回 True @@ -60,13 +68,14 @@ def create_embeddings_object( """ 根据用户在UI中配置的参数,返回对应的 embeddings 对象。 - 当 interface_format = "Ollama" => OllamaEmbeddings(...) - - 当 interface_format = "OpenAI" => OpenAIEmbeddings - - 当 interface_format = "ML Studio" => OpenAIEmbeddings + - 当 interface_format = "OpenAI" or "ML Studio" => OpenAIEmbeddings + - 其它情况可自行扩展 """ if is_using_ollama_api(interface_format, embed_url): # 使用 Ollama Embeddings return OllamaEmbeddings(model_name=embedding_model_name, base_url=embed_url) elif is_using_ml_studio_api(interface_format, base_url): + # 示例同用 OpenAIEmbeddings return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) else: # 默认使用 OpenAIEmbeddings @@ -75,12 +84,7 @@ def create_embeddings_object( # ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") -def debug_log(prompt: str, response_content: str): - """在控制台打印或记录下每次Prompt与Response,[调试]""" - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") - -# ============ 向量检索相关 ============ +# ============ 向量库相关 ============ VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") if not os.path.exists(VECTOR_STORE_DIR): @@ -106,20 +110,25 @@ def clear_vector_store(): logging.info("No vector store found to clear.") def init_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - texts: List[str], - embedding_base_url: str = "" - ) -> Chroma: + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + texts: List[str], + embedding_base_url: str = "" +) -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 - 如果不存在该目录,会自动创建。 - 如果 embedding_base_url 不为空,则使用它做为embedding的base,否则默认base_url。 + embedding_base_url 若不为空,则用于 Ollama 模式下;否则默认使用 base_url """ embed_url = embedding_base_url if embedding_base_url else base_url - embeddings = create_embeddings_object(api_key, base_url, embed_url, interface_format, embedding_model_name) + embeddings = create_embeddings_object( + api_key=api_key, + base_url=base_url, + embed_url=embed_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name + ) documents = [Document(page_content=t) for t in texts] vectorstore = Chroma.from_documents( documents, @@ -130,35 +139,55 @@ def init_vector_store( return vectorstore def load_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - embedding_base_url: str = "" - ) -> Optional[Chroma]: + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str = "" +) -> Optional[Chroma]: """ 读取已存在的向量库。若不存在则返回 None。 - 同样支持可选的 embedding_base_url。 """ if not os.path.exists(VECTOR_STORE_DIR): return None embed_url = embedding_base_url if embedding_base_url else base_url - embeddings = create_embeddings_object(api_key, base_url, embed_url, interface_format, embedding_model_name) + embeddings = create_embeddings_object( + api_key=api_key, + base_url=base_url, + embed_url=embed_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name + ) return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) def update_vector_store( - api_key: str, - base_url: str, - new_chapter: str, - interface_format: str, - embedding_model_name: str, - embedding_base_url: str = "" - ) -> None: - """将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。""" - store = load_vector_store(api_key, base_url,interface_format, embedding_model_name, embedding_base_url) + api_key: str, + base_url: str, + new_chapter: str, + interface_format: str = "OpenAI", + embedding_model_name: str = "", + embedding_base_url: str = "" +) -> None: + """ + 将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。 + """ + store = load_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + embedding_base_url=embedding_base_url + ) if not store: logging.info("Vector store does not exist. Initializing a new one...") - init_vector_store(api_key, base_url,interface_format, embedding_model_name, [new_chapter], embedding_base_url) + init_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=[new_chapter], + embedding_base_url=embedding_base_url + ) return new_doc = Document(page_content=new_chapter) @@ -166,19 +195,25 @@ def update_vector_store( store.persist() def get_relevant_context_from_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - query: str, - k: int = 2, - embedding_base_url: str = "" - ) -> str: + api_key: str, + base_url: str, + query: str, + interface_format: str = "OpenAI", + embedding_model_name: str = "", + embedding_base_url: str = "", + k: int = 2 +) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 若向量库不存在则返回空字符串。 """ - store = load_vector_store(api_key, base_url,interface_format, embedding_model_name, embedding_base_url) + store = load_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + embedding_base_url=embedding_base_url + ) if not store: logging.warning("Vector store not found. Returning empty context.") return "" @@ -186,6 +221,7 @@ def get_relevant_context_from_vector_store( combined = "\n".join([d.page_content for d in docs]) return combined + # ============ 多步生成:设置 & 目录 ============ class OverallState(TypedDict): @@ -324,7 +360,6 @@ def Novel_novel_directory_generate( filename_set = os.path.join(filepath, "Novel_setting.txt") filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") - # 清理文本(可根据需要去除多余字符) def clean_text(txt: str) -> str: return txt.replace('#', '').replace('*', '') @@ -336,6 +371,7 @@ def Novel_novel_directory_generate( logging.info("Novel settings and directory generated successfully.") + # ============ 获取最近N章内容,生成短期摘要 ============ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: @@ -356,17 +392,37 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int def summarize_recent_chapters(model, chapters_text_list: List[str]) -> str: """ 将最近几章的文本拼接后,通过模型生成一个相对详细的“短期内容摘要”。 - 这里仅作示例,实际可传入 ChatOpenAI 或其他模型对象,以获取真实摘要。 + 如果没有可用的模型(model=None),则退化为简单截断示例。 """ if not chapters_text_list: return "" - # 模拟返回合并摘要,这里不做真实OpenAI调用 combined_text = "\n".join(chapters_text_list) - # 简单演示:直接返回合并后的文本,或你自己实现真正的摘要逻辑 - return f"【摘要】最近几章内容:\n{combined_text[:800]}..." # 截断示例 + # 如果未传入model,就做个简单的退化输出 + if not model: + return f"【摘要-演示】\n{combined_text[:800]}..." -# ============ 新增1:记录剧情要点/未解决冲突 ============ + # 构造一个提示词(Prompt),指示模型生成精简摘要 + prompt = f"""你是一名资深的长篇小说写作辅助AI。下面是最近几章的合并文本内容: +{combined_text} + +请你为此文本生成一段简洁扼要的摘要,突出主要剧情进展、角色变化、冲突焦点等要点。 +1.请用中文输出,不超过500字。 +2.仅回复摘要内容,不需要其他信息。 +""" + + # 调用模型获取摘要 + response = model.invoke(prompt) + if not response or not response.content.strip(): + # 若模型无响应或空,返回简单截断 + return f"【摘要-演示】\n{combined_text[:800]}..." + + # 返回模型生成的摘要文本 + return response.content.strip() + + + +# ============ 新增:更新剧情要点/未解决冲突 ============ PLOT_ARCS_PROMPT = """\ 下面是新生成的章节内容: @@ -409,6 +465,7 @@ def update_plot_arcs( debug_log(prompt, response.content) return response.content.strip() + # ============ 生成章节草稿 & 定稿 ============ def generate_chapter_draft( @@ -436,13 +493,17 @@ def generate_chapter_draft( chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 1) 从向量库检索往期上下文 - # 在此示例中,如需独立的embedding url,可自行扩展 + # 1) 从向量库检索上下文 (此处仅演示 query="回顾剧情") relevant_context = get_relevant_context_from_vector_store( - api_key, base_url, "回顾剧情", k=2 + api_key=api_key, + base_url=base_url, + query="回顾剧情", + interface_format="OpenAI", # 若需根据 UI 选择可再传参 + embedding_model_name="", # 同上 + embedding_base_url="", + k=2 ) - # 2) 生成大纲 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -450,6 +511,7 @@ def generate_chapter_draft( temperature=temperature ) + # 2) 生成大纲 outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, @@ -458,18 +520,11 @@ def generate_chapter_draft( chapter_title=chapter_title, chapter_brief=chapter_brief ) - - outline_prompt_text += f"\n\n【本章目录标题与简述】\n标题:{chapter_title}\n简述:{chapter_brief}\n" - outline_prompt_text += f"\n【最近几章摘要】\n{recent_chapters_summary}" + outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" response_outline = model.invoke(outline_prompt_text) - if not response_outline: - logging.warning("generate_chapter_draft: outline no response.") - chapter_outline = "" - else: - debug_log(outline_prompt_text, response_outline.content) - chapter_outline = response_outline.content.strip() + chapter_outline = response_outline.content.strip() if response_outline else "" outlines_dir = os.path.join(filepath, "outlines") os.makedirs(outlines_dir, exist_ok=True) @@ -487,18 +542,11 @@ def generate_chapter_draft( chapter_title=chapter_title, chapter_brief=chapter_brief ) - - writing_prompt_text += f"\n\n【本章目录标题与简述】\n标题:{chapter_title}\n简述:{chapter_brief}\n" - writing_prompt_text += f"\n【最近几章摘要】\n{recent_chapters_summary}" + writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" response_chapter = model.invoke(writing_prompt_text) - if not response_chapter: - logging.warning("generate_chapter_draft: writing no response.") - chapter_content = "" - else: - debug_log(writing_prompt_text, response_chapter.content) - chapter_content = response_chapter.content.strip() + chapter_content = response_chapter.content.strip() if response_chapter else "" chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) @@ -534,16 +582,15 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 读取角色状态 & 全局摘要 & 剧情要点 character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") # 新增文件 + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") old_char_state = read_file(character_state_file) old_global_summary = read_file(global_summary_file) old_plot_arcs = read_file(plot_arcs_file) - # 1) 先检查字数是否过少,若少于 80% 则调用 enrich 逻辑 + # 1) 若字数明显不足,做 enrich if len(chapter_text) < 0.8 * word_number: logging.info("Chapter text seems shorter than 80% of desired length. Attempting to enrich content...") chapter_text = enrich_chapter_text( @@ -554,7 +601,6 @@ def finalize_chapter( model_name=model_name, temperature=temperature ) - # 覆盖写回文件 clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) logging.info("Chapter text has been enriched and updated.") @@ -573,11 +619,7 @@ def finalize_chapter( global_summary=old_summary ) response = model.invoke(prompt) - if not response: - logging.warning("update_global_summary: No response.") - return old_summary - debug_log(prompt, response.content) - return response.content.strip() + return response.content.strip() if response else old_summary new_global_summary = update_global_summary(chapter_text, old_global_summary) @@ -588,15 +630,11 @@ def finalize_chapter( old_state=old_state ) response = model.invoke(prompt) - if not response: - logging.warning("update_character_state: No response.") - return old_state - debug_log(prompt, response.content) - return response.content.strip() + return response.content.strip() if response else old_state new_char_state = update_character_state(chapter_text, old_char_state) - # ============ 新增2: 更新剧情要点 ============= + # 4) 更新剧情要点 new_plot_arcs = update_plot_arcs( chapter_text=chapter_text, old_plot_arcs=old_plot_arcs, @@ -606,7 +644,7 @@ def finalize_chapter( temperature=temperature ) - # 4) 覆盖写入角色状态文件、全局摘要文件、剧情要点文件 + # 5) 覆盖写入文件 clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) @@ -616,10 +654,16 @@ def finalize_chapter( clear_file_content(plot_arcs_file) save_string_to_txt(new_plot_arcs, plot_arcs_file) - # 5) 更新向量检索库 - update_vector_store(api_key, base_url, chapter_text) + # 6) 更新向量库 + update_vector_store( + api_key=api_key, + base_url=base_url, + new_chapter=chapter_text, + interface_format="OpenAI", + embedding_model_name="" + ) - logging.info(f"Chapter {novel_number} has been finalized (summary & state updated, plot arcs updated, vector store updated).") + logging.info(f"Chapter {novel_number} has been finalized.") def enrich_chapter_text( chapter_text: str, @@ -639,17 +683,14 @@ def enrich_chapter_text( base_url=base_url, temperature=temperature ) - prompt = f"""\ -以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 + prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 原章节内容: -{chapter_text} -""" +{chapter_text}""" + response = model.invoke(prompt) if not response: - logging.warning("enrich_chapter_text: No response.") - return chapter_text # 无响应时就返回原文 - debug_log(prompt, response.content) + return chapter_text return response.content.strip() # ============ 导入外部知识文本 ============ diff --git a/ui.py b/ui.py index c485834..0832f7a 100644 --- a/ui.py +++ b/ui.py @@ -31,7 +31,7 @@ class NovelGeneratorGUI: # 窗口最大化 self.master.state("zoomed") # 配置窗口大小 - self.master.geometry("1344x896") + self.master.geometry("1428x960") # 配置持久化 self.config_file = "config.json" @@ -61,7 +61,7 @@ class NovelGeneratorGUI: self.tabview.pack(fill="both", expand=True) # 创建各个Tab - self.main_tab = self.tabview.add("主功能") + self.main_tab = self.tabview.add("Main Functions") self.setting_tab = self.tabview.add("Novel Settings") self.directory_tab = self.tabview.add("Novel Directory") self.character_tab = self.tabview.add("Character State") @@ -137,25 +137,31 @@ class NovelGeneratorGUI: 右侧用于显示配置与功能按钮。 其中配置被拆分到一个子 TabView:AI接口配置 和 Embedding配置。 下面再放与小说相关的输入(主题/类型/章节数等)与功能按钮。 + + 另:我们只使用 2 列布局,0 列放标签,1 列放输入框或子区域,保证对齐。 """ - # 第一行创建一个子 TabView 放置 AI 配置 & Embedding 配置 - self.config_tabview = ctk.CTkTabview(self.right_frame, width=350, height=300) - self.config_tabview.grid(row=0, column=0, columnspan=2, sticky="nsew", padx=5, pady=5) + self.right_frame.grid_rowconfigure(0, weight=0) + self.right_frame.grid_rowconfigure(1, weight=0) + self.right_frame.grid_rowconfigure(2, weight=1) + self.right_frame.grid_columnconfigure(0, weight=0) + self.right_frame.grid_columnconfigure(1, weight=1) - self.ai_config_tab = self.config_tabview.add("AI接口配置") - self.embeddings_config_tab = self.config_tabview.add("Embedding配置") + # ========== 1. 配置项 TabView(AI接口配置 & Embedding配置) ========== + self.config_tabview = ctk.CTkTabview(self.right_frame) + self.config_tabview.grid(row=0, column=0, columnspan=2, sticky="we", padx=5, pady=5) - # 分别构建这两个 Tab 的布局 + self.ai_config_tab = self.config_tabview.add("LLM Model settings") + self.embeddings_config_tab = self.config_tabview.add("Embedding settings") + + # 构建这两个 Tab 的布局 self.build_ai_config_tab() self.build_embeddings_config_tab() - # 下面再放其他通用参数(主题、类型等) & 功能按钮 - row_base = 1 - # row_base + 1 处构建剩余输入,如topic, genre, etc. - self.build_novel_params_area(start_row=row_base+1) + # ========== 2. 与小说相关的参数区 ========== + self.build_novel_params_area(start_row=1) - # 最后放一些主功能按钮 - self.build_main_buttons_area(start_row=row_base+10) + # ========== 3. 放主要功能按钮 & “保存/加载配置”按钮区 ========== + self.build_main_buttons_area(start_row=10) def build_ai_config_tab(self): """ @@ -167,42 +173,55 @@ class NovelGeneratorGUI: - Temperature """ # 配置网格 - for i in range(6): + for i in range(5): 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) + # 回调:当接口格式下拉框发生变更时,如果 Base URL 为空,则根据接口类型自动填默认值 + def on_interface_format_changed(new_value): + # current_base = self.base_url_var.get().strip() + # if not current_base: + if new_value == "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.base_url_var.set("https://api.agicto.cn/v1") + # 1. API Key - api_key_label = ctk.CTkLabel(self.ai_config_tab, text="API Key:") + api_key_label = ctk.CTkLabel(self.ai_config_tab, text="API Key:", font=("Microsoft YaHei", 12)) api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - api_key_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var) + 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, sticky="nsew") # 2. Base URL - base_url_label = ctk.CTkLabel(self.ai_config_tab, text="Base URL:") + base_url_label = ctk.CTkLabel(self.ai_config_tab, text="Base URL:", font=("Microsoft YaHei", 12)) base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - base_url_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.base_url_var) + 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, sticky="nsew") # 3. 接口格式 - interface_label = ctk.CTkLabel(self.ai_config_tab, text="接口格式:") + interface_label = ctk.CTkLabel(self.ai_config_tab, text="接口格式:", font=("Microsoft YaHei", 12)) interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") interface_options = ["OpenAI", "Ollama", "ML Studio", "Local"] interface_dropdown = ctk.CTkOptionMenu( self.ai_config_tab, values=interface_options, - variable=self.interface_format_var + 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, sticky="nsew") # 4. 模型名称 - model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:") + model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:", font=("Microsoft YaHei", 12)) model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") - model_name_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.model_name_var) + 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, sticky="nsew") # 5. Temperature - temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:") + temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:", font=("Microsoft YaHei", 12)) temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") def update_temp_label(value): @@ -217,19 +236,9 @@ class NovelGeneratorGUI: ) 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}") + 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=1, pady=1, sticky="w") - # 保存/加载配置按钮(与AI配置归一处) - config_frame = ctk.CTkFrame(self.ai_config_tab) - config_frame.grid(row=5, column=0, columnspan=3, sticky="nsew") - - save_config_btn = ctk.CTkButton(config_frame, text="保存配置", command=self.save_config_btn) - save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="nsew") - - load_config_btn = ctk.CTkButton(config_frame, text="加载配置", command=self.load_config_btn) - load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") - def build_embeddings_config_tab(self): """ 在 Embedding配置 子Tab 上放置: @@ -242,22 +251,22 @@ class NovelGeneratorGUI: self.embeddings_config_tab.grid_columnconfigure(1, weight=1) # 1. Embedding URL - embedding_url_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding URL:") + embedding_url_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding URL:", font=("Microsoft YaHei", 12)) embedding_url_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - embedding_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var) + embedding_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var, font=("Microsoft YaHei", 12)) embedding_url_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") # 2. Embedding 模型名称 - emb_model_name_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Model Name:") + emb_model_name_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Model Name:", font=("Microsoft YaHei", 12)) emb_model_name_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - emb_model_name_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_model_name_var) + 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=1, column=1, padx=5, pady=5, sticky="nsew") def build_novel_params_area(self, start_row=2): """ 放置与小说本身相关的一些配置:主题、类型、章节数、文件路径等 + 只使用两列:0列放Label,1列放输入框(可能加子Frame)。 """ - # row = start_row # 主题(Topic) topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) topic_label.grid(row=start_row, column=0, padx=5, pady=5, sticky="e") @@ -270,32 +279,37 @@ class NovelGeneratorGUI: genre_label = ctk.CTkLabel(self.right_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) genre_label.grid(row=start_row+1, column=0, padx=5, pady=5, sticky="e") genre_entry = ctk.CTkEntry(self.right_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12)) - genre_entry.grid(row=start_row+1, column=1, padx=5, pady=5, sticky="nsew") + genre_entry.grid(row=start_row+1, column=1, padx=5, pady=5, sticky="ew") # 章节数 num_chapters_label = ctk.CTkLabel(self.right_frame, text="章节数:", font=("Microsoft YaHei", 12)) num_chapters_label.grid(row=start_row+2, column=0, padx=5, pady=5, sticky="e") - num_chapters_entry = ctk.CTkEntry(self.right_frame, textvariable=self.num_chapters_var, width=80) + num_chapters_entry = ctk.CTkEntry(self.right_frame, textvariable=self.num_chapters_var, width=80, font=("Microsoft YaHei", 12)) num_chapters_entry.grid(row=start_row+2, column=1, padx=5, pady=5, sticky="w") # 每章字数 word_number_label = ctk.CTkLabel(self.right_frame, text="每章字数:", font=("Microsoft YaHei", 12)) word_number_label.grid(row=start_row+3, column=0, padx=5, pady=5, sticky="e") - word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80) + word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80, font=("Microsoft YaHei", 12)) word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w") - # 保存路径 + # 保存路径 (使用子Frame放置 Entry + Button) filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e") - filepath_entry = ctk.CTkEntry(self.right_frame, textvariable=self.filepath_var) - filepath_entry.grid(row=start_row+4, column=1, padx=5, pady=5, sticky="nsew") - browse_btn = ctk.CTkButton(self.right_frame, text="浏览...", command=self.browse_folder, width=60) - browse_btn.grid(row=start_row+4, column=2, padx=1, pady=1, sticky="w") + + self.filepath_frame = ctk.CTkFrame(self.right_frame) + self.filepath_frame.grid(row=start_row+4, 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") # 章节号 chapter_num_label = ctk.CTkLabel(self.right_frame, text="章节号:", font=("Microsoft YaHei", 12)) chapter_num_label.grid(row=start_row+5, column=0, padx=5, pady=5, sticky="e") - chapter_num_entry = ctk.CTkEntry(self.right_frame, textvariable=self.chapter_num_var, width=80) + chapter_num_entry = ctk.CTkEntry(self.right_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12)) chapter_num_entry.grid(row=start_row+5, column=1, padx=5, pady=5, sticky="w") # 用户指导 @@ -306,46 +320,59 @@ class NovelGeneratorGUI: def build_main_buttons_area(self, start_row=10): """ - 主要功能按钮 + 主要功能按钮 + 配置保存/加载 按钮 """ + # 调整下布局:0,1列 + # 这里按行摆放多个按钮 self.right_frame.grid_rowconfigure(start_row, weight=0) - self.right_frame.grid_columnconfigure(0, weight=0) - self.right_frame.grid_columnconfigure(1, weight=0) + # 先放“保存配置”和“加载配置”按钮 + self.btn_frame_config = ctk.CTkFrame(self.right_frame) + self.btn_frame_config.grid(row=start_row, column=0, columnspan=2, 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") + + # 下面是主要功能按钮 self.btn_generate_full = ctk.CTkButton( self.right_frame, text="Step1. 生成设定 & 目录", command=self.generate_full_novel, font=("Microsoft YaHei", 12) ) - self.btn_generate_full.grid(row=start_row, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_generate_full.grid(row=start_row+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_generate_chapter = ctk.CTkButton( self.right_frame, text="Step2. 生成章节草稿", command=self.generate_chapter_draft_ui, font=("Microsoft YaHei", 12) ) - self.btn_generate_chapter.grid(row=start_row+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_generate_chapter.grid(row=start_row+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_finalize_chapter = ctk.CTkButton( self.right_frame, text="Step3. 定稿当前章节", command=self.finalize_chapter_ui, font=("Microsoft YaHei", 12) ) - self.btn_finalize_chapter.grid(row=start_row+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_finalize_chapter.grid(row=start_row+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_check_consistency = ctk.CTkButton( self.right_frame, text="[可选]一致性审校", command=self.do_consistency_check, font=("Microsoft YaHei", 12) ) - self.btn_check_consistency.grid(row=start_row+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_check_consistency.grid(row=start_row+4, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_import_knowledge = ctk.CTkButton( self.right_frame, text="[可选]导入知识库", command=self.import_knowledge_handler, font=("Microsoft YaHei", 12) ) - self.btn_import_knowledge.grid(row=start_row+4, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_import_knowledge.grid(row=start_row+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_clear_vectorstore = ctk.CTkButton( self.right_frame, text="清空向量库", @@ -353,14 +380,14 @@ class NovelGeneratorGUI: command=self.clear_vectorstore_handler, font=("Microsoft YaHei", 12) ) - self.btn_clear_vectorstore.grid(row=start_row+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_clear_vectorstore.grid(row=start_row+6, column=0, columnspan=2, padx=5, pady=5, sticky="ew") plot_arcs_btn = ctk.CTkButton( self.right_frame, text="[查看] 剧情要点", command=self.show_plot_arcs_ui, font=("Microsoft YaHei", 12) ) - plot_arcs_btn.grid(row=start_row+6, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + plot_arcs_btn.grid(row=start_row+7, column=0, columnspan=2, padx=5, pady=5, sticky="ew") # ------------------ Novel Settings Tab ------------------ def build_setting_tab(self): @@ -371,10 +398,10 @@ class NovelGeneratorGUI: self.setting_tab.rowconfigure(1, weight=1) self.setting_tab.columnconfigure(0, weight=1) - load_btn = ctk.CTkButton(self.setting_tab, text="加载 Novel_setting.txt", command=self.load_novel_setting) + load_btn = ctk.CTkButton(self.setting_tab, text="加载 Novel_setting.txt", command=self.load_novel_setting, 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_setting) + save_btn = ctk.CTkButton(self.setting_tab, text="保存修改", command=self.save_novel_setting, 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)) @@ -412,10 +439,10 @@ class NovelGeneratorGUI: 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_novel_directory) + load_btn = ctk.CTkButton(self.directory_tab, text="加载 Novel_directory.txt", command=self.load_novel_directory, 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_novel_directory) + save_btn = ctk.CTkButton(self.directory_tab, text="保存修改", command=self.save_novel_directory, 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)) @@ -453,10 +480,10 @@ class NovelGeneratorGUI: 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) + 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) + 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)) @@ -494,10 +521,10 @@ class NovelGeneratorGUI: 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) + 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) + 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)) @@ -670,7 +697,7 @@ class NovelGeneratorGUI: # 简易生成最近章节摘要(示例) recent_chapters_summary = summarize_recent_chapters( - None, # 此处仅示例 + model_name, # 此处仅示例 recent_3_texts ) @@ -865,7 +892,7 @@ class NovelGeneratorGUI: top.title("剧情要点/未解决冲突") top.geometry("600x400") - text_area = ctk.CTkTextbox(top, wrap="word") + 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) From 7741345ee13e172a5d2c922c17c780f3ca947565 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 15:50:19 +0800 Subject: [PATCH 025/142] 11 --- embedding_ollama.py | 13 +++++--- novel_generator.py | 81 ++++++++++++++++++++++++++++++++------------- ui.py | 17 +++++----- 3 files changed, 74 insertions(+), 37 deletions(-) diff --git a/embedding_ollama.py b/embedding_ollama.py index 36ee973..b755ddd 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -4,13 +4,15 @@ from typing import List class OllamaEmbeddings: """ - Ollama 本地服务提供 /api/embeddings 接口,响应中包含 {"embedding": [...]}。 + Ollama 本地服务提供的 Embedding 接口, + 本需求里我们最终拼出形如: http://localhost:11434/api/embed + 即 base_url + "/embed" """ def __init__(self, model_name: str, base_url: str): self.model_name = model_name - self.base_url = base_url - + self.base_url = base_url # 这里应形如 http://localhost:11434/api (不再含 /v1) + def embed(self, texts: List[str]) -> List[List[float]]: embeddings = [] for text in texts: @@ -35,9 +37,10 @@ class OllamaEmbeddings: def embed_single_document(self, text: str) -> List[float]: """ - 调用 Ollama 本地服务接口,获取文本的 embedding + 调用 Ollama 本地服务接口,获取文本的 embedding。 + 这里统一改为请求: [base_url]/embed """ - url = f"{self.base_url}/api/embeddings" + url = f"{self.base_url}/embed" data = { "model": self.model_name, "prompt": text diff --git a/novel_generator.py b/novel_generator.py index 77b4b56..a9d53ca 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -37,9 +37,10 @@ from chapter_directory_parser import get_chapter_info_from_directory logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") def debug_log(prompt: str, response_content: str): - """打印Prompt与Response,可根据需要保留或去掉。""" - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") + """打印Prompt与Response,可根据需要保留或去掉。""" + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") + # ============ 接口判断函数 ============ def is_using_ollama_api(interface_format: str, base_url: str) -> bool: @@ -58,6 +59,7 @@ def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: return True return False + def create_embeddings_object( api_key: str, base_url: str, @@ -68,14 +70,20 @@ def create_embeddings_object( """ 根据用户在UI中配置的参数,返回对应的 embeddings 对象。 - 当 interface_format = "Ollama" => OllamaEmbeddings(...) + (此时把 embed_url 中的 /v1 替换成 /api,以便最后调用 /api/embed) - 当 interface_format = "OpenAI" or "ML Studio" => OpenAIEmbeddings - 其它情况可自行扩展 """ if is_using_ollama_api(interface_format, embed_url): - # 使用 Ollama Embeddings - return OllamaEmbeddings(model_name=embedding_model_name, base_url=embed_url) + # 去除末尾斜杠 + fixed_url = embed_url.rstrip("/") + # 如果包含 /v1 则替换为 /api + fixed_url = fixed_url.replace("/v1", "/api") + return OllamaEmbeddings( + model_name=embedding_model_name, + base_url=fixed_url + ) elif is_using_ml_studio_api(interface_format, base_url): - # 示例同用 OpenAIEmbeddings return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) else: # 默认使用 OpenAIEmbeddings @@ -85,7 +93,6 @@ def create_embeddings_object( logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") # ============ 向量库相关 ============ - VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") if not os.path.exists(VECTOR_STORE_DIR): os.makedirs(VECTOR_STORE_DIR) @@ -119,7 +126,7 @@ def init_vector_store( ) -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 - embedding_base_url 若不为空,则用于 Ollama 模式下;否则默认使用 base_url + embedding_base_url 若不为空,则用于 Ollama 模式下;否则默认使用 base_url。 """ embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( @@ -164,8 +171,8 @@ def update_vector_store( api_key: str, base_url: str, new_chapter: str, - interface_format: str = "OpenAI", - embedding_model_name: str = "", + interface_format: str, + embedding_model_name: str, embedding_base_url: str = "" ) -> None: """ @@ -198,8 +205,8 @@ def get_relevant_context_from_vector_store( api_key: str, base_url: str, query: str, - interface_format: str = "OpenAI", - embedding_model_name: str = "", + interface_format: str, + embedding_model_name: str, embedding_base_url: str = "", k: int = 2 ) -> str: @@ -389,11 +396,24 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int texts.append(text) return texts -def summarize_recent_chapters(model, chapters_text_list: List[str]) -> str: +def summarize_recent_chapters( + llm_model: str, + api_key: str, + base_url: str, + temperature: float, + chapters_text_list: List[str] + ) -> str: """ 将最近几章的文本拼接后,通过模型生成一个相对详细的“短期内容摘要”。 如果没有可用的模型(model=None),则退化为简单截断示例。 """ + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=base_url, + temperature=temperature + ) + if not chapters_text_list: return "" @@ -410,7 +430,6 @@ def summarize_recent_chapters(model, chapters_text_list: List[str]) -> str: 1.请用中文输出,不超过500字。 2.仅回复摘要内容,不需要其他信息。 """ - # 调用模型获取摘要 response = model.invoke(prompt) if not response or not response.content.strip(): @@ -421,7 +440,6 @@ def summarize_recent_chapters(model, chapters_text_list: List[str]) -> str: return response.content.strip() - # ============ 新增:更新剧情要点/未解决冲突 ============ PLOT_ARCS_PROMPT = """\ @@ -498,8 +516,8 @@ def generate_chapter_draft( api_key=api_key, base_url=base_url, query="回顾剧情", - interface_format="OpenAI", # 若需根据 UI 选择可再传参 - embedding_model_name="", # 同上 + interface_format="OpenAI", + embedding_model_name="", embedding_base_url="", k=2 ) @@ -562,6 +580,8 @@ def finalize_chapter( word_number: int, api_key: str, base_url: str, + interface_format: str, + embedding_model_name: str, model_name: str, temperature: float, filepath: str @@ -659,8 +679,8 @@ def finalize_chapter( api_key=api_key, base_url=base_url, new_chapter=chapter_text, - interface_format="OpenAI", - embedding_model_name="" + interface_format=interface_format, + embedding_model_name=embedding_model_name ) logging.info(f"Chapter {novel_number} has been finalized.") @@ -695,10 +715,18 @@ def enrich_chapter_text( # ============ 导入外部知识文本 ============ -def import_knowledge_file(api_key: str, base_url: str, file_path: str, embedding_base_url: str = "") -> None: +def import_knowledge_file( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + file_path: str, + embedding_base_url: str = "" + ) -> None: """ 将用户选定的文本文件导入到向量库,以便在写作时检索。 """ + logging.info(f"开始导入知识库文件: {file_path},当前接口格式: {interface_format},当前模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return @@ -710,10 +738,17 @@ def import_knowledge_file(api_key: str, base_url: str, file_path: str, embedding paragraphs = advanced_split_content(content) - store = load_vector_store(api_key, base_url, embedding_base_url) + store = load_vector_store(api_key, base_url, interface_format, embedding_model_name, embedding_base_url) if not store: logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store(api_key, base_url, paragraphs, embedding_base_url) + init_vector_store( + api_key, + base_url, + interface_format, + embedding_model_name, + paragraphs, + embedding_base_url + ) return docs = [Document(page_content=p) for p in paragraphs] @@ -727,7 +762,7 @@ def advanced_split_content(content: str, """ 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 """ - nltk.download('punkt_tab', quiet=True) # 如有需求,可改成 'punkt' + nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(content) if not sentences: diff --git a/ui.py b/ui.py index 0832f7a..6bfc0e8 100644 --- a/ui.py +++ b/ui.py @@ -180,14 +180,12 @@ class NovelGeneratorGUI: # 回调:当接口格式下拉框发生变更时,如果 Base URL 为空,则根据接口类型自动填默认值 def on_interface_format_changed(new_value): - # current_base = self.base_url_var.get().strip() - # if not current_base: - if new_value == "Ollama": - self.base_url_var.set("http://localhost:11434/v1") - elif new_value == "ML Studio": - self.base_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.base_url_var.set("https://api.agicto.cn/v1") + if new_value == "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.base_url_var.set("https://api.agicto.cn/v1") # 1. API Key api_key_label = ctk.CTkLabel(self.ai_config_tab, text="API Key:", font=("Microsoft YaHei", 12)) @@ -849,7 +847,8 @@ class NovelGeneratorGUI: import_knowledge_file( api_key=self.api_key_var.get().strip(), base_url=self.base_url_var.get().strip(), - # 传入 embedding_url + embedding_model_name + interface_format=self.interface_format_var.get().strip(), + embedding_base_url=self.embedding_url_var.get().strip(), embedding_base_url=self.embedding_url_var.get().strip(), file_path=selected_file ) From aca9e4868339ac93f3d152a3bbb6962eaee28014 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 17:41:16 +0800 Subject: [PATCH 026/142] =?UTF-8?q?=E4=B8=8D=E7=9F=A5=E9=81=93=E5=87=BA?= =?UTF-8?q?=E4=BA=86=E4=BB=80=E4=B9=88=E9=97=AE=E9=A2=98=EF=BC=8C=E5=85=88?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=80=E4=B8=8B=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Src/Novel_directory.txt | 182 ---------------------------------- Src/Novel_setting.txt | 98 ------------------ embedding_ollama.py | 33 ++++-- novel_generator.py | 215 ++++++++++++++++++++-------------------- ui.py | 71 ++++++------- 5 files changed, 168 insertions(+), 431 deletions(-) delete mode 100644 Src/Novel_directory.txt delete mode 100644 Src/Novel_setting.txt diff --git a/Src/Novel_directory.txt b/Src/Novel_directory.txt deleted file mode 100644 index 3a9cc6f..0000000 --- a/Src/Novel_directory.txt +++ /dev/null @@ -1,182 +0,0 @@ - -第1章 :星河坠落提瓦特 - 穿越与初遇派蒙,危机初现端倪 -第2章 :蒙德的初印象 - 初探蒙德城,与丽莎的首次相遇 -第3章 :酒馆的闲谈 - 了解提瓦特局势,派蒙展现惊人一面 -第4章 :丽莎的试探 - 星河展现科技实力,引来丽莎的警惕 -第5章 :委托与冒险 - 接受委托,初次展现星穹铁道科技的运用 -第6章 :风魔龙的挑战 - 运用科技对抗风魔龙,展现策略与智慧 -第7章 :派蒙的异常 - 派蒙出现记忆碎片,暗示其不寻常身份 -第8章 :丽莎的秘密魔法 - 丽莎使用禁忌魔法,展现其神秘一面 -第9章 :旅行者的出现 - 与原神旅行者初次相遇,产生摩擦与合作 -第10章 :蒙德的阴谋 - 发现蒙德隐藏的危机,与丽莎深入合作 -第11章 :追捕令的阴影 - 星河收到星穹铁道追捕令,内心挣扎 -第12章 :深入调查 - 调查蒙德的危机,发现与丽莎的秘密组织有关 -第13章 :钟离的现身 - 钟离初次出现,对星河进行暗示与引导 -第14章 :失落科技的线索 - 发现关于提瓦特失落科技的线索 -第15章 :前往璃月 - 踏上前往璃月的旅程,面对新的挑战 -第16章 :璃月港的繁华 - 体验璃月港的文化,了解璃月局势 -第17章 :往生堂的委托 - 接受往生堂的委托,深入了解璃月历史 -第18章 :岩王帝君的考验 - 钟离对星河进行考验,揭示其真实目的 -第19章 :地脉异常 - 发现璃月地脉异常,与旅行者合作调查 -第20章 :黄金屋的秘密 - 探索黄金屋的秘密,发现与失落科技有关 -第21章 :刻晴的质疑 - 刻晴对星河的科技持有怀疑态度 -第22章 :对抗岩龙蜥 - 运用科技与策略对抗强大的岩龙蜥 -第23章 :派蒙的回忆 - 派蒙回忆片段增多,身份逐渐清晰 -第24章 :丽莎的引导 - 丽莎引导星河学习提瓦特魔法 -第25章 :星河的抉择 - 星河面临道德困境,做出艰难选择 -第26章 :前往稻妻 - 前往稻妻,面对新的挑战与危机 -第27章 :稻妻的雷电 - 体验稻妻的文化与习俗,对抗雷电将军的威压 -第28章 :八重神子的试探 - 八重神子对星河进行试探,考验其能力 -第29章 :影向地的秘密 - 探索影向地的秘密,发现与提瓦特历史有关 -第30章 :反抗军与合作 - 与稻妻反抗军合作,对抗幕后黑手 -第31章 :永恒的执政官 - 与雷电将军正面交锋,展现智慧与实力 -第32章 :派蒙的过去 - 派蒙的过去逐渐揭开,与提瓦特古老文明有关 -第33章 :丽莎的组织 - 丽莎所属组织的真相逐渐浮出水面 -第34章 :前往须弥 - 前往须弥,面对新的挑战与文化 -第35章 :须弥的智慧 - 体验须弥的文化,了解须弥的智慧与科技 -第36章 :草神的大智慧 - 与草神进行交流,探讨生命的意义 -第37章 :森林的危机 - 须弥森林面临危机,星河运用科技解决问题 -第38章 :沙漠的秘密 - 探索须弥沙漠的秘密,发现远古文明的遗迹 -第39章 :散兵的阴谋 - 散兵的阴谋逐渐浮出水面,星河需要应对 -第40章 :与散兵的对抗 - 与散兵展开对抗,展现星穹铁道科技的优势 -第41章 :前往枫丹 - 前往枫丹,面对新的挑战与科技 -第42章 :枫丹的审判 - 体验枫丹的法律与秩序,面对复杂的案件 -第43章 :水神的神秘 - 与水神进行互动,了解枫丹的秘密 -第44章 :机械岛的危机 - 枫丹机械岛面临危机,星河运用科技解决问题 -第45章 :潜入深海 - 潜入深海,探索深海的秘密与科技 -第46章 :派蒙的预言 - 派蒙预言即将到来的巨大危机 -第47章 :丽莎的抉择 - 丽莎面临艰难的抉择,需要做出牺牲 -第48章 :前往纳塔 - 前往纳塔,面对新的挑战与文化 -第49章 :纳塔的冰雪 - 体验纳塔的严寒环境,与冰雪元素对抗 -第50章 :雪山之巅的秘密 - 探索雪山之巅的秘密,发现远古文明的遗迹 -第51章 :冰神的考验 - 冰神对星河进行考验,考验其心性与毅力 -第52章 :与旅行者的合作 - 与旅行者深入合作,对抗共同的敌人 -第53章 :深渊的威胁 - 深渊的威胁日益逼近,星河需要寻找应对之策 -第54章 :钟离的计划 - 钟离的计划逐渐显露,其真实目的更加清晰 -第55章 :星河的过去 - 星河过去的阴影再次浮现,影响其与他人的关系 -第56章 :派蒙的觉醒 - 派蒙觉醒,展现其强大的力量 -第57章 :丽莎的牺牲 - 丽莎为了保护星河做出牺牲 -第58章 :前往至冬 - 前往至冬,面对新的挑战与阴谋 -第59章 :至冬的阴谋 - 揭露至冬的阴谋,对抗幕后黑手 -第60章 :女皇的算计 - 女皇对星河进行算计,考验其智慧与勇气 -第61章 :机械生命的危机 - 至冬的机械生命失控,引发巨大的危机 -第62章 :星河的策略 - 星河运用策略,化解机械生命的危机 -第63章 :派蒙的秘密力量 - 派蒙展现其强大的秘密力量 -第64章 :丽莎的遗产 - 丽莎留下宝贵的遗产,帮助星河对抗危机 -第65章 :对抗深渊使徒 - 对抗强大的深渊使徒,展现星穹铁道科技的威力 -第66章 :旅行者的选择 - 旅行者面临艰难的选择,需要做出决定 -第67章 :钟离的帮助 - 钟离给予星河关键的帮助,但其目的依旧不明 -第68章 :星河的成长 - 星河在经历种种磨难后得到成长 -第69章 :提瓦特的命运 - 提瓦特的命运掌握在星河手中 -第70章 :最终决战的序幕 - 最终决战的序幕拉开,危机全面爆发 -第71章 :与幕后黑手的对抗 - 与幕后黑手展开激烈的对抗 -第72章 :星河的牺牲 - 星河为了拯救提瓦特做出牺牲 -第73章 :派蒙的付出 - 派蒙为了帮助星河付出一切 -第74章 :丽莎的回归 - 丽莎以另一种方式回归,继续守护蒙德 -第75章 :旅行者的抉择 - 旅行者做出最终的抉择 -第76章 :钟离的真相 - 钟离的真实目的最终揭晓 -第77章 :星河的复活 - 星河以意想不到的方式复活 -第78章 :新的危机 - 新的危机出现,提瓦特面临新的挑战 -第79章 :星河的抉择 - 星河需要做出新的抉择,选择自己的未来 -第80章 :派蒙的未来 - 派蒙的未来充满希望与未知 -第81章 :丽莎的守护 - 丽莎继续守护蒙德,为提瓦特贡献力量 -第82章 :旅行者的旅程 - 旅行者继续自己的旅程,探索提瓦特的奥秘 -第83章 :钟离的隐退 - 钟离选择隐退,默默守护提瓦特 -第84章 :星河的归宿 - 星河思考自己的归宿,是留下还是离开 -第85章 :与伙伴的告别 - 与伙伴们告别,回忆共同经历的冒险 -第86章 :返回星穹铁道? - 星河考虑返回星穹铁道,但心中仍有牵挂 -第87章 :提瓦特的未来 - 提瓦特的未来充满希望与挑战 -第88章 :科技与魔法的融合 - 星穹铁道科技与提瓦特魔法开始融合 -第89章 :新的时代 - 提瓦特进入新的时代,科技与魔法共存 -第90章 :星河的遗产 - 星河留下宝贵的遗产,影响着提瓦特的未来 -第91章 :派蒙的传承 - 派蒙将自己的智慧与经验传承下去 -第92章 :丽莎的教导 - 丽莎继续教导年轻一代,传承魔法的艺术 -第93章 :旅行者的故事 - 旅行者的故事继续,探索更多未知的领域 -第94章 :钟离的箴言 - 钟离留下意味深长的箴言,引导提瓦特的未来 -第95章 :星河的回归 - 星河最终做出决定,选择自己的归宿 -第96章 :告别的时刻 - 与提瓦特的伙伴们告别,心中充满不舍 -第97章 :新的旅程 - 星河踏上新的旅程,迎接新的挑战 -第98章 :回首往昔 - 回首在提瓦特的冒险,感慨万千 -第99章 :未来的希望 - 提瓦特的未来充满希望,光明与挑战并存 -第100章 :星河挽歌 - 故事的结尾,留下无限的遐想,开放式结局 - -嗯,我需要按照用户提供的《小说设定》来生成100章的目录。首先,我得仔细阅读并理解这个设定,确保每一章都紧扣主题和叙事结构。 - - 世界观设定:暗影之歌 - -在提瓦特大陆上,暗影力量正在苏醒,带来了新的挑战和阴谋。主要角色包括星辰、艾莎、凯文,以及暗影力量本身。故事探讨了力量与责任、身份与命运、信任与背叛等主题。 - - 目录模板 -第1章: - <简述> -... -第100章: - <简述> - -接下来,我需要规划每一章的内容,确保涵盖起始点、初识艾莎、凯文的动机、危机初现、真相浮现、高潮对决以及开放式结局。 - - 起始点 -星辰被选中成为暗影力量的继承者,但她对自己的能力感到不安,同时怀疑“暗影力量”是否真是保护大陆的存在。这一部分需要引入角色和设定,铺垫后续的冲突。 - - 初识艾莎 -艾莎以刺客的身份接近星辰,试图利用她的暗影能力。两人合作中发现彼此的共同点和隐藏的冲突,这可以增加角色的互动和紧张感。 - - 凯文的动机 -凯文表面上支持星辰和艾莎,但实际上有自己的秘密。他可能是“暗影力量”的后代,或者另有隐情。这部分需要揭示凯文的多面性和潜在威胁。 - - 危机初现 -随着暗影力量的觉醒,大陆出现诡异事件。暗影力量失控,普通民众受害。这可以是故事转折点,展示力量带来的危险。 - - 真相浮现 -通过对凯文、艾莎和星辰的深入探索,揭示他们的过去与命运联系。他们之间存在更大的联系,关系到暗影力量的未来。 - - 高潮对决 -关键时刻,星辰和艾莎必须共同面临强敌。这场对决是力量的较量,更是信任与背叛的考验,增加戏剧性和紧张感。 - - 结局模糊 -故事留下开放结局,暗示暗影力量的未来影响,星辰和艾莎是否能找到自己的道路,或者被力量吞噬。 - -在规划每一章时,我需要确保情节连贯,角色发展合理,同时埋下伏笔,为后续章节做好铺垫。例如,在初识艾莎的阶段,可以设置她的多面性和潜在的秘密,为后期冲突做准备。 - -此外,每章的标题应简洁明了,能够反映主要情节或角色的发展,并附上简短的提示性简述,帮助读者了解每一章的内容。 - -现在,我需要将这些思考整理成一个100章的目录,确保每一章都紧扣设定中的叙事结构和角色发展,同时埋下足够的暗线伏笔。 - - -第1章 :星辰的选择 - 星辰被选中为暗影力量的继承者,但内心充满疑惑与不安。 - -第2章 :暗影之息 - 星辰在练习中意外触发暗影力量,引发混乱,开始探索自己的能力边界。 - -第3章 :艾莎的来临 - 艾莎以刺客身份接近星辰,试图获取更多关于暗影力量的信息,同时对星辰产生复杂情感。 - -第4章 :凯文的真实意图 - 凯文表面上支持星辰和艾莎,但逐渐显露出多面性和隐藏的秘密,引起星辰的警觉。 - -第5章:暗影失控 - 随着暗影力量的强化,大陆出现一系列诡异事件,普通民众受害,暗影力量开始失控。 - -第6章:艾莎的内心挣扎 - 艾莎在完成任务后,发现自己逐渐被暗影力量控制,对自己的选择感到恐慌和纠结。 - -第7章:凯文的秘密揭露 - 凯文被迫公开他的多面性和隐藏的秘密,星辰和艾莎联手调查他的真实意图。 - -第8章:命运的交错 - 通过对凯文、艾莎和星辰的深入了解,逐渐揭示他们之间存在更大的联系关系到暗影力量的未来走向。 - -第9章:危机加剧 - 凯文与暗影力量的势力开始对星辰和艾莎构成直接威胁,两人必须面临更为严峻的挑战。 - -第10章:信任的考验 - 在关键时刻,星辰和艾莎之间的信任面临突破,但也隐藏着潜在的背叛阴谋。 - -第11章:暗影力量的真相 - 星辰通过回忆和资料,逐渐揭开暗影力量的起源和其对世界的影响。 - -第12章:凯文的最终动向 - 凯文的双重身份和真实意图被彻底揭露,他的选择将对暗影力量的未来产生重大影响。 - -第13章:星辰的觉醒 - 星辰在与艾莎的合作中逐渐意识到自己肩负的责任,开始寻找解决问题的方法。 - -第14章:艾莎的挣扎 - 艾莎被迫面对自己的过去和现在的选择,她是否能站出来对抗暗影力量? - -第15章:凯文的覆灭 - 凯文最终的命运如何?他的计划是否能够成功,或者他将成为阻碍? - -第16章:暗影之歌 - 星辰和艾莎联手面临最后的危机,他们需要找到一个办法来控制暗影力量。 - -第17章:力量与责任的抉择 - 在关键时刻,星辰和艾莎必须做出艰难的决定,选择自己的道路还是被力量吞噬。 - -第18章:命运的终结 - 故事进入高潮,暗影力量的威胁更加严峻,星辰和艾莎必须团结起来对抗它。 - -第19章:最后的对决 - 星辰和艾莎面临最终的对决,他们的能力和信任将决定世界的命运。 - -第20章:暗影之光 - 故事情节以开放式结束,暗示暗影力量的未来影响,星辰和艾莎是否能找到自己的道路? \ No newline at end of file diff --git a/Src/Novel_setting.txt b/Src/Novel_setting.txt deleted file mode 100644 index 340c5cc..0000000 --- a/Src/Novel_setting.txt +++ /dev/null @@ -1,98 +0,0 @@ - -小说设定:《星河挽歌:提瓦特星轨》 - -小说类型:玄幻穿越,史诗级冒险,轻松幽默与细腻情感并存,整体偏正剧,但避免过于沉重。 - -字数:约400万字(100章 x 4000字/章) - -视角:第三人称,穿插主角第一人称内心独白。 - -世界观:基于原神世界线平行宇宙的提瓦特大陆,地理环境与原神基本一致,但部分区域势力分布及历史事件有所调整,以适应主角的到来和剧情发展。七国结构保留,但政治格局和社会风貌因主角介入而变。科技与魔法并存,魔法更为显著,主角的星穹铁道科技作为“外来力量”融入其中,引发冲突和变革。重要历史事件保留原神设定,并添加主角引发的新的历史事件。 - -核心内容梗概:主角星河在星穹铁道执行任务时意外穿越到提瓦特大陆,发现大陆面临史无前例的巨大危机。凭借星穹铁道科技力量及经验,星河成为对抗危机的关键人物。在拯救提瓦特过程中,他将与原神角色相遇,建立深厚情谊或产生冲突,最终完成使命,并决定自己的归宿。采用英雄之旅叙事结构,主线为拯救提瓦特,副线为主角与原神角色的情感纠葛。 - -主要角色: - -1. 星河:坚毅、责任感强、略带孤傲,内心温柔渴望归宿。拥有强大的星穹铁道科技力量,但更倾向于智慧和策略解决问题。拥有丰富战斗经验和冷静判断力,但也有过去任务的阴影和对未来命运的迷茫,偶尔冲动,也可能因自身能力产生优越感。他的目标是拯救提瓦特,找到归宿,弥补过去遗憾。他的隐藏秘密是:在星穹铁道的一次任务中,他导致一位重要伙伴牺牲,这个秘密影响着他与他人的关系,并在与原神角色建立深厚感情时成为关键转折点。 - -2. 派蒙:活泼、贪吃、略显成熟,更能理解星河内心世界,给予更深层次的支持。积极参与冒险,但鲁莽会带来麻烦,是重要的喜剧元素。对提瓦特知识渊博,但对星穹铁道科技一无所知。她的目标是帮助星河拯救提瓦特,探索自身存在秘密,与星河建立更深厚羁绊。她的隐藏秘密是:她并非简单的导航精灵,拥有远超常人的智慧和感知能力,与提瓦特大陆的过去有着神秘联系。这会在中段通过关键事件逐步揭示。 她的“记忆碎片”——偶尔失神,喃喃自语,对特定场景反应强烈,甚至出现幻觉,这些会逐渐清晰,暗示她与提瓦特古老文明甚至超越提瓦特的力量有关。 - -3. 丽莎:慵懒、智慧,强大的魔法能力,比原神设定中更成熟、更富有责任感。对星穹铁道科技好奇又警惕,将星河视为竞争对手和值得培养的对象。她会引导主角,但在关键时刻设置考验。她的目标是保护蒙德,培养星河,并寻求突破自身瓶颈。她的隐藏秘密是:她拥有不为人知的秘密身份,与某个神秘组织有关,该组织与即将到来的危机密切相关。她的“禁忌魔法”——偶尔使用威力远超平时能力的魔法,使用后疲惫甚至身体异样,她会刻意隐藏,这与她的神秘组织联系紧密。 - -4. 原神旅行者:与星河互动,产生冲突与合作。两人对提瓦特大陆的理解不同,可能会产生矛盾,但在对抗危机时携手合作。 - -5. 钟离:神秘幕后人物,引导星河完成使命,但目的和动机不明,会带来考验和帮助,也可能成为潜在敌人。他的“真实目的”——对星河的帮助看似无私,但始终保持距离,言行举止充满深意,他的行动与提瓦特危机有关,目的可能并非简单帮助主角,而是为了实现某种更宏大的计划。 - -角色关系与冲突:星河与派蒙(依赖与独立的矛盾),星河与丽莎(竞争与合作),星河与原神旅行者(身份认同冲突),星河与钟离(考验与引导)。 - -暗线与伏笔:派蒙的记忆碎片,丽莎的禁忌魔法,星河的星穹铁道追捕令,钟离的真实目的,提瓦特大陆的失落科技。这些暗线相互交织,推动剧情发展,并为角色成长和故事走向增添可能性和悬念。 - -结局:开放式结局,主角战胜危机,但提瓦特仍面临挑战,主角可以选择留在提瓦特或回到星穹铁道;也可能根据剧情走向相对圆满结局。 - - -世界观与角色动机结合:提瓦特大陆面临的危机是推动星河行动的主要动力,而他过去的经历(星穹铁道任务的阴影和伙伴的牺牲)以及对归宿的渴望,则构成了他内心的冲突和驱动力。派蒙的秘密与提瓦特古老文明的关联,丽莎的禁忌魔法与神秘组织的联系,这些都与即将到来的危机息息相关,也与主角的命运紧密相连。 钟离的真实目的则成为贯穿始终的神秘力量,推动剧情发展,并最终影响故事的结局。 星河的星穹铁道科技,则成为改变提瓦特魔法世界格局的关键因素,引发一系列冲突和变革。 - 世界观设定:暗影之歌 - - 引言 -在一个名为提瓦特的大陆上,力量如同黑暗中的呼吸,无声无息地渗透着一切。这片大陆的历史被称为“暗影年代”,那时期充满了权力斗争和悲剧命运。如今,随着“暗影力量”的再次苏醒,新的阴谋与挑战正逐渐显露。 - - 主要角色 -1. 星辰(亦名:斯琴) - - 背景:一位被称为“暗影之心”的天才少女,拥有操纵暗影的特殊能力。她的力量是暗影之力最强者之一,但同时也让她背负着沉重的责任。 - -2. 艾莎(亦名:艾丽莎) - - 背景:一位冷酷无情的刺客,曾为“暗影力量”效力。她的身世与“暗影力量”密不可分,性格多变,隐藏着巨大的秘密。 - -3. 凯文(亦名:克莱文) - - 背景:自封为提瓦特大陆的守护者,实则心中藏着大不明小谋。他表面上支持星辰和艾莎,但背后暗藏自己的秘密计划。 - -4. 暗影力量 - - 背景:一股强大的力量源于远古,能够操控暗影与黑暗。它的真实目的尚未完全被揭示,既有守护大陆的传说,也有毁灭一切的预言。 - - 世界观亮点 - -1. 力量的双面性 - - 暗影力量是世界的核心,但同时也是最大的危险。掌握它的人往往会被力量所控制,经历深刻的心理变化。 - -2. 身份的迷失 - - 星辰和艾莎都背负着来自“暗影力量”的阴影,他们的身份与命运与力量紧密相连,这种联系既是他们的优势,也是他们的诅咒。 - -3. 权力真空 - - 提瓦特大陆在“暗影年代”后期逐渐走向新的权力真空,各种势力争夺资源和权利,形成了复杂多变的政治格局。 - -4. 宿命的重遇 - - 星辰和艾莎被暗影力量联系起来,这种宿命使他们在面对危机时不得不合作,但同时也让彼此之间充满了矛盾与误解。 - -5. 未知的威胁 - - “暗影力量”的真实目标尚未完全明确,可能是为了大陆的繁荣,也可能是为了彻底摧毁一切。这种不确定性为故事增添了紧张感和悬念。 - - 故事情节展开 - -1. 起始点 - - 星辰被选中成为暗影力量的继承者,但她对自己的能力感到不安,同时也怀疑“暗影力量”是否真的是保护大陆的存在。 - -2. 初识艾莎 - - 艾莎以刺客的身份接近星辰,试图利用她的暗影能力。但两人在合作中逐渐发现彼此之间的共同点,以及隐藏的冲突。 - -3. 凯文的动机 - - 凯文表面上是支持星辰和艾莎,但实际上他有自己的秘密。他可能是“暗影力量”的后代,或者背后另有hidden agenda。 - -4. 危机初现 - - 随着暗影力量的觉醒,大陆出现了一系列诡异的事件。暗影力量开始失控,普通大民被笼罩在阴影中,生存成为一件艰难的事。 - -5. 真相浮现 - - 通过对凯文、艾莎和星辰的深入探索,逐渐揭示他们各自的过去与命运联系。最终,他们会发现自己之间存在着更大的联系,而这可能关系到整个暗影力量的未来。 - -6. 高潮对决 - - 在一次关键时刻,星辰和艾莎必须共同面临一个强大的敌人。这场对决不仅是力量的较量,更是信任与背叛的考验。 - -7. 结局的模糊性 - - 故事情节留下了开放的结局,让读者思考暗影力量最终会带来什么样的未来。星辰和艾莎是否能找到自己的道路,或者他们将被暗影力量彻底吞噬。 - - 主题 -- 力量与责任:暗影力量赋予了一些人巨大的能力,但同时也带来了沉重的责任。 -- 身份与命运:每个人的过去决定了他们的未来,他们是否能超越自己的宿命? -- 信任与背叛:在危难时刻,真实的朋友和潜在的敌人是如何区分的? - - 总结 -“暗影之歌”是一个充满矛盾与复杂性的故事,它探讨了力量、身份、信任等多个深刻主题。通过星辰和艾莎两位主角的视角,读者可以感受到在黑暗与光明之间挣扎的人性,以及他们如何面对命运给予的挑战。 \ No newline at end of file diff --git a/embedding_ollama.py b/embedding_ollama.py index b755ddd..5764e61 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -1,14 +1,9 @@ -# embedding_ollama.py +import logging import requests from typing import List +import traceback class OllamaEmbeddings: - """ - Ollama 本地服务提供的 Embedding 接口, - 本需求里我们最终拼出形如: http://localhost:11434/api/embed - 即 base_url + "/embed" - """ - def __init__(self, model_name: str, base_url: str): self.model_name = model_name self.base_url = base_url # 这里应形如 http://localhost:11434/api (不再含 /v1) @@ -49,6 +44,28 @@ class OllamaEmbeddings: response = requests.post(url, json=data) response.raise_for_status() result = response.json() + print(result) + + # 检查返回结果是否包含 'embedding' 字段 + if "embedding" not in result: + logging.warning(f"No 'embedding' field in response. Returning empty embedding.") + return [] # 返回空列表 return result["embedding"] + except requests.exceptions.RequestException as e: - raise Exception(f"Ollama embeddings request error: {e}") + logging.error(f"Ollama embeddings request error: {e}") + logging.error(f"Request URL: {url}") + logging.error(f"Request Data: {data}") + logging.error("Full error details:\n" + traceback.format_exc()) + return [] + + except ValueError as e: + logging.error(f"Invalid response structure: {e}") + logging.error(f"Response content: {response.text}") + logging.error("Full error details:\n" + traceback.format_exc()) + return [] + + except Exception as e: + logging.error(f"Unexpected error: {e}") + logging.error("Full error details:\n" + traceback.format_exc()) + return [] diff --git a/novel_generator.py b/novel_generator.py index a9d53ca..71343a2 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -4,17 +4,12 @@ import os import logging import re from typing import Dict, List, Optional -try: - from typing import TypedDict -except ImportError: - from typing_extensions import TypedDict - +from typing import TypedDict from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, START, END from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain.docstore.document import Document - import nltk import math from sentence_transformers import SentenceTransformer @@ -34,15 +29,15 @@ from embedding_ollama import OllamaEmbeddings from chapter_directory_parser import get_chapter_info_from_directory # ============ 日志配置 ============ + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") def debug_log(prompt: str, response_content: str): - """打印Prompt与Response,可根据需要保留或去掉。""" - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") +# ============ 判断接口格式相关 ============ -# ============ 接口判断函数 ============ def is_using_ollama_api(interface_format: str, base_url: str) -> bool: """ 当 interface_format == "Ollama" 时返回 True @@ -60,6 +55,8 @@ def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: return False +# ============ 创建 Embeddings 对象 ============ + def create_embeddings_object( api_key: str, base_url: str, @@ -72,27 +69,25 @@ def create_embeddings_object( - 当 interface_format = "Ollama" => OllamaEmbeddings(...) (此时把 embed_url 中的 /v1 替换成 /api,以便最后调用 /api/embed) - 当 interface_format = "OpenAI" or "ML Studio" => OpenAIEmbeddings - - 其它情况可自行扩展 + - 其它情况视需求可扩展 """ if is_using_ollama_api(interface_format, embed_url): - # 去除末尾斜杠 fixed_url = embed_url.rstrip("/") - # 如果包含 /v1 则替换为 /api fixed_url = fixed_url.replace("/v1", "/api") return OllamaEmbeddings( model_name=embedding_model_name, base_url=fixed_url ) elif is_using_ml_studio_api(interface_format, base_url): + # ML Studio / OpenAI 兼容 return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) else: # 默认使用 OpenAIEmbeddings return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) -# ============ 日志配置 ============ -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") # ============ 向量库相关 ============ + VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") if not os.path.exists(VECTOR_STORE_DIR): os.makedirs(VECTOR_STORE_DIR) @@ -102,8 +97,8 @@ def clear_vector_store(): 清空本地向量库(删除 vectorstore 文件夹内的内容)。 """ if os.path.exists(VECTOR_STORE_DIR): + import shutil try: - import shutil for filename in os.listdir(VECTOR_STORE_DIR): file_path = os.path.join(VECTOR_STORE_DIR, filename) if os.path.isfile(file_path) or os.path.islink(file_path): @@ -126,7 +121,6 @@ def init_vector_store( ) -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 - embedding_base_url 若不为空,则用于 Ollama 模式下;否则默认使用 base_url。 """ embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( @@ -156,6 +150,7 @@ def load_vector_store( 读取已存在的向量库。若不存在则返回 None。 """ if not os.path.exists(VECTOR_STORE_DIR): + logging.info("Vector store not found. Initializing a new one...") return None embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( @@ -185,8 +180,10 @@ def update_vector_store( embedding_model_name=embedding_model_name, embedding_base_url=embedding_base_url ) + + # 如果向量库不存在,初始化它 if not store: - logging.info("Vector store does not exist. Initializing a new one...") + logging.info("Vector store does not exist. Initializing a new one for new chapter...") init_vector_store( api_key=api_key, base_url=base_url, @@ -200,6 +197,7 @@ def update_vector_store( new_doc = Document(page_content=new_chapter) store.add_documents([new_doc]) store.persist() + logging.info("Vector store updated with the new chapter.") def get_relevant_context_from_vector_store( api_key: str, @@ -212,7 +210,7 @@ def get_relevant_context_from_vector_store( ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - 若向量库不存在则返回空字符串。 + 若向量库不存在或没有足够的内容,则返回空字符串。 """ store = load_vector_store( api_key=api_key, @@ -221,10 +219,19 @@ def get_relevant_context_from_vector_store( embedding_model_name=embedding_model_name, embedding_base_url=embedding_base_url ) + + # 如果向量库为空,直接返回空字符串 if not store: - logging.warning("Vector store not found. Returning empty context.") + logging.info("No vector store found. Returning empty context.") return "" + + # 向量库存在,但没有足够的内容时也避免索引错误 docs = store.similarity_search(query, k=k) + + if not docs: + logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") + return "" + combined = "\n".join([d.page_content for d in docs]) return combined @@ -256,7 +263,6 @@ def Novel_novel_directory_generate( """ 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 """ - # 确保文件夹存在 os.makedirs(filepath, exist_ok=True) model = ChatOpenAI( @@ -327,7 +333,6 @@ def Novel_novel_directory_generate( debug_log(prompt, response.content) return {"novel_directory": response.content.strip()} - # 构建状态图 graph = StateGraph(OverallState) graph.add_node("generate_base_setting", generate_base_setting) graph.add_node("generate_character_setting", generate_character_setting) @@ -363,7 +368,6 @@ def Novel_novel_directory_generate( logging.warning("生成失败:缺少 final_novel_setting 或 novel_directory。") return - # 写入文件 filename_set = os.path.join(filepath, "Novel_setting.txt") filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") @@ -375,7 +379,6 @@ def Novel_novel_directory_generate( append_text_to_file(final_novel_setting_cleaned, filename_set) append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) - logging.info("Novel settings and directory generated successfully.") @@ -394,19 +397,25 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int text = read_file(chap_file).strip() if text: texts.append(text) + if len(texts) < n: + texts = [''] * (n - len(texts)) + texts return texts + def summarize_recent_chapters( - llm_model: str, - api_key: str, - base_url: str, - temperature: float, - chapters_text_list: List[str] - ) -> str: + llm_model: str, + api_key: str, + base_url: str, + temperature: float, + chapters_text_list: List[str] +) -> str: """ - 将最近几章的文本拼接后,通过模型生成一个相对详细的“短期内容摘要”。 - 如果没有可用的模型(model=None),则退化为简单截断示例。 + 将最近几章文本拼接,通过模型生成相对简要的“短期内容摘要”。 """ + if not chapters_text_list: + return "" + if chapters_text_list==['', '', '']: + return "暂无摘要。" model = ChatOpenAI( model=llm_model, api_key=api_key, @@ -414,33 +423,19 @@ def summarize_recent_chapters( temperature=temperature ) - if not chapters_text_list: - return "" - combined_text = "\n".join(chapters_text_list) - # 如果未传入model,就做个简单的退化输出 - if not model: - return f"【摘要-演示】\n{combined_text[:800]}..." - - # 构造一个提示词(Prompt),指示模型生成精简摘要 - prompt = f"""你是一名资深的长篇小说写作辅助AI。下面是最近几章的合并文本内容: + prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: {combined_text} -请你为此文本生成一段简洁扼要的摘要,突出主要剧情进展、角色变化、冲突焦点等要点。 -1.请用中文输出,不超过500字。 -2.仅回复摘要内容,不需要其他信息。 -""" - # 调用模型获取摘要 +请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" + response = model.invoke(prompt) if not response or not response.content.strip(): - # 若模型无响应或空,返回简单截断 - return f"【摘要-演示】\n{combined_text[:800]}..." - - # 返回模型生成的摘要文本 + return combined_text[:800] + "..." if len(combined_text) > 800 else combined_text return response.content.strip() -# ============ 新增:更新剧情要点/未解决冲突 ============ +# ============ 新增:剧情要点/未解决冲突 ============ PLOT_ARCS_PROMPT = """\ 下面是新生成的章节内容: @@ -449,9 +444,9 @@ PLOT_ARCS_PROMPT = """\ 这里是已记录的剧情要点/未解决冲突(可能为空): {old_plot_arcs} -请基于新的章节内容,提炼出本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 +请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出一份更新后的剧情要点列表,以帮助后续保持故事的整体一致性和悬念延续。 +最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 """ def update_plot_arcs( @@ -462,10 +457,6 @@ def update_plot_arcs( model_name: str, temperature: float ) -> str: - """ - 利用模型分析最新章节文本,提炼或更新“未解决冲突或剧情要点”。 - 并返回更新后的字符串。 - """ model = ChatOpenAI( model=model_name, api_key=api_key, @@ -480,7 +471,6 @@ def update_plot_arcs( if not response: logging.warning("update_plot_arcs: No response.") return old_plot_arcs - debug_log(prompt, response.content) return response.content.strip() @@ -499,28 +489,44 @@ def generate_chapter_draft( word_number: int, temperature: float, novel_novel_directory: str, - filepath: str + filepath: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str ) -> str: """ - 仅生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 - 并将生成的内容写到 "chapter_{novel_number}.txt" 覆盖写入。 - 同时生成 "outline_{novel_number}.txt" 存储大纲内容。 + 生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 """ - # 0) 根据 novel_number 从 novel_novel_directory 中获取本章标题及简述 + # 根据目录信息获取本章标题、简介 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 1) 从向量库检索上下文 (此处仅演示 query="回顾剧情") - relevant_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=base_url, - query="回顾剧情", - interface_format="OpenAI", - embedding_model_name="", - embedding_base_url="", - k=2 - ) + # 从向量库检索多次上下文(示例:对本章简介、用户指导分别做查询,再合并) + queries = [] + if user_guidance.strip(): + queries.append(user_guidance) + if chapter_brief.strip(): + queries.append(chapter_brief) + # 也可加一句“回顾剧情”之类 + queries.append("回顾剧情") + + relevant_context = "" + for q in queries: + partial_context = get_relevant_context_from_vector_store( + api_key=api_key, + base_url=base_url, + query=q, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + embedding_base_url=embedding_base_url, + k=2 + ) + if partial_context.strip(): + relevant_context += "\n" + partial_context + # 如果检索结果为空,使用默认值(如空字符串) + if not relevant_context: + relevant_context = "暂无相关内容。" model = ChatOpenAI( model=model_name, @@ -529,10 +535,10 @@ def generate_chapter_draft( temperature=temperature ) - # 2) 生成大纲 + # 1) 生成本章大纲 outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, - character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, global_summary=global_summary, novel_number=novel_number, chapter_title=chapter_title, @@ -550,10 +556,10 @@ def generate_chapter_draft( clear_file_content(outline_file) save_string_to_txt(chapter_outline, outline_file) - # 3) 生成正文草稿 + # 2) 生成正文草稿 writing_prompt_text = chapter_write_prompt.format( novel_setting=novel_settings, - character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, global_summary=global_summary, chapter_outline=chapter_outline, word_number=word_number, @@ -588,13 +594,12 @@ def finalize_chapter( ): """ 对当前章节进行定稿: - 1. 读取 chapter_{novel_number}.txt 的最终内容; - 2. 更新全局摘要、角色状态文件; - 3. 如果字数明显少于 word_number 的 80%,则自动调用 enrich_chapter_text 再次扩写; - 4. 更新向量库; - 5. 新增:更新剧情要点/未解决冲突 -> plot_arcs.txt + 1. 读取草稿文本 + 2. 若字数太短则再次扩写 + 3. 更新全局摘要、角色状态 + 4. 更新剧情要点 + 5. 更新向量库 """ - # 读取当前章节内容 chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -610,9 +615,9 @@ def finalize_chapter( old_global_summary = read_file(global_summary_file) old_plot_arcs = read_file(plot_arcs_file) - # 1) 若字数明显不足,做 enrich + # 若篇幅过短,二次扩写 if len(chapter_text) < 0.8 * word_number: - logging.info("Chapter text seems shorter than 80% of desired length. Attempting to enrich content...") + logging.info("Chapter text is shorter than 80% of desired length. Enriching...") chapter_text = enrich_chapter_text( chapter_text=chapter_text, word_number=word_number, @@ -623,9 +628,8 @@ def finalize_chapter( ) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - logging.info("Chapter text has been enriched and updated.") - # 2) 更新全局摘要 + # 更新全局摘要 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -643,7 +647,7 @@ def finalize_chapter( new_global_summary = update_global_summary(chapter_text, old_global_summary) - # 3) 更新角色状态 + # 更新角色状态 def update_character_state(chapter_text: str, old_state: str) -> str: prompt = update_character_state_prompt.format( chapter_text=chapter_text, @@ -654,7 +658,7 @@ def finalize_chapter( new_char_state = update_character_state(chapter_text, old_char_state) - # 4) 更新剧情要点 + # 更新剧情要点 new_plot_arcs = update_plot_arcs( chapter_text=chapter_text, old_plot_arcs=old_plot_arcs, @@ -664,7 +668,7 @@ def finalize_chapter( temperature=temperature ) - # 5) 覆盖写入文件 + # 写回文件 clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) @@ -674,10 +678,10 @@ def finalize_chapter( clear_file_content(plot_arcs_file) save_string_to_txt(new_plot_arcs, plot_arcs_file) - # 6) 更新向量库 + # 更新向量库 update_vector_store( - api_key=api_key, - base_url=base_url, + api_key=api_key, + base_url=base_url, new_chapter=chapter_text, interface_format=interface_format, embedding_model_name=embedding_model_name @@ -695,7 +699,6 @@ def enrich_chapter_text( ) -> str: """ 当章节篇幅不足时,调用此函数对章节文本进行二次扩写。 - 可以让模型补充场景描写、角色心理等,保证与现有文本风格一致。 """ model = ChatOpenAI( model=model_name, @@ -713,20 +716,21 @@ def enrich_chapter_text( return chapter_text return response.content.strip() + # ============ 导入外部知识文本 ============ def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - file_path: str, - embedding_base_url: str = "" - ) -> None: + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + file_path: str, + embedding_base_url: str = "" +) -> None: """ 将用户选定的文本文件导入到向量库,以便在写作时检索。 """ - logging.info(f"开始导入知识库文件: {file_path},当前接口格式: {interface_format},当前模型: {embedding_model_name}") + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return @@ -760,11 +764,12 @@ def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: """ - 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 + 将文本先按句子切分,然后根据语义相似度进行合并,最后按max_length二次切分。 """ - nltk.download('punkt_tab', quiet=True) - sentences = nltk.sent_tokenize(content) + # 纠正下载punkt包:'punkt' 而非 'punkt_tab' + nltk.download('punkt', quiet=True) + sentences = nltk.sent_tokenize(content) if not sentences: return [] diff --git a/ui.py b/ui.py index 6bfc0e8..dd787c5 100644 --- a/ui.py +++ b/ui.py @@ -1,11 +1,12 @@ # ui.py # -*- coding: utf-8 -*- +import logging import os import threading import customtkinter as ctk from tkinter import filedialog, messagebox - +import traceback from config_manager import load_config, save_config from utils import read_file, save_string_to_txt from novel_generator import ( @@ -19,6 +20,12 @@ from novel_generator import ( ) from consistency_checker import check_consistency +def log_error(message: str): + """ + 用于打印详细的错误信息和堆栈信息。 + """ + logging.error(f"{message}\n{traceback.format_exc()}") + # 设置全局主题和颜色 ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") @@ -38,7 +45,6 @@ class NovelGeneratorGUI: self.loaded_config = load_config(self.config_file) # ========== 主要的属性变量 ========== - # 注意:配置项较多,此处增加 embedding_model_name_var 以支持 Embedding 模型名称 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) @@ -46,7 +52,7 @@ class NovelGeneratorGUI: self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) - + self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) @@ -128,8 +134,6 @@ class NovelGeneratorGUI: # 日志:只读 self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - - # 设置只读 self.log_text.configure(state="disabled") def build_right_layout(self): @@ -137,8 +141,6 @@ class NovelGeneratorGUI: 右侧用于显示配置与功能按钮。 其中配置被拆分到一个子 TabView:AI接口配置 和 Embedding配置。 下面再放与小说相关的输入(主题/类型/章节数等)与功能按钮。 - - 另:我们只使用 2 列布局,0 列放标签,1 列放输入框或子区域,保证对齐。 """ self.right_frame.grid_rowconfigure(0, weight=0) self.right_frame.grid_rowconfigure(1, weight=0) @@ -172,14 +174,13 @@ class NovelGeneratorGUI: - 模型名称 - Temperature """ - # 配置网格 for i in range(5): 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) - # 回调:当接口格式下拉框发生变更时,如果 Base URL 为空,则根据接口类型自动填默认值 def on_interface_format_changed(new_value): + # 如果用户切换接口格式,可根据需要修改BaseURL为默认值 if new_value == "Ollama": self.base_url_var.set("http://localhost:11434/v1") elif new_value == "ML Studio": @@ -263,7 +264,6 @@ class NovelGeneratorGUI: def build_novel_params_area(self, start_row=2): """ 放置与小说本身相关的一些配置:主题、类型、章节数、文件路径等 - 只使用两列:0列放Label,1列放输入框(可能加子Frame)。 """ # 主题(Topic) topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) @@ -291,7 +291,7 @@ class NovelGeneratorGUI: word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80, font=("Microsoft YaHei", 12)) word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w") - # 保存路径 (使用子Frame放置 Entry + Button) + # 保存路径 filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e") @@ -320,11 +320,8 @@ class NovelGeneratorGUI: """ 主要功能按钮 + 配置保存/加载 按钮 """ - # 调整下布局:0,1列 - # 这里按行摆放多个按钮 self.right_frame.grid_rowconfigure(start_row, weight=0) - # 先放“保存配置”和“加载配置”按钮 self.btn_frame_config = ctk.CTkFrame(self.right_frame) self.btn_frame_config.grid(row=start_row, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_frame_config.columnconfigure(0, weight=1) @@ -336,7 +333,6 @@ class NovelGeneratorGUI: 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") - # 下面是主要功能按钮 self.btn_generate_full = ctk.CTkButton( self.right_frame, text="Step1. 生成设定 & 目录", command=self.generate_full_novel, @@ -389,9 +385,6 @@ class NovelGeneratorGUI: # ------------------ Novel Settings Tab ------------------ def build_setting_tab(self): - """ - 可查看/编辑 Novel_setting.txt 并保存 - """ self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) self.setting_tab.columnconfigure(0, weight=1) @@ -430,9 +423,6 @@ class NovelGeneratorGUI: # ------------------ Novel Directory Tab ------------------ def build_directory_tab(self): - """ - 可查看/编辑 Novel_directory.txt 并保存 - """ self.directory_tab.rowconfigure(0, weight=0) self.directory_tab.rowconfigure(1, weight=1) self.directory_tab.columnconfigure(0, weight=1) @@ -471,9 +461,6 @@ class NovelGeneratorGUI: # ------------------ Character State Tab ------------------ def build_character_tab(self): - """ - 查看/编辑 character_state.txt - """ self.character_tab.rowconfigure(0, weight=0) self.character_tab.rowconfigure(1, weight=1) self.character_tab.columnconfigure(0, weight=1) @@ -512,9 +499,6 @@ class NovelGeneratorGUI: # ------------------ Global Summary Tab ------------------ def build_summary_tab(self): - """ - 查看/编辑 global_summary.txt - """ self.summary_tab.rowconfigure(0, weight=0) self.summary_tab.rowconfigure(1, weight=1) self.summary_tab.columnconfigure(0, weight=1) @@ -603,11 +587,9 @@ class NovelGeneratorGUI: # ------------------ 日志输出 ------------------ 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") # ------------------ 功能区 -------------------- @@ -650,6 +632,7 @@ class NovelGeneratorGUI: ) self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") except Exception as e: + logging.error(f"生成小说设定 & 目录时出错: {e}") self.log(f"❌ 生成小说设定 & 目录时出错: {e}") finally: self.enable_button(self.btn_generate_full) @@ -693,10 +676,13 @@ class NovelGeneratorGUI: chapters_dir = os.path.join(filepath, "chapters") recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) - # 简易生成最近章节摘要(示例) + # 生成最近章节摘要 recent_chapters_summary = summarize_recent_chapters( - model_name, # 此处仅示例 - recent_3_texts + llm_model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature, + chapters_text_list=recent_3_texts ) self.log(f"开始生成第{chap_num}章草稿...") @@ -713,7 +699,10 @@ class NovelGeneratorGUI: word_number=word_number, temperature=temperature, novel_novel_directory=novel_directory, - filepath=filepath + filepath=filepath, + interface_format=self.interface_format_var.get().strip(), + embedding_model_name=self.embedding_model_name_var.get().strip(), + embedding_base_url=self.embedding_url_var.get().strip() ) if draft_text: self.log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -724,6 +713,7 @@ class NovelGeneratorGUI: self.log("⚠️ 本章草稿生成失败或无内容。") except Exception as e: + logging.error(f"生成章节草稿时出错: {e}") self.log(f"❌ 生成章节草稿时出错: {e}") finally: self.enable_button(self.btn_generate_chapter) @@ -741,6 +731,8 @@ class NovelGeneratorGUI: model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() filepath = self.filepath_var.get().strip() + interface_format = self.interface_format_var.get().strip() + embedding_model_name = self.embedding_model_name_var.get().strip() if not filepath: self.log("请先配置保存文件路径。") @@ -755,6 +747,8 @@ class NovelGeneratorGUI: word_number=word_number, api_key=api_key, base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, model_name=model_name, temperature=temperature, filepath=filepath @@ -769,6 +763,7 @@ class NovelGeneratorGUI: self.chapter_result.see("end") except Exception as e: + logging.error(f"定稿章节时出错: {e}") self.log(f"❌ 定稿章节时出错: {e}") finally: self.enable_button(self.btn_finalize_chapter) @@ -801,7 +796,6 @@ class NovelGeneratorGUI: global_summary = read_file(global_summary_file) plot_arcs = read_file(plot_arcs_file) - # 获取当前章节文本 chap_num = self.chapter_num_var.get() chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") chapter_text = read_file(chap_file) @@ -826,6 +820,7 @@ class NovelGeneratorGUI: self.log(result) except Exception as e: + logging.error(f"审校时出错: {e}") self.log(f"❌ 审校时出错: {e}") finally: self.enable_button(self.btn_check_consistency) @@ -848,12 +843,13 @@ class NovelGeneratorGUI: api_key=self.api_key_var.get().strip(), base_url=self.base_url_var.get().strip(), interface_format=self.interface_format_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip(), - file_path=selected_file + embedding_model_name=self.embedding_model_name_var.get().strip(), + file_path=selected_file, + embedding_base_url=self.embedding_url_var.get().strip() ) self.log("✅ 知识库文件导入完成。") except Exception as e: + logging.error(f"导入知识库时出错: {e}") self.log(f"❌ 导入知识库时出错: {e}") finally: self.enable_button(self.btn_import_knowledge) @@ -886,7 +882,6 @@ class NovelGeneratorGUI: if not arcs_text: arcs_text = "当前没有记录的剧情要点或冲突。" - # 弹窗显示 top = ctk.CTkToplevel(self.master) top.title("剧情要点/未解决冲突") top.geometry("600x400") From 9822f3b3ffd841490da894907381fc64506fdf69 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 17:46:29 +0800 Subject: [PATCH 027/142] =?UTF-8?q?Revert=20"=E4=B8=8D=E7=9F=A5=E9=81=93?= =?UTF-8?q?=E5=87=BA=E4=BA=86=E4=BB=80=E4=B9=88=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E5=85=88=E8=AE=B0=E5=BD=95=E4=B8=80=E4=B8=8B=E6=9B=B4=E6=96=B0?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit aca9e4868339ac93f3d152a3bbb6962eaee28014. --- Src/Novel_directory.txt | 182 ++++++++++++++++++++++++++++++++++ Src/Novel_setting.txt | 98 ++++++++++++++++++ embedding_ollama.py | 33 ++---- novel_generator.py | 215 ++++++++++++++++++++-------------------- ui.py | 71 +++++++------ 5 files changed, 431 insertions(+), 168 deletions(-) create mode 100644 Src/Novel_directory.txt create mode 100644 Src/Novel_setting.txt diff --git a/Src/Novel_directory.txt b/Src/Novel_directory.txt new file mode 100644 index 0000000..3a9cc6f --- /dev/null +++ b/Src/Novel_directory.txt @@ -0,0 +1,182 @@ + +第1章 :星河坠落提瓦特 - 穿越与初遇派蒙,危机初现端倪 +第2章 :蒙德的初印象 - 初探蒙德城,与丽莎的首次相遇 +第3章 :酒馆的闲谈 - 了解提瓦特局势,派蒙展现惊人一面 +第4章 :丽莎的试探 - 星河展现科技实力,引来丽莎的警惕 +第5章 :委托与冒险 - 接受委托,初次展现星穹铁道科技的运用 +第6章 :风魔龙的挑战 - 运用科技对抗风魔龙,展现策略与智慧 +第7章 :派蒙的异常 - 派蒙出现记忆碎片,暗示其不寻常身份 +第8章 :丽莎的秘密魔法 - 丽莎使用禁忌魔法,展现其神秘一面 +第9章 :旅行者的出现 - 与原神旅行者初次相遇,产生摩擦与合作 +第10章 :蒙德的阴谋 - 发现蒙德隐藏的危机,与丽莎深入合作 +第11章 :追捕令的阴影 - 星河收到星穹铁道追捕令,内心挣扎 +第12章 :深入调查 - 调查蒙德的危机,发现与丽莎的秘密组织有关 +第13章 :钟离的现身 - 钟离初次出现,对星河进行暗示与引导 +第14章 :失落科技的线索 - 发现关于提瓦特失落科技的线索 +第15章 :前往璃月 - 踏上前往璃月的旅程,面对新的挑战 +第16章 :璃月港的繁华 - 体验璃月港的文化,了解璃月局势 +第17章 :往生堂的委托 - 接受往生堂的委托,深入了解璃月历史 +第18章 :岩王帝君的考验 - 钟离对星河进行考验,揭示其真实目的 +第19章 :地脉异常 - 发现璃月地脉异常,与旅行者合作调查 +第20章 :黄金屋的秘密 - 探索黄金屋的秘密,发现与失落科技有关 +第21章 :刻晴的质疑 - 刻晴对星河的科技持有怀疑态度 +第22章 :对抗岩龙蜥 - 运用科技与策略对抗强大的岩龙蜥 +第23章 :派蒙的回忆 - 派蒙回忆片段增多,身份逐渐清晰 +第24章 :丽莎的引导 - 丽莎引导星河学习提瓦特魔法 +第25章 :星河的抉择 - 星河面临道德困境,做出艰难选择 +第26章 :前往稻妻 - 前往稻妻,面对新的挑战与危机 +第27章 :稻妻的雷电 - 体验稻妻的文化与习俗,对抗雷电将军的威压 +第28章 :八重神子的试探 - 八重神子对星河进行试探,考验其能力 +第29章 :影向地的秘密 - 探索影向地的秘密,发现与提瓦特历史有关 +第30章 :反抗军与合作 - 与稻妻反抗军合作,对抗幕后黑手 +第31章 :永恒的执政官 - 与雷电将军正面交锋,展现智慧与实力 +第32章 :派蒙的过去 - 派蒙的过去逐渐揭开,与提瓦特古老文明有关 +第33章 :丽莎的组织 - 丽莎所属组织的真相逐渐浮出水面 +第34章 :前往须弥 - 前往须弥,面对新的挑战与文化 +第35章 :须弥的智慧 - 体验须弥的文化,了解须弥的智慧与科技 +第36章 :草神的大智慧 - 与草神进行交流,探讨生命的意义 +第37章 :森林的危机 - 须弥森林面临危机,星河运用科技解决问题 +第38章 :沙漠的秘密 - 探索须弥沙漠的秘密,发现远古文明的遗迹 +第39章 :散兵的阴谋 - 散兵的阴谋逐渐浮出水面,星河需要应对 +第40章 :与散兵的对抗 - 与散兵展开对抗,展现星穹铁道科技的优势 +第41章 :前往枫丹 - 前往枫丹,面对新的挑战与科技 +第42章 :枫丹的审判 - 体验枫丹的法律与秩序,面对复杂的案件 +第43章 :水神的神秘 - 与水神进行互动,了解枫丹的秘密 +第44章 :机械岛的危机 - 枫丹机械岛面临危机,星河运用科技解决问题 +第45章 :潜入深海 - 潜入深海,探索深海的秘密与科技 +第46章 :派蒙的预言 - 派蒙预言即将到来的巨大危机 +第47章 :丽莎的抉择 - 丽莎面临艰难的抉择,需要做出牺牲 +第48章 :前往纳塔 - 前往纳塔,面对新的挑战与文化 +第49章 :纳塔的冰雪 - 体验纳塔的严寒环境,与冰雪元素对抗 +第50章 :雪山之巅的秘密 - 探索雪山之巅的秘密,发现远古文明的遗迹 +第51章 :冰神的考验 - 冰神对星河进行考验,考验其心性与毅力 +第52章 :与旅行者的合作 - 与旅行者深入合作,对抗共同的敌人 +第53章 :深渊的威胁 - 深渊的威胁日益逼近,星河需要寻找应对之策 +第54章 :钟离的计划 - 钟离的计划逐渐显露,其真实目的更加清晰 +第55章 :星河的过去 - 星河过去的阴影再次浮现,影响其与他人的关系 +第56章 :派蒙的觉醒 - 派蒙觉醒,展现其强大的力量 +第57章 :丽莎的牺牲 - 丽莎为了保护星河做出牺牲 +第58章 :前往至冬 - 前往至冬,面对新的挑战与阴谋 +第59章 :至冬的阴谋 - 揭露至冬的阴谋,对抗幕后黑手 +第60章 :女皇的算计 - 女皇对星河进行算计,考验其智慧与勇气 +第61章 :机械生命的危机 - 至冬的机械生命失控,引发巨大的危机 +第62章 :星河的策略 - 星河运用策略,化解机械生命的危机 +第63章 :派蒙的秘密力量 - 派蒙展现其强大的秘密力量 +第64章 :丽莎的遗产 - 丽莎留下宝贵的遗产,帮助星河对抗危机 +第65章 :对抗深渊使徒 - 对抗强大的深渊使徒,展现星穹铁道科技的威力 +第66章 :旅行者的选择 - 旅行者面临艰难的选择,需要做出决定 +第67章 :钟离的帮助 - 钟离给予星河关键的帮助,但其目的依旧不明 +第68章 :星河的成长 - 星河在经历种种磨难后得到成长 +第69章 :提瓦特的命运 - 提瓦特的命运掌握在星河手中 +第70章 :最终决战的序幕 - 最终决战的序幕拉开,危机全面爆发 +第71章 :与幕后黑手的对抗 - 与幕后黑手展开激烈的对抗 +第72章 :星河的牺牲 - 星河为了拯救提瓦特做出牺牲 +第73章 :派蒙的付出 - 派蒙为了帮助星河付出一切 +第74章 :丽莎的回归 - 丽莎以另一种方式回归,继续守护蒙德 +第75章 :旅行者的抉择 - 旅行者做出最终的抉择 +第76章 :钟离的真相 - 钟离的真实目的最终揭晓 +第77章 :星河的复活 - 星河以意想不到的方式复活 +第78章 :新的危机 - 新的危机出现,提瓦特面临新的挑战 +第79章 :星河的抉择 - 星河需要做出新的抉择,选择自己的未来 +第80章 :派蒙的未来 - 派蒙的未来充满希望与未知 +第81章 :丽莎的守护 - 丽莎继续守护蒙德,为提瓦特贡献力量 +第82章 :旅行者的旅程 - 旅行者继续自己的旅程,探索提瓦特的奥秘 +第83章 :钟离的隐退 - 钟离选择隐退,默默守护提瓦特 +第84章 :星河的归宿 - 星河思考自己的归宿,是留下还是离开 +第85章 :与伙伴的告别 - 与伙伴们告别,回忆共同经历的冒险 +第86章 :返回星穹铁道? - 星河考虑返回星穹铁道,但心中仍有牵挂 +第87章 :提瓦特的未来 - 提瓦特的未来充满希望与挑战 +第88章 :科技与魔法的融合 - 星穹铁道科技与提瓦特魔法开始融合 +第89章 :新的时代 - 提瓦特进入新的时代,科技与魔法共存 +第90章 :星河的遗产 - 星河留下宝贵的遗产,影响着提瓦特的未来 +第91章 :派蒙的传承 - 派蒙将自己的智慧与经验传承下去 +第92章 :丽莎的教导 - 丽莎继续教导年轻一代,传承魔法的艺术 +第93章 :旅行者的故事 - 旅行者的故事继续,探索更多未知的领域 +第94章 :钟离的箴言 - 钟离留下意味深长的箴言,引导提瓦特的未来 +第95章 :星河的回归 - 星河最终做出决定,选择自己的归宿 +第96章 :告别的时刻 - 与提瓦特的伙伴们告别,心中充满不舍 +第97章 :新的旅程 - 星河踏上新的旅程,迎接新的挑战 +第98章 :回首往昔 - 回首在提瓦特的冒险,感慨万千 +第99章 :未来的希望 - 提瓦特的未来充满希望,光明与挑战并存 +第100章 :星河挽歌 - 故事的结尾,留下无限的遐想,开放式结局 + +嗯,我需要按照用户提供的《小说设定》来生成100章的目录。首先,我得仔细阅读并理解这个设定,确保每一章都紧扣主题和叙事结构。 + + 世界观设定:暗影之歌 + +在提瓦特大陆上,暗影力量正在苏醒,带来了新的挑战和阴谋。主要角色包括星辰、艾莎、凯文,以及暗影力量本身。故事探讨了力量与责任、身份与命运、信任与背叛等主题。 + + 目录模板 +第1章: - <简述> +... +第100章: - <简述> + +接下来,我需要规划每一章的内容,确保涵盖起始点、初识艾莎、凯文的动机、危机初现、真相浮现、高潮对决以及开放式结局。 + + 起始点 +星辰被选中成为暗影力量的继承者,但她对自己的能力感到不安,同时怀疑“暗影力量”是否真是保护大陆的存在。这一部分需要引入角色和设定,铺垫后续的冲突。 + + 初识艾莎 +艾莎以刺客的身份接近星辰,试图利用她的暗影能力。两人合作中发现彼此的共同点和隐藏的冲突,这可以增加角色的互动和紧张感。 + + 凯文的动机 +凯文表面上支持星辰和艾莎,但实际上有自己的秘密。他可能是“暗影力量”的后代,或者另有隐情。这部分需要揭示凯文的多面性和潜在威胁。 + + 危机初现 +随着暗影力量的觉醒,大陆出现诡异事件。暗影力量失控,普通民众受害。这可以是故事转折点,展示力量带来的危险。 + + 真相浮现 +通过对凯文、艾莎和星辰的深入探索,揭示他们的过去与命运联系。他们之间存在更大的联系,关系到暗影力量的未来。 + + 高潮对决 +关键时刻,星辰和艾莎必须共同面临强敌。这场对决是力量的较量,更是信任与背叛的考验,增加戏剧性和紧张感。 + + 结局模糊 +故事留下开放结局,暗示暗影力量的未来影响,星辰和艾莎是否能找到自己的道路,或者被力量吞噬。 + +在规划每一章时,我需要确保情节连贯,角色发展合理,同时埋下伏笔,为后续章节做好铺垫。例如,在初识艾莎的阶段,可以设置她的多面性和潜在的秘密,为后期冲突做准备。 + +此外,每章的标题应简洁明了,能够反映主要情节或角色的发展,并附上简短的提示性简述,帮助读者了解每一章的内容。 + +现在,我需要将这些思考整理成一个100章的目录,确保每一章都紧扣设定中的叙事结构和角色发展,同时埋下足够的暗线伏笔。 + + +第1章 :星辰的选择 - 星辰被选中为暗影力量的继承者,但内心充满疑惑与不安。 + +第2章 :暗影之息 - 星辰在练习中意外触发暗影力量,引发混乱,开始探索自己的能力边界。 + +第3章 :艾莎的来临 - 艾莎以刺客身份接近星辰,试图获取更多关于暗影力量的信息,同时对星辰产生复杂情感。 + +第4章 :凯文的真实意图 - 凯文表面上支持星辰和艾莎,但逐渐显露出多面性和隐藏的秘密,引起星辰的警觉。 + +第5章:暗影失控 - 随着暗影力量的强化,大陆出现一系列诡异事件,普通民众受害,暗影力量开始失控。 + +第6章:艾莎的内心挣扎 - 艾莎在完成任务后,发现自己逐渐被暗影力量控制,对自己的选择感到恐慌和纠结。 + +第7章:凯文的秘密揭露 - 凯文被迫公开他的多面性和隐藏的秘密,星辰和艾莎联手调查他的真实意图。 + +第8章:命运的交错 - 通过对凯文、艾莎和星辰的深入了解,逐渐揭示他们之间存在更大的联系关系到暗影力量的未来走向。 + +第9章:危机加剧 - 凯文与暗影力量的势力开始对星辰和艾莎构成直接威胁,两人必须面临更为严峻的挑战。 + +第10章:信任的考验 - 在关键时刻,星辰和艾莎之间的信任面临突破,但也隐藏着潜在的背叛阴谋。 + +第11章:暗影力量的真相 - 星辰通过回忆和资料,逐渐揭开暗影力量的起源和其对世界的影响。 + +第12章:凯文的最终动向 - 凯文的双重身份和真实意图被彻底揭露,他的选择将对暗影力量的未来产生重大影响。 + +第13章:星辰的觉醒 - 星辰在与艾莎的合作中逐渐意识到自己肩负的责任,开始寻找解决问题的方法。 + +第14章:艾莎的挣扎 - 艾莎被迫面对自己的过去和现在的选择,她是否能站出来对抗暗影力量? + +第15章:凯文的覆灭 - 凯文最终的命运如何?他的计划是否能够成功,或者他将成为阻碍? + +第16章:暗影之歌 - 星辰和艾莎联手面临最后的危机,他们需要找到一个办法来控制暗影力量。 + +第17章:力量与责任的抉择 - 在关键时刻,星辰和艾莎必须做出艰难的决定,选择自己的道路还是被力量吞噬。 + +第18章:命运的终结 - 故事进入高潮,暗影力量的威胁更加严峻,星辰和艾莎必须团结起来对抗它。 + +第19章:最后的对决 - 星辰和艾莎面临最终的对决,他们的能力和信任将决定世界的命运。 + +第20章:暗影之光 - 故事情节以开放式结束,暗示暗影力量的未来影响,星辰和艾莎是否能找到自己的道路? \ No newline at end of file diff --git a/Src/Novel_setting.txt b/Src/Novel_setting.txt new file mode 100644 index 0000000..340c5cc --- /dev/null +++ b/Src/Novel_setting.txt @@ -0,0 +1,98 @@ + +小说设定:《星河挽歌:提瓦特星轨》 + +小说类型:玄幻穿越,史诗级冒险,轻松幽默与细腻情感并存,整体偏正剧,但避免过于沉重。 + +字数:约400万字(100章 x 4000字/章) + +视角:第三人称,穿插主角第一人称内心独白。 + +世界观:基于原神世界线平行宇宙的提瓦特大陆,地理环境与原神基本一致,但部分区域势力分布及历史事件有所调整,以适应主角的到来和剧情发展。七国结构保留,但政治格局和社会风貌因主角介入而变。科技与魔法并存,魔法更为显著,主角的星穹铁道科技作为“外来力量”融入其中,引发冲突和变革。重要历史事件保留原神设定,并添加主角引发的新的历史事件。 + +核心内容梗概:主角星河在星穹铁道执行任务时意外穿越到提瓦特大陆,发现大陆面临史无前例的巨大危机。凭借星穹铁道科技力量及经验,星河成为对抗危机的关键人物。在拯救提瓦特过程中,他将与原神角色相遇,建立深厚情谊或产生冲突,最终完成使命,并决定自己的归宿。采用英雄之旅叙事结构,主线为拯救提瓦特,副线为主角与原神角色的情感纠葛。 + +主要角色: + +1. 星河:坚毅、责任感强、略带孤傲,内心温柔渴望归宿。拥有强大的星穹铁道科技力量,但更倾向于智慧和策略解决问题。拥有丰富战斗经验和冷静判断力,但也有过去任务的阴影和对未来命运的迷茫,偶尔冲动,也可能因自身能力产生优越感。他的目标是拯救提瓦特,找到归宿,弥补过去遗憾。他的隐藏秘密是:在星穹铁道的一次任务中,他导致一位重要伙伴牺牲,这个秘密影响着他与他人的关系,并在与原神角色建立深厚感情时成为关键转折点。 + +2. 派蒙:活泼、贪吃、略显成熟,更能理解星河内心世界,给予更深层次的支持。积极参与冒险,但鲁莽会带来麻烦,是重要的喜剧元素。对提瓦特知识渊博,但对星穹铁道科技一无所知。她的目标是帮助星河拯救提瓦特,探索自身存在秘密,与星河建立更深厚羁绊。她的隐藏秘密是:她并非简单的导航精灵,拥有远超常人的智慧和感知能力,与提瓦特大陆的过去有着神秘联系。这会在中段通过关键事件逐步揭示。 她的“记忆碎片”——偶尔失神,喃喃自语,对特定场景反应强烈,甚至出现幻觉,这些会逐渐清晰,暗示她与提瓦特古老文明甚至超越提瓦特的力量有关。 + +3. 丽莎:慵懒、智慧,强大的魔法能力,比原神设定中更成熟、更富有责任感。对星穹铁道科技好奇又警惕,将星河视为竞争对手和值得培养的对象。她会引导主角,但在关键时刻设置考验。她的目标是保护蒙德,培养星河,并寻求突破自身瓶颈。她的隐藏秘密是:她拥有不为人知的秘密身份,与某个神秘组织有关,该组织与即将到来的危机密切相关。她的“禁忌魔法”——偶尔使用威力远超平时能力的魔法,使用后疲惫甚至身体异样,她会刻意隐藏,这与她的神秘组织联系紧密。 + +4. 原神旅行者:与星河互动,产生冲突与合作。两人对提瓦特大陆的理解不同,可能会产生矛盾,但在对抗危机时携手合作。 + +5. 钟离:神秘幕后人物,引导星河完成使命,但目的和动机不明,会带来考验和帮助,也可能成为潜在敌人。他的“真实目的”——对星河的帮助看似无私,但始终保持距离,言行举止充满深意,他的行动与提瓦特危机有关,目的可能并非简单帮助主角,而是为了实现某种更宏大的计划。 + +角色关系与冲突:星河与派蒙(依赖与独立的矛盾),星河与丽莎(竞争与合作),星河与原神旅行者(身份认同冲突),星河与钟离(考验与引导)。 + +暗线与伏笔:派蒙的记忆碎片,丽莎的禁忌魔法,星河的星穹铁道追捕令,钟离的真实目的,提瓦特大陆的失落科技。这些暗线相互交织,推动剧情发展,并为角色成长和故事走向增添可能性和悬念。 + +结局:开放式结局,主角战胜危机,但提瓦特仍面临挑战,主角可以选择留在提瓦特或回到星穹铁道;也可能根据剧情走向相对圆满结局。 + + +世界观与角色动机结合:提瓦特大陆面临的危机是推动星河行动的主要动力,而他过去的经历(星穹铁道任务的阴影和伙伴的牺牲)以及对归宿的渴望,则构成了他内心的冲突和驱动力。派蒙的秘密与提瓦特古老文明的关联,丽莎的禁忌魔法与神秘组织的联系,这些都与即将到来的危机息息相关,也与主角的命运紧密相连。 钟离的真实目的则成为贯穿始终的神秘力量,推动剧情发展,并最终影响故事的结局。 星河的星穹铁道科技,则成为改变提瓦特魔法世界格局的关键因素,引发一系列冲突和变革。 + 世界观设定:暗影之歌 + + 引言 +在一个名为提瓦特的大陆上,力量如同黑暗中的呼吸,无声无息地渗透着一切。这片大陆的历史被称为“暗影年代”,那时期充满了权力斗争和悲剧命运。如今,随着“暗影力量”的再次苏醒,新的阴谋与挑战正逐渐显露。 + + 主要角色 +1. 星辰(亦名:斯琴) + - 背景:一位被称为“暗影之心”的天才少女,拥有操纵暗影的特殊能力。她的力量是暗影之力最强者之一,但同时也让她背负着沉重的责任。 + +2. 艾莎(亦名:艾丽莎) + - 背景:一位冷酷无情的刺客,曾为“暗影力量”效力。她的身世与“暗影力量”密不可分,性格多变,隐藏着巨大的秘密。 + +3. 凯文(亦名:克莱文) + - 背景:自封为提瓦特大陆的守护者,实则心中藏着大不明小谋。他表面上支持星辰和艾莎,但背后暗藏自己的秘密计划。 + +4. 暗影力量 + - 背景:一股强大的力量源于远古,能够操控暗影与黑暗。它的真实目的尚未完全被揭示,既有守护大陆的传说,也有毁灭一切的预言。 + + 世界观亮点 + +1. 力量的双面性 + - 暗影力量是世界的核心,但同时也是最大的危险。掌握它的人往往会被力量所控制,经历深刻的心理变化。 + +2. 身份的迷失 + - 星辰和艾莎都背负着来自“暗影力量”的阴影,他们的身份与命运与力量紧密相连,这种联系既是他们的优势,也是他们的诅咒。 + +3. 权力真空 + - 提瓦特大陆在“暗影年代”后期逐渐走向新的权力真空,各种势力争夺资源和权利,形成了复杂多变的政治格局。 + +4. 宿命的重遇 + - 星辰和艾莎被暗影力量联系起来,这种宿命使他们在面对危机时不得不合作,但同时也让彼此之间充满了矛盾与误解。 + +5. 未知的威胁 + - “暗影力量”的真实目标尚未完全明确,可能是为了大陆的繁荣,也可能是为了彻底摧毁一切。这种不确定性为故事增添了紧张感和悬念。 + + 故事情节展开 + +1. 起始点 + - 星辰被选中成为暗影力量的继承者,但她对自己的能力感到不安,同时也怀疑“暗影力量”是否真的是保护大陆的存在。 + +2. 初识艾莎 + - 艾莎以刺客的身份接近星辰,试图利用她的暗影能力。但两人在合作中逐渐发现彼此之间的共同点,以及隐藏的冲突。 + +3. 凯文的动机 + - 凯文表面上是支持星辰和艾莎,但实际上他有自己的秘密。他可能是“暗影力量”的后代,或者背后另有hidden agenda。 + +4. 危机初现 + - 随着暗影力量的觉醒,大陆出现了一系列诡异的事件。暗影力量开始失控,普通大民被笼罩在阴影中,生存成为一件艰难的事。 + +5. 真相浮现 + - 通过对凯文、艾莎和星辰的深入探索,逐渐揭示他们各自的过去与命运联系。最终,他们会发现自己之间存在着更大的联系,而这可能关系到整个暗影力量的未来。 + +6. 高潮对决 + - 在一次关键时刻,星辰和艾莎必须共同面临一个强大的敌人。这场对决不仅是力量的较量,更是信任与背叛的考验。 + +7. 结局的模糊性 + - 故事情节留下了开放的结局,让读者思考暗影力量最终会带来什么样的未来。星辰和艾莎是否能找到自己的道路,或者他们将被暗影力量彻底吞噬。 + + 主题 +- 力量与责任:暗影力量赋予了一些人巨大的能力,但同时也带来了沉重的责任。 +- 身份与命运:每个人的过去决定了他们的未来,他们是否能超越自己的宿命? +- 信任与背叛:在危难时刻,真实的朋友和潜在的敌人是如何区分的? + + 总结 +“暗影之歌”是一个充满矛盾与复杂性的故事,它探讨了力量、身份、信任等多个深刻主题。通过星辰和艾莎两位主角的视角,读者可以感受到在黑暗与光明之间挣扎的人性,以及他们如何面对命运给予的挑战。 \ No newline at end of file diff --git a/embedding_ollama.py b/embedding_ollama.py index 5764e61..b755ddd 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -1,9 +1,14 @@ -import logging +# embedding_ollama.py import requests from typing import List -import traceback class OllamaEmbeddings: + """ + Ollama 本地服务提供的 Embedding 接口, + 本需求里我们最终拼出形如: http://localhost:11434/api/embed + 即 base_url + "/embed" + """ + def __init__(self, model_name: str, base_url: str): self.model_name = model_name self.base_url = base_url # 这里应形如 http://localhost:11434/api (不再含 /v1) @@ -44,28 +49,6 @@ class OllamaEmbeddings: response = requests.post(url, json=data) response.raise_for_status() result = response.json() - print(result) - - # 检查返回结果是否包含 'embedding' 字段 - if "embedding" not in result: - logging.warning(f"No 'embedding' field in response. Returning empty embedding.") - return [] # 返回空列表 return result["embedding"] - except requests.exceptions.RequestException as e: - logging.error(f"Ollama embeddings request error: {e}") - logging.error(f"Request URL: {url}") - logging.error(f"Request Data: {data}") - logging.error("Full error details:\n" + traceback.format_exc()) - return [] - - except ValueError as e: - logging.error(f"Invalid response structure: {e}") - logging.error(f"Response content: {response.text}") - logging.error("Full error details:\n" + traceback.format_exc()) - return [] - - except Exception as e: - logging.error(f"Unexpected error: {e}") - logging.error("Full error details:\n" + traceback.format_exc()) - return [] + raise Exception(f"Ollama embeddings request error: {e}") diff --git a/novel_generator.py b/novel_generator.py index 71343a2..a9d53ca 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -4,12 +4,17 @@ import os import logging import re from typing import Dict, List, Optional -from typing import TypedDict +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict + from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, START, END from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain.docstore.document import Document + import nltk import math from sentence_transformers import SentenceTransformer @@ -29,15 +34,15 @@ from embedding_ollama import OllamaEmbeddings from chapter_directory_parser import get_chapter_info_from_directory # ============ 日志配置 ============ - logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") def debug_log(prompt: str, response_content: str): - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") + """打印Prompt与Response,可根据需要保留或去掉。""" + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") -# ============ 判断接口格式相关 ============ +# ============ 接口判断函数 ============ def is_using_ollama_api(interface_format: str, base_url: str) -> bool: """ 当 interface_format == "Ollama" 时返回 True @@ -55,8 +60,6 @@ def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: return False -# ============ 创建 Embeddings 对象 ============ - def create_embeddings_object( api_key: str, base_url: str, @@ -69,25 +72,27 @@ def create_embeddings_object( - 当 interface_format = "Ollama" => OllamaEmbeddings(...) (此时把 embed_url 中的 /v1 替换成 /api,以便最后调用 /api/embed) - 当 interface_format = "OpenAI" or "ML Studio" => OpenAIEmbeddings - - 其它情况视需求可扩展 + - 其它情况可自行扩展 """ if is_using_ollama_api(interface_format, embed_url): + # 去除末尾斜杠 fixed_url = embed_url.rstrip("/") + # 如果包含 /v1 则替换为 /api fixed_url = fixed_url.replace("/v1", "/api") return OllamaEmbeddings( model_name=embedding_model_name, base_url=fixed_url ) elif is_using_ml_studio_api(interface_format, base_url): - # ML Studio / OpenAI 兼容 return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) else: # 默认使用 OpenAIEmbeddings return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) +# ============ 日志配置 ============ +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") # ============ 向量库相关 ============ - VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") if not os.path.exists(VECTOR_STORE_DIR): os.makedirs(VECTOR_STORE_DIR) @@ -97,8 +102,8 @@ def clear_vector_store(): 清空本地向量库(删除 vectorstore 文件夹内的内容)。 """ if os.path.exists(VECTOR_STORE_DIR): - import shutil try: + import shutil for filename in os.listdir(VECTOR_STORE_DIR): file_path = os.path.join(VECTOR_STORE_DIR, filename) if os.path.isfile(file_path) or os.path.islink(file_path): @@ -121,6 +126,7 @@ def init_vector_store( ) -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 + embedding_base_url 若不为空,则用于 Ollama 模式下;否则默认使用 base_url。 """ embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( @@ -150,7 +156,6 @@ def load_vector_store( 读取已存在的向量库。若不存在则返回 None。 """ if not os.path.exists(VECTOR_STORE_DIR): - logging.info("Vector store not found. Initializing a new one...") return None embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( @@ -180,10 +185,8 @@ def update_vector_store( embedding_model_name=embedding_model_name, embedding_base_url=embedding_base_url ) - - # 如果向量库不存在,初始化它 if not store: - logging.info("Vector store does not exist. Initializing a new one for new chapter...") + logging.info("Vector store does not exist. Initializing a new one...") init_vector_store( api_key=api_key, base_url=base_url, @@ -197,7 +200,6 @@ def update_vector_store( new_doc = Document(page_content=new_chapter) store.add_documents([new_doc]) store.persist() - logging.info("Vector store updated with the new chapter.") def get_relevant_context_from_vector_store( api_key: str, @@ -210,7 +212,7 @@ def get_relevant_context_from_vector_store( ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - 若向量库不存在或没有足够的内容,则返回空字符串。 + 若向量库不存在则返回空字符串。 """ store = load_vector_store( api_key=api_key, @@ -219,19 +221,10 @@ def get_relevant_context_from_vector_store( embedding_model_name=embedding_model_name, embedding_base_url=embedding_base_url ) - - # 如果向量库为空,直接返回空字符串 if not store: - logging.info("No vector store found. Returning empty context.") + logging.warning("Vector store not found. Returning empty context.") return "" - - # 向量库存在,但没有足够的内容时也避免索引错误 docs = store.similarity_search(query, k=k) - - if not docs: - logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") - return "" - combined = "\n".join([d.page_content for d in docs]) return combined @@ -263,6 +256,7 @@ def Novel_novel_directory_generate( """ 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 """ + # 确保文件夹存在 os.makedirs(filepath, exist_ok=True) model = ChatOpenAI( @@ -333,6 +327,7 @@ def Novel_novel_directory_generate( debug_log(prompt, response.content) return {"novel_directory": response.content.strip()} + # 构建状态图 graph = StateGraph(OverallState) graph.add_node("generate_base_setting", generate_base_setting) graph.add_node("generate_character_setting", generate_character_setting) @@ -368,6 +363,7 @@ def Novel_novel_directory_generate( logging.warning("生成失败:缺少 final_novel_setting 或 novel_directory。") return + # 写入文件 filename_set = os.path.join(filepath, "Novel_setting.txt") filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") @@ -379,6 +375,7 @@ def Novel_novel_directory_generate( append_text_to_file(final_novel_setting_cleaned, filename_set) append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) + logging.info("Novel settings and directory generated successfully.") @@ -397,25 +394,19 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int text = read_file(chap_file).strip() if text: texts.append(text) - if len(texts) < n: - texts = [''] * (n - len(texts)) + texts return texts - def summarize_recent_chapters( - llm_model: str, - api_key: str, - base_url: str, - temperature: float, - chapters_text_list: List[str] -) -> str: + llm_model: str, + api_key: str, + base_url: str, + temperature: float, + chapters_text_list: List[str] + ) -> str: """ - 将最近几章文本拼接,通过模型生成相对简要的“短期内容摘要”。 + 将最近几章的文本拼接后,通过模型生成一个相对详细的“短期内容摘要”。 + 如果没有可用的模型(model=None),则退化为简单截断示例。 """ - if not chapters_text_list: - return "" - if chapters_text_list==['', '', '']: - return "暂无摘要。" model = ChatOpenAI( model=llm_model, api_key=api_key, @@ -423,19 +414,33 @@ def summarize_recent_chapters( temperature=temperature ) + if not chapters_text_list: + return "" + combined_text = "\n".join(chapters_text_list) - prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: + # 如果未传入model,就做个简单的退化输出 + if not model: + return f"【摘要-演示】\n{combined_text[:800]}..." + + # 构造一个提示词(Prompt),指示模型生成精简摘要 + prompt = f"""你是一名资深的长篇小说写作辅助AI。下面是最近几章的合并文本内容: {combined_text} -请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" - +请你为此文本生成一段简洁扼要的摘要,突出主要剧情进展、角色变化、冲突焦点等要点。 +1.请用中文输出,不超过500字。 +2.仅回复摘要内容,不需要其他信息。 +""" + # 调用模型获取摘要 response = model.invoke(prompt) if not response or not response.content.strip(): - return combined_text[:800] + "..." if len(combined_text) > 800 else combined_text + # 若模型无响应或空,返回简单截断 + return f"【摘要-演示】\n{combined_text[:800]}..." + + # 返回模型生成的摘要文本 return response.content.strip() -# ============ 新增:剧情要点/未解决冲突 ============ +# ============ 新增:更新剧情要点/未解决冲突 ============ PLOT_ARCS_PROMPT = """\ 下面是新生成的章节内容: @@ -444,9 +449,9 @@ PLOT_ARCS_PROMPT = """\ 这里是已记录的剧情要点/未解决冲突(可能为空): {old_plot_arcs} -请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 +请基于新的章节内容,提炼出本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 +最终输出一份更新后的剧情要点列表,以帮助后续保持故事的整体一致性和悬念延续。 """ def update_plot_arcs( @@ -457,6 +462,10 @@ def update_plot_arcs( model_name: str, temperature: float ) -> str: + """ + 利用模型分析最新章节文本,提炼或更新“未解决冲突或剧情要点”。 + 并返回更新后的字符串。 + """ model = ChatOpenAI( model=model_name, api_key=api_key, @@ -471,6 +480,7 @@ def update_plot_arcs( if not response: logging.warning("update_plot_arcs: No response.") return old_plot_arcs + debug_log(prompt, response.content) return response.content.strip() @@ -489,44 +499,28 @@ def generate_chapter_draft( word_number: int, temperature: float, novel_novel_directory: str, - filepath: str, - interface_format: str, - embedding_model_name: str, - embedding_base_url: str + filepath: str ) -> str: """ - 生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 + 仅生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 + 并将生成的内容写到 "chapter_{novel_number}.txt" 覆盖写入。 + 同时生成 "outline_{novel_number}.txt" 存储大纲内容。 """ - # 根据目录信息获取本章标题、简介 + # 0) 根据 novel_number 从 novel_novel_directory 中获取本章标题及简述 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 从向量库检索多次上下文(示例:对本章简介、用户指导分别做查询,再合并) - queries = [] - if user_guidance.strip(): - queries.append(user_guidance) - if chapter_brief.strip(): - queries.append(chapter_brief) - # 也可加一句“回顾剧情”之类 - queries.append("回顾剧情") - - relevant_context = "" - for q in queries: - partial_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=base_url, - query=q, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - embedding_base_url=embedding_base_url, - k=2 - ) - if partial_context.strip(): - relevant_context += "\n" + partial_context - # 如果检索结果为空,使用默认值(如空字符串) - if not relevant_context: - relevant_context = "暂无相关内容。" + # 1) 从向量库检索上下文 (此处仅演示 query="回顾剧情") + relevant_context = get_relevant_context_from_vector_store( + api_key=api_key, + base_url=base_url, + query="回顾剧情", + interface_format="OpenAI", + embedding_model_name="", + embedding_base_url="", + k=2 + ) model = ChatOpenAI( model=model_name, @@ -535,10 +529,10 @@ def generate_chapter_draft( temperature=temperature ) - # 1) 生成本章大纲 + # 2) 生成大纲 outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, + character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, global_summary=global_summary, novel_number=novel_number, chapter_title=chapter_title, @@ -556,10 +550,10 @@ def generate_chapter_draft( clear_file_content(outline_file) save_string_to_txt(chapter_outline, outline_file) - # 2) 生成正文草稿 + # 3) 生成正文草稿 writing_prompt_text = chapter_write_prompt.format( novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, + character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, global_summary=global_summary, chapter_outline=chapter_outline, word_number=word_number, @@ -594,12 +588,13 @@ def finalize_chapter( ): """ 对当前章节进行定稿: - 1. 读取草稿文本 - 2. 若字数太短则再次扩写 - 3. 更新全局摘要、角色状态 - 4. 更新剧情要点 - 5. 更新向量库 + 1. 读取 chapter_{novel_number}.txt 的最终内容; + 2. 更新全局摘要、角色状态文件; + 3. 如果字数明显少于 word_number 的 80%,则自动调用 enrich_chapter_text 再次扩写; + 4. 更新向量库; + 5. 新增:更新剧情要点/未解决冲突 -> plot_arcs.txt """ + # 读取当前章节内容 chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -615,9 +610,9 @@ def finalize_chapter( old_global_summary = read_file(global_summary_file) old_plot_arcs = read_file(plot_arcs_file) - # 若篇幅过短,二次扩写 + # 1) 若字数明显不足,做 enrich if len(chapter_text) < 0.8 * word_number: - logging.info("Chapter text is shorter than 80% of desired length. Enriching...") + logging.info("Chapter text seems shorter than 80% of desired length. Attempting to enrich content...") chapter_text = enrich_chapter_text( chapter_text=chapter_text, word_number=word_number, @@ -628,8 +623,9 @@ def finalize_chapter( ) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) + logging.info("Chapter text has been enriched and updated.") - # 更新全局摘要 + # 2) 更新全局摘要 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -647,7 +643,7 @@ def finalize_chapter( new_global_summary = update_global_summary(chapter_text, old_global_summary) - # 更新角色状态 + # 3) 更新角色状态 def update_character_state(chapter_text: str, old_state: str) -> str: prompt = update_character_state_prompt.format( chapter_text=chapter_text, @@ -658,7 +654,7 @@ def finalize_chapter( new_char_state = update_character_state(chapter_text, old_char_state) - # 更新剧情要点 + # 4) 更新剧情要点 new_plot_arcs = update_plot_arcs( chapter_text=chapter_text, old_plot_arcs=old_plot_arcs, @@ -668,7 +664,7 @@ def finalize_chapter( temperature=temperature ) - # 写回文件 + # 5) 覆盖写入文件 clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) @@ -678,10 +674,10 @@ def finalize_chapter( clear_file_content(plot_arcs_file) save_string_to_txt(new_plot_arcs, plot_arcs_file) - # 更新向量库 + # 6) 更新向量库 update_vector_store( - api_key=api_key, - base_url=base_url, + api_key=api_key, + base_url=base_url, new_chapter=chapter_text, interface_format=interface_format, embedding_model_name=embedding_model_name @@ -699,6 +695,7 @@ def enrich_chapter_text( ) -> str: """ 当章节篇幅不足时,调用此函数对章节文本进行二次扩写。 + 可以让模型补充场景描写、角色心理等,保证与现有文本风格一致。 """ model = ChatOpenAI( model=model_name, @@ -716,21 +713,20 @@ def enrich_chapter_text( return chapter_text return response.content.strip() - # ============ 导入外部知识文本 ============ def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - file_path: str, - embedding_base_url: str = "" -) -> None: + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + file_path: str, + embedding_base_url: str = "" + ) -> None: """ 将用户选定的文本文件导入到向量库,以便在写作时检索。 """ - logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") + logging.info(f"开始导入知识库文件: {file_path},当前接口格式: {interface_format},当前模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return @@ -764,12 +760,11 @@ def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: """ - 将文本先按句子切分,然后根据语义相似度进行合并,最后按max_length二次切分。 + 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 """ - # 纠正下载punkt包:'punkt' 而非 'punkt_tab' - nltk.download('punkt', quiet=True) - + nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(content) + if not sentences: return [] diff --git a/ui.py b/ui.py index dd787c5..6bfc0e8 100644 --- a/ui.py +++ b/ui.py @@ -1,12 +1,11 @@ # ui.py # -*- coding: utf-8 -*- -import logging import os import threading import customtkinter as ctk from tkinter import filedialog, messagebox -import traceback + from config_manager import load_config, save_config from utils import read_file, save_string_to_txt from novel_generator import ( @@ -20,12 +19,6 @@ from novel_generator import ( ) from consistency_checker import check_consistency -def log_error(message: str): - """ - 用于打印详细的错误信息和堆栈信息。 - """ - logging.error(f"{message}\n{traceback.format_exc()}") - # 设置全局主题和颜色 ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") @@ -45,6 +38,7 @@ class NovelGeneratorGUI: self.loaded_config = load_config(self.config_file) # ========== 主要的属性变量 ========== + # 注意:配置项较多,此处增加 embedding_model_name_var 以支持 Embedding 模型名称 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) @@ -52,7 +46,7 @@ class NovelGeneratorGUI: self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) - + self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) @@ -134,6 +128,8 @@ class NovelGeneratorGUI: # 日志:只读 self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + + # 设置只读 self.log_text.configure(state="disabled") def build_right_layout(self): @@ -141,6 +137,8 @@ class NovelGeneratorGUI: 右侧用于显示配置与功能按钮。 其中配置被拆分到一个子 TabView:AI接口配置 和 Embedding配置。 下面再放与小说相关的输入(主题/类型/章节数等)与功能按钮。 + + 另:我们只使用 2 列布局,0 列放标签,1 列放输入框或子区域,保证对齐。 """ self.right_frame.grid_rowconfigure(0, weight=0) self.right_frame.grid_rowconfigure(1, weight=0) @@ -174,13 +172,14 @@ class NovelGeneratorGUI: - 模型名称 - Temperature """ + # 配置网格 for i in range(5): 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) + # 回调:当接口格式下拉框发生变更时,如果 Base URL 为空,则根据接口类型自动填默认值 def on_interface_format_changed(new_value): - # 如果用户切换接口格式,可根据需要修改BaseURL为默认值 if new_value == "Ollama": self.base_url_var.set("http://localhost:11434/v1") elif new_value == "ML Studio": @@ -264,6 +263,7 @@ class NovelGeneratorGUI: def build_novel_params_area(self, start_row=2): """ 放置与小说本身相关的一些配置:主题、类型、章节数、文件路径等 + 只使用两列:0列放Label,1列放输入框(可能加子Frame)。 """ # 主题(Topic) topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) @@ -291,7 +291,7 @@ class NovelGeneratorGUI: word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80, font=("Microsoft YaHei", 12)) word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w") - # 保存路径 + # 保存路径 (使用子Frame放置 Entry + Button) filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e") @@ -320,8 +320,11 @@ class NovelGeneratorGUI: """ 主要功能按钮 + 配置保存/加载 按钮 """ + # 调整下布局:0,1列 + # 这里按行摆放多个按钮 self.right_frame.grid_rowconfigure(start_row, weight=0) + # 先放“保存配置”和“加载配置”按钮 self.btn_frame_config = ctk.CTkFrame(self.right_frame) self.btn_frame_config.grid(row=start_row, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_frame_config.columnconfigure(0, weight=1) @@ -333,6 +336,7 @@ class NovelGeneratorGUI: 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") + # 下面是主要功能按钮 self.btn_generate_full = ctk.CTkButton( self.right_frame, text="Step1. 生成设定 & 目录", command=self.generate_full_novel, @@ -385,6 +389,9 @@ class NovelGeneratorGUI: # ------------------ Novel Settings Tab ------------------ def build_setting_tab(self): + """ + 可查看/编辑 Novel_setting.txt 并保存 + """ self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) self.setting_tab.columnconfigure(0, weight=1) @@ -423,6 +430,9 @@ class NovelGeneratorGUI: # ------------------ Novel Directory Tab ------------------ def build_directory_tab(self): + """ + 可查看/编辑 Novel_directory.txt 并保存 + """ self.directory_tab.rowconfigure(0, weight=0) self.directory_tab.rowconfigure(1, weight=1) self.directory_tab.columnconfigure(0, weight=1) @@ -461,6 +471,9 @@ class NovelGeneratorGUI: # ------------------ Character State Tab ------------------ def build_character_tab(self): + """ + 查看/编辑 character_state.txt + """ self.character_tab.rowconfigure(0, weight=0) self.character_tab.rowconfigure(1, weight=1) self.character_tab.columnconfigure(0, weight=1) @@ -499,6 +512,9 @@ class NovelGeneratorGUI: # ------------------ Global Summary Tab ------------------ def build_summary_tab(self): + """ + 查看/编辑 global_summary.txt + """ self.summary_tab.rowconfigure(0, weight=0) self.summary_tab.rowconfigure(1, weight=1) self.summary_tab.columnconfigure(0, weight=1) @@ -587,9 +603,11 @@ class NovelGeneratorGUI: # ------------------ 日志输出 ------------------ 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") # ------------------ 功能区 -------------------- @@ -632,7 +650,6 @@ class NovelGeneratorGUI: ) self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") except Exception as e: - logging.error(f"生成小说设定 & 目录时出错: {e}") self.log(f"❌ 生成小说设定 & 目录时出错: {e}") finally: self.enable_button(self.btn_generate_full) @@ -676,13 +693,10 @@ class NovelGeneratorGUI: chapters_dir = os.path.join(filepath, "chapters") recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) - # 生成最近章节摘要 + # 简易生成最近章节摘要(示例) recent_chapters_summary = summarize_recent_chapters( - llm_model=model_name, - api_key=api_key, - base_url=base_url, - temperature=temperature, - chapters_text_list=recent_3_texts + model_name, # 此处仅示例 + recent_3_texts ) self.log(f"开始生成第{chap_num}章草稿...") @@ -699,10 +713,7 @@ class NovelGeneratorGUI: word_number=word_number, temperature=temperature, novel_novel_directory=novel_directory, - filepath=filepath, - interface_format=self.interface_format_var.get().strip(), - embedding_model_name=self.embedding_model_name_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip() + filepath=filepath ) if draft_text: self.log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -713,7 +724,6 @@ class NovelGeneratorGUI: self.log("⚠️ 本章草稿生成失败或无内容。") except Exception as e: - logging.error(f"生成章节草稿时出错: {e}") self.log(f"❌ 生成章节草稿时出错: {e}") finally: self.enable_button(self.btn_generate_chapter) @@ -731,8 +741,6 @@ class NovelGeneratorGUI: model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() filepath = self.filepath_var.get().strip() - interface_format = self.interface_format_var.get().strip() - embedding_model_name = self.embedding_model_name_var.get().strip() if not filepath: self.log("请先配置保存文件路径。") @@ -747,8 +755,6 @@ class NovelGeneratorGUI: word_number=word_number, api_key=api_key, base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, model_name=model_name, temperature=temperature, filepath=filepath @@ -763,7 +769,6 @@ class NovelGeneratorGUI: self.chapter_result.see("end") except Exception as e: - logging.error(f"定稿章节时出错: {e}") self.log(f"❌ 定稿章节时出错: {e}") finally: self.enable_button(self.btn_finalize_chapter) @@ -796,6 +801,7 @@ class NovelGeneratorGUI: global_summary = read_file(global_summary_file) plot_arcs = read_file(plot_arcs_file) + # 获取当前章节文本 chap_num = self.chapter_num_var.get() chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") chapter_text = read_file(chap_file) @@ -820,7 +826,6 @@ class NovelGeneratorGUI: self.log(result) except Exception as e: - logging.error(f"审校时出错: {e}") self.log(f"❌ 审校时出错: {e}") finally: self.enable_button(self.btn_check_consistency) @@ -843,13 +848,12 @@ class NovelGeneratorGUI: api_key=self.api_key_var.get().strip(), base_url=self.base_url_var.get().strip(), interface_format=self.interface_format_var.get().strip(), - embedding_model_name=self.embedding_model_name_var.get().strip(), - file_path=selected_file, - embedding_base_url=self.embedding_url_var.get().strip() + embedding_base_url=self.embedding_url_var.get().strip(), + embedding_base_url=self.embedding_url_var.get().strip(), + file_path=selected_file ) self.log("✅ 知识库文件导入完成。") except Exception as e: - logging.error(f"导入知识库时出错: {e}") self.log(f"❌ 导入知识库时出错: {e}") finally: self.enable_button(self.btn_import_knowledge) @@ -882,6 +886,7 @@ class NovelGeneratorGUI: if not arcs_text: arcs_text = "当前没有记录的剧情要点或冲突。" + # 弹窗显示 top = ctk.CTkToplevel(self.master) top.title("剧情要点/未解决冲突") top.geometry("600x400") From 231575c2da178de7de9ec222d27a33ddf6f3dc6e Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 18:25:35 +0800 Subject: [PATCH 028/142] 111 --- Src/outlines/outline_1.txt | 43 ++++++++ novel_generator.py | 215 +++++++++++++++++++------------------ ui.py | 71 ++++++------ 3 files changed, 186 insertions(+), 143 deletions(-) create mode 100644 Src/outlines/outline_1.txt diff --git a/Src/outlines/outline_1.txt b/Src/outlines/outline_1.txt new file mode 100644 index 0000000..1cd6cc6 --- /dev/null +++ b/Src/outlines/outline_1.txt @@ -0,0 +1,43 @@ + +好,我现在需要帮用户构思小说的第1章大纲,标题是“星河坠落提瓦特”,简述是“穿越与初遇派蒙,危机初现端倪”。首先,我得理解这个标题和简述的意思。看起来这是一部含有玄幻或奇幻元素的小说,可能涉及时间旅行、门派纷争或者某种隐藏的危机。 + +用户给出的内容里提到要围绕本章标题和简述设计详细大纲,并且用户已经提供了一个示例的结构。所以我需要按照那个格式来写,而不是用数字分点。也就是说,先描述每个部分,然后是主要冲突、出现的角色,他们的动机,暗线推进,以及结尾的悬念。 + +首先,本章标题“星河坠落提瓦特”,听起来像是主角从一个繁荣的地方坠落到另一个世界。简述说的是穿越和初遇派蒙,同时危机开始显现。这可能意味着主角从一个平行世界穿越到这个世界,并在门派之间初次相遇,引发了一些危机。 + +接下来,我需要构思主要冲突。可能是主角到达提瓦特城,发现这里有一个古老的力量或秘密,门派之间开始竞争,而主角作为穿越者,带来了改变,同时也面临着被追杀的风险。这样既呼应了标题,又体现了简述中的危机初现。 + +然后是角色部分。可能需要包括主角、对方的门派成员,比如派蒙,以及提瓦特城里的其他关键人物。每个角色在这章里有什么目标和动机,比如追杀主角的人,或者试图掌控力量的人。 + +接下来是推动暗线和悬念的部分。比如,提瓦特城的秘密可能涉及到星河的力量,而派蒙门派对这个力量的争夺可能引发更大的冲突。同时,主角可能带有未知的任务或使命,这会影响后续章节。 + +最后是结尾的悬念,可能是一个转折点,比如主角揭示了某个秘密,或者派蒙开始怀疑自己,而星河的力量在台面上或下面开始显现,为后续剧情埋下伏笔。 + +整体来看,我需要确保大纲紧扣标题和简述,同时为后续章节留有余地。可能还要注意角色的性格发展和潜在冲突,以保持故事的吸引力。 + + +### 第 1 章:星河坠落提瓦特 + +#### 主要冲突: +- **星河降临**:穿越者从一个繁荣世纪跨越到提瓦特大陆,带来了“星河”般的强大力量,引发了提瓦特城内外的关注与恐慌。 +- **门派初遇**:派蒙门派首领派长派出追杀队伍,试图抓住这股未知力量,以掌控“星河之力”。 +- **危机初现**:一场隐藏已久的秘密势力开始苏醒,在提瓦特城内外的阴谋逐渐浮现,为后续的灾难埋下伏笔。 + +#### 角色出现: +1. **主角(未命名)**:穿越者,带着星河般的强大力量,性格冷静果敢,却对自己的来历一无所知。 +2. **派长**:派蒙门派领袖,精通古老法术,眼光独到,对“星河之力”嗜求不已。 +3. **追杀队伍**:派长派出的十名绝技高手,打着门派的旗号,在提瓦特城内外肆虐。 +4. **提瓦特市民**:生活在平静世代的普通百姓,见证并参与这场突如其来的震撼。 +5. **神秘老者**:提瓦特城中的隐世高人,对“星河之力”知之甚详,暗中观察与等待。 + +#### 推动暗线: +- **星河力量的真相**:主角身上蕴含的力量竟是千年前就被预言的“星河之力”,而提瓦特城内外的秘密势力对此虎视眈眈。 +- **派蒙门派的野心**:派长不仅想要获取“星河之力”,还暗藏着更大的图谋,试图借此重塑整个世界。 +- **神秘老者的警告**:一位隐世高人试图接触主角,给出“星河之力”与世界危机的隐喻,为后续剧情埋下重大伏笔。 + +#### 结尾悬念: +- **力量暴露**:在一次追杀中,主角无意间释放出“星河之力”,震撼整个提瓦特城,引发更大的关注与恐慌。 +- **派长的挣扎**:派长对自己的能力和未来感到迷茫,开始怀疑自己是否已经被力量的猎头所误导。 +- **神秘老者的突然消失**:一位神秘老者在关键时刻悄然离去,为后续追杀埋下更多谜团。 + +通过这场开篇的震撼,未命名的主角将从一个普通穿越者逐渐成长为“星河”的继承者,而提瓦特城与整个世界也将迎来一场前所未有的巨变。 \ No newline at end of file diff --git a/novel_generator.py b/novel_generator.py index a9d53ca..71343a2 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -4,17 +4,12 @@ import os import logging import re from typing import Dict, List, Optional -try: - from typing import TypedDict -except ImportError: - from typing_extensions import TypedDict - +from typing import TypedDict from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, START, END from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain.docstore.document import Document - import nltk import math from sentence_transformers import SentenceTransformer @@ -34,15 +29,15 @@ from embedding_ollama import OllamaEmbeddings from chapter_directory_parser import get_chapter_info_from_directory # ============ 日志配置 ============ + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") def debug_log(prompt: str, response_content: str): - """打印Prompt与Response,可根据需要保留或去掉。""" - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") +# ============ 判断接口格式相关 ============ -# ============ 接口判断函数 ============ def is_using_ollama_api(interface_format: str, base_url: str) -> bool: """ 当 interface_format == "Ollama" 时返回 True @@ -60,6 +55,8 @@ def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: return False +# ============ 创建 Embeddings 对象 ============ + def create_embeddings_object( api_key: str, base_url: str, @@ -72,27 +69,25 @@ def create_embeddings_object( - 当 interface_format = "Ollama" => OllamaEmbeddings(...) (此时把 embed_url 中的 /v1 替换成 /api,以便最后调用 /api/embed) - 当 interface_format = "OpenAI" or "ML Studio" => OpenAIEmbeddings - - 其它情况可自行扩展 + - 其它情况视需求可扩展 """ if is_using_ollama_api(interface_format, embed_url): - # 去除末尾斜杠 fixed_url = embed_url.rstrip("/") - # 如果包含 /v1 则替换为 /api fixed_url = fixed_url.replace("/v1", "/api") return OllamaEmbeddings( model_name=embedding_model_name, base_url=fixed_url ) elif is_using_ml_studio_api(interface_format, base_url): + # ML Studio / OpenAI 兼容 return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) else: # 默认使用 OpenAIEmbeddings return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) -# ============ 日志配置 ============ -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") # ============ 向量库相关 ============ + VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") if not os.path.exists(VECTOR_STORE_DIR): os.makedirs(VECTOR_STORE_DIR) @@ -102,8 +97,8 @@ def clear_vector_store(): 清空本地向量库(删除 vectorstore 文件夹内的内容)。 """ if os.path.exists(VECTOR_STORE_DIR): + import shutil try: - import shutil for filename in os.listdir(VECTOR_STORE_DIR): file_path = os.path.join(VECTOR_STORE_DIR, filename) if os.path.isfile(file_path) or os.path.islink(file_path): @@ -126,7 +121,6 @@ def init_vector_store( ) -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 - embedding_base_url 若不为空,则用于 Ollama 模式下;否则默认使用 base_url。 """ embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( @@ -156,6 +150,7 @@ def load_vector_store( 读取已存在的向量库。若不存在则返回 None。 """ if not os.path.exists(VECTOR_STORE_DIR): + logging.info("Vector store not found. Initializing a new one...") return None embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( @@ -185,8 +180,10 @@ def update_vector_store( embedding_model_name=embedding_model_name, embedding_base_url=embedding_base_url ) + + # 如果向量库不存在,初始化它 if not store: - logging.info("Vector store does not exist. Initializing a new one...") + logging.info("Vector store does not exist. Initializing a new one for new chapter...") init_vector_store( api_key=api_key, base_url=base_url, @@ -200,6 +197,7 @@ def update_vector_store( new_doc = Document(page_content=new_chapter) store.add_documents([new_doc]) store.persist() + logging.info("Vector store updated with the new chapter.") def get_relevant_context_from_vector_store( api_key: str, @@ -212,7 +210,7 @@ def get_relevant_context_from_vector_store( ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - 若向量库不存在则返回空字符串。 + 若向量库不存在或没有足够的内容,则返回空字符串。 """ store = load_vector_store( api_key=api_key, @@ -221,10 +219,19 @@ def get_relevant_context_from_vector_store( embedding_model_name=embedding_model_name, embedding_base_url=embedding_base_url ) + + # 如果向量库为空,直接返回空字符串 if not store: - logging.warning("Vector store not found. Returning empty context.") + logging.info("No vector store found. Returning empty context.") return "" + + # 向量库存在,但没有足够的内容时也避免索引错误 docs = store.similarity_search(query, k=k) + + if not docs: + logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") + return "" + combined = "\n".join([d.page_content for d in docs]) return combined @@ -256,7 +263,6 @@ def Novel_novel_directory_generate( """ 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 """ - # 确保文件夹存在 os.makedirs(filepath, exist_ok=True) model = ChatOpenAI( @@ -327,7 +333,6 @@ def Novel_novel_directory_generate( debug_log(prompt, response.content) return {"novel_directory": response.content.strip()} - # 构建状态图 graph = StateGraph(OverallState) graph.add_node("generate_base_setting", generate_base_setting) graph.add_node("generate_character_setting", generate_character_setting) @@ -363,7 +368,6 @@ def Novel_novel_directory_generate( logging.warning("生成失败:缺少 final_novel_setting 或 novel_directory。") return - # 写入文件 filename_set = os.path.join(filepath, "Novel_setting.txt") filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") @@ -375,7 +379,6 @@ def Novel_novel_directory_generate( append_text_to_file(final_novel_setting_cleaned, filename_set) append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) - logging.info("Novel settings and directory generated successfully.") @@ -394,19 +397,25 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int text = read_file(chap_file).strip() if text: texts.append(text) + if len(texts) < n: + texts = [''] * (n - len(texts)) + texts return texts + def summarize_recent_chapters( - llm_model: str, - api_key: str, - base_url: str, - temperature: float, - chapters_text_list: List[str] - ) -> str: + llm_model: str, + api_key: str, + base_url: str, + temperature: float, + chapters_text_list: List[str] +) -> str: """ - 将最近几章的文本拼接后,通过模型生成一个相对详细的“短期内容摘要”。 - 如果没有可用的模型(model=None),则退化为简单截断示例。 + 将最近几章文本拼接,通过模型生成相对简要的“短期内容摘要”。 """ + if not chapters_text_list: + return "" + if chapters_text_list==['', '', '']: + return "暂无摘要。" model = ChatOpenAI( model=llm_model, api_key=api_key, @@ -414,33 +423,19 @@ def summarize_recent_chapters( temperature=temperature ) - if not chapters_text_list: - return "" - combined_text = "\n".join(chapters_text_list) - # 如果未传入model,就做个简单的退化输出 - if not model: - return f"【摘要-演示】\n{combined_text[:800]}..." - - # 构造一个提示词(Prompt),指示模型生成精简摘要 - prompt = f"""你是一名资深的长篇小说写作辅助AI。下面是最近几章的合并文本内容: + prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: {combined_text} -请你为此文本生成一段简洁扼要的摘要,突出主要剧情进展、角色变化、冲突焦点等要点。 -1.请用中文输出,不超过500字。 -2.仅回复摘要内容,不需要其他信息。 -""" - # 调用模型获取摘要 +请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" + response = model.invoke(prompt) if not response or not response.content.strip(): - # 若模型无响应或空,返回简单截断 - return f"【摘要-演示】\n{combined_text[:800]}..." - - # 返回模型生成的摘要文本 + return combined_text[:800] + "..." if len(combined_text) > 800 else combined_text return response.content.strip() -# ============ 新增:更新剧情要点/未解决冲突 ============ +# ============ 新增:剧情要点/未解决冲突 ============ PLOT_ARCS_PROMPT = """\ 下面是新生成的章节内容: @@ -449,9 +444,9 @@ PLOT_ARCS_PROMPT = """\ 这里是已记录的剧情要点/未解决冲突(可能为空): {old_plot_arcs} -请基于新的章节内容,提炼出本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 +请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出一份更新后的剧情要点列表,以帮助后续保持故事的整体一致性和悬念延续。 +最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 """ def update_plot_arcs( @@ -462,10 +457,6 @@ def update_plot_arcs( model_name: str, temperature: float ) -> str: - """ - 利用模型分析最新章节文本,提炼或更新“未解决冲突或剧情要点”。 - 并返回更新后的字符串。 - """ model = ChatOpenAI( model=model_name, api_key=api_key, @@ -480,7 +471,6 @@ def update_plot_arcs( if not response: logging.warning("update_plot_arcs: No response.") return old_plot_arcs - debug_log(prompt, response.content) return response.content.strip() @@ -499,28 +489,44 @@ def generate_chapter_draft( word_number: int, temperature: float, novel_novel_directory: str, - filepath: str + filepath: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str ) -> str: """ - 仅生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 - 并将生成的内容写到 "chapter_{novel_number}.txt" 覆盖写入。 - 同时生成 "outline_{novel_number}.txt" 存储大纲内容。 + 生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 """ - # 0) 根据 novel_number 从 novel_novel_directory 中获取本章标题及简述 + # 根据目录信息获取本章标题、简介 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 1) 从向量库检索上下文 (此处仅演示 query="回顾剧情") - relevant_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=base_url, - query="回顾剧情", - interface_format="OpenAI", - embedding_model_name="", - embedding_base_url="", - k=2 - ) + # 从向量库检索多次上下文(示例:对本章简介、用户指导分别做查询,再合并) + queries = [] + if user_guidance.strip(): + queries.append(user_guidance) + if chapter_brief.strip(): + queries.append(chapter_brief) + # 也可加一句“回顾剧情”之类 + queries.append("回顾剧情") + + relevant_context = "" + for q in queries: + partial_context = get_relevant_context_from_vector_store( + api_key=api_key, + base_url=base_url, + query=q, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + embedding_base_url=embedding_base_url, + k=2 + ) + if partial_context.strip(): + relevant_context += "\n" + partial_context + # 如果检索结果为空,使用默认值(如空字符串) + if not relevant_context: + relevant_context = "暂无相关内容。" model = ChatOpenAI( model=model_name, @@ -529,10 +535,10 @@ def generate_chapter_draft( temperature=temperature ) - # 2) 生成大纲 + # 1) 生成本章大纲 outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, - character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, global_summary=global_summary, novel_number=novel_number, chapter_title=chapter_title, @@ -550,10 +556,10 @@ def generate_chapter_draft( clear_file_content(outline_file) save_string_to_txt(chapter_outline, outline_file) - # 3) 生成正文草稿 + # 2) 生成正文草稿 writing_prompt_text = chapter_write_prompt.format( novel_setting=novel_settings, - character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, global_summary=global_summary, chapter_outline=chapter_outline, word_number=word_number, @@ -588,13 +594,12 @@ def finalize_chapter( ): """ 对当前章节进行定稿: - 1. 读取 chapter_{novel_number}.txt 的最终内容; - 2. 更新全局摘要、角色状态文件; - 3. 如果字数明显少于 word_number 的 80%,则自动调用 enrich_chapter_text 再次扩写; - 4. 更新向量库; - 5. 新增:更新剧情要点/未解决冲突 -> plot_arcs.txt + 1. 读取草稿文本 + 2. 若字数太短则再次扩写 + 3. 更新全局摘要、角色状态 + 4. 更新剧情要点 + 5. 更新向量库 """ - # 读取当前章节内容 chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -610,9 +615,9 @@ def finalize_chapter( old_global_summary = read_file(global_summary_file) old_plot_arcs = read_file(plot_arcs_file) - # 1) 若字数明显不足,做 enrich + # 若篇幅过短,二次扩写 if len(chapter_text) < 0.8 * word_number: - logging.info("Chapter text seems shorter than 80% of desired length. Attempting to enrich content...") + logging.info("Chapter text is shorter than 80% of desired length. Enriching...") chapter_text = enrich_chapter_text( chapter_text=chapter_text, word_number=word_number, @@ -623,9 +628,8 @@ def finalize_chapter( ) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - logging.info("Chapter text has been enriched and updated.") - # 2) 更新全局摘要 + # 更新全局摘要 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -643,7 +647,7 @@ def finalize_chapter( new_global_summary = update_global_summary(chapter_text, old_global_summary) - # 3) 更新角色状态 + # 更新角色状态 def update_character_state(chapter_text: str, old_state: str) -> str: prompt = update_character_state_prompt.format( chapter_text=chapter_text, @@ -654,7 +658,7 @@ def finalize_chapter( new_char_state = update_character_state(chapter_text, old_char_state) - # 4) 更新剧情要点 + # 更新剧情要点 new_plot_arcs = update_plot_arcs( chapter_text=chapter_text, old_plot_arcs=old_plot_arcs, @@ -664,7 +668,7 @@ def finalize_chapter( temperature=temperature ) - # 5) 覆盖写入文件 + # 写回文件 clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) @@ -674,10 +678,10 @@ def finalize_chapter( clear_file_content(plot_arcs_file) save_string_to_txt(new_plot_arcs, plot_arcs_file) - # 6) 更新向量库 + # 更新向量库 update_vector_store( - api_key=api_key, - base_url=base_url, + api_key=api_key, + base_url=base_url, new_chapter=chapter_text, interface_format=interface_format, embedding_model_name=embedding_model_name @@ -695,7 +699,6 @@ def enrich_chapter_text( ) -> str: """ 当章节篇幅不足时,调用此函数对章节文本进行二次扩写。 - 可以让模型补充场景描写、角色心理等,保证与现有文本风格一致。 """ model = ChatOpenAI( model=model_name, @@ -713,20 +716,21 @@ def enrich_chapter_text( return chapter_text return response.content.strip() + # ============ 导入外部知识文本 ============ def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - file_path: str, - embedding_base_url: str = "" - ) -> None: + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + file_path: str, + embedding_base_url: str = "" +) -> None: """ 将用户选定的文本文件导入到向量库,以便在写作时检索。 """ - logging.info(f"开始导入知识库文件: {file_path},当前接口格式: {interface_format},当前模型: {embedding_model_name}") + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return @@ -760,11 +764,12 @@ def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: """ - 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 + 将文本先按句子切分,然后根据语义相似度进行合并,最后按max_length二次切分。 """ - nltk.download('punkt_tab', quiet=True) - sentences = nltk.sent_tokenize(content) + # 纠正下载punkt包:'punkt' 而非 'punkt_tab' + nltk.download('punkt', quiet=True) + sentences = nltk.sent_tokenize(content) if not sentences: return [] diff --git a/ui.py b/ui.py index 6bfc0e8..dd787c5 100644 --- a/ui.py +++ b/ui.py @@ -1,11 +1,12 @@ # ui.py # -*- coding: utf-8 -*- +import logging import os import threading import customtkinter as ctk from tkinter import filedialog, messagebox - +import traceback from config_manager import load_config, save_config from utils import read_file, save_string_to_txt from novel_generator import ( @@ -19,6 +20,12 @@ from novel_generator import ( ) from consistency_checker import check_consistency +def log_error(message: str): + """ + 用于打印详细的错误信息和堆栈信息。 + """ + logging.error(f"{message}\n{traceback.format_exc()}") + # 设置全局主题和颜色 ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") @@ -38,7 +45,6 @@ class NovelGeneratorGUI: self.loaded_config = load_config(self.config_file) # ========== 主要的属性变量 ========== - # 注意:配置项较多,此处增加 embedding_model_name_var 以支持 Embedding 模型名称 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) @@ -46,7 +52,7 @@ class NovelGeneratorGUI: self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) - + self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) @@ -128,8 +134,6 @@ class NovelGeneratorGUI: # 日志:只读 self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - - # 设置只读 self.log_text.configure(state="disabled") def build_right_layout(self): @@ -137,8 +141,6 @@ class NovelGeneratorGUI: 右侧用于显示配置与功能按钮。 其中配置被拆分到一个子 TabView:AI接口配置 和 Embedding配置。 下面再放与小说相关的输入(主题/类型/章节数等)与功能按钮。 - - 另:我们只使用 2 列布局,0 列放标签,1 列放输入框或子区域,保证对齐。 """ self.right_frame.grid_rowconfigure(0, weight=0) self.right_frame.grid_rowconfigure(1, weight=0) @@ -172,14 +174,13 @@ class NovelGeneratorGUI: - 模型名称 - Temperature """ - # 配置网格 for i in range(5): 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) - # 回调:当接口格式下拉框发生变更时,如果 Base URL 为空,则根据接口类型自动填默认值 def on_interface_format_changed(new_value): + # 如果用户切换接口格式,可根据需要修改BaseURL为默认值 if new_value == "Ollama": self.base_url_var.set("http://localhost:11434/v1") elif new_value == "ML Studio": @@ -263,7 +264,6 @@ class NovelGeneratorGUI: def build_novel_params_area(self, start_row=2): """ 放置与小说本身相关的一些配置:主题、类型、章节数、文件路径等 - 只使用两列:0列放Label,1列放输入框(可能加子Frame)。 """ # 主题(Topic) topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) @@ -291,7 +291,7 @@ class NovelGeneratorGUI: word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80, font=("Microsoft YaHei", 12)) word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w") - # 保存路径 (使用子Frame放置 Entry + Button) + # 保存路径 filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e") @@ -320,11 +320,8 @@ class NovelGeneratorGUI: """ 主要功能按钮 + 配置保存/加载 按钮 """ - # 调整下布局:0,1列 - # 这里按行摆放多个按钮 self.right_frame.grid_rowconfigure(start_row, weight=0) - # 先放“保存配置”和“加载配置”按钮 self.btn_frame_config = ctk.CTkFrame(self.right_frame) self.btn_frame_config.grid(row=start_row, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_frame_config.columnconfigure(0, weight=1) @@ -336,7 +333,6 @@ class NovelGeneratorGUI: 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") - # 下面是主要功能按钮 self.btn_generate_full = ctk.CTkButton( self.right_frame, text="Step1. 生成设定 & 目录", command=self.generate_full_novel, @@ -389,9 +385,6 @@ class NovelGeneratorGUI: # ------------------ Novel Settings Tab ------------------ def build_setting_tab(self): - """ - 可查看/编辑 Novel_setting.txt 并保存 - """ self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) self.setting_tab.columnconfigure(0, weight=1) @@ -430,9 +423,6 @@ class NovelGeneratorGUI: # ------------------ Novel Directory Tab ------------------ def build_directory_tab(self): - """ - 可查看/编辑 Novel_directory.txt 并保存 - """ self.directory_tab.rowconfigure(0, weight=0) self.directory_tab.rowconfigure(1, weight=1) self.directory_tab.columnconfigure(0, weight=1) @@ -471,9 +461,6 @@ class NovelGeneratorGUI: # ------------------ Character State Tab ------------------ def build_character_tab(self): - """ - 查看/编辑 character_state.txt - """ self.character_tab.rowconfigure(0, weight=0) self.character_tab.rowconfigure(1, weight=1) self.character_tab.columnconfigure(0, weight=1) @@ -512,9 +499,6 @@ class NovelGeneratorGUI: # ------------------ Global Summary Tab ------------------ def build_summary_tab(self): - """ - 查看/编辑 global_summary.txt - """ self.summary_tab.rowconfigure(0, weight=0) self.summary_tab.rowconfigure(1, weight=1) self.summary_tab.columnconfigure(0, weight=1) @@ -603,11 +587,9 @@ class NovelGeneratorGUI: # ------------------ 日志输出 ------------------ 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") # ------------------ 功能区 -------------------- @@ -650,6 +632,7 @@ class NovelGeneratorGUI: ) self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") except Exception as e: + logging.error(f"生成小说设定 & 目录时出错: {e}") self.log(f"❌ 生成小说设定 & 目录时出错: {e}") finally: self.enable_button(self.btn_generate_full) @@ -693,10 +676,13 @@ class NovelGeneratorGUI: chapters_dir = os.path.join(filepath, "chapters") recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) - # 简易生成最近章节摘要(示例) + # 生成最近章节摘要 recent_chapters_summary = summarize_recent_chapters( - model_name, # 此处仅示例 - recent_3_texts + llm_model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature, + chapters_text_list=recent_3_texts ) self.log(f"开始生成第{chap_num}章草稿...") @@ -713,7 +699,10 @@ class NovelGeneratorGUI: word_number=word_number, temperature=temperature, novel_novel_directory=novel_directory, - filepath=filepath + filepath=filepath, + interface_format=self.interface_format_var.get().strip(), + embedding_model_name=self.embedding_model_name_var.get().strip(), + embedding_base_url=self.embedding_url_var.get().strip() ) if draft_text: self.log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -724,6 +713,7 @@ class NovelGeneratorGUI: self.log("⚠️ 本章草稿生成失败或无内容。") except Exception as e: + logging.error(f"生成章节草稿时出错: {e}") self.log(f"❌ 生成章节草稿时出错: {e}") finally: self.enable_button(self.btn_generate_chapter) @@ -741,6 +731,8 @@ class NovelGeneratorGUI: model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() filepath = self.filepath_var.get().strip() + interface_format = self.interface_format_var.get().strip() + embedding_model_name = self.embedding_model_name_var.get().strip() if not filepath: self.log("请先配置保存文件路径。") @@ -755,6 +747,8 @@ class NovelGeneratorGUI: word_number=word_number, api_key=api_key, base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, model_name=model_name, temperature=temperature, filepath=filepath @@ -769,6 +763,7 @@ class NovelGeneratorGUI: self.chapter_result.see("end") except Exception as e: + logging.error(f"定稿章节时出错: {e}") self.log(f"❌ 定稿章节时出错: {e}") finally: self.enable_button(self.btn_finalize_chapter) @@ -801,7 +796,6 @@ class NovelGeneratorGUI: global_summary = read_file(global_summary_file) plot_arcs = read_file(plot_arcs_file) - # 获取当前章节文本 chap_num = self.chapter_num_var.get() chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") chapter_text = read_file(chap_file) @@ -826,6 +820,7 @@ class NovelGeneratorGUI: self.log(result) except Exception as e: + logging.error(f"审校时出错: {e}") self.log(f"❌ 审校时出错: {e}") finally: self.enable_button(self.btn_check_consistency) @@ -848,12 +843,13 @@ class NovelGeneratorGUI: api_key=self.api_key_var.get().strip(), base_url=self.base_url_var.get().strip(), interface_format=self.interface_format_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip(), - file_path=selected_file + embedding_model_name=self.embedding_model_name_var.get().strip(), + file_path=selected_file, + embedding_base_url=self.embedding_url_var.get().strip() ) self.log("✅ 知识库文件导入完成。") except Exception as e: + logging.error(f"导入知识库时出错: {e}") self.log(f"❌ 导入知识库时出错: {e}") finally: self.enable_button(self.btn_import_knowledge) @@ -886,7 +882,6 @@ class NovelGeneratorGUI: if not arcs_text: arcs_text = "当前没有记录的剧情要点或冲突。" - # 弹窗显示 top = ctk.CTkToplevel(self.master) top.title("剧情要点/未解决冲突") top.geometry("600x400") From 6d8a67782cd9393a5518cd2267cf58220036d77d Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 18:35:10 +0800 Subject: [PATCH 029/142] 222 --- embedding_ollama.py | 18 ++-- novel_generator.py | 54 +++++------ ui.py | 218 ++++++++++++++++++++------------------------ 3 files changed, 133 insertions(+), 157 deletions(-) diff --git a/embedding_ollama.py b/embedding_ollama.py index b755ddd..8dd6dea 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -5,15 +5,18 @@ from typing import List class OllamaEmbeddings: """ Ollama 本地服务提供的 Embedding 接口, - 本需求里我们最终拼出形如: http://localhost:11434/api/embed + 最终拼出形如: http://localhost:11434/api/embed 即 base_url + "/embed" """ def __init__(self, model_name: str, base_url: str): self.model_name = model_name - self.base_url = base_url # 这里应形如 http://localhost:11434/api (不再含 /v1) + self.base_url = base_url # 这里一般形如 http://localhost:11434/api (而非 /v1) def embed(self, texts: List[str]) -> List[List[float]]: + """ + 批量将多段文本转换为embedding向量 + """ embeddings = [] for text in texts: embeddings.append(self.embed_single_document(text)) @@ -21,13 +24,9 @@ class OllamaEmbeddings: def embed_documents(self, texts: List[str]) -> List[List[float]]: """ - 将多段文本转换为向量列表 + 兼容langchain的接口写法 """ - embeddings = [] - for text in texts: - emb = self.embed_single_document(text) - embeddings.append(emb) - return embeddings + return self.embed(texts) def embed_query(self, query: str) -> List[float]: """ @@ -38,7 +37,6 @@ class OllamaEmbeddings: def embed_single_document(self, text: str) -> List[float]: """ 调用 Ollama 本地服务接口,获取文本的 embedding。 - 这里统一改为请求: [base_url]/embed """ url = f"{self.base_url}/embed" data = { @@ -49,6 +47,8 @@ class OllamaEmbeddings: response = requests.post(url, json=data) response.raise_for_status() result = response.json() + if "embedding" not in result: + raise ValueError("No 'embedding' field in Ollama response.") return result["embedding"] except requests.exceptions.RequestException as e: raise Exception(f"Ollama embeddings request error: {e}") diff --git a/novel_generator.py b/novel_generator.py index 71343a2..0b01ac6 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -29,12 +29,11 @@ from embedding_ollama import OllamaEmbeddings from chapter_directory_parser import get_chapter_info_from_directory # ============ 日志配置 ============ - logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") def debug_log(prompt: str, response_content: str): - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") # ============ 判断接口格式相关 ============ @@ -54,7 +53,6 @@ def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: return True return False - # ============ 创建 Embeddings 对象 ============ def create_embeddings_object( @@ -67,25 +65,23 @@ def create_embeddings_object( """ 根据用户在UI中配置的参数,返回对应的 embeddings 对象。 - 当 interface_format = "Ollama" => OllamaEmbeddings(...) - (此时把 embed_url 中的 /v1 替换成 /api,以便最后调用 /api/embed) - - 当 interface_format = "OpenAI" or "ML Studio" => OpenAIEmbeddings - - 其它情况视需求可扩展 + - 当 interface_format = "OpenAI"/"ML Studio" => OpenAIEmbeddings(...) + - 其它情况可扩展 """ if is_using_ollama_api(interface_format, embed_url): fixed_url = embed_url.rstrip("/") + # Ollama embedding接口通常是 /api/embed fixed_url = fixed_url.replace("/v1", "/api") return OllamaEmbeddings( model_name=embedding_model_name, base_url=fixed_url ) elif is_using_ml_studio_api(interface_format, base_url): - # ML Studio / OpenAI 兼容 return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) else: # 默认使用 OpenAIEmbeddings return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) - # ============ 向量库相关 ============ VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") @@ -225,9 +221,7 @@ def get_relevant_context_from_vector_store( logging.info("No vector store found. Returning empty context.") return "" - # 向量库存在,但没有足够的内容时也避免索引错误 docs = store.similarity_search(query, k=k) - if not docs: logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") return "" @@ -235,7 +229,6 @@ def get_relevant_context_from_vector_store( combined = "\n".join([d.page_content for d in docs]) return combined - # ============ 多步生成:设置 & 目录 ============ class OverallState(TypedDict): @@ -308,7 +301,7 @@ def Novel_novel_directory_generate( debug_log(prompt, response.content) return {"dark_lines": response.content.strip()} - def finalize_novel_setting(state: OverallState) -> Dict[str, str]: + def finalize_novel_setting_func(state: OverallState) -> Dict[str, str]: prompt = finalize_setting_prompt.format( novel_setting_base=state["novel_setting_base"], character_setting=state["character_setting"], @@ -321,7 +314,7 @@ def Novel_novel_directory_generate( debug_log(prompt, response.content) return {"final_novel_setting": response.content.strip()} - def generate_novel_directory(state: OverallState) -> Dict[str, str]: + def generate_novel_directory_func(state: OverallState) -> Dict[str, str]: prompt = novel_directory_prompt.format( final_novel_setting=state["final_novel_setting"], number_of_chapters=state["number_of_chapters"] @@ -337,8 +330,8 @@ def Novel_novel_directory_generate( graph.add_node("generate_base_setting", generate_base_setting) graph.add_node("generate_character_setting", generate_character_setting) graph.add_node("generate_dark_lines", generate_dark_lines) - graph.add_node("finalize_novel_setting", finalize_novel_setting) - graph.add_node("generate_novel_directory", generate_novel_directory) + graph.add_node("finalize_novel_setting", finalize_novel_setting_func) + graph.add_node("generate_novel_directory", generate_novel_directory_func) graph.add_edge(START, "generate_base_setting") graph.add_edge("generate_base_setting", "generate_character_setting") @@ -377,10 +370,14 @@ def Novel_novel_directory_generate( final_novel_setting_cleaned = clean_text(final_novel_setting) final_novel_directory_cleaned = clean_text(final_novel_directory) - append_text_to_file(final_novel_setting_cleaned, filename_set) - append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) - logging.info("Novel settings and directory generated successfully.") + # 改进:写文件时先清空再写入 + clear_file_content(filename_set) + save_string_to_txt(final_novel_setting_cleaned, filename_set) + clear_file_content(filename_novel_directory) + save_string_to_txt(final_novel_directory_cleaned, filename_novel_directory) + + logging.info("Novel settings and directory generated successfully.") # ============ 获取最近N章内容,生成短期摘要 ============ @@ -401,7 +398,6 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int texts = [''] * (n - len(texts)) + texts return texts - def summarize_recent_chapters( llm_model: str, api_key: str, @@ -414,8 +410,10 @@ def summarize_recent_chapters( """ if not chapters_text_list: return "" - if chapters_text_list==['', '', '']: + # 如果列表里全是空,则无法生成摘要 + if all(not txt.strip() for txt in chapters_text_list): return "暂无摘要。" + model = ChatOpenAI( model=llm_model, api_key=api_key, @@ -431,10 +429,10 @@ def summarize_recent_chapters( response = model.invoke(prompt) if not response or not response.content.strip(): + # 若模型无响应,就截取一段作为“备选” return combined_text[:800] + "..." if len(combined_text) > 800 else combined_text return response.content.strip() - # ============ 新增:剧情要点/未解决冲突 ============ PLOT_ARCS_PROMPT = """\ @@ -473,7 +471,6 @@ def update_plot_arcs( return old_plot_arcs return response.content.strip() - # ============ 生成章节草稿 & 定稿 ============ def generate_chapter_draft( @@ -502,13 +499,12 @@ def generate_chapter_draft( chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 从向量库检索多次上下文(示例:对本章简介、用户指导分别做查询,再合并) + # 从向量库检索上下文 queries = [] if user_guidance.strip(): queries.append(user_guidance) if chapter_brief.strip(): queries.append(chapter_brief) - # 也可加一句“回顾剧情”之类 queries.append("回顾剧情") relevant_context = "" @@ -524,7 +520,6 @@ def generate_chapter_draft( ) if partial_context.strip(): relevant_context += "\n" + partial_context - # 如果检索结果为空,使用默认值(如空字符串) if not relevant_context: relevant_context = "暂无相关内容。" @@ -716,7 +711,6 @@ def enrich_chapter_text( return chapter_text return response.content.strip() - # ============ 导入外部知识文本 ============ def import_knowledge_file( @@ -740,6 +734,8 @@ def import_knowledge_file( logging.warning("知识库文件内容为空。") return + nltk.download('punkt_tab', quiet=True) + paragraphs = advanced_split_content(content) store = load_vector_store(api_key, base_url, interface_format, embedding_model_name, embedding_base_url) @@ -765,10 +761,8 @@ def advanced_split_content(content: str, max_length: int = 500) -> List[str]: """ 将文本先按句子切分,然后根据语义相似度进行合并,最后按max_length二次切分。 + 可根据需要微调此逻辑。 """ - # 纠正下载punkt包:'punkt' 而非 'punkt_tab' - nltk.download('punkt', quiet=True) - sentences = nltk.sent_tokenize(content) if not sentences: return [] diff --git a/ui.py b/ui.py index dd787c5..43fe10c 100644 --- a/ui.py +++ b/ui.py @@ -8,7 +8,7 @@ import customtkinter as ctk from tkinter import filedialog, messagebox import traceback from config_manager import load_config, save_config -from utils import read_file, save_string_to_txt +from utils import read_file, save_string_to_txt, clear_file_content from novel_generator import ( Novel_novel_directory_generate, generate_chapter_draft, @@ -34,7 +34,14 @@ class NovelGeneratorGUI: def __init__(self, master): self.master = master self.master.title("Novel Generator GUI (CustomTkinter)") - self.master.iconbitmap("icon.ico") + + # 防止因 icon.ico 不存在导致程序崩溃 + try: + if os.path.exists("icon.ico"): + self.master.iconbitmap("icon.ico") + except Exception: + pass + # 窗口最大化 self.master.state("zoomed") # 配置窗口大小 @@ -90,9 +97,6 @@ class NovelGeneratorGUI: # ------------------ 主功能 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) @@ -100,6 +104,7 @@ class NovelGeneratorGUI: # 左侧Frame self.left_frame = ctk.CTkFrame(self.main_tab) self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) + # 右侧Frame self.right_frame = ctk.CTkFrame(self.main_tab) self.right_frame.grid(row=0, column=1, sticky="nsew", padx=2, pady=2) @@ -110,11 +115,6 @@ class NovelGeneratorGUI: self.build_right_layout() def build_left_layout(self): - """ - 左侧包含两个区域: - 1. 本章草稿内容(可编辑) - 2. 输出日志(只读) - """ self.left_frame.grid_rowconfigure(0, weight=3) self.left_frame.grid_rowconfigure(1, weight=1) self.left_frame.grid_columnconfigure(0, weight=1) @@ -137,16 +137,11 @@ class NovelGeneratorGUI: self.log_text.configure(state="disabled") def build_right_layout(self): - """ - 右侧用于显示配置与功能按钮。 - 其中配置被拆分到一个子 TabView:AI接口配置 和 Embedding配置。 - 下面再放与小说相关的输入(主题/类型/章节数等)与功能按钮。 - """ self.right_frame.grid_rowconfigure(0, weight=0) self.right_frame.grid_rowconfigure(1, weight=0) self.right_frame.grid_rowconfigure(2, weight=1) - self.right_frame.grid_columnconfigure(0, weight=0) - self.right_frame.grid_columnconfigure(1, weight=1) + self.right_frame.columnconfigure(0, weight=0) + self.right_frame.columnconfigure(1, weight=1) # ========== 1. 配置项 TabView(AI接口配置 & Embedding配置) ========== self.config_tabview = ctk.CTkTabview(self.right_frame) @@ -166,19 +161,6 @@ class NovelGeneratorGUI: self.build_main_buttons_area(start_row=10) def build_ai_config_tab(self): - """ - 在 AI接口配置 子Tab 上放置: - - API Key - - Base URL - - 接口格式 - - 模型名称 - - Temperature - """ - for i in range(5): - 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) - def on_interface_format_changed(new_value): # 如果用户切换接口格式,可根据需要修改BaseURL为默认值 if new_value == "Ollama": @@ -188,6 +170,11 @@ class NovelGeneratorGUI: elif new_value == "OpenAI": self.base_url_var.set("https://api.agicto.cn/v1") + for i in range(5): + 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) + # 1. API Key api_key_label = ctk.CTkLabel(self.ai_config_tab, text="API Key:", font=("Microsoft YaHei", 12)) api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") @@ -239,11 +226,6 @@ class NovelGeneratorGUI: self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") def build_embeddings_config_tab(self): - """ - 在 Embedding配置 子Tab 上放置: - - Embedding URL - - Embedding 模型名称 - """ for i in range(2): self.embeddings_config_tab.grid_rowconfigure(i, weight=0) self.embeddings_config_tab.grid_columnconfigure(0, weight=0) @@ -262,9 +244,6 @@ class NovelGeneratorGUI: emb_model_name_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") def build_novel_params_area(self, start_row=2): - """ - 放置与小说本身相关的一些配置:主题、类型、章节数、文件路径等 - """ # 主题(Topic) topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) topic_label.grid(row=start_row, column=0, padx=5, pady=5, sticky="e") @@ -317,9 +296,6 @@ class NovelGeneratorGUI: self.user_guide_text.grid(row=start_row+6, column=1, padx=5, pady=5, sticky="nsew") def build_main_buttons_area(self, start_row=10): - """ - 主要功能按钮 + 配置保存/加载 按钮 - """ self.right_frame.grid_rowconfigure(start_row, weight=0) self.btn_frame_config = ctk.CTkFrame(self.right_frame) @@ -418,6 +394,7 @@ class NovelGeneratorGUI: content = self.setting_text.get("0.0", "end").strip() setting_file = os.path.join(filepath, "Novel_setting.txt") + clear_file_content(setting_file) save_string_to_txt(content, setting_file) self.log("已保存对 Novel_setting.txt 的修改。") @@ -456,6 +433,7 @@ class NovelGeneratorGUI: content = self.directory_text.get("0.0", "end").strip() directory_file = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(directory_file) save_string_to_txt(content, directory_file) self.log("已保存对 Novel_directory.txt 的修改。") @@ -494,6 +472,7 @@ class NovelGeneratorGUI: content = self.character_text.get("0.0", "end").strip() char_file = os.path.join(filepath, "character_state.txt") + clear_file_content(char_file) save_string_to_txt(content, char_file) self.log("已保存对 character_state.txt 的修改。") @@ -532,6 +511,7 @@ class NovelGeneratorGUI: content = self.summary_text.get("0.0", "end").strip() summary_file = os.path.join(filepath, "global_summary.txt") + clear_file_content(summary_file) save_string_to_txt(content, summary_file) self.log("已保存对 global_summary.txt 的修改。") @@ -585,24 +565,35 @@ class NovelGeneratorGUI: if selected_dir: self.filepath_var.set(selected_dir) - # ------------------ 日志输出 ------------------ + # ------------------ 日志输出(主线程安全) ------------------ 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 disable_button(self, btn): - btn.configure(state="disabled") + def safe_log(self, message: str): + # 子线程中调用:把真正的日志操作切换回主线程执行 + self.master.after(0, lambda: self.log(message)) - def enable_button(self, btn): - btn.configure(state="normal") + # 由于按钮更新也属于UI操作,也需要安全方式 + 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 generate_full_novel(self): """生成小说设定 & 目录""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + def task(): - self.disable_button(self.btn_generate_full) + self.disable_button_safe(self.btn_generate_full) try: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() @@ -611,14 +602,9 @@ class NovelGeneratorGUI: genre = self.genre_var.get().strip() num_chapters = self.num_chapters_var.get() word_number = self.word_number_var.get() - filepath = self.filepath_var.get().strip() temperature = self.temperature_var.get() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - self.log("开始生成小说设定和目录...") + self.safe_log("开始生成小说设定和目录...") Novel_novel_directory_generate( api_key=api_key, base_url=base_url, @@ -630,35 +616,33 @@ class NovelGeneratorGUI: filepath=filepath, temperature=temperature ) - self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") + self.safe_log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") except Exception as e: - logging.error(f"生成小说设定 & 目录时出错: {e}") - self.log(f"❌ 生成小说设定 & 目录时出错: {e}") + log_error(f"生成小说设定 & 目录时出错: {e}") + self.safe_log(f"❌ 生成小说设定 & 目录时出错: {e}") finally: - self.enable_button(self.btn_generate_full) + self.enable_button_safe(self.btn_generate_full) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() def generate_chapter_draft_ui(self): """生成当前章节的草稿""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + def task(): - self.disable_button(self.btn_generate_chapter) + self.disable_button_safe(self.btn_generate_chapter) 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() - filepath = self.filepath_var.get().strip() - - if not filepath: - self.log("请先配置保存文件路径。") - return - novel_settings_file = os.path.join(filepath, "Novel_setting.txt") novel_settings = read_file(novel_settings_file) if not novel_settings.strip(): - self.log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") + self.safe_log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") return character_state_file = os.path.join(filepath, "character_state.txt") @@ -685,7 +669,7 @@ class NovelGeneratorGUI: chapters_text_list=recent_3_texts ) - self.log(f"开始生成第{chap_num}章草稿...") + self.safe_log(f"开始生成第{chap_num}章草稿...") draft_text = generate_chapter_draft( novel_settings=novel_settings, global_summary=global_summary, @@ -705,43 +689,46 @@ class NovelGeneratorGUI: embedding_base_url=self.embedding_url_var.get().strip() ) if draft_text: - self.log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") - self.chapter_result.delete("0.0", "end") - self.chapter_result.insert("0.0", draft_text) - self.chapter_result.see("end") + self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") + # 更新UI中的章节草稿 + self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) else: - self.log("⚠️ 本章草稿生成失败或无内容。") + self.safe_log("⚠️ 本章草稿生成失败或无内容。") except Exception as e: - logging.error(f"生成章节草稿时出错: {e}") - self.log(f"❌ 生成章节草稿时出错: {e}") + log_error(f"生成章节草稿时出错: {e}") + self.safe_log(f"❌ 生成章节草稿时出错: {e}") finally: - self.enable_button(self.btn_generate_chapter) + self.enable_button_safe(self.btn_generate_chapter) - thread = threading.Thread(target=task) - thread.start() + 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): """定稿当前章节:更新全局摘要、角色状态、向量库等""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + def task(): - self.disable_button(self.btn_finalize_chapter) + self.disable_button_safe(self.btn_finalize_chapter) 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() - filepath = self.filepath_var.get().strip() interface_format = self.interface_format_var.get().strip() embedding_model_name = self.embedding_model_name_var.get().strip() - if not filepath: - self.log("请先配置保存文件路径。") - return - chap_num = self.chapter_num_var.get() word_number = self.word_number_var.get() - self.log(f"开始定稿第{chap_num}章...") + self.safe_log(f"开始定稿第{chap_num}章...") finalize_chapter( novel_number=chap_num, word_number=word_number, @@ -753,38 +740,35 @@ class NovelGeneratorGUI: temperature=temperature, filepath=filepath ) - self.log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") + self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") # 读取定稿后的文本显示 chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") final_text = read_file(chap_file) - self.chapter_result.delete("0.0", "end") - self.chapter_result.insert("0.0", final_text) - self.chapter_result.see("end") + self.master.after(0, lambda: self.show_chapter_in_textbox(final_text)) except Exception as e: - logging.error(f"定稿章节时出错: {e}") - self.log(f"❌ 定稿章节时出错: {e}") + log_error(f"定稿章节时出错: {e}") + self.safe_log(f"❌ 定稿章节时出错: {e}") finally: - self.enable_button(self.btn_finalize_chapter) + self.enable_button_safe(self.btn_finalize_chapter) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() def do_consistency_check(self): """使用审校Agent对最新章节进行简单一致性或冲突检查""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + def task(): - self.disable_button(self.btn_check_consistency) + 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() - filepath = self.filepath_var.get().strip() - - if not filepath: - self.log("请先配置保存文件路径。") - return novel_settings_file = os.path.join(filepath, "Novel_setting.txt") character_state_file = os.path.join(filepath, "character_state.txt") @@ -801,10 +785,10 @@ class NovelGeneratorGUI: chapter_text = read_file(chap_file) if not chapter_text.strip(): - self.log("⚠️ 当前章节文件为空或不存在,无法审校。") + self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。") return - self.log("开始一致性审校...") + self.safe_log("开始一致性审校...") result = check_consistency( novel_setting=novel_setting, character_state=character_state, @@ -816,17 +800,16 @@ class NovelGeneratorGUI: temperature=temperature, plot_arcs=plot_arcs ) - self.log("审校结果:") - self.log(result) + self.safe_log("审校结果:") + self.safe_log(result) except Exception as e: - logging.error(f"审校时出错: {e}") - self.log(f"❌ 审校时出错: {e}") + log_error(f"审校时出错: {e}") + self.safe_log(f"❌ 审校时出错: {e}") finally: - self.enable_button(self.btn_check_consistency) + self.enable_button_safe(self.btn_check_consistency) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() def import_knowledge_handler(self): """处理导入知识库文件。""" @@ -836,9 +819,9 @@ class NovelGeneratorGUI: ) if selected_file: def task(): - self.disable_button(self.btn_import_knowledge) + self.disable_button_safe(self.btn_import_knowledge) try: - self.log(f"开始导入知识库文件: {selected_file}") + self.safe_log(f"开始导入知识库文件: {selected_file}") import_knowledge_file( api_key=self.api_key_var.get().strip(), base_url=self.base_url_var.get().strip(), @@ -847,15 +830,14 @@ class NovelGeneratorGUI: file_path=selected_file, embedding_base_url=self.embedding_url_var.get().strip() ) - self.log("✅ 知识库文件导入完成。") + self.safe_log("✅ 知识库文件导入完成。") except Exception as e: - logging.error(f"导入知识库时出错: {e}") - self.log(f"❌ 导入知识库时出错: {e}") + log_error(f"导入知识库时出错: {e}") + self.safe_log(f"❌ 导入知识库时出错: {e}") finally: - self.enable_button(self.btn_import_knowledge) + self.enable_button_safe(self.btn_import_knowledge) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() def clear_vectorstore_handler(self): """清空向量库按钮:弹出二次确认。""" From 4b8ceffc01d3c4623424e1a54a765c01e429faf6 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 19:17:07 +0800 Subject: [PATCH 030/142] 333 --- Src/Novel_directory.txt | 182 ------------------------------------- Src/Novel_setting.txt | 98 -------------------- Src/outlines/outline_1.txt | 43 --------- embedding_ollama.py | 2 +- novel_generator.py | 100 ++++++++++---------- ui.py | 2 +- 6 files changed, 50 insertions(+), 377 deletions(-) delete mode 100644 Src/Novel_directory.txt delete mode 100644 Src/Novel_setting.txt delete mode 100644 Src/outlines/outline_1.txt diff --git a/Src/Novel_directory.txt b/Src/Novel_directory.txt deleted file mode 100644 index 3a9cc6f..0000000 --- a/Src/Novel_directory.txt +++ /dev/null @@ -1,182 +0,0 @@ - -第1章 :星河坠落提瓦特 - 穿越与初遇派蒙,危机初现端倪 -第2章 :蒙德的初印象 - 初探蒙德城,与丽莎的首次相遇 -第3章 :酒馆的闲谈 - 了解提瓦特局势,派蒙展现惊人一面 -第4章 :丽莎的试探 - 星河展现科技实力,引来丽莎的警惕 -第5章 :委托与冒险 - 接受委托,初次展现星穹铁道科技的运用 -第6章 :风魔龙的挑战 - 运用科技对抗风魔龙,展现策略与智慧 -第7章 :派蒙的异常 - 派蒙出现记忆碎片,暗示其不寻常身份 -第8章 :丽莎的秘密魔法 - 丽莎使用禁忌魔法,展现其神秘一面 -第9章 :旅行者的出现 - 与原神旅行者初次相遇,产生摩擦与合作 -第10章 :蒙德的阴谋 - 发现蒙德隐藏的危机,与丽莎深入合作 -第11章 :追捕令的阴影 - 星河收到星穹铁道追捕令,内心挣扎 -第12章 :深入调查 - 调查蒙德的危机,发现与丽莎的秘密组织有关 -第13章 :钟离的现身 - 钟离初次出现,对星河进行暗示与引导 -第14章 :失落科技的线索 - 发现关于提瓦特失落科技的线索 -第15章 :前往璃月 - 踏上前往璃月的旅程,面对新的挑战 -第16章 :璃月港的繁华 - 体验璃月港的文化,了解璃月局势 -第17章 :往生堂的委托 - 接受往生堂的委托,深入了解璃月历史 -第18章 :岩王帝君的考验 - 钟离对星河进行考验,揭示其真实目的 -第19章 :地脉异常 - 发现璃月地脉异常,与旅行者合作调查 -第20章 :黄金屋的秘密 - 探索黄金屋的秘密,发现与失落科技有关 -第21章 :刻晴的质疑 - 刻晴对星河的科技持有怀疑态度 -第22章 :对抗岩龙蜥 - 运用科技与策略对抗强大的岩龙蜥 -第23章 :派蒙的回忆 - 派蒙回忆片段增多,身份逐渐清晰 -第24章 :丽莎的引导 - 丽莎引导星河学习提瓦特魔法 -第25章 :星河的抉择 - 星河面临道德困境,做出艰难选择 -第26章 :前往稻妻 - 前往稻妻,面对新的挑战与危机 -第27章 :稻妻的雷电 - 体验稻妻的文化与习俗,对抗雷电将军的威压 -第28章 :八重神子的试探 - 八重神子对星河进行试探,考验其能力 -第29章 :影向地的秘密 - 探索影向地的秘密,发现与提瓦特历史有关 -第30章 :反抗军与合作 - 与稻妻反抗军合作,对抗幕后黑手 -第31章 :永恒的执政官 - 与雷电将军正面交锋,展现智慧与实力 -第32章 :派蒙的过去 - 派蒙的过去逐渐揭开,与提瓦特古老文明有关 -第33章 :丽莎的组织 - 丽莎所属组织的真相逐渐浮出水面 -第34章 :前往须弥 - 前往须弥,面对新的挑战与文化 -第35章 :须弥的智慧 - 体验须弥的文化,了解须弥的智慧与科技 -第36章 :草神的大智慧 - 与草神进行交流,探讨生命的意义 -第37章 :森林的危机 - 须弥森林面临危机,星河运用科技解决问题 -第38章 :沙漠的秘密 - 探索须弥沙漠的秘密,发现远古文明的遗迹 -第39章 :散兵的阴谋 - 散兵的阴谋逐渐浮出水面,星河需要应对 -第40章 :与散兵的对抗 - 与散兵展开对抗,展现星穹铁道科技的优势 -第41章 :前往枫丹 - 前往枫丹,面对新的挑战与科技 -第42章 :枫丹的审判 - 体验枫丹的法律与秩序,面对复杂的案件 -第43章 :水神的神秘 - 与水神进行互动,了解枫丹的秘密 -第44章 :机械岛的危机 - 枫丹机械岛面临危机,星河运用科技解决问题 -第45章 :潜入深海 - 潜入深海,探索深海的秘密与科技 -第46章 :派蒙的预言 - 派蒙预言即将到来的巨大危机 -第47章 :丽莎的抉择 - 丽莎面临艰难的抉择,需要做出牺牲 -第48章 :前往纳塔 - 前往纳塔,面对新的挑战与文化 -第49章 :纳塔的冰雪 - 体验纳塔的严寒环境,与冰雪元素对抗 -第50章 :雪山之巅的秘密 - 探索雪山之巅的秘密,发现远古文明的遗迹 -第51章 :冰神的考验 - 冰神对星河进行考验,考验其心性与毅力 -第52章 :与旅行者的合作 - 与旅行者深入合作,对抗共同的敌人 -第53章 :深渊的威胁 - 深渊的威胁日益逼近,星河需要寻找应对之策 -第54章 :钟离的计划 - 钟离的计划逐渐显露,其真实目的更加清晰 -第55章 :星河的过去 - 星河过去的阴影再次浮现,影响其与他人的关系 -第56章 :派蒙的觉醒 - 派蒙觉醒,展现其强大的力量 -第57章 :丽莎的牺牲 - 丽莎为了保护星河做出牺牲 -第58章 :前往至冬 - 前往至冬,面对新的挑战与阴谋 -第59章 :至冬的阴谋 - 揭露至冬的阴谋,对抗幕后黑手 -第60章 :女皇的算计 - 女皇对星河进行算计,考验其智慧与勇气 -第61章 :机械生命的危机 - 至冬的机械生命失控,引发巨大的危机 -第62章 :星河的策略 - 星河运用策略,化解机械生命的危机 -第63章 :派蒙的秘密力量 - 派蒙展现其强大的秘密力量 -第64章 :丽莎的遗产 - 丽莎留下宝贵的遗产,帮助星河对抗危机 -第65章 :对抗深渊使徒 - 对抗强大的深渊使徒,展现星穹铁道科技的威力 -第66章 :旅行者的选择 - 旅行者面临艰难的选择,需要做出决定 -第67章 :钟离的帮助 - 钟离给予星河关键的帮助,但其目的依旧不明 -第68章 :星河的成长 - 星河在经历种种磨难后得到成长 -第69章 :提瓦特的命运 - 提瓦特的命运掌握在星河手中 -第70章 :最终决战的序幕 - 最终决战的序幕拉开,危机全面爆发 -第71章 :与幕后黑手的对抗 - 与幕后黑手展开激烈的对抗 -第72章 :星河的牺牲 - 星河为了拯救提瓦特做出牺牲 -第73章 :派蒙的付出 - 派蒙为了帮助星河付出一切 -第74章 :丽莎的回归 - 丽莎以另一种方式回归,继续守护蒙德 -第75章 :旅行者的抉择 - 旅行者做出最终的抉择 -第76章 :钟离的真相 - 钟离的真实目的最终揭晓 -第77章 :星河的复活 - 星河以意想不到的方式复活 -第78章 :新的危机 - 新的危机出现,提瓦特面临新的挑战 -第79章 :星河的抉择 - 星河需要做出新的抉择,选择自己的未来 -第80章 :派蒙的未来 - 派蒙的未来充满希望与未知 -第81章 :丽莎的守护 - 丽莎继续守护蒙德,为提瓦特贡献力量 -第82章 :旅行者的旅程 - 旅行者继续自己的旅程,探索提瓦特的奥秘 -第83章 :钟离的隐退 - 钟离选择隐退,默默守护提瓦特 -第84章 :星河的归宿 - 星河思考自己的归宿,是留下还是离开 -第85章 :与伙伴的告别 - 与伙伴们告别,回忆共同经历的冒险 -第86章 :返回星穹铁道? - 星河考虑返回星穹铁道,但心中仍有牵挂 -第87章 :提瓦特的未来 - 提瓦特的未来充满希望与挑战 -第88章 :科技与魔法的融合 - 星穹铁道科技与提瓦特魔法开始融合 -第89章 :新的时代 - 提瓦特进入新的时代,科技与魔法共存 -第90章 :星河的遗产 - 星河留下宝贵的遗产,影响着提瓦特的未来 -第91章 :派蒙的传承 - 派蒙将自己的智慧与经验传承下去 -第92章 :丽莎的教导 - 丽莎继续教导年轻一代,传承魔法的艺术 -第93章 :旅行者的故事 - 旅行者的故事继续,探索更多未知的领域 -第94章 :钟离的箴言 - 钟离留下意味深长的箴言,引导提瓦特的未来 -第95章 :星河的回归 - 星河最终做出决定,选择自己的归宿 -第96章 :告别的时刻 - 与提瓦特的伙伴们告别,心中充满不舍 -第97章 :新的旅程 - 星河踏上新的旅程,迎接新的挑战 -第98章 :回首往昔 - 回首在提瓦特的冒险,感慨万千 -第99章 :未来的希望 - 提瓦特的未来充满希望,光明与挑战并存 -第100章 :星河挽歌 - 故事的结尾,留下无限的遐想,开放式结局 - -嗯,我需要按照用户提供的《小说设定》来生成100章的目录。首先,我得仔细阅读并理解这个设定,确保每一章都紧扣主题和叙事结构。 - - 世界观设定:暗影之歌 - -在提瓦特大陆上,暗影力量正在苏醒,带来了新的挑战和阴谋。主要角色包括星辰、艾莎、凯文,以及暗影力量本身。故事探讨了力量与责任、身份与命运、信任与背叛等主题。 - - 目录模板 -第1章: - <简述> -... -第100章: - <简述> - -接下来,我需要规划每一章的内容,确保涵盖起始点、初识艾莎、凯文的动机、危机初现、真相浮现、高潮对决以及开放式结局。 - - 起始点 -星辰被选中成为暗影力量的继承者,但她对自己的能力感到不安,同时怀疑“暗影力量”是否真是保护大陆的存在。这一部分需要引入角色和设定,铺垫后续的冲突。 - - 初识艾莎 -艾莎以刺客的身份接近星辰,试图利用她的暗影能力。两人合作中发现彼此的共同点和隐藏的冲突,这可以增加角色的互动和紧张感。 - - 凯文的动机 -凯文表面上支持星辰和艾莎,但实际上有自己的秘密。他可能是“暗影力量”的后代,或者另有隐情。这部分需要揭示凯文的多面性和潜在威胁。 - - 危机初现 -随着暗影力量的觉醒,大陆出现诡异事件。暗影力量失控,普通民众受害。这可以是故事转折点,展示力量带来的危险。 - - 真相浮现 -通过对凯文、艾莎和星辰的深入探索,揭示他们的过去与命运联系。他们之间存在更大的联系,关系到暗影力量的未来。 - - 高潮对决 -关键时刻,星辰和艾莎必须共同面临强敌。这场对决是力量的较量,更是信任与背叛的考验,增加戏剧性和紧张感。 - - 结局模糊 -故事留下开放结局,暗示暗影力量的未来影响,星辰和艾莎是否能找到自己的道路,或者被力量吞噬。 - -在规划每一章时,我需要确保情节连贯,角色发展合理,同时埋下伏笔,为后续章节做好铺垫。例如,在初识艾莎的阶段,可以设置她的多面性和潜在的秘密,为后期冲突做准备。 - -此外,每章的标题应简洁明了,能够反映主要情节或角色的发展,并附上简短的提示性简述,帮助读者了解每一章的内容。 - -现在,我需要将这些思考整理成一个100章的目录,确保每一章都紧扣设定中的叙事结构和角色发展,同时埋下足够的暗线伏笔。 - - -第1章 :星辰的选择 - 星辰被选中为暗影力量的继承者,但内心充满疑惑与不安。 - -第2章 :暗影之息 - 星辰在练习中意外触发暗影力量,引发混乱,开始探索自己的能力边界。 - -第3章 :艾莎的来临 - 艾莎以刺客身份接近星辰,试图获取更多关于暗影力量的信息,同时对星辰产生复杂情感。 - -第4章 :凯文的真实意图 - 凯文表面上支持星辰和艾莎,但逐渐显露出多面性和隐藏的秘密,引起星辰的警觉。 - -第5章:暗影失控 - 随着暗影力量的强化,大陆出现一系列诡异事件,普通民众受害,暗影力量开始失控。 - -第6章:艾莎的内心挣扎 - 艾莎在完成任务后,发现自己逐渐被暗影力量控制,对自己的选择感到恐慌和纠结。 - -第7章:凯文的秘密揭露 - 凯文被迫公开他的多面性和隐藏的秘密,星辰和艾莎联手调查他的真实意图。 - -第8章:命运的交错 - 通过对凯文、艾莎和星辰的深入了解,逐渐揭示他们之间存在更大的联系关系到暗影力量的未来走向。 - -第9章:危机加剧 - 凯文与暗影力量的势力开始对星辰和艾莎构成直接威胁,两人必须面临更为严峻的挑战。 - -第10章:信任的考验 - 在关键时刻,星辰和艾莎之间的信任面临突破,但也隐藏着潜在的背叛阴谋。 - -第11章:暗影力量的真相 - 星辰通过回忆和资料,逐渐揭开暗影力量的起源和其对世界的影响。 - -第12章:凯文的最终动向 - 凯文的双重身份和真实意图被彻底揭露,他的选择将对暗影力量的未来产生重大影响。 - -第13章:星辰的觉醒 - 星辰在与艾莎的合作中逐渐意识到自己肩负的责任,开始寻找解决问题的方法。 - -第14章:艾莎的挣扎 - 艾莎被迫面对自己的过去和现在的选择,她是否能站出来对抗暗影力量? - -第15章:凯文的覆灭 - 凯文最终的命运如何?他的计划是否能够成功,或者他将成为阻碍? - -第16章:暗影之歌 - 星辰和艾莎联手面临最后的危机,他们需要找到一个办法来控制暗影力量。 - -第17章:力量与责任的抉择 - 在关键时刻,星辰和艾莎必须做出艰难的决定,选择自己的道路还是被力量吞噬。 - -第18章:命运的终结 - 故事进入高潮,暗影力量的威胁更加严峻,星辰和艾莎必须团结起来对抗它。 - -第19章:最后的对决 - 星辰和艾莎面临最终的对决,他们的能力和信任将决定世界的命运。 - -第20章:暗影之光 - 故事情节以开放式结束,暗示暗影力量的未来影响,星辰和艾莎是否能找到自己的道路? \ No newline at end of file diff --git a/Src/Novel_setting.txt b/Src/Novel_setting.txt deleted file mode 100644 index 340c5cc..0000000 --- a/Src/Novel_setting.txt +++ /dev/null @@ -1,98 +0,0 @@ - -小说设定:《星河挽歌:提瓦特星轨》 - -小说类型:玄幻穿越,史诗级冒险,轻松幽默与细腻情感并存,整体偏正剧,但避免过于沉重。 - -字数:约400万字(100章 x 4000字/章) - -视角:第三人称,穿插主角第一人称内心独白。 - -世界观:基于原神世界线平行宇宙的提瓦特大陆,地理环境与原神基本一致,但部分区域势力分布及历史事件有所调整,以适应主角的到来和剧情发展。七国结构保留,但政治格局和社会风貌因主角介入而变。科技与魔法并存,魔法更为显著,主角的星穹铁道科技作为“外来力量”融入其中,引发冲突和变革。重要历史事件保留原神设定,并添加主角引发的新的历史事件。 - -核心内容梗概:主角星河在星穹铁道执行任务时意外穿越到提瓦特大陆,发现大陆面临史无前例的巨大危机。凭借星穹铁道科技力量及经验,星河成为对抗危机的关键人物。在拯救提瓦特过程中,他将与原神角色相遇,建立深厚情谊或产生冲突,最终完成使命,并决定自己的归宿。采用英雄之旅叙事结构,主线为拯救提瓦特,副线为主角与原神角色的情感纠葛。 - -主要角色: - -1. 星河:坚毅、责任感强、略带孤傲,内心温柔渴望归宿。拥有强大的星穹铁道科技力量,但更倾向于智慧和策略解决问题。拥有丰富战斗经验和冷静判断力,但也有过去任务的阴影和对未来命运的迷茫,偶尔冲动,也可能因自身能力产生优越感。他的目标是拯救提瓦特,找到归宿,弥补过去遗憾。他的隐藏秘密是:在星穹铁道的一次任务中,他导致一位重要伙伴牺牲,这个秘密影响着他与他人的关系,并在与原神角色建立深厚感情时成为关键转折点。 - -2. 派蒙:活泼、贪吃、略显成熟,更能理解星河内心世界,给予更深层次的支持。积极参与冒险,但鲁莽会带来麻烦,是重要的喜剧元素。对提瓦特知识渊博,但对星穹铁道科技一无所知。她的目标是帮助星河拯救提瓦特,探索自身存在秘密,与星河建立更深厚羁绊。她的隐藏秘密是:她并非简单的导航精灵,拥有远超常人的智慧和感知能力,与提瓦特大陆的过去有着神秘联系。这会在中段通过关键事件逐步揭示。 她的“记忆碎片”——偶尔失神,喃喃自语,对特定场景反应强烈,甚至出现幻觉,这些会逐渐清晰,暗示她与提瓦特古老文明甚至超越提瓦特的力量有关。 - -3. 丽莎:慵懒、智慧,强大的魔法能力,比原神设定中更成熟、更富有责任感。对星穹铁道科技好奇又警惕,将星河视为竞争对手和值得培养的对象。她会引导主角,但在关键时刻设置考验。她的目标是保护蒙德,培养星河,并寻求突破自身瓶颈。她的隐藏秘密是:她拥有不为人知的秘密身份,与某个神秘组织有关,该组织与即将到来的危机密切相关。她的“禁忌魔法”——偶尔使用威力远超平时能力的魔法,使用后疲惫甚至身体异样,她会刻意隐藏,这与她的神秘组织联系紧密。 - -4. 原神旅行者:与星河互动,产生冲突与合作。两人对提瓦特大陆的理解不同,可能会产生矛盾,但在对抗危机时携手合作。 - -5. 钟离:神秘幕后人物,引导星河完成使命,但目的和动机不明,会带来考验和帮助,也可能成为潜在敌人。他的“真实目的”——对星河的帮助看似无私,但始终保持距离,言行举止充满深意,他的行动与提瓦特危机有关,目的可能并非简单帮助主角,而是为了实现某种更宏大的计划。 - -角色关系与冲突:星河与派蒙(依赖与独立的矛盾),星河与丽莎(竞争与合作),星河与原神旅行者(身份认同冲突),星河与钟离(考验与引导)。 - -暗线与伏笔:派蒙的记忆碎片,丽莎的禁忌魔法,星河的星穹铁道追捕令,钟离的真实目的,提瓦特大陆的失落科技。这些暗线相互交织,推动剧情发展,并为角色成长和故事走向增添可能性和悬念。 - -结局:开放式结局,主角战胜危机,但提瓦特仍面临挑战,主角可以选择留在提瓦特或回到星穹铁道;也可能根据剧情走向相对圆满结局。 - - -世界观与角色动机结合:提瓦特大陆面临的危机是推动星河行动的主要动力,而他过去的经历(星穹铁道任务的阴影和伙伴的牺牲)以及对归宿的渴望,则构成了他内心的冲突和驱动力。派蒙的秘密与提瓦特古老文明的关联,丽莎的禁忌魔法与神秘组织的联系,这些都与即将到来的危机息息相关,也与主角的命运紧密相连。 钟离的真实目的则成为贯穿始终的神秘力量,推动剧情发展,并最终影响故事的结局。 星河的星穹铁道科技,则成为改变提瓦特魔法世界格局的关键因素,引发一系列冲突和变革。 - 世界观设定:暗影之歌 - - 引言 -在一个名为提瓦特的大陆上,力量如同黑暗中的呼吸,无声无息地渗透着一切。这片大陆的历史被称为“暗影年代”,那时期充满了权力斗争和悲剧命运。如今,随着“暗影力量”的再次苏醒,新的阴谋与挑战正逐渐显露。 - - 主要角色 -1. 星辰(亦名:斯琴) - - 背景:一位被称为“暗影之心”的天才少女,拥有操纵暗影的特殊能力。她的力量是暗影之力最强者之一,但同时也让她背负着沉重的责任。 - -2. 艾莎(亦名:艾丽莎) - - 背景:一位冷酷无情的刺客,曾为“暗影力量”效力。她的身世与“暗影力量”密不可分,性格多变,隐藏着巨大的秘密。 - -3. 凯文(亦名:克莱文) - - 背景:自封为提瓦特大陆的守护者,实则心中藏着大不明小谋。他表面上支持星辰和艾莎,但背后暗藏自己的秘密计划。 - -4. 暗影力量 - - 背景:一股强大的力量源于远古,能够操控暗影与黑暗。它的真实目的尚未完全被揭示,既有守护大陆的传说,也有毁灭一切的预言。 - - 世界观亮点 - -1. 力量的双面性 - - 暗影力量是世界的核心,但同时也是最大的危险。掌握它的人往往会被力量所控制,经历深刻的心理变化。 - -2. 身份的迷失 - - 星辰和艾莎都背负着来自“暗影力量”的阴影,他们的身份与命运与力量紧密相连,这种联系既是他们的优势,也是他们的诅咒。 - -3. 权力真空 - - 提瓦特大陆在“暗影年代”后期逐渐走向新的权力真空,各种势力争夺资源和权利,形成了复杂多变的政治格局。 - -4. 宿命的重遇 - - 星辰和艾莎被暗影力量联系起来,这种宿命使他们在面对危机时不得不合作,但同时也让彼此之间充满了矛盾与误解。 - -5. 未知的威胁 - - “暗影力量”的真实目标尚未完全明确,可能是为了大陆的繁荣,也可能是为了彻底摧毁一切。这种不确定性为故事增添了紧张感和悬念。 - - 故事情节展开 - -1. 起始点 - - 星辰被选中成为暗影力量的继承者,但她对自己的能力感到不安,同时也怀疑“暗影力量”是否真的是保护大陆的存在。 - -2. 初识艾莎 - - 艾莎以刺客的身份接近星辰,试图利用她的暗影能力。但两人在合作中逐渐发现彼此之间的共同点,以及隐藏的冲突。 - -3. 凯文的动机 - - 凯文表面上是支持星辰和艾莎,但实际上他有自己的秘密。他可能是“暗影力量”的后代,或者背后另有hidden agenda。 - -4. 危机初现 - - 随着暗影力量的觉醒,大陆出现了一系列诡异的事件。暗影力量开始失控,普通大民被笼罩在阴影中,生存成为一件艰难的事。 - -5. 真相浮现 - - 通过对凯文、艾莎和星辰的深入探索,逐渐揭示他们各自的过去与命运联系。最终,他们会发现自己之间存在着更大的联系,而这可能关系到整个暗影力量的未来。 - -6. 高潮对决 - - 在一次关键时刻,星辰和艾莎必须共同面临一个强大的敌人。这场对决不仅是力量的较量,更是信任与背叛的考验。 - -7. 结局的模糊性 - - 故事情节留下了开放的结局,让读者思考暗影力量最终会带来什么样的未来。星辰和艾莎是否能找到自己的道路,或者他们将被暗影力量彻底吞噬。 - - 主题 -- 力量与责任:暗影力量赋予了一些人巨大的能力,但同时也带来了沉重的责任。 -- 身份与命运:每个人的过去决定了他们的未来,他们是否能超越自己的宿命? -- 信任与背叛:在危难时刻,真实的朋友和潜在的敌人是如何区分的? - - 总结 -“暗影之歌”是一个充满矛盾与复杂性的故事,它探讨了力量、身份、信任等多个深刻主题。通过星辰和艾莎两位主角的视角,读者可以感受到在黑暗与光明之间挣扎的人性,以及他们如何面对命运给予的挑战。 \ No newline at end of file diff --git a/Src/outlines/outline_1.txt b/Src/outlines/outline_1.txt deleted file mode 100644 index 1cd6cc6..0000000 --- a/Src/outlines/outline_1.txt +++ /dev/null @@ -1,43 +0,0 @@ - -好,我现在需要帮用户构思小说的第1章大纲,标题是“星河坠落提瓦特”,简述是“穿越与初遇派蒙,危机初现端倪”。首先,我得理解这个标题和简述的意思。看起来这是一部含有玄幻或奇幻元素的小说,可能涉及时间旅行、门派纷争或者某种隐藏的危机。 - -用户给出的内容里提到要围绕本章标题和简述设计详细大纲,并且用户已经提供了一个示例的结构。所以我需要按照那个格式来写,而不是用数字分点。也就是说,先描述每个部分,然后是主要冲突、出现的角色,他们的动机,暗线推进,以及结尾的悬念。 - -首先,本章标题“星河坠落提瓦特”,听起来像是主角从一个繁荣的地方坠落到另一个世界。简述说的是穿越和初遇派蒙,同时危机开始显现。这可能意味着主角从一个平行世界穿越到这个世界,并在门派之间初次相遇,引发了一些危机。 - -接下来,我需要构思主要冲突。可能是主角到达提瓦特城,发现这里有一个古老的力量或秘密,门派之间开始竞争,而主角作为穿越者,带来了改变,同时也面临着被追杀的风险。这样既呼应了标题,又体现了简述中的危机初现。 - -然后是角色部分。可能需要包括主角、对方的门派成员,比如派蒙,以及提瓦特城里的其他关键人物。每个角色在这章里有什么目标和动机,比如追杀主角的人,或者试图掌控力量的人。 - -接下来是推动暗线和悬念的部分。比如,提瓦特城的秘密可能涉及到星河的力量,而派蒙门派对这个力量的争夺可能引发更大的冲突。同时,主角可能带有未知的任务或使命,这会影响后续章节。 - -最后是结尾的悬念,可能是一个转折点,比如主角揭示了某个秘密,或者派蒙开始怀疑自己,而星河的力量在台面上或下面开始显现,为后续剧情埋下伏笔。 - -整体来看,我需要确保大纲紧扣标题和简述,同时为后续章节留有余地。可能还要注意角色的性格发展和潜在冲突,以保持故事的吸引力。 - - -### 第 1 章:星河坠落提瓦特 - -#### 主要冲突: -- **星河降临**:穿越者从一个繁荣世纪跨越到提瓦特大陆,带来了“星河”般的强大力量,引发了提瓦特城内外的关注与恐慌。 -- **门派初遇**:派蒙门派首领派长派出追杀队伍,试图抓住这股未知力量,以掌控“星河之力”。 -- **危机初现**:一场隐藏已久的秘密势力开始苏醒,在提瓦特城内外的阴谋逐渐浮现,为后续的灾难埋下伏笔。 - -#### 角色出现: -1. **主角(未命名)**:穿越者,带着星河般的强大力量,性格冷静果敢,却对自己的来历一无所知。 -2. **派长**:派蒙门派领袖,精通古老法术,眼光独到,对“星河之力”嗜求不已。 -3. **追杀队伍**:派长派出的十名绝技高手,打着门派的旗号,在提瓦特城内外肆虐。 -4. **提瓦特市民**:生活在平静世代的普通百姓,见证并参与这场突如其来的震撼。 -5. **神秘老者**:提瓦特城中的隐世高人,对“星河之力”知之甚详,暗中观察与等待。 - -#### 推动暗线: -- **星河力量的真相**:主角身上蕴含的力量竟是千年前就被预言的“星河之力”,而提瓦特城内外的秘密势力对此虎视眈眈。 -- **派蒙门派的野心**:派长不仅想要获取“星河之力”,还暗藏着更大的图谋,试图借此重塑整个世界。 -- **神秘老者的警告**:一位隐世高人试图接触主角,给出“星河之力”与世界危机的隐喻,为后续剧情埋下重大伏笔。 - -#### 结尾悬念: -- **力量暴露**:在一次追杀中,主角无意间释放出“星河之力”,震撼整个提瓦特城,引发更大的关注与恐慌。 -- **派长的挣扎**:派长对自己的能力和未来感到迷茫,开始怀疑自己是否已经被力量的猎头所误导。 -- **神秘老者的突然消失**:一位神秘老者在关键时刻悄然离去,为后续追杀埋下更多谜团。 - -通过这场开篇的震撼,未命名的主角将从一个普通穿越者逐渐成长为“星河”的继承者,而提瓦特城与整个世界也将迎来一场前所未有的巨变。 \ No newline at end of file diff --git a/embedding_ollama.py b/embedding_ollama.py index 8dd6dea..5538c87 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -11,7 +11,7 @@ class OllamaEmbeddings: def __init__(self, model_name: str, base_url: str): self.model_name = model_name - self.base_url = base_url # 这里一般形如 http://localhost:11434/api (而非 /v1) + self.base_url = base_url def embed(self, texts: List[str]) -> List[List[float]]: """ diff --git a/novel_generator.py b/novel_generator.py index 0b01ac6..407779a 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -5,11 +5,13 @@ import logging import re from typing import Dict, List, Optional from typing import TypedDict + from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, START, END from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain.docstore.document import Document + import nltk import math from sentence_transformers import SentenceTransformer @@ -32,9 +34,30 @@ from chapter_directory_parser import get_chapter_info_from_directory logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") def debug_log(prompt: str, response_content: str): + """ + 打印prompt和response的辅助函数 + """ logging.info(f"\n[Prompt >>>] {prompt}\n") logging.info(f"[Response >>>] {response_content}\n") +def remove_think_tags(text: str) -> str: + """ + 移除 ... 包裹的内容 + """ + return re.sub(r'.*?', '', text, flags=re.DOTALL) + +def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: + """ + 通用封装:调用模型并移除 ... 文本,记录日志后返回 + """ + response = model.invoke(prompt) + if not response: + logging.warning("No response from model.") + return "" + cleaned_text = remove_think_tags(response.content) + debug_log(prompt, cleaned_text) + return cleaned_text.strip() + # ============ 判断接口格式相关 ============ def is_using_ollama_api(interface_format: str, base_url: str) -> bool: @@ -272,34 +295,22 @@ def Novel_novel_directory_generate( number_of_chapters=state["number_of_chapters"], word_number=state["word_number"] ) - response = model.invoke(prompt) - if not response: - logging.warning("generate_base_setting: No response.") - return {"novel_setting_base": ""} - debug_log(prompt, response.content) - return {"novel_setting_base": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"novel_setting_base": result_text} def generate_character_setting(state: OverallState) -> Dict[str, str]: prompt = character_prompt.format( novel_setting=state["novel_setting_base"] ) - response = model.invoke(prompt) - if not response: - logging.warning("generate_character_setting: No response.") - return {"character_setting": ""} - debug_log(prompt, response.content) - return {"character_setting": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"character_setting": result_text} def generate_dark_lines(state: OverallState) -> Dict[str, str]: prompt = dark_lines_prompt.format( character_info=state["character_setting"] ) - response = model.invoke(prompt) - if not response: - logging.warning("generate_dark_lines: No response.") - return {"dark_lines": ""} - debug_log(prompt, response.content) - return {"dark_lines": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"dark_lines": result_text} def finalize_novel_setting_func(state: OverallState) -> Dict[str, str]: prompt = finalize_setting_prompt.format( @@ -307,24 +318,16 @@ def Novel_novel_directory_generate( character_setting=state["character_setting"], dark_lines=state["dark_lines"] ) - response = model.invoke(prompt) - if not response: - logging.warning("finalize_novel_setting: No response.") - return {"final_novel_setting": ""} - debug_log(prompt, response.content) - return {"final_novel_setting": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"final_novel_setting": result_text} def generate_novel_directory_func(state: OverallState) -> Dict[str, str]: prompt = novel_directory_prompt.format( final_novel_setting=state["final_novel_setting"], number_of_chapters=state["number_of_chapters"] ) - response = model.invoke(prompt) - if not response: - logging.warning("generate_novel_directory: No response.") - return {"novel_directory": ""} - debug_log(prompt, response.content) - return {"novel_directory": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"novel_directory": result_text} graph = StateGraph(OverallState) graph.add_node("generate_base_setting", generate_base_setting) @@ -410,7 +413,6 @@ def summarize_recent_chapters( """ if not chapters_text_list: return "" - # 如果列表里全是空,则无法生成摘要 if all(not txt.strip() for txt in chapters_text_list): return "暂无摘要。" @@ -427,11 +429,11 @@ def summarize_recent_chapters( 请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" - response = model.invoke(prompt) - if not response or not response.content.strip(): + summary_text = invoke_with_cleaning(model, prompt) + if not summary_text: # 若模型无响应,就截取一段作为“备选” return combined_text[:800] + "..." if len(combined_text) > 800 else combined_text - return response.content.strip() + return summary_text # ============ 新增:剧情要点/未解决冲突 ============ @@ -465,11 +467,11 @@ def update_plot_arcs( chapter_text=chapter_text, old_plot_arcs=old_plot_arcs ) - response = model.invoke(prompt) - if not response: - logging.warning("update_plot_arcs: No response.") + arcs_text = invoke_with_cleaning(model, prompt) + if not arcs_text: + logging.warning("update_plot_arcs: No response or empty result.") return old_plot_arcs - return response.content.strip() + return arcs_text # ============ 生成章节草稿 & 定稿 ============ @@ -542,8 +544,7 @@ def generate_chapter_draft( outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - response_outline = model.invoke(outline_prompt_text) - chapter_outline = response_outline.content.strip() if response_outline else "" + chapter_outline = invoke_with_cleaning(model, outline_prompt_text) outlines_dir = os.path.join(filepath, "outlines") os.makedirs(outlines_dir, exist_ok=True) @@ -564,8 +565,7 @@ def generate_chapter_draft( writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - response_chapter = model.invoke(writing_prompt_text) - chapter_content = response_chapter.content.strip() if response_chapter else "" + chapter_content = invoke_with_cleaning(model, writing_prompt_text) chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) @@ -637,8 +637,7 @@ def finalize_chapter( chapter_text=chapter_text, global_summary=old_summary ) - response = model.invoke(prompt) - return response.content.strip() if response else old_summary + return invoke_with_cleaning(model, prompt) or old_summary new_global_summary = update_global_summary(chapter_text, old_global_summary) @@ -648,8 +647,7 @@ def finalize_chapter( chapter_text=chapter_text, old_state=old_state ) - response = model.invoke(prompt) - return response.content.strip() if response else old_state + return invoke_with_cleaning(model, prompt) or old_state new_char_state = update_character_state(chapter_text, old_char_state) @@ -705,11 +703,8 @@ def enrich_chapter_text( 原章节内容: {chapter_text}""" - - response = model.invoke(prompt) - if not response: - return chapter_text - return response.content.strip() + enriched_text = invoke_with_cleaning(model, prompt) + return enriched_text if enriched_text else chapter_text # ============ 导入外部知识文本 ============ @@ -734,7 +729,8 @@ def import_knowledge_file( logging.warning("知识库文件内容为空。") return - nltk.download('punkt_tab', quiet=True) + # 改为下载 punkt_tab + nltk.download('punkt', quiet=True) paragraphs = advanced_split_content(content) diff --git a/ui.py b/ui.py index 43fe10c..bbf7767 100644 --- a/ui.py +++ b/ui.py @@ -33,7 +33,7 @@ ctk.set_default_color_theme("blue") class NovelGeneratorGUI: def __init__(self, master): self.master = master - self.master.title("Novel Generator GUI (CustomTkinter)") + self.master.title("Novel Generator GUI") # 防止因 icon.ico 不存在导致程序崩溃 try: From bc6e2ff869a79c8645519e6d6aa1445f6511c1c2 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 19:22:54 +0800 Subject: [PATCH 031/142] =?UTF-8?q?UI=E6=9B=B4=E6=96=B0=E3=80=81=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=9C=AC=E5=9C=B0=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_ollama.py | 3 ++- novel_generator.py | 21 +++++---------------- ui.py | 31 ++++++++++++++++--------------- 3 files changed, 23 insertions(+), 32 deletions(-) diff --git a/embedding_ollama.py b/embedding_ollama.py index 5538c87..c7979be 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -1,5 +1,6 @@ # embedding_ollama.py import requests +import traceback from typing import List class OllamaEmbeddings: @@ -51,4 +52,4 @@ class OllamaEmbeddings: raise ValueError("No 'embedding' field in Ollama response.") return result["embedding"] except requests.exceptions.RequestException as e: - raise Exception(f"Ollama embeddings request error: {e}") + raise Exception(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}") diff --git a/novel_generator.py b/novel_generator.py index 407779a..44b96d3 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -3,6 +3,7 @@ import os import logging import re +import traceback from typing import Dict, List, Optional from typing import TypedDict @@ -59,25 +60,19 @@ def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: return cleaned_text.strip() # ============ 判断接口格式相关 ============ - def is_using_ollama_api(interface_format: str, base_url: str) -> bool: """ 当 interface_format == "Ollama" 时返回 True """ - if interface_format.lower() == "ollama": - return True - return False + return interface_format.lower() == "ollama" def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: """ 如果用户在下拉里选择了 ML Studio """ - if interface_format.lower() == "ml studio": - return True - return False + return interface_format.lower() == "ml studio" # ============ 创建 Embeddings 对象 ============ - def create_embeddings_object( api_key: str, base_url: str, @@ -106,7 +101,6 @@ def create_embeddings_object( return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) # ============ 向量库相关 ============ - VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") if not os.path.exists(VECTOR_STORE_DIR): os.makedirs(VECTOR_STORE_DIR) @@ -125,8 +119,8 @@ def clear_vector_store(): elif os.path.isdir(file_path): shutil.rmtree(file_path) logging.info("Local vector store has been cleared.") - except Exception as e: - logging.warning(f"Failed to clear vector store: {e}") + except Exception: + logging.warning(f"Failed to clear vector store:\n{traceback.format_exc()}") else: logging.info("No vector store found to clear.") @@ -253,7 +247,6 @@ def get_relevant_context_from_vector_store( return combined # ============ 多步生成:设置 & 目录 ============ - class OverallState(TypedDict): topic: str genre: str @@ -383,7 +376,6 @@ def Novel_novel_directory_generate( logging.info("Novel settings and directory generated successfully.") # ============ 获取最近N章内容,生成短期摘要 ============ - def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: """ 从指定文件夹中,读取最近 n 章的内容(如果存在),并按从旧到新的顺序返回文本列表。 @@ -436,7 +428,6 @@ def summarize_recent_chapters( return summary_text # ============ 新增:剧情要点/未解决冲突 ============ - PLOT_ARCS_PROMPT = """\ 下面是新生成的章节内容: {chapter_text} @@ -474,7 +465,6 @@ def update_plot_arcs( return arcs_text # ============ 生成章节草稿 & 定稿 ============ - def generate_chapter_draft( novel_settings: str, global_summary: str, @@ -707,7 +697,6 @@ def enrich_chapter_text( return enriched_text if enriched_text else chapter_text # ============ 导入外部知识文本 ============ - def import_knowledge_file( api_key: str, base_url: str, diff --git a/ui.py b/ui.py index bbf7767..cbd371f 100644 --- a/ui.py +++ b/ui.py @@ -95,6 +95,12 @@ class NovelGeneratorGUI: # 全局摘要 Tab self.build_summary_tab() + # ------------------ 统一异常处理方法 ------------------ + 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): self.main_tab.rowconfigure(0, weight=1) @@ -617,9 +623,8 @@ class NovelGeneratorGUI: temperature=temperature ) self.safe_log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") - except Exception as e: - log_error(f"生成小说设定 & 目录时出错: {e}") - self.safe_log(f"❌ 生成小说设定 & 目录时出错: {e}") + except Exception: + self.handle_exception("生成小说设定 & 目录时出错") finally: self.enable_button_safe(self.btn_generate_full) @@ -695,9 +700,8 @@ class NovelGeneratorGUI: else: self.safe_log("⚠️ 本章草稿生成失败或无内容。") - except Exception as e: - log_error(f"生成章节草稿时出错: {e}") - self.safe_log(f"❌ 生成章节草稿时出错: {e}") + except Exception: + self.handle_exception("生成章节草稿时出错") finally: self.enable_button_safe(self.btn_generate_chapter) @@ -747,9 +751,8 @@ class NovelGeneratorGUI: final_text = read_file(chap_file) self.master.after(0, lambda: self.show_chapter_in_textbox(final_text)) - except Exception as e: - log_error(f"定稿章节时出错: {e}") - self.safe_log(f"❌ 定稿章节时出错: {e}") + except Exception: + self.handle_exception("定稿章节时出错") finally: self.enable_button_safe(self.btn_finalize_chapter) @@ -803,9 +806,8 @@ class NovelGeneratorGUI: self.safe_log("审校结果:") self.safe_log(result) - except Exception as e: - log_error(f"审校时出错: {e}") - self.safe_log(f"❌ 审校时出错: {e}") + except Exception: + self.handle_exception("审校时出错") finally: self.enable_button_safe(self.btn_check_consistency) @@ -831,9 +833,8 @@ class NovelGeneratorGUI: embedding_base_url=self.embedding_url_var.get().strip() ) self.safe_log("✅ 知识库文件导入完成。") - except Exception as e: - log_error(f"导入知识库时出错: {e}") - self.safe_log(f"❌ 导入知识库时出错: {e}") + except Exception: + self.handle_exception("导入知识库时出错") finally: self.enable_button_safe(self.btn_import_knowledge) From fd3dc33b9d2b3ab7dce6965354f03402d86bdbb2 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 19:28:21 +0800 Subject: [PATCH 032/142] 223 --- novel_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/novel_generator.py b/novel_generator.py index 44b96d3..3c4722d 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -718,8 +718,8 @@ def import_knowledge_file( logging.warning("知识库文件内容为空。") return - # 改为下载 punkt_tab nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) paragraphs = advanced_split_content(content) From 1849eda8149dd64d14031f75f0d6246d772e1147 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 19:38:45 +0800 Subject: [PATCH 033/142] =?UTF-8?q?fixed=20#11=20=EF=BC=8C#16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_ollama.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/embedding_ollama.py b/embedding_ollama.py index c7979be..0f8bba3 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -6,8 +6,9 @@ from typing import List class OllamaEmbeddings: """ Ollama 本地服务提供的 Embedding 接口, - 最终拼出形如: http://localhost:11434/api/embed - 即 base_url + "/embed" + 最终拼出形如: http://localhost:11434/api/embeddings + 即 base_url + "/embeddings" + 但是按文档,好像/embeddings接口已经被废弃了,现在是/embed才对,实际测试都可以用,视情况而定 """ def __init__(self, model_name: str, base_url: str): @@ -39,7 +40,7 @@ class OllamaEmbeddings: """ 调用 Ollama 本地服务接口,获取文本的 embedding。 """ - url = f"{self.base_url}/embed" + url = f"{self.base_url}/embeddings" data = { "model": self.model_name, "prompt": text From 73a9b79686182ae2972eb9c112cd4d0d84c1d62f Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 2 Feb 2025 21:17:24 +0800 Subject: [PATCH 034/142] Update requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index d492909..f75acc9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ typing_extensions langchain-openai langgraph +customtkinter openai chromadb langchain-community From d1f739c05163e53453c9f5ebf4d301e1764a9e41 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 3 Feb 2025 11:14:34 +0800 Subject: [PATCH 035/142] issue template --- .github/CONTRIBUTING.md | 65 +++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/code_issue.yml | 60 +++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/opinion.yml | 48 ++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/code_issue.yml create mode 100644 .github/ISSUE_TEMPLATE/opinion.yml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..efd594d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to This Project + +首先,感谢你愿意为本项目贡献力量!在提交任何形式的反馈或 Pull Request 之前,请先阅读以下内容。 + +--- + +## 一、反馈类型说明 + +1. **代码问题(Code Issue)** + - 仅限与项目代码本身相关的问题:如编译失败、运行报错、逻辑缺陷等。 + - 反馈之前,请确认该问题与你的环境或配置无关。 + - 如果确认是代码本身导致的错误,请使用 [代码问题反馈模板](?template=code_issue.yml)。 + +2. **意见或建议(Opinion / Enhancement)** + - 如果你有关于功能新增、代码重构、性能优化或其他方面的意见或建议,请使用 [意见/建议模板](?template=opinion.yml)。 + - 我们会积极审阅并讨论可行性,但可能不会立刻实现,视项目计划而定。 + +3. **接口/配置/部署等问题** + - 本项目不针对接口配置、环境部署或第三方服务的参数设置等问题提供支持。 + - 遇到此类问题,请阅读官方文档、社区讨论区或自行搜索相关信息。 + +--- + +## 二、在提交 Issues 之前 + +1. **搜索现有的 Issues** + - 避免重复提交相同问题。 + - 如果发现类似问题可以补充你的信息或在对应 Issue 下评论。 + +2. **提供尽可能详细的信息** + - 提交问题时,尽量提供可复现的步骤、日志信息、环境说明等。 + - 提交意见或建议时,需要清楚说明理由和期望。 + +3. **保持尊重与礼貌** + - 请尊重项目维护者和其他贡献者。 + - 交流中请使用恰当、礼貌的语言。 + +--- + +## 三、Pull Request 提交指南 + +1. **先 Fork 再修改** + - 在你自己的 Fork 中进行修改和测试。 + - 确保修改内容不会引入新的 Bug。 + +2. **遵守代码风格** + - 保持原有代码风格,遵循项目的 Lint 规则(如有)。 + - 减少不必要的格式改动,保证可读性。 + +3. **更新文档或注释** + - 如果你的修改影响到了文档或注释,请及时补充或更新。 + +4. **描述清楚修改内容** + - Pull Request 标题与描述中需包含本次修改的目的、解决的问题以及修改的主要内容。 + +--- + +## 四、其他说明 + +- 我们对所有 Issue 和 Pull Request 均会尽量及时处理,但无法保证立即回复。 +- 对于不符合上述规则的 Issue 或 Pull Request,我们保留关闭或忽略的权利。 + +如果你对上述要求有任何疑问,欢迎在意见区进行讨论。再次感谢你的贡献! + +--- diff --git a/.github/ISSUE_TEMPLATE/code_issue.yml b/.github/ISSUE_TEMPLATE/code_issue.yml new file mode 100644 index 0000000..b3d4793 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/code_issue.yml @@ -0,0 +1,60 @@ +name: "代码问题反馈" +description: "此模板仅用于反馈代码相关问题,例如出现编译错误、运行报错、逻辑缺陷。" +title: "[Code Issue]: " +labels: ["bug", "code issue"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + **⚠ 注意:此处仅受理代码本身的问题,包括但不限于编译错误、运行报错、逻辑异常等。** + **如果是接口配置或环境部署等问题,请自行阅读文档或在讨论区寻求帮助。** + **如果是意见或建议,请使用 [意见模板](?template=opinion.yml)。** + 感谢你的配合! + + - type: textarea + id: description + attributes: + label: "问题描述" + description: "请清晰、简要地描述代码出现的问题。" + placeholder: "例如:运行时报错xxx,或逻辑存在xxx。" + validations: + required: true + + - type: textarea + id: steps + attributes: + label: "复现步骤" + description: "请提供完整的复现步骤,以便我们定位和解决问题。" + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: input + id: environment + attributes: + label: "环境信息" + description: "如编译器、操作系统、依赖版本等。" + placeholder: "示例:Windows 10, Node.js v14, Python 3.9, etc." + + - type: textarea + id: logs + attributes: + label: "日志信息(如适用)" + description: "如果有报错日志或截图,可以贴在此处。" + placeholder: "请粘贴日志内容或相关截图链接(可选)" + validations: + required: false + + - type: textarea + id: additional + attributes: + label: "补充信息" + description: "如果有更多信息,可在此补充。" + placeholder: "任何与问题相关的额外背景说明..." + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/opinion.yml b/.github/ISSUE_TEMPLATE/opinion.yml new file mode 100644 index 0000000..90504f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/opinion.yml @@ -0,0 +1,48 @@ +name: "意见或建议" +description: "如果你有对项目的需求、功能建议、或其他意见,请使用此模板。" +title: "[Opinion]: " +labels: ["enhancement", "discussion"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + **⚠ 注意:此处不用于反馈代码报错或编译问题,如果是纯代码报错或逻辑问题,请使用 [代码问题反馈模板](?template=code_issue.yml)。** + 感谢你的宝贵意见或建议,我们会酌情采纳! + + - type: textarea + id: suggestion + attributes: + label: "意见/建议内容" + description: "请简要描述你的想法或建议。" + placeholder: "例如:希望新增xx功能,或者修改xx逻辑。" + validations: + required: true + + - type: textarea + id: reason + attributes: + label: "为什么需要这个功能或修改?" + description: "简单说明你提出此意见/建议的原因或背景需求。" + placeholder: "例如:在实际项目中遇到xx需求场景;希望提升xx效率;等等。" + validations: + required: true + + - type: input + id: relevance + attributes: + label: "相关链接或参考" + description: "如果你有看到类似实现或参考资料,可在此提供链接。" + placeholder: "例如:相关文档链接、RFC、规范文档等" + validations: + required: false + + - type: textarea + id: additional + attributes: + label: "补充信息" + description: "如果有更多信息,可在此补充。" + placeholder: "任何与意见或建议相关的额外说明..." + validations: + required: false From fd952ead9791b8a275e3c5c8f905517dcd92bd42 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 3 Feb 2025 11:20:17 +0800 Subject: [PATCH 036/142] move CONTRIBUTING.md --- .github/CONTRIBUTING.md => CONTRIBUTING.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/CONTRIBUTING.md => CONTRIBUTING.md (100%) diff --git a/.github/CONTRIBUTING.md b/CONTRIBUTING.md similarity index 100% rename from .github/CONTRIBUTING.md rename to CONTRIBUTING.md From 3f7b434cbadb4966dde7db21b8d4d382d7ef098e Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 3 Feb 2025 13:00:03 +0800 Subject: [PATCH 037/142] fix issue #24 #23 #7 #19 #18 #17 #16 #12 --- CONTRIBUTING.md => .github/CONTRIBUTING.md | 0 novel_generator.py | 325 ++++--- ui.py | 999 +++++++++++++++------ 3 files changed, 889 insertions(+), 435 deletions(-) rename CONTRIBUTING.md => .github/CONTRIBUTING.md (100%) diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/novel_generator.py b/novel_generator.py index 3c4722d..0fcadfd 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -4,43 +4,49 @@ import os import logging import re import traceback -from typing import Dict, List, Optional -from typing import TypedDict +from typing import List, Optional +# langchain 相关 from langchain_openai import ChatOpenAI -from langgraph.graph import StateGraph, START, END from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain.docstore.document import Document +# nltk、sentence_transformers 及文本处理相关 import nltk import math from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity +# 工具函数 from utils import ( read_file, append_text_to_file, clear_file_content, save_string_to_txt ) + +# prompt模板 from prompt_definitions import ( + # 设定相关 set_prompt, character_prompt, dark_lines_prompt, finalize_setting_prompt, novel_directory_prompt, + + # 写作流程相关 summary_prompt, update_character_state_prompt, chapter_outline_prompt, chapter_write_prompt ) + +# Ollama嵌入 (如使用Ollama时需要) from embedding_ollama import OllamaEmbeddings + +# 用于目录解析章节标题/简介 from chapter_directory_parser import get_chapter_info_from_directory + # ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") -def debug_log(prompt: str, response_content: str): - """ - 打印prompt和response的辅助函数 - """ - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") +# ============ 通用调用函数 ============ def remove_think_tags(text: str) -> str: """ 移除 ... 包裹的内容 @@ -59,6 +65,14 @@ def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: debug_log(prompt, cleaned_text) return cleaned_text.strip() +def debug_log(prompt: str, response_content: str): + """ + 打印prompt和response的辅助函数 + """ + logging.info(f"\n[Prompt >>>] {prompt}\n") + logging.info(f"[Response >>>] {response_content}\n") + + # ============ 判断接口格式相关 ============ def is_using_ollama_api(interface_format: str, base_url: str) -> bool: """ @@ -72,6 +86,25 @@ def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: """ return interface_format.lower() == "ml studio" + +# ============ 帮助函数:自动检查 & 补充 /v1 ============ +import re + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 如果用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + 如果已经包含 '/v1',则不再重复追加。 + """ + url = url.strip() + if not url: + return url + # 若末尾没有 /v\d+,但也没出现 /v1,才补上 + if not re.search(r'/v\d+$', url): + if '/v1' not in url: + url = url.rstrip('/') + '/v1' + return url + + # ============ 创建 Embeddings 对象 ============ def create_embeddings_object( api_key: str, @@ -84,21 +117,25 @@ def create_embeddings_object( 根据用户在UI中配置的参数,返回对应的 embeddings 对象。 - 当 interface_format = "Ollama" => OllamaEmbeddings(...) - 当 interface_format = "OpenAI"/"ML Studio" => OpenAIEmbeddings(...) - - 其它情况可扩展 + 这里统一把 base_url/embed_url 处理为含 /v1。 """ if is_using_ollama_api(interface_format, embed_url): fixed_url = embed_url.rstrip("/") - # Ollama embedding接口通常是 /api/embed - fixed_url = fixed_url.replace("/v1", "/api") return OllamaEmbeddings( model_name=embedding_model_name, base_url=fixed_url ) - elif is_using_ml_studio_api(interface_format, base_url): - return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) else: - # 默认使用 OpenAIEmbeddings - return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) + # 对 OpenAI 或 ML Studio 统一用 OpenAIEmbeddings + # 并设置 model=embedding_model_name + # base_url/embed_url 若不含 /v1,需要自动补上 + fixed_url = ensure_openai_base_url_has_v1(embed_url if embed_url else base_url) + return OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=fixed_url, + model=embedding_model_name + ) + # ============ 向量库相关 ============ VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") @@ -107,7 +144,7 @@ if not os.path.exists(VECTOR_STORE_DIR): def clear_vector_store(): """ - 清空本地向量库(删除 vectorstore 文件夹内的内容)。 + 清空本地向量库(删除 vectorstore 文件夹内的所有内容) """ if os.path.exists(VECTOR_STORE_DIR): import shutil @@ -124,12 +161,13 @@ def clear_vector_store(): else: logging.info("No vector store found to clear.") + def init_vector_store( api_key: str, - base_url: str, + base_url: str, interface_format: str, embedding_model_name: str, - texts: List[str], + texts: List[str], embedding_base_url: str = "" ) -> Chroma: """ @@ -143,7 +181,7 @@ def init_vector_store( interface_format=interface_format, embedding_model_name=embedding_model_name ) - documents = [Document(page_content=t) for t in texts] + documents = [Document(page_content=str(t)) for t in texts] # 确保是字符串 vectorstore = Chroma.from_documents( documents, embedding=embeddings, @@ -152,6 +190,7 @@ def init_vector_store( vectorstore.persist() return vectorstore + def load_vector_store( api_key: str, base_url: str, @@ -163,8 +202,9 @@ def load_vector_store( 读取已存在的向量库。若不存在则返回 None。 """ if not os.path.exists(VECTOR_STORE_DIR): - logging.info("Vector store not found. Initializing a new one...") + logging.info("Vector store not found. Will return None.") return None + embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( api_key=api_key, @@ -175,10 +215,11 @@ def load_vector_store( ) return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) + def update_vector_store( - api_key: str, - base_url: str, - new_chapter: str, + api_key: str, + base_url: str, + new_chapter: str, interface_format: str, embedding_model_name: str, embedding_base_url: str = "" @@ -193,8 +234,7 @@ def update_vector_store( embedding_model_name=embedding_model_name, embedding_base_url=embedding_base_url ) - - # 如果向量库不存在,初始化它 + if not store: logging.info("Vector store does not exist. Initializing a new one for new chapter...") init_vector_store( @@ -207,11 +247,12 @@ def update_vector_store( ) return - new_doc = Document(page_content=new_chapter) + new_doc = Document(page_content=str(new_chapter)) store.add_documents([new_doc]) store.persist() logging.info("Vector store updated with the new chapter.") + def get_relevant_context_from_vector_store( api_key: str, base_url: str, @@ -223,7 +264,7 @@ def get_relevant_context_from_vector_store( ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - 若向量库不存在或没有足够的内容,则返回空字符串。 + 若向量库不存在或没有足够内容,则返回空字符串。 """ store = load_vector_store( api_key=api_key, @@ -232,12 +273,10 @@ def get_relevant_context_from_vector_store( embedding_model_name=embedding_model_name, embedding_base_url=embedding_base_url ) - - # 如果向量库为空,直接返回空字符串 if not store: logging.info("No vector store found. Returning empty context.") return "" - + docs = store.similarity_search(query, k=k) if not docs: logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") @@ -246,19 +285,9 @@ def get_relevant_context_from_vector_store( combined = "\n".join([d.page_content for d in docs]) return combined -# ============ 多步生成:设置 & 目录 ============ -class OverallState(TypedDict): - topic: str - genre: str - number_of_chapters: int - word_number: int - novel_setting_base: str - character_setting: str - dark_lines: str - final_novel_setting: str - novel_directory: str -def Novel_novel_directory_generate( +# ============ 1. 独立:生成小说“设定” (Novel_setting.txt) ============ +def Novel_setting_generate( api_key: str, base_url: str, llm_model: str, @@ -270,112 +299,104 @@ def Novel_novel_directory_generate( temperature: float = 0.7 ) -> None: """ - 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 + 分步生成 Novel_setting.txt (含世界观、角色信息、暗线等) + 不包括目录。 """ os.makedirs(filepath, exist_ok=True) model = ChatOpenAI( model=llm_model, api_key=api_key, - base_url=base_url, + base_url=ensure_openai_base_url_has_v1(base_url), # 确保带 /v1 temperature=temperature ) - def generate_base_setting(state: OverallState) -> Dict[str, str]: - prompt = set_prompt.format( - topic=state["topic"], - genre=state["genre"], - number_of_chapters=state["number_of_chapters"], - word_number=state["word_number"] - ) - result_text = invoke_with_cleaning(model, prompt) - return {"novel_setting_base": result_text} + # Step1: 基础设定 + prompt_base = set_prompt.format( + topic=topic, + genre=genre, + number_of_chapters=number_of_chapters, + word_number=word_number + ) + base_setting = invoke_with_cleaning(model, prompt_base) - def generate_character_setting(state: OverallState) -> Dict[str, str]: - prompt = character_prompt.format( - novel_setting=state["novel_setting_base"] - ) - result_text = invoke_with_cleaning(model, prompt) - return {"character_setting": result_text} + # Step2: 角色设定 + prompt_char = character_prompt.format( + novel_setting=base_setting + ) + character_setting = invoke_with_cleaning(model, prompt_char) - def generate_dark_lines(state: OverallState) -> Dict[str, str]: - prompt = dark_lines_prompt.format( - character_info=state["character_setting"] - ) - result_text = invoke_with_cleaning(model, prompt) - return {"dark_lines": result_text} + # Step3: 暗线/雷点 + prompt_dark = dark_lines_prompt.format( + character_info=character_setting + ) + dark_lines = invoke_with_cleaning(model, prompt_dark) - def finalize_novel_setting_func(state: OverallState) -> Dict[str, str]: - prompt = finalize_setting_prompt.format( - novel_setting_base=state["novel_setting_base"], - character_setting=state["character_setting"], - dark_lines=state["dark_lines"] - ) - result_text = invoke_with_cleaning(model, prompt) - return {"final_novel_setting": result_text} - - def generate_novel_directory_func(state: OverallState) -> Dict[str, str]: - prompt = novel_directory_prompt.format( - final_novel_setting=state["final_novel_setting"], - number_of_chapters=state["number_of_chapters"] - ) - result_text = invoke_with_cleaning(model, prompt) - return {"novel_directory": result_text} - - graph = StateGraph(OverallState) - graph.add_node("generate_base_setting", generate_base_setting) - graph.add_node("generate_character_setting", generate_character_setting) - graph.add_node("generate_dark_lines", generate_dark_lines) - graph.add_node("finalize_novel_setting", finalize_novel_setting_func) - graph.add_node("generate_novel_directory", generate_novel_directory_func) - - graph.add_edge(START, "generate_base_setting") - graph.add_edge("generate_base_setting", "generate_character_setting") - graph.add_edge("generate_character_setting", "generate_dark_lines") - graph.add_edge("generate_dark_lines", "finalize_novel_setting") - graph.add_edge("finalize_novel_setting", "generate_novel_directory") - graph.add_edge("generate_novel_directory", END) - - app = graph.compile() - - input_params = { - "topic": topic, - "genre": genre, - "number_of_chapters": number_of_chapters, - "word_number": word_number - } - result = app.invoke(input_params) - - if not result: - logging.warning("Novel_novel_directory_generate: invoke() 结果为空,生成失败。") - return - - final_novel_setting = result.get("final_novel_setting", "") - final_novel_directory = result.get("novel_directory", "") - - if not final_novel_setting or not final_novel_directory: - logging.warning("生成失败:缺少 final_novel_setting 或 novel_directory。") - return + # Step4: 最终整合为“小说设定” + prompt_final = finalize_setting_prompt.format( + novel_setting_base=base_setting, + character_setting=character_setting, + dark_lines=dark_lines + ) + final_novel_setting = invoke_with_cleaning(model, prompt_final) + # 写入 Novel_setting.txt filename_set = os.path.join(filepath, "Novel_setting.txt") - filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") - - def clean_text(txt: str) -> str: - return txt.replace('#', '').replace('*', '') - - final_novel_setting_cleaned = clean_text(final_novel_setting) - final_novel_directory_cleaned = clean_text(final_novel_directory) - - # 改进:写文件时先清空再写入 clear_file_content(filename_set) + + final_novel_setting_cleaned = final_novel_setting.replace('#', '').replace('*', '') save_string_to_txt(final_novel_setting_cleaned, filename_set) - clear_file_content(filename_novel_directory) - save_string_to_txt(final_novel_directory_cleaned, filename_novel_directory) + logging.info("Novel_setting.txt has been generated successfully.") - logging.info("Novel settings and directory generated successfully.") -# ============ 获取最近N章内容,生成短期摘要 ============ +# ============ 2. 独立:基于已有设定,生成小说目录 (Novel_directory.txt) ============ +def Novel_directory_generate( + api_key: str, + base_url: str, + llm_model: str, + number_of_chapters: int, + filepath: str, + temperature: float = 0.7 +) -> None: + """ + 基于先前已经生成并保存的 Novel_setting.txt,来生成 Novel_directory.txt + """ + # 读取已有的小说设定 + filename_set = os.path.join(filepath, "Novel_setting.txt") + final_novel_setting = read_file(filename_set).strip() + if not final_novel_setting: + logging.warning("Novel_setting.txt 内容为空,请先生成小说设定。") + return + + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + # 生成目录 + prompt_dir = novel_directory_prompt.format( + final_novel_setting=final_novel_setting, + number_of_chapters=number_of_chapters + ) + final_novel_directory = invoke_with_cleaning(model, prompt_dir) + if not final_novel_directory.strip(): + logging.warning("Novel_directory生成结果为空。") + return + + # 写入 Novel_directory.txt + filename_dir = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename_dir) + + final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') + save_string_to_txt(final_novel_directory_cleaned, filename_dir) + + logging.info("Novel_directory.txt has been generated successfully.") + + +# ============ 获取最近 N 章内容,生成短期摘要 ============ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: """ 从指定文件夹中,读取最近 n 章的内容(如果存在),并按从旧到新的顺序返回文本列表。 @@ -390,6 +411,7 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int if text: texts.append(text) if len(texts) < n: + # 如果前面章节不足 n 章,用空字符串填充 texts = [''] * (n - len(texts)) + texts return texts @@ -411,7 +433,7 @@ def summarize_recent_chapters( model = ChatOpenAI( model=llm_model, api_key=api_key, - base_url=base_url, + base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) @@ -423,11 +445,11 @@ def summarize_recent_chapters( summary_text = invoke_with_cleaning(model, prompt) if not summary_text: - # 若模型无响应,就截取一段作为“备选” - return combined_text[:800] + "..." if len(combined_text) > 800 else combined_text + return (combined_text[:800] + "...") if len(combined_text) > 800 else combined_text return summary_text -# ============ 新增:剧情要点/未解决冲突 ============ + +# ============ 剧情要点/未解决冲突 ============ PLOT_ARCS_PROMPT = """\ 下面是新生成的章节内容: {chapter_text} @@ -451,7 +473,7 @@ def update_plot_arcs( model = ChatOpenAI( model=model_name, api_key=api_key, - base_url=base_url, + base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) prompt = PLOT_ARCS_PROMPT.format( @@ -464,7 +486,8 @@ def update_plot_arcs( return old_plot_arcs return arcs_text -# ============ 生成章节草稿 & 定稿 ============ + +# ============ 生成章节草稿 ============ def generate_chapter_draft( novel_settings: str, global_summary: str, @@ -486,12 +509,12 @@ def generate_chapter_draft( """ 生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 """ - # 根据目录信息获取本章标题、简介 + # 1) 从目录中获取本章标题、简介 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 从向量库检索上下文 + # 2) 从向量库检索上下文 queries = [] if user_guidance.strip(): queries.append(user_guidance) @@ -515,14 +538,15 @@ def generate_chapter_draft( if not relevant_context: relevant_context = "暂无相关内容。" + # 创建 ChatOpenAI,用于大纲和写作 model = ChatOpenAI( model=model_name, api_key=api_key, - base_url=base_url, + base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - # 1) 生成本章大纲 + # 3) 生成本章大纲 outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, @@ -542,7 +566,7 @@ def generate_chapter_draft( clear_file_content(outline_file) save_string_to_txt(chapter_outline, outline_file) - # 2) 生成正文草稿 + # 4) 生成正文草稿 writing_prompt_text = chapter_write_prompt.format( novel_setting=novel_settings, character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, @@ -566,6 +590,8 @@ def generate_chapter_draft( logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") return chapter_content + +# ============ 定稿章节 ============ def finalize_chapter( novel_number: int, word_number: int, @@ -618,7 +644,7 @@ def finalize_chapter( model = ChatOpenAI( model=model_name, api_key=api_key, - base_url=base_url, + base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) @@ -672,6 +698,7 @@ def finalize_chapter( logging.info(f"Chapter {novel_number} has been finalized.") + def enrich_chapter_text( chapter_text: str, word_number: int, @@ -686,7 +713,7 @@ def enrich_chapter_text( model = ChatOpenAI( model=model_name, api_key=api_key, - base_url=base_url, + base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 @@ -696,13 +723,14 @@ def enrich_chapter_text( enriched_text = invoke_with_cleaning(model, prompt) return enriched_text if enriched_text else chapter_text + # ============ 导入外部知识文本 ============ def import_knowledge_file( api_key: str, - base_url: str, + base_url: str, interface_format: str, embedding_model_name: str, - file_path: str, + file_path: str, embedding_base_url: str = "" ) -> None: """ @@ -719,7 +747,6 @@ def import_knowledge_file( return nltk.download('punkt', quiet=True) - nltk.download('punkt_tab', quiet=True) paragraphs = advanced_split_content(content) @@ -736,16 +763,17 @@ def import_knowledge_file( ) return - docs = [Document(page_content=p) for p in paragraphs] + docs = [Document(page_content=str(p)) for p in paragraphs] store.add_documents(docs) store.persist() logging.info("知识库文件已成功导入至向量库。") + def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: """ - 将文本先按句子切分,然后根据语义相似度进行合并,最后按max_length二次切分。 + 将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。 可根据需要微调此逻辑。 """ sentences = nltk.sent_tokenize(content) @@ -782,6 +810,7 @@ def advanced_split_content(content: str, return final_segments + def split_by_length(text: str, max_length: int = 500) -> List[str]: segments = [] start_idx = 0 diff --git a/ui.py b/ui.py index cbd371f..785610d 100644 --- a/ui.py +++ b/ui.py @@ -10,7 +10,8 @@ import traceback from config_manager import load_config, save_config from utils import read_file, save_string_to_txt, clear_file_content from novel_generator import ( - Novel_novel_directory_generate, + Novel_setting_generate, + Novel_directory_generate, generate_chapter_draft, finalize_chapter, import_knowledge_file, @@ -51,7 +52,7 @@ class NovelGeneratorGUI: self.config_file = "config.json" self.loaded_config = load_config(self.config_file) - # ========== 主要的属性变量 ========== + # ========== 主要的属性变量 ========== self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) @@ -69,7 +70,7 @@ class NovelGeneratorGUI: self.chapter_num_var = ctk.IntVar(value=1) - # ========== 主容器使用 TabView ========== + # ========== 主容器使用 TabView ========== self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) self.tabview.pack(fill="both", expand=True) @@ -79,30 +80,29 @@ class NovelGeneratorGUI: self.directory_tab = self.tabview.add("Novel Directory") 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的布局 + # 构建各个 Tab 的布局 self.build_main_tab() - - # 构建“Novel Settings”编辑Tab self.build_setting_tab() - - # 构建“Novel Directory”编辑Tab self.build_directory_tab() - - # 角色状态 Tab self.build_character_tab() - - # 全局摘要 Tab self.build_summary_tab() + self.build_chapters_tab() # 新增 - # ------------------ 统一异常处理方法 ------------------ + # ------------------ 统一异常处理方法 ------------------ def handle_exception(self, context: str): full_message = f"{context}\n{traceback.format_exc()}" logging.error(full_message) self.safe_log(full_message) - # ------------------ 主功能 Tab ------------------ + # ------------------ 主功能 Tab ------------------ def build_main_tab(self): + """ + 主Tab分为左右两栏: + 左侧:本章内容、Step按钮、日志 + 右侧:配置区域(带边框) + 保存/加载配置 + 小说参数 + 可选功能按钮 + """ self.main_tab.rowconfigure(0, weight=1) self.main_tab.columnconfigure(0, weight=1) self.main_tab.columnconfigure(1, weight=0) @@ -121,114 +121,275 @@ class NovelGeneratorGUI: self.build_right_layout() def build_left_layout(self): - self.left_frame.grid_rowconfigure(0, weight=3) - self.left_frame.grid_rowconfigure(1, weight=1) + """ + 左侧布局: + row=0 -> “本章内容”文本框 (chapter_result) + row=1 -> Step1~4按钮 + row=2 -> “输出日志”标题 + row=3 -> “输出日志”文本框 (log_text) + """ + 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.grid_columnconfigure(0, weight=1) - # 本章内容 - chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容", font=("Microsoft YaHei", 14)) + # ========== 本章内容 ========== + chapter_label = ctk.CTkLabel( + self.left_frame, + text="本章内容 (可编辑)", + font=("Microsoft YaHei", 14) + ) 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)) - self.chapter_result.grid(row=0, column=0, sticky="nsew", padx=5, pady=(0, 5)) + self.chapter_result = ctk.CTkTextbox( + self.left_frame, + wrap="word", + font=("Microsoft YaHei", 14) + ) + self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - # 输出日志 - log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 14)) - log_label.grid(row=1, column=0, padx=5, pady=(5, 0), sticky="w") + # ========== Step1~4按钮 ========== + self.build_step_buttons_area() - # 日志:只读 - self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) - self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + # ========== 输出日志 label ========== + log_label = ctk.CTkLabel( + self.left_frame, + text="输出日志 (只读)", + font=("Microsoft YaHei", 14) + ) + 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) + ) + 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): - self.right_frame.grid_rowconfigure(0, weight=0) - self.right_frame.grid_rowconfigure(1, weight=0) - self.right_frame.grid_rowconfigure(2, weight=1) - self.right_frame.columnconfigure(0, weight=0) - self.right_frame.columnconfigure(1, weight=1) + def build_step_buttons_area(self): + """ + 在左侧,仅放 Step1~Step4 四个按钮 + """ + 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) - # ========== 1. 配置项 TabView(AI接口配置 & Embedding配置) ========== - self.config_tabview = ctk.CTkTabview(self.right_frame) - self.config_tabview.grid(row=0, column=0, columnspan=2, sticky="we", padx=5, pady=5) + self.btn_generate_setting = ctk.CTkButton( + self.step_buttons_frame, + text="Step1. 生成设定", + command=self.generate_novel_setting_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_setting.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_novel_directory_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") + + def build_right_layout(self): + """ + 右侧布局,包含: + row=0 -> 带边框的配置区 (TabView + 保存/加载配置按钮) + row=1 -> 小说参数区域 + row=2 -> 可选功能按钮 (一致性审校 / 导入知识库 / 清空向量库 / 查看剧情要点) + """ + 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) + + # 1) 配置区 + 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() # LLM、Embedding等配置 + self.build_main_buttons_area() # 保存/加载配置 + + # 2) 小说参数 + self.build_novel_params_area(start_row=1) + + # 3) 可选功能按钮 + self.build_optional_buttons_area(start_row=2) + + # ------------------ 可选功能按钮区域(右下) ------------------ + 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") + + # ========== 配置区域(TabView) ========== + def build_config_tabview(self): + self.config_tabview = ctk.CTkTabview(self.config_frame, width=600, height=200) + 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") - # 构建这两个 Tab 的布局 self.build_ai_config_tab() self.build_embeddings_config_tab() - # ========== 2. 与小说相关的参数区 ========== - self.build_novel_params_area(start_row=1) - - # ========== 3. 放主要功能按钮 & “保存/加载配置”按钮区 ========== - self.build_main_buttons_area(start_row=10) - def build_ai_config_tab(self): def on_interface_format_changed(new_value): - # 如果用户切换接口格式,可根据需要修改BaseURL为默认值 if new_value == "Ollama": self.base_url_var.set("http://localhost:11434/v1") elif new_value == "ML Studio": - self.base_url_var.set("http://localhost:1234/v1") + self.base_url_var.set("http://localhost:1234") elif new_value == "OpenAI": - self.base_url_var.set("https://api.agicto.cn/v1") + self.base_url_var.set("https://api.agicto.cn") for i in range(5): 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) # for temp label - # 1. API Key - api_key_label = ctk.CTkLabel(self.ai_config_tab, text="API Key:", font=("Microsoft YaHei", 12)) + api_key_label = ctk.CTkLabel( + self.ai_config_tab, + text="API Key:", + font=("Microsoft YaHei", 12) + ) api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - api_key_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var, 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, sticky="nsew") - # 2. Base URL - base_url_label = ctk.CTkLabel(self.ai_config_tab, text="Base URL:", font=("Microsoft YaHei", 12)) + base_url_label = ctk.CTkLabel( + self.ai_config_tab, + text="Base URL:", + font=("Microsoft YaHei", 12) + ) base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - base_url_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.base_url_var, 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, sticky="nsew") - # 3. 接口格式 - interface_label = ctk.CTkLabel(self.ai_config_tab, text="接口格式:", font=("Microsoft YaHei", 12)) + interface_label = ctk.CTkLabel( + self.ai_config_tab, + text="接口格式:", + font=("Microsoft YaHei", 12) + ) interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI", "Ollama", "ML Studio", "Local"] + interface_options = ["OpenAI", "Ollama", "ML Studio"] interface_dropdown = ctk.CTkOptionMenu( - self.ai_config_tab, - values=interface_options, + 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, sticky="nsew") - # 4. 模型名称 - model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:", font=("Microsoft YaHei", 12)) + model_name_label = ctk.CTkLabel( + self.ai_config_tab, + text="Model Name:", + font=("Microsoft YaHei", 12) + ) model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") - model_name_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.model_name_var, 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, sticky="nsew") - # 5. Temperature - temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:", font=("Microsoft YaHei", 12)) + temp_label = ctk.CTkLabel( + self.ai_config_tab, + text="Temperature:", + font=("Microsoft YaHei", 12) + ) temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") 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=1.0, + self.ai_config_tab, + from_=0.0, to=1.0, number_of_steps=100, - command=update_temp_label, + 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 = 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=1, pady=1, sticky="w") def build_embeddings_config_tab(self): @@ -237,291 +398,438 @@ class NovelGeneratorGUI: self.embeddings_config_tab.grid_columnconfigure(0, weight=0) self.embeddings_config_tab.grid_columnconfigure(1, weight=1) - # 1. Embedding URL - embedding_url_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding URL:", font=("Microsoft YaHei", 12)) + embedding_url_label = ctk.CTkLabel( + self.embeddings_config_tab, + text="Embedding URL:", + font=("Microsoft YaHei", 12) + ) embedding_url_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - embedding_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var, font=("Microsoft YaHei", 12)) + embedding_url_entry = ctk.CTkEntry( + self.embeddings_config_tab, + textvariable=self.embedding_url_var, + font=("Microsoft YaHei", 12) + ) embedding_url_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") - # 2. Embedding 模型名称 - emb_model_name_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Model Name:", font=("Microsoft YaHei", 12)) + emb_model_name_label = ctk.CTkLabel( + self.embeddings_config_tab, + text="Embedding Model Name:", + font=("Microsoft YaHei", 12) + ) emb_model_name_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - emb_model_name_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_model_name_var, 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=1, column=1, padx=5, pady=5, sticky="nsew") - def build_novel_params_area(self, start_row=2): + # ========== 保存/加载 配置按钮区域 ========== + def build_main_buttons_area(self): + """ + 放置在带边框配置区(config_frame)内部,位于TabView下方 + """ + 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 build_novel_params_area(self, start_row=1): + """ + 右侧下方区域: 输入主题, 类型, 章节数, 字数, 保存路径, 指导信息等 + """ + self.params_frame = ctk.CTkFrame(self.right_frame) + self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) + self.params_frame.columnconfigure(1, weight=1) + # 主题(Topic) - topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) - topic_label.grid(row=start_row, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox(self.right_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) - self.topic_text.grid(row=start_row, column=1, padx=5, pady=5, sticky="nsew") + topic_label = ctk.CTkLabel( + self.params_frame, + text="主题(Topic):", + font=("Microsoft YaHei", 12) + ) + topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + self.topic_text = ctk.CTkTextbox( + self.params_frame, + width=200, + height=80, + wrap="word", + font=("Microsoft YaHei", 12) + ) + 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) # 类型(Genre) - genre_label = ctk.CTkLabel(self.right_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) - genre_label.grid(row=start_row+1, column=0, padx=5, pady=5, sticky="e") - genre_entry = ctk.CTkEntry(self.right_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12)) - genre_entry.grid(row=start_row+1, column=1, padx=5, pady=5, sticky="ew") + genre_label = ctk.CTkLabel( + self.params_frame, + text="类型(Genre):", + font=("Microsoft YaHei", 12) + ) + genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + 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") # 章节数 - num_chapters_label = ctk.CTkLabel(self.right_frame, text="章节数:", font=("Microsoft YaHei", 12)) - num_chapters_label.grid(row=start_row+2, column=0, padx=5, pady=5, sticky="e") - num_chapters_entry = ctk.CTkEntry(self.right_frame, textvariable=self.num_chapters_var, width=80, font=("Microsoft YaHei", 12)) - num_chapters_entry.grid(row=start_row+2, column=1, padx=5, pady=5, sticky="w") + num_chapters_label = ctk.CTkLabel( + self.params_frame, + text="章节数:", + font=("Microsoft YaHei", 12) + ) + num_chapters_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + num_chapters_entry = ctk.CTkEntry( + self.params_frame, + textvariable=self.num_chapters_var, + width=80, + font=("Microsoft YaHei", 12) + ) + num_chapters_entry.grid(row=2, column=1, padx=5, pady=5, sticky="w") # 每章字数 - word_number_label = ctk.CTkLabel(self.right_frame, text="每章字数:", font=("Microsoft YaHei", 12)) - word_number_label.grid(row=start_row+3, column=0, padx=5, pady=5, sticky="e") - word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80, font=("Microsoft YaHei", 12)) - word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w") + word_number_label = ctk.CTkLabel( + self.params_frame, + text="每章字数:", + font=("Microsoft YaHei", 12) + ) + word_number_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + word_number_entry = ctk.CTkEntry( + self.params_frame, + textvariable=self.word_number_var, + width=80, + font=("Microsoft YaHei", 12) + ) + word_number_entry.grid(row=3, column=1, padx=5, pady=5, sticky="w") # 保存路径 - filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) - filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e") + filepath_label = ctk.CTkLabel( + self.params_frame, + text="保存路径:", + font=("Microsoft YaHei", 12) + ) + filepath_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - self.filepath_frame = ctk.CTkFrame(self.right_frame) - self.filepath_frame.grid(row=start_row+4, column=1, padx=5, pady=5, sticky="nsew") + self.filepath_frame = ctk.CTkFrame(self.params_frame) + self.filepath_frame.grid(row=4, 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 = 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 = 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") # 章节号 - chapter_num_label = ctk.CTkLabel(self.right_frame, text="章节号:", font=("Microsoft YaHei", 12)) - chapter_num_label.grid(row=start_row+5, column=0, padx=5, pady=5, sticky="e") - chapter_num_entry = ctk.CTkEntry(self.right_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12)) - chapter_num_entry.grid(row=start_row+5, column=1, padx=5, pady=5, sticky="w") + chapter_num_label = ctk.CTkLabel( + self.params_frame, + text="章节号:", + font=("Microsoft YaHei", 12) + ) + chapter_num_label.grid(row=5, column=0, padx=5, pady=5, sticky="e") + chapter_num_entry = ctk.CTkEntry( + self.params_frame, + textvariable=self.chapter_num_var, + width=80, + font=("Microsoft YaHei", 12) + ) + chapter_num_entry.grid(row=5, column=1, padx=5, pady=5, sticky="w") # 用户指导 - guide_label = ctk.CTkLabel(self.right_frame, text="本章指导:", font=("Microsoft YaHei", 12)) - guide_label.grid(row=start_row+6, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox(self.right_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) - self.user_guide_text.grid(row=start_row+6, column=1, padx=5, pady=5, sticky="nsew") - - def build_main_buttons_area(self, start_row=10): - self.right_frame.grid_rowconfigure(start_row, weight=0) - - self.btn_frame_config = ctk.CTkFrame(self.right_frame) - self.btn_frame_config.grid(row=start_row, column=0, columnspan=2, 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") - - self.btn_generate_full = ctk.CTkButton( - self.right_frame, text="Step1. 生成设定 & 目录", - command=self.generate_full_novel, + guide_label = ctk.CTkLabel( + self.params_frame, + text="本章指导:", font=("Microsoft YaHei", 12) ) - self.btn_generate_full.grid(row=start_row+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - self.btn_generate_chapter = ctk.CTkButton( - self.right_frame, text="Step2. 生成章节草稿", - command=self.generate_chapter_draft_ui, + guide_label.grid(row=6, column=0, padx=5, pady=5, sticky="ne") + self.user_guide_text = ctk.CTkTextbox( + self.params_frame, + width=200, + height=80, + wrap="word", font=("Microsoft YaHei", 12) ) - self.btn_generate_chapter.grid(row=start_row+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.user_guide_text.grid(row=6, column=1, padx=5, pady=5, sticky="nsew") - self.btn_finalize_chapter = ctk.CTkButton( - self.right_frame, text="Step3. 定稿当前章节", - command=self.finalize_chapter_ui, - font=("Microsoft YaHei", 12) - ) - self.btn_finalize_chapter.grid(row=start_row+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - self.btn_check_consistency = ctk.CTkButton( - self.right_frame, text="[可选]一致性审校", - command=self.do_consistency_check, - font=("Microsoft YaHei", 12) - ) - self.btn_check_consistency.grid(row=start_row+4, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - self.btn_import_knowledge = ctk.CTkButton( - self.right_frame, text="[可选]导入知识库", - command=self.import_knowledge_handler, - font=("Microsoft YaHei", 12) - ) - self.btn_import_knowledge.grid(row=start_row+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - self.btn_clear_vectorstore = ctk.CTkButton( - self.right_frame, text="清空向量库", - fg_color="red", - command=self.clear_vectorstore_handler, - font=("Microsoft YaHei", 12) - ) - self.btn_clear_vectorstore.grid(row=start_row+6, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - plot_arcs_btn = ctk.CTkButton( - self.right_frame, text="[查看] 剧情要点", - command=self.show_plot_arcs_ui, - font=("Microsoft YaHei", 12) - ) - plot_arcs_btn.grid(row=start_row+7, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # ------------------ Novel Settings Tab ------------------ + # ------------------ 其他Tab的构建 ------------------ 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_setting.txt", command=self.load_novel_setting, font=("Microsoft YaHei", 12)) + load_btn = ctk.CTkButton( + self.setting_tab, + text="加载 Novel_setting.txt", + command=self.load_novel_setting, + 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_setting, font=("Microsoft YaHei", 12)) + save_btn = ctk.CTkButton( + self.setting_tab, + text="保存修改", + command=self.save_novel_setting, + 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)) + self.setting_text = ctk.CTkTextbox( + self.setting_tab, + wrap="word", + font=("Microsoft YaHei", 12) + ) self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - def load_novel_setting(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - - setting_file = os.path.join(filepath, "Novel_setting.txt") - content = read_file(setting_file) - self.setting_text.delete("0.0", "end") - self.setting_text.insert("0.0", content) - self.log("已加载 Novel_setting.txt 内容到编辑区。") - - def save_novel_setting(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - - content = self.setting_text.get("0.0", "end").strip() - setting_file = os.path.join(filepath, "Novel_setting.txt") - clear_file_content(setting_file) - save_string_to_txt(content, setting_file) - self.log("已保存对 Novel_setting.txt 的修改。") - - # ------------------ Novel Directory Tab ------------------ 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_novel_directory, font=("Microsoft YaHei", 12)) + load_btn = ctk.CTkButton( + self.directory_tab, + text="加载 Novel_directory.txt", + command=self.load_novel_directory, + 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_novel_directory, font=("Microsoft YaHei", 12)) + save_btn = ctk.CTkButton( + self.directory_tab, + text="保存修改", + command=self.save_novel_directory, + 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)) + self.directory_text = ctk.CTkTextbox( + self.directory_tab, + wrap="word", + font=("Microsoft YaHei", 12) + ) self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - def load_novel_directory(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - - directory_file = os.path.join(filepath, "Novel_directory.txt") - content = read_file(directory_file) - self.directory_text.delete("0.0", "end") - self.directory_text.insert("0.0", content) - self.log("已加载 Novel_directory.txt 内容到编辑区。") - - def save_novel_directory(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - - content = self.directory_text.get("0.0", "end").strip() - directory_file = os.path.join(filepath, "Novel_directory.txt") - clear_file_content(directory_file) - save_string_to_txt(content, directory_file) - self.log("已保存对 Novel_directory.txt 的修改。") - - # ------------------ Character State Tab ------------------ 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 = 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 = 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)) + self.character_text = ctk.CTkTextbox( + self.character_tab, + wrap="word", + font=("Microsoft YaHei", 12) + ) 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("警告", "请先在主Tab中设置保存文件路径") - return - - char_file = os.path.join(filepath, "character_state.txt") - content = read_file(char_file) - 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("警告", "请先在主Tab中设置保存文件路径") - return - - content = self.character_text.get("0.0", "end").strip() - char_file = os.path.join(filepath, "character_state.txt") - clear_file_content(char_file) - save_string_to_txt(content, char_file) - self.log("已保存对 character_state.txt 的修改。") - - # ------------------ Global Summary Tab ------------------ 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 = 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 = 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)) + self.summary_text = ctk.CTkTextbox( + self.summary_tab, + wrap="word", + font=("Microsoft YaHei", 12) + ) self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - def load_global_summary(self): + def build_chapters_tab(self): + """ + 新增的 Tab,用于查看、编辑和保存已生成的各章节内容。 + """ + 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)) + 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): filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + 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 - summary_file = os.path.join(filepath, "global_summary.txt") - content = read_file(summary_file) - self.summary_text.delete("0.0", "end") - self.summary_text.insert("0.0", content) - self.log("已加载 global_summary.txt 内容到编辑区。") + 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) - def save_global_summary(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + 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 - content = self.summary_text.get("0.0", "end").strip() - summary_file = os.path.join(filepath, "global_summary.txt") - clear_file_content(summary_file) - save_string_to_txt(content, summary_file) - self.log("已保存对 global_summary.txt 的修改。") + 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 load_config_btn(self): cfg = load_config(self.config_file) if cfg: @@ -571,35 +879,32 @@ class NovelGeneratorGUI: if selected_dir: self.filepath_var.set(selected_dir) - # ------------------ 日志输出(主线程安全) ------------------ + # ------------------ 日志输出(主线程安全) ------------------ 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)) - # 由于按钮更新也属于UI操作,也需要安全方式 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 generate_full_novel(self): - """生成小说设定 & 目录""" + # ------------------ 分步操作:生成设定、目录、章节草稿、定稿 ------------------ + def generate_novel_setting_ui(self): + """Step1. 生成小说设定(Novel_setting.txt)""" filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先选择保存文件路径") return def task(): - self.disable_button_safe(self.btn_generate_full) + self.disable_button_safe(self.btn_generate_setting) try: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() @@ -610,8 +915,8 @@ class NovelGeneratorGUI: word_number = self.word_number_var.get() temperature = self.temperature_var.get() - self.safe_log("开始生成小说设定和目录...") - Novel_novel_directory_generate( + self.safe_log("开始生成小说设定...") + Novel_setting_generate( api_key=api_key, base_url=base_url, llm_model=model_name, @@ -622,16 +927,49 @@ class NovelGeneratorGUI: filepath=filepath, temperature=temperature ) - self.safe_log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") + self.safe_log("✅ 小说设定生成完成。请在 'Novel Settings' 标签页进行查看或编辑。") except Exception: - self.handle_exception("生成小说设定 & 目录时出错") + self.handle_exception("生成小说设定时出错") finally: - self.enable_button_safe(self.btn_generate_full) + self.enable_button_safe(self.btn_generate_setting) + + threading.Thread(target=task, daemon=True).start() + + def generate_novel_directory_ui(self): + """Step2. 基于已有 Novel_setting.txt 生成 Novel_directory.txt""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + + def task(): + self.disable_button_safe(self.btn_generate_directory) + 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() + num_chapters = self.num_chapters_var.get() + temperature = self.temperature_var.get() + + self.safe_log("开始生成小说目录...") + Novel_directory_generate( + api_key=api_key, + base_url=base_url, + llm_model=model_name, + number_of_chapters=num_chapters, + filepath=filepath, + temperature=temperature + ) + self.safe_log("✅ 小说目录生成完成。请在 'Novel Directory' 标签页查看或编辑。") + 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("警告", "请先配置保存文件路径。") @@ -695,7 +1033,6 @@ class NovelGeneratorGUI: ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") - # 更新UI中的章节草稿 self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) else: self.safe_log("⚠️ 本章草稿生成失败或无内容。") @@ -713,7 +1050,7 @@ class NovelGeneratorGUI: self.chapter_result.see("end") def finalize_chapter_ui(self): - """定稿当前章节:更新全局摘要、角色状态、向量库等""" + """Step4. 定稿当前章节:更新全局摘要、角色状态、向量库等""" filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先配置保存文件路径。") @@ -758,6 +1095,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() + # ------------------ 一致性审校 ------------------ def do_consistency_check(self): """使用审校Agent对最新章节进行简单一致性或冲突检查""" filepath = self.filepath_var.get().strip() @@ -813,8 +1151,8 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() + # ------------------ 导入知识库/清空向量库/查看剧情要点 ------------------ def import_knowledge_handler(self): - """处理导入知识库文件。""" selected_file = filedialog.askopenfilename( title="选择要导入的知识库文件", filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")] @@ -841,7 +1179,6 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() def clear_vectorstore_handler(self): - """清空向量库按钮:弹出二次确认。""" first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") if first_confirm: second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") @@ -850,7 +1187,6 @@ class NovelGeneratorGUI: self.log("已清空向量库。") def show_plot_arcs_ui(self): - """[查看]当前剧情要点""" filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") @@ -875,7 +1211,96 @@ class NovelGeneratorGUI: text_area.insert("0.0", arcs_text) text_area.configure(state="disabled") + # ------------------ Novel Settings/Directory/Character/Global Summary 的加载与保存 ------------------ + def load_novel_setting(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + setting_file = os.path.join(filepath, "Novel_setting.txt") + content = read_file(setting_file) + self.setting_text.delete("0.0", "end") + self.setting_text.insert("0.0", content) + self.log("已加载 Novel_setting.txt 内容到编辑区。") + def save_novel_setting(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + content = self.setting_text.get("0.0", "end").strip() + setting_file = os.path.join(filepath, "Novel_setting.txt") + clear_file_content(setting_file) + save_string_to_txt(content, setting_file) + self.log("已保存对 Novel_setting.txt 的修改。") + + def load_novel_directory(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + directory_file = os.path.join(filepath, "Novel_directory.txt") + content = read_file(directory_file) + self.directory_text.delete("0.0", "end") + self.directory_text.insert("0.0", content) + self.log("已加载 Novel_directory.txt 内容到编辑区。") + + def save_novel_directory(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + content = self.directory_text.get("0.0", "end").strip() + directory_file = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(directory_file) + save_string_to_txt(content, directory_file) + self.log("已保存对 Novel_directory.txt 的修改。") + + def load_character_state(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + char_file = os.path.join(filepath, "character_state.txt") + content = read_file(char_file) + 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("警告", "请先在主Tab中设置保存文件路径") + return + content = self.character_text.get("0.0", "end").strip() + char_file = os.path.join(filepath, "character_state.txt") + clear_file_content(char_file) + save_string_to_txt(content, char_file) + self.log("已保存对 character_state.txt 的修改。") + + def load_global_summary(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + summary_file = os.path.join(filepath, "global_summary.txt") + content = read_file(summary_file) + 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("警告", "请先在主Tab中设置保存文件路径") + return + content = self.summary_text.get("0.0", "end").strip() + summary_file = os.path.join(filepath, "global_summary.txt") + clear_file_content(summary_file) + save_string_to_txt(content, summary_file) + self.log("已保存对 global_summary.txt 的修改。") + +# 入口 if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app) From 32d14833138eb24fc42c3a16f26e03521e541e31 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 3 Feb 2025 13:40:57 +0800 Subject: [PATCH 038/142] Update README.md --- README.md | 258 ++++++++++++++++++++++++++---------------------------- 1 file changed, 122 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 41ec5bd..84696da 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,54 @@ +# 📖 自动小说生成工具 -# **自动小说生成工具** - -> 一款基于大模型的多功能小说生成器,帮助你快速生成连贯、可控、可审校的长篇故事。 +> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事
+ +✨ **核心功能** ✨ -| 功能点 | 描述 | -|-------|-----| -| 🚀 **小说设定** | 世界观 / 人物 / 剧情结构 | -| 📝 **章节生成** | 多步生成,确保逻辑连贯 | -| 👥 **角色状态 & 伏笔管理** | 追踪人物发展、重要事件 | -| 🔍 **向量检索** | 保证长篇小说上下文一致 | -| 📁 **自定义知识库** | 可上传本地参考文档 | -| ✅ **一致性检查** | 自动识别剧情冲突或矛盾 | -| 🖥 **GUI 友好交互** | 所见即所得,配置 & 操作简便 | +| 功能模块 | 关键能力 | +|-------------------|----------------------------------| +| 🎨 小说设定工坊 | 世界观架构 / 角色设定 / 剧情蓝图 | +| 📖 智能章节生成 | 多阶段生成保障剧情连贯性 | +| 🧠 状态追踪系统 | 角色发展轨迹 / 伏笔管理系统 | +| 🔍 语义检索引擎 | 基于向量的长程上下文一致性维护 | +| 📚 知识库集成 | 支持本地文档参考 | +| ✅ 自动审校机制 | 检测剧情矛盾与逻辑冲突 | +| 🖥 可视化工作台 | 全流程GUI操作,配置/生成/审校一体化 |
--- -## **目录** -1. [环境要求](#环境要求) -2. [安装依赖](#安装依赖) -3. [项目结构](#项目结构) -4. [配置 API Key](#配置-api-key) -5. [运行程序](#运行程序) -6. [使用指南](#使用指南) -7. [生成文件管理](#生成文件管理) -8. [常见问题](#常见问题) +## 📑 目录导航 +1. [环境准备](#-环境准备) +2. [项目架构](#-项目架构) +3. [配置指南](#⚙️-配置指南) +4. [运行说明](#🚀-运行说明) +5. [使用教程](#📘-使用教程) +6. [疑难解答](#❓-疑难解答) --- -## **环境要求** -在开始之前,请确保你的系统满足以下条件: -- 推荐**Python 3.10+** -- 已安装 **pip**(Python 包管理器) -- 拥有 **API Key**(如 OpenAI 或 DeepSeek)或 **支持OpenAI调用方式的本地接口** +## 🛠 环境准备 +确保满足以下运行条件: +- **Python 3.10+** 运行环境 +- **pip** 包管理工具 +- 有效API密钥: + - 云端服务:OpenAI / DeepSeek 等 + - 本地服务:Ollama 等兼容 OpenAI 的接口 --- -## **安装依赖** -1. 在项目根目录下,打开终端或命令行 -2. 执行以下命令安装所有依赖: - ```bash - pip install -r requirements.txt - ``` -3. (可选)若需要手动安装 `nltk` 的 `punkt_tab` 数据包: - ```bash - python - >>> import nltk - >>> nltk.download('punkt_tab') - ``` - ---- - -## **项目结构** -克隆或下载本项目后,你将看到如下目录结构: - +## 🗂 项目架构 ``` -. +novel-generator/ ├── main.py # 入口文件, 运行 GUI ├── ui.py # 图形界面 ├── novel_generator.py # 章节生成核心逻辑 ├── consistency_checker.py # 一致性检查, 防止剧情冲突 -|—— chapter_directory_parser.py #格式化目录 +|—— chapter_directory_parser.py # 目录解析 +|—— embedding_ollama.py # Ollama 本地服务的 Embedding 接口 ├── prompt_definitions.py # 定义 AI 提示词 ├── utils.py # 常用工具函数, 文件操作 ├── config_manager.py # 管理配置 (API Key, Base URL) @@ -71,37 +56,49 @@ └── vectorstore/ # (可选) 本地向量数据库存储 ``` -> `vectorstore/` 文件夹将在程序运行后自动生成,存储向量检索的缓存数据。 - --- -## **配置 API Key** -你可以通过以下方式指定或修改 API Key: - -### 方式 1:**修改 `config.json`** -打开 `config.json`,将对应字段替换为你的配置: +## ⚙️ 配置指南 +### 📌 基础配置(config.json) ```json { - "api_key": "your_openai_api_key", + "api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXX", "base_url": "https://api.openai.com/v1", - "model_name": "gpt-4o", - "topic": "未来科技", - "genre": "科幻", + "interface_format": "OpenAI", + "model_name": "gpt-4o-mini", + "embedding_url": "https://api.openai.com/v1", + "embedding_model_name": "text-embedding-ada-002", + "temperature": 0.70, + "topic": "星穹铁道主角星穿越到原神提瓦特大陆,拯救提瓦特大陆,并与其中的角色展开爱恨情仇的小说", + "genre": "玄幻", "num_chapters": 10, - "word_number": 3000, - "filepath": "output_directory" + "word_number": 2000, + "filepath": "C:/Users/Documents/test" } ``` -### 方式 2:**通过 GUI 输入** -1. 运行 `main.py` 后会弹出图形界面 -2. 在界面输入 `API Key`、`Base URL`、模型名称、Temperature 等 -3. 点击 **“保存配置”** 按钮,自动生成或更新 `config.json` +### 🔧 配置说明 +1. **生成模型配置** + - `api_key`: 大模型服务的API密钥 + - `base_url`: API终端地址(本地服务填Ollama等地址) + - `interface_format`: 接口模式 + - `model_name`: 主生成模型名称(如gpt-4, claude-3等) + - `temperature`: 创意度参数(0-1,越高越有创造性) + +2. **Embedding模型配置** + - `embedding_model_name`: 模型名称(如Ollama的nomic-embed-text) + - `embedding_url`: 服务地址 + +3. **小说参数配置** + - `topic`: 核心故事主题 + - `genre`: 作品类型 + - `num_chapters`: 总章节数 + - `word_number`: 单章目标字数 + - `filepath`: 生成文件存储路径 --- -## **运行程序** - +## 🚀 运行说明 ### **方式 1:使用 Python 解释器** ```bash python main.py @@ -119,93 +116,82 @@ pyinstaller main.spec --- -## **使用指南** - +## 📘 使用教程 1. **启动后,先完成基本参数设置:** - **API Key & Base URL**(如 `https://api.openai.com/v1`) - - **模型名称**(如 `gpt-3.5-turbo`、`gpt-4o` 等) - - **Temperature**(0~1,决定文字创意程度) - - **主题(Topic)**(如 “废土世界的 AI 叛乱”) - - **类型(Genre)**(如 “科幻”/“魔幻”/“都市幻想”) - - **章节数**、**每章字数**(如 10 章,每章约 3000 字) + - **模型名称**(如 `gpt-3.5-turbo`、`gpt-4o` 等) + - **Temperature** (0~1,决定文字创意程度) + - **主题(Topic)**(如 “废土世界的 AI 叛乱”) + - **类型(Genre)**(如 “科幻”/“魔幻”/“都市幻想”) + - **章节数**、**每章字数**(如 10 章,每章约 3000 字) - **保存路径**(建议创建一个新的输出文件夹) -2. **点击「1. 生成设定 & 目录」** - - 系统将基于主题、类型等生成: - - `Novel_setting.txt`:**世界观 & 整体设定** - - `Novel_directory.txt`:**章节目录**(含标题、简要提示) - - 可以在生成后的文件中查看、修改或补充世界观设定和目录标题。 +2. **点击「Step1. 生成设定」** + - 系统将基于主题、类型、章节数等信息,生成: + - `Novel_setting.txt`:包含世界观、角色信息、雷点暗线等。 + - 可以在生成后的 `Novel_setting.txt` 中查看或修改设定内容。 -3. **点击「2. 生成章节草稿」** - - 在生成章节之前,你可以: - - **设置章节号**(如要写第 1 章,就填 `1`) - - **在“本章指导”输入框**中,填写对本章剧情的任何期望或指导 - - 点击按钮后,系统将: - - 自动读取前文与 `Novel_directory.txt` 的标题与简述 - - 调用向量检索回顾剧情,确保上下文连贯 +3. **点击「Step2. 生成目录」** + - 系统会根据已完成的 `Novel_setting.txt` 内容,为全部章节生成: + - `Novel_directory.txt`:包括每章标题和简要提示。 + - 可以在生成后的文件中查看、修改或补充章节标题和描述。 + +4. **点击「Step3. 生成章节草稿」** + - 在生成章节之前,你可以: + - **设置章节号**(如写第 1 章,就填 `1`) + - **在“本章指导”输入框**中提供对本章剧情的任何期望或提示 + - 点击按钮后,系统将: + - 自动读取前文设定、`Novel_directory.txt`、以及已定稿章节 + - 调用向量检索回顾剧情,保证上下文连贯 - 生成本章大纲 (`outline_X.txt`) 及正文 (`chapter_X.txt`) - - 生成完成后,可在左侧查看本章草稿内容。 + - 生成完成后,你可在左侧的文本框查看、编辑本章草稿内容。 -4. **手动检查 & 编辑**(可选) - - 你可以在文本编辑器中修改 `chapter_X.txt` 的内容,使之更符合个人审美或剧情要求。 - -5. **点击「3. 定稿当前章节」** - - 系统将: - - **更新全局摘要**(`global_summary.txt`) - - **更新角色状态**(`character_state.txt`,包含人物发展、物品变动、剧情线索等) - - **更新向量检索库**,保证后续章节能够调用最新信息。 +5. **点击「Step4. 定稿当前章节」** + - 系统将: + - **更新全局摘要**(写入 `global_summary.txt`) + - **更新角色状态**(写入 `character_state.txt`) + - **更新向量检索库**(保证后续章节可以调用最新信息) + - **更新剧情要点**(如 `plot_arcs.txt`) + - 定稿完成后,你可以在 `chapter_X.txt` 中看到定稿后的文本。 6. **一致性检查(可选)** - - 点击「4. 一致性审校」进行冲突检测,比如**角色逻辑、剧情前后矛盾**等。 - - 若有冲突,会在日志区输出详细说明。 + - 点击「[可选] 一致性审校」按钮,对最新章节进行冲突检测,如角色逻辑、剧情前后矛盾等。 + - 若有冲突,会在日志区输出详细提示。 -7. **重复第 3~5 步** 直到所有章节完成! +7. **重复第 4-6 步** 直到所有章节生成并定稿! + +> **向量检索配置提示** +> 1. embedding模型需要显示指定接口和模型名称; +> 2. 使用**本地Ollama**的**Embedding**时需提前启动Ollama服务: +> ```bash +> ollama serve # 启动服务 +> ollama pull nomic-embed-text # 下载/启用模型 +> ``` +> 3. 切换不同Embedding模型后建议清空vectorstore目录 +> 4. 云端Embedding需确保对应API权限已开通 --- -## **生成文件管理** +## ❓ 疑难解答 +### Q1: 如何验证Embedding服务是否正常? +```python +# 测试脚本 test_embedding.py +from core.embedding import get_embedder -在你指定的输出文件夹中,程序会自动生成并维护以下文件/目录: - -``` -output_directory/ -├── Novel_setting.txt # 世界观 & 整体设定 -├── Novel_directory.txt # 小说章节目录 -├── character_state.txt # 角色状态/物品/伏笔等追踪 -├── global_summary.txt # 整体剧情摘要 (用于后续上下文) -├── outlines/ # 存放各章大纲 (outline_1.txt 等) -├── chapters/ # 存放每一章成稿 (chapter_1.txt 等) -└── vectorstore/ # 向量检索数据库 (可清空重置) +embedder = get_embedder() +test_text = "这是一个测试句子" +vector = embedder.embed_query(test_text) +print(f"向量维度: {len(vector)}") # 正常应输出预设维度数 ``` -- **`character_state.txt`**:记录角色的动机、能力、持有物品等关键信息 -- **`global_summary.txt`**:每次定稿后都会更新,保证剧情可被后续章节引用 -- **`chapters/`**:每一章的正文会独立保存,方便你随时手动修改 +### Q2: 本地Embedding响应缓慢怎么办? +- 检查模型是否已下载到本地 +- 尝试较小尺寸的Embedding模型 +- 增加Ollama服务的运行内存 + +### Q3: 如何切换不同的Embedding提供商? +在GUI界面中对应输入即可。 --- -## **常见问题** - -#### 1. **`Chroma' object has no attribute 'persist'` 错误** - -- **原因**:Chroma 库版本不匹配 -- **解决办法**: - ```bash - pip uninstall chromadb - pip install chromadb==0.3.21 - ``` - 或者在 `novel_generator.py` 中找到 `store.persist()` 并注释掉,如果你的版本不支持此方法。 - ---- - -#### 2. **生成内容与预期不符** - -- **可能原因**: - 1. 主题或类型过于宽泛,模型难以把握重点 - 2. 角色和世界观设定不够详细,可在 `Novel_setting.txt` 中增补更多细节 -- **建议**: - - 在 GUI 的“本章指导”输入框中填写更明确的剧情走向或重点描述,让模型更好地理解和发挥 - ---- - -若你还有其他问题或需求,欢迎在项目 Issues 中提出。 \ No newline at end of file +如有更多问题或需求,欢迎在**项目 Issues** 中提出。 From 874283cbd22e0050639b704b021ac2819f1852b8 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 3 Feb 2025 19:00:27 +0800 Subject: [PATCH 039/142] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E5=B0=BD=E9=87=8F=E7=A1=AE=E4=BF=9D=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_ollama.py | 19 +++++++++++-------- main.spec | 9 +++++++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/embedding_ollama.py b/embedding_ollama.py index 0f8bba3..749a462 100644 --- a/embedding_ollama.py +++ b/embedding_ollama.py @@ -4,13 +4,6 @@ import traceback from typing import List class OllamaEmbeddings: - """ - Ollama 本地服务提供的 Embedding 接口, - 最终拼出形如: http://localhost:11434/api/embeddings - 即 base_url + "/embeddings" - 但是按文档,好像/embeddings接口已经被废弃了,现在是/embed才对,实际测试都可以用,视情况而定 - """ - def __init__(self, model_name: str, base_url: str): self.model_name = model_name self.base_url = base_url @@ -40,7 +33,17 @@ class OllamaEmbeddings: """ 调用 Ollama 本地服务接口,获取文本的 embedding。 """ - url = f"{self.base_url}/embeddings" + if self.base_url.endswith("/"): + self.base_url = self.base_url.rstrip("/") + if "api/embeddings" in self.base_url: + # 如果 base_url 已经包含 'api/embeddings',则保持不变 + url = f"{self.base_url.rstrip('/')}/api/embeddings" + else: + if "/v1" in self.base_url: + self.base_url = self.base_url.split("/v1")[0] + if "/api" in self.base_url: + self.base_url = self.base_url.split("/api")[0] + url = f"{self.base_url}/api/embeddings" data = { "model": self.model_name, "prompt": text diff --git a/main.spec b/main.spec index cf52c4c..2c7b79a 100644 --- a/main.spec +++ b/main.spec @@ -16,9 +16,12 @@ hiddenimports = ['typing_extensions', 'tiktoken_ext.openai_public', 'tiktoken_ext' ] + tmp_ret = collect_all('chromadb') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +customtkinter_dir = r'c:/Users/xieli/Desktop/AI_NovelGenerator/.venv/Lib/site-packages/customtkinter' +datas.append((customtkinter_dir, 'customtkinter')) a = Analysis( ['main.py'], @@ -33,6 +36,7 @@ a = Analysis( noarchive=False, optimize=0, ) + pyz = PYZ(a.pure) exe = EXE( @@ -40,7 +44,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.2.4', + name='AI_NovelGenerator_V1.3.0', debug=True, bootloader_ignore_signals=False, strip=False, @@ -53,6 +57,7 @@ exe = EXE( entitlements_file=None, icon=['icon.ico'] ) + coll = COLLECT( exe, a.binaries, @@ -60,5 +65,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.2.4' + name='AI_NovelGenerator_V1.3.0' ) From a4a22ec5565dcd8c2ae7fd7c23aa4131e9efc8bc Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 3 Feb 2025 19:38:29 +0800 Subject: [PATCH 040/142] =?UTF-8?q?=E4=B8=8D=E8=A6=81=E7=B4=A7=E7=9A=84?= =?UTF-8?q?=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __pycache__/config_manager.cpython-310.pyc | Bin 969 -> 0 bytes __pycache__/consistency_checker.cpython-310.pyc | Bin 1403 -> 0 bytes __pycache__/novel_generator.cpython-310.pyc | Bin 18027 -> 0 bytes __pycache__/prompt_definitions.cpython-310.pyc | Bin 5294 -> 0 bytes __pycache__/ui.cpython-310.pyc | Bin 12543 -> 0 bytes __pycache__/utils.cpython-310.pyc | Bin 2480 -> 0 bytes ui.py | 7 +++++-- 7 files changed, 5 insertions(+), 2 deletions(-) delete mode 100644 __pycache__/config_manager.cpython-310.pyc delete mode 100644 __pycache__/consistency_checker.cpython-310.pyc delete mode 100644 __pycache__/novel_generator.cpython-310.pyc delete mode 100644 __pycache__/prompt_definitions.cpython-310.pyc delete mode 100644 __pycache__/ui.cpython-310.pyc delete mode 100644 __pycache__/utils.cpython-310.pyc diff --git a/__pycache__/config_manager.cpython-310.pyc b/__pycache__/config_manager.cpython-310.pyc deleted file mode 100644 index 575cf01f9c3ac2e49de4b4df2c21b9b20b3a2332..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 969 zcmY*Y&rcIU6rP!#E-MABXky|)d(cA<_~T{>F&YhqOD|y1rmJj}Km|m=IZS75#DqCy6f6i22eJyX4g4MZTVL6z3U)_EieE3vZ*$h_S z1(O>)PoMda?MFP3>$tuGeST1Evz_UCoHCvswWw{S?@<0&xP|nso=nmmruJRxP-Y63rRzfvnU0yK z>~i6WA5|qYHCzeF+ofwk2*#iRFCJp7A0QdAQ>tY{7h(!CtA=PSO$JAL+?UDNB>{@? zNQ2YkNWejD72vl!Rm^5y|R3;{-Pm(nk8P|{;24l zT5q80=ETbzt4fWK{&*kr=#_5M<}_YiTCRNA+@1R#%zoRQnhmBuhnkGAh%C(%3*CEI z`UFa~P=|YrW=x*7EMK=ALLHIpOK2>r&bCPBW8w`JB%+uLrD1IX4n4dx@s7GEVM&?f`JU!j?Ojz1*K&RK>z>% diff --git a/__pycache__/consistency_checker.cpython-310.pyc b/__pycache__/consistency_checker.cpython-310.pyc deleted file mode 100644 index 792d4c92b1a4922621320a2cb554bcaeb84002f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1403 zcmZuxTTk3Z6t-t=@2+79NvmF-x|RB}Qd?EZTZKr`_97k%D5O$Ls8-H;fDOC0HJ*(^ zYNX{BAV8p1E)+JDZ7GmM1rni_L?rnU?bAGl9j|%fv1yuKjtyxyZN@rh#xuv?obQ|C zF=H`RfcDEf#qJ$h5O(~R>h=P>ya#4~2u>GtF)QT64p9_z)PbTQywIRJ|1isotM2SYclJI% zf4g#Hz#Y3%-kK_J6}-{S@|_93G`zPx$(IM+;yB>*v8#Oa?%wvC`c}Le+@JZvFwfv@ z%EpvtjP8CFLcEo)yz$$W3qQJrfxYc9KE3V@-Q$xZeCFov=#;lSP<~Ky2iEtt#{+BD`GtptqI-3*TBnDFYdeKOwQ@9QKObYU zXXvv-{~^Qbo=u$9Lcl-iv9M-kQoXp3MNVlpPUT6KsX0>zJq;~~Sq;TG3lojz31$kx z&>QK9Uk3-R26_c731)u^jtZwmM{q<3IU!w~lKLYQIf^4WvJ=)3yhmwB4>^$`BnnP+ zQF0aLdf!UP|ftE?6H z)MY5U{pxWGEBoaLI>E`iP((a8(Z)F(7_T}_Z6B3bN z2Lk_-KzvRl3@#xtIS_>Sv`N%=KIwCD^<>gC?2JvZk?u?O;B+r0pU2yXnX_oTv9U4# zB82g^671E~jSUJ12`9>zrp~?o+%Jz~+X6rwZ)j*pgxQf~YfF1``-u-)lAop8j<PTINtDF;kc!AF5ZICsNeKFc!s@e{(G4}6 zF;XVnL0TqIgVez)3juW|cV$h`$p0*0P~~3sgCY%Zp7|_pgsV%i-vn2SB!pg-9zgF^m*oYM-oOc1>`-Nl%U}$+3j~c#Rae+ll2)YTv~!VfMA}## zIxTNl2Fo^JR@;R{*%()0%f%`$;65`wGpEn=IsG{2#KSxc%9M2Gp&sb!s;((uzW?5c z$i=b?I(6dS8+VWY{`bHCc9pNM4}0+Y=BeYot5P1%SM*Z(n~j%Mc=WK>>iiJgMN$Ws-;fcv(o4{lb%z zc2JIz31{onI$r9_M6&g2eKx8_vkhtk&-0_aQElYuK&C0%tTyv>Fw>H4Ra<#l%CxC% z=u679tL=zGnOWHmwS$+1GqbaE)Hys|mzkTLr_RgXpx%(3ug=fjsNTr)Bbiusfw~~O zP+gc^q%O)XRu^ZNs7tapsW)YBR&UNORhMStYFzT9q%F7LxmByzqQf%UHmJ90jj3f? z6Q1UjFSTs8M{7wn_XM?8t!=NOcDki4DSxVYwx`FhwQI9ba=Tm7fwnE0gf_FaIVib9 z)7-LoD7ztbC;D906V~QyH}3VRcRl9OV%mbe9&JG?u*a+39a^ZR+!l+_Vlkd2cy2<= zoAE3~I-a_RkJ75$qTPyK?oE5N+ceK>lD3S8ojkl3Bpy{LkJ(%RwH~wTZ8aX?XlM+^*-%!jNFpCvq#a^YJY=o zyFYY?)}#FtxldqTPugD=)Vj2%FsC1dp2oNIYJZFJXYe?XU1!&)@1XBlkN853UoXHtp*@e*4`^vEiSo)a zzqU#1M%fBYTIY#xnWkOR757@oBk69#Ql3cbh829O&q(KU$xK|bLhE<-r8FwUOL+C> zr14Z=D)-P@GzqUWl1Az=mh9VX`PQv}=$ZAFcTLwLsMjd!tjTUlX<9nht0T|9dNa#s zlV>C=_(;CHkWJ;N$Ch=7Q@QTcdX~)TJ$aT*F=RJ&=k;_hmC)1KbSBBt#?H86g;*-7 zC3@1Cl+}{#!z{Ffk$S;M7GiqTlBL;{t|xo(5|=F>FSGmv*`#6BZRgX}G1Zh6NcN=@TT?r& z;HIRWN)%Ye@)1CF%g<6qf#teEEZv_UDIcEIc=Y=a7#`DON}4n*4|p|M^X~PS@*|!- z(tu(}TVzu)y@pp)hP?wm(>J1wf?S$Dpq#JpE*ghp_!Wmv6i>fgJorZO!e>)gjuwyo zYGQnFV*ED(Q;J7kncjc6c=5I0Up`no|Kh~uk>b8nlke;;y|KS^{KKg$$EHRP7C$|R zOf;Xka-w+lt>Vym6cvx3pFDQ1_{HmA4el1>yuZ8jtK$>n!&K$?2UBCGL^(R1xctu4 z{@)Z|`k**;ta$07)8dS*M)Sb1WIAC3No}ZjCwO&B_zdSKBSFAfhv(~d1ib*xbAbT~ zK*ISlEWILclh=99NM|H9(uc)y5R^qw8S5l>-eF0`84tS!?|*)vd-=v^^%T=LzK~93 z(i06C_-^Pd5Cb|gU9!up?EQv+jDA2FhdGGAoX@ys0u!*~Y+$-;<>CoKTJ^f44 zxwMfG>yHfq1n69m$#*9+`bs367I9e(rJQYRHT7W*C@SN3+{fsoWpxPDXb-kfLbG?X zZZ-aF&Fb|}J(F06U`^tYwa-`qyMwr&#i-c=3Kk-;!tN-#8gjc}H&O+Hfe3D;s!dnT zoGR!Y2xOlu$&&xusG=a|>2HEQMPhfMu$#Wz`4K8BcsTklMZ)lGkw#?0J1XfxqJ@US z(OCvM%ZAVJnn3ActXW(sJb>y`W0#7D-za|mk-#C~p^KkDnyHshx#(3KdRriKtSg^O zp|FcBMh9`3bs|!m-49Rn6mr|!@#j`4s1 zR3MY@?L`$!N#{UB39nc^0;@i$;h1%BpWQ)id=-myKUMPavDE-U8+B_Jt;IeSDwpk1 zw9^|Agt2U4DJmh9BT__Sw4l?3)0S^rA;orD($;QTD(4qK!d<$D@!~F>Vk&yHymYh_ zrm_VnZh8sH2Yi|`qG&$N4;tanO!KD?C~lxL9XJp4yIcFO%VnCzzFdU%K1! z;q(JH)0`{d+yV#HI`fVVjl|NSwkpuek3Flmq%-<&jUi!5orcANyDKCf-i$!5XldMW+(?FO#y$0gA$^U zUkmJ24tTVnp=cpGJdrCyG*tF`_Demo7S`(aMg~IHERSgQC=Xw=JgPOIyzZLijan1R zBiAf%)>=?rZ}<%b{AtvTjs&z;Gc<~?;^{Us?55kzIyXJbjJW9zv))bzjgZ#rq)9Ax z(`{P2n}$&Are|p#Zo0#8#;P0j3^bTtaLPW@ugyNL3^bYzBN4L^)VIlO(*K81KN8jE z&|$011;uU}Y0&0rH;l>y%{)Dyr(4Ws_P@*){eSb?H#)Vkc8w!VSiKnPx4Lf&@Ybd+ z#L9*E$}Q3s?^Ooc&33aHkeP+J1#ySbY|b)U_t2_0n;m8wPt}jKXiJ>_<2Mz)0|zWl zhA*z*lrElpr*!sI>C;`4AH2-Z?RQHFW2Fy9i@$ogcw~6;^!dtmoB}zMfk1Qq@YL8R6XTbr-+QaF8mCaT)h2&;34K;p zyPAS6kJ&8J>s7;6kTvSHIQ%-QL8d8t(;K>si5X|tMN0%i|_Ml>q(sF3{@h(^^9Wq1ObYHf{2r815%dX5w%%L z?*rL$rJpJeola0!hN9!v@QgsbG(E?_JDvca4rHVpuPK#tGzABPudoW$A=#AinLmvDcsLkA^ph z^4Q9iE1!$8htX+&!v?2nY{iNd&&9gV$ozz6KSn9i&m&?)rz?m!ms&zvYEz*%0rv3_ zO1~z?tXXcC`tKCDUvr2gY!R+PD0ABr7YSVfTZAsEco-toM6g@%=){F^f(@#!S?z2mJVpaG^C&*&eL!4(T3Loro6>R+&h)T zLtQf+EN7INa{CuK-;>}Q(cb^dvGHRL5_GaBF=e(20hk#&<=A`#mOq`_p5K}hP=17R z0**EZfMDsqGhWt9wV$W5pwk`JB1O3g3fyDVYMXH)ub~nbAQj2){QV{DCq5?#Ry!*7 zKlpu@X~zDqS|(a5TBfh*SS~LUPFAUAQ9AgdaCFzelV7zc5V9&3#oxuD2$fmAz$hEi z{Y%|>UB%+mtU?!Ngx&8(2l*{~H4Pc%^O9wEd)A>&Tre%gv zKBTU61pN>E;O{Tb^{Nji(#Z({ao}YPt0V+m8&^#N=5uWGaS{M7?ka5aS7BRFU>k=i zBk({i!8b9-A}pD@@UaH8Pot^>+F6wT;W*bm^FW`YH=S0?-Pzv=`NiJXE59$y zXYT$guoGS9V5d*21v?k6s{uZj16<(%hroRmzy!0B^0AGYw!R1YhCx18f$tiG{Hjr0Q-vltuP6%E{)cO78 zcP1}iES`S_d@1Yz)dFD*Yz7odRb~Km+TSE*SHb%Bw}>ovv54=rya=k$3T!pk z;cI$EaHwnYsEio8_E8zfvC{QtX%SyqOS%R0KfqFbnPhh=F0mc-A)GpwNpzTuBROs` z`48%sxUE*H|E}wUu&k#jH}AiPw~5&wrpJ;wSnnJINX%JIEv6T`yJ3pxDP%G`JL9eF zZVYJ!v&pR~)YchsF6>zfNRVYTAr?TO21&D)gCr|bDXllff>cVvDlr4uKFG2)CzR!d zR)gI`)z(nZL;>+=@!2d!=>-)0f`S>uW2RC8F{{_aI@Q<9EnQxip43P>*H?fEiDVLP zF3}4agKtl@trA6Cu&5DY!?}(Ca8o)eTT zl1W7rL3xm;qTZ;~>?IxS z9_bO!b2mXP4$Ihf54+uvOfckR7t_33eQYV@e|f9IZZv%Z^0qKEEMC*s>bc*8UeE(_ z@NUYjo>?Ax3wcBxDmRHSR|>@Yb9tN$uyq@6R888y;fU<{VnL;?DCHDF}>65Ep* zOCfg5@*-JFsGyP)V1~68+iO$0yB?$K#2_d*UkJw>O_*PfD1IsGRlGsy%Gs+lY-`Pi z;uOz{h+JC}7>2DGCO94T+$!5g13DmF;R^{z3@qJ<2TB@mFP8K@*fr9$XkIMlL!=%b zkx+(MZ`&5ZatBNWC3g%24H>HSpy@{pWhV@vAv46*nxPS>*&%s_c?v8SZ(nD~BVNr< z3MlrVff(wSNKP>VB4yTL=_62p1_t1Nl9RU0HUsR*ZINw}UJn#nL9)PfaG7luJWpnM z_Q;sS5>j!QKA_qlVri?)bhCa0>NhhA$vK1`qq8AZpWs}`whg>2e62Dzq}7dtq5J}> zX%V1Xec}HCMjS85B*0Z3LVq9>{=hIotiwlOFDo6~Up)7YK&;u=l{pmcPsbZ{56R+FcPu?MD3@1i!sA~kZkc=@nXG43xc!WaK? z^1}}|>bET2uu*&N);LnAyJ0z4gXexi*`2q<7Z-?oaO;84fyRNnVMY@d4^E6900Eo0 zc(l0h?8Nw<;=bQbTsTo2-UZSFf`hGk&0ZH$ud9WzZliwd(v2D)zjp8&M78VGnDH}U zfvfFl=6Yi%YU(`-w!_zxN%rc3Hav(46z-*2Z1~~dzIc6s_TN{7N5t}eHTVhuULfKM zKm*L28Y7GAWdu)K2TzW^Oq#n)=v+Ltpap;yDrvw@5?=dQ7Yxy>A6mBtMgo>E^l=iw zevU3JAIC4tFC0lY&k%25uc3$?rQmg5i93NEq#LqWgN6a<+hyDx~N)Ql9F5+e%ObD4-GO1@SaaeRNLD08@JVV*QcmIuW# z&fX&stU_Q#1?6;@J*&;8q}6PT73PXRZUnLFGD-L!h?!dT1^6)}dJFJGf|pNs9r|>B z^N=9TfESP6Dcj5VsT|tC=*|7MU86~BhFziA42}dw zJ*>w>%4~wEyd@`_O#>~wO-oH1a-)k3wAyV3O{C05v}r?|#(_4Fbh66AdZ2YcVb^Yi zK;nJmNQZ-DmIm=rn}_EHJoAmPb|c~#9;ov8%o^>PEz}laln&_bJ!YHP0+QbjjeclP zU|@DtAB)j$$qfB8+x^_6-Hd+bm~-rYaQxUk1qbGubB{ zwoC9m6}E@vJSP^rGaf4)`vhJFw+r8%;=T`xzZ)#Q^48S*yX-1=xmAR;H+A~esRJKP zzVumXa2FkBqrbK*-W`t#Q?>9wu#cfH2TRA_ofvq%9s90+Uu6`h4=z+r|AC z(ZTe4CyFQesXKNVN2}e(edy!Gx8Yq8bLhw4SMoyxG`rhuV8v+jI&_8#>^|;yK;@e3 ztL!q>RKhiNkW;{=Axv!Ii{r)Dc1@l866^rY-?8jFleEn{GZL5Owp~Rq8G=jTvzpl^ z6cfJ+i_cH5YZ|Jq|1epHo_*$t$^pu>&HXSmx}IIA61?R$;$Q>%32bxOjpE_YD~GEw z*x2*K!oyZ$TW=hTO=YthqmbR~`nI78N?o2NEObC=&QQI$uXyeP){FZ$k@BQs;cS+y z^yY|Lzh*V7NWa^^K-uEhg-SNA26v%SDed{V^zrMJA6R3IvOiG3vQ)N7Ef>ylw*X?3WZgPeBsFHO*9P6G~xzBDKQzF+breW44Kjte}o% z3GD5}msjBBGbdnVvK_Ljv#WKgFjmb*+v{sm;|d>u zouQh~Q;?;A>t)JHxwaXH`y6ZaHQB?2+n(Rnt3vVx6Gt9Kw&vG?;Y{l{W5-sA1DaO4 z3Z*L{%zlG`%MfDc@Y=uF?yhEeuY$`Q`o`<3@*TKPx7UVC`^@A0Ga>O=n$QD31PC>= zE=14+e2u3^zbqa&UD~sck=()_qF^@yC{T&{XWI~1VQMTk2m38$@J&E2MOHmfmn9t^ zFDvRY;t41)3@hwXzf?DAqnsbkl8Ppe5T5h=EHed#AR7Y&1dVtd=PTGh7!t>G=)ej$mI4$IHL69q12SE^V z226eWIh+NEb8yMlSw}3U{{GO0uLy@Bw25vV*UVSK6(yWSAx*&8We9cQKad@Qw<#Wa zv3U3+mw}=};0K302~d@kgae;TX8kufKJutSGFCMf3c)bI=3Hl> zDENqik16;B0o=f=Jo0DU#tlv#dq!UYPj%Yma+D-2{>bvTP_TYWD$_8eGALR96s{ww z%-+`bd@qcy8ORFQ8J4mGDA`TQE9VzRg7A*x8J%Yalvo14IGzQn@C?|;xo`zGLf=4; zV_FcH2Gx}g8G*b*xycGXp>Pohnjst05IdOG#XiL->@y0;3I}gkT1qxf0l~K{72C7s zo@xDa#odPMt|p0!e`PhpwX1oN+FYWbc1dYK_*UANEM{EN`l?9 z{xKY>Skk+`*OFEZ)E`_ekUG}iaV<+qe~VKB7s6Oi5_ScxGv36_E6k+tCGsLHD`k5~ zBjs(S;4uo`qkt44jPQez7|#8R%f2n#?jN7WeoaLuDTq<9fPzyLa5V|Hn><0U?^AG^ z0y3jO?`yLSba%jgNF1 zVdh>38MYI>pnVfvQe}h*M*UH#U5-L?6D7TkG|wB1L?lIaVi~#}|94RzH^h8r)0b~# zMg9veM*Kh7FaKE%$p0Y+8~-HVh?V5b+nQ@#x195cdWjB=BfDR4E`V&|pIs z&3QI{ivXq$C|tnJ5n<7x;6NSW4g<3%^x|HWDo~T)h=ke%Mil*i)CrMp9SVdn<(rCg zC&6}Vhh`qF{HAZD&gA}1NJZ#Y0&j^U)xMt)EF8oX`Ef}{B|o{Q2M>@A+qrmT%VA@e zYi=tL6MyLx_;?5ckhUT4mfrkKs0*AE6H+^!xW_+ub}gA<4j-BL@>uEMf$3Mzl}?<3 z7aEOs_GEGY7ckE_Z3@IwO}=|-YV1Yabr6HQHy&{G6^PxcDR+)<64=mB|4*yD=D*)X z2sqZW*w$Qr2dGPLDwfXn<(Yxo5#7L5_eX3x+2GdUVM{2uiGm0MaM()`SyErEIC2}Z zHMC^Qkm!$#o)hj+e3zF6k@D4~0@bhm($7DFs8oFEUGNrIkE#71yXpyic)VT^MNS3p zMe7QOCYK&s?MV&J`rX*%T$k+L%({;n{Fs8fC|FLx9SEvC@Bfi8 zvq!0jxBwB{ij>thq`O2Om>R~BeWu{F5?>YjlRHfpdJnoBc8HTTF= zkJk;GuyDfa*<}Vsg3#`woVFi$3SJjna9}Qa+;%GrY(XtJ5;Czpc@17yj7qI|odDZX zTMskDCWjepHuSxIXeL2mU|rn}(h$J~r4a7B)SGo61Tbkyo^3g#gYfgV(-05Cg_8h{ zLjW~hhD^fpWOQy>`kSS3#H+6Q)6c*E*!uOn2F1m;T>yV zPdqXNuV&PQ9?Y#-UK>#frXhHyicV?Q^MP&EmVKB&19s?LT$QxvQdU>k)n-BhxDFp1 zpWcmIYkbapCLm%p^PGNbs5l5!A#PY6h5~}f&){X;!NI-A;$_Tv4Ej?dedj)%I`T#3 z%*tP}JOfGq$IkG!XeYOt@4u09^7~~9c z9Df-WYd}>fEnOv!fJ^bz7w#;ILvO+u$mgQ^`U<(N#(J_tZ7ZZxM*l3@g?*bDw0O&& z#J#~Mo?Q0Ceap7rRkmRF0{`i^>d#UPqTYW;G9Iy%T*la{2DSW-99^VS>vghO8}LKT z!Ai_%Qu!iy)1OeH!)82b@mk+m)wRlwcPyRQmNdu}O z>YlDW>%w34PReY;-REAAp)w&+n;h%8Tfdy;-XpZ6wCV*4;65hzPSR7obh}ztaiNeU z)rel$1P6SPS3{84f5E_8i2M+ZjMhUOfpLhAd4&_4W|@pbQMp-e#5?7JK!NEXKM(xL zikJ6bDWu1#(kT#`anJqrc)_-mHGZxOmx!$%wiJ#K@aCQc!b46HIL+w}CN4zmgYPm< zb{XWzH{u-?1VSO6z-g<8O>hHoxj=$N^;QMGP3}8g#!s=!9Q2|?dV1HD;=W;lufRfJ zs&m>-U3nX~4(X6~Fm_`6B2=goUkr#>fDr5Aiv0PW&q(fgnL=!GJ18k{OA#_JSIqEAKpE zm)5T4zo7joT212T62c?`Hh`@5TS(EmY1?=u^BM~R^3|8EUaNH&(tPSF~N6&PM4mD{m=D+gHPuY z;!ZUeSkwl}<_Bme54kf~+3}0tg;immaN5ETEK5!$J3GbAI)2qs7dMmVy45=AE*!t@ z?A+Dl*EiWeW2X2=A7EG2HfM7spwDss34zY{yxO5}rGF}r>&(KtEZwbl_9fG7M;bOw z+WVHTV5BoTL^V0aqvzrzi9l z^N>>O;5nb}UY*bNq;dOnZP%kui95m1QZ>$!a1Lb!y>dy83wDp=m5lOvn4))4(IE<6 zrQkgZh*#%K1mCzEBPy_nvn!la39f+E1J~GA;wbd!_)jPv591DKyjwpw}m7j$X{10vp+FmUnk=R3XkpEq-lm8$`!vE&^hdJxy OMY2@x%a6Wd5Bz@#`iOJ@ diff --git a/__pycache__/prompt_definitions.cpython-310.pyc b/__pycache__/prompt_definitions.cpython-310.pyc deleted file mode 100644 index 44af9b959844b16765b425e8480bcb518f4c74af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5294 zcmbtYS#KNH6_#yz3DYzQkfu*9_!tAV>LqClBQRQ^Xpx5o1%kc^1_)WCT_Lh4P*NRN z5HMOukrX#sqE>35EQz!&E?O*c5yd~yU*N}h?wuh})b9lf^gH*=P?VFl549md9Ny*J zbH498-yQFJ?>XA=@3R}J2VecDt?e~^X#IB%AAXIWfuG@*yREa$eXjGI`+Vnl_l3?2 z?)J`h_r=bO?n|AQ+?P8qyRUR!alg~~j@$87+tqi6eupWJ#&SR}U9ZH9>Y85CjC|Ti1jLhwUeC+f@@s9(G4!_!ZEa@Z zNUoP-}S#kx!uZx8nkJ-p}fxxDvX z-GA=tAN2SJ=v!Hc8!y&HZeERf9kC%uR@5+$}T7DZzE&2dm{X3WV0 zGcqog4@7id#4$5O3*WRS^GWnlOsrMR;yBIO9>emD@tP=&(NUk(&BHLQ9$e5XAtM*! zDU`$NmWYSVT2-!`(8yrG*qNr0OY@EDG)CHMsx5C}%fO;uOEsRxp-7&I5~NG zY0gMS)X<=uKWxk&nBx(-SU_ANRYaYQSV3%zQ@ooe`tg!cosp{(^f5Cn!Y+R%M*HeO3wRFH~0mT7F?0JEn{f{c`(CcA|H~mCxD0? z-^WgXrctTW;X(!eL$7Qa)k(^}f4?!Cm)eSXWS9MyEz9YZiV=E-LB@U+^~XXuCUR9Vl8t@Db z_Vhg%q0sc&Iun&?$n~Z|mhvL}Oa`a*>W&1-wrYg3>Vb@mS_!Ugn7e6+3pQ*d>t-!$ z*2^}3gY!5SBZyN?IiK7Y#pzpm={Yh>JCqMap$rMZ6RSM+WiuV&)JQD@G8v(=I2o1k zI1*>m`k*Jea0 z$`Gf3j`pyZ5i4?GIm1W>tA;`Yq}yqAh^a2&17TWGTVvpo-Ua#Z1)7ol2rFsG8zl?ro7 z6LxDRdMO~W;$RH5jzkbaplJ|46gk)~05T9$WD*Y%3jB-(DzlI?vl@zQB>f6YQEs?@ zz=7S~M}5#mbQpYNEFT~Mt88Wxf4sKY8XzE{UIA}AFUHt@5MT=Q-7&|*R5QY3!iW!0?3VLm8+Qgpx1j3 z6&@IR_^`|Om?lFFNK&Hap&w#$QvC<+ZihoO?OuTYJpb$ROgsU1XzV8zBN zOAp4wDA1FZK15N{)E-hD(9yre%VB$(uRN!kiSV{Z8(O@P+9M|IYATcT z5HX9&HpHJSSL!+4HRwqJ`j5^lBCT zj0~5h-{~a@Mf6G!$0^vXAu@voOk3WhKQvbfmLJz^G>6U&L_|7MCVFmtwN(twu!#kjy4Eli*xZ?o{4lW`A{ z8^?1ZeuP1gA;_un>DFDEgj9iyj36$%#Q--Dq|2#9t93BOIuW#)Pjh=iioq7#myOCE zR#y&7Q5Un{y7n*_-W;!~TlCCxxFiMuZj)>!@f|#doWRS47*%9_l5tCO=o+j%Fz|(~ z$$!8_Z;~~Uu`D%2WvU?^yr}IQ*9dexlPF$1fscav*j8%I;ovq09NYj>ZsO+_em)hr zmD4>+c|-LIfPgimQ^8BruTXHau!VeZw(!aXZtb+f&DRY7vBXU#{+AllssBfj$@i;_ zH;d2|a_`U}85m2B1B=M=DIg%l%9?=L0Jl{;ZlBhA>b(mj9M>vb|D40EFJPQ}hon-H z$ZwBYm-b+QI?DPa-ur-GoLbAosmmzuMyaHusp~$wAaIfr$(QT;xVnCh8tR>cEH2|c zjJooY1!qk#5BJ698g7P=XE8{=7&kNX0!f_pfZ8(3i#H)}gGOaUsJhWie+ zQ!%>Kn5l@>7qr}$R~@e|yIgK>x6Ad)0mta-SMR`HeQ?S{>&b_wPSGCpV`X@H{_3&s z!uA&*GxOX%K2P_c*Z0_d{zI#C+MjayZ z*&N`rHs8B=!KZ%x^G^5eyMG$M`?b5@^muxE?*7&@@HJk~-TloMt}lt_zxVWcd|e3b zE>Cv3Jvd9xV2`(Nprilst1F*B^ty+7J$Jx^wtV!Z diff --git a/__pycache__/ui.cpython-310.pyc b/__pycache__/ui.cpython-310.pyc deleted file mode 100644 index af2051ec73ac7222b967bcd9c1506bb51982b46d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12543 zcmb7KYj9lUdERsO?ER{XZDEYPU;|z|78poFR0&!5gD-j(#HWF}3MVW!aMeZR9u zdy!hA)p^hPZtr(F@Atfy?-U}DpbLNhee>Cod;Z+z`ZXn*|J+F2j;r`6i0;yroGT^o zN=ijq%_-yVlzUuDY2%)hXWX0eDze;{@}bG#*Zc`Pq|;j7L+^ z@mMN09#6$N&ztKS?@o0quDe~jPxl{o>Ha6wRFAGa;7SITF$ULQQnTEqdC&?DXLE+0 z&EyIrR=7AM3OOWA7hb?I6Gp6ikU|YJCRhZfG|)- zjFN6^^I+i-BbUxI)w9AFG7DlVJ!0eykueSH^2SqoXf!in8X`TJHAmA$lM2^m$0rKH zOdre_CNYi?LvmHDuAxz5=pc1j%oa@}KQuK&(`f#47P*XE!i2v)nkXXR8WuWUC0gT-Fgr1VZB#hhkHa{ zuW!ITs`u%a;U3dB>X+jl*EeB-udup!3GS9NE{MAyymv@#8YvA_!*wUF;wg}dQgNkS z71x-ns_4p`f_JDQM>XBZQ=gzx&Gd|UD+*0xR-INW>X?tFqWfl5#btV`{)#$B^#Ujf zqE$fm>j7CdhB7^l2Otq>}PYWOO~7A6ZsVEg7}E;~BhfVR_7~nKLZATNVr9MHMST zf@w5FQ_wk!hW=1CuNNjQZ6Y&t(DK|RGUJ97%o)RGdYGTx=*bER8ik$Hb)b~Z z>1nQ$%S;tYrqwO8WG$HoA=ipVZdj~CU)YtIY(9;d8S^f26>>iB9~yZ0!J;9G4kcbXcT@?x<4PQt z-yK%V8(SIRo%A;v57B}({%DPTxLCx#zyk@_7=%k#bvKEHomN}Yyjx_s=^oRlj*cAz zfm15Vm$t}Uzk@c zjbTsA7mdkf4~ml>%Pk;4-l0OSG@iG-MKe2eaEb&>#oeguAStePi(#)!LIsYUe*lX$Nv8K&IGDRc-)D1&0g57!ie1UQY(a zCS+M*3_RGf-Z$NqjMzpnc z?#QKgeGR?qX|1B2Qp9emS?$C4E~{uQypQ;?ci}@~iCDa+eP@mHsJ8w>qUHca2hs54YI!+J$-fZN9#-%h8KOgl$bT*{1Kb z-@vYpnMiJbW^=pMUZu6=^lFUjYRu=>R!;hD(_Zu_*X4H9xrW9LxxN<9*TVKAB$In> zkABB2`~#=Fab8q5X#?*kgn)0@TSc;MKG%3ojQNb=Eq+l#xW+IzD zXiQly*p;3~GSHXH2^7E~36m;&BqOq!1380LsHIGn19xPKM&iNm-ZxELL$c2F#9uQ=zSs`;JYjK$W>bHIF?!RGp1jYPkKm6vlq?Ymwi^6!?QsEEL z>RA59=u_&XF=RL38`&zP|2E(%k`;gzm%2%TGz2A0_~2o7HyZG^W7+Wi5AxS&e zs2#5^ef+Vk(KMIZ$@d22aM^6EpEz1u_-J4mV{0Po=`9tMwqp^#ifG+ur3Cog(BM;>eGY50y>{vsORxQC>8W=X zkH1@=c`R8XnZL0=aq0b;rK1<>Gq2Uo&(*7QC6Z+ft9Inv;w$Ht7M`qq_)N0j@`y}+ z#1Ld5ikpahod|C+4_m9^8_2+BIA{ePFal=J~JZ^fE|EHa~Ehf}w-V`C)@FFjJq>a;Im)%ReG99T&YmvVO4#p(hx zC*r~d);gzNgC#fbZK-J&S-pCpIn;tpgy4&{j_P*AVXVH6n9V4*05I8N=Iy0PALERr zWK%&#mLk0&@fTQ-R7AcB-5AcmFG+!6#I^~y%I7tVE5O~7GU4~NCOX6XSO?h5B4;sO# z=d<(R137tqI}f(4lNYe_5G!)>f_5HzM|QsxJmx6Z zG?m$1?V*qwwP4NFJt+PRa~HP~p`{S}h}=cwZXyp7d58$Xw@4BBDyZE(^dfuuEA0)K!9yhFqv=jg85cej1l?7N5JlO{qtb86Mk zH3N1@(7uQ4dzkLJdNwevm=TI^jzynP#cgJ+8mAbhlo`l|$B~U#wVQ%Vj%@e`#Sx4u z8IU5YQ!XZnRn6@f-^=wisKl|gQg@qDD z_nI|#?bWxJE}SFz=wYc?RUqi)ue@~2Q-o@p5UYs^?S3g@Bo8S+$wM}e_|H?63{Vn! zq97W=(TIG)B+Il5$sshkfQyC1ulg1Gbu*<-B26b}hZbf5ufLi?$c9qP(ti?7)JKfi@QVw`6P~!0IwN ztlF^k=hZ=Sg!r%omgIe?#G|tOd+&W<&mH&e+dJRD5-97oQCU$vTO@BOeSmF}e?jZt z(&}J%iO%7*R$g*`n$Bi}TPf2A7Ffvom^TWYq5 zCaqNC`t3F(IGRMqJ$_&gbSa&d%qkRls;Ydlp}|-3l*sau_Hym5lS>O1+N|aw6gz)} zk7R6<3tQX^uk0AG7`_;beEHe(2%gy^t7>zuX)1w+o`WDEMUh14W;=zL*%-7k(9=H# zUCT8n<^^qQ*Puuy^ekFZL?O&iUOYvd8a||=obRvrir-@_MI4mG_aXc+Hj2tQ+Y#w* zAT2Bhv!_C5Olrkl(JG#bx8ke#5poYG)CQjqHrIOK<+H_QT^0*h>rV)s7rno_VVN z^t{Afsgb4cRiam1`)H>2+S8qHulA!8(&r&xtVv-Yq@5d2v!B3r0&9#^xAgJSh2a(N za7BUKg?>t8WBlsukwmkIIGx!2pFaVg3+kZ$%DKg}Ps^n4MOqkh19%m11m)%Ka`-rTmZ)v&2EkxKLcOVd_DZu}KM?vu( zkT5Wu&Ih8fUZP46KcbpS`SI@HZ&ao8=ly*&6je4U{u%X4^~<=Xez93C-`ELMj%Tyt z0jFBp$bc%$d3g>ZPGh`iz(o?ecxqc|k`<7D z!&UTxgw^oYuu{JID-CH)@DeYA6@ZB>z1pj%>u;Wu0w%*5pM7+C@#2O0@mFg{eqMWf zrvAehmwxux&Su9PyQDt%)6YISQ9Jd+<)bI-Kb={;@ROxaj?|9NQ|1TnE?#`5cIuei zAeTO!t3S3tqWtX7>&M=c5d}zO3+%-)5(<(q?ERq~_WrZ{4}h6J3ibeM1a@#in!pau zhYfZNf(U7#R$^^X9eXs!P>s6-)h%;eZPrz%2B11N___R;p&8i9JuL8Kx5JQtG8TCX6IdS)+MN&ut8(oPzCH{q&61cc%zgAXNvyuDQtV3OKzw7nG5yj-%64`30cuRf6QWId(m1a(~>%4X@g8F zY}ev|B0H??zv4?8EL0=X5^>@=^jQUaz9sSuNcqrE6U{?yQ#_48PN@r(q{eyg9XW7X zzwN}PvHW!EY&T~&MtZ<<1*kIlpDPMULXRwM)e@n>~SDDN%LDD0R5h2f6kcTRM43dnuEwp%zirD!}(GzJT ztEcsq@%JLBjjO&lF0;Ct#aDh_a*nwDjL6T4%oE>mW6>#kwwm5!?ST#JDvqK4irSM3 z4-}E4sABtQDmnD=tNj1L_bt7by46lKD095O)1rNJ(2ieF)n=lIJ+We(iMv@betnRH z^zNpQj`|(N*gWV~)hNzjl!M|Wn&2D}R^Ti8?os4@iL0US2B7b*{fa&PzZ!2Z7+?DM zQ>daEhm!YTz-DdXgZkMSY(6Q3OP`)unme(0eg;u#C-MyGtv&h9;_Ht=*}?p3QC=}H zB>`gPHPNoTUN27rSpP`nMe1TjflX^>0CqdToE4SFNs*%RIw?|AJ|~5U2b5U2;(?k9 z;O=Gh6=pRSLYmZAxDsZ?GzzH4CoxTm3AT8PT_2G)yBVtlSp9@;1%y-1S^qStsfg~U z_ViR~RvPiCm!UMGw$g~`fl9PZX+$55veLlaQ5r4%v(jkEK`%}kN+UQZeu@`R?t$1L z)J~uHvBOFbRqh_i6%J%_>0${dC1Q&5#H*-Xx&y?9tLCl9xOVCN4;SY@1kl${KaRj% z?c{=d@r$PqODQ4HKKlj%8J!?VH^Gq>DO?2Qek;;~3;3L-b%<89b$DU{?Aq~$07mK+ zmRgus^_S1WPUDCXfvD`T892Kna6U%dhKa0Zz%ypDiT*lO`2~?Th_s<)nDQ73W9>s@ z6r}NqH$hUpZPSx5*wZ>k33lhH(OX2`Ch`t7Z^cADFYwAwhSo&B+X?vyK30&A41I}u z4(cFkAo zr~}kv72De}<+73SWr}hAxunGu15I20$*WhLcA6$>R7@Tmo!AYqG z8l#~2BfzOGr#TI9@(h-)!007Jth%u9x7{oIO0EaK@5Gre=|(opS{GPysWsvO{Nfk} zS`S5=@gOMi;0Z94W)MZ0Rz(Kd=j^B_72b67YAWJHPU%&(o!YbgQ6iD1(Xx9D*E)=! zs(Ak=>gjN(Z|)TL_9MHreW176-a>$sggzC6pIw+R@SQ;pF}ra?yi5asg-Dgi91%7y zF3{6fB9DXQX{Z$J)L{B}74H|ChHqYD82D%99bbKDgUK%Xj+S~(r>$U`f<7fgI@4A- zo&J6)lanQ48}++|NP-CY+=8O#ViE-BJf@KE<5|jM+mt+hL1vJcAwpwrif8hScrxTL zlObN*1mZ`~+JBjUga2>*z5ec~*YEMW@!RgdEquM8nmA)3khgs1!E7EHQd++%vUoY= z=`=@bXdP2g`y><}7vPJ0A}zJlPejv!-5kbI?jz@_o| nygbMSE7m47q}66+`EQX6NgyfT6Pd1kI8=xuc7vl-Pq+HNggPR& diff --git a/__pycache__/utils.cpython-310.pyc b/__pycache__/utils.cpython-310.pyc deleted file mode 100644 index 1304809b7b3a238b6aa3aab1bd4672d584264ada..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2480 zcmai0?QaxC7@wKjdu@BP1)?D0p+P(oOz;z;@g+zNemG*PK}*TyyxGFyb@wtmOJ8zF z2rq3#YGYE0^hH|}Nl{)JO~_HI{Xh0A-Rt#Jzw-fnp4sbprA7C0v$N0aGc))6UgoT( zrWk?i%L9|A9)t;bgb(#s2p&F!uWdrZ38y*I;ooToj>}=M8YZ( z;ZcZa_)Zt&G2Q@ALEZ@C7}0nWk3%HHx9DNafO#TaA>PbeAR6Vgog`aVU`>*8BV9&L zH#6P38xp#7gqfu{P`xy`<8L?&Uwa6e{)8k2*eQoOhXW(=A}!EqIzx)=IN_Q`G;%RO zNdcbP$pK>T=%JM0=PUU7QH@B}_vTuZ2iqY>2Ko+DQiRO@xYT&45SSoS>>}hIhfJ;& z1Lp$xrr+zKJtP@+K7;L4?mhHI#>!)ZOAqfak6-dG&#qjY^#(3`cOR@ST&;Zft3R|< z8usq|=uOU+hsL~x3tp+@4c(|Lj(QV6E#I2=?p$BKeRHYw$D{KXTq+(19oe=2;4SJ# zbTeyl!#tI8iIaroP6$22(^#>SiTb6>*YA5HKP->_yfS*Da_`=uL^9}VR$ez<4Kv)9 zHb_ZXvdwZ#enbdMxX~lMSv@Ze%XEWzVVE+h2?k5J8cy$qvz94!Q@T{YMMMh>JU);; z)cJ+23%j$|&~rxT$GZKEwDO%F9ZjdKvwH3m-PA=!TB6gDM$UdK-{(ea`$ekRyP#pL znZ{`BX^1sH39&{RqH*b){8t#l?2zks_@+)z57k*Bk`!L@50HNJ;v zfry3znFeyqP`iQC0(pTHN&g-R6hXRh4G6QP$O>$l708SNmj~A<1M~rafiipRDdR^K zLE8RQLNPvdMC1)!gbmtbI>&;t;t30=ZN|E_gHj}>XXz_G8;+Wrh$5Cybo-#dU7 z139-y#)AbiK|s_DM6K0{nw?`f3y2zeRm^Z><8fZ>CJ?(nuT>{f_$T%`SigMwC8*5pc^*J=8BZ zxTz|4$ax!<_vQv_4!E>91xC44nor1HnedChx-e9^Ij}r2<_+9fUHBGI5NIUzIN&rV z?95p`ZAaGhsGlnQn~0|-QwXMIouMB~wC00}G4 zLO@hc0TOfez%~Go^0m40@Lj)p-q2j))AnPjDjXZ@-?oBYoMdjJZrYB}(-}K!7;eZg zIh-J=&z*xX?8AdKKyc33mRUnbtp%&#BL6})I1h&zo@Bl|X_x{_wgT&jcyPDZ)|0)jq!%o$j-<=w~o&_ diff --git a/ui.py b/ui.py index 785610d..715266e 100644 --- a/ui.py +++ b/ui.py @@ -300,10 +300,13 @@ class NovelGeneratorGUI: def on_interface_format_changed(new_value): if new_value == "Ollama": self.base_url_var.set("http://localhost:11434/v1") + self.embedding_url_var.set("http://localhost:11434/api") elif new_value == "ML Studio": - self.base_url_var.set("http://localhost:1234") + self.base_url_var.set("http://localhost:1234/v1") + self.embedding_url_var.set("http://localhost:1234/api") elif new_value == "OpenAI": - self.base_url_var.set("https://api.agicto.cn") + self.base_url_var.set("https://api.openai.com/v1") + self.embedding_url_var.set("https://api.openai.com/v1") for i in range(5): self.ai_config_tab.grid_rowconfigure(i, weight=0) From 90b7b771477a5e88c1672210e02297ee18d660e3 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 3 Feb 2025 19:52:14 +0800 Subject: [PATCH 041/142] fixed #29 --- ui.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ui.py b/ui.py index 715266e..cb87419 100644 --- a/ui.py +++ b/ui.py @@ -43,10 +43,8 @@ class NovelGeneratorGUI: except Exception: pass - # 窗口最大化 - self.master.state("zoomed") # 配置窗口大小 - self.master.geometry("1428x960") + self.master.geometry("1350x840") # 配置持久化 self.config_file = "config.json" @@ -458,7 +456,10 @@ class NovelGeneratorGUI: """ 右侧下方区域: 输入主题, 类型, 章节数, 字数, 保存路径, 指导信息等 """ - self.params_frame = ctk.CTkFrame(self.right_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) From dd871741ed677bed383014af52216fdd1a00fc50 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 3 Feb 2025 20:53:37 +0800 Subject: [PATCH 042/142] =?UTF-8?q?=E7=A6=81=E7=94=A8=E9=81=A5=E6=B5=8B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD(Telemetry)=20#25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在调用 Chroma 时,传入 client_settings=Settings(anonymized_telemetry=False) --- novel_generator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index 0fcadfd..f394186 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -10,6 +10,7 @@ from typing import List, Optional from langchain_openai import ChatOpenAI from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma +from chromadb.config import Settings from langchain.docstore.document import Document # nltk、sentence_transformers 及文本处理相关 @@ -185,7 +186,8 @@ def init_vector_store( vectorstore = Chroma.from_documents( documents, embedding=embeddings, - persist_directory=VECTOR_STORE_DIR + persist_directory=VECTOR_STORE_DIR, + client_settings=Settings(anonymized_telemetry=False) ) vectorstore.persist() return vectorstore @@ -213,7 +215,7 @@ def load_vector_store( interface_format=interface_format, embedding_model_name=embedding_model_name ) - return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) + return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings,client_settings=Settings(anonymized_telemetry=False)) def update_vector_store( From f27c2c808723676629a697cd2139b012a3a83705 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 4 Feb 2025 00:10:19 +0800 Subject: [PATCH 043/142] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=EF=BC=88=E5=8F=AF=E8=83=BD=E4=BC=98=E5=8C=96=E4=BA=86?= =?UTF-8?q?=E5=90=A7=EF=BC=89=EF=BC=8C=E6=94=B9=E8=BF=9BUI=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E6=94=AF=E6=8C=81=E5=AF=B9embedding=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9A=84=E7=8B=AC=E7=AB=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + novel_generator.py | 240 +++++++++++++++--------------------- prompt_definitions.py | 274 +++++++++++++++++++++++++++++------------- ui.py | 121 ++++++++++++++----- 4 files changed, 383 insertions(+), 253 deletions(-) diff --git a/.gitignore b/.gitignore index 6d519ce..fcb067b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ /__pycache__ /markdown /vectorstore +/example config.json diff --git a/novel_generator.py b/novel_generator.py index f394186..3e3648d 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -43,21 +43,16 @@ from embedding_ollama import OllamaEmbeddings from chapter_directory_parser import get_chapter_info_from_directory -# ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") -# ============ 通用调用函数 ============ +# ============ 帮助函数 ============ def remove_think_tags(text: str) -> str: - """ - 移除 ... 包裹的内容 - """ + """移除 ... 包裹的内容""" return re.sub(r'.*?', '', text, flags=re.DOTALL) def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: - """ - 通用封装:调用模型并移除 ... 文本,记录日志后返回 - """ + """通用封装:调用模型并移除 ... 文本,记录日志后返回""" response = model.invoke(prompt) if not response: logging.warning("No response from model.") @@ -67,70 +62,60 @@ def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: return cleaned_text.strip() def debug_log(prompt: str, response_content: str): - """ - 打印prompt和response的辅助函数 - """ logging.info(f"\n[Prompt >>>] {prompt}\n") logging.info(f"[Response >>>] {response_content}\n") -# ============ 判断接口格式相关 ============ -def is_using_ollama_api(interface_format: str, base_url: str) -> bool: - """ - 当 interface_format == "Ollama" 时返回 True - """ - return interface_format.lower() == "ollama" - -def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: - """ - 如果用户在下拉里选择了 ML Studio - """ - return interface_format.lower() == "ml studio" - - -# ============ 帮助函数:自动检查 & 补充 /v1 ============ -import re - def ensure_openai_base_url_has_v1(url: str) -> str: """ - 如果用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 - 如果已经包含 '/v1',则不再重复追加。 + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 """ + import re url = url.strip() if not url: return url - # 若末尾没有 /v\d+,但也没出现 /v1,才补上 if not re.search(r'/v\d+$', url): if '/v1' not in url: url = url.rstrip('/') + '/v1' return url +def is_using_ollama_api(interface_format: str) -> bool: + return interface_format.lower() == "ollama" + +def is_using_ml_studio_api(interface_format: str) -> bool: + return interface_format.lower() == "ml studio" + + +# ============ 获取 vectorstore 路径 ============ +def get_vectorstore_dir(filepath: str) -> str: + """ + 返回存储向量库的本地路径: + 在用户指定的 `filepath` 下创建/使用 'vectorstore' 文件夹。 + """ + return os.path.join(filepath, "vectorstore") + + # ============ 创建 Embeddings 对象 ============ def create_embeddings_object( api_key: str, base_url: str, - embed_url: str, interface_format: str, embedding_model_name: str ): """ - 根据用户在UI中配置的参数,返回对应的 embeddings 对象。 - - 当 interface_format = "Ollama" => OllamaEmbeddings(...) - - 当 interface_format = "OpenAI"/"ML Studio" => OpenAIEmbeddings(...) - 这里统一把 base_url/embed_url 处理为含 /v1。 + 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 + base_url: 在 OpenAI 或 ML Studio 时,需要自动补'/v1';Ollama 则通常是 http://localhost:11434/v1 """ - if is_using_ollama_api(interface_format, embed_url): - fixed_url = embed_url.rstrip("/") + if is_using_ollama_api(interface_format): + fixed_url = base_url.rstrip("/") return OllamaEmbeddings( model_name=embedding_model_name, base_url=fixed_url ) else: - # 对 OpenAI 或 ML Studio 统一用 OpenAIEmbeddings - # 并设置 model=embedding_model_name - # base_url/embed_url 若不含 /v1,需要自动补上 - fixed_url = ensure_openai_base_url_has_v1(embed_url if embed_url else base_url) + # OpenAI 或 ML Studio 均使用 OpenAIEmbeddings,注意 base_url 可能需要 ensure /v1 + fixed_url = ensure_openai_base_url_has_v1(base_url) return OpenAIEmbeddings( openai_api_key=api_key, openai_api_base=fixed_url, @@ -138,20 +123,17 @@ def create_embeddings_object( ) -# ============ 向量库相关 ============ -VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") -if not os.path.exists(VECTOR_STORE_DIR): - os.makedirs(VECTOR_STORE_DIR) - -def clear_vector_store(): +# ============ 向量库相关操作 ============ +def clear_vector_store(filepath: str): """ - 清空本地向量库(删除 vectorstore 文件夹内的所有内容) + 清空本地向量库(删除 filepath/vectorstore 文件夹内的所有内容) """ - if os.path.exists(VECTOR_STORE_DIR): + store_dir = get_vectorstore_dir(filepath) + if os.path.exists(store_dir): import shutil try: - for filename in os.listdir(VECTOR_STORE_DIR): - file_path = os.path.join(VECTOR_STORE_DIR, filename) + for filename in os.listdir(store_dir): + file_path = os.path.join(store_dir, filename) if os.path.isfile(file_path) or os.path.islink(file_path): os.unlink(file_path) elif os.path.isdir(file_path): @@ -169,25 +151,25 @@ def init_vector_store( interface_format: str, embedding_model_name: str, texts: List[str], - embedding_base_url: str = "" + filepath: str ) -> Chroma: """ - 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 + 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 """ - embed_url = embedding_base_url if embedding_base_url else base_url + store_dir = get_vectorstore_dir(filepath) + os.makedirs(store_dir, exist_ok=True) + embeddings = create_embeddings_object( api_key=api_key, base_url=base_url, - embed_url=embed_url, interface_format=interface_format, embedding_model_name=embedding_model_name ) - documents = [Document(page_content=str(t)) for t in texts] # 确保是字符串 + documents = [Document(page_content=str(t)) for t in texts] vectorstore = Chroma.from_documents( documents, embedding=embeddings, - persist_directory=VECTOR_STORE_DIR, - client_settings=Settings(anonymized_telemetry=False) + persist_directory=store_dir ) vectorstore.persist() return vectorstore @@ -198,24 +180,26 @@ def load_vector_store( base_url: str, interface_format: str, embedding_model_name: str, - embedding_base_url: str = "" + filepath: str ) -> Optional[Chroma]: """ - 读取已存在的向量库。若不存在则返回 None。 + 读取已存在的 Chroma 向量库。若不存在则返回 None。 """ - if not os.path.exists(VECTOR_STORE_DIR): + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): logging.info("Vector store not found. Will return None.") return None - embed_url = embedding_base_url if embedding_base_url else base_url embeddings = create_embeddings_object( api_key=api_key, base_url=base_url, - embed_url=embed_url, interface_format=interface_format, embedding_model_name=embedding_model_name ) - return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings,client_settings=Settings(anonymized_telemetry=False)) + return Chroma( + persist_directory=store_dir, + embedding_function=embeddings + ) def update_vector_store( @@ -224,19 +208,18 @@ def update_vector_store( new_chapter: str, interface_format: str, embedding_model_name: str, - embedding_base_url: str = "" -) -> None: + filepath: str +): """ - 将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。 + 将最新章节文本插入到向量库中。若库不存在则初始化。 """ store = load_vector_store( api_key=api_key, base_url=base_url, interface_format=interface_format, embedding_model_name=embedding_model_name, - embedding_base_url=embedding_base_url + filepath=filepath ) - if not store: logging.info("Vector store does not exist. Initializing a new one for new chapter...") init_vector_store( @@ -245,7 +228,7 @@ def update_vector_store( interface_format=interface_format, embedding_model_name=embedding_model_name, texts=[new_chapter], - embedding_base_url=embedding_base_url + filepath=filepath ) return @@ -261,19 +244,18 @@ def get_relevant_context_from_vector_store( query: str, interface_format: str, embedding_model_name: str, - embedding_base_url: str = "", + filepath: str, k: int = 2 ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - 若向量库不存在或没有足够内容,则返回空字符串。 """ store = load_vector_store( api_key=api_key, base_url=base_url, interface_format=interface_format, embedding_model_name=embedding_model_name, - embedding_base_url=embedding_base_url + filepath=filepath ) if not store: logging.info("No vector store found. Returning empty context.") @@ -288,7 +270,7 @@ def get_relevant_context_from_vector_store( return combined -# ============ 1. 独立:生成小说“设定” (Novel_setting.txt) ============ +# ============ 1. 生成小说“设定” (Novel_setting.txt) ============ def Novel_setting_generate( api_key: str, base_url: str, @@ -300,16 +282,12 @@ def Novel_setting_generate( filepath: str, temperature: float = 0.7 ) -> None: - """ - 分步生成 Novel_setting.txt (含世界观、角色信息、暗线等) - 不包括目录。 - """ os.makedirs(filepath, exist_ok=True) model = ChatOpenAI( model=llm_model, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), # 确保带 /v1 + base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) @@ -334,7 +312,7 @@ def Novel_setting_generate( ) dark_lines = invoke_with_cleaning(model, prompt_dark) - # Step4: 最终整合为“小说设定” + # Step4: 最终整合 prompt_final = finalize_setting_prompt.format( novel_setting_base=base_setting, character_setting=character_setting, @@ -342,17 +320,15 @@ def Novel_setting_generate( ) final_novel_setting = invoke_with_cleaning(model, prompt_final) - # 写入 Novel_setting.txt filename_set = os.path.join(filepath, "Novel_setting.txt") clear_file_content(filename_set) final_novel_setting_cleaned = final_novel_setting.replace('#', '').replace('*', '') save_string_to_txt(final_novel_setting_cleaned, filename_set) - logging.info("Novel_setting.txt has been generated successfully.") -# ============ 2. 独立:基于已有设定,生成小说目录 (Novel_directory.txt) ============ +# ============ 2. 生成小说目录 (Novel_directory.txt) ============ def Novel_directory_generate( api_key: str, base_url: str, @@ -361,10 +337,6 @@ def Novel_directory_generate( filepath: str, temperature: float = 0.7 ) -> None: - """ - 基于先前已经生成并保存的 Novel_setting.txt,来生成 Novel_directory.txt - """ - # 读取已有的小说设定 filename_set = os.path.join(filepath, "Novel_setting.txt") final_novel_setting = read_file(filename_set).strip() if not final_novel_setting: @@ -378,7 +350,6 @@ def Novel_directory_generate( temperature=temperature ) - # 生成目录 prompt_dir = novel_directory_prompt.format( final_novel_setting=final_novel_setting, number_of_chapters=number_of_chapters @@ -388,7 +359,6 @@ def Novel_directory_generate( logging.warning("Novel_directory生成结果为空。") return - # 写入 Novel_directory.txt filename_dir = os.path.join(filepath, "Novel_directory.txt") clear_file_content(filename_dir) @@ -400,10 +370,6 @@ def Novel_directory_generate( # ============ 获取最近 N 章内容,生成短期摘要 ============ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: - """ - 从指定文件夹中,读取最近 n 章的内容(如果存在),并按从旧到新的顺序返回文本列表。 - 不包含当前章,只拿之前的 n 章。 - """ texts = [] start_chap = max(1, current_chapter_num - n) for c in range(start_chap, current_chapter_num): @@ -413,7 +379,6 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int if text: texts.append(text) if len(texts) < n: - # 如果前面章节不足 n 章,用空字符串填充 texts = [''] * (n - len(texts)) + texts return texts @@ -424,9 +389,6 @@ def summarize_recent_chapters( temperature: float, chapters_text_list: List[str] ) -> str: - """ - 将最近几章文本拼接,通过模型生成相对简要的“短期内容摘要”。 - """ if not chapters_text_list: return "" if all(not txt.strip() for txt in chapters_text_list): @@ -451,7 +413,7 @@ def summarize_recent_chapters( return summary_text -# ============ 剧情要点/未解决冲突 ============ +# ============ 剧情要点/冲突 ============ PLOT_ARCS_PROMPT = """\ 下面是新生成的章节内容: {chapter_text} @@ -508,10 +470,7 @@ def generate_chapter_draft( embedding_model_name: str, embedding_base_url: str ) -> str: - """ - 生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 - """ - # 1) 从目录中获取本章标题、简介 + # 1) 根据目录解析标题、简介 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] @@ -528,11 +487,11 @@ def generate_chapter_draft( for q in queries: partial_context = get_relevant_context_from_vector_store( api_key=api_key, - base_url=base_url, + base_url=embedding_base_url if embedding_base_url else base_url, query=q, interface_format=interface_format, embedding_model_name=embedding_model_name, - embedding_base_url=embedding_base_url, + filepath=filepath, k=2 ) if partial_context.strip(): @@ -540,7 +499,7 @@ def generate_chapter_draft( if not relevant_context: relevant_context = "暂无相关内容。" - # 创建 ChatOpenAI,用于大纲和写作 + # 3) 生成本章大纲 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -548,7 +507,6 @@ def generate_chapter_draft( temperature=temperature ) - # 3) 生成本章大纲 outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, @@ -603,16 +561,10 @@ def finalize_chapter( embedding_model_name: str, model_name: str, temperature: float, - filepath: str + filepath: str, + embedding_base_url: str, + embedding_api_key: str ): - """ - 对当前章节进行定稿: - 1. 读取草稿文本 - 2. 若字数太短则再次扩写 - 3. 更新全局摘要、角色状态 - 4. 更新剧情要点 - 5. 更新向量库 - """ chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -628,7 +580,7 @@ def finalize_chapter( old_global_summary = read_file(global_summary_file) old_plot_arcs = read_file(plot_arcs_file) - # 若篇幅过短,二次扩写 + # 篇幅不足,二次扩写 if len(chapter_text) < 0.8 * word_number: logging.info("Chapter text is shorter than 80% of desired length. Enriching...") chapter_text = enrich_chapter_text( @@ -649,7 +601,6 @@ def finalize_chapter( base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - def update_global_summary(chapter_text: str, old_summary: str) -> str: prompt = summary_prompt.format( chapter_text=chapter_text, @@ -689,13 +640,14 @@ def finalize_chapter( clear_file_content(plot_arcs_file) save_string_to_txt(new_plot_arcs, plot_arcs_file) - # 更新向量库 + # 更新向量库(此时用 embedding_api_key/embedding_base_url) update_vector_store( - api_key=api_key, - base_url=base_url, + api_key=embedding_api_key, + base_url=embedding_base_url if embedding_base_url else base_url, new_chapter=chapter_text, interface_format=interface_format, - embedding_model_name=embedding_model_name + embedding_model_name=embedding_model_name, + filepath=filepath ) logging.info(f"Chapter {novel_number} has been finalized.") @@ -709,9 +661,6 @@ def enrich_chapter_text( model_name: str, temperature: float ) -> str: - """ - 当章节篇幅不足时,调用此函数对章节文本进行二次扩写。 - """ model = ChatOpenAI( model=model_name, api_key=api_key, @@ -726,18 +675,16 @@ def enrich_chapter_text( return enriched_text if enriched_text else chapter_text -# ============ 导入外部知识文本 ============ +# ============ 导入外部知识文本到向量库 ============ def import_knowledge_file( api_key: str, base_url: str, interface_format: str, embedding_model_name: str, file_path: str, - embedding_base_url: str = "" -) -> None: - """ - 将用户选定的文本文件导入到向量库,以便在写作时检索。 - """ + embedding_base_url: str, + filepath: str +): logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") @@ -752,22 +699,28 @@ def import_knowledge_file( paragraphs = advanced_split_content(content) - store = load_vector_store(api_key, base_url, interface_format, embedding_model_name, embedding_base_url) + # 若向量库不存在则初始化,否则追加 + store = load_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", # 默认给个地址 + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath + ) if not store: logging.info("Vector store does not exist. Initializing a new one for knowledge import...") init_vector_store( - api_key, - base_url, - interface_format, - embedding_model_name, - paragraphs, - embedding_base_url + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=paragraphs, + filepath=filepath ) - return - - docs = [Document(page_content=str(p)) for p in paragraphs] - store.add_documents(docs) - store.persist() + else: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + store.persist() logging.info("知识库文件已成功导入至向量库。") @@ -776,7 +729,6 @@ def advanced_split_content(content: str, max_length: int = 500) -> List[str]: """ 将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。 - 可根据需要微调此逻辑。 """ sentences = nltk.sent_tokenize(content) if not sentences: diff --git a/prompt_definitions.py b/prompt_definitions.py index b2859f8..60bf818 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,139 +1,249 @@ # prompt_definitions.py # -*- coding: utf-8 -*- """ -集中存放所有提示词(Prompt),便于统一管理和修改。 +小说创作辅助系统的提示词(Prompt)集合。 +这些提示词被设计用于引导AI生成连贯、丰富的小说内容。 """ -# =============== 提示词:设定 & 目录 =================== +# =============== 基础设定与规划提示词 =================== set_prompt = """\ -请根据主题:{topic}、类型:{genre}、章数:{number_of_chapters}、每章字数:{word_number}来完善小说整体设定。 -需要包含以下信息: -1. 小说名称、总字数走向(大致范围即可)。 -2. 小说类型与基调(如:都市、穿越、战争等类型,以及轻松、爆笑、暗黑等基调)。 -3. 写作风格(正式 / 轻松;细腻 / 简洁;抒情 / 客观;叙事视角等)。 -4. 整体世界观(时间背景、地理环境、社会结构、科技或魔法水平、重要历史事件等)。 -5. 核心内容梗概(可以使用常见叙事结构,如三幕结构、英雄之旅等)。 -6. 初步的情节安排设想(主线、副线、交织等关键思路)。 -7. 初步的人物关系与主要角色设定(角色定位、主要冲突或关系)。 -8. 结尾可能的方向(圆满、悲剧、开放式等)。 +基于主题「{topic}」、类型「{genre}」,将创作一部{number_of_chapters}章、每章约{word_number}字的小说。 +请详细规划以下要素: -请按照上述要点详细输出,但不用标数字。要清晰、有逻辑、有条理。 +【基本信息】 +• 建议书名(可含副标题) +• 预估总字数区间 +• 主要类型定位(如:奇幻/都市/科幻等) +• 基调与氛围(如:史诗/轻松/黑暗等) + +【创作风格】 +• 叙事视角选择及理由 +• 语言风格特点 +• 节奏把控思路 + +【世界观构筑】 +• 时空背景设定 +• 世界运行规则(社会/科技/魔法体系等) +• 重大历史事件或背景 + +【核心故事】 +• 主线故事框架 +• 重要支线规划 +• 核心冲突设置 +• 结局走向构思 + +请具体阐述以上各点,确保前后呼应、逻辑自洽。 """ character_prompt = """\ -基于已生成的小说整体设定: +基于已确立的小说设定: {novel_setting} -请你完善以下内容,帮助我们更好地维持人物形象和成长轨迹: -1. 列出核心角色(至少3个),并对每个角色进行详细性格特征描述。 -2. 强调每个角色的潜在内心冲突、目标与动机。 -3. 为每个角色添加至少一个“暗线”或隐藏秘密,以及在故事进行中可能如何被揭示。 -4. 指出主要角色之间的关键关系和冲突点,为后续情节埋下伏笔。 + +请完善以下角色体系: + +【核心角色塑造】(至少3个) +• 角色基本信息(名字/年龄/身份等) +• 外在特征与性格特点 +• 核心价值观与行为模式 +• 个人成长轨迹设想 +• 独特能力或专长 + +【人物关系网络】 +• 角色间的重要关联 +• 潜在矛盾点 +• 关系发展预期 + +【隐藏维度】 +• 每个角色的个人秘密 +• 待揭示的过往经历 +• 性格中的矛盾面 + +重点说明这些特质如何推动故事发展,为情节转折提供基础。 """ dark_lines_prompt = """\ -在当前设定中已出现以下角色与背景: +根据已设定的角色与背景: {character_info} -请帮助我们构思若干暗线、伏笔或隐藏冲突,以便在后续章节中逐渐揭示并影响故事走向。要求: -1. 每个暗线至少说明其最初的表现、发展走向,以及揭示或爆发的条件。 -2. 这些暗线可以与角色背景、世界观、关键事件等有关。 -3. 需注意保留悬念,与已知设定不冲突。 -4. 在后续创作中可多次提及这些暗线,并在中后期通过角色行为或剧情变化逐步揭示。 + +请构建以下隐藏线索体系: + +【关键暗线设计】 +• 暗线起源与表现形式 +• 发展脉络规划 +• 揭示时机与方式 +• 对整体故事的影响 + +【伏笔布置】 +• 早期暗示点设置 +• 中期发展线索 +• 后期爆发契机 + +【隐藏冲突】 +• 角色间潜在矛盾 +• 阵营对立根源 +• 价值观冲突点 + +确保这些暗线自然融入故事,避免生硬设置。建议提供具体场景建议。 """ finalize_setting_prompt = """\ -请基于以下信息,整合并输出最终的《小说设定》: -1. 之前的“整体设定”: +请整合以下创作准备内容: + +【现有设定】 {novel_setting_base} -2. 扩充的“角色设定”: + +【角色系统】 {character_setting} -3. 暗线与伏笔构思: + +【暗线规划】 {dark_lines} -要求: -1. 结构清晰,将以上内容融合为一个完整的设定说明。 -2. 着重强调角色与暗线的衔接、世界观与角色动机的结合,方便后续写作保持前后一致。 -3. 语言通畅,不使用Markdown格式,直接输出文本内容。 +将以上要素整合为完整的创作蓝图: + +1. 总体框架 + • 核心故事脉络 + • 世界观体系 + • 主题表达方式 + +2. 人物系统 + • 角色群像 + • 关系网络 + • 成长轨迹 + +3. 情节编排 + • 主线发展 + • 支线设计 + • 暗线铺陈 + +4. 创作建议 + • 重点场景构思 + • 节奏控制要点 + • 细节描写建议 + +请以流畅的叙述文本呈现,突出要素间的有机联系。 """ novel_directory_prompt = """\ -根据以下最终《小说设定》: +依据最终设定: {final_novel_setting} -并按照下面的小说目录模板生成 {number_of_chapters} 章的目录,同时确保目录符合小说设定中的叙事结构、角色发展及暗线伏笔。 -目录模板(示例): -第1章 :< text > -第2章 :< text > -... -第{number_of_chapters}章 :< text > -请严格按照上述格式输出每一章的名称,最好在重要情节标题后增加提示性简述, -若要加更详细的简述,用“ - ”分隔,举例如“第n章 :< text > - 主要角色冲突爆发,角色A发生xx意外”。 -请直接输出,不要使用Markdown语法。 +请规划{number_of_chapters}章的详细目录。每章格式: +第N章:章节名 - 核心内容提示 + +要求: +1. 章节名需简明扼要,富有吸引力 +2. 核心内容提示需点明关键信息,为创作提供指引 +3. 整体节奏要富有张力,符合三幕结构 +4. 适当预留转折与高潮 + +示例: +第n章:黎明前的暗影 - 主角遭遇神秘袭击,接触核心谜题 +... """ -# =============== 提示词:章节+角色状态流程 =================== +# =============== 章节创作辅助提示词 =================== summary_prompt = """\ -这是新生成的章节文本: +新增章节内容: {chapter_text} -这是当前的全局摘要(可能为空): +当前全局摘要: {global_summary} -请在不超过3000字的前提下,基于当前全局摘要和本章新增剧情,更新全局摘要。 -保留原有重要信息,并融入本章的新内容。 -不要透露结局,不要过度展开未来剧情。 +请更新全局摘要(控制在3000字以内): + +【已发生】 +• 关键事件梳理 +• 人物关系变化 +• 重要线索进展 + +【正在进行】 +• 当前危机/冲突 +• 角色动态 +• 悬而未决的问题 + +确保摘要重点突出,为后续创作提供清晰参考。 +不展开未来发展,保持故事悬念。 """ update_character_state_prompt = """\ -这是新生成的章节文本: +本章内容: {chapter_text} -这是当前角色状态文档(可能为空): +现有角色状态: {old_state} -请更新角色状态,包括: -1. 角色持有的物品或能力变化。 -2. 角色间关系、冲突或合作的新动向。 -3. 正在发生的重要事件列表,有无进展或新事件产生。 -4. 任意新增角色或出场人物等。 -5. 请保证结构完整,能在后续章节继续引用。 +请更新角色状态档案: -使用简洁、易读的方式描述,可用条目或段落表示。保持与旧文档风格一致。 +【角色发展】 +• 能力/状态变化 +• 重要物品获得/失去 +• 性格/观念的微妙改变 + +【人际关系】 +• 新建立的联系 +• 关系的强化或弱化 +• 潜在矛盾点 + +【事件参与】 +• 正在进行的事件 +• 个人目标进展 +• 新接触的任务 + +请保持简洁明了,便于后续参考。 """ chapter_outline_prompt = """\ -以下是当前小说设定与角色状态信息: -- 小说设定:{novel_setting} +创作参考资料: +- 设定:{novel_setting} - 角色状态:{character_state} - 全局摘要:{global_summary} -现在要为第 {novel_number} 章进行大纲构思。 -本章标题:{chapter_title} -简述(若有):{chapter_brief} +第{novel_number}章:{chapter_title} +章节说明:{chapter_brief} -请围绕本章标题与简述,设计一个详细大纲: -1. 本章的主要冲突或事件?如何与标题呼应? -2. 哪些角色会出现?他们在此章的目标与动机是否有所变化? -3. 如何推动或暗示已存在的暗线、角色冲突或新的悬念? -4. 在结尾留下什么悬念或转折?(与本章标题或简述形成呼应或对比) +请设计本章节详细大纲: -请直接用 1、2、3、4 分点说明大纲要点即可。 +【核心设计】 +• 本章主要冲突/事件 +• 与章节主题的呼应方式 +• 情节推进目标 + +【人物安排】 +• 出场角色及其状态 +• 个人目标与动机 +• 互动关系设计 + +【暗线发展】 +• 已有伏笔的推进 +• 新增悬念的埋设 +• 线索的暗示方式 + +【结构布局】 +• 章节节奏规划 +• 高潮设计 +• 结尾悬念构思 """ chapter_write_prompt = """\ -下面是该章写作所需信息: -1. 小说设定:{novel_setting} +创作参考信息: +1. 设定:{novel_setting} 2. 角色状态:{character_state} -3. 全局摘要:{global_summary} -4. 本章大纲:{chapter_outline} +3. 摘要:{global_summary} +4. 大纲:{chapter_outline} -本章标题:{chapter_title} -简述:{chapter_brief} +第{chapter_title}章 +核心:{chapter_brief} -请写出本章节的完整正文: -1. 确保本章字数不少于 {word_number} 字。 -2. 内容需与标题“{chapter_title}”相呼应,并尽量呼应简述中的核心要点。 -3. 不要使用分节标题,直接整体输出正文。 -4. 可以着重描写人物心理、环境氛围,以保证足够长度。 -5. 在结尾部分保留一定悬念或剧情转折,为下一章做铺垫。 -""" +创作要求: +1. 字数不少于{word_number}字 +2. 紧扣章节主题 +3. 注重细节描写 +4. 深入角色内心 +5. 为下章预留引子 +建议: +• 通过环境描写渲染氛围 +• 展现人物细微情感变化 +• 适当运用对话推进情节 +• 保持节奏张弛有度 +""" \ No newline at end of file diff --git a/ui.py b/ui.py index cb87419..3475b76 100644 --- a/ui.py +++ b/ui.py @@ -21,6 +21,7 @@ from novel_generator import ( ) from consistency_checker import check_consistency + def log_error(message: str): """ 用于打印详细的错误信息和堆栈信息。 @@ -31,6 +32,7 @@ def log_error(message: str): ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") + class NovelGeneratorGUI: def __init__(self, master): self.master = master @@ -51,15 +53,20 @@ class NovelGeneratorGUI: self.loaded_config = load_config(self.config_file) # ========== 主要的属性变量 ========== + # LLM 接口相关 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) + self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) - self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) + # Embedding 接口相关 + self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) + self.embedding_api_key_var = ctk.StringVar(value=self.loaded_config.get("embedding_api_key", "")) + self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) - self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) + # 小说通用参数 self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) self.num_chapters_var = ctk.IntVar(value=self.loaded_config.get("num_chapters", 10)) @@ -298,13 +305,10 @@ class NovelGeneratorGUI: def on_interface_format_changed(new_value): if new_value == "Ollama": self.base_url_var.set("http://localhost:11434/v1") - self.embedding_url_var.set("http://localhost:11434/api") elif new_value == "ML Studio": self.base_url_var.set("http://localhost:1234/v1") - self.embedding_url_var.set("http://localhost:1234/api") elif new_value == "OpenAI": self.base_url_var.set("https://api.openai.com/v1") - self.embedding_url_var.set("https://api.openai.com/v1") for i in range(5): self.ai_config_tab.grid_rowconfigure(i, weight=0) @@ -314,7 +318,7 @@ class NovelGeneratorGUI: api_key_label = ctk.CTkLabel( self.ai_config_tab, - text="API Key:", + text="LLM API Key:", font=("Microsoft YaHei", 12) ) api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") @@ -327,7 +331,7 @@ class NovelGeneratorGUI: base_url_label = ctk.CTkLabel( self.ai_config_tab, - text="Base URL:", + text="LLM Base URL:", font=("Microsoft YaHei", 12) ) base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") @@ -340,7 +344,7 @@ class NovelGeneratorGUI: interface_label = ctk.CTkLabel( self.ai_config_tab, - text="接口格式:", + text="LLM 接口格式:", font=("Microsoft YaHei", 12) ) interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") @@ -394,36 +398,73 @@ class NovelGeneratorGUI: self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") def build_embeddings_config_tab(self): - for i in range(2): + def on_embedding_interface_changed(new_value): + if new_value == "Ollama": + self.embedding_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.embedding_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.embedding_url_var.set("https://api.openai.com/v1") + + for i in range(3): 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) - embedding_url_label = ctk.CTkLabel( + emb_api_key_label = ctk.CTkLabel( self.embeddings_config_tab, - text="Embedding URL:", + text="Embedding API Key:", font=("Microsoft YaHei", 12) ) - embedding_url_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - embedding_url_entry = ctk.CTkEntry( + emb_api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + 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") + + emb_interface_label = ctk.CTkLabel( + self.embeddings_config_tab, + text="Embedding 接口格式:", + font=("Microsoft YaHei", 12) + ) + emb_interface_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + emb_interface_options = ["OpenAI", "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") + + emb_url_label = ctk.CTkLabel( + self.embeddings_config_tab, + text="Embedding Base URL:", + font=("Microsoft YaHei", 12) + ) + emb_url_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + emb_url_entry = ctk.CTkEntry( self.embeddings_config_tab, textvariable=self.embedding_url_var, font=("Microsoft YaHei", 12) ) - embedding_url_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + emb_url_entry.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") emb_model_name_label = ctk.CTkLabel( self.embeddings_config_tab, text="Embedding Model Name:", font=("Microsoft YaHei", 12) ) - emb_model_name_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + emb_model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") 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=1, column=1, padx=5, pady=5, sticky="nsew") + emb_model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") # ========== 保存/加载 配置按钮区域 ========== def build_main_buttons_area(self): @@ -837,13 +878,20 @@ class NovelGeneratorGUI: def load_config_btn(self): cfg = load_config(self.config_file) if cfg: + # LLM self.api_key_var.set(cfg.get("api_key", "")) self.base_url_var.set(cfg.get("base_url", "")) self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) self.model_name_var.set(cfg.get("model_name", "")) + self.temperature_var.set(cfg.get("temperature", 0.7)) + + # Embedding + self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) + self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) self.embedding_url_var.set(cfg.get("embedding_url", "")) self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) - self.temperature_var.set(cfg.get("temperature", 0.7)) + + # Novel self.genre_var.set(cfg.get("genre", "")) self.num_chapters_var.set(cfg.get("num_chapters", 10)) self.word_number_var.set(cfg.get("word_number", 3000)) @@ -859,13 +907,20 @@ class NovelGeneratorGUI: def save_config_btn(self): config_data = { + # LLM "api_key": self.api_key_var.get(), "base_url": self.base_url_var.get(), "interface_format": self.interface_format_var.get(), "model_name": self.model_name_var.get(), + "temperature": self.temperature_var.get(), + + # Embedding + "embedding_api_key": self.embedding_api_key_var.get(), + "embedding_interface_format": self.embedding_interface_format_var.get(), "embedding_url": self.embedding_url_var.get(), "embedding_model_name": self.embedding_model_name_var.get(), - "temperature": self.temperature_var.get(), + + # Novel "topic": self.topic_text.get("0.0", "end").strip(), "genre": self.genre_var.get(), "num_chapters": self.num_chapters_var.get(), @@ -1031,9 +1086,11 @@ class NovelGeneratorGUI: temperature=temperature, novel_novel_directory=novel_directory, filepath=filepath, - interface_format=self.interface_format_var.get().strip(), + + # 传入 Embedding 的专用配置 + interface_format=self.embedding_interface_format_var.get().strip(), embedding_model_name=self.embedding_model_name_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip() + embedding_base_url=self.embedding_url_var.get().strip(), ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -1067,8 +1124,9 @@ class NovelGeneratorGUI: 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().strip() + interface_format = self.embedding_interface_format_var.get().strip() embedding_model_name = self.embedding_model_name_var.get().strip() + embedding_base_url = self.embedding_url_var.get().strip() chap_num = self.chapter_num_var.get() word_number = self.word_number_var.get() @@ -1083,7 +1141,9 @@ class NovelGeneratorGUI: embedding_model_name=embedding_model_name, model_name=model_name, temperature=temperature, - filepath=filepath + filepath=filepath, + embedding_base_url=embedding_base_url, + embedding_api_key=self.embedding_api_key_var.get().strip() ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") @@ -1167,12 +1227,13 @@ class NovelGeneratorGUI: try: self.safe_log(f"开始导入知识库文件: {selected_file}") import_knowledge_file( - api_key=self.api_key_var.get().strip(), - base_url=self.base_url_var.get().strip(), - interface_format=self.interface_format_var.get().strip(), + 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(), embedding_model_name=self.embedding_model_name_var.get().strip(), file_path=selected_file, - embedding_base_url=self.embedding_url_var.get().strip() + embedding_base_url=self.embedding_url_var.get().strip(), + filepath=self.filepath_var.get().strip() # 新增,用于本地化 vectorstore ) self.safe_log("✅ 知识库文件导入完成。") except Exception: @@ -1183,11 +1244,16 @@ class NovelGeneratorGUI: 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: - clear_vector_store() + clear_vector_store(filepath) self.log("已清空向量库。") def show_plot_arcs_ui(self): @@ -1304,6 +1370,7 @@ class NovelGeneratorGUI: save_string_to_txt(content, summary_file) self.log("已保存对 global_summary.txt 的修改。") + # 入口 if __name__ == "__main__": app = ctk.CTk() From 88258e1b433c456959a81757b8a57b076704a5e0 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 4 Feb 2025 13:23:50 +0800 Subject: [PATCH 044/142] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator.py | 63 ++++++++++++++++++++++++++++------------------ ui.py | 4 +-- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index 3e3648d..f6115c9 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -3,13 +3,13 @@ import os import logging import re +import time import traceback from typing import List, Optional # langchain 相关 -from langchain_openai import ChatOpenAI -from langchain_openai import OpenAIEmbeddings -from langchain_community.vectorstores import Chroma +from langchain_openai import ChatOpenAI,OpenAIEmbeddings +from langchain_chroma import Chroma from chromadb.config import Settings from langchain.docstore.document import Document @@ -105,7 +105,6 @@ def create_embeddings_object( ): """ 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 - base_url: 在 OpenAI 或 ML Studio 时,需要自动补'/v1';Ollama 则通常是 http://localhost:11434/v1 """ if is_using_ollama_api(interface_format): fixed_url = base_url.rstrip("/") @@ -114,7 +113,6 @@ def create_embeddings_object( base_url=fixed_url ) else: - # OpenAI 或 ML Studio 均使用 OpenAIEmbeddings,注意 base_url 可能需要 ensure /v1 fixed_url = ensure_openai_base_url_has_v1(base_url) return OpenAIEmbeddings( openai_api_key=api_key, @@ -126,23 +124,35 @@ def create_embeddings_object( # ============ 向量库相关操作 ============ def clear_vector_store(filepath: str): """ - 清空本地向量库(删除 filepath/vectorstore 文件夹内的所有内容) + 不删除文件,仅通过 Chroma API 移除集合数据(保留空目录) """ + from chromadb import Client + store_dir = get_vectorstore_dir(filepath) - if os.path.exists(store_dir): - import shutil - try: - for filename in os.listdir(store_dir): - file_path = os.path.join(store_dir, filename) - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - logging.info("Local vector store has been cleared.") - except Exception: - logging.warning(f"Failed to clear vector store:\n{traceback.format_exc()}") - else: + if not os.path.exists(store_dir): logging.info("No vector store found to clear.") + return + + try: + client = Client(settings=Settings( + persist_directory=store_dir, + allow_reset=True # 允许重置操作 + )) + print(client.list_collections()) + + if client.list_collections(): + client.delete_collection(name="novel_collection") + logging.info("Collection 'novel_collection' deleted via API.") + + client.reset() + + except Exception as e: + logging.error(f"API-based clear failed: {str(e)}") + traceback.print_exc() + finally: + # 显式关闭客户端释放资源 + if 'client' in locals(): + del client def init_vector_store( @@ -169,9 +179,10 @@ def init_vector_store( vectorstore = Chroma.from_documents( documents, embedding=embeddings, - persist_directory=store_dir + persist_directory=store_dir, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" ) - vectorstore.persist() return vectorstore @@ -198,7 +209,9 @@ def load_vector_store( ) return Chroma( persist_directory=store_dir, - embedding_function=embeddings + embedding_function=embeddings, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" ) @@ -234,7 +247,6 @@ def update_vector_store( new_doc = Document(page_content=str(new_chapter)) store.add_documents([new_doc]) - store.persist() logging.info("Vector store updated with the new chapter.") @@ -481,6 +493,8 @@ def generate_chapter_draft( queries.append(user_guidance) if chapter_brief.strip(): queries.append(chapter_brief) + if recent_chapters_summary.strip(): + queries.append(recent_chapters_summary) queries.append("回顾剧情") relevant_context = "" @@ -702,7 +716,7 @@ def import_knowledge_file( # 若向量库不存在则初始化,否则追加 store = load_vector_store( api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", # 默认给个地址 + base_url=base_url if base_url else "http://localhost:11434/v1", interface_format=interface_format, embedding_model_name=embedding_model_name, filepath=filepath @@ -720,7 +734,6 @@ def import_knowledge_file( else: docs = [Document(page_content=str(p)) for p in paragraphs] store.add_documents(docs) - store.persist() logging.info("知识库文件已成功导入至向量库。") diff --git a/ui.py b/ui.py index 3475b76..9a2f53e 100644 --- a/ui.py +++ b/ui.py @@ -240,7 +240,7 @@ class NovelGeneratorGUI: self.config_frame.columnconfigure(0, weight=1) self.build_config_tabview() # LLM、Embedding等配置 - self.build_main_buttons_area() # 保存/加载配置 + self.build_main_buttons_area() # 保存/加载配置按钮 # 2) 小说参数 self.build_novel_params_area(start_row=1) @@ -1233,7 +1233,7 @@ class NovelGeneratorGUI: embedding_model_name=self.embedding_model_name_var.get().strip(), file_path=selected_file, embedding_base_url=self.embedding_url_var.get().strip(), - filepath=self.filepath_var.get().strip() # 新增,用于本地化 vectorstore + filepath=self.filepath_var.get().strip() # 用于本地化 vectorstore ) self.safe_log("✅ 知识库文件导入完成。") except Exception: From fa6010473aaed0b7997bdf863bb60be31a5ff8cf Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 4 Feb 2025 16:11:17 +0800 Subject: [PATCH 045/142] 1 --- novel_generator.py | 259 ++++++++++++++++++++++++---------------- prompt_definitions.py | 268 +++++++++++++----------------------------- ui.py | 171 +++++++++++++++++---------- 3 files changed, 353 insertions(+), 345 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index f6115c9..0826b2e 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -8,7 +8,7 @@ import traceback from typing import List, Optional # langchain 相关 -from langchain_openai import ChatOpenAI,OpenAIEmbeddings +from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_chroma import Chroma from chromadb.config import Settings from langchain.docstore.document import Document @@ -51,6 +51,10 @@ def remove_think_tags(text: str) -> str: """移除 ... 包裹的内容""" return re.sub(r'.*?', '', text, flags=re.DOTALL) +def debug_log(prompt: str, response_content: str): + logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n") + logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n") + def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: """通用封装:调用模型并移除 ... 文本,记录日志后返回""" response = model.invoke(prompt) @@ -61,11 +65,6 @@ def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: debug_log(prompt, cleaned_text) return cleaned_text.strip() -def debug_log(prompt: str, response_content: str): - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") - - def ensure_openai_base_url_has_v1(url: str) -> str: """ 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 @@ -79,7 +78,6 @@ def ensure_openai_base_url_has_v1(url: str) -> str: url = url.rstrip('/') + '/v1' return url - def is_using_ollama_api(interface_format: str) -> bool: return interface_format.lower() == "ollama" @@ -113,6 +111,7 @@ def create_embeddings_object( base_url=fixed_url ) else: + # OpenAI 或 ML Studio 均使用 OpenAIEmbeddings,注意 base_url 可能需要 ensure /v1 fixed_url = ensure_openai_base_url_has_v1(base_url) return OpenAIEmbeddings( openai_api_key=api_key, @@ -122,37 +121,47 @@ def create_embeddings_object( # ============ 向量库相关操作 ============ -def clear_vector_store(filepath: str): +def clear_vector_store(filepath: str) -> bool: """ - 不删除文件,仅通过 Chroma API 移除集合数据(保留空目录) + 通过 Chroma API 移除集合数据后尝试删除整个 vectorstore 目录。 + 返回值表示是否成功清空向量库。 """ from chromadb import Client + import shutil store_dir = get_vectorstore_dir(filepath) if not os.path.exists(store_dir): logging.info("No vector store found to clear.") - return + return False try: client = Client(settings=Settings( persist_directory=store_dir, allow_reset=True # 允许重置操作 )) - print(client.list_collections()) - - if client.list_collections(): + collections = client.list_collections() + if collections: client.delete_collection(name="novel_collection") logging.info("Collection 'novel_collection' deleted via API.") - + client.reset() - + logging.info("Client reset successfully.") + + # 直接删除整个 vectorstore 目录 + if os.path.exists(store_dir): + shutil.rmtree(store_dir) + logging.info(f"Vector store directory '{store_dir}' removed.") + return True except Exception as e: - logging.error(f"API-based clear failed: {str(e)}") + logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") traceback.print_exc() + return False finally: - # 显式关闭客户端释放资源 if 'client' in locals(): - del client + try: + del client + except AttributeError: + logging.warning("Client object not found to delete.") def init_vector_store( @@ -215,6 +224,64 @@ def load_vector_store( ) +def split_by_length(text: str, max_length: int = 500) -> List[str]: + segments = [] + start_idx = 0 + while start_idx < len(text): + end_idx = min(start_idx + max_length, len(text)) + segment = text[start_idx:end_idx] + segments.append(segment.strip()) + start_idx = end_idx + return segments + + +def split_text_for_vectorstore(chapter_text: str, + max_length: int = 500, + similarity_threshold: float = 0.7) -> List[str]: + """ + 对新的章节文本进行分段后,再用于存入向量库。 + """ + if not chapter_text.strip(): + return [] + + nltk.download('punkt', quiet=True) + sentences = nltk.sent_tokenize(chapter_text) + if not sentences: + return [] + + # 先对相近句子进行合并 + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + current_sentences.append(sentences[i]) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + + # 再对合并好的段落做 max_length 切分 + final_segments = [] + for para in merged_paragraphs: + if len(para) > max_length: + sub_segments = split_by_length(para, max_length=max_length) + final_segments.extend(sub_segments) + else: + final_segments.append(para) + + return final_segments + + def update_vector_store( api_key: str, base_url: str, @@ -226,6 +293,11 @@ def update_vector_store( """ 将最新章节文本插入到向量库中。若库不存在则初始化。 """ + splitted_texts = split_text_for_vectorstore(new_chapter) + if not splitted_texts: + logging.warning("No valid text to insert into vector store. Skipping.") + return + store = load_vector_store( api_key=api_key, base_url=base_url, @@ -240,14 +312,14 @@ def update_vector_store( base_url=base_url, interface_format=interface_format, embedding_model_name=embedding_model_name, - texts=[new_chapter], + texts=splitted_texts, filepath=filepath ) return - new_doc = Document(page_content=str(new_chapter)) - store.add_documents([new_doc]) - logging.info("Vector store updated with the new chapter.") + docs = [Document(page_content=str(t)) for t in splitted_texts] + store.add_documents(docs) + logging.info("Vector store updated with the new chapter splitted segments.") def get_relevant_context_from_vector_store( @@ -480,37 +552,38 @@ def generate_chapter_draft( filepath: str, interface_format: str, embedding_model_name: str, - embedding_base_url: str + embedding_base_url: str, + embedding_retrieval_k: int = 4 ) -> str: # 1) 根据目录解析标题、简介 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 2) 从向量库检索上下文 - queries = [] + # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) + combined_query_parts = [] if user_guidance.strip(): - queries.append(user_guidance) + combined_query_parts.append(user_guidance) if chapter_brief.strip(): - queries.append(chapter_brief) + combined_query_parts.append(chapter_brief) if recent_chapters_summary.strip(): - queries.append(recent_chapters_summary) - queries.append("回顾剧情") + combined_query_parts.append(recent_chapters_summary) + # 额外加一个关键字 + combined_query_parts.append("回顾剧情") - relevant_context = "" - for q in queries: - partial_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=embedding_base_url if embedding_base_url else base_url, - query=q, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath, - k=2 - ) - if partial_context.strip(): - relevant_context += "\n" + partial_context - if not relevant_context: + merged_query_str = "\n".join(combined_query_parts) + + # 2) 从向量库检索上下文 + relevant_context = get_relevant_context_from_vector_store( + api_key=api_key, + base_url=embedding_base_url if embedding_base_url else base_url, + query=merged_query_str, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath, + k=embedding_retrieval_k + ) + if not relevant_context.strip(): relevant_context = "暂无相关内容。" # 3) 生成本章大纲 @@ -615,6 +688,7 @@ def finalize_chapter( base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) + def update_global_summary(chapter_text: str, old_summary: str) -> str: prompt = summary_prompt.format( chapter_text=chapter_text, @@ -690,59 +764,13 @@ def enrich_chapter_text( # ============ 导入外部知识文本到向量库 ============ -def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - file_path: str, - embedding_base_url: str, - filepath: str -): - logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") - if not os.path.exists(file_path): - logging.warning(f"知识库文件不存在: {file_path}") - return - - content = read_file(file_path) - if not content.strip(): - logging.warning("知识库文件内容为空。") - return - - nltk.download('punkt', quiet=True) - - paragraphs = advanced_split_content(content) - - # 若向量库不存在则初始化,否则追加 - store = load_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) - if not store: - logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=paragraphs, - filepath=filepath - ) - else: - docs = [Document(page_content=str(p)) for p in paragraphs] - store.add_documents(docs) - logging.info("知识库文件已成功导入至向量库。") - - def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: """ 将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。 """ + nltk.download('punkt', quiet=True) sentences = nltk.sent_tokenize(content) if not sentences: return [] @@ -777,13 +805,46 @@ def advanced_split_content(content: str, return final_segments +def import_knowledge_file( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + file_path: str, + embedding_base_url: str, + filepath: str +): + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") + if not os.path.exists(file_path): + logging.warning(f"知识库文件不存在: {file_path}") + return -def split_by_length(text: str, max_length: int = 500) -> List[str]: - segments = [] - start_idx = 0 - while start_idx < len(text): - end_idx = min(start_idx + max_length, len(text)) - segment = text[start_idx:end_idx] - segments.append(segment.strip()) - start_idx = end_idx - return segments + content = read_file(file_path) + if not content.strip(): + logging.warning("知识库文件内容为空。") + return + + paragraphs = advanced_split_content(content) + + # 若向量库不存在则初始化,否则追加 + store = load_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath + ) + if not store: + logging.info("Vector store does not exist. Initializing a new one for knowledge import...") + init_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=paragraphs, + filepath=filepath + ) + else: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + logging.info("知识库文件已成功导入至向量库。") diff --git a/prompt_definitions.py b/prompt_definitions.py index 60bf818..ff18c2f 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,249 +1,143 @@ # prompt_definitions.py # -*- coding: utf-8 -*- """ -小说创作辅助系统的提示词(Prompt)集合。 -这些提示词被设计用于引导AI生成连贯、丰富的小说内容。 +集中存放所有提示词(Prompt),新版本更精确、更具创新性,结合部分雪花写作法等理论。 """ -# =============== 基础设定与规划提示词 =================== +# =============== 1. 整体设定 =================== set_prompt = """\ -基于主题「{topic}」、类型「{genre}」,将创作一部{number_of_chapters}章、每章约{word_number}字的小说。 -请详细规划以下要素: +请根据主题: {topic}、类型: {genre}、章数: {number_of_chapters}、每章字数: {word_number} 来设计小说的总体结构和世界观。 +写作时请参考雪花写作法等创作理论,结合以下要点: -【基本信息】 -• 建议书名(可含副标题) -• 预估总字数区间 -• 主要类型定位(如:奇幻/都市/科幻等) -• 基调与氛围(如:史诗/轻松/黑暗等) +• 小说标题与大致篇幅(总字数范围)。 +• 类型与基调(如:都市、魔幻、战争、轻松、暗黑等)。 +• 主要写作风格(视角、叙述方式、语言风格等)。 +• 世界观设定(背景时间、地理环境、社会结构、科技或魔法水平、重要历史等)。 +• 整体故事框架(可提及常见叙事结构:三幕、英雄之旅等)。 +• 主线与副线的初步构想,以及它们如何交织。 +• 关键角色群像定位与主要冲突关系。 +• 结局的可能方向(圆满、悲剧、开放式等)。 -【创作风格】 -• 叙事视角选择及理由 -• 语言风格特点 -• 节奏把控思路 - -【世界观构筑】 -• 时空背景设定 -• 世界运行规则(社会/科技/魔法体系等) -• 重大历史事件或背景 - -【核心故事】 -• 主线故事框架 -• 重要支线规划 -• 核心冲突设置 -• 结局走向构思 - -请具体阐述以上各点,确保前后呼应、逻辑自洽。 +请以简洁、逻辑清晰的方式输出,保留足够细节以支撑后续创作。 """ +# =============== 2. 角色设定 =================== character_prompt = """\ -基于已确立的小说设定: +基于已生成的小说整体设定: {novel_setting} -请完善以下角色体系: +请进一步扩展角色设置,至少包含三名核心角色。对每位角色说明: +• 角色背景、外貌与主要性格特征。 +• 内在冲突、目标与动机。 +• 暗藏的秘密或潜在弱点(可与世界观或其他角色有关)。 +• 与其他角色的关系或对立点,如何推动或阻碍情节发展。 -【核心角色塑造】(至少3个) -• 角色基本信息(名字/年龄/身份等) -• 外在特征与性格特点 -• 核心价值观与行为模式 -• 个人成长轨迹设想 -• 独特能力或专长 - -【人物关系网络】 -• 角色间的重要关联 -• 潜在矛盾点 -• 关系发展预期 - -【隐藏维度】 -• 每个角色的个人秘密 -• 待揭示的过往经历 -• 性格中的矛盾面 - -重点说明这些特质如何推动故事发展,为情节转折提供基础。 +请重点突出角色的多重面向,为后续情节埋下伏笔。 """ +# =============== 3. 暗线与伏笔 =================== dark_lines_prompt = """\ -根据已设定的角色与背景: +在当前世界观与角色关系中: {character_info} -请构建以下隐藏线索体系: +请构思若干暗线、伏笔或隐藏冲突,结合雪花写作法“细节逐步扩展”的思路。要求: +• 每条暗线给出初始迹象、后续发展与可能的爆发条件。 +• 与角色背景、世界观或关键事件相呼应。 +• 保持合理的悬念设置,与现有设定不冲突。 +• 为后续剧情保留足够展开空间,强调持续影响故事进程。 -【关键暗线设计】 -• 暗线起源与表现形式 -• 发展脉络规划 -• 揭示时机与方式 -• 对整体故事的影响 - -【伏笔布置】 -• 早期暗示点设置 -• 中期发展线索 -• 后期爆发契机 - -【隐藏冲突】 -• 角色间潜在矛盾 -• 阵营对立根源 -• 价值观冲突点 - -确保这些暗线自然融入故事,避免生硬设置。建议提供具体场景建议。 +请避免一次性透漏全部细节,以逐步揭示的方式为后文做铺垫。 """ +# =============== 4. 最终设定整合 =================== finalize_setting_prompt = """\ -请整合以下创作准备内容: - -【现有设定】 +基于以下内容,请整合并输出终稿《小说设定》: +1. 整体设定: {novel_setting_base} - -【角色系统】 +2. 角色设定: {character_setting} - -【暗线规划】 +3. 暗线与伏笔: {dark_lines} -将以上要素整合为完整的创作蓝图: - -1. 总体框架 - • 核心故事脉络 - • 世界观体系 - • 主题表达方式 - -2. 人物系统 - • 角色群像 - • 关系网络 - • 成长轨迹 - -3. 情节编排 - • 主线发展 - • 支线设计 - • 暗线铺陈 - -4. 创作建议 - • 重点场景构思 - • 节奏控制要点 - • 细节描写建议 - -请以流畅的叙述文本呈现,突出要素间的有机联系。 +整合要求: +• 以整体视角整理世界观、角色与暗线,形成一个有机结合的故事框架。 +• 注意角色动机与暗线如何与世界观互相呼应,并兼顾主线与副线节奏。 +• 语言通顺、层次分明。直接输出文本,不使用Markdown格式。 """ +# =============== 5. 小说目录 =================== novel_directory_prompt = """\ -依据最终设定: +根据以下最终《小说设定》: {final_novel_setting} -请规划{number_of_chapters}章的详细目录。每章格式: -第N章:章节名 - 核心内容提示 - -要求: -1. 章节名需简明扼要,富有吸引力 -2. 核心内容提示需点明关键信息,为创作提供指引 -3. 整体节奏要富有张力,符合三幕结构 -4. 适当预留转折与高潮 - -示例: -第n章:黎明前的暗影 - 主角遭遇神秘袭击,接触核心谜题 +请列出共 {number_of_chapters} 章的目录,并保证每章的标题或简述能呼应主要冲突、角色成长及暗线埋设。 +目录格式示例: +第1章 :<标题> - <简要说明> +第2章 :<标题> - <简要说明> ... +第{number_of_chapters}章 :<标题> - <简要说明> + +每章可酌情加入一句简述,引导读者了解本章看点。直接输出文本,不使用Markdown。 """ -# =============== 章节创作辅助提示词 =================== +# =============== 6. 全局摘要更新 =================== summary_prompt = """\ -新增章节内容: +以下是新完成的章节文本: {chapter_text} -当前全局摘要: +这是当前的全局摘要(可为空): {global_summary} -请更新全局摘要(控制在3000字以内): - -【已发生】 -• 关键事件梳理 -• 人物关系变化 -• 重要线索进展 - -【正在进行】 -• 当前危机/冲突 -• 角色动态 -• 悬而未决的问题 - -确保摘要重点突出,为后续创作提供清晰参考。 -不展开未来发展,保持故事悬念。 +请根据本章新增内容,更新全局摘要。保留既有重要信息,同时融入新剧情要点,勿剧透未来。控制在不超过3000字的范围内,语言简练流畅。 """ +# =============== 7. 角色状态更新 =================== update_character_state_prompt = """\ -本章内容: +以下是新完成的章节文本: {chapter_text} -现有角色状态: +这是当前的角色状态文档(可为空): {old_state} -请更新角色状态档案: +请更新角色状态,内容包括: +• 角色的物品、能力或心理状态变化。 +• 角色间关系的最新进展或冲突。 +• 是否触发或加深了某些暗线或关键事件。 +• 任何新增角色或临时出场人物的基本信息。 -【角色发展】 -• 能力/状态变化 -• 重要物品获得/失去 -• 性格/观念的微妙改变 - -【人际关系】 -• 新建立的联系 -• 关系的强化或弱化 -• 潜在矛盾点 - -【事件参与】 -• 正在进行的事件 -• 个人目标进展 -• 新接触的任务 - -请保持简洁明了,便于后续参考。 +请直接在已有文档基础上进行增删,语言尽量简洁、有条理。 """ +# =============== 8. 章节大纲设计 =================== chapter_outline_prompt = """\ -创作参考资料: -- 设定:{novel_setting} +这是当前小说的重要信息: +- 小说设定:{novel_setting} - 角色状态:{character_state} - 全局摘要:{global_summary} -第{novel_number}章:{chapter_title} -章节说明:{chapter_brief} +我们即将写第 {novel_number} 章,标题:{chapter_title},简述(若有):{chapter_brief} -请设计本章节详细大纲: +请按照以下思路设计本章大纲: +1. 本章的主要冲突或情节目标,如何与标题呼应。 +2. 出场角色及其动机,对剧情走向的推动或阻碍。 +3. 暗线或伏笔如何有所进展或被揭示。 +4. 本章结尾的悬念或转折,如何为下一章做铺垫。 -【核心设计】 -• 本章主要冲突/事件 -• 与章节主题的呼应方式 -• 情节推进目标 - -【人物安排】 -• 出场角色及其状态 -• 个人目标与动机 -• 互动关系设计 - -【暗线发展】 -• 已有伏笔的推进 -• 新增悬念的埋设 -• 线索的暗示方式 - -【结构布局】 -• 章节节奏规划 -• 高潮设计 -• 结尾悬念构思 +请以简要分点方式输出,不要使用Markdown格式。 """ +# =============== 9. 章节正文写作 =================== chapter_write_prompt = """\ -创作参考信息: -1. 设定:{novel_setting} +以下信息供你参考: +1. 小说设定:{novel_setting} 2. 角色状态:{character_state} -3. 摘要:{global_summary} -4. 大纲:{chapter_outline} +3. 全局摘要:{global_summary} +4. 本章大纲:{chapter_outline} -第{chapter_title}章 -核心:{chapter_brief} +请写出第 {novel_number} 章的正文,标题为“{chapter_title}”。需要: +• 字数不少于 {word_number} 字,与标题和简述相呼应。 +• 保持连贯叙述,可增加环境、心理、对话等细节描写。 +• 适度呼应前文暗线或角色矛盾,为后续发展留出悬念。 -创作要求: -1. 字数不少于{word_number}字 -2. 紧扣章节主题 -3. 注重细节描写 -4. 深入角色内心 -5. 为下章预留引子 - -建议: -• 通过环境描写渲染氛围 -• 展现人物细微情感变化 -• 适当运用对话推进情节 -• 保持节奏张弛有度 -""" \ No newline at end of file +直接输出正文内容,不使用分章节小标题,章节末留一定悬念。 +""" diff --git a/ui.py b/ui.py index 9a2f53e..5bd0248 100644 --- a/ui.py +++ b/ui.py @@ -28,6 +28,7 @@ def log_error(message: str): """ logging.error(f"{message}\n{traceback.format_exc()}") + # 设置全局主题和颜色 ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") @@ -52,12 +53,14 @@ class NovelGeneratorGUI: self.config_file = "config.json" self.loaded_config = load_config(self.config_file) - # ========== 主要的属性变量 ========== + # ========== 主要的属性变量 ========== # LLM 接口相关 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) + + # 注意:使用 DoubleVar/IntVar 时,如果用户输入空值可能报错,需要在取值时做安全处理 self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) # Embedding 接口相关 @@ -66,16 +69,23 @@ class NovelGeneratorGUI: self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) + # 新增 Embedding 检索的 k 值(默认为4) + self.embedding_retrieval_k_var = ctk.IntVar(value=self.loaded_config.get("embedding_retrieval_k", 4)) + # 小说通用参数 self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) + + # 章节数、每章字数等用 IntVar,但取值时需要安全转换 self.num_chapters_var = ctk.IntVar(value=self.loaded_config.get("num_chapters", 10)) self.word_number_var = ctk.IntVar(value=self.loaded_config.get("word_number", 3000)) + self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) + # 当前要处理的章节号 self.chapter_num_var = ctk.IntVar(value=1) - # ========== 主容器使用 TabView ========== + # ========== 主容器使用 TabView ========== self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) self.tabview.pack(fill="both", expand=True) @@ -95,13 +105,15 @@ class NovelGeneratorGUI: self.build_summary_tab() self.build_chapters_tab() # 新增 - # ------------------ 统一异常处理方法 ------------------ - def handle_exception(self, context: str): - full_message = f"{context}\n{traceback.format_exc()}" - logging.error(full_message) - self.safe_log(full_message) + # ------------------ 工具方法:安全获取 IntVar ------------------ + def safe_get_int(self, int_var, default=0): + try: + return int(int_var.get()) + except Exception: + int_var.set(default) + return default - # ------------------ 主功能 Tab ------------------ + # ------------------ 主功能 Tab ------------------ def build_main_tab(self): """ 主Tab分为左右两栏: @@ -140,34 +152,34 @@ class NovelGeneratorGUI: self.left_frame.grid_rowconfigure(4, weight=1) self.left_frame.grid_columnconfigure(0, weight=1) - # ========== 本章内容 ========== + # ========== 本章内容 ========== chapter_label = ctk.CTkLabel( self.left_frame, text="本章内容 (可编辑)", - font=("Microsoft YaHei", 14) + 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", + self.left_frame, + wrap="word", font=("Microsoft YaHei", 14) ) self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - # ========== Step1~4按钮 ========== + # ========== Step1~4按钮 ========== self.build_step_buttons_area() - # ========== 输出日志 label ========== + # ========== 输出日志 label ========== log_label = ctk.CTkLabel( self.left_frame, text="输出日志 (只读)", - font=("Microsoft YaHei", 14) + 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", @@ -248,7 +260,7 @@ class NovelGeneratorGUI: # 3) 可选功能按钮 self.build_optional_buttons_area(start_row=2) - # ------------------ 可选功能按钮区域(右下) ------------------ + # ------------------ 可选功能按钮区域(右下) ------------------ def build_optional_buttons_area(self, start_row=2): """ 放在右侧的最下方:包括 一致性审校、导入知识库、清空向量库、查看剧情要点 @@ -389,7 +401,7 @@ class NovelGeneratorGUI: 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}", @@ -406,7 +418,7 @@ class NovelGeneratorGUI: elif new_value == "OpenAI": self.embedding_url_var.set("https://api.openai.com/v1") - for i in range(3): + 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) @@ -466,6 +478,20 @@ class NovelGeneratorGUI: ) emb_model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + # 新增:检索 K 值设置 + emb_retrieval_k_label = ctk.CTkLabel( + self.embeddings_config_tab, + text="Retrieval Top-K:", + font=("Microsoft YaHei", 12) + ) + emb_retrieval_k_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + 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") + # ========== 保存/加载 配置按钮区域 ========== def build_main_buttons_area(self): """ @@ -499,7 +525,7 @@ class NovelGeneratorGUI: """ self.params_frame = ctk.CTkScrollableFrame( self.right_frame, - orientation="vertical" # 默认垂直滚动 + orientation="vertical" ) self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) self.params_frame.columnconfigure(1, weight=1) @@ -536,35 +562,45 @@ class NovelGeneratorGUI: ) genre_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") - # 章节数 + # 章节数、每章字数 放在同一行 + row_for_chapter_and_word = 2 num_chapters_label = ctk.CTkLabel( self.params_frame, text="章节数:", font=("Microsoft YaHei", 12) ) - num_chapters_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + + # 此处放一个小的Frame,用于在同一行放两个输入框 + ch_word_frame = ctk.CTkFrame(self.params_frame) + ch_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") + ch_word_frame.columnconfigure(0, weight=0) + ch_word_frame.columnconfigure(1, weight=0) + ch_word_frame.columnconfigure(2, weight=0) + ch_word_frame.columnconfigure(3, weight=1) + num_chapters_entry = ctk.CTkEntry( - self.params_frame, + ch_word_frame, textvariable=self.num_chapters_var, - width=80, + width=60, font=("Microsoft YaHei", 12) ) - num_chapters_entry.grid(row=2, column=1, padx=5, pady=5, sticky="w") + num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") - # 每章字数 word_number_label = ctk.CTkLabel( - self.params_frame, + ch_word_frame, text="每章字数:", font=("Microsoft YaHei", 12) ) - word_number_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") + word_number_entry = ctk.CTkEntry( - self.params_frame, + ch_word_frame, textvariable=self.word_number_var, - width=80, + width=60, font=("Microsoft YaHei", 12) ) - word_number_entry.grid(row=3, column=1, padx=5, pady=5, sticky="w") + word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") # 保存路径 filepath_label = ctk.CTkLabel( @@ -572,10 +608,10 @@ class NovelGeneratorGUI: text="保存路径:", font=("Microsoft YaHei", 12) ) - filepath_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - + filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + self.filepath_frame = ctk.CTkFrame(self.params_frame) - self.filepath_frame.grid(row=4, column=1, padx=5, pady=5, sticky="nsew") + self.filepath_frame.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") self.filepath_frame.columnconfigure(0, weight=1) filepath_entry = ctk.CTkEntry( @@ -599,14 +635,14 @@ class NovelGeneratorGUI: text="章节号:", font=("Microsoft YaHei", 12) ) - chapter_num_label.grid(row=5, column=0, padx=5, pady=5, sticky="e") + chapter_num_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") chapter_num_entry = ctk.CTkEntry( self.params_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12) ) - chapter_num_entry.grid(row=5, column=1, padx=5, pady=5, sticky="w") + chapter_num_entry.grid(row=4, column=1, padx=5, pady=5, sticky="w") # 用户指导 guide_label = ctk.CTkLabel( @@ -614,7 +650,7 @@ class NovelGeneratorGUI: text="本章指导:", font=("Microsoft YaHei", 12) ) - guide_label.grid(row=6, column=0, padx=5, pady=5, sticky="ne") + guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") self.user_guide_text = ctk.CTkTextbox( self.params_frame, width=200, @@ -622,9 +658,9 @@ class NovelGeneratorGUI: wrap="word", font=("Microsoft YaHei", 12) ) - self.user_guide_text.grid(row=6, column=1, padx=5, pady=5, sticky="nsew") + self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") - # ------------------ 其他Tab的构建 ------------------ + # ------------------ 其他Tab的构建 ------------------ def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -874,7 +910,7 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") - # ------------------ 配置管理 ------------------ + # ------------------ 配置管理 ------------------ def load_config_btn(self): cfg = load_config(self.config_file) if cfg: @@ -890,11 +926,13 @@ class NovelGeneratorGUI: self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) self.embedding_url_var.set(cfg.get("embedding_url", "")) self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) + self.embedding_retrieval_k_var.set(cfg.get("embedding_retrieval_k", 4)) # Novel self.genre_var.set(cfg.get("genre", "")) - self.num_chapters_var.set(cfg.get("num_chapters", 10)) - self.word_number_var.set(cfg.get("word_number", 3000)) + # 安全转换,防止出现空值 + self.num_chapters_var.set(int(cfg.get("num_chapters", 10) or 10)) + self.word_number_var.set(int(cfg.get("word_number", 3000) or 3000)) self.filepath_var.set(cfg.get("filepath", "")) # 主题 @@ -919,6 +957,8 @@ class NovelGeneratorGUI: "embedding_interface_format": self.embedding_interface_format_var.get(), "embedding_url": self.embedding_url_var.get(), "embedding_model_name": self.embedding_model_name_var.get(), + # 新增:向量库检索 K 值 + "embedding_retrieval_k": self.embedding_retrieval_k_var.get(), # Novel "topic": self.topic_text.get("0.0", "end").strip(), @@ -938,7 +978,7 @@ class NovelGeneratorGUI: if selected_dir: self.filepath_var.set(selected_dir) - # ------------------ 日志输出(主线程安全) ------------------ + # ------------------ 日志输出(主线程安全) ------------------ def log(self, message: str): self.log_text.configure(state="normal") self.log_text.insert("end", message + "\n") @@ -954,7 +994,12 @@ class NovelGeneratorGUI: 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) + + # ------------------ 分步操作:生成设定、目录、章节草稿、定稿 ------------------ def generate_novel_setting_ui(self): """Step1. 生成小说设定(Novel_setting.txt)""" filepath = self.filepath_var.get().strip() @@ -968,10 +1013,11 @@ class NovelGeneratorGUI: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() + topic = self.topic_text.get("0.0", "end").strip() genre = self.genre_var.get().strip() - num_chapters = self.num_chapters_var.get() - word_number = self.word_number_var.get() + num_chapters = self.safe_get_int(self.num_chapters_var, 10) + word_number = self.safe_get_int(self.word_number_var, 3000) temperature = self.temperature_var.get() self.safe_log("开始生成小说设定...") @@ -1007,7 +1053,7 @@ class NovelGeneratorGUI: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() - num_chapters = self.num_chapters_var.get() + num_chapters = self.safe_get_int(self.num_chapters_var, 10) temperature = self.temperature_var.get() self.safe_log("开始生成小说目录...") @@ -1041,6 +1087,7 @@ class NovelGeneratorGUI: base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + novel_settings_file = os.path.join(filepath, "Novel_setting.txt") novel_settings = read_file(novel_settings_file) if not novel_settings.strip(): @@ -1054,8 +1101,8 @@ class NovelGeneratorGUI: novel_directory_file = os.path.join(filepath, "Novel_directory.txt") novel_directory = read_file(novel_directory_file) - chap_num = self.chapter_num_var.get() - word_number = self.word_number_var.get() + 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() # 获取最近3章文本 @@ -1087,10 +1134,12 @@ class NovelGeneratorGUI: novel_novel_directory=novel_directory, filepath=filepath, - # 传入 Embedding 的专用配置 + # Embedding 配置 interface_format=self.embedding_interface_format_var.get().strip(), embedding_model_name=self.embedding_model_name_var.get().strip(), embedding_base_url=self.embedding_url_var.get().strip(), + # 新增:检索 K 值 + embedding_retrieval_k=self.safe_get_int(self.embedding_retrieval_k_var, 4) ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -1124,12 +1173,14 @@ class NovelGeneratorGUI: base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + interface_format = self.embedding_interface_format_var.get().strip() embedding_model_name = self.embedding_model_name_var.get().strip() embedding_base_url = self.embedding_url_var.get().strip() + embedding_api_key = self.embedding_api_key_var.get().strip() - chap_num = self.chapter_num_var.get() - word_number = self.word_number_var.get() + 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}章...") finalize_chapter( @@ -1143,7 +1194,7 @@ class NovelGeneratorGUI: temperature=temperature, filepath=filepath, embedding_base_url=embedding_base_url, - embedding_api_key=self.embedding_api_key_var.get().strip() + embedding_api_key=embedding_api_key ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") @@ -1159,7 +1210,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ 一致性审校 ------------------ + # ------------------ 一致性审校 ------------------ def do_consistency_check(self): """使用审校Agent对最新章节进行简单一致性或冲突检查""" filepath = self.filepath_var.get().strip() @@ -1185,7 +1236,7 @@ class NovelGeneratorGUI: global_summary = read_file(global_summary_file) plot_arcs = read_file(plot_arcs_file) - chap_num = self.chapter_num_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) @@ -1215,7 +1266,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ 导入知识库/清空向量库/查看剧情要点 ------------------ + # ------------------ 导入知识库/清空向量库/查看剧情要点 ------------------ def import_knowledge_handler(self): selected_file = filedialog.askopenfilename( title="选择要导入的知识库文件", @@ -1233,7 +1284,7 @@ class NovelGeneratorGUI: embedding_model_name=self.embedding_model_name_var.get().strip(), file_path=selected_file, embedding_base_url=self.embedding_url_var.get().strip(), - filepath=self.filepath_var.get().strip() # 用于本地化 vectorstore + filepath=self.filepath_var.get().strip() ) self.safe_log("✅ 知识库文件导入完成。") except Exception: @@ -1253,8 +1304,10 @@ class NovelGeneratorGUI: if first_confirm: second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") if second_confirm: - clear_vector_store(filepath) - self.log("已清空向量库。") + if clear_vector_store(filepath): + self.log("已清空向量库。") + else: + self.log(f"未能清空向量库。请关闭程序后前往{filepath}文件夹手动删除vectorstore目录。") def show_plot_arcs_ui(self): filepath = self.filepath_var.get().strip() From 251d40f8a315eb1d396c905961b2316a7804378b Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 4 Feb 2025 16:23:08 +0800 Subject: [PATCH 046/142] Update ui.py --- ui.py | 55 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/ui.py b/ui.py index 5bd0248..d04ddc1 100644 --- a/ui.py +++ b/ui.py @@ -54,13 +54,14 @@ class NovelGeneratorGUI: self.loaded_config = load_config(self.config_file) # ========== 主要的属性变量 ========== + # LLM 接口相关 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) - # 注意:使用 DoubleVar/IntVar 时,如果用户输入空值可能报错,需要在取值时做安全处理 + # 仍然用 DoubleVar,但因为是 Slider,不会让用户手动清空文本,一般不会出现空字符串问题 self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) # Embedding 接口相关 @@ -69,21 +70,21 @@ class NovelGeneratorGUI: self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) - # 新增 Embedding 检索的 k 值(默认为4) - self.embedding_retrieval_k_var = ctk.IntVar(value=self.loaded_config.get("embedding_retrieval_k", 4)) + # ### CHANGED:将 IntVar 改为 StringVar,避免用户清空输入时抛错 + self.embedding_retrieval_k_var = ctk.StringVar(value=str(self.loaded_config.get("embedding_retrieval_k", 4))) # 小说通用参数 self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - # 章节数、每章字数等用 IntVar,但取值时需要安全转换 - self.num_chapters_var = ctk.IntVar(value=self.loaded_config.get("num_chapters", 10)) - self.word_number_var = ctk.IntVar(value=self.loaded_config.get("word_number", 3000)) + # ### CHANGED:将章节数、每章字数改为 StringVar + self.num_chapters_var = ctk.StringVar(value=str(self.loaded_config.get("num_chapters", 10))) + self.word_number_var = ctk.StringVar(value=str(self.loaded_config.get("word_number", 3000))) self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - # 当前要处理的章节号 - self.chapter_num_var = ctk.IntVar(value=1) + # ### CHANGED:章节号也改为 StringVar + self.chapter_num_var = ctk.StringVar(value="1") # ========== 主容器使用 TabView ========== self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) @@ -105,12 +106,17 @@ class NovelGeneratorGUI: self.build_summary_tab() self.build_chapters_tab() # 新增 - # ------------------ 工具方法:安全获取 IntVar ------------------ - def safe_get_int(self, int_var, default=0): + # ------------------ 工具方法:安全获取 IntVar (现已兼容 StringVar) ------------------ + def safe_get_int(self, var, default=1): + """ + 尝试把 StringVar 或 IntVar 中的值转换为 int; + 若失败则将其重置为 default 并返回 default。 + """ try: - return int(int_var.get()) - except Exception: - int_var.set(default) + val_str = str(var.get()).strip() + return int(val_str) + except: + var.set(str(default)) return default # ------------------ 主功能 Tab ------------------ @@ -478,7 +484,6 @@ class NovelGeneratorGUI: ) emb_model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") - # 新增:检索 K 值设置 emb_retrieval_k_label = ctk.CTkLabel( self.embeddings_config_tab, text="Retrieval Top-K:", @@ -571,7 +576,6 @@ class NovelGeneratorGUI: ) num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") - # 此处放一个小的Frame,用于在同一行放两个输入框 ch_word_frame = ctk.CTkFrame(self.params_frame) ch_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") ch_word_frame.columnconfigure(0, weight=0) @@ -926,18 +930,20 @@ class NovelGeneratorGUI: self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) self.embedding_url_var.set(cfg.get("embedding_url", "")) self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) - self.embedding_retrieval_k_var.set(cfg.get("embedding_retrieval_k", 4)) + # ### CHANGED:用字符串形式设值 + self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) # Novel self.genre_var.set(cfg.get("genre", "")) - # 安全转换,防止出现空值 - self.num_chapters_var.set(int(cfg.get("num_chapters", 10) or 10)) - self.word_number_var.set(int(cfg.get("word_number", 3000) or 3000)) + + # ### CHANGED:用字符串形式设值 + self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) + self.word_number_var.set(str(cfg.get("word_number", 3000))) self.filepath_var.set(cfg.get("filepath", "")) - # 主题 + topic_value = cfg.get("topic", "") self.topic_text.delete("0.0", "end") - self.topic_text.insert("0.0", cfg.get("topic", "")) + self.topic_text.insert("0.0", topic_value) self.log("已加载配置。") else: @@ -957,14 +963,13 @@ class NovelGeneratorGUI: "embedding_interface_format": self.embedding_interface_format_var.get(), "embedding_url": self.embedding_url_var.get(), "embedding_model_name": self.embedding_model_name_var.get(), - # 新增:向量库检索 K 值 - "embedding_retrieval_k": self.embedding_retrieval_k_var.get(), + "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), # Novel "topic": self.topic_text.get("0.0", "end").strip(), "genre": self.genre_var.get(), - "num_chapters": self.num_chapters_var.get(), - "word_number": self.word_number_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() } if save_config(config_data, self.config_file): From b60ac8c7e0e6021b89e8437c0c63c3b052090872 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 4 Feb 2025 16:37:29 +0800 Subject: [PATCH 047/142] 2 --- chapter_directory_parser.py | 3 --- novel_generator.py | 23 +---------------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py index f9fc23b..a2c6311 100644 --- a/chapter_directory_parser.py +++ b/chapter_directory_parser.py @@ -27,11 +27,8 @@ def get_chapter_info_from_directory(novel_directory_content: str, chapter_number if match: chap_num = int(match.group(1)) if chap_num == chapter_number: - # group(2) 可能是标题及简述的混合 full_title = match.group(2).strip() - # 这里假设用 '-' 进一步区分“标题 - 简述”,也可能用户没写“ - ” if ' - ' in full_title: - # 根据你的目录格式自由处理 parts = full_title.split(' - ', 1) return { "chapter_title": parts[0].strip(), diff --git a/novel_generator.py b/novel_generator.py index 0826b2e..d21e534 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -123,10 +123,8 @@ def create_embeddings_object( # ============ 向量库相关操作 ============ def clear_vector_store(filepath: str) -> bool: """ - 通过 Chroma API 移除集合数据后尝试删除整个 vectorstore 目录。 返回值表示是否成功清空向量库。 """ - from chromadb import Client import shutil store_dir = get_vectorstore_dir(filepath) @@ -135,19 +133,6 @@ def clear_vector_store(filepath: str) -> bool: return False try: - client = Client(settings=Settings( - persist_directory=store_dir, - allow_reset=True # 允许重置操作 - )) - collections = client.list_collections() - if collections: - client.delete_collection(name="novel_collection") - logging.info("Collection 'novel_collection' deleted via API.") - - client.reset() - logging.info("Client reset successfully.") - - # 直接删除整个 vectorstore 目录 if os.path.exists(store_dir): shutil.rmtree(store_dir) logging.info(f"Vector store directory '{store_dir}' removed.") @@ -156,13 +141,6 @@ def clear_vector_store(filepath: str) -> bool: logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") traceback.print_exc() return False - finally: - if 'client' in locals(): - try: - del client - except AttributeError: - logging.warning("Client object not found to delete.") - def init_vector_store( api_key: str, @@ -245,6 +223,7 @@ def split_text_for_vectorstore(chapter_text: str, return [] nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(chapter_text) if not sentences: return [] From 542846f96976fbaf4f879b89fe676fd293c64157 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 4 Feb 2025 18:14:55 +0800 Subject: [PATCH 048/142] 1.3 --- main.spec | 4 ++-- novel_generator.py | 1 + prompt_definitions.py | 2 +- ui.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/main.spec b/main.spec index 2c7b79a..4694d44 100644 --- a/main.spec +++ b/main.spec @@ -44,7 +44,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.3.0', + name='AI_NovelGenerator_V1.3.2', debug=True, bootloader_ignore_signals=False, strip=False, @@ -65,5 +65,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.3.0' + name='AI_NovelGenerator_V1.3.2' ) diff --git a/novel_generator.py b/novel_generator.py index d21e534..7505948 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -599,6 +599,7 @@ def generate_chapter_draft( global_summary=global_summary, chapter_outline=chapter_outline, word_number=word_number, + novel_number=novel_number, chapter_title=chapter_title, chapter_brief=chapter_brief ) diff --git a/prompt_definitions.py b/prompt_definitions.py index ff18c2f..9e99408 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -139,5 +139,5 @@ chapter_write_prompt = """\ • 保持连贯叙述,可增加环境、心理、对话等细节描写。 • 适度呼应前文暗线或角色矛盾,为后续发展留出悬念。 -直接输出正文内容,不使用分章节小标题,章节末留一定悬念。 +直接输出正文内容,不使用分章节小标题,章节末模仿正常小说中断或转场。 """ diff --git a/ui.py b/ui.py index d04ddc1..73d4d12 100644 --- a/ui.py +++ b/ui.py @@ -418,7 +418,7 @@ class NovelGeneratorGUI: def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): if new_value == "Ollama": - self.embedding_url_var.set("http://localhost:11434/v1") + self.embedding_url_var.set("http://localhost:11434/api") elif new_value == "ML Studio": self.embedding_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": From 45d470fde0bbb37f464e71899889111a14699cd0 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 4 Feb 2025 22:57:21 +0800 Subject: [PATCH 049/142] Update ui.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在修改草稿后的定稿阶段,进行文件保存工作 --- ui.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui.py b/ui.py index 73d4d12..7e2f461 100644 --- a/ui.py +++ b/ui.py @@ -1188,6 +1188,12 @@ class NovelGeneratorGUI: word_number = self.safe_get_int(self.word_number_var, 3000) self.safe_log(f"开始定稿第{chap_num}章...") + chapters_dir = os.path.join(filepath, "chapters") + chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt") + edited_text = self.chapter_result.get("0.0", "end").strip() + clear_file_content(chapter_file) + save_string_to_txt(edited_text, chapter_file) + finalize_chapter( novel_number=chap_num, word_number=word_number, From b80a5cde35ac08740217001233d5f852835aff46 Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:22:14 +0800 Subject: [PATCH 050/142] Create LICENSE --- LICENSE | 661 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. From bdce84b72694ab95b6c18c24aa3197e1a0ec870c Mon Sep 17 00:00:00 2001 From: raptoravis Date: Wed, 5 Feb 2025 20:57:53 +0800 Subject: [PATCH 051/142] langchain_chroma added --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index f75acc9..6c3d413 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ customtkinter openai chromadb langchain-community +langchain_chroma sentence_transformers scikit-learn nltk \ No newline at end of file From d230d4ba239fe990d34c74388f1b500f5c106af5 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 5 Feb 2025 21:12:57 +0800 Subject: [PATCH 052/142] new thinks --- main.spec | 7 +- prompt_definitions.py | 271 ++++++++++++++++++++++++++++-------------- 2 files changed, 183 insertions(+), 95 deletions(-) diff --git a/main.spec b/main.spec index 4694d44..94baffe 100644 --- a/main.spec +++ b/main.spec @@ -14,7 +14,8 @@ hiddenimports = ['typing_extensions', 'pydantic', 'pydantic.deprecated.decorator', 'tiktoken_ext.openai_public', - 'tiktoken_ext' + 'tiktoken_ext', + 'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2' ] tmp_ret = collect_all('chromadb') @@ -44,7 +45,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.3.2', + name='AI_NovelGenerator_V1.3.3', debug=True, bootloader_ignore_signals=False, strip=False, @@ -65,5 +66,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.3.2' + name='AI_NovelGenerator_V1.3.3' ) diff --git a/prompt_definitions.py b/prompt_definitions.py index 9e99408..ebe588c 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,86 +1,148 @@ # prompt_definitions.py # -*- coding: utf-8 -*- """ -集中存放所有提示词(Prompt),新版本更精确、更具创新性,结合部分雪花写作法等理论。 +集中存放所有提示词(Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型 """ -# =============== 1. 整体设定 =================== -set_prompt = """\ -请根据主题: {topic}、类型: {genre}、章数: {number_of_chapters}、每章字数: {word_number} 来设计小说的总体结构和世界观。 -写作时请参考雪花写作法等创作理论,结合以下要点: +# =============== 1. 核心种子设定(雪花第1层)=================== +core_seed_prompt = """\ +作为专业作家,请用"雪花写作法"第一步构建故事核心: +主题:{topic} +类型:{genre} +篇幅:约{number_of_chapters}章(每章{word_number}字) -• 小说标题与大致篇幅(总字数范围)。 -• 类型与基调(如:都市、魔幻、战争、轻松、暗黑等)。 -• 主要写作风格(视角、叙述方式、语言风格等)。 -• 世界观设定(背景时间、地理环境、社会结构、科技或魔法水平、重要历史等)。 -• 整体故事框架(可提及常见叙事结构:三幕、英雄之旅等)。 -• 主线与副线的初步构想,以及它们如何交织。 -• 关键角色群像定位与主要冲突关系。 -• 结局的可能方向(圆满、悲剧、开放式等)。 +请用单句公式概括故事本质,例如: +"当[主角]遭遇[核心事件],必须[关键行动],否则[灾难后果];与此同时,[隐藏的更大危机]正在发酵。" -请以简洁、逻辑清晰的方式输出,保留足够细节以支撑后续创作。 +要求: +1. 必须包含显性冲突与潜在危机 +2. 体现人物核心驱动力 +3. 暗示世界观关键矛盾 +4. 使用25-100字精准表达 + +仅返回故事核心文本,不要解释任何内容。 """ -# =============== 2. 角色设定 =================== -character_prompt = """\ -基于已生成的小说整体设定: -{novel_setting} +# =============== 2. 角色动力学设定(角色弧光模型)=================== +character_dynamics_prompt = """\ +基于核心种子: +{core_seed} -请进一步扩展角色设置,至少包含三名核心角色。对每位角色说明: -• 角色背景、外貌与主要性格特征。 -• 内在冲突、目标与动机。 -• 暗藏的秘密或潜在弱点(可与世界观或其他角色有关)。 -• 与其他角色的关系或对立点,如何推动或阻碍情节发展。 +请设计3-6个具有动态变化潜力的核心角色,每个角色需包含: +特征: +- 背景、外貌、性别、年龄、职业等 +- 暗藏的秘密或潜在弱点(可与世界观或其他角色有关) -请重点突出角色的多重面向,为后续情节埋下伏笔。 +核心驱动力三角: +- 表面追求(物质目标) +- 深层渴望(情感需求) +- 灵魂需求(哲学层面) + +角色弧线设计: +初始状态 → 触发事件 → 认知失调 → 蜕变节点 → 最终状态 + +关系冲突网: +- 与其他角色的关系或对立点 +- 与至少两个其他角色的价值观冲突 +- 一个合作纽带 +- 一个隐藏的背叛可能性 + +要求: +仅给出最终文本,不要解释任何内容。 """ -# =============== 3. 暗线与伏笔 =================== -dark_lines_prompt = """\ -在当前世界观与角色关系中: -{character_info} +# =============== 3. 世界构建矩阵(三维度交织法)=================== +world_building_prompt = """\ +为服务核心冲突"{core_seed}",请构建三维交织的世界观: -请构思若干暗线、伏笔或隐藏冲突,结合雪花写作法“细节逐步扩展”的思路。要求: -• 每条暗线给出初始迹象、后续发展与可能的爆发条件。 -• 与角色背景、世界观或关键事件相呼应。 -• 保持合理的悬念设置,与现有设定不冲突。 -• 为后续剧情保留足够展开空间,强调持续影响故事进程。 +1. 物理维度: +- 空间结构(地理×社会阶层分布图) +- 时间轴(关键历史事件年表) +- 法则体系(物理/魔法/社会规则的漏洞点) -请避免一次性透漏全部细节,以逐步揭示的方式为后文做铺垫。 +2. 社会维度: +- 权力结构断层线(可引发冲突的阶层/种族/组织矛盾) +- 文化禁忌(可被打破的禁忌及其后果) +- 经济命脉(资源争夺焦点) + +3. 隐喻维度: +- 贯穿全书的视觉符号系统(如反复出现的意象) +- 气候/环境变化映射的心理状态 +- 建筑风格暗示的文明困境 + +要求: +每个维度至少包含3个可与角色决策产生互动的动态元素。 +仅给出最终文本,不要解释任何内容。 """ -# =============== 4. 最终设定整合 =================== -finalize_setting_prompt = """\ -基于以下内容,请整合并输出终稿《小说设定》: -1. 整体设定: -{novel_setting_base} -2. 角色设定: -{character_setting} -3. 暗线与伏笔: -{dark_lines} +# =============== 4. 情节架构(悬念三幕式)=================== +plot_architecture_prompt = """\ +基于以下元素构建三幕式悬念架构: +核心种子:{core_seed} +角色体系:{character_dynamics} +世界观:{world_building} -整合要求: -• 以整体视角整理世界观、角色与暗线,形成一个有机结合的故事框架。 -• 注意角色动机与暗线如何与世界观互相呼应,并兼顾主线与副线节奏。 -• 语言通顺、层次分明。直接输出文本,不使用Markdown格式。 +要求按以下结构设计: +▶ 第一幕(触发) +- 日常状态中的异常征兆(3处铺垫) +- 引出故事:展示主线、暗线、副线的开端 +- 关键事件:打破平衡的催化剂(需改变至少3个角色的关系) +- 错误抉择:主角的认知局限导致的错误反应 + +▶ 第二幕(对抗) +- 剧情升级:主线+副线的交叉点 +- 双重压力:外部障碍升级+内部挫折 +- 虚假胜利:看似解决实则深化危机的转折点 +- 灵魂黑夜:世界观认知颠覆时刻 + +▶ 第三幕(解决) +- 代价显现:解决危机必须牺牲的核心价值 +- 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择) +- 余波:留下2个开放式悬念因子 + +每个阶段需包含3个关键转折点及其对应的伏笔回收方案。 +仅给出最终文本,不要解释任何内容。 """ -# =============== 5. 小说目录 =================== -novel_directory_prompt = """\ -根据以下最终《小说设定》: -{final_novel_setting} +# =============== 5. 章节目录生成(悬念节奏曲线)=================== +chapter_blueprint_prompt = """\ +根据三幕式架构: +{plot_architecture} -请列出共 {number_of_chapters} 章的目录,并保证每章的标题或简述能呼应主要冲突、角色成长及暗线埋设。 -目录格式示例: -第1章 :<标题> - <简要说明> -第2章 :<标题> - <简要说明> -... -第{number_of_chapters}章 :<标题> - <简要说明> +设计{number_of_chapters}章的节奏分布: +1. 每章需明确: +- 核心悬念类型(信息差/道德困境/时间压力等) +- 情感基调迁移(如从怀疑→恐惧→决绝) +- 伏笔操作(埋设/强化/回收) +- 认知颠覆强度(1-5级) -每章可酌情加入一句简述,引导读者了解本章看点。直接输出文本,不使用Markdown。 +2. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +输出格式示例: +第n章 - [标题] +本章定位:[角色/事件/主题] +核心作用:[推进/转折/揭示] +悬念密度:[紧凑/渐进/爆发] +伏笔操作:埋设(A线索)→强化(B矛盾) +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +第n+1章 - [标题] +本章定位:[角色/事件/主题] +核心作用:[推进/转折/揭示] +悬念密度:[紧凑/渐进/爆发] +伏笔操作:埋设(A线索)→强化(B矛盾) +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +使用精炼语言描述,每章字数控制在100字以内。 +仅给出最终文本,不要解释任何内容。 """ -# =============== 6. 全局摘要更新 =================== +# =============== 6. 全局摘要更新 =================== summary_prompt = """\ 以下是新完成的章节文本: {chapter_text} @@ -88,10 +150,17 @@ summary_prompt = """\ 这是当前的全局摘要(可为空): {global_summary} -请根据本章新增内容,更新全局摘要。保留既有重要信息,同时融入新剧情要点,勿剧透未来。控制在不超过3000字的范围内,语言简练流畅。 +请根据本章新增内容,更新全局摘要。 +要求: +- 保留既有重要信息,同时融入新剧情要点 +- 以简洁、连贯的语言描述全书进展 +- 客观描绘,不展开联想或解释 +- 字数控制在2000字以内 + +仅返回全局摘要文本,不要解释任何内容。 """ -# =============== 7. 角色状态更新 =================== +# =============== 7. 角色状态更新 =================== update_character_state_prompt = """\ 以下是新完成的章节文本: {chapter_text} @@ -100,44 +169,62 @@ update_character_state_prompt = """\ {old_state} 请更新角色状态,内容包括: -• 角色的物品、能力或心理状态变化。 -• 角色间关系的最新进展或冲突。 -• 是否触发或加深了某些暗线或关键事件。 -• 任何新增角色或临时出场人物的基本信息。 +角色属性: +- 角色物品 +- 能力 +- 状态 +- 主要角色间关系网 +- 触发或加深的事件 -请直接在已有文档基础上进行增删,语言尽量简洁、有条理。 +新出场角色: +- 任何新增角色或临时出场人物的基本信息 + +要求: +- 请直接在已有文档基础上进行增删 +- 不改变原有结构,语言尽量简洁、有条理 + +仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 8. 章节大纲设计 =================== -chapter_outline_prompt = """\ -这是当前小说的重要信息: +# =============== 7. 章节正文写作 =================== +scene_dynamics_prompt = """\ +即将创作:第{novel_number}章《{chapter_title}》 +本章定位:{chapter_role} +核心作用:{chapter_purpose} +悬念密度:{suspense_level} +伏笔操作:{foreshadowing} +认知颠覆:{plot_twist_level} +本章简述:{chapter_summary} + +可用元素: +- 核心人物(可能未指定):{characters_involved} +- 关键道具(可能未指定):{key_items} +- 空间坐标(可能未指定):{scene_location} +- 时间压力(可能未指定):{time_constraint} + +参考文档: +- 世界观:{world_building} - 小说设定:{novel_setting} -- 角色状态:{character_state} - 全局摘要:{global_summary} +- 角色状态:{character_state} -我们即将写第 {novel_number} 章,标题:{chapter_title},简述(若有):{chapter_brief} +请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +1. 对话场景: +- 潜台词冲突(表面谈论A,实际博弈B) +- 权力关系变化(使用非对称对话长度控制) +- 至少1处双关语暗示未来危机 -请按照以下思路设计本章大纲: -1. 本章的主要冲突或情节目标,如何与标题呼应。 -2. 出场角色及其动机,对剧情走向的推动或阻碍。 -3. 暗线或伏笔如何有所进展或被揭示。 -4. 本章结尾的悬念或转折,如何为下一章做铺垫。 +2. 动作场景: +- 环境交互细节(至少3个感官描写维度) +- 节奏控制(短句加速+比喻减速) +- 动作揭示人物隐藏特质 -请以简要分点方式输出,不要使用Markdown格式。 -""" +3. 心理场景: +- 认知失调的具体表现(行为矛盾) +- 隐喻系统的运用(连接世界观符号) +- 决策前的价值天平描写 -# =============== 9. 章节正文写作 =================== -chapter_write_prompt = """\ -以下信息供你参考: -1. 小说设定:{novel_setting} -2. 角色状态:{character_state} -3. 全局摘要:{global_summary} -4. 本章大纲:{chapter_outline} +最后设置一个"钩链转折":结尾同时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 -请写出第 {novel_number} 章的正文,标题为“{chapter_title}”。需要: -• 字数不少于 {word_number} 字,与标题和简述相呼应。 -• 保持连贯叙述,可增加环境、心理、对话等细节描写。 -• 适度呼应前文暗线或角色矛盾,为后续发展留出悬念。 - -直接输出正文内容,不使用分章节小标题,章节末模仿正常小说中断或转场。 -""" +仅返回章节正文文本,不要解释任何内容。 +""" \ No newline at end of file From dd786660717512e2381816bfa585c2db537ea30d Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 5 Feb 2025 21:55:48 +0800 Subject: [PATCH 053/142] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chapter_directory_parser.py | 149 +++- novel_generator copy.py | 830 +++++++++++++++++ novel_generator.py | 409 +++++---- prompt_definitions.py | 4 +- ui.py | 1676 ++++++++++++++--------------------- 5 files changed, 1859 insertions(+), 1209 deletions(-) create mode 100644 novel_generator copy.py diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py index a2c6311..796b9f5 100644 --- a/chapter_directory_parser.py +++ b/chapter_directory_parser.py @@ -1,47 +1,126 @@ -# chapter_directory_parser.py +# chapter_blueprint_parser.py # -*- coding: utf-8 -*- import re -def get_chapter_info_from_directory(novel_directory_content: str, chapter_number: int): +def parse_chapter_blueprint(blueprint_text: str): """ - 从给定的 novel_directory_content 文本中,解析 “第X章” 行,并提取本章的标题和可能的简述。 - 返回一个 dict: { - "chapter_title": <字符串>, - "chapter_brief": <字符串> (若没有则为空) + 解析整份章节蓝图文本,返回一个列表,每个元素是一个 dict: + { + "chapter_number": int, + "chapter_title": str, + "chapter_role": str, # 本章定位 + "chapter_purpose": str, # 核心作用 + "suspense_level": str, # 悬念密度 + "foreshadowing": str, # 伏笔操作 + "plot_twist_level": str, # 认知颠覆 + "chapter_summary": str # 本章简述 } - 注意:目录文本示例格式: - 第1章 :潮起 - 第2章 :阴影浮现 - 主要角色冲突爆发 - ... - 也可能没有简述,只有一个简单标题。 """ - # 将文本逐行拆分 - lines = novel_directory_content.splitlines() + # 先按空行进行分块,以免多章之间混淆 + chunks = re.split(r'\n\s*\n', blueprint_text.strip()) + results = [] - # 章节匹配:形如 “第5章 :xxx” or “第5章: xxx” or “第5章 xxx” - pattern = re.compile(r'^第\s*(\d+)\s*章\s*[::]?\s*(.*)$') + chapter_number_pattern = re.compile(r'^第\s*(\d+)\s*章\s*-\s*\[(.*?)\]') # 捕获章号与标题 + role_pattern = re.compile(r'^本章定位:\s*(.*)$') + purpose_pattern = re.compile(r'^核心作用:\s*(.*)$') + suspense_pattern = re.compile(r'^悬念密度:\s*(.*)$') + foreshadow_pattern = re.compile(r'^伏笔操作:\s*(.*)$') + twist_pattern = re.compile(r'^认知颠覆:\s*(.*)$') + summary_pattern = re.compile(r'^本章简述:\s*\[(.*)\]$') - for line in lines: - match = pattern.match(line.strip()) - if match: - chap_num = int(match.group(1)) - if chap_num == chapter_number: - full_title = match.group(2).strip() - if ' - ' in full_title: - parts = full_title.split(' - ', 1) - return { - "chapter_title": parts[0].strip(), - "chapter_brief": parts[1].strip() - } - else: - return { - "chapter_title": full_title, - "chapter_brief": "" - } + for chunk in chunks: + lines = chunk.strip().splitlines() + if not lines: + continue - # 如果没有匹配到,返回默认 + chapter_number = None + chapter_title = "" + chapter_role = "" + chapter_purpose = "" + suspense_level = "" + foreshadowing = "" + plot_twist_level = "" + chapter_summary = "" + + # 先匹配第一行(或前几行),找到章号和标题 + header_match = chapter_number_pattern.match(lines[0].strip()) if lines else None + if not header_match: + # 不符合格式,跳过 + continue + + chapter_number = int(header_match.group(1)) + chapter_title = header_match.group(2).strip() + + # 从后面的行匹配其他字段 + for line in lines[1:]: + line_stripped = line.strip() + if not line_stripped: + continue + + m_role = role_pattern.match(line_stripped) + if m_role: + chapter_role = m_role.group(1).strip() + continue + + m_purpose = purpose_pattern.match(line_stripped) + if m_purpose: + chapter_purpose = m_purpose.group(1).strip() + continue + + m_suspense = suspense_pattern.match(line_stripped) + if m_suspense: + suspense_level = m_suspense.group(1).strip() + continue + + m_foreshadow = foreshadow_pattern.match(line_stripped) + if m_foreshadow: + foreshadowing = m_foreshadow.group(1).strip() + continue + + m_twist = twist_pattern.match(line_stripped) + if m_twist: + plot_twist_level = m_twist.group(1).strip() + continue + + m_summary = summary_pattern.match(line_stripped) + if m_summary: + chapter_summary = m_summary.group(1).strip() + continue + + results.append({ + "chapter_number": chapter_number, + "chapter_title": chapter_title, + "chapter_role": chapter_role, + "chapter_purpose": chapter_purpose, + "suspense_level": suspense_level, + "foreshadowing": foreshadowing, + "plot_twist_level": plot_twist_level, + "chapter_summary": chapter_summary + }) + + # 按照 chapter_number 排序后返回 + results.sort(key=lambda x: x["chapter_number"]) + return results + + +def get_chapter_info_from_blueprint(blueprint_text: str, target_chapter_number: int): + """ + 在已经加载好的章节蓝图文本中,找到对应章号的结构化信息,返回一个 dict。 + 若找不到则返回一个默认的结构。 + """ + all_chapters = parse_chapter_blueprint(blueprint_text) + for ch in all_chapters: + if ch["chapter_number"] == target_chapter_number: + return ch + # 默认返回 return { - "chapter_title": f"第{chapter_number}章", - "chapter_brief": "" + "chapter_number": target_chapter_number, + "chapter_title": f"第{target_chapter_number}章", + "chapter_role": "", + "chapter_purpose": "", + "suspense_level": "", + "foreshadowing": "", + "plot_twist_level": "", + "chapter_summary": "" } diff --git a/novel_generator copy.py b/novel_generator copy.py new file mode 100644 index 0000000..7505948 --- /dev/null +++ b/novel_generator copy.py @@ -0,0 +1,830 @@ +# novel_generator.py +# -*- coding: utf-8 -*- +import os +import logging +import re +import time +import traceback +from typing import List, Optional + +# langchain 相关 +from langchain_openai import ChatOpenAI, OpenAIEmbeddings +from langchain_chroma import Chroma +from chromadb.config import Settings +from langchain.docstore.document import Document + +# nltk、sentence_transformers 及文本处理相关 +import nltk +import math +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity + +# 工具函数 +from utils import ( + read_file, append_text_to_file, clear_file_content, + save_string_to_txt +) + +# prompt模板 +from prompt_definitions import ( + # 设定相关 + set_prompt, character_prompt, dark_lines_prompt, + finalize_setting_prompt, novel_directory_prompt, + + # 写作流程相关 + summary_prompt, update_character_state_prompt, + chapter_outline_prompt, chapter_write_prompt +) + +# Ollama嵌入 (如使用Ollama时需要) +from embedding_ollama import OllamaEmbeddings + +# 用于目录解析章节标题/简介 +from chapter_directory_parser import get_chapter_info_from_directory + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + + +# ============ 帮助函数 ============ +def remove_think_tags(text: str) -> str: + """移除 ... 包裹的内容""" + return re.sub(r'.*?', '', text, flags=re.DOTALL) + +def debug_log(prompt: str, response_content: str): + logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n") + logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n") + +def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: + """通用封装:调用模型并移除 ... 文本,记录日志后返回""" + response = model.invoke(prompt) + if not response: + logging.warning("No response from model.") + return "" + cleaned_text = remove_think_tags(response.content) + debug_log(prompt, cleaned_text) + return cleaned_text.strip() + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + """ + import re + url = url.strip() + if not url: + return url + if not re.search(r'/v\d+$', url): + if '/v1' not in url: + url = url.rstrip('/') + '/v1' + return url + +def is_using_ollama_api(interface_format: str) -> bool: + return interface_format.lower() == "ollama" + +def is_using_ml_studio_api(interface_format: str) -> bool: + return interface_format.lower() == "ml studio" + + +# ============ 获取 vectorstore 路径 ============ +def get_vectorstore_dir(filepath: str) -> str: + """ + 返回存储向量库的本地路径: + 在用户指定的 `filepath` 下创建/使用 'vectorstore' 文件夹。 + """ + return os.path.join(filepath, "vectorstore") + + +# ============ 创建 Embeddings 对象 ============ +def create_embeddings_object( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str +): + """ + 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 + """ + if is_using_ollama_api(interface_format): + fixed_url = base_url.rstrip("/") + return OllamaEmbeddings( + model_name=embedding_model_name, + base_url=fixed_url + ) + else: + # OpenAI 或 ML Studio 均使用 OpenAIEmbeddings,注意 base_url 可能需要 ensure /v1 + fixed_url = ensure_openai_base_url_has_v1(base_url) + return OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=fixed_url, + model=embedding_model_name + ) + + +# ============ 向量库相关操作 ============ +def clear_vector_store(filepath: str) -> bool: + """ + 返回值表示是否成功清空向量库。 + """ + import shutil + + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("No vector store found to clear.") + return False + + try: + if os.path.exists(store_dir): + shutil.rmtree(store_dir) + logging.info(f"Vector store directory '{store_dir}' removed.") + return True + except Exception as e: + logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") + traceback.print_exc() + return False + +def init_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + texts: List[str], + filepath: str +) -> Chroma: + """ + 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 + """ + store_dir = get_vectorstore_dir(filepath) + os.makedirs(store_dir, exist_ok=True) + + embeddings = create_embeddings_object( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name + ) + documents = [Document(page_content=str(t)) for t in texts] + vectorstore = Chroma.from_documents( + documents, + embedding=embeddings, + persist_directory=store_dir, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + return vectorstore + + +def load_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + filepath: str +) -> Optional[Chroma]: + """ + 读取已存在的 Chroma 向量库。若不存在则返回 None。 + """ + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("Vector store not found. Will return None.") + return None + + embeddings = create_embeddings_object( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name + ) + return Chroma( + persist_directory=store_dir, + embedding_function=embeddings, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + + +def split_by_length(text: str, max_length: int = 500) -> List[str]: + segments = [] + start_idx = 0 + while start_idx < len(text): + end_idx = min(start_idx + max_length, len(text)) + segment = text[start_idx:end_idx] + segments.append(segment.strip()) + start_idx = end_idx + return segments + + +def split_text_for_vectorstore(chapter_text: str, + max_length: int = 500, + similarity_threshold: float = 0.7) -> List[str]: + """ + 对新的章节文本进行分段后,再用于存入向量库。 + """ + if not chapter_text.strip(): + return [] + + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + sentences = nltk.sent_tokenize(chapter_text) + if not sentences: + return [] + + # 先对相近句子进行合并 + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + current_sentences.append(sentences[i]) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + + # 再对合并好的段落做 max_length 切分 + final_segments = [] + for para in merged_paragraphs: + if len(para) > max_length: + sub_segments = split_by_length(para, max_length=max_length) + final_segments.extend(sub_segments) + else: + final_segments.append(para) + + return final_segments + + +def update_vector_store( + api_key: str, + base_url: str, + new_chapter: str, + interface_format: str, + embedding_model_name: str, + filepath: str +): + """ + 将最新章节文本插入到向量库中。若库不存在则初始化。 + """ + splitted_texts = split_text_for_vectorstore(new_chapter) + if not splitted_texts: + logging.warning("No valid text to insert into vector store. Skipping.") + return + + store = load_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath + ) + if not store: + logging.info("Vector store does not exist. Initializing a new one for new chapter...") + init_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=splitted_texts, + filepath=filepath + ) + return + + docs = [Document(page_content=str(t)) for t in splitted_texts] + store.add_documents(docs) + logging.info("Vector store updated with the new chapter splitted segments.") + + +def get_relevant_context_from_vector_store( + api_key: str, + base_url: str, + query: str, + interface_format: str, + embedding_model_name: str, + filepath: str, + k: int = 2 +) -> str: + """ + 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 + """ + store = load_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath + ) + if not store: + logging.info("No vector store found. Returning empty context.") + return "" + + docs = store.similarity_search(query, k=k) + if not docs: + logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") + return "" + + combined = "\n".join([d.page_content for d in docs]) + return combined + + +# ============ 1. 生成小说“设定” (Novel_setting.txt) ============ +def Novel_setting_generate( + api_key: str, + base_url: str, + llm_model: str, + topic: str, + genre: str, + number_of_chapters: int, + word_number: int, + filepath: str, + temperature: float = 0.7 +) -> None: + os.makedirs(filepath, exist_ok=True) + + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + # Step1: 基础设定 + prompt_base = set_prompt.format( + topic=topic, + genre=genre, + number_of_chapters=number_of_chapters, + word_number=word_number + ) + base_setting = invoke_with_cleaning(model, prompt_base) + + # Step2: 角色设定 + prompt_char = character_prompt.format( + novel_setting=base_setting + ) + character_setting = invoke_with_cleaning(model, prompt_char) + + # Step3: 暗线/雷点 + prompt_dark = dark_lines_prompt.format( + character_info=character_setting + ) + dark_lines = invoke_with_cleaning(model, prompt_dark) + + # Step4: 最终整合 + prompt_final = finalize_setting_prompt.format( + novel_setting_base=base_setting, + character_setting=character_setting, + dark_lines=dark_lines + ) + final_novel_setting = invoke_with_cleaning(model, prompt_final) + + filename_set = os.path.join(filepath, "Novel_setting.txt") + clear_file_content(filename_set) + + final_novel_setting_cleaned = final_novel_setting.replace('#', '').replace('*', '') + save_string_to_txt(final_novel_setting_cleaned, filename_set) + logging.info("Novel_setting.txt has been generated successfully.") + + +# ============ 2. 生成小说目录 (Novel_directory.txt) ============ +def Novel_directory_generate( + api_key: str, + base_url: str, + llm_model: str, + number_of_chapters: int, + filepath: str, + temperature: float = 0.7 +) -> None: + filename_set = os.path.join(filepath, "Novel_setting.txt") + final_novel_setting = read_file(filename_set).strip() + if not final_novel_setting: + logging.warning("Novel_setting.txt 内容为空,请先生成小说设定。") + return + + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + prompt_dir = novel_directory_prompt.format( + final_novel_setting=final_novel_setting, + number_of_chapters=number_of_chapters + ) + final_novel_directory = invoke_with_cleaning(model, prompt_dir) + if not final_novel_directory.strip(): + logging.warning("Novel_directory生成结果为空。") + return + + filename_dir = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename_dir) + + final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') + save_string_to_txt(final_novel_directory_cleaned, filename_dir) + + logging.info("Novel_directory.txt has been generated successfully.") + + +# ============ 获取最近 N 章内容,生成短期摘要 ============ +def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: + texts = [] + start_chap = max(1, current_chapter_num - n) + for c in range(start_chap, current_chapter_num): + chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") + if os.path.exists(chap_file): + text = read_file(chap_file).strip() + if text: + texts.append(text) + if len(texts) < n: + texts = [''] * (n - len(texts)) + texts + return texts + +def summarize_recent_chapters( + llm_model: str, + api_key: str, + base_url: str, + temperature: float, + chapters_text_list: List[str] +) -> str: + if not chapters_text_list: + return "" + if all(not txt.strip() for txt in chapters_text_list): + return "暂无摘要。" + + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + combined_text = "\n".join(chapters_text_list) + prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: +{combined_text} + +请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" + + summary_text = invoke_with_cleaning(model, prompt) + if not summary_text: + return (combined_text[:800] + "...") if len(combined_text) > 800 else combined_text + return summary_text + + +# ============ 剧情要点/冲突 ============ +PLOT_ARCS_PROMPT = """\ +下面是新生成的章节内容: +{chapter_text} + +这里是已记录的剧情要点/未解决冲突(可能为空): +{old_plot_arcs} + +请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 +若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 +最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 +""" + +def update_plot_arcs( + chapter_text: str, + old_plot_arcs: str, + api_key: str, + base_url: str, + model_name: str, + temperature: float +) -> str: + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + prompt = PLOT_ARCS_PROMPT.format( + chapter_text=chapter_text, + old_plot_arcs=old_plot_arcs + ) + arcs_text = invoke_with_cleaning(model, prompt) + if not arcs_text: + logging.warning("update_plot_arcs: No response or empty result.") + return old_plot_arcs + return arcs_text + + +# ============ 生成章节草稿 ============ +def generate_chapter_draft( + novel_settings: str, + global_summary: str, + character_state: str, + recent_chapters_summary: str, + user_guidance: str, + api_key: str, + base_url: str, + model_name: str, + novel_number: int, + word_number: int, + temperature: float, + novel_novel_directory: str, + filepath: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str, + embedding_retrieval_k: int = 4 +) -> str: + # 1) 根据目录解析标题、简介 + chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) + chapter_title = chapter_info["chapter_title"] + chapter_brief = chapter_info["chapter_brief"] + + # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) + combined_query_parts = [] + if user_guidance.strip(): + combined_query_parts.append(user_guidance) + if chapter_brief.strip(): + combined_query_parts.append(chapter_brief) + if recent_chapters_summary.strip(): + combined_query_parts.append(recent_chapters_summary) + # 额外加一个关键字 + combined_query_parts.append("回顾剧情") + + merged_query_str = "\n".join(combined_query_parts) + + # 2) 从向量库检索上下文 + relevant_context = get_relevant_context_from_vector_store( + api_key=api_key, + base_url=embedding_base_url if embedding_base_url else base_url, + query=merged_query_str, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath, + k=embedding_retrieval_k + ) + if not relevant_context.strip(): + relevant_context = "暂无相关内容。" + + # 3) 生成本章大纲 + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + outline_prompt_text = chapter_outline_prompt.format( + novel_setting=novel_settings, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, + global_summary=global_summary, + novel_number=novel_number, + chapter_title=chapter_title, + chapter_brief=chapter_brief + ) + outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" + outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" + + chapter_outline = invoke_with_cleaning(model, outline_prompt_text) + + outlines_dir = os.path.join(filepath, "outlines") + os.makedirs(outlines_dir, exist_ok=True) + outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") + clear_file_content(outline_file) + save_string_to_txt(chapter_outline, outline_file) + + # 4) 生成正文草稿 + writing_prompt_text = chapter_write_prompt.format( + novel_setting=novel_settings, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, + global_summary=global_summary, + chapter_outline=chapter_outline, + word_number=word_number, + novel_number=novel_number, + chapter_title=chapter_title, + chapter_brief=chapter_brief + ) + writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" + writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" + + chapter_content = invoke_with_cleaning(model, writing_prompt_text) + + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) + chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + clear_file_content(chapter_file) + save_string_to_txt(chapter_content, chapter_file) + + logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") + return chapter_content + + +# ============ 定稿章节 ============ +def finalize_chapter( + novel_number: int, + word_number: int, + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + model_name: str, + temperature: float, + filepath: str, + embedding_base_url: str, + embedding_api_key: str +): + chapters_dir = os.path.join(filepath, "chapters") + chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + chapter_text = read_file(chapter_file).strip() + if not chapter_text: + logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") + return + + character_state_file = os.path.join(filepath, "character_state.txt") + global_summary_file = os.path.join(filepath, "global_summary.txt") + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") + + old_char_state = read_file(character_state_file) + old_global_summary = read_file(global_summary_file) + old_plot_arcs = read_file(plot_arcs_file) + + # 篇幅不足,二次扩写 + if len(chapter_text) < 0.8 * word_number: + logging.info("Chapter text is shorter than 80% of desired length. Enriching...") + chapter_text = enrich_chapter_text( + chapter_text=chapter_text, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature + ) + clear_file_content(chapter_file) + save_string_to_txt(chapter_text, chapter_file) + + # 更新全局摘要 + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + def update_global_summary(chapter_text: str, old_summary: str) -> str: + prompt = summary_prompt.format( + chapter_text=chapter_text, + global_summary=old_summary + ) + return invoke_with_cleaning(model, prompt) or old_summary + + new_global_summary = update_global_summary(chapter_text, old_global_summary) + + # 更新角色状态 + def update_character_state(chapter_text: str, old_state: str) -> str: + prompt = update_character_state_prompt.format( + chapter_text=chapter_text, + old_state=old_state + ) + return invoke_with_cleaning(model, prompt) or old_state + + new_char_state = update_character_state(chapter_text, old_char_state) + + # 更新剧情要点 + new_plot_arcs = update_plot_arcs( + chapter_text=chapter_text, + old_plot_arcs=old_plot_arcs, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature + ) + + # 写回文件 + clear_file_content(character_state_file) + save_string_to_txt(new_char_state, character_state_file) + + clear_file_content(global_summary_file) + save_string_to_txt(new_global_summary, global_summary_file) + + clear_file_content(plot_arcs_file) + save_string_to_txt(new_plot_arcs, plot_arcs_file) + + # 更新向量库(此时用 embedding_api_key/embedding_base_url) + update_vector_store( + api_key=embedding_api_key, + base_url=embedding_base_url if embedding_base_url else base_url, + new_chapter=chapter_text, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath + ) + + logging.info(f"Chapter {novel_number} has been finalized.") + + +def enrich_chapter_text( + chapter_text: str, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float +) -> str: + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 + +原章节内容: +{chapter_text}""" + enriched_text = invoke_with_cleaning(model, prompt) + return enriched_text if enriched_text else chapter_text + + +# ============ 导入外部知识文本到向量库 ============ +def advanced_split_content(content: str, + similarity_threshold: float = 0.7, + max_length: int = 500) -> List[str]: + """ + 将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。 + """ + nltk.download('punkt', quiet=True) + sentences = nltk.sent_tokenize(content) + if not sentences: + return [] + + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + current_sentences.append(sentences[i]) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + + final_segments = [] + for para in merged_paragraphs: + if len(para) > max_length: + sub_segments = split_by_length(para, max_length=max_length) + final_segments.extend(sub_segments) + else: + final_segments.append(para) + + return final_segments + +def import_knowledge_file( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + file_path: str, + embedding_base_url: str, + filepath: str +): + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") + if not os.path.exists(file_path): + logging.warning(f"知识库文件不存在: {file_path}") + return + + content = read_file(file_path) + if not content.strip(): + logging.warning("知识库文件内容为空。") + return + + paragraphs = advanced_split_content(content) + + # 若向量库不存在则初始化,否则追加 + store = load_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath + ) + if not store: + logging.info("Vector store does not exist. Initializing a new one for knowledge import...") + init_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=paragraphs, + filepath=filepath + ) + else: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + logging.info("知识库文件已成功导入至向量库。") diff --git a/novel_generator.py b/novel_generator.py index 7505948..f29076c 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -15,7 +15,6 @@ from langchain.docstore.document import Document # nltk、sentence_transformers 及文本处理相关 import nltk -import math from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity @@ -27,26 +26,26 @@ from utils import ( # prompt模板 from prompt_definitions import ( - # 设定相关 - set_prompt, character_prompt, dark_lines_prompt, - finalize_setting_prompt, novel_directory_prompt, - - # 写作流程相关 - summary_prompt, update_character_state_prompt, - chapter_outline_prompt, chapter_write_prompt + core_seed_prompt, + character_dynamics_prompt, + world_building_prompt, + plot_architecture_prompt, + chapter_blueprint_prompt, + summary_prompt, + update_character_state_prompt, + scene_dynamics_prompt ) # Ollama嵌入 (如使用Ollama时需要) from embedding_ollama import OllamaEmbeddings # 用于目录解析章节标题/简介 -from chapter_directory_parser import get_chapter_info_from_directory - +from chapter_directory_parser import get_chapter_info_from_blueprint logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") -# ============ 帮助函数 ============ +# ============ 基础工具 ============ def remove_think_tags(text: str) -> str: """移除 ... 包裹的内容""" return re.sub(r'.*?', '', text, flags=re.DOTALL) @@ -333,8 +332,8 @@ def get_relevant_context_from_vector_store( return combined -# ============ 1. 生成小说“设定” (Novel_setting.txt) ============ -def Novel_setting_generate( +# ========== 1) 生成总体架构 (Novel_architecture.txt) ========== +def Novel_architecture_generate( api_key: str, base_url: str, llm_model: str, @@ -345,8 +344,15 @@ def Novel_setting_generate( filepath: str, temperature: float = 0.7 ) -> None: + """ + 依次调用: + 1. core_seed_prompt + 2. character_dynamics_prompt + 3. world_building_prompt + 4. plot_architecture_prompt + 将结果整合为“Novel_architecture.txt”。 + """ os.makedirs(filepath, exist_ok=True) - model = ChatOpenAI( model=llm_model, api_key=api_key, @@ -354,58 +360,95 @@ def Novel_setting_generate( temperature=temperature ) - # Step1: 基础设定 - prompt_base = set_prompt.format( + # 1) 核心种子 + prompt_core = core_seed_prompt.format( topic=topic, genre=genre, number_of_chapters=number_of_chapters, word_number=word_number ) - base_setting = invoke_with_cleaning(model, prompt_base) + core_seed_result = invoke_with_cleaning(model, prompt_core) + core_seed_text = core_seed_result.strip() - # Step2: 角色设定 - prompt_char = character_prompt.format( - novel_setting=base_setting + # 2) 角色动力学 + prompt_character = character_dynamics_prompt.format(core_seed=core_seed_text) + character_dynamics_result = invoke_with_cleaning(model, prompt_character) + character_dynamics_text = character_dynamics_result.strip() + + # 3) 世界观 + prompt_world = world_building_prompt.format(core_seed=core_seed_text) + world_building_result = invoke_with_cleaning(model, prompt_world) + world_building_text = world_building_result.strip() + + # 4) 三幕式情节架构 + prompt_plot = plot_architecture_prompt.format( + core_seed=core_seed_text, + character_dynamics=character_dynamics_text, + world_building=world_building_text ) - character_setting = invoke_with_cleaning(model, prompt_char) + plot_arch_result = invoke_with_cleaning(model, prompt_plot) + plot_arch_text = plot_arch_result.strip() - # Step3: 暗线/雷点 - prompt_dark = dark_lines_prompt.format( - character_info=character_setting + # 整合并写入 Novel_architecture.txt + final_content = ( + "#=== 1) 核心种子 ===\n" + f"{core_seed_text}\n\n" + "#=== 2) 角色动力学 ===\n" + f"{character_dynamics_text}\n\n" + "#=== 3) 世界观 ===\n" + f"{world_building_text}\n\n" + "#=== 4) 三幕式情节架构 ===\n" + f"{plot_arch_text}\n" ) - dark_lines = invoke_with_cleaning(model, prompt_dark) - # Step4: 最终整合 - prompt_final = finalize_setting_prompt.format( - novel_setting_base=base_setting, - character_setting=character_setting, - dark_lines=dark_lines - ) - final_novel_setting = invoke_with_cleaning(model, prompt_final) + arch_file = os.path.join(filepath, "Novel_architecture.txt") + clear_file_content(arch_file) + save_string_to_txt(final_content, arch_file) - filename_set = os.path.join(filepath, "Novel_setting.txt") - clear_file_content(filename_set) - - final_novel_setting_cleaned = final_novel_setting.replace('#', '').replace('*', '') - save_string_to_txt(final_novel_setting_cleaned, filename_set) - logging.info("Novel_setting.txt has been generated successfully.") + logging.info("Novel_architecture.txt has been generated successfully.") -# ============ 2. 生成小说目录 (Novel_directory.txt) ============ -def Novel_directory_generate( +# ========== 2) 生成章节蓝图 (Novel_directory.txt) ========== +def Chapter_blueprint_generate( api_key: str, base_url: str, llm_model: str, - number_of_chapters: int, filepath: str, temperature: float = 0.7 ) -> None: - filename_set = os.path.join(filepath, "Novel_setting.txt") - final_novel_setting = read_file(filename_set).strip() - if not final_novel_setting: - logging.warning("Novel_setting.txt 内容为空,请先生成小说设定。") + """ + 基于“Novel_architecture.txt”中的三幕式情节架构,调用 chapter_blueprint_prompt, + 生成章节蓝图并写入 Novel_directory.txt。 + """ + arch_file = os.path.join(filepath, "Novel_architecture.txt") + if not os.path.exists(arch_file): + logging.warning("Novel_architecture.txt not found. Please generate architecture first.") return + architecture_text = read_file(arch_file).strip() + if not architecture_text: + logging.warning("Novel_architecture.txt is empty.") + return + + # 从内容中尽量提取 number_of_chapters + # 如果之前已经存储了 number_of_chapters,可以在外面传入,这里做简化: + # 这里用正则或者其他逻辑提取,但演示时直接写 10 也可 + match_chaps = re.search(r'约(\d+)章', architecture_text) + if match_chaps: + number_of_chapters = int(match_chaps.group(1)) + else: + number_of_chapters = 10 # fallback + + # 提取三幕式文本 + # 在写入时,我们将 4) 三幕式情节架构 作为传给 prompt 的核心 + # 这里做一个简易匹配 + plot_arch_text = "" + # 假设 "#=== 4) 三幕式情节架构 ===" 是分隔点 + pat_plot = r'#=== 4\) 三幕式情节架构 ===\n([\s\S]+)$' + m = re.search(pat_plot, architecture_text) + if m: + plot_arch_text = m.group(1).strip() + model = ChatOpenAI( model=llm_model, api_key=api_key, @@ -413,22 +456,20 @@ def Novel_directory_generate( temperature=temperature ) - prompt_dir = novel_directory_prompt.format( - final_novel_setting=final_novel_setting, + prompt = chapter_blueprint_prompt.format( + plot_architecture=plot_arch_text, number_of_chapters=number_of_chapters ) - final_novel_directory = invoke_with_cleaning(model, prompt_dir) - if not final_novel_directory.strip(): - logging.warning("Novel_directory生成结果为空。") + blueprint_text = invoke_with_cleaning(model, prompt) + if not blueprint_text.strip(): + logging.warning("Chapter blueprint generation result is empty.") return filename_dir = os.path.join(filepath, "Novel_directory.txt") clear_file_content(filename_dir) + save_string_to_txt(blueprint_text, filename_dir) - final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') - save_string_to_txt(final_novel_directory_cleaned, filename_dir) - - logging.info("Novel_directory.txt has been generated successfully.") + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") # ============ 获取最近 N 章内容,生成短期摘要 ============ @@ -514,58 +555,114 @@ def update_plot_arcs( return arcs_text -# ============ 生成章节草稿 ============ +# ========== 3) 生成章节草稿 ========== + def generate_chapter_draft( - novel_settings: str, - global_summary: str, - character_state: str, - recent_chapters_summary: str, - user_guidance: str, api_key: str, base_url: str, model_name: str, + filepath: str, novel_number: int, word_number: int, temperature: float, - novel_novel_directory: str, - filepath: str, - interface_format: str, - embedding_model_name: str, - embedding_base_url: str, - embedding_retrieval_k: int = 4 + user_guidance: str, + characters_involved: str, + key_items: str, + scene_location: str, + time_constraint: str, + embedding_retrieval_k: int = 2 ) -> str: - # 1) 根据目录解析标题、简介 - chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) + """ + 根据 scene_dynamics_prompt,生成本章草稿。 + - novel_architecture 取自 Novel_architecture.txt + - blueprint 取自 Novel_directory.txt + - global_summary, character_state 分别取自全局摘要、角色状态文件 + - 向量库检索上下文 + - 用户还可以额外提供四个可选元素:核心人物、关键道具、空间坐标、时间压力 + """ + + # 1) 读取相关文件 + arch_file = os.path.join(filepath, "Novel_architecture.txt") + novel_architecture_text = read_file(arch_file) + + directory_file = os.path.join(filepath, "Novel_directory.txt") + blueprint_text = read_file(directory_file) + + global_summary_file = os.path.join(filepath, "global_summary.txt") + global_summary_text = read_file(global_summary_file) + + character_state_file = os.path.join(filepath, "character_state.txt") + character_state_text = read_file(character_state_file) + + # 2) 解析 blueprint,得到本章所需的字段 + chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] - chapter_brief = chapter_info["chapter_brief"] + chapter_role = chapter_info["chapter_role"] + chapter_purpose = chapter_info["chapter_purpose"] + suspense_level = chapter_info["suspense_level"] + foreshadowing = chapter_info["foreshadowing"] + plot_twist_level = chapter_info["plot_twist_level"] + chapter_summary = chapter_info["chapter_summary"] - # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) - combined_query_parts = [] - if user_guidance.strip(): - combined_query_parts.append(user_guidance) - if chapter_brief.strip(): - combined_query_parts.append(chapter_brief) - if recent_chapters_summary.strip(): - combined_query_parts.append(recent_chapters_summary) - # 额外加一个关键字 - combined_query_parts.append("回顾剧情") + # 3) 取最近3章文本,拼成查询语句 => 用于向量库检索 + chapters_dir = os.path.join(filepath, "chapters") + recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + merged_query_str = "回顾剧情:\n" + "\n".join(recent_3_texts) + "\n" + user_guidance - merged_query_str = "\n".join(combined_query_parts) - - # 2) 从向量库检索上下文 + # 4) 检索向量库上下文 relevant_context = get_relevant_context_from_vector_store( api_key=api_key, - base_url=embedding_base_url if embedding_base_url else base_url, + base_url=base_url, query=merged_query_str, - interface_format=interface_format, - embedding_model_name=embedding_model_name, + embedding_model_name=model_name, filepath=filepath, k=embedding_retrieval_k ) - if not relevant_context.strip(): - relevant_context = "暂无相关内容。" - # 3) 生成本章大纲 + if not relevant_context.strip(): + relevant_context = "(无检索到的上下文)" + + # 5) 构造prompt,调用 scene_dynamics_prompt + # 在这里,我们拆分架构文本,以便给模型提供: + # - “世界观”与“小说设定”可以从 arch_file 中的相应片段读取 + # 这里为了简化,直接把 novel_architecture_text 整体塞入 novel_setting + # 也可更精细地拆分 "#=== 3) 世界观 ===" 片段给 world_building + # 下方仅作示例。 + world_building_text = "" + match_world = re.search(r'#=== 3\) 世界观 ===\n([\s\S]+?)\n#===', novel_architecture_text) + if match_world: + world_building_text = match_world.group(1).strip() + else: + world_building_text = "暂无世界观信息" + + novel_setting_text = novel_architecture_text # 整份当做“小说设定”参考 + + prompt_text = scene_dynamics_prompt.format( + novel_number=novel_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, + + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + + world_building=world_building_text, + novel_setting=novel_setting_text, + global_summary=global_summary_text, + character_state=character_state_text + ) + + # 因为我们还想让模型了解向量库检索到的上下文,可以合并到最后 + prompt_text += f"\n\n【检索到的上下文】\n{relevant_context}" + # 也可合并用户指导 + prompt_text += f"\n\n【用户指导】\n{user_guidance}\n" + model = ChatOpenAI( model=model_name, api_key=api_key, @@ -573,44 +670,15 @@ def generate_chapter_draft( temperature=temperature ) - outline_prompt_text = chapter_outline_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_outline = invoke_with_cleaning(model, outline_prompt_text) - - outlines_dir = os.path.join(filepath, "outlines") - os.makedirs(outlines_dir, exist_ok=True) - outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") - clear_file_content(outline_file) - save_string_to_txt(chapter_outline, outline_file) - - # 4) 生成正文草稿 - writing_prompt_text = chapter_write_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - chapter_outline=chapter_outline, - word_number=word_number, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_content = invoke_with_cleaning(model, writing_prompt_text) + chapter_content = invoke_with_cleaning(model, prompt_text) + if not chapter_content.strip(): + logging.warning("Generated chapter draft is empty.") + # 6) 写入 chapters 目录 chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -618,20 +686,20 @@ def generate_chapter_draft( return chapter_content -# ============ 定稿章节 ============ +# ========== 4) 定稿章节 ========== def finalize_chapter( novel_number: int, word_number: int, api_key: str, base_url: str, - interface_format: str, - embedding_model_name: str, model_name: str, temperature: float, filepath: str, - embedding_base_url: str, - embedding_api_key: str + embedding_model_name: str ): + """ + 定稿:更新全局摘要、角色状态,并将本章文本插入向量库。 + """ chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -639,82 +707,55 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - character_state_file = os.path.join(filepath, "character_state.txt") - global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - - old_char_state = read_file(character_state_file) - old_global_summary = read_file(global_summary_file) - old_plot_arcs = read_file(plot_arcs_file) - - # 篇幅不足,二次扩写 - if len(chapter_text) < 0.8 * word_number: - logging.info("Chapter text is shorter than 80% of desired length. Enriching...") - chapter_text = enrich_chapter_text( - chapter_text=chapter_text, - word_number=word_number, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature - ) + # 如果长度比目标少很多,可考虑在此扩写 + if len(chapter_text) < 0.6 * word_number: + chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - # 更新全局摘要 + # 读取全局摘要、角色状态 + global_summary_file = os.path.join(filepath, "global_summary.txt") + old_global_summary = read_file(global_summary_file) + character_state_file = os.path.join(filepath, "character_state.txt") + old_character_state = read_file(character_state_file) + + # 1) 更新全局摘要 model = ChatOpenAI( model=model_name, api_key=api_key, base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - - def update_global_summary(chapter_text: str, old_summary: str) -> str: - prompt = summary_prompt.format( - chapter_text=chapter_text, - global_summary=old_summary - ) - return invoke_with_cleaning(model, prompt) or old_summary - - new_global_summary = update_global_summary(chapter_text, old_global_summary) - - # 更新角色状态 - def update_character_state(chapter_text: str, old_state: str) -> str: - prompt = update_character_state_prompt.format( - chapter_text=chapter_text, - old_state=old_state - ) - return invoke_with_cleaning(model, prompt) or old_state - - new_char_state = update_character_state(chapter_text, old_char_state) - - # 更新剧情要点 - new_plot_arcs = update_plot_arcs( + prompt_summary = summary_prompt.format( chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature + global_summary=old_global_summary ) + new_global_summary = invoke_with_cleaning(model, prompt_summary) + if not new_global_summary.strip(): + new_global_summary = old_global_summary + + # 2) 更新角色状态 + prompt_char_state = update_character_state_prompt.format( + chapter_text=chapter_text, + old_state=old_character_state + ) + new_char_state = invoke_with_cleaning(model, prompt_char_state) + if not new_char_state.strip(): + new_char_state = old_character_state # 写回文件 - clear_file_content(character_state_file) - save_string_to_txt(new_char_state, character_state_file) - clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) - clear_file_content(plot_arcs_file) - save_string_to_txt(new_plot_arcs, plot_arcs_file) + clear_file_content(character_state_file) + save_string_to_txt(new_char_state, character_state_file) - # 更新向量库(此时用 embedding_api_key/embedding_base_url) + # 3) 更新向量库 update_vector_store( - api_key=embedding_api_key, - base_url=embedding_base_url if embedding_base_url else base_url, + api_key=api_key, + base_url=base_url, new_chapter=chapter_text, - interface_format=interface_format, - embedding_model_name=embedding_model_name, + model_name=embedding_model_name, # 用于embedding filepath=filepath ) diff --git a/prompt_definitions.py b/prompt_definitions.py index ebe588c..989236d 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -186,7 +186,7 @@ update_character_state_prompt = """\ 仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 7. 章节正文写作 =================== +# =============== 8. 章节正文写作 =================== scene_dynamics_prompt = """\ 即将创作:第{novel_number}章《{chapter_title}》 本章定位:{chapter_role} @@ -227,4 +227,4 @@ scene_dynamics_prompt = """\ 最后设置一个"钩链转折":结尾同时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 仅返回章节正文文本,不要解释任何内容。 -""" \ No newline at end of file +""" diff --git a/ui.py b/ui.py index 7e2f461..2a4fb1d 100644 --- a/ui.py +++ b/ui.py @@ -7,17 +7,17 @@ import threading import customtkinter as ctk from tkinter import filedialog, messagebox import traceback + from config_manager import load_config, save_config from utils import read_file, save_string_to_txt, clear_file_content from novel_generator import ( - Novel_setting_generate, - Novel_directory_generate, + Novel_architecture_generate, + Chapter_blueprint_generate, generate_chapter_draft, finalize_chapter, import_knowledge_file, clear_vector_store, get_last_n_chapters_text, - summarize_recent_chapters ) from consistency_checker import check_consistency @@ -28,8 +28,6 @@ def log_error(message: str): """ logging.error(f"{message}\n{traceback.format_exc()}") - -# 设置全局主题和颜色 ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") @@ -39,79 +37,65 @@ class NovelGeneratorGUI: self.master = master self.master.title("Novel Generator GUI") - # 防止因 icon.ico 不存在导致程序崩溃 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) - # ========== 主要的属性变量 ========== - - # LLM 接口相关 + # 主要属性变量 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) - self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) + self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.openai.com/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) - - # 仍然用 DoubleVar,但因为是 Slider,不会让用户手动清空文本,一般不会出现空字符串问题 self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) - # Embedding 接口相关 + # Embedding相关 self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) self.embedding_api_key_var = ctk.StringVar(value=self.loaded_config.get("embedding_api_key", "")) - self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) - self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) - - # ### CHANGED:将 IntVar 改为 StringVar,避免用户清空输入时抛错 + self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "https://api.openai.com/v1")) + self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "text-embedding-ada-002")) self.embedding_retrieval_k_var = ctk.StringVar(value=str(self.loaded_config.get("embedding_retrieval_k", 4))) - # 小说通用参数 self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - - # ### CHANGED:将章节数、每章字数改为 StringVar self.num_chapters_var = ctk.StringVar(value=str(self.loaded_config.get("num_chapters", 10))) self.word_number_var = ctk.StringVar(value=str(self.loaded_config.get("word_number", 3000))) - self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - # ### CHANGED:章节号也改为 StringVar self.chapter_num_var = ctk.StringVar(value="1") - # ========== 主容器使用 TabView ========== + # 新增四个可选要素 + 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="") + + # UI 布局 self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) self.tabview.pack(fill="both", expand=True) - # 创建各个Tab self.main_tab = self.tabview.add("Main Functions") - self.setting_tab = self.tabview.add("Novel Settings") - self.directory_tab = self.tabview.add("Novel Directory") + 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 的布局 self.build_main_tab() self.build_setting_tab() self.build_directory_tab() self.build_character_tab() self.build_summary_tab() - self.build_chapters_tab() # 新增 + self.build_chapters_tab() - # ------------------ 工具方法:安全获取 IntVar (现已兼容 StringVar) ------------------ def safe_get_int(self, var, default=1): - """ - 尝试把 StringVar 或 IntVar 中的值转换为 int; - 若失败则将其重置为 default 并返回 default。 - """ try: val_str = str(var.get()).strip() return int(val_str) @@ -119,102 +103,51 @@ class NovelGeneratorGUI: var.set(str(default)) return default - # ------------------ 主功能 Tab ------------------ + # ------------------ 主 Tab ------------------ def build_main_tab(self): - """ - 主Tab分为左右两栏: - 左侧:本章内容、Step按钮、日志 - 右侧:配置区域(带边框) + 保存/加载配置 + 小说参数 + 可选功能按钮 - """ self.main_tab.rowconfigure(0, weight=1) self.main_tab.columnconfigure(0, weight=1) self.main_tab.columnconfigure(1, weight=0) - # 左侧Frame self.left_frame = ctk.CTkFrame(self.main_tab) self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) - - # 右侧Frame 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): - """ - 左侧布局: - row=0 -> “本章内容”文本框 (chapter_result) - row=1 -> Step1~4按钮 - row=2 -> “输出日志”标题 - row=3 -> “输出日志”文本框 (log_text) - """ 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.grid_columnconfigure(0, weight=1) + self.left_frame.columnconfigure(0, weight=1) - # ========== 本章内容 ========== - chapter_label = ctk.CTkLabel( - self.left_frame, - text="本章内容 (可编辑)", - font=("Microsoft YaHei", 12) - ) + 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) - ) + self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - # ========== Step1~4按钮 ========== - self.build_step_buttons_area() - - # ========== 输出日志 label ========== - 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) - ) - self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5)) - self.log_text.configure(state="disabled") - - def build_step_buttons_area(self): - """ - 在左侧,仅放 Step1~Step4 四个按钮 - """ + # 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_setting = ctk.CTkButton( + self.btn_generate_architecture = ctk.CTkButton( self.step_buttons_frame, - text="Step1. 生成设定", - command=self.generate_novel_setting_ui, + text="Step1. 生成架构", + command=self.generate_novel_architecture_ui, font=("Microsoft YaHei", 12) ) - self.btn_generate_setting.grid(row=0, column=0, padx=5, pady=2, sticky="ew") + 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_novel_directory_ui, + 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") @@ -235,42 +168,240 @@ class NovelGeneratorGUI: ) 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)) + 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): - """ - 右侧布局,包含: - row=0 -> 带边框的配置区 (TabView + 保存/加载配置按钮) - row=1 -> 小说参数区域 - row=2 -> 可选功能按钮 (一致性审校 / 导入知识库 / 清空向量库 / 查看剧情要点) - """ 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) - # 1) 配置区 - self.config_frame = ctk.CTkFrame( - self.right_frame, - corner_radius=10, - border_width=2, - border_color="gray" - ) + # 配置区 + 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() # LLM、Embedding等配置 - self.build_main_buttons_area() # 保存/加载配置按钮 + self.build_config_tabview() + self.build_main_buttons_area() - # 2) 小说参数 + # 小说参数 self.build_novel_params_area(start_row=1) - # 3) 可选功能按钮 + # 可选功能按钮 self.build_optional_buttons_area(start_row=2) - # ------------------ 可选功能按钮区域(右下) ------------------ + def build_config_tabview(self): + self.config_tabview = ctk.CTkTabview(self.config_frame, width=600, height=200) + 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() + + def build_ai_config_tab(self): + def on_interface_format_changed(new_value): + if new_value == "OpenAI": + self.base_url_var.set("https://api.openai.com/v1") + # 可根据需要扩展 Ollama / ML Studio 等 + + for i in range(5): + 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) + + api_key_label = ctk.CTkLabel(self.ai_config_tab, text="LLM API Key:", font=("Microsoft YaHei", 12)) + api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + 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, sticky="nsew") + + base_url_label = ctk.CTkLabel(self.ai_config_tab, text="LLM Base URL:", font=("Microsoft YaHei", 12)) + base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + 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, sticky="nsew") + + interface_label = ctk.CTkLabel(self.ai_config_tab, text="LLM 接口格式:", font=("Microsoft YaHei", 12)) + interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + interface_options = ["OpenAI"] + 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, sticky="nsew") + + model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:", font=("Microsoft YaHei", 12)) + model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + 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, sticky="nsew") + + temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:", font=("Microsoft YaHei", 12)) + temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + + 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=1.0, + number_of_steps=100, + 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=1, pady=1, sticky="w") + + def build_embeddings_config_tab(self): + 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) + + emb_api_key_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding API Key:", font=("Microsoft YaHei", 12)) + emb_api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + 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") + + emb_interface_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding 接口格式:", font=("Microsoft YaHei", 12)) + emb_interface_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + emb_interface_options = ["OpenAI"] # 可扩展 + emb_interface_dropdown = ctk.CTkOptionMenu( + self.embeddings_config_tab, + values=emb_interface_options, + variable=self.embedding_interface_format_var, + font=("Microsoft YaHei", 12) + ) + emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + + emb_url_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Base URL:", font=("Microsoft YaHei", 12)) + emb_url_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + 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") + + emb_model_name_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Model Name:", font=("Microsoft YaHei", 12)) + emb_model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + 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") + + emb_retrieval_k_label = ctk.CTkLabel(self.embeddings_config_tab, text="Retrieval Top-K:", font=("Microsoft YaHei", 12)) + emb_retrieval_k_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + 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") + + def build_main_buttons_area(self): + 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 build_novel_params_area(self, start_row=1): + 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) + + topic_label = ctk.CTkLabel(self.params_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) + topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + self.topic_text = ctk.CTkTextbox(self.params_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + 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) + + genre_label = ctk.CTkLabel(self.params_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) + genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + 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") + + row_for_chapter_and_word = 2 + num_chapters_label = ctk.CTkLabel(self.params_frame, text="章节数:", font=("Microsoft YaHei", 12)) + num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + + ch_word_frame = ctk.CTkFrame(self.params_frame) + ch_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") + ch_word_frame.columnconfigure((0, 1, 2, 3), weight=0) + + num_chapters_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12)) + num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + word_number_label = ctk.CTkLabel(ch_word_frame, text="每章字数:", font=("Microsoft YaHei", 12)) + word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") + + word_number_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12)) + word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") + + # 保存路径 + filepath_label = ctk.CTkLabel(self.params_frame, text="保存路径:", font=("Microsoft YaHei", 12)) + filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + + self.filepath_frame = ctk.CTkFrame(self.params_frame) + self.filepath_frame.grid(row=3, 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") + + # 章节号 + chapter_num_label = ctk.CTkLabel(self.params_frame, text="章节号:", font=("Microsoft YaHei", 12)) + chapter_num_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + chapter_num_entry = ctk.CTkEntry(self.params_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12)) + chapter_num_entry.grid(row=4, column=1, padx=5, pady=5, sticky="w") + + # 用户指导 + guide_label = ctk.CTkLabel(self.params_frame, text="本章指导:", font=("Microsoft YaHei", 12)) + guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") + self.user_guide_text = ctk.CTkTextbox(self.params_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") + + # 新增:四个可选元素 + row_index = 6 + + char_inv_label = ctk.CTkLabel(self.params_frame, text="核心人物:", font=("Microsoft YaHei", 12)) + char_inv_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + char_inv_entry = ctk.CTkEntry(self.params_frame, textvariable=self.characters_involved_var, font=("Microsoft YaHei", 12)) + char_inv_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + + row_index += 1 + key_items_label = ctk.CTkLabel(self.params_frame, text="关键道具:", font=("Microsoft YaHei", 12)) + key_items_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + key_items_entry = ctk.CTkEntry(self.params_frame, textvariable=self.key_items_var, font=("Microsoft YaHei", 12)) + key_items_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + + row_index += 1 + scene_loc_label = ctk.CTkLabel(self.params_frame, text="空间坐标:", font=("Microsoft YaHei", 12)) + scene_loc_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + scene_loc_entry = ctk.CTkEntry(self.params_frame, textvariable=self.scene_location_var, font=("Microsoft YaHei", 12)) + scene_loc_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + + row_index += 1 + time_const_label = ctk.CTkLabel(self.params_frame, text="时间压力:", font=("Microsoft YaHei", 12)) + time_const_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + time_const_entry = ctk.CTkEntry(self.params_frame, textvariable=self.time_constraint_var, font=("Microsoft YaHei", 12)) + time_const_entry.grid(row=row_index, 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) @@ -308,363 +439,385 @@ class NovelGeneratorGUI: ) self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew") - # ========== 配置区域(TabView) ========== - def build_config_tabview(self): - self.config_tabview = ctk.CTkTabview(self.config_frame, width=600, height=200) - self.config_tabview.grid(row=0, column=0, sticky="we", padx=5, pady=5) + def load_config_btn(self): + cfg = load_config(self.config_file) + if cfg: + self.api_key_var.set(cfg.get("api_key", "")) + self.base_url_var.set(cfg.get("base_url", "")) + self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) + self.model_name_var.set(cfg.get("model_name", "")) + self.temperature_var.set(cfg.get("temperature", 0.7)) + self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) + self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) + self.embedding_url_var.set(cfg.get("embedding_url", "")) + self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) + self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) + self.genre_var.set(cfg.get("genre", "")) + self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) + self.word_number_var.set(str(cfg.get("word_number", 3000))) + self.filepath_var.set(cfg.get("filepath", "")) - self.ai_config_tab = self.config_tabview.add("LLM Model settings") - self.embeddings_config_tab = self.config_tabview.add("Embedding settings") + topic_value = cfg.get("topic", "") + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", topic_value) - self.build_ai_config_tab() - self.build_embeddings_config_tab() + self.log("已加载配置。") + else: + messagebox.showwarning("提示", "未找到或无法读取配置文件。") - def build_ai_config_tab(self): - def on_interface_format_changed(new_value): - if new_value == "Ollama": - self.base_url_var.set("http://localhost:11434/v1") - elif new_value == "ML Studio": - self.base_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.base_url_var.set("https://api.openai.com/v1") + def save_config_btn(self): + config_data = { + "api_key": self.api_key_var.get(), + "base_url": self.base_url_var.get(), + "interface_format": self.interface_format_var.get(), + "model_name": self.model_name_var.get(), + "temperature": self.temperature_var.get(), - for i in range(5): - 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) # for temp label + "embedding_api_key": self.embedding_api_key_var.get(), + "embedding_interface_format": self.embedding_interface_format_var.get(), + "embedding_url": self.embedding_url_var.get(), + "embedding_model_name": self.embedding_model_name_var.get(), + "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), - api_key_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM API Key:", - font=("Microsoft YaHei", 12) - ) - api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - 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, sticky="nsew") + "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() + } + if save_config(config_data, self.config_file): + messagebox.showinfo("提示", "配置已保存至 config.json") + self.log("配置已保存。") + else: + messagebox.showerror("错误", "保存配置失败。") - base_url_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM Base URL:", - font=("Microsoft YaHei", 12) - ) - base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - 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, sticky="nsew") + def browse_folder(self): + selected_dir = filedialog.askdirectory() + if selected_dir: + self.filepath_var.set(selected_dir) - interface_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM 接口格式:", - font=("Microsoft YaHei", 12) - ) - interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI", "Ollama", "ML Studio"] - 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, sticky="nsew") + 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") - model_name_label = ctk.CTkLabel( - self.ai_config_tab, - text="Model Name:", - font=("Microsoft YaHei", 12) - ) - model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") - 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, sticky="nsew") + def safe_log(self, message: str): + self.master.after(0, lambda: self.log(message)) - temp_label = ctk.CTkLabel( - self.ai_config_tab, - text="Temperature:", - font=("Microsoft YaHei", 12) - ) - temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + def disable_button_safe(self, btn): + self.master.after(0, lambda: btn.configure(state="disabled")) - def update_temp_label(value): - self.temp_value_label.configure(text=f"{float(value):.2f}") + def enable_button_safe(self, btn): + self.master.after(0, lambda: btn.configure(state="normal")) - temp_scale = ctk.CTkSlider( - self.ai_config_tab, - from_=0.0, to=1.0, - number_of_steps=100, - command=update_temp_label, - variable=self.temperature_var - ) - temp_scale.grid(row=4, column=1, padx=5, pady=5, sticky="we") + def handle_exception(self, context: str): + full_message = f"{context}\n{traceback.format_exc()}" + logging.error(full_message) + self.safe_log(full_message) - 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=1, pady=1, sticky="w") + # ------------------ Step1: 生成架构 ------------------ + def generate_novel_architecture_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return - def build_embeddings_config_tab(self): - def on_embedding_interface_changed(new_value): - if new_value == "Ollama": - self.embedding_url_var.set("http://localhost:11434/api") - elif new_value == "ML Studio": - self.embedding_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.embedding_url_var.set("https://api.openai.com/v1") + def task(): + self.disable_button_safe(self.btn_generate_architecture) + 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() - 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) + 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) - emb_api_key_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding API Key:", - font=("Microsoft YaHei", 12) - ) - emb_api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - 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") + self.safe_log("开始生成小说架构...") + Novel_architecture_generate( + 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 + ) + self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成小说架构时出错") + finally: + self.enable_button_safe(self.btn_generate_architecture) - emb_interface_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding 接口格式:", - font=("Microsoft YaHei", 12) - ) - emb_interface_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - emb_interface_options = ["OpenAI", "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") + threading.Thread(target=task, daemon=True).start() - emb_url_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding Base URL:", - font=("Microsoft YaHei", 12) - ) - emb_url_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - 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") + # ------------------ Step2: 生成章节蓝图 ------------------ + def generate_chapter_blueprint_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return - emb_model_name_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding Model Name:", - font=("Microsoft YaHei", 12) - ) - emb_model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") - 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") + def task(): + self.disable_button_safe(self.btn_generate_directory) + 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() - emb_retrieval_k_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Retrieval Top-K:", - font=("Microsoft YaHei", 12) - ) - emb_retrieval_k_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - 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") + self.safe_log("开始生成章节蓝图...") + Chapter_blueprint_generate( + api_key=api_key, + base_url=base_url, + llm_model=model_name, + filepath=filepath, + temperature=temperature + ) + self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成章节蓝图时出错") + finally: + self.enable_button_safe(self.btn_generate_directory) - # ========== 保存/加载 配置按钮区域 ========== - def build_main_buttons_area(self): - """ - 放置在带边框配置区(config_frame)内部,位于TabView下方 - """ - 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) + threading.Thread(target=task, daemon=True).start() - 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") + # ------------------ Step3: 生成草稿 ------------------ + def generate_chapter_draft_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return - 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 task(): + self.disable_button_safe(self.btn_generate_chapter) + 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() - # ========== 小说参数区域 ========== - def build_novel_params_area(self, start_row=1): - """ - 右侧下方区域: 输入主题, 类型, 章节数, 字数, 保存路径, 指导信息等 - """ - 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) + 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() - # 主题(Topic) - topic_label = ctk.CTkLabel( - self.params_frame, - text="主题(Topic):", - font=("Microsoft YaHei", 12) - ) - topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox( - self.params_frame, - width=200, - height=80, - wrap="word", - font=("Microsoft YaHei", 12) - ) - 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) + # 新增四个可选要素 + 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() - # 类型(Genre) - genre_label = ctk.CTkLabel( - self.params_frame, - text="类型(Genre):", - font=("Microsoft YaHei", 12) - ) - genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - 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") + embedding_model_name = self.embedding_model_name_var.get().strip() + embedding_k = self.safe_get_int(self.embedding_retrieval_k_var, 4) - # 章节数、每章字数 放在同一行 - row_for_chapter_and_word = 2 - num_chapters_label = ctk.CTkLabel( - self.params_frame, - text="章节数:", - font=("Microsoft YaHei", 12) - ) - num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + 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_retrieval_k=embedding_k + ) + 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("⚠️ 本章草稿生成失败或无内容。") - ch_word_frame = ctk.CTkFrame(self.params_frame) - ch_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") - ch_word_frame.columnconfigure(0, weight=0) - ch_word_frame.columnconfigure(1, weight=0) - ch_word_frame.columnconfigure(2, weight=0) - ch_word_frame.columnconfigure(3, weight=1) + except Exception: + self.handle_exception("生成章节草稿时出错") + finally: + self.enable_button_safe(self.btn_generate_chapter) - num_chapters_entry = ctk.CTkEntry( - ch_word_frame, - textvariable=self.num_chapters_var, - width=60, - font=("Microsoft YaHei", 12) - ) - num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") + threading.Thread(target=task, daemon=True).start() - word_number_label = ctk.CTkLabel( - ch_word_frame, - text="每章字数:", - font=("Microsoft YaHei", 12) - ) - word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") + 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") - word_number_entry = ctk.CTkEntry( - ch_word_frame, - textvariable=self.word_number_var, - width=60, - font=("Microsoft YaHei", 12) - ) - word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") + # ------------------ Step4: 定稿章节 ------------------ + def finalize_chapter_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return - # 保存路径 - filepath_label = ctk.CTkLabel( - self.params_frame, - text="保存路径:", - font=("Microsoft YaHei", 12) - ) - filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + def task(): + self.disable_button_safe(self.btn_finalize_chapter) + 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() - self.filepath_frame = ctk.CTkFrame(self.params_frame) - self.filepath_frame.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") - self.filepath_frame.columnconfigure(0, weight=1) + embedding_model_name = self.embedding_model_name_var.get().strip() - 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") + chap_num = self.safe_get_int(self.chapter_num_var, 1) + word_number = self.safe_get_int(self.word_number_var, 3000) - # 章节号 - chapter_num_label = ctk.CTkLabel( - self.params_frame, - text="章节号:", - font=("Microsoft YaHei", 12) - ) - chapter_num_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - chapter_num_entry = ctk.CTkEntry( - self.params_frame, - textvariable=self.chapter_num_var, - width=80, - font=("Microsoft YaHei", 12) - ) - chapter_num_entry.grid(row=4, column=1, padx=5, pady=5, sticky="w") + 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() + clear_file_content(chapter_file) + save_string_to_txt(edited_text, chapter_file) - # 用户指导 - guide_label = ctk.CTkLabel( - self.params_frame, - text="本章指导:", - font=("Microsoft YaHei", 12) - ) - guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox( - self.params_frame, - width=200, - height=80, - wrap="word", - font=("Microsoft YaHei", 12) - ) - self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") + 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_model_name=embedding_model_name + ) + self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") - # ------------------ 其他Tab的构建 ------------------ + 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() + + 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="", # 如果需要,可传入最新的 Novel_architecture 内容 + 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, + 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: + 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() + embedding_model_name = self.embedding_model_name_var.get().strip() + + self.safe_log(f"开始导入知识库文件: {selected_file}") + import_knowledge_file( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + file_path=selected_file, + embedding_base_url=base_url, + 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): + 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, Chapter Blueprint, Character State, Summary ------------------ def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -672,8 +825,8 @@ class NovelGeneratorGUI: load_btn = ctk.CTkButton( self.setting_tab, - text="加载 Novel_setting.txt", - command=self.load_novel_setting, + 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") @@ -681,18 +834,36 @@ class NovelGeneratorGUI: save_btn = ctk.CTkButton( self.setting_tab, text="保存修改", - command=self.save_novel_setting, + 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) - ) + self.setting_text = ctk.CTkTextbox(self.setting_tab, wrap="word", font=("Microsoft YaHei", 12)) 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 的修改。") + def build_directory_tab(self): self.directory_tab.rowconfigure(0, weight=0) self.directory_tab.rowconfigure(1, weight=1) @@ -701,7 +872,7 @@ class NovelGeneratorGUI: load_btn = ctk.CTkButton( self.directory_tab, text="加载 Novel_directory.txt", - command=self.load_novel_directory, + command=self.load_chapter_blueprint, font=("Microsoft YaHei", 12) ) load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") @@ -709,18 +880,36 @@ class NovelGeneratorGUI: save_btn = ctk.CTkButton( self.directory_tab, text="保存修改", - command=self.save_novel_directory, + 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) - ) + self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12)) 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 的修改。") + def build_character_tab(self): self.character_tab.rowconfigure(0, weight=0) self.character_tab.rowconfigure(1, weight=1) @@ -742,13 +931,31 @@ class NovelGeneratorGUI: ) 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) - ) + self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12)) 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 的修改。") + def build_summary_tab(self): self.summary_tab.rowconfigure(0, weight=0) self.summary_tab.rowconfigure(1, weight=1) @@ -770,17 +977,33 @@ class NovelGeneratorGUI: ) 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) - ) + self.summary_text = ctk.CTkTextbox(self.summary_tab, wrap="word", font=("Microsoft YaHei", 12)) 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 的修改。") + + # ------------------ 章节管理标签页 ------------------ def build_chapters_tab(self): - """ - 新增的 Tab,用于查看、编辑和保存已生成的各章节内容。 - """ self.chapters_view_tab.rowconfigure(0, weight=0) self.chapters_view_tab.rowconfigure(1, weight=1) self.chapters_view_tab.columnconfigure(0, weight=1) @@ -821,7 +1044,6 @@ class NovelGeneratorGUI: self.chapters_list = [] self.refresh_chapters_list() - # ------------------ 章节管理辅助方法 ------------------ def refresh_chapters_list(self): filepath = self.filepath_var.get().strip() chapters_dir = os.path.join(filepath, "chapters") @@ -857,7 +1079,6 @@ class NovelGeneratorGUI: 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): @@ -914,528 +1135,7 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") - # ------------------ 配置管理 ------------------ - def load_config_btn(self): - cfg = load_config(self.config_file) - if cfg: - # LLM - self.api_key_var.set(cfg.get("api_key", "")) - self.base_url_var.set(cfg.get("base_url", "")) - self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) - self.model_name_var.set(cfg.get("model_name", "")) - self.temperature_var.set(cfg.get("temperature", 0.7)) - # Embedding - self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) - self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) - self.embedding_url_var.set(cfg.get("embedding_url", "")) - self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) - # ### CHANGED:用字符串形式设值 - self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) - - # Novel - self.genre_var.set(cfg.get("genre", "")) - - # ### CHANGED:用字符串形式设值 - self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) - self.word_number_var.set(str(cfg.get("word_number", 3000))) - self.filepath_var.set(cfg.get("filepath", "")) - - topic_value = cfg.get("topic", "") - self.topic_text.delete("0.0", "end") - self.topic_text.insert("0.0", topic_value) - - self.log("已加载配置。") - else: - messagebox.showwarning("提示", "未找到或无法读取配置文件。") - - def save_config_btn(self): - config_data = { - # LLM - "api_key": self.api_key_var.get(), - "base_url": self.base_url_var.get(), - "interface_format": self.interface_format_var.get(), - "model_name": self.model_name_var.get(), - "temperature": self.temperature_var.get(), - - # Embedding - "embedding_api_key": self.embedding_api_key_var.get(), - "embedding_interface_format": self.embedding_interface_format_var.get(), - "embedding_url": self.embedding_url_var.get(), - "embedding_model_name": self.embedding_model_name_var.get(), - "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), - - # Novel - "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() - } - if save_config(config_data, 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 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) - - # ------------------ 分步操作:生成设定、目录、章节草稿、定稿 ------------------ - def generate_novel_setting_ui(self): - """Step1. 生成小说设定(Novel_setting.txt)""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - def task(): - self.disable_button_safe(self.btn_generate_setting) - 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() - - 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) - temperature = self.temperature_var.get() - - self.safe_log("开始生成小说设定...") - Novel_setting_generate( - 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 - ) - self.safe_log("✅ 小说设定生成完成。请在 'Novel Settings' 标签页进行查看或编辑。") - except Exception: - self.handle_exception("生成小说设定时出错") - finally: - self.enable_button_safe(self.btn_generate_setting) - - threading.Thread(target=task, daemon=True).start() - - def generate_novel_directory_ui(self): - """Step2. 基于已有 Novel_setting.txt 生成 Novel_directory.txt""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - def task(): - self.disable_button_safe(self.btn_generate_directory) - 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() - num_chapters = self.safe_get_int(self.num_chapters_var, 10) - temperature = self.temperature_var.get() - - self.safe_log("开始生成小说目录...") - Novel_directory_generate( - api_key=api_key, - base_url=base_url, - llm_model=model_name, - number_of_chapters=num_chapters, - filepath=filepath, - temperature=temperature - ) - self.safe_log("✅ 小说目录生成完成。请在 'Novel Directory' 标签页查看或编辑。") - 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: - 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() - - novel_settings_file = os.path.join(filepath, "Novel_setting.txt") - novel_settings = read_file(novel_settings_file) - if not novel_settings.strip(): - self.safe_log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") - return - - character_state_file = os.path.join(filepath, "character_state.txt") - character_state = read_file(character_state_file) - global_summary_file = os.path.join(filepath, "global_summary.txt") - global_summary = read_file(global_summary_file) - novel_directory_file = os.path.join(filepath, "Novel_directory.txt") - novel_directory = read_file(novel_directory_file) - - 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() - - # 获取最近3章文本 - chapters_dir = os.path.join(filepath, "chapters") - recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) - - # 生成最近章节摘要 - recent_chapters_summary = summarize_recent_chapters( - llm_model=model_name, - api_key=api_key, - base_url=base_url, - temperature=temperature, - chapters_text_list=recent_3_texts - ) - - self.safe_log(f"开始生成第{chap_num}章草稿...") - draft_text = generate_chapter_draft( - novel_settings=novel_settings, - global_summary=global_summary, - character_state=character_state, - recent_chapters_summary=recent_chapters_summary, - user_guidance=user_guidance, - api_key=api_key, - base_url=base_url, - model_name=model_name, - novel_number=chap_num, - word_number=word_number, - temperature=temperature, - novel_novel_directory=novel_directory, - filepath=filepath, - - # Embedding 配置 - interface_format=self.embedding_interface_format_var.get().strip(), - embedding_model_name=self.embedding_model_name_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip(), - # 新增:检索 K 值 - embedding_retrieval_k=self.safe_get_int(self.embedding_retrieval_k_var, 4) - ) - 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: - 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.embedding_interface_format_var.get().strip() - embedding_model_name = self.embedding_model_name_var.get().strip() - embedding_base_url = self.embedding_url_var.get().strip() - embedding_api_key = self.embedding_api_key_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") - chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt") - edited_text = self.chapter_result.get("0.0", "end").strip() - 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, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - model_name=model_name, - temperature=temperature, - filepath=filepath, - embedding_base_url=embedding_base_url, - embedding_api_key=embedding_api_key - ) - self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") - - # 读取定稿后的文本显示 - chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") - final_text = read_file(chap_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): - """使用审校Agent对最新章节进行简单一致性或冲突检查""" - 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() - - novel_settings_file = os.path.join(filepath, "Novel_setting.txt") - character_state_file = os.path.join(filepath, "character_state.txt") - global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - - novel_setting = read_file(novel_settings_file) - character_state = read_file(character_state_file) - global_summary = read_file(global_summary_file) - plot_arcs = read_file(plot_arcs_file) - - 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=novel_setting, - character_state=character_state, - global_summary=global_summary, - chapter_text=chapter_text, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - plot_arcs=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: - self.safe_log(f"开始导入知识库文件: {selected_file}") - import_knowledge_file( - 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(), - embedding_model_name=self.embedding_model_name_var.get().strip(), - file_path=selected_file, - embedding_base_url=self.embedding_url_var.get().strip(), - 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): - 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 Settings/Directory/Character/Global Summary 的加载与保存 ------------------ - def load_novel_setting(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - setting_file = os.path.join(filepath, "Novel_setting.txt") - content = read_file(setting_file) - self.setting_text.delete("0.0", "end") - self.setting_text.insert("0.0", content) - self.log("已加载 Novel_setting.txt 内容到编辑区。") - - def save_novel_setting(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.setting_text.get("0.0", "end").strip() - setting_file = os.path.join(filepath, "Novel_setting.txt") - clear_file_content(setting_file) - save_string_to_txt(content, setting_file) - self.log("已保存对 Novel_setting.txt 的修改。") - - def load_novel_directory(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - directory_file = os.path.join(filepath, "Novel_directory.txt") - content = read_file(directory_file) - self.directory_text.delete("0.0", "end") - self.directory_text.insert("0.0", content) - self.log("已加载 Novel_directory.txt 内容到编辑区。") - - def save_novel_directory(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.directory_text.get("0.0", "end").strip() - directory_file = os.path.join(filepath, "Novel_directory.txt") - clear_file_content(directory_file) - save_string_to_txt(content, directory_file) - self.log("已保存对 Novel_directory.txt 的修改。") - - def load_character_state(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - char_file = os.path.join(filepath, "character_state.txt") - content = read_file(char_file) - 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("警告", "请先在主Tab中设置保存文件路径") - return - content = self.character_text.get("0.0", "end").strip() - char_file = os.path.join(filepath, "character_state.txt") - clear_file_content(char_file) - save_string_to_txt(content, char_file) - self.log("已保存对 character_state.txt 的修改。") - - def load_global_summary(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - summary_file = os.path.join(filepath, "global_summary.txt") - content = read_file(summary_file) - 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("警告", "请先在主Tab中设置保存文件路径") - return - content = self.summary_text.get("0.0", "end").strip() - summary_file = os.path.join(filepath, "global_summary.txt") - clear_file_content(summary_file) - save_string_to_txt(content, summary_file) - self.log("已保存对 global_summary.txt 的修改。") - - -# 入口 if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app) From f388403b08ae53ea66c61d217ab6d629f572f23b Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 5 Feb 2025 22:43:09 +0800 Subject: [PATCH 054/142] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=AF=B9=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=80=BB=E8=BE=91=E7=9A=84=E4=BC=98=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E4=BD=86=E4=B9=9F=E5=8F=91=E7=8E=B0=E6=96=B0=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前后章节的衔接有问题,这里想着应该要把前一个章节内容发送作为参考,不然中断感很重 --- chapter_directory_parser.py | 24 +++++++----- novel_generator.py | 73 +++++++++++++++++-------------------- ui.py | 36 +++++++++++++----- 3 files changed, 76 insertions(+), 57 deletions(-) diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py index 796b9f5..696cbb8 100644 --- a/chapter_directory_parser.py +++ b/chapter_directory_parser.py @@ -21,13 +21,19 @@ def parse_chapter_blueprint(blueprint_text: str): chunks = re.split(r'\n\s*\n', blueprint_text.strip()) results = [] - chapter_number_pattern = re.compile(r'^第\s*(\d+)\s*章\s*-\s*\[(.*?)\]') # 捕获章号与标题 - role_pattern = re.compile(r'^本章定位:\s*(.*)$') - purpose_pattern = re.compile(r'^核心作用:\s*(.*)$') - suspense_pattern = re.compile(r'^悬念密度:\s*(.*)$') - foreshadow_pattern = re.compile(r'^伏笔操作:\s*(.*)$') - twist_pattern = re.compile(r'^认知颠覆:\s*(.*)$') - summary_pattern = re.compile(r'^本章简述:\s*\[(.*)\]$') + # 兼容是否使用方括号包裹章节标题 + # 例如: + # 第1章 - 紫极光下的预兆 + # 或 + # 第1章 - [紫极光下的预兆] + chapter_number_pattern = re.compile(r'^第\s*(\d+)\s*章\s*-\s*\[?(.*?)\]?$') + + role_pattern = re.compile(r'^本章定位:\s*\[?(.*)\]?$') + purpose_pattern = re.compile(r'^核心作用:\s*\[?(.*)\]?$') + suspense_pattern = re.compile(r'^悬念密度:\s*\[?(.*)\]?$') + foreshadow_pattern = re.compile(r'^伏笔操作:\s*\[?(.*)\]?$') + twist_pattern = re.compile(r'^认知颠覆:\s*\[?(.*)\]?$') + summary_pattern = re.compile(r'^本章简述:\s*\[?(.*)\]?$') for chunk in chunks: lines = chunk.strip().splitlines() @@ -44,9 +50,9 @@ def parse_chapter_blueprint(blueprint_text: str): chapter_summary = "" # 先匹配第一行(或前几行),找到章号和标题 - header_match = chapter_number_pattern.match(lines[0].strip()) if lines else None + header_match = chapter_number_pattern.match(lines[0].strip()) if not header_match: - # 不符合格式,跳过 + # 不符合“第X章 - 标题”的格式,跳过 continue chapter_number = int(header_match.group(1)) diff --git a/novel_generator.py b/novel_generator.py index f29076c..ebe885c 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -431,8 +431,6 @@ def Chapter_blueprint_generate( return # 从内容中尽量提取 number_of_chapters - # 如果之前已经存储了 number_of_chapters,可以在外面传入,这里做简化: - # 这里用正则或者其他逻辑提取,但演示时直接写 10 也可 match_chaps = re.search(r'约(\d+)章', architecture_text) if match_chaps: number_of_chapters = int(match_chaps.group(1)) @@ -440,10 +438,7 @@ def Chapter_blueprint_generate( number_of_chapters = 10 # fallback # 提取三幕式文本 - # 在写入时,我们将 4) 三幕式情节架构 作为传给 prompt 的核心 - # 这里做一个简易匹配 plot_arch_text = "" - # 假设 "#=== 4) 三幕式情节架构 ===" 是分隔点 pat_plot = r'#=== 4\) 三幕式情节架构 ===\n([\s\S]+)$' m = re.search(pat_plot, architecture_text) if m: @@ -556,7 +551,6 @@ def update_plot_arcs( # ========== 3) 生成章节草稿 ========== - def generate_chapter_draft( api_key: str, base_url: str, @@ -570,6 +564,10 @@ def generate_chapter_draft( key_items: str, scene_location: str, time_constraint: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, embedding_retrieval_k: int = 2 ) -> str: """ @@ -577,7 +575,7 @@ def generate_chapter_draft( - novel_architecture 取自 Novel_architecture.txt - blueprint 取自 Novel_directory.txt - global_summary, character_state 分别取自全局摘要、角色状态文件 - - 向量库检索上下文 + - 从向量库检索上下文(embedding_*参数) - 用户还可以额外提供四个可选元素:核心人物、关键道具、空间坐标、时间压力 """ @@ -609,25 +607,21 @@ def generate_chapter_draft( recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) merged_query_str = "回顾剧情:\n" + "\n".join(recent_3_texts) + "\n" + user_guidance - # 4) 检索向量库上下文 + # 4) 检索向量库上下文 (使用embedding_*参数) relevant_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=base_url, + api_key=embedding_api_key, + base_url=embedding_url, query=merged_query_str, - embedding_model_name=model_name, + interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, filepath=filepath, k=embedding_retrieval_k ) - if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 5) 构造prompt,调用 scene_dynamics_prompt - # 在这里,我们拆分架构文本,以便给模型提供: - # - “世界观”与“小说设定”可以从 arch_file 中的相应片段读取 - # 这里为了简化,直接把 novel_architecture_text 整体塞入 novel_setting - # 也可更精细地拆分 "#=== 3) 世界观 ===" 片段给 world_building - # 下方仅作示例。 + # 5) 构造prompt + # 拆分 world_building_text, novel_architecture_text 等等 world_building_text = "" match_world = re.search(r'#=== 3\) 世界观 ===\n([\s\S]+?)\n#===', novel_architecture_text) if match_world: @@ -658,9 +652,8 @@ def generate_chapter_draft( character_state=character_state_text ) - # 因为我们还想让模型了解向量库检索到的上下文,可以合并到最后 + # 合并检索到的上下文和用户指导 prompt_text += f"\n\n【检索到的上下文】\n{relevant_context}" - # 也可合并用户指导 prompt_text += f"\n\n【用户指导】\n{user_guidance}\n" model = ChatOpenAI( @@ -674,8 +667,7 @@ def generate_chapter_draft( if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 6) 写入 chapters 目录 - chapters_dir = os.path.join(filepath, "chapters") + # 6) 写入 chapters os.makedirs(chapters_dir, exist_ok=True) chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") @@ -695,6 +687,9 @@ def finalize_chapter( model_name: str, temperature: float, filepath: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, embedding_model_name: str ): """ @@ -707,7 +702,7 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 如果长度比目标少很多,可考虑在此扩写 + # 若篇幅过短,可尝试扩写 if len(chapter_text) < 0.6 * word_number: chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) clear_file_content(chapter_file) @@ -750,12 +745,13 @@ def finalize_chapter( clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 3) 更新向量库 + # 3) 更新向量库 (embedding相关) update_vector_store( - api_key=api_key, - base_url=base_url, + api_key=embedding_api_key, + base_url=embedding_url, new_chapter=chapter_text, - model_name=embedding_model_name, # 用于embedding + interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, filepath=filepath ) @@ -827,15 +823,14 @@ def advanced_split_content(content: str, return final_segments def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, embedding_model_name: str, file_path: str, - embedding_base_url: str, filepath: str ): - logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {embedding_interface_format}, 模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return @@ -847,20 +842,20 @@ def import_knowledge_file( paragraphs = advanced_split_content(content) - # 若向量库不存在则初始化,否则追加 + # 尝试加载已有的向量库 store = load_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, + api_key=embedding_api_key, + base_url=embedding_url if embedding_url else "http://localhost:11434/v1", + interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, filepath=filepath ) if not store: logging.info("Vector store does not exist. Initializing a new one for knowledge import...") init_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, + api_key=embedding_api_key, + base_url=embedding_url if embedding_url else "http://localhost:11434/v1", + interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, texts=paragraphs, filepath=filepath diff --git a/ui.py b/ui.py index 2a4fb1d..425dd40 100644 --- a/ui.py +++ b/ui.py @@ -597,11 +597,13 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_chapter) try: + # LLM相关 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() + # 章节信息 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() @@ -612,6 +614,10 @@ class NovelGeneratorGUI: scene_loc = self.scene_location_var.get().strip() time_constr = self.time_constraint_var.get().strip() + # Embedding相关 + 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) @@ -629,6 +635,10 @@ class NovelGeneratorGUI: 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 ) if draft_text: @@ -659,13 +669,19 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_finalize_chapter) try: + # LLM相关 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() + # Embedding相关 + 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) @@ -686,6 +702,9 @@ class NovelGeneratorGUI: 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 ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") @@ -754,19 +773,18 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_import_knowledge) try: - 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() - embedding_model_name = self.embedding_model_name_var.get().strip() + 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( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, + embedding_api_key=emb_api_key, + embedding_url=emb_url, + embedding_interface_format=emb_format, + embedding_model_name=emb_model, file_path=selected_file, - embedding_base_url=base_url, filepath=self.filepath_var.get().strip() ) self.safe_log("✅ 知识库文件导入完成。") From 65e579b6478244951ac246ed76827661b4882fac Mon Sep 17 00:00:00 2001 From: Xianyun <115537554+YILING0013@users.noreply.github.com> Date: Wed, 5 Feb 2025 23:00:01 +0800 Subject: [PATCH 055/142] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 84696da..fde216c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # 📖 自动小说生成工具 -> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 +## 2025/2/5说明 +>当前**dev-2**分支采用了**雪花写作法、角色弧光理论、悬念三要素模型**的预设,尚在完善中,但就测试质量来说,小说质量会高于当前main分支效果。
@@ -18,6 +19,8 @@
+> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 + --- ## 📑 目录导航 From a000b6929b836a7b29dbeaaad828f2914690a658 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 5 Feb 2025 23:03:34 +0800 Subject: [PATCH 056/142] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=B9=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E8=A1=94=E6=8E=A5=E7=9A=84=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++++- novel_generator.py | 4 +++- prompt_definitions.py | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 84696da..fde216c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # 📖 自动小说生成工具 -> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 +## 2025/2/5说明 +>当前**dev-2**分支采用了**雪花写作法、角色弧光理论、悬念三要素模型**的预设,尚在完善中,但就测试质量来说,小说质量会高于当前main分支效果。
@@ -18,6 +19,8 @@
+> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 + --- ## 📑 目录导航 diff --git a/novel_generator.py b/novel_generator.py index ebe885c..ed4f4b7 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -504,7 +504,9 @@ def summarize_recent_chapters( prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: {combined_text} -请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" +请用中文输出不超过1000字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点: +重点强调最后一章的重要内容,以及最后一幕场景细节。 +""" summary_text = invoke_with_cleaning(model, prompt) if not summary_text: diff --git a/prompt_definitions.py b/prompt_definitions.py index 989236d..d23bc04 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -226,5 +226,8 @@ scene_dynamics_prompt = """\ 最后设置一个"钩链转折":结尾同时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 -仅返回章节正文文本,不要解释任何内容。 +格式要求: +- 仅返回章节正文文本; +- 不使用分章节小标题; +- 不要使用markdown格式。 """ From f0efb2947e2b67fb08f4d0c41ead5782e71e2881 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 15:01:07 +0800 Subject: [PATCH 057/142] test --- novel_generator.py | 24 +++++------------ prompt_definitions.py | 62 +++++++++++++++++++++++++++++++++---------- ui.py | 20 +++++++++++--- 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index ed4f4b7..6385e59 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -501,11 +501,9 @@ def summarize_recent_chapters( ) combined_text = "\n".join(chapters_text_list) - prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: -{combined_text} + prompt = f"""你是一名资深长篇小说编辑,分析以下合并文本:\n\n {combined_text} \n\n -请用中文输出不超过1000字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点: -重点强调最后一章的重要内容,以及最后一幕场景细节。 +从中提取并预测下一章节的关键字[关键物品/人物/地点/事件/情节] """ summary_text = invoke_with_cleaning(model, prompt) @@ -622,16 +620,7 @@ def generate_chapter_draft( if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 5) 构造prompt - # 拆分 world_building_text, novel_architecture_text 等等 - world_building_text = "" - match_world = re.search(r'#=== 3\) 世界观 ===\n([\s\S]+?)\n#===', novel_architecture_text) - if match_world: - world_building_text = match_world.group(1).strip() - else: - world_building_text = "暂无世界观信息" - - novel_setting_text = novel_architecture_text # 整份当做“小说设定”参考 + novel_setting_text = novel_architecture_text prompt_text = scene_dynamics_prompt.format( novel_number=novel_number, @@ -648,7 +637,6 @@ def generate_chapter_draft( scene_location=scene_location, time_constraint=time_constraint, - world_building=world_building_text, novel_setting=novel_setting_text, global_summary=global_summary_text, character_state=character_state_text @@ -656,7 +644,7 @@ def generate_chapter_draft( # 合并检索到的上下文和用户指导 prompt_text += f"\n\n【检索到的上下文】\n{relevant_context}" - prompt_text += f"\n\n【用户指导】\n{user_guidance}\n" + prompt_text += f"\n\n【章节额外指导】\n{user_guidance}\n" model = ChatOpenAI( model=model_name, @@ -847,7 +835,7 @@ def import_knowledge_file( # 尝试加载已有的向量库 store = load_vector_store( api_key=embedding_api_key, - base_url=embedding_url if embedding_url else "http://localhost:11434/v1", + base_url=embedding_url if embedding_url else "http://localhost:11434/api", interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, filepath=filepath @@ -856,7 +844,7 @@ def import_knowledge_file( logging.info("Vector store does not exist. Initializing a new one for knowledge import...") init_vector_store( api_key=embedding_api_key, - base_url=embedding_url if embedding_url else "http://localhost:11434/v1", + base_url=embedding_url if embedding_url else "http://localhost:11434/api", interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, texts=paragraphs, diff --git a/prompt_definitions.py b/prompt_definitions.py index d23bc04..2410ce0 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -83,19 +83,19 @@ plot_architecture_prompt = """\ 世界观:{world_building} 要求按以下结构设计: -▶ 第一幕(触发) +第一幕(触发) - 日常状态中的异常征兆(3处铺垫) - 引出故事:展示主线、暗线、副线的开端 - 关键事件:打破平衡的催化剂(需改变至少3个角色的关系) - 错误抉择:主角的认知局限导致的错误反应 -▶ 第二幕(对抗) +第二幕(对抗) - 剧情升级:主线+副线的交叉点 - 双重压力:外部障碍升级+内部挫折 - 虚假胜利:看似解决实则深化危机的转折点 - 灵魂黑夜:世界观认知颠覆时刻 -▶ 第三幕(解决) +第三幕(解决) - 代价显现:解决危机必须牺牲的核心价值 - 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择) - 余波:留下2个开放式悬念因子 @@ -169,12 +169,43 @@ update_character_state_prompt = """\ {old_state} 请更新角色状态,内容包括: -角色属性: -- 角色物品 -- 能力 -- 状态 -- 主要角色间关系网 -- 触发或加深的事件 +角色A属性: +├──物品: + ├──道具1:描述 + ├──道具2:描述 + ... +├──能力 + ├──技能1:描述 + ├──技能2:描述 + ... +├──状态 + ├──身体状态: + ├──Buff/Debuff + ├──心理状态:描述 + +├──主要角色间关系网 + ├──角色B:描述 + ├──角色C:描述 + ... +├──触发或加深的事件 + ├──事件1:描述 + ├──事件2:描述 + ... + +角色B属性: +├──物品 + ├──... +├──能力 + ├──... +├──状态 + ├──... +├──主要角色间关系网 + ├──... +├──触发或加深的事件 + ├──... + +角色C属性: +...... 新出场角色: - 任何新增角色或临时出场人物的基本信息 @@ -203,10 +234,13 @@ scene_dynamics_prompt = """\ - 时间压力(可能未指定):{time_constraint} 参考文档: -- 世界观:{world_building} -- 小说设定:{novel_setting} -- 全局摘要:{global_summary} -- 角色状态:{character_state} +- 小说设定:\n{novel_setting}\n +- 全局摘要:\n{global_summary}\n +- 角色状态:\n{character_state}\n + +前章片段(可能为空):\n{previous_chapter_excerpt}\n + +本地向量库检索到的上下文片段(可能为空):\n{context_excerpt}\n 请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: 1. 对话场景: @@ -224,7 +258,7 @@ scene_dynamics_prompt = """\ - 隐喻系统的运用(连接世界观符号) - 决策前的价值天平描写 -最后设置一个"钩链转折":结尾同时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 +文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 格式要求: - 仅返回章节正文文本; diff --git a/ui.py b/ui.py index 425dd40..63f6360 100644 --- a/ui.py +++ b/ui.py @@ -208,9 +208,12 @@ class NovelGeneratorGUI: def build_ai_config_tab(self): def on_interface_format_changed(new_value): - if new_value == "OpenAI": + if new_value == "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": self.base_url_var.set("https://api.openai.com/v1") - # 可根据需要扩展 Ollama / ML Studio 等 for i in range(5): self.ai_config_tab.grid_rowconfigure(i, weight=0) @@ -230,7 +233,7 @@ class NovelGeneratorGUI: interface_label = ctk.CTkLabel(self.ai_config_tab, text="LLM 接口格式:", font=("Microsoft YaHei", 12)) interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI"] + interface_options = ["OpenAI", "Ollama", "ML Studio"] interface_dropdown = ctk.CTkOptionMenu( self.ai_config_tab, values=interface_options, @@ -268,6 +271,14 @@ class NovelGeneratorGUI: self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") def build_embeddings_config_tab(self): + def on_embedding_interface_changed(new_value): + if new_value == "Ollama": + self.embedding_url_var.set("http://localhost:11434/api") + elif new_value == "ML Studio": + self.embedding_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.embedding_url_var.set("https://api.openai.com/v1") + for i in range(5): self.embeddings_config_tab.grid_rowconfigure(i, weight=0) self.embeddings_config_tab.grid_columnconfigure(0, weight=0) @@ -280,11 +291,12 @@ class NovelGeneratorGUI: emb_interface_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding 接口格式:", font=("Microsoft YaHei", 12)) emb_interface_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - emb_interface_options = ["OpenAI"] # 可扩展 + emb_interface_options = ["OpenAI", "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") From bf55e74ac9b9b96e7d45934c67380c9db48815b0 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 15:54:25 +0800 Subject: [PATCH 058/142] =?UTF-8?q?=E5=88=86=E7=A6=BB=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator.py | 187 ++++++++++++++++++++++-------------------- prompt_definitions.py | 68 ++++++++++----- 2 files changed, 142 insertions(+), 113 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index 6385e59..2337d5b 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -5,7 +5,7 @@ import logging import re import time import traceback -from typing import List, Optional +from typing import List, Optional, Tuple # langchain 相关 from langchain_openai import ChatOpenAI, OpenAIEmbeddings @@ -33,7 +33,8 @@ from prompt_definitions import ( chapter_blueprint_prompt, summary_prompt, update_character_state_prompt, - scene_dynamics_prompt + chapter_draft_prompt, + summarize_recent_chapters_prompt ) # Ollama嵌入 (如使用Ollama时需要) @@ -51,8 +52,12 @@ def remove_think_tags(text: str) -> str: return re.sub(r'.*?', '', text, flags=re.DOTALL) def debug_log(prompt: str, response_content: str): - logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n") - logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n") + logging.info( + f"\n[######################################### Prompt #########################################]\n{prompt}\n" + ) + logging.info( + f"\n[######################################### Response #########################################]\n{response_content}\n" + ) def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: """通用封装:调用模型并移除 ... 文本,记录日志后返回""" @@ -132,9 +137,8 @@ def clear_vector_store(filepath: str) -> bool: return False try: - if os.path.exists(store_dir): - shutil.rmtree(store_dir) - logging.info(f"Vector store directory '{store_dir}' removed.") + shutil.rmtree(store_dir) + logging.info(f"Vector store directory '{store_dir}' removed.") return True except Exception as e: logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") @@ -222,7 +226,6 @@ def split_text_for_vectorstore(chapter_text: str, return [] nltk.download('punkt', quiet=True) - nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(chapter_text) if not sentences: return [] @@ -332,7 +335,7 @@ def get_relevant_context_from_vector_store( return combined -# ========== 1) 生成总体架构 (Novel_architecture.txt) ========== +# ============ 1) 生成总体架构 (Novel_architecture.txt) ============ def Novel_architecture_generate( api_key: str, base_url: str, @@ -408,7 +411,7 @@ def Novel_architecture_generate( logging.info("Novel_architecture.txt has been generated successfully.") -# ========== 2) 生成章节蓝图 (Novel_directory.txt) ========== +# ============ 2) 生成章节蓝图 (Novel_directory.txt) ============ def Chapter_blueprint_generate( api_key: str, base_url: str, @@ -467,31 +470,41 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") -# ============ 获取最近 N 章内容,生成短期摘要 ============ +# ============ 工具:获取最近N章内容 ============ + def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: + """ + 返回从 (current_chapter_num - n) 开始到 (current_chapter_num-1) 的章节文本列表。 + 若缺少文件,则对应位置为空字符串。 + """ texts = [] start_chap = max(1, current_chapter_num - n) for c in range(start_chap, current_chapter_num): chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") if os.path.exists(chap_file): text = read_file(chap_file).strip() - if text: - texts.append(text) - if len(texts) < n: - texts = [''] * (n - len(texts)) + texts + texts.append(text) + else: + texts.append("") return texts + +# ============ 新增函数:从合并文本中提炼「当前情节短期摘要」 & 「下一章关键字」 ============ def summarize_recent_chapters( llm_model: str, api_key: str, base_url: str, temperature: float, chapters_text_list: List[str] -) -> str: - if not chapters_text_list: - return "" - if all(not txt.strip() for txt in chapters_text_list): - return "暂无摘要。" +) -> Tuple[str, str]: + """ + 输入若干章节文本,合并后调用 summarize_recent_chapters_prompt, + 返回 (short_summary, next_chapter_keywords) + 如果解析失败,则返回(合并文本, "") + """ + combined_text = "\n".join(chapters_text_list).strip() + if not combined_text: + return ("", "") model = ChatOpenAI( model=llm_model, @@ -500,57 +513,29 @@ def summarize_recent_chapters( temperature=temperature ) - combined_text = "\n".join(chapters_text_list) - prompt = f"""你是一名资深长篇小说编辑,分析以下合并文本:\n\n {combined_text} \n\n + prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) + response_text = invoke_with_cleaning(model, prompt) -从中提取并预测下一章节的关键字[关键物品/人物/地点/事件/情节] -""" + # 简易解析 + short_summary = "" + next_chapter_keywords = "" - summary_text = invoke_with_cleaning(model, prompt) - if not summary_text: - return (combined_text[:800] + "...") if len(combined_text) > 800 else combined_text - return summary_text + for line in response_text.splitlines(): + line = line.strip() + if line.startswith("短期摘要:"): + short_summary = line.replace("短期摘要:", "").strip() + elif line.startswith("下一章关键字:"): + next_chapter_keywords = line.replace("下一章关键字:", "").strip() + + # 如果解析失败,就把返回文本当作短期摘要 + if not short_summary and not next_chapter_keywords: + short_summary = response_text + + return (short_summary, next_chapter_keywords) -# ============ 剧情要点/冲突 ============ -PLOT_ARCS_PROMPT = """\ -下面是新生成的章节内容: -{chapter_text} +# ============ 3) 生成章节草稿(新版) ============ -这里是已记录的剧情要点/未解决冲突(可能为空): -{old_plot_arcs} - -请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 -若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 -""" - -def update_plot_arcs( - chapter_text: str, - old_plot_arcs: str, - api_key: str, - base_url: str, - model_name: str, - temperature: float -) -> str: - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - prompt = PLOT_ARCS_PROMPT.format( - chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs - ) - arcs_text = invoke_with_cleaning(model, prompt) - if not arcs_text: - logging.warning("update_plot_arcs: No response or empty result.") - return old_plot_arcs - return arcs_text - - -# ========== 3) 生成章节草稿 ========== def generate_chapter_draft( api_key: str, base_url: str, @@ -571,12 +556,12 @@ def generate_chapter_draft( embedding_retrieval_k: int = 2 ) -> str: """ - 根据 scene_dynamics_prompt,生成本章草稿。 - - novel_architecture 取自 Novel_architecture.txt - - blueprint 取自 Novel_directory.txt - - global_summary, character_state 分别取自全局摘要、角色状态文件 - - 从向量库检索上下文(embedding_*参数) - - 用户还可以额外提供四个可选元素:核心人物、关键道具、空间坐标、时间压力 + 根据新的 chapter_draft_prompt,生成本章草稿。 + - 首先获取最近3章文本 => 提炼短期摘要 & 下一章关键字 + - 使用(短期摘要 + 下一章关键字) 拼成 query => 检索向量库 + - 同时取上一章(或最后一个非空章节)末尾1500字作为 "前章片段" + - 组合所有信息后,调用模型生成章节草稿 + - 最后保存到 chapters/chapter_{novel_number}.txt """ # 1) 读取相关文件 @@ -602,16 +587,37 @@ def generate_chapter_draft( plot_twist_level = chapter_info["plot_twist_level"] chapter_summary = chapter_info["chapter_summary"] - # 3) 取最近3章文本,拼成查询语句 => 用于向量库检索 chapters_dir = os.path.join(filepath, "chapters") - recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) - merged_query_str = "回顾剧情:\n" + "\n".join(recent_3_texts) + "\n" + user_guidance + os.makedirs(chapters_dir, exist_ok=True) - # 4) 检索向量库上下文 (使用embedding_*参数) + # 3) 获取最近3章文本 => 提炼 (短期摘要 & 下一章关键字) + recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + short_summary, next_chapter_keywords = summarize_recent_chapters( + llm_model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature, + chapters_text_list=recent_3_texts + ) + + # 4) 取上一章片段(或最后一个非空章节)的末尾1500字 + previous_chapter_excerpt = "" + for text_block in reversed(recent_3_texts): + if text_block.strip(): + # 找到最近一个非空章节 + if len(text_block) > 1500: + previous_chapter_excerpt = text_block[-1500:] + else: + previous_chapter_excerpt = text_block + break + # 如果全为空,则 previous_chapter_excerpt 就是 "" + + # 5) 构造向量检索查询: (短期摘要 + 下一章关键字) + retrieval_query = short_summary + " " + next_chapter_keywords relevant_context = get_relevant_context_from_vector_store( api_key=embedding_api_key, base_url=embedding_url, - query=merged_query_str, + query=retrieval_query, interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, filepath=filepath, @@ -620,9 +626,8 @@ def generate_chapter_draft( if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - novel_setting_text = novel_architecture_text - - prompt_text = scene_dynamics_prompt.format( + # 6) 组装 Prompt + prompt_text = chapter_draft_prompt.format( novel_number=novel_number, chapter_title=chapter_title, chapter_role=chapter_role, @@ -636,16 +641,17 @@ def generate_chapter_draft( key_items=key_items, scene_location=scene_location, time_constraint=time_constraint, + user_guidance=user_guidance, - novel_setting=novel_setting_text, + novel_setting=novel_architecture_text, global_summary=global_summary_text, - character_state=character_state_text + character_state=character_state_text, + + previous_chapter_excerpt=previous_chapter_excerpt, + context_excerpt=relevant_context ) - # 合并检索到的上下文和用户指导 - prompt_text += f"\n\n【检索到的上下文】\n{relevant_context}" - prompt_text += f"\n\n【章节额外指导】\n{user_guidance}\n" - + # 7) 调用 LLM 生成章节正文 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -657,10 +663,8 @@ def generate_chapter_draft( if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 6) 写入 chapters - os.makedirs(chapters_dir, exist_ok=True) + # 8) 写入 chapters chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -668,7 +672,7 @@ def generate_chapter_draft( return chapter_content -# ========== 4) 定稿章节 ========== +# ============ 4) 定稿章节 ============ def finalize_chapter( novel_number: int, word_number: int, @@ -735,7 +739,7 @@ def finalize_chapter( clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 3) 更新向量库 (embedding相关) + # 3) 更新向量库 update_vector_store( api_key=embedding_api_key, base_url=embedding_url, @@ -771,6 +775,7 @@ def enrich_chapter_text( # ============ 导入外部知识文本到向量库 ============ + def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: diff --git a/prompt_definitions.py b/prompt_definitions.py index 2410ce0..72dc37c 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,7 +1,22 @@ # prompt_definitions.py # -*- coding: utf-8 -*- """ -集中存放所有提示词(Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型 +集中存放所有提示词 (Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型等 +并包含新增加的短期摘要/下一章关键字提炼提示词,以及章节正文写作提示词。 +""" + +# =============== 摘要与下一章关键字提炼 =============== +summarize_recent_chapters_prompt = """\ +你是一名资深长篇小说编辑,请分析以下合并文本(可能包含最近几章内容): +{combined_text} + +现在请你基于目前故事的进展,完成以下两件事: +1) 用最多200字,写一个简洁明了的「当前情节短期摘要」。 +2) 提炼「下一章」的关键字(例如关键物品、重要人物、地点、事件、情节等),可以用逗号分隔或条目列出。 + +请按如下格式输出(不需要额外解释): +短期摘要: <这里写短期摘要> +下一章关键字: <这里写下一章关键字> """ # =============== 1. 核心种子设定(雪花第1层)=================== @@ -67,7 +82,7 @@ world_building_prompt = """\ 3. 隐喻维度: - 贯穿全书的视觉符号系统(如反复出现的意象) -- 气候/环境变化映射的心理状态 +- 氣候/环境变化映射的心理状态 - 建筑风格暗示的文明困境 要求: @@ -75,7 +90,7 @@ world_building_prompt = """\ 仅给出最终文本,不要解释任何内容。 """ -# =============== 4. 情节架构(悬念三幕式)=================== +# =============== 4. 情节架构(三幕式悬念)=================== plot_architecture_prompt = """\ 基于以下元素构建三幕式悬念架构: 核心种子:{core_seed} @@ -95,7 +110,7 @@ plot_architecture_prompt = """\ - 虚假胜利:看似解决实则深化危机的转折点 - 灵魂黑夜:世界观认知颠覆时刻 -第三幕(解决) +第三幕(解决) - 代价显现:解决危机必须牺牲的核心价值 - 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择) - 余波:留下2个开放式悬念因子 @@ -217,9 +232,9 @@ update_character_state_prompt = """\ 仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 8. 章节正文写作 =================== -scene_dynamics_prompt = """\ -即将创作:第{novel_number}章《{chapter_title}》 +# =============== 8. 章节正文写作(新版) =================== +chapter_draft_prompt = """\ +即将创作:第 {novel_number} 章《{chapter_title}》 本章定位:{chapter_role} 核心作用:{chapter_purpose} 悬念密度:{suspense_level} @@ -234,34 +249,43 @@ scene_dynamics_prompt = """\ - 时间压力(可能未指定):{time_constraint} 参考文档: -- 小说设定:\n{novel_setting}\n -- 全局摘要:\n{global_summary}\n -- 角色状态:\n{character_state}\n +- 小说设定: +{novel_setting} -前章片段(可能为空):\n{previous_chapter_excerpt}\n +- 全局摘要: +{global_summary} -本地向量库检索到的上下文片段(可能为空):\n{context_excerpt}\n +- 角色状态: +{character_state} + +前章片段(可能为空): +{previous_chapter_excerpt} + +本地知识(向量)库检索到的片段(可能为空): +{context_excerpt} 请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: 1. 对话场景: -- 潜台词冲突(表面谈论A,实际博弈B) -- 权力关系变化(使用非对称对话长度控制) -- 至少1处双关语暗示未来危机 + - 潜台词冲突(表面谈论A,实际博弈B) + - 权力关系变化(通过非对称对话长度体现) + - 至少1处双关语暗示未来危机 2. 动作场景: -- 环境交互细节(至少3个感官描写维度) -- 节奏控制(短句加速+比喻减速) -- 动作揭示人物隐藏特质 + - 环境交互细节(至少3个感官描写) + - 节奏控制(短句加速+比喻减速) + - 动作揭示人物隐藏特质 3. 心理场景: -- 认知失调的具体表现(行为矛盾) -- 隐喻系统的运用(连接世界观符号) -- 决策前的价值天平描写 + - 认知失调的具体表现(行为矛盾) + - 隐喻系统的运用(连接世界观符号) + - 决策前的价值天平描写 -文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 +文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知/神转折等。 格式要求: - 仅返回章节正文文本; - 不使用分章节小标题; - 不要使用markdown格式。 + +用户额外指导(可能未指定):{user_guidance} """ From a2b86a90dc2615745fc0c9c8b57d21ca736cfef4 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 18:35:28 +0800 Subject: [PATCH 059/142] =?UTF-8?q?=E4=B8=AD=E9=80=94=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_adapters.py | 122 ++++++++++ llm_adapters.py | 148 ++++++++++++ novel_generator.py | 515 +++++++++++++++++------------------------- tooltips.py | 27 +++ ui.py | 42 ++-- 5 files changed, 523 insertions(+), 331 deletions(-) create mode 100644 embedding_adapters.py create mode 100644 llm_adapters.py create mode 100644 tooltips.py diff --git a/embedding_adapters.py b/embedding_adapters.py new file mode 100644 index 0000000..8c39f51 --- /dev/null +++ b/embedding_adapters.py @@ -0,0 +1,122 @@ +# embedding_adapters.py +# -*- coding: utf-8 -*- +import logging +import requests +import traceback +from typing import List +from langchain_openai import OpenAIEmbeddings + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + """ + import re + url = url.strip() + if not url: + return url + if not re.search(r'/v\d+$', url): + if '/v1' not in url: + url = url.rstrip('/') + '/v1' + return url + +class BaseEmbeddingAdapter: + """ + Embedding 接口统一基类 + """ + def embed_documents(self, texts: List[str]) -> List[List[float]]: + raise NotImplementedError + + def embed_query(self, query: str) -> List[float]: + raise NotImplementedError + +class OpenAIEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 OpenAIEmbeddings(或兼容接口)的适配器 + """ + def __init__(self, api_key: str, base_url: str, model_name: str): + self._embedding = OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=ensure_openai_base_url_has_v1(base_url), + model=model_name + ) + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._embedding.embed_documents(texts) + + def embed_query(self, query: str) -> List[float]: + return self._embedding.embed_query(query) + +class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): + """ + Ollama Embedding,示例中和之前的 embedding_ollama.py 类似 + 其接口路径往往为 /api/embeddings + """ + def __init__(self, model_name: str, base_url: str): + self.model_name = model_name + self.base_url = base_url.rstrip("/") + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + embeddings = [] + for text in texts: + vec = self._embed_single(text) + embeddings.append(vec) + return embeddings + + def embed_query(self, query: str) -> List[float]: + return self._embed_single(query) + + def _embed_single(self, text: str) -> List[float]: + """ + 调用 Ollama 本地服务 /api/embeddings 接口,获取文本 embedding + """ + # 如果 base_url 中已含 /api/embeddings,可直接用;否则拼上 /api/embeddings + url = self.base_url + if "api/embeddings" not in url: + url = f"{url}/api/embeddings" + + data = { + "model": self.model_name, + "prompt": text + } + try: + response = requests.post(url, json=data) + response.raise_for_status() + result = response.json() + if "embedding" not in result: + raise ValueError("No 'embedding' field in Ollama response.") + return result["embedding"] + except requests.exceptions.RequestException as e: + logging.error(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}") + return [] + +class MLStudioEmbeddingAdapter(BaseEmbeddingAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str): + self._embedding = OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=ensure_openai_base_url_has_v1(base_url), + model=model_name + ) + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._embedding.embed_documents(texts) + + def embed_query(self, query: str) -> List[float]: + return self._embedding.embed_query(query) + +def create_embedding_adapter( + interface_format: str, + api_key: str, + base_url: str, + model_name: str +) -> BaseEmbeddingAdapter: + """ + 工厂函数:根据 interface_format 返回不同的 embedding 适配器实例 + """ + if interface_format.lower() == "openai": + return OpenAIEmbeddingAdapter(api_key, base_url, model_name) + elif interface_format.lower() == "ollama": + return OllamaEmbeddingAdapter(model_name, base_url) + elif interface_format.lower() == "ml studio": + return MLStudioEmbeddingAdapter(api_key, base_url, model_name) + else: + raise ValueError(f"Unknown embedding interface_format: {interface_format}") diff --git a/llm_adapters.py b/llm_adapters.py new file mode 100644 index 0000000..2fa5e37 --- /dev/null +++ b/llm_adapters.py @@ -0,0 +1,148 @@ +# llm_adapters.py +# -*- coding: utf-8 -*- +import logging +from typing import Optional +from langchain_openai import ChatOpenAI + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + """ + import re + url = url.strip() + if not url: + return url + if not re.search(r'/v\d+$', url): + if '/v1' not in url: + url = url.rstrip('/') + '/v1' + return url + +class BaseLLMAdapter: + """ + 统一的 LLM 接口基类,为不同后端(OpenAI、Ollama、ML Studio 等)提供一致的方法签名。 + """ + def invoke(self, prompt: str) -> str: + raise NotImplementedError("Subclasses must implement .invoke(prompt) method.") + +class DeepSeekAdapter(BaseLLMAdapter): + """ + 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from DeepSeekAdapter.") + return "" + return response.content + +class OpenAIAdapter(BaseLLMAdapter): + """ + 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from OpenAIAdapter.") + return "" + return response.content + +class OllamaAdapter(BaseLLMAdapter): + """ + Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 + 但是通常 Ollama 默认本地服务在 http://localhost:11434,如果符合OpenAI风格即可直接传参。 + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from OllamaAdapter.") + return "" + return response.content + +class MLStudioAdapter(BaseLLMAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from MLStudioAdapter.") + return "" + return response.content + +def create_llm_adapter( + interface_format: str, + base_url: str, + model_name: str, + api_key: str, + temperature: float +) -> BaseLLMAdapter: + """ + 工厂函数:根据 interface_format 返回不同的适配器实例。 + """ + if interface_format.lower() == "deepseek": + return DeepSeekAdapter(api_key, base_url, model_name, temperature) + elif interface_format.lower() == "openai": + return OpenAIAdapter(api_key, base_url, model_name, temperature) + elif interface_format.lower() == "ollama": + return OllamaAdapter(api_key, base_url, model_name, temperature) + elif interface_format.lower() == "ml studio": + return MLStudioAdapter(api_key, base_url, model_name, temperature) + else: + raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/novel_generator.py b/novel_generator.py index 2337d5b..9e6206a 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -7,8 +7,6 @@ import time import traceback from typing import List, Optional, Tuple -# langchain 相关 -from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_chroma import Chroma from chromadb.config import Settings from langchain.docstore.document import Document @@ -37,16 +35,16 @@ from prompt_definitions import ( summarize_recent_chapters_prompt ) -# Ollama嵌入 (如使用Ollama时需要) -from embedding_ollama import OllamaEmbeddings - -# 用于目录解析章节标题/简介 +# 章节目录解析 from chapter_directory_parser import get_chapter_info_from_blueprint +from llm_adapters import create_llm_adapter +from embedding_adapters import create_embedding_adapter + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +# ============ 工具函数 ============ -# ============ 基础工具 ============ def remove_think_tags(text: str) -> str: """移除 ... 包裹的内容""" return re.sub(r'.*?', '', text, flags=re.DOTALL) @@ -59,128 +57,79 @@ def debug_log(prompt: str, response_content: str): f"\n[######################################### Response #########################################]\n{response_content}\n" ) -def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: - """通用封装:调用模型并移除 ... 文本,记录日志后返回""" - response = model.invoke(prompt) +def invoke_with_cleaning(llm_adapter, prompt: str) -> str: + """通用封装:调用 LLM,并移除 ... 文本,记录日志后返回""" + response = llm_adapter.invoke(prompt) if not response: logging.warning("No response from model.") return "" - cleaned_text = remove_think_tags(response.content) + cleaned_text = remove_think_tags(response) debug_log(prompt, cleaned_text) return cleaned_text.strip() -def ensure_openai_base_url_has_v1(url: str) -> str: - """ - 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 - """ - import re - url = url.strip() - if not url: - return url - if not re.search(r'/v\d+$', url): - if '/v1' not in url: - url = url.rstrip('/') + '/v1' - return url - -def is_using_ollama_api(interface_format: str) -> bool: - return interface_format.lower() == "ollama" - -def is_using_ml_studio_api(interface_format: str) -> bool: - return interface_format.lower() == "ml studio" - - # ============ 获取 vectorstore 路径 ============ + def get_vectorstore_dir(filepath: str) -> str: - """ - 返回存储向量库的本地路径: - 在用户指定的 `filepath` 下创建/使用 'vectorstore' 文件夹。 - """ return os.path.join(filepath, "vectorstore") +# ============ 清空向量库 ============ -# ============ 创建 Embeddings 对象 ============ -def create_embeddings_object( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str -): - """ - 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 - """ - if is_using_ollama_api(interface_format): - fixed_url = base_url.rstrip("/") - return OllamaEmbeddings( - model_name=embedding_model_name, - base_url=fixed_url - ) - else: - # OpenAI 或 ML Studio 均使用 OpenAIEmbeddings,注意 base_url 可能需要 ensure /v1 - fixed_url = ensure_openai_base_url_has_v1(base_url) - return OpenAIEmbeddings( - openai_api_key=api_key, - openai_api_base=fixed_url, - model=embedding_model_name - ) - - -# ============ 向量库相关操作 ============ def clear_vector_store(filepath: str) -> bool: - """ - 返回值表示是否成功清空向量库。 - """ import shutil - store_dir = get_vectorstore_dir(filepath) if not os.path.exists(store_dir): logging.info("No vector store found to clear.") return False - try: shutil.rmtree(store_dir) logging.info(f"Vector store directory '{store_dir}' removed.") return True except Exception as e: - logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") + logging.error(f"无法删除向量库文件夹,请关闭程序后手动删除 {store_dir}。\n {str(e)}") traceback.print_exc() return False +# ============ 根据 embedding 接口创建/加载 Chroma ============ + def init_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, + embedding_adapter, texts: List[str], filepath: str ) -> Chroma: """ 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 + 这里 embedding_adapter 是一个实现了 embed_documents(texts) 的对象 """ store_dir = get_vectorstore_dir(filepath) os.makedirs(store_dir, exist_ok=True) - embeddings = create_embeddings_object( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) + # 将文本封装为 Document documents = [Document(page_content=str(t)) for t in texts] + + # 因为我们是自定义的 embeddings,对接Chroma时需包装一个“langchain兼容对象” + # 这里示例:写一个包装函数 + from langchain.embeddings.base import Embeddings as LCEmbeddings + + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + return embedding_adapter.embed_documents(doc_texts) + + def embed_query(self, query_text: str) -> List[float]: + return embedding_adapter.embed_query(query_text) + + chroma_embedding = LCEmbeddingWrapper() + vectorstore = Chroma.from_documents( documents, - embedding=embeddings, + embedding=chroma_embedding, persist_directory=store_dir, client_settings=Settings(anonymized_telemetry=False), collection_name="novel_collection" ) return vectorstore - def load_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, + embedding_adapter, filepath: str ) -> Optional[Chroma]: """ @@ -191,19 +140,26 @@ def load_vector_store( logging.info("Vector store not found. Will return None.") return None - embeddings = create_embeddings_object( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) + # 同样要包装embedding_adapter + from langchain.embeddings.base import Embeddings as LCEmbeddings + + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + return embedding_adapter.embed_documents(doc_texts) + + def embed_query(self, query_text: str) -> List[float]: + return embedding_adapter.embed_query(query_text) + + chroma_embedding = LCEmbeddingWrapper() + return Chroma( persist_directory=store_dir, - embedding_function=embeddings, + embedding_function=chroma_embedding, client_settings=Settings(anonymized_telemetry=False), collection_name="novel_collection" ) +# ============ 文本分段工具 ============ def split_by_length(text: str, max_length: int = 500) -> List[str]: segments = [] @@ -215,12 +171,12 @@ def split_by_length(text: str, max_length: int = 500) -> List[str]: start_idx = end_idx return segments - def split_text_for_vectorstore(chapter_text: str, max_length: int = 500, similarity_threshold: float = 0.7) -> List[str]: """ 对新的章节文本进行分段后,再用于存入向量库。 + 先句子切分 -> 语义相似度合并 -> 再按 max_length 切分。 """ if not chapter_text.strip(): return [] @@ -230,7 +186,6 @@ def split_text_for_vectorstore(chapter_text: str, if not sentences: return [] - # 先对相近句子进行合并 model = SentenceTransformer('paraphrase-MiniLM-L6-v2') embeddings = model.encode(sentences) @@ -251,7 +206,6 @@ def split_text_for_vectorstore(chapter_text: str, if current_sentences: merged_paragraphs.append(" ".join(current_sentences)) - # 再对合并好的段落做 max_length 切分 final_segments = [] for para in merged_paragraphs: if len(para) > max_length: @@ -262,13 +216,11 @@ def split_text_for_vectorstore(chapter_text: str, return final_segments +# ============ 更新向量库 ============ def update_vector_store( - api_key: str, - base_url: str, + embedding_adapter, new_chapter: str, - interface_format: str, - embedding_model_name: str, filepath: str ): """ @@ -279,49 +231,28 @@ def update_vector_store( logging.warning("No valid text to insert into vector store. Skipping.") return - store = load_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) + store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("Vector store does not exist. Initializing a new one for new chapter...") - init_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=splitted_texts, - filepath=filepath - ) + init_vector_store(embedding_adapter, splitted_texts, filepath) return docs = [Document(page_content=str(t)) for t in splitted_texts] store.add_documents(docs) logging.info("Vector store updated with the new chapter splitted segments.") - + +# ============ 向量检索上下文 ============ def get_relevant_context_from_vector_store( - api_key: str, - base_url: str, + embedding_adapter, query: str, - interface_format: str, - embedding_model_name: str, filepath: str, k: int = 2 ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 """ - store = load_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) + store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("No vector store found. Returning empty context.") return "" @@ -334,8 +265,68 @@ def get_relevant_context_from_vector_store( combined = "\n".join([d.page_content for d in docs]) return combined +# ============ 从目录中获取最近 n 章文本 ============ + +def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: + texts = [] + start_chap = max(1, current_chapter_num - n) + for c in range(start_chap, current_chapter_num): + chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") + if os.path.exists(chap_file): + text = read_file(chap_file).strip() + texts.append(text) + else: + texts.append("") + return texts + +# ============ 提炼(短期摘要, 下一章关键字) ============ + +def summarize_recent_chapters( + interface_format: str, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + chapters_text_list: List[str] +) -> Tuple[str, str]: + """ + 生成 (short_summary, next_chapter_keywords) + 如果解析失败,则返回 (合并文本, "") + """ + combined_text = "\n".join(chapters_text_list).strip() + if not combined_text: + return ("", "") + + # 1) 构造 llm_adapter + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature + ) + + prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) + response_text = invoke_with_cleaning(llm_adapter, prompt) + + short_summary = "" + next_chapter_keywords = "" + + for line in response_text.splitlines(): + line = line.strip() + if line.startswith("短期摘要:"): + short_summary = line.replace("短期摘要:", "").strip() + elif line.startswith("下一章关键字:"): + next_chapter_keywords = line.replace("下一章关键字:", "").strip() + + if not short_summary and not next_chapter_keywords: + short_summary = response_text + + return (short_summary, next_chapter_keywords) + + +# ============ 1) 生成总体架构 ============ -# ============ 1) 生成总体架构 (Novel_architecture.txt) ============ def Novel_architecture_generate( api_key: str, base_url: str, @@ -348,70 +339,68 @@ def Novel_architecture_generate( temperature: float = 0.7 ) -> None: """ - 依次调用: + 依次调用: 1. core_seed_prompt 2. character_dynamics_prompt 3. world_building_prompt 4. plot_architecture_prompt - 将结果整合为“Novel_architecture.txt”。 + 最终输出 Novel_architecture.txt """ os.makedirs(filepath, exist_ok=True) - model = ChatOpenAI( - model=llm_model, + + # 通过工厂函数创建 LLM 适配器 + llm_adapter = create_llm_adapter( + interface_format="openai", # 或根据你的实际:若你在UI中就是 "OpenAI" 就传递过来 + base_url=base_url, + model_name=llm_model, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - # 1) 核心种子 + # Step1: 核心种子 prompt_core = core_seed_prompt.format( topic=topic, genre=genre, number_of_chapters=number_of_chapters, word_number=word_number ) - core_seed_result = invoke_with_cleaning(model, prompt_core) - core_seed_text = core_seed_result.strip() + core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) - # 2) 角色动力学 - prompt_character = character_dynamics_prompt.format(core_seed=core_seed_text) - character_dynamics_result = invoke_with_cleaning(model, prompt_character) - character_dynamics_text = character_dynamics_result.strip() + # Step2: 角色动力学 + prompt_character = character_dynamics_prompt.format(core_seed=core_seed_result.strip()) + character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) - # 3) 世界观 - prompt_world = world_building_prompt.format(core_seed=core_seed_text) - world_building_result = invoke_with_cleaning(model, prompt_world) - world_building_text = world_building_result.strip() + # Step3: 世界观 + prompt_world = world_building_prompt.format(core_seed=core_seed_result.strip()) + world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) - # 4) 三幕式情节架构 + # Step4: 三幕式情节 prompt_plot = plot_architecture_prompt.format( - core_seed=core_seed_text, - character_dynamics=character_dynamics_text, - world_building=world_building_text + core_seed=core_seed_result.strip(), + character_dynamics=character_dynamics_result.strip(), + world_building=world_building_result.strip() ) - plot_arch_result = invoke_with_cleaning(model, prompt_plot) - plot_arch_text = plot_arch_result.strip() + plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) - # 整合并写入 Novel_architecture.txt + # 合并 final_content = ( "#=== 1) 核心种子 ===\n" - f"{core_seed_text}\n\n" + f"{core_seed_result}\n\n" "#=== 2) 角色动力学 ===\n" - f"{character_dynamics_text}\n\n" + f"{character_dynamics_result}\n\n" "#=== 3) 世界观 ===\n" - f"{world_building_text}\n\n" + f"{world_building_result}\n\n" "#=== 4) 三幕式情节架构 ===\n" - f"{plot_arch_text}\n" + f"{plot_arch_result}\n" ) arch_file = os.path.join(filepath, "Novel_architecture.txt") clear_file_content(arch_file) save_string_to_txt(final_content, arch_file) - logging.info("Novel_architecture.txt has been generated successfully.") +# ============ 2) 生成章节蓝图 ============ -# ============ 2) 生成章节蓝图 (Novel_directory.txt) ============ def Chapter_blueprint_generate( api_key: str, base_url: str, @@ -419,10 +408,6 @@ def Chapter_blueprint_generate( filepath: str, temperature: float = 0.7 ) -> None: - """ - 基于“Novel_architecture.txt”中的三幕式情节架构,调用 chapter_blueprint_prompt, - 生成章节蓝图并写入 Novel_directory.txt。 - """ arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): logging.warning("Novel_architecture.txt not found. Please generate architecture first.") @@ -433,12 +418,11 @@ def Chapter_blueprint_generate( logging.warning("Novel_architecture.txt is empty.") return - # 从内容中尽量提取 number_of_chapters match_chaps = re.search(r'约(\d+)章', architecture_text) if match_chaps: number_of_chapters = int(match_chaps.group(1)) else: - number_of_chapters = 10 # fallback + number_of_chapters = 10 # 提取三幕式文本 plot_arch_text = "" @@ -447,10 +431,11 @@ def Chapter_blueprint_generate( if m: plot_arch_text = m.group(1).strip() - model = ChatOpenAI( - model=llm_model, + llm_adapter = create_llm_adapter( + interface_format="openai", # 或实际由UI传入 + base_url=base_url, + model_name=llm_model, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) @@ -458,7 +443,7 @@ def Chapter_blueprint_generate( plot_architecture=plot_arch_text, number_of_chapters=number_of_chapters ) - blueprint_text = invoke_with_cleaning(model, prompt) + blueprint_text = invoke_with_cleaning(llm_adapter, prompt) if not blueprint_text.strip(): logging.warning("Chapter blueprint generation result is empty.") return @@ -469,72 +454,7 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") - -# ============ 工具:获取最近N章内容 ============ - -def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: - """ - 返回从 (current_chapter_num - n) 开始到 (current_chapter_num-1) 的章节文本列表。 - 若缺少文件,则对应位置为空字符串。 - """ - texts = [] - start_chap = max(1, current_chapter_num - n) - for c in range(start_chap, current_chapter_num): - chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") - if os.path.exists(chap_file): - text = read_file(chap_file).strip() - texts.append(text) - else: - texts.append("") - return texts - - -# ============ 新增函数:从合并文本中提炼「当前情节短期摘要」 & 「下一章关键字」 ============ -def summarize_recent_chapters( - llm_model: str, - api_key: str, - base_url: str, - temperature: float, - chapters_text_list: List[str] -) -> Tuple[str, str]: - """ - 输入若干章节文本,合并后调用 summarize_recent_chapters_prompt, - 返回 (short_summary, next_chapter_keywords) - 如果解析失败,则返回(合并文本, "") - """ - combined_text = "\n".join(chapters_text_list).strip() - if not combined_text: - return ("", "") - - model = ChatOpenAI( - model=llm_model, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) - response_text = invoke_with_cleaning(model, prompt) - - # 简易解析 - short_summary = "" - next_chapter_keywords = "" - - for line in response_text.splitlines(): - line = line.strip() - if line.startswith("短期摘要:"): - short_summary = line.replace("短期摘要:", "").strip() - elif line.startswith("下一章关键字:"): - next_chapter_keywords = line.replace("下一章关键字:", "").strip() - - # 如果解析失败,就把返回文本当作短期摘要 - if not short_summary and not next_chapter_keywords: - short_summary = response_text - - return (short_summary, next_chapter_keywords) - - -# ============ 3) 生成章节草稿(新版) ============ +# ============ 3) 生成章节草稿 ============ def generate_chapter_draft( api_key: str, @@ -555,16 +475,6 @@ def generate_chapter_draft( embedding_model_name: str, embedding_retrieval_k: int = 2 ) -> str: - """ - 根据新的 chapter_draft_prompt,生成本章草稿。 - - 首先获取最近3章文本 => 提炼短期摘要 & 下一章关键字 - - 使用(短期摘要 + 下一章关键字) 拼成 query => 检索向量库 - - 同时取上一章(或最后一个非空章节)末尾1500字作为 "前章片段" - - 组合所有信息后,调用模型生成章节草稿 - - 最后保存到 chapters/chapter_{novel_number}.txt - """ - - # 1) 读取相关文件 arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -577,7 +487,7 @@ def generate_chapter_draft( character_state_file = os.path.join(filepath, "character_state.txt") character_state_text = read_file(character_state_file) - # 2) 解析 blueprint,得到本章所需的字段 + # 解析本章信息 chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] chapter_role = chapter_info["chapter_role"] @@ -590,43 +500,45 @@ def generate_chapter_draft( chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - # 3) 获取最近3章文本 => 提炼 (短期摘要 & 下一章关键字) + # 获取最近3章 => (短期摘要, 下一章关键字) recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) short_summary, next_chapter_keywords = summarize_recent_chapters( - llm_model=model_name, + interface_format="openai", # 或由UI传进 api_key=api_key, base_url=base_url, + model_name=model_name, temperature=temperature, chapters_text_list=recent_3_texts ) - # 4) 取上一章片段(或最后一个非空章节)的末尾1500字 + # 上一章片段(末尾1500字) previous_chapter_excerpt = "" for text_block in reversed(recent_3_texts): if text_block.strip(): - # 找到最近一个非空章节 if len(text_block) > 1500: previous_chapter_excerpt = text_block[-1500:] else: previous_chapter_excerpt = text_block break - # 如果全为空,则 previous_chapter_excerpt 就是 "" - # 5) 构造向量检索查询: (短期摘要 + 下一章关键字) + # 使用embedding检索上下文 + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name + ) retrieval_query = short_summary + " " + next_chapter_keywords relevant_context = get_relevant_context_from_vector_store( - api_key=embedding_api_key, - base_url=embedding_url, + embedding_adapter=embedding_adapter, query=retrieval_query, - interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, filepath=filepath, k=embedding_retrieval_k ) if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 6) 组装 Prompt + # 组装 Prompt prompt_text = chapter_draft_prompt.format( novel_number=novel_number, chapter_title=chapter_title, @@ -646,24 +558,23 @@ def generate_chapter_draft( novel_setting=novel_architecture_text, global_summary=global_summary_text, character_state=character_state_text, - previous_chapter_excerpt=previous_chapter_excerpt, context_excerpt=relevant_context ) - # 7) 调用 LLM 生成章节正文 - model = ChatOpenAI( - model=model_name, + # 调用 LLM 生成 + llm_adapter = create_llm_adapter( + interface_format="openai", # 或由UI传进 + base_url=base_url, + model_name=model_name, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - - chapter_content = invoke_with_cleaning(model, prompt_text) + chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 8) 写入 chapters + # 写入 chapter_X.txt chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -671,8 +582,8 @@ def generate_chapter_draft( logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") return chapter_content - # ============ 4) 定稿章节 ============ + def finalize_chapter( novel_number: int, word_number: int, @@ -686,9 +597,6 @@ def finalize_chapter( embedding_interface_format: str, embedding_model_name: str ): - """ - 定稿:更新全局摘要、角色状态,并将本章文本插入向量库。 - """ chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -696,7 +604,7 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 若篇幅过短,可尝试扩写 + # 如果篇幅过短,可以扩写 if len(chapter_text) < 0.6 * word_number: chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) clear_file_content(chapter_file) @@ -708,50 +616,49 @@ def finalize_chapter( character_state_file = os.path.join(filepath, "character_state.txt") old_character_state = read_file(character_state_file) - # 1) 更新全局摘要 - model = ChatOpenAI( - model=model_name, + # 调用 LLM 更新全局摘要 + llm_adapter = create_llm_adapter( + interface_format="openai", + base_url=base_url, + model_name=model_name, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) prompt_summary = summary_prompt.format( chapter_text=chapter_text, global_summary=old_global_summary ) - new_global_summary = invoke_with_cleaning(model, prompt_summary) + new_global_summary = invoke_with_cleaning(llm_adapter, prompt_summary) if not new_global_summary.strip(): new_global_summary = old_global_summary - # 2) 更新角色状态 + # 更新角色状态 prompt_char_state = update_character_state_prompt.format( chapter_text=chapter_text, old_state=old_character_state ) - new_char_state = invoke_with_cleaning(model, prompt_char_state) + new_char_state = invoke_with_cleaning(llm_adapter, prompt_char_state) if not new_char_state.strip(): new_char_state = old_character_state - # 写回文件 + # 写回 clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 3) 更新向量库 - update_vector_store( - api_key=embedding_api_key, - base_url=embedding_url, - new_chapter=chapter_text, - interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath + # 更新向量库 + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name ) + update_vector_store(embedding_adapter, chapter_text, filepath) logging.info(f"Chapter {novel_number} has been finalized.") - def enrich_chapter_text( chapter_text: str, word_number: int, @@ -760,28 +667,25 @@ def enrich_chapter_text( model_name: str, temperature: float ) -> str: - model = ChatOpenAI( - model=model_name, + llm_adapter = create_llm_adapter( + interface_format="openai", + base_url=base_url, + model_name=model_name, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 - -原章节内容: -{chapter_text}""" - enriched_text = invoke_with_cleaning(model, prompt) + prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: +原内容: +{chapter_text} +""" + enriched_text = invoke_with_cleaning(llm_adapter, prompt) return enriched_text if enriched_text else chapter_text - -# ============ 导入外部知识文本到向量库 ============ +# ============ 导入知识文件到向量库 ============ def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: - """ - 将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。 - """ nltk.download('punkt', quiet=True) sentences = nltk.sent_tokenize(content) if not sentences: @@ -837,24 +741,17 @@ def import_knowledge_file( paragraphs = advanced_split_content(content) - # 尝试加载已有的向量库 - store = load_vector_store( + embedding_adapter = create_embedding_adapter( + interface_format=embedding_interface_format, api_key=embedding_api_key, base_url=embedding_url if embedding_url else "http://localhost:11434/api", - interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath + model_name=embedding_model_name ) + + store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store( - api_key=embedding_api_key, - base_url=embedding_url if embedding_url else "http://localhost:11434/api", - interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, - texts=paragraphs, - filepath=filepath - ) + init_vector_store(embedding_adapter, paragraphs, filepath) else: docs = [Document(page_content=str(p)) for p in paragraphs] store.add_documents(docs) diff --git a/tooltips.py b/tooltips.py new file mode 100644 index 0000000..f4e8220 --- /dev/null +++ b/tooltips.py @@ -0,0 +1,27 @@ +# tooltips.py +# -*- coding: utf-8 -*- + +tooltips = { + "api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。", + "base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。", + "interface_format": "指定LLM接口兼容格式,可选OpenAI、Ollama、ML Studio等。", + "model_name": "要使用的模型名称,例如gpt-3.5-turbo、llama2等。如果是Ollama,请填写你下载好的本地模型名。", + "temperature": "生成文本的随机度。数值越大越具有发散性,越小越严谨。", + "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。", + "embedding_api_key": "调用Embedding模型时所需的API Key。", + "embedding_interface_format": "Embedding模型接口风格,比如OpenAI或Ollama。", + "embedding_url": "Embedding模型接口地址。", + "embedding_model_name": "Embedding模型名称,如text-embedding-ada-002。", + "embedding_retrieval_k": "向量检索时返回的Top-K结果数量。", + "topic": "小说的大致主题或主要故事背景描述。", + "genre": "小说的题材类型,如玄幻、都市、科幻等。", + "num_chapters": "小说期望的章节总数。", + "word_number": "每章的目标字数。", + "filepath": "生成文件存储的根目录路径。所有txt文件、向量库等放在该目录下。", + "chapter_num": "当前正在处理的章节号,用于生成草稿或定稿操作。", + "user_guidance": "为本章提供的一些额外指令或写作引导。", + "characters_involved": "本章需要重点描写或影响剧情的角色名单。", + "key_items": "在本章中出现的重要道具、线索或物品。", + "scene_location": "本章主要发生的地点或场景描述。", + "time_constraint": "本章剧情中涉及的时间压力或时限设置。" +} diff --git a/ui.py b/ui.py index 63f6360..3c2a267 100644 --- a/ui.py +++ b/ui.py @@ -10,6 +10,7 @@ import traceback from config_manager import load_config, save_config from utils import read_file, save_string_to_txt, clear_file_content + from novel_generator import ( Novel_architecture_generate, Chapter_blueprint_generate, @@ -17,21 +18,17 @@ from novel_generator import ( finalize_chapter, import_knowledge_file, clear_vector_store, - get_last_n_chapters_text, + get_last_n_chapters_text ) + from consistency_checker import check_consistency - 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: def __init__(self, master): self.master = master @@ -71,14 +68,14 @@ class NovelGeneratorGUI: 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="") # UI 布局 - self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) + self.tabview = ctk.CTkTabview(self.master) self.tabview.pack(fill="both", expand=True) self.main_tab = self.tabview.add("Main Functions") @@ -197,7 +194,7 @@ class NovelGeneratorGUI: self.build_optional_buttons_area(start_row=2) def build_config_tabview(self): - self.config_tabview = ctk.CTkTabview(self.config_frame, width=600, height=200) + 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") @@ -256,8 +253,8 @@ class NovelGeneratorGUI: temp_scale = ctk.CTkSlider( self.ai_config_tab, - from_=0.0, to=1.0, - number_of_steps=100, + from_=0.0, to=2.0, + number_of_steps=200, command=update_temp_label, variable=self.temperature_var ) @@ -335,7 +332,7 @@ class NovelGeneratorGUI: topic_label = ctk.CTkLabel(self.params_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox(self.params_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.topic_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) 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) @@ -384,7 +381,7 @@ class NovelGeneratorGUI: # 用户指导 guide_label = ctk.CTkLabel(self.params_frame, text="本章指导:", font=("Microsoft YaHei", 12)) guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox(self.params_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.user_guide_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") # 新增:四个可选元素 @@ -528,7 +525,7 @@ class NovelGeneratorGUI: logging.error(full_message) self.safe_log(full_message) - # ------------------ Step1: 生成架构 ------------------ + # ============ Step1: 生成小说架构 ============ def generate_novel_architecture_ui(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -568,7 +565,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ Step2: 生成章节蓝图 ------------------ + # ============ Step2: 生成章节蓝图 ============ def generate_chapter_blueprint_ui(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -599,7 +596,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ Step3: 生成草稿 ------------------ + # ============ Step3: 生成章节草稿 ============ def generate_chapter_draft_ui(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -671,7 +668,7 @@ class NovelGeneratorGUI: self.chapter_result.insert("0.0", text) self.chapter_result.see("end") - # ------------------ Step4: 定稿章节 ------------------ + # ============ Step4: 定稿章节 ============ def finalize_chapter_ui(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -731,7 +728,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ 一致性审校 ------------------ + # ============ 一致性审校 (可选) ============ def do_consistency_check(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -756,7 +753,7 @@ class NovelGeneratorGUI: self.safe_log("开始一致性审校...") result = check_consistency( - novel_setting="", # 如果需要,可传入最新的 Novel_architecture 内容 + 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, @@ -776,6 +773,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() + # ============ 导入知识库 ============ def import_knowledge_handler(self): selected_file = filedialog.askopenfilename( title="选择要导入的知识库文件", @@ -847,7 +845,8 @@ class NovelGeneratorGUI: text_area.insert("0.0", arcs_text) text_area.configure(state="disabled") - # ------------------ 其他标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ------------------ + # ============ 其余标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ============ + def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -1032,7 +1031,7 @@ class NovelGeneratorGUI: save_string_to_txt(content, filename) self.log("已保存对 global_summary.txt 的修改。") - # ------------------ 章节管理标签页 ------------------ + # ============ 章节管理标签页 ============ def build_chapters_tab(self): self.chapters_view_tab.rowconfigure(0, weight=0) self.chapters_view_tab.rowconfigure(1, weight=1) @@ -1165,7 +1164,6 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") - if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app) From ca0d6e5047eab9b9a05a8ea141c8680413599151 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 19:37:37 +0800 Subject: [PATCH 060/142] add tooltip,max_tokens --- embedding_adapters.py | 1 - llm_adapters.py | 19 +- novel_generator.py | 83 +++++---- tooltips.py | 16 +- ui.py | 403 ++++++++++++++++++++++++++++++++---------- 5 files changed, 378 insertions(+), 144 deletions(-) diff --git a/embedding_adapters.py b/embedding_adapters.py index 8c39f51..a17bb7a 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -69,7 +69,6 @@ class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): """ 调用 Ollama 本地服务 /api/embeddings 接口,获取文本 embedding """ - # 如果 base_url 中已含 /api/embeddings,可直接用;否则拼上 /api/embeddings url = self.base_url if "api/embeddings" not in url: url = f"{url}/api/embeddings" diff --git a/llm_adapters.py b/llm_adapters.py index 2fa5e37..714e72a 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -28,7 +28,7 @@ class DeepSeekAdapter(BaseLLMAdapter): """ 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -54,7 +54,7 @@ class OpenAIAdapter(BaseLLMAdapter): """ 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -81,7 +81,7 @@ class OllamaAdapter(BaseLLMAdapter): Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 但是通常 Ollama 默认本地服务在 http://localhost:11434,如果符合OpenAI风格即可直接传参。 """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -104,7 +104,7 @@ class OllamaAdapter(BaseLLMAdapter): return response.content class MLStudioAdapter(BaseLLMAdapter): - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -131,18 +131,19 @@ def create_llm_adapter( base_url: str, model_name: str, api_key: str, - temperature: float + temperature: float, + max_tokens: int ) -> BaseLLMAdapter: """ 工厂函数:根据 interface_format 返回不同的适配器实例。 """ if interface_format.lower() == "deepseek": - return DeepSeekAdapter(api_key, base_url, model_name, temperature) + return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "openai": - return OpenAIAdapter(api_key, base_url, model_name, temperature) + return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "ollama": - return OllamaAdapter(api_key, base_url, model_name, temperature) + return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "ml studio": - return MLStudioAdapter(api_key, base_url, model_name, temperature) + return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature) else: raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/novel_generator.py b/novel_generator.py index 9e6206a..5e2a6ed 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -43,6 +43,7 @@ from embedding_adapters import create_embedding_adapter logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + # ============ 工具函数 ============ def remove_think_tags(text: str) -> str: @@ -67,6 +68,7 @@ def invoke_with_cleaning(llm_adapter, prompt: str) -> str: debug_log(prompt, cleaned_text) return cleaned_text.strip() + # ============ 获取 vectorstore 路径 ============ def get_vectorstore_dir(filepath: str) -> str: @@ -89,6 +91,7 @@ def clear_vector_store(filepath: str) -> bool: traceback.print_exc() return False + # ============ 根据 embedding 接口创建/加载 Chroma ============ def init_vector_store( @@ -103,11 +106,8 @@ def init_vector_store( store_dir = get_vectorstore_dir(filepath) os.makedirs(store_dir, exist_ok=True) - # 将文本封装为 Document documents = [Document(page_content=str(t)) for t in texts] - # 因为我们是自定义的 embeddings,对接Chroma时需包装一个“langchain兼容对象” - # 这里示例:写一个包装函数 from langchain.embeddings.base import Embeddings as LCEmbeddings class LCEmbeddingWrapper(LCEmbeddings): @@ -140,7 +140,6 @@ def load_vector_store( logging.info("Vector store not found. Will return None.") return None - # 同样要包装embedding_adapter from langchain.embeddings.base import Embeddings as LCEmbeddings class LCEmbeddingWrapper(LCEmbeddings): @@ -159,6 +158,7 @@ def load_vector_store( collection_name="novel_collection" ) + # ============ 文本分段工具 ============ def split_by_length(text: str, max_length: int = 500) -> List[str]: @@ -240,7 +240,7 @@ def update_vector_store( docs = [Document(page_content=str(t)) for t in splitted_texts] store.add_documents(docs) logging.info("Vector store updated with the new chapter splitted segments.") - + # ============ 向量检索上下文 ============ def get_relevant_context_from_vector_store( @@ -287,6 +287,7 @@ def summarize_recent_chapters( base_url: str, model_name: str, temperature: float, + max_tokens: int, chapters_text_list: List[str] ) -> Tuple[str, str]: """ @@ -297,13 +298,13 @@ def summarize_recent_chapters( if not combined_text: return ("", "") - # 1) 构造 llm_adapter llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) @@ -328,6 +329,7 @@ def summarize_recent_chapters( # ============ 1) 生成总体架构 ============ def Novel_architecture_generate( + interface_format: str, api_key: str, base_url: str, llm_model: str, @@ -336,7 +338,8 @@ def Novel_architecture_generate( number_of_chapters: int, word_number: int, filepath: str, - temperature: float = 0.7 + temperature: float = 0.7, + max_tokens: int = 2048 ) -> None: """ 依次调用: @@ -348,13 +351,13 @@ def Novel_architecture_generate( """ os.makedirs(filepath, exist_ok=True) - # 通过工厂函数创建 LLM 适配器 llm_adapter = create_llm_adapter( - interface_format="openai", # 或根据你的实际:若你在UI中就是 "OpenAI" 就传递过来 + interface_format=interface_format, base_url=base_url, model_name=llm_model, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) # Step1: 核心种子 @@ -382,7 +385,6 @@ def Novel_architecture_generate( ) plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) - # 合并 final_content = ( "#=== 1) 核心种子 ===\n" f"{core_seed_result}\n\n" @@ -399,14 +401,17 @@ def Novel_architecture_generate( save_string_to_txt(final_content, arch_file) logging.info("Novel_architecture.txt has been generated successfully.") + # ============ 2) 生成章节蓝图 ============ def Chapter_blueprint_generate( + interface_format: str, api_key: str, base_url: str, llm_model: str, filepath: str, - temperature: float = 0.7 + temperature: float = 0.7, + max_tokens: int = 2048 ) -> None: arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): @@ -432,11 +437,12 @@ def Chapter_blueprint_generate( plot_arch_text = m.group(1).strip() llm_adapter = create_llm_adapter( - interface_format="openai", # 或实际由UI传入 + interface_format=interface_format, base_url=base_url, model_name=llm_model, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = chapter_blueprint_prompt.format( @@ -454,6 +460,7 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") + # ============ 3) 生成章节草稿 ============ def generate_chapter_draft( @@ -473,7 +480,9 @@ def generate_chapter_draft( embedding_url: str, embedding_interface_format: str, embedding_model_name: str, - embedding_retrieval_k: int = 2 + embedding_retrieval_k: int = 2, + interface_format: str = "openai", + max_tokens: int = 2048 ) -> str: arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -487,7 +496,6 @@ def generate_chapter_draft( 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"] @@ -500,18 +508,17 @@ def generate_chapter_draft( chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - # 获取最近3章 => (短期摘要, 下一章关键字) recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) short_summary, next_chapter_keywords = summarize_recent_chapters( - interface_format="openai", # 或由UI传进 + interface_format=interface_format, api_key=api_key, base_url=base_url, model_name=model_name, temperature=temperature, + max_tokens=max_tokens, chapters_text_list=recent_3_texts ) - # 上一章片段(末尾1500字) previous_chapter_excerpt = "" for text_block in reversed(recent_3_texts): if text_block.strip(): @@ -521,7 +528,6 @@ def generate_chapter_draft( previous_chapter_excerpt = text_block break - # 使用embedding检索上下文 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -538,7 +544,6 @@ def generate_chapter_draft( if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 组装 Prompt prompt_text = chapter_draft_prompt.format( novel_number=novel_number, chapter_title=chapter_title, @@ -562,19 +567,18 @@ def generate_chapter_draft( context_excerpt=relevant_context ) - # 调用 LLM 生成 llm_adapter = create_llm_adapter( - interface_format="openai", # 或由UI传进 + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 写入 chapter_X.txt chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -595,7 +599,9 @@ def finalize_chapter( embedding_api_key: str, embedding_url: str, embedding_interface_format: str, - embedding_model_name: str + embedding_model_name: str, + interface_format: str, + max_tokens: int ): chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") @@ -604,25 +610,23 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 如果篇幅过短,可以扩写 if len(chapter_text) < 0.6 * word_number: - chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) + chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - # 读取全局摘要、角色状态 global_summary_file = os.path.join(filepath, "global_summary.txt") old_global_summary = read_file(global_summary_file) character_state_file = os.path.join(filepath, "character_state.txt") old_character_state = read_file(character_state_file) - # 调用 LLM 更新全局摘要 llm_adapter = create_llm_adapter( - interface_format="openai", + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt_summary = summary_prompt.format( chapter_text=chapter_text, @@ -632,7 +636,6 @@ def finalize_chapter( if not new_global_summary.strip(): new_global_summary = old_global_summary - # 更新角色状态 prompt_char_state = update_character_state_prompt.format( chapter_text=chapter_text, old_state=old_character_state @@ -641,14 +644,12 @@ def finalize_chapter( if not new_char_state.strip(): new_char_state = old_character_state - # 写回 clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 更新向量库 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -665,14 +666,17 @@ def enrich_chapter_text( api_key: str, base_url: str, model_name: str, - temperature: float + temperature: float, + interface_format: str, + max_tokens: int ) -> str: llm_adapter = create_llm_adapter( - interface_format="openai", + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: 原内容: @@ -681,6 +685,7 @@ def enrich_chapter_text( enriched_text = invoke_with_cleaning(llm_adapter, prompt) return enriched_text if enriched_text else chapter_text + # ============ 导入知识文件到向量库 ============ def advanced_split_content(content: str, diff --git a/tooltips.py b/tooltips.py index f4e8220..062de2f 100644 --- a/tooltips.py +++ b/tooltips.py @@ -4,10 +4,20 @@ tooltips = { "api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。", "base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。", - "interface_format": "指定LLM接口兼容格式,可选OpenAI、Ollama、ML Studio等。", - "model_name": "要使用的模型名称,例如gpt-3.5-turbo、llama2等。如果是Ollama,请填写你下载好的本地模型名。", + "interface_format": "指定LLM接口兼容格式,可选DeepSeek\OpenAI\Ollama\ML Studio等。\n\n注意:"+ + "OpenAI 兼容是指的可以通过该标准请求的任何接口,不是只允许使用api.openai.com接口\n"+ + "例如Ollama接口格式也兼容OpenAI,可以无需修改直接使用\n"+ + "ML Studio接口格式与OpenAI接口格式也一致。", + "model_name": "要使用的模型名称,例如deepseek-reasoner、gpt-4o等。如果是Ollama等,请填写你下载好的本地模型名。", "temperature": "生成文本的随机度。数值越大越具有发散性,越小越严谨。", - "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。", + "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。\n"+ + "以下是一些常见模型的最大值:\n"+ + "o1:100,000\n"+ + "o1-mini:65,536\n"+ + "gpt-4o:16384\n"+ + "gpt-4o-mini:16384\n"+ + "deepseek-reasoner:8192\n"+ + "deepseek-chat:4096\n", "embedding_api_key": "调用Embedding模型时所需的API Key。", "embedding_interface_format": "Embedding模型接口风格,比如OpenAI或Ollama。", "embedding_url": "Embedding模型接口地址。", diff --git a/ui.py b/ui.py index 3c2a267..f5b81cc 100644 --- a/ui.py +++ b/ui.py @@ -20,9 +20,11 @@ from novel_generator import ( clear_vector_store, get_last_n_chapters_text ) - from consistency_checker import check_consistency +# ---- Import the tooltip texts ---- +from tooltips import tooltips + def log_error(message: str): logging.error(f"{message}\n{traceback.format_exc()}") @@ -52,6 +54,7 @@ class NovelGeneratorGUI: self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) + self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) # Embedding相关 self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) @@ -92,6 +95,11 @@ class NovelGeneratorGUI: self.build_summary_tab() self.build_chapters_tab() + def show_tooltip(self, key: str): + """Display a popup with tooltip text.""" + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) + def safe_get_int(self, var, default=1): try: val_str = str(var.get()).strip() @@ -203,6 +211,27 @@ class NovelGeneratorGUI: self.build_ai_config_tab() self.build_embeddings_config_tab() + # 封装一个小工具函数,用来创建「标签 + 问号按钮」的组合 + def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): + # frame容器:同一格子里存放 label + "?"按钮 + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + # 先放 label + 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 + def build_ai_config_tab(self): def on_interface_format_changed(new_value): if new_value == "Ollama": @@ -211,26 +240,49 @@ class NovelGeneratorGUI: self.base_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.base_url_var.set("https://api.openai.com/v1") + elif new_value == "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") - for i in range(5): + for i in range(6): 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) - api_key_label = ctk.CTkLabel(self.ai_config_tab, text="LLM API Key:", font=("Microsoft YaHei", 12)) - api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + # 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, sticky="nsew") + api_key_entry.grid(row=0, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - base_url_label = ctk.CTkLabel(self.ai_config_tab, text="LLM Base URL:", font=("Microsoft YaHei", 12)) - base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + # 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, sticky="nsew") + base_url_entry.grid(row=1, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - interface_label = ctk.CTkLabel(self.ai_config_tab, text="LLM 接口格式:", font=("Microsoft YaHei", 12)) - interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI", "Ollama", "ML Studio"] + # 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", "Ollama", "ML Studio"] interface_dropdown = ctk.CTkOptionMenu( self.ai_config_tab, values=interface_options, @@ -238,15 +290,29 @@ class NovelGeneratorGUI: command=on_interface_format_changed, font=("Microsoft YaHei", 12) ) - interface_dropdown.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") + interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:", font=("Microsoft YaHei", 12)) - model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + # 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, sticky="nsew") + model_name_entry.grid(row=3, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:", font=("Microsoft YaHei", 12)) - temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + # 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}") @@ -265,7 +331,37 @@ class NovelGeneratorGUI: text=f"{self.temperature_var.get():.2f}", font=("Microsoft YaHei", 12) ) - self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") + 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") def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): @@ -275,20 +371,37 @@ class NovelGeneratorGUI: self.embedding_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.embedding_url_var.set("https://api.openai.com/v1") - + elif new_value == "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + 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) - emb_api_key_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding API Key:", font=("Microsoft YaHei", 12)) - emb_api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + # 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") - emb_interface_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding 接口格式:", font=("Microsoft YaHei", 12)) - emb_interface_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - emb_interface_options = ["OpenAI", "Ollama", "ML Studio"] + # 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", "Ollama", "ML Studio"] emb_interface_dropdown = ctk.CTkOptionMenu( self.embeddings_config_tab, values=emb_interface_options, @@ -298,18 +411,39 @@ class NovelGeneratorGUI: ) emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") - emb_url_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Base URL:", font=("Microsoft YaHei", 12)) - emb_url_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + # 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") - emb_model_name_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Model Name:", font=("Microsoft YaHei", 12)) - emb_model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + # 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") - emb_retrieval_k_label = ctk.CTkLabel(self.embeddings_config_tab, text="Retrieval Top-K:", font=("Microsoft YaHei", 12)) - emb_retrieval_k_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + # 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") @@ -330,41 +464,72 @@ class NovelGeneratorGUI: self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) self.params_frame.columnconfigure(1, weight=1) - topic_label = ctk.CTkLabel(self.params_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) - topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) + # 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)) 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) - genre_label = ctk.CTkLabel(self.params_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) - genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + # 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 - num_chapters_label = ctk.CTkLabel(self.params_frame, text="章节数:", font=("Microsoft YaHei", 12)) - num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + 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) - ch_word_frame = ctk.CTkFrame(self.params_frame) - ch_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") - ch_word_frame.columnconfigure((0, 1, 2, 3), weight=0) + # 左边标签 + label_frame = 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) + ) - num_chapters_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12)) - num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") + # 输入框 + 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(ch_word_frame, text="每章字数:", font=("Microsoft YaHei", 12)) - word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") - - word_number_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12)) - word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") - - # 保存路径 - filepath_label = ctk.CTkLabel(self.params_frame, text="保存路径:", font=("Microsoft YaHei", 12)) - filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + 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=3, column=1, padx=5, pady=5, sticky="nsew") + 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)) @@ -372,43 +537,85 @@ class NovelGeneratorGUI: 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") - # 章节号 - chapter_num_label = ctk.CTkLabel(self.params_frame, text="章节号:", font=("Microsoft YaHei", 12)) - chapter_num_label.grid(row=4, column=0, 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=4, column=1, padx=5, pady=5, sticky="w") + chapter_num_entry.grid(row=row_chap_num, column=1, padx=5, pady=5, sticky="w") - # 用户指导 - guide_label = ctk.CTkLabel(self.params_frame, text="本章指导:", font=("Microsoft YaHei", 12)) - guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) - self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") + # 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)) + self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") - # 新增:四个可选元素 - row_index = 6 - - char_inv_label = ctk.CTkLabel(self.params_frame, text="核心人物:", font=("Microsoft YaHei", 12)) - char_inv_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 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_index, column=1, padx=5, pady=5, sticky="ew") + char_inv_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - key_items_label = ctk.CTkLabel(self.params_frame, text="关键道具:", font=("Microsoft YaHei", 12)) - key_items_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 关键道具 + 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_index, column=1, padx=5, pady=5, sticky="ew") + key_items_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - scene_loc_label = ctk.CTkLabel(self.params_frame, text="空间坐标:", font=("Microsoft YaHei", 12)) - scene_loc_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 空间坐标 + 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_index, column=1, padx=5, pady=5, sticky="ew") + scene_loc_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - time_const_label = ctk.CTkLabel(self.params_frame, text="时间压力:", font=("Microsoft YaHei", 12)) - time_const_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 时间压力 + 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_index, column=1, padx=5, pady=5, sticky="ew") + 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) @@ -456,11 +663,14 @@ class NovelGeneratorGUI: self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) self.model_name_var.set(cfg.get("model_name", "")) self.temperature_var.set(cfg.get("temperature", 0.7)) + self.max_tokens_var.set(cfg.get("max_tokens", 2048)) + self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) self.embedding_url_var.set(cfg.get("embedding_url", "")) self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) + self.genre_var.set(cfg.get("genre", "")) self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) self.word_number_var.set(str(cfg.get("word_number", 3000))) @@ -481,6 +691,7 @@ class NovelGeneratorGUI: "interface_format": self.interface_format_var.get(), "model_name": self.model_name_var.get(), "temperature": self.temperature_var.get(), + "max_tokens": self.max_tokens_var.get(), "embedding_api_key": self.embedding_api_key_var.get(), "embedding_interface_format": self.embedding_interface_format_var.get(), @@ -535,10 +746,12 @@ class NovelGeneratorGUI: 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() topic = self.topic_text.get("0.0", "end").strip() genre = self.genre_var.get().strip() @@ -547,6 +760,7 @@ class NovelGeneratorGUI: self.safe_log("开始生成小说架构...") Novel_architecture_generate( + interface_format=interface_format, api_key=api_key, base_url=base_url, llm_model=model_name, @@ -555,7 +769,8 @@ class NovelGeneratorGUI: number_of_chapters=num_chapters, word_number=word_number, filepath=filepath, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") except Exception: @@ -575,18 +790,22 @@ class NovelGeneratorGUI: 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() temperature = self.temperature_var.get() + max_tokens = self.max_tokens_var.get() self.safe_log("开始生成章节蓝图...") Chapter_blueprint_generate( + interface_format=interface_format, api_key=api_key, base_url=base_url, llm_model=model_name, filepath=filepath, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") except Exception: @@ -606,24 +825,22 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_chapter) try: - # 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() - # 章节信息 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相关 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() @@ -648,7 +865,9 @@ class NovelGeneratorGUI: embedding_url=embedding_url, embedding_interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, - embedding_retrieval_k=embedding_k + embedding_retrieval_k=embedding_k, + interface_format=interface_format, + max_tokens=max_tokens ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -678,24 +897,22 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_finalize_chapter) try: - # 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() - # Embedding相关 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") @@ -714,7 +931,9 @@ class NovelGeneratorGUI: embedding_api_key=embedding_api_key, embedding_url=embedding_url, embedding_interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name + embedding_model_name=embedding_model_name, + interface_format=interface_format, + max_tokens=max_tokens ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") @@ -846,7 +1065,6 @@ class NovelGeneratorGUI: text_area.configure(state="disabled") # ============ 其余标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ============ - def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -1164,6 +1382,7 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") + if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app) From 12f7f3062939db5b39c0d9635b74a5dc911338fd Mon Sep 17 00:00:00 2001 From: pandarong Date: Thu, 6 Feb 2025 20:49:21 +0800 Subject: [PATCH 061/142] Update requirements.txt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加缺少的依赖langchain_chroma --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f75acc9..c2fec01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ chromadb langchain-community sentence_transformers scikit-learn -nltk \ No newline at end of file +nltk +langchain_chroma From 5c4dfd1e4308f45feecf7561313c0da755b052f0 Mon Sep 17 00:00:00 2001 From: pandarong Date: Thu, 6 Feb 2025 20:59:49 +0800 Subject: [PATCH 062/142] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加安装说明 --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index fde216c..d9a6890 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,32 @@ --- + +## 📥 安装说明 +1. **下载项目** + - 通过 [GitHub](https://github.com) 下载项目 ZIP 文件,或使用以下命令克隆本项目: + ```bash + git clone https://github.com/your-repo/project-name.git + ``` + +2. **安装编译工具** + - 访问 [Visual Studio Build Tools](https://visualstudio.microsoft.com/zh-hans/visual-cpp-build-tools/) 下载并安装编译工具。 + - 安装时,默认只包含 MSBuild 工具,需手动勾选左上角列表栏中的 **C++ 桌面开发** 选项。 + +3. **安装依赖并运行** + - 打开终端,进入项目源文件目录: + ```bash + cd project-name + ``` + - 安装项目依赖: + ```bash + pip install -r requirements.txt + ``` + - 安装完成后,运行主程序: + ```bash + python main.py + ``` + ## 🗂 项目架构 ``` novel-generator/ From 20c23837f08cd224abe829a8660349b9e0909c4c Mon Sep 17 00:00:00 2001 From: pandarong Date: Thu, 6 Feb 2025 21:01:00 +0800 Subject: [PATCH 063/142] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加安装说明 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d9a6890..464f4ad 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ 1. **下载项目** - 通过 [GitHub](https://github.com) 下载项目 ZIP 文件,或使用以下命令克隆本项目: ```bash - git clone https://github.com/your-repo/project-name.git + git clone https://github.com/your-repo/project-name.git](https://github.com/YILING0013/AI_NovelGenerator ``` 2. **安装编译工具** From 62a1c01d06b5c2f7d61d84ca2c942884b8c4cccb Mon Sep 17 00:00:00 2001 From: pandarong Date: Thu, 6 Feb 2025 21:01:59 +0800 Subject: [PATCH 064/142] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改说明,增加安装步骤 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 464f4ad..5246a57 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ 3. **安装依赖并运行** - 打开终端,进入项目源文件目录: ```bash - cd project-name + cd AI_NovelGenerator ``` - 安装项目依赖: ```bash From fd50a8130b25624277b8744fb307034b6bd1ad08 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 21:54:06 +0800 Subject: [PATCH 065/142] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=88=86=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator copy.py | 830 ---------------------------------------- novel_generator.py | 114 ++++-- prompt_definitions.py | 170 ++++++-- ui.py | 2 + 4 files changed, 227 insertions(+), 889 deletions(-) delete mode 100644 novel_generator copy.py diff --git a/novel_generator copy.py b/novel_generator copy.py deleted file mode 100644 index 7505948..0000000 --- a/novel_generator copy.py +++ /dev/null @@ -1,830 +0,0 @@ -# novel_generator.py -# -*- coding: utf-8 -*- -import os -import logging -import re -import time -import traceback -from typing import List, Optional - -# langchain 相关 -from langchain_openai import ChatOpenAI, OpenAIEmbeddings -from langchain_chroma import Chroma -from chromadb.config import Settings -from langchain.docstore.document import Document - -# nltk、sentence_transformers 及文本处理相关 -import nltk -import math -from sentence_transformers import SentenceTransformer -from sklearn.metrics.pairwise import cosine_similarity - -# 工具函数 -from utils import ( - read_file, append_text_to_file, clear_file_content, - save_string_to_txt -) - -# prompt模板 -from prompt_definitions import ( - # 设定相关 - set_prompt, character_prompt, dark_lines_prompt, - finalize_setting_prompt, novel_directory_prompt, - - # 写作流程相关 - summary_prompt, update_character_state_prompt, - chapter_outline_prompt, chapter_write_prompt -) - -# Ollama嵌入 (如使用Ollama时需要) -from embedding_ollama import OllamaEmbeddings - -# 用于目录解析章节标题/简介 -from chapter_directory_parser import get_chapter_info_from_directory - - -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - - -# ============ 帮助函数 ============ -def remove_think_tags(text: str) -> str: - """移除 ... 包裹的内容""" - return re.sub(r'.*?', '', text, flags=re.DOTALL) - -def debug_log(prompt: str, response_content: str): - logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n") - logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n") - -def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: - """通用封装:调用模型并移除 ... 文本,记录日志后返回""" - response = model.invoke(prompt) - if not response: - logging.warning("No response from model.") - return "" - cleaned_text = remove_think_tags(response.content) - debug_log(prompt, cleaned_text) - return cleaned_text.strip() - -def ensure_openai_base_url_has_v1(url: str) -> str: - """ - 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 - """ - import re - url = url.strip() - if not url: - return url - if not re.search(r'/v\d+$', url): - if '/v1' not in url: - url = url.rstrip('/') + '/v1' - return url - -def is_using_ollama_api(interface_format: str) -> bool: - return interface_format.lower() == "ollama" - -def is_using_ml_studio_api(interface_format: str) -> bool: - return interface_format.lower() == "ml studio" - - -# ============ 获取 vectorstore 路径 ============ -def get_vectorstore_dir(filepath: str) -> str: - """ - 返回存储向量库的本地路径: - 在用户指定的 `filepath` 下创建/使用 'vectorstore' 文件夹。 - """ - return os.path.join(filepath, "vectorstore") - - -# ============ 创建 Embeddings 对象 ============ -def create_embeddings_object( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str -): - """ - 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 - """ - if is_using_ollama_api(interface_format): - fixed_url = base_url.rstrip("/") - return OllamaEmbeddings( - model_name=embedding_model_name, - base_url=fixed_url - ) - else: - # OpenAI 或 ML Studio 均使用 OpenAIEmbeddings,注意 base_url 可能需要 ensure /v1 - fixed_url = ensure_openai_base_url_has_v1(base_url) - return OpenAIEmbeddings( - openai_api_key=api_key, - openai_api_base=fixed_url, - model=embedding_model_name - ) - - -# ============ 向量库相关操作 ============ -def clear_vector_store(filepath: str) -> bool: - """ - 返回值表示是否成功清空向量库。 - """ - import shutil - - store_dir = get_vectorstore_dir(filepath) - if not os.path.exists(store_dir): - logging.info("No vector store found to clear.") - return False - - try: - if os.path.exists(store_dir): - shutil.rmtree(store_dir) - logging.info(f"Vector store directory '{store_dir}' removed.") - return True - except Exception as e: - logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") - traceback.print_exc() - return False - -def init_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - texts: List[str], - filepath: str -) -> Chroma: - """ - 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 - """ - store_dir = get_vectorstore_dir(filepath) - os.makedirs(store_dir, exist_ok=True) - - embeddings = create_embeddings_object( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) - documents = [Document(page_content=str(t)) for t in texts] - vectorstore = Chroma.from_documents( - documents, - embedding=embeddings, - persist_directory=store_dir, - client_settings=Settings(anonymized_telemetry=False), - collection_name="novel_collection" - ) - return vectorstore - - -def load_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - filepath: str -) -> Optional[Chroma]: - """ - 读取已存在的 Chroma 向量库。若不存在则返回 None。 - """ - store_dir = get_vectorstore_dir(filepath) - if not os.path.exists(store_dir): - logging.info("Vector store not found. Will return None.") - return None - - embeddings = create_embeddings_object( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) - return Chroma( - persist_directory=store_dir, - embedding_function=embeddings, - client_settings=Settings(anonymized_telemetry=False), - collection_name="novel_collection" - ) - - -def split_by_length(text: str, max_length: int = 500) -> List[str]: - segments = [] - start_idx = 0 - while start_idx < len(text): - end_idx = min(start_idx + max_length, len(text)) - segment = text[start_idx:end_idx] - segments.append(segment.strip()) - start_idx = end_idx - return segments - - -def split_text_for_vectorstore(chapter_text: str, - max_length: int = 500, - similarity_threshold: float = 0.7) -> List[str]: - """ - 对新的章节文本进行分段后,再用于存入向量库。 - """ - if not chapter_text.strip(): - return [] - - nltk.download('punkt', quiet=True) - nltk.download('punkt_tab', quiet=True) - sentences = nltk.sent_tokenize(chapter_text) - if not sentences: - return [] - - # 先对相近句子进行合并 - model = SentenceTransformer('paraphrase-MiniLM-L6-v2') - embeddings = model.encode(sentences) - - merged_paragraphs = [] - current_sentences = [sentences[0]] - current_embedding = embeddings[0] - - for i in range(1, len(sentences)): - sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] - if sim >= similarity_threshold: - current_sentences.append(sentences[i]) - current_embedding = (current_embedding + embeddings[i]) / 2.0 - else: - merged_paragraphs.append(" ".join(current_sentences)) - current_sentences = [sentences[i]] - current_embedding = embeddings[i] - - if current_sentences: - merged_paragraphs.append(" ".join(current_sentences)) - - # 再对合并好的段落做 max_length 切分 - final_segments = [] - for para in merged_paragraphs: - if len(para) > max_length: - sub_segments = split_by_length(para, max_length=max_length) - final_segments.extend(sub_segments) - else: - final_segments.append(para) - - return final_segments - - -def update_vector_store( - api_key: str, - base_url: str, - new_chapter: str, - interface_format: str, - embedding_model_name: str, - filepath: str -): - """ - 将最新章节文本插入到向量库中。若库不存在则初始化。 - """ - splitted_texts = split_text_for_vectorstore(new_chapter) - if not splitted_texts: - logging.warning("No valid text to insert into vector store. Skipping.") - return - - store = load_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) - if not store: - logging.info("Vector store does not exist. Initializing a new one for new chapter...") - init_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=splitted_texts, - filepath=filepath - ) - return - - docs = [Document(page_content=str(t)) for t in splitted_texts] - store.add_documents(docs) - logging.info("Vector store updated with the new chapter splitted segments.") - - -def get_relevant_context_from_vector_store( - api_key: str, - base_url: str, - query: str, - interface_format: str, - embedding_model_name: str, - filepath: str, - k: int = 2 -) -> str: - """ - 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - """ - store = load_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) - if not store: - logging.info("No vector store found. Returning empty context.") - return "" - - docs = store.similarity_search(query, k=k) - if not docs: - logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") - return "" - - combined = "\n".join([d.page_content for d in docs]) - return combined - - -# ============ 1. 生成小说“设定” (Novel_setting.txt) ============ -def Novel_setting_generate( - api_key: str, - base_url: str, - llm_model: str, - topic: str, - genre: str, - number_of_chapters: int, - word_number: int, - filepath: str, - temperature: float = 0.7 -) -> None: - os.makedirs(filepath, exist_ok=True) - - model = ChatOpenAI( - model=llm_model, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - # Step1: 基础设定 - prompt_base = set_prompt.format( - topic=topic, - genre=genre, - number_of_chapters=number_of_chapters, - word_number=word_number - ) - base_setting = invoke_with_cleaning(model, prompt_base) - - # Step2: 角色设定 - prompt_char = character_prompt.format( - novel_setting=base_setting - ) - character_setting = invoke_with_cleaning(model, prompt_char) - - # Step3: 暗线/雷点 - prompt_dark = dark_lines_prompt.format( - character_info=character_setting - ) - dark_lines = invoke_with_cleaning(model, prompt_dark) - - # Step4: 最终整合 - prompt_final = finalize_setting_prompt.format( - novel_setting_base=base_setting, - character_setting=character_setting, - dark_lines=dark_lines - ) - final_novel_setting = invoke_with_cleaning(model, prompt_final) - - filename_set = os.path.join(filepath, "Novel_setting.txt") - clear_file_content(filename_set) - - final_novel_setting_cleaned = final_novel_setting.replace('#', '').replace('*', '') - save_string_to_txt(final_novel_setting_cleaned, filename_set) - logging.info("Novel_setting.txt has been generated successfully.") - - -# ============ 2. 生成小说目录 (Novel_directory.txt) ============ -def Novel_directory_generate( - api_key: str, - base_url: str, - llm_model: str, - number_of_chapters: int, - filepath: str, - temperature: float = 0.7 -) -> None: - filename_set = os.path.join(filepath, "Novel_setting.txt") - final_novel_setting = read_file(filename_set).strip() - if not final_novel_setting: - logging.warning("Novel_setting.txt 内容为空,请先生成小说设定。") - return - - model = ChatOpenAI( - model=llm_model, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - prompt_dir = novel_directory_prompt.format( - final_novel_setting=final_novel_setting, - number_of_chapters=number_of_chapters - ) - final_novel_directory = invoke_with_cleaning(model, prompt_dir) - if not final_novel_directory.strip(): - logging.warning("Novel_directory生成结果为空。") - return - - filename_dir = os.path.join(filepath, "Novel_directory.txt") - clear_file_content(filename_dir) - - final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') - save_string_to_txt(final_novel_directory_cleaned, filename_dir) - - logging.info("Novel_directory.txt has been generated successfully.") - - -# ============ 获取最近 N 章内容,生成短期摘要 ============ -def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: - texts = [] - start_chap = max(1, current_chapter_num - n) - for c in range(start_chap, current_chapter_num): - chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") - if os.path.exists(chap_file): - text = read_file(chap_file).strip() - if text: - texts.append(text) - if len(texts) < n: - texts = [''] * (n - len(texts)) + texts - return texts - -def summarize_recent_chapters( - llm_model: str, - api_key: str, - base_url: str, - temperature: float, - chapters_text_list: List[str] -) -> str: - if not chapters_text_list: - return "" - if all(not txt.strip() for txt in chapters_text_list): - return "暂无摘要。" - - model = ChatOpenAI( - model=llm_model, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - combined_text = "\n".join(chapters_text_list) - prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: -{combined_text} - -请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" - - summary_text = invoke_with_cleaning(model, prompt) - if not summary_text: - return (combined_text[:800] + "...") if len(combined_text) > 800 else combined_text - return summary_text - - -# ============ 剧情要点/冲突 ============ -PLOT_ARCS_PROMPT = """\ -下面是新生成的章节内容: -{chapter_text} - -这里是已记录的剧情要点/未解决冲突(可能为空): -{old_plot_arcs} - -请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 -若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 -""" - -def update_plot_arcs( - chapter_text: str, - old_plot_arcs: str, - api_key: str, - base_url: str, - model_name: str, - temperature: float -) -> str: - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - prompt = PLOT_ARCS_PROMPT.format( - chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs - ) - arcs_text = invoke_with_cleaning(model, prompt) - if not arcs_text: - logging.warning("update_plot_arcs: No response or empty result.") - return old_plot_arcs - return arcs_text - - -# ============ 生成章节草稿 ============ -def generate_chapter_draft( - novel_settings: str, - global_summary: str, - character_state: str, - recent_chapters_summary: str, - user_guidance: str, - api_key: str, - base_url: str, - model_name: str, - novel_number: int, - word_number: int, - temperature: float, - novel_novel_directory: str, - filepath: str, - interface_format: str, - embedding_model_name: str, - embedding_base_url: str, - embedding_retrieval_k: int = 4 -) -> str: - # 1) 根据目录解析标题、简介 - chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) - chapter_title = chapter_info["chapter_title"] - chapter_brief = chapter_info["chapter_brief"] - - # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) - combined_query_parts = [] - if user_guidance.strip(): - combined_query_parts.append(user_guidance) - if chapter_brief.strip(): - combined_query_parts.append(chapter_brief) - if recent_chapters_summary.strip(): - combined_query_parts.append(recent_chapters_summary) - # 额外加一个关键字 - combined_query_parts.append("回顾剧情") - - merged_query_str = "\n".join(combined_query_parts) - - # 2) 从向量库检索上下文 - relevant_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=embedding_base_url if embedding_base_url else base_url, - query=merged_query_str, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath, - k=embedding_retrieval_k - ) - if not relevant_context.strip(): - relevant_context = "暂无相关内容。" - - # 3) 生成本章大纲 - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - outline_prompt_text = chapter_outline_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_outline = invoke_with_cleaning(model, outline_prompt_text) - - outlines_dir = os.path.join(filepath, "outlines") - os.makedirs(outlines_dir, exist_ok=True) - outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") - clear_file_content(outline_file) - save_string_to_txt(chapter_outline, outline_file) - - # 4) 生成正文草稿 - writing_prompt_text = chapter_write_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - chapter_outline=chapter_outline, - word_number=word_number, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_content = invoke_with_cleaning(model, writing_prompt_text) - - chapters_dir = os.path.join(filepath, "chapters") - os.makedirs(chapters_dir, exist_ok=True) - chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - clear_file_content(chapter_file) - save_string_to_txt(chapter_content, chapter_file) - - logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") - return chapter_content - - -# ============ 定稿章节 ============ -def finalize_chapter( - novel_number: int, - word_number: int, - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - model_name: str, - temperature: float, - filepath: str, - embedding_base_url: str, - embedding_api_key: str -): - chapters_dir = os.path.join(filepath, "chapters") - chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - chapter_text = read_file(chapter_file).strip() - if not chapter_text: - logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") - return - - character_state_file = os.path.join(filepath, "character_state.txt") - global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - - old_char_state = read_file(character_state_file) - old_global_summary = read_file(global_summary_file) - old_plot_arcs = read_file(plot_arcs_file) - - # 篇幅不足,二次扩写 - if len(chapter_text) < 0.8 * word_number: - logging.info("Chapter text is shorter than 80% of desired length. Enriching...") - chapter_text = enrich_chapter_text( - chapter_text=chapter_text, - word_number=word_number, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature - ) - clear_file_content(chapter_file) - save_string_to_txt(chapter_text, chapter_file) - - # 更新全局摘要 - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - def update_global_summary(chapter_text: str, old_summary: str) -> str: - prompt = summary_prompt.format( - chapter_text=chapter_text, - global_summary=old_summary - ) - return invoke_with_cleaning(model, prompt) or old_summary - - new_global_summary = update_global_summary(chapter_text, old_global_summary) - - # 更新角色状态 - def update_character_state(chapter_text: str, old_state: str) -> str: - prompt = update_character_state_prompt.format( - chapter_text=chapter_text, - old_state=old_state - ) - return invoke_with_cleaning(model, prompt) or old_state - - new_char_state = update_character_state(chapter_text, old_char_state) - - # 更新剧情要点 - new_plot_arcs = update_plot_arcs( - chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature - ) - - # 写回文件 - clear_file_content(character_state_file) - save_string_to_txt(new_char_state, character_state_file) - - clear_file_content(global_summary_file) - save_string_to_txt(new_global_summary, global_summary_file) - - clear_file_content(plot_arcs_file) - save_string_to_txt(new_plot_arcs, plot_arcs_file) - - # 更新向量库(此时用 embedding_api_key/embedding_base_url) - update_vector_store( - api_key=embedding_api_key, - base_url=embedding_base_url if embedding_base_url else base_url, - new_chapter=chapter_text, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) - - logging.info(f"Chapter {novel_number} has been finalized.") - - -def enrich_chapter_text( - chapter_text: str, - word_number: int, - api_key: str, - base_url: str, - model_name: str, - temperature: float -) -> str: - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 - -原章节内容: -{chapter_text}""" - enriched_text = invoke_with_cleaning(model, prompt) - return enriched_text if enriched_text else chapter_text - - -# ============ 导入外部知识文本到向量库 ============ -def advanced_split_content(content: str, - similarity_threshold: float = 0.7, - max_length: int = 500) -> List[str]: - """ - 将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。 - """ - nltk.download('punkt', quiet=True) - sentences = nltk.sent_tokenize(content) - if not sentences: - return [] - - model = SentenceTransformer('paraphrase-MiniLM-L6-v2') - embeddings = model.encode(sentences) - - merged_paragraphs = [] - current_sentences = [sentences[0]] - current_embedding = embeddings[0] - - for i in range(1, len(sentences)): - sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] - if sim >= similarity_threshold: - current_sentences.append(sentences[i]) - current_embedding = (current_embedding + embeddings[i]) / 2.0 - else: - merged_paragraphs.append(" ".join(current_sentences)) - current_sentences = [sentences[i]] - current_embedding = embeddings[i] - - if current_sentences: - merged_paragraphs.append(" ".join(current_sentences)) - - final_segments = [] - for para in merged_paragraphs: - if len(para) > max_length: - sub_segments = split_by_length(para, max_length=max_length) - final_segments.extend(sub_segments) - else: - final_segments.append(para) - - return final_segments - -def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - file_path: str, - embedding_base_url: str, - filepath: str -): - logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") - if not os.path.exists(file_path): - logging.warning(f"知识库文件不存在: {file_path}") - return - - content = read_file(file_path) - if not content.strip(): - logging.warning("知识库文件内容为空。") - return - - paragraphs = advanced_split_content(content) - - # 若向量库不存在则初始化,否则追加 - store = load_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) - if not store: - logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=paragraphs, - filepath=filepath - ) - else: - docs = [Document(page_content=str(p)) for p in paragraphs] - store.add_documents(docs) - logging.info("知识库文件已成功导入至向量库。") diff --git a/novel_generator.py b/novel_generator.py index 5e2a6ed..c02a2a2 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -29,6 +29,7 @@ from prompt_definitions import ( world_building_prompt, plot_architecture_prompt, chapter_blueprint_prompt, + chunked_chapter_blueprint_prompt, summary_prompt, update_character_state_prompt, chapter_draft_prompt, @@ -386,6 +387,8 @@ def Novel_architecture_generate( plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) final_content = ( + "#=== 0) 小说设定 ===\n" + f"主题:{topic},类型:{genre},篇幅:约{number_of_chapters}章(每章{word_number}字)\n\n" "#=== 1) 核心种子 ===\n" f"{core_seed_result}\n\n" "#=== 2) 角色动力学 ===\n" @@ -402,7 +405,29 @@ def Novel_architecture_generate( logging.info("Novel_architecture.txt has been generated successfully.") -# ============ 2) 生成章节蓝图 ============ +# ============ 计算分块大小的工具函数 ============ + +def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: + """ + 基于“每章约100 tokens”的粗略估算, + 再结合当前max_tokens,计算分块大小: + chunk_size = (floor(max_tokens/100/10)*10) - 10 + 并确保 chunk_size 不会小于1或大于实际章节数。 + """ + tokens_per_chapter = 100.0 + ratio = max_tokens / tokens_per_chapter # 8192 / 100 = 81.92 + # 先取到最接近的10倍 + ratio_rounded_to_10 = int(ratio // 10) * 10 # => 80 + # 再减10 + chunk_size = ratio_rounded_to_10 - 10 # => 70 + if chunk_size < 1: + chunk_size = 1 + if chunk_size > number_of_chapters: + chunk_size = number_of_chapters + return chunk_size + + +# ============ 2) 生成章节蓝图(新增分块逻辑) ============ def Chapter_blueprint_generate( interface_format: str, @@ -410,9 +435,18 @@ def Chapter_blueprint_generate( base_url: str, llm_model: str, filepath: str, + number_of_chapters: int, temperature: float = 0.7, max_tokens: int = 2048 ) -> None: + """ + 如果章节数小于等于 chunk_size,则直接使用 chapter_blueprint_prompt 一次性生成。 + 如果章节数较多,则进行分块生成: + 1) 首先说明要生成的总章节数 + 2) 先生成 [1..chunk_size] 的章节 + 3) 将生成的文本作为已有目录传入,继续生成 [chunk_size+1..] 的章节 + 4) 最后汇总全部章节目录写入 Novel_directory.txt + """ arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): logging.warning("Novel_architecture.txt not found. Please generate architecture first.") @@ -423,19 +457,6 @@ def Chapter_blueprint_generate( logging.warning("Novel_architecture.txt is empty.") return - match_chaps = re.search(r'约(\d+)章', architecture_text) - if match_chaps: - number_of_chapters = int(match_chaps.group(1)) - else: - number_of_chapters = 10 - - # 提取三幕式文本 - plot_arch_text = "" - pat_plot = r'#=== 4\) 三幕式情节架构 ===\n([\s\S]+)$' - m = re.search(pat_plot, architecture_text) - if m: - plot_arch_text = m.group(1).strip() - llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, @@ -445,20 +466,65 @@ def Chapter_blueprint_generate( max_tokens=max_tokens ) - prompt = chapter_blueprint_prompt.format( - plot_architecture=plot_arch_text, - number_of_chapters=number_of_chapters - ) - blueprint_text = invoke_with_cleaning(llm_adapter, prompt) - if not blueprint_text.strip(): - logging.warning("Chapter blueprint generation result is empty.") + # 计算分块大小 + chunk_size = compute_chunk_size(number_of_chapters, max_tokens) + logging.info(f"Number of chapters = {number_of_chapters}, computed chunk_size = {chunk_size}.") + + # 如果一次就可以生成全部 + if chunk_size >= number_of_chapters: + prompt = chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + number_of_chapters=number_of_chapters + ) + blueprint_text = invoke_with_cleaning(llm_adapter, prompt) + if not blueprint_text.strip(): + logging.warning("Chapter blueprint generation result is empty.") + return + + filename_dir = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename_dir) + save_string_to_txt(blueprint_text, filename_dir) + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (single-shot).") + return + + # 否则,分块生成 + final_blueprint = "" + current_start = 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + + # 分块提示 + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=final_blueprint, # 已有的章节列表文本 + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end + ) + logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") + + chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) + if not chunk_result.strip(): + logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") + chunk_result = "" + + # 将本次生成的文本拼接到最终结果中 + if final_blueprint.strip(): + final_blueprint += "\n\n" + chunk_result + else: + final_blueprint = chunk_result + + current_start = current_end + 1 + + if not final_blueprint.strip(): + logging.warning("All chunked generation results are empty, cannot create blueprint.") return filename_dir = os.path.join(filepath, "Novel_directory.txt") clear_file_content(filename_dir) - save_string_to_txt(blueprint_text, filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) - logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") # ============ 3) 生成章节草稿 ============ @@ -610,7 +676,7 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - if len(chapter_text) < 0.6 * word_number: + if len(chapter_text) < 0.7 * word_number: chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) diff --git a/prompt_definitions.py b/prompt_definitions.py index 72dc37c..c7b5661 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -121,39 +121,91 @@ plot_architecture_prompt = """\ # =============== 5. 章节目录生成(悬念节奏曲线)=================== chapter_blueprint_prompt = """\ -根据三幕式架构: -{plot_architecture} +根据小说架构:\n +{novel_architecture} 设计{number_of_chapters}章的节奏分布: -1. 每章需明确: +1. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +2. 每章需明确: +- 章节定位(角色/事件/主题等) - 核心悬念类型(信息差/道德困境/时间压力等) - 情感基调迁移(如从怀疑→恐惧→决绝) - 伏笔操作(埋设/强化/回收) - 认知颠覆强度(1-5级) -2. 章节集群划分: -- 每3-5章构成一个悬念单元,包含完整的小高潮 -- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) -- 关键转折章需预留多视角铺垫 - 输出格式示例: 第n章 - [标题] -本章定位:[角色/事件/主题] -核心作用:[推进/转折/揭示] -悬念密度:[紧凑/渐进/爆发] -伏笔操作:埋设(A线索)→强化(B矛盾) +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... 认知颠覆:★☆☆☆☆ 本章简述:[一句话概括] 第n+1章 - [标题] -本章定位:[角色/事件/主题] -核心作用:[推进/转折/揭示] -悬念密度:[紧凑/渐进/爆发] -伏笔操作:埋设(A线索)→强化(B矛盾) +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... 认知颠覆:★☆☆☆☆ 本章简述:[一句话概括] -使用精炼语言描述,每章字数控制在100字以内。 +要求: +- 使用精炼语言描述,每章字数控制在100字以内。 +- 合理安排节奏,确保整体悬念曲线的连贯性。 +- 在生成{number_of_chapters}章前不要出现结局章节。 + +仅给出最终文本,不要解释任何内容。 +""" + +chunked_chapter_blueprint_prompt = """\ +根据小说架构:\n +{novel_architecture} + +需要生成总共{number_of_chapters}章的节奏分布, + +当前已有章节目录(若未空则说明是初始生成):\n +{chapter_list} + +现在请设计第{n}章到第{m}的节奏分布: +1. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +2. 每章需明确: +- 章节定位(角色/事件/主题等) +- 核心悬念类型(信息差/道德困境/时间压力等) +- 情感基调迁移(如从怀疑→恐惧→决绝) +- 伏笔操作(埋设/强化/回收) +- 认知颠覆强度(1-5级) + +输出格式示例: +第n章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +第n+1章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +要求: +- 使用精炼语言描述,每章字数控制在100字以内。 +- 合理安排节奏,确保整体悬念曲线的连贯性。 +- 在生成{number_of_chapters}章前不要出现结局章节。 + 仅给出最终文本,不要解释任何内容。 """ @@ -183,11 +235,11 @@ update_character_state_prompt = """\ 这是当前的角色状态文档(可为空): {old_state} -请更新角色状态,内容包括: +请更新角色状态,内容格式: 角色A属性: ├──物品: - ├──道具1:描述 - ├──道具2:描述 + ├──某物(道具):描述 + ├──XX长剑(武器):描述 ... ├──能力 ├──技能1:描述 @@ -232,8 +284,10 @@ update_character_state_prompt = """\ 仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 8. 章节正文写作(新版) =================== -chapter_draft_prompt = """\ +# =============== 8. 章节正文写作 =================== + +# 8.1 第一章草稿提示 +first_chapter_draft_prompt = """\ 即将创作:第 {novel_number} 章《{chapter_title}》 本章定位:{chapter_role} 核心作用:{chapter_purpose} @@ -252,18 +306,6 @@ chapter_draft_prompt = """\ - 小说设定: {novel_setting} -- 全局摘要: -{global_summary} - -- 角色状态: -{character_state} - -前章片段(可能为空): -{previous_chapter_excerpt} - -本地知识(向量)库检索到的片段(可能为空): -{context_excerpt} - 请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: 1. 对话场景: - 潜台词冲突(表面谈论A,实际博弈B) @@ -287,5 +329,63 @@ chapter_draft_prompt = """\ - 不使用分章节小标题; - 不要使用markdown格式。 -用户额外指导(可能未指定):{user_guidance} +额外指导(可能未指定):{user_guidance} """ + +# 8.2 后续章节草稿提示 +next_chapter_draft_prompt = """\ +参考文档: +- 小说设定: +{novel_setting} + +- 全局摘要: +{global_summary} + +- 角色状态: +{character_state} + +本地知识库检索到的片段: +{context_excerpt} + +即将创作:第 {novel_number} 章《{chapter_title}》 +本章定位:{chapter_role} +核心作用:{chapter_purpose} +悬念密度:{suspense_level} +伏笔操作:{foreshadowing} +认知颠覆:{plot_twist_level} +本章简述:{chapter_summary} + +可用元素: +- 核心人物(可能未指定):{characters_involved} +- 关键道具(可能未指定):{key_items} +- 空间坐标(可能未指定):{scene_location} +- 时间压力(可能未指定):{time_constraint} + +前章结尾段: +{previous_chapter_excerpt} + +请从前章结尾处继续完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +1. 对话场景: + - 潜台词冲突(表面谈论A,实际博弈B) + - 权力关系变化(通过非对称对话长度体现) + - 至少1处双关语暗示未来危机 + +2. 动作场景: + - 环境交互细节(至少3个感官描写) + - 节奏控制(短句加速+比喻减速) + - 动作揭示人物隐藏特质 + +3. 心理场景: + - 认知失调的具体表现(行为矛盾) + - 隐喻系统的运用(连接世界观符号) + - 决策前的价值天平描写 + +文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知/神转折等。 + +格式要求: +- 仅返回章节正文文本; +- 不使用分章节小标题; +- 不要使用markdown格式。 + +额外指导(可能未指定):{user_guidance} +""" \ No newline at end of file diff --git a/ui.py b/ui.py index f5b81cc..22b3bbc 100644 --- a/ui.py +++ b/ui.py @@ -794,6 +794,7 @@ class NovelGeneratorGUI: 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() @@ -803,6 +804,7 @@ class NovelGeneratorGUI: 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 From 2a2beac952ac09a245a211526cd520667355a2b5 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 22:38:50 +0800 Subject: [PATCH 066/142] =?UTF-8?q?=E6=8B=86=E5=88=86=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E7=AB=A0=E5=92=8C=E5=90=8E=E7=BB=AD=E7=AB=A0=E8=8A=82=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=89=BF=E6=8E=A5=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator.py | 154 ++++++++++++++++++++++++++---------------- prompt_definitions.py | 2 +- 2 files changed, 98 insertions(+), 58 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index c02a2a2..6c4e47f 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -32,7 +32,8 @@ from prompt_definitions import ( chunked_chapter_blueprint_prompt, summary_prompt, update_character_state_prompt, - chapter_draft_prompt, + first_chapter_draft_prompt, + next_chapter_draft_prompt, summarize_recent_chapters_prompt ) @@ -415,11 +416,11 @@ def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: 并确保 chunk_size 不会小于1或大于实际章节数。 """ tokens_per_chapter = 100.0 - ratio = max_tokens / tokens_per_chapter # 8192 / 100 = 81.92 + ratio = max_tokens / tokens_per_chapter # 例如:8192 / 100 = 81.92 # 先取到最接近的10倍 ratio_rounded_to_10 = int(ratio // 10) * 10 # => 80 # 再减10 - chunk_size = ratio_rounded_to_10 - 10 # => 70 + chunk_size = ratio_rounded_to_10 - 10 # => 70 if chunk_size < 1: chunk_size = 1 if chunk_size > number_of_chapters: @@ -527,7 +528,7 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") -# ============ 3) 生成章节草稿 ============ +# ============ 3) 生成章节草稿(分「第一章」与「后续章节」) ============ def generate_chapter_draft( api_key: str, @@ -550,6 +551,11 @@ def generate_chapter_draft( interface_format: str = "openai", max_tokens: int = 2048 ) -> str: + """ + 根据 novel_number 判断是否为第一章。 + - 若是第一章,则使用 first_chapter_draft_prompt + - 否则使用 next_chapter_draft_prompt + """ arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -562,6 +568,7 @@ def generate_chapter_draft( 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"] @@ -571,68 +578,97 @@ def generate_chapter_draft( plot_twist_level = chapter_info["plot_twist_level"] chapter_summary = chapter_info["chapter_summary"] + # 准备章节目录文件夹 chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) - short_summary, next_chapter_keywords = summarize_recent_chapters( - interface_format=interface_format, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - max_tokens=max_tokens, - chapters_text_list=recent_3_texts - ) + # 如果是第一章,不需要前情检索与前章结尾 + if novel_number == 1: + # 使用第一章提示词 + prompt_text = first_chapter_draft_prompt.format( + novel_number=novel_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, - previous_chapter_excerpt = "" - for text_block in reversed(recent_3_texts): - if text_block.strip(): - if len(text_block) > 1500: - previous_chapter_excerpt = text_block[-1500:] - else: - previous_chapter_excerpt = text_block - break + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + user_guidance=user_guidance, - embedding_adapter = create_embedding_adapter( - embedding_interface_format, - embedding_api_key, - embedding_url, - embedding_model_name - ) - retrieval_query = short_summary + " " + next_chapter_keywords - relevant_context = get_relevant_context_from_vector_store( - embedding_adapter=embedding_adapter, - query=retrieval_query, - filepath=filepath, - k=embedding_retrieval_k - ) - if not relevant_context.strip(): - relevant_context = "(无检索到的上下文)" + novel_setting=novel_architecture_text + ) - prompt_text = chapter_draft_prompt.format( - novel_number=novel_number, - chapter_title=chapter_title, - chapter_role=chapter_role, - chapter_purpose=chapter_purpose, - suspense_level=suspense_level, - foreshadowing=foreshadowing, - plot_twist_level=plot_twist_level, - chapter_summary=chapter_summary, + else: + # 若不是第一章,则先获取最近几章文本,并做摘要与检索 + recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + short_summary, next_chapter_keywords = summarize_recent_chapters( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + max_tokens=max_tokens, + chapters_text_list=recent_3_texts + ) - characters_involved=characters_involved, - key_items=key_items, - scene_location=scene_location, - time_constraint=time_constraint, - user_guidance=user_guidance, + # 从最近章节中获取最后一段内容作为前章结尾 + previous_chapter_excerpt = "" + for text_block in reversed(recent_3_texts): + if text_block.strip(): + if len(text_block) > 1500: + previous_chapter_excerpt = text_block[-1500:] + else: + previous_chapter_excerpt = text_block + break - novel_setting=novel_architecture_text, - global_summary=global_summary_text, - character_state=character_state_text, - previous_chapter_excerpt=previous_chapter_excerpt, - context_excerpt=relevant_context - ) + # 从向量库检索上下文 + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name + ) + retrieval_query = short_summary + " " + next_chapter_keywords + relevant_context = get_relevant_context_from_vector_store( + embedding_adapter=embedding_adapter, + query=retrieval_query, + filepath=filepath, + k=embedding_retrieval_k + ) + if not relevant_context.strip(): + relevant_context = "(无检索到的上下文)" + # 使用后续章节提示词 + prompt_text = next_chapter_draft_prompt.format( + novel_number=novel_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, + + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + user_guidance=user_guidance, + + novel_setting=novel_architecture_text, + global_summary=global_summary_text, + character_state=character_state_text, + context_excerpt=relevant_context, + previous_chapter_excerpt=previous_chapter_excerpt + ) + + # 调用LLM生成 llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, @@ -645,6 +681,7 @@ def generate_chapter_draft( if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") + # 保存章节文本 chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -652,6 +689,7 @@ def generate_chapter_draft( logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") return chapter_content + # ============ 4) 定稿章节 ============ def finalize_chapter( @@ -676,6 +714,7 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return + # 如果内容过短,则尝试扩写 if len(chapter_text) < 0.7 * word_number: chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) clear_file_content(chapter_file) @@ -716,6 +755,7 @@ def finalize_chapter( clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) + # 更新向量库 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, diff --git a/prompt_definitions.py b/prompt_definitions.py index c7b5661..a6b2d2b 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -364,7 +364,7 @@ next_chapter_draft_prompt = """\ 前章结尾段: {previous_chapter_excerpt} -请从前章结尾处继续完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +请依据前章结尾片段,继续完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: 1. 对话场景: - 潜台词冲突(表面谈论A,实际博弈B) - 权力关系变化(通过非对称对话长度体现) From 21d151d4a63691616f0512796a3517081f9c9764 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 22:51:11 +0800 Subject: [PATCH 067/142] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=BA=9F=E5=BC=83?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_adapters.py | 3 +-- embedding_ollama.py | 59 ------------------------------------------- 2 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 embedding_ollama.py diff --git a/embedding_adapters.py b/embedding_adapters.py index a17bb7a..9b0d7a8 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -48,8 +48,7 @@ class OpenAIEmbeddingAdapter(BaseEmbeddingAdapter): class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): """ - Ollama Embedding,示例中和之前的 embedding_ollama.py 类似 - 其接口路径往往为 /api/embeddings + 其接口路径为 /api/embeddings """ def __init__(self, model_name: str, base_url: str): self.model_name = model_name diff --git a/embedding_ollama.py b/embedding_ollama.py deleted file mode 100644 index 749a462..0000000 --- a/embedding_ollama.py +++ /dev/null @@ -1,59 +0,0 @@ -# embedding_ollama.py -import requests -import traceback -from typing import List - -class OllamaEmbeddings: - def __init__(self, model_name: str, base_url: str): - self.model_name = model_name - self.base_url = base_url - - def embed(self, texts: List[str]) -> List[List[float]]: - """ - 批量将多段文本转换为embedding向量 - """ - embeddings = [] - for text in texts: - embeddings.append(self.embed_single_document(text)) - return embeddings - - def embed_documents(self, texts: List[str]) -> List[List[float]]: - """ - 兼容langchain的接口写法 - """ - return self.embed(texts) - - def embed_query(self, query: str) -> List[float]: - """ - 将单条 query 转换为 embedding 向量 - """ - return self.embed_single_document(query) - - def embed_single_document(self, text: str) -> List[float]: - """ - 调用 Ollama 本地服务接口,获取文本的 embedding。 - """ - if self.base_url.endswith("/"): - self.base_url = self.base_url.rstrip("/") - if "api/embeddings" in self.base_url: - # 如果 base_url 已经包含 'api/embeddings',则保持不变 - url = f"{self.base_url.rstrip('/')}/api/embeddings" - else: - if "/v1" in self.base_url: - self.base_url = self.base_url.split("/v1")[0] - if "/api" in self.base_url: - self.base_url = self.base_url.split("/api")[0] - url = f"{self.base_url}/api/embeddings" - data = { - "model": self.model_name, - "prompt": text - } - try: - response = requests.post(url, json=data) - response.raise_for_status() - result = response.json() - if "embedding" not in result: - raise ValueError("No 'embedding' field in Ollama response.") - return result["embedding"] - except requests.exceptions.RequestException as e: - raise Exception(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}") From e294553c70587634e92c2365c5c309867cd99ea1 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 23:16:30 +0800 Subject: [PATCH 068/142] update requirements.txt --- requirements-default.txt | Bin 392 -> 0 bytes requirements.txt | Bin 169 -> 328 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 requirements-default.txt diff --git a/requirements-default.txt b/requirements-default.txt deleted file mode 100644 index d4ff65e5770ec8914ecf9b70f6b97834685eaed8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 392 zcmZ{g-3o$06oltG=ur%RBJiST5i`@Jbj3BOm#=1yC=`UK>+XCrb9}!lv>4&hp~M*{ z9C^jq;lMk?78~4n(xZWtB^=ig*CIOH`Q-!ZNl{ILuovxbIDs)g^ z?u{-=R3|DY9ZPNG9O`%yrx}HvH#K+$-mD5@OD~~wuhE@;E*vNI)H90o#dDT(1OM+F zYv!h@p>DG|CF{vF_IIkGKT1ZB+{5$Ns((@i$s0~g8EDN}Hu9hFoPnZ+najo|opB@_ I-eKW>0JLR2EdT%j literal 169 zcmZY0Q3?Vv420qL-eu3U#2DMqc2bfoxVKkSQ1JDi`SM(fy30KUp@t}fP`Qy}vn3#X zh3d3g;Um&!c14Pp&& Date: Thu, 6 Feb 2025 23:44:44 +0800 Subject: [PATCH 069/142] Update tooltips.py fix `SyntaxWarning: invalid escape sequence '\O'` --- tooltips.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tooltips.py b/tooltips.py index 062de2f..e62db78 100644 --- a/tooltips.py +++ b/tooltips.py @@ -4,7 +4,7 @@ tooltips = { "api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。", "base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。", - "interface_format": "指定LLM接口兼容格式,可选DeepSeek\OpenAI\Ollama\ML Studio等。\n\n注意:"+ + "interface_format": "指定LLM接口兼容格式,可选DeepSeek、OpenAI、Ollama、ML Studio等。\n\n注意:"+ "OpenAI 兼容是指的可以通过该标准请求的任何接口,不是只允许使用api.openai.com接口\n"+ "例如Ollama接口格式也兼容OpenAI,可以无需修改直接使用\n"+ "ML Studio接口格式与OpenAI接口格式也一致。", From 9cbfdbcb9dd8c79bf08861a1ee045ff3835998f1 Mon Sep 17 00:00:00 2001 From: maoqijie <109787609+maoqijie@users.noreply.github.com> Date: Fri, 7 Feb 2025 00:17:08 +0800 Subject: [PATCH 070/142] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8F=B3=E9=94=AE?= =?UTF-8?q?=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在GUI中增加了右键菜单,包括复制,粘贴,剪切,全选操作 --- ui.py | 2841 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 1450 insertions(+), 1391 deletions(-) diff --git a/ui.py b/ui.py index 22b3bbc..cce0570 100644 --- a/ui.py +++ b/ui.py @@ -1,1391 +1,1450 @@ -# ui.py -# -*- coding: utf-8 -*- - -import logging -import os -import threading -import customtkinter as ctk -from tkinter import filedialog, messagebox -import traceback - -from config_manager import load_config, save_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 -) -from consistency_checker import check_consistency - -# ---- Import the tooltip texts ---- -from tooltips import tooltips - -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: - 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) - - # 主要属性变量 - self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) - self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.openai.com/v1")) - self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) - self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) - self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) - self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) - - # Embedding相关 - self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) - self.embedding_api_key_var = ctk.StringVar(value=self.loaded_config.get("embedding_api_key", "")) - self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "https://api.openai.com/v1")) - self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "text-embedding-ada-002")) - self.embedding_retrieval_k_var = ctk.StringVar(value=str(self.loaded_config.get("embedding_retrieval_k", 4))) - - self.topic_default = self.loaded_config.get("topic", "") - self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - self.num_chapters_var = ctk.StringVar(value=str(self.loaded_config.get("num_chapters", 10))) - self.word_number_var = ctk.StringVar(value=str(self.loaded_config.get("word_number", 3000))) - self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - - 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="") - - # UI 布局 - 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") - - 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): - """Display a popup with tooltip text.""" - info_text = tooltips.get(key, "暂无说明") - messagebox.showinfo("参数说明", info_text) - - def safe_get_int(self, var, default=1): - try: - val_str = str(var.get()).strip() - return int(val_str) - except: - var.set(str(default)) - return default - - # ------------------ 主 Tab ------------------ - def build_main_tab(self): - 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): - 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)) - 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)) - 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): - 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) - - # 配置区 - 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_main_buttons_area() - - # 小说参数 - self.build_novel_params_area(start_row=1) - - # 可选功能按钮 - self.build_optional_buttons_area(start_row=2) - - def build_config_tabview(self): - 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() - - # 封装一个小工具函数,用来创建「标签 + 问号按钮」的组合 - def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): - # frame容器:同一格子里存放 label + "?"按钮 - frame = ctk.CTkFrame(parent) - frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) - frame.columnconfigure(0, weight=0) - # 先放 label - 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 - - def build_ai_config_tab(self): - def on_interface_format_changed(new_value): - if new_value == "Ollama": - self.base_url_var.set("http://localhost:11434/v1") - elif new_value == "ML Studio": - self.base_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.base_url_var.set("https://api.openai.com/v1") - elif new_value == "DeepSeek": - self.base_url_var.set("https://api.deepseek.com/v1") - - for i in range(6): - 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", "Ollama", "ML Studio"] - 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") - - def build_embeddings_config_tab(self): - def on_embedding_interface_changed(new_value): - if new_value == "Ollama": - self.embedding_url_var.set("http://localhost:11434/api") - elif new_value == "ML Studio": - self.embedding_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.embedding_url_var.set("https://api.openai.com/v1") - elif new_value == "DeepSeek": - self.embedding_url_var.set("https://api.deepseek.com/v1") - - 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", "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") - - def build_main_buttons_area(self): - 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 build_novel_params_area(self, start_row=1): - 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)) - 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 - 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) - - # 左边标签 - label_frame = 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) - ) - - # 输入框 - 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)) - self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") - - # 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): - cfg = load_config(self.config_file) - if cfg: - self.api_key_var.set(cfg.get("api_key", "")) - self.base_url_var.set(cfg.get("base_url", "")) - self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) - self.model_name_var.set(cfg.get("model_name", "")) - self.temperature_var.set(cfg.get("temperature", 0.7)) - self.max_tokens_var.set(cfg.get("max_tokens", 2048)) - - self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) - self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) - self.embedding_url_var.set(cfg.get("embedding_url", "")) - self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) - self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) - - self.genre_var.set(cfg.get("genre", "")) - self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) - self.word_number_var.set(str(cfg.get("word_number", 3000))) - self.filepath_var.set(cfg.get("filepath", "")) - - topic_value = cfg.get("topic", "") - self.topic_text.delete("0.0", "end") - self.topic_text.insert("0.0", topic_value) - - self.log("已加载配置。") - else: - messagebox.showwarning("提示", "未找到或无法读取配置文件。") - - def save_config_btn(self): - config_data = { - "api_key": self.api_key_var.get(), - "base_url": self.base_url_var.get(), - "interface_format": self.interface_format_var.get(), - "model_name": self.model_name_var.get(), - "temperature": self.temperature_var.get(), - "max_tokens": self.max_tokens_var.get(), - - "embedding_api_key": self.embedding_api_key_var.get(), - "embedding_interface_format": self.embedding_interface_format_var.get(), - "embedding_url": self.embedding_url_var.get(), - "embedding_model_name": self.embedding_model_name_var.get(), - "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), - - "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() - } - if save_config(config_data, 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 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) - - # ============ Step1: 生成小说架构 ============ - def generate_novel_architecture_ui(self): - 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() - - 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 - ) - 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() - - # ============ Step2: 生成章节蓝图 ============ - def generate_chapter_blueprint_ui(self): - 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() - - 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 - ) - 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() - - # ============ Step3: 生成章节草稿 ============ - def generate_chapter_draft_ui(self): - 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() - - 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 - ) - 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") - - # ============ Step4: 定稿章节 ============ - def finalize_chapter_ui(self): - 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() - - 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() - 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 - ) - 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() - - 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, - 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): - 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, Chapter Blueprint, Character State, Summary ============ - 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)) - 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 的修改。") - - 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)) - 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 的修改。") - - 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)) - 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 的修改。") - - 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)) - 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 的修改。") - - # ============ 章节管理标签页 ============ - 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)) - 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): - 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("提示", "已经是最后一章了。") - - -if __name__ == "__main__": - app = ctk.CTk() - gui = NovelGeneratorGUI(app) - app.mainloop() +# 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 +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 +) +from consistency_checker import check_consistency + +# ---- Import the tooltip texts ---- +from tooltips import tooltips + +# 新增:右键菜单功能 -------------------------------------------- +class TextWidgetContextMenu: + 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("", 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: + 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) + + # 主要属性变量 + self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) + self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.openai.com/v1")) + self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) + self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) + self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) + self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) + + # Embedding相关 + self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) + self.embedding_api_key_var = ctk.StringVar(value=self.loaded_config.get("embedding_api_key", "")) + self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "https://api.openai.com/v1")) + self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "text-embedding-ada-002")) + self.embedding_retrieval_k_var = ctk.StringVar(value=str(self.loaded_config.get("embedding_retrieval_k", 4))) + + self.topic_default = self.loaded_config.get("topic", "") + self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) + self.num_chapters_var = ctk.StringVar(value=str(self.loaded_config.get("num_chapters", 10))) + self.word_number_var = ctk.StringVar(value=str(self.loaded_config.get("word_number", 3000))) + self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) + + 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="") + + # UI 布局 + 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") + + 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): + """Display a popup with tooltip text.""" + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) + + def safe_get_int(self, var, default=1): + try: + val_str = str(var.get()).strip() + return int(val_str) + except: + var.set(str(default)) + return default + + # ------------------ 主 Tab ------------------ + def build_main_tab(self): + 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): + 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): + 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) + + # 配置区 + 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_main_buttons_area() + + # 小说参数 + self.build_novel_params_area(start_row=1) + + # 可选功能按钮 + self.build_optional_buttons_area(start_row=2) + + def build_config_tabview(self): + 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() + + # 封装一个小工具函数,用来创建「标签 + 问号按钮」的组合 + def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): + # frame容器:同一格子里存放 label + "?"按钮 + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + # 先放 label + 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 + + def build_ai_config_tab(self): + def on_interface_format_changed(new_value): + if new_value == "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.base_url_var.set("https://api.openai.com/v1") + elif new_value == "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") + + for i in range(6): + 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", "Ollama", "ML Studio"] + 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") + + def build_embeddings_config_tab(self): + def on_embedding_interface_changed(new_value): + if new_value == "Ollama": + self.embedding_url_var.set("http://localhost:11434/api") + elif new_value == "ML Studio": + self.embedding_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.embedding_url_var.set("https://api.openai.com/v1") + elif new_value == "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + + 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", "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") + + def build_main_buttons_area(self): + 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 build_novel_params_area(self, start_row=1): + 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 + 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) + + # 左边标签 + label_frame = 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) + ) + + # 输入框 + 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") + + # 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): + cfg = load_config(self.config_file) + if cfg: + self.api_key_var.set(cfg.get("api_key", "")) + self.base_url_var.set(cfg.get("base_url", "")) + self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) + self.model_name_var.set(cfg.get("model_name", "")) + self.temperature_var.set(cfg.get("temperature", 0.7)) + self.max_tokens_var.set(cfg.get("max_tokens", 2048)) + + self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) + self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) + self.embedding_url_var.set(cfg.get("embedding_url", "")) + self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) + self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) + + self.genre_var.set(cfg.get("genre", "")) + self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) + self.word_number_var.set(str(cfg.get("word_number", 3000))) + self.filepath_var.set(cfg.get("filepath", "")) + + topic_value = cfg.get("topic", "") + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", topic_value) + + self.log("已加载配置。") + else: + messagebox.showwarning("提示", "未找到或无法读取配置文件。") + + def save_config_btn(self): + config_data = { + "api_key": self.api_key_var.get(), + "base_url": self.base_url_var.get(), + "interface_format": self.interface_format_var.get(), + "model_name": self.model_name_var.get(), + "temperature": self.temperature_var.get(), + "max_tokens": self.max_tokens_var.get(), + + "embedding_api_key": self.embedding_api_key_var.get(), + "embedding_interface_format": self.embedding_interface_format_var.get(), + "embedding_url": self.embedding_url_var.get(), + "embedding_model_name": self.embedding_model_name_var.get(), + "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), + + "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() + } + if save_config(config_data, 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 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) + + # ============ Step1: 生成小说架构 ============ + def generate_novel_architecture_ui(self): + 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() + + 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 + ) + 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() + + # ============ Step2: 生成章节蓝图 ============ + def generate_chapter_blueprint_ui(self): + 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() + + 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 + ) + 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() + + # ============ Step3: 生成章节草稿 ============ + def generate_chapter_draft_ui(self): + 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() + + 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 + ) + 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") + + # ============ Step4: 定稿章节 ============ + def finalize_chapter_ui(self): + 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() + + 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() + 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 + ) + 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() + + 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, + 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): + 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, Chapter Blueprint, Character State, Summary ============ + 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 的修改。") + + 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 的修改。") + + 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 的修改。") + + 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 的修改。") + + # ============ 章节管理标签页 ============ + 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): + 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("提示", "已经是最后一章了。") + + +if __name__ == "__main__": + app = ctk.CTk() + gui = NovelGeneratorGUI(app) + app.mainloop() From a64467f4e3684146f4887f0ebd2e02e71121c584 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Fri, 7 Feb 2025 10:43:44 +0800 Subject: [PATCH 071/142] Update main.spec --- main.spec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.spec b/main.spec index 94baffe..0974bbb 100644 --- a/main.spec +++ b/main.spec @@ -45,7 +45,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.3.3', + name='AI_NovelGenerator_V1.4.0', debug=True, bootloader_ignore_signals=False, strip=False, @@ -66,5 +66,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.3.3' + name='AI_NovelGenerator_V1.4.0' ) From 1eeb95dcd69d6e9ae4ee3bbb341a7a0388109211 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Fri, 7 Feb 2025 18:07:20 +0800 Subject: [PATCH 072/142] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E9=80=BB=E8=BE=91=EF=BC=8C=E9=A2=84=E8=A3=85?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_adapters.py | 27 ++-- novel_generator.py | 304 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 260 insertions(+), 71 deletions(-) diff --git a/llm_adapters.py b/llm_adapters.py index 714e72a..64695fb 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -5,9 +5,6 @@ from typing import Optional from langchain_openai import ChatOpenAI def ensure_openai_base_url_has_v1(url: str) -> str: - """ - 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 - """ import re url = url.strip() if not url: @@ -28,19 +25,21 @@ class DeepSeekAdapter(BaseLLMAdapter): """ 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): + 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 = ensure_openai_base_url_has_v1(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 = ChatOpenAI( model=self.model_name, api_key=self.api_key, base_url=self.base_url, max_tokens=self.max_tokens, - temperature=self.temperature + temperature=self.temperature, + timeout=self.timeout ) def invoke(self, prompt: str) -> str: @@ -54,19 +53,21 @@ class OpenAIAdapter(BaseLLMAdapter): """ 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): + 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 = ensure_openai_base_url_has_v1(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 = ChatOpenAI( model=self.model_name, api_key=self.api_key, base_url=self.base_url, max_tokens=self.max_tokens, - temperature=self.temperature + temperature=self.temperature, + timeout=self.timeout ) def invoke(self, prompt: str) -> str: @@ -81,19 +82,21 @@ class OllamaAdapter(BaseLLMAdapter): Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 但是通常 Ollama 默认本地服务在 http://localhost:11434,如果符合OpenAI风格即可直接传参。 """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): + 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 = ensure_openai_base_url_has_v1(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 = ChatOpenAI( model=self.model_name, api_key=self.api_key, base_url=self.base_url, max_tokens=self.max_tokens, - temperature=self.temperature + temperature=self.temperature, + timeout=self.timeout ) def invoke(self, prompt: str) -> str: @@ -104,19 +107,21 @@ class OllamaAdapter(BaseLLMAdapter): return response.content class MLStudioAdapter(BaseLLMAdapter): - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): + 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 = ensure_openai_base_url_has_v1(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 = ChatOpenAI( model=self.model_name, api_key=self.api_key, base_url=self.base_url, max_tokens=self.max_tokens, - temperature=self.temperature + temperature=self.temperature, + timeout=self.timeout ) def invoke(self, prompt: str) -> str: diff --git a/novel_generator.py b/novel_generator.py index 6c4e47f..381055d 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -5,6 +5,7 @@ import logging import re import time import traceback +import json from typing import List, Optional, Tuple from langchain_chroma import Chroma @@ -46,6 +47,69 @@ from embedding_adapters import create_embedding_adapter logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +# ============ 进度文件管理 ============ + +PROGRESS_FILE = "progress.json" + +def load_progress() -> dict: + """ + 简易进度文件读取,如果不存在则返回默认空字典。 + 你也可以在这里定制更多的进度信息。 + """ + if not os.path.exists(PROGRESS_FILE): + return { + "architecture_done": False, + "blueprint_done": False, + "blueprint_chunk_index": 1, # 若有分块生成,则记录当前分块的起始 + # 也可以记录已完成的章节 + "chapters_generated": [], # 已经生成草稿的章节列表 + "chapters_finalized": [] # 已经定稿的章节列表 + } + try: + with open(PROGRESS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return { + "architecture_done": False, + "blueprint_done": False, + "blueprint_chunk_index": 1, + "chapters_generated": [], + "chapters_finalized": [] + } + +def save_progress(progress: dict): + """ + 将进度写入到 progress.json 中。 + """ + with open(PROGRESS_FILE, "w", encoding="utf-8") as f: + json.dump(progress, f, ensure_ascii=False, indent=2) + + +# ============ 通用的重试封装 ============ + +def call_with_retry(func, max_retries=3, sleep_time=2, fallback_return=None, **kwargs): + """ + 通用的重试机制封装。 + :param func: 要执行的函数 + :param max_retries: 最大重试次数 + :param sleep_time: 重试前的等待秒数 + :param fallback_return: 如果多次重试仍失败时的返回值 + :param kwargs: 传给func的命名参数 + :return: func的结果,若失败则返回 fallback_return + """ + for attempt in range(1, max_retries + 1): + try: + return func(**kwargs) + except Exception as e: + logging.warning(f"[call_with_retry] Attempt {attempt} failed with error: {e}") + traceback.print_exc() + if attempt < max_retries: + time.sleep(sleep_time) + else: + logging.error("Max retries reached, returning fallback_return.") + return fallback_return + + # ============ 工具函数 ============ def remove_think_tags(text: str) -> str: @@ -61,10 +125,16 @@ def debug_log(prompt: str, response_content: str): ) def invoke_with_cleaning(llm_adapter, prompt: str) -> str: - """通用封装:调用 LLM,并移除 ... 文本,记录日志后返回""" - response = llm_adapter.invoke(prompt) + """ + 对 LLM 的调用增加了重试封装, + 如果多次失败,则返回空字符串以继续流程,而不是中断。 + """ + def _invoke(prompt): + return llm_adapter.invoke(prompt) + + response = call_with_retry(func=_invoke, max_retries=3, fallback_return="", prompt=prompt) if not response: - logging.warning("No response from model.") + logging.warning("No response from model after retry. Return empty.") return "" cleaned_text = remove_think_tags(response) debug_log(prompt, cleaned_text) @@ -76,6 +146,7 @@ def invoke_with_cleaning(llm_adapter, prompt: str) -> str: def get_vectorstore_dir(filepath: str) -> str: return os.path.join(filepath, "vectorstore") + # ============ 清空向量库 ============ def clear_vector_store(filepath: str) -> bool: @@ -100,35 +171,52 @@ def init_vector_store( embedding_adapter, texts: List[str], filepath: str -) -> Chroma: +) -> Optional[Chroma]: """ 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 - 这里 embedding_adapter 是一个实现了 embed_documents(texts) 的对象 + 如果Embedding失败,则返回 None,不中断任务。 """ + from langchain.embeddings.base import Embeddings as LCEmbeddings + store_dir = get_vectorstore_dir(filepath) os.makedirs(store_dir, exist_ok=True) documents = [Document(page_content=str(t)) for t in texts] - from langchain.embeddings.base import Embeddings as LCEmbeddings + # 包一层try,如果embedding在初始化或插入过程中报错,则跳过 + try: + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + return call_with_retry( + func=embedding_adapter.embed_documents, + max_retries=3, + fallback_return=[], + doc_texts=doc_texts + ) - class LCEmbeddingWrapper(LCEmbeddings): - def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: - return embedding_adapter.embed_documents(doc_texts) + def embed_query(self, query_text: str) -> List[float]: + res = call_with_retry( + func=embedding_adapter.embed_query, + max_retries=3, + fallback_return=[], + query_text=query_text + ) + return res - def embed_query(self, query_text: str) -> List[float]: - return embedding_adapter.embed_query(query_text) + chroma_embedding = LCEmbeddingWrapper() - chroma_embedding = LCEmbeddingWrapper() - - vectorstore = Chroma.from_documents( - documents, - embedding=chroma_embedding, - persist_directory=store_dir, - client_settings=Settings(anonymized_telemetry=False), - collection_name="novel_collection" - ) - return vectorstore + vectorstore = Chroma.from_documents( + documents, + embedding=chroma_embedding, + persist_directory=store_dir, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + return vectorstore + except Exception as e: + logging.warning(f"Init vector store failed: {e}") + traceback.print_exc() + return None def load_vector_store( embedding_adapter, @@ -136,6 +224,7 @@ def load_vector_store( ) -> Optional[Chroma]: """ 读取已存在的 Chroma 向量库。若不存在则返回 None。 + 如果加载失败(embedding 或IO问题),则返回 None。 """ store_dir = get_vectorstore_dir(filepath) if not os.path.exists(store_dir): @@ -144,21 +233,37 @@ def load_vector_store( from langchain.embeddings.base import Embeddings as LCEmbeddings - class LCEmbeddingWrapper(LCEmbeddings): - def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: - return embedding_adapter.embed_documents(doc_texts) + try: + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + return call_with_retry( + func=embedding_adapter.embed_documents, + max_retries=3, + fallback_return=[], + doc_texts=doc_texts + ) - def embed_query(self, query_text: str) -> List[float]: - return embedding_adapter.embed_query(query_text) + def embed_query(self, query_text: str) -> List[float]: + res = call_with_retry( + func=embedding_adapter.embed_query, + max_retries=3, + fallback_return=[], + query_text=query_text + ) + return res - chroma_embedding = LCEmbeddingWrapper() + chroma_embedding = LCEmbeddingWrapper() - return Chroma( - persist_directory=store_dir, - embedding_function=chroma_embedding, - client_settings=Settings(anonymized_telemetry=False), - collection_name="novel_collection" - ) + return Chroma( + persist_directory=store_dir, + embedding_function=chroma_embedding, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + except Exception as e: + logging.warning(f"Failed to load vector store: {e}") + traceback.print_exc() + return None # ============ 文本分段工具 ============ @@ -218,6 +323,7 @@ def split_text_for_vectorstore(chapter_text: str, return final_segments + # ============ 更新向量库 ============ def update_vector_store( @@ -226,7 +332,8 @@ def update_vector_store( filepath: str ): """ - 将最新章节文本插入到向量库中。若库不存在则初始化。 + 将最新章节文本插入到向量库中。 + 若库不存在则初始化;若初始化/更新失败,则跳过。 """ splitted_texts = split_text_for_vectorstore(new_chapter) if not splitted_texts: @@ -235,13 +342,23 @@ def update_vector_store( store = load_vector_store(embedding_adapter, filepath) if not store: - logging.info("Vector store does not exist. Initializing a new one for new chapter...") - init_vector_store(embedding_adapter, splitted_texts, filepath) + logging.info("Vector store does not exist or failed to load. Initializing a new one for new chapter...") + store = init_vector_store(embedding_adapter, splitted_texts, filepath) + if not store: + logging.warning("Init vector store failed, skip embedding.") + else: + logging.info("New vector store created successfully.") return - docs = [Document(page_content=str(t)) for t in splitted_texts] - store.add_documents(docs) - logging.info("Vector store updated with the new chapter splitted segments.") + # 如果已有store,则直接往里插入 + try: + docs = [Document(page_content=str(t)) for t in splitted_texts] + store.add_documents(docs) + logging.info("Vector store updated with the new chapter splitted segments.") + except Exception as e: + logging.warning(f"Failed to update vector store: {e}") + traceback.print_exc() + # ============ 向量检索上下文 ============ @@ -253,19 +370,25 @@ def get_relevant_context_from_vector_store( ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 + 如果向量库加载/检索失败,则返回空字符串。 """ store = load_vector_store(embedding_adapter, filepath) if not store: - logging.info("No vector store found. Returning empty context.") + logging.info("No vector store found or load failed. Returning empty context.") return "" - docs = store.similarity_search(query, k=k) - if not docs: - logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") + try: + docs = store.similarity_search(query, k=k) + if not docs: + logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") + return "" + combined = "\n".join([d.page_content for d in docs]) + return combined + except Exception as e: + logging.warning(f"Similarity search failed: {e}") + traceback.print_exc() return "" - combined = "\n".join([d.page_content for d in docs]) - return combined # ============ 从目录中获取最近 n 章文本 ============ @@ -281,6 +404,7 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int texts.append("") return texts + # ============ 提炼(短期摘要, 下一章关键字) ============ def summarize_recent_chapters( @@ -350,7 +474,13 @@ def Novel_architecture_generate( 3. world_building_prompt 4. plot_architecture_prompt 最终输出 Novel_architecture.txt + 如果已生成,则不重复执行(利用 progress.json 中的标记)。 """ + progress = load_progress() + if progress.get("architecture_done", False): + logging.info("Novel architecture generation is already done. Skip.") + return + os.makedirs(filepath, exist_ok=True) llm_adapter = create_llm_adapter( @@ -405,6 +535,10 @@ def Novel_architecture_generate( save_string_to_txt(final_content, arch_file) logging.info("Novel_architecture.txt has been generated successfully.") + # 更新进度 + progress["architecture_done"] = True + save_progress(progress) + # ============ 计算分块大小的工具函数 ============ @@ -447,7 +581,14 @@ def Chapter_blueprint_generate( 2) 先生成 [1..chunk_size] 的章节 3) 将生成的文本作为已有目录传入,继续生成 [chunk_size+1..] 的章节 4) 最后汇总全部章节目录写入 Novel_directory.txt + + 过程中若发生错误,会进行一定次数重试;若仍失败则保留已生成的结果,方便下次中断续作。 """ + progress = load_progress() + if progress.get("blueprint_done", False): + logging.info("Chapter blueprint generation is already done. Skip.") + return + arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): logging.warning("Novel_architecture.txt not found. Please generate architecture first.") @@ -486,11 +627,14 @@ def Chapter_blueprint_generate( clear_file_content(filename_dir) save_string_to_txt(blueprint_text, filename_dir) logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (single-shot).") + + progress["blueprint_done"] = True + save_progress(progress) return # 否则,分块生成 final_blueprint = "" - current_start = 1 + current_start = progress.get("blueprint_chunk_index", 1) # 若之前中断,则从上一次的 chunk index 开始 while current_start <= number_of_chapters: current_end = min(current_start + chunk_size - 1, number_of_chapters) @@ -515,20 +659,29 @@ def Chapter_blueprint_generate( else: final_blueprint = chunk_result + # 更新下一个块 current_start = current_end + 1 + # 将当前的 final_blueprint 写入文件,以便中断后保留 + filename_dir = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + + # 更新进度,以便中断后能接着来 + progress["blueprint_chunk_index"] = current_start + save_progress(progress) + if not final_blueprint.strip(): logging.warning("All chunked generation results are empty, cannot create blueprint.") return - filename_dir = os.path.join(filepath, "Novel_directory.txt") - clear_file_content(filename_dir) - save_string_to_txt(final_blueprint.strip(), filename_dir) - + # 生成完成 logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") + progress["blueprint_done"] = True + save_progress(progress) -# ============ 3) 生成章节草稿(分「第一章」与「后续章节」) ============ +# ============ 3) 生成章节草稿 ============ def generate_chapter_draft( api_key: str, @@ -555,7 +708,16 @@ def generate_chapter_draft( 根据 novel_number 判断是否为第一章。 - 若是第一章,则使用 first_chapter_draft_prompt - 否则使用 next_chapter_draft_prompt + 生成草稿后存入 chapters/chapter_{novel_number}.txt """ + progress = load_progress() + if novel_number in progress.get("chapters_generated", []): + logging.info(f"Chapter {novel_number} draft already generated. Skip.") + # 直接返回已有内容 + chapters_dir = os.path.join(filepath, "chapters") + chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + return read_file(chapter_file) + arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -582,7 +744,7 @@ def generate_chapter_draft( chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - # 如果是第一章,不需要前情检索与前章结尾 + # 根据是否是第一章,选择不同的 Prompt if novel_number == 1: # 使用第一章提示词 prompt_text = first_chapter_draft_prompt.format( @@ -603,7 +765,6 @@ def generate_chapter_draft( novel_setting=novel_architecture_text ) - else: # 若不是第一章,则先获取最近几章文本,并做摘要与检索 recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) @@ -627,7 +788,7 @@ def generate_chapter_draft( previous_chapter_excerpt = text_block break - # 从向量库检索上下文 + # 从向量库检索上下文(若失败则为空,不中断) embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -687,6 +848,11 @@ def generate_chapter_draft( save_string_to_txt(chapter_content, chapter_file) logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") + + # 更新进度 + progress["chapters_generated"].append(novel_number) + save_progress(progress) + return chapter_content @@ -707,6 +873,11 @@ def finalize_chapter( interface_format: str, max_tokens: int ): + progress = load_progress() + if novel_number in progress.get("chapters_finalized", []): + logging.info(f"Chapter {novel_number} is already finalized. Skip.") + return + chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -755,7 +926,7 @@ def finalize_chapter( clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 更新向量库 + # 更新向量库(若失败则跳过) embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -766,6 +937,11 @@ def finalize_chapter( logging.info(f"Chapter {novel_number} has been finalized.") + # 更新进度 + progress["chapters_finalized"].append(novel_number) + save_progress(progress) + + def enrich_chapter_text( chapter_text: str, word_number: int, @@ -861,9 +1037,17 @@ def import_knowledge_file( store = load_vector_store(embedding_adapter, filepath) if not store: - logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store(embedding_adapter, paragraphs, filepath) + logging.info("Vector store does not exist or load failed. Initializing a new one for knowledge import...") + store = init_vector_store(embedding_adapter, paragraphs, filepath) + if store: + logging.info("知识库文件已成功导入至向量库(新初始化)。") + else: + logging.warning("知识库导入失败,跳过。") else: - docs = [Document(page_content=str(p)) for p in paragraphs] - store.add_documents(docs) - logging.info("知识库文件已成功导入至向量库。") + try: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + logging.info("知识库文件已成功导入至向量库(追加模式)。") + except Exception as e: + logging.warning(f"知识库导入失败: {e}") + traceback.print_exc() From 03994870ea453ffbe200244b1b7fd30802babfc7 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Fri, 7 Feb 2025 19:20:20 +0800 Subject: [PATCH 073/142] =?UTF-8?q?=E4=BC=A0=E9=80=92=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator.py | 27 ++++++++++++++-------- ui.py | 57 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index 381055d..d3d322e 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -414,7 +414,8 @@ def summarize_recent_chapters( model_name: str, temperature: float, max_tokens: int, - chapters_text_list: List[str] + chapters_text_list: List[str], + timeout: int = 600 ) -> Tuple[str, str]: """ 生成 (short_summary, next_chapter_keywords) @@ -430,7 +431,8 @@ def summarize_recent_chapters( model_name=model_name, api_key=api_key, temperature=temperature, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout ) prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) @@ -572,7 +574,8 @@ def Chapter_blueprint_generate( filepath: str, number_of_chapters: int, temperature: float = 0.7, - max_tokens: int = 2048 + max_tokens: int = 4096, + timeout: int = 600 ) -> None: """ 如果章节数小于等于 chunk_size,则直接使用 chapter_blueprint_prompt 一次性生成。 @@ -605,7 +608,8 @@ def Chapter_blueprint_generate( model_name=llm_model, api_key=api_key, temperature=temperature, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout ) # 计算分块大小 @@ -702,7 +706,8 @@ def generate_chapter_draft( embedding_model_name: str, embedding_retrieval_k: int = 2, interface_format: str = "openai", - max_tokens: int = 2048 + max_tokens: int = 2048, + timeout: int = 600 ) -> str: """ 根据 novel_number 判断是否为第一章。 @@ -775,7 +780,8 @@ def generate_chapter_draft( model_name=model_name, temperature=temperature, max_tokens=max_tokens, - chapters_text_list=recent_3_texts + chapters_text_list=recent_3_texts, + timeout=timeout ) # 从最近章节中获取最后一段内容作为前章结尾 @@ -836,7 +842,8 @@ def generate_chapter_draft( model_name=model_name, api_key=api_key, temperature=temperature, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout ) chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) if not chapter_content.strip(): @@ -871,7 +878,8 @@ def finalize_chapter( embedding_interface_format: str, embedding_model_name: str, interface_format: str, - max_tokens: int + max_tokens: int, + timeout: int = 600 ): progress = load_progress() if novel_number in progress.get("chapters_finalized", []): @@ -902,7 +910,8 @@ def finalize_chapter( model_name=model_name, api_key=api_key, temperature=temperature, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout ) prompt_summary = summary_prompt.format( chapter_text=chapter_text, diff --git a/ui.py b/ui.py index cce0570..35bdd16 100644 --- a/ui.py +++ b/ui.py @@ -105,6 +105,8 @@ class NovelGeneratorGUI: self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) + # === New: Timeout === + self.timeout_var = ctk.IntVar(value=self.loaded_config.get("timeout", 600)) # Embedding相关 self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) @@ -295,7 +297,7 @@ class NovelGeneratorGUI: elif new_value == "DeepSeek": self.base_url_var.set("https://api.deepseek.com/v1") - for i in range(6): + 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) @@ -415,6 +417,39 @@ class NovelGeneratorGUI: ) self.max_tokens_value_label.grid(row=5, column=2, padx=5, pady=5, sticky="w") + # 7) Timeout (sec) + # === MODIFIED: 使用Slider替换Entry === + 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, # 设定一个合理上限,例如1小时 + 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") + # === MODIFIED END === + def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): if new_value == "Ollama": @@ -718,6 +753,7 @@ class NovelGeneratorGUI: self.model_name_var.set(cfg.get("model_name", "")) self.temperature_var.set(cfg.get("temperature", 0.7)) self.max_tokens_var.set(cfg.get("max_tokens", 2048)) + self.timeout_var.set(cfg.get("timeout", 600)) self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) @@ -746,13 +782,12 @@ class NovelGeneratorGUI: "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_api_key": self.embedding_api_key_var.get(), "embedding_interface_format": self.embedding_interface_format_var.get(), "embedding_url": self.embedding_url_var.get(), "embedding_model_name": self.embedding_model_name_var.get(), "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), - "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), @@ -806,6 +841,7 @@ class NovelGeneratorGUI: 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() @@ -824,7 +860,8 @@ class NovelGeneratorGUI: word_number=word_number, filepath=filepath, temperature=temperature, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout_val ) self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") except Exception: @@ -851,6 +888,7 @@ class NovelGeneratorGUI: 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( @@ -861,7 +899,8 @@ class NovelGeneratorGUI: number_of_chapters=number_of_chapters, filepath=filepath, temperature=temperature, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout_val ) self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") except Exception: @@ -887,6 +926,7 @@ class NovelGeneratorGUI: 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) @@ -923,7 +963,8 @@ class NovelGeneratorGUI: embedding_model_name=embedding_model_name, embedding_retrieval_k=embedding_k, interface_format=interface_format, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout_val ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -959,6 +1000,7 @@ class NovelGeneratorGUI: 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() @@ -989,7 +1031,8 @@ class NovelGeneratorGUI: embedding_interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, interface_format=interface_format, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout_val ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") From b12ad4ffe59596bf3b6c2ae6155353a5dd73447d Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Fri, 7 Feb 2025 20:59:12 +0800 Subject: [PATCH 074/142] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E4=BC=A0=E5=8F=82=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_adapters.py | 11 ++++++----- novel_generator.py | 14 +++++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/llm_adapters.py b/llm_adapters.py index 64695fb..5bc8fc9 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -137,18 +137,19 @@ def create_llm_adapter( model_name: str, api_key: str, temperature: float, - max_tokens: int + max_tokens: int, + timeout: int ) -> BaseLLMAdapter: """ 工厂函数:根据 interface_format 返回不同的适配器实例。 """ if interface_format.lower() == "deepseek": - return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature) + return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif interface_format.lower() == "openai": - return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature) + return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif interface_format.lower() == "ollama": - return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature) + return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif interface_format.lower() == "ml studio": - return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature) + return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) else: raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/novel_generator.py b/novel_generator.py index d3d322e..ef02698 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -467,7 +467,8 @@ def Novel_architecture_generate( word_number: int, filepath: str, temperature: float = 0.7, - max_tokens: int = 2048 + max_tokens: int = 2048, + timeout: int = 600 ) -> None: """ 依次调用: @@ -491,7 +492,8 @@ def Novel_architecture_generate( model_name=llm_model, api_key=api_key, temperature=temperature, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout ) # Step1: 核心种子 @@ -895,7 +897,7 @@ def finalize_chapter( # 如果内容过短,则尝试扩写 if len(chapter_text) < 0.7 * word_number: - chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) + chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens, timeout) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) @@ -959,7 +961,8 @@ def enrich_chapter_text( model_name: str, temperature: float, interface_format: str, - max_tokens: int + max_tokens: int, + timeout: int=600 ) -> str: llm_adapter = create_llm_adapter( interface_format=interface_format, @@ -967,7 +970,8 @@ def enrich_chapter_text( model_name=model_name, api_key=api_key, temperature=temperature, - max_tokens=max_tokens + max_tokens=max_tokens, + timeout=timeout ) prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: 原内容: From 2346524682863bcb7a36a86b1df2f240a579e0ef Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Fri, 7 Feb 2025 21:44:06 +0800 Subject: [PATCH 075/142] Update README.md --- README.md | 76 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5246a57..fa7fbb7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # 📖 自动小说生成工具 -## 2025/2/5说明 ->当前**dev-2**分支采用了**雪花写作法、角色弧光理论、悬念三要素模型**的预设,尚在完善中,但就测试质量来说,小说质量会高于当前main分支效果。 -
✨ **核心功能** ✨ @@ -35,7 +32,7 @@ ## 🛠 环境准备 确保满足以下运行条件: -- **Python 3.10+** 运行环境 +- **Python 3.9+** 运行环境(推荐3.10-3.12之间) - **pip** 包管理工具 - 有效API密钥: - 云端服务:OpenAI / DeepSeek 等 @@ -48,11 +45,11 @@ 1. **下载项目** - 通过 [GitHub](https://github.com) 下载项目 ZIP 文件,或使用以下命令克隆本项目: ```bash - git clone https://github.com/your-repo/project-name.git](https://github.com/YILING0013/AI_NovelGenerator + git clone https://github.com/YILING0013/AI_NovelGenerator ``` -2. **安装编译工具** - - 访问 [Visual Studio Build Tools](https://visualstudio.microsoft.com/zh-hans/visual-cpp-build-tools/) 下载并安装编译工具。 +2. **安装编译工具(可选)** + - 如果对某些包无法正常安装,访问 [Visual Studio Build Tools](https://visualstudio.microsoft.com/zh-hans/visual-cpp-build-tools/) 下载并安装C++编译工具,用于构建部分模块包; - 安装时,默认只包含 MSBuild 工具,需手动勾选左上角列表栏中的 **C++ 桌面开发** 选项。 3. **安装依赖并运行** @@ -68,21 +65,28 @@ ```bash python main.py ``` - + +>如果缺失部分依赖,后续**手动执行** +>```bash +>pip install XXX +>``` +>进行安装即可 + ## 🗂 项目架构 ``` novel-generator/ -├── main.py # 入口文件, 运行 GUI -├── ui.py # 图形界面 -├── novel_generator.py # 章节生成核心逻辑 -├── consistency_checker.py # 一致性检查, 防止剧情冲突 -|—— chapter_directory_parser.py # 目录解析 -|—— embedding_ollama.py # Ollama 本地服务的 Embedding 接口 -├── prompt_definitions.py # 定义 AI 提示词 -├── utils.py # 常用工具函数, 文件操作 -├── config_manager.py # 管理配置 (API Key, Base URL) -├── config.json # 用户配置文件 (可选) -└── vectorstore/ # (可选) 本地向量数据库存储 +├── main.py # 入口文件, 运行 GUI +├── ui.py # 图形界面 +├── novel_generator.py # 章节生成核心逻辑 +├── consistency_checker.py # 一致性检查, 防止剧情冲突 +|—— chapter_directory_parser.py # 目录解析 +|—— embedding_adapters.py # Embedding 接口封装 +|—— llm_adapters.py # LLM 接口封装 +├── prompt_definitions.py # 定义 AI 提示词 +├── utils.py # 常用工具函数, 文件操作 +├── config_manager.py # 管理配置 (API Key, Base URL) +├── config.json # 用户配置文件 (可选) +└── vectorstore/ # (可选) 本地向量数据库存储 ``` --- @@ -91,18 +95,22 @@ novel-generator/ ### 📌 基础配置(config.json) ```json { - "api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXX", + "api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "base_url": "https://api.openai.com/v1", "interface_format": "OpenAI", "model_name": "gpt-4o-mini", + "temperature": 0.7, + "max_tokens": 4096, + "embedding_api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "embedding_interface_format": "OpenAI", "embedding_url": "https://api.openai.com/v1", "embedding_model_name": "text-embedding-ada-002", - "temperature": 0.70, + "embedding_retrieval_k": 4, "topic": "星穹铁道主角星穿越到原神提瓦特大陆,拯救提瓦特大陆,并与其中的角色展开爱恨情仇的小说", "genre": "玄幻", - "num_chapters": 10, - "word_number": 2000, - "filepath": "C:/Users/Documents/test" + "num_chapters": 120, + "word_number": 4000, + "filepath": "D:/AI_NovelGenerator/filepath" } ``` @@ -113,10 +121,12 @@ novel-generator/ - `interface_format`: 接口模式 - `model_name`: 主生成模型名称(如gpt-4, claude-3等) - `temperature`: 创意度参数(0-1,越高越有创造性) + - `max_tokens`: 模型最大回复长度 2. **Embedding模型配置** - `embedding_model_name`: 模型名称(如Ollama的nomic-embed-text) - `embedding_url`: 服务地址 + - `embedding_retrieval_k`: 3. **小说参数配置** - `topic`: 核心故事主题 @@ -202,21 +212,13 @@ pyinstaller main.spec --- ## ❓ 疑难解答 -### Q1: 如何验证Embedding服务是否正常? -```python -# 测试脚本 test_embedding.py -from core.embedding import get_embedder +### Q1: Expecting value: line 1 column 1 (char 0) -embedder = get_embedder() -test_text = "这是一个测试句子" -vector = embedder.embed_query(test_text) -print(f"向量维度: {len(vector)}") # 正常应输出预设维度数 -``` +该问题大概率由于API未正确响应造成,也许响应了一个html?其它内容,导致出现该报错; -### Q2: 本地Embedding响应缓慢怎么办? -- 检查模型是否已下载到本地 -- 尝试较小尺寸的Embedding模型 -- 增加Ollama服务的运行内存 + +### Q2: HTTP/1.1 504 Gateway Timeout? +确认接口是否稳定; ### Q3: 如何切换不同的Embedding提供商? 在GUI界面中对应输入即可。 From 8d2d66bd79ef4b834cf9f2e9f43dd2717333a7f2 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sat, 8 Feb 2025 00:03:50 +0800 Subject: [PATCH 076/142] =?UTF-8?q?=EF=BC=88=E6=9C=AA=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=89=E6=94=B9=E8=BF=9B=E4=BA=86=E6=96=AD=E7=82=B9=E7=BB=AD?= =?UTF-8?q?=E8=B7=91=E7=9A=84=E9=80=BB=E8=BE=91=EF=BC=8C=E6=89=A9=E5=86=99?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=94=B9=E4=B8=BA=E8=B4=A8=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. **在生成小说架构 (`Novel_architecture_generate`) 时**: - 新增了 `partial_architecture.json` 用来保存各步骤(核心种子、角色动力学、世界观、三幕式情节)已经生成的结果。 - 每完成一步,就将结果写入 `partial_architecture.json`;如果中途中断或失败了,下次调用该函数时,会直接跳过已完成的步骤,从失败的步骤继续执行。 - 全部完成后,会生成 `Novel_architecture.txt`,并删除 `partial_architecture.json`。 2. **在生成章节蓝图 (`Chapter_blueprint_generate`) 时**: - 如果 `Novel_directory.txt` **已有部分内容**,则说明之前已经生成了一部分。此时会从已完成的章节数继续往后生成,以实现**断点续跑**。 - 如果 `Novel_directory.txt`为空但**章节数少于一个阈值**(计算自 `chunk_size >= number_of_chapters`),则**一次性**生成,否则进行**分块生成**。 - 每完成一个分块,就把新生成的内容**追加**到 `Novel_directory.txt`(实际是整体覆盖写入,但包含已经生成的+新增的),以保证**中途出错**时不至于全部丢失。 --- main.spec | 4 +- novel_generator.py | 351 ++++++++++++++++++++++++------------------ prompt_definitions.py | 18 ++- ui.py | 51 ++++-- 4 files changed, 255 insertions(+), 169 deletions(-) diff --git a/main.spec b/main.spec index 0974bbb..17db507 100644 --- a/main.spec +++ b/main.spec @@ -45,7 +45,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.4.0', + name='AI_NovelGenerator_V1.4.1', debug=True, bootloader_ignore_signals=False, strip=False, @@ -66,5 +66,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.4.0' + name='AI_NovelGenerator_V1.4.1' ) diff --git a/novel_generator.py b/novel_generator.py index ef02698..5192942 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -46,45 +46,6 @@ from embedding_adapters import create_embedding_adapter logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - -# ============ 进度文件管理 ============ - -PROGRESS_FILE = "progress.json" - -def load_progress() -> dict: - """ - 简易进度文件读取,如果不存在则返回默认空字典。 - 你也可以在这里定制更多的进度信息。 - """ - if not os.path.exists(PROGRESS_FILE): - return { - "architecture_done": False, - "blueprint_done": False, - "blueprint_chunk_index": 1, # 若有分块生成,则记录当前分块的起始 - # 也可以记录已完成的章节 - "chapters_generated": [], # 已经生成草稿的章节列表 - "chapters_finalized": [] # 已经定稿的章节列表 - } - try: - with open(PROGRESS_FILE, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return { - "architecture_done": False, - "blueprint_done": False, - "blueprint_chunk_index": 1, - "chapters_generated": [], - "chapters_finalized": [] - } - -def save_progress(progress: dict): - """ - 将进度写入到 progress.json 中。 - """ - with open(PROGRESS_FILE, "w", encoding="utf-8") as f: - json.dump(progress, f, ensure_ascii=False, indent=2) - - # ============ 通用的重试封装 ============ def call_with_retry(func, max_retries=3, sleep_time=2, fallback_return=None, **kwargs): @@ -183,7 +144,6 @@ def init_vector_store( documents = [Document(page_content=str(t)) for t in texts] - # 包一层try,如果embedding在初始化或插入过程中报错,则跳过 try: class LCEmbeddingWrapper(LCEmbeddings): def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: @@ -454,6 +414,37 @@ def summarize_recent_chapters( return (short_summary, next_chapter_keywords) +# ============ 持久化:情节架构(partial_architecture.json) ============ + +def load_partial_architecture_data(filepath: str) -> dict: + """ + 从 filepath 下的 partial_architecture.json 读取已有的阶段性数据。 + 如果文件不存在或无法解析,返回空 dict。 + """ + partial_file = os.path.join(filepath, "partial_architecture.json") + if not os.path.exists(partial_file): + return {} + + try: + with open(partial_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data + except Exception as e: + logging.warning(f"Failed to load partial_architecture.json: {e}") + return {} + +def save_partial_architecture_data(filepath: str, data: dict): + """ + 将阶段性数据写入 partial_architecture.json。 + """ + partial_file = os.path.join(filepath, "partial_architecture.json") + try: + with open(partial_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + logging.warning(f"Failed to save partial_architecture.json: {e}") + + # ============ 1) 生成总体架构 ============ def Novel_architecture_generate( @@ -476,16 +467,15 @@ def Novel_architecture_generate( 2. character_dynamics_prompt 3. world_building_prompt 4. plot_architecture_prompt + 若在中间任何一步报错且重试多次失败,则将已经生成的内容写入 partial_architecture.json 并退出; + 下次调用时可从该步骤继续。 最终输出 Novel_architecture.txt - 如果已生成,则不重复执行(利用 progress.json 中的标记)。 """ - progress = load_progress() - if progress.get("architecture_done", False): - logging.info("Novel architecture generation is already done. Skip.") - return - os.makedirs(filepath, exist_ok=True) + # 加载已有的阶段性数据 + partial_data = load_partial_architecture_data(filepath) + llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, @@ -497,29 +487,77 @@ def Novel_architecture_generate( ) # Step1: 核心种子 - prompt_core = core_seed_prompt.format( - topic=topic, - genre=genre, - number_of_chapters=number_of_chapters, - word_number=word_number - ) - core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) + if "core_seed_result" not in partial_data: + logging.info("Step1: Generating core_seed_prompt (核心种子) ...") + prompt_core = core_seed_prompt.format( + topic=topic, + genre=genre, + number_of_chapters=number_of_chapters, + word_number=word_number + ) + core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) + if not core_seed_result.strip(): + # 多次重试依旧失败,则写入已完成内容后退出 + logging.warning("core_seed_prompt generation failed and returned empty.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["core_seed_result"] = core_seed_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step1 already done. Skipping...") # Step2: 角色动力学 - prompt_character = character_dynamics_prompt.format(core_seed=core_seed_result.strip()) - character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) + 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()) + character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) + if not character_dynamics_result.strip(): + logging.warning("character_dynamics_prompt generation failed.") + # 写入目前已有结果,然后退出 + save_partial_architecture_data(filepath, partial_data) + return + partial_data["character_dynamics_result"] = character_dynamics_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step2 already done. Skipping...") # Step3: 世界观 - prompt_world = world_building_prompt.format(core_seed=core_seed_result.strip()) - world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) + 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()) + world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) + if not world_building_result.strip(): + logging.warning("world_building_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["world_building_result"] = world_building_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step3 already done. Skipping...") # Step4: 三幕式情节 - prompt_plot = plot_architecture_prompt.format( - core_seed=core_seed_result.strip(), - character_dynamics=character_dynamics_result.strip(), - world_building=world_building_result.strip() - ) - plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) + if "plot_arch_result" not in partial_data: + logging.info("Step4: Generating plot_architecture_prompt ...") + 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() + ) + plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) + if not plot_arch_result.strip(): + logging.warning("plot_architecture_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["plot_arch_result"] = plot_arch_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step4 already done. Skipping...") + + # 如果能走到这里,说明全部步骤都完成了 + core_seed_result = partial_data["core_seed_result"] + character_dynamics_result = partial_data["character_dynamics_result"] + world_building_result = partial_data["world_building_result"] + plot_arch_result = partial_data["plot_arch_result"] final_content = ( "#=== 0) 小说设定 ===\n" @@ -539,9 +577,12 @@ def Novel_architecture_generate( save_string_to_txt(final_content, arch_file) logging.info("Novel_architecture.txt has been generated successfully.") - # 更新进度 - progress["architecture_done"] = True - save_progress(progress) + # 全部生成完成后,可以考虑删除 partial_architecture.json,或保留做追溯 + # 这里选择删除 + partial_arch_file = os.path.join(filepath, "partial_architecture.json") + if os.path.exists(partial_arch_file): + os.remove(partial_arch_file) + logging.info("partial_architecture.json removed (all steps completed).") # ============ 计算分块大小的工具函数 ============ @@ -555,9 +596,7 @@ def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: """ tokens_per_chapter = 100.0 ratio = max_tokens / tokens_per_chapter # 例如:8192 / 100 = 81.92 - # 先取到最接近的10倍 ratio_rounded_to_10 = int(ratio // 10) * 10 # => 80 - # 再减10 chunk_size = ratio_rounded_to_10 - 10 # => 70 if chunk_size < 1: chunk_size = 1 @@ -566,7 +605,7 @@ def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: return chunk_size -# ============ 2) 生成章节蓝图(新增分块逻辑) ============ +# ============ 2) 生成章节蓝图(新增分块逻辑 + 断点续跑) ============ def Chapter_blueprint_generate( interface_format: str, @@ -580,20 +619,13 @@ def Chapter_blueprint_generate( timeout: int = 600 ) -> None: """ - 如果章节数小于等于 chunk_size,则直接使用 chapter_blueprint_prompt 一次性生成。 - 如果章节数较多,则进行分块生成: - 1) 首先说明要生成的总章节数 - 2) 先生成 [1..chunk_size] 的章节 - 3) 将生成的文本作为已有目录传入,继续生成 [chunk_size+1..] 的章节 - 4) 最后汇总全部章节目录写入 Novel_directory.txt - - 过程中若发生错误,会进行一定次数重试;若仍失败则保留已生成的结果,方便下次中断续作。 + 若 Novel_directory.txt 已存在且内容非空,则表示可能是之前的部分生成结果; + 解析其中已有的章节数,从下一个章节继续分块生成; + 否则: + - 若章节数 <= chunk_size,直接一次性生成 + - 若章节数 > chunk_size,进行分块生成 + 生成完成后输出至 Novel_directory.txt。 """ - progress = load_progress() - if progress.get("blueprint_done", False): - logging.info("Chapter blueprint generation is already done. Skip.") - return - arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): logging.warning("Novel_architecture.txt not found. Please generate architecture first.") @@ -614,11 +646,65 @@ def Chapter_blueprint_generate( timeout=timeout ) - # 计算分块大小 + filename_dir = os.path.join(filepath, "Novel_directory.txt") + if not os.path.exists(filename_dir): + # 如果文件不存在,就先建一个空文件 + open(filename_dir, "w", encoding="utf-8").close() + + existing_blueprint = read_file(filename_dir).strip() chunk_size = compute_chunk_size(number_of_chapters, max_tokens) logging.info(f"Number of chapters = {number_of_chapters}, computed chunk_size = {chunk_size}.") - # 如果一次就可以生成全部 + # 如果已经有部分章节蓝图生成了,则进行断点续跑 + if existing_blueprint: + logging.info("Detected existing blueprint content. Will resume chunked generation from that point.") + + pattern = r"第\s*(\d+)\s*章" + existing_chapter_numbers = re.findall(pattern, existing_blueprint) + existing_chapter_numbers = [int(x) for x in existing_chapter_numbers if x.isdigit()] + + if existing_chapter_numbers: + max_existing_chap = max(existing_chapter_numbers) + else: + max_existing_chap = 0 + + logging.info(f"Existing blueprint indicates up to chapter {max_existing_chap} has been generated.") + + final_blueprint = existing_blueprint + current_start = max_existing_chap + 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=final_blueprint, # 已有的章节列表文本 + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end + ) + logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") + + chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) + if not chunk_result.strip(): + logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") + # 写入当前已经有的 final_blueprint,并结束 + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + return + + final_blueprint += "\n\n" + chunk_result.strip() + + # 实时写入,以免中途崩溃造成丢失 + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + + current_start = current_end + 1 + + logging.info("All chapters blueprint have been generated (resumed chunked).") + return + + # 如果 Novel_directory.txt 为空,则分情况: + # 1) 如果 chunk_size >= number_of_chapters,可以一次性生成 if chunk_size >= number_of_chapters: prompt = chapter_blueprint_prompt.format( novel_architecture=architecture_text, @@ -629,25 +715,21 @@ def Chapter_blueprint_generate( logging.warning("Chapter blueprint generation result is empty.") return - filename_dir = os.path.join(filepath, "Novel_directory.txt") clear_file_content(filename_dir) save_string_to_txt(blueprint_text, filename_dir) logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (single-shot).") - - progress["blueprint_done"] = True - save_progress(progress) return - # 否则,分块生成 + # 2) 如果 chunk_size < number_of_chapters,则进行分块生成 + logging.info("Will generate chapter blueprint in chunked mode from scratch.") final_blueprint = "" - current_start = progress.get("blueprint_chunk_index", 1) # 若之前中断,则从上一次的 chunk index 开始 + current_start = 1 while current_start <= number_of_chapters: current_end = min(current_start + chunk_size - 1, number_of_chapters) - # 分块提示 chunk_prompt = chunked_chapter_blueprint_prompt.format( novel_architecture=architecture_text, - chapter_list=final_blueprint, # 已有的章节列表文本 + chapter_list=final_blueprint, # 已有的章节列表文本 number_of_chapters=number_of_chapters, n=current_start, m=current_end @@ -657,34 +739,23 @@ def Chapter_blueprint_generate( chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) if not chunk_result.strip(): logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") - chunk_result = "" + # 写入已经生成的 final_blueprint + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + return - # 将本次生成的文本拼接到最终结果中 if final_blueprint.strip(): - final_blueprint += "\n\n" + chunk_result + final_blueprint += "\n\n" + chunk_result.strip() else: - final_blueprint = chunk_result + final_blueprint = chunk_result.strip() - # 更新下一个块 - current_start = current_end + 1 - - # 将当前的 final_blueprint 写入文件,以便中断后保留 - filename_dir = os.path.join(filepath, "Novel_directory.txt") + # 实时写入,以免中途崩溃造成丢失 clear_file_content(filename_dir) save_string_to_txt(final_blueprint.strip(), filename_dir) - # 更新进度,以便中断后能接着来 - progress["blueprint_chunk_index"] = current_start - save_progress(progress) + current_start = current_end + 1 - if not final_blueprint.strip(): - logging.warning("All chunked generation results are empty, cannot create blueprint.") - return - - # 生成完成 logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") - progress["blueprint_done"] = True - save_progress(progress) # ============ 3) 生成章节草稿 ============ @@ -715,16 +786,8 @@ def generate_chapter_draft( 根据 novel_number 判断是否为第一章。 - 若是第一章,则使用 first_chapter_draft_prompt - 否则使用 next_chapter_draft_prompt - 生成草稿后存入 chapters/chapter_{novel_number}.txt + 最终将生成文本存入 chapters/chapter_{novel_number}.txt。 """ - progress = load_progress() - if novel_number in progress.get("chapters_generated", []): - logging.info(f"Chapter {novel_number} draft already generated. Skip.") - # 直接返回已有内容 - chapters_dir = os.path.join(filepath, "chapters") - chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - return read_file(chapter_file) - arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -751,11 +814,11 @@ def generate_chapter_draft( chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - # 根据是否是第一章,选择不同的 Prompt + # 判断是否为第一章 if novel_number == 1: - # 使用第一章提示词 prompt_text = first_chapter_draft_prompt.format( novel_number=novel_number, + word_number=word_number, chapter_title=chapter_title, chapter_role=chapter_role, chapter_purpose=chapter_purpose, @@ -773,7 +836,7 @@ def generate_chapter_draft( novel_setting=novel_architecture_text ) else: - # 若不是第一章,则先获取最近几章文本,并做摘要与检索 + # 若不是第一章,则获取最近几章文本,并做摘要与检索 recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) short_summary, next_chapter_keywords = summarize_recent_chapters( interface_format=interface_format, @@ -786,17 +849,18 @@ def generate_chapter_draft( timeout=timeout ) - # 从最近章节中获取最后一段内容作为前章结尾 + # 从最近章节中获取最后一段作为前章结尾 previous_chapter_excerpt = "" for text_block in reversed(recent_3_texts): if text_block.strip(): + # 取后1500字符左右 if len(text_block) > 1500: previous_chapter_excerpt = text_block[-1500:] else: previous_chapter_excerpt = text_block break - # 从向量库检索上下文(若失败则为空,不中断) + # 从向量库检索上下文 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -813,9 +877,9 @@ def generate_chapter_draft( if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 使用后续章节提示词 prompt_text = next_chapter_draft_prompt.format( novel_number=novel_number, + word_number=word_number, chapter_title=chapter_title, chapter_role=chapter_role, chapter_purpose=chapter_purpose, @@ -837,7 +901,6 @@ def generate_chapter_draft( previous_chapter_excerpt=previous_chapter_excerpt ) - # 调用LLM生成 llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, @@ -847,21 +910,16 @@ def generate_chapter_draft( max_tokens=max_tokens, timeout=timeout ) + chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 保存章节文本 chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") - - # 更新进度 - progress["chapters_generated"].append(novel_number) - save_progress(progress) - return chapter_content @@ -883,11 +941,10 @@ def finalize_chapter( max_tokens: int, timeout: int = 600 ): - progress = load_progress() - if novel_number in progress.get("chapters_finalized", []): - logging.info(f"Chapter {novel_number} is already finalized. Skip.") - return - + """ + 对指定章节做最终处理:更新全局摘要、更新角色状态、插入向量库等。 + 默认无需再做扩写操作,若有需要可在外部调用 enrich_chapter_text 处理后再定稿。 + """ chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -895,14 +952,10 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 如果内容过短,则尝试扩写 - if len(chapter_text) < 0.7 * word_number: - chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens, timeout) - clear_file_content(chapter_file) - save_string_to_txt(chapter_text, chapter_file) - + # 进行摘要、角色状态更新 global_summary_file = os.path.join(filepath, "global_summary.txt") old_global_summary = read_file(global_summary_file) + character_state_file = os.path.join(filepath, "character_state.txt") old_character_state = read_file(character_state_file) @@ -915,6 +968,7 @@ def finalize_chapter( max_tokens=max_tokens, timeout=timeout ) + prompt_summary = summary_prompt.format( chapter_text=chapter_text, global_summary=old_global_summary @@ -937,7 +991,7 @@ def finalize_chapter( clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 更新向量库(若失败则跳过) + # 更新向量库 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -948,10 +1002,6 @@ def finalize_chapter( logging.info(f"Chapter {novel_number} has been finalized.") - # 更新进度 - progress["chapters_finalized"].append(novel_number) - save_progress(progress) - def enrich_chapter_text( chapter_text: str, @@ -964,6 +1014,9 @@ def enrich_chapter_text( max_tokens: int, timeout: int=600 ) -> str: + """ + 对章节文本进行扩写,使其更接近 word_number 字数,保持剧情连贯。 + """ llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, diff --git a/prompt_definitions.py b/prompt_definitions.py index a6b2d2b..e32745d 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -306,7 +306,7 @@ first_chapter_draft_prompt = """\ - 小说设定: {novel_setting} -请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +请完成第 {novel_number} 章的正文,字数要求{word_number}字,至少设计下方2个或以上具有动态张力的场景: 1. 对话场景: - 潜台词冲突(表面谈论A,实际博弈B) - 权力关系变化(通过非对称对话长度体现) @@ -322,6 +322,12 @@ first_chapter_draft_prompt = """\ - 隐喻系统的运用(连接世界观符号) - 决策前的价值天平描写 +4. 环境场景: + - 空间透视变化(宏观→微观→异常焦点) + - 非常规感官组合(如"听见阳光的重量") + - 动态环境反映心理(环境与人物心理对应) + - 隐藏线索植入(环境暗示未来事件) + 文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知/神转折等。 格式要求: @@ -364,7 +370,9 @@ next_chapter_draft_prompt = """\ 前章结尾段: {previous_chapter_excerpt} -请依据前章结尾片段,继续完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +请参考前章结尾片段,继续完成第 {novel_number} 章的正文,字数要求{word_number}字,确保与前章结尾衔接流畅, + +本章至少设计下方2个或以上具有动态张力的场景: 1. 对话场景: - 潜台词冲突(表面谈论A,实际博弈B) - 权力关系变化(通过非对称对话长度体现) @@ -380,6 +388,12 @@ next_chapter_draft_prompt = """\ - 隐喻系统的运用(连接世界观符号) - 决策前的价值天平描写 +4. 环境场景: + - 空间透视变化(宏观→微观→异常焦点) + - 非常规感官组合(如"听见阳光的重量") + - 动态环境反映心理(环境与人物心理对应) + - 隐藏线索植入(环境暗示未来事件) + 文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知/神转折等。 格式要求: diff --git a/ui.py b/ui.py index 35bdd16..a9845b8 100644 --- a/ui.py +++ b/ui.py @@ -19,7 +19,8 @@ from novel_generator import ( finalize_chapter, import_knowledge_file, clear_vector_store, - get_last_n_chapters_text + get_last_n_chapters_text, + enrich_chapter_text ) from consistency_checker import check_consistency @@ -105,7 +106,6 @@ class NovelGeneratorGUI: self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) - # === New: Timeout === self.timeout_var = ctk.IntVar(value=self.loaded_config.get("timeout", 600)) # Embedding相关 @@ -265,16 +265,12 @@ class NovelGeneratorGUI: self.build_ai_config_tab() self.build_embeddings_config_tab() - # 封装一个小工具函数,用来创建「标签 + 问号按钮」的组合 def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): - # frame容器:同一格子里存放 label + "?"按钮 frame = ctk.CTkFrame(parent) frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) frame.columnconfigure(0, weight=0) - # 先放 label label = ctk.CTkLabel(frame, text=label_text, font=font) label.pack(side="left") - # 再放问号按钮 btn = ctk.CTkButton( frame, text="?", @@ -418,7 +414,6 @@ class NovelGeneratorGUI: self.max_tokens_value_label.grid(row=5, column=2, padx=5, pady=5, sticky="w") # 7) Timeout (sec) - # === MODIFIED: 使用Slider替换Entry === self.create_label_with_help( parent=self.ai_config_tab, label_text="Timeout (sec):", @@ -435,7 +430,7 @@ class NovelGeneratorGUI: timeout_slider = ctk.CTkSlider( self.ai_config_tab, from_=0, - to=3600, # 设定一个合理上限,例如1小时 + to=3600, number_of_steps=3600, command=update_timeout_label, variable=self.timeout_var @@ -448,7 +443,6 @@ class NovelGeneratorGUI: font=("Microsoft YaHei", 12) ) self.timeout_value_label.grid(row=6, column=2, padx=5, pady=5, sticky="w") - # === MODIFIED END === def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): @@ -585,7 +579,6 @@ class NovelGeneratorGUI: 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) - # 左边标签 label_frame = self.create_label_with_help( parent=self.params_frame, label_text="章节数 & 每章字数:", @@ -595,7 +588,6 @@ class NovelGeneratorGUI: font=("Microsoft YaHei", 12) ) - # 输入框 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)) @@ -655,7 +647,6 @@ class NovelGeneratorGUI: # 7) 可选元素:核心人物/关键道具/空间坐标/时间压力 row_idx = 6 - # 核心人物 self.create_label_with_help( parent=self.params_frame, label_text="核心人物:", @@ -668,7 +659,6 @@ class NovelGeneratorGUI: 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="关键道具:", @@ -681,7 +671,6 @@ class NovelGeneratorGUI: 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="空间坐标:", @@ -694,7 +683,6 @@ class NovelGeneratorGUI: 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="时间压力:", @@ -1011,13 +999,44 @@ class NovelGeneratorGUI: 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: + # 调用 enrich_chapter_text 进行扩写 + 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 做最终处理 finalize_chapter( novel_number=chap_num, word_number=word_number, @@ -1163,7 +1182,7 @@ class NovelGeneratorGUI: text_area.insert("0.0", arcs_text) text_area.configure(state="disabled") - # ============ 其余标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ============ + # ============ 其余标签页 ============ def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) From 1ce0edceac42c422ba1f181d4760a35aa66559fa Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 9 Feb 2025 15:36:47 +0800 Subject: [PATCH 077/142] fixed #86 #82 ;optimized prompt words again --- novel_generator.py | 86 +++++++++++++++++++++++++++++++++++-------- prompt_definitions.py | 56 +++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 21 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index 5192942..1ec5062 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -35,7 +35,8 @@ from prompt_definitions import ( update_character_state_prompt, first_chapter_draft_prompt, next_chapter_draft_prompt, - summarize_recent_chapters_prompt + summarize_recent_chapters_prompt, + create_character_state_prompt ) # 章节目录解析 @@ -146,20 +147,20 @@ def init_vector_store( try: class LCEmbeddingWrapper(LCEmbeddings): - def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + def embed_documents(self, texts: List[str]) -> List[List[float]]: return call_with_retry( func=embedding_adapter.embed_documents, max_retries=3, fallback_return=[], - doc_texts=doc_texts + texts=texts ) - def embed_query(self, query_text: str) -> List[float]: + def embed_query(self, query: str) -> List[float]: res = call_with_retry( func=embedding_adapter.embed_query, max_retries=3, fallback_return=[], - query_text=query_text + query=query ) return res @@ -195,20 +196,20 @@ def load_vector_store( try: class LCEmbeddingWrapper(LCEmbeddings): - def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + def embed_documents(self, texts: List[str]) -> List[List[float]]: return call_with_retry( func=embedding_adapter.embed_documents, max_retries=3, fallback_return=[], - doc_texts=doc_texts + texts=texts ) - def embed_query(self, query_text: str) -> List[float]: + def embed_query(self, query: str) -> List[float]: res = call_with_retry( func=embedding_adapter.embed_query, max_retries=3, fallback_return=[], - query_text=query_text + query=query ) return res @@ -247,8 +248,9 @@ def split_text_for_vectorstore(chapter_text: str, """ if not chapter_text.strip(): return [] - + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(chapter_text) if not sentences: return [] @@ -331,6 +333,7 @@ def get_relevant_context_from_vector_store( """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 如果向量库加载/检索失败,则返回空字符串。 + 最终只返回最多2000字符的检索片段。 """ store = load_vector_store(embedding_adapter, filepath) if not store: @@ -343,6 +346,9 @@ def get_relevant_context_from_vector_store( logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") return "" combined = "\n".join([d.page_content for d in docs]) + # 限制长度最多2000字符 + if len(combined) > 2000: + combined = combined[:2000] return combined except Exception as e: logging.warning(f"Similarity search failed: {e}") @@ -470,6 +476,10 @@ def Novel_architecture_generate( 若在中间任何一步报错且重试多次失败,则将已经生成的内容写入 partial_architecture.json 并退出; 下次调用时可从该步骤继续。 最终输出 Novel_architecture.txt + + 新增: + - 在完成角色动力学设定后,依据该角色体系,使用 create_character_state_prompt 生成初始角色状态表, + 并存储到 character_state.txt,后续维护更新。 """ os.makedirs(filepath, exist_ok=True) @@ -521,6 +531,28 @@ def Novel_architecture_generate( else: logging.info("Step2 already done. Skipping...") + # 在完成角色动力学设定后,生成初始角色状态表 + if "character_dynamics_result" in partial_data and "character_state_result" not in partial_data: + logging.info("Generating initial character state from character dynamics ...") + prompt_char_state_init = create_character_state_prompt.format( + character_dynamics=partial_data["character_dynamics_result"].strip() + ) + character_state_init = invoke_with_cleaning(llm_adapter, prompt_char_state_init) + if not character_state_init.strip(): + logging.warning("create_character_state_prompt generation failed.") + # 写入目前已有结果,然后退出 + save_partial_architecture_data(filepath, partial_data) + return + + partial_data["character_state_result"] = character_state_init + # 保存到文件 + character_state_file = os.path.join(filepath, "character_state.txt") + clear_file_content(character_state_file) + save_string_to_txt(character_state_init, character_state_file) + + save_partial_architecture_data(filepath, partial_data) + logging.info("Initial character state created and saved.") + # Step3: 世界观 if "world_building_result" not in partial_data: logging.info("Step3: Generating world_building_prompt ...") @@ -595,9 +627,9 @@ def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: 并确保 chunk_size 不会小于1或大于实际章节数。 """ tokens_per_chapter = 100.0 - ratio = max_tokens / tokens_per_chapter # 例如:8192 / 100 = 81.92 - ratio_rounded_to_10 = int(ratio // 10) * 10 # => 80 - chunk_size = ratio_rounded_to_10 - 10 # => 70 + ratio = max_tokens / tokens_per_chapter + ratio_rounded_to_10 = int(ratio // 10) * 10 + chunk_size = ratio_rounded_to_10 - 10 if chunk_size < 1: chunk_size = 1 if chunk_size > number_of_chapters: @@ -605,6 +637,22 @@ def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: return chunk_size +def limit_chapter_blueprint(blueprint_text: str, limit_chapters: int = 100) -> str: + """ + 从已有章节目录中只取最近的 limit_chapters 章,以避免 prompt 超长。 + """ + pattern = r"(第\s*\d+\s*章.*?)(?=第\s*\d+\s*章|$)" + chapters = re.findall(pattern, blueprint_text, flags=re.DOTALL) + if not chapters: + return blueprint_text + + if len(chapters) <= limit_chapters: + return blueprint_text + + selected = chapters[-limit_chapters:] + return "\n\n".join(selected).strip() + + # ============ 2) 生成章节蓝图(新增分块逻辑 + 断点续跑) ============ def Chapter_blueprint_generate( @@ -621,6 +669,7 @@ def Chapter_blueprint_generate( """ 若 Novel_directory.txt 已存在且内容非空,则表示可能是之前的部分生成结果; 解析其中已有的章节数,从下一个章节继续分块生成; + 对于已有章节目录,传入时仅保留最近100章目录,避免prompt过长。 否则: - 若章节数 <= chunk_size,直接一次性生成 - 若章节数 > chunk_size,进行分块生成 @@ -674,10 +723,11 @@ def Chapter_blueprint_generate( current_start = max_existing_chap + 1 while current_start <= number_of_chapters: current_end = min(current_start + chunk_size - 1, number_of_chapters) + limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) chunk_prompt = chunked_chapter_blueprint_prompt.format( novel_architecture=architecture_text, - chapter_list=final_blueprint, # 已有的章节列表文本 + chapter_list=limited_blueprint, # 只保留最近100章 number_of_chapters=number_of_chapters, n=current_start, m=current_end @@ -694,7 +744,7 @@ def Chapter_blueprint_generate( final_blueprint += "\n\n" + chunk_result.strip() - # 实时写入,以免中途崩溃造成丢失 + # 实时写入 clear_file_content(filename_dir) save_string_to_txt(final_blueprint.strip(), filename_dir) @@ -726,10 +776,11 @@ def Chapter_blueprint_generate( current_start = 1 while current_start <= number_of_chapters: current_end = min(current_start + chunk_size - 1, number_of_chapters) + limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) chunk_prompt = chunked_chapter_blueprint_prompt.format( novel_architecture=architecture_text, - chapter_list=final_blueprint, # 已有的章节列表文本 + chapter_list=limited_blueprint, # 只保留最近100章 number_of_chapters=number_of_chapters, n=current_start, m=current_end @@ -969,6 +1020,7 @@ def finalize_chapter( timeout=timeout ) + # 更新全局摘要 prompt_summary = summary_prompt.format( chapter_text=chapter_text, global_summary=old_global_summary @@ -977,6 +1029,7 @@ def finalize_chapter( if not new_global_summary.strip(): new_global_summary = old_global_summary + # 更新角色状态 prompt_char_state = update_character_state_prompt.format( chapter_text=chapter_text, old_state=old_character_state @@ -1040,6 +1093,7 @@ def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(content) if not sentences: return [] diff --git a/prompt_definitions.py b/prompt_definitions.py index e32745d..6ad1d11 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -168,7 +168,7 @@ chunked_chapter_blueprint_prompt = """\ 需要生成总共{number_of_chapters}章的节奏分布, -当前已有章节目录(若未空则说明是初始生成):\n +当前已有章节目录(若为空则说明是初始生成):\n {chapter_list} 现在请设计第{n}章到第{m}的节奏分布: @@ -228,14 +228,60 @@ summary_prompt = """\ """ # =============== 7. 角色状态更新 =================== +create_character_state_prompt = """\ +依据当前角色动力学设定:{character_dynamics} + +请生成一个角色状态文档,内容格式: +角色A属性: +├──物品: + ├──物品(若有初始物品则增加,没有则为暂无):描述 + ... +├──能力 + ├──技能1(若有初始技能则增加,没有则为暂无):描述 + ... +├──状态 + ├──身体状态: + ├──Buff/Debuff + ├──心理状态:描述 + +├──主要角色间关系网 + ├──角色B:描述(初始有关联则增加,没有则为暂无关系) + ├──角色C:描述(初始有关联则增加,没有则为暂无关系) + ... +├──触发或加深的事件 + ├──暂无事件 + ... + +角色B属性: +├──物品 + ├──... +├──能力 + ├──... +├──状态 + ├──... +├──主要角色间关系网 + ├──... +├──触发或加深的事件 + ├──... + +角色C属性: +...... + +新出场角色: +- (此处填写未来任何新增角色或临时出场人物的基本信息) + +要求: +仅返回编写好的角色状态文本,不要解释任何内容。 +""" + update_character_state_prompt = """\ 以下是新完成的章节文本: {chapter_text} -这是当前的角色状态文档(可为空): +这是当前的角色状态文档: {old_state} -请更新角色状态,内容格式: +请更新主要角色状态,内容格式: 角色A属性: ├──物品: ├──某物(道具):描述 @@ -275,7 +321,7 @@ update_character_state_prompt = """\ ...... 新出场角色: -- 任何新增角色或临时出场人物的基本信息 +- 任何新增角色或临时出场人物的基本信息,简要描述即可,不要展开,淡出视线的角色可删除。 要求: - 请直接在已有文档基础上进行增删 @@ -370,7 +416,7 @@ next_chapter_draft_prompt = """\ 前章结尾段: {previous_chapter_excerpt} -请参考前章结尾片段,继续完成第 {novel_number} 章的正文,字数要求{word_number}字,确保与前章结尾衔接流畅, +依据前章结尾剧情,开始完成第 {novel_number} 章的正文,字数要求{word_number}字,确保与前章结尾衔接流畅, 本章至少设计下方2个或以上具有动态张力的场景: 1. 对话场景: From 8afa1083e0f7627b4fa02cce2f2884ce56b08bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=91=E6=A6=86=E8=82=96=E7=89=A9?= Date: Sun, 9 Feb 2025 22:50:52 +0800 Subject: [PATCH 078/142] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=20Azure=20O?= =?UTF-8?q?penAI=20=E7=9A=84=E6=94=AF=E6=8C=81=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_adapters.py | 31 ++++++++++++++++++++++++++++++- llm_adapters.py | 41 ++++++++++++++++++++++++++++++++++++++++- ui.py | 8 ++++++-- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/embedding_adapters.py b/embedding_adapters.py index 9b0d7a8..4304644 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -4,7 +4,7 @@ import logging import requests import traceback from typing import List -from langchain_openai import OpenAIEmbeddings +from langchain_openai import OpenAIEmbeddings, AzureOpenAIEmbeddings def ensure_openai_base_url_has_v1(url: str) -> str: """ @@ -45,6 +45,33 @@ class OpenAIEmbeddingAdapter(BaseEmbeddingAdapter): def embed_query(self, query: str) -> List[float]: return self._embedding.embed_query(query) + +class AzureOpenAIEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 AzureOpenAIEmbeddings(或兼容接口)的适配器 + """ + def __init__(self, api_key: str, base_url: str, model_name: str): + import re + match = re.match(r'https://(.+?)/openai/deployments/(.+?)/embeddings\?api-version=(.+)', base_url) + if match: + self.azure_endpoint = f"https://{match.group(1)}" + self.azure_deployment = match.group(2) + self.api_version = match.group(3) + else: + raise ValueError("Invalid Azure OpenAI base_url format") + + self._embedding = AzureOpenAIEmbeddings( + azure_endpoint=self.azure_endpoint, + azure_deployment=self.azure_deployment, + openai_api_key=api_key, + api_version=self.api_version, + ) + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._embedding.embed_documents(texts) + + def embed_query(self, query: str) -> List[float]: + return self._embedding.embed_query(query) class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): """ @@ -112,6 +139,8 @@ def create_embedding_adapter( """ if interface_format.lower() == "openai": return OpenAIEmbeddingAdapter(api_key, base_url, model_name) + elif interface_format.lower() == "azure openai": + return AzureOpenAIEmbeddingAdapter(api_key, base_url, model_name) elif interface_format.lower() == "ollama": return OllamaEmbeddingAdapter(model_name, base_url) elif interface_format.lower() == "ml studio": diff --git a/llm_adapters.py b/llm_adapters.py index 5bc8fc9..f09d06b 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import logging from typing import Optional -from langchain_openai import ChatOpenAI +from langchain_openai import ChatOpenAI, AzureChatOpenAI def ensure_openai_base_url_has_v1(url: str) -> str: import re @@ -77,6 +77,43 @@ class OpenAIAdapter(BaseLLMAdapter): return "" return response.content +class AzureOpenAIAdapter(BaseLLMAdapter): + """ + 适配 Azure OpenAI 接口(使用 langchain.ChatOpenAI) + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + import re + match = re.match(r'https://(.+?)/openai/deployments/(.+?)/chat/completions\?api-version=(.+)', base_url) + if match: + self.azure_endpoint = f"https://{match.group(1)}" + self.azure_deployment = match.group(2) + self.api_version = match.group(3) + else: + raise ValueError("Invalid Azure OpenAI base_url format") + + self.api_key = api_key + self.model_name = self.azure_deployment + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = AzureChatOpenAI( + azure_endpoint=self.azure_endpoint, + azure_deployment=self.azure_deployment, + api_version=self.api_version, + api_key=self.api_key, + max_tokens=self.max_tokens, + temperature=self.temperature, + timeout=self.timeout + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from AzureOpenAIAdapter.") + return "" + return response.content + class OllamaAdapter(BaseLLMAdapter): """ Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 @@ -147,6 +184,8 @@ def create_llm_adapter( return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif interface_format.lower() == "openai": return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif interface_format.lower() == "azure openai": + return AzureOpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif interface_format.lower() == "ollama": return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif interface_format.lower() == "ml studio": diff --git a/ui.py b/ui.py index a9845b8..ec9a2a2 100644 --- a/ui.py +++ b/ui.py @@ -290,6 +290,8 @@ class NovelGeneratorGUI: self.base_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.base_url_var.set("https://api.openai.com/v1") + elif new_value == "Azure OpenAI": + self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview") elif new_value == "DeepSeek": self.base_url_var.set("https://api.deepseek.com/v1") @@ -332,7 +334,7 @@ class NovelGeneratorGUI: column=0, font=("Microsoft YaHei", 12) ) - interface_options = ["DeepSeek", "OpenAI", "Ollama", "ML Studio"] + interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Ollama", "ML Studio"] interface_dropdown = ctk.CTkOptionMenu( self.ai_config_tab, values=interface_options, @@ -452,6 +454,8 @@ class NovelGeneratorGUI: self.embedding_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.embedding_url_var.set("https://api.openai.com/v1") + elif new_value == "Azure OpenAI": + self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15") elif new_value == "DeepSeek": self.embedding_url_var.set("https://api.deepseek.com/v1") @@ -482,7 +486,7 @@ class NovelGeneratorGUI: column=0, font=("Microsoft YaHei", 12) ) - emb_interface_options = ["DeepSeek", "OpenAI", "Ollama", "ML Studio"] + emb_interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Ollama", "ML Studio"] emb_interface_dropdown = ctk.CTkOptionMenu( self.embeddings_config_tab, values=emb_interface_options, From 20805abbd10339f567ca4729ed56d68956529790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=91=E6=A6=86=E8=82=96=E7=89=A9?= Date: Sun, 9 Feb 2025 23:34:07 +0800 Subject: [PATCH 079/142] =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=99=A8=E4=BD=BF=E7=94=A8=20llm=5Fadapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- consistency_checker.py | 28 ++++++++++++++++++---------- ui.py | 6 ++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/consistency_checker.py b/consistency_checker.py index 7e6abe3..a205764 100644 --- a/consistency_checker.py +++ b/consistency_checker.py @@ -1,6 +1,6 @@ # consistency_checker.py # -*- coding: utf-8 -*- -from langchain_openai import ChatOpenAI +from llm_adapters import create_llm_adapter # ============== 增加对“剧情要点/未解决冲突”进行检查的可选引导 ============== CONSISTENCY_PROMPT = """\ @@ -32,7 +32,10 @@ def check_consistency( base_url: str, model_name: str, temperature: float = 0.3, - plot_arcs: str = "" # 新增参数,默认空字符串 + plot_arcs: str = "", + interface_format: str = "OpenAI", + max_tokens: int = 2048, + timeout: int = 600 ) -> str: """ 调用模型做简单的一致性检查。可扩展更多提示或校验规则。 @@ -45,20 +48,25 @@ def check_consistency( plot_arcs=plot_arcs, chapter_text=chapter_text ) - model = ChatOpenAI( - model=model_name, - api_key=api_key, + + llm_adapter = create_llm_adapter( + interface_format=interface_format, base_url=base_url, - temperature=temperature + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout ) + # 调试日志 print("\n[ConsistencyChecker] Prompt >>>", prompt) - response = model.invoke(prompt) + response = llm_adapter.invoke(prompt) if not response: return "审校Agent无回复" - + # 调试日志 - print("[ConsistencyChecker] Response <<<", response.content.strip()) + print("[ConsistencyChecker] Response <<<", response) - return response.content.strip() + return response diff --git a/ui.py b/ui.py index ec9a2a2..5d5b36c 100644 --- a/ui.py +++ b/ui.py @@ -1083,6 +1083,9 @@ class NovelGeneratorGUI: 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") @@ -1102,6 +1105,9 @@ class NovelGeneratorGUI: 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("审校结果:") From 5392bb505c681019b376fa2d95e4040fd039f0f8 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 10 Feb 2025 15:12:45 +0800 Subject: [PATCH 080/142] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=B6=E4=BD=99?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=9A=84=E7=BC=93=E5=AD=98=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui.py | 294 +++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 219 insertions(+), 75 deletions(-) diff --git a/ui.py b/ui.py index 5d5b36c..c27964f 100644 --- a/ui.py +++ b/ui.py @@ -27,8 +27,12 @@ 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) @@ -82,11 +86,16 @@ def log_error(message: str): 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") @@ -95,11 +104,12 @@ class NovelGeneratorGUI: self.master.geometry("1350x840") - # 配置持久化 + # --------------- 配置文件路径 --------------- self.config_file = "config.json" self.loaded_config = load_config(self.config_file) - # 主要属性变量 + # --------------- 主要属性变量 --------------- + # -- LLM通用参数 -- self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.openai.com/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) @@ -108,31 +118,35 @@ class NovelGeneratorGUI: self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) self.timeout_var = ctk.IntVar(value=self.loaded_config.get("timeout", 600)) - # Embedding相关 + # -- Embedding相关 -- self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) self.embedding_api_key_var = ctk.StringVar(value=self.loaded_config.get("embedding_api_key", "")) self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "https://api.openai.com/v1")) self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "text-embedding-ada-002")) self.embedding_retrieval_k_var = ctk.StringVar(value=str(self.loaded_config.get("embedding_retrieval_k", 4))) + # -- 小说参数相关 -- self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) self.num_chapters_var = ctk.StringVar(value=str(self.loaded_config.get("num_chapters", 10))) self.word_number_var = ctk.StringVar(value=str(self.loaded_config.get("word_number", 3000))) self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - self.chapter_num_var = ctk.StringVar(value="1") + # -- 章节参数及可选要素 -- + self.chapter_num_var = ctk.StringVar(value=str(self.loaded_config.get("chapter_num", "1"))) + self.characters_involved_var = ctk.StringVar(value=self.loaded_config.get("characters_involved", "")) + self.key_items_var = ctk.StringVar(value=self.loaded_config.get("key_items", "")) + self.scene_location_var = ctk.StringVar(value=self.loaded_config.get("scene_location", "")) + self.time_constraint_var = ctk.StringVar(value=self.loaded_config.get("time_constraint", "")) - # 四个可选要素 - 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 = self.loaded_config.get("user_guidance", "") - # UI 布局 + # --------------- 整体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") @@ -140,6 +154,7 @@ class NovelGeneratorGUI: 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() @@ -147,12 +162,14 @@ class NovelGeneratorGUI: self.build_summary_tab() self.build_chapters_tab() + # ----------------- 通用辅助函数 ----------------- def show_tooltip(self, key: str): - """Display a popup with tooltip text.""" + """显示自定义的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) @@ -160,14 +177,43 @@ class NovelGeneratorGUI: var.set(str(default)) return default - # ------------------ 主 Tab ------------------ + 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) @@ -175,6 +221,9 @@ class NovelGeneratorGUI: 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) @@ -185,8 +234,9 @@ class NovelGeneratorGUI: 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) # 新增右键菜单 + TextWidgetContextMenu(self.chapter_result) self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) # Step 按钮区域 @@ -226,36 +276,42 @@ class NovelGeneratorGUI: ) 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) # 新增右键菜单 + 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_main_buttons_area() - # 小说参数 + # 小说参数(主题、类型、章节数、字数、保存路径、可选元素等) 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) @@ -265,12 +321,30 @@ class NovelGeneratorGUI: self.build_ai_config_tab() self.build_embeddings_config_tab() - def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): + # 底部的“保存配置”和“加载配置”按钮 + 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="?", @@ -280,10 +354,15 @@ class NovelGeneratorGUI: 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为对应的默认值。 + """ if new_value == "Ollama": self.base_url_var.set("http://localhost:11434/v1") elif new_value == "ML Studio": @@ -446,8 +525,12 @@ class NovelGeneratorGUI: ) self.timeout_value_label.grid(row=6, column=2, padx=5, pady=5, sticky="w") + # --------------- Embedding 模型配置 --------------- def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): + """ + 当切换Embedding接口格式时,自动设置embedding_url为对应的默认值。 + """ if new_value == "Ollama": self.embedding_url_var.set("http://localhost:11434/api") elif new_value == "ML Studio": @@ -532,19 +615,11 @@ class NovelGeneratorGUI: 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") - def build_main_buttons_area(self): - 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 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) @@ -560,7 +635,7 @@ class NovelGeneratorGUI: sticky="ne" ) self.topic_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) - TextWidgetContextMenu(self.topic_text) # 新增右键菜单 + 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) @@ -579,11 +654,7 @@ class NovelGeneratorGUI: # 3) 章节数 & 每章字数 row_for_chapter_and_word = 2 - 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) - - label_frame = self.create_label_with_help( + self.create_label_with_help( parent=self.params_frame, label_text="章节数 & 每章字数:", tooltip_key="num_chapters", @@ -592,13 +663,19 @@ class NovelGeneratorGUI: 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") @@ -618,6 +695,7 @@ class NovelGeneratorGUI: 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") @@ -646,8 +724,11 @@ class NovelGeneratorGUI: sticky="ne" ) self.user_guide_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) - TextWidgetContextMenu(self.user_guide_text) # 新增右键菜单 + 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 @@ -698,7 +779,11 @@ class NovelGeneratorGUI: 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) @@ -736,7 +821,11 @@ class NovelGeneratorGUI: ) 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: self.api_key_var.set(cfg.get("api_key", "")) @@ -762,11 +851,26 @@ class NovelGeneratorGUI: self.topic_text.delete("0.0", "end") self.topic_text.insert("0.0", topic_value) + # 新增:读取章节号、本章指导、可选元素 + self.chapter_num_var.set(str(cfg.get("chapter_num", "1"))) + + user_guidance_value = cfg.get("user_guidance", "") + self.user_guide_text.delete("0.0", "end") + self.user_guide_text.insert("0.0", user_guidance_value) + + self.characters_involved_var.set(cfg.get("characters_involved", "")) + self.key_items_var.set(cfg.get("key_items", "")) + self.scene_location_var.set(cfg.get("scene_location", "")) + self.time_constraint_var.set(cfg.get("time_constraint", "")) + self.log("已加载配置。") else: messagebox.showwarning("提示", "未找到或无法读取配置文件。") def save_config_btn(self): + """ + 将当前界面的配置信息保存到 config.json + """ config_data = { "api_key": self.api_key_var.get(), "base_url": self.base_url_var.get(), @@ -775,17 +879,28 @@ class NovelGeneratorGUI: "temperature": self.temperature_var.get(), "max_tokens": self.max_tokens_var.get(), "timeout": self.safe_get_int(self.timeout_var, 600), + "embedding_api_key": self.embedding_api_key_var.get(), "embedding_interface_format": self.embedding_interface_format_var.get(), "embedding_url": self.embedding_url_var.get(), "embedding_model_name": self.embedding_model_name_var.get(), "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), + "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() + "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() } + if save_config(config_data, self.config_file): messagebox.showinfo("提示", "配置已保存至 config.json") self.log("配置已保存。") @@ -797,28 +912,11 @@ class NovelGeneratorGUI: if selected_dir: self.filepath_var.set(selected_dir) - 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) - - # ============ Step1: 生成小说架构 ============ + # ----------------- 生成与定稿的函数 ----------------- def generate_novel_architecture_ui(self): + """ + Step1:生成小说整体架构 + """ filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先选择保存文件路径") @@ -863,8 +961,10 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ============ Step2: 生成章节蓝图 ============ def generate_chapter_blueprint_ui(self): + """ + Step2:生成章节蓝图 + """ filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先选择保存文件路径") @@ -902,8 +1002,10 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ============ Step3: 生成章节草稿 ============ def generate_chapter_draft_ui(self): + """ + Step3:生成章节草稿 + """ filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先配置保存文件路径。") @@ -972,12 +1074,17 @@ class NovelGeneratorGUI: 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") - # ============ Step4: 定稿章节 ============ def finalize_chapter_ui(self): + """ + Step4:定稿章节,更新全局摘要、角色状态、向量库等。 + """ filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先配置保存文件路径。") @@ -1040,7 +1147,7 @@ class NovelGeneratorGUI: clear_file_content(chapter_file) save_string_to_txt(edited_text, chapter_file) - # 调用 finalize_chapter 做最终处理 + # 调用 finalize_chapter 做最终处理(更新全局摘要、角色状态、向量库等) finalize_chapter( novel_number=chap_num, word_number=word_number, @@ -1069,8 +1176,11 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ============ 一致性审校 (可选) ============ + # ----------------- 一致性审校 ----------------- def do_consistency_check(self): + """ + 可选功能:对当前章节进行一致性审校,基于全局摘要和角色状态等进行检查。 + """ filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先配置保存文件路径。") @@ -1120,8 +1230,11 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ============ 导入知识库 ============ + # ----------------- 知识库操作 ----------------- def import_knowledge_handler(self): + """ + 导入文本文件到本地知识库,供后续生成章节时的检索。 + """ selected_file = filedialog.askopenfilename( title="选择要导入的知识库文件", filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")] @@ -1153,6 +1266,9 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() def clear_vectorstore_handler(self): + """ + 清空本地向量库文件夹。 + """ filepath = self.filepath_var.get().strip() if not filepath: messagebox.showwarning("警告", "请先配置保存文件路径。") @@ -1168,6 +1284,9 @@ class NovelGeneratorGUI: 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中设置保存文件路径") @@ -1192,7 +1311,7 @@ class NovelGeneratorGUI: 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) @@ -1215,7 +1334,7 @@ class NovelGeneratorGUI: 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) # 新增右键菜单 + TextWidgetContextMenu(self.setting_text) self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) def load_novel_architecture(self): @@ -1240,6 +1359,7 @@ class NovelGeneratorGUI: 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) @@ -1262,7 +1382,7 @@ class NovelGeneratorGUI: 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) # 新增右键菜单 + TextWidgetContextMenu(self.directory_text) self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) def load_chapter_blueprint(self): @@ -1287,6 +1407,7 @@ class NovelGeneratorGUI: 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) @@ -1309,7 +1430,7 @@ class NovelGeneratorGUI: 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) # 新增右键菜单 + TextWidgetContextMenu(self.character_text) self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) def load_character_state(self): @@ -1334,6 +1455,7 @@ class NovelGeneratorGUI: 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) @@ -1356,7 +1478,7 @@ class NovelGeneratorGUI: 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) # 新增右键菜单 + TextWidgetContextMenu(self.summary_text) self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) def load_global_summary(self): @@ -1381,8 +1503,11 @@ class NovelGeneratorGUI: 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) @@ -1402,6 +1527,8 @@ class NovelGeneratorGUI: next_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w") self.chapter_select_var = ctk.StringVar(value="") + + # 下拉菜单(若章节超10个,则启用滚动限制) self.chapter_select_menu = ctk.CTkOptionMenu( top_frame, values=[], @@ -1418,13 +1545,17 @@ class NovelGeneratorGUI: 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) # 新增右键菜单 + 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文件,并更新下拉菜单。 + 当章节多于10个时,启用CTkOptionMenu的滚动限制。 + """ filepath = self.filepath_var.get().strip() chapters_dir = os.path.join(filepath, "chapters") if not os.path.exists(chapters_dir): @@ -1454,6 +1585,9 @@ class NovelGeneratorGUI: 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): @@ -1470,6 +1604,9 @@ class NovelGeneratorGUI: 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("警告", "尚未选择章节,无法保存。") @@ -1488,6 +1625,9 @@ class NovelGeneratorGUI: self.safe_log(f"已保存对第 {chapter_number_str} 章的修改。") def prev_chapter(self): + """ + 切换到上一章 + """ if not self.chapters_list: return current = self.chapter_select_var.get() @@ -1502,6 +1642,9 @@ class NovelGeneratorGUI: messagebox.showinfo("提示", "已经是第一章了。") def next_chapter(self): + """ + 切换到下一章 + """ if not self.chapters_list: return current = self.chapter_select_var.get() @@ -1516,6 +1659,7 @@ class NovelGeneratorGUI: messagebox.showinfo("提示", "已经是最后一章了。") +# ----------------- 程序入口 ----------------- if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app) From 6d90d524b14640a1ef93aaef920ffd0f9952feee Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Mon, 10 Feb 2025 21:48:47 +0800 Subject: [PATCH 081/142] update main.spec --- main.py | 9 ++++----- main.spec | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 94f27b2..c968f64 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,12 @@ # main.py # -*- coding: utf-8 -*- -import tkinter as tk +import customtkinter as ctk from ui import NovelGeneratorGUI def main(): - root = tk.Tk() - root.title("Novel Generator") - app = NovelGeneratorGUI(root) - root.mainloop() + app = ctk.CTk() + gui = NovelGeneratorGUI(app) + app.mainloop() if __name__ == "__main__": main() diff --git a/main.spec b/main.spec index 17db507..50a88dd 100644 --- a/main.spec +++ b/main.spec @@ -45,7 +45,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.4.1', + name='AI_NovelGenerator_V1.4.2', debug=True, bootloader_ignore_signals=False, strip=False, @@ -66,5 +66,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.4.1' + name='AI_NovelGenerator_V1.4.2' ) From aac83fab0e7efb24488cefd5d624ab4da46f1cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=91=E6=A6=86=E8=82=96=E7=89=A9?= Date: Tue, 11 Feb 2025 00:26:23 +0800 Subject: [PATCH 082/142] =?UTF-8?q?=E6=B7=BB=E5=8A=A0LLM=E5=92=8CEmbedding?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=B5=8B=E8=AF=95=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0UI=E4=BB=A5=E6=94=AF=E6=8C=81=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config_manager.py | 57 +++++++++++++++++++++++++++++++++++++++++++ ui.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/config_manager.py b/config_manager.py index c3b45f3..a65be54 100644 --- a/config_manager.py +++ b/config_manager.py @@ -2,6 +2,10 @@ # -*- coding: utf-8 -*- import json import os +import threading +from llm_adapters import create_llm_adapter +from embedding_adapters import create_embedding_adapter + def load_config(config_file: str) -> dict: """从指定的 config_file 加载配置,若不存在则返回空字典。""" @@ -21,3 +25,56 @@ def save_config(config_data: dict, config_file: str) -> bool: return True except: return False + +def test_llm_config(interface_format, api_key, base_url, model_name, temperature, max_tokens, timeout, log_func, handle_exception_func): + """测试当前的LLM配置是否可用""" + def task(): + try: + log_func("开始测试LLM配置...") + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + test_prompt = "Please reply 'OK'" + response = llm_adapter.invoke(test_prompt) + if response: + log_func("✅ LLM配置测试成功!") + log_func(f"测试回复: {response}") + else: + log_func("❌ LLM配置测试失败:未获取到响应") + except Exception as e: + log_func(f"❌ LLM配置测试出错: {str(e)}") + handle_exception_func("测试LLM配置时出错") + + threading.Thread(target=task, daemon=True).start() + +def test_embedding_config(api_key, base_url, interface_format, model_name, log_func, handle_exception_func): + """测试当前的Embedding配置是否可用""" + def task(): + try: + log_func("开始测试Embedding配置...") + embedding_adapter = create_embedding_adapter( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + model_name=model_name + ) + + test_text = "测试文本" + embeddings = embedding_adapter.embed_query(test_text) + if embeddings and len(embeddings) > 0: + log_func("✅ Embedding配置测试成功!") + log_func(f"生成的向量维度: {len(embeddings)}") + else: + log_func("❌ Embedding配置测试失败:未获取到向量") + except Exception as e: + log_func(f"❌ Embedding配置测试出错: {str(e)}") + handle_exception_func("测试Embedding配置时出错") + + threading.Thread(target=task, daemon=True).start() \ No newline at end of file diff --git a/ui.py b/ui.py index c27964f..7538b97 100644 --- a/ui.py +++ b/ui.py @@ -9,7 +9,7 @@ from tkinter import filedialog, messagebox import tkinter as tk import traceback -from config_manager import load_config, save_config +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 ( @@ -525,6 +525,15 @@ class NovelGeneratorGUI: ) 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): @@ -615,6 +624,15 @@ class NovelGeneratorGUI: 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): """ @@ -1658,6 +1676,48 @@ class NovelGeneratorGUI: 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__": From bc0fffaf9c12d6c2ec148bb9db4d8e16b201868b Mon Sep 17 00:00:00 2001 From: lorge <1833935022@qq.com> Date: Tue, 11 Feb 2025 05:40:34 +0800 Subject: [PATCH 083/142] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81Gemini?= =?UTF-8?q?=E7=B3=BB=E5=88=97=E6=A8=A1=E5=9E=8B=E7=9A=84=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_adapters.py | 36 ++++++++++++++++++++++++++++++++++++ requirements.txt | Bin 328 -> 366 bytes tooltips.py | 4 ++-- ui.py | 4 +++- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/llm_adapters.py b/llm_adapters.py index f09d06b..525b117 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -3,6 +3,8 @@ import logging from typing import Optional from langchain_openai import ChatOpenAI, AzureChatOpenAI +from google import genai +from google.genai import types def ensure_openai_base_url_has_v1(url: str) -> str: import re @@ -77,6 +79,38 @@ class OpenAIAdapter(BaseLLMAdapter): return "" return response.content +class GeminiAdapter(BaseLLMAdapter): + """ + 适配 Google Gemini 接口 + """ + def __init__(self, api_key: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = genai.Client(api_key=self.api_key) + + def invoke(self, prompt: str) -> str: + try: + response = self._client.models.generate_content( + model = self.model_name, + contents = prompt, + config = types.GenerateContentConfig( + max_output_tokens=self.max_tokens, + temperature=self.temperature, + ) + ) + if response and response.text: + return response.text + else: + logging.warning("No text response from Gemini API.") + return "" + except Exception as e: + logging.error(f"Gemini API 调用失败: {e}") + return "" + class AzureOpenAIAdapter(BaseLLMAdapter): """ 适配 Azure OpenAI 接口(使用 langchain.ChatOpenAI) @@ -190,5 +224,7 @@ def create_llm_adapter( return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif interface_format.lower() == "ml studio": return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif interface_format.lower() == "gemini": + return GeminiAdapter(api_key, model_name, max_tokens, temperature, timeout) else: raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/requirements.txt b/requirements.txt index 981d39b908cc7e1d861cc64db11f772fedd02975..3c699d82120bd45d56313eece6bd278ef18b1d92 100644 GIT binary patch delta 46 ucmX@X^p0tR2cud#Lp~6uGvqL&GUx*7RE9htE@DVzC}GHCC Date: Tue, 11 Feb 2025 10:46:08 +0800 Subject: [PATCH 084/142] Update requirements.txt --- requirements.txt | Bin 366 -> 394 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3c699d82120bd45d56313eece6bd278ef18b1d92..e9bd518d242f876ffa070eacf41d9667ff35d600 100644 GIT binary patch delta 36 ocmaFI)Wy6Zk5Pt~fr}xXAs-0S8FCm>8FYbkDnlMaB10wv0Gm4pegFUf delta 7 OcmeBTe#f*Sj}ZV1dIFsQ From d54871b2c208be748a280a4ee9c503f344d14554 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 11 Feb 2025 16:16:21 +0800 Subject: [PATCH 085/142] fixed #82 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复ollama的格式检查 --- embedding_adapters.py | 11 ++++++++--- main.spec | 2 ++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/embedding_adapters.py b/embedding_adapters.py index 4304644..8e9cf6f 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -95,9 +95,14 @@ class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): """ 调用 Ollama 本地服务 /api/embeddings 接口,获取文本 embedding """ - url = self.base_url - if "api/embeddings" not in url: - url = f"{url}/api/embeddings" + url = self.base_url.rstrip("/") + if "/api/embeddings" not in url: + if "/api" in url: + url = f"{url}/embeddings" + else: + if "/v1" in url: + url = url[:url.index("/v1")] + url = f"{url}/api/embeddings" data = { "model": self.model_name, diff --git a/main.spec b/main.spec index 50a88dd..5935660 100644 --- a/main.spec +++ b/main.spec @@ -7,6 +7,8 @@ hiddenimports = ['typing_extensions', 'langchain-openai', 'langgraph', 'openai', + 'google-genai', + 'google', 'nltk', 'sentence_transformers', 'scikit-learn', From 8ed0166d3e5cfd3c53b9cf0e4a3559187ad97e88 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Tue, 11 Feb 2025 21:32:05 +0800 Subject: [PATCH 086/142] fixed #98 --- embedding_adapters.py | 46 ++++++++++++++++++++++++++++++++++++++----- llm_adapters.py | 19 +++++++++--------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/embedding_adapters.py b/embedding_adapters.py index 8e9cf6f..db4738b 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -45,7 +45,7 @@ class OpenAIEmbeddingAdapter(BaseEmbeddingAdapter): def embed_query(self, query: str) -> List[float]: return self._embedding.embed_query(query) - + class AzureOpenAIEmbeddingAdapter(BaseEmbeddingAdapter): """ 基于 AzureOpenAIEmbeddings(或兼容接口)的适配器 @@ -133,6 +133,38 @@ class MLStudioEmbeddingAdapter(BaseEmbeddingAdapter): def embed_query(self, query: str) -> List[float]: return self._embedding.embed_query(query) +class GeminiEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 Google Generative AI (Gemini)接口的 Embedding 适配器 + """ + def __init__(self, api_key: str, model_name: str): + from google import genai + # 全局配置,也可根据需要改成 Client(...) 初始化方式 + genai.configure(api_key=api_key) + self.model_name = model_name + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + from google import genai + embeddings = [] + for text in texts: + try: + result = genai.embed_content(model=self.model_name, content=text) + # 返回结构中包含 'embedding' 字段 + embeddings.append(result.get('embedding', [])) + except Exception as e: + logging.error(f"Gemini embed_content error: {e}") + embeddings.append([]) + return embeddings + + def embed_query(self, query: str) -> List[float]: + from google import genai + try: + result = genai.embed_content(model=self.model_name, content=query) + return result.get('embedding', []) + except Exception as e: + logging.error(f"Gemini embed_content error: {e}") + return [] + def create_embedding_adapter( interface_format: str, api_key: str, @@ -142,13 +174,17 @@ def create_embedding_adapter( """ 工厂函数:根据 interface_format 返回不同的 embedding 适配器实例 """ - if interface_format.lower() == "openai": + fmt = interface_format.strip().lower() + if fmt == "openai": return OpenAIEmbeddingAdapter(api_key, base_url, model_name) - elif interface_format.lower() == "azure openai": + elif fmt == "azure openai": return AzureOpenAIEmbeddingAdapter(api_key, base_url, model_name) - elif interface_format.lower() == "ollama": + elif fmt == "ollama": return OllamaEmbeddingAdapter(model_name, base_url) - elif interface_format.lower() == "ml studio": + elif fmt == "ml studio": return MLStudioEmbeddingAdapter(api_key, base_url, model_name) + elif fmt == "gemini": + # base_url 对 Gemini 暂无用处,可忽略 + return GeminiEmbeddingAdapter(api_key, model_name) else: raise ValueError(f"Unknown embedding interface_format: {interface_format}") diff --git a/llm_adapters.py b/llm_adapters.py index 525b117..6437509 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -18,7 +18,7 @@ def ensure_openai_base_url_has_v1(url: str) -> str: class BaseLLMAdapter: """ - 统一的 LLM 接口基类,为不同后端(OpenAI、Ollama、ML Studio 等)提供一致的方法签名。 + 统一的 LLM 接口基类,为不同后端(OpenAI、Ollama、ML Studio、Gemini等)提供一致的方法签名。 """ def invoke(self, prompt: str) -> str: raise NotImplementedError("Subclasses must implement .invoke(prompt) method.") @@ -81,7 +81,7 @@ class OpenAIAdapter(BaseLLMAdapter): class GeminiAdapter(BaseLLMAdapter): """ - 适配 Google Gemini 接口 + 适配 Google Gemini (Google Generative AI) 接口 """ def __init__(self, api_key: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): self.api_key = api_key @@ -151,7 +151,6 @@ class AzureOpenAIAdapter(BaseLLMAdapter): class OllamaAdapter(BaseLLMAdapter): """ Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 - 但是通常 Ollama 默认本地服务在 http://localhost:11434,如果符合OpenAI风格即可直接传参。 """ 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 = ensure_openai_base_url_has_v1(base_url) @@ -214,17 +213,19 @@ def create_llm_adapter( """ 工厂函数:根据 interface_format 返回不同的适配器实例。 """ - if interface_format.lower() == "deepseek": + fmt = interface_format.strip().lower() + if fmt == "deepseek": return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) - elif interface_format.lower() == "openai": + elif fmt == "openai": return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) - elif interface_format.lower() == "azure openai": + elif fmt == "azure openai": return AzureOpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) - elif interface_format.lower() == "ollama": + elif fmt == "ollama": return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) - elif interface_format.lower() == "ml studio": + elif fmt == "ml studio": return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) - elif interface_format.lower() == "gemini": + elif fmt == "gemini": + # base_url 对 Gemini 暂无用处,可忽略 return GeminiAdapter(api_key, model_name, max_tokens, temperature, timeout) else: raise ValueError(f"Unknown interface_format: {interface_format}") From 186566f173e977987fad469592852b190112dbc9 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 12 Feb 2025 10:49:28 +0800 Subject: [PATCH 087/142] fixed #100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复贡献于[TdRoseval](https://github.com/TdRoseval)的Pr: #97 所遗留问题; --- embedding_adapters.py | 59 +++++++++++++++++++++++++++++-------------- ui.py | 6 ++++- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/embedding_adapters.py b/embedding_adapters.py index db4738b..6bc6ddb 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -135,34 +135,56 @@ class MLStudioEmbeddingAdapter(BaseEmbeddingAdapter): class GeminiEmbeddingAdapter(BaseEmbeddingAdapter): """ - 基于 Google Generative AI (Gemini)接口的 Embedding 适配器 + 基于 Google Generative AI (Gemini) 接口的 Embedding 适配器 + 使用直接 POST 请求方式,URL 示例: + https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=YOUR_API_KEY """ - def __init__(self, api_key: str, model_name: str): - from google import genai - # 全局配置,也可根据需要改成 Client(...) 初始化方式 - genai.configure(api_key=api_key) + def __init__(self, api_key: str, model_name: str, base_url: str): + """ + :param api_key: 传入的 Google API Key + :param model_name: 这里一般是 "text-embedding-004" + :param base_url: e.g. https://generativelanguage.googleapis.com/v1beta/models + """ + self.api_key = api_key self.model_name = model_name + self.base_url = base_url.rstrip("/") def embed_documents(self, texts: List[str]) -> List[List[float]]: - from google import genai embeddings = [] for text in texts: - try: - result = genai.embed_content(model=self.model_name, content=text) - # 返回结构中包含 'embedding' 字段 - embeddings.append(result.get('embedding', [])) - except Exception as e: - logging.error(f"Gemini embed_content error: {e}") - embeddings.append([]) + vec = self._embed_single(text) + embeddings.append(vec) return embeddings def embed_query(self, query: str) -> List[float]: - from google import genai + return self._embed_single(query) + + def _embed_single(self, text: str) -> List[float]: + """ + 直接调用 Google Generative Language API (Gemini) 接口,获取文本 embedding + """ + url = f"{self.base_url}/{self.model_name}:embedContent?key={self.api_key}" + payload = { + "model": self.model_name, + "content": { + "parts": [ + {"text": text} + ] + } + } + try: - result = genai.embed_content(model=self.model_name, content=query) - return result.get('embedding', []) + response = requests.post(url, json=payload) + print(response.text) + response.raise_for_status() + result = response.json() + embedding_data = result.get("embedding", {}) + return embedding_data.get("values", []) + except requests.exceptions.RequestException as e: + logging.error(f"Gemini embed_content request error: {e}\n{traceback.format_exc()}") + return [] except Exception as e: - logging.error(f"Gemini embed_content error: {e}") + logging.error(f"Gemini embed_content parse error: {e}\n{traceback.format_exc()}") return [] def create_embedding_adapter( @@ -184,7 +206,6 @@ def create_embedding_adapter( elif fmt == "ml studio": return MLStudioEmbeddingAdapter(api_key, base_url, model_name) elif fmt == "gemini": - # base_url 对 Gemini 暂无用处,可忽略 - return GeminiEmbeddingAdapter(api_key, model_name) + return GeminiEmbeddingAdapter(api_key, model_name, base_url) else: raise ValueError(f"Unknown embedding interface_format: {interface_format}") diff --git a/ui.py b/ui.py index 8cfa772..ed88749 100644 --- a/ui.py +++ b/ui.py @@ -548,10 +548,14 @@ class NovelGeneratorGUI: self.embedding_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.embedding_url_var.set("https://api.openai.com/v1") + self.embedding_model_name_var.set("text-embedding-ada-002") elif new_value == "Azure OpenAI": self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15") elif new_value == "DeepSeek": self.embedding_url_var.set("https://api.deepseek.com/v1") + elif new_value == "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) @@ -580,7 +584,7 @@ class NovelGeneratorGUI: column=0, font=("Microsoft YaHei", 12) ) - emb_interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Ollama", "ML Studio"] + emb_interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Gemini", "Ollama", "ML Studio"] emb_interface_dropdown = ctk.CTkOptionMenu( self.embeddings_config_tab, values=emb_interface_options, From c1d5a0458874db2e4b7bec324b2771650e3eb7cb Mon Sep 17 00:00:00 2001 From: huanshang141 <1375436987@qq.com> Date: Thu, 13 Feb 2025 23:50:13 +0800 Subject: [PATCH 088/142] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=AF=B9Azu?= =?UTF-8?q?reAI=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_adapters.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | Bin 394 -> 434 bytes ui.py | 10 +++++---- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/llm_adapters.py b/llm_adapters.py index 6437509..ab1ad16 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -5,6 +5,9 @@ from typing import Optional from langchain_openai import ChatOpenAI, AzureChatOpenAI from google import genai from google.genai import types +from azure.ai.inference import ChatCompletionsClient +from azure.core.credentials import AzureKeyCredential +from azure.ai.inference.models import SystemMessage, UserMessage def ensure_openai_base_url_has_v1(url: str) -> str: import re @@ -201,6 +204,56 @@ class MLStudioAdapter(BaseLLMAdapter): return "" return response.content +class AzureAIAdapter(BaseLLMAdapter): + """ + 适配 Azure AI Inference 接口,用于访问Azure AI服务部署的模型 + 使用 azure-ai-inference 库进行API调用 + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + import re + # 匹配形如 https://xxx.services.ai.azure.com/models/chat/completions?api-version=xxx 的URL + match = re.match(r'https://(.+?)\.services\.ai\.azure\.com(?:/models)?(?:/chat/completions)?(?:\?api-version=(.+))?', base_url) + if match: + # endpoint需要是形如 https://xxx.services.ai.azure.com/models 的格式 + self.endpoint = f"https://{match.group(1)}.services.ai.azure.com/models" + # 如果URL中包含api-version参数,使用它;否则使用默认值 + self.api_version = match.group(2) if match.group(2) else "2024-05-01-preview" + else: + raise ValueError("Invalid Azure AI base_url format. Expected format: https://.services.ai.azure.com/models/chat/completions?api-version=xxx") + + self.base_url = self.endpoint # 存储处理后的endpoint URL + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = ChatCompletionsClient( + endpoint=self.endpoint, + credential=AzureKeyCredential(self.api_key), + model=self.model_name, + temperature=self.temperature, + max_tokens=self.max_tokens, + timeout=self.timeout + ) + + def invoke(self, prompt: str) -> str: + try: + response = self._client.complete( + messages=[ + SystemMessage("You are a helpful assistant."), + UserMessage(prompt) + ] + ) + if response and response.choices: + return response.choices[0].message.content + else: + logging.warning("No response from AzureAIAdapter.") + return "" + except Exception as e: + logging.error(f"Azure AI Inference API 调用失败: {e}") + return "" + def create_llm_adapter( interface_format: str, base_url: str, @@ -220,6 +273,8 @@ def create_llm_adapter( return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif fmt == "azure openai": return AzureOpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "azure ai": + return AzureAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif fmt == "ollama": return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif fmt == "ml studio": diff --git a/requirements.txt b/requirements.txt index e9bd518d242f876ffa070eacf41d9667ff35d600..4eae4bf89df35570bc42d71be889d42b2a669f21 100644 GIT binary patch delta 48 wcmeBT-o(72i&2A@fr}xLp^BlDp@<=sK^Mr*1d^Eyc?@YlK1eK&AsI*l0RGSkw*UYD delta 7 OcmdnQ+{L`1ixB_{+5&z6 diff --git a/ui.py b/ui.py index ed88749..064d048 100644 --- a/ui.py +++ b/ui.py @@ -205,7 +205,7 @@ class NovelGeneratorGUI: # ----------------- 主Tab布局 ----------------- def build_main_tab(self): """ - 主Tab包含左侧的“本章内容”编辑框和输出日志,以及右侧的主要操作和参数设置区 + 主Tab包含左侧的"本章内容"编辑框和输出日志,以及右侧的主要操作和参数设置区 """ self.main_tab.rowconfigure(0, weight=1) self.main_tab.columnconfigure(0, weight=1) @@ -321,7 +321,7 @@ class NovelGeneratorGUI: 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) @@ -336,7 +336,7 @@ class NovelGeneratorGUI: def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): """ - 封装一个带“?”按钮的Label,用于展示提示信息。 + 封装一个带"?"按钮的Label,用于展示提示信息。 """ frame = ctk.CTkFrame(parent) frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) @@ -375,6 +375,8 @@ class NovelGeneratorGUI: self.base_url_var.set("https://api.deepseek.com/v1") elif new_value == "Gemini": self.base_url_var.set("") # Gemini 通常不需要 Base URL,可以设置为空 + elif new_value == "Azure AI": + self.base_url_var.set("https://.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) @@ -415,7 +417,7 @@ class NovelGeneratorGUI: column=0, font=("Microsoft YaHei", 12) ) - interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "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, From ee908ee8d061daaccd6974e79a57f94b25d4125d Mon Sep 17 00:00:00 2001 From: huanshang141 <1375436987@qq.com> Date: Fri, 14 Feb 2025 00:34:17 +0800 Subject: [PATCH 089/142] =?UTF-8?q?=E6=9B=B4=E6=94=B9ensure=5Fopenai=5Fbas?= =?UTF-8?q?e=5Furl=5Fhas=5Fv1=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E4=BD=BF=E7=94=A8base=20url?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_adapters.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/llm_adapters.py b/llm_adapters.py index 6437509..7b20f45 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -7,10 +7,19 @@ from google import genai from google.genai import types def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 处理base_url的规则: + 1. 如果url以#结尾,则移除#并直接使用用户提供的url + 2. 否则检查是否需要添加/v1后缀 + """ import re url = url.strip() if not url: return url + + if url.endswith('#'): + return url.rstrip('#') + if not re.search(r'/v\d+$', url): if '/v1' not in url: url = url.rstrip('/') + '/v1' From 72f20d15e5973c4f2a7be2a2200fd12fd78da53e Mon Sep 17 00:00:00 2001 From: huanshang141 <1375436987@qq.com> Date: Fri, 14 Feb 2025 00:38:05 +0800 Subject: [PATCH 090/142] =?UTF-8?q?=E6=9B=B4=E6=94=B9=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=90=8D=E5=AD=97=E4=B8=BAcheck=5Fbase=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_adapters.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/llm_adapters.py b/llm_adapters.py index 7b20f45..538d2c3 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -6,7 +6,7 @@ from langchain_openai import ChatOpenAI, AzureChatOpenAI from google import genai from google.genai import types -def ensure_openai_base_url_has_v1(url: str) -> str: +def check_base_url(url: str) -> str: """ 处理base_url的规则: 1. 如果url以#结尾,则移除#并直接使用用户提供的url @@ -37,7 +37,7 @@ class DeepSeekAdapter(BaseLLMAdapter): 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ 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 = ensure_openai_base_url_has_v1(base_url) + self.base_url = check_base_url(base_url) self.api_key = api_key self.model_name = model_name self.max_tokens = max_tokens @@ -65,7 +65,7 @@ class OpenAIAdapter(BaseLLMAdapter): 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ 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 = ensure_openai_base_url_has_v1(base_url) + self.base_url = check_base_url(base_url) self.api_key = api_key self.model_name = model_name self.max_tokens = max_tokens @@ -162,7 +162,7 @@ class OllamaAdapter(BaseLLMAdapter): Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 """ 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 = ensure_openai_base_url_has_v1(base_url) + self.base_url = check_base_url(base_url) self.api_key = api_key self.model_name = model_name self.max_tokens = max_tokens @@ -187,7 +187,7 @@ class OllamaAdapter(BaseLLMAdapter): class MLStudioAdapter(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 = ensure_openai_base_url_has_v1(base_url) + self.base_url = check_base_url(base_url) self.api_key = api_key self.model_name = model_name self.max_tokens = max_tokens From 0be413236bd9d7cfa00e76ebc643b598a48ed77d Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 16 Feb 2025 20:23:42 +0800 Subject: [PATCH 091/142] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=9B=B4=E5=85=BC=E5=AE=B9=E7=9A=84=E5=8A=9E=E6=B3=95=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0#109?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用了更具有扩展性的实现方法;但存在一个小问题: 新保存的配置不会立马生效,需要手动点击读取配置; 下次程序启动时会自动切换配置,无需手动点击读取; --- .gitignore | 1 + ui.py | 317 ++++++++++++++++++++++++++++++++++------------------- 2 files changed, 204 insertions(+), 114 deletions(-) diff --git a/.gitignore b/.gitignore index fcb067b..c1b2214 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /vectorstore /example config.json +config_test.json diff --git a/ui.py b/ui.py index 064d048..6818005 100644 --- a/ui.py +++ b/ui.py @@ -108,39 +108,79 @@ class NovelGeneratorGUI: 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=self.loaded_config.get("api_key", "")) - self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.openai.com/v1")) - self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) - self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) - self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) - self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) - self.timeout_var = ctk.IntVar(value=self.loaded_config.get("timeout", 600)) + 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=self.loaded_config.get("embedding_interface_format", "OpenAI")) - self.embedding_api_key_var = ctk.StringVar(value=self.loaded_config.get("embedding_api_key", "")) - self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "https://api.openai.com/v1")) - self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "text-embedding-ada-002")) - self.embedding_retrieval_k_var = ctk.StringVar(value=str(self.loaded_config.get("embedding_retrieval_k", 4))) + 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))) # -- 小说参数相关 -- - self.topic_default = self.loaded_config.get("topic", "") - self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - self.num_chapters_var = ctk.StringVar(value=str(self.loaded_config.get("num_chapters", 10))) - self.word_number_var = ctk.StringVar(value=str(self.loaded_config.get("word_number", 3000))) - self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - - # -- 章节参数及可选要素 -- - self.chapter_num_var = ctk.StringVar(value=str(self.loaded_config.get("chapter_num", "1"))) - self.characters_involved_var = ctk.StringVar(value=self.loaded_config.get("characters_involved", "")) - self.key_items_var = ctk.StringVar(value=self.loaded_config.get("key_items", "")) - self.scene_location_var = ctk.StringVar(value=self.loaded_config.get("scene_location", "")) - self.time_constraint_var = ctk.StringVar(value=self.loaded_config.get("time_constraint", "")) - - # 用于存储本章指导(多行) - self.user_guidance_default = self.loaded_config.get("user_guidance", "") + 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) @@ -327,10 +367,10 @@ class NovelGeneratorGUI: 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 = 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 = 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, @@ -361,22 +401,39 @@ class NovelGeneratorGUI: def build_ai_config_tab(self): def on_interface_format_changed(new_value): """ - 当切换LLM接口格式时,自动设置base_url为对应的默认值。 + 当切换LLM接口格式时,自动设置base_url为对应的默认值, + 并尝试加载该接口的已保存配置(若存在)。 """ - if new_value == "Ollama": - self.base_url_var.set("http://localhost:11434/v1") - elif new_value == "ML Studio": - self.base_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.base_url_var.set("https://api.openai.com/v1") - elif new_value == "Azure OpenAI": - self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview") - elif new_value == "DeepSeek": - self.base_url_var.set("https://api.deepseek.com/v1") - elif new_value == "Gemini": - self.base_url_var.set("") # Gemini 通常不需要 Base URL,可以设置为空 - elif new_value == "Azure AI": - self.base_url_var.set("https://.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview") + 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://.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) @@ -542,22 +599,38 @@ class NovelGeneratorGUI: def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): """ - 当切换Embedding接口格式时,自动设置embedding_url为对应的默认值。 + 当切换Embedding接口格式时,自动设置embedding_url为对应的默认值, + 并尝试加载该接口的已保存配置(若存在)。 """ - if new_value == "Ollama": - self.embedding_url_var.set("http://localhost:11434/api") - elif new_value == "ML Studio": - self.embedding_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.embedding_url_var.set("https://api.openai.com/v1") - self.embedding_model_name_var.set("text-embedding-ada-002") - elif new_value == "Azure OpenAI": - self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15") - elif new_value == "DeepSeek": - self.embedding_url_var.set("https://api.deepseek.com/v1") - elif new_value == "Gemini": - self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/") - self.embedding_model_name_var.set("models/text-embedding-004") + 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) @@ -752,7 +825,6 @@ class NovelGeneratorGUI: 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) @@ -854,71 +926,78 @@ class NovelGeneratorGUI: """ cfg = load_config(self.config_file) if cfg: - self.api_key_var.set(cfg.get("api_key", "")) - self.base_url_var.set(cfg.get("base_url", "")) - self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) - self.model_name_var.set(cfg.get("model_name", "")) - self.temperature_var.set(cfg.get("temperature", 0.7)) - self.max_tokens_var.set(cfg.get("max_tokens", 2048)) - self.timeout_var.set(cfg.get("timeout", 600)) - - self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) - self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) - self.embedding_url_var.set(cfg.get("embedding_url", "")) - self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) - self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) - - self.genre_var.set(cfg.get("genre", "")) - self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) - self.word_number_var.set(str(cfg.get("word_number", 3000))) - self.filepath_var.set(cfg.get("filepath", "")) - - topic_value = cfg.get("topic", "") + # 加载上次使用的接口类型 + 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", topic_value) - - # 新增:读取章节号、本章指导、可选元素 - self.chapter_num_var.set(str(cfg.get("chapter_num", "1"))) - - user_guidance_value = cfg.get("user_guidance", "") + 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", user_guidance_value) - - self.characters_involved_var.set(cfg.get("characters_involved", "")) - self.key_items_var.set(cfg.get("key_items", "")) - self.scene_location_var.set(cfg.get("scene_location", "")) - self.time_constraint_var.set(cfg.get("time_constraint", "")) - + 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 + 将当前界面的配置信息保存到 config.json, + 按照不同接口类型分别保存 LLM 与 Embedding 配置,确保各自独立。 """ - config_data = { + 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(), - "interface_format": self.interface_format_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_api_key": self.embedding_api_key_var.get(), - "embedding_interface_format": self.embedding_interface_format_var.get(), - "embedding_url": self.embedding_url_var.get(), - "embedding_model_name": self.embedding_model_name_var.get(), - "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), - + "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(), @@ -927,7 +1006,23 @@ class NovelGeneratorGUI: "time_constraint": self.time_constraint_var.get() } - if save_config(config_data, self.config_file): + # 读取已有配置(若存在) + 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: @@ -1151,7 +1246,6 @@ class NovelGeneratorGUI: f"当前章节字数 ({len(edited_text)}) 低于目标字数({word_number})的70%,是否要尝试扩写?" ) if ask: - # 调用 enrich_chapter_text 进行扩写 self.safe_log("正在扩写章节内容...") enriched = enrich_chapter_text( chapter_text=edited_text, @@ -1165,15 +1259,12 @@ class NovelGeneratorGUI: 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 做最终处理(更新全局摘要、角色状态、向量库等) finalize_chapter( novel_number=chap_num, word_number=word_number, @@ -1377,7 +1468,7 @@ class NovelGeneratorGUI: def save_novel_architecture(self): filepath = self.filepath_var.get().strip() if not filepath: - messagebox.showwarning("警告", "请先设置保存文件路径") + messagebox.showwarning("警告", "请先设置保存文件路径。") return content = self.setting_text.get("0.0", "end").strip() filename = os.path.join(filepath, "Novel_architecture.txt") @@ -1554,7 +1645,6 @@ class NovelGeneratorGUI: self.chapter_select_var = ctk.StringVar(value="") - # 下拉菜单(若章节超10个,则启用滚动限制) self.chapter_select_menu = ctk.CTkOptionMenu( top_frame, values=[], @@ -1580,7 +1670,6 @@ class NovelGeneratorGUI: def refresh_chapters_list(self): """ 刷新并获取当前保存路径下的所有章节txt文件,并更新下拉菜单。 - 当章节多于10个时,启用CTkOptionMenu的滚动限制。 """ filepath = self.filepath_var.get().strip() chapters_dir = os.path.join(filepath, "chapters") From ba10e6dd66f3f3c0623a4aa38dc0a4acb52115c1 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 16 Feb 2025 22:32:32 +0800 Subject: [PATCH 092/142] =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E9=80=BB=E8=BE=91=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 初步对ui.py以及novel_generator.py进行了拆分 --- .gitignore | 2 + novel_generator/__init__.py | 7 + novel_generator/architecture.py | 192 + novel_generator/blueprint.py | 169 + novel_generator/chapter.py | 216 + novel_generator/common.py | 59 + novel_generator/finalization.py | 121 + novel_generator/knowledge.py | 93 + novel_generator/vectorstore_utils.py | 228 ++ ...generator.py => novel_generator_backup.txt | 0 ui/__init__.py | 2 + ui/chapters_tab.py | 129 + ui/character_tab.py | 45 + ui/config_tab.py | 302 ++ ui/context_menu.py | 54 + ui/directory_tab.py | 45 + ui/generation_handlers.py | 362 ++ ui/helpers.py | 7 + ui/main_tab.py | 103 + ui/main_window.py | 251 ++ ui/novel_params_tab.py | 110 + ui/setting_tab.py | 45 + ui/summary_tab.py | 45 + ui.py => ui_backup.txt | 3646 ++++++++--------- 24 files changed, 4410 insertions(+), 1823 deletions(-) create mode 100644 novel_generator/__init__.py create mode 100644 novel_generator/architecture.py create mode 100644 novel_generator/blueprint.py create mode 100644 novel_generator/chapter.py create mode 100644 novel_generator/common.py create mode 100644 novel_generator/finalization.py create mode 100644 novel_generator/knowledge.py create mode 100644 novel_generator/vectorstore_utils.py rename novel_generator.py => novel_generator_backup.txt (100%) create mode 100644 ui/__init__.py create mode 100644 ui/chapters_tab.py create mode 100644 ui/character_tab.py create mode 100644 ui/config_tab.py create mode 100644 ui/context_menu.py create mode 100644 ui/directory_tab.py create mode 100644 ui/generation_handlers.py create mode 100644 ui/helpers.py create mode 100644 ui/main_tab.py create mode 100644 ui/main_window.py create mode 100644 ui/novel_params_tab.py create mode 100644 ui/setting_tab.py create mode 100644 ui/summary_tab.py rename ui.py => ui_backup.txt (97%) diff --git a/.gitignore b/.gitignore index c1b2214..b239dd5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ /example config.json config_test.json +/novel_generator/__pycache__ +/ui/__pycache__ diff --git a/novel_generator/__init__.py b/novel_generator/__init__.py new file mode 100644 index 0000000..a19f65b --- /dev/null +++ b/novel_generator/__init__.py @@ -0,0 +1,7 @@ +#novel_generator/__init__.py +from .architecture import Novel_architecture_generate +from .blueprint import Chapter_blueprint_generate +from .chapter import generate_chapter_draft, get_last_n_chapters_text +from .finalization import finalize_chapter, enrich_chapter_text +from .knowledge import import_knowledge_file +from .vectorstore_utils import clear_vector_store \ No newline at end of file diff --git a/novel_generator/architecture.py b/novel_generator/architecture.py new file mode 100644 index 0000000..d6dae79 --- /dev/null +++ b/novel_generator/architecture.py @@ -0,0 +1,192 @@ +#novel_generator/architecture.py +# -*- coding: utf-8 -*- +""" +小说总体架构生成(Novel_architecture_generate 及相关辅助函数) +""" +import os +import json +import logging +import traceback +from novel_generator.common import invoke_with_cleaning +from llm_adapters import create_llm_adapter +from prompt_definitions import ( + core_seed_prompt, + character_dynamics_prompt, + world_building_prompt, + plot_architecture_prompt, + create_character_state_prompt +) +from utils import clear_file_content, save_string_to_txt + +def load_partial_architecture_data(filepath: str) -> dict: + """ + 从 filepath 下的 partial_architecture.json 读取已有的阶段性数据。 + 如果文件不存在或无法解析,返回空 dict。 + """ + partial_file = os.path.join(filepath, "partial_architecture.json") + if not os.path.exists(partial_file): + return {} + try: + with open(partial_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data + except Exception as e: + logging.warning(f"Failed to load partial_architecture.json: {e}") + return {} + +def save_partial_architecture_data(filepath: str, data: dict): + """ + 将阶段性数据写入 partial_architecture.json。 + """ + partial_file = os.path.join(filepath, "partial_architecture.json") + try: + with open(partial_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + logging.warning(f"Failed to save partial_architecture.json: {e}") + +def Novel_architecture_generate( + interface_format: str, + api_key: str, + base_url: str, + llm_model: str, + topic: str, + genre: str, + number_of_chapters: int, + word_number: int, + filepath: str, + temperature: float = 0.7, + max_tokens: int = 2048, + timeout: int = 600 +) -> None: + """ + 依次调用: + 1. core_seed_prompt + 2. character_dynamics_prompt + 3. world_building_prompt + 4. plot_architecture_prompt + 若在中间任何一步报错且重试多次失败,则将已经生成的内容写入 partial_architecture.json 并退出; + 下次调用时可从该步骤继续。 + 最终输出 Novel_architecture.txt + + 新增: + - 在完成角色动力学设定后,依据该角色体系,使用 create_character_state_prompt 生成初始角色状态表, + 并存储到 character_state.txt,后续维护更新。 + """ + os.makedirs(filepath, exist_ok=True) + partial_data = load_partial_architecture_data(filepath) + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=llm_model, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + # Step1: 核心种子 + if "core_seed_result" not in partial_data: + logging.info("Step1: Generating core_seed_prompt (核心种子) ...") + prompt_core = core_seed_prompt.format( + topic=topic, + genre=genre, + number_of_chapters=number_of_chapters, + word_number=word_number + ) + core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) + if not core_seed_result.strip(): + logging.warning("core_seed_prompt generation failed and returned empty.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["core_seed_result"] = core_seed_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step1 already done. Skipping...") + # 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()) + character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) + if not character_dynamics_result.strip(): + logging.warning("character_dynamics_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["character_dynamics_result"] = character_dynamics_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step2 already done. Skipping...") + # 生成初始角色状态 + if "character_dynamics_result" in partial_data and "character_state_result" not in partial_data: + logging.info("Generating initial character state from character dynamics ...") + prompt_char_state_init = create_character_state_prompt.format( + character_dynamics=partial_data["character_dynamics_result"].strip() + ) + character_state_init = invoke_with_cleaning(llm_adapter, prompt_char_state_init) + if not character_state_init.strip(): + logging.warning("create_character_state_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["character_state_result"] = character_state_init + character_state_file = os.path.join(filepath, "character_state.txt") + clear_file_content(character_state_file) + save_string_to_txt(character_state_init, character_state_file) + save_partial_architecture_data(filepath, partial_data) + logging.info("Initial character state created and saved.") + # 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()) + world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) + if not world_building_result.strip(): + logging.warning("world_building_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["world_building_result"] = world_building_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step3 already done. Skipping...") + # Step4: 三幕式情节 + if "plot_arch_result" not in partial_data: + logging.info("Step4: Generating plot_architecture_prompt ...") + 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() + ) + plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) + if not plot_arch_result.strip(): + logging.warning("plot_architecture_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["plot_arch_result"] = plot_arch_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step4 already done. Skipping...") + + core_seed_result = partial_data["core_seed_result"] + character_dynamics_result = partial_data["character_dynamics_result"] + world_building_result = partial_data["world_building_result"] + plot_arch_result = partial_data["plot_arch_result"] + + final_content = ( + "#=== 0) 小说设定 ===\n" + f"主题:{topic},类型:{genre},篇幅:约{number_of_chapters}章(每章{word_number}字)\n\n" + "#=== 1) 核心种子 ===\n" + f"{core_seed_result}\n\n" + "#=== 2) 角色动力学 ===\n" + f"{character_dynamics_result}\n\n" + "#=== 3) 世界观 ===\n" + f"{world_building_result}\n\n" + "#=== 4) 三幕式情节架构 ===\n" + f"{plot_arch_result}\n" + ) + + arch_file = os.path.join(filepath, "Novel_architecture.txt") + clear_file_content(arch_file) + save_string_to_txt(final_content, arch_file) + logging.info("Novel_architecture.txt has been generated successfully.") + + partial_arch_file = os.path.join(filepath, "partial_architecture.json") + if os.path.exists(partial_arch_file): + os.remove(partial_arch_file) + logging.info("partial_architecture.json removed (all steps completed).") diff --git a/novel_generator/blueprint.py b/novel_generator/blueprint.py new file mode 100644 index 0000000..a0425df --- /dev/null +++ b/novel_generator/blueprint.py @@ -0,0 +1,169 @@ +#novel_generator/blueprint.py +# -*- coding: utf-8 -*- +""" +章节蓝图生成(Chapter_blueprint_generate 及辅助函数) +""" +import os +import re +import logging +from novel_generator.common import invoke_with_cleaning +from llm_adapters import create_llm_adapter +from prompt_definitions import chapter_blueprint_prompt, chunked_chapter_blueprint_prompt +from utils import read_file, clear_file_content, save_string_to_txt + +def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: + """ + 基于“每章约100 tokens”的粗略估算, + 再结合当前max_tokens,计算分块大小: + chunk_size = (floor(max_tokens/100/10)*10) - 10 + 并确保 chunk_size 不会小于1或大于实际章节数。 + """ + tokens_per_chapter = 100.0 + ratio = max_tokens / tokens_per_chapter + ratio_rounded_to_10 = int(ratio // 10) * 10 + chunk_size = ratio_rounded_to_10 - 10 + if chunk_size < 1: + chunk_size = 1 + if chunk_size > number_of_chapters: + chunk_size = number_of_chapters + return chunk_size + +def limit_chapter_blueprint(blueprint_text: str, limit_chapters: int = 100) -> str: + """ + 从已有章节目录中只取最近的 limit_chapters 章,以避免 prompt 超长。 + """ + pattern = r"(第\s*\d+\s*章.*?)(?=第\s*\d+\s*章|$)" + chapters = re.findall(pattern, blueprint_text, flags=re.DOTALL) + if not chapters: + return blueprint_text + if len(chapters) <= limit_chapters: + return blueprint_text + selected = chapters[-limit_chapters:] + return "\n\n".join(selected).strip() + +def Chapter_blueprint_generate( + interface_format: str, + api_key: str, + base_url: str, + llm_model: str, + filepath: str, + number_of_chapters: int, + temperature: float = 0.7, + max_tokens: int = 4096, + timeout: int = 600 +) -> None: + """ + 若 Novel_directory.txt 已存在且内容非空,则表示可能是之前的部分生成结果; + 解析其中已有的章节数,从下一个章节继续分块生成; + 对于已有章节目录,传入时仅保留最近100章目录,避免prompt过长。 + 否则: + - 若章节数 <= chunk_size,直接一次性生成 + - 若章节数 > chunk_size,进行分块生成 + 生成完成后输出至 Novel_directory.txt。 + """ + arch_file = os.path.join(filepath, "Novel_architecture.txt") + if not os.path.exists(arch_file): + logging.warning("Novel_architecture.txt not found. Please generate architecture first.") + return + + architecture_text = read_file(arch_file).strip() + if not architecture_text: + logging.warning("Novel_architecture.txt is empty.") + return + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=llm_model, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + filename_dir = os.path.join(filepath, "Novel_directory.txt") + if not os.path.exists(filename_dir): + open(filename_dir, "w", encoding="utf-8").close() + + existing_blueprint = read_file(filename_dir).strip() + chunk_size = compute_chunk_size(number_of_chapters, max_tokens) + logging.info(f"Number of chapters = {number_of_chapters}, computed chunk_size = {chunk_size}.") + + if existing_blueprint: + logging.info("Detected existing blueprint content. Will resume chunked generation from that point.") + pattern = r"第\s*(\d+)\s*章" + existing_chapter_numbers = re.findall(pattern, existing_blueprint) + existing_chapter_numbers = [int(x) for x in existing_chapter_numbers if x.isdigit()] + max_existing_chap = max(existing_chapter_numbers) if existing_chapter_numbers else 0 + logging.info(f"Existing blueprint indicates up to chapter {max_existing_chap} has been generated.") + final_blueprint = existing_blueprint + current_start = max_existing_chap + 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=limited_blueprint, + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end + ) + logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") + chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) + if not chunk_result.strip(): + logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + return + final_blueprint += "\n\n" + chunk_result.strip() + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + current_start = current_end + 1 + + logging.info("All chapters blueprint have been generated (resumed chunked).") + return + + if chunk_size >= number_of_chapters: + prompt = chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + number_of_chapters=number_of_chapters + ) + blueprint_text = invoke_with_cleaning(llm_adapter, prompt) + if not blueprint_text.strip(): + logging.warning("Chapter blueprint generation result is empty.") + return + + clear_file_content(filename_dir) + save_string_to_txt(blueprint_text, filename_dir) + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (single-shot).") + return + + logging.info("Will generate chapter blueprint in chunked mode from scratch.") + final_blueprint = "" + current_start = 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=limited_blueprint, + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end + ) + logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") + chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) + if not chunk_result.strip(): + logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + return + if final_blueprint.strip(): + final_blueprint += "\n\n" + chunk_result.strip() + else: + final_blueprint = chunk_result.strip() + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + current_start = current_end + 1 + + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") diff --git a/novel_generator/chapter.py b/novel_generator/chapter.py new file mode 100644 index 0000000..b3eddae --- /dev/null +++ b/novel_generator/chapter.py @@ -0,0 +1,216 @@ +#novel_generator/chapter.py +# -*- coding: utf-8 -*- +""" +章节草稿生成及获取历史章节文本、短期摘要等 +""" +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 +from novel_generator.common import invoke_with_cleaning +from utils import read_file, clear_file_content, save_string_to_txt +from novel_generator.vectorstore_utils import get_relevant_context_from_vector_store + +def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> list: + """ + 从目录 chapters_dir 中获取最近 n 章的文本内容,返回文本列表。 + """ + texts = [] + start_chap = max(1, current_chapter_num - n) + for c in range(start_chap, current_chapter_num): + chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") + if os.path.exists(chap_file): + text = read_file(chap_file).strip() + texts.append(text) + else: + texts.append("") + return texts + +def summarize_recent_chapters( + interface_format: str, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + max_tokens: int, + chapters_text_list: list, + timeout: int = 600 +) -> tuple: + """ + 生成 (short_summary, next_chapter_keywords) + 如果解析失败,则返回 (合并文本, "") + """ + combined_text = "\n".join(chapters_text_list).strip() + if not combined_text: + return ("", "") + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) + response_text = invoke_with_cleaning(llm_adapter, prompt) + short_summary = "" + next_chapter_keywords = "" + for line in response_text.splitlines(): + line = line.strip() + if line.startswith("短期摘要:"): + short_summary = line.replace("短期摘要:", "").strip() + elif line.startswith("下一章关键字:"): + next_chapter_keywords = line.replace("下一章关键字:", "").strip() + if not short_summary and not next_chapter_keywords: + short_summary = response_text + return (short_summary, next_chapter_keywords) + +def generate_chapter_draft( + api_key: str, + base_url: str, + model_name: str, + filepath: str, + novel_number: int, + word_number: int, + temperature: float, + user_guidance: str, + characters_involved: str, + key_items: str, + scene_location: str, + time_constraint: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + embedding_retrieval_k: int = 2, + interface_format: str = "openai", + max_tokens: int = 2048, + timeout: int = 600 +) -> str: + """ + 根据 novel_number 判断是否为第一章。 + - 若是第一章,则使用 first_chapter_draft_prompt + - 否则使用 next_chapter_draft_prompt + 最终将生成文本存入 chapters/chapter_{novel_number}.txt。 + """ + arch_file = os.path.join(filepath, "Novel_architecture.txt") + novel_architecture_text = read_file(arch_file) + directory_file = os.path.join(filepath, "Novel_directory.txt") + blueprint_text = read_file(directory_file) + global_summary_file = os.path.join(filepath, "global_summary.txt") + global_summary_text = read_file(global_summary_file) + 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"] + chapter_purpose = chapter_info["chapter_purpose"] + suspense_level = chapter_info["suspense_level"] + foreshadowing = chapter_info["foreshadowing"] + plot_twist_level = chapter_info["plot_twist_level"] + chapter_summary = chapter_info["chapter_summary"] + + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) + + if novel_number == 1: + prompt_text = first_chapter_draft_prompt.format( + novel_number=novel_number, + word_number=word_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + user_guidance=user_guidance, + novel_setting=novel_architecture_text + ) + else: + recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + short_summary, next_chapter_keywords = summarize_recent_chapters( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + max_tokens=max_tokens, + chapters_text_list=recent_3_texts, + timeout=timeout + ) + previous_chapter_excerpt = "" + for text_block in reversed(recent_3_texts): + if text_block.strip(): + if len(text_block) > 1500: + previous_chapter_excerpt = text_block[-1500:] + else: + previous_chapter_excerpt = text_block + break + from llm_adapters import create_llm_adapter # 避免循环依赖 + embedding_adapter = create_llm_adapter( + interface_format=embedding_interface_format, + base_url=embedding_url, + model_name=embedding_model_name, + api_key=embedding_api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + retrieval_query = short_summary + " " + next_chapter_keywords + relevant_context = get_relevant_context_from_vector_store( + embedding_adapter=embedding_adapter, + query=retrieval_query, + filepath=filepath, + k=embedding_retrieval_k + ) + if not relevant_context.strip(): + relevant_context = "(无检索到的上下文)" + prompt_text = next_chapter_draft_prompt.format( + novel_number=novel_number, + word_number=word_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + user_guidance=user_guidance, + novel_setting=novel_architecture_text, + global_summary=global_summary_text, + character_state=character_state_text, + context_excerpt=relevant_context, + previous_chapter_excerpt=previous_chapter_excerpt + ) + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) + if not chapter_content.strip(): + logging.warning("Generated chapter draft is empty.") + chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + clear_file_content(chapter_file) + save_string_to_txt(chapter_content, chapter_file) + logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") + return chapter_content diff --git a/novel_generator/common.py b/novel_generator/common.py new file mode 100644 index 0000000..f31fed7 --- /dev/null +++ b/novel_generator/common.py @@ -0,0 +1,59 @@ +#novel_generator/common.py +# -*- coding: utf-8 -*- +""" +通用重试、清洗、日志工具 +""" +import logging +import re +import time +import traceback + +def call_with_retry(func, max_retries=3, sleep_time=2, fallback_return=None, **kwargs): + """ + 通用的重试机制封装。 + :param func: 要执行的函数 + :param max_retries: 最大重试次数 + :param sleep_time: 重试前的等待秒数 + :param fallback_return: 如果多次重试仍失败时的返回值 + :param kwargs: 传给func的命名参数 + :return: func的结果,若失败则返回 fallback_return + """ + for attempt in range(1, max_retries + 1): + try: + return func(**kwargs) + except Exception as e: + logging.warning(f"[call_with_retry] Attempt {attempt} failed with error: {e}") + traceback.print_exc() + if attempt < max_retries: + time.sleep(sleep_time) + else: + logging.error("Max retries reached, returning fallback_return.") + return fallback_return + +def remove_think_tags(text: str) -> str: + """移除 ... 包裹的内容""" + return re.sub(r'.*?', '', text, flags=re.DOTALL) + +def debug_log(prompt: str, response_content: str): + logging.info( + f"\n[######################################### Prompt #########################################]\n{prompt}\n" + ) + logging.info( + 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) + + 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() diff --git a/novel_generator/finalization.py b/novel_generator/finalization.py new file mode 100644 index 0000000..0e6a362 --- /dev/null +++ b/novel_generator/finalization.py @@ -0,0 +1,121 @@ +#novel_generator/finalization.py +# -*- coding: utf-8 -*- +""" +定稿章节和扩写章节(finalize_chapter、enrich_chapter_text) +""" +import os +import logging +from llm_adapters import create_llm_adapter +from prompt_definitions import summary_prompt, update_character_state_prompt +from novel_generator.common import invoke_with_cleaning +from utils import read_file, clear_file_content, save_string_to_txt +from novel_generator.vectorstore_utils import update_vector_store + +def finalize_chapter( + novel_number: int, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + filepath: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + interface_format: str, + max_tokens: int, + timeout: int = 600 +): + """ + 对指定章节做最终处理:更新全局摘要、更新角色状态、插入向量库等。 + 默认无需再做扩写操作,若有需要可在外部调用 enrich_chapter_text 处理后再定稿。 + """ + chapters_dir = os.path.join(filepath, "chapters") + chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + chapter_text = read_file(chapter_file).strip() + if not chapter_text: + logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") + return + + global_summary_file = os.path.join(filepath, "global_summary.txt") + old_global_summary = read_file(global_summary_file) + character_state_file = os.path.join(filepath, "character_state.txt") + old_character_state = read_file(character_state_file) + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + prompt_summary = summary_prompt.format( + chapter_text=chapter_text, + global_summary=old_global_summary + ) + new_global_summary = invoke_with_cleaning(llm_adapter, prompt_summary) + if not new_global_summary.strip(): + new_global_summary = old_global_summary + + prompt_char_state = update_character_state_prompt.format( + chapter_text=chapter_text, + old_state=old_character_state + ) + new_char_state = invoke_with_cleaning(llm_adapter, prompt_char_state) + if not new_char_state.strip(): + new_char_state = old_character_state + + clear_file_content(global_summary_file) + save_string_to_txt(new_global_summary, global_summary_file) + clear_file_content(character_state_file) + save_string_to_txt(new_char_state, character_state_file) + + update_vector_store( + embedding_adapter=create_llm_adapter( + interface_format=embedding_interface_format, + base_url=embedding_url, + model_name=embedding_model_name, + api_key=embedding_api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ), + new_chapter=chapter_text, + filepath=filepath + ) + + logging.info(f"Chapter {novel_number} has been finalized.") + +def enrich_chapter_text( + chapter_text: str, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + interface_format: str, + max_tokens: int, + timeout: int=600 +) -> str: + """ + 对章节文本进行扩写,使其更接近 word_number 字数,保持剧情连贯。 + """ + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: +原内容: +{chapter_text} +""" + enriched_text = invoke_with_cleaning(llm_adapter, prompt) + return enriched_text if enriched_text else chapter_text diff --git a/novel_generator/knowledge.py b/novel_generator/knowledge.py new file mode 100644 index 0000000..b883455 --- /dev/null +++ b/novel_generator/knowledge.py @@ -0,0 +1,93 @@ +#novel_generator/knowledge.py +# -*- coding: utf-8 -*- +""" +知识文件导入至向量库(advanced_split_content、import_knowledge_file) +""" +import os +import logging +import re +import traceback +import nltk +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity +from utils import read_file +from novel_generator.vectorstore_utils import load_vector_store, init_vector_store +from langchain.docstore.document import Document + +def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> list: + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + sentences = nltk.sent_tokenize(content) + if not sentences: + return [] + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + current_sentences.append(sentences[i]) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + final_segments = [] + for para in merged_paragraphs: + if len(para) > max_length: + sub_segments = [] + start_idx = 0 + while start_idx < len(para): + end_idx = min(start_idx + max_length, len(para)) + segment = para[start_idx:end_idx].strip() + sub_segments.append(segment) + start_idx = end_idx + final_segments.extend(sub_segments) + else: + final_segments.append(para) + return final_segments + +def import_knowledge_file( + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + file_path: str, + filepath: str +): + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {embedding_interface_format}, 模型: {embedding_model_name}") + if not os.path.exists(file_path): + logging.warning(f"知识库文件不存在: {file_path}") + return + content = read_file(file_path) + if not content.strip(): + logging.warning("知识库文件内容为空。") + return + paragraphs = advanced_split_content(content) + from llm_adapters import create_embedding_adapter + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url if embedding_url else "http://localhost:11434/api", + embedding_model_name + ) + store = load_vector_store(embedding_adapter, filepath) + if not store: + logging.info("Vector store does not exist or load failed. Initializing a new one for knowledge import...") + store = init_vector_store(embedding_adapter, paragraphs, filepath) + if store: + logging.info("知识库文件已成功导入至向量库(新初始化)。") + else: + logging.warning("知识库导入失败,跳过。") + else: + try: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + logging.info("知识库文件已成功导入至向量库(追加模式)。") + except Exception as e: + logging.warning(f"知识库导入失败: {e}") + traceback.print_exc() diff --git a/novel_generator/vectorstore_utils.py b/novel_generator/vectorstore_utils.py new file mode 100644 index 0000000..5de3a97 --- /dev/null +++ b/novel_generator/vectorstore_utils.py @@ -0,0 +1,228 @@ +#novel_generator/vectorstore_utils.py +# -*- coding: utf-8 -*- +""" +向量库相关操作(初始化、更新、检索、清空、文本切分等) +""" +import os +import logging +import traceback +import nltk +from langchain_chroma import Chroma +from chromadb.config import Settings +from langchain.docstore.document import Document +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity +from .common import call_with_retry + +def get_vectorstore_dir(filepath: str) -> str: + """获取 vectorstore 路径""" + return os.path.join(filepath, "vectorstore") + +def clear_vector_store(filepath: str) -> bool: + """清空 清空向量库""" + import shutil + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("No vector store found to clear.") + return False + try: + shutil.rmtree(store_dir) + logging.info(f"Vector store directory '{store_dir}' removed.") + return True + except Exception as e: + logging.error(f"无法删除向量库文件夹,请关闭程序后手动删除 {store_dir}。\n {str(e)}") + traceback.print_exc() + return False + +def init_vector_store(embedding_adapter, texts, filepath: str): + """ + 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 + 如果Embedding失败,则返回 None,不中断任务。 + """ + from langchain.embeddings.base import Embeddings as LCEmbeddings + + store_dir = get_vectorstore_dir(filepath) + os.makedirs(store_dir, exist_ok=True) + documents = [Document(page_content=str(t)) for t in texts] + + try: + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, texts): + return call_with_retry( + func=embedding_adapter.embed_documents, + max_retries=3, + fallback_return=[], + texts=texts + ) + def embed_query(self, query: str): + res = call_with_retry( + func=embedding_adapter.embed_query, + max_retries=3, + fallback_return=[], + query=query + ) + return res + + chroma_embedding = LCEmbeddingWrapper() + vectorstore = Chroma.from_documents( + documents, + embedding=chroma_embedding, + persist_directory=store_dir, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + return vectorstore + except Exception as e: + logging.warning(f"Init vector store failed: {e}") + traceback.print_exc() + return None + +def load_vector_store(embedding_adapter, filepath: str): + """ + 读取已存在的 Chroma 向量库。若不存在则返回 None。 + 如果加载失败(embedding 或IO问题),则返回 None。 + """ + from langchain.embeddings.base import Embeddings as LCEmbeddings + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("Vector store not found. Will return None.") + return None + + try: + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, texts): + return call_with_retry( + func=embedding_adapter.embed_documents, + max_retries=3, + fallback_return=[], + texts=texts + ) + def embed_query(self, query: str): + res = call_with_retry( + func=embedding_adapter.embed_query, + max_retries=3, + fallback_return=[], + query=query + ) + return res + + chroma_embedding = LCEmbeddingWrapper() + return Chroma( + persist_directory=store_dir, + embedding_function=chroma_embedding, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + except Exception as e: + logging.warning(f"Failed to load vector store: {e}") + traceback.print_exc() + return None + +def split_by_length(text: str, max_length: int = 500): + """按照 max_length 切分文本""" + segments = [] + start_idx = 0 + while start_idx < len(text): + end_idx = min(start_idx + max_length, len(text)) + segment = text[start_idx:end_idx] + segments.append(segment.strip()) + start_idx = end_idx + return segments + +def split_text_for_vectorstore(chapter_text: str, max_length: int = 500, similarity_threshold: float = 0.7): + """ + 对新的章节文本进行分段后,再用于存入向量库。 + 先句子切分 -> 语义相似度合并 -> 再按 max_length 切分。 + """ + if not chapter_text.strip(): + return [] + + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + sentences = nltk.sent_tokenize(chapter_text) + if not sentences: + return [] + + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + current_sentences.append(sentences[i]) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + + final_segments = [] + for para in merged_paragraphs: + if len(para) > max_length: + sub_segments = split_by_length(para, max_length=max_length) + final_segments.extend(sub_segments) + else: + final_segments.append(para) + + return final_segments + +def update_vector_store(embedding_adapter, new_chapter: str, filepath: str): + """ + 将最新章节文本插入到向量库中。 + 若库不存在则初始化;若初始化/更新失败,则跳过。 + """ + from utils import read_file, clear_file_content, save_string_to_txt + splitted_texts = split_text_for_vectorstore(new_chapter) + if not splitted_texts: + logging.warning("No valid text to insert into vector store. Skipping.") + return + + store = load_vector_store(embedding_adapter, filepath) + if not store: + logging.info("Vector store does not exist or failed to load. Initializing a new one for new chapter...") + store = init_vector_store(embedding_adapter, splitted_texts, filepath) + if not store: + logging.warning("Init vector store failed, skip embedding.") + else: + logging.info("New vector store created successfully.") + return + + try: + docs = [Document(page_content=str(t)) for t in splitted_texts] + store.add_documents(docs) + logging.info("Vector store updated with the new chapter splitted segments.") + except Exception as e: + logging.warning(f"Failed to update vector store: {e}") + traceback.print_exc() + +def get_relevant_context_from_vector_store(embedding_adapter, query: str, filepath: str, k: int = 2) -> str: + """ + 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 + 如果向量库加载/检索失败,则返回空字符串。 + 最终只返回最多2000字符的检索片段。 + """ + store = load_vector_store(embedding_adapter, filepath) + if not store: + logging.info("No vector store found or load failed. Returning empty context.") + return "" + + try: + docs = store.similarity_search(query, k=k) + if not docs: + logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") + return "" + combined = "\n".join([d.page_content for d in docs]) + if len(combined) > 2000: + combined = combined[:2000] + return combined + except Exception as e: + logging.warning(f"Similarity search failed: {e}") + traceback.print_exc() + return "" diff --git a/novel_generator.py b/novel_generator_backup.txt similarity index 100% rename from novel_generator.py rename to novel_generator_backup.txt diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..bd11ca3 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,2 @@ +# ui/__init__.py +from .main_window import NovelGeneratorGUI diff --git a/ui/chapters_tab.py b/ui/chapters_tab.py new file mode 100644 index 0000000..5310fbf --- /dev/null +++ b/ui/chapters_tab.py @@ -0,0 +1,129 @@ +# ui/chapters_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from ui.context_menu import TextWidgetContextMenu +from utils import read_file, save_string_to_txt, clear_file_content + +def build_chapters_tab(self): + self.chapters_view_tab = self.tabview.add("Chapters Manage") + 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 = [] + refresh_chapters_list(self) + +def refresh_chapters_list(self): + 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]) + load_chapter_content(self, self.chapters_list[0]) + else: + self.chapter_select_var.set("") + self.chapter_view_text.delete("0.0", "end") + +def on_chapter_selected(self, value): + load_chapter_content(self, 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]) + load_chapter_content(self, 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]) + load_chapter_content(self, self.chapters_list[new_idx]) + else: + messagebox.showinfo("提示", "已经是最后一章了。") diff --git a/ui/character_tab.py b/ui/character_tab.py new file mode 100644 index 0000000..2a87670 --- /dev/null +++ b/ui/character_tab.py @@ -0,0 +1,45 @@ +# ui/character_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_character_tab(self): + self.character_tab = self.tabview.add("Character State") + 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 的修改。") diff --git a/ui/config_tab.py b/ui/config_tab.py new file mode 100644 index 0000000..3d724ac --- /dev/null +++ b/ui/config_tab.py @@ -0,0 +1,302 @@ +# ui/config_tab.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from tkinter import messagebox +from config_manager import load_config, save_config +from tooltips import tooltips + +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: messagebox.showinfo("参数说明", tooltips.get(tooltip_key, "暂无说明")) + ) + btn.pack(side="left", padx=3) + + return frame + +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") + + build_ai_config_tab(self) + build_embeddings_config_tab(self) + + # 底部的"保存配置"和"加载配置"按钮 + 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 build_ai_config_tab(self): + def on_interface_format_changed(new_value): + 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: + if new_value == "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.base_url_var.set("https://api.openai.com/v1") + elif new_value == "Azure OpenAI": + self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview") + elif new_value == "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") + elif new_value == "Gemini": + self.base_url_var.set("") + elif new_value == "Azure AI": + self.base_url_var.set("https://.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 + create_label_with_help(self, 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 + create_label_with_help(self, 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) 接口格式 + 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_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 + create_label_with_help(self, 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 + create_label_with_help(self, 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 + create_label_with_help(self, 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) + create_label_with_help(self, 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") + +def build_embeddings_config_tab(self): + def on_embedding_interface_changed(new_value): + self.embedding_interface_format_var.set(new_value) + 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: + if new_value == "Ollama": + self.embedding_url_var.set("http://localhost:11434/api") + elif new_value == "ML Studio": + self.embedding_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.embedding_url_var.set("https://api.openai.com/v1") + self.embedding_model_name_var.set("text-embedding-ada-002") + elif new_value == "Azure OpenAI": + self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15") + elif new_value == "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + elif new_value == "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 + create_label_with_help(self, 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 接口格式 + 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"] + 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 + create_label_with_help(self, 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 + create_label_with_help(self, 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 + create_label_with_help(self, 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 load_config_btn(self): + 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_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_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): + current_llm_interface = self.interface_format_var.get().strip() + current_embedding_interface = self.embedding_interface_format_var.get().strip() + 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_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("错误", "保存配置失败。") diff --git a/ui/context_menu.py b/ui/context_menu.py new file mode 100644 index 0000000..438081c --- /dev/null +++ b/ui/context_menu.py @@ -0,0 +1,54 @@ +# ui/context_menu.py +# -*- coding: utf-8 -*- +import tkinter as tk +import customtkinter as ctk + +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("", 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") diff --git a/ui/directory_tab.py b/ui/directory_tab.py new file mode 100644 index 0000000..09b7aba --- /dev/null +++ b/ui/directory_tab.py @@ -0,0 +1,45 @@ +# ui/directory_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_directory_tab(self): + self.directory_tab = self.tabview.add("Chapter Blueprint") + 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 的修改。") diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py new file mode 100644 index 0000000..c798aae --- /dev/null +++ b/ui/generation_handlers.py @@ -0,0 +1,362 @@ +# ui/generation_handlers.py +# -*- coding: utf-8 -*- +import os +import threading +import tkinter as tk +from tkinter import messagebox +import customtkinter as ctk +import traceback +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, + enrich_chapter_text +) +from consistency_checker import check_consistency + +def generate_novel_architecture_ui(self): + 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): + 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): + 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 finalize_chapter_ui(self): + 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() + + 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 = tk.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): + 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") diff --git a/ui/helpers.py b/ui/helpers.py new file mode 100644 index 0000000..870549e --- /dev/null +++ b/ui/helpers.py @@ -0,0 +1,7 @@ +# ui/helpers.py +# -*- coding: utf-8 -*- +import logging +import traceback + +def log_error(message: str): + logging.error(f"{message}\n{traceback.format_exc()}") diff --git a/ui/main_tab.py b/ui/main_tab.py new file mode 100644 index 0000000..2ca6add --- /dev/null +++ b/ui/main_tab.py @@ -0,0 +1,103 @@ +# ui/main_tab.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from tkinter import messagebox +from ui.context_menu import TextWidgetContextMenu + +def build_main_tab(self): + """ + 主Tab包含左侧的"本章内容"编辑框和输出日志,以及右侧的主要操作和参数设置区 + """ + self.main_tab = self.tabview.add("Main Functions") + 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) + + build_left_layout(self) + build_right_layout(self) + +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) + # 其余部分将在 config_tab.py 与 novel_params_tab.py 中构建 diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..382223e --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,251 @@ +# ui/main_window.py +# -*- coding: utf-8 -*- +import os +import threading +import logging +import traceback +import customtkinter as ctk +import tkinter as tk +from tkinter import filedialog, messagebox + +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 tooltips import tooltips + +from ui.context_menu import TextWidgetContextMenu +from ui.main_tab import build_main_tab, build_left_layout, build_right_layout +from ui.config_tab import build_config_tabview, load_config_btn, save_config_btn +from ui.novel_params_tab import build_novel_params_area, build_optional_buttons_area +from ui.generation_handlers import ( + generate_novel_architecture_ui, + generate_chapter_blueprint_ui, + generate_chapter_draft_ui, + finalize_chapter_ui, + do_consistency_check, + import_knowledge_handler, + clear_vectorstore_handler, + show_plot_arcs_ui +) +from ui.setting_tab import build_setting_tab, load_novel_architecture, save_novel_architecture +from ui.directory_tab import build_directory_tab, load_chapter_blueprint, save_chapter_blueprint +from ui.character_tab import build_character_tab, load_character_state, save_character_state +from ui.summary_tab import build_summary_tab, load_global_summary, save_global_summary +from ui.chapters_tab import build_chapters_tab, refresh_chapters_list, on_chapter_selected, load_chapter_content, save_current_chapter, prev_chapter, next_chapter + +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) + + # 创建各个标签页 + build_main_tab(self) + build_config_tabview(self) + build_novel_params_area(self, start_row=1) + build_optional_buttons_area(self, start_row=2) + build_setting_tab(self) + build_directory_tab(self) + build_character_tab(self) + build_summary_tab(self) + build_chapters_tab(self) + + # ----------------- 通用辅助函数 ----------------- + def show_tooltip(self, key: str): + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) + + def safe_get_int(self, var, default=1): + 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) + + 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 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 + ) + + def browse_folder(self): + selected_dir = filedialog.askdirectory() + if selected_dir: + self.filepath_var.set(selected_dir) + + # ----------------- 将导入的各模块函数直接赋给类方法 ----------------- + generate_novel_architecture_ui = generate_novel_architecture_ui + generate_chapter_blueprint_ui = generate_chapter_blueprint_ui + generate_chapter_draft_ui = generate_chapter_draft_ui + finalize_chapter_ui = finalize_chapter_ui + do_consistency_check = do_consistency_check + import_knowledge_handler = import_knowledge_handler + clear_vectorstore_handler = clear_vectorstore_handler + show_plot_arcs_ui = show_plot_arcs_ui + load_config_btn = load_config_btn + save_config_btn = save_config_btn + load_novel_architecture = load_novel_architecture + save_novel_architecture = save_novel_architecture + load_chapter_blueprint = load_chapter_blueprint + save_chapter_blueprint = save_chapter_blueprint + load_character_state = load_character_state + save_character_state = save_character_state + load_global_summary = load_global_summary + save_global_summary = save_global_summary + refresh_chapters_list = refresh_chapters_list + on_chapter_selected = on_chapter_selected + save_current_chapter = save_current_chapter + prev_chapter = prev_chapter + next_chapter = next_chapter + test_llm_config = test_llm_config + test_embedding_config = test_embedding_config + browse_folder = browse_folder diff --git a/ui/novel_params_tab.py b/ui/novel_params_tab.py new file mode 100644 index 0000000..28f57f3 --- /dev/null +++ b/ui/novel_params_tab.py @@ -0,0 +1,110 @@ +# ui/novel_params_tab.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from tkinter import filedialog, messagebox +from ui.context_menu import TextWidgetContextMenu + +def build_novel_params_area(self, start_row=1): + 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) + create_label_with_help_for_novel_params(self, 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 hasattr(self, 'topic_default') and self.topic_default: + self.topic_text.insert("0.0", self.topic_default) + + # 2) 类型(Genre) + create_label_with_help_for_novel_params(self, 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 + create_label_with_help_for_novel_params(self, 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 + create_label_with_help_for_novel_params(self, 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 + create_label_with_help_for_novel_params(self, 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 + 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") + if hasattr(self, 'user_guidance_default') and self.user_guidance_default: + self.user_guide_text.insert("0.0", self.user_guidance_default) + + # 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") + 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)) + key_items_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + create_label_with_help_for_novel_params(self, 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 + create_label_with_help_for_novel_params(self, 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 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) + 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: messagebox.showinfo("参数说明", "暂无说明")) + btn.pack(side="left", padx=3) + return frame diff --git a/ui/setting_tab.py b/ui/setting_tab.py new file mode 100644 index 0000000..11599c1 --- /dev/null +++ b/ui/setting_tab.py @@ -0,0 +1,45 @@ +# ui/setting_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_setting_tab(self): + self.setting_tab = self.tabview.add("Novel Architecture") + 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 的修改。") diff --git a/ui/summary_tab.py b/ui/summary_tab.py new file mode 100644 index 0000000..3f7a999 --- /dev/null +++ b/ui/summary_tab.py @@ -0,0 +1,45 @@ +# ui/summary_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_summary_tab(self): + self.summary_tab = self.tabview.add("Global Summary") + 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 的修改。") diff --git a/ui.py b/ui_backup.txt similarity index 97% rename from ui.py rename to ui_backup.txt index 6818005..80836f4 100644 --- a/ui.py +++ b/ui_backup.txt @@ -1,1823 +1,1823 @@ -# 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("", 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://.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() +# 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("", 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://.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() From f5691f268f6b5fe3849568ac81f1f8a2748404c9 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 16 Feb 2025 23:10:32 +0800 Subject: [PATCH 093/142] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dembedding=E5=A4=B1?= =?UTF-8?q?=E8=AF=AF=E5=AF=BC=E5=85=A5=E6=88=90llm=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator/chapter.py | 85 +++++++++++++++++++++++++++------ novel_generator/finalization.py | 14 +++--- novel_generator/knowledge.py | 2 +- ui/generation_handlers.py | 68 +++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 26 deletions(-) diff --git a/novel_generator/chapter.py b/novel_generator/chapter.py index b3eddae..9d1a6a4 100644 --- a/novel_generator/chapter.py +++ b/novel_generator/chapter.py @@ -1,4 +1,4 @@ -#novel_generator/chapter.py +# novel_generator/chapter.py # -*- coding: utf-8 -*- """ 章节草稿生成及获取历史章节文本、短期摘要等 @@ -68,7 +68,7 @@ def summarize_recent_chapters( short_summary = response_text return (short_summary, next_chapter_keywords) -def generate_chapter_draft( +def build_chapter_prompt( api_key: str, base_url: str, model_name: str, @@ -91,10 +91,7 @@ def generate_chapter_draft( timeout: int = 600 ) -> str: """ - 根据 novel_number 判断是否为第一章。 - - 若是第一章,则使用 first_chapter_draft_prompt - - 否则使用 next_chapter_draft_prompt - 最终将生成文本存入 chapters/chapter_{novel_number}.txt。 + 构造当前章节的请求提示词,不调用 LLM,仅返回构造好的提示词字符串。 """ arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -155,15 +152,12 @@ def generate_chapter_draft( else: previous_chapter_excerpt = text_block break - from llm_adapters import create_llm_adapter # 避免循环依赖 - embedding_adapter = create_llm_adapter( - interface_format=embedding_interface_format, - base_url=embedding_url, - model_name=embedding_model_name, - api_key=embedding_api_key, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout + from embedding_adapters import create_embedding_adapter # 避免循环依赖 + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name ) retrieval_query = short_summary + " " + next_chapter_keywords relevant_context = get_relevant_context_from_vector_store( @@ -195,6 +189,67 @@ def generate_chapter_draft( context_excerpt=relevant_context, previous_chapter_excerpt=previous_chapter_excerpt ) + return prompt_text + +def generate_chapter_draft( + api_key: str, + base_url: str, + model_name: str, + filepath: str, + novel_number: int, + word_number: int, + temperature: float, + user_guidance: str, + characters_involved: str, + key_items: str, + scene_location: str, + time_constraint: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + embedding_retrieval_k: int = 2, + interface_format: str = "openai", + max_tokens: int = 2048, + timeout: int = 600, + custom_prompt_text: str = None # 新增参数,若不为 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, + base_url=base_url, + model_name=model_name, + filepath=filepath, + novel_number=novel_number, + word_number=word_number, + temperature=temperature, + user_guidance=user_guidance, + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + 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_retrieval_k, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout + ) + else: + prompt_text = custom_prompt_text + + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) llm_adapter = create_llm_adapter( interface_format=interface_format, diff --git a/novel_generator/finalization.py b/novel_generator/finalization.py index 0e6a362..a86c5ed 100644 --- a/novel_generator/finalization.py +++ b/novel_generator/finalization.py @@ -6,6 +6,7 @@ import os import logging from llm_adapters import create_llm_adapter +from embedding_adapters import create_embedding_adapter from prompt_definitions import summary_prompt, update_character_state_prompt from novel_generator.common import invoke_with_cleaning from utils import read_file, clear_file_content, save_string_to_txt @@ -75,14 +76,11 @@ def finalize_chapter( save_string_to_txt(new_char_state, character_state_file) update_vector_store( - embedding_adapter=create_llm_adapter( - interface_format=embedding_interface_format, - base_url=embedding_url, - model_name=embedding_model_name, - api_key=embedding_api_key, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout + embedding_adapter=create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name ), new_chapter=chapter_text, filepath=filepath diff --git a/novel_generator/knowledge.py b/novel_generator/knowledge.py index b883455..8226c7a 100644 --- a/novel_generator/knowledge.py +++ b/novel_generator/knowledge.py @@ -68,7 +68,7 @@ def import_knowledge_file( logging.warning("知识库文件内容为空。") return paragraphs = advanced_split_content(content) - from llm_adapters import create_embedding_adapter + from embedding_adapters import create_embedding_adapter embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index c798aae..681e2e9 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -131,8 +131,11 @@ def generate_chapter_draft_ui(self): 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( + self.safe_log(f"生成第{chap_num}章草稿:准备生成请求提示词...") + + # 调用新添加的 build_chapter_prompt 函数构造初始提示词 + from novel_generator.chapter import build_chapter_prompt + prompt_text = build_chapter_prompt( api_key=api_key, base_url=base_url, model_name=model_name, @@ -154,6 +157,67 @@ def generate_chapter_draft_ui(self): max_tokens=max_tokens, timeout=timeout_val ) + + # 弹出可编辑提示词对话框,等待用户确认或取消 + result = {"prompt": None} + event = threading.Event() + + def create_dialog(): + dialog = ctk.CTkToplevel(self.master) + dialog.title("当前章节请求提示词(可编辑)") + 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) + button_frame = ctk.CTkFrame(dialog) + button_frame.pack(pady=10) + def on_confirm(): + result["prompt"] = text_box.get("1.0", "end").strip() + dialog.destroy() + event.set() + def on_cancel(): + result["prompt"] = None + dialog.destroy() + event.set() + btn_confirm = ctk.CTkButton(button_frame, text="确认使用", font=("Microsoft YaHei", 12), command=on_confirm) + btn_confirm.pack(side="left", padx=10) + btn_cancel = ctk.CTkButton(button_frame, text="取消请求", font=("Microsoft YaHei", 12), command=on_cancel) + btn_cancel.pack(side="left", padx=10) + # 若用户直接关闭弹窗,则调用 on_cancel 处理 + dialog.protocol("WM_DELETE_WINDOW", on_cancel) + dialog.grab_set() + self.master.after(0, create_dialog) + event.wait() # 等待用户操作完成 + edited_prompt = result["prompt"] + if edited_prompt is None: + self.safe_log("❌ 用户取消了草稿生成请求。") + return + + self.safe_log("开始生成章节草稿...") + from novel_generator.chapter import generate_chapter_draft + 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, + custom_prompt_text=edited_prompt # 使用用户编辑后的提示词 + ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) From 939b613342f4b71caecf64da9a87c0fb1950b3a2 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 16 Feb 2025 23:12:47 +0800 Subject: [PATCH 094/142] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator_backup.txt | 1173 ----------------------- ui_backup.txt | 1823 ------------------------------------ 2 files changed, 2996 deletions(-) delete mode 100644 novel_generator_backup.txt delete mode 100644 ui_backup.txt diff --git a/novel_generator_backup.txt b/novel_generator_backup.txt deleted file mode 100644 index 1ec5062..0000000 --- a/novel_generator_backup.txt +++ /dev/null @@ -1,1173 +0,0 @@ -# novel_generator.py -# -*- coding: utf-8 -*- -import os -import logging -import re -import time -import traceback -import json -from typing import List, Optional, Tuple - -from langchain_chroma import Chroma -from chromadb.config import Settings -from langchain.docstore.document import Document - -# nltk、sentence_transformers 及文本处理相关 -import nltk -from sentence_transformers import SentenceTransformer -from sklearn.metrics.pairwise import cosine_similarity - -# 工具函数 -from utils import ( - read_file, append_text_to_file, clear_file_content, - save_string_to_txt -) - -# prompt模板 -from prompt_definitions import ( - core_seed_prompt, - character_dynamics_prompt, - world_building_prompt, - plot_architecture_prompt, - chapter_blueprint_prompt, - chunked_chapter_blueprint_prompt, - summary_prompt, - update_character_state_prompt, - first_chapter_draft_prompt, - next_chapter_draft_prompt, - summarize_recent_chapters_prompt, - create_character_state_prompt -) - -# 章节目录解析 -from chapter_directory_parser import get_chapter_info_from_blueprint - -from llm_adapters import create_llm_adapter -from embedding_adapters import create_embedding_adapter - -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - -# ============ 通用的重试封装 ============ - -def call_with_retry(func, max_retries=3, sleep_time=2, fallback_return=None, **kwargs): - """ - 通用的重试机制封装。 - :param func: 要执行的函数 - :param max_retries: 最大重试次数 - :param sleep_time: 重试前的等待秒数 - :param fallback_return: 如果多次重试仍失败时的返回值 - :param kwargs: 传给func的命名参数 - :return: func的结果,若失败则返回 fallback_return - """ - for attempt in range(1, max_retries + 1): - try: - return func(**kwargs) - except Exception as e: - logging.warning(f"[call_with_retry] Attempt {attempt} failed with error: {e}") - traceback.print_exc() - if attempt < max_retries: - time.sleep(sleep_time) - else: - logging.error("Max retries reached, returning fallback_return.") - return fallback_return - - -# ============ 工具函数 ============ - -def remove_think_tags(text: str) -> str: - """移除 ... 包裹的内容""" - return re.sub(r'.*?', '', text, flags=re.DOTALL) - -def debug_log(prompt: str, response_content: str): - logging.info( - f"\n[######################################### Prompt #########################################]\n{prompt}\n" - ) - logging.info( - 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) - - 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() - - -# ============ 获取 vectorstore 路径 ============ - -def get_vectorstore_dir(filepath: str) -> str: - return os.path.join(filepath, "vectorstore") - - -# ============ 清空向量库 ============ - -def clear_vector_store(filepath: str) -> bool: - import shutil - store_dir = get_vectorstore_dir(filepath) - if not os.path.exists(store_dir): - logging.info("No vector store found to clear.") - return False - try: - shutil.rmtree(store_dir) - logging.info(f"Vector store directory '{store_dir}' removed.") - return True - except Exception as e: - logging.error(f"无法删除向量库文件夹,请关闭程序后手动删除 {store_dir}。\n {str(e)}") - traceback.print_exc() - return False - - -# ============ 根据 embedding 接口创建/加载 Chroma ============ - -def init_vector_store( - embedding_adapter, - texts: List[str], - filepath: str -) -> Optional[Chroma]: - """ - 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 - 如果Embedding失败,则返回 None,不中断任务。 - """ - from langchain.embeddings.base import Embeddings as LCEmbeddings - - store_dir = get_vectorstore_dir(filepath) - os.makedirs(store_dir, exist_ok=True) - - documents = [Document(page_content=str(t)) for t in texts] - - try: - class LCEmbeddingWrapper(LCEmbeddings): - def embed_documents(self, texts: List[str]) -> List[List[float]]: - return call_with_retry( - func=embedding_adapter.embed_documents, - max_retries=3, - fallback_return=[], - texts=texts - ) - - def embed_query(self, query: str) -> List[float]: - res = call_with_retry( - func=embedding_adapter.embed_query, - max_retries=3, - fallback_return=[], - query=query - ) - return res - - chroma_embedding = LCEmbeddingWrapper() - - vectorstore = Chroma.from_documents( - documents, - embedding=chroma_embedding, - persist_directory=store_dir, - client_settings=Settings(anonymized_telemetry=False), - collection_name="novel_collection" - ) - return vectorstore - except Exception as e: - logging.warning(f"Init vector store failed: {e}") - traceback.print_exc() - return None - -def load_vector_store( - embedding_adapter, - filepath: str -) -> Optional[Chroma]: - """ - 读取已存在的 Chroma 向量库。若不存在则返回 None。 - 如果加载失败(embedding 或IO问题),则返回 None。 - """ - store_dir = get_vectorstore_dir(filepath) - if not os.path.exists(store_dir): - logging.info("Vector store not found. Will return None.") - return None - - from langchain.embeddings.base import Embeddings as LCEmbeddings - - try: - class LCEmbeddingWrapper(LCEmbeddings): - def embed_documents(self, texts: List[str]) -> List[List[float]]: - return call_with_retry( - func=embedding_adapter.embed_documents, - max_retries=3, - fallback_return=[], - texts=texts - ) - - def embed_query(self, query: str) -> List[float]: - res = call_with_retry( - func=embedding_adapter.embed_query, - max_retries=3, - fallback_return=[], - query=query - ) - return res - - chroma_embedding = LCEmbeddingWrapper() - - return Chroma( - persist_directory=store_dir, - embedding_function=chroma_embedding, - client_settings=Settings(anonymized_telemetry=False), - collection_name="novel_collection" - ) - except Exception as e: - logging.warning(f"Failed to load vector store: {e}") - traceback.print_exc() - return None - - -# ============ 文本分段工具 ============ - -def split_by_length(text: str, max_length: int = 500) -> List[str]: - segments = [] - start_idx = 0 - while start_idx < len(text): - end_idx = min(start_idx + max_length, len(text)) - segment = text[start_idx:end_idx] - segments.append(segment.strip()) - start_idx = end_idx - return segments - -def split_text_for_vectorstore(chapter_text: str, - max_length: int = 500, - similarity_threshold: float = 0.7) -> List[str]: - """ - 对新的章节文本进行分段后,再用于存入向量库。 - 先句子切分 -> 语义相似度合并 -> 再按 max_length 切分。 - """ - if not chapter_text.strip(): - return [] - - nltk.download('punkt', quiet=True) - nltk.download('punkt_tab', quiet=True) - sentences = nltk.sent_tokenize(chapter_text) - if not sentences: - return [] - - model = SentenceTransformer('paraphrase-MiniLM-L6-v2') - embeddings = model.encode(sentences) - - merged_paragraphs = [] - current_sentences = [sentences[0]] - current_embedding = embeddings[0] - - for i in range(1, len(sentences)): - sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] - if sim >= similarity_threshold: - current_sentences.append(sentences[i]) - current_embedding = (current_embedding + embeddings[i]) / 2.0 - else: - merged_paragraphs.append(" ".join(current_sentences)) - current_sentences = [sentences[i]] - current_embedding = embeddings[i] - - if current_sentences: - merged_paragraphs.append(" ".join(current_sentences)) - - final_segments = [] - for para in merged_paragraphs: - if len(para) > max_length: - sub_segments = split_by_length(para, max_length=max_length) - final_segments.extend(sub_segments) - else: - final_segments.append(para) - - return final_segments - - -# ============ 更新向量库 ============ - -def update_vector_store( - embedding_adapter, - new_chapter: str, - filepath: str -): - """ - 将最新章节文本插入到向量库中。 - 若库不存在则初始化;若初始化/更新失败,则跳过。 - """ - splitted_texts = split_text_for_vectorstore(new_chapter) - if not splitted_texts: - logging.warning("No valid text to insert into vector store. Skipping.") - return - - store = load_vector_store(embedding_adapter, filepath) - if not store: - logging.info("Vector store does not exist or failed to load. Initializing a new one for new chapter...") - store = init_vector_store(embedding_adapter, splitted_texts, filepath) - if not store: - logging.warning("Init vector store failed, skip embedding.") - else: - logging.info("New vector store created successfully.") - return - - # 如果已有store,则直接往里插入 - try: - docs = [Document(page_content=str(t)) for t in splitted_texts] - store.add_documents(docs) - logging.info("Vector store updated with the new chapter splitted segments.") - except Exception as e: - logging.warning(f"Failed to update vector store: {e}") - traceback.print_exc() - - -# ============ 向量检索上下文 ============ - -def get_relevant_context_from_vector_store( - embedding_adapter, - query: str, - filepath: str, - k: int = 2 -) -> str: - """ - 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - 如果向量库加载/检索失败,则返回空字符串。 - 最终只返回最多2000字符的检索片段。 - """ - store = load_vector_store(embedding_adapter, filepath) - if not store: - logging.info("No vector store found or load failed. Returning empty context.") - return "" - - try: - docs = store.similarity_search(query, k=k) - if not docs: - logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") - return "" - combined = "\n".join([d.page_content for d in docs]) - # 限制长度最多2000字符 - if len(combined) > 2000: - combined = combined[:2000] - return combined - except Exception as e: - logging.warning(f"Similarity search failed: {e}") - traceback.print_exc() - return "" - - -# ============ 从目录中获取最近 n 章文本 ============ - -def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: - texts = [] - start_chap = max(1, current_chapter_num - n) - for c in range(start_chap, current_chapter_num): - chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") - if os.path.exists(chap_file): - text = read_file(chap_file).strip() - texts.append(text) - else: - texts.append("") - return texts - - -# ============ 提炼(短期摘要, 下一章关键字) ============ - -def summarize_recent_chapters( - interface_format: str, - api_key: str, - base_url: str, - model_name: str, - temperature: float, - max_tokens: int, - chapters_text_list: List[str], - timeout: int = 600 -) -> Tuple[str, str]: - """ - 生成 (short_summary, next_chapter_keywords) - 如果解析失败,则返回 (合并文本, "") - """ - combined_text = "\n".join(chapters_text_list).strip() - if not combined_text: - return ("", "") - - llm_adapter = create_llm_adapter( - interface_format=interface_format, - base_url=base_url, - model_name=model_name, - api_key=api_key, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout - ) - - prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) - response_text = invoke_with_cleaning(llm_adapter, prompt) - - short_summary = "" - next_chapter_keywords = "" - - for line in response_text.splitlines(): - line = line.strip() - if line.startswith("短期摘要:"): - short_summary = line.replace("短期摘要:", "").strip() - elif line.startswith("下一章关键字:"): - next_chapter_keywords = line.replace("下一章关键字:", "").strip() - - if not short_summary and not next_chapter_keywords: - short_summary = response_text - - return (short_summary, next_chapter_keywords) - - -# ============ 持久化:情节架构(partial_architecture.json) ============ - -def load_partial_architecture_data(filepath: str) -> dict: - """ - 从 filepath 下的 partial_architecture.json 读取已有的阶段性数据。 - 如果文件不存在或无法解析,返回空 dict。 - """ - partial_file = os.path.join(filepath, "partial_architecture.json") - if not os.path.exists(partial_file): - return {} - - try: - with open(partial_file, "r", encoding="utf-8") as f: - data = json.load(f) - return data - except Exception as e: - logging.warning(f"Failed to load partial_architecture.json: {e}") - return {} - -def save_partial_architecture_data(filepath: str, data: dict): - """ - 将阶段性数据写入 partial_architecture.json。 - """ - partial_file = os.path.join(filepath, "partial_architecture.json") - try: - with open(partial_file, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - except Exception as e: - logging.warning(f"Failed to save partial_architecture.json: {e}") - - -# ============ 1) 生成总体架构 ============ - -def Novel_architecture_generate( - interface_format: str, - api_key: str, - base_url: str, - llm_model: str, - topic: str, - genre: str, - number_of_chapters: int, - word_number: int, - filepath: str, - temperature: float = 0.7, - max_tokens: int = 2048, - timeout: int = 600 -) -> None: - """ - 依次调用: - 1. core_seed_prompt - 2. character_dynamics_prompt - 3. world_building_prompt - 4. plot_architecture_prompt - 若在中间任何一步报错且重试多次失败,则将已经生成的内容写入 partial_architecture.json 并退出; - 下次调用时可从该步骤继续。 - 最终输出 Novel_architecture.txt - - 新增: - - 在完成角色动力学设定后,依据该角色体系,使用 create_character_state_prompt 生成初始角色状态表, - 并存储到 character_state.txt,后续维护更新。 - """ - os.makedirs(filepath, exist_ok=True) - - # 加载已有的阶段性数据 - partial_data = load_partial_architecture_data(filepath) - - llm_adapter = create_llm_adapter( - interface_format=interface_format, - base_url=base_url, - model_name=llm_model, - api_key=api_key, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout - ) - - # Step1: 核心种子 - if "core_seed_result" not in partial_data: - logging.info("Step1: Generating core_seed_prompt (核心种子) ...") - prompt_core = core_seed_prompt.format( - topic=topic, - genre=genre, - number_of_chapters=number_of_chapters, - word_number=word_number - ) - core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) - if not core_seed_result.strip(): - # 多次重试依旧失败,则写入已完成内容后退出 - logging.warning("core_seed_prompt generation failed and returned empty.") - save_partial_architecture_data(filepath, partial_data) - return - partial_data["core_seed_result"] = core_seed_result - save_partial_architecture_data(filepath, partial_data) - else: - logging.info("Step1 already done. Skipping...") - - # 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()) - character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) - if not character_dynamics_result.strip(): - logging.warning("character_dynamics_prompt generation failed.") - # 写入目前已有结果,然后退出 - save_partial_architecture_data(filepath, partial_data) - return - partial_data["character_dynamics_result"] = character_dynamics_result - save_partial_architecture_data(filepath, partial_data) - else: - logging.info("Step2 already done. Skipping...") - - # 在完成角色动力学设定后,生成初始角色状态表 - if "character_dynamics_result" in partial_data and "character_state_result" not in partial_data: - logging.info("Generating initial character state from character dynamics ...") - prompt_char_state_init = create_character_state_prompt.format( - character_dynamics=partial_data["character_dynamics_result"].strip() - ) - character_state_init = invoke_with_cleaning(llm_adapter, prompt_char_state_init) - if not character_state_init.strip(): - logging.warning("create_character_state_prompt generation failed.") - # 写入目前已有结果,然后退出 - save_partial_architecture_data(filepath, partial_data) - return - - partial_data["character_state_result"] = character_state_init - # 保存到文件 - character_state_file = os.path.join(filepath, "character_state.txt") - clear_file_content(character_state_file) - save_string_to_txt(character_state_init, character_state_file) - - save_partial_architecture_data(filepath, partial_data) - logging.info("Initial character state created and saved.") - - # 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()) - world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) - if not world_building_result.strip(): - logging.warning("world_building_prompt generation failed.") - save_partial_architecture_data(filepath, partial_data) - return - partial_data["world_building_result"] = world_building_result - save_partial_architecture_data(filepath, partial_data) - else: - logging.info("Step3 already done. Skipping...") - - # Step4: 三幕式情节 - if "plot_arch_result" not in partial_data: - logging.info("Step4: Generating plot_architecture_prompt ...") - 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() - ) - plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) - if not plot_arch_result.strip(): - logging.warning("plot_architecture_prompt generation failed.") - save_partial_architecture_data(filepath, partial_data) - return - partial_data["plot_arch_result"] = plot_arch_result - save_partial_architecture_data(filepath, partial_data) - else: - logging.info("Step4 already done. Skipping...") - - # 如果能走到这里,说明全部步骤都完成了 - core_seed_result = partial_data["core_seed_result"] - character_dynamics_result = partial_data["character_dynamics_result"] - world_building_result = partial_data["world_building_result"] - plot_arch_result = partial_data["plot_arch_result"] - - final_content = ( - "#=== 0) 小说设定 ===\n" - f"主题:{topic},类型:{genre},篇幅:约{number_of_chapters}章(每章{word_number}字)\n\n" - "#=== 1) 核心种子 ===\n" - f"{core_seed_result}\n\n" - "#=== 2) 角色动力学 ===\n" - f"{character_dynamics_result}\n\n" - "#=== 3) 世界观 ===\n" - f"{world_building_result}\n\n" - "#=== 4) 三幕式情节架构 ===\n" - f"{plot_arch_result}\n" - ) - - arch_file = os.path.join(filepath, "Novel_architecture.txt") - clear_file_content(arch_file) - save_string_to_txt(final_content, arch_file) - logging.info("Novel_architecture.txt has been generated successfully.") - - # 全部生成完成后,可以考虑删除 partial_architecture.json,或保留做追溯 - # 这里选择删除 - partial_arch_file = os.path.join(filepath, "partial_architecture.json") - if os.path.exists(partial_arch_file): - os.remove(partial_arch_file) - logging.info("partial_architecture.json removed (all steps completed).") - - -# ============ 计算分块大小的工具函数 ============ - -def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: - """ - 基于“每章约100 tokens”的粗略估算, - 再结合当前max_tokens,计算分块大小: - chunk_size = (floor(max_tokens/100/10)*10) - 10 - 并确保 chunk_size 不会小于1或大于实际章节数。 - """ - tokens_per_chapter = 100.0 - ratio = max_tokens / tokens_per_chapter - ratio_rounded_to_10 = int(ratio // 10) * 10 - chunk_size = ratio_rounded_to_10 - 10 - if chunk_size < 1: - chunk_size = 1 - if chunk_size > number_of_chapters: - chunk_size = number_of_chapters - return chunk_size - - -def limit_chapter_blueprint(blueprint_text: str, limit_chapters: int = 100) -> str: - """ - 从已有章节目录中只取最近的 limit_chapters 章,以避免 prompt 超长。 - """ - pattern = r"(第\s*\d+\s*章.*?)(?=第\s*\d+\s*章|$)" - chapters = re.findall(pattern, blueprint_text, flags=re.DOTALL) - if not chapters: - return blueprint_text - - if len(chapters) <= limit_chapters: - return blueprint_text - - selected = chapters[-limit_chapters:] - return "\n\n".join(selected).strip() - - -# ============ 2) 生成章节蓝图(新增分块逻辑 + 断点续跑) ============ - -def Chapter_blueprint_generate( - interface_format: str, - api_key: str, - base_url: str, - llm_model: str, - filepath: str, - number_of_chapters: int, - temperature: float = 0.7, - max_tokens: int = 4096, - timeout: int = 600 -) -> None: - """ - 若 Novel_directory.txt 已存在且内容非空,则表示可能是之前的部分生成结果; - 解析其中已有的章节数,从下一个章节继续分块生成; - 对于已有章节目录,传入时仅保留最近100章目录,避免prompt过长。 - 否则: - - 若章节数 <= chunk_size,直接一次性生成 - - 若章节数 > chunk_size,进行分块生成 - 生成完成后输出至 Novel_directory.txt。 - """ - arch_file = os.path.join(filepath, "Novel_architecture.txt") - if not os.path.exists(arch_file): - logging.warning("Novel_architecture.txt not found. Please generate architecture first.") - return - - architecture_text = read_file(arch_file).strip() - if not architecture_text: - logging.warning("Novel_architecture.txt is empty.") - return - - llm_adapter = create_llm_adapter( - interface_format=interface_format, - base_url=base_url, - model_name=llm_model, - api_key=api_key, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout - ) - - filename_dir = os.path.join(filepath, "Novel_directory.txt") - if not os.path.exists(filename_dir): - # 如果文件不存在,就先建一个空文件 - open(filename_dir, "w", encoding="utf-8").close() - - existing_blueprint = read_file(filename_dir).strip() - chunk_size = compute_chunk_size(number_of_chapters, max_tokens) - logging.info(f"Number of chapters = {number_of_chapters}, computed chunk_size = {chunk_size}.") - - # 如果已经有部分章节蓝图生成了,则进行断点续跑 - if existing_blueprint: - logging.info("Detected existing blueprint content. Will resume chunked generation from that point.") - - pattern = r"第\s*(\d+)\s*章" - existing_chapter_numbers = re.findall(pattern, existing_blueprint) - existing_chapter_numbers = [int(x) for x in existing_chapter_numbers if x.isdigit()] - - if existing_chapter_numbers: - max_existing_chap = max(existing_chapter_numbers) - else: - max_existing_chap = 0 - - logging.info(f"Existing blueprint indicates up to chapter {max_existing_chap} has been generated.") - - final_blueprint = existing_blueprint - current_start = max_existing_chap + 1 - while current_start <= number_of_chapters: - current_end = min(current_start + chunk_size - 1, number_of_chapters) - limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) - - chunk_prompt = chunked_chapter_blueprint_prompt.format( - novel_architecture=architecture_text, - chapter_list=limited_blueprint, # 只保留最近100章 - number_of_chapters=number_of_chapters, - n=current_start, - m=current_end - ) - logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") - - chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) - if not chunk_result.strip(): - logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") - # 写入当前已经有的 final_blueprint,并结束 - clear_file_content(filename_dir) - save_string_to_txt(final_blueprint.strip(), filename_dir) - return - - final_blueprint += "\n\n" + chunk_result.strip() - - # 实时写入 - clear_file_content(filename_dir) - save_string_to_txt(final_blueprint.strip(), filename_dir) - - current_start = current_end + 1 - - logging.info("All chapters blueprint have been generated (resumed chunked).") - return - - # 如果 Novel_directory.txt 为空,则分情况: - # 1) 如果 chunk_size >= number_of_chapters,可以一次性生成 - if chunk_size >= number_of_chapters: - prompt = chapter_blueprint_prompt.format( - novel_architecture=architecture_text, - number_of_chapters=number_of_chapters - ) - blueprint_text = invoke_with_cleaning(llm_adapter, prompt) - if not blueprint_text.strip(): - logging.warning("Chapter blueprint generation result is empty.") - return - - clear_file_content(filename_dir) - save_string_to_txt(blueprint_text, filename_dir) - logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (single-shot).") - return - - # 2) 如果 chunk_size < number_of_chapters,则进行分块生成 - logging.info("Will generate chapter blueprint in chunked mode from scratch.") - final_blueprint = "" - current_start = 1 - while current_start <= number_of_chapters: - current_end = min(current_start + chunk_size - 1, number_of_chapters) - limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) - - chunk_prompt = chunked_chapter_blueprint_prompt.format( - novel_architecture=architecture_text, - chapter_list=limited_blueprint, # 只保留最近100章 - number_of_chapters=number_of_chapters, - n=current_start, - m=current_end - ) - logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") - - chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) - if not chunk_result.strip(): - logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") - # 写入已经生成的 final_blueprint - clear_file_content(filename_dir) - save_string_to_txt(final_blueprint.strip(), filename_dir) - return - - if final_blueprint.strip(): - final_blueprint += "\n\n" + chunk_result.strip() - else: - final_blueprint = chunk_result.strip() - - # 实时写入,以免中途崩溃造成丢失 - clear_file_content(filename_dir) - save_string_to_txt(final_blueprint.strip(), filename_dir) - - current_start = current_end + 1 - - logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") - - -# ============ 3) 生成章节草稿 ============ - -def generate_chapter_draft( - api_key: str, - base_url: str, - model_name: str, - filepath: str, - novel_number: int, - word_number: int, - temperature: float, - user_guidance: str, - characters_involved: str, - key_items: str, - scene_location: str, - time_constraint: str, - embedding_api_key: str, - embedding_url: str, - embedding_interface_format: str, - embedding_model_name: str, - embedding_retrieval_k: int = 2, - interface_format: str = "openai", - max_tokens: int = 2048, - timeout: int = 600 -) -> str: - """ - 根据 novel_number 判断是否为第一章。 - - 若是第一章,则使用 first_chapter_draft_prompt - - 否则使用 next_chapter_draft_prompt - 最终将生成文本存入 chapters/chapter_{novel_number}.txt。 - """ - arch_file = os.path.join(filepath, "Novel_architecture.txt") - novel_architecture_text = read_file(arch_file) - - directory_file = os.path.join(filepath, "Novel_directory.txt") - blueprint_text = read_file(directory_file) - - global_summary_file = os.path.join(filepath, "global_summary.txt") - global_summary_text = read_file(global_summary_file) - - 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"] - chapter_purpose = chapter_info["chapter_purpose"] - suspense_level = chapter_info["suspense_level"] - foreshadowing = chapter_info["foreshadowing"] - plot_twist_level = chapter_info["plot_twist_level"] - chapter_summary = chapter_info["chapter_summary"] - - # 准备章节目录文件夹 - chapters_dir = os.path.join(filepath, "chapters") - os.makedirs(chapters_dir, exist_ok=True) - - # 判断是否为第一章 - if novel_number == 1: - prompt_text = first_chapter_draft_prompt.format( - novel_number=novel_number, - word_number=word_number, - chapter_title=chapter_title, - chapter_role=chapter_role, - chapter_purpose=chapter_purpose, - suspense_level=suspense_level, - foreshadowing=foreshadowing, - plot_twist_level=plot_twist_level, - chapter_summary=chapter_summary, - - characters_involved=characters_involved, - key_items=key_items, - scene_location=scene_location, - time_constraint=time_constraint, - user_guidance=user_guidance, - - novel_setting=novel_architecture_text - ) - else: - # 若不是第一章,则获取最近几章文本,并做摘要与检索 - recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) - short_summary, next_chapter_keywords = summarize_recent_chapters( - interface_format=interface_format, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - max_tokens=max_tokens, - chapters_text_list=recent_3_texts, - timeout=timeout - ) - - # 从最近章节中获取最后一段作为前章结尾 - previous_chapter_excerpt = "" - for text_block in reversed(recent_3_texts): - if text_block.strip(): - # 取后1500字符左右 - if len(text_block) > 1500: - previous_chapter_excerpt = text_block[-1500:] - else: - previous_chapter_excerpt = text_block - break - - # 从向量库检索上下文 - embedding_adapter = create_embedding_adapter( - embedding_interface_format, - embedding_api_key, - embedding_url, - embedding_model_name - ) - retrieval_query = short_summary + " " + next_chapter_keywords - relevant_context = get_relevant_context_from_vector_store( - embedding_adapter=embedding_adapter, - query=retrieval_query, - filepath=filepath, - k=embedding_retrieval_k - ) - if not relevant_context.strip(): - relevant_context = "(无检索到的上下文)" - - prompt_text = next_chapter_draft_prompt.format( - novel_number=novel_number, - word_number=word_number, - chapter_title=chapter_title, - chapter_role=chapter_role, - chapter_purpose=chapter_purpose, - suspense_level=suspense_level, - foreshadowing=foreshadowing, - plot_twist_level=plot_twist_level, - chapter_summary=chapter_summary, - - characters_involved=characters_involved, - key_items=key_items, - scene_location=scene_location, - time_constraint=time_constraint, - user_guidance=user_guidance, - - novel_setting=novel_architecture_text, - global_summary=global_summary_text, - character_state=character_state_text, - context_excerpt=relevant_context, - previous_chapter_excerpt=previous_chapter_excerpt - ) - - llm_adapter = create_llm_adapter( - interface_format=interface_format, - base_url=base_url, - model_name=model_name, - api_key=api_key, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout - ) - - chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) - if not chapter_content.strip(): - logging.warning("Generated chapter draft is empty.") - - chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - clear_file_content(chapter_file) - save_string_to_txt(chapter_content, chapter_file) - - logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") - return chapter_content - - -# ============ 4) 定稿章节 ============ - -def finalize_chapter( - novel_number: int, - word_number: int, - api_key: str, - base_url: str, - model_name: str, - temperature: float, - filepath: str, - embedding_api_key: str, - embedding_url: str, - embedding_interface_format: str, - embedding_model_name: str, - interface_format: str, - max_tokens: int, - timeout: int = 600 -): - """ - 对指定章节做最终处理:更新全局摘要、更新角色状态、插入向量库等。 - 默认无需再做扩写操作,若有需要可在外部调用 enrich_chapter_text 处理后再定稿。 - """ - chapters_dir = os.path.join(filepath, "chapters") - chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - chapter_text = read_file(chapter_file).strip() - if not chapter_text: - logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") - return - - # 进行摘要、角色状态更新 - global_summary_file = os.path.join(filepath, "global_summary.txt") - old_global_summary = read_file(global_summary_file) - - character_state_file = os.path.join(filepath, "character_state.txt") - old_character_state = read_file(character_state_file) - - llm_adapter = create_llm_adapter( - interface_format=interface_format, - base_url=base_url, - model_name=model_name, - api_key=api_key, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout - ) - - # 更新全局摘要 - prompt_summary = summary_prompt.format( - chapter_text=chapter_text, - global_summary=old_global_summary - ) - new_global_summary = invoke_with_cleaning(llm_adapter, prompt_summary) - if not new_global_summary.strip(): - new_global_summary = old_global_summary - - # 更新角色状态 - prompt_char_state = update_character_state_prompt.format( - chapter_text=chapter_text, - old_state=old_character_state - ) - new_char_state = invoke_with_cleaning(llm_adapter, prompt_char_state) - if not new_char_state.strip(): - new_char_state = old_character_state - - clear_file_content(global_summary_file) - save_string_to_txt(new_global_summary, global_summary_file) - - clear_file_content(character_state_file) - save_string_to_txt(new_char_state, character_state_file) - - # 更新向量库 - embedding_adapter = create_embedding_adapter( - embedding_interface_format, - embedding_api_key, - embedding_url, - embedding_model_name - ) - update_vector_store(embedding_adapter, chapter_text, filepath) - - logging.info(f"Chapter {novel_number} has been finalized.") - - -def enrich_chapter_text( - chapter_text: str, - word_number: int, - api_key: str, - base_url: str, - model_name: str, - temperature: float, - interface_format: str, - max_tokens: int, - timeout: int=600 -) -> str: - """ - 对章节文本进行扩写,使其更接近 word_number 字数,保持剧情连贯。 - """ - llm_adapter = create_llm_adapter( - interface_format=interface_format, - base_url=base_url, - model_name=model_name, - api_key=api_key, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout - ) - prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: -原内容: -{chapter_text} -""" - enriched_text = invoke_with_cleaning(llm_adapter, prompt) - return enriched_text if enriched_text else chapter_text - - -# ============ 导入知识文件到向量库 ============ - -def advanced_split_content(content: str, - similarity_threshold: float = 0.7, - max_length: int = 500) -> List[str]: - nltk.download('punkt', quiet=True) - nltk.download('punkt_tab', quiet=True) - sentences = nltk.sent_tokenize(content) - if not sentences: - return [] - - model = SentenceTransformer('paraphrase-MiniLM-L6-v2') - embeddings = model.encode(sentences) - - merged_paragraphs = [] - current_sentences = [sentences[0]] - current_embedding = embeddings[0] - - for i in range(1, len(sentences)): - sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] - if sim >= similarity_threshold: - current_sentences.append(sentences[i]) - current_embedding = (current_embedding + embeddings[i]) / 2.0 - else: - merged_paragraphs.append(" ".join(current_sentences)) - current_sentences = [sentences[i]] - current_embedding = embeddings[i] - - if current_sentences: - merged_paragraphs.append(" ".join(current_sentences)) - - final_segments = [] - for para in merged_paragraphs: - if len(para) > max_length: - sub_segments = split_by_length(para, max_length=max_length) - final_segments.extend(sub_segments) - else: - final_segments.append(para) - - return final_segments - -def import_knowledge_file( - embedding_api_key: str, - embedding_url: str, - embedding_interface_format: str, - embedding_model_name: str, - file_path: str, - filepath: str -): - logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {embedding_interface_format}, 模型: {embedding_model_name}") - if not os.path.exists(file_path): - logging.warning(f"知识库文件不存在: {file_path}") - return - - content = read_file(file_path) - if not content.strip(): - logging.warning("知识库文件内容为空。") - return - - paragraphs = advanced_split_content(content) - - embedding_adapter = create_embedding_adapter( - interface_format=embedding_interface_format, - api_key=embedding_api_key, - base_url=embedding_url if embedding_url else "http://localhost:11434/api", - model_name=embedding_model_name - ) - - store = load_vector_store(embedding_adapter, filepath) - if not store: - logging.info("Vector store does not exist or load failed. Initializing a new one for knowledge import...") - store = init_vector_store(embedding_adapter, paragraphs, filepath) - if store: - logging.info("知识库文件已成功导入至向量库(新初始化)。") - else: - logging.warning("知识库导入失败,跳过。") - else: - try: - docs = [Document(page_content=str(p)) for p in paragraphs] - store.add_documents(docs) - logging.info("知识库文件已成功导入至向量库(追加模式)。") - except Exception as e: - logging.warning(f"知识库导入失败: {e}") - traceback.print_exc() diff --git a/ui_backup.txt b/ui_backup.txt deleted file mode 100644 index 80836f4..0000000 --- a/ui_backup.txt +++ /dev/null @@ -1,1823 +0,0 @@ -# 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("", 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://.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() From e965b3c9af3b46e4794321fd7e870c2908578e5d Mon Sep 17 00:00:00 2001 From: Karasukaigan <80465610+Karasukaigan@users.noreply.github.com> Date: Mon, 17 Feb 2025 23:09:28 +0800 Subject: [PATCH 095/142] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9C=AC=E5=9C=B0Oll?= =?UTF-8?q?ama=E6=8E=A5=E5=8F=A3=E4=BD=BF=E7=94=A8=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在使用本地Ollama接口时无需再填写API Key,API Key字段可以留空。 --- ui/generation_handlers.py | 15 +++++++++++++++ ui/main_window.py | 3 +++ 2 files changed, 18 insertions(+) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index 681e2e9..944fe3a 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -35,6 +35,9 @@ def generate_novel_architecture_ui(self): max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) + if interface_format == 'Ollama' and api_key == '': + api_key = 'ollama' + 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) @@ -80,6 +83,9 @@ def generate_chapter_blueprint_ui(self): max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) + if interface_format == 'Ollama' and api_key == '': + api_key = 'ollama' + self.safe_log("开始生成章节蓝图...") Chapter_blueprint_generate( interface_format=interface_format, @@ -116,6 +122,9 @@ def generate_chapter_draft_ui(self): max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) + if interface_format == 'Ollama' and api_key == '': + api_key = 'ollama' + 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() @@ -246,6 +255,9 @@ def finalize_chapter_ui(self): max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) + if interface_format == 'Ollama' and api_key == '': + api_key = 'ollama' + 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() @@ -326,6 +338,9 @@ def do_consistency_check(self): max_tokens = self.max_tokens_var.get() timeout = self.timeout_var.get() + if interface_format == 'Ollama' and api_key == '': + api_key = 'ollama' + 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) diff --git a/ui/main_window.py b/ui/main_window.py index 382223e..ca7f3e6 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -187,6 +187,9 @@ class NovelGeneratorGUI: max_tokens = self.max_tokens_var.get() timeout = self.timeout_var.get() + if interface_format == 'Ollama' and api_key == '': + api_key = 'ollama' + test_llm_config( interface_format=interface_format, api_key=api_key, From 0a24217eb533dccfdd3ff01933ec800c26c53707 Mon Sep 17 00:00:00 2001 From: Karasukaigan <80465610+Karasukaigan@users.noreply.github.com> Date: Tue, 18 Feb 2025 00:46:31 +0800 Subject: [PATCH 096/142] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E9=98=BF?= =?UTF-8?q?=E9=87=8C=E4=BA=91=E7=99=BE=E7=82=BC=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加了对阿里云百炼的支持。接口格式里增加了“阿里云百炼”选项,并设置默认模型为“qwen-plus”。已经过测试,可以使用。 此外,还添加了DeepSeek的默认模型“deepseek-chat”和OpenAI的默认模型“gpt-4o-mini”。 --- llm_adapters.py | 2 ++ ui/config_tab.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/llm_adapters.py b/llm_adapters.py index 52012ea..c771bb2 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -291,5 +291,7 @@ def create_llm_adapter( elif fmt == "gemini": # base_url 对 Gemini 暂无用处,可忽略 return GeminiAdapter(api_key, model_name, max_tokens, temperature, timeout) + elif fmt == "阿里云百炼": + return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) else: raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/ui/config_tab.py b/ui/config_tab.py index 3d724ac..3f3200a 100644 --- a/ui/config_tab.py +++ b/ui/config_tab.py @@ -76,14 +76,19 @@ def build_ai_config_tab(self): self.base_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.base_url_var.set("https://api.openai.com/v1") + self.model_name_var.set("gpt-4o-mini") elif new_value == "Azure OpenAI": self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview") elif new_value == "DeepSeek": self.base_url_var.set("https://api.deepseek.com/v1") + self.model_name_var.set("deepseek-chat") elif new_value == "Gemini": self.base_url_var.set("") elif new_value == "Azure AI": self.base_url_var.set("https://.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview") + elif new_value == "阿里云百炼": + self.base_url_var.set("https://dashscope.aliyuncs.com/compatible-mode/v1") + self.model_name_var.set("qwen-plus") for i in range(7): self.ai_config_tab.grid_rowconfigure(i, weight=0) @@ -103,7 +108,7 @@ 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") From fd7f97b0e82445e55d5a0847090c0fc5897d30eb Mon Sep 17 00:00:00 2001 From: Karasukaigan <80465610+Karasukaigan@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:42:37 +0800 Subject: [PATCH 097/142] =?UTF-8?q?=E4=BF=AE=E6=94=B9Ollama=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=B0=83=E7=94=A8=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在调用本地Ollama接口时,将api_key默认值设置为“ollama”。 --- llm_adapters.py | 3 +++ ui/generation_handlers.py | 15 --------------- ui/main_window.py | 3 --- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/llm_adapters.py b/llm_adapters.py index c771bb2..f919062 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -172,6 +172,9 @@ class OllamaAdapter(BaseLLMAdapter): self.temperature = temperature self.timeout = timeout + if self.api_key == '': + self.api_key= 'ollama' + self._client = ChatOpenAI( model=self.model_name, api_key=self.api_key, diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index 944fe3a..681e2e9 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -35,9 +35,6 @@ def generate_novel_architecture_ui(self): max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) - if interface_format == 'Ollama' and api_key == '': - api_key = 'ollama' - 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) @@ -83,9 +80,6 @@ def generate_chapter_blueprint_ui(self): max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) - if interface_format == 'Ollama' and api_key == '': - api_key = 'ollama' - self.safe_log("开始生成章节蓝图...") Chapter_blueprint_generate( interface_format=interface_format, @@ -122,9 +116,6 @@ def generate_chapter_draft_ui(self): max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) - if interface_format == 'Ollama' and api_key == '': - api_key = 'ollama' - 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() @@ -255,9 +246,6 @@ def finalize_chapter_ui(self): max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) - if interface_format == 'Ollama' and api_key == '': - api_key = 'ollama' - 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() @@ -338,9 +326,6 @@ def do_consistency_check(self): max_tokens = self.max_tokens_var.get() timeout = self.timeout_var.get() - if interface_format == 'Ollama' and api_key == '': - api_key = 'ollama' - 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) diff --git a/ui/main_window.py b/ui/main_window.py index ca7f3e6..382223e 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -187,9 +187,6 @@ class NovelGeneratorGUI: max_tokens = self.max_tokens_var.get() timeout = self.timeout_var.get() - if interface_format == 'Ollama' and api_key == '': - api_key = 'ollama' - test_llm_config( interface_format=interface_format, api_key=api_key, From 2385893893fc041ec0694757ce9b45578f8cd8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?7934-=E6=96=B9=E9=9C=B2=E5=AE=87?= Date: Mon, 24 Feb 2025 17:38:02 +0800 Subject: [PATCH 098/142] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BA=86=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=E5=BC=95=E6=93=8E=E7=9A=84=E9=80=89=E6=8B=A9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_adapters.py | 34 ++++++++++++++++++++++++++++++++++ ui/config_tab.py | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/llm_adapters.py b/llm_adapters.py index f919062..ba65947 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -8,6 +8,8 @@ from google.genai import types from azure.ai.inference import ChatCompletionsClient from azure.core.credentials import AzureKeyCredential from azure.ai.inference.models import SystemMessage, UserMessage +from openai import OpenAI + def check_base_url(url: str) -> str: """ @@ -266,6 +268,36 @@ class AzureAIAdapter(BaseLLMAdapter): logging.error(f"Azure AI Inference API 调用失败: {e}") return "" +# 火山引擎实现 +class VolcanoEngineAIAdapter(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="https://ark.cn-beijing.volces.com/api/v3/bots", + # 从环境变量中获取您的 API Key + api_key=api_key + ) + 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}, + ], + ) + # response = self._client.invoke(prompt) + if not response: + logging.warning("No response from DeepSeekAdapter.") + return "" + return response.choices[0].message.content + def create_llm_adapter( interface_format: str, base_url: str, @@ -296,5 +328,7 @@ def create_llm_adapter( return GeminiAdapter(api_key, model_name, max_tokens, temperature, timeout) elif fmt == "阿里云百炼": 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) else: raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/ui/config_tab.py b/ui/config_tab.py index 3f3200a..1c51539 100644 --- a/ui/config_tab.py +++ b/ui/config_tab.py @@ -108,7 +108,7 @@ 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") From fa29b3b7b9773a56cd22ced410ab4675860b8dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?7934-=E6=96=B9=E9=9C=B2=E5=AE=87?= Date: Tue, 25 Feb 2025 09:15:09 +0800 Subject: [PATCH 099/142] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E5=86=99?= =?UTF-8?q?=E6=AD=BB=E7=81=AB=E5=B1=B1=E5=BC=95=E6=93=8E=E7=9A=84url?= =?UTF-8?q?=E7=9A=84bug=EF=BC=8C=E7=81=AB=E5=B1=B1=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E7=9A=84=E6=A8=A1=E5=9E=8B=E5=A6=82=E6=9E=9C=E5=8A=A0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=9A=84=E8=AF=9D=EF=BC=8Curl=E6=98=AF=E4=B8=8D?= =?UTF-8?q?=E5=90=8C=E7=9A=84=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_adapters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/llm_adapters.py b/llm_adapters.py index ba65947..30ff1e6 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -280,7 +280,7 @@ class VolcanoEngineAIAdapter(BaseLLMAdapter): self._client = OpenAI( # 此为默认路径,您可根据业务所在地域进行配置 - base_url="https://ark.cn-beijing.volces.com/api/v3/bots", + base_url=base_url, # 从环境变量中获取您的 API Key api_key=api_key ) From 954f5f8ad7aec7e99e22848ec223dd031e7a435a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?7934-=E6=96=B9=E9=9C=B2=E5=AE=87?= Date: Tue, 25 Feb 2025 15:18:54 +0800 Subject: [PATCH 100/142] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=85=8D=E7=BD=AEtab?= =?UTF-8?q?=EF=BC=8C=E5=B0=86apiKey=E9=9A=90=E8=97=8F=E3=80=82=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E5=9B=A0=E4=B8=BA=E6=88=AA=E5=9B=BE=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E4=B8=8D=E5=B0=8F=E5=BF=83=E5=B0=B1=E6=B3=84=E9=9C=B2?= =?UTF-8?q?=E4=BA=86apiKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/config_tab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/config_tab.py b/ui/config_tab.py index 1c51539..0ca6f69 100644 --- a/ui/config_tab.py +++ b/ui/config_tab.py @@ -98,7 +98,7 @@ def build_ai_config_tab(self): # 1) API Key create_label_with_help(self, 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 = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var, font=("Microsoft YaHei", 12),show="*") api_key_entry.grid(row=0, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") # 2) Base URL From 693b5fd4b0795461053c1d8703de2790ad6df421 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:05:26 +0800 Subject: [PATCH 101/142] Update prompt_definitions.py --- prompt_definitions.py | 168 ++++++++++++++++++++++++++++-------------- 1 file changed, 114 insertions(+), 54 deletions(-) diff --git a/prompt_definitions.py b/prompt_definitions.py index 6ad1d11..fc943cd 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -232,40 +232,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 +287,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 +416,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 +467,45 @@ next_chapter_draft_prompt = """\ - 不要使用markdown格式。 额外指导(可能未指定):{user_guidance} -""" \ No newline at end of file +""" + + +Character_Import_Prompt = """\ +根据以下文本内容,分析出所有角色及其属性信息,严格按照以下格式要求: + +<<角色状态格式要求>> +1. 必须包含以下五个分类(按顺序): + ● 物品 ● 能力 ● 状态 ● 关系网 ● 触发事件 +2. 每个属性条目必须用【名称: 描述】格式 + 例:├──青衫: 一件破损的青色长袍,带有暗红色污渍 +3. 状态必须包含: + ● 身体状态: [当前身体状况] + ● 心理状态: [当前心理状况] +4. 关系网格式: + ● [角色名称]: [关系类型,如"竞争对手"/"盟友"] +5. 触发事件格式: + ● [事件名称]: [简要描述及影响] + +<<示例>> +李员外: +├──物品: +│ ├──青衫: 一件破损的青色长袍,带有暗红色污渍 +│ └──寒铁长剑: 剑身有裂痕,刻有「青云」符文 +├──能力: +│ ├──精神感知: 能感知半径30米内的生命体 +│ └──剑气压制: 通过目光释放精神威压 +├──状态: +│ ├──身体状态: 右臂有未愈合的刀伤 +│ └──心理状态: 对苏明远的实力感到忌惮 +├──主要角色间关系网: +│ ├──苏明远: 竞争对手,十年前的同僚 +│ └──林婉儿: 暗中培养的继承人 +├──触发或加深的事件: +│ ├──兵器库遇袭: 丢失三把传家宝剑,影响战力 +│ └──匿名威胁信: 信纸带有檀香味,暗示内部泄密 + +请严格按上述格式分析以下内容: +<<待分析小说文本开始>> +{content} +<<待分析小说文本结束>> +""" From e89b0916b0dd4c2621930d8295ba77fa8e6b696d Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:06:42 +0800 Subject: [PATCH 102/142] Update llm_adapters.py --- llm_adapters.py | 46 +++++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/llm_adapters.py b/llm_adapters.py index 30ff1e6..520e124 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -114,7 +114,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 +213,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 +284,27 @@ 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}, - ], - ) - # response = self._client.invoke(prompt) - if not response: - logging.warning("No response from DeepSeekAdapter.") + 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, From dd31da34d398cbab82af78d7d6ba5b6bf6007b68 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:12:03 +0800 Subject: [PATCH 103/142] Update generation_handlers.py --- ui/generation_handlers.py | 62 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index 681e2e9..db6a6ba 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -25,6 +25,13 @@ 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() @@ -69,6 +76,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 +178,50 @@ 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) + + # 插入角色内容 + 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"{os.path.splitext(file)[0]}:\n{f.read()}\n") + 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) button_frame = ctk.CTkFrame(dialog) button_frame.pack(pady=10) def on_confirm(): @@ -236,6 +289,13 @@ def finalize_chapter_ui(self): return def task(): + if not messagebox.askyesno("确认", "确定要定稿当前章节吗?"): + self.enable_button_safe(self.btn_finalize_chapter) + return + if not messagebox.askyesno("确认", "确定要生成章节草稿吗?"): + self.enable_button_safe(self.btn_generate_chapter) + return + self.disable_button_safe(self.btn_finalize_chapter) try: interface_format = self.interface_format_var.get().strip() From 67ec7be036d78917cdccdf1ce19a33354cbe4353 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:12:39 +0800 Subject: [PATCH 104/142] Update main_window.py --- ui/main_window.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/ui/main_window.py b/ui/main_window.py index 382223e..0e89687 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -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,87 @@ 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): + for category in os.listdir(role_lib_path): + category_path = os.path.join(role_lib_path, category) + if os.path.isdir(category_path): + # 添加分类标签 + category_label = ctk.CTkLabel(scroll_frame, text=f"【{category}】", font=("Microsoft YaHei", 12, "bold")) + category_label.pack(anchor="w", pady=(10,5)) + + # 添加该分类下的角色 + 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(scroll_frame, text=role_name) + chk.pack(anchor="w", padx=20) + self.selected_roles.append((chk, role_name)) + + # 底部按钮框架 + 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 From 6cf08e1fa655e9cd69522c96a248b681074ee63a Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:12:53 +0800 Subject: [PATCH 105/142] Update novel_params_tab.py --- ui/novel_params_tab.py | 49 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/ui/novel_params_tab.py b/ui/novel_params_tab.py index 28f57f3..b2183c1 100644 --- a/ui/novel_params_tab.py +++ b/ui/novel_params_tab.py @@ -66,8 +66,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)) @@ -84,20 +100,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) From b2b8e53ce987bedc510c3a9db3f7ab81991ff4dd Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:13:34 +0800 Subject: [PATCH 106/142] Create role_library.py --- ui/role_library.py | 1519 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1519 insertions(+) create mode 100644 ui/role_library.py diff --git a/ui/role_library.py b/ui/role_library.py new file mode 100644 index 0000000..51e13a4 --- /dev/null +++ b/ui/role_library.py @@ -0,0 +1,1519 @@ +# ui/role_library.py +import os +import tkinter as tk +from tkinter import filedialog +import shutil +import re +import customtkinter as ctk +from tkinter import messagebox, BooleanVar +from customtkinter import CTkScrollableFrame, CTkTextbox, END +from utils import read_file, save_string_to_txt # 导入 utils 中的函数 +from novel_generator.common import invoke_with_cleaning # 新增导入 +from prompt_definitions import Character_Import_Prompt + +class RoleLibrary: + def __init__(self, master, save_path, llm_adapter): # 新增llm_adapter参数 + self.master = master + self.save_path = os.path.join(save_path, "角色库") + self.selected_category = None + self.current_roles = [] + self.selected_del = [] + self.llm_adapter = llm_adapter # 保存LLM适配器实例 + + # 初始化窗口 + self.window = ctk.CTkToplevel(master) + self.window.title("角色库管理") + self.window.geometry("1200x800") + self.window.protocol("WM_DELETE_WINDOW", self.on_close) + + # 创建目录结构 + self.create_library_structure() + # 构建UI + self.create_ui() + # 窗口居中 + self.center_window() + # 窗口模态设置 + self.window.grab_set() + self.window.attributes('-topmost', 1) + self.window.after(200, lambda: self.window.attributes('-topmost', 0)) + + def create_library_structure(self): + """创建必要的目录结构""" + os.makedirs(self.save_path, exist_ok=True) + all_dir = os.path.join(self.save_path, "全部") + os.makedirs(all_dir, exist_ok=True) + + def create_ui(self): + """创建主界面""" + # 分类按钮区 + self.create_category_bar() + + # 主内容区 + main_frame = ctk.CTkFrame(self.window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 左侧面板(保持不变) + left_panel = ctk.CTkFrame(main_frame, width=300) + left_panel.pack(side="left", fill="both", padx=5, pady=5) + + # 上部角色列表区(保持不变) + role_list_container = ctk.CTkFrame(left_panel) + role_list_container.pack(fill="both", expand=True, pady=(0, 5)) + + self.role_list_frame = ctk.CTkScrollableFrame(role_list_container) + self.role_list_frame.pack(fill="both", expand=True) + + # 下部内容预览区(保持不变) + preview_container = ctk.CTkFrame(left_panel) + preview_container.pack(fill="both", expand=True, pady=(5, 0)) + + self.preview_text = ctk.CTkTextbox(preview_container, wrap="word", + font=("Microsoft YaHei", 12)) + scrollbar = ctk.CTkScrollbar( + preview_container, command=self.preview_text.yview) + self.preview_text.configure(yscrollcommand=scrollbar.set) + + self.preview_text.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # 右侧面板(信息编辑区) + right_panel = ctk.CTkFrame(main_frame) + right_panel.pack(side="right", fill="both", expand=True, padx=5, pady=5) + + # 分类选择行 + category_frame = ctk.CTkFrame(right_panel) + category_frame.pack(fill="x", padx=5, pady=5) + + # 分类选择标签 + ctk.CTkLabel(category_frame, text="分类选择").pack(side="left", padx=(0, 5)) + + # 分类选择框 + self.category_combobox = ctk.CTkComboBox( + category_frame, + values=self._get_all_categories(), + width=200 + ) + self.category_combobox.pack(side="left", padx=0) + + # 分类保存按钮 + self.save_category_btn = ctk.CTkButton( + category_frame, + text="保存分类", + width=80, + command=self._move_to_category + ) + self.save_category_btn.pack(side="left", padx=(0, 5)) + + # 打开文件夹按钮 + ctk.CTkButton( + category_frame, + text="打开文件夹", + width=80, + command=lambda: os.startfile( + os.path.join(self.save_path, self.category_combobox.get())) + ).pack(side="left", padx=0) + + # 角色名编辑行 + name_frame = ctk.CTkFrame(right_panel) + name_frame.pack(fill="x", padx=5, pady=5) + + # 角色名称标签 + ctk.CTkLabel(name_frame, text="角色名称").pack(side="left", padx=(0, 5)) + + self.role_name_var = tk.StringVar() + self.role_name_entry = ctk.CTkEntry( + name_frame, + textvariable=self.role_name_var, + placeholder_text="角色名称", + width=200 + ) + self.role_name_entry.pack(side="left", padx=0) + + ctk.CTkButton( + name_frame, + text="修改", + width=60, + command=self._rename_role_file + ).pack(side="left", padx=(0, 5)) + + ctk.CTkButton( + name_frame, + text="新增", + width=60, + command=lambda: self._create_new_role("全部") + ).pack(side="left", padx=0) + + # 属性编辑区(基础框架) + self.attributes_frame = ctk.CTkScrollableFrame(right_panel) + self.attributes_frame.pack(fill="both", expand=True, padx=5, pady=5) + # 设置统一的列权重 + self.attributes_frame.grid_columnconfigure(1, weight=1) + + button_frame = ctk.CTkFrame(right_panel) + button_frame.pack(fill="x", padx=5, pady=5) + + ctk.CTkButton(button_frame, text="导入角色", + command=self.import_roles).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="删除", + command=self.delete_current_role).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="保存", + command=self.save_current_role).pack(side="left", padx=5) + + def _get_all_categories(self): + """获取所有有效分类(包括动态更新)""" + categories = ["全部"] + for d in os.listdir(self.save_path): + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部": + categories.append(d) + return categories + + def _move_to_category(self): + """分类转移功能""" + if not hasattr(self, 'current_role') or not self.current_role: + messagebox.showwarning("警告", "请先选择一个角色", parent=self.window) + return + + new_category = self.category_combobox.get() + + # 如果当前在"全部"分类下,需要找到角色实际所在分类 + if self.selected_category == "全部": + # 遍历所有分类查找实际存储位置(包含全部目录) + actual_category = None + for category in os.listdir(self.save_path): + test_path = os.path.join( + self.save_path, category, f"{self.current_role}.txt") + if os.path.exists(test_path): + actual_category = category + break + + if not actual_category: + msg = messagebox.showerror("错误", f"找不到角色 {self.current_role} 的实际存储位置", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + old_path = os.path.join( + self.save_path, actual_category, f"{self.current_role}.txt") + else: + old_path = os.path.join( + self.save_path, self.selected_category, f"{self.current_role}.txt") + + # 如果目标分类是"全部",则实际移动到"全部"分类 + if new_category == "全部": + new_path = os.path.join( + self.save_path, "全部", f"{self.current_role}.txt") + else: + new_path = os.path.join( + self.save_path, new_category, f"{self.current_role}.txt") + + # 检查是否已经在目标分类 + if os.path.exists(new_path): + msg = messagebox.showinfo("提示", "角色已在目标分类中", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + confirm = messagebox.askyesno( + "确认", f"确定要将角色 {self.current_role} 移动到 {new_category} 分类吗?", parent=self.window) + if not confirm: + return + + try: + # 确保目标目录存在 + os.makedirs(os.path.dirname(new_path), exist_ok=True) + + try: + # 执行移动操作 + shutil.move(old_path, new_path) + + # 更新显示 + self.selected_category = new_category if new_category != "全部" else "全部" + self.show_category(self.selected_category) + self.category_combobox.set(new_category) + + # 成功提示 + messagebox.showinfo("成功", "分类已更新", parent=self.window) + return # 成功时直接返回 + + except Exception as e: + # 失败时恢复原分类显示 + self.category_combobox.set(self.selected_category) + raise e + except Exception as e: + msg = messagebox.showerror("错误", f"分类转移失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + self.category_combobox.set(self.selected_category) + + def import_roles(self): + """导入角色窗口""" + import_window = ctk.CTkToplevel(self.window) + import_window.title("角色导入") + import_window.geometry("800x600") + import_window.transient(self.window) # 设置为子窗口 + import_window.grab_set() # 模态窗口 + import_window.lift() # 置于父窗口前面 + + # 窗口居中计算 + import_window.update_idletasks() + i_width = import_window.winfo_width() + i_height = import_window.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - i_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - i_height) // 2 + import_window.geometry(f"+{x}+{y}") + + # 主内容区 + main_frame = ctk.CTkFrame(import_window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 左右面板容器 + content_frame = ctk.CTkFrame(main_frame) + content_frame.pack(fill="both", expand=True, pady=(0, 10)) + content_frame.grid_columnconfigure(0, weight=1) # 左侧面板权重 + content_frame.grid_columnconfigure(1, weight=1) # 右侧面板权重 + + # 左侧面板 - 使用权重让控件占满空间 + left_panel = ctk.CTkFrame(content_frame) + left_panel.grid(row=0, column=0, sticky="nsew", padx=(0, 5), pady=5) + left_panel.grid_rowconfigure(0, weight=1) + left_panel.grid_columnconfigure(0, weight=1) + left_panel.grid_propagate(False) # 防止子控件改变父容器大小 + + # 右侧面板(2份宽度) - 添加初始可编辑文本框 + right_panel = ctk.CTkFrame(content_frame) + right_panel.grid(row=0, column=1, sticky="nsew", padx=(5, 0), pady=5) + right_panel.grid_rowconfigure(0, weight=1) + right_panel.grid_columnconfigure(0, weight=1) + + # 创建初始可编辑文本框 + text_box = ctk.CTkTextbox(right_panel, wrap="word") + text_box.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + text_box.configure(state="normal") # 保持可编辑状态 + + # 初始化角色列表 + self.import_roles_list = [] + + # 底部按钮区 + btn_frame = ctk.CTkFrame(main_frame) + btn_frame.pack(fill="x", pady=(0, 10)) + + # 导入按钮 + ctk.CTkButton( + btn_frame, + text="导入临时角色库", + width=120, + command=lambda: self.confirm_import(import_window) + ).pack(side="left", padx=10) + + # 分析文件按钮 + ctk.CTkButton( + btn_frame, + text="分析文件", + width=100, + command=lambda: self.analyze_character_state(right_panel, left_panel) + ).pack(side="left", padx=10) + + # 加载character_state.txt按钮 + ctk.CTkButton( + btn_frame, + text="加载character_state.txt", + width=160, + command=lambda: self.load_default_character_state(right_panel) + ).pack(side="right", padx=10) + + # 从文件导入按钮 + ctk.CTkButton( + btn_frame, + text="从文件导入", + width=100, + command=lambda: self.import_from_file(right_panel) + ).pack(side="right", padx=10) + + # 设置内容区权重 + content_frame.grid_rowconfigure(0, weight=1) + + def analyze_character_state(self, right_panel, left_panel): + """分析角色状态文件,使用LLM提取角色信息并保存到临时角色库""" + content = "" + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + content = widget.get("1.0", "end").strip() + break + + if not content: + messagebox.showwarning("警告", "未找到可分析的内容", parent=self.window) + return + + try: + # 创建临时角色库目录 + target_dir = os.path.join(self.save_path, "临时角色库") + os.makedirs(target_dir, exist_ok=True) + + # 调用LLM进行分析 + prompt = f"{Character_Import_Prompt}\n<<待分析小说文本开始>>\n{content}\n<<待分析小说文本结束>>" + response = invoke_with_cleaning( + self.llm_adapter, + prompt + ) + + # 解析LLM响应 + roles = self._parse_llm_response(response) + + if not roles: + messagebox.showwarning("警告", "未解析到有效角色信息", parent=self.window) + return + + # 直接显示分析结果而不保存到文件 + self._display_analyzed_roles(left_panel, roles) + + except Exception as e: + messagebox.showerror("分析失败", f"LLM分析出错:{str(e)}", parent=self.window) + + def _display_temp_roles(self, parent, temp_dir): + """显示临时角色库中的角色""" + # 清空左侧面板 + for widget in parent.winfo_children(): + widget.destroy() + + # 创建滚动容器 + scroll_frame = ctk.CTkScrollableFrame(parent) + scroll_frame.pack(fill="both", expand=True) + + # 读取所有临时角色文件 + self.character_checkboxes = {} + for file_name in os.listdir(temp_dir): + if file_name.endswith(".txt"): + role_name = os.path.splitext(file_name)[0] + file_path = os.path.join(temp_dir, file_name) + + # 解析角色属性 + attributes = self._parse_temp_role_file(file_path) + + # 创建带勾选框的条目 + frame = ctk.CTkFrame(scroll_frame) + frame.pack(fill="x", pady=2, padx=5) + + # 勾选框 + var = BooleanVar(value=True) + cb = ctk.CTkCheckBox(frame, text="", variable=var, width=20) + cb.pack(side="left", padx=5) + + # 角色名称 + lbl = ctk.CTkLabel(frame, text=role_name, + font=("Microsoft YaHei", 11, "bold")) + lbl.pack(side="left", padx=5) + + # 属性摘要 + attrs = [f"{k}({len(v)})" for k,v in attributes.items()] + summary = ctk.CTkLabel(frame, text=" | ".join(attrs), + text_color="gray") + summary.pack(side="right", padx=10) + + self.character_checkboxes[role_name] = { + 'var': var, + 'data': {'name': role_name, 'attributes': attributes} + } + + # 添加操作按钮 + btn_frame = ctk.CTkFrame(scroll_frame) + btn_frame.pack(fill="x", pady=5) + ctk.CTkButton(btn_frame, text="全选", + command=lambda: self._toggle_all(True)).pack(side="left") + ctk.CTkButton(btn_frame, text="取消选择", + command=lambda: self._toggle_all(False)).pack(side="left") + + def _parse_temp_role_file(self, file_path): + """解析临时角色文件""" + attributes = {} + current_attr = None + try: + with open(file_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # 统一解析├──和└──两种前缀 + if any(prefix in line for prefix in ['├──', '└──']) and ':' in line: + prefix = '├──' if '├──' in line else '└──' + current_attr = line.split(prefix)[1].split(':')[0].strip() + attributes[current_attr] = [] + elif any(prefix in line for prefix in ['│ ├──', '│ └──']): + prefix = '│ ├──' if '│ ├──' in line else '│ └──' + if current_attr: + item = line.split(prefix)[1].strip() + attributes[current_attr].append(item) + except Exception as e: + messagebox.showerror("解析错误", f"解析临时文件失败:{str(e)}", parent=self.window) + return attributes + + def _parse_llm_response(self, response): + """解析LLM返回的角色数据""" + roles = [] + current_role = None + current_attr = None + current_subattr = None + + attribute_pattern = re.compile(r'^([├└]──)([\w\u4e00-\u9fa5]+)\s*[::]') + item_pattern = re.compile(r'^│\s+([├└]──)\s*(.*)') + + for line in response.split('\n'): + line = line.rstrip() + + # 检测角色名称行(兼容中英文冒号和前后空格) + role_match = re.match(r'^\s*([\u4e00-\u9fa5a-zA-Z0-9]+)\s*[::]\s*$', line) + if role_match: + current_role = role_match.group(1).strip() + roles.append({'name': current_role, 'attributes': {}}) + continue + + if not current_role: + continue + + # 解析属性(支持子属性) + attr_match = attribute_pattern.match(line) + if attr_match: + prefix, attr_name = attr_match.groups() + current_attr = attr_name.strip() + roles[-1]['attributes'][current_attr] = [] + current_subattr = None + continue + + # 解析属性条目(支持多级结构) + item_match = item_pattern.match(line) + if item_match and current_attr: + prefix, content = item_match.groups() + content = content.strip() + + # 解析子属性(例如"身体状态: xxx") + if ':' in content or ':' in content: + subattr_match = re.split(r'[::]', content, 1) + if len(subattr_match) > 1: + current_subattr = subattr_match[0].strip() + value = subattr_match[1].strip() + if value: # 值不为空时才添加 + roles[-1]['attributes'][current_attr].append( + f"{current_subattr}: {value}" + ) + continue + + # 普通条目处理 + if content: + if current_subattr: + # 子属性的延续条目 + roles[-1]['attributes'][current_attr][-1] += f",{content}" + else: + roles[-1]['attributes'][current_attr].append(content) + return roles + + def _display_analyzed_roles(self, parent, roles): + """显示分析后的角色列表""" + self.character_checkboxes = {} + + # 创建带滚动条的容器 + scroll_frame = ctk.CTkScrollableFrame(parent) + scroll_frame.pack(fill="both", expand=True, padx=5, pady=5) + scroll_frame.grid_rowconfigure(0, weight=1) + scroll_frame.grid_columnconfigure(0, weight=1) + + # 为每个角色创建带勾选框的条目 + for role in roles: + frame = ctk.CTkFrame(scroll_frame) + frame.pack(fill="x", pady=2, padx=5) + + # 勾选框 + var = BooleanVar(value=True) + cb = ctk.CTkCheckBox(frame, text="", variable=var, width=20) + cb.pack(side="left", padx=5) + + # 角色名称标签 + lbl = ctk.CTkLabel(frame, text=role['name'], + font=("Microsoft YaHei", 11, "bold")) + lbl.pack(side="left", padx=5) + + # 属性摘要 + attrs = [f"{k}({len(v)})" for k,v in role['attributes'].items()] + summary = ctk.CTkLabel(frame, text=" | ".join(attrs), + text_color="gray") + summary.pack(side="right", padx=10) + + self.character_checkboxes[role['name']] = { + 'var': var, + 'data': role + } + + # 添加全选/反选按钮 + btn_frame = ctk.CTkFrame(scroll_frame) + btn_frame.pack(fill="x", pady=5) + + ctk.CTkButton(btn_frame, text="全选", + command=lambda: self._toggle_all(True)).pack(side="left") + ctk.CTkButton(btn_frame, text="反选", + command=lambda: self._toggle_all(False)).pack(side="left") + + def _toggle_all(self, select): + """全选/反选操作""" + for role in self.character_checkboxes.values(): + current_state = role['var'].get() + # 如果是反选操作,则设置相反状态 + if isinstance(select, bool): + role['var'].set(select) + else: + role['var'].set(not current_state) + + + def import_from_file(self, right_panel): + """从文件导入内容到右侧窗口""" + filetypes = ( + ('文本文件', '*.txt'), + ('Word文档', '*.docx'), + ('所有文件', '*.*') + ) + + file_path = filedialog.askopenfilename( + title="选择要导入的文件", + initialdir=os.path.expanduser("~"), + filetypes=filetypes + ) + + if not file_path: + return + + try: + content = "" + if file_path.endswith('.docx'): + # 处理Word文档 + from docx import Document + doc = Document(file_path) + content = "\n".join([para.text for para in doc.paragraphs]) + else: + # 处理普通文本文件 + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 更新右侧文本框中 + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + widget.delete("1.0", "end") + widget.insert("1.0", content) + break + + except Exception as e: + messagebox.showerror("导入失败", f"无法读取文件:{str(e)}", parent=self.window) + + def load_default_character_state(self, right_panel): + """加载character_state.txt文件到右侧窗口""" + # 获取保存路径 + save_path = os.path.dirname(self.save_path) + file_path = os.path.join(save_path, "character_state.txt") + + if not os.path.exists(file_path): + messagebox.showwarning("警告", f"未找到文件: {file_path}", parent=self.window) + return + + try: + # 读取文件内容 + content = read_file(file_path) + + # 清空右侧面板中可能存在的旧控件 + for widget in right_panel.winfo_children(): + widget.destroy() + + # 查找或创建文本框 + text_box = None + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + text_box = widget + break + + if not text_box: + text_box = ctk.CTkTextbox(right_panel, wrap="word") + text_box.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + text_box.configure(state="normal") + text_box.delete("1.0", "end") + text_box.insert("1.0", content) + + # 设置右边面板的布局权重 + right_panel.grid_rowconfigure(0, weight=1) + right_panel.grid_columnconfigure(0, weight=1) + + except Exception as e: + messagebox.showerror("错误", f"加载文件失败: {str(e)}", parent=self.window) + + def confirm_import(self, import_window): + """从临时角色库导入选中的角色""" + # 创建必要的目录 + target_dir = os.path.join(self.save_path, "临时角色库") + os.makedirs(target_dir, exist_ok=True) + + try: + # 获取选中的角色 + selected_roles = [role_data['data'] for role_data in self.character_checkboxes.values() + if role_data['var'].get()] + + if not selected_roles: + # 创建错误提示窗口 + error_window = ctk.CTkToplevel(import_window) + error_window.title("错误") + error_window.transient(import_window) + error_window.grab_set() + + # 窗口内容 + ctk.CTkLabel(error_window, text="请至少选择一个角色").pack(padx=20, pady=10) + ctk.CTkButton(error_window, text="确定", command=error_window.destroy).pack(pady=10) + + # 窗口居中 + error_window.update_idletasks() + e_width = error_window.winfo_width() + e_height = error_window.winfo_height() + x = import_window.winfo_x() + (import_window.winfo_width() - e_width) // 2 + y = import_window.winfo_y() + (import_window.winfo_height() - e_height) // 2 + error_window.geometry(f"+{x}+{y}") + error_window.attributes('-topmost', 1) + return + + # 从内存数据直接保存角色 + for role in selected_roles: + dest_path = os.path.join(target_dir, f"{role['name']}.txt") + + # 构建角色内容 + content_lines = [f"{role['name']}:"] + for attr, items in role['attributes'].items(): + content_lines.append(f"├──{attr}:") + for i, item in enumerate(items): + prefix = "├──" if i < len(items)-1 else "└──" + content_lines.append(f"│ {prefix}{item}") + + # 直接写入文件,覆盖已存在的文件 + with open(dest_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(content_lines)) + + # 刷新分类显示 + self.load_categories() + import_window.destroy() + + except Exception as e: + # 静默处理错误 + import_window.destroy() + + + + def delete_current_role(self): + """删除当前角色""" + if not hasattr(self, 'current_role') or not self.current_role: + return + + confirm = messagebox.askyesno( + "确认删除", f"确定要删除角色 {self.current_role} 吗?", parent=self.window) + if not confirm: + return + + role_path = os.path.join( + self.save_path, self.selected_category, f"{self.current_role}.txt") + try: + os.remove(role_path) + # 从"全部"分类也删除 + all_path = os.path.join( + self.save_path, "全部", f"{self.current_role}.txt") + if os.path.exists(all_path): + os.remove(all_path) + self.show_category(self.selected_category) + self.preview_text.delete("1.0", "end") + msg = messagebox.showinfo("成功", "角色已删除", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + except Exception as e: + msg = messagebox.showerror("错误", f"删除失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + + def _build_role_content(self): + """构建角色文件内容""" + content = [f"{self.role_name_var.get()}:"] + attributes_order = ["物品", "能力", "状态", "主要角色间关系网", "触发或加深的事件"] + + for attr_name in attributes_order: + content.append(f"├──{attr_name}:") + # 找到对应的 attribute_block + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + # 遍历该 block 中的所有 CTkEntry + for child in block.winfo_children(): + if isinstance(child, ctk.CTkFrame): # 条目行 + for item in child.winfo_children(): + if isinstance(item, ctk.CTkEntry): + entry_text = item.get().strip() + if entry_text: # 只添加非空条目 + content.append(f"│ ├──{entry_text}") + break # 找到对应属性后跳出循环 + return content + + def _save_role_file(self, content, save_path): + """保存角色文件""" + with open(save_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(content)) + + def _check_role_name_conflict(self, new_name): + """检查角色名是否重复,遍历整个角色文件夹""" + conflicts = [] + # 遍历所有分类目录 + for category in os.listdir(self.save_path): + if os.path.isdir(os.path.join(self.save_path, category)): + # 检查该分类下是否有同名角色 + role_path = os.path.join( + self.save_path, category, f"{new_name}.txt") + if os.path.exists(role_path): + # 如果是"全部"分类,需要进一步检查是否是实际文件 + if category == "全部": + # 检查"全部"目录下的文件是否是实际文件 + all_path = os.path.join( + self.save_path, "全部", f"{new_name}.txt") + if os.path.isfile(all_path): + # 如果是实际文件,则认为是冲突 + conflicts.append(category) + else: + # 普通分类直接记录冲突 + conflicts.append(category) + return conflicts + + def save_current_role(self): + """保存当前编辑的角色""" + if not hasattr(self, 'current_role') or not self.current_role: + return + + new_name = self.role_name_var.get().strip() + if not new_name: + msg = messagebox.showwarning("警告", "角色名称不能为空", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + # 检查角色名是否重复 + if new_name != self.current_role: + conflicts = self._check_role_name_conflict(new_name) + if conflicts: + messagebox.showerror("错误", + f"角色名称 '{new_name}' 已存在于以下分类中:\n" + + "\n".join(conflicts) + + "\n请使用不同的角色名称", parent=self.window) + return + + content = self._build_role_content() + save_path = os.path.join(self.save_path, self.selected_category, + f"{new_name}.txt") + + try: + self._save_role_file(content, save_path) + # 如果修改了角色名,更新文件名 + if new_name != self.current_role: + old_path = os.path.join(self.save_path, self.selected_category, + f"{self.current_role}.txt") + os.rename(old_path, save_path) + + # 更新显示 + self.current_role = new_name + self.show_category(self.selected_category) + self.show_role(new_name) # 刷新角色显示 + messagebox.showinfo("成功", "角色已保存", parent=self.window) + except Exception as e: + messagebox.showerror("错误", f"保存失败:{str(e)}", parent=self.window) + + def _rename_role_file(self): + """修改角色名称""" + old_name = self.current_role + new_name = self.role_name_var.get().strip() + + if not old_name or not new_name: + return + + # 处理中英文冒号 + for colon in [":", ":"]: + old_name = old_name.split(colon)[0] + new_name = new_name.split(colon)[0] + + # 如果角色名没有改变,直接返回 + if new_name == old_name: + return + + # 检查角色名是否重复 + conflicts = self._check_role_name_conflict(new_name) + if conflicts: + messagebox.showerror("错误", + f"角色名称 '{new_name}' 已存在于以下分类中:\n" + + "\n".join(conflicts) + + "\n请使用不同的角色名称", parent=self.window) + return + + try: + # 如果是"全部"分类,需要找到实际存储的分类 + if self.selected_category == "全部": + # 首先检查"全部"目录下是否有该角色文件 + all_path = os.path.join( + self.save_path, "全部", f"{old_name}.txt") + if os.path.exists(all_path): + # 如果"全部"目录下有文件,则直接操作 + actual_category = "全部" + else: + # 遍历所有分类查找实际存储位置 + actual_category = None + for category in os.listdir(self.save_path): + if category == "全部": + continue + test_path = os.path.join( + self.save_path, category, f"{old_name}.txt") + if os.path.exists(test_path): + actual_category = category + break + + if not actual_category: + raise FileNotFoundError( + f"找不到角色 {old_name} 的实际存储位置") + else: + actual_category = self.selected_category + + # 读取旧文件内容并更新角色名 + old_path = os.path.join( + self.save_path, actual_category, f"{old_name}.txt") + with open(old_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 获取第一行内容 + first_line = content.split('\n')[0].strip() + # 提取内容中的角色名 + content_role_name = first_line.split(':')[0].split(':')[0].strip() + # 如果内容中的角色名与旧文件名不同,更新内容 + if content_role_name != old_name: + content = content.replace( + f"{content_role_name}:", f"{new_name}:", 1) + else: + content = content.replace(f"{old_name}:", f"{new_name}:", 1) + + # 写入新文件 + new_path = os.path.join( + self.save_path, actual_category, f"{new_name}.txt") + with open(new_path, 'w', encoding='utf-8') as f: + f.write(content) + + # 删除旧文件 + os.remove(old_path) + + # 处理"全部"目录 + all_old_path = os.path.join( + self.save_path, "全部", f"{old_name}.txt") + all_new_path = os.path.join( + self.save_path, "全部", f"{new_name}.txt") + + # 如果"全部"目录存在旧文件 + if os.path.exists(all_old_path): + try: + # 更新"全部"目录中的文件内容 + with open(all_old_path, 'r', encoding='utf-8') as f: + all_content = f.read() + updated_all_content = all_content.replace( + f"{old_name}:", f"{new_name}:", 1) + + # 写入新文件 + with open(all_new_path, 'w', encoding='utf-8') as f: + f.write(updated_all_content) + + # 删除旧文件 + os.remove(all_old_path) + except Exception as e: + messagebox.showerror("错误", f"更新全部目录失败: {str(e)}", parent=self.window) + # 回滚重命名操作 + os.rename(new_path, old_path) + return + + # 刷新显示 + self.current_role = new_name + self.show_category(self.selected_category) + self.role_name_var.set(new_name) + self.show_role(new_name) # 刷新角色显示区域 + + except Exception as e: + msg = messagebox.showerror("错误", f"重命名失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + + def _create_new_role(self, category): + """在指定分类创建新角色""" + role_dir = os.path.join(self.save_path, category) + base_name = "未命名" + counter = 1 + + # 生成唯一文件名 + while os.path.exists(os.path.join(role_dir, f"{base_name}.txt")): + base_name = f"未命名{counter}" + counter += 1 + + # 创建基础文件结构(包含初始条目) + content = f"{base_name}:\n" + "\n".join([ + "├──物品:", + "│ └──待补充", + "├──能力:", + "│ └──待补充", + "├──状态:", + "│ └──待补充", + "├──主要角色间关系网:", + "│ └──待补充", + "├──触发或加深的事件:", + "│ └──待补充" + ]) + + with open(os.path.join(role_dir, f"{base_name}.txt"), "w", encoding="utf-8") as f: + f.write(content) + + # 刷新显示 + self.show_category(category) + self.role_name_var.set(base_name) + self.current_role = base_name + + def create_category_bar(self): + """创建分类按钮区""" + category_frame = ctk.CTkFrame(self.window) + category_frame.pack(fill="x", padx=10, pady=5) + + # 操作提示 + ctk.CTkLabel(category_frame, + text="右键分类名即可重命名", + font=("Microsoft YaHei", 10), + text_color="gray").pack(side="top", anchor="w", padx=5) + + # 固定按钮 + ctk.CTkButton(category_frame, text="全部", width=50, + command=lambda: self.show_category("全部")).pack(side="left", padx=2) + + # 滚动分类区 + self.scroll_frame = ctk.CTkScrollableFrame( + category_frame, orientation="horizontal", height=30) + self.scroll_frame.pack(side="left", fill="x", expand=True, padx=5) + + # 操作按钮 + ctk.CTkButton(category_frame, text="新增", width=50, + command=self.add_category).pack(side="right", padx=2) + ctk.CTkButton(category_frame, text="删除", width=50, + command=self.delete_category).pack(side="right", padx=2) + + self.load_categories() + + def center_window(self): + """窗口居中""" + self.window.update_idletasks() + parent_x = self.master.winfo_x() + parent_y = self.master.winfo_y() + parent_width = self.master.winfo_width() + parent_height = self.master.winfo_height() + win_width = 1200 + win_height = 800 + x = parent_x + (parent_width - win_width) // 2 + y = parent_y + (parent_height - win_height) // 2 + self.window.geometry(f"{win_width}x{win_height}+{x}+{y}") + + def load_categories(self): + """加载分类按钮""" + for widget in self.scroll_frame.winfo_children(): + widget.destroy() + + categories = [d for d in os.listdir(self.save_path) + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部"] + + for category in categories: + btn = ctk.CTkButton(self.scroll_frame, text=category, width=80) + btn.bind("", lambda e, c=category: self.show_category(c)) + btn.bind("", lambda e, c=category: self.rename_category(c)) + btn.pack(side="left", padx=2) + + def _create_category_directory(self, category_name): + """创建分类目录""" + new_dir = os.path.join(self.save_path, category_name) + if not os.path.exists(new_dir): + os.makedirs(new_dir) + return new_dir + + def add_category(self): + """新增分类""" + self._create_category_directory("未命名") + self.load_categories() + # 刷新分类选择下拉框 + self.category_combobox.configure(values=self._get_all_categories()) + + def delete_category(self): + """删除分类对话框""" + if not self.window.winfo_exists(): + return + + del_window = ctk.CTkToplevel(self.window) + del_window.title("删除分类") + del_window.transient(self.window) + del_window.grab_set() + del_window.attributes('-topmost', 1) + + # 居中计算 + parent_x = self.window.winfo_x() + parent_y = self.window.winfo_y() + parent_width = self.window.winfo_width() + parent_height = self.window.winfo_height() + del_window.geometry( + f"300x400+{parent_x + (parent_width-300)//2}+{parent_y + (parent_height-400)//2}") + + scroll_frame = ctk.CTkScrollableFrame(del_window) + scroll_frame.pack(fill="both", expand=True) + + categories = [d for d in os.listdir(self.save_path) + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部"] + self.selected_del = [] + + for cat in categories: + var = tk.BooleanVar() + chk = ctk.CTkCheckBox(scroll_frame, text=cat, variable=var) + chk.pack(anchor="w") + self.selected_del.append((cat, var)) + + # 操作按钮 + btn_frame = ctk.CTkFrame(del_window) + btn_frame.pack(fill="x", pady=5) + + ctk.CTkButton(btn_frame, text="删除选中", + command=lambda: self.confirm_delete(del_window)).pack(side="left", padx=5) + ctk.CTkButton(btn_frame, text="取消", + command=del_window.destroy).pack(side="right", padx=5) + + self.category_combobox.configure(values=self._get_all_categories()) + self.category_combobox.set("全部") + + def confirm_delete(self, original_window): + """确认删除操作""" + selected = [item[0] for item in self.selected_del if item[1].get()] + if not selected: + msg = messagebox.showwarning("警告", "请至少选择一个分类", parent=self.window) + self.window.attributes('-topmost', 1) + self.window.after(200, lambda: self.window.attributes('-topmost', 0)) + return + + # 创建选择窗口时添加前置设置 + choice_window = ctk.CTkToplevel(self.window) + choice_window.transient(self.window) # 设置为子窗口 + choice_window.grab_set() # 模态窗口 + choice_window.lift() # 置顶 + choice_window.attributes('-topmost', 1) # 强制置顶 + + # 添加居中计算 + choice_window.update_idletasks() + c_width = choice_window.winfo_width() + c_height = choice_window.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - c_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - c_height) // 2 + choice_window.geometry(f"+{x}+{y}") + + ctk.CTkLabel(choice_window, text="请选择删除方式:").pack(pady=10) + btn_frame = ctk.CTkFrame(choice_window) + btn_frame.pack(pady=10) + + def perform_delete(mode): + all_dir = os.path.join(self.save_path, "全部") + for cat in selected: + cat_path = os.path.join(self.save_path, cat) + if mode == "move": + for role_file in os.listdir(cat_path): + if role_file.endswith(".txt"): + src = os.path.join(cat_path, role_file) + dst = os.path.join(all_dir, role_file) + try: + shutil.move(src, dst) + except: + os.remove(dst) + shutil.move(src, dst) + shutil.rmtree(cat_path) + self.load_categories() + # 刷新分类选择下拉框 + self.category_combobox.configure(values=self._get_all_categories()) + original_window.destroy() + choice_window.destroy() + + ctk.CTkButton(btn_frame, text="全部删除", + command=lambda: perform_delete("all")).pack(side="left", padx=5) + ctk.CTkButton(btn_frame, text="移动角色", + command=lambda: perform_delete("move")).pack(side="left", padx=5) + + def count_roles(self, categories): + """统计角色数量""" + count = 0 + for cat in categories: + cat_path = os.path.join(self.save_path, cat) + count += len([f for f in os.listdir(cat_path) if f.endswith(".txt")]) + return count + + def show_category(self, category): + """显示分类内容""" + self.selected_category = category + self.category_combobox.set(category) + for widget in self.role_list_frame.winfo_children(): + widget.destroy() + + # 如果是"全部"分类,显示所有角色 + if category == "全部": + # 获取所有分类目录 + categories = [d for d in os.listdir(self.save_path) + + if os.path.isdir(os.path.join(self.save_path, d))] + # 用于去重的角色集合 + unique_roles = set() + + for cat in categories: + role_dir = os.path.join(self.save_path, cat) + try: + for role_file in os.listdir(role_dir): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + # 去重 + if role_name not in unique_roles: + unique_roles.add(role_name) + btn = ctk.CTkButton( + self.role_list_frame, + text=role_name, + command=lambda r=role_name: self.show_role(r) + ) + btn.pack(fill="x", pady=2) + except FileNotFoundError: + continue + else: + # 普通分类显示 + role_dir = os.path.join(self.save_path, category) + try: + for role_file in os.listdir(role_dir): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + btn = ctk.CTkButton( + self.role_list_frame, + text=role_name, + command=lambda r=role_name: self.show_role(r) + ) + btn.pack(fill="x", pady=2) + except FileNotFoundError: + messagebox.showerror("错误", "分类目录不存在", parent=self.window) + + def show_role(self, role_name): + """显示角色详细信息(支持UTF-8/ANSI编码)""" + try: + # 清空现有属性控件 + self.preview_text.delete('1.0', tk.END) + for widget in self.attributes_frame.winfo_children(): + widget.destroy() + + # 更新角色名称显示 + self.current_role = role_name.split(":")[0].split(":")[0] + self.role_name_var.set(self.current_role) + + # 查找角色实际所在目录 + if self.selected_category == "全部": + # 首先检查"全部"目录下是否有该角色文件 + all_path = os.path.join( + self.save_path, "全部", f"{role_name}.txt") + if os.path.exists(all_path): + file_path = all_path + actual_category = "全部" + else: + # 如果"全部"目录下没有,则遍历其他分类查找 + file_path = None + for cat in os.listdir(self.save_path): + if cat == "全部": + continue + test_path = os.path.join( + self.save_path, cat, f"{role_name}.txt") + if os.path.exists(test_path): + file_path = test_path + actual_category = cat + # 保存实际分类 + self.actual_category = cat + break + if file_path is None: + raise FileNotFoundError(f"找不到角色文件:{role_name}") + + # 只更新分类选择框的显示值,不改变当前选中的分类 + self.category_combobox.set(actual_category) + else: + # 普通分类直接使用当前路径 + file_path = os.path.join( + self.save_path, self.selected_category, f"{role_name}.txt") + + content, _ = self._read_file_with_fallback_encoding(file_path) + + # 解析属性结构 + attributes = { + "物品": [], + "能力": [], + "状态": [], + "主要角色间关系网": [], + "触发或加深的事件": [] + } + current_attribute = None + for line in content[1:]: + # 改进属性名称识别 + if line.startswith(("├──", "├──")): + # 提取属性名称(兼容冒号和空格) + attr_part = line.split("──")[1].strip() + attr_name = re.split(r'[::]', attr_part, 1)[0].strip() + + # 匹配预设属性 + for preset_attr in attributes: + if attr_name == preset_attr: + current_attribute = preset_attr + indent_level = line.find( + "├") if "├" in line else line.find("├") + break + else: + current_attribute = None + + # 改进条目内容提取 + elif current_attribute and line.startswith(("│ ", " ")): + # 提取整个条目内容 + item_content = line.strip() + # 去掉前面的符号和空格 + item_content = re.sub(r'^[│├└─\s]*', '', item_content) + attributes[current_attribute].append(item_content) + + # 显示原始文件内容 + self.preview_text.insert(tk.END, '\n'.join(content)) + + # 重构属性编辑区 + for attr_name, items in attributes.items(): + self._create_attribute_section(attr_name, items) + + except FileNotFoundError as e: + messagebox.showerror("错误", f"文件不存在:{str(e)}", parent=self.window) + except Exception as e: + messagebox.showerror("错误", f"读取文件失败:{str(e)}", parent=self.window) + + def _create_attribute_section(self, attr_name, items): + """创建单个属性的编辑区域""" + + # 属性块 (attribute_block) + attribute_block = ctk.CTkFrame(self.attributes_frame) + attribute_block.pack(fill="x", pady=5) + attribute_block.attribute_name = attr_name # 存储属性名称 + attribute_block.grid_columnconfigure(1, weight=1) # 设置第二列权重 + attribute_block.grid_columnconfigure(1, weight=1) # 设置第二列权重 + + # 属性名称标签 + label = ctk.CTkLabel(attribute_block, text=attr_name) + label.grid(row=0, column=0, sticky="w", padx=(5, 10), pady=2) + + # 第一个条目和“增加”按钮的容器 + first_item_frame = ctk.CTkFrame(attribute_block) + first_item_frame.grid(row=0, column=1, sticky="ew", padx=5, pady=2) + first_item_frame.grid_columnconfigure(0, weight=1) + + # 第一个条目输入框 + first_entry = ctk.CTkEntry(first_item_frame) + first_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5), ipadx=5, ipady=3) + if items: + first_entry.insert(0, items[0]) # 填充第一个条目的内容 + + # “增加”按钮容器 + add_button_frame = ctk.CTkFrame(first_item_frame, fg_color="transparent") + add_button_frame.grid(row=0, column=1, sticky="e", padx=(5, 0)) + + # “增加”按钮 + add_button = ctk.CTkButton( + add_button_frame, + text="+", + width=30, + command=lambda: self._add_item(attr_name) + ) + add_button.grid(row=0, column=0) + + # 创建剩余的条目(如果有) + for i, item_text in enumerate(items[1:]): + self._add_item(attr_name, item_text) # 传入初始文本 + + def _add_item(self, attr_name, initial_text=""): + """为指定属性添加一个新条目""" + + # 找到对应的 attribute_block + attribute_block = None + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + attribute_block = block + break + + if attribute_block is None: + return + + # 计算新条目的行号 + row_number = 0 + for child in attribute_block.winfo_children(): + if isinstance(child, ctk.CTkFrame): + row_number += 1 + + # 条目容器 + item_frame = ctk.CTkFrame(attribute_block) + item_frame.grid(row=row_number, column=1, sticky="ew", padx=5, pady=2) + item_frame.grid_columnconfigure(0, weight=1) + + # 条目输入框 + new_entry = ctk.CTkEntry(item_frame) + new_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5), ipadx=5, ipady=3) + new_entry.insert(0, initial_text) # 设置初始文本 + + # 删除按钮容器 + del_button_frame = ctk.CTkFrame(item_frame, fg_color="transparent") + del_button_frame.grid(row=0, column=1, sticky="e", padx=(5, 0)) + # “删除”按钮 + del_button = ctk.CTkButton( + del_button_frame, + text="-", + width=30, + command=lambda f=item_frame: self._remove_item(f, attr_name) + ) + del_button.grid(row=0, column=0) + + + def _remove_item(self, item_frame, attr_name): + """移除指定的条目,并重新调整布局""" + + # 找到对应的 attribute_block + attribute_block = None + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + attribute_block = block + break + + if attribute_block is None: + return + + # 确认不是删除带"+"号的原始条目 + for child in item_frame.winfo_children(): + if isinstance(child, ctk.CTkFrame): + for btn in child.winfo_children(): + if isinstance(btn, ctk.CTkButton) and btn.cget("text") == "+": + msg = messagebox.showinfo("提示", "不能删除带'+'号的原始条目", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + # 移除条目 + item_frame.destroy() + + # 重新调整剩余条目的行号 + current_row = 0 + for child in attribute_block.winfo_children(): + if isinstance(child, ctk.CTkFrame): + if current_row == 0: # 找到属性标签 + current_row += 1 + continue + ctk.CTkFrame.grid_configure(child, row=current_row) + current_row += 1 + + def _read_file_with_fallback_encoding(self, file_path): + """带编码回退的文件读取,支持UTF-8、GBK(ANSI)和BOM""" + encodings = ['utf-8-sig', 'utf-8', 'gbk', 'latin1'] # 增加更多编码支持 + + for encoding in encodings: + try: + with open(file_path, "r", encoding=encoding) as f: + content = f.read() + # 检查内容是否包含乱码 + if any(ord(char) > 127 and not char.isprintable() for char in content): + continue # 如果包含乱码,尝试下一个编码 + return content.splitlines(), encoding + except UnicodeDecodeError: + continue + except Exception as e: + raise + + # 如果所有编码尝试都失败,尝试二进制读取 + try: + with open(file_path, "rb") as f: + raw_data = f.read() + # 尝试UTF-8解码 + try: + return raw_data.decode('utf-8').splitlines(), 'utf-8' + except UnicodeDecodeError: + # 尝试GBK解码 + try: + return raw_data.decode('gbk').splitlines(), 'gbk' + except UnicodeDecodeError: + # 最后尝试latin1解码 + return raw_data.decode('latin1').splitlines(), 'latin1' + except Exception as e: + raise ValueError(f"无法识别的文件编码:{file_path}") + + def rename_category(self, old_name): + """分类重命名(带居中功能)""" + new_name = None # 初始化变量 + + # 创建对话框窗口 + dialog = ctk.CTkToplevel(self.window) + dialog.title("重命名分类") + dialog.transient(self.window) + dialog.grab_set() + + # 窗口内容 + content_frame = ctk.CTkFrame(dialog) + content_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 顶部提示 + ctk.CTkLabel(content_frame, text=f"当前分类:{old_name}").pack(pady=(10, 5)) + + # 输入框 + input_frame = ctk.CTkFrame(content_frame) + input_frame.pack(fill="x", pady=5) + ctk.CTkLabel(input_frame, text="新名称:").pack(side="left", padx=5) + name_var = tk.StringVar() + name_entry = ctk.CTkEntry(input_frame, textvariable=name_var, width=150) + name_entry.pack(side="left", padx=5) + + # 按钮区 + button_frame = ctk.CTkFrame(content_frame) + button_frame.pack(fill="x", pady=(10, 5)) + + def confirm_rename(): + nonlocal new_name # 引用外部变量 + new_name = name_var.get().strip() + if not new_name: + messagebox.showwarning("警告", "分类名称不能为空", parent=self.window) + return + if new_name == old_name: + dialog.destroy() + return + if os.path.exists(os.path.join(self.save_path, new_name)): + messagebox.showerror("错误", "分类名称已存在", parent=self.window) + return + + try: + os.rename(os.path.join(self.save_path, old_name), + os.path.join(self.save_path, new_name)) + self.load_categories() + # 更新分类选择框 + self.category_combobox.configure( + values=self._get_all_categories()) + self.category_combobox.set(new_name) + dialog.destroy() + except Exception as e: + messagebox.showerror("错误", f"重命名失败:{str(e)}", parent=self.window) + + ctk.CTkButton(button_frame, text="确认", + command=confirm_rename).pack(side="left", padx=10) + ctk.CTkButton(button_frame, text="取消", + command=dialog.destroy).pack(side="right", padx=10) + + # 窗口居中 + dialog.update_idletasks() + d_width = dialog.winfo_width() + d_height = dialog.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - d_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - d_height) // 2 + dialog.geometry(f"+{x}+{y}") + dialog.attributes('-topmost', 1) + + def on_close(self): + """关闭窗口""" + self.window.destroy() + + From a14aab325545f4da7a97fa86677446261846ff07 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:15:18 +0800 Subject: [PATCH 107/142] Update chapter.py --- novel_generator/chapter.py | 40 ++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/novel_generator/chapter.py b/novel_generator/chapter.py index 9d1a6a4..7758564 100644 --- a/novel_generator/chapter.py +++ b/novel_generator/chapter.py @@ -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, From 338db8d7cf59be844e386038aba78309a7b68032 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:16:04 +0800 Subject: [PATCH 108/142] Update common.py --- novel_generator/common.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/novel_generator/common.py b/novel_generator/common.py index f31fed7..1a620da 100644 --- a/novel_generator/common.py +++ b/novel_generator/common.py @@ -44,16 +44,24 @@ def debug_log(prompt: str, response_content: str): def invoke_with_cleaning(llm_adapter, prompt: str) -> str: """ - 调用 LLM,增加重试和清洗逻辑 - 如果多次失败,则返回空字符串以继续流程,而不是中断。 + 增强版调用方法,支持自定义prompt结构 """ def _invoke(prompt): return llm_adapter.invoke(prompt) 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) + + # 移除可能的多余标记 + cleaned_text = re.sub(r'^```markdown\s*', '', cleaned_text, flags=re.IGNORECASE) + cleaned_text = re.sub(r'\s*```$', '', cleaned_text) + debug_log(prompt, cleaned_text) return cleaned_text.strip() + From b46f58651cf515a21717a300218696a0b0f4fd5b Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 05:23:40 +0800 Subject: [PATCH 109/142] Update generation_handlers.py --- ui/generation_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index db6a6ba..abf111b 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -192,7 +192,7 @@ def generate_chapter_draft_ui(self): file_path = os.path.join(root, file) try: with open(file_path, 'r', encoding='utf-8') as f: - role_contents.append(f"{os.path.splitext(file)[0]}:\n{f.read()}\n") + role_contents.append(f.read().strip()) # 直接使用文件内容,不添加重复名字 except Exception as e: self.safe_log(f"读取角色文件 {file} 失败: {str(e)}") From 2fd7c4f2dd691de55e7b7e0965669e5567da297e Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 05:23:55 +0800 Subject: [PATCH 110/142] Update main_window.py --- ui/main_window.py | 47 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/ui/main_window.py b/ui/main_window.py index 0e89687..8f8f75a 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -246,22 +246,55 @@ class NovelGeneratorGUI: # 动态加载角色分类 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_label = ctk.CTkLabel(scroll_frame, text=f"【{category}】", font=("Microsoft YaHei", 12, "bold")) - category_label.pack(anchor="w", pady=(10,5)) + # 创建分类容器 + 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(scroll_frame, text=role_name) - chk.pack(anchor="w", padx=20) + 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) From 4d707f1eb6428510168081c7eb34d18bda4270c3 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:30:22 +0800 Subject: [PATCH 111/142] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fa7fbb7..778ee47 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@
> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 - +2025-03-05 添加角色库功能 --- ## 📑 目录导航 From a3a8a021685841c1ac7065263d5203e048423f2f Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:30:56 +0800 Subject: [PATCH 112/142] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 778ee47..761ee2f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ > 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 + 2025-03-05 添加角色库功能 --- From 4f249439235eaedf9736114bb06782e1700662e7 Mon Sep 17 00:00:00 2001 From: FynnCX Date: Thu, 6 Mar 2025 00:07:17 +0800 Subject: [PATCH 113/142] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=94=AF=E6=8C=81Sil?= =?UTF-8?q?iconFlow=E7=9A=84embedding=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_adapters.py | 46 +++++++++++++++++++++++++++++++++++++++++-- ui/config_tab.py | 10 ++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/embedding_adapters.py b/embedding_adapters.py index 6bc6ddb..cb00673 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -1,10 +1,12 @@ # embedding_adapters.py # -*- coding: utf-8 -*- import logging -import requests import traceback from typing import List -from langchain_openai import OpenAIEmbeddings, AzureOpenAIEmbeddings + +import requests +from langchain_openai import AzureOpenAIEmbeddings, OpenAIEmbeddings + def ensure_openai_base_url_has_v1(url: str) -> str: """ @@ -187,6 +189,44 @@ class GeminiEmbeddingAdapter(BaseEmbeddingAdapter): logging.error(f"Gemini embed_content parse error: {e}\n{traceback.format_exc()}") return [] +class SiliconFlowEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 SiliconFlow 的 embedding 适配器 + """ + def __init__(self, api_key: str, base_url: str, model_name: str): + # 自动为 base_url 添加 scheme(如果缺失) + if not base_url.startswith("http://") and not base_url.startswith("https://"): + base_url = "https://" + base_url + self.url = base_url if base_url else "https://api.siliconflow.cn/v1/embeddings" + + self.payload = { + "model": model_name, + "input": "Silicon flow embedding online: fast, affordable, and high-quality embedding services. come try it out!", + "encoding_format": "float" + } + self.headers = { + "Authorization": "Bearer {api_key}".format(api_key=api_key), + "Content-Type": "application/json" + } + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + embeddings = [] + for text in texts: + self.payload["input"] = text + response = requests.post(self.url, json=self.payload, headers=self.headers) + result = response.json() + # 从返回数据中提取第一个 embedding + emb = result.get("data", [{}])[0].get("embedding", []) + embeddings.append(emb) + return embeddings + + def embed_query(self, query: str) -> List[float]: + self.payload["input"] = query + # print('SiliconFlowEmbeddingAdapter发送',self.payload) + response = requests.post(self.url, json=self.payload, headers=self.headers) + result = response.json() + return result.get("data", [{}])[0].get("embedding", []) + def create_embedding_adapter( interface_format: str, api_key: str, @@ -207,5 +247,7 @@ def create_embedding_adapter( return MLStudioEmbeddingAdapter(api_key, base_url, model_name) elif fmt == "gemini": return GeminiEmbeddingAdapter(api_key, model_name, base_url) + elif fmt == "siliconflow": + return SiliconFlowEmbeddingAdapter(api_key, base_url, model_name) else: raise ValueError(f"Unknown embedding interface_format: {interface_format}") diff --git a/ui/config_tab.py b/ui/config_tab.py index 0ca6f69..91f05a1 100644 --- a/ui/config_tab.py +++ b/ui/config_tab.py @@ -1,10 +1,13 @@ # ui/config_tab.py # -*- coding: utf-8 -*- -import customtkinter as ctk from tkinter import messagebox + +import customtkinter as ctk + from config_manager import load_config, save_config from tooltips import tooltips + def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): """ @@ -177,6 +180,9 @@ def build_embeddings_config_tab(self): elif new_value == "Gemini": self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/") self.embedding_model_name_var.set("models/text-embedding-004") + elif new_value == "SiliconFlow": + self.embedding_url_var.set("https://api.siliconflow.cn/v1/embeddings") + self.embedding_model_name_var.set("BAAI/bge-m3") for i in range(5): self.embeddings_config_tab.grid_rowconfigure(i, weight=0) @@ -191,7 +197,7 @@ 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"] + 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") From eef7fe58e0a3f32c69fd7bb4f4fc5ec7ac3b836a Mon Sep 17 00:00:00 2001 From: Karasukaigan <80465610+Karasukaigan@users.noreply.github.com> Date: Fri, 7 Mar 2025 03:27:27 +0800 Subject: [PATCH 114/142] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=83=A8=E5=88=86?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E8=AF=B4=E6=98=8E=E6=97=A0=E6=B3=95=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E6=98=BE=E7=A4=BA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 虽然`tooltips.py`里已有对各项参数的说明,但因为`novel_params_tab.py`并未正确引入tooltips,因此部分参数说明无法正确显示。此项提交修复了这个问题。 --- ui/novel_params_tab.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/novel_params_tab.py b/ui/novel_params_tab.py index 28f57f3..8eb9a9b 100644 --- a/ui/novel_params_tab.py +++ b/ui/novel_params_tab.py @@ -3,6 +3,7 @@ import customtkinter as ctk from tkinter import filedialog, messagebox from ui.context_menu import TextWidgetContextMenu +from tooltips import tooltips def build_novel_params_area(self, start_row=1): self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical") @@ -105,6 +106,6 @@ def create_label_with_help_for_novel_params(self, parent, label_text, tooltip_ke 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: messagebox.showinfo("参数说明", "暂无说明")) + command=lambda: messagebox.showinfo("参数说明", tooltips.get(tooltip_key, "暂无说明"))) btn.pack(side="left", padx=3) return frame From 51510449a978f0d9f9955794dc294035107b8973 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sat, 8 Mar 2025 09:47:44 +0800 Subject: [PATCH 115/142] Update generation_handlers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改定稿重复提醒 --- ui/generation_handlers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index abf111b..f31f1ac 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -292,9 +292,6 @@ def finalize_chapter_ui(self): if not messagebox.askyesno("确认", "确定要定稿当前章节吗?"): self.enable_button_safe(self.btn_finalize_chapter) return - if not messagebox.askyesno("确认", "确定要生成章节草稿吗?"): - self.enable_button_safe(self.btn_generate_chapter) - return self.disable_button_safe(self.btn_finalize_chapter) try: From eb62e2e82ff81890020e9d1fdeb3e8a8cff7f03d Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sat, 8 Mar 2025 09:51:37 +0800 Subject: [PATCH 116/142] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=90=8E=E6=98=BE=E7=A4=BA=E5=AD=97=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/main_tab.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/main_tab.py b/ui/main_tab.py index 2ca6add..a5f66d4 100644 --- a/ui/main_tab.py +++ b/ui/main_tab.py @@ -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("", update_word_count) + self.chapter_result.bind("", 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) From 9ed0aa0aa648294250236bc7d242901b605057a5 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:32:04 +0800 Subject: [PATCH 117/142] Update chapters_tab.py --- ui/chapters_tab.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/chapters_tab.py b/ui/chapters_tab.py index 5310fbf..2566468 100644 --- a/ui/chapters_tab.py +++ b/ui/chapters_tab.py @@ -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("", update_word_count) + self.chapter_view_text.bind("", 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) From ff6ac7c77487068305b5ed580626408853305c7b Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:32:19 +0800 Subject: [PATCH 118/142] Update character_tab.py --- ui/character_tab.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/character_tab.py b/ui/character_tab.py index 2a87670..eefd781 100644 --- a/ui/character_tab.py +++ b/ui/character_tab.py @@ -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("", update_word_count) + self.character_text.bind("", 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() From 679177e839a8d45fc051b29045989f1763be0a10 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:33:17 +0800 Subject: [PATCH 119/142] Update directory_tab.py --- ui/directory_tab.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/directory_tab.py b/ui/directory_tab.py index 09b7aba..2a26c64 100644 --- a/ui/directory_tab.py +++ b/ui/directory_tab.py @@ -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("", update_word_count) + self.directory_text.bind("", 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() From b7f814400be7cfedfdf12359313b896598e885ba Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:33:34 +0800 Subject: [PATCH 120/142] Update generation_handlers.py --- ui/generation_handlers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index f31f1ac..b68dc49 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -442,7 +442,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() From ddcd18efdbb291a7f6845c36243143666feb9da5 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:34:06 +0800 Subject: [PATCH 121/142] Update main_window.py From 447d78fb3e22d7e01b90d2293b00e8606c2f58a6 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:34:37 +0800 Subject: [PATCH 122/142] Update role_library.py --- ui/role_library.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ui/role_library.py b/ui/role_library.py index 51e13a4..0f53d58 100644 --- a/ui/role_library.py +++ b/ui/role_library.py @@ -350,6 +350,15 @@ class RoleLibrary: try: # 创建临时角色库目录 target_dir = os.path.join(self.save_path, "临时角色库") + # 清空现有临时角色库 + if os.path.exists(target_dir): + for filename in os.listdir(target_dir): + file_path = os.path.join(target_dir, filename) + try: + if os.path.isfile(file_path): + os.unlink(file_path) + except Exception as e: + print(f"删除文件{file_path}时出错: {e}") os.makedirs(target_dir, exist_ok=True) # 调用LLM进行分析 From 05232e5f1007c222b546b366a3b8d3c0def8d06e Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:34:51 +0800 Subject: [PATCH 123/142] Update setting_tab.py --- ui/setting_tab.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/setting_tab.py b/ui/setting_tab.py index 11599c1..a5b6548 100644 --- a/ui/setting_tab.py +++ b/ui/setting_tab.py @@ -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("", update_word_count) + self.setting_text.bind("", update_word_count) def load_novel_architecture(self): filepath = self.filepath_var.get().strip() From f6567577e9f51bcd489d062f9d21de42e160e738 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:35:05 +0800 Subject: [PATCH 124/142] Update summary_tab.py --- ui/summary_tab.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ui/summary_tab.py b/ui/summary_tab.py index 3f7a999..9a66d7c 100644 --- a/ui/summary_tab.py +++ b/ui/summary_tab.py @@ -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("", update_word_count) + self.summary_text.bind("", update_word_count) def load_global_summary(self): filepath = self.filepath_var.get().strip() if not filepath: From 4423663b38a6005e7104933ad8df59caf9a5f805 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:36:42 +0800 Subject: [PATCH 125/142] Update prompt_definitions.py --- prompt_definitions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/prompt_definitions.py b/prompt_definitions.py index fc943cd..4a3afa4 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -475,7 +475,7 @@ Character_Import_Prompt = """\ <<角色状态格式要求>> 1. 必须包含以下五个分类(按顺序): - ● 物品 ● 能力 ● 状态 ● 关系网 ● 触发事件 + ● 物品 ● 能力 ● 状态 ● 主要角色间关系网 ● 触发或加深的事件 2. 每个属性条目必须用【名称: 描述】格式 例:├──青衫: 一件破损的青色长袍,带有暗红色污渍 3. 状态必须包含: @@ -503,6 +503,7 @@ Character_Import_Prompt = """\ ├──触发或加深的事件: │ ├──兵器库遇袭: 丢失三把传家宝剑,影响战力 │ └──匿名威胁信: 信纸带有檀香味,暗示内部泄密 +│ 请严格按上述格式分析以下内容: <<待分析小说文本开始>> From 00620ed77f60ea4346c9e0c1831c9ad31fcbf822 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 9 Mar 2025 19:31:38 +0800 Subject: [PATCH 126/142] Update generation_handlers.py --- ui/generation_handlers.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index b68dc49..099a8d4 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -178,6 +178,10 @@ 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) + + # 字数统计标签 + 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 @@ -222,6 +226,16 @@ def generate_chapter_draft_ui(self): 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("", update_word_count) + text_box.bind("", update_word_count) + update_word_count() # 初始化统计 + button_frame = ctk.CTkFrame(dialog) button_frame.pack(pady=10) def on_confirm(): From d0a5d9bcee37324d6fff7010d8a3bba113e5f3cf Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:18:22 +0800 Subject: [PATCH 127/142] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 761ee2f..b75a9b7 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ > 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 2025-03-05 添加角色库功能 +2025-03-09 添加字数显示 --- ## 📑 目录导航 From 51fe16b18f1f9d64cd954f60fd0afe5024627ffd Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 05:49:19 +0800 Subject: [PATCH 128/142] Update architecture.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把本章指导改成内容指导,添加进部分引导词 --- novel_generator/architecture.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/novel_generator/architecture.py b/novel_generator/architecture.py index d6dae79..96b6198 100644 --- a/novel_generator/architecture.py +++ b/novel_generator/architecture.py @@ -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 @@ -105,7 +106,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 +139,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 +158,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(): From 78aa58eca2a8de6179a473a224d2f321e0a701dc Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 05:52:07 +0800 Subject: [PATCH 129/142] Update common.py --- novel_generator/common.py | 48 +++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/novel_generator/common.py b/novel_generator/common.py index 1a620da..f61b665 100644 --- a/novel_generator/common.py +++ b/novel_generator/common.py @@ -42,26 +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: - """ - 增强版调用方法,支持自定义prompt结构 - """ - def _invoke(prompt): - return llm_adapter.invoke(prompt) - - response = call_with_retry(func=_invoke, max_retries=3, fallback_return="", prompt=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") - if not response: - logging.warning("No response from model after retry. Return empty.") - return "" + result = "" + retry_count = 0 - # 增强清洗逻辑 - cleaned_text = remove_think_tags(response) + 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 - # 移除可能的多余标记 - cleaned_text = re.sub(r'^```markdown\s*', '', cleaned_text, flags=re.IGNORECASE) - cleaned_text = re.sub(r'\s*```$', '', cleaned_text) - - debug_log(prompt, cleaned_text) - return cleaned_text.strip() + return result From 1eb6ffc5358c56ab71eddf6f3eda3e5f6039bfc4 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 05:52:49 +0800 Subject: [PATCH 130/142] Update config_tab.py --- ui/config_tab.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/config_tab.py b/ui/config_tab.py index 0ca6f69..b018ae3 100644 --- a/ui/config_tab.py +++ b/ui/config_tab.py @@ -1,7 +1,7 @@ # ui/config_tab.py # -*- coding: utf-8 -*- -import customtkinter as ctk from tkinter import messagebox +import customtkinter as ctk from config_manager import load_config, save_config from tooltips import tooltips @@ -177,6 +177,10 @@ def build_embeddings_config_tab(self): elif new_value == "Gemini": self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/") self.embedding_model_name_var.set("models/text-embedding-004") + elif new_value == "SiliconFlow": + self.embedding_url_var.set("https://api.siliconflow.cn/v1/embeddings") + self.embedding_model_name_var.set("BAAI/bge-m3") + for i in range(5): self.embeddings_config_tab.grid_rowconfigure(i, weight=0) @@ -191,7 +195,7 @@ 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"] + 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") From de5c08fb0ee43f8de2b4626b89f6f016b458825f Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 05:53:44 +0800 Subject: [PATCH 131/142] Update novel_params_tab.py --- ui/novel_params_tab.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/novel_params_tab.py b/ui/novel_params_tab.py index b2183c1..125ed33 100644 --- a/ui/novel_params_tab.py +++ b/ui/novel_params_tab.py @@ -3,6 +3,8 @@ import customtkinter as ctk from tkinter import filedialog, messagebox from ui.context_menu import TextWidgetContextMenu +from tooltips import tooltips +from tooltips import tooltips def build_novel_params_area(self, start_row=1): self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical") @@ -54,9 +56,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") @@ -140,6 +142,6 @@ def create_label_with_help_for_novel_params(self, parent, label_text, tooltip_ke 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: messagebox.showinfo("参数说明", "暂无说明")) + command=lambda: messagebox.showinfo("参数说明", tooltips.get(tooltip_key, "暂无说明"))) btn.pack(side="left", padx=3) return frame From f192efadd903d89b51c2f597cf2bfa6ed4e5c0e4 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 05:54:39 +0800 Subject: [PATCH 132/142] Update embedding_adapters.py --- embedding_adapters.py | 44 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/embedding_adapters.py b/embedding_adapters.py index 6bc6ddb..03eda9e 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -1,10 +1,10 @@ # embedding_adapters.py # -*- coding: utf-8 -*- import logging -import requests import traceback from typing import List -from langchain_openai import OpenAIEmbeddings, AzureOpenAIEmbeddings +import requests +from langchain_openai import AzureOpenAIEmbeddings, OpenAIEmbeddings def ensure_openai_base_url_has_v1(url: str) -> str: """ @@ -187,6 +187,44 @@ class GeminiEmbeddingAdapter(BaseEmbeddingAdapter): logging.error(f"Gemini embed_content parse error: {e}\n{traceback.format_exc()}") return [] +class SiliconFlowEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 SiliconFlow 的 embedding 适配器 + """ + def __init__(self, api_key: str, base_url: str, model_name: str): + # 自动为 base_url 添加 scheme(如果缺失) + if not base_url.startswith("http://") and not base_url.startswith("https://"): + base_url = "https://" + base_url + self.url = base_url if base_url else "https://api.siliconflow.cn/v1/embeddings" + + self.payload = { + "model": model_name, + "input": "Silicon flow embedding online: fast, affordable, and high-quality embedding services. come try it out!", + "encoding_format": "float" + } + self.headers = { + "Authorization": "Bearer {api_key}".format(api_key=api_key), + "Content-Type": "application/json" + } + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + embeddings = [] + for text in texts: + self.payload["input"] = text + response = requests.post(self.url, json=self.payload, headers=self.headers) + result = response.json() + # 从返回数据中提取第一个 embedding + emb = result.get("data", [{}])[0].get("embedding", []) + embeddings.append(emb) + return embeddings + + def embed_query(self, query: str) -> List[float]: + self.payload["input"] = query + # print('SiliconFlowEmbeddingAdapter发送',self.payload) + response = requests.post(self.url, json=self.payload, headers=self.headers) + result = response.json() + return result.get("data", [{}])[0].get("embedding", []) + def create_embedding_adapter( interface_format: str, api_key: str, @@ -207,5 +245,7 @@ def create_embedding_adapter( return MLStudioEmbeddingAdapter(api_key, base_url, model_name) elif fmt == "gemini": return GeminiEmbeddingAdapter(api_key, model_name, base_url) + elif fmt == "siliconflow": + return SiliconFlowEmbeddingAdapter(api_key, base_url, model_name) else: raise ValueError(f"Unknown embedding interface_format: {interface_format}") From 8456fd0fbb2883e01e49e9f5983f973cf22f027f Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 05:54:57 +0800 Subject: [PATCH 133/142] Update prompt_definitions.py --- prompt_definitions.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/prompt_definitions.py b/prompt_definitions.py index 4a3afa4..4035372 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -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}章的节奏分布, From a4e3dce5c15945724d6d7da476e29bc80d9b13ee Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 06:03:13 +0800 Subject: [PATCH 134/142] Update README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index b75a9b7..1240290 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,14 @@ > 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 2025-03-05 添加角色库功能 + 2025-03-09 添加字数显示 + +2025-03-13 +1、新增闲云修改; +2、把本章指导改成内容指导; +3、在生成架构中的: 2. 角色动力学设定(角色弧光模型)、 3. 世界构建矩阵(三维度交织法)、 4. 情节架构(三幕式悬念)与生成目录的:5. 章节目录生成(悬念节奏曲线)加入引导词内容指导,以方便生成角色动力学时只以核心种子生成,导致生成的内容与实际需求不符。 +4、在终端加回被删除的LLM提示词与LLM返回内容显示,以便复盘,参考修改提示词。 --- ## 📑 目录导航 From d2576ddaedffaf72fe59d581ff9c0e5dcb473a79 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 06:38:11 +0800 Subject: [PATCH 135/142] Update config_tab.py --- ui/config_tab.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ui/config_tab.py b/ui/config_tab.py index b018ae3..c8e5161 100644 --- a/ui/config_tab.py +++ b/ui/config_tab.py @@ -1,10 +1,13 @@ # ui/config_tab.py # -*- coding: utf-8 -*- from tkinter import messagebox + import customtkinter as ctk + from config_manager import load_config, save_config from tooltips import tooltips + def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): """ @@ -89,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) @@ -108,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") @@ -177,7 +184,7 @@ def build_embeddings_config_tab(self): elif new_value == "Gemini": self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/") self.embedding_model_name_var.set("models/text-embedding-004") - elif new_value == "SiliconFlow": + elif new_value == "硅基流动": self.embedding_url_var.set("https://api.siliconflow.cn/v1/embeddings") self.embedding_model_name_var.set("BAAI/bge-m3") @@ -195,7 +202,7 @@ 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_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") From 33944c02663efb28692d55c02253e8da3302acd2 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 06:47:27 +0800 Subject: [PATCH 136/142] Update llm_adapters.py --- llm_adapters.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/llm_adapters.py b/llm_adapters.py index 520e124..12ddee2 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -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: @@ -306,6 +307,38 @@ class VolcanoEngineAIAdapter(BaseLLMAdapter): 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 # 添加超时配置 + ) + 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 "" + def create_llm_adapter( interface_format: str, base_url: str, @@ -338,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}") From 54acd6b6b47a80275cd35263b0a045f7532cb126 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 07:15:29 +0800 Subject: [PATCH 137/142] Update architecture.py --- novel_generator/architecture.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/novel_generator/architecture.py b/novel_generator/architecture.py index 96b6198..7e06a07 100644 --- a/novel_generator/architecture.py +++ b/novel_generator/architecture.py @@ -92,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(): @@ -141,7 +142,7 @@ def Novel_architecture_generate( logging.info("Step3: Generating world_building_prompt ...") prompt_world = world_building_prompt.format( core_seed=partial_data["core_seed_result"].strip(), - user_guidance=user_guidance + user_guidance=user_guidance # 修复:添加用户指导 ) world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) if not world_building_result.strip(): @@ -159,7 +160,7 @@ def Novel_architecture_generate( core_seed=partial_data["core_seed_result"].strip(), character_dynamics=partial_data["character_dynamics_result"].strip(), world_building=partial_data["world_building_result"].strip(), - user_guidance=user_guidance + user_guidance=user_guidance # 修复:添加用户指导 ) plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) if not plot_arch_result.strip(): From 4ff370706234b6d627db8c766435f5fe28a633a2 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 07:15:59 +0800 Subject: [PATCH 138/142] Update generation_handlers.py --- ui/generation_handlers.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index 099a8d4..d1fbadb 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -25,13 +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() @@ -46,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( @@ -60,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: From 972e6702a24126fd6b5ecc003fa3b29e91337ee1 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Thu, 13 Mar 2025 07:16:13 +0800 Subject: [PATCH 139/142] Update prompt_definitions.py --- prompt_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prompt_definitions.py b/prompt_definitions.py index 4035372..0951d2a 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -487,7 +487,7 @@ Character_Import_Prompt = """\ 1. 必须包含以下五个分类(按顺序): ● 物品 ● 能力 ● 状态 ● 主要角色间关系网 ● 触发或加深的事件 2. 每个属性条目必须用【名称: 描述】格式 - 例:├──青衫: 一件破损的青色长袍,带有暗红色污渍 + 例:├──青衫: 一件破损的青色长袍,带有暗红色的污渍 3. 状态必须包含: ● 身体状态: [当前身体状况] ● 心理状态: [当前心理状况] From dc9ff7733b8e72a77318e6bedc30dbb6919fe26a Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 16 Mar 2025 21:53:46 +0800 Subject: [PATCH 140/142] Update blueprint.py --- novel_generator/blueprint.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/novel_generator/blueprint.py b/novel_generator/blueprint.py index a0425df..a0f0f6c 100644 --- a/novel_generator/blueprint.py +++ b/novel_generator/blueprint.py @@ -48,6 +48,7 @@ def Chapter_blueprint_generate( llm_model: str, filepath: str, number_of_chapters: int, + user_guidance: str = "", # 新增参数 temperature: float = 0.7, max_tokens: int = 4096, timeout: int = 600 @@ -106,7 +107,8 @@ def Chapter_blueprint_generate( chapter_list=limited_blueprint, number_of_chapters=number_of_chapters, n=current_start, - m=current_end + m=current_end, + user_guidance=user_guidance # 新增参数 ) logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) @@ -126,7 +128,8 @@ def Chapter_blueprint_generate( if chunk_size >= number_of_chapters: prompt = chapter_blueprint_prompt.format( novel_architecture=architecture_text, - number_of_chapters=number_of_chapters + number_of_chapters=number_of_chapters, + user_guidance=user_guidance # 新增参数 ) blueprint_text = invoke_with_cleaning(llm_adapter, prompt) if not blueprint_text.strip(): @@ -149,7 +152,8 @@ def Chapter_blueprint_generate( chapter_list=limited_blueprint, number_of_chapters=number_of_chapters, n=current_start, - m=current_end + m=current_end, + user_guidance=user_guidance # 新增参数 ) logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) From bda3bb9c25b60d39b88fa7bf9836249d695c1d1b Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 16 Mar 2025 21:54:15 +0800 Subject: [PATCH 141/142] Update generation_handlers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改生成目录弹错 --- ui/generation_handlers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index d1fbadb..9f030a8 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -77,7 +77,7 @@ def generate_chapter_blueprint_ui(self): return def task(): - if not messagebox.askyesno("确认", "确定要生成章节草稿吗?"): + if not messagebox.askyesno("确认", "确定要生成章节目录吗?"): self.enable_button_safe(self.btn_generate_chapter) return self.disable_button_safe(self.btn_generate_directory) @@ -90,6 +90,7 @@ def generate_chapter_blueprint_ui(self): temperature = self.temperature_var.get() max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) + user_guidance = self.user_guide_text.get("0.0", "end").strip() # 新增获取用户指导 self.safe_log("开始生成章节蓝图...") Chapter_blueprint_generate( @@ -101,7 +102,8 @@ def generate_chapter_blueprint_ui(self): filepath=filepath, temperature=temperature, max_tokens=max_tokens, - timeout=timeout_val + timeout=timeout_val, + user_guidance=user_guidance # 新增参数 ) self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") except Exception: From 2c55841389da43ea6b4b6ace28407318fce1efb3 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Mon, 17 Mar 2025 02:50:22 +0800 Subject: [PATCH 142/142] Update generation_handlers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复导入知识库txt文件为ANSI编码报错问题 --- ui/generation_handlers.py | 53 +++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index 9f030a8..d394f8d 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -445,20 +445,53 @@ def import_knowledge_handler(self): 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("✅ 知识库文件导入完成。") + # 尝试不同编码读取文件 + content = None + encodings = ['utf-8', 'gbk', 'gb2312', 'ansi'] + for encoding in encodings: + try: + with open(selected_file, 'r', encoding=encoding) as f: + content = f.read() + break + except UnicodeDecodeError: + continue + except Exception as e: + self.safe_log(f"读取文件时发生错误: {str(e)}") + raise + + if content is None: + raise Exception("无法以任何已知编码格式读取文件") + + # 创建临时UTF-8文件 + import tempfile + import os + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, suffix='.txt') as temp: + temp.write(content) + temp_path = temp.name + + try: + 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=temp_path, + filepath=self.filepath_var.get().strip() + ) + self.safe_log("✅ 知识库文件导入完成。") + finally: + # 清理临时文件 + try: + os.unlink(temp_path) + except: + pass + except Exception: self.handle_exception("导入知识库时出错") finally: self.enable_button_safe(self.btn_import_knowledge) + try: thread = threading.Thread(target=task, daemon=True) thread.start()