diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 092adbbc..cc90145c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -102,6 +102,7 @@ one tree rather than beside the source. | `apps/dev-tools-help-docs` | [.claude/apps/dev-tools-help-docs/CLAUDE.md](.claude/apps/dev-tools-help-docs/CLAUDE.md) | | `apps/vscode-cloud` | [.claude/apps/vscode-cloud/CLAUDE.md](.claude/apps/vscode-cloud/CLAUDE.md) | | `packages/about-system-info` | [.claude/packages/about-system-info/CLAUDE.md](.claude/packages/about-system-info/CLAUDE.md) | +| `packages/ask-ai-button` | [.claude/packages/ask-ai-button/CLAUDE.md](.claude/packages/ask-ai-button/CLAUDE.md) | | `packages/api2ai-mcp-generator` | [.claude/packages/api2ai-mcp-generator/CLAUDE.md](.claude/packages/api2ai-mcp-generator/CLAUDE.md) | | `packages/cloudflare-to-claude-fix` | [.claude/packages/cloudflare-to-claude-fix/CLAUDE.md](.claude/packages/cloudflare-to-claude-fix/CLAUDE.md) | | `packages/code-tree-graph` | [.claude/packages/code-tree-graph/CLAUDE.md](.claude/packages/code-tree-graph/CLAUDE.md) | diff --git a/.claude/architecture/overview.md b/.claude/architecture/overview.md index c2f3c940..b05a25e3 100644 --- a/.claude/architecture/overview.md +++ b/.claude/architecture/overview.md @@ -21,6 +21,7 @@ name is what turbo filters and `--skill` flags use. | Directory | npm name | What it owns | | --- | --- | --- | | `about-system-info` | `about-system` | Cross-platform CLI printing CPU/memory/disk/uptime/IP/ISP as one emoji line; also a desktop app under `native/` | +| `ask-ai-button` | `ask-ai-button` | Ask AI dropdown / floating button / inline panel for docs pages: sends the page (link or pasted Markdown) plus a question to Claude, ChatGPT, Gemini, Perplexity and more, or copies it. Spun out of `template-fumadocs` | | `api2ai-mcp-generator` | `api2ai` | Generates MCP servers from any OpenAPI spec (mcp-use); HTTP/SSE/Streamable transports, inspector UI, Zod validation | | `cloudflare-to-claude-fix` | `cloudflare-to-claude-fix` | Workers Queue consumer that fires a Claude Code routine when a Workers build fails | | `code-tree-graph` | `code-tree-graph` | Fumadocs/Next components: `DependencyGraph` (Mermaid from AST), `FileTreeView`, `TypeTable` — all from a local parser, no external service | diff --git a/.claude/packages/ask-ai-button/CLAUDE.md b/.claude/packages/ask-ai-button/CLAUDE.md new file mode 100644 index 00000000..9c815750 --- /dev/null +++ b/.claude/packages/ask-ai-button/CLAUDE.md @@ -0,0 +1,32 @@ +# CLAUDE.md — `ask-ai-button` + +**skill:** [`skills/ask-ai-button`](../../../skills/ask-ai-button/SKILL.md) +· **runner:** Vitest (jsdom) · **build:** Vite library + `vite-plugin-dts` + +"Ask AI about this page" for docs sites: `AskAIButton` (dropdown or FAB), +`AskAIPanel` (inline), `CopyPageButton`. Spun out of +`starter-templates/template-fumadocs/components/fumadocs/ai/`. + +## Rules + +- **Provider URLs are public API.** A changed query parameter silently empties + every prompt for that provider. Only change one after checking it opens with + the prompt filled in, and keep the encode-everything test passing. +- **`window.open` must stay synchronous in the click handler.** Content is + prefetched for exactly this reason; an `await` before the open gets the tab + blocked as a popup. +- **No Tailwind, no CSS import.** Styles live in `src/styles.ts` and are + injected at runtime; class names (`aai-*`) and `--aai-*` variables are public + surface. +- The bundle keeps its `"use client"` banner (`vite.config.ts`) so Next.js + server components can render it. + +## Layout + +`src/AskAIButton.tsx` (trigger, FAB, CopyPageButton) · `src/AskAIPanel.tsx` +(the panel) · `src/providers.tsx` · `src/prompt.ts` · `src/clipboard.ts` · +`src/styles.ts` · `src/icons.tsx` · `demo/` (`bun run dev`) + +```bash +cd packages/ask-ai-button && bun run typecheck && bun run test && bun run build +``` diff --git a/.github/scripts/sync-package-readmes.mjs b/.github/scripts/sync-package-readmes.mjs index c72aa024..9e196730 100644 --- a/.github/scripts/sync-package-readmes.mjs +++ b/.github/scripts/sync-package-readmes.mjs @@ -145,6 +145,7 @@ const SKILLS_SOURCE = 'https://github.com/OpenSourceAGI/dev-tools-starter-agent' const SKILLS_BY_PACKAGE = { 'about-system-info': ['about-system'], 'api2ai-mcp-generator': ['api2ai'], + 'ask-ai-button': ['ask-ai-button'], 'cloudflare-to-claude-fix': ['cloudflare-to-claude-fix'], 'code-tree-graph': ['code-tree-graph'], 'create-cloud-db': ['create-cloud-db'], diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1364ad7..286d6294 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,7 @@ jobs: include: - package: about-system-info - package: api2ai-mcp-generator + - package: ask-ai-button - package: cloudflare-to-claude-fix - package: code-tree-graph - package: create-cloud-db diff --git a/README.md b/README.md index af1253c2..585c41be 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ [![npm downloads](https://img.shields.io/npm/dm/code-tree-graph.svg)](https://www.npmjs.com/package/code-tree-graph) **[code-tree-graph](packages/code-tree-graph/)** - Interactive code dependency graph and file tree components for Fumadocs + Next.js. `DependencyGraph` renders a pan/zoom Mermaid flowchart from full AST analysis, `FileTreeView` a searchable table with export/JSDoc metadata and GitHub deep links, and `TypeTable` collapsible property tables — all from a local TypeScript/JS parser, no external service. `npm install code-tree-graph` · `bun add code-tree-graph` +[![npm downloads](https://img.shields.io/npm/dm/ask-ai-button.svg)](https://www.npmjs.com/package/ask-ai-button) **[ask-ai-button](packages/ask-ai-button/)** - "Ask AI about this page" for docs sites, as a dropdown, a floating action button or an inline panel. The visitor types a question and picks Claude, ChatGPT, Gemini, Perplexity, Grok, Copilot, Le Chat, T3 Chat, Brave, QwkSearch or Cursor; a new tab opens with the page (as a link or as pasted Markdown) and the question pre-filled. Also copies the prompt or the page Markdown, takes custom URL or API providers, and picks up Fumadocs theme colors with no CSS import. +`npm install ask-ai-button` · `bun add ask-ai-button` + [![npm downloads](https://img.shields.io/npm/dm/create-cloud-db.svg)](https://www.npmjs.com/package/create-cloud-db) **[create-cloud-db](packages/create-cloud-db/)** - Interactive CLI that creates a Turso edge database and writes `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` directly into your `.env` file. Handles Turso login, database creation, token generation, and env-file patching in one command. `npx create-cloud-db [db-name]` · `npm install -g create-cloud-db` diff --git a/apps/dev-tools-help-docs/scripts/sync-readme-docs.ts b/apps/dev-tools-help-docs/scripts/sync-readme-docs.ts index 3de4bd51..94df785a 100644 --- a/apps/dev-tools-help-docs/scripts/sync-readme-docs.ts +++ b/apps/dev-tools-help-docs/scripts/sync-readme-docs.ts @@ -98,6 +98,11 @@ const PACKAGES: Entry[] = [ title: 'manage-storage', icon: 'HardDrive', }, + { + dir: 'packages/ask-ai-button', + title: 'ask-ai-button', + icon: 'Sparkles', + }, { dir: 'packages/react-app-store-buttons', title: 'react-app-store-buttons', diff --git a/codecov.yml b/codecov.yml index 479b6475..4c742b73 100644 --- a/codecov.yml +++ b/codecov.yml @@ -79,6 +79,9 @@ flag_management: - name: react-app-store-buttons paths: - packages/react-app-store-buttons/ + - name: ask-ai-button + paths: + - packages/ask-ai-button/ comment: layout: "header, diff, flags, files" diff --git a/packages/ask-ai-button/README.md b/packages/ask-ai-button/README.md new file mode 100644 index 00000000..27a0dfbc --- /dev/null +++ b/packages/ask-ai-button/README.md @@ -0,0 +1,176 @@ +

+ +
+💍One Code to rule them all — and in the cloud compile them. +

+ + +

+ Documentation +
+ GitHub Stars + NPM Monthly Downloads + npm version + NPM Total Downloads + TypeScript types + Install size + Coverage +
+ GitHub Issues + Open Pull Requests + Merged Pull Requests + GitHub Discussions + GitHub last commit +
+ Open in StackBlitz + Bun TypeScript React Vite Vitest +

+ + + +**🤖 Agent skill** — `npx skills@latest add https://github.com/OpenSourceAGI/dev-tools-starter-agent --skill ask-ai-button` ([what it covers](../../skills/ask-ai-button/SKILL.md)) + + + +# ask-ai-button + +An **Ask AI** button for docs sites. The visitor types a question, picks a +model, and a new tab opens in Claude, ChatGPT, Gemini, Perplexity, Grok, +Copilot, Le Chat, T3 Chat, Brave, QwkSearch or Cursor with the prompt already +filled in: the current page (as a link or as pasted Markdown) plus their +question. They can also just copy the prompt, or copy the page as Markdown. + +Ships as a **dropdown** for a page header, a **floating action button**, or an +**inline panel**. Self-styled — no Tailwind or CSS import needed — and it picks +up Fumadocs' theme colors automatically. + +Spun out of the Ask AI dropdown and copy button in +[`starter-templates/template-fumadocs`](../../starter-templates/template-fumadocs/). + +## Install + +```bash +bun add ask-ai-button # or: npm install ask-ai-button +``` + +`react` and `react-dom` 18+ are peers. + +## Usage + +```tsx +import { AskAIButton, CopyPageButton } from "ask-ai-button"; + +// Dropdown, e.g. under a Fumadocs page title +
+ + +
+ +// Floating action button, once in your layout + + +// Inline panel, anywhere in the page + +``` + +The components are client components (the bundle starts with `"use client"`), +so they can be rendered straight from a Next.js server component. + +### What gets sent + +The panel has a **Link to page / Include page text** toggle: + +| Mode | Prompt | +| --- | --- | +| `link` *(default with `markdownUrl`)* | `Read https://site/docs/intro.mdx, ` — the model fetches the page | +| `content` | The page's Markdown pasted between `` tags, then the question | + +In `content` mode the text comes from `markdownUrl`, or from `getContent()` if +you pass one, or from the page's `
`/`
` text when neither is set. +If a pasted page would make the URL longer than `maxUrlLength` (default 8000), +the tab opens with the `link` prompt and the full prompt is put on the +clipboard so the visitor can paste it. + +`Ctrl/⌘ + Enter` in the message box sends to the first provider. + +### Choosing providers + +```tsx + +``` + +Built-in IDs: `claude`, `chatgpt`, `gemini`, `perplexity`, `grok`, `copilot`, +`mistral`, `t3chat`, `brave`, `qwksearch`, `cursor`. Mix them with your own: + +```tsx + `https://www.phind.com/search?q=${encodeURIComponent(p)}` }, + // Or your own API / in-page chat — no tab is opened + { id: "api", title: "Our assistant", onSelect: (prompt, page) => fetch("/api/ask", { method: "POST", body: prompt }) }, + ]} +/> +``` + +A provider without an `icon` shows its site's favicon. + +## Props + +`AskAIButton` takes every `AskAIPanel` prop plus the trigger props. + +| Prop | Default | | +| --- | --- | --- | +| `markdownUrl` | — | Raw Markdown/MDX of the page. Relative URLs resolve against the current page. | +| `pageUrl` | `location.href` | The page being asked about. | +| `title` | — | Named in the prompt and shown in the panel header. | +| `githubUrl` | — | Adds a GitHub link. | +| `providers` | all built-ins | IDs and/or `AIProvider` objects, in order. | +| `defaultMode` | `link` if `markdownUrl`, else `content` | Starting mode. | +| `showModeToggle` | `true` | Show the link/content toggle. | +| `getContent` | — | Supply the page text yourself. | +| `promptTemplate` | `buildPrompt` | `(input) => string` to change the wording. | +| `maxUrlLength` | `8000` | URL length before falling back to the link prompt. | +| `placeholder`, `heading` | — | Text in the panel. `heading={null}` hides the header. | +| `onSend` | — | `({ provider, prompt, href })` after each send — for analytics. | +| `injectStyles` | `true` | Set `false` if you ship `askAIButtonCss` yourself. | +| `variant` | `dropdown` | `dropdown` or `fab`. | +| `label`, `icon` | `"Ask AI"`, sparkle | Trigger content. `label={null}` gives an icon-only FAB. | +| `align` | `start` | Dropdown: panel aligns to the trigger's `start` or `end` edge. | +| `position` | `bottom-right` | FAB: `bottom-right` or `bottom-left`. | +| `open`, `defaultOpen`, `onOpenChange` | — | Controlled or uncontrolled open state. | + +Helpers exported for building your own UI: `buildPrompt`, +`resolveProviderTarget`, `resolveProviders`, `AI_PROVIDERS`, `fetchMarkdown`, +`copyText`, `copyPendingText`, and the brand icons. + +## Theming + +Every class is prefixed `aai-`. Colors come from CSS variables that default to +Fumadocs' `--color-fd-*` tokens and fall back to a neutral palette, with a dark +palette under `.dark` or `[data-theme="dark"]`: + +```css +.aai-root { + --aai-primary: #7c3aed; + --aai-radius: 1rem; +} +``` + +The stylesheet is injected once as ` + + +
+ + + diff --git a/packages/ask-ai-button/demo/main.tsx b/packages/ask-ai-button/demo/main.tsx new file mode 100644 index 00000000..91e03d9d --- /dev/null +++ b/packages/ask-ai-button/demo/main.tsx @@ -0,0 +1,50 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { AskAIButton, AskAIPanel, CopyPageButton } from "../src"; + +const markdownUrl = "./sample.md"; + +function App() { + return ( +
+

Getting started

+
+ + alert(`POST /api/ask\n\n${prompt}`), + }, + ]} + /> +
+

+ Install the package, then render the button in your docs page header. +

+

Inline panel

+ + +
+ ); +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/ask-ai-button/demo/public/sample.md b/packages/ask-ai-button/demo/public/sample.md new file mode 100644 index 00000000..3a36f11b --- /dev/null +++ b/packages/ask-ai-button/demo/public/sample.md @@ -0,0 +1,3 @@ +# Getting started + +Install the package, then render the button in your docs page header. diff --git a/packages/ask-ai-button/package.json b/packages/ask-ai-button/package.json new file mode 100644 index 00000000..f39d5cb7 --- /dev/null +++ b/packages/ask-ai-button/package.json @@ -0,0 +1,66 @@ +{ + "name": "ask-ai-button", + "version": "0.1.0", + "description": "Ask AI about this page: a dropdown or floating button that sends the current docs page plus your question to Claude, ChatGPT, Gemini, Perplexity and more, or copies the prompt", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "sideEffects": false, + "scripts": { + "dev": "vite --config vite.demo.config.ts", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "@vitest/coverage-v8": "^4.1.0", + "jsdom": "^28.0.1", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vite-plugin-dts": "^4.3.0", + "vitest": "^4.1.0" + }, + "keywords": [ + "react", + "ask-ai", + "llm", + "claude", + "chatgpt", + "gemini", + "perplexity", + "fumadocs", + "docs", + "copy-markdown", + "floating-action-button" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/OpenSourceAGI/dev-tools-starter-agent.git", + "directory": "packages/ask-ai-button" + }, + "homepage": "https://github.com/OpenSourceAGI/dev-tools-starter-agent/tree/master/packages/ask-ai-button" +} diff --git a/packages/ask-ai-button/src/AskAIButton.tsx b/packages/ask-ai-button/src/AskAIButton.tsx new file mode 100644 index 00000000..b4e73c39 --- /dev/null +++ b/packages/ask-ai-button/src/AskAIButton.tsx @@ -0,0 +1,227 @@ +/** + * @file AskAIButton.tsx + * @description The trigger: a dropdown button for a docs page header, or a + * floating action button pinned to a corner. Both open the same AskAIPanel. + */ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { copyPendingText, fetchMarkdown } from "./clipboard"; +import { + AskAIPanel, + CopyIcon, + SparkleIcon, + type AskAIPanelProps, +} from "./AskAIPanel"; +import { toAbsoluteUrl } from "./prompt"; +import { useAskAIStyles } from "./styles"; + +export interface AskAIButtonProps extends Omit< + AskAIPanelProps, + "inline" | "alignClassName" +> { + /** `dropdown` (default) sits inline; `fab` floats in a corner of the viewport. */ + variant?: "dropdown" | "fab"; + /** Trigger text. Default "Ask AI". The FAB shows only its icon when set to `null`. */ + label?: ReactNode; + /** Trigger icon. Defaults to a sparkle. */ + icon?: ReactNode; + /** Dropdown only: which edge of the trigger the panel lines up with. Default `start`. */ + align?: "start" | "end"; + /** FAB only: which corner it sits in. Default `bottom-right`. */ + position?: "bottom-right" | "bottom-left"; + /** Start open (uncontrolled). */ + defaultOpen?: boolean; + /** Controlled open state. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** Class for the trigger button. */ + triggerClassName?: string; + /** Class for the panel. */ + panelClassName?: string; +} + +function ChevronDown() { + return ( + + ); +} + +export function AskAIButton({ + variant = "dropdown", + label = "Ask AI", + icon = , + align = "start", + position = "bottom-right", + defaultOpen = false, + open: controlledOpen, + onOpenChange, + triggerClassName, + panelClassName, + className, + injectStyles = true, + ...panelProps +}: AskAIButtonProps) { + useAskAIStyles(injectStyles); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const open = controlledOpen ?? uncontrolledOpen; + + const setOpen = useCallback( + (next: boolean) => { + if (controlledOpen === undefined) setUncontrolledOpen(next); + onOpenChange?.(next); + }, + [controlledOpen, onOpenChange], + ); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + setOpen(false); + triggerRef.current?.focus(); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open, setOpen]); + + const isFab = variant === "fab"; + const rootClasses = [ + "aai-root", + isFab && "aai-fab-root", + isFab && `aai-pos-${position}`, + open && "aai-open", + className, + ] + .filter(Boolean) + .join(" "); + + return ( +
+ + {open && ( + + )} +
+ ); +} + +export interface CopyPageButtonProps { + /** URL of the page's raw Markdown/MDX. */ + markdownUrl: string; + /** Button text. Default "Copy page". */ + label?: ReactNode; + className?: string; + injectStyles?: boolean; +} + +/** One-click "copy this page as Markdown" — the old LLMCopyButton. */ +export function CopyPageButton({ + markdownUrl, + label = "Copy page", + className, + injectStyles = true, +}: CopyPageButtonProps) { + useAskAIStyles(injectStyles); + const [state, setState] = useState<"idle" | "busy" | "done" | "error">( + "idle", + ); + + useEffect(() => { + if (state !== "done" && state !== "error") return; + const timer = setTimeout(() => setState("idle"), 1500); + return () => clearTimeout(timer); + }, [state]); + + const onClick = async () => { + setState("busy"); + try { + const ok = await copyPendingText( + fetchMarkdown(toAbsoluteUrl(markdownUrl)), + ); + setState(ok ? "done" : "error"); + } catch { + setState("error"); + } + }; + + return ( + + + + ); +} + +function CheckIcon() { + return ( + + ); +} diff --git a/packages/ask-ai-button/src/AskAIPanel.tsx b/packages/ask-ai-button/src/AskAIPanel.tsx new file mode 100644 index 00000000..900136cd --- /dev/null +++ b/packages/ask-ai-button/src/AskAIPanel.tsx @@ -0,0 +1,410 @@ +/** + * @file AskAIPanel.tsx + * @description The panel itself: a message box, a link/content toggle, the + * provider list and copy actions. Rendered inside the dropdown and the floating + * button, or on its own with `inline`. + */ +"use client"; + +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { copyText, fetchMarkdown } from "./clipboard"; +import { GitHubIcon } from "./icons"; +import { + buildPrompt, + DEFAULT_MAX_URL_LENGTH, + faviconFor, + resolveProviderTarget, + toAbsoluteUrl, +} from "./prompt"; +import { resolveProviders, type ProviderOption } from "./providers"; +import { useAskAIStyles } from "./styles"; +import type { AIProvider, PageContext, PromptInput, PromptMode } from "./types"; + +export interface AskAIPanelProps { + /** URL of the page's raw Markdown/MDX (e.g. `${page.url}.mdx`). Relative is fine. */ + markdownUrl?: string; + /** URL of the page. Defaults to `window.location.href`. */ + pageUrl?: string; + /** Page title, named in the prompt. */ + title?: string; + /** Source file on GitHub; adds an "Open in GitHub" action. */ + githubUrl?: string; + /** Provider IDs and/or custom providers, in display order. Defaults to all built-ins. */ + providers?: ProviderOption[]; + /** Starting prompt mode. Defaults to `link` when `markdownUrl` is set, else `content`. */ + defaultMode?: PromptMode; + /** Show the link/content toggle. Default `true`. */ + showModeToggle?: boolean; + /** + * Where `content` mode gets the page text. Defaults to fetching + * `markdownUrl`, or reading the page's `
`/`
` text without one. + */ + getContent?: () => string | Promise; + /** Replace the built-in prompt wording. */ + promptTemplate?: (input: PromptInput) => string; + /** Longest provider URL before falling back to the link prompt. Default 8000. */ + maxUrlLength?: number; + /** Placeholder for the message box. */ + placeholder?: string; + /** Heading above the message box. Pass `null` to hide it. */ + heading?: ReactNode; + /** Called whenever a prompt is sent to a provider — handy for analytics. */ + onSend?: (event: { + provider: AIProvider; + prompt: string; + href?: string; + }) => void; + /** Focus the message box on mount. */ + autoFocus?: boolean; + /** Render in normal flow instead of as a floating popover. */ + inline?: boolean; + /** Inject the bundled stylesheet. Default `true`. */ + injectStyles?: boolean; + className?: string; + /** Internal: popover alignment class. */ + alignClassName?: string; +} + +type LoadState = "idle" | "loading" | "ready" | "error"; +type Status = { text: string; tone?: "error" } | null; + +/** Visible text of the page's main content, for `content` mode without Markdown. */ +function readPageText(): string { + if (typeof document === "undefined") return ""; + const node = + document.querySelector("article") ?? + document.querySelector("main") ?? + document.body; + return (node as HTMLElement).innerText ?? node.textContent ?? ""; +} + +function ProviderIcon({ + provider, + href, +}: { + provider: AIProvider; + href?: string; +}) { + const [failed, setFailed] = useState(false); + if (provider.icon !== undefined) return <>{provider.icon}; + const favicon = href ? faviconFor(href) : undefined; + if (favicon && !failed) { + return ( + setFailed(true)} + /> + ); + } + return ( + + ); +} + +export function AskAIPanel({ + markdownUrl, + pageUrl, + title, + githubUrl, + providers, + defaultMode, + showModeToggle = true, + getContent, + promptTemplate = buildPrompt, + maxUrlLength = DEFAULT_MAX_URL_LENGTH, + placeholder = "Ask a question about this page…", + heading = "Ask AI about this page", + onSend, + autoFocus, + inline, + injectStyles = true, + className, + alignClassName, +}: AskAIPanelProps) { + useAskAIStyles(injectStyles); + const inputId = useId(); + const inputRef = useRef(null); + const [message, setMessage] = useState(""); + const [mode, setMode] = useState( + defaultMode ?? (markdownUrl ? "link" : "content"), + ); + const [content, setContent] = useState(); + const [loadState, setLoadState] = useState("idle"); + const [status, setStatus] = useState(null); + + const items = useMemo(() => resolveProviders(providers), [providers]); + + useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + const loadContent = useCallback(async (): Promise => { + if (content !== undefined) return content; + setLoadState("loading"); + try { + const text = getContent + ? await getContent() + : markdownUrl + ? await fetchMarkdown(toAbsoluteUrl(markdownUrl)) + : readPageText(); + setContent(text); + setLoadState("ready"); + return text; + } catch { + setLoadState("error"); + setStatus({ + text: "Couldn't load the page text — sending a link instead.", + tone: "error", + }); + return undefined; + } + }, [content, getContent, markdownUrl]); + + // Prefetch as soon as content mode is chosen, so a provider click can open + // its tab synchronously (popup blockers drop window.open after an await). + useEffect(() => { + if (mode === "content" && loadState === "idle") void loadContent(); + }, [mode, loadState, loadContent]); + + const page = useCallback((): PageContext => { + const absolutePage = toAbsoluteUrl( + pageUrl ?? (typeof window !== "undefined" ? window.location.href : ""), + ); + return { + pageUrl: absolutePage, + markdownUrl: markdownUrl ? toAbsoluteUrl(markdownUrl) : undefined, + title, + githubUrl, + }; + }, [pageUrl, markdownUrl, title, githubUrl]); + + const prompts = useCallback(() => { + const ctx = page(); + const linkPrompt = promptTemplate({ ...ctx, message, mode: "link" }); + const fullPrompt = + mode === "content" && content + ? promptTemplate({ ...ctx, message, mode: "content", content }) + : linkPrompt; + return { ctx, linkPrompt, fullPrompt }; + }, [page, promptTemplate, message, mode, content]); + + const send = useCallback( + async (provider: AIProvider) => { + const { ctx, linkPrompt, fullPrompt } = prompts(); + if (provider.onSelect) { + onSend?.({ provider, prompt: fullPrompt }); + try { + await provider.onSelect(fullPrompt, ctx); + setStatus({ text: `Sent to ${provider.title}.` }); + } catch { + setStatus({ + text: `${provider.title} failed to receive the prompt.`, + tone: "error", + }); + } + return; + } + const target = resolveProviderTarget( + provider, + fullPrompt, + linkPrompt, + ctx, + maxUrlLength, + ); + if (!target.href) return; + window.open(target.href, "_blank", "noopener,noreferrer"); + onSend?.({ provider, prompt: fullPrompt, href: target.href }); + if (target.shortened) { + const copied = await copyText(fullPrompt); + setStatus({ + text: copied + ? `Page too long for a link — sent a link, and the full prompt is on your clipboard to paste into ${provider.title}.` + : `Page too long for a link — sent a link to ${provider.title} instead.`, + }); + } else { + setStatus(null); + } + }, + [prompts, onSend, maxUrlLength], + ); + + const copyPrompt = useCallback(async () => { + const { fullPrompt } = prompts(); + const ok = await copyText(fullPrompt); + setStatus( + ok + ? { text: "Prompt copied." } + : { text: "Couldn't copy.", tone: "error" }, + ); + }, [prompts]); + + const copyPage = useCallback(async () => { + const text = await loadContent(); + if (text === undefined) return; + const ok = await copyText(text); + setStatus( + ok + ? { text: "Page copied as Markdown." } + : { text: "Couldn't copy.", tone: "error" }, + ); + }, [loadContent]); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && items[0]) { + event.preventDefault(); + void send(items[0]); + } + }; + + const waiting = mode === "content" && loadState === "loading"; + const classes = [ + "aai-panel", + inline && "aai-root aai-inline", + alignClassName, + className, + ] + .filter(Boolean) + .join(" "); + + return ( +
+ {heading !== null && ( +
+ + {title && {title}} +
+ )} +