ウェルカム画面の実装方法|アプリ完成【Runpod×Kimi入門⑦】

ウェルカム画面の実装方法|アプリ完成【Runpod×Kimi入門⑦】


 

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

 

 

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

 

ステップ⑦では、ステップ⑥で完成したアプリに、いよいよ最後の拡張
――ウェルカム画面を実装します。

 




 

これは、起動時や新規チャット時に表示される「初期画面」です。

ステップ①から、新規チャット時は「何も表示されない状態」から始まっていましたが、このステップ⑦で、
「APIキーの入力状態に応じて、以下の2つの表示に切り替わるウェルカム画面」
を実装します。

 

 APIキーの状態 表示される内容
 未入力の場合 「カスタムAIチャットアプリ開発(MIT License)」
  というタイトルと、
 「画面右上の「設定」パネルを開き「APIキー」を入力し
  チャットを始めましょう。」という案内が表示される
 入力済みの場合 選択中のモデル名(例:Kimi K3)というタイトルと、
 「準備はできています。会話を始めましょう。」という
  案内が表示される

 

このウェルカム画面により、
「APIキーをまだ入力していないのか、それとも入力済みなのか」
が、起動時や新規チャット時に一目で分かるようになります。

 

【APIキー未入力時の表示】

ステップ⑦完成時のウェルカム画面 - APIキー未入力時:ウェルカム画面の実装方法|アプリ完成【Runpod×Kimi入門⑦】
ステップ⑦完成時のウェルカム画面 – APIキー未入力時

 

【APIキー入力済み時の表示】

ステップ⑦完成時のウェルカム画面 - APIキー入力済み時:ウェルカム画面の実装方法|アプリ完成【Runpod×Kimi入門⑦】
ステップ⑦完成時のウェルカム画面 – APIキー入力済み時

 

ウェルカム画面は「どういう仕組み」で動いているの?

 

このウェルカム画面は、以下の3つの技術で実現されています。

 

1. 状態変数(conversations)とDOM参照(apiKeyInput)を見て、画面の表示を切り替える

「現在の会話が空っぽ(メッセージが0件)かどうか」と「APIキーが入力されているかどうか」の2つの状態を見て、「ウェルカム画面を表示するかどうか」と「タイトルとサブタイトルをどう書き換えるか」を決めています。

 

📄 JavaScript:ウェルカム画面の表示制御(確認のみ・作業不要)
  const conv = getActiveConversation();
  const isEmpty = conv && conv.messages.length === 0;   // 会話が空っぽかどうか
  const hasApiKey = apiKeyInput.value.trim() !== "";      // APIキーが入力されているか

  if (isEmpty) {
    /* タイトルとサブタイトルをAPIキー状態で切り替え */
    if (hasApiKey) {
      welcomeTitle.textContent = getModelDisplayName();   // モデル名(例:Kimi K3)
      welcomeSubtitle.textContent = "準備はできています。会話を始めましょう。";
    } else {
      welcomeTitle.textContent = "カスタムAIチャットアプリ開発(MIT License)";
      welcomeSubtitle.textContent = "画面右上の「設定」パネルを開き「APIキー」を入力しチャットを始めましょう。";
    }
    welcomeScreen.classList.add("visible");   // ウェルカム画面を表示
  }

 

「データが変わるたびに、画面を最新の状態に保つ」という、おなじみの設計の骨格の、ウェルカム画面版です。

 

2. renderActiveConversation への連携

ステップ⑥で追加した updateQuestionTimeline と同じく、renderActiveConversation の中で updateWelcomeScreen を呼び出すことで、
「会話を切り替えた時や、新しい質問を送信した時に、ウェルカム画面も自動で更新される」
ようになります。

 

ただし、ここには1つ重要な工夫があります。

renderActiveConversation は、
「一度空にしてから、最新のデータで作り直す」(【8】の基本パターン)
という処理を行いますが、この時
chatContainer.innerHTML = “”;
でチャット欄の中身がすべて消去されてしまいます。

もしウェルカム画面のHTMLがチャット欄の中に書かれていると、この消去処理で「ウェルカム画面ごと消えてしまう」問題が起きます。

 

そこで完成形では、
「消去する前に、ウェルカム画面を一度『退避』させてから、消去後に『戻す』」
という処理を行っています。

 

📄 JavaScript:renderActiveConversation 内の退避処理(確認のみ・作業不要)
function renderActiveConversation() {
  const conv = getActiveConversation();
  if (!conv) return;

  /* innerHTML で消えないよう、ウェルカム画面を退避 */
  const welcomeScreen = $("#welcomeScreen");

  chatContainer.innerHTML = "";   // ← ここでウェルカム画面も消えてしまうが…

  /* メッセージより先にウェルカム画面を戻す */
  if (welcomeScreen) {
    chatContainer.appendChild(welcomeScreen);   // ← 退避させておいたので、ここで戻せる
  }

  conv.messages.forEach(m => chatContainer.appendChild(renderMessage(m)));

  updateQuestionTimeline();
  updateWelcomeScreen();   // ← ウェルカム画面の表示制御も更新
  scrollToBottom(true);
}

 

「消去する前に退避させ、消去後に戻す」という、さりげないが重要な配慮です。
これにより、「一度空にしてから作り直す」という基本パターンを保ちながら、
「ウェルカム画面だけは消さない」という、2つの要件を両立させています。

 

3. APIキー入力時・モデル変更時の「即時更新」

最後に、これがこのステップの最も重要なポイントです。

ウェルカム画面のタイトルとサブタイトルは、「APIキーが入力されているか」と「どのモデルが選ばれているか」で変わります。
そのため、「APIキーを入力した時」や「モデルを変更した時」にも、ウェルカム画面が「即座に」更新される必要があります。

 

完成形では、以下の2つのイベントで、updateWelcomeScreen() を呼び出しています。

 

 イベント 動作
 APIキー入力時
 (apiKeyInput の input イベント)
 APIキーを入力・消去するたびに、
 updateWelcomeScreen() を呼び出して、
 ウェルカム画面のタイトルとサブタイトルを
 「即座に」書き換える
 モデル変更時
 (modelInput の
  change イベント)
 モデルを変更するたびに、
 updateWelcomeScreen() を呼び出して、
 ウェルカム画面のタイトル(モデル名)を
 「即座に」書き換える

 

これにより、
「APIキーを入力した瞬間に、ウェルカム画面が『準備はできています』に切り替わる」
「モデルを変更した瞬間に、タイトルがそのモデル名に切り替わる」
という、リアルタイムな更新が実現しています。

「データが変わるたびに、画面を最新の状態に保つ」という設計の骨格が、ここでも鮮やかに活きています。

 

 

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

 

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

 

1. HTML:
ウェルカム画面のHTMLを、チャット表示領域(#chatContainer)の中に追加

2. CSS:
ウェルカム画面(#welcomeScreen)と、空状態時の中央配置(#main.is-welcome)のスタイルを追加

3. JavaScript(新規セクション):
updateWelcomeScreen 関数を追加

4. JavaScript【9】:
renderActiveConversation を「ウェルカム画面の退避と、updateWelcomeScreen() の呼び出し」に書き換え

5. JavaScript【24】:
apiKeyInput の input イベントと、modelInput の change イベントに、
updateWelcomeScreen() の呼び出しを追加

 

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

 

 

差分①:HTML(ウェルカム画面を、チャット表示領域(#chatContainer)の中に追加)

 

まず、ウェルカム画面のHTMLを追加します。

 

■ 追加する場所

チャット表示領域(id=”chatContainer”)の中にある、以下のコメント行を検索してください。

 

🔍 検索用の目印(このコメント行を探す)
  <!-- 会話表示エリア(メッセージ一覧)
       ※ステップ①ではウェルカム画面は未実装のため、
         新規チャット時は「何も表示されない状態」から始まります。
         (ウェルカム画面はステップ⑦で追加します)
       ※質問ナビゲーションタイムラインはステップ⑥で追加します -->
  <div id="chatContainer"></div>

 

この <div id=”chatContainer”></div> を、以下のコードに置き換えてください。

 

■ 置き換えるコード

 

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

📄 差分①:ウェルカム画面の追加HTML
  <div id="chatContainer">
    <!-- ウェルカム画面(ステップ⑦で追加)
         起動時や新規チャット時に表示される「初期画面」です。
         初期状態は非表示(display: none)で、JavaScriptの
         updateWelcomeScreen 関数が、「会話が空っぽの時」だけ
         visible クラスを付けて表示します。
         タイトルとサブタイトルは、APIキーの入力状態に応じて、
         JavaScriptが書き換えます。 -->
    <div id="welcomeScreen">
      <div class="welcome-title" id="welcomeTitle">カスタムAIチャットアプリ開発(MIT License)</div>
      <div class="welcome-subtitle" id="welcomeSubtitle">画面右上の「設定」パネルを開き「APIキー」を入力しチャットを始めましょう。</div>
    </div>
  </div>

 

■ この差分のポイント

 

・#welcomeScreen は、起動時や新規チャット時に表示される「初期画面」の「枠」です。
初期状態は非表示(display: none)で、JavaScript(後の差分③で追加する updateWelcomeScreen 関数)が、
「会話が空っぽの時」
だけ visible クラスを付けて表示します。

・#welcomeTitle と #welcomeSubtitle は、ウェルカム画面の「タイトル」と「サブタイトル」の表示部分です。
初期値として
「カスタムAIチャットアプリ開発(MIT License)」
『画面右上の「設定」パネルを開き「APIキー」を入力しチャットを始めましょう。』
と書かれていますが、これは「APIキー未入力時」の表示です。
APIキーが入力されると、JavaScript(updateWelcomeScreen)が
「選択中のモデル名」
「準備はできています。会話を始めましょう。」
に書き換えます。

・これは、ステップ①から学んできた
「HTMLでは場所(器)だけ用意して、中身はJavaScriptが後から入れる」
という、動的なWebアプリの基本的な考え方と同じ構造です。

 

差分②:CSS(ウェルカム画面と、空状態時の中央配置のスタイルを追加)

 

差分①で追加したウェルカム画面の見た目を整えるためのスタイルを追加します。

ここには、「ウェルカム画面そのもののスタイル」と、「空状態時に、ウェルカム画面と入力欄を『一体として中央配置』にするスタイル」の、2つの役割があります。

 

■ 追加する場所

CSSの中にある、「長い質問の折りたたみ」のセクションを検索してください。

 

🔍 検索用の目印(「長い質問の折りたたみ」のセクションを探す)
/* ============================================================
   長い質問の折りたたみ(「全文を表示」)
============================================================ */

 

このセクションの直前((「応答計測結果の表示」セクションの直後)に、以下のコードを追加してください。

 

■ 追加するコード

 

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

📄 差分②:ウェルカム画面と、空状態時の中央配置のスタイル追加>
/* ============================================================
   ウェルカム画面(ステップ⑦で追加)
   起動時や新規チャット時に表示される「初期画面」のスタイルです。
   空状態時(会話が空っぽの時)には、ウェルカム画面と入力欄を
   「一体として中央配置」にするための調整も含まれています。
   (完成形と同じスタイル)
============================================================ */
#welcomeScreen {
  display: none;            /* 初期状態は非表示 */
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  padding: 60px 24px 24px;
  text-align: center;
}
#welcomeScreen.visible { display: flex; }   /* visibleクラスが付いたら表示 */
/* タイトル(「カスタムAIチャットアプリ開発(MIT License)」) */
#welcomeScreen .welcome-title {
  font-size: 28px;
  font-weight: 700;
  margin-bottom: 12px;
  color: var(--text);
}
/* サブタイトル(案内文) */
#welcomeScreen .welcome-subtitle {
  font-size: 15px;
  color: var(--text-sub);
  margin-bottom: 24px;
  line-height: 1.8;
}

/* ============================================================
   空状態時:ウェルカム画面と入力欄を「一体として中央配置」
   ------------------------------------------------------------
   会話が空っぽの時(#main に is-welcome クラスが付いた時)、
   ウェルカム画面と入力欄を「上下の中央」に配置するための調整です。
   これにより、空っぽの画面が「上部に寄りすぎず」、
   見た目のバランスが良くなります(完成形と同じ仕様)。
============================================================ */
/* 空状態時のチャット表示領域の調整 */
#main.is-welcome #chatContainer {
  flex: 0 0 auto;          /* 高さを内容に合わせる */
  margin-top: auto;        /* 上側の空きスペースを吸収 */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  padding-bottom: 0;
  overflow: visible;
}
/* 空状態時のウェルカム画面の余白調整 */
#chatContainer.is-welcome #welcomeScreen {
  padding: 0 24px;
}
/* 空状態時のサブタイトルの余白調整 */
#main.is-welcome .welcome-subtitle {
  margin-bottom: 20px;
}
/* 空状態時の入力エリアの調整 */
#main.is-welcome #inputArea {
  margin-bottom: auto;     /* 下側の空きスペースを吸収(上下バランス) */
  border-top: none;
  padding: 0 24px 24px;
  width: 100%;
}
/* 空状態時の入力欄の調整(カードの幅と配置) */
#main.is-welcome #inputRow {
  max-width: 680px;
  align-items: flex-start;
}
/* 空状態時のテキストエリアの調整(高さを固定) */
#main.is-welcome #messageInput {
  min-height: 140px;
  max-height: 140px;
  overflow-y: auto;
}
/* 空状態時の送信・停止ボタンの調整 */
#main.is-welcome #sendBtn,
#main.is-welcome #stopBtn {
  margin-top: 4px;
}

/* モバイル対応(画面幅768px以下の時適用) */
@media (max-width: 768px) {
  #welcomeScreen .welcome-title { font-size: 22px; }
  #main.is-welcome #inputArea { padding: 0 16px 16px; }
  #main.is-welcome #messageInput { min-height: 110px; max-height: 110px; }
}

 

■ この差分のポイント

 

ここでは、3つの重要なポイントを押さえておきましょう。

 

1. #welcomeScreen.visible による「表示・非表示の切り替え」

#welcomeScreen は display: none で初期状態は非表示です。
JavaScript(後の差分③で追加する updateWelcomeScreen 関数)が、「会話が空っぽの時」だけ visible クラスを付けて、display: flex で表示します。

これが、「CSSに『状態』を定義し、JavaScriptでその状態を切り替える」という、おなじみの設計パターンの、ウェルカム画面版です。

 

2. #main.is-welcome による「空状態時の中央配置」

これが、このステップの最も重要なポイントです。

「空状態時(会話が空っぽの時)には、ウェルカム画面と入力欄を『一体として中央配置』にする」という、さりげないが洗練された配慮です。

 

📄 CSS:空状態時の中央配置の仕組み(確認のみ・作業不要)
#main.is-welcome #chatContainer {
  flex: 0 0 auto;          /* 高さを内容に合わせる */
  margin-top: auto;        /* 上側の空きスペースを吸収 */
  /* … */
}
#main.is-welcome #inputArea {
  margin-bottom: auto;     /* 下側の空きスペースを吸収(上下バランス) */
  /* … */
}

 

ここで使われているのが、
margin-top: auto と margin-bottom: auto
という、Flexboxの「自動マージン」の仕組みです。

 

・#chatContainer に margin-top: auto を指定すると、「上側の空きスペース」が自動で吸収され、チャット表示領域が「下寄り」に配置されます。

・#inputArea に margin-bottom: auto を指定すると、「下側の空きスペース」が自動で吸収され、入力エリアが「上寄り」に配置されます。

 

この2つの組み合わせにより、
「ウェルカム画面と入力欄が、上下の中央に、バランス良く配置される」
という、洗練されたレイアウトが実現しています。

「空っぽの画面が、上部に寄りすぎず、見た目のバランスが良い」という、さりげないが重要なユーザー体験への配慮です。

 

3. 空状態時のテキストエリアの高さ固定

空状態時には、テキストエリアの高さを「140px」に固定しています。

 

📄 CSS:空状態時のテキストエリアの高さ固定(確認のみ・作業不要)
#main.is-welcome #messageInput {
  min-height: 140px;
  max-height: 140px;
  overflow-y: auto;
}

 

これは、「空状態時には、テキストエリアを『最初から大きく』表示する」という、配慮です。
通常時は「1行の高さ」から始まり、入力に応じて伸縮しますが、空状態時は「最初から大きな入力欄」として表示することで、「ここに何かを入力してほしい」という、ユーザーへの視覚的な促しになっています。

 

モバイル(画面幅768px以下)では「110px」に調整されており、画面の狭い環境でも、バランスの取れた表示になっています。

 

 

ここまでの中間確認(差分①・②)

 

差分①・②で、以下の「土台」が整いました。

 

・差分①(HTML):
ウェルカム画面のHTMLを、チャット表示領域(#chatContainer)の中に追加

・差分②(CSS):
ウェルカム画面(#welcomeScreen)と、空状態時の中央配置(#main.is-welcome)のスタイルを追加

 

まだこの時点では「HTMLとCSSを追加しただけ」で、ウェルカム画面は「常に非表示」のままです。
実際に表示されるのは、後の差分③(updateWelcomeScreen 関数の追加)と、差分④(renderActiveConversation の書き換え)、差分⑤(イベント登録)を追加してからです。

 

次の差分③では、「updateWelcomeScreen 関数」を追加します。
これが、
「会話が空っぽの時だけ、ウェルカム画面を表示し、タイトルとサブタイトルをAPIキーの入力状態に応じて書き換える」
という、ウェルカム画面の「心臓部」となる関数です。

 

差分③:JavaScript(新規セクション)updateWelcomeScreen 関数を追加

 

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

ここでは、
「会話が空っぽの時だけ、ウェルカム画面を表示し、タイトルとサブタイトルをAPIキーの入力状態に応じて書き換える」
という、ウェルカム画面の「心臓部」となる updateWelcomeScreen 関数を追加します。

 

この関数は、以下の2つの状態を見て、ウェルカム画面の表示を制御します。

 

 見ている状態 役割
 「会話が空っぽかどうか」
 (conv.messages.length === 0)
 空っぽの時だけ、ウェルカム画面を表示する。
 メッセージが1件でもあれば、ウェルカム画面を隠す
 「APIキーが
  入力されているかどうか」
 (apiKeyInput.value
  .trim() !== “”)
 入力済みなら「モデル名」「準備はできています。」と表示。
 未入力なら「カスタムAIチャットアプリ開発(MIT License)」
 「APIキーを入力しチャットを始めましょう。」と表示

 

■ 追加する場所

JavaScript【9】renderMessage 関数の閉じ括弧 } の直後(renderActiveConversation 関数のコメント行の直前)に追加します。

まず、以下のコードブロックを検索してください(renderMessage 関数の末尾部分と、renderActiveConversation 関数のコメント行です)。

 

🔍 検索用の目印(renderMessage 関数の末尾と、renderActiveConversation 関数のコメント行を探す)
  body.appendChild(actions);
  wrap.appendChild(avatar);
  wrap.appendChild(body);
  return wrap;
}

/* --- 現在の会話の「メッセージ一覧」を描画する ---
   ※ウェルカム画面はステップ⑦で追加します。
   ※ステップ⑥で、タイムラインの更新も追加しました。 */

 

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

 

■ 追加するコード

 

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

📄 差分③:updateWelcomeScreen 関数の追加
/* ============================================================
   ウェルカム画面の表示制御(ステップ⑦で追加)
   ------------------------------------------------------------
   起動時や新規チャット時に表示される「初期画面」(ウェルカム画面)を
   制御する関数です。
   「会話が空っぽの時」だけウェルカム画面を表示し、
   タイトルとサブタイトルをAPIキーの入力状態に応じて書き換えます。
   (完成形の updateWelcomeScreen と同じ仕様)
============================================================ */
function updateWelcomeScreen() {
  const welcomeScreen = $("#welcomeScreen");
  const welcomeTitle = $("#welcomeTitle");
  const welcomeSubtitle = $("#welcomeSubtitle");
  if (!welcomeScreen) return;

  const conv = getActiveConversation();
  const isEmpty = conv && conv.messages.length === 0;   // 会話が空っぽかどうか
  const hasApiKey = apiKeyInput.value.trim() !== "";        // APIキーが入力されているか

  if (isEmpty) {
    /* タイトルとサブタイトルをAPIキー状態で切り替え */
    if (hasApiKey) {
      /* APIキー入力済みの場合:
         選択中のモデル名(例:Kimi K3)と「準備はできています。」を表示する */
      welcomeTitle.textContent = getModelDisplayName();
      welcomeSubtitle.textContent = "準備はできています。会話を始めましょう。";
    } else {
      /* APIキー未入力の場合:
         「カスタムAIチャットアプリ開発(MIT License)」と「APIキーを入力しチャットを始めましょう。」を表示する */
      welcomeTitle.textContent = "カスタムAIチャットアプリ開発(MIT License)";
      welcomeSubtitle.textContent = "画面右上の「設定」パネルを開き「APIキー」を入力しチャットを始めましょう。";
    }
    /* 会話が空っぽの場合:ウェルカム画面を表示する */
    welcomeScreen.classList.add("visible");
    chatContainer.classList.add("is-welcome");
    $("#main").classList.add("is-welcome");
  } else {
    /* メッセージが1件でもある場合:ウェルカム画面を隠す */
    welcomeScreen.classList.remove("visible");
    chatContainer.classList.remove("is-welcome");
    $("#main").classList.remove("is-welcome");
  }
}

 

■ この差分のポイント

 

ここでは、3つの重要なポイントを押さえておきましょう。

 

1. 「2つの状態」を見て、表示を切り替える設計

この関数は、「会話が空っぽかどうか」と「APIキーが入力されているかどうか」の2つの状態を見て、ウェルカム画面の表示を制御しています。

 

これが、「データが変わるたびに、画面を最新の状態に保つ」という、ステップ①から学んできた設計の骨格の、ウェルカム画面版です。

 

2. classList.add / remove による「状態の切り替え」

ウェルカム画面の表示・非表示は、visible クラスの付け外しで切り替えています。

 

・会話が空っぽの場合 … welcomeScreen.classList.add(“visible”) で表示
・メッセージが1件でもある場合 … welcomeScreen.classList.remove(“visible”) で非表示

 

あわせて、chatContainer と #main にも is-welcome クラスを付け外ししています。
これが、差分②で追加したCSS(#main.is-welcome #chatContainer や #main.is-welcome #inputArea)と連携して、「空状態時に、ウェルカム画面と入力欄を『一体として中央配置』にする」ための仕組みです。

 

「CSSに『状態』を定義し、JavaScriptでその状態を切り替える」という、おなじみの設計パターンの、ウェルカム画面版です。

 

3. getModelDisplayName() の再利用

APIキー入力済みの場合、タイトルに「選択中のモデル名」(例:Kimi K3)を表示しています。
これは、ステップ②で追加した getModelDisplayName() を再利用している、DRYの原則の実践です。

 

「同じ処理は、同じ関数に任せる」ことで、
「モデルを変更した時に、ウェルカム画面のタイトルも自動で切り替わる」
という、シームレスな連携が実現しています。
この「モデル変更時の即時更新」は、後の差分⑤で、modelInput の change イベントに updateWelcomeScreen() を追加することで実現します。

 

差分④:JavaScript【9】renderActiveConversation(ウェルカム画面の退避と、updateWelcomeScreen() の呼び出しに書き換え)

 

次に、renderActiveConversation 関数を、
「ウェルカム画面の退避と、updateWelcomeScreen() の呼び出し」
に書き換えます。

 

これが、このステップの最も重要なポイントです。

renderActiveConversation は、
「一度空にしてから、最新のデータで作り直す」(【8】の基本パターン)
という処理を行いますが、この時
chatContainer.innerHTML = “”;
でチャット欄の中身がすべて消去されてしまいます。

 

もしウェルカム画面のHTMLがチャット欄の中に書かれていると、この消去処理で
「ウェルカム画面ごと消えてしまう」
問題が起きます。

 

そこで、
「消去する前に、ウェルカム画面を一度『退避』させてから、消去後に『戻す』」
という処理を行うことで対応します。

 

■ 置き換える場所

JavaScript【9】renderActiveConversation 関数全体を、以下のコードで置き換えてください。

 

🔍 検索用の目印(renderActiveConversation 関数を探す)
/* --- 現在の会話の「メッセージ一覧」を描画する ---
   ※ウェルカム画面はステップ⑦で追加します。
   ※ステップ⑥で、タイムラインの更新も追加しました。 */
function renderActiveConversation() {
  const conv = getActiveConversation();
  if (!conv) return;
  chatContainer.innerHTML = "";
  conv.messages.forEach(m => chatContainer.appendChild(renderMessage(m)));

  /* タイムラインのマーカーも更新する(ステップ⑥で追加)
     会話を切り替えた時や、新しい質問を送信した時に、
     タイムラインのマーカーも自動で更新されます。 */
  updateQuestionTimeline();

  scrollToBottom(true);
}

 

この関数全体を以下のコードに置き換えてください。

 

■ 置き換えるコード

 

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

📄 差分④:renderActiveConversation を書き換え
/* --- 現在の会話の「メッセージ一覧」を描画する ---
   ※ステップ⑦で、ウェルカム画面の退避と、
     updateWelcomeScreen() の呼び出しを追加しました。 */
function renderActiveConversation() {
  const conv = getActiveConversation();
  if (!conv) return;

  /* innerHTML で消えないよう、ウェルカム画面を退避(ステップ⑦で追加)
     chatContainer.innerHTML = "" でチャット欄の中身がすべて消去されるため、
     ウェルカム画面も一緒に消えてしまいます。
     そこで、消去する前にウェルカム画面を一度「退避」させてから、
     消去後に「戻す」処理を行います。 */
  const welcomeScreen = $("#welcomeScreen");

  chatContainer.innerHTML = "";

  /* メッセージより先にウェルカム画面を戻す(ステップ⑦で追加)
     退避させておいたウェルカム画面を、チャット欄の先頭に戻します。
     これにより、ウェルカム画面が「消去されずに残る」ようになります。 */
  if (welcomeScreen) {
    chatContainer.appendChild(welcomeScreen);
  }

  conv.messages.forEach(m => chatContainer.appendChild(renderMessage(m)));

  /* タイムラインのマーカーも更新する(ステップ⑥で追加) */
  updateQuestionTimeline();

  /* ウェルカム画面の表示制御も更新する(ステップ⑦で追加)
     会話を切り替えた時や、新しい質問を送信した時に、
     ウェルカム画面も自動で更新されます。 */
  updateWelcomeScreen();

  scrollToBottom(true);
}

 

■ この差分のポイント

 

ここでは、3つの重要なポイントを押さえておきましょう。

 

1. 「消去する前に退避させ、消去後に戻す」という、さりげないが重要な配慮

この関数の冒頭にある const welcomeScreen = $(“#welcomeScreen”); が、「退避」の処理です。

 

chatContainer.innerHTML = “”; でチャット欄の中身がすべて消去されるため、ウェルカム画面も一緒に消えてしまいます。
そこで、消去する前にウェルカム画面を変数に「退避」させておき、消去後に chatContainer.appendChild(welcomeScreen); で「戻す」処理を行っています。

 

これにより、「一度空にしてから作り直す」という基本パターンを保ちながら、「ウェルカム画面だけは消さない」という、2つの要件を両立させています。

 

2. 「メッセージより先にウェルカム画面を戻す」という順番の意味

退避させたウェルカム画面を、メッセージの描画(conv.messages.forEach)より先に戻しています。

 

これは、「ウェルカム画面が、チャット欄の先頭に配置される」ための、順番の配慮です。
もしメッセージの描画より後に戻すと、
「ウェルカム画面が、メッセージの後ろに配置されてしまう」
という、表示の順番のずれが起きます。

 

「退避させたものを、正しい順番で戻す」という、さりげないが重要な配慮です。

 

3. updateWelcomeScreen() の呼び出しによる「自動更新」

この関数の中で updateWelcomeScreen() を呼ぶことで、
「会話を切り替えた時や、新しい質問を送信した時に、ウェルカム画面も自動で更新される」
ようになります。

 

これが、「データが変わるたびに、画面を最新の状態に保つ」という、ステップ①から学んできた設計の骨格の、ウェルカム画面版です。
ステップ⑥で追加した updateQuestionTimeline と同じく、renderActiveConversation の中で updateWelcomeScreen を呼び出すことで、
「会話の切り替えや、新しい質問の送信に、ウェルカム画面も追従する」
という、シームレスな連携が実現しています。

 

差分⑤:JavaScript【24】イベント登録(apiKeyInput・modelInput のイベントに updateWelcomeScreen() を追加)

 

最後の差分です。
apiKeyInput の input イベントと、modelInput の change イベントに、updateWelcomeScreen() の呼び出しを追加します。

 

これが、
「APIキーを入力した瞬間に、ウェルカム画面が『準備はできています』に切り替わる」
「モデルを変更した瞬間に、タイトルがそのモデル名に切り替わる」
という、リアルタイムな更新を実現するための、重要な連携です。

 

■ 修正1:apiKeyInput の input イベントに updateWelcomeScreen() を追加

 

修正する場所

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

 

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

 

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

 

📄 差分⑤-1:apiKeyInput の input イベントに updateWelcomeScreen() を追加
  /* APIキー入力(変数に保持するだけ。保存はしない)
     ※ステップ⑦で、updateWelcomeScreen() の呼び出しを追加しました。
       APIキーを入力・消去するたびに、ウェルカム画面の
       タイトルとサブタイトルを「即座に」書き換えます。 */
  apiKeyInput.addEventListener("input", () => {
    apiKey = apiKeyInput.value.trim();
    updateWelcomeScreen(); // 入力状態に応じてウェルカム画面を即時更新
  });

 

■ この差分のポイント(修正1)

 

apiKeyInput の input イベントに updateWelcomeScreen() を追加しただけで、
「APIキーを入力した瞬間に、ウェルカム画面が『準備はできています』に切り替わる」
という、リアルタイムな更新が実現します。

 

これが、「データが変わるたびに、画面を最新の状態に保つ」という設計の骨格の、ウェルカム画面版です。
「APIキーを入力した瞬間に、ウェルカム画面が追従する」という、シームレスなユーザー体験が実現しています。

 

■ 修正2:modelInput の change イベントに updateWelcomeScreen() を追加

 

続いて、modelInput の change イベントに、updateWelcomeScreen() の呼び出しを追加します。

 

修正する場所

JavaScript【24】registerEvents 関数内にある、以下のモデル名の変更時のイベント登録部分を検索してください。

 

🔍 検索用の目印(このイベント登録部分を探す)
  /* モデル名の変更時:
     モデルに応じた設定項目の表示を切り替えて、設定を保存する */
  modelInput.addEventListener("change", () => {
    updateModelSpecificSettingsUI();   // 差分④で追加した表示切替関数
    saveUiSettings();                  // 設定を保存
  });

 

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

 

📄 差分⑤-2:modelInput の change イベントに updateWelcomeScreen() を追加
  /* モデル名の変更時:
     モデルに応じた設定項目の表示を切り替えて、設定を保存する
     ※ステップ⑦で、updateWelcomeScreen() の呼び出しを追加しました。
       モデルを変更するたびに、ウェルカム画面のタイトル(モデル名)を
       「即座に」書き換えます。 */
  modelInput.addEventListener("change", () => {
    updateModelSpecificSettingsUI();   // 差分④で追加した表示切替関数
    saveUiSettings();                  // 設定を保存
    updateWelcomeScreen();             // モデル名表示を即時更新(ステップ⑦で追加)
  });

 

■ この差分のポイント(修正2)

 

modelInput の change イベントに updateWelcomeScreen() を追加しただけで、「モデルを変更した瞬間に、ウェルカム画面のタイトルがそのモデル名に切り替わる」という、リアルタイムな更新が実現します。

 

これも、「データが変わるたびに、画面を最新の状態に保つ」という設計の骨格の、ウェルカム画面版です。
「モデルを変更した瞬間に、ウェルカム画面のタイトルが追従する」という、シームレスなユーザー体験が実現しています。

 

 

動作確認の手順

 

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

 

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

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

 

2. 起動時のウェルカム画面を確認する

アプリを起動すると、「カスタムAIチャットアプリ開発(MIT License)」というタイトルと、
『画面右上の「設定」パネルを開き「APIキー」を入力しチャットを始めましょう。』
という案内が、画面中央に表示されていれば成功です。

 

ステップ⑦完成時のウェルカム画面 - APIキー未入力時:ウェルカム画面の実装方法|アプリ完成【Runpod×Kimi入門⑦】
ウェルカム画面 – APIキー未入力時

 

3. APIキーを入力してみる

設定パネルを開き、APIキーを入力してください。
「カスタムAIチャットアプリ開発(MIT License)」のタイトルが、「Kimi K3」(選択中のモデル名)に切り替わり、「準備はできています。会話を始めましょう。」という案内に変われば、差分⑤-1の修正が正しく動いています。

 

ステップ⑦完成時のウェルカム画面 - APIキー入力済み時:ウェルカム画面の実装方法|アプリ完成【Runpod×Kimi入門⑦】
ウェルカム画面 – APIキー入力済み時

 

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

設定パネルで「モデル名」を kimi-k2.7-code や kimi-k2.6 に切り替えてみてください。
ウェルカム画面のタイトルが、「Kimi K2.7 Code」や「Kimi K2.6」に切り替われば、差分⑤-2の修正が正しく動いています。

 

5. 質問を送信してみる

質問を送信すると、ウェルカム画面が消え、通常のチャット画面に切り替わります。
renderActiveConversation(差分④)が正しく動いていれば、「ウェルカム画面が消えずに残る」ことなく、「質問の送信で、ウェルカム画面が消えてチャット画面に切り替わる」はずです。

 

6. 新しいチャットを作成してみる

「+ 新しいチャット」ボタンを押すと、新しい空のチャットが作成され、ウェルカム画面が再び表示されます。
「新しいチャット時に、ウェルカム画面が再び表示される」ことを確認してください。

 

7. 会話を切り替えてみる

サイドバーの履歴一覧から、別の会話を選択してみてください。
メッセージがある会話に切り替えるとウェルカム画面が消え、空の会話に切り替えるとウェルカム画面が再び表示されます。
「会話を切り替えた時に、ウェルカム画面も自動で更新される」ことを確認してください。

 

 

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

 

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

 

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

 

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

起動時や新規チャット時に表示される「初期画面」(ウェルカム画面)を実装しました。
APIキーの入力状態に応じて、「カスタムAIチャットアプリ開発(MIT License)」と「選択中のモデル名」の2つの表示に切り替わり、空状態時にはウェルカム画面と入力欄が「一体として中央配置」になります。
APIキーを入力した瞬間や、モデルを変更した瞬間に、ウェルカム画面が「即座に」更新される、リアルタイムな連携も実現しています。

 

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

 

・状態変数(conversations)とDOM参照(apiKeyInput)を見て、画面の表示を切り替える:
「データが変わるたびに、画面を最新の状態に保つ」という、おなじみの設計の骨格の、ウェルカム画面版です。

・「消去する前に退避させ、消去後に戻す」という配慮:
「一度空にしてから作り直す」という基本パターンを保ちながら、「ウェルカム画面だけは消さない」という、2つの要件を両立させる、完成形の中でもさりげないが重要な配慮です。

・「メッセージより先にウェルカム画面を戻す」という順番の意味:
「退避させたものを、正しい順番で戻す」という、表示の順番のずれを防ぐ配慮です。

・classList.add / remove による「状態の切り替え」:
「CSSに『状態』を定義し、JavaScriptでその状態を切り替える」という、おなじみの設計パターンの、ウェルカム画面版です。

・getModelDisplayName() の再利用:
「同じ処理は、同じ関数に任せる」という、DRYの原則の実践です。

 

これらは、どれも「ユーザーの操作を支援するUI開発」で通用する、実務的な基礎体力です。
特に「退避させる」という考え方は、「一度空にしてから作り直す」という基本パターンと、「消してはいけないものを残す」という要件が衝突した時に、どう両立させるかを学ぶ格好の題材です。

 

 

ステップ⑦:完成コード

 

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

 

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

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

--------

Permission is hereby granted, free of charge, to any person obtaining a 
copy of this software and associated documentation files (the “Software”), 
to deal in the Software without restriction, including without limitation 
the rights to use, copy, modify, merge, publish, distribute, sublicense, 
and/or sell copies of the Software, and to permit persons to whom the Software 
is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all 
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

     ============================================================================== -->

<!-- ============================================================
     外部ライブラリの読み込み(CDN:インターネット経由)
     1ファイル完結のため、「カスタムAIチャットアプリ」(ダウンロード版)
     のローカル参照(./js/...)をCDN参照に切り替えています。
     ============================================================ -->
<!-- marked:Markdown記法をHTMLに変換する -->
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<!-- DOMPurify:生成されたHTMLの安全性をチェックする(XSS対策) -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.5/dist/purify.min.js"></script>
<!-- highlight.js:コードブロックに色を付ける(シンタックスハイライト) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- highlight.js の配色テーマ(github-dark) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">

<!-- PDF.js:PDFファイルをブラウザ上で解析するライブラリ
     テキストの抽出や、ページの画像化(レンダリング)に使います。 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>

<style>
/* ==================================================
   CSS:アプリの「見た目」を定義する部分
   家でいうところの「内装・壁紙・家具の配置」です。
   色合いは「カスタムAIチャットアプリ」(ダウンロード版)と同一です。
   ================================================== */

/* ------------------------------------------
   テーマカラーの設定(CSS変数)
   「--〇〇」という名前で色を登録しておき、
   各所で var(--〇〇) として呼び出します。
   ------------------------------------------ */
/* ライトモード:目に優しい配色 */
:root {
  /* 背景:真っ白ではなく、わずかに暖かみのあるオフホワイト */
  --bg: #f7f5f0;
  /* サイドバー背景:背景より少し暗く、境界を明確に */
  --bg-sidebar: #efede6;
  /* 入力欄背景:白背景で明確に識別 */
  --bg-input: #ffffff;
  /* テキスト:濃すぎないダークグレー */
  --text: #2c2c2c;
  --text-sub: #5a5a5a;
  /* 境界線:暖色系に合わせて調整 */
  --border: #d8d5cc;
  /* アクセント色:視認性を維持しつつ、少し落ち着いた色に */
  --accent: #4a5dd8;
  --accent-hover: #3d4ec4;
  /* 危険色:落ち着いた赤に */
  --danger: #d64545;
  /* コードブロック用 */
  --bg-code: #1e1e2e;
  --bg-user-msg: #e8f0fe;
  --bg-progress: #f4f6f8;
  --code-text: #e6e6f0;
  --shadow: 0 2px 8px rgba(0,0,0,.08);
}

/* ダークモード(bodyタグに dark クラスが付いた時適用) */
body.dark {
  --bg: #17181c;
  --bg-sidebar: #1e1f24;
  --bg-input: #2a2a2a;
  --bg-code: #0f0f17;
  --bg-user-msg: #2b3348;
  --bg-progress: #202127;
  --text: #e8e8ec;
  --text-sub: #9a9aa5;
  --border: #34353c;
  --accent: #7a86ff;
  --accent-hover: #9198ff;
  --danger: #f16469;
  --code-text: #e6e6f0;
  --shadow: 0 2px 8px rgba(0,0,0,.4);
}

/* ============================================================
   ベース(画面全体の骨組み)
============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
  font-family: -apple-system, BlinkMacSystemFont, "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--text);
  display: flex;
  overflow: hidden;           /* 画面全体ではスクロールさせない */
  transition: background .2s, color .2s;
}
button {
  font-family: inherit;
  cursor: pointer;
  border: none;
  background: none;
  color: inherit;
}
.hidden { display: none !important; }

/* ============================================================
   サイドバー(左側:新しいチャット+履歴一覧)
============================================================ */
#sidebar {
  width: 260px;
  min-width: 260px;
  background: var(--bg-sidebar);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  transition: margin-left .25s;
  overflow-x: hidden;
  overflow-y: auto;
}
/* サイドバーが閉じている状態(マイナスマージンで画面外へ) */
#sidebar.collapsed { margin-left: -260px; }
#sidebar-header { padding: 12px; }
#newChatBtn {
  width: 100%;
  padding: 10px;
  background: var(--accent);
  color: #fff;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 600;
  transition: background .15s;
}
#newChatBtn:hover { background: var(--accent-hover); }
#conversationList {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 8px;
}
/* 会話履歴1件分 */
.conv-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 9px 10px;
  border-radius: 8px;
  margin-bottom: 4px;
  cursor: pointer;
  font-size: 14px;
  color: var(--text);
}
.conv-item:hover { background: rgba(127,127,127,.12); }
.conv-item.active { background: rgba(91,108,255,.16); }
.conv-title {
  flex: 1;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  pointer-events: auto;
  user-select: none;   /* テキスト選択を防ぐ */
}
/* ×削除ボタン(ホバー時だけ表示) */
.conv-delete {
  opacity: 0;
  color: var(--text-sub);
  font-size: 14px;
  padding: 2px 6px;
  border-radius: 4px;
}
.conv-item:hover .conv-delete { opacity: 1; }
.conv-delete:hover { color: var(--danger); background: rgba(229,72,77,.12); }
/* ✎編集ボタン(ホバー時だけ表示) */
.conv-edit {
  opacity: 0;
  color: var(--text-sub);
  font-size: 12px;
  padding: 2px 6px;
  border-radius: 4px;
}
.conv-item:hover .conv-edit { opacity: 1; }
.conv-edit:hover { color: var(--accent); background: rgba(91,108,255,.12); }
/* タイトル編集用の入力欄 */
.conv-title-input {
  flex: 1;
  min-width: 0;
  padding: 2px 6px;
  border: 1px solid var(--accent);
  border-radius: 4px;
  background: var(--bg);
  color: var(--text);
  font-size: 13px;
  font-family: inherit;
  outline: none;
}
/* サイドバー下部のボタン群(全履歴削除・テーマ) */
#sidebar-footer {
  padding: 10px 12px;
  border-top: 1px solid var(--border);
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}
#sidebar-footer button {
  flex: 1;
  min-width: calc(50% - 4px);
  font-size: 12px;
  color: var(--text-sub);
  padding: 6px;
  border: 1px solid var(--border);
  border-radius: 6px;
}
#sidebar-footer button:hover { color: var(--text); background: rgba(127,127,127,.1); }

/* 設定パネル内のリンクの色 */
#settingsPanel a:link,
#settingsPanel a:visited { color: var(--accent); text-decoration: underline; }
#settingsPanel a:hover,
#settingsPanel a:active { color: var(--accent-hover); }

/* ============================================================
   スクロールバー(ダークモード対応)
============================================================ */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-sub); }
/* Firefox用 */
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }

/* ============================================================
   メイン領域(右側:チャット画面)
============================================================ */
#main {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}
/* トップバー */
#topbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--border);
}
#toggleSidebarBtn { font-size: 18px; padding: 4px 8px; border-radius: 6px; }
#toggleSidebarBtn:hover { background: rgba(127,127,127,.12); }
#topbar-title {
  font-size: 15px; font-weight: 600; flex: 1;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
#settingsToggleBtn {
  font-size: 15px;
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text-sub);
}
#settingsToggleBtn:hover { color: var(--text); background: rgba(127,127,127,.1); }

/* 設定パネル(トップバーの下に展開されるエリア) */
#settingsPanel {
  border-bottom: 1px solid var(--border);
  padding: 14px;
  background: var(--bg-sidebar);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 12px;
  max-height: calc(100vh - 60px);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.setting-field label {
  display: block;
  font-size: 12px;
  color: var(--text-sub);
  margin-bottom: 4px;
}
.setting-field input,
.setting-field select,
.setting-field textarea {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
  color: var(--text);
  font-size: 13px;
  font-family: inherit;
}
/* チェックボックスだけは横幅いっぱいにしない(ステップ③で使用) */
.setting-field input[type="checkbox"] {
  width: auto;
  margin: 0;
}
/* 設定項目の下に表示する小さな補足説明文(完成形と同じ見た目) */
.setting-hint {
  font-size: 12px;
  color: var(--accent);   /* アクセントカラーで目立たせる */
  margin-top: 4px;
  font-weight: 600;       /* 少し太字にして読みやすく */
}
/* カスタムシステムプロンプトの編集エリア
   長文を扱うため、フォントを等幅(monospace)にし、
   高さを自由に変えられる(resize: vertical)ようにしています。 */
#customPromptField textarea {
  font-family: "SF Mono", Consolas, Menlo, monospace;
  font-size: 14px;
  line-height: 1.6;
  resize: vertical;   /* 高さだけを自由に変えられる(横は固定) */
  min-height: 200px;
}
/* エラーメッセージ表示エリア */
#errorArea {
  background: rgba(229,72,77,.1);
  border: 1px solid var(--danger);
  color: var(--danger);
  font-size: 13px;
  padding: 10px 14px;
  margin: 10px 16px 0;
  border-radius: 8px;
  display: none;
  white-space: pre-wrap;
  word-break: break-word;
}
#errorArea.visible { display: block; }

/* ============================================================
   会話表示エリア(メッセージ一覧)
   ------------------------------------------------------------
   完成形の構造:アバター(U/K)+ロールラベル+本文を
   「横並び(flex)」で配置し、コンテナ全体を中央寄せします。
   ユーザーの質問が右に寄ることはありません。
============================================================ */
#chatContainer {
  flex: 1;
  overflow-y: auto;
  padding: 20px 16px 24px;
  scroll-behavior: smooth;
}
/* メッセージ1件分の枠(アバターと本文を横並びにする) */
.message {
  max-width: 860px;
  margin: 0 auto 22px;
  display: flex;
  gap: 12px;
}
/* 丸いアバター(ユーザーは「U」、AIは「K」) */
.msg-avatar {
  width: 30px;
  height: 30px;
  min-width: 30px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  font-weight: 700;
  color: #fff;
}
.message.user .msg-avatar { background: #3b82c4; }     /* ユーザー:青 */
.message.assistant .msg-avatar { background: var(--accent); }  /* AI:アクセント色 */
.msg-body { flex: 1; min-width: 0; }
.msg-role { font-size: 12px; color: var(--text-sub); margin-bottom: 4px; }
.msg-content {
  font-size: 15.5px;
  line-height: 1.8;
  word-break: break-word;
}
/* あなた(ユーザー)の質問の吹き出し */
.message.user .msg-content {
  background: var(--bg-user-msg);
  padding: 10px 14px;
  border-radius: 12px;
  white-space: pre-wrap;
}
/* メッセージ操作ボタン(コピー・再生成など。ホバー時だけ表示) */
.msg-actions {
  margin-top: 6px;
  display: flex;
  gap: 8px;
  opacity: 0;
  transition: opacity .15s;
}
.message:hover .msg-actions { opacity: 1; }
.msg-actions button {
  font-size: 12px;
  color: var(--text-sub);
  padding: 3px 8px;
  border: 1px solid var(--border);
  border-radius: 5px;
}
.msg-actions button:hover { color: var(--text); background: rgba(127,127,127,.1); }

/* ============================================================
   処理状況パネル(「回答を受信しています…」などの表示)
   スピナー+ステータステキスト+開閉できるログの構造です。
============================================================ */
.progress-panel {
  max-width: 860px;
  margin: 0 auto 18px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-progress);
  font-size: 13px;
  overflow: hidden;
}
.progress-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  cursor: pointer;
  user-select: none;
}
.progress-header:hover { background: rgba(127,127,127,.08); }
.progress-caret { font-size: 11px; color: var(--text-sub); transition: transform .2s; }
.progress-panel.open .progress-caret { transform: rotate(90deg); }
.progress-current { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.progress-body {
  display: none;
  border-top: 1px solid var(--border);
  padding: 8px 14px 10px;
  max-height: 180px;
  overflow-y: auto;
}
.progress-panel.open .progress-body { display: block; }
.progress-log-item {
  color: var(--text-sub);
  padding: 3px 0;
  display: flex;
  gap: 8px;
  align-items: center;
}
/* ログの状態別アイコン */
.progress-log-item.done::before { content: "✓"; color: #2e9e5b; font-weight: 700; }
.progress-log-item.current::before { content: "●"; color: var(--accent); }
.progress-log-item.stopped::before { content: "■"; color: var(--danger); }
/* くるくる回るスピナー(処理中の目印) */
.spinner {
  width: 14px; height: 14px;
  border: 2px solid var(--border);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin .8s linear infinite;
  display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ============================================================
   思考過程パネル(AIの考えている内容を表示する領域)
   生成中のみ表示される一時的なパネルです。
   回答完了時にストリーミング用DOMごと消去されます
   (思考過程のテキストは保存されません)。
============================================================ */
.reasoning-panel {
  border: 1px dashed var(--border);
  border-radius: 8px;
  margin-bottom: 10px;
  background: var(--bg-progress);
  overflow: hidden;
}
.reasoning-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 12px;
  cursor: pointer;
  user-select: none;
  font-size: 12.5px;
  color: var(--text-sub);
}
.reasoning-header:hover { background: rgba(127,127,127,.08); }
.reasoning-caret { font-size: 10px; transition: transform .2s; }
.reasoning-panel.open .reasoning-caret { transform: rotate(90deg); }
.reasoning-body {
  display: none;
  border-top: 1px dashed var(--border);
  padding: 10px 14px;
  font-size: 12.5px;
  line-height: 1.7;
  color: var(--text-sub);
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 300px;
  overflow-y: auto;
}
.reasoning-panel.open .reasoning-body { display: block; }

/* ストリーミング中のカーソル(▍の点滅) */
.stream-cursor {
  display: inline-block;
  width: 8px; height: 16px;
  background: var(--accent);
  margin-left: 3px;
  vertical-align: text-bottom;
  animation: blink 1s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }

/* ============================================================
   Markdown表示の調整(AIの回答内)
============================================================ */
.markdown-body pre {
  background: var(--bg-code);
  border-radius: 10px;
  margin: 12px 0;
  overflow: hidden;
  position: relative;
  border: 1px solid rgba(128, 128, 128, 0.3);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* コードブロックのヘッダー(言語名+コピーボタン) */
.code-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 6px 12px;
  background: rgba(255, 255, 255, 0.08);
  border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.code-lang {
  font-size: 11.5px;
  color: #7dd3fc;
  font-family: monospace;
  text-transform: none;
  font-weight: 600;
  letter-spacing: 0.5px;
}
body.dark .markdown-body pre {
  background: #1a1b26;
  border: 1px solid rgba(255, 255, 255, 0.1);
}
body.dark .code-header {
  background: rgba(255, 255, 255, 0.06);
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
body.dark .code-lang {
  color: #22d3ee;
  text-shadow: 0 0 8px rgba(34, 211, 238, 0.3);
}
.code-copy-btn {
  font-size: 11.5px;
  color: #c0c4d6;
  background: rgba(255,255,255,.08);
  border-radius: 5px;
  padding: 3px 10px;
}
.code-copy-btn:hover { background: rgba(255,255,255,.16); color: #fff; }
.code-copy-btn.copied { color: #7ee2a0; }
.markdown-body pre code {
  display: block;
  padding: 14px;
  overflow-x: auto;
  font-family: "SF Mono", Consolas, Menlo, monospace;
  font-size: 13px;
  line-height: 1.6;
  color: var(--code-text);
  white-space: pre;
}
.markdown-body code:not(pre code) {
  background: rgba(127,127,127,.18);
  padding: 2px 6px;
  border-radius: 4px;
  font-family: "SF Mono", Consolas, Menlo, monospace;
  font-size: 13px;
}
.markdown-body p { margin: 8px 0; }
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { margin: 16px 0 8px; }
.markdown-body ul, .markdown-body ol { margin: 8px 0 8px 22px; }
.markdown-body table { border-collapse: collapse; margin: 10px 0; }
.markdown-body th, .markdown-body td { border: 1px solid var(--border); padding: 6px 12px; font-size: 13.5px; }
.markdown-body blockquote {
  border-left: 3px solid var(--accent);
  padding: 2px 12px;
  margin: 10px 0;
  color: var(--text-sub);
}
.markdown-body img { max-width: 100%; border-radius: 8px; }

/* ============================================================
   カスタムツールチップ(data-tooltip属性)
   完成形と同じ仕組み:data-tooltip="説明文" と書くだけで、
   ホバー時に見やすいポップ(三角矢印付き)が出ます。
============================================================ */
[data-tooltip] { position: relative; }
/* 吹き出し本体(デフォルト:上向き表示) */
[data-tooltip]::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) scale(0.95);
  background: var(--bg-sidebar);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 14px;
  font-size: 12px;
  font-weight: normal;
  line-height: 1.6;
  white-space: pre-line;
  text-align: left;
  box-shadow: var(--shadow);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
  pointer-events: none;
  z-index: 10000;
  max-width: 260px;
  min-width: 180px;
}
/* 三角矢印(上向き表示用) */
[data-tooltip]::before {
  content: "";
  position: absolute;
  bottom: calc(100% + 4px);
  left: 50%;
  transform: translateX(-50%) scale(0.95);
  border: 6px solid transparent;
  border-top-color: var(--border);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
  pointer-events: none;
  z-index: 10001;
}
[data-tooltip]:hover::after,
[data-tooltip]:hover::before,
[data-tooltip].tooltip-visible::after,
[data-tooltip].tooltip-visible::before {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) scale(1);
}
body.dark [data-tooltip]::after {
  background: #2a2b31;
  border-color: var(--border);
}
/* 下向き表示(上部に十分なスペースがない場合にJSで付与) */
[data-tooltip].tooltip-below::after {
  bottom: auto;
  top: calc(100% + 10px);
}
[data-tooltip].tooltip-below::before {
  bottom: auto;
  top: calc(100% + 4px);
  border-top-color: transparent;
  border-bottom-color: var(--border);
}

/* トップバー内の要素:ツールチップを左端基準で内側に開く */
#topbar [data-tooltip]::after {
  left: 0; right: auto;
  transform: translateX(0) scale(0.95);
  max-width: 220px; min-width: 160px;
}
#topbar [data-tooltip]::before {
  left: 16px; right: auto;
  transform: translateX(0) scale(0.95);
}
#topbar [data-tooltip]:hover::after,
#topbar [data-tooltip]:hover::before,
#topbar [data-tooltip].tooltip-visible::after,
#topbar [data-tooltip].tooltip-visible::before {
  transform: translateX(0) scale(1);
}

/* サイドバーフッターの右側ボタン(読込・テーマ):ツールチップを右端基準で内側に開く */
#sidebar-footer #importBtn::after,
#sidebar-footer #darkModeBtn::after {
  left: auto; right: 0;
  transform: translateX(0) scale(0.95);
  max-width: 220px; min-width: 160px;
}
#sidebar-footer #importBtn::before,
#sidebar-footer #darkModeBtn::before {
  left: auto; right: 16px;
  transform: translateX(0) scale(0.95);
}
#sidebar-footer #importBtn:hover::after,
#sidebar-footer #importBtn:hover::before,
#sidebar-footer #darkModeBtn:hover::after,
#sidebar-footer #darkModeBtn:hover::before,
#sidebar-footer #importBtn.tooltip-visible::after,
#sidebar-footer #importBtn.tooltip-visible::before,
#sidebar-footer #darkModeBtn.tooltip-visible::after,
#sidebar-footer #darkModeBtn.tooltip-visible::before {
  transform: translateX(0) scale(1);
}

/* サイドバーフッターの左側ボタン(出力・全履歴削除):ツールチップを左端基準で内側に開く */
#sidebar-footer #exportBtn::after,
#sidebar-footer #clearAllBtn::after {
  left: 0; right: auto;
  transform: translateX(0) scale(0.95);
  max-width: 220px; min-width: 160px;
}
#sidebar-footer #exportBtn::before,
#sidebar-footer #clearAllBtn::before {
  left: 16px; right: auto;
  transform: translateX(0) scale(0.95);
}
#sidebar-footer #exportBtn:hover::after,
#sidebar-footer #exportBtn:hover::before,
#sidebar-footer #clearAllBtn:hover::after,
#sidebar-footer #clearAllBtn:hover::before,
#sidebar-footer #exportBtn.tooltip-visible::after,
#sidebar-footer #exportBtn.tooltip-visible::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;
}

/* ============================================================
   ウェルカム画面(ステップ⑦で追加)
   起動時や新規チャット時に表示される「初期画面」のスタイルです。
   空状態時(会話が空っぽの時)には、ウェルカム画面と入力欄を
   「一体として中央配置」にするための調整も含まれています。
   (完成形と同じスタイル)
============================================================ */
#welcomeScreen {
  display: none;            /* 初期状態は非表示 */
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  padding: 60px 24px 24px;
  text-align: center;
}
#welcomeScreen.visible { display: flex; }   /* visibleクラスが付いたら表示 */
/* タイトル(「カスタムAIチャットアプリ開発(MIT License)」) */
#welcomeScreen .welcome-title {
  font-size: 28px;
  font-weight: 700;
  margin-bottom: 12px;
  color: var(--text);
}
/* サブタイトル(案内文) */
#welcomeScreen .welcome-subtitle {
  font-size: 15px;
  color: var(--text-sub);
  margin-bottom: 24px;
  line-height: 1.8;
}

/* ============================================================
   空状態時:ウェルカム画面と入力欄を「一体として中央配置」
   ------------------------------------------------------------
   会話が空っぽの時(#main に is-welcome クラスが付いた時)、
   ウェルカム画面と入力欄を「上下の中央」に配置するための調整です。
   これにより、空っぽの画面が「上部に寄りすぎず」、
   見た目のバランスが良くなります(完成形と同じ仕様)。
============================================================ */
/* 空状態時のチャット表示領域の調整 */
#main.is-welcome #chatContainer {
  flex: 0 0 auto;          /* 高さを内容に合わせる */
  margin-top: auto;        /* 上側の空きスペースを吸収 */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  padding-bottom: 0;
  overflow: visible;
}
/* 空状態時のウェルカム画面の余白調整 */
#chatContainer.is-welcome #welcomeScreen {
  padding: 0 24px;
}
/* 空状態時のサブタイトルの余白調整 */
#main.is-welcome .welcome-subtitle {
  margin-bottom: 20px;
}
/* 空状態時の入力エリアの調整 */
#main.is-welcome #inputArea {
  margin-bottom: auto;     /* 下側の空きスペースを吸収(上下バランス) */
  border-top: none;
  padding: 0 24px 24px;
  width: 100%;
}
/* 空状態時の入力欄の調整(カードの幅と配置) */
#main.is-welcome #inputRow {
  max-width: 680px;
  align-items: flex-start;
}
/* 空状態時のテキストエリアの調整(高さを固定) */
#main.is-welcome #messageInput {
  min-height: 140px;
  max-height: 140px;
  overflow-y: auto;
}
/* 空状態時の送信・停止ボタンの調整 */
#main.is-welcome #sendBtn,
#main.is-welcome #stopBtn {
  margin-top: 4px;
}

/* モバイル対応(画面幅768px以下の時適用) */
@media (max-width: 768px) {
  #welcomeScreen .welcome-title { font-size: 22px; }
  #main.is-welcome #inputArea { padding: 0 16px 16px; }
  #main.is-welcome #messageInput { min-height: 110px; max-height: 110px; }
}

/* ============================================================
   長い質問の折りたたみ(「全文を表示」)
============================================================ */
/* 折りたたみ状態:高さを制限して続きを隠す */
.user-content-collapsed {
  max-height: 120px;
  overflow: hidden;
  position: relative;
}
/* 折りたたみ時の下端のグラデーション(続きがあることを示す演出) */
.user-content-collapsed::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 40px;
  background: linear-gradient(transparent, var(--bg-user-msg));
  pointer-events: none;
}
/* 「全文を表示 / 折りたたむ」ボタン */
.user-toggle-btn {
  display: inline-block;
  margin-top: 4px;
  font-size: 12px;
  color: var(--accent);
  background: none;
  border: none;
  cursor: pointer;
  padding: 6px 16px;
  border: 1px solid var(--accent);
  border-radius: 6px;
  transition: background .15s;
}
.user-toggle-btn:hover {
  background: rgba(91,108,255,.1);
}
/* ボタンを中央に配置するためのコンテナ */
.user-collapse-container {
  margin-top: 4px;
  text-align: center;
}

/* ============================================================
   全画面ドラッグ&ドロップオーバーレイ
============================================================ */
#dropOverlay {
  position: fixed;          /* 画面全体に固定 */
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(91, 108, 255, 0.08);
  border: 3px dashed var(--accent);   /* アクセント色の点線で囲む */
  z-index: 9999;            /* すべての要素の上に表示 */
  display: none;            /* 初期状態は非表示 */
  align-items: center;
  justify-content: center;
  pointer-events: none;     /* オーバーレイ自体はクリックを透過 */
  backdrop-filter: blur(2px); /* 背景を少しぼかす */
}
#dropOverlay.visible {
  display: flex;            /* visibleクラスが付いたら表示 */
}
.drop-message {
  background: var(--bg);
  padding: 24px 48px;
  border-radius: 16px;
  box-shadow: var(--shadow);
  border: 2px solid var(--accent);
  text-align: center;
  color: var(--text);
  font-size: 16px;
  font-weight: 600;
}
.drop-message small {
  display: block;
  margin-top: 8px;
  font-size: 12px;
  font-weight: normal;
  color: var(--text-sub);
}

/* ============================================================
   添付プレビューエリア(選択したファイルの一覧表示)
============================================================ */
#attachmentPreviewArea {
  max-width: 860px;
  margin: 0 auto 10px;
  display: flex;
  flex-wrap: wrap;          /* ファイルが多い時は折り返す */
  gap: 10px;
}
/* 添付ファイル1件分(チップ) */
.attach-chip {
  position: relative;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 10px;
  font-size: 12px;
  background: var(--bg-sidebar);
  display: flex;
  align-items: center;
  gap: 8px;
}
/* 画像のサムネイル(小さなプレビュー) */
.attach-chip img {
  width: 40px;
  height: 40px;
  object-fit: cover;        /* 縦横比を保って切り抜き */
  border-radius: 5px;
}
/* 添付ファイルの削除ボタン(✕) */
.attach-remove {
  color: var(--text-sub);
  font-size: 14px;
  padding: 2px 6px;
  border-radius: 4px;
}
.attach-remove:hover {
  color: var(--danger);
  background: rgba(229,72,77,.12);
}

/* ============================================================
   添付ボタン(画像・PDF・テキスト・フォルダ)
============================================================ */
.attach-btn {
  font-size: 18px;
  color: var(--text-sub);
  padding: 6px 8px;
  border-radius: 8px;
}
.attach-btn:hover {
  color: var(--text);
  background: rgba(127,127,127,.14);
}
/* ドラッグ&ドロップ中の入力エリアの強調表示 */
#inputRow.dragging {
  outline: 2px dashed var(--accent);
}

/* ============================================================
   質問ナビゲーションタイムライン(ステップ⑥で追加)
   画面右端に表示される「過去の質問へワンクリックでジャンプできる
   タイムライン」のスタイルです。
   (完成形と同じスタイル)
============================================================ */
#questionTimeline {
  position: fixed;          /* 画面右端に固定表示 */
  right: 0;
  top: 0;
  bottom: 0;
  width: 32px;
  z-index: 500;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;     /* 背景はクリックを透過(マーカーだけクリック可能に) */
}
/* タイムラインの「線」 */
.timeline-track {
  position: relative;
  width: 3px;
  height: calc(100% - 140px); /* トップバーと入力エリア分を引く */
  max-height: 600px;
  background: var(--border);
  border-radius: 2px;
  pointer-events: auto;     /* トラック自体はクリック可能 */
}
/* マーカー(質問1件分の「●」) */
.timeline-marker {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: 12px;
  height: 12px;
  background: rgba(0, 0, 0, 0.4); /* ライトモード:透過性のある黒 */
  border: 2px solid var(--bg);
  border-radius: 50%;
  cursor: pointer;
  transition: all 0.2s ease;
  box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.2);
}
/* ホバー時:マーカーを少し大きくして、クリックできることを伝える */
.timeline-marker:hover {
  width: 20px;
  height: 20px;
  background: rgba(0, 0, 0, 0.6);
  box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.3), var(--shadow);
  z-index: 10;
}
/* 現在位置(最後にジャンプした質問):赤いマーカーで表示 */
.timeline-marker.current {
  background: var(--danger);
  box-shadow: 0 0 0 3px var(--danger);
  width: 16px;
  height: 16px;
}

/* タイムラインマーカーのポップ(ツールチップ):
   マーカーの「左側」に表示する(画面右端での途切れ防止)。
   デフォルトの「上向き表示」を無効化し、左側表示に変更します。 */
#questionTimeline .timeline-marker[data-tooltip]::after {
  /* デフォルトの上向き表示を無効化し、左側表示に変更 */
  bottom: auto;
  top: 50%;
  left: auto;
  right: calc(100% + 12px);
  transform: translateY(-50%) scale(0.95);
  max-width: 240px;
  min-width: 120px;
  white-space: normal;
}
#questionTimeline .timeline-marker[data-tooltip]::before {
  /* 三角矢印を右向きに変更(ツールチップとマーカーの間に配置) */
  bottom: auto;
  top: 50%;
  left: auto;
  right: 100%;
  transform: translateY(-50%) scale(0.95);
  border: 6px solid transparent;
  border-left-color: var(--border);
}
#questionTimeline .timeline-marker[data-tooltip]:hover::after,
#questionTimeline .timeline-marker[data-tooltip].tooltip-visible::after {
  transform: translateY(-50%) scale(1);
}
#questionTimeline .timeline-marker[data-tooltip]:hover::before,
#questionTimeline .timeline-marker[data-tooltip].tooltip-visible::before {
  transform: translateY(-50%) scale(1);
}

/* ダークモード時のマーカー色 */
body.dark .timeline-marker {
  background: rgba(128, 128, 128, 0.5); /* ダークモード:透過性のある鼠色 */
  box-shadow: 0 0 0 2px rgba(128, 128, 128, 0.3);
}
body.dark .timeline-marker:hover {
  background: rgba(128, 128, 128, 0.7);
  box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.4), var(--shadow);
}
body.dark .timeline-marker.current {
  background: var(--danger); /* 現在位置は赤色のまま */
  box-shadow: 0 0 0 3px var(--danger);
}
/* モバイル(画面幅768px以下)では、タイムラインを非表示にする
   スマホの縦画面では画面が狭く、タイムラインが本文に
   重なってしまうため、表示しません。 */
@media (max-width: 768px) {
  #questionTimeline { display: none; }
}

/* ============================================================
   添付メタデータ表示(会話履歴の「どのファイルを添付したか」の表示)
============================================================ */
.msg-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.msg-attachment {
  font-size: 12px;
  background: rgba(127,127,127,.12);
  padding: 4px 10px;
  border-radius: 6px;
  color: var(--text-sub);
}

/* ============================================================
   レスポンシブ(モバイル対応:画面幅768px以下の時適用)
============================================================ */
@media (max-width: 768px) {
  /* サイドバーは画面全体に重ねて表示(初期は画面外に隠す) */
  #sidebar {
    position: fixed;
    z-index: 100;
    height: 100%;
    margin-left: -260px;
  }
  #sidebar.open { margin-left: 0; }   /* openクラスでスライド表示 */
  .message { max-width: 100%; }

  /* モバイル時の設定パネル調整(1カラム表示) */
  #settingsPanel {
    grid-template-columns: 1fr;
    max-height: calc(100vh - 50px);
    padding: 12px;
  }
}

/* スマホ横画面(高さが低い場合)の設定パネル調整 */
@media (max-height: 500px) and (orientation: landscape) {
  #settingsPanel {
    max-height: calc(100vh - 45px);
    padding: 8px;
    gap: 8px;
  }
  .setting-field label { font-size: 11px; }
  .setting-field input,
  .setting-field select,
  .setting-field textarea {
    padding: 6px 8px;
    font-size: 11px;
  }
}
</style>
</head>

<body>

<!-- ==================================================
     ① サイドバー(画面左側)
     新しいチャット・会話履歴の一覧・各種操作ボタンを配置
     ================================================== -->
<aside id="sidebar">
  <div id="sidebar-header">
    <button id="newChatBtn">+ 新しいチャット</button>
  </div>
  <!-- 会話履歴の一覧(JavaScriptが中身を自動生成します) -->
  <div id="conversationList"></div>

  <!-- サイドバー下部の操作ボタン群
       data-tooltip="..." がカスタムツールチップ(ホバーで説明ポップ)。
       完成形では「📤 出力」「📥 読込」もありますが、
       これらはステップ⑤で追加します。 -->
  <div id="sidebar-footer">
    <!-- ▼▼ ステップ⑤で追加したエクスポート・インポートボタン ▼▼ -->
    <button id="exportBtn" data-tooltip="すべての会話履歴をJSONファイルとしてエクスポートします">📤 出力</button>
    <button id="importBtn" data-tooltip="エクスポートしたJSONファイルの会話履歴を、現在の履歴に追加して読み込みます
既存の会話履歴は削除されません(上限:50MB)">📥 読込</button>

    <button id="clearAllBtn" data-tooltip="すべての会話履歴を削除します
この操作は元に戻せません">全履歴削除</button>
    <button id="darkModeBtn" data-tooltip="ライトモード / ダークモードを切り替えます">🌙 テーマ</button>
  </div>
  <!-- インポート用の「隠しファイル入力」
       「📥 読込」ボタンが押された時に、JavaScriptがこれをクリックして
       ファイル選択ダイアログを開きます。画面には表示されません(class="hidden")。
       accept=".json" で、JSONファイルのみを選択できるように制限しています。 -->
  <input type="file" id="importFileInput" accept=".json" class="hidden">
</aside>

<!-- ==================================================
     ② メイン領域(画面右側)
     トップバー・設定パネル・チャット表示・入力エリアを配置
     ================================================== -->
<div id="main">

  <!-- トップバー(メイン領域の上部) -->
  <div id="topbar">
    <button id="toggleSidebarBtn" data-tooltip="サイドバーの表示/非表示を切り替えます">☰</button>
    <div id="topbar-title">カスタムAIチャットアプリ開発(MIT License)</div>
    <button id="settingsToggleBtn">⚙ 設定</button>
  </div>

  <!-- 設定パネル(トップバーの下に展開されるエリア)
       初期状態は hidden(非表示)。
       ステップ①ではAPIキー項目のみでしたが、
       ステップ②で「モデル名」「reasoning_effort」などの
       設定項目を追加しました。 -->
  <div id="settingsPanel" class="hidden">
    <div class="setting-field">
      <label for="apiKeyInput">APIキー(画面更新でリセット)</label>
      <input type="password" id="apiKeyInput" placeholder="Runpod APIキーを入力" autocomplete="off">
      <div class="setting-note" style="margin-top: 6px; font-size: 12px; color: var(--text-sub); line-height: 1.5;">
        <strong style="color: #e74c3c;">【API Keyの設定手順】</strong><br>
        RunpodでAPIキーを発行する手順をまとめておきました。<a href="https://child-programmer.com/rp-api/chat-k/setting/" target="_blank"><b>こちら</b></a>のページを参照ください。<br>
        <a href="https://child-programmer.com/rp-api/" target="_blank"><b>Runpod APIの使い方入門講座(一覧)</b></a>
      </div>
    </div>

    <!-- ▼▼ ステップ②で追加した設定項目 ▼▼ -->

    <!-- モデル名の選択
         3つのモデルから選べます。選択に応じて、
         「思考モード」「reasoning_effort」の表示が切り替わります
         (JavaScriptの updateModelSpecificSettingsUI が担当)。 -->
    <div class="setting-field">
      <label for="modelInput">モデル名</label>
      <select id="modelInput">
        <option value="kimi-k3" selected>kimi-k3</option>
        <option value="kimi-k2.7-code">kimi-k2.7-code</option>
        <option value="kimi-k2.6">kimi-k2.6</option>
      </select>
      <div class="setting-hint">kimi-k3:最高性能を狙う万能型</div>
      <div class="setting-hint">kimi-k2.7-code:本格的な開発に特化</div>
      <div class="setting-hint">kimi-k2.6:コード以外も対応の万能型</div>
    </div>

    <!-- 思考モード(kimi-k2.7-code / kimi-k2.6 用)
       初期状態は hidden(非表示)。
       kimi-k2.7-code または kimi-k2.6 が選ばれた時だけ表示されます。
       kimi-k3 は常時思考モデルのため、この項目は表示しません。 -->
    <div class="setting-field hidden" id="thinkingModeField">
      <label for="thinkingModeSelect" id="thinkingModeFieldLabel">思考モード</label>
      <select id="thinkingModeSelect">
        <option value="disabled" selected>OFF(通常回答)</option>
        <option value="enabled">ON(思考モード)</option>
      </select>
      <div class="setting-hint" id="thinkingModeFieldHint">モデルに応じて思考モードの設定が切り替わります。</div>
    </div>

    <!-- reasoning_effort(Kimi K3 専用の推論レベル)
       kimi-k3 選択時だけ表示されます。 -->
    <div class="setting-field" id="reasoningEffortField">
      <label for="reasoningEffortSelect">reasoning_effort - (Kimi K3)</label>
      <select id="reasoningEffortSelect">
        <option value="low" selected>low</option>
        <option value="high">high</option>
        <option value="max">max</option>
      </select>
      <div class="setting-hint">推論強度(思考の深さ)を選択</div>
    </div>

    <!-- max_tokens(一度に出力できる文章の上限) -->
    <div class="setting-field">
      <label for="maxTokensInput">max_tokens</label>
      <input type="number" id="maxTokensInput" value="8192" min="1">
      <div class="setting-hint">一度に出力できる文章の上限を指定</div>
      <div class="setting-hint">目安(日本語):1文字≒1トークン</div>
      <div class="setting-hint">・8192:約8000〜16000文字</div>
      <div class="setting-hint">思考過程+回答本文の文字数</div>
      <div class="setting-hint">マークダウン記号(#、*、`)も含む</div>
    </div>

    <!-- 画面更新間隔(ストリーミング中の描画間隔) -->
    <div class="setting-field">
      <label for="renderIntervalInput">画面更新間隔 (ms)</label>
      <input type="number" id="renderIntervalInput" value="80" min="50" max="100" step="10">
      <div class="setting-hint">50〜100ms の範囲で指定</div>
      <div class="setting-hint">小さいほど滑らか</div>
    </div>

    <!-- ▼▼ ステップ④で追加したアップロード上限の設定項目 ▼▼ -->

    <!-- 画像アップロード上限 -->
    <div class="setting-field">
      <label for="maxImageSizeInput">🖼 画像アップロード上限 (KB)</label>
      <input type="number" id="maxImageSizeInput" value="10240" min="100" max="51200" step="100">
      <div class="setting-hint" id="imageSizeHint">10.0MB まで</div>
    </div>

    <!-- PDFアップロード上限 -->
    <div class="setting-field">
      <label for="maxPdfSizeInput">📄 PDFアップロード上限 (KB)</label>
      <input type="number" id="maxPdfSizeInput" value="20480" min="100" max="51200" step="100">
      <div class="setting-hint" id="pdfSizeHint">20.0MB まで</div>
      <div class="setting-hint">1ファイルの先頭約20000文字まで対応</div>
    </div>

    <!-- PDFページ数上限 -->
    <div class="setting-field">
      <label for="maxPdfPagesInput">PDFページ数上限</label>
      <input type="number" id="maxPdfPagesInput" value="10" min="1" max="50" step="1">
      <div class="setting-hint" id="pdfPagesHint">10ページ まで</div>
    </div>

    <!-- テキストファイル上限 -->
    <div class="setting-field">
      <label for="maxTextSizeInput">📝 テキストファイル上限 (KB)</label>
      <input type="number" id="maxTextSizeInput" value="5120" min="100" max="10240" step="100">
      <div class="setting-hint" id="textSizeHint">5.0MB まで</div>
    </div>

    <!-- APIタイムアウト(応答を待つ上限時間) -->
    <div class="setting-field">
      <label for="requestTimeoutInput">APIタイムアウト (秒)</label>
      <input type="number" id="requestTimeoutInput" value="360" min="30" max="900" step="10">
      <div class="setting-hint" id="timeoutHint">6分 まで</div>
      <div class="setting-hint">処理が中断される応答時間の上限を指定</div>
    </div>

    <!-- カスタムシステムプロンプトモードの切り替え
         チェックボックスをONにすると、下記の編集エリアが表示されます。
         style="grid-column: 1 / -1;" で、この項目だけ横幅いっぱいに広げています。 -->
    <div class="setting-field" style="grid-column: 1 / -1; margin: 20px 0;">
      <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
        <input type="checkbox" id="customPromptModeToggle" style="width: auto;">
        <span style="font-size: 14px; font-weight: 600; color: var(--text);">カスタムシステムプロンプトモードを有効化</span>
      </label>
      <div class="setting-hint" style="margin-top: 8px;">有効にすると、下記のテキストエリアでシステムプロンプトを編集できます。無効時はプログラム標準のプロンプトを使用します。</div>
    </div>

    <!-- カスタムシステムプロンプトの編集エリア
         初期状態は hidden(非表示)。
         チェックボックスがONになった時だけ表示されます
         (JavaScriptの updateCustomPromptVisibility が担当)。 -->
    <div class="setting-field hidden" id="customPromptField" style="grid-column: 1 / -1;">
      <label for="customSystemPrompt">カスタムシステムプロンプト</label>
      <textarea id="customSystemPrompt" rows="12" style="width: 100%; font-family: monospace; font-size: 14px; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); resize: vertical;"></textarea>
      <div style="display: flex; gap: 8px; margin-top: 8px;">
        <button type="button" id="resetPromptBtn" style="padding: 6px 12px; font-size: 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; color: var(--text-sub); cursor: pointer;">デフォルトに戻す</button>
        <button type="button" id="copyPromptBtn" style="padding: 6px 12px; font-size: 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; color: var(--text-sub); cursor: pointer;">コピー</button>
      </div>
    </div>
  </div>

  <!-- エラーメッセージ表示エリア(エラー発生時のみ表示) -->
  <div id="errorArea"></div>

  <div id="chatContainer">
    <!-- ウェルカム画面(ステップ⑦で追加)
         起動時や新規チャット時に表示される「初期画面」です。
         初期状態は非表示(display: none)で、JavaScriptの
         updateWelcomeScreen 関数が、「会話が空っぽの時」だけ
         visible クラスを付けて表示します。
         タイトルとサブタイトルは、APIキーの入力状態に応じて、
         JavaScriptが書き換えます。 -->
    <div id="welcomeScreen">
      <div class="welcome-title" id="welcomeTitle">カスタムAIチャットアプリ開発(MIT License)</div>
      <div class="welcome-subtitle" id="welcomeSubtitle">画面右上の「設定」パネルを開き「APIキー」を入力しチャットを始めましょう。</div>
    </div>
  </div>

  <!-- 入力エリア(画面下部)
       添付ボタン(🖼📄📝📁)をステップ④で追加。 -->
  <div id="inputArea">
    <!-- 添付プレビューエリア
         選択したファイル(画像・PDF・テキスト)の一覧が、ここに表示されます。
         初期状態は空っぽで、JavaScript(renderAttachmentPreview)が中身を生成します。 -->
    <div id="attachmentPreviewArea"></div>

    <div id="inputRow">
      <!-- 添付ボタン4つ
           左から順に「画像・PDF・テキストファイル・フォルダ」の添付ボタンです。
           それぞれのボタンを押すと、対応する「隠しファイル入力」が開きます。 -->
      <button class="attach-btn" id="imageAttachBtn" data-tooltip="🖼 画像を添付
対応形式: JPEG / PNG / GIF / WebP
サイズ: 1枚〜複数可。
   (設定で上限指定)
貼り付け・ドラッグ&ドロップにも対応">🖼</button>
      <button class="attach-btn" id="pdfAttachBtn" data-tooltip="📄 PDFを添付
サイズ: 20MBまで
ページ数: 先頭から順番に処理。
    (設定で上限指定)
テキスト抽出またはページ画像として送信">📄</button>
      <button class="attach-btn" id="textAttachBtn" data-tooltip="📝 テキストファイルを添付
対応形式: .txt .py .js .html .css .json .md など
サイズ: 1ファイル〜複数可。
   (設定で上限指定)">📝</button>
      <button class="attach-btn" id="folderAttachBtn" data-tooltip="📁 フォルダを添付
フォルダ内のテキストファイルを読み込みます
(画像・PDFも含めることができます)
※フォルダのドロップは非対応のため
  このボタンから選択してください">📁</button>

      <textarea id="messageInput" placeholder="メッセージを入力(Enter:送信 / Shift+Enter:改行)" rows="1"></textarea>
      <button id="sendBtn" data-tooltip="送信(Enter でも送信できます)">➤</button>
      <button id="stopBtn" class="hidden" data-tooltip="生成を停止します
それまでに受信した内容は保持されます">■</button>
    </div>
    <div id="aiDisclaimer">AIは不正確な情報を含む場合があります。重要な内容は必ずご自身でも確認してください。</div>

    <!-- 隠しファイル入力(4種類)
         添付ボタンが押された時に、JavaScriptがこれらをクリックして
         ファイル選択ダイアログを開きます。画面には表示されません(class="hidden")。 -->
    <input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" multiple class="hidden">
    <input type="file" id="pdfFileInput" accept="application/pdf" class="hidden">
    <input type="file" id="textFileInput" accept=".txt,.py,.js,.jsx,.ts,.tsx,.html,.css,.json,.md,.xml,.yaml,.yml,.c,.cpp,.h,.hpp,.java,.cs,.php,.rb,.go,.rs,.sql,.sh,.bash,.bat,.ini,.conf,.cfg,.log,.vue,.svelte,.swift,.kt,.scala,.pl,.pm,.r,.m,.mm,.gradle,.properties,.env,.gitignore,.dockerfile,.makefile,.cmake,.toml,.lock,.csv,.tsv,.tex,.rst,.adoc,.asciidoc" multiple class="hidden">
    <input type="file" id="folderFileInput" webkitdirectory directory multiple class="hidden">
  </div>

  <!-- 全画面ドラッグオーバーレイ
       ファイルを画面にドラッグしている間だけ、画面全体に表示される案内です。
       初期状態は非表示(display: none)で、JavaScriptがドラッグを検知した時に
       visible クラスを付けて表示します。 -->
  <div id="dropOverlay">
    <div class="drop-message">
      📂 ファイル・フォルダをドロップ
      <small>画像・PDF・テキストファイル(.txt, .py, .js, .html 等)・フォルダ</small>
    </div>
  </div>

  <!-- 質問ナビゲーションタイムライン(ステップ⑥で追加)
       画面右端に表示される「過去の質問へワンクリックでジャンプできる
       タイムライン」です。
       中身(.timeline-track と マーカー)は、JavaScriptの
       updateQuestionTimeline 関数が、ユーザーメッセージ(質問)の
       数と位置に応じて自動で生成します。 -->
  <div id="questionTimeline">
    <div class="timeline-track" id="timelineTrack"></div>
  </div>

</div>
<script>

/* ==================================================
   JavaScript:アプリの「動き」を定義する部分
   家でいうところの「電気配線・水道管」。
   このアプリの心臓部です。
   構造・関数名は「カスタムAIチャットアプリ」(ダウンロード版)と同一です。
   ================================================== */

/* ============================================================
   【1】定数(アプリの設定値)
   ------------------------------------------------------------
   ※APIキーはここには書きません(UIから入力・メモリ上のみ保持)
============================================================ */
/* RunpodのAPI接続先URL(完成形と同じエンドポイント) */
const API_ENDPOINT = "https://api.runpod.ai/v2/moonshot-kimi/openai/v1/chat/completions";
/* localStorage(ブラウザ内の保存領域)に使う「保存名」 */
const STORAGE_KEY = "kimi-k3-chat-conversations-v1";   // 会話履歴
const UI_SETTINGS_KEY = "kimi-k3-chat-ui-settings-v1"; // 画面設定(テーマなど)

/* --- アプリの動作パラメータの「初期値」 ---
   ステップ①ではコード上の固定値として使っていましたが、
   ステップ②で設定パネルから変更可能になったため、
   これらの値は「設定パネルの初期表示値」としての役割に変わりました。
   (実際の動作では、設定パネルの値が優先されます) */
const FIXED_MODEL_NAME = "kimi-k3";        // モデルの初期値
const FIXED_REASONING_EFFORT = "low";      // 推論レベルの初期値
const FIXED_MAX_TOKENS = 8192;             // max_tokens の初期値
const FIXED_RENDER_INTERVAL = 80;          // 画面更新間隔の初期値(ミリ秒)
let REQUEST_TIMEOUT_MS = 360000;           // APIタイムアウト(360秒=6分)

/* --- アップロード制限の設定値(ステップ④で追加) ---
   画像・PDF・テキストファイルの「サイズ上限」や「対応形式」を
   決める値です。これらは「変数」として宣言しており、
   設定パネルで変更された値が、後の差分で追加する
   applyUploadSettings() によって反映されます。 */

/* 画像の容量の上限(デフォルト: 10MB) */
let MAX_IMAGE_SIZE = 10 * 1024 * 1024;
/* PDFの容量の上限(デフォルト: 20MB) */
let MAX_PDF_SIZE = 20 * 1024 * 1024;
/* PDFのページ数の上限(デフォルト: 10ページ) */
let MAX_PDF_PAGES = 10;
/* 対応する画像の形式(これ以外の形式は添付できません) */
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
/* 対応するテキストファイルの拡張子(これ以外は添付できません) */
const TEXT_FILE_EXTENSIONS = [
  '.txt', '.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.htm', '.css', '.scss', '.sass', '.less',
  '.json', '.md', '.markdown', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf',
  '.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.java', '.cs', '.php', '.rb', '.go', '.rs',
  '.sql', '.sh', '.bash', '.zsh', '.fish', '.bat', '.cmd', '.ps1', '.log',
  '.vue', '.svelte', '.swift', '.kt', '.kts', '.scala', '.pl', '.pm', '.r', '.m', '.mm',
  '.gradle', '.properties', '.env', '.gitignore', '.dockerignore', '.editorconfig',
  '.dockerfile', '.makefile', '.cmake', '.lock', '.csv', '.tsv', '.tex', '.rst'
];
/* テキストファイル1件あたりの容量の上限(デフォルト: 5MB) */
let MAX_TEXT_FILE_SIZE = 5 * 1024 * 1024;
/* テキストファイル全体の合計容量の上限(デフォルト: 20MB) */
let MAX_TEXT_TOTAL_SIZE = 20 * 1024 * 1024;

/* ------------------------------------------------------------
   システムプロンプト(AIへの指示書・性格設定)
   内容は完成形と同一です(ステップ③で画面から変更可能に)。
   テンプレートリテラル(` `)で複数行をそのまま書けます。
------------------------------------------------------------ */
const SYSTEM_PROMPT = `あなたは、日本語で回答するプログラミング支援専門のAIアシスタントです。

主な対象分野:
JavaScript / HTML / CSS / TypeScript / Python / Java / C# / React / Node.js / API連携 / Webアプリケーション / データベース / Linux / Docker / クラウド / AIアプリケーション / デバッグ / コードレビュー

回答ルール:
1. 最初に結論を簡潔に述べる
2. 必要に応じて、原因、手順、実装例の順に説明する
3. コードは言語名付きMarkdownコードブロックで表示する
4. コード本文と解説文を分離する
5. コードはコピーして利用しやすい形にする
6. 重要な処理を省略しない
7. APIキー、パスワード、トークンなどの秘密情報を出力しない
8. 秘密情報は環境変数や安全な設定方法で管理するよう説明する
9. ユーザーが提示したコードを修正する場合は、変更点を説明する
10. 必要に応じて修正後の完全なコードを提示する
11. エラー処理、入力検証、セキュリティにも配慮する
12. 仕様が不明な場合は断定せず、前提条件を明示する
13. 画像が添付された場合は、画像内のコード、エラー、UI、図表を確認して回答する
14. PDFが添付された場合は、提供されたテキストまたはページ画像の範囲内で回答する
15. 判読できない情報は推測で断定しない
16. コード例は可能な限り実行可能な形で提示する
17. 複数ファイルが必要な場合は、ファイル名ごとにコードブロックを分ける
18. 回答は日本語で行う
19. 必要に応じて見出し、箇条書き、表を使用する
20. 確実な事実と推測を区別する
21. 回答本文(content)には、内部の詳細な思考過程や隠れた推論を記述しない
22. 思考過程はモデルの thinking 機能が reasoning_content として出力するものであり、回答本文に思考内容を書き写したり、その存在に言及したりしない
23. 「分析中」「回答を構成中」などの進捗状況はアプリ側が表示するため、回答本文には含めない
24. 回答本文に進捗メッセージを混在させない
25. ウェブ検索や外部サイトの検索を実行したことにしてはいけない
26. 外部情報を確認していない場合は、確認していないことを明示する
27. ユーザーが提供したテキスト、画像、PDF、コードを主な入力情報として扱う
28. 画像やPDFに書かれていない内容を断定しない
29. 回答の最後に、必要であれば注意点や制限事項を記載する
30. 新しいコードを追加する場合は、既存コードのどの位置に挿入するかを具体的に説明する
31. 追加対象が関数の場合、その関数が呼び出される既存コード(使用箇所)の位置も併記する
コードを生成する場合:Markdownコードブロックを使用し、適切な言語名を指定し、コードと説明文を分離し、APIキーなどの秘密情報を直接記述せず、エラー処理を含め、コピーして利用しやすい形式にする
画像やPDFについて回答する場合:読み取れる範囲だけを根拠にし、不鮮明な部分は判読不能であると説明し、推測した内容は推測であると明示する`;

/* カスタムプロンプトのデフォルト値(ステップ③で使用) */
const DEFAULT_SYSTEM_PROMPT = SYSTEM_PROMPT;

/* ============================================================
   【2】状態管理(アプリが覚えておくデータ)
   ------------------------------------------------------------
   アプリが動いている間、メモリ上で保持するデータです。
   添付関連(画像・PDF・テキスト)はステップ④で追加します。
============================================================ */
let conversations = [];            // 全会話 [{id, title, messages:[{role, content}]}]
let activeConversationId = null;   // 現在の会話ID
let apiKey = "";                   // APIキー(メモリ上のみ保持。保存しない)
let abortController = null;        // ストリーム中断用
let isGenerating = false;          // 生成中フラグ
let fullResponseText = "";         // 受信回答バッファ
let renderScheduled = false;       // 部分描画スケジュール中フラグ
let currentStreamEl = null;        // ストリーミング中の回答DOM要素
let currentProgressPanel = null;   // 現在の処理状況パネル要素
let currentProgressState = { logs: [], lastStatus: "" };  // 処理状況の状態
let autoScrollEnabled = true;      // 自動スクロール有効フラグ
let hasRetriedNonStream = false;   // 非ストリーム再試行フラグ
/* 応答計測用 */
let responseStartTime = 0;         // 計測開始時刻
let lastReasoningLength = 0;       // 最後の応答の思考過程文字数
let lastContentLength = 0;         // 最後の応答の本文文字数
let lastResponseTime = 0;          // 最後の応答の所要時間(ミリ秒)

let fullReasoningText = "";        // 思考過程バッファ
let currentReasoningEl = null;     // 思考過程表示DOM

/* --- 添付ファイルの状態管理(ステップ④で追加) ---
   ユーザーが添付したファイルを、送信までの間
   メモリ上で覚えておくための変数です。 */
let attachedImages = [];           // 添付画像 [{name, size, type, dataUrl}]
let attachedPdf = null;            // 添付PDF {name, size, pageRange, text, pageImages}
let attachedTextFiles = [];        // 添付テキストファイル [{name, size, type, content, path}]

/* ============================================================
   【3】DOM参照(画面の部品を変数に入れておく)
   ------------------------------------------------------------
   $ は「document.querySelector」の短縮形。
   CSSセレクタ("#id名" など)で要素を1つ取得します。
============================================================ */
const $ = (sel) => document.querySelector(sel);
const sidebar = $("#sidebar");
const conversationList = $("#conversationList");
const chatContainer = $("#chatContainer");
const messageInput = $("#messageInput");
const sendBtn = $("#sendBtn");
const stopBtn = $("#stopBtn");
const errorArea = $("#errorArea");
const apiKeyInput = $("#apiKeyInput");
const settingsPanel = $("#settingsPanel");
const topbarTitle = $("#topbar-title");

/* --- ステップ②で追加した設定項目のDOM参照 --- */
/* モデル名の選択欄 */
const modelInput = $("#modelInput");
/* reasoning_effort(Kimi K3専用)の項目「枠」と「選択欄」 */
const reasoningEffortField = $("#reasoningEffortField");
const reasoningEffortSelect = $("#reasoningEffortSelect");
/* 思考モードの項目「枠」「選択欄」「ラベル」「補足説明」 */
const thinkingModeField = $("#thinkingModeField");
const thinkingModeSelect = $("#thinkingModeSelect");
const thinkingModeFieldLabel = $("#thinkingModeFieldLabel");
const thinkingModeFieldHint = $("#thinkingModeFieldHint");
/* max_tokens の入力欄 */
const maxTokensInput = $("#maxTokensInput");
/* 画面更新間隔の入力欄 */
const renderIntervalInput = $("#renderIntervalInput");
/* APIタイムアウトの入力欄 */
const requestTimeoutInput = $("#requestTimeoutInput");
/* APIタイムアウトの補足説明(ヒント) */
const timeoutHint = $("#timeoutHint");
/* --- ステップ③で追加したカスタムプロンプト関連のDOM参照 --- */
/* カスタムプロンプトモードの切り替え(チェックボックス) */
const customPromptModeToggle = $("#customPromptModeToggle");
/* カスタムプロンプトの編集エリア「枠」 */
const customPromptField = $("#customPromptField");
/* カスタムプロンプトのテキストエリア */
const customSystemPrompt = $("#customSystemPrompt");
/* 「デフォルトに戻す」ボタン */
const resetPromptBtn = $("#resetPromptBtn");
/* 「コピー」ボタン */
const copyPromptBtn = $("#copyPromptBtn");

/* --- ステップ④で追加した添付関連のDOM参照 --- */
/* 添付プレビューエリア(選択したファイルの一覧表示) */
const attachmentPreviewArea = $("#attachmentPreviewArea");
/* 入力エリアの「カード」(ドラッグ中の強調表示に使う) */
const inputRow = $("#inputRow");
/* 隠しファイル入力(添付ボタンが押された時に開く) */
const imageFileInput = $("#imageFileInput");
const pdfFileInput = $("#pdfFileInput");
const textFileInput = $("#textFileInput");
const folderFileInput = $("#folderFileInput");
/* ドロップオーバーレイ(ドラッグ&ドロップの案内表示) */
const dropOverlay = $("#dropOverlay");

/* --- アップロード上限の設定項目のDOM参照 --- */
const maxImageSizeInput = $("#maxImageSizeInput");
const maxPdfSizeInput = $("#maxPdfSizeInput");
const maxPdfPagesInput = $("#maxPdfPagesInput");
const maxTextSizeInput = $("#maxTextSizeInput");

/* --- ステップ⑤で追加したエクスポート・インポート関連のDOM参照 --- */
/* インポート用の「隠しファイル入力」
   「📥 読込」ボタンが押された時に、JavaScriptがこれをクリックして
   ファイル選択ダイアログを開きます(差分⑤でイベント登録)。 */
const importFileInput = $("#importFileInput");

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

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

/* --- ファイルサイズを読みやすい形式で表示する ---
   バイト数を受け取り、「○○ B」「○○ KB」「○○ MB」の
   読みやすい文字列に変換して返します。
   例:1024 → "1.0 KB"、1048576 → "1.0 MB"
   (完成形の formatBytes と同じ仕様) */
function formatBytes(bytes) {
  if (bytes < 1024) return bytes + " B";
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}

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

/* --- エラーメッセージを画面上部に表示する ---
   APIキーなどの秘密情報が誤って表示されないよう除去します。 */
function showError(message) {
  const safeMsg = String(message).replace(/Bearer\s+\S+/gi, "Bearer [非表示]");
  errorArea.textContent = "⚠ " + safeMsg;
  errorArea.classList.add("visible");
}

/* --- エラーメッセージを消す --- */
function clearError() {
  errorArea.textContent = "";
  errorArea.classList.remove("visible");
}

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

/* ============================================================
   【5】モデル関連(設定パネルの値を読む形に拡張)
   ------------------------------------------------------------
   ステップ①では固定値を返していましたが、
   ステップ②では設定パネルの値を読んで返します。
   また、モデルに応じて設定項目の表示を切り替える
   updateModelSpecificSettingsUI() を新たに追加します。
============================================================ */

/* --- 使用するモデル名を返す(設定パネルの選択値を読む) --- */
function getSelectedModel() {
  return modelInput.value.trim() || "kimi-k3";
}

/* --- モデルの表示名を返す(ロールラベル・応答計測に使う) --- */
function getModelDisplayName() {
  const names = {
    "kimi-k3": "Kimi K3",
    "kimi-k2.7-code": "Kimi K2.7 Code",
    "kimi-k2.6": "Kimi K2.6"
  };
  return names[getSelectedModel()] || getSelectedModel();
}

/* --- 思考モードが有効かどうかを返す ---
   モデルに応じて、思考モードの有効・無効を判定します。 */
function isThinkingEnabled() {
  const model = getSelectedModel();

  // Kimi K3 は「常時思考モデル」なので常に true
  if (model === "kimi-k3") return true;

  // Kimi K2.7 Code は本アプリでは思考モード固定ON
  if (model === "kimi-k2.7-code") return true;

  // Kimi K2.6 はユーザーがON/OFFを選択
  return model === "kimi-k2.6" && thinkingModeSelect.value === "enabled";
}

/* --- 画面更新間隔(ミリ秒)を返す(設定パネルの値を読む) --- */
function getRenderInterval() {
  let v = parseInt(renderIntervalInput.value, 10);
  if (isNaN(v)) v = 80;
  return Math.min(100, Math.max(50, v)); // 50〜100msに制限
}

/* --- モデルに応じて、設定項目の表示を切り替える ---
   「思考モード」と「reasoning_effort」の表示・非表示を
   モデルの選択に応じて切り替えます。 */
function updateModelSpecificSettingsUI() {
  const model = getSelectedModel();

  // Kimi K3:reasoning_effort のみ表示
  if (model === "kimi-k3") {
    reasoningEffortField.classList.remove("hidden");
    reasoningEffortSelect.value = normalizeReasoningEffort(reasoningEffortSelect.value);
    thinkingModeField.classList.add("hidden");
    thinkingModeSelect.value = "enabled";
    thinkingModeSelect.disabled = true;
    thinkingModeFieldHint.textContent = "";
    return;
  }

  // Kimi K2.7 Code:思考モード固定ON、reasoning_effortは使用しない
  if (model === "kimi-k2.7-code") {
    reasoningEffortField.classList.add("hidden");
    thinkingModeField.classList.remove("hidden");
    thinkingModeFieldLabel.textContent = "思考モード(kimi-k2.7-code)";
    thinkingModeSelect.value = "enabled";
    thinkingModeSelect.disabled = true;
    thinkingModeFieldHint.textContent = "Kimi K2.7 Code は本アプリでは思考モードをONにして使用します。reasoning_effort は送信しません。";
    return;
  }

  // Kimi K2.6:思考モードON/OFF、reasoning_effortは使用しない
  if (model === "kimi-k2.6") {
    reasoningEffortField.classList.add("hidden");
    thinkingModeField.classList.remove("hidden");
    thinkingModeFieldLabel.textContent = "思考モード(kimi-k2.6)";
    thinkingModeSelect.disabled = false;
    const savedK26ThinkingMode = ["disabled", "enabled"].includes(thinkingModeSelect.dataset.k26Value)
      ? thinkingModeSelect.dataset.k26Value
      : "enabled";
    thinkingModeSelect.value = savedK26ThinkingMode;
    thinkingModeFieldHint.textContent = "ON/OFFを選択できます。reasoning_effort は送信しません。";
    return;
  }

  // 未知のモデル:モデル固有設定を送信しない
  reasoningEffortField.classList.add("hidden");
  thinkingModeField.classList.add("hidden");
  thinkingModeSelect.disabled = true;
}

/* --- reasoning_effort の値を正規化する ---
   「low」「high」「max」以外の値が来た場合は「low」に丸めます。 */
function normalizeReasoningEffort(value) {
  return ["low", "high", "max"].includes(value) ? value : "low";
}

/* ============================================================
   【6】UI設定(テーマ+設定項目)の保存・読み込み
   ------------------------------------------------------------
   テーマ(ダークモード)に加えて、ステップ②で追加した
   設定項目(モデル名・reasoning_effort・max_tokens など)も
   localStorage に保存・復元します。
============================================================ */

/* --- 現在の画面設定をlocalStorageに保存する ---
   新しく追加した設定項目(モデル名・reasoning_effort・
   max_tokens など)も、テーマと同じく保存します。 */
function saveUiSettings() {
  try {
    const settings = {
      dark: document.body.classList.contains("dark"),
      model: modelInput.value,
      reasoningEffort: normalizeReasoningEffort(reasoningEffortSelect.value),
      maxTokens: maxTokensInput.value,
      renderInterval: renderIntervalInput.value,
      thinkingMode: thinkingModeSelect.dataset.k26Value || thinkingModeSelect.value,
      requestTimeout: requestTimeoutInput.value,
      /* ステップ③で追加したカスタムプロンプト設定 */
      customPromptMode: customPromptModeToggle.checked,
      customPrompt: customSystemPrompt.value,
      /* ステップ④で追加したアップロード上限の設定 */
      maxImageSize: maxImageSizeInput.value,
      maxPdfSize: maxPdfSizeInput.value,
      maxTextSize: maxTextSizeInput.value,
      maxPdfPages: maxPdfPagesInput.value
    };
    localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));

    /* 変更された設定値を、動作パラメータ(変数)に即座に反映する */
    applyUploadSettings();
  } catch (e) { /* localStorage不可時は無視 */ }
}

/* --- 保存された画面設定を読み込んで適用する(起動時に1回実行) ---
   保存した設定項目を、設定パネルの各入力欄に復元します。 */
function loadUiSettings() {
  try {
    const raw = localStorage.getItem(UI_SETTINGS_KEY);
    if (raw) {
      const s = JSON.parse(raw);
      if (s.dark) document.body.classList.add("dark");
      if (s.model) {
        if ([...modelInput.options].some(o => o.value === s.model)) {
          modelInput.value = s.model;
        }
      }
      if (s.reasoningEffort) reasoningEffortSelect.value = normalizeReasoningEffort(s.reasoningEffort);
      if (s.maxTokens) maxTokensInput.value = s.maxTokens;
      if (s.renderInterval) renderIntervalInput.value = s.renderInterval;
      if (s.thinkingMode) {
        thinkingModeSelect.value = ["disabled", "enabled"].includes(s.thinkingMode)
          ? s.thinkingMode
          : "enabled";
        thinkingModeSelect.dataset.k26Value = thinkingModeSelect.value;
      }
      if (s.requestTimeout) requestTimeoutInput.value = s.requestTimeout;

      /* ステップ③で追加したカスタムプロンプト設定の復元 */
      if (s.customPromptMode !== undefined) {
        customPromptModeToggle.checked = s.customPromptMode;
        if (s.customPromptMode) {
          customSystemPrompt.value = s.customPrompt || DEFAULT_SYSTEM_PROMPT;
        }
      }

      /* ステップ④で追加したアップロード上限の設定の復元 */
      if (s.maxImageSize) maxImageSizeInput.value = s.maxImageSize;
      if (s.maxPdfSize) maxPdfSizeInput.value = s.maxPdfSize;
      if (s.maxTextSize) maxTextSizeInput.value = s.maxTextSize;
      if (s.maxPdfPages) maxPdfPagesInput.value = s.maxPdfPages;
    }
  } catch (e) { /* 無視 */ }

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

  /* 保存済みの設定値を、動作パラメータ(変数)に反映する */
  applyUploadSettings();

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

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

/* --- アップロード設定を、アプリの動作パラメータ(変数)に反映する ---
   設定パネルの値を読み取り、以下の変数をまとめて更新します。
   ・画像アップロード上限(MAX_IMAGE_SIZE)
   ・PDFアップロード上限(MAX_PDF_SIZE)
   ・テキストファイル上限(MAX_TEXT_FILE_SIZE)
   ・テキストファイル合計上限(MAX_TEXT_TOTAL_SIZE)
   ・APIタイムアウト(REQUEST_TIMEOUT_MS)
   ・PDFページ数上限(MAX_PDF_PAGES)
   (完成形の applyUploadSettings と同じ仕様) */
function applyUploadSettings() {
  /* 画像の容量の上限(設定値のKBをバイト数に変換) */
  MAX_IMAGE_SIZE = (parseInt(maxImageSizeInput.value, 10) || 10240) * 1024;
  /* PDFの容量の上限(設定値のKBをバイト数に変換) */
  MAX_PDF_SIZE = (parseInt(maxPdfSizeInput.value, 10) || 20480) * 1024;
  /* テキストファイル1件あたりの容量の上限(設定値のKBをバイト数に変換) */
  MAX_TEXT_FILE_SIZE = (parseInt(maxTextSizeInput.value, 10) || 5120) * 1024;
  /* テキストファイル全体の合計容量の上限(1件あたりの4倍に設定) */
  MAX_TEXT_TOTAL_SIZE = MAX_TEXT_FILE_SIZE * 4;
  /* APIタイムアウト(設定値の秒をミリ秒に変換) */
  REQUEST_TIMEOUT_MS = (parseInt(requestTimeoutInput.value, 10) || 360) * 1000;
  /* PDFのページ数の上限(設定値をそのまま使用) */
  MAX_PDF_PAGES = parseInt(maxPdfPagesInput.value, 10) || 10;
}

/* --- 設定パネルの補足表示(ヒント)を、現在の設定値に合わせて更新する ---
   画像・PDF・PDFページ数・テキストファイル・APIタイムアウトの
   ヒント表示を、それぞれ現在の設定値に合わせて書き換えます。
   (完成形の updateSettingHints と同じ仕様) */
function updateSettingHints() {
  /* 画像アップロード上限のヒント:「10240KB」→「10.0MB まで」のようにMBに換算して表示 */
  const imageKB = parseInt(maxImageSizeInput.value, 10) || 10240;
  const imageMB = (imageKB / 1024).toFixed(1);
  document.getElementById("imageSizeHint").textContent =
    imageMB >= 1 ? `${imageMB}MB まで` : `${imageKB}KB まで`;

  /* PDFアップロード上限のヒント:「20480KB」→「20.0MB まで」のようにMBに換算して表示 */
  const pdfKB = parseInt(maxPdfSizeInput.value, 10) || 20480;
  const pdfMB = (pdfKB / 1024).toFixed(1);
  document.getElementById("pdfSizeHint").textContent =
    pdfMB >= 1 ? `${pdfMB}MB まで` : `${pdfKB}KB まで`;

  /* PDFページ数上限のヒント:「10」→「10ページ まで」のように表示 */
  const pdfPages = parseInt(maxPdfPagesInput.value, 10) || 10;
  document.getElementById("pdfPagesHint").textContent = `${pdfPages}ページ まで`;

  /* テキストファイル上限のヒント:「5120KB」→「5.0MB まで」のようにMBに換算して表示 */
  const textKB = parseInt(maxTextSizeInput.value, 10) || 5120;
  const textMB = (textKB / 1024).toFixed(1);
  document.getElementById("textSizeHint").textContent =
    textMB >= 1 ? `${textMB}MB まで` : `${textKB}KB まで`;

  /* APIタイムアウトのヒント:「360秒」→「6.0分 まで」のように分に換算して表示 */
  const timeoutSec = parseInt(requestTimeoutInput.value, 10) || 360;
  const timeoutMin = (timeoutSec / 60).toFixed(1);
  document.getElementById("timeoutHint").textContent =
    timeoutMin >= 1 ? `${timeoutMin}分 まで` : `${timeoutSec}秒 まで`;
}

/* --- カスタムプロンプトの表示/非表示を切り替える ---
   チェックボックスがONの時だけ、編集エリアを表示します。
   (完成形の updateCustomPromptVisibility と同じ仕様) */
function updateCustomPromptVisibility() {
  if (customPromptModeToggle.checked) {
    customPromptField.classList.remove("hidden");
    /* 初回有効化時、エリアが空の場合はデフォルトプロンプトを挿入 */
    if (!customSystemPrompt.value.trim()) {
      customSystemPrompt.value = DEFAULT_SYSTEM_PROMPT;
    }
  } else {
    customPromptField.classList.add("hidden");
  }
}

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

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

/* ============================================================
   【7】会話履歴管理(localStorage)
   ------------------------------------------------------------
   ※ APIキーは保存しません(セキュリティ仕様)。
============================================================ */

/* --- 新しい会話を作成する --- */
function createConversation() {
  const conv = {
    id: generateId(),
    title: "新しいチャット",
    messages: [],
    createdAt: Date.now()
  };
  conversations.unshift(conv);   // unshift:配列の「先頭」に追加
  activeConversationId = conv.id;
  saveConversations();
  renderConversationList();
  renderActiveConversation();
  return conv;
}

/* --- 現在開いている会話(1件分のデータ)を取得する --- */
function getActiveConversation() {
  return conversations.find(c => c.id === activeConversationId) || null;
}

/* --- 会話履歴をlocalStorageに保存する --- */
function saveConversations() {
  try {
    /* 保存用に「必要な項目だけ」を取り出して軽くする */
    const safe = conversations.map(c => ({
      id: c.id,
      title: c.title,
      createdAt: c.createdAt,
      messages: c.messages.map(m => ({
        role: m.role,
        content: m.content,
        metricsText: m.metricsText || undefined,
        /* ステップ④で追加した attachmentsMeta も保存対象に含める
           これにより、インポートした会話の attachmentsMeta も、
           画面を更新(リロード)しても消えずに残ります。 */
        attachmentsMeta: (m.attachmentsMeta || []).map(a => ({
          name: a.name, type: a.type, size: a.size, pages: a.pages
        }))
      }))
    }));
    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();
}

/* ============================================================
   会話履歴のエクスポート・インポート(ステップ⑤で追加)
   ------------------------------------------------------------
   すべての会話履歴を、JSONファイルとして出力(エクスポート)し、
   別のパソコンやブラウザで読み込み(インポート)できるようにします。
   ※添付の「中身」(画像データやPDFのテキスト)は含まれず、
     「会話の内容」と「添付のメタデータ(ファイル名・サイズ)」のみが
     エクスポートされます(ステップ④で学んだ設計と同じ)。
   (完成形の exportConversations・importConversations と同じ仕様)
============================================================ */

/* --- 会話履歴をJSONファイルとしてエクスポートする ---
   「Blob」というブラウザ標準機能でJSONファイルを生成し、
   一時的なダウンロードリンク(aタグ)で保存させます。
   ファイル名は「kimi-chat-history-YYYYMMDD.json」の形式です。 */
function exportConversations() {
  /* エクスポートする会話が1件もない場合は、エラーを表示して中止 */
  if (conversations.length === 0) {
    showError("エクスポートする会話がありません。");
    return;
  }

  /* エクスポート用のデータを組み立てる
     「会話の内容」と「添付のメタデータ」のみを含め、
     添付の「中身」(画像データやPDFのテキスト)は含めません。 */
  const exportData = {
    appName: "Kimi K3 Webチャット",
    exportVersion: 1,
    exportedAt: new Date().toISOString(),
    activeConversationId: activeConversationId,
    conversations: 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,
        attachmentsMeta: (m.attachmentsMeta || []).map(a => ({
          name: a.name, type: a.type, size: a.size, pages: a.pages
        }))
      }))
    }))
  };

  /* JSON文字列に変換し、「Blob」でファイルを生成する
     Blob(ブロブ)とは、「バイナリデータの塊」を扱うブラウザ標準の機能で、
     これを使うと「ブラウザ上でファイルを生成」できます。 */
  const json = JSON.stringify(exportData, null, 2);
  const blob = new Blob([json], { type: "application/json" });

  /* 一時的なダウンロードリンク(aタグ)を作り、クリックして保存させる
     URL.createObjectURL で「Blobへの一時的なURL」を作り、
     それをaタグの href に設定してクリックします。 */
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  /* ファイル名は「kimi-chat-history-YYYYMMDD.json」の形式 */
  const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
  a.href = url;
  a.download = `kimi-chat-history-${dateStr}.json`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  /* 一時的なURLは、使い終わったら必ず解放する(メモリリーク防止) */
  URL.revokeObjectURL(url);
}

/* --- エクスポートしたJSONファイルを読み込み(インポート)する ---
   引数 file:選択されたJSONファイル
   「形式の検証」「サイズの上限(50MB)」「確認ダイアログ」に対応し、
   現在の履歴に「追加して」復元します(既存の履歴は削除されません)。 */
function importConversations(file) {
  if (!file) return;

  /* サイズの上限(50MB)を検証する */
  if (file.size > 50 * 1024 * 1024) {
    showError("インポートファイルが大きすぎます(50MBまで)。");
    return;
  }

  /* ファイルの中身をテキストとして読み込む */
  const reader = new FileReader();
  reader.onload = () => {
    try {
      const data = JSON.parse(reader.result);

      /* 形式の検証:conversations が配列でなければ、エラーを表示して中止 */
      if (!data.conversations || !Array.isArray(data.conversations)) {
        showError("インポートファイルの形式が不正です。このアプリでエクスポートしたJSONファイルを選択してください。");
        return;
      }

      /* 会話データの検証:id・title・messages が正しく入っている会話だけを取り出す */
      const validConversations = data.conversations.filter(c => {
        return c && typeof c.id === "string" && typeof c.title === "string" && Array.isArray(c.messages);
      });

      if (validConversations.length === 0) {
        showError("有効な会話データが見つかりませんでした。");
        return;
      }

      /* 確認ダイアログを表示する(「キャンセル」で処理を中断)
         メッセージの内容は、完成形のアプリと同じものを採用しています */
      const ok = confirm(
        `${validConversations.length}件の会話が含まれています。\n\n` +
        `現在の会話履歴に追加してインポートしますか?\n` +
        `(既存の会話履歴は削除されません)`
      );

      if (!ok) return; // キャンセル時は何もしない

      /* 追加モード(IDの重複を避けるため、新しいIDを付与する)
         インポートした会話には、generateId() で新しいIDを付け直します。
         これにより、既存の会話とIDが重複するのを防ぎます。 */
      validConversations.forEach(c => {
        c.id = generateId();
        conversations.unshift(c);
      });

      /* アクティブ会話の設定(インポートした最新の会話を選択) */
      activeConversationId = conversations[0].id;

      saveConversations();
      renderConversationList();
      renderActiveConversation();
      alert(`${validConversations.length}件の会話を追加しました。`);

      /* トップバーのタイトルも、現在の会話のタイトルに更新する */
      const activeConv = getActiveConversation();
      if (activeConv) topbarTitle.textContent = activeConv.title;

    } catch (e) {
      showError("JSONファイルの解析に失敗しました:" + sanitizeLog(e));
    }
  };
  reader.onerror = () => {
    showError("ファイルの読み込みに失敗しました。");
  };
  reader.readAsText(file);
}

/* ============================================================
   【8】サイドバーの「会話履歴一覧」の描画
============================================================ */
function renderConversationList() {
  conversationList.innerHTML = "";   // 一度中身を空にする
  conversations.forEach(c => {
    const item = document.createElement("div");
    item.className = "conv-item" + (c.id === activeConversationId ? " active" : "");

    /* タイトル(ダブルクリックで編集開始・ホバーで全文表示) */
    const title = document.createElement("span");
    title.className = "conv-title";
    title.textContent = c.title;
    title.title = c.title;
    title.addEventListener("dblclick", (e) => {
      e.stopPropagation();
      e.preventDefault();
      if (isGenerating) return;
      startRenameConversation(c, item);
    }, { capture: true });

    /* ホバー時:上に140px以内ならポップを下向きに切り替える
       (サイドバー最上部の履歴でもポップが見切れないようにする完成形の配慮) */
    title.addEventListener("mouseenter", () => {
      const rect = title.getBoundingClientRect();
      if (rect.top < 140) {
        title.classList.add("tooltip-below");
      } else {
        title.classList.remove("tooltip-below");
      }
    });

    /* ✎編集ボタン(ホバー時だけ表示) */
    const edit = document.createElement("button");
    edit.className = "conv-edit";
    edit.textContent = "✎";
    edit.setAttribute("data-tooltip", "タイトルを編集します");
    edit.addEventListener("click", (e) => {
      e.stopPropagation();
      if (isGenerating) return;
      startRenameConversation(c, item);
    });

    /* ✕削除ボタン(ホバー時だけ表示) */
    const del = document.createElement("button");
    del.className = "conv-delete";
    del.textContent = "✕";
    del.setAttribute("data-tooltip", "この会話を削除します");
    del.addEventListener("click", (e) => {
      e.stopPropagation();
      if (confirm("この会話を削除しますか?")) deleteConversation(c.id);
    });

    /* 編集ボタン:ホバー時のポップ位置調整 */
    edit.addEventListener("mouseenter", () => {
      const rect = edit.getBoundingClientRect();
      if (rect.top < 140) {
        edit.classList.add("tooltip-below");
      } else {
        edit.classList.remove("tooltip-below");
      }
    });

    /* 削除ボタン:ホバー時のポップ位置調整 */
    del.addEventListener("mouseenter", () => {
      const rect = del.getBoundingClientRect();
      if (rect.top < 140) {
        del.classList.add("tooltip-below");
      } else {
        del.classList.remove("tooltip-below");
      }
    });

    item.appendChild(title);
    item.appendChild(edit);
    item.appendChild(del);
    /* 行全体が押された時:その会話を開く */
    item.addEventListener("click", (e) => {
      if (e.detail === 2) return;   // ダブルクリックは編集なので除外
      if (isGenerating) {
        if (window.innerWidth <= 768) sidebar.classList.remove("open");
        return;
      }
      activeConversationId = c.id;
      topbarTitle.textContent = c.title;
      renderConversationList();
      renderActiveConversation();
      if (window.innerWidth <= 768) sidebar.classList.remove("open");
    });
    conversationList.appendChild(item);
  });
}

/* --- 会話タイトルのインライン編集(ダブルクリック/✎時) --- */
function startRenameConversation(conv, itemElement) {
  const titleEl = itemElement.querySelector(".conv-title");
  if (!titleEl) return;
  const currentTitle = conv.title;

  /* タイトル表示を「入力フィールド」に置き換える */
  const input = document.createElement("input");
  input.type = "text";
  input.className = "conv-title-input";
  input.value = currentTitle;
  input.maxLength = 100;
  titleEl.replaceWith(input);
  input.focus();
  input.select();

  let finished = false;
  const finish = (save) => {
    if (finished) return;
    finished = true;
    const newTitle = input.value.trim();
    if (save && newTitle && newTitle !== currentTitle) {
      conv.title = newTitle;
      if (conv.id === activeConversationId) {
        topbarTitle.textContent = newTitle;
      }
      saveConversations();
    }
    renderConversationList();
  };

  /* Enterで確定 / Escでキャンセル / フォーカスアウトで確定 */
  input.addEventListener("keydown", (e) => {
    if (e.isComposing) return;   // IME変換中のEnterは無視
    if (e.key === "Enter") { e.preventDefault(); finish(true); }
    else if (e.key === "Escape") { e.preventDefault(); finish(false); }
    e.stopPropagation();
  });
  input.addEventListener("blur", () => finish(true));
  input.addEventListener("click", (e) => e.stopPropagation());
  input.addEventListener("dblclick", (e) => e.stopPropagation());
}

/* ============================================================
   【9】メッセージ描画(完成形と同一の構造)
   ------------------------------------------------------------
   「アバター(U/K)+ロールラベル+本文」を横並びで配置します。
============================================================ */
function renderMessage(msg) {
  const wrap = document.createElement("div");
  wrap.className = "message " + msg.role;

  /* 丸いアバター(ユーザーは「U」、AIは「K」) */
  const avatar = document.createElement("div");
  avatar.className = "msg-avatar";
  avatar.textContent = msg.role === "user" ? "U" : "K";

  const body = document.createElement("div");
  body.className = "msg-body";

  /* ロールラベル(「あなた」/「Kimi K3」) */
  const roleLabel = document.createElement("div");
  roleLabel.className = "msg-role";
  roleLabel.textContent = msg.role === "user" ? "あなた" : getModelDisplayName();

  const content = document.createElement("div");
  content.className = "msg-content";

  if (msg.role === "assistant") {
    /* ----- AIの回答:Markdownとして描画(安全化つき) ----- */
    content.classList.add("markdown-body");
    content.innerHTML = renderMarkdown(msg.content);
    addCopyButtons(content);          // 言語名+コピーボタン
    applySyntaxHighlight(content);    // シンタックスハイライト

    /* 応答計測の結果(計測データがある時だけ) */
    if (msg.metricsText) {
      const metricsEl = document.createElement("div");
      metricsEl.className = "response-metrics";
      metricsEl.textContent = msg.metricsText;
      content.appendChild(metricsEl);
    }
  } else {
    /* ----- あなたの質問:無害化したテキストをそのまま表示 ----- */
    content.textContent = msg.content;

    /* 長い質問(500文字超)は折りたたんで「全文を表示」を付ける */
    const COLLAPSE_THRESHOLD = 500;
    if (msg.content.length > COLLAPSE_THRESHOLD) {
      content.classList.add("user-content-collapsed");
      const collapseContainer = document.createElement("div");
      collapseContainer.className = "user-collapse-container";
      const toggleBtn = document.createElement("button");
      toggleBtn.className = "user-toggle-btn";
      toggleBtn.textContent = "▼ 全文を表示";
      toggleBtn.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
        const isCollapsed = content.classList.toggle("user-content-collapsed");
        toggleBtn.textContent = isCollapsed ? "▼ 全文を表示" : "▲ 折りたたむ";
      });
      collapseContainer.appendChild(toggleBtn);
      content._collapseContainer = collapseContainer;   // 後で挿入するため保持
    }
  }

  body.appendChild(roleLabel);
  body.appendChild(content);

  /* 折りたたみコンテナがあれば挿入 */
  if (content._collapseContainer) {
    content.parentNode.insertBefore(content._collapseContainer, content.nextSibling);
    delete content._collapseContainer;
  }

  /* 添付メタデータ表示(ステップ④で追加)
     「どのファイルを添付したか」の情報(ファイル名・形式・サイズ)を、
     会話履歴を再表示した時に表示します。
     ※添付の「中身」(画像データやPDFのテキスト)は保存していないため、
       ここでは「ファイル名とサイズ」のみを表示します。 */
  if (msg.attachmentsMeta && msg.attachmentsMeta.length > 0) {
    const attachBox = document.createElement("div");
    attachBox.className = "msg-attachments";
    msg.attachmentsMeta.forEach(a => {
      const chip = document.createElement("span");
      chip.className = "msg-attachment";
      /* PDFの場合は「📄」、それ以外(画像・テキストファイル)は「🖼」のアイコンを付ける */
      const icon = a.type === "application/pdf" ? "📄" : "🖼";
      chip.textContent = `${icon} ${a.name} (${formatBytes(a.size || 0)})`;
      attachBox.appendChild(chip);
    });
    body.appendChild(attachBox);
  }

  /* 操作ボタン(コピー / 再生成 / 再送信。ホバー時だけ表示) */
  const actions = document.createElement("div");
  actions.className = "msg-actions";

  /* コピーボタン */
  const copyBtn = document.createElement("button");
  copyBtn.textContent = "📋 コピー";
  copyBtn.addEventListener("click", async () => {
    try {
      await navigator.clipboard.writeText(msg.content);
      copyBtn.textContent = "✓ コピーしました";
      setTimeout(() => { copyBtn.textContent = "📋 コピー"; }, 1500);
    } catch (e) {
      showError("コピーに失敗しました。ブラウザのクリップボード権限を確認してください。");
    }
  });
  actions.appendChild(copyBtn);

  /* 再生成ボタン(AIの回答)/ 再送信ボタン(あなたの質問) */
  if (msg.role === "assistant") {
    const regenBtn = document.createElement("button");
    regenBtn.textContent = "↻ 再生成";
    regenBtn.addEventListener("click", () => regenerateResponse(msg));
    actions.appendChild(regenBtn);
  } else {
    const resendBtn = document.createElement("button");
    resendBtn.textContent = "↻ 再送信";
    resendBtn.addEventListener("click", () => {
      if (isGenerating) return;
      messageInput.value = msg.content;
      messageInput.focus();
      autoResizeTextarea();
    });
    actions.appendChild(resendBtn);
  }

  body.appendChild(actions);
  wrap.appendChild(avatar);
  wrap.appendChild(body);
  return wrap;
}

/* ============================================================
   ウェルカム画面の表示制御(ステップ⑦で追加)
   ------------------------------------------------------------
   起動時や新規チャット時に表示される「初期画面」(ウェルカム画面)を
   制御する関数です。
   「会話が空っぽの時」だけウェルカム画面を表示し、
   タイトルとサブタイトルをAPIキーの入力状態に応じて書き換えます。
   (完成形の updateWelcomeScreen と同じ仕様)
============================================================ */
function updateWelcomeScreen() {
  const welcomeScreen = $("#welcomeScreen");
  const welcomeTitle = $("#welcomeTitle");
  const welcomeSubtitle = $("#welcomeSubtitle");
  if (!welcomeScreen) return;

  const conv = getActiveConversation();
  const isEmpty = conv && conv.messages.length === 0;   // 会話が空っぽかどうか
  const hasApiKey = apiKeyInput.value.trim() !== "";        // APIキーが入力されているか

  if (isEmpty) {
    /* タイトルとサブタイトルをAPIキー状態で切り替え */
    if (hasApiKey) {
      /* APIキー入力済みの場合:
         選択中のモデル名(例:Kimi K3)と「準備はできています。」を表示する */
      welcomeTitle.textContent = getModelDisplayName();
      welcomeSubtitle.textContent = "準備はできています。会話を始めましょう。";
    } else {
      /* APIキー未入力の場合:
         「カスタムAIチャットアプリ開発(MIT License)」と「APIキーを入力しチャットを始めましょう。」を表示する */
      welcomeTitle.textContent = "カスタムAIチャットアプリ開発(MIT License)";
      welcomeSubtitle.textContent = "画面右上の「設定」パネルを開き「APIキー」を入力しチャットを始めましょう。";
    }
    /* 会話が空っぽの場合:ウェルカム画面を表示する */
    welcomeScreen.classList.add("visible");
    chatContainer.classList.add("is-welcome");
    $("#main").classList.add("is-welcome");
  } else {
    /* メッセージが1件でもある場合:ウェルカム画面を隠す */
    welcomeScreen.classList.remove("visible");
    chatContainer.classList.remove("is-welcome");
    $("#main").classList.remove("is-welcome");
  }
}

/* --- 現在の会話の「メッセージ一覧」を描画する ---
   ※ステップ⑦で、ウェルカム画面の退避と、
     updateWelcomeScreen() の呼び出しを追加しました。 */
function renderActiveConversation() {
  const conv = getActiveConversation();
  if (!conv) return;

  /* innerHTML で消えないよう、ウェルカム画面を退避(ステップ⑦で追加)
     chatContainer.innerHTML = "" でチャット欄の中身がすべて消去されるため、
     ウェルカム画面も一緒に消えてしまいます。
     そこで、消去する前にウェルカム画面を一度「退避」させてから、
     消去後に「戻す」処理を行います。 */
  const welcomeScreen = $("#welcomeScreen");

  chatContainer.innerHTML = "";

  /* メッセージより先にウェルカム画面を戻す(ステップ⑦で追加)
     退避させておいたウェルカム画面を、チャット欄の先頭に戻します。
     これにより、ウェルカム画面が「消去されずに残る」ようになります。 */
  if (welcomeScreen) {
    chatContainer.appendChild(welcomeScreen);
  }

  conv.messages.forEach(m => chatContainer.appendChild(renderMessage(m)));

  /* タイムラインのマーカーも更新する(ステップ⑥で追加) */
  updateQuestionTimeline();

  /* ウェルカム画面の表示制御も更新する(ステップ⑦で追加)
     会話を切り替えた時や、新しい質問を送信した時に、
     ウェルカム画面も自動で更新されます。 */
  updateWelcomeScreen();

  scrollToBottom(true);
}

/* --- メッセージ一覧の一番下(最新)までスクロールする ---
   autoScrollEnabled がOFF(ユーザーが上を読んでいる)時は
   スクロールを奪わない配慮です。 */
function scrollToBottom(force) {
  if (force || autoScrollEnabled) {
    chatContainer.scrollTop = chatContainer.scrollHeight;
  }
}

/* ============================================================
   【10】Markdownレンダリング(サニタイズ付き)
   ------------------------------------------------------------
   ① marked → HTML変換、② DOMPurify → 安全チェック。
============================================================ */
function renderMarkdown(text) {
  try {
    if (typeof marked !== "undefined") {
      marked.setOptions({ breaks: true, gfm: true });
      const html = marked.parse(text || "");
      if (typeof DOMPurify !== "undefined") {
        return DOMPurify.sanitize(html, {
          ADD_ATTR: ["target", "rel"],
          FORBID_TAGS: ["style", "script", "iframe", "object", "embed", "form"]
        });
      }
      return html;
    }
    /* CDN利用不可時のフォールバック:プレーンテキスト表示 */
    return "<pre style='background:none;padding:0;white-space:pre-wrap;color:inherit'>"
      + escapeHtml(text || "") + "</pre>";
  } catch (e) {
    return "<p>" + escapeHtml(text || "") + "</p>";
  }
}

/* ============================================================
   【11】コードブロック装飾(言語名+コピーボタン)
   ------------------------------------------------------------
   Markdownで生成されたコードブロック(<pre><code>)に、
   「言語名の表示」と「コピーボタン」を追加します。
============================================================ */
function addCopyButtons(container) {
  container.querySelectorAll("pre").forEach(pre => {
    if (pre.querySelector(".code-header")) return;   // 二重装飾防止
    const code = pre.querySelector("code");
    if (!code) return;

    /* コードブロックの言語名を取得(class="language-js" など) */
    let lang = "";
    code.classList.forEach(cls => {
      if (cls.startsWith("language-")) lang = cls.replace("language-", "");
    });
    /* 言語名を一般的な表記に変換 */
    const langDisplayNames = {
      'javascript': 'JavaScript',
      'typescript': 'TypeScript',
      'python': 'Python',
      'html': 'HTML',
      'css': 'CSS',
      'json': 'JSON',
      'markdown': 'Markdown',
      'bash': 'Bash',
      'shell': 'Shell',
      'sql': 'SQL',
      'java': 'Java',
      'c': 'C',
      'cpp': 'C++',
      'csharp': 'C#',
      'php': 'PHP',
      'ruby': 'Ruby',
      'go': 'Go',
      'rust': 'Rust',
      'swift': 'Swift',
      'kotlin': 'Kotlin',
      'xml': 'XML',
      'yaml': 'YAML',
      'dockerfile': 'Dockerfile',
      'makefile': 'Makefile',
      'ini': 'INI',
      'toml': 'TOML',
      'csv': 'CSV',
      'txt': 'Text'
    };
    /* 一覧に無い言語は、先頭だけ大文字にして表示(例:js → Js) */
    const displayLang = langDisplayNames[lang.toLowerCase()] || lang.charAt(0).toUpperCase() + lang.slice(1);

    /* ヘッダー(言語名+コピーボタン)をコードブロックの先頭に追加 */
    const header = document.createElement("div");
    header.className = "code-header";

    const langLabel = document.createElement("span");
    langLabel.className = "code-lang";
    langLabel.textContent = displayLang || "code";

    const copyBtn = document.createElement("button");
    copyBtn.className = "code-copy-btn";
    copyBtn.textContent = "コピー";
    copyBtn.addEventListener("click", async () => {
      try {
        await navigator.clipboard.writeText(code.textContent);
        copyBtn.textContent = "✓ コピーしました";
        copyBtn.classList.add("copied");
        setTimeout(() => {
          copyBtn.textContent = "コピー";
          copyBtn.classList.remove("copied");
        }, 1500);
      } catch (e) {
        showError("コードのコピーに失敗しました。");
      }
    });

    header.appendChild(langLabel);
    header.appendChild(copyBtn);
    pre.insertBefore(header, pre.firstChild);
  });
}

/* ============================================================
   【12】シンタックスハイライト(コードに色を付ける)
   ------------------------------------------------------------
   highlight.js を使ってコードブロックを色付けします。
   ライブラリが読み込めていない場合は、何もせずスキップします。
============================================================ */
function applySyntaxHighlight(container) {
  if (!container || !window.hljs) return;
  container.querySelectorAll("pre code").forEach(code => {
    if (code.classList.contains("hljs")) return;   // 適用済みはスキップ
    try {
      hljs.highlightElement(code);
    } catch (e) { /* ハイライト失敗時はそのまま表示 */ }
  });
}

/* ============================================================
   【13】処理状況パネル
   ------------------------------------------------------------
   「質問を分析しています…」「回答を受信しています…」など、
   AIの処理状況を表示するパネルです。
   構造:ヘッダー(スピナー+ステータス)+開閉できるログ一覧
============================================================ */

/* --- 処理状況パネルを作成してチャット欄に追加する --- */
function createProgressPanel() {
  const panel = document.createElement("div");
  panel.className = "progress-panel";

  /* ヘッダー(クリックでログの開閉ができる) */
  const header = document.createElement("div");
  header.className = "progress-header";
  header.innerHTML = `<span class="progress-caret">▶</span>
    <span class="progress-current"><span class="spinner"></span><span class="progress-status-text">待機中</span></span>`;
  header.addEventListener("click", () => panel.classList.toggle("open"));

  /* ログの一覧(初期は閉じた状態) */
  const body = document.createElement("div");
  body.className = "progress-body";

  panel.appendChild(header);
  panel.appendChild(body);
  chatContainer.appendChild(panel);
  scrollToBottom();
  return panel;
}

/* --- 処理状況のステータスを更新する ---
   前のステータスを「完了(✓)」にして、新しいステータスを
   「実行中(●)」としてログに追加します。 */
function showProgressStatus(status) {
  if (!currentProgressPanel) return;
  if (currentProgressState.lastStatus === status) return;   // 同じ内容は無視

  const body = currentProgressPanel.querySelector(".progress-body");
  /* 直前のログを「完了(✓)」に変える */
  if (currentProgressState.lastStatus) {
    const last = body.lastElementChild;
    if (last) {
      last.classList.remove("current");
      last.classList.add("done");
    }
  }

  /* 新しいステータスを「実行中(●)」としてログに追加 */
  const item = document.createElement("div");
  item.className = "progress-log-item current";
  item.textContent = status;
  body.appendChild(item);

  /* ヘッダーの表示テキストも更新 */
  const statusText = currentProgressPanel.querySelector(".progress-status-text");
  statusText.textContent = status;
  currentProgressState.lastStatus = status;

  /* 完了/停止時はスピナー(くるくる回る印)を消す */
  const spinner = currentProgressPanel.querySelector(".spinner");
  if (status === "完了しました") {
    spinner.style.display = "none";
    item.classList.remove("current");
    item.classList.add("done");
  } else if (status === "生成を停止しました") {
    spinner.style.display = "none";
    item.classList.remove("current");
    item.classList.add("stopped");
  }
}

/* ============================================================
   【14】思考過程パネル
   ------------------------------------------------------------
   AIの「考えている内容」を表示するパネルです。
   受信中は開いた状態(🧠 思考過程+スピナー)で表示され、
   回答完了時にストリーミング用DOMごと消去されます。
   (思考過程のテキストは保存されないため、
     完了後に再表示することはできません)
============================================================ */

/* --- 思考過程パネルを作成する(回答本文の「前」に挿入) --- */
function createReasoningPanel(streamWrap) {
  const body = streamWrap.querySelector(".msg-body");
  const contentEl = streamWrap.querySelector(".msg-content");

  const panel = document.createElement("div");
  panel.className = "reasoning-panel open";   // 受信中は開いた状態

  const header = document.createElement("div");
  header.className = "reasoning-header";
  header.innerHTML = `<span class="reasoning-caret">▶</span>
    <span class="reasoning-title">🧠 思考過程</span>
    <span class="spinner reasoning-spinner"></span>`;
  header.addEventListener("click", () => panel.classList.toggle("open"));

  const bodyEl = document.createElement("div");
  bodyEl.className = "reasoning-body";

  panel.appendChild(header);
  panel.appendChild(bodyEl);
  body.insertBefore(panel, contentEl);   // 回答本文の前に挿入
  return bodyEl;
}

/* --- 思考過程の表示内容を更新する(受信のたびに呼ばれる) --- */
function updateReasoningPanel() {
  if (!currentReasoningEl) return;
  currentReasoningEl.textContent = fullReasoningText;
}

/* ============================================================
   ドロップされたファイルの振り分け(ステップ④で追加)
   ------------------------------------------------------------
   ドラッグ&ドロップで落とされたファイルを、
   「画像・PDF・テキストファイル」の種類ごとに振り分け、
   それぞれの添付処理(handleImageUpload・handlePdfUpload・
   handleTextFiles)に渡します。
   (完成形の distributeDroppedFiles と同じ仕様)
============================================================ */
function distributeDroppedFiles(files) {
  /* ドロップされたファイルを、種類ごとに分類する */
  const images = files.filter(f => ALLOWED_IMAGE_TYPES.includes(f.type));
  const pdf = files.find(f => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"));
  const textFiles = files.filter(f => isTextFile(f));

  let handledCount = 0;   // 処理できたファイルの数

  /* 画像があれば、画像の添付処理に渡す(差分⑩-1の関数を再利用) */
  if (images.length > 0) {
    handleImageUpload(images);
    handledCount += images.length;
  }

  /* PDFがあれば、PDFの添付処理に渡す(差分⑩-3-1の関数を再利用)
     PDFは「1件だけ」しか添付できないため、find で「最初の1件」だけを処理する */
  if (pdf) {
    handlePdfUpload(pdf);
    handledCount += 1;
  }

  /* テキストファイルがあれば、テキストの添付処理に渡す(差分⑩-2の関数を再利用) */
  if (textFiles.length > 0) {
    handleTextFiles(textFiles);
    handledCount += textFiles.length;
  }

  /* 処理できなかったファイルがある場合は、エラーを表示する */
  const unsupported = files.filter(f =>
    !ALLOWED_IMAGE_TYPES.includes(f.type) &&
    f.type !== "application/pdf" &&
    !f.name.toLowerCase().endsWith(".pdf") &&
    !isTextFile(f)
  );

  if (unsupported.length > 0) {
    /* 全てが未対応の場合のみエラー表示(一部対応済みなら警告程度に) */
    if (handledCount === 0) {
      showError(`対応していないファイル形式です:${unsupported.map(f => f.name).join(', ')}\n\n対応形式:画像(JPEG/PNG/GIF/WebP)、PDF、テキストファイル(.txt, .py, .js, .html 等)`);
    } else {
      console.warn("未対応ファイル:", unsupported.map(f => f.name));
    }
  }
}

/* ============================================================
   【15】UI状態管理
============================================================ */

/* --- 生成中は「停止」、待機中は「送信」を表示する --- */
function updateUiState() {
  if (isGenerating) {
    sendBtn.classList.add("hidden");
    stopBtn.classList.remove("hidden");
    messageInput.disabled = true;
  } else {
    sendBtn.classList.remove("hidden");
    stopBtn.classList.add("hidden");
    messageInput.disabled = false;
    messageInput.focus();
  }
}

/* --- 入力欄の高さを、入力量に合わせて自動調整する --- */
function autoResizeTextarea() {
  messageInput.style.height = "auto";   // 一度リセットしてから…
  /* scrollHeight(内容の実際の高さ)に合わせる(上限はCSSで200px) */
  messageInput.style.height = Math.min(messageInput.scrollHeight, 200) + "px";
}

/* ============================================================
   画像処理(ステップ④で追加)
   ------------------------------------------------------------
   選択された画像ファイルの「形式」と「サイズ」を検証し、
   問題なければ添付リスト(attachedImages)に追加します。
   画像は「dataUrl」(Base64エンコードした文字列)に変換して
   保持し、送信時にマルチモーダル形式でAPIに送ります。
   (完成形の画像処理関数と同じ仕様)
============================================================ */

/* --- 画像ファイルの添付処理 ---
   引数 files:選択されたファイルの配列(FileList または Array)
   1件ずつ「形式」と「サイズ」を検証し、問題なければ添付します。 */
async function handleImageUpload(files) {
  for (const file of files) {
    /* 形式検証:対応していない形式(JPEG / PNG / GIF / WebP 以外)はスキップ */
    if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
      showError(`画像形式が不正です(${file.name})。JPEG / PNG / GIF / WebP のみ対応しています。`);
      continue;
    }
    /* サイズ検証:上限を超えている場合はスキップ */
    if (file.size > MAX_IMAGE_SIZE) {
      showError(`画像サイズが上限を超えています(${file.name}: ${formatBytes(file.size)})。上限は ${formatBytes(MAX_IMAGE_SIZE)} です。`);
      continue;
    }
    try {
      /* 画像を「dataUrl」(Base64エンコードした文字列)に変換して添付リストに追加 */
      const dataUrl = await convertImageToDataUrl(file);
      attachedImages.push({ name: file.name, size: file.size, type: file.type, dataUrl });
    } catch (e) {
      showError(`画像の読み込みに失敗しました(${file.name})。`);
    }
  }
  /* 添付プレビューを更新する(renderAttachmentPreview は差分⑩-4で追加します) */
  renderAttachmentPreview();
}

/* --- 画像ファイルを「dataUrl」に変換する ---
   引数 file:画像ファイル
   戻り値:dataUrl(Base64エンコードした文字列)を返す Promise
   FileReader API を使って、画像データを「文字列」の形に変換します。 */
function convertImageToDataUrl(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    /* 読み込み成功時:dataUrl(文字列)を返す */
    reader.onload = () => resolve(reader.result);
    /* 読み込み失敗時:エラーを返す */
    reader.onerror = () => reject(new Error("read error"));
    /* readAsDataURL で、画像を「data:image/png;base64,...」の形に変換 */
    reader.readAsDataURL(file);
  });
}

/* --- 添付画像の削除 ---
   添付プレビューの「✕」ボタンが押された時に呼ばれ、
   指定した位置(index)の画像を添付リストから削除します。 */
function removeAttachedImage(index) {
  attachedImages.splice(index, 1);   // 指定した位置の1件を削除
  renderAttachmentPreview();          // 添付プレビューを更新
}

/* ============================================================
   テキストファイル・フォルダ処理(ステップ④で追加)
   ------------------------------------------------------------
   テキストファイル(.txt・.py・.js・.html など)の中身を
   読み込んで添付し、送信時に質問文に添えて送ります。
   フォルダを選択した場合は、中のファイルを「種類ごと」に
   振り分けて、それぞれの添付処理に渡します。
   (完成形のテキストファイル・フォルダ処理関数と同じ仕様)
============================================================ */

/* --- そのファイルが「テキストファイル」かどうかを判定する ---
   2つの方法で判定します。
   ① 拡張子が TEXT_FILE_EXTENSIONS の一覧に含まれているか
   ② MIMEタイプが text/ で始まるか(text/plain など)
   どちらか一方でも当てはまれば、テキストファイルとみなします。 */
function isTextFile(file) {
  /* ファイル名の末尾の「拡張子」を取り出す
     split('.') で「.」で区切り、pop() で最後の要素(拡張子)を取得する */
  const ext = '.' + file.name.split('.').pop().toLowerCase();
  return TEXT_FILE_EXTENSIONS.includes(ext) || file.type.startsWith('text/');
}

/* --- ファイルの中身を「テキスト」として読み込む ---
   引数 file:テキストファイル
   戻り値:ファイルの中身(文字列)を返す Promise
   FileReader API を使って、UTF-8 のテキストとして読み込みます。 */
function readFileAsText(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    /* 読み込み成功時:ファイルの中身(文字列)を返す */
    reader.onload = () => resolve(reader.result);
    /* 読み込み失敗時:エラーを返す */
    reader.onerror = () => reject(new Error("read error"));
    /* readAsText で、UTF-8 のテキストとして読み込む */
    reader.readAsText(file, 'UTF-8');
  });
}

/* --- テキストファイルの添付処理 ---
   引数 files:選択されたファイルの配列
   1件ずつ「拡張子」「1件あたりのサイズ」「合計サイズ」を検証し、
   問題なければ添付リスト(attachedTextFiles)に追加します。 */
async function handleTextFiles(files) {
  /* すでに添付済みのファイルの「合計サイズ」を計算しておく
     (合計サイズの上限チェックに使う) */
  let totalSize = attachedTextFiles.reduce((sum, f) => sum + f.size, 0);

  for (const file of files) {
    /* 拡張子検証:テキストファイル以外はスキップ */
    if (!isTextFile(file)) {
      showError(`対応していないファイル形式です(${file.name})。テキストファイルのみ添付可能です。`);
      continue;
    }

    /* サイズ検証①:1件あたりの上限を超えている場合はスキップ */
    if (file.size > MAX_TEXT_FILE_SIZE) {
      showError(`ファイルサイズが上限を超えています(${file.name}: ${formatBytes(file.size)})。上限は ${formatBytes(MAX_TEXT_FILE_SIZE)} です。`);
      continue;
    }

    /* サイズ検証②:合計サイズが上限を超える場合は、それ以降の処理を中止 */
    if (totalSize + file.size > MAX_TEXT_TOTAL_SIZE) {
      showError(`テキストファイルの合計サイズが上限(${formatBytes(MAX_TEXT_TOTAL_SIZE)})を超えています。`);
      break;
    }

    try {
      /* ファイルの中身をテキストとして読み込む */
      const content = await readFileAsText(file);
      /* フォルダから選択した場合、webkitRelativePath に
         「フォルダ名/ファイル名」の相対パスが入っています。
         パスが長すぎる場合は、ファイル名のみを使います(エラー対策) */
      let safePath = file.webkitRelativePath || file.name;
      if (safePath.length > 200) {
        safePath = file.name;
      }

      /* 添付リストに追加
         name:ファイル名、size:サイズ、type:形式、
         content:中身のテキスト、path:表示用のパス */
      attachedTextFiles.push({
        name: file.name,
        size: file.size,
        type: file.type || 'text/plain',
        content: content,
        path: safePath
      });
      totalSize += file.size;
    } catch (e) {
      showError(`ファイルの読み込みに失敗しました(${file.name})。`);
    }
  }
  /* 添付プレビューを更新する(renderAttachmentPreview は差分⑩-4で追加します) */
  renderAttachmentPreview();
}

/* --- フォルダの添付処理 ---
   引数 files:フォルダ内の全ファイルの配列
   フォルダ内のファイルを「テキスト・画像・PDF」の種類ごとに振り分け、
   それぞれの添付処理(handleTextFiles・handleImageUpload・handlePdfUpload)
   に渡します。 */
async function handleFolderUpload(files) {
  /* フォルダ内の全ファイルを、種類ごとに分類する
     Array.from で「配列風オブジェクト」を本物の配列に変換し、
     filter で条件に合うファイルだけを取り出す */
  const allFiles = Array.from(files);
  const textFiles = allFiles.filter(f => isTextFile(f));
  const images = allFiles.filter(f => ALLOWED_IMAGE_TYPES.includes(f.type));
  const pdfs = allFiles.filter(f => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"));

  let processed = false;   // 1件でも処理できたかどうかのフラグ

  /* テキストファイルがあれば、テキストの添付処理に渡す */
  if (textFiles.length > 0) {
    await handleTextFiles(textFiles);
    processed = true;
  }

  /* 画像があれば、画像の添付処理に渡す(差分⑩-1の関数を再利用) */
  if (images.length > 0) {
    await handleImageUpload(images);
    processed = true;
  }

  /* PDFがあれば、PDFの添付処理に渡す
     PDFは「1件だけ」しか添付できないため、最初の1件だけを処理する
     (handlePdfUpload は差分⑩-3で追加します) */
  if (pdfs.length > 0) {
    await handlePdfUpload(pdfs[0]);
    if (pdfs.length > 1) {
      showError(`PDFは1つだけ添付可能です。${pdfs.length - 1}個のPDFは無視されました。`);
    }
    processed = true;
  }

  /* 1件も処理できるファイルがなかった場合は、エラーを表示 */
  if (!processed) {
    showError("フォルダ内に処理可能なファイル(テキスト・画像・PDF)が見つかりませんでした。");
  }
}

/* ============================================================
   PDF処理(ステップ④で追加)
   ------------------------------------------------------------
   選択されたPDFファイルを、PDF.js(外部ライブラリ)で解析し、
   「テキストを抽出」します。
   テキストが抽出できた場合は「テキストで送る」、
   抽出できない場合(スキャン画像など)は「ページ画像で送る」
   という2つの方式を、自動で振り分けます。
   (完成形の PDF処理関数と同じ仕様)
============================================================ */

/* --- PDFファイルの添付処理 ---
   引数 file:選択されたPDFファイル(1ファイルのみ)
   「形式」と「サイズ」を検証し、問題なければPDF.jsで解析を開始します。 */
async function handlePdfUpload(file) {
  /* 形式検証:PDF以外のファイルは受け付けない */
  if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
    showError("PDF形式のファイルを選択してください。");
    return;
  }
  /* サイズ検証:上限を超えている場合は中止 */
  if (file.size > MAX_PDF_SIZE) {
    showError(`PDFサイズが上限を超えています(${formatBytes(file.size)})。上限は ${formatBytes(MAX_PDF_SIZE)} です。`);
    return;
  }
  /* PDF.jsが読み込めていない場合は中止(オフラインなど) */
  if (typeof pdfjsLib === "undefined") {
    showError("PDF.jsライブラリを読み込めませんでした。ネットワーク接続を確認してください。");
    return;
  }

  clearError();

  /* 添付PDFの情報を保持する「器」を初期化する
     これから解析を進め、結果(text・pageImagesなど)をここに入れていきます */
  attachedPdf = {
    name: file.name,
    size: file.size,
    pageRange: "",        // 処理対象のページ範囲(例:「先頭 10 / 25 ページ」)
    text: "",             // 抽出したテキスト(抽出できた場合)
    pageImages: [],       // ページ画像の配列(テキストが取れない場合)
    garbledFallback: false  // 文字化けによるフォールバックかどうか(差分⑩-3-2で使用)
  };
  /* 添付プレビューを「処理中…」の状態で先に表示する
     (renderAttachmentPreview は差分⑩-4で追加します) */
  renderAttachmentPreview();

  try {
    /* PDF.jsの「ワーカー」(解析処理を裏側で行う部品)の場所を設定する
       CDN版を読み込んでいるため、同じCDNのURLを指定します */
    pdfjsLib.GlobalWorkerOptions.workerSrc =
      "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";

    /* ファイルの中身を「バイト列」(ArrayBuffer)として読み込む */
    const arrayBuffer = await file.arrayBuffer();
    /* PDF.jsでPDF文書を解析する(解析が終わるまで待つ) */
    const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;

    /* 総ページ数を取得し、「処理対象のページ数」を決める
       上限(MAX_PDF_PAGES)を超えている場合は、先頭のページだけを処理する */
    const totalPages = pdf.numPages;
    if (totalPages > MAX_PDF_PAGES) {
      showError(`PDFのページ数が上限(${MAX_PDF_PAGES}ページ)を超えています(${totalPages}ページ)。先頭${MAX_PDF_PAGES}ページのみ処理します。`);
    }
    const targetPages = Math.min(totalPages, MAX_PDF_PAGES);
    attachedPdf.pageRange = `先頭 ${targetPages} / ${totalPages} ページ`;

    /* テキスト抽出を試行する
       抽出できたかどうかで、以下の2つに振り分けます。
       ① テキストが取れた → 「テキストで送る」
       ② テキストが取れない → 「ページ画像で送る」 */
    const extractedText = await extractPdfText(pdf, targetPages);

    if (extractedText && extractedText.trim().length > 20) {
      /* テキストが抽出できた場合:
         ただし「文字化け」していないかを検査します。
         文字化けと判定した場合は、テキスト抽出をあきらめて
         「ページ画像で送る」方式に切り替えます。 */
      if (isLikelyGarbled(extractedText)) {
        /* 文字化け検出:自動でページ画像へフォールバック
           garbledFallback フラグを true にしておくと、
           buildPdfContext や添付プレビューで
           「文字化けによるフォールバック」として扱われます */
        attachedPdf.garbledFallback = true;
        attachedPdf.text = "";
        attachedPdf.pageImages = await renderPdfPagesAsImages(pdf, targetPages);
      } else {
        /* 文字化けしていない場合:抽出したテキストを添付する */
        attachedPdf.text = extractedText;
      }
    } else {
      /* テキストが抽出できない場合(スキャン画像のPDFなど):
         ページを「画像」として描画(レンダリング)して添付する
         (renderPdfPagesAsImages は差分⑩-3-2で追加します) */
      attachedPdf.pageImages = await renderPdfPagesAsImages(pdf, targetPages);
    }
  } catch (e) {
    /* 解析に失敗した場合は、添付を取り消してエラーを表示 */
    showError("PDFの処理に失敗しました:" + sanitizeLog(e));
    attachedPdf = null;
  }
  /* 解析結果を反映して、添付プレビューを更新する */
  renderAttachmentPreview();
}

/* --- PDFの各ページから「テキスト」を抽出する ---
   引数 pdf:PDF.jsで解析したPDF文書
        targetPages:処理対象のページ数
   戻り値:抽出したテキスト(1つの文字列) */
async function extractPdfText(pdf, targetPages) {
  let text = "";
  /* 1ページずつ順番にテキストを抽出する */
  for (let i = 1; i <= targetPages; i++) {
    try {
      /* ページを取得し、その中の「テキストコンテンツ」を取り出す */
      const page = await pdf.getPage(i);
      const content = await page.getTextContent();
      /* テキストコンテンツ(items配列)の各要素(item.str)を、
         スペースでつなげて1つの文字列にする */
      const pageText = content.items.map(item => item.str).join(" ");
      /* ページ番号の区切りを付けて、テキストに追加する */
      text += `\n--- ${i}ページ ---\n${pageText}\n`;
    } catch (e) {
      /* そのページの抽出に失敗した場合は、失敗したことを記録して次へ進む */
      text += `\n--- ${i}ページ(抽出失敗) ---\n`;
    }
  }
  return text.trim();
}

/* --- PDFの各ページを「画像」として描画(レンダリング)する ---
   引数 pdf:PDF.jsで解析したPDF文書
        targetPages:処理対象のページ数
   戻り値:ページ画像(dataUrl)の配列
   スキャンPDF(画像だけのPDF)や、文字化けしたPDFに対応するため、
   ページを1枚ずつ「画像」に変換してAPIに送ります。
   (完成形の renderPdfPagesAsImages と同じ仕様) */
async function renderPdfPagesAsImages(pdf, targetPages) {
  const images = [];
  /* 描画の拡大率(1.5倍)
     大きくするほど文字が鮮明になるが、画像サイズも大きくなる */
  const scale = 1.5;

  /* 1ページずつ順番に画像化する */
  for (let i = 1; i <= targetPages; i++) {
    try {
      /* ページを取得し、その「表示サイズ」(viewport)を計算する */
      const page = await pdf.getPage(i);
      const viewport = page.getViewport({ scale });

      /* 描画用の「キャンバス」(canvas:絵を描くための領域)を作り、
         ページの表示サイズに合わせる */
      const canvas = document.createElement("canvas");
      canvas.width = viewport.width;
      canvas.height = viewport.height;
      const ctx = canvas.getContext("2d");

      /* ページをキャンバスに描画(レンダリング)する(終わるまで待つ) */
      await page.render({ canvasContext: ctx, viewport }).promise;

      /* 描画したキャンバスを「JPEG形式・品質80%」の dataUrl に変換して配列に追加 */
      images.push(canvas.toDataURL("image/jpeg", 0.8));
    } catch (e) {
      /* そのページの描画に失敗した場合はスキップ(全体は止めない) */
    }
  }
  return images;
}

/* --- 抽出したテキストが「文字化け」していないかを検査する ---
   引数 text:抽出したテキスト
   戻り値:文字化けの可能性が高い場合は true、そうでない場合は false

   【なぜこの検査が必要か】
   PDFによっては「フォントの文字コードの対応表(ToUnicode CMap)」が
   正しく埋め込まれていないケースがあり、PDF.jsでテキストを抽出すると
   「意味のない文字の羅列」(文字化け)になってしまいます。
   文字化けしたテキストをAIに送っても正しく理解してもらえないため、
   文字化けと判定した場合は「ページ画像で送る」方式に切り替えます。

   【判定の仕組み】
   「文字種の偏り」+「文字コードの範囲」+「既知の文字化けパターン」の
   3つの観点から、6つの判定ロジックで多角的に検査します。
   (完成形の isLikelyGarbled と同じ仕様) */
function isLikelyGarbled(text) {
  if (!text) return false;

  /* 先頭の2000文字を「サンプル」として検査する(全部は重いため) */
  const sample = text.slice(0, 2000);
  if (sample.length < 20) return false;

  /* --- 判定1:日本語文字の比率 ---
     ひらがな・カタカナ(〜ゞ)・漢字(一〜鿿)の割合を計算する */
  const japaneseChars = sample.match(/[぀-ヿ一-鿿]/g) || [];
  const japaneseRatio = japaneseChars.length / sample.length;

  /* --- 判定2:異常な文字(私用領域・ゼロ幅スペースなど)の比率 ---
     文字化け時に頻出する範囲(〜の私用領域、​〜‏の
     ゼロ幅スペース、〜￿の特殊文字)の割合を計算する */
  const suspiciousChars = sample.match(/[-​-‏-￿]/g) || [];
  const suspiciousRatio = suspiciousChars.length / sample.length;

  /* --- 判定3:ASCII文字(ラテン文字・数字・句読点)の比率 ---
     日本語文書なのにASCII文字( ~)ばかりの場合は、文字化けの可能性が高い */
  const asciiChars = sample.match(/[ -~]/g) || [];
  const asciiRatio = asciiChars.length / sample.length;

  /* --- 判定4:同一文字の異常な連続 ---
     文字化け時に「,」「.」「0」「e」などが大量に連続するケースを検出する */
  const repeatedPattern = /(.)\1{9,}/;   // 同一文字が10回以上連続
  const hasRepeatedPattern = repeatedPattern.test(sample);

  /* --- 判定5:意味不明なASCII文字列の連続 ---
     文字化けPDFで頻出する「意味のないASCII文字の長い連続」を検出する */
  const meaninglessAscii = sample.match(/[a-zA-Z0-9]{30,}/g) || [];
  const hasMeaninglessAscii = meaninglessAscii.length > 2;

  /* --- 判定6:日本語文書に通常現れない文字(異国文字)の混入比率 ---
     日本語文書に現れない文字範囲(က〜ჿ、᐀〜ᙿ、ก〜๿、က〜႟など)の
     割合を計算する */
  const unusualChars = sample.match(/[က-ჿ᐀-ᙿก-๿က-႟]/g) || [];
  const unusualRatio = unusualChars.length / sample.length;

  /* 日本語文書に異国文字が1%以上混入している場合は、文字化けと判定 */
  if (unusualRatio > 0.01) {
    return true;
  }

  /* --- 総合判定 ---
     ページ区切り文字列(「ページ」)があるのに日本語がほぼない、
     などの条件を組み合わせて、文字化けの可能性を判定する */
  const hasJapanesePageMarker = /ページ/.test(sample);

  /* 日本語PDFなのに日本語がほぼなく(2%未満)、ASCII文字ばかり(60%超)の場合 */
  if (hasJapanesePageMarker && japaneseRatio < 0.02 && asciiRatio > 0.6) {
    return true;
  }
  /* 私用領域・異常文字が5%超の場合 */
  if (suspiciousRatio > 0.05) {
    return true;
  }
  /* 日本語文書なのに意味不明なASCII文字列が多く、日本語がほぼない場合 */
  if (hasJapanesePageMarker && japaneseRatio < 0.05 && hasMeaninglessAscii) {
    return true;
  }
  /* 異常な文字繰り返しがあり、日本語がほぼない場合 */
  if (hasRepeatedPattern && japaneseRatio < 0.05) {
    return true;
  }

  return false;
}

/* --- PDFの内容を「質問文に添える形」(コンテキスト)に整える ---
   引数 pdf:添付PDFの情報(attachedPdf と同じ構造)
   戻り値:APIに送る際の「前置き文」を含むコンテキスト文字列

   PDFの「状態」に応じて、以下の3つに振り分けます。
   ① 文字化けによるフォールバック(garbledFallback が true)
      → 「文字化けのため、ページ画像として添付しています」と伝える
   ② テキストが抽出できた(text がある)
      → 「以下はPDFから抽出したテキストです」と前置きして、
        抽出したテキストを添える(長すぎる場合は省略)
   ③ テキストが取れなかった(スキャンPDFなど)
      → 「テキスト抽出できなかったため、ページ画像として添付しています」と伝える
   (完成形の buildPdfContext と同じ仕様) */
function buildPdfContext(pdf) {
  let ctx = `【添付PDF: ${pdf.name}(${pdf.pageRange})】\n`;

  if (pdf.garbledFallback) {
    /* ① 文字化け検出による自動フォールバックの場合:
       「なぜページ画像になったのか」を、AIに正直に伝えます */
    ctx += "このPDFはPDF内部のフォント埋め込みやエンコーディングが対応しておらず、" +
           "テキスト抽出で文字化けが発生したため、ページ画像として添付しています。" +
           "画像の内容を確認してください。";
  } else if (pdf.text) {
    /* ② テキストが抽出できた場合:
       ただし、長すぎるテキストはトークン数(API料金)が跳ね上がるため、
       上限を設けて省略します */
    const MAX_PDF_CHARS = 20000;   // トークン肥大防止のため上限を設ける
    const body = pdf.text.length > MAX_PDF_CHARS
      ? pdf.text.slice(0, MAX_PDF_CHARS) + "\n…(以降省略)"
      : pdf.text;
    ctx += `以下はPDFから抽出したテキストです。\n${body}`;
  } else {
    /* ③ テキストが取れなかった場合(スキャンPDFなど):
       ページ画像として添付していることを伝えます */
    ctx += "このPDFはテキスト抽出できなかったため、ページ画像として添付しています。画像の内容を確認してください。";
  }
  return ctx;
}

/* ============================================================
   添付プレビュー描画(ステップ④で追加)
   ------------------------------------------------------------
   添付したファイル(画像・PDF・テキスト)の一覧を、
   添付プレビューエリアに描画します。
   「データが変わるたびに、一度空にしてから最新の状態で作り直す」
   という、画面描画の基本パターン(【8】と同じ)で実装しています。
   (完成形の renderAttachmentPreview と同じ仕様)
============================================================ */
function renderAttachmentPreview() {
  /* 一度中身を空にする(追加し続けると、一覧が重複してしまうため) */
  attachmentPreviewArea.innerHTML = "";

  /* --- 画像のプレビュー(サムネイル付き) ---
     attachedImages の1件ごとに「チップ」を作り、
     画像のサムネイル・ファイル名・サイズ・削除ボタンを表示します。 */
  attachedImages.forEach((img, i) => {
    const chip = document.createElement("div");
    chip.className = "attach-chip";

    /* 画像のサムネイル(dataUrl をそのまま src に指定するだけで表示できる) */
    const thumb = document.createElement("img");
    thumb.src = img.dataUrl;
    thumb.alt = img.name;

    /* ファイル名とサイズ(formatBytes で読みやすい形式に変換) */
    const label = document.createElement("span");
    label.textContent = `${img.name} (${formatBytes(img.size)})`;

    /* 削除ボタン(✕):押すと removeAttachedImage で添付から削除する */
    const remove = document.createElement("button");
    remove.className = "attach-remove";
    remove.textContent = "✕";
    remove.title = "削除";
    remove.addEventListener("click", () => removeAttachedImage(i));

    chip.appendChild(thumb);
    chip.appendChild(label);
    chip.appendChild(remove);
    attachmentPreviewArea.appendChild(chip);
  });

  /* --- PDFのプレビュー ---
     添付PDFの「処理状態」(処理中…/テキスト抽出済み/ページ画像化済み/
     文字化けフォールバック)を、完成形と同じ表記で表示します。 */
  if (attachedPdf) {
    const chip = document.createElement("div");
    chip.className = "attach-chip";
    const label = document.createElement("span");

    /* PDFの「状態」に応じて、表示するステータスを振り分ける */
    let status;
    if (attachedPdf.garbledFallback) {
      /* 文字化け検出による自動フォールバックの場合:
         「API利用料金が増加する」という警告も、完成形と同じ表記で表示します */
      status = "⚠ 文字化けの可能性 → ページ画像で送信(API利用料金が増加しますので注意してください)";
    } else if (attachedPdf.text) {
      status = "テキスト抽出済み";
    } else if (attachedPdf.pageImages && attachedPdf.pageImages.length > 0) {
      status = "ページ画像化済み";
    } else {
      /* 解析がまだ終わっていない場合(処理中) */
      status = "処理中…";
    }
    label.textContent = `📄 ${attachedPdf.name} (${formatBytes(attachedPdf.size)}) ${attachedPdf.pageRange} / ${status}`;

    /* 削除ボタン(✕):押すと removeAttachedPdf で添付から削除する */
    const remove = document.createElement("button");
    remove.className = "attach-remove";
    remove.textContent = "✕";
    remove.title = "削除";
    remove.addEventListener("click", removeAttachedPdf);

    chip.appendChild(label);
    chip.appendChild(remove);
    attachmentPreviewArea.appendChild(chip);
  }

  /* --- テキストファイルのプレビュー ---
     attachedTextFiles の1件ごとに「チップ」を作り、
     ファイル名(またはパス)・サイズ・削除ボタンを表示します。 */
  attachedTextFiles.forEach((file, i) => {
    const chip = document.createElement("div");
    chip.className = "attach-chip";
    const label = document.createElement("span");

    /* フォルダから選択した場合は「フォルダ名/ファイル名」のパスを表示し、
       ホバーでフルパスが見えるようにします */
    const displayPath = file.path !== file.name ? file.path : file.name;
    label.textContent = `📝 ${displayPath} (${formatBytes(file.size)})`;
    label.title = displayPath;   // ホバーでフルパス表示

    /* 削除ボタン(✕):押すと removeAttachedTextFile で添付から削除する */
    const remove = document.createElement("button");
    remove.className = "attach-remove";
    remove.textContent = "✕";
    remove.title = "削除";
    remove.addEventListener("click", () => removeAttachedTextFile(i));

    chip.appendChild(label);
    chip.appendChild(remove);
    attachmentPreviewArea.appendChild(chip);
  });
}

/* --- 添付PDFの削除 ---
   添付プレビューの「✕」ボタンが押された時に呼ばれ、
   添付PDF(attachedPdf)を取り消します。
   PDFは「1件だけ」しか添付できないため、null に戻すだけでOKです。 */
function removeAttachedPdf() {
  attachedPdf = null;          // 添付PDFを取り消す
  renderAttachmentPreview();   // 添付プレビューを更新
}

/* --- 添付テキストファイルの削除 ---
   添付プレビューの「✕」ボタンが押された時に呼ばれ、
   指定した位置(index)のテキストファイルを添付リストから削除します。
   (removeAttachedImage と同じ、splice による指定位置の削除です) */
function removeAttachedTextFile(index) {
  attachedTextFiles.splice(index, 1);   // 指定した位置の1件を削除
  renderAttachmentPreview();            // 添付プレビューを更新
}

/* --- すべての添付ファイルをクリアする ---
   送信後に呼ばれ、すべての添付ファイルをクリアします。
   あわせて、「隠しファイル入力」の値もクリアしておくことで、
   同じファイルを続けて選択できるようにします。
   (完成形の clearAttachments と同じ仕様) */
function clearAttachments() {
  attachedImages = [];
  attachedPdf = null;
  attachedTextFiles = [];
  renderAttachmentPreview();

  /* 「隠しファイル入力」の値もクリアする
     これをしないと、同じファイルを続けて選択した時に
     change イベントが発火せず、添付できなくなってしまいます */
  imageFileInput.value = "";
  pdfFileInput.value = "";
  textFileInput.value = "";
  folderFileInput.value = "";
}

/* ============================================================
   マルチモーダルメッセージの構築(ステップ④で追加)
   ------------------------------------------------------------
   テキストと画像を組み合わせて、APIに送るデータを構築します。
   「マルチモーダル(multimodal)」とは、
   「複数の形式(モード)のデータを組み合わせる」という意味です。
   差分⑪で拡張する sendMessage の中から呼ばれます。
   (完成形の buildMultimodalMessage と同じ仕様)
============================================================ */
function buildMultimodalMessage(text) {
  const parts = [];
  let fullText = text;

  /* ----- ① テキストファイルの内容を「質問文の前」に添える -----
     添付テキストファイルがある場合、ファイル名と中身を
     質問文の前に添えて、AIに「どのファイルの話か」を伝えます。 */
  if (attachedTextFiles.length > 0) {
    const fileContexts = attachedTextFiles.map(f => {
      const header = f.path !== f.name
        ? `【ファイル: ${f.path}】`
        : `【ファイル: ${f.name}】`;
      return `${header}\n${f.content}\n`;
    }).join('\n---\n\n');

    fullText = `【添付テキストファイル ${attachedTextFiles.length}件】\n${fileContexts}\n\n【ユーザーの質問】\n${text}`;
  }

  /* ----- ② PDFのコンテキストを「質問文の前」に添える -----
     添付PDFがある場合、buildPdfContext(差分⑩-3-3)で組み立てた
     「前置き文+抽出テキスト(または画像の案内)」を添えます。 */
  if (attachedPdf) {
    fullText = buildPdfContext(attachedPdf) + "\n\n" + fullText;
  }

  /* ----- ③ 「画像データがあるか」を判定する -----
     画像(attachedImages)またはPDFのページ画像(attachedPdf.pageImages)
     がある場合は、マルチモーダル形式(配列)で送る必要があります。 */
  const hasMedia = attachedImages.length > 0 || (attachedPdf && attachedPdf.pageImages && attachedPdf.pageImages.length > 0);

  /* 画像データがない場合は、これまで通り「テキストのみ」の形で送る */
  if (!hasMedia) {
    return { role: "user", content: fullText };
  }

  /* ----- ④ 画像データがある場合は「マルチモーダル形式」で送る -----
     content を配列にして、各要素を
     { type: "text", text: ... } や { type: "image_url", image_url: { url: ... } }
     の形にするのが、マルチモーダル形式のルールです。 */
  parts.push({ type: "text", text: fullText });

  /* 添付画像を配列に追加する */
  attachedImages.forEach(img => {
    parts.push({
      type: "image_url",
      image_url: { url: img.dataUrl }
    });
  });

  /* PDFのページ画像を配列に追加する(テキスト抽出不可時・文字化け時) */
  if (attachedPdf && attachedPdf.pageImages) {
    attachedPdf.pageImages.forEach(dataUrl => {
      parts.push({
        type: "image_url",
        image_url: { url: dataUrl }
      });
    });
  }

  return { role: "user", content: parts };
}

/* ============================================================
   【16】メッセージ送信(このアプリの主役となる処理)
   ------------------------------------------------------------
   送信ボタンが押されてから、AIの回答が表示されるまでの
   全体の流れを管理します。
   ※ステップ④で「添付ファイル対応」に拡張し、
     テキストに加えて、添付した画像・PDF・テキストファイルも
     送信できるようになりました。
============================================================ */
async function sendMessage() {
  if (isGenerating) return;   // 生成中の二重送信を防ぐ
  clearError();

  /* ----- ① 送信前のチェック(バリデーション) -----
     「質問文が空」でも、「添付ファイルがある」場合は送信を許可します。
     その場合は、既定の質問文(例:「添付ファイルの内容を説明してください。」)
     を自動で使用します。 */
  const text = messageInput.value.trim();

  /* 質問文も添付ファイルも両方ない場合のみ、送信を中止する */
  if (!text && attachedImages.length === 0 && !attachedPdf && attachedTextFiles.length === 0) {
    showError("メッセージを入力してください。");
    return;
  }

  /* APIキーは入力欄からその都度読み取る(保存はしない) */
  apiKey = apiKeyInput.value.trim();
  if (!apiKey) {
    showError("APIキーが未入力です。設定パネルからRunpod APIキーを入力してください。");
    settingsPanel.classList.remove("hidden");
    return;
  }

  /* 質問文が空の場合は、「何が添付されているか」に応じた既定文を使う */
  const userText = text || (
    attachedTextFiles.length > 0 ? "添付ファイルの内容を説明してください。"
    : attachedImages.length > 0 ? "添付ファイルを説明してください。"
    : "添付PDFの内容を説明してください。"
  );

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

  /* ----- ② 添付のメタデータ(attachmentsMeta)を、会話履歴に保存する -----
     「どのファイルを添付したか」の情報(ファイル名・形式・サイズ)を、
     会話履歴に残します。
     ※添付の「中身」(画像データやPDFのテキスト)は保存しません。
       これは、localStorage の容量を節約するためと、
       「添付ファイルの内容は、送信したその時だけ参照される」という
       仕様(【17】の解説を参照)に基づいています。 */
  const attachmentsMeta = [];
  attachedImages.forEach(img => attachmentsMeta.push({ name: img.name, type: img.type, size: img.size }));
  if (attachedPdf) attachmentsMeta.push({ name: attachedPdf.name, type: "application/pdf", size: attachedPdf.size, pages: attachedPdf.pageRange });
  attachedTextFiles.forEach(f => attachmentsMeta.push({ name: f.path, type: "text/plain", size: f.size }));

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

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

  /* ----- ④ API送信用メッセージを「buildMultimodalMessage」で構築する -----
     差分⑩-4で追加した buildMultimodalMessage を呼んで、
     テキストと画像を組み合わせた「マルチモーダル形式」のデータを作ります。
     添付がない場合は、これまで通り「テキストのみ」の形になります。 */
  const apiUserMessage = buildMultimodalMessage(userText);
  /* 今回の送信用ペイロードを、会話データに一時的に保持する
     (buildChatCompletionRequest で、会話履歴の末尾をこれに差し替えるために使う。
       これは保存しない一時的なデータです) */
  conv.apiPayloadMessage = apiUserMessage;

  saveConversations();
  renderActiveConversation();

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

  /* ----- ⑤ 送信後に、すべての添付ファイルをクリアする -----
     差分⑩-4で追加した clearAttachments を呼んで、
     すべての添付ファイル(画像・PDF・テキスト)をクリアします。
     あわせて、「隠しファイル入力」の値もクリアされるため、
     同じファイルを続けて選択できるようになります。 */
  clearAttachments();

  /* ----- ⑥ 生成開始の準備 ----- */
  isGenerating = true;
  requestWakeLock();             // 生成中はスリープを抑制(対応ブラウザのみ)
  responseStartTime = performance.now();   // 応答計測の開始
  hasRetriedNonStream = false;
  fullResponseText = "";
  renderScheduled = false;
  abortController = new AbortController(); // 通信を途中で止める「中止装置」
  updateUiState();                         // 送信→停止ボタンに切り替え

  /* 処理状況パネルを作成 */
  currentProgressState = { logs: [], lastStatus: "" };
  currentProgressPanel = createProgressPanel();
  showProgressStatus("リクエストを準備しています…");

  /* ----- ⑦ AIの回答用の「空の器」を先に表示する ----- */
  const streamWrap = document.createElement("div");
  streamWrap.className = "message assistant";
  streamWrap.innerHTML = `
    <div class="msg-avatar">K</div>
    <div class="msg-body">
      <div class="msg-role">${escapeHtml(getModelDisplayName())}</div>
      <div class="msg-content markdown-body"></div>
    </div>`;
  chatContainer.appendChild(streamWrap);
  currentStreamEl = streamWrap.querySelector(".msg-content");

  /* 思考モードが有効なら思考過程パネルを作成 */
  fullReasoningText = "";
  if (isThinkingEnabled()) {
    currentReasoningEl = createReasoningPanel(streamWrap);
  } else {
    currentReasoningEl = null;
  }
  scrollToBottom(true);

  /* ----- ⑧ API通信(ストリーミング)を実行 -----
     引数を「apiUserMessage」に変更しました。
     これにより、添付した画像やPDFのページ画像も、APIに送られます。 */
  try {
    await streamChatCompletion(conv, apiUserMessage, true);
  } catch (err) {
    if (err && err.name === "AbortError") {
      if (isGenerating) {
        /* タイムアウト等による中断:受信済みの内容を保持して確定 */
        fullResponseText += "\n\n---\n*(タイムアウトまたは接続が中断されました)*";
        showProgressStatus("接続が中断されました");
        finishGeneration(true);
      }
      return;
    }

    /* ストリーミング失敗時:通常モード(stream:false)で一度だけ再試行 */
    console.error("ストリーミングエラー:", sanitizeLog(err));
    if (!hasRetriedNonStream) {
      hasRetriedNonStream = true;
      showProgressStatus("ストリーミングに失敗したため、通常モードで再試行しています…");
      try {
        await streamChatCompletion(conv, apiUserMessage, false);
        return;
      } catch (err2) {
        handleRequestError(err2);
        finishGeneration(false);
        return;
      }
    }
    handleRequestError(err);
    finishGeneration(false);
  }
}

/* --- エラーオブジェクトの安全なログ出力(APIキーを含めない) --- */
function sanitizeLog(err) {
  try {
    const msg = String(err && err.message ? err.message : err)
      .replace(/Bearer\s+\S+/gi, "Bearer [非表示]");
    return msg;
  } catch (e) { return "unknown error"; }
}

/* --- エラーを画面に表示し、処理状況パネルも更新する --- */
function handleRequestError(err) {
  const msg = String(err && err.message ? err.message : "不明なエラー");
  showError(msg);
  showProgressStatus("エラーが発生しました");
}

/* --- 生成の終了処理(成功・失敗・停止のすべてで実行) --- */
function finishGeneration(success) {
  releaseWakeLock();         // スリープ抑制を解除
  isGenerating = false;
  abortController = null;
  updateUiState();           // 停止→送信ボタンに戻す

  /* 応答計測を確定 */
  if (responseStartTime > 0) {
    lastResponseTime = performance.now() - responseStartTime;
    lastReasoningLength = fullReasoningText.length;
    lastContentLength = fullResponseText.length;
  }

  /* 受信した回答を会話データに保存
     ※attachmentsMeta は「どのファイルを添付したか」の情報です。
       ユーザーの質問メッセージと同じデータ構造に揃えるため、
       AIの回答にも「空の配列」を入れています。
     ※apiPayloadMessage は差分⑪で設定した「API送信用の一時データ」です。
       回答が確定した今、役目を終えたため消去します(後片付け)。
       localStorage には保存されない設計のためリロードでも消えますが、
       メモリ上にも残さないよう、確実に消しておきます。 */
  const conv = getActiveConversation();
  if (conv && fullResponseText) {
    conv.messages.push({ role: "assistant", content: fullResponseText, attachmentsMeta: [] });
    delete conv.apiPayloadMessage;
    saveConversations();
  }

  /* 応答計測の結果を、回答の最後に追加(完成形と同じ形式) */
  if (responseStartTime > 0 && conv && conv.messages.length > 0) {
    const lastMsg = conv.messages[conv.messages.length - 1];
    if (lastMsg.role === "assistant") {
      const modelName = getModelDisplayName();
      const thinkingStatus = isThinkingEnabled() ? "思考ON" : "思考OFF";
      const timeSec = (lastResponseTime / 1000).toFixed(1);
      lastMsg.metricsText =
        `${modelName} / ${thinkingStatus} / 応答時間: ${timeSec}秒 / 思考過程: ${lastReasoningLength}文字 / 本文: ${lastContentLength}文字(マークダウン記号も含む)`;
      saveConversations();
    }
  }

  /* ストリーミング用の「器」を、確定した回答で描画し直す */
  currentStreamEl = null;
  if (conv) renderActiveConversation();

  if (success) showProgressStatus("完了しました");
  responseStartTime = 0;   // 計測開始時刻をリセット
}

/* ============================================================
   【17】リクエスト構築(APIに送るデータを組み立てる)
   ------------------------------------------------------------
   先頭にシステムプロンプトを付け、会話履歴を時系列で並べ、
   モデルやパラメータを指定します。
   ※ステップ②で「設定パネルの値を読む形」に拡張し、
     kimi-k2.7-code・kimi-k2.6 の分岐を追加しました。
============================================================ */
function buildChatCompletionRequest(conv, userText, useStream) {
  const systemPrompt = getCurrentSystemPrompt();
  const messages = [{ role: "system", content: systemPrompt }];

  /* これまでの会話を時系列で追加 */
  conv.messages.forEach(m => {
    messages.push({ role: m.role, content: m.content });
  });

  /* 会話履歴の末尾(今回の質問)を、apiPayloadMessage に差し替える
     画面上に表示する質問文(userText)と、実際にAPIに送る質問文
     (apiUserMessage:ファイルの中身を含んだマルチモーダル形式)が
     異なる場合があるため、末尾を差し替えます。 */
  if (conv.apiPayloadMessage) {
    messages.pop();                        // 末尾(userText)を取り除く
    messages.push(conv.apiPayloadMessage); // apiPayloadMessage を末尾に追加
  }

  const model = getSelectedModel();

  const request = {
    model: model,                                // モデル名(設定パネルの値)
    messages: messages,                          // 会話履歴
    temperature: 1,
    max_tokens: parseInt(maxTokensInput.value, 10) || 8192,   // 設定パネルの値
    stream: useStream                            // ストリーミングON/OFF
  };

  /* Kimi K3:常時思考モデル+reasoning_effort(設定パネルの値)を送信 */
  if (model === "kimi-k3") {
    request.reasoning_effort = normalizeReasoningEffort(reasoningEffortSelect.value);
  }

  /* Kimi K2.7 Code:思考モード固定ON(reasoning_effortは送信しない) */
  if (model === "kimi-k2.7-code") {
    request.thinking = { type: "enabled", keep: "all" };
  }

  /* Kimi K2.6:思考モードON/OFF(設定パネルの値に応じて切り替え) */
  // ※ OFF の場合は thinking パラメータ自体を送信しない。
  //    { type: "disabled" } を明示送信すると Runpod 側で
  //    500エラー/空ストリームが返るため、省略して
  //    APIのデフォルト(非思考)動作を使う。
  if (model === "kimi-k2.6" && thinkingModeSelect.value === "enabled") {
    request.thinking = { type: "enabled" };
  }

  return request;
}

/* ============================================================
   【18】SSEストリーミング処理(API通信の中核)
   ------------------------------------------------------------
   「回答が少しずつ届く」方式(SSE:Server-Sent Events)で
   RunpodのAPIと通信します。
============================================================ */
async function streamChatCompletion(conv, userText, useStream) {
  showProgressStatus("AIが質問を分析しています…");
  let lastFinishReason = null;   // "stop" / "length" / null

  const requestBody = buildChatCompletionRequest(conv, userText, useStream);

  /* タイムアウト用タイマー(設定秒数経過したら通信を中止) */
  const timeoutId = setTimeout(() => {
    if (abortController) abortController.abort("timeout");
  }, REQUEST_TIMEOUT_MS);

  let response;
  try {
    response = await fetch(API_ENDPOINT, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": "Bearer " + apiKey   // APIキーによる利用者の証明
      },
      body: JSON.stringify(requestBody),
      signal: abortController.signal          // 中止装置を紐付け
    });
  } catch (e) {
    clearTimeout(timeoutId);
    if (e.name === "AbortError") throw e;
    throw new Error("ネットワークエラー、またはCORSエラーが発生しました。接続先とブラウザの設定を確認してください。");
  }

  /* サーバーからの「お返事の状態」を確認 */
  if (!response.ok) {
    clearTimeout(timeoutId);
    let detail = "";
    try {
      const errJson = await response.json();
      detail = errJson.error && errJson.error.message ? errJson.error.message : "";
    } catch (e) { /* JSONでない場合は無視 */ }
    const safeDetail = String(detail).replace(/Bearer\s+\S+/gi, "[非表示]");
    if (response.status === 401 || response.status === 403) {
      throw new Error("認証エラー(HTTP " + response.status + "):APIキーが不正です。" + (safeDetail ? " " + safeDetail : ""));
    } else if (response.status >= 500) {
      throw new Error("サーバーエラー(HTTP " + response.status + "):しばらく待って再試行してください。" + (safeDetail ? " " + safeDetail : ""));
    } else {
      throw new Error("APIエラー(HTTP " + response.status + "):" + (safeDetail || "リクエストが拒否されました。"));
    }
  }

  /* 回答の受け取り(ストリーミング or 通常) */
  if (useStream) {
    lastFinishReason = await readSseStream(response);
  } else {
    await readNonStreamResponse(response);
  }
  clearTimeout(timeoutId);

  /* 回答が1文字も来なかった場合のガード */
  if (!fullResponseText.trim()) {
    if (lastFinishReason === "length") {
      throw new Error(
        "回答本文が生成される前に max_tokens(現在の設定: " +
        (parseInt(maxTokensInput.value, 10) || 8192) +
        ")の上限に達しました。思考過程でトークンを使い切った可能性があります。設定パネルで max_tokens を大きくしてください。"
      );
    }
    throw new Error("空の回答が返されました。モデル名やmax_tokensの設定を確認してください。");
  }

  /* 最終レンダリング(Markdown解析・色付け) */
  showProgressStatus("回答を整形しています…");
  finalizeAssistantResponse(fullResponseText);
  showProgressStatus("コードをハイライトしています…");
  finishGeneration(true);
}

/* --- ストリーミング(SSE)で回答を少しずつ受け取る --- */
async function readSseStream(response) {
  if (!response.body) {
    throw new Error("レスポンスボディを読み取れません(ストリーム非対応)。");
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder("utf-8");   // バイト列 → 文字列
  let buffer = "";   // 受信途中のデータを一時的にためる場所
  let firstChunkReceived = false;
  let codeFenceDetected = false;
  let lastFinishReason = null;

  /* SSEの1行を解析して反映する共通処理 */
  const processLine = (line) => {
    const trimmed = line.trim();
    if (!trimmed || !trimmed.startsWith("data:")) return;

    const dataStr = trimmed.slice(5).trim();   // 「data:」の後ろを取り出す
    /* 終了の合図「[DONE]」(配列の文字コードで組み立てて誤検出を防ぐ) */
    const doneToken = String.fromCharCode(91, 68, 79, 78, 69, 93);
    if (dataStr === doneToken) return;

    let json;
    try {
      json = JSON.parse(dataStr);   // JSON文字列 → データに変換
    } catch (e) {
      return;   // 壊れたチャンクはスキップ
    }

    if (json.error) {
      const msg = String(json.error.message || "不明なエラー")
        .replace(/Bearer\s+\S+/gi, "[非表示]");
      throw new Error("APIエラー:" + msg);
    }

    /* choices[0].delta に「今回届いたかけら」が入っている */
    const choice = json.choices && json.choices[0];
    if (choice && typeof choice.finish_reason === "string") {
      lastFinishReason = choice.finish_reason;   // 終了理由を記録
    }

    if (choice && choice.delta) {
      const delta = choice.delta;
      /* 思考過程(reasoning_content)を蓄積して表示を更新 */
      if (typeof delta.reasoning_content === "string") {
        fullReasoningText += delta.reasoning_content;
        updateReasoningPanel();
      }
      /* 回答本文(content)を蓄積 */
      if (typeof delta.content === "string") {
        if (!firstChunkReceived) {
          firstChunkReceived = true;
          showProgressStatus("回答を受信しています…");
        }
        if (!codeFenceDetected && (fullResponseText + delta.content).includes("```")) {
          codeFenceDetected = true;
          showProgressStatus("コード例を生成しています…");
        }
        handleStreamEvent(delta.content);
      }
    }
  };

  try {
    /* 通信が終わるまで、読み取りを繰り返す */
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;   // 全部読み終えたらループを抜ける

      buffer += decoder.decode(value, { stream: true });

      /* 改行で区切って1行ずつ処理。
         最後の行は途中で切れている可能性があるので、
         pop() で取り出して次回のバッファに残す。 */
      const lines = buffer.split("\n");
      buffer = lines.pop() || "";

      for (const line of lines) {
        processLine(line);
      }
    }
    /* ストリーム終了時に残ったバッファを処理 */
    if (buffer.trim()) processLine(buffer);
  } catch (e) {
    if (e.name === "AbortError") throw e;
    throw new Error("ストリーム読み取りエラー:" + sanitizeLog(e));
  } finally {
    try { reader.releaseLock(); } catch (e) { /* 無視 */ }
  }

  return lastFinishReason;
}

/* --- 通常モード(非ストリーミング)で回答を一括で受け取る --- */
async function readNonStreamResponse(response) {
  let json;
  try {
    json = await response.json();
  } catch (e) {
    throw new Error("APIレスポンス形式エラー:JSONとして解析できませんでした。");
  }
  const parts = extractPartsFromJson(json);
  const finishReason = json && json.choices && json.choices[0]
    ? json.choices[0].finish_reason
    : null;

  /* 非ストリーミングでも思考過程が返る場合に対応 */
  if (parts.reasoning) {
    fullReasoningText += parts.reasoning;
    updateReasoningPanel();
  }

  if (!parts.content) {
    if (finishReason === "length") {
      throw new Error(
        "回答本文が生成される前に max_tokens(現在の設定: " +
        (parseInt(maxTokensInput.value, 10) || 8192) +
        ")の上限に達しました。思考過程でトークンを使い切った可能性があります。設定パネルで max_tokens を大きくしてください。"
      );
    }
    throw new Error("APIレスポンス形式エラー:回答本文が見つかりませんでした。");
  }
  showProgressStatus("回答を受信しています…");
  handleStreamEvent(parts.content);
}

/* --- APIの応答データから「本文」と「思考過程」を取り出す --- */
function extractPartsFromJson(json) {
  let content = "";
  let reasoning = "";
  if (!json) return { content, reasoning };
  const choice = json.choices && json.choices[0];
  if (choice) {
    const src = choice.delta || choice.message || {};
    if (typeof src.content === "string") content = src.content;
    if (typeof src.reasoning_content === "string") reasoning = src.reasoning_content;
  }
  if (typeof json.content === "string") content = json.content;
  return { content, reasoning };
}

/* --- 届いた回答のかけらを受け取って画面更新を予約する --- */
function handleStreamEvent(text) {
  if (!text) return;
  appendStreamText(text);
}

/* ============================================================
   【19】画面更新の最適化(描画ループ)
   ------------------------------------------------------------
   回答のかけらが届くたびに画面を書き換えるとPCが重くなるため、
   「設定した間隔(80ms)ごとにまとめて描画」します。
   これが設定項目「画面更新間隔 (ms)」の正体です。
============================================================ */
function appendStreamText(text) {
  fullResponseText += text;
  schedulePartialRender();
}

function schedulePartialRender() {
  if (renderScheduled) return;   // 予約済みなら二重に予約しない
  renderScheduled = true;

  setTimeout(() => {
    renderPartialResponse(fullResponseText);
    renderScheduled = false;
  }, getRenderInterval());
}

/* --- ストリーミング中はプレーンテキストで軽量に表示する --- */
function renderPartialResponse(text) {
  if (!currentStreamEl) return;
  currentStreamEl.textContent = text;

  /* ストリーミング中であることが分かるカーソル(▍)を付ける */
  const cursor = document.createElement("span");
  cursor.className = "stream-cursor";
  currentStreamEl.appendChild(cursor);

  scrollToBottom();
}

/* ============================================================
   【20】最終レンダリング(回答が全部届いたあとの仕上げ)
   ------------------------------------------------------------
   ①Markdown解析 → ②安全化 → ③コピーボタン → ④色付け、の順。
============================================================ */
function finalizeAssistantResponse(fullText) {
  if (!currentStreamEl) return;
  currentStreamEl.innerHTML = renderMarkdown(fullText);
  addCopyButtons(currentStreamEl);
  applySyntaxHighlight(currentStreamEl);
  scrollToBottom(true);
}

/* ============================================================
   【21】Wake Lock API(生成中のスリープ抑制)
   ------------------------------------------------------------
   生成中に画面がスリープしないよう抑制します。
   対応ブラウザ(Chrome / Edge / Safari 16.4以降)のみ動作し、
   HTTPS環境でのみ有効です。非対応でも生成は継続します。
============================================================ */
let wakeLock = null;

async function requestWakeLock() {
  if (!("wakeLock" in navigator)) return;   // 非対応ブラウザは何もしない
  try {
    wakeLock = await navigator.wakeLock.request("screen");
    wakeLock.addEventListener("release", () => { wakeLock = null; });
  } catch (e) { /* 取得失敗(低バッテリー等)は無視して生成は継続 */ }
}

async function releaseWakeLock() {
  if (wakeLock) {
    try { await wakeLock.release(); } catch (e) { /* 無視 */ }
    wakeLock = null;
  }
}

/* ============================================================
   【22】生成停止(停止ボタン)
============================================================ */
function cancelCurrentRequest() {
  if (!isGenerating) return;
  if (abortController) {
    try { abortController.abort(); } catch (e) { /* 無視 */ }
  }
  fullResponseText += "\n\n---\n*(生成を停止しました)*";
  showProgressStatus("生成を停止しました");
  finishGeneration(true);   // 受信済みの内容を保持して確定
}

/* ============================================================
   【23】回答の再生成(最後の回答を作り直す)
============================================================ */
function regenerateResponse(assistantMsg) {
  if (isGenerating) return;
  const conv = getActiveConversation();
  if (!conv) return;

  const idx = conv.messages.indexOf(assistantMsg);
  if (idx < 1) return;

  /* 直前のユーザーメッセージを探す */
  let userMsg = null;
  for (let i = idx - 1; i >= 0; i--) {
    if (conv.messages[i].role === "user") { userMsg = conv.messages[i]; break; }
  }
  if (!userMsg) return;

  /* 当該回答以降と、その質問を取り除いて再送信する */
  conv.messages = conv.messages.slice(0, idx);
  conv.messages = conv.messages.filter(m => m !== userMsg);
  saveConversations();

  messageInput.value = userMsg.content;
  renderActiveConversation();
  sendMessage();
}

/* ============================================================
   【24】イベント登録(ボタンやキー操作と関数を結び付ける)
   ------------------------------------------------------------
   ※ステップ②で、新しく追加した設定項目(モデル名・
     reasoning_effort・max_tokens など)のイベントを追加しました。
============================================================ */
function registerEvents() {
  /* 送信ボタン / 停止ボタン */
  sendBtn.addEventListener("click", sendMessage);
  stopBtn.addEventListener("click", cancelCurrentRequest);

  /* Enterで送信 / Shift+Enterで改行
     (isComposing は日本語入力の変換確定Enterを除外するための判定) */
  messageInput.addEventListener("keydown", (e) => {
    if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
      e.preventDefault();
      sendMessage();
    }
  });
  messageInput.addEventListener("input", autoResizeTextarea);

  /* 自動スクロール検知(ユーザーが上へスクロールしたら追従を止める) */
  chatContainer.addEventListener("scroll", () => {
    const threshold = 60;
    autoScrollEnabled =
      chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight < threshold;
  });

  /* 新しいチャット */
  document.getElementById("newChatBtn").addEventListener("click", () => {
    if (isGenerating) return;
    createConversation();
    topbarTitle.textContent = "新しいチャット";
  });

  /* ============================================================
     ステップ⑤で追加したエクスポート・インポート関連のイベント登録
     ------------------------------------------------------------
     「📤 出力」ボタンと「📥 読込」ボタン、
     そしてインポート用の「隠しファイル入力(importFileInput)」の
     イベントを登録します。
  ============================================================ */

  /* 「📤 出力」ボタン:すべての会話履歴をJSONファイルとしてエクスポートする */
  document.getElementById("exportBtn").addEventListener("click", () => {
    if (isGenerating) return;
    exportConversations();
  });

  /* 「📥 読込」ボタン:インポート用の「隠しファイル入力」をクリックして、
     ファイル選択ダイアログを開く */
  document.getElementById("importBtn").addEventListener("click", () => {
    if (isGenerating) return;
    importFileInput.click();
  });

  /* 隠しファイル入力(importFileInput)の change イベント:
     JSONファイルが選択された時に、importConversations に渡す。
     あわせて、e.target.value = "" で値をクリアしておくことで、
     同じファイルを続けて選択できるようにします。 */
  importFileInput.addEventListener("change", (e) => {
    const file = e.target.files && e.target.files[0];
    if (file) importConversations(file);
    e.target.value = "";   // 同じファイルを連続選択可能にする
  });

  /* サイドバーの開閉(モバイルと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キー入力(変数に保持するだけ。保存はしない)
     ※ステップ⑦で、updateWelcomeScreen() の呼び出しを追加しました。
       APIキーを入力・消去するたびに、ウェルカム画面の
       タイトルとサブタイトルを「即座に」書き換えます。 */
  apiKeyInput.addEventListener("input", () => {
    apiKey = apiKeyInput.value.trim();
    updateWelcomeScreen(); // 入力状態に応じてウェルカム画面を即時更新
  });

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

  /* モデル名の変更時:
     モデルに応じた設定項目の表示を切り替えて、設定を保存する
     ※ステップ⑦で、updateWelcomeScreen() の呼び出しを追加しました。
       モデルを変更するたびに、ウェルカム画面のタイトル(モデル名)を
       「即座に」書き換えます。 */
  modelInput.addEventListener("change", () => {
    updateModelSpecificSettingsUI();   // 差分④で追加した表示切替関数
    saveUiSettings();                  // 設定を保存
    updateWelcomeScreen();             // モデル名表示を即時更新(ステップ⑦で追加)
  });

  /* 思考モード(kimi-k2.6)の変更時:
     data 属性にも値を保存してから、設定を保存する */
  thinkingModeSelect.addEventListener("change", () => {
    if (getSelectedModel() === "kimi-k2.6") {
      /* Kimi K2.6 での選択値を data 属性に覚えておく
         (他のモデルに切り替えても、元の選択値を保持するため) */
      thinkingModeSelect.dataset.k26Value = thinkingModeSelect.value;
    }
    saveUiSettings();
  });

  /* max_tokens の変更時:
     1以上の整数かを検証(バリデーション)してから保存する */
  maxTokensInput.addEventListener("change", () => {
    if (!validateMaxTokens(maxTokensInput.value)) {
      maxTokensInput.value = 8192;   // 不正な値はデフォルトに戻す
      maxTokensInput.focus();
      return;                        // 保存しない
    }
    saveUiSettings();
  });

  /* その他の設定項目(reasoning_effort・画面更新間隔)の変更時:
     まとめて「設定を保存」するイベントを登録する */
  [reasoningEffortSelect, renderIntervalInput].forEach(el => {
    el.addEventListener("change", saveUiSettings);
  });

  /* APIタイムアウトの変更時:
     入力のたびにヒントを更新する(input イベントは値が変わるたびに発火) */
  requestTimeoutInput.addEventListener("input", () => {
    saveUiSettings();
    updateSettingHints();
  });


  /* ============================================================
     ステップ③で追加したカスタムプロンプト関連のイベント登録
     ------------------------------------------------------------
     チェックボックスの切り替え・テキストエリアの編集・
     「デフォルトに戻す」「コピー」ボタンのイベントを登録します。
  ============================================================ */

  /* カスタムプロンプトモードの切り替え時:
     編集エリアの表示を切り替えて、設定を保存する */
  customPromptModeToggle.addEventListener("change", () => {
    updateCustomPromptVisibility();   // 差分④で追加した表示切替関数
    saveUiSettings();                 // 設定を保存
  });

  /* カスタムプロンプトの編集時:
     入力のたびに設定を保存する(input イベントは値が変わるたびに発火) */
  customSystemPrompt.addEventListener("input", () => {
    saveUiSettings();
  });

  /* 「デフォルトに戻す」ボタン:
     確認ダイアログを表示してから、デフォルトプロンプトに戻す
     (完成形と同じ確認メッセージを採用) */
  resetPromptBtn.addEventListener("click", () => {
    if (confirm("カスタムプロンプトをデフォルトに戻しますか?\n現在の編集内容は失われます。")) {
      customSystemPrompt.value = DEFAULT_SYSTEM_PROMPT;
      saveUiSettings();
    }
  });

  /* 「コピー」ボタン:
     編集エリアの内容をクリップボードにコピーする。
     成功したらボタンの表示を一時的に「✓ コピーしました」に変える */
  copyPromptBtn.addEventListener("click", () => {
    navigator.clipboard.writeText(customSystemPrompt.value).then(() => {
      const originalText = copyPromptBtn.textContent;
      copyPromptBtn.textContent = "✓ コピーしました";
      setTimeout(() => {
        copyPromptBtn.textContent = originalText;
      }, 2000);
    }).catch(() => {
      alert("コピーに失敗しました。手動で選択してコピーしてください。");
    });
  });

  /* ============================================================
     ステップ④で追加した添付関連のイベント登録
     ------------------------------------------------------------
     添付ボタン4つ(🖼📄📝📁)のクリックイベントと、
     隠しファイル入力の change イベント、
     そしてアップロード上限の設定項目の変更時イベントを登録します。
  ============================================================ */

  /* ----- 添付ボタン4つ:隠しファイル入力をクリックする ----- */
  /* 画像添付ボタン(🖼)を押すと、隠しファイル入力(imageFileInput)を開く */
  $("#imageAttachBtn").addEventListener("click", () => imageFileInput.click());
  /* PDF添付ボタン(📄)を押すと、隠しファイル入力(pdfFileInput)を開く */
  $("#pdfAttachBtn").addEventListener("click", () => pdfFileInput.click());
  /* テキストファイル添付ボタン(📝)を押すと、隠しファイル入力(textFileInput)を開く */
  $("#textAttachBtn").addEventListener("click", () => textFileInput.click());
  /* フォルダ添付ボタン(📁)を押すと、隠しファイル入力(folderFileInput)を開く */
  $("#folderAttachBtn").addEventListener("click", () => folderFileInput.click());

  /* ----- 隠しファイル入力の change イベント:ファイルが選択された時に呼ばれる ----- */
  /* 画像が選択された時:handleImageUpload(差分⑩-1)に渡す */
  imageFileInput.addEventListener("change", (e) => {
    handleImageUpload(Array.from(e.target.files || []));
  });
  /* PDFが選択された時:handlePdfUpload(差分⑩-3-1)に渡す(1ファイルのみ) */
  pdfFileInput.addEventListener("change", (e) => {
    const file = (e.target.files || [])[0];
    if (file) handlePdfUpload(file);
  });
  /* テキストファイルが選択された時:handleTextFiles(差分⑩-2)に渡す */
  textFileInput.addEventListener("change", (e) => {
    handleTextFiles(Array.from(e.target.files || []));
  });
  /* フォルダが選択された時:handleFolderUpload(差分⑩-2)に渡す */
  folderFileInput.addEventListener("change", (e) => {
    handleFolderUpload(e.target.files || []);
  });

  /* ----- アップロード上限の設定項目の変更時イベント -----
     設定が変更された時に、設定を保存し、ヒント表示を更新します。
     saveUiSettings(差分⑨-3)の中で applyUploadSettings() が呼ばれるため、
     「保存」「変数への反映」の2つがまとめて行われます。 */
  [maxImageSizeInput, maxPdfSizeInput, maxPdfPagesInput, maxTextSizeInput, requestTimeoutInput].forEach(el => {
    el.addEventListener("change", () => {
      saveUiSettings();
      updateSettingHints();
    });
  });

  /* ============================================================
     ドラッグ&ドロップのイベント登録
     ------------------------------------------------------------
     全画面のドラッグ&ドロップ(ドロップオーバーレイの表示・
     ファイルの振り分け)を登録します。
     ドロップされたファイルは、distributeDroppedFiles(この後に追加)で
     「画像・PDF・テキストファイル」の種類ごとに振り分けられます。
  ============================================================ */
  let dragCounter = 0;   // ドロップオーバーレイの表示制御用カウンター

  /* ファイルが画面に「入ってきた」時:
     ドロップオーバーレイを表示する */
  document.addEventListener('dragenter', (e) => {
    e.preventDefault();
    dragCounter++;
    if (dragCounter === 1) {
      dropOverlay.classList.add('visible');
    }
  });

  /* ファイルが画面から「出ていった」時:
     ドロップオーバーレイを隠す */
  document.addEventListener('dragleave', (e) => {
    e.preventDefault();
    dragCounter--;
    if (dragCounter === 0) {
      dropOverlay.classList.remove('visible');
    }
  });

  /* ドラッグ中のデフォルト動作(ブラウザがファイルを開こうとする動作)をキャンセルする */
  document.addEventListener('dragover', (e) => {
    e.preventDefault();
  });

  /* ファイルが「ドロップ」された時:
     ファイルを「種類ごと」に振り分けて、それぞれの添付処理に渡す */
  document.addEventListener('drop', async (e) => {
    e.preventDefault();
    dragCounter = 0;
    dropOverlay.classList.remove('visible');

    /* ドロップされたファイルを取得する */
    const files = Array.from(e.dataTransfer.files || []);

    /* フォルダのドロップはブラウザの制限により不安定なため、
       フォルダ選択ボタンを自動でクリックしてダイアログを開く */
    const items = Array.from(e.dataTransfer.items || []);
    const hasDirectoryEntry = items.some(it => {
      try {
        const entry = it.webkitGetAsEntry ? it.webkitGetAsEntry() : null;
        return entry && entry.isDirectory;
      } catch (err) {
        return false;
      }
    });

    if (hasDirectoryEntry) {
      showError("フォルダのドラッグ&ドロップはブラウザの制限により利用できません。フォルダ選択ダイアログを開きます。");
      setTimeout(() => {
        folderFileInput.click();
      }, 100);
      return;
    }

    /* フォルダ名と同じ名前の空ファイルが含まれる場合の除外処理
       (一部のブラウザでフォルダをドロップすると、フォルダ名の0バイトファイルが作成される場合がある) */
    const validFiles = files.filter(f => {
      if (f.size === 0 && !f.name.includes('.')) {
        return false;
      }
      return true;
    });

    if (validFiles.length === 0) {
      showError("フォルダまたは無効なファイルがドロップされました。ファイルを個別に選択するか、フォルダ選択ボタン(📁)を使用してください。");
      return;
    }

    /* ファイルを「種類ごと」に振り分けて、それぞれの添付処理に渡す */
    distributeDroppedFiles(validFiles);
  });

  /* ============================================================
     クリップボードからの貼り付けのイベント登録
     ------------------------------------------------------------
     クリップボードからの画像・テキストファイルの貼り付けを登録します。
     これが、差分③の解説で「貼り付け・ドラッグ&ドロップにも対応」
     と案内した、貼り付け機能の正体です。
  ============================================================ */
  messageInput.addEventListener("paste", (e) => {
    const items = Array.from(e.clipboardData.items || []);

    /* 画像の貼り付け(スクリーンショットなど)
       クリップボードに画像データがある場合は、それをファイルとして添付する */
    const imageItems = items.filter(it => ALLOWED_IMAGE_TYPES.includes(it.type));
    if (imageItems.length > 0) {
      const files = imageItems.map(it => it.getAsFile()).filter(Boolean);
      if (files.length > 0) {
        e.preventDefault();   // テキストとしての貼り付けをキャンセル
        handleImageUpload(files);
      }
    }

    /* テキストファイルの貼り付け
       クリップボードにテキストファイルがある場合は、それを添付する */
    const textItems = items.filter(it => {
      if (it.kind === 'file') {
        const file = it.getAsFile();
        return file && isTextFile(file);
      }
      return false;
    });

    if (textItems.length > 0) {
      const files = textItems.map(it => it.getAsFile()).filter(Boolean);
      if (files.length > 0) {
        e.preventDefault();
        handleTextFiles(files);
      }
    }
  });

  /* ============================================================
     ツールチップ補助(モバイル長押し+位置自動調整)
     data-tooltip 属性を持つ全要素に対して設定します。
  ============================================================ */
  let tooltipTimer = null;
  let tooltipTarget = null;

  function showTooltip(el) {
    hideTooltip();
    adjustTooltipPosition(el);
    el.classList.add("tooltip-visible");
    tooltipTarget = el;
  }
  function hideTooltip() {
    if (tooltipTarget) {
      tooltipTarget.classList.remove("tooltip-visible");
      tooltipTarget = null;
    }
  }
  /* 画面上部のスペースが足りない場合は下向きに切り替える */
  function adjustTooltipPosition(el) {
    el.classList.remove("tooltip-below");
    const rect = el.getBoundingClientRect();
    if (rect.top < 140) el.classList.add("tooltip-below");
  }

  document.querySelectorAll("[data-tooltip]").forEach(el => {
    /* モバイル長押し対応(500ms長押しで表示) */
    el.addEventListener("touchstart", () => {
      tooltipTimer = setTimeout(() => showTooltip(el), 500);
    }, { passive: true });
    el.addEventListener("touchend", () => {
      clearTimeout(tooltipTimer);
      if (tooltipTarget === el) setTimeout(hideTooltip, 2500);
    });
    el.addEventListener("touchmove", () => {
      clearTimeout(tooltipTimer);
      hideTooltip();
    }, { passive: true });
    /* PCホバー時:位置を自動調整 */
    el.addEventListener("mouseenter", () => adjustTooltipPosition(el));
  });

  /* 画面の他の場所をタップしたらツールチップを閉じる */
  document.addEventListener("touchstart", (e) => {
    if (tooltipTarget && !tooltipTarget.contains(e.target)) {
      hideTooltip();
    }
  }, { passive: true });
}

/* ============================================================
   【25】アプリの起動(初期化)
   ------------------------------------------------------------
   ページを開いた時に一番最初に実行される関数。
============================================================ */
function init() {
  loadUiSettings();       // ① 保存されたテーマと設定を適用
  updateDarkModeBtn();    // ② テーマボタンの表示を更新
  loadConversations();    // ③ 保存された会話履歴を読み込む
  registerEvents();       // ④ ボタンやキー操作の設定

  /* ----- ⑤ タイムラインの監視と更新(ステップ⑥で追加) -----
     起動時に、タイムラインの監視(MutationObserver)を開始し、
     現在の会話の質問に応じた初期のマーカーを配置します。 */
  initTimelineObserver();
  updateQuestionTimeline();

  updateUiState();        // ⑥ 送信/停止ボタンを初期状態に

  /* ⑦ トップバーに現在の会話タイトルを表示 */
  const conv = getActiveConversation();
  if (conv) topbarTitle.textContent = conv.title;
}

/* ============================================================
   質問ナビゲーションタイムライン(ステップ⑥で追加)
   ------------------------------------------------------------
   画面右端に表示される「過去の質問へワンクリックでジャンプできる
   タイムライン」を動かすための関数と変数です。
   ユーザーメッセージ(質問)の数と位置を計算して、
   タイムラインにマーカー(●)を配置し、
   クリックで該当メッセージへスクロールさせます。
   (完成形のタイムライン関連コードと同じ仕様)
============================================================ */
let timelineObserver = null;

/* --- タイムラインのマーカーを更新する ---
   ユーザーメッセージ(質問)の数と位置を計算して、
   タイムラインにマーカー(●)を配置します。
   「質問が増えたら、マーカーも増える」という、シンプルな連動です。 */
function updateQuestionTimeline() {
  const track = document.getElementById("timelineTrack");
  if (!track) return;
  
  /* 既存のマーカーをクリアする
     「データが変わるたびに、一度空にしてから最新の状態で作り直す」
     という、画面描画の基本パターン(【8】と同じ)です。 */
  track.innerHTML = "";
  
  const conv = getActiveConversation();
  if (!conv || conv.messages.length === 0) return;
  
  /* ユーザーメッセージのみを抽出する(質問のみ)
     タイムラインは「質問へのナビゲーション」なので、
     AIの回答(assistant)は対象外です。 */
  const userMessages = [];
  conv.messages.forEach((msg, index) => {
    if (msg.role === "user") {
      userMessages.push({ msg, index });
    }
  });
  
  if (userMessages.length === 0) return;
  
  /* マーカーを配置する
     質問1件ごとに「●」を作り、位置を計算して配置します。
     位置の計算は、「最初の質問を5%、最後の質問を95%」に配置する
     完成形と同じ設計です。 */
  userMessages.forEach((item, i) => {
    const marker = document.createElement("div");
    marker.className = "timeline-marker";

    /* 位置を計算する
       質問が1件だけの場合は「50%」に配置し、
       複数ある場合は「5%〜95%」の間に均等に配置します。 */
    const position = userMessages.length === 1 
      ? 50 
      : 5 + (i / (userMessages.length - 1)) * 90;
    marker.style.top = `${position}%`;
    
    /* ツールチップ用のテキストを設定する
       質問内容の先頭20文字を「Q1: 〇〇…」の形で表示します。
       完成形と同じ表記です。 */
    const previewText = item.msg.content.replace(/\s+/g, " ").trim().slice(0, 20);
    marker.setAttribute("data-tooltip", `Q${i + 1}: ${previewText}${item.msg.content.length > 20 ? "…" : ""}`);
    
    /* クリックで該当メッセージへスクロールする
       scrollToMessage(この後に追加する関数)を呼び、
       現在位置を示すクラス(current)を更新します。 */
    marker.addEventListener("click", () => {
      scrollToMessage(item.index);
      /* 現在位置を示すクラスを更新する
         他のマーカーから current を外し、
         クリックしたマーカーに current を付けます。 */
      document.querySelectorAll(".timeline-marker").forEach(m => m.classList.remove("current"));
      marker.classList.add("current");
    });
    
    track.appendChild(marker);
  });
  
  /* 初期状態で最後のマーカーをアクティブにする
     「最新の質問」を現在位置として表示します。 */
  const markers = track.querySelectorAll(".timeline-marker");
  if (markers.length > 0) {
    markers[markers.length - 1].classList.add("current");
  }
}

/* --- クリックで該当メッセージへスクロールする ---
   引数 messageIndex:conv.messages 内での、質問メッセージの位置(インデックス)
   「質問」と「直後の回答」の位置を計算して、
   「回答の上端が、チャット欄の上端から16px下に来るように」
   スムーズにスクロールさせます。 */
function scrollToMessage(messageIndex) {
  const messageElements = chatContainer.querySelectorAll(".message");
  if (messageElements.length === 0) return;

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

  /* ユーザーメッセージの「何番目か」を特定する
     conv.messages 内での位置(messageIndex)から、
     「ユーザーメッセージの何番目か」を計算します。 */
  let targetUserIndex = 0;
  let userCount = 0;
  for (let i = 0; i < conv.messages.length; i++) {
    if (conv.messages[i].role === "user") {
      if (i === messageIndex) {
        targetUserIndex = userCount;
        break;
      }
      userCount++;
    }
  }

  /* DOM上で対象のユーザーメッセージと直後の回答を特定する
     「質問」と「直後の回答」の位置を計算して、
     スクロール先の要素(assistantEl)を決めます。 */
  let currentUserIdx = 0;
  let assistantEl = null;

  for (let i = 0; i < messageElements.length; i++) {
    if (messageElements[i].classList.contains("user")) {
      if (currentUserIdx === targetUserIndex) {
        /* 次の要素がassistantならそれを対象にする
           (質問と回答を「セット」で見せるための完成形の配慮) */
        if (i + 1 < messageElements.length && messageElements[i + 1].classList.contains("assistant")) {
          assistantEl = messageElements[i + 1];
        } else {
          assistantEl = messageElements[i]; // 回答がなければ質問自体
        }
        break;
      }
      currentUserIdx++;
    }
  }

  if (!assistantEl) return;

  /* scrollIntoView ではなく scrollTop を直接設定する
     「回答の上端が、チャット欄の上端から16px下に来るように」
     スクロール位置を計算します。 */
  const containerRect = chatContainer.getBoundingClientRect();
  const targetRect = assistantEl.getBoundingClientRect();
  const offset = targetRect.top - containerRect.top + chatContainer.scrollTop;

  chatContainer.scrollTo({
    top: offset - 16,   // 上に16pxの余白
    behavior: "smooth"  // スムーズにスクロール
  });
}

/* --- 会話が更新されたらタイムラインも更新する(MutationObserver の初期化) ---
   MutationObserver(ミューテーションオブザーバー)とは、
   「DOM(画面の部品)の変化を監視する」ブラウザ標準の機能です。
   「チャット欄(chatContainer)に、新しい質問の要素が追加されたら、
     タイムラインのマーカーを再描画する」という監視を設定します。
   (完成形の initTimelineObserver と同じ仕様) */
function initTimelineObserver() {
  /* すでに監視役が設定されている場合は、一度解除する
     (二重に監視が設定されるのを防ぐため) */
  if (timelineObserver) {
    timelineObserver.disconnect();
  }

  /* MutationObserver を作成する
     引数の関数は「DOMの変化が検知された時」に呼ばれます。 */
  timelineObserver = new MutationObserver((mutations) => {
    let shouldUpdate = false;

    /* 検知された変化(mutations)を1件ずつ確認する */
    for (const mutation of mutations) {
      /* childList(子要素の追加・削除)以外の変化は無視する */
      if (mutation.type !== "childList") continue;

      /* 追加された要素(addedNodes)を1件ずつ確認する */
      for (const node of mutation.addedNodes) {
        /* ユーザーメッセージのDOM追加時のみ更新する
           (生成中の部分描画(【19】)は除外するため、
             「message と user の両方のクラスを持つ要素」に限定します。
             これにより、AIの回答のストリーミング表示では
             タイムラインが再描画されず、無駄な描画を防げます) */
        if (node.nodeType === 1 && node.classList.contains("message") && node.classList.contains("user")) {
          shouldUpdate = true;
          break;
        }
      }
      if (shouldUpdate) break;
    }

    /* ユーザーメッセージが追加された場合のみ、タイムラインを再描画する */
    if (shouldUpdate) updateQuestionTimeline();
  });

  /* 監視を開始する
     監視対象:chatContainer(チャット欄)
     監視内容:childList(子要素の追加・削除)を、
               subtree(子孫要素も含めて)監視する */
  timelineObserver.observe(chatContainer, {
    childList: true,
    subtree: true
  });
}

/* ------------------------------------------------------------
   アプリの起動
   ------------------------------------------------------------
   DOMの準備ができてから init を実行します。
------------------------------------------------------------ */
document.addEventListener("DOMContentLoaded", init);

</script>
</body>
</html>

 

ステップ⑦完成時のウェルカム画面 - APIキー未入力時:ウェルカム画面の実装方法|アプリ完成【Runpod×Kimi入門⑦】
ステップ⑦完成時のアプリ画面

 

 

シリーズ完結の挨拶:7つのステップを完走して

 

シリーズ完結、本当におつかれさまでした。

 

ステップ①からステップ⑦まで、7つのステップを完走したあなたは、
HTML1ファイルだけで動く、本格的なAIチャットアプリ
を、自分の手で作り上げました。

 

ここまでの道のりを、7つのステップで振り返ってみましょう。

 

 ステップ 作り上げたもの
 ステップ①
 会話ができる
 アプリ
 チャットの土台。
 Runpod APIと通信し、回答をストリーミングで少しずつ表示し、
 思考過程パネルやMarkdown表示、応答計測まで備えた、
 本格的なAIチャットアプリの土台
 ステップ②
 モデルと
 パラメータの設定
 モデル名・reasoning_effort・思考モード・max_tokens・
 画面更新間隔・APIタイムアウトを、設定パネルから変更可能に。
 「値を関数経由で取得する」設計の効果を実感
 ステップ③
 カスタムシステム
 プロンプト
 AIの性格や役割を、設定パネルから自由に設定可能に。
 プロンプトエンジニアリングの土台を手に入れた
 ステップ④
 ファイル
 アップロード
 画像・PDF・テキストファイル・フォルダのアップロードに対応。
 ドラッグ&ドロップやクリップボードからの貼り付けにも対応し、
 設定パネルでアップロード上限も変更可能に
 ステップ⑤
 エクスポート・
 インポート
 会話履歴をJSONファイルとして出力・読込可能に。
 パソコンを買い替えた時や、別のブラウザで作業したい時にも、
 会話履歴を持ち運べるようにした
 ステップ⑥
 質問ナビゲーショ
 ンタイムライン
 過去の質問へワンクリックでジャンプできるタイムラインを実装。
 長い会話も快適に操作できるようにした
 ステップ⑦
 ウェルカム画面
 起動時や新規チャット時の初期画面を実装。
 APIキーの入力状態に応じて2つの表示に切り替わり、
 アプリが完成した!(その後もオリジナルの拡張に挑戦してみてください)

 

■ このシリーズで学んだ、プログラミングの核心概念

 

・設定値の集約(const で1か所にまとめる)
・状態管理(isGenerating などの変数で「今」を制御する)
・localStorage(JSON.stringify / JSON.parse でデータを保存・復元)
・DOM操作(createElement と appendChild で画面を組み立てる)
・XSS対策(escapeHtml と DOMPurify で入力を無害化する)
・async / await(時間のかかる通信処理を「待つ」)
・SSEストリーミング(蛇口+バッファで、回答を少しずつ受け取る)
・スロットリング(80msごとにまとめて描画し、動作を軽く保つ)
・イベント駆動(addEventListener で「操作」と「処理」を結び付ける)
・FileReader API(ファイルの中身を読み込む)
・Base64エンコード(画像データを「文字列」に変換してAPIに送る)
・PDF.js(PDFをブラウザ上で解析する外部ライブラリ)
・マルチモーダル形式(テキストと画像を組み合わせてAPIに送る)
・Blob と URL.createObjectURL(ブラウザ上でファイルを生成してダウンロードさせる)
・MutationObserver(DOMの変化を監視して、別の部品を自動で更新する)
・「値を関数経由で取得する」設計(変更に強い、拡張しやすいコードを書く)
・DRYの原則(同じ処理は、同じ関数に任せる)
・「退避させる」配慮(「一度空にしてから作り直す」と「消してはいけないものを残す」の両立)

 

これらは、どれもチャットアプリに限らず、あらゆるWebアプリ開発で通用する基礎体力です。
このシリーズを完走したあなたは、すでに「APIを使ったアプリ開発」の本質的な部分を、確実に手中にしています。

 

■ 次のステップへ:アプリの拡張に挑戦しよう

 

このシリーズで作り上げたアプリは、「あなただけの、AIチャットアプリ」です。

 

まずは、ウェブ版のUI上で「カスタムシステムプロンプト」を活用した
プロンプトエンジニアリング
(プロンプトの内容を工夫することで、AIの出力内容を調整する技術)
に慣れてみてください。
「必ず英語で回答してください」「小学生でも分かるように説明してください」など、小さな変更から始めるのがおすすめです。

 

そして、プロンプトエンジニアリングに慣れてきたら、次は「アプリ自体を拡張する」フェーズに挑戦してみましょう。

 

このシリーズで学んだ「プログラムの全体像と、各コードの役割」が、その拡張の道しるべになります。

 

本当に、おつかれさまでした。
あなたのアプリ開発の旅が、ここからさらに広がっていくことを、心より応援しています。

 

【プロンプトエンジニアリング】
【詳細解説】システムプロンプト - AIチャットアプリ:Runpod API専用 – Kimi編
:プロンプトエンジニアリング入門|コーディング支援AIのシステムプロンプト徹底解説【Runpod×Kimi】

31項目のルールを「5つの柱」に整理し、コーディング支援に強い理由を分析。
あなた自身がシステムプロンプトを書き・カスタマイズするためのコツと実践テンプレートも紹介します。

 

【アプリの拡張】
カスタムAIチャットアプリの拡張:Runpod API専用 – Kimi編
:カスタムAIチャットアプリの拡張方法|初心者でもKimi K3でアプリ開発を楽しむ【Runpod×Kimi入門】

アプリのタイトル・テーマカラー・システムプロンプトの変更から、会話の検索機能・お気に入り機能の追加まで、レベル別の拡張アイデアとKimi K3へのプロンプト例を紹介します。

 

by 子供プログラマー

 

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