AIチャットアプリに設定機能を追加する方法|モデル・推論レベルをUIで変更【Runpod×Kimi入門②】

AIチャットアプリに設定機能を追加する方法|モデル・推論レベルをUIで変更【Runpod×Kimi入門②】


 

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

 

 

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

 

ステップ②では、ステップ①で完成した「会話ができるアプリ」を拡張し、APIの各種パラメータを設定パネル(画面)から変更できるようにします。

 




 

ステップ①では、以下のパラメータはすべてコード内の「固定値」でした。

 

 設定項目 ステップ①
 (固定値)
 ステップ②(この記事でやること)
 モデル名 kimi-k3 kimi-k3 / kimi-k2.7-code / kimi-k2.6
 から選択可能に
 reasoning_effort low Kimi K3選択時:low / high / max
 から選択可能に
 思考モード ― kimi-k2.7-code(固定ON)・kimi-k2.6(ON/OFF選択可)に対応
 max_tokens 8192 数値を自由に変更可能に
(矢印ボタンの変更範囲)
 画面更新間隔 (ms) 80 50〜100msの範囲で変更可能に
(許容範囲)
(この範囲内に数値が丸められます)
 APIタイムアウト(秒) 360 30〜900秒の範囲で変更可能に
(矢印ボタンの変更範囲)
(手入力では好きな数値を入力できます)

 

さらに、変更した設定はテーマと同じく localStorage に保存し、画面を更新しても設定が残るようにします。

 

完成時の設定パネルを開いた状態:AIチャットアプリに設定機能を追加する方法|モデル・推論レベルをUIで変更【Runpod×Kimi入門②】
完成時の設定パネルを開いた状態

 

 

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

 

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

 

1. HTML:
設定パネルに6つの設定項目を追加

2. CSS:
補足ヒント(.setting-hint)のスタイルを追加

3. JavaScript【3】:
新しい入力欄のDOM参照を追加

4. JavaScript【5】:
モデル関連の関数を「設定パネルの値を読む」形に書き換え

5. JavaScript【6】:
設定の保存・読み込み(saveUiSettings / loadUiSettings)を拡張

6. JavaScript【6】:
APIタイムアウト設定を実際の通信に反映させる関数を追加

7. JavaScript【17】:
リクエスト構築で「設定パネルの値」と「モデル別の分岐」に対応

8. JavaScript【18】:
エラーメッセージ内の max_tokens 表示を2か所修正

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

 

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

 

 

差分①:HTML(設定パネルに設定項目を追加)

 

まず、設定パネルに6つの設定項目を追加します。

 

■ 追加する場所

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

 

🔍 検索用の目印(このコメント行を探す)
    <!-- ステップ②で、モデル名・reasoning_effort・max_tokensなどの
         設定項目を、この直後に追加していきます -->

 

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

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

 

■ 追加するコード

 

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

📄 差分①:設定パネルへの追加HTML
    <!-- モデル名の選択
         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>

 

■ この差分のポイント

 

・各項目は、ステップ①でAPIキーを配置したのと同じ setting-field という枠組みで作られています。
グリッドレイアウトのおかげで、追加するだけで自動的にきれいに並びます。

・各項目の下にある小さな説明文(setting-hint)は、完成形のアプリの説明文をそのまま採用しています。
この説明文用のスタイルは、次の差分②で追加します。

・「思考モード」の項目には最初から class=”hidden” が付いています。これは「kimi-k2.7-code か kimi-k2.6 が選ばれた時だけ表示する」ためで、切り替えはJavaScript側(差分④の updateModelSpecificSettingsUI 関数)が行います。

・各入力欄の id(modelInput・reasoningEffortSelect など)は、JavaScriptから値を読み取るための目印です。
差分③でDOM参照として登録します。

 

差分②:CSS(.setting-hint のスタイルを追加)

 

差分①で追加した各設定項目の説明文(setting-hint)を、完成形と同じ見た目(アクセントカラー・太字)にするためのスタイルを追加します。

 

■ 追加する場所

CSSの中にある、以下のセレクタのブロック全体を検索してください。

 

🔍 検索用の目印(このセレクタのブロックを探す)
.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-hint のスタイル追加
/* チェックボックスだけは横幅いっぱいにしない(ステップ③で使用) */
.setting-field input[type="checkbox"] {
  width: auto;
  margin: 0;
}
/* 設定項目の下に表示する小さな補足説明文(完成形と同じ見た目) */
.setting-hint {
  font-size: 12px;
  color: var(--accent);   /* アクセントカラーで目立たせる */
  margin-top: 4px;
  font-weight: 600;       /* 少し太字にして読みやすく */
}

 

■ この差分のポイント

 

・.setting-hint は、差分①で追加した各設定項目の説明文(「kimi-k3:最高性能を狙う万能型」など)に適用されます。
色の指定に var(–accent) を使っているため、テーマ(ライト/ダーク)を切り替えても、自動的にそのテーマのアクセントカラーで表示されます。

・input[type=”checkbox”] のスタイルは、このステップではまだ使いません。
ステップ③で「カスタムプロンプトモードを有効化」のチェックボックスを追加する時に必要になるため、完成形に合わせて今のうちに仕込んでおきます。

 

 

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

 

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

HTML編(ステップ①の解説)で「id が JavaScript との架け橋」と学びましたが、ここでその架け橋を、新しい部品にも渡していきます。

 

■ 追加する場所

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

 

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

 

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

 

■ 追加するコード

 

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

📄 差分③:新しい入力欄のDOM参照を追加

/* --- ステップ②で追加した設定項目の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");

 

■ この差分のポイント

 

ここで注目してほしいのが、「枠(Field)」と「入力部品(Select/Input)」を分けて取得していることです。

 

 変数の例 種類 主な使い道
 reasoningEffortField
 thinkingModeField
 「枠」
 (項目全体)
 モデルの選択に応じて、項目ごと表示・
 非表示を切り替える
ために使う
 (差分④で登場)
 reasoningEffortSelect
 thinkingModeSelect
 maxTokensInput など
 「入力部品」
 (選択欄・
  入力欄)
 実際の設定値を読み取るために使う
 (差分④・⑦で登場)
 thinkingModeFieldLabel
 thinkingModeFieldHint
 「表示テキスト」 選択中のモデルに応じて、ラベルや説明
 文の内容を書き換える
ために使う
 (差分④で登場)

 

なお、表の「表示テキスト」の仲間として、APIタイムアウトのヒント表示(timeoutHint)もここで登録しています。
差分①のHTMLに書かれた「6分 まで」という文言を、後の差分⑥で「入力値に応じて書き換える」ために使います。

 

この「役割ごとに変数を分ける」設計のおかげで、次の差分④では「表示の切り替え」と「値の読み取り」を、それぞれ独立して扱えるようになります。

なお、これらの変数は差分③の時点では「取得しただけ」で、まだ何も動きません。
実際に使われるのは、次の差分④(モデル関連の関数の書き換え)以降です。

 

差分④:JavaScript【5】モデル関連(設定パネルの値を読む形に書き換え)

 

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

ステップ①の解説で「値を関数経由で取得する形にしておくと、将来の拡張が楽になる」と学んだ getSelectedModel() などの関数の中身を、「固定値を返す」から「設定パネルの値を読んで返す」に書き換えます。

 

■ 置き換える場所

JavaScript【5】モデル関連のセクション全体を、以下のコードで置き換えてください。

 

🔍 検索用の目印(このコメント行を探す)
/* ============================================================
   【5】モデル関連(ステップ①では固定値を返す)
   ------------------------------------------------------------
   完成形では設定パネルの値を読みますが、ステップ①では
   コード上の固定値を返します。関数名は完成形と同一なので、
   ステップ②で中身を差し替えるだけで拡張できます。
============================================================ */

 

このコメント行から、セクションの最後(getRenderInterval() 関数の閉じ括弧 } の後)までをすべて選択して削除し、以下のコードに置き換えてください。

 

■ 置き換えるコード

 

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

📄 差分④:モデル関連の関数を書き換え
/* ============================================================
   【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";
}

 

■ この差分のポイント

 

ここでは、4つの重要な変更を行っています。

 

1. getSelectedModel() の中身を「設定パネルの値を読む」形に変更

ステップ①では return FIXED_MODEL_NAME; と固定値を返していましたが、return modelInput.value.trim() || “kimi-k3”; に変更しました。
これで、設定パネルで選んだモデル名が、そのまま返されるようになります。

 

2. getRenderInterval() の中身を「設定パネルの値を読む」形に変更

こちらも、ステップ①では return FIXED_RENDER_INTERVAL; と固定値を返していましたが、設定パネルの入力値を読み取り、50〜100msの範囲に収める(Math.min と Math.max で制限)形に変更しました。

 

3. isThinkingEnabled() を「モデル別の判定」に拡張

モデルに応じて、思考モードの有効・無効を判定するようにしました。
Kimi K2.6 では thinkingModeSelect.value === “enabled” という条件を加え、
「設定パネルでONが選ばれている時だけ true を返す」
ようにしています。

 

4. updateModelSpecificSettingsUI() を新たに追加

これが、差分①の解説で
「モデルの選択に応じて表示が切り替わる」
と述べた、表示切り替えの担当関数です。
選択されたモデルに応じて、「思考モード」と「reasoning_effort」の項目の表示・非表示を切り替えます。

この関数は、モデル名の選択が変わるたびに呼ばれます(差分⑨のイベント登録で設定)。

 

また、normalizeReasoningEffort() という「値の正規化」を行う小さな関数も追加しています。
これは、localStorage から読み込んだ値や、予期しない値が来た場合に、「low」「high」「max」のどれかに丸める(安全な値に変換する)ためのものです。

 

 

差分⑤:JavaScript【6】UI設定(設定の保存・読み込みの拡張)

 

次に、新しく追加した設定項目(モデル名・reasoning_effort・max_tokens など)を、テーマと同じく localStorage に保存・復元できるように拡張します。

 

■ 置き換える場所

JavaScript【6】UI設定のセクションにある、以下の2つの関数を、それぞれ置き換えてください。

 

🔍 検索用の目印①(saveUiSettings 関数全体を探す)
/* --- 現在の画面設定をlocalStorageに保存する --- */
function saveUiSettings() {
  try {
    const settings = {
      dark: document.body.classList.contains("dark")
    };
    localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));
  } catch (e) { /* localStorage不可時は無視 */ }
}

 

🔍 検索用の目印②(loadUiSettings 関数全体を探す)
/* --- 保存された画面設定を読み込んで適用する(起動時に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");
    }
  } catch (e) { /* 無視 */ }
}

 

これらの関数全体を削除し、以下のコードに置き換えてください。

 

■ 置き換えるコード

 

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

📄 差分⑤:設定の保存・読み込みの拡張
/* --- 現在の画面設定を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
    };
    localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));
  } 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;
    }
  } catch (e) { /* 無視 */ }

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

 

■ この差分のポイント

 

1. 保存する設定項目を大幅に追加

ステップ①では dark(テーマ)だけを保存していましたが、新しく追加した設定項目(model・reasoningEffort・maxTokens・renderInterval・thinkingMode・requestTimeout)も、すべて保存するようにしました。

 

2. 読み込んだ値を、設定パネルの各入力欄に復元

loadUiSettings の中で、保存した値をモデル名・reasoning_effort・max_tokens などの各入力欄の value プロパティに代入しています。
これで、画面を更新しても、前回設定した値が復元されるようになります。

 

3. thinkingMode の保存には data 属性を活用

Kimi K2.6 の思考モードは、ユーザーが「ON/OFF」を選択できますが、その値を thinkingModeSelect.dataset.k26Value という data 属性に一時的に保存しています。
これは、モデルを「kimi-k2.6」以外に切り替えても、元の「kimi-k2.6」での選択値を覚えておくためです。

 

4. 起動時に updateModelSpecificSettingsUI() を呼ぶ

loadUiSettings の最後に、差分④で追加した updateModelSpecificSettingsUI() を呼んでいます。
これで、起動時に保存されたモデル名を復元したあと、「そのモデルに応じた設定項目の表示」に切り替わります。

 

差分⑥:JavaScript【6】APIタイムアウト設定を実際の通信に反映させる

 

ここまでの差分で、設定パネルの値を localStorage に保存・復元できるようになりました。
ただ実は、このままでは「APIタイムアウトの設定を変更しても、実際の通信処理に反映されない」という状態が残っています。

 

この差分で「設定と実際の動作の橋渡し」を行います。

 

なぜ、この修正が必要なのか?

 

このアプリのタイムアウト時間は、JavaScript【1】で宣言した「変数」に保持されています。

 

📄 JavaScript【1】にある変数の宣言(確認のみ・作業不要)
let REQUEST_TIMEOUT_MS = 360000;           // APIタイムアウト(360秒=6分)

 

通信処理(streamChatCompletion)は、この変数の値を使って「タイムアウト用のタイマー」をセットしています。

 

📄 JavaScript【18】にあるタイマーのセット部分(確認のみ・作業不要)
  /* タイムアウト用タイマー(設定秒数経過したら通信を中止) */
  const timeoutId = setTimeout(() => {
    if (abortController) abortController.abort("timeout");
  }, REQUEST_TIMEOUT_MS);

 

ここで重要なのは、差分⑤で拡張した saveUiSettings / loadUiSettings は、あくまで「設定パネルの入力欄の値」を保存・復元しているだけで、この変数 REQUEST_TIMEOUT_MS を更新する処理がどこにもない、という点です。

 

つまり現状では、設定パネルで「30秒」に変更しても REQUEST_TIMEOUT_MS は宣言時の 360000(360秒)のままなので、通信が中断されるのは設定に関係なく「常に360秒後」になってしまいます。

 

そこで、「設定パネルの値を REQUEST_TIMEOUT_MS に反映する関数」を新たに追加し、設定の保存時と読み込み時に呼び出すようにします。

 

これは、「カスタムAIチャットアプリ」(ダウンロード版)にある applyUploadSettings() と同じ設計です。
完成形でも同様に、「設定パネルの値を、アプリの動作パラメータ(変数)に反映する関数」を、saveUiSettings / loadUiSettings から呼び出しています。
今回追加する関数は、その「タイムアウト部分だけ」を取り出したものです(ステップ④で applyUploadSettings に統合します)。

 

■ 修正1:applyRequestTimeoutSetting 関数の追加

 

追加する場所

JavaScript【6】UI設定セクションにある、loadUiSettings 関数の閉じ括弧 } の直後に追加します。
まず、以下のコードブロックを検索してください(loadUiSettings 関数の末尾部分です)。

 

🔍 検索用の目印(loadUiSettings 関数の末尾を探す)
  /* 起動時に、モデルに応じた設定項目の表示を更新する */
  updateModelSpecificSettingsUI();
}

 

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

 

追加するコード

 

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

📄 差分⑥-1:applyRequestTimeoutSetting 関数の追加

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

 

■ 修正2:saveUiSettings() から呼び出す(設定変更時の反映)

 

修正する場所

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

 

🔍 検索用の目印(saveUiSettings 内の保存処理を探す)
    localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));
  } catch (e) { /* localStorage不可時は無視 */ }

 

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

 

📄 差分⑥-2:saveUiSettings への呼び出し追加
    localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));

    /* 変更されたタイムアウト値を即座に反映する */
    applyRequestTimeoutSetting();
  } catch (e) { /* localStorage不可時は無視 */ }

 

■ 修正3:loadUiSettings() から呼び出す(起動時の反映)

 

修正する場所

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

 

🔍 検索用の目印(loadUiSettings 関数の末尾を探す)
  /* 起動時に、モデルに応じた設定項目の表示を更新する */
  updateModelSpecificSettingsUI();
}

 

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

 

📄 差分⑥-3:loadUiSettings への呼び出し追加
  /* 起動時に、モデルに応じた設定項目の表示を更新する */
  updateModelSpecificSettingsUI();

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

 

■ この差分のポイント

 

1. なぜ、通信処理(streamChatCompletion)は修正しなくていいのか?

タイマーのセット部分(}, REQUEST_TIMEOUT_MS);)は、変数を「読んでいるだけ」です。
そのため、「変数の中身を更新する処理」を追加するだけで、読む側のコードは一切変更せずに、次回の通信から自動的に新しい値が使われます。
これが「変数に状態を保持しておき、設定変更のタイミングで更新する」という設計(完成形と同じ設計)の利点です。

 

2. 2つの設計パターンの違いを知ろう

このアプリには、設定値の使われ方が2つのパターンに分かれています。

 

 パターン 該当する設定 仕組み
 「使うたびに読む」
 (関数経由)
 モデル名
 画面更新間隔
 max_tokens など
 getSelectedModel() や
 getRenderInterval() のように、
 呼ばれるたびに設定パネルの値を読
 みにいく
 「保持しておき、変更時に
  更新する」
(変数保持)
 APIタイムアウト
 (完成形ではアップロード
  上限なども)
 変数に保持しておき、
 設定の保存時・起動時に
 まとめて更新する

 (今回追加した方式)

 

どちらのパターンでも正しく動きますが、完成形のアプリでは「変数保持」パターンが採用されているため、今回はそれに合わせました。
「同じ目的でも、設計のやり方は一つではない」という、プログラミングの面白い側面です。

 

3. 反映されるタイミングは「保存時」と「起動時」の2回

 

・設定変更時:
設定パネルの値を変更すると input イベント(差分⑨で登録)→ saveUiSettings() → applyRequestTimeoutSetting() の順に呼ばれ、即座に変数が更新されます。

・起動時:
画面を開くと loadUiSettings() → applyRequestTimeoutSetting() の順に呼ばれ、前回保存した値が変数に復元されます。

 

なお、反映は「次回以降の通信」から有効です。
すでに通信中のリクエストのタイマーは、通信開始時にセット済みのため、途中で変更してもその通信には適用されません。

 

■ 修正4:タイムアウトのヒント表示を、入力値に合わせて動的に変える

 

修正1〜3で「設定値が実際の通信処理に反映される」ようになりましたが、もう一つ、設定パネルの見た目に関する課題が残っています。

 

差分①で追加した「APIタイムアウト」の入力欄の下には、「6分 まで」というヒント表示があります。
しかしこの文言はHTMLに直接書かれた「固定の文字」なので、入力値を「120」に変えても「30」に変えても、表示はずっと「6分 まで」のままです。

 

そこで、このヒント表示を「入力された秒数を分に換算して、リアルタイムに書き換える」仕組みに変えます。
これも「カスタムAIチャットアプリ」(ダウンロード版)にある updateSettingHints() と同じ設計です。
完成形ではこの関数で「APIタイムアウト」「画像アップロードの上限」など、複数のヒント表示をまとめて更新していますが、ここではまずタイムアウト分だけを実装します(ステップ④で、他のヒントもこの関数に追加していきます)。

 

■ 修正4-1:updateSettingHints 関数の追加

 

追加する場所

JavaScript【6】UI設定セクションにある、applyRequestTimeoutSetting 関数の閉じ括弧 } の直後(updateDarkModeBtn 関数の直前)に追加します。
まず、以下のコードブロックを検索してください。

 

🔍 検索用の目印(applyRequestTimeoutSetting 関数を探す)
function applyRequestTimeoutSetting() {
  REQUEST_TIMEOUT_MS = (parseInt(requestTimeoutInput.value, 10) || 360) * 1000;
}

 

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

 

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

📄 差分⑥-4-1:updateSettingHints 関数の追加

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

 

ここで使っている timeoutHint は、差分③で登録したDOM参照です。
また、toFixed(1) は「小数点以下1桁で表示する」命令で、例えば 90秒 なら「1.5分 まで」、360秒 なら「6.0分 まで」と表示されます。
60秒未満の時は「45秒 まで」のように、秒のまま表示するようにしています。

 

■ 修正4-2:loadUiSettings() から呼び出す(起動時のヒント更新)

 

修正する場所

修正3で書き換えた loadUiSettings 関数の末尾にある、以下のコードブロックを検索してください。

 

🔍 検索用の目印(loadUiSettings 関数の末尾を探す)
  /* 保存済みのタイムアウト設定を反映する */
  applyRequestTimeoutSetting();
}

 

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

 

📄 差分⑥-4-2:loadUiSettings への呼び出し追加
  /* 保存済みのタイムアウト設定を反映する */
  applyRequestTimeoutSetting();

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

 

これで、ページを開いた時(起動時)に、保存済みのタイムアウト値に合わせたヒント表示(例:「6.0分 まで」)が最初から正しく表示されるようになります。
あとは「入力中にもヒントを追従させる」ためのイベント登録を、差分⑨で行います。

 

差分⑦:JavaScript【17】リクエスト構築(設定パネルの値とモデル別分岐に対応)

 

次に、APIに送る「注文票」を組み立てる buildChatCompletionRequest 関数を、設定パネルの値を読む形に書き換え、さらに kimi-k2.7-code ・ kimi-k2.6 の分岐を追加します。

 

■ 置き換える場所

JavaScript【17】リクエスト構築のセクションにある、以下のコメント行を検索してください。

 

🔍 検索用の目印(このコメント行を探す)
function buildChatCompletionRequest(conv, userText, useStream) {

 

この関数全体({ から } まで)をすべて選択して削除し、以下のコードに置き換えてください。

 

■ 置き換えるコード

 

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

📄 差分⑦:リクエスト構築を書き換え
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;
}

 

■ この差分のポイント

 

ここでは、3つの重要な変更を行っています。

 

1. max_tokens を「設定パネルの値」に変更

ステップ①では max_tokens: FIXED_MAX_TOKENS と固定値でしたが、parseInt(maxTokensInput.value, 10) || 8192 に変更しました。
これで、設定パネルで入力した値が、そのままAPIに送られます。

 

2. reasoning_effort を「設定パネルの値」に変更(Kimi K3)

ステップ①では request.reasoning_effort = FIXED_REASONING_EFFORT と固定値でしたが、normalizeReasoningEffort(reasoningEffortSelect.value) に変更しました。
これで、設定パネルで選んだ推論レベル(low / high / max)が、そのままAPIに送られます。

 

3. kimi-k2.7-code ・ kimi-k2.6 の分岐を追加

ステップ①では「※kimi-k2.7-code・kimi-k2.6 の分岐はステップ②で追加します」というコメントだけでしたが、ここで実際の分岐を追加しました。

 

 モデル 送信するパラメータ
 kimi-k3 request.reasoning_effort = “low” / “high” / “max”
 (常時思考モデルのため、思考ON/OFFの指定は不要)
 kimi-k2.7-code request.thinking = { type: “enabled”, keep: “all” }
 (思考モード固定ON、reasoning_effortは送信しない)
 kimi-k2.6 思考モードON:request.thinking = { type: “enabled” } を送信
 思考モードOFF:thinking パラメータ自体を送信しない(省略する)
 (設定パネルの選択に応じて切り替え、reasoning_effortは送信しない)

 

このように、モデルごとに「APIに送るパラメータの形」が異なるため、if 文で分岐して適切な形に組み立てています。

 

 

差分⑧:JavaScript【18】エラーメッセージ内の max_tokens 表示を2か所修正

 

次に、エラーメッセージ内で「max_tokens の現在値」を表示している箇所を、固定値から設定パネルの値に修正します。

 

■ 修正1:streamChatCompletion 内の空回答ガード

JavaScript【18】streamChatCompletion 関数内にある、以下のエラーメッセージ生成部分を検索してください。

 

🔍 検索用の目印①(このエラーメッセージを探す)
      throw new Error(
        "回答本文が生成される前に max_tokens(現在の設定: " + FIXED_MAX_TOKENS +
        ")の上限に達しました。思考過程でトークンを使い切った可能性があります。"
      );

 

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

 

📄 差分⑧-1:streamChatCompletion 内の修正
      throw new Error(
        "回答本文が生成される前に max_tokens(現在の設定: " +
        (parseInt(maxTokensInput.value, 10) || 8192) +
        ")の上限に達しました。思考過程でトークンを使い切った可能性があります。設定パネルで max_tokens を大きくしてください。"
      );

 

■ 修正2:readNonStreamResponse 内の空回答ガード

JavaScript【18】readNonStreamResponse 関数内にある、以下のエラーメッセージ生成部分を検索してください。

 

🔍 検索用の目印②(このエラーメッセージを探す)
      throw new Error(
        "回答本文が生成される前に max_tokens(現在の設定: " + FIXED_MAX_TOKENS +
        ")の上限に達しました。思考過程でトークンを使い切った可能性があります。"
      );

 

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

 

📄 差分⑧-2:readNonStreamResponse 内の修正
      throw new Error(
        "回答本文が生成される前に max_tokens(現在の設定: " +
        (parseInt(maxTokensInput.value, 10) || 8192) +
        ")の上限に達しました。思考過程でトークンを使い切った可能性があります。設定パネルで max_tokens を大きくしてください。"
      );

 

■ この差分のポイント

 

ここでは、2つの変更を行っています。

 

1. FIXED_MAX_TOKENS を「設定パネルの値」に変更

ステップ①では FIXED_MAX_TOKENS(固定値:8192)を表示していましたが、(parseInt(maxTokensInput.value, 10) || 8192) に変更しました。
これで、設定パネルで変更した値が、エラーメッセージにも正しく反映されます。

 

2. 解決策をエラーメッセージに追加

完成形と同じく、「設定パネルで max_tokens を大きくしてください。」という具体的な解決策を、エラーメッセージの末尾に追加しました。

 

「エラー」をただ表示するのではなく、「何が原因で、どうすれば解決するか」まで伝えるのは、ステップ①の解説(【18】補足)でも学んだ、使いやすいアプリを作るうえでとても大切な視点です。

 

差分⑨:JavaScript【24】イベント登録(設定変更時のイベントを登録)

 

最後の差分です。
新しく追加した設定項目(モデル名・reasoning_effort・max_tokens など)が変更された時に、設定を保存したり、表示を切り替えたりするためのイベントを登録します。

 

■ 追加1

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

 

🔍 検索用の目印(このイベント登録部分を探す)
  /* APIキー入力(変数に保持するだけ。保存はしない) */
  apiKeyInput.addEventListener("input", () => {
    apiKey = apiKeyInput.value.trim();
  });

 

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

 

■ 追加するコード

 

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

📄 差分⑨-1:設定変更時のイベント登録を追加

  /* ============================================================
     ステップ②で追加した設定項目のイベント登録
     ------------------------------------------------------------
     設定が変更された時に、設定を保存したり、
     モデルに応じた表示の切り替えを行います。
  ============================================================ */

  /* モデル名の変更時:
     モデルに応じた設定項目の表示を切り替えて、設定を保存する */
  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();
  });

 

■ 追加2:あわせて追加する関数 – validateMaxTokens(max_tokens の検証)

上記のコードで呼び出している validateMaxTokens という関数を、新たに追加します。

JavaScript【4】ユーティリティのセクションにある、以下のclearError 関数を検索してください。

 

🔍 検索用の目印(clearError 関数を探す)
/* --- エラーメッセージを消す --- */
function clearError() {
  errorArea.textContent = "";
  errorArea.classList.remove("visible");
}

 

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

 

📄 差分⑨-2:validateMaxTokens 関数の追加

/* --- max_tokens の入力検証(バリデーション) ---
   1以上の整数かどうかをチェックします。
   不正な値の場合はアラートを表示して false を返します。 */
function validateMaxTokens(value) {
  const n = Number(value);
  if (!Number.isInteger(n) || n < 1) {
    alert("最大トークン数は1以上の整数で入力してください");
    return false;
  }
  return true;
}

 

■ この差分のポイント

 

ここでは、設定項目ごとに「変更された時に、何をするか」を結び付けています。

 

 設定項目 変更時の動作
 モデル名 ① updateModelSpecificSettingsUI() で
 「そのモデルに応じた設定項目の表示」に切り替える
 ② saveUiSettings() で設定を保存する
 思考モード(K2.6) ① data 属性(dataset.k26Value)にも選択値を覚えさせる
 ② 設定を保存する
 max_tokens ① validateMaxTokens() で「1以上の整数か」を検証する
 ② 正しければ保存、不正ならデフォルト値(8192)に戻す
 reasoning_effort など 変更されたら、まとめて saveUiSettings() で保存する
 APIタイムアウト ① saveUiSettings() で設定を保存する
 (内部で applyRequestTimeoutSetting() が呼ばれ、
 通信で使う変数 REQUEST_TIMEOUT_MS も更新される)
 ② updateSettingHints() でヒント表示(○分 まで)を
 リアルタイムに更新する

 

また、APIタイムアウトだけは、他の設定項目と少し違う登録方法にしているのがポイントです。

 

他の項目は change イベント(入力欄からフォーカスが外れた時に1回だけ発火)で保存していますが、APIタイムアウトは input イベント(値が変わるたびに発火)で登録しています。
これは、差分⑥-4-1で追加した updateSettingHints() を「入力中にも呼び出す」ためで、
数字を1つ入力・消去するたびに、ヒント表示(○分 まで)がリアルタイムに追従します。

 

なお、saveUiSettings() の中で applyRequestTimeoutSetting() も呼ばれるため、
「保存」「変数への反映」「ヒント表示の更新」の3つが、この1つのイベントでまとめて行われます。

 

特に、max_tokens にはバリデーション(入力検証)を入れているのがポイントです。
「1以上の整数」以外(例:マイナスの数や、文字列)が入力された場合は、アラートを表示してデフォルト値に戻し、保存を行いません。
これが「おかしなデータは、早い段階で門前払いする」という、ステップ①で学んだ堅牢な設計の実践です。

 

 

動作確認の手順

 

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

 

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

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

 

2. 設定パネルを開く

右上の「⚙ 設定」ボタンを押して、設定パネルを開きます。
APIキーに加えて、
モデル名・reasoning_effort・max_tokens・画面更新間隔・APIタイムアウト
の項目が表示されていれば成功です。

 

3. モデルを切り替えてみる

「モデル名」を kimi-k2.7-code や kimi-k2.6 に切り替えてみてください。

 

・kimi-k2.7-code を選ぶと
… 「思考モード(kimi-k2.7-code)」の項目が現れ、「reasoning_effort」の項目が隠れます

設定パネルの表示 - kimi-k2.7-code:AIチャットアプリに設定機能を追加する方法|モデル・推論レベルをUIで変更【Runpod×Kimi入門②】
設定パネルの表示 – kimi-k2.7-code

・kimi-k2.6 を選ぶと
… 「思考モード(kimi-k2.6)」の項目が現れ、ON/OFFを選べるようになります

設定パネルの表示 - kimi-k2.6:AIチャットアプリに設定機能を追加する方法|モデル・推論レベルをUIで変更【Runpod×Kimi入門②】
設定パネルの表示 – kimi-k2.6

・kimi-k3 に戻すと
… 「reasoning_effort」の項目が再び現れ、「思考モード」の項目が隠れます

設定パネルの表示 - kimi-k3:AIチャットアプリに設定機能を追加する方法|モデル・推論レベルをUIで変更【Runpod×Kimi入門②】
設定パネルの表示 – kimi-k3

 

 

4. 設定を変えて会話してみる

モデル名や reasoning_effort を変えてから質問を送信し、回答が返ってくることを確認します。
回答の下の応答計測には、選択したモデル名(例:Kimi K2.6 / 思考ON / …)が表示されます。

 

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

設定を変えたあと、画面を更新(リロード)してみてください。
先ほど変更した設定(モデル名や max_tokens など)が、リロード後も残っていれば、localStorage への保存が正しく動いています。

6. タイムアウトのヒント表示を確認する

設定パネルの「APIタイムアウト (秒)」の下にあるヒント表示を見ながら、入力欄の数値を変更してみてください。

 

・初期値「360」の時 → 「6.0分 まで」と表示されます

・「120」に入力 → 入力中に 「2.0分 まで」に変わります

・「45」に入力 → 「45秒 まで」と秒表示になります

 

画面を更新(リロード)しても、保存した値に合わせたヒント表示が復元されれば、差分⑥-4と差分⑨の修正が正しく動いています。

 

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

 

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

 

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

 

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

コード内に「固定値」として書かれていた API の各種パラメータを、設定パネル(画面)から自由に変更できるように拡張しました。
さらに、変更した設定は localStorage に保存され、画面を更新しても残るようになっています。

 

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

 

・「値を関数経由で取得する」設計の効果
getSelectedModel() などの関数の中身を書き換えるだけで、呼び出し側のコードを一切変えずに拡張できた。

 

・「枠(Field)」と「入力部品(Select/Input)」の分離
表示の切り替えと値の読み取りを、独立して扱えるようにした。

 

・バリデーション(入力検証)
max_tokens に「1以上の整数か」の検証を入れ、おかしなデータを門前払いする堅牢な設計を実践した。

 

・localStorage による設定の保存・復元
テーマだけでなく、複数の設定項目をまとめて保存・復元する仕組みを拡張した。

 

・「設定の保存・復元」と「実際の動作への反映」は別の処理
localStorage に保存するだけでは、通信処理が使う変数は更新されない。
「設定パネルの値を、動作パラメータに反映する関数」を用意し、適切なタイミング(保存時・起動時)に呼ぶ設計を学んだ。

 

これらは、どれも「変更に強い、拡張しやすいコード」を書くための重要な考え方です。
ステップ①で仕込んだ「小さな工夫」が、ここで見事に実を結びました。

 

 

ステップ②:完成コード

 

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

 

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

📄 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/step2/

--------

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;       /* 少し太字にして読みやすく */
}
/* エラーメッセージ表示エリア */
#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>

    <!-- ステップ③で「カスタムシステムプロンプト」の項目を、
         この直後に追加します -->
  </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");

/* ============================================================
   【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
    };
    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;
    }
  } catch (e) { /* 無視 */ }

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

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

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

/* --- 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 + "秒 まで";
}

/* --- テーマボタンの表示を現在の状態に合わせて更新する --- */
function updateDarkModeBtn() {
  const isDark = document.body.classList.contains("dark");
  const btn = document.getElementById("darkModeBtn");
  if (btn) {
    btn.textContent = isDark ? "☀️ テーマ" : "🌙 テーマ";
  }
}

/* --- 現在使用すべきシステムプロンプトを返す ---
   ステップ②では固定値を返します(ステップ③で拡張)。 */
function getCurrentSystemPrompt() {
  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();
  });

  /* ============================================================
     ツールチップ補助(モバイル長押し+位置自動調整)
     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チャットアプリに設定機能を追加する方法|モデル・推論レベルをUIで変更【Runpod×Kimi入門②】
ステップ②完成時のアプリ画面

 

 

次のステップ③の予告

 

次のステップ③では、カスタムシステムプロンプトを設定パネルから編集できるようにします。

 

これまでコード内に固定値として書かれていた「システムプロンプト」(AIへの指示書)を、画面から自由に書き換えられるようにし、AIの性格や役割を自分好みにカスタマイズできるようにします。

 

ここでも、ステップ①で仕込んだ「仕掛け」が効いてきます。

 

getCurrentSystemPrompt() という関数が、現在は「固定値を返す」形になっています。
この関数の中身を「カスタムプロンプトが有効ならその値を、無効ならデフォルト値を返す」形に書き換えるだけで、呼び出し側(buildChatCompletionRequest)のコードは一切変えずに拡張できます。

 

それでは次のセクションから、ステップ③:カスタムシステムプロンプトを始めましょう。

 

【ステップ③】AIチャットアプリの拡張 – カスタムシステムプロンプト
:カスタムシステムプロンプトの作り方|AIの性格を自分で設定【Runpod×Kimi入門③】

Runpod API×KimiのAIチャットアプリ開発入門・第3回。
カスタムシステムプロンプトを設定パネルから編集できるようにし、AIの性格や役割を自由にカスタマイズ。プロンプトエンジニアリングの第一歩を差分コード付きで解説します。

 

by 子供プログラマー

 

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