Spaces:
Running
Running
Update book.html
Browse files
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: '
|
| 17 |
-
|
| 18 |
-
|
| 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-
|
| 39 |
-
--
|
| 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:
|
| 49 |
-
--
|
| 50 |
-
--dialogue-highlight-color: #cbd5e1; /* slate-300 */
|
| 51 |
}
|
| 52 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
@layer utilities {
|
| 54 |
-
|
| 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-
|
| 69 |
|
| 70 |
-
<
|
| 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-
|
| 74 |
-
<div class="flex items-center space-x-2">
|
| 75 |
-
<button id="custom-prompt-btn" class="
|
| 76 |
-
<button id="
|
| 77 |
-
<button id="
|
| 78 |
-
<span class="text-
|
| 79 |
-
<button id="open-
|
| 80 |
-
<button id="open-
|
| 81 |
-
<button id="theme-toggle-btn" class="p-
|
| 82 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
</div>
|
| 84 |
</header>
|
| 85 |
|
| 86 |
-
<
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
<
|
| 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-
|
| 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-
|
| 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-
|
| 118 |
-
<h2 class="text-2xl font-bold text-
|
| 119 |
-
<p class="text-
|
| 120 |
</div>
|
| 121 |
</div>
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 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-
|
| 129 |
-
<span id="page-info" class="text-xs
|
| 130 |
-
<button id="next-page-btn" class="p-1 rounded-md hover:bg-
|
| 131 |
</div>
|
| 132 |
</div>
|
| 133 |
<div class="flex space-x-1">
|
| 134 |
-
<button id="edit-chapter-btn" class="p-
|
| 135 |
-
<button id="open-reading-settings-btn" class="p-
|
| 136 |
-
<button id="export-chapter-btn" class="p-
|
| 137 |
-
<button id="export-all-btn" class="p-
|
| 138 |
</div>
|
| 139 |
</div>
|
| 140 |
</div>
|
| 141 |
</main>
|
| 142 |
|
| 143 |
-
<
|
| 144 |
-
<
|
| 145 |
-
<div id="
|
| 146 |
-
|
| 147 |
-
|
| 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 |
-
|
| 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 |
-
|
| 321 |
-
let selectedPresets = {};
|
| 322 |
-
let activePresetTab = 'character';
|
| 323 |
const PRESET_CATEGORIES = {
|
| 324 |
-
character:
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 338 |
-
|
| 339 |
-
|
| 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 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
saveEditBtn: $('#save-edit-btn'), deleteChapterBtn: $('#delete-chapter-btn'),
|
| 355 |
-
apiTypeSelect: $('#api-type-select'), apiUrlInput: $('#api-url-input'),
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 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
|
| 379 |
-
|
| 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 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
};
|
| 415 |
-
|
| 416 |
-
const
|
| 417 |
-
|
| 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
|
| 598 |
-
|
| 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 |
-
|
| 641 |
-
|
| 642 |
-
|
| 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 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 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.
|
| 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 |
-
|
| 718 |
-
elements.
|
|
|
|
| 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 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 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();
|