LLMClient / gemini.js
SenY's picture
Upload 2 files
b6623da verified
let lastSaveTimestamp = 0;
let controller;
let lastTokenUpdateTimestamp = 0;
let summeries = {};
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-1.5-flash-002: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: 0.7, max_output_tokens: 256 }
})
};
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';
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) {
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;
}
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).forEach(key => {
const elem = document.getElementById(key);
if (elem) {
if (elem.type === 'checkbox') {
elem.checked = geminiClientData[key];
} else {
elem.value = geminiClientData[key];
}
// 特別な処理が必要な要素
if (key === 'characterCount' || key === 'encodeLength' || key === 'contentWidth') {
const inputElem = document.getElementById(`${key}Input`);
if (inputElem) {
inputElem.value = geminiClientData[key];
}
}
} else {
console.debug(`要素が見つかりません: ${key}`);
}
});
}
}
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)} ${document.getElementById('characterCountInput').value}文字程度。${systemPrompt}`;
let messages = [
{
"role": "user",
"parts": [{ "text": "." }]
},
{
"role": "model",
"parts": [{ "text": partialEncodeURI(lines.join("\n")) }]
},
{
"role": "user",
"parts": [{ "text": prompt }]
}
];
return {
method: 'POST',
headers: {},
body: JSON.stringify({
contents: messages,
"generationConfig": {
"temperature": 1.0,
"max_output_tokens": 4096
},
safetySettings: [
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_NONE"
}
]
}),
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);
// バッファから完全なJSONオブジェクトを抽出して処理
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;
}
});
}
// finishReasonとblockReasonをチェック
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 lastPart = lines.pop() || '';
let messages = [
{
"content": document.getElementById('generatePrompt').value || ".",
"role": "system"
},
{
"content": ".",
"role": "user"
},
{
"content": partialEncodeURI(lines.join("\n")) || ".",
"role": "assistant"
},
{
"content": `続きを${document.getElementById('characterCountInput').value}文字程度で書いてください。${partialEncodeURI(document.getElementById('nextPrompt').value)}`,
"role": "user"
},
{
"content": lastPart,
"role": "assistant"
}
];
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', // CORSモードを追加
credentials: 'same-origin' // 必要に応じて認証情報を含める
};
}
function fetchOpenAIStream(ENDPOINT, payload) {
const novelContent2 = document.getElementById('novelContent2');
updateRequestButtonState('generating');
controller = new AbortController();
const signal = controller.signal;
fetch(ENDPOINT, {
...payload,
signal,
mode: 'cors', // CORSモードを追加
credentials: 'same-origin' // 必要に応じて認証情報を含める
})
.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 response = await fetch(ENDPOINT, {
...payload,
mode: 'cors',
credentials: 'same-origin'
});
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);
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;
}
}
async function createDraft() {
const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-002: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() {
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 = createPayload();
} else if (selectedEndpoint === 'restart') {
ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro-002: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 = `# 下書き\n${draft}\n\n# リライト\n`;
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": 4096
},
safetySettings: [
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_NONE"
}
]
}),
mode: 'cors'
};
selectedEndpoint = 'models/gemini-1.5-pro-002';
} 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');
// content1の最後の行とcontent2の先頭行が完全に一致する場合、content2から削除
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();
}
}
}
// content2の内容をcontent1の末尾に追加
content1.value = content1Lines.join('\n') + '\n' + content2Lines.join('\n');
// content2を空にする
content2.value = '';
// content1Collapseを開く
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'; // Googleブルー
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() {
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'; // Font Awesomeのプラスアイコン
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 = '目次がありません';
}
updateAllAccordionHeaderCounts();
updateTokenCount(false);
}
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);
};
// ボタンをコンテナに追加
buttonContainer.appendChild(summaryButton);
buttonContainer.appendChild(deleteSummaryButton);
// 要素の追加
li.appendChild(textarea);
li.appendChild(summaryInput);
li.appendChild(buttonContainer);
ul.appendChild(li);
}
function scrollToHeading(headingText) {
const content = document.getElementById('novelContent1');
const lines = content.value.split('\n');
let position = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith('#') && lines[i].includes(headingText)) {
// アコーディオンを開く
openAccordionContainingPosition(position);
content.focus();
content.setSelectionRange(position, position);
content.scrollTop = content.scrollHeight * (position / content.value.length);
break;
}
position += lines[i].length + 1; // +1 for newline character
}
// Offcanvasを閉じる
const indexOffcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('indexOffcanvas'));
}
function openAccordionContainingPosition(position) {
const content = document.getElementById('novelContent1');
const accordionItems = document.querySelectorAll('#mainAccordion .accordion-item');
let currentPosition = 0;
for (let i = 0; i < accordionItems.length; i++) {
const textArea = accordionItems[i].querySelector('textarea');
if (textArea && textArea.id === 'novelContent1') {
if (position >= currentPosition && position < currentPosition + textArea.value.length) {
// このアコーディオンアイテムに目的の位置が含まれている
const collapseElement = accordionItems[i].querySelector('.accordion-collapse');
const bsCollapse = new bootstrap.Collapse(collapseElement, { toggle: false });
bsCollapse.show();
break;
}
currentPosition += textArea.value.length;
}
}
}
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);
}
document.addEventListener('DOMContentLoaded', function () {
// ページ読み込み時にデータを復元
loadFromUserStorage();
// メイン画面の要素のイベントリスナー。inputイベントが発生する頻度が非常に高いのでこちらの発動は60秒に1回に制限する
['novelContent1', 'novelContent2', 'generatePrompt', 'nextPrompt', 'savedTitle'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
saveToUserStorage(false);
generateIndexMenu();
});
});
// 設定画面の要素のイベントリスナー
['memo', 'geminiApiKey', 'endpointSelect', 'openaiEndpoint', 'openaiHeaders', 'openaiJsonBody', 'characterCount', 'encodeLength'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
saveToUserStorage(true);
generateIndexMenu();
});
});
['partialEncodeToggle', 'streamToggle'].forEach(id => {
document.getElementById(id).addEventListener('change', () => {
saveToUserStorage(true);
generateIndexMenu();
});
});
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();
// 60秒ごとに自動保存実行
setInterval(() => {
saveToUserStorage();
generateIndexMenu();
updateTokenCount(true); // 強制的に更新
}, 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);
// エンドポイント選択が変更されたときにnavbar-brandを更新
document.getElementById('endpointSelect').addEventListener('change', updateNavbarBrand);
// 初期表示時にも実行
updateNavbarBrand();
generateIndexMenu();
updateAllAccordionHeaderCounts();
updateTokenCount(true); // 強制的に更新
});