Yii2

Yii2アプリに”会話しながらコードを書いてくれる”AIエージェントを組み込む

この記事は約17分で読めます。

この記事で作るもの

管理画面に「AIアシスタント」タブを1つ追加し、そこにチャット欄を置くだけです。

[あなた] UserモデルにSNS連携用のprovider, provider_idカラムを追加するマイグレーションを書いて
[Claude] 以下のマイグレーションを作成しました。中間テーブルではなくusersテーブルへの
         直接追加案です。ユニーク制約は (provider, provider_id) の複合キーにしています…
```php
         class m260714_000000_add_sns_columns_to_users extends Migration { ... }
```
         この設計だと1ユーザーが複数SNS連携する場合に対応できません。もし複数連携を
         想定しているなら中間テーブル案も出せますが、どちらにしますか?
[あなた] 複数連携できるようにして
[Claude] では user_social_accounts テーブルを新設する案に切り替えます…

単発の「コード生成」ではなく、要件を詰めながら会話でコードを育てていくのがポイントです。Claudeはmax_tokensの許す限り会話履歴を保持するので、前の提案を踏まえた修正依頼にも自然に対応できます。

全体構成

要素役割
components/ClaudeAgent.phpClaude APIを叩くYii2コンポーネント
controllers/AiAssistantController.phpチャットのAPIエンドポイント
models/AiConversation.php会話履歴(セッション or DB保存)
views/ai-assistant/index.phpチャットUI(Bootstrap + Fetch API)
config/params.phpAPIキーなどの設定

Yii2のDI/Componentの仕組みは、LaravelでいうServiceクラスをコンテナに登録するのとほぼ同じ感覚で扱えます。

1. APIキーの設定

config/params.php に追記します(.env を使っている場合はそちら経由で読み込んでください)。

<?php
return [
    'claudeApiKey' => getenv('ANTHROPIC_API_KEY'),
    'claudeModel'  => 'claude-sonnet-5', // コーディング用途はSonnetでバランス良好
];

web.phpcomponents にも登録しておくとコントローラーから Yii::$app->claudeAgent で呼べて便利です。

'components' => [
    // ...既存のコンポーネント
    'claudeAgent' => [
        'class' => 'app\components\ClaudeAgent',
        'apiKey' => $params['claudeApiKey'],
        'model'  => $params['claudeModel'],
    ],
],

2. Claude APIを叩くコンポーネント

Yii2は標準で yii\httpclient\Clientyiisoft/yii2-httpclient)が使えるので、Guzzleを別途入れなくてもOKです。未導入なら composer require yiisoft/yii2-httpclient を実行してください。

<?php
// components/ClaudeAgent.php

namespace app\components;

use yii\base\Component;
use yii\httpclient\Client;
use yii\base\Exception;

class ClaudeAgent extends Component
{
    public string $apiKey;
    public string $model = 'claude-sonnet-5';
    public int $maxTokens = 2048;

    private const API_URL = 'https://api.anthropic.com/v1/messages';
    private const API_VERSION = '2023-06-01';

    /**
     * @param array $history [['role' => 'user'|'assistant', 'content' => string], ...]
     * @param string $systemPrompt Yii2固有の文脈を教えるシステムプロンプト
     * @return string アシスタントの返答テキスト
     */
    public function chat(array $history, string $systemPrompt): string
    {
        $client = new Client(['baseUrl' => '']);

        $response = $client->createRequest()
            ->setMethod('POST')
            ->setUrl(self::API_URL)
            ->setHeaders([
                'x-api-key' => $this->apiKey,
                'anthropic-version' => self::API_VERSION,
                'content-type' => 'application/json',
            ])
            ->setContent(json_encode([
                'model' => $this->model,
                'max_tokens' => $this->maxTokens,
                'system' => $systemPrompt,
                'messages' => $history,
            ]))
            ->send();

        if (!$response->isOk) {
            throw new Exception('Claude API error: ' . $response->content);
        }

        $data = $response->data;
        // content配列からtext種別を連結(tool_use等が混じる場合を考慮)
        $text = '';
        foreach ($data['content'] as $block) {
            if ($block['type'] === 'text') {
                $text .= $block['text'];
            }
        }
        return $text;
    }
}

ポイント

  • system パラメータにYii2固有のルール(後述)を書くことで、的外れなLaravel流の回答を防ぎます。
  • content を配列でループしているのは、将来ツール実行(tool_use)を組み込む拡張余地を残すためです。

3. 会話履歴を持つコントローラー

会話はDBに残すのが理想ですが、まずは最小構成としてセッション保存にします。

<?php
// controllers/AiAssistantController.php

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\filters\VerbFilter;

class AiAssistantController extends Controller
{
    public function behaviors()
    {
        return [
            'verbs' => [
                'class' => VerbFilter::class,
                'actions' => [
                    'send' => ['post'],
                    'reset' => ['post'],
                ],
            ],
        ];
    }

    public function actionIndex()
    {
        $history = Yii::$app->session->get('ai_chat_history', []);
        return $this->render('index', ['history' => $history]);
    }

    public function actionSend()
    {
        Yii::$app->response->format = Response::FORMAT_JSON;

        $message = Yii::$app->request->post('message', '');
        if (trim($message) === '') {
            return ['error' => 'メッセージが空です'];
        }

        $history = Yii::$app->session->get('ai_chat_history', []);
        $history[] = ['role' => 'user', 'content' => $message];

        $systemPrompt = <<<PROMPT
        あなたはYii2フレームワークの上級エンジニアです。
        - 回答はYii2の作法(ActiveRecord, Widget, Behavior, RBAC等)に従うこと
        - Laravel的な書き方(Eloquentのスコープ記法など)を混ぜないこと
        - コードは動くものを示し、設計判断が分かれる場合は選択肢を提示して確認を取ること
        - 日本語で簡潔に回答すること
        PROMPT;

        try {
            $reply = Yii::$app->claudeAgent->chat($history, $systemPrompt);
        } catch (\Throwable $e) {
            Yii::error($e->getMessage(), 'claude-agent');
            return ['error' => 'AIエージェントとの通信に失敗しました'];
        }

        $history[] = ['role' => 'assistant', 'content' => $reply];
        Yii::$app->session->set('ai_chat_history', $history);

        return ['reply' => $reply];
    }

    public function actionReset()
    {
        Yii::$app->session->remove('ai_chat_history');
        Yii::$app->response->format = Response::FORMAT_JSON;
        return ['status' => 'ok'];
    }
}

system プロンプトで「Yii2の作法に従う」「設計判断が割れる場合は確認を取る」と明示しているのが、単なるコード生成器と対話型エージェントを分ける最大のポイントです。

4. チャットUI(View)

Bootstrap 5(Yii2 basic templateに標準搭載)+ 素のFetch APIだけで組めます。Reactなどは不要です。

<?php
// views/ai-assistant/index.php
/** @var array $history */
use yii\helpers\Html;
use yii\helpers\Url;

$this->title = 'AIコーディングアシスタント';
?>
<div class="ai-assistant-index">
    <h1><?= Html::encode($this->title) ?></h1>

    <div id="chat-log" class="border rounded p-3 mb-3" style="height: 480px; overflow-y: auto; background:#fafafa;">
        <?php foreach ($history as $msg): ?>
            <div class="mb-3 <?= $msg['role'] === 'user' ? 'text-end' : '' ?>">
                <span class="badge <?= $msg['role'] === 'user' ? 'bg-primary' : 'bg-secondary' ?> mb-1">
                    <?= $msg['role'] === 'user' ? 'あなた' : 'Claude' ?>
                </span>
                <pre class="bg-white border rounded p-2" style="white-space: pre-wrap;"><?= Html::encode($msg['content']) ?></pre>
            </div>
        <?php endforeach; ?>
    </div>

    <form id="chat-form" class="d-flex gap-2">
        <input type="text" id="chat-input" class="form-control"
               placeholder="例: UserモデルにSNS連携カラムを追加したい" autocomplete="off">
        <button type="submit" class="btn btn-primary">送信</button>
        <button type="button" id="chat-reset" class="btn btn-outline-secondary">リセット</button>
    </form>
</div>

<?php
$sendUrl = Url::to(['ai-assistant/send']);
$resetUrl = Url::to(['ai-assistant/reset']);
$csrfParam = Yii::$app->request->csrfParam;
$csrfToken = Yii::$app->request->csrfToken;

$js = <<<JS
const chatLog = document.getElementById('chat-log');
const form = document.getElementById('chat-form');
const input = document.getElementById('chat-input');

function appendBubble(role, text) {
    const wrap = document.createElement('div');
    wrap.className = 'mb-3' + (role === 'user' ? ' text-end' : '');
    wrap.innerHTML = `
        <span class="badge ${role === 'user' ? 'bg-primary' : 'bg-secondary'} mb-1">
            ${role === 'user' ? 'あなた' : 'Claude'}
        </span>
        <pre class="bg-white border rounded p-2" style="white-space: pre-wrap;"></pre>
    `;
    wrap.querySelector('pre').textContent = text;
    chatLog.appendChild(wrap);
    chatLog.scrollTop = chatLog.scrollHeight;
}

form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const message = input.value.trim();
    if (!message) return;

    appendBubble('user', message);
    input.value = '';

    const res = await fetch('$sendUrl', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({ message: message, '$csrfParam': '$csrfToken' }),
    });
    const data = await res.json();
    appendBubble('assistant', data.reply || ('エラー: ' + data.error));
});

document.getElementById('chat-reset').addEventListener('click', async () => {
    await fetch('$resetUrl', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({ '$csrfParam': '$csrfToken' }),
    });
    chatLog.innerHTML = '';
});
JS;
$this->registerJs($js);
?>

5. 動作イメージ

SiteController のメニューか、AppAsset のナビバーに以下を追加すればアクセスできます。

['label' => 'AIアシスタント', 'url' => ['/ai-assistant/index']],

実際にブラウザで「Userモデルに論理削除を追加して」と送ると、Claudeは

  1. deleted_at カラム追加のマイグレーション
  2. SoftDeleteBehavior(Yii2公式の yii2tech/ar-softdelete or 自前Behavior)の実装案
  3. 既存クエリへの影響(デフォルトスコープの扱い)についての確認質問

という順で、コードと一緒に「次にどうしたいか」を聞き返してくる会話になります。これがChatGPT的な単発生成との一番の違いです。

コメント

タイトルとURLをコピーしました