@kanketsu/ai-share-btn
v0.1.0
Published
Headless React utilities to send the current page as a prompt to ChatGPT, Perplexity, Claude, and other AI providers. No UI included — bring your own dialog.
Maintainers
Readme
@kanketsu/ai-share-btn
Headless utilities to send "the current page" as a prompt to ChatGPT, Perplexity, Claude, and others. No UI — bring your own.
✨ Features
- Headless: No bundled CSS, no
<dialog>, no opinions about your design system. Use your own Shadcn / Radix / Tailwind / vanilla UI. - Zero runtime dependencies: Only
react/react-domas peer deps. - TypeScript first: Full types for every export.
- SSR safe:
getPageContext()returns sensible fallbacks whenwindowis undefined. - 6+ providers built in: ChatGPT, Perplexity, Grok, Claude, Gemini, Copilot — with the latest query-spec quirks baked in.
- Future-proof: When a provider changes its URL spec, you just bump this package — your UI code stays the same.
📦 Install
npm i @kanketsu/ai-share-btn
# or
bun add @kanketsu/ai-share-btnPeer deps: react >= 18, react-dom >= 18.
🚀 Quick Start (minimal)
import { buildProviderUrl, getPageContext } from "@kanketsu/ai-share-btn";
function askChatGpt() {
const ctx = getPageContext();
const prompt = `${ctx.url}\n\nこのページについて教えて`;
const { url } = buildProviderUrl("chatgpt", prompt);
window.open(url, "_blank", "noopener,noreferrer");
}That's it. No CSS to import. No component to wrap.
🧑🍳 Recipes (copy-paste ready)
Recipe 1 — Shadcn UI Dialog
The most common case. Drops straight into a Shadcn UI project.
"use client";
import { useState } from "react";
import { Sparkles } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
builtinProviders,
copyText,
getPageContext,
type Provider,
} from "@kanketsu/ai-share-btn";
export function AskAiButton() {
const [open, setOpen] = useState(false);
async function handleClick(provider: Provider) {
const ctx = getPageContext();
const prompt = `${ctx.url}\n\nこのページについて要約して教えてください。`;
const { url, promptDelivered } = provider.buildUrl(prompt);
if (!promptDelivered) {
await copyText(prompt); // clipboard fallback
}
window.open(url, "_blank", "noopener,noreferrer");
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Sparkles className="size-4 mr-1.5" />
AIに質問
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogTitle>AIに質問する</DialogTitle>
<div className="grid grid-cols-2 gap-3 mt-4">
{builtinProviders.map((p) => (
<button
key={p.id}
type="button"
onClick={() => handleClick(p)}
className="flex flex-col items-center gap-2 p-4 rounded-lg border hover:bg-muted"
>
<p.Icon size={32} />
<span className="text-sm">{p.name}</span>
{p.clipboardFallback && (
<span className="text-[10px] text-muted-foreground">
(クリップボード経由)
</span>
)}
</button>
))}
</div>
</DialogContent>
</Dialog>
);
}Recipe 2 — Radix UI Dialog
For projects that use Radix directly (without Shadcn).
"use client";
import { useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import {
builtinProviders,
copyText,
getPageContext,
type Provider,
} from "@kanketsu/ai-share-btn";
export function AskAiButton() {
const [open, setOpen] = useState(false);
async function handleClick(provider: Provider) {
const ctx = getPageContext();
const prompt = `${ctx.url}\n\n${ctx.title}\n\nこのページについて教えて`;
const { url, promptDelivered } = provider.buildUrl(prompt);
if (!promptDelivered) await copyText(prompt);
window.open(url, "_blank", "noopener,noreferrer");
setOpen(false);
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger
style={{ padding: "8px 12px", border: "1px solid #ccc", borderRadius: 6 }}
>
✨ AIに質問
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)" }}
/>
<Dialog.Content
style={{
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "white",
padding: 24,
borderRadius: 12,
minWidth: 320,
}}
>
<Dialog.Title style={{ marginBottom: 16 }}>AIに質問する</Dialog.Title>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{builtinProviders.map((p) => (
<button
key={p.id}
type="button"
onClick={() => handleClick(p)}
style={{
padding: 12,
border: "1px solid #eee",
borderRadius: 8,
cursor: "pointer",
}}
>
<p.Icon size={24} />
<div>{p.name}</div>
</button>
))}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Recipe 3 — Native <dialog> (zero deps)
No UI library at all. Pure React + the browser's native <dialog> element.
"use client";
import { useRef } from "react";
import {
builtinProviders,
copyText,
getPageContext,
type Provider,
} from "@kanketsu/ai-share-btn";
export function AskAiButton() {
const ref = useRef<HTMLDialogElement>(null);
async function handleClick(provider: Provider) {
const ctx = getPageContext();
const prompt = `${ctx.url}\n\nこのページについて教えて`;
const { url, promptDelivered } = provider.buildUrl(prompt);
if (!promptDelivered) await copyText(prompt);
window.open(url, "_blank", "noopener,noreferrer");
ref.current?.close();
}
return (
<>
<button type="button" onClick={() => ref.current?.showModal()}>
✨ AIに質問
</button>
<dialog ref={ref} style={{ borderRadius: 12, padding: 24, border: "none" }}>
<h3 style={{ margin: 0, marginBottom: 12 }}>AIに質問する</h3>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{builtinProviders.map((p) => (
<button key={p.id} type="button" onClick={() => handleClick(p)}>
<p.Icon size={20} /> {p.name}
</button>
))}
</div>
<button
type="button"
onClick={() => ref.current?.close()}
style={{ marginTop: 12 }}
>
閉じる
</button>
</dialog>
</>
);
}Note: ネイティブ
<dialog>のshowModal()は内部でfocus()を呼び、ページがスクロールすることがあります。気になる場合は Recipe 1 / 2 の手組み dialog を使ってください。
Recipe 4 — Generic click handler (clipboard fallback aware)
UI を完全に自分で書く場合の「最小ロジック」だけ抜き出した型。
import {
buildProviderUrl,
copyText,
getPageContext,
type ProviderId,
} from "@kanketsu/ai-share-btn";
export async function shareToAi(
providerId: ProviderId,
buildPrompt?: (ctx: ReturnType<typeof getPageContext>) => string,
) {
const ctx = getPageContext();
const prompt =
buildPrompt?.(ctx) ?? `${ctx.url}\n\n${ctx.title}\n\nこのページについて教えて`;
const { url, promptDelivered, truncated } = buildProviderUrl(providerId, prompt);
if (!promptDelivered) {
const ok = await copyText(prompt);
if (!ok) {
console.warn("[ai-share] clipboard copy failed.");
}
}
if (truncated) {
console.info("[ai-share] prompt was truncated to fit provider limits.");
}
window.open(url, "_blank", "noopener,noreferrer");
}
// 使い方:
// <button onClick={() => shareToAi("chatgpt")}>ChatGPTに質問</button>📚 API Reference
buildProviderUrl(providerId, prompt, opts?) => BuildUrlResult
buildProviderUrl("chatgpt", "Hello world", {
model: "gpt-4", // optional: model param
extraParams: { foo: "1" }, // optional: extra query params
maxLength: 2500, // optional: truncation cap (default 2500)
});
// => { url: "https://chatgpt.com/?q=...", promptDelivered: true, truncated: false }| Field | Type | Notes |
| ----------------- | --------- | -------------------------------------------------------------- |
| url | string | Open with window.open(url, "_blank", "noopener,noreferrer"). |
| promptDelivered | boolean | false → URL spec doesn't accept query; copy to clipboard. |
| truncated | boolean | true if prompt was cut to fit maxLength. |
getPageContext() => PageContext
{
url: string; // window.location.href (or "" on server)
title: string; // document.title
description?: string; // <meta name="description">
selection?: string; // window.getSelection() if any
}SSR-safe: returns empty strings when window / document is undefined.
copyText(text) => Promise<boolean>
navigator.clipboard.writeText() を優先し、失敗したら execCommand('copy') にフォールバックします。
ブラウザのセキュリティ制約上、ユーザー操作 (click) 起因のハンドラ内で呼ぶ必要があります。
builtinProviders: Provider[]
各 Provider は以下を持ちます:
{
id: ProviderId;
name: string;
brandColor: string;
Icon: ComponentType<{ size?: number; className?: string }>;
buildUrl: (prompt: string, opts?) => BuildUrlResult;
clipboardFallback?: boolean; // true なら copyText() してから開く
maxPromptLength?: number;
}getProvider(id) / getDefaultProviders() / registerProvider(provider)
getProvider("chatgpt"); // => Provider | undefined
getDefaultProviders(); // => [chatgpt, claude, perplexity]
registerProvider(myCustomProv); // upserts into the internal registry🤖 Supported Providers
| ID | Name | Query で prompt を渡せる? |
| ------------ | ---------- | --------------------------------------------------- |
| chatgpt | ChatGPT | ✅ ?q= |
| perplexity | Perplexity | ✅ ?q= |
| grok | Grok | ✅ ?q= |
| claude | Claude | ❌ clipboard fallback (?q= removed 2025-10) |
| gemini | Gemini | ❌ clipboard fallback (no URL param) |
| copilot | Copilot | ❌ clipboard fallback (regression) |
仕様が変わったら本パッケージを bump するだけで追従できます。アプリ側のコードは原則そのまま。
📋 Clipboard fallback の扱い方
provider.clipboardFallback === true のプロバイダは URL で prompt を渡せません。クリックハンドラの中で:
copyText(prompt)を呼んでクリップボードへwindow.open(provider.buildUrl(prompt).url, ...)でサイトを開く- ユーザーには「クリップボードにコピーしました。貼り付けて送信してください」とトーストで通知
import { toast } from "sonner";
async function handle(p: Provider) {
const { url, promptDelivered } = p.buildUrl(prompt);
if (!promptDelivered) {
await copyText(prompt);
toast.success("プロンプトをコピーしました。開いたページに貼り付けてください。");
}
window.open(url, "_blank", "noopener,noreferrer");
}fallback を出したくない場合は、クエリ対応のプロバイダだけに絞れます:
const queryOnly = builtinProviders.filter((p) => !p.clipboardFallback);
// => [chatgpt, perplexity, grok]🧩 Custom Providers
registerProvider() で任意の AI サービスを追加できます。
import { registerProvider, type Provider } from "@kanketsu/ai-share-btn";
const myProvider: Provider = {
id: "myai",
name: "MyAI",
brandColor: "#ff5599",
Icon: ({ size = 24 }) => (
<svg width={size} height={size} viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" fill="currentColor" />
</svg>
),
buildUrl: (prompt) => ({
url: `https://my-ai.example.com/?q=${encodeURIComponent(prompt)}`,
promptDelivered: true,
truncated: false,
}),
};
registerProvider(myProvider);
// 以降 getProvider("myai") で取得可能📜 Logo trademarks
プロバイダのロゴ SVG は lobehub/lobe-icons (MIT) から取得しています。
ChatGPT / Claude / Gemini / Grok / Copilot / Perplexity の各ブランドおよびロゴは、それぞれの企業の商標です。
詳細は NOTICE.md を参照してください。
License
MIT © Kazuma Horiike
