0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ollama Chat Apiを利用したチャットWEBアプリ

Last updated at Posted at 2025-05-25

目的

Ollama Web UIを利用しない形でOllamaを利用したLLMのチャットツールを作成したい!

Ollama Web UI を作成する際は、Docker環境を構築し、その環境下でDocker環境を立ち上げる必要がある。
Docker For Desktopは使用する場面によっては有料利用を行う必要があるため、別の対応を行う形でOllama Chat Apiを利用できるようにしたい。

Docker利用についての詳細:

Docker Desktopの利用が有料になる条件は、原則として、従業員数250人以上または年間売上高1000万ドル(約15億円)以上の企業が、Docker Desktopを商用利用する場合です。個人利用、スモールビジネス(上記条件に該当しない)、教育機関、非商用のオープンソースプロジェクトは引き続き無料で利用可能です。

有料になる条件:
従業員数250人以上
年間売上高1000万ドル(約15億円)以上
上記いずれかに該当する組織が、Docker Desktopを商用利用する場合

無償で利用できる条件:
個人利用
スモールビジネス(従業員数250人未満かつ年間売上高1000万ドル未満)
教育機関
非商用のオープンソースプロジェクト

ReactプログラムをCluadeを利用してそれっぽいものを作成してみました、
私の備忘録としてこの記事を残しておきます。

実際のコード (React **.jsx)

見た目はこんな感じです。

スクリーンショット 2025-05-26 8.47.16.png

import React, { useState, useEffect, useCallback } from 'react';
import { Send, Bot, User, AlertCircle, Settings, Zap, Globe } from 'lucide-react';

const OllamaClient = () => {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');
  const [loading, setLoading] = useState(false);
  const [models, setModels] = useState([]);
  const [selectedModel, setSelectedModel] = useState('');
  const [ollamaUrl, setOllamaUrl] = useState('http://localhost:11434');
  const [connectionStatus, setConnectionStatus] = useState('disconnected');
  const [showSettings, setShowSettings] = useState(false);
  const [useStreaming, setUseStreaming] = useState(true);

  // Ollamaサーバーの接続確認
  const checkConnection = useCallback(async () => {
    try {
      const response = await fetch(`${ollamaUrl}/api/tags`);
      if (response.ok) {
        setConnectionStatus('connected');
        return true;
      } else {
        setConnectionStatus('error');
        return false;
      }
    } catch (error) {
      setConnectionStatus('error');
      console.error('Connection error:', error);
      return false;
    }
  }, [ollamaUrl]);

  // 利用可能なモデル一覧を取得
  const fetchModels = useCallback(async () => {
    try {
      const response = await fetch(`${ollamaUrl}/api/tags`);
      if (response.ok) {
        const data = await response.json();
        setModels(data.models || []);
        if (data.models && data.models.length > 0 && !selectedModel) {
          setSelectedModel(data.models[0].name);
        }
      }
    } catch (error) {
      console.error('Error fetching models:', error);
    }
  }, [ollamaUrl, selectedModel]);

  // メッセージをOllamaに送信
  const sendMessage = async (message) => {
    if (!message.trim() || !selectedModel) return;

    const userMessage = { role: 'user', content: message };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setLoading(true);

    try {
      const response = await fetch(`${ollamaUrl}/api/chat`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          model: selectedModel,
          messages: [...messages, userMessage],
          stream: false
        }),
      });

      if (response.ok) {
        const data = await response.json();
        const assistantMessage = {
          role: 'assistant',
          content: data.message?.content || 'レスポンスを取得できませんでした。'
        };
        setMessages(prev => [...prev, assistantMessage]);
      } else {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
    } catch (error) {
      console.error('Error sending message:', error);
      setMessages(prev => [...prev, {
        role: 'assistant',
        content: `エラーが発生しました: ${error.message}`
      }]);
    } finally {
      setLoading(false);
    }
  };

  // ストリーミングレスポンス用の関数
  const sendMessageStream = async (message) => {
    if (!message.trim() || !selectedModel) return;

    const userMessage = { role: 'user', content: message };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setLoading(true);

    try {
      const response = await fetch(`${ollamaUrl}/api/chat`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          model: selectedModel,
          messages: [...messages, userMessage],
          stream: true
        }),
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let assistantMessage = { role: 'assistant', content: '' };
      
      setMessages(prev => [...prev, assistantMessage]);

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value);
        const lines = chunk.split('\n').filter(line => line.trim());

        for (const line of lines) {
          try {
            const data = JSON.parse(line);
            if (data.message?.content) {
              assistantMessage.content += data.message.content;
              setMessages(prev => {
                const newMessages = [...prev];
                newMessages[newMessages.length - 1] = { ...assistantMessage };
                return newMessages;
              });
            }
          } catch (e) {
            // JSONパースエラーは無視
          }
        }
      }
    } catch (error) {
      console.error('Error sending message:', error);
      setMessages(prev => [...prev, {
        role: 'assistant',
        content: `エラーが発生しました: ${error.message}`
      }]);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    const initializeConnection = async () => {
      const connected = await checkConnection();
      if (connected) {
        await fetchModels();
      }
    };
    initializeConnection();
  }, [ollamaUrl, checkConnection, fetchModels]);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim() || !selectedModel || connectionStatus !== 'connected') return;
    if (useStreaming) {
      sendMessageStream(input);
    } else {
      sendMessage(input);
    }
  };

  const getConnectionStatusColor = () => {
    switch (connectionStatus) {
      case 'connected': return 'text-emerald-400';
      case 'error': return 'text-red-400';
      default: return 'text-gray-400';
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/10 to-slate-900 flex flex-col relative overflow-hidden">
      {/* Animated background elements */}
      <div className="absolute inset-0 overflow-hidden pointer-events-none">
        <div className="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl animate-pulse"></div>
        <div className="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl animate-pulse" style={{animationDelay: '2s'}}></div>
        <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-indigo-500/5 rounded-full blur-3xl animate-pulse" style={{animationDelay: '4s'}}></div>
      </div>

      {/* Header */}
      <div className="relative z-10 bg-white/10 backdrop-blur-xl border-b border-white/20 p-6">
        <div className="flex justify-between items-center">
          <div className="flex items-center gap-4">
            <div className="relative">
              <div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
                <Zap className="w-6 h-6 text-white" />
              </div>
              <div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-r from-green-400 to-emerald-500 rounded-full animate-pulse"></div>
            </div>
            <div>
              <h1 className="text-3xl font-bold bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
                Ollama Chat
              </h1>
              <p className="text-gray-400 text-sm">AI-Powered Conversations</p>
            </div>
          </div>
          
          <div className="flex items-center gap-6">
            <div className="flex items-center gap-3 bg-white/5 rounded-full px-4 py-2 border border-white/10">
              <div className={`w-3 h-3 rounded-full ${connectionStatus === 'connected' ? 'bg-emerald-400 shadow-lg shadow-emerald-400/50' : 'bg-red-400 shadow-lg shadow-red-400/50'} animate-pulse`}></div>
              <span className={`text-sm font-medium ${getConnectionStatusColor()}`}>
                {connectionStatus === 'connected' ? 'Connected' : 'Disconnected'}
              </span>
            </div>
            <button
              onClick={() => setShowSettings(!showSettings)}
              className="relative group p-3 bg-white/10 hover:bg-white/20 rounded-xl border border-white/20 transition-all duration-300 hover:scale-105"
            >
              <Settings className="w-5 h-5 text-white group-hover:rotate-45 transition-transform duration-300" />
            </button>
          </div>
        </div>
      </div>

      {/* Settings Panel */}
      {showSettings && (
        <div className="relative z-10 bg-white/10 backdrop-blur-xl border-b border-white/20 p-6 transform transition-all duration-500">
          <div className="flex flex-col gap-6 max-w-4xl mx-auto">
            <div className="grid md:grid-cols-2 gap-6">
              <div className="space-y-3">
                <label className="block text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
                  <Globe className="w-4 h-4" />
                  Ollama Server URL
                </label>
                <input
                  type="text"
                  value={ollamaUrl}
                  onChange={(e) => setOllamaUrl(e.target.value)}
                  className="w-full p-4 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50 transition-all duration-300"
                  placeholder="http://localhost:11434"
                />
              </div>
              <div className="space-y-3">
                <label className="block text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
                  <Bot className="w-4 h-4" />
                  Model Selection
                </label>
                <select
                  value={selectedModel}
                  onChange={(e) => setSelectedModel(e.target.value)}
                  className="w-full p-4 bg-white/10 border border-white/20 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50 transition-all duration-300"
                >
                  <option value="" className="bg-slate-800">Select a model...</option>
                  {models.map((model) => (
                    <option key={model.name} value={model.name} className="bg-slate-800">
                      {model.name}
                    </option>
                  ))}
                </select>
              </div>
              <div className="space-y-3">
                <label className="block text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
                  <Zap className="w-4 h-4" />
                  Response Mode
                </label>
                <div className="flex items-center gap-4">
                  <button
                    onClick={() => setUseStreaming(true)}
                    className={`flex-1 p-3 rounded-xl border transition-all duration-300 ${
                      useStreaming
                        ? 'bg-gradient-to-r from-purple-600 to-blue-600 text-white border-purple-500/50'
                        : 'bg-white/10 text-gray-300 border-white/20 hover:bg-white/20'
                    }`}
                  >
                    Streaming
                  </button>
                  <button
                    onClick={() => setUseStreaming(false)}
                    className={`flex-1 p-3 rounded-xl border transition-all duration-300 ${
                      !useStreaming
                        ? 'bg-gradient-to-r from-purple-600 to-blue-600 text-white border-purple-500/50'
                        : 'bg-white/10 text-gray-300 border-white/20 hover:bg-white/20'
                    }`}
                  >
                    Standard
                  </button>
                </div>
              </div>
            </div>
            <button
              onClick={() => {
                checkConnection().then(connected => {
                  if (connected) fetchModels();
                });
              }}
              className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white px-6 py-3 rounded-xl font-semibold transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25 w-fit"
            >
              Reconnect Server
            </button>
          </div>
        </div>
      )}

      {/* Chat Area */}
      <div className="flex-1 flex flex-col max-w-6xl mx-auto w-full relative z-10">
        {/* Messages */}
        <div className="flex-1 overflow-y-auto p-6 space-y-6">
          {messages.length === 0 && (
            <div className="text-center text-gray-400 mt-16">
              <div className="relative inline-block mb-8">
                <div className="w-24 h-24 bg-gradient-to-br from-purple-500/20 to-blue-600/20 rounded-2xl flex items-center justify-center backdrop-blur-sm border border-white/10">
                  <Bot size={48} className="text-purple-400" />
                </div>
                <div className="absolute -top-2 -right-2 w-8 h-8 bg-gradient-to-r from-green-400 to-emerald-500 rounded-full flex items-center justify-center animate-bounce">
                  <Zap className="w-4 h-4 text-white" />
                </div>
              </div>
              <h2 className="text-2xl font-bold text-white mb-2">Ready to Chat</h2>
              <p className="text-lg">Send a message to start your conversation with Ollama</p>
            </div>
          )}
          
          {messages.map((message, index) => (
            <div
              key={index}
              className={`flex gap-4 ${
                message.role === 'user' ? 'justify-end' : 'justify-start'
              } animate-fade-in-up`}
              style={{animationDelay: `${index * 0.1}s`}}
            >
              <div
                className={`flex gap-4 max-w-[85%] ${
                  message.role === 'user' ? 'flex-row-reverse' : 'flex-row'
                }`}
              >
                <div className="flex-shrink-0">
                  <div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
                    message.role === 'user' 
                      ? 'bg-gradient-to-br from-purple-500 to-blue-600 shadow-lg shadow-purple-500/25' 
                      : 'bg-gradient-to-br from-emerald-500 to-teal-600 shadow-lg shadow-emerald-500/25'
                  }`}>
                    {message.role === 'user' ? (
                      <User size={18} className="text-white" />
                    ) : (
                      <Bot size={18} className="text-white" />
                    )}
                  </div>
                </div>
                <div
                  className={`p-4 rounded-2xl backdrop-blur-sm border transition-all duration-300 hover:scale-[1.02] ${
                    message.role === 'user'
                      ? 'bg-gradient-to-br from-purple-600/90 to-blue-600/90 text-white border-white/20 shadow-lg shadow-purple-500/20'
                      : 'bg-white/10 text-gray-100 border-white/20 shadow-lg'
                  }`}
                >
                  <div className="whitespace-pre-wrap leading-relaxed">{message.content}</div>
                </div>
              </div>
            </div>
          ))}
          
          {loading && (
            <div className="flex gap-4 justify-start animate-fade-in-up">
              <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center shadow-lg shadow-emerald-500/25">
                <Bot size={18} className="text-white" />
              </div>
              <div className="bg-white/10 backdrop-blur-sm border border-white/20 p-4 rounded-2xl shadow-lg">
                <div className="flex space-x-2">
                  <div className="w-3 h-3 bg-emerald-400 rounded-full animate-bounce shadow-lg shadow-emerald-400/50"></div>
                  <div className="w-3 h-3 bg-emerald-400 rounded-full animate-bounce shadow-lg shadow-emerald-400/50" style={{animationDelay: '0.2s'}}></div>
                  <div className="w-3 h-3 bg-emerald-400 rounded-full animate-bounce shadow-lg shadow-emerald-400/50" style={{animationDelay: '0.4s'}}></div>
                </div>
              </div>
            </div>
          )}
        </div>

        {/* Input Form */}
        <div className="border-t border-white/20 bg-white/5 backdrop-blur-xl p-6">
          {connectionStatus !== 'connected' && (
            <div className="mb-6 p-4 bg-gradient-to-r from-yellow-500/20 to-orange-500/20 border border-yellow-500/30 rounded-xl flex items-center gap-3 backdrop-blur-sm">
              <div className="w-8 h-8 bg-yellow-500/20 rounded-lg flex items-center justify-center">
                <AlertCircle size={18} className="text-yellow-400" />
              </div>
              <span className="text-yellow-200 font-medium">
                Unable to connect to Ollama server. Please ensure the server is running.
              </span>
            </div>
          )}
          
          <form onSubmit={handleSubmit} className="flex gap-4">
            <div className="flex-1 relative">
              <input
                type="text"
                value={input}
                onChange={(e) => setInput(e.target.value)}
                placeholder={selectedModel ? "Type your message..." : "Please select a model first"}
                className="w-full p-4 pr-12 bg-white/10 border border-white/20 rounded-2xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50 transition-all duration-300 backdrop-blur-sm"
                disabled={loading || !selectedModel || connectionStatus !== 'connected'}
              />
              <div className="absolute right-4 top-1/2 transform -translate-y-1/2">
                <div className="w-2 h-2 bg-purple-400 rounded-full animate-pulse"></div>
              </div>
            </div>
            <button
              type="submit"
              disabled={loading || !input.trim() || !selectedModel || connectionStatus !== 'connected'}
              className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 disabled:from-gray-600 disabled:to-gray-700 text-white p-4 rounded-2xl transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25 disabled:cursor-not-allowed disabled:opacity-50 disabled:scale-100 group"
            >
              <Send size={20} className="group-hover:translate-x-0.5 transition-transform duration-300" />
            </button>
          </form>
        </div>
      </div>

      <style jsx>{`
        @keyframes fade-in-up {
          from {
            opacity: 0;
            transform: translateY(20px);
          }
          to {
            opacity: 1;
            transform: translateY(0);
          }
        }
        .animate-fade-in-up {
          animation: fade-in-up 0.6s ease-out forwards;
        }
      `}</style>
    </div>
  );
};

export default OllamaClient;

デザイン用にインポートしたパッケージの追記を忘れてました。

私は下記のコードをindex.htmlの<header/>に追記しました
<script src="https://cdn.tailwindcss.com"></script>
0
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?