1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

皆さん、こんにちは!
AIチャットボット、どんどん進化してますね。
今回は、Go言語を使って、自分だけのAIチャットボットを作成・管理できるWebアプリを作ってみました。

完成イメージ

最初に完成イメージを載せてた方が分かりやすいですよね。
(今度からそうしよう)

要件

  • 役割を設定したAIを複数作ることができる
  • 入力欄にプロンプトを入力して「Run」ボタンをクリックすると、AIが回答を生成する
  • 履歴は画面中央に表示され、スクロールする

技術スタック

  • Go: 軽量かつ高速なプログラミング言語
  • Gin: GoのWebアプリケーションフレームワーク / 今回はテンプレートを使います
  • Tailwind CSS: utilityファーストなCSSフレームワーク
  • OpenAI API: AIチャットボット機能を実装するために、OpenAIのAPIを利用
  • DB: 今回は簡易的に、JSONで管理

フロントはReact等を使っても良かったのですが、久々にテンプレートを使うことにしました。

プロジェクトの始め方

以下のようなコマンドを実行します。

mkdir go-may-ai
cd go-may-ai
go mod init go-may-ai

あとはVSCodeなどで実装しましょう!

ディレクトリ構成

ai_configs.json が DB の代わりです。

実装

main.go

main.go
package main

import (
	"encoding/json"
	"fmt"
	"html/template"
	"io/ioutil"
	"net/http"
	"os"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/sashabaranov/go-openai"
)

// AIの設定を保持する構造体
type AIConfig struct {
	ID      int               `json:"id"`
	Name    string            `json:"name"`
	Type    string            `json:"type"`
	Options map[string]string `json:"options"` // "Role"と"Model"を格納
	History []ChatMessage     `json:"history"`
}

// チャットメッセージ
type ChatMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

// AIConfigのスライス
type AIConfigs []AIConfig

// DBファイル名
const dbFileName = "ai_configs.json"

// DBからAIの設定を読み込む
func (a *AIConfigs) LoadDB() error {
	if _, err := os.Stat(dbFileName); err == nil {
		data, err := ioutil.ReadFile(dbFileName)
		if err != nil {
			return fmt.Errorf("failed to read DB file: %w", err)
		}
		if err := json.Unmarshal(data, a); err != nil {
			return fmt.Errorf("failed to unmarshal DB data: %w", err)
		}
	}
	return nil
}

// AIの設定を保存
func (a *AIConfigs) SaveDB() error {
	data, err := json.MarshalIndent(a, "", "  ")
	if err != nil {
		return fmt.Errorf("failed to marshal DB data: %w", err)
	}
	if err := ioutil.WriteFile(dbFileName, data, 0644); err != nil {
		return fmt.Errorf("failed to write DB file: %w", err)
	}
	return nil
}

// AIの設定を取得
func (a *AIConfigs) GetByID(id int) (*AIConfig, error) {
	for _, config := range *a {
		if config.ID == id {
			return &config, nil
		}
	}
	return nil, fmt.Errorf("AI config not found for ID: %d", id)
}

// AIの設定を追加
func (a *AIConfigs) Add(newConfig *AIConfig) error {
	newConfig.ID = len(*a) + 1
	*a = append(*a, *newConfig)
	return a.SaveDB()
}

// AIの設定を更新
func (a *AIConfigs) Update(updatedConfig *AIConfig) error {
	for i, config := range *a {
		if config.ID == updatedConfig.ID {
			(*a)[i] = *updatedConfig
			return a.SaveDB()
		}
	}
	return fmt.Errorf("AI config not found for ID: %d", updatedConfig.ID)
}

var aiConfigs AIConfigs

func main() {
	// DBからAIの設定を読み込む
	err := aiConfigs.LoadDB()
	if err != nil {
		panic(err)
	}

	router := gin.Default()
	router.SetFuncMap(template.FuncMap{
		"add": func(a, b int) int { return a + b },
	})
	router.LoadHTMLGlob("templates/*.html")
	router.Static("/assets", "./assets")

	// ダッシュボード
	router.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", gin.H{
			"AIConfigs": aiConfigs,
		})
	})

	// AI作成画面
	router.GET("/create", func(c *gin.Context) {
		c.HTML(http.StatusOK, "create.html", nil)
	})
	router.POST("/create", func(c *gin.Context) {
		name := c.PostForm("name")
		aitype := c.PostForm("type")
		apikey := c.PostForm("api-key")
		model := c.PostForm("model")
		newConfig := &AIConfig{
			Name: name,
			Type: aitype,
			Options: map[string]string{
				"APIKey": apikey,
				"Model":  model,
			},
			History: []ChatMessage{},
		}
		err := aiConfigs.Add(newConfig)
		if err != nil {
			c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to add AI config: %v", err))
			return
		}
		c.Redirect(http.StatusSeeOther, "/")
	})

	// AI画面
	router.GET("/ai/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.String(http.StatusBadRequest, "Invalid ID")
			return
		}
		config, err := aiConfigs.GetByID(id)
		if err != nil {
			c.String(http.StatusNotFound, "AI config not found")
			return
		}
		c.HTML(http.StatusOK, "ai.html", gin.H{
			"AIConfig": config,
		})
	})

	router.POST("/ai/:id/chat", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.String(http.StatusBadRequest, "Invalid ID")
			return
		}

		config, err := aiConfigs.GetByID(id)
		if err != nil {
			c.String(http.StatusNotFound, "AI config not found")
			return
		}

		input := c.PostForm("input")

		// --- OpenAI APIとのやりとり ---
		client := openai.NewClient(config.Options["APIKey"])
		resp, err := client.CreateChatCompletion(
			c,
			openai.ChatCompletionRequest{
				Model: config.Options["Model"],
				Messages: []openai.ChatCompletionMessage{
					{
						Role:    "system",
						Content: config.Options["Role"],
					},
					{
						Role:    "user",
						Content: input,
					},
				},
			},
		)

		if err != nil {
			// OpenAI APIからエラーが発生した場合、エラーを処理
			c.String(http.StatusInternalServerError, fmt.Sprintf("OpenAI API Error: %v", err))
			return
		}

		aiResponse := resp.Choices[0].Message.Content
		// --- OpenAI APIとのやりとりの終了 ---

		// チャット履歴を更新
		config.History = append(config.History, ChatMessage{Role: "user", Content: input})
		config.History = append(config.History, ChatMessage{Role: "AI", Content: aiResponse})

		err = aiConfigs.Update(config)
		if err != nil {
			c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update AI config: %v", err))
			return
		}

		// AIのレスポンスのみを返す
		c.String(http.StatusOK, aiResponse)
	})

	// Roleの更新 (POST)
	router.POST("/ai/:id/role", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.String(http.StatusBadRequest, "Invalid ID")
			return
		}

		config, err := aiConfigs.GetByID(id)
		if err != nil {
			c.String(http.StatusNotFound, "AI config not found")
			return
		}

		role := c.PostForm("role")
		config.Options["Role"] = role // Roleを更新

		err = aiConfigs.Update(config) // DBに更新を保存
		if err != nil {
			c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update AI config: %v", err))
			return
		}

		// リダイレクト (元のAI画面に戻る)
		c.Redirect(http.StatusSeeOther, fmt.Sprintf("/ai/%d", id))
	})

	// サーバーを起動
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	router.Run(":" + port)
}

index.html

ダッシュボード

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Custom AI Dashboard</title>
	<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css">
</head>
<body class="bg-gray-900">
    <header class="bg-gray-800 py-4 fixed top-0 left-0 right-0 z-10 flex justify-between items-center px-4">
        <div class="flex items-center">
            <h1 class="text-white text-3xl font-bold"><a href="/">My Custom AI</a></h1>
        </div>
        <nav class="flex">
            <a href="/" class="text-gray-400 hover:text-white px-3 py-2 rounded-md font-medium">Dashboard</a>
            <a href="/create" class="text-gray-400 hover:text-white px-3 py-2 rounded-md font-medium">Create AI</a>
        </nav>
        </header>
      <main class="container mx-auto px-4 py-20">
    <a href="/create" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mb-4 inline-block">
      Create AI
    </a>
    <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
      {{ range $index, $config := .AIConfigs }}
      <a href="/ai/{{ $config.ID }}" class="bg-gray-800 hover:bg-gray-700 shadow-md rounded-lg p-6">
        <h2 class="text-xl font-bold text-white">{{ $config.Name }}</h2>
        <p class="text-gray-400">{{ $config.Options.Model }}</p>
      </a>
      {{ end }}
    </div>
  </main>
</body>
</html>

create.html

AIの作成画面。
今回は、OpenAI限定の実装です。

create.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Create AI</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
    />
  </head>
  <body class="bg-gray-900">
    <header class="bg-gray-800 py-4 fixed top-0 left-0 right-0 z-10 flex justify-between items-center px-4">
        <div class="flex items-center">
            <h1 class="text-white text-3xl font-bold"><a href="/">My Custom AI</a></h1>
        </div>
        <nav class="flex">
            <a href="/" class="text-gray-400 hover:text-white px-3 py-2 rounded-md font-medium">Dashboard</a>
            <a href="/create" class="text-gray-400 hover:text-white px-3 py-2 rounded-md font-medium">Create AI</a>
        </nav>
    </header>
    <main class="container mx-auto px-4 py-8">
      <h2 class="text-2xl font-bold text-white mb-4">Create AI</h2>
      <form
        action="/create"
        method="post"
        class="bg-gray-800 shadow-md rounded-lg p-6"
      >
        <div class="mb-4">
          <label for="name" class="block text-gray-400 font-bold mb-2">AI Name:</label>
          <input
            type="text"
            id="name"
            name="name"
            required
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-200 bg-gray-700 leading-tight focus:outline-none focus:shadow-outline"
          />
        </div>
        <div class="mb-4">
          <label for="type" class="block text-gray-400 font-bold mb-2">AI Type:</label>
          <select
            id="type"
            name="type"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-200 bg-gray-700 leading-tight focus:outline-none focus:shadow-outline"
          >
            <option value="OpenAI">OpenAI</option>
            </select>
        </div>
        <div id="options" class="mb-4">
          <div id="openai-options">
            <div class="mb-4">
              <label for="api-key" class="block text-gray-400 font-bold mb-2">API Key:</label>
              <input
                type="text"
                id="api-key"
                name="api-key"
                required
                class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-200 bg-gray-700 leading-tight focus:outline-none focus:shadow-outline"
              />
            </div>
            <div class="mb-4">
              <label for="model" class="block text-gray-400 font-bold mb-2">Model:</label>
              <select
                id="model"
                name="model"
                class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-200 bg-gray-700 leading-tight focus:outline-none focus:shadow-outline"
              >
              <option value="gpt-4o">GPT-4o (about $0.02 / 1K tokens)</option>
              <option value="gpt-4-turbo">GPT-4 (about $0.04 / 1K tokens)</option>
              <option value="gpt-3.5-turbo">GPT-3.5 Turbo (about $0.002 / 1K tokens)</option>
              </select>
            </div>
          </div>
        </div>
        <div class="flex items-center justify-between">
          <button
            type="submit"
            class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            Create
          </button>
        </div>
      </form>
    </main>
  </body>
</html>

ai.html

AIとのチャット画面。
役割を設定できるようになってます。

ai.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>AI - {{ .AIConfig.Name }}</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css">
  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    function toggleRole() {
      var roleDiv = document.getElementById("roleDiv");
      roleDiv.style.display = roleDiv.style.display === "none" ? "block" : "none";
    }

    function scrollToBottom(elementId) {
      var element = document.getElementById(elementId);
      element.scrollTop = element.scrollHeight;
    }
  </script>
</head>
<body class="bg-gray-900">
  <header class="bg-gray-800 py-4 fixed top-0 left-0 right-0 z-10 flex justify-between items-center px-4">
    <div class="flex items-center">
        <h1 class="text-white text-3xl font-bold"><a href="/">My Custom AI</a></h1>
        <h2 class="text-white text-xl font-bold ml-4">[{{ .AIConfig.Name }}]</h2>
    </div>
    <nav class="flex">
        <a href="/" class="text-gray-400 hover:text-white px-3 py-2 rounded-md font-medium">Dashboard</a>
        <a href="/create" class="text-gray-400 hover:text-white px-3 py-2 rounded-md font-medium">Create AI</a>
    </nav>
    </header>

  <main class="container mx-auto px-4 py-16 flex flex-col h-screen"> 

    <div class="flex-grow flex flex-col bg-gray-800 shadow-md rounded-lg p-6 mb-4 overflow-hidden"> 
        <button onclick="toggleRole()" 
                class="text-gray-400 hover:text-white focus:outline-none px-2 py-1 rounded-md text-sm font-medium">
          <span id="roleButtonText">
            {{ if .AIConfig.Options.Role }} Edit Role {{ else }} Set Role {{ end }}
          </span>
        </button>

      <div id="roleDiv" style="display: none;"> 
        <form action="/ai/{{ .AIConfig.ID }}/role" method="post" class="mb-4"> 
          <label for="role" class="block text-gray-400 font-bold mb-2">Role:</label>
          <input type="text" id="role" name="role" value="{{ .AIConfig.Options.Role }}" 
                 placeholder="e.g., You are an excellent marketer."
                 class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-200 bg-gray-700 leading-tight focus:outline-none focus:shadow-outline">
          <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline mt-2">
              Update Role
          </button>
        </form>
      </div>

      <div id="chatHistory" class="chat-history overflow-y-auto flex-grow"> 
        {{ range .AIConfig.History }}
          <div class="chat-message {{if eq .Role "user"}}bg-gray-700{{else}}bg-gray-600{{end}} rounded-lg p-3 mb-2 break-words">
            <p class="text-white font-bold">{{.Role}}</p>
            <p class="text-gray-300">{{.Content}}</p>
          </div>
        {{ end }}
      </div>
    </div>

    <div class="bg-gray-800 shadow-md rounded-lg p-4 fixed bottom-0 left-0 right-0 flex" id="promptArea"> 
        <form action="/ai/{{ .AIConfig.ID }}/chat" method="post" id="promptForm" class="flex w-full"> 
          <textarea id="input" name="input" placeholder="Enter your prompt..."
                    class="shadow appearance-none border rounded-l w-full py-2 px-3 text-gray-200 bg-gray-700 leading-tight focus:outline-none focus:shadow-outline resize-none"
                    rows="1"></textarea>
          <input type="hidden" name="role" value="{{ .AIConfig.Options.Role }}">  
          <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-r focus:outline-none focus:shadow-outline">
            Run
          </button>
        </form>
        <div id="loading" class="hidden ml-2"> 
          <svg class="animate-spin h-5 w-5 text-white" viewBox="0 0 24 24">
            <circle class="opacity-75" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
            <path class="opacity-25" fill="currentColor" d="M12 14l4.24-4.24L14.81 8.63 8.63 14.81 12 14z"/>
          </svg>
        </div>
    </div>

  </main>

  <script>
    document.addEventListener('DOMContentLoaded', function() {
      document.getElementById("promptForm").addEventListener('submit', function(e) {
        e.preventDefault(); // ページのリロードを防止

        var form = e.target;
        var input = document.getElementById("input").value;
        var role = document.querySelector('input[name="role"]').value;
        var chatHistory = document.getElementById("chatHistory");

        // プロンプトを履歴に追加
        var userMessage = document.createElement('div');
        userMessage.classList.add("chat-message", "bg-gray-700", "rounded-lg", "p-3", "mb-2", "break-words");
        userMessage.innerHTML = `<p class="text-white font-bold">User</p><p class="text-gray-300">${input}</p>`;
        chatHistory.appendChild(userMessage);

        // 履歴を一番下までスクロール
        scrollToBottom("chatHistory");
        
        // ローディングインジケーターを表示
        document.getElementById("loading").classList.remove("hidden");

        // AJAX でプロンプトを送信
        fetch(`/ai/{{ .AIConfig.ID }}/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          body: `input=${encodeURIComponent(input)}&role=${encodeURIComponent(role)}` 
        })
        .then(response => response.text()) 
        .then(aiResponse => {
          // レスポンスを履歴に追加
          var aiMessage = document.createElement('div');
          aiMessage.classList.add("chat-message", "bg-gray-600", "rounded-lg", "p-3", "mb-2", "break-words");
          aiMessage.innerHTML = `<p class="text-white font-bold">AI</p><p class="text-gray-300">${aiResponse}</p>`;
          chatHistory.appendChild(aiMessage);

          // ローディングインジケーターを非表示にする
          document.getElementById("loading").classList.add("hidden");

          // 入力欄をクリア
          document.getElementById("input").value = '';

          // 履歴を一番下までスクロール
          scrollToBottom("chatHistory");
        })
        .catch(error => {
          // 例外処理が必要な場合は、ここに実装
          console.error('Error:', error);
        });
      });
    });

    window.onload = function() {
      scrollToBottom("chatHistory");
    };
  </script>
</body>
</html>

実行

以下のコマンドでサーバーを起動し、localhost の ポート番号8080 にアクセスします。

go run main.go

起動直後の画面

AIの作成画面
API Keyをセットして作成します。

ダッシュボード
作成すると、ダッシュボードにパネルで並びます。

チャット画面
まずは Set Role をクリックして、役割を設定します。

今回は話し相手にしました。

AIとのやりとり
それでは、会話してみましょう。

きちんとやりとり出来ているようですね!

まとめ

Go言語、Gin、Tailwind CSSを使って、自分だけのAIチャットボットWebアプリを作成しました。
自分用であればDBを使わずJSONでも十分ですが、複数名で利用する場合はDBの利用をお願いします。

ぜひ皆さんも、自分だけのオリジナルAIを作ってみてください!

一緒にがんばりましょう😀

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?