| let lastSaveTimestamp = 0; |
| let controller; |
| let lastTokenUpdateTimestamp = 0; |
| let summeries = {}; |
| let lastIndexUpdateTimestamp = 0; |
|
|
| let replaceProofReadHistory = []; |
|
|
| |
| let imageCounter = 0; |
|
|
| function addImageInput() { |
| const container = document.getElementById('imageInputsContainer'); |
| const imageInputGroup = document.createElement('div'); |
| imageInputGroup.className = 'input-group mb-2'; |
| imageInputGroup.id = `imageGroup_${imageCounter}`; |
|
|
| const input = document.createElement('input'); |
| input.type = 'file'; |
| input.className = 'form-control'; |
| input.accept = 'image/*'; |
| input.id = `imageInput_${imageCounter}`; |
|
|
| const deleteButton = document.createElement('button'); |
| deleteButton.className = 'btn btn-outline-danger'; |
| deleteButton.innerHTML = '<i class="fas fa-trash"></i>'; |
| deleteButton.onclick = function() { |
| removeImageInput(this.parentElement); |
| }; |
|
|
| imageInputGroup.appendChild(input); |
| imageInputGroup.appendChild(deleteButton); |
| container.appendChild(imageInputGroup); |
|
|
| imageCounter++; |
| } |
|
|
| function removeImageInput(element) { |
| if (element) { |
| element.remove(); |
| } |
| } |
|
|
| function getAttachedImages() { |
| const images = []; |
| const container = document.getElementById('imageInputsContainer'); |
| const inputs = container.getElementsByTagName('input'); |
| |
| for (let input of inputs) { |
| if (input.files && input.files[0]) { |
| images.push(input.files[0]); |
| } |
| } |
| |
| return images; |
| } |
|
|
| function replaceProofRead(textarea, proofReadText) { |
| let novelContent1TextLines = document.getElementById("novelContent1").value.split("\n"); |
| let proofReadTextLines = proofReadText.split("\n"); |
| let textareaTextLines = textarea.value.split("\n"); |
| let start = novelContent1TextLines.indexOf(textareaTextLines[0]); |
| let end = novelContent1TextLines.indexOf(textareaTextLines[textareaTextLines.length - 1]); |
| console.log(start, end); |
|
|
| |
| let originalText = novelContent1TextLines.slice(start, end + 1); |
| replaceProofReadHistory.push([originalText, proofReadTextLines]); |
|
|
| |
| novelContent1TextLines.splice(start, end - start + 1, ...proofReadTextLines); |
|
|
| |
| document.getElementById("novelContent1").value = novelContent1TextLines.join("\n"); |
|
|
| |
| textarea.value = proofReadTextLines.join("\n"); |
|
|
| console.log("校正が完了しました。"); |
| } |
|
|
|
|
| async function getModelList() { |
| const url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' + document.getElementById('geminiApiKey').value; |
| const response = await fetch(url); |
| const data = await response.json(); |
| return data.models |
| .filter(x => x.supportedGenerationMethods.includes("generateContent")) |
| .filter(x => { |
| return x.name.match(/^models\/gemini/); |
| }); |
| } |
|
|
| function getSafetySettings(endpoint) { |
| const threshold = endpoint.startsWith('models/model-exp-') ? 'OFF' : 'BLOCK_NONE'; |
| return [ |
| { |
| "category": "HARM_CATEGORY_HATE_SPEECH", |
| "threshold": threshold |
| }, |
| { |
| "category": "HARM_CATEGORY_DANGEROUS_CONTENT", |
| "threshold": threshold |
| }, |
| { |
| "category": "HARM_CATEGORY_HARASSMENT", |
| "threshold": threshold |
| }, |
| { |
| "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", |
| "threshold": threshold |
| } |
| ]; |
| } |
|
|
| async function proofRead(textarea) { |
| let content = textarea.value; |
| const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${document.getElementById('geminiApiKey').value}`; |
| let prompt = `以下の文章を校正してください。文法がおかしい、誤字脱字、冗長な表現などを校正するのみに留め、内容は一切変更しないでください。\n\n${content}`; |
| const payload = { |
| method: 'POST', |
| headers: {}, |
| body: JSON.stringify({ |
| contents: [{ parts: [{ text: prompt }] }], |
| generationConfig: { |
| "temperature": parseFloat(document.getElementById('temperature').value), |
| "max_output_tokens": 8192 |
| }, |
| safetySettings: getSafetySettings('models/gemini-2.0-flash-exp') |
| }) |
| }; |
| let proofReadText; |
| const response = await fetch(ENDPOINT, payload); |
| try { |
| const data = await response.json(); |
| proofReadText = data.candidates[0].content.parts[0].text; |
| } catch (error) { |
| console.error('校正エラー:', error); |
| return ''; |
| } |
| if (proofReadText) { |
| return replaceProofRead(textarea, proofReadText); |
| } else { |
| return ''; |
| } |
| } |
|
|
|
|
| function formatText() { |
| const textOrg = document.getElementById('novelContent1').value; |
| let text = textOrg.replace(/[」。)]/g, '$&\n'); |
| while (text.includes('\n\n')) { |
| text = text.replace(/\n\n/g, '\n'); |
| } |
| text = text.replace(/「([^」\n]*)\n([^」\n]*)」/g, '「$1$2」'); |
| text = text.replace(/(([^)\n]*)\n([^)\n]*))/g, '($1$2)'); |
|
|
| while (text.search(/「[^「\n]*。\n/) >= 0) { |
| text = text.replace(/「([^「\n]*。)\n/, '「$1'); |
| } |
|
|
| text = text.replace(/\n/g, "\n\n"); |
| text = text.replace(/\n#/g, "\n\n#"); |
|
|
| document.getElementById('novelContent1').value = text; |
| } |
|
|
| function unmalform(text) { |
| let result = null; |
| while (!result && text) { |
| try { |
| result = decodeURI(text); |
| } catch (error) { |
| text = text.slice(0, -1); |
| } |
| } |
| return result || ''; |
| } |
|
|
| async function summerize(text) { |
| const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${document.getElementById('geminiApiKey').value}`; |
| const prompt = `以下の文章を240文字程度に要約してください:\n\n${text}`; |
| const payload = { |
| method: 'POST', |
| headers: {}, |
| body: JSON.stringify({ |
| contents: [{ parts: [{ text: prompt }] }], |
| generationConfig: { |
| temperature: parseFloat(document.getElementById('temperature').value), |
| max_output_tokens: 512 |
| }, |
| safetySettings: getSafetySettings('models/gemini-2.0-flash-exp') |
| }) |
| }; |
| try { |
| const response = await fetch(ENDPOINT, payload); |
| const data = await response.json(); |
| return data.candidates[0].content.parts[0].text; |
| } catch (error) { |
| console.error('要約エラー:', error); |
| return ''; |
| } |
| } |
|
|
| function partialEncodeURI(text) { |
| if (!document.getElementById("partialEncodeToggle").checked) { |
| return text; |
| } |
| let length = parseInt(document.getElementById("encodeLength").value); |
| const chunks = []; |
| for (let i = 0; i < text.length; i += 1) { |
| chunks.push(text.slice(i, i + 1)); |
| } |
| const encodedChunks = chunks.map((chunk, index) => { |
| if (index % length === 0) { |
| return encodeURI(chunk); |
| } |
| return chunk; |
| }); |
| const result = encodedChunks.join(''); |
| return result; |
| } |
|
|
| function saveToJson() { |
| const novelContent1 = document.getElementById('novelContent1').value; |
| const novelContent2 = document.getElementById('novelContent2').value; |
| const generatePrompt = document.getElementById('generatePrompt').value; |
| const nextPrompt = document.getElementById('nextPrompt').value; |
| const savedTitle = document.getElementById('savedTitle').value; |
| const jsonData = JSON.stringify({ |
| novelContent1: novelContent1, |
| novelContent2: novelContent2, |
| generatePrompt: generatePrompt, |
| nextPrompt: nextPrompt, |
| savedTitle: savedTitle |
| }); |
| const blob = new Blob([jsonData], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = 'novel_data.json'; |
| if (savedTitle) { |
| a.download = savedTitle + '.json'; |
| } |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| } |
|
|
| function loadFromJson() { |
| const fileInput = document.createElement('input'); |
| fileInput.type = 'file'; |
| fileInput.accept = '.json,.txt'; |
| fileInput.style.display = 'none'; |
| document.body.appendChild(fileInput); |
| fileInput.addEventListener('change', function (event) { |
| const file = event.target.files[0]; |
| if (file) { |
| const reader = new FileReader(); |
| reader.onload = function (e) { |
| if (file.name.endsWith('.txt')) { |
| document.getElementById('novelContent1').value = e.target.result; |
| alert('テキストファイルを正常に読み込みました'); |
| } else { |
| try { |
| const jsonData = JSON.parse(e.target.result); |
| if (jsonData.novelContent1) { |
| document.getElementById('novelContent1').value = jsonData.novelContent1; |
| } |
| if (jsonData.novelContent2) { |
| document.getElementById('novelContent2').value = jsonData.novelContent2; |
| } |
| if (jsonData.generatePrompt) { |
| document.getElementById('generatePrompt').value = jsonData.generatePrompt; |
| } |
| if (jsonData.nextPrompt) { |
| document.getElementById('nextPrompt').value = jsonData.nextPrompt; |
| } |
| if (jsonData.savedTitle) { |
| document.getElementById('savedTitle').value = jsonData.savedTitle; |
| } |
| generateIndexMenu(true); |
| alert('JSONファイルを正常に読み込みました'); |
| } catch (error) { |
| alert('無効なJSONファイルです。'); |
| } |
| } |
| }; |
| reader.readAsText(file); |
| } |
| }); |
| fileInput.click(); |
| } |
|
|
| function saveToUserStorage(force = false) { |
| const currentTime = Date.now(); |
| if (currentTime - lastSaveTimestamp < 5000 && !force) { |
| console.debug('セーブをスキップします'); |
| return; |
| } |
| console.debug('セーブを実行します'); |
|
|
| |
| const geminiClientData = JSON.parse(localStorage.getItem('geminiClient') || '{}'); |
|
|
| const newData = {}; |
| Array.from(document.querySelectorAll("input[id], textarea[id], select[id]")).forEach(el => { |
| if (el.id) { |
| newData[el.id] = el.type === 'checkbox' ? el.checked : el.value; |
| } |
| }); |
| Object.assign(geminiClientData, newData); |
| console.log(geminiClientData); |
| localStorage.setItem('geminiClient', JSON.stringify(geminiClientData)); |
| lastSaveTimestamp = currentTime; |
| } |
|
|
| function loadFromUserStorage() { |
| const savedData = localStorage.getItem('geminiClient'); |
| if (savedData) { |
| const geminiClientData = JSON.parse(savedData); |
| Object.keys(geminiClientData).filter(key => key != "endpointSelect").forEach(key => { |
| const elem = document.getElementById(key); |
| if (elem) { |
| if (elem.type === 'checkbox') { |
| elem.checked = geminiClientData[key]; |
| } else { |
| elem.value = geminiClientData[key]; |
| } |
|
|
| |
| if (key === 'encodeLength' || key === 'contentWidth') { |
| const inputElem = document.getElementById(`${key}Input`); |
| if (inputElem) { |
| inputElem.value = geminiClientData[key]; |
| } |
| } |
| } else { |
| console.debug(`要素が見つかりません: ${key}`); |
| } |
| }); |
| getModelList().then(models => { |
| const endpointSelect = document.getElementById('endpointSelect'); |
| |
| models.forEach(model => { |
| const option = document.createElement('option'); |
| option.value = model.name; |
| option.textContent = model.name; |
| endpointSelect.appendChild(option); |
| }); |
| endpointSelect.value = geminiClientData.endpointSelect; |
| }); |
| } |
| } |
|
|
| function createSummarizedText() { |
| const indexOffcanvasBody = document.querySelector('#indexOffcanvas .offcanvas-body'); |
| const rootUl = indexOffcanvasBody.querySelector('ul.list-unstyled'); |
| let summarizedText = ''; |
|
|
| function processUl(ul, level = 0) { |
| const items = ul.children; |
| for (let item of items) { |
| const a = item.querySelector(':scope > a'); |
| if (a) { |
| summarizedText += '#'.repeat(level + 1) + ' ' + a.textContent + '\n'; |
| } |
|
|
| const contentItem = item.querySelector(':scope > ul > li'); |
| if (contentItem) { |
| const fullText = contentItem.querySelector('.full-text'); |
| const summaryText = contentItem.querySelector('.summery-text'); |
| if (summaryText && summaryText.value.trim()) { |
| summarizedText += summaryText.value + '\n\n'; |
| } else if (fullText) { |
| summarizedText += fullText.value + '\n\n'; |
| } |
| } |
|
|
| const subUl = item.querySelector(':scope > ul'); |
| if (subUl) { |
| processUl(subUl, level + 1); |
| } |
| } |
| } |
|
|
| if (rootUl) { |
| processUl(rootUl); |
| } |
| if (summarizedText) { |
| return summarizedText.trim(); |
| } else { |
| return document.getElementById('novelContent1').value; |
| } |
| } |
|
|
| function createPayload() { |
| const novelContent1 = document.getElementById('novelContent1'); |
| let text = novelContent1.value; |
| if (document.getElementById('summerizedPromptToggle').checked) { |
| text = createSummarizedText(); |
| } |
| const lines = text.split('\n').filter(x => x); |
|
|
| let systemPrompt = `${partialEncodeURI(document.getElementById('generatePrompt').value)}`; |
| let prompt = `続きを書いて。${partialEncodeURI(document.getElementById('nextPrompt').value)} ${systemPrompt}`; |
| |
| |
| const attachedImages = getAttachedImages(); |
| const imagePromises = attachedImages.map(file => { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.onload = () => { |
| |
| const base64Data = reader.result.split(',')[1]; |
| resolve({ |
| inline_data: { |
| data: base64Data, |
| mime_type: file.type |
| } |
| }); |
| }; |
| reader.onerror = reject; |
| reader.readAsDataURL(file); |
| }); |
| }); |
|
|
| |
| return Promise.all(imagePromises).then(imageParts => { |
| let messages = [ |
| { |
| "role": "user", |
| "parts": [{ "text": "." }] |
| }, |
| { |
| "role": "model", |
| "parts": [{ "text": partialEncodeURI(lines.join("\n")) }] |
| }, |
| { |
| "role": "user", |
| "parts": [ |
| { "text": prompt }, |
| ...imageParts |
| ] |
| } |
| ]; |
|
|
| let max_output_tokens = 4096; |
| let selectedEndpoint = document.getElementById('endpointSelect').value; |
| if (selectedEndpoint.match(/^models\/gemini-2\.0/)) { |
| max_output_tokens = 8192; |
| } |
| if (selectedEndpoint.match(/^models\/gemini-2\.0-flash-thinking/)) { |
| max_output_tokens = 65536; |
| } |
|
|
| return { |
| method: 'POST', |
| headers: {}, |
| body: JSON.stringify({ |
| contents: messages, |
| generationConfig: { |
| "temperature": parseFloat(document.getElementById('temperature').value), |
| "max_output_tokens": max_output_tokens |
| }, |
| safetySettings: getSafetySettings(selectedEndpoint) |
| }), |
| mode: 'cors' |
| }; |
| }); |
| } |
|
|
| function debugPrompt() { |
| console.log({ |
| "gemini": JSON.parse(createPayload().body), |
| "openai": JSON.parse(createOpenAIPayload().body) |
| }); |
| } |
|
|
| |
| function updateRequestButtonState(state, flashClass = null) { |
| const requestButton = document.getElementById('requestButton'); |
| const stopButton = document.getElementById('stopButton'); |
|
|
| switch (state) { |
| case 'generating': |
| requestButton.disabled = true; |
| stopButton.classList.remove('d-none'); |
| break; |
| case 'idle': |
| requestButton.disabled = false; |
| stopButton.classList.add('d-none'); |
| break; |
| case 'error': |
| requestButton.disabled = false; |
| stopButton.classList.add('d-none'); |
| break; |
| } |
|
|
| if (flashClass) { |
| requestButton.classList.add(flashClass); |
| setTimeout(() => { |
| requestButton.classList.remove(flashClass); |
| }, 2000); |
| } |
| } |
|
|
| function fetchStream(ENDPOINT, payload) { |
| const novelContent2 = document.getElementById('novelContent2'); |
| updateRequestButtonState('generating'); |
| controller = new AbortController(); |
| const signal = controller.signal; |
|
|
| fetch(ENDPOINT, { ...payload, signal }) |
| .then(response => { |
| if (!response.ok) { |
| throw new Error('ネットワークの応答が正常ではありません'); |
| } |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
|
|
| function readStream() { |
| reader.read().then(({ done, value }) => { |
| if (done) { |
| console.debug('ストリームが完了しました'); |
| document.getElementById('stopButton').classList.add('d-none'); |
| requestButton.disabled = false; |
| return; |
| } |
|
|
| const chunk = decoder.decode(value, { stream: true }); |
| buffer += chunk; |
| console.debug('チャンクを受信しまし:', chunk); |
|
|
| |
| let startIndex = 0; |
| while (true) { |
| const endIndex = buffer.indexOf('\n', startIndex); |
| if (endIndex === -1) break; |
|
|
| const line = buffer.slice(startIndex, endIndex).trim(); |
| startIndex = endIndex + 1; |
|
|
| if (line.startsWith('data: ')) { |
| const jsonString = line.slice(5); |
| if (jsonString === '[DONE]') { |
| console.debug('Received [DONE] signal'); |
| break; |
| } |
| try { |
| const data = JSON.parse(jsonString); |
| console.debug('解析されたJSON:', data); |
| if (data.candidates && data.candidates[0].content && data.candidates[0].content.parts) { |
| data.candidates[0].content.parts.forEach(part => { |
| if (part.text) { |
| console.debug('出力にテキストを追加:', part.text); |
| novelContent2.value += part.text; |
| novelContent2.scrollTop = novelContent2.scrollHeight; |
| } |
| }); |
| } |
| |
| if (data.candidates && data.candidates[0]) { |
| if (data.candidates[0].finishReason) { |
| if (data.candidates[0].finishReason === 'STOP') { |
| requestButton.classList.add('green-flash-bg'); |
| setTimeout(() => { |
| requestButton.classList.remove('green-flash-bg'); |
| }, 2000); |
| } else { |
| requestButton.classList.add('red-flash-bg'); |
| setTimeout(() => { |
| requestButton.classList.remove('red-flash-bg'); |
| }, 2000); |
| } |
| } |
| if (data.candidates[0].blockReason) { |
| requestButton.classList.add('red-flash-bg'); |
| setTimeout(() => { |
| requestButton.classList.remove('red-flash-bg'); |
| }, 2000); |
| } |
| } |
| } catch (error) { |
| console.error('JSONパースエラー:', error); |
| } |
| } |
| } |
|
|
| |
| buffer = buffer.slice(startIndex); |
|
|
| readStream(); |
| }).catch(error => { |
| if (error.name === 'AbortError') { |
| console.log('フェッチがユーザーによって中止されました'); |
| updateRequestButtonState('idle'); |
| } else { |
| console.error('ストリーム読み取りエラー:', error); |
| updateRequestButtonState('error', 'red-flash-bg'); |
| } |
| }); |
| } |
|
|
| readStream(); |
| }) |
| .catch(error => { |
| if (error.name === 'AbortError') { |
| console.log('フェッチがユーザーよって中止されました'); |
| updateRequestButtonState('idle'); |
| } else { |
| console.error('フェッチエラー:', error); |
| updateRequestButtonState('error', 'red-flash-bg'); |
| } |
| }); |
| } |
|
|
| async function fetchNonStream(ENDPOINT, payload) { |
| const novelContent2 = document.getElementById('novelContent2'); |
| updateRequestButtonState('generating'); |
| try { |
| const response = await fetch(ENDPOINT, payload); |
| const data = await response.json(); |
| if (data && data.candidates && data.candidates[0].content && data.candidates[0].content.parts && data.candidates[0].content.parts[0].text) { |
| novelContent2.value += data.candidates[0].content.parts[0].text; |
| novelContent2.scrollTop = novelContent2.scrollHeight; |
| updateRequestButtonState('idle', 'green-flash-bg'); |
| } else { |
| throw new Error('予期しないレスポンス形式'); |
| } |
| } catch (error) { |
| console.error('エラー:', error); |
| updateRequestButtonState('error', 'red-flash-bg'); |
| } |
| } |
|
|
| function createOpenAIPayload() { |
| const novelContent1 = document.getElementById('novelContent1'); |
| const text = novelContent1.value; |
| const lines = text.split('\n').filter(x => x); |
|
|
| let messages = [ |
| { |
| "content": document.getElementById('generatePrompt').value || ".", |
| "role": "system" |
| }, |
| { |
| "content": ".", |
| "role": "user" |
| }, |
| { |
| "content": partialEncodeURI(lines.join("\n")) || ".", |
| "role": "assistant" |
| }, |
| { |
| "content": `${partialEncodeURI(document.getElementById('nextPrompt').value)}`, |
| "role": "user" |
| } |
| ]; |
|
|
| let jsonBody = JSON.parse(document.getElementById('openaiJsonBody').value); |
| jsonBody.messages = messages; |
| jsonBody.stream = document.getElementById('streamToggle').checked; |
|
|
| return { |
| method: 'POST', |
| headers: JSON.parse(document.getElementById('openaiHeaders').value), |
| body: JSON.stringify(jsonBody), |
| mode: 'cors', |
| credentials: 'same-origin' |
| }; |
| } |
|
|
| function fetchOpenAIStream(ENDPOINT, payload) { |
| const novelContent2 = document.getElementById('novelContent2'); |
| updateRequestButtonState('generating'); |
| controller = new AbortController(); |
| const signal = controller.signal; |
| payload.signal = signal; |
| fetch(ENDPOINT, payload) |
| .then(response => { |
| if (!response.ok) { |
| throw new Error('ネットワークの応答が常ではありません'); |
| } |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
|
|
| function readStream() { |
| reader.read().then(({ done, value }) => { |
| if (done) { |
| console.debug('ストリームが完了しました'); |
| document.getElementById('stopButton').classList.add('d-none'); |
| requestButton.disabled = false; |
| return; |
| } |
|
|
| const chunk = decoder.decode(value, { stream: true }); |
| buffer += chunk; |
|
|
| const lines = buffer.split('\n'); |
| buffer = lines.pop(); |
|
|
| lines.forEach(line => { |
| if (line.startsWith('data: ')) { |
| const jsonString = line.slice(6); |
| if (jsonString === '[DONE]') { |
| console.debug('Received [DONE] signal'); |
| return; |
| } |
| try { |
| const data = JSON.parse(jsonString); |
| if (data.choices && data.choices[0].delta && data.choices[0].delta.content) { |
| novelContent2.value += data.choices[0].delta.content; |
| novelContent2.scrollTop = novelContent2.scrollHeight; |
| } |
| } catch (error) { |
| console.error('JSONパースエラー:', error); |
| } |
| } |
| }); |
|
|
| readStream(); |
| }).catch(error => { |
| if (error.name === 'AbortError') { |
| console.log('フェッチがユーザーによって中止されました'); |
| updateRequestButtonState('idle'); |
| } else { |
| console.error('ストリーム読み取りエラー:', error); |
| updateRequestButtonState('error', 'red-flash-bg'); |
| } |
| }); |
| } |
|
|
| readStream(); |
| }) |
| .catch(error => { |
| if (error.name === 'AbortError') { |
| console.log('フェッチがユーザーよって中止されました'); |
| updateRequestButtonState('idle'); |
| } else { |
| console.error('フェッチエラー:', error); |
| updateRequestButtonState('error', 'red-flash-bg'); |
| } |
| }); |
| } |
|
|
| async function fetchOpenAINonStream(ENDPOINT, payload) { |
| const novelContent2 = document.getElementById('novelContent2'); |
| updateRequestButtonState('generating'); |
| try { |
| const signal = controller.signal; |
| payload.signal = signal; |
| const response = await fetch(ENDPOINT, payload); |
| const data = await response.json(); |
| if (data && data.choices && data.choices[0].message && data.choices[0].message.content) { |
| novelContent2.value += data.choices[0].message.content; |
| novelContent2.scrollTop = novelContent2.scrollHeight; |
| updateRequestButtonState('idle', 'green-flash-bg'); |
| } else { |
| throw new Error('予期しないレスポンス形式'); |
| } |
| } catch (error) { |
| console.error('エラー:', error); |
| updateRequestButtonState('error', 'red-flash-bg'); |
| } |
| } |
|
|
| async function tokenCount() { |
| const selectedEndpoint = document.getElementById('endpointSelect').value; |
| let payload = createPayload(); |
| payload.body = { |
| "contents": JSON.parse(payload.body).contents |
| }; |
| payload.body = JSON.stringify(payload.body); |
| if (selectedEndpoint.startsWith('models/gemini')) { |
| const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/${selectedEndpoint}:countTokens?key=` + document.getElementById('geminiApiKey').value; |
| try { |
| const response = await fetch(ENDPOINT, payload); |
| const data = await response.json(); |
| return data.totalTokens; |
| } catch (error) { |
| console.error('エラー:', error); |
| return null; |
| } |
| } else { |
| return -1; |
| } |
|
|
| } |
|
|
| async function createDraft() { |
| const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=` + document.getElementById('geminiApiKey').value; |
| let payload = createPayload(); |
| const response = await fetch(ENDPOINT, payload); |
| const data = await response.json(); |
| const text = data.candidates[0].content.parts[0].text; |
| return text |
| } |
|
|
|
|
| async function Request() { |
| generateIndexMenu(true); |
| let selectedEndpoint = document.getElementById('endpointSelect').value; |
| const requestButton = document.getElementById('requestButton'); |
| requestButton.disabled = true; |
| document.getElementById('novelContent2').value = ''; |
| const outputAccordion = document.querySelector('#content2Collapse'); |
| if (outputAccordion) { |
| const bsCollapse = new bootstrap.Collapse(outputAccordion, { toggle: false }); |
| bsCollapse.show(); |
| } |
|
|
| let ENDPOINT; |
| let payload; |
|
|
| if (selectedEndpoint.startsWith('models/gemini')) { |
| ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/${selectedEndpoint}:generateContent?key=` + document.getElementById('geminiApiKey').value; |
| payload = await createPayload(); |
| } else if (selectedEndpoint === 'restart') { |
| ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=` + document.getElementById('geminiApiKey').value; |
| document.getElementById('novelContent2').value = '(下書き中)'; |
| try { |
| draft = await createDraft(); |
| } catch (error) { |
| console.error('エラー:', error); |
| requestButton.disabled = false; |
| requestButton.classList.add('green-flash-bg'); |
| setTimeout(() => { |
| requestButton.classList.remove('green-flash-bg'); |
| }, 2000); |
| return; |
| } |
| |
| document.getElementById('novelContent2').value = ""; |
| const prompt = `以下の文章を、要件を満たす形に直したものを返信してください。\n返信内容は直した後のテキストのみになるようにし他の内容が混ざらないようにしてください。\n\n# 要件\n- 元の文章や会話の言い回しは不自然な日本語になっている部分もかなり多いので、根本的に書き替えること。ただし、登場人物の一人称や他者への呼称は変更しないこと。\n- 物語の大筋やキャラクターの性格は保ったまま、文章は全く別物に書き換える必要がある。元のテキストそのものはなるべく流用しないで新規に書くよう心掛ける。\n- 随所にレトリックを駆使しつつも凝り過ぎて読み辛くなることのないシンプルな美文になることを意識する。\n- 登場人物の会話を重視し、文脈を読み取り元のキャラクター設定に合った台詞回しを保ちつつ、より生き生きとした魅力的な物像に仕上がるようにする。\n- 細かい動作や心理描写のディテールを重視し、よりリアルな描写になるようにする。\n- 文章の終わりに「。」をつける、字下げをするなど、一般的な小説のフォーマットに従う書き方にする。\n\n# 文章\n${draft}`; |
| payload = { |
| method: 'POST', |
| headers: {}, |
| body: JSON.stringify({ |
| contents: [ |
| { |
| "parts": [ |
| { |
| "text": prompt |
| } |
| ], |
| "role": "user" |
| } |
| ], |
| generationConfig: { |
| "temperature": 1.0, |
| "max_output_tokens": 8192 |
| }, |
| safetySettings: getSafetySettings('models/gemini-2.0-flash-exp') |
| }), |
| mode: 'cors' |
| }; |
| selectedEndpoint = 'models/gemini-2.0-flash-exp'; |
| } else { |
| ENDPOINT = document.getElementById('openaiEndpoint').value; |
| payload = createOpenAIPayload(); |
| } |
|
|
| let stream = document.getElementById('streamToggle').checked; |
| if (stream) { |
| if (selectedEndpoint.startsWith('models/gemini')) { |
| ENDPOINT = ENDPOINT.replace(':generateContent', ':streamGenerateContent') + '&alt=sse'; |
| fetchStream(ENDPOINT, payload); |
| } else { |
| fetchOpenAIStream(ENDPOINT, payload); |
| } |
| document.getElementById('stopButton').classList.remove('d-none'); |
| } else { |
| if (selectedEndpoint.startsWith('models/gemini')) { |
| fetchNonStream(ENDPOINT, payload); |
| } else { |
| fetchOpenAINonStream(ENDPOINT, payload); |
| } |
| } |
| } |
|
|
| function stopGeneration() { |
| if (controller) { |
| controller.abort(); |
| controller = null; |
| } |
| updateRequestButtonState('idle'); |
| } |
|
|
| |
| function handleKeyPress(event) { |
| if (event.ctrlKey && event.key === 'Enter') { |
| Request(); |
| } |
| } |
|
|
| function syncInputs() { |
| const inputs = document.querySelectorAll('input[type="range"], input[type="number"]'); |
| inputs.forEach(input => { |
| const baseId = input.id.replace('Input', ''); |
| const pairedInput = document.getElementById(baseId + (input.type === 'range' ? 'Input' : '')); |
|
|
| if (pairedInput) { |
| input.addEventListener('input', function () { |
| pairedInput.value = this.value; |
| }); |
| } |
| }); |
| } |
|
|
| function openNextAccordion() { |
| const accordions = document.querySelectorAll('#mainAccordion .accordion-item'); |
| let currentIndex = -1; |
|
|
| |
| for (let i = 0; i < accordions.length; i++) { |
| if (!accordions[i].querySelector('.accordion-button').classList.contains('collapsed')) { |
| currentIndex = i; |
| break; |
| } |
| } |
|
|
| |
| if (currentIndex < accordions.length - 1) { |
| new bootstrap.Collapse(accordions[currentIndex].querySelector('.accordion-collapse')).hide(); |
| new bootstrap.Collapse(accordions[currentIndex + 1].querySelector('.accordion-collapse')).show(); |
| } else { |
| |
| const nextButton = document.getElementById('nextAccordion'); |
| nextButton.classList.add('red-flash-bg'); |
| setTimeout(() => { |
| nextButton.classList.remove('red-flash-bg'); |
| }, 2000); |
| } |
| } |
|
|
| function openPreviousAccordion() { |
| const accordions = document.querySelectorAll('#mainAccordion .accordion-item'); |
| let currentIndex = -1; |
|
|
| |
| for (let i = 0; i < accordions.length; i++) { |
| if (!accordions[i].querySelector('.accordion-button').classList.contains('collapsed')) { |
| currentIndex = i; |
| break; |
| } |
| } |
|
|
| |
| if (currentIndex > 0) { |
| new bootstrap.Collapse(accordions[currentIndex].querySelector('.accordion-collapse')).hide(); |
| new bootstrap.Collapse(accordions[currentIndex - 1].querySelector('.accordion-collapse')).show(); |
| } else { |
| |
| const prevButton = document.getElementById('prevAccordion'); |
| prevButton.classList.add('red-flash-bg'); |
| setTimeout(() => { |
| prevButton.classList.remove('red-flash-bg'); |
| }, 2000); |
| } |
| } |
|
|
| function moveToInput() { |
| const content1 = document.getElementById('novelContent1'); |
| const content2 = document.getElementById('novelContent2'); |
|
|
| let content1Lines = content1.value.trim().split('\n'); |
| let content2Lines = content2.value.trim().split('\n'); |
|
|
| |
| if (content1Lines[content1Lines.length - 1] === content2Lines[0]) { |
| content2Lines.shift(); |
| } else { |
| |
| const lastLine = content1Lines[content1Lines.length - 1]; |
| const firstLine = content2Lines[0]; |
| const overlapIndex = firstLine.indexOf(lastLine); |
| if (overlapIndex !== -1) { |
| content2Lines[0] = firstLine.slice(overlapIndex + lastLine.length).trim(); |
| if (content2Lines[0] === '') { |
| content2Lines.shift(); |
| } |
| } |
| } |
|
|
| |
| content1.value = content1Lines.join('\n') + '\n' + content2Lines.join('\n'); |
|
|
| |
| content2.value = ''; |
|
|
| |
| const content1Collapse = new bootstrap.Collapse(document.getElementById('content1Collapse'), { |
| show: true |
| }); |
| } |
|
|
| function updateNavbarBrand() { |
| const endpointSelect = document.getElementById('endpointSelect'); |
| const navbarBrand = document.querySelector('.navbar-brand'); |
| const googleIcon = navbarBrand.querySelector('.fa-google'); |
| const robotIcon = navbarBrand.querySelector('.fa-robot'); |
|
|
| if (endpointSelect.value.startsWith('models/gemini')) { |
| navbarBrand.style.color = '#4285F4'; |
| googleIcon.classList.remove('d-none'); |
| robotIcon.classList.add('d-none'); |
| } else { |
| navbarBrand.style.color = '#00FF00'; |
| googleIcon.classList.add('d-none'); |
| robotIcon.classList.remove('d-none'); |
| } |
| } |
|
|
| async function updateTokenCount(force = false) { |
| const currentTime = Date.now(); |
| if (currentTime - lastTokenUpdateTimestamp < 60000 && !force) { |
| console.debug('トークン数更新をスキップします'); |
| return; |
| } |
| console.debug('トークン数更新を実行します'); |
|
|
| const count = await tokenCount(); |
| const indexOffcanvasLabel = document.getElementById('indexOffcanvasLabel'); |
| indexOffcanvasLabel.textContent = `目次 (${count}トークン)`; |
| lastTokenUpdateTimestamp = currentTime; |
| } |
|
|
| function generateIndexMenu(force = false) { |
| const currentTime = Date.now(); |
| if (currentTime - lastIndexUpdateTimestamp < 60000 && !force) { |
| console.debug('目次更新をスキップします'); |
| return; |
| } |
| console.debug('目次更新を実行します'); |
|
|
| const content = document.getElementById('novelContent1').value; |
| const tokens = marked.lexer(content); |
| const indexOffcanvasBody = document.querySelector('#indexOffcanvas .offcanvas-body'); |
|
|
| indexOffcanvasBody.innerHTML = ''; |
|
|
| const rootUl = document.createElement('ul'); |
| rootUl.className = 'list-unstyled'; |
|
|
| let stack = [{ ul: rootUl, level: 0 }]; |
| let lastHeading = null; |
| let contentBuffer = ''; |
|
|
| tokens.forEach((token, index) => { |
| if (token.type === 'heading') { |
| if (lastHeading && contentBuffer.trim()) { |
| addTextarea(lastHeading.querySelector('ul'), contentBuffer.trim()); |
| } |
| contentBuffer = ''; |
|
|
| while (stack.length > 1 && stack[stack.length - 1].level >= token.depth) { |
| stack.pop(); |
| } |
|
|
| const li = document.createElement('li'); |
| const toggleBtn = document.createElement('button'); |
| toggleBtn.className = 'btn btn-sm btn-outline-secondary me-2 toggle-btn'; |
| const icon = document.createElement('i'); |
| icon.className = 'fas fa-plus'; |
| toggleBtn.appendChild(icon); |
| toggleBtn.onclick = () => toggleSubMenu(li); |
|
|
| const a = document.createElement('a'); |
| a.href = '#'; |
| a.textContent = token.text; |
| a.onclick = (e) => { |
| e.preventDefault(); |
| scrollToHeading(token.text); |
| }; |
|
|
| li.appendChild(toggleBtn); |
| li.appendChild(a); |
|
|
| const subUl = document.createElement('ul'); |
| subUl.className = 'list-unstyled ms-3 d-none'; |
| li.appendChild(subUl); |
|
|
| stack[stack.length - 1].ul.appendChild(li); |
|
|
| if (token.depth > stack[stack.length - 1].level) { |
| stack.push({ ul: subUl, level: token.depth }); |
| } |
|
|
| lastHeading = li; |
| } else if (token.type === 'text' || token.type === 'paragraph') { |
| contentBuffer += token.text + '\n'; |
| } |
| }); |
|
|
| if (lastHeading && contentBuffer.trim()) { |
| addTextarea(lastHeading.querySelector('ul'), contentBuffer.trim()); |
| } |
|
|
| if (rootUl.children.length > 0) { |
| indexOffcanvasBody.appendChild(rootUl); |
| } else { |
| indexOffcanvasBody.textContent = '目次がありません'; |
| } |
|
|
| updateTokenCount(force); |
| lastIndexUpdateTimestamp = currentTime; |
| } |
|
|
| function toggleSubMenu(li) { |
| const subUl = li.querySelector('ul'); |
| const toggleBtn = li.querySelector('.toggle-btn'); |
| const icon = toggleBtn.querySelector('i'); |
| subUl.classList.toggle('d-none'); |
| icon.className = subUl.classList.contains('d-none') ? 'fas fa-plus' : 'fas fa-minus'; |
| } |
|
|
| function addTextarea(ul, content) { |
| const li = document.createElement('li'); |
|
|
| |
| const textarea = document.createElement('textarea'); |
| textarea.readOnly = true; |
| textarea.className = 'form-control mt-2 full-text'; |
| textarea.value = content; |
| textarea.rows = 3; |
|
|
| |
| const summaryInput = document.createElement('textarea'); |
| summaryInput.className = 'form-control mt-2 summery-text'; |
| summaryInput.placeholder = '要約'; |
| summaryInput.rows = 3; |
| if (summeries[content]) { |
| summaryInput.value = summeries[content]; |
| } |
|
|
| |
| const buttonContainer = document.createElement('div'); |
| buttonContainer.className = 'mt-2'; |
|
|
| |
| const summaryButton = document.createElement('button'); |
| summaryButton.textContent = '要約を取得'; |
| summaryButton.className = 'btn btn-secondary me-2'; |
| summaryButton.onclick = async () => { |
| summaryButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Loading...'; |
| summaryButton.disabled = true; |
| try { |
| const summary = await summerize(content); |
| summaryInput.value = summary; |
| summeries[content] = summary; |
| updateTokenCount(true); |
| } finally { |
| summaryButton.innerHTML = '要約を取得'; |
| summaryButton.disabled = false; |
| } |
| }; |
|
|
| |
| const deleteSummaryButton = document.createElement('button'); |
| deleteSummaryButton.textContent = '要約を削除'; |
| deleteSummaryButton.className = 'btn btn-danger'; |
| deleteSummaryButton.onclick = () => { |
| summaryInput.value = ''; |
| delete summeries[content]; |
| updateTokenCount(true); |
| }; |
|
|
| |
| const proofReadButton = document.createElement('button'); |
| proofReadButton.textContent = '校正'; |
| proofReadButton.className = 'btn btn-secondary me-2'; |
| proofReadButton.onclick = async () => { |
| proofReadButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 校正中...'; |
| proofReadButton.disabled = true; |
| try { |
| await proofRead(textarea); |
| } finally { |
| proofReadButton.innerHTML = '校正'; |
| proofReadButton.disabled = false; |
| } |
| }; |
| const hr = document.createElement('hr'); |
|
|
| |
| buttonContainer.appendChild(summaryButton); |
| buttonContainer.appendChild(deleteSummaryButton); |
| buttonContainer.appendChild(hr); |
| buttonContainer.appendChild(proofReadButton); |
| |
| li.appendChild(textarea); |
| li.appendChild(summaryInput); |
| li.appendChild(buttonContainer); |
| ul.appendChild(li); |
| } |
|
|
| function scrollToHeading(headingText) { |
| const content1 = document.getElementById('novelContent1'); |
| const content1Collapse = document.getElementById('content1Collapse'); |
| const accordion = new bootstrap.Collapse(content1Collapse, { toggle: false }); |
|
|
| |
| const lines = content1.value.split('\n'); |
|
|
| |
| const headingIndex = lines.findIndex(line => line.includes(headingText)); |
|
|
| if (headingIndex !== -1) { |
| |
| const tempDiv = document.createElement('div'); |
| tempDiv.style.cssText = ` |
| position: absolute; |
| top: -9999px; |
| left: -9999px; |
| width: ${content1.clientWidth}px; |
| font-size: ${window.getComputedStyle(content1).fontSize}; |
| font-family: ${window.getComputedStyle(content1).fontFamily}; |
| line-height: ${window.getComputedStyle(content1).lineHeight}; |
| white-space: pre-wrap; |
| word-wrap: break-word; |
| visibility: hidden; |
| `; |
| document.body.appendChild(tempDiv); |
|
|
| |
| tempDiv.textContent = lines.slice(0, headingIndex).join('\n'); |
|
|
| |
| const scrollPosition = tempDiv.clientHeight; |
|
|
| |
| document.body.removeChild(tempDiv); |
|
|
| |
| if (content1Collapse.classList.contains('show')) { |
| |
| content1.scrollTop = scrollPosition; |
| } else { |
| |
| accordion.show(); |
| content1Collapse.addEventListener('shown.bs.collapse', function onShown() { |
| content1.scrollTop = scrollPosition; |
| content1Collapse.removeEventListener('shown.bs.collapse', onShown); |
| }, { once: true }); |
| } |
| } |
| } |
|
|
| function updateAccordionHeaderCount(accordionId) { |
| const accordionItem = document.getElementById(accordionId).closest('.accordion-item'); |
| if (!accordionItem) return; |
|
|
| const textarea = accordionItem.querySelector('.accordion-body textarea'); |
| const header = accordionItem.querySelector('.accordion-header button'); |
|
|
| if (textarea && header) { |
| const charCount = textarea.value.length; |
| const originalText = header.textContent.split('(')[0].trim(); |
| header.textContent = `${originalText} (${charCount}文字)`; |
| } |
| } |
|
|
| function updateAllAccordionHeaderCounts() { |
| const accordionIds = ['promptsCollapse', 'content1Collapse', 'nextPromptCollapse', 'content2Collapse']; |
| accordionIds.forEach(updateAccordionHeaderCount); |
| } |
|
|
| function showDiffModal() { |
| const diffContainer = document.getElementById('diffContainer'); |
| diffContainer.innerHTML = ''; |
|
|
| if (replaceProofReadHistory.length === 0) { |
| diffContainer.innerHTML = '<p>校正履歴がありません。</p>'; |
| return; |
| } |
|
|
| replaceProofReadHistory.forEach((entry, index) => { |
| const [original, corrected] = entry; |
| const diff = Diff.diffLines(original.join('\n'), corrected.join('\n')); |
|
|
| const diffHtml = diff.map(part => { |
| const color = part.added ? 'green' : part.removed ? 'red' : 'grey'; |
| const prefix = part.added ? '+' : part.removed ? '-' : ' '; |
| return `<span style="color: ${color}">${prefix} ${part.value}</span>`; |
| }).join(''); |
|
|
| const entryDiv = document.createElement('div'); |
| entryDiv.innerHTML = `<h6>校正 ${index + 1}</h6><pre>${diffHtml}</pre><hr>`; |
| diffContainer.appendChild(entryDiv); |
| }); |
|
|
| const modal = new bootstrap.Modal(document.getElementById('diffModal')); |
| modal.show(); |
| } |
|
|
| document.addEventListener('DOMContentLoaded', function () { |
| |
| loadFromUserStorage(); |
|
|
| |
| ['novelContent1', 'novelContent2', 'generatePrompt', 'nextPrompt', 'savedTitle'].forEach(id => { |
| document.getElementById(id).addEventListener('input', () => { |
| saveToUserStorage(false); |
| generateIndexMenu(false); |
| updateAllAccordionHeaderCounts(); |
| }); |
| }); |
|
|
| |
| ['memo', 'geminiApiKey', 'endpointSelect', 'openaiEndpoint', 'openaiHeaders', 'openaiJsonBody', 'encodeLength', 'temperature'].forEach(id => { |
| document.getElementById(id).addEventListener('input', () => { |
| saveToUserStorage(true); |
| }); |
| }); |
|
|
| ['partialEncodeToggle', 'streamToggle'].forEach(id => { |
| document.getElementById(id).addEventListener('change', () => { |
| saveToUserStorage(true); |
| }); |
| }); |
|
|
| document.getElementById('novelContent1').addEventListener('keydown', handleKeyPress); |
|
|
| document.querySelectorAll('[data-modal-text]').forEach(element => { |
| element.addEventListener('click', function () { |
| document.querySelectorAll(".modal-text").forEach(el => { |
| el.classList.add("d-none"); |
| if (el.classList.contains(this.getAttribute('data-modal-text'))) { |
| el.classList.remove("d-none"); |
| } |
| }); |
| }); |
| }); |
|
|
| syncInputs(); |
|
|
| |
| setInterval(() => { |
| saveToUserStorage(); |
| generateIndexMenu(true); |
| updateAllAccordionHeaderCounts(); |
| }, 60000); |
|
|
| |
| const basicSettingsAccordion = document.querySelector('#promptsCollapse'); |
| if (basicSettingsAccordion) { |
| new bootstrap.Collapse(basicSettingsAccordion).show(); |
| } |
|
|
| |
| document.getElementById('prevAccordion').addEventListener('click', openPreviousAccordion); |
| document.getElementById('nextAccordion').addEventListener('click', openNextAccordion); |
|
|
| |
| document.getElementById('endpointSelect').addEventListener('change', updateNavbarBrand); |
|
|
| |
| updateNavbarBrand(); |
| |
| updateAllAccordionHeaderCounts(); |
|
|
| |
| const script = document.createElement('script'); |
| script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jsdiff/5.1.0/diff.min.js'; |
| document.head.appendChild(script); |
| }); |
|
|