diff --git a/README.md b/README.md index 3f39f9c..0dcffca 100644 --- a/README.md +++ b/README.md @@ -158,13 +158,13 @@ askable-ui is the context layer. It doesn't replace your LLM SDK — it gives it | Package | Version | Use when | |---|---|---| | [`@askable-ui/core`](https://www.npmjs.com/package/@askable-ui/core) | [![npm](https://img.shields.io/npm/v/@askable-ui/core?color=4f46e5)](https://www.npmjs.com/package/@askable-ui/core) | Vanilla JS, custom framework, or as a peer dep | -| [`@askable-ui/context`](https://www.npmjs.com/package/@askable-ui/context) | npm package | Shared Context packet types, schema, and validators | +| [`@askable-ui/context`](https://www.npmjs.com/package/@askable-ui/context) | [![npm](https://img.shields.io/npm/v/@askable-ui/context?color=4f46e5)](https://www.npmjs.com/package/@askable-ui/context) | Shared Context packet types, schema, and validators | | [`@askable-ui/react`](https://www.npmjs.com/package/@askable-ui/react) | [![npm](https://img.shields.io/npm/v/@askable-ui/react?color=4f46e5)](https://www.npmjs.com/package/@askable-ui/react) | React 18+ | | [`@askable-ui/react-native`](https://www.npmjs.com/package/@askable-ui/react-native) | [![npm](https://img.shields.io/npm/v/@askable-ui/react-native?color=4f46e5)](https://www.npmjs.com/package/@askable-ui/react-native) | React Native (initial press-driven adapter) | | [`@askable-ui/vue`](https://www.npmjs.com/package/@askable-ui/vue) | [![npm](https://img.shields.io/npm/v/@askable-ui/vue?color=4f46e5)](https://www.npmjs.com/package/@askable-ui/vue) | Vue 3 | | [`@askable-ui/svelte`](https://www.npmjs.com/package/@askable-ui/svelte) | [![npm](https://img.shields.io/npm/v/@askable-ui/svelte?color=4f46e5)](https://www.npmjs.com/package/@askable-ui/svelte) | Svelte 4 & 5 | -| [`@askable-ui/mcp`](https://www.npmjs.com/package/@askable-ui/mcp) | npm package | MCP bridge for exposing Context packets to agents | -| [`@askable-ui/create-app`](https://www.npmjs.com/package/@askable-ui/create-app) | npm package | React + Vite + CopilotKit starter scaffold | +| [`@askable-ui/mcp`](https://www.npmjs.com/package/@askable-ui/mcp) | [![npm](https://img.shields.io/npm/v/@askable-ui/mcp?color=4f46e5)](https://www.npmjs.com/package/@askable-ui/mcp) | MCP bridge for exposing Context packets to agents | +| [`@askable-ui/create-app`](https://www.npmjs.com/package/@askable-ui/create-app) | [![npm](https://img.shields.io/npm/v/@askable-ui/create-app?color=4f46e5)](https://www.npmjs.com/package/@askable-ui/create-app) | React + Vite + CopilotKit starter scaffold |
Framework quick starts diff --git a/examples/analytics-dashboard-react/components/askable-interaction-toolbar.tsx b/examples/analytics-dashboard-react/components/askable-interaction-toolbar.tsx index a0bdf20..7d36bef 100644 --- a/examples/analytics-dashboard-react/components/askable-interaction-toolbar.tsx +++ b/examples/analytics-dashboard-react/components/askable-interaction-toolbar.tsx @@ -61,7 +61,6 @@ export function AskableInteractionToolbar() { ctx, source: { app: "analytics-dashboard-demo" }, onCapture(packet, selection) { - const label = `Highlighted text: ${selection.text}` ctx.push( { capture: packet.capture.mode, @@ -69,7 +68,7 @@ export function AskableInteractionToolbar() { length: selection.text.length, ...(selection.bounds ? { bounds: selection.bounds } : {}), }, - label, + selection.text, ) setStatus(`Sent ${selection.text.length} selected characters to chat context.`) resumeImplicitFocus() diff --git a/examples/analytics-dashboard-react/components/dashboard/chat-sidebar.tsx b/examples/analytics-dashboard-react/components/dashboard/chat-sidebar.tsx index 34f50af..077d6bf 100644 --- a/examples/analytics-dashboard-react/components/dashboard/chat-sidebar.tsx +++ b/examples/analytics-dashboard-react/components/dashboard/chat-sidebar.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect } from "react" -import { Send, Sparkles, X, Maximize2, Minimize2, Eye } from "lucide-react" +import { Send, Sparkles, X, Maximize2, Minimize2, Eye, Quote } from "lucide-react" import { useAskable } from "@askable-ui/react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -15,6 +15,8 @@ interface Message { timestamp: Date context?: string isContext?: boolean + contextKind?: "text" | "ui" + selectedText?: string } const initialMessages: Message[] = [ @@ -33,7 +35,9 @@ export function ChatSidebar() { const [isMinimized, setIsMinimized] = useState(false) const [showContext, setShowContext] = useState(true) - const { promptContext } = useAskable({ inspector: true }); + const { promptContext, focus } = useAskable({ inspector: true }) + const hasSelectedText = isTextSelectionMeta(focus?.meta) + const selectedText = hasSelectedText ? focus?.text ?? "" : "" const handleSend = () => { if (!input.trim()) return @@ -44,6 +48,8 @@ export function ChatSidebar() { content: input, timestamp: new Date(), context: promptContext || undefined, + contextKind: hasSelectedText ? "text" : "ui", + selectedText, } setMessages((prev) => [...prev, userMessage]) @@ -56,6 +62,8 @@ export function ChatSidebar() { content: promptContext, timestamp: new Date(), isContext: true, + contextKind: hasSelectedText ? "text" : "ui", + selectedText, } setMessages((prev) => [...prev, assistantMessage]) } @@ -119,26 +127,43 @@ export function ChatSidebar() { {/* Context Panel - Main Feature Showcase */}
{promptContext ? ( -
+
-
- +
+ {hasSelectedText ? : }
- Context Captured -

AI can see this element

+ + {hasSelectedText ? "Selected Text Captured" : "Context Captured"} + +

+ {hasSelectedText ? "This exact highlight will be sent to chat" : "AI can see this element"} +

{showContext && ( - + )}
) : ( @@ -176,13 +201,20 @@ export function ChatSidebar() { )}
{message.context && message.role === "user" && ( -
- - with context +
+ {message.contextKind === "text" ? : } + {message.contextKind === "text" ? "with selected text" : "with context"}
)} {message.isContext ? ( - + ) : (
| string[] | nul return lines.length > 1 ? lines : null } +function isTextSelectionMeta(meta: unknown) { + return Boolean( + meta && + typeof meta === "object" && + (meta as Record).capture === "text-selection" + ) +} + function renderContextValue(value: unknown, tone: "panel" | "card" = "panel") { if (value !== null && typeof value === "object") { return ( @@ -266,7 +306,28 @@ function renderContextValue(value: unknown, tone: "panel" | "card" = "panel") { return {String(value)} } -function ContextPanel({ content }: { content: string }) { +function SelectedTextPreview({ text, tone = "panel" }: { text: string; tone?: "panel" | "card" }) { + return ( +
+
+ + Selected text passed to chat +
+
+ {text} +
+
+ ) +} + +function ContextPanel({ content, selectedText }: { content: string; selectedText?: string }) { + if (selectedText) return + const parsed = parseContext(content) if (parsed && !Array.isArray(parsed)) { @@ -299,7 +360,17 @@ function ContextPanel({ content }: { content: string }) { ) } -function ContextCard({ content }: { content: string }) { +function ContextCard({ + content, + kind = "ui", + selectedText, +}: { + content: string + kind?: "text" | "ui" + selectedText?: string +}) { + if (kind === "text" && selectedText) return + const parsed = parseContext(content) const entries = parsed && !Array.isArray(parsed) ? Object.entries(parsed) : null const lines = Array.isArray(parsed) ? parsed : content.split(/\s*—\s*/).filter(Boolean) diff --git a/examples/analytics-dashboard-react/package.json b/examples/analytics-dashboard-react/package.json index c86ad19..41a6ea8 100644 --- a/examples/analytics-dashboard-react/package.json +++ b/examples/analytics-dashboard-react/package.json @@ -9,7 +9,7 @@ "start": "next start" }, "dependencies": { - "@askable-ui/react": "^0.11.1", + "@askable-ui/react": "^0.12.0", "@hookform/resolvers": "^3.9.1", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", diff --git a/examples/react-native-expo/package.json b/examples/react-native-expo/package.json index a5a96f5..0f6150d 100644 --- a/examples/react-native-expo/package.json +++ b/examples/react-native-expo/package.json @@ -1,7 +1,7 @@ { "name": "@askable-ui/example-react-native-expo", "private": true, - "version": "0.0.0", + "version": "0.12.0", "main": "expo/AppEntry", "scripts": { "start": "expo start", @@ -11,8 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@askable-ui/core": "^0.11.1", - "@askable-ui/react-native": "^0.11.1", + "@askable-ui/core": "^0.12.0", + "@askable-ui/react-native": "^0.12.0", "@react-navigation/native": "^7.2.5", "@react-navigation/native-stack": "^7.16.0", "expo": "^55.0.26", diff --git a/package-lock.json b/package-lock.json index e5f357f..5a21d9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "askable", - "version": "0.11.1", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "askable", - "version": "0.11.1", + "version": "0.12.0", "workspaces": [ "packages/*" ], @@ -4844,7 +4844,7 @@ }, "packages/context": { "name": "@askable-ui/context", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "devDependencies": { "typescript": "^6.0.3", @@ -4867,10 +4867,10 @@ }, "packages/core": { "name": "@askable-ui/core", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { - "@askable-ui/context": "^0.11.1" + "@askable-ui/context": "^0.12.0" }, "devDependencies": { "jsdom": "^29.1.1", @@ -4894,7 +4894,7 @@ }, "packages/create-askable-app": { "name": "@askable-ui/create-app", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "bin": { "create-askable-app": "bin/create-askable-app.js" @@ -4902,11 +4902,11 @@ }, "packages/mcp": { "name": "@askable-ui/mcp", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { - "@askable-ui/context": "^0.11.1", - "@askable-ui/core": "^0.11.1", + "@askable-ui/context": "^0.12.0", + "@askable-ui/core": "^0.12.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, @@ -4931,10 +4931,10 @@ }, "packages/react": { "name": "@askable-ui/react", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { - "@askable-ui/core": "^0.11.1" + "@askable-ui/core": "^0.12.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.0", @@ -4954,10 +4954,10 @@ }, "packages/react-native": { "name": "@askable-ui/react-native", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { - "@askable-ui/core": "^0.11.1" + "@askable-ui/core": "^0.12.0" }, "devDependencies": { "@types/react": "^19.2.15", @@ -5129,10 +5129,10 @@ }, "packages/svelte": { "name": "@askable-ui/svelte", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { - "@askable-ui/core": "^0.11.1" + "@askable-ui/core": "^0.12.0" }, "devDependencies": { "@askable-ui/core": "*", @@ -5163,10 +5163,10 @@ }, "packages/vue": { "name": "@askable-ui/vue", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { - "@askable-ui/core": "^0.11.1" + "@askable-ui/core": "^0.12.0" }, "devDependencies": { "@askable-ui/core": "*", diff --git a/package.json b/package.json index 14c05a9..4c4e865 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "askable", - "version": "0.11.1", + "version": "0.12.0", "private": true, "workspaces": [ "packages/*" diff --git a/packages/context/package.json b/packages/context/package.json index 1eecf35..0080c85 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -1,6 +1,6 @@ { "name": "@askable-ui/context", - "version": "0.11.1", + "version": "0.12.0", "description": "Open Context packet types and schema for AI-native interfaces", "type": "module", "main": "./dist/index.js", diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index a54e39d..a56fe89 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -82,6 +82,7 @@ export interface WebContextSurrounding { nearby?: WebContextTarget[]; visible?: WebContextTarget[]; history?: WebContextTarget[]; + sources?: WebContextTarget[]; } export interface WebContextPrivacy { @@ -180,6 +181,7 @@ export const webContextPacketSchema = { nearby: { type: 'array', items: { $ref: '#/$defs/target' } }, visible: { type: 'array', items: { $ref: '#/$defs/target' } }, history: { type: 'array', items: { $ref: '#/$defs/target' } }, + sources: { type: 'array', items: { $ref: '#/$defs/target' } }, }, }, privacy: { diff --git a/packages/core/README.md b/packages/core/README.md index 277c086..6e0d9dc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -48,7 +48,11 @@ Use `createAskableRegionCapture()` when the user should draw a page region, circle an area, or lasso a freehand shape and send it as structured context. ```ts -import { createAskableContext, createAskableRegionCapture } from '@askable-ui/core'; +import { + ASKABLE_REGION_CAPTURE_THEME, + createAskableContext, + createAskableRegionCapture, +} from '@askable-ui/core'; const ctx = createAskableContext({ viewport: true }); ctx.observe(document); @@ -58,6 +62,7 @@ const capture = createAskableRegionCapture(ctx, { intent: 'explain this selected area', includeViewport: true, theme: { + ...ASKABLE_REGION_CAPTURE_THEME, lassoStrokeWidth: 4, lassoGlowRadius: 12, }, @@ -72,8 +77,10 @@ capture.start(); The packet uses `capture.mode` of `region`, `circle`, or `lasso`, marks consent as explicit, and includes the selected geometry in `target.bounds`. Lasso captures also include `target.metadata.points` for the freehand path. -The built-in lasso overlay uses a solid gradient freehand stroke by default; -pass `theme` to align the overlay with your app. +The built-in lasso overlay uses `ASKABLE_REGION_CAPTURE_THEME` by default; pass +`theme` to override any overlay, selection, or lasso style for your app. +Set `once: false` to keep the overlay mounted for repeated captures. The handle +reports active until `cancel()` or `destroy()` runs. ## Text Selection Capture @@ -238,6 +245,58 @@ ctx.toPromptContext({ maxTokens: 50 }); | `textLabel` | `string` | `'value'` | Label for text field in natural format | | `maxTokens` | `number` | — | Approximate token budget. Uses a 4 chars/token estimate. Truncates output and appends `[truncated]` if exceeded. | +#### `registerSource(id, source)` + +Register app-owned context that is not fully represented in the DOM: paginated +tables, virtualized lists, documents, maps, charts, calendars, canvases, file +trees, or custom product state. + +```ts +import { createAskableCollectionSource } from '@askable-ui/core'; + +const accountsSource = ctx.registerSource('accounts', createAskableCollectionSource({ + describe: 'Customer accounts matching the active filters', + getState: () => ({ filters, sort, page, pageSize, totalCount }), + getVisibleItems: () => table.getVisibleRows(), + getSelectedItems: ({ selection }) => getAccountsByIds(selection), + getItems: () => accountStore.getAllMatching({ filters, sort }), + getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), + maxItems: 50, + sanitizeItem: redactAccountFields, + sanitize: (source) => ({ + ...source, + state: redactFilterState(source.state), + }), +})); + +const prompt = await ctx.toPromptContextAsync({ + sources: [{ id: 'accounts', mode: 'all', maxItems: 20, timeoutMs: 750 }], + sourceErrorMode: 'include', +}); + +table.onStateChange(() => { + accountsSource.notifyChanged(); +}); +``` + +Source resolvers let Askable capture what the user meant while your app supplies +what it knows. Use `createAskableSource()` for arbitrary documents, charts, +maps, canvases, and product state. Use `createAskableCollectionSource()` when a +list, grid, table, board, or search result has more data than the DOM currently +renders. Failed or timed-out sources are represented with a safe unavailable +marker by default; use `sourceErrorMode: 'omit'` or `'throw'` for stricter +runtimes. + +Call `handle.notifyChanged()` or `ctx.notifySourceChanged('accounts')` when +filters, pagination, query data, or selected records change without a DOM focus +change. Async subscribers re-resolve matching sources automatically. Stale +handles from unmounted or replaced components cannot unregister or notify a +newer source with the same id. + +Use `ctx.hasSource(id)` and `ctx.listSources()` to drive source pickers, +diagnostics, or chat controls without resolving source data. `listSources()` +returns each source id, optional kind, registration time, and last update time. + #### `toHistoryContext(limit?: number, options?: AskablePromptContextOptions): string` Serializes the focus history (newest first) into a prompt-ready string with numbered entries. Accepts the same `AskablePromptContextOptions` as `toPromptContext()`, including `maxTokens`. Returns `'No interaction history.'` when no interactions have occurred. @@ -317,6 +376,62 @@ unsubscribe(); Use `debounce` to coalesce rapid focus changes while a response is streaming. +#### `toAgentRequest(question, options?): Promise` + +Package a user question with source-backed context for chat and agent +transports. The returned object is JSON-ready and includes the question, +`toContextAsync()` output, serialized focus, optional Context packet, timestamp, +and optional app metadata. + +```ts +const request = await ctx.toAgentRequest('Why did this metric change?', { + requestId: crypto.randomUUID(), + history: 3, + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], + packet: true, + metadata: { route: '/dashboard' }, +}); + +await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), +}); +``` + +For "select first, then ask" flows, pass an existing `WebContextPacket` from a +region, circle, lasso, or text selection capture as `packet`. Askable attaches +that exact packet to the request while still generating the prompt-ready context +string from the current focus and registered sources. + +#### `subscribeAsync(callback, options?): () => void` + +Subscribe to source-backed context updates. The callback receives +`ctx.toContextAsync()` output, so registered app sources can be included in live +chat or streaming transports. + +```ts +const unsubscribe = ctx.subscribeAsync(async (context, focus) => { + await streamTransport.send({ + type: 'ui-context', + context, + focusedMeta: focus?.meta ?? null, + }); +}, { + history: 3, + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], + debounce: 100, + onError(error) { + reportContextError(error); + }, +}); +``` + +Async subscriptions rerun when focus changes, clear is called, or a matching +source calls `notifyChanged()`. They ignore stale source results when a newer +focus or source update happens before a previous resolver finishes. Use +`emitInitial: true` to send the current context immediately after registration. + #### `select(element: HTMLElement): void` Programmatically set focus to any element, as if the user had interacted with it. Useful for "Ask AI" buttons that explicitly set context before opening a chat. @@ -405,15 +520,12 @@ export function App() { const ctx = useAskable(handleFocus); async function askAssistant(question: string) { - const context = ctx.toPromptContext(); const response = await fetch('/api/chat', { method: 'POST', - body: JSON.stringify({ - messages: [ - { role: 'system', content: `UI context: ${context}` }, - { role: 'user', content: question }, - ], - }), + body: JSON.stringify(await ctx.toAgentRequest(question, { + history: 3, + packet: true, + })), }); return response.json(); } diff --git a/packages/core/package.json b/packages/core/package.json index 3b83e7c..6e25d42 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@askable-ui/core", - "version": "0.11.1", + "version": "0.12.0", "description": "Framework-agnostic context tracker for LLM-aware UIs", "type": "module", "main": "./dist/index.js", @@ -38,7 +38,7 @@ "homepage": "https://askable-ui.com", "license": "MIT", "dependencies": { - "@askable-ui/context": "^0.11.1" + "@askable-ui/context": "^0.12.0" }, "devDependencies": { "jsdom": "^29.1.1", diff --git a/packages/core/src/__tests__/capture.test.ts b/packages/core/src/__tests__/capture.test.ts index e718842..77127c8 100644 --- a/packages/core/src/__tests__/capture.test.ts +++ b/packages/core/src/__tests__/capture.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; -import { createAskableContext, createAskableRegionCapture } from '../index.js'; +import { ASKABLE_REGION_CAPTURE_THEME, createAskableContext, createAskableRegionCapture } from '../index.js'; function pointerEvent(type: string, x: number, y: number): PointerEvent { const event = new MouseEvent(type, { @@ -179,18 +179,45 @@ describe('createAskableRegionCapture', () => { expect(polyline.getAttribute('fill')).toBe('none'); expect(polyline.getAttribute('stroke')).toBe('url(#askable-region-capture-lasso-gradient)'); expect(polyline.getAttribute('stroke-width')).toBe('3'); - expect(polyline.style.filter).toBe('drop-shadow(0 0 8px rgba(79,70,229,0.35))'); + expect(polyline.style.filter).toBe('drop-shadow(0 0 8px rgba(124,58,237,0.16))'); expect(stops).toEqual([ - { offset: '0%', color: '#06b6d4' }, - { offset: '38%', color: '#4f46e5' }, - { offset: '70%', color: '#a855f7' }, - { offset: '100%', color: '#22c55e' }, + ...ASKABLE_REGION_CAPTURE_THEME.lassoGradientStops.map((stop) => ({ + offset: stop.offset, + color: stop.color, + })), ]); capture.destroy(); ctx.destroy(); }); + it('keeps repeated capture handles active when once is false', () => { + const ctx = createAskableContext(); + const onCapture = vi.fn(); + const capture = createAskableRegionCapture(ctx, { + once: false, + onCapture, + }); + + expect(capture.isActive()).toBe(false); + + capture.start(); + expect(capture.isActive()).toBe(true); + + const overlay = document.getElementById('askable-region-capture')!; + overlay.dispatchEvent(pointerEvent('pointerdown', 20, 30)); + overlay.dispatchEvent(pointerEvent('pointermove', 80, 90)); + overlay.dispatchEvent(pointerEvent('pointerup', 80, 90)); + + expect(onCapture).toHaveBeenCalledTimes(1); + expect(document.getElementById('askable-region-capture')).toBe(overlay); + expect(capture.isActive()).toBe(true); + + capture.destroy(); + expect(capture.isActive()).toBe(false); + ctx.destroy(); + }); + it('allows consumers to theme lasso capture styling', () => { const ctx = createAskableContext(); const capture = createAskableRegionCapture(ctx, { diff --git a/packages/core/src/__tests__/context.test.ts b/packages/core/src/__tests__/context.test.ts index b8b2c83..667cee1 100644 --- a/packages/core/src/__tests__/context.test.ts +++ b/packages/core/src/__tests__/context.test.ts @@ -85,6 +85,8 @@ describe('createAskableContext', () => { expect(typeof (ctx as any).serializeFocus).toBe('function'); expect(typeof (ctx as any).getVisibleElements).toBe('function'); expect(typeof (ctx as any).toViewportContext).toBe('function'); + expect(typeof ctx.hasSource).toBe('function'); + expect(typeof ctx.listSources).toBe('function'); expect(typeof ctx.subscribe).toBe('function'); expect(typeof ctx.destroy).toBe('function'); ctx.destroy(); @@ -167,6 +169,266 @@ describe('createAskableContext', () => { cleanup(second); }); + it('subscribes to source-backed async context updates', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { + kind: 'collection', + getState: () => ({ filter: 'enterprise' }), + resolve: ({ mode }) => ({ mode, totalMatching: 12 }), + }); + + const received = new Promise<[string, unknown]>((resolve) => { + ctx.subscribeAsync((context, focus) => { + resolve([context, focus?.meta]); + }, { + history: 1, + sources: ['accounts'], + }); + }); + + ctx.push({ widget: 'accounts-table' }, 'Accounts'); + + const [context, meta] = await received; + expect(context).toContain('Current: User is focused on: — widget: accounts-table'); + expect(context).toContain('Context sources'); + expect(context).toContain('"filter":"enterprise"'); + expect(context).toContain('"totalMatching":12'); + expect(meta).toEqual({ widget: 'accounts-table' }); + + ctx.destroy(); + }); + + it('refreshes async subscriptions when a registered source changes', async () => { + const ctx = createAskableContext(); + ctx.push({ widget: 'accounts-table' }, 'Accounts'); + let totalMatching = 12; + const handle = ctx.registerSource('accounts', { + kind: 'collection', + resolve: ({ mode }) => ({ mode, totalMatching }), + }); + + const received: string[] = []; + const receivedSecond = new Promise((resolve) => { + ctx.subscribeAsync((context) => { + received.push(context); + if (context.includes('"totalMatching":24')) resolve(); + }, { + emitInitial: true, + sources: ['accounts'], + }); + }); + + await vi.waitFor(() => expect(received[0]).toContain('"totalMatching":12')); + totalMatching = 24; + handle.notifyChanged(); + + await receivedSecond; + expect(received).toHaveLength(2); + expect(received[1]).toContain('widget: accounts-table'); + expect(received[1]).toContain('"totalMatching":24'); + + ctx.destroy(); + }); + + it('does not refresh async subscriptions for unrelated source changes', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { + resolve: () => ({ totalMatching: 12 }), + }); + const calendar = ctx.registerSource('calendar', { + resolve: () => ({ events: 3 }), + }); + const received: string[] = []; + + ctx.subscribeAsync((context) => { + received.push(context); + }, { + emitInitial: true, + sources: ['accounts'], + }); + + await vi.waitFor(() => expect(received).toHaveLength(1)); + calendar.notifyChanged(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(received).toHaveLength(1); + expect(received[0]).toContain('accounts'); + expect(received[0]).not.toContain('calendar'); + + ctx.destroy(); + }); + + it('ignores stale async source results after a newer focus update', async () => { + const ctx = createAskableContext(); + let resolveFirst: ((value: unknown) => void) | undefined; + ctx.registerSource('active-panel', { + resolve: ({ focus }) => { + const widget = typeof focus?.meta === 'object' ? focus.meta.widget : undefined; + if (widget === 'first') { + return new Promise((resolve) => { + resolveFirst = resolve; + }).then(() => ({ widget })); + } + return { widget }; + }, + }); + + const received: string[] = []; + const receivedSecond = new Promise((resolve) => { + ctx.subscribeAsync((context) => { + received.push(context); + if (context.includes('"widget":"second"')) resolve(); + }, { + sources: ['active-panel'], + }); + }); + + ctx.push({ widget: 'first' }, 'First panel'); + ctx.push({ widget: 'second' }, 'Second panel'); + + await receivedSecond; + resolveFirst?.({}); + await Promise.resolve(); + await Promise.resolve(); + + expect(received).toHaveLength(1); + expect(received[0]).toContain('widget: second'); + expect(received[0]).toContain('"widget":"second"'); + + ctx.destroy(); + }); + + it('reports async subscription errors without calling the subscriber', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { + resolve: () => { + throw new Error('resolver failed'); + }, + }); + + const onContext = vi.fn(); + const error = new Promise((resolve) => { + ctx.subscribeAsync(onContext, { + sources: ['accounts'], + sourceErrorMode: 'throw', + onError: resolve, + }); + }); + + ctx.push({ widget: 'accounts-table' }, 'Accounts'); + + await expect(error).resolves.toBeInstanceOf(Error); + expect(onContext).not.toHaveBeenCalled(); + + ctx.destroy(); + }); + + it('packages a user question with source-backed context for agent requests', async () => { + const ctx = createAskableContext(); + ctx.push({ widget: 'accounts-table', debug: 'internal' }, 'Accounts'); + ctx.registerSource('accounts', { + kind: 'collection', + getState: () => ({ filter: 'enterprise' }), + resolve: ({ mode }) => ({ mode, totalMatching: 12 }), + }); + + const request = await ctx.toAgentRequest('Which accounts need follow-up?', { + requestId: 'req_123', + metadata: { route: '/accounts' }, + history: 1, + sources: ['accounts'], + excludeKeys: ['debug'], + packet: true, + }); + + expect(request.requestId).toBe('req_123'); + expect(request.question).toBe('Which accounts need follow-up?'); + expect(request.context).toContain('Current: User is focused on: — widget: accounts-table'); + expect(request.context).toContain('Context sources'); + expect(request.context).toContain('"totalMatching":12'); + expect(request.context).not.toContain('internal'); + expect(request.focus?.meta).toEqual({ widget: 'accounts-table' }); + expect(request.packet?.target?.metadata).toEqual({ widget: 'accounts-table' }); + expect(request.packet?.surrounding?.sources?.[0]).toMatchObject({ + label: 'accounts', + role: 'collection', + metadata: { + id: 'accounts', + mode: 'summary', + state: { filter: 'enterprise' }, + data: { mode: 'summary', totalMatching: 12 }, + }, + }); + expect(request.metadata).toEqual({ route: '/accounts' }); + expect(typeof request.timestamp).toBe('number'); + + ctx.destroy(); + }); + + it('allows explicit packet options in agent request payloads', async () => { + const ctx = createAskableContext(); + ctx.push({ widget: 'chart' }, 'Revenue'); + ctx.registerSource('chart-data', { + resolve: () => ({ points: 12 }), + }); + + const request = await ctx.toAgentRequest('Explain this chart', { + sources: ['chart-data'], + packet: { + intent: 'inspect chart', + includeText: false, + sources: ['chart-data'], + privacy: { consent: 'explicit' }, + }, + }); + + expect(request.context).toContain('Context sources'); + expect(request.packet?.capture.intent).toBe('inspect chart'); + expect(request.packet?.privacy.consent).toBe('explicit'); + expect(request.packet?.target?.text).toBeUndefined(); + expect(request.packet?.surrounding?.sources?.[0].metadata).toMatchObject({ + id: 'chart-data', + data: { points: 12 }, + }); + + ctx.destroy(); + }); + + it('accepts an existing capture packet in agent request payloads', async () => { + const ctx = createAskableContext(); + ctx.push({ capture: 'lasso' }, 'lasso selected 1 dashboard item'); + const packet = ctx.toContextPacket({ + mode: 'lasso', + gesture: 'drag', + intent: 'ask about selected accounts', + target: { + label: 'lasso selection', + text: 'Acme Corp', + bounds: { x: 10, y: 20, width: 120, height: 80 }, + metadata: { + selectedItems: [{ company: 'Acme Corp', mrr: '$8,400' }], + }, + }, + privacy: { consent: 'explicit' }, + }); + + const request = await ctx.toAgentRequest('Why is this account healthy?', { + packet, + metadata: { source: 'selection-composer' }, + }); + + expect(request.packet).toBe(packet); + expect(request.packet?.capture.mode).toBe('lasso'); + expect(request.packet?.capture.intent).toBe('ask about selected accounts'); + expect(request.packet?.target?.metadata).toEqual({ + selectedItems: [{ company: 'Acme Corp', mrr: '$8,400' }], + }); + expect(request.context).toContain('lasso selected 1 dashboard item'); + expect(request.metadata).toEqual({ source: 'selection-composer' }); + + ctx.destroy(); + }); + it('getFocus() returns null before any interaction', () => { const ctx = createAskableContext(); ctx.observe(document); @@ -1289,6 +1551,350 @@ describe('createAskableContext', () => { }); }); + describe('context sources', () => { + it('registers and resolves app-owned source context', async () => { + const ctx = createAskableContext(); + const handle = ctx.registerSource('accounts', { + kind: 'collection', + describe: 'Customer accounts', + getState: () => ({ page: 2, pageSize: 25, totalCount: 80 }), + resolve: ({ mode, maxItems }) => ({ + mode, + rows: [{ company: 'Acme Corp', mrr: '$8,400' }].slice(0, maxItems), + summary: { atRisk: 4 }, + }), + }); + + const resolved = await ctx.resolveSource('accounts', { mode: 'visible', maxItems: 1 }); + + expect(handle.id).toBe('accounts'); + expect(resolved).toEqual({ + id: 'accounts', + kind: 'collection', + description: 'Customer accounts', + mode: 'visible', + state: { page: 2, pageSize: 25, totalCount: 80 }, + data: { + mode: 'visible', + rows: [{ company: 'Acme Corp', mrr: '$8,400' }], + summary: { atRisk: 4 }, + }, + }); + + ctx.destroy(); + }); + + it('includes selected sources in async prompt context', async () => { + const ctx = createAskableContext(); + ctx.push({ widget: 'accounts-table' }, 'Accounts'); + ctx.registerSource('accounts', { + kind: 'collection', + getState: () => ({ filter: 'at_risk' }), + resolve: ({ focus, mode }) => ({ + mode, + focusedWidget: typeof focus?.meta === 'object' ? focus.meta.widget : undefined, + totalMatching: 12, + }), + }); + + const prompt = await ctx.toPromptContextAsync({ + sources: [{ id: 'accounts', mode: 'summary' }], + }); + + expect(prompt).toContain('User is focused on'); + expect(prompt).toContain('Context sources'); + expect(prompt).toContain('accounts'); + expect(prompt).toContain('"filter":"at_risk"'); + expect(prompt).toContain('"focusedWidget":"accounts-table"'); + + ctx.destroy(); + }); + + it('includes all registered sources when requested', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { resolve: () => ({ count: 2 }) }); + ctx.registerSource('calendar', { resolve: () => ({ events: 3 }) }); + + const prompt = await ctx.toPromptContextAsync({ sources: 'all' }); + + expect(prompt).toContain('accounts'); + expect(prompt).toContain('calendar'); + expect(prompt).toContain('"count":2'); + expect(prompt).toContain('"events":3'); + + ctx.destroy(); + }); + + it('lists registered sources without resolving source data', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-01T12:00:00Z')); + const ctx = createAskableContext(); + const resolve = vi.fn(() => ({ count: 2 })); + + ctx.registerSource('accounts', { kind: 'collection', resolve }); + + expect(ctx.hasSource(' accounts ')).toBe(true); + expect(ctx.hasSource('calendar')).toBe(false); + expect(ctx.listSources()).toEqual([{ + id: 'accounts', + kind: 'collection', + registeredAt: Date.parse('2026-06-01T12:00:00Z'), + updatedAt: Date.parse('2026-06-01T12:00:00Z'), + }]); + expect(resolve).not.toHaveBeenCalled(); + + vi.setSystemTime(new Date('2026-06-01T12:00:02Z')); + ctx.notifySourceChanged('accounts'); + + expect(ctx.listSources()[0]).toEqual({ + id: 'accounts', + kind: 'collection', + registeredAt: Date.parse('2026-06-01T12:00:00Z'), + updatedAt: Date.parse('2026-06-01T12:00:02Z'), + }); + + ctx.destroy(); + }); + + it('wraps focus and sources in JSON async prompt context', async () => { + const ctx = createAskableContext(); + ctx.push({ widget: 'chart' }, 'Revenue'); + ctx.registerSource('chart-data', { + kind: 'chart', + resolve: () => ({ series: ['MRR'], points: 12 }), + }); + + const prompt = await ctx.toPromptContextAsync({ + format: 'json', + sources: ['chart-data'], + }); + const parsed = JSON.parse(prompt); + + expect(parsed.focus.meta).toEqual({ widget: 'chart' }); + expect(parsed.sources[0]).toEqual({ + id: 'chart-data', + kind: 'chart', + mode: 'summary', + data: { series: ['MRR'], points: 12 }, + }); + + ctx.destroy(); + }); + + it('includes sources in async Context packets', async () => { + const ctx = createAskableContext(); + ctx.push({ widget: 'accounts-table' }, 'Accounts'); + ctx.registerSource('accounts', { + kind: 'collection', + describe: 'Accounts matching active filters', + getState: () => ({ filter: 'at_risk' }), + resolve: ({ mode }) => ({ mode, totalMatching: 12 }), + }); + + const packet = await ctx.toContextPacketAsync({ + sources: [{ id: 'accounts', mode: 'summary' }], + history: 1, + }); + + expect(packet.capture.mode).toBe('semantic'); + expect(packet.target?.metadata).toEqual({ widget: 'accounts-table' }); + expect(packet.surrounding?.sources?.[0]).toEqual({ + label: 'accounts', + role: 'collection', + text: 'Accounts matching active filters', + metadata: { + id: 'accounts', + mode: 'summary', + state: { filter: 'at_risk' }, + data: { mode: 'summary', totalMatching: 12 }, + }, + }); + + ctx.destroy(); + }); + + it('includes safe source errors in async Context packets by default', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { + resolve: () => { + throw new Error('api key leaked'); + }, + }); + + const packet = await ctx.toContextPacketAsync({ sources: ['accounts'] }); + + expect(packet.surrounding?.sources?.[0]).toEqual({ + label: 'accounts', + metadata: { + id: 'accounts', + mode: 'summary', + error: { message: 'Context source unavailable.' }, + }, + }); + expect(JSON.stringify(packet)).not.toContain('api key leaked'); + + ctx.destroy(); + }); + + it('unregisters sources', async () => { + const ctx = createAskableContext(); + const handle = ctx.registerSource('accounts', { resolve: () => ({ count: 1 }) }); + + handle.unregister(); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + expect(ctx.unregisterSource('accounts')).toBe(false); + + ctx.destroy(); + }); + + it('does not let stale source handles unregister replacement sources', async () => { + const ctx = createAskableContext(); + const first = ctx.registerSource('accounts', { resolve: () => ({ version: 1 }) }); + const second = ctx.registerSource('accounts', { resolve: () => ({ version: 2 }) }); + + first.unregister(); + + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + data: { version: 2 }, + }); + + second.unregister(); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + + ctx.destroy(); + }); + + it('ignores stale source handle notifications after replacement', () => { + const ctx = createAskableContext(); + const changedIds: string[] = []; + ctx.on('sourcechange', (change) => { + changedIds.push(change.id ?? '*'); + }); + const first = ctx.registerSource('accounts', { resolve: () => ({ version: 1 }) }); + changedIds.length = 0; + ctx.registerSource('accounts', { resolve: () => ({ version: 2 }) }); + changedIds.length = 0; + + first.notifyChanged(); + + expect(changedIds).toEqual([]); + + ctx.notifySourceChanged('accounts'); + + expect(changedIds).toEqual(['accounts']); + + ctx.destroy(); + }); + + it('still allows app-level unregister by source id', async () => { + const ctx = createAskableContext(); + const handle = ctx.registerSource('accounts', { resolve: () => ({ count: 1 }) }); + + expect(ctx.unregisterSource('accounts')).toBe(true); + handle.notifyChanged(); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + + ctx.destroy(); + }); + + it('applies source-level and context-level sanitizers', async () => { + const ctx = createAskableContext({ + sanitizeSource: (source) => ({ + ...source, + state: { safeState: true }, + }), + }); + ctx.registerSource('accounts', { + getState: () => ({ token: 'secret-token' }), + resolve: () => ({ rows: [{ company: 'Acme', ssn: '123-45-6789' }] }), + sanitize: (source) => ({ + ...source, + data: { rows: [{ company: 'Acme' }] }, + }), + }); + + const resolved = await ctx.resolveSource('accounts'); + + expect(resolved.state).toEqual({ safeState: true }); + expect(resolved.data).toEqual({ rows: [{ company: 'Acme' }] }); + expect(JSON.stringify(resolved)).not.toContain('secret-token'); + expect(JSON.stringify(resolved)).not.toContain('123-45-6789'); + + ctx.destroy(); + }); + + it('includes a safe source error by default during async prompt serialization', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { + resolve: () => { + throw new Error('database password leaked'); + }, + }); + + const prompt = await ctx.toPromptContextAsync({ sources: ['accounts'] }); + + expect(prompt).toContain('accounts'); + expect(prompt).toContain('Context source unavailable.'); + expect(prompt).not.toContain('database password leaked'); + + ctx.destroy(); + }); + + it('can omit failed sources during async prompt serialization', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { + resolve: () => { + throw new Error('failed'); + }, + }); + + const prompt = await ctx.toPromptContextAsync({ + sources: ['accounts'], + sourceErrorMode: 'omit', + }); + + expect(prompt).not.toContain('Context sources'); + expect(prompt).not.toContain('accounts'); + + ctx.destroy(); + }); + + it('can throw failed sources during async prompt serialization', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { + resolve: () => { + throw new Error('failed'); + }, + }); + + await expect(ctx.toPromptContextAsync({ + sources: ['accounts'], + sourceErrorMode: 'throw', + })).rejects.toThrow('failed'); + + ctx.destroy(); + }); + + it('times out slow source requests', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', { + resolve: () => new Promise(() => undefined), + }); + + const prompt = await ctx.toPromptContextAsync({ + sources: [{ id: 'accounts', timeoutMs: 0 }], + }); + + expect(prompt).toContain('accounts'); + expect(prompt).toContain('Context source unavailable.'); + + ctx.destroy(); + }); + }); + describe('toContext() combined method', () => { it('returns current focus with label when history is 0', () => { const el = makeEl({ metric: 'revenue' }, '$2.3M'); diff --git a/packages/core/src/__tests__/sources.test.ts b/packages/core/src/__tests__/sources.test.ts new file mode 100644 index 0000000..d32ce92 --- /dev/null +++ b/packages/core/src/__tests__/sources.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + createAskableCollectionSource, + createAskableContext, + createAskableSource, +} from '../index.js'; + +describe('source helpers', () => { + it('creates a generic app-owned context source', async () => { + const ctx = createAskableContext(); + ctx.registerSource('document', createAskableSource({ + kind: 'document', + describe: 'Current editor document', + state: () => ({ title: 'Launch plan', dirty: false }), + data: ({ mode }) => ({ mode, outline: ['Overview', 'Risks'] }), + })); + + const resolved = await ctx.resolveSource('document', { mode: 'summary' }); + + expect(resolved).toEqual({ + id: 'document', + kind: 'document', + description: 'Current editor document', + mode: 'summary', + state: { title: 'Launch plan', dirty: false }, + data: { mode: 'summary', outline: ['Overview', 'Risks'] }, + }); + + ctx.destroy(); + }); + + it('creates a collection source that can resolve all logical items beyond the visible page', async () => { + const accounts = [ + { id: 'a1', company: 'Acme Corp', mrr: 8400, secret: 'token-a' }, + { id: 'b2', company: 'Beta Labs', mrr: 5100, secret: 'token-b' }, + { id: 'c3', company: 'Cobalt Inc', mrr: 3900, secret: 'token-c' }, + ]; + const ctx = createAskableContext(); + ctx.registerSource('accounts', createAskableCollectionSource({ + describe: 'Accounts matching active filters', + getState: () => ({ page: 1, pageSize: 1, totalCount: accounts.length }), + getVisibleItems: () => accounts.slice(0, 1), + getItems: () => accounts, + getSummary: () => ({ totalMrr: 17400 }), + sanitizeItem: ({ secret: _secret, ...safe }) => safe, + })); + + await expect(ctx.resolveSource('accounts', { mode: 'visible' })).resolves.toMatchObject({ + data: { + mode: 'visible', + items: [{ id: 'a1', company: 'Acme Corp', mrr: 8400 }], + totalCount: 1, + returnedCount: 1, + truncated: false, + }, + }); + + const all = await ctx.resolveSource('accounts', { mode: 'all', maxItems: 2 }); + + expect(all.data).toEqual({ + mode: 'all', + items: [ + { id: 'a1', company: 'Acme Corp', mrr: 8400 }, + { id: 'b2', company: 'Beta Labs', mrr: 5100 }, + ], + totalCount: 3, + returnedCount: 2, + truncated: true, + }); + expect(JSON.stringify(all)).not.toContain('token-a'); + + await expect(ctx.resolveSource('accounts', { mode: 'summary' })).resolves.toMatchObject({ + data: { + mode: 'summary', + summary: { totalMrr: 17400 }, + }, + }); + + ctx.destroy(); + }); +}); diff --git a/packages/core/src/capture.ts b/packages/core/src/capture.ts index ea06229..ade8f71 100644 --- a/packages/core/src/capture.ts +++ b/packages/core/src/capture.ts @@ -77,19 +77,19 @@ type Point = AskableRegionCapturePoint; const OVERLAY_ID = 'askable-region-capture'; const SELECTION_ATTR = 'data-askable-region-capture-selection'; -const DEFAULT_REGION_CAPTURE_THEME: AskableRegionCaptureTheme = { +export const ASKABLE_REGION_CAPTURE_THEME: AskableRegionCaptureTheme = { overlayBackground: 'rgba(15,23,42,0.08)', selectionStroke: '#2563eb', selectionFill: 'rgba(37,99,235,0.14)', selectionScrim: 'rgba(15,23,42,0.12)', lassoGradientStops: [ - { offset: '0%', color: '#06b6d4' }, - { offset: '38%', color: '#4f46e5' }, - { offset: '70%', color: '#a855f7' }, - { offset: '100%', color: '#22c55e' }, + { offset: '0%', color: '#6d28d9' }, + { offset: '46%', color: '#7c3aed' }, + { offset: '78%', color: '#8b5cf6' }, + { offset: '100%', color: '#a78bfa' }, ], lassoStrokeWidth: 3, - lassoGlowColor: 'rgba(79,70,229,0.35)', + lassoGlowColor: 'rgba(124,58,237,0.16)', lassoGlowRadius: 8, }; @@ -333,15 +333,15 @@ export function createAskableRegionCapture( }, cancel, destroy: removeOverlay, - isActive: () => active, + isActive: () => Boolean(overlay), }; } function resolveRegionCaptureTheme(theme?: Partial): AskableRegionCaptureTheme { return { - ...DEFAULT_REGION_CAPTURE_THEME, + ...ASKABLE_REGION_CAPTURE_THEME, ...theme, - lassoGradientStops: theme?.lassoGradientStops ?? DEFAULT_REGION_CAPTURE_THEME.lassoGradientStops, + lassoGradientStops: theme?.lassoGradientStops ?? ASKABLE_REGION_CAPTURE_THEME.lassoGradientStops, }; } diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 98be080..b61ee0d 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,12 +1,25 @@ -import { createWebContextPacket } from '@askable-ui/context'; +import { createWebContextPacket, isWebContextPacket } from '@askable-ui/context'; import { Emitter } from './emitter.js'; import { buildFocus, Observer } from './observer.js'; import type { AskableContext, + AskableAgentRequest, + AskableAgentRequestOptions, + AskableAsyncContextSubscriber, + AskableAsyncContextPacketOptions, AskableContextOptions, AskableContextOutputOptions, AskableContextPacketOptions, + AskableAsyncContextOutputOptions, + AskableAsyncPromptContextOptions, AskableContextSubscriber, + AskableContextSource, + AskableContextSourceHandle, + AskableContextSourceInclude, + AskableContextSourceInfo, + AskableContextSourceMode, + AskableContextSourceRequest, + AskableContextSourceChange, AskableEventHandler, AskableEventName, AskableFocus, @@ -15,8 +28,10 @@ import type { AskablePromptContextOptions, AskablePromptPreset, AskablePushOptions, + AskableResolvedContextSource, AskableSerializedFocus, AskableSerializedFocusSegment, + AskableAsyncSubscribeOptions, AskableSubscribeOptions, } from './types.js'; import type { @@ -36,6 +51,13 @@ const PRESETS: Record = { const DEFAULT_MAX_HISTORY = 50; +type AskableContextSourceEntry = { + source: AskableContextSource; + token: symbol; + registeredAt: number; + updatedAt: number; +}; + export class AskableContextImpl implements AskableContext { private emitter = new Emitter(); private observer: Observer; @@ -45,15 +67,18 @@ export class AskableContextImpl implements AskableContext { private intersectionObserver: IntersectionObserver | null = null; private viewportEnabled: boolean; private maxHistory: number; + private sources = new Map(); private textExtractor: ((el: HTMLElement) => string) | undefined; private sanitizeMetaFn: ((meta: Record) => Record) | undefined; private sanitizeTextFn: ((text: string) => string) | undefined; + private sanitizeSourceFn: ((source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise) | undefined; private subscriptions = new Set<() => void>(); constructor(options?: AskableContextOptions) { this.textExtractor = options?.textExtractor; this.sanitizeMetaFn = options?.sanitizeMeta; this.sanitizeTextFn = options?.sanitizeText; + this.sanitizeSourceFn = options?.sanitizeSource; this.viewportEnabled = options?.viewport ?? false; this.maxHistory = options?.maxHistory ?? DEFAULT_MAX_HISTORY; this.observer = new Observer((rawFocus) => { @@ -268,6 +293,121 @@ export class AskableContextImpl implements AskableContext { this.emitter.emit('focus', focus); } + registerSource(id: string, source: AskableContextSource): AskableContextSourceHandle { + const normalizedId = id.trim(); + if (!normalizedId) { + throw new Error('Askable context source id must be a non-empty string.'); + } + const token = Symbol(normalizedId); + const now = Date.now(); + this.sources.set(normalizedId, { + source, + token, + registeredAt: now, + updatedAt: now, + }); + this.notifySourceChanged(normalizedId); + return { + id: normalizedId, + notifyChanged: () => { + this.notifySourceHandleChanged(normalizedId, token); + }, + unregister: () => { + this.unregisterSourceHandle(normalizedId, token); + }, + }; + } + + hasSource(id: string): boolean { + return this.sources.has(id.trim()); + } + + listSources(): AskableContextSourceInfo[] { + return Array.from(this.sources.entries()).map(([id, entry]) => ({ + id, + ...(entry.source.kind ? { kind: entry.source.kind } : {}), + registeredAt: entry.registeredAt, + updatedAt: entry.updatedAt, + })); + } + + unregisterSource(id: string): boolean { + const normalizedId = id.trim(); + const deleted = this.sources.delete(normalizedId); + if (deleted) this.notifySourceChanged(normalizedId); + return deleted; + } + + private unregisterSourceHandle(id: string, token: symbol): boolean { + const entry = this.sources.get(id); + if (!entry || entry.token !== token) return false; + this.sources.delete(id); + this.notifySourceChanged(id); + return true; + } + + private notifySourceHandleChanged(id: string, token: symbol): void { + const entry = this.sources.get(id); + if (entry?.token !== token) return; + this.notifySourceChanged(id); + } + + notifySourceChanged(id?: string): void { + const normalizedId = id?.trim(); + const timestamp = Date.now(); + if (normalizedId) { + const entry = this.sources.get(normalizedId); + if (entry) entry.updatedAt = timestamp; + } else { + this.sources.forEach((entry) => { + entry.updatedAt = timestamp; + }); + } + this.emitter.emit('sourcechange', { + ...(normalizedId ? { id: normalizedId } : {}), + timestamp, + }); + } + + async resolveSource( + id: string, + request?: Omit + ): Promise { + const sourceId = id.trim(); + const entry = this.sources.get(sourceId); + if (!entry) { + throw new Error(`Askable context source "${sourceId}" is not registered.`); + } + const { source } = entry; + + const mode = request?.mode ?? 'summary'; + const signal = request?.signal; + const [description, state, data] = await Promise.all([ + this.runSourceTask(() => this.resolveSourceDescription(source), request?.timeoutMs, signal), + source.getState ? this.runSourceTask(() => source.getState!(), request?.timeoutMs, signal) : undefined, + source.resolve ? this.runSourceTask(() => source.resolve!({ + sourceId, + mode, + focus: this.currentFocus, + selection: request?.selection, + maxItems: request?.maxItems, + maxTokens: request?.maxTokens, + timeoutMs: request?.timeoutMs, + signal, + }), request?.timeoutMs, signal) : undefined, + ]); + + const resolved: AskableResolvedContextSource = { + id: sourceId, + ...(source.kind ? { kind: source.kind } : {}), + ...(description ? { description } : {}), + mode, + ...(state !== undefined ? { state } : {}), + ...(data !== undefined ? { data } : {}), + }; + return this.applySourceSanitizers(resolved, source); + } + clear(): void { this.currentFocus = null; this.emitter.emit('clear', null); @@ -286,6 +426,16 @@ export class AskableContextImpl implements AskableContext { return this.applyTokenBudget(output, resolved.maxTokens); } + async toPromptContextAsync(options?: AskableAsyncPromptContextOptions): Promise { + const resolved = this.resolveOptions(options); + const { maxTokens, ...baseOptions } = resolved; + const base = this.toPromptContext(baseOptions); + const sources = await this.resolveIncludedSources(options); + if (sources.length === 0) return this.applyTokenBudget(base, maxTokens); + const output = this.appendSourcesToOutput(base, sources, resolved, options?.sourceLabel); + return this.applyTokenBudget(output, maxTokens); + } + toHistoryContext(limit?: number, options?: AskablePromptContextOptions): string { const resolved = this.resolveOptions(options); const history = this.filterByScope(this.getHistory(limit), resolved.scope); @@ -328,6 +478,16 @@ export class AskableContextImpl implements AskableContext { return this.applyTokenBudget(output, resolved.maxTokens); } + async toContextAsync(options?: AskableAsyncContextOutputOptions): Promise { + const resolved = this.resolveOptions(options); + const { sources: _sources, sourceMode: _sourceMode, sourceLabel, maxTokens, ...contextOptions } = options ?? {}; + const base = this.toContext(contextOptions); + const sources = await this.resolveIncludedSources(options); + if (sources.length === 0) return this.applyTokenBudget(base, maxTokens); + const output = this.appendSourcesToOutput(base, sources, resolved, sourceLabel); + return this.applyTokenBudget(output, maxTokens); + } + toContextPacket(options?: AskableContextPacketOptions): WebContextPacket { const resolved = this.resolveOptions(options); const currentFocus = this.matchesScope(this.currentFocus, resolved.scope) ? this.currentFocus : null; @@ -370,6 +530,51 @@ export class AskableContextImpl implements AskableContext { }); } + async toContextPacketAsync(options?: AskableAsyncContextPacketOptions): Promise { + const { sources: _sources, sourceMode: _sourceMode, sourceErrorMode: _sourceErrorMode, ...packetOptions } = options ?? {}; + const packet = this.toContextPacket(packetOptions); + const sources = await this.resolveIncludedSources(options); + if (sources.length === 0) return packet; + + return { + ...packet, + surrounding: { + ...packet.surrounding, + sources: sources.map((source) => this.sourceToTarget(source)), + }, + }; + } + + async toAgentRequest(question: string, options?: AskableAgentRequestOptions): Promise { + const { + requestId, + metadata, + packet: packetOption, + ...contextOptions + } = options ?? {}; + const context = await this.toContextAsync(contextOptions); + let packet: WebContextPacket | undefined; + if (packetOption) { + if (packetOption === true) { + packet = await this.toContextPacketAsync(this.agentRequestOptionsToPacketOptions(contextOptions)); + } else if (isWebContextPacket(packetOption)) { + packet = packetOption; + } else { + packet = await this.toContextPacketAsync(packetOption); + } + } + + return { + ...(requestId ? { requestId } : {}), + question, + context, + focus: this.serializeFocus(contextOptions), + ...(packet ? { packet } : {}), + ...(metadata ? { metadata } : {}), + timestamp: Date.now(), + }; + } + subscribe(callback: AskableContextSubscriber, options?: AskableSubscribeOptions): () => void { const { debounce = 0, ...contextOptions } = options ?? {}; let timer: ReturnType | null = null; @@ -420,6 +625,82 @@ export class AskableContextImpl implements AskableContext { return unsubscribe; } + subscribeAsync(callback: AskableAsyncContextSubscriber, options?: AskableAsyncSubscribeOptions): () => void { + const { + debounce = 0, + emitInitial = false, + onError, + ...contextOptions + } = options ?? {}; + let timer: ReturnType | null = null; + let active = true; + let version = 0; + + const reportError = (error: unknown, scheduledVersion: number) => { + if (!active || scheduledVersion !== version) return; + onError?.(error); + }; + + const emitContext = async (scheduledVersion: number) => { + if (!active) return; + try { + const focus = this.currentFocus; + const scopedFocus = this.matchesScope(focus, contextOptions.scope) ? focus : null; + const context = await this.toContextAsync(contextOptions); + if (!active || scheduledVersion !== version) return; + await callback(context, scopedFocus); + } catch (error) { + reportError(error, scheduledVersion); + } + }; + + const schedule = () => { + if (!active) return; + version += 1; + const scheduledVersion = version; + if (timer) { + clearTimeout(timer); + timer = null; + } + if (debounce > 0) { + timer = setTimeout(() => { + timer = null; + void emitContext(scheduledVersion); + }, debounce); + return; + } + void emitContext(scheduledVersion); + }; + + const onFocus = () => schedule(); + const onClear = () => schedule(); + const onSourceChange = (change: AskableContextSourceChange) => { + if (this.shouldRefreshSources(contextOptions.sources, change.id)) schedule(); + }; + + this.on('focus', onFocus); + this.on('clear', onClear); + this.on('sourcechange', onSourceChange); + if (emitInitial) schedule(); + + const unsubscribe = () => { + if (!active) return; + active = false; + version += 1; + if (timer) { + clearTimeout(timer); + timer = null; + } + this.off('focus', onFocus); + this.off('clear', onClear); + this.off('sourcechange', onSourceChange); + this.subscriptions.delete(unsubscribe); + }; + + this.subscriptions.add(unsubscribe); + return unsubscribe; + } + destroy(): void { this.unobserve(); this.subscriptions.forEach((unsubscribe) => unsubscribe()); @@ -428,6 +709,166 @@ export class AskableContextImpl implements AskableContext { this.currentFocus = null; this.history = []; this.visibleElements.clear(); + this.sources.clear(); + } + + private async resolveSourceDescription(source: AskableContextSource): Promise { + if (!source.describe) return undefined; + return typeof source.describe === 'function' ? source.describe() : source.describe; + } + + private async runSourceTask( + task: () => T | Promise, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + if (signal?.aborted) { + throw new Error('Context source request aborted.'); + } + + const value = Promise.resolve().then(task); + if (timeoutMs === undefined) return value; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Context source timed out.')); + }, Math.max(0, timeoutMs)); + + const abort = () => { + clearTimeout(timer); + reject(new Error('Context source request aborted.')); + }; + + signal?.addEventListener('abort', abort, { once: true }); + value.then( + (result) => { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + resolve(result); + }, + (error) => { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + reject(error); + }, + ); + }); + } + + private async applySourceSanitizers( + resolved: AskableResolvedContextSource, + source: AskableContextSource + ): Promise { + const sourceSanitized = source.sanitize ? await source.sanitize(resolved) : resolved; + return this.sanitizeSourceFn ? this.sanitizeSourceFn(sourceSanitized) : sourceSanitized; + } + + private normalizeSourceRequest( + include: AskableContextSourceInclude, + defaultMode: AskableContextSourceMode + ): AskableContextSourceRequest { + return typeof include === 'string' + ? { id: include, mode: defaultMode } + : { ...include, mode: include.mode ?? defaultMode }; + } + + private shouldRefreshSources( + includes: 'all' | AskableContextSourceInclude[] | undefined, + changedId?: string + ): boolean { + if (!includes) return false; + if (!changedId) return true; + if (includes === 'all') return true; + return includes.some((include) => ( + typeof include === 'string' + ? include.trim() === changedId + : include.id.trim() === changedId + )); + } + + private async resolveIncludedSources( + options?: AskableAsyncPromptContextOptions | AskableAsyncContextOutputOptions + ): Promise { + const includes = options?.sources; + if (!includes) return []; + const defaultMode = options.sourceMode ?? 'summary'; + const requests = includes === 'all' + ? Array.from(this.sources.keys()).map((id) => ({ id, mode: defaultMode })) + : includes.map((include) => this.normalizeSourceRequest(include, defaultMode)); + + const errorMode = options.sourceErrorMode ?? 'include'; + const resolved = await Promise.all(requests.map(({ id, ...request }) => ( + this.resolveSource(id, request).catch((error) => { + if (errorMode === 'throw') throw error; + if (errorMode === 'omit') return null; + return this.buildSourceError(id, request.mode ?? defaultMode); + }) + ))); + + return resolved.filter((source): source is AskableResolvedContextSource => Boolean(source)); + } + + private buildSourceError( + id: string, + mode: AskableContextSourceMode + ): AskableResolvedContextSource { + return { + id, + mode, + error: { + message: 'Context source unavailable.', + }, + }; + } + + private appendSourcesToOutput( + base: string, + sources: AskableResolvedContextSource[], + options: AskablePromptContextOptions, + label = 'Context sources' + ): string { + if ((options.format ?? 'natural') === 'json') { + return JSON.stringify({ + focus: this.safeParseJson(base), + sources, + }); + } + + const sourceOutput = sources + .map((source, index) => `[${index + 1}] ${this.formatResolvedSource(source)}`) + .join('\n'); + return `${base}\n\n${label}:\n${sourceOutput}`; + } + + private formatResolvedSource(source: AskableResolvedContextSource): string { + const heading = [ + source.id, + source.kind ? `kind: ${source.kind}` : '', + `mode: ${source.mode}`, + source.description ? `description: ${source.description}` : '', + ].filter(Boolean).join(' — '); + const parts = [heading]; + if (source.state !== undefined) parts.push(`state ${this.stringifySourceValue(source.state)}`); + if (source.data !== undefined) parts.push(`data ${this.stringifySourceValue(source.data)}`); + if (source.error) parts.push(`error "${source.error.message}"`); + return parts.join(' — '); + } + + private stringifySourceValue(value: unknown): string { + if (typeof value === 'string') return `"${value}"`; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + private safeParseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } } private normalizeMeta( @@ -487,6 +928,33 @@ export class AskableContextImpl implements AskableContext { })); } + private sourceToTarget(source: AskableResolvedContextSource): WebContextTarget { + return { + label: source.id, + ...(source.kind ? { role: source.kind } : {}), + ...(source.description ? { text: source.description } : {}), + metadata: { + id: source.id, + mode: source.mode, + ...(source.state !== undefined ? { state: source.state } : {}), + ...(source.data !== undefined ? { data: source.data } : {}), + ...(source.error ? { error: source.error } : {}), + }, + }; + } + + private agentRequestOptionsToPacketOptions( + options: AskableAsyncContextOutputOptions + ): AskableAsyncContextPacketOptions { + const { + currentLabel: _currentLabel, + historyLabel: _historyLabel, + sourceLabel: _sourceLabel, + ...packetOptions + } = options; + return packetOptions; + } + private resolveCaptureMode(focus: AskableFocus | null): WebContextCaptureMode { if (!focus) return 'full-page'; if (focus.source === 'push') return 'semantic'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bac30f0..9b7893e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,8 @@ export { createAskableInspector } from './inspector.js'; -export { createAskableRegionCapture } from './capture.js'; +export { ASKABLE_REGION_CAPTURE_THEME, createAskableRegionCapture } from './capture.js'; export { createAskableTextSelectionCapture } from './selection.js'; export { a11yTextExtractor } from './a11y.js'; +export { createAskableCollectionSource, createAskableSource } from './sources.js'; export { WEB_CONTEXT_PROTOCOL, WEB_CONTEXT_VERSION, @@ -41,12 +42,34 @@ export type { AskableTextSelectionCaptureOptions, AskableTextSelectionCaptureSelection, } from './selection.js'; +export type { + AskableCollectionSourceData, + AskableCreateCollectionSourceOptions, + AskableCreateSourceOptions, + AskableSourceValue, +} from './sources.js'; export type { AskableContext, + AskableAgentRequest, + AskableAgentRequestOptions, + AskableAsyncContextSubscriber, + AskableAsyncContextPacketOptions, AskableContextOptions, AskableContextOutputOptions, AskableContextPacketOptions, + AskableAsyncContextOutputOptions, + AskableAsyncPromptContextOptions, AskableContextSubscriber, + AskableContextSource, + AskableContextSourceChange, + AskableContextSourceErrorMode, + AskableContextSourceHandle, + AskableContextSourceInclude, + AskableContextSourceInfo, + AskableContextSourceMode, + AskableContextSourceRequest, + AskableContextSourceResolveRequest, + AskableAsyncSubscribeOptions, AskableSubscribeOptions, AskableEvent, AskableEventHandler, @@ -60,6 +83,7 @@ export type { AskablePromptFormat, AskablePromptPreset, AskablePushOptions, + AskableResolvedContextSource, AskableSerializedFocus, AskableSerializedFocusSegment, AskableTargetStrategy, diff --git a/packages/core/src/sources.ts b/packages/core/src/sources.ts new file mode 100644 index 0000000..5021530 --- /dev/null +++ b/packages/core/src/sources.ts @@ -0,0 +1,148 @@ +import type { + AskableContextSource, + AskableContextSourceMode, + AskableContextSourceResolveRequest, + AskableResolvedContextSource, +} from './types.js'; + +export type AskableSourceValue = T | (() => T | Promise); + +export interface AskableCreateSourceOptions { + /** Source category. Examples: "document", "collection", "chart", "map", "canvas". */ + kind?: string; + /** Human-readable source description. */ + describe?: string | (() => string | Promise); + /** Current app state for this source. */ + state?: AskableSourceValue; + /** App-owned context data. Receives the same request that custom sources receive. */ + data?: TData | ((request: AskableContextSourceResolveRequest) => TData | Promise); + /** Custom resolver for advanced source behavior. Overrides `data` when provided. */ + resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise; + /** Redact or transform this source before serialization. */ + sanitize?: (source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise; +} + +export interface AskableCollectionSourceData { + mode: AskableContextSourceMode; + items?: TItem[]; + summary?: unknown; + totalCount?: number; + returnedCount?: number; + truncated?: boolean; +} + +export interface AskableCreateCollectionSourceOptions { + /** Source category. Defaults to "collection". */ + kind?: string; + /** Human-readable source description. */ + describe?: string | (() => string | Promise); + /** Current collection state, such as filters, sort, page, route, or query. */ + getState?: () => TState | Promise; + /** All logical items, including items not currently mounted in the DOM. */ + getItems?: () => readonly TItem[] | Promise; + /** Items currently visible on screen. */ + getVisibleItems?: () => readonly TItem[] | Promise; + /** Items explicitly selected by the user or active app state. */ + getSelectedItems?: (request: AskableContextSourceResolveRequest) => readonly TItem[] | Promise; + /** Lightweight aggregate summary for prompt budgets. */ + getSummary?: (request: AskableContextSourceResolveRequest) => unknown | Promise; + /** Fallback for custom modes. */ + resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise; + /** Default item cap when the request does not provide `maxItems`. */ + maxItems?: number; + /** Redact or transform each returned item. */ + sanitizeItem?: (item: TItem, request: AskableContextSourceResolveRequest) => unknown | Promise; + /** Redact or transform this source before serialization. */ + sanitize?: (source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise; +} + +export function createAskableSource( + options: AskableCreateSourceOptions, +): AskableContextSource { + return { + kind: options.kind, + describe: options.describe, + getState: options.state === undefined + ? undefined + : () => resolveSourceValue(options.state), + resolve: options.resolve ?? (options.data === undefined + ? undefined + : (request) => (typeof options.data === 'function' + ? (options.data as (request: AskableContextSourceResolveRequest) => TData | Promise)(request) + : options.data)), + sanitize: options.sanitize, + }; +} + +export function createAskableCollectionSource( + options: AskableCreateCollectionSourceOptions, +): AskableContextSource { + return { + kind: options.kind ?? 'collection', + describe: options.describe, + getState: options.getState, + resolve: async (request) => { + const custom = await resolveCustomCollectionMode(options, request); + if (custom !== undefined) return custom; + if (!options.resolve) return undefined; + return options.resolve(request); + }, + sanitize: options.sanitize, + }; +} + +async function resolveSourceValue(value: AskableSourceValue): Promise { + return typeof value === 'function' + ? (value as () => T | Promise)() + : value; +} + +async function resolveCustomCollectionMode( + options: AskableCreateCollectionSourceOptions, + request: AskableContextSourceResolveRequest, +): Promise | unknown | undefined> { + if (request.mode === 'summary' && options.getSummary) { + return { + mode: request.mode, + summary: await options.getSummary(request), + }; + } + + if (request.mode === 'visible' && options.getVisibleItems) { + return collectionItemsResult(await options.getVisibleItems(), options, request); + } + + if (request.mode === 'selected' && options.getSelectedItems) { + return collectionItemsResult(await options.getSelectedItems(request), options, request); + } + + if (request.mode === 'all' && options.getItems) { + return collectionItemsResult(await options.getItems(), options, request); + } + + if (request.mode === 'state') { + return undefined; + } + + return undefined; +} + +async function collectionItemsResult( + items: readonly TItem[], + options: AskableCreateCollectionSourceOptions, + request: AskableContextSourceResolveRequest, +): Promise> { + const maxItems = request.maxItems ?? options.maxItems; + const capped = maxItems === undefined ? items : items.slice(0, Math.max(0, maxItems)); + const serialized = options.sanitizeItem + ? await Promise.all(capped.map((item) => options.sanitizeItem!(item, request))) + : [...capped]; + + return { + mode: request.mode, + items: serialized, + totalCount: items.length, + returnedCount: serialized.length, + truncated: serialized.length < items.length, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fde53c1..b07c5fb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -32,6 +32,8 @@ export type AskableEventMap = { focus: AskableFocus; /** Fires when clear() is called — focus has been reset to null */ clear: null; + /** Fires when a registered app-owned context source may have changed. */ + sourcechange: AskableContextSourceChange; }; export type AskableEventName = keyof AskableEventMap; @@ -141,6 +143,125 @@ export interface AskablePromptContextOptions { maxTokens?: number; } +export type AskableContextSourceMode = + | 'state' + | 'visible' + | 'selected' + | 'summary' + | 'all' + | (string & {}); + +export interface AskableContextSourceResolveRequest { + /** Registered source id. */ + sourceId: string; + /** Requested slice of context. Defaults to "summary". */ + mode: AskableContextSourceMode; + /** Current Askable focus, if any, so sources can resolve the user's active reference. */ + focus: AskableFocus | null; + /** Optional app-defined selection payload, such as row ids, ranges, or canvas bounds. */ + selection?: unknown; + /** Optional item cap for sources that can return many records. */ + maxItems?: number; + /** Optional token budget for source-owned summaries. */ + maxTokens?: number; + /** Optional timeout in ms for this source request. */ + timeoutMs?: number; + /** Optional cancellation signal for async source work. */ + signal?: AbortSignal; +} + +export type AskableContextSourceErrorMode = 'include' | 'omit' | 'throw'; + +export interface AskableContextSource { + /** Source category. Examples: "collection", "document", "chart", "map", "canvas", "custom". */ + kind?: string; + /** Human-readable source description. */ + describe?: string | (() => string | Promise); + /** Current app state for this source, such as filters, sort, page, route, or viewport. */ + getState?: () => unknown | Promise; + /** Resolve app-owned context for the requested mode. */ + resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise; + /** Redact/transform this source before it is serialized. */ + sanitize?: (source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise; +} + +export interface AskableContextSourceHandle { + id: string; + notifyChanged(): void; + unregister(): void; +} + +export interface AskableContextSourceInfo { + /** Registered source id. */ + id: string; + /** Source category, when provided by the source. */ + kind?: string; + /** Unix timestamp (ms) when this source id was last registered. */ + registeredAt: number; + /** Unix timestamp (ms) when this source was last registered or notified as changed. */ + updatedAt: number; +} + +export interface AskableContextSourceChange { + /** Source id that changed. Omitted when all registered sources should be refreshed. */ + id?: string; + /** Unix timestamp (ms) when the change was signalled. */ + timestamp: number; +} + +export interface AskableContextSourceRequest { + /** Registered source id. */ + id: string; + /** Requested slice of context. Defaults to the top-level sourceMode, then "summary". */ + mode?: AskableContextSourceMode; + /** Optional app-defined selection payload passed to the source resolver. */ + selection?: unknown; + /** Optional item cap for sources that can return many records. */ + maxItems?: number; + /** Optional token budget for source-owned summaries. */ + maxTokens?: number; + /** Optional timeout in ms for this source request. */ + timeoutMs?: number; + /** Optional cancellation signal for async source work. */ + signal?: AbortSignal; +} + +export type AskableContextSourceInclude = string | AskableContextSourceRequest; + +export interface AskableResolvedContextSource { + id: string; + kind?: string; + description?: string; + mode: AskableContextSourceMode; + state?: unknown; + data?: unknown; + error?: { + message: string; + }; +} + +export interface AskableAsyncPromptContextOptions extends AskablePromptContextOptions { + /** Sources to include. Use "all" for every registered source. */ + sources?: 'all' | AskableContextSourceInclude[]; + /** Default source mode when a source request omits mode. Defaults to "summary". */ + sourceMode?: AskableContextSourceMode; + /** Heading used in natural-language output. Defaults to "Context sources". */ + sourceLabel?: string; + /** How async prompt generation handles failed sources. Defaults to "include". */ + sourceErrorMode?: AskableContextSourceErrorMode; +} + +export interface AskableAsyncContextOutputOptions extends AskableContextOutputOptions { + /** Sources to include. Use "all" for every registered source. */ + sources?: 'all' | AskableContextSourceInclude[]; + /** Default source mode when a source request omits mode. Defaults to "summary". */ + sourceMode?: AskableContextSourceMode; + /** Heading used in natural-language output. Defaults to "Context sources". */ + sourceLabel?: string; + /** How async prompt generation handles failed sources. Defaults to "include". */ + sourceErrorMode?: AskableContextSourceErrorMode; +} + /** * Options for creating an AskableContext. */ @@ -155,6 +276,27 @@ export interface AskableSubscribeOptions extends AskableContextOutputOptions { export type AskableContextSubscriber = (context: string, focus: AskableFocus | null) => void; +export interface AskableAsyncSubscribeOptions extends AskableAsyncContextOutputOptions { + /** + * Debounce delay in ms applied to subscription callbacks. + * Useful when source-backed context should not resolve on every rapid focus change. + * Defaults to 0 (no debounce). + */ + debounce?: number; + /** + * Emit once immediately with the current context after the subscriber is registered. + * Defaults to false. + */ + emitInitial?: boolean; + /** Called when async context resolution or the subscriber callback fails. */ + onError?: (error: unknown) => void; +} + +export type AskableAsyncContextSubscriber = ( + context: string, + focus: AskableFocus | null +) => void | Promise; + export interface AskableContextOptions { /** * Optional name for reusing a shared context instance across the same page/runtime. @@ -194,6 +336,11 @@ export interface AskableContextOptions { * }) */ sanitizeText?: (text: string) => string; + /** + * Sanitize or redact resolved source context before it is serialized. + * Applied after source-level sanitizers. + */ + sanitizeSource?: (source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise; /** * Maximum number of focus entries retained in history. * Oldest entries are evicted when the limit is exceeded. @@ -250,6 +397,41 @@ export interface AskableContextPacketOptions extends AskablePromptContextOptions provenance?: Partial; } +export interface AskableAsyncContextPacketOptions extends AskableContextPacketOptions { + /** Sources to include in `surrounding.sources`. Use "all" for every registered source. */ + sources?: 'all' | AskableContextSourceInclude[]; + /** Default source mode when a source request omits mode. Defaults to "summary". */ + sourceMode?: AskableContextSourceMode; + /** How async packet generation handles failed sources. Defaults to "include". */ + sourceErrorMode?: AskableContextSourceErrorMode; +} + +export interface AskableAgentRequestOptions extends AskableAsyncContextOutputOptions { + /** Stable id for tracing this question through chat transports, logs, and agent runs. */ + requestId?: string; + /** App-owned metadata copied into the request payload. */ + metadata?: Record; + /** Include a structured Context packet. Pass true, packet options, or an existing packet from a capture tool. */ + packet?: boolean | AskableAsyncContextPacketOptions | WebContextPacket; +} + +export interface AskableAgentRequest { + /** Stable id for tracing, when provided by the app. */ + requestId?: string; + /** User-authored question or instruction. */ + question: string; + /** Prompt-ready context string from `toContextAsync()`. */ + context: string; + /** Serialized current focus at request creation time. */ + focus: AskableSerializedFocus | null; + /** Structured Context packet when requested. */ + packet?: WebContextPacket; + /** App-owned metadata copied from request options. */ + metadata?: Record; + /** Unix timestamp (ms) when the request payload was created. */ + timestamp: number; +} + export interface AskableContext { /** Observe a DOM subtree for [data-askable] elements */ observe(root: HTMLElement | Document, options?: AskableObserveOptions): void; @@ -269,22 +451,44 @@ export interface AskableContext { select(element: HTMLElement): void; /** Set focus from data alone — no DOM element required. Ideal for virtualizing table libraries. */ push(meta: Record | string, text?: string, options?: AskablePushOptions): void; + /** Register an app-owned context source for data not fully represented in the DOM. */ + registerSource(id: string, source: AskableContextSource): AskableContextSourceHandle; + /** Return true when a context source id is currently registered. */ + hasSource(id: string): boolean; + /** List registered context sources without resolving their data. */ + listSources(): AskableContextSourceInfo[]; + /** Remove a registered context source. */ + unregisterSource(id: string): boolean; + /** Notify async subscribers that one source, or all sources, should be re-resolved. */ + notifySourceChanged(id?: string): void; + /** Resolve one registered source on demand. */ + resolveSource(id: string, request?: Omit): Promise; /** Reset the current focus to null and emit a 'clear' event */ clear(): void; /** Serialize current focus to structured prompt-ready data */ serializeFocus(options?: AskablePromptContextOptions): AskableSerializedFocus | null; /** Serialize current focus to a prompt-ready string */ toPromptContext(options?: AskablePromptContextOptions): string; + /** Serialize current focus plus async app-owned sources to a prompt-ready string. */ + toPromptContextAsync(options?: AskableAsyncPromptContextOptions): Promise; /** Serialize focus history to a prompt-ready string (newest first). Optional limit caps the entries returned. */ toHistoryContext(limit?: number, options?: AskablePromptContextOptions): string; /** Serialize visible viewport elements to a prompt-ready string. */ toViewportContext(options?: AskablePromptContextOptions): string; /** Combined current focus + history in a single prompt-ready string */ toContext(options?: AskableContextOutputOptions): string; + /** Combined current focus + history + async app-owned sources in a single prompt-ready string. */ + toContextAsync(options?: AskableAsyncContextOutputOptions): Promise; /** Serialize current UI state to a structured Context packet for agents and MCP bridges. */ toContextPacket(options?: AskableContextPacketOptions): WebContextPacket; + /** Serialize current UI state plus async app-owned sources to a structured Context packet. */ + toContextPacketAsync(options?: AskableAsyncContextPacketOptions): Promise; + /** Package a user question with source-backed context for chat or agent transports. */ + toAgentRequest(question: string, options?: AskableAgentRequestOptions): Promise; /** Subscribe to serialized context updates for streaming/chat integrations. Returns an unsubscribe function. */ subscribe(callback: AskableContextSubscriber, options?: AskableSubscribeOptions): () => void; + /** Subscribe to source-backed serialized context updates. Stale async resolutions are ignored. */ + subscribeAsync(callback: AskableAsyncContextSubscriber, options?: AskableAsyncSubscribeOptions): () => void; /** Clean up all listeners and observers */ destroy(): void; } diff --git a/packages/create-askable-app/package.json b/packages/create-askable-app/package.json index 69943fa..b5eecf6 100644 --- a/packages/create-askable-app/package.json +++ b/packages/create-askable-app/package.json @@ -1,6 +1,6 @@ { "name": "@askable-ui/create-app", - "version": "0.11.1", + "version": "0.12.0", "description": "Scaffold a React + Vite + CopilotKit + askable-ui starter app", "type": "module", "bin": { diff --git a/packages/create-askable-app/src/scaffold.js b/packages/create-askable-app/src/scaffold.js index 380791d..4414083 100644 --- a/packages/create-askable-app/src/scaffold.js +++ b/packages/create-askable-app/src/scaffold.js @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const ASKABLE_VERSION = '0.11.1'; +const ASKABLE_VERSION = '0.12.0'; const COPILOTKIT_VERSION = '1.59.2'; const __filename = fileURLToPath(import.meta.url); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 091e1f4..c04f2a2 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -23,9 +23,33 @@ const server = createAskableMcpServer({ ``` The built-in provider adapts `ctx.toContextPacket()` and `ctx.toContext()` to -the MCP tools. Tool callers can request prompt shaping options such as `scope`, -`preset`, `format`, `includeText`, `maxTextLength`, `maxTokens`, `history`, and -`includeViewport`. +the MCP tools. When available, it uses `ctx.toContextPacketAsync()` and +`ctx.toContextAsync()` so registered app-owned sources are included in +structured packets and prompt renderings. Tool callers can request prompt +shaping options such as `scope`, `preset`, `format`, `includeText`, +`maxTextLength`, `maxTokens`, `history`, `includeViewport`, and `sources`. + +```ts +import { createAskableCollectionSource } from '@askable-ui/core'; + +ctx.registerSource('accounts', createAskableCollectionSource({ + describe: 'Accounts matching active filters', + getState: () => ({ filters, sort, totalCount }), + getVisibleItems: () => table.getVisibleRows(), + getItems: () => accountStore.getAllMatching({ filters, sort }), + getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), + sanitizeItem: redactAccountFields, +})); + +const server = createAskableMcpServer({ + provider: createAskableMcpContextProvider(ctx, { + history: 3, + includeViewport: true, + sources: [{ id: 'accounts', mode: 'all', maxItems: 25, timeoutMs: 750 }], + sourceErrorMode: 'include', + }), +}); +``` Transports are intentionally left to the host app so the same server factory can be used with stdio, Streamable HTTP, or an embedded web runtime. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 2d16caf..856d438 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@askable-ui/mcp", - "version": "0.11.1", + "version": "0.12.0", "description": "MCP bridge for exposing Context packets to agents", "type": "module", "main": "./dist/index.js", @@ -36,8 +36,8 @@ "homepage": "https://askable-ui.com", "license": "MIT", "dependencies": { - "@askable-ui/context": "^0.11.1", - "@askable-ui/core": "^0.11.1", + "@askable-ui/context": "^0.12.0", + "@askable-ui/core": "^0.12.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, diff --git a/packages/mcp/src/__tests__/index.test.ts b/packages/mcp/src/__tests__/index.test.ts index c824607..e440d11 100644 --- a/packages/mcp/src/__tests__/index.test.ts +++ b/packages/mcp/src/__tests__/index.test.ts @@ -42,6 +42,76 @@ describe('createAskableMcpContextProvider', () => { }); }); + it('uses async packet and prompt methods when available', async () => { + const packet = createWebContextPacket({ + capture: { mode: 'semantic' }, + surrounding: { + sources: [ + { + label: 'accounts', + role: 'collection', + metadata: { id: 'accounts', mode: 'summary', data: { total: 12 } }, + }, + ], + }, + }); + const ctx = { + toContextPacket: vi.fn(() => createWebContextPacket({ capture: { mode: 'element-focus' } })), + toContextPacketAsync: vi.fn(async () => packet), + toContext: vi.fn(() => 'Sync prompt'), + toContextAsync: vi.fn(async () => 'Async prompt with sources'), + } satisfies AskableMcpSourceContext; + const provider = createAskableMcpContextProvider(ctx, { + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], + sourceErrorMode: 'include', + }); + + await expect(Promise.resolve(provider.getContext({ + sourceMode: 'summary', + }))).resolves.toBe(packet); + await expect(Promise.resolve(provider.formatContextForPrompt?.(packet, { + sourceLabel: 'App sources', + }))).resolves.toBe('Async prompt with sources'); + + expect(ctx.toContextPacketAsync).toHaveBeenCalledWith({ + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], + sourceErrorMode: 'include', + sourceMode: 'summary', + }); + expect(ctx.toContextPacket).not.toHaveBeenCalled(); + expect(ctx.toContextAsync).toHaveBeenCalledWith({ + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], + sourceErrorMode: 'include', + sourceLabel: 'App sources', + }); + expect(ctx.toContext).not.toHaveBeenCalled(); + }); + + it('removes source options when falling back to sync packet and prompt methods', async () => { + const packet = createWebContextPacket({ + capture: { mode: 'element-focus' }, + }); + const ctx = { + toContextPacket: vi.fn(() => packet), + toContext: vi.fn(() => 'Sync prompt'), + } satisfies AskableMcpSourceContext; + const provider = createAskableMcpContextProvider(ctx, { + sources: ['accounts'], + sourceMode: 'summary', + sourceErrorMode: 'include', + sourceLabel: 'Sources', + currentLabel: 'Current UI', + }); + + await provider.getContext(); + await provider.formatContextForPrompt?.(packet); + + expect(ctx.toContextPacket).toHaveBeenCalledWith({}); + expect(ctx.toContext).toHaveBeenCalledWith({ + currentLabel: 'Current UI', + }); + }); + it('formats prompt text from the source context with prompt-safe options', async () => { const packet = createWebContextPacket({ capture: { mode: 'element-focus' }, @@ -92,6 +162,7 @@ describe('defaultPromptFormatter', () => { surrounding: { visible: [{ metadata: { metric: 'churn' }, text: 'Churn is 4.2%' }], history: [{ metadata: { metric: 'pipeline' }, text: 'Pipeline is $8.1M' }], + sources: [{ label: 'accounts', role: 'collection', metadata: { data: { total: 12 } } }], }, }); @@ -104,6 +175,7 @@ describe('defaultPromptFormatter', () => { 'Target metadata: {"metric":"revenue","delta":"+12%"}', 'Visible context: [{"metadata":{"metric":"churn"},"text":"Churn is 4.2%"}]', 'Recent context: [{"metadata":{"metric":"pipeline"},"text":"Pipeline is $8.1M"}]', + 'Source context: [{"label":"accounts","role":"collection","metadata":{"data":{"total":12}}}]', ].join('\n')); }); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 0dd735c..b857b10 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -3,16 +3,21 @@ import { z } from 'zod'; import { webContextPacketSchema } from '@askable-ui/context'; import type { WebContextPacket } from '@askable-ui/context'; import type { + AskableAsyncContextOutputOptions, + AskableAsyncContextPacketOptions, AskableContext, AskableContextOutputOptions, - AskableContextPacketOptions, + AskableContextSourceErrorMode, + AskableContextSourceInclude, + AskableContextSourceMode, AskablePromptFormat, AskablePromptPreset, } from '@askable-ui/core'; -export interface AskableMcpContextOptions extends AskableContextPacketOptions { +export interface AskableMcpContextOptions extends AskableAsyncContextPacketOptions { currentLabel?: string; historyLabel?: string; + sourceLabel?: string; } export interface AskableMcpContextProvider { @@ -29,7 +34,8 @@ export interface AskableMcpServerOptions { provider: AskableMcpContextProvider; } -export type AskableMcpSourceContext = Pick; +export type AskableMcpSourceContext = Pick & + Partial>; export interface CreateAskableMcpContextProviderOptions extends AskableMcpContextOptions {} @@ -46,6 +52,22 @@ const contextOptionsShape = { intent: z.string().optional(), currentLabel: z.string().optional(), historyLabel: z.string().optional(), + sourceMode: z.string().optional(), + sourceErrorMode: z.enum(['include', 'omit', 'throw']).optional(), + sourceLabel: z.string().optional(), + sources: z.union([ + z.literal('all'), + z.array(z.union([ + z.string(), + z.object({ + id: z.string(), + mode: z.string().optional(), + maxItems: z.number().int().min(0).max(100_000).optional(), + maxTokens: z.number().int().min(1).max(100_000).optional(), + timeoutMs: z.number().int().min(0).max(60_000).optional(), + }).passthrough(), + ])), + ]).optional(), }; export function createAskableMcpContextProvider( @@ -54,10 +76,16 @@ export function createAskableMcpContextProvider( ): AskableMcpContextProvider { return { getContext(options) { - return ctx.toContextPacket(mergeContextOptions(defaults, options)); + const merged = mergeContextOptions(defaults, options); + return ctx.toContextPacketAsync + ? ctx.toContextPacketAsync(merged) + : ctx.toContextPacket(toPacketOptions(merged)); }, formatContextForPrompt(_packet, options) { - return ctx.toContext(toPromptOptions(mergeContextOptions(defaults, options))); + const merged = mergeContextOptions(defaults, options); + return ctx.toContextAsync + ? ctx.toContextAsync(toAsyncPromptOptions(merged)) + : ctx.toContext(toPromptOptions(merged)); }, }; } @@ -163,6 +191,7 @@ export function defaultPromptFormatter(packet: WebContextPacket): string { packet.target?.metadata ? `Target metadata: ${JSON.stringify(packet.target.metadata)}` : undefined, packet.surrounding?.visible?.length ? `Visible context: ${JSON.stringify(packet.surrounding.visible)}` : undefined, packet.surrounding?.history?.length ? `Recent context: ${JSON.stringify(packet.surrounding.history)}` : undefined, + packet.surrounding?.sources?.length ? `Source context: ${JSON.stringify(packet.surrounding.sources)}` : undefined, ]; return parts.filter((part): part is string => Boolean(part)).join('\n'); @@ -195,6 +224,10 @@ function toPromptOptions(options: AskableMcpContextOptions): AskableContextOutpu gesture: _gesture, target: _target, source: _source, + sources: _sources, + sourceMode: _sourceMode, + sourceErrorMode: _sourceErrorMode, + sourceLabel: _sourceLabel, privacy: _privacy, provenance: _provenance, ...promptOptions @@ -206,3 +239,28 @@ function toPromptOptions(options: AskableMcpContextOptions): AskableContextOutpu ...(promptOptions.format ? { format: promptOptions.format as AskablePromptFormat } : {}), }; } + +function toAsyncPromptOptions(options: AskableMcpContextOptions): AskableAsyncContextOutputOptions { + const promptOptions = toPromptOptions(options) as AskableAsyncContextOutputOptions; + return { + ...promptOptions, + ...(options.sources ? { sources: options.sources as 'all' | AskableContextSourceInclude[] } : {}), + ...(options.sourceMode ? { sourceMode: options.sourceMode as AskableContextSourceMode } : {}), + ...(options.sourceErrorMode ? { sourceErrorMode: options.sourceErrorMode as AskableContextSourceErrorMode } : {}), + ...(options.sourceLabel ? { sourceLabel: options.sourceLabel } : {}), + }; +} + +function toPacketOptions(options: AskableMcpContextOptions): AskableMcpContextOptions { + const { + sources: _sources, + sourceMode: _sourceMode, + sourceErrorMode: _sourceErrorMode, + sourceLabel: _sourceLabel, + currentLabel: _currentLabel, + historyLabel: _historyLabel, + ...packetOptions + } = options; + + return packetOptions; +} diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 15b32f2..18c8796 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@askable-ui/react-native", - "version": "0.11.1", + "version": "0.12.0", "description": "React Native bindings for askable — LLM-aware UI context", "type": "module", "main": "./dist/index.js", @@ -39,7 +39,7 @@ "react": ">=17.0.0" }, "dependencies": { - "@askable-ui/core": "^0.11.1" + "@askable-ui/core": "^0.12.0" }, "devDependencies": { "@types/react": "^19.2.15", diff --git a/packages/react/README.md b/packages/react/README.md index e11e145..ab6360a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -33,12 +33,10 @@ function AIChatInput() { async function handleSubmit(question: string) { const res = await fetch('/api/chat', { method: 'POST', - body: JSON.stringify({ - messages: [ - { role: 'system', content: `UI context: ${promptContext}` }, - { role: 'user', content: question }, - ], - }), + body: JSON.stringify(await ctx.toAgentRequest(question, { + history: 3, + packet: true, + })), }); return res.json(); } @@ -164,8 +162,12 @@ const { focus: focusOnly } = useAskable({ events: ['focus'] }); - `ctx.getHistory(limit?)` — focus history, newest first - `ctx.toHistoryContext(limit?, options?)` — history as a prompt-ready string - `ctx.toPromptContext(options?)` — full serialization options (format, maxTokens, excludeKeys, …) + - `ctx.toPromptContextAsync(options?)` — include async app-owned sources + - `ctx.toAgentRequest(question, options?)` — package a user question with prompt context, focus, and an optional Context packet + - `ctx.subscribeAsync(callback, options?)` — stream source-backed context updates with stale result protection - `ctx.serializeFocus(options?)` — structured `AskableSerializedFocus` object - `ctx.toContextPacket()` — structured Context packet for agents and MCP bridges +- `useAskableSource()` — lifecycle-managed app-owned source registration - `useAskableRegionCapture()` — explicit region/circle/lasso capture for visual page selection - `useAskableTextSelectionCapture()` — explicit highlighted text capture for page copy selection @@ -208,6 +210,65 @@ function RevenueCard({ data }) { } ``` +### App-owned sources + +Use `useAskableSource()` when the assistant needs data that is not fully +rendered in the DOM: paginated tables, virtualized lists, documents, charts, +maps, calendars, canvases, or custom product state. The hook registers the +source after mount, keeps the latest resolver implementation current, and +unregisters automatically on unmount. + +```tsx +import { useEffect } from 'react'; +import { useAskableSource } from '@askable-ui/react'; + +function AccountsSource({ table, filters, sort }) { + const accounts = useAskableSource('accounts', { + kind: 'collection', + describe: 'Customer accounts matching the active filters', + getState: () => ({ + filters, + sort, + page: table.getState().pagination.pageIndex + 1, + pageSize: table.getState().pagination.pageSize, + totalCount: table.options.meta?.totalCount, + }), + resolve: async ({ mode, maxItems }) => { + if (mode === 'visible') return table.getRowModel().rows.map((row) => row.original); + return summarizeAccounts({ filters, sort, maxItems }); + }, + sanitize: (source) => ({ + ...source, + data: redactAccountFields(source.data), + }), + }); + + async function askAboutAccounts(question: string) { + const promptContext = await accounts.toPromptContext({ + source: { mode: 'summary', maxItems: 20, timeoutMs: 750 }, + sourceErrorMode: 'include', + }); + + return sendToAgent({ question, promptContext }); + } + + useEffect(() => { + return table.onStateChange(() => { + accounts.notifyChanged(); + }); + }, [accounts.notifyChanged, table]); + + return null; +} +``` + +Use `ctx.toPromptContext()` for synchronous focus-only prompts. Use +`toPromptContextAsync()` or the hook's `toPromptContext()` helper when the +assistant should include resolver-backed application data. +Call `notifyChanged()` when source data changes without a DOM focus change, +such as pagination, filters, selected rows, or query-cache updates. Async +subscribers created with `ctx.subscribeAsync()` re-resolve matching sources. + ### Region, circle, and lasso capture Use `useAskableRegionCapture()` when a user should select an area of the page @@ -247,9 +308,13 @@ function RegionTools() { } ``` -The lasso overlay ships with a solid gradient freehand stroke. Pass `theme` -through `useAskableRegionCapture()` to override the overlay, region/circle -fill, or lasso gradient for your app. +The lasso overlay ships with the core `ASKABLE_REGION_CAPTURE_THEME`. Pass +`theme` through `useAskableRegionCapture()` to override the overlay, +region/circle fill, or lasso gradient for your app. + +Use `once: false` when the capture control should stay active for repeated +region, circle, or lasso selections. The hook keeps `active` true until +`cancel()` or `destroy()` runs. ### Text selection capture diff --git a/packages/react/package.json b/packages/react/package.json index 366ad81..5a0111c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@askable-ui/react", - "version": "0.11.1", + "version": "0.12.0", "description": "React bindings for askable — LLM-aware UI context", "type": "module", "main": "./dist/index.js", @@ -40,7 +40,7 @@ "react-dom": ">=17.0.0" }, "dependencies": { - "@askable-ui/core": "^0.11.1" + "@askable-ui/core": "^0.12.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.0", diff --git a/packages/react/src/__tests__/useAskableRegionCapture.test.tsx b/packages/react/src/__tests__/useAskableRegionCapture.test.tsx index 33f0876..285d546 100644 --- a/packages/react/src/__tests__/useAskableRegionCapture.test.tsx +++ b/packages/react/src/__tests__/useAskableRegionCapture.test.tsx @@ -160,6 +160,46 @@ describe('useAskableRegionCapture', () => { expect(packet.target.metadata.points).toHaveLength(4); }); + it('keeps React state active after capture when once is false', async () => { + function Consumer() { + const capture = useAskableRegionCapture({ once: false }); + + return ( +
+ + {String(capture.active)} + {String(capture.isActive())} + {capture.lastPacket ? JSON.stringify(capture.lastPacket) : 'null'} +
+ ); + } + + render(); + + act(() => { + fireEvent.click(screen.getByText('Start')); + }); + + expect(screen.getByTestId('active').textContent).toBe('true'); + expect(screen.getByTestId('is-active').textContent).toBe('true'); + + const overlay = document.getElementById('askable-region-capture')!; + act(() => { + overlay.dispatchEvent(pointerEvent('pointerdown', 20, 30)); + overlay.dispatchEvent(pointerEvent('pointermove', 80, 90)); + overlay.dispatchEvent(pointerEvent('pointerup', 80, 90)); + }); + + await waitFor(() => { + expect(screen.getByTestId('packet').textContent).not.toBe('null'); + expect(screen.getByTestId('active').textContent).toBe('true'); + expect(screen.getByTestId('is-active').textContent).toBe('true'); + }); + expect(document.getElementById('askable-region-capture')).toBe(overlay); + }); + it('cancels the active overlay from React state', async () => { function Consumer() { const capture = useAskableRegionCapture(); diff --git a/packages/react/src/__tests__/useAskableSource.test.tsx b/packages/react/src/__tests__/useAskableSource.test.tsx new file mode 100644 index 0000000..d3433b2 --- /dev/null +++ b/packages/react/src/__tests__/useAskableSource.test.tsx @@ -0,0 +1,176 @@ +import { render, waitFor } from '@testing-library/react'; +import { useEffect, useMemo } from 'react'; +import { createAskableContext } from '@askable-ui/core'; +import type { AskableContextSource, AskableResolvedContextSource } from '@askable-ui/core'; +import { useAskableSource } from '../useAskableSource.js'; + +describe('useAskableSource', () => { + it('registers a source and unregisters it on unmount', async () => { + const ctx = createAskableContext(); + + function SourceConsumer() { + useAskableSource('accounts', { + kind: 'collection', + getState: () => ({ totalCount: 2 }), + resolve: () => ({ rows: [{ company: 'Acme' }] }), + }, { ctx }); + return null; + } + + const view = render(); + + await waitFor(async () => { + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + id: 'accounts', + kind: 'collection', + state: { totalCount: 2 }, + data: { rows: [{ company: 'Acme' }] }, + }); + }); + + view.unmount(); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + ctx.destroy(); + }); + + it('uses the latest source implementation without re-registering', async () => { + const ctx = createAskableContext(); + const results: AskableResolvedContextSource[] = []; + + function SourceConsumer({ count }: { count: number }) { + useAskableSource('accounts', { + getState: () => ({ count }), + resolve: () => ({ count }), + }, { ctx }); + + useEffect(() => { + ctx.resolveSource('accounts').then((source) => { + results.push(source); + }); + }, [count]); + + return null; + } + + const view = render(); + await waitFor(() => expect(results).toHaveLength(1)); + + view.rerender(); + await waitFor(() => expect(results).toHaveLength(2)); + + expect(results[0].state).toEqual({ count: 1 }); + expect(results[1].state).toEqual({ count: 2 }); + + view.unmount(); + ctx.destroy(); + }); + + it('returns helpers for resolving and serializing the registered source', async () => { + const ctx = createAskableContext(); + let prompt = ''; + + function SourceConsumer() { + const source = useMemo(() => ({ + kind: 'collection', + resolve: ({ mode }) => ({ mode, total: 12 }), + }), []); + const accounts = useAskableSource('accounts', source, { ctx }); + + useEffect(() => { + accounts.toPromptContext({ + source: { mode: 'summary' }, + }).then((value) => { + prompt = value; + }); + }, [accounts]); + + return null; + } + + render(); + + await waitFor(() => expect(prompt).toContain('accounts')); + expect(prompt).toContain('"total":12'); + + ctx.destroy(); + }); + + it('notifies async subscribers when source data changes', async () => { + const ctx = createAskableContext(); + let total = 1; + let accounts: ReturnType | undefined; + + function SourceConsumer() { + accounts = useAskableSource('accounts', { + resolve: () => ({ total }), + }, { ctx }); + return null; + } + + render(); + + await waitFor(async () => { + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + data: { total: 1 }, + }); + }); + + const received: string[] = []; + ctx.subscribeAsync((context) => { + received.push(context); + }, { + emitInitial: true, + sources: ['accounts'], + }); + + await waitFor(() => expect(received).toHaveLength(1)); + total = 2; + accounts?.notifyChanged(); + + await waitFor(() => { + expect(received).toHaveLength(2); + expect(received[1]).toContain('"total":2'); + }); + + ctx.destroy(); + }); + + it('can disable registration', async () => { + const ctx = createAskableContext(); + + function SourceConsumer() { + useAskableSource('accounts', { + resolve: () => ({ total: 1 }), + }, { ctx, enabled: false }); + return null; + } + + render(); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + ctx.destroy(); + }); + + it('normalizes whitespace around ids', async () => { + const ctx = createAskableContext(); + + function SourceConsumer() { + useAskableSource(' accounts ', { + resolve: () => ({ total: 1 }), + }, { ctx }); + return null; + } + + render(); + + await waitFor(async () => { + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + id: 'accounts', + data: { total: 1 }, + }); + }); + + ctx.destroy(); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2d38df3..39fda42 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,9 +1,14 @@ export { Askable } from './Askable.js'; export { AskableInspector } from './AskableInspector.js'; export { useAskable } from './useAskable.js'; +export { useAskableSource } from './useAskableSource.js'; export { useAskableRegionCapture } from './useAskableRegionCapture.js'; export { useAskableTextSelectionCapture } from './useAskableTextSelectionCapture.js'; export type { UseAskableOptions, UseAskableResult } from './useAskable.js'; +export type { + UseAskableSourceOptions, + UseAskableSourceResult, +} from './useAskableSource.js'; export type { UseAskableRegionCaptureOptions, UseAskableRegionCaptureResult, diff --git a/packages/react/src/useAskableRegionCapture.ts b/packages/react/src/useAskableRegionCapture.ts index f36ef4e..1e3a584 100644 --- a/packages/react/src/useAskableRegionCapture.ts +++ b/packages/react/src/useAskableRegionCapture.ts @@ -63,7 +63,12 @@ export function useAskableRegionCapture( onCapture(packet, selection) { setLastPacket(packet); setLastSelection(selection); - setActive(false); + if (currentOptions.once === false) { + setActive(true); + } else { + handleRef.current = null; + setActive(false); + } currentOptions.onCapture?.(packet, selection); }, onCancel() { diff --git a/packages/react/src/useAskableSource.ts b/packages/react/src/useAskableSource.ts new file mode 100644 index 0000000..1192364 --- /dev/null +++ b/packages/react/src/useAskableSource.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { + AskableAsyncPromptContextOptions, + AskableContext, + AskableContextSource, + AskableContextSourceHandle, + AskableContextSourceRequest, + AskableResolvedContextSource, +} from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.js'; + +export interface UseAskableSourceOptions extends Omit { + /** Register the source while true. Defaults to true. */ + enabled?: boolean; +} + +export interface UseAskableSourceResult { + ctx: AskableContext; + sourceId: string; + resolve: (request?: Omit) => Promise; + toPromptContext: ( + options?: Omit + & { source?: Omit }, + ) => Promise; + notifyChanged: () => void; + unregister: () => void; +} + +export function useAskableSource( + id: string, + source: AskableContextSource, + options: UseAskableSourceOptions = {}, +): UseAskableSourceResult { + const { enabled = true, ...askableOptions } = options; + const { ctx } = useAskable(askableOptions); + const sourceRef = useRef(source); + const handleRef = useRef(null); + sourceRef.current = source; + + const sourceId = id.trim(); + + useEffect(() => { + if (!enabled || !sourceId) return undefined; + + const proxy: AskableContextSource = { + get kind() { + return sourceRef.current.kind; + }, + describe: () => { + const describe = sourceRef.current.describe; + if (typeof describe === 'function') return describe(); + return describe ?? ''; + }, + getState: () => sourceRef.current.getState?.(), + resolve: (request) => sourceRef.current.resolve?.(request), + sanitize: (resolved) => sourceRef.current.sanitize?.(resolved) ?? resolved, + }; + + const handle = ctx.registerSource(sourceId, proxy); + handleRef.current = handle; + + return () => { + handle.unregister(); + if (handleRef.current === handle) handleRef.current = null; + }; + }, [ctx, enabled, sourceId]); + + const resolve = useCallback( + (request?: Omit) => ctx.resolveSource(sourceId, request), + [ctx, sourceId], + ); + + const toPromptContext = useCallback( + (promptOptions?: Omit + & { source?: Omit }) => { + const { source: sourceRequest, ...rest } = promptOptions ?? {}; + return ctx.toPromptContextAsync({ + ...rest, + sources: [{ id: sourceId, ...sourceRequest }], + }); + }, + [ctx, sourceId], + ); + + const notifyChanged = useCallback(() => { + handleRef.current?.notifyChanged(); + }, []); + + const unregister = useCallback(() => { + handleRef.current?.unregister(); + handleRef.current = null; + }, []); + + return { + ctx, + sourceId, + resolve, + toPromptContext, + notifyChanged, + unregister, + }; +} diff --git a/packages/svelte/README.md b/packages/svelte/README.md index df3fd99..539cb6e 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -72,8 +72,65 @@ const { focus, promptContext, ctx, destroy } = createAskableStore({ events: ['cl - `ctx.getHistory(limit?)` — focus history, newest first - `ctx.toHistoryContext(limit?, options?)` — history as a prompt-ready string - `ctx.toPromptContext(options?)` — full serialization options (format, maxTokens, excludeKeys, …) +- `ctx.toPromptContextAsync(options?)` — include async app-owned sources - `ctx.serializeFocus(options?)` — structured `AskableSerializedFocus` object +### `createAskableSourceStore(options?)` + +Use `createAskableSourceStore()` when the assistant needs data that is not fully +rendered in the DOM: paginated tables, virtualized lists, documents, charts, +maps, calendars, canvases, or custom product state. The store registers the +source immediately and unregisters it when `destroy()` is called. + +```svelte + +``` + +Call `notifyChanged()` when source data changes without a DOM focus change, +such as pagination, filters, selected rows, or query-cache updates. Async +subscribers created with `ctx.subscribeAsync()` re-resolve matching sources. + ### `createAskableRegionCaptureStore(options?)` Starts an explicit region, circle, or lasso selection overlay and exposes the captured Context packet as Svelte stores. @@ -106,6 +163,9 @@ Starts an explicit region, circle, or lasso selection overlay and exposes the ca ``` The store includes `active`, `lastPacket`, `lastSelection`, `start(overrides)`, `cancel()`, `destroy()`, `isActive()`, and `ctx`. +Pass `once: false` when the capture control should stay active for repeated +region, circle, or lasso selections. The store keeps `active` true until +`cancel()` or `destroy()` runs. ### `createAskableTextSelectionCaptureStore(options?)` diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 8da32de..2abe9b6 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@askable-ui/svelte", - "version": "0.11.1", + "version": "0.12.0", "description": "Svelte 4 & 5 bindings for askable — LLM-aware UI context", "type": "module", "main": "./dist/index.js", @@ -46,7 +46,7 @@ "svelte": "^4.0.0 || ^5.0.0" }, "dependencies": { - "@askable-ui/core": "^0.11.1" + "@askable-ui/core": "^0.12.0" }, "devDependencies": { "@askable-ui/core": "*", diff --git a/packages/svelte/src/__tests__/store.test.ts b/packages/svelte/src/__tests__/store.test.ts index f7665ae..50c0351 100644 --- a/packages/svelte/src/__tests__/store.test.ts +++ b/packages/svelte/src/__tests__/store.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { get } from 'svelte/store'; import { createAskableRegionCaptureStore, + createAskableSourceStore, createAskableStore, createAskableTextSelectionCaptureStore, } from '../askable.js'; @@ -192,6 +193,125 @@ describe('createAskableTextSelectionCaptureStore', () => { }); }); +describe('createAskableSourceStore', () => { + it('registers a source and unregisters it on destroy', async () => { + const { createAskableContext } = await import('@askable-ui/core'); + const ctx = createAskableContext(); + const source = createAskableSourceStore('accounts', { + kind: 'collection', + getState: () => ({ totalCount: 2 }), + resolve: () => ({ rows: [{ company: 'Acme' }] }), + }, { ctx }); + + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + id: 'accounts', + kind: 'collection', + state: { totalCount: 2 }, + data: { rows: [{ company: 'Acme' }] }, + }); + + source.destroy(); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + ctx.destroy(); + }); + + it('resolves current closure values', async () => { + const { createAskableContext } = await import('@askable-ui/core'); + const ctx = createAskableContext(); + let count = 1; + const source = createAskableSourceStore('accounts', { + getState: () => ({ count }), + resolve: () => ({ count }), + }, { ctx }); + + const first = await source.resolve(); + count = 2; + const second = await source.resolve(); + + expect(first.state).toEqual({ count: 1 }); + expect(second.state).toEqual({ count: 2 }); + + source.destroy(); + ctx.destroy(); + }); + + it('returns a helper for serializing the registered source', async () => { + const source = createAskableSourceStore('accounts', { + kind: 'collection', + resolve: ({ mode }) => ({ mode, total: 12 }), + }); + + const prompt = await source.toPromptContext({ + source: { mode: 'summary' }, + }); + + expect(prompt).toContain('accounts'); + expect(prompt).toContain('"total":12'); + + source.destroy(); + }); + + it('notifies async subscribers when source data changes', async () => { + const { createAskableContext } = await import('@askable-ui/core'); + const ctx = createAskableContext(); + let total = 1; + const source = createAskableSourceStore('accounts', { + resolve: () => ({ total }), + }, { ctx }); + + const received: string[] = []; + ctx.subscribeAsync((context) => { + received.push(context); + }, { + emitInitial: true, + sources: ['accounts'], + }); + + await vi.waitFor(() => expect(received).toHaveLength(1)); + + total = 2; + source.notifyChanged(); + + await vi.waitFor(() => { + expect(received).toHaveLength(2); + expect(received[1]).toContain('"total":2'); + }); + + source.destroy(); + ctx.destroy(); + }); + + it('can disable registration', async () => { + const { createAskableContext } = await import('@askable-ui/core'); + const ctx = createAskableContext(); + const source = createAskableSourceStore('accounts', { + resolve: () => ({ total: 1 }), + }, { ctx, enabled: false }); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + + source.destroy(); + ctx.destroy(); + }); + + it('normalizes whitespace around ids', async () => { + const { createAskableContext } = await import('@askable-ui/core'); + const ctx = createAskableContext(); + const source = createAskableSourceStore(' accounts ', { + resolve: () => ({ total: 1 }), + }, { ctx }); + + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + id: 'accounts', + data: { total: 1 }, + }); + + source.destroy(); + ctx.destroy(); + }); +}); + describe('createAskableRegionCaptureStore', () => { afterEach(() => { document.getElementById('askable-region-capture')?.remove(); @@ -277,6 +397,31 @@ describe('createAskableRegionCaptureStore', () => { capture.destroy(); }); + it('keeps store state active after capture when once is false', () => { + const capture = createAskableRegionCaptureStore({ once: false }); + + capture.start(); + expect(get(capture.active)).toBe(true); + expect(capture.isActive()).toBe(true); + + const overlay = document.getElementById('askable-region-capture')!; + overlay.dispatchEvent(pointerEvent('pointerdown', 20, 30)); + overlay.dispatchEvent(pointerEvent('pointermove', 80, 90)); + overlay.dispatchEvent(pointerEvent('pointerup', 80, 90)); + + expect(get(capture.lastPacket)).toMatchObject({ + capture: { + mode: 'region', + gesture: 'drag', + }, + }); + expect(get(capture.active)).toBe(true); + expect(capture.isActive()).toBe(true); + expect(document.getElementById('askable-region-capture')).toBe(overlay); + + capture.destroy(); + }); + it('cancels active capture from store state', () => { const capture = createAskableRegionCaptureStore(); diff --git a/packages/svelte/src/askable.ts b/packages/svelte/src/askable.ts index eabfce7..4ca1ef0 100644 --- a/packages/svelte/src/askable.ts +++ b/packages/svelte/src/askable.ts @@ -6,9 +6,12 @@ import { createAskableTextSelectionCapture, } from '@askable-ui/core'; import type { + AskableAsyncPromptContextOptions, AskableEvent, AskableFocus, AskableContext, + AskableContextSource, + AskableContextSourceRequest, AskableInspectorOptions, AskableContextOptions, AskableRegionCaptureHandle, @@ -17,6 +20,7 @@ import type { AskableTextSelectionCaptureHandle, AskableTextSelectionCaptureOptions, AskableTextSelectionCaptureSelection, + AskableResolvedContextSource, WebContextPacket, } from '@askable-ui/core'; @@ -60,6 +64,24 @@ export interface AskableTextSelectionCaptureStore { isActive: () => boolean; } +export interface AskableSourceStoreOptions extends AskableStoreOptions { + /** Register the source while true. Defaults to true. */ + enabled?: boolean; +} + +export interface AskableSourceStore { + ctx: AskableContext; + sourceId: string; + resolve: (request?: Omit) => Promise; + toPromptContext: ( + options?: Omit + & { source?: Omit }, + ) => Promise; + notifyChanged: () => void; + unregister: () => void; + destroy: () => void; +} + export function createAskableStore(options?: AskableStoreOptions) { const usesProvidedCtx = Boolean(options?.ctx); const ctx = options?.ctx ?? createAskableContext(options?.name ? { name: options.name } : undefined); @@ -100,6 +122,72 @@ export function createAskableStore(options?: AskableStoreOptions) { return { focus, promptContext, ctx, destroy }; } +export function createAskableSourceStore( + id: string, + source: AskableContextSource, + options: AskableSourceStoreOptions = {}, +): AskableSourceStore { + const { enabled = true, ...storeOptions } = options; + const askable = createAskableStore(storeOptions); + const sourceId = id.trim(); + let registered = false; + let handle: ReturnType | null = null; + + function buildProxy(): AskableContextSource { + return { + get kind() { + return source.kind; + }, + describe: () => { + const describe = source.describe; + if (typeof describe === 'function') return describe(); + return describe ?? ''; + }, + getState: () => source.getState?.(), + resolve: (request) => source.resolve?.(request), + sanitize: (resolved) => source.sanitize?.(resolved) ?? resolved, + }; + } + + function unregister() { + if (!registered) return; + handle?.unregister(); + handle = null; + registered = false; + } + + if (enabled && sourceId) { + handle = askable.ctx.registerSource(sourceId, buildProxy()); + registered = true; + } + + function notifyChanged() { + handle?.notifyChanged(); + } + + function destroy() { + unregister(); + askable.destroy(); + } + + return { + ctx: askable.ctx, + sourceId, + resolve: (request?: Omit) => askable.ctx.resolveSource(sourceId, request), + toPromptContext: (promptOptions?: Omit + & { source?: Omit }) => { + const { source: sourceRequest, ...rest } = promptOptions ?? {}; + return askable.ctx.toPromptContextAsync({ + ...rest, + sources: [{ id: sourceId, ...sourceRequest }], + }); + }, + notifyChanged, + unregister, + destroy, + }; +} + export function createAskableRegionCaptureStore( options: AskableRegionCaptureStoreOptions = {}, ): AskableRegionCaptureStore { @@ -129,8 +217,12 @@ export function createAskableRegionCaptureStore( onCapture(packet, selection) { _lastPacket.set(packet); _lastSelection.set(selection); - handle = null; - _active.set(false); + if (currentOptions.once === false) { + _active.set(true); + } else { + handle = null; + _active.set(false); + } currentOptions.onCapture?.(packet, selection); }, onCancel() { diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 9b0ad99..a812031 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -1,12 +1,15 @@ // Svelte 4 — store-based API export { createAskableRegionCaptureStore, + createAskableSourceStore, createAskableStore, createAskableTextSelectionCaptureStore, } from './askable.js'; export type { AskableRegionCaptureStore, AskableRegionCaptureStoreOptions, + AskableSourceStore, + AskableSourceStoreOptions, AskableStore, AskableStoreOptions, AskableTextSelectionCaptureStore, diff --git a/packages/vue/README.md b/packages/vue/README.md index 5d53c01..af3ad69 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -67,10 +67,64 @@ const { focus, promptContext } = useAskable({ events: ['click'] }); - `ctx.getHistory(limit?)` — focus history, newest first - `ctx.toHistoryContext(limit?, options?)` — history as a prompt-ready string - `ctx.toPromptContext(options?)` — full serialization options (format, maxTokens, excludeKeys, …) +- `ctx.toPromptContextAsync(options?)` — include async app-owned sources - `ctx.serializeFocus(options?)` — structured `AskableSerializedFocus` object +- `useAskableSource()` — lifecycle-managed app-owned source registration The composable manages a shared singleton context per `events` configuration. Multiple `useAskable()` consumers with the same `events` reuse one observer lifecycle, while differing `events` configurations get isolated shared contexts of their own. Each shared context is automatically destroyed when its last consumer unmounts. +### `useAskableSource(options?)` + +Use `useAskableSource()` when the assistant needs data that is not fully +rendered in the DOM: paginated tables, virtualized lists, documents, charts, +maps, calendars, canvases, or custom product state. The composable registers the +source during setup, keeps reactive values current through your resolver +closures, and unregisters automatically on unmount. + +```vue + +``` + +Call `notifyChanged()` when source data changes without a DOM focus change, +such as pagination, filters, selected rows, or query-cache updates. Async +subscribers created with `ctx.subscribeAsync()` re-resolve matching sources. + ### `useAskableRegionCapture(options?)` Starts an explicit region, circle, or lasso selection overlay and emits a structured Context packet through the same `AskableContext`. @@ -101,6 +155,9 @@ const selectedContext = computed(() => ``` The result includes `active`, `lastPacket`, `lastSelection`, `start(overrides)`, `cancel()`, `destroy()`, `isActive()`, and `ctx`. +Pass `once: false` when the capture control should stay active for repeated +region, circle, or lasso selections. The composable keeps `active` true until +`cancel()` or `destroy()` runs. ### `useAskableTextSelectionCapture(options?)` diff --git a/packages/vue/package.json b/packages/vue/package.json index cb4bc12..c4b9666 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@askable-ui/vue", - "version": "0.11.1", + "version": "0.12.0", "description": "Vue 3 bindings for askable — LLM-aware UI context", "type": "module", "main": "./dist/index.js", @@ -39,7 +39,7 @@ "vue": "^3.0.0" }, "dependencies": { - "@askable-ui/core": "^0.11.1" + "@askable-ui/core": "^0.12.0" }, "devDependencies": { "@askable-ui/core": "*", diff --git a/packages/vue/src/__tests__/useAskableRegionCapture.test.ts b/packages/vue/src/__tests__/useAskableRegionCapture.test.ts index 37f750f..c38eb0c 100644 --- a/packages/vue/src/__tests__/useAskableRegionCapture.test.ts +++ b/packages/vue/src/__tests__/useAskableRegionCapture.test.ts @@ -167,6 +167,46 @@ describe('useAskableRegionCapture (Vue)', () => { expect(packet.target.metadata.points).toHaveLength(4); }); + it('keeps Vue state active after capture when once is false', async () => { + const Consumer = defineComponent({ + name: 'RepeatedCaptureConsumer', + setup() { + const capture = useAskableRegionCapture({ once: false }); + const packet = computed(() => capture.lastPacket.value ? JSON.stringify(capture.lastPacket.value) : 'null'); + return { + active: capture.active, + packet, + start: capture.start, + }; + }, + template: ` +
+ + {{ String(active) }} + {{ packet }} +
+ `, + }); + + const wrapper = track(mount(Consumer, { attachTo: document.body })); + await flushAll(); + + await wrapper.find('button').trigger('click'); + await nextTick(); + + expect(wrapper.find('[data-testid="active"]').text()).toBe('true'); + + const overlay = document.getElementById('askable-region-capture')!; + overlay.dispatchEvent(pointerEvent('pointerdown', 20, 30)); + overlay.dispatchEvent(pointerEvent('pointermove', 80, 90)); + overlay.dispatchEvent(pointerEvent('pointerup', 80, 90)); + await flushAll(); + + expect(wrapper.find('[data-testid="packet"]').text()).not.toBe('null'); + expect(wrapper.find('[data-testid="active"]').text()).toBe('true'); + expect(document.getElementById('askable-region-capture')).toBe(overlay); + }); + it('cancels active capture from Vue state', async () => { const Consumer = defineComponent({ name: 'CancelCaptureConsumer', diff --git a/packages/vue/src/__tests__/useAskableSource.test.ts b/packages/vue/src/__tests__/useAskableSource.test.ts new file mode 100644 index 0000000..f7b364f --- /dev/null +++ b/packages/vue/src/__tests__/useAskableSource.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { defineComponent, nextTick, reactive } from 'vue'; +import { createAskableContext } from '@askable-ui/core'; +import type { AskableResolvedContextSource } from '@askable-ui/core'; +import { useAskableSource } from '../useAskableSource.js'; +import { track } from './helpers.js'; + +async function flushAll() { + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); +} + +describe('useAskableSource (Vue)', () => { + it('registers a source and unregisters it on unmount', async () => { + const ctx = createAskableContext(); + + const SourceConsumer = defineComponent({ + setup() { + useAskableSource('accounts', { + kind: 'collection', + getState: () => ({ totalCount: 2 }), + resolve: () => ({ rows: [{ company: 'Acme' }] }), + }, { ctx }); + return {}; + }, + template: '
', + }); + + const wrapper = track(mount(SourceConsumer, { attachTo: document.body })); + await flushAll(); + + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + id: 'accounts', + kind: 'collection', + state: { totalCount: 2 }, + data: { rows: [{ company: 'Acme' }] }, + }); + + wrapper.unmount(); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + ctx.destroy(); + }); + + it('resolves current reactive values', async () => { + const ctx = createAskableContext(); + const state = reactive({ count: 1 }); + + const SourceConsumer = defineComponent({ + setup() { + useAskableSource('accounts', { + getState: () => ({ count: state.count }), + resolve: () => ({ count: state.count }), + }, { ctx }); + return {}; + }, + template: '
', + }); + + const wrapper = track(mount(SourceConsumer, { attachTo: document.body })); + await flushAll(); + + const first = await ctx.resolveSource('accounts'); + state.count = 2; + await nextTick(); + const second = await ctx.resolveSource('accounts'); + + expect(first.state).toEqual({ count: 1 }); + expect(second.state).toEqual({ count: 2 }); + + wrapper.unmount(); + ctx.destroy(); + }); + + it('returns helpers for resolving and serializing the registered source', async () => { + const ctx = createAskableContext(); + const results: AskableResolvedContextSource[] = []; + let prompt = ''; + + const SourceConsumer = defineComponent({ + async setup() { + const accounts = useAskableSource('accounts', { + kind: 'collection', + resolve: ({ mode }) => ({ mode, total: 12 }), + }, { ctx }); + + results.push(await accounts.resolve({ mode: 'summary' })); + prompt = await accounts.toPromptContext({ + source: { mode: 'summary' }, + }); + return {}; + }, + template: '
', + }); + + track(mount(SourceConsumer, { attachTo: document.body })); + await flushAll(); + + expect(results[0]).toMatchObject({ + id: 'accounts', + kind: 'collection', + data: { mode: 'summary', total: 12 }, + }); + expect(prompt).toContain('accounts'); + expect(prompt).toContain('"total":12'); + + ctx.destroy(); + }); + + it('notifies async subscribers when source data changes', async () => { + const ctx = createAskableContext(); + let total = 1; + let accounts: ReturnType | undefined; + + const SourceConsumer = defineComponent({ + setup() { + accounts = useAskableSource('accounts', { + resolve: () => ({ total }), + }, { ctx }); + return {}; + }, + template: '
', + }); + + track(mount(SourceConsumer, { attachTo: document.body })); + await flushAll(); + + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + data: { total: 1 }, + }); + + const received: string[] = []; + ctx.subscribeAsync((context) => { + received.push(context); + }, { + emitInitial: true, + sources: ['accounts'], + }); + + await flushAll(); + expect(received).toHaveLength(1); + + total = 2; + accounts?.notifyChanged(); + await flushAll(); + + expect(received).toHaveLength(2); + expect(received[1]).toContain('"total":2'); + + ctx.destroy(); + }); + + it('can disable registration', async () => { + const ctx = createAskableContext(); + + const SourceConsumer = defineComponent({ + setup() { + useAskableSource('accounts', { + resolve: () => ({ total: 1 }), + }, { ctx, enabled: false }); + return {}; + }, + template: '
', + }); + + track(mount(SourceConsumer, { attachTo: document.body })); + await flushAll(); + + await expect(ctx.resolveSource('accounts')).rejects.toThrow('not registered'); + ctx.destroy(); + }); + + it('normalizes whitespace around ids', async () => { + const ctx = createAskableContext(); + + const SourceConsumer = defineComponent({ + setup() { + useAskableSource(' accounts ', { + resolve: () => ({ total: 1 }), + }, { ctx }); + return {}; + }, + template: '
', + }); + + const wrapper = track(mount(SourceConsumer, { attachTo: document.body })); + await flushAll(); + + await expect(ctx.resolveSource('accounts')).resolves.toMatchObject({ + id: 'accounts', + data: { total: 1 }, + }); + + wrapper.unmount(); + ctx.destroy(); + }); +}); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 8c88f47..8484d15 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,9 +1,14 @@ export { Askable } from './Askable.js'; export { AskableInspector } from './AskableInspector.js'; export { useAskable } from './useAskable.js'; +export { useAskableSource } from './useAskableSource.js'; export { useAskableRegionCapture } from './useAskableRegionCapture.js'; export { useAskableTextSelectionCapture } from './useAskableTextSelectionCapture.js'; export type { UseAskableOptions, UseAskableResult } from './useAskable.js'; +export type { + UseAskableSourceOptions, + UseAskableSourceResult, +} from './useAskableSource.js'; export type { UseAskableRegionCaptureOptions, UseAskableRegionCaptureResult, diff --git a/packages/vue/src/useAskableRegionCapture.ts b/packages/vue/src/useAskableRegionCapture.ts index 55f4c7d..f699db1 100644 --- a/packages/vue/src/useAskableRegionCapture.ts +++ b/packages/vue/src/useAskableRegionCapture.ts @@ -58,8 +58,12 @@ export function useAskableRegionCapture( onCapture(packet, selection) { lastPacket.value = packet; lastSelection.value = selection; - handle = null; - active.value = false; + if (currentOptions.once === false) { + active.value = true; + } else { + handle = null; + active.value = false; + } currentOptions.onCapture?.(packet, selection); }, onCancel() { diff --git a/packages/vue/src/useAskableSource.ts b/packages/vue/src/useAskableSource.ts new file mode 100644 index 0000000..65928ce --- /dev/null +++ b/packages/vue/src/useAskableSource.ts @@ -0,0 +1,88 @@ +import { onUnmounted } from 'vue'; +import type { + AskableAsyncPromptContextOptions, + AskableContext, + AskableContextSource, + AskableContextSourceRequest, + AskableResolvedContextSource, +} from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.js'; + +export interface UseAskableSourceOptions extends Omit { + /** Register the source while true. Defaults to true. */ + enabled?: boolean; +} + +export interface UseAskableSourceResult { + ctx: AskableContext; + sourceId: string; + resolve: (request?: Omit) => Promise; + toPromptContext: ( + options?: Omit + & { source?: Omit }, + ) => Promise; + notifyChanged: () => void; + unregister: () => void; +} + +export function useAskableSource( + id: string, + source: AskableContextSource, + options: UseAskableSourceOptions = {}, +): UseAskableSourceResult { + const { enabled = true, ...askableOptions } = options; + const { ctx } = useAskable(askableOptions); + const sourceId = id.trim(); + let registered = false; + let handle: ReturnType | null = null; + + function buildProxy(): AskableContextSource { + return { + get kind() { + return source.kind; + }, + describe: () => { + const describe = source.describe; + if (typeof describe === 'function') return describe(); + return describe ?? ''; + }, + getState: () => source.getState?.(), + resolve: (request) => source.resolve?.(request), + sanitize: (resolved) => source.sanitize?.(resolved) ?? resolved, + }; + } + + function unregister() { + if (!registered) return; + handle?.unregister(); + handle = null; + registered = false; + } + + if (enabled && sourceId) { + handle = ctx.registerSource(sourceId, buildProxy()); + registered = true; + } + + function notifyChanged() { + handle?.notifyChanged(); + } + + onUnmounted(unregister); + + return { + ctx, + sourceId, + resolve: (request?: Omit) => ctx.resolveSource(sourceId, request), + toPromptContext: (promptOptions?: Omit + & { source?: Omit }) => { + const { source: sourceRequest, ...rest } = promptOptions ?? {}; + return ctx.toPromptContextAsync({ + ...rest, + sources: [{ id: sourceId, ...sourceRequest }], + }); + }, + notifyChanged, + unregister, + }; +} diff --git a/site/docs/api/context.md b/site/docs/api/context.md index 4fd304f..49d6058 100644 --- a/site/docs/api/context.md +++ b/site/docs/api/context.md @@ -62,6 +62,6 @@ JSON.stringify(webContextPacketSchema, null, 2); | `source` | URL, title, app, route, and timestamp | | `capture` | Capture mode, gesture, and optional user intent | | `target` | Text, label, role, selector, bounds, metadata, and screenshot | -| `surrounding` | Ancestors, nearby items, visible items, and history | +| `surrounding` | Ancestors, nearby items, visible items, history, and app-owned sources | | `privacy` | Redaction and consent metadata | | `provenance` | Producer and capture method | diff --git a/site/docs/api/core.md b/site/docs/api/core.md index 4b5f466..134f068 100644 --- a/site/docs/api/core.md +++ b/site/docs/api/core.md @@ -215,6 +215,103 @@ Sanitizers (`sanitizeMeta`, `sanitizeText`) apply to `push()` the same way they --- +### `registerSource(id, source)` + +Register app-owned context that is not fully represented in the DOM: paginated +tables, virtualized lists, documents, maps, canvases, calendars, charts, file +trees, or any custom state. + +```ts +import { createAskableCollectionSource, createAskableSource } from '@askable-ui/core'; + +const handle = ctx.registerSource('accounts', createAskableCollectionSource({ + describe: 'Customer accounts in the dashboard', + getState: () => ({ + filters, + sort, + page, + pageSize, + totalCount, + }), + getVisibleItems: () => table.getVisibleRows(), + getSelectedItems: ({ selection }) => getSelectedAccounts(selection), + getItems: () => accountStore.getAllMatching({ filters, sort }), + getSummary: ({ focus, maxItems }) => summarizeAccounts({ filters, sort, focus, maxItems }), + maxItems: 50, + sanitizeItem: redactAccountFields, + sanitize: (source) => ({ + ...source, + state: redactFilterState(source.state), + }), +})); + +ctx.registerSource('active-document', createAskableSource({ + kind: 'document', + describe: 'Open editor document', + state: () => ({ title: editor.title, dirty: editor.dirty }), + data: ({ mode }) => mode === 'summary' ? editor.summary() : editor.export(), +})); + +await ctx.toPromptContextAsync({ + sources: [{ id: 'accounts', mode: 'all', maxItems: 20, timeoutMs: 750 }], + sourceErrorMode: 'include', +}); + +table.onStateChange(() => { + handle.notifyChanged(); +}); + +handle.unregister(); +``` + +Use this when the UI only renders part of the data. Askable captures what the +user meant; the source resolver supplies what the app knows. + +| Field | Type | Description | +|---|---|---| +| `kind` | `string` | Optional category, such as `collection`, `document`, `chart`, `map`, or `custom` | +| `describe` | `string \| () => string \| Promise` | Human-readable source description | +| `getState` | `() => unknown \| Promise` | Current state, such as filters, sort, page, route, or viewport | +| `resolve` | `(request) => unknown \| Promise` | Returns selected, visible, summary, all-matching, or custom context | +| `sanitize` | `(source) => source \| Promise` | Redacts or transforms resolved source context before serialization | + +`createAskableSource()` is a small factory for arbitrary app context. +`createAskableCollectionSource()` adds `getItems`, `getVisibleItems`, +`getSelectedItems`, `getSummary`, `maxItems`, and `sanitizeItem` so paginated or +virtualized collections can expose more than the rows currently mounted in the +DOM without a table-specific API. + +`registerSource()` returns a handle with `id`, `notifyChanged()`, and +`unregister()`. Call `notifyChanged()` when filters, pagination, selected +records, query caches, documents, or store data change without a DOM focus +change. Source-backed async subscribers re-resolve matching sources +automatically. Stale handles from unmounted or replaced components cannot +unregister or notify a newer source with the same id. + +Use `ctx.hasSource(id)` and `ctx.listSources()` to drive source pickers, +diagnostics, or chat controls without resolving source data. `listSources()` +returns each source id, optional kind, registration time, and last update time. + +Async prompt methods isolate source failures by default. If a resolver throws or +times out, Askable includes a safe `Context source unavailable.` marker and does +not expose the original error message or stack trace. Use +`sourceErrorMode: 'omit'` to skip failed sources or `'throw'` to fail the prompt +call. + +Related methods: + +```ts +ctx.hasSource('accounts'); +ctx.listSources(); +ctx.unregisterSource('accounts'); +ctx.notifySourceChanged('accounts'); +await ctx.resolveSource('accounts', { mode: 'visible' }); +await ctx.toPromptContextAsync({ sources: 'all' }); +await ctx.toContextAsync({ history: 3, sources: ['accounts'] }); +``` + +--- + ### `clear()` Reset current focus to `null` and fire the `'clear'` event. History is not affected. @@ -259,6 +356,49 @@ Use this when the model/runtime should stay in sync while the user keeps interac --- +### `subscribeAsync(callback, options?)` + +Subscribe to source-backed serialized context updates for streaming or +long-running AI integrations. The callback receives the latest +`ctx.toContextAsync()` output plus the current `AskableFocus | null`. + +```ts +const unsubscribe = ctx.subscribeAsync(async (context, focus) => { + await streamTransport.send({ + type: 'ui-context', + context, + focusMeta: focus?.meta ?? null, + }); +}, { + history: 3, + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], + debounce: 100, + onError(error) { + reportContextError(error); + }, +}); +``` + +Async subscriptions rerun when focus changes, clear is called, or a matching +source calls `notifyChanged()`. They ignore stale resolver results when a newer +focus or source update happens before an earlier request finishes. Use +`emitInitial: true` when the runtime needs the current context as soon as the +subscription is registered. + +**Options:** + +| Option | Type | Default | Description | +|---|---|---|---| +| `sources` | `'all' \| Array` | — | Sources to resolve and append | +| `sourceMode` | `AskableContextSourceMode` | `'summary'` | Default source mode | +| `sourceLabel` | `string` | `'Context sources'` | Natural-language source section label | +| `sourceErrorMode` | `'include' \| 'omit' \| 'throw'` | `'include'` | How failed sources are handled | +| `emitInitial` | `boolean` | `false` | Emit once immediately after registration | +| `onError` | `(error) => void` | — | Handles source or callback failures | +| _...all `AskableContextOutputOptions`_ | | | Passed through to `toContextAsync()` | + +--- + ### `toPromptContext(options?)` Serialize the current focus to a prompt-ready string. See [Prompt Serialization](/guide/serialization) for full option details. @@ -284,6 +424,116 @@ ctx.toPromptContext({ scope: 'analytics' }); --- +### `toPromptContextAsync(options?)` + +Serialize the current focus plus registered async context sources. + +```ts +await ctx.toPromptContextAsync({ + sources: [ + { id: 'accounts', mode: 'summary', timeoutMs: 750 }, + { id: 'calendar', mode: 'selected' }, + ], + sourceErrorMode: 'include', +}); + +await ctx.toPromptContextAsync({ sources: 'all', sourceMode: 'summary' }); +``` + +In JSON mode, the output is wrapped as: + +```json +{ + "focus": { "meta": { "widget": "accounts-table" }, "text": "Accounts" }, + "sources": [ + { + "id": "accounts", + "kind": "collection", + "mode": "summary", + "state": { "page": 2, "totalCount": 80 }, + "data": { "atRisk": 4 } + } + ] +} +``` + +Use `toPromptContext()` for synchronous focus-only prompts. Use +`toPromptContextAsync()` when the prompt should include app state, API data, +summaries, selected rows, or other resolver-backed context. + +| Option | Type | Default | Description | +|---|---|---|---| +| `sources` | `'all' \| Array` | — | Sources to resolve and append | +| `sourceMode` | `AskableContextSourceMode` | `'summary'` | Default mode when a source request omits `mode` | +| `sourceLabel` | `string` | `'Context sources'` | Natural-language section label | +| `sourceErrorMode` | `'include' \| 'omit' \| 'throw'` | `'include'` | How failed sources are handled | + +--- + +### `toAgentRequest(question, options?)` + +Package a user question with source-backed Askable context for chat and agent +transports. This returns a JSON-ready payload so production apps do not need to +invent a different request shape for each provider. + +```ts +const request = await ctx.toAgentRequest('Which accounts need follow-up?', { + requestId: crypto.randomUUID(), + history: 3, + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], + packet: true, + metadata: { + route: '/accounts', + }, +}); + +await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), +}); +``` + +The returned object includes: + +| Field | Description | +|---|---| +| `question` | User-authored question or instruction | +| `context` | Prompt-ready string from `toContextAsync()` | +| `focus` | Serialized current focus at request creation time | +| `packet` | Optional structured Context packet when `packet` is enabled | +| `requestId` | Optional app-provided tracing id | +| `metadata` | Optional app-provided metadata | +| `timestamp` | Unix timestamp in ms | + +Pass `packet: true` to derive packet options from the request options, pass a +full `AskableAsyncContextPacketOptions` object when the packet needs different +privacy, provenance, source, or capture settings than the prompt context, or +pass an existing `WebContextPacket` from a region, circle, lasso, or text +selection capture. Existing packets are attached as-is, which is useful for +"select first, then ask a question" chat composers. + +```ts +let pendingPacket: WebContextPacket | null = null; + +const lasso = createAskableRegionCapture(ctx, { + shape: 'lasso', + onCapture: (packet) => { + pendingPacket = packet; + openChatComposer(); + }, +}); + +async function submit(question: string) { + return ctx.toAgentRequest(question, { + packet: pendingPacket ?? true, + sources: ['accounts'], + }); +} +``` + +--- + ### `toHistoryContext(limit?, options?)` Serialize focus history as a numbered, prompt-ready string. @@ -366,6 +616,49 @@ const packet = ctx.toContextPacket({ --- +### `toContextPacketAsync(options?)` + +Serialize the current UI state plus registered async context sources to a +structured Context packet. Resolved sources are added to +`packet.surrounding.sources`. + +```ts +const packet = await ctx.toContextPacketAsync({ + source: { app: 'analytics-dashboard' }, + sources: [{ id: 'accounts', mode: 'summary', maxItems: 20, timeoutMs: 750 }], + sourceErrorMode: 'include', +}); + +packet.surrounding?.sources; +// [ +// { +// label: 'accounts', +// role: 'collection', +// metadata: { +// id: 'accounts', +// mode: 'summary', +// state: { ... }, +// data: { ... } +// } +// } +// ] +``` + +Use `toContextPacket()` for synchronous focus/viewport/history packets. Use +`toContextPacketAsync()` when agent runtimes or MCP bridges need resolver-backed +application state in the same structured packet. + +| Option | Type | Default | Description | +|---|---|---|---| +| `sources` | `'all' \| Array` | — | Sources to resolve into `surrounding.sources` | +| `sourceMode` | `AskableContextSourceMode` | `'summary'` | Default mode when a source request omits `mode` | +| `sourceErrorMode` | `'include' \| 'omit' \| 'throw'` | `'include'` | How failed sources are handled | +| _...all `AskableContextPacketOptions`_ | | | Passed through to base packet serialization | + +**Returns:** `Promise` + +--- + ### `serializeFocus(options?)` Returns structured focus data as `AskableSerializedFocus | null`. Same options as `toPromptContext()`. @@ -396,7 +689,11 @@ Mounts a temporary browser overlay that lets the user drag a rectangle, circle, or lasso, then emits a structured Context packet with explicit consent metadata. ```ts -import { createAskableContext, createAskableRegionCapture } from '@askable-ui/core'; +import { + ASKABLE_REGION_CAPTURE_THEME, + createAskableContext, + createAskableRegionCapture, +} from '@askable-ui/core'; const ctx = createAskableContext({ viewport: true }); ctx.observe(document); @@ -406,11 +703,12 @@ const capture = createAskableRegionCapture(ctx, { intent: 'explain this selected area', includeViewport: true, theme: { + ...ASKABLE_REGION_CAPTURE_THEME, lassoStrokeWidth: 4, lassoGradientStops: [ - { offset: '0%', color: '#06b6d4' }, - { offset: '70%', color: '#a855f7' }, - { offset: '100%', color: '#22c55e' }, + { offset: '0%', color: '#6d28d9' }, + { offset: '78%', color: '#8b5cf6' }, + { offset: '100%', color: '#a78bfa' }, ], }, onCapture: (packet, selection) => { @@ -429,14 +727,18 @@ capture.start(); | `shape` | `'region' \| 'circle' \| 'lasso'` | `'region'` | Shape produced by the drag gesture | | `minSize` | `number` | `6` | Minimum accepted width/height in CSS pixels | | `once` | `boolean` | `true` | Remove the overlay after the first accepted capture | -| `theme` | `Partial` | Askable default | Overlay colors, selection fill/stroke, and lasso gradient/glow styling | +| `theme` | `Partial` | `ASKABLE_REGION_CAPTURE_THEME` | Overlay colors, selection fill/stroke, and lasso gradient/glow styling | | `onCapture` | `(packet, selection) => void` | — | Called with the Context packet and selection geometry | | `onCancel` | `() => void` | — | Called when the capture is cancelled | | _...most `AskableContextPacketOptions`_ | | | Passed through to `toContextPacket()` | -The default lasso theme is a solid gradient freehand stroke with a soft glow. -Use `theme` when your app needs brand-specific capture styling without -replacing the library overlay. +The default lasso theme is exported as `ASKABLE_REGION_CAPTURE_THEME`. Use +`theme` when your app needs brand-specific capture styling without replacing the +library overlay. + +Set `once: false` for persistent tools in production dashboards, canvases, and +editors. The overlay stays mounted after each accepted capture, and +`isActive()` remains `true` until `cancel()` or `destroy()` is called. **Returns:** `AskableRegionCaptureHandle` — object with `start()`, `cancel()`, `destroy()`, and `isActive()` methods. diff --git a/site/docs/api/mcp.md b/site/docs/api/mcp.md index 5cd5fc5..8412987 100644 --- a/site/docs/api/mcp.md +++ b/site/docs/api/mcp.md @@ -28,12 +28,36 @@ Adapts an existing `AskableContext` to the MCP provider interface. | Option | Type | Description | |---|---|---| -| `ctx` | `Pick` | Source context to expose through MCP | +| `ctx` | `Pick` plus optional async methods | Source context to expose through MCP | | `defaults` | `AskableMcpContextOptions` | Default packet and prompt options applied to every tool call | Defaults and tool-call options are merged. Nested `source`, `privacy`, and `provenance` metadata are merged field-by-field. +When the provided context implements `toContextPacketAsync()` and +`toContextAsync()`, the built-in provider uses those methods so registered +app-owned sources can flow into MCP tools. + +```ts +import { createAskableCollectionSource } from '@askable-ui/core'; + +ctx.registerSource('accounts', createAskableCollectionSource({ + describe: 'Accounts matching active filters', + getState: () => ({ filters, sort, totalCount }), + getVisibleItems: () => table.getVisibleRows(), + getItems: () => accountStore.getAllMatching({ filters, sort }), + getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), + sanitizeItem: redactAccountFields, +})); + +const provider = createAskableMcpContextProvider(ctx, { + history: 3, + includeViewport: true, + sources: [{ id: 'accounts', mode: 'all', maxItems: 25, timeoutMs: 750 }], + sourceErrorMode: 'include', +}); +``` + ## `createAskableMcpServer(options)` Creates an MCP server with tools/resources for reading structured Context packets. @@ -61,8 +85,12 @@ options: | `hierarchyDepth` | Limit included ancestor hierarchy | | `history` | Include recent interactions | | `includeViewport` | Include visible annotated elements in packets | +| `sources` | Include registered app-owned sources in packets and prompt text | +| `sourceMode` | Default source mode when a source request omits `mode` | +| `sourceErrorMode` | Use `include`, `omit`, or `throw` for failed sources | | `intent` | Attach user intent to packets | | `currentLabel` / `historyLabel` | Customize prompt section labels | +| `sourceLabel` | Customize the prompt section label for source context | ## Registered MCP surface diff --git a/site/docs/api/react.md b/site/docs/api/react.md index 4a8ab1d..abf41de 100644 --- a/site/docs/api/react.md +++ b/site/docs/api/react.md @@ -181,6 +181,84 @@ myCtx.observe(panelEl, { events: ['hover'] }); const { focus: panelFocus } = useAskable({ ctx: myCtx }); ``` +--- + +## `useAskableSource(id, source, options?)` + +Lifecycle-managed registration for app-owned context sources. Use this when the +assistant needs data that is not fully rendered in the DOM: paginated tables, +virtualized lists, documents, charts, maps, calendars, canvases, file trees, or +custom state. + +The hook registers the source after mount, keeps the latest resolver functions +current across rerenders, and unregisters automatically on unmount. + +```tsx +import { useEffect } from 'react'; +import { useAskableSource } from '@askable-ui/react'; + +function AccountsContextSource({ table, filters, sort }) { + const accounts = useAskableSource('accounts', { + kind: 'collection', + describe: 'Customer accounts matching the active filters', + getState: () => ({ + filters, + sort, + page: table.getState().pagination.pageIndex + 1, + pageSize: table.getState().pagination.pageSize, + totalCount: table.options.meta?.totalCount, + }), + resolve: async ({ mode, maxItems }) => { + if (mode === 'visible') return table.getRowModel().rows.map((row) => row.original); + return summarizeAccounts({ filters, sort, maxItems }); + }, + sanitize: (source) => ({ + ...source, + data: redactAccountFields(source.data), + }), + }); + + async function ask(question: string) { + const promptContext = await accounts.toPromptContext({ + source: { mode: 'summary', maxItems: 20, timeoutMs: 750 }, + sourceErrorMode: 'include', + }); + + return sendToAgent({ question, promptContext }); + } + + useEffect(() => { + return table.onStateChange(() => { + accounts.notifyChanged(); + }); + }, [accounts.notifyChanged, table]); + + return null; +} +``` + +Call `notifyChanged()` when source data changes without a DOM focus change, +such as pagination, filters, selected rows, or query-cache updates. Async +subscribers created with `ctx.subscribeAsync()` re-resolve matching sources. + +**Options:** + +| Option | Type | Description | +|---|---|---| +| `enabled` | `boolean` | Register while true. Defaults to `true` | +| `name` / `events` / `viewport` / `ctx` | same as `useAskable()` | Choose the context that owns the source | + +**Returns:** + +| Value | Type | Description | +|---|---|---| +| `ctx` | `AskableContext` | Context instance that owns the source | +| `sourceId` | `string` | Trimmed registered source id | +| `resolve(request?)` | `Promise` | Resolve this source directly | +| `toPromptContext(options?)` | `Promise` | Serialize focus plus this source | +| `notifyChanged()` | `() => void` | Re-resolve matching async subscribers after source data changes | +| `unregister()` | `() => void` | Manually unregister before unmount when needed | + ### Practical option patterns #### Shared default hook @@ -349,6 +427,10 @@ function DashboardCapture() { | `destroy()` | `function` | Remove the overlay without firing cancel | | `isActive()` | `function` | Read active state from the live capture handle | +For persistent capture tools, pass `once: false`. The overlay and `active` state +stay on after each accepted capture until the user cancels or the component +unmounts. + --- ## `useAskableTextSelectionCapture(options?)` diff --git a/site/docs/api/svelte.md b/site/docs/api/svelte.md index bd06ba4..4e38346 100644 --- a/site/docs/api/svelte.md +++ b/site/docs/api/svelte.md @@ -145,6 +145,87 @@ Use the private default for isolated widgets. Pass a shared `ctx` when multiple --- +## `createAskableSourceStore(id, source, options?)` + +Lifecycle-managed registration for app-owned context sources. Use this when the +assistant needs data that is not fully rendered in the DOM: paginated tables, +virtualized lists, documents, charts, maps, calendars, canvases, file trees, or +custom state. + +The store registers the source immediately and unregisters it when `destroy()` +is called. + +```svelte + +``` + +Call `notifyChanged()` when source data changes without a DOM focus change, +such as pagination, filters, selected rows, or query-cache updates. Async +subscribers created with `ctx.subscribeAsync()` re-resolve matching sources. + +**Options:** + +| Option | Type | Description | +|---|---|---| +| `enabled` | `boolean` | Register while true. Defaults to `true` | +| `ctx` | `AskableContext` | Optional context to share with other Svelte stores/components | +| `events` | `AskableEvent[]` | Observation events for the underlying store context | + +**Returns:** + +| Value | Type | Description | +|---|---|---| +| `ctx` | `AskableContext` | Context instance that owns the source | +| `sourceId` | `string` | Trimmed registered source id | +| `resolve(request?)` | `Promise` | Resolve this source directly | +| `toPromptContext(options?)` | `Promise` | Serialize focus plus this source | +| `notifyChanged()` | `() => void` | Re-resolve matching async subscribers after source data changes | +| `unregister()` | `() => void` | Manually unregister before destroy when needed | +| `destroy()` | `() => void` | Unregisters the source and destroys the underlying store context | + +--- + ## `createAskableRegionCaptureStore(options?)` Factory that starts an explicit region, circle, or lasso selection overlay and exposes the captured Context packet as Svelte stores. @@ -198,6 +279,10 @@ onDestroy(capture.destroy); | `isActive` | `() => boolean` | Reads the current overlay state | | `ctx` | `AskableContext` | Store context instance | +For persistent capture tools, pass `once: false`. The overlay and `active` store +stay on after each accepted capture until the user cancels or the store is +destroyed. + --- ## `createAskableTextSelectionCaptureStore(options?)` diff --git a/site/docs/api/types.md b/site/docs/api/types.md index fde5080..297f2fd 100644 --- a/site/docs/api/types.md +++ b/site/docs/api/types.md @@ -5,13 +5,33 @@ All types are exported from `@askable-ui/core`. ```ts import type { AskableContext, + AskableAgentRequest, + AskableAgentRequestOptions, + AskableAsyncContextSubscriber, + AskableAsyncContextPacketOptions, + AskableAsyncContextOutputOptions, + AskableAsyncPromptContextOptions, AskableContextOptions, AskableContextOutputOptions, + AskableCollectionSourceData, + AskableCreateCollectionSourceOptions, + AskableCreateSourceOptions, + AskableContextSource, + AskableContextSourceErrorMode, + AskableContextSourceHandle, + AskableContextSourceInclude, + AskableContextSourceInfo, + AskableContextSourceMode, + AskableContextSourceRequest, + AskableContextSourceResolveRequest, + AskableContextSubscriber, AskableFocus, AskableFocusSegment, AskableFocusSource, + AskableResolvedContextSource, AskableSerializedFocus, AskableSerializedFocusSegment, + AskableSourceValue, AskablePromptContextOptions, AskablePromptFormat, AskablePromptPreset, @@ -21,6 +41,9 @@ import type { AskableEventMap, AskableEventName, AskableEventHandler, + AskableAsyncSubscribeOptions, + AskableSubscribeOptions, + WebContextPacket, } from '@askable-ui/core'; ``` @@ -57,6 +80,11 @@ interface AskableContextOptions { * Applied at capture time. */ sanitizeText?: (text: string) => string; + /** + * Sanitize resolved source context before serialization. + * Applied after source-level sanitizers. + */ + sanitizeSource?: (source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise; } ``` @@ -186,6 +214,182 @@ interface AskablePromptContextOptions { --- +## Context Source Types + +Generic app-owned context source types used by `registerSource()`, +`resolveSource()`, `toPromptContextAsync()`, and `toContextAsync()`. + +```ts +type AskableContextSourceMode = + | 'state' + | 'visible' + | 'selected' + | 'summary' + | 'all' + | (string & {}); + +interface AskableContextSource { + kind?: string; + describe?: string | (() => string | Promise); + getState?: () => unknown | Promise; + resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise; + sanitize?: (source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise; +} + +interface AskableContextSourceResolveRequest { + sourceId: string; + mode: AskableContextSourceMode; + focus: AskableFocus | null; + selection?: unknown; + maxItems?: number; + maxTokens?: number; + timeoutMs?: number; + signal?: AbortSignal; +} + +type AskableContextSourceErrorMode = 'include' | 'omit' | 'throw'; + +interface AskableContextSourceRequest { + id: string; + mode?: AskableContextSourceMode; + selection?: unknown; + maxItems?: number; + maxTokens?: number; + timeoutMs?: number; + signal?: AbortSignal; +} + +type AskableContextSourceInclude = string | AskableContextSourceRequest; + +interface AskableResolvedContextSource { + id: string; + kind?: string; + description?: string; + mode: AskableContextSourceMode; + state?: unknown; + data?: unknown; + error?: { message: string }; +} + +interface AskableContextSourceHandle { + id: string; + notifyChanged(): void; + unregister(): void; +} + +interface AskableContextSourceInfo { + id: string; + kind?: string; + registeredAt: number; + updatedAt: number; +} + +interface AskableContextSourceChange { + id?: string; + timestamp: number; +} +``` + +Helper factories: + +```ts +type AskableSourceValue = T | (() => T | Promise); + +interface AskableCreateSourceOptions { + kind?: string; + describe?: string | (() => string | Promise); + state?: AskableSourceValue; + data?: TData | ((request: AskableContextSourceResolveRequest) => TData | Promise); + resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise; + sanitize?: (source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise; +} + +interface AskableCollectionSourceData { + mode: AskableContextSourceMode; + items?: TItem[]; + summary?: unknown; + totalCount?: number; + returnedCount?: number; + truncated?: boolean; +} + +interface AskableCreateCollectionSourceOptions { + kind?: string; + describe?: string | (() => string | Promise); + getState?: () => TState | Promise; + getItems?: () => readonly TItem[] | Promise; + getVisibleItems?: () => readonly TItem[] | Promise; + getSelectedItems?: (request: AskableContextSourceResolveRequest) => readonly TItem[] | Promise; + getSummary?: (request: AskableContextSourceResolveRequest) => unknown | Promise; + resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise; + maxItems?: number; + sanitizeItem?: (item: TItem, request: AskableContextSourceResolveRequest) => unknown | Promise; + sanitize?: (source: AskableResolvedContextSource) => AskableResolvedContextSource | Promise; +} +``` + +```ts +interface AskableAsyncPromptContextOptions extends AskablePromptContextOptions { + sources?: 'all' | AskableContextSourceInclude[]; + sourceMode?: AskableContextSourceMode; + sourceLabel?: string; + sourceErrorMode?: AskableContextSourceErrorMode; +} + +interface AskableAsyncContextOutputOptions extends AskableContextOutputOptions { + sources?: 'all' | AskableContextSourceInclude[]; + sourceMode?: AskableContextSourceMode; + sourceLabel?: string; + sourceErrorMode?: AskableContextSourceErrorMode; +} + +interface AskableAsyncContextPacketOptions extends AskableContextPacketOptions { + sources?: 'all' | AskableContextSourceInclude[]; + sourceMode?: AskableContextSourceMode; + sourceErrorMode?: AskableContextSourceErrorMode; +} + +interface AskableAgentRequestOptions extends AskableAsyncContextOutputOptions { + requestId?: string; + metadata?: Record; + packet?: boolean | AskableAsyncContextPacketOptions | WebContextPacket; +} + +interface AskableAgentRequest { + requestId?: string; + question: string; + context: string; + focus: AskableSerializedFocus | null; + packet?: WebContextPacket; + metadata?: Record; + timestamp: number; +} +``` + +```ts +type AskableContextSubscriber = ( + context: string, + focus: AskableFocus | null +) => void; + +interface AskableSubscribeOptions extends AskableContextOutputOptions { + debounce?: number; +} + +type AskableAsyncContextSubscriber = ( + context: string, + focus: AskableFocus | null +) => void | Promise; + +interface AskableAsyncSubscribeOptions extends AskableAsyncContextOutputOptions { + debounce?: number; + emitInitial?: boolean; + onError?: (error: unknown) => void; +} +``` + +--- + ## `AskableContextOutputOptions` Options accepted by `toContext()`. Extends `AskablePromptContextOptions`. diff --git a/site/docs/api/versioning.md b/site/docs/api/versioning.md index db9cf3c..9acbff1 100644 --- a/site/docs/api/versioning.md +++ b/site/docs/api/versioning.md @@ -7,14 +7,14 @@ askable-ui supports two kinds of docs URLs: ## Current version -- Latest stable: `v0.11.1` -- Versioned current docs URL: `/docs/v0.11.1/` +- Latest stable: `v0.12.0` +- Versioned current docs URL: `/docs/v0.12.0/` ## Archived versions No archived major versions yet. -The current release is also published at `/docs/v0.11.1/` so version-specific links work before the first breaking release. +The current release is also published at `/docs/v0.12.0/` so version-specific links work before the first breaking release. ## Breaking release workflow diff --git a/site/docs/api/vue.md b/site/docs/api/vue.md index b0850d8..b914623 100644 --- a/site/docs/api/vue.md +++ b/site/docs/api/vue.md @@ -140,6 +140,80 @@ const panel = useAskable({ ctx: panelCtx }); --- +## `useAskableSource(id, source, options?)` + +Lifecycle-managed registration for app-owned context sources. Use this when the +assistant needs data that is not fully rendered in the DOM: paginated tables, +virtualized lists, documents, charts, maps, calendars, canvases, file trees, or +custom state. + +The composable registers the source during setup, keeps reactive values current +through your resolver closures, and unregisters automatically on unmount. + +```vue + +``` + +Call `notifyChanged()` when source data changes without a DOM focus change, +such as pagination, filters, selected rows, or query-cache updates. Async +subscribers created with `ctx.subscribeAsync()` re-resolve matching sources. + +**Options:** + +| Option | Type | Description | +|---|---|---| +| `enabled` | `boolean` | Register while true. Defaults to `true` | +| `name` / `events` / `viewport` / `ctx` | same as `useAskable()` | Choose the context that owns the source | + +**Returns:** + +| Value | Type | Description | +|---|---|---| +| `ctx` | `AskableContext` | Context instance that owns the source | +| `sourceId` | `string` | Trimmed registered source id | +| `resolve(request?)` | `Promise` | Resolve this source directly | +| `toPromptContext(options?)` | `Promise` | Serialize focus plus this source | +| `notifyChanged()` | `() => void` | Re-resolve matching async subscribers after source data changes | +| `unregister()` | `() => void` | Manually unregister before unmount when needed | + +--- + ## `useAskableRegionCapture(options?)` Composable that starts an explicit region, circle, or lasso selection overlay and emits a structured Context packet through the same `AskableContext`. @@ -186,6 +260,10 @@ capture.cancel(); | `isActive` | `() => boolean` | Reads the current overlay state | | `ctx` | `AskableContext` | Shared or provided context instance | +For persistent capture tools, pass `once: false`. The overlay and `active` ref +stay on after each accepted capture until the user cancels or the composable is +unmounted. + --- ## `useAskableTextSelectionCapture(options?)` diff --git a/site/docs/examples/ai-sdk.md b/site/docs/examples/ai-sdk.md index 2711476..239aaae 100644 --- a/site/docs/examples/ai-sdk.md +++ b/site/docs/examples/ai-sdk.md @@ -166,7 +166,7 @@ export function StreamingChat() { useEffect(() => { if (status !== 'streaming') return; - return ctx.subscribe(async (context) => { + return ctx.subscribeAsync(async (context) => { if (!requestIdRef.current) return; await fetch('/api/chat/context', { method: 'POST', @@ -178,7 +178,11 @@ export function StreamingChat() { }); }, { history: 5, + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], debounce: 100, + onError(error) { + console.error(error); + }, }); }, [ctx, status]); @@ -213,13 +217,16 @@ export async function askWithContext(userMessage: string, uiContext: string) { import { useAskable } from '@askable-ui/react'; function AskButton() { - const { promptContext } = useAskable(); + const { ctx } = useAskable(); async function ask(question: string) { const res = await fetch('/api/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ question, uiContext: promptContext }), + body: JSON.stringify(await ctx.toAgentRequest(question, { + history: 3, + packet: true, + })), }); return res.json(); } @@ -227,6 +234,22 @@ function AskButton() { } ``` +Server routes can read the same payload shape regardless of provider: + +```ts +export async function POST(req: Request) { + const { question, context, packet, focus, requestId } = await req.json(); + const answer = await askWithContext(question, context); + + return Response.json({ + answer, + requestId, + receivedContextPacket: Boolean(packet), + focusMeta: focus?.meta ?? null, + }); +} +``` + ## OpenAI SDK ```ts diff --git a/site/docs/examples/copilotkit.md b/site/docs/examples/copilotkit.md index dbdc693..8ab46b7 100644 --- a/site/docs/examples/copilotkit.md +++ b/site/docs/examples/copilotkit.md @@ -155,10 +155,11 @@ export function LiveCopilotContext() { const [liveContext, setLiveContext] = useState(() => ctx.toContext({ history: 3 })); useEffect(() => { - return ctx.subscribe((context) => { + return ctx.subscribeAsync((context) => { setLiveContext(context); }, { history: 3, + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], debounce: 100, }); }, [ctx]); diff --git a/site/docs/guide/context.md b/site/docs/guide/context.md index 86ba1b7..04850a8 100644 --- a/site/docs/guide/context.md +++ b/site/docs/guide/context.md @@ -12,6 +12,11 @@ const packet = ctx.toContextPacket({ includeViewport: true, source: { app: 'analytics-dashboard' }, }); + +const packetWithSources = await ctx.toContextPacketAsync({ + source: { app: 'analytics-dashboard' }, + sources: [{ id: 'accounts', mode: 'summary', maxItems: 20 }], +}); ``` The packet describes: @@ -19,7 +24,7 @@ The packet describes: - where the context came from: URL, title, app, route, timestamp - how the context was captured: focused element, selected text, semantic push, viewport, region, lasso, circle, or custom capture - what the user is pointing at: text, role, label, selector, bounds, metadata, optional screenshots -- surrounding context: ancestors, visible items, and recent interactions +- surrounding context: ancestors, visible items, recent interactions, and app-owned sources - privacy and provenance: redaction state, consent state, producer, and capture method ## Why packets? @@ -50,6 +55,39 @@ ctx.toPromptContext(); Use packets when the receiving system should validate, store, transform, or route context before sending it to a model. +## App-owned sources + +When a page only renders part of the underlying data, register a source and use +`toContextPacketAsync()` to include resolver-backed application context in +`surrounding.sources`. + +```ts +import { createAskableCollectionSource } from '@askable-ui/core'; + +ctx.registerSource('accounts', createAskableCollectionSource({ + describe: 'Customer accounts matching the active filters', + getState: () => ({ filters, sort, page, pageSize, totalCount }), + getVisibleItems: () => table.getRowModel().rows.map((row) => row.original), + getItems: () => accountStore.getAllMatching({ filters, sort }), + getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), + sanitizeItem: redactAccountFields, + sanitize: (source) => ({ + ...source, + state: redactFilterState(source.state), + }), +})); + +const packet = await ctx.toContextPacketAsync({ + sources: [{ id: 'accounts', mode: 'all', maxItems: 20, timeoutMs: 750 }], + sourceErrorMode: 'include', +}); +``` + +Each source becomes a `WebContextTarget` with the source id in `label`, source +kind in `role`, source description in `text`, and resolved state/data in +`metadata`. Failed sources use a safe unavailable marker by default so packet +consumers do not receive stack traces or secret-bearing error messages. + ## MCP bridge `@askable-ui/mcp` exposes packets through MCP tools and resources. The built-in @@ -132,9 +170,9 @@ capture.start(); The resulting packet uses `capture.mode` of `region`, `circle`, or `lasso`, sets `privacy.consent` to `explicit`, and places the selected bounds on `target.bounds`. Lasso packets also include the freehand path in -`target.metadata.points`. The default lasso overlay uses a solid gradient -freehand stroke; pass `theme` to adjust overlay colors, selection fill/stroke, -or lasso line styling. +`target.metadata.points`. The default lasso overlay uses +`ASKABLE_REGION_CAPTURE_THEME`; pass `theme` to adjust overlay colors, +selection fill/stroke, or lasso line styling. Framework apps can use wrapper APIs instead: diff --git a/site/docs/guide/getting-started.md b/site/docs/guide/getting-started.md index 8c15ffc..6a576d9 100644 --- a/site/docs/guide/getting-started.md +++ b/site/docs/guide/getting-started.md @@ -2,7 +2,7 @@ ## Pick your framework -> Current npm release: **v0.11.1**. +> Current npm release: **v0.12.0**. > > Latest docs live at `/docs/`. Version-specific docs are published at `/docs//` for breaking releases. @@ -144,23 +144,21 @@ ctx.observe(document); ::: -## Step 3 — Inject into your LLM call +## Step 3 — Send context with the question -Pass `promptContext` (or `ctx.toPromptContext()`) as a system message before any user question: +Use `ctx.toAgentRequest()` when you want one JSON-ready payload containing the +user question, prompt-ready context, current focus, and an optional Context +packet: ```ts async function ask(userMessage: string) { const response = await fetch('/api/chat', { method: 'POST', - body: JSON.stringify({ - messages: [ - { - role: 'system', - content: `You are a helpful assistant.\nUI context: ${promptContext}`, - }, - { role: 'user', content: userMessage }, - ], - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(await ctx.toAgentRequest(userMessage, { + history: 3, + packet: true, + })), }); return response.json(); } diff --git a/site/docs/guide/react.md b/site/docs/guide/react.md index 1100c6f..c9361b5 100644 --- a/site/docs/guide/react.md +++ b/site/docs/guide/react.md @@ -21,6 +21,26 @@ function Dashboard({ data }) { ); } +function ChatInput() { + const { ctx } = useAskable(); + + async function submit(question: string) { + await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(await ctx.toAgentRequest(question, { + history: 3, + packet: true, + })), + }); + } +} +``` + +For very small integrations you can still inject `promptContext` directly as a +system message: + +```tsx function ChatInput() { const { promptContext } = useAskable(); @@ -174,8 +194,8 @@ function RegionTools() { Region packets use `capture.mode: 'region'`; circle packets use `capture.mode: 'circle'` and include center/radius metadata. Lasso packets use `capture.mode: 'lasso'` and include freehand path points. -The default lasso overlay is a solid gradient freehand stroke; pass `theme` -when you need brand-specific overlay colors or line styling. +The default lasso overlay uses the core `ASKABLE_REGION_CAPTURE_THEME`; pass +`theme` when you need brand-specific overlay colors or line styling. ## Text selection capture diff --git a/site/docs/guide/serialization.md b/site/docs/guide/serialization.md index 79e50dc..e7c0053 100644 --- a/site/docs/guide/serialization.md +++ b/site/docs/guide/serialization.md @@ -1,6 +1,6 @@ # Prompt Serialization -`toPromptContext()`, `toHistoryContext()`, and `toContext()` accept an `AskablePromptContextOptions` object to control exactly how context is serialized. +`toPromptContext()`, `toHistoryContext()`, and `toContext()` accept an `AskablePromptContextOptions` object to control exactly how context is serialized. Use `toPromptContextAsync()` or `toContextAsync()` when you also want to include registered app-owned context sources. ## Presets @@ -112,6 +112,45 @@ ctx.toHistoryContext(10, { maxTokens: 300 }); // History trimmed to fit ~1200 chars, with [truncated] marker if needed ``` +## App-owned sources + +DOM context only includes what is rendered. For paginated tables, virtualized +lists, documents, maps, charts, calendars, canvases, or API-backed dashboards, +register a source that resolves data from your application state. + +```ts +import { createAskableCollectionSource } from '@askable-ui/core'; + +ctx.registerSource('accounts', createAskableCollectionSource({ + describe: 'Customer accounts matching the active filters', + getState: () => ({ filters, sort, page, pageSize, totalCount }), + getVisibleItems: () => table.getVisibleRows(), + getSelectedItems: ({ selection }) => getAccountsByIds(selection), + getItems: () => accountStore.getAllMatching({ filters, sort }), + getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), + sanitizeItem: redactAccountFields, + sanitize: (source) => ({ + ...source, + state: redactFilterState(source.state), + }), +})); + +const context = await ctx.toPromptContextAsync({ + sources: [{ id: 'accounts', mode: 'all', maxItems: 20, timeoutMs: 750 }], + sourceErrorMode: 'include', +}); +``` + +This keeps Askable generic: interaction tools capture what the user meant, and +source resolvers supply what the app knows. `createAskableCollectionSource()` +is useful when the app owns more items than the DOM renders; `mode: 'all'` +asks that source for the logical collection, while `maxItems` keeps prompt +budgets explicit. Source output can be redacted with `sanitizeItem`, a +source-level `sanitize` hook, or a context-level `sanitizeSource` hook. Failed +or timed-out sources are represented with a safe unavailable marker by default; +use `sourceErrorMode: 'omit'` or `'throw'` when your runtime needs stricter +behavior. + ## Custom labels ```ts diff --git a/site/docs/guide/whats-new.md b/site/docs/guide/whats-new.md index 8eb2a32..337e550 100644 --- a/site/docs/guide/whats-new.md +++ b/site/docs/guide/whats-new.md @@ -1,8 +1,8 @@ -# What’s New in v0.11.1 +# What’s New in v0.12.0 -askable-ui v0.11.1 refines explicit selection capture, so users can freehand -lasso irregular areas, keep selected text highlighted, and send richer page -context to agents. +askable-ui v0.12.0 adds generic app-owned context sources and refines explicit +selection capture, so users can freehand lasso irregular areas, keep selected +text highlighted, and send richer page context to agents. ## Highlights @@ -17,7 +17,11 @@ That gradient stroke is the library default, and apps can tune it with the `theme` option instead of rebuilding the overlay. ```ts -import { createAskableContext, createAskableRegionCapture } from '@askable-ui/core'; +import { + ASKABLE_REGION_CAPTURE_THEME, + createAskableContext, + createAskableRegionCapture, +} from '@askable-ui/core'; const ctx = createAskableContext({ viewport: true }); ctx.observe(document); @@ -27,6 +31,7 @@ const capture = createAskableRegionCapture(ctx, { includeViewport: true, intent: 'answer using this freehand-selected region', theme: { + ...ASKABLE_REGION_CAPTURE_THEME, lassoStrokeWidth: 4, }, onCapture: (packet) => sendToAgent(packet), @@ -45,6 +50,104 @@ Related docs: - [@askable-ui/core API](/api/core) - [@askable-ui/react API](/api/react) +### App-owned context sources + +`registerSource()` lets apps expose data that is not fully represented in the +DOM. This covers paginated tables, virtualized lists, documents, maps, charts, +calendars, canvases, and custom product state. + +```ts +import { createAskableCollectionSource } from '@askable-ui/core'; + +ctx.registerSource('accounts', createAskableCollectionSource({ + describe: 'Customer accounts matching the active filters', + getState: () => ({ filters, sort, page, pageSize, totalCount }), + getVisibleItems: () => table.getVisibleRows(), + getSelectedItems: ({ selection }) => getAccountsByIds(selection), + getItems: () => accountStore.getAllMatching({ filters, sort }), + getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), + sanitizeItem: redactAccountFields, +})); + +const promptContext = await ctx.toPromptContextAsync({ + sources: [{ id: 'accounts', mode: 'all', maxItems: 20 }], +}); + +const packet = await ctx.toContextPacketAsync({ + sources: [{ id: 'accounts', mode: 'all', maxItems: 20 }], +}); +``` + +React apps can register the same source with component lifecycle: + +```tsx +const accounts = useAskableSource('accounts', { + getState: () => ({ filters, sort, totalCount }), + resolve: async ({ mode, maxItems }) => { + if (mode === 'visible') return table.getRowModel().rows.map((row) => row.original); + return summarizeAccounts({ filters, sort, maxItems }); + }, +}); +``` + +Vue apps get the same lifecycle-managed source registration: + +```ts +const accounts = useAskableSource('accounts', { + getState: () => ({ filters: filters.value, sort: sort.value, totalCount: totalCount.value }), + resolve: async ({ mode, maxItems }) => { + if (mode === 'visible') return table.getRowModel().rows.map((row) => row.original); + return summarizeAccounts({ filters: filters.value, sort: sort.value, maxItems }); + }, +}); +``` + +Svelte apps can use the store-based source helper: + +```ts +const accounts = createAskableSourceStore('accounts', { + getState: () => ({ filters, sort, totalCount }), + resolve: async ({ mode, maxItems }) => { + if (mode === 'visible') return table.getRowModel().rows.map((row) => row.original); + return summarizeAccounts({ filters, sort, maxItems }); + }, +}); +``` + +This keeps Askable generic: interactions capture what the user meant, while +source resolvers supply what the app knows. + +### Source-backed live subscriptions + +`subscribeAsync()` streams `toContextAsync()` output to chat transports while +including registered sources. It debounces rapid focus changes and ignores stale +resolver results if the user moves to newer context before an earlier source +request finishes. + +```ts +const unsubscribe = ctx.subscribeAsync(sendLiveContext, { + history: 5, + sources: [{ id: 'accounts', mode: 'summary', timeoutMs: 750 }], + debounce: 100, +}); +``` + +`toAgentRequest()` packages a user question with `toContextAsync()` output, +serialized focus, optional packet data, tracing metadata, and a timestamp: + +```ts +const request = await ctx.toAgentRequest(question, { + requestId, + history: 3, + sources: ['accounts'], + packet: true, +}); +``` + +You can also pass a packet that came from a region, circle, lasso, or text +selection capture. This supports a controlled UX where the user selects context, +sees it pinned in the chat composer, then adds a question before sending. + ### Region, circle, lasso, and text capture together Askable now covers four explicit user selection patterns: @@ -59,8 +162,8 @@ browser tools, and agent runtimes. ### Starter and docs version alignment -`npm create @askable-ui/app` now scaffolds projects pinned to `^0.11.1`, and the -versioned docs have been advanced to `/docs/v0.11.1/`. +`npm create @askable-ui/app` now scaffolds projects pinned to `^0.12.0`, and the +versioned docs have been advanced to `/docs/v0.12.0/`. ## Recommended next step @@ -72,7 +175,7 @@ If you are integrating Askable into an AI or agent runtime, start here: ## Version note -The current published docs track **v0.11.1** at both: +The current published docs track **v0.12.0** at both: - `/docs/` -- `/docs/v0.11.1/` +- `/docs/v0.12.0/` diff --git a/site/docs/index.md b/site/docs/index.md index a637e10..0a8f941 100644 --- a/site/docs/index.md +++ b/site/docs/index.md @@ -40,7 +40,7 @@ features: details: toPromptContext() returns a plain string, while toContextPacket() returns structured Context packets for MCP bridges, browser tools, and agent runtimes. --- -> Current npm release: **v0.11.1**. +> Current npm release: **v0.12.0**. > > Need a breaking-release upgrade path? See [Migration Guides](/guide/migrations). Versioned docs are available at `/docs//`. @@ -59,11 +59,16 @@ features:
-## Latest in v0.11.1 +## Latest in v0.12.0 - lasso capture via `shape: 'lasso'` for freehand-selected page regions +- generic `registerSource()` resolvers plus source helper factories for app-owned context +- React, Vue, and Svelte lifecycle helpers for resolver-backed app state +- async prompt and packet serialization with `toPromptContextAsync()`, `toContextAsync()`, and `toContextPacketAsync()` +- agent request packaging with `toAgentRequest()` +- source-backed live subscriptions with `subscribeAsync()` - point-path metadata on lasso Context packets -- starter app dependency pins advanced to `^0.11.1` +- starter app dependency pins advanced to `^0.12.0` - continued support for region/circle/text capture, MCP Context packets, and framework wrappers ## Interaction patterns @@ -84,7 +89,7 @@ Every pattern can produce a prompt string with `toPromptContext()` or a structur Start here: -- [What’s New in v0.11.1](/guide/whats-new) +- [What’s New in v0.12.0](/guide/whats-new) - [Context Packets](/guide/context) - [React interaction patterns](/guide/react#region-circle-and-lasso-capture) - [AI SDK integration patterns](/examples/ai-sdk) diff --git a/site/docs/versions.json b/site/docs/versions.json index fc0f06f..fc6e57f 100644 --- a/site/docs/versions.json +++ b/site/docs/versions.json @@ -1,7 +1,7 @@ { "current": { - "label": "v0.11.1", - "slug": "v0.11.1" + "label": "v0.12.0", + "slug": "v0.12.0" }, "archived": [] } diff --git a/site/docs/versions/README.md b/site/docs/versions/README.md index f0384c7..8e3b4a2 100644 --- a/site/docs/versions/README.md +++ b/site/docs/versions/README.md @@ -2,10 +2,10 @@ This directory stores frozen **built** docs snapshots for archived major versions. -The current release (`v0.11.1`) is built on every deploy and published to both: +The current release (`v0.12.0`) is built on every deploy and published to both: - `/docs/` (latest stable) -- `/docs/v0.11.1/` (version-specific URL) +- `/docs/v0.12.0/` (version-specific URL) ## How it works diff --git a/site/www/index.html b/site/www/index.html index 63824da..04d1a2e 100644 --- a/site/www/index.html +++ b/site/www/index.html @@ -93,18 +93,25 @@ .nav-gh { display: inline-flex; align-items: center; - gap: .4rem; - padding: .42rem 1rem; + justify-content: center; + width: 2.05rem; + height: 2.05rem; + padding: 0; border-radius: var(--radius-pill); background: var(--ink); color: #fff; - font-size: .82rem; - font-weight: 600; text-decoration: none; margin-left: .5rem; transition: opacity .15s; } .nav-gh:hover { opacity: .82; } + .github-icon { + width: 1.05rem; + height: 1.05rem; + display: block; + fill: currentColor; + flex: none; + } /* BUTTONS */ .btn { @@ -126,6 +133,14 @@ .btn-primary:hover { box-shadow: 0 6px 22px rgba(0,0,0,0.2); } .btn-secondary { background: rgba(0,0,0,0.04); color: var(--ink); border: 1px solid rgba(0,0,0,0.09); } .btn-secondary:hover { background: rgba(0,0,0,0.07); } + .btn-icon { + justify-content: center; + width: 2.95rem; + min-width: 2.95rem; + height: 2.95rem; + padding: 0; + } + .btn-icon .github-icon { width: 1.15rem; height: 1.15rem; } /* LAYOUT */ .page-shell { max-width: 1280px; margin: 0 auto; padding: 0 2rem 8rem; } @@ -319,14 +334,54 @@ /* DEMO DECK */ .deck { padding: 1.5rem; border-radius: var(--radius-lg); border: 1px solid var(--line); background: #f9fafb; } - .demo-controls { display: flex; flex-direction: column; align-items: center; gap: .75rem; margin-bottom: 1.35rem; } + .demo-controls { display: grid; gap: .8rem; margin-bottom: 1.35rem; } .demo-controls-row { display: flex; justify-content: center; align-items: center; gap: .6rem; flex-wrap: wrap; } .toggle-label { color: var(--ink-3); font-size: .82rem; } .toggle-group { display: inline-flex; gap: .22rem; padding: .22rem; border-radius: var(--radius-pill); background: #fff; border: 1px solid var(--line); box-shadow: 0 2px 6px rgba(0,0,0,0.04); } - .context-option-group { flex-wrap: wrap; justify-content: center; border-radius: 22px; } .toggle-btn { border: none; background: transparent; color: var(--ink-3); padding: .45rem .9rem; border-radius: var(--radius-pill); font-size: .82rem; font-weight: 600; cursor: pointer; transition: background .14s, color .14s; } .toggle-btn.active { color: var(--ink); background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.09); } + .pattern-dock { + border: 1px solid var(--line); background: #fff; border-radius: 20px; padding: .72rem; + box-shadow: 0 8px 24px rgba(0,0,0,0.045); + } + .pattern-dock-header { + display: flex; align-items: center; justify-content: space-between; gap: .8rem; + padding: .08rem .18rem .62rem; + } + .pattern-dock-header span { color: var(--ink-3); font-size: .72rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; } + .pattern-dock-header strong { font-size: .88rem; letter-spacing: -.02em; } + .pattern-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: .36rem; } + .pattern-option { + display: grid; grid-template-columns: 1.45rem minmax(0, 1fr); align-items: center; gap: .48rem; + border: 1px solid transparent; background: #f8fafc; color: var(--ink-2); + border-radius: 14px; padding: .55rem .62rem; cursor: pointer; text-align: left; + transition: transform .14s, box-shadow .14s, border-color .14s, background .14s; + min-width: 0; + } + .pattern-option:hover { transform: translateY(-1px); border-color: rgba(79,70,229,0.16); background: #fff; } + .pattern-option.active { + background: #fff; color: var(--ink); border-color: rgba(79,70,229,0.26); + box-shadow: 0 7px 18px rgba(79,70,229,0.1); + } + .pattern-glyph { + width: 1.45rem; height: 1.45rem; display: inline-flex; align-items: center; justify-content: center; + border-radius: 10px; background: rgba(79,70,229,0.08); color: var(--accent); + font-size: .82rem; font-weight: 850; + } + .pattern-copy-mini { min-width: 0; display: grid; gap: .08rem; } + .pattern-copy-mini b { display: block; font-size: .78rem; line-height: 1.08; letter-spacing: -.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .pattern-copy-mini small { display: block; color: var(--ink-3); font-size: .62rem; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .pattern-status { + display: flex; align-items: center; gap: .55rem; margin-top: .58rem; padding: .5rem .58rem; + border: 1px solid rgba(0,0,0,0.06); background: #fbfcfe; border-radius: 14px; + } + .pattern-payload { + flex: 0 0 auto; max-width: 45%; border-radius: var(--radius-pill); padding: .22rem .5rem; + background: rgba(79,70,229,0.08); color: #3730a3; font-size: .68rem; font-weight: 800; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .output-controls { justify-content: flex-end; } .context-tools { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: .45rem; padding-top: .2rem; } .tool-btn { display: inline-flex; align-items: center; gap: .35rem; @@ -335,21 +390,25 @@ padding: .48rem .82rem; cursor: pointer; transition: transform .13s, box-shadow .13s, border-color .13s; } .tool-btn:hover, .tool-btn.active { transform: translateY(-1px); border-color: rgba(79,70,229,0.24); box-shadow: 0 4px 12px rgba(79,70,229,0.09); color: var(--ink); } - .tool-hint { width: 100%; text-align: center; color: var(--ink-3); font-size: .75rem; min-height: 1.2rem; } + .tool-hint { flex: 1 1 auto; min-width: 11rem; color: var(--ink-3); font-size: .75rem; line-height: 1.35; } .demo-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 336px); gap: 1.15rem; align-items: start; } .dashboard { position: relative; display: flex; flex-direction: column; gap: 1rem; min-width: 0; } .selection-layer { position: absolute; inset: -.65rem; z-index: 9; pointer-events: none; border-radius: 28px; overflow: visible; } .dashboard.selecting .selection-layer { pointer-events: auto; cursor: crosshair; } + .dashboard.selecting.lasso-active .selection-layer { cursor: none; } .selection-layer::before { content: ''; position: absolute; inset: 0; border-radius: inherit; border: 1px dashed rgba(79,70,229,0.22); background: rgba(79,70,229,0.03); opacity: 0; transition: opacity .12s; } .dashboard.selecting .selection-layer::before { opacity: 1; } + .dashboard.selecting.lasso-active .selection-layer::before { + opacity: 0; + } .selection-drawing { position: absolute; border: 2px solid var(--accent); background: rgba(79,70,229,0.09); - box-shadow: 0 10px 28px rgba(79,70,229,0.12); pointer-events: none; + box-shadow: 0 10px 28px rgba(79,70,229,0.12); pointer-events: none; z-index: 11; } .selection-drawing.circle { border-radius: 999px; } .selection-drawing.lasso { background: transparent; border: none; box-shadow: none; inset: 0; filter: drop-shadow(0 0 10px rgba(79,70,229,0.32)); } @@ -364,12 +423,45 @@ } .selection-drawing.lasso .lasso-aura { fill: none; - stroke: rgba(14,165,233,0.28); - stroke-width: 10; + stroke: rgba(124,58,237,0.16); + stroke-width: 8; stroke-linecap: round; stroke-linejoin: round; filter: blur(1px); } + .ai-cursor-canvas { + position: absolute; inset: 0; z-index: 10; + width: 100%; height: 100%; + pointer-events: none; opacity: 0; + transition: opacity .12s ease; + } + .dashboard.lasso-active .ai-cursor-canvas { opacity: 1; } + .ai-cursor { + position: absolute; left: 0; top: 0; z-index: 12; + display: none; width: 1.95rem; height: 1.95rem; + transform: translate3d(0,0,0); pointer-events: none; + filter: + drop-shadow(0 2px 4px rgba(15,23,42,0.18)) + drop-shadow(0 0 7px rgba(124,58,237,0.34)); + } + .dashboard.lasso-active .ai-cursor { display: block; } + .ai-cursor-icon { + position: absolute; left: 50%; top: 50%; + display: block; width: 1.95rem; height: 1.95rem; + overflow: visible; transform: translate(-45%, -45%); + } + .ai-cursor-outline { + fill: none; + stroke: #a78bfa; + stroke-linejoin: round; + stroke-width: 4.6; + } + .ai-cursor-fill { + fill: #fff; + stroke: #7c3aed; + stroke-linejoin: round; + stroke-width: 1.2; + } .kpi-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .85rem; } .panel { background: #fff; border: 1px solid var(--line); border-radius: var(--radius-card); overflow: hidden; min-width: 0; } @@ -431,6 +523,13 @@ .msg.user .msg-bubble { align-self: flex-end; background: var(--ink); color: #fff; border-color: transparent; } .msg.ai .msg-bubble { color: var(--ink); } .msg.ctx .msg-bubble { font-family: var(--mono); font-size: .69rem; background: transparent; border: none; padding: .1rem 0; color: var(--ink-3); } + .msg.ctx.text-context .msg-bubble { + font-family: var(--font); border: 1px solid rgba(124,58,237,0.16); + background: linear-gradient(180deg, rgba(124,58,237,0.08), rgba(79,70,229,0.04)); + color: var(--ink); padding: .64rem .72rem; border-radius: 14px; + } + .msg-context-kicker { display: block; margin-bottom: .28rem; color: #6d28d9; font-size: .62rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } + .msg-context-quote { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; color: var(--ink-2); font-size: .76rem; line-height: 1.48; } .msg-label { font-size: .65rem; color: var(--ink-3); padding: 0 .2rem; } .msg.user .msg-label { align-self: flex-end; } .typing { display: inline-flex; gap: 4px; align-items: center; width: fit-content; padding: .62rem .78rem; border-radius: 14px; background: #f4f4f5; border: 1px solid rgba(0,0,0,0.07); } @@ -438,10 +537,40 @@ .typing span:nth-child(2) { animation-delay: .15s; } .typing span:nth-child(3) { animation-delay: .3s; } @keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-4px)} } .chat-context-bar { display: flex; gap: .45rem; align-items: center; padding: .62rem .9rem; background: rgba(79,70,229,0.05); border-top: 1px solid rgba(79,70,229,0.12); } + .chat-context-bar.text-context { background: rgba(124,58,237,0.07); border-top-color: rgba(124,58,237,0.18); } .context-bar-label { font-size: .68rem; font-weight: 600; color: var(--accent); letter-spacing: .04em; text-transform: uppercase; flex-shrink: 0; } + .chat-context-bar.text-context .context-bar-label { color: #6d28d9; } .context-chip { flex: 1; font-family: var(--mono); font-size: .69rem; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .context-dismiss { border: none; background: none; color: var(--ink-3); cursor: pointer; font-size: .95rem; } + .selected-text-card { + margin: .72rem .78rem 0; padding: .72rem .78rem; border-radius: 14px; + border: 1px solid rgba(124,58,237,0.18); + background: linear-gradient(180deg, rgba(124,58,237,0.08), rgba(255,255,255,0.88)); + } + .selected-text-card-head { display: flex; align-items: center; justify-content: space-between; gap: .6rem; margin-bottom: .44rem; } + .selected-text-card-title { display: flex; align-items: center; gap: .42rem; color: #5b21b6; font-size: .7rem; font-weight: 850; letter-spacing: .05em; text-transform: uppercase; } + .selected-text-card-title::before { content: '“'; display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 999px; background: #6d28d9; color: #fff; font-size: .9rem; line-height: 1; } + .selected-text-card-state { color: var(--ink-3); font-size: .64rem; font-weight: 700; } + .selected-text-quote { margin: 0; color: var(--ink); font-size: .78rem; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } + .dashboard ::selection { background: rgba(124,58,237,0.24); color: var(--ink); } + .text-capture-mark { + position: absolute; pointer-events: none; z-index: 6; border-radius: 7px; + background: rgba(124,58,237,0.13); outline: 1px solid rgba(124,58,237,0.25); + box-shadow: 0 0 0 3px rgba(124,58,237,0.06), 0 8px 22px rgba(91,33,182,0.10); + } + .composer-suggestions { + display: flex; gap: .42rem; padding: .62rem .78rem 0; flex-wrap: wrap; + border-top: 1px solid rgba(0,0,0,0.06); + } + .question-chip { + border: 1px solid rgba(124,58,237,0.16); background: rgba(124,58,237,0.06); + color: #4c1d95; border-radius: var(--radius-pill); + padding: .34rem .58rem; font-size: .7rem; font-weight: 750; + cursor: pointer; transition: background .15s, border-color .15s, transform .15s; + } + .question-chip:hover { background: rgba(124,58,237,0.1); border-color: rgba(124,58,237,0.24); transform: translateY(-1px); } .chat-input-row { display: flex; gap: .5rem; padding: .78rem; border-top: 1px solid rgba(0,0,0,0.06); } + .composer-suggestions + .chat-input-row { border-top: none; } .chat-input { flex: 1; border-radius: var(--radius-pill); padding: .72rem 1rem; border: 1px solid rgba(0,0,0,0.1); background: #fff; font-size: .81rem; color: var(--ink); outline: none; font-family: var(--font); transition: border-color .15s, box-shadow .15s; } .chat-input:focus { border-color: rgba(79,70,229,0.28); box-shadow: 0 0 0 4px rgba(79,70,229,0.07); } .chat-send { width: 38px; height: 38px; border-radius: 50%; border: none; background: var(--ink); color: #fff; cursor: pointer; font-size: .9rem; transition: opacity .15s; } @@ -518,6 +647,13 @@ .pkg-grid { grid-template-columns: 1fr !important; } .deck { padding: 1rem; border-radius: 24px; } .demo-controls-row { align-items: stretch; flex-wrap: wrap; } + .pattern-dock { padding: .62rem; border-radius: 18px; } + .pattern-dock-header { align-items: flex-start; flex-direction: column; gap: .2rem; } + .pattern-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .pattern-option { grid-template-columns: 1.35rem minmax(0, 1fr); padding: .52rem; } + .pattern-status { align-items: flex-start; flex-direction: column; } + .pattern-payload { max-width: 100%; } + .tool-hint { min-width: 0; } .toggle-group { justify-content: stretch; } .toggle-btn { flex: 1 1 0; padding-inline: .65rem; } .panel { overflow: visible; } @@ -578,7 +714,11 @@ Packages Integrations Docs - GitHub + + +
@@ -607,7 +747,11 @@

Your model knows what the user means.

See it live Documentation - GitHub + + +
Element + visual context @@ -736,9 +880,9 @@

Context follows intent, not the whole DOM

Other approaches serialize the entire page or guess what matters. askable captures exactly what the user marked: an element, area, shape, or text selection.

-
Zero glue code
-

Your API data is already the metadata

-

The same object that renders a row or card becomes model-ready context, and explicit captures can add bounds, shape, text, and user intent.

+
App-owned sources
+

Use real state, not just visible DOM

+

Register redacted, timeout-safe resolvers for paginated tables, documents, maps, charts, calendars, or custom state so prompts include the right data.

Any stack
@@ -755,37 +899,72 @@

Select the UI. Watch the prompt tighten.

-
- Context option: -
- - - - - - - +
+
+ Interaction pattern + Click +
+
+ + + + + + + +
+
+ Element metadata + text +
Click an annotated KPI or account row to load its context.
+
- -
Use the tools to send explicit page context into the assistant.
-
+
Format:
- Include text: + Prompt text:
- - + +
- +
Top Accounts same data drives UI + AI context
@@ -810,10 +989,18 @@

Select the UI. Watch the prompt tighten.

+ +
@@ -864,8 +1051,10 @@

Observe intent

3

Inject

-

At the AI boundary, send askable.toPromptContext() or a Context packet. The assistant now knows what the user actually meant.

-
const context = askable.toPromptContext();
+          

At the AI boundary, send askable.toPromptContext(), resolve app sources, or emit a Context packet. The assistant now knows what the user actually meant.

+
const context = await askable.toPromptContextAsync({
+  sources: ['accounts']
+});
 // → "User is focused on: metric: MRR, value: $128K"
@@ -943,7 +1132,11 @@

Start with one attribute.

Drop data-askable on your most important element. Working context in under five minutes.

@@ -965,7 +1158,28 @@

Start with one attribute.

var EVENT_MAP = { click: 'click', hover: 'mouseenter', focus: 'focus' }; var ALL_EVENTS = ['click', 'hover', 'focus']; function parseMeta(raw) { try { return JSON.parse(raw); } catch (e) { return raw; } } - function extractText(el) { return (el.textContent || '').trim(); } + function extractText(el) { + var parts = []; + function visit(node) { + if (node.nodeType === 3) { + var value = node.nodeValue.replace(/\s+/g, ' ').trim(); + if (value) parts.push(value); + return; + } + if (node.nodeType !== 1) return; + var element = node; + if ( + element.matches('button, [role="button"], [aria-hidden="true"], [hidden], .ask-btn, .ask-btn-row, [data-label="Ask"]') + ) { + return; + } + var style = window.getComputedStyle ? window.getComputedStyle(element) : null; + if (style && (style.display === 'none' || style.visibility === 'hidden')) return; + Array.prototype.forEach.call(element.childNodes, visit); + } + visit(el); + return parts.join(' ').replace(/\s+/g, ' ').trim(); + } function Observer(onFocus) { this.root = null; this.mo = null; this.els = []; this.evts = ALL_EVENTS; var self = this; @@ -1096,12 +1310,22 @@

Start with one attribute.

var codeAttr = document.getElementById('code-attr'); var codeCtx = document.getElementById('code-ctx'); var contextBar = document.getElementById('chat-context-bar'); + var contextBarLabel = document.getElementById('context-bar-label'); var contextChip = document.getElementById('context-chip'); + var selectedTextCard = document.getElementById('selected-text-card'); + var selectedTextPreview = document.getElementById('selected-text-preview'); + var composerSuggestions = document.getElementById('composer-suggestions'); var selectionLayer = document.getElementById('selection-layer'); + var aiCursor = document.getElementById('ai-cursor'); + var aiCursorCanvas = document.getElementById('ai-cursor-canvas'); + var aiCursorCtx = aiCursorCanvas ? aiCursorCanvas.getContext('2d') : null; var toolHint = document.getElementById('tool-hint'); + var activePatternName = document.getElementById('active-pattern-name'); + var patternPayload = document.getElementById('pattern-payload'); var cancelTool = document.getElementById('cancel-tool'); var activeEl = null; var typingTimer = null; var lastKey = null; var activeTool = null; var drawingEl = null; var dragStart = null; var lassoPoints = []; var toolClearTimer = null; var textSelectionMode = false; + var aiTrailPoints = []; var aiTrailFrame = null; var aiTrailDpr = 1; var aiTrailMaxPoints = 22; var dashboard = document.querySelector('.dashboard'); var promptOpts = { format: 'natural', includeText: true }; function refreshCtxDisplay() { @@ -1127,6 +1351,7 @@

Start with one attribute.

lastKey = null; askable.unobserve(); if (mode === 'button') { dashboard.classList.add('button-mode'); } else { dashboard.classList.remove('button-mode'); askable.observe(document, { events: mode === 'hover' ? ['hover'] : ['click'] }); } + setPatternState(btn, mode === 'hover' ? 'Move over an annotated widget to preview its context.' : mode === 'button' ? 'Use an Ask AI button to explicitly choose a widget.' : 'Click an annotated KPI or account row to load its context.'); }); }); document.querySelectorAll('[data-format]').forEach(function(btn) { @@ -1151,11 +1376,95 @@

Start with one attribute.

function setToolHint(text) { if (toolHint) toolHint.textContent = text; } + function setPatternState(btn, hint) { + if (!btn) return; + if (activePatternName) activePatternName.textContent = btn.getAttribute('data-pattern-title') || btn.textContent.trim(); + if (patternPayload) patternPayload.textContent = btn.getAttribute('data-pattern-payload') || 'Context packet'; + if (hint) setToolHint(hint); + } + function resizeAiTrail() { + if (!aiCursorCanvas || !selectionLayer || !aiCursorCtx) return; + var rect = selectionLayer.getBoundingClientRect(); + aiTrailDpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); + aiCursorCanvas.width = Math.max(1, Math.round(rect.width * aiTrailDpr)); + aiCursorCanvas.height = Math.max(1, Math.round(rect.height * aiTrailDpr)); + aiCursorCtx.setTransform(aiTrailDpr, 0, 0, aiTrailDpr, 0, 0); + } + function clearAiTrailCanvas() { + if (!aiCursorCanvas || !aiCursorCtx) return; + aiCursorCtx.setTransform(1, 0, 0, 1, 0, 0); + aiCursorCtx.clearRect(0, 0, aiCursorCanvas.width, aiCursorCanvas.height); + aiCursorCtx.setTransform(aiTrailDpr, 0, 0, aiTrailDpr, 0, 0); + } + function resetAiTrail() { + aiTrailPoints = []; + if (aiTrailFrame) { + cancelAnimationFrame(aiTrailFrame); + aiTrailFrame = null; + } + clearAiTrailCanvas(); + } + function drawAiTrail() { + aiTrailFrame = null; + clearAiTrailCanvas(); + if (activeTool !== 'lasso') { + aiTrailPoints = []; + return; + } + if (aiTrailPoints.length > 1 && aiCursorCtx) { + var start = aiTrailPoints[0]; + var end = aiTrailPoints[aiTrailPoints.length - 1]; + aiCursorCtx.beginPath(); + aiCursorCtx.moveTo(start.x, start.y); + for (var i = 1; i < aiTrailPoints.length - 1; i++) { + var xc = (aiTrailPoints[i].x + aiTrailPoints[i + 1].x) / 2; + var yc = (aiTrailPoints[i].y + aiTrailPoints[i + 1].y) / 2; + aiCursorCtx.quadraticCurveTo(aiTrailPoints[i].x, aiTrailPoints[i].y, xc, yc); + } + var gradient = aiCursorCtx.createLinearGradient(start.x, start.y, end.x, end.y); + gradient.addColorStop(0, 'rgba(109, 40, 217, 0)'); + gradient.addColorStop(0.48, 'rgba(124, 58, 237, 0.22)'); + gradient.addColorStop(0.84, 'rgba(139, 92, 246, 0.38)'); + gradient.addColorStop(1, 'rgba(167, 139, 250, 0.52)'); + aiCursorCtx.strokeStyle = gradient; + aiCursorCtx.lineWidth = 9; + aiCursorCtx.lineCap = 'round'; + aiCursorCtx.lineJoin = 'round'; + aiCursorCtx.shadowBlur = 8; + aiCursorCtx.shadowColor = '#8b5cf6'; + aiCursorCtx.stroke(); + aiCursorCtx.shadowBlur = 0; + aiCursorCtx.strokeStyle = 'rgba(255,255,255,0.12)'; + aiCursorCtx.lineWidth = 2; + aiCursorCtx.stroke(); + } + if (aiTrailPoints.length) aiTrailPoints.shift(); + if (aiTrailPoints.length) aiTrailFrame = requestAnimationFrame(drawAiTrail); + } + function addAiTrailPoint(point) { + if (activeTool !== 'lasso') return; + aiTrailPoints.push({ x: point.x + 9, y: point.y + 9 }); + if (aiTrailPoints.length > aiTrailMaxPoints) aiTrailPoints.shift(); + if (!aiTrailFrame) aiTrailFrame = requestAnimationFrame(drawAiTrail); + } + function restoreClickMode(hint) { + clearToolState(); + askable.unobserve(); + askable.observe(document, { events: ['click'] }); + if (dashboard) dashboard.classList.remove('button-mode'); + document.querySelectorAll('[data-mode], [data-tool], #send-selection').forEach(function(btn) { btn.classList.remove('active'); }); + var clickButton = document.querySelector('[data-mode="click"]'); + if (clickButton) clickButton.classList.add('active'); + setPatternState(clickButton, hint || 'Click an annotated KPI or account row to load its context.'); + } function clearToolState() { if (toolClearTimer) { clearTimeout(toolClearTimer); toolClearTimer = null; } activeTool = null; dragStart = null; lassoPoints = []; textSelectionMode = false; + clearSelectedTextPreview(); if (drawingEl) { drawingEl.remove(); drawingEl = null; } - if (dashboard) dashboard.classList.remove('selecting'); + resetAiTrail(); + if (dashboard) dashboard.classList.remove('selecting', 'lasso-active'); + if (aiCursor) aiCursor.style.display = ''; if (cancelTool) cancelTool.style.display = 'none'; document.querySelectorAll('[data-tool]').forEach(function(btn) { btn.classList.remove('active'); }); var textButton = document.getElementById('send-selection'); @@ -1167,13 +1476,23 @@

Start with one attribute.

askable.unobserve(); if (dashboard) dashboard.classList.remove('button-mode'); if (dashboard) dashboard.classList.add('selecting'); + if (dashboard) dashboard.classList.toggle('lasso-active', tool === 'lasso'); + if (tool === 'lasso') { + resizeAiTrail(); + resetAiTrail(); + } if (dashboard && dashboard.scrollIntoView) dashboard.scrollIntoView({ block: 'center', inline: 'nearest' }); if (cancelTool) cancelTool.style.display = 'inline-flex'; document.querySelectorAll('[data-mode], [data-tool], #send-selection').forEach(function(btn) { btn.classList.remove('active'); }); document.querySelectorAll('[data-tool]').forEach(function(btn) { btn.classList.toggle('active', btn.getAttribute('data-tool') === tool); }); - setToolHint(tool === 'region' ? 'Drag a box over the dashboard.' : tool === 'circle' ? 'Drag from the center to circle an area.' : 'Freehand drag around an irregular area.'); + setPatternState(document.querySelector('[data-tool="' + tool + '"]'), tool === 'region' ? 'Drag a box over the dashboard.' : tool === 'circle' ? 'Drag from the center to circle an area.' : 'Freehand drag with the AI cursor. Lasso stays active until you cancel.'); + } + function updateAiCursor(point) { + if (!aiCursor || activeTool !== 'lasso') return; + aiCursor.style.transform = 'translate3d(' + Math.round(point.x + 2) + 'px,' + Math.round(point.y + 2) + 'px,0)'; + addAiTrailPoint(point); } function layerPoint(e) { var rect = selectionLayer.getBoundingClientRect(); @@ -1265,7 +1584,7 @@

Start with one attribute.

if (activeTool === 'lasso') { lassoPoints.push(point); var path = lassoPoints.map(function(p, index) { return (index ? 'L' : 'M') + Math.round(p.x) + ' ' + Math.round(p.y); }).join(' '); - drawingEl.innerHTML = ''; + drawingEl.innerHTML = ''; return; } var b = boundsFromPoints(dragStart, point); @@ -1300,7 +1619,13 @@

Start with one attribute.

var radius = activeTool === 'circle' ? Math.round(Math.sqrt(Math.pow(point.x - dragStart.x, 2) + Math.pow(point.y - dragStart.y, 2))) : null; if ((bounds.width < 8 || bounds.height < 8) && activeTool !== 'circle') { setToolHint('Selection was too small. Try dragging a larger area.'); - clearToolState(); + if (activeTool === 'lasso') { + if (drawingEl) { drawingEl.remove(); drawingEl = null; } + dragStart = null; + lassoPoints = []; + } else { + clearToolState(); + } return; } var selectedItems = selectedDashboardItems(bounds, activeTool, radius); @@ -1322,7 +1647,17 @@

Start with one attribute.

}; askable.push(meta, selectionSummary(activeTool, selectedItems), dashboard); setToolHint(activeTool + ' context sent to the assistant.'); - toolClearTimer = setTimeout(clearToolState, 450); + if (activeTool === 'lasso') { + toolClearTimer = setTimeout(function() { + if (drawingEl) { drawingEl.remove(); drawingEl = null; } + dragStart = null; + lassoPoints = []; + resetAiTrail(); + setToolHint('Lasso context sent. Draw another lasso, or cancel to leave lasso mode.'); + }, 450); + } else { + toolClearTimer = setTimeout(clearToolState, 450); + } } document.querySelectorAll('[data-tool]').forEach(function(btn) { btn.addEventListener('click', function(e) { @@ -1333,30 +1668,56 @@

Start with one attribute.

if (cancelTool) { cancelTool.addEventListener('click', function(e) { e.stopPropagation(); - clearToolState(); - setToolHint('Selection cancelled.'); + restoreClickMode('Selection cancelled. Click an annotated KPI or account row to load context.'); }); } if (selectionLayer) { selectionLayer.addEventListener('pointerdown', function(e) { if (!activeTool) return; e.preventDefault(); + if (toolClearTimer) { clearTimeout(toolClearTimer); toolClearTimer = null; } + if (drawingEl) { drawingEl.remove(); drawingEl = null; } + if (activeTool === 'lasso') { + resizeAiTrail(); + resetAiTrail(); + } selectionLayer.setPointerCapture(e.pointerId); dragStart = layerPoint(e); + updateAiCursor(dragStart); lassoPoints = [dragStart]; drawSelection(dragStart); }); selectionLayer.addEventListener('pointermove', function(e) { - if (!activeTool || !dragStart) return; + if (!activeTool) return; e.preventDefault(); - drawSelection(layerPoint(e)); + var point = layerPoint(e); + updateAiCursor(point); + if (!dragStart) return; + drawSelection(point); }); selectionLayer.addEventListener('pointerup', function(e) { if (!activeTool || !dragStart) return; e.preventDefault(); - finishSelection(layerPoint(e)); + var point = layerPoint(e); + updateAiCursor(point); + finishSelection(point); + }); + selectionLayer.addEventListener('pointerleave', function() { + if (aiCursor) aiCursor.style.display = 'none'; + resetAiTrail(); + }); + selectionLayer.addEventListener('pointerenter', function(e) { + if (aiCursor) aiCursor.style.display = ''; + if (activeTool === 'lasso') resizeAiTrail(); + if (activeTool === 'lasso') updateAiCursor(layerPoint(e)); }); } + window.addEventListener('resize', function() { + if (activeTool === 'lasso') { + resizeAiTrail(); + resetAiTrail(); + } + }); document.getElementById('send-selection').addEventListener('click', function(e) { e.stopPropagation(); clearToolState(); @@ -1364,12 +1725,15 @@

Start with one attribute.

if (dashboard) dashboard.classList.remove('button-mode'); if (dashboard && dashboard.scrollIntoView) dashboard.scrollIntoView({ block: 'center', inline: 'nearest' }); textSelectionMode = true; + clearSelectedTextPreview(); document.querySelectorAll('[data-mode], [data-tool], #send-selection').forEach(function(btn) { btn.classList.remove('active'); }); e.currentTarget.classList.add('active'); + setPatternState(e.currentTarget, 'Highlight text in the demo. It will be sent when you release the mouse.'); askable.clear(); lastKey = null; contextBar.style.display = 'none'; contextChip.textContent = ''; + hideQuestionSuggestions(); if (activeEl) activeEl.classList.remove('active'); activeEl = null; codeAttr.textContent = 'highlight text in the demo, then release the mouse…'; @@ -1382,13 +1746,17 @@

Start with one attribute.

}); document.addEventListener('mouseup', function() { if (!textSelectionMode) return; - var text = window.getSelection ? String(window.getSelection()).trim() : ''; + var sel = window.getSelection ? window.getSelection() : null; + var text = sel ? String(sel).trim() : ''; if (!text) { setToolHint('Highlight text in the demo first, then send it as context.'); return; } + var range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; askable.push({ capture: 'text-selection', length: text.length, consent: 'explicit' }, text, dashboard); - setToolHint('Highlighted text sent to the assistant.'); + showTextCaptureMarks(range); + showSelectedTextPreview(text); + setToolHint('Highlighted text was sent as explicit chat context.'); }); var responses = { revenue: 'MRR is up 12.4% to $128,400. Momentum looks healthy, likely expansion + retention working together.', @@ -1431,6 +1799,100 @@

Start with one attribute.

} return ''; } + function questionOptions(meta) { + if (!meta || typeof meta !== 'object') return []; + if (meta.capture === 'text-selection') return ['Summarize this', 'Extract action items', 'Use this in my answer']; + if (meta.capture === 'lasso') return ['What stands out?', 'Summarize this area', 'What should I do next?']; + if (meta.capture === 'region') return ['Summarize this area', 'Find risks here', 'What changed?']; + if (meta.capture === 'circle') return ['Explain this point', 'Is this an issue?', 'What should I do?']; + if (meta.widget === 'account_row') { + if (meta.status === 'at_risk') return ['Recommended action', 'Why is this risky?', 'Draft next step']; + if (meta.status === 'churned') return ['Can we win back?', 'Explain churn', 'Draft follow-up']; + return ['Upsell candidate?', 'Summarize account', 'Draft QBR note']; + } + if (meta.widget === 'revenue') return ['Why is this trending?', 'Explain drivers', 'Forecast next month']; + if (meta.widget === 'churn') return ['What drove churn?', 'Find retention risks', 'Next action']; + if (meta.widget === 'arpu') return ['How improve ARPU?', 'Explain drop', 'Find plan mix issue']; + if (meta.widget === 'nps') return ['What should we do?', 'Summarize sentiment', 'Find expansion signal']; + return ['Summarize context', 'Find risks', 'Recommended action']; + } + function hideQuestionSuggestions() { + if (!composerSuggestions) return; + composerSuggestions.style.display = 'none'; + composerSuggestions.innerHTML = ''; + } + function clearSelectedTextPreview() { + if (selectedTextCard) selectedTextCard.style.display = 'none'; + if (selectedTextPreview) selectedTextPreview.textContent = ''; + if (contextBar) contextBar.classList.remove('text-context'); + if (contextBarLabel) contextBarLabel.textContent = 'Context:'; + document.querySelectorAll('.text-capture-mark').forEach(function(mark) { mark.remove(); }); + } + function showSelectedTextPreview(text) { + if (!selectedTextCard || !selectedTextPreview) return; + selectedTextPreview.textContent = text; + selectedTextCard.style.display = 'block'; + if (contextBar) contextBar.classList.add('text-context'); + if (contextBarLabel) contextBarLabel.textContent = 'Selected text:'; + } + function showTextCaptureMarks(range) { + if (!dashboard || !range) return; + document.querySelectorAll('.text-capture-mark').forEach(function(mark) { mark.remove(); }); + var hostRect = dashboard.getBoundingClientRect(); + Array.from(range.getClientRects()).forEach(function(rect) { + if (rect.width < 3 || rect.height < 3) return; + var mark = document.createElement('span'); + mark.className = 'text-capture-mark'; + mark.style.left = Math.max(0, rect.left - hostRect.left - 2) + 'px'; + mark.style.top = Math.max(0, rect.top - hostRect.top - 1) + 'px'; + mark.style.width = rect.width + 4 + 'px'; + mark.style.height = rect.height + 2 + 'px'; + dashboard.appendChild(mark); + }); + } + function addContextMsg(focus) { + var meta = focus ? focus.meta : null; + if (meta && meta.capture === 'text-selection') { + var wrap = document.createElement('div'); + wrap.className = 'msg ctx text-context'; + var bubble = document.createElement('div'); + bubble.className = 'msg-bubble'; + var kicker = document.createElement('span'); + kicker.className = 'msg-context-kicker'; + kicker.textContent = 'Selected text sent as context'; + var quote = document.createElement('span'); + quote.className = 'msg-context-quote'; + quote.textContent = '"' + focus.text + '"'; + bubble.appendChild(kicker); + bubble.appendChild(quote); + wrap.appendChild(bubble); + messagesEl.appendChild(wrap); + scrollBottom(); + return; + } + addMsg('ctx', 'Context: ' + askable.toPromptContext(promptOpts)); + } + function showQuestionSuggestions(meta) { + if (!composerSuggestions) return; + var options = questionOptions(meta).slice(0, 3); + if (!options.length) { + hideQuestionSuggestions(); + return; + } + composerSuggestions.innerHTML = ''; + options.forEach(function(label) { + var chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'question-chip'; + chip.textContent = label; + chip.addEventListener('click', function() { + chatInput.value = label; + chatInput.focus(); + }); + composerSuggestions.appendChild(chip); + }); + composerSuggestions.style.display = 'flex'; + } askable.on('focus', function(focus) { var meta = focus.meta; var prompt = askable.toPromptContext(promptOpts); var key = JSON.stringify(meta); if (key === lastKey) return; lastKey = key; @@ -1448,18 +1910,24 @@

Start with one attribute.

// Show context bar — let user see what's loaded before sending contextBar.style.display = 'flex'; contextChip.textContent = prompt; + if (meta && meta.capture === 'text-selection') showSelectedTextPreview(focus.text); + else clearSelectedTextPreview(); // Highlight active element if (activeEl) activeEl.classList.remove('active'); - activeEl = focus.element; activeEl.classList.add('active'); + activeEl = focus.element; + if (activeEl) activeEl.classList.add('active'); // Pre-fill without moving scroll; selection tools should stay under the pointer. var suggestion = suggestQuestion(meta); chatInput.value = suggestion; chatInput.placeholder = 'Ask about this context, or type your own question…'; + showQuestionSuggestions(meta); }); document.getElementById('context-dismiss').addEventListener('click', function() { contextBar.style.display = 'none'; contextChip.textContent = ''; + clearSelectedTextPreview(); if (activeEl) activeEl.classList.remove('active'); activeEl = null; lastKey = null; askable.clear(); + hideQuestionSuggestions(); chatInput.value = ''; chatInput.placeholder = 'Select context above, then ask anything…'; codeAttr.textContent = 'select context above…'; codeCtx.textContent = 'waiting for context…'; @@ -1474,8 +1942,7 @@

Start with one attribute.

// Show user message, then context badge inline if context is loaded addMsg('user', text); if (focus) { - var ctxText = askable.toPromptContext(promptOpts); - addMsg('ctx', 'Context: ' + ctxText); + addContextMsg(focus); } clearTimeout(typingTimer); removeTyping(); showTyping(); typingTimer = setTimeout(function() { @@ -1491,7 +1958,7 @@

Start with one attribute.

} else if (meta.capture === 'lasso') { reply = 'The lassoed area gives the agent an irregular visual target. It can carry path points, bounds, and the surrounding UI state.'; } else if (meta.capture === 'text-selection') { - reply = 'I will answer using the highlighted text directly. Selected text context avoids guessing which copy on the page matters.'; + reply = 'I will answer from the selected text you attached. In a real chat request this text is sent as explicit context beside your question, so the model does not need to infer which copy on the page matters.'; } else if (w === 'revenue') { if (lower.indexOf('trend') !== -1 || lower.indexOf('why') !== -1 || lower.indexOf('driv') !== -1) reply = 'MRR grew 12.4% — likely a mix of new expansion + retention improving. Check if seat upgrades or plan changes account for the delta.';