0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HTML/CSS/JSだけで作る「LINE風」OpenAIチャットUI

Last updated at Posted at 2025-07-30

目的

Node.jsなどを使わずにHTML/CSS/JSだけで記述されたデモを作ってみたかった。

機能の概要

  • LINE風のチャットUIでAIとチャットできる
  • 2カラム構成
    • 左サイドはチャット
    • 右サイドに入力フォーム、パラメータ指定、料金・トークン表示、ログ表示
      • パラメータ:previous_response_id のON/OFF指定
      • 料金・トークン数表示:入力/出力/合計のトークン数と金額を表示
      • ログ表示: APIレスポンスを表示
  • API KeyはJS内に直接記述する。ローカルでのデモ用にのみ使用する。決して不特定多数の人がアクセスできる場所には公開しない

画面イメージ

image.png

コード

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 => ({
        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[s]));
}

/* 改行を <br> に変換するヘルパー */
function convertNewlines(str) {
    return str.replace(/\\n/g, "<br>").replace(/\n/g, "<br>");
}

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?