-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: improve AI agent discoverability #8607
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4c7041b
f5874cd
9e8a542
a73e0f2
d352406
affd334
428c602
4c196e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -129,5 +129,5 @@ | |
| "overrides": { | ||
| "tmp": "^0.2.4" | ||
| }, | ||
| "packageManager": "yarn@4.14.1" | ||
| "packageManager": "yarn@4.17.0" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,14 +8,36 @@ interface MarkdownMenuProps { | |
| isOverview?: boolean; | ||
| } | ||
|
|
||
| function getMarkdownUrl(route: string): string { | ||
| // Strip platform prefix and trailing slash | ||
| // e.g. /react/build-a-backend/auth/set-up-auth/ → build-a-backend/auth/set-up-auth | ||
| const parts = route.replace(/^\//, '').replace(/\/$/, '').split('/'); | ||
| export function getMarkdownUrl(route: string): string { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| // Strip any query string / hash, then the platform prefix and trailing slash. | ||
| // e.g. /react/build-a-backend/auth/set-up-auth/?foo=bar#x | ||
| // → build-a-backend/auth/set-up-auth | ||
| const pathOnly = route.replace(/[?#].*$/, ''); | ||
| const parts = pathOnly.replace(/^\//, '').replace(/\/$/, '').split('/'); | ||
| const withoutPlatform = parts.slice(1).join('/'); | ||
| return `/ai/pages/${withoutPlatform}.md`; | ||
| } | ||
|
|
||
| /** | ||
| * Fetch a page's generated Markdown, rejecting the SPA HTML fallback (e.g. a | ||
| * 404 page) that Amplify serves when the .md file is missing. Shared by the | ||
| * copy/open menu and the WebMCP tools so the fallback detection lives in one | ||
| * place. | ||
| */ | ||
| export async function fetchPageMarkdown(url: string): Promise<string> { | ||
| const response = await fetch(url, { | ||
| headers: { accept: 'text/markdown, text/plain' } | ||
| }); | ||
| if (!response.ok) { | ||
| throw new Error(`Request for ${url} failed with status ${response.status}`); | ||
| } | ||
| const text = await response.text(); | ||
| if (/^\s*<!doctype/i.test(text) || /^\s*<html/i.test(text)) { | ||
| throw new Error(`No markdown available at ${url}`); | ||
| } | ||
| return text; | ||
| } | ||
|
|
||
| export function MarkdownMenu({ route, isGen1, isHome, isOverview }: MarkdownMenuProps) { | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [copied, setCopied] = useState(false); | ||
|
|
@@ -27,17 +49,13 @@ export function MarkdownMenu({ route, isGen1, isHome, isOverview }: MarkdownMenu | |
|
|
||
| const handleCopy = useCallback(async () => { | ||
| try { | ||
| const response = await fetch(mdUrl); | ||
| if (!response.ok) return; | ||
| const text = await response.text(); | ||
| // Guard against accidentally copying HTML (e.g. 404 page) | ||
| if (/^\s*<!doctype/i.test(text) || /^\s*<html/i.test(text)) return; | ||
| const text = await fetchPageMarkdown(mdUrl); | ||
| await navigator.clipboard.writeText(text); | ||
| setCopied(true); | ||
| setIsOpen(false); | ||
| copiedTimerRef.current = setTimeout(() => setCopied(false), 2000); | ||
| } catch { | ||
| // Silently fail if clipboard not available | ||
| // Silently fail if the markdown is unavailable or clipboard is blocked | ||
| } | ||
| }, [mdUrl]); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| export { MarkdownMenu } from './MarkdownMenu'; | ||
| export { MarkdownMenu, getMarkdownUrl, fetchPageMarkdown } from './MarkdownMenu'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import { useEffect } from 'react'; | ||
| import { getMarkdownUrl, fetchPageMarkdown } from '@/components/MarkdownMenu'; | ||
|
|
||
| /** | ||
| * Minimal shape of the WebMCP API we depend on. The spec exposes | ||
| * `document.modelContext.registerTool`; some early implementations also alias | ||
| * it on `navigator.modelContext`. We feature-detect both and treat the API as | ||
| * entirely optional so this is a silent no-op in browsers without WebMCP. | ||
| * | ||
| * See https://webmachinelearning.github.io/webmcp/ | ||
| */ | ||
| interface ModelContextTool { | ||
| name: string; | ||
| description: string; | ||
| inputSchema?: object; | ||
| annotations?: { readOnlyHint?: boolean }; | ||
| execute: (input: Record<string, unknown>) => Promise<unknown>; | ||
| } | ||
|
|
||
| interface ModelContextLike { | ||
| registerTool: ( | ||
| tool: ModelContextTool, | ||
| options?: { signal?: AbortSignal } | ||
| ) => Promise<void>; | ||
| } | ||
|
|
||
| function getModelContext(): ModelContextLike | null { | ||
| if (typeof document !== 'undefined') { | ||
| const fromDoc = (document as unknown as { modelContext?: ModelContextLike }) | ||
| .modelContext; | ||
| if (fromDoc?.registerTool) return fromDoc; | ||
| } | ||
| if (typeof navigator !== 'undefined') { | ||
| const fromNav = (navigator as unknown as { modelContext?: ModelContextLike }) | ||
| .modelContext; | ||
| if (fromNav?.registerTool) return fromNav; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Register a single tool, isolating failures so one rejected registration | ||
| * (e.g. a transient duplicate-name error during abort/re-register on fast | ||
| * client-side navigation) can't prevent the others from registering. | ||
| */ | ||
| async function safeRegister( | ||
| modelContext: ModelContextLike, | ||
| tool: ModelContextTool, | ||
| signal: AbortSignal | ||
| ): Promise<void> { | ||
| try { | ||
| await modelContext.registerTool(tool, { signal }); | ||
| } catch { | ||
| // Registration is best-effort; ignore failures for this tool. | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Registers read-only WebMCP tools that expose this documentation site's key | ||
| * actions to in-browser AI agents. Every tool is backed by content the site | ||
| * already generates (the per-page markdown twins under /ai/pages and the | ||
| * llms.txt index), so the tools return real data rather than stubs. | ||
| * | ||
| * Renders nothing; the registration happens as a side effect on mount and is | ||
| * torn down via an AbortSignal on unmount. | ||
| */ | ||
| export function WebMcp({ route }: { route: string }) { | ||
| useEffect(() => { | ||
| const modelContext = getModelContext(); | ||
| if (!modelContext) return; | ||
|
|
||
| const controller = new AbortController(); | ||
| const { signal } = controller; | ||
| const origin = window.location.origin; | ||
|
|
||
| // Each tool is registered independently so one failure can't block the rest. | ||
| safeRegister( | ||
| modelContext, | ||
| { | ||
| name: 'get_current_page_markdown', | ||
| description: | ||
| 'Return the current AWS Amplify documentation page as clean Markdown, ideal for reading or summarizing without HTML chrome.', | ||
| inputSchema: { type: 'object', properties: {} }, | ||
| annotations: { readOnlyHint: true }, | ||
| execute: async () => { | ||
| const markdown = await fetchPageMarkdown(origin + getMarkdownUrl(route)); | ||
| return { markdown }; | ||
| } | ||
| }, | ||
| signal | ||
| ); | ||
|
|
||
| safeRegister( | ||
| modelContext, | ||
| { | ||
| name: 'get_documentation_index', | ||
| description: | ||
| 'Return the AWS Amplify documentation index (llms.txt), a Markdown list of all documentation pages with descriptions and links to their Markdown versions.', | ||
| inputSchema: { type: 'object', properties: {} }, | ||
| annotations: { readOnlyHint: true }, | ||
| execute: async () => { | ||
| const index = await fetchPageMarkdown(origin + '/ai/llms.txt'); | ||
| return { index }; | ||
| } | ||
| }, | ||
| signal | ||
| ); | ||
|
|
||
| return () => controller.abort(); | ||
| }, [route]); | ||
|
|
||
| return null; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
Linkheader sits on the global**/*block, so it rides every response (HTML, images, JSON), not just discovery routes — bytes on every request and a wider blast radius for the missing-file cases above. Worth a conscious choice vs. scoping it to the relevant paths.