
【プログラミング – ステップ④】AIチャットアプリ開発入門講座 – Runpod API専用 – Kimi編
このステップでやること(全体像)
ステップ④では、ステップ③で完成したアプリを拡張し、いよいよファイルアップロード機能を実装します。
これまで「テキストのみ」だった質問に、以下のファイルを添付して送れるようになります。
| ファイルの種類 | 対応形式 | 送信の仕組み |
|---|---|---|
| 画像(🖼) | JPEG/PNG/GIF/WebP (複数可) | 画像データをそのままAPIに送信 (マルチモーダル形式) |
| PDF(📄) | PDF(1ファイル) | テキストを抽出して送信。 抽出できない場合はページ画像として 送信 |
| テキストファイル (📝) | .txt/.py/.js/.html/.css/ .json/.md など(複数可) | ファイルの中身(テキスト)を 質問文に添えて送信 |
| フォルダ(📁) | フォルダ内の テキスト系ファイル (画像・PDFも可) | フォルダ内のファイルを まとめて添付して送信 |
さらに、以下の2つの機能にも対応します。
・ドラッグ&ドロップでの添付(画面のどこにでもファイルを落とせる)
・設定パネルでの「アップロード上限」の設定(画像・PDF・PDFページ数・テキストファイルの上限)
変更した上限設定は、他の設定と同じく localStorage に保存し、画面を更新しても残るようにします。
(ココにステップ④完成時の添付プレビューのスクショを貼る)
ステップ④で新しく登場する技術要素
ステップ④は、これまでのステップの中で最もボリュームの大きい拡張です。
新しい技術要素がいくつか登場しますが、すべて1つずつ丁寧に解説していきますので、ご安心ください。
| 技術要素 | 役割 |
|---|---|
| PDF.js(外部ライブラリ) | PDFファイルをブラウザ上で解析し、 テキストの抽出やページの画像化を行う |
| FileReader API (ブラウザ標準機能) | 選択されたファイルの中身を読み込む |
| Base64エンコード | 画像データを「文字列」に変換し、 APIに送信できる形にする |
| マルチモーダル形式 | テキストと画像を組み合わせて、 APIに送信するデータ形式 |
| ドラッグ&ドロップ API | ファイルを画面に落として添付する操作を実現する |
作業の流れ(差分は全部で12か所)
ステップ③のコードに対して、以下の12か所を「追加」または「置き換え」していきます。
作業量が多いため、1つずつ着実に進めてください。
1. HTML(<head>):
PDF.jsライブラリの読み込みを追加
2. HTML(設定パネル):
「アップロード上限」の設定項目を追加
3. HTML(入力エリア):
添付ボタン4つ・添付プレビューエリア・ドラッグ&ドロップのオーバーレイ・隠しファイル入力を追加
4. CSS:
添付プレビュー・添付ボタン・ドロップオーバーレイのスタイルを追加
5. JavaScript【1】:
アップロード制限の設定値(定数・変数)を追加
6. JavaScript【2】:
添付ファイルの状態管理変数を追加
7. JavaScript【3】:
新しい部品のDOM参照を追加
8. JavaScript【4】:
ファイルサイズの表示関数(formatBytes)を追加
9. JavaScript【6】:
applyRequestTimeoutSetting を applyUploadSettings に統合・拡張
10. JavaScript(新規セクション):
ファイル処理関数(画像・PDF・テキスト・フォルダ)を追加
11. JavaScript【16】:
sendMessage を「添付ファイル対応」に書き換え
(あわせて、finishGeneration に「回答データの attachmentsMeta」と「apiPayloadMessage の後片付け」を追加)
12. JavaScript【24】:
添付ボタン・ドラッグ&ドロップ・貼り付け・設定変更のイベントを登録
各差分では、「どのコードの直後に追加するか」「どのブロックを置き換えるか」を検索用の目印つきで示します。
差分①:HTML(PDF.jsライブラリの読み込みを追加)
まず、PDFファイルをブラウザ上で解析するための外部ライブラリ「PDF.js」を読み込みます。
■ 追加する場所
<head> 内にある、以下のコメント行を検索してください。
<!-- ※PDF処理用ライブラリ(PDF.js)は、ステップ④で
「PDFアップロード機能」を実装する時にここへ追加します -->
このコメント行を以下のコードに置き換えてください。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
<!-- PDF.js:PDFファイルをブラウザ上で解析するライブラリ
テキストの抽出や、ページの画像化(レンダリング)に使います。 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
■ この差分のポイント
PDF.js は、Mozilla(Firefoxの開発元)が公開している、PDFファイルをブラウザ上で扱うためのライブラリです。
このアプリでは、主に以下の2つの用途で使います。
1. テキストの抽出
… PDFの中の文字を読み取り、その内容を質問として送信する
2. ページの画像化
… テキストが抽出できないPDF(スキャン画像など)を、ページ画像としてレンダリング(描画)して送信する
なお、PDF.jsの「ワーカー」(解析処理を裏側で行う部品)の設定は、JavaScript側(差分⑩の handlePdfUpload 関数内)で行います。
差分②:HTML(設定パネルに「アップロード上限」の設定項目を追加)
次に、設定パネルに「画像・PDF・PDFページ数・テキストファイル」の4つのアップロード上限を設定する項目を追加します。
■ 追加する場所
設定パネル(id=”settingsPanel”)の中にある、「画面更新間隔」の設定項目を検索してください。
<!-- 画面更新間隔(ストリーミング中の描画間隔) -->
<div class="setting-field">
<label for="renderIntervalInput">画面更新間隔 (ms)</label>
<input type="number" id="renderIntervalInput" value="80" min="50" max="100" step="10">
<div class="setting-hint">50〜100ms の範囲で指定</div>
<div class="setting-hint">小さいほど滑らか</div>
</div>
このブロックの直後(APIタイムアウトの設定項目の直前)に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
<!-- ▼▼ ステップ④で追加したアップロード上限の設定項目 ▼▼ -->
<!-- 画像アップロード上限 -->
<div class="setting-field">
<label for="maxImageSizeInput">🖼 画像アップロード上限 (KB)</label>
<input type="number" id="maxImageSizeInput" value="10240" min="100" max="51200" step="100">
<div class="setting-hint" id="imageSizeHint">10.0MB まで</div>
</div>
<!-- PDFアップロード上限 -->
<div class="setting-field">
<label for="maxPdfSizeInput">📄 PDFアップロード上限 (KB)</label>
<input type="number" id="maxPdfSizeInput" value="20480" min="100" max="51200" step="100">
<div class="setting-hint" id="pdfSizeHint">20.0MB まで</div>
<div class="setting-hint">1ファイルの先頭約20000文字まで対応</div>
</div>
<!-- PDFページ数上限 -->
<div class="setting-field">
<label for="maxPdfPagesInput">PDFページ数上限</label>
<input type="number" id="maxPdfPagesInput" value="10" min="1" max="50" step="1">
<div class="setting-hint" id="pdfPagesHint">10ページ まで</div>
</div>
<!-- テキストファイル上限 -->
<div class="setting-field">
<label for="maxTextSizeInput">📝 テキストファイル上限 (KB)</label>
<input type="number" id="maxTextSizeInput" value="5120" min="100" max="10240" step="100">
<div class="setting-hint" id="textSizeHint">5.0MB まで</div>
</div>
■ この差分のポイント
・各項目の下にある小さな説明文(setting-hint)は、完成形のアプリの説明文をそのまま採用しています。
「10.0MB まで」などの表示は、後の差分⑨-4で「入力値に応じて動的に書き換える」ようにします(完成形の updateSettingHints と同じ仕組みです)。
・各入力欄の id(maxImageSizeInput・maxPdfSizeInput など)は、JavaScriptから値を読み取るための目印です。
差分⑦でDOM参照として登録します。
・「PDFページ数上限」は、PDFファイルの「先頭から何ページまで処理するか」の上限です。
大きなPDF(数百ページ)を丸ごと処理するとAPIの料金が跳ね上がるため、先頭のページ数を制限するための設定です。
差分③:HTML(添付ボタン・添付プレビュー・ドロップオーバーレイを追加)
次に、入力エリアに「添付ボタン4つ」と「添付プレビューエリア」を、画面全体に「ドロップオーバーレイ」を追加します。
■ 修正1:入力エリアに添付プレビューエリアと添付ボタンを追加
修正する場所
入力エリア(id=”inputArea”)の中にある、id=”inputRow” のブロックを検索してください。
<div id="inputArea">
<div id="inputRow">
<textarea id="messageInput" placeholder="メッセージを入力(Enter:送信 / Shift+Enter:改行)" rows="1"></textarea>
<button id="sendBtn" data-tooltip="送信(Enter でも送信できます)">➤</button>
<button id="stopBtn" class="hidden" data-tooltip="生成を停止します
それまでに受信した内容は保持されます">■</button>
</div>
<div id="aiDisclaimer">AIは不正確な情報を含む場合があります。重要な内容は必ずご自身でも確認してください。</div>
</div>
このブロックを以下のコードに置き換えてください。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
<div id="inputArea">
<!-- 添付プレビューエリア
選択したファイル(画像・PDF・テキスト)の一覧が、ここに表示されます。
初期状態は空っぽで、JavaScript(renderAttachmentPreview)が中身を生成します。 -->
<div id="attachmentPreviewArea"></div>
<div id="inputRow">
<!-- 添付ボタン4つ
左から順に「画像・PDF・テキストファイル・フォルダ」の添付ボタンです。
それぞれのボタンを押すと、対応する「隠しファイル入力」が開きます。 -->
<button class="attach-btn" id="imageAttachBtn" data-tooltip="🖼 画像を添付
対応形式: JPEG / PNG / GIF / WebP
サイズ: 1枚〜複数可。
(設定で上限指定)
貼り付け・ドラッグ&ドロップにも対応">🖼</button>
<button class="attach-btn" id="pdfAttachBtn" data-tooltip="📄 PDFを添付
サイズ: 20MBまで
ページ数: 先頭から順番に処理。
(設定で上限指定)
テキスト抽出またはページ画像として送信">📄</button>
<button class="attach-btn" id="textAttachBtn" data-tooltip="📝 テキストファイルを添付
対応形式: .txt .py .js .html .css .json .md など
サイズ: 1ファイル〜複数可。
(設定で上限指定)">📝</button>
<button class="attach-btn" id="folderAttachBtn" data-tooltip="📁 フォルダを添付
フォルダ内のテキストファイルを読み込みます
(画像・PDFも含めることができます)
※フォルダのドロップは非対応のため
このボタンから選択してください">📁</button>
<textarea id="messageInput" placeholder="メッセージを入力(Enter:送信 / Shift+Enter:改行)" rows="1"></textarea>
<button id="sendBtn" data-tooltip="送信(Enter でも送信できます)">➤</button>
<button id="stopBtn" class="hidden" data-tooltip="生成を停止します
それまでに受信した内容は保持されます">■</button>
</div>
<div id="aiDisclaimer">AIは不正確な情報を含む場合があります。重要な内容は必ずご自身でも確認してください。</div>
<!-- 隠しファイル入力(4種類)
添付ボタンが押された時に、JavaScriptがこれらをクリックして
ファイル選択ダイアログを開きます。画面には表示されません(class="hidden")。 -->
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" multiple class="hidden">
<input type="file" id="pdfFileInput" accept="application/pdf" class="hidden">
<input type="file" id="textFileInput" accept=".txt,.py,.js,.jsx,.ts,.tsx,.html,.css,.json,.md,.xml,.yaml,.yml,.c,.cpp,.h,.hpp,.java,.cs,.php,.rb,.go,.rs,.sql,.sh,.bash,.bat,.ini,.conf,.cfg,.log,.vue,.svelte,.swift,.kt,.scala,.pl,.pm,.r,.m,.mm,.gradle,.properties,.env,.gitignore,.dockerfile,.makefile,.cmake,.toml,.lock,.csv,.tsv,.tex,.rst,.adoc,.asciidoc" multiple class="hidden">
<input type="file" id="folderFileInput" webkitdirectory directory multiple class="hidden">
</div>
■ この差分(修正1)のポイント
1. 添付プレビューエリア(id=”attachmentPreviewArea”)
inputRow の「上」に、選択したファイルの一覧を表示するエリアを追加しました。
初期状態は空っぽで、ファイルが選択されると、JavaScript(後の差分で追加する renderAttachmentPreview 関数)が、画像のサムネイルやファイル名をここに生成します。
2. 添付ボタン4つ(class=”attach-btn”)
inputRow の「左端」に、画像(🖼)・PDF(📄)・テキストファイル(📝)・フォルダ(📁)の4つの添付ボタンを追加しました。
data-tooltip 属性には、完成形のアプリと同じ説明文を採用しています(ホバーでポップが出ます)。
3. 隠しファイル入力(input type=”file”)
添付ボタン自体は「ファイルを選ぶ機能」を持っていません。そこで、画面には見えない「隠しファイル入力」を4つ用意し、添付ボタンが押された時にJavaScriptがこれらをクリックして、ファイル選択ダイアログを開く仕組みにしています。
それぞれの accept 属性で「選べるファイルの種類」を制限しています。
| 隠しファイル入力 | accept(選べるファイル) |
|---|---|
| imageFileInput | JPEG/PNG/GIF/WebP(複数可) |
| pdfFileInput | PDF(1ファイルのみ) |
| textFileInput | .txt/.py/.js/.html など、多数の拡張子(複数可) |
| folderFileInput | webkitdirectory 属性で「フォルダごと」選択可能に |
■ 修正2:ドロップオーバーレイを追加
追加する場所
メイン領域(id=”main”)の閉じタグ(</div>)の直前(<script> の前の </div> の直前)を検索してください。
</div>
<script>
この <script> タグの前の </div> の直前に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
<!-- 全画面ドラッグオーバーレイ
ファイルを画面にドラッグしている間だけ、画面全体に表示される案内です。
初期状態は非表示(display: none)で、JavaScriptがドラッグを検知した時に
visible クラスを付けて表示します。 -->
<div id="dropOverlay">
<div class="drop-message">
📂 ファイル・フォルダをドロップ
<small>画像・PDF・テキストファイル(.txt, .py, .js, .html 等)・フォルダ</small>
</div>
</div>
■ この差分(修正2)のポイント
ドロップオーバーレイ(id=”dropOverlay”)は、ファイルを画面にドラッグしている間だけ、画面全体に表示される案内です。
「ここにファイルを落としていいよ」という視覚的な合図で、ユーザーが「ドラッグ&ドロップで添付できる」ことを直感的に理解できるようにしています。
初期状態は非表示で、JavaScript(後の差分で追加するドラッグ&ドロップのイベント処理)が、ファイルのドラッグを検知した時に visible クラスを付けて表示します。
差分④:CSS(添付プレビュー・添付ボタン・ドロップオーバーレイのスタイルを追加)
差分③で追加した各要素の見た目を整えるためのスタイルを追加します。
■ 追加する場所
CSSの中にある、.user-collapse-container のブロックを検索してください。
/* ボタンを中央に配置するためのコンテナ */
.user-collapse-container {
margin-top: 4px;
text-align: center;
}
このブロックの直後(レスポンシブのメディアクエリの直前)に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
全画面ドラッグ&ドロップオーバーレイ
============================================================ */
#dropOverlay {
position: fixed; /* 画面全体に固定 */
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(91, 108, 255, 0.08);
border: 3px dashed var(--accent); /* アクセント色の点線で囲む */
z-index: 9999; /* すべての要素の上に表示 */
display: none; /* 初期状態は非表示 */
align-items: center;
justify-content: center;
pointer-events: none; /* オーバーレイ自体はクリックを透過 */
backdrop-filter: blur(2px); /* 背景を少しぼかす */
}
#dropOverlay.visible {
display: flex; /* visibleクラスが付いたら表示 */
}
.drop-message {
background: var(--bg);
padding: 24px 48px;
border-radius: 16px;
box-shadow: var(--shadow);
border: 2px solid var(--accent);
text-align: center;
color: var(--text);
font-size: 16px;
font-weight: 600;
}
.drop-message small {
display: block;
margin-top: 8px;
font-size: 12px;
font-weight: normal;
color: var(--text-sub);
}
/* ============================================================
添付プレビューエリア(選択したファイルの一覧表示)
============================================================ */
#attachmentPreviewArea {
max-width: 860px;
margin: 0 auto 10px;
display: flex;
flex-wrap: wrap; /* ファイルが多い時は折り返す */
gap: 10px;
}
/* 添付ファイル1件分(チップ) */
.attach-chip {
position: relative;
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
background: var(--bg-sidebar);
display: flex;
align-items: center;
gap: 8px;
}
/* 画像のサムネイル(小さなプレビュー) */
.attach-chip img {
width: 40px;
height: 40px;
object-fit: cover; /* 縦横比を保って切り抜き */
border-radius: 5px;
}
/* 添付ファイルの削除ボタン(✕) */
.attach-remove {
color: var(--text-sub);
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
}
.attach-remove:hover {
color: var(--danger);
background: rgba(229,72,77,.12);
}
/* ============================================================
添付ボタン(画像・PDF・テキスト・フォルダ)
============================================================ */
.attach-btn {
font-size: 18px;
color: var(--text-sub);
padding: 6px 8px;
border-radius: 8px;
}
.attach-btn:hover {
color: var(--text);
background: rgba(127,127,127,.14);
}
/* ドラッグ&ドロップ中の入力エリアの強調表示 */
#inputRow.dragging {
outline: 2px dashed var(--accent);
}
/* ============================================================
添付メタデータ表示(会話履歴の「どのファイルを添付したか」の表示)
※ステップ④:差分⑫-5 の renderMessage で使用します
============================================================ */
.msg-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.msg-attachment {
font-size: 12px;
background: rgba(127,127,127,.12);
padding: 4px 10px;
border-radius: 6px;
color: var(--text-sub);
}
■ この差分のポイント
1. ドロップオーバーレイの「画面全体に固定」
position: fixed で画面全体に固定し、z-index: 9999 で「すべての要素の上」に表示しています。
また、pointer-events: none で「オーバーレイ自体はクリックを透過」させているため、ドラッグ中に誤ってオーバーレイをクリックしてしまうことがありません。
2. 添付プレビューの「チップ」デザイン
添付したファイル1件分を「チップ」(小さなカード状の部品)として表示します。
画像の場合はサムネイル(小さなプレビュー)を、PDFやテキストファイルの場合はファイル名とサイズを表示し、右端に「✕」の削除ボタンを付けています。
3. object-fit: cover によるサムネイルの切り抜き
画像のサムネイルは object-fit: cover で「縦横比を保ったまま切り抜く」ようにしています。
これにより、縦長・横長どちらの画像でも、40×40pxの正方形にきれいに収まります。
4. #inputRow.dragging によるドラッグ中の強調表示
ファイルを入力エリアにドラッグしている間、JavaScriptが dragging クラスを付けて、入力エリアの枠をアクセント色の点線で強調します。
「ここに落としていいよ」という視覚的な合図です(後の差分で追加するドラッグ&ドロップのイベント処理で使用します)。
差分⑤:JavaScript【1】定数(アップロード制限の設定値を追加)
ここからは、JavaScriptの拡張に入ります。
まずJavaScript【1】の定数セクションに、「アップロード制限の設定値」を追加します。
これらは、画像・PDF・テキストファイルの「サイズ上限」や「対応形式」を決める値で、完成形と同じ内容です。
■ 追加する場所
JavaScript【1】定数セクションにある、以下の変数宣言の行を検索してください。
let REQUEST_TIMEOUT_MS = 360000; // APIタイムアウト(360秒=6分)
この行の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- アップロード制限の設定値(ステップ④で追加) ---
画像・PDF・テキストファイルの「サイズ上限」や「対応形式」を
決める値です。これらは「変数」として宣言しており、
設定パネルで変更された値が、後の差分で追加する
applyUploadSettings() によって反映されます。 */
/* 画像の容量の上限(デフォルト: 10MB) */
let MAX_IMAGE_SIZE = 10 * 1024 * 1024;
/* PDFの容量の上限(デフォルト: 20MB) */
let MAX_PDF_SIZE = 20 * 1024 * 1024;
/* PDFのページ数の上限(デフォルト: 10ページ) */
let MAX_PDF_PAGES = 10;
/* 対応する画像の形式(これ以外の形式は添付できません) */
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
/* 対応するテキストファイルの拡張子(これ以外は添付できません) */
const TEXT_FILE_EXTENSIONS = [
'.txt', '.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.htm', '.css', '.scss', '.sass', '.less',
'.json', '.md', '.markdown', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf',
'.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.java', '.cs', '.php', '.rb', '.go', '.rs',
'.sql', '.sh', '.bash', '.zsh', '.fish', '.bat', '.cmd', '.ps1', '.log',
'.vue', '.svelte', '.swift', '.kt', '.kts', '.scala', '.pl', '.pm', '.r', '.m', '.mm',
'.gradle', '.properties', '.env', '.gitignore', '.dockerignore', '.editorconfig',
'.dockerfile', '.makefile', '.cmake', '.lock', '.csv', '.tsv', '.tex', '.rst'
];
/* テキストファイル1件あたりの容量の上限(デフォルト: 5MB) */
let MAX_TEXT_FILE_SIZE = 5 * 1024 * 1024;
/* テキストファイル全体の合計容量の上限(デフォルト: 20MB) */
let MAX_TEXT_TOTAL_SIZE = 20 * 1024 * 1024;
■ この差分のポイント
1. 上限値は「let(変数)」、対応形式は「const(定数)」で宣言
MAX_IMAGE_SIZE などの上限値は、設定パネルで変更できるように let(変数)で宣言しています。
一方、ALLOWED_IMAGE_TYPES や TEXT_FILE_EXTENSIONS は「対応形式の一覧」で、これはアプリの仕様として変わらないため const(定数)で宣言しています。
2. 「1024 * 1024」の意味
10 * 1024 * 1024 は、バイト数の計算です。
1KB = 1024バイト、1MB = 1024 × 1024バイト(約104万バイト)なので、10 * 1024 * 1024 は「10MB」をバイト数で表しています。
「10 * 1024 * 1024」のように計算式の形で書いておくと、「これは10MBだ」と一目で分かるため、完成形でもこの書き方が採用されています。
3. この時点では「宣言しただけ」
これらの値は、まだ「宣言しただけ」で、実際のファイル処理には使われていません。
実際に使われるのは、後の差分で追加する「ファイル処理関数」(差分⑩)と「設定反映関数」(差分⑨)の中です。
差分⑥:JavaScript【2】状態管理(添付ファイルの状態管理変数を追加)
次にJavaScript【2】の状態管理セクションに、「添付したファイルを覚えておくための変数」を追加します。
■ 追加する場所
JavaScript【2】状態管理セクションの末尾にある、以下の2行を検索してください。
let fullReasoningText = ""; // 思考過程バッファ
let currentReasoningEl = null; // 思考過程表示DOM
この2行の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- 添付ファイルの状態管理(ステップ④で追加) ---
ユーザーが添付したファイルを、送信までの間
メモリ上で覚えておくための変数です。 */
let attachedImages = []; // 添付画像 [{name, size, type, dataUrl}]
let attachedPdf = null; // 添付PDF {name, size, pageRange, text, pageImages}
let attachedTextFiles = []; // 添付テキストファイル [{name, size, type, content, path}]
■ この差分のポイント
1. 3つの変数の役割
| 変数 | 役割 |
|---|---|
| attachedImages | 添付された画像(複数可)を配列で保持。 1件ごとに「ファイル名・サイズ・形式・画像データ(dataUrl)」を持つ |
| attachedPdf | 添付されたPDF(1件のみ)を保持。 「ファイル名・サイズ・ページ範囲・抽出テキスト・ページ画像」を持つ。 添付されていない時は null |
| attachedTextFiles | 添付されたテキストファイル(複数可)を配列で保持。 1件ごとに「ファイル名・サイズ・形式・中身のテキスト・パス」を持つ |
2. なぜ「配列」と「null」で初期値が違うのか
画像とテキストファイルは「複数添付できる」ため、空の配列([])で初期化しています。
PDFは「1件だけ」しか添付できないため、null(何もない)で初期化し、「添付されているかどうか」を if (attachedPdf) で判定できるようにしています。
3. dataUrl とは
コメントにある dataUrl は、画像データを「文字列」に変換したものです(Base64エンコード)。
画像をそのままAPIに送ることはできないため、「文字列の形」に変換して送信します。
この変換処理は、後の差分で追加する convertImageToDataUrl 関数が担当します。
差分⑦:JavaScript【3】DOM参照(新しい部品を変数に登録)
次にJavaScript【3】のDOM参照セクションに、差分②・③で追加した各部品のDOM参照を登録します。
■ 追加する場所
JavaScript【3】DOM参照セクションの末尾にある、以下の変数宣言の行を検索してください。
/* 「コピー」ボタン */
const copyPromptBtn = $("#copyPromptBtn");
この行の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- ステップ④で追加した添付関連のDOM参照 --- */
/* 添付プレビューエリア(選択したファイルの一覧表示) */
const attachmentPreviewArea = $("#attachmentPreviewArea");
/* 入力エリアの「カード」(ドラッグ中の強調表示に使う) */
const inputRow = $("#inputRow");
/* 隠しファイル入力(添付ボタンが押された時に開く) */
const imageFileInput = $("#imageFileInput");
const pdfFileInput = $("#pdfFileInput");
const textFileInput = $("#textFileInput");
const folderFileInput = $("#folderFileInput");
/* ドロップオーバーレイ(ドラッグ&ドロップの案内表示) */
const dropOverlay = $("#dropOverlay");
/* --- アップロード上限の設定項目のDOM参照 --- */
const maxImageSizeInput = $("#maxImageSizeInput");
const maxPdfSizeInput = $("#maxPdfSizeInput");
const maxPdfPagesInput = $("#maxPdfPagesInput");
const maxTextSizeInput = $("#maxTextSizeInput");
■ この差分のポイント
1. 「添付プレビューエリア」と「隠しファイル入力」の2種類がある
添付機能には、大きく分けて2種類の部品があります。
・attachmentPreviewArea:
選択したファイルの一覧を「表示する」ためのエリア(差分③で追加)
・imageFileInput など4つ:
画面には見えない「隠しファイル入力」で、添付ボタンが押された時にJavaScriptがこれらをクリックして、ファイル選択ダイアログを開きます。
2. inputRow も取得する理由
inputRow(入力エリアのカード)は、ファイルをドラッグしている間、JavaScriptが dragging クラスを付け外しして「枠をアクセント色の点線で強調」するために使います(差分⑫のドラッグ&ドロップ処理で使用)。
3. 設定項目のDOM参照
maxImageSizeInput などは、設定パネルで変更された上限値を読み取るために使います。
後の差分⑨で追加する applyUploadSettings() の中で、これらの値が MAX_IMAGE_SIZE などの変数に反映されます。
差分⑧:JavaScript【4】ユーティリティ(formatBytes 関数を追加)
JavaScript【4】のユーティリティセクションに、「ファイルサイズを読みやすい形式で表示する」ための小さな便利関数 formatBytes を追加します。
■ 追加する場所
JavaScript【4】ユーティリティセクションにある、以下のgenerateId 関数を検索してください。
/* --- 一意なIDを作る(会話の識別番号として使う) --- */
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
この関数の閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- ファイルサイズを読みやすい形式で表示する ---
バイト数を受け取り、「○○ B」「○○ KB」「○○ MB」の
読みやすい文字列に変換して返します。
例:1024 → "1.0 KB"、1048576 → "1.0 MB"
(完成形の formatBytes と同じ仕様) */
function formatBytes(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
■ この差分のポイント
1. この関数の役割
ファイルのサイズは「バイト数」で得られますが、「10485760バイト」と表示されても、人間には読みにくいです。
この関数は、バイト数を「10.0 MB」のような読みやすい文字列に変換します。
2. 3段階の判定
・1024バイト未満
… 「○○ B」(バイト)のまま表示
・1024バイト〜1MB未満
… 1024で割って「○○ KB」表示
・1MB以上
… 1024 × 1024 で割って「○○ MB」表示
toFixed(1) は「小数点以下1桁で表示する」命令で、「10.0 MB」のようにきれいに表示されます。
3. どこで使われるか
この関数は、後の差分で追加する以下の場面で使われます。
・添付プレビューで「ファイル名 (10.0 MB)」と表示する時
・サイズ超過のエラーメッセージで「上限は 10.0 MB です」と表示する時
・設定パネルのヒント表示で「10.0MB まで」と表示する時(差分⑨の updateSettingHints)
ここまでの中間確認
差分⑤〜⑧で、以下の「土台」が整いました。
・差分⑤:アップロード制限の設定値(MAX_IMAGE_SIZE など)を宣言
・差分⑥:添付ファイルの状態管理変数(attachedImages など)を宣言
・差分⑦:新しい部品のDOM参照を登録
・差分⑧:ファイルサイズ表示の便利関数(formatBytes)を追加
まだこの時点では「宣言・登録しただけ」で、画面には何も変化がありません。
次の差分⑨で「設定値を実際の動作に反映する関数」を追加し、差分⑩で「ファイル処理関数」、差分⑪で「送信処理の拡張」、差分⑫で「イベント登録」を行って、初めてアップロード機能が動き出します。
差分⑨:JavaScript【6】UI設定(applyRequestTimeoutSetting を applyUploadSettings に統合・拡張+updateSettingHints の拡張)
次に、ステップ②で「タイムアウト部分だけを取り出した関数」として作った applyRequestTimeoutSetting() を、完成形のアプリと同じ applyUploadSettings() に統合・拡張します。
これは「設定パネルの値を、アプリの動作パラメータ(変数)に反映する関数」で、タイムアウトに加えて、差分⑤で宣言したアップロード上限の変数(MAX_IMAGE_SIZE など)にも、設定パネルの値を反映させる役割を持ちます。
あわせて、設定パネルの「ヒント表示」を更新する updateSettingHints() も拡張し、画像・PDF・PDFページ数・テキストファイルのヒント表示にも対応させます。
なぜ「統合」が必要なのか?
ステップ②の差分⑥で、「2つの設計パターンの違い」を学びました。
| パターン | 該当する設定 | 仕組み |
|---|---|---|
| 「使うたびに読む」 (関数経由) | モデル名 画面更新間隔 max_tokens など | getSelectedModel() や getRenderInterval() のように、 呼ばれるたびに設定パネルの値を読 みにいく |
| 「保持しておき、変更時に 更新する」(変数保持) | APIタイムアウト アップロード上限 (ステップ④で追加) | 変数に保持しておき、 設定の保存時・起動時に まとめて更新する |
アップロード上限の変数(MAX_IMAGE_SIZE など)は、後の差分⑩で追加する「ファイル処理関数」の中で「添付されたファイルのサイズを検証する」ために使われます。
これらの変数は、ファイルが選択されるたびに頻繁に読まれるため、「使うたびに読む」方式ではなく、「変数に保持しておき、設定変更時にまとめて更新する」方式が適しています。
そして、タイムアウトの変数(REQUEST_TIMEOUT_MS)も同じ方式です。
つまり、これらの変数は「同じタイミングで、まとめて更新する」のが自然な設計です。
そこで、タイムアウト専用の applyRequestTimeoutSetting() を、アップロード上限も含めてまとめて更新する applyUploadSettings() に統合します。
「カスタムAIチャットアプリ」(ダウンロード版)と同じ設計です。
■ 修正1:applyRequestTimeoutSetting を applyUploadSettings に置き換え
置き換える場所
JavaScript【6】UI設定セクションにある、以下のapplyRequestTimeoutSetting 関数全体を検索してください。
/* --- APIタイムアウト設定を REQUEST_TIMEOUT_MS に反映する ---
設定パネルの「APIタイムアウト(秒)」の値を読み取り、
通信処理で使う変数 REQUEST_TIMEOUT_MS を更新します。
(完成形の applyUploadSettings と同じ役割の、
タイムアウト部分だけを取り出した関数です。
ステップ④で applyUploadSettings に統合します) */
function applyRequestTimeoutSetting() {
REQUEST_TIMEOUT_MS = (parseInt(requestTimeoutInput.value, 10) || 360) * 1000;
}
この関数全体を削除し、以下のコードに置き換えてください。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- アップロード設定を、アプリの動作パラメータ(変数)に反映する ---
設定パネルの値を読み取り、以下の変数をまとめて更新します。
・画像アップロード上限(MAX_IMAGE_SIZE)
・PDFアップロード上限(MAX_PDF_SIZE)
・テキストファイル上限(MAX_TEXT_FILE_SIZE)
・テキストファイル合計上限(MAX_TEXT_TOTAL_SIZE)
・APIタイムアウト(REQUEST_TIMEOUT_MS)
・PDFページ数上限(MAX_PDF_PAGES)
(完成形の applyUploadSettings と同じ仕様) */
function applyUploadSettings() {
/* 画像の容量の上限(設定値のKBをバイト数に変換) */
MAX_IMAGE_SIZE = (parseInt(maxImageSizeInput.value, 10) || 10240) * 1024;
/* PDFの容量の上限(設定値のKBをバイト数に変換) */
MAX_PDF_SIZE = (parseInt(maxPdfSizeInput.value, 10) || 20480) * 1024;
/* テキストファイル1件あたりの容量の上限(設定値のKBをバイト数に変換) */
MAX_TEXT_FILE_SIZE = (parseInt(maxTextSizeInput.value, 10) || 5120) * 1024;
/* テキストファイル全体の合計容量の上限(1件あたりの4倍に設定) */
MAX_TEXT_TOTAL_SIZE = MAX_TEXT_FILE_SIZE * 4;
/* APIタイムアウト(設定値の秒をミリ秒に変換) */
REQUEST_TIMEOUT_MS = (parseInt(requestTimeoutInput.value, 10) || 360) * 1000;
/* PDFのページ数の上限(設定値をそのまま使用) */
MAX_PDF_PAGES = parseInt(maxPdfPagesInput.value, 10) || 10;
}
■ この差分のポイント(修正1)
1. タイムアウト専用の関数を、アップロード上限も含めた関数に統合
ステップ②では「タイムアウト部分だけを取り出した関数」として作った applyRequestTimeoutSetting() を、ステップ④で完成形の applyUploadSettings() に統合しました。
関数名が変わったため、後の修正3・4で「呼び出し側」も書き換えます。
2. 設定値の単位変換
設定パネルでは「KB」(キロバイト)や「秒」で入力しますが、アプリの動作パラメータ(変数)は「バイト数」や「ミリ秒」で保持します。
そのため、設定値を読み取ったあと、* 1024(KB → バイト)や * 1000(秒 → ミリ秒)で単位変換しています。
3. MAX_TEXT_TOTAL_SIZE は「1件あたりの4倍」に設定
テキストファイルは「複数添付できる」ため、全体の合計容量の上限(MAX_TEXT_TOTAL_SIZE)を「1件あたりの上限(MAX_TEXT_FILE_SIZE)の4倍」に設定しています。
これにより、「1件5MBまで、合計20MBまで」という、完成形と同じ制限になります。
■ 修正2:updateSettingHints を拡張(画像・PDF・PDFページ数・テキストのヒントを追加)
次に、設定パネルの「ヒント表示」を更新する updateSettingHints() を拡張し、画像・PDF・PDFページ数・テキストファイルのヒント表示にも対応させます。
置き換える場所
JavaScript【6】UI設定セクションにある、以下のupdateSettingHints 関数全体を検索してください。
function updateSettingHints() {
/* APIタイムアウトのヒント:「360秒」→「6.0分 まで」のように分に換算して表示 */
const timeoutSec = parseInt(requestTimeoutInput.value, 10) || 360;
timeoutHint.textContent = timeoutSec >= 60
? (timeoutSec / 60).toFixed(1) + "分 まで"
: timeoutSec + "秒 まで";
}
この関数全体を削除し、以下のコードに置き換えてください。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- 設定パネルの補足表示(ヒント)を、現在の設定値に合わせて更新する ---
画像・PDF・PDFページ数・テキストファイル・APIタイムアウトの
ヒント表示を、それぞれ現在の設定値に合わせて書き換えます。
(完成形の updateSettingHints と同じ仕様) */
function updateSettingHints() {
/* 画像アップロード上限のヒント:「10240KB」→「10.0MB まで」のようにMBに換算して表示 */
const imageKB = parseInt(maxImageSizeInput.value, 10) || 10240;
const imageMB = (imageKB / 1024).toFixed(1);
document.getElementById("imageSizeHint").textContent =
imageMB >= 1 ? `${imageMB}MB まで` : `${imageKB}KB まで`;
/* PDFアップロード上限のヒント:「20480KB」→「20.0MB まで」のようにMBに換算して表示 */
const pdfKB = parseInt(maxPdfSizeInput.value, 10) || 20480;
const pdfMB = (pdfKB / 1024).toFixed(1);
document.getElementById("pdfSizeHint").textContent =
pdfMB >= 1 ? `${pdfMB}MB まで` : `${pdfKB}KB まで`;
/* PDFページ数上限のヒント:「10」→「10ページ まで」のように表示 */
const pdfPages = parseInt(maxPdfPagesInput.value, 10) || 10;
document.getElementById("pdfPagesHint").textContent = `${pdfPages}ページ まで`;
/* テキストファイル上限のヒント:「5120KB」→「5.0MB まで」のようにMBに換算して表示 */
const textKB = parseInt(maxTextSizeInput.value, 10) || 5120;
const textMB = (textKB / 1024).toFixed(1);
document.getElementById("textSizeHint").textContent =
textMB >= 1 ? `${textMB}MB まで` : `${textKB}KB まで`;
/* APIタイムアウトのヒント:「360秒」→「6.0分 まで」のように分に換算して表示 */
const timeoutSec = parseInt(requestTimeoutInput.value, 10) || 360;
const timeoutMin = (timeoutSec / 60).toFixed(1);
document.getElementById("timeoutHint").textContent =
timeoutMin >= 1 ? `${timeoutMin}分 まで` : `${timeoutSec}秒 まで`;
}
■ この差分のポイント(修正2)
1. KB(キロバイト)を「MB」に換算して表示
設定パネルでは「KB」で入力しますが、ヒント表示は「10.0MB まで」のように、より直感的な「MB」に換算して表示します。
imageMB >= 1 という条件で「1MB以上か」を判定し、1MB未満の場合は「512KB まで」のようにKBのまま表示する、完成形と同じ仕様です。
2. timeoutHint だけは「分」に換算
APIタイムアウトは「秒」で入力しますが、ヒント表示は「6.0分 まで」のように「分」に換算して表示します。
これはステップ②で実装した処理と同じもので、完成形の updateSettingHints では、すべてのヒント(画像・PDF・PDFページ数・テキスト・タイムアウト)がこの1つの関数に集約されています。
3. document.getElementById(“…Hint”) で、ヒント表示の要素を直接取得
すべてのヒント表示を document.getElementById() で取得する形に統一しました(完成形と同じ実装です)。
なお、タイムアウト用の変数 timeoutHint は【3】のDOM参照に残っていますが、この関数内では使用していません。
■ 修正3:saveUiSettings 内の呼び出しを applyUploadSettings() に書き換え+アップロード上限の保存項目を追加
関数名を applyRequestTimeoutSetting() から applyUploadSettings() に変更したため、呼び出し側も書き換えます。
あわせて、新しく追加したアップロード上限の設定項目(画像・PDF・PDFページ数・テキスト)も、localStorage に保存するようにします。
■ 修正する場所
saveUiSettings 関数内にある、以下のコードブロックを検索してください。
const settings = {
dark: document.body.classList.contains("dark"),
model: modelInput.value,
reasoningEffort: normalizeReasoningEffort(reasoningEffortSelect.value),
maxTokens: maxTokensInput.value,
renderInterval: renderIntervalInput.value,
thinkingMode: thinkingModeSelect.dataset.k26Value || thinkingModeSelect.value,
requestTimeout: requestTimeoutInput.value,
/* ステップ③で追加したカスタムプロンプト設定 */
customPromptMode: customPromptModeToggle.checked,
customPrompt: customSystemPrompt.value
};
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));
/* 変更されたタイムアウト値を即座に反映する */
applyRequestTimeoutSetting();
これを以下のコードに置き換えてください(保存項目の追加と、呼び出し関数名の変更を行います)。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
const settings = {
dark: document.body.classList.contains("dark"),
model: modelInput.value,
reasoningEffort: normalizeReasoningEffort(reasoningEffortSelect.value),
maxTokens: maxTokensInput.value,
renderInterval: renderIntervalInput.value,
thinkingMode: thinkingModeSelect.dataset.k26Value || thinkingModeSelect.value,
requestTimeout: requestTimeoutInput.value,
/* ステップ③で追加したカスタムプロンプト設定 */
customPromptMode: customPromptModeToggle.checked,
customPrompt: customSystemPrompt.value,
/* ステップ④で追加したアップロード上限の設定 */
maxImageSize: maxImageSizeInput.value,
maxPdfSize: maxPdfSizeInput.value,
maxTextSize: maxTextSizeInput.value,
maxPdfPages: maxPdfPagesInput.value
};
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));
/* 変更された設定値を、動作パラメータ(変数)に即座に反映する */
applyUploadSettings();
■ この差分のポイント(修正3)
1. アップロード上限の4項目を保存対象に追加
maxImageSize(画像上限)・maxPdfSize(PDF上限)・maxTextSize(テキスト上限)・maxPdfPages(PDFページ数上限)を、localStorage の保存対象に追加しました。
これで、変更した上限設定も、画面を更新しても残るようになります。
2. 呼び出し関数名を applyUploadSettings() に変更
修正1で関数名を変更したため、呼び出し側も applyRequestTimeoutSetting() から applyUploadSettings() に書き換えています。
■ 修正4:loadUiSettings 内の呼び出しを applyUploadSettings() に書き換え+アップロード上限の復元処理を追加
最後に、起動時に保存された設定を復元する loadUiSettings も、applyUploadSettings() への書き換えと、アップロード上限の復元処理の追加を行います。
■ 修正する場所
loadUiSettings 関数内にある、以下のコードブロックを検索してください。
/* ステップ③で追加したカスタムプロンプト設定の復元 */
if (s.customPromptMode !== undefined) {
customPromptModeToggle.checked = s.customPromptMode;
if (s.customPromptMode) {
customSystemPrompt.value = s.customPrompt || DEFAULT_SYSTEM_PROMPT;
}
}
}
} catch (e) { /* 無視 */ }
/* 起動時に、モデルに応じた設定項目の表示を更新する */
updateModelSpecificSettingsUI();
/* 保存済みのタイムアウト設定を反映する */
applyRequestTimeoutSetting();
これを以下のコードに置き換えてください(アップロード上限の復元処理の追加と、呼び出し関数名の変更を行います)。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ステップ③で追加したカスタムプロンプト設定の復元 */
if (s.customPromptMode !== undefined) {
customPromptModeToggle.checked = s.customPromptMode;
if (s.customPromptMode) {
customSystemPrompt.value = s.customPrompt || DEFAULT_SYSTEM_PROMPT;
}
}
/* ステップ④で追加したアップロード上限の設定の復元 */
if (s.maxImageSize) maxImageSizeInput.value = s.maxImageSize;
if (s.maxPdfSize) maxPdfSizeInput.value = s.maxPdfSize;
if (s.maxTextSize) maxTextSizeInput.value = s.maxTextSize;
if (s.maxPdfPages) maxPdfPagesInput.value = s.maxPdfPages;
}
} catch (e) { /* 無視 */ }
/* 起動時に、モデルに応じた設定項目の表示を更新する */
updateModelSpecificSettingsUI();
/* 保存済みの設定値を、動作パラメータ(変数)に反映する */
applyUploadSettings();
■ この差分のポイント(修正4)
1. アップロード上限の復元処理を追加
localStorage に保存されていた上限値を、起動時に各入力欄の value プロパティに代入しています。
if (s.maxImageSize) という条件で「保存データにその項目があるか」を確認しているのは、古いバージョン(ステップ③以前)で保存したデータには、これらの項目が存在しないためです。
項目がない場合は、入力欄の初期値(HTMLに書かれた value 属性の値)がそのまま使われます。
2. 呼び出し関数名を applyUploadSettings() に変更
修正1・修正3と同様に、呼び出し側も applyUploadSettings() に書き換えています。
これで、起動時に保存された上限値が、動作パラメータ(MAX_IMAGE_SIZE などの変数)に復元されます。
3. この直後にある updateSettingHints() と updateCustomPromptVisibility() の呼び出しは変更不要
loadUiSettings の末尾にある updateSettingHints() と updateCustomPromptVisibility() の呼び出しは、そのまま残してください。
updateSettingHints() は修正2で拡張したため、起動時にすべてのヒント表示(画像・PDF・PDFページ数・テキスト・タイムアウト)が、保存された値に合わせて正しく表示されます。
差分⑨の全体像(まとめ)
差分⑨では、4つの修正を行いました。
| 修正 | 内容 |
|---|---|
| 修正1 | applyRequestTimeoutSetting() を applyUploadSettings() に統合・拡張。 タイムアウトに加えて、アップロード上限の変数(MAX_IMAGE_SIZE など)も まとめて更新するようにした |
| 修正2 | updateSettingHints() を拡張。 タイムアウトに加えて、画像・PDF・PDFページ数・テキストのヒント表示も更新 するようにした |
| 修正3 | saveUiSettings に、アップロード上限の4項目の保存を追加し、 呼び出しを applyUploadSettings() に書き換えた |
| 修正4 | loadUiSettings に、アップロード上限の4項目の復元を追加し、 呼び出しを applyUploadSettings() に書き換えた |
これで、「設定パネルで変更した上限値が、localStorage に保存・復元され、かつ動作パラメータ(変数)に反映される」という、設定まわりの土台が完成しました。
あとは、後の差分⑫で「アップロード上限の設定項目の変更時イベント」を登録すれば、設定パネルでの上限変更が完全に機能するようになります。
ここまでの中間確認(差分⑤〜⑨)
ステップ④も、折り返し地点を過ぎました。
ここまでに整った「土台」を確認しておきましょう。
| 差分 | 整った土台 |
|---|---|
| 差分① | PDF.jsライブラリの読み込み |
| 差分② | 設定パネルに「アップロード上限」の設定項目を追加 |
| 差分③ | 添付ボタン・添付プレビュー・ドロップオーバーレイ(HTML) |
| 差分④ | 添付プレビュー・ドロップオーバーレイのスタイル(CSS) |
| 差分⑤ | アップロード制限の設定値(MAX_IMAGE_SIZE など)を宣言 |
| 差分⑥ | 添付ファイルの状態管理変数(attachedImages など)を宣言 |
| 差分⑦ | 新しい部品のDOM参照を登録 |
| 差分⑧ | ファイルサイズ表示の便利関数(formatBytes)を追加 |
| 差分⑨ | 設定値の保存・復元・動作パラメータへの反映(applyUploadSettings など) |
次の差分⑩で、いよいよ「ファイル処理関数」(画像・PDF・テキスト・フォルダの読み込みと添付)を追加します。
ステップ④の中で最もボリュームの大きい差分ですが、これが完成すれば、実際にファイルを添付できるようになります。
差分⑩-1:JavaScript(新規セクション)画像処理(handleImageUpload・convertImageToDataUrl・removeAttachedImage)
いよいよ、ステップ④の中で最もボリュームの大きい差分⑩「ファイル処理関数」に入ります。
差分⑩は、ファイルの種類ごとに4つのパートに分けて進めます。
・差分⑩-1:
画像処理(この記事)
・差分⑩-2:
テキストファイル・フォルダ処理
・差分⑩-3:
PDF処理
・差分⑩-4:
添付プレビュー描画と、マルチモーダルメッセージの構築
まずは画像処理から始めます。
ここでは、以下の3つの関数を、JavaScriptの新しいセクションとして追加します。
| 関数名 | 役割 |
|---|---|
| handleImageUpload(files) | 選択された画像ファイルの「形式」と「サイズ」を検証し、 問題なければ添付リスト(attachedImages)に追加する |
| convertImageToDataUrl(file) | 画像ファイルを「dataUrl」(Base64エンコードした文字列)に 変換する |
| removeAttachedImage(index) | 添付プレビューの「✕」ボタンが押された時に、 指定した画像を添付リストから削除する |
■ 追加する場所
JavaScript【15】UI状態管理セクションの末尾にある、autoResizeTextarea 関数の閉じ括弧 } の直後に追加します。
まず、以下のコードブロックを検索してください(autoResizeTextarea 関数の全体です)。
/* --- 入力欄の高さを、入力量に合わせて自動調整する --- */
function autoResizeTextarea() {
messageInput.style.height = "auto"; // 一度リセットしてから…
/* scrollHeight(内容の実際の高さ)に合わせる(上限はCSSで200px) */
messageInput.style.height = Math.min(messageInput.scrollHeight, 200) + "px";
}
この閉じ括弧 } の直後(【16】メッセージ送信セクションの直前)に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
画像処理(ステップ④で追加)
------------------------------------------------------------
選択された画像ファイルの「形式」と「サイズ」を検証し、
問題なければ添付リスト(attachedImages)に追加します。
画像は「dataUrl」(Base64エンコードした文字列)に変換して
保持し、送信時にマルチモーダル形式でAPIに送ります。
(完成形の画像処理関数と同じ仕様)
============================================================ */
/* --- 画像ファイルの添付処理 ---
引数 files:選択されたファイルの配列(FileList または Array)
1件ずつ「形式」と「サイズ」を検証し、問題なければ添付します。 */
async function handleImageUpload(files) {
for (const file of files) {
/* 形式検証:対応していない形式(JPEG / PNG / GIF / WebP 以外)はスキップ */
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
showError(`画像形式が不正です(${file.name})。JPEG / PNG / GIF / WebP のみ対応しています。`);
continue;
}
/* サイズ検証:上限を超えている場合はスキップ */
if (file.size > MAX_IMAGE_SIZE) {
showError(`画像サイズが上限を超えています(${file.name}: ${formatBytes(file.size)})。上限は ${formatBytes(MAX_IMAGE_SIZE)} です。`);
continue;
}
try {
/* 画像を「dataUrl」(Base64エンコードした文字列)に変換して添付リストに追加 */
const dataUrl = await convertImageToDataUrl(file);
attachedImages.push({ name: file.name, size: file.size, type: file.type, dataUrl });
} catch (e) {
showError(`画像の読み込みに失敗しました(${file.name})。`);
}
}
/* 添付プレビューを更新する(renderAttachmentPreview は差分⑩-4で追加します) */
renderAttachmentPreview();
}
/* --- 画像ファイルを「dataUrl」に変換する ---
引数 file:画像ファイル
戻り値:dataUrl(Base64エンコードした文字列)を返す Promise
FileReader API を使って、画像データを「文字列」の形に変換します。 */
function convertImageToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
/* 読み込み成功時:dataUrl(文字列)を返す */
reader.onload = () => resolve(reader.result);
/* 読み込み失敗時:エラーを返す */
reader.onerror = () => reject(new Error("read error"));
/* readAsDataURL で、画像を「data:image/png;base64,...」の形に変換 */
reader.readAsDataURL(file);
});
}
/* --- 添付画像の削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
指定した位置(index)の画像を添付リストから削除します。 */
function removeAttachedImage(index) {
attachedImages.splice(index, 1); // 指定した位置の1件を削除
renderAttachmentPreview(); // 添付プレビューを更新
}
■ この差分のポイント
ここでは、3つの重要なポイントを押さえておきましょう。
1. 「形式」と「サイズ」の2段階検証(バリデーション)
handleImageUpload では、画像を添付する前に、2つの検証を行っています。
① 形式検証:
ALLOWED_IMAGE_TYPES(差分⑤で宣言した対応形式の一覧)に含まれているかを確認します。
含まれていない形式(例:.bmp や .svg)は、エラーを表示してスキップします。
② サイズ検証:
file.size(ファイルのサイズ)が MAX_IMAGE_SIZE(差分⑤で宣言した上限)を超えていないかを確認します。
超えている場合は、エラーを表示してスキップします。
この「おかしなデータは、早い段階で門前払いする」という考え方は、ステップ①で学んだバリデーションの実践です。
そして、エラーメッセージには formatBytes()(差分⑧で追加した関数)を使って、「5.0 MB」のような読みやすい形式でサイズを表示しています。
2. convertImageToDataUrl:画像を「文字列」に変換する仕組み
ここが、画像処理の最も重要なポイントです。
画像をそのままAPIに送ることはできません。
APIは「テキスト(文字列)」のデータしか受け付けないため、画像データを「文字列の形」に変換する必要があります。
そこで使うのが、ブラウザ標準の FileReader API です。
const reader = new FileReader();
reader.onload = () => resolve(reader.result); // 読み込み成功時:dataUrl を返す
reader.onerror = () => reject(new Error("read error")); // 失敗時:エラーを返す
reader.readAsDataURL(file); // 画像を「data:image/png;base64,...」の形に変換
reader.readAsDataURL(file) と1行呼ぶだけで、画像データは
「data:image/png;base64,iVBORw0KG…」
という、dataUrl(Base64エンコードした文字列)に変換されます。
Base64(ベース64)エンコード とは、バイナリデータ(画像など)を、64種類の文字(A〜Z・a〜z・0〜9・+・/)だけで表現する変換方式です。
これにより、画像データを「文字列」として扱えるようになり、APIに送信できる形になります。
また、この関数は Promise(プロミス) を返しています。
これは「読み込みが終わるまで待つ」ための仕組みで、呼び出し側(handleImageUpload)では await convertImageToDataUrl(file) のように、await で「変換が終わるまで待ってから」添付リストに追加しています。
3. splice による「指定位置の削除」
removeAttachedImage では、attachedImages.splice(index, 1) という1行で、添付リストから「指定した位置の1件」を削除しています。
splice(スプライス) は、配列の「途中の要素を削除・置換・挿入」するための命令です。
attachedImages.splice(index, 1) は、「index の位置から1件を削除する」という意味です。
添付プレビューの各「チップ」には、それぞれの画像に対応する「✕」ボタンがあり、押されるとその画像の位置(index)が removeAttachedImage に渡されて削除されます。
この「✕」ボタンと index の結び付けは、後の差分⑩-4で追加する renderAttachmentPreview 関数の中で行われます。
この時点での動作について(重要な注意)
ここで1つ、重要な注意があります。
差分⑩-1のコードを追加した時点では、以下の関数がまだ存在しません。
・renderAttachmentPreview()(添付プレビューを描画する関数)
この関数は、差分⑩-4で追加します。
つまり、差分⑩-1〜⑩-3をすべて追加し、差分⑩-4まで完了して初めて、アップロード機能が動き出します。
そのため、差分⑩-1を追加しただけの状態でブラウザを開くと、「renderAttachmentPreview is not defined」(renderAttachmentPreview が定義されていません)というエラーが出る可能性があります。
これは正常な状態です。
差分⑩は「1つの大きな機能」を4つのパートに分けて追加しているため、すべてのパートが揃うまで、エラーが出るのは当然です。
焦らず、差分⑩-4まで進めてください。
もし途中で動作確認をしたい場合は、一時的に以下の「空っぽの関数」を、差分⑩-1のコードの直後に追加しておくと、エラーを回避できます(差分⑩-4で本物の関数に置き換えます)。
/* 一時的な空っぽの関数(差分⑩-4で本物に置き換えます) */
function renderAttachmentPreview() {
/* 差分⑩-4で実装します */
}
ただし、通常は差分⑩-4まで一気に進めてしまうのがおすすめです。
ここまでの中間確認(差分⑩-1)
差分⑩-1で、以下の「画像処理の土台」が整いました。
・handleImageUpload:
画像の「形式」と「サイズ」を検証し、添付リストに追加する
・convertImageToDataUrl:
画像を「dataUrl」(Base64エンコードした文字列)に変換する
・removeAttachedImage:
指定した画像を添付リストから削除する
まだこの時点では「関数を追加しただけ」で、これらの関数が実際に呼ばれるのは、後の差分⑫(イベント登録)で「添付ボタンが押された時」「ドラッグ&ドロップでファイルが落とされた時」などです。
次の差分⑩-2では、「テキストファイル・フォルダ処理」を追加します。
テキストファイルの「拡張子チェック」と「中身の読み込み」、そしてフォルダ内のファイルを「種類ごとに振り分ける」処理を学びます。
差分⑩-2:JavaScript(新規セクション)テキストファイル・フォルダ処理(isTextFile・readFileAsText・handleTextFiles・handleFolderUpload)
続いて、テキストファイルとフォルダの添付処理を追加します。
ここでは、以下の4つの関数を追加します。
| 関数名 | 役割 |
|---|---|
| isTextFile(file) | そのファイルが「テキストファイル」かどうかを判定する (拡張子の一覧、または MIMEタイプで判定) |
| readFileAsText(file) | ファイルの中身を「テキスト」として読み込む |
| handleTextFiles(files) | 選択されたテキストファイルの「拡張子」「サイズ」を検証し、 問題なければ添付リスト(attachedTextFiles)に追加する |
| handleFolderUpload(files) | フォルダ内のファイルを「テキスト・画像・PDF」の 種類ごとに振り分けて、それぞれの添付処理に渡す |
■ 追加する場所
差分⑩-1で追加した「画像処理」セクションの末尾にある、removeAttachedImage 関数の閉じ括弧 } の直後に追加します。
まず、以下のコードブロックを検索してください(差分⑩-1で追加した removeAttachedImage 関数です)。
/* --- 添付画像の削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
指定した位置(index)の画像を添付リストから削除します。 */
function removeAttachedImage(index) {
attachedImages.splice(index, 1); // 指定した位置の1件を削除
renderAttachmentPreview(); // 添付プレビューを更新
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
テキストファイル・フォルダ処理(ステップ④で追加)
------------------------------------------------------------
テキストファイル(.txt・.py・.js・.html など)の中身を
読み込んで添付し、送信時に質問文に添えて送ります。
フォルダを選択した場合は、中のファイルを「種類ごと」に
振り分けて、それぞれの添付処理に渡します。
(完成形のテキストファイル・フォルダ処理関数と同じ仕様)
============================================================ */
/* --- そのファイルが「テキストファイル」かどうかを判定する ---
2つの方法で判定します。
① 拡張子が TEXT_FILE_EXTENSIONS の一覧に含まれているか
② MIMEタイプが text/ で始まるか(text/plain など)
どちらか一方でも当てはまれば、テキストファイルとみなします。 */
function isTextFile(file) {
/* ファイル名の末尾の「拡張子」を取り出す
split('.') で「.」で区切り、pop() で最後の要素(拡張子)を取得する */
const ext = '.' + file.name.split('.').pop().toLowerCase();
return TEXT_FILE_EXTENSIONS.includes(ext) || file.type.startsWith('text/');
}
/* --- ファイルの中身を「テキスト」として読み込む ---
引数 file:テキストファイル
戻り値:ファイルの中身(文字列)を返す Promise
FileReader API を使って、UTF-8 のテキストとして読み込みます。 */
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
/* 読み込み成功時:ファイルの中身(文字列)を返す */
reader.onload = () => resolve(reader.result);
/* 読み込み失敗時:エラーを返す */
reader.onerror = () => reject(new Error("read error"));
/* readAsText で、UTF-8 のテキストとして読み込む */
reader.readAsText(file, 'UTF-8');
});
}
/* --- テキストファイルの添付処理 ---
引数 files:選択されたファイルの配列
1件ずつ「拡張子」「1件あたりのサイズ」「合計サイズ」を検証し、
問題なければ添付リスト(attachedTextFiles)に追加します。 */
async function handleTextFiles(files) {
/* すでに添付済みのファイルの「合計サイズ」を計算しておく
(合計サイズの上限チェックに使う) */
let totalSize = attachedTextFiles.reduce((sum, f) => sum + f.size, 0);
for (const file of files) {
/* 拡張子検証:テキストファイル以外はスキップ */
if (!isTextFile(file)) {
showError(`対応していないファイル形式です(${file.name})。テキストファイルのみ添付可能です。`);
continue;
}
/* サイズ検証①:1件あたりの上限を超えている場合はスキップ */
if (file.size > MAX_TEXT_FILE_SIZE) {
showError(`ファイルサイズが上限を超えています(${file.name}: ${formatBytes(file.size)})。上限は ${formatBytes(MAX_TEXT_FILE_SIZE)} です。`);
continue;
}
/* サイズ検証②:合計サイズが上限を超える場合は、それ以降の処理を中止 */
if (totalSize + file.size > MAX_TEXT_TOTAL_SIZE) {
showError(`テキストファイルの合計サイズが上限(${formatBytes(MAX_TEXT_TOTAL_SIZE)})を超えています。`);
break;
}
try {
/* ファイルの中身をテキストとして読み込む */
const content = await readFileAsText(file);
/* フォルダから選択した場合、webkitRelativePath に
「フォルダ名/ファイル名」の相対パスが入っています。
パスが長すぎる場合は、ファイル名のみを使います(エラー対策) */
let safePath = file.webkitRelativePath || file.name;
if (safePath.length > 200) {
safePath = file.name;
}
/* 添付リストに追加
name:ファイル名、size:サイズ、type:形式、
content:中身のテキスト、path:表示用のパス */
attachedTextFiles.push({
name: file.name,
size: file.size,
type: file.type || 'text/plain',
content: content,
path: safePath
});
totalSize += file.size;
} catch (e) {
showError(`ファイルの読み込みに失敗しました(${file.name})。`);
}
}
/* 添付プレビューを更新する(renderAttachmentPreview は差分⑩-4で追加します) */
renderAttachmentPreview();
}
/* --- フォルダの添付処理 ---
引数 files:フォルダ内の全ファイルの配列
フォルダ内のファイルを「テキスト・画像・PDF」の種類ごとに振り分け、
それぞれの添付処理(handleTextFiles・handleImageUpload・handlePdfUpload)
に渡します。 */
async function handleFolderUpload(files) {
/* フォルダ内の全ファイルを、種類ごとに分類する
Array.from で「配列風オブジェクト」を本物の配列に変換し、
filter で条件に合うファイルだけを取り出す */
const allFiles = Array.from(files);
const textFiles = allFiles.filter(f => isTextFile(f));
const images = allFiles.filter(f => ALLOWED_IMAGE_TYPES.includes(f.type));
const pdfs = allFiles.filter(f => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"));
let processed = false; // 1件でも処理できたかどうかのフラグ
/* テキストファイルがあれば、テキストの添付処理に渡す */
if (textFiles.length > 0) {
await handleTextFiles(textFiles);
processed = true;
}
/* 画像があれば、画像の添付処理に渡す(差分⑩-1の関数を再利用) */
if (images.length > 0) {
await handleImageUpload(images);
processed = true;
}
/* PDFがあれば、PDFの添付処理に渡す
PDFは「1件だけ」しか添付できないため、最初の1件だけを処理する
(handlePdfUpload は差分⑩-3で追加します) */
if (pdfs.length > 0) {
await handlePdfUpload(pdfs[0]);
if (pdfs.length > 1) {
showError(`PDFは1つだけ添付可能です。${pdfs.length - 1}個のPDFは無視されました。`);
}
processed = true;
}
/* 1件も処理できるファイルがなかった場合は、エラーを表示 */
if (!processed) {
showError("フォルダ内に処理可能なファイル(テキスト・画像・PDF)が見つかりませんでした。");
}
}
■ この差分のポイント
ここでは、4つの重要なポイントを押さえておきましょう。
1. isTextFile の「2段構え」の判定
テキストファイルかどうかの判定には、2つの方法を組み合わせています。
① 拡張子で判定:
file.name.split(‘.’).pop().toLowerCase() で、ファイル名の末尾の「拡張子」(.txt・.py など)を取り出し、TEXT_FILE_EXTENSIONS(差分⑤で宣言した一覧)に含まれているかを確認します。
② MIMEタイプで判定:
file.type.startsWith(‘text/’) で、ブラウザが認識した「ファイルの種類」(MIMEタイプ)が text/ で始まるかを確認します。
この「どちらか一方でも当てはまればOK」とする緩和(かんわ)のおかげで、一覧に無い拡張子のファイルでも、ブラウザが「テキストファイル」と認識していれば添付できる、柔軟な設計になっています。
2. readFileAsText:readAsDataURL との違い
画像の読み込み(差分⑩-1)では readAsDataURL を使いましたが、テキストファイルでは readAsText を使います。
| FileReader の命令 | 読み込む形式 | 用途 |
|---|---|---|
| readAsDataURL | dataUrl(Base64エンコード した文字列) | 画像をAPIに送る時。 画像データを「文字列」の形に変換する |
| readAsText | プレーンテキスト (そのままの文字列) | テキストファイルを読む時。 中身をそのまま質問文に添えられる |
テキストファイルは「中身がそのままテキスト」なので、Base64エンコードは不要です。
readAsText(file, ‘UTF-8’) で、UTF-8 の文字コードとして読み込んでいます。
3. handleTextFiles の「3段階検証」
画像の検証(形式・サイズの2段階)に加えて、テキストファイルでは3段階の検証を行っています。
① 拡張子検証:
テキストファイル以外はスキップ(continue)します。
② 1件あたりのサイズ検証:
MAX_TEXT_FILE_SIZE(1件あたりの上限)を超えている場合はスキップします。
③ 合計サイズ検証:
すでに添付済みのファイルとの合計が MAX_TEXT_TOTAL_SIZE(合計の上限)を超える場合は、break で「それ以降の処理を中止」します。
テキストファイルは「複数添付できる」ため、「1件あたりの上限」に加えて「合計の上限」も設けています。
これにより、大量のファイルを添付しすぎて、APIの料金が跳ね上がるのを防いでいます。
また、ループの制御にも注目です。
・continue
… 「そのファイルだけ」をスキップして、次のファイルの処理に進む
・break
… ループそのものを抜けて、「それ以降のファイルすべて」の処理を中止する
「1件がダメでも次へ進む」か「合計が上限に達したら全部止める」かの、使い分けの良い実例です。
4. handleFolderUpload:「種類ごとに振り分ける」設計
フォルダを選択した場合、中には「テキストファイル」「画像」「PDF」が混在している可能性があります。
handleFolderUpload は、これらを filter で「種類ごとに分類」して、それぞれの添付処理に渡す、司令塔のような関数です。
ここで見事なのは、「新しい処理をほとんど書いていない」ことです。
・テキストファイル → handleTextFiles()(この差分で追加した関数)
・画像 → handleImageUpload()(差分⑩-1で追加した関数を再利用)
・PDF → handlePdfUpload()(差分⑩-3で追加する関数)
「同じ処理は、同じ関数に任せる」という、DRY(Don’t Repeat Yourself)の原則の実践です。
差分⑩-1で作った画像処理が、そのままフォルダ添付にも使われているのは、関数を「部品」として設計したおかげです。
また、webkitRelativePath というプロパティにも注目です。
フォルダから選択した場合、各ファイルには「フォルダ名/ファイル名」という相対パスがこのプロパティに入っています。
これにより、添付プレビューで「どのフォルダの、どのファイルか」を表示できるようになります(差分⑩-4の renderAttachmentPreview で使用します)。
この時点での動作について(重要な注意・差分⑩-1の続き)
差分⑩-1でもお伝えしましたが、差分⑩-2のコードを追加した時点でも、以下の関数がまだ存在しません。
・renderAttachmentPreview()(差分⑩-4で追加)
・handlePdfUpload()(差分⑩-3で追加)
差分⑩-1〜⑩-4をすべて追加して初めて、アップロード機能が完全に動き出します。
途中でブラウザを開くと「renderAttachmentPreview is not defined」や「handlePdfUpload is not defined」というエラーが出る可能性がありますが、これは正常な状態です。
焦らず、差分⑩-4まで進めてください。
ここまでの中間確認(差分⑩-1・⑩-2)
差分⑩-1・⑩-2で、以下の「ファイル処理の土台」が整いました。
・差分⑩-1(画像処理):
画像の「形式」と「サイズ」を検証し、dataUrl に変換して添付リストに追加する
・差分⑩-2(テキスト・フォルダ処理):
テキストファイルの「拡張子」「サイズ」を検証し、中身のテキストを読み込んで添付リストに追加する。
フォルダの場合は、中のファイルを「種類ごと」に振り分ける
次の差分⑩-3では、最も複雑な「PDF処理」を追加します。
PDF.js による「テキストの抽出」と「ページの画像化」、そして「文字化けの検出」という、完成形の中でも特に高度な処理を、1つずつ丁寧に解説していきます。
差分⑩-3-1:JavaScript(新規セクション)PDF処理の本体(handlePdfUpload・extractPdfText)
続いて、ステップ④の中でも最も複雑な「PDF処理」に入ります。
PDFは、画像やテキストファイルと違い、「そのままでは読み取れない」形式です。
そのため、PDF.js(差分①で読み込んだ外部ライブラリ)を使って、ブラウザ上でPDFを「解析」する必要があります。
差分⑩-3は、PDF処理を3つのパートに分けて進めます。
・差分⑩-3-1:
PDF処理の本体(この記事)
――PDFを解析して「テキストを抽出」するまでの流れ
・差分⑩-3-2:
ページ画像化と文字化け検出――テキストが取れないPDFへの対応
・差分⑩-3-3:
PDFコンテキストの構築
――抽出した内容を「質問文に添える形」に整える
まずは、この記事で以下の2つの関数を追加します。
| 関数名 | 役割 |
|---|---|
| handlePdfUpload(file) | 選択されたPDFファイルの「形式」と「サイズ」を検証し、 PDF.jsで解析を開始する。 テキストが抽出できたかどうかで、 「テキストで送る」か「ページ画像で送る」かを振り分ける |
| extractPdfText(pdf, targetPages) | PDFの各ページから「テキスト」を抽出して、 まとめて1つの文字列として返す |
■ 追加する場所
差分⑩-2で追加した「テキストファイル・フォルダ処理」セクションの末尾にある、handleFolderUpload 関数の閉じ括弧 } の直後に追加します。
まず、以下のコードブロックを検索してください(差分⑩-2で追加した handleFolderUpload 関数の末尾部分です)。
/* 1件も処理できるファイルがなかった場合は、エラーを表示 */
if (!processed) {
showError("フォルダ内に処理可能なファイル(テキスト・画像・PDF)が見つかりませんでした。");
}
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
PDF処理(ステップ④で追加)
------------------------------------------------------------
選択されたPDFファイルを、PDF.js(外部ライブラリ)で解析し、
「テキストを抽出」します。
テキストが抽出できた場合は「テキストで送る」、
抽出できない場合(スキャン画像など)は「ページ画像で送る」
という2つの方式を、自動で振り分けます。
(完成形の PDF処理関数と同じ仕様)
============================================================ */
/* --- PDFファイルの添付処理 ---
引数 file:選択されたPDFファイル(1ファイルのみ)
「形式」と「サイズ」を検証し、問題なければPDF.jsで解析を開始します。 */
async function handlePdfUpload(file) {
/* 形式検証:PDF以外のファイルは受け付けない */
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
showError("PDF形式のファイルを選択してください。");
return;
}
/* サイズ検証:上限を超えている場合は中止 */
if (file.size > MAX_PDF_SIZE) {
showError(`PDFサイズが上限を超えています(${formatBytes(file.size)})。上限は ${formatBytes(MAX_PDF_SIZE)} です。`);
return;
}
/* PDF.jsが読み込めていない場合は中止(オフラインなど) */
if (typeof pdfjsLib === "undefined") {
showError("PDF.jsライブラリを読み込めませんでした。ネットワーク接続を確認してください。");
return;
}
clearError();
/* 添付PDFの情報を保持する「器」を初期化する
これから解析を進め、結果(text・pageImagesなど)をここに入れていきます */
attachedPdf = {
name: file.name,
size: file.size,
pageRange: "", // 処理対象のページ範囲(例:「先頭 10 / 25 ページ」)
text: "", // 抽出したテキスト(抽出できた場合)
pageImages: [], // ページ画像の配列(テキストが取れない場合)
garbledFallback: false // 文字化けによるフォールバックかどうか(差分⑩-3-2で使用)
};
/* 添付プレビューを「処理中…」の状態で先に表示する
(renderAttachmentPreview は差分⑩-4で追加します) */
renderAttachmentPreview();
try {
/* PDF.jsの「ワーカー」(解析処理を裏側で行う部品)の場所を設定する
CDN版を読み込んでいるため、同じCDNのURLを指定します */
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
/* ファイルの中身を「バイト列」(ArrayBuffer)として読み込む */
const arrayBuffer = await file.arrayBuffer();
/* PDF.jsでPDF文書を解析する(解析が終わるまで待つ) */
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
/* 総ページ数を取得し、「処理対象のページ数」を決める
上限(MAX_PDF_PAGES)を超えている場合は、先頭のページだけを処理する */
const totalPages = pdf.numPages;
if (totalPages > MAX_PDF_PAGES) {
showError(`PDFのページ数が上限(${MAX_PDF_PAGES}ページ)を超えています(${totalPages}ページ)。先頭${MAX_PDF_PAGES}ページのみ処理します。`);
}
const targetPages = Math.min(totalPages, MAX_PDF_PAGES);
attachedPdf.pageRange = `先頭 ${targetPages} / ${totalPages} ページ`;
/* テキスト抽出を試行する
抽出できたかどうかで、以下の2つに振り分けます。
① テキストが取れた → 「テキストで送る」
② テキストが取れない → 「ページ画像で送る」 */
const extractedText = await extractPdfText(pdf, targetPages);
if (extractedText && extractedText.trim().length > 20) {
/* テキストが抽出できた場合:
ただし「文字化け」していないかを検査します(差分⑩-3-2で追加) */
attachedPdf.text = extractedText;
} else {
/* テキストが抽出できない場合(スキャン画像のPDFなど):
ページを「画像」として描画(レンダリング)して添付する
(renderPdfPagesAsImages は差分⑩-3-2で追加します) */
attachedPdf.pageImages = await renderPdfPagesAsImages(pdf, targetPages);
}
} catch (e) {
/* 解析に失敗した場合は、添付を取り消してエラーを表示 */
showError("PDFの処理に失敗しました:" + sanitizeLog(e));
attachedPdf = null;
}
/* 解析結果を反映して、添付プレビューを更新する */
renderAttachmentPreview();
}
/* --- PDFの各ページから「テキスト」を抽出する ---
引数 pdf:PDF.jsで解析したPDF文書
targetPages:処理対象のページ数
戻り値:抽出したテキスト(1つの文字列) */
async function extractPdfText(pdf, targetPages) {
let text = "";
/* 1ページずつ順番にテキストを抽出する */
for (let i = 1; i <= targetPages; i++) {
try {
/* ページを取得し、その中の「テキストコンテンツ」を取り出す */
const page = await pdf.getPage(i);
const content = await page.getTextContent();
/* テキストコンテンツ(items配列)の各要素(item.str)を、
スペースでつなげて1つの文字列にする */
const pageText = content.items.map(item => item.str).join(" ");
/* ページ番号の区切りを付けて、テキストに追加する */
text += `\n--- ${i}ページ ---\n${pageText}\n`;
} catch (e) {
/* そのページの抽出に失敗した場合は、失敗したことを記録して次へ進む */
text += `\n--- ${i}ページ(抽出失敗) ---\n`;
}
}
return text.trim();
}
■ この差分のポイント
ここでは、4つの重要なポイントを押さえておきましょう。
1. handlePdfUpload の「2段階検証」と「振り分け」
画像やテキストファイルと同じく、まず「形式」と「サイズ」の2段階で検証(バリデーション)しています。
これに加えて、PDF.jsが読み込めているか(typeof pdfjsLib === “undefined”)も確認しており、「オフラインなどでライブラリが使えない場合でも、アプリが壊れない」ようにしています。
そして、解析の結果を「テキストで送る」か「ページ画像で送る」かに振り分けているのが、PDF処理の最も重要な設計です。
| PDFの種類 | 抽出結果 | 送信の仕組み |
|---|---|---|
| 通常のPDF (Wordなどから出力 されたテキストを含む もの) | テキストが抽出できる | テキストで送る。 extractPdfText で抽出したテキスト を、質問文に添えて送信する。 (トークン数が少なく、 コストパフォーマンスが良い) |
| スキャンPDF (紙をスキャンした、 画像だけのもの) | テキストが抽出できない (20文字以下) | ページ画像で送る。 renderPdfPagesAsImages で各ページを「画像」として 描画(レンダリング)し、 画像としてAPIに送信する。 (AIが画像の内容を「見て」 回答する) |
「テキストが抽出できたかどうか」の判定は、extractedText.trim().length > 20 という条件で行っています。
「20文字以下」しか取れなかった場合は、「実質的にテキストが入っていない」とみなして、ページ画像の方式に切り替えています。
2. pdfjsLib.GlobalWorkerOptions.workerSrc の意味
この1行は、PDF.jsの「ワーカー(worker)」の場所を設定しています。
ワーカーとは、PDF.jsが「重い解析処理」を、ブラウザのメインの動作(画面の描画など)を止めないように、「裏側(バックグラウンド)で行うための部品」です。
PDF.jsは、このワーカー用のファイル(pdf.worker.min.js)が別途必要で、その場所(URL)をここで教えています。
CDN版のPDF.jsを読み込んでいるため、
同じCDNのURL(https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js)
を指定しています。
(配布版の「カスタムAIチャットアプリ」では、セキュリティの観点からローカル参照方式を採用しており、「./js/pdf.worker.min.js」という自分のパソコン内のファイルを参照しています)
3. await と async/await の「連鎖」
handlePdfUpload は async な関数で、その中で以下のような「時間のかかる処理を待つ」処理が連鎖しています。
・await file.arrayBuffer() … ファイルの中身を「バイト列」として読み込むのを待つ
・await pdfjsLib.getDocument(…).promise … PDF.jsで解析するのを待つ
・await extractPdfText(…) … テキスト抽出が終わるのを待つ
file.arrayBuffer() は、FileReader API とは別の、より新しい「ファイルの中身をバイト列として読み込む」ためのブラウザ標準の命令です。
PDF.jsは「バイト列」のデータを必要とするため、ここで変換しています。
また、extractPdfText も async な関数で、その中でさらに await pdf.getPage(i) や await page.getTextContent() という待機が続きます。
このように、「時間のかかる処理を、順番に、確実に待ちながら進める」のが
async/await
の力です。
4. extractPdfText の「ページ区切り」と「失敗への備え」
extractPdfText では、1ページずつ順番にテキストを抽出し、
— 1ページ —
のような「ページ区切り」を付けてまとめています。
これにより、AIが「何ページ目の内容か」を理解しやすくなります。
また、try〜catch で「そのページの抽出に失敗しても、次へ進む」設計にしています。
例えば、10ページ中の3ページ目だけが破損していても、「3ページ目(抽出失敗)」と記録して、残りの9ページは正常に処理されます。
これも「1か所の失敗で全体を止めない」という、堅牢な設計の実践です。
content.items.map(item => item.str).join(” “) という処理は、「ページ内のテキストの部品(items)を、スペースでつなげて1つの文章にする」という意味です。
PDF内のテキストは、単語や文字の「部品」に分かれて格納されているため、これを map で取り出して join でつなげています。
この時点での動作について(重要な注意・差分⑩-1・⑩-2の続き)
差分⑩-1・⑩-2でもお伝えしましたが、差分⑩-3-1のコードを追加した時点でも、以下の関数がまだ存在しません。
・renderAttachmentPreview()(差分⑩-4で追加)
・renderPdfPagesAsImages()(差分⑩-3-2で追加)
差分⑩-3-1で追加した handlePdfUpload の中には、「テキストが抽出できない場合」に renderPdfPagesAsImages() を呼ぶ処理が含まれています。
そのため、差分⑩-3-1〜⑩-4をすべて追加して初めて、アップロード機能が完全に動き出します。
途中でブラウザを開くと「renderAttachmentPreview is not defined」や「renderPdfPagesAsImages is not defined」というエラーが出る可能性がありますが、これは正常な状態です。
焦らず、差分⑩-4まで進めてください。
ここまでの中間確認(差分⑩-1・⑩-2・⑩-3-1)
ここまでで、以下の「ファイル処理の土台」が整いました。
・差分⑩-1(画像処理):
画像の「形式」と「サイズ」を検証し、dataUrl に変換して添付リストに追加する
・差分⑩-2(テキスト・フォルダ処理):
テキストファイルの「拡張子」「サイズ」を検証し、中身のテキストを読み込んで添付リストに追加する。
フォルダの場合は、中のファイルを「種類ごと」に振り分ける
・差分⑩-3-1(PDF処理の本体):
PDFの「形式」と「サイズ」を検証し、PDF.jsで解析を開始する。
「テキストが取れるか」で「テキストで送る」か「ページ画像で送る」かを振り分ける
次の差分⑩-3-2では、「ページ画像化と文字化け検出」を追加します。
テキストが取れないPDF(スキャン画像)を「ページ画像」として描画する renderPdfPagesAsImages と、
抽出したテキストが「文字化け」していないかを検査する isLikelyGarbled という、完成形の中でも特に高度な処理を、1つずつ丁寧に解説していきます。
差分⑩-3-2:JavaScript(新規セクション)ページ画像化と文字化け検出(renderPdfPagesAsImages・isLikelyGarbled)
続いて、PDF処理の2つ目のパート「ページ画像化と文字化け検出」です。
差分⑩-3-1で追加した handlePdfUpload には、「テキストが抽出できなかった場合」や「抽出したテキストが文字化けしていた場合」に、PDFの各ページを「画像」として描画(レンダリング)して送信する、という設計がありました。
そのための関数が、今回追加する renderPdfPagesAsImages です。
そして、抽出したテキストが「文字化け」していないかを検査する関数が、isLikelyGarbled です。
| 関数名 | 役割 |
|---|---|
| renderPdfPagesAsImages (pdf, targetPages) | PDFの各ページを「画像」として描画(レンダリング)し、 dataUrl の配列として返す。 スキャンPDFや、文字化けしたPDFに対応するための方式 |
| isLikelyGarbled(text) | 抽出したテキストが「文字化け」していないかを、 6つの判定ロジックで多角的に検査する。 文字化けと判定した場合は 「ページ画像で送る」方式に切り替える |
なぜ「文字化け検出」が必要なのでしょうか。
PDFによっては、「フォントの文字コードの対応表(ToUnicode CMap)が正しく埋め込まれていない」というケースがあります。
このようなPDFは、PDF.jsでテキストを抽出しようとすると、文字コードを正しく解読できず、「意味のない文字の羅列」(文字化け)になってしまいます。
文字化けしたテキストをAIに送っても、正しく理解してもらえません。
そこで、「文字化けの可能性が高い」と検出した場合は、テキスト抽出をあきらめて「ページ画像」として送り、AIに「画像の内容を見て」回答してもらう方式に、自動で切り替えるのがこの仕組みです。
■ 追加1:renderPdfPagesAsImages 関数(PDFのページを画像として描画する)
まず、PDFの各ページを「画像」として描画する renderPdfPagesAsImages 関数を追加します。
■ 追加する場所
差分⑩-3-1で追加した extractPdfText 関数の末尾(閉じ括弧 } の直後)に追加します。
まず、以下のコードブロックを検索してください(差分⑩-3-1で追加した extractPdfText 関数の末尾部分です)。
} catch (e) {
/* そのページの抽出に失敗した場合は、失敗したことを記録して次へ進む */
text += `\n--- ${i}ページ(抽出失敗) ---\n`;
}
}
return text.trim();
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- PDFの各ページを「画像」として描画(レンダリング)する ---
引数 pdf:PDF.jsで解析したPDF文書
targetPages:処理対象のページ数
戻り値:ページ画像(dataUrl)の配列
スキャンPDF(画像だけのPDF)や、文字化けしたPDFに対応するため、
ページを1枚ずつ「画像」に変換してAPIに送ります。
(完成形の renderPdfPagesAsImages と同じ仕様) */
async function renderPdfPagesAsImages(pdf, targetPages) {
const images = [];
/* 描画の拡大率(1.5倍)
大きくするほど文字が鮮明になるが、画像サイズも大きくなる */
const scale = 1.5;
/* 1ページずつ順番に画像化する */
for (let i = 1; i <= targetPages; i++) {
try {
/* ページを取得し、その「表示サイズ」(viewport)を計算する */
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale });
/* 描画用の「キャンバス」(canvas:絵を描くための領域)を作り、
ページの表示サイズに合わせる */
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
/* ページをキャンバスに描画(レンダリング)する(終わるまで待つ) */
await page.render({ canvasContext: ctx, viewport }).promise;
/* 描画したキャンバスを「JPEG形式・品質80%」の dataUrl に変換して配列に追加 */
images.push(canvas.toDataURL("image/jpeg", 0.8));
} catch (e) {
/* そのページの描画に失敗した場合はスキップ(全体は止めない) */
}
}
return images;
}
■ この差分のポイント(renderPdfPagesAsImages)
1. canvas(キャンバス)による「描画」の仕組み
ここでは、ブラウザ標準の canvas(キャンバス) という「絵を描くための領域」を使っています。
処理の流れは、以下の3段階です。
① page.getViewport({ scale })
… ページの「表示サイズ」を計算する(scale は拡大率)
② page.render({ canvasContext: ctx, viewport })
… ページをキャンバスに描画する
③ canvas.toDataURL(“image/jpeg”, 0.8)
… 描画したキャンバスを「JPEG画像の dataUrl」に変換する
これにより、PDFの1ページが「そのページを写真に撮ったような画像」になります。
画像の形式は dataUrl(差分⑩-1で学んだ Base64エンコードの文字列)なので、そのままAPIに送信できます。
2. scale = 1.5 の意味
描画の拡大率を「1.5倍」に設定しています。
拡大率を上げるほど、描画される画像の解像度が上がり、小さな文字も鮮明に読み取れるようになります。
ただし、上げすぎると画像サイズ(データ量)も大きくなり、APIの料金が増えるため、「読み取れる最低限の鮮明さ」に抑えたバランスの取れた値です。
3. toDataURL(“image/jpeg”, 0.8) の意味
描画したキャンバスを「JPEG形式・品質80%」の画像に変換しています。
JPEGは「写真のような画像」を小さなデータ量で表現できる形式です。
品質80%は、「画質」と「データ量」のバランスが取れた値で、これもAPIの料金を抑えるための配慮です。
(PNG形式にすると文字は鮮明ですが、データ量が大きくなりすぎるため、JPEGを採用しています)
4. try〜catch で「そのページの失敗はスキップ」
extractPdfText と同じく、「そのページの描画に失敗しても、全体は止めない」設計にしています。
例えば、10ページ中の3ページ目だけが破損していても、残りの9ページは正常に画像化されます。
■ 追加2:isLikelyGarbled 関数(文字化けの検出)
次に、抽出したテキストが「文字化け」していないかを検査する isLikelyGarbled 関数を追加します。
■ 追加する場所
追加1で追加した renderPdfPagesAsImages 関数の末尾(閉じ括弧 } の直後)に追加します。
まず、以下のコードブロックを検索してください(追加1で追加した renderPdfPagesAsImages 関数の末尾部分です)。
/* 描画したキャンバスを「JPEG形式・品質80%」の dataUrl に変換して配列に追加 */
images.push(canvas.toDataURL("image/jpeg", 0.8));
} catch (e) {
/* そのページの描画に失敗した場合はスキップ(全体は止めない) */
}
}
return images;
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- 抽出したテキストが「文字化け」していないかを検査する ---
引数 text:抽出したテキスト
戻り値:文字化けの可能性が高い場合は true、そうでない場合は false
【なぜこの検査が必要か】
PDFによっては「フォントの文字コードの対応表(ToUnicode CMap)」が
正しく埋め込まれていないケースがあり、PDF.jsでテキストを抽出すると
「意味のない文字の羅列」(文字化け)になってしまいます。
文字化けしたテキストをAIに送っても正しく理解してもらえないため、
文字化けと判定した場合は「ページ画像で送る」方式に切り替えます。
【判定の仕組み】
「文字種の偏り」+「文字コードの範囲」+「既知の文字化けパターン」の
3つの観点から、6つの判定ロジックで多角的に検査します。
(完成形の isLikelyGarbled と同じ仕様) */
function isLikelyGarbled(text) {
if (!text) return false;
/* 先頭の2000文字を「サンプル」として検査する(全部は重いため) */
const sample = text.slice(0, 2000);
if (sample.length < 20) return false;
/* --- 判定1:日本語文字の比率 ---
ひらがな・カタカナ(〜ゞ)・漢字(一〜鿿)の割合を計算する */
const japaneseChars = sample.match(/[-ヿ一-鿿]/g) || [];
const japaneseRatio = japaneseChars.length / sample.length;
/* --- 判定2:異常な文字(私用領域・ゼロ幅スペースなど)の比率 ---
文字化け時に頻出する範囲(〜の私用領域、〜の
ゼロ幅スペース、〜の特殊文字)の割合を計算する */
const suspiciousChars = sample.match(/[---]/g) || [];
const suspiciousRatio = suspiciousChars.length / sample.length;
/* --- 判定3:ASCII文字(ラテン文字・数字・句読点)の比率 ---
日本語文書なのにASCII文字( ~)ばかりの場合は、文字化けの可能性が高い */
const asciiChars = sample.match(/[ -~]/g) || [];
const asciiRatio = asciiChars.length / sample.length;
/* --- 判定4:同一文字の異常な連続 ---
文字化け時に「,」「.」「0」「e」などが大量に連続するケースを検出する */
const repeatedPattern = /(.)\1{9,}/; // 同一文字が10回以上連続
const hasRepeatedPattern = repeatedPattern.test(sample);
/* --- 判定5:意味不明なASCII文字列の連続 ---
文字化けPDFで頻出する「意味のないASCII文字の長い連続」を検出する */
const meaninglessAscii = sample.match(/[a-zA-Z0-9]{30,}/g) || [];
const hasMeaninglessAscii = meaninglessAscii.length > 2;
/* --- 判定6:日本語文書に通常現れない文字(異国文字)の混入比率 ---
日本語文書に現れない文字範囲(က〜ჿ、᐀〜ᙿ、ก〜、က〜႟など)の
割合を計算する */
const unusualChars = sample.match(/[က-ჿ᐀-ᙿก-က-႟]/g) || [];
const unusualRatio = unusualChars.length / sample.length;
/* 日本語文書に異国文字が1%以上混入している場合は、文字化けと判定 */
if (unusualRatio > 0.01) {
return true;
}
/* --- 総合判定 ---
ページ区切り文字列(「ページ」)があるのに日本語がほぼない、
などの条件を組み合わせて、文字化けの可能性を判定する */
const hasJapanesePageMarker = /ページ/.test(sample);
/* 日本語PDFなのに日本語がほぼなく(2%未満)、ASCII文字ばかり(60%超)の場合 */
if (hasJapanesePageMarker && japaneseRatio < 0.02 && asciiRatio > 0.6) {
return true;
}
/* 私用領域・異常文字が5%超の場合 */
if (suspiciousRatio > 0.05) {
return true;
}
/* 日本語文書なのに意味不明なASCII文字列が多く、日本語がほぼない場合 */
if (hasJapanesePageMarker && japaneseRatio < 0.05 && hasMeaninglessAscii) {
return true;
}
/* 異常な文字繰り返しがあり、日本語がほぼない場合 */
if (hasRepeatedPattern && japaneseRatio < 0.05) {
return true;
}
return false;
}
■ この差分のポイント(isLikelyGarbled)
この関数は、完成形のアプリの中でも特に高度な処理です。
「文字化け」を、1つの基準ではなく、6つの判定ロジックで多角的に検査しています。
1. 「サンプル」で検査する理由
まず、text.slice(0, 2000) で「先頭の2000文字」だけを取り出して検査しています。
これは、PDFから抽出したテキストが数万文字に及ぶこともあるため、
「全部を検査すると重くなる」
のを防ぐためです。
文字化けは、文書の先頭から現れることが多いため、
「先頭の2000文字を検査すれば、ほぼ判定できる」
という、実務的な割り切りです。
2. match() と正規表現による「文字のカウント」
各判定では、sample.match(/…/g) という形で、「条件に合う文字をすべて抜き出す」処理を行っています。
例えば、判定1の sample.match(/[-ヿ一-鿿]/g) は、
「ひらがな(〜ゟ)・カタカナ(〜ヿ)・漢字(一〜鿿)に合う文字をすべて抜き出す」
という意味です。
match() は「条件に合う部分を配列で返す」命令で、その配列の長さ(length)を、サンプル全体の長さで割ることで、「日本語文字の比率」を計算しています。
ここで使われている /[-ヿ一-鿿]/ のような表記は「正規表現(せいきひょうげん)」と呼ばれる、「文字のパターンを指定して検索する」ための仕組みです。
/[-ヿ一-鿿]/ は、
「 から ヿ まで(ひらがな・カタカナ)」
と
「一 から 鿿 まで(漢字)」
の範囲の文字、という意味です。
3. 6つの判定ロジックの役割
「文字化け」は、1つの特徴だけでは確実に判定できません。
そこで、以下の6つの観点から、多角的に検査しています。
| 判定 | 検査内容 | 文字化けと判定する条件 |
|---|---|---|
| 判定1 日本語文字の比率 | ひらがな・カタカナ・漢字の 割合を計算 | (総合判定で使用。 単独では使わない) |
| 判定2 異常な文字の比率 | 私用領域・ゼロ幅スペースなどの 文字化け時に頻出する文字の割合 | 5%を超える場合 |
| 判定3 ASCII文字の比率 | ラテン文字・数字・句読点の割合 | (総合判定で使用。 単独では使わない) |
| 判定4 同一文字の異常な 連続 | 「,」「.」「0」などが 10回以上連続するか | (総合判定で使用。 単独では使わない) |
| 判定5 意味不明なASCII 文字列 | 30文字以上の意味のない ASCII文字の連続が2つを超えるか | (総合判定で使用。 単独では使わない) |
| 判定6 異国文字の 混入比率 | 日本語文書に現れない 文字範囲の割合 | 1%を超える場合 (この条件だけで即座に 文字化けと判定) |
判定6の「異国文字が1%以上混入している場合」は、この条件だけで即座に true(文字化け)を返します。
これは、
「日本語文書に、日本語で使われない文字範囲(例:タイ文字やジョージア文字など)が混入している」
という、極めて強い文字化けの兆候だからです。
4. 「総合判定」による誤判定の防止
単独の判定だけでは、
「文字化けではないのに、文字化けと誤判定してしまう」
可能性があります。
例えば、プログラムのコードを含むPDFは、ASCII文字の比率が自然と高くなります。
そこで、最後の「総合判定」では、
「ページ区切り(「ページ」という文字)があるのに、日本語がほぼない」
というように、複数の条件を組み合わせて判定しています。
/* 日本語PDFなのに日本語がほぼなく(2%未満)、ASCII文字ばかり(60%超)の場合 */
if (hasJapanesePageMarker && japaneseRatio < 0.02 && asciiRatio > 0.6) {
return true;
}
この条件は、
「「ページ」という日本語の区切りがある(=日本語の文書のはず)なのに、日本語が2%未満で、ASCII文字が60%を超えている」
という、明らかに不自然な状態を検出しています。
「日本語文書のはずなのに、日本語が読めない」という、文字化けの本質的な特徴を捉えた、完成形の中でも特に巧妙な判定です。
5. なぜ、ここまで「多角的」に検査するのか
「文字化け」を確実に検出することは、意外と難しい問題です。
・検査が緩すぎると
… 文字化けを「見逃して」、AIに送ってしまう(正しく理解してもらえない)
・検査が厳しすぎると
… 正常なPDFを「文字化けと誤判定して」、ページ画像で送ってしまう(APIの料金が増える)
ページ画像で送る方式は、AIが画像を「見て」回答するため、テキストで送るよりもAPIの料金が高くなります。
そのため、
「本当に文字化けしている時だけ」
ページ画像に切り替える、誤判定の少ない検査が求められるのです。
この「6つの判定ロジックで多角的に検査する」設計は、「見逃し」と「誤判定」のバランスを取った、実務的なアプローチです。
ただ、判定基準を多く設けることで、判定基準が少ない時には日本語文章をテキスト化できていたPDFも、画像として送信されることも経験していますので、バランスを取るのが難しい領域ですね・・・
ここまでの中間確認(差分⑩-3-1・⑩-3-2)
PDF処理も、いよいよ最後のパートを残すのみとなりました。
ここまでに整った「PDF処理の土台」を確認しておきましょう。
| 差分 | 整った土台 |
|---|---|
| 差分⑩-3-1 PDF処理の本体 | PDFの「形式」と「サイズ」を検証し、PDF.jsで解析を開始する (handlePdfUpload)。 「テキストが取れるか」で「テキストで送る」か 「ページ画像で送る」かを振り分ける。 各ページからテキストを抽出する(extractPdfText) |
| 差分⑩-3-2 ページ画像化と 文字化け検出 | テキストが取れないPDFを「ページ画像」として描画する (renderPdfPagesAsImages)。 抽出したテキストが「文字化け」していないかを、 6つの判定ロジックで検査する(isLikelyGarbled) |
次の差分⑩-3-3では、最後の「PDFコンテキストの構築」を追加します。
抽出したテキストや、ページ画像を、「質問文に添える形」に整える buildPdfContext 関数と、
差分⑩-3-1で追加した「文字化け検出によるフォールバック」の分岐を、handlePdfUpload に追加する修正を行います。
差分⑩-3-3:JavaScript(新規セクション)PDFコンテキストの構築(buildPdfContext)と、handlePdfUpload への文字化けフォールバック分岐の追加
PDF処理も、いよいよ最後のパートです。
ここでは、以下の2つの作業を行います。
1. 追加1:
buildPdfContext 関数の追加
――抽出したテキストやページ画像を、「質問文に添える形」に整える
2. 追加2:
handlePdfUpload への「文字化けフォールバック分岐」の追加
――差分⑩-3-2で作った isLikelyGarbled を、実際の振り分けに組み込む
■ 追加1:buildPdfContext 関数(PDFの内容を「質問文に添える形」に整える)
まず、buildPdfContext 関数を追加します。
この関数は、添付されたPDFの「状態」(テキストが抽出できたか・文字化けによるフォールバックか・テキストが取れなかったか)に応じて、「APIに送る際の前置き文」を組み立てる役割を持ちます。
「コンテキスト」という言葉は、「AIに渡す背景情報」という意味です。
PDFの内容をそのまま送るだけでなく、
「これはPDFから抽出したテキストです」
「このPDFは文字化けのため、ページ画像として添付しています」
といった前置きを添えることで、AIが「どういう状態のデータか」を理解して、適切に回答できるようにしています。
■ 追加する場所
差分⑩-3-2で追加した isLikelyGarbled 関数の末尾(閉じ括弧 } の直後)に追加します。
まず、以下のコードブロックを検索してください(差分⑩-3-2で追加した isLikelyGarbled 関数の末尾部分です)。
/* 異常な文字繰り返しがあり、日本語がほぼない場合 */
if (hasRepeatedPattern && japaneseRatio < 0.05) {
return true;
}
return false;
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- PDFの内容を「質問文に添える形」(コンテキスト)に整える ---
引数 pdf:添付PDFの情報(attachedPdf と同じ構造)
戻り値:APIに送る際の「前置き文」を含むコンテキスト文字列
PDFの「状態」に応じて、以下の3つに振り分けます。
① 文字化けによるフォールバック(garbledFallback が true)
→ 「文字化けのため、ページ画像として添付しています」と伝える
② テキストが抽出できた(text がある)
→ 「以下はPDFから抽出したテキストです」と前置きして、
抽出したテキストを添える(長すぎる場合は省略)
③ テキストが取れなかった(スキャンPDFなど)
→ 「テキスト抽出できなかったため、ページ画像として添付しています」と伝える
(完成形の buildPdfContext と同じ仕様) */
function buildPdfContext(pdf) {
let ctx = `【添付PDF: ${pdf.name}(${pdf.pageRange})】\n`;
if (pdf.garbledFallback) {
/* ① 文字化け検出による自動フォールバックの場合:
「なぜページ画像になったのか」を、AIに正直に伝えます */
ctx += "このPDFはPDF内部のフォント埋め込みやエンコーディングが対応しておらず、" +
"テキスト抽出で文字化けが発生したため、ページ画像として添付しています。" +
"画像の内容を確認してください。";
} else if (pdf.text) {
/* ② テキストが抽出できた場合:
ただし、長すぎるテキストはトークン数(API料金)が跳ね上がるため、
上限を設けて省略します */
const MAX_PDF_CHARS = 20000; // トークン肥大防止のため上限を設ける
const body = pdf.text.length > MAX_PDF_CHARS
? pdf.text.slice(0, MAX_PDF_CHARS) + "\n…(以降省略)"
: pdf.text;
ctx += `以下はPDFから抽出したテキストです。\n${body}`;
} else {
/* ③ テキストが取れなかった場合(スキャンPDFなど):
ページ画像として添付していることを伝えます */
ctx += "このPDFはテキスト抽出できなかったため、ページ画像として添付しています。画像の内容を確認してください。";
}
return ctx;
}
■ この差分のポイント(buildPdfContext)
1. 「状態」に応じた3つの振り分け
この関数は、PDFの「状態」を示す2つのフラグ(garbledFallback と text)を見て、3つに振り分けています。
| PDFの状態 | AIに伝える前置き | 送信データ |
|---|---|---|
| ① 文字化け フォールバック (garbledFallback が true) | 「文字化けのため、 ページ画像として添付 しています」 | ページ画像。 前置きのあと、 「画像の内容を確認して ください」と促す |
| ② テキスト抽出済み (text がある) | 「以下はPDFから抽出 したテキストです」 | 抽出テキスト。 ただし20000文字を 超える場合は省略する |
| ③ テキスト抽出不可 (スキャンPDFなど) | 「テキスト抽出 できなかったため、 ページ画像として 添付しています」 | ページ画像。 前置きのあと、 「画像の内容を確認して ください」と促す |
2. MAX_PDF_CHARS による「トークン肥大防止」
②の「テキスト抽出済み」の場合でも、
「長すぎるテキストは省略する」
という配慮が入っています。
大きなPDF(例:100ページの技術書)から抽出したテキストは、数万文字に及ぶことがあります。
これをそのままAPIに送ると、トークン数(input tokens)が跳ね上がり、APIの料金が大幅に増えてしまいます。
そこで、MAX_PDF_CHARS = 20000 という上限を設け、
「20000文字を超える場合は、先頭の20000文字だけを送り、残りは省略する」
という、コストパフォーマンスへの配慮をしています。
これが、設定パネルのヒント表示(差分②で追加した「1ファイルの先頭約20000文字まで対応」という説明文)の根拠です。
3. 「なぜページ画像になったのか」をAIに正直に伝える
①と③の場合、「なぜページ画像として添付しているのか」を、AIに正直に伝えています。
これは、単なる丁寧さではありません。
AIは「ページ画像が送られてきた」だけでは、「なぜテキストではなく画像なのか」が分かりません。
「文字化けのため」「テキスト抽出できなかったため」と理由を伝えることで、AIは
「この画像は、テキストが読み取れないPDFの代わりだ」
と理解し、画像の内容を「見て」回答しようとします。
「AIに、データの状態を正しく伝える」ことも、良いプロンプト設計の一部です。
■ 追加2:handlePdfUpload への「文字化けフォールバック分岐」の追加
最後に、差分⑩-3-1で追加した handlePdfUpload の中の「テキスト抽出の振り分け」部分に、差分⑩-3-2で作った isLikelyGarbled を使った「文字化けフォールバック分岐」を追加します。
差分⑩-3-1のコードでは、「テキストが抽出できた場合」は無条件に attachedPdf.text = extractedText; としていました。
しかし完成形では、「抽出できたとしても、文字化けしていたら、ページ画像に切り替える」という分岐が入っています。
■ 置き換える場所
差分⑩-3-1で追加した handlePdfUpload 関数内にある、以下のコードブロックを検索してください。
if (extractedText && extractedText.trim().length > 20) {
/* テキストが抽出できた場合:
ただし「文字化け」していないかを検査します(差分⑩-3-2で追加) */
attachedPdf.text = extractedText;
} else {
この部分を以下のコードに置き換えてください。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
if (extractedText && extractedText.trim().length > 20) {
/* テキストが抽出できた場合:
ただし「文字化け」していないかを検査します。
文字化けと判定した場合は、テキスト抽出をあきらめて
「ページ画像で送る」方式に切り替えます。 */
if (isLikelyGarbled(extractedText)) {
/* 文字化け検出:自動でページ画像へフォールバック
garbledFallback フラグを true にしておくと、
buildPdfContext や添付プレビューで
「文字化けによるフォールバック」として扱われます */
attachedPdf.garbledFallback = true;
attachedPdf.text = "";
attachedPdf.pageImages = await renderPdfPagesAsImages(pdf, targetPages);
} else {
/* 文字化けしていない場合:抽出したテキストを添付する */
attachedPdf.text = extractedText;
}
} else {
■ この差分のポイント(文字化けフォールバック分岐)
1. 差分⑩-3-2で作った isLikelyGarbled が、ここで実際に使われる
差分⑩-3-2で「6つの判定ロジックで多角的に検査する」isLikelyGarbled 関数を作りましたが、それがこの分岐で、実際の振り分けに組み込まれました。
「検査する関数」と「振り分ける処理」を分けたことで、検査ロジックを後で調整・拡張しても、振り分ける処理は変えずに済みます。
これも「役割ごとに関数を分ける」設計の利点です。
2. garbledFallback フラグの役割
文字化けと判定した場合、attachedPdf.garbledFallback = true; というフラグを立てています。
このフラグは、以下の2つの場面で使われます。
・buildPdfContext(追加1):
「文字化けによるフォールバック」として、
「文字化けのため、ページ画像として添付しています」
という前置きを生成する
・添付プレビュー(差分⑩-4で追加する renderAttachmentPreview):
「⚠ 文字化けの可能性 → ページ画像で送信(API利用料金が増加しますので注意してください)」
という、完成形と同じ警告表示をする
「文字化けした」という情報を、フラグとして保持しておくことで、後続の処理が
「なぜページ画像になったのか」
を正しく扱えるようにしています。
3. フォールバック時に text を空にする理由
フォールバック時には、
attachedPdf.text = “”;
と、抽出したテキストを空にしています。
これは、「文字化けしたテキストを、誤ってAIに送らないようにする」ための重要な処理です。
もし text に文字化けしたテキストが残ったままだと、buildPdfContext が「テキスト抽出済み」として扱ってしまい、意味のない文字の羅列をAIに送ってしまいます。
「フォールバックしたら、元のデータは確実に破棄する」という、堅牢な設計です。
4. buildMultimodalMessage との連携(差分⑩-4で完成)
ここで追加した「文字化けフォールバック分岐」は、差分⑩-4で追加する buildMultimodalMessage 関数と連携して、初めて「ページ画像としてAPIに送られる」という流れが完成します。
buildMultimodalMessage は、
「attachedPdf.pageImages がある場合は、それらを画像としてAPIに送る」
という処理を持っています。
文字化けフォールバック時に attachedPdf.pageImages にページ画像が格納されるため、buildMultimodalMessage は「テキストではなく、ページ画像を送る」という判断を、自動的に行います。
つまり、差分⑩-3-1〜⑩-3-3で整えた「PDFの状態管理」が、差分⑩-4の「送信データの構築」に、正しく引き継がれる設計になっています。
ここまでの中間確認(差分⑩-3-1・⑩-3-2・⑩-3-3)
これで、PDF処理の3つのパートがすべて揃いました。
「PDFの状態管理」の全体像を、確認しておきましょう。
| 差分 | 整った土台 |
|---|---|
| 差分⑩-3-1 PDF処理の本体 | PDFの「形式」と「サイズ」を検証し、PDF.jsで解析を開始する (handlePdfUpload)。 各ページからテキストを抽出する(extractPdfText) |
| 差分⑩-3-2 ページ画像化と 文字化け検出 | テキストが取れないPDFを「ページ画像」として描画する (renderPdfPagesAsImages)。 抽出したテキストが「文字化け」していないかを、 6つの判定ロジックで検査する(isLikelyGarbled) |
| 差分⑩-3-3 PDFコンテキストの構築 | PDFの「状態」に応じて、AIに送る「前置き文」を組み立てる (buildPdfContext)。 文字化けと判定した場合は、ページ画像に切り替える 「文字化けフォールバック分岐」を handlePdfUpload に追加 |
PDF処理の「流れ」は、以下のようになります。
1. 形式・サイズ検証 → 2. PDF.jsで解析 → 3. テキスト抽出を試行
3-a. テキストが取れない(スキャンPDF)
→ ページ画像化
3-b. テキストが取れたが、文字化け
→ ページ画像化(フォールバック)
3-c. テキストが取れて、文字化けしていない
→ テキストで送る
この「状態に応じた3つの振り分け」が、PDF処理の核心です。
あとは、差分⑩-4で「添付プレビューの描画」と「送信データの構築」を追加すれば、実際にPDFを添付して送信できるようになります。
この時点での動作について(重要な注意・差分⑩-1〜⑩-3の続き)
差分⑩-1〜⑩-3でもお伝えしましたが、差分⑩-3をすべて追加した時点でも、以下の関数がまだ存在しません。
・renderAttachmentPreview()(差分⑩-4で追加)
・removeAttachedPdf()(差分⑩-4で追加)
・buildMultimodalMessage()(差分⑩-4で追加)
差分⑩-1〜⑩-4をすべて追加して初めて、アップロード機能が完全に動き出します。
途中でブラウザを開くと「renderAttachmentPreview is not defined」などのエラーが出る可能性がありますが、これは正常な状態です。
焦らず、差分⑩-4まで進めてください。
次の差分⑩-4では、最後の「添付プレビュー描画と、マルチモーダルメッセージの構築」を追加します。
選択したファイル(画像・PDF・テキスト)の一覧を表示する renderAttachmentPreview と、
テキストと画像を組み合わせてAPIに送る buildMultimodalMessage という、
アップロード機能の「仕上げ」となる処理を追加します。
差分⑩-4:JavaScript(新規セクション)添付プレビュー描画と、マルチモーダルメッセージの構築(renderAttachmentPreview・removeAttachedPdf・clearAttachments・buildMultimodalMessage)
いよいよ、差分⑩の最後のパート「添付プレビュー描画と、マルチモーダルメッセージの構築」です。
差分⑩-1〜⑩-3で追加した各関数(handleImageUpload・handleTextFiles・handlePdfUpload など)の中には、繰り返し「renderAttachmentPreview()」という関数が呼ばれていました。
これが、選択したファイル(画像・PDF・テキスト)の一覧を「添付プレビューエリア」に描画する関数です。
そして、差分⑩-4のもう1つの主役が buildMultimodalMessage です。
これは、「テキストと画像を組み合わせて、APIに送るデータを構築する」関数で、差分⑪で拡張する sendMessage の中から呼ばれます。
ここでは、以下の4つの関数を追加します。
| 関数名 | 役割 |
|---|---|
| renderAttachmentPreview() | 添付したファイル(画像・PDF・テキスト)の一覧を、 添付プレビューエリアに描画する |
| removeAttachedPdf() | 添付プレビューの「✕」ボタンが押された時に、 添付PDFを取り消す |
| clearAttachments() | 送信後に、すべての添付ファイルをクリアする |
| buildMultimodalMessage (text) | テキストと画像を組み合わせて、 APIに送る「マルチモーダル形式」のメッセージを構築する |
この差分⑩-4を追加すれば、差分⑩の「ファイル処理関数」がすべて揃い、あとは差分⑪(sendMessage の拡張)と差分⑫(イベント登録)で、アップロード機能が完全に動き出します。
■ 追加1:renderAttachmentPreview 関数(添付プレビューを描画する)
まず、添付プレビューを描画する renderAttachmentPreview 関数を追加します。
■ 追加する場所
差分⑩-3-3で追加した buildPdfContext 関数の末尾(閉じ括弧 } の直後)に追加します。
まず、以下のコードブロックを検索してください(差分⑩-3-3で追加した buildPdfContext 関数の末尾部分です)。
} else {
/* ③ テキストが取れなかった場合(スキャンPDFなど):
ページ画像として添付していることを伝えます */
ctx += "このPDFはテキスト抽出できなかったため、ページ画像として添付しています。画像の内容を確認してください。";
}
return ctx;
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
添付プレビュー描画(ステップ④で追加)
------------------------------------------------------------
添付したファイル(画像・PDF・テキスト)の一覧を、
添付プレビューエリアに描画します。
「データが変わるたびに、一度空にしてから最新の状態で作り直す」
という、画面描画の基本パターン(【8】と同じ)で実装しています。
(完成形の renderAttachmentPreview と同じ仕様)
============================================================ */
function renderAttachmentPreview() {
/* 一度中身を空にする(追加し続けると、一覧が重複してしまうため) */
attachmentPreviewArea.innerHTML = "";
/* --- 画像のプレビュー(サムネイル付き) ---
attachedImages の1件ごとに「チップ」を作り、
画像のサムネイル・ファイル名・サイズ・削除ボタンを表示します。 */
attachedImages.forEach((img, i) => {
const chip = document.createElement("div");
chip.className = "attach-chip";
/* 画像のサムネイル(dataUrl をそのまま src に指定するだけで表示できる) */
const thumb = document.createElement("img");
thumb.src = img.dataUrl;
thumb.alt = img.name;
/* ファイル名とサイズ(formatBytes で読みやすい形式に変換) */
const label = document.createElement("span");
label.textContent = `${img.name} (${formatBytes(img.size)})`;
/* 削除ボタン(✕):押すと removeAttachedImage で添付から削除する */
const remove = document.createElement("button");
remove.className = "attach-remove";
remove.textContent = "✕";
remove.title = "削除";
remove.addEventListener("click", () => removeAttachedImage(i));
chip.appendChild(thumb);
chip.appendChild(label);
chip.appendChild(remove);
attachmentPreviewArea.appendChild(chip);
});
/* --- PDFのプレビュー ---
添付PDFの「処理状態」(処理中…/テキスト抽出済み/ページ画像化済み/
文字化けフォールバック)を、完成形と同じ表記で表示します。 */
if (attachedPdf) {
const chip = document.createElement("div");
chip.className = "attach-chip";
const label = document.createElement("span");
/* PDFの「状態」に応じて、表示するステータスを振り分ける */
let status;
if (attachedPdf.garbledFallback) {
/* 文字化け検出による自動フォールバックの場合:
「API利用料金が増加する」という警告も、完成形と同じ表記で表示します */
status = "⚠ 文字化けの可能性 → ページ画像で送信(API利用料金が増加しますので注意してください)";
} else if (attachedPdf.text) {
status = "テキスト抽出済み";
} else if (attachedPdf.pageImages && attachedPdf.pageImages.length > 0) {
status = "ページ画像化済み";
} else {
/* 解析がまだ終わっていない場合(処理中) */
status = "処理中…";
}
label.textContent = `📄 ${attachedPdf.name} (${formatBytes(attachedPdf.size)}) ${attachedPdf.pageRange} / ${status}`;
/* 削除ボタン(✕):押すと removeAttachedPdf で添付から削除する */
const remove = document.createElement("button");
remove.className = "attach-remove";
remove.textContent = "✕";
remove.title = "削除";
remove.addEventListener("click", removeAttachedPdf);
chip.appendChild(label);
chip.appendChild(remove);
attachmentPreviewArea.appendChild(chip);
}
/* --- テキストファイルのプレビュー ---
attachedTextFiles の1件ごとに「チップ」を作り、
ファイル名(またはパス)・サイズ・削除ボタンを表示します。 */
attachedTextFiles.forEach((file, i) => {
const chip = document.createElement("div");
chip.className = "attach-chip";
const label = document.createElement("span");
/* フォルダから選択した場合は「フォルダ名/ファイル名」のパスを表示し、
ホバーでフルパスが見えるようにします */
const displayPath = file.path !== file.name ? file.path : file.name;
label.textContent = `📝 ${displayPath} (${formatBytes(file.size)})`;
label.title = displayPath; // ホバーでフルパス表示
/* 削除ボタン(✕):押すと removeAttachedTextFile で添付から削除する */
const remove = document.createElement("button");
remove.className = "attach-remove";
remove.textContent = "✕";
remove.title = "削除";
remove.addEventListener("click", () => removeAttachedTextFile(i));
chip.appendChild(label);
chip.appendChild(remove);
attachmentPreviewArea.appendChild(chip);
});
}
■ この差分のポイント(renderAttachmentPreview)
1. 「一度空にしてから、最新の状態で作り直す」基本パターン
この関数の冒頭にある
attachmentPreviewArea.innerHTML = “”;
は、ステップ①の解説【8】で学んだ「データの描画」の基本パターンそのものです。
添付ファイルが「追加・削除」されるたびに、この関数が呼ばれます。
そのたびに
「一度空にしてから、最新のデータ(attachedImages・attachedPdf・attachedTextFiles)で作り直す」
ことで、一覧が常に正しい状態に保たれます。
2. 「状態」に応じたステータス表示
PDFのプレビューでは、差分⑩-3で整えた「PDFの状態管理」が、そのまま表示に活きています。
| PDFの状態 | 表示されるステータス |
|---|---|
| 文字化けフォールバック (garbledFallback が true) | 「⚠ 文字化けの可能性 → ページ画像で送信 (API利用料金が増加しますので注意してください)」 |
| テキスト抽出済み (text がある) | 「テキスト抽出済み」 |
| ページ画像化済み (pageImages がある) | 「ページ画像化済み」 |
| 処理中 (まだ解析が 終わっていない) | 「処理中…」 |
特に、文字化けフォールバックの場合は、
「API利用料金が増加しますので注意してください」
という警告も、完成形と同じ表記で表示しています。
ページ画像で送る方式は、テキストで送るよりもAPIの料金が高くなるため、
「なぜ料金が増えるのか」
をユーザーに正直に伝える、コストパフォーマンスへの配慮です。
3. 削除ボタンと「index」の結び付け
各「チップ」の削除ボタン(✕)には、それぞれのファイルに対応する「位置(index)」が結び付けられています。
例えば、画像の削除ボタンは
remove.addEventListener(“click”, () => removeAttachedImage(i));
と、
forEach
の第2引数 i(添え字)を渡しています。
これにより、「どのチップの✕が押されたか」が分かり、
removeAttachedImage(差分⑩-1)
や
removeAttachedTextFile(差分⑩-2)
が、正しい位置のファイルを削除できます。
なお、removeAttachedTextFile という関数は、差分⑩-2ではまだ追加していませんでした。
この関数も、後の「追加3」で追加します。
■ 追加2:removeAttachedPdf 関数(添付PDFを取り消す)
次に、添付PDFを取り消す removeAttachedPdf 関数を追加します。
■ 追加する場所
追加1で追加した renderAttachmentPreview 関数の末尾(閉じ括弧 } の直後)に追加します。
まず、以下のコードブロックを検索してください(追加1で追加した renderAttachmentPreview 関数の末尾部分です)。
/* 削除ボタン(✕):押すと removeAttachedTextFile で添付から削除する */
const remove = document.createElement("button");
remove.className = "attach-remove";
remove.textContent = "✕";
remove.title = "削除";
remove.addEventListener("click", () => removeAttachedTextFile(i));
chip.appendChild(label);
chip.appendChild(remove);
attachmentPreviewArea.appendChild(chip);
});
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- 添付PDFの削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
添付PDF(attachedPdf)を取り消します。
PDFは「1件だけ」しか添付できないため、null に戻すだけでOKです。 */
function removeAttachedPdf() {
attachedPdf = null; // 添付PDFを取り消す
renderAttachmentPreview(); // 添付プレビューを更新
}
■ この差分のポイント(removeAttachedPdf)
PDFは「1件だけ」しか添付できないため、画像やテキストファイルのような
「splice で指定位置を削除」
という処理は不要で、
attachedPdf = null;
と null に戻すだけで取り消せます。
削除のあとに renderAttachmentPreview() を呼んでいるのは、画像やテキストファイルの削除(removeAttachedImage・removeAttachedTextFile)と同じく、
「データが変わったら、画面を最新の状態に作り直す」
という基本パターンです。
■ 追加3:removeAttachedTextFile 関数(添付テキストファイルを削除する)
差分⑩-2では追加していなかった removeAttachedTextFile 関数を、ここで追加します。
■ 追加する場所
追加2で追加した removeAttachedPdf 関数の末尾(閉じ括弧 } の直後)に追加します。
まず、以下のコードブロックを検索してください(追加2で追加した removeAttachedPdf 関数の全体です)。
/* --- 添付PDFの削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
添付PDF(attachedPdf)を取り消します。
PDFは「1件だけ」しか添付できないため、null に戻すだけでOKです。 */
function removeAttachedPdf() {
attachedPdf = null; // 添付PDFを取り消す
renderAttachmentPreview(); // 添付プレビューを更新
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- 添付テキストファイルの削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
指定した位置(index)のテキストファイルを添付リストから削除します。
(removeAttachedImage と同じ、splice による指定位置の削除です) */
function removeAttachedTextFile(index) {
attachedTextFiles.splice(index, 1); // 指定した位置の1件を削除
renderAttachmentPreview(); // 添付プレビューを更新
}
■ この差分のポイント(removeAttachedTextFile)
この関数は、差分⑩-1で学んだ removeAttachedImage と全く同じ仕組みです。
attachedTextFiles.splice(index, 1) で「指定した位置の1件」を削除し、renderAttachmentPreview() で画面を最新の状態に作り直します。
「同じ処理は、同じパターンで書く」ことで、コードの読みやすさと一貫性が保たれます。
■ 追加4:clearAttachments 関数(すべての添付ファイルをクリアする)
次に、送信後にすべての添付ファイルをクリアする clearAttachments 関数を追加します。
■ 追加する場所
追加3で追加した removeAttachedTextFile 関数の末尾(閉じ括弧 } の直後)に追加します。
まず、以下のコードブロックを検索してください(追加3で追加した removeAttachedTextFile 関数の全体です)。
/* --- 添付テキストファイルの削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
指定した位置(index)のテキストファイルを添付リストから削除します。
(removeAttachedImage と同じ、splice による指定位置の削除です) */
function removeAttachedTextFile(index) {
attachedTextFiles.splice(index, 1); // 指定した位置の1件を削除
renderAttachmentPreview(); // 添付プレビューを更新
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* --- すべての添付ファイルをクリアする ---
送信後に呼ばれ、すべての添付ファイルをクリアします。
あわせて、「隠しファイル入力」の値もクリアしておくことで、
同じファイルを続けて選択できるようにします。
(完成形の clearAttachments と同じ仕様) */
function clearAttachments() {
attachedImages = [];
attachedPdf = null;
attachedTextFiles = [];
renderAttachmentPreview();
/* 「隠しファイル入力」の値もクリアする
これをしないと、同じファイルを続けて選択した時に
change イベントが発火せず、添付できなくなってしまいます */
imageFileInput.value = "";
pdfFileInput.value = "";
textFileInput.value = "";
folderFileInput.value = "";
}
■ この差分のポイント(clearAttachments)
この関数は、差分⑪で拡張する sendMessage の中から「送信後」に呼ばれます。
3つの添付リスト(attachedImages・attachedPdf・attachedTextFiles)を空にし、添付プレビューを更新するだけでなく、「隠しファイル入力の値もクリアする」という、さりげないが重要な処理をしています。
なぜ、隠しファイル入力の値をクリアする必要があるのでしょうか。
ファイル入力(input type=”file”)は、
「同じファイルを続けて選択した場合、change イベントが発火しない」
という仕様があります。
つまり、送信後に
value = “”
で値をクリアしておかないと、
「さっき送信したのと同じファイルを、もう一度添付しようとしても、change イベントが発火せず、添付できない」
という、不便な状態になってしまいます。
「送信後は、入力の状態も確実にリセットする」という、細やかなユーザー体験への配慮です。
■ 追加5:buildMultimodalMessage 関数(テキストと画像を組み合わせてAPIに送るデータを構築する)
最後に、差分⑩の主役の1つである buildMultimodalMessage 関数を追加します。
この関数は、
「テキストと画像を組み合わせて、APIに送るデータを構築する」
役割を持ち、差分⑪で拡張する sendMessage の中から呼ばれます。
「マルチモーダル(multimodal)」という言葉は、
「複数の形式(モード)のデータを組み合わせる」
という意味です。
これまでの送信は「テキストのみ」でしたが、画像を添付すると「テキスト+画像」という、2つの形式のデータを組み合わせて送る必要があります。
APIは、このような「テキスト+画像」のデータを、
「content を配列にして、各要素を { type: “text”, … } や { type: “image_url”, … } の形にする」
という、マルチモーダル形式で受け付けます。
■ 追加する場所
追加4で追加した clearAttachments 関数の末尾(閉じ括弧 } の直後)に追加します。
まず、以下のコードブロックを検索してください(追加4で追加した clearAttachments 関数の末尾部分です)。
imageFileInput.value = "";
pdfFileInput.value = "";
textFileInput.value = "";
folderFileInput.value = "";
}
この閉じ括弧 } の直後に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
マルチモーダルメッセージの構築(ステップ④で追加)
------------------------------------------------------------
テキストと画像を組み合わせて、APIに送るデータを構築します。
「マルチモーダル(multimodal)」とは、
「複数の形式(モード)のデータを組み合わせる」という意味です。
差分⑪で拡張する sendMessage の中から呼ばれます。
(完成形の buildMultimodalMessage と同じ仕様)
============================================================ */
function buildMultimodalMessage(text) {
const parts = [];
let fullText = text;
/* ----- ① テキストファイルの内容を「質問文の前」に添える -----
添付テキストファイルがある場合、ファイル名と中身を
質問文の前に添えて、AIに「どのファイルの話か」を伝えます。 */
if (attachedTextFiles.length > 0) {
const fileContexts = attachedTextFiles.map(f => {
const header = f.path !== f.name
? `【ファイル: ${f.path}】`
: `【ファイル: ${f.name}】`;
return `${header}\n${f.content}\n`;
}).join('\n---\n\n');
fullText = `【添付テキストファイル ${attachedTextFiles.length}件】\n${fileContexts}\n\n【ユーザーの質問】\n${text}`;
}
/* ----- ② PDFのコンテキストを「質問文の前」に添える -----
添付PDFがある場合、buildPdfContext(差分⑩-3-3)で組み立てた
「前置き文+抽出テキスト(または画像の案内)」を添えます。 */
if (attachedPdf) {
fullText = buildPdfContext(attachedPdf) + "\n\n" + fullText;
}
/* ----- ③ 「画像データがあるか」を判定する -----
画像(attachedImages)またはPDFのページ画像(attachedPdf.pageImages)
がある場合は、マルチモーダル形式(配列)で送る必要があります。 */
const hasMedia = attachedImages.length > 0 || (attachedPdf && attachedPdf.pageImages && attachedPdf.pageImages.length > 0);
/* 画像データがない場合は、これまで通り「テキストのみ」の形で送る */
if (!hasMedia) {
return { role: "user", content: fullText };
}
/* ----- ④ 画像データがある場合は「マルチモーダル形式」で送る -----
content を配列にして、各要素を
{ type: "text", text: ... } や { type: "image_url", image_url: { url: ... } }
の形にするのが、マルチモーダル形式のルールです。 */
parts.push({ type: "text", text: fullText });
/* 添付画像を配列に追加する */
attachedImages.forEach(img => {
parts.push({
type: "image_url",
image_url: { url: img.dataUrl }
});
});
/* PDFのページ画像を配列に追加する(テキスト抽出不可時・文字化け時) */
if (attachedPdf && attachedPdf.pageImages) {
attachedPdf.pageImages.forEach(dataUrl => {
parts.push({
type: "image_url",
image_url: { url: dataUrl }
});
});
}
return { role: "user", content: parts };
}
■ この差分のポイント(buildMultimodalMessage)
この関数は、差分⑩の「仕上げ」となる、最も重要な関数です。
4つの段階に分けて、APIに送るデータを組み立てています。
1. テキストファイルの内容を「質問文の前」に添える
添付テキストファイルがある場合、ファイル名と中身を、質問文の前に添えています。
【添付テキストファイル 2件】
【ファイル: sample.js】
(sample.js の中身)
---
【ファイル: style.css】
(style.css の中身)
【ユーザーの質問】
このコードのどこが問題ですか?
「【添付テキストファイル ○件】【ファイル: 〇〇】」という見出しを付けることで、AIは
「どのファイルの中身が、どこまでか」
を正しく区別して理解できます。
フォルダから選択した場合は、f.path に「フォルダ名/ファイル名」の相対パスが入るため、
「どのフォルダの、どのファイルか」
まで伝わります。
2. PDFのコンテキストを「質問文の前」に添える
添付PDFがある場合は、差分⑩-3-3で作った buildPdfContext を呼んで、
「前置き文+抽出テキスト(または画像の案内)」
を添えています。
ここで、差分⑩-3で整えた「PDFの状態管理」が、そのまま送信データに活きています。
・テキスト抽出済みの場合
… 「以下はPDFから抽出したテキストです。」という前置きのあと、
抽出テキストが添えられる
・テキスト抽出不可・文字化けの場合
… 「ページ画像として添付しています。
画像の内容を確認してください。」という前置きが添えられる
3. 「画像データがあるか」で、送信形式を振り分ける
ここが、この関数の最も重要な分岐です。
| 画像データ | 送信形式 | content の形 |
|---|---|---|
| ない (テキストのみ・ テキストファイル・ テキスト抽出済みPDF) | テキスト形式 (これまで通り) | content: “質問文” (文字列) |
| ある (画像・ページ画像化 PDF) | マルチモーダル形式 | content: [ { type: “text”, text: “質問文” }, { type: “image_url”, … } ](配列) |
APIは、
「content を配列にして、各要素を { type: “text”, … } や { type: “image_url”, … } の形にする」
という、マルチモーダル形式で「テキスト+画像」のデータを受け付けます。
「画像データがある時だけ、配列の形に切り替える」ことで、画像がない通常の質問は、これまで通りシンプルな「テキストのみ」の形で送れる、無駄のない設計になっています。
4. 画像とPDFのページ画像を、配列に追加する
画像データがある場合、parts という配列に、以下の順番で要素を追加しています。
① { type: “text”, text: fullText }
… 質問文(テキストファイル・PDFの内容を含む)
② { type: “image_url”, image_url: { url: img.dataUrl } }
… 添付画像(複数可)
③ { type: “image_url”, image_url: { url: dataUrl } }
… PDFのページ画像(複数可)
ここで、差分⑩-1で学んだ「dataUrl」(Base64エンコードした文字列)が、実際にAPIに送られる形で使われています。
画像データを「文字列」の形に変換しておいたおかげで、JSON形式のリクエストに、そのまま画像を含められるわけです。
差分⑩の全体像(まとめ)
これで、差分⑩の「ファイル処理関数」がすべて揃いました。
長い道のりでしたが、各関数が「どう連携しているか」の全体像を、確認しておきましょう。
■ ファイル処理関数の連携マップ
| 関数 | 役割 |
|---|---|
| handleImageUpload (差分⑩-1) | 画像の「形式」と「サイズ」を検証し、dataUrl に変換して attachedImages に追加する |
| handleTextFiles (差分⑩-2) | テキストファイルの「拡張子」「サイズ」を検証し、 中身のテキストを読み込んで attachedTextFiles に追加する |
| handleFolderUpload (差分⑩-2) | フォルダ内のファイルを「種類ごと」に振り分け、 それぞれの添付処理に渡す |
| handlePdfUpload (差分⑩-3-1) | PDFの「形式」と「サイズ」を検証し、PDF.jsで解析を開始する。 「テキストが取れるか」で「テキストで送る」か 「ページ画像で送る」かを振り分ける |
| renderPdfPages AsImages (差分⑩-3-2) | テキストが取れないPDFを「ページ画像」として描画する |
| isLikelyGarbled (差分⑩-3-2) | 抽出したテキストが「文字化け」していないかを、 6つの判定ロジックで検査する |
| buildPdfContext (差分⑩-3-3) | PDFの「状態」に応じて、AIに送る「前置き文」を組み立てる |
| renderAttachment Preview (差分⑩-4) | 添付したファイルの一覧を、添付プレビューエリアに描画する |
| buildMultimodal Message (差分⑩-4) | テキストと画像を組み合わせて、 APIに送るデータを構築する |
各関数が
「添付リスト(attachedImages・attachedPdf・attachedTextFiles)を更新する」→「renderAttachmentPreview で画面を最新の状態に作り直す」
という、共通のパターンで連携しているのが分かるでしょうか。
これが、「データが変わるたびに、画面を最新の状態に保つ」という、ステップ①から学んできた設計の骨格です。
ここまでの中間確認(差分⑩)
差分⑩で、アップロード機能の「ファイル処理の土台」が、すべて整いました。
ただし、まだこの時点では「関数を追加しただけ」で、これらの関数が実際に呼ばれるのは、以下の2つの差分を追加してからです。
・差分⑪:
sendMessage を「添付ファイル対応」に書き換え(buildMultimodalMessage を呼ぶようにする)
・差分⑫:
添付ボタン・ドラッグ&ドロップ・貼り付け・設定変更のイベントを登録(handleImageUpload などを呼ぶようにする)
つまり、差分⑪・⑫まで追加して初めて、アップロード機能が完全に動き出します。
途中でブラウザを開いても、まだアップロード機能は動きません(エラーにはなりませんが、添付ボタンを押しても何も起きません)。
焦らず、差分⑫まで進めてください。
次の差分⑪では、「sendMessage を添付ファイル対応に書き換える」作業を行います。
これまで「テキストのみ」を送っていた送信処理を、「添付ファイルがある場合は、buildMultimodalMessage で組み立てたデータを送る」形に拡張します。
あわせて、送信後に「すべての添付ファイルをクリアする」clearAttachments の呼び出しと、「添付のみ(質問文が空)の場合の既定文」の処理も追加します。
差分⑪:JavaScript【16】sendMessage(「添付ファイル対応」に書き換え)
いよいよ、ステップ④も終盤です。
ここでは、これまで「テキストのみ」を送っていた送信処理(sendMessage)を、
「添付ファイルがある場合は、buildMultimodalMessage で組み立てたデータを送る」
形に拡張します。
この差分では、以下の7つの変更を行います。
| No. | 変更内容 |
|---|---|
| 1 | 「空メッセージの送信防止」を「添付のみ(質問文が空)の場合は、既定文を使用」に拡張 |
| 2 | 「attachmentsMeta」(添付のメタデータ)を、会話履歴に保存するように追加 |
| 3 | 「buildMultimodalMessage」で、API送信用メッセージを構築するように変更 |
| 4 | 「conv.apiPayloadMessage」に、今回の送信用ペイロードを保持するように追加 |
| 5 | 送信後に「clearAttachments」を呼び出し、すべての添付ファイルをクリアするように追加 |
| 6 | 「streamChatCompletion」の引数を、「apiUserMessage」に変更 |
| 7 | 「finishGeneration」に、回答データへの「attachmentsMeta: []」の追加と、 「delete conv.apiPayloadMessage」(一時データの後片付け)を追加 |
■ 置き換える場所
JavaScript【16】sendMessage のセクションにある、以下のコメント行を検索してください。
/* ============================================================
【16】メッセージ送信(このアプリの主役となる処理)
------------------------------------------------------------
送信ボタンが押されてから、AIの回答が表示されるまでの
全体の流れを管理します。
※ステップ③では添付(画像・PDF・テキスト)は未実装のため、
テキストのみを送信します(ステップ④で拡張)。
============================================================ */
async function sendMessage() {
この関数全体(コメント行から、streamChatCompletion の呼び出し部分の try { の直前まで)をすべて選択して削除し、以下のコードに置き換えてください。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
【16】メッセージ送信(このアプリの主役となる処理)
------------------------------------------------------------
送信ボタンが押されてから、AIの回答が表示されるまでの
全体の流れを管理します。
※ステップ④で「添付ファイル対応」に拡張し、
テキストに加えて、添付した画像・PDF・テキストファイルも
送信できるようになりました。
============================================================ */
async function sendMessage() {
if (isGenerating) return; // 生成中の二重送信を防ぐ
clearError();
/* ----- ① 送信前のチェック(バリデーション) -----
「質問文が空」でも、「添付ファイルがある」場合は送信を許可します。
その場合は、既定の質問文(例:「添付ファイルの内容を説明してください。」)
を自動で使用します。 */
const text = messageInput.value.trim();
/* 質問文も添付ファイルも両方ない場合のみ、送信を中止する */
if (!text && attachedImages.length === 0 && !attachedPdf && attachedTextFiles.length === 0) {
showError("メッセージを入力してください。");
return;
}
/* APIキーは入力欄からその都度読み取る(保存はしない) */
apiKey = apiKeyInput.value.trim();
if (!apiKey) {
showError("APIキーが未入力です。設定パネルからRunpod APIキーを入力してください。");
settingsPanel.classList.remove("hidden");
return;
}
/* 質問文が空の場合は、「何が添付されているか」に応じた既定文を使う */
const userText = text || (
attachedTextFiles.length > 0 ? "添付ファイルの内容を説明してください。"
: attachedImages.length > 0 ? "添付ファイルを説明してください。"
: "添付PDFの内容を説明してください。"
);
const conv = getActiveConversation();
if (!conv) return;
/* ----- ② 添付のメタデータ(attachmentsMeta)を、会話履歴に保存する -----
「どのファイルを添付したか」の情報(ファイル名・形式・サイズ)を、
会話履歴に残します。
※添付の「中身」(画像データやPDFのテキスト)は保存しません。
これは、localStorage の容量を節約するためと、
「添付ファイルの内容は、送信したその時だけ参照される」という
仕様(【17】の解説を参照)に基づいています。 */
const attachmentsMeta = [];
attachedImages.forEach(img => attachmentsMeta.push({ name: img.name, type: img.type, size: img.size }));
if (attachedPdf) attachmentsMeta.push({ name: attachedPdf.name, type: "application/pdf", size: attachedPdf.size, pages: attachedPdf.pageRange });
attachedTextFiles.forEach(f => attachmentsMeta.push({ name: f.path, type: "text/plain", size: f.size }));
/* ----- ③ あなたの質問をデータに追加して画面に表示 ----- */
const userMsg = { role: "user", content: userText, attachmentsMeta };
conv.messages.push(userMsg);
/* この会話最初の質問なら、タイトルを自動設定する */
if (conv.messages.filter(m => m.role === "user").length === 1) {
generateConversationTitle(userText);
}
/* ----- ④ API送信用メッセージを「buildMultimodalMessage」で構築する -----
差分⑩-4で追加した buildMultimodalMessage を呼んで、
テキストと画像を組み合わせた「マルチモーダル形式」のデータを作ります。
添付がない場合は、これまで通り「テキストのみ」の形になります。 */
const apiUserMessage = buildMultimodalMessage(userText);
/* 今回の送信用ペイロードを、会話データに一時的に保持する
(buildChatCompletionRequest で、会話履歴の末尾をこれに差し替えるために使う。
これは保存しない一時的なデータです) */
conv.apiPayloadMessage = apiUserMessage;
saveConversations();
renderActiveConversation();
/* 入力欄を空にして高さをリセット */
messageInput.value = "";
autoResizeTextarea();
/* ----- ⑤ 送信後に、すべての添付ファイルをクリアする -----
差分⑩-4で追加した clearAttachments を呼んで、
すべての添付ファイル(画像・PDF・テキスト)をクリアします。
あわせて、「隠しファイル入力」の値もクリアされるため、
同じファイルを続けて選択できるようになります。 */
clearAttachments();
/* ----- ⑥ 生成開始の準備 ----- */
isGenerating = true;
requestWakeLock(); // 生成中はスリープを抑制(対応ブラウザのみ)
responseStartTime = performance.now(); // 応答計測の開始
hasRetriedNonStream = false;
fullResponseText = "";
renderScheduled = false;
abortController = new AbortController(); // 通信を途中で止める「中止装置」
updateUiState(); // 送信→停止ボタンに切り替え
/* 処理状況パネルを作成 */
currentProgressState = { logs: [], lastStatus: "" };
currentProgressPanel = createProgressPanel();
showProgressStatus("リクエストを準備しています…");
/* ----- ⑦ AIの回答用の「空の器」を先に表示する ----- */
const streamWrap = document.createElement("div");
streamWrap.className = "message assistant";
streamWrap.innerHTML = `
<div class="msg-avatar">K</div>
<div class="msg-body">
<div class="msg-role">${escapeHtml(getModelDisplayName())}</div>
<div class="msg-content markdown-body"></div>
</div>`;
chatContainer.appendChild(streamWrap);
currentStreamEl = streamWrap.querySelector(".msg-content");
/* 思考モードが有効なら思考過程パネルを作成 */
fullReasoningText = "";
if (isThinkingEnabled()) {
currentReasoningEl = createReasoningPanel(streamWrap);
} else {
currentReasoningEl = null;
}
scrollToBottom(true);
/* ----- ⑧ API通信(ストリーミング)を実行 -----
引数を「apiUserMessage」に変更しました。
これにより、添付した画像やPDFのページ画像も、APIに送られます。 */
try {
await streamChatCompletion(conv, apiUserMessage, true);
■ あわせて修正する場所:streamChatCompletion の呼び出し部分(catch・finally 内)
上記の置き換えで try { 以降のコードは、引数を apiUserMessage に変更しただけで、既存のコード(catch・finally 内の streamChatCompletion の呼び出し)と繋がります。
ただし、catch 内の「通常モードで再試行する処理」にも、引数の変更が必要です。
修正する場所
streamChatCompletion の呼び出し部分(try の直後)にある、以下のコードブロックを検索してください。
try {
await streamChatCompletion(conv, userText, false);
return;
} catch (err2) {
これを以下のコードに置き換えてください(引数を apiUserMessage に変更します)。
try {
await streamChatCompletion(conv, apiUserMessage, false);
return;
} catch (err2) {
■ あわせて修正する場所:buildChatCompletionRequest(会話履歴の末尾を apiPayloadMessage に差し替える処理を追加)
最後に、buildChatCompletionRequest 関数に、
「会話履歴の末尾を apiPayloadMessage に差し替える処理」
を追加します。
これは、「画面上に表示する質問文(userText)」と「実際にAPIに送る質問文(apiUserMessage)」が、異なる場合があるためです。
例えば、テキストファイルを添付した場合、画面上には
「このコードのどこが問題ですか?」
という質問文だけが表示されますが、実際にAPIに送るのは
「【添付テキストファイル ○件】(ファイルの中身)【ユーザーの質問】このコードのどこが問題ですか?」
という、ファイルの中身を含んだ質問文です。
buildChatCompletionRequest は、conv.messages(会話履歴)をそのままAPIに送るため、
「会話履歴の末尾(今回の質問)を、apiPayloadMessage に差し替える」
必要があります。
修正する場所
JavaScript【17】buildChatCompletionRequest 関数内にある、以下のコードブロックを検索してください。
/* これまでの会話を時系列で追加 */
conv.messages.forEach(m => {
messages.push({ role: m.role, content: m.content });
});
const model = getSelectedModel();
これを以下のコードに置き換えてください(末尾を apiPayloadMessage に差し替える処理を追加します)。
/* これまでの会話を時系列で追加 */
conv.messages.forEach(m => {
messages.push({ role: m.role, content: m.content });
});
/* 会話履歴の末尾(今回の質問)を、apiPayloadMessage に差し替える
画面上に表示する質問文(userText)と、実際にAPIに送る質問文
(apiUserMessage:ファイルの中身を含んだマルチモーダル形式)が
異なる場合があるため、末尾を差し替えます。 */
if (conv.apiPayloadMessage) {
messages.pop(); // 末尾(userText)を取り除く
messages.push(conv.apiPayloadMessage); // apiPayloadMessage を末尾に追加
}
const model = getSelectedModel();
■ あわせて修正する場所:finishGeneration(回答データへの attachmentsMeta 追加と、apiPayloadMessage の後片付け)
差分⑪の最後に、finishGeneration 関数に2つの小さな修正を加えます。
1つ目は、AIの回答メッセージにも attachmentsMeta: [](空の配列)を持たせる修正です。
差分⑪で、ユーザーの質問メッセージには attachmentsMeta(添付のメタデータ)を持たせるようにしました。
すべてのメッセージを同じデータ構造に揃えるための修正です。
2つ目は、delete conv.apiPayloadMessage の1行です。
差分⑪で設定した「API送信用の一時データ」を、回答の確定時に消去する「後片付け」です。
修正する場所
finishGeneration 関数内にある、以下のコードブロックを検索してください。
/* 受信した回答を会話データに保存 */
const conv = getActiveConversation();
if (conv && fullResponseText) {
conv.messages.push({ role: "assistant", content: fullResponseText });
saveConversations();
}
これを以下のコードに置き換えてください。
■ 置き換えるコード
※スマホの方はコードを長押しして選択・コピーしてください
/* 受信した回答を会話データに保存
※attachmentsMeta は「どのファイルを添付したか」の情報です。
ユーザーの質問メッセージと同じデータ構造に揃えるため、
AIの回答にも「空の配列」を入れています。
※apiPayloadMessage は差分⑪で設定した「API送信用の一時データ」です。
回答が確定した今、役目を終えたため消去します(後片付け)。
localStorage には保存されない設計のためリロードでも消えますが、
メモリ上にも残さないよう、確実に消しておきます。 */
const conv = getActiveConversation();
if (conv && fullResponseText) {
conv.messages.push({ role: "assistant", content: fullResponseText, attachmentsMeta: [] });
delete conv.apiPayloadMessage;
saveConversations();
}
■ この差分のポイント(finishGeneration の修正)
1. なぜ、AIの回答にも attachmentsMeta: [] を入れるのか
差分⑪で、ユーザーの質問メッセージは { role: “user”, content: userText, attachmentsMeta } という形にしました。
すべてのメッセージを同じデータ構造に揃えるため、AIの回答にも「空の配列」を入れています。
これにより、
差分⑫-5の renderMessage や、ステップ⑤の saveConversations・exportConversations で、
「attachmentsMeta があるか」を毎回厳密にチェックしなくても、安全に扱えるようになります。
(実際には、これらの関数にはガードがあるため、この修正がなくても正しく動作します。
「完成形と同じデータ構造に揃える」ことが目的の、統一のための修正です)
2. delete conv.apiPayloadMessage の意味
差分⑪で、conv.apiPayloadMessage に「今回の送信用ペイロード」を一時的に保持するようにしました。
これは「buildChatCompletionRequest で会話履歴の末尾を差し替える」ためだけの一時データで、
回答が確定した時点で役目を終えます。
saveConversations は「必要な項目だけ」を取り出して保存する設計のため、
この一時データが localStorage に保存されることはありません(リロードでも消えます)。
それでも、メモリ上にも残さないよう、確実に消去する「後片付け」を行います。
「使い終わった一時データは、確実に消す」というのも、
メモリリーク(メモリの無駄遣い)を防ぐ、堅牢な設計の基本です。
■ この差分のポイント
ここでは、6つの重要なポイントを押さえておきましょう。
1. 「添付のみ(質問文が空)の場合は、既定文を使用」
これまでは「質問文が空」の場合は無条件で送信を中止していましたが、今回の拡張で
「質問文が空でも、添付ファイルがある場合は送信を許可」
するようにしました。
その場合は、「何が添付されているか」に応じた既定文を自動で使用します。
| 添付されているもの | 自動で使われる既定文 |
|---|---|
| テキストファイルがある | 「添付ファイルの内容を説明してください。」 |
| テキストファイルがなく、画像がある | 「添付ファイルを説明してください。」 |
| テキストファイルも画像もなく、 PDFがある | 「添付PDFの内容を説明してください。」 |
「添付ファイルを説明してほしい」という、最も一般的な用途を、既定文でカバーしています。
2. attachmentsMeta(添付のメタデータ)を、会話履歴に保存する
「どのファイルを添付したか」の情報(ファイル名・形式・サイズ)を、attachmentsMeta として、会話履歴に保存するようにしました。
これにより、会話履歴を再表示した時に、
「この質問には、どのファイルが添付されていたか」
が分かります(差分⑪-補足で追加する renderMessage の拡張で、添付メタデータを表示します)。
重要なのは、
「添付の『中身』(画像データやPDFのテキスト)は保存しない」
という点です。
これは、localStorage の容量を節約するためと、
「添付ファイルの内容は、送信したその時だけ参照される」
という仕様(【17】の解説を参照)に基づいています。
3. buildMultimodalMessage で、API送信用メッセージを構築する
差分⑩-4で追加した buildMultimodalMessage を呼んで、
「テキストと画像を組み合わせたマルチモーダル形式」
のデータを作るようにしました。
添付がない場合は、これまで通り「テキストのみ」の形になるため、通常の質問は、今までと全く同じ流れで送られます。
添付がある場合だけ、「マルチモーダル形式」に切り替わる、無駄のない設計です。
4. conv.apiPayloadMessage に、今回の送信用ペイロードを保持する
「画面上に表示する質問文(userText)」と「実際にAPIに送る質問文(apiUserMessage)」が、異なる場合があるため、今回の送信用ペイロードを、会話データに一時的に保持しています。
これは保存しない一時的なデータで、buildChatCompletionRequest(差分⑪-補足)で「会話履歴の末尾をこれに差し替える」ために使います。
なお、この一時データは、回答の確定時に finishGeneration 内の
delete conv.apiPayloadMessage(直前の補足で追加した後片付け)
によって消去されます。
localStorage にも保存されない設計のため、画面を更新(リロード)しても残ることはありません。
5. 送信後に clearAttachments を呼び出す
送信後に clearAttachments(差分⑩-4)を呼んで、すべての添付ファイルをクリアするようにしました。
これにより、「送信したら、添付はリセットされる」という、直感的な動作になります。
あわせて、「隠しファイル入力」の値もクリアされるため、
「同じファイルを続けて選択できる」
という、細やかなユーザー体験への配慮も含まれています。
6. streamChatCompletion の引数を apiUserMessage に変更する
最後に、streamChatCompletion の引数を userText から apiUserMessage に変更しました。
これにより、「添付した画像やPDFのページ画像も、APIに送られる」ようになります。
buildChatCompletionRequest(差分⑪-補足)で
「会話履歴の末尾を apiPayloadMessage に差し替える」
ため、streamChatCompletion には apiUserMessage を渡せば、buildChatCompletionRequest 内で正しく差し替えられます。
この時点での動作について(重要な注意)
差分⑪のコードを追加した時点では、まだ以下の関数・機能が完成していません。
・renderMessage の「添付メタデータ表示」(差分⑫で追加)
・添付ボタン・ドラッグ&ドロップ・貼り付けのイベント登録(差分⑫で追加)
つまり、差分⑫まで追加して初めて、アップロード機能が完全に動き出します。
差分⑪までの時点でブラウザを開いても、
「添付ボタンを押しても何も起きない」(イベント登録がまだないため)
状態です。
焦らず、次の差分⑫まで進めてください。
ここまでの中間確認(差分⑩・⑪)
差分⑩・⑪で、アップロード機能の「ファイル処理と送信処理」が、すべて揃いました。
| 差分 | 整った土台 |
|---|---|
| 差分⑩ (ファイル処理関数) | 画像・PDF・テキスト・フォルダの読み込みと添付、 添付プレビュー描画、マルチモーダルメッセージの構築 |
| 差分⑪ (sendMessage の拡張) | 「添付ファイル対応」の送信処理。 添付のメタデータ保存、buildMultimodalMessage による API送信用メッセージの構築、送信後の添付クリア |
あとは、最後の差分⑫で「イベント登録」(添付ボタン・ドラッグ&ドロップ・貼り付け・設定変更)と、「renderMessage の添付メタデータ表示」を追加すれば、実際にファイルを添付して送信できるようになります。
次の差分⑫では、ステップ④の「仕上げ」となる作業を行います。
添付ボタン4つ・ドラッグ&ドロップ・貼り付け・設定変更のイベントを登録し、
あわせて renderMessage に「添付メタデータの表示」を追加して、
実際にファイルを添付して送信できるようにします。
差分⑫:JavaScript【24】イベント登録(添付ボタン・ドラッグ&ドロップ・貼り付け・設定変更)+renderMessage の添付メタデータ表示
いよいよ、ステップ④の「仕上げ」となる最後の差分です。
ここでは、以下の5つの作業を行います。
| No. | 作業内容 |
|---|---|
| 1 | イベント登録: 添付ボタン4つ(🖼📄📝📁)のクリックイベントと、 隠しファイル入力の change イベントを登録 |
| 2 | ドラッグ&ドロップ: 全画面のドラッグ&ドロップ(ドロップオーバーレイの表示・ファイルの振り分け)を登録 |
| 3 | 貼り付け: クリップボードからの画像・テキストファイルの貼り付けを登録 |
| 4 | 設定変更: アップロード上限の 設定項目の変更時イベント(saveUiSettings・updateSettingHints の呼び出し) を登録 |
| 5 | renderMessage の拡張: 添付メタデータ(attachmentsMeta)の表示を追加 |
この差分⑫を追加すれば、実際にファイルを添付して送信できるようになります。
■ 追加1:添付ボタン・隠しファイル入力・設定変更のイベント登録
まず、添付ボタン4つと、隠しファイル入力の change イベント、そしてアップロード上限の設定項目の変更時イベントを登録します。
■ 追加する場所
JavaScript【24】registerEvents 関数内にある、以下のカスタムプロンプトの「コピー」ボタンのイベント登録部分を検索してください。
/* 「コピー」ボタン:
編集エリアの内容をクリップボードにコピーする。
成功したらボタンの表示を一時的に「✓ コピーしました」に変える */
copyPromptBtn.addEventListener("click", () => {
navigator.clipboard.writeText(customSystemPrompt.value).then(() => {
const originalText = copyPromptBtn.textContent;
copyPromptBtn.textContent = "✓ コピーしました";
setTimeout(() => {
copyPromptBtn.textContent = originalText;
}, 2000);
}).catch(() => {
alert("コピーに失敗しました。手動で選択してコピーしてください。");
});
});
この部分の直後(ツールチップ補助のセクションの直前)に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
ステップ④で追加した添付関連のイベント登録
------------------------------------------------------------
添付ボタン4つ(🖼📄📝📁)のクリックイベントと、
隠しファイル入力の change イベント、
そしてアップロード上限の設定項目の変更時イベントを登録します。
============================================================ */
/* ----- 添付ボタン4つ:隠しファイル入力をクリックする ----- */
/* 画像添付ボタン(🖼)を押すと、隠しファイル入力(imageFileInput)を開く */
$("#imageAttachBtn").addEventListener("click", () => imageFileInput.click());
/* PDF添付ボタン(📄)を押すと、隠しファイル入力(pdfFileInput)を開く */
$("#pdfAttachBtn").addEventListener("click", () => pdfFileInput.click());
/* テキストファイル添付ボタン(📝)を押すと、隠しファイル入力(textFileInput)を開く */
$("#textAttachBtn").addEventListener("click", () => textFileInput.click());
/* フォルダ添付ボタン(📁)を押すと、隠しファイル入力(folderFileInput)を開く */
$("#folderAttachBtn").addEventListener("click", () => folderFileInput.click());
/* ----- 隠しファイル入力の change イベント:ファイルが選択された時に呼ばれる ----- */
/* 画像が選択された時:handleImageUpload(差分⑩-1)に渡す */
imageFileInput.addEventListener("change", (e) => {
handleImageUpload(Array.from(e.target.files || []));
});
/* PDFが選択された時:handlePdfUpload(差分⑩-3-1)に渡す(1ファイルのみ) */
pdfFileInput.addEventListener("change", (e) => {
const file = (e.target.files || [])[0];
if (file) handlePdfUpload(file);
});
/* テキストファイルが選択された時:handleTextFiles(差分⑩-2)に渡す */
textFileInput.addEventListener("change", (e) => {
handleTextFiles(Array.from(e.target.files || []));
});
/* フォルダが選択された時:handleFolderUpload(差分⑩-2)に渡す */
folderFileInput.addEventListener("change", (e) => {
handleFolderUpload(e.target.files || []);
});
/* ----- アップロード上限の設定項目の変更時イベント -----
設定が変更された時に、設定を保存し、ヒント表示を更新します。
saveUiSettings(差分⑨-3)の中で applyUploadSettings() が呼ばれるため、
「保存」「変数への反映」の2つがまとめて行われます。 */
[maxImageSizeInput, maxPdfSizeInput, maxPdfPagesInput, maxTextSizeInput, requestTimeoutInput].forEach(el => {
el.addEventListener("change", () => {
saveUiSettings();
updateSettingHints();
});
});
■ この差分のポイント(追加1)
1. 添付ボタンと「隠しファイル入力」の連携
差分③で学んだように、添付ボタン自体は「ファイルを選ぶ機能」を持っていません。
そこで、添付ボタンが押された時に、画面には見えない
「隠しファイル入力」(imageFileInput など)
を imageFileInput.click() でクリックして、ファイル選択ダイアログを開く仕組みにしています。
「見た目のボタン」と「実際のファイル選択」を分離する、Webアプリの定番のテクニックです。
2. change イベントで「ファイルが選択された時」に処理を呼ぶ
隠しファイル入力の change イベントは、「ファイルが選択された時」に発火します。
選択されたファイルは e.target.files に配列風オブジェクトとして入っているため、Array.from() で本物の配列に変換してから、それぞれの処理(handleImageUpload など)に渡しています。
PDFは「1ファイルのみ」しか添付できないため、
(e.target.files || [])[0]
で「最初の1件」だけを取り出しています。
3. アップロード上限の設定項目の変更時イベント
差分②で追加したアップロード上限の設定項目(maxImageSizeInput など)が変更された時に、saveUiSettings() と updateSettingHints() を呼んでいます。
saveUiSettings(差分⑨-3)の中で applyUploadSettings() が呼ばれるため、
「保存」「変数への反映」
の2つがまとめて行われます。
あわせて、updateSettingHints(差分⑨-2)で、ヒント表示(「10.0MB まで」など)も、現在の設定値に合わせて更新されます。
■ 追加2:ドラッグ&ドロップのイベント登録
次に、全画面のドラッグ&ドロップ(ドロップオーバーレイの表示・ファイルの振り分け)を登録します。
■ 追加する場所
追加1で追加したコードの直後(ツールチップ補助のセクションの直前)に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
ドラッグ&ドロップのイベント登録
------------------------------------------------------------
全画面のドラッグ&ドロップ(ドロップオーバーレイの表示・
ファイルの振り分け)を登録します。
ドロップされたファイルは、distributeDroppedFiles(この後に追加)で
「画像・PDF・テキストファイル」の種類ごとに振り分けられます。
============================================================ */
let dragCounter = 0; // ドロップオーバーレイの表示制御用カウンター
/* ファイルが画面に「入ってきた」時:
ドロップオーバーレイを表示する */
document.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
if (dragCounter === 1) {
dropOverlay.classList.add('visible');
}
});
/* ファイルが画面から「出ていった」時:
ドロップオーバーレイを隠す */
document.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter === 0) {
dropOverlay.classList.remove('visible');
}
});
/* ドラッグ中のデフォルト動作(ブラウザがファイルを開こうとする動作)をキャンセルする */
document.addEventListener('dragover', (e) => {
e.preventDefault();
});
/* ファイルが「ドロップ」された時:
ファイルを「種類ごと」に振り分けて、それぞれの添付処理に渡す */
document.addEventListener('drop', async (e) => {
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.remove('visible');
/* ドロップされたファイルを取得する */
const files = Array.from(e.dataTransfer.files || []);
/* フォルダのドロップはブラウザの制限により不安定なため、
フォルダ選択ボタンを自動でクリックしてダイアログを開く */
const items = Array.from(e.dataTransfer.items || []);
const hasDirectoryEntry = items.some(it => {
try {
const entry = it.webkitGetAsEntry ? it.webkitGetAsEntry() : null;
return entry && entry.isDirectory;
} catch (err) {
return false;
}
});
if (hasDirectoryEntry) {
showError("フォルダのドラッグ&ドロップはブラウザの制限により利用できません。フォルダ選択ダイアログを開きます。");
setTimeout(() => {
folderFileInput.click();
}, 100);
return;
}
/* フォルダ名と同じ名前の空ファイルが含まれる場合の除外処理
(一部のブラウザでフォルダをドロップすると、フォルダ名の0バイトファイルが作成される場合がある) */
const validFiles = files.filter(f => {
if (f.size === 0 && !f.name.includes('.')) {
return false;
}
return true;
});
if (validFiles.length === 0) {
showError("フォルダまたは無効なファイルがドロップされました。ファイルを個別に選択するか、フォルダ選択ボタン(📁)を使用してください。");
return;
}
/* ファイルを「種類ごと」に振り分けて、それぞれの添付処理に渡す */
distributeDroppedFiles(validFiles);
});
■ この差分のポイント(追加2)
1. dragCounter による「表示制御」の仕組み
ドロップオーバーレイの表示・非表示は、dragCounter というカウンターで制御しています。
これは、「ドラッグが画面の子要素を通過する時に、dragleave が誤って発火する」という、ブラウザの挙動への対策です。
・dragenter
… ファイルが画面に「入ってきた」時にカウンターを増やし、1になったら表示
・dragleave
… ファイルが画面から「出ていった」時にカウンターを減らし、0になったら隠す
このカウンターのおかげで、
「画面内の要素を通過するたびに、オーバーレイがチカチカ点滅する」
のを防いでいます。
2. dragover のデフォルト動作のキャンセル
document.addEventListener(‘dragover’, (e) => { e.preventDefault(); });
という1行は、
「ブラウザがファイルを開こうとするデフォルトの動作をキャンセルする」
ための、ドラッグ&ドロップの定番の処理です。
これを書かないと、ファイルをドロップした時に、ブラウザがそのファイル(画像やPDF)を新しいタブで開いてしまい、アプリの画面から離れてしまいます。
3. フォルダのドロップへの「ブラウザ制限」への対応
フォルダのドラッグ&ドロップは、「ブラウザの制限により不安定」という問題があります。
そこで完成形では、
「フォルダがドロップされたことを検知したら、フォルダ選択ダイアログを自動で開く」
という、実務的な対応をしています。
setTimeout(() => { folderFileInput.click(); }, 100);
で、エラーメッセージを表示したあと、少し遅れてフォルダ選択ダイアログを開いています。
これが、差分③の解説で「※フォルダのドロップは非対応のため、このボタンから選択してください」と案内した理由です。
4. distributeDroppedFiles(この後に追加する関数)で「種類ごとに振り分ける」
ドロップされたファイルは、distributeDroppedFiles という関数で
「画像・PDF・テキストファイル」
の種類ごとに振り分けられ、それぞれの添付処理(handleImageUpload・handlePdfUpload・handleTextFiles)に渡されます。
この関数は、差分⑫-4で追加します。
■ 追加3:クリップボードからの貼り付けのイベント登録
次に、クリップボードからの画像・テキストファイルの貼り付けを登録します。
■ 追加する場所
追加2で追加したコードの直後(ツールチップ補助のセクションの直前)に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
クリップボードからの貼り付けのイベント登録
------------------------------------------------------------
クリップボードからの画像・テキストファイルの貼り付けを登録します。
これが、差分③の解説で「貼り付け・ドラッグ&ドロップにも対応」
と案内した、貼り付け機能の正体です。
============================================================ */
messageInput.addEventListener("paste", (e) => {
const items = Array.from(e.clipboardData.items || []);
/* 画像の貼り付け(スクリーンショットなど)
クリップボードに画像データがある場合は、それをファイルとして添付する */
const imageItems = items.filter(it => ALLOWED_IMAGE_TYPES.includes(it.type));
if (imageItems.length > 0) {
const files = imageItems.map(it => it.getAsFile()).filter(Boolean);
if (files.length > 0) {
e.preventDefault(); // テキストとしての貼り付けをキャンセル
handleImageUpload(files);
}
}
/* テキストファイルの貼り付け
クリップボードにテキストファイルがある場合は、それを添付する */
const textItems = items.filter(it => {
if (it.kind === 'file') {
const file = it.getAsFile();
return file && isTextFile(file);
}
return false;
});
if (textItems.length > 0) {
const files = textItems.map(it => it.getAsFile()).filter(Boolean);
if (files.length > 0) {
e.preventDefault();
handleTextFiles(files);
}
}
});
■ この差分のポイント(追加3)
1. クリップボードの「中身」を、種類ごとに振り分ける
クリップボードからの貼り付けは、e.clipboardData.items で「中身」を取得し、それを「種類ごと」に振り分けています。
・画像データ(ALLOWED_IMAGE_TYPES に含まれる形式)
… スクリーンショットなどをファイルとして添付
・テキストファイル(isTextFile で判定)
… テキストファイルとして添付
it.getAsFile() で、クリップボードのデータを「File オブジェクト」に変換してから、それぞれの添付処理(handleImageUpload・handleTextFiles)に渡しています。
2. e.preventDefault() で「テキストとしての貼り付け」をキャンセルする
画像やテキストファイルが貼り付けられた場合、e.preventDefault() で
「テキストとしての貼り付けをキャンセル」
しています。
これを書かないと、スクリーンショットを貼り付けた時に、「画像が添付される」と同時に
「意味のないテキスト(画像のバイナリデータを文字化したもの)」
も入力欄に貼り付けられてしまいます。
「ファイルとして添付する」のか「テキストとして貼り付ける」のかを、明確に切り分けるための処理です。
■ 追加4:distributeDroppedFiles 関数(ドロップされたファイルを「種類ごと」に振り分ける)
次に、追加2で呼び出した distributeDroppedFiles 関数を追加します。
この関数は、ドロップされたファイルを「画像・PDF・テキストファイル」の種類ごとに振り分け、それぞれの添付処理に渡す役割を持ちます。
■ 追加する場所
JavaScript【14】思考過程パネルのセクションの末尾にある、
updateReasoningPanel 関数の閉じ括弧 } の直後
に追加します。
まず、以下のコードブロックを検索してください(updateReasoningPanel 関数の全体です)。
/* --- 思考過程の表示内容を更新する(受信のたびに呼ばれる) --- */
function updateReasoningPanel() {
if (!currentReasoningEl) return;
currentReasoningEl.textContent = fullReasoningText;
}
この閉じ括弧 } の直後(【15】UI状態管理セクションの直前)に、以下のコードを追加してください。
■ 追加するコード
※スマホの方はコードを長押しして選択・コピーしてください
/* ============================================================
ドロップされたファイルの振り分け(ステップ④で追加)
------------------------------------------------------------
ドラッグ&ドロップで落とされたファイルを、
「画像・PDF・テキストファイル」の種類ごとに振り分け、
それぞれの添付処理(handleImageUpload・handlePdfUpload・
handleTextFiles)に渡します。
(完成形の distributeDroppedFiles と同じ仕様)
============================================================ */
function distributeDroppedFiles(files) {
/* ドロップされたファイルを、種類ごとに分類する */
const images = files.filter(f => ALLOWED_IMAGE_TYPES.includes(f.type));
const pdf = files.find(f => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"));
const textFiles = files.filter(f => isTextFile(f));
let handledCount = 0; // 処理できたファイルの数
/* 画像があれば、画像の添付処理に渡す(差分⑩-1の関数を再利用) */
if (images.length > 0) {
handleImageUpload(images);
handledCount += images.length;
}
/* PDFがあれば、PDFの添付処理に渡す(差分⑩-3-1の関数を再利用)
PDFは「1件だけ」しか添付できないため、find で「最初の1件」だけを処理する */
if (pdf) {
handlePdfUpload(pdf);
handledCount += 1;
}
/* テキストファイルがあれば、テキストの添付処理に渡す(差分⑩-2の関数を再利用) */
if (textFiles.length > 0) {
handleTextFiles(textFiles);
handledCount += textFiles.length;
}
/* 処理できなかったファイルがある場合は、エラーを表示する */
const unsupported = files.filter(f =>
!ALLOWED_IMAGE_TYPES.includes(f.type) &&
f.type !== "application/pdf" &&
!f.name.toLowerCase().endsWith(".pdf") &&
!isTextFile(f)
);
if (unsupported.length > 0) {
/* 全てが未対応の場合のみエラー表示(一部対応済みなら警告程度に) */
if (handledCount === 0) {
showError(`対応していないファイル形式です:${unsupported.map(f => f.name).join(', ')}\n\n対応形式:画像(JPEG/PNG/GIF/WebP)、PDF、テキストファイル(.txt, .py, .js, .html 等)`);
} else {
console.warn("未対応ファイル:", unsupported.map(f => f.name));
}
}
}
■ この差分のポイント(追加4)
1. 「種類ごとに振り分ける」設計
この関数は、ドロップされたファイルを filter で「種類ごとに分類」して、それぞれの添付処理に渡す、司令塔のような関数です。
ここで見事なのは、「新しい処理をほとんど書いていない」ことです。
・画像 → handleImageUpload()(差分⑩-1で追加した関数を再利用)
・PDF → handlePdfUpload()(差分⑩-3-1で追加した関数を再利用)
・テキストファイル → handleTextFiles()(差分⑩-2で追加した関数を再利用)
「同じ処理は、同じ関数に任せる」という、DRY(Don’t Repeat Yourself)の原則の実践です。
差分⑩で作った各ファイル処理が、そのままドラッグ&ドロップにも使われているのは、関数を「部品」として設計したおかげです。
2. PDFは「1件だけ」しか添付できない
PDFは「1件だけ」しか添付できないため、find で「最初の1件」だけを取り出しています。
filter ではなく find を使っているのは、「条件に合う最初の1件だけを取り出す」ためです。
3. 処理できなかったファイルへの「丁寧なエラー表示」
ドロップされたファイルの中に「処理できなかったファイル」(対応していない形式)がある場合、以下のように振り分けています。
・全てが未対応の場合(handledCount === 0)
… エラーを表示し、対応形式を案内する
・一部対応済みの場合
… コンソールに警告を出すだけに留め、処理済みのファイルは正常に添付する
「一部でも処理できたなら、そのファイルは正常に添付する」という、実務的な設計です。
例えば、「画像2枚と、対応していないファイル1つ」をドロップした場合でも、画像2枚は正常に添付され、対応していないファイルだけが警告されます。
■ 追加5:renderMessage への「添付メタデータ表示」の追加
最後に、renderMessage 関数に「添付メタデータ(attachmentsMeta)の表示」を追加します。
これは、差分⑪で会話履歴に保存した
「どのファイルを添付したか」の情報(ファイル名・形式・サイズ)
を、会話履歴を再表示した時に表示するためのものです。
■ 追加する場所
renderMessage 関数内にある、body.appendChild(actions); の直前に追加します。
まず、以下のコードブロックを検索してください(renderMessage 関数内の、操作ボタンを追加する直前の部分です)。
/* 操作ボタン(コピー / 再生成 / 再送信。ホバー時だけ表示) */
const actions = document.createElement("div");
actions.className = "msg-actions";
このコメント行の直前に、以下のコードを追加します。
実際には、操作ボタンの該当箇所を置き換える形になります。
■ 追加する(置き換える)コード
※スマホの方はコードを長押しして選択・コピーしてください
/* 添付メタデータ表示(ステップ④で追加)
「どのファイルを添付したか」の情報(ファイル名・形式・サイズ)を、
会話履歴を再表示した時に表示します。
※添付の「中身」(画像データやPDFのテキスト)は保存していないため、
ここでは「ファイル名とサイズ」のみを表示します。 */
if (msg.attachmentsMeta && msg.attachmentsMeta.length > 0) {
const attachBox = document.createElement("div");
attachBox.className = "msg-attachments";
msg.attachmentsMeta.forEach(a => {
const chip = document.createElement("span");
chip.className = "msg-attachment";
/* PDFの場合は「📄」、それ以外(画像・テキストファイル)は「🖼」のアイコンを付ける */
const icon = a.type === "application/pdf" ? "📄" : "🖼";
chip.textContent = `${icon} ${a.name} (${formatBytes(a.size || 0)})`;
attachBox.appendChild(chip);
});
body.appendChild(attachBox);
}
/* 操作ボタン(コピー / 再生成 / 再送信。ホバー時だけ表示) */
const actions = document.createElement("div");
actions.className = "msg-actions";
■ この差分のポイント(追加5)
1. 「ファイル名とサイズ」のみを表示する理由
差分⑪の解説でお伝えしたように、添付の「中身」(画像データやPDFのテキスト)は、localStorage の容量を節約するため、保存していません。
そのため、会話履歴を再表示した時には、
「どのファイルが添付されていたか」(ファイル名とサイズ)
だけを表示します。
これが、
「添付ファイルの内容は、送信したその時だけ参照される」
という仕様(【17】の解説を参照)に基づく、表示の設計です。
2. msg-attachments と msg-attachment のスタイル(差分④で追加済み)
この表示に使われている .msg-attachments と .msg-attachment のスタイルは、差分④で追加した CSS に含まれています。
/* ============================================================
添付メタデータ表示(会話履歴の「どのファイルを添付したか」の表示)
※差分⑫-5 の renderMessage で使用します
============================================================ */
.msg-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.msg-attachment {
font-size: 12px;
background: rgba(127,127,127,.12);
padding: 4px 10px;
border-radius: 6px;
color: var(--text-sub);
}
「添付プレビュー」(送信前の一覧表示)は「チップ」で、「添付メタデータ」(会話履歴の表示)は「小さなラベル」で、それぞれ別のスタイルが適用されています。
3. formatBytes()(差分⑧で追加済み)の再利用
a.size(ファイルのサイズ)を formatBytes() で「○○ MB」のような読みやすい形式に変換しています。
これも、差分⑧で追加した関数を再利用している、DRYの原則の実践です。
動作確認の手順
ここまでの差分①〜⑫をすべて適用したら、以下の手順で動作確認を行ってください。
1. ファイルを保存して、ブラウザで開き直す
編集した custom-app-dev.html を保存し、ブラウザで開き直します(すでに開いている場合は再読み込みします)。
2. 添付ボタンを試す
入力エリアの左端にある4つの添付ボタン(🖼📄📝📁)を、それぞれ押してみてください。
ファイル選択ダイアログが開けば成功です。
3. 画像を添付してみる
🖼 ボタンを押し、JPEG/PNG/GIF/WebP のいずれかの画像を選択してください。
添付プレビューエリアに、画像のサムネイル・ファイル名・サイズが表示されれば成功です。
(ココに画像添付時の添付プレビューのスクショを貼る)
4. 画像を添付したまま、質問を送信してみる
画像を添付したまま、「この画像の内容を説明してください」などと送信してください。
画像の内容を説明する回答が返ってくれば、画像の添付が正しく機能しています。
5. ドラッグ&ドロップを試す
エクスプローラー(Windows)やFinder(Mac)から、画像ファイルをアプリの画面にドラッグ&ドロップしてください。
画面全体に「📂 ファイル・フォルダをドロップ」というオーバーレイが表示され、ドロップすると画像が添付されれば成功です。
6. テキストファイルを添付してみる
📝 ボタンを押し、.txt/.py/.js などのテキストファイルを選択してください。
添付プレビューエリアにファイル名とサイズが表示されれば成功です。
「このコードのどこが問題ですか?」などと送信し、ファイルの内容を踏まえた回答が返ってくることを確認してください。
7. PDFを添付してみる
📄 ボタンを押し、PDFファイルを選択してください。
添付プレビューエリアに
「テキスト抽出済み」
または
「ページ画像化済み」
と表示されれば成功です。
「このPDFの内容を説明してください」などと送信し、PDFの内容を踏まえた回答が返ってくることを確認してください。
8. アップロード上限を変えてみる
設定パネルを開き、
「🖼 画像アップロード上限 (KB)」
を「5120」(5MB)に変更してください。
ヒント表示が「5.0MB まで」に変われば、差分⑨の修正が正しく動いています。
その状態で、5MBを超える画像を添付しようとすると、「画像サイズが上限を超えています」というエラーが表示されれば成功です。
9. 添付メタデータが表示されるかを確認する
ファイルを添付して質問を送信したあと、画面を更新(リロード)してください。
会話履歴の質問の下に、「🖼 ファイル名 (○○ MB)」という小さなラベルが表示されていれば、添付メタデータの保存・表示が正しく機能しています。
ステップ④、完走おつかれさまでした!
これで、ステップ④の作業はすべて終了です。おつかれさまでした。
長い道のりでしたが、ここまでの道のりを、振り返ってみましょう。
■ あなたが作り上げたもの
これまで「テキストのみ」だった質問に、画像・PDF・テキストファイル・フォルダを添付して送れる、本格的なファイルアップロード機能を実装しました。
ドラッグ&ドロップやクリップボードからの貼り付けにも対応し、設定パネルでは「アップロード上限」も変更できるようになっています。
■ この過程で学んだ、プログラミングの核心概念
・FileReader API
(ファイルの中身を読み込むブラウザ標準機能)
・Base64エンコード
(画像データを「文字列」に変換してAPIに送る仕組み)
・PDF.js
(PDFをブラウザ上で解析する外部ライブラリ)
・マルチモーダル形式
(テキストと画像を組み合わせてAPIに送るデータ形式)
・ドラッグ&ドロップ API
(ファイルを画面に落として添付する操作の実現)
・文字化け検出
(6つの判定ロジックによる、多角的な検査設計)
・フォールバック設計
(テキスト抽出不可時・文字化け時の「ページ画像で送る」代替手段)
・DRYの原則
(差分⑩で作った各ファイル処理を、ドラッグ&ドロップにも再利用する設計)
これらは、どれも「ファイルを扱うWebアプリ開発」で通用する、実務的な基礎体力です。
特に「文字化け検出」と「フォールバック設計」は、完成形のアプリの中でも特に高度な処理で、「想定外の状況にも、丁寧に対応する」という、堅牢な設計の考え方を学ぶ格好の題材です。
ステップ④:完成コード
ここまでの拡張、お疲れ様でした。
以下がステップ④を完走時点のコードです。
※スマホの方はコードを長押しして選択・コピーしてください
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<!-- viewport:スマホ・タブレットでも見やすい表示にするための設定 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>カスタムAIチャットアプリ開発(MIT License)</title>
<!-- ============================================================================
The MIT License
Copyright 2026 child programmer
--------
Here is the original version of the program released by child programmer.
https://child-programmer.com//rp-api/chat-k/custom-app-development/step4/
--------
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the “Software”),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================================== -->
<!-- ============================================================
外部ライブラリの読み込み(CDN:インターネット経由)
1ファイル完結のため、「カスタムAIチャットアプリ」(ダウンロード版)
のローカル参照(./js/...)をCDN参照に切り替えています。
============================================================ -->
<!-- marked:Markdown記法をHTMLに変換する -->
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<!-- DOMPurify:生成されたHTMLの安全性をチェックする(XSS対策) -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.5/dist/purify.min.js"></script>
<!-- highlight.js:コードブロックに色を付ける(シンタックスハイライト) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- highlight.js の配色テーマ(github-dark) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
<!-- PDF.js:PDFファイルをブラウザ上で解析するライブラリ
テキストの抽出や、ページの画像化(レンダリング)に使います。 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<style>
/* ==================================================
CSS:アプリの「見た目」を定義する部分
家でいうところの「内装・壁紙・家具の配置」です。
色合いは「カスタムAIチャットアプリ」(ダウンロード版)と同一です。
================================================== */
/* ------------------------------------------
テーマカラーの設定(CSS変数)
「--〇〇」という名前で色を登録しておき、
各所で var(--〇〇) として呼び出します。
------------------------------------------ */
/* ライトモード:目に優しい配色 */
:root {
/* 背景:真っ白ではなく、わずかに暖かみのあるオフホワイト */
--bg: #f7f5f0;
/* サイドバー背景:背景より少し暗く、境界を明確に */
--bg-sidebar: #efede6;
/* 入力欄背景:白背景で明確に識別 */
--bg-input: #ffffff;
/* テキスト:濃すぎないダークグレー */
--text: #2c2c2c;
--text-sub: #5a5a5a;
/* 境界線:暖色系に合わせて調整 */
--border: #d8d5cc;
/* アクセント色:視認性を維持しつつ、少し落ち着いた色に */
--accent: #4a5dd8;
--accent-hover: #3d4ec4;
/* 危険色:落ち着いた赤に */
--danger: #d64545;
/* コードブロック用 */
--bg-code: #1e1e2e;
--bg-user-msg: #e8f0fe;
--bg-progress: #f4f6f8;
--code-text: #e6e6f0;
--shadow: 0 2px 8px rgba(0,0,0,.08);
}
/* ダークモード(bodyタグに dark クラスが付いた時適用) */
body.dark {
--bg: #17181c;
--bg-sidebar: #1e1f24;
--bg-input: #2a2a2a;
--bg-code: #0f0f17;
--bg-user-msg: #2b3348;
--bg-progress: #202127;
--text: #e8e8ec;
--text-sub: #9a9aa5;
--border: #34353c;
--accent: #7a86ff;
--accent-hover: #9198ff;
--danger: #f16469;
--code-text: #e6e6f0;
--shadow: 0 2px 8px rgba(0,0,0,.4);
}
/* ============================================================
ベース(画面全体の骨組み)
============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
overflow: hidden; /* 画面全体ではスクロールさせない */
transition: background .2s, color .2s;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
.hidden { display: none !important; }
/* ============================================================
サイドバー(左側:新しいチャット+履歴一覧)
============================================================ */
#sidebar {
width: 260px;
min-width: 260px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: margin-left .25s;
overflow-x: hidden;
overflow-y: auto;
}
/* サイドバーが閉じている状態(マイナスマージンで画面外へ) */
#sidebar.collapsed { margin-left: -260px; }
#sidebar-header { padding: 12px; }
#newChatBtn {
width: 100%;
padding: 10px;
background: var(--accent);
color: #fff;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
transition: background .15s;
}
#newChatBtn:hover { background: var(--accent-hover); }
#conversationList {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
}
/* 会話履歴1件分 */
.conv-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 10px;
border-radius: 8px;
margin-bottom: 4px;
cursor: pointer;
font-size: 14px;
color: var(--text);
}
.conv-item:hover { background: rgba(127,127,127,.12); }
.conv-item.active { background: rgba(91,108,255,.16); }
.conv-title {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: auto;
user-select: none; /* テキスト選択を防ぐ */
}
/* ×削除ボタン(ホバー時だけ表示) */
.conv-delete {
opacity: 0;
color: var(--text-sub);
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
}
.conv-item:hover .conv-delete { opacity: 1; }
.conv-delete:hover { color: var(--danger); background: rgba(229,72,77,.12); }
/* ✎編集ボタン(ホバー時だけ表示) */
.conv-edit {
opacity: 0;
color: var(--text-sub);
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
}
.conv-item:hover .conv-edit { opacity: 1; }
.conv-edit:hover { color: var(--accent); background: rgba(91,108,255,.12); }
/* タイトル編集用の入力欄 */
.conv-title-input {
flex: 1;
min-width: 0;
padding: 2px 6px;
border: 1px solid var(--accent);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
}
/* サイドバー下部のボタン群(全履歴削除・テーマ) */
#sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
flex-wrap: wrap;
}
#sidebar-footer button {
flex: 1;
min-width: calc(50% - 4px);
font-size: 12px;
color: var(--text-sub);
padding: 6px;
border: 1px solid var(--border);
border-radius: 6px;
}
#sidebar-footer button:hover { color: var(--text); background: rgba(127,127,127,.1); }
/* 設定パネル内のリンクの色 */
#settingsPanel a:link,
#settingsPanel a:visited { color: var(--accent); text-decoration: underline; }
#settingsPanel a:hover,
#settingsPanel a:active { color: var(--accent-hover); }
/* ============================================================
スクロールバー(ダークモード対応)
============================================================ */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-sub); }
/* Firefox用 */
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
/* ============================================================
メイン領域(右側:チャット画面)
============================================================ */
#main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* トップバー */
#topbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
#toggleSidebarBtn { font-size: 18px; padding: 4px 8px; border-radius: 6px; }
#toggleSidebarBtn:hover { background: rgba(127,127,127,.12); }
#topbar-title {
font-size: 15px; font-weight: 600; flex: 1;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
#settingsToggleBtn {
font-size: 15px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-sub);
}
#settingsToggleBtn:hover { color: var(--text); background: rgba(127,127,127,.1); }
/* 設定パネル(トップバーの下に展開されるエリア) */
#settingsPanel {
border-bottom: 1px solid var(--border);
padding: 14px;
background: var(--bg-sidebar);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
max-height: calc(100vh - 60px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.setting-field label {
display: block;
font-size: 12px;
color: var(--text-sub);
margin-bottom: 4px;
}
.setting-field input,
.setting-field select,
.setting-field textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 13px;
font-family: inherit;
}
/* チェックボックスだけは横幅いっぱいにしない(ステップ③で使用) */
.setting-field input[type="checkbox"] {
width: auto;
margin: 0;
}
/* 設定項目の下に表示する小さな補足説明文(完成形と同じ見た目) */
.setting-hint {
font-size: 12px;
color: var(--accent); /* アクセントカラーで目立たせる */
margin-top: 4px;
font-weight: 600; /* 少し太字にして読みやすく */
}
/* カスタムシステムプロンプトの編集エリア
長文を扱うため、フォントを等幅(monospace)にし、
高さを自由に変えられる(resize: vertical)ようにしています。 */
#customPromptField textarea {
font-family: "SF Mono", Consolas, Menlo, monospace;
font-size: 14px;
line-height: 1.6;
resize: vertical; /* 高さだけを自由に変えられる(横は固定) */
min-height: 200px;
}
/* エラーメッセージ表示エリア */
#errorArea {
background: rgba(229,72,77,.1);
border: 1px solid var(--danger);
color: var(--danger);
font-size: 13px;
padding: 10px 14px;
margin: 10px 16px 0;
border-radius: 8px;
display: none;
white-space: pre-wrap;
word-break: break-word;
}
#errorArea.visible { display: block; }
/* ============================================================
会話表示エリア(メッセージ一覧)
------------------------------------------------------------
完成形の構造:アバター(U/K)+ロールラベル+本文を
「横並び(flex)」で配置し、コンテナ全体を中央寄せします。
ユーザーの質問が右に寄ることはありません。
============================================================ */
#chatContainer {
flex: 1;
overflow-y: auto;
padding: 20px 16px 24px;
scroll-behavior: smooth;
}
/* メッセージ1件分の枠(アバターと本文を横並びにする) */
.message {
max-width: 860px;
margin: 0 auto 22px;
display: flex;
gap: 12px;
}
/* 丸いアバター(ユーザーは「U」、AIは「K」) */
.msg-avatar {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #fff;
}
.message.user .msg-avatar { background: #3b82c4; } /* ユーザー:青 */
.message.assistant .msg-avatar { background: var(--accent); } /* AI:アクセント色 */
.msg-body { flex: 1; min-width: 0; }
.msg-role { font-size: 12px; color: var(--text-sub); margin-bottom: 4px; }
.msg-content {
font-size: 15.5px;
line-height: 1.8;
word-break: break-word;
}
/* あなた(ユーザー)の質問の吹き出し */
.message.user .msg-content {
background: var(--bg-user-msg);
padding: 10px 14px;
border-radius: 12px;
white-space: pre-wrap;
}
/* メッセージ操作ボタン(コピー・再生成など。ホバー時だけ表示) */
.msg-actions {
margin-top: 6px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity .15s;
}
.message:hover .msg-actions { opacity: 1; }
.msg-actions button {
font-size: 12px;
color: var(--text-sub);
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 5px;
}
.msg-actions button:hover { color: var(--text); background: rgba(127,127,127,.1); }
/* ============================================================
処理状況パネル(「回答を受信しています…」などの表示)
スピナー+ステータステキスト+開閉できるログの構造です。
============================================================ */
.progress-panel {
max-width: 860px;
margin: 0 auto 18px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-progress);
font-size: 13px;
overflow: hidden;
}
.progress-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
}
.progress-header:hover { background: rgba(127,127,127,.08); }
.progress-caret { font-size: 11px; color: var(--text-sub); transition: transform .2s; }
.progress-panel.open .progress-caret { transform: rotate(90deg); }
.progress-current { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.progress-body {
display: none;
border-top: 1px solid var(--border);
padding: 8px 14px 10px;
max-height: 180px;
overflow-y: auto;
}
.progress-panel.open .progress-body { display: block; }
.progress-log-item {
color: var(--text-sub);
padding: 3px 0;
display: flex;
gap: 8px;
align-items: center;
}
/* ログの状態別アイコン */
.progress-log-item.done::before { content: "✓"; color: #2e9e5b; font-weight: 700; }
.progress-log-item.current::before { content: "●"; color: var(--accent); }
.progress-log-item.stopped::before { content: "■"; color: var(--danger); }
/* くるくる回るスピナー(処理中の目印) */
.spinner {
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ============================================================
思考過程パネル(AIの考えている内容を表示する領域)
生成中のみ表示される一時的なパネルです。
回答完了時にストリーミング用DOMごと消去されます
(思考過程のテキストは保存されません)。
============================================================ */
.reasoning-panel {
border: 1px dashed var(--border);
border-radius: 8px;
margin-bottom: 10px;
background: var(--bg-progress);
overflow: hidden;
}
.reasoning-header {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
cursor: pointer;
user-select: none;
font-size: 12.5px;
color: var(--text-sub);
}
.reasoning-header:hover { background: rgba(127,127,127,.08); }
.reasoning-caret { font-size: 10px; transition: transform .2s; }
.reasoning-panel.open .reasoning-caret { transform: rotate(90deg); }
.reasoning-body {
display: none;
border-top: 1px dashed var(--border);
padding: 10px 14px;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-sub);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
.reasoning-panel.open .reasoning-body { display: block; }
/* ストリーミング中のカーソル(▍の点滅) */
.stream-cursor {
display: inline-block;
width: 8px; height: 16px;
background: var(--accent);
margin-left: 3px;
vertical-align: text-bottom;
animation: blink 1s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* ============================================================
Markdown表示の調整(AIの回答内)
============================================================ */
.markdown-body pre {
background: var(--bg-code);
border-radius: 10px;
margin: 12px 0;
overflow: hidden;
position: relative;
border: 1px solid rgba(128, 128, 128, 0.3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* コードブロックのヘッダー(言語名+コピーボタン) */
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.08);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.code-lang {
font-size: 11.5px;
color: #7dd3fc;
font-family: monospace;
text-transform: none;
font-weight: 600;
letter-spacing: 0.5px;
}
body.dark .markdown-body pre {
background: #1a1b26;
border: 1px solid rgba(255, 255, 255, 0.1);
}
body.dark .code-header {
background: rgba(255, 255, 255, 0.06);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
body.dark .code-lang {
color: #22d3ee;
text-shadow: 0 0 8px rgba(34, 211, 238, 0.3);
}
.code-copy-btn {
font-size: 11.5px;
color: #c0c4d6;
background: rgba(255,255,255,.08);
border-radius: 5px;
padding: 3px 10px;
}
.code-copy-btn:hover { background: rgba(255,255,255,.16); color: #fff; }
.code-copy-btn.copied { color: #7ee2a0; }
.markdown-body pre code {
display: block;
padding: 14px;
overflow-x: auto;
font-family: "SF Mono", Consolas, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
color: var(--code-text);
white-space: pre;
}
.markdown-body code:not(pre code) {
background: rgba(127,127,127,.18);
padding: 2px 6px;
border-radius: 4px;
font-family: "SF Mono", Consolas, Menlo, monospace;
font-size: 13px;
}
.markdown-body p { margin: 8px 0; }
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { margin: 16px 0 8px; }
.markdown-body ul, .markdown-body ol { margin: 8px 0 8px 22px; }
.markdown-body table { border-collapse: collapse; margin: 10px 0; }
.markdown-body th, .markdown-body td { border: 1px solid var(--border); padding: 6px 12px; font-size: 13.5px; }
.markdown-body blockquote {
border-left: 3px solid var(--accent);
padding: 2px 12px;
margin: 10px 0;
color: var(--text-sub);
}
.markdown-body img { max-width: 100%; border-radius: 8px; }
/* ============================================================
カスタムツールチップ(data-tooltip属性)
完成形と同じ仕組み:data-tooltip="説明文" と書くだけで、
ホバー時に見やすいポップ(三角矢印付き)が出ます。
============================================================ */
[data-tooltip] { position: relative; }
/* 吹き出し本体(デフォルト:上向き表示) */
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) scale(0.95);
background: var(--bg-sidebar);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
font-size: 12px;
font-weight: normal;
line-height: 1.6;
white-space: pre-line;
text-align: left;
box-shadow: var(--shadow);
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
pointer-events: none;
z-index: 10000;
max-width: 260px;
min-width: 180px;
}
/* 三角矢印(上向き表示用) */
[data-tooltip]::before {
content: "";
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%) scale(0.95);
border: 6px solid transparent;
border-top-color: var(--border);
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
pointer-events: none;
z-index: 10001;
}
[data-tooltip]:hover::after,
[data-tooltip]:hover::before,
[data-tooltip].tooltip-visible::after,
[data-tooltip].tooltip-visible::before {
opacity: 1;
visibility: visible;
transform: translateX(-50%) scale(1);
}
body.dark [data-tooltip]::after {
background: #2a2b31;
border-color: var(--border);
}
/* 下向き表示(上部に十分なスペースがない場合にJSで付与) */
[data-tooltip].tooltip-below::after {
bottom: auto;
top: calc(100% + 10px);
}
[data-tooltip].tooltip-below::before {
bottom: auto;
top: calc(100% + 4px);
border-top-color: transparent;
border-bottom-color: var(--border);
}
/* トップバー内の要素:ツールチップを左端基準で内側に開く */
#topbar [data-tooltip]::after {
left: 0; right: auto;
transform: translateX(0) scale(0.95);
max-width: 220px; min-width: 160px;
}
#topbar [data-tooltip]::before {
left: 16px; right: auto;
transform: translateX(0) scale(0.95);
}
#topbar [data-tooltip]:hover::after,
#topbar [data-tooltip]:hover::before,
#topbar [data-tooltip].tooltip-visible::after,
#topbar [data-tooltip].tooltip-visible::before {
transform: translateX(0) scale(1);
}
/* サイドバーフッターの右側ボタン(テーマ):ツールチップを右端基準で内側に開く */
#sidebar-footer #darkModeBtn::after {
left: auto; right: 0;
transform: translateX(0) scale(0.95);
max-width: 220px; min-width: 160px;
}
#sidebar-footer #darkModeBtn::before {
left: auto; right: 16px;
transform: translateX(0) scale(0.95);
}
#sidebar-footer #darkModeBtn:hover::after,
#sidebar-footer #darkModeBtn:hover::before,
#sidebar-footer #darkModeBtn.tooltip-visible::after,
#sidebar-footer #darkModeBtn.tooltip-visible::before {
transform: translateX(0) scale(1);
}
/* サイドバーフッターの左側ボタン(全履歴削除):ツールチップを左端基準で内側に開く */
#sidebar-footer #clearAllBtn::after {
left: 0; right: auto;
transform: translateX(0) scale(0.95);
max-width: 220px; min-width: 160px;
}
#sidebar-footer #clearAllBtn::before {
left: 16px; right: auto;
transform: translateX(0) scale(0.95);
}
#sidebar-footer #clearAllBtn:hover::after,
#sidebar-footer #clearAllBtn:hover::before,
#sidebar-footer #clearAllBtn.tooltip-visible::after,
#sidebar-footer #clearAllBtn.tooltip-visible::before {
transform: translateX(0) scale(1);
}
/* ============================================================
入力エリア(画面下部)
完成形の構造:#inputRow が「カード型」の容器になり、
その中にテキストエリアと送信ボタンが入っています。
============================================================ */
#inputArea {
border-top: 1px solid var(--border);
padding: 12px 16px 16px;
background: var(--bg);
}
#inputRow {
max-width: 860px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-input); /* カードの背景色(白背景で明確に識別) */
border: 1px solid var(--border); /* カードの境界線 */
border-radius: 14px;
padding: 8px 10px;
box-shadow: 0 2px 8px rgba(0,0,0,.08); /* 軽い影でカードを浮き上がらせる */
}
/* フォーカス時の視覚効果(入力欄を選択中にカードを強調) */
#inputRow:focus-within {
border-color: var(--accent);
box-shadow: 0 2px 12px rgba(74, 93, 216, .15); /* アクセント色の影 */
}
/* メッセージ入力欄(カードの中に透明で配置する) */
#messageInput {
flex: 1;
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--text);
font-size: 16.5px;
line-height: 1.5;
max-height: 200px;
font-family: inherit;
}
/* プレースホルダー(入力前の薄い案内文)の色調整 */
#messageInput::placeholder {
color: #8a8a8a;
}
/* 送信ボタン・停止ボタンの共通デザイン */
#sendBtn, #stopBtn {
min-width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
transition: background .15s;
}
#sendBtn { background: var(--accent); color: #fff; }
#sendBtn:hover:not(:disabled) { background: var(--accent-hover); }
#sendBtn:disabled { opacity: .4; cursor: not-allowed; }
#stopBtn { background: var(--danger); color: #fff; }
#stopBtn:hover { filter: brightness(1.1); }
/* AI免責表示(入力欄の下の注意書き) */
#aiDisclaimer {
text-align: center;
font-size: 13px;
color: var(--text-sub);
margin-top: 6px;
}
/* ============================================================
応答計測結果の表示(回答の最後に出る小さな情報)
============================================================ */
.response-metrics {
max-width: 860px;
margin: 4px auto 0;
padding: 0 4px;
font-size: 13.5px;
color: var(--text-sub);
opacity: 0.8;
text-align: right;
font-family: monospace;
}
/* ============================================================
長い質問の折りたたみ(「全文を表示」)
============================================================ */
/* 折りたたみ状態:高さを制限して続きを隠す */
.user-content-collapsed {
max-height: 120px;
overflow: hidden;
position: relative;
}
/* 折りたたみ時の下端のグラデーション(続きがあることを示す演出) */
.user-content-collapsed::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(transparent, var(--bg-user-msg));
pointer-events: none;
}
/* 「全文を表示 / 折りたたむ」ボタン */
.user-toggle-btn {
display: inline-block;
margin-top: 4px;
font-size: 12px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
padding: 6px 16px;
border: 1px solid var(--accent);
border-radius: 6px;
transition: background .15s;
}
.user-toggle-btn:hover {
background: rgba(91,108,255,.1);
}
/* ボタンを中央に配置するためのコンテナ */
.user-collapse-container {
margin-top: 4px;
text-align: center;
}
/* ============================================================
全画面ドラッグ&ドロップオーバーレイ
============================================================ */
#dropOverlay {
position: fixed; /* 画面全体に固定 */
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(91, 108, 255, 0.08);
border: 3px dashed var(--accent); /* アクセント色の点線で囲む */
z-index: 9999; /* すべての要素の上に表示 */
display: none; /* 初期状態は非表示 */
align-items: center;
justify-content: center;
pointer-events: none; /* オーバーレイ自体はクリックを透過 */
backdrop-filter: blur(2px); /* 背景を少しぼかす */
}
#dropOverlay.visible {
display: flex; /* visibleクラスが付いたら表示 */
}
.drop-message {
background: var(--bg);
padding: 24px 48px;
border-radius: 16px;
box-shadow: var(--shadow);
border: 2px solid var(--accent);
text-align: center;
color: var(--text);
font-size: 16px;
font-weight: 600;
}
.drop-message small {
display: block;
margin-top: 8px;
font-size: 12px;
font-weight: normal;
color: var(--text-sub);
}
/* ============================================================
添付プレビューエリア(選択したファイルの一覧表示)
============================================================ */
#attachmentPreviewArea {
max-width: 860px;
margin: 0 auto 10px;
display: flex;
flex-wrap: wrap; /* ファイルが多い時は折り返す */
gap: 10px;
}
/* 添付ファイル1件分(チップ) */
.attach-chip {
position: relative;
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
background: var(--bg-sidebar);
display: flex;
align-items: center;
gap: 8px;
}
/* 画像のサムネイル(小さなプレビュー) */
.attach-chip img {
width: 40px;
height: 40px;
object-fit: cover; /* 縦横比を保って切り抜き */
border-radius: 5px;
}
/* 添付ファイルの削除ボタン(✕) */
.attach-remove {
color: var(--text-sub);
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
}
.attach-remove:hover {
color: var(--danger);
background: rgba(229,72,77,.12);
}
/* ============================================================
添付ボタン(画像・PDF・テキスト・フォルダ)
============================================================ */
.attach-btn {
font-size: 18px;
color: var(--text-sub);
padding: 6px 8px;
border-radius: 8px;
}
.attach-btn:hover {
color: var(--text);
background: rgba(127,127,127,.14);
}
/* ドラッグ&ドロップ中の入力エリアの強調表示 */
#inputRow.dragging {
outline: 2px dashed var(--accent);
}
/* ============================================================
添付メタデータ表示(会話履歴の「どのファイルを添付したか」の表示)
※ステップ④:差分⑫-5 の renderMessage で使用します
============================================================ */
.msg-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.msg-attachment {
font-size: 12px;
background: rgba(127,127,127,.12);
padding: 4px 10px;
border-radius: 6px;
color: var(--text-sub);
}
/* ============================================================
レスポンシブ(モバイル対応:画面幅768px以下の時適用)
============================================================ */
@media (max-width: 768px) {
/* サイドバーは画面全体に重ねて表示(初期は画面外に隠す) */
#sidebar {
position: fixed;
z-index: 100;
height: 100%;
margin-left: -260px;
}
#sidebar.open { margin-left: 0; } /* openクラスでスライド表示 */
.message { max-width: 100%; }
/* モバイル時の設定パネル調整(1カラム表示) */
#settingsPanel {
grid-template-columns: 1fr;
max-height: calc(100vh - 50px);
padding: 12px;
}
}
/* スマホ横画面(高さが低い場合)の設定パネル調整 */
@media (max-height: 500px) and (orientation: landscape) {
#settingsPanel {
max-height: calc(100vh - 45px);
padding: 8px;
gap: 8px;
}
.setting-field label { font-size: 11px; }
.setting-field input,
.setting-field select,
.setting-field textarea {
padding: 6px 8px;
font-size: 11px;
}
}
</style>
</head>
<body>
<!-- ==================================================
① サイドバー(画面左側)
新しいチャット・会話履歴の一覧・各種操作ボタンを配置
================================================== -->
<aside id="sidebar">
<div id="sidebar-header">
<button id="newChatBtn">+ 新しいチャット</button>
</div>
<!-- 会話履歴の一覧(JavaScriptが中身を自動生成します) -->
<div id="conversationList"></div>
<!-- サイドバー下部の操作ボタン群
data-tooltip="..." がカスタムツールチップ(ホバーで説明ポップ)。
完成形では「📤 出力」「📥 読込」もありますが、
これらはステップ⑤で追加します。 -->
<div id="sidebar-footer">
<button id="clearAllBtn" data-tooltip="すべての会話履歴を削除します
この操作は元に戻せません">全履歴削除</button>
<button id="darkModeBtn" data-tooltip="ライトモード / ダークモードを切り替えます">🌙 テーマ</button>
</div>
</aside>
<!-- ==================================================
② メイン領域(画面右側)
トップバー・設定パネル・チャット表示・入力エリアを配置
================================================== -->
<div id="main">
<!-- トップバー(メイン領域の上部) -->
<div id="topbar">
<button id="toggleSidebarBtn" data-tooltip="サイドバーの表示/非表示を切り替えます">☰</button>
<div id="topbar-title">カスタムAIチャット</div>
<button id="settingsToggleBtn">⚙ 設定</button>
</div>
<!-- 設定パネル(トップバーの下に展開されるエリア)
初期状態は hidden(非表示)。
ステップ①ではAPIキー項目のみでしたが、
ステップ②で「モデル名」「reasoning_effort」などの
設定項目を追加しました。 -->
<div id="settingsPanel" class="hidden">
<div class="setting-field">
<label for="apiKeyInput">APIキー(画面更新でリセット)</label>
<input type="password" id="apiKeyInput" placeholder="Runpod APIキーを入力" autocomplete="off">
<div class="setting-note" style="margin-top: 6px; font-size: 12px; color: var(--text-sub); line-height: 1.5;">
<strong style="color: #e74c3c;">【API Keyの設定手順】</strong><br>
RunpodでAPIキーを発行する手順をまとめておきました。<a href="https://child-programmer.com/rp-api/chat-k/setting/" target="_blank"><b>こちら</b></a>のページを参照ください。<br>
<a href="https://child-programmer.com/rp-api/" target="_blank"><b>Runpod APIの使い方入門講座(一覧)</b></a>
</div>
</div>
<!-- ▼▼ ステップ②で追加した設定項目 ▼▼ -->
<!-- モデル名の選択
3つのモデルから選べます。選択に応じて、
「思考モード」「reasoning_effort」の表示が切り替わります
(JavaScriptの updateModelSpecificSettingsUI が担当)。 -->
<div class="setting-field">
<label for="modelInput">モデル名</label>
<select id="modelInput">
<option value="kimi-k3" selected>kimi-k3</option>
<option value="kimi-k2.7-code">kimi-k2.7-code</option>
<option value="kimi-k2.6">kimi-k2.6</option>
</select>
<div class="setting-hint">kimi-k3:最高性能を狙う万能型</div>
<div class="setting-hint">kimi-k2.7-code:本格的な開発に特化</div>
<div class="setting-hint">kimi-k2.6:コード以外も対応の万能型</div>
</div>
<!-- 思考モード(kimi-k2.7-code / kimi-k2.6 用)
初期状態は hidden(非表示)。
kimi-k2.7-code または kimi-k2.6 が選ばれた時だけ表示されます。
kimi-k3 は常時思考モデルのため、この項目は表示しません。 -->
<div class="setting-field hidden" id="thinkingModeField">
<label for="thinkingModeSelect" id="thinkingModeFieldLabel">思考モード</label>
<select id="thinkingModeSelect">
<option value="disabled" selected>OFF(通常回答)</option>
<option value="enabled">ON(思考モード)</option>
</select>
<div class="setting-hint" id="thinkingModeFieldHint">モデルに応じて思考モードの設定が切り替わります。</div>
</div>
<!-- reasoning_effort(Kimi K3 専用の推論レベル)
kimi-k3 選択時だけ表示されます。 -->
<div class="setting-field" id="reasoningEffortField">
<label for="reasoningEffortSelect">reasoning_effort - (Kimi K3)</label>
<select id="reasoningEffortSelect">
<option value="low" selected>low</option>
<option value="high">high</option>
<option value="max">max</option>
</select>
<div class="setting-hint">推論強度(思考の深さ)を選択</div>
</div>
<!-- max_tokens(一度に出力できる文章の上限) -->
<div class="setting-field">
<label for="maxTokensInput">max_tokens</label>
<input type="number" id="maxTokensInput" value="8192" min="1">
<div class="setting-hint">一度に出力できる文章の上限を指定</div>
<div class="setting-hint">目安(日本語):1文字≒1トークン</div>
<div class="setting-hint">・8192:約8000〜16000文字</div>
<div class="setting-hint">思考過程+回答本文の文字数</div>
<div class="setting-hint">マークダウン記号(#、*、`)も含む</div>
</div>
<!-- 画面更新間隔(ストリーミング中の描画間隔) -->
<div class="setting-field">
<label for="renderIntervalInput">画面更新間隔 (ms)</label>
<input type="number" id="renderIntervalInput" value="80" min="50" max="100" step="10">
<div class="setting-hint">50〜100ms の範囲で指定</div>
<div class="setting-hint">小さいほど滑らか</div>
</div>
<!-- ▼▼ ステップ④で追加したアップロード上限の設定項目 ▼▼ -->
<!-- 画像アップロード上限 -->
<div class="setting-field">
<label for="maxImageSizeInput">🖼 画像アップロード上限 (KB)</label>
<input type="number" id="maxImageSizeInput" value="10240" min="100" max="51200" step="100">
<div class="setting-hint" id="imageSizeHint">10.0MB まで</div>
</div>
<!-- PDFアップロード上限 -->
<div class="setting-field">
<label for="maxPdfSizeInput">📄 PDFアップロード上限 (KB)</label>
<input type="number" id="maxPdfSizeInput" value="20480" min="100" max="51200" step="100">
<div class="setting-hint" id="pdfSizeHint">20.0MB まで</div>
<div class="setting-hint">1ファイルの先頭約20000文字まで対応</div>
</div>
<!-- PDFページ数上限 -->
<div class="setting-field">
<label for="maxPdfPagesInput">PDFページ数上限</label>
<input type="number" id="maxPdfPagesInput" value="10" min="1" max="50" step="1">
<div class="setting-hint" id="pdfPagesHint">10ページ まで</div>
</div>
<!-- テキストファイル上限 -->
<div class="setting-field">
<label for="maxTextSizeInput">📝 テキストファイル上限 (KB)</label>
<input type="number" id="maxTextSizeInput" value="5120" min="100" max="10240" step="100">
<div class="setting-hint" id="textSizeHint">5.0MB まで</div>
</div>
<!-- APIタイムアウト(応答を待つ上限時間) -->
<div class="setting-field">
<label for="requestTimeoutInput">APIタイムアウト (秒)</label>
<input type="number" id="requestTimeoutInput" value="360" min="30" max="900" step="10">
<div class="setting-hint" id="timeoutHint">6分 まで</div>
<div class="setting-hint">処理が中断される応答時間の上限を指定</div>
</div>
<!-- カスタムシステムプロンプトモードの切り替え
チェックボックスをONにすると、下記の編集エリアが表示されます。
style="grid-column: 1 / -1;" で、この項目だけ横幅いっぱいに広げています。 -->
<div class="setting-field" style="grid-column: 1 / -1; margin: 20px 0;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="customPromptModeToggle" style="width: auto;">
<span style="font-size: 14px; font-weight: 600; color: var(--text);">カスタムシステムプロンプトモードを有効化</span>
</label>
<div class="setting-hint" style="margin-top: 8px;">有効にすると、下記のテキストエリアでシステムプロンプトを編集できます。無効時はプログラム標準のプロンプトを使用します。</div>
</div>
<!-- カスタムシステムプロンプトの編集エリア
初期状態は hidden(非表示)。
チェックボックスがONになった時だけ表示されます
(JavaScriptの updateCustomPromptVisibility が担当)。 -->
<div class="setting-field hidden" id="customPromptField" style="grid-column: 1 / -1;">
<label for="customSystemPrompt">カスタムシステムプロンプト</label>
<textarea id="customSystemPrompt" rows="12" style="width: 100%; font-family: monospace; font-size: 14px; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); resize: vertical;"></textarea>
<div style="display: flex; gap: 8px; margin-top: 8px;">
<button type="button" id="resetPromptBtn" style="padding: 6px 12px; font-size: 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; color: var(--text-sub); cursor: pointer;">デフォルトに戻す</button>
<button type="button" id="copyPromptBtn" style="padding: 6px 12px; font-size: 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; color: var(--text-sub); cursor: pointer;">コピー</button>
</div>
</div>
</div>
<!-- エラーメッセージ表示エリア(エラー発生時のみ表示) -->
<div id="errorArea"></div>
<!-- 会話表示エリア(メッセージ一覧)
※ステップ①ではウェルカム画面は未実装のため、
新規チャット時は「何も表示されない状態」から始まります。
(ウェルカム画面はステップ⑦で追加します)
※質問ナビゲーションタイムラインはステップ⑥で追加します -->
<div id="chatContainer"></div>
<!-- 入力エリア(画面下部)
添付ボタン(🖼📄📝📁)をステップ④で追加。 -->
<div id="inputArea">
<!-- 添付プレビューエリア
選択したファイル(画像・PDF・テキスト)の一覧が、ここに表示されます。
初期状態は空っぽで、JavaScript(renderAttachmentPreview)が中身を生成します。 -->
<div id="attachmentPreviewArea"></div>
<div id="inputRow">
<!-- 添付ボタン4つ
左から順に「画像・PDF・テキストファイル・フォルダ」の添付ボタンです。
それぞれのボタンを押すと、対応する「隠しファイル入力」が開きます。 -->
<button class="attach-btn" id="imageAttachBtn" data-tooltip="🖼 画像を添付
対応形式: JPEG / PNG / GIF / WebP
サイズ: 1枚〜複数可。
(設定で上限指定)
貼り付け・ドラッグ&ドロップにも対応">🖼</button>
<button class="attach-btn" id="pdfAttachBtn" data-tooltip="📄 PDFを添付
サイズ: 20MBまで
ページ数: 先頭から順番に処理。
(設定で上限指定)
テキスト抽出またはページ画像として送信">📄</button>
<button class="attach-btn" id="textAttachBtn" data-tooltip="📝 テキストファイルを添付
対応形式: .txt .py .js .html .css .json .md など
サイズ: 1ファイル〜複数可。
(設定で上限指定)">📝</button>
<button class="attach-btn" id="folderAttachBtn" data-tooltip="📁 フォルダを添付
フォルダ内のテキストファイルを読み込みます
(画像・PDFも含めることができます)
※フォルダのドロップは非対応のため
このボタンから選択してください">📁</button>
<textarea id="messageInput" placeholder="メッセージを入力(Enter:送信 / Shift+Enter:改行)" rows="1"></textarea>
<button id="sendBtn" data-tooltip="送信(Enter でも送信できます)">➤</button>
<button id="stopBtn" class="hidden" data-tooltip="生成を停止します
それまでに受信した内容は保持されます">■</button>
</div>
<div id="aiDisclaimer">AIは不正確な情報を含む場合があります。重要な内容は必ずご自身でも確認してください。</div>
<!-- 隠しファイル入力(4種類)
添付ボタンが押された時に、JavaScriptがこれらをクリックして
ファイル選択ダイアログを開きます。画面には表示されません(class="hidden")。 -->
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" multiple class="hidden">
<input type="file" id="pdfFileInput" accept="application/pdf" class="hidden">
<input type="file" id="textFileInput" accept=".txt,.py,.js,.jsx,.ts,.tsx,.html,.css,.json,.md,.xml,.yaml,.yml,.c,.cpp,.h,.hpp,.java,.cs,.php,.rb,.go,.rs,.sql,.sh,.bash,.bat,.ini,.conf,.cfg,.log,.vue,.svelte,.swift,.kt,.scala,.pl,.pm,.r,.m,.mm,.gradle,.properties,.env,.gitignore,.dockerfile,.makefile,.cmake,.toml,.lock,.csv,.tsv,.tex,.rst,.adoc,.asciidoc" multiple class="hidden">
<input type="file" id="folderFileInput" webkitdirectory directory multiple class="hidden">
</div>
<!-- 全画面ドラッグオーバーレイ
ファイルを画面にドラッグしている間だけ、画面全体に表示される案内です。
初期状態は非表示(display: none)で、JavaScriptがドラッグを検知した時に
visible クラスを付けて表示します。 -->
<div id="dropOverlay">
<div class="drop-message">
📂 ファイル・フォルダをドロップ
<small>画像・PDF・テキストファイル(.txt, .py, .js, .html 等)・フォルダ</small>
</div>
</div>
</div>
<script>
/* ==================================================
JavaScript:アプリの「動き」を定義する部分
家でいうところの「電気配線・水道管」。
このアプリの心臓部です。
構造・関数名は「カスタムAIチャットアプリ」(ダウンロード版)と同一です。
================================================== */
/* ============================================================
【1】定数(アプリの設定値)
------------------------------------------------------------
※APIキーはここには書きません(UIから入力・メモリ上のみ保持)
============================================================ */
/* RunpodのAPI接続先URL(完成形と同じエンドポイント) */
const API_ENDPOINT = "https://api.runpod.ai/v2/moonshot-kimi/openai/v1/chat/completions";
/* localStorage(ブラウザ内の保存領域)に使う「保存名」 */
const STORAGE_KEY = "kimi-k3-chat-conversations-v1"; // 会話履歴
const UI_SETTINGS_KEY = "kimi-k3-chat-ui-settings-v1"; // 画面設定(テーマなど)
/* --- アプリの動作パラメータの「初期値」 ---
ステップ①ではコード上の固定値として使っていましたが、
ステップ②で設定パネルから変更可能になったため、
これらの値は「設定パネルの初期表示値」としての役割に変わりました。
(実際の動作では、設定パネルの値が優先されます) */
const FIXED_MODEL_NAME = "kimi-k3"; // モデルの初期値
const FIXED_REASONING_EFFORT = "low"; // 推論レベルの初期値
const FIXED_MAX_TOKENS = 8192; // max_tokens の初期値
const FIXED_RENDER_INTERVAL = 80; // 画面更新間隔の初期値(ミリ秒)
let REQUEST_TIMEOUT_MS = 360000; // APIタイムアウト(360秒=6分)
/* --- アップロード制限の設定値(ステップ④で追加) ---
画像・PDF・テキストファイルの「サイズ上限」や「対応形式」を
決める値です。これらは「変数」として宣言しており、
設定パネルで変更された値が、後の差分で追加する
applyUploadSettings() によって反映されます。 */
/* 画像の容量の上限(デフォルト: 10MB) */
let MAX_IMAGE_SIZE = 10 * 1024 * 1024;
/* PDFの容量の上限(デフォルト: 20MB) */
let MAX_PDF_SIZE = 20 * 1024 * 1024;
/* PDFのページ数の上限(デフォルト: 10ページ) */
let MAX_PDF_PAGES = 10;
/* 対応する画像の形式(これ以外の形式は添付できません) */
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
/* 対応するテキストファイルの拡張子(これ以外は添付できません) */
const TEXT_FILE_EXTENSIONS = [
'.txt', '.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.htm', '.css', '.scss', '.sass', '.less',
'.json', '.md', '.markdown', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf',
'.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.java', '.cs', '.php', '.rb', '.go', '.rs',
'.sql', '.sh', '.bash', '.zsh', '.fish', '.bat', '.cmd', '.ps1', '.log',
'.vue', '.svelte', '.swift', '.kt', '.kts', '.scala', '.pl', '.pm', '.r', '.m', '.mm',
'.gradle', '.properties', '.env', '.gitignore', '.dockerignore', '.editorconfig',
'.dockerfile', '.makefile', '.cmake', '.lock', '.csv', '.tsv', '.tex', '.rst'
];
/* テキストファイル1件あたりの容量の上限(デフォルト: 5MB) */
let MAX_TEXT_FILE_SIZE = 5 * 1024 * 1024;
/* テキストファイル全体の合計容量の上限(デフォルト: 20MB) */
let MAX_TEXT_TOTAL_SIZE = 20 * 1024 * 1024;
/* ------------------------------------------------------------
システムプロンプト(AIへの指示書・性格設定)
内容は完成形と同一です(ステップ③で画面から変更可能に)。
テンプレートリテラル(` `)で複数行をそのまま書けます。
------------------------------------------------------------ */
const SYSTEM_PROMPT = `あなたは、日本語で回答するプログラミング支援専門のAIアシスタントです。
主な対象分野:
JavaScript / HTML / CSS / TypeScript / Python / Java / C# / React / Node.js / API連携 / Webアプリケーション / データベース / Linux / Docker / クラウド / AIアプリケーション / デバッグ / コードレビュー
回答ルール:
1. 最初に結論を簡潔に述べる
2. 必要に応じて、原因、手順、実装例の順に説明する
3. コードは言語名付きMarkdownコードブロックで表示する
4. コード本文と解説文を分離する
5. コードはコピーして利用しやすい形にする
6. 重要な処理を省略しない
7. APIキー、パスワード、トークンなどの秘密情報を出力しない
8. 秘密情報は環境変数や安全な設定方法で管理するよう説明する
9. ユーザーが提示したコードを修正する場合は、変更点を説明する
10. 必要に応じて修正後の完全なコードを提示する
11. エラー処理、入力検証、セキュリティにも配慮する
12. 仕様が不明な場合は断定せず、前提条件を明示する
13. 画像が添付された場合は、画像内のコード、エラー、UI、図表を確認して回答する
14. PDFが添付された場合は、提供されたテキストまたはページ画像の範囲内で回答する
15. 判読できない情報は推測で断定しない
16. コード例は可能な限り実行可能な形で提示する
17. 複数ファイルが必要な場合は、ファイル名ごとにコードブロックを分ける
18. 回答は日本語で行う
19. 必要に応じて見出し、箇条書き、表を使用する
20. 確実な事実と推測を区別する
21. 回答本文(content)には、内部の詳細な思考過程や隠れた推論を記述しない
22. 思考過程はモデルの thinking 機能が reasoning_content として出力するものであり、回答本文に思考内容を書き写したり、その存在に言及したりしない
23. 「分析中」「回答を構成中」などの進捗状況はアプリ側が表示するため、回答本文には含めない
24. 回答本文に進捗メッセージを混在させない
25. ウェブ検索や外部サイトの検索を実行したことにしてはいけない
26. 外部情報を確認していない場合は、確認していないことを明示する
27. ユーザーが提供したテキスト、画像、PDF、コードを主な入力情報として扱う
28. 画像やPDFに書かれていない内容を断定しない
29. 回答の最後に、必要であれば注意点や制限事項を記載する
30. 新しいコードを追加する場合は、既存コードのどの位置に挿入するかを具体的に説明する
31. 追加対象が関数の場合、その関数が呼び出される既存コード(使用箇所)の位置も併記する
コードを生成する場合:Markdownコードブロックを使用し、適切な言語名を指定し、コードと説明文を分離し、APIキーなどの秘密情報を直接記述せず、エラー処理を含め、コピーして利用しやすい形式にする
画像やPDFについて回答する場合:読み取れる範囲だけを根拠にし、不鮮明な部分は判読不能であると説明し、推測した内容は推測であると明示する`;
/* カスタムプロンプトのデフォルト値(ステップ③で使用) */
const DEFAULT_SYSTEM_PROMPT = SYSTEM_PROMPT;
/* ============================================================
【2】状態管理(アプリが覚えておくデータ)
------------------------------------------------------------
アプリが動いている間、メモリ上で保持するデータです。
添付関連(画像・PDF・テキスト)はステップ④で追加します。
============================================================ */
let conversations = []; // 全会話 [{id, title, messages:[{role, content}]}]
let activeConversationId = null; // 現在の会話ID
let apiKey = ""; // APIキー(メモリ上のみ保持。保存しない)
let abortController = null; // ストリーム中断用
let isGenerating = false; // 生成中フラグ
let fullResponseText = ""; // 受信回答バッファ
let renderScheduled = false; // 部分描画スケジュール中フラグ
let currentStreamEl = null; // ストリーミング中の回答DOM要素
let currentProgressPanel = null; // 現在の処理状況パネル要素
let currentProgressState = { logs: [], lastStatus: "" }; // 処理状況の状態
let autoScrollEnabled = true; // 自動スクロール有効フラグ
let hasRetriedNonStream = false; // 非ストリーム再試行フラグ
/* 応答計測用 */
let responseStartTime = 0; // 計測開始時刻
let lastReasoningLength = 0; // 最後の応答の思考過程文字数
let lastContentLength = 0; // 最後の応答の本文文字数
let lastResponseTime = 0; // 最後の応答の所要時間(ミリ秒)
let fullReasoningText = ""; // 思考過程バッファ
let currentReasoningEl = null; // 思考過程表示DOM
/* --- 添付ファイルの状態管理(ステップ④で追加) ---
ユーザーが添付したファイルを、送信までの間
メモリ上で覚えておくための変数です。 */
let attachedImages = []; // 添付画像 [{name, size, type, dataUrl}]
let attachedPdf = null; // 添付PDF {name, size, pageRange, text, pageImages}
let attachedTextFiles = []; // 添付テキストファイル [{name, size, type, content, path}]
/* ============================================================
【3】DOM参照(画面の部品を変数に入れておく)
------------------------------------------------------------
$ は「document.querySelector」の短縮形。
CSSセレクタ("#id名" など)で要素を1つ取得します。
============================================================ */
const $ = (sel) => document.querySelector(sel);
const sidebar = $("#sidebar");
const conversationList = $("#conversationList");
const chatContainer = $("#chatContainer");
const messageInput = $("#messageInput");
const sendBtn = $("#sendBtn");
const stopBtn = $("#stopBtn");
const errorArea = $("#errorArea");
const apiKeyInput = $("#apiKeyInput");
const settingsPanel = $("#settingsPanel");
const topbarTitle = $("#topbar-title");
/* --- ステップ②で追加した設定項目のDOM参照 --- */
/* モデル名の選択欄 */
const modelInput = $("#modelInput");
/* reasoning_effort(Kimi K3専用)の項目「枠」と「選択欄」 */
const reasoningEffortField = $("#reasoningEffortField");
const reasoningEffortSelect = $("#reasoningEffortSelect");
/* 思考モードの項目「枠」「選択欄」「ラベル」「補足説明」 */
const thinkingModeField = $("#thinkingModeField");
const thinkingModeSelect = $("#thinkingModeSelect");
const thinkingModeFieldLabel = $("#thinkingModeFieldLabel");
const thinkingModeFieldHint = $("#thinkingModeFieldHint");
/* max_tokens の入力欄 */
const maxTokensInput = $("#maxTokensInput");
/* 画面更新間隔の入力欄 */
const renderIntervalInput = $("#renderIntervalInput");
/* APIタイムアウトの入力欄 */
const requestTimeoutInput = $("#requestTimeoutInput");
/* APIタイムアウトの補足説明(ヒント) */
const timeoutHint = $("#timeoutHint");
/* --- ステップ③で追加したカスタムプロンプト関連のDOM参照 --- */
/* カスタムプロンプトモードの切り替え(チェックボックス) */
const customPromptModeToggle = $("#customPromptModeToggle");
/* カスタムプロンプトの編集エリア「枠」 */
const customPromptField = $("#customPromptField");
/* カスタムプロンプトのテキストエリア */
const customSystemPrompt = $("#customSystemPrompt");
/* 「デフォルトに戻す」ボタン */
const resetPromptBtn = $("#resetPromptBtn");
/* 「コピー」ボタン */
const copyPromptBtn = $("#copyPromptBtn");
/* --- ステップ④で追加した添付関連のDOM参照 --- */
/* 添付プレビューエリア(選択したファイルの一覧表示) */
const attachmentPreviewArea = $("#attachmentPreviewArea");
/* 入力エリアの「カード」(ドラッグ中の強調表示に使う) */
const inputRow = $("#inputRow");
/* 隠しファイル入力(添付ボタンが押された時に開く) */
const imageFileInput = $("#imageFileInput");
const pdfFileInput = $("#pdfFileInput");
const textFileInput = $("#textFileInput");
const folderFileInput = $("#folderFileInput");
/* ドロップオーバーレイ(ドラッグ&ドロップの案内表示) */
const dropOverlay = $("#dropOverlay");
/* --- アップロード上限の設定項目のDOM参照 --- */
const maxImageSizeInput = $("#maxImageSizeInput");
const maxPdfSizeInput = $("#maxPdfSizeInput");
const maxPdfPagesInput = $("#maxPdfPagesInput");
const maxTextSizeInput = $("#maxTextSizeInput");
/* ============================================================
【4】ユーティリティ(よく使う小さな便利関数)
============================================================ */
/* --- 一意なIDを作る(会話の識別番号として使う) --- */
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/* --- ファイルサイズを読みやすい形式で表示する ---
バイト数を受け取り、「○○ B」「○○ KB」「○○ MB」の
読みやすい文字列に変換して返します。
例:1024 → "1.0 KB"、1048576 → "1.0 MB"
(完成形の formatBytes と同じ仕様) */
function formatBytes(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
/* --- HTMLの特殊文字を「無害な文字」に変換する(XSS対策) ---
ユーザー入力をそのままHTMLに埋め込むと危険なため、
< > & などを「ただの文字」として表示される形に変換します。 */
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/* --- エラーメッセージを画面上部に表示する ---
APIキーなどの秘密情報が誤って表示されないよう除去します。 */
function showError(message) {
const safeMsg = String(message).replace(/Bearer\s+\S+/gi, "Bearer [非表示]");
errorArea.textContent = "⚠ " + safeMsg;
errorArea.classList.add("visible");
}
/* --- エラーメッセージを消す --- */
function clearError() {
errorArea.textContent = "";
errorArea.classList.remove("visible");
}
/* --- max_tokens の入力検証(バリデーション) ---
1以上の整数かどうかをチェックします。
不正な値の場合はアラートを表示して false を返します。 */
function validateMaxTokens(value) {
const n = Number(value);
if (!Number.isInteger(n) || n < 1) {
alert("最大トークン数は1以上の整数で入力してください");
return false;
}
return true;
}
/* ============================================================
【5】モデル関連(設定パネルの値を読む形に拡張)
------------------------------------------------------------
ステップ①では固定値を返していましたが、
ステップ②では設定パネルの値を読んで返します。
また、モデルに応じて設定項目の表示を切り替える
updateModelSpecificSettingsUI() を新たに追加します。
============================================================ */
/* --- 使用するモデル名を返す(設定パネルの選択値を読む) --- */
function getSelectedModel() {
return modelInput.value.trim() || "kimi-k3";
}
/* --- モデルの表示名を返す(ロールラベル・応答計測に使う) --- */
function getModelDisplayName() {
const names = {
"kimi-k3": "Kimi K3",
"kimi-k2.7-code": "Kimi K2.7 Code",
"kimi-k2.6": "Kimi K2.6"
};
return names[getSelectedModel()] || getSelectedModel();
}
/* --- 思考モードが有効かどうかを返す ---
モデルに応じて、思考モードの有効・無効を判定します。 */
function isThinkingEnabled() {
const model = getSelectedModel();
// Kimi K3 は「常時思考モデル」なので常に true
if (model === "kimi-k3") return true;
// Kimi K2.7 Code は本アプリでは思考モード固定ON
if (model === "kimi-k2.7-code") return true;
// Kimi K2.6 はユーザーがON/OFFを選択
return model === "kimi-k2.6" && thinkingModeSelect.value === "enabled";
}
/* --- 画面更新間隔(ミリ秒)を返す(設定パネルの値を読む) --- */
function getRenderInterval() {
let v = parseInt(renderIntervalInput.value, 10);
if (isNaN(v)) v = 80;
return Math.min(100, Math.max(50, v)); // 50〜100msに制限
}
/* --- モデルに応じて、設定項目の表示を切り替える ---
「思考モード」と「reasoning_effort」の表示・非表示を
モデルの選択に応じて切り替えます。 */
function updateModelSpecificSettingsUI() {
const model = getSelectedModel();
// Kimi K3:reasoning_effort のみ表示
if (model === "kimi-k3") {
reasoningEffortField.classList.remove("hidden");
reasoningEffortSelect.value = normalizeReasoningEffort(reasoningEffortSelect.value);
thinkingModeField.classList.add("hidden");
thinkingModeSelect.value = "enabled";
thinkingModeSelect.disabled = true;
thinkingModeFieldHint.textContent = "";
return;
}
// Kimi K2.7 Code:思考モード固定ON、reasoning_effortは使用しない
if (model === "kimi-k2.7-code") {
reasoningEffortField.classList.add("hidden");
thinkingModeField.classList.remove("hidden");
thinkingModeFieldLabel.textContent = "思考モード(kimi-k2.7-code)";
thinkingModeSelect.value = "enabled";
thinkingModeSelect.disabled = true;
thinkingModeFieldHint.textContent = "Kimi K2.7 Code は本アプリでは思考モードをONにして使用します。reasoning_effort は送信しません。";
return;
}
// Kimi K2.6:思考モードON/OFF、reasoning_effortは使用しない
if (model === "kimi-k2.6") {
reasoningEffortField.classList.add("hidden");
thinkingModeField.classList.remove("hidden");
thinkingModeFieldLabel.textContent = "思考モード(kimi-k2.6)";
thinkingModeSelect.disabled = false;
const savedK26ThinkingMode = ["disabled", "enabled"].includes(thinkingModeSelect.dataset.k26Value)
? thinkingModeSelect.dataset.k26Value
: "enabled";
thinkingModeSelect.value = savedK26ThinkingMode;
thinkingModeFieldHint.textContent = "ON/OFFを選択できます。reasoning_effort は送信しません。";
return;
}
// 未知のモデル:モデル固有設定を送信しない
reasoningEffortField.classList.add("hidden");
thinkingModeField.classList.add("hidden");
thinkingModeSelect.disabled = true;
}
/* --- reasoning_effort の値を正規化する ---
「low」「high」「max」以外の値が来た場合は「low」に丸めます。 */
function normalizeReasoningEffort(value) {
return ["low", "high", "max"].includes(value) ? value : "low";
}
/* ============================================================
【6】UI設定(テーマ+設定項目)の保存・読み込み
------------------------------------------------------------
テーマ(ダークモード)に加えて、ステップ②で追加した
設定項目(モデル名・reasoning_effort・max_tokens など)も
localStorage に保存・復元します。
============================================================ */
/* --- 現在の画面設定をlocalStorageに保存する ---
新しく追加した設定項目(モデル名・reasoning_effort・
max_tokens など)も、テーマと同じく保存します。 */
function saveUiSettings() {
try {
const settings = {
dark: document.body.classList.contains("dark"),
model: modelInput.value,
reasoningEffort: normalizeReasoningEffort(reasoningEffortSelect.value),
maxTokens: maxTokensInput.value,
renderInterval: renderIntervalInput.value,
thinkingMode: thinkingModeSelect.dataset.k26Value || thinkingModeSelect.value,
requestTimeout: requestTimeoutInput.value,
/* ステップ③で追加したカスタムプロンプト設定 */
customPromptMode: customPromptModeToggle.checked,
customPrompt: customSystemPrompt.value,
/* ステップ④で追加したアップロード上限の設定 */
maxImageSize: maxImageSizeInput.value,
maxPdfSize: maxPdfSizeInput.value,
maxTextSize: maxTextSizeInput.value,
maxPdfPages: maxPdfPagesInput.value
};
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));
/* 変更された設定値を、動作パラメータ(変数)に即座に反映する */
applyUploadSettings();
} catch (e) { /* localStorage不可時は無視 */ }
}
/* --- 保存された画面設定を読み込んで適用する(起動時に1回実行) ---
保存した設定項目を、設定パネルの各入力欄に復元します。 */
function loadUiSettings() {
try {
const raw = localStorage.getItem(UI_SETTINGS_KEY);
if (raw) {
const s = JSON.parse(raw);
if (s.dark) document.body.classList.add("dark");
if (s.model) {
if ([...modelInput.options].some(o => o.value === s.model)) {
modelInput.value = s.model;
}
}
if (s.reasoningEffort) reasoningEffortSelect.value = normalizeReasoningEffort(s.reasoningEffort);
if (s.maxTokens) maxTokensInput.value = s.maxTokens;
if (s.renderInterval) renderIntervalInput.value = s.renderInterval;
if (s.thinkingMode) {
thinkingModeSelect.value = ["disabled", "enabled"].includes(s.thinkingMode)
? s.thinkingMode
: "enabled";
thinkingModeSelect.dataset.k26Value = thinkingModeSelect.value;
}
if (s.requestTimeout) requestTimeoutInput.value = s.requestTimeout;
/* ステップ③で追加したカスタムプロンプト設定の復元 */
if (s.customPromptMode !== undefined) {
customPromptModeToggle.checked = s.customPromptMode;
if (s.customPromptMode) {
customSystemPrompt.value = s.customPrompt || DEFAULT_SYSTEM_PROMPT;
}
}
/* ステップ④で追加したアップロード上限の設定の復元 */
if (s.maxImageSize) maxImageSizeInput.value = s.maxImageSize;
if (s.maxPdfSize) maxPdfSizeInput.value = s.maxPdfSize;
if (s.maxTextSize) maxTextSizeInput.value = s.maxTextSize;
if (s.maxPdfPages) maxPdfPagesInput.value = s.maxPdfPages;
}
} catch (e) { /* 無視 */ }
/* 起動時に、モデルに応じた設定項目の表示を更新する */
updateModelSpecificSettingsUI();
/* 保存済みの設定値を、動作パラメータ(変数)に反映する */
applyUploadSettings();
/* 補足表示(ヒント)を現在の設定値に合わせて更新する */
updateSettingHints();
/* カスタムプロンプト表示状態を更新する */
updateCustomPromptVisibility();
}
/* --- アップロード設定を、アプリの動作パラメータ(変数)に反映する ---
設定パネルの値を読み取り、以下の変数をまとめて更新します。
・画像アップロード上限(MAX_IMAGE_SIZE)
・PDFアップロード上限(MAX_PDF_SIZE)
・テキストファイル上限(MAX_TEXT_FILE_SIZE)
・テキストファイル合計上限(MAX_TEXT_TOTAL_SIZE)
・APIタイムアウト(REQUEST_TIMEOUT_MS)
・PDFページ数上限(MAX_PDF_PAGES)
(完成形の applyUploadSettings と同じ仕様) */
function applyUploadSettings() {
/* 画像の容量の上限(設定値のKBをバイト数に変換) */
MAX_IMAGE_SIZE = (parseInt(maxImageSizeInput.value, 10) || 10240) * 1024;
/* PDFの容量の上限(設定値のKBをバイト数に変換) */
MAX_PDF_SIZE = (parseInt(maxPdfSizeInput.value, 10) || 20480) * 1024;
/* テキストファイル1件あたりの容量の上限(設定値のKBをバイト数に変換) */
MAX_TEXT_FILE_SIZE = (parseInt(maxTextSizeInput.value, 10) || 5120) * 1024;
/* テキストファイル全体の合計容量の上限(1件あたりの4倍に設定) */
MAX_TEXT_TOTAL_SIZE = MAX_TEXT_FILE_SIZE * 4;
/* APIタイムアウト(設定値の秒をミリ秒に変換) */
REQUEST_TIMEOUT_MS = (parseInt(requestTimeoutInput.value, 10) || 360) * 1000;
/* PDFのページ数の上限(設定値をそのまま使用) */
MAX_PDF_PAGES = parseInt(maxPdfPagesInput.value, 10) || 10;
}
/* --- 設定パネルの補足表示(ヒント)を、現在の設定値に合わせて更新する ---
画像・PDF・PDFページ数・テキストファイル・APIタイムアウトの
ヒント表示を、それぞれ現在の設定値に合わせて書き換えます。
(完成形の updateSettingHints と同じ仕様) */
function updateSettingHints() {
/* 画像アップロード上限のヒント:「10240KB」→「10.0MB まで」のようにMBに換算して表示 */
const imageKB = parseInt(maxImageSizeInput.value, 10) || 10240;
const imageMB = (imageKB / 1024).toFixed(1);
document.getElementById("imageSizeHint").textContent =
imageMB >= 1 ? `${imageMB}MB まで` : `${imageKB}KB まで`;
/* PDFアップロード上限のヒント:「20480KB」→「20.0MB まで」のようにMBに換算して表示 */
const pdfKB = parseInt(maxPdfSizeInput.value, 10) || 20480;
const pdfMB = (pdfKB / 1024).toFixed(1);
document.getElementById("pdfSizeHint").textContent =
pdfMB >= 1 ? `${pdfMB}MB まで` : `${pdfKB}KB まで`;
/* PDFページ数上限のヒント:「10」→「10ページ まで」のように表示 */
const pdfPages = parseInt(maxPdfPagesInput.value, 10) || 10;
document.getElementById("pdfPagesHint").textContent = `${pdfPages}ページ まで`;
/* テキストファイル上限のヒント:「5120KB」→「5.0MB まで」のようにMBに換算して表示 */
const textKB = parseInt(maxTextSizeInput.value, 10) || 5120;
const textMB = (textKB / 1024).toFixed(1);
document.getElementById("textSizeHint").textContent =
textMB >= 1 ? `${textMB}MB まで` : `${textKB}KB まで`;
/* APIタイムアウトのヒント:「360秒」→「6.0分 まで」のように分に換算して表示 */
const timeoutSec = parseInt(requestTimeoutInput.value, 10) || 360;
const timeoutMin = (timeoutSec / 60).toFixed(1);
document.getElementById("timeoutHint").textContent =
timeoutMin >= 1 ? `${timeoutMin}分 まで` : `${timeoutSec}秒 まで`;
}
/* --- カスタムプロンプトの表示/非表示を切り替える ---
チェックボックスがONの時だけ、編集エリアを表示します。
(完成形の updateCustomPromptVisibility と同じ仕様) */
function updateCustomPromptVisibility() {
if (customPromptModeToggle.checked) {
customPromptField.classList.remove("hidden");
/* 初回有効化時、エリアが空の場合はデフォルトプロンプトを挿入 */
if (!customSystemPrompt.value.trim()) {
customSystemPrompt.value = DEFAULT_SYSTEM_PROMPT;
}
} else {
customPromptField.classList.add("hidden");
}
}
/* --- テーマボタンの表示を現在の状態に合わせて更新する --- */
function updateDarkModeBtn() {
const isDark = document.body.classList.contains("dark");
const btn = document.getElementById("darkModeBtn");
if (btn) {
btn.textContent = isDark ? "☀️ テーマ" : "🌙 テーマ";
}
}
/* --- 現在使用すべきシステムプロンプトを返す ---
カスタムプロンプトモードが有効なら編集内容を、
無効ならデフォルト値を返します。
(完成形の getCurrentSystemPrompt と同じ仕様) */
function getCurrentSystemPrompt() {
if (customPromptModeToggle.checked) {
const custom = customSystemPrompt.value.trim();
/* 空の場合はデフォルトを返す(空のプロンプトは送信しない) */
return custom || DEFAULT_SYSTEM_PROMPT;
}
return DEFAULT_SYSTEM_PROMPT;
}
/* ============================================================
【7】会話履歴管理(localStorage)
------------------------------------------------------------
※ APIキーは保存しません(セキュリティ仕様)。
============================================================ */
/* --- 新しい会話を作成する --- */
function createConversation() {
const conv = {
id: generateId(),
title: "新しいチャット",
messages: [],
createdAt: Date.now()
};
conversations.unshift(conv); // unshift:配列の「先頭」に追加
activeConversationId = conv.id;
saveConversations();
renderConversationList();
renderActiveConversation();
return conv;
}
/* --- 現在開いている会話(1件分のデータ)を取得する --- */
function getActiveConversation() {
return conversations.find(c => c.id === activeConversationId) || null;
}
/* --- 会話履歴をlocalStorageに保存する --- */
function saveConversations() {
try {
/* 保存用に「必要な項目だけ」を取り出して軽くする */
const safe = conversations.map(c => ({
id: c.id,
title: c.title,
createdAt: c.createdAt,
messages: c.messages.map(m => ({
role: m.role,
content: m.content,
metricsText: m.metricsText || undefined
}))
}));
localStorage.setItem(STORAGE_KEY, JSON.stringify(safe));
} catch (e) {
/* 容量超過など:古い会話を削って再試行 */
try {
conversations = conversations.slice(0, 20);
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
} catch (e2) { /* 保存断念 */ }
}
}
/* --- localStorageから会話履歴を読み込む --- */
function loadConversations() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) conversations = JSON.parse(raw) || [];
} catch (e) {
conversations = [];
}
if (conversations.length > 0) {
activeConversationId = conversations[0].id;
} else {
createConversation();
return;
}
renderConversationList();
renderActiveConversation();
}
/* --- 会話1件を削除する --- */
function deleteConversation(id) {
conversations = conversations.filter(c => c.id !== id);
if (activeConversationId === id) {
if (conversations.length > 0) {
activeConversationId = conversations[0].id;
} else {
saveConversations();
renderConversationList();
createConversation();
return;
}
}
saveConversations();
renderConversationList();
renderActiveConversation();
}
/* --- 最初の質問から会話のタイトルを自動設定する --- */
function generateConversationTitle(text) {
const conv = getActiveConversation();
if (!conv) return;
let title = text.replace(/\s+/g, " ").trim().slice(0, 24);
if (!title) title = "新しいチャット";
conv.title = title;
topbarTitle.textContent = title;
saveConversations();
renderConversationList();
}
/* ============================================================
【8】サイドバーの「会話履歴一覧」の描画
============================================================ */
function renderConversationList() {
conversationList.innerHTML = ""; // 一度中身を空にする
conversations.forEach(c => {
const item = document.createElement("div");
item.className = "conv-item" + (c.id === activeConversationId ? " active" : "");
/* タイトル(ダブルクリックで編集開始・ホバーで全文表示) */
const title = document.createElement("span");
title.className = "conv-title";
title.textContent = c.title;
title.title = c.title;
title.addEventListener("dblclick", (e) => {
e.stopPropagation();
e.preventDefault();
if (isGenerating) return;
startRenameConversation(c, item);
}, { capture: true });
/* ホバー時:上に140px以内ならポップを下向きに切り替える
(サイドバー最上部の履歴でもポップが見切れないようにする完成形の配慮) */
title.addEventListener("mouseenter", () => {
const rect = title.getBoundingClientRect();
if (rect.top < 140) {
title.classList.add("tooltip-below");
} else {
title.classList.remove("tooltip-below");
}
});
/* ✎編集ボタン(ホバー時だけ表示) */
const edit = document.createElement("button");
edit.className = "conv-edit";
edit.textContent = "✎";
edit.setAttribute("data-tooltip", "タイトルを編集します");
edit.addEventListener("click", (e) => {
e.stopPropagation();
if (isGenerating) return;
startRenameConversation(c, item);
});
/* ✕削除ボタン(ホバー時だけ表示) */
const del = document.createElement("button");
del.className = "conv-delete";
del.textContent = "✕";
del.setAttribute("data-tooltip", "この会話を削除します");
del.addEventListener("click", (e) => {
e.stopPropagation();
if (confirm("この会話を削除しますか?")) deleteConversation(c.id);
});
/* 編集ボタン:ホバー時のポップ位置調整 */
edit.addEventListener("mouseenter", () => {
const rect = edit.getBoundingClientRect();
if (rect.top < 140) {
edit.classList.add("tooltip-below");
} else {
edit.classList.remove("tooltip-below");
}
});
/* 削除ボタン:ホバー時のポップ位置調整 */
del.addEventListener("mouseenter", () => {
const rect = del.getBoundingClientRect();
if (rect.top < 140) {
del.classList.add("tooltip-below");
} else {
del.classList.remove("tooltip-below");
}
});
item.appendChild(title);
item.appendChild(edit);
item.appendChild(del);
/* 行全体が押された時:その会話を開く */
item.addEventListener("click", (e) => {
if (e.detail === 2) return; // ダブルクリックは編集なので除外
if (isGenerating) {
if (window.innerWidth <= 768) sidebar.classList.remove("open");
return;
}
activeConversationId = c.id;
topbarTitle.textContent = c.title;
renderConversationList();
renderActiveConversation();
if (window.innerWidth <= 768) sidebar.classList.remove("open");
});
conversationList.appendChild(item);
});
}
/* --- 会話タイトルのインライン編集(ダブルクリック/✎時) --- */
function startRenameConversation(conv, itemElement) {
const titleEl = itemElement.querySelector(".conv-title");
if (!titleEl) return;
const currentTitle = conv.title;
/* タイトル表示を「入力フィールド」に置き換える */
const input = document.createElement("input");
input.type = "text";
input.className = "conv-title-input";
input.value = currentTitle;
input.maxLength = 100;
titleEl.replaceWith(input);
input.focus();
input.select();
let finished = false;
const finish = (save) => {
if (finished) return;
finished = true;
const newTitle = input.value.trim();
if (save && newTitle && newTitle !== currentTitle) {
conv.title = newTitle;
if (conv.id === activeConversationId) {
topbarTitle.textContent = newTitle;
}
saveConversations();
}
renderConversationList();
};
/* Enterで確定 / Escでキャンセル / フォーカスアウトで確定 */
input.addEventListener("keydown", (e) => {
if (e.isComposing) return; // IME変換中のEnterは無視
if (e.key === "Enter") { e.preventDefault(); finish(true); }
else if (e.key === "Escape") { e.preventDefault(); finish(false); }
e.stopPropagation();
});
input.addEventListener("blur", () => finish(true));
input.addEventListener("click", (e) => e.stopPropagation());
input.addEventListener("dblclick", (e) => e.stopPropagation());
}
/* ============================================================
【9】メッセージ描画(完成形と同一の構造)
------------------------------------------------------------
「アバター(U/K)+ロールラベル+本文」を横並びで配置します。
============================================================ */
function renderMessage(msg) {
const wrap = document.createElement("div");
wrap.className = "message " + msg.role;
/* 丸いアバター(ユーザーは「U」、AIは「K」) */
const avatar = document.createElement("div");
avatar.className = "msg-avatar";
avatar.textContent = msg.role === "user" ? "U" : "K";
const body = document.createElement("div");
body.className = "msg-body";
/* ロールラベル(「あなた」/「Kimi K3」) */
const roleLabel = document.createElement("div");
roleLabel.className = "msg-role";
roleLabel.textContent = msg.role === "user" ? "あなた" : getModelDisplayName();
const content = document.createElement("div");
content.className = "msg-content";
if (msg.role === "assistant") {
/* ----- AIの回答:Markdownとして描画(安全化つき) ----- */
content.classList.add("markdown-body");
content.innerHTML = renderMarkdown(msg.content);
addCopyButtons(content); // 言語名+コピーボタン
applySyntaxHighlight(content); // シンタックスハイライト
/* 応答計測の結果(計測データがある時だけ) */
if (msg.metricsText) {
const metricsEl = document.createElement("div");
metricsEl.className = "response-metrics";
metricsEl.textContent = msg.metricsText;
content.appendChild(metricsEl);
}
} else {
/* ----- あなたの質問:無害化したテキストをそのまま表示 ----- */
content.textContent = msg.content;
/* 長い質問(500文字超)は折りたたんで「全文を表示」を付ける */
const COLLAPSE_THRESHOLD = 500;
if (msg.content.length > COLLAPSE_THRESHOLD) {
content.classList.add("user-content-collapsed");
const collapseContainer = document.createElement("div");
collapseContainer.className = "user-collapse-container";
const toggleBtn = document.createElement("button");
toggleBtn.className = "user-toggle-btn";
toggleBtn.textContent = "▼ 全文を表示";
toggleBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const isCollapsed = content.classList.toggle("user-content-collapsed");
toggleBtn.textContent = isCollapsed ? "▼ 全文を表示" : "▲ 折りたたむ";
});
collapseContainer.appendChild(toggleBtn);
content._collapseContainer = collapseContainer; // 後で挿入するため保持
}
}
body.appendChild(roleLabel);
body.appendChild(content);
/* 折りたたみコンテナがあれば挿入 */
if (content._collapseContainer) {
content.parentNode.insertBefore(content._collapseContainer, content.nextSibling);
delete content._collapseContainer;
}
/* 添付メタデータ表示(ステップ④で追加)
「どのファイルを添付したか」の情報(ファイル名・形式・サイズ)を、
会話履歴を再表示した時に表示します。
※添付の「中身」(画像データやPDFのテキスト)は保存していないため、
ここでは「ファイル名とサイズ」のみを表示します。 */
if (msg.attachmentsMeta && msg.attachmentsMeta.length > 0) {
const attachBox = document.createElement("div");
attachBox.className = "msg-attachments";
msg.attachmentsMeta.forEach(a => {
const chip = document.createElement("span");
chip.className = "msg-attachment";
/* PDFの場合は「📄」、それ以外(画像・テキストファイル)は「🖼」のアイコンを付ける */
const icon = a.type === "application/pdf" ? "📄" : "🖼";
chip.textContent = `${icon} ${a.name} (${formatBytes(a.size || 0)})`;
attachBox.appendChild(chip);
});
body.appendChild(attachBox);
}
/* 操作ボタン(コピー / 再生成 / 再送信。ホバー時だけ表示) */
const actions = document.createElement("div");
actions.className = "msg-actions";
/* コピーボタン */
const copyBtn = document.createElement("button");
copyBtn.textContent = "📋 コピー";
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(msg.content);
copyBtn.textContent = "✓ コピーしました";
setTimeout(() => { copyBtn.textContent = "📋 コピー"; }, 1500);
} catch (e) {
showError("コピーに失敗しました。ブラウザのクリップボード権限を確認してください。");
}
});
actions.appendChild(copyBtn);
/* 再生成ボタン(AIの回答)/ 再送信ボタン(あなたの質問) */
if (msg.role === "assistant") {
const regenBtn = document.createElement("button");
regenBtn.textContent = "↻ 再生成";
regenBtn.addEventListener("click", () => regenerateResponse(msg));
actions.appendChild(regenBtn);
} else {
const resendBtn = document.createElement("button");
resendBtn.textContent = "↻ 再送信";
resendBtn.addEventListener("click", () => {
if (isGenerating) return;
messageInput.value = msg.content;
messageInput.focus();
autoResizeTextarea();
});
actions.appendChild(resendBtn);
}
body.appendChild(actions);
wrap.appendChild(avatar);
wrap.appendChild(body);
return wrap;
}
/* --- 現在の会話の「メッセージ一覧」を描画する ---
※ウェルカム画面はステップ⑦で追加します。 */
function renderActiveConversation() {
const conv = getActiveConversation();
if (!conv) return;
chatContainer.innerHTML = "";
conv.messages.forEach(m => chatContainer.appendChild(renderMessage(m)));
scrollToBottom(true);
}
/* --- メッセージ一覧の一番下(最新)までスクロールする ---
autoScrollEnabled がOFF(ユーザーが上を読んでいる)時は
スクロールを奪わない配慮です。 */
function scrollToBottom(force) {
if (force || autoScrollEnabled) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
/* ============================================================
【10】Markdownレンダリング(サニタイズ付き)
------------------------------------------------------------
① marked → HTML変換、② DOMPurify → 安全チェック。
============================================================ */
function renderMarkdown(text) {
try {
if (typeof marked !== "undefined") {
marked.setOptions({ breaks: true, gfm: true });
const html = marked.parse(text || "");
if (typeof DOMPurify !== "undefined") {
return DOMPurify.sanitize(html, {
ADD_ATTR: ["target", "rel"],
FORBID_TAGS: ["style", "script", "iframe", "object", "embed", "form"]
});
}
return html;
}
/* CDN利用不可時のフォールバック:プレーンテキスト表示 */
return "<pre style='background:none;padding:0;white-space:pre-wrap;color:inherit'>"
+ escapeHtml(text || "") + "</pre>";
} catch (e) {
return "<p>" + escapeHtml(text || "") + "</p>";
}
}
/* ============================================================
【11】コードブロック装飾(言語名+コピーボタン)
------------------------------------------------------------
Markdownで生成されたコードブロック(<pre><code>)に、
「言語名の表示」と「コピーボタン」を追加します。
============================================================ */
function addCopyButtons(container) {
container.querySelectorAll("pre").forEach(pre => {
if (pre.querySelector(".code-header")) return; // 二重装飾防止
const code = pre.querySelector("code");
if (!code) return;
/* コードブロックの言語名を取得(class="language-js" など) */
let lang = "";
code.classList.forEach(cls => {
if (cls.startsWith("language-")) lang = cls.replace("language-", "");
});
/* 言語名を一般的な表記に変換 */
const langDisplayNames = {
'javascript': 'JavaScript',
'typescript': 'TypeScript',
'python': 'Python',
'html': 'HTML',
'css': 'CSS',
'json': 'JSON',
'markdown': 'Markdown',
'bash': 'Bash',
'shell': 'Shell',
'sql': 'SQL',
'java': 'Java',
'c': 'C',
'cpp': 'C++',
'csharp': 'C#',
'php': 'PHP',
'ruby': 'Ruby',
'go': 'Go',
'rust': 'Rust',
'swift': 'Swift',
'kotlin': 'Kotlin',
'xml': 'XML',
'yaml': 'YAML',
'dockerfile': 'Dockerfile',
'makefile': 'Makefile',
'ini': 'INI',
'toml': 'TOML',
'csv': 'CSV',
'txt': 'Text'
};
/* 一覧に無い言語は、先頭だけ大文字にして表示(例:js → Js) */
const displayLang = langDisplayNames[lang.toLowerCase()] || lang.charAt(0).toUpperCase() + lang.slice(1);
/* ヘッダー(言語名+コピーボタン)をコードブロックの先頭に追加 */
const header = document.createElement("div");
header.className = "code-header";
const langLabel = document.createElement("span");
langLabel.className = "code-lang";
langLabel.textContent = displayLang || "code";
const copyBtn = document.createElement("button");
copyBtn.className = "code-copy-btn";
copyBtn.textContent = "コピー";
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(code.textContent);
copyBtn.textContent = "✓ コピーしました";
copyBtn.classList.add("copied");
setTimeout(() => {
copyBtn.textContent = "コピー";
copyBtn.classList.remove("copied");
}, 1500);
} catch (e) {
showError("コードのコピーに失敗しました。");
}
});
header.appendChild(langLabel);
header.appendChild(copyBtn);
pre.insertBefore(header, pre.firstChild);
});
}
/* ============================================================
【12】シンタックスハイライト(コードに色を付ける)
------------------------------------------------------------
highlight.js を使ってコードブロックを色付けします。
ライブラリが読み込めていない場合は、何もせずスキップします。
============================================================ */
function applySyntaxHighlight(container) {
if (!container || !window.hljs) return;
container.querySelectorAll("pre code").forEach(code => {
if (code.classList.contains("hljs")) return; // 適用済みはスキップ
try {
hljs.highlightElement(code);
} catch (e) { /* ハイライト失敗時はそのまま表示 */ }
});
}
/* ============================================================
【13】処理状況パネル
------------------------------------------------------------
「質問を分析しています…」「回答を受信しています…」など、
AIの処理状況を表示するパネルです。
構造:ヘッダー(スピナー+ステータス)+開閉できるログ一覧
============================================================ */
/* --- 処理状況パネルを作成してチャット欄に追加する --- */
function createProgressPanel() {
const panel = document.createElement("div");
panel.className = "progress-panel";
/* ヘッダー(クリックでログの開閉ができる) */
const header = document.createElement("div");
header.className = "progress-header";
header.innerHTML = `<span class="progress-caret">▶</span>
<span class="progress-current"><span class="spinner"></span><span class="progress-status-text">待機中</span></span>`;
header.addEventListener("click", () => panel.classList.toggle("open"));
/* ログの一覧(初期は閉じた状態) */
const body = document.createElement("div");
body.className = "progress-body";
panel.appendChild(header);
panel.appendChild(body);
chatContainer.appendChild(panel);
scrollToBottom();
return panel;
}
/* --- 処理状況のステータスを更新する ---
前のステータスを「完了(✓)」にして、新しいステータスを
「実行中(●)」としてログに追加します。 */
function showProgressStatus(status) {
if (!currentProgressPanel) return;
if (currentProgressState.lastStatus === status) return; // 同じ内容は無視
const body = currentProgressPanel.querySelector(".progress-body");
/* 直前のログを「完了(✓)」に変える */
if (currentProgressState.lastStatus) {
const last = body.lastElementChild;
if (last) {
last.classList.remove("current");
last.classList.add("done");
}
}
/* 新しいステータスを「実行中(●)」としてログに追加 */
const item = document.createElement("div");
item.className = "progress-log-item current";
item.textContent = status;
body.appendChild(item);
/* ヘッダーの表示テキストも更新 */
const statusText = currentProgressPanel.querySelector(".progress-status-text");
statusText.textContent = status;
currentProgressState.lastStatus = status;
/* 完了/停止時はスピナー(くるくる回る印)を消す */
const spinner = currentProgressPanel.querySelector(".spinner");
if (status === "完了しました") {
spinner.style.display = "none";
item.classList.remove("current");
item.classList.add("done");
} else if (status === "生成を停止しました") {
spinner.style.display = "none";
item.classList.remove("current");
item.classList.add("stopped");
}
}
/* ============================================================
【14】思考過程パネル
------------------------------------------------------------
AIの「考えている内容」を表示するパネルです。
受信中は開いた状態(🧠 思考過程+スピナー)で表示され、
回答完了時にストリーミング用DOMごと消去されます。
(思考過程のテキストは保存されないため、
完了後に再表示することはできません)
============================================================ */
/* --- 思考過程パネルを作成する(回答本文の「前」に挿入) --- */
function createReasoningPanel(streamWrap) {
const body = streamWrap.querySelector(".msg-body");
const contentEl = streamWrap.querySelector(".msg-content");
const panel = document.createElement("div");
panel.className = "reasoning-panel open"; // 受信中は開いた状態
const header = document.createElement("div");
header.className = "reasoning-header";
header.innerHTML = `<span class="reasoning-caret">▶</span>
<span class="reasoning-title">🧠 思考過程</span>
<span class="spinner reasoning-spinner"></span>`;
header.addEventListener("click", () => panel.classList.toggle("open"));
const bodyEl = document.createElement("div");
bodyEl.className = "reasoning-body";
panel.appendChild(header);
panel.appendChild(bodyEl);
body.insertBefore(panel, contentEl); // 回答本文の前に挿入
return bodyEl;
}
/* --- 思考過程の表示内容を更新する(受信のたびに呼ばれる) --- */
function updateReasoningPanel() {
if (!currentReasoningEl) return;
currentReasoningEl.textContent = fullReasoningText;
}
/* ============================================================
ドロップされたファイルの振り分け(ステップ④で追加)
------------------------------------------------------------
ドラッグ&ドロップで落とされたファイルを、
「画像・PDF・テキストファイル」の種類ごとに振り分け、
それぞれの添付処理(handleImageUpload・handlePdfUpload・
handleTextFiles)に渡します。
(完成形の distributeDroppedFiles と同じ仕様)
============================================================ */
function distributeDroppedFiles(files) {
/* ドロップされたファイルを、種類ごとに分類する */
const images = files.filter(f => ALLOWED_IMAGE_TYPES.includes(f.type));
const pdf = files.find(f => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"));
const textFiles = files.filter(f => isTextFile(f));
let handledCount = 0; // 処理できたファイルの数
/* 画像があれば、画像の添付処理に渡す(差分⑩-1の関数を再利用) */
if (images.length > 0) {
handleImageUpload(images);
handledCount += images.length;
}
/* PDFがあれば、PDFの添付処理に渡す(差分⑩-3-1の関数を再利用)
PDFは「1件だけ」しか添付できないため、find で「最初の1件」だけを処理する */
if (pdf) {
handlePdfUpload(pdf);
handledCount += 1;
}
/* テキストファイルがあれば、テキストの添付処理に渡す(差分⑩-2の関数を再利用) */
if (textFiles.length > 0) {
handleTextFiles(textFiles);
handledCount += textFiles.length;
}
/* 処理できなかったファイルがある場合は、エラーを表示する */
const unsupported = files.filter(f =>
!ALLOWED_IMAGE_TYPES.includes(f.type) &&
f.type !== "application/pdf" &&
!f.name.toLowerCase().endsWith(".pdf") &&
!isTextFile(f)
);
if (unsupported.length > 0) {
/* 全てが未対応の場合のみエラー表示(一部対応済みなら警告程度に) */
if (handledCount === 0) {
showError(`対応していないファイル形式です:${unsupported.map(f => f.name).join(', ')}\n\n対応形式:画像(JPEG/PNG/GIF/WebP)、PDF、テキストファイル(.txt, .py, .js, .html 等)`);
} else {
console.warn("未対応ファイル:", unsupported.map(f => f.name));
}
}
}
/* ============================================================
【15】UI状態管理
============================================================ */
/* --- 生成中は「停止」、待機中は「送信」を表示する --- */
function updateUiState() {
if (isGenerating) {
sendBtn.classList.add("hidden");
stopBtn.classList.remove("hidden");
messageInput.disabled = true;
} else {
sendBtn.classList.remove("hidden");
stopBtn.classList.add("hidden");
messageInput.disabled = false;
messageInput.focus();
}
}
/* --- 入力欄の高さを、入力量に合わせて自動調整する --- */
function autoResizeTextarea() {
messageInput.style.height = "auto"; // 一度リセットしてから…
/* scrollHeight(内容の実際の高さ)に合わせる(上限はCSSで200px) */
messageInput.style.height = Math.min(messageInput.scrollHeight, 200) + "px";
}
/* ============================================================
画像処理(ステップ④で追加)
------------------------------------------------------------
選択された画像ファイルの「形式」と「サイズ」を検証し、
問題なければ添付リスト(attachedImages)に追加します。
画像は「dataUrl」(Base64エンコードした文字列)に変換して
保持し、送信時にマルチモーダル形式でAPIに送ります。
(完成形の画像処理関数と同じ仕様)
============================================================ */
/* --- 画像ファイルの添付処理 ---
引数 files:選択されたファイルの配列(FileList または Array)
1件ずつ「形式」と「サイズ」を検証し、問題なければ添付します。 */
async function handleImageUpload(files) {
for (const file of files) {
/* 形式検証:対応していない形式(JPEG / PNG / GIF / WebP 以外)はスキップ */
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
showError(`画像形式が不正です(${file.name})。JPEG / PNG / GIF / WebP のみ対応しています。`);
continue;
}
/* サイズ検証:上限を超えている場合はスキップ */
if (file.size > MAX_IMAGE_SIZE) {
showError(`画像サイズが上限を超えています(${file.name}: ${formatBytes(file.size)})。上限は ${formatBytes(MAX_IMAGE_SIZE)} です。`);
continue;
}
try {
/* 画像を「dataUrl」(Base64エンコードした文字列)に変換して添付リストに追加 */
const dataUrl = await convertImageToDataUrl(file);
attachedImages.push({ name: file.name, size: file.size, type: file.type, dataUrl });
} catch (e) {
showError(`画像の読み込みに失敗しました(${file.name})。`);
}
}
/* 添付プレビューを更新する(renderAttachmentPreview は差分⑩-4で追加します) */
renderAttachmentPreview();
}
/* --- 画像ファイルを「dataUrl」に変換する ---
引数 file:画像ファイル
戻り値:dataUrl(Base64エンコードした文字列)を返す Promise
FileReader API を使って、画像データを「文字列」の形に変換します。 */
function convertImageToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
/* 読み込み成功時:dataUrl(文字列)を返す */
reader.onload = () => resolve(reader.result);
/* 読み込み失敗時:エラーを返す */
reader.onerror = () => reject(new Error("read error"));
/* readAsDataURL で、画像を「data:image/png;base64,...」の形に変換 */
reader.readAsDataURL(file);
});
}
/* --- 添付画像の削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
指定した位置(index)の画像を添付リストから削除します。 */
function removeAttachedImage(index) {
attachedImages.splice(index, 1); // 指定した位置の1件を削除
renderAttachmentPreview(); // 添付プレビューを更新
}
/* ============================================================
テキストファイル・フォルダ処理(ステップ④で追加)
------------------------------------------------------------
テキストファイル(.txt・.py・.js・.html など)の中身を
読み込んで添付し、送信時に質問文に添えて送ります。
フォルダを選択した場合は、中のファイルを「種類ごと」に
振り分けて、それぞれの添付処理に渡します。
(完成形のテキストファイル・フォルダ処理関数と同じ仕様)
============================================================ */
/* --- そのファイルが「テキストファイル」かどうかを判定する ---
2つの方法で判定します。
① 拡張子が TEXT_FILE_EXTENSIONS の一覧に含まれているか
② MIMEタイプが text/ で始まるか(text/plain など)
どちらか一方でも当てはまれば、テキストファイルとみなします。 */
function isTextFile(file) {
/* ファイル名の末尾の「拡張子」を取り出す
split('.') で「.」で区切り、pop() で最後の要素(拡張子)を取得する */
const ext = '.' + file.name.split('.').pop().toLowerCase();
return TEXT_FILE_EXTENSIONS.includes(ext) || file.type.startsWith('text/');
}
/* --- ファイルの中身を「テキスト」として読み込む ---
引数 file:テキストファイル
戻り値:ファイルの中身(文字列)を返す Promise
FileReader API を使って、UTF-8 のテキストとして読み込みます。 */
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
/* 読み込み成功時:ファイルの中身(文字列)を返す */
reader.onload = () => resolve(reader.result);
/* 読み込み失敗時:エラーを返す */
reader.onerror = () => reject(new Error("read error"));
/* readAsText で、UTF-8 のテキストとして読み込む */
reader.readAsText(file, 'UTF-8');
});
}
/* --- テキストファイルの添付処理 ---
引数 files:選択されたファイルの配列
1件ずつ「拡張子」「1件あたりのサイズ」「合計サイズ」を検証し、
問題なければ添付リスト(attachedTextFiles)に追加します。 */
async function handleTextFiles(files) {
/* すでに添付済みのファイルの「合計サイズ」を計算しておく
(合計サイズの上限チェックに使う) */
let totalSize = attachedTextFiles.reduce((sum, f) => sum + f.size, 0);
for (const file of files) {
/* 拡張子検証:テキストファイル以外はスキップ */
if (!isTextFile(file)) {
showError(`対応していないファイル形式です(${file.name})。テキストファイルのみ添付可能です。`);
continue;
}
/* サイズ検証①:1件あたりの上限を超えている場合はスキップ */
if (file.size > MAX_TEXT_FILE_SIZE) {
showError(`ファイルサイズが上限を超えています(${file.name}: ${formatBytes(file.size)})。上限は ${formatBytes(MAX_TEXT_FILE_SIZE)} です。`);
continue;
}
/* サイズ検証②:合計サイズが上限を超える場合は、それ以降の処理を中止 */
if (totalSize + file.size > MAX_TEXT_TOTAL_SIZE) {
showError(`テキストファイルの合計サイズが上限(${formatBytes(MAX_TEXT_TOTAL_SIZE)})を超えています。`);
break;
}
try {
/* ファイルの中身をテキストとして読み込む */
const content = await readFileAsText(file);
/* フォルダから選択した場合、webkitRelativePath に
「フォルダ名/ファイル名」の相対パスが入っています。
パスが長すぎる場合は、ファイル名のみを使います(エラー対策) */
let safePath = file.webkitRelativePath || file.name;
if (safePath.length > 200) {
safePath = file.name;
}
/* 添付リストに追加
name:ファイル名、size:サイズ、type:形式、
content:中身のテキスト、path:表示用のパス */
attachedTextFiles.push({
name: file.name,
size: file.size,
type: file.type || 'text/plain',
content: content,
path: safePath
});
totalSize += file.size;
} catch (e) {
showError(`ファイルの読み込みに失敗しました(${file.name})。`);
}
}
/* 添付プレビューを更新する(renderAttachmentPreview は差分⑩-4で追加します) */
renderAttachmentPreview();
}
/* --- フォルダの添付処理 ---
引数 files:フォルダ内の全ファイルの配列
フォルダ内のファイルを「テキスト・画像・PDF」の種類ごとに振り分け、
それぞれの添付処理(handleTextFiles・handleImageUpload・handlePdfUpload)
に渡します。 */
async function handleFolderUpload(files) {
/* フォルダ内の全ファイルを、種類ごとに分類する
Array.from で「配列風オブジェクト」を本物の配列に変換し、
filter で条件に合うファイルだけを取り出す */
const allFiles = Array.from(files);
const textFiles = allFiles.filter(f => isTextFile(f));
const images = allFiles.filter(f => ALLOWED_IMAGE_TYPES.includes(f.type));
const pdfs = allFiles.filter(f => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"));
let processed = false; // 1件でも処理できたかどうかのフラグ
/* テキストファイルがあれば、テキストの添付処理に渡す */
if (textFiles.length > 0) {
await handleTextFiles(textFiles);
processed = true;
}
/* 画像があれば、画像の添付処理に渡す(差分⑩-1の関数を再利用) */
if (images.length > 0) {
await handleImageUpload(images);
processed = true;
}
/* PDFがあれば、PDFの添付処理に渡す
PDFは「1件だけ」しか添付できないため、最初の1件だけを処理する
(handlePdfUpload は差分⑩-3で追加します) */
if (pdfs.length > 0) {
await handlePdfUpload(pdfs[0]);
if (pdfs.length > 1) {
showError(`PDFは1つだけ添付可能です。${pdfs.length - 1}個のPDFは無視されました。`);
}
processed = true;
}
/* 1件も処理できるファイルがなかった場合は、エラーを表示 */
if (!processed) {
showError("フォルダ内に処理可能なファイル(テキスト・画像・PDF)が見つかりませんでした。");
}
}
/* ============================================================
PDF処理(ステップ④で追加)
------------------------------------------------------------
選択されたPDFファイルを、PDF.js(外部ライブラリ)で解析し、
「テキストを抽出」します。
テキストが抽出できた場合は「テキストで送る」、
抽出できない場合(スキャン画像など)は「ページ画像で送る」
という2つの方式を、自動で振り分けます。
(完成形の PDF処理関数と同じ仕様)
============================================================ */
/* --- PDFファイルの添付処理 ---
引数 file:選択されたPDFファイル(1ファイルのみ)
「形式」と「サイズ」を検証し、問題なければPDF.jsで解析を開始します。 */
async function handlePdfUpload(file) {
/* 形式検証:PDF以外のファイルは受け付けない */
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
showError("PDF形式のファイルを選択してください。");
return;
}
/* サイズ検証:上限を超えている場合は中止 */
if (file.size > MAX_PDF_SIZE) {
showError(`PDFサイズが上限を超えています(${formatBytes(file.size)})。上限は ${formatBytes(MAX_PDF_SIZE)} です。`);
return;
}
/* PDF.jsが読み込めていない場合は中止(オフラインなど) */
if (typeof pdfjsLib === "undefined") {
showError("PDF.jsライブラリを読み込めませんでした。ネットワーク接続を確認してください。");
return;
}
clearError();
/* 添付PDFの情報を保持する「器」を初期化する
これから解析を進め、結果(text・pageImagesなど)をここに入れていきます */
attachedPdf = {
name: file.name,
size: file.size,
pageRange: "", // 処理対象のページ範囲(例:「先頭 10 / 25 ページ」)
text: "", // 抽出したテキスト(抽出できた場合)
pageImages: [], // ページ画像の配列(テキストが取れない場合)
garbledFallback: false // 文字化けによるフォールバックかどうか(差分⑩-3-2で使用)
};
/* 添付プレビューを「処理中…」の状態で先に表示する
(renderAttachmentPreview は差分⑩-4で追加します) */
renderAttachmentPreview();
try {
/* PDF.jsの「ワーカー」(解析処理を裏側で行う部品)の場所を設定する
CDN版を読み込んでいるため、同じCDNのURLを指定します */
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
/* ファイルの中身を「バイト列」(ArrayBuffer)として読み込む */
const arrayBuffer = await file.arrayBuffer();
/* PDF.jsでPDF文書を解析する(解析が終わるまで待つ) */
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
/* 総ページ数を取得し、「処理対象のページ数」を決める
上限(MAX_PDF_PAGES)を超えている場合は、先頭のページだけを処理する */
const totalPages = pdf.numPages;
if (totalPages > MAX_PDF_PAGES) {
showError(`PDFのページ数が上限(${MAX_PDF_PAGES}ページ)を超えています(${totalPages}ページ)。先頭${MAX_PDF_PAGES}ページのみ処理します。`);
}
const targetPages = Math.min(totalPages, MAX_PDF_PAGES);
attachedPdf.pageRange = `先頭 ${targetPages} / ${totalPages} ページ`;
/* テキスト抽出を試行する
抽出できたかどうかで、以下の2つに振り分けます。
① テキストが取れた → 「テキストで送る」
② テキストが取れない → 「ページ画像で送る」 */
const extractedText = await extractPdfText(pdf, targetPages);
if (extractedText && extractedText.trim().length > 20) {
/* テキストが抽出できた場合:
ただし「文字化け」していないかを検査します。
文字化けと判定した場合は、テキスト抽出をあきらめて
「ページ画像で送る」方式に切り替えます。 */
if (isLikelyGarbled(extractedText)) {
/* 文字化け検出:自動でページ画像へフォールバック
garbledFallback フラグを true にしておくと、
buildPdfContext や添付プレビューで
「文字化けによるフォールバック」として扱われます */
attachedPdf.garbledFallback = true;
attachedPdf.text = "";
attachedPdf.pageImages = await renderPdfPagesAsImages(pdf, targetPages);
} else {
/* 文字化けしていない場合:抽出したテキストを添付する */
attachedPdf.text = extractedText;
}
} else {
/* テキストが抽出できない場合(スキャン画像のPDFなど):
ページを「画像」として描画(レンダリング)して添付する
(renderPdfPagesAsImages は差分⑩-3-2で追加します) */
attachedPdf.pageImages = await renderPdfPagesAsImages(pdf, targetPages);
}
} catch (e) {
/* 解析に失敗した場合は、添付を取り消してエラーを表示 */
showError("PDFの処理に失敗しました:" + sanitizeLog(e));
attachedPdf = null;
}
/* 解析結果を反映して、添付プレビューを更新する */
renderAttachmentPreview();
}
/* --- PDFの各ページから「テキスト」を抽出する ---
引数 pdf:PDF.jsで解析したPDF文書
targetPages:処理対象のページ数
戻り値:抽出したテキスト(1つの文字列) */
async function extractPdfText(pdf, targetPages) {
let text = "";
/* 1ページずつ順番にテキストを抽出する */
for (let i = 1; i <= targetPages; i++) {
try {
/* ページを取得し、その中の「テキストコンテンツ」を取り出す */
const page = await pdf.getPage(i);
const content = await page.getTextContent();
/* テキストコンテンツ(items配列)の各要素(item.str)を、
スペースでつなげて1つの文字列にする */
const pageText = content.items.map(item => item.str).join(" ");
/* ページ番号の区切りを付けて、テキストに追加する */
text += `\n--- ${i}ページ ---\n${pageText}\n`;
} catch (e) {
/* そのページの抽出に失敗した場合は、失敗したことを記録して次へ進む */
text += `\n--- ${i}ページ(抽出失敗) ---\n`;
}
}
return text.trim();
}
/* --- PDFの各ページを「画像」として描画(レンダリング)する ---
引数 pdf:PDF.jsで解析したPDF文書
targetPages:処理対象のページ数
戻り値:ページ画像(dataUrl)の配列
スキャンPDF(画像だけのPDF)や、文字化けしたPDFに対応するため、
ページを1枚ずつ「画像」に変換してAPIに送ります。
(完成形の renderPdfPagesAsImages と同じ仕様) */
async function renderPdfPagesAsImages(pdf, targetPages) {
const images = [];
/* 描画の拡大率(1.5倍)
大きくするほど文字が鮮明になるが、画像サイズも大きくなる */
const scale = 1.5;
/* 1ページずつ順番に画像化する */
for (let i = 1; i <= targetPages; i++) {
try {
/* ページを取得し、その「表示サイズ」(viewport)を計算する */
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale });
/* 描画用の「キャンバス」(canvas:絵を描くための領域)を作り、
ページの表示サイズに合わせる */
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
/* ページをキャンバスに描画(レンダリング)する(終わるまで待つ) */
await page.render({ canvasContext: ctx, viewport }).promise;
/* 描画したキャンバスを「JPEG形式・品質80%」の dataUrl に変換して配列に追加 */
images.push(canvas.toDataURL("image/jpeg", 0.8));
} catch (e) {
/* そのページの描画に失敗した場合はスキップ(全体は止めない) */
}
}
return images;
}
/* --- 抽出したテキストが「文字化け」していないかを検査する ---
引数 text:抽出したテキスト
戻り値:文字化けの可能性が高い場合は true、そうでない場合は false
【なぜこの検査が必要か】
PDFによっては「フォントの文字コードの対応表(ToUnicode CMap)」が
正しく埋め込まれていないケースがあり、PDF.jsでテキストを抽出すると
「意味のない文字の羅列」(文字化け)になってしまいます。
文字化けしたテキストをAIに送っても正しく理解してもらえないため、
文字化けと判定した場合は「ページ画像で送る」方式に切り替えます。
【判定の仕組み】
「文字種の偏り」+「文字コードの範囲」+「既知の文字化けパターン」の
3つの観点から、6つの判定ロジックで多角的に検査します。
(完成形の isLikelyGarbled と同じ仕様) */
function isLikelyGarbled(text) {
if (!text) return false;
/* 先頭の2000文字を「サンプル」として検査する(全部は重いため) */
const sample = text.slice(0, 2000);
if (sample.length < 20) return false;
/* --- 判定1:日本語文字の比率 ---
ひらがな・カタカナ(〜ゞ)・漢字(一〜鿿)の割合を計算する */
const japaneseChars = sample.match(/[-ヿ一-鿿]/g) || [];
const japaneseRatio = japaneseChars.length / sample.length;
/* --- 判定2:異常な文字(私用領域・ゼロ幅スペースなど)の比率 ---
文字化け時に頻出する範囲(〜の私用領域、〜の
ゼロ幅スペース、〜の特殊文字)の割合を計算する */
const suspiciousChars = sample.match(/[---]/g) || [];
const suspiciousRatio = suspiciousChars.length / sample.length;
/* --- 判定3:ASCII文字(ラテン文字・数字・句読点)の比率 ---
日本語文書なのにASCII文字( ~)ばかりの場合は、文字化けの可能性が高い */
const asciiChars = sample.match(/[ -~]/g) || [];
const asciiRatio = asciiChars.length / sample.length;
/* --- 判定4:同一文字の異常な連続 ---
文字化け時に「,」「.」「0」「e」などが大量に連続するケースを検出する */
const repeatedPattern = /(.)\1{9,}/; // 同一文字が10回以上連続
const hasRepeatedPattern = repeatedPattern.test(sample);
/* --- 判定5:意味不明なASCII文字列の連続 ---
文字化けPDFで頻出する「意味のないASCII文字の長い連続」を検出する */
const meaninglessAscii = sample.match(/[a-zA-Z0-9]{30,}/g) || [];
const hasMeaninglessAscii = meaninglessAscii.length > 2;
/* --- 判定6:日本語文書に通常現れない文字(異国文字)の混入比率 ---
日本語文書に現れない文字範囲(က〜ჿ、᐀〜ᙿ、ก〜、က〜႟など)の
割合を計算する */
const unusualChars = sample.match(/[က-ჿ᐀-ᙿก-က-႟]/g) || [];
const unusualRatio = unusualChars.length / sample.length;
/* 日本語文書に異国文字が1%以上混入している場合は、文字化けと判定 */
if (unusualRatio > 0.01) {
return true;
}
/* --- 総合判定 ---
ページ区切り文字列(「ページ」)があるのに日本語がほぼない、
などの条件を組み合わせて、文字化けの可能性を判定する */
const hasJapanesePageMarker = /ページ/.test(sample);
/* 日本語PDFなのに日本語がほぼなく(2%未満)、ASCII文字ばかり(60%超)の場合 */
if (hasJapanesePageMarker && japaneseRatio < 0.02 && asciiRatio > 0.6) {
return true;
}
/* 私用領域・異常文字が5%超の場合 */
if (suspiciousRatio > 0.05) {
return true;
}
/* 日本語文書なのに意味不明なASCII文字列が多く、日本語がほぼない場合 */
if (hasJapanesePageMarker && japaneseRatio < 0.05 && hasMeaninglessAscii) {
return true;
}
/* 異常な文字繰り返しがあり、日本語がほぼない場合 */
if (hasRepeatedPattern && japaneseRatio < 0.05) {
return true;
}
return false;
}
/* --- PDFの内容を「質問文に添える形」(コンテキスト)に整える ---
引数 pdf:添付PDFの情報(attachedPdf と同じ構造)
戻り値:APIに送る際の「前置き文」を含むコンテキスト文字列
PDFの「状態」に応じて、以下の3つに振り分けます。
① 文字化けによるフォールバック(garbledFallback が true)
→ 「文字化けのため、ページ画像として添付しています」と伝える
② テキストが抽出できた(text がある)
→ 「以下はPDFから抽出したテキストです」と前置きして、
抽出したテキストを添える(長すぎる場合は省略)
③ テキストが取れなかった(スキャンPDFなど)
→ 「テキスト抽出できなかったため、ページ画像として添付しています」と伝える
(完成形の buildPdfContext と同じ仕様) */
function buildPdfContext(pdf) {
let ctx = `【添付PDF: ${pdf.name}(${pdf.pageRange})】\n`;
if (pdf.garbledFallback) {
/* ① 文字化け検出による自動フォールバックの場合:
「なぜページ画像になったのか」を、AIに正直に伝えます */
ctx += "このPDFはPDF内部のフォント埋め込みやエンコーディングが対応しておらず、" +
"テキスト抽出で文字化けが発生したため、ページ画像として添付しています。" +
"画像の内容を確認してください。";
} else if (pdf.text) {
/* ② テキストが抽出できた場合:
ただし、長すぎるテキストはトークン数(API料金)が跳ね上がるため、
上限を設けて省略します */
const MAX_PDF_CHARS = 20000; // トークン肥大防止のため上限を設ける
const body = pdf.text.length > MAX_PDF_CHARS
? pdf.text.slice(0, MAX_PDF_CHARS) + "\n…(以降省略)"
: pdf.text;
ctx += `以下はPDFから抽出したテキストです。\n${body}`;
} else {
/* ③ テキストが取れなかった場合(スキャンPDFなど):
ページ画像として添付していることを伝えます */
ctx += "このPDFはテキスト抽出できなかったため、ページ画像として添付しています。画像の内容を確認してください。";
}
return ctx;
}
/* ============================================================
添付プレビュー描画(ステップ④で追加)
------------------------------------------------------------
添付したファイル(画像・PDF・テキスト)の一覧を、
添付プレビューエリアに描画します。
「データが変わるたびに、一度空にしてから最新の状態で作り直す」
という、画面描画の基本パターン(【8】と同じ)で実装しています。
(完成形の renderAttachmentPreview と同じ仕様)
============================================================ */
function renderAttachmentPreview() {
/* 一度中身を空にする(追加し続けると、一覧が重複してしまうため) */
attachmentPreviewArea.innerHTML = "";
/* --- 画像のプレビュー(サムネイル付き) ---
attachedImages の1件ごとに「チップ」を作り、
画像のサムネイル・ファイル名・サイズ・削除ボタンを表示します。 */
attachedImages.forEach((img, i) => {
const chip = document.createElement("div");
chip.className = "attach-chip";
/* 画像のサムネイル(dataUrl をそのまま src に指定するだけで表示できる) */
const thumb = document.createElement("img");
thumb.src = img.dataUrl;
thumb.alt = img.name;
/* ファイル名とサイズ(formatBytes で読みやすい形式に変換) */
const label = document.createElement("span");
label.textContent = `${img.name} (${formatBytes(img.size)})`;
/* 削除ボタン(✕):押すと removeAttachedImage で添付から削除する */
const remove = document.createElement("button");
remove.className = "attach-remove";
remove.textContent = "✕";
remove.title = "削除";
remove.addEventListener("click", () => removeAttachedImage(i));
chip.appendChild(thumb);
chip.appendChild(label);
chip.appendChild(remove);
attachmentPreviewArea.appendChild(chip);
});
/* --- PDFのプレビュー ---
添付PDFの「処理状態」(処理中…/テキスト抽出済み/ページ画像化済み/
文字化けフォールバック)を、完成形と同じ表記で表示します。 */
if (attachedPdf) {
const chip = document.createElement("div");
chip.className = "attach-chip";
const label = document.createElement("span");
/* PDFの「状態」に応じて、表示するステータスを振り分ける */
let status;
if (attachedPdf.garbledFallback) {
/* 文字化け検出による自動フォールバックの場合:
「API利用料金が増加する」という警告も、完成形と同じ表記で表示します */
status = "⚠ 文字化けの可能性 → ページ画像で送信(API利用料金が増加しますので注意してください)";
} else if (attachedPdf.text) {
status = "テキスト抽出済み";
} else if (attachedPdf.pageImages && attachedPdf.pageImages.length > 0) {
status = "ページ画像化済み";
} else {
/* 解析がまだ終わっていない場合(処理中) */
status = "処理中…";
}
label.textContent = `📄 ${attachedPdf.name} (${formatBytes(attachedPdf.size)}) ${attachedPdf.pageRange} / ${status}`;
/* 削除ボタン(✕):押すと removeAttachedPdf で添付から削除する */
const remove = document.createElement("button");
remove.className = "attach-remove";
remove.textContent = "✕";
remove.title = "削除";
remove.addEventListener("click", removeAttachedPdf);
chip.appendChild(label);
chip.appendChild(remove);
attachmentPreviewArea.appendChild(chip);
}
/* --- テキストファイルのプレビュー ---
attachedTextFiles の1件ごとに「チップ」を作り、
ファイル名(またはパス)・サイズ・削除ボタンを表示します。 */
attachedTextFiles.forEach((file, i) => {
const chip = document.createElement("div");
chip.className = "attach-chip";
const label = document.createElement("span");
/* フォルダから選択した場合は「フォルダ名/ファイル名」のパスを表示し、
ホバーでフルパスが見えるようにします */
const displayPath = file.path !== file.name ? file.path : file.name;
label.textContent = `📝 ${displayPath} (${formatBytes(file.size)})`;
label.title = displayPath; // ホバーでフルパス表示
/* 削除ボタン(✕):押すと removeAttachedTextFile で添付から削除する */
const remove = document.createElement("button");
remove.className = "attach-remove";
remove.textContent = "✕";
remove.title = "削除";
remove.addEventListener("click", () => removeAttachedTextFile(i));
chip.appendChild(label);
chip.appendChild(remove);
attachmentPreviewArea.appendChild(chip);
});
}
/* --- 添付PDFの削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
添付PDF(attachedPdf)を取り消します。
PDFは「1件だけ」しか添付できないため、null に戻すだけでOKです。 */
function removeAttachedPdf() {
attachedPdf = null; // 添付PDFを取り消す
renderAttachmentPreview(); // 添付プレビューを更新
}
/* --- 添付テキストファイルの削除 ---
添付プレビューの「✕」ボタンが押された時に呼ばれ、
指定した位置(index)のテキストファイルを添付リストから削除します。
(removeAttachedImage と同じ、splice による指定位置の削除です) */
function removeAttachedTextFile(index) {
attachedTextFiles.splice(index, 1); // 指定した位置の1件を削除
renderAttachmentPreview(); // 添付プレビューを更新
}
/* --- すべての添付ファイルをクリアする ---
送信後に呼ばれ、すべての添付ファイルをクリアします。
あわせて、「隠しファイル入力」の値もクリアしておくことで、
同じファイルを続けて選択できるようにします。
(完成形の clearAttachments と同じ仕様) */
function clearAttachments() {
attachedImages = [];
attachedPdf = null;
attachedTextFiles = [];
renderAttachmentPreview();
/* 「隠しファイル入力」の値もクリアする
これをしないと、同じファイルを続けて選択した時に
change イベントが発火せず、添付できなくなってしまいます */
imageFileInput.value = "";
pdfFileInput.value = "";
textFileInput.value = "";
folderFileInput.value = "";
}
/* ============================================================
マルチモーダルメッセージの構築(ステップ④で追加)
------------------------------------------------------------
テキストと画像を組み合わせて、APIに送るデータを構築します。
「マルチモーダル(multimodal)」とは、
「複数の形式(モード)のデータを組み合わせる」という意味です。
差分⑪で拡張する sendMessage の中から呼ばれます。
(完成形の buildMultimodalMessage と同じ仕様)
============================================================ */
function buildMultimodalMessage(text) {
const parts = [];
let fullText = text;
/* ----- ① テキストファイルの内容を「質問文の前」に添える -----
添付テキストファイルがある場合、ファイル名と中身を
質問文の前に添えて、AIに「どのファイルの話か」を伝えます。 */
if (attachedTextFiles.length > 0) {
const fileContexts = attachedTextFiles.map(f => {
const header = f.path !== f.name
? `【ファイル: ${f.path}】`
: `【ファイル: ${f.name}】`;
return `${header}\n${f.content}\n`;
}).join('\n---\n\n');
fullText = `【添付テキストファイル ${attachedTextFiles.length}件】\n${fileContexts}\n\n【ユーザーの質問】\n${text}`;
}
/* ----- ② PDFのコンテキストを「質問文の前」に添える -----
添付PDFがある場合、buildPdfContext(差分⑩-3-3)で組み立てた
「前置き文+抽出テキスト(または画像の案内)」を添えます。 */
if (attachedPdf) {
fullText = buildPdfContext(attachedPdf) + "\n\n" + fullText;
}
/* ----- ③ 「画像データがあるか」を判定する -----
画像(attachedImages)またはPDFのページ画像(attachedPdf.pageImages)
がある場合は、マルチモーダル形式(配列)で送る必要があります。 */
const hasMedia = attachedImages.length > 0 || (attachedPdf && attachedPdf.pageImages && attachedPdf.pageImages.length > 0);
/* 画像データがない場合は、これまで通り「テキストのみ」の形で送る */
if (!hasMedia) {
return { role: "user", content: fullText };
}
/* ----- ④ 画像データがある場合は「マルチモーダル形式」で送る -----
content を配列にして、各要素を
{ type: "text", text: ... } や { type: "image_url", image_url: { url: ... } }
の形にするのが、マルチモーダル形式のルールです。 */
parts.push({ type: "text", text: fullText });
/* 添付画像を配列に追加する */
attachedImages.forEach(img => {
parts.push({
type: "image_url",
image_url: { url: img.dataUrl }
});
});
/* PDFのページ画像を配列に追加する(テキスト抽出不可時・文字化け時) */
if (attachedPdf && attachedPdf.pageImages) {
attachedPdf.pageImages.forEach(dataUrl => {
parts.push({
type: "image_url",
image_url: { url: dataUrl }
});
});
}
return { role: "user", content: parts };
}
/* ============================================================
【16】メッセージ送信(このアプリの主役となる処理)
------------------------------------------------------------
送信ボタンが押されてから、AIの回答が表示されるまでの
全体の流れを管理します。
※ステップ④で「添付ファイル対応」に拡張し、
テキストに加えて、添付した画像・PDF・テキストファイルも
送信できるようになりました。
============================================================ */
async function sendMessage() {
if (isGenerating) return; // 生成中の二重送信を防ぐ
clearError();
/* ----- ① 送信前のチェック(バリデーション) -----
「質問文が空」でも、「添付ファイルがある」場合は送信を許可します。
その場合は、既定の質問文(例:「添付ファイルの内容を説明してください。」)
を自動で使用します。 */
const text = messageInput.value.trim();
/* 質問文も添付ファイルも両方ない場合のみ、送信を中止する */
if (!text && attachedImages.length === 0 && !attachedPdf && attachedTextFiles.length === 0) {
showError("メッセージを入力してください。");
return;
}
/* APIキーは入力欄からその都度読み取る(保存はしない) */
apiKey = apiKeyInput.value.trim();
if (!apiKey) {
showError("APIキーが未入力です。設定パネルからRunpod APIキーを入力してください。");
settingsPanel.classList.remove("hidden");
return;
}
/* 質問文が空の場合は、「何が添付されているか」に応じた既定文を使う */
const userText = text || (
attachedTextFiles.length > 0 ? "添付ファイルの内容を説明してください。"
: attachedImages.length > 0 ? "添付ファイルを説明してください。"
: "添付PDFの内容を説明してください。"
);
const conv = getActiveConversation();
if (!conv) return;
/* ----- ② 添付のメタデータ(attachmentsMeta)を、会話履歴に保存する -----
「どのファイルを添付したか」の情報(ファイル名・形式・サイズ)を、
会話履歴に残します。
※添付の「中身」(画像データやPDFのテキスト)は保存しません。
これは、localStorage の容量を節約するためと、
「添付ファイルの内容は、送信したその時だけ参照される」という
仕様(【17】の解説を参照)に基づいています。 */
const attachmentsMeta = [];
attachedImages.forEach(img => attachmentsMeta.push({ name: img.name, type: img.type, size: img.size }));
if (attachedPdf) attachmentsMeta.push({ name: attachedPdf.name, type: "application/pdf", size: attachedPdf.size, pages: attachedPdf.pageRange });
attachedTextFiles.forEach(f => attachmentsMeta.push({ name: f.path, type: "text/plain", size: f.size }));
/* ----- ③ あなたの質問をデータに追加して画面に表示 ----- */
const userMsg = { role: "user", content: userText, attachmentsMeta };
conv.messages.push(userMsg);
/* この会話最初の質問なら、タイトルを自動設定する */
if (conv.messages.filter(m => m.role === "user").length === 1) {
generateConversationTitle(userText);
}
/* ----- ④ API送信用メッセージを「buildMultimodalMessage」で構築する -----
差分⑩-4で追加した buildMultimodalMessage を呼んで、
テキストと画像を組み合わせた「マルチモーダル形式」のデータを作ります。
添付がない場合は、これまで通り「テキストのみ」の形になります。 */
const apiUserMessage = buildMultimodalMessage(userText);
/* 今回の送信用ペイロードを、会話データに一時的に保持する
(buildChatCompletionRequest で、会話履歴の末尾をこれに差し替えるために使う。
これは保存しない一時的なデータです) */
conv.apiPayloadMessage = apiUserMessage;
saveConversations();
renderActiveConversation();
/* 入力欄を空にして高さをリセット */
messageInput.value = "";
autoResizeTextarea();
/* ----- ⑤ 送信後に、すべての添付ファイルをクリアする -----
差分⑩-4で追加した clearAttachments を呼んで、
すべての添付ファイル(画像・PDF・テキスト)をクリアします。
あわせて、「隠しファイル入力」の値もクリアされるため、
同じファイルを続けて選択できるようになります。 */
clearAttachments();
/* ----- ⑥ 生成開始の準備 ----- */
isGenerating = true;
requestWakeLock(); // 生成中はスリープを抑制(対応ブラウザのみ)
responseStartTime = performance.now(); // 応答計測の開始
hasRetriedNonStream = false;
fullResponseText = "";
renderScheduled = false;
abortController = new AbortController(); // 通信を途中で止める「中止装置」
updateUiState(); // 送信→停止ボタンに切り替え
/* 処理状況パネルを作成 */
currentProgressState = { logs: [], lastStatus: "" };
currentProgressPanel = createProgressPanel();
showProgressStatus("リクエストを準備しています…");
/* ----- ⑦ AIの回答用の「空の器」を先に表示する ----- */
const streamWrap = document.createElement("div");
streamWrap.className = "message assistant";
streamWrap.innerHTML = `
<div class="msg-avatar">K</div>
<div class="msg-body">
<div class="msg-role">${escapeHtml(getModelDisplayName())}</div>
<div class="msg-content markdown-body"></div>
</div>`;
chatContainer.appendChild(streamWrap);
currentStreamEl = streamWrap.querySelector(".msg-content");
/* 思考モードが有効なら思考過程パネルを作成 */
fullReasoningText = "";
if (isThinkingEnabled()) {
currentReasoningEl = createReasoningPanel(streamWrap);
} else {
currentReasoningEl = null;
}
scrollToBottom(true);
/* ----- ⑧ API通信(ストリーミング)を実行 -----
引数を「apiUserMessage」に変更しました。
これにより、添付した画像やPDFのページ画像も、APIに送られます。 */
try {
await streamChatCompletion(conv, apiUserMessage, true);
} catch (err) {
if (err && err.name === "AbortError") {
if (isGenerating) {
/* タイムアウト等による中断:受信済みの内容を保持して確定 */
fullResponseText += "\n\n---\n*(タイムアウトまたは接続が中断されました)*";
showProgressStatus("接続が中断されました");
finishGeneration(true);
}
return;
}
/* ストリーミング失敗時:通常モード(stream:false)で一度だけ再試行 */
console.error("ストリーミングエラー:", sanitizeLog(err));
if (!hasRetriedNonStream) {
hasRetriedNonStream = true;
showProgressStatus("ストリーミングに失敗したため、通常モードで再試行しています…");
try {
await streamChatCompletion(conv, apiUserMessage, false);
return;
} catch (err2) {
handleRequestError(err2);
finishGeneration(false);
return;
}
}
handleRequestError(err);
finishGeneration(false);
}
}
/* --- エラーオブジェクトの安全なログ出力(APIキーを含めない) --- */
function sanitizeLog(err) {
try {
const msg = String(err && err.message ? err.message : err)
.replace(/Bearer\s+\S+/gi, "Bearer [非表示]");
return msg;
} catch (e) { return "unknown error"; }
}
/* --- エラーを画面に表示し、処理状況パネルも更新する --- */
function handleRequestError(err) {
const msg = String(err && err.message ? err.message : "不明なエラー");
showError(msg);
showProgressStatus("エラーが発生しました");
}
/* --- 生成の終了処理(成功・失敗・停止のすべてで実行) --- */
function finishGeneration(success) {
releaseWakeLock(); // スリープ抑制を解除
isGenerating = false;
abortController = null;
updateUiState(); // 停止→送信ボタンに戻す
/* 応答計測を確定 */
if (responseStartTime > 0) {
lastResponseTime = performance.now() - responseStartTime;
lastReasoningLength = fullReasoningText.length;
lastContentLength = fullResponseText.length;
}
/* 受信した回答を会話データに保存
※attachmentsMeta は「どのファイルを添付したか」の情報です。
ユーザーの質問メッセージと同じデータ構造に揃えるため、
AIの回答にも「空の配列」を入れています。
※apiPayloadMessage は差分⑪で設定した「API送信用の一時データ」です。
回答が確定した今、役目を終えたため消去します(後片付け)。
localStorage には保存されない設計のためリロードでも消えますが、
メモリ上にも残さないよう、確実に消しておきます。 */
const conv = getActiveConversation();
if (conv && fullResponseText) {
conv.messages.push({ role: "assistant", content: fullResponseText, attachmentsMeta: [] });
delete conv.apiPayloadMessage;
saveConversations();
}
/* 応答計測の結果を、回答の最後に追加(完成形と同じ形式) */
if (responseStartTime > 0 && conv && conv.messages.length > 0) {
const lastMsg = conv.messages[conv.messages.length - 1];
if (lastMsg.role === "assistant") {
const modelName = getModelDisplayName();
const thinkingStatus = isThinkingEnabled() ? "思考ON" : "思考OFF";
const timeSec = (lastResponseTime / 1000).toFixed(1);
lastMsg.metricsText =
`${modelName} / ${thinkingStatus} / 応答時間: ${timeSec}秒 / 思考過程: ${lastReasoningLength}文字 / 本文: ${lastContentLength}文字(マークダウン記号も含む)`;
saveConversations();
}
}
/* ストリーミング用の「器」を、確定した回答で描画し直す */
currentStreamEl = null;
if (conv) renderActiveConversation();
if (success) showProgressStatus("完了しました");
responseStartTime = 0; // 計測開始時刻をリセット
}
/* ============================================================
【17】リクエスト構築(APIに送るデータを組み立てる)
------------------------------------------------------------
先頭にシステムプロンプトを付け、会話履歴を時系列で並べ、
モデルやパラメータを指定します。
※ステップ②で「設定パネルの値を読む形」に拡張し、
kimi-k2.7-code・kimi-k2.6 の分岐を追加しました。
============================================================ */
function buildChatCompletionRequest(conv, userText, useStream) {
const systemPrompt = getCurrentSystemPrompt();
const messages = [{ role: "system", content: systemPrompt }];
/* これまでの会話を時系列で追加 */
conv.messages.forEach(m => {
messages.push({ role: m.role, content: m.content });
});
/* 会話履歴の末尾(今回の質問)を、apiPayloadMessage に差し替える
画面上に表示する質問文(userText)と、実際にAPIに送る質問文
(apiUserMessage:ファイルの中身を含んだマルチモーダル形式)が
異なる場合があるため、末尾を差し替えます。 */
if (conv.apiPayloadMessage) {
messages.pop(); // 末尾(userText)を取り除く
messages.push(conv.apiPayloadMessage); // apiPayloadMessage を末尾に追加
}
const model = getSelectedModel();
const request = {
model: model, // モデル名(設定パネルの値)
messages: messages, // 会話履歴
temperature: 1,
max_tokens: parseInt(maxTokensInput.value, 10) || 8192, // 設定パネルの値
stream: useStream // ストリーミングON/OFF
};
/* Kimi K3:常時思考モデル+reasoning_effort(設定パネルの値)を送信 */
if (model === "kimi-k3") {
request.reasoning_effort = normalizeReasoningEffort(reasoningEffortSelect.value);
}
/* Kimi K2.7 Code:思考モード固定ON(reasoning_effortは送信しない) */
if (model === "kimi-k2.7-code") {
request.thinking = { type: "enabled", keep: "all" };
}
/* Kimi K2.6:思考モードON/OFF(設定パネルの値に応じて切り替え) */
// ※ OFF の場合は thinking パラメータ自体を送信しない。
// { type: "disabled" } を明示送信すると Runpod 側で
// 500エラー/空ストリームが返るため、省略して
// APIのデフォルト(非思考)動作を使う。
if (model === "kimi-k2.6" && thinkingModeSelect.value === "enabled") {
request.thinking = { type: "enabled" };
}
return request;
}
/* ============================================================
【18】SSEストリーミング処理(API通信の中核)
------------------------------------------------------------
「回答が少しずつ届く」方式(SSE:Server-Sent Events)で
RunpodのAPIと通信します。
============================================================ */
async function streamChatCompletion(conv, userText, useStream) {
showProgressStatus("AIが質問を分析しています…");
let lastFinishReason = null; // "stop" / "length" / null
const requestBody = buildChatCompletionRequest(conv, userText, useStream);
/* タイムアウト用タイマー(設定秒数経過したら通信を中止) */
const timeoutId = setTimeout(() => {
if (abortController) abortController.abort("timeout");
}, REQUEST_TIMEOUT_MS);
let response;
try {
response = await fetch(API_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + apiKey // APIキーによる利用者の証明
},
body: JSON.stringify(requestBody),
signal: abortController.signal // 中止装置を紐付け
});
} catch (e) {
clearTimeout(timeoutId);
if (e.name === "AbortError") throw e;
throw new Error("ネットワークエラー、またはCORSエラーが発生しました。接続先とブラウザの設定を確認してください。");
}
/* サーバーからの「お返事の状態」を確認 */
if (!response.ok) {
clearTimeout(timeoutId);
let detail = "";
try {
const errJson = await response.json();
detail = errJson.error && errJson.error.message ? errJson.error.message : "";
} catch (e) { /* JSONでない場合は無視 */ }
const safeDetail = String(detail).replace(/Bearer\s+\S+/gi, "[非表示]");
if (response.status === 401 || response.status === 403) {
throw new Error("認証エラー(HTTP " + response.status + "):APIキーが不正です。" + (safeDetail ? " " + safeDetail : ""));
} else if (response.status >= 500) {
throw new Error("サーバーエラー(HTTP " + response.status + "):しばらく待って再試行してください。" + (safeDetail ? " " + safeDetail : ""));
} else {
throw new Error("APIエラー(HTTP " + response.status + "):" + (safeDetail || "リクエストが拒否されました。"));
}
}
/* 回答の受け取り(ストリーミング or 通常) */
if (useStream) {
lastFinishReason = await readSseStream(response);
} else {
await readNonStreamResponse(response);
}
clearTimeout(timeoutId);
/* 回答が1文字も来なかった場合のガード */
if (!fullResponseText.trim()) {
if (lastFinishReason === "length") {
throw new Error(
"回答本文が生成される前に max_tokens(現在の設定: " +
(parseInt(maxTokensInput.value, 10) || 8192) +
")の上限に達しました。思考過程でトークンを使い切った可能性があります。設定パネルで max_tokens を大きくしてください。"
);
}
throw new Error("空の回答が返されました。モデル名やmax_tokensの設定を確認してください。");
}
/* 最終レンダリング(Markdown解析・色付け) */
showProgressStatus("回答を整形しています…");
finalizeAssistantResponse(fullResponseText);
showProgressStatus("コードをハイライトしています…");
finishGeneration(true);
}
/* --- ストリーミング(SSE)で回答を少しずつ受け取る --- */
async function readSseStream(response) {
if (!response.body) {
throw new Error("レスポンスボディを読み取れません(ストリーム非対応)。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8"); // バイト列 → 文字列
let buffer = ""; // 受信途中のデータを一時的にためる場所
let firstChunkReceived = false;
let codeFenceDetected = false;
let lastFinishReason = null;
/* SSEの1行を解析して反映する共通処理 */
const processLine = (line) => {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data:")) return;
const dataStr = trimmed.slice(5).trim(); // 「data:」の後ろを取り出す
/* 終了の合図「[DONE]」(配列の文字コードで組み立てて誤検出を防ぐ) */
const doneToken = String.fromCharCode(91, 68, 79, 78, 69, 93);
if (dataStr === doneToken) return;
let json;
try {
json = JSON.parse(dataStr); // JSON文字列 → データに変換
} catch (e) {
return; // 壊れたチャンクはスキップ
}
if (json.error) {
const msg = String(json.error.message || "不明なエラー")
.replace(/Bearer\s+\S+/gi, "[非表示]");
throw new Error("APIエラー:" + msg);
}
/* choices[0].delta に「今回届いたかけら」が入っている */
const choice = json.choices && json.choices[0];
if (choice && typeof choice.finish_reason === "string") {
lastFinishReason = choice.finish_reason; // 終了理由を記録
}
if (choice && choice.delta) {
const delta = choice.delta;
/* 思考過程(reasoning_content)を蓄積して表示を更新 */
if (typeof delta.reasoning_content === "string") {
fullReasoningText += delta.reasoning_content;
updateReasoningPanel();
}
/* 回答本文(content)を蓄積 */
if (typeof delta.content === "string") {
if (!firstChunkReceived) {
firstChunkReceived = true;
showProgressStatus("回答を受信しています…");
}
if (!codeFenceDetected && (fullResponseText + delta.content).includes("```")) {
codeFenceDetected = true;
showProgressStatus("コード例を生成しています…");
}
handleStreamEvent(delta.content);
}
}
};
try {
/* 通信が終わるまで、読み取りを繰り返す */
while (true) {
const { done, value } = await reader.read();
if (done) break; // 全部読み終えたらループを抜ける
buffer += decoder.decode(value, { stream: true });
/* 改行で区切って1行ずつ処理。
最後の行は途中で切れている可能性があるので、
pop() で取り出して次回のバッファに残す。 */
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
/* ストリーム終了時に残ったバッファを処理 */
if (buffer.trim()) processLine(buffer);
} catch (e) {
if (e.name === "AbortError") throw e;
throw new Error("ストリーム読み取りエラー:" + sanitizeLog(e));
} finally {
try { reader.releaseLock(); } catch (e) { /* 無視 */ }
}
return lastFinishReason;
}
/* --- 通常モード(非ストリーミング)で回答を一括で受け取る --- */
async function readNonStreamResponse(response) {
let json;
try {
json = await response.json();
} catch (e) {
throw new Error("APIレスポンス形式エラー:JSONとして解析できませんでした。");
}
const parts = extractPartsFromJson(json);
const finishReason = json && json.choices && json.choices[0]
? json.choices[0].finish_reason
: null;
/* 非ストリーミングでも思考過程が返る場合に対応 */
if (parts.reasoning) {
fullReasoningText += parts.reasoning;
updateReasoningPanel();
}
if (!parts.content) {
if (finishReason === "length") {
throw new Error(
"回答本文が生成される前に max_tokens(現在の設定: " +
(parseInt(maxTokensInput.value, 10) || 8192) +
")の上限に達しました。思考過程でトークンを使い切った可能性があります。設定パネルで max_tokens を大きくしてください。"
);
}
throw new Error("APIレスポンス形式エラー:回答本文が見つかりませんでした。");
}
showProgressStatus("回答を受信しています…");
handleStreamEvent(parts.content);
}
/* --- APIの応答データから「本文」と「思考過程」を取り出す --- */
function extractPartsFromJson(json) {
let content = "";
let reasoning = "";
if (!json) return { content, reasoning };
const choice = json.choices && json.choices[0];
if (choice) {
const src = choice.delta || choice.message || {};
if (typeof src.content === "string") content = src.content;
if (typeof src.reasoning_content === "string") reasoning = src.reasoning_content;
}
if (typeof json.content === "string") content = json.content;
return { content, reasoning };
}
/* --- 届いた回答のかけらを受け取って画面更新を予約する --- */
function handleStreamEvent(text) {
if (!text) return;
appendStreamText(text);
}
/* ============================================================
【19】画面更新の最適化(描画ループ)
------------------------------------------------------------
回答のかけらが届くたびに画面を書き換えるとPCが重くなるため、
「設定した間隔(80ms)ごとにまとめて描画」します。
これが設定項目「画面更新間隔 (ms)」の正体です。
============================================================ */
function appendStreamText(text) {
fullResponseText += text;
schedulePartialRender();
}
function schedulePartialRender() {
if (renderScheduled) return; // 予約済みなら二重に予約しない
renderScheduled = true;
setTimeout(() => {
renderPartialResponse(fullResponseText);
renderScheduled = false;
}, getRenderInterval());
}
/* --- ストリーミング中はプレーンテキストで軽量に表示する --- */
function renderPartialResponse(text) {
if (!currentStreamEl) return;
currentStreamEl.textContent = text;
/* ストリーミング中であることが分かるカーソル(▍)を付ける */
const cursor = document.createElement("span");
cursor.className = "stream-cursor";
currentStreamEl.appendChild(cursor);
scrollToBottom();
}
/* ============================================================
【20】最終レンダリング(回答が全部届いたあとの仕上げ)
------------------------------------------------------------
①Markdown解析 → ②安全化 → ③コピーボタン → ④色付け、の順。
============================================================ */
function finalizeAssistantResponse(fullText) {
if (!currentStreamEl) return;
currentStreamEl.innerHTML = renderMarkdown(fullText);
addCopyButtons(currentStreamEl);
applySyntaxHighlight(currentStreamEl);
scrollToBottom(true);
}
/* ============================================================
【21】Wake Lock API(生成中のスリープ抑制)
------------------------------------------------------------
生成中に画面がスリープしないよう抑制します。
対応ブラウザ(Chrome / Edge / Safari 16.4以降)のみ動作し、
HTTPS環境でのみ有効です。非対応でも生成は継続します。
============================================================ */
let wakeLock = null;
async function requestWakeLock() {
if (!("wakeLock" in navigator)) return; // 非対応ブラウザは何もしない
try {
wakeLock = await navigator.wakeLock.request("screen");
wakeLock.addEventListener("release", () => { wakeLock = null; });
} catch (e) { /* 取得失敗(低バッテリー等)は無視して生成は継続 */ }
}
async function releaseWakeLock() {
if (wakeLock) {
try { await wakeLock.release(); } catch (e) { /* 無視 */ }
wakeLock = null;
}
}
/* ============================================================
【22】生成停止(停止ボタン)
============================================================ */
function cancelCurrentRequest() {
if (!isGenerating) return;
if (abortController) {
try { abortController.abort(); } catch (e) { /* 無視 */ }
}
fullResponseText += "\n\n---\n*(生成を停止しました)*";
showProgressStatus("生成を停止しました");
finishGeneration(true); // 受信済みの内容を保持して確定
}
/* ============================================================
【23】回答の再生成(最後の回答を作り直す)
============================================================ */
function regenerateResponse(assistantMsg) {
if (isGenerating) return;
const conv = getActiveConversation();
if (!conv) return;
const idx = conv.messages.indexOf(assistantMsg);
if (idx < 1) return;
/* 直前のユーザーメッセージを探す */
let userMsg = null;
for (let i = idx - 1; i >= 0; i--) {
if (conv.messages[i].role === "user") { userMsg = conv.messages[i]; break; }
}
if (!userMsg) return;
/* 当該回答以降と、その質問を取り除いて再送信する */
conv.messages = conv.messages.slice(0, idx);
conv.messages = conv.messages.filter(m => m !== userMsg);
saveConversations();
messageInput.value = userMsg.content;
renderActiveConversation();
sendMessage();
}
/* ============================================================
【24】イベント登録(ボタンやキー操作と関数を結び付ける)
------------------------------------------------------------
※ステップ②で、新しく追加した設定項目(モデル名・
reasoning_effort・max_tokens など)のイベントを追加しました。
============================================================ */
function registerEvents() {
/* 送信ボタン / 停止ボタン */
sendBtn.addEventListener("click", sendMessage);
stopBtn.addEventListener("click", cancelCurrentRequest);
/* Enterで送信 / Shift+Enterで改行
(isComposing は日本語入力の変換確定Enterを除外するための判定) */
messageInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
sendMessage();
}
});
messageInput.addEventListener("input", autoResizeTextarea);
/* 自動スクロール検知(ユーザーが上へスクロールしたら追従を止める) */
chatContainer.addEventListener("scroll", () => {
const threshold = 60;
autoScrollEnabled =
chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight < threshold;
});
/* 新しいチャット */
document.getElementById("newChatBtn").addEventListener("click", () => {
if (isGenerating) return;
createConversation();
topbarTitle.textContent = "新しいチャット";
});
/* サイドバーの開閉(モバイルとPCで動作を分ける) */
document.getElementById("toggleSidebarBtn").addEventListener("click", () => {
if (window.innerWidth <= 768) {
sidebar.classList.toggle("open");
} else {
sidebar.classList.toggle("collapsed");
}
});
/* 全履歴削除 */
document.getElementById("clearAllBtn").addEventListener("click", () => {
if (isGenerating) return;
if (!confirm("すべての会話履歴を削除しますか?この操作は元に戻せません。")) return;
conversations = [];
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* 無視 */ }
createConversation();
topbarTitle.textContent = "新しいチャット";
});
/* テーマ(ダークモード)切り替え */
document.getElementById("darkModeBtn").addEventListener("click", () => {
document.body.classList.toggle("dark");
updateDarkModeBtn();
saveUiSettings();
});
/* 設定パネルの開閉 */
document.getElementById("settingsToggleBtn").addEventListener("click", () => {
settingsPanel.classList.toggle("hidden");
});
/* APIキー入力(変数に保持するだけ。保存はしない) */
apiKeyInput.addEventListener("input", () => {
apiKey = apiKeyInput.value.trim();
});
/* ============================================================
ステップ②で追加した設定項目のイベント登録
------------------------------------------------------------
設定が変更された時に、設定を保存したり、
モデルに応じた表示の切り替えを行います。
============================================================ */
/* モデル名の変更時:
モデルに応じた設定項目の表示を切り替えて、設定を保存する */
modelInput.addEventListener("change", () => {
updateModelSpecificSettingsUI(); // 差分④で追加した表示切替関数
saveUiSettings(); // 設定を保存
});
/* 思考モード(kimi-k2.6)の変更時:
data 属性にも値を保存してから、設定を保存する */
thinkingModeSelect.addEventListener("change", () => {
if (getSelectedModel() === "kimi-k2.6") {
/* Kimi K2.6 での選択値を data 属性に覚えておく
(他のモデルに切り替えても、元の選択値を保持するため) */
thinkingModeSelect.dataset.k26Value = thinkingModeSelect.value;
}
saveUiSettings();
});
/* max_tokens の変更時:
1以上の整数かを検証(バリデーション)してから保存する */
maxTokensInput.addEventListener("change", () => {
if (!validateMaxTokens(maxTokensInput.value)) {
maxTokensInput.value = 8192; // 不正な値はデフォルトに戻す
maxTokensInput.focus();
return; // 保存しない
}
saveUiSettings();
});
/* その他の設定項目(reasoning_effort・画面更新間隔)の変更時:
まとめて「設定を保存」するイベントを登録する */
[reasoningEffortSelect, renderIntervalInput].forEach(el => {
el.addEventListener("change", saveUiSettings);
});
/* APIタイムアウトの変更時:
入力のたびにヒントを更新する(input イベントは値が変わるたびに発火) */
requestTimeoutInput.addEventListener("input", () => {
saveUiSettings();
updateSettingHints();
});
/* ============================================================
ステップ③で追加したカスタムプロンプト関連のイベント登録
------------------------------------------------------------
チェックボックスの切り替え・テキストエリアの編集・
「デフォルトに戻す」「コピー」ボタンのイベントを登録します。
============================================================ */
/* カスタムプロンプトモードの切り替え時:
編集エリアの表示を切り替えて、設定を保存する */
customPromptModeToggle.addEventListener("change", () => {
updateCustomPromptVisibility(); // 差分④で追加した表示切替関数
saveUiSettings(); // 設定を保存
});
/* カスタムプロンプトの編集時:
入力のたびに設定を保存する(input イベントは値が変わるたびに発火) */
customSystemPrompt.addEventListener("input", () => {
saveUiSettings();
});
/* 「デフォルトに戻す」ボタン:
確認ダイアログを表示してから、デフォルトプロンプトに戻す
(完成形と同じ確認メッセージを採用) */
resetPromptBtn.addEventListener("click", () => {
if (confirm("カスタムプロンプトをデフォルトに戻しますか?\n現在の編集内容は失われます。")) {
customSystemPrompt.value = DEFAULT_SYSTEM_PROMPT;
saveUiSettings();
}
});
/* 「コピー」ボタン:
編集エリアの内容をクリップボードにコピーする。
成功したらボタンの表示を一時的に「✓ コピーしました」に変える */
copyPromptBtn.addEventListener("click", () => {
navigator.clipboard.writeText(customSystemPrompt.value).then(() => {
const originalText = copyPromptBtn.textContent;
copyPromptBtn.textContent = "✓ コピーしました";
setTimeout(() => {
copyPromptBtn.textContent = originalText;
}, 2000);
}).catch(() => {
alert("コピーに失敗しました。手動で選択してコピーしてください。");
});
});
/* ============================================================
ステップ④で追加した添付関連のイベント登録
------------------------------------------------------------
添付ボタン4つ(🖼📄📝📁)のクリックイベントと、
隠しファイル入力の change イベント、
そしてアップロード上限の設定項目の変更時イベントを登録します。
============================================================ */
/* ----- 添付ボタン4つ:隠しファイル入力をクリックする ----- */
/* 画像添付ボタン(🖼)を押すと、隠しファイル入力(imageFileInput)を開く */
$("#imageAttachBtn").addEventListener("click", () => imageFileInput.click());
/* PDF添付ボタン(📄)を押すと、隠しファイル入力(pdfFileInput)を開く */
$("#pdfAttachBtn").addEventListener("click", () => pdfFileInput.click());
/* テキストファイル添付ボタン(📝)を押すと、隠しファイル入力(textFileInput)を開く */
$("#textAttachBtn").addEventListener("click", () => textFileInput.click());
/* フォルダ添付ボタン(📁)を押すと、隠しファイル入力(folderFileInput)を開く */
$("#folderAttachBtn").addEventListener("click", () => folderFileInput.click());
/* ----- 隠しファイル入力の change イベント:ファイルが選択された時に呼ばれる ----- */
/* 画像が選択された時:handleImageUpload(差分⑩-1)に渡す */
imageFileInput.addEventListener("change", (e) => {
handleImageUpload(Array.from(e.target.files || []));
});
/* PDFが選択された時:handlePdfUpload(差分⑩-3-1)に渡す(1ファイルのみ) */
pdfFileInput.addEventListener("change", (e) => {
const file = (e.target.files || [])[0];
if (file) handlePdfUpload(file);
});
/* テキストファイルが選択された時:handleTextFiles(差分⑩-2)に渡す */
textFileInput.addEventListener("change", (e) => {
handleTextFiles(Array.from(e.target.files || []));
});
/* フォルダが選択された時:handleFolderUpload(差分⑩-2)に渡す */
folderFileInput.addEventListener("change", (e) => {
handleFolderUpload(e.target.files || []);
});
/* ----- アップロード上限の設定項目の変更時イベント -----
設定が変更された時に、設定を保存し、ヒント表示を更新します。
saveUiSettings(差分⑨-3)の中で applyUploadSettings() が呼ばれるため、
「保存」「変数への反映」の2つがまとめて行われます。 */
[maxImageSizeInput, maxPdfSizeInput, maxPdfPagesInput, maxTextSizeInput, requestTimeoutInput].forEach(el => {
el.addEventListener("change", () => {
saveUiSettings();
updateSettingHints();
});
});
/* ============================================================
ドラッグ&ドロップのイベント登録
------------------------------------------------------------
全画面のドラッグ&ドロップ(ドロップオーバーレイの表示・
ファイルの振り分け)を登録します。
ドロップされたファイルは、distributeDroppedFiles(この後に追加)で
「画像・PDF・テキストファイル」の種類ごとに振り分けられます。
============================================================ */
let dragCounter = 0; // ドロップオーバーレイの表示制御用カウンター
/* ファイルが画面に「入ってきた」時:
ドロップオーバーレイを表示する */
document.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
if (dragCounter === 1) {
dropOverlay.classList.add('visible');
}
});
/* ファイルが画面から「出ていった」時:
ドロップオーバーレイを隠す */
document.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter === 0) {
dropOverlay.classList.remove('visible');
}
});
/* ドラッグ中のデフォルト動作(ブラウザがファイルを開こうとする動作)をキャンセルする */
document.addEventListener('dragover', (e) => {
e.preventDefault();
});
/* ファイルが「ドロップ」された時:
ファイルを「種類ごと」に振り分けて、それぞれの添付処理に渡す */
document.addEventListener('drop', async (e) => {
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.remove('visible');
/* ドロップされたファイルを取得する */
const files = Array.from(e.dataTransfer.files || []);
/* フォルダのドロップはブラウザの制限により不安定なため、
フォルダ選択ボタンを自動でクリックしてダイアログを開く */
const items = Array.from(e.dataTransfer.items || []);
const hasDirectoryEntry = items.some(it => {
try {
const entry = it.webkitGetAsEntry ? it.webkitGetAsEntry() : null;
return entry && entry.isDirectory;
} catch (err) {
return false;
}
});
if (hasDirectoryEntry) {
showError("フォルダのドラッグ&ドロップはブラウザの制限により利用できません。フォルダ選択ダイアログを開きます。");
setTimeout(() => {
folderFileInput.click();
}, 100);
return;
}
/* フォルダ名と同じ名前の空ファイルが含まれる場合の除外処理
(一部のブラウザでフォルダをドロップすると、フォルダ名の0バイトファイルが作成される場合がある) */
const validFiles = files.filter(f => {
if (f.size === 0 && !f.name.includes('.')) {
return false;
}
return true;
});
if (validFiles.length === 0) {
showError("フォルダまたは無効なファイルがドロップされました。ファイルを個別に選択するか、フォルダ選択ボタン(📁)を使用してください。");
return;
}
/* ファイルを「種類ごと」に振り分けて、それぞれの添付処理に渡す */
distributeDroppedFiles(validFiles);
});
/* ============================================================
クリップボードからの貼り付けのイベント登録
------------------------------------------------------------
クリップボードからの画像・テキストファイルの貼り付けを登録します。
これが、差分③の解説で「貼り付け・ドラッグ&ドロップにも対応」
と案内した、貼り付け機能の正体です。
============================================================ */
messageInput.addEventListener("paste", (e) => {
const items = Array.from(e.clipboardData.items || []);
/* 画像の貼り付け(スクリーンショットなど)
クリップボードに画像データがある場合は、それをファイルとして添付する */
const imageItems = items.filter(it => ALLOWED_IMAGE_TYPES.includes(it.type));
if (imageItems.length > 0) {
const files = imageItems.map(it => it.getAsFile()).filter(Boolean);
if (files.length > 0) {
e.preventDefault(); // テキストとしての貼り付けをキャンセル
handleImageUpload(files);
}
}
/* テキストファイルの貼り付け
クリップボードにテキストファイルがある場合は、それを添付する */
const textItems = items.filter(it => {
if (it.kind === 'file') {
const file = it.getAsFile();
return file && isTextFile(file);
}
return false;
});
if (textItems.length > 0) {
const files = textItems.map(it => it.getAsFile()).filter(Boolean);
if (files.length > 0) {
e.preventDefault();
handleTextFiles(files);
}
}
});
/* ============================================================
ツールチップ補助(モバイル長押し+位置自動調整)
data-tooltip 属性を持つ全要素に対して設定します。
============================================================ */
let tooltipTimer = null;
let tooltipTarget = null;
function showTooltip(el) {
hideTooltip();
adjustTooltipPosition(el);
el.classList.add("tooltip-visible");
tooltipTarget = el;
}
function hideTooltip() {
if (tooltipTarget) {
tooltipTarget.classList.remove("tooltip-visible");
tooltipTarget = null;
}
}
/* 画面上部のスペースが足りない場合は下向きに切り替える */
function adjustTooltipPosition(el) {
el.classList.remove("tooltip-below");
const rect = el.getBoundingClientRect();
if (rect.top < 140) el.classList.add("tooltip-below");
}
document.querySelectorAll("[data-tooltip]").forEach(el => {
/* モバイル長押し対応(500ms長押しで表示) */
el.addEventListener("touchstart", () => {
tooltipTimer = setTimeout(() => showTooltip(el), 500);
}, { passive: true });
el.addEventListener("touchend", () => {
clearTimeout(tooltipTimer);
if (tooltipTarget === el) setTimeout(hideTooltip, 2500);
});
el.addEventListener("touchmove", () => {
clearTimeout(tooltipTimer);
hideTooltip();
}, { passive: true });
/* PCホバー時:位置を自動調整 */
el.addEventListener("mouseenter", () => adjustTooltipPosition(el));
});
/* 画面の他の場所をタップしたらツールチップを閉じる */
document.addEventListener("touchstart", (e) => {
if (tooltipTarget && !tooltipTarget.contains(e.target)) {
hideTooltip();
}
}, { passive: true });
}
/* ============================================================
【25】アプリの起動(初期化)
------------------------------------------------------------
ページを開いた時に一番最初に実行される関数。
============================================================ */
function init() {
loadUiSettings(); // ① 保存されたテーマと設定を適用
updateDarkModeBtn(); // ② テーマボタンの表示を更新
loadConversations(); // ③ 保存された会話履歴を読み込む
registerEvents(); // ④ ボタンやキー操作の設定
updateUiState(); // ⑤ 送信/停止ボタンを初期状態に
/* ⑥ トップバーに現在の会話タイトルを表示 */
const conv = getActiveConversation();
if (conv) topbarTitle.textContent = conv.title;
/* ※ステップ④でも以下は未実装です(後のステップで追加)
・ウェルカム画面(ステップ⑦)
・質問ナビゲーションタイムライン(ステップ⑥)
・エクスポート/インポート(ステップ⑤) */
}
/* ------------------------------------------------------------
アプリの起動
------------------------------------------------------------
DOMの準備ができてから init を実行します。
------------------------------------------------------------ */
document.addEventListener("DOMContentLoaded", init);
</script>
</body>
</html>(ココにステップ④完成時のアプリ画面のスクショを貼る)
次のステップ⑤の予告
次のステップ⑤では、エクスポート・インポート機能を実装します。
これまでブラウザの localStorage に保存していた「会話履歴」を、JSONファイルとして出力(エクスポート)し、別のパソコンやブラウザで読み込み(インポート)できるようにします。
これにより、「パソコンを買い替えた時」や「別のブラウザで作業したい時」にも、会話履歴を持ち運べるようになります。
ここでも、ステップ④で学んだ「添付の『中身』は保存しない」という設計が効いてきます。
エクスポートされるJSONファイルには、「会話の内容」と「添付のメタデータ(ファイル名・サイズ)」のみが含まれ、添付の「中身」(画像データやPDFのテキスト)は含まれません。
これにより、JSONファイルのサイズを抑えつつ、「どのファイルが添付されていたか」の情報は残る、無駄のない設計になっています。
それでは次のセクションから、ステップ⑤:エクスポート・インポートを始めましょう。
【ステップ⑤】AIチャットアプリの拡張 – エクスポート・インポート
:チャット履歴のエクスポート・インポート機能の作り方【Runpod×Kimi入門⑤】
Runpod API×KimiのAIチャットアプリ開発入門・第5回。
会話履歴をJSONファイルにエクスポート(出力)し、インポート(読込)で復元できる機能を追加。差分コード付きで初心者にも分かりやすく解説します。
by 子供プログラマー
Runpod APIキーですぐに使えるウェブアプリ
:【ウェブアプリ】AIチャットアプリ:Runpod API専用 – Kimi編(Kimi K3対応)