2
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?

月額100円台のロリポップで120B巨大AIを動かす!フロントエンドおじさんがCORSとWAFを力技で突破して「ai& Inference」を試した話

2
Posted at

はじめに

こんにちは、普段はフロントエンドばかり触っている「フロントエンドおじさん」です。

最近、国内のAIモデル推論プラットフォームである「ai& Inference」のQiita書き込みキャンペーン(OpenAI・Claude APIと互換性あり!国内AIモデル推論プラットフォーム『ai& Inference』を使ってみよう)を見つけました。なんと最優秀賞はGeForce RTX 5070。ローカルLLMをブンブン回したい身としては、喉から手が出るほど欲しい逸品です。

「OpenAI互換APIだから、普段作っている自作のフロントエンド(Vanilla JS)の接続先をサクッと書き換えれば動くだけっしょ!」

と、お気楽に手を出した結果、共有レンタルサーバー特有の「CORSの壁」と「WAF(403エラー)の罠」にブチ当たりました。本記事は、それらを力技(とちょっとのPHP)で突破し、最安のロリポップ!エコノミープラン上で openai/gpt-oss-120bを無事に爆速稼働させた執念の記録です。


1. 構築した環境とやりたかったこと

最初は、私が元々作っていたAIチャットツールにai&のAPIを指定してテストしました。

  • UIスタンス: HTML / Vanilla JS / CSS(外部ライブラリに頼らない軽量設計)
  • ホスト環境: ロリポップ!エコノミープラン(月額100円台〜の共有レンタルサーバー)
  • ターゲットAPI: ai& Inference(api.aiand.com / openai/gpt-oss-120b, ``)

仕組みは極めてシンプル。HTML側でAPIキーとモデル名を入力し、チャット形式でAPIを叩くだけです。


2. 第1の壁:ブラウザの天敵「CORSエラー」

最初は、HTML/JSから直接 https://api.aiand.com/v1/chat/completionsfetch() でリクエストを投げました。しかし、ブラウザのコンソールに非情な赤文字が浮かび上がります。

Access-Control-Allow-Origin header is missing...(CORSエラー)

API側がブラウザからの直接接続を許可していないため、セキュリティロックがかかりました。フロントエンドだけで完結させたかったのですが、ここで断念。同じロリポップのサーバー内に通信を中継(プロキシ)するだけの超軽量なPHPファイルを1枚置くことで解決を図りました。

通信の流れ

[ブラウザ(HTML/JS)] ──(自サーバー内通信)──> [proxy.php] ──(サーバー間 cURL通信)──> [api.aiand.com]

サーバー同士の通信であれば、ブラウザのCORS規制は一切関係ありません。また、この方法なら、ユーザーが画面に入力したAPIキーを安全に中継できます。


3. 第2の壁:コードを貼ったら403を返すロリポップの「WAF」

中継プロキシのおかげで、テキストの会話は完璧に動くようになりました。「よしよし」と思い、AIのテストとして「HTMLのスニペット(コードの断片)」をプロンプトに入力して送信したその時、事件が起きました。

403 Forbidden(アクセス拒否)

レンタルサーバーをお使いの方ならピンとくるでしょう。ロリポップに標準搭載されている強力なセキュリティ機能「WAF(Web Application Firewall:SiteGuard)」の誤検知です。
プロンプトに含まれる <script><div> といったコードを、WAFが「悪意あるインジェクション攻撃(SQL注釈やOSコマンド攻撃)」だと勘違いして、通信を強制遮断してしまったのです。

今回はテストツールなので、これを回避する必要があります。ロリポップの管理画面からWAFの検出ログを確認し、該当するシグネチャ(検出ルール)を特定。
HTMLやPHPと同じディレクトリの .htaccess に以下を追記し、特定の誤検知ルールのみをピンポイントで除外しました。

# ロリポップのWAF(SiteGuard)の誤検知を回避
SiteGuard_User_ExcludeSig sqlinj-15
SiteGuard_User_ExcludeSig sqlinj-55
SiteGuard_User_ExcludeSig oscmd-21

※注意:これは自作ツールを特定環境でテストするための設定です。本番公開するアプリでは、プロンプトのサニタイズなどの対策を推奨します。


4. 実装ソースコード(完全版)

実際にロリポップで完全に動作した、ai& Inference専用のフロントエンド(index.html)と、中継用プロキシ(proxy.php)の全コードです。

スクリーンショット 2026-06-17 144250.png

index.html

Vanilla JSで組んでいます。マークダウンレンダリング(marked.js)や画像添付(ビジョンモデル用)の機能、ログのJSON保存機能を網羅しています。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI Chat (aiand.com Dedicated)</title>
    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>

    <style>
        html, body { 
            height: 100%; 
            margin: 0; 
            padding: 0;
            font-family: sans-serif; 
            background-color: #f4f4f9; 
        }

        #chat-container { 
            max-width: 900px; 
            margin: 0 auto; 
            background: #fff; 
            border-radius: 8px; 
            box-shadow: 0 4px 6px rgba(0,0,0,0.1); 
            display: flex; 
            flex-direction: column; 
            height: 100vh; 
            overflow: hidden; 
        }
        
        #settings-bar { 
            padding: 10px 15px; 
            background-color: #e9ecef; 
            border-bottom: 1px solid #ddd; 
            display: flex; 
            align-items: center; 
            gap: 8px; 
            flex-shrink: 0; 
            flex-wrap: wrap; 
        }
        #settings-bar label { font-weight: bold; font-size: 12px; }
        #api-key-input, #model-input { padding: 5px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
        #api-key-input { width: 180px; }
        #model-input { width: 140px; }
        
        .btn {
            padding: 5px 12px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 13px;
            transition: opacity 0.2s;
        }
        .btn:hover { opacity: 0.8; }
        
        #char-setting-btn { background-color: #20a8d8; color: white; }
        #reload-btn { background-color: #ffc107; color: #333; }
        #help-btn { background-color: #17a2b8; color: white; }
        #save-json-btn { background-color: #6c757d; color: white; }
        #load-json-btn { background-color: #28a745; color: white; }

        #messages {
            flex-grow: 1; 
            padding: 15px;
            overflow-y: auto; 
            border-bottom: 1px solid #eee;
            scroll-behavior: smooth;
        }
        
        .message-wrapper { margin-bottom: 15px; }
        .message { padding: 12px 16px; border-radius: 8px; max-width: 90%; line-height: 1.5; font-size: 15px; }
        .user { background-color: #dcf8c6; margin-left: auto; white-space: pre-wrap; word-break: break-all; }
        .ai-wrapper { display: flex; flex-direction: column; align-items: flex-start; margin-right: auto; }
        .ai { background-color: #e5e5ea; text-align: left; min-width: 50px; word-break: break-all; }
        
        .markdown-content pre { background: #2d2d2d; color: #f8f8f2; padding: 12px; border-radius: 6px; overflow-x: auto; margin: 10px 0; }
        .markdown-content code { font-family: monospace; background: rgba(0,0,0,0.08); padding: 2px 4px; border-radius: 3px; }
        .markdown-content p { margin: 8px 0; }

        .thinking-block {
            max-width: 90%;
            margin-bottom: 6px;
            border: 1px solid #b8c0cc;
            border-radius: 8px;
            overflow: hidden;
            font-size: 13px;
        }
        .thinking-summary {
            display: flex;
            align-items: center;
            gap: 6px;
            padding: 6px 12px;
            background-color: #d8dde6;
            cursor: pointer;
            user-select: none;
            color: #444;
            font-weight: bold;
        }
        .thinking-summary:hover { background-color: #c8cfd8; }
        .thinking-arrow {
            display: inline-block;
            transition: transform 0.2s;
            font-size: 10px;
        }
        .thinking-arrow.open { transform: rotate(90deg); }
        .thinking-content {
            display: none;
            padding: 10px 14px;
            background-color: #f0f2f5;
            color: #555;
            white-space: pre-wrap;
            word-break: break-all;
            line-height: 1.6;
            max-height: 300px;
            overflow-y: auto;
        }
        .thinking-content.open { display: block; }

        .thinking-indicator {
            max-width: 90%;
            margin-bottom: 6px;
            padding: 6px 12px;
            background-color: #d8dde6;
            border-radius: 8px;
            font-size: 13px;
            color: #555;
            font-style: italic;
        }
        .thinking-dot::after {
            content: '';
            animation: dots 1.2s steps(3, end) infinite;
        }
        @keyframes dots {
            0%   { content: ''; }
            33%  { content: '.'; }
            66%  { content: '..'; }
            100% { content: '...'; }
        }
        
        #input-form { 
            display: flex; 
            flex-direction: column;
            padding: 15px; 
            border-top: 1px solid #eee; 
            background: #fff;
        }
        #user-input { 
            width: 100%;
            min-height: 60px;
            max-height: 250px;
            padding: 12px; 
            border: 1px solid #ccc; 
            border-radius: 8px; 
            font-size: 16px; 
            resize: vertical;
            box-sizing: border-box;
            margin-bottom: 8px;
            font-family: inherit;
        }
        .input-controls {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .input-hint { font-size: 12px; color: #888; }
        #send-button { 
            padding: 8px 24px; 
            background-color: #007bff; 
            color: white; 
            border: none; 
            border-radius: 4px; 
            cursor: pointer; 
            font-size: 16px; 
        }
        #send-button:disabled { background-color: #ccc; }
        
        .status-msg { text-align: center; color: #888; font-size: 12px; padding: 10px; }
        
        dialog { border: none; padding: 30px; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); max-width: 600px; width: 90%; line-height: 1.6; }
        dialog::backdrop { background-color: rgba(0,0,0,0.5); }
        dialog h2 { margin-top: 0; color: #333; border-bottom: 2px solid #eee; padding-bottom: 10px; }
        .dialog-content ul { padding-left: 20px; }
        .dialog-content li { margin-bottom: 8px; }

        #image-preview-area {
            display: none;
            flex-wrap: wrap;
            gap: 8px;
            padding: 8px 0;
            border-bottom: 1px solid #eee;
            margin-bottom: 8px;
        }
        .preview-item {
            position: relative;
            display: inline-block;
        }
        .preview-item img {
            width: 80px;
            height: 80px;
            object-fit: cover;
            border-radius: 6px;
            border: 1px solid #ccc;
            display: block;
        }
        .preview-item .remove-img-btn {
            position: absolute;
            top: -6px;
            right: -6px;
            width: 18px;
            height: 18px;
            border-radius: 50%;
            background: #e53e3e;
            color: white;
            border: none;
            cursor: pointer;
            font-size: 11px;
            line-height: 18px;
            text-align: center;
            padding: 0;
        }
        #image-attach-btn {
            background: none;
            border: 1px solid #aaa;
            border-radius: 4px;
            padding: 4px 10px;
            cursor: pointer;
            font-size: 13px;
            color: #555;
        }
        #image-attach-btn:hover { background: #f0f0f0; }

        .user-images {
            display: flex;
            flex-wrap: wrap;
            gap: 6px;
            margin-bottom: 6px;
            justify-content: flex-end;
        }
        .user-images img {
            max-width: 200px;
            max-height: 200px;
            border-radius: 6px;
            border: 1px solid #bbb;
            cursor: pointer;
        }
        #img-lightbox {
            display: none;
            position: fixed;
            inset: 0;
            background: rgba(0,0,0,0.8);
            z-index: 9999;
            align-items: center;
            justify-content: center;
            cursor: zoom-out;
        }
        #img-lightbox.active { display: flex; }
        #img-lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; }
    </style>
</head>
<body>

<div id="chat-container">
    <div id="settings-bar">
        <label>API Key:</label>
        <input type="password" id="api-key-input" placeholder="sk-...">
        <label>モデル名:</label>
        <input type="text" id="model-input" placeholder="openai/gpt-oss-120b">
        <button id="char-setting-btn" class="btn">設定</button>
        <button id="save-json-btn" class="btn">保存</button>
        <button id="load-json-btn" class="btn">読込</button>
        <button id="reload-btn" class="btn">クリア</button>
        <button id="help-btn" class="btn">説明</button>
        <input type="file" id="file-input" style="display:none" accept=".json">
    </div>
    
    <div id="messages">
        <div class="status-msg">API Keyとモデル名を入力してメッセージを送ってください。</div>
    </div>

    <form id="input-form">
        <div id="image-preview-area"></div>
        <textarea id="user-input" placeholder="メッセージを入力... (Enterで送信 / 画像はCtrl+Vでペースト可)"></textarea>
        <div class="input-controls">
            <div style="display:flex; align-items:center; gap:8px;">
                <button type="button" id="image-attach-btn">🖼 画像添付</button>
                <span class="input-hint" id="status-display">準備完了</span>
            </div>
            <button type="submit" id="send-button" disabled>送信</button>
        </div>
    </form>
</div>

<input type="file" id="image-file-input" style="display:none" accept="image/*" multiple>

<div id="img-lightbox">
    <img id="lightbox-img" src="" alt="">
</div>

<dialog id="char-setting-dialog">
    <h2>キャラクター設定</h2>
    <div class="dialog-content">
        <div style="margin-bottom:10px;">
            <label>プリセット:</label>
            <select id="char-preset" style="width:100%; padding:5px;">
                <option value="default">デフォルト</option>
                <option value="expert">専門家</option>
                <option value="counselor">カウンセラー</option>
                <option value="casual">カジュアル</option>
                <option value="custom">カスタム</option>
            </select>
        </div>
        <div>
            <label>システムプロンプト:</label>
            <textarea id="char-system-prompt" style="width:100%; min-height:100px; padding:8px; border: 1px solid #ccc; border-radius: 4px;"></textarea>
        </div>
        <div style="margin-top:10px;">
            <label>回答の長さ:</label>
            <select id="length-preset" style="width:100%; padding:5px;">
                <option value="nolimit">指定なし</option>
                <option value="long">長め (1000字)</option>
                <option value="short">短め (300字)</option>
                <option value="very_short">超短め (120字)</option>
            </select>
        </div>
    </div>
    <div style="margin-top:20px; text-align:right;">
        <button id="save-char-btn" style="background:#007bff; color:white; padding:8px 15px; border:none; border-radius:4px; cursor:pointer;">保存して再開</button>
        <button id="close-dialog-btn" style="padding:8px 15px; border:none; border-radius:4px; cursor:pointer;">閉じる</button>
    </div>
</dialog>

<dialog id="help-dialog">
    <h2>SF-OS AI Chat (aiand.com専用) の使い方</h2>
    <div class="dialog-content">
        <p>このツールは、aiand.comのAPIサービスに特化したチャットUIです。同一ディレクトリ内の <code>proxy.php</code> を経由して安全に通信を行います。</p>
        <ul>
            <li><strong>設定:</strong> API Keyとモデル名を入力すると送信が可能になります。入力値はブラウザに自動保存されます。</li>
            <li><strong>画像利用:</strong> クリップボードからの貼り付け(Ctrl+V)やファイル添付に対応しています。</li>
            <li><strong>ログの管理:</strong> 「保存」でJSON形式の履歴を出力、「読込」で過去の対話を再現できます。</li>
        </ul>
    </div>
    <div style="margin-top:20px; text-align:right;">
        <button id="close-help-btn" style="background:#6c757d; color:white; padding:8px 15px; border:none; border-radius:4px; cursor:pointer;">閉じる</button>
    </div>
</dialog>

<script>
    const LOCAL_STORAGE_KEY_KEY = 'sfAiChat_dedicated_apiKey';
    const LOCAL_STORAGE_KEY_MODEL = 'sfAiChat_dedicated_modelName';
    const LOCAL_STORAGE_KEY_CHAR_SETTINGS = 'sfAiChat_dedicated_charSettings';
    
    // 通信先は同一ディレクトリの proxy.php 固定
    const PROXY_API_URL = './proxy.php';

    const messagesContainer = document.getElementById('messages');
    const inputForm = document.getElementById('input-form');
    const userInput = document.getElementById('user-input');
    const sendButton = document.getElementById('send-button');
    const apiKeyInput = document.getElementById('api-key-input');
    const modelInput = document.getElementById('model-input');
    const statusDisplay = document.getElementById('status-display');
    const charSettingDialog = document.getElementById('char-setting-dialog');
    const helpDialog = document.getElementById('help-dialog');
    const fileInput = document.getElementById('file-input');

    let chatHistory = [];
    let pendingImages = []; 
    let isAutoScroll = true;
    let currentSettings = {};

    const CHAR_PRESETS = {
        'default': 'あなたは親切で丁寧なアシスタントです。',
        'expert': 'あなたは特定の分野に精通した専門家です。論理的で詳細な回答をします。',
        'counselor': 'あなたは共感的で優しいカウンセラーです。',
        'casual': 'あなたは親しみやすいネットの友人です。タメ口で話します。',
        'custom': ''
    };

    const LENGTH_PRESETS = {
        'nolimit': { max_tokens: 4096, prompt_suffix: '' },
        'long': { max_tokens: 1200, prompt_suffix: ' 回答は1000文字程度で。' },
        'short': { max_tokens: 400, prompt_suffix: ' 回答は300文字以内で。' },
        'very_short': { max_tokens: 150, prompt_suffix: ' 回答は120文字以内で。' }
    };

    function escapeHTML(str) {
        return str.replace(/[&<>"']/g, function(m) {
            return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m];
        });
    }

    function extractThinking(text) {
        const thinkingParts = [];
        const re = /<\|channel>thought([\s\S]*?)<channel\|>/g;
        let match;
        while ((match = re.exec(text)) !== null) {
            thinkingParts.push(match[1].trim());
        }
        const mainText = text.replace(re, '').trim();
        return { thinkingParts, mainText };
    }

    function createThinkingBlockEl(thinkingText, index) {
        const block = document.createElement('div');
        block.className = 'thinking-block';
        const id = 'thinking-' + Date.now() + '-' + index;
        block.innerHTML = `
            <div class="thinking-summary" onclick="toggleThinking('${id}')">
                <span class="thinking-arrow" id="arrow-${id}">▶</span>
                💭 思考プロセス
            </div>
            <div class="thinking-content" id="${id}">${escapeHTML(thinkingText)}</div>
        `;
        return block;
    }

    function toggleThinking(id) {
        const content = document.getElementById(id);
        const arrow = document.getElementById('arrow-' + id);
        if (!content) return;
        const isOpen = content.classList.toggle('open');
        arrow.classList.toggle('open', isOpen);
    }

    // --- 画像添付 ---
    const imagePreviewArea = document.getElementById('image-preview-area');
    const imageFileInput = document.getElementById('image-file-input');
    const imageAttachBtn = document.getElementById('image-attach-btn');
    const lightbox = document.getElementById('img-lightbox');
    const lightboxImg = document.getElementById('lightbox-img');

    function fileToDataUrl(file) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = e => resolve({ dataUrl: e.target.result, mediaType: file.type });
            reader.onerror = reject;
            reader.readAsDataURL(file);
        });
    }

    function addImageToPreview(dataUrl, mediaType) {
        pendingImages.push({ dataUrl, mediaType });
        const idx = pendingImages.length - 1;
        const item = document.createElement('div');
        item.className = 'preview-item';
        item.dataset.idx = idx;
        item.innerHTML = `<img src="${dataUrl}" alt="添付画像"><button class="remove-img-btn" title="削除">✕</button>`;
        item.querySelector('.remove-img-btn').onclick = () => {
            pendingImages.splice(idx, 1);
            item.remove();
            if (pendingImages.length === 0) imagePreviewArea.style.display = 'none';
            [...imagePreviewArea.querySelectorAll('.preview-item')].forEach((el, i) => el.dataset.idx = i);
        };
        imagePreviewArea.appendChild(item);
        imagePreviewArea.style.display = 'flex';
    }

    imageAttachBtn.onclick = () => imageFileInput.click();
    imageFileInput.onchange = async (e) => {
        for (const file of e.target.files) {
            if (!file.type.startsWith('image/')) continue;
            const { dataUrl, mediaType } = await fileToDataUrl(file);
            addImageToPreview(dataUrl, mediaType);
        }
        imageFileInput.value = '';
    };

    userInput.addEventListener('paste', async (e) => {
        const items = e.clipboardData?.items;
        if (!items) return;
        for (const item of items) {
            if (item.type.startsWith('image/')) {
                e.preventDefault();
                const file = item.getAsFile();
                const { dataUrl, mediaType } = await fileToDataUrl(file);
                addImageToPreview(dataUrl, mediaType);
            }
        }
    });

    lightbox.onclick = () => lightbox.classList.remove('active');

    function openLightbox(src) {
        lightboxImg.src = src;
        lightbox.classList.add('active');
    }

    function renderUserImages(images) {
        if (!images || images.length === 0) return null;
        const container = document.createElement('div');
        container.className = 'user-images';
        images.forEach(({ dataUrl }) => {
            const img = document.createElement('img');
            img.src = dataUrl;
            img.alt = '添付画像';
            img.onclick = () => openLightbox(dataUrl);
            container.appendChild(img);
        });
        return container;
    }

    function checkInputs() {
        const hasKey = apiKeyInput.value.trim().length > 0;
        sendButton.disabled = !hasKey;
        statusDisplay.textContent = hasKey ? '✅ 準備完了' : '❌ API Keyを入力してください';
    }

    function loadSettings() {
        const savedKey = localStorage.getItem(LOCAL_STORAGE_KEY_KEY);
        if (savedKey) { apiKeyInput.value = savedKey; }

        const savedModel = localStorage.getItem(LOCAL_STORAGE_KEY_MODEL);
        modelInput.value = savedModel ? savedModel : 'gemma-4';
        
        const savedSettings = localStorage.getItem(LOCAL_STORAGE_KEY_CHAR_SETTINGS);
        currentSettings = savedSettings ? JSON.parse(savedSettings) : {
            charPreset: 'default', charSystemPrompt: CHAR_PRESETS['default'], lengthPreset: 'nolimit'
        };
        document.getElementById('char-preset').value = currentSettings.charPreset;
        document.getElementById('char-system-prompt').value = currentSettings.charSystemPrompt;
        document.getElementById('length-preset').value = currentSettings.lengthPreset;

        checkInputs();
    }

    apiKeyInput.oninput = () => {
        localStorage.setItem(LOCAL_STORAGE_KEY_KEY, apiKeyInput.value);
        checkInputs();
    };
    modelInput.oninput = () => localStorage.setItem(LOCAL_STORAGE_KEY_MODEL, modelInput.value);

    messagesContainer.onscroll = () => {
        const offset = messagesContainer.scrollHeight - messagesContainer.clientHeight - messagesContainer.scrollTop;
        isAutoScroll = offset < 50;
    };

    function smoothScroll() {
        if (isAutoScroll) {
            window.requestAnimationFrame(() => {
                messagesContainer.scrollTop = messagesContainer.scrollHeight;
            });
        }
    }

    function createAiContainer() {
        const wrapper = document.createElement('div');
        wrapper.className = 'message-wrapper ai-wrapper';
        const thinkingIndicator = document.createElement('div');
        thinkingIndicator.className = 'thinking-indicator';
        thinkingIndicator.innerHTML = '💭 思考中<span class="thinking-dot"></span>';
        thinkingIndicator.style.display = 'none';
        const controlButtons = document.createElement('div');
        controlButtons.className = 'control-buttons';
        controlButtons.style.display = 'none';
        const copyBtn = document.createElement('button');
        copyBtn.textContent = 'コピー';
        copyBtn.style = "background: #666; color: white; border: none; padding: 4px 8px; font-size: 11px; border-radius: 3px; cursor: pointer;";
        controlButtons.appendChild(copyBtn);
        const aiDiv = document.createElement('div');
        aiDiv.className = 'message ai';
        aiDiv.textContent = '...';
        wrapper.appendChild(thinkingIndicator);
        wrapper.appendChild(controlButtons);
        wrapper.appendChild(aiDiv);
        messagesContainer.appendChild(wrapper);
        return { wrapper, div: aiDiv, controls: controlButtons, copyBtn, thinkingIndicator };
    }

    function renderUserMsg(txt, images) {
        const wrapper = document.createElement('div');
        wrapper.className = 'message-wrapper';
        const imgEl = renderUserImages(images);
        if (imgEl) wrapper.appendChild(imgEl);
        const div = document.createElement('div');
        div.className = 'message user';
        div.textContent = txt;
        if (txt) wrapper.appendChild(div);
        messagesContainer.appendChild(wrapper);
        smoothScroll();
    }

    function finalizeAiMsg(container, fullRawText) {
        const { thinkingParts, mainText } = extractThinking(fullRawText);
        container.thinkingIndicator.style.display = 'none';
        thinkingParts.forEach((tp, i) => {
            const blockEl = createThinkingBlockEl(tp, i);
            container.wrapper.insertBefore(blockEl, container.div);
        });
        const html = DOMPurify.sanitize(marked.parse(mainText || '(本文なし)'));
        container.div.innerHTML = `<div class="markdown-content">${html}</div>`;
        container.controls.style.display = 'flex';
        container.copyBtn.onclick = () => {
            navigator.clipboard.writeText(mainText);
            container.copyBtn.textContent = '完了!';
            setTimeout(() => container.copyBtn.textContent = 'コピー', 1000);
        };
        smoothScroll();
    }

    async function sendMessage() {
        const text = userInput.value.trim();
        const images = [...pendingImages];
        if (!text && images.length === 0) return;

        renderUserMsg(text, images);
        userInput.value = '';
        userInput.style.height = 'auto';
        pendingImages = [];
        imagePreviewArea.innerHTML = '';
        imagePreviewArea.style.display = 'none';

        sendButton.disabled = true;
        isAutoScroll = true;

        const aiBox = createAiContainer();
        let fullText = "";
        let inThinking = false;

        const systemMsg = { 
            role: "system", 
            content: `あなたは優秀なアシスタントです。回答は必ずMarkdown形式で行ってください。 ${currentSettings.charSystemPrompt} ${LENGTH_PRESETS[currentSettings.lengthPreset].prompt_suffix}` 
        };

        let userContent;
        if (images.length > 0) {
            userContent = [];
            images.forEach(({ dataUrl, mediaType }) => {
                const base64 = dataUrl.split(',')[1];
                userContent.push({
                    type: 'image_url',
                    image_url: { url: `data:${mediaType};base64,${base64}` }
                });
            });
            if (text) userContent.push({ type: 'text', text });
        } else {
            userContent = text;
        }

        // 専用プロキシ(proxy.php)へのヘッダー構築
        const headers = { 
            'Content-Type': 'application/json',
            'X-Proxy-Auth': `Bearer ${apiKeyInput.value.trim()}`
        };

        const modelName = modelInput.value.trim() || "gemma-4";

        try {
            const response = await fetch(PROXY_API_URL, {
                method: 'POST',
                headers: headers,
                body: JSON.stringify({
                    model: modelName,
                    messages: [systemMsg, ...chatHistory, { role: "user", content: userContent }],
                    temperature: 0.7,
                    max_tokens: LENGTH_PRESETS[currentSettings.lengthPreset].max_tokens,
                    stream: true
                })
            });

            const reader = response.body.getReader();
            const decoder = new TextDecoder();

            while (true) {
                const { done, value } = await reader.read();
                if (done) break;
                const chunk = decoder.decode(value);
                const lines = chunk.split('\n');
                for (const line of lines) {
                    if (line.startsWith('data: ')) {
                        const rawData = line.substring(6).trim();
                        if (rawData === '[DONE]') break;
                        try {
                            const json = JSON.parse(rawData);
                            const content = json.choices[0].delta.content;
                            if (content) {
                                fullText += content;
                                if (fullText.includes('<|channel>thought') && !fullText.includes('<channel|>')) {
                                    if (!inThinking) { inThinking = true; aiBox.thinkingIndicator.style.display = 'block'; }
                                } else if (inThinking && fullText.includes('<channel|>')) {
                                    inThinking = false; aiBox.thinkingIndicator.style.display = 'none';
                                }
                                if (!inThinking) {
                                    const { mainText } = extractThinking(fullText);
                                    aiBox.div.textContent = mainText || '...';
                                }
                                smoothScroll();
                            }
                        } catch(e) {}
                    }
                }
            }
            chatHistory.push({ role: "user", content: text || '[画像のみ]' }, { role: "assistant", content: fullText });
            finalizeAiMsg(aiBox, fullText);
        } catch (err) {
            aiBox.thinkingIndicator.style.display = 'none';
            aiBox.div.textContent = "Error: " + err.message;
        } finally {
            sendButton.disabled = false;
        }
    }

    // 各種ボタン操作
    document.getElementById('save-json-btn').onclick = () => {
        const data = { settings: currentSettings, history: chatHistory, date: new Date().toISOString() };
        const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = `sf-chat-dedicated-log-${Date.now()}.json`;
        a.click();
    };

    document.getElementById('load-json-btn').onclick = () => fileInput.click();
    fileInput.onchange = (e) => {
        const file = e.target.files[0];
        if (!file) return;
        const reader = new FileReader();
        reader.onload = (event) => {
            try {
                const data = JSON.parse(event.target.result);
                chatHistory = data.history || [];
                currentSettings = data.settings || currentSettings;
                localStorage.setItem(LOCAL_STORAGE_KEY_CHAR_SETTINGS, JSON.stringify(currentSettings));
                messagesContainer.innerHTML = '<div class="status-msg">履歴を読み込みました</div>';
                chatHistory.forEach(m => {
                    if (m.role === 'user') renderUserMsg(typeof m.content === 'string' ? m.content : '[画像]', []);
                    else { const box = createAiContainer(); finalizeAiMsg(box, m.content); }
                });
            } catch(e) { alert("読込失敗"); }
        };
        reader.readAsText(file);
    };

    userInput.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } };
    userInput.oninput = () => { userInput.style.height = 'auto'; userInput.style.height = (userInput.scrollHeight) + 'px'; };
    inputForm.onsubmit = (e) => { e.preventDefault(); sendMessage(); };
    document.getElementById('reload-btn').onclick = () => { if(confirm('履歴をクリアしますか?')) location.reload(); };
    
    // ダイアログ制御
    document.getElementById('char-setting-btn').onclick = () => charSettingDialog.showModal();
    document.getElementById('close-dialog-btn').onclick = () => charSettingDialog.close();
    document.getElementById('save-char-btn').onclick = () => {
        currentSettings.charPreset = document.getElementById('char-preset').value;
        currentSettings.lengthPreset = document.getElementById('length-preset').value;
        currentSettings.charSystemPrompt = document.getElementById('char-system-prompt').value;
        localStorage.setItem(LOCAL_STORAGE_KEY_CHAR_SETTINGS, JSON.stringify(currentSettings));
        charSettingDialog.close();
    };
    document.getElementById('char-preset').onchange = (e) => {
        if(e.target.value !== 'custom') document.getElementById('char-system-prompt').value = CHAR_PRESETS[e.target.value];
    };

    // 説明ダイアログ
    document.getElementById('help-btn').onclick = () => helpDialog.showModal();
    document.getElementById('close-help-btn').onclick = () => helpDialog.close();

    loadSettings();
</script>
</body>
</html>

proxy.php

ブラウザから届いたリクエストを api.aiand.com へ右から左へ横流しするプロキシです。APIキーはカスタムヘッダー X-Proxy-Auth 経由で安全に渡されます。

<?php
// タイムアウトを無効化(長い生成に対応するため)
set_time_limit(0);

// ストリーミング用のヘッダーを設定
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // nginx環境でのバッファリングを無効化

// ブラウザからのPOSTデータを取得
$inputRaw = file_get_contents('php://input');

// ブラウザが送ってきたヘッダーから APIキー(X-Proxy-Auth) を取得
$headers = getallheaders();
$authHeader = isset($headers['X-Proxy-Auth']) ? $headers['X-Proxy-Auth'] : '';

if (empty($authHeader)) {
    echo "data: " . json_encode(["choices" => [["delta" => ["content" => "Error: API Key is missing in proxy request."]]]]) . "\n\n";
    exit;
}

// aiand.com 向けの通信設定
$targetUrl = 'https://api.aiand.com/v1/chat/completions';

$ch = curl_init($targetUrl);

curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); // 直接出力(WRITEFUNCTIONで制御するためfalse)
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $inputRaw);

// 正しい Authorization ヘッダーを再構築して aiand.com に投げる
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Authorization: ' . $authHeader
]);

// aiand.com からデータを受信するたびに実行されるコールバック関数
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) {
    // 受け取ったデータをそのままブラウザに向けて出力
    echo $data;
    
    // 各種バッファを強制的に空にして即座にブラウザへ送り届ける
    if (ob_get_level() > 0) {
        ob_flush();
    }
    flush();
    
    return strlen($data);
});

// 通信を実行
curl_exec($ch);

if (curl_errno($ch)) {
    $error_msg = curl_error($ch);
    echo "data: " . json_encode(["choices" => [["delta" => ["content" => "Proxy cURL Error: " . $error_msg]]]]) . "\n\n";
}

curl_close($ch);
?>

5. 実際に動かしてみた感想

ロリポップエコノミープランという、スペック的には極小のサーバーですが、中継しているだけなので挙動はめちゃくちゃ軽快です。

openai/gpt-oss-120b のような超巨大モデルを叩いても、レスポンスの初期速度が非常に速く、ストレスなく思考結果が返ってきます。

何より、自作の使い慣れたUIから、世界中のオープンモデル(GemmaやDeepSeekなど)を1行で切り替えて、クローズドな環境で実験できるのが最高に楽しいだろうと思います。


おわりに

フロントエンド完結にこだわった結果、CORSとWAFという「レンタルサーバーあるある」の洗礼を浴びましたが、最小限のPHP(1枚)と .htaccess の調整で見事?に手懐けることができました。

自作UIから最先端の推推論基盤に格安サーバーで繋ぐこの構成、ライトな開発環境としてはかなりアリ?ではないでしょうか。

RTX5070欲しいですけどこの程度じゃ無理ですよねw

2
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
2
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?