
フロントエンド
イベント
マガジン
技術ブログ
はじめに はじめまして、村尾と申します。 2026年1月にセーフィーへ入社し、気付けばもう5ヶ月が経ちました。 フロントエンドエンジニアとして、日々プロダクト開発に携わっています。 セーフィーに来る前は、愛知県のスタートアップで7年間、フルスタックエンジニアをしていました。 当時から AI には触れていましたが、ここに来てから景色が大きく変わったので、今回は新入社員の等身大の目線で、その変化について書いてみようと思います。 「セーフィーで実際 AI はどう使われているのか」が気になっている方の、雰囲気を掴むヒントになれば幸いです。 まず印象的だったのは「AI 活用に必要なツールが
はじめに さくらのナレッジ編集部の法林です。 ある日、さくナレ編集部に読者からのお便りが届きました。そこには、「高齢者の家族が子どもや孫たちとビデオで会話する仕組みを、さくらインターネットのサーバを使って作りました。せっ […]
こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の7日目の記事です。 EGP Code は、ランディングページ(LP)を AI と作る社内向けのエディタです。作成背景については AI と作る HTML ベースの LP エディタ EGP Code を内製した理由 という記事で紹介しました。本記事では、その内部で動いている 4 つの仕組みを紹介します。 EGP Code が扱うのは、HTML と状態や動きを担う少数の <egp-*> (独自の Web Components)を混ぜた 1 枚のページです。 <section class="p-6 text-center"> <h1>春のキャンペーン</h1> <p>応募受付中</p> <egp-button>応募する</egp-button> </section> この HTML を AI エージェントとの対話やエディタで編集し、プレビューで確認して公開します。紹介する 4 つの仕組みは、(1) エージェントの再帰ループ、(2) Firestore を介したリアルタイム反映、(3) ブラウザだけで完結するテストランナー、(4) プレビューと HTML を結ぶ対応表です。 1 つの指示がユーザーから届いてプレビューに反映されるまでの流れと、それぞれの仕組みが効く場所は次のとおりです。 仕組み 1: 文脈とツールを束ねるエージェントの再帰ループ 「フォントサイズを 24px にして」「文字を太くして」のように特定の要素への簡単な指示なら、その要素を特定して CSS を更新したり文言を調整したりするだけなので、ほぼ 1 回の操作で終わります。一方で複数の要素にまとめて指示したり、API を使った画面やテストを作ったり、Lint・テストのエラーを直したりする場合は、推論とツール実行を何度か往復することになります。 エージェントは「推論 → ツール実行 → 結果を会話履歴に追加 → 再び推論」という再帰ループで動きます。ここでいうツールとは、AI が必要と判断したときに呼べる関数の定義と説明のことです。HTML を書き換える、ファイルを読む、Lint で検証する、テストを走らせる、といった操作をツールとして用意しておき、モデルがその中から必要なものを選んで呼び、戻り値を見て次の手を考えます。 1 回の入力に対して 4 つの情報をまとめて渡します。システムプロンプトで AI エージェントに役割やコード生成のルールといった前提を与え、それにユーザーの指示と選択要素と現在の HTML、これまでの会話履歴、そして使えるツールの一覧を教えます。このうち現在の HTML・仕様・テストといったページの状態は、種類ごとに XML タグで区切ってまとめます。 会話履歴の持ち方は、開発当初 OpenAI API 側に任せていました。しかし全社的に ZDR(Zero Data Retention) を適用することになり、プロバイダ側に会話を残せなくなったため、いまは履歴をすべて自前で記録し、毎回のリクエストに載せて送るステートレス方式にしています。履歴の肥大化を防ぐため、一定量を超えたら要約させてコンテキストを圧縮しています。 // 1 ラウンドの推論 const stream = await client.responses.stream({ ...args, input: sessionBuffer.getItems(), // 自前で管理している履歴全体を送る previous_response_id: undefined, store: false, // プロバイダ側に会話を保存させない }); ループの工夫をいくつか紹介します。 1 つ目は、ツールの失敗の扱いです。ここでいう失敗とは、 apply_patch の差分が当たらない、Lint がエラーを返す、テストが落ちるなど、ツールが期待どおりに完了しなかった状態を指します。こうした失敗ではループを止めず、エラーの内容をそのまま結果としてエージェントに返すことで自己修正させています。 2 つ目は、 find_skill ツールによる情報の出し分けです。社内 API の使い方などのドキュメントは、最初は ID と一行説明の一覧だけを見せておき、本文は必要になったタイミングで読み込みます。たとえば「商品一覧を表示したい」という指示が来ると、エージェントはまず一覧から関連しそうなものを探し、 find_skill でそのドキュメント本文を取得します。エージェントは取得したドキュメントを読み、正しい引数で API を呼びます。 この推論とツール実行の往復を繰り返し、最後にエージェントが apply_patch で HTML を差分更新すると 1 つの指示が編集として完成します。 ループの途中で方向を変える Real-time steering ここまでは、1 つの指示を最後まで処理してから次を受け取る前提でした。ですが実際には、処理の途中で「やっぱり色は青にして」と方針を変えたり、「ついでにフッターも直して」と指示を足したくなることがあります。完了を待たずに割り込みで指示を足し、走っているループに後から反映する仕組みを用意しています。こうした仕組みは Real-time steering とも呼ばれます。 ユーザーが処理中に指示を足したときの流れは、次のようになります。 エージェントが処理中(画面がローディング中)にユーザーがメッセージを送ると、クライアントはそれを通常の指示ではなく、割り込みメッセージとして送信します。サーバは受け取った割り込みメッセージを、 Firestore の通常の会話履歴とは別のサブコレクションに、いま走っているリクエストの ID を添えて書き込みます。エージェントは、自分が処理しているリクエストの ID に一致する割り込みだけを読み取ります。 // 自分のリクエスト宛の割り込みメッセージだけを読み、読んだら消す const consumeSteeringMessages = async (conversationId, requestId) => { const snapshot = await steeringRef .where('requestId', '==', requestId) // このリクエスト宛だけを対象にする .orderBy('timestamp', 'asc') .get(); const messages = snapshot.docs.map((doc) => doc.data()); await deleteDocs(snapshot.docs); // 読んだら消す return messages; }; ID で絞るので別のリクエスト宛の割り込みを拾うことはなく、読んだら消すので同じ指示が二重に効いたり取りこぼしたりすることもありません。処理のループに差し込むため、割り込みが来たときにエージェントが何をしようとしていたかで、対応が 2 つに分かれます。 1 つ目は、ツールを呼ぼうとしていた場合です。そのツールを実行せず、戻り値の代わりに「ツールは実行していません。処理中に新しい指示が届きました」という内容を返します。 // ツール実行の直前に割り込みを確認する const steering = await consumeSteeringMessages(conversationId, requestId); if (steering.length > 0 && toolCalls.length > 0) { for (const toolCall of toolCalls) { // ツールは実行せず、戻り値の代わりに割り込みを差し込む buffer.pushMessage({ role: 'tool', tool_call_id: toolCall.id, content: '[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...', }); } continue; // 計画を立て直すため、もう一度推論へ } 2 つ目は、ツールを呼ばずに、ユーザーへの返信メッセージを作り終えていた場合です。本来ならこれを見せてループが終わるところですが、割り込みが届いたので止めるべきツールがありません。この返信はまだ画面に出していないので破棄し、直前のユーザーの指示に割り込みメッセージを足して送り直すことで、続きの作業を依頼します。 // ツールがない場合は、画面に出していない返信を捨てて指示を足す if (steering.length > 0 && toolCalls.length === 0) { buffer.pop(); // まだ画面に出していない返信を捨てる const priorUserTurn = buffer.pop(); // 直前のユーザーの指示を取り出す buffer.pushMessage({ role: 'user', content: `${priorUserTurn.content}\\n[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...`, }); continue; } いずれの場合も、すでに実行した副作用を巻き戻すわけではなく、これから実行するはずだったツールを止めたり、まだ画面に出していない返信を捨てたりして、計画を組み直しています。 仕組み 2: Firestore を指示の受け渡し場所にしたリアルタイム反映 仕組み 1 で見たように、エージェントは複数のツールを往復させて指示に応えるため、編集には時間がかかることがあります。処理が終わるまで画面が何も更新されないと、利用者は反映されたかどうか分からないまま待つことになります。 そこで Firestore SDK を利用して、 変更をリアルタイムに受け取れる ようにしています。サーバ側は 1 つの操作が終わるたびにその内容を Firestore へ書き込み、ブラウザ側はそれをサブスクライブして即座に検知・反映します。これで自前で WebSocket を張らずに、編集の途中経過をそのままプレビューへ反映できます。 エージェントが何かを書き換えると、サーバは会話ごとのコレクションに、次のようなドキュメントを 1 件追加します。 { "status": "PENDING", "requests": [ { "action": "setHtmlSchema", "payload": "<body> ...更新後の HTML... </body>", "reason": "ユーザーの依頼を反映" } ] } ブラウザ側の JS は、Firestore の SDK( onSnapshot )でこのコレクションをサブスクライブしており、 PENDING のドキュメントが届くと requests の各操作をエディタの状態へ反映します。たとえば setHtmlSchema なら、エディタが表示している HTML を新しいものに置き換えて、プレビューを再描画します。 ブラウザが requests の操作を反映し終えると、その JS が status を COMPLETED に書き戻します。HTML 差し替えなどの「反映だけ」のアクションは、投げたら終わりで結果を待ちません。一方でテスト実行のように結果が必要なアクションでは、ドキュメントが COMPLETED になるまで一定間隔で読み直して、書き込まれた結果を取り出します。取り出した結果はツールの戻り値としてエージェントに返り、それを見て次のツール呼び出しを決めます。 仕組み 3: ブラウザだけで完結するテストランナー 応募ボタンを押したときの API 呼び出しやその結果に応じた表示の切り替え、リンクによる画面遷移といった LP の動作を手動で確認するのは手間がかかります。そこで EGP Code では、こうした動作をテストで確かめられるようにしています。 テストはブラウザ上のエディタで直接書いたり、AI に書かせたりできます。これらのテストは、サーバや CI ではなく、プレビューと同じブラウザの中で実行します。ただし Jest や Vitest は Node.js 上で動くツールなので、そのままブラウザには読み込めません。そこで test / it / describe / expect を提供する小さなランナーを自作しました。アサーションは単体で使える @vitest/expect 、DOM 操作は Testing Library をそのまま利用しています。 // 自作の test 関数でテストを定義する test('エントリーボタンで API が呼ばれる', async () => { // Testing Library でユーザーのクリックを再現する await userEvent.click(screen.getByText('エントリー')); // @vitest/expect で結果を検証する expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが社内 API を呼ぶこともありますが、本番に飛ばすわけにはいきません。そこで iframe 内で window.fetch を差し替え、リクエストはすべてモック関数に通します。モックしていない呼び出しはエラーになるので、本番へ漏れることはありません。 テストの実行は、この iframe を作るエディタのページ(ホスト)と iframe の postMessage のやり取りで進みます。 ホストは iframe を作ってテスト用 HTML を流し込み、iframe 側の初期化( window.fetch の差し替えなど)が済むのを待ってから実行を指示します。先に指示が届くと取りこぼすため、必ず「準備完了」を待つようにします。結果は仕組み 2 のアクションでエージェントへ戻ります。失敗していれば、仕組み 1 で触れた自己修正がここで働き、内容を読んで実装を直してもう一度走らせます。 仕組み 4: プレビューの要素と HTML の位置を結ぶ対応表 ここまではエージェント主導の編集を見てきましたが、人が直接手を入れる場面もあります。Code タブを開くと Monaco エディタで HTML を直接編集でき、プレビューでリアルタイムに確認できます。プレビューの要素をクリックしてエージェントへ指示を出したり、Monaco の対応行へジャンプするには、「プレビューの要素と HTML 上の位置」を対応づける仕組みが必要です。 HTML をパースして各要素へ data-egp-src-id (公開ページには残らない内部 id)を注入してプレビューを描画することで、これを実現しています。 <section data-egp-src-id="src-10"> <h1 data-egp-src-id="src-11">春のキャンペーン</h1> <egp-button data-egp-src-id="src-42">応募する</egp-button> </section> プレビュー上で「応募する」ボタンをクリックすると src-42 が取れます。クリック先が Web Components の内部要素などで id を持たない場合もあるため、 closest で祖先方向に遡って最寄りの data-egp-src-id を探します。 取得した id はエディタが各要素に振った内部 id で、対応表を引くキーになります。対応表はパース時に作る「id → その要素の HTML 上の位置」の Map で、id を引くと文字列オフセットやタグ名が取れます。 // 対応表(id → HTML 上の位置とタグ) Map { 'src-10' => { range: { startOffset: 0, endOffset: 130 }, tagName: 'section' }, 'src-42' => { range: { startOffset: 64, endOffset: 110 }, tagName: 'egp-button' }, // ... } クリックされるのは描画後の DOM 要素で位置を持つのはこの対応表です。要素に振った data-egp-src-id をキーにすることで、両者を突き合わせています。この対応表には 2 つの用途があります。1 つ目が Monaco エディタへのジャンプです。 // 対応表から位置を取得 const range = await mappingApi.findRangeById('src-42'); // オフセットを Monaco の行・列に変換 const pos = model.getPositionAt(range.startOffset); // その行をエディタの中央に表示してカーソルを移動 editor.revealPositionInCenter(pos); 取得した id を元に対応表から文字列オフセットを取り出して Monaco の行・列に変換します。その行をエディタ中央にスクロールしてカーソルを移動することでジャンプさせています。 もう 1 つが AI への指示です。 data-egp-src-id が付いているのはプレビュー用に描画した HTML だけで、エージェントが読み書きする公開用の HTML には付いていないため、id をそのまま渡しても対応する要素は見つかりません。そこで id は対応表を引くキーとしてのみ使い、エージェントには、そこから取り出した要素の HTML・タグ名・行番号をユーザーの指示文と一緒に XML へまとめて渡します。 <annotated_elements> <annotation> <tagName>egp-button</tagName> <snippet><egp-button class="..."></snippet> <lineNumber>10</lineNumber> <instruction>色を赤にして</instruction> </annotation> </annotated_elements> エージェントはこれらを手がかりに HTML の中から対象要素を見つけて指示に対応します。このように指示を XML タグで構造化する書き方は、 Anthropic のプロンプトのベストプラクティスの一つ としても紹介されています。 おわりに 一見シンプルに見える AI エディタですが、実際に体験を作ろうとすると、待ち時間が長い、途中経過が見えない、どこを編集したか分からないといった小さなつまずきが積み重なります。EGP Code では、そのつまずきを 1 つずつ潰すために、ここまで紹介した仕組みを裏側で積み上げてきました。AI を組み込むときほど、モデルの手前にある体験と安全性を設計することが重要だと感じています。本記事が、AI を組み込んだプロダクトづくりの参考になれば幸いです。 次の記事は@mikupoさんです。引き続きお楽しみください。




















