カスタムシステムプロンプトの作り方|AIの性格を自分で設定【Runpod×Kimi入門③】

カスタムシステムプロンプトの作り方|AIの性格を自分で設定【Runpod×Kimi入門③】


 

【プログラミング – ステップ③】AIチャットアプリ開発入門講座 – Runpod API専用 – Kimi編

 

 

このステップでやること(全体像)

 

ステップ③では、ステップ②で完成したアプリを拡張し、「カスタムシステムプロンプト」を設定パネル(画面)から編集できるようにします。

 




 
 

「システムプロンプト」とは、会話の最初にAIへこっそり渡される「あなたはこう振る舞ってください」という指示書のことです。

ステップ①から、このアプリには「プログラミング支援専門のAIアシスタント」という指示書が、コード内に固定値として書かれていました(SYSTEM_PROMPT)。

 

このステップ③では、その指示書を画面から自由に書き換えられるようにして、AIの性格や役割を自分好みにカスタマイズできるようにします。

 

 設定項目 ステップ②
 (固定値)
 ステップ③(この記事でやること)
 システムプロンプト
 (AIへの指示書)
 プログラミング
 支援専門の
 AIアシスタント
 (コード内で
  固定)
 「カスタムシステムプロンプトモード」を
 有効化すると、画面の編集エリアから
 自由に書き換え可能に。
 「デフォルトに戻す」「コピー」の
 ボタン付き

 

変更したプロンプトは、他の設定と同じく localStorage に保存し、画面を更新しても残るようにします。

 

ステップ③完成時の設定パネル - カスタムプロンプトモード有効時:カスタムシステムプロンプトの作り方|AIの性格を自分で設定【Runpod×Kimi入門③】
ステップ③完成時の設定パネル – カスタムプロンプトモード有効時

 

カスタムシステムプロンプトで何ができるの?

 

システムプロンプトを書き換えると、例えば以下のようなカスタマイズができます。

 

・回答の言語を変える(「必ず英語で回答してください」など)
・専門分野を変える(「料理の専門家として回答してください」「法律の専門家として回答してください」など)
・回答のスタイルを変える(「小学生でも分かるように説明してください」「簡潔に3行で回答してください」など)
・出力形式を固定する(「回答は必ず表形式で出力してください」など)

 

このように、システムプロンプトは
「AIの性格や役割を、自分好みに調整するための指示書」
です。
プロンプトの内容を工夫してAIの出力を調整する技術は「プロンプトエンジニアリング」と呼ばれ、このステップ③が、その第一歩となります。

 

カスタムシステムプロンプトの記入例:カスタムシステムプロンプトの作り方|AIの性格を自分で設定【Runpod×Kimi入門③】
カスタムシステムプロンプトの記入例

 

 

作業の流れ(差分は全部で5か所)

 

ステップ②のコードに対して、以下の5か所を「追加」または「置き換え」していきます。

 

1. HTML:
設定パネルに「カスタムシステムプロンプトモード」のチェックボックスと、編集エリアを追加

2. CSS:
カスタムプロンプト編集エリア(#customPromptField)のスタイルを追加

3. JavaScript【3】:
新しい部品のDOM参照を追加

4. JavaScript【6】:
「カスタムプロンプトの表示切替」と「使用するプロンプトの決定」に関する関数を追加・書き換え

5. JavaScript【24】:
設定変更時のイベントを登録

 

各差分では、「どのコードの直後に追加するか」「どのブロックを置き換えるか」を検索用の目印つきで示します。
落ち着いて1つずつ作業してください。

 

差分①:HTML(設定パネルにカスタムプロンプトの項目を追加)

 

まず、設定パネルに「カスタムシステムプロンプトモード」のチェックボックスと、その下に表示される「編集エリア」を追加します。

 

■ 追加する場所

設定パネル(id=”settingsPanel”)の中にある、以下のコメント行を検索してください。

 

🔍 検索用の目印(このコメント行を探す)
    <!-- ステップ③で「カスタムシステムプロンプト」の項目を、
         この直後に追加します -->

 

このコメント行の直後(設定パネルの閉じタグ </div> の直前)に、以下のコードを追加してください。

※コメント行自体は、追加後に削除しても、そのまま残しても構いません。

 

■ 追加するコード

 

※スマホの方はコードを長押しして選択・コピーしてください

📄 差分①:カスタムプロンプトの追加HTML
    <!-- カスタムシステムプロンプトモードの切り替え
         チェックボックスを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>

 

■ この差分のポイント

 

・style=”grid-column: 1 / -1;” という指定で、これらの項目だけ横幅いっぱいに広げています。
設定パネルはグリッドレイアウト(複数カラム)のため、通常は1カラム分の幅ですが、プロンプトの編集エリアは長文を扱うため、横幅いっぱいに広げています。

・編集エリア(id=”customPromptField”)には最初から class=”hidden” が付いています。
これは「チェックボックスがONになった時だけ表示する」ためで、切り替えはJavaScript側(差分④の updateCustomPromptVisibility 関数)が行います。

・「デフォルトに戻す」「コピー」の2つのボタンは、完成形のアプリの仕様をそのまま採用しています。
「デフォルトに戻す」は、編集した内容を初期のプロンプトに戻すためのもので、押すと確認ダイアログが表示されます。

 

差分②:CSS(カスタムプロンプト編集エリアのスタイルを追加)

 

編集エリア(#customPromptField)の見た目を整えるためのスタイルを追加します。

 

■ 追加する場所

CSSの中にある、.setting-hint のブロックを検索してください。

 

🔍 検索用の目印(.setting-hint のブロックを探す)
.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;
}

 

■ この差分のポイント

 

・font-family: monospace で、プロンプトの編集を「等幅フォント」にしています。
コードやプロンプトのような構造化されたテキストは、等幅フォントのほうが読みやすいためです。

・resize: vertical で、編集エリアの「高さだけ」を自由に変えられるようにしています。
長文のプロンプトを編集する際に、画面に合わせて高さを調整できるため、便利です。

 

差分③:JavaScript【3】DOM参照(新しい部品を変数に登録)

 

差分①で追加した各要素を、JavaScriptから操作できるように、DOM参照として登録します。

 

■ 追加する場所

JavaScript【3】DOM参照のセクションにある、以下の変数宣言の行を検索してください。

 

🔍 検索用の目印(この行を探す)
const timeoutHint = $("#timeoutHint");

 

この行の直後に、以下のコードを追加してください。

 

■ 追加するコード

 

※スマホの方はコードを長押しして選択・コピーしてください

📄 差分③:新しい部品のDOM参照を追加
/* --- ステップ③で追加したカスタムプロンプト関連のDOM参照 --- */
/* カスタムプロンプトモードの切り替え(チェックボックス) */
const customPromptModeToggle = $("#customPromptModeToggle");
/* カスタムプロンプトの編集エリア「枠」 */
const customPromptField = $("#customPromptField");
/* カスタムプロンプトのテキストエリア */
const customSystemPrompt = $("#customSystemPrompt");
/* 「デフォルトに戻す」ボタン */
const resetPromptBtn = $("#resetPromptBtn");
/* 「コピー」ボタン */
const copyPromptBtn = $("#copyPromptBtn");

 

■ この差分のポイント

 

・customPromptModeToggle は「チェックボックスがONかOFFか」を読み取ったり、変更時のイベントを登録したりするために使います。

・customPromptField は「編集エリアを表示・非表示に切り替える」ために使います(差分④の updateCustomPromptVisibility 関数で使用)。

・customSystemPrompt は「実際のプロンプトの内容を読み取ったり、書き換えたり」するために使います(差分④の getCurrentSystemPrompt 関数で使用)。

 

差分④:JavaScript【6】UI設定(カスタムプロンプトの表示切替と、使用するプロンプトの決定)

 

いよいよ、このステップの心臓部です。

ここでは、1つの新しい関数を追加し、2つの既存関数に保存・復元処理を追加し、getCurrentSystemPrompt を書き換えます。

 

■ 追加1:updateCustomPromptVisibility 関数(編集エリアの表示切替)

まず、「チェックボックスがONの時だけ、編集エリアを表示する」関数を追加します。

追加する場所

JavaScript【6】UI設定セクションにある、updateSettingHints 関数の閉じ括弧 } の直後に追加します。

まず、以下のコードブロックを検索してください。

 

🔍 検索用の目印(updateSettingHints 関数を探す)
function updateSettingHints() {
  /* APIタイムアウトのヒント:「360秒」→「6.0分 まで」のように分に換算して表示 */
  const timeoutSec = parseInt(requestTimeoutInput.value, 10) || 360;
  timeoutHint.textContent = timeoutSec >= 60
    ? (timeoutSec / 60).toFixed(1) + "分 まで"
    : timeoutSec + "秒 まで";
}

 

この閉じ括弧 } の直後(updateDarkModeBtn 関数の直前)に、以下のコードを追加してください。

 

※スマホの方はコードを長押しして選択・コピーしてください

📄 差分④-1:updateCustomPromptVisibility 関数の追加
/* --- カスタムプロンプトの表示/非表示を切り替える ---
   チェックボックスが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");
  }
}

 

■ 追加2:saveUiSettings / loadUiSettings にカスタムプロンプト設定を追加

次に、カスタムプロンプトの設定(モードのON/OFFと、編集した内容)を、localStorage に保存・復元できるように拡張します。

修正1:saveUiSettings 内の settings オブジェクトに追加

saveUiSettings 関数内にある、以下のsettings オブジェクトを検索してください。

 

🔍 検索用の目印(saveUiSettings 内の settings オブジェクトを探す)
    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
    };

 

これを以下のコードに置き換えてください(最後の2行を追加します)。

 

📄 差分④-2-1: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
    };

 

修正2:loadUiSettings にカスタムプロンプトの復元処理を追加

loadUiSettings 関数内にある、以下のtry ブロックの末尾(if (s.requestTimeout) の行の直後)を検索してください。

 

🔍 検索用の目印(loadUiSettings 内の if (s.requestTimeout) の行を探す)
      if (s.requestTimeout) requestTimeoutInput.value = s.requestTimeout;
    }
  } catch (e) { /* 無視 */ }

 

これを以下のコードに置き換えてください(カスタムプロンプトの復元処理を追加します)。

 

📄 差分④-2-2:loadUiSettings への追加
      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;
        }
      }
    }
  } catch (e) { /* 無視 */ }

 

修正3:loadUiSettings の末尾に updateCustomPromptVisibility() の呼び出しを追加

loadUiSettings 関数の末尾にある、以下のコードブロックを検索してください。

 

🔍 検索用の目印(loadUiSettings 関数の末尾を探す)
  /* 補足表示(ヒント)を現在の設定値に合わせて更新する */
  updateSettingHints();
}

 

これを以下のコードに置き換えてください(updateCustomPromptVisibility() の呼び出しを追加します)。

 

📄 差分④-2-3:loadUiSettings への呼び出し追加
  /* 補足表示(ヒント)を現在の設定値に合わせて更新する */
  updateSettingHints();

  /* カスタムプロンプト表示状態を更新する */
  updateCustomPromptVisibility();
}

 

■ 置き換え:getCurrentSystemPrompt 関数を「カスタムプロンプト対応」に書き換え

最後に、ステップ①から「固定値を返す」形になっていた getCurrentSystemPrompt 関数の中身を、「カスタムプロンプトが有効ならその値を、無効ならデフォルト値を返す」形に書き換えます。

置き換える場所

JavaScript【6】UI設定セクションにある、以下のgetCurrentSystemPrompt 関数全体を検索してください。

 

🔍 検索用の目印(getCurrentSystemPrompt 関数を探す)
/* --- 現在使用すべきシステムプロンプトを返す ---
   ステップ②では固定値を返します(ステップ③で拡張)。 */
function getCurrentSystemPrompt() {
  return DEFAULT_SYSTEM_PROMPT;
}

 

これを以下のコードに置き換えてください。

 

📄 差分④-3:getCurrentSystemPrompt 関数を書き換え
/* --- 現在使用すべきシステムプロンプトを返す ---
   カスタムプロンプトモードが有効なら編集内容を、
   無効ならデフォルト値を返します。
   (完成形の getCurrentSystemPrompt と同じ仕様) */
function getCurrentSystemPrompt() {
  if (customPromptModeToggle.checked) {
    const custom = customSystemPrompt.value.trim();
    /* 空の場合はデフォルトを返す(空のプロンプトは送信しない) */
    return custom || DEFAULT_SYSTEM_PROMPT;
  }
  return DEFAULT_SYSTEM_PROMPT;
}

 

■ この差分のポイント

 

1. updateCustomPromptVisibility() の役割

チェックボックスがONになった時に、編集エリア(customPromptField)の hidden クラスを外して表示します。
また、初回有効化時に、エリアが空の場合はデフォルトプロンプトを挿入する処理も含まれています。これにより、ユーザーは「まずデフォルトの内容を見ながら編集を始められる」ようになります。

 

2. saveUiSettings / loadUiSettings への追加

customPromptMode(モードのON/OFF)と customPrompt(編集した内容)を、localStorage に保存・復元するようにしました。
これで、画面を更新しても、編集したプロンプトが残るようになります。

 

3. getCurrentSystemPrompt() の書き換え

ここが、ステップ①で学んだ「値を関数経由で取得する」設計の効果が最大限に発揮される場面です。

 

この関数の中身を「カスタムプロンプト対応」に書き換えただけで、呼び出し側のコード(buildChatCompletionRequest)を一切変更せずに、拡張が完了しました。

 

buildChatCompletionRequest は、相変わらず getCurrentSystemPrompt() を呼んでいるだけですが、その中身が「カスタムプロンプトが有効ならその値を、無効ならデフォルト値を返す」に変わったため、自動的にカスタムプロンプトがAPIに送られるようになります。

 

これが「変更に強いコード」を書くための、プログラミングの基本的なテクニックです。

 

差分⑤:JavaScript【24】イベント登録(カスタムプロンプトのイベントを登録)

 

最後の差分です。
カスタムプロンプトの「チェックボックスの切り替え」「テキストエリアの編集」「デフォルトに戻すボタン」「コピーボタン」のイベントを登録します。

 

■ 追加する場所

JavaScript【24】registerEvents 関数内にある、以下のAPIタイムアウトのイベント登録部分を検索してください。

 

🔍 検索用の目印(このイベント登録部分を探す)
  /* 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つの操作を結び付けています。

 

 操作 動作
 チェックボックスの切り替え ① updateCustomPromptVisibility() で
 編集エリアの表示・非表示を切り替える
 ② saveUiSettings() で設定を保存する
 テキストエリアの編集 入力のたびに saveUiSettings() で保存する
 (input イベントは値が変わるたびに発火)
 「デフォルトに戻す」ボタン ① confirm() で確認ダイアログを表示する
 ② OKなら DEFAULT_SYSTEM_PROMPT に戻して保存する
 「コピー」ボタン 編集内容をクリップボードにコピーし、
 成功したらボタン表示を一時的に変える

 

1. 「デフォルトに戻す」には、確認ダイアログを挟む

confirm() というブラウザ標準の確認ダイアログを表示し、「OK」が押された時だけ、デフォルトに戻す処理を実行します。

編集した内容が失われる操作なので、「本当に実行しますか?」と一度確認を挟むことで、うっかり押してしまった事故を防いでいます。
メッセージの内容は、完成形のアプリと同じ「カスタムプロンプトをデフォルトに戻しますか?\n現在の編集内容は失われます。」を採用しています(\n は改行です)。

 

2. 「コピー」ボタンの小さな気配り

navigator.clipboard.writeText() で、編集エリアの内容をクリップボードにコピーします。
成功したら、ボタンの表示を一時的に「✓ コピーしました」に変え、2秒後に元に戻します。

「操作が成功したかどうかを、視覚的に伝える」という、細やかなユーザー体験への配慮です。
また、catch で「コピーに失敗した場合」にも対応しており、その場合はアラートで手動コピーを促します。

 

 

動作確認の手順

 

ここまでの差分①〜⑤をすべて適用したら、以下の手順で動作確認を行ってください。

 

1. ファイルを保存して、ブラウザで開き直す

編集した custom-app-dev.html を保存し、ブラウザで開き直します(すでに開いている場合は再読み込みします)。

 

2. 設定パネルを開き、チェックボックスを探す

右上の「⚙ 設定」ボタンを押して設定パネルを開きます。
一番下に「カスタムシステムプロンプトモードを有効化」というチェックボックスが表示されていれば成功です。

 

3. チェックボックスをONにしてみる

チェックボックスをONにすると、その下に「カスタムシステムプロンプト」の編集エリアが現れます。
初回は、デフォルトのプロンプト(プログラミング支援専門のAIアシスタントの指示書)が、あらかじめ入力された状態で表示されます。

 

ステップ③完成時の設定パネル - カスタムプロンプトモード有効時:カスタムシステムプロンプトの作り方|AIの性格を自分で設定【Runpod×Kimi入門③】
設定パネル – カスタムプロンプトモード有効時

 

4. プロンプトを書き換えて、会話してみる

例えば、編集エリアの内容を以下のように書き換えてみてください。

 

📄 カスタムプロンプトの記入例
あなたは、親切な料理研究家です。
ユーザーの質問には、料理の専門家として、
初心者にも分かりやすく回答してください。

 

書き換えたあと、「おすすめのカレーの作り方を教えて」などと質問を送信してみてください。
料理研究家としての回答が返ってくれば、カスタムプロンプトが正しく機能しています。

 

5. 設定が保存されるかを確認する

編集したプロンプトを残したまま、画面を更新(リロード)してみてください。
チェックボックスのON状態と、編集した内容が復元されれば、localStorage への保存が正しく動いています。

 

6. 「デフォルトに戻す」ボタンを試す

「デフォルトに戻す」ボタンを押すと、確認ダイアログが表示されます。
「OK」を押すと、編集エリアの内容がデフォルトのプロンプトに戻ります。

 

7. チェックボックスをOFFにしてみる

チェックボックスをOFFにすると、編集エリアが隠れ、デフォルトのプロンプトに戻ります。
この状態で先ほどと同じ質問を送信すると、プログラミング支援専門のAIアシスタントとしての回答が返ってきます。

 

 

ステップ③、完走おつかれさまでした!

 

これで、ステップ③の作業はすべて終了です。おつかれさまでした。

 

ここまでの道のりを、振り返ってみましょう。

 

■ あなたが作り上げたもの

コード内に固定値として書かれていた「システムプロンプト」(AIへの指示書)を、設定パネル(画面)から自由に編集できるように拡張しました。
「カスタムシステムプロンプトモード」を有効化すると編集エリアが現れ、そこに書いた内容がAIへの指示書として使われます。変更した内容は localStorage に保存され、画面を更新しても残ります。

 

■ この過程で学んだ、プログラミングの核心概念

 

・「値を関数経由で取得する」設計の、最大の効果
getCurrentSystemPrompt() の中身を書き換えただけで、呼び出し側(buildChatCompletionRequest)を一切変えずに拡張が完了した。
ステップ①で学んだ設計の仕掛けが、ここで最も鮮やかに報われた瞬間です。

 

・「表示の切り替え」と「値の決定」を、別の関数に分ける
updateCustomPromptVisibility()(表示切替)と getCurrentSystemPrompt()(値の決定)を分けたことで、それぞれの役割が明確になった。

 

・確認ダイアログ(confirm)で、破壊的な操作を守る
「デフォルトに戻す」のような、編集内容が失われる操作には、確認を挟んで事故を防ぐ。

 

■ プロンプトエンジニアリングへの第一歩

このステップ③で、あなたは「プロンプトエンジニアリング」(プロンプトの内容を工夫して、AIの出力を調整する技術)の土台を手に入れました。

まずは、デフォルトのプロンプトを少しずつ書き換えて、AIの反応がどう変わるかを試してみてください。
「必ず英語で回答してください」「小学生でも分かるように説明してください」など、小さな変更から始めるのがおすすめです。

 

 

ステップ③:完成コード

 

ここまでの拡張、お疲れ様でした。
以下がステップ③を完走時点のコードです。

 

※スマホの方はコードを長押しして選択・コピーしてください

📄 custom-app-dev.html(ステップ③:完全コード)
<!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/step3/

--------

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処理用ライブラリ(PDF.js)は、ステップ④で
     「PDFアップロード機能」を実装する時にここへ追加します -->

<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;
}

/* ============================================================
   レスポンシブ(モバイル対応:画面幅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>

    <!-- 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">
    <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>
<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分)

/* ------------------------------------------------------------
   システムプロンプト(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

/* ============================================================
   【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");

/* ============================================================
   【4】ユーティリティ(よく使う小さな便利関数)
============================================================ */

/* --- 一意なIDを作る(会話の識別番号として使う) --- */
function generateId() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

/* --- HTMLの特殊文字を「無害な文字」に変換する(XSS対策) ---
   ユーザー入力をそのままHTMLに埋め込むと危険なため、
   < > & などを「ただの文字」として表示される形に変換します。 */
function escapeHtml(str) {
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

/* --- エラーメッセージを画面上部に表示する ---
   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
    };
    localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));

    /* 変更されたタイムアウト値を即座に反映する */
    applyRequestTimeoutSetting();
  } 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;
        }
      }
    }
  } catch (e) { /* 無視 */ }

  /* 起動時に、モデルに応じた設定項目の表示を更新する */
  updateModelSpecificSettingsUI();

  /* 保存済みのタイムアウト設定を反映する */
  applyRequestTimeoutSetting();

  /* 補足表示(ヒント)を現在の設定値に合わせて更新する */
  updateSettingHints();

  /* カスタムプロンプト表示状態を更新する */
  updateCustomPromptVisibility();
}

/* --- APIタイムアウト設定を REQUEST_TIMEOUT_MS に反映する ---
   設定パネルの「APIタイムアウト(秒)」の値を読み取り、
   通信処理で使う変数 REQUEST_TIMEOUT_MS を更新します。
   (完成形の applyUploadSettings と同じ役割の、
     タイムアウト部分だけを取り出した関数です。
     ステップ④で applyUploadSettings に統合します) */
function applyRequestTimeoutSetting() {
  REQUEST_TIMEOUT_MS = (parseInt(requestTimeoutInput.value, 10) || 360) * 1000;
}

/* --- 設定パネルの補足表示(ヒント)を、現在の設定値に合わせて更新する ---
   現在は「APIタイムアウトのヒント(○秒 → ○分 表示)」のみ。
   (完成形の updateSettingHints と同じ役割の、
     タイムアウト部分だけを取り出した関数です。
     ステップ③以降で、他の設定項目のヒントもここに追加していきます) */
function updateSettingHints() {
  /* APIタイムアウトのヒント:「360秒」→「6.0分 まで」のように分に換算して表示 */
  const timeoutSec = parseInt(requestTimeoutInput.value, 10) || 360;
  timeoutHint.textContent = timeoutSec >= 60
    ? (timeoutSec / 60).toFixed(1) + "分 まで"
    : 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;
  }

  /* 操作ボタン(コピー / 再生成 / 再送信。ホバー時だけ表示) */
  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;
}

/* ============================================================
   【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";
}

/* ============================================================
   【16】メッセージ送信(このアプリの主役となる処理)
   ------------------------------------------------------------
   送信ボタンが押されてから、AIの回答が表示されるまでの
   全体の流れを管理します。
   ※ステップ③では添付(画像・PDF・テキスト)は未実装のため、
     テキストのみを送信します(ステップ④で拡張)。
============================================================ */
async function sendMessage() {
  if (isGenerating) return;   // 生成中の二重送信を防ぐ
  clearError();

  /* ----- ① 送信前のチェック(バリデーション) ----- */
  const text = messageInput.value.trim();
  if (!text) {
    showError("メッセージを入力してください。");
    return;
  }
  /* APIキーは入力欄からその都度読み取る(保存はしない) */
  apiKey = apiKeyInput.value.trim();
  if (!apiKey) {
    showError("APIキーが未入力です。設定パネルからRunpod APIキーを入力してください。");
    settingsPanel.classList.remove("hidden");
    return;
  }

  const userText = text;
  const conv = getActiveConversation();
  if (!conv) return;

  /* ----- ② あなたの質問をデータに追加して画面に表示 ----- */
  const userMsg = { role: "user", content: userText };
  conv.messages.push(userMsg);

  /* この会話最初の質問なら、タイトルを自動設定する */
  if (conv.messages.filter(m => m.role === "user").length === 1) {
    generateConversationTitle(userText);
  }
  saveConversations();
  renderActiveConversation();

  /* 入力欄を空にして高さをリセット */
  messageInput.value = "";
  autoResizeTextarea();

  /* ----- ③ 生成開始の準備 ----- */
  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");

  /* 思考モードが有効なら思考過程パネルを作成
     (Kimi K3 は常時思考モデルなので、常に作成される) */
  fullReasoningText = "";
  if (isThinkingEnabled()) {
    currentReasoningEl = createReasoningPanel(streamWrap);
  } else {
    currentReasoningEl = null;
  }
  scrollToBottom(true);

  /* ----- ⑤ API通信(ストリーミング)を実行 ----- */
  try {
    await streamChatCompletion(conv, userText, 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, userText, 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;
  }

  /* 受信した回答を会話データに保存 */
  const conv = getActiveConversation();
  if (conv && fullResponseText) {
    conv.messages.push({ role: "assistant", content: fullResponseText });
    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 });
  });

  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("コピーに失敗しました。手動で選択してコピーしてください。");
    });
  });

  /* ============================================================
     ツールチップ補助(モバイル長押し+位置自動調整)
     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>

 

ステップ③完成時のアプリ画面:カスタムシステムプロンプトの作り方|AIの性格を自分で設定【Runpod×Kimi入門③】
ステップ③完成時のアプリ画面

 

 

次のステップ④の予告

 

次のステップ④では、いよいよファイルアップロード機能を実装します。

 

これまで「テキストのみ」だった質問に、以下のファイルを添付して送れるようになります。

 

・画像(🖼:JPEG/PNG/GIF/WebP)
・PDF(📄:テキスト抽出して内容を送信)
・テキストファイル(📝:.txt/.md/.js/.html/.css/.py/.json/.csv など)
・フォルダ(📁:フォルダ内のテキスト系ファイルをまとめて添付)

 

さらに、ドラッグ&ドロップでの添付や、設定パネルでの「アップロード上限」の設定(画像・PDF・PDFページ数・テキストファイルの上限)にも対応します。

 

ステップ④は、これまでのステップの中で最もボリュームの大きい拡張です。
新しい外部ライブラリ(PDF.js)の読み込みや、ファイルの読み込み処理(FileReader API)、画像のBase64エンコードなど、新しい技術要素も登場しますが、1つずつ丁寧に解説していきますので、ご安心ください。

 

それでは次のセクションから、ステップ④:ファイルアップロードを始めましょう。

 

【ステップ④】AIチャットアプリの拡張 – ファイルアップロード
:AIチャットアプリに画像・PDFアップロード機能を追加する方法【Runpod×Kimi入門④】

Runpod API×KimiのAIチャットアプリ開発入門・第4回。
画像・PDF・テキストファイル・フォルダのアップロード機能を実装。ドラッグ&ドロップ対応とアップロード上限の設定方法を差分コード付きで解説します。

 

by 子供プログラマー

 

Runpod APIキーですぐに使えるウェブアプリ
:【ウェブアプリ】AIチャットアプリ:Runpod API専用 – Kimi編(Kimi K3対応)