AIチャットアプリ開発入門|Runpod API×KimiをHTML1ファイルで作る講座【会話編】【Runpod×Kimi入門①】

AIチャットアプリ開発入門|Runpod API×KimiをHTML1ファイルで作る講座【会話編】


 

Table of Contents

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

 

 

この講座で作るもの(完成形の紹介)

 

まず、この講座のゴールとなる「完成形のアプリ」を確認しておきましょう。

 




 

ここでいう

 

この講座で開発するのは、HTML1ファイルだけで動く、Runpod API専用のカスタムAIチャットアプリです。

 

完成形アプリのUI表示:【プログラミング】AIチャットアプリ開発入門講座 - Runpod API専用 - Kimi編
完成形アプリのUI表示

 

完成形のアプリには、以下の機能がすべて実装されています。

 

■ チャットの基本機能

・AIとのリアルタイムチャット(回答が少しずつ表示されるストリーミング方式)
・AIの「思考過程」の表示(Kimi K3(常時思考)/Kimi K2.7 Code/K2.6の思考モード対応)
・回答のMarkdown表示・シンタックスハイライト(コードが色付きで見やすく表示)
・応答時間の計測表示(応答時間・思考過程の文字数・本文の文字数)
・長い質問は自動で折りたたみ(「全文を表示」ボタンで展開)
・チャット履歴の保存(ブラウザのlocalStorageに自動保存)
・「+新しいチャット」「全履歴削除」「テーマ(ライト/ダーク)切り替え」

 

■ モデル・パラメータの設定機能

・モデル名の切り替え(Kimi K3/K2.7 Code/K2.6)
・reasoning_effort(Kimi K3の推論レベル)の設定
・思考モードのON/OFF
・max_tokens(最大出力トークン数)の設定
・画面更新間隔・APIタイムアウトの調整
・カスタムシステムプロンプト(AIの性格や役割を自分で設定)

 

■ ファイルアップロード機能

・画像(🖼)・PDF(📄)・テキストファイル(📝)・フォルダ(📁)のアップロード
・ドラッグ&ドロップ対応
・アップロード上限サイズの設定

 

■ 便利機能

・会話履歴のエクスポート(出力)/インポート(読込)
・質問ナビゲーションタイムライン(過去の質問へワンクリックでジャンプ)
・ウェルカム画面(起動時の初期画面)
・モバイル対応(スマホ・タブレットでも快適に動作)

 

サーバーの構築は不要です。
ブラウザとテキストエディタだけあれば開発でき、ファイルをダブルクリックするだけで動きます。

注:ファイル構造によるアプリの不具合と対応
「custom-app-dev.html」のファイルが配置されていディレクトリ(ファイルの階層構造)内に

・file:///Users/childprogram/Desktop/custom-app-dev.html
*ブラウザのアプリ表示画面のURLの表示

などと、全て半角英数の文字だけになっている場合には、アプリのUIのレイアウトの崩れがない状態で使えるようでした。
どうしても半角英数以外の文字が入ってしまう場合の対応は、以下の記事ページ

ローカルサーバー上でアプリを起動する手順例
:【ダウンロード】カスタムAIチャットアプリ:Runpod API専用 - Kimi編

で、ローカルサーバー上でアプリを起動する手順を参照してください。
その際には、フォルダ内にこの講座で作成するファイルを配置して手順を進めてください。

【ファイル構造】

custom-aichatapp/
└─ custom-app-dev.html

 

 

開発者の思い:なぜこのアプリを公開し、講座まで作ったのか

 

ここで少し、このアプリの開発の経緯と、公開に込めた思いをお話しさせてください。

 

今回、ウェブ版のAIチャットアプリだけでなく、ローカル環境(自分のパソコン環境内)で使える「カスタム版」のアプリも、ダウンロードして使えるように用意しました。

 

その理由は、単に「使ってもらう」だけでなく、その先にある「自分で作り、自分で進化させる楽しさ」を体験してほしかったからです。

 

ウェブ版のUI上で「カスタムシステムプロンプト」を活用したプロンプトエンジニアリング(プロンプトの内容を工夫することで、AIの出力内容を調整する技術)に慣れてきたら、次は「アプリ自体を拡張する」フェーズに移行できます。

 

アプリの性質上、APIの制限はあります。それでも、APIの仕様が許す範囲で「自分が思うようにアプリをカスタムして進化させる」経験を積むことで、チャットAI(会話型生成AI)のアプリ開発そのものを楽しんでもらうこと。それも、このコンテンツを作った目的の1つです。

 

システムプロンプトを設定して「自分好みのチャットAI」にカスタマイズすることはもちろん、その先にある「アプリ自体をカスタマイズして、機能やUIを拡張する」ことまでを想定しています。

 

ただ、プログラミングに馴染みのない方が、いきなりアプリをカスタマイズするのはハードルが高いでしょう。

そこでこの講座では、カスタムAIチャットアプリを拡張する前の段階として、プログラムの全体像や各コードの役割を深掘りして解説します。

この講座が、プログラムを「読める・理解できる」きっかけとなり、スムーズなアプリ開発への移行のお役に立てましたら幸いです。

 

 

この講座の進め方・前提条件

 

前提条件(始める前に必要なもの)

 

この講座を進めるために必要なものは、以下の3つだけです。

 

1. RunpodのアカウントとAPIキー

RunpodのAPIキーの取得方法は、別記事「【API Keyの使い方】AIチャットアプリ:Runpod API専用 – Kimi編」で詳しく解説しています。まだ取得していない方は、先にそちらをご覧ください。

 

2. テキストエディタ

Windowsの「メモ帳」やmacOSの「テキストエディット」でも始められます。もちろん、VS Codeなどのエディタをお持ちなら、そちらのほうが快適です。

 

3. ウェブブラウザ

Google Chrome、Microsoft Edge、Firefox、Safariなど、普段お使いのブラウザで構いません。

 

※なお、開発環境の構築方法や各種ツールのインストール手順については、この講座では扱いません。アプリのプログラムそのものの解説に焦点を当てて進めていきます。

 

7ステップのロードマップ

 

この講座は、全部で7つのステップで構成されています。

 

ステップ①:会話ができるアプリ
チャットの土台を作ります。この時点で、API経由でAIと会話できるアプリが完成します。

ステップ②:モデルとパラメータの設定
モデル名・reasoning_effort・思考モード・max_tokensなどを、UI上で変更できるようにします。

ステップ③:カスタムシステムプロンプト
AIの性格や役割を自由に設定できるようにします。

ステップ④:ファイルアップロード
画像・PDF・テキストファイル・フォルダのアップロードに対応します。

ステップ⑤:エクスポート・インポート
会話履歴をファイルに出力し、読み込めるようにします。

ステップ⑥:質問ナビゲーションタイムライン
過去の質問へワンクリックでジャンプできる機能を追加します。

ステップ⑦:ウェルカム画面
起動時のウェルカム画面を実装して、完成です。

 

重要なポイントは、各ステップの終わりに、必ず「その時点で動くアプリ」が手に入ることです。

いきなり完成形のコードを渡されても、初心者の方は圧倒されてしまいます。
しかし、動く状態を保ちながら1つずつ機能を追加していけば、
「どのコードが、どの機能を担当しているのか」
が自然と理解できるようになります。

 

コードの読み方・この記事の表記ルール

 

この記事では、以下のルールでコードを提示します。

 

・ステップ①は「完全コード」を提示

最初のステップでは、ファイル全体のコードをすべて掲載します。コードエリア右上の「コピー」ボタンで全体をコピーし、テキストエディタに貼り付けて「custom-app-dev.html」(名前は一例です:半角英数で記述します)という名前で保存してください。

 

・ステップ②以降は「差分コード」を提示

2つ目のステップ以降は、「この関数の直後に、このコードを追加」「このコードを、こちらのコードに置き換え」という形で、変更箇所だけを明示します。

「〇〇関数の直前(function 〇〇() { の行の前)」のように、検索すればすぐ見つかる目印を必ず記載しますので、落ち着いて1つずつ作業してください。
念のため、初心者の方でも大丈夫なように、差分適応後の「完全コード」も記事の末に掲載しておきました。

 

・コード内のコメントを読み返してください

提示するコードには、初学者の方向けの丁寧なコメントを多数入れています。

「まずコピーして動かす」→「コメントを読んで役割を理解する」→「解説を読んで深掘りする」という流れで、何度も行き来しながら読み進めるのがおすすめです。

 

 

アプリの全体構造を理解しよう

 

コードを書き始める前に、今から作るアプリが「どういう仕組みで動いているのか」の全体像を押さえておきましょう。

ここが理解できると、各コードの役割がぐっと見通しやすくなります。

 

アプリは「ブラウザの中」だけで動いている

 

今回作るアプリは、HTML・CSS・JavaScriptが書かれたたった1つのファイルです。

 

このファイルをブラウザで開くと、以下のような流れでAIと会話が行われます。

 

1. あなたがブラウザ上のアプリに質問を入力する

2. アプリ内のJavaScriptが、RunpodのAPIに対して質問データを送信する
(この時、パスワードのような役割を持つ「APIキー」も一緒に送り、「自分が正しい利用者であること」を証明します)

3. Runpodのサーバー上で、KimiのAIモデルが回答を生成する

4. 生成された回答が、少しずつ(ストリーミング方式で)ブラウザに送り返される

5. アプリが受け取った回答を画面に表示する

 

アプリの通信イメージ図:【プログラミング】AIチャットアプリ開発入門講座 - Runpod API専用 - Kimi編
アプリの通信イメージ図

 

ポイントは、自分でサーバーを立てる必要がないことです。

通常、Webアプリは「サーバー」を用意してプログラムを動かす必要がありますが、このアプリはブラウザからRunpodのAPIを直接呼び出すため、HTMLファイル1枚だけで完結します。難しいインフラの知識なしに、APIを使ったアプリ開発の本質を学べる構成になっています。

 

「API」って何? 初心者向けの超ざっくり解説

 

API(エーピーアイ)とは、簡単に言うと「プログラム同士が会話するための窓口」です。

 

レストランに例えるなら、あなた(アプリ)が「注文票」(リクエスト)を店員さん(API)に渡すと、厨房(AIモデル)で料理(回答)が作られ、店員さんが料理を運んでくる、というイメージです。

 

今回使うRunpodのAPIは、以下の「注文票のルール」に従っています。

 

・どのモデルに聞くか(model名:kimi-k3 など)
・何を聞くか(messages:これまでの会話内容)
・どのくらい深く考えるか(reasoning_effort:推論レベル)
・どのくらい長い回答まで許可するか(max_tokens:最大出力トークン数)

 

これらの「注文項目」こそが、ステップ②で設定できるようにする「パラメータ」です。

 

HTML・CSS・JavaScript、それぞれの役割

 

この1つのファイルの中には、3つの言語が書かれています。役割は以下のとおりです。

 

・HTML(エイチティーエムエル):アプリの「骨組み」
チャット画面・ボタン・入力欄など、アプリの「部品」を配置します。家でいうところの「柱や壁」です。

 

・CSS(シーエスエス):アプリの「見た目」
色・大きさ・余白・ダークテーマなど、デザインを整えます。家でいう「内装・壁紙」です。ファイル内の <style> ~ </style> の部分がこれにあたります。

 

・JavaScript(ジャバスクリプト):アプリの「動き」
ボタンを押した時の処理・APIとの通信・回答の表示など、すべての「動作」を担当します。家でいう「電気配線・水道管」のような、裏側で全体を動かす心臓部です。ファイル内の <script> ~ </script> の部分がこれにあたります。

 

この講座では特に、JavaScriptの部分に注目して解説を進めていきます。

 

セキュリティの仕様:APIキーはどこに保存されるの?

 

最後に、セキュリティについて確認しておきましょう。

 

このアプリでは、APIキーを画面の入力欄からのみ読み取り、メモリ上(変数)にだけ保持します。

つまり、以下のような「安全な仕様」になっています。

 

・APIキーはlocalStorageやファイルには一切保存されない
・ブラウザの画面を更新(リロード)したりタブを閉じると、APIキーはリセットされ、再入力が必要になる
・APIキーを含む通信は、Runpodの公式APIサーバーに対してのみ行われる

アプリ開発チュートリアルでは1ファイルのみで完成を想定しているので、
「CDN(jsdelivr / cloudflare)からライブラリを読み込む方式」(外部参照方式)
を採用していますが、外部通信を極力減らしたい場合には、ライブラリを自分のパソコンに別ファイルで保存し、そのファイルをパソコン内で参照(ローカル参照方式)するようにするとセキュリティ対策としては一層良いようです。
そうすると、チャットの通信はすべてRunpodの公式APIサーバーに対してのみ行われる仕様になります。
(設定画面に設置している当サイトへのリンクをクリックした際には、当サイトへの接続のため通信が発生します)
その方式は、ダウンロードできるように配布している

「カスタムAIチャットアプリ」(ダウンロード版)
:【ダウンロード】カスタムAIチャットアプリ:Runpod API専用 - Kimi編

で採用しています。
必要に応じて「カスタムAIチャットアプリ」のプログラムとファイル構造を参考にしてみてください。

【ファイル構造】

custom-aichatapp/
└─ custom-app-dev.html
├─ js/
│ ├─ marked.min.js
│ ├─ purify.min.js
│ ├─ highlight.min.js
│ ├─ pdf.min.js
│ └─ pdf.worker.min.js
└─ css/
└─ github-dark.min.css

 

「リロードなどのたびに入力するのは少し面倒」に感じるかもしれませんが、万が一の時にAPIキーが漏れにくい、安全性を優先した設計になっています。

なお、会話の履歴はブラウザの「localStorage」(ブラウザ内の小さな保存領域)に保存されます。
これは自分のパソコン内のデータであり、外部のサーバーに送信されるものではありません。
なお、会話の内容そのものは、質問としてAI(Runpod)に送信されます。
ここで言う「保存」とはブラウザ内での履歴保持のことであり、
履歴を保存するために外部サーバーへデータが送られるわけではありません。

 

 

それでは、開発を始めましょう

 

全体像の理解はバッチリです。

次のセクションから、いよいよステップ①:会話ができるアプリの作成に入ります。

まずはコードを丸ごとコピーして、「AIと会話できる」感動を体験してみてください。そのあとで、じっくりとコードの中身を解説していきます。

 

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

📄 custom-app-dev.html(ステップ①:完全コード)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<!-- viewport:スマホ・タブレットでも見やすい表示にするための設定 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>カスタムAIチャットアプリ開発(MIT License)</title>

<!-- ============================================================================

The MIT License

Copyright 2026 child programmer

--------

Here is the original version of the program released by child programmer.
https://child-programmer.com//rp-api/chat-k/custom-app-development/step1/

--------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/* 設定パネル(トップバーの下に展開されるエリア) */
#settingsPanel {
  border-bottom: 1px solid var(--border);
  padding: 14px;
  background: var(--bg-sidebar);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 12px;
  max-height: calc(100vh - 60px);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.setting-field label {
  display: block;
  font-size: 12px;
  color: var(--text-sub);
  margin-bottom: 4px;
}
.setting-field input,
.setting-field select,
.setting-field textarea {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
  color: var(--text);
  font-size: 13px;
  font-family: inherit;
}
/* エラーメッセージ表示エリア */
#errorArea {
  background: rgba(229,72,77,.1);
  border: 1px solid var(--danger);
  color: var(--danger);
  font-size: 13px;
  padding: 10px 14px;
  margin: 10px 16px 0;
  border-radius: 8px;
  display: none;
  white-space: pre-wrap;
  word-break: break-word;
}
#errorArea.visible { display: block; }

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

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

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

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

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

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

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

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

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

/* ============================================================
   入力エリア(画面下部)
   完成形の構造:#inputRow が「カード型」の容器になり、
   その中にテキストエリアと送信ボタンが入っています。
============================================================ */
#inputArea {
  border-top: 1px solid var(--border);
  padding: 12px 16px 16px;
  background: var(--bg);
}
#inputRow {
  max-width: 860px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--bg-input);     /* カードの背景色(白背景で明確に識別) */
  border: 1px solid var(--border); /* カードの境界線 */
  border-radius: 14px;
  padding: 8px 10px;
  box-shadow: 0 2px 8px rgba(0,0,0,.08);  /* 軽い影でカードを浮き上がらせる */
}
/* フォーカス時の視覚効果(入力欄を選択中にカードを強調) */
#inputRow:focus-within {
  border-color: var(--accent);
  box-shadow: 0 2px 12px rgba(74, 93, 216, .15);  /* アクセント色の影 */
}
/* メッセージ入力欄(カードの中に透明で配置する) */
#messageInput {
  flex: 1;
  background: transparent;
  border: none;
  outline: none;
  resize: none;
  color: var(--text);
  font-size: 16.5px;
  line-height: 1.5;
  max-height: 200px;
  font-family: inherit;
}
/* プレースホルダー(入力前の薄い案内文)の色調整 */
#messageInput::placeholder {
  color: #8a8a8a;
}
/* 送信ボタン・停止ボタンの共通デザイン */
#sendBtn, #stopBtn {
  min-width: 40px;
  height: 40px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 16px;
  font-weight: 700;
  transition: background .15s;
}
#sendBtn { background: var(--accent); color: #fff; }
#sendBtn:hover:not(:disabled) { background: var(--accent-hover); }
#sendBtn:disabled { opacity: .4; cursor: not-allowed; }
#stopBtn { background: var(--danger); color: #fff; }
#stopBtn:hover { filter: brightness(1.1); }
/* AI免責表示(入力欄の下の注意書き) */
#aiDisclaimer {
  text-align: center;
  font-size: 13px;
  color: var(--text-sub);
  margin-top: 6px;
}

/* ============================================================
   応答計測結果の表示(回答の最後に出る小さな情報)
============================================================ */
.response-metrics {
  max-width: 860px;
  margin: 4px auto 0;
  padding: 0 4px;
  font-size: 13.5px;
  color: var(--text-sub);
  opacity: 0.8;
  text-align: right;
  font-family: monospace;
}

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

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

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

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

<body>

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

  <!-- サイドバー下部の操作ボタン群
       data-tooltip="..." がカスタムツールチップ(ホバーで説明ポップ)。
       完成形では「📤 出力」「📥 読込」もありますが、
       これらはステップ⑤で追加します。 -->
  <div id="sidebar-footer">
    <button id="clearAllBtn" data-tooltip="すべての会話履歴を削除します
この操作は元に戻せません">全履歴削除</button>
    <button id="darkModeBtn" data-tooltip="ライトモード / ダークモードを切り替えます">🌙 テーマ</button>
  </div>
</aside>

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

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

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

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

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

  <!-- 入力エリア(画面下部)
       完成形では添付ボタン(🖼📄📝📁)もありますが、
       これらはステップ④で追加します。 -->
  <div id="inputArea">
    <div id="inputRow">
      <textarea id="messageInput" placeholder="メッセージを入力(Enter:送信 / Shift+Enter:改行)" rows="1"></textarea>
      <button id="sendBtn" data-tooltip="送信(Enter でも送信できます)">➤</button>
      <button id="stopBtn" class="hidden" data-tooltip="生成を停止します
それまでに受信した内容は保持されます">■</button>
    </div>
    <div id="aiDisclaimer">AIは不正確な情報を含む場合があります。重要な内容は必ずご自身でも確認してください。</div>
  </div>

</div>

<script>

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

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

/* --- アプリの動作パラメータ(ステップ①ではコード上で固定) ---
   ステップ②で「設定パネルから変更可能」に拡張します。 */
const FIXED_MODEL_NAME = "kimi-k3";        // 使用するモデル(完成形のデフォルト)
const FIXED_REASONING_EFFORT = "low";      // 推論レベル(low / high / max)
const FIXED_MAX_TOKENS = 8192;             // 回答の最大トークン数
const FIXED_RENDER_INTERVAL = 80;          // 画面更新間隔(ミリ秒)
let REQUEST_TIMEOUT_MS = 360000;           // APIタイムアウト(360秒=6分)

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

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

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

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

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

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

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

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

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

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

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

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

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

/* --- 使用するモデル名を返す(ステップ①では固定) --- */
function getSelectedModel() {
  return FIXED_MODEL_NAME;
}

/* --- モデルの表示名を返す(ロールラベル・応答計測に使う) --- */
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();
}

/* --- 思考モードが有効かどうかを返す ---
   Kimi K3 は「常時思考モデル」なので常に true を返します。 */
function isThinkingEnabled() {
  const model = getSelectedModel();
  if (model === "kimi-k3") return true;        // Kimi K3 は常時思考
  if (model === "kimi-k2.7-code") return true; // K2.7 Code は固定ON
  return model === "kimi-k2.6";                // K2.6(ステップ②で分岐)
}

/* --- 画面更新間隔(ミリ秒)を返す(ステップ①では固定値) --- */
function getRenderInterval() {
  return FIXED_RENDER_INTERVAL;
}

/* ============================================================
   【6】UI設定(テーマ)の保存・読み込み
   ------------------------------------------------------------
   ダークモードのON/OFFだけを localStorage に保存します。
   (ステップ②で「モデル名」などもここに追加していきます)
============================================================ */

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

/* --- 保存された画面設定を読み込んで適用する(起動時に1回実行) --- */
function loadUiSettings() {
  try {
    const raw = localStorage.getItem(UI_SETTINGS_KEY);
    if (raw) {
      const s = JSON.parse(raw);
      if (s.dark) document.body.classList.add("dark");
    }
  } catch (e) { /* 無視 */ }
}

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

/* --- 現在使用すべきシステムプロンプトを返す ---
   ステップ①では固定値を返します(ステップ③で拡張)。 */
function getCurrentSystemPrompt() {
  return DEFAULT_SYSTEM_PROMPT;
}

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

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

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

/* --- 会話履歴をlocalStorageに保存する --- */
function saveConversations() {
  try {
    /* 保存用に「必要な項目だけ」を取り出して軽くする */
    const safe = conversations.map(c => ({
      id: c.id,
      title: c.title,
      createdAt: c.createdAt,
      messages: c.messages.map(m => ({
        role: m.role,
        content: m.content,
        metricsText: m.metricsText || undefined
      }))
    }));
    localStorage.setItem(STORAGE_KEY, JSON.stringify(safe));
  } catch (e) {
    /* 容量超過など:古い会話を削って再試行 */
    try {
      conversations = conversations.slice(0, 20);
      localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
    } catch (e2) { /* 保存断念 */ }
  }
}

/* --- localStorageから会話履歴を読み込む --- */
function loadConversations() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw) conversations = JSON.parse(raw) || [];
  } catch (e) {
    conversations = [];
  }
  if (conversations.length > 0) {
    activeConversationId = conversations[0].id;
  } else {
    createConversation();
    return;
  }
  renderConversationList();
  renderActiveConversation();
}

/* --- 会話1件を削除する --- */
function deleteConversation(id) {
  conversations = conversations.filter(c => c.id !== id);
  if (activeConversationId === id) {
    if (conversations.length > 0) {
      activeConversationId = conversations[0].id;
    } else {
      saveConversations();
      renderConversationList();
      createConversation();
      return;
    }
  }
  saveConversations();
  renderConversationList();
  renderActiveConversation();
}

/* --- 最初の質問から会話のタイトルを自動設定する --- */
function generateConversationTitle(text) {
  const conv = getActiveConversation();
  if (!conv) return;
  let title = text.replace(/\s+/g, " ").trim().slice(0, 24);
  if (!title) title = "新しいチャット";
  conv.title = title;
  topbarTitle.textContent = title;
  saveConversations();
  renderConversationList();
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/* --- 現在の会話の「メッセージ一覧」を描画する ---
   ※ステップ①ではウェルカム画面は未実装のため、
     新規チャット時は「何も表示されない状態」になります。 */
function renderActiveConversation() {
  const conv = getActiveConversation();
  if (!conv) return;
  chatContainer.innerHTML = "";
  conv.messages.forEach(m => chatContainer.appendChild(renderMessage(m)));
  scrollToBottom(true);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  /* ----- ④ AIの回答用の「空の器」を先に表示する -----
     回答が少しずつ届く(ストリーミング)ので、受け取るたびに
     この器の中身を書き換えていきます。 */
  const streamWrap = document.createElement("div");
  streamWrap.className = "message assistant";
  streamWrap.innerHTML = `
    <div class="msg-avatar">K</div>
    <div class="msg-body">
      <div class="msg-role">${escapeHtml(getModelDisplayName())}</div>
      <div class="msg-content markdown-body"></div>
    </div>`;
  chatContainer.appendChild(streamWrap);
  currentStreamEl = streamWrap.querySelector(".msg-content");

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

  /* ----- ⑤ API通信(ストリーミング)を実行 ----- */
  try {
    await streamChatCompletion(conv, userText, true);
  } catch (err) {
    if (err && err.name === "AbortError") {
      if (isGenerating) {
        /* タイムアウト等による中断:受信済みの内容を保持して確定 */
        fullResponseText += "\n\n---\n*(タイムアウトまたは接続が中断されました)*";
        showProgressStatus("接続が中断されました");
        finishGeneration(true);
      }
      return;
    }

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

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

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

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

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

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

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

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

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

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

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

  const model = getSelectedModel();

  const request = {
    model: model,                                // モデル名
    messages: messages,                          // 会話履歴
    temperature: 1,
    max_tokens: FIXED_MAX_TOKENS,                // 最大出力トークン数
    stream: useStream                            // ストリーミングON/OFF
  };

  /* Kimi K3:常時思考モデル+reasoning_effort(low / high / max)を送信 */
  if (model === "kimi-k3") {
    request.reasoning_effort = FIXED_REASONING_EFFORT;
  }

  /* ※kimi-k2.7-code・kimi-k2.6 の分岐はステップ②で追加します */
  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(現在の設定: " + FIXED_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(現在の設定: " + FIXED_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】イベント登録(ボタンやキー操作と関数を結び付ける)
   ------------------------------------------------------------
   ※ステップ①では添付・エクスポート/インポート関連の
     イベントは未実装です(ステップ④・⑤で追加)。
============================================================ */
function registerEvents() {
  /* 送信ボタン / 停止ボタン */
  sendBtn.addEventListener("click", sendMessage);
  stopBtn.addEventListener("click", cancelCurrentRequest);

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

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

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

  /* サイドバーの開閉(モバイルとPCで動作を分ける) */
  document.getElementById("toggleSidebarBtn").addEventListener("click", () => {
    if (window.innerWidth <= 768) {
      sidebar.classList.toggle("open");
    } else {
      sidebar.classList.toggle("collapsed");
    }
  });

  /* 全履歴削除 */
  document.getElementById("clearAllBtn").addEventListener("click", () => {
    if (isGenerating) return;
    if (!confirm("すべての会話履歴を削除しますか?この操作は元に戻せません。")) return;
    conversations = [];
    try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* 無視 */ }
    createConversation();
    topbarTitle.textContent = "新しいチャット";
  });

  /* テーマ(ダークモード)切り替え */
  document.getElementById("darkModeBtn").addEventListener("click", () => {
    document.body.classList.toggle("dark");
    updateDarkModeBtn();
    saveUiSettings();
  });

  /* 設定パネルの開閉 */
  document.getElementById("settingsToggleBtn").addEventListener("click", () => {
    settingsPanel.classList.toggle("hidden");
  });

  /* APIキー入力(変数に保持するだけ。保存はしない) */
  apiKeyInput.addEventListener("input", () => {
    apiKey = apiKeyInput.value.trim();
  });

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

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

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

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

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

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

  /* ※ステップ①では以下は未実装です(後のステップで追加)
     ・ウェルカム画面(ステップ⑦)
     ・質問ナビゲーションタイムライン(ステップ⑥)
     ・エクスポート/インポート(ステップ⑤)
     ・添付アップロード(ステップ④) */
}

/* ------------------------------------------------------------
   アプリの起動
   ------------------------------------------------------------
   DOMの準備ができてから init を実行します。
   (<script> が </body> 直前にあるため DOM は既に
     構築済みですが、念のため DOMContentLoaded を使います)
------------------------------------------------------------ */
document.addEventListener("DOMContentLoaded", init);

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

 

ステップ①完成時のアプリ画面:AIチャットアプリ開発入門|Runpod API×KimiをHTML1ファイルで作る講座【会話編】
ステップ①完成時のアプリ画面

 

 

ステップ①のコードをじっくり解説

 

おつかれさまでした。
ここまでで、AIと実際に会話できるアプリが手に入りました。

ここからは、このアプリが「どういう仕組みで動いているのか」を、コードを引用しながら1つずつ解説していきます。

 

解説の進め方(読む前の心構え)

 

最初にお伝えしておきます。
すべてのコードを一度に理解しようとしなくて大丈夫です。

プロのエンジニアでも、初めて読むコードを上から順に全部理解する、という読み方はしません。
まず全体の「地図」を見て、どこに何があるかを把握してから、気になる場所を掘り下げていきます。

 

この解説も、同じ順番で進めます。

1. コード全体の構造マップ:
ファイルの中に「何が・どこに」書かれているかの地図

2. HTML編:
画面の骨組み(要素とid)

3. CSS編:
見た目の仕組み(テーマ変数が鍵)

4. JavaScript編:
動きの仕組み(25のセクションを4つのグループに分けて解説)

 

コード内のコメントと、この解説を行ったり来たりしながら読み進めるのがおすすめです。

 

 

コード全体の構造マップ(ファイルの地図)

 

まず、custom-app-dev.html の中身を「地図」にしてみましょう。このファイルには、3つの言語が1枚に収まっています。

 

■ ファイルの3層構造

 

 言語 ファイル内の場所 役割
 HTML <body> ~ </body> 画面の「骨組み」。
 ボタンや入力欄などの部品を配置する
 CSS <style> ~ </style> 画面の「見た目」。
 色・余白・レイアウトを整える
 JavaScript <script> ~ </script> 画面の「動き」。通信・描画・保存など、
 すべての動作を担当する

 

さらに、心臓部のJavaScriptは「25のセクション」に分かれています。これを役割ごとに4つのグループにまとめたのが、以下の地図です。

 

■ JavaScript 25セクションの地図

 

 グループ セクション やっていること
 A. 設定とデータ管理
(土台)
【1】定数
【2】状態管理
【3】DOM参照
【4】ユーティリティ
【5】モデル関連
【6】UI設定
【7】会話履歴管理
【8】履歴一覧の描画
 アプリの設定値、データの保存場所、
 便利関数など「土台」の部分
 B. 画面描画
(見える部分を作る)
【9】メッセージ描画
【10】Markdown変換
【11】コード装飾
【12】ハイライト
【13】処理状況パネル
【14】思考過程パネル
【15】UI状態管理
 データをもとに、画面上のHTMLを組み立てる
 部分
 C. API通信
(心臓部)
【16】メッセージ送信
【17】リクエスト構築
【18】SSEストリーミング
【19】描画ループ
【20】最終レンダリング
 Runpod APIとやり取りし、
 回答を少しずつ受け取って表示する部分
 D. 補助機能と起動【21】Wake Lock
【22】生成停止
【23】再生成
【24】イベント登録
【25】初期化
 便利な追加機能と、「ボタンと関数の結び付け」「アプリの起動」

 

「設定 → 描画 → 通信 → 起動」という流れになっているのが分かるでしょうか。この地図を頭の片隅に置きながら、各パートを見ていきましょう。

 

 

【HTML編】画面の骨組みを読む

 

HTMLは「どんな部品が、どこに配置されているか」を決める部分です。このアプリのHTMLは、大きく2つのブロックに分かれています。

 

① サイドバー(<aside id="sidebar">)

 

まず画面左側のサイドバーから見てみましょう。該当コードはこちらです(一部抜粋)。

 

📄 HTML:サイドバー(抜粋)
<aside id="sidebar">
  <div id="sidebar-header">
    <button id="newChatBtn">+ 新しいチャット</button>
  </div>
  <div id="conversationList"></div>
  <div id="sidebar-footer">
    <button id="clearAllBtn" data-tooltip="すべての会話履歴を削除します
この操作は元に戻せません">全履歴削除</button>
    <button id="darkModeBtn" data-tooltip="ライトモード / ダークモードを切り替えます">🌙 テーマ</button>
  </div>
</aside>

 

ここで注目してほしいのが、id 属性です。

 

・<b>id="newChatBtn"</b> … 「新しいチャット」ボタン
・<b>id="conversationList"</b> … 会話履歴の一覧が入る場所(中身は空っぽ)
・<b>id="clearAllBtn"</b> / <b>id="darkModeBtn"</b> … 下部の操作ボタン

 

HTMLの時点では、id="conversationList" の中身は空っぽです。会話履歴は、ページを開いたあとにJavaScriptが動的に生成して流し込みます(【8】renderConversationList 関数が担当)。

このように「HTMLでは場所(器)だけ用意して、中身はJavaScriptが後から入れる」というのが、動的なWebアプリの基本的な考え方です。

 

また、ボタンにある <b>data-tooltip</b> という属性にも注目です。これはHTMLの標準機能ではなく、このアプリ独自の「カスタム属性」です。

 

「data-tooltip="説明文"」と書いておくと、CSS(::after 疑似要素)がその説明文を読み取って、ホバー時に見やすいポップを表示してくれます。HTML・CSS・JavaScriptが連携して1つの機能を実現している、良い例です。

 

② メイン領域(<div id="main">)

 

続いて、画面右側のメイン領域です。こちらは「トップバー」「設定パネル」「チャット表示」「入力エリア」の4つで構成されています。

 

📄 HTML:トップバーと設定パネル(抜粋)
<div id="topbar">
  <button id="toggleSidebarBtn" data-tooltip="サイドバーの表示/非表示を切り替えます">☰</button>
  <div id="topbar-title">カスタムAIチャット</div>
  <button id="settingsToggleBtn">⚙ 設定</button>
</div>

<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>
</div>

 

ポイントは、設定パネルに付いている <b>class="hidden"</b> です。

 

第2部で詳しく見ますが、コードのCSSで、このクラスが付いている要素は非表示になります。

つまり、設定パネルは「最初は隠れていて、⚙設定ボタンが押されると現れる」仕組みです。この「開閉」を実際に行うのが、JavaScriptの次の1行です(【24】registerEvents 内)。

 

📄 JavaScript:設定パネルの開閉(抜粋)
document.getElementById("settingsToggleBtn").addEventListener("click", () => {
  settingsPanel.classList.toggle("hidden");
});

 

読み解いてみましょう。

 

1. document.getElementById("settingsToggleBtn") … idが settingsToggleBtn の要素(⚙設定ボタン)を探す
2. .addEventListener("click", …) … 「クリックされたら、次の処理を実行して」と予約する
3. settingsPanel.classList.toggle("hidden") … 設定パネルの hidden クラスを「付いていれば外す、無ければ付ける」(トグル)

 

たったこれだけで「ボタンを押すたびに、パネルが開いたり閉じたりする」動きが実現します。

「HTMLにidを付ける → JavaScriptでそのidを捕まえる → イベント(クリックなど)に処理を結び付ける」という3ステップの連携は、このアプリ全体で何度も出てくる基本パターンです。

 

③ 入力エリア(<div id="inputArea">)

 

最後に、画面下部の入力エリアです。

 

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

 

ここでのポイントは3つです。

 

・<textarea> が質問の入力欄
id="messageInput" で、JavaScript(sendMessage 関数)がこの中身を value プロパティで読み取ります。

・停止ボタンには最初から hidden が付いている
「AIが回答を生成している間だけ表示したい」ので、最初は隠しておき、生成開始時にJavaScript(updateUiState 関数)が hidden を外します。送信ボタンと停止ボタンを、hidden クラスの付け外しで交互に切り替えているわけです。

・id="inputRow" は「カード」の器
CSS編で詳しく見ますが、この inputRow が「背景・枠線・角丸・影のあるカード」になっていて、その中にテキストエリアとボタンが収まっています。

 

ここまで読むと、HTMLの役割がはっきりします。「部品を配置し、idで目印を付ける」こと。あとの「動き」はすべてJavaScriptが担当します。

 

次は【CSS編】です。「色がなぜ1か所の変更で全体に反映されるのか(CSS変数)」「質問が右に寄らないレイアウトの仕組み」など、見た目の裏側を解説します。

 

【CSS編】見た目の仕組みを読む

 

続いてCSS編です。
CSSは「アプリの見た目」を定義する部分で、今回のコードでは <style> ~ </style> の間にすべて収まっています。

 

CSS編では、以下の8つのポイントを順に見ていきます。

 

1. テーマ変数:
色を「変数」で一元管理する仕組み

2. ベースレイアウト:
画面全体の骨組み

3. メッセージ構造:
質問が右に寄らないレイアウトの秘密

4. カード型の入力エリア:
完成形の特徴的な入力欄

5. カスタムツールチップ:
data-tooltip でポップを出す仕組み

6. 処理状況パネル・思考過程パネル:
スピナーと開閉の仕組み

7. 長い質問の折りたたみ:
グラデーションの演出

8. レスポンシブ:
モバイル対応の仕組み

 

 

① テーマ変数:色を「変数」で一元管理する仕組み

 

まず、CSSの冒頭にある「テーマ変数」から見てみましょう。このアプリの配色の心臓部です。

 

📄 CSS:テーマ変数(抜粋)
/* ライトモード:目に優しい配色 */
:root {
  --bg: #f7f5f0;        /* 画面全体の背景色 */
  --bg-sidebar: #efede6; /* サイドバー背景 */
  --bg-input: #ffffff;  /* 入力欄背景 */
  --text: #2c2c2c;      /* 文字色 */
  --text-sub: #5a5a5a;  /* 薄い文字色 */
  --border: #d8d5cc;    /* 境界線の色 */
  --accent: #4a5dd8;    /* アクセントカラー */
  --accent-hover: #3d4ec4;
  --danger: #d64545;    /* 危険色(削除・停止など) */
}

/* ダークモード(bodyタグに dark クラスが付いた時適用) */
body.dark {
  --bg: #17181c;
  --bg-sidebar: #1e1f24;
  --bg-input: #2a2a2a;
  --text: #e8e8ec;
  --text-sub: #9a9aa5;
  --border: #34353c;
  --accent: #7a86ff;
  --accent-hover: #9198ff;
  --danger: #f16469;
}

 

ここで使われているのが CSS変数(カスタムプロパティ) という仕組みです。

 

「--bg」や「--text」のように、色に名前を付けて登録しておき、CSSの各所では var(--bg) のように「名前で呼び出して」使います。

 

例えば、body の背景色は以下のように指定されています。

 

📄 CSS:var() での呼び出し例
body {
  background: var(--bg);   /* 「--bg」という名前の色を使う */
  color: var(--text);
}

 

この仕組みのすごいところは、ダークモードの切り替えが「たった1か所」で完結することです。

 

bodyタグに dark クラスが付くと、「body.dark」のブロックに書かれた変数の値が、:root の値を上書きします。すると、var(--bg) を使っている全箇所の色が、一斉にダーク用の色に変わります。

 

つまり、テーマ切り替えボタンがやっていることは「bodyタグに dark クラスを付けたり外したりする」ことだけ(【24】registerEvents 内の darkModeBtn クリック処理)。
個別の部品の色を1つずつ書き換える必要がない、とても効率的な設計です。

 

 

② ベースレイアウト:画面全体の骨組み

 

続いて、画面全体の骨組みを決めている部分です。

 

📄 CSS:ベースレイアウト(抜粋)
body {
  background: var(--bg);
  color: var(--text);
  display: flex;              /* 子要素(サイドバーとメイン領域)を横並びに */
  overflow: hidden;           /* 画面全体ではスクロールさせない */
  transition: background .2s, color .2s;
}
#main {
  flex: 1;                    /* 残りの横幅をすべて使う */
  display: flex;
  flex-direction: column;     /* 中身は縦に並べる */
  min-width: 0;
}

 

ポイントは2つです。

 

・display: flex で横並び

body の直下にあるのは「<aside id="sidebar">」と「<div id="main">」の2つでした。display: flex を指定すると、子要素が横に並びます。これが「左にサイドバー、右にメイン領域」という基本構図を作っています。

 

・overflow: hidden で「領域ごとのスクロール」を実現

body に overflow: hidden を指定すると、画面全体はスクロールしなくなります。その代わり、履歴一覧(#conversationList)やチャット欄(#chatContainer)といった各領域の内側で個別にスクロールする仕組みです。

これにより、ヘッダーや入力欄が常に画面に固定されたまま、チャット欄だけがスクロールする、チャットアプリらしい動きが実現しています。

 

 

③ メッセージ構造:質問が右に寄らないレイアウトの秘密

 

次に、チャットのメッセージ表示です。「あなたの質問」と「AIの回答」が、きれいに整列して表示される仕組みを見てみましょう。

 

📄 CSS:メッセージ構造(抜粋)
/* メッセージ1件分の枠(アバターと本文を横並びにする) */
.message {
  max-width: 860px;
  margin: 0 auto 22px;   /* 左右autoで中央寄せ、下に22pxの余白 */
  display: flex;         /* アバターと本文を横並びに */
  gap: 12px;
}
.message.user .msg-content {
  background: var(--bg-user-msg);   /* 質問の吹き出し色 */
  padding: 10px 14px;
  border-radius: 12px;
  white-space: pre-wrap;            /* 改行をそのまま表示 */
}

 

このアプリでは、質問と回答の両方が「左揃え」で表示されます。

 

その秘密は以下の2行です。

 

・max-width: 860px … メッセージの横幅の上限を860pxに統一
・margin: 0 auto 22px … 左右のマージンを auto(自動調整)にして、メッセージ全体を中央に配置

 

さらに display: flex によって、丸いアバター(「U」「K」)と本文が横並びになります。ユーザーとAIで「左右を入れ替える」のではなく「アバターの色と吹き出しの色で区別する」という、完成形と同じデザイン方針です。

 

 

④ カード型の入力エリア(#inputRow)

 

完成形の大きな特徴のひとつが、画面下部の「カード型」の入力エリアです。

 

📄 CSS:入力エリア(抜粋)
#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);
}

 

ここでの工夫は、「見た目の器」と「実際の入力欄」を分離していることです。

 

1. #inputRow(器) … 背景色・枠線・角丸・影を持つ「カード」
2. #messageInput(入力欄) … 背景も枠線も透明にして、カードの中に溶け込ませる

 

これにより、あたかも「カード全体が入力欄」のように見える、洗練されたデザインになっています。

 

また、:focus-within という疑似クラスにも注目です。これは「その要素の中のどこかがフォーカスされている間」に適用されるスタイルで、入力欄をクリックしている間だけ、カードの枠をアクセント色に変えています。JavaScriptを使わず、CSSだけで「入力中はカードを光らせる」演出ができる便利な仕組みです。

 

 

⑤ カスタムツールチップ(data-tooltip)

 

HTML編で触れた「data-tooltip」属性。それを実現しているのが、このCSSです。

 

📄 CSS:カスタムツールチップ(抜粋)
[data-tooltip] { position: relative; }
/* 吹き出し本体(デフォルト:上向き表示) */
[data-tooltip]::after {
  content: attr(data-tooltip);   /* 属性の文字列をそのまま表示 */
  position: absolute;
  bottom: calc(100% + 10px);     /* 要素の上に配置 */
  left: 50%;
  transform: translateX(-50%);
  background: var(--bg-sidebar);
  color: var(--text);
  border-radius: 8px;
  padding: 10px 14px;
  white-space: pre-line;         /* 属性内の改行をそのまま反映 */
  opacity: 0;                    /* 普段は透明 */
  visibility: hidden;            /* 普段は非表示 */
  transition: opacity .15s;
  pointer-events: none;          /* ポップ自体はクリックを透過 */
}
[data-tooltip]:hover::after {
  opacity: 1;                    /* ホバーで表示 */
  visibility: visible;
}

 

最大のポイントは、この1行です。

 

content: attr(data-tooltip);

 

::after という「疑似要素」(HTMLには書かれていない、CSSが作り出す仮想的な要素)を使い、属性に書かれた文字列を、そのままポップの中身として表示しています。

 

つまり、HTMLで data-tooltip="説明文" と書くだけで、CSS側が自動で「説明文のポップ」を生成してくれる仕組みです。普段は opacity: 0 と visibility: hidden で隠しておき、:hover の時だけ表示します。

 

また、white-space: pre-line のおかげで、属性内の改行(Enterキーの改行)がそのままポップ内の改行になります。
複数行にわたる説明ポップは、この指定によって実現されています。

 

 

⑥ 処理状況パネル・思考過程パネル:スピナーと開閉の仕組み

 

回答の生成中に表示される「くるくる回るスピナー」と、パネルの開閉の仕組みです。

 

📄 CSS:スピナーとパネル開閉(抜粋)
/* くるくる回るスピナー */
.spinner {
  width: 14px; height: 14px;
  border: 2px solid var(--border);
  border-top-color: var(--accent);   /* 上側だけ色を変える */
  border-radius: 50%;
  animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* 思考過程パネルの本体(普段は非表示) */
.reasoning-body {
  display: none;
  max-height: 300px;
  overflow-y: auto;
}
/* openクラスが付いたら表示 */
.reasoning-panel.open .reasoning-body { display: block; }

 

・スピナーの秘密

正円(border-radius: 50%)の枠線の「上側だけ」色を変え、@keyframes で0.8秒かけて360度回転させ続けています。画像を使わず、CSSだけでローディング表示を作る定番のテクニックです。

 

・パネル開閉の秘密

本体(.reasoning-body)は display: none で隠しておき、親要素に open クラスが付いた時だけ display: block で表示します。この「open クラスの付け外し」はJavaScript側で行います(ヘッダークリック時の panel.classList.toggle("open"))。

「設定パネルの hidden クラス」と同じく、CSSに「状態」を定義し、JavaScriptでその状態を切り替えるという、先ほどから繰り返し出てくる設計パターンです。

 

 

⑦ 長い質問の折りたたみ:グラデーションの演出

 

500文字を超える長い質問は、自動で折りたたまれます。その際の「続きがある」ことを示す演出もCSSで作られています。

 

📄 CSS:質問の折りたたみ(抜粋)
/* 折りたたみ状態:高さを制限して続きを隠す */
.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;
}

 

仕組みは2段階です。

 

1. max-height + overflow: hidden で、高さを120pxに制限して、はみ出た部分を隠す
2. ::after のグラデーション で、下端に「透明 → 吹き出しと同じ色」の薄い膜を重ねる

 

このグラデーションのおかげで、文章の下端が「ふわっと消えていく」ように見え、直感的に「下に続きがある」と伝わります。linear-gradient の終点を吹き出しと同じ色(var(--bg-user-msg))にしているので、自然に馴染むのもポイントです。

 

 

⑧ レスポンシブ:モバイル対応の仕組み

 

最後に、スマホやタブレットでも快適に使えるようにする「レスポンシブ対応」です。

 

📄 CSS:レスポンシブ対応(抜粋)
@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);
  }
}

 

@media (max-width: 768px) は「画面幅が768px以下の時だけ、この中のCSSを適用する」という指定です(メディアクエリ)。

 

モバイル時のサイドバーの動きが工夫されています。

 

1. position: fixed で、画面に「貼り付く」状態にする
2. margin-left: -260px で、自分の幅(260px)と同じ分だけ左にずらし、画面外に隠す
3. open クラスが付くと margin-left: 0 になり、画面内にスライドイン

 

PC表示では画面内に常駐しているサイドバーが、モバイルでは「☰ボタンで開閉する引き出し」に変わる仕組みです。切り替えのopen クラスはJavaScript(registerEvents 内のサイドバー開閉処理)が操作しています。

 

 

CSS編のまとめ

 

CSS編で押さえておきたい要点は、以下の3つです。

 

1. CSS変数(--〇〇 と var())
色を一元管理し、「body.dark クラスの付け外しだけで全テーマが切り替わる」仕組みを実現している。

 

2. 状態の切り替えは「クラスの付け外し」
hidden・open・collapsed・tooltip-below など、CSSが「状態」を定義し、JavaScriptがそれを切り替える。この分業が、このアプリ全体の設計の骨格です。

 

3. 疑似要素(::after / ::before)の活用
ツールチップのポップ、折りたたみのグラデーションなど、HTMLを汚さずに装飾を追加している。

 

次はいよいよ【JavaScript編】です。まず「設定とデータ管理(セクション【1】〜【8】)」として、アプリの土台となる部分

――定数、状態変数、localStorageによる履歴保存、サイドバーの履歴一覧の生成――

を解説します。

 

【JavaScript編①】設定とデータ管理(セクション【1】〜【8】)

 

いよいよ、このアプリの心臓部であるJavaScriptの解説です。

JavaScript編は、構造マップで見た4つのグループに沿って、4回に分けて進めます。今回は最初のグループ「A. 設定とデータ管理」(セクション【1】〜【8】)です。

 

ここは、アプリ全体の「土台」にあたる部分です。

・どこに接続するか(定数)
・今何が起きているか(状態)
・画面のどの部品を操作するか(DOM参照)
・データをどう保存するか(localStorage)

といった、後のすべての処理の基盤を作っています。
順に見ていきましょう。

 

 

【1】定数:アプリの「設定値」を1か所に集約する

 

まず、JavaScriptの冒頭にある「定数」のセクションです。ここには、アプリの動作を決める設定値が集められています。

 

📄 JavaScript【1】定数(抜粋)
/* 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;             // 回答の最大トークン数
const FIXED_RENDER_INTERVAL = 80;          // 画面更新間隔(ミリ秒)
let REQUEST_TIMEOUT_MS = 360000;           // APIタイムアウト(360秒)

 

ポイントは、設定値を「バラバラに書かず、冒頭に集約している」ことです。

 

例えば、もし「画面更新間隔を80msから100msに変えたい」と思った時、この FIXED_RENDER_INTERVAL の値を1か所書き換えるだけで済みます。もし各所に「80」と直接書かれていたら、全部を探して書き換えなければなりません。

「変更する可能性のある値は、名前を付けて1か所にまとめる」というのは、プログラミングの基本かつ重要な習慣です。

 

また、const と let という2つの宣言が使い分けられていることにも注目です。

 

・const(コンスト) … 一度値を入れたら、あとから変更できない(定数)
・let(レット) … あとから値を変更できる(変数)

 

「変わらない設定値」は const で宣言して、うっかり書き換えてしまう事故を防いでいます。

 

 

【2】状態管理:アプリが「今」を覚えておく変数

 

続いて「状態管理」のセクションです。
ここには、アプリが動いている間ずっと覚えておくべきデータが並んでいます。

 

📄 JavaScript【2】状態管理(抜粋)
let conversations = [];            // 全会話 [{id, title, messages:[...]}]
let activeConversationId = null;   // 現在の会話ID
let apiKey = "";                   // APIキー(メモリ上のみ保持。保存しない)
let isGenerating = false;          // 生成中フラグ
let fullResponseText = "";         // 受信回答バッファ
let currentStreamEl = null;        // ストリーミング中の回答DOM要素
let autoScrollEnabled = true;      // 自動スクロール有効フラグ

 

ここで最も重要なのが、conversations という変数です。これが「アプリの全データ」の入れ物です。

 

この変数は「配列(はいれつ)」で、その中に「会話1件分のオブジェクト」が複数入っています。構造を図にすると、以下のようになります。

 

📄 conversations のデータ構造(イメージ)
conversations = [
  {
    id: "abc123",              // 会話の識別番号
    title: "HTMLの作り方",      // 会話のタイトル
    messages: [                // この会話のメッセージ一覧
      { role: "user", content: "HTMLの作り方を教えて" },
      { role: "assistant", content: "はい、HTMLは..." }
    ]
  },
  { id: "def456", title: "新しいチャット", messages: [] }
]

 

「どの会話を開いているか」を示すのが activeConversationId で、「今、AIが回答を生成している途中か」を示すのが isGenerating です。

 

このように、アプリの「今の状態」を変数で管理しておくことで、「生成中は送信ボタンを押せないようにする」「生成中は会話の切り替えをしない」といった制御ができるようになります。

 

 

【3】DOM参照:「画面の部品」を変数に入れておく

 

HTML編で「id が JavaScript との架け橋」と説明しました。
その架け橋を実際に渡っているのが、このセクションです。

 

📄 JavaScript【3】DOM参照(抜粋)
/* $ は「document.querySelector」の短縮形。
   CSSセレクタ("#id名" など)で要素を1つ取得します。 */
const $ = (sel) => document.querySelector(sel);
const sidebar = $("#sidebar");
const chatContainer = $("#chatContainer");
const messageInput = $("#messageInput");
const sendBtn = $("#sendBtn");
const apiKeyInput = $("#apiKeyInput");
const settingsPanel = $("#settingsPanel");

 

document.querySelector は「CSSセレクタと同じ書き方で、HTMLの要素を1つ探してくる」命令です。

 

「$("#sidebar")」の "#sidebar" は「id が sidebar の要素」という意味で、これはCSSで「#sidebar { ... }」と書くのと同じ指定方法です。こうして取得した要素を変数に入れておくと、あとは「変数.プロパティ」や「変数.メソッド()」で自由に操作できます。

 

冒頭の「const $ = (sel) => document.querySelector(sel);」は、アロー関数という書き方で、「$ という名前の、querySelector の短縮版」を作っています。何度も書く命令を短くするための工夫です。

 

 

【4】ユーティリティ:よく使う「小さな便利関数」

 

次は、特定の大きな機能を持たない「小さな便利関数」を集めたセクションです。
ここでは特に重要な escapeHtml を見てみましょう。

 

📄 JavaScript【4】escapeHtml(抜粋)
/* --- HTMLの特殊文字を「無害な文字」に変換する(XSS対策) --- */
function escapeHtml(str) {
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

 

この関数は、セキュリティ上とても重要です。

 

もしユーザーが「<script>悪いコード</script>」と入力し、それをそのままHTMLに埋め込んで表示してしまうと、悪意のあるコードが実行されてしまいます。これが「XSS(クロスサイトスクリプティング)攻撃」です。

 

escapeHtml は、「<」を「&lt;」という「ただの文字として表示される形」に変換します。これにより、入力された文字は「コードとして実行されず、ただの文字として安全に表示される」ようになります。

「ユーザーの入力をそのままHTMLに埋め込まない」というのは、Web開発の鉄則です。

 

 

【5】モデル関連:ステップ②への「拡張の仕掛け」

 

ここは、今は「固定値を返すだけ」ですが、実はステップ②への拡張のための重要な仕掛けが隠れています。

 

📄 JavaScript【5】getSelectedModel(抜粋)
/* --- 使用するモデル名を返す(ステップ①では固定) --- */
function getSelectedModel() {
  return FIXED_MODEL_NAME;
}

 

「固定値なら、直接 FIXED_MODEL_NAME と書けばいいのでは?」と思うかもしれません。しかし、あえて「関数で包んで返す」形にしているのには理由があります。

 

ステップ②では、この関数の中身を「設定パネルの選択値を読んで返す」形に書き換えます。すると、getSelectedModel() を呼んでいる他のすべてのコードは、1行も変更せずにそのまま動き続けます。

 

「値を直接使うのではなく、関数経由で取得する」ようにしておくと、将来その取得方法が変わっても、関数の中身だけを直せば済みます。これは「変更に強いコード」を書くための、プロの基本的なテクニックです。

 

 

【6】UI設定:localStorage でテーマを保存する

 

ここでは、ダークモードのON/OFFを「ブラウザを閉じても覚えておく」仕組みを作っています。

 

📄 JavaScript【6】saveUiSettings / loadUiSettings(抜粋)
/* --- 現在の画面設定をlocalStorageに保存する --- */
function saveUiSettings() {
  try {
    const settings = {
      dark: document.body.classList.contains("dark")
    };
    localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(settings));
  } catch (e) { /* localStorage不可時は無視 */ }
}

/* --- 保存された画面設定を読み込んで適用する(起動時に1回実行) --- */
function loadUiSettings() {
  try {
    const raw = localStorage.getItem(UI_SETTINGS_KEY);
    if (raw) {
      const s = JSON.parse(raw);
      if (s.dark) document.body.classList.add("dark");
    }
  } catch (e) { /* 無視 */ }
}

 

localStorage は、ブラウザに備わった「小さな保存領域」です。ここに保存したデータは、ブラウザを閉じても、パソコンを再起動しても消えません。

 

ここで重要なルールが2つあります。

 

1. localStorage には「文字列」しか保存できない

そのため、{ dark: true } というオブジェクトを、JSON.stringify で「{"dark":true}」という文字列に変換して保存します。読み込む時は、逆に JSON.parse で文字列をオブジェクトに戻します。この「保存する時は文字列化、読む時は復元」というセットは、localStorage を使う際の定番パターンです。

 

2. APIキーは保存しない

セキュリティのため、APIキーは localStorage には一切保存せず、「変数(メモリ)」にのみ保持します。これが「画面を更新するとAPIキーが消える」理由です。

 

 

【7】会話履歴管理:localStorage で会話を保存する

 

UI設定と同じ localStorage の仕組みを使って、今度は「会話の履歴」を保存しています。

 

📄 JavaScript【7】saveConversations(抜粋)
/* --- 会話履歴をlocalStorageに保存する --- */
function saveConversations() {
  try {
    /* 保存用に「必要な項目だけ」を取り出して軽くする */
    const safe = conversations.map(c => ({
      id: c.id,
      title: c.title,
      createdAt: c.createdAt,
      messages: c.messages.map(m => ({
        role: m.role,
        content: m.content,
        metricsText: m.metricsText || undefined
      }))
    }));
    localStorage.setItem(STORAGE_KEY, JSON.stringify(safe));
  } catch (e) {
    /* 容量超過など:古い会話を削って再試行 */
    try {
      conversations = conversations.slice(0, 20);
      localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
    } catch (e2) { /* 保存断念 */ }
  }
}

 

ここには2つの工夫があります。

 

1. 「必要な項目だけ」を取り出して保存

map を使って、保存に必要な項目(id・title・messages など)だけを取り出しています。これは、将来「保存したくない一時的なデータ」(例:添付ファイルの大きなバイナリデータ)が増えた時に、それを保存から除外するための準備です(ステップ④で効いてきます)。

 

2. try ~ catch で「失敗してもアプリを止めない」

localStorage には容量の上限(約5〜10MB)があります。長文の履歴が増えすぎると、保存に失敗することがあります。その時アプリごと落ちないよう、catch(失敗した時の処理)で「古い会話を削って再試行する」というフォールバック(代替手段)を用意しています。

「うまくいかなかった場合も考えておく」というのは、堅牢なプログラムを書くうえで欠かせない考え方です。

 

 

【8】履歴一覧の描画:「データ」を「画面」に変える

 

設定とデータ管理の最後は、保存されている会話の一覧を、サイドバーに「描画(表示)」する関数です。

 

📄 JavaScript【8】renderConversationList(抜粋)
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;

    /* 行全体が押された時:その会話を開く */
    item.addEventListener("click", (e) => {
      activeConversationId = c.id;
      topbarTitle.textContent = c.title;
      renderConversationList();
      renderActiveConversation();
      if (window.innerWidth <= 768) sidebar.classList.remove("open");
    });

    item.appendChild(title);
    conversationList.appendChild(item);
  });
}

 

この関数は、「データの描画」の基本的な流れを教えてくれます。

 

1. 一度空にする(conversationList.innerHTML = "")
2. データを1件ずつループ(conversations.forEach)
3. 要素を作って中身を入れる(createElement と textContent)
4. クリックされた時の処理を結び付ける(addEventListener)
5. 画面に追加する(appendChild)

 

特に「1. 一度空にする」は重要です。
もし空にせずに追加し続けると、データが変わるたびに履歴がどんどん重複して表示されてしまいます。
「空にしてから、最新のデータで作り直す」
のが、画面を常に正しい状態に保つための鉄則です。

 

また、クリック時の処理で renderConversationList() と renderActiveConversation() の両方を呼んでいる点にも注目です。会話を切り替えたら、「選択中のハイライト」を更新するために一覧を、そして「表示するメッセージ」を切り替えるために会話表示を、それぞれ再描画しています。

 

 

JavaScript編①のまとめ

 

今回の「設定とデータ管理」で押さえておきたい要点は、以下の4つです。

 

1. 設定値は const で1か所に集約する
変更が必要になった時、1か所を直すだけで済む。

 

2. アプリの「今の状態」は変数で管理する
conversations や isGenerating といった変数が、「生成中は操作を制限する」などの制御を可能にする。

 

3. localStorage は「文字列で保存・復元して読む・失敗に備える」
JSON.stringify / JSON.parse のセットと、try〜catch のフォールバックが定番。

 

4. 「値は関数経由で取得する」と拡張に強い
getSelectedModel() のように関数で包んでおくと、ステップ②で中身を差し替えるだけで済む。

 

次は【JavaScript編②】画面描画(セクション【9】〜【15】)です。

保存したデータを、どうやって「メッセージ」として画面に組み立てているのか――Markdownの変換、コードブロックへの色付け、処理状況パネル、思考過程パネルの仕組みを解説します。

 

【JavaScript編②】画面描画(セクション【9】〜【15】)

 

続いて、JavaScript編の第2グループ「B. 画面描画」(セクション【9】〜【15】)です。

 

ここは、保存されているデータをもとに、「メッセージ」や「処理状況パネル」「思考過程パネル」といった、画面に見える部品を組み立てる部分です。

AIの回答がきれいに表示される仕組みや、生成中の「今、何が起きているか」を伝える仕組みが、すべてここに集まっています。

 

 

【9】メッセージ描画:1件のメッセージを組み立てる

 

まず、チャット欄に表示される「1件のメッセージ」を作る renderMessage 関数です。
これは、あなたの質問とAIの回答の両方を担当します。

 

📄 JavaScript【9】renderMessage(抜粋・構造)
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);    // シンタックスハイライト
  } else {
    /* ----- あなたの質問:無害化したテキストをそのまま表示 ----- */
    content.textContent = msg.content;
  }

  body.appendChild(roleLabel);
  body.appendChild(content);
  wrap.appendChild(avatar);
  wrap.appendChild(body);
  return wrap;
}

 

この関数がやっていることを、順に分解してみましょう。

 

1. msg.role で「質問」と「回答」を分岐する

msg.role には "user"(あなた)か "assistant"(AI)が入っています。
この値によって、クラス名("message user" か "message assistant")や、アバターの文字("U" か "K")、ロールラベル("あなた" か "Kimi K3")が切り替わります。

 

2. 質問と回答で「表示方法」を変える

ここが重要な分岐です。

 

・あなたの質問(user)
… content.textContent = msg.content と、textContent でそのまま表示。
これだけで、入力が「ただの文字」として安全に表示されます(XSS対策)。

・AIの回答(assistant)
… renderMarkdown(msg.content) で「Markdown → 安全なHTML」に変換してから innerHTML で表示。
これにより、AIが返す「**太字**」や「# 見出し」「```コード」が、きれいに装飾されて表示されます。

 

「質問はプレーンテキスト、回答はMarkdown」という使い分けが、チャットアプリの定番です。

 

3. 回答には3段階の「仕上げ」を施す

AIの回答の部分では、以下の3つの関数を順に呼んでいます。

 

① renderMarkdown:
Markdownを安全なHTMLに変換

② addCopyButtons:
コードブロックに「言語名+コピーボタン」を追加

③ applySyntaxHighlight:
コードブロックに色を付ける

 

この3つの関数が、次の【10】〜【12】で登場します。

 

 

【10】Markdown変換:AIの回答をきれいに見せる心臓部

 

AIの回答を「ただの文字の羅列」から「読みやすい文書」に変える、renderMarkdown 関数です。

 

📄 JavaScript【10】renderMarkdown(抜粋)
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>";
  }
}

 

この関数は、外部ライブラリ(CDNから読み込んだもの)を2つ使っています。

 

① marked(マークド):Markdown → HTML 変換

marked.parse(text) で、Markdown記法の文字列をHTMLに変換します。「**太字**」は「<strong>」に、「# 見出し」は「<h1>」に、「```」で囲まれた部分は「<pre><code>」に変わります。

 

② DOMPurify(ドムピュリファイ):HTMLの安全チェック

marked が生成したHTMLを、そのまま使うのは危険です。もしAIの回答に悪意のある「<script>」タグなどが含まれていたら、実行されてしまいます。

そこで DOMPurify.sanitize() で「危険なタグや属性を取り除く」処理を挟みます。FORBID_TAGS で指定した "script" や "iframe" などは、たとえ含まれていても削除されます。

「変換(marked)→ 安全化(DOMPurify)」という2段構えが、AIの出力を安全に表示するための鉄則です。

 

・フォールバック(代替手段)も用意

typeof marked !== "undefined" という条件分岐で、「ライブラリが読み込めなかった場合(オフラインなど)」にも対応しています。その場合は、装飾なしのプレーンテキストとして表示します。「ライブラリが使えなくても、アプリが壊れない」ようにする配慮です。

 

 

【11】コードブロック装飾:「言語名+コピーボタン」を追加

 

AIの回答に含まれるコードブロック(<pre><code>)に、「言語名の表示」と「コピーボタン」を追加する addCopyButtons 関数です。

 

📄 JavaScript【11】addCopyButtons(抜粋)
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 header = document.createElement("div");
    header.className = "code-header";
    /* …言語ラベルとコピーボタンを作成… */
    pre.insertBefore(header, pre.firstChild);
  });
}

 

ポイントは2つです。

 

1. 言語名を「class属性」から読み取る

marked が生成するコードブロックは、「<code class="language-javascript">」のように、class に「language-言語名」が付いています。
この関数名を取り出して、ヘッダーに「JavaScript」と表示しています。

 

2. 二重装飾の防止

if (pre.querySelector(".code-header")) return; という1行で、「すでにヘッダーが付いているコードブロックは処理しない」ようにしています。
同じコードブロックに何度もヘッダーが付くのを防ぐためです。

 

 

【12】シンタックスハイライト:コードに色を付ける

 

コードブロックに「色」を付けて見やすくする applySyntaxHighlight 関数です。

 

📄 JavaScript【12】applySyntaxHighlight(抜粋)
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) { /* ハイライト失敗時はそのまま表示 */ }
  });
}

 

ここで使っているのが、外部ライブラリの highlight.js です。

 

hljs.highlightElement(code) と1行呼ぶだけで、コードブロックの中身(キーワード・文字列・コメントなど)が自動で色付けされます。

 

if (!window.hljs) return; という条件で「ライブラリが読み込めていなければ何もしない」ようにしています。これも renderMarkdown と同じく、「ライブラリが使えなくてもエラーで止まらない」ための配慮です。

 

 

【13】処理状況パネル:「今、何をしているか」を伝える

 

回答の生成中に表示される

「処理状況パネル」(「質問を分析しています…」「回答を受信しています…」などの表示)

を作る部分です。

 

📄 JavaScript【13】createProgressPanel / showProgressStatus(抜粋)
/* --- 処理状況パネルを作成してチャット欄に追加する --- */
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);
  return panel;
}

 

このパネルは、2つの部分で構成されています。

 

1. ヘッダー
… 「スピナー(くるくる回る印)+現在のステータス」を表示。ク
リックでログを開閉できる。

2. ログの一覧
… これまでの処理の流れを「✓完了」「●実行中」で時系列に表示する(普段は閉じている)。

 

そして、処理の進行に合わせてステータスを更新するのが showProgressStatus 関数です。

 

📄 JavaScript【13】showProgressStatus(抜粋)
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);
  currentProgressState.lastStatus = status;
}

 

この関数の工夫は、「新しいステータスが来たら、直前のものを『完了』に変える」という仕組みです。

 

例えば、

「リクエストを準備しています…」→「AIが質問を分析しています…」

と進んだ時、直前の「準備中」が「✓完了」に変わり、新しい「分析中」が「●実行中」になります。
これにより、処理が順に進んでいる様子が、視覚的に分かりやすく伝わります。

 

また、currentProgressState.lastStatus === status で「同じステータスの連続更新」を無視しているのもポイントです。
無駄なログの重複を防いでいます。

 

 

【14】思考過程パネル:AIの「考え」を可視化する

 

Kimi K3 のような「思考モデル」が出力する「思考過程」を表示するパネルです。

 

📄 JavaScript【14】createReasoningPanel
/* --- 思考過程パネルを作成する(回答本文の「前」に挿入) --- */
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";   // 受信中は開いた状態
  /* …ヘッダー( 思考過程+スピナー)と本文を作成… */
  body.insertBefore(panel, contentEl);   // 回答本文の前に挿入
  return bodyEl;
}

 

このパネルの「一生」は、以下のような流れです。

 

1. 生成開始時
… createReasoningPanel で、open クラス付き(開いた状態)+スピナー表示で作成され、回答本文の「前」に挿入される。

2. 生成中
… 思考が届くたびに updateReasoningPanel で本文が更新され、考えている様子がリアルタイムで見える。

3. 生成完了時
… finishGeneration() という「終了処理の専任関数」が、回答本文だけを会話データに保存した後、チャット欄を丸ごと描き直します。
この時、streamWrap ごと思考過程パネルが消去されます。

 

streamWrap は sendMessage() 内で作られる回答受信用の「使い捨ての器」です。
思考過程パネルはこの器の中に挿入されるため、器が消えれば一緒に消える運命にあります。

 

では、なぜ「完了後に再表示できない」のでしょうか。
理由は2つあります。

 

1. 思考過程のテキストが、どこにも保存されない
受信した思考過程は fullReasoningText という変数に蓄積されますが、回答本文(fullResponseText)とは異なり、会話データにも localStorage にも保存されません。

 

2. 描き直す側に、思考過程を表示する仕組みがない
チャット欄の描き直しを担当する renderMessage()(【9】)は、本文と応答計測だけを表示します。
思考過程パネルを再構築する処理は、そもそも存在しないのです。

 

つまり、今回のプログラムの仕様では、「保存されていない」うえに「再表示する仕組みもない」ため、思考過程は「生成中だけのライブ配信」として楽しむ設計になっています。
(回答出力後に思考過程を残す仕様にすることもできます)

 

この「消える仕組み」の根拠となるコード(finishGeneration() と renderActiveConversation())は、【16】sendMessage の解説の最後(【16】補足)で詳しく見ていきます。

 

 

【15】UI状態管理:ボタンと入力欄の「有効・無効」を切り替える

 

画面描画の最後は、「生成中かどうか」に応じて、ボタンや入力欄の状態を切り替える updateUiState 関数です。

 

📄 JavaScript【15】updateUiState / autoResizeTextarea(抜粋)
/* --- 生成中は「停止」、待機中は「送信」を表示する --- */
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";
  messageInput.style.height = Math.min(messageInput.scrollHeight, 200) + "px";
}

 

updateUiStateは、状態変数の isGenerating(生成中フラグ)を見て、以下の3つを切り替えています。

 

1. 送信ボタン
… 生成中は hidden を付けて隠す

2. 停止ボタン
… 生成中は hidden を外して表示する

3. 入力欄
… 生成中は disabled = true で「入力できない状態」にする

 

「生成中は新しい質問を送れないようにする」ことで、二重送信や状態の混乱を防いでいます。

 

また、autoResizeTextarea は、入力欄(textarea)の高さを「内容に合わせて自動で伸縮」させる関数です。

 

・style.height = "auto" で一度高さをリセットし、
・scrollHeight(内容の実際の高さ)に合わせて高さを設定する(上限200px)。

 

この2行で、1行しか入力していない時は低く、長文を入力するほど高くなる、使いやすい入力欄が実現しています。

 

 

JavaScript編②のまとめ

 

今回の「画面描画」で押さえておきたい要点は、以下の3つです。

 

1. 質問と回答で「表示方法」を使い分ける
質問は textContent で安全に、回答は renderMarkdown(変換→安全化)で装飾して表示する。

 

2. AIの出力は「変換(marked)→ 安全化(DOMPurify)」の2段構え
これが、AIの出力を安全かつきれいに表示するための鉄則。

 

3. 「状態変数」を見て「画面の状態」を切り替える
isGenerating という1つの変数が、送信/停止ボタン・入力欄の有効無効を一括で制御している。

 

次はいよいよ、JavaScript編の心臓部――【JavaScript編③】API通信の中核(セクション【16】〜【20】)です。

「送信ボタンが押されてから、回答が画面に表示されるまで」の全工程――sendMessage、SSEストリーミング、描画ループの仕組みを、じっくりと解説します。

 

【JavaScript編③】API通信の中核(セクション【16】〜【20】)

 

いよいよ、JavaScript編の第3グループ「C. API通信」(セクション【16】〜【20】)です。

 

ここは、このアプリの心臓部の中の心臓部です。

「送信ボタンが押されてから、AIの回答が画面に少しずつ表示されるまで」の全工程を担当しています。

 

まず、全体の流れを俯瞰してから、各セクションを見ていきましょう。

 

■ 送信ボタンを押してから回答が表示されるまでの流れ

 

 順番 セクション・関数 やっていること
 ①【16】sendMessage 入力をチェックし、質問を表示。
 回答用の「空の器」を用意して通信を開始する
 ②【17】
 buildChatCompletionRequest
 APIに送る「注文票」(リクエスト)を組み立てる
 ③【18】streamChatCompletion ほか APIにリクエストを送信し、回答を「少しずつ」
 受け取る
 ④【19】appendStreamText ほか 受け取った「かけら」を、80msごとにまとめて
 画面に反映する
 ⑤【20】finalizeAssistantResponse 全部届いたら、Markdown解析・色付けで仕上げる

 

この「器の用意 → 注文 → 少しずつ受取 → 定期反映 → 仕上げ」という流れを頭に入れておくと、各関数の役割がぐっと分かりやすくなります。

 

 

【16】sendMessage:全体の流れを指揮する「司令塔」

 

まずは、このアプリの主役ともいえる sendMessage 関数です
。送信ボタンが押された時に実行され、回答が表示されるまでの全工程を指揮します。

 

📄 JavaScript【16】sendMessage(前半・抜粋)
async function sendMessage() {
  if (isGenerating) return;   // 生成中の二重送信を防ぐ
  clearError();

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

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

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

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

 

まず、関数の宣言にある async というキーワードに注目です。

 

これは「この関数の中には、時間のかかる処理(通信など)を『待つ』処理が含まれます」という印です。
asyncが付いた関数の中では、await(待機する)という命令が使えるようになります。

 

処理の流れを見ていきましょう。

 

・冒頭のガード(二重送信の防止)

if (isGenerating) return; の1行で、「AIが回答を生成している最中は、新しい送信を受け付けない」ようにしています。
状態変数(【2】)がここで活きています。

 

・① 送信前のチェック(バリデーション)

「入力が空っぽでないか」「APIキーが入力されているか」を、送信前にチェックしています。
条件に合わなければ、エラーを表示して return で処理を終了します。

「おかしなデータは、早い段階で門前払いする」というのは、堅牢なプログラムの基本です。
このようなチェックを「バリデーション(検証)」と呼びます。

 

・② 質問を「データ」と「画面」の両方に反映

conv.messages.push(userMsg) で会話データに質問を追加し、saveConversations() で保存、renderActiveConversation() で画面に表示しています。

「データを更新したら、保存して、画面に反映する」という3点セットは、これまでのセクションでも繰り返し出てきた基本パターンです。

 

続いて後半です。

 

📄 JavaScript【16】sendMessage(後半・抜粋)
  /* ----- ③ 生成開始の準備 ----- */
  isGenerating = true;
  requestWakeLock();             // 生成中はスリープを抑制
  responseStartTime = performance.now();   // 応答計測の開始
  hasRetriedNonStream = false;
  fullResponseText = "";
  abortController = new AbortController(); // 通信を途中で止める「中止装置」
  updateUiState();                         // 送信→停止ボタンに切り替え

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

  /* ----- ⑤ API通信(ストリーミング)を実行 ----- */
  try {
    await streamChatCompletion(conv, userText, true);
  } catch (err) {
    if (err && err.name === "AbortError") {
      if (isGenerating) {
        /* タイムアウト等による中断:受信済みの内容を保持して確定 */
        fullResponseText += "\n\n---\n*(タイムアウトまたは接続が中断されました)*";
        showProgressStatus("接続が中断されました");
        finishGeneration(true);
      }
      return;
    }

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

 

後半のポイントは3つです。

 

1. ④ 「空の器」を先に表示する

回答は「少しずつ届く」(ストリーミング)ので、先に「K」のアバター付きの空っぽの吹き出し(器)を画面に用意しておき、そこに届いた分から順に流し込んでいきます。

currentStreamEl という変数に「器」の参照(場所)を保存しておくことで、後続の描画関数(【19】【20】)が「どこに書き込めばいいか」を知ることができます。

 

2. await で「通信が終わるまで待つ」

await streamChatCompletion(...) の await は、
「この通信処理が終わるまで、ここで待機する」
という意味です。
待っている間も、ブラウザの他の動作(スクロールなど)は止まりません。
これが async/await の便利なところです。

 

3. try〜catch と「再試行」の仕組み

通信は、ネットワークの調子などで失敗することがあります。
この関数には、2段階の備えがあります。

 

・try〜catch:
通信でエラーが起きたら、catch に飛んで適切に対処する

・再試行(リトライ):
ストリーミング(stream:true)が失敗したら、通常モード(stream:false)で一度だけやり直す

 

「一度失敗しても、別の方法でもう一度試す」という設計により、一時的な通信トラブルでいきなりエラーになるのを防いでいます。

 

 

【16】補足:finishGeneration――思考過程パネルが「消える」仕組みの正体

 

先程の try〜catch の各所に登場した finishGeneration() という関数に注目してください。

これは「成功・失敗・停止のすべてのケースで、最後に必ず実行される終了処理」です。
そして【14】で「思考過程パネルは生成完了時に消える」と説明した、あの仕組みの正体がここにあります。

 

📄 JavaScript【16】finishGeneration(抜粋)
/* --- 生成の終了処理(成功・失敗・停止のすべてで実行) --- */
function finishGeneration(success) {
  /* …スリープ抑制の解除やフラグの後片付け(省略)… */

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

  /* …応答計測の結果を回答の最後に追加する処理(省略)… */

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

  if (success) showProgressStatus("完了しました");
}

 

ここで注目すべきは2点です。

 

・保存されるのは fullResponseText(回答本文)のみ

fullReasoningText(思考過程のテキスト)は、conv.messages にも localStorage にも保存されず、メモリ上の変数に残るだけです。

 

・保存の直後に renderActiveConversation() で画面を描き直す

「データを更新したら、保存して、画面に反映する」というおなじみの3点セットです。
そして、この「画面に反映する」処理こそが、思考過程パネルを消去する瞬間です。
その中身を見てみましょう。

 

📄 JavaScript【9】renderActiveConversation
function renderActiveConversation() {
  const conv = getActiveConversation();
  if (!conv) return;
  chatContainer.innerHTML = "";   // ← チャット欄の全DOMがここで消去される
  conv.messages.forEach(m => chatContainer.appendChild(renderMessage(m)));
  scrollToBottom(true);
}

 

chatContainer.innerHTML = ""; の1行で、チャット欄の中身がすべて消去されます。

【8】で「一度空にする」ことの重要性を学びましたが、ここでも同じ鉄則が適用されています。
そしてこの消去の対象には、ストリーミング用の器(streamWrap)
――すなわち思考過程パネルも含まれるのです。

 

その後、保存済みの会話データ(conv.messages)をもとに、1件ずつ renderMessage() で描き直します。
しかし【9】で見たとおり、renderMessage() が描画するのは「本文(content)」と「応答計測(metricsText)」だけです。

 

📄 JavaScript【9】renderMessage(AIの回答部分・抜粋)
if (msg.role === "assistant") {
  /* ----- AIの回答:Markdownとして描画(安全化つき) ----- */
  content.classList.add("markdown-body");
  content.innerHTML = renderMarkdown(msg.content);  // ← 本文(content)だけを描画
  addCopyButtons(content);
  applySyntaxHighlight(content);
  /* 応答計測(metricsText)の表示はあるが、
     思考過程の描画処理は存在しない */
}

 

つまり、「再表示したくても、表示する仕組み自体がない」状態なのです。

 

さらに念押しとして、localStorage への保存処理(【7】saveConversations)を見ても、思考過程は保存対象に含まれていません。

 

📄 JavaScript【7】saveConversations(抜粋)
/* 保存用に「必要な項目だけ」を取り出して軽くする */
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   // ← 思考過程のフィールドは存在しない
  }))
}));

 

ページを再読み込みしても思考過程が復元されないのは、この保存構造が理由です。
なお、思考過程について後に残るのは、応答計測(metricsText)に含まれる
「思考過程: ○○文字」
という文字数の記録だけです。
テキスト本体は残りません。

 

▲ 【14】の答え合わせ:「消える」仕組みの時系列

 

ここまでの内容を、時系列で整理してみましょう。

 

 タイミング 何が起きるか
 生成中 思考過程が fullReasoningText(メモリ変数)に蓄積され、
 streamWrap 内のパネルに表示される
 完了時 finishGeneration() で本文だけが conv.messages に
 保存される
 完了時 renderActiveConversation() の innerHTML = "" で
 streamWrap ごと画面から消去
 再描画後 renderMessage() は本文と計測結果しか描画しないため、
 思考過程は二度と表示されない
 次の送信時 sendMessage() 内の fullReasoningText = ""; で
 メモリ上の思考過程もリセットされる

 

このように、思考過程パネルは「生成中だけのライブ配信」として、意図的に設計されています。

 

もし将来「完了後も思考過程を残したい」と思ったら、conv.messages に思考過程用のフィールドを追加し、saveConversations() と renderMessage() の両方を拡張する必要があります。
その時、ここで見た3つの関数が「改造のポイント」になります。
拡張の際の道しるべとして、覚えておいてください。

 

■ 拡張前に知っておきたい「利点」と「リスク」

 

この拡張には、もちろん大きな利点があります。

 

・思考過程を後から読み返せる
AIが「どう考えてこの答えにたどり着いたか」の記録が残るため、回答の根拠を確認したり、プロンプト(指示文)の改善に役立てたりできます。

・会話の再現性が上がる
思考過程込みで履歴が残るため、過去のやり取りをより正確に振り返ることができます。

 

一方で、初学者の方にぜひ知っておいてほしい「リスク」もあります。
それが「localStorage の容量問題」です。

 

localStorage には、保存できるデータ量に上限があります。
上限はブラウザによって異なりますが、一般的には約5MB〜10MB程度と言われています(正確な値は、お使いのブラウザの仕様をご確認ください)。

 

そして、思考モデルの「思考過程」は、回答本文よりもはるかに長くなることが少なくありません。
短い質問でも数千文字、複雑な問題では数万文字に達することもあります。

 

つまり、「思考過程を残す」拡張をすると、1回の回答あたりの保存データ量が、数倍〜数十倍に膨らむ可能性があるのです。
これが複数の会話にまたがって蓄積されると、容量超過のリスクが一気に高まります。

 

■ リスクは容量だけではない:API 料金(input tokens)にも注意

 

実は、リスクは localStorage の容量だけではありません。「APIの利用料金」にも影響する可能性があります。

 

【17】で見るとおり、このアプリは送信のたびに会話履歴(conv.messages)をまるごとAPIに送る設計になっています。
過去のやり取りが長くなるほど、送信データ(input tokens)は増え、料金も加算されていきます。

 

もし拡張の際に「保存した思考過程も、次の会話で APIに送る」という設計にすると、思考過程の文字数分だけ input tokens が増大します。
思考過程は回答本文より長くなりがちなため、1回の送信あたりの料金が数倍に跳ね上がる可能性があるのです。

 

ここで重要なのは、「保存する」と「APIに送る」は別の話だという点です。

 

・localStorage に保存して、画面に表示するだけ

→ buildChatCompletionRequest が思考過程を送信対象に含めなければ、追加料金は発生しません。

 

・思考過程も含めて APIに送る(AIに前回の思考も踏まえさせたい場合)

→ そのぶん input tokens が増え、料金が加算されます。

 

なお、そもそも現行のアプリでも、思考過程の「生成」自体は、API側で output tokens としてカウント・課金されています。

つまり「表示していなかったから無料」だったわけではありません。
拡張で新たに増えるのは「次回以降のリクエストに、過去の思考過程を含めた分の input tokens」という整理になります。

 

拡張する場合は、「思考過程をAPIに送るかどうか」も設計の選択肢として検討しましょう。
表示のためだけに保存するなら、送信対象から外すのが料金面では賢明です。

 

■ 容量を超えたら、何が起きるのか

 

実は、このアプリには容量超過への「備え」がすでに組み込まれています。
【7】で学んだ saveConversations() の catch ブロックを、もう一度見てみましょう。

 

📄 JavaScript【7】saveConversations(フォールバック部分・抜粋)
  } catch (e) {
    /* 容量超過など:古い会話を削って再試行 */
    try {
      conversations = conversations.slice(0, 20);   // ← 最新20件だけを残す
      localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
    } catch (e2) { /* 保存断念 */ }
  }

 

保存に失敗した時の動作は、以下の2段階です。

 

1. まず「最新20件だけ残す」作戦で再試行する

conversations.slice(0, 20) で、会話を「最新の20件」にまで絞り込んでから、保存をやり直します。

このアプリでは、新しい会話ほど配列の「先頭」に追加される仕組みになっているため、slice(0, 20) は「先頭から20件」=「新しい順に20件を残す」という意味です。
言い換えると、21件目以降の古い会話は、ここで自動的に削除されるのです。

 

ここで注意したいのは、conversations = の代入によって、メモリ上の変数そのものも書き換わるという点です。

つまり、再試行が一度でも走ると、削られた古い会話は保存データからだけでなく、画面上の履歴一覧からも消えてしまいます。
しかも「保存に失敗しました」というエラーメッセージは表示されないため、ユーザーから見ると「いつの間にか古い会話が消えていた」という体験になり得ます。

これはアプリをクラッシュから守る「安全網」であると同時に、「静かに古いデータを手放す」という設計上の割り切りでもあります。

 

2. それでも失敗したら、その回の保存は「断念」する

最新20件に絞ってもなお容量に収まらない場合は、catch (e2) に進み、その回の保存自体をあきらめます。

この場合、画面上では普通に会話を続けられますが、内容はメモリ上にしか存在しないため、ページを閉じたり再読み込みしたりすると失われる可能性があります。

 

■ 拡張時のもうひとつの注意点:再試行は「そのまま保存」

 

実は、このフォールバックにはもうひとつ特徴があります。
再試行のコードをよく見ると、JSON.stringify(conversations) となっており、最初の保存で使っていた safe(必要な項目だけを取り出した軽量版)を使っていません。

現状のデータ構造では実害はほぼありませんが、思考過程用のフィールドを追加する拡張を行った場合、再試行時にも思考過程込みの「フルサイズ」で保存されることになります。
拡張の際は、この再試行ルートにも気を配る必要があります。

 

■ まとめ:「保存できる」から「保存する」ではない

 

最後に、利点とリスクを整理しておきましょう。

 

 利点 リスク
 ・AIの「考えた道筋」を後から読み返せる ・1回答あたりのデータ量が数倍〜数十倍に
  膨らむ可能性がある
 ・回答の根拠確認やプロンプト改善に役立つ ・容量超過で「古い会話が静かに自動削除」
  される場面が増える
 ・会話の再現性が上がる ・最悪の場合、保存自体が断念され、
  リロードで内容が失われる
 ・思考過程込みで会話の文脈を維持できる ・思考過程を APIに送る設計にすると、
  input tokens 増大で料金が上がる

 

現行の設計が思考過程を「あえて保存しない」のは、この容量リスクそのものを回避する、という合理的な判断でもあるのです。

 

もし拡張に挑戦するなら、例えば次のような「リスク対策」をセットで考えるのが実務的です。

 

・保存する思考過程の文字数に上限を設ける(例:先頭2000文字だけ保存する)

・思考過程を保存するかどうかを、設定で切り替えられるようにする

・思考過程は「保存・表示」に留め、APIへの送信対象からは外す(料金の増大を防ぐ)

・大容量を扱いたい場合は、localStorage より大きな保存領域(IndexedDBなど)の利用を検討する

 

「どんなデータを、どれだけ、どこに保存するか」。
これはアプリの使い勝手と安定性を左右する、設計の重要な判断ポイントです。
今回の拡張論は、その「設計のものさし」を養う格好の題材でもあります。

 

 

【17】buildChatCompletionRequest:APIへの「注文票」を作る

 

次に、APIに送信するデータ(リクエスト)を組み立てる関数です。
記事の冒頭で「APIはレストランの注文票のようなもの」と例えましたが、まさにその注文票を作る部分です。

 

📄 JavaScript【17】buildChatCompletionRequest(抜粋)
function buildChatCompletionRequest(conv, userText, useStream) {
  const systemPrompt = getCurrentSystemPrompt();
  const messages = [{ role: "system", content: systemPrompt }];

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

  const model = getSelectedModel();

  const request = {
    model: model,                                // モデル名
    messages: messages,                          // 会話履歴
    temperature: 1,
    max_tokens: FIXED_MAX_TOKENS,                // 最大出力トークン数
    stream: useStream                            // ストリーミングON/OFF
  };

  /* Kimi K3:常時思考モデル+reasoning_effort を送信 */
  if (model === "kimi-k3") {
    request.reasoning_effort = FIXED_REASONING_EFFORT;
  }

  /* ※kimi-k2.7-code・kimi-k2.6 の分岐はステップ②で追加します */
  return request;
}

 

注文票に書かれている項目を、1つずつ見てみましょう。

 

 項目 意味
 model どのAIモデルに聞くか(ステップ①では "kimi-k3" 固定)
 messages これまでの会話の履歴。先頭には「system」(システムプロンプト)
 を入れる
 temperature 回答の「ランダム性」。1は標準的なバランスの取れた値
 max_tokens 回答の最大の長さ(思考過程+本文の合計)
 stream trueなら「少しずつ回答を受け取る」ストリーミング方式になる
 reasoning_effort Kimi K3専用。「どのくらい深く考えるか」の指定(low / high / max)

 

ここで重要なのが messages の構造です。

 

messages の先頭には、必ず { role: "system", content: システムプロンプト } が入ります。
これが「AIへの指示書」です。
AIはこの指示書を読んでから、続く会話(user と assistant のやり取り)を踏まえて回答を生成します。

 

会話の履歴を毎回すべて送っている点も、チャットアプリの重要な仕組みです。
APIは「前の会話を覚えている」わけではないため、「これまでの会話を全部同封して、文脈を伝える」必要があるのです。

 

ここで、会話の仕組みを理解するうえで、「添付ファイル」と「テキスト入力」の大きな違いを押さえておきましょう(添付機能はステップ④で実装しますが、先に仕組みを知っておくと後の理解がスムーズです)。

 

■ 添付ファイルの内容は「送信したその時だけ」参照される

画像やPDFなどを添付して送信すると、AIはその時点でファイルの内容を理解して回答します。
しかし、次の会話では、履歴には「ファイル名などのメタデータ」のみが残り、ファイルの中身そのものは送られません。

そのため、同じファイルについて何度もやり取りしたい場合は、その都度、改めてファイルを添付し直す必要があります。

 

■ テキストで入力した内容は「毎回すべて」参照される

一方、入力欄にテキストで書き込んだ内容は、会話履歴に全文が保存され、送信のたびにすべてAPIへ送られます。
そのため、長い前提条件やコードなどは、テキストで入力しておけば、毎回参照してもらうことができます。

 

■ 使い分けのポイント:コストパフォーマンスの視点

ただし、テキストで毎回すべて送る方式は、会話を重ねるほど送信するトークン数(input tokens)が増え、APIの利用料金が嵩む可能性があります。

 

「一度だけ見てほしい大きなファイル」は添付で、「何度も参照してほしい前提条件やコード」はテキスト入力で
――というように、用途に応じて使い分けるのが、コストパフォーマンスの良い使い方と言えるでしょう。

 

 

【18】streamChatCompletion と readSseStream:通信の中核

 

ここが、このアプリで最も「通信らしい」部分です。2つの関数に分けて見ていきましょう。

 

まず、streamChatCompletion の前半(リクエスト送信とエラーチェック)です。

 

📄 JavaScript【18】streamChatCompletion(前半・抜粋)
async function streamChatCompletion(conv, userText, useStream) {
  showProgressStatus("AIが質問を分析しています…");
  let lastFinishReason = 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);
    /* …ステータスコードに応じたエラーメッセージを作る… */
    if (response.status === 401 || response.status === 403) {
      throw new Error("認証エラー(HTTP " + response.status + "):APIキーが不正です。");
    } else if (response.status >= 500) {
      throw new Error("サーバーエラー(HTTP " + response.status + "):しばらく待って再試行してください。");
    }
  }

 

ここでの主役は、fetch(フェッチ) という命令です。これは「指定したURLに対して、データを送受信する」ための、ブラウザ標準の機能です。

 

送信している内容を分解して見てみましょう。

 

・method: "POST"
… データを「送信する」方式(注文を出すイメージ)

・headers
… 「JSON形式のデータを送ります」(Content-Type)と「このAPIキーの持ち主です」(Authorization)という、2つの証明書

・body: JSON.stringify(requestBody)
… 【17】で作った注文票を、JSON形式の文字列に変換して同封

・signal: abortController.signal
… 「中止装置」との紐付け。これにより、停止ボタンやタイムアウトで通信を途中で切れる

 

「Authorization: "Bearer " + apiKey」の部分が、APIキーによる認証です。Bearer(ベアラー)は「この鍵を持っている者」という意味で、ここに正しいAPIキーが入っていないと、サーバーは門前払い(401/403エラー)を返します。

 

また、タイムアウトの仕組みにも注目です。setTimeout で「REQUEST_TIMEOUT_MS(360秒)後に中止装置を作動させる」タイマーを仕掛けておき、応答が返ってきたら clearTimeout で解除します。これにより、「いつまでも応答がない場合に、処理が固まったままにならない」ようになっています。

 

続いて、回答を「少しずつ」受け取る readSseStream 関数です。

 

📄 JavaScript【18】readSseStream(抜粋)
async function readSseStream(response) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder("utf-8");   // バイト列 → 文字列
  let buffer = "";   // 受信途中のデータを一時的にためる場所

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

 

「ストリーミング」という言葉の意味が、このコードに詰まっています。順に見ていきましょう。

 

1. 「蛇口」からデータを少しずつ受け取る

通常の通信は「回答が全部できてから、まとめて受け取る」方式です。しかしストリーミングでは、response.body.getReader() という「蛇口」を用意し、回答が生成された分だけ、少しずつ受け取り続けます。これが「回答がリアルタイムで少しずつ表示される」仕組みの正体です。

 

2. バッファ(一時保管場所)で「行の切れ目」に対応

データは「塊(かたまり)」で届きますが、その塊がちょうど行の途中で切れることがあります。そこで、buffer という変数に届いたデータをためておき、改行(\n)で区切って処理します。最後の「途中の行」は pop() で取り出してバッファに残し、次の塊が届いてから続きを処理します。この「バッファリング」という技術は、通信処理の定番です。

 

3. 届いた1行ずつを processLine で解析

取り出した1行は、processLine という関数で解析します。次にその中身を見てみましょう。

 

📄 JavaScript【18】processLine(抜粋)
  const processLine = (line) => {
    const trimmed = line.trim();
    if (!trimmed || !trimmed.startsWith("data:")) return;

    const dataStr = trimmed.slice(5).trim();   // 「data:」の後ろを取り出す
    if (dataStr === doneToken) return;         // 終了の合図「[DONE]」

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

    /* choices[0].delta に「今回届いたかけら」が入っている */
    const choice = json.choices && json.choices[0];
    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") {
        handleStreamEvent(delta.content);
      }
    }
  };

 

APIから届くデータは、「data: {…}」という形式(SSE:Server-Sent Events)の行の集まりです。processLine は、各行から次の処理を行っています。

 

1. 「data:」の後ろのJSON文字列を取り出し、JSON.parse でデータに変換
2. choices[0].delta の中から「今回届いたかけら」を取り出す
3. 「思考(reasoning_content)」と「本文(content)」を振り分けて、それぞれのバッファに蓄積する

 

この「思考と本文の振り分け」が、思考過程パネル(【14】)と回答本文の表示を、別々にリアルタイム更新できる仕組みの核心です。

 

また、try〜catch で囲まれた「壊れたチャンクはスキップ」という処理も、通信処理ならではの堅牢さを生む工夫です。

 

通信の途中で、ごく稀に「JSONとして正しく読めない壊れた行」が届くことがあります。その時に1行だけで全体を止めてしまうと、せっかくの回答が途中で失われてしまいます。そこで、「1行の読み取りに失敗しても無視して次へ進む」ことで、回答全体の受信を守っています。

 

ちなみに、終了の合図「[DONE]」を判定している次の1行にも、さりげない工夫があります。

 

📄 JavaScript【18】終了合図の判定(抜粋)
/* 終了の合図「[DONE]」(配列の文字コードで組み立てて誤検出を防ぐ) */
const doneToken = String.fromCharCode(91, 68, 79, 78, 69, 93);
if (dataStr === doneToken) return;

 

「91, 68, 79, 78, 69, 93」という数字の並びは、文字コードで「[」「D」「O」「N」「E」「]」を表しています。
あえて「"[DONE]"」と直接書かずに文字コードから組み立てているのは、このコード自体を誰かがコピー・検索した時に、文字列「[DONE]」が誤ってマッチしてしまう事故を防ぐための、実務的な配慮です。

 

 

【18】補足:回答が1文字も来なかった時のガード

 

readSseStream で回答を受け取ったあと、streamChatCompletion の末尾では
「回答がちゃんと届いたか」
を最終確認しています。

 

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

 

ここで活きているのが、processLine で記録しておいた lastFinishReason です。

 

APIは回答を終える時に「どうして終わったか」という理由(finish_reason)を教えてくれます。

 

・”stop”
… 回答が自然に完成して終わった

・”length”
… max_tokens の上限に達して、途中で打ち切られた

 

もし思考モデルが「考えすぎて」、思考過程だけで max_tokens を使い切ってしまうと、本文が1文字も出ないまま終わってしまいます。
その場合に「"length" で終わっていた」ことが分かれば、「空の回答」ではなく「max_tokens の上限に達した」という、原因の分かるエラーメッセージを表示できるわけです。

 

「エラー」をただ表示するのではなく、「何が原因で、どうすれば解決するか」まで伝えるのは、使いやすいアプリを作るうえでとても大切な視点です。

 

 

【19】画面更新の最適化:なぜ「80msごと」に描画するのか

 

受け取った回答の「かけら」を画面に反映する仕組みです。
ここには、パフォーマンス(動作の軽さ)への大切な工夫があります。

 

📄 JavaScript【19】描画のスケジューリング(抜粋)
function appendStreamText(text) {
  fullResponseText += text;
  schedulePartialRender();
}

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

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

 

もし「かけらが届くたびに、毎回画面を書き換える」と、1秒間に何百回も描画が走ってPCが重くなってしまいます。

 

そこでこのコードでは、「届いた分はすぐ変数にためておき、画面への反映は80msごとにまとめて行う」という方式を取っています。

 

1. appendStreamText
… かけらを fullResponseText に追加し、「描画の予約」を入れる

2. schedulePartialRender
… renderScheduled フラグで「二重予約」を防ぎつつ、80ms 後に renderPartialResponse を実行するよう予約する

3. renderPartialResponse
… その時点の全文を画面に表示する

 

この「頻繁に起きるイベントを、一定間隔にまとめて処理する」技法は「スロットリング(絞り込み)」と呼ばれ、Web開発でとてもよく使われます。

 

そして、この「80ms」という間隔を返しているのが getRenderInterval() です。
これが、ステップ②で設定パネルから変更できるようになる「画面更新間隔 (ms)」の正体です。
「小さいほど滑らか(描画が頻繁)だが、その分PCへの負荷が増える」という、トレードオフの関係にある設定値です。

 

 

【19】補足:ストリーミング中は「プレーンテキスト」で軽量に

 

実際に画面へ表示する renderPartialResponse では、意外なほどシンプルな表示方法を取っています。

 

📄 JavaScript【19】renderPartialResponse(抜粋)
function renderPartialResponse(text) {
  if (!currentStreamEl) return;
  currentStreamEl.textContent = text;   // Markdown解析せず、そのまま表示

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

  scrollToBottom();
}

 

注目ポイントは、textContent で「そのまま」表示していることです。

 

「AIの回答はMarkdownで装飾して表示するのでは?」と思うかもしれません。
しかし、Markdownの解析(renderMarkdown)は比較的重い処理です。
これを80msごとに実行していては、かえって動作が重くなってしまいます。

 

そこで、以下のように使い分けています。

 

・ストリーミング中
… textContent で軽量に、文字が流れるように表示(リアルタイム感を優先)

・受信完了後
… 最後に1回だけ、Markdown解析で装飾して表示(仕上がりの美しさを優先)

 

「途中は速さ優先、最後に1回だけ丁寧に仕上げる」という割り切りが、「滑らかな表示」と「きれいな完成形」の両立を実現しています。

 

また、stream-cursor(▍)という点滅するカーソルを末尾に付けているのも、
「今まさに生成中である」
ことを視覚的に伝える、さりげない演出です。

 

 

【20】最終レンダリング:完成形への「仕上げ」

 

回答が全部届いたあとの「仕上げ」を行う finalizeAssistantResponse 関数です。

 

📄 JavaScript【20】finalizeAssistantResponse(抜粋)
function finalizeAssistantResponse(fullText) {
  if (!currentStreamEl) return;
  currentStreamEl.innerHTML = renderMarkdown(fullText);   // ①変換+安全化
  addCopyButtons(currentStreamEl);                        // ②言語名+コピー
  applySyntaxHighlight(currentStreamEl);                  // ③色付け
  scrollToBottom(true);
}

 

たった4行ですが、これまで学んできた関数がすべて集結しています。

 

① renderMarkdown(【10】)
… Markdown → 安全なHTML に変換

② addCopyButtons(【11】)
… コードブロックに言語名+コピーボタンを追加

③ applySyntaxHighlight(【12】)
… コードブロックに色を付ける

 

ストリーミング中は textContent で「そのまま」表示していた回答が、この1回の処理で「装飾された完成形」に生まれ変わります。

 

ここで「あれ?」と気づいた方もいるかもしれません。最終レンダリングを行ったあと、finishGeneration の中でも renderActiveConversation() が呼ばれ、会話全体が再描画されます。

 

これは「ストリーミング用の一時的な器」から「確定したデータ(conv.messages)に基づく、コピー・再生成ボタンや応答計測付きの完全な表示」へと、表示を引き継ぐためです。
データを保存(saveConversations)してから再描画する流れは、「データを更新したら、保存して、画面に反映する」という、おなじみの3点セットそのものです。

 

 

JavaScript編③のまとめ

 

今回の「API通信の中核」で押さえておきたい要点は、以下の3つです。

 

1. async/await で「時間のかかる処理を待つ」
sendMessage は

「チェック → 質問表示 → 器の用意 → 通信 → 後片付け」

の流れを指揮する司令塔。
try〜catch と再試行で、通信の失敗にも備えている。

 

2. ストリーミングは「蛇口+バッファ」で少しずつ受け取る
response.body.getReader() という「蛇口」から届いたデータを、buffer で行単位に整え、processLine で「思考」と「本文」に振り分ける。
壊れた1行はスキップして全体を守る。

 

3. 描画は「途中は速さ優先、最後に丁寧に仕上げる」
80msごとのスロットリング+textContent で軽量に表示し、完了後に renderMarkdown で装飾する。
この割り切りが、滑らかさと美しさの両立を生む。

 

次は最後のグループ――【JavaScript編④】補助機能と起動(セクション【21】〜【25】)です。

生成中のスリープ抑制(Wake Lock)、停止ボタン、再生成、そして「ボタンと関数を結び付ける」イベント登録、アプリの起動処理(init)を解説し、ステップ①の解説を締めくくります。

 

【JavaScript編④】補助機能と起動(セクション【21】〜【25】)

 

JavaScript編も、いよいよ最後のグループ「D. 補助機能と起動」(セクション【21】〜【25】)です。

 

ここには、チャットの主役ではないものの、アプリの使い勝手を大きく左右する
「縁の下の力持ち」
たちが集まっています。

 

・生成中に画面がスリープしないようにする【21】
・生成を途中で止める【22】
・回答を作り直す【23】
・ボタンと関数を結び付ける【24】
・そして、アプリ全体を起動する【25】

 

最後まで、一気に見ていきましょう。

 

 

【21】Wake Lock API:生成中のスリープを防ぐ

 

まずは、少し変わった機能から。「Wake Lock(ウェイクロック)API」という、ブラウザの比較的新しい仕組みです。

 

📄 JavaScript【21】Wake Lock API(抜粋)
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;
  }
}

 

これは何をしているのでしょうか。

 

AIの回答生成には、数十秒から数分かかることがあります。その間、スマホやPCの画面が自動でスリープしてしまうと、通信が途中で切れてしまう可能性があります。

 

そこで Wake Lock API を使い、「生成中は画面をスリープさせない」ようOSにお願いしています。

 

読み解くポイントは3つです。

 

1. 対応しているかを、まず確認する

"wakeLock" in navigator という条件で、「このブラウザは Wake Lock に対応しているか」を調べています。対応していなければ return で何もしません。
これにより、非対応ブラウザ(Firefoxなど)でもエラーにならず、普通に生成が続きます。

 

2. 「鍵」を取得して、終わったら返す

navigator.wakeLock.request("screen") で「スリープ抑制の鍵」を取得し、生成が終わったら releaseWakeLock() で返却します。
「必要な間だけ借りて、終わったら返す」という、資源管理の基本パターンです。

 

3. 失敗しても、生成そのものは止めない

try〜catch で囲み、鍵の取得に失敗しても(低バッテリー時など)「無視して生成は継続」する設計です。
「あくまで補助機能。本体の動作は妨げない」という割り切りが、堅牢な設計です。

 

 

【22】生成停止:通信を途中で「安全に」止める

 

停止ボタンが押された時に動く cancelCurrentRequest 関数です。

 

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

 

ここで活躍するのが、【16】で登場した AbortController(中止装置) です。

 

abortController.abort() と1行呼ぶだけで、これに紐付けられた fetch 通信(【18】)が中断され、AbortError というエラーが投げられます。

 

注目してほしいのは、単に「止める」だけでなく、丁寧な後片付けをしていることです。

 

1. 受信済みの内容は「捨てない」

fullResponseText には、その時点までに受信した回答が残っています。
そこに「(生成を停止しました)」という注記を付け加え、finishGeneration(true) で「成功」として確定させています。
これにより、途中までの回答が、そのまま会話履歴に残ります。

 

2. 処理状況パネルにも「停止」を記録

showProgressStatus("生成を停止しました") で、パネルのログにも停止したことが残ります。
「なぜ回答が途中で終わっているのか」が、あとから見ても分かる配慮です。

 

 

【23】回答の再生成:「もう一度だけ、やり直して」

 

回答の下にある「↻ 再生成」ボタンから呼ばれる regenerateResponse 関数です。

 

📄 JavaScript【23】regenerateResponse(抜粋)
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();
}

 

この関数の「やっていること」を、手順に分解してみましょう。

 

1. 直前の「質問」を探す

for ループで、再生成したい回答(assistantMsg)より手前を逆順にたどり、最初に見つかった「user」のメッセージを取り出します。
回答を作り直すには、「どの質問に対する回答か」が必要だからです。

 

2. その回答と質問を、履歴から取り除く

slice(0, idx) で「その回答より手前」だけを残し、filter で質問も取り除きます。
これで履歴が「質問を送る直前」の状態に戻ります。

 

3. 質問を入力欄に戻して、sendMessage() を呼ぶ

取り出した質問を入力欄にセットし、あとは通常の送信処理に丸投げします。

 

ここで見事なのは、「再生成専用の通信コードを書いていない」ことです。
sendMessage() という既存の司令塔を再利用することで、コードの重複をなくし、「再生成でも通常送信と全く同じ流れ(計測・思考パネル・再試行など)が動く」ことを保証しています。

「同じ処理は、同じ関数に任せる」という、DRY(Don't Repeat Yourself:繰り返しを避ける)の原則の良い実例です。

 

 

【24】イベント登録:ボタンと関数の「結び付け所」

 

次は registerEvents 関数です。これまで解説してきた各種の関数を、「どのボタンが押されたら、どの関数を動かすか」という形で、一括して結び付けている部分です。

 

📄 JavaScript【24】registerEvents(抜粋・冒頭)
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);

 

addEventListener("イベント名", 実行する関数) という形が、繰り返し並んでいます。
この「結び付け」を1つの関数に集約しておくと、「アプリ全体で、どの操作に何が結び付いているか」が、この関数を見れば一目で把握できるという利点があります。

 

ここで特に注目したいのが、Enter送信の判定にある e.isComposing です。

 

これは、日本語入力(IME)の「変換中かどうか」を示すフラグです。
日本語で「かんじ」と入力してスペースで変換し、Enterで確定する――この「確定のEnter」でも e.key === "Enter" は true になってしまいます。

 

もし isComposing のチェックがなければ、変換を確定しただけでメッセージが送信されてしまうという、日本語ユーザーには致命的な誤作動が起きます。
「!e.isComposing(変換中でない)」という条件を加えることで、「変換確定のEnter」ではなく「本当に送りたい時のEnter」だけを検知しています。

 

日本語環境のWebアプリを作る際の、必須の配慮のひとつです。

 

もう1つ、自動スクロールの判定も見てみましょう。

 

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

 

この1行の計算式は、少し複雑に見えますが、意味はこうです。

 

scrollHeight(全体の高さ)− scrollTop(今スクロールしている位置)− clientHeight(画面に見えている高さ)

 

この計算で出るのは「今の表示位置の下に、あとどれくらいのコンテンツが残っているか」です。
これが60px未満なら「ほぼ一番下まで読んでいる」と判定し、autoScrollEnabled(自動追従)をONにします。

 

逆に、ユーザーが上にスクロールして過去のやり取りを読んでいる間はOFFになり、【19】のストリーミング表示が勝手に画面を下へ引っ張らないようになります。「読んでいる最中は、スクロールを奪わない」という、細やかなユーザー体験への配慮です。

 

また、この関数の後半にある「ツールチップ補助」では、data-tooltip 属性を持つ全要素に対して、モバイルの長押し表示や、ポップの位置自動調整(上にスペースがなければ下向きに)を設定しています。
CSS編で学んだ data-tooltip の仕組みを、JavaScript側からさらに使いやすく補強している部分です。

 

 

【25】アプリの起動:すべての準備を整える init

 

最後は、アプリの「エンジンキー」ともいえる init 関数です。

 

📄 JavaScript【25】init と起動(全文)
function init() {
  loadUiSettings();       // ① 保存されたテーマを適用
  updateDarkModeBtn();    // ② テーマボタンの表示を更新
  loadConversations();    // ③ 保存された会話履歴を読み込む
  registerEvents();       // ④ ボタンやキー操作の設定
  updateUiState();        // ⑤ 送信/停止ボタンを初期状態に

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

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

 

init がやっていることは、これまでの解説の総復習です。

 

① loadUiSettings(【6】)
… 保存されたテーマを読み込む

② updateDarkModeBtn(【6】)
… テーマボタンの表示を更新

③ loadConversations(【7】)
… 保存された会話履歴を読み込む

④ registerEvents(【24】)
… ボタンと関数を結び付ける

⑤ updateUiState(【15】)
… ボタンと入力欄を初期状態に

⑥ タイトル表示
… 現在の会話名をトップバーに出す

 

「設定の読込 → データの読込 → イベント設定 → 画面の初期化」という順番で、アプリを「使える状態」に整えています。

 

そして、最後の1行 document.addEventListener("DOMContentLoaded", init); が、この init を「HTMLの読み込みが完了したタイミング」で実行する予約です。

 

<script> は </body> の直前にあるため、この時点ではHTMLの部品(DOM)はすでにできあがっています。
しかし、念のため DOMContentLoaded という「HTMLの構築が完了しました」という合図を待ってから起動することで、「まだ要素が無いのに操作しようとしてエラーになる」といった事故を、確実に防いでいます。

 

 

JavaScript編④のまとめ

 

今回の「補助機能と起動」で押さえておきたい要点は、以下の3つです。

 

1. 補助機能は「失敗しても本体を止めない」
Wake Lock のように、「あれば便利」な機能は、非対応・失敗時にも本体の動作を妨げない設計にする。

 

2. 同じ処理は、同じ関数に任せる(DRYの原則)
再生成も sendMessage() を再利用することで、コードの重複をなくし、動作の一貫性を保っている。

 

3. 起動処理は「順番」が大事
設定読込 → データ読込 → イベント設定 → 画面初期化、という init の流れは、多くのWebアプリに共通する基本形。そして DOMContentLoaded で「HTMLの完成」を待ってから起動する。

 

 

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

 

これで、ステップ①のコード解説はすべて終了です。おつかれさまでした。

 

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

 

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

HTML1ファイルだけで動く、本格的なAIチャットアプリです。
Runpod APIと通信し、回答をストリーミングで少しずつ表示し、思考過程パネルやMarkdown表示、応答計測まで備えています。

 

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

 

・設定値の集約(const で1か所にまとめる)
・状態管理(isGenerating などの変数で「今」を制御する)
・localStorage(JSON.stringify / JSON.parse でデータを保存・復元)
・DOM操作(createElement と appendChild で画面を組み立てる)
・XSS対策(escapeHtml と DOMPurify で入力を無害化する)
・async / await(時間のかかる通信処理を「待つ」)
・SSEストリーミング(蛇口+バッファで、回答を少しずつ受け取る)
・スロットリング(80msごとにまとめて描画し、動作を軽く保つ)
・イベント駆動(addEventListener で「操作」と「処理」を結び付ける)

 

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

 

 

次のステップ②の予告

 

次のステップ②では、ステップ①のアプリをさらに拡張します。

 

これまでコード内に「固定値」として書いていた以下のパラメータを、設定パネル(UI)から変更できるようにしていきます。

 

・モデル名(kimi-k3 / kimi-k2.7-code / kimi-k2.6)
・reasoning_effort(Kimi K3の推論レベル)
・思考モード(kimi-k2.7-code / kimi-k2.6)
・max_tokens
・画面更新間隔 (ms)
・APIタイムアウト(秒)

 

ここで、解説の中で何度か触れた「仕掛け」を思い出してください。

 

getSelectedModel() や getRenderInterval() のように、「値を関数経由で取得する」形にしていたおかげで、ステップ②では「関数の中身を、設定パネルの値を読む形に書き換えるだけ」で拡張が完了します。

 

あの時の「なぜわざわざ関数で包むのか?」という小さな工夫が、ここで大きく報われることになります。

 

■ ステップ②の作業のイメージ

 

ステップ②での作業は、大きく3つです。

 

1. 設定パネルに「設定項目」を追加する(HTML)
モデル名・reasoning_effort・max_tokens などの入力欄を、設定パネルに追加します。

 

2. 「値を返す関数」の中身を書き換える(JavaScript)
getSelectedModel() などの関数を、「固定値を返す」から「設定パネルの値を読んで返す」に書き換えます。

 

3. 変更した設定を保存・復元する(JavaScript)
saveUiSettings / loadUiSettings に、新しい設定項目を追加します。
テーマと同じく、設定もブラウザに覚えておけるようにします。

 

ステップ①で土台をしっかり作ったおかげで、変更は「決まった場所への、小さな追記と書き換え」だけで済みます。

 

それでは次のセクションから、ステップ②:モデルとパラメータの設定を始めましょう。

 

【ステップ②】AIチャットアプリの拡張 - モデルとパラメータの設定
:AIチャットアプリに設定機能を追加する方法|モデル・推論レベルをUIで変更【Runpod×Kimi入門②】

Runpod API×KimiのAIチャットアプリ開発入門・第2回。
モデル名・reasoning_effort・思考モード・max_tokens・画面更新間隔・APIタイムアウトを設定パネルから変更できるように拡張。差分コードと丁寧な解説付きで、プログラミング初心者でも安心です。

 

by 子供プログラマー

 

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