Lchiki-Lab
arrow_backBack to Archive

いじスタに「AIに聞く」ボタンを導入した話(?q=プリフィルとllms.txtの運用設計)

calendar_today2026.10.23folderTechnology
Cookie Cat

ツールにAIチャットを自作すると、サーバー代もAPI費用もかかっちゃうよね。
そこで、普段使ってるAIに案内を任せる「AIに聞く」ボタンを入れてみたよ!
作り方と、llms.txtを古くさせない仕組みまでまとめるね!

WebサービスやツールのLPで、普段使っているAIにツールのことを相談できるボタンを見かけることが増えてきました。

サイトの仕様や説明をテキストファイル(llms.txt)として公開しておき、ボタンを押すとURLと質問文が入った状態でChatGPTやClaudeが開く仕組みです。 素直に考えると自前でAIチャットを組みたくなりますが、既存のAIに案内を任せてしまえば、サーバー代やAPI費用は一切かかりません。

運営しているLINEスタンプ作成ツールいじスタでもこのボタンを導入しました。 リンクを置くだけなら手軽ですが、実際に運用してみると各AIごとの仕様の違いやPC・スマホでの挙動の調整など、考えるべきポイントがいくつもありました。さらに、llms.txtをコードと食い違わせない仕組みも必要でした。

この記事では、実装手順やUIの工夫、CIでドキュメントの更新忘れを防ぐ仕組みについてまとめます。

「AIに聞く」ボタン導入の全体像と設計思想

▲ 「AIに聞く」ボタン導入の全体像と設計思想


1. 導入の​背景

自前チャットボットと、既存のAIに任せる構成の違い

▲ 自前チャットボットと、既存のAIに任せる構成の違い

いじスタで​抱えていた​課題

いじスタは、背景透過やAPNGプレビュー、一括リサイズなど機能が全部で22個あります。機能が増えるにつれて、いくつかの悩みが出てきました。

まず、1つのページに全部の機能を載せると情報が多すぎて、必要な機能を探してもらうのが難しくなります。また、手描きイラストの人、画像生成AIで絵を作る人、動くスタンプを作りたい人ではおすすめしたい機能も違います。さらに最近は、マニュアルを読む前にChatGPTなどへ質問する人も増えてきました。

そこでツールの仕様をまとめた llms.txt を用意し、使い慣れたAIに案内を任せられれば、サーバー代をかけずに読者それぞれへ合わせた案内ができると考えました。

llms.txtとは

Webサイトやツールの仕様、機能一覧をプレーンテキストで簡潔にまとめたファイルです。ルート直下(/llms.txt)に配置しておくことで、LLMや検索AIがサービスの内容を正確に読み取るための案内板として機能します。


2. 仕組みと​URL設計​(?q= プリフィル)

各AIのURLに、エンコードした質問文を ?q=質問文 の形で付けることで、入力欄にテキストが入った状態で新規チャットを開けます。

実際のボタンは、PCでマウスを乗せるとこのように一覧が開きます。

いじスタのトップページで「AIに聞く」ボタンを開いた様子(PC)

▲ いじスタのトップページ(PC)。ボタンに触れると、各AIの一覧が開く

「?q=」プリフィルリンクのURL設計とサービス別挙動

▲ 「?q=」プリフィルリンクのURL設計とサービス別挙動

各AIを​開く​URL

サービスURL備考
ChatGPThttps://chatgpt.com/?q=llms.txtを安定して読み込んで要約します
Google AIモードhttps://www.google.com/search?udm=50&q=udm=50 パラメータでAIモードを開きます
Claudehttps://claude.ai/new?q=新規チャット画面で質問が入ります
Perplexityhttps://www.perplexity.ai/search?q=Web検索と合わせてURLを参照します
Geminihttps://gemini.google.com/app?q= に非対応のため、クリップボード経由で対応します

URLの形式確認には、先行事例として公開されているリンクジェネレータを参考にしました。


3. 実装内容

3-1. URLを​作る​関数​(ask-ai.ts)

サービスの一覧とURLを作る関数を1つのファイルにまとめました。

// src/lib/ask-ai.ts
export const LLMS_TXT_URL = "https://example.com/llms.txt";

export type AskAiService = "chatgpt" | "gemini" | "claude" | "perplexity" | "google_ai_mode";

export const ASK_AI_SERVICES: {
  id: AskAiService;
  label: string;
  base: string;
  icon: string;
  copyPrompt?: true;   // ?q= に非対応で、コピーしてから開くもの
  recommended?: true;  // おすすめラベルを付ける
}[] = [
  { id: "chatgpt", label: "ChatGPT", base: "https://chatgpt.com/?q=", icon: "/images/ai/chatgpt.svg", recommended: true },
  { id: "google_ai_mode", label: "Google AIモード", base: "https://www.google.com/search?udm=50&q=", icon: "/images/ai/google.svg", recommended: true },
  { id: "claude", label: "Claude", base: "https://claude.ai/new?q=", icon: "/images/ai/claude.svg" },
  { id: "gemini", label: "Gemini", base: "https://gemini.google.com/app", icon: "/images/ai/gemini.svg", copyPrompt: true },
  { id: "perplexity", label: "Perplexity", base: "https://www.perplexity.ai/search?q=", icon: "/images/ai/perplexity.svg" },
];

export const askAiUrl = (s: (typeof ASK_AI_SERVICES)[number]): string =>
  s.copyPrompt ? s.base : s.base + encodeURIComponent(PROMPT);

3-2. 質問文の​組み立て方

最初は「まず私の用途について質問してから答えてください」というプロンプトにしていました。ただ、これだとボタンを押した後の最初の返事が質問だけになってしまい、何も教えてもらえないまま返信を求められる流れになってしまいます。

そこで、順番を入れ替えました。

const PROMPT = `次のファイルを読んでください。
${LLMS_TXT_URL}

LINEスタンプ用の画像を作る無料ツール「〇〇」の説明です。
最初の返事で、次の2つを紹介してください。
1. 〇〇ならではの強み(ほかのツールとの違い)
2. できること
最後に、私に合った使い方を提案するために、作りたいスタンプの種類と絵の用意の仕方(手描き・AI生成など)を1つずつ質問してください。
ファイルを読めなかったときは、推測で答えずにそう伝えてください。`;

最初の返信でツールの強みとできることを紹介してもらい、そのうえで作りたいスタンプの種類を1つずつ質問してもらうようにしました。また、ファイルを読めなかったときは推測で答えない指示を残し、URLを無視した回答を防いでいます。

押した直後にツールの概要が分かり、そのあと自然に個別相談へ進めるようにしています。

URLの長さを2,000文字以内に抑える

日本語は encodeURIComponent でおよそ9倍の長さに膨らみます。長すぎるとURLパラメータが途中で切り捨てられる可能性があるため、文章を削りながら調整し、エンコード後で約1,770文字に収めました。

# 長さの確認ワンライナー
node -e 'console.log(encodeURIComponent(PROMPT).length)'

3-3. Geminiは​コピーしてから​開く

GeminiはURLパラメータでのプロンプト自動入力に対応していません。そこでGeminiだけは、押したタイミングで質問文をクリップボードにコピーしてから開き、貼り付けて送信してもらう形にしました。

/** copyPrompt 対象用。失敗しても画面遷移自体は止めない */
export const copyAskAiPrompt = (): void => {
  navigator.clipboard?.writeText(PROMPT).catch(() => {});
};
<a
  href={askAiUrl(s)}
  target="_blank"
  rel="noopener noreferrer"
  onClick={() => {
    if (s.copyPrompt) copyAskAiPrompt();
    close();
  }}
>

ブラウザの制限により、クリップボードへの書き込みはクリックなどの直接の操作内で行う必要があります。また、書き込みに失敗した場合でもGeminiのページが開くよう、エラーは安全にキャッチしています。


4. UIの​調整と​操作感の​工夫

画面の邪魔にならず、必要なときにスムーズに使えるよう、細部の操作感にも調整を入れました。

自然な操作感を実現するフロントエンドの3つの工夫

▲ 自然な操作感を実現するフロントエンドの3つの工夫

4-1. PCは​ホバー、​スマホは​タップ​(pointerType で​判定)

PCではマウスを乗せるだけで開き、スマホではタップで開く動きにする際、mouseenter をそのまま使うとスマホでのタップ時にも疑似ホバーが発生してしまいます。

スマホで「AIに聞く」ボタンをタップして一覧を開いた様子

▲ スマホではタップで開き、開いている間はボタンが×になる

これを避けるため、PointerEvent の pointerType === "mouse" でマウス操作だけを判定するようにしました。

<div
  ref={rootRef}
  onPointerEnter={(e) => {
    if (e.pointerType !== "mouse") return; // タッチやペンは無視
    clearTimeout(closeTimer.current);
    show();
  }}
  onPointerLeave={(e) => {
    if (e.pointerType !== "mouse") return;
    // ボタンから吹き出しへカーソルを移す途中で閉じないよう、少し待つ
    closeTimer.current = setTimeout(() => close(), 300);
  }}
>
  {/* 吹き出し と ボタン */}
</div>

親要素でenterとleaveを監視し、300msの遅延を入れることで、ボタンから吹き出しへカーソルを動かす途中で閉じてしまうのを防いでいます。

4-2. ボタンの​アイコンと​動きを​一致させる

「乗せて開く」と「押して開閉」を両立させようとした際、挙動を何回か見直しました。

最初に乗せて開いたあとでボタンを押すと、閉じてしまう動きになっていました。そこで開いたあとは押しても開いたままにしたところ、今度は閉じるアイコンを押しているのに閉じないという不自然さが出ました。

最終的に、開いている間はボタンを×アイコンにし、押せば確実に閉じる形に落ち着きました。頭で考えた理屈よりも、見えているアイコンに合わせて素直に動くほうが扱いやすいと感じました。

<button
  type="button"
  onClick={() => {
    if (open) close();
    else show();
  }}
  aria-expanded={open}
  aria-label={open ? "AIに聞くを閉じる" : "AIに聞く"}
>
  {open ? <X size={24} /> : <MessageCircleQuestion size={26} />}
</button>

4-3. アプリ画面で​下部の​バーと​重ならないように​する

実際の作業画面(/app)では、画像を選ぶと画面下に一括処理バーが表示されます。ボタンがこのバーと被らないよう、バーの高さを測って持ち上げる配置にしました。

画像を選択して一括処理バーが出た作業画面(スマホ)

▲ 画像を選ぶと下にバーが出て、ボタンはその上に持ち上がる

<div
  style={{ "--ask-ai-lift": `${fabHeight}px` } as CSSProperties}
  className={`fixed z-30 size-0 right-[calc(max(0px,50%_-_40rem)_+_1rem)] ${
    selectedCount > 0
      ? "bottom-[calc(1rem+env(safe-area-inset-bottom)+var(--ask-ai-lift))] lg:bottom-[calc(2rem+var(--ask-ai-lift))]"
      : "bottom-[env(safe-area-inset-bottom)]"
  }`}
>
  <AskAiLauncher location="app" contained />
</div>

高さについては、ResizeObserver で測ったバーの高さをCSS変数で渡し、iPhoneのホームバーの余白(env(safe-area-inset-bottom))とまとめて計算しています。 また横の位置は、画面幅が広い場合でもボタンが右端へ離れすぎないよう、中央寄せのコンテンツ幅に合わせています。

4-4. アニメーションの​配慮

LPではボタンの存在が伝わるよう4秒おきに跳ねる演出を入れていますが、作業画面では邪魔になるため最初から動かしていません。また、OSの視差効果を減らす設定にしている人には、useReducedMotion() でアニメーションを停止しています。


5. llms.txtの​更新忘れを​止める​仕組み

AIが答える内容は llms.txt の中身に左右されます。ただ、機能を追加したり変更したりしたときに、人の注意だけに頼って更新しているといつか必ず忘れます。

人の注意に頼らず「llms.txt」の整合性を死守する仕組み

▲ 人の注意に頼らず「llms.txt」の整合性を死守する仕組み

5-1. llms.txtを​書く​ときの​方​針

機能については、書いていないものはAIにとって存在しないのと同じなので、22個の機能をすべて洗い出しました。使い方の案内も公式ページに合わせて、例えば背景透過は自動設定を一番におすすめするよう書いています。

一方で不要な情報は削りました。聞かれてもいないのにライセンス形態を語り出したためライセンスの記述は消し、個人情報やSNSの連絡先も載せずに専用フォームへ誘導するようにしました。

5-2. スクリプトで​自動確認する

コードと llms.txt の内容が食い違わないよう、チェック用のスクリプトを作成し、コミット時とCIの両方で回しています。

コードの​数値と​テキストを​比べる

TypeScriptをビルドせず、正規表現でコードの定数を直接読み取って比較します。軽量で、コミット時にすぐ終わります。

// scripts/check-llms-txt.mjs
function constOf(file, name) {
  const m = read(file).match(new RegExp(`${name}\\s*=\\s*(\\d+)`));
  if (!m) throw new Error(`${file} に ${name} が見つかりません`);
  return Number(m[1]);
}

function expectAll(label, pattern, expected, source) {
  const hits = [...llms.matchAll(pattern)].map((m) => m[1]);
  if (hits.length === 0) {
    errors.push(`${label}: llms.txt に記述が見つかりません`);
    return;
  }
  for (const h of hits) {
    if (h !== String(expected)) {
      errors.push(`${label}: llms.txt は「${h}」、コードは ${expected}(${source})`);
    }
  }
}

// 枚数制限や動画の秒数を自動で確認
expectAll('読み込める枚数', /最大(\d+)枚/g, constOf('src/hooks/useGallery.ts', 'MAX_IMAGES'), 'MAX_IMAGES');
expectAll('動画の長さ', /(\d+)秒以内の動画/g, constOf('src/lib/video-frames.ts', 'MAX_SOURCE_DURATION_SEC'), 'video-frames');

コード側の定数名が変わって読めなくなったときは、エラーを出して停止するようにしています。

このチェックを入れたことで、一括処理にはまだ実装していなかった自動背景透過を、うっかり書こうとしていたミスを事前に防げました。

機能説明を​変更した​際の​更新忘れを​止める

const WATCHED = [
  'src/app/how-to-use/steps.ts',
  'src/components/landing/HeroSection.tsx',
  'src/components/landing/FeaturesSection.tsx',
];

if (process.argv.includes('--staged') && process.env.SKIP_LLMS_TXT !== '1') {
  const staged = execSync('git diff --cached --name-only', { encoding: 'utf-8' }).split('\n').filter(Boolean);
  const touched = WATCHED.filter((f) => staged.includes(f));
  if (touched.length > 0 && !staged.includes('public/llms.txt')) {
    errors.push('機能説明の元になるファイルを変更しましたが、public/llms.txt がコミットに含まれていません');
  }
}

あえて SKIP_LLMS_TXT=1 という逃げ道を用意し、急ぎの修正などでチェック全体を飛ばされてしまわないようにしています。

自動化のポイント

コミット時(husky)だけでなくGitHub ActionsのCIでも同じスクリプトを回しています。ローカルで --no-verify を使ってコミットされた場合や、GitHub上で直接編集された場合でも、CI側で確実に不整合を検知できます。


6. まとめ

自前でチャットボットを作らず既存のAIに任せることで、サーバー代やAPI費用をかけずに使い慣れたAIで案内を受けられる環境が整いました。

実際に運用してみると、単にリンクを置くだけではなく、最初にツールの良さを伝えてから相談に進めるプロンプトの工夫や、PCとスマホでの挙動の違いへの配慮が大切だと実感しました。

また、ボタンを置いた時点でllms.txtは古くなると困るファイルになります。人の注意に頼らず、コードと自動で比較するスクリプトを用意しておくことで、安心して機能を更新し続けられます。

機能が多くて案内に困っているツールや個人開発サービスがあれば、ぜひ試してみてください。

Cookie Cat

リンクを置くだけで終わらせず、
最初の返事の設計と更新忘れの防止まで作るのがコツだよ!
おつかれさま、またねー!

関連記事