これは何?
履歴・Ubuntuで実行しChromebookで家庭内LANで接続する・モデルの切り替えが出来る。
だけの、シンプルなWeb-UIです。
デモ
スクリーンショット
設計思想
日本は地震国です。AIに慣れて、被災時に使えないことは不便で不利益でしょう。
パソコンのCPU/GPUで動くから、外部と通信しないのでプライバシーやデータ主権の意味でも価値があります。
最低限、GUIで操作出来ると導入しやすいと考えました。
非常用に個人や組織が用意することで、被災時のインフラへの負担を減らせます。災害時に限られた通信帯域は、安否確認、救助要請、緊急連絡など、人命に関わる重要な情報のやり取りを優先したいと考えました。
防災ツールキットにローカルLLMも常備出来るようになれば、その仕組を海外に輸出すると利益が出るかもしれません。人は安心にお金払うので。
Ollamaバージョン
- ollama version is 0.9.6
使用したモデル
- qwen3:8b-q4_K_M
- gemma3:12b-it-q4_K_M
- deepseek-r1:7b-qwen-distill-q4_K_M
この3つの中では、「qwen3:8b-q4_K_M」が安定して日本語で回答しました。英語で回答されて方も利用されるなら、これが無難かもしれません。
ソースコード
試作品です。私の環境では、これで動きます。
#!/usr/bin/env python3
# ollama-webui.py
#
# 概要:
# 家庭内LAN上のOllamaと対話するための、単一ファイルで動作するWebアプリケーションです。
# FlaskをWebフレームワークとして使用し、SQLiteで会話履歴を管理します。
#
# 依存ライブラリのインストール:
# pip install Flask requests
#
# 実行方法:
# python ollama-webui.py
#
# ブラウザからのアクセス:
# http://<このスクリプトを実行しているマシンのIPアドレス>:8080
import sqlite3
import json
import requests
import uuid
from flask import Flask, request, Response, jsonify, render_template_string
from datetime import datetime
# --- 基本設定 (ご自身の環境に合わせて変更してください) ---
DB_FILE = "chat_history.db"
# Ollamaが動作しているURL。Docker内でこのスクリプトを動かす場合などは 'http://host.docker.internal:11434' などに変更。
OLLAMA_BASE_URL = "http://localhost:11434"
OLLAMA_API_URL = f"{OLLAMA_BASE_URL}/api/chat"
OLLAMA_TAGS_URL = f"{OLLAMA_BASE_URL}/api/tags"
HOST = "0.0.0.0" # LAN内の他のデバイスからアクセス可能にする
PORT = 8080
# --- Flaskアプリケーションの初期化 ---
app = Flask(__name__)
# --- データベースのセットアップ ---
def get_db_connection():
"""データベース接続を取得します。"""
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
def init_database():
"""データベースを初期化し、必要なテーブルを作成します。"""
conn = get_db_connection()
cursor = conn.cursor()
# 会話のメタ情報を管理するテーブル
cursor.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
# 個々のメッセージを保存するテーブル
cursor.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
model TEXT NOT NULL,
role TEXT NOT NULL, -- 'user' or 'assistant'
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations (id) ON DELETE CASCADE
)
""")
conn.commit()
conn.close()
print(f"データベース '{DB_FILE}' が初期化されました。")
# --- HTML/CSS/JSテンプレート (単一ファイルに埋め込み) ---
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="ja" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Local LLM Chat</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- marked.js (Markdownパーサー) -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Font Awesome (アイコン) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
/* カスタムスタイル */
body { font-family: 'Inter', sans-serif; }
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
/* スクロールバーのスタイル */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #1f2937; }
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* コードブロックのスタイル */
.prose pre {
background-color: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 0.5rem;
position: relative;
}
.prose pre code {
font-family: 'Fira Code', monospace;
}
.copy-code-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background-color: #334155;
color: #94a3b8;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.prose pre:hover .copy-code-btn {
opacity: 1;
}
.copy-code-btn:hover {
background-color: #475569;
color: #e2e8f0;
}
#chat-messages {
scroll-behavior: smooth;
}
</style>
</head>
<body class="bg-gray-900 text-gray-200 flex h-screen">
<!-- サイドバー: 会話履歴 -->
<aside id="sidebar" class="bg-gray-800 w-64 p-4 flex flex-col h-full">
<h1 class="text-xl font-bold mb-4">会話履歴</h1>
<button id="new-chat-btn" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg mb-4 transition-colors">
<i class="fas fa-plus mr-2"></i>新しいチャット
</button>
<div id="conversation-list" class="flex-grow overflow-y-auto">
<!-- 会話履歴がここに動的に挿入されます -->
</div>
<div id="status-indicator" class="mt-auto text-sm text-gray-400">
<span id="ollama-status-icon" class="mr-2"><i class="fas fa-circle text-yellow-500 animate-pulse"></i></span>
<span id="ollama-status-text">Ollamaに接続中...</span>
</div>
</aside>
<!-- メインコンテンツ: チャットウィンドウ -->
<main class="flex-1 flex flex-col h-full">
<!-- ヘッダー: モデル選択 -->
<header class="bg-gray-800/50 backdrop-blur-sm p-4 border-b border-gray-700 flex items-center justify-between">
<h2 id="chat-title" class="text-lg font-semibold">新しいチャット</h2>
<div class="flex items-center gap-4">
<select id="model-select" class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<!-- モデルがここに動的に挿入されます -->
</select>
<button id="delete-chat-btn" class="text-gray-400 hover:text-red-500 transition-colors" title="このチャットを削除">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</header>
<!-- チャットメッセージ表示エリア -->
<div id="chat-messages" class="flex-1 overflow-y-auto p-6 space-y-6">
<!-- メッセージがここに動的に挿入されます -->
</div>
<!-- 入力フォーム -->
<div class="p-4 bg-gray-900/80 backdrop-blur-sm border-t border-gray-700">
<form id="chat-form" class="flex items-center space-x-4">
<textarea id="message-input" class="flex-1 bg-gray-700 rounded-lg p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="メッセージを入力... (Shift+Enterで改行)" rows="1"></textarea>
<button type="submit" id="send-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-paper-plane"></i>
</button>
</form>
</div>
</main>
<script>
// --- DOM要素の取得 ---
const modelSelect = document.getElementById('model-select');
const conversationList = document.getElementById('conversation-list');
const newChatBtn = document.getElementById('new-chat-btn');
const deleteChatBtn = document.getElementById('delete-chat-btn');
const chatMessages = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
const chatTitle = document.getElementById('chat-title');
const ollamaStatusIcon = document.getElementById('ollama-status-icon');
const ollamaStatusText = document.getElementById('ollama-status-text');
// --- 状態管理 ---
let currentConversationId = null;
let messages = [];
let isGenerating = false;
// --- 初期化処理 ---
document.addEventListener('DOMContentLoaded', async () => {
await checkOllamaStatus();
await loadModels();
await loadConversations();
startNewChat();
// テキストエリアの自動リサイズ
messageInput.addEventListener('input', () => {
messageInput.style.height = 'auto';
messageInput.style.height = (messageInput.scrollHeight) + 'px';
});
// Shift+Enter以外でのEnterキーで送信
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
chatForm.dispatchEvent(new Event('submit'));
}
});
});
// --- API通信 ---
/** Ollamaサーバーの状態を確認する */
async function checkOllamaStatus() {
try {
const response = await fetch(OLLAMA_BASE_URL);
if (response.ok) {
ollamaStatusIcon.innerHTML = '<i class="fas fa-circle text-green-500"></i>';
ollamaStatusText.textContent = 'Ollama 接続済み';
} else {
throw new Error('Ollama not running');
}
} catch (error) {
ollamaStatusIcon.innerHTML = '<i class="fas fa-circle text-red-500"></i>';
ollamaStatusText.textContent = 'Ollama 接続不可';
console.error('Ollama status check failed:', error);
}
}
/** 利用可能なモデルをロードしてドロップダウンに設定する */
async function loadModels() {
try {
const response = await fetch('/api/models');
if (!response.ok) throw new Error('モデルの取得に失敗しました。');
const data = await response.json();
modelSelect.innerHTML = data.models
.map(model => `<option value="${model.name}">${model.name}</option>`)
.join('');
} catch (error) {
console.error(error);
modelSelect.innerHTML = '<option>モデル取得失敗</option>';
}
}
/** 会話履歴をロードしてサイドバーに表示する */
async function loadConversations() {
try {
const response = await fetch('/api/conversations');
if (!response.ok) throw new Error('会話履歴の取得に失敗しました。');
const conversations = await response.json();
conversationList.innerHTML = conversations
.map(conv => `
<div class="conversation-item p-2 rounded-lg cursor-pointer hover:bg-gray-700 ${conv.id === currentConversationId ? 'bg-blue-800/50' : ''}" data-id="${conv.id}">
<p class="font-semibold truncate">${conv.title}</p>
<p class="text-xs text-gray-400">${new Date(conv.created_at).toLocaleString()}</p>
</div>
`).join('');
// イベントリスナーを設定
document.querySelectorAll('.conversation-item').forEach(item => {
item.addEventListener('click', () => loadConversation(item.dataset.id));
});
} catch (error) {
console.error(error);
conversationList.innerHTML = '<p class="text-red-400">履歴の取得に失敗</p>';
}
}
/** 特定の会話をロードする */
async function loadConversation(convId) {
if (isGenerating) return;
try {
const response = await fetch(`/api/conversations/${convId}`);
if (!response.ok) throw new Error('会話の読み込みに失敗しました。');
const data = await response.json();
currentConversationId = convId;
messages = data.messages;
chatTitle.textContent = data.title;
deleteChatBtn.style.display = 'block';
renderMessages();
updateConversationListHighlight();
} catch (error) {
console.error(error);
}
}
// --- イベントハンドラ ---
/** 新しいチャットボタンの処理 */
newChatBtn.addEventListener('click', startNewChat);
/** チャット削除ボタンの処理 */
deleteChatBtn.addEventListener('click', async () => {
if (!currentConversationId || !confirm('この会話を本当に削除しますか?')) return;
try {
const response = await fetch(`/api/conversations/delete/${currentConversationId}`, { method: 'POST' });
if (!response.ok) throw new Error('会話の削除に失敗しました。');
await loadConversations();
startNewChat();
} catch (error) {
console.error(error);
alert(error.message);
}
});
/** チャットフォーム送信時の処理 */
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const userInput = messageInput.value.trim();
if (!userInput || isGenerating) return;
toggleGenerating(true);
// ユーザーのメッセージを履歴に追加して表示
const userMessage = { role: 'user', content: userInput };
messages.push(userMessage);
renderMessages();
scrollToBottom();
// 新規チャットの場合、最初に会話を作成する
if (!currentConversationId) {
try {
const response = await fetch('/api/conversations/new', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: userInput.substring(0, 30) })
});
if (!response.ok) throw new Error('新規会話の作成に失敗しました。');
const data = await response.json();
currentConversationId = data.conversation_id;
chatTitle.textContent = data.title;
deleteChatBtn.style.display = 'block';
await loadConversations();
updateConversationListHighlight();
} catch (error) {
console.error(error);
toggleGenerating(false);
return;
}
}
// アシスタントの応答をストリーミングで取得
const assistantMessage = { role: 'assistant', content: '' };
messages.push(assistantMessage);
const assistantMessageDiv = renderMessage(assistantMessage, messages.length - 1);
chatMessages.appendChild(assistantMessageDiv);
const contentDiv = assistantMessageDiv.querySelector('.message-content');
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversation_id: currentConversationId,
model: modelSelect.value,
messages: messages.slice(0, -1) // 最後のアシスタントの空メッセージは含めない
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'APIリクエストに失敗しました。');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\\n\\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonData = line.substring(6);
if (jsonData.trim()) {
try {
const parsedData = JSON.parse(jsonData);
if (parsedData.content) {
assistantMessage.content += parsedData.content;
renderMarkdown(contentDiv, assistantMessage.content + ' ▌');
scrollToBottom();
}
if (parsedData.error) {
throw new Error(parsedData.error);
}
} catch (e) {
console.warn("JSONのパースに失敗:", jsonData);
}
}
}
}
}
} catch (error) {
console.error('ストリーミングエラー:', error);
assistantMessage.content += `\n\n**エラーが発生しました:** ${error.message}`;
} finally {
renderMarkdown(contentDiv, assistantMessage.content);
addCopyButtons();
toggleGenerating(false);
// 会話が長くなった場合、タイトルを更新するかもしれない
// await loadConversations();
}
});
// --- UI描画・ヘルパー関数 ---
/** 新しいチャットを開始する状態にする */
function startNewChat() {
currentConversationId = null;
messages = [];
chatTitle.textContent = '新しいチャット';
chatMessages.innerHTML = `
<div class="text-center text-gray-500">
<p>下部の入力欄からチャットを開始してください。</p>
</div>
`;
deleteChatBtn.style.display = 'none';
updateConversationListHighlight();
}
/** メッセージリスト全体を描画する */
function renderMessages() {
chatMessages.innerHTML = '';
messages.forEach((msg, index) => {
const messageDiv = renderMessage(msg, index);
chatMessages.appendChild(messageDiv);
});
addCopyButtons();
scrollToBottom();
}
/** 個々のメッセージを描画するHTML要素を生成する */
function renderMessage(message, index) {
const isUser = message.role === 'user';
const div = document.createElement('div');
div.className = `flex items-start gap-4 ${isUser ? 'justify-end' : ''}`;
const iconClass = isUser ? 'fa-user' : 'fa-robot';
const bgColor = isUser ? 'bg-blue-600' : 'bg-gray-700';
const iconHtml = `
<div class="w-8 h-8 rounded-full ${bgColor} flex items-center justify-center flex-shrink-0">
<i class="fas ${iconClass}"></i>
</div>
`;
const contentHtml = `
<div class="max-w-2xl prose prose-invert message-content rounded-lg p-4 ${isUser ? 'bg-blue-900/50' : 'bg-gray-800'}">
${message.content}
</div>
`;
div.innerHTML = isUser ? contentHtml + iconHtml : iconHtml + contentHtml;
// 初回レンダリング時にMarkdownを適用
renderMarkdown(div.querySelector('.message-content'), message.content);
return div;
}
/** MarkdownをHTMLに変換して表示する */
function renderMarkdown(element, text) {
element.innerHTML = marked.parse(text);
}
/** コードブロックにコピーボタンを追加する */
function addCopyButtons() {
document.querySelectorAll('.prose pre').forEach(pre => {
// 既にボタンがあれば追加しない
if (pre.querySelector('.copy-code-btn')) return;
const code = pre.querySelector('code');
const btn = document.createElement('button');
btn.innerHTML = '<i class="fas fa-copy"></i>';
btn.className = 'copy-code-btn';
btn.title = 'Copy code';
btn.addEventListener('click', () => {
navigator.clipboard.writeText(code.innerText).then(() => {
btn.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(() => { btn.innerHTML = '<i class="fas fa-copy"></i>'; }, 2000);
});
});
pre.appendChild(btn);
});
}
/** チャットウィンドウを一番下までスクロールする */
function scrollToBottom() {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
/** 会話リストのハイライトを更新する */
function updateConversationListHighlight() {
document.querySelectorAll('.conversation-item').forEach(item => {
if (item.dataset.id === currentConversationId) {
item.classList.add('bg-blue-800/50');
} else {
item.classList.remove('bg-blue-800/50');
}
});
}
/** 生成中のUIの状態を切り替える */
function toggleGenerating(generating) {
isGenerating = generating;
sendBtn.disabled = generating;
messageInput.disabled = generating;
messageInput.value = generating ? '' : messageInput.value;
messageInput.style.height = 'auto';
}
</script>
</body>
</html>
"""
# --- APIエンドポイント ---
@app.route("/")
def index():
"""メインのWebページを表示します。"""
return render_template_string(HTML_TEMPLATE)
@app.route("/api/models")
def get_models():
"""Ollamaから利用可能なモデルの一覧を取得します。"""
try:
response = requests.get(OLLAMA_TAGS_URL, timeout=5)
response.raise_for_status()
return jsonify(response.json())
except requests.exceptions.RequestException as e:
print(f"Ollamaモデルの取得に失敗: {e}")
return jsonify({"error": "Ollamaサーバーに接続できませんでした。"}), 500
@app.route("/api/conversations", methods=['GET'])
def get_conversations():
"""保存されているすべての会話のリストを取得します。"""
conn = get_db_connection()
conversations = conn.execute("SELECT id, title, created_at FROM conversations ORDER BY created_at DESC").fetchall()
conn.close()
return jsonify([dict(row) for row in conversations])
@app.route("/api/conversations/new", methods=['POST'])
def new_conversation():
"""新しい会話を作成します。"""
data = request.json
title = data.get('title', '無題の会話')
new_id = str(uuid.uuid4())
conn = get_db_connection()
conn.execute("INSERT INTO conversations (id, title) VALUES (?, ?)", (new_id, title))
conn.commit()
conn.close()
return jsonify({"conversation_id": new_id, "title": title})
@app.route("/api/conversations/<string:conv_id>", methods=['GET'])
def get_conversation_details(conv_id):
"""特定の会話のタイトルとメッセージ履歴を取得します。"""
conn = get_db_connection()
conv = conn.execute("SELECT title FROM conversations WHERE id = ?", (conv_id,)).fetchone()
if not conv:
return jsonify({"error": "Conversation not found"}), 404
messages = conn.execute(
"SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
(conv_id,)
).fetchall()
conn.close()
return jsonify({
"title": conv['title'],
"messages": [dict(row) for row in messages]
})
@app.route("/api/conversations/delete/<string:conv_id>", methods=['POST'])
def delete_conversation(conv_id):
"""特定の会話を削除します。"""
conn = get_db_connection()
conn.execute("DELETE FROM conversations WHERE id = ?", (conv_id,))
# messagesテーブルはFOREIGN KEYのON DELETE CASCADEで自動的に削除される
conn.commit()
conn.close()
return jsonify({"success": True})
@app.route("/api/chat", methods=["POST"])
def chat():
"""Ollamaと通信し、結果をストリーミングで返します。"""
data = request.json
model = data.get("model")
conversation_id = data.get("conversation_id")
# フロントエンドから送られてくるメッセージ履歴には、ユーザーの最新メッセージが含まれている
messages_history = data.get("messages", [])
if not all([model, conversation_id, messages_history]):
return jsonify({"error": "モデル、会話ID、メッセージは必須です。"}), 400
# ユーザーの最後のメッセージをDBに保存
user_message = messages_history[-1]
conn = get_db_connection()
conn.execute(
"INSERT INTO messages (conversation_id, model, role, content) VALUES (?, ?, ?, ?)",
(conversation_id, model, 'user', user_message['content'])
)
conn.commit()
conn.close()
def generate_responses():
assistant_full_response = ""
try:
# Ollamaへのリクエストペイロードを作成
payload = {
"model": model,
"messages": messages_history,
"stream": True
}
# requestsを使ってOllamaにストリーミングリクエストを送信
with requests.post(OLLAMA_API_URL, json=payload, stream=True, timeout=300) as r:
r.raise_for_status() # HTTPエラーがあれば例外を発生させる
for chunk in r.iter_lines():
if chunk:
try:
decoded_chunk = json.loads(chunk)
content = decoded_chunk.get("message", {}).get("content", "")
assistant_full_response += content
# フロントエンドにイベントを送信
sse_data = {"content": content}
yield f"data: {json.dumps(sse_data)}\n\n"
# ストリームの最後かチェック
if decoded_chunk.get("done"):
break
except json.JSONDecodeError:
print(f"JSONデコードエラー: {chunk}")
continue
except requests.exceptions.RequestException as e:
print(f"Ollamaへのリクエストエラー: {e}")
error_message = f"Ollamaへの接続中にエラーが発生しました: {e}"
yield f"data: {json.dumps({'error': error_message})}\n\n"
except Exception as e:
print(f"予期せぬエラー: {e}")
error_message = f"予期せぬサーバーエラーが発生しました: {e}"
yield f"data: {json.dumps({'error': error_message})}\n\n"
finally:
# アシスタントの完全な応答をデータベースに保存
if assistant_full_response.strip():
conn_final = get_db_connection()
conn_final.execute(
"INSERT INTO messages (conversation_id, model, role, content) VALUES (?, ?, ?, ?)",
(conversation_id, model, 'assistant', assistant_full_response)
)
conn_final.commit()
conn_final.close()
# Server-Sent Events (SSE)としてレスポンスを返す
return Response(generate_responses(), mimetype="text/event-stream")
# --- アプリケーションの実行 ---
if __name__ == "__main__":
init_database()
print(f"サーバーを http://{HOST}:{PORT} で起動します。")
print("LAN内の他のデバイスからは http://<あなたのIPアドレス>:"+str(PORT)+" でアクセスしてください。")
app.run(host=HOST, port=PORT, debug=False) # debug=Trueは開発時のみ