2026年10月8日に、Next.jsの環境変数、MDNのAbortController、ReactのuseEffect、OWASPのPrompt Injectionを再読しました。公開環境変数はクライアントへ組み込まれ得るため秘密キーを置かず、AbortControllerの中止を外部サービスの処理や課金の取り消しと同一視しません。cleanupは古い応答を採用しない境界に使い、プロンプトインジェクションは取得データやツールの権限まで含む多層対策で扱います。
WebサイトへAIを組み込むとき、最初に決めたいのはモデル名よりもブラウザとサーバーの責任分担です。利用者の入力、秘密キー、権限、遅れて届く応答を同じ場所で扱うと、小さな試作でも問題が起きます。
本記事では、チャット、サーバーAPI、通信アダプター、検索候補、個別表示、助手UIの六つを整理します。コードは接続点を明示した教材です。ローカル関数と静的表示の検査を、実モデル・実認証・実ブラウザの確認として数えません。
30秒で要点
- 外部AIの秘密キーは、デモを含めブラウザへ渡しません。サーバー側で認証と利用上限を確認します。
- 古い検索や会話の応答が現在の画面を上書きしないよう、要求の世代とキャンセルを分けて扱います。
- 効果は送信時間だけでなく、確認・修正・誤案内・費用を含めて比較します。AIを置いただけでUXが良くなるとは限りません。
どこにAIを置くかを先に決める
表が収まらない場合は、横にスクロールできます
| 使い方 | AIへ渡す候補 | 先に決める境界 |
|---|---|---|
| チャット | 利用者の質問と許可した文脈 | 入力上限、保存範囲、保留時の案内 |
| 意味検索 | 検索語とアクセス可能な文書 | 認可を検索前に適用、根拠不足を表示 |
| 個別表示 | 必要最小の許可済み情報 | サーバーの本人確認、同意、初回表示 |
| 作業助手 | 操作の候補や下書き | 実行権限、確認者、取り消し手段 |
定型の絞り込みや決定表で十分なら、生成を使わない方が判断を説明しやすいこともあります。モデル選びの前に、成功と失敗をどう確かめるかを決めてください。
ブラウザに秘密キーを置かない
Next.jsでは、NEXT_PUBLIC_付きの環境変数はブラウザ向けJavaScriptへ値が組み込まれます。ビルド後は値が固定されることにも注意が必要です。[S6] 外部プロバイダーの秘密をこの名前で渡す例は、PoCでも使いません。非公開の環境変数名にするだけで安全な実装が完成するわけではなく、ログや応答への漏出も避けます。
表が収まらない場合は、横にスクロールできます
| 境界 | 強制する条件 | その条件だけでは分からないこと |
|---|---|---|
| ブラウザ | 空入力、二重送信、表示中の要求 | 利用者が本当に権限を持つか |
| サーバー | 認証、認可、入力上限、利用上限 | モデルの回答が正しいか |
| 外部アダプター | 固定接続先、期限、応答の型 | 外部サービス内の処理完了や取消 |
| 表示・確認 | 文字列として表示、古い結果を無視 | 根拠の妥当性、業務上の承認 |
六つの教材で接続点を分ける
各枠は別ファイルとして扱うTypeScript/React教材です。UIのsend等は呼び出し側から渡します。実SDKのモデル名や価格を固定した「最新版」の例にはしていません。実際に外部APIへ接続するときはAPI実装ガイドと利用版の公式仕様を確認してください。
1. チャットUI:要求の世代を区別する
makeLatestは、古い要求が終わっても現在の要求の結果にしないための小さな部品です。画面内には直近10発言を保持しますが、外部へ渡すのは利用者の一つの質問だけです。履歴をモデルの文脈として送信する処理ではありません。
import React, { useEffect, useRef, useState } from "react";
export type Send = (text: string, signal: AbortSignal) => Promise<string>;
export function makeLatest() {
let revision = 0;
return { next: () => ++revision, current: (id: number) => id === revision };
}
export function AiChat({ send }: { send: Send }) {
const [text, setText] = useState("");
const [answer, setAnswer] = useState("");
const [messages, setMessages] = useState<{ role: "user" | "assistant"; text: string }[]>([]);
const [pending, setPending] = useState(false);
const latest = useRef(makeLatest());
const controller = useRef<AbortController | null>(null);
useEffect(() => () => { latest.current.next(); controller.current?.abort(); }, []);
async function submit(event: React.FormEvent) {
event.preventDefault();
const question = text.trim();
if (!question || question.length > 200 || pending) return;
const id = latest.current.next();
controller.current?.abort();
const next = new AbortController(); controller.current = next;
setPending(true); setAnswer(""); setText("");
setMessages(xs => [...xs, { role: "user" as const, text: question }].slice(-10));
try {
const value = await send(question, next.signal);
if (latest.current.current(id)) {
setMessages(xs => [...xs, { role: "assistant" as const, text: value }].slice(-10));
setAnswer("候補を受信しました");
}
} catch {
if (latest.current.current(id) && !next.signal.aborted) setAnswer("取得できませんでした");
} finally {
if (latest.current.current(id)) setPending(false);
}
}
return <form onSubmit={submit}>
<label>質問<textarea value={text} maxLength={200}
onChange={e => setText(e.target.value)} /></label>
<button type="submit" disabled={pending || !text.trim()}>送信</button>
<ol aria-label="この画面の会話">{messages.map((m, i) =>
<li key={i}>{m.role === "user" ? "質問" : "候補"}: {m.text}</li>)}</ol>
<p role="status">{pending ? "確認中" : answer}</p>
</form>;
}
入力欄をtextareaにし、送信ボタンで送る例です。日本語の変換確定を独自のEnterハンドラーで送信と取り違えません。ただし、実端末のIMEやキーボード操作は別に確認が必要です。ReactのEffectによる取得では、応答の順序が要求の順序と異なる競合に注意します。[S8]
2. サーバーAPI:利用者と上限をサーバーで確定する
以下はWeb標準のRequest/Responseを使う判断関数です。Next.js等のルートから呼び出せますが、フレームワークへの実配備はここでは行っていません。sessionは検証済みセッションを返す接続点、takeQuotaは同時要求でも原子的に利用枠を消費する接続点です。クライアントが申告した利用者IDを信用する関数にはしません。
type Dependencies = {
session: (r: Request) => Promise<{ id: string } | null>;
takeQuota: (id: string) => Promise<boolean>;
model: (q: string, signal: AbortSignal) => Promise<unknown>;
};
export async function handleAi(r: Request, d: Dependencies): Promise<Response> {
const reply = (status: number, data: object) => Response.json(data, { status });
if (r.method !== "POST") return reply(405, { error: "method" });
const user = await d.session(r);
if (!user) return reply(401, { error: "authentication" });
let data: unknown;
try { data = await r.json(); } catch { return reply(400, { error: "json" }); }
if (!data || typeof data !== "object" || Array.isArray(data)
|| Object.keys(data).join(",") !== "question") return reply(400, { error: "shape" });
const q = (data as { question: unknown }).question;
if (typeof q !== "string" || !q.trim() || q.length > 200) return reply(400, { error: "input" });
if (!(await d.takeQuota(user.id))) return reply(429, { error: "quota" });
try {
const answer = await d.model(q.trim(), AbortSignal.timeout(8000));
if (typeof answer !== "string" || !answer.trim() || answer.length > 2000)
return reply(502, { error: "output" });
return reply(200, { answer });
} catch { return reply(502, { error: "provider" }); }
}
8秒は教材の仮の期限です。アダプターがSignalへ対応すること、接続前段の総ボディサイズ制限、CSRF対策、セッション期限、権限、過剰利用の監視は別に必要です。r.json()後の文字数検査だけでは巨大な要求の受信を防げません。認証や利用枠の接続障害も、配備時には境界で安全なエラーへ変換します。
3. 通信アダプター:HTTP成功と回答の形を別に確かめる
export class AIService {
constructor(private transport: typeof fetch = fetch) {}
async generateText(question: string, signal: AbortSignal): Promise<string> {
const r = await this.transport("/api/ai/process", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }), signal, credentials: "same-origin"
});
if (!r.ok) throw new Error("request_failed");
const data: unknown = await r.json();
if (!data || typeof data !== "object" || !("answer" in data)
|| typeof data.answer !== "string" || !data.answer.trim()
|| data.answer.length > 2000) throw new Error("invalid_answer");
return data.answer;
}
}
このクライアントは固定した同一サイトのAPIだけを呼びます。任意の外部URLや秘密キーをコンストラクターへ渡しません。ローカルテストではtransportを固定応答へ交換し、HTTP失敗と型不正を確かめます。モデルが返す文章の事実性までは検査していません。
4. 検索候補:古い応答を捨て、選択をボタンにする
import React, { useEffect, useState } from "react";
type Suggest = (q: string, s: AbortSignal) => Promise<string[]>;
export function AISearch({ suggest, onChoose }: {
suggest: Suggest; onChoose: (value: string) => void;
}) {
const [q, setQ] = useState(""); const [items, setItems] = useState<string[]>([]);
const [error, setError] = useState("");
useEffect(() => {
let current = true; const c = new AbortController();
setItems([]); setError("");
if (q.trim().length < 2) return () => { current = false; c.abort(); };
const timer = setTimeout(() => {
suggest(q.trim(), c.signal).then(values => {
if (current) setItems([...new Set(values)].slice(0, 5));
}).catch(() => { if (current && !c.signal.aborted) setError("候補を取得できませんでした"); });
}, 300);
return () => { current = false; clearTimeout(timer); c.abort(); };
}, [q, suggest]);
return <section><label>検索<input value={q} onChange={e => setQ(e.target.value)} /></label>
<p role="status">{error}</p><ul>{items.map(x => <li key={x}>
<button type="button" onClick={() => onChoose(x)}>{x}</button>
</li>)}</ul></section>;
}
300ミリ秒の待ち時間、2文字、5候補は説明用です。全サイトの最適値ではありません。suggestの参照を親で安定させ、API結果の配列・文字数検査をその接続点で行います。ここは独立したボタン一覧で、コンボボックスの矢印キー操作を実装したとの主張はしません。
5. 個別表示:本人確認と画面の切替を混ぜない
import React, { useEffect, useState } from "react";
export function PersonalizedContent({ sessionVersion, load }: {
sessionVersion: string; load: (s: AbortSignal) => Promise<string[]>;
}) {
const [items, setItems] = useState<string[]>([]); const [status, setStatus] = useState("確認中");
useEffect(() => {
let current = true; const c = new AbortController();
setItems([]); setStatus("確認中");
load(c.signal).then(rows => {
if (current) { setItems([...new Set(rows)].slice(0, 5)); setStatus(rows.length ? "候補" : "候補はありません"); }
}).catch(() => { if (current && !c.signal.aborted) setStatus("一般の一覧をご利用ください"); });
return () => { current = false; c.abort(); };
}, [sessionVersion, load]);
return <section><p role="status">{status}</p><ul>{items.map(x => <li key={x}>{x}</li>)}</ul></section>;
}
sessionVersionは古い表示を破棄するための印で、認証情報ではありません。loadが接続するサーバーは、セッションから本人と権限を確定します。行動履歴の取得を当然とせず、必要な情報、同意、保存期間、一般表示への戻し方を決めます。
6. 助手UI:開閉できても業務を自動承認しない
import React, { useState } from "react";
export function AIAssistant({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return <aside>
<button type="button" aria-expanded={open} aria-controls="ai-assistant-panel"
onClick={() => setOpen(x => !x)}>作業助手</button>
<section id="ai-assistant-panel" hidden={!open} aria-hidden={!open}>
<p>候補を確認してから使ってください。契約や返金は担当者が判断します。</p>
{open ? children : null}
</section>
</aside>;
}
この例は一画面に一つ置く前提です。複数置く場合はIDを一意にします。閉じると子のチャットを取り外すため、履歴を残す必要があれば保存場所と期限を別に設計します。開閉やフォーカス移動の実端末確認は、静的HTMLの検査とは別です。
キャッシュ、注入対策、費用の条件
キャッシュの可否は「同じ質問なら必ず同じ回答が出るか」だけでは決まりません。ある期間、同じ回答を再利用してよい業務なのかが先です。
表が収まらない場合は、横にスクロールできます
| 条件 | 例 | 間違えると起こること |
|---|---|---|
| 権限と利用者範囲 | 同じ部署の公開済みFAQだけ | 他利用者の情報を表示 |
| 版 | モデル、プロンプト、参照資料の版 | 古い条件で回答を再利用 |
| 有効期間 | 明示したTTLと無効化 | 更新済みの手順を見落とす |
| 保存内容 | 必要最小の識別子と回答 | 秘密や個人情報の不要な蓄積 |
プロンプトの指示やサニタイズは、権限の代わりになりません。取得したWebページや資料も信頼できない入力として扱い、出力の形式検査とツールの許可を別に実装します。[S9] AbortControllerはfetch等の中止に使えますが、外部AIの処理や費用が取り消された証明にはなりません。[S7]
効果を短期の印象だけで決めない
「最初は必ず評判が良い」「三つ目標を置けばほぼ失敗する」といった一般則は置きません。対象業務に合う観測期間、主要な判断指標、許容できない誤りを先に決めます。
架空の20件で、従来の確認込み作業が100分、新しい手順が生成待ち10分+確認50分+修正20分なら合計80分、差は20分です。(100−80)÷100=20%という計算だけでは、誤案内が増えていないかは分かりません。入力条件と評価例をそろえ、時間と品質を別に報告します。失敗予防では、継続・修正・中止の基準も確認してください。
参考資料と検証の境界
- S6 Next.js環境変数:ブラウザへ公開される値とビルド時の扱い。
- S7 MDN AbortController:Signalを通じた中止の範囲。
- S8 React useEffect:取得処理のcleanupと応答順序の競合。
- S9 OWASP Prompt Injection:入力・取得資料・権限・出力検証の境界。
確認日2026年10月6日。掲載例はローカルの固定応答と静的レンダリングを対象に検査します。実サーバーの認証、外部モデル、ハイドレーション、IME、スクリーンリーダー、性能・費用の実測は別途必要です。