aimdeepcafe commited on
Commit
e8c45ff
·
verified ·
1 Parent(s): 65ef249

Update book.html

Browse files
Files changed (1) hide show
  1. book.html +179 -29
book.html CHANGED
@@ -3,7 +3,7 @@
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>
@@ -58,8 +58,8 @@
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-1">|</span>
61
- <button id="import-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="导入数据"><i class="fa fa-cloud-upload"></i></button>
62
- <button id="export-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="导出数据"><i class="fa fa-cloud-download"></i></button>
63
  <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>
64
  <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>
65
  <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>
@@ -73,8 +73,8 @@
73
  <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>
74
  <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>
75
  <div class="my-1 h-px bg-slate-200 dark:bg-slate-700"></div>
76
- <a href="#" id="import-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-cloud-upload fa-fw mr-3"></i>导入数据</a>
77
- <a href="#" id="export-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-cloud-download fa-fw mr-3"></i>导出数据</a>
78
  <div class="my-1 h-px bg-slate-200 dark:bg-slate-700"></div>
79
  <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>
80
  <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>
@@ -127,9 +127,14 @@
127
  </main>
128
 
129
  <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 space-y-5 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><div class="space-y-2"><p class="text-xs text-light-subtext">从本地上传字体文件:</p><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"><p class="text-xs text-light-subtext pt-2">或从URL加载字体:</p><div class="flex space-x-2"><input type="url" id="font-url-input" class="input-base" placeholder="https://.../font.woff2"><button id="apply-font-url-btn" class="btn-secondary flex-shrink-0">应用</button></div></div></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-highlight-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 id="dialogue-options" class="hidden pl-7 space-y-3 pt-2"><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></div>
130
- <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5"><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>
131
  <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 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>
132
- <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 space-y-5 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-5 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>
 
 
 
 
 
133
  <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 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-0 border border-slate-200 dark:border-slate-700 rounded-lg p-1"></div><button id="add-preset-btn" title="新增预设" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 md:btn-secondary flex items-center"><i class="fa fa-plus md:mr-2"></i><span class="hidden md:inline">新增</span></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-4 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>
134
  <input type="file" id="import-file-input" class="hidden" accept=".json">
135
 
@@ -137,10 +142,19 @@
137
  <script>
138
  document.addEventListener('DOMContentLoaded', () => {
139
 
140
- const LS_KEYS = { STATE: 'aiNovelState_v3', PRESETS: 'aiNovelPresets_v3', THEME: 'aiNovelTheme_v3' };
141
  let story = [], currentChapterIndex = -1, chapterPages = [], currentPageIndex = 0;
142
- let apiConfig = { type: 'openai', url: '', keys: [], currentKeyIndex: 0, isConnected: false, models: [] };
 
 
 
 
 
 
 
 
143
  let presetData = {}, selectedPresets = {}, activePresetTab = 'character', expandedPresets = new Set();
 
144
  const PRESET_CATEGORIES = {
145
  character: { name: '角色', icon: 'fa-user-circle' }, player: { name: '玩家', icon: 'fa-gamepad' }, world: { name: '世界观', icon: 'fa-globe' },
146
  style: { name: '文风', icon: 'fa-paint-brush' }, script: { name: '剧本', icon: 'fa-file-code-o' }, format: { name: '格式', icon: 'fa-list-alt' }
@@ -164,37 +178,160 @@
164
  fontSizeSlider: $('#font-size-slider'), fontSizeValue: $('#font-size-value'), lineHeightSlider: $('#line-height-slider'), lineHeightValue: $('#line-height-value'),
165
  fontUploadInput: $('#font-upload-input'), fontUrlInput: $('#font-url-input'), applyFontUrlBtn: $('#apply-font-url-btn'),
166
  dialogueHighlightToggle: $('#dialogue-highlight-toggle'), dialogueOptions: $('#dialogue-options'), dialogueBoldToggle: $('#dialogue-bold-toggle'), dialogueColorPicker: $('#dialogue-color-picker'), resetDialogueColorBtn: $('#reset-dialogue-color-btn'),
167
- customPromptInput: $('#custom-prompt-input'), submitCustomPromptBtn: $('#submit-custom-prompt-btn'), editChapterTitleInput: $('#edit-chapter-title-input'),
168
- editChapterContentInput: $('#edit-chapter-content-input'), saveEditBtn: $('#save-edit-btn'), deleteChapterBtn: $('#delete-chapter-btn'),
169
- apiTypeSelect: $('#api-type-select'), apiUrlInput: $('#api-url-input'), apiKeyInput: $('#api-key-input'), apiKeyDisplay: $('#api-key-display'),
 
 
 
170
  maskedKeysList: $('#masked-keys-list'), editApiKeysBtn: $('#edit-api-keys-btn'), connectApiBtn: $('#connect-api-btn'), apiStatus: $('#api-status'),
171
  modelSelect: $('#model-select'), contextMemorySlider: $('#context-memory-slider'), contextMemoryValue: $('#context-memory-value'),
172
  temperatureSlider: $('#temperature-slider'), temperatureValue: $('#temperature-value'), presetTabsContainer: $('#preset-tabs-container'),
173
  addPresetBtn: $('#add-preset-btn'), presetListContainer: $('#preset-list-container'), generateFromPresetsBtn: $('#generate-from-presets-btn'),
174
  importBtn: $('#import-btn'), exportBtn: $('#export-btn'), importBtnMobile: $('#import-btn-mobile'), exportBtnMobile: $('#export-btn-mobile'), importFileInput: $('#import-file-input'),
 
 
175
  };
176
 
177
- 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, dialogueHighlight: elements.dialogueHighlightToggle.checked, dialogueBold: elements.dialogueBoldToggle.checked, dialogueColor: elements.dialogueColorPicker.value, fontUrl: elements.fontUrlInput.value }}; localStorage.setItem(LS_KEYS.STATE, JSON.stringify(stateToSave));};
178
- 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.dialogueHighlightToggle.checked = state.readingSettings.dialogueHighlight || false; elements.dialogueBoldToggle.checked = state.readingSettings.dialogueBold || false; elements.dialogueColorPicker.value = state.readingSettings.dialogueColor || (document.documentElement.classList.contains('dark') ? '#cbd5e1' : '#334155'); if(state.readingSettings.fontUrl) { elements.fontUrlInput.value = state.readingSettings.fontUrl; applyFontFromUrl(state.readingSettings.fontUrl); } }} catch (e) { console.error("Failed to load state:", e); localStorage.removeItem(LS_KEYS.STATE); }};
179
- const savePresets = () => localStorage.setItem(LS_KEYS.PRESETS, JSON.stringify({ data: presetData, selected: selectedPresets, expanded: Array.from(expandedPresets) }));
180
  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; if (p.expanded) expandedPresets = new Set(p.expanded) } catch (e) { console.error("Failed to load presets:", e); } }};
181
- 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; };
182
- 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(); };
183
- 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'); }};
184
- 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); }};
185
- 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 }; };
186
- 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 : ''; }};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  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); if (elements.dialogueHighlightToggle.checked) { contentHtml = highlightDialogue(contentHtml); } 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; } elements.chapterTitleDisplay.textContent = chapter.title; chapterPages = paginateContent(contentHtml, 2500); currentPageIndex = 0; renderCurrentPage(); elements.readingArea.scrollTop = 0; updateUI(); saveState(); };
188
  const renderCurrentPage = () => { elements.contentArea.innerHTML = chapterPages[currentPageIndex] || ''; updatePaginationUI(); };
189
  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; };
190
  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'); }};
191
- 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'); };
192
  const showWelcomeScreen = () => { elements.contentContainer.classList.add('hidden'); elements.welcomeMessage.classList.remove('hidden'); currentChapterIndex = -1; updateUI(); };
193
  const hideWelcomeScreen = () => { elements.contentContainer.classList.remove('hidden'); elements.welcomeMessage.classList.add('hidden'); };
194
  const toggleLoading = (isLoading) => elements.loadingState.classList.toggle('hidden', !isLoading);
195
  const maskApiKey = (key) => `${key.substring(0, 5)}...${key.substring(key.length - 4)}`;
196
- 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(''); }};
197
- 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'); }};
198
  const toggleModal = (modalName, show) => { if(elements.modals[modalName]) elements.modals[modalName].classList.toggle('hidden', !show); };
199
  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); elements.dialogueOptions.classList.toggle('hidden', !elements.dialogueHighlightToggle.checked); updateUI(); saveState(); renderChapter(currentChapterIndex); };
200
  const applyFontFromUrl = (url) => { if(!url) return; const fontName = 'CustomOnlineFont'; const styleId = 'custom-font-style'; $(`#${styleId}`)?.remove(); const style = document.createElement('style'); style.id = styleId; style.textContent = `@font-face { font-family: '${fontName}'; src: url('${url}'); }`; document.head.appendChild(style); document.documentElement.style.setProperty('--reading-font', `'${fontName}'`);};
@@ -202,14 +339,18 @@
202
  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; };
203
  const downloadFile = (filename, content, mimeType) => { const blob = new Blob([content], { type: mimeType }); 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); };
204
  const renderPresetTabs = () => { const isMobile = window.innerWidth < 768; elements.presetTabsContainer.innerHTML = Object.entries(PRESET_CATEGORIES).map(([key, {name, icon}]) => { const content = isMobile ? `<i class="fa ${icon} fa-fw"></i>` : name; return `<button data-tab="${key}" title="${name}" class="tab-button px-3 py-1.5 text-sm rounded-md transition-colors ${activePresetTab === key ? 'active' : ''}">${content}</button>`}).join('');};
205
- const renderPresetList = () => { const list = presetData[activePresetTab] || []; const categoryName = PRESET_CATEGORIES[activePresetTab].name; elements.presetListContainer.innerHTML = list.length === 0 ? `<p class="text-center text-light-subtext py-4">暂无 ${categoryName} 预设</p>` : list.map(item => { const isExpanded = expandedPresets.has(item.id); return `<div class="bg-slate-100 dark:bg-slate-900/50 rounded-lg"><div class="p-2 flex items-start justify-between ${selectedPresets[activePresetTab]?.includes(item.id) ? 'bg-sky-100/50 dark:bg-sky-900/20' : ''}"><div class="flex items-center flex-grow min-w-0" data-action="toggle-select" data-id="${item.id}"><input type="checkbox" class="h-4 w-4 rounded border-slate-300 text-primary flex-shrink-0" ${selectedPresets[activePresetTab]?.includes(item.id) ? 'checked' : ''}><div class="ml-3 flex-grow cursor-pointer min-w-0" data-action="toggle-expand" data-id="${item.id}"><p class="font-semibold text-light-text dark:text-dark-text truncate">${item.name}</p><div class="grid ${isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'} transition-[grid-template-rows] duration-300 ease-in-out"><div class="overflow-hidden"><p class="text-xs text-light-subtext mt-1 pt-1 border-t border-slate-200 dark:border-slate-700">${item.desc.replace(/\n/g, '<br>')}</p></div></div></div></div><div class="flex items-center space-x-1 ml-2 flex-shrink-0"><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></div>`}).join(''); };
206
  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-4 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-4 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(); }; };
207
  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 || "无预设,请自由发挥。"; };
 
 
 
 
208
 
209
  function initializeEventListeners() {
210
  Object.entries(elements.openers).forEach(([name, opener]) => { if(opener) opener.addEventListener('click', () => toggleModal(name, true)); });
211
  $$('.close-modal-btn').forEach(btn => btn.addEventListener('click', (e) => e.target.closest('.fixed').classList.add('hidden')));
212
- const mainActionHandler = (action) => () => { if (action === 'continue') generateChapter("接续上一章的情节,创作新的一章。"); else if (action === 'regenerate') { if (currentChapterIndex < 0) return; const prompt = story[currentChapterIndex].customPrompt || "用不同的视角或风格重写这一章。"; generateChapter(prompt, true); }};
213
  elements.continueStoryBtn.addEventListener('click', mainActionHandler('continue'));
214
  elements.regenerateChapterBtn.addEventListener('click', mainActionHandler('regenerate'));
215
  elements.prevChapterBtn.addEventListener('click', () => renderChapter(currentChapterIndex - 1));
@@ -222,14 +363,15 @@
222
  elements.applyFontUrlBtn.addEventListener('click', () => { const url = elements.fontUrlInput.value.trim(); if (url) { applyFontFromUrl(url); saveState();} });
223
  elements.exportChapterBtn.addEventListener('click', () => { if(currentChapterIndex < 0) return; downloadFile(`${story[currentChapterIndex].title}.txt`, story[currentChapterIndex].content, 'text/plain;charset=utf-8'); });
224
  elements.exportAllBtn.addEventListener('click', () => { if(!story.length) return; downloadFile('Full Story.txt', story.map(c => `# ${c.title}\n\n${c.content}`).join('\n\n---\n\n'), 'text/plain;charset=utf-8'); });
225
- elements.submitCustomPromptBtn.addEventListener('click', () => { const prompt = elements.customPromptInput.value.trim(); if (prompt) { generateChapter(prompt); toggleModal('customPrompt', false); elements.customPromptInput.value = ''; } });
226
  elements.openers.editChapter.addEventListener('click', () => { if(currentChapterIndex < 0) return; const chapter = story[currentChapterIndex]; elements.editChapterTitleInput.value = chapter.title; elements.editChapterContentInput.value = chapter.content; });
227
  elements.saveEditBtn.addEventListener('click', () => { story[currentChapterIndex].title = elements.editChapterTitleInput.value.trim(); story[currentChapterIndex].content = elements.editChapterContentInput.value.trim(); renderChapter(currentChapterIndex); toggleModal('editChapter', false); });
228
  elements.deleteChapterBtn.addEventListener('click', () => { if (confirm('确定要删除本章吗?此操作无法撤销。')) { story.splice(currentChapterIndex, 1); saveState(); const newIndex = Math.max(-1, currentChapterIndex - 1); renderChapter(newIndex); toggleModal('editChapter', false); }});
229
  elements.connectApiBtn.addEventListener('click', connectToApi);
230
  elements.apiTypeSelect.addEventListener('change', handleApiTypeChange);
 
231
  [elements.contextMemorySlider, elements.temperatureSlider, elements.modelSelect].forEach(el => el.addEventListener('input', () => { saveState(); updateUI(); }));
232
- 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(); });
233
  elements.presetTabsContainer.addEventListener('click', (e) => { const tab = e.target.closest('button')?.dataset.tab; if (tab) { activePresetTab = tab; renderPresetTabs(); renderPresetList(); } });
234
  elements.addPresetBtn.addEventListener('click', () => showPresetEditor());
235
  elements.presetListContainer.addEventListener('click', (e) => { const target = e.target.closest('[data-action]'); if (!target) return; const { action, id } = target.dataset; 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); expandedPresets.delete(id); savePresets(); renderPresetList(); } } else if (action === 'toggle-select') { const list = selectedPresets[activePresetTab]; const index = list.indexOf(id); if (index > -1) list.splice(index, 1); else list.push(id); savePresets(); renderPresetList(); } else if (action === 'toggle-expand') { if (expandedPresets.has(id)) expandedPresets.delete(id); else expandedPresets.add(id); savePresets(); renderPresetList(); }});
@@ -253,16 +395,24 @@
253
  elements.importFileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target.result); if (data.state && data.presets) { if (confirm('导入将覆盖当前所有故事和设置,确定继续吗?')) { localStorage.setItem(LS_KEYS.STATE, data.state); localStorage.setItem(LS_KEYS.PRESETS, data.presets); alert('导入成功!页面将自动刷新。'); window.location.reload(); } } else { alert('文件格式无效!'); } } catch (err) { alert('导入失败,文件解析错误!'); } finally { elements.importFileInput.value = ''; }}; reader.readAsText(file); });
254
  elements.exportBtnMobile.addEventListener('click', createMobileButtonHandler(elements.exportBtn));
255
  elements.importBtnMobile.addEventListener('click', createMobileButtonHandler(elements.importBtn));
 
 
 
 
 
 
256
  }
257
 
258
  function initializeApp() {
259
- loadState(); loadPresets(); initializeEventListeners();
260
  const savedTheme = localStorage.getItem(LS_KEYS.THEME);
261
  const isDark = savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
262
  if (isDark) document.documentElement.classList.add('dark');
263
  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`; };
264
  updateThemeIcon(isDark);
265
  applyReadingSettings(); handleApiTypeChange(); renderApiKeyDisplay(); restoreApiConnectionUI(); renderPresetTabs(); renderPresetList();
 
 
266
  if (story.length === 0 || currentChapterIndex < 0) { showWelcomeScreen(); } else { renderChapter(currentChapterIndex); }
267
  }
268
 
 
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>
 
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-1">|</span>
61
+ <button id="import-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="导入文件"><i class="fa fa-folder-open-o"></i></button>
62
+ <button id="export-btn" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700" title="导出文件"><i class="fa fa-save"></i></button>
63
  <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>
64
  <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>
65
  <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>
 
73
  <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>
74
  <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>
75
  <div class="my-1 h-px bg-slate-200 dark:bg-slate-700"></div>
76
+ <a href="#" id="import-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-folder-open-o fa-fw mr-3"></i>导入文件</a>
77
+ <a href="#" id="export-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-save fa-fw mr-3"></i>导出文件</a>
78
  <div class="my-1 h-px bg-slate-200 dark:bg-slate-700"></div>
79
  <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>
80
  <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>
 
127
  </main>
128
 
129
  <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 space-y-5 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><div class="space-y-2"><p class="text-xs text-light-subtext">从本地上传字体文件:</p><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"><p class="text-xs text-light-subtext pt-2">或从URL加载字体:</p><div class="flex space-x-2"><input type="url" id="font-url-input" class="input-base" placeholder="https://.../font.woff2"><button id="apply-font-url-btn" class="btn-secondary flex-shrink-0">应用</button></div></div></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-highlight-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 id="dialogue-options" class="hidden pl-7 space-y-3 pt-2"><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></div>
130
+ <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 space-y-4"><div id="image-preview-container" class="hidden relative group"><img id="image-preview" class="rounded-lg max-h-48 w-auto mx-auto"><button id="remove-image-btn" class="absolute top-2 right-2 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"><i class="fa fa-times"></i></button></div><label id="image-upload-label" class="cursor-pointer w-full flex flex-col items-center justify-center p-4 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"><i class="fa fa-image text-2xl text-slate-400 mb-2"></i><span class="text-sm text-light-subtext">上传图片 (可选)</span><input type="file" id="image-upload-input" accept="image/*" class="hidden"></label><div id="vision-model-warning" class="hidden text-xs text-amber-600 dark:text-amber-500 text-center p-2 bg-amber-50 dark:bg-amber-900/50 rounded-md"><i class="fa fa-warning mr-1"></i>当前模型可能不支持图片。请在设置中选择带 "vision" 标识的模型。</div><textarea id="custom-prompt-input" class="input-base h-28" placeholder="请输入你希望接下来发生的情节..."></textarea><div class="flex justify-end"><button id="submit-custom-prompt-btn" class="btn-primary">生成</button></div></div></div></div>
131
  <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 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>
132
+ <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 space-y-5 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 id="openai-url-container">
133
+ <label for="api-url-input-openai" class="block text-sm font-medium mb-1">API Base URL (OpenAI)</label><input type="url" id="api-url-input-openai" class="input-base" placeholder="例如: https://api.openai.com/v1">
134
+ </div>
135
+ <div id="gemini-url-container" class="hidden">
136
+ <label for="api-url-input-gemini" class="block text-sm font-medium mb-1">API Base URL (Gemini)</label><input type="url" id="api-url-input-gemini" class="input-base bg-slate-100 dark:bg-slate-800 cursor-not-allowed" value="https://generativelanguage.googleapis.com" readonly>
137
+ </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-5 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 class="space-y-4 pt-5 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-github mr-2"></i>GitHub 同步</h2><div class="grid grid-cols-1 sm:grid-cols-2 gap-4"><div><label for="github-user-input" class="block text-sm font-medium mb-1">用户名</label><input id="github-user-input" type="text" class="input-base" placeholder="YourGitHubUser"></div><div><label for="github-repo-input" class="block text-sm font-medium mb-1">仓库名</label><input id="github-repo-input" type="text" class="input-base" placeholder="my-ainovel-backup"></div></div><div><label for="github-pat-input" class="block text-sm font-medium mb-1">Personal Access Token</label><input id="github-pat-input" type="password" class="input-base font-mono" placeholder="ghp_..."><p class="mt-1 text-xs text-amber-600 dark:text-amber-500"><i class="fa fa-warning mr-1"></i>Token将保存在浏览器本地。请确保它仅有您指定私有仓库的 <code class="bg-slate-200 dark:bg-slate-700 px-1 rounded">repo</code> 权限。</p></div><div class="flex items-center space-x-4 pt-2"><label class="flex items-center space-x-2 cursor-pointer"><input type="checkbox" id="github-autosync-toggle" class="h-4 w-4 rounded border-slate-300 text-primary focus:ring-primary"><span class="text-sm">开启自动同步</span></label><select id="github-autosync-interval" class="input-base w-auto hidden"><option value="300000">每 5 分钟</option><option value="900000">每 15 分钟</option><option value="1800000">每 30 分钟</option><option value="3600000">每小时</option></select></div><div class="flex space-x-2"><button id="github-pull-btn" class="btn-secondary w-full flex items-center justify-center"><i class="fa fa-cloud-download mr-2"></i>下载同步</button><button id="github-push-btn" class="btn-primary w-full flex items-center justify-center"><i class="fa fa-cloud-upload mr-2"></i>上传同步</button></div><div id="github-status" class="hidden text-sm py-2 px-3 rounded-md text-center"></div></div></div></div></div>
138
  <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-4 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 -mr-2"><i class="fa fa-times text-light-subtext"></i></button></div><div class="p-5 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-0 border border-slate-200 dark:border-slate-700 rounded-lg p-1"></div><button id="add-preset-btn" title="新增预设" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 md:btn-secondary flex items-center"><i class="fa fa-plus md:mr-2"></i><span class="hidden md:inline">新增</span></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-4 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>
139
  <input type="file" id="import-file-input" class="hidden" accept=".json">
140
 
 
142
  <script>
143
  document.addEventListener('DOMContentLoaded', () => {
144
 
145
+ const LS_KEYS = { STATE: 'aiNovelState_v3.3', PRESETS: 'aiNovelPresets_v3', THEME: 'aiNovelTheme_v3', GITHUB: 'aiNovelGithub_v3' };
146
  let story = [], currentChapterIndex = -1, chapterPages = [], currentPageIndex = 0;
147
+ let apiConfig = {
148
+ type: 'openai',
149
+ urls: { openai: 'https://api.openai.com/v1', gemini: 'https://generativelanguage.googleapis.com' },
150
+ keys: { openai: [], gemini: [] },
151
+ currentKeyIndices: { openai: 0, gemini: 0 },
152
+ isConnected: false,
153
+ models: []
154
+ };
155
+ let githubConfig = { user: '', repo: '', pat: '', sha: null, autoSyncEnabled: false, autoSyncInterval: 900000 };
156
  let presetData = {}, selectedPresets = {}, activePresetTab = 'character', expandedPresets = new Set();
157
+ let autoSyncTimer = null, isDataDirty = false, pendingImageBase64 = null;
158
  const PRESET_CATEGORIES = {
159
  character: { name: '角色', icon: 'fa-user-circle' }, player: { name: '玩家', icon: 'fa-gamepad' }, world: { name: '世界观', icon: 'fa-globe' },
160
  style: { name: '文风', icon: 'fa-paint-brush' }, script: { name: '剧本', icon: 'fa-file-code-o' }, format: { name: '格式', icon: 'fa-list-alt' }
 
178
  fontSizeSlider: $('#font-size-slider'), fontSizeValue: $('#font-size-value'), lineHeightSlider: $('#line-height-slider'), lineHeightValue: $('#line-height-value'),
179
  fontUploadInput: $('#font-upload-input'), fontUrlInput: $('#font-url-input'), applyFontUrlBtn: $('#apply-font-url-btn'),
180
  dialogueHighlightToggle: $('#dialogue-highlight-toggle'), dialogueOptions: $('#dialogue-options'), dialogueBoldToggle: $('#dialogue-bold-toggle'), dialogueColorPicker: $('#dialogue-color-picker'), resetDialogueColorBtn: $('#reset-dialogue-color-btn'),
181
+ customPromptInput: $('#custom-prompt-input'), submitCustomPromptBtn: $('#submit-custom-prompt-btn'), imageUploadInput: $('#image-upload-input'), imageUploadLabel: $('#image-upload-label'), imagePreviewContainer: $('#image-preview-container'), imagePreview: $('#image-preview'), removeImageBtn: $('#remove-image-btn'), visionModelWarning: $('#vision-model-warning'),
182
+ editChapterTitleInput: $('#edit-chapter-title-input'), editChapterContentInput: $('#edit-chapter-content-input'), saveEditBtn: $('#save-edit-btn'), deleteChapterBtn: $('#delete-chapter-btn'),
183
+ apiTypeSelect: $('#api-type-select'),
184
+ apiUrlInputOpenAI: $('#api-url-input-openai'),
185
+ openaiUrlContainer: $('#openai-url-container'), geminiUrlContainer: $('#gemini-url-container'),
186
+ apiKeyInput: $('#api-key-input'), apiKeyDisplay: $('#api-key-display'),
187
  maskedKeysList: $('#masked-keys-list'), editApiKeysBtn: $('#edit-api-keys-btn'), connectApiBtn: $('#connect-api-btn'), apiStatus: $('#api-status'),
188
  modelSelect: $('#model-select'), contextMemorySlider: $('#context-memory-slider'), contextMemoryValue: $('#context-memory-value'),
189
  temperatureSlider: $('#temperature-slider'), temperatureValue: $('#temperature-value'), presetTabsContainer: $('#preset-tabs-container'),
190
  addPresetBtn: $('#add-preset-btn'), presetListContainer: $('#preset-list-container'), generateFromPresetsBtn: $('#generate-from-presets-btn'),
191
  importBtn: $('#import-btn'), exportBtn: $('#export-btn'), importBtnMobile: $('#import-btn-mobile'), exportBtnMobile: $('#export-btn-mobile'), importFileInput: $('#import-file-input'),
192
+ githubUserInput: $('#github-user-input'), githubRepoInput: $('#github-repo-input'), githubPatInput: $('#github-pat-input'), githubPushBtn: $('#github-push-btn'), githubPullBtn: $('#github-pull-btn'), githubStatus: $('#github-status'),
193
+ githubAutoSyncToggle: $('#github-autosync-toggle'), githubAutoSyncInterval: $('#github-autosync-interval'),
194
  };
195
 
196
+ const saveState = () => { isDataDirty = true; const stateToSave = { story, currentChapterIndex, apiConfig: { ...apiConfig, keys: { openai: apiConfig.keys.openai.map(k => btoa(k)), gemini: apiConfig.keys.gemini.map(k => btoa(k)) } }, modelSettings: { model: elements.modelSelect.value, contextMemory: elements.contextMemorySlider.value, temperature: elements.temperatureSlider.value }, readingSettings: { fontSize: elements.fontSizeSlider.value, lineHeight: elements.lineHeightSlider.value, dialogueHighlight: elements.dialogueHighlightToggle.checked, dialogueBold: elements.dialogueBoldToggle.checked, dialogueColor: elements.dialogueColorPicker.value, fontUrl: elements.fontUrlInput.value }}; localStorage.setItem(LS_KEYS.STATE, JSON.stringify(stateToSave));};
197
+ 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) { const defaultConfig = { type: 'openai', urls: { openai: 'https://api.openai.com/v1', gemini: 'https://generativelanguage.googleapis.com' }, keys: { openai: [], gemini: [] }, currentKeyIndices: { openai: 0, gemini: 0 }, isConnected: false, models: [] }; apiConfig = { ...defaultConfig, ...state.apiConfig }; apiConfig.urls.openai = (state.apiConfig?.urls?.openai || 'https://api.openai.com/v1').replace(/\/chat\/completions\/?$/, ''); const savedKeys = state.apiConfig.keys || {}; apiConfig.keys = { openai: (savedKeys.openai || []).map(k => { try { return atob(k); } catch { return ''; } }).filter(Boolean), gemini: (savedKeys.gemini || []).map(k => { try { return atob(k); } catch { return ''; } }).filter(Boolean) }; elements.apiTypeSelect.value = apiConfig.type || 'openai'; elements.apiUrlInputOpenAI.value = apiConfig.urls.openai; } 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.dialogueHighlightToggle.checked = state.readingSettings.dialogueHighlight || false; elements.dialogueBoldToggle.checked = state.readingSettings.dialogueBold || false; elements.dialogueColorPicker.value = state.readingSettings.dialogueColor || (document.documentElement.classList.contains('dark') ? '#cbd5e1' : '#334155'); if(state.readingSettings.fontUrl) { elements.fontUrlInput.value = state.readingSettings.fontUrl; applyFontFromUrl(state.readingSettings.fontUrl); } }} catch (e) { console.error("Failed to load state:", e); localStorage.removeItem(LS_KEYS.STATE); }};
198
+ const savePresets = () => { isDataDirty = true; localStorage.setItem(LS_KEYS.PRESETS, JSON.stringify({ data: presetData, selected: selectedPresets, expanded: Array.from(expandedPresets) }));};
199
  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; if (p.expanded) expandedPresets = new Set(p.expanded) } catch (e) { console.error("Failed to load presets:", e); } }};
200
+ const saveGithubConfig = () => { githubConfig.user = elements.githubUserInput.value; githubConfig.repo = elements.githubRepoInput.value; githubConfig.pat = elements.githubPatInput.value; githubConfig.autoSyncEnabled = elements.githubAutoSyncToggle.checked; githubConfig.autoSyncInterval = parseInt(elements.githubAutoSyncInterval.value, 10); localStorage.setItem(LS_KEYS.GITHUB, JSON.stringify({ ...githubConfig, pat: btoa(githubConfig.pat) }));};
201
+ const loadGithubConfig = () => { const raw = localStorage.getItem(LS_KEYS.GITHUB); if (raw) { try { const saved = JSON.parse(raw); githubConfig = { ...githubConfig, ...saved, pat: atob(saved.pat || '') }; elements.githubUserInput.value = githubConfig.user; elements.githubRepoInput.value = githubConfig.repo; elements.githubPatInput.value = githubConfig.pat; elements.githubAutoSyncToggle.checked = githubConfig.autoSyncEnabled; elements.githubAutoSyncInterval.value = githubConfig.autoSyncInterval; } catch(e) { console.error("Failed to load GitHub config", e); } }};
202
+ const getNextApiKey = () => { const type = apiConfig.type; const keysForType = apiConfig.keys[type]; if (!apiConfig.isConnected || !keysForType || !keysForType.length) return null; let keyIndex = apiConfig.currentKeyIndices[type]; const key = keysForType[keyIndex]; apiConfig.currentKeyIndices[type] = (keyIndex + 1) % keysForType.length; saveState(); return key; };
203
+ 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} ${response.statusText}`); } return response.json(); };
204
+
205
+ const connectToApi = async () => {
206
+ const type = apiConfig.type;
207
+ const url = (apiConfig.urls[type] || '').trim().replace(/\/$/, '');
208
+
209
+ // Determine which keys to use for the connection attempt.
210
+ // 1. Prioritize new keys from the visible input box (for initial entry or edits).
211
+ // 2. Fall back to keys already saved in the state for the current API type.
212
+ let keysToTest = [];
213
+ const keysInputFromUI = elements.apiKeyInput.value.trim();
214
+ if (!elements.apiKeyInput.classList.contains('hidden') && keysInputFromUI) {
215
+ keysToTest = keysInputFromUI.split(/[\s,]+/).filter(Boolean);
216
+ } else {
217
+ keysToTest = apiConfig.keys[type] || [];
218
+ }
219
+
220
+ if (!url || keysToTest.length === 0) {
221
+ return updateApiStatus('Base URL 和 API Key 不能为空。', 'error');
222
+ }
223
+
224
+ updateApiStatus('正在连接...', 'loading');
225
+ try {
226
+ const testKey = keysToTest[0];
227
+ let modelsUrl, headers;
228
+
229
+ if (type === 'openai') {
230
+ modelsUrl = `${url}/models`;
231
+ headers = { 'Authorization': `Bearer ${testKey}` };
232
+ } else { // 'gemini'
233
+ modelsUrl = `${url}/v1beta/models?key=${testKey}`;
234
+ headers = {};
235
+ }
236
+
237
+ const data = await fetchFromApi(modelsUrl, { headers });
238
+ const apiModels = (type === 'openai' ? data.data : data.models) || [];
239
+ const modelList = [];
240
+ elements.modelSelect.innerHTML = '';
241
+ apiModels.forEach(model => {
242
+ const modelId = model.id || model.name;
243
+ if (!modelId) return;
244
+ const finalId = modelId.startsWith('models/') ? modelId.substring(7) : modelId;
245
+ modelList.push(finalId);
246
+ elements.modelSelect.add(new Option(finalId, finalId));
247
+ });
248
+
249
+ if (modelList.length > 0) {
250
+ elements.modelSelect.disabled = false;
251
+ const lastModel = localStorage.getItem('aiNovelLastModel');
252
+ if (lastModel && modelList.includes(lastModel)) {
253
+ elements.modelSelect.value = lastModel;
254
+ } else {
255
+ elements.modelSelect.selectedIndex = 0;
256
+ }
257
+
258
+ // On successful connection, update the state with the validated keys.
259
+ apiConfig.keys[type] = keysToTest;
260
+ apiConfig.currentKeyIndices[type] = 0;
261
+ apiConfig.isConnected = true;
262
+ apiConfig.models = modelList;
263
+
264
+ const successMessage = `连接成功!发现 ${modelList.length} 个模型。${keysToTest.length > 1 ? `已配置 ${keysToTest.length} 个Key进行轮换。` : ''}`;
265
+ updateApiStatus(successMessage, 'success');
266
+ renderApiKeyDisplay(); // This will now show the masked keys and hide the input.
267
+ saveState();
268
+ } else {
269
+ throw new Error("未找到兼容的模型。");
270
+ }
271
+ } catch (error) {
272
+ apiConfig.isConnected = false;
273
+ apiConfig.models = [];
274
+ saveState(); // Persist the failed connection state
275
+ elements.modelSelect.disabled = true;
276
+ elements.modelSelect.innerHTML = '<option value="">连接失败</option>';
277
+ updateApiStatus(`连接失败: ${error.message}`, 'error');
278
+ }
279
+ };
280
+
281
+ const generateChapter = async (userPrompt, isRegeneration = false, imageBase64 = null) => { 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, imageBase64); let endpoint, payload, headers; const baseUrl = (apiConfig.urls[apiConfig.type] || '').trim().replace(/\/$/, ''); if (apiConfig.type === 'openai') { endpoint = `${baseUrl}/chat/completions`; payload = { model, temperature, messages: promptData.messages, max_tokens: 8192 }; headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; } else { endpoint = `${baseUrl}/v1beta/models/${model}:generateContent?key=${apiKey}`; payload = { generationConfig: { temperature, maxOutputTokens: 8192 }, contents: promptData.geminiContents }; 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; story[currentChapterIndex].promptImage = imageBase64; } else { const newIndex = story.length; story.push({ title: `第 ${newIndex + 1} 章`, content: newContent, customPrompt: userPrompt, promptImage: imageBase64 }); currentChapterIndex = newIndex; } renderChapter(currentChapterIndex); } catch (error) { console.error('生成失败:', error); alert(`生成失败: ${error.message}`); toggleLoading(false); }};
282
+
283
+ const buildFullPrompt = (userPrompt, contextMemory, isRegeneration, imageBase64) => {
284
+ const presetsPrompt = generatePromptFromSelectedPresets();
285
+ const formatPreset = presetData.format?.find(p => selectedPresets.format?.includes(p.id));
286
+ const formatInstructions = formatPreset ? formatPreset.desc : "Use Markdown format. Chapter title as H1.";
287
+ 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}`;
288
+
289
+ const contextStory = isRegeneration ? story.slice(0, currentChapterIndex) : story;
290
+ const contextChapters = contextMemory > 0 ? contextStory.slice(-contextMemory) : [];
291
+
292
+ // --- OpenAI Messages ---
293
+ let messages = [{ role: 'system', content: systemContent }];
294
+ if (contextChapters.length > 0) {
295
+ const contextText = "Here is the context from previous chapters:\n\n" + contextChapters.map(c => `# ${c.title}\n${c.content}`).join('\n\n');
296
+ messages.push({ role: 'user', content: contextText });
297
+ messages.push({ role: 'assistant', content: 'Understood. I have the context. What should I write next?' });
298
+ }
299
+
300
+ let finalUserContent;
301
+ if (imageBase64 && apiConfig.type === 'openai') {
302
+ finalUserContent = [{ type: 'text', text: userPrompt }];
303
+ finalUserContent.push({ type: 'image_url', image_url: { url: imageBase64 } });
304
+ } else {
305
+ finalUserContent = userPrompt;
306
+ }
307
+ messages.push({ role: 'user', content: finalUserContent });
308
+
309
+ // --- Gemini Contents ---
310
+ let geminiUserParts = [{ text: systemContent }];
311
+ if (contextChapters.length > 0) {
312
+ geminiUserParts.push({ text: "\n\n### Previous Chapters (for context)\n" + contextChapters.map(c => `# ${c.title}\n${c.content}`).join('\n\n') });
313
+ }
314
+ geminiUserParts.push({ text: `\n\n### Current Task\n${userPrompt}` });
315
+ if (imageBase64) {
316
+ geminiUserParts.push({ inline_data: { mime_type: imageBase64.match(/data:(.*);/)[1], data: imageBase64.split(',')[1] } });
317
+ }
318
+ const geminiContents = [{ role: 'user', parts: geminiUserParts }];
319
+
320
+ return { messages, geminiContents };
321
+ };
322
+
323
+ const handleApiTypeChange = () => { const newType = elements.apiTypeSelect.value; apiConfig.type = newType; apiConfig.isConnected = false; apiConfig.models = []; elements.openaiUrlContainer.classList.toggle('hidden', newType !== 'openai'); elements.geminiUrlContainer.classList.toggle('hidden', newType !== 'gemini'); elements.modelSelect.innerHTML = '<option value="">请先连接API</option>'; elements.modelSelect.disabled = true; updateApiStatus('', ''); renderApiKeyDisplay(); saveState(); };
324
  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); if (elements.dialogueHighlightToggle.checked) { contentHtml = highlightDialogue(contentHtml); } 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; } elements.chapterTitleDisplay.textContent = chapter.title; chapterPages = paginateContent(contentHtml, 2500); currentPageIndex = 0; renderCurrentPage(); elements.readingArea.scrollTop = 0; updateUI(); saveState(); };
325
  const renderCurrentPage = () => { elements.contentArea.innerHTML = chapterPages[currentPageIndex] || ''; updatePaginationUI(); };
326
  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; };
327
  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'); }};
328
+ const updateApiStatus = (message, type) => { const s = elements.apiStatus; s.textContent = message; if (!message) { s.classList.add('hidden'); return; } s.classList.remove('hidden'); 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'); };
329
  const showWelcomeScreen = () => { elements.contentContainer.classList.add('hidden'); elements.welcomeMessage.classList.remove('hidden'); currentChapterIndex = -1; updateUI(); };
330
  const hideWelcomeScreen = () => { elements.contentContainer.classList.remove('hidden'); elements.welcomeMessage.classList.add('hidden'); };
331
  const toggleLoading = (isLoading) => elements.loadingState.classList.toggle('hidden', !isLoading);
332
  const maskApiKey = (key) => `${key.substring(0, 5)}...${key.substring(key.length - 4)}`;
333
+ const renderApiKeyDisplay = () => { const keysForType = apiConfig.keys[apiConfig.type] || []; const hasKeys = keysForType.length > 0; elements.apiKeyInput.classList.toggle('hidden', hasKeys); elements.apiKeyDisplay.classList.toggle('hidden', !hasKeys); if (hasKeys) { elements.maskedKeysList.innerHTML = keysForType.map((key, index) => `<li>Key ${index + 1}: ${maskApiKey(key)}</li>`).join(''); } };
334
+ 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; } else if (elements.modelSelect.options.length > 0) { elements.modelSelect.selectedIndex = 0; } elements.modelSelect.disabled = false; const keysForType = apiConfig.keys[apiConfig.type] || []; const successMessage = `已连接! 找到 ${apiConfig.models.length} 个模型。${keysForType.length > 1 ? `正在轮换 ${keysForType.length} 个Key。` : ''}`; updateApiStatus(successMessage, 'success'); } };
335
  const toggleModal = (modalName, show) => { if(elements.modals[modalName]) elements.modals[modalName].classList.toggle('hidden', !show); };
336
  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); elements.dialogueOptions.classList.toggle('hidden', !elements.dialogueHighlightToggle.checked); updateUI(); saveState(); renderChapter(currentChapterIndex); };
337
  const applyFontFromUrl = (url) => { if(!url) return; const fontName = 'CustomOnlineFont'; const styleId = 'custom-font-style'; $(`#${styleId}`)?.remove(); const style = document.createElement('style'); style.id = styleId; style.textContent = `@font-face { font-family: '${fontName}'; src: url('${url}'); }`; document.head.appendChild(style); document.documentElement.style.setProperty('--reading-font', `'${fontName}'`);};
 
339
  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; };
340
  const downloadFile = (filename, content, mimeType) => { const blob = new Blob([content], { type: mimeType }); 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); };
341
  const renderPresetTabs = () => { const isMobile = window.innerWidth < 768; elements.presetTabsContainer.innerHTML = Object.entries(PRESET_CATEGORIES).map(([key, {name, icon}]) => { const content = isMobile ? `<i class="fa ${icon} fa-fw"></i>` : name; return `<button data-tab="${key}" title="${name}" class="tab-button px-3 py-1.5 text-sm rounded-md transition-colors ${activePresetTab === key ? 'active' : ''}">${content}</button>`}).join('');};
342
+ const renderPresetList = () => { const list = presetData[activePresetTab] || []; const categoryName = PRESET_CATEGORIES[activePresetTab].name; elements.presetListContainer.innerHTML = list.length === 0 ? `<p class="text-center text-light-subtext py-4">暂无 ${categoryName} 预设</p>` : list.map(item => { const isExpanded = expandedPresets.has(item.id); return `<div class="bg-slate-100 dark:bg-slate-900/50 rounded-lg"><div class="p-2 flex items-center justify-between ${selectedPresets[activePresetTab]?.includes(item.id) ? 'bg-sky-100/50 dark:bg-sky-900/20' : ''}"><div class="flex items-center flex-grow min-w-0" data-action="toggle-select" data-id="${item.id}"><input type="checkbox" class="h-4 w-4 rounded border-slate-300 text-primary flex-shrink-0" ${selectedPresets[activePresetTab]?.includes(item.id) ? 'checked' : ''}><div class="ml-3 flex-grow cursor-pointer min-w-0" data-action="toggle-expand" data-id="${item.id}"><p class="font-semibold text-light-text dark:text-dark-text truncate">${item.name}</p><div class="grid ${isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'} transition-[grid-template-rows] duration-300 ease-in-out"><div class="overflow-hidden"><p class="text-xs text-light-subtext mt-1 pt-1 border-t border-slate-200 dark:border-slate-700">${item.desc.replace(/\n/g, '<br>')}</p></div></div></div></div><div class="flex items-center space-x-1 ml-2 flex-shrink-0"><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></div>`}).join(''); };
343
  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-4 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-4 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(); }; };
344
  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 || "无预设,请自由发挥。"; };
345
+ const updateGithubStatus = (message, type, autoClear = false) => { const s = elements.githubStatus; s.textContent = message; s.className = 'text-sm py-2 px-3 rounded-md text-center mt-2'; 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'); if(autoClear) { setTimeout(() => { if (s.textContent === message) s.classList.add('hidden'); }, 3000); }};
346
+ const githubSync = async (direction, isAuto = false) => { saveGithubConfig(); const { user, repo, pat } = githubConfig; if (!user || !repo || !pat) return updateGithubStatus('请填写完整的GitHub信息', 'error'); const url = `https://api.github.com/repos/${user}/${repo}/contents/ainovel.json`; const headers = { 'Authorization': `token ${pat}`, 'Accept': 'application/vnd.github.v3+json' }; updateGithubStatus(isAuto ? '自动同步中...' : '同步中...', 'loading'); try { if (direction === 'push') { const backupData = { state: localStorage.getItem(LS_KEYS.STATE), presets: localStorage.getItem(LS_KEYS.PRESETS) }; const content = btoa(unescape(encodeURIComponent(JSON.stringify(backupData)))); let sha; try { const response = await fetch(url, { headers }); if (response.ok) { const data = await response.json(); sha = data.sha; }} catch(e) {} const body = JSON.stringify({ message: `AINovel Backup ${new Date().toISOString()}`, content, sha }); const pushResponse = await fetch(url, { method: 'PUT', headers, body }); if (!pushResponse.ok) throw new Error(`GitHub API Error: ${pushResponse.statusText}`); isDataDirty = false; updateGithubStatus(isAuto ? '自动同步成功' : '上传成功!', 'success', isAuto); } else { if (!isAuto && !confirm('从GitHub下载将覆盖本地所有数据,确定吗?')) { updateGithubStatus('', ''); return; } const response = await fetch(url, { headers }); if (!response.ok) throw new Error('文件未找到或无权访问'); const data = await response.json(); const decodedContent = decodeURIComponent(escape(atob(data.content))); const backupData = JSON.parse(decodedContent); if (backupData.state && backupData.presets) { localStorage.setItem(LS_KEYS.STATE, data.state); localStorage.setItem(LS_KEYS.PRESETS, data.presets); updateGithubStatus('下载成功,即将刷新...', 'success'); setTimeout(() => window.location.reload(), 1000); } else { throw new Error('备份文件格式无效'); } } } catch (e) { updateGithubStatus(`同步失败: ${e.message}`, 'error'); }};
347
+ const stopAutoSyncTimer = () => { if (autoSyncTimer) clearInterval(autoSyncTimer); autoSyncTimer = null; };
348
+ const startAutoSyncTimer = () => { stopAutoSyncTimer(); if (githubConfig.autoSyncEnabled) { autoSyncTimer = setInterval(async () => { if (isDataDirty) { await githubSync('push', true); } }, githubConfig.autoSyncInterval); }};
349
 
350
  function initializeEventListeners() {
351
  Object.entries(elements.openers).forEach(([name, opener]) => { if(opener) opener.addEventListener('click', () => toggleModal(name, true)); });
352
  $$('.close-modal-btn').forEach(btn => btn.addEventListener('click', (e) => e.target.closest('.fixed').classList.add('hidden')));
353
+ const mainActionHandler = (action) => () => { if (action === 'continue') generateChapter("接续上一章的情节,创作新的一章。"); else if (action === 'regenerate') { if (currentChapterIndex < 0) return; const chapter = story[currentChapterIndex]; const prompt = chapter.customPrompt || "用不同的视角或风格重写这一章。"; const image = chapter.promptImage || null; generateChapter(prompt, true, image); }};
354
  elements.continueStoryBtn.addEventListener('click', mainActionHandler('continue'));
355
  elements.regenerateChapterBtn.addEventListener('click', mainActionHandler('regenerate'));
356
  elements.prevChapterBtn.addEventListener('click', () => renderChapter(currentChapterIndex - 1));
 
363
  elements.applyFontUrlBtn.addEventListener('click', () => { const url = elements.fontUrlInput.value.trim(); if (url) { applyFontFromUrl(url); saveState();} });
364
  elements.exportChapterBtn.addEventListener('click', () => { if(currentChapterIndex < 0) return; downloadFile(`${story[currentChapterIndex].title}.txt`, story[currentChapterIndex].content, 'text/plain;charset=utf-8'); });
365
  elements.exportAllBtn.addEventListener('click', () => { if(!story.length) return; downloadFile('Full Story.txt', story.map(c => `# ${c.title}\n\n${c.content}`).join('\n\n---\n\n'), 'text/plain;charset=utf-8'); });
366
+ elements.submitCustomPromptBtn.addEventListener('click', () => { const prompt = elements.customPromptInput.value.trim(); if (prompt || pendingImageBase64) { generateChapter(prompt, false, pendingImageBase64); toggleModal('customPrompt', false); elements.customPromptInput.value = ''; elements.removeImageBtn.click(); }});
367
  elements.openers.editChapter.addEventListener('click', () => { if(currentChapterIndex < 0) return; const chapter = story[currentChapterIndex]; elements.editChapterTitleInput.value = chapter.title; elements.editChapterContentInput.value = chapter.content; });
368
  elements.saveEditBtn.addEventListener('click', () => { story[currentChapterIndex].title = elements.editChapterTitleInput.value.trim(); story[currentChapterIndex].content = elements.editChapterContentInput.value.trim(); renderChapter(currentChapterIndex); toggleModal('editChapter', false); });
369
  elements.deleteChapterBtn.addEventListener('click', () => { if (confirm('确定要删除本章吗?此操作无法撤销。')) { story.splice(currentChapterIndex, 1); saveState(); const newIndex = Math.max(-1, currentChapterIndex - 1); renderChapter(newIndex); toggleModal('editChapter', false); }});
370
  elements.connectApiBtn.addEventListener('click', connectToApi);
371
  elements.apiTypeSelect.addEventListener('change', handleApiTypeChange);
372
+ elements.apiUrlInputOpenAI.addEventListener('input', (e) => { apiConfig.urls.openai = e.target.value; saveState(); });
373
  [elements.contextMemorySlider, elements.temperatureSlider, elements.modelSelect].forEach(el => el.addEventListener('input', () => { saveState(); updateUI(); }));
374
+ elements.editApiKeysBtn.addEventListener('click', () => { const type = apiConfig.type; apiConfig.keys[type] = []; apiConfig.isConnected = false; elements.apiKeyInput.value = ''; renderApiKeyDisplay(); updateApiStatus('请重新输入API Key并连接。', 'loading'); elements.modelSelect.disabled = true; elements.modelSelect.innerHTML = '<option value="">请先连接API</option>'; saveState(); });
375
  elements.presetTabsContainer.addEventListener('click', (e) => { const tab = e.target.closest('button')?.dataset.tab; if (tab) { activePresetTab = tab; renderPresetTabs(); renderPresetList(); } });
376
  elements.addPresetBtn.addEventListener('click', () => showPresetEditor());
377
  elements.presetListContainer.addEventListener('click', (e) => { const target = e.target.closest('[data-action]'); if (!target) return; const { action, id } = target.dataset; 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); expandedPresets.delete(id); savePresets(); renderPresetList(); } } else if (action === 'toggle-select') { const list = selectedPresets[activePresetTab]; const index = list.indexOf(id); if (index > -1) list.splice(index, 1); else list.push(id); savePresets(); renderPresetList(); } else if (action === 'toggle-expand') { if (expandedPresets.has(id)) expandedPresets.delete(id); else expandedPresets.add(id); savePresets(); renderPresetList(); }});
 
395
  elements.importFileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target.result); if (data.state && data.presets) { if (confirm('导入将覆盖当前所有故事和设置,确定继续吗?')) { localStorage.setItem(LS_KEYS.STATE, data.state); localStorage.setItem(LS_KEYS.PRESETS, data.presets); alert('导入成功!页面将自动刷新。'); window.location.reload(); } } else { alert('文件格式无效!'); } } catch (err) { alert('导入失败,文件解析错误!'); } finally { elements.importFileInput.value = ''; }}; reader.readAsText(file); });
396
  elements.exportBtnMobile.addEventListener('click', createMobileButtonHandler(elements.exportBtn));
397
  elements.importBtnMobile.addEventListener('click', createMobileButtonHandler(elements.importBtn));
398
+ elements.githubPushBtn.addEventListener('click', () => githubSync('push'));
399
+ elements.githubPullBtn.addEventListener('click', () => githubSync('pull'));
400
+ elements.githubAutoSyncToggle.addEventListener('change', () => { elements.githubAutoSyncInterval.classList.toggle('hidden', !elements.githubAutoSyncToggle.checked); saveGithubConfig(); startAutoSyncTimer(); });
401
+ elements.githubAutoSyncInterval.addEventListener('change', () => { saveGithubConfig(); startAutoSyncTimer(); });
402
+ elements.imageUploadInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { pendingImageBase64 = event.target.result; elements.imagePreview.src = pendingImageBase64; elements.imagePreviewContainer.classList.remove('hidden'); elements.imageUploadLabel.classList.add('hidden'); const currentModel = elements.modelSelect.value; if (currentModel && !currentModel.toLowerCase().includes('vision')) { elements.visionModelWarning.classList.remove('hidden'); }}; reader.readAsDataURL(file); });
403
+ elements.removeImageBtn.addEventListener('click', () => { pendingImageBase64 = null; elements.imagePreview.src = ''; elements.imagePreviewContainer.classList.add('hidden'); elements.imageUploadLabel.classList.remove('hidden'); elements.visionModelWarning.classList.add('hidden'); elements.imageUploadInput.value = ''; });
404
  }
405
 
406
  function initializeApp() {
407
+ loadState(); loadPresets(); loadGithubConfig(); initializeEventListeners();
408
  const savedTheme = localStorage.getItem(LS_KEYS.THEME);
409
  const isDark = savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
410
  if (isDark) document.documentElement.classList.add('dark');
411
  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`; };
412
  updateThemeIcon(isDark);
413
  applyReadingSettings(); handleApiTypeChange(); renderApiKeyDisplay(); restoreApiConnectionUI(); renderPresetTabs(); renderPresetList();
414
+ elements.githubAutoSyncInterval.classList.toggle('hidden', !elements.githubAutoSyncToggle.checked);
415
+ startAutoSyncTimer();
416
  if (story.length === 0 || currentChapterIndex < 0) { showWelcomeScreen(); } else { renderChapter(currentChapterIndex); }
417
  }
418