Spaces:
Running
Running
Update book.html
Browse files
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>爱小说
|
| 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>
|
|
@@ -134,7 +134,7 @@
|
|
| 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="
|
| 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 |
|
|
@@ -186,17 +186,19 @@
|
|
| 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'),
|
|
|
|
|
|
|
| 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; };
|
|
@@ -206,9 +208,6 @@
|
|
| 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) {
|
|
@@ -255,7 +254,6 @@
|
|
| 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;
|
|
@@ -263,7 +261,7 @@
|
|
| 263 |
|
| 264 |
const successMessage = `连接成功!发现 ${modelList.length} 个模型。${keysToTest.length > 1 ? `已配置 ${keysToTest.length} 个Key进行轮换。` : ''}`;
|
| 265 |
updateApiStatus(successMessage, 'success');
|
| 266 |
-
renderApiKeyDisplay();
|
| 267 |
saveState();
|
| 268 |
} else {
|
| 269 |
throw new Error("未找到兼容的模型。");
|
|
@@ -271,14 +269,14 @@
|
|
| 271 |
} catch (error) {
|
| 272 |
apiConfig.isConnected = false;
|
| 273 |
apiConfig.models = [];
|
| 274 |
-
saveState();
|
| 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:
|
| 282 |
|
| 283 |
const buildFullPrompt = (userPrompt, contextMemory, isRegeneration, imageBase64) => {
|
| 284 |
const presetsPrompt = generatePromptFromSelectedPresets();
|
|
@@ -289,7 +287,6 @@
|
|
| 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');
|
|
@@ -306,7 +303,6 @@
|
|
| 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') });
|
|
@@ -323,7 +319,7 @@
|
|
| 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(); };
|
|
@@ -342,8 +338,71 @@
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -370,7 +429,7 @@
|
|
| 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());
|
|
|
|
| 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>
|
|
|
|
| 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="50" value="3" class="w-full"> <!-- MODIFIED --></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><label for="max-tokens-slider" class="block text-sm font-medium mb-1">最大输出长度: <span id="max-tokens-value">4096</span></label><input type="range" id="max-tokens-slider" min="512" max="16384" step="256" value="4096" class="w-full"> <!-- MODIFIED --></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 |
|
|
|
|
| 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'),
|
| 190 |
+
maxTokensSlider: $('#max-tokens-slider'), maxTokensValue: $('#max-tokens-value'),
|
| 191 |
+
presetTabsContainer: $('#preset-tabs-container'),
|
| 192 |
addPresetBtn: $('#add-preset-btn'), presetListContainer: $('#preset-list-container'), generateFromPresetsBtn: $('#generate-from-presets-btn'),
|
| 193 |
importBtn: $('#import-btn'), exportBtn: $('#export-btn'), importBtnMobile: $('#import-btn-mobile'), exportBtnMobile: $('#export-btn-mobile'), importFileInput: $('#import-file-input'),
|
| 194 |
githubUserInput: $('#github-user-input'), githubRepoInput: $('#github-repo-input'), githubPatInput: $('#github-pat-input'), githubPushBtn: $('#github-push-btn'), githubPullBtn: $('#github-pull-btn'), githubStatus: $('#github-status'),
|
| 195 |
githubAutoSyncToggle: $('#github-autosync-toggle'), githubAutoSyncInterval: $('#github-autosync-interval'),
|
| 196 |
};
|
| 197 |
|
| 198 |
+
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, maxTokens: elements.maxTokensSlider.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));};
|
| 199 |
+
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; elements.maxTokensSlider.value = state.modelSettings.maxTokens || 4096; } 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); }};
|
| 200 |
const savePresets = () => { isDataDirty = true; localStorage.setItem(LS_KEYS.PRESETS, JSON.stringify({ data: presetData, selected: selectedPresets, expanded: Array.from(expandedPresets) }));};
|
| 201 |
+
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); } } };
|
| 202 |
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) }));};
|
| 203 |
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); } }};
|
| 204 |
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; };
|
|
|
|
| 208 |
const type = apiConfig.type;
|
| 209 |
const url = (apiConfig.urls[type] || '').trim().replace(/\/$/, '');
|
| 210 |
|
|
|
|
|
|
|
|
|
|
| 211 |
let keysToTest = [];
|
| 212 |
const keysInputFromUI = elements.apiKeyInput.value.trim();
|
| 213 |
if (!elements.apiKeyInput.classList.contains('hidden') && keysInputFromUI) {
|
|
|
|
| 254 |
elements.modelSelect.selectedIndex = 0;
|
| 255 |
}
|
| 256 |
|
|
|
|
| 257 |
apiConfig.keys[type] = keysToTest;
|
| 258 |
apiConfig.currentKeyIndices[type] = 0;
|
| 259 |
apiConfig.isConnected = true;
|
|
|
|
| 261 |
|
| 262 |
const successMessage = `连接成功!发现 ${modelList.length} 个模型。${keysToTest.length > 1 ? `已配置 ${keysToTest.length} 个Key进行轮换。` : ''}`;
|
| 263 |
updateApiStatus(successMessage, 'success');
|
| 264 |
+
renderApiKeyDisplay();
|
| 265 |
saveState();
|
| 266 |
} else {
|
| 267 |
throw new Error("未找到兼容的模型。");
|
|
|
|
| 269 |
} catch (error) {
|
| 270 |
apiConfig.isConnected = false;
|
| 271 |
apiConfig.models = [];
|
| 272 |
+
saveState();
|
| 273 |
elements.modelSelect.disabled = true;
|
| 274 |
elements.modelSelect.innerHTML = '<option value="">连接失败</option>';
|
| 275 |
updateApiStatus(`连接失败: ${error.message}`, 'error');
|
| 276 |
}
|
| 277 |
};
|
| 278 |
|
| 279 |
+
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, maxTokens = parseInt(elements.maxTokensSlider.value, 10); 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: maxTokens }; headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; } else { endpoint = `${baseUrl}/v1beta/models/${model}:generateContent?key=${apiKey}`; payload = { generationConfig: { temperature, maxOutputTokens: maxTokens }, 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); }};
|
| 280 |
|
| 281 |
const buildFullPrompt = (userPrompt, contextMemory, isRegeneration, imageBase64) => {
|
| 282 |
const presetsPrompt = generatePromptFromSelectedPresets();
|
|
|
|
| 287 |
const contextStory = isRegeneration ? story.slice(0, currentChapterIndex) : story;
|
| 288 |
const contextChapters = contextMemory > 0 ? contextStory.slice(-contextMemory) : [];
|
| 289 |
|
|
|
|
| 290 |
let messages = [{ role: 'system', content: systemContent }];
|
| 291 |
if (contextChapters.length > 0) {
|
| 292 |
const contextText = "Here is the context from previous chapters:\n\n" + contextChapters.map(c => `# ${c.title}\n${c.content}`).join('\n\n');
|
|
|
|
| 303 |
}
|
| 304 |
messages.push({ role: 'user', content: finalUserContent });
|
| 305 |
|
|
|
|
| 306 |
let geminiUserParts = [{ text: systemContent }];
|
| 307 |
if (contextChapters.length > 0) {
|
| 308 |
geminiUserParts.push({ text: "\n\n### Previous Chapters (for context)\n" + contextChapters.map(c => `# ${c.title}\n${c.content}`).join('\n\n') });
|
|
|
|
| 319 |
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(); };
|
| 320 |
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(); };
|
| 321 |
const renderCurrentPage = () => { elements.contentArea.innerHTML = chapterPages[currentPageIndex] || ''; updatePaginationUI(); };
|
| 322 |
+
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.maxTokensValue.textContent = elements.maxTokensSlider.value; elements.fontSizeValue.textContent = `${elements.fontSizeSlider.value}px`; elements.lineHeightValue.textContent = elements.lineHeightSlider.value; };
|
| 323 |
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'); }};
|
| 324 |
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'); };
|
| 325 |
const showWelcomeScreen = () => { elements.contentContainer.classList.add('hidden'); elements.welcomeMessage.classList.remove('hidden'); currentChapterIndex = -1; updateUI(); };
|
|
|
|
| 338 |
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(''); };
|
| 339 |
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(); }; };
|
| 340 |
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 || "无预设,请自由发挥。"; };
|
| 341 |
+
const updateGithubStatus = (message, type, autoClear = false) => { const s = elements.githubStatus; s.textContent = message; s.className = 'hidden text-sm py-2 px-3 rounded-md text-center mt-2'; if (message) { s.classList.remove('hidden'); } 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); }};
|
| 342 |
+
|
| 343 |
+
const githubSync = async (direction, isAuto = false) => {
|
| 344 |
+
saveGithubConfig();
|
| 345 |
+
const { user, repo, pat } = githubConfig;
|
| 346 |
+
if (!user || !repo || !pat) return updateGithubStatus('请填写完整的GitHub信息', 'error');
|
| 347 |
+
const url = `https://api.github.com/repos/${user}/${repo}/contents/ainovel.json`;
|
| 348 |
+
const headers = { 'Authorization': `token ${pat}`, 'Accept': 'application/vnd.github.v3+json' };
|
| 349 |
+
updateGithubStatus(isAuto ? '自动同步中...' : '同步中...', 'loading');
|
| 350 |
+
try {
|
| 351 |
+
if (direction === 'push') {
|
| 352 |
+
const backupData = {
|
| 353 |
+
state: localStorage.getItem(LS_KEYS.STATE),
|
| 354 |
+
presets: localStorage.getItem(LS_KEYS.PRESETS)
|
| 355 |
+
};
|
| 356 |
+
const content = btoa(unescape(encodeURIComponent(JSON.stringify(backupData))));
|
| 357 |
+
let sha;
|
| 358 |
+
try {
|
| 359 |
+
const response = await fetch(url, { headers });
|
| 360 |
+
if (response.ok) {
|
| 361 |
+
const data = await response.json();
|
| 362 |
+
sha = data.sha;
|
| 363 |
+
}
|
| 364 |
+
} catch (e) { /* Getting sha is optional, ignore if it fails */ }
|
| 365 |
+
const body = JSON.stringify({
|
| 366 |
+
message: `AINovel Backup ${new Date().toISOString()}`,
|
| 367 |
+
content,
|
| 368 |
+
sha
|
| 369 |
+
});
|
| 370 |
+
const pushResponse = await fetch(url, {
|
| 371 |
+
method: 'PUT',
|
| 372 |
+
headers,
|
| 373 |
+
body
|
| 374 |
+
});
|
| 375 |
+
if (!pushResponse.ok) {
|
| 376 |
+
const errorData = await pushResponse.json();
|
| 377 |
+
throw new Error(`GitHub API Error: ${errorData.message || pushResponse.statusText}`);
|
| 378 |
+
}
|
| 379 |
+
isDataDirty = false;
|
| 380 |
+
updateGithubStatus(isAuto ? '自动同步成功' : '上传成功!', 'success', true);
|
| 381 |
+
} else { // pull
|
| 382 |
+
if (!isAuto && !confirm('从GitHub下载将覆盖本地所有数据,确定吗?')) {
|
| 383 |
+
updateGithubStatus('', '');
|
| 384 |
+
return;
|
| 385 |
+
}
|
| 386 |
+
const response = await fetch(url, { headers });
|
| 387 |
+
if (!response.ok) throw new Error('文件未找到或无权访问');
|
| 388 |
+
const data = await response.json();
|
| 389 |
+
const decodedContent = decodeURIComponent(escape(atob(data.content)));
|
| 390 |
+
const backupData = JSON.parse(decodedContent);
|
| 391 |
+
|
| 392 |
+
if (backupData && typeof backupData.state !== 'undefined' && typeof backupData.presets !== 'undefined') {
|
| 393 |
+
localStorage.setItem(LS_KEYS.STATE, backupData.state);
|
| 394 |
+
localStorage.setItem(LS_KEYS.PRESETS, backupData.presets);
|
| 395 |
+
updateGithubStatus('下载成功,即将刷新...', 'success');
|
| 396 |
+
setTimeout(() => window.location.reload(), 1500);
|
| 397 |
+
} else {
|
| 398 |
+
throw new Error('备份文件格式无效或内容为空');
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
} catch (e) {
|
| 402 |
+
updateGithubStatus(`同步失败: ${e.message}`, 'error');
|
| 403 |
+
}
|
| 404 |
+
};
|
| 405 |
+
|
| 406 |
const stopAutoSyncTimer = () => { if (autoSyncTimer) clearInterval(autoSyncTimer); autoSyncTimer = null; };
|
| 407 |
const startAutoSyncTimer = () => { stopAutoSyncTimer(); if (githubConfig.autoSyncEnabled) { autoSyncTimer = setInterval(async () => { if (isDataDirty) { await githubSync('push', true); } }, githubConfig.autoSyncInterval); }};
|
| 408 |
|
|
|
|
| 429 |
elements.connectApiBtn.addEventListener('click', connectToApi);
|
| 430 |
elements.apiTypeSelect.addEventListener('change', handleApiTypeChange);
|
| 431 |
elements.apiUrlInputOpenAI.addEventListener('input', (e) => { apiConfig.urls.openai = e.target.value; saveState(); });
|
| 432 |
+
[elements.contextMemorySlider, elements.temperatureSlider, elements.maxTokensSlider, elements.modelSelect].forEach(el => el.addEventListener('input', () => { saveState(); updateUI(); }));
|
| 433 |
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(); });
|
| 434 |
elements.presetTabsContainer.addEventListener('click', (e) => { const tab = e.target.closest('button')?.dataset.tab; if (tab) { activePresetTab = tab; renderPresetTabs(); renderPresetList(); } });
|
| 435 |
elements.addPresetBtn.addEventListener('click', () => showPresetEditor());
|