環境構築からWEBアプリ開発・スマホアプリ開発まで。ときには動画制作やゲームも。

supilog
すぴろぐ

【Gemini3】完全AIチャレンジ! #1ブログらしい何かを作る

【Gemini3】完全AIチャレンジ! #1ブログらしい何かを作る

AIにおまかせして、現時点でどこまで出来るのかを確認したい思いもあり、今回から数回に分けて、チャレンジ企画をやってみようと思います。題して完全AIチャレンジ。これまで培ってきたエンジニアとしての知識を封印して、AIに言われたことのみでどこまで出来るのか。やってみたいと思います。(厳密には、AIに指示を出すところで、多少の知識は使用している)

最初のチャレンジは、無難にブログ的な何かです。というのも、最近イラストを勉強したいなと思い、ほんの2習慣ほど前からデジタルイラストに挑戦してみております。誰に見せるでもなく、自分の成長記録を残しておきたいと思いまして、良い機会なのでこの企画を利用されてもらおうかななんて。

要件

  • AIが出力した「手順」や「ソースコード」以外は使用しない
  • 誰にも見せる予定がないので、ローカルで完結すれば問題ないものとする
  • ログインなどは必要ない
  • 投稿データも含めて、githubに保管できるようにしたい(必然的に、DBサーバーはなしか?)
  • 描いた絵も投稿できるようにしたい
  • 課題点を記録したいので、課題を記事にタグ付けできるようにしたい
  • フレームワークはLaravelを使用するものとする(blade)
  • 使用するAIはGeminiとする

こんなところである。

AIさんお願いします

最初に出す指示を以下とする

画像を投稿できる個人的な日記を作成したいので、以下の要件を満たすソースコードを出力してください。
使用する言語はPHP、フレームワークはLaravelとし、必要に応じて、routeファイル、Contrllerファイル、
Modelファイル、Viewファイル、js、cssファイルなどを作成してください。

# 要件
-アプリ名は「mylogs」とする
-誰にも見せることはないので、ローカルで起動して利用できればよく、ログインなども必要ない
-投稿記事には、画像も投稿できるようにしたい
-投稿記事には、タグ(ラベル)を付与できて、その日の課題を記録したい
-記事投稿の際に、一度作成済みのタグに関しては、入力補助がほしい
-ソースコードをgitに保管する際に、投稿データも保管したい
-ソースコードをgitからcloneした際に、すでに投稿したデータの続きから利用できるようにしたい
-投稿済みの記事は「修正」「削除」が出来るようにしたい
-投稿済みの画像ファイルが一覧で閲覧できるページも欲しい

さてこれで、どんな出力をしてくれるか楽しみです。

AIさんが出してきた設計のポイント

データベース

SQLite を使用します。通常は .gitignore に含まれますが、今回はデータもGit管理するという要件のため、Git管理対象に含める設定をします。

画像保存

public ディレクトリではなく、storage 経由で画像を保存し、Gitで追跡できるようにします。

タグ入力補助

HTMLの <datalist> タグを使用し、追加のJSライブラリなしで既存タグのオートコンプリートを実現します。

デザイン

Tailwind CSS (CDN版) を使用し、ビルド不要ですぐに見栄えの良いUIにします。

だそうです。とくに問題ないので、ノータッチでAIの言う事をききます。

AIさんが出してきた「手順」と「ソースコード」

こちら量が多くなるので、すべてを記載するのは最初だけになるかもしれませんが、一旦すべて載せます。

プロジェクト作成と設定

まずLaravelプロジェクトを作成し、ディレクトリに入ります。

laravel new mylogs
cd mylogs

データベースとGitの設定

.env ファイルの編集 MySQLの設定を削除し、SQLiteを使うように変更します。

DB_CONNECTION=sqlite
# DB_HOST, DB_PORT, DB_DATABASE 等のMySQL関連記述は削除またはコメントアウト

データベースファイルの作成

touch database/database.sqlite

.gitignore の編集(重要) ルートディレクトリにある .gitignore を開き、以下の行を探して**削除(またはコメントアウト)**してください。これでDBと画像がGitに含まれるようになります。

# 以下の行を削除または # でコメントアウト
/database/*.sqlite
/storage/*.key

次に、storage/app/public/.gitignore を開き、中身を以下のように変更(すべて許可)するか、このファイル自体を削除してください。

*
!.gitignore

シンボリックリンクの作成 画像をWEBから閲覧できるようにします。

php artisan storage:link

データベース設計 (Migration)

php artisan make:model Post -m
php artisan make:model Tag -m
php artisan make:migration create_post_tag_table

database/migrations/xxxx_create_posts_table.php

public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->text('body')->nullable();
        $table->string('image_path')->nullable();
        $table->date('date');
        $table->timestamps();
    });
}

database/migrations/xxxx_create_tags_table.php

public function up(): void
{
    Schema::create('tags', function (Blueprint $table) {
        $table->id();
        $table->string('name')->unique();
        $table->timestamps();
    });
}

database/migrations/xxxx_create_post_tag_table.php

public function up(): void
{
    Schema::create('post_tag', function (Blueprint $table) {
        $table->id();
        $table->foreignId('post_id')->constrained()->cascadeOnDelete();
        $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    });
}

マイグレーション実行

php artisan migrate

Model の実装

app/Models/Post.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Post extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'body', 'image_path', 'date'];

    protected $casts = [
        'date' => 'date',
    ];

    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }
}

app/Models/Tag.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Tag extends Model
{
    use HasFactory;
    
    protected $fillable = ['name'];

    public function posts()
    {
        return $this->belongsToMany(Post::class);
    }
}

Controller の実装

作成コマンド

php artisan make:controller LogController

app/Http/Controllers/LogController.php

namespace App\Http\Controllers;

use App\Models\Post;
use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class LogController extends Controller
{
    // 一覧画面
    public function index()
    {
        $posts = Post::with('tags')->orderBy('date', 'desc')->get();
        return view('index', compact('posts'));
    }

    // 画像ギャラリー
    public function gallery()
    {
        $posts = Post::whereNotNull('image_path')->orderBy('date', 'desc')->get();
        return view('gallery', compact('posts'));
    }

    // 新規作成フォーム
    public function create()
    {
        $allTags = Tag::all(); // 入力補助用
        return view('create', compact('allTags'));
    }

    // 保存処理
    public function store(Request $request)
    {
        $validated = $request->validate([
            'date' => 'required|date',
            'title' => 'required|string|max:255',
            'body' => 'nullable|string',
            'image' => 'nullable|image|max:10240', // 10MBまで
            'tags' => 'nullable|string',
        ]);

        $post = new Post($validated);

        // 画像保存 (storage/app/public/uploads へ)
        if ($request->hasFile('image')) {
            $path = $request->file('image')->store('uploads', 'public');
            $post->image_path = $path;
        }

        $post->save();
        $this->syncTags($post, $request->tags);

        return redirect()->route('index')->with('success', 'ログを記録しました。');
    }

    // 編集画面
    public function edit(Post $post)
    {
        $allTags = Tag::all();
        // タグ配列をカンマ区切り文字列に変換
        $currentTags = $post->tags->pluck('name')->implode(',');
        return view('edit', compact('post', 'allTags', 'currentTags'));
    }

    // 更新処理
    public function update(Request $request, Post $post)
    {
        $validated = $request->validate([
            'date' => 'required|date',
            'title' => 'required|string|max:255',
            'body' => 'nullable|string',
            'image' => 'nullable|image|max:10240',
            'tags' => 'nullable|string',
        ]);

        $post->fill($validated);

        if ($request->hasFile('image')) {
            // 古い画像を削除
            if ($post->image_path) {
                Storage::disk('public')->delete($post->image_path);
            }
            $path = $request->file('image')->store('uploads', 'public');
            $post->image_path = $path;
        }

        $post->save();
        $this->syncTags($post, $request->tags);

        return redirect()->route('index')->with('success', 'ログを更新しました。');
    }

    // 削除処理
    public function destroy(Post $post)
    {
        if ($post->image_path) {
            Storage::disk('public')->delete($post->image_path);
        }
        $post->delete();
        return redirect()->route('index')->with('success', 'ログを削除しました。');
    }

    // タグ保存用の共通メソッド
    private function syncTags(Post $post, ?string $tagsInput)
    {
        if (!$tagsInput) {
            $post->tags()->detach();
            return;
        }

        // 全角カンマ対応と空白除去
        $tagNames = array_map('trim', explode(',', str_replace('、', ',', $tagsInput)));
        $tagIds = [];

        foreach ($tagNames as $name) {
            if (empty($name)) continue;
            // タグがあれば取得、なければ作成
            $tag = Tag::firstOrCreate(['name' => $name]);
            $tagIds[] = $tag->id;
        }

        $post->tags()->sync($tagIds);
    }
}

Routing

routes/web.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\LogController;

Route::controller(LogController::class)->group(function () {
    Route::get('/', 'index')->name('index');
    Route::get('/gallery', 'gallery')->name('gallery');
    Route::get('/create', 'create')->name('create');
    Route::post('/store', 'store')->name('store');
    Route::get('/edit/{post}', 'edit')->name('edit');
    Route::put('/update/{post}', 'update')->name('update');
    Route::delete('/destroy/{post}', 'destroy')->name('destroy');
});

Views (Blade Templates)

resources/views/layout.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>mylogs</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-100 text-slate-800 min-h-screen">
    <nav class="bg-white shadow p-4 mb-6 sticky top-0 z-50">
        <div class="container mx-auto flex justify-between items-center max-w-4xl">
            <a href="{{ route('index') }}" class="text-xl font-bold text-indigo-600 tracking-wider">mylogs</a>
            <div class="space-x-4 text-sm font-medium">
                <a href="{{ route('index') }}" class="hover:text-indigo-500">タイムライン</a>
                <a href="{{ route('gallery') }}" class="hover:text-indigo-500">ギャラリー</a>
                <a href="{{ route('create') }}" class="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition">新規記録</a>
            </div>
        </div>
    </nav>

    <div class="container mx-auto px-4 pb-12 max-w-4xl">
        @if(session('success'))
            <div class="bg-emerald-100 border border-emerald-400 text-emerald-700 px-4 py-3 rounded mb-6">
                {{ session('success') }}
            </div>
        @endif
        
        @yield('content')
    </div>
</body>
</html>

resources/views/index.blade.php

@extends('layout')

@section('content')
<div class="space-y-6">
    @foreach($posts as $post)
    <div class="bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition duration-200">
        <div class="flex flex-col md:flex-row gap-6">
            {{-- 画像がある場合のみ表示 --}}
            @if($post->image_path)
            <div class="w-full md:w-1/3 shrink-0">
                <img src="{{ asset('storage/' . $post->image_path) }}" class="rounded-lg object-cover w-full h-48 md:h-full cursor-pointer hover:opacity-90">
            </div>
            @endif

            <div class="flex-1">
                <div class="flex justify-between items-start mb-2">
                    <span class="text-slate-500 text-sm font-mono bg-slate-100 px-2 py-1 rounded">{{ $post->date->format('Y-m-d') }}</span>
                    <a href="{{ route('edit', $post) }}" class="text-slate-400 hover:text-indigo-600">
                        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
                        </svg>
                    </a>
                </div>
                
                <h2 class="text-xl font-bold mb-3 text-slate-800">{{ $post->title }}</h2>
                
                <div class="mb-4 flex flex-wrap gap-2">
                    @foreach($post->tags as $tag)
                        <span class="bg-indigo-50 text-indigo-600 text-xs px-2 py-1 rounded-full font-medium">#{{ $tag->name }}</span>
                    @endforeach
                </div>
                
                <p class="whitespace-pre-wrap text-slate-600 leading-relaxed">{{ $post->body }}</p>
            </div>
        </div>
    </div>
    @endforeach
</div>
@endsection

resources/views/gallery.blade.php

@extends('layout')

@section('content')
<h1 class="text-2xl font-bold mb-6 text-slate-700">画像ギャラリー</h1>

@if($posts->isEmpty())
    <p class="text-slate-500">画像付きの投稿はまだありません。</p>
@else
    <div class="grid grid-cols-2 md:grid-cols-3 gap-4">
        @foreach($posts as $post)
            <a href="{{ route('edit', $post) }}" class="group relative block overflow-hidden rounded-lg aspect-square">
                <img src="{{ asset('storage/' . $post->image_path) }}" class="w-full h-full object-cover transition duration-300 group-hover:scale-110">
                <div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition duration-300 flex items-end">
                    <p class="text-white text-sm p-3 opacity-0 group-hover:opacity-100 truncate w-full font-bold">
                        {{ $post->date->format('Y-m-d') }}<br>
                        {{ $post->title }}
                    </p>
                </div>
            </a>
        @endforeach
    </div>
@endif
@endsection

resources/views/create.blade.php

@extends('layout')

@section('content')
<div class="max-w-2xl mx-auto bg-white p-8 rounded-xl shadow-sm">
    <h1 class="text-2xl font-bold mb-6 text-slate-700">新しいログを記録</h1>
    
    <form action="{{ route('store') }}" method="POST" enctype="multipart/form-data">
        @csrf
        
        <div class="mb-5">
            <label class="block text-slate-700 font-bold mb-2 text-sm">日付</label>
            <input type="date" name="date" value="{{ date('Y-m-d') }}" class="w-full bg-slate-50 border border-slate-300 p-3 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500" required>
        </div>

        <div class="mb-5">
            <label class="block text-slate-700 font-bold mb-2 text-sm">タイトル</label>
            <input type="text" name="title" class="w-full bg-slate-50 border border-slate-300 p-3 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="今日の課題や出来事" required>
        </div>

        <div class="mb-5">
            <label class="block text-slate-700 font-bold mb-2 text-sm">タグ(課題など)</label>
            <p class="text-xs text-slate-500 mb-2">カンマ( , )区切りで複数入力。過去のタグは候補に出ます。</p>
            <input type="text" name="tags" list="tag-list" class="w-full bg-slate-50 border border-slate-300 p-3 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="Laravel, 学習, エラー解決" autocomplete="off">
            <datalist id="tag-list">
                @foreach($allTags as $tag)
                    <option value="{{ $tag->name }}">
                @endforeach
            </datalist>
        </div>

        <div class="mb-5">
            <label class="block text-slate-700 font-bold mb-2 text-sm">画像</label>
            <input type="file" name="image" class="w-full text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" accept="image/*">
        </div>

        <div class="mb-8">
            <label class="block text-slate-700 font-bold mb-2 text-sm">内容</label>
            <textarea name="body" rows="6" class="w-full bg-slate-50 border border-slate-300 p-3 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500"></textarea>
        </div>

        <button type="submit" class="w-full bg-indigo-600 text-white font-bold py-3 rounded-lg hover:bg-indigo-700 transition duration-300">保存する</button>
    </form>
</div>
@endsection

resources/views/edit.blade.php

@extends('layout')

@section('content')
<div class="max-w-2xl mx-auto bg-white p-8 rounded-xl shadow-sm relative">
    
    {{-- 削除ボタン --}}
    <div class="absolute top-8 right-8">
        <form action="{{ route('destroy', $post) }}" method="POST" onsubmit="return confirm('本当にこのログを削除しますか?\n(画像も削除されます)');">
            @csrf
            @method('DELETE')
            <button type="submit" class="text-red-500 hover:text-red-700 text-sm font-bold">削除する</button>
        </form>
    </div>

    <h1 class="text-2xl font-bold mb-6 text-slate-700">ログの編集</h1>
    
    <form action="{{ route('update', $post) }}" method="POST" enctype="multipart/form-data">
        @csrf
        @method('PUT')
        
        <div class="mb-5">
            <label class="block text-slate-700 font-bold mb-2 text-sm">日付</label>
            <input type="date" name="date" value="{{ $post->date->format('Y-m-d') }}" class="w-full bg-slate-50 border border-slate-300 p-3 rounded" required>
        </div>

        <div class="mb-5">
            <label class="block text-slate-700 font-bold mb-2 text-sm">タイトル</label>
            <input type="text" name="title" value="{{ $post->title }}" class="w-full bg-slate-50 border border-slate-300 p-3 rounded" required>
        </div>

        <div class="mb-5">
            <label class="block text-slate-700 font-bold mb-2 text-sm">タグ</label>
            <input type="text" name="tags" value="{{ $currentTags }}" list="tag-list" class="w-full bg-slate-50 border border-slate-300 p-3 rounded" autocomplete="off">
            <datalist id="tag-list">
                @foreach($allTags as $tag)
                    <option value="{{ $tag->name }}">
                @endforeach
            </datalist>
        </div>

        <div class="mb-5">
            <label class="block text-slate-700 font-bold mb-2 text-sm">画像</label>
            @if($post->image_path)
                <div class="mb-2">
                    <img src="{{ asset('storage/' . $post->image_path) }}" class="h-32 rounded object-cover">
                    <p class="text-xs text-slate-500 mt-1">※新しい画像をアップロードすると上書きされます</p>
                </div>
            @endif
            <input type="file" name="image" class="w-full text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" accept="image/*">
        </div>

        <div class="mb-8">
            <label class="block text-slate-700 font-bold mb-2 text-sm">内容</label>
            <textarea name="body" rows="6" class="w-full bg-slate-50 border border-slate-300 p-3 rounded">{{ $post->body }}</textarea>
        </div>

        <button type="submit" class="w-full bg-indigo-600 text-white font-bold py-3 rounded-lg hover:bg-indigo-700 transition duration-300">更新する</button>
    </form>
</div>
@endsection

起動

php artisan serve

Gitへの保存方法

git init
git add .
git commit -m "Initial commit with data"

別のPCで続きから使う場合

git clone [リポジトリURL]
cd mylogs
composer install
cp .env.example .env
# .envを編集して DB_CONNECTION=sqlite にする
php artisan key:generate
php artisan storage:link
php artisan serve

※ php artisan migrate は不要です(SQLiteファイルそのものがCloneされるため)。

以上がAIが出力してくれた内容です。

はたして、どんな動作をするのか楽しみです。

アプリ確認

描いた絵は恥ずかしいので、モザイクをかけました。許してくださいw

機能的には、要件に記載した事項を全て満たしたものが一発で完成しました。なんなら、記事を書くのに時間がかかっているだけで、所要時間は10分程度といったところです。

残りの要件はgitに保存して新たにcloneしても続きから閲覧可能かどうかのチェックです。やってみます。

cloneしてアプリ確認

gitに保存する際にAIくんの見落とし発見。.gitignore関連の処理で、「database.sqlite」や「投稿した画像ファイル」が保存されないという問題が発生していました。

とはいえ、Laravelのバージョンも伝えてないので、どこの.gitignoreファイルになんの記述があるかも教えてないので、バージョンによっては動作したかもしれないので、見落としと言えるかどうかも怪しいですねw

現状をAIくんにお伝えして、数回のやり取りを行ったところ、無事に解決してくれました。優秀!

さて。アプリのファイルたちをすべて削除して、githubから再構築してみます。

表示されたー!これで、自分が最初に求めていた全ての要件を満たしました。お疲れ様でした。

まとめ

AIやりおるぜ!

もっと色々と不備があって、何度もやり取りをするものかなと思っていました。結果は、「ソースコード」に関しては修正なし。「手順」に関しては、Laravelの.gitignoreファイルがどこにどのような記述になっているか完全には把握していない為、少し修正が必要になっただけでした。

また、問題が発生した時も、こちらで勝手なことはせずに、発生している症状だけ伝えて、AIが出してきた改善方法の提案のみで作業を行いました。

あっぱれです。

もっと要求しても大丈夫そうなので、近いうちに第2回をやってみたいと思います。