From 234a8aac4e06b869ff76799b5a8a103811b3925a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:52:23 +0000 Subject: [PATCH 1/2] feat(svelte,solid): add useAskableRegionCapture and useAskableTextSelectionCapture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings Svelte and Solid to full capture parity with React: - useAskableRegionCapture — rect/circle/lasso region selection - useAskableTextSelectionCapture — highlighted text capture Svelte 5 implementations use $state/$effect runes and return reactive getter objects. Solid implementations use createSignal/createEffect with accessor functions following the existing hook patterns. Both hooks are exported from the ./core sub-path barrel and added to package.json exports/files for Svelte. Svelte tsconfig exclude list updated to keep the .svelte.ts files out of tsc compilation. --- packages/solid/src/core.ts | 2 + packages/solid/src/index.ts | 4 + packages/solid/src/useAskableRegionCapture.ts | 127 +++++++++++++++ .../src/useAskableTextSelectionCapture.ts | 144 +++++++++++++++++ packages/svelte/package.json | 4 + packages/svelte/src/core.svelte.ts | 2 + .../src/useAskableRegionCapture.svelte.ts | 129 +++++++++++++++ .../useAskableTextSelectionCapture.svelte.ts | 148 ++++++++++++++++++ packages/svelte/tsconfig.json | 2 +- 9 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 packages/solid/src/useAskableRegionCapture.ts create mode 100644 packages/solid/src/useAskableTextSelectionCapture.ts create mode 100644 packages/svelte/src/useAskableRegionCapture.svelte.ts create mode 100644 packages/svelte/src/useAskableTextSelectionCapture.svelte.ts diff --git a/packages/solid/src/core.ts b/packages/solid/src/core.ts index 3cfca6ad..3c4f6425 100644 --- a/packages/solid/src/core.ts +++ b/packages/solid/src/core.ts @@ -18,3 +18,5 @@ export { useAskableStorageSource } from './useAskableStorageSource.js'; export { useAskableNotificationSource } from './useAskableNotificationSource.js'; export { useAskableCartSource } from './useAskableCartSource.js'; export { useAskableMultistepSource } from './useAskableMultistepSource.js'; +export { useAskableRegionCapture } from './useAskableRegionCapture.js'; +export { useAskableTextSelectionCapture } from './useAskableTextSelectionCapture.js'; diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index b6325b6c..0f9ef064 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -40,6 +40,8 @@ export { useAskableTimeSource } from './useAskableTimeSource.js'; export { useAskableFocusSource } from './useAskableFocusSource.js'; export { useAskableMultistepSource } from './useAskableMultistepSource.js'; export { useAskableCartSource } from './useAskableCartSource.js'; +export { useAskableRegionCapture } from './useAskableRegionCapture.js'; +export { useAskableTextSelectionCapture } from './useAskableTextSelectionCapture.js'; export { useAskableStream } from './useAskableStream.js'; export { useAskableChat } from './useAskableChat.js'; // Re-export typed meta utility from core for convenience @@ -86,6 +88,8 @@ export type { UseAskableTimeSourceOptions, UseAskableTimeSourceResult, AskableBu export type { UseAskableFocusSourceOptions, UseAskableFocusSourceResult, AskableFocusedElementSnapshot, AskableFocusSourceSnapshot } from './useAskableFocusSource.js'; export type { UseAskableMultistepSourceOptions, UseAskableMultistepSourceResult, AskableMultistepStep, AskableMultistepSourceSnapshot } from './useAskableMultistepSource.js'; export type { UseAskableCartSourceOptions, UseAskableCartSourceResult, AskableCartItem, AskableCartSourceSnapshot, AskableCartTotals } from './useAskableCartSource.js'; +export type { UseAskableRegionCaptureOptions, UseAskableRegionCaptureResult, AskableRegionCaptureSelection, AskableRegionCaptureState } from './useAskableRegionCapture.js'; +export type { UseAskableTextSelectionCaptureOptions, UseAskableTextSelectionCaptureResult, AskableTextSelectionCaptureSelection, AskableTextSelectionCaptureState } from './useAskableTextSelectionCapture.js'; export type { UseAskableViewportOptions, UseAskableViewportResult } from './useAskableViewport.js'; export type { UseAskableHistoryOptions, UseAskableHistoryResult } from './useAskableHistory.js'; export type { diff --git a/packages/solid/src/useAskableRegionCapture.ts b/packages/solid/src/useAskableRegionCapture.ts new file mode 100644 index 00000000..b675d53c --- /dev/null +++ b/packages/solid/src/useAskableRegionCapture.ts @@ -0,0 +1,127 @@ +import { createSignal, createEffect, onCleanup } from 'solid-js'; +import { createAskableRegionCapture } from '@askable-ui/core'; +import type { + AskableRegionCaptureHandle, + AskableRegionCaptureOptions, + AskableRegionCaptureSelection, + AskableRegionCaptureState, + WebContextPacket, +} from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.js'; + +export type { AskableRegionCaptureOptions, AskableRegionCaptureSelection, AskableRegionCaptureState }; + +export interface UseAskableRegionCaptureOptions + extends AskableRegionCaptureOptions, + Omit {} + +export interface UseAskableRegionCaptureResult { + ctx: ReturnType['ctx']; + active: () => boolean; + lastPacket: () => WebContextPacket | null; + lastSelection: () => AskableRegionCaptureSelection | null; + selectionState: () => AskableRegionCaptureState | null; + start(overrides?: Partial): void; + cancel(): void; + clearSelection(): void; + getSelection(): AskableRegionCaptureState | null; + destroy(): void; + isActive(): boolean; +} + +/** + * SolidJS primitive for rectangular / circle / lasso region capture. + * + * @example + * ```tsx + * const region = useAskableRegionCapture({ shape: 'rect' }); + * + * return ( + * <> + * + * + * {(packet) =>

Captured: {packet().text}

} + *
+ * + * ); + * ``` + */ +export function useAskableRegionCapture( + options: UseAskableRegionCaptureOptions = {}, +): UseAskableRegionCaptureResult { + const { ctx } = useAskable(options); + + let handle: AskableRegionCaptureHandle | null = null; + const [active, setActive] = createSignal(false); + const [lastPacket, setLastPacket] = createSignal(null); + const [lastSelection, setLastSelection] = createSignal(null); + const [selectionState, setSelectionState] = createSignal(null); + + createEffect(() => { + onCleanup(() => { handle?.destroy(); handle = null; }); + }); + + function start(overrides?: Partial): void { + handle?.destroy(); + const merged = { ...options, ...overrides }; + handle = createAskableRegionCapture(ctx, { + ...merged, + onCapture(packet, selection) { + setLastPacket(() => packet); + setLastSelection(() => selection); + setActive(merged.once === false); + options.onCapture?.(packet, selection); + }, + onSelectionChange(state) { + setSelectionState(() => state); + options.onSelectionChange?.(state); + }, + onCancel() { + setActive(false); + options.onCancel?.(); + }, + }); + handle.start(); + setActive(true); + } + + function cancel(): void { + handle?.cancel(); + handle = null; + setActive(false); + setSelectionState(null); + } + + function clearSelection(): void { + handle?.clearSelection(); + } + + function getSelection(): AskableRegionCaptureState | null { + return handle?.getSelection() ?? null; + } + + function destroy(): void { + handle?.destroy(); + handle = null; + setActive(false); + setSelectionState(null); + } + + function isActive(): boolean { + return handle?.isActive() ?? active(); + } + + return { + ctx, + active, + lastPacket, + lastSelection, + selectionState, + start, + cancel, + clearSelection, + getSelection, + destroy, + isActive, + }; +} diff --git a/packages/solid/src/useAskableTextSelectionCapture.ts b/packages/solid/src/useAskableTextSelectionCapture.ts new file mode 100644 index 00000000..45a7fbaa --- /dev/null +++ b/packages/solid/src/useAskableTextSelectionCapture.ts @@ -0,0 +1,144 @@ +import { createSignal, createEffect, onCleanup } from 'solid-js'; +import { createAskableTextSelectionCapture } from '@askable-ui/core'; +import type { + AskableTextSelectionCaptureHandle, + AskableTextSelectionCaptureOptions, + AskableTextSelectionCaptureSelection, + AskableTextSelectionCaptureState, + WebContextPacket, +} from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.js'; + +export type { + AskableTextSelectionCaptureOptions, + AskableTextSelectionCaptureSelection, + AskableTextSelectionCaptureState, +}; + +export interface UseAskableTextSelectionCaptureOptions + extends AskableTextSelectionCaptureOptions, + Omit {} + +export interface UseAskableTextSelectionCaptureResult { + ctx: ReturnType['ctx']; + active: () => boolean; + lastPacket: () => WebContextPacket | null; + lastSelection: () => AskableTextSelectionCaptureSelection | null; + selectionState: () => AskableTextSelectionCaptureState | null; + start(overrides?: Partial): void; + captureNow(overrides?: Partial): WebContextPacket | null; + cancel(): void; + clearSelection(): void; + getSelection(): AskableTextSelectionCaptureState | null; + destroy(): void; + isActive(): boolean; +} + +/** + * SolidJS primitive for capturing highlighted / selected text. + * + * @example + * ```tsx + * const sel = useAskableTextSelectionCapture(); + * sel.start(); + * + * return ( + * + * {(packet) =>

"{packet().text}"

} + *
+ * ); + * ``` + */ +export function useAskableTextSelectionCapture( + options: UseAskableTextSelectionCaptureOptions = {}, +): UseAskableTextSelectionCaptureResult { + const { ctx } = useAskable(options); + + let handle: AskableTextSelectionCaptureHandle | null = null; + const [active, setActive] = createSignal(false); + const [lastPacket, setLastPacket] = createSignal(null); + const [lastSelection, setLastSelection] = createSignal(null); + const [selectionState, setSelectionState] = createSignal(null); + + createEffect(() => { + onCleanup(() => { handle?.destroy(); handle = null; }); + }); + + function ensureHandle(overrides?: Partial): AskableTextSelectionCaptureHandle { + handle?.destroy(); + const merged = { ...options, ...overrides }; + const h = createAskableTextSelectionCapture(ctx, { + ...merged, + onCapture(packet, selection) { + setLastPacket(() => packet); + setLastSelection(() => selection); + if (merged.once) setActive(false); + options.onCapture?.(packet, selection); + }, + onSelectionChange(state) { + setSelectionState(() => state); + options.onSelectionChange?.(state); + }, + onCancel() { + handle = null; + setActive(false); + options.onCancel?.(); + }, + }); + handle = h; + return h; + } + + function start(overrides?: Partial): void { + ensureHandle(overrides).start(); + setActive(true); + } + + function captureNow(overrides?: Partial): WebContextPacket | null { + const h = handle ?? ensureHandle(overrides); + const packet = h.captureNow(overrides); + if (packet && (options.once || overrides?.once)) setActive(false); + return packet; + } + + function cancel(): void { + handle?.cancel(); + handle = null; + setActive(false); + setSelectionState(null); + } + + function clearSelection(): void { + handle?.clearSelection(); + } + + function getSelection(): AskableTextSelectionCaptureState | null { + return handle?.getSelection() ?? null; + } + + function destroy(): void { + handle?.destroy(); + handle = null; + setActive(false); + setSelectionState(null); + } + + function isActive(): boolean { + return handle?.isActive() ?? active(); + } + + return { + ctx, + active, + lastPacket, + lastSelection, + selectionState, + start, + captureNow, + cancel, + clearSelection, + getSelection, + destroy, + isActive, + }; +} diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 8b213c16..a576434d 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -54,6 +54,8 @@ "./useAskableFocusSource.svelte": "./src/useAskableFocusSource.svelte.ts", "./useAskableMultistepSource.svelte": "./src/useAskableMultistepSource.svelte.ts", "./useAskableCartSource.svelte": "./src/useAskableCartSource.svelte.ts", + "./useAskableRegionCapture.svelte": "./src/useAskableRegionCapture.svelte.ts", + "./useAskableTextSelectionCapture.svelte": "./src/useAskableTextSelectionCapture.svelte.ts", "./core": "./src/core.svelte.ts", "./extended": "./src/extended.svelte.ts" }, @@ -103,6 +105,8 @@ "src/useAskableFocusSource.svelte.ts", "src/useAskableMultistepSource.svelte.ts", "src/useAskableCartSource.svelte.ts", + "src/useAskableRegionCapture.svelte.ts", + "src/useAskableTextSelectionCapture.svelte.ts", "README.md", "src/core.svelte.ts", "src/extended.svelte.ts" diff --git a/packages/svelte/src/core.svelte.ts b/packages/svelte/src/core.svelte.ts index dd2e0aad..f74da055 100644 --- a/packages/svelte/src/core.svelte.ts +++ b/packages/svelte/src/core.svelte.ts @@ -18,3 +18,5 @@ export { useAskableStorageSource } from './useAskableStorageSource.svelte.ts'; export { useAskableNotificationSource } from './useAskableNotificationSource.svelte.ts'; export { useAskableCartSource } from './useAskableCartSource.svelte.ts'; export { useAskableMultistepSource } from './useAskableMultistepSource.svelte.ts'; +export { useAskableRegionCapture } from './useAskableRegionCapture.svelte.ts'; +export { useAskableTextSelectionCapture } from './useAskableTextSelectionCapture.svelte.ts'; diff --git a/packages/svelte/src/useAskableRegionCapture.svelte.ts b/packages/svelte/src/useAskableRegionCapture.svelte.ts new file mode 100644 index 00000000..9ed41ab4 --- /dev/null +++ b/packages/svelte/src/useAskableRegionCapture.svelte.ts @@ -0,0 +1,129 @@ +import { createAskableRegionCapture } from '@askable-ui/core'; +import type { + AskableContext, + AskableRegionCaptureHandle, + AskableRegionCaptureOptions, + AskableRegionCaptureSelection, + AskableRegionCaptureState, + WebContextPacket, +} from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.svelte.ts'; + +export type { AskableRegionCaptureOptions, AskableRegionCaptureSelection, AskableRegionCaptureState }; + +export interface UseAskableRegionCaptureOptions + extends AskableRegionCaptureOptions, + Omit {} + +export interface UseAskableRegionCapture { + readonly ctx: AskableContext; + readonly active: boolean; + readonly lastPacket: WebContextPacket | null; + readonly lastSelection: AskableRegionCaptureSelection | null; + readonly selectionState: AskableRegionCaptureState | null; + start(overrides?: Partial): void; + cancel(): void; + clearSelection(): void; + getSelection(): AskableRegionCaptureState | null; + destroy(): void; + isActive(): boolean; +} + +/** + * Svelte 5 runes-based composable for rectangular region capture. + * + * @example + * ```svelte + * + * + * + * {#if region.lastPacket} + *

Captured: {region.lastPacket.text}

+ * {/if} + * ``` + */ +export function useAskableRegionCapture( + options: UseAskableRegionCaptureOptions = {}, +): UseAskableRegionCapture { + const { ctx } = useAskable(options); + + let handle: AskableRegionCaptureHandle | null = null; + let active = $state(false); + let lastPacket = $state(null); + let lastSelection = $state(null); + let selectionState = $state(null); + + let latestOptions = options; + $effect(() => { latestOptions = options; }); + + $effect(() => { + return () => { handle?.destroy(); handle = null; }; + }); + + function start(overrides?: Partial): void { + handle?.destroy(); + const merged = { ...latestOptions, ...overrides }; + handle = createAskableRegionCapture(ctx, { + ...merged, + onCapture(packet, selection) { + lastPacket = packet; + lastSelection = selection; + active = merged.once === false; + latestOptions.onCapture?.(packet, selection); + }, + onSelectionChange(state) { + selectionState = state; + latestOptions.onSelectionChange?.(state); + }, + onCancel() { + active = false; + latestOptions.onCancel?.(); + }, + }); + handle.start(); + active = true; + } + + function cancel(): void { + handle?.cancel(); + handle = null; + active = false; + selectionState = null; + } + + function clearSelection(): void { + handle?.clearSelection(); + } + + function getSelection(): AskableRegionCaptureState | null { + return handle?.getSelection() ?? null; + } + + function destroy(): void { + handle?.destroy(); + handle = null; + active = false; + selectionState = null; + } + + function isActive(): boolean { + return handle?.isActive() ?? active; + } + + return { + ctx, + get active() { return active; }, + get lastPacket() { return lastPacket; }, + get lastSelection() { return lastSelection; }, + get selectionState() { return selectionState; }, + start, + cancel, + clearSelection, + getSelection, + destroy, + isActive, + }; +} diff --git a/packages/svelte/src/useAskableTextSelectionCapture.svelte.ts b/packages/svelte/src/useAskableTextSelectionCapture.svelte.ts new file mode 100644 index 00000000..76dd190c --- /dev/null +++ b/packages/svelte/src/useAskableTextSelectionCapture.svelte.ts @@ -0,0 +1,148 @@ +import { createAskableTextSelectionCapture } from '@askable-ui/core'; +import type { + AskableContext, + AskableTextSelectionCaptureHandle, + AskableTextSelectionCaptureOptions, + AskableTextSelectionCaptureSelection, + AskableTextSelectionCaptureState, + WebContextPacket, +} from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.svelte.ts'; + +export type { + AskableTextSelectionCaptureOptions, + AskableTextSelectionCaptureSelection, + AskableTextSelectionCaptureState, +}; + +export interface UseAskableTextSelectionCaptureOptions + extends AskableTextSelectionCaptureOptions, + Omit {} + +export interface UseAskableTextSelectionCapture { + readonly ctx: AskableContext; + readonly active: boolean; + readonly lastPacket: WebContextPacket | null; + readonly lastSelection: AskableTextSelectionCaptureSelection | null; + readonly selectionState: AskableTextSelectionCaptureState | null; + start(overrides?: Partial): void; + captureNow(overrides?: Partial): WebContextPacket | null; + cancel(): void; + clearSelection(): void; + getSelection(): AskableTextSelectionCaptureState | null; + destroy(): void; + isActive(): boolean; +} + +/** + * Svelte 5 runes-based composable for text selection capture. + * + * @example + * ```svelte + * + * + * {#if sel.lastPacket} + *

Selected: {sel.lastPacket.text}

+ * {/if} + * ``` + */ +export function useAskableTextSelectionCapture( + options: UseAskableTextSelectionCaptureOptions = {}, +): UseAskableTextSelectionCapture { + const { ctx } = useAskable(options); + + let handle: AskableTextSelectionCaptureHandle | null = null; + let active = $state(false); + let lastPacket = $state(null); + let lastSelection = $state(null); + let selectionState = $state(null); + + let latestOptions = options; + $effect(() => { latestOptions = options; }); + + $effect(() => { + return () => { handle?.destroy(); handle = null; }; + }); + + function ensureHandle(overrides?: Partial): AskableTextSelectionCaptureHandle { + handle?.destroy(); + const merged = { ...latestOptions, ...overrides }; + const h = createAskableTextSelectionCapture(ctx, { + ...merged, + onCapture(packet, selection) { + lastPacket = packet; + lastSelection = selection; + if (merged.once) active = false; + latestOptions.onCapture?.(packet, selection); + }, + onSelectionChange(state) { + selectionState = state; + latestOptions.onSelectionChange?.(state); + }, + onCancel() { + handle = null; + active = false; + latestOptions.onCancel?.(); + }, + }); + handle = h; + return h; + } + + function start(overrides?: Partial): void { + ensureHandle(overrides).start(); + active = true; + } + + function captureNow(overrides?: Partial): WebContextPacket | null { + const h = handle ?? ensureHandle(overrides); + const packet = h.captureNow(overrides); + if (packet && (latestOptions.once || overrides?.once)) active = false; + return packet; + } + + function cancel(): void { + handle?.cancel(); + handle = null; + active = false; + selectionState = null; + } + + function clearSelection(): void { + handle?.clearSelection(); + } + + function getSelection(): AskableTextSelectionCaptureState | null { + return handle?.getSelection() ?? null; + } + + function destroy(): void { + handle?.destroy(); + handle = null; + active = false; + selectionState = null; + } + + function isActive(): boolean { + return handle?.isActive() ?? active; + } + + return { + ctx, + get active() { return active; }, + get lastPacket() { return lastPacket; }, + get lastSelection() { return lastSelection; }, + get selectionState() { return selectionState; }, + start, + captureNow, + cancel, + clearSelection, + getSelection, + destroy, + isActive, + }; +} diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json index 0fd3406d..f024ee66 100644 --- a/packages/svelte/tsconfig.json +++ b/packages/svelte/tsconfig.json @@ -13,5 +13,5 @@ "skipLibCheck": true }, "include": ["src/**/*.ts"], - "exclude": ["src/useAskable.svelte.ts", "src/core.svelte.ts", "src/extended.svelte.ts", "src/__tests__"] + "exclude": ["src/useAskable.svelte.ts", "src/core.svelte.ts", "src/extended.svelte.ts", "src/useAskableRegionCapture.svelte.ts", "src/useAskableTextSelectionCapture.svelte.ts", "src/__tests__"] } From 9f2b32ee1f920fa778c633f62d34014851caca4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:52:35 +0000 Subject: [PATCH 2/2] docs: add Angular and MCP integration guides - Angular guide covers AskableService, [askable] directive, AskableModule, cart/multistep sources, agent requests, region/text capture, and SSR safety - MCP guide covers browser-local page bridge, remote WebMCP with Next.js and Express examples, authorization, client setup (Claude Desktop, Claude.ai, ChatGPT), and in-process MCP server embedding - Both guides wired into the sidebar (Framework Guides + Integrations sections) - Docs homepage updated with direct links to both guides --- site/docs/.vitepress/config.ts | 4 +- site/docs/guide/angular.md | 278 +++++++++++++++++++++++++++++++++ site/docs/guide/mcp.md | 235 ++++++++++++++++++++++++++++ site/docs/index.md | 2 + 4 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 site/docs/guide/angular.md create mode 100644 site/docs/guide/mcp.md diff --git a/site/docs/.vitepress/config.ts b/site/docs/.vitepress/config.ts index b1ffaadc..7c7fbf95 100644 --- a/site/docs/.vitepress/config.ts +++ b/site/docs/.vitepress/config.ts @@ -62,6 +62,7 @@ export default defineConfig({ { text: 'React', link: '/guide/react' }, { text: 'Vue', link: '/guide/vue' }, { text: 'Svelte', link: '/guide/svelte' }, + { text: 'Angular', link: '/guide/angular' }, { text: 'Plain JS / HTML', link: '/guide/vanilla' }, ], }, @@ -81,8 +82,9 @@ export default defineConfig({ { text: 'Integrations', items: [ - { text: 'Third-Party Libraries', link: '/guide/third-party-libraries' }, + { text: 'MCP Integration', link: '/guide/mcp' }, { text: 'CopilotKit', link: '/guide/copilotkit' }, + { text: 'Third-Party Libraries', link: '/guide/third-party-libraries' }, ], }, { diff --git a/site/docs/guide/angular.md b/site/docs/guide/angular.md new file mode 100644 index 00000000..e6b24ecc --- /dev/null +++ b/site/docs/guide/angular.md @@ -0,0 +1,278 @@ +# Angular Guide + +## Install + +```bash +npm install @askable-ui/angular @askable-ui/core +``` + +## Quick start + +Inject `AskableService` into any component and apply the `[askable]` directive to annotate elements. + +```ts +// app.component.ts +import { Component, inject } from '@angular/core'; +import { AskableService } from '@askable-ui/angular'; +import { AskableDirective } from '@askable-ui/angular'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [AskableDirective], + template: ` +
+ +
+ + + `, +}) +export class AppComponent { + revenue = '$128k'; + private readonly askable = inject(AskableService); + + async askAI() { + const prompt = this.askable.promptContext(); + await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { role: 'system', content: `UI context: ${prompt}` }, + { role: 'user', content: 'What is the user looking at?' }, + ], + }), + }); + } +} +``` + +## `AskableService` + +Provided at root by default — one context instance per app. Inject it anywhere to read focus and prompt context. + +```ts +import { inject } from '@angular/core'; +import { AskableService } from '@askable-ui/angular'; + +class MyComponent { + private readonly askable = inject(AskableService); + + // Reactive Angular signal — use in templates with promptContext() + promptContext = this.askable.promptContext; + + // Current focus signal + focus = this.askable.focus; + + // Raw AskableContext for advanced usage + ctx = this.askable.context; +} +``` + +### Signals + +| Signal | Type | Description | +|---|---|---| +| `promptContext` | `Signal` | Serialized prompt string for the current focus | +| `focus` | `Signal` | Current focused element metadata | + +### Methods + +| Method | Description | +|---|---| +| `context` | Raw `AskableContext` — for agent requests, region capture, etc. | + +### Use `promptContext` in a template + +```html +

{{ askable.promptContext() }}

+``` + +Because `promptContext` is a Signal, Angular automatically rerenders only the affected part when focus changes. + +## `[askable]` directive + +Apply to any element to annotate it with context metadata. + +```html + +
+ +
+ + +
+ +
+ + +
+ +
+``` + +## `AskableModule` (NgModule apps) + +For apps that use NgModules instead of standalone components, import `AskableModule`: + +```ts +import { NgModule } from '@angular/core'; +import { AskableModule } from '@askable-ui/angular'; + +@NgModule({ + imports: [AskableModule], + // ... +}) +export class AppModule {} +``` + +Then use `[askable]` in any template within the module. + +## Sources + +Sources expose app state (cart contents, multistep progress, form data, etc.) to the AI. Each source is an Angular injectable service. + +### Cart source + +```ts +import { Component, inject, OnInit } from '@angular/core'; +import { AskableCartSourceService } from '@askable-ui/angular'; +import type { AskableCartItem } from '@askable-ui/angular'; + +@Component({ + selector: 'app-cart', + standalone: true, + providers: [AskableCartSourceService], + template: ` +

{{ cart.snapshot?.itemCount }} items — {{ cart.snapshot?.total | currency }}

+ `, +}) +export class CartComponent implements OnInit { + readonly cart = inject(AskableCartSourceService); + + ngOnInit() { + this.cart.init({ + items: [], + totals: { currency: 'USD' }, + }); + } + + addItem(item: AskableCartItem) { + this.cart.addItem(item); + } + + removeItem(id: string) { + this.cart.removeItem(id); + } + + checkout() { + this.cart.clearCart(); + } +} +``` + +### Multistep / wizard source + +```ts +import { Component, inject, OnInit } from '@angular/core'; +import { AskableMultistepSourceService } from '@askable-ui/angular'; + +@Component({ + selector: 'app-checkout', + standalone: true, + providers: [AskableMultistepSourceService], + template: ` +

Step {{ wizard.snapshot?.currentIndex + 1 }} of {{ wizard.snapshot?.totalSteps }}

+ + + `, +}) +export class CheckoutComponent implements OnInit { + readonly wizard = inject(AskableMultistepSourceService); + + ngOnInit() { + this.wizard.init({ + steps: [ + { id: 'cart', label: 'Cart' }, + { id: 'shipping', label: 'Shipping' }, + { id: 'payment', label: 'Payment' }, + { id: 'confirm', label: 'Confirm' }, + ], + }); + } +} +``` + +### Other sources + +All sources follow the same `inject → init()` pattern: + +| Service | Purpose | +|---|---| +| `AskablePageSourceService` | Current page title, URL, description | +| `AskableFormSourceService` | Form field values and validation state | +| `AskableTableSourceService` | Table rows, columns, selection | +| `AskableNavigationSourceService` | Route history | +| `AskableUserSourceService` | Authenticated user info | +| `AskableNotificationSourceService` | Active toasts and alerts | +| `AskableErrorSourceService` | Recent errors | +| `AskableLoadingSourceService` | Loading/pending states | +| `AskableSearchSourceService` | Search query and results | + +## Agent requests + +Use `context.toAgentRequest()` to send a structured request that includes the full context packet: + +```ts +async askAI(question: string) { + const req = await this.askable.context.toAgentRequest(question, { + history: 3, + packet: true, + }); + + await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); +} +``` + +## Region and text selection capture + +Region and text selection capture use the raw `AskableContext` directly: + +```ts +import { Component, inject, OnInit, OnDestroy } from '@angular/core'; +import { createAskableRegionCapture } from '@askable-ui/core'; +import type { AskableRegionCaptureHandle } from '@askable-ui/core'; +import { AskableService } from '@askable-ui/angular'; + +@Component({ selector: 'app-annotate', standalone: true, template: ` + +` }) +export class AnnotateComponent implements OnDestroy { + private readonly askable = inject(AskableService); + private captureHandle: AskableRegionCaptureHandle | null = null; + + startCapture() { + this.captureHandle?.destroy(); + this.captureHandle = createAskableRegionCapture(this.askable.context, { + shape: 'rect', + onCapture: (packet) => { + console.log('Region captured:', packet.text); + }, + }); + this.captureHandle.start(); + } + + ngOnDestroy() { + this.captureHandle?.destroy(); + } +} +``` + +## Server-side rendering + +`AskableService` checks `typeof document !== 'undefined'` before observing the DOM, so it is safe to use in Angular Universal / SSR apps. The service constructor is a no-op on the server and activates automatically in the browser. diff --git a/site/docs/guide/mcp.md b/site/docs/guide/mcp.md new file mode 100644 index 00000000..43c0cec2 --- /dev/null +++ b/site/docs/guide/mcp.md @@ -0,0 +1,235 @@ +# MCP Integration Guide + +The `@askable-ui/mcp` package bridges the browser-captured UI context with any MCP-compatible AI client (Claude Desktop, Claude.ai, ChatGPT with extensions, custom agents). + +Two deployment modes are supported: + +| Mode | When to use | +|---|---| +| **Browser-local page bridge** | A browser extension or companion app reads context directly from the tab | +| **Remote WebMCP** | Your server exposes a stateless Streamable HTTP MCP endpoint that agents call over the network | + +## Install + +```bash +npm install @askable-ui/mcp @askable-ui/core +``` + +## Browser-local page bridge + +The page bridge exposes a `read_current_resource` MCP tool and an `askable://current` resource. A local MCP companion (e.g. a browser extension with MCP proxy support) can call this to read the current UI context without a network round trip. + +```ts +// In your app entry point (e.g. main.ts / index.ts) +import { createAskableContext } from '@askable-ui/core'; +import { createAskableMcpPageBridge } from '@askable-ui/mcp'; + +const ctx = createAskableContext(); +ctx.observe(document); + +const bridge = createAskableMcpPageBridge(ctx, { + resourceUri: 'askable://current', + name: 'My App UI Context', +}); + +// The bridge installs a window message listener that local companions use. +// Call bridge.destroy() when tearing down. +``` + +The companion receives a resource-shaped JSON object or a prompt-ready string depending on the request format. + +### Prompt-ready vs structured output + +```ts +// Returns a plain string like: +// "User is focused on: metric: revenue, value: $128k, period: Q3" +bridge.promptContext(); + +// Returns a structured WebContextPacket for programmatic use +bridge.contextPacket(); +``` + +## Remote WebMCP (server-side) + +Expose UI context as a stateless Streamable HTTP MCP endpoint that any MCP client can connect to. + +### Next.js example + +```ts +// app/api/mcp/route.ts +import { createAskableMcpWebHandler } from '@askable-ui/mcp'; +import { createAskableContext } from '@askable-ui/core'; + +const ctx = createAskableContext(); + +// In production, ctx is populated by packets sent from the browser client. +// The browser calls ctx.push() or ctx.select() before the user sends a message. + +const handler = createAskableMcpWebHandler({ + provider: { + getContext: () => ctx.toContextPacketAsync(), + }, + cors: { + origin: ['https://claude.ai', 'https://chat.openai.com'], + }, +}); + +export const GET = handler; +export const POST = handler; +``` + +### Express example + +```ts +import express from 'express'; +import { createAskableMcpWebHandler } from '@askable-ui/mcp'; +import { createAskableContext } from '@askable-ui/core'; + +const app = express(); +const ctx = createAskableContext(); + +const handler = createAskableMcpWebHandler({ + provider: { + getContext: () => ctx.toContextPacketAsync(), + }, + cors: { origin: true }, +}); + +app.all('/api/mcp', async (req, res) => { + const webRequest = new Request(`http://localhost${req.url}`, { + method: req.method, + headers: req.headers as HeadersInit, + body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined, + }); + const webResponse = await handler(webRequest); + res.status(webResponse.status); + webResponse.headers.forEach((v, k) => res.setHeader(k, v)); + res.send(await webResponse.text()); +}); +``` + +### Authorization + +Use the `authorize` callback to validate bearer tokens or API keys: + +```ts +const handler = createAskableMcpWebHandler({ + provider: { getContext: () => ctx.toContextPacketAsync() }, + authorize: async (request) => { + const token = request.headers.get('authorization')?.replace('Bearer ', ''); + if (token !== process.env.MCP_SECRET) { + return new Response('Unauthorized', { status: 401 }); + } + // Return void / true to allow the request through + }, + cors: { + origin: ['https://claude.ai'], + credentials: true, + }, +}); +``` + +## Connecting clients + +### Claude Desktop + +Add your endpoint to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "my-app": { + "url": "https://your-app.com/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_SECRET" + } + } + } +} +``` + +### Claude.ai (claude.ai/code) + +In Claude.ai settings → Integrations, add the MCP server URL. Claude will call `read_current_resource` to read the current UI state whenever it needs page context. + +### ChatGPT plugins / connectors + +Point the ChatGPT connector at your `/api/mcp` endpoint. The MCP server exposes a `read_current_resource` tool that ChatGPT's function-calling layer will invoke. + +## MCP tools exposed + +The MCP server exposes a single tool: + +| Tool | Description | +|---|---| +| `read_current_resource` | Returns the current UI context as a `WebContextPacket` or prompt string | + +And a resource: + +| Resource | URI | Description | +|---|---|---| +| Current context | `askable://current` | Structured JSON packet of the focused UI element and active sources | + +## createAskableMcpServer (embedding MCP in-process) + +If you want to embed an MCP server directly inside your Node.js process (e.g. for a CLI tool or Electron app): + +```ts +import { createAskableMcpServer } from '@askable-ui/mcp'; +import { createAskableContext } from '@askable-ui/core'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const ctx = createAskableContext(); + +const server = createAskableMcpServer({ + name: 'my-app-context', + version: '1.0.0', + provider: { + getContext: () => ctx.toContextPacketAsync(), + }, +}); + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +## Validating agent requests + +When your server receives requests from AI agents that include a serialized context packet, use `isAskableAgentRequest` to validate and unwrap them: + +```ts +import { isAskableAgentRequest } from '@askable-ui/core'; + +app.post('/api/chat', async (req, res) => { + const body = req.body; + + if (isAskableAgentRequest(body)) { + const { question, packet, sources } = body; + // packet is a validated WebContextPacket + // sources contains per-source context + const context = packet ? JSON.stringify(packet) : body.context; + // ... pass to your LLM + } +}); +``` + +## React example with page bridge + +```tsx +import { useEffect } from 'react'; +import { useAskable } from '@askable-ui/react'; +import { createAskableMcpPageBridge } from '@askable-ui/mcp'; + +function App() { + const { ctx } = useAskable(); + + useEffect(() => { + const bridge = createAskableMcpPageBridge(ctx, { + resourceUri: 'askable://current', + }); + return () => bridge.destroy(); + }, [ctx]); + + return ; +} +``` diff --git a/site/docs/index.md b/site/docs/index.md index 04370163..2258ec27 100644 --- a/site/docs/index.md +++ b/site/docs/index.md @@ -114,6 +114,8 @@ Start here: - [What’s New in v0.15.0](/guide/whats-new) - [Context Packets](/guide/context) - [React interaction patterns](/guide/react#region-circle-and-lasso-capture) +- [Angular guide](/guide/angular) +- [MCP integration guide](/guide/mcp) - [AI SDK integration patterns](/examples/ai-sdk) - [CopilotKit guide](/guide/copilotkit)