aimdeepcafe commited on
Commit
162dba5
·
verified ·
1 Parent(s): 52ef6ba

Update book.html

Browse files
Files changed (1) hide show
  1. book.html +151 -667
book.html CHANGED
@@ -3,719 +3,217 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>爱小说</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
9
  <script>
10
- // Tailwind CSS Configuration
11
  tailwind.config = {
12
  darkMode: 'class',
13
  theme: {
14
  extend: {
15
  colors: {
16
- primary: '#4F46E5',
17
- secondary: '#10B981',
18
- accent: '#F59E0B',
19
- dark: '#111827',
20
- light: '#F8FAFC',
21
- 'slate-800': '#1E293B',
22
- 'slate-700': '#334155',
23
- 'slate-600': '#475569',
24
- },
25
- fontFamily: {
26
- sans: ['Inter', 'system-ui', 'sans-serif'],
27
- reading: ['var(--reading-font)', 'Inter', 'system-ui', 'sans-serif'],
28
  },
 
29
  },
30
  }
31
  }
32
  </script>
33
  <style type="text/tailwindcss">
34
  @layer base {
35
- /* Reading Area CSS Variables */
36
  :root {
37
- --reading-font: 'Inter';
38
- --reading-font-size: 1.125rem; /* 18px */
39
- --reading-line-height: 1.75;
40
- --reading-letter-spacing: 0.025em;
41
- --reading-bg-color: #ffffff;
42
- --reading-text-color: #334155; /* slate-700 */
43
- --dialogue-highlight-color: #334155;
44
- --dialogue-highlight-weight: normal;
45
  }
46
- /* Dark Mode Variables */
47
  :root.dark {
48
- --reading-bg-color: #1E293B; /* slate-800 */
49
- --reading-text-color: #cbd5e1; /* slate-300 */
50
- --dialogue-highlight-color: #cbd5e1; /* slate-300 */
51
  }
52
  }
 
 
 
 
 
 
53
  @layer utilities {
54
- /* Custom utility classes */
55
- .transition-custom {
56
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
57
- }
58
  .scrollbar-hide::-webkit-scrollbar { display: none; }
59
  .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
60
  .tab-button.active { @apply bg-primary text-white; }
61
- .dialogue-highlight {
62
- color: var(--dialogue-highlight-color);
63
- font-weight: var(--dialogue-highlight-weight);
64
- }
65
  }
66
  </style>
67
  </head>
68
- <body class="bg-gray-100 font-sans text-gray-800 min-h-screen flex flex-col dark:bg-dark dark:text-light">
69
 
70
- <!-- 1. Top Navigation Bar -->
71
- <header class="bg-white shadow-sm sticky top-0 z-50 dark:bg-slate-800 dark:border-b dark:border-slate-700">
72
  <div class="container mx-auto px-4 py-3 flex justify-between items-center">
73
- <h1 class="text-2xl font-bold text-primary flex items-center"><i class="fa fa-book mr-2"></i>爱小说</h1>
74
- <div class="flex items-center space-x-2">
75
- <button id="custom-prompt-btn" class="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700 transition-custom" title="自定义情节"><i class="fa fa-magic text-gray-600 dark:text-gray-300"></i></button>
76
- <button id="continue-story-btn" class="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700 transition-custom" title="继续故事"><i class="fa fa-play text-gray-600 dark:text-gray-300"></i></button>
77
- <button id="regenerate-chapter-btn" class="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700 transition-custom" title="重新生成"><i class="fa fa-refresh text-gray-600 dark:text-gray-300"></i></button>
78
- <span class="text-gray-300 dark:text-gray-400 mx-1">|</span>
79
- <button id="open-model-settings-btn" class="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700 transition-custom" title="配置模型"><i class="fa fa-cogs text-gray-600 dark:text-gray-300"></i></button>
80
- <button id="open-story-preset-btn" class="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700 transition-custom" title="设置故事"><i class="fa fa-file-text-o text-gray-600 dark:text-gray-300"></i></button>
81
- <button id="theme-toggle-btn" class="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700 transition-custom"><i class="fa fa-moon-o text-gray-600 dark:text-gray-300"></i></button>
82
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  </div>
84
  </header>
85
 
86
- <!-- 2. Main Content Area -->
87
- <main class="flex-grow container mx-auto px-4 py-6 flex flex-col">
88
- <!-- Chapter Navigation -->
89
- <div class="bg-white rounded-xl shadow-sm p-4 mb-6 flex flex-wrap gap-2 justify-between items-center transition-custom hover:shadow-md dark:bg-slate-800">
90
- <div class="flex items-center space-x-2">
91
- <button id="prev-chapter-btn" class="px-2 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-custom flex items-center disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-700 dark:text-gray-300 dark:hover:bg-slate-600 text-sm"><i class="fa fa-arrow-left mr-1"></i>上章</button>
92
- <span id="chapter-info" class="text-sm text-gray-500 px-2 dark:text-gray-400">无内容</span>
93
- <button id="next-chapter-btn" class="px-2 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-custom flex items-center disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-700 dark:text-gray-300 dark:hover:bg-slate-600 text-sm">下章 <i class="fa fa-arrow-right ml-1"></i></button>
94
- </div>
95
  </div>
96
-
97
- <!-- Reading Area -->
98
- <div class="bg-white rounded-xl shadow-sm overflow-hidden flex-grow transition-custom hover:shadow-md flex flex-col" style="background-color: var(--reading-bg-color);">
99
  <div id="reading-area" class="p-8 md:p-10 lg:p-12 min-h-[500px] relative flex-grow overflow-y-auto scrollbar-hide" style="color: var(--reading-text-color); font-family: var(--reading-font); font-size: var(--reading-font-size); line-height: var(--reading-line-height); letter-spacing: var(--reading-letter-spacing);">
100
-
101
- <!-- Loading State Overlay (Matches original document) -->
102
- <div id="loading-state" class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-slate-800/80 z-10 hidden">
103
  <div class="text-center">
104
  <div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary mb-4"></div>
105
- <p class="text-gray-600 dark:text-gray-300">AI创作中...</p>
106
  </div>
107
  </div>
108
-
109
- <!-- Content will be injected here -->
110
  <div id="content-container" class="hidden">
111
- <h1 id="chapter-title-display" class="text-3xl md:text-4xl font-bold mb-6 text-center" style="color: var(--reading-text-color);"></h1>
112
  <div id="content-area" class="prose max-w-none"></div>
113
  </div>
114
-
115
- <!-- Welcome Message (Matches original document) -->
116
  <div id="welcome-message" class="text-center p-10 flex flex-col items-center justify-center h-full">
117
- <i class="fa fa-magic text-6xl text-gray-300 dark:text-gray-600 mb-4"></i>
118
- <h2 class="text-2xl font-bold text-gray-700 dark:text-gray-300 mb-2">欢迎使用 爱小说</h2>
119
- <p class="text-gray-500 dark:text-gray-400 max-w-md mx-auto">请先点击顶部的 <i class="fa fa-cogs"></i> 图标配置AI模型,然后通过 <i class="fa fa-file-text-o"></i> 设定故事,或直接 <i class="fa fa-play"></i> 开始创作。</p>
120
  </div>
121
  </div>
122
-
123
- <!-- Bottom Toolbar -->
124
- <div class="bg-gray-50/50 border-t border-gray-200/80 p-3 flex justify-between items-center backdrop-blur-sm dark:bg-slate-800/80 dark:border-t-slate-700">
125
- <div class="flex items-center space-x-3">
126
- <div class="text-sm text-gray-500 dark:text-gray-400"><i class="fa fa-file-word-o mr-1"></i>字数: <span id="word-count">0</span></div>
127
  <div id="pagination-controls" class="flex items-center space-x-1 hidden">
128
- <button id="prev-page-btn" class="p-1 rounded-md hover:bg-gray-200 dark:hover:bg-slate-700 disabled:opacity-50" title="上一页"><i class="fa fa-chevron-left text-gray-600 dark:text-gray-300 text-sm"></i></button>
129
- <span id="page-info" class="text-xs text-gray-500 dark:text-gray-400 px-1"></span>
130
- <button id="next-page-btn" class="p-1 rounded-md hover:bg-gray-200 dark:hover:bg-slate-700 disabled:opacity-50" title="下一页"><i class="fa fa-chevron-right text-gray-600 dark:text-gray-300 text-sm"></i></button>
131
  </div>
132
  </div>
133
  <div class="flex space-x-1">
134
- <button id="edit-chapter-btn" class="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-slate-700" title="编辑章节"><i class="fa fa-edit text-gray-600 dark:text-gray-300 text-sm"></i></button>
135
- <button id="open-reading-settings-btn" class="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-slate-700" title="阅读设置"><i class="fa fa-sliders text-gray-600 dark:text-gray-300 text-sm"></i></button>
136
- <button id="export-chapter-btn" class="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-slate-700" title="导出本章"><i class="fa fa-download text-gray-600 dark:text-gray-300 text-sm"></i></button>
137
- <button id="export-all-btn" class="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-slate-700" title="导出全部"><i class="fa fa-save text-gray-600 dark:text-gray-300 text-sm"></i></button>
138
  </div>
139
  </div>
140
  </div>
141
  </main>
142
 
143
- <!-- 3. Modals -->
144
- <!-- Reading Settings Modal -->
145
- <div id="reading-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden p-4">
146
- <div class="bg-white rounded-xl shadow-xl w-full max-w-md max-h-[90vh] flex flex-col dark:bg-slate-800">
147
- <div class="p-5 border-b flex justify-between items-center dark:border-slate-700">
148
- <h3 class="text-lg font-semibold text-primary">阅读设置</h3>
149
- <button class="close-modal-btn p-2 rounded-full hover:bg-gray-100 dark:hover:bg-slate-700"><i class="fa fa-times text-gray-600 dark:text-gray-300"></i></button>
150
- </div>
151
- <div class="p-6 space-y-6 overflow-y-auto">
152
- <div>
153
- <h4 class="text-md font-medium text-gray-700 mb-3 dark:text-gray-300"><i class="fa fa-font mr-2 text-primary"></i>字体样式</h4>
154
- <div class="space-y-4">
155
- <div>
156
- <label for="font-size-slider" class="block text-sm font-medium text-gray-600 mb-1 dark:text-gray-400">字体大小: <span id="font-size-value">18px</span></label>
157
- <input type="range" id="font-size-slider" min="12" max="32" step="1" value="18" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-slate-600">
158
- </div>
159
- <div>
160
- <label for="line-height-slider" class="block text-sm font-medium text-gray-600 mb-1 dark:text-gray-400">行间距: <span id="line-height-value">1.75</span></label>
161
- <input type="range" id="line-height-slider" min="1.2" max="2.5" step="0.05" value="1.75" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-slate-600">
162
- </div>
163
- </div>
164
- </div>
165
- <div>
166
- <h4 class="text-md font-medium text-gray-700 mb-3 dark:text-gray-300"><i class="fa fa-upload mr-2 text-primary"></i>自定义字体</h4>
167
- <input type="file" id="font-upload-input" accept=".ttf,.otf,.woff,.woff2" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20 cursor-pointer dark:text-gray-400 dark:file:bg-primary/20 dark:hover:file:bg-primary/30">
168
- </div>
169
- <div>
170
- <h4 class="text-md font-medium text-gray-700 mb-3 dark:text-gray-300"><i class="fa fa-comments-o mr-2 text-primary"></i>对话高亮</h4>
171
- <div class="space-y-3">
172
- <label class="flex items-center space-x-3 cursor-pointer">
173
- <input type="checkbox" id="dialogue-bold-toggle" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary dark:bg-slate-700 dark:border-slate-600">
174
- <span class="text-sm font-medium text-gray-700 dark:text-gray-300">对话内容加粗</span>
175
- </label>
176
- <div class="flex items-center space-x-3">
177
- <label for="dialogue-color-picker" class="text-sm font-medium text-gray-700 dark:text-gray-300">对话文字颜色:</label>
178
- <input type="color" id="dialogue-color-picker" value="#334155" class="w-10 h-8 p-1 border rounded-md cursor-pointer dark:bg-slate-700 dark:border-slate-600">
179
- <button id="reset-dialogue-color-btn" class="text-xs text-gray-500 hover:text-primary dark:text-gray-400">重置</button>
180
- </div>
181
- </div>
182
- </div>
183
- </div>
184
- </div>
185
- </div>
186
-
187
- <!-- Custom Prompt Modal -->
188
- <div id="custom-prompt-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden p-4">
189
- <div class="bg-white rounded-xl shadow-xl w-full max-w-lg dark:bg-slate-800">
190
- <div class="p-5 border-b flex justify-between items-center dark:border-slate-700">
191
- <h3 class="text-lg font-semibold text-primary">自定义生成</h3>
192
- <button class="close-modal-btn p-2 rounded-full hover:bg-gray-100 dark:hover:bg-slate-700"><i class="fa fa-times text-gray-600 dark:text-gray-300"></i></button>
193
- </div>
194
- <div class="p-6">
195
- <textarea id="custom-prompt-input" class="w-full p-3 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 h-40 dark:bg-slate-700 dark:border-slate-600 dark:text-light" placeholder="请输入你希望接下来发生的情节..."></textarea>
196
- <div class="mt-4 flex justify-end">
197
- <button id="submit-custom-prompt-btn" class="px-6 py-2 bg-secondary text-white rounded-md hover:bg-secondary/90 transition-custom">生成</button>
198
- </div>
199
- </div>
200
- </div>
201
- </div>
202
 
203
- <!-- Edit Chapter Modal -->
204
- <div id="edit-chapter-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden p-4">
205
- <div class="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col dark:bg-slate-800">
206
- <div class="p-5 border-b flex justify-between items-center dark:border-slate-700">
207
- <h3 class="text-lg font-semibold text-primary">编辑章节</h3>
208
- <button class="close-modal-btn p-2 rounded-full hover:bg-gray-100 dark:hover:bg-slate-700"><i class="fa fa-times text-gray-600 dark:text-gray-300"></i></button>
209
- </div>
210
- <div class="p-6 flex-grow flex flex-col">
211
- <input type="text" id="edit-chapter-title-input" class="w-full p-3 mb-4 border rounded-md dark:bg-slate-700 dark:border-slate-600" placeholder="章节标题">
212
- <textarea id="edit-chapter-content-input" class="flex-grow w-full p-3 border rounded-md resize-none dark:bg-slate-700 dark:border-slate-600" style="min-height: 300px;"></textarea>
213
- <div class="mt-4 flex justify-between items-center">
214
- <button id="delete-chapter-btn" class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 flex items-center"><i class="fa fa-trash mr-2"></i>删除章节</button>
215
- <div>
216
- <button id="save-edit-btn" class="px-6 py-2 bg-primary text-white rounded-md hover:bg-primary/90">保存</button>
217
- </div>
218
- </div>
219
- </div>
220
- </div>
221
- </div>
222
-
223
- <!-- Model Settings Modal -->
224
- <div id="model-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden p-4">
225
- <div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col dark:bg-slate-800">
226
- <div class="p-5 border-b flex justify-between items-center dark:border-slate-700">
227
- <h3 class="text-lg font-semibold text-primary">模型设置</h3>
228
- <button class="close-modal-btn p-2 rounded-full hover:bg-gray-100 dark:hover:bg-slate-700"><i class="fa fa-times text-gray-600 dark:text-gray-300"></i></button>
229
- </div>
230
- <div class="p-6 space-y-6 overflow-y-auto">
231
- <div>
232
- <h2 class="text-lg font-semibold mb-4 flex items-center text-primary"><i class="fa fa-plug mr-2"></i>API 配置</h2>
233
- <div class="space-y-3">
234
- <div>
235
- <label for="api-type-select" class="block text-sm font-medium mb-1 dark:text-gray-300">API 类型</label>
236
- <select id="api-type-select" class="w-full p-2 border rounded-md dark:bg-slate-700 dark:border-slate-600"><option value="openai">OpenAI</option><option value="gemini">Gemini</option></select>
237
- </div>
238
- <div>
239
- <label for="api-url-input" class="block text-sm font-medium mb-1 dark:text-gray-300">API 地址</label>
240
- <input type="url" id="api-url-input" class="w-full p-2 border rounded-md dark:bg-slate-700 dark:border-slate-600 transition-colors" placeholder="https://api.openai.com/v1/chat/completions">
241
- </div>
242
- <div>
243
- <label class="block text-sm font-medium mb-1 dark:text-gray-300">API Key 配置</label>
244
- <div id="api-key-display" class="hidden p-3 border rounded-md bg-gray-50 dark:bg-slate-700 dark:border-slate-600">
245
- <div class="flex justify-between items-center">
246
- <ul id="masked-keys-list" class="space-y-1 text-sm font-mono text-gray-600 dark:text-gray-300"></ul>
247
- <button id="edit-api-keys-btn" class="text-sm text-primary hover:underline">编辑</button>
248
- </div>
249
- </div>
250
- <textarea id="api-key-input" rows="3" class="w-full p-2 border rounded-md dark:bg-slate-700 dark:border-slate-600 font-mono text-sm" placeholder="可输入多个Key,用逗号或换行分隔,每次生成时会自动轮换。"></textarea>
251
- <p class="mt-1 text-xs text-yellow-600 dark:text-yellow-400"><i class="fa fa-warning mr-1"></i>注意:API密钥将经过混淆后保存在您的浏览器本地,请勿在不信任的计算机上使用。</p>
252
- </div>
253
- <button type="button" id="connect-api-btn" class="w-full bg-primary text-white py-2 rounded-md hover:bg-primary/90 flex items-center justify-center"><i class="fa fa-link mr-2"></i>连接模型</button>
254
- <div id="api-status" class="hidden text-sm py-2 px-3 rounded-md text-center"></div>
255
- </div>
256
- </div>
257
- <div>
258
- <h2 class="text-lg font-semibold mb-4 flex items-center text-primary"><i class="fa fa-sliders mr-2"></i>生成参数</h2>
259
- <div class="space-y-3">
260
- <div>
261
- <label for="model-select" class="block text-sm font-medium mb-1 dark:text-gray-300">选择模型</label>
262
- <select id="model-select" class="w-full p-2 border rounded-md dark:bg-slate-700 dark:border-slate-600" disabled><option value="">请先连接API</option></select>
263
- </div>
264
- <div>
265
- <label for="context-memory-slider" class="block text-sm font-medium mb-1 dark:text-gray-300">上下文记忆: <span id="context-memory-value">3</span> 章</label>
266
- <input type="range" id="context-memory-slider" min="0" max="20" value="3" class="w-full h-2 bg-gray-200 rounded-lg cursor-pointer dark:bg-slate-600">
267
- </div>
268
- <div>
269
- <label for="temperature-slider" class="block text-sm font-medium mb-1 dark:text-gray-300">创意温度: <span id="temperature-value">0.7</span></label>
270
- <input type="range" id="temperature-slider" min="0.1" max="1.5" step="0.1" value="0.7" class="w-full h-2 bg-gray-200 rounded-lg cursor-pointer dark:bg-slate-600">
271
- </div>
272
- </div>
273
- </div>
274
- </div>
275
- </div>
276
- </div>
277
-
278
- <!-- Story Preset Modal -->
279
- <div id="story-preset-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden p-4">
280
- <div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col dark:bg-slate-800">
281
- <div class="p-5 border-b flex justify-between items-center dark:border-slate-700">
282
- <h3 class="text-lg font-semibold text-primary">故事预设</h3>
283
- <button class="close-modal-btn p-2 rounded-full hover:bg-gray-100 dark:hover:bg-slate-700"><i class="fa fa-times text-gray-600 dark:text-gray-300"></i></button>
284
- </div>
285
- <div class="p-6 flex-grow flex flex-col overflow-hidden">
286
- <div class="flex-shrink-0 mb-4 flex justify-between items-center">
287
- <div id="preset-tabs-container" class="flex space-x-1 border border-gray-200 dark:border-slate-600 rounded-lg p-1"></div>
288
- <button id="add-preset-btn" class="px-3 py-1 bg-primary text-white text-sm rounded-md hover:bg-primary/90 flex items-center"><i class="fa fa-plus mr-1"></i>新增</button>
289
- </div>
290
- <div id="preset-list-container" class="flex-grow overflow-y-auto space-y-2 pr-2"></div>
291
- <div class="flex-shrink-0 mt-6 pt-4 border-t dark:border-slate-700 flex justify-center">
292
- <button id="generate-from-presets-btn" class="px-6 py-2 bg-secondary text-white rounded-md hover:bg-secondary/90 flex items-center"><i class="fa fa-magic mr-2"></i>根据选择生成故事</button>
293
- </div>
294
- </div>
295
- </div>
296
- </div>
297
-
298
- <!-- Preset Editor Modal (A modal inside a modal pattern, handled by JS) -->
299
- <!-- This modal is created and destroyed dynamically by JS -->
300
-
301
- <!-- 4. JavaScript -->
302
  <script src="https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js"></script>
303
  <script>
304
  document.addEventListener('DOMContentLoaded', () => {
305
 
306
- // --- I. STATE & CONFIGURATION ---
307
-
308
- const LS_KEYS = {
309
- STATE: 'aiNovelState_v3',
310
- PRESETS: 'aiNovelPresets_v3',
311
- THEME: 'aiNovelTheme_v3'
312
- };
313
-
314
- let story = [];
315
- let currentChapterIndex = -1;
316
- let chapterPages = [];
317
- let currentPageIndex = 0;
318
  let apiConfig = { type: 'openai', url: '', keys: [], currentKeyIndex: 0, isConnected: false, models: [] };
319
-
320
- let presetData = {};
321
- let selectedPresets = {};
322
- let activePresetTab = 'character';
323
  const PRESET_CATEGORIES = {
324
- character: '角色', player: '玩家', world: '世界观',
325
- style: '文风', script: '剧本', format: '格式'
 
 
 
 
326
  };
327
-
328
  const mdConverter = new showdown.Converter({ noHeaderId: true, simplifiedAutoLink: true, openLinksInNewWindow: true });
329
 
330
- // --- II. DOM ELEMENT CACHE ---
331
-
332
  const $ = (selector) => document.querySelector(selector);
333
  const $$ = (selector) => document.querySelectorAll(selector);
334
-
335
  const elements = {
336
- themeToggleBtn: $('#theme-toggle-btn'), loadingState: $('#loading-state'),
337
- contentContainer: $('#content-container'), welcomeMessage: $('#welcome-message'),
338
- chapterTitleDisplay: $('#chapter-title-display'), contentArea: $('#content-area'),
339
- readingArea: $('#reading-area'), prevChapterBtn: $('#prev-chapter-btn'),
340
- nextChapterBtn: $('#next-chapter-btn'), chapterInfo: $('#chapter-info'),
341
- paginationControls: $('#pagination-controls'), prevPageBtn: $('#prev-page-btn'),
342
- nextPageBtn: $('#next-page-btn'), pageInfo: $('#page-info'),
343
- wordCount: $('#word-count'), editChapterBtn: $('#edit-chapter-btn'),
344
  exportChapterBtn: $('#export-chapter-btn'), exportAllBtn: $('#export-all-btn'),
345
  modals: { readingSettings: $('#reading-settings-modal'), customPrompt: $('#custom-prompt-modal'), editChapter: $('#edit-chapter-modal'), modelSettings: $('#model-settings-modal'), storyPreset: $('#story-preset-modal') },
346
  openers: { readingSettings: $('#open-reading-settings-btn'), customPrompt: $('#custom-prompt-btn'), editChapter: $('#edit-chapter-btn'), modelSettings: $('#open-model-settings-btn'), storyPreset: $('#open-story-preset-btn') },
347
  continueStoryBtn: $('#continue-story-btn'), regenerateChapterBtn: $('#regenerate-chapter-btn'),
348
- fontSizeSlider: $('#font-size-slider'), fontSizeValue: $('#font-size-value'),
349
- lineHeightSlider: $('#line-height-slider'), lineHeightValue: $('#line-height-value'),
350
- fontUploadInput: $('#font-upload-input'), dialogueBoldToggle: $('#dialogue-bold-toggle'),
351
- dialogueColorPicker: $('#dialogue-color-picker'), resetDialogueColorBtn: $('#reset-dialogue-color-btn'),
352
- customPromptInput: $('#custom-prompt-input'), submitCustomPromptBtn: $('#submit-custom-prompt-btn'),
353
- editChapterTitleInput: $('#edit-chapter-title-input'), editChapterContentInput: $('#edit-chapter-content-input'),
354
- saveEditBtn: $('#save-edit-btn'), deleteChapterBtn: $('#delete-chapter-btn'),
355
- apiTypeSelect: $('#api-type-select'), apiUrlInput: $('#api-url-input'),
356
- apiKeyInput: $('#api-key-input'), apiKeyDisplay: $('#api-key-display'),
357
- maskedKeysList: $('#masked-keys-list'), editApiKeysBtn: $('#edit-api-keys-btn'),
358
- connectApiBtn: $('#connect-api-btn'), apiStatus: $('#api-status'),
359
- modelSelect: $('#model-select'), contextMemorySlider: $('#context-memory-slider'),
360
- contextMemoryValue: $('#context-memory-value'), temperatureSlider: $('#temperature-slider'),
361
- temperatureValue: $('#temperature-value'), presetTabsContainer: $('#preset-tabs-container'),
362
- addPresetBtn: $('#add-preset-btn'), presetListContainer: $('#preset-list-container'),
363
- generateFromPresetsBtn: $('#generate-from-presets-btn'),
364
- };
365
-
366
- // --- III. CORE LOGIC & HELPER FUNCTIONS ---
367
-
368
- const saveState = () => {
369
- const stateToSave = {
370
- story, currentChapterIndex,
371
- apiConfig: { ...apiConfig, keys: apiConfig.keys.map(key => btoa(key)) },
372
- modelSettings: { model: elements.modelSelect.value, contextMemory: elements.contextMemorySlider.value, temperature: elements.temperatureSlider.value },
373
- readingSettings: { fontSize: elements.fontSizeSlider.value, lineHeight: elements.lineHeightSlider.value, dialogueBold: elements.dialogueBoldToggle.checked, dialogueColor: elements.dialogueColorPicker.value }
374
- };
375
- localStorage.setItem(LS_KEYS.STATE, JSON.stringify(stateToSave));
376
  };
377
 
378
- const loadState = () => {
379
- const savedState = localStorage.getItem(LS_KEYS.STATE);
380
- if (!savedState) return;
381
- try {
382
- const state = JSON.parse(savedState);
383
- story = state.story || [];
384
- currentChapterIndex = state.currentChapterIndex ?? -1;
385
-
386
- if (state.apiConfig) {
387
- apiConfig = { ...state.apiConfig, keys: (state.apiConfig.keys || []).map(key => { try { return atob(key) } catch { return '' } }).filter(Boolean) };
388
- elements.apiTypeSelect.value = apiConfig.type || 'openai';
389
- elements.apiUrlInput.value = apiConfig.url || '';
390
- }
391
-
392
- if (state.modelSettings) {
393
- localStorage.setItem('aiNovelLastModel', state.modelSettings.model || '');
394
- elements.contextMemorySlider.value = state.modelSettings.contextMemory || 3;
395
- elements.temperatureSlider.value = state.modelSettings.temperature || 0.7;
396
- }
397
- if (state.readingSettings) {
398
- elements.fontSizeSlider.value = state.readingSettings.fontSize || 18;
399
- elements.lineHeightSlider.value = state.readingSettings.lineHeight || 1.75;
400
- elements.dialogueBoldToggle.checked = state.readingSettings.dialogueBold || false;
401
- elements.dialogueColorPicker.value = state.readingSettings.dialogueColor || '#334155';
402
- }
403
- } catch (e) {
404
- console.error("Failed to load state:", e);
405
- localStorage.removeItem(LS_KEYS.STATE);
406
- }
407
- };
408
-
409
  const savePresets = () => localStorage.setItem(LS_KEYS.PRESETS, JSON.stringify({ data: presetData, selected: selectedPresets }));
410
- const loadPresets = () => {
411
- const raw = localStorage.getItem(LS_KEYS.PRESETS);
412
- Object.keys(PRESET_CATEGORIES).forEach(key => { presetData[key] = []; selectedPresets[key] = []; });
413
- if (raw) { try { const p = JSON.parse(raw); if (p.data) presetData = p.data; if (p.selected) selectedPresets = p.selected; } catch (e) { console.error("Failed to load presets:", e); } }
414
- };
415
-
416
- const getNextApiKey = () => {
417
- if (!apiConfig.isConnected || !apiConfig.keys.length) return null;
418
- const key = apiConfig.keys[apiConfig.currentKeyIndex];
419
- apiConfig.currentKeyIndex = (apiConfig.currentKeyIndex + 1) % apiConfig.keys.length;
420
- saveState();
421
- return key;
422
- };
423
-
424
- const fetchFromApi = async (endpoint, options) => {
425
- const response = await fetch(endpoint, options);
426
- if (!response.ok) {
427
- const errorData = await response.json().catch(() => ({}));
428
- throw new Error(errorData?.error?.message || `HTTP Error: ${response.status}`);
429
- }
430
- return response.json();
431
- };
432
-
433
- const connectToApi = async () => {
434
- const type = elements.apiTypeSelect.value;
435
- const url = elements.apiUrlInput.value.trim();
436
- const keysInput = elements.apiKeyInput.value.trim();
437
-
438
- if (!url || !keysInput) return updateApiStatus('URL和API Key不能为空。', 'error');
439
-
440
- const keys = keysInput.split(/[\s,]+/).filter(Boolean);
441
- if (!keys.length) return updateApiStatus('请输入一个有效的API Key。', 'error');
442
-
443
- updateApiStatus('正在连接...', 'loading');
444
-
445
- try {
446
- const testKey = keys[0];
447
- let modelsUrl, headers;
448
- if (type === 'openai') {
449
- modelsUrl = `${url.replace(/\/v1\/.*$/, '')}/v1/models`;
450
- headers = { 'Authorization': `Bearer ${testKey}` };
451
- } else {
452
- modelsUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${testKey}`;
453
- headers = {};
454
- }
455
-
456
- const data = await fetchFromApi(modelsUrl, { headers });
457
- const apiModels = (type === 'openai' ? data.data : data.models) || [];
458
-
459
- const modelList = [];
460
- elements.modelSelect.innerHTML = '';
461
- apiModels.forEach(model => {
462
- const modelId = model.id || model.name;
463
- if (!modelId || (type === 'openai' && (modelId.includes('embed') || modelId.includes('vision')))) return;
464
- const finalId = modelId.startsWith('models/') ? modelId.substring(7) : modelId;
465
- modelList.push(finalId);
466
- elements.modelSelect.add(new Option(finalId, finalId));
467
- });
468
-
469
- if (elements.modelSelect.options.length > 0) {
470
- elements.modelSelect.value = localStorage.getItem('aiNovelLastModel') || '';
471
- elements.modelSelect.disabled = false;
472
- apiConfig = { type, url, keys, currentKeyIndex: 0, isConnected: true, models: modelList };
473
- const successMessage = `连接成功!发现 ${modelList.length} 个模型。${keys.length > 1 ? `已配置 ${keys.length} 个Key进行轮换。` : ''}`;
474
- updateApiStatus(successMessage, 'success');
475
- renderApiKeyDisplay();
476
- saveState();
477
- } else {
478
- throw new Error("未找到兼容的模型。");
479
- }
480
- } catch (error) {
481
- apiConfig.isConnected = false;
482
- apiConfig.models = [];
483
- saveState();
484
- elements.modelSelect.disabled = true;
485
- elements.modelSelect.innerHTML = '<option value="">连接失败</option>';
486
- updateApiStatus(`连接失败: ${error.message}`, 'error');
487
- }
488
- };
489
-
490
- const generateChapter = async (userPrompt, isRegeneration = false) => {
491
- if (!apiConfig.isConnected) return alert('请先在模型设置中连接API。');
492
- const apiKey = getNextApiKey();
493
- if (!apiKey) {
494
- toggleLoading(false);
495
- return alert('API已连接,但未配置密钥。请重新配置。');
496
- }
497
- toggleLoading(true);
498
-
499
- const contextMemory = parseInt(elements.contextMemorySlider.value, 10);
500
- const temperature = parseFloat(elements.temperatureSlider.value);
501
- const model = elements.modelSelect.value;
502
- const promptData = buildFullPrompt(userPrompt, contextMemory, isRegeneration);
503
-
504
- let endpoint = apiConfig.url, payload, headers;
505
- if (apiConfig.type === 'openai') {
506
- payload = { model, temperature, messages: promptData.messages };
507
- headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` };
508
- } else {
509
- payload = { generationConfig: { temperature }, contents: [{ role: 'user', parts: [{ text: promptData.fullPrompt }] }] };
510
- endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
511
- headers = { 'Content-Type': 'application/json' };
512
- }
513
-
514
- try {
515
- const data = await fetchFromApi(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) });
516
- const newContent = (apiConfig.type === 'openai' ? data.choices?.[0]?.message?.content : data.candidates?.[0]?.content?.parts?.[0]?.text)?.trim();
517
- if (!newContent) throw new Error('API返回内容为空。');
518
-
519
- if (isRegeneration) {
520
- story[currentChapterIndex].content = newContent;
521
- story[currentChapterIndex].customPrompt = userPrompt;
522
- } else {
523
- const newIndex = story.length;
524
- story.push({ title: `第 ${newIndex + 1} 章`, content: newContent, customPrompt: userPrompt });
525
- currentChapterIndex = newIndex;
526
- }
527
- renderChapter(currentChapterIndex);
528
- } catch (error) {
529
- console.error('生成失败:', error);
530
- alert(`生成失败: ${error.message}`);
531
- toggleLoading(false);
532
- }
533
- };
534
-
535
- const buildFullPrompt = (userPrompt, contextMemory, isRegeneration) => {
536
- const presetsPrompt = generatePromptFromSelectedPresets();
537
- const formatPreset = presetData.format?.find(p => selectedPresets.format?.includes(p.id));
538
- const formatInstructions = formatPreset ? formatPreset.desc : "Use Markdown format. Chapter title as H1.";
539
- const systemContent = `You are a talented novelist. Create the story based on the following settings and requirements.\n\n### Story Presets\n${presetsPrompt}\n\n### Output Format\n${formatInstructions}`;
540
- const contextStory = isRegeneration ? story.slice(0, currentChapterIndex) : story;
541
- const contextChapters = contextMemory > 0 ? contextStory.slice(-contextMemory) : [];
542
- let fullPrompt = `${systemContent}\n\n`;
543
- if(contextChapters.length > 0) fullPrompt += "### Previous Chapters (for context)\n" + contextChapters.map(c => `# ${c.title}\n${c.content}`).join('\n\n') + '\n\n';
544
- fullPrompt += `### Current Task\n${userPrompt}`;
545
- let messages = [{ role: 'system', content: systemContent }];
546
- if(contextChapters.length > 0) {
547
- messages.push({ role: 'user', content: "Here is the context from previous chapters:\n\n" + contextChapters.map(c => `# ${c.title}\n${c.content}`).join('\n\n') });
548
- messages.push({ role: 'assistant', content: 'Understood. I have the context. What should I write next?' });
549
- }
550
- messages.push({ role: 'user', content: `### Current Task\n${userPrompt}` });
551
- return { fullPrompt, messages };
552
- };
553
-
554
- // --- IV. UI RENDERING & UPDATES ---
555
-
556
- const handleApiTypeChange = () => {
557
- const type = elements.apiTypeSelect.value;
558
- const urlInput = elements.apiUrlInput;
559
- if (type === 'gemini') {
560
- urlInput.value = 'https://generativelanguage.googleapis.com';
561
- urlInput.readOnly = true;
562
- urlInput.classList.add('bg-gray-100', 'dark:bg-slate-700/50', 'cursor-not-allowed');
563
- } else {
564
- urlInput.readOnly = false;
565
- urlInput.classList.remove('bg-gray-100', 'dark:bg-slate-700/50', 'cursor-not-allowed');
566
- urlInput.placeholder = 'https://api.openai.com/v1/chat/completions';
567
- urlInput.value = (apiConfig.type === 'openai' && apiConfig.url) ? apiConfig.url : '';
568
- }
569
- };
570
-
571
- const renderChapter = (index) => {
572
- toggleLoading(false);
573
- if (index < 0 || index >= story.length) {
574
- showWelcomeScreen();
575
- return;
576
- }
577
- hideWelcomeScreen();
578
- currentChapterIndex = index;
579
- const chapter = story[index];
580
- let contentHtml = mdConverter.makeHtml(chapter.content);
581
- const tempDiv = document.createElement('div');
582
- tempDiv.innerHTML = contentHtml;
583
- const h1 = tempDiv.querySelector('h1');
584
- if (h1) { chapter.title = h1.textContent.trim(); h1.remove(); contentHtml = tempDiv.innerHTML; }
585
- contentHtml = highlightDialogue(contentHtml);
586
- elements.chapterTitleDisplay.textContent = chapter.title;
587
- chapterPages = paginateContent(contentHtml, 2500);
588
- currentPageIndex = 0;
589
- renderCurrentPage();
590
- elements.readingArea.scrollTop = 0;
591
- updateUI();
592
- saveState();
593
- };
594
-
595
  const renderCurrentPage = () => { elements.contentArea.innerHTML = chapterPages[currentPageIndex] || ''; updatePaginationUI(); };
596
-
597
- const updateUI = () => {
598
- const hasStory = story.length > 0;
599
- elements.chapterInfo.textContent = hasStory ? `第 ${currentChapterIndex + 1} / ${story.length} 章` : '无内容';
600
- elements.prevChapterBtn.disabled = !hasStory || currentChapterIndex === 0;
601
- elements.nextChapterBtn.disabled = !hasStory || currentChapterIndex === story.length - 1;
602
- [elements.continueStoryBtn, elements.regenerateChapterBtn, elements.editChapterBtn, elements.exportChapterBtn, elements.exportAllBtn, elements.openers.customPrompt].forEach(btn => { btn.disabled = !hasStory; });
603
- elements.wordCount.textContent = hasStory ? (story[currentChapterIndex].content.match(/[\u4e00-\u9fa5\w]+/g) || []).join('').length : 0;
604
- elements.contextMemoryValue.textContent = elements.contextMemorySlider.value;
605
- elements.temperatureValue.textContent = elements.temperatureSlider.value;
606
- elements.fontSizeValue.textContent = `${elements.fontSizeSlider.value}px`;
607
- elements.lineHeightValue.textContent = elements.lineHeightSlider.value;
608
- };
609
-
610
- const updatePaginationUI = () => {
611
- if (chapterPages.length > 1) {
612
- elements.paginationControls.classList.remove('hidden');
613
- elements.pageInfo.textContent = `${currentPageIndex + 1} / ${chapterPages.length}`;
614
- elements.prevPageBtn.disabled = currentPageIndex === 0;
615
- elements.nextPageBtn.disabled = currentPageIndex >= chapterPages.length - 1;
616
- } else {
617
- elements.paginationControls.classList.add('hidden');
618
- }
619
- };
620
-
621
- const updateApiStatus = (message, type) => {
622
- const s = elements.apiStatus;
623
- s.textContent = message;
624
- s.className = 'text-sm py-2 px-3 rounded-md text-center';
625
- if (type === 'success') s.classList.add('bg-green-100', 'text-green-800', 'dark:bg-green-900/50', 'dark:text-green-300');
626
- else if (type === 'error') s.classList.add('bg-red-100', 'text-red-800', 'dark:bg-red-900/50', 'dark:text-red-300');
627
- else s.classList.add('bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900/50', 'dark:text-yellow-300');
628
- elements.connectApiBtn.innerHTML = type === 'loading' ? '<i class="fa fa-spinner fa-spin mr-2"></i>正在连接...' : '<i class="fa fa-link mr-2"></i>连接模型';
629
- elements.connectApiBtn.disabled = (type === 'loading');
630
- };
631
-
632
  const showWelcomeScreen = () => { elements.contentContainer.classList.add('hidden'); elements.welcomeMessage.classList.remove('hidden'); currentChapterIndex = -1; updateUI(); };
633
  const hideWelcomeScreen = () => { elements.contentContainer.classList.remove('hidden'); elements.welcomeMessage.classList.add('hidden'); };
634
  const toggleLoading = (isLoading) => elements.loadingState.classList.toggle('hidden', !isLoading);
635
-
636
- // --- V. UI HELPERS & MODAL LOGIC ---
637
-
638
  const maskApiKey = (key) => `${key.substring(0, 5)}...${key.substring(key.length - 4)}`;
639
- const renderApiKeyDisplay = () => {
640
- const hasKeys = apiConfig.keys && apiConfig.keys.length > 0;
641
- elements.apiKeyInput.classList.toggle('hidden', hasKeys);
642
- elements.apiKeyDisplay.classList.toggle('hidden', !hasKeys);
643
-
644
- if (hasKeys) {
645
- elements.maskedKeysList.innerHTML = apiConfig.keys.map((key, index) =>
646
- `<li>Key ${index + 1}: ${maskApiKey(key)}</li>`
647
- ).join('');
648
- }
649
- };
650
-
651
- const restoreApiConnectionUI = () => {
652
- if (apiConfig.isConnected && apiConfig.models && apiConfig.models.length > 0) {
653
- elements.modelSelect.innerHTML = '';
654
- apiConfig.models.forEach(modelId => { elements.modelSelect.add(new Option(modelId, modelId)); });
655
- const lastModel = localStorage.getItem('aiNovelLastModel');
656
- if (lastModel && apiConfig.models.includes(lastModel)) elements.modelSelect.value = lastModel;
657
- elements.modelSelect.disabled = false;
658
- const successMessage = `已连接! 找到 ${apiConfig.models.length} 个模型。${apiConfig.keys.length > 1 ? `正在轮换 ${apiConfig.keys.length} 个Key。` : ''}`;
659
- updateApiStatus(successMessage, 'success');
660
- }
661
- };
662
-
663
- const toggleModal = (modalName, show) => { elements.modals[modalName].classList.toggle('hidden', !show); };
664
-
665
- const applyReadingSettings = () => {
666
- const root = document.documentElement;
667
- root.style.setProperty('--reading-font-size', `${elements.fontSizeSlider.value}px`);
668
- root.style.setProperty('--reading-line-height', elements.lineHeightSlider.value);
669
- root.style.setProperty('--dialogue-highlight-weight', elements.dialogueBoldToggle.checked ? 'bold' : 'normal');
670
- root.style.setProperty('--dialogue-highlight-color', elements.dialogueColorPicker.value);
671
- updateUI();
672
- saveState();
673
- };
674
-
675
  const highlightDialogue = (text) => text.replace(/(“[^”]+”|‘[^’]+’|"[^"]+"|\'[^\']+\'|「[^」]+」|『[^』]+』)/g, `<span class="dialogue-highlight">$1</span>`);
676
- const paginateContent = (htmlContent, limit) => {
677
- const pages = [];
678
- const tempDiv = document.createElement('div');
679
- tempDiv.innerHTML = htmlContent;
680
- const nodes = Array.from(tempDiv.childNodes);
681
- if (nodes.length === 0) return [htmlContent];
682
- let currentPageHtml = '';
683
- for (const node of nodes) {
684
- if (currentPageHtml && (currentPageHtml.length + (node.textContent || '').length) > limit) {
685
- pages.push(currentPageHtml);
686
- currentPageHtml = '';
687
- }
688
- currentPageHtml += node.outerHTML || node.textContent;
689
- }
690
- if (currentPageHtml) pages.push(currentPageHtml);
691
- return pages;
692
- };
693
- const downloadAsTxt = (filename, text) => {
694
- const blob = new Blob(['\uFEFF' + text], { type: 'text/plain;charset=utf-8' });
695
- const url = URL.createObjectURL(blob);
696
- const a = document.createElement('a');
697
- a.href = url;
698
- a.download = filename;
699
- document.body.appendChild(a);
700
- a.click();
701
- document.body.removeChild(a);
702
- URL.revokeObjectURL(url);
703
  };
 
 
 
704
 
705
- // --- VI. STORY PRESET LOGIC ---
706
- const renderPresetTabs = () => { elements.presetTabsContainer.innerHTML = Object.entries(PRESET_CATEGORIES).map(([key, name]) => `<button data-tab="${key}" class="tab-button px-3 py-1 text-sm rounded-md transition-colors ${activePresetTab === key ? 'active' : ''}">${name}</button>`).join(''); };
707
- const renderPresetList = () => { const list = presetData[activePresetTab] || []; elements.presetListContainer.innerHTML = list.length === 0 ? `<p class="text-center text-gray-500 py-4">此分类下无预设。</p>` : list.map(item => `<div class="p-3 rounded-lg flex items-center justify-between cursor-pointer transition-colors ${selectedPresets[activePresetTab]?.includes(item.id) ? 'bg-primary/10' : 'bg-gray-50 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-slate-600'}"><div class="flex items-center flex-grow" data-action="toggle-select" data-id="${item.id}"><input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary pointer-events-none" ${selectedPresets[activePresetTab]?.includes(item.id) ? 'checked' : ''}><div class="ml-3"><p class="font-semibold">${item.name}</p><p class="text-xs text-gray-500 dark:text-gray-400">${item.desc}</p></div></div><div class="flex space-x-1 ml-2"><button data-action="edit-preset" data-id="${item.id}" class="p-2 rounded-md hover:bg-gray-200 dark:hover:bg-slate-600"><i class="fa fa-edit text-sm"></i></button><button data-action="delete-preset" data-id="${item.id}" class="p-2 rounded-md hover:bg-red-100 dark:hover:bg-red-800/50"><i class="fa fa-trash text-sm text-red-500"></i></button></div></div>`).join(''); };
708
- const showPresetEditor = (itemToEdit = null) => { const isEditing = !!itemToEdit; const modalId = 'dynamic-preset-editor'; $(`#${modalId}`)?.remove(); const modal = document.createElement('div'); modal.id = modalId; modal.className = 'fixed inset-0 bg-black bg-opacity-60 z-[60] flex items-center justify-center p-4'; modal.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6"><h3 class='text-lg font-semibold mb-4'>${isEditing ? '编辑' : '新增'} ${PRESET_CATEGORIES[activePresetTab]} 预设</h3><div class='space-y-3'><input id='preset-edit-name' class='w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600' placeholder='名称' value='${itemToEdit?.name || ''}'/><textarea id='preset-edit-desc' class='w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 h-24' placeholder='描述'>${itemToEdit?.desc || ''}</textarea></div><div class='mt-4 flex justify-end space-x-2'><button id='preset-edit-cancel' class='px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 dark:bg-slate-600'>取消</button><button id='preset-edit-save' class='px-4 py-2 rounded bg-primary text-white'>保存</button></div></div>`; document.body.appendChild(modal); modal.querySelector('#preset-edit-cancel').onclick = () => modal.remove(); modal.querySelector('#preset-edit-save').onclick = () => { const name = modal.querySelector('#preset-edit-name').value.trim(); const desc = modal.querySelector('#preset-edit-desc').value.trim(); if (!name) return alert('名称不能为空。'); if (isEditing) { itemToEdit.name = name; itemToEdit.desc = desc; } else { presetData[activePresetTab].push({ id: `p_${Date.now()}`, name, desc }); } savePresets(); renderPresetList(); modal.remove(); }; };
709
- const generatePromptFromSelectedPresets = () => { let prompt = ""; Object.entries(selectedPresets).forEach(([category, ids]) => { if (ids.length === 0) return; prompt += `### ${PRESET_CATEGORIES[category]}\n`; ids.forEach(id => { const item = presetData[category].find(p => p.id === id); if (item) prompt += `- ${item.name}: ${item.desc}\n`; }); prompt += '\n'; }); return prompt || "无预设,请自由发挥。"; };
710
-
711
- // --- VII. EVENT LISTENERS ---
712
-
713
  function initializeEventListeners() {
714
- elements.themeToggleBtn.addEventListener('click', () => { const isDark = document.documentElement.classList.toggle('dark'); localStorage.setItem(LS_KEYS.THEME, isDark ? 'dark' : 'light'); elements.themeToggleBtn.querySelector('i').className = `fa ${isDark ? 'fa-sun-o text-gray-300' : 'fa-moon-o text-gray-600'}`; });
715
- Object.entries(elements.openers).forEach(([name, opener]) => opener.addEventListener('click', () => toggleModal(name, true)));
716
  $$('.close-modal-btn').forEach(btn => btn.addEventListener('click', (e) => e.target.closest('.fixed').classList.add('hidden')));
717
- elements.continueStoryBtn.addEventListener('click', () => generateChapter("接续上一章的情节,创作新的一章。"));
718
- elements.regenerateChapterBtn.addEventListener('click', () => { if (currentChapterIndex < 0) return; const prompt = story[currentChapterIndex].customPrompt || "用不同的视角或风格重写这一章。"; generateChapter(prompt, true); });
 
719
  elements.prevChapterBtn.addEventListener('click', () => renderChapter(currentChapterIndex - 1));
720
  elements.nextChapterBtn.addEventListener('click', () => renderChapter(currentChapterIndex + 1));
721
  elements.prevPageBtn.addEventListener('click', () => { if (currentPageIndex > 0) renderCurrentPage(--currentPageIndex); });
@@ -728,54 +226,40 @@
728
  elements.submitCustomPromptBtn.addEventListener('click', () => { const prompt = elements.customPromptInput.value.trim(); if (prompt) { generateChapter(prompt); toggleModal('customPrompt', false); elements.customPromptInput.value = ''; } });
729
  elements.openers.editChapter.addEventListener('click', () => { if(currentChapterIndex < 0) return; const chapter = story[currentChapterIndex]; elements.editChapterTitleInput.value = chapter.title; elements.editChapterContentInput.value = chapter.content; });
730
  elements.saveEditBtn.addEventListener('click', () => { story[currentChapterIndex].title = elements.editChapterTitleInput.value.trim(); story[currentChapterIndex].content = elements.editChapterContentInput.value.trim(); renderChapter(currentChapterIndex); toggleModal('editChapter', false); });
731
- elements.deleteChapterBtn.addEventListener('click', () => { if (confirm('确定要删除本章吗?此操作无法撤销。')) { story.splice(currentChapterIndex, 1); const newIndex = Math.max(-1, currentChapterIndex - 1); renderChapter(newIndex); toggleModal('editChapter', false); } });
732
  elements.connectApiBtn.addEventListener('click', connectToApi);
733
  elements.apiTypeSelect.addEventListener('change', handleApiTypeChange);
734
  [elements.contextMemorySlider, elements.temperatureSlider, elements.modelSelect].forEach(el => el.addEventListener('input', () => { saveState(); updateUI(); }));
735
-
736
- elements.editApiKeysBtn.addEventListener('click', () => {
737
- apiConfig.keys = [];
738
- apiConfig.isConnected = false;
739
- elements.apiKeyInput.value = '';
740
- renderApiKeyDisplay();
741
- updateApiStatus('请重新输入API Key并连接。', 'loading');
742
- elements.modelSelect.disabled = true;
743
- elements.modelSelect.innerHTML = '<option value="">请先连接API</option>';
744
- saveState();
745
- });
746
-
747
  elements.presetTabsContainer.addEventListener('click', (e) => { const tab = e.target.closest('button')?.dataset.tab; if (tab) { activePresetTab = tab; renderPresetTabs(); renderPresetList(); } });
748
  elements.addPresetBtn.addEventListener('click', () => showPresetEditor());
749
  elements.presetListContainer.addEventListener('click', (e) => { const btn = e.target.closest('button'); const id = btn?.dataset.id; const action = btn?.dataset.action; if (action === 'edit-preset') { const item = presetData[activePresetTab].find(p => p.id === id); if (item) showPresetEditor(item); } else if (action === 'delete-preset') { if (confirm('确定删除此预设?')) { presetData[activePresetTab] = presetData[activePresetTab].filter(p => p.id !== id); selectedPresets[activePresetTab] = selectedPresets[activePresetTab].filter(pId => pId !== id); savePresets(); renderPresetList(); } } else { const container = e.target.closest('[data-action="toggle-select"]'); const selectId = container?.dataset.id; if (selectId) { const list = selectedPresets[activePresetTab]; const index = list.indexOf(selectId); if (index > -1) list.splice(index, 1); else list.push(selectId); savePresets(); renderPresetList(); } } });
750
  elements.generateFromPresetsBtn.addEventListener('click', () => { if (!Object.values(selectedPresets).some(arr => arr.length > 0)) return alert('请至少选择一个预设。'); generateChapter("根据所选预设开始第一章。"); toggleModal('storyPreset', false); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  }
752
-
753
- // --- VIII. INITIALIZATION ---
754
-
755
  function initializeApp() {
 
756
  const savedTheme = localStorage.getItem(LS_KEYS.THEME);
757
- if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
758
- document.documentElement.classList.add('dark');
759
- elements.themeToggleBtn.querySelector('i').className = 'fa fa-sun-o text-gray-300';
760
- }
761
-
762
- loadState();
763
- loadPresets();
764
-
765
- initializeEventListeners();
766
- applyReadingSettings();
767
- handleApiTypeChange();
768
- renderApiKeyDisplay();
769
- restoreApiConnectionUI();
770
- renderPresetTabs();
771
- renderPresetList();
772
-
773
- // MODIFIED: Explicit check for initial state
774
- if (story.length === 0 || currentChapterIndex < 0) {
775
- showWelcomeScreen();
776
- } else {
777
- renderChapter(currentChapterIndex);
778
- }
779
  }
780
 
781
  initializeApp();
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>爱小说 (重构版)</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
9
  <script>
 
10
  tailwind.config = {
11
  darkMode: 'class',
12
  theme: {
13
  extend: {
14
  colors: {
15
+ primary: { DEFAULT: 'rgb(2, 132, 199)', hover: 'rgb(3, 105, 161)', focus: 'rgb(14, 165, 233)' },
16
+ light: { bg: 'rgb(241, 245, 249)', card: '#ffffff', text: 'rgb(51, 65, 85)', subtext: 'rgb(100, 116, 139)' },
17
+ dark: { bg: 'rgb(15, 23, 42)', card: 'rgb(30, 41, 59)', text: 'rgb(203, 213, 225)', subtext: 'rgb(100, 116, 139)' }
 
 
 
 
 
 
 
 
 
18
  },
19
+ fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], reading: ['var(--reading-font)', 'Inter', 'system-ui', 'sans-serif'] },
20
  },
21
  }
22
  }
23
  </script>
24
  <style type="text/tailwindcss">
25
  @layer base {
 
26
  :root {
27
+ --reading-font: 'Inter'; --reading-font-size: 1.125rem; --reading-line-height: 1.75; --reading-letter-spacing: 0.025em;
28
+ --reading-bg-color: theme('colors.light.card'); --reading-text-color: theme('colors.light.text');
29
+ --dialogue-highlight-color: theme('colors.light.text'); --dialogue-highlight-weight: normal;
 
 
 
 
 
30
  }
 
31
  :root.dark {
32
+ --reading-bg-color: theme('colors.dark.card'); --reading-text-color: theme('colors.dark.text');
33
+ --dialogue-highlight-color: theme('colors.dark.text');
 
34
  }
35
  }
36
+ @layer components {
37
+ .btn { @apply px-4 py-2 rounded-lg font-semibold text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-bg disabled:opacity-50 disabled:cursor-not-allowed; }
38
+ .btn-primary { @apply btn bg-primary text-white hover:bg-primary-hover focus:ring-primary-focus; }
39
+ .btn-secondary { @apply btn bg-slate-200 text-slate-700 hover:bg-slate-300 focus:ring-slate-400 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600 dark:focus:ring-slate-500; }
40
+ .input-base { @apply w-full p-2 border border-slate-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-primary-focus focus:border-transparent dark:bg-slate-700 dark:border-slate-600 dark:text-dark-text; }
41
+ }
42
  @layer utilities {
43
+ .transition-custom { @apply transition-all duration-300 ease-in-out; }
 
 
 
44
  .scrollbar-hide::-webkit-scrollbar { display: none; }
45
  .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
46
  .tab-button.active { @apply bg-primary text-white; }
47
+ .dialogue-highlight { color: var(--dialogue-highlight-color); font-weight: var(--dialogue-highlight-weight); }
 
 
 
48
  }
49
  </style>
50
  </head>
51
+ <body class="bg-light-bg font-sans text-light-text min-h-screen flex flex-col dark:bg-dark-bg dark:text-dark-text">
52
 
53
+ <header class="bg-light-card/80 dark:bg-dark-card/80 backdrop-blur-sm shadow-sm sticky top-0 z-50 border-b border-slate-200 dark:border-slate-800">
 
54
  <div class="container mx-auto px-4 py-3 flex justify-between items-center">
55
+ <h1 class="text-2xl font-bold text-sky-600 dark:text-sky-500 flex items-center"><i class="fa fa-book mr-2"></i>爱小说</h1>
56
+ <div class="hidden md:flex items-center space-x-2">
57
+ <button id="custom-prompt-btn" class="btn-secondary" title="自定义情节"><i class="fa fa-magic mr-2"></i>自定义</button>
58
+ <button id="regenerate-chapter-btn" class="btn-secondary" title="重新生成"><i class="fa fa-refresh mr-2"></i>重写</button>
59
+ <button id="continue-story-btn" class="btn-primary" title="继续故事"><i class="fa fa-play mr-2"></i>继续创作</button>
60
+ <span class="text-slate-300 dark:text-slate-600 mx-2">|</span>
61
+ <button id="open-story-preset-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="故事设定"><i class="fa fa-file-text-o"></i></button>
62
+ <button id="open-model-settings-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="模型设置"><i class="fa fa-cogs"></i></button>
63
+ <button id="theme-toggle-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700"><i class="fa fa-moon-o"></i></button>
64
  </div>
65
+ <div class="md:hidden">
66
+ <button id="continue-story-btn-mobile" class="btn-primary" title="继续故事"><i class="fa fa-play"></i></button>
67
+ <button id="mobile-menu-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 ml-2" title="更多操作"><i class="fa fa-ellipsis-v"></i></button>
68
+ </div>
69
+ </div>
70
+ <div id="mobile-menu" class="hidden md:hidden absolute top-full right-4 mt-2 w-48 bg-light-card dark:bg-dark-card rounded-lg shadow-xl border border-slate-200 dark:border-slate-700 p-2 z-50">
71
+ <a href="#" id="custom-prompt-btn-mobile" class="flex items-center w-full px-3 py-2 text-sm rounded-md hover:bg-slate-100 dark:hover:bg-slate-700"><i class="fa fa-magic fa-fw mr-3"></i>自定义情节</a>
72
+ <a href="#" id="regenerate-chapter-btn-mobile" class="flex items-center w-full px-3 py-2 text-sm rounded-md hover:bg-slate-100 dark:hover:bg-slate-700"><i class="fa fa-refresh fa-fw mr-3"></i>重新生成</a>
73
+ <div class="my-1 h-px bg-slate-200 dark:bg-slate-700"></div>
74
+ <a href="#" id="open-story-preset-btn-mobile" class="flex items-center w-full px-3 py-2 text-sm rounded-md hover:bg-slate-100 dark:hover:bg-slate-700"><i class="fa fa-file-text-o fa-fw mr-3"></i>故事设定</a>
75
+ <a href="#" id="open-model-settings-btn-mobile" class="flex items-center w-full px-3 py-2 text-sm rounded-md hover:bg-slate-100 dark:hover:bg-slate-700"><i class="fa fa-cogs fa-fw mr-3"></i>模型设置</a>
76
+ <div class="my-1 h-px bg-slate-200 dark:bg-slate-700"></div>
77
+ <a href="#" id="theme-toggle-btn-mobile" class="flex items-center w-full px-3 py-2 text-sm rounded-md hover:bg-slate-100 dark:hover:bg-slate-700"><i class="fa fa-moon-o fa-fw mr-3"></i>切换主题</a>
78
  </div>
79
  </header>
80
 
81
+ <main class="flex-grow container mx-auto px-4 py-8 flex flex-col">
82
+ <div class="mb-6 flex justify-between items-center">
83
+ <button id="prev-chapter-btn" class="btn-secondary"><i class="fa fa-arrow-left mr-2"></i>上章</button>
84
+ <span id="chapter-info" class="text-sm text-light-subtext dark:text-dark-subtext">无内容</span>
85
+ <button id="next-chapter-btn" class="btn-secondary">下章<i class="fa fa-arrow-right ml-2"></i></button>
 
 
 
 
86
  </div>
87
+ <div class="bg-light-card rounded-xl shadow-lg flex-grow flex flex-col overflow-hidden border border-slate-200 dark:border-slate-700/50" style="background-color: var(--reading-bg-color);">
 
 
88
  <div id="reading-area" class="p-8 md:p-10 lg:p-12 min-h-[500px] relative flex-grow overflow-y-auto scrollbar-hide" style="color: var(--reading-text-color); font-family: var(--reading-font); font-size: var(--reading-font-size); line-height: var(--reading-line-height); letter-spacing: var(--reading-letter-spacing);">
89
+ <div id="loading-state" class="absolute inset-0 flex items-center justify-center bg-light-card/80 dark:bg-dark-card/80 z-10 hidden">
 
 
90
  <div class="text-center">
91
  <div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary mb-4"></div>
92
+ <p class="text-light-subtext dark:text-dark-subtext">AI创作中...</p>
93
  </div>
94
  </div>
 
 
95
  <div id="content-container" class="hidden">
96
+ <h1 id="chapter-title-display" class="text-3xl md:text-4xl font-bold mb-8 text-center" style="color: var(--reading-text-color);"></h1>
97
  <div id="content-area" class="prose max-w-none"></div>
98
  </div>
 
 
99
  <div id="welcome-message" class="text-center p-10 flex flex-col items-center justify-center h-full">
100
+ <i class="fa fa-magic text-6xl text-slate-300 dark:text-slate-600 mb-6"></i>
101
+ <h2 class="text-2xl font-bold text-light-text dark:text-dark-text mb-2">欢迎使用 爱小说</h2>
102
+ <p class="text-light-subtext dark:text-dark-subtext max-w-md mx-auto">请先点击顶部的 <i class="fa fa-cogs"></i> 图标配置AI模型,然后通过 <i class="fa fa-file-text-o"></i> 设定故事,或直接 <i class="fa fa-play"></i> 开始创作。</p>
103
  </div>
104
  </div>
105
+ <div class="bg-light-card/80 border-t border-slate-200/80 p-3 flex justify-between items-center backdrop-blur-sm dark:bg-dark-card/80 dark:border-t-slate-700/50">
106
+ <div class="flex items-center space-x-3 text-sm text-light-subtext dark:text-dark-subtext">
107
+ <div class="flex items-center"><i class="fa fa-file-word-o mr-2"></i><span class="hidden sm:inline">字数: </span><span id="word-count">0</span></div>
 
 
108
  <div id="pagination-controls" class="flex items-center space-x-1 hidden">
109
+ <button id="prev-page-btn" class="p-1 rounded-md hover:bg-slate-200 dark:hover:bg-slate-700 disabled:opacity-50" title="上一页"><i class="fa fa-chevron-left text-xs"></i></button>
110
+ <span id="page-info" class="text-xs px-1"></span>
111
+ <button id="next-page-btn" class="p-1 rounded-md hover:bg-slate-200 dark:hover:bg-slate-700 disabled:opacity-50" title="下一页"><i class="fa fa-chevron-right text-xs"></i></button>
112
  </div>
113
  </div>
114
  <div class="flex space-x-1">
115
+ <button id="edit-chapter-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="编辑章节"><i class="fa fa-edit"></i></button>
116
+ <button id="open-reading-settings-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="阅读设置"><i class="fa fa-sliders"></i></button>
117
+ <button id="export-chapter-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="导出本章"><i class="fa fa-download"></i></button>
118
+ <button id="export-all-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="导出全部"><i class="fa fa-save"></i></button>
119
  </div>
120
  </div>
121
  </div>
122
  </main>
123
 
124
+ <div id="reading-settings-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden p-4"><div class="bg-light-card rounded-xl shadow-xl w-full max-w-md max-h-[90vh] flex flex-col dark:bg-dark-card"><div class="p-5 border-b flex justify-between items-center dark:border-slate-700"><h3 class="text-lg font-semibold text-sky-600 dark:text-sky-500">阅读设置</h3><button class="close-modal-btn p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-6 space-y-6 overflow-y-auto"><div><h4 class="text-md font-medium mb-3 text-light-text dark:text-dark-text"><i class="fa fa-font mr-2 text-primary"></i>字体样式</h4><div class="space-y-4"><div><label for="font-size-slider" class="block text-sm font-medium mb-1 text-light-subtext dark:text-dark-subtext">字体大小: <span id="font-size-value">18px</span></label><input type="range" id="font-size-slider" min="12" max="32" step="1" value="18" class="w-full"></div><div><label for="line-height-slider" class="block text-sm font-medium mb-1 text-light-subtext dark:text-dark-subtext">行间距: <span id="line-height-value">1.75</span></label><input type="range" id="line-height-slider" min="1.2" max="2.5" step="0.05" value="1.75" class="w-full"></div></div></div><div><h4 class="text-md font-medium mb-3 text-light-text dark:text-dark-text"><i class="fa fa-upload mr-2 text-primary"></i>自定义字体</h4><input type="file" id="font-upload-input" accept=".ttf,.otf,.woff,.woff2" class="block w-full text-sm text-light-subtext file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-sky-100 file:text-primary hover:file:bg-sky-200 cursor-pointer dark:file:bg-sky-900/50 dark:hover:file:bg-sky-900"></div><div><h4 class="text-md font-medium mb-3 text-light-text dark:text-dark-text"><i class="fa fa-comments-o mr-2 text-primary"></i>对话高亮</h4><div class="space-y-3"><label class="flex items-center space-x-3 cursor-pointer"><input type="checkbox" id="dialogue-bold-toggle" class="h-4 w-4 rounded border-slate-300 text-primary focus:ring-primary dark:bg-slate-700 dark:border-slate-600"><span class="text-sm font-medium">对话内容加粗</span></label><div class="flex items-center space-x-3"><label for="dialogue-color-picker" class="text-sm font-medium text-light-subtext dark:text-dark-subtext">对话文字颜色:</label><input type="color" id="dialogue-color-picker" value="#334155" class="w-10 h-8 p-1 border rounded-md cursor-pointer dark:bg-slate-700 dark:border-slate-600"><button id="reset-dialogue-color-btn" class="text-xs text-light-subtext hover:text-primary">重置</button></div></div></div></div></div></div>
125
+ <div id="custom-prompt-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden p-4"><div class="bg-light-card rounded-xl shadow-xl w-full max-w-lg dark:bg-dark-card"><div class="p-5 border-b flex justify-between items-center dark:border-slate-700"><h3 class="text-lg font-semibold text-sky-600 dark:text-sky-500">自定义生成</h3><button class="close-modal-btn p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-6"><textarea id="custom-prompt-input" class="input-base h-40" placeholder="请输入你希望接下来发��的情节..."></textarea><div class="mt-4 flex justify-end"><button id="submit-custom-prompt-btn" class="btn-primary">生成</button></div></div></div></div>
126
+ <div id="edit-chapter-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden p-4"><div class="bg-light-card rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col dark:bg-dark-card"><div class="p-5 border-b flex justify-between items-center dark:border-slate-700"><h3 class="text-lg font-semibold text-sky-600 dark:text-sky-500">编辑章节</h3><button class="close-modal-btn p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-6 flex-grow flex flex-col"><input type="text" id="edit-chapter-title-input" class="input-base mb-4" placeholder="章节标题"><textarea id="edit-chapter-content-input" class="input-base flex-grow w-full resize-none" style="min-height: 300px;"></textarea><div class="mt-4 flex justify-between items-center"><button id="delete-chapter-btn" class="btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 flex items-center"><i class="fa fa-trash mr-2"></i>删除章节</button><div><button id="save-edit-btn" class="btn-primary">保存</button></div></div></div></div></div>
127
+ <div id="model-settings-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden p-4"><div class="bg-light-card rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col dark:bg-dark-card"><div class="p-5 border-b flex justify-between items-center dark:border-slate-700"><h3 class="text-lg font-semibold text-sky-600 dark:text-sky-500">模型设置</h3><button class="close-modal-btn p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-6 space-y-6 overflow-y-auto"><div class="space-y-4"><h2 class="text-lg font-semibold flex items-center text-sky-600 dark:text-sky-500 -mb-2"><i class="fa fa-plug mr-2"></i>API 配置</h2><div><label for="api-type-select" class="block text-sm font-medium mb-1">API 类型</label><select id="api-type-select" class="input-base"><option value="openai">OpenAI</option><option value="gemini">Gemini</option></select></div><div><label for="api-url-input" class="block text-sm font-medium mb-1">API 地址</label><input type="url" id="api-url-input" class="input-base" placeholder="https://api.openai.com/v1/chat/completions"></div><div><label class="block text-sm font-medium mb-1">API Key 配置</label><div id="api-key-display" class="hidden p-3 border rounded-md bg-slate-100 dark:bg-slate-900/50 dark:border-slate-700"><div class="flex justify-between items-center"><ul id="masked-keys-list" class="space-y-1 text-sm font-mono text-light-subtext dark:text-dark-subtext"></ul><button id="edit-api-keys-btn" class="text-sm text-primary hover:underline">编辑</button></div></div><textarea id="api-key-input" rows="3" class="input-base font-mono text-sm" placeholder="可输入多个Key,用逗号或换行分隔..."></textarea><p class="mt-1 text-xs text-amber-600 dark:text-amber-500"><i class="fa fa-warning mr-1"></i>注意:API密钥将经过混淆后保存在您的浏览器本地。</p></div><button type="button" id="connect-api-btn" class="btn-primary w-full flex items-center justify-center"><i class="fa fa-link mr-2"></i>连接模型</button><div id="api-status" class="hidden text-sm py-2 px-3 rounded-md text-center"></div></div><div class="space-y-4 pt-6 border-t dark:border-slate-700"><h2 class="text-lg font-semibold -mb-2 flex items-center text-sky-600 dark:text-sky-500"><i class="fa fa-sliders mr-2"></i>生成参数</h2><div><label for="model-select" class="block text-sm font-medium mb-1">选择模型</label><select id="model-select" class="input-base" disabled><option value="">请先连接API</option></select></div><div><label for="context-memory-slider" class="block text-sm font-medium mb-1">上下文记忆: <span id="context-memory-value">3</span> 章</label><input type="range" id="context-memory-slider" min="0" max="20" value="3" class="w-full"></div><div><label for="temperature-slider" class="block text-sm font-medium mb-1">创意温度: <span id="temperature-value">0.7</span></label><input type="range" id="temperature-slider" min="0.1" max="1.5" step="0.1" value="0.7" class="w-full"></div></div></div></div></div>
128
+ <div id="story-preset-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden p-4"><div class="bg-light-card rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col dark:bg-dark-card"><div class="p-5 border-b flex justify-between items-center dark:border-slate-700"><h3 class="text-lg font-semibold text-sky-600 dark:text-sky-500">故事预设</h3><button class="close-modal-btn p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-6 flex-grow flex flex-col overflow-hidden"><div class="flex-shrink-0 mb-4 flex justify-between items-center"><div id="preset-tabs-container" class="flex space-x-1 border border-slate-200 dark:border-slate-700 rounded-lg p-1"></div><button id="add-preset-btn" class="btn-secondary flex items-center"><i class="fa fa-plus mr-2"></i>新增</button></div><div id="preset-list-container" class="flex-grow overflow-y-auto space-y-2 pr-2"></div><div class="flex-shrink-0 mt-6 pt-4 border-t dark:border-slate-700 flex justify-center"><button id="generate-from-presets-btn" class="btn-primary flex items-center"><i class="fa fa-magic mr-2"></i>根据选择生成故事</button></div></div></div></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  <script src="https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js"></script>
131
  <script>
132
  document.addEventListener('DOMContentLoaded', () => {
133
 
134
+ const LS_KEYS = { STATE: 'aiNovelState_v3', PRESETS: 'aiNovelPresets_v3', THEME: 'aiNovelTheme_v3' };
135
+ let story = [], currentChapterIndex = -1, chapterPages = [], currentPageIndex = 0;
 
 
 
 
 
 
 
 
 
 
136
  let apiConfig = { type: 'openai', url: '', keys: [], currentKeyIndex: 0, isConnected: false, models: [] };
137
+ let presetData = {}, selectedPresets = {}, activePresetTab = 'character';
138
+ // NEW: Icons added to categories
 
 
139
  const PRESET_CATEGORIES = {
140
+ character: { name: '角色', icon: 'fa-user-circle' },
141
+ player: { name: '玩家', icon: 'fa-gamepad' },
142
+ world: { name: '世界观', icon: 'fa-globe' },
143
+ style: { name: '文风', icon: 'fa-paint-brush' },
144
+ script: { name: '剧本', icon: 'fa-file-code-o' },
145
+ format: { name: '格式', icon: 'fa-list-alt' }
146
  };
 
147
  const mdConverter = new showdown.Converter({ noHeaderId: true, simplifiedAutoLink: true, openLinksInNewWindow: true });
148
 
 
 
149
  const $ = (selector) => document.querySelector(selector);
150
  const $$ = (selector) => document.querySelectorAll(selector);
 
151
  const elements = {
152
+ themeToggleBtn: $('#theme-toggle-btn'), loadingState: $('#loading-state'), contentContainer: $('#content-container'), welcomeMessage: $('#welcome-message'),
153
+ chapterTitleDisplay: $('#chapter-title-display'), contentArea: $('#content-area'), readingArea: $('#reading-area'), prevChapterBtn: $('#prev-chapter-btn'),
154
+ nextChapterBtn: $('#next-chapter-btn'), chapterInfo: $('#chapter-info'), paginationControls: $('#pagination-controls'), prevPageBtn: $('#prev-page-btn'),
155
+ nextPageBtn: $('#next-page-btn'), pageInfo: $('#page-info'), wordCount: $('#word-count'), editChapterBtn: $('#edit-chapter-btn'),
 
 
 
 
156
  exportChapterBtn: $('#export-chapter-btn'), exportAllBtn: $('#export-all-btn'),
157
  modals: { readingSettings: $('#reading-settings-modal'), customPrompt: $('#custom-prompt-modal'), editChapter: $('#edit-chapter-modal'), modelSettings: $('#model-settings-modal'), storyPreset: $('#story-preset-modal') },
158
  openers: { readingSettings: $('#open-reading-settings-btn'), customPrompt: $('#custom-prompt-btn'), editChapter: $('#edit-chapter-btn'), modelSettings: $('#open-model-settings-btn'), storyPreset: $('#open-story-preset-btn') },
159
  continueStoryBtn: $('#continue-story-btn'), regenerateChapterBtn: $('#regenerate-chapter-btn'),
160
+ mobileMenuBtn: $('#mobile-menu-btn'), mobileMenu: $('#mobile-menu'), continueStoryBtnMobile: $('#continue-story-btn-mobile'), customPromptBtnMobile: $('#custom-prompt-btn-mobile'),
161
+ regenerateChapterBtnMobile: $('#regenerate-chapter-btn-mobile'), openStoryPresetBtnMobile: $('#open-story-preset-btn-mobile'),
162
+ openModelSettingsBtnMobile: $('#open-model-settings-btn-mobile'), themeToggleBtnMobile: $('#theme-toggle-btn-mobile'), themeToggleIcon: $('#theme-toggle-btn').querySelector('i'),
163
+ fontSizeSlider: $('#font-size-slider'), fontSizeValue: $('#font-size-value'), lineHeightSlider: $('#line-height-slider'), lineHeightValue: $('#line-height-value'),
164
+ fontUploadInput: $('#font-upload-input'), dialogueBoldToggle: $('#dialogue-bold-toggle'), dialogueColorPicker: $('#dialogue-color-picker'), resetDialogueColorBtn: $('#reset-dialogue-color-btn'),
165
+ customPromptInput: $('#custom-prompt-input'), submitCustomPromptBtn: $('#submit-custom-prompt-btn'), editChapterTitleInput: $('#edit-chapter-title-input'),
166
+ editChapterContentInput: $('#edit-chapter-content-input'), saveEditBtn: $('#save-edit-btn'), deleteChapterBtn: $('#delete-chapter-btn'),
167
+ apiTypeSelect: $('#api-type-select'), apiUrlInput: $('#api-url-input'), apiKeyInput: $('#api-key-input'), apiKeyDisplay: $('#api-key-display'),
168
+ maskedKeysList: $('#masked-keys-list'), editApiKeysBtn: $('#edit-api-keys-btn'), connectApiBtn: $('#connect-api-btn'), apiStatus: $('#api-status'),
169
+ modelSelect: $('#model-select'), contextMemorySlider: $('#context-memory-slider'), contextMemoryValue: $('#context-memory-value'),
170
+ temperatureSlider: $('#temperature-slider'), temperatureValue: $('#temperature-value'), presetTabsContainer: $('#preset-tabs-container'),
171
+ addPresetBtn: $('#add-preset-btn'), presetListContainer: $('#preset-list-container'), generateFromPresetsBtn: $('#generate-from-presets-btn'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  };
173
 
174
+ const saveState = () => { const stateToSave = { story, currentChapterIndex, apiConfig: { ...apiConfig, keys: apiConfig.keys.map(key => btoa(key)) }, modelSettings: { model: elements.modelSelect.value, contextMemory: elements.contextMemorySlider.value, temperature: elements.temperatureSlider.value }, readingSettings: { fontSize: elements.fontSizeSlider.value, lineHeight: elements.lineHeightSlider.value, dialogueBold: elements.dialogueBoldToggle.checked, dialogueColor: elements.dialogueColorPicker.value }}; localStorage.setItem(LS_KEYS.STATE, JSON.stringify(stateToSave));};
175
+ const loadState = () => { const savedState = localStorage.getItem(LS_KEYS.STATE); if (!savedState) return; try { const state = JSON.parse(savedState); story = state.story || []; currentChapterIndex = state.currentChapterIndex ?? -1; if (state.apiConfig) { apiConfig = { ...state.apiConfig, keys: (state.apiConfig.keys || []).map(key => { try { return atob(key) } catch { return '' } }).filter(Boolean) }; elements.apiTypeSelect.value = apiConfig.type || 'openai'; elements.apiUrlInput.value = apiConfig.url || ''; } if (state.modelSettings) { localStorage.setItem('aiNovelLastModel', state.modelSettings.model || ''); elements.contextMemorySlider.value = state.modelSettings.contextMemory || 3; elements.temperatureSlider.value = state.modelSettings.temperature || 0.7; } if (state.readingSettings) { elements.fontSizeSlider.value = state.readingSettings.fontSize || 18; elements.lineHeightSlider.value = state.readingSettings.lineHeight || 1.75; elements.dialogueBoldToggle.checked = state.readingSettings.dialogueBold || false; elements.dialogueColorPicker.value = state.readingSettings.dialogueColor || '#334155'; }} catch (e) { console.error("Failed to load state:", e); localStorage.removeItem(LS_KEYS.STATE); }};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  const savePresets = () => localStorage.setItem(LS_KEYS.PRESETS, JSON.stringify({ data: presetData, selected: selectedPresets }));
177
+ const loadPresets = () => { const raw = localStorage.getItem(LS_KEYS.PRESETS); Object.keys(PRESET_CATEGORIES).forEach(key => { presetData[key] = []; selectedPresets[key] = []; }); if (raw) { try { const p = JSON.parse(raw); if (p.data) presetData = p.data; if (p.selected) selectedPresets = p.selected; } catch (e) { console.error("Failed to load presets:", e); } }};
178
+ const getNextApiKey = () => { if (!apiConfig.isConnected || !apiConfig.keys.length) return null; const key = apiConfig.keys[apiConfig.currentKeyIndex]; apiConfig.currentKeyIndex = (apiConfig.currentKeyIndex + 1) % apiConfig.keys.length; saveState(); return key; };
179
+ const fetchFromApi = async (endpoint, options) => { const response = await fetch(endpoint, options); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData?.error?.message || `HTTP Error: ${response.status}`); } return response.json(); };
180
+ const connectToApi = async () => { const type = elements.apiTypeSelect.value, url = elements.apiUrlInput.value.trim(), keysInput = elements.apiKeyInput.value.trim(); if (!url || !keysInput) return updateApiStatus('URL和API Key不能为空。', 'error'); const keys = keysInput.split(/[\s,]+/).filter(Boolean); if (!keys.length) return updateApiStatus('请输入一个有效的API Key。', 'error'); updateApiStatus('正在连接...', 'loading'); try { const testKey = keys[0]; let modelsUrl, headers; if (type === 'openai') { modelsUrl = `${url.replace(/\/v1\/.*$/, '')}/v1/models`; headers = { 'Authorization': `Bearer ${testKey}` }; } else { modelsUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${testKey}`; headers = {}; } const data = await fetchFromApi(modelsUrl, { headers }); const apiModels = (type === 'openai' ? data.data : data.models) || []; const modelList = []; elements.modelSelect.innerHTML = ''; apiModels.forEach(model => { const modelId = model.id || model.name; if (!modelId || (type === 'openai' && (modelId.includes('embed') || modelId.includes('vision')))) return; const finalId = modelId.startsWith('models/') ? modelId.substring(7) : modelId; modelList.push(finalId); elements.modelSelect.add(new Option(finalId, finalId)); }); if (elements.modelSelect.options.length > 0) { elements.modelSelect.value = localStorage.getItem('aiNovelLastModel') || ''; elements.modelSelect.disabled = false; apiConfig = { type, url, keys, currentKeyIndex: 0, isConnected: true, models: modelList }; const successMessage = `连接成功!发现 ${modelList.length} 个模型。${keys.length > 1 ? `已配置 ${keys.length} 个Key进行轮换。` : ''}`; updateApiStatus(successMessage, 'success'); renderApiKeyDisplay(); saveState(); } else { throw new Error("未找到兼容的模型。"); }} catch (error) { apiConfig.isConnected = false; apiConfig.models = []; saveState(); elements.modelSelect.disabled = true; elements.modelSelect.innerHTML = '<option value="">连接失败</option>'; updateApiStatus(`连接失败: ${error.message}`, 'error'); }};
181
+ const generateChapter = async (userPrompt, isRegeneration = false) => { if (!apiConfig.isConnected) return alert('请先在模型设置中连接API。'); const apiKey = getNextApiKey(); if (!apiKey) { toggleLoading(false); return alert('API已连接,但未配置密钥。请重新配置。'); } toggleLoading(true); const contextMemory = parseInt(elements.contextMemorySlider.value, 10), temperature = parseFloat(elements.temperatureSlider.value), model = elements.modelSelect.value; const promptData = buildFullPrompt(userPrompt, contextMemory, isRegeneration); let endpoint = apiConfig.url, payload, headers; if (apiConfig.type === 'openai') { payload = { model, temperature, messages: promptData.messages }; headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; } else { payload = { generationConfig: { temperature }, contents: [{ role: 'user', parts: [{ text: promptData.fullPrompt }] }] }; endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; headers = { 'Content-Type': 'application/json' }; } try { const data = await fetchFromApi(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) }); const newContent = (apiConfig.type === 'openai' ? data.choices?.[0]?.message?.content : data.candidates?.[0]?.content?.parts?.[0]?.text)?.trim(); if (!newContent) throw new Error('API返回内容为空。'); if (isRegeneration) { story[currentChapterIndex].content = newContent; story[currentChapterIndex].customPrompt = userPrompt; } else { const newIndex = story.length; story.push({ title: `第 ${newIndex + 1} 章`, content: newContent, customPrompt: userPrompt }); currentChapterIndex = newIndex; } renderChapter(currentChapterIndex); } catch (error) { console.error('生成失败:', error); alert(`生成失败: ${error.message}`); toggleLoading(false); }};
182
+ const buildFullPrompt = (userPrompt, contextMemory, isRegeneration) => { const presetsPrompt = generatePromptFromSelectedPresets(); const formatPreset = presetData.format?.find(p => selectedPresets.format?.includes(p.id)); const formatInstructions = formatPreset ? formatPreset.desc : "Use Markdown format. Chapter title as H1."; const systemContent = `You are a talented novelist. Create the story based on the following settings and requirements.\n\n### Story Presets\n${presetsPrompt}\n\n### Output Format\n${formatInstructions}`; const contextStory = isRegeneration ? story.slice(0, currentChapterIndex) : story; const contextChapters = contextMemory > 0 ? contextStory.slice(-contextMemory) : []; let fullPrompt = `${systemContent}\n\n`; if(contextChapters.length > 0) fullPrompt += "### Previous Chapters (for context)\n" + contextChapters.map(c => `# ${c.title}\n${c.content}`).join('\n\n') + '\n\n'; fullPrompt += `### Current Task\n${userPrompt}`; let messages = [{ role: 'system', content: systemContent }]; if(contextChapters.length > 0) { messages.push({ role: 'user', content: "Here is the context from previous chapters:\n\n" + contextChapters.map(c => `# ${c.title}\n${c.content}`).join('\n\n') }); messages.push({ role: 'assistant', content: 'Understood. I have the context. What should I write next?' }); } messages.push({ role: 'user', content: `### Current Task\n${userPrompt}` }); return { fullPrompt, messages }; };
183
+ const handleApiTypeChange = () => { const type = elements.apiTypeSelect.value, urlInput = elements.apiUrlInput; if (type === 'gemini') { urlInput.value = 'https://generativelanguage.googleapis.com'; urlInput.readOnly = true; urlInput.classList.add('bg-slate-100', 'dark:bg-slate-700/50', 'cursor-not-allowed'); } else { urlInput.readOnly = false; urlInput.classList.remove('bg-slate-100', 'dark:bg-slate-700/50', 'cursor-not-allowed'); urlInput.placeholder = 'https://api.openai.com/v1/chat/completions'; urlInput.value = (apiConfig.type === 'openai' && apiConfig.url) ? apiConfig.url : ''; }};
184
+ const renderChapter = (index) => { toggleLoading(false); if (index < 0 || index >= story.length) return showWelcomeScreen(); hideWelcomeScreen(); currentChapterIndex = index; const chapter = story[index]; let contentHtml = mdConverter.makeHtml(chapter.content); const tempDiv = document.createElement('div'); tempDiv.innerHTML = contentHtml; const h1 = tempDiv.querySelector('h1'); if (h1) { chapter.title = h1.textContent.trim(); h1.remove(); contentHtml = tempDiv.innerHTML; } contentHtml = highlightDialogue(contentHtml); elements.chapterTitleDisplay.textContent = chapter.title; chapterPages = paginateContent(contentHtml, 2500); currentPageIndex = 0; renderCurrentPage(); elements.readingArea.scrollTop = 0; updateUI(); saveState(); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  const renderCurrentPage = () => { elements.contentArea.innerHTML = chapterPages[currentPageIndex] || ''; updatePaginationUI(); };
186
+ const updateUI = () => { const hasStory = story.length > 0; elements.chapterInfo.textContent = hasStory ? `第 ${currentChapterIndex + 1} / ${story.length} 章` : '无内容'; elements.prevChapterBtn.disabled = !hasStory || currentChapterIndex === 0; elements.nextChapterBtn.disabled = !hasStory || currentChapterIndex === story.length - 1; [elements.continueStoryBtn, elements.regenerateChapterBtn, elements.editChapterBtn, elements.exportChapterBtn, elements.exportAllBtn, elements.openers.customPrompt, elements.continueStoryBtnMobile, elements.customPromptBtnMobile, elements.regenerateChapterBtnMobile].forEach(btn => { btn.disabled = !hasStory; }); elements.wordCount.textContent = hasStory ? (story[currentChapterIndex].content.match(/[\u4e00-\u9fa5\w]+/g) || []).join('').length : 0; elements.contextMemoryValue.textContent = elements.contextMemorySlider.value; elements.temperatureValue.textContent = elements.temperatureSlider.value; elements.fontSizeValue.textContent = `${elements.fontSizeSlider.value}px`; elements.lineHeightValue.textContent = elements.lineHeightSlider.value; };
187
+ const updatePaginationUI = () => { if (chapterPages.length > 1) { elements.paginationControls.classList.remove('hidden'); elements.pageInfo.textContent = `${currentPageIndex + 1} / ${chapterPages.length}`; elements.prevPageBtn.disabled = currentPageIndex === 0; elements.nextPageBtn.disabled = currentPageIndex >= chapterPages.length - 1; } else { elements.paginationControls.classList.add('hidden'); }};
188
+ const updateApiStatus = (message, type) => { const s = elements.apiStatus; s.textContent = message; s.className = 'text-sm py-2 px-3 rounded-md text-center'; if (type === 'success') s.classList.add('bg-green-100', 'text-green-800', 'dark:bg-green-900/50', 'dark:text-green-300'); else if (type === 'error') s.classList.add('bg-red-100', 'text-red-800', 'dark:bg-red-900/50', 'dark:text-red-300'); else s.classList.add('bg-amber-100', 'text-amber-800', 'dark:bg-amber-900/50', 'dark:text-amber-300'); elements.connectApiBtn.innerHTML = type === 'loading' ? '<i class="fa fa-spinner fa-spin mr-2"></i>正在连接...' : '<i class="fa fa-link mr-2"></i>连接模型'; elements.connectApiBtn.disabled = (type === 'loading'); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  const showWelcomeScreen = () => { elements.contentContainer.classList.add('hidden'); elements.welcomeMessage.classList.remove('hidden'); currentChapterIndex = -1; updateUI(); };
190
  const hideWelcomeScreen = () => { elements.contentContainer.classList.remove('hidden'); elements.welcomeMessage.classList.add('hidden'); };
191
  const toggleLoading = (isLoading) => elements.loadingState.classList.toggle('hidden', !isLoading);
 
 
 
192
  const maskApiKey = (key) => `${key.substring(0, 5)}...${key.substring(key.length - 4)}`;
193
+ const renderApiKeyDisplay = () => { const hasKeys = apiConfig.keys && apiConfig.keys.length > 0; elements.apiKeyInput.classList.toggle('hidden', hasKeys); elements.apiKeyDisplay.classList.toggle('hidden', !hasKeys); if (hasKeys) { elements.maskedKeysList.innerHTML = apiConfig.keys.map((key, index) => `<li>Key ${index + 1}: ${maskApiKey(key)}</li>`).join(''); }};
194
+ const restoreApiConnectionUI = () => { if (apiConfig.isConnected && apiConfig.models && apiConfig.models.length > 0) { elements.modelSelect.innerHTML = ''; apiConfig.models.forEach(modelId => { elements.modelSelect.add(new Option(modelId, modelId)); }); const lastModel = localStorage.getItem('aiNovelLastModel'); if (lastModel && apiConfig.models.includes(lastModel)) elements.modelSelect.value = lastModel; elements.modelSelect.disabled = false; const successMessage = `已连接! 找到 ${apiConfig.models.length} 个模型。${apiConfig.keys.length > 1 ? `正在轮换 ${apiConfig.keys.length} 个Key。` : ''}`; updateApiStatus(successMessage, 'success'); }};
195
+ const toggleModal = (modalName, show) => { if(elements.modals[modalName]) elements.modals[modalName].classList.toggle('hidden', !show); };
196
+ const applyReadingSettings = () => { const root = document.documentElement; root.style.setProperty('--reading-font-size', `${elements.fontSizeSlider.value}px`); root.style.setProperty('--reading-line-height', elements.lineHeightSlider.value); root.style.setProperty('--dialogue-highlight-weight', elements.dialogueBoldToggle.checked ? 'bold' : 'normal'); root.style.setProperty('--dialogue-highlight-color', elements.dialogueColorPicker.value); updateUI(); saveState(); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  const highlightDialogue = (text) => text.replace(/(“[^”]+”|‘[^’]+’|"[^"]+"|\'[^\']+\'|「[^」]+」|『[^』]+』)/g, `<span class="dialogue-highlight">$1</span>`);
198
+ const paginateContent = (htmlContent, limit) => { const pages = []; const tempDiv = document.createElement('div'); tempDiv.innerHTML = htmlContent; const nodes = Array.from(tempDiv.childNodes); if (nodes.length === 0) return [htmlContent]; let currentPageHtml = ''; for (const node of nodes) { if (currentPageHtml && (currentPageHtml.length + (node.textContent || '').length) > limit) { pages.push(currentPageHtml); currentPageHtml = ''; } currentPageHtml += node.outerHTML || node.textContent; } if (currentPageHtml) pages.push(currentPageHtml); return pages; };
199
+ const downloadAsTxt = (filename, text) => { const blob = new Blob(['\uFEFF' + text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); };
200
+ const renderPresetTabs = () => {
201
+ const isMobile = window.innerWidth < 768;
202
+ elements.presetTabsContainer.innerHTML = Object.entries(PRESET_CATEGORIES).map(([key, {name, icon}]) => {
203
+ const content = isMobile ? `<i class="fa ${icon} fa-fw"></i>` : name;
204
+ return `<button data-tab="${key}" title="${name}" class="tab-button px-3 py-1 text-sm rounded-md transition-colors ${activePresetTab === key ? 'active' : ''}">${content}</button>`
205
+ }).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  };
207
+ const renderPresetList = () => { const list = presetData[activePresetTab] || []; elements.presetListContainer.innerHTML = list.length === 0 ? `<p class="text-center text-light-subtext py-4">此分类下无预设。</p>` : list.map(item => `<div class="p-3 rounded-lg flex items-center justify-between cursor-pointer transition-colors ${selectedPresets[activePresetTab]?.includes(item.id) ? 'bg-sky-100/50 dark:bg-sky-900/20' : 'bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600'}"><div class="flex items-center flex-grow" data-action="toggle-select" data-id="${item.id}"><input type="checkbox" class="h-4 w-4 rounded border-slate-300 text-primary pointer-events-none" ${selectedPresets[activePresetTab]?.includes(item.id) ? 'checked' : ''}><div class="ml-3"><p class="font-semibold text-light-text dark:text-dark-text">${item.name}</p><p class="text-xs text-light-subtext">${item.desc}</p></div></div><div class="flex space-x-1 ml-2"><button data-action="edit-preset" data-id="${item.id}" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700"><i class="fa fa-edit text-sm"></i></button><button data-action="delete-preset" data-id="${item.id}" class="p-2 rounded-full hover:bg-red-100 dark:hover:bg-red-800/50"><i class="fa fa-trash text-sm text-red-500"></i></button></div></div>`).join(''); };
208
+ const showPresetEditor = (itemToEdit = null) => { const isEditing = !!itemToEdit; const modalId = 'dynamic-preset-editor'; $(`#${modalId}`)?.remove(); const modal = document.createElement('div'); modal.id = modalId; modal.className = 'fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4'; modal.innerHTML = `<div class="bg-light-card dark:bg-dark-card rounded-xl shadow-xl w-full max-w-md"><div class="p-5 border-b dark:border-slate-700"><h3 class='text-lg font-semibold text-light-text dark:text-dark-text'>${isEditing ? '编辑' : '新增'} ${PRESET_CATEGORIES[activePresetTab].name} 预设</h3></div><div class="p-5 space-y-4"><input id='preset-edit-name' class='input-base' placeholder='名称' value='${itemToEdit?.name || ''}'/><textarea id='preset-edit-desc' class='input-base h-24' placeholder='描述'>${itemToEdit?.desc || ''}</textarea></div><div class='p-5 flex justify-end space-x-2 border-t dark:border-slate-700'><button id='preset-edit-cancel' class='btn-secondary'>取消</button><button id='preset-edit-save' class='btn-primary'>保存</button></div></div>`; document.body.appendChild(modal); modal.querySelector('#preset-edit-cancel').onclick = () => modal.remove(); modal.querySelector('#preset-edit-save').onclick = () => { const name = modal.querySelector('#preset-edit-name').value.trim(); const desc = modal.querySelector('#preset-edit-desc').value.trim(); if (!name) return alert('名称不能为空。'); if (isEditing) { itemToEdit.name = name; itemToEdit.desc = desc; } else { presetData[activePresetTab].push({ id: `p_${Date.now()}`, name, desc }); } savePresets(); renderPresetList(); modal.remove(); }; };
209
+ const generatePromptFromSelectedPresets = () => { let prompt = ""; Object.entries(selectedPresets).forEach(([category, ids]) => { if (ids.length === 0) return; prompt += `### ${PRESET_CATEGORIES[category].name}\n`; ids.forEach(id => { const item = presetData[category].find(p => p.id === id); if (item) prompt += `- ${item.name}: ${item.desc}\n`; }); prompt += '\n'; }); return prompt || "无预设,请自由发挥。"; };
210
 
 
 
 
 
 
 
 
 
211
  function initializeEventListeners() {
212
+ Object.entries(elements.openers).forEach(([name, opener]) => { if(opener) opener.addEventListener('click', () => toggleModal(name, true)); });
 
213
  $$('.close-modal-btn').forEach(btn => btn.addEventListener('click', (e) => e.target.closest('.fixed').classList.add('hidden')));
214
+ const mainActionHandler = (action) => () => { if (action === 'continue') generateChapter("接续上一章的情节,创作新的一章。"); else if (action === 'regenerate') { if (currentChapterIndex < 0) return; const prompt = story[currentChapterIndex].customPrompt || "用不同的视角或风格重写这一章。"; generateChapter(prompt, true); }};
215
+ elements.continueStoryBtn.addEventListener('click', mainActionHandler('continue'));
216
+ elements.regenerateChapterBtn.addEventListener('click', mainActionHandler('regenerate'));
217
  elements.prevChapterBtn.addEventListener('click', () => renderChapter(currentChapterIndex - 1));
218
  elements.nextChapterBtn.addEventListener('click', () => renderChapter(currentChapterIndex + 1));
219
  elements.prevPageBtn.addEventListener('click', () => { if (currentPageIndex > 0) renderCurrentPage(--currentPageIndex); });
 
226
  elements.submitCustomPromptBtn.addEventListener('click', () => { const prompt = elements.customPromptInput.value.trim(); if (prompt) { generateChapter(prompt); toggleModal('customPrompt', false); elements.customPromptInput.value = ''; } });
227
  elements.openers.editChapter.addEventListener('click', () => { if(currentChapterIndex < 0) return; const chapter = story[currentChapterIndex]; elements.editChapterTitleInput.value = chapter.title; elements.editChapterContentInput.value = chapter.content; });
228
  elements.saveEditBtn.addEventListener('click', () => { story[currentChapterIndex].title = elements.editChapterTitleInput.value.trim(); story[currentChapterIndex].content = elements.editChapterContentInput.value.trim(); renderChapter(currentChapterIndex); toggleModal('editChapter', false); });
229
+ elements.deleteChapterBtn.addEventListener('click', () => { if (confirm('确定要删除本章吗?此操作无法撤销。')) { story.splice(currentChapterIndex, 1); saveState(); const newIndex = Math.max(-1, currentChapterIndex - 1); renderChapter(newIndex); toggleModal('editChapter', false); }});
230
  elements.connectApiBtn.addEventListener('click', connectToApi);
231
  elements.apiTypeSelect.addEventListener('change', handleApiTypeChange);
232
  [elements.contextMemorySlider, elements.temperatureSlider, elements.modelSelect].forEach(el => el.addEventListener('input', () => { saveState(); updateUI(); }));
233
+ elements.editApiKeysBtn.addEventListener('click', () => { apiConfig.keys = []; apiConfig.isConnected = false; elements.apiKeyInput.value = ''; renderApiKeyDisplay(); updateApiStatus('请重新输入API Key并连接。', 'loading'); elements.modelSelect.disabled = true; elements.modelSelect.innerHTML = '<option value="">请先连接API</option>'; saveState(); });
 
 
 
 
 
 
 
 
 
 
 
234
  elements.presetTabsContainer.addEventListener('click', (e) => { const tab = e.target.closest('button')?.dataset.tab; if (tab) { activePresetTab = tab; renderPresetTabs(); renderPresetList(); } });
235
  elements.addPresetBtn.addEventListener('click', () => showPresetEditor());
236
  elements.presetListContainer.addEventListener('click', (e) => { const btn = e.target.closest('button'); const id = btn?.dataset.id; const action = btn?.dataset.action; if (action === 'edit-preset') { const item = presetData[activePresetTab].find(p => p.id === id); if (item) showPresetEditor(item); } else if (action === 'delete-preset') { if (confirm('确定删除此预设?')) { presetData[activePresetTab] = presetData[activePresetTab].filter(p => p.id !== id); selectedPresets[activePresetTab] = selectedPresets[activePresetTab].filter(pId => pId !== id); savePresets(); renderPresetList(); } } else { const container = e.target.closest('[data-action="toggle-select"]'); const selectId = container?.dataset.id; if (selectId) { const list = selectedPresets[activePresetTab]; const index = list.indexOf(selectId); if (index > -1) list.splice(index, 1); else list.push(selectId); savePresets(); renderPresetList(); } } });
237
  elements.generateFromPresetsBtn.addEventListener('click', () => { if (!Object.values(selectedPresets).some(arr => arr.length > 0)) return alert('请至少选择一个预设。'); generateChapter("根据所选预设开始第一章。"); toggleModal('storyPreset', false); });
238
+ const toggleMobileMenu = (forceClose = false) => { if(elements.mobileMenu) elements.mobileMenu.classList.toggle('hidden', forceClose); };
239
+ elements.mobileMenuBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleMobileMenu(); });
240
+ document.addEventListener('click', () => toggleMobileMenu(true));
241
+ elements.mobileMenu.addEventListener('click', (e) => { e.stopPropagation(); });
242
+ const createMobileButtonHandler = (desktopButton) => (e) => { e.preventDefault(); if(desktopButton) desktopButton.click(); toggleMobileMenu(true); };
243
+ elements.customPromptBtnMobile.addEventListener('click', createMobileButtonHandler(elements.openers.customPrompt));
244
+ elements.regenerateChapterBtnMobile.addEventListener('click', createMobileButtonHandler(elements.regenerateChapterBtn));
245
+ elements.openStoryPresetBtnMobile.addEventListener('click', createMobileButtonHandler(elements.openers.storyPreset));
246
+ elements.openModelSettingsBtnMobile.addEventListener('click', createMobileButtonHandler(elements.openers.modelSettings));
247
+ elements.themeToggleBtnMobile.addEventListener('click', createMobileButtonHandler(elements.themeToggleBtn));
248
+ elements.continueStoryBtnMobile.addEventListener('click', createMobileButtonHandler(elements.continueStoryBtn));
249
+ const updateThemeIcon = (isDark) => { const iconClass = isDark ? 'fa-sun-o' : 'fa-moon-o'; if(elements.themeToggleIcon) elements.themeToggleIcon.className = `fa ${iconClass}`; if(elements.themeToggleBtnMobile) elements.themeToggleBtnMobile.querySelector('i').className = `fa ${iconClass} fa-fw mr-3`; };
250
+ elements.themeToggleBtn.addEventListener('click', () => { const isDark = document.documentElement.classList.toggle('dark'); localStorage.setItem(LS_KEYS.THEME, isDark ? 'dark' : 'light'); updateThemeIcon(isDark); });
251
+ window.addEventListener('resize', renderPresetTabs);
252
  }
253
+
 
 
254
  function initializeApp() {
255
+ loadState(); loadPresets(); initializeEventListeners();
256
  const savedTheme = localStorage.getItem(LS_KEYS.THEME);
257
+ const isDark = savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
258
+ if (isDark) document.documentElement.classList.add('dark');
259
+ const updateThemeIcon = (isDark) => { const iconClass = isDark ? 'fa-sun-o' : 'fa-moon-o'; if(elements.themeToggleIcon) elements.themeToggleIcon.className = `fa ${iconClass}`; if(elements.themeToggleBtnMobile) elements.themeToggleBtnMobile.querySelector('i').className = `fa ${iconClass} fa-fw mr-3`; };
260
+ updateThemeIcon(isDark);
261
+ applyReadingSettings(); handleApiTypeChange(); renderApiKeyDisplay(); restoreApiConnectionUI(); renderPresetTabs(); renderPresetList();
262
+ if (story.length === 0 || currentChapterIndex < 0) { showWelcomeScreen(); } else { renderChapter(currentChapterIndex); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  }
264
 
265
  initializeApp();