目的
Node.jsなどを使わずにHTML/CSS/JSだけで記述されたデモを作ってみたかった。
機能の概要
- LINE風のチャットUIでAIとチャットできる
- 2カラム構成
- 左サイドはチャット
- 右サイドに入力フォーム、パラメータ指定、料金・トークン表示、ログ表示
- パラメータ:previous_response_id のON/OFF指定
- 料金・トークン数表示:入力/出力/合計のトークン数と金額を表示
- ログ表示: APIレスポンスを表示
- API KeyはJS内に直接記述する。ローカルでのデモ用にのみ使用する。決して不特定多数の人がアクセスできる場所には公開しない
画面イメージ
コード
HTML
line-like-ui.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>Chat UI with Side Panel</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ★ 画面全体を横に 2 分割するコンテナ -->
<div class="app-container">
<!-- ---------------- 左:チャットエリア ---------------- -->
<section class="chat-panel">
<header class="chat-header">GPT API Demo</header>
<main class="chat-body" id="chatBody">
</main>
</section>
<!-- ---------------- 右:入力+ログエリア ---------------- -->
<section class="side-panel">
<!-- ◆ 入力欄 -->
<div class="input-area">
<h3>プロンプト入力</h3>
<textarea id="prompt" placeholder="Input your question, request, etc."></textarea>
<button class="send-btn" id="send">送信(CTRL+Enter)</button>
</div>
<!-- ◆ パラメータ欄 -->
<div class="param-area">
<h3>パラメータ</h3>
<label>
<input type="checkbox" id="usePrevId" checked> previous_response_id を利用
</label>
</div>
<!-- ◆ コスト表示欄 -->
<div class="cost-area">
<h3>料金</h3>
<div id="costDisplay">
<div>
<span class="label">入力トークン合計:</span>
0 tks, 0.000 JPY
</div>
<div>
<span class="label">出力トークン合計:</span>
0 tks, 0.000 JPY
</div>
<div>
<span class="label">総合計:</span>
0 tks, 0.000 JPY
</div>
</div>
</div>
<!-- ◆ ログ表示欄 -->
<div class="log-area">
<h2>API レスポンスログ</h2>
<pre id="jsonLog">{
N/A
}</pre>
</div>
</section>
</div><!-- /.app-container -->
</body>
<script src="line-like-ui.js"></script>
</html>
CSS
style.css
/* --------------------------------------------------
全体レイアウト
-------------------------------------------------- */
:root {
--bubble-self: #d1f8ce;
--bubble-other: #ffffff;
--bg-chat: #e5ddd5;
--panel-border: #ccc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
height: 100vh;
display: flex;
/* body 直下を横並びに */
}
.app-container {
flex: 1;
display: flex;
/* 左右 2 カラム */
overflow: hidden;
/* 高さは 100vh 固定 */
}
/* --------------------------------------------------
左:チャットパネル(幅 720px 固定)
-------------------------------------------------- */
.chat-panel {
width: 640px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--panel-border);
}
/* 既存チャットヘッダー */
.chat-header {
position: sticky;
top: 0;
z-index: 10;
background: #06c755;
color: #fff;
padding: 12px 16px;
font-size: 1.1rem;
font-weight: 500;
}
/* 既存チャットボディ */
.chat-body {
flex: 1;
overflow-y: auto;
padding: 16px;
background: var(--bg-chat);
display: flex;
flex-direction: column;
gap: 12px;
}
/* 行/吹き出し/アイコン(前回までと同じ) */
.msg-row {
display: flex;
}
.msg-row.self {
flex-direction: row-reverse;
align-items: flex-end;
}
.msg-row.other {
align-items: flex-start;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
margin: 0 8px;
}
.status {
font-size: .7rem;
color: #666;
line-height: 1.2;
margin: 0 8px;
}
.msg-row.self .status {
align-self: flex-end;
}
/* 自分側:左で下揃え */
.msg-row.other .status {
align-self: flex-end;
}
/* 相手側:右で下揃え */
.msg-bubble {
max-width: 70%;
padding: 8px 12px;
font-size: .95rem;
line-height: 1.4;
border-radius: 18px;
position: relative;
}
.msg-row.self .msg-bubble {
background: var(--bubble-self);
border-bottom-right-radius: 4px;
}
.msg-row.self .msg-bubble::after {
content: '';
position: absolute;
right: -6px;
bottom: 0;
border: 6px solid transparent;
border-left-color: var(--bubble-self);
}
.msg-row.other .msg-bubble {
background: var(--bubble-other);
border-bottom-left-radius: 4px;
}
.msg-row.other .msg-bubble::after {
content: '';
position: absolute;
left: -6px;
bottom: 0;
border: 6px solid transparent;
border-right-color: var(--bubble-other);
}
/* --------------------------------------------------
右:入力+ログパネル(残り幅全部)
-------------------------------------------------- */
.side-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ▼ 入力エリア */
.input-area {
padding: 16px;
border-bottom: 1px solid var(--panel-border);
display: flex;
flex-direction: column;
gap: 12px;
}
.input-area textarea {
width: 100%;
resize: vertical;
padding: 8px;
font-family: inherit;
}
/* ボタン無効時の視認性向上 */
.send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* テキストエリア無効時の背景色 */
textarea:disabled {
background: #f0f0f0;
}
.send-btn {
align-self: flex-start;
padding: 6px 16px;
font-size: .9rem;
cursor: pointer;
}
/* ▼ パラメータエリア */
.param-area {
padding: 16px;
border-bottom: 1px solid var(--panel-border);
}
.param-area h3 {
margin: 0 0 8px;
font-size: 1rem;
}
.param-area label {
display: flex;
align-items: center;
gap: 6px;
font-size: .9rem;
}
/* コストエリア */
.cost-area {
padding: 16px;
border-bottom: 1px solid var(--panel-border);
}
.cost-area h3 {
margin: 0 0 8px;
font-size: 1rem;
}
.cost-area pre {
margin: 0;
padding: 12px;
background: #f6f8fa;
border-radius: 6px;
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
/* ▼ ログエリア */
.log-area {
flex: 1;
/* 残り高さ全部 */
padding: 16px;
overflow-y: auto;
}
.log-area pre {
background: #f6f8fa;
padding: 12px;
border-radius: 6px;
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
JavaScript
like-like-ui.js
/* ===== 参考にしたドキュメント =====
* OpenAI API ドキュメント https://platform.openai.com/docs/api-reference/responses/
*/
/* ====== API Key ======
* ★ローカルでのデモ用にのみ使用する。不特定多数の人がアクセスできる場所には公開しない!
* ★「ブラウザ直叩き+API キー埋め込み」は本番・公開では禁止
*/
const OPENAI_API_KEY = 'Write_API_Key_Here';
/* ====== 定数 ====== */
const input_unit_price = 2.5 / 1_000_000; // USD/token
const output_unit_price = 10.0 / 1_000_000; // USD/token
const dollar_to_jpy = 145; // 1 USD = 145 JPY
/* ====== Global変数 ====== */
let previous_response_id = null; // 直近の response.id を保存する
let total_input_tokens = 0; // 入力トークンの総数
let total_output_tokens = 0; // 出力トークンの総数
let total_input_cost = 0.0; // 入力トークンの総コスト
let total_output_cost = 0.0; // 出力トークンの総コスト
/* ====== ヘルパー ====== */
const nfComma = new Intl.NumberFormat('en-US'); // 1,234,567
const nfJPY = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 3,
maximumFractionDigits: 3
});
/* 送信ボタンと入力系をまとめて取得しておく ---------------------------- */
const sendBtn = document.querySelector('.send-btn');
const inputsDOM = document.querySelectorAll('#prompt, #usePrevId'); // ← 必要に応じて追加
/* UI の ON / OFF ヘルパー */
function toggleInputLock(lock = true) {
sendBtn.disabled = lock;
inputsDOM.forEach(el => (el.disabled = lock));
}
/* --- Ctrl+Enter 送信ハンドラを追加 ---------------------- */
document.getElementById('prompt').addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault(); // 改行を阻止
document.getElementById('send').click(); // ボタンを擬似クリック
}
});
/* ====== UI ハンドラ ====== */
document.getElementById('send').addEventListener('click', async () => {
const input = document.getElementById('prompt').value.trim();
if (!input) return;
/* 入力無効化 */
toggleInputLock(true);
/* --- レスポンスID --- */
// チェックボックスの状態を判定、ONなら前のレスポンスIDを使用、OFFならnull
const usePrevID = document.getElementById('usePrevId').checked;
previous_response_id = usePrevID ? previous_response_id : null;
try {
const res = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENAI_API_KEY}`
},
body: JSON.stringify({
model: 'gpt-4.1',
input,
previous_response_id
})
});
/* ---- エラー処理 ---- */
if (!res.ok) {
updateResult({ input, error: `HTTP ${res.status}: ${await res.text()}` });
return;
}
const data = await res.json();
updateResult({ input, data });
/* prompt の内容をクリア */
document.getElementById('prompt').value = '';
} catch (err) {
updateResult({ input, error: err.message });
} finally {
/* 終了後に再び入力有効化 */
toggleInputLock(false);
}
});
/* ====== ログ追加 ====== */
function updateResult({ input, data, error }) {
/* --- JSON ログの更新 --- */
const jsonLog = document.getElementById('jsonLog');
jsonLog.textContent = data.choices?.[0]?.output ?? JSON.stringify(data, null, 2);
/* --- トークンの集計とコスト計算 --- */
const usage = data.usage ?? {};
const inTok = usage.input_tokens ?? 0;
total_input_tokens += inTok;
const outTok = usage.output_tokens ?? 0;
total_output_tokens += outTok;
const inCost = inTok * input_unit_price * dollar_to_jpy;
total_input_cost += inCost;
const outCost = outTok * output_unit_price * dollar_to_jpy;
total_output_cost += outCost;
/* --- トークン・コストの表示更新 --- */
const costDisplay = document.getElementById('costDisplay');
costDisplay.innerHTML = `
<div>
<span class="label">入力トークン合計:</span>
${nfComma.format(total_input_tokens)} tks, ${nfJPY.format(total_input_cost)} JPY
</div>
<div>
<span class="label">出力トークン合計:</span>
${nfComma.format(total_output_tokens)} tks, ${nfJPY.format(total_output_cost)} JPY
</div>
<div>
<span class="label">総合計:</span>
${nfComma.format(total_input_tokens + total_output_tokens)} tks,
${nfJPY.format(total_input_cost + total_output_cost)} JPY
</div>
`;
/* --- 入力内容の追加 --- */
addSelfMessage(input, `${nfComma.format(inTok)} tks, ${nfJPY.format(inCost)} JPY`, '');
/* --- 回答内容の追加 --- */
const outputText = data.output?.[0]?.content?.find(c => c.type === 'output_text')?.text ?? '(no output)';
addResponseMessage(outputText, `${nfComma.format(outTok)} tks, ${nfJPY.format(outCost)} JPY`, '');
/* --- レスポンスIDの更新 --- */
previous_response_id = data.id;
}
function addSelfMessage(text, s1, s2 = '') {
if (!text.trim()) return; // 空行は無視
/* <section class="msg-row self"> を組み立てる */
const row = document.createElement('section');
row.className = 'msg-row self';
/* ステータス */
const st = document.createElement('div');
st.className = 'status';
st.innerHTML = `${s1}`;
/* 吹き出し */
const bubble = document.createElement('p');
bubble.className = 'msg-bubble';
bubble.textContent = text;
/* DOM 順にする(bubble ➜ status) */
row.append(bubble, st);
/* 追加してスクロール最下部へ */
const chatBody = document.getElementById('chatBody');
chatBody.appendChild(row);
}
function addResponseMessage(text, s1 = '', s2 = '') {
if (!text.trim()) return; // 空行は無視
const row = document.createElement('section');
row.className = 'msg-row other';
const img = document.createElement('img');
img.className = 'avatar';
img.src = 'https://placehold.co/60x60?text=AI';
img.alt = '相手';
const bubble = document.createElement('p');
bubble.className = 'msg-bubble';
const safeText = escapeHtml(text);
const htmlWithBreaks = convertNewlines(safeText);
bubble.innerHTML = htmlWithBreaks;
const st = document.createElement('div');
st.className = 'status';
st.innerHTML = `${s1}`;
/* DOM 順:アイコン → 吹き出し → ステータス */
row.append(img, bubble, st);
const chatBody = document.getElementById('chatBody');
chatBody.appendChild(row);
chatBody.scrollTop = chatBody.scrollHeight;
}
/* ====== XSS 逃げ(簡易) ====== */
function escapeHtml(str) {
return str.replace(/[&<>"']/g, s => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[s]));
}
/* 改行を <br> に変換するヘルパー */
function convertNewlines(str) {
return str.replace(/\\n/g, "<br>").replace(/\n/g, "<br>");
}