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

Raspberry Pi 4 に Ollama を入れて、Windowsブラウザから使えるWebチャットを作る

1
Last updated at Posted at 2026-06-27

image.png

書いてること

Raspberry Pi 4 に Raspberry Pi OS をインストールし、ローカルLLM(Ollama)をセットアップ
更に、ブラウザからOllmaにプロンプトを送信する簡易Webアプリを作成

これらのセットアップ手順を備忘録として残している

環境

  • Raspberry Pi 4
    • メモリ:4GB
    • Raspberry Pi OS
    • ローカルLLM:Ollama モデル:gemma2:2b
    • Webサーバー:FastAPI + Uvicorn
  • 操作端末:Windows PC
  • Windowsとラズパイは同じローカルネットワーク内にあり通信可

全体構成

Windowsブラウザ
↓ HTTP
Raspberry Pi 4 上のWebサーバー(FastAPI)
↓ HTTP
Ollama API(127.0.0.1:11434)

gemma2:2b

Windowsのブラウザから質問を送ると、ラズパイ上のOllamaが回答を生成する

ブラウザ上には回答文字列を表示し、Web Speech APIを使ってWindows側のスピーカーから読み上げる

セットアップ手順

(手順1) Raspberry Pi OS をmicroSDへ書き込む

Raspberry Pi Imager でmicroSDにOSをインストールする

インストールの際、SSHの有効化と、Raspberry Pi Connectをしておくと便利

書き込み後、microSDをラズパイへ挿入して起動する

(手順2) WindowsからSSH接続する

git bashでWindowsからRaspberry Piに接続する

ssh {ユーザーID}@{Raspberry PiのIPアドレス}
  • 参考①
    • Raspberry PiのIPアドレスは以下のPowreShellコマンドでIPアドレスを確認することができるかも
Get-NetNeighbor -AddressFamily IPv4

(手順3) Raspberry Pi に Ollama をインストールする

SSH接続したコンソールにて、Ollama をインストールする

Ollamaのインストール方法は公式ドキュメントにある

私はこのブログを参考にインストールし、モデルも同じ gemma2:2b にした

補足情報

インストール後、Ollamaのサービスを有効にする

sudo systemctl enable --now ollama

状態を確認する。

sudo systemctl status ollama`

以下のように active (running) なら起動している

Active: active (running)

サービスを再起動したいは以下のコマンドを実行する

sudo systemctl restart ollama

ログをリアルタイムで確認する場合は以下のコマンドを実行する

journalctl -u ollama -f

インストール済みモデルを確認する

ollama list

(手順4) Ollama動作確認

ollama が動作するか確認する

ollama run gemma2:2b "こんにちは。一言で返して"

以下のような応答があると動作OK

user1@raspi:~ $ ollama run gemma2:2b "こんにちは。一言で返して"
こんにちは! 😊

(手順5) Ollama APIを確認する

APIの動作を確認する

curl http://127.0.0.1:11434/api/tags

実行結果の例

user1@raspi:~ $ curl http://127.0.0.1:11434/api/tags
{"models":[{"name":"gemma2:2b","model":"gemma2:2b","modified_at":"2026-06-27T11:42:07.678765719+09:00","size":1629518495,"digest":"8ccf136fdd5298f3ffe2d69862750ea7fb56555fa4d5b18c04e3fa4d82ee09d7","details":{"parent_model":"","format":"gguf","family":"gemma2","families":["gemma2"],"parameter_size":"2.6B","quantization_level":"Q4_0","context_length":8192,"embedding_length":2304},"capabilities":["completion"]}]}

チャットAPIを確認する

curl -sS -X POST http://127.0.0.1:11434/api/chat -H "Content-Type: application/json" -d '{"model":"gemma2:2b","messages":[{"role":"user","content":"こんにちは。一言で返して"}],"stream":false}'

実行結果の例

user1@raspi:~ $ curl -sS -X POST http://127.0.0.1:11434/api/chat -H "Content-Type: application/json" -d '{"model":"gemma2:2b","messages":[{"role":"user","content":"こんにちは。一言で返して"}],"stream":false}'
{"model":"gemma2:2b","created_at":"2026-06-27T07:22:27.141580348Z","message":{"role":"assistant","content":"こんにちは 😊 \n"},"done":true,"done_reason":"stop","total_duration":54730834103,"load_duration":49725680459,"prompt_eval_count":15,"prompt_eval_duration":3016451000,"eval_count":5,"eval_duration":1983463000}

(手順6) FastAPI用のPython環境を作成する

Webアプリ用のフォルダを作成する。

mkdir -p ~/ollama-web
cd ~/ollama-web

Python仮想環境を作成して有効化する

python3 -m venv .venv
source .venv/bin/activate

FastAPIとUvicornをインストールする

pip install --upgrade pip
pip install fastapi uvicorn

(手順7) Webチャットアプリを作る

~/ollama-web/app.py を作成する

nano ~/ollama-web/app.py

構成

(.venv) user@raspi:~/ollama-web $ pwd
/home/raspi/ollama-web
(.venv) user@raspi:~/ollama-web $ ls -l
total 20
-rw-r--r-- 1 root  root  6888 Jun 27 13:32 app.py

app.py

app.py
import json
import urllib.error
import urllib.request
from pathlib import Path

from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel

APP_DIR = Path(__file__).parent
OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
MODEL = "gemma2:2b"

app = FastAPI()


class ChatRequest(BaseModel):
    message: str
    history: list[dict[str, str]] = []


HTML = """
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Raspberry Pi Ollama Chat</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      background: #111827;
      color: #e5e7eb;
      max-width: 900px;
      margin: 0 auto;
      padding: 20px;
    }
    h1 { font-size: 1.4rem; }
    #chat {
      min-height: 420px;
      border: 1px solid #374151;
      border-radius: 12px;
      padding: 12px;
      background: #1f2937;
      overflow-y: auto;
    }
    .msg {
      white-space: pre-wrap;
      padding: 10px 12px;
      margin: 8px 0;
      border-radius: 10px;
      max-width: 85%;
    }
    .user { background: #1d4ed8; margin-left: auto; }
    .assistant { background: #374151; }
    form { display: flex; gap: 8px; margin-top: 12px; }
    textarea {
      flex: 1;
      min-height: 54px;
      padding: 10px;
      border-radius: 8px;
      border: 1px solid #4b5563;
      background: #111827;
      color: #fff;
    }
    button {
      padding: 10px 16px;
      border: 0;
      border-radius: 8px;
      cursor: pointer;
    }
    .options { margin: 10px 0; font-size: .9rem; }
    #status { color: #9ca3af; margin-top: 8px; }
  </style>
</head>
<body>
  <h1>Raspberry Pi Ollama Chat</h1>

  <div class="options">
    <label>
      <input type="checkbox" id="speak" checked>
      Windowsのスピーカーで読み上げる
    </label>
  </div>

  <div id="chat"></div>

  <form id="form">
    <textarea id="message" placeholder="日本語で入力してください" required></textarea>
    <button type="submit">送信</button>
  </form>

  <div id="status"></div>

<script>
const chat = document.getElementById("chat");
const form = document.getElementById("form");
const messageInput = document.getElementById("message");
const speakCheckbox = document.getElementById("speak");
const status = document.getElementById("status");

const history = [];

function addMessage(role, text) {
  const div = document.createElement("div");
  div.className = "msg " + role;
  div.textContent = text;
  chat.appendChild(div);
  chat.scrollTop = chat.scrollHeight;
  return div;
}

function speak(text) {
  if (!speakCheckbox.checked || !("speechSynthesis" in window)) {
    return;
  }

  window.speechSynthesis.cancel();

  const utterance = new SpeechSynthesisUtterance(text);
  utterance.lang = "ja-JP";
  utterance.rate = 1.05;
  utterance.pitch = 1.0;

  const voices = window.speechSynthesis.getVoices();
  const japaneseVoice = voices.find(v => v.lang.startsWith("ja"));
  if (japaneseVoice) {
    utterance.voice = japaneseVoice;
  }

  window.speechSynthesis.speak(utterance);
}

window.speechSynthesis?.getVoices();
window.speechSynthesis?.addEventListener("voiceschanged", () => {
  window.speechSynthesis.getVoices();
});

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const message = messageInput.value.trim();
  if (!message) return;

  addMessage("user", message);
  history.push({ role: "user", content: message });
  messageInput.value = "";

  const waiting = addMessage("assistant", "考えています...");
  status.textContent = "Ollamaに問い合わせ中...";

  try {
    const response = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        message,
        history: history.slice(-8)
      })
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.detail || "エラーが発生しました");
    }

    waiting.textContent = data.answer;
    history.push({ role: "assistant", content: data.answer });
    speak(data.answer);
    status.textContent = "";
  } catch (error) {
    waiting.textContent = "エラー: " + error.message;
    status.textContent = "Ollamaが起動しているか確認してください。";
  }
});

messageInput.addEventListener("keydown", (event) => {
  if (event.key === "Enter" && !event.shiftKey) {
    event.preventDefault();
    form.requestSubmit();
  }
});
</script>
</body>
</html>
"""


@app.get("/", response_class=HTMLResponse)
def index():
    return HTML


@app.post("/api/chat")
def chat(request: ChatRequest):
    message = request.message.strip()

    if not message:
        raise HTTPException(status_code=400, detail="メッセージが空です。")

    if len(message) > 1000:
        raise HTTPException(status_code=400, detail="メッセージが長すぎます。")

    messages = [
        {
            "role": "system",
            "content": (
                "あなたはRaspberry Pi上で動く小型AIです。"
                "日本語で、簡潔かつ分かりやすく答えてください。"
                "返答は音声でも読み上げられるため、記号や表は控えめにしてください。"
            ),
        }
    ]

    for item in request.history[-8:]:
        role = item.get("role")
        content = item.get("content", "").strip()

        if role in ("user", "assistant") and content:
            messages.append({"role": role, "content": content})

    if not messages or messages[-1]["role"] != "user":
        messages.append({"role": "user", "content": message})

    payload = {
        "model": MODEL,
        "messages": messages,
        "stream": False,
        "think": False,
        "options": {
            "num_predict": 180
        }
    }

    try:
        body = json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(
            OLLAMA_URL,
            data=body,
            headers={"Content-Type": "application/json"},
            method="POST",
        )

        with urllib.request.urlopen(req, timeout=180) as response:
            result = json.loads(response.read().decode("utf-8"))

        answer = result.get("message", {}).get("content", "").strip()

        if not answer:
            raise HTTPException(status_code=502, detail="Ollamaから回答を取得できませんでした。")

        return {"answer": answer}

    except urllib.error.URLError as error:
        raise HTTPException(
            status_code=503,
            detail=f"Ollamaへ接続できません: {error.reason}",
        )
  • 注意
    • インストールしたモデルと、app.py の MODEL = "gemma2:2b" を合わせること

(手順8) Webサーバを起動する

app.py があるところをカレントディレクトリにして、Webサーバを起動する

cd ~/ollama-web
source .venv/bin/activate
uvicorn app:app --host 0.0.0.0 --port 8000

コマンド実行例

(.venv) user@raspi:/ $ cd ~/ollama-web
(.venv) user@raspi:~/ollama-web $ ls
app.py  ng_app.py  __pycache__
(.venv) user@raspi:~/ollama-web $ source .venv/bin/activate
(.venv) user@raspi:~/ollama-web $ uvicorn app:app --host 0.0.0.0 --port 8000
INFO:     Started server process [12941]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

(手順9) Webアプリの動作画面

Windowsのブラウザで、http://{Raspberry PiのIPアドレス}:8000/ を開く

image.png

質問を送信する。

image.png

(20秒くらい待つと)以下のような応答がくる。Windowsのスピーカーで読み上げるにチェックあると音声読み上げもする

image.png

ー以上ー

所感

Ollamaを動作させるには、今回用意したものではスペックが足りず、応答が遅かったり、タイムアウトが発生したりと安定的に動作はできませんでした。もっと、スペックの高い環境に実装が必要でした

用意したWebアプリに音声入力の仕組みも追加するともう少し便利かもしれません

扉絵はChatGPTにより生成されたものです、完成度の高さに驚き

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