SenY commited on
Commit
4f68bf2
·
1 Parent(s): 576688b
Files changed (4) hide show
  1. README.md +4 -4
  2. gemini.js +1369 -0
  3. index.html +434 -19
  4. util/index.html +406 -0
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: Llmclient
3
- emoji: 🐢
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: static
7
  pinned: false
8
  license: other
 
1
  ---
2
+ title: llmclient
3
+ emoji: 🐨
4
+ colorFrom: pink
5
+ colorTo: purple
6
  sdk: static
7
  pinned: false
8
  license: other
gemini.js ADDED
@@ -0,0 +1,1369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let lastSaveTimestamp = 0;
2
+ let controller;
3
+ let lastTokenUpdateTimestamp = 0;
4
+ let summeries = {};
5
+ let lastIndexUpdateTimestamp = 0;
6
+
7
+ let replaceProofReadHistory = [];
8
+
9
+ // 画像アップロード関連の機能
10
+ let imageCounter = 0;
11
+
12
+ function addImageInput() {
13
+ const container = document.getElementById('imageInputsContainer');
14
+ const imageInputGroup = document.createElement('div');
15
+ imageInputGroup.className = 'input-group mb-2';
16
+ imageInputGroup.id = `imageGroup_${imageCounter}`;
17
+
18
+ const input = document.createElement('input');
19
+ input.type = 'file';
20
+ input.className = 'form-control';
21
+ input.accept = 'image/*';
22
+ input.id = `imageInput_${imageCounter}`;
23
+
24
+ const deleteButton = document.createElement('button');
25
+ deleteButton.className = 'btn btn-outline-danger';
26
+ deleteButton.innerHTML = '<i class="fas fa-trash"></i>';
27
+ deleteButton.onclick = function() {
28
+ removeImageInput(this.parentElement);
29
+ };
30
+
31
+ imageInputGroup.appendChild(input);
32
+ imageInputGroup.appendChild(deleteButton);
33
+ container.appendChild(imageInputGroup);
34
+
35
+ imageCounter++;
36
+ }
37
+
38
+ function removeImageInput(element) {
39
+ if (element) {
40
+ element.remove();
41
+ }
42
+ }
43
+
44
+ function getAttachedImages() {
45
+ const images = [];
46
+ const container = document.getElementById('imageInputsContainer');
47
+ const inputs = container.getElementsByTagName('input');
48
+
49
+ for (let input of inputs) {
50
+ if (input.files && input.files[0]) {
51
+ images.push(input.files[0]);
52
+ }
53
+ }
54
+
55
+ return images;
56
+ }
57
+
58
+ function replaceProofRead(textarea, proofReadText) {
59
+ let novelContent1TextLines = document.getElementById("novelContent1").value.split("\n");
60
+ let proofReadTextLines = proofReadText.split("\n");
61
+ let textareaTextLines = textarea.value.split("\n");
62
+ let start = novelContent1TextLines.indexOf(textareaTextLines[0]);
63
+ let end = novelContent1TextLines.indexOf(textareaTextLines[textareaTextLines.length - 1]);
64
+ console.log(start, end);
65
+
66
+ // 差し替え前のテキストを保存
67
+ let originalText = novelContent1TextLines.slice(start, end + 1);
68
+ replaceProofReadHistory.push([originalText, proofReadTextLines]);
69
+
70
+ // novelContent1TextLinesから該当部分を削除し、proofReadTextLinesを挿入
71
+ novelContent1TextLines.splice(start, end - start + 1, ...proofReadTextLines);
72
+
73
+ // 更新された内容をnovelContent1に反映
74
+ document.getElementById("novelContent1").value = novelContent1TextLines.join("\n");
75
+
76
+ // textareaの内容も更新
77
+ textarea.value = proofReadTextLines.join("\n");
78
+
79
+ console.log("校正が完了しました。");
80
+ }
81
+
82
+
83
+ async function getModelList() {
84
+ const url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' + document.getElementById('geminiApiKey').value;
85
+ const response = await fetch(url);
86
+ const data = await response.json();
87
+ return data.models
88
+ .filter(x => x.supportedGenerationMethods.includes("generateContent"))
89
+ .filter(x => {
90
+ return x.name.match(/^models\/gemini/);
91
+ });
92
+ }
93
+
94
+ function getSafetySettings(endpoint) {
95
+ const threshold = endpoint.startsWith('models/model-exp-') ? 'OFF' : 'BLOCK_NONE';
96
+ return [
97
+ {
98
+ "category": "HARM_CATEGORY_HATE_SPEECH",
99
+ "threshold": threshold
100
+ },
101
+ {
102
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
103
+ "threshold": threshold
104
+ },
105
+ {
106
+ "category": "HARM_CATEGORY_HARASSMENT",
107
+ "threshold": threshold
108
+ },
109
+ {
110
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
111
+ "threshold": threshold
112
+ }
113
+ ];
114
+ }
115
+
116
+ async function proofRead(textarea) {
117
+ let content = textarea.value;
118
+ const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${document.getElementById('geminiApiKey').value}`;
119
+ let prompt = `以下の文章を校正してください。文法がおかしい、誤字脱字、冗長な表現などを校正するのみに留め、内容は一切変更しないでください。\n\n${content}`;
120
+ const payload = {
121
+ method: 'POST',
122
+ headers: {},
123
+ body: JSON.stringify({
124
+ contents: [{ parts: [{ text: prompt }] }],
125
+ generationConfig: {
126
+ "temperature": parseFloat(document.getElementById('temperature').value),
127
+ "max_output_tokens": 8192
128
+ },
129
+ safetySettings: getSafetySettings('models/gemini-2.0-flash-exp')
130
+ })
131
+ };
132
+ let proofReadText;
133
+ const response = await fetch(ENDPOINT, payload);
134
+ try {
135
+ const data = await response.json();
136
+ proofReadText = data.candidates[0].content.parts[0].text;
137
+ } catch (error) {
138
+ console.error('校正エラー:', error);
139
+ return '';
140
+ }
141
+ if (proofReadText) {
142
+ return replaceProofRead(textarea, proofReadText);
143
+ } else {
144
+ return '';
145
+ }
146
+ }
147
+
148
+
149
+ function formatText() {
150
+ const textOrg = document.getElementById('novelContent1').value;
151
+ let text = textOrg.replace(/[」。)]/g, '$&\n');
152
+ while (text.includes('\n\n')) {
153
+ text = text.replace(/\n\n/g, '\n');
154
+ }
155
+ text = text.replace(/「([^」\n]*)\n([^」\n]*)」/g, '「$1$2」');
156
+ text = text.replace(/(([^)\n]*)\n([^)\n]*))/g, '($1$2)');
157
+
158
+ while (text.search(/「[^「\n]*。\n/) >= 0) {
159
+ text = text.replace(/「([^「\n]*。)\n/, '「$1');
160
+ }
161
+
162
+ text = text.replace(/\n/g, "\n\n");
163
+ text = text.replace(/\n#/g, "\n\n#");
164
+
165
+ document.getElementById('novelContent1').value = text;
166
+ }
167
+
168
+ function unmalform(text) {
169
+ let result = null;
170
+ while (!result && text) {
171
+ try {
172
+ result = decodeURI(text);
173
+ } catch (error) {
174
+ text = text.slice(0, -1);
175
+ }
176
+ }
177
+ return result || '';
178
+ }
179
+
180
+ async function summerize(text) {
181
+ const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${document.getElementById('geminiApiKey').value}`;
182
+ const prompt = `以下の文章を240文字程度に要約してください:\n\n${text}`;
183
+ const payload = {
184
+ method: 'POST',
185
+ headers: {},
186
+ body: JSON.stringify({
187
+ contents: [{ parts: [{ text: prompt }] }],
188
+ generationConfig: {
189
+ temperature: parseFloat(document.getElementById('temperature').value),
190
+ max_output_tokens: 512
191
+ },
192
+ safetySettings: getSafetySettings('models/gemini-2.0-flash-exp')
193
+ })
194
+ };
195
+ try {
196
+ const response = await fetch(ENDPOINT, payload);
197
+ const data = await response.json();
198
+ return data.candidates[0].content.parts[0].text;
199
+ } catch (error) {
200
+ console.error('要約エラー:', error);
201
+ return '';
202
+ }
203
+ }
204
+
205
+ function partialEncodeURI(text) {
206
+ if (!document.getElementById("partialEncodeToggle").checked) {
207
+ return text;
208
+ }
209
+ let length = parseInt(document.getElementById("encodeLength").value);
210
+ const chunks = [];
211
+ for (let i = 0; i < text.length; i += 1) {
212
+ chunks.push(text.slice(i, i + 1));
213
+ }
214
+ const encodedChunks = chunks.map((chunk, index) => {
215
+ if (index % length === 0) {
216
+ return encodeURI(chunk);
217
+ }
218
+ return chunk;
219
+ });
220
+ const result = encodedChunks.join('');
221
+ return result;
222
+ }
223
+
224
+ function saveToJson() {
225
+ const novelContent1 = document.getElementById('novelContent1').value;
226
+ const novelContent2 = document.getElementById('novelContent2').value;
227
+ const generatePrompt = document.getElementById('generatePrompt').value;
228
+ const nextPrompt = document.getElementById('nextPrompt').value;
229
+ const savedTitle = document.getElementById('savedTitle').value;
230
+ const jsonData = JSON.stringify({
231
+ novelContent1: novelContent1,
232
+ novelContent2: novelContent2,
233
+ generatePrompt: generatePrompt,
234
+ nextPrompt: nextPrompt,
235
+ savedTitle: savedTitle
236
+ });
237
+ const blob = new Blob([jsonData], { type: 'application/json' });
238
+ const url = URL.createObjectURL(blob);
239
+ const a = document.createElement('a');
240
+ a.href = url;
241
+ a.download = 'novel_data.json';
242
+ if (savedTitle) {
243
+ a.download = savedTitle + '.json';
244
+ }
245
+ document.body.appendChild(a);
246
+ a.click();
247
+ document.body.removeChild(a);
248
+ URL.revokeObjectURL(url);
249
+ }
250
+
251
+ function loadFromJson() {
252
+ const fileInput = document.createElement('input');
253
+ fileInput.type = 'file';
254
+ fileInput.accept = '.json,.txt';
255
+ fileInput.style.display = 'none';
256
+ document.body.appendChild(fileInput);
257
+ fileInput.addEventListener('change', function (event) {
258
+ const file = event.target.files[0];
259
+ if (file) {
260
+ const reader = new FileReader();
261
+ reader.onload = function (e) {
262
+ if (file.name.endsWith('.txt')) {
263
+ document.getElementById('novelContent1').value = e.target.result;
264
+ alert('テキストファイルを正常に読み込みました');
265
+ } else {
266
+ try {
267
+ const jsonData = JSON.parse(e.target.result);
268
+ if (jsonData.novelContent1) {
269
+ document.getElementById('novelContent1').value = jsonData.novelContent1;
270
+ }
271
+ if (jsonData.novelContent2) {
272
+ document.getElementById('novelContent2').value = jsonData.novelContent2;
273
+ }
274
+ if (jsonData.generatePrompt) {
275
+ document.getElementById('generatePrompt').value = jsonData.generatePrompt;
276
+ }
277
+ if (jsonData.nextPrompt) {
278
+ document.getElementById('nextPrompt').value = jsonData.nextPrompt;
279
+ }
280
+ if (jsonData.savedTitle) {
281
+ document.getElementById('savedTitle').value = jsonData.savedTitle;
282
+ }
283
+ generateIndexMenu(true);
284
+ alert('JSONファイルを正常に読み込みました');
285
+ } catch (error) {
286
+ alert('無効なJSONファイルです。');
287
+ }
288
+ }
289
+ };
290
+ reader.readAsText(file);
291
+ }
292
+ });
293
+ fileInput.click();
294
+ }
295
+
296
+ function saveToUserStorage(force = false) {
297
+ const currentTime = Date.now();
298
+ if (currentTime - lastSaveTimestamp < 5000 && !force) {
299
+ console.debug('セーブをスキップします');
300
+ return;
301
+ }
302
+ console.debug('セーブを実行します');
303
+
304
+ // 既存のデータを取得
305
+ const geminiClientData = JSON.parse(localStorage.getItem('geminiClient') || '{}');
306
+
307
+ const newData = {};
308
+ Array.from(document.querySelectorAll("input[id], textarea[id], select[id]")).forEach(el => {
309
+ if (el.id) {
310
+ newData[el.id] = el.type === 'checkbox' ? el.checked : el.value;
311
+ }
312
+ });
313
+ Object.assign(geminiClientData, newData);
314
+ console.log(geminiClientData);
315
+ localStorage.setItem('geminiClient', JSON.stringify(geminiClientData));
316
+ lastSaveTimestamp = currentTime;
317
+ }
318
+
319
+ function loadFromUserStorage() {
320
+ const savedData = localStorage.getItem('geminiClient');
321
+ if (savedData) {
322
+ const geminiClientData = JSON.parse(savedData);
323
+ Object.keys(geminiClientData).filter(key => key != "endpointSelect").forEach(key => {
324
+ const elem = document.getElementById(key);
325
+ if (elem) {
326
+ if (elem.type === 'checkbox') {
327
+ elem.checked = geminiClientData[key];
328
+ } else {
329
+ elem.value = geminiClientData[key];
330
+ }
331
+
332
+ // 特別な処理が必要な要素
333
+ if (key === 'encodeLength' || key === 'contentWidth') {
334
+ const inputElem = document.getElementById(`${key}Input`);
335
+ if (inputElem) {
336
+ inputElem.value = geminiClientData[key];
337
+ }
338
+ }
339
+ } else {
340
+ console.debug(`要素が見つかりません: ${key}`);
341
+ }
342
+ });
343
+ getModelList().then(models => {
344
+ const endpointSelect = document.getElementById('endpointSelect');
345
+ //endpointSelect.innerHTML = '';
346
+ models.forEach(model => {
347
+ const option = document.createElement('option');
348
+ option.value = model.name;
349
+ option.textContent = model.name;
350
+ endpointSelect.appendChild(option);
351
+ });
352
+ endpointSelect.value = geminiClientData.endpointSelect;
353
+ });
354
+ }
355
+ }
356
+
357
+ function createSummarizedText() {
358
+ const indexOffcanvasBody = document.querySelector('#indexOffcanvas .offcanvas-body');
359
+ const rootUl = indexOffcanvasBody.querySelector('ul.list-unstyled');
360
+ let summarizedText = '';
361
+
362
+ function processUl(ul, level = 0) {
363
+ const items = ul.children;
364
+ for (let item of items) {
365
+ const a = item.querySelector(':scope > a');
366
+ if (a) {
367
+ summarizedText += '#'.repeat(level + 1) + ' ' + a.textContent + '\n';
368
+ }
369
+
370
+ const contentItem = item.querySelector(':scope > ul > li');
371
+ if (contentItem) {
372
+ const fullText = contentItem.querySelector('.full-text');
373
+ const summaryText = contentItem.querySelector('.summery-text');
374
+ if (summaryText && summaryText.value.trim()) {
375
+ summarizedText += summaryText.value + '\n\n';
376
+ } else if (fullText) {
377
+ summarizedText += fullText.value + '\n\n';
378
+ }
379
+ }
380
+
381
+ const subUl = item.querySelector(':scope > ul');
382
+ if (subUl) {
383
+ processUl(subUl, level + 1);
384
+ }
385
+ }
386
+ }
387
+
388
+ if (rootUl) {
389
+ processUl(rootUl);
390
+ }
391
+ if (summarizedText) {
392
+ return summarizedText.trim();
393
+ } else {
394
+ return document.getElementById('novelContent1').value;
395
+ }
396
+ }
397
+
398
+ function createPayload() {
399
+ const novelContent1 = document.getElementById('novelContent1');
400
+ let text = novelContent1.value;
401
+ if (document.getElementById('summerizedPromptToggle').checked) {
402
+ text = createSummarizedText();
403
+ }
404
+ const lines = text.split('\n').filter(x => x);
405
+
406
+ let systemPrompt = `${partialEncodeURI(document.getElementById('generatePrompt').value)}`;
407
+ let prompt = `続きを書いて。${partialEncodeURI(document.getElementById('nextPrompt').value)} ${systemPrompt}`;
408
+
409
+ // 画像の取得と変換
410
+ const attachedImages = getAttachedImages();
411
+ const imagePromises = attachedImages.map(file => {
412
+ return new Promise((resolve, reject) => {
413
+ const reader = new FileReader();
414
+ reader.onload = () => {
415
+ // Base64エンコードされた画像データを取得("data:image/jpeg;base64,"などのプレフィックスを除去)
416
+ const base64Data = reader.result.split(',')[1];
417
+ resolve({
418
+ inline_data: {
419
+ data: base64Data,
420
+ mime_type: file.type
421
+ }
422
+ });
423
+ };
424
+ reader.onerror = reject;
425
+ reader.readAsDataURL(file);
426
+ });
427
+ });
428
+
429
+ // 画像の処理が完了してからペイロードを作成
430
+ return Promise.all(imagePromises).then(imageParts => {
431
+ let messages = [
432
+ {
433
+ "role": "user",
434
+ "parts": [{ "text": "." }]
435
+ },
436
+ {
437
+ "role": "model",
438
+ "parts": [{ "text": partialEncodeURI(lines.join("\n")) }]
439
+ },
440
+ {
441
+ "role": "user",
442
+ "parts": [
443
+ { "text": prompt },
444
+ ...imageParts
445
+ ]
446
+ }
447
+ ];
448
+
449
+ let max_output_tokens = 4096;
450
+ let selectedEndpoint = document.getElementById('endpointSelect').value;
451
+ if (selectedEndpoint.match(/^models\/gemini-2\.0/)) {
452
+ max_output_tokens = 8192;
453
+ }
454
+ if (selectedEndpoint.match(/^models\/gemini-2\.0-flash-thinking/)) {
455
+ max_output_tokens = 65536;
456
+ }
457
+
458
+ return {
459
+ method: 'POST',
460
+ headers: {},
461
+ body: JSON.stringify({
462
+ contents: messages,
463
+ generationConfig: {
464
+ "temperature": parseFloat(document.getElementById('temperature').value),
465
+ "max_output_tokens": max_output_tokens
466
+ },
467
+ safetySettings: getSafetySettings(selectedEndpoint)
468
+ }),
469
+ mode: 'cors'
470
+ };
471
+ });
472
+ }
473
+
474
+ function debugPrompt() {
475
+ console.log({
476
+ "gemini": JSON.parse(createPayload().body),
477
+ "openai": JSON.parse(createOpenAIPayload().body)
478
+ });
479
+ }
480
+
481
+ // 新しい関数を追加
482
+ function updateRequestButtonState(state, flashClass = null) {
483
+ const requestButton = document.getElementById('requestButton');
484
+ const stopButton = document.getElementById('stopButton');
485
+
486
+ switch (state) {
487
+ case 'generating':
488
+ requestButton.disabled = true;
489
+ stopButton.classList.remove('d-none');
490
+ break;
491
+ case 'idle':
492
+ requestButton.disabled = false;
493
+ stopButton.classList.add('d-none');
494
+ break;
495
+ case 'error':
496
+ requestButton.disabled = false;
497
+ stopButton.classList.add('d-none');
498
+ break;
499
+ }
500
+
501
+ if (flashClass) {
502
+ requestButton.classList.add(flashClass);
503
+ setTimeout(() => {
504
+ requestButton.classList.remove(flashClass);
505
+ }, 2000);
506
+ }
507
+ }
508
+
509
+ function fetchStream(ENDPOINT, payload) {
510
+ const novelContent2 = document.getElementById('novelContent2');
511
+ updateRequestButtonState('generating');
512
+ controller = new AbortController();
513
+ const signal = controller.signal;
514
+
515
+ fetch(ENDPOINT, { ...payload, signal })
516
+ .then(response => {
517
+ if (!response.ok) {
518
+ throw new Error('ネットワークの応答が正常ではありません');
519
+ }
520
+ const reader = response.body.getReader();
521
+ const decoder = new TextDecoder();
522
+ let buffer = '';
523
+
524
+ function readStream() {
525
+ reader.read().then(({ done, value }) => {
526
+ if (done) {
527
+ console.debug('ストリームが完了しました');
528
+ document.getElementById('stopButton').classList.add('d-none');
529
+ requestButton.disabled = false;
530
+ return;
531
+ }
532
+
533
+ const chunk = decoder.decode(value, { stream: true });
534
+ buffer += chunk;
535
+ console.debug('チャンクを受信しまし:', chunk);
536
+
537
+ // バッファから完全なJSONオブジェクトを抽出して処理
538
+ let startIndex = 0;
539
+ while (true) {
540
+ const endIndex = buffer.indexOf('\n', startIndex);
541
+ if (endIndex === -1) break;
542
+
543
+ const line = buffer.slice(startIndex, endIndex).trim();
544
+ startIndex = endIndex + 1;
545
+
546
+ if (line.startsWith('data: ')) {
547
+ const jsonString = line.slice(5);
548
+ if (jsonString === '[DONE]') {
549
+ console.debug('Received [DONE] signal');
550
+ break;
551
+ }
552
+ try {
553
+ const data = JSON.parse(jsonString);
554
+ console.debug('解析されたJSON:', data);
555
+ if (data.candidates && data.candidates[0].content && data.candidates[0].content.parts) {
556
+ data.candidates[0].content.parts.forEach(part => {
557
+ if (part.text) {
558
+ console.debug('出力にテキストを追加:', part.text);
559
+ novelContent2.value += part.text;
560
+ novelContent2.scrollTop = novelContent2.scrollHeight;
561
+ }
562
+ });
563
+ }
564
+ // finishReasonとblockReasonをチェック
565
+ if (data.candidates && data.candidates[0]) {
566
+ if (data.candidates[0].finishReason) {
567
+ if (data.candidates[0].finishReason === 'STOP') {
568
+ requestButton.classList.add('green-flash-bg');
569
+ setTimeout(() => {
570
+ requestButton.classList.remove('green-flash-bg');
571
+ }, 2000);
572
+ } else {
573
+ requestButton.classList.add('red-flash-bg');
574
+ setTimeout(() => {
575
+ requestButton.classList.remove('red-flash-bg');
576
+ }, 2000);
577
+ }
578
+ }
579
+ if (data.candidates[0].blockReason) {
580
+ requestButton.classList.add('red-flash-bg');
581
+ setTimeout(() => {
582
+ requestButton.classList.remove('red-flash-bg');
583
+ }, 2000);
584
+ }
585
+ }
586
+ } catch (error) {
587
+ console.error('JSONパースエラー:', error);
588
+ }
589
+ }
590
+ }
591
+
592
+ // 処理済みの部分をバッファから削除
593
+ buffer = buffer.slice(startIndex);
594
+
595
+ readStream();
596
+ }).catch(error => {
597
+ if (error.name === 'AbortError') {
598
+ console.log('フェッチがユーザーによって中止されました');
599
+ updateRequestButtonState('idle');
600
+ } else {
601
+ console.error('ストリーム読み取りエラー:', error);
602
+ updateRequestButtonState('error', 'red-flash-bg');
603
+ }
604
+ });
605
+ }
606
+
607
+ readStream();
608
+ })
609
+ .catch(error => {
610
+ if (error.name === 'AbortError') {
611
+ console.log('フェッチがユーザーよって中止されました');
612
+ updateRequestButtonState('idle');
613
+ } else {
614
+ console.error('フェッチエラー:', error);
615
+ updateRequestButtonState('error', 'red-flash-bg');
616
+ }
617
+ });
618
+ }
619
+
620
+ async function fetchNonStream(ENDPOINT, payload) {
621
+ const novelContent2 = document.getElementById('novelContent2');
622
+ updateRequestButtonState('generating');
623
+ try {
624
+ const response = await fetch(ENDPOINT, payload);
625
+ const data = await response.json();
626
+ if (data && data.candidates && data.candidates[0].content && data.candidates[0].content.parts && data.candidates[0].content.parts[0].text) {
627
+ novelContent2.value += data.candidates[0].content.parts[0].text;
628
+ novelContent2.scrollTop = novelContent2.scrollHeight;
629
+ updateRequestButtonState('idle', 'green-flash-bg');
630
+ } else {
631
+ throw new Error('予期しないレスポンス形式');
632
+ }
633
+ } catch (error) {
634
+ console.error('エラー:', error);
635
+ updateRequestButtonState('error', 'red-flash-bg');
636
+ }
637
+ }
638
+
639
+ function createOpenAIPayload() {
640
+ const novelContent1 = document.getElementById('novelContent1');
641
+ const text = novelContent1.value;
642
+ const lines = text.split('\n').filter(x => x);
643
+
644
+ let messages = [
645
+ {
646
+ "content": document.getElementById('generatePrompt').value || ".",
647
+ "role": "system"
648
+ },
649
+ {
650
+ "content": ".",
651
+ "role": "user"
652
+ },
653
+ {
654
+ "content": partialEncodeURI(lines.join("\n")) || ".",
655
+ "role": "assistant"
656
+ },
657
+ {
658
+ "content": `${partialEncodeURI(document.getElementById('nextPrompt').value)}`,
659
+ "role": "user"
660
+ }
661
+ ];
662
+
663
+ let jsonBody = JSON.parse(document.getElementById('openaiJsonBody').value);
664
+ jsonBody.messages = messages;
665
+ jsonBody.stream = document.getElementById('streamToggle').checked;
666
+
667
+ return {
668
+ method: 'POST',
669
+ headers: JSON.parse(document.getElementById('openaiHeaders').value),
670
+ body: JSON.stringify(jsonBody),
671
+ mode: 'cors',
672
+ credentials: 'same-origin'
673
+ };
674
+ }
675
+
676
+ function fetchOpenAIStream(ENDPOINT, payload) {
677
+ const novelContent2 = document.getElementById('novelContent2');
678
+ updateRequestButtonState('generating');
679
+ controller = new AbortController();
680
+ const signal = controller.signal;
681
+ payload.signal = signal;
682
+ fetch(ENDPOINT, payload)
683
+ .then(response => {
684
+ if (!response.ok) {
685
+ throw new Error('ネットワークの応答が常ではありません');
686
+ }
687
+ const reader = response.body.getReader();
688
+ const decoder = new TextDecoder();
689
+ let buffer = '';
690
+
691
+ function readStream() {
692
+ reader.read().then(({ done, value }) => {
693
+ if (done) {
694
+ console.debug('ストリームが完了しました');
695
+ document.getElementById('stopButton').classList.add('d-none');
696
+ requestButton.disabled = false;
697
+ return;
698
+ }
699
+
700
+ const chunk = decoder.decode(value, { stream: true });
701
+ buffer += chunk;
702
+
703
+ const lines = buffer.split('\n');
704
+ buffer = lines.pop();
705
+
706
+ lines.forEach(line => {
707
+ if (line.startsWith('data: ')) {
708
+ const jsonString = line.slice(6);
709
+ if (jsonString === '[DONE]') {
710
+ console.debug('Received [DONE] signal');
711
+ return;
712
+ }
713
+ try {
714
+ const data = JSON.parse(jsonString);
715
+ if (data.choices && data.choices[0].delta && data.choices[0].delta.content) {
716
+ novelContent2.value += data.choices[0].delta.content;
717
+ novelContent2.scrollTop = novelContent2.scrollHeight;
718
+ }
719
+ } catch (error) {
720
+ console.error('JSONパースエラー:', error);
721
+ }
722
+ }
723
+ });
724
+
725
+ readStream();
726
+ }).catch(error => {
727
+ if (error.name === 'AbortError') {
728
+ console.log('フェッチがユーザーによって中止されました');
729
+ updateRequestButtonState('idle');
730
+ } else {
731
+ console.error('ストリーム読み取りエラー:', error);
732
+ updateRequestButtonState('error', 'red-flash-bg');
733
+ }
734
+ });
735
+ }
736
+
737
+ readStream();
738
+ })
739
+ .catch(error => {
740
+ if (error.name === 'AbortError') {
741
+ console.log('フェッチがユーザーよって中止されました');
742
+ updateRequestButtonState('idle');
743
+ } else {
744
+ console.error('フェッチエラー:', error);
745
+ updateRequestButtonState('error', 'red-flash-bg');
746
+ }
747
+ });
748
+ }
749
+
750
+ async function fetchOpenAINonStream(ENDPOINT, payload) {
751
+ const novelContent2 = document.getElementById('novelContent2');
752
+ updateRequestButtonState('generating');
753
+ try {
754
+ const signal = controller.signal;
755
+ payload.signal = signal;
756
+ const response = await fetch(ENDPOINT, payload);
757
+ const data = await response.json();
758
+ if (data && data.choices && data.choices[0].message && data.choices[0].message.content) {
759
+ novelContent2.value += data.choices[0].message.content;
760
+ novelContent2.scrollTop = novelContent2.scrollHeight;
761
+ updateRequestButtonState('idle', 'green-flash-bg');
762
+ } else {
763
+ throw new Error('予期しないレスポンス形式');
764
+ }
765
+ } catch (error) {
766
+ console.error('エラー:', error);
767
+ updateRequestButtonState('error', 'red-flash-bg');
768
+ }
769
+ }
770
+
771
+ async function tokenCount() {
772
+ const selectedEndpoint = document.getElementById('endpointSelect').value;
773
+ let payload = createPayload();
774
+ payload.body = {
775
+ "contents": JSON.parse(payload.body).contents
776
+ };
777
+ payload.body = JSON.stringify(payload.body);
778
+ if (selectedEndpoint.startsWith('models/gemini')) {
779
+ const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/${selectedEndpoint}:countTokens?key=` + document.getElementById('geminiApiKey').value;
780
+ try {
781
+ const response = await fetch(ENDPOINT, payload);
782
+ const data = await response.json();
783
+ return data.totalTokens;
784
+ } catch (error) {
785
+ console.error('エラー:', error);
786
+ return null;
787
+ }
788
+ } else {
789
+ return -1;
790
+ }
791
+
792
+ }
793
+
794
+ async function createDraft() {
795
+ const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=` + document.getElementById('geminiApiKey').value;
796
+ let payload = createPayload();
797
+ const response = await fetch(ENDPOINT, payload);
798
+ const data = await response.json();
799
+ const text = data.candidates[0].content.parts[0].text;
800
+ return text
801
+ }
802
+
803
+
804
+ async function Request() {
805
+ generateIndexMenu(true);
806
+ let selectedEndpoint = document.getElementById('endpointSelect').value;
807
+ const requestButton = document.getElementById('requestButton');
808
+ requestButton.disabled = true;
809
+ document.getElementById('novelContent2').value = '';
810
+ const outputAccordion = document.querySelector('#content2Collapse');
811
+ if (outputAccordion) {
812
+ const bsCollapse = new bootstrap.Collapse(outputAccordion, { toggle: false });
813
+ bsCollapse.show();
814
+ }
815
+
816
+ let ENDPOINT;
817
+ let payload;
818
+
819
+ if (selectedEndpoint.startsWith('models/gemini')) {
820
+ ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/${selectedEndpoint}:generateContent?key=` + document.getElementById('geminiApiKey').value;
821
+ payload = await createPayload(); // 非同期処理を待機
822
+ } else if (selectedEndpoint === 'restart') {
823
+ ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=` + document.getElementById('geminiApiKey').value;
824
+ document.getElementById('novelContent2').value = '(下書き中)';
825
+ try {
826
+ draft = await createDraft();
827
+ } catch (error) {
828
+ console.error('エラー:', error);
829
+ requestButton.disabled = false;
830
+ requestButton.classList.add('green-flash-bg');
831
+ setTimeout(() => {
832
+ requestButton.classList.remove('green-flash-bg');
833
+ }, 2000);
834
+ return;
835
+ }
836
+ //document.getElementById('novelContent2').value = `# 下書き\n${draft}\n\n# リライト\n`;
837
+ document.getElementById('novelContent2').value = "";
838
+ const prompt = `以下の文章を、要件を満たす形に直したものを返信してください。\n返信内容は直した後のテキストのみになるようにし他の内容が混ざらないようにしてください。\n\n# 要件\n- 元の文章や会話の言い回しは不自然な日本語になっている部分もかなり多いので、根本的に書き替えること。ただし、登場人物の一人称や他者への呼称は変更しないこと。\n- 物語の大筋やキャラクターの性格は保ったまま、文章は全く別物に書き換える必要がある。元のテキストそのものはなるべく流用しないで新規に書くよう心掛ける。\n- 随所にレトリックを駆使しつつも凝り過ぎて読み辛くなることのないシンプルな美文になることを意識する。\n- 登場人物の会話を重視し、文脈を読み取り元のキャラクター設定に合った台詞回しを保ちつつ、より生き生きとした魅力的な物像に仕上がるようにする。\n- 細かい動作や心理描写のディテールを重視し、よりリアルな描写になるようにする。\n- 文章の終わりに「。」をつける、字下げをするなど、一般的な小説のフォーマットに従う書き方にする。\n\n# 文章\n${draft}`;
839
+ payload = {
840
+ method: 'POST',
841
+ headers: {},
842
+ body: JSON.stringify({
843
+ contents: [
844
+ {
845
+ "parts": [
846
+ {
847
+ "text": prompt
848
+ }
849
+ ],
850
+ "role": "user"
851
+ }
852
+ ],
853
+ generationConfig: {
854
+ "temperature": 1.0,
855
+ "max_output_tokens": 8192
856
+ },
857
+ safetySettings: getSafetySettings('models/gemini-2.0-flash-exp')
858
+ }),
859
+ mode: 'cors'
860
+ };
861
+ selectedEndpoint = 'models/gemini-2.0-flash-exp';
862
+ } else {
863
+ ENDPOINT = document.getElementById('openaiEndpoint').value;
864
+ payload = createOpenAIPayload();
865
+ }
866
+
867
+ let stream = document.getElementById('streamToggle').checked;
868
+ if (stream) {
869
+ if (selectedEndpoint.startsWith('models/gemini')) {
870
+ ENDPOINT = ENDPOINT.replace(':generateContent', ':streamGenerateContent') + '&alt=sse';
871
+ fetchStream(ENDPOINT, payload);
872
+ } else {
873
+ fetchOpenAIStream(ENDPOINT, payload);
874
+ }
875
+ document.getElementById('stopButton').classList.remove('d-none');
876
+ } else {
877
+ if (selectedEndpoint.startsWith('models/gemini')) {
878
+ fetchNonStream(ENDPOINT, payload);
879
+ } else {
880
+ fetchOpenAINonStream(ENDPOINT, payload);
881
+ }
882
+ }
883
+ }
884
+
885
+ function stopGeneration() {
886
+ if (controller) {
887
+ controller.abort();
888
+ controller = null;
889
+ }
890
+ updateRequestButtonState('idle');
891
+ }
892
+
893
+ // 新しい関数を追加
894
+ function handleKeyPress(event) {
895
+ if (event.ctrlKey && event.key === 'Enter') {
896
+ Request();
897
+ }
898
+ }
899
+
900
+ function syncInputs() {
901
+ const inputs = document.querySelectorAll('input[type="range"], input[type="number"]');
902
+ inputs.forEach(input => {
903
+ const baseId = input.id.replace('Input', '');
904
+ const pairedInput = document.getElementById(baseId + (input.type === 'range' ? 'Input' : ''));
905
+
906
+ if (pairedInput) {
907
+ input.addEventListener('input', function () {
908
+ pairedInput.value = this.value;
909
+ });
910
+ }
911
+ });
912
+ }
913
+
914
+ function openNextAccordion() {
915
+ const accordions = document.querySelectorAll('#mainAccordion .accordion-item');
916
+ let currentIndex = -1;
917
+
918
+ // 現在開いているアコーディオンのインデックスを見つける
919
+ for (let i = 0; i < accordions.length; i++) {
920
+ if (!accordions[i].querySelector('.accordion-button').classList.contains('collapsed')) {
921
+ currentIndex = i;
922
+ break;
923
+ }
924
+ }
925
+
926
+ // 次のアコーディオンを開く
927
+ if (currentIndex < accordions.length - 1) {
928
+ new bootstrap.Collapse(accordions[currentIndex].querySelector('.accordion-collapse')).hide();
929
+ new bootstrap.Collapse(accordions[currentIndex + 1].querySelector('.accordion-collapse')).show();
930
+ } else {
931
+ // もう次がない場合、ボタンを赤く点滅させる
932
+ const nextButton = document.getElementById('nextAccordion');
933
+ nextButton.classList.add('red-flash-bg');
934
+ setTimeout(() => {
935
+ nextButton.classList.remove('red-flash-bg');
936
+ }, 2000);
937
+ }
938
+ }
939
+
940
+ function openPreviousAccordion() {
941
+ const accordions = document.querySelectorAll('#mainAccordion .accordion-item');
942
+ let currentIndex = -1;
943
+
944
+ // 現在開いているアコーディオンのインデックスを見つける
945
+ for (let i = 0; i < accordions.length; i++) {
946
+ if (!accordions[i].querySelector('.accordion-button').classList.contains('collapsed')) {
947
+ currentIndex = i;
948
+ break;
949
+ }
950
+ }
951
+
952
+ // 前のアコーディオンを開く
953
+ if (currentIndex > 0) {
954
+ new bootstrap.Collapse(accordions[currentIndex].querySelector('.accordion-collapse')).hide();
955
+ new bootstrap.Collapse(accordions[currentIndex - 1].querySelector('.accordion-collapse')).show();
956
+ } else {
957
+ // もう前がない場合、ボタンを赤く点滅させる
958
+ const prevButton = document.getElementById('prevAccordion');
959
+ prevButton.classList.add('red-flash-bg');
960
+ setTimeout(() => {
961
+ prevButton.classList.remove('red-flash-bg');
962
+ }, 2000);
963
+ }
964
+ }
965
+
966
+ function moveToInput() {
967
+ const content1 = document.getElementById('novelContent1');
968
+ const content2 = document.getElementById('novelContent2');
969
+
970
+ let content1Lines = content1.value.trim().split('\n');
971
+ let content2Lines = content2.value.trim().split('\n');
972
+
973
+ // content1の最後の行とcontent2の先頭行が完全に一致する場合、content2から削除
974
+ if (content1Lines[content1Lines.length - 1] === content2Lines[0]) {
975
+ content2Lines.shift();
976
+ } else {
977
+ // 分的な重複を検出して削除
978
+ const lastLine = content1Lines[content1Lines.length - 1];
979
+ const firstLine = content2Lines[0];
980
+ const overlapIndex = firstLine.indexOf(lastLine);
981
+ if (overlapIndex !== -1) {
982
+ content2Lines[0] = firstLine.slice(overlapIndex + lastLine.length).trim();
983
+ if (content2Lines[0] === '') {
984
+ content2Lines.shift();
985
+ }
986
+ }
987
+ }
988
+
989
+ // content2の内容をcontent1の末尾に追加
990
+ content1.value = content1Lines.join('\n') + '\n' + content2Lines.join('\n');
991
+
992
+ // content2を空にする
993
+ content2.value = '';
994
+
995
+ // content1Collapseを開く
996
+ const content1Collapse = new bootstrap.Collapse(document.getElementById('content1Collapse'), {
997
+ show: true
998
+ });
999
+ }
1000
+
1001
+ function updateNavbarBrand() {
1002
+ const endpointSelect = document.getElementById('endpointSelect');
1003
+ const navbarBrand = document.querySelector('.navbar-brand');
1004
+ const googleIcon = navbarBrand.querySelector('.fa-google');
1005
+ const robotIcon = navbarBrand.querySelector('.fa-robot');
1006
+
1007
+ if (endpointSelect.value.startsWith('models/gemini')) {
1008
+ navbarBrand.style.color = '#4285F4'; // Googleブルー
1009
+ googleIcon.classList.remove('d-none');
1010
+ robotIcon.classList.add('d-none');
1011
+ } else {
1012
+ navbarBrand.style.color = '#00FF00'; // 明るい緑色
1013
+ googleIcon.classList.add('d-none');
1014
+ robotIcon.classList.remove('d-none');
1015
+ }
1016
+ }
1017
+
1018
+ async function updateTokenCount(force = false) {
1019
+ const currentTime = Date.now();
1020
+ if (currentTime - lastTokenUpdateTimestamp < 60000 && !force) {
1021
+ console.debug('トークン数更新をスキップします');
1022
+ return;
1023
+ }
1024
+ console.debug('トークン数更新を実行します');
1025
+
1026
+ const count = await tokenCount();
1027
+ const indexOffcanvasLabel = document.getElementById('indexOffcanvasLabel');
1028
+ indexOffcanvasLabel.textContent = `目次 (${count}トークン)`;
1029
+ lastTokenUpdateTimestamp = currentTime;
1030
+ }
1031
+
1032
+ function generateIndexMenu(force = false) {
1033
+ const currentTime = Date.now();
1034
+ if (currentTime - lastIndexUpdateTimestamp < 60000 && !force) {
1035
+ console.debug('目次更新をスキップします');
1036
+ return;
1037
+ }
1038
+ console.debug('目次更新を実行します');
1039
+
1040
+ const content = document.getElementById('novelContent1').value;
1041
+ const tokens = marked.lexer(content);
1042
+ const indexOffcanvasBody = document.querySelector('#indexOffcanvas .offcanvas-body');
1043
+
1044
+ indexOffcanvasBody.innerHTML = '';
1045
+
1046
+ const rootUl = document.createElement('ul');
1047
+ rootUl.className = 'list-unstyled';
1048
+
1049
+ let stack = [{ ul: rootUl, level: 0 }];
1050
+ let lastHeading = null;
1051
+ let contentBuffer = '';
1052
+
1053
+ tokens.forEach((token, index) => {
1054
+ if (token.type === 'heading') {
1055
+ if (lastHeading && contentBuffer.trim()) {
1056
+ addTextarea(lastHeading.querySelector('ul'), contentBuffer.trim());
1057
+ }
1058
+ contentBuffer = '';
1059
+
1060
+ while (stack.length > 1 && stack[stack.length - 1].level >= token.depth) {
1061
+ stack.pop();
1062
+ }
1063
+
1064
+ const li = document.createElement('li');
1065
+ const toggleBtn = document.createElement('button');
1066
+ toggleBtn.className = 'btn btn-sm btn-outline-secondary me-2 toggle-btn';
1067
+ const icon = document.createElement('i');
1068
+ icon.className = 'fas fa-plus'; // Font Awesomeのプラスアイコン
1069
+ toggleBtn.appendChild(icon);
1070
+ toggleBtn.onclick = () => toggleSubMenu(li);
1071
+
1072
+ const a = document.createElement('a');
1073
+ a.href = '#';
1074
+ a.textContent = token.text;
1075
+ a.onclick = (e) => {
1076
+ e.preventDefault();
1077
+ scrollToHeading(token.text);
1078
+ };
1079
+
1080
+ li.appendChild(toggleBtn);
1081
+ li.appendChild(a);
1082
+
1083
+ const subUl = document.createElement('ul');
1084
+ subUl.className = 'list-unstyled ms-3 d-none';
1085
+ li.appendChild(subUl);
1086
+
1087
+ stack[stack.length - 1].ul.appendChild(li);
1088
+
1089
+ if (token.depth > stack[stack.length - 1].level) {
1090
+ stack.push({ ul: subUl, level: token.depth });
1091
+ }
1092
+
1093
+ lastHeading = li;
1094
+ } else if (token.type === 'text' || token.type === 'paragraph') {
1095
+ contentBuffer += token.text + '\n';
1096
+ }
1097
+ });
1098
+
1099
+ if (lastHeading && contentBuffer.trim()) {
1100
+ addTextarea(lastHeading.querySelector('ul'), contentBuffer.trim());
1101
+ }
1102
+
1103
+ if (rootUl.children.length > 0) {
1104
+ indexOffcanvasBody.appendChild(rootUl);
1105
+ } else {
1106
+ indexOffcanvasBody.textContent = '目次がありません';
1107
+ }
1108
+
1109
+ updateTokenCount(force);
1110
+ lastIndexUpdateTimestamp = currentTime;
1111
+ }
1112
+
1113
+ function toggleSubMenu(li) {
1114
+ const subUl = li.querySelector('ul');
1115
+ const toggleBtn = li.querySelector('.toggle-btn');
1116
+ const icon = toggleBtn.querySelector('i');
1117
+ subUl.classList.toggle('d-none');
1118
+ icon.className = subUl.classList.contains('d-none') ? 'fas fa-plus' : 'fas fa-minus';
1119
+ }
1120
+
1121
+ function addTextarea(ul, content) {
1122
+ const li = document.createElement('li');
1123
+
1124
+ // テキストエリアの作成
1125
+ const textarea = document.createElement('textarea');
1126
+ textarea.readOnly = true;
1127
+ textarea.className = 'form-control mt-2 full-text';
1128
+ textarea.value = content;
1129
+ textarea.rows = 3;
1130
+
1131
+ // 要約用のテキストエリアの作成
1132
+ const summaryInput = document.createElement('textarea');
1133
+ summaryInput.className = 'form-control mt-2 summery-text';
1134
+ summaryInput.placeholder = '要約';
1135
+ summaryInput.rows = 3;
1136
+ if (summeries[content]) {
1137
+ summaryInput.value = summeries[content];
1138
+ }
1139
+
1140
+ // ボタン用のコンテナ作成
1141
+ const buttonContainer = document.createElement('div');
1142
+ buttonContainer.className = 'mt-2';
1143
+
1144
+ // 要約取得ボタンの作成
1145
+ const summaryButton = document.createElement('button');
1146
+ summaryButton.textContent = '要約を取得';
1147
+ summaryButton.className = 'btn btn-secondary me-2';
1148
+ summaryButton.onclick = async () => {
1149
+ summaryButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Loading...';
1150
+ summaryButton.disabled = true;
1151
+ try {
1152
+ const summary = await summerize(content);
1153
+ summaryInput.value = summary;
1154
+ summeries[content] = summary;
1155
+ updateTokenCount(true);
1156
+ } finally {
1157
+ summaryButton.innerHTML = '要約を取得';
1158
+ summaryButton.disabled = false;
1159
+ }
1160
+ };
1161
+
1162
+ // 要約削除ボタンの作成
1163
+ const deleteSummaryButton = document.createElement('button');
1164
+ deleteSummaryButton.textContent = '要約を削除';
1165
+ deleteSummaryButton.className = 'btn btn-danger';
1166
+ deleteSummaryButton.onclick = () => {
1167
+ summaryInput.value = '';
1168
+ delete summeries[content];
1169
+ updateTokenCount(true);
1170
+ };
1171
+
1172
+ // 校正ボタンの作成
1173
+ const proofReadButton = document.createElement('button');
1174
+ proofReadButton.textContent = '校正';
1175
+ proofReadButton.className = 'btn btn-secondary me-2';
1176
+ proofReadButton.onclick = async () => {
1177
+ proofReadButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 校正中...';
1178
+ proofReadButton.disabled = true;
1179
+ try {
1180
+ await proofRead(textarea);
1181
+ } finally {
1182
+ proofReadButton.innerHTML = '校正';
1183
+ proofReadButton.disabled = false;
1184
+ }
1185
+ };
1186
+ const hr = document.createElement('hr');
1187
+
1188
+ // ボタンをコンテナに追加
1189
+ buttonContainer.appendChild(summaryButton);
1190
+ buttonContainer.appendChild(deleteSummaryButton);
1191
+ buttonContainer.appendChild(hr);
1192
+ buttonContainer.appendChild(proofReadButton);
1193
+ // 要素の追加
1194
+ li.appendChild(textarea);
1195
+ li.appendChild(summaryInput);
1196
+ li.appendChild(buttonContainer);
1197
+ ul.appendChild(li);
1198
+ }
1199
+
1200
+ function scrollToHeading(headingText) {
1201
+ const content1 = document.getElementById('novelContent1');
1202
+ const content1Collapse = document.getElementById('content1Collapse');
1203
+ const accordion = new bootstrap.Collapse(content1Collapse, { toggle: false });
1204
+
1205
+ // テキストエリアの内容を行ごとに分割
1206
+ const lines = content1.value.split('\n');
1207
+
1208
+ // 見出しテキストを含む行のインデックスを探す
1209
+ const headingIndex = lines.findIndex(line => line.includes(headingText));
1210
+
1211
+ if (headingIndex !== -1) {
1212
+ // 一時的な要素を作成してテキストエリアの内容をコピー
1213
+ const tempDiv = document.createElement('div');
1214
+ tempDiv.style.cssText = `
1215
+ position: absolute;
1216
+ top: -9999px;
1217
+ left: -9999px;
1218
+ width: ${content1.clientWidth}px;
1219
+ font-size: ${window.getComputedStyle(content1).fontSize};
1220
+ font-family: ${window.getComputedStyle(content1).fontFamily};
1221
+ line-height: ${window.getComputedStyle(content1).lineHeight};
1222
+ white-space: pre-wrap;
1223
+ word-wrap: break-word;
1224
+ visibility: hidden;
1225
+ `;
1226
+ document.body.appendChild(tempDiv);
1227
+
1228
+ // 見出しまでの内容を一時的な要素に挿入
1229
+ tempDiv.textContent = lines.slice(0, headingIndex).join('\n');
1230
+
1231
+ // 見出しまでの高さを計算
1232
+ const scrollPosition = tempDiv.clientHeight;
1233
+
1234
+ // 一時的な要素を削除
1235
+ document.body.removeChild(tempDiv);
1236
+
1237
+ // アコーディオンが既に開かれているかチェック
1238
+ if (content1Collapse.classList.contains('show')) {
1239
+ // 既に開かれている場合は直接スクロール
1240
+ content1.scrollTop = scrollPosition;
1241
+ } else {
1242
+ // 閉じている場合はアコーディオンを開いてからスクロール
1243
+ accordion.show();
1244
+ content1Collapse.addEventListener('shown.bs.collapse', function onShown() {
1245
+ content1.scrollTop = scrollPosition;
1246
+ content1Collapse.removeEventListener('shown.bs.collapse', onShown);
1247
+ }, { once: true });
1248
+ }
1249
+ }
1250
+ }
1251
+
1252
+ function updateAccordionHeaderCount(accordionId) {
1253
+ const accordionItem = document.getElementById(accordionId).closest('.accordion-item');
1254
+ if (!accordionItem) return;
1255
+
1256
+ const textarea = accordionItem.querySelector('.accordion-body textarea');
1257
+ const header = accordionItem.querySelector('.accordion-header button');
1258
+
1259
+ if (textarea && header) {
1260
+ const charCount = textarea.value.length;
1261
+ const originalText = header.textContent.split('(')[0].trim();
1262
+ header.textContent = `${originalText} (${charCount}文字)`;
1263
+ }
1264
+ }
1265
+
1266
+ function updateAllAccordionHeaderCounts() {
1267
+ const accordionIds = ['promptsCollapse', 'content1Collapse', 'nextPromptCollapse', 'content2Collapse'];
1268
+ accordionIds.forEach(updateAccordionHeaderCount);
1269
+ }
1270
+
1271
+ function showDiffModal() {
1272
+ const diffContainer = document.getElementById('diffContainer');
1273
+ diffContainer.innerHTML = '';
1274
+
1275
+ if (replaceProofReadHistory.length === 0) {
1276
+ diffContainer.innerHTML = '<p>校正履歴がありません。</p>';
1277
+ return;
1278
+ }
1279
+
1280
+ replaceProofReadHistory.forEach((entry, index) => {
1281
+ const [original, corrected] = entry;
1282
+ const diff = Diff.diffLines(original.join('\n'), corrected.join('\n'));
1283
+
1284
+ const diffHtml = diff.map(part => {
1285
+ const color = part.added ? 'green' : part.removed ? 'red' : 'grey';
1286
+ const prefix = part.added ? '+' : part.removed ? '-' : ' ';
1287
+ return `<span style="color: ${color}">${prefix} ${part.value}</span>`;
1288
+ }).join('');
1289
+
1290
+ const entryDiv = document.createElement('div');
1291
+ entryDiv.innerHTML = `<h6>校正 ${index + 1}</h6><pre>${diffHtml}</pre><hr>`;
1292
+ diffContainer.appendChild(entryDiv);
1293
+ });
1294
+
1295
+ const modal = new bootstrap.Modal(document.getElementById('diffModal'));
1296
+ modal.show();
1297
+ }
1298
+
1299
+ document.addEventListener('DOMContentLoaded', function () {
1300
+ // ページ読み込み時にデータを復元
1301
+ loadFromUserStorage();
1302
+
1303
+ // メイン画面の要素のイベントリスナー。inputイベントが発生する頻度が非常に高いのでこちらの発動は60秒に1回に制限する
1304
+ ['novelContent1', 'novelContent2', 'generatePrompt', 'nextPrompt', 'savedTitle'].forEach(id => {
1305
+ document.getElementById(id).addEventListener('input', () => {
1306
+ saveToUserStorage(false);
1307
+ generateIndexMenu(false);
1308
+ updateAllAccordionHeaderCounts();
1309
+ });
1310
+ });
1311
+
1312
+ // 設定画面の要素のイベントリスナー
1313
+ ['memo', 'geminiApiKey', 'endpointSelect', 'openaiEndpoint', 'openaiHeaders', 'openaiJsonBody', 'encodeLength', 'temperature'].forEach(id => {
1314
+ document.getElementById(id).addEventListener('input', () => {
1315
+ saveToUserStorage(true);
1316
+ });
1317
+ });
1318
+
1319
+ ['partialEncodeToggle', 'streamToggle'].forEach(id => {
1320
+ document.getElementById(id).addEventListener('change', () => {
1321
+ saveToUserStorage(true);
1322
+ });
1323
+ });
1324
+
1325
+ document.getElementById('novelContent1').addEventListener('keydown', handleKeyPress);
1326
+
1327
+ document.querySelectorAll('[data-modal-text]').forEach(element => {
1328
+ element.addEventListener('click', function () {
1329
+ document.querySelectorAll(".modal-text").forEach(el => {
1330
+ el.classList.add("d-none");
1331
+ if (el.classList.contains(this.getAttribute('data-modal-text'))) {
1332
+ el.classList.remove("d-none");
1333
+ }
1334
+ });
1335
+ });
1336
+ });
1337
+
1338
+ syncInputs();
1339
+
1340
+ // 60秒ごとに自動保存実行
1341
+ setInterval(() => {
1342
+ saveToUserStorage();
1343
+ generateIndexMenu(true);
1344
+ updateAllAccordionHeaderCounts();
1345
+ }, 60000);
1346
+
1347
+ // 基本設定のアコーディオンを開く
1348
+ const basicSettingsAccordion = document.querySelector('#promptsCollapse');
1349
+ if (basicSettingsAccordion) {
1350
+ new bootstrap.Collapse(basicSettingsAccordion).show();
1351
+ }
1352
+
1353
+ // ナビゲーションボタンのイベントリスナーを設定
1354
+ document.getElementById('prevAccordion').addEventListener('click', openPreviousAccordion);
1355
+ document.getElementById('nextAccordion').addEventListener('click', openNextAccordion);
1356
+
1357
+ // エンドポイント選択が変更されたときにnavbar-brandを更新
1358
+ document.getElementById('endpointSelect').addEventListener('change', updateNavbarBrand);
1359
+
1360
+ // 初期表示時にも実行
1361
+ updateNavbarBrand();
1362
+ //generateIndexMenu(true);
1363
+ updateAllAccordionHeaderCounts();
1364
+
1365
+ // diff2htmlライブラリの読み込み
1366
+ const script = document.createElement('script');
1367
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jsdiff/5.1.0/diff.min.js';
1368
+ document.head.appendChild(script);
1369
+ });
index.html CHANGED
@@ -1,19 +1,434 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>LLM Client</title>
8
+ <link href="https://unpkg.com/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
10
+ crossorigin="anonymous">
11
+ <style>
12
+ #mainContent textarea.form-control {
13
+ min-height: 40vh;
14
+ font-size: 1.2rem;
15
+ }
16
+
17
+ .accordion-button:not(.collapsed) {
18
+ background-color: #212529;
19
+ color: #fff;
20
+ }
21
+
22
+ .navbar-toggler {
23
+ display: block !important;
24
+ }
25
+
26
+
27
+ @keyframes redFlashBg {
28
+
29
+ 0%,
30
+ 100% {
31
+ background-color: initial;
32
+ }
33
+
34
+ 50% {
35
+ background-color: rgba(255, 0, 0, 0.5);
36
+ }
37
+ }
38
+
39
+ @keyframes redFlashFg {
40
+
41
+ 0%,
42
+ 100% {
43
+ color: initial;
44
+ }
45
+
46
+ 50% {
47
+ color: red;
48
+ }
49
+ }
50
+
51
+ .red-flash-bg {
52
+ animation: redFlashBg 0.5s infinite;
53
+ }
54
+
55
+ .red-flash-fg {
56
+ animation: redFlashFg 0.5s infinite;
57
+ }
58
+
59
+ @keyframes greenFlashBg {
60
+
61
+ 0%,
62
+ 100% {
63
+ background-color: initial;
64
+ }
65
+
66
+ 50% {
67
+ background-color: rgba(0, 255, 0, 0.5);
68
+ }
69
+ }
70
+
71
+ @keyframes greenFlashFg {
72
+
73
+ 0%,
74
+ 100% {
75
+ color: initial;
76
+ }
77
+
78
+ 50% {
79
+ color: green;
80
+ }
81
+ }
82
+
83
+ .green-flash-bg {
84
+ animation: greenFlashBg 0.5s infinite;
85
+ }
86
+
87
+ .green-flash-fg {
88
+ animation: greenFlashFg 0.5s infinite;
89
+ }
90
+
91
+ /* 新しいスタイルを追加 */
92
+ .navbar {
93
+ position: sticky;
94
+ top: 0;
95
+ z-index: 1000;
96
+ }
97
+
98
+ /* メインコンテンツの上部にパディングを追加 */
99
+ #mainContent {
100
+ padding-top: 1rem;
101
+ }
102
+
103
+ @media (max-width: 991.98px) {
104
+ .navbar-brand {
105
+ font-size: 1rem;
106
+ }
107
+
108
+ .btn-sm {
109
+ padding: 0.25rem 0.5rem;
110
+ font-size: 0.75rem;
111
+ }
112
+ }
113
+
114
+ @media (min-width: 1400px) {}
115
+
116
+ /* 目次のスタイル */
117
+ #indexOffcanvas .offcanvas-body ul {
118
+ padding-left: 0;
119
+ }
120
+
121
+ #indexOffcanvas .offcanvas-body li {
122
+ margin-bottom: 0.5rem;
123
+ }
124
+
125
+ #indexOffcanvas .offcanvas-body a {
126
+ text-decoration: none;
127
+ color: inherit;
128
+ }
129
+
130
+ #indexOffcanvas .offcanvas-body .toggle-btn {
131
+ padding: 0.1rem 0.3rem;
132
+ font-size: 0.8rem;
133
+ }
134
+ </style>
135
+ </head>
136
+
137
+ <body data-bs-theme="dark">
138
+ <div class="container-fluid">
139
+ <div class="row">
140
+ <div class="col-12 text-center">
141
+ <a class="navbar-brand" href="#">
142
+ <i class="fa-brands fa-google"></i>
143
+ <i class="fa-solid fa-robot d-none"></i>
144
+ LLM Client
145
+ </a>
146
+ </div>
147
+ </div>
148
+ <div class="row w-100">
149
+ <div class="col-3 d-flex justify-content-end">
150
+ <button class="navbar-toggler" type="button" data-bs-toggle="offcanvas"
151
+ data-bs-target="#settingsOffcanvas" aria-controls="settingsOffcanvas">
152
+ <i class="fas fa-list"></i>
153
+ </button>
154
+ </div>
155
+ <div class="col-6 d-flex justify-content-center">
156
+ <button id="prevAccordion" class="btn btn-outline-light">
157
+ <i class="fas fa-chevron-left"></i> 前へ
158
+ </button>
159
+ <button id="requestButton" class="btn btn-primary" onclick="Request()">
160
+ 生成
161
+ </button>
162
+ <button id="stopButton" class="btn btn-danger d-none" onclick="stopGeneration()">
163
+ 中止
164
+ </button>
165
+ <button id="nextAccordion" class="btn btn-outline-light">
166
+ 次へ <i class="fas fa-chevron-right"></i>
167
+ </button>
168
+ </div>
169
+ <div class="col-3 d-flex justify-content-start">
170
+ <button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#indexOffcanvas"
171
+ aria-controls="indexOffcanvas">
172
+ <i class="fas fa-book"></i>
173
+ </button>
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ <div class="container">
179
+ <div class="row justify-content-center">
180
+ <!-- 画像アップロード用セクション -->
181
+ <div id="imageUploadSection" class="mb-3">
182
+ <div class="d-flex justify-content-between align-items-center mb-2">
183
+ <h5>画像添付</h5>
184
+ <button class="btn btn-outline-primary btn-sm" onclick="addImageInput()">
185
+ <i class="fas fa-plus"></i> 画像を追加
186
+ </button>
187
+ </div>
188
+ <div id="imageInputsContainer">
189
+ <!-- 画像入力フィールドがここに動的に追加されます -->
190
+ </div>
191
+ </div>
192
+ <div id="mainContent">
193
+ <div class="mt-3">
194
+ <div class="accordion" id="mainAccordion">
195
+ <div class="accordion-item">
196
+ <h2 class="accordion-header">
197
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
198
+ data-bs-target="#promptsCollapse">
199
+ 基本設定
200
+ </button>
201
+ </h2>
202
+ <div id="promptsCollapse" class="accordion-collapse collapse"
203
+ data-bs-parent="#mainAccordion">
204
+ <div class="accordion-body">
205
+ <textarea class="form-control mb-2" id="generatePrompt"
206
+ placeholder="システムプロンプトを入力"></textarea>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ <div class="accordion-item">
211
+ <h2 class="accordion-header">
212
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
213
+ data-bs-target="#content1Collapse">
214
+ 小説内容 (入力)
215
+ </button>
216
+ </h2>
217
+ <div id="content1Collapse" class="accordion-collapse collapse"
218
+ data-bs-parent="#mainAccordion">
219
+ <div class="accordion-body">
220
+ <textarea class="form-control mb-2" id="novelContent1"
221
+ placeholder="ここに小説の本文を入力してください"></textarea>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ <div class="accordion-item">
226
+ <h2 class="accordion-header">
227
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
228
+ data-bs-target="#nextPromptCollapse">
229
+ 次の展開
230
+ </button>
231
+ </h2>
232
+ <div id="nextPromptCollapse" class="accordion-collapse collapse"
233
+ data-bs-parent="#mainAccordion">
234
+ <div class="accordion-body">
235
+ <div class="d-flex justify-content-between align-items-center mb-3">
236
+ <textarea class="form-control me-2" id="nextPrompt" placeholder="次の展開を指示" rows="1"></textarea>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ <div class="accordion-item">
242
+ <h2 class="accordion-header">
243
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
244
+ data-bs-target="#content2Collapse">
245
+ 小説内容 (出力)
246
+ </button>
247
+ </h2>
248
+ <div id="content2Collapse" class="accordion-collapse collapse"
249
+ data-bs-parent="#mainAccordion">
250
+ <div class="accordion-body">
251
+ <textarea class="form-control" id="novelContent2"
252
+ placeholder="ここに続きが表示されます。"></textarea>
253
+ <button id="moveToInputButton" class="btn btn-primary mt-2" onclick="moveToInput()">
254
+ 入力欄に追記
255
+ </button>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </div>
260
+
261
+ <div class="row mt-3">
262
+ <div class="col-12 d-flex justify-content-end">
263
+ <input type="text" class="form-control d-inline-block w-auto me-2" id="savedTitle"
264
+ placeholder="タイトル">
265
+ <button id="saveButton" class="btn btn-secondary me-2" onclick="saveToJson()">
266
+ 保存
267
+ </button>
268
+ <button id="loadButton" class="btn btn-secondary" onclick="loadFromJson()">
269
+ 読込
270
+ </button>
271
+ </div>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ <div class="offcanvas offcanvas-start" tabindex="-1" id="settingsOffcanvas"
279
+ aria-labelledby="settingsOffcanvasLabel">
280
+ <div class="offcanvas-header">
281
+ <h5 class="offcanvas-title" id="settingsOffcanvasLabel">設定</h5>
282
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
283
+ </div>
284
+ <div class="offcanvas-body">
285
+ <div class="accordion" id="settingsAccordion">
286
+ <!-- API設定 -->
287
+ <div class="accordion-item">
288
+ <h2 class="accordion-header">
289
+ <button class="accordion-button" type="button" data-bs-toggle="collapse"
290
+ data-bs-target="#apiSettings">
291
+ API設定
292
+ </button>
293
+ </h2>
294
+ <div id="apiSettings" class="accordion-collapse collapse show" data-bs-parent="#settingsAccordion">
295
+ <div class="accordion-body">
296
+ <h5>エンドポイント</h5>
297
+ <select class="form-select mb-2" id="endpointSelect">
298
+ <option value="restart">Restart</option>
299
+ <option value="openai">OpenAI Compatible</option>
300
+ </select>
301
+
302
+ <h5>Gemini API Key</h5>
303
+ <p class="text-muted">エンドポイントがGeminiの場合は必須</p>
304
+ <input type="text" class="form-control mb-2" id="geminiApiKey" placeholder="Gemini API Key">
305
+
306
+ <h5 class="mt-3">OpenAI Compatible設定</h5>
307
+ <p class="text-muted">エンドポイントがOpenAI Compatibleの場合は必須</p>
308
+ <input type="text" class="form-control mb-2" id="openaiEndpoint"
309
+ placeholder="OpenAI Endpoint">
310
+ <textarea class="form-control mb-2" id="openaiHeaders"
311
+ placeholder='{
312
+ "Content-Type": "application/json",
313
+ "Authorization": "Bearer sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
314
+ }'></textarea>
315
+ <textarea class="form-control mb-2" id="openaiJsonBody"
316
+ placeholder='{
317
+ "model": "gemini-pro",
318
+ "messages": [],
319
+ "temperature": 1.0,
320
+ "max_tokens": 8192,
321
+ "stream": true
322
+ }'></textarea>
323
+ </div>
324
+ </div>
325
+ </div>
326
+
327
+ <!-- 生成設定 -->
328
+ <div class="accordion-item">
329
+ <h2 class="accordion-header">
330
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
331
+ data-bs-target="#generationSettings">
332
+ 生成設定
333
+ </button>
334
+ </h2>
335
+ <div id="generationSettings" class="accordion-collapse collapse"
336
+ data-bs-parent="#settingsAccordion">
337
+ <div class="accordion-body">
338
+ <div class="mb-2">
339
+ <label for="encodeLength" class="form-label">エンコード長</label>
340
+ <input type="range" class="form-range" id="encodeLength" placeholder="エンコード長" min="1"
341
+ max="16" value="4">
342
+ <input type="number" class="form-control" id="encodeLengthInput" placeholder="エンコード長"
343
+ min="1" max="16" value="4">
344
+ </div>
345
+ <div class="form-check mb-2 form-switch">
346
+ <input class="form-check-input" type="checkbox" id="summerizedPromptToggle">
347
+ <label class="form-check-label" for="summerizedPromptToggle">Summerize</label>
348
+ </div>
349
+ <div class="form-check mb-2 form-switch">
350
+ <input class="form-check-input" type="checkbox" id="partialEncodeToggle">
351
+ <label class="form-check-label" for="partialEncodeToggle">Encode</label>
352
+ </div>
353
+ <div class="form-check mb-2 form-switch">
354
+ <input class="form-check-input" type="checkbox" id="streamToggle" checked>
355
+ <label class="form-check-label" for="streamToggle">Stream</label>
356
+ </div>
357
+ <div class="mb-2">
358
+ <label for="temperature" class="form-label">Temperature</label>
359
+ <input type="range" class="form-range" id="temperature" min="0" max="2" value="1.0" step="0.1">
360
+ <input type="number" class="form-control" id="temperatureInput" value="1.0" min="0" max="2" step="0.1">
361
+ </div>
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- その他の設定 -->
367
+ <div class="accordion-item">
368
+ <h2 class="accordion-header">
369
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
370
+ data-bs-target="#otherSettings">
371
+ その他の設定
372
+ </button>
373
+ </h2>
374
+ <div id="otherSettings" class="accordion-collapse collapse" data-bs-parent="#settingsAccordion">
375
+ <div class="accordion-body">
376
+ <h5>メモ欄</h5>
377
+ <textarea class="form-control mb-2" id="memo" placeholder="メモ用項目。生成に一切影響しません。"
378
+ rows="5"></textarea>
379
+
380
+ <button id="formatTextButton" class="btn btn-primary mb-2" onclick="formatText()">
381
+ <i class="fa-solid fa-align-left"></i> 改行を整理
382
+ </button>
383
+
384
+ <h5 class="mt-3">デバッグ</h5>
385
+ <button id="debugButton" class="btn btn-secondary mb-2" onclick="debugPrompt()">
386
+ <i class="fa-solid fa-bug"></i> payload
387
+ </button>
388
+ <button id="summaryButton" class="btn btn-secondary mb-2" onclick="console.log(createSummarizedText())">
389
+ <i class="fa-solid fa-bug"></i> 要約
390
+ </button>
391
+
392
+ <h5 class="mt-3">校正履歴</h5>
393
+ <button id="showDiffButton" class="btn btn-secondary mb-2" onclick="showDiffModal()">
394
+ <i class="fa-solid fa-history"></i> 校正履歴を表示
395
+ </button>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ </div>
401
+ </div>
402
+
403
+ <!-- 新しい右側のOffCanvas -->
404
+ <div class="offcanvas offcanvas-end" tabindex="-1" id="indexOffcanvas" aria-labelledby="indexOffcanvasLabel">
405
+ <div class="offcanvas-header">
406
+ <h5 class="offcanvas-title" id="indexOffcanvasLabel">目次</h5>
407
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
408
+ </div>
409
+ <div class="offcanvas-body">
410
+ <!-- ここに目次の内容を追加します -->
411
+ </div>
412
+ </div>
413
+
414
+ <!-- モーダルウィンドウの追加 -->
415
+ <div class="modal fade" id="diffModal" tabindex="-1" aria-labelledby="diffModalLabel" aria-hidden="true">
416
+ <div class="modal-dialog modal-lg">
417
+ <div class="modal-content">
418
+ <div class="modal-header">
419
+ <h5 class="modal-title" id="diffModalLabel">校正履歴</h5>
420
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
421
+ </div>
422
+ <div class="modal-body">
423
+ <div id="diffContainer"></div>
424
+ </div>
425
+ </div>
426
+ </div>
427
+ </div>
428
+
429
+ <script src="https://unpkg.com/[email protected]/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
430
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
431
+ <script src="gemini.js"></script>
432
+ </body>
433
+
434
+ </html>
util/index.html ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja" data-bs-theme="dark">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>テキスト編集ユーティリティ</title>
8
+ <!-- Bootstrap 5 CSS -->
9
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
10
+ <!-- Font Awesome -->
11
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
12
+ <style>
13
+ .text-area-container {
14
+ margin: 20px 0;
15
+ position: relative;
16
+ }
17
+
18
+ .text-area-controls {
19
+ position: absolute;
20
+ top: 10px;
21
+ right: 10px;
22
+ z-index: 10;
23
+ display: flex;
24
+ gap: 5px;
25
+ }
26
+
27
+ .text-area-controls .btn {
28
+ padding: 4px 8px;
29
+ font-size: 12px;
30
+ border-radius: 4px;
31
+ }
32
+
33
+ .text-area-container textarea {
34
+ padding-right: 80px;
35
+ }
36
+
37
+ .accordion-button:not(.collapsed) {
38
+ background-color: var(--bs-primary-bg-subtle);
39
+ }
40
+
41
+ .layout-wrapper {
42
+ display: flex;
43
+ flex-direction: row;
44
+ height: 100vh;
45
+ }
46
+
47
+ .sidebar {
48
+ width: 250px;
49
+ min-width: 250px;
50
+ transition: all 0.3s;
51
+ z-index: 1000;
52
+ background-color: var(--bs-body-bg);
53
+ border-right: 1px solid var(--bs-border-color, #444);
54
+ margin-top: 32px;
55
+ }
56
+
57
+ .main-content {
58
+ flex: 1 1 0%;
59
+ transition: all 0.3s;
60
+ margin-top: 32px;
61
+ display: flex;
62
+ flex-direction: column;
63
+ align-items: stretch;
64
+ }
65
+
66
+ .main-inner {
67
+ width: 100%;
68
+ max-width: 100%;
69
+ padding: 0 8px;
70
+ margin: 0;
71
+ }
72
+
73
+ @media (min-width: 768px) {
74
+ .layout-wrapper {
75
+ padding: 0 25vh;
76
+ }
77
+ }
78
+ </style>
79
+ </head>
80
+
81
+ <body>
82
+ <div class="layout-wrapper">
83
+
84
+ <!-- メインコンテンツ -->
85
+ <div class="main-content" id="mainContent">
86
+ <div class="main-inner">
87
+ <h2 class="mb-3">テキスト編集ユーティリティ</h2>
88
+ <div class="d-grid gap-2">
89
+ <button class="btn btn-primary" id="processBtn">
90
+ <i class="fas fa-cog me-2"></i>Process
91
+ </button>
92
+ <button class="btn btn-secondary" id="deprocessBtn">
93
+ <i class="fas fa-undo me-2"></i>Deprocess
94
+ </button>
95
+ </div>
96
+ <div class="accordion" id="textEditorAccordion">
97
+ <!-- 上部テキストエリア -->
98
+ <div class="accordion-item">
99
+ <h2 class="accordion-header">
100
+ <button class="accordion-button" type="button" data-bs-toggle="collapse"
101
+ data-bs-target="#collapseOne">
102
+ <i class="fas fa-chevron-down me-2"></i>上部テキストエリア
103
+ </button>
104
+ </h2>
105
+ <div id="collapseOne" class="accordion-collapse collapse show"
106
+ data-bs-parent="#textEditorAccordion">
107
+ <div class="accordion-body">
108
+ <div class="text-area-container">
109
+ <div class="text-area-controls">
110
+ <button class="btn btn-outline-primary btn-sm" onclick="copyToClipboard('topText', event)" title="コピー">
111
+ <i class="fas fa-copy"></i>
112
+ </button>
113
+ <button class="btn btn-outline-secondary btn-sm" onclick="pasteFromClipboard('topText')" title="ペースト">
114
+ <i class="fas fa-paste"></i>
115
+ </button>
116
+ </div>
117
+ <textarea id="topText" class="form-control" style="width:100%; min-height:50vh;"
118
+ rows="10" placeholder="ここにテキストを入力してください"></textarea>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ <!-- 下部テキストエリア -->
124
+ <div class="accordion-item">
125
+ <h2 class="accordion-header">
126
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
127
+ data-bs-target="#collapseTwo">
128
+ <i class="fas fa-chevron-down me-2"></i>下部テキストエリア
129
+ </button>
130
+ </h2>
131
+ <div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#textEditorAccordion">
132
+ <div class="accordion-body">
133
+ <div class="text-area-container">
134
+ <div class="text-area-controls">
135
+ <button class="btn btn-outline-primary btn-sm" onclick="copyToClipboard('bottomText', event)" title="コピー">
136
+ <i class="fas fa-copy"></i>
137
+ </button>
138
+ <button class="btn btn-outline-secondary btn-sm" onclick="pasteFromClipboard('bottomText')" title="ペースト">
139
+ <i class="fas fa-paste"></i>
140
+ </button>
141
+ </div>
142
+ <textarea id="bottomText" class="form-control" style="width:100%; min-height:50vh;"
143
+ rows="10" placeholder="ここにテキストを入力してください"></textarea>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <div class="accordion-item">
150
+ <h2 class="accordion-header">
151
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
152
+ data-bs-target="#collapseThree">
153
+ <i class="fas fa-chevron-down me-2"></i>メモ
154
+ </button>
155
+ </h2>
156
+ <div id="collapseThree" class="accordion-collapse collapse"
157
+ data-bs-parent="#textEditorAccordion">
158
+ <div class="accordion-body">
159
+ <div class="text-area-container">
160
+ <div class="text-area-controls">
161
+ <button class="btn btn-outline-primary btn-sm" onclick="copyToClipboard('memoArea', event)" title="コピー">
162
+ <i class="fas fa-copy"></i>
163
+ </button>
164
+ <button class="btn btn-outline-secondary btn-sm" onclick="pasteFromClipboard('memoArea')" title="ペースト">
165
+ <i class="fas fa-paste"></i>
166
+ </button>
167
+ </div>
168
+ <textarea id="memoArea" class="form-control" style="width:100%; min-height:50vh;"
169
+ rows="10" placeholder="ここにテキストを入力してください"></textarea>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ <!-- Bootstrap 5 JS Bundle with Popper -->
181
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
182
+ <script>
183
+ let lastSaveTimestamp = 0;
184
+
185
+ // 0pxスペース(ゼロ幅スペース)の定数
186
+ const ZERO_WIDTH_SPACE = '&#8204;';
187
+
188
+ // 既知のダミー文字の候補
189
+ const KNOWN_DUMMY_CHARS = [
190
+ '\u200c', '\u200b', '\u200d', // ゼロ幅文字
191
+ '&#8204;', '&#8203;', '&zwnj;', // HTMLエンティティ
192
+ '\u200e', '\u200f', // 方向制御文字
193
+ '\u2060', '\u2061', '\u2062', '\u2063', '\u2064' // その他の制御文字
194
+ ];
195
+
196
+ // 文字の出現頻度を分析する関数
197
+ function analyzeCharFrequency(text) {
198
+ const frequency = new Map();
199
+ for (let i = 0; i < text.length; i++) {
200
+ const char = text[i];
201
+ frequency.set(char, (frequency.get(char) || 0) + 1);
202
+ }
203
+ return frequency;
204
+ }
205
+
206
+ // 文字列のパターンを分析してダミー文字の候補を探す関数
207
+ function findPatternCandidate(text) {
208
+ if (!text || text.length < 3) return null;
209
+
210
+ // 文字の出現頻度を分析
211
+ const frequency = analyzeCharFrequency(text);
212
+
213
+ // 文字列を2文字ずつに分割して、各文字の間の文字を確認
214
+ const patternMap = new Map();
215
+ for (let i = 1; i < text.length - 1; i += 2) {
216
+ const char = text[i];
217
+ const prevChar = text[i - 1];
218
+ const nextChar = text[i + 1];
219
+
220
+ // 前後の文字が同じで、かつ現在の文字が一定の頻度で出現している場合
221
+ if (prevChar === nextChar &&
222
+ frequency.get(char) > text.length * 0.3) { // 30%以上の出現率
223
+ patternMap.set(char, (patternMap.get(char) || 0) + 1);
224
+ }
225
+ }
226
+
227
+ // 最も頻出するパターンを返す
228
+ let maxCount = 0;
229
+ let candidate = null;
230
+ for (const [char, count] of patternMap) {
231
+ if (count > maxCount) {
232
+ maxCount = count;
233
+ candidate = char;
234
+ }
235
+ }
236
+
237
+ return candidate;
238
+ }
239
+
240
+ // 文字間のダミー文字を検出する関数
241
+ function detectDummyChar(text) {
242
+ if (!text || text.length < 3) return null;
243
+
244
+ // まず既知のダミー文字をチェック
245
+ for (let i = 1; i < text.length - 1; i += 2) {
246
+ const char = text[i];
247
+ if (KNOWN_DUMMY_CHARS.includes(char) &&
248
+ text[i - 1] !== char && text[i + 1] !== char) {
249
+ return char;
250
+ }
251
+ }
252
+
253
+ // 既知のダミー文字が見つからない場合はパターン分析を実行
254
+ return findPatternCandidate(text);
255
+ }
256
+
257
+ // 文字列の各文字の間に指定の文字列を挟む
258
+ function insertBetweenChars(text, insertStr) {
259
+ if (!text) return '';
260
+ return text.split('').join(insertStr);
261
+ }
262
+
263
+ // 文字列から指定の文字列を除去
264
+ function removeBetweenChars(text, removeStr) {
265
+ if (!text) return '';
266
+ return text.split(removeStr).join('');
267
+ }
268
+
269
+ // HTMLエンティティを実体参照に変換する関数
270
+ function decodeHtmlEntities(str) {
271
+ const textarea = document.createElement('textarea');
272
+ textarea.innerHTML = str;
273
+ return textarea.value;
274
+ }
275
+
276
+ // テキストエリアの値を取得・設定する関数
277
+ function getUpperText() {
278
+ return document.querySelectorAll('.text-area-container textarea')[0].value;
279
+ }
280
+ function setUpperText(val) {
281
+ document.querySelectorAll('.text-area-container textarea')[0].value = val;
282
+ }
283
+ function getLowerText() {
284
+ return document.querySelectorAll('.text-area-container textarea')[1].value;
285
+ }
286
+ function setLowerText(val) {
287
+ document.querySelectorAll('.text-area-container textarea')[1].value = val;
288
+ }
289
+
290
+ // processボタンの挙動
291
+ document.getElementById('processBtn').addEventListener('click', function () {
292
+ const upperText = getUpperText();
293
+ const processed = insertBetweenChars(upperText, ZERO_WIDTH_SPACE);
294
+ setLowerText(processed);
295
+ // 下部テキストエリアを表示
296
+ const lowerAccordion = new bootstrap.Collapse(document.getElementById('collapseTwo'), {
297
+ toggle: false
298
+ });
299
+ lowerAccordion.show();
300
+ });
301
+
302
+ // deprocessボタンの挙動
303
+ document.getElementById('deprocessBtn').addEventListener('click', function () {
304
+ let lowerText = getLowerText();
305
+ // まずHTMLエンティティを実体参照に変換
306
+ lowerText = decodeHtmlEntities(lowerText);
307
+ const dummyChar = detectDummyChar(lowerText);
308
+ if (!dummyChar) {
309
+ alert('文字間のダミー文字を検出できませんでした。');
310
+ return;
311
+ }
312
+ const deprocessed = removeBetweenChars(lowerText, dummyChar);
313
+ setUpperText(deprocessed);
314
+ // 上部テキストエリアを表示
315
+ const upperAccordion = new bootstrap.Collapse(document.getElementById('collapseOne'), {
316
+ toggle: false
317
+ });
318
+ upperAccordion.show();
319
+ });
320
+
321
+ function saveToUserStorage(force = false) {
322
+ const currentTime = Date.now();
323
+ if (currentTime - lastSaveTimestamp < 5000 && !force) {
324
+ console.debug('セーブをスキップします');
325
+ return;
326
+ }
327
+ console.debug('セーブを実行します');
328
+
329
+ // 既存のデータを取得
330
+ const textUtilData = JSON.parse(localStorage.getItem('textUtil') || '{}');
331
+
332
+ const newData = {};
333
+ Array.from(document.querySelectorAll("input[id], textarea[id], select[id]")).forEach(el => {
334
+ if (el.id) {
335
+ newData[el.id] = el.type === 'checkbox' ? el.checked : el.value;
336
+ }
337
+ });
338
+ Object.assign(textUtilData, newData);
339
+ console.log(textUtilData);
340
+ localStorage.setItem('textUtil', JSON.stringify(textUtilData));
341
+ lastSaveTimestamp = currentTime;
342
+ }
343
+
344
+ function loadFromUserStorage() {
345
+ const textUtilData = JSON.parse(localStorage.getItem('textUtil') || '{}');
346
+ document.getElementById('bottomText').value = textUtilData['bottomText'] || '';
347
+ document.getElementById('topText').value = textUtilData['topText'] || '';
348
+ document.getElementById('memoArea').value = textUtilData['memoArea'] || '';
349
+ }
350
+
351
+ document.querySelectorAll("#bottomText, #topText").forEach(el => {
352
+ el.addEventListener('input', () => {
353
+ saveToUserStorage(false);
354
+ });
355
+ });
356
+ document.querySelectorAll("#memoArea").forEach(el => {
357
+ el.addEventListener('input', () => {
358
+ saveToUserStorage(true);
359
+ });
360
+ });
361
+
362
+ document.addEventListener('DOMContentLoaded', function () {
363
+ // ページ読み込み時にデータを復元
364
+ loadFromUserStorage();
365
+ });
366
+
367
+ // クリップボードにコピーする関数
368
+ async function copyToClipboard(textareaId, event) {
369
+ const textarea = document.getElementById(textareaId);
370
+ const text = textarea.value;
371
+
372
+ try {
373
+ await navigator.clipboard.writeText(text);
374
+ // 成功時のフィードバック(オプション)
375
+ const button = event.target.closest('button');
376
+ const originalText = button.innerHTML;
377
+ button.innerHTML = '<i class="fas fa-check"></i>';
378
+ setTimeout(() => {
379
+ button.innerHTML = originalText;
380
+ }, 1000);
381
+ } catch (err) {
382
+ console.error('クリップボードへのコピーに失敗しました:', err);
383
+ alert('クリップボードへのコピーに失敗しました');
384
+ }
385
+ }
386
+
387
+ // クリップボードからペーストする関数
388
+ async function pasteFromClipboard(textareaId) {
389
+ const textarea = document.getElementById(textareaId);
390
+
391
+ try {
392
+ const text = await navigator.clipboard.readText();
393
+ textarea.value = text;
394
+ // ペースト後に自動保存
395
+ saveToUserStorage(true);
396
+ } catch (err) {
397
+ console.error('クリップボードからのペーストに失敗しました:', err);
398
+ alert('クリップボードからのペーストに失敗しました');
399
+ }
400
+ }
401
+
402
+
403
+ </script>
404
+ </body>
405
+
406
+ </html>