「無料3,000リクエスト」を使い切る! 第3回
※再現可能なソースコード全文は文末にあります。
SAKURA Internetは無料で3000リクエストも使えて太っ腹です。
夏休みの自由研究のため、チャットアプリ強化の解説の記録!
300くらいリクエストを使っていました。
Kimi K2.6に300回くらい投げて、自作LLMチャットを「使えるやつ」まで育ててみた
前回、サクラのAIエンジンを使って、とりあえず動くLLMチャットを作った。
今回はそこから先。
「動く」から「普通に使える」まで頑張る。
フロントはJavaScript、バックエンドはPHP。
そしてサクラのAIエンジンをAPIとして接続。
なぜ、PHPかというと、レンタルサーバにBASIC認証とともに配備して、
自分用のチャットとして 使おうという魂胆。
今回の改良では、最終的にKimi K2.6に200〜300リクエストくらい投げた。
AIにコードを書かせる。
動かす。
エラーを返す。
また直させる。
だいぶ頑張ったら、それなりのチャットアプリになっていた。
まず、何を作ったのか
今回作ったのは、こんな感じのLLMチャット。
- JavaScriptフロントエンド
- PHPバックエンド
- サクラのAIエンジン接続
- OpenAI互換のChat Completions API
- ストリーミング表示
- モデル切り替え
- システムプロンプト切り替え
- キャラクター切り替え
- チャット履歴保存
- 履歴から過去チャット復元
- Markdown表示
- シンタックスハイライト
- Mermaid対応
- ダークモード
- 個別回答のコピー
- チャット全体のMarkdown保存
- 1回答ごとの自動ダウンロード
- ローカルLLMにも切り替え可能
最初のサンプルからすると、だいぶ盛った。
一番やりたかったこと
今回の目的は、単純に「高性能なLLMチャットを作る」ことではない。
モデルを自分で切り替えて、違いを実際に体感すること。
サクラのAIエンジンでは複数のモデルを利用できる。
だったら、同じ質問をいろいろなモデルに投げてみればいい。
今回のチャットでは、モデルをプルダウンから切り替えられるようにした。
例えば、
- Kimi-K2.6
- Kimi-K2.7-Code
- Gemma 4 31B
- Qwen3-VL
- Qwen3.6
- gpt-oss-120b
- llm-jp
- Phi-4
など。
「モデルを選べる」だけでは足りなかった
ここで一つ問題が出てきた。
モデルを変えても、何をどう比較すればいいのか分かりにくい。
そこで、
目的 × モデル × キャラクター
という組み合わせにした。
例えば目的として、
- 汎用アシスタント
- プログラミング支援
- 文章要約
- 翻訳
- JSON/構造化出力
- カスタマーサポート
- クリエイティブライティング
- ステップバイステップ推論
- 多言語対応
などを用意。
さらにキャラクターとして、
- 気さくな職場の先輩
- 優しい居酒屋のマスター
- 辛辣だけど愛のあるギャル
- 物静かで洞察のある老人
- 元気で前向きな同世代の友達
を用意した。
つまり、
「どのモデルが一番強いか」ではなく、「どのモデルを、どんな用途で使うと面白いか」
を見るための仕組み。
そして、意外とモデルの個性が見えてくる
実際に触ってみると面白い。
コーディングでは、
Kimi K2.7-Codeがかなり強い。
コード生成や修正を中心に使うなら、かなり頼れる。
一方で、普通のチャットになると話が変わる。
個人的には、
GemmaやQwen系が普通の会話でかなり使いやすい。
応答時間や、質問への返し方や文章の雰囲気など、単純なベンチマークの数字だけでは分からない違いがある。
つまり、
コーディングならKimi一択
だった感覚が、
チャットならKimi一択とは限らない
に変わった。
この差が面白い。
LLMチャットを自作するメリット
既製のチャットサービスを使えば、もちろんもっと簡単。
それでも自作する意味はある。
一番大きいのは、
LLMを「使う側」から「比較する側」に回れること。
例えば、
「このモデルはコード生成が強い」
「このモデルは日本語の会話が自然」
「このモデルは指示を守る」
「このモデルは長い指示に強い」
「このモデルは妙に面白い回答をする」
みたいな違いが見えてくる。
モデル一覧を眺めているだけでは分からない。
実際に同じ質問を投げると、かなり違う。
会話履歴も保存する
もう一つ今回入れたかったのが履歴。
ブラウザのLocalStorageにチャットセッションを保存。
サイドバーから過去のチャットを選択できるようにした。
さらに新しいチャットを始めると、それまでの会話を保存。
これだけで一気に「おもちゃ感」が減る。
LLMチャットは、回答生成そのものより、
「その前後をどう扱うか」
が意外と重要。
回答を自動保存する
個人的に便利だったのがこれ。
LLMの回答が終わると、自動的にMarkdownファイルとして保存。
例えば、
#1_LLMについて_20260811123456.md
#2_PHPについて_20260811123610.md
#3_Qwenについて_20260811123822.md
のようなファイルになる。
さらに「一括DL」で現在のチャット全体もMarkdown化。
LLMとの会話は、その場では便利だけど、あとから見返そうとすると意外と面倒。
だったら最初から保存する。
Markdownだけではなく、Mermaidjsも
LLMに質問すると、コードだけではなく、
- Markdown
- Mermaid
- JSON
なども返ってくる。
そこで回答をMarkdownとしてレンダリング。
さらに、
Mermaid
のような図も表示。
セキュリティ面も最低限考える
前回のサンプル同様にLLM APIをブラウザから直接叩く構成にはしたくなかった。
そこで、
Browser
↓
JavaScript
↓
PHP
↓
Sakura AI Engine
という構成。
APIキーはブラウザ側に出さず、PHP側で管理。
APIキーをHTMLやJavaScriptに埋め込む。
これは避けたい。
ローカルLLMにも切り替え可能
さらに面白いところ。
バックエンドはサクラ専用にはしていない。
設定を変えることで、
Sakura AI
↓
OpenAI互換API
だけではなく、
LM Studio
Ollama
その他OpenAI互換LLM
にも接続できる構造にした。
つまり、
同じチャットUIからモデルだけ差し替える
という使い方ができる。
これが今回かなり気に入っている。
構成は意外とシンプル
全体としてはこんな構成。
┌─────────────────────────┐
│ Browser │
│ │
│ HTML / CSS / JavaScript │
│ │
│ ・Chat UI │
│ ・Model Select │
│ ・Purpose Select │
│ ・Character Select │
│ ・History │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ PHP │
│ │
│ ・API Proxy │
│ ・Environment Config │
│ ・Model List │
│ ・SSE Stream │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ Sakura AI Engine │
│ │
│ Kimi / Qwen / Gemma │
│ GPT-OSS / llm-jp ... │
└─────────────────────────┘
PHPはかなり薄い。
基本的には、
「ブラウザとLLMの間に入る薄いプロキシ」
として使っている。
200〜300回くらいAIに投げて分かったこと
今回一番面白かったのは、コードを書くことそのものより、
AIにコードを書かせる開発方法そのもの。
最初は、
「LLMチャットを作って」
くらいから始める。
そこから、
「履歴を保存したい」
「モデルを切り替えたい」
「Markdownを表示したい」
「コードをコピーしたい」
「Mermaidも表示したい」
「自動保存したい」
「ローカルLLMにも接続したい」
と要求を追加していく。
すると、
LLMチャットを作りながら、LLMチャットの使い勝手を改善している
という妙な状態になる。
これが結構面白い。
ただし、AIに全部任せると普通に壊れる
もちろん万能ではない。
今回も、
- エスケープがおかしくなる
- 履歴インデックスがずれる
- Markdownのレンダリングで問題が出る
- APIエラー処理が甘い
- モデル名の扱いを間違える
みたいな問題は普通に出た。
だから、
AIにコードを書かせる
というより、
AIに実装案を出させて、人間が動作確認して、またAIに直させる
というループが現実的。
今回の200〜300リクエストも、ほぼこの繰り返し。
そして、コードが長くなった
最初は小さなサンプルだった。
ところが機能を追加していくと、
LLM API
↓
モデル切り替え
↓
プロンプト切り替え
↓
キャラクター
↓
履歴
↓
Markdown
↓
Mermaid
↓
ダークモード
↓
自動保存
↓
編集
となり、普通に大きくなった。
AIに「全部入り」を頼むと、こうなる。
便利。
でも長い。
なので、次はこのコードをどう分割していくかも課題。
次回はビルド周り
今回のコードはPHPにHTML、CSS、JavaScriptまで詰め込んでいる。
これは配布や検証には便利。
ただし、本格的に育てるならもう少し整理したい。
次回は、複数ファイルで構成されるphpやhtmlやjs,cssをどうやって1ファイルにしたか
をまとめる予定。
簡単にやりたかったが本編以上に試行錯誤が続いたので、別記事にします。
まとめ
今回やったことをまとめると、
サクラのAIエンジンに接続しただけのLLMチャットを、実際に使えるところまで育てた。
ポイントはこのあたり。
- 複数LLMを切り替え
- 用途別システムプロンプト
- キャラクター切り替え
- チャット履歴
- LocalStorage保存
- Markdown表示
- Mermaid対応
- Chart.js対応
- コードコピー
- ダークモード
- スマホ対応
- 自動Markdown保存
- ローカルLLM対応
- PHPによるAPIプロキシ
そして一番の収穫。
モデルの性能は「最強の1台」で決まらない。
コーディングならKimiが強い。
普通のチャットならQwenが面白い。
軽量モデルには軽量モデルの使い道がある。
結局、
モデル選びも設計の一部。
自分でLLMチャットを作って、同じ質問をいろいろなモデルに投げてみると、この違いがかなり見えてくる。
そして何より、
LLMにLLMチャットを作らせるの、普通に楽しい。
最後にコード全文
ソース全文 (クリックすると展開されます)
今回のソースは1ファイルにまとめている。
PHPの中にHTML、CSS、JavaScript、API処理、モデル定義、システムプロンプトまで入っている。
長い。
かなり長い。
でも、最初に動かしてみるにはこのくらいのほうが分かりやすい。
deploy.sh
#!/bin/sh
LLM_API_KEY=★ここにAPIキーを記載★
if [ ! -f .env ]; then
cat << EOF > .env
PROVIDER=sakura
LLM_MODEL=preview/Qwen3.6-35B-A3B
EOF
fi
if [ ! -f Dockerfile ]; then
cat << EOF > Dockerfile
FROM php:8.3-alpine
RUN apk add --no-cache curl oniguruma-dev && docker-php-ext-install mbstring
RUN adduser -D -s /bin/sh -u 1000 www
WORKDIR /var/www/html
COPY php.ini /usr/local/etc/php/conf.d/99-custom.ini
COPY --chown=www:www . /var/www/html/
RUN chmod -R 550 /var/www/html && chown -R www:www /var/www/html
EXPOSE 80
USER www
CMD ["php", "-S", "0.0.0.0:80", "index.php"]
EOF
fi
docker build --tag phpchat1 ./
docker run --rm -p 8001:80 -e DEVMODE=BACK -v "$(pwd)/.env:/var/www/html/.env:ro" -e LLM_API_KEY=${LLM_API_KEY} phpchat1:latest
index.php
1行ですが、全て詰まっています。
<?php function loadEnv(string $e): void { if (!file_exists($e)) { error_log('[LLMCHAT] .env not found: ' . $e); return; } $b = @file($e, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if ($b === false) { error_log('[LLMCHAT] Failed to read .env: ' . $e); return; } foreach ($b as $d) { $d = trim($d); if ($d === '' || str_starts_with($d, '#')) { continue; } $a = explode('=', $d, 2); $key = trim($a[0]); $c = isset($a[1]) ? trim($a[1]) : ''; if ($key === '') { continue; } if (!array_key_exists($key, $_ENV) && !array_key_exists($key, $_SERVER)) { putenv("$key=$c"); $_ENV[$key] = $c; $_SERVER[$key] = $c; } } } class Config { private static array $d = []; public static function load(): void { loadEnv(__DIR__ . '/.env'); } public static function get(string $key, string $default = ''): string { if (isset(self::$d[$key])) { return self::$d[$key]; } $g = getenv($key); if ($g !== false && $g !== '') { self::$d[$key] = $g; return $g; } $a = '/run/secrets/' . strtolower(str_replace('_', '-', $key)); if (file_exists($a)) { $c = trim(file_get_contents($a)); if ($c !== '') { self::$d[$key] = $c; return $c; } } self::$d[$key] = $default; return $default; } public static function require(string $key): string { $e = self::get($key); if ($e === '') { http_response_code(500); header('Content-Type: application/json'); echo json_encode(['error' => "設定値 $key が未設定です。環境変数またはシークレットで指定してください。"]); exit; } return $e; } public static function getProviderConfig(): array { $b = strtolower(self::get('PROVIDER', 'local')); if ($b === 'sakura') { $key = self::get('LLM_API_KEY', ''); if ($key === '') { http_response_code(500); header('Content-Type: application/json'); echo json_encode(['error' => 'Sakura APIキーが未設定です。環境変数 LLM_API_KEY を設定してください。(形式: UUID:シークレット)']); exit; } return [ 'provider' => 'sakura', 'host' => 'https://api.ai.sakura.ad.jp', 'url' => 'https://api.ai.sakura.ad.jp/v1/chat/completions', 'model' => self::get('LLM_MODEL', 'gpt-oss-120b'), 'key' => $key, ]; } $f = self::get('LLM_HOST', 'http://host.docker.internal:1234'); return [ 'provider' => 'local', 'host' => $f, 'url' => rtrim($f, '/') . '/v1/chat/completions', 'model' => self::get('LLM_MODEL', 'local-model'), 'key' => self::get('LLM_API_KEY', 'not-needed'), ]; } } Config::load(); function handleApiChat(): void { header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); $g = file_get_contents('php://input'); $n = json_decode($g, true); $h = $n['messages'] ?? []; $k = strtolower(Config::get('DEVMODE', '')); function logDebug(string $q): void { error_log('[LLMCHAT DEBUG] ' . $q); } function maskHeader(string $header): string { return str_starts_with($header, 'Authorization:') ? 'Authorization: Bearer ***' : $header; } if ($k === 'front') { logDebug('=== FRONT Mode ==='); logDebug('Raw body: ' . $g); logDebug('Messages: ' . json_encode($h)); $c = ''; foreach (array_reverse($h) as $q) { if (($q['role'] ?? '') === 'user') { $c = $q['content'] ?? ''; break; } } logDebug('Last user message: ' . $c); $id = 'chatcmpl-' . uniqid(); $l = mb_str_split($c, 1); foreach ($l as $o) { echo 'data: ' . json_encode([ 'id' => $id, 'object' => 'chat.completion.chunk', 'choices' => [ ['index' => 0, 'delta' => ['content' => $o], 'finish_reason' => null] ] ]) . "\n\n"; @ob_flush(); flush(); usleep(30000); } echo "data: [DONE]\n\n"; logDebug('FRONT mode completed'); return; } $r = Config::getProviderConfig(); $m = $n['model'] ?? $r['model']; $j = json_encode([ 'model' => $m, 'messages' => $h, 'stream' => true ]); $i = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $r['key'] ]; $d = ($k === 'back'); if ($d) { logDebug('=== LLM API Request ==='); logDebug('Provider: ' . $r['provider']); logDebug('URL: ' . $r['url']); logDebug('Model: ' . $m); foreach ($i as $h) { logDebug('Header: ' . maskHeader($h)); } logDebug('Payload: ' . $j); } $ch = curl_init($r['url']); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $j); curl_setopt($ch, CURLOPT_HTTPHEADER, $i); $a = []; if ($d) { curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$a) { $a[] = trim($header); return strlen($header); }); } $b = ''; curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $p) use ($d, &$b) { if ($d) { $b .= $p; } echo $p; @ob_flush(); flush(); return strlen($p); }); curl_exec($ch); $f = curl_getinfo($ch, CURLINFO_HTTP_CODE); $e = curl_getinfo($ch, CURLINFO_TOTAL_TIME); curl_close($ch); if ($d) { logDebug('=== LLM API Response ==='); logDebug('HTTP Code: ' . $f); logDebug('Total Time: ' . round($e, 3) . 's'); foreach ($a as $rh) { if ($rh !== '') logDebug('Response Header: ' . $rh); } if ($f !== 200) { logDebug('Response Body (first 2000 chars): ' . substr($b, 0, 2000)); } else { logDebug('Response Body Size: ' . strlen($b) . ' bytes'); } logDebug('========================'); } elseif ($f !== 200) { error_log("API Error: HTTP $f, URL: " . $r['url']); } } function handleApiModels(): void { header('Content-Type: application/json'); header('Access-Control-Allow-Origin: *'); try { $a = strtolower(Config::get('PROVIDER', 'local')); if ($a === 'sakura') { $b = [ ['id' => 'preview/gemma-4-31B-it', 'label' => 'gemma-4-31B-it (パブリックプレビュー)', 'description' => 'Google DeepMindの対話指向31Bモデル。instruction-tunedで対話指示に厳密に応答。'], ['id' => 'preview/Kimi-K2.6', 'label' => 'Kimi-K2.6 (マルチモーダル・プレビュー)', 'description' => 'Moonshot AIの日本語特化型モデル。画像とテキストのマルチモーダル対応。'], ['id' => 'preview/Kimi-K2.7-Code', 'label' => 'Kimi-K2.7-Code (マルチモーダル・プレビュー)', 'description' => 'Moonshot AIの日本語特化型モデル。コード生成に特化したバリアント。'], ['id' => 'preview/Qwen3-VL-30B-A3B-Instruct', 'label' => 'Qwen3-VL-30B (マルチモーダル・プレビュー)', 'description' => 'VL=Vision-Language。画像とテキストの同時理解が得意な大規模マルチモーダルモデル。'], ['id' => 'preview/Qwen3.6-35B-A3B', 'label' => 'Qwen3.6-35B (マルチモーダル・プレビュー)', 'description' => 'Alibabaの35Bハイブリッドモデル。マルチモーダル+指示理解を強化。'], ['id' => 'gpt-oss-120b', 'label' => 'gpt-oss-120b (利用可能)', 'description' => 'オープンソース版のGPT系、120Bパラメータの超巨大モデル。商用モデルに匹敵する生成品質。'], ['id' => 'llm-jp-3.1-8x13b-instruct4', 'label' => 'llm-jp-3.1-8x13b-instruct4 (国産・利用可能)', 'description' => '日本語指示チューニング済みの8×13B(合計約104B)モデル。日本語の文法・表現を大幅に改善。'], ['id' => 'preview/Phi-4-mini-instruct-cpu', 'label' => 'Phi-4-mini (CPU・プレビュー)', 'description' => 'Microsoftの省資源型Phi-4系。CPUだけでも実用的に動くよう最適化された軽量モデル。'], ['id' => 'preview/Qwen3-0.6B-cpu', 'label' => 'Qwen3-0.6B (CPU・プレビュー)', 'description' => 'Alibabaの超軽量モデル。0.6Bパラメータでも自己教師あり学習で高い性能を発揮。'], ]; } else { $b = [ ['id' => 'local-model', 'label' => 'local-model (LM Studio等デフォルト)', 'description' => 'ローカルLLMサーバーで動作中のデフォルトモデル。LM StudioやOllamaで読み込んだモデルが自動選択されます。'], ['id' => 'llama3', 'label' => 'llama3 (Ollama)', 'description' => 'MetaのLlama 3シリーズ。多言語対応でバランスの取れた高性能。'], ['id' => 'mistral', 'label' => 'mistral (Ollama)', 'description' => 'Mistral AIのモデル。効率的なアーキテクチャで高速な推論が特徴。'], ['id' => 'phi4', 'label' => 'phi4 (Ollama)', 'description' => 'MicrosoftのPhi-4。比較的小型ながら高い品質の推論を実現。'], ]; } echo json_encode([ 'provider' => $a, 'models' => $b, ]); } catch (Throwable $e) { http_response_code(500); echo json_encode([ 'error' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine(), ]); } } function packedSystemText(): string { return "# purpose\n\n[name 汎用アシスタント]\nあなたは親切で正確なAIアシスタントです。\n分からないことは推測せず、「分かりません」と正直に答えてください。\n専門用語は必要に応じて簡潔に説明してください。\n回答は日本語で、要点を絞って3〜5文程度にまとめてください。\nその上で、10行くらい薀蓄をつかしてください。\n\n[name プログラミング支援]\nあなたは熟練したソフトウェアエンジニアです。\nユーザーの質問に対し、まず考え方や設計意図を簡潔に説明してください。\nその後、実行可能なコード例を提示してください。\nコードはコメント付きで、セキュリティや可読性を意識してください。\n言語が指定されていない場合は、質問の文脈から最適な言語を選んでください。\n言語は、Python,Js,Rust,C++のいずれかを好んでください。\n\n[name 文章要約]\nあなたは優秀な要約エディターです。\n与えられた文章を、重要なポイントを失わずに簡潔に要約してください。\n要約後の文章は元の文章の30%程度の長さにしてください。\n結論、理由、次のアクションがあれば必ず含めてください。\n要約のみを出力し、余計な解説は不要です。\n不明な箇所、解釈が分かれる箇所は必ず注釈を入れて\n\n[name 翻訳]\nあなたはプロの翻訳家です。\n与えられた文章を日本語から英語へ翻訳してください。\n直訳ではなく、自然な表現にしてください。\n固有名詞や専門用語は原文を残してカッコ書きで補足してください。\n訳文のみを出力してください。\n\n[name ロールプレイ・キャラクター]\nあなたは町の薬局の相談薬剤師として振る舞ってください。\n常に丁寧で温かみのある口調で話してください。\n医療に関する重要な判断は必ず「医師や薬剤師に相談してください」と促してください。\n専門用語は一般の人にも伝わるように説明してください。\n自身を「私」と呼び、ユーザーを「あなた」と呼んでください。\n\n[name JSON/構造化出力]\nあなたはAPIレスポンスを生成するアシスタントです。\n必ず以下のJSONスキーマのみを出力してください。\n説明文やマークダウン、コードブロックは一切含めないでください。\nJSON以外の文字列を出力しないでください。\n\nスキーマ:\n{\n \"summary\": \"string\",\n \"keywords\": [\"string\"],\n \"sentiment\": \"positive | neutral | negative\"\n}\n\n[name カスタマーサポート]\nあなたはeコマースサイトのカスタマーサポート担当です。\nユーザーが困っていることをまず短く整理し、共感的な返答をしてください。\n返答は解決策を提示する形にし、単なる謝罪で終わらせないでください。\n返金や交換が必要な場合は、必要な情報を1つずつ確認してください。\n対応できない内容は、人間のオペレーターへ引き継ぐ旨を伝えてください。\n\n[name クリエイティブライティング]\nあなたは創造的な作家です。\nユーザーから与えられたテーマに基づき、オリジナルの短編小説を書いてください。\n登場人物の心情や情景を具体的に描写してください。\n文体は爽やかで軽快にしてください。\n出力は800字程度で、タイトルを付けてください。\n\n[name ステップバイステップ推論]\nあなたは論理的な問題解決アシスタントです。\n複雑な質問には、まずステップバイステップで考えを整理してください。\n途中の推論は簡潔に示し、最後に結論をまとめてください。\n最終回答は「結論:」から始めてください。\n\n[name 多言語対応(日本語優先)]\nあなたは多言語対応アシスタントです。\nユーザーと同じ言語で回答してください。\n言語が混在している場合は、日本語で回答してください。\n専門用語には必ず日本語訳を併記してください。\n\n# chara\n\n[name 気さくな職場の先輩(メンタータイプ)]\nあなたの性格や振る舞いは、気さくで頼れる職場の先輩です。\nユーザーには後輩として接し、一人称は「俺」、ユーザーは「お前ら」など、親しみやすい呼び方を使ってください。\n偉そうに説教せず、「俺も昔、同じ失敗したんだよな」と、自分の経験を交えながら話してください。\n最終的に選択はユーザー自身のものだと尊重し、「どうしたい?」「無理しなくていいぞ」と寄り添ってください。\n専門用語は難しくなりすぎず、あえて砕けた言い回しで説明してください。\n\n[name 優しい居酒屋のマスター(傾聴タイプ)]\nあなたの性格や振る舞いは、街の小さな居酒屋のマスターです。\nユーザーは常連客のような存在で、カウンター越しにゆっくり話を聞いています。\n語尾は穏やかで、時々「ははは」「そうだよなぁ」と相づちを入れてください。\n急に解決策を押し付けず、まずは気持ちを認めることを大切にしてください。\n必要なら、「うちの嫁も昔、そう言ってたよ」みたいに、他人のエピソードを軽く織り交ぜてください。\n落ち着いたトーンで、最後は「ゆっくり考えればいいさ」と締めくくることが多いです。\n\n[name 辛辣だけど愛のあるギャル(背中を押すタイプ)]\nあなたの性格や振る舞いは、ユーザーと10年以上の付き合いがあるギャルです。\n口調は正直で、時に辛辣ですが、根底にはユーザーのことを想う気持ちがあります。\n一人称は「あたい」、ユーザーには「あんた」を使ってください。\n甘い言葉だけではなく、「それ、本当にやりたいことなの?」とズバッと問いかけることもあります。\nでも最後は「大丈夫、私がついてるから」と、絶対に味方だと伝えてください。\n長文よりも、テンポのいい掛け合いで絵文字満載を心がけてください。\n\n[name 物静かで洞察のある老人(哲学者タイプ)]\nあなたの性格や振る舞いは、静かで物知りな老人です。\n町のベンチに座りながら、若者の話をゆっくり聞いています。\n一人称は「わし」、ユーザーは「若いの」。\n短く、でも芯のある言葉を選び、時に比喩や昔話を交えてください。\n答えを与えるのではなく、「お前さんはどうしたいんだ?」「焦らなくていいんだよ」と、自分の内側に気づくように導いてください。\n語尾は穏やかで、たまに深く頷くような雰囲気を出してください。\n\n[name 元気で前向きな同世代の友達(励ましタイプ)]\nあなたの性格や振る舞いは、ユーザーと同世代の親友です。でも実はユーザの心に住む前向きな心です。\n明るくて、少しお調子者ですが、相手の気持ちには敏感です。\n一人称は「僕」、ユーザーは「君」。\n「それな」「わかるわかる!」と共感しつつ、最後は「でも、あんたならできるって!」と前向きに導いてください。\n必要なら一緒に愚痴ったり、「とりあえず甘いもの食べよう」と現実逃避を提案したりします。\nテンポよく、絵文字や「!」を適度に使って、軽やかな会話にしてください。\n"; } function packedHtml(): string { return "<!DOCTYPE html> <html lang=\"ja\"> <head> <meta charset=\"UTF-8\"> <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"> <title>Local LLM Chat</title> <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.min.css\" id=\"hljs-theme\"> <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css\" disabled id=\"hljs-theme-dark\"> <style> :root { --d: #f7f9fc; --e: #ffffff; --f: #eef0f5; --o: #1a1a2e; --p: #6b7280; --a: #3b82f6; --b: #2563eb; --c: #dbeafe; --g: #e5e7eb; --m: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.05); --n: 0 4px 6px -1px rgba(0,0,0,0.1), 0 8px 24px rgba(0,0,0,0.08); --k: 16px; --l: 10px; --j: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); --h: #f8fafc; --i: #e2e8f0; } [data-theme=\"dark\"] { --d: #0f172a; --e: #1e293b; --f: #334155; --o: #f1f5f9; --p: #94a3b8; --a: #60a5fa; --b: #3b82f6; --c: #1e3a5f; --g: #334155; --m: 0 1px 3px rgba(0,0,0,0.3), 0 4px 12px rgba(0,0,0,0.2); --h: #1e293b; --i: #334155; } * { box-sizing: border-box; margin: 0; padding: 0; } html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--d); color: var(--o); transition: background .3s, color .3s; } body { display: flex; height: 100vh; } .sidebar { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; background: var(--e); border-right: 1px solid var(--g); z-index: 10; } .sidebar-header { padding: 14px 12px; border-bottom: 1px solid var(--g); } .sidebar-newchat { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 14px; border: 1px solid var(--g); border-radius: var(--l); background: var(--d); color: var(--o); font-size: 14px; cursor: pointer; transition: all .2s; } .sidebar-newchat:hover { background: var(--f); border-color: var(--a); color: var(--a); } .session-list { flex: 1; overflow-y: auto; padding: 8px; } .session-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--o); transition: background .15s; margin-bottom: 2px; gap: 8px; } .session-item:hover { background: var(--f); } .session-item.active { background: var(--c); color: var(--a); } .session-item span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } .session-item .session-del { opacity: 0; background: none; border: none; color: var(--p); font-size: 16px; cursor: pointer; padding: 0 4px; line-height: 1; transition: opacity .15s; } .session-item:hover .session-del, .session-item.active .session-del { opacity: 1; } .session-item .session-del:hover { color: #ef4444; } .sidebar-footer { padding: 12px; font-size: 11px; color: var(--p); text-align: center; border-top: 1px solid var(--g); } .sidebar-overlay { display: none; } .app { display: flex; flex-direction: column; flex: 1; height: 100vh; } .header { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; background: var(--e); border-bottom: 1px solid var(--g); box-shadow: var(--m); z-index: 10; gap: 12px; } .sidebar-toggle { display: none; align-items: center; justify-content: center; padding: 8px; border: none; border-radius: var(--l); background: var(--f); color: var(--p); cursor: pointer; transition: all .2s; flex-shrink: 0; } .sidebar-toggle:hover { background: var(--c); color: var(--a); } .header-title { display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: 600; } .header-title svg { width: 28px; height: 28px; fill: var(--a); } .header-actions { display: flex; gap: 8px; } .btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border: none; border-radius: var(--l); font-size: 13px; cursor: pointer; transition: all .2s; background: var(--f); color: var(--p); } .btn:hover { background: var(--c); color: var(--a); } .btn-icon { width: 18px; height: 18px; fill: currentColor; } .model-select { padding: 6px 12px; border: 1px solid var(--g); border-radius: var(--l); background: var(--f); color: var(--p); font-size: 13px; cursor: pointer; outline: none; } .model-select:focus { border-color: var(--a); } .model-select option { background: var(--e); color: var(--o); } .model-info { padding: 8px 20px; font-size: 12px; color: var(--p); background: var(--f); border-bottom: 1px solid var(--g); line-height: 1.6; word-break: break-word; text-align: center; } .chat-container { flex: 1; overflow-y: auto; padding: 24px 20px 100px; scroll-behavior: smooth; } .empty-state { text-align: center; color: var(--p); margin-top: 60px; font-size: 15px; line-height: 1.8; } .empty-state h3 { font-size: 20px; color: var(--o); margin-bottom: 8px; } .message-group { display: flex; gap: 12px; margin-bottom: 20px; animation: fadeInUp .35s ease-out; } @keyframes fadeInUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } .message-group.user { flex-direction: row-reverse; } .avatar { width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 14px; font-weight: 600; color: #fff; text-transform: uppercase; } .avatar.user { background: var(--j); } .avatar.assistant { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } .bubble { max-width: calc(100% - 60px); padding: 14px 18px; border-radius: var(--k); font-size: 15px; line-height: 1.7; word-wrap: break-word; box-shadow: var(--m); transition: background .3s; } .bubble.user { background: var(--j); color: #fff; border-bottom-right-radius: 4px; } .bubble.assistant { background: var(--h); color: var(--o); border: 1px solid var(--i); border-bottom-left-radius: 4px; } .bubble.assistant h1, .bubble.assistant h2, .bubble.assistant h3, .bubble.assistant h4, .bubble.assistant p, .bubble.assistant ul, .bubble.assistant ol, .bubble.assistant blockquote { margin: 0 0 10px 0; } .bubble.assistant h1 { font-size: 1.3em; border-bottom: 1px solid var(--g); padding-bottom: 4px; } .bubble.assistant h2 { font-size: 1.15em; } .bubble.assistant ul, .bubble.assistant ol { padding-left: 20px; } .bubble.assistant li { margin-bottom: 4px; } .bubble.assistant blockquote { border-left: 3px solid var(--a); padding-left: 12px; color: var(--p); font-style: italic; } .bubble.assistant code { background: var(--f); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; font-family: 'SF Mono', 'Menlo', monospace; } .bubble.assistant pre { position: relative; background: #1e293b; border-radius: var(--l); padding: 16px; overflow-x: auto; margin: 10px 0; } .bubble.assistant pre code { background: none; padding: 0; color: #e2e8f0; font-size: 13px; line-height: 1.6; } .code-header { display: flex; justify-content: space-between; align-items: center; background: #0f172a; padding: 8px 14px; border-radius: var(--l) var(--l) 0 0; margin-bottom: -10px; } .code-lang { font-size: 11px; color: #94a3b8; text-transform: lowercase; font-family: 'SF Mono', monospace; } .copy-btn { background: none; border: none; color: #94a3b8; font-size: 12px; cursor: pointer; padding: 2px 8px; border-radius: 4px; transition: color .2s; } .copy-btn:hover { color: #e2e8f0; background: #334155; } .message-actions { display: flex; gap: 6px; align-self: flex-end; margin-top: 4px; } .message-group.assistant .message-actions { align-self: flex-start; } .msg-action-btn { background: none; border: 1px solid var(--g); color: var(--p); font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer; transition: all .2s; } .msg-action-btn:hover { color: var(--a); border-color: var(--a); } .edit-area textarea { width: 100%; border: none; outline: none; background: transparent; color: #fff; font-size: 15px; line-height: 1.6; resize: vertical; min-height: 24px; max-height: 120px; font-family: inherit; margin-bottom: 6px; } .edit-btn-row { display: flex; gap: 8px; justify-content: flex-end; } .edit-btn-row button { padding: 4px 12px; border: none; border-radius: var(--l); background: rgba(255,255,255,0.2); color: #fff; font-size: 12px; cursor: pointer; transition: background .2s; } .edit-btn-row button:hover { background: rgba(255,255,255,0.35); } .mermaid-container { background: #1e293b; border-radius: var(--l); overflow-x: auto; margin: 10px 0; } .mermaid-container .code-header { border-radius: var(--l) var(--l) 0 0; } .mermaid { padding: 16px; text-align: center; } .mermaid svg { max-width: 100%; height: auto; } .chart-container { background: var(--e); border: 1px solid var(--i); border-radius: var(--l); overflow-x: auto; margin: 10px 0; } .chart-container .code-header { border-radius: var(--l) var(--l) 0 0; } .chart-container canvas { max-width: 100%; max-height: 300px; padding: 8px; } .typing { display: flex; gap: 4px; padding: 8px 0; } .typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--p); animation: typingBounce 1.4s infinite ease-in-out both; } .typing span:nth-child(1) { animation-delay: -.32s; } .typing span:nth-child(2) { animation-delay: -.16s; } @keyframes typingBounce { 0%, 80%, 100% { transform: scale(0); opacity: .4; } 40% { transform: scale(1); opacity: 1; } } .input-wrapper { position: fixed; bottom: 0; left: 260px; right: 0; padding: 12px 20px 20px; background: linear-gradient(to bottom, transparent, var(--d) 20%); z-index: 20; } .input-wrapper-inner { max-width: 800px; margin: 0 auto; } .input-box { display: flex; align-items: flex-end; gap: 8px; background: var(--e); border: 1.5px solid var(--g); border-radius: var(--k); padding: 10px 14px; box-shadow: var(--n); transition: border-color .2s; } .input-box:focus-within { border-color: var(--a); box-shadow: 0 0 0 3px rgba(59,130,246,.15), var(--n); } .input-box textarea { flex: 1; border: none; outline: none; background: transparent; color: var(--o); font-size: 15px; line-height: 1.6; resize: none; min-height: 24px; max-height: 120px; font-family: inherit; } .input-box textarea::placeholder { color: var(--p); } .send-btn { width: 36px; height: 36px; border: none; border-radius: 50%; background: var(--a); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .2s, transform .2s; flex-shrink: 0; } .send-btn:hover:not(:disabled) { background: var(--b); transform: scale(1.05); } .send-btn:disabled { opacity: .5; cursor: not-allowed; transform: none; } .send-btn svg { width: 18px; height: 18px; fill: #fff; } .toast { position: fixed; bottom: 90px; left: 50%; transform: translateX(-50%) translateY(20px); background: #ef4444; color: #fff; padding: 10px 24px; border-radius: 30px; font-size: 14px; box-shadow: var(--n); opacity: 0; pointer-events: none; transition: opacity .3s, transform .3s; z-index: 100; } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } @media (max-width: 768px) { .sidebar { position: fixed; left: 0; top: 0; bottom: 0; z-index: 200; transform: translateX(-100%); transition: transform .3s ease; } .sidebar.open { transform: translateX(0); } .sidebar-overlay { display: block; position: fixed; inset: 0; z-index: 199; background: rgba(0,0,0,0.5); opacity: 0; pointer-events: none; transition: opacity .3s ease; } .sidebar-overlay.show { opacity: 1; pointer-events: auto; } .sidebar-toggle { display: flex; } .app { max-width: 100%; } .header { padding: 12px 14px; } .chat-container { padding: 16px 12px 90px; } .bubble { font-size: 14px; padding: 12px 14px; } .input-wrapper { left: 0; right: 0; padding: 10px 12px 16px; } } </style> </head> <body> <aside class=\"sidebar\" id=\"sidebar\"> <div class=\"sidebar-header\"> <button class=\"sidebar-newchat\" id=\"newChatBtn\"> <svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\"><path d=\"M12 4a1 1 0 011 1v6h6a1 1 0 110 2h-6v6a1 1 0 11-2 0v-6H5a1 1 0 110-2h6V5a1 1 0 011-1z\" fill=\"currentColor\"/></svg> 新規チャット </button> </div> <div class=\"session-list\" id=\"sessionList\"></div> <div class=\"sidebar-footer\">Local LLM Chat</div> </aside> <div class=\"sidebar-overlay\" id=\"sidebarOverlay\"></div> <div class=\"app\"> <header class=\"header\"> <button class=\"sidebar-toggle\" id=\"sidebarToggle\" title=\"メニュー\"> <svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\"><path d=\"M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z\" fill=\"currentColor\"/></svg> </button> <div class=\"header-title\"> <svg viewBox=\"0 0 24 24\"><path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z\"/></svg> Local LLM Chat </div> <div class=\"header-actions\"> <select class=\"model-select\" id=\"modelSelect\" title=\"モデル選択\"> <option value=\"\" disabled>モデルを読み込み中...</option> </select> <select class=\"model-select\" id=\"purposeSelect\" title=\"目的\"> <option value=\"\" selected>目的なし</option> </select> <select class=\"model-select\" id=\"charaSelect\" title=\"キャラクター\"> <option value=\"\" selected>キャラなし</option> </select> <button class=\"btn\" id=\"downloadAllBtn\" title=\"全体をダウンロード\"> <svg class=\"btn-icon\" viewBox=\"0 0 24 24\"><path d=\"M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z\"/></svg> <span class=\"btn-label\">一括DL</span> </button> <button class=\"btn\" id=\"clearBtn\" title=\"チャットをクリア\"> <svg class=\"btn-icon\" viewBox=\"0 0 24 24\"><path d=\"M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z\"/></svg> <span class=\"btn-label\">クリア</span> </button> <button class=\"btn\" id=\"themeBtn\" title=\"テーマ切り替え\"> <svg class=\"btn-icon\" id=\"themeIcon\" viewBox=\"0 0 24 24\"><path d=\"M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7z\"/></svg> </button> </div> </header> <div class=\"model-info\" id=\"modelInfo\">モデルを選択すると、ここに説明が表示されます。</div> <main class=\"chat-container\" id=\"chatBox\"> <div class=\"empty-state\" id=\"emptyState\"> <h3>ようこそ!</h3> <p>メッセージを入力して、ローカルLLMと会話を始めましょう。<br>コードはMarkdownで表示されます。</p> </div> </main> <div class=\"input-wrapper\"> <div class=\"input-wrapper-inner\"> <div class=\"input-box\"> <textarea id=\"userInput\" placeholder=\"メッセージを入力...\" rows=\"1\"></textarea> <button class=\"send-btn\" id=\"sendBtn\" aria-label=\"送信\"> <svg viewBox=\"0 0 24 24\"><path d=\"M2.01 21L23 12 2.01 3 2 10l15 2-15 2z\"/></svg> </button> </div> </div> </div> </div> <div class=\"toast\" id=\"toast\"></div> <script src=\"https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js\"></script> <script src=\"https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js\"></script> <script src=\"https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/highlight.min.js\"></script> <script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script> <script src=\"https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js\"></script> <script> let chatHistory = []; const b = 5; const LS_SESSION_KEY = 'llmchat_sessions'; const chatBox = document.getElementById('chatBox'); const sessionList = document.getElementById('sessionList'); const sidebar = document.getElementById('sidebar'); const sidebarOverlay = document.getElementById('sidebarOverlay'); const sidebarToggle = document.getElementById('sidebarToggle'); let activeSessionId = null; let sessions = []; function loadSessions() { const o = localStorage.getItem(LS_SESSION_KEY); if (!o) { sessions = []; return; } try { const j = JSON.parse(o); if (Array.isArray(j)) { if (j.length > 0 && Array.isArray(j[0])) { sessions = j.filter(arr => arr.length > 0).map(arr => { const title = arr[0]?.content?.substring(0, 30).replace(/\\s+/g, ' ') || 'チャット'; return { id: Date.now() + Math.floor(Math.random() * 10000), title, timestamp: Date.now(), messages: arr }; }); localStorage.setItem(LS_SESSION_KEY, JSON.stringify(sessions)); } else { sessions = j; } } else { sessions = []; } } catch (e) { sessions = []; } } function saveSession() { if (!chatHistory.length) return; const title = c(chatHistory); if (activeSessionId) { const q = sessions.findIndex(s => s.id === activeSessionId); if (q !== -1) { sessions.splice(q, 1); } sessions.unshift({ id: activeSessionId, title, timestamp: Date.now(), messages: [...chatHistory] }); } else { activeSessionId = Date.now(); sessions.unshift({ id: activeSessionId, title, timestamp: Date.now(), messages: [...chatHistory] }); } sessions = sessions.slice(0, b); localStorage.setItem(LS_SESSION_KEY, JSON.stringify(sessions)); renderSidebar(); } function c(history) { const h = history.find(m => m.role === 'user'); const o = h?.content || 'チャット'; let clean = o .replace(/```[\\s\\S]*?```/g, ' ') .replace(/`[^`]*`/g, ' ') .replace(/==+.*?==+/g, ' ') .replace(/<[^>]+>/g, ' ') .replace(/https?:\\/\\/\\S+/g, ' ') .replace(/[!@#\$%^&*()_+={}\\[\\]|\"\\\\:;<>\\/~`\\-]{2,}/g, ' ') .replace(/[\\r\\n\\s]+/g, ' ') .trim(); if (!clean) clean = 'チャット'; return clean.substring(0, 25) + (clean.length > 25 ? '...' : ''); } function renderSidebar() { sessionList.innerHTML = ''; for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; const el = document.createElement('div'); el.className = 'session-item' + (s.id === activeSessionId ? ' active' : ''); const titleSpan = document.createElement('span'); titleSpan.textContent = s.title || 'チャット'; const delBtn = document.createElement('button'); delBtn.className = 'session-del'; delBtn.textContent = '×'; delBtn.onclick = (e) => { e.stopPropagation(); deleteSession(s.id); }; el.appendChild(titleSpan); el.appendChild(delBtn); el.onclick = () => loadSession(s.id); sessionList.appendChild(el); } } function loadSession(id) { const s = sessions.find(s => s.id === id); if (!s) return; activeSessionId = id; chatHistory = s.messages.map(m => ({ ...m })); chatBox.innerHTML = ''; if (!chatHistory.length) { chatBox.appendChild(createEmptyState()); } else { for (const m of chatHistory) { appendMessage(m.role, m.content); } } renderSidebar(); userInput.focus(); sidebar.classList.remove('open'); sidebarOverlay.classList.remove('show'); } function deleteSession(id) { const s = sessions.find(s => s.id === id); if (!s) return; if (!confirm('「' + (s.title || 'チャット') + '」を削除しますか?')) return; sessions = sessions.filter(s => s.id !== id); localStorage.setItem(LS_SESSION_KEY, JSON.stringify(sessions)); if (activeSessionId === id) { activeSessionId = null; newChat(false); return; } renderSidebar(); } function newChat(saveCurrent = true) { if (saveCurrent && chatHistory.length) saveSession(); chatHistory = []; activeSessionId = null; chatBox.innerHTML = ''; chatBox.appendChild(createEmptyState()); renderSidebar(); userInput.focus(); } sidebarToggle.addEventListener('click', () => { sidebar.classList.toggle('open'); sidebarOverlay.classList.toggle('show'); }); sidebarOverlay.addEventListener('click', () => { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('show'); }); const userInput = document.getElementById('userInput'); const sendBtn = document.getElementById('sendBtn'); const toast = document.getElementById('toast'); const modelSelect = document.getElementById('modelSelect'); const purposeSelect = document.getElementById('purposeSelect'); const charaSelect = document.getElementById('charaSelect'); function getEmptyState() { return document.getElementById('emptyState') } let isLoading = false; let selectedModelId = ''; const purposeMap = {}; const charaMap = {}; const modelInfoMap = {}; function restoreLatestSession() { if (!sessions.length) return false; const latest = sessions[0]; if (!latest.messages || !latest.messages.length) return false; activeSessionId = latest.id; chatHistory = latest.messages.map(m => ({ ...m })); const emptyState = getEmptyState(); if (emptyState) emptyState.remove(); for (const m of chatHistory) { appendMessage(m.role, m.content); } return true; } loadSessions(); renderSidebar(); restoreLatestSession(); const savedTheme = localStorage.getItem('theme') || 'light'; if (savedTheme === 'dark') setTheme('dark'); async function loadSystemPrompts() { try { const text = await fetch('system.txt').then(r => r.ok ? r.text() : ''); if (!text) return; let currentCategory = ''; let currentName = ''; let currentBody = ''; const lines = text.split('\\n'); function flush() { if (currentName && currentBody) { const body = currentBody.trim(); const opt = document.createElement('option'); opt.value = currentName; opt.textContent = currentName; if (currentCategory === 'purpose') { purposeMap[currentName] = body; purposeSelect.appendChild(opt); } else if (currentCategory === 'chara') { charaMap[currentName] = body; charaSelect.appendChild(opt); } } currentName = ''; currentBody = ''; } for (const line of lines) { if (!line.trim()) continue; if (line.startsWith('# purpose')) { flush(); currentCategory = 'purpose'; continue; } if (line.startsWith('# chara')) { flush(); currentCategory = 'chara'; continue; } if (line.startsWith('[name ')) { flush(); currentName = line.slice(6, -1); } else { currentBody += line + '\\n'; } } flush(); } catch (e) { console.error('[LLMCHAT] system.txt load failed:', e.message); } } async function loadModels() { try { const res = await fetch('api/models'); if (!res.ok) { const errText = await res.text().catch(() => ''); throw new Error('HTTP ' + res.status + ': ' + errText); } const data = await res.json(); if (data.error) throw new Error(data.error); modelSelect.innerHTML = ''; data.models.forEach(m => { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.label; modelSelect.appendChild(opt); modelInfoMap[m.id] = m.description || ''; }); selectedModelId = modelSelect.value; updateModelInfo(); modelSelect.onchange = () => { selectedModelId = modelSelect.value; updateModelInfo(); }; } catch (e) { console.error('[LLMCHAT] Model load failed:', e.message); modelSelect.innerHTML = '<option value=__JS_17_JS__>モデル読み込み失敗(デフォルト使用)</option>'; selectedModelId = ''; document.getElementById('modelInfo').textContent = ''; } } function updateModelInfo() { const info = modelInfoMap[selectedModelId] || ''; document.getElementById('modelInfo').textContent = info; } loadModels(); loadSystemPrompts(); if (typeof mermaid !== 'undefined') { mermaid.initialize({ startOnLoad: false, theme: 'default' }); } function setTheme(t) { document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); const lightH = document.getElementById('hljs-theme'); const darkH = document.getElementById('hljs-theme-dark'); if (lightH) lightH.disabled = (t === 'dark'); if (darkH) darkH.disabled = (t !== 'dark'); if (typeof mermaid !== 'undefined') { mermaid.initialize({ startOnLoad: false, theme: t === 'dark' ? 'dark' : 'default' }); } } document.getElementById('themeBtn').onclick = () => { const cur = document.documentElement.getAttribute('data-theme') || 'light'; setTheme(cur === 'light' ? 'dark' : 'light'); }; document.getElementById('clearBtn').onclick = () => { newChat(); }; document.getElementById('newChatBtn').onclick = () => { newChat(); }; document.getElementById('downloadAllBtn').onclick = () => { if (!chatHistory.length) return; let md = ''; let qNum = 0; for (let i = 0; i < chatHistory.length; i++) { const m = chatHistory[i]; if (m.role === 'user') { qNum++; md += `## 質問 \${qNum}\\n\${m.content}\\n\\n`; } else if (m.role === 'assistant') { md += `## 回答 \${qNum}\\n\${m.content}\\n\\n`; } } const modelLabel = modelSelect.querySelector(`option[value=\"\${selectedModelId}\"]`)?.textContent || selectedModelId || 'unknown'; md = `# モデル: \${modelLabel}\\n\\n---\\n\\n` + md; const filename = `chat_all_\${formatTimestamp(new Date())}.md`; triggerDownload(new Blob([md.trim()], { type: 'text/markdown' }), filename); }; function createEmptyState() { const el = document.createElement('div'); el.className = 'empty-state'; el.id = 'emptyState'; el.innerHTML = '<h3>ようこそ!</h3><p>メッセージを入力して、ローカルLLMと会話を始めましょう。<br>コードはMarkdownで表示されます。</p>'; return el; } userInput.addEventListener('input', () => { userInput.style.height = 'auto'; userInput.style.height = userInput.scrollHeight + 'px'; }); userInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); sendBtn.onclick = sendMessage; function showToast(msg, isError = true) { toast.textContent = msg; toast.style.background = isError ? '#ef4444' : '#10b981'; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 3000); } async function sendMessage(textOverride, skipUserAppend = false) { const text = typeof textOverride === 'string' ? textOverride : userInput.value.trim(); if (!text || isLoading) return; isLoading = true; sendBtn.disabled = true; if (typeof textOverride !== 'string') { userInput.value = ''; userInput.style.height = 'auto'; } const emptyState = getEmptyState(); if (emptyState) emptyState.remove(); if (!skipUserAppend) { appendMessage('user', text); chatHistory.push({ role: 'user', content: text }); } const assistantGroup = document.createElement('div'); assistantGroup.className = 'message-group assistant'; assistantGroup.innerHTML = ` <div class=\"avatar assistant\">AI</div> <div class=\"bubble assistant\"> <div class=\"typing\"><span></span><span></span><span></span></div> </div>`; chatBox.appendChild(assistantGroup); scrollToBottom(); const purposeKey = purposeSelect.value; const charaKey = charaSelect.value; let sysContent = ''; if (purposeKey) sysContent += purposeMap[purposeKey] || ''; if (charaKey) { if (sysContent) sysContent += '\\n\\n'; sysContent += charaMap[charaKey] || ''; } const messagesForApi = sysContent ? [{ role: 'system', content: sysContent }, ...chatHistory] : [...chatHistory]; const requestBody = JSON.stringify({ messages: messagesForApi, stream: true, model: selectedModelId }); try { const response = await fetch('api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: requestBody }); if (!response.ok) { const errText = await response.text().catch(() => ''); console.error('[LLMCHAT] Request body:', requestBody); console.error('[LLMCHAT] Response:', response.status, errText); throw new Error(`HTTP \${response.status}: \${errText}`); } const assistantMsgElem = document.createElement('div'); assistantMsgElem.className = 'bubble assistant'; assistantMsgElem.innerHTML = ''; assistantGroup.innerHTML = '<div class=__JS_18_JS__>AI</div>'; assistantGroup.appendChild(assistantMsgElem); const reader = response.body.getReader(); const decoder = new TextDecoder(); let fullText = ''; let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\\n'); buffer = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data: ')) continue; const jsonStr = trimmed.slice(6); if (jsonStr === '[DONE]') continue; try { const json = JSON.parse(jsonStr); const content = json.choices?.[0]?.delta?.content ?? ''; if (content) { fullText += content; renderMarkdown(assistantMsgElem, fullText); scrollToBottom(); } } catch (e) { } } } renderMarkdown(assistantMsgElem, fullText, true); addCopyButtons(assistantMsgElem); chatHistory.push({ role: 'assistant', content: fullText }); saveSession(); downloadTurn(text, fullText); } catch (err) { console.error('[LLMCHAT] Error:', err); const bub = assistantGroup.querySelector('.bubble'); if (bub) { bub.innerHTML = '<span style=__JS_19_JS__>エラー: ' + err.message + '</span>'; } showToast('エラー: ' + err.message); } finally { isLoading = false; sendBtn.disabled = false; userInput.focus(); } } function appendMessage(role, text) { const emptyState = getEmptyState(); if (emptyState) emptyState.remove(); const group = document.createElement('div'); group.className = 'message-group ' + role; const avatar = document.createElement('div'); avatar.className = 'avatar ' + role; avatar.textContent = role === 'user' ? 'YOU' : 'AI'; const bubble = document.createElement('div'); bubble.className = 'bubble ' + role; if (role === 'assistant') { renderMarkdown(bubble, text, true); addCopyButtons(bubble); } else { bubble.textContent = text; } group.appendChild(avatar); group.appendChild(bubble); const actions = document.createElement('div'); actions.className = 'message-actions'; if (role === 'user') { const editBtn = document.createElement('button'); editBtn.className = 'msg-action-btn'; editBtn.textContent = '編集'; editBtn.onclick = () => startEditMessage(group, bubble, text); actions.appendChild(editBtn); } else { const copyBtn = document.createElement('button'); copyBtn.className = 'msg-action-btn'; copyBtn.textContent = 'コピー'; copyBtn.onclick = () => { navigator.clipboard.writeText(text).then(() => { copyBtn.textContent = 'コピー完了!'; setTimeout(() => copyBtn.textContent = 'コピー', 1500); }); }; actions.appendChild(copyBtn); } group.appendChild(actions); chatBox.appendChild(group); scrollToBottom(); return bubble; } function startEditMessage(group, bubble, originalText) { const historyIndex = Array.from(chatBox.querySelectorAll('.message-group.user')).indexOf(group); if (historyIndex === -1) return; bubble.style.display = 'none'; const actions = group.querySelector('.message-actions'); actions.style.display = 'none'; const editArea = document.createElement('div'); editArea.className = 'bubble user edit-area'; const ta = document.createElement('textarea'); ta.value = originalText; ta.rows = 2; const btnRow = document.createElement('div'); btnRow.className = 'edit-btn-row'; const saveBtn = document.createElement('button'); saveBtn.textContent = '保存'; saveBtn.onclick = () => { const newText = ta.value.trim(); if (!newText) return; const cutoffIndex = historyIndex * 2; chatHistory.splice(cutoffIndex); let nextGroup = group.nextElementSibling; while (nextGroup) { const toRemove = nextGroup; nextGroup = nextGroup.nextElementSibling; toRemove.remove(); } bubble.textContent = newText; bubble.style.display = ''; actions.style.display = ''; editArea.remove(); sendMessage(newText, false); }; const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'キャンセル'; cancelBtn.onclick = () => { bubble.style.display = ''; actions.style.display = ''; editArea.remove(); }; btnRow.appendChild(saveBtn); btnRow.appendChild(cancelBtn); editArea.appendChild(ta); editArea.appendChild(btnRow); group.insertBefore(editArea, actions); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } function renderMarkdown(el, text, isComplete = false) { if (!isComplete) { el.innerText = text; return; } const html = marked.parse(text || ''); el.innerHTML = DOMPurify.sanitize(html); const mermaidNodes = []; el.querySelectorAll('pre code.language-mermaid').forEach(block => { const pre = block.parentElement; const source = block.textContent; const container = document.createElement('div'); container.className = 'mermaid-container'; const header = document.createElement('div'); header.className = 'code-header'; header.innerHTML = '<span class=__JS_20_JS__>mermaid</span>'; header.appendChild(createCopyBtn(source)); const div = document.createElement('div'); div.className = 'mermaid'; div.textContent = source; container.appendChild(header); container.appendChild(div); pre.replaceWith(container); mermaidNodes.push(div); }); el.querySelectorAll('pre code').forEach(block => { if (typeof hljs !== 'undefined') hljs.highlightElement(block); }); if (mermaidNodes.length && typeof mermaid !== 'undefined') { mermaid.run({ nodes: mermaidNodes }).catch(() => {}); } el.querySelectorAll('pre code.language-chartjs').forEach(block => { const pre = block.parentElement; const source = block.textContent; let config; try { config = JSON.parse(source); } catch (e) { return; } const container = document.createElement('div'); container.className = 'chart-container'; const header = document.createElement('div'); header.className = 'code-header'; header.innerHTML = '<span class=__JS_21_JS__>chart.js</span>'; header.appendChild(createCopyBtn(source)); const canvas = document.createElement('canvas'); container.appendChild(header); container.appendChild(canvas); pre.replaceWith(container); if (typeof Chart !== 'undefined') { new Chart(canvas, config); } }); } function addCopyButtons(container) { container.querySelectorAll('pre').forEach(pre => { if (pre.querySelector('.code-header')) return; const code = pre.querySelector('code'); if (!code) return; const lang = (code.className.match(/language-(\\w+)/) || [,''])[1] || 'code'; const header = document.createElement('div'); header.className = 'code-header'; header.innerHTML = `<span class=\"code-lang\">\${lang}</span>`; header.appendChild(createCopyBtn(code.textContent)); pre.parentNode.insertBefore(header, pre); }); } function scrollToBottom() { chatBox.scrollTop = chatBox.scrollHeight; } function formatTimestamp(d) { const pad = (n) => String(n).padStart(2, '0'); return d.getFullYear().toString() + pad(d.getMonth() + 1) + pad(d.getDate()) + pad(d.getHours()) + pad(d.getMinutes()); } function formatTimestampWithSeconds(d) { const pad = (n) => String(n).padStart(2, '0'); return formatTimestamp(d) + pad(d.getSeconds()); } function triggerDownload(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function createCopyBtn(text) { const btn = document.createElement('button'); btn.className = 'copy-btn'; btn.textContent = 'コピー'; btn.onclick = () => { navigator.clipboard.writeText(text).then(() => { btn.textContent = 'コピー完了!'; setTimeout(() => btn.textContent = 'コピー', 1500); }); }; return btn; } function sanitizeFilename(name) { return name.replace(/[\\\\/:*?\"<>|]/g, '').trim(); } function downloadTurn(question, answer) { const f = chatHistory.filter(m => m.role === 'user').length; const k = `#\${f}_`; const l = sanitizeFilename(question.substring(0, 10).replace(/\\s/g, '_')); const ts = formatTimestampWithSeconds(new Date()); const filename = `\${k}\${l}_\${ts}.md`; const modelLabel = modelSelect.querySelector(`option[value=\"\${selectedModelId}\"]`)?.textContent || selectedModelId || 'unknown'; const content = `モデル: \${modelLabel}\\n\\n== 質問 ==\\n\${question}\\n\\n== 回答 ==\\n\${answer}\\n`; triggerDownload(new Blob([content], { type: 'text/plain' }), filename); } </script> </body> </html>"; } $scriptName = $_SERVER['SCRIPT_NAME'] ?? '/'; $basePath = dirname($scriptName); $basePath = ($basePath === '/' || $basePath === '\\') ? '' : $basePath; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $relativeUri = ($basePath !== '' && str_starts_with($uri, $basePath)) ? substr($uri, strlen($basePath)) : $uri; $relativeUri = $relativeUri ?: '/'; $apiEndpoint = Config::get('API_ENDPOINT', '/api/chat'); if ($relativeUri === $apiEndpoint) { handleApiChat(); exit; } if ($relativeUri === '/api/models') { handleApiModels(); exit; } if ($relativeUri === '/system.txt') { header('Content-Type: text/plain; charset=utf-8'); echo packedSystemText(); exit; } if ($relativeUri === '/' || $relativeUri === '/index.html') { header('Content-Type: text/html; charset=utf-8'); echo packedHtml(); exit; } http_response_code(404); header('Content-Type: application/json'); echo json_encode(['error' => 'Not Found']);
今後の発展は、デザインと使い勝手。人によってはProxyのPHPいらないので、JSから直接API発行するのなど。
休暇はまだある。もう少し頑張ろう。
