diff --git a/packages/qwik/src/index.ts b/packages/qwik/src/index.ts index 5b876299..d4ca8d9e 100644 --- a/packages/qwik/src/index.ts +++ b/packages/qwik/src/index.ts @@ -1,9 +1,50 @@ export { Askable } from './Askable.js'; +export type { AskableProps } from './Askable.js'; + export { useAskable } from './useAskable.js'; export type { UseAskableOptions, UseAskableResult } from './useAskable.js'; + +export { useAskableSource } from './useAskableSource.js'; +export type { UseAskableSourceOptions, UseAskableSourceResult } from './useAskableSource.js'; + export { useAskableAgent } from './useAskableAgent.js'; export type { AskableAgentStatus, UseAskableAgentOptions, UseAskableAgentResult } from './useAskableAgent.js'; -export type { AskableProps } from './Askable.js'; + +export { useAskableStream } from './useAskableStream.js'; +export type { AskableStreamStatus, AskableStreamHandler, UseAskableStreamOptions, UseAskableStreamResult } from './useAskableStream.js'; + +export { useAskableChat } from './useAskableChat.js'; +export type { AskableChatRole, AskableChatMessage, AskableChatStatus, AskableChatStreamHandler, UseAskableChatOptions, UseAskableChatResult } from './useAskableChat.js'; + +export { useAskableHistory } from './useAskableHistory.js'; +export type { UseAskableHistoryOptions, UseAskableHistoryResult } from './useAskableHistory.js'; + +export { useAskablePageSource } from './useAskablePageSource.js'; +export type { UseAskablePageSourceOptions, UseAskablePageSourceResult } from './useAskablePageSource.js'; + +export { useAskableNavigationSource } from './useAskableNavigationSource.js'; +export type { UseAskableNavigationSourceOptions, UseAskableNavigationSourceResult, AskableNavigationEntry } from './useAskableNavigationSource.js'; + +export { useAskableFormSource } from './useAskableFormSource.js'; +export type { UseAskableFormSourceOptions, UseAskableFormSourceResult } from './useAskableFormSource.js'; + +export { useAskableTableSource } from './useAskableTableSource.js'; +export type { UseAskableTableSourceOptions, UseAskableTableSourceResult } from './useAskableTableSource.js'; + +export { useAskableUserSource } from './useAskableUserSource.js'; +export type { UseAskableUserSourceOptions, UseAskableUserSourceResult } from './useAskableUserSource.js'; + +export { useAskableErrorSource } from './useAskableErrorSource.js'; +export type { UseAskableErrorSourceOptions, UseAskableErrorSourceResult } from './useAskableErrorSource.js'; + +export { useAskableNotificationSource } from './useAskableNotificationSource.js'; +export type { UseAskableNotificationSourceOptions, UseAskableNotificationSourceResult, AskableNotification, AskableNotificationSeverity } from './useAskableNotificationSource.js'; + +export { useAskableCartSource } from './useAskableCartSource.js'; +export type { UseAskableCartSourceOptions, UseAskableCartSourceResult, AskableCartItem, AskableCartSourceSnapshot, AskableCartTotals } from './useAskableCartSource.js'; + +export { useAskableMultistepSource } from './useAskableMultistepSource.js'; +export type { UseAskableMultistepSourceOptions, UseAskableMultistepSourceResult, AskableMultistepStep, AskableMultistepSourceSnapshot } from './useAskableMultistepSource.js'; // Re-export typed meta utility from core for convenience export { asMeta } from '@askable-ui/core'; diff --git a/packages/qwik/src/useAskable.ts b/packages/qwik/src/useAskable.ts index 86cf2ca9..e8fee567 100644 --- a/packages/qwik/src/useAskable.ts +++ b/packages/qwik/src/useAskable.ts @@ -1,4 +1,4 @@ -import { useSignal, useVisibleTask$, useTask$ } from '@builder.io/qwik'; +import { useSignal, useVisibleTask$ } from '@builder.io/qwik'; import { createAskableContext } from '@askable-ui/core'; import type { AskableContext, AskableContextOptions, AskableEvent, AskableFocus } from '@askable-ui/core'; @@ -15,9 +15,43 @@ export interface UseAskableResult { const DEFAULT_EVENTS: AskableEvent[] = ['click', 'hover', 'focus']; +// Module-level cache so all hooks in the same page share one default context +const globalCtxByKey = new Map(); +const globalRefCount = new Map(); + +function sharedKey(options?: UseAskableOptions): string { + const name = options?.name?.trim() ? `name:${options.name.trim()}` : 'global'; + const evts = (options?.events ?? DEFAULT_EVENTS).slice().sort().join('|'); + return `${name}::${evts}`; +} + +function retainCtx(key: string, options?: UseAskableOptions): AskableContext { + const existing = globalCtxByKey.get(key); + if (existing) { + globalRefCount.set(key, (globalRefCount.get(key) ?? 0) + 1); + return existing; + } + const ctx = createAskableContext(options); + globalCtxByKey.set(key, ctx); + globalRefCount.set(key, 1); + ctx.observe(document, { events: options?.events ?? DEFAULT_EVENTS }); + return ctx; +} + +function releaseCtx(key: string): void { + const count = (globalRefCount.get(key) ?? 1) - 1; + if (count > 0) { globalRefCount.set(key, count); return; } + globalRefCount.delete(key); + globalCtxByKey.get(key)?.destroy(); + globalCtxByKey.delete(key); +} + /** - * Qwik hook that creates an AskableContext, observes the document, and - * returns reactive signals for the current focus and prompt context. + * Qwik hook that creates (or shares) an AskableContext and returns reactive + * signals for the current focus and prompt context. + * + * Multiple calls with the same options share a single context instance so all + * source hooks on the page read from the same focus stream. * * @example * ```tsx @@ -33,17 +67,14 @@ const DEFAULT_EVENTS: AskableEvent[] = ['click', 'hover', 'focus']; export function useAskable(options?: UseAskableOptions): UseAskableResult { const focus = useSignal(null); const promptContext = useSignal(''); + const usesProvidedCtx = Boolean(options?.ctx); - // ctx lives outside signals — it's imperatively managed let ctx: AskableContext | null = null; + const key = sharedKey(options); // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(({ cleanup }) => { - ctx = options?.ctx ?? createAskableContext(options); - - if (!options?.ctx) { - ctx.observe(document, { events: options?.events ?? DEFAULT_EVENTS }); - } + ctx = usesProvidedCtx ? options!.ctx! : retainCtx(key, options); const handleFocus = (f: AskableFocus) => { focus.value = f; @@ -60,7 +91,7 @@ export function useAskable(options?: UseAskableOptions): UseAskableResult { cleanup(() => { ctx!.off('focus', handleFocus); ctx!.off('clear', handleClear); - if (!options?.ctx) ctx!.destroy(); + if (!usesProvidedCtx) releaseCtx(key); ctx = null; }); }); diff --git a/packages/qwik/src/useAskableCartSource.ts b/packages/qwik/src/useAskableCartSource.ts new file mode 100644 index 00000000..11216653 --- /dev/null +++ b/packages/qwik/src/useAskableCartSource.ts @@ -0,0 +1,100 @@ +import { useSignal } from '@builder.io/qwik'; +import { createAskableCartSource, buildCartSnapshot } from '@askable-ui/core'; +import type { + AskableCreateCartSourceOptions, + AskableCartItem, + AskableCartSourceSnapshot, + AskableCartTotals, +} from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export type { AskableCartItem, AskableCartSourceSnapshot, AskableCartTotals }; + +export interface UseAskableCartSourceOptions + extends UseAskableSourceOptions, + Omit { + id?: string; + items?: AskableCartItem[]; + totals?: AskableCartTotals; +} + +export interface UseAskableCartSourceResult extends UseAskableSourceResult { + snapshot: ReturnType>; + addItem(item: AskableCartItem): void; + removeItem(id: string): void; + updateQuantity(id: string, quantity: number): void; + setItems(items: AskableCartItem[]): void; + setTotals(totals: AskableCartTotals): void; + clearCart(): void; +} + +/** + * Qwik hook that tracks shopping cart state and exposes it to AI assistants. + * + * ```tsx + * export const CartWidget = component$(() => { + * const { snapshot, addItem } = useAskableCartSource({ + * items: [], totals: { currency: 'USD' }, + * }); + * return

{snapshot.value?.itemCount} items

; + * }); + * ``` + */ +export function useAskableCartSource(options: UseAskableCartSourceOptions = {}): UseAskableCartSourceResult { + const { id = 'cart', items: initialItems = [], totals: initialTotals = {}, describe, kind, enabled, ctx, name, events } = options; + + const snapshot = useSignal( + buildCartSnapshot(initialItems, initialTotals, new Date().toISOString()), + ); + + const source = createAskableCartSource({ describe, kind, getSnapshot: () => snapshot.value }); + const result = useAskableSource(id, source, { enabled, ctx, name, events }); + + function getTotals(): AskableCartTotals { + const s = snapshot.value; + return s ? { discount: s.discount, tax: s.tax, shipping: s.shipping, currency: s.currency, couponCode: s.couponCode } : {}; + } + + function addItem(item: AskableCartItem): void { + const prev = snapshot.value; + if (!prev) return; + const idx = prev.items.findIndex((i) => i.id === item.id); + const items = idx >= 0 ? prev.items.map((i, k) => (k === idx ? item : i)) : [...prev.items, item]; + snapshot.value = buildCartSnapshot(items, getTotals(), new Date().toISOString()); + result.notifyChanged(); + } + + function removeItem(id: string): void { + if (!snapshot.value) return; + snapshot.value = buildCartSnapshot(snapshot.value.items.filter((i) => i.id !== id), getTotals(), new Date().toISOString()); + result.notifyChanged(); + } + + function updateQuantity(id: string, quantity: number): void { + if (!snapshot.value) return; + const items = quantity <= 0 + ? snapshot.value.items.filter((i) => i.id !== id) + : snapshot.value.items.map((i) => (i.id === id ? { ...i, quantity } : i)); + snapshot.value = buildCartSnapshot(items, getTotals(), new Date().toISOString()); + result.notifyChanged(); + } + + function setItems(items: AskableCartItem[]): void { + snapshot.value = buildCartSnapshot(items, getTotals(), new Date().toISOString()); + result.notifyChanged(); + } + + function setTotals(totals: AskableCartTotals): void { + if (!snapshot.value) return; + snapshot.value = buildCartSnapshot(snapshot.value.items, totals, new Date().toISOString()); + result.notifyChanged(); + } + + function clearCart(): void { + const currency = snapshot.value?.currency ?? 'USD'; + snapshot.value = buildCartSnapshot([], { discount: 0, tax: 0, shipping: 0, currency, couponCode: null }, new Date().toISOString()); + result.notifyChanged(); + } + + return { ...result, snapshot, addItem, removeItem, updateQuantity, setItems, setTotals, clearCart }; +} diff --git a/packages/qwik/src/useAskableChat.ts b/packages/qwik/src/useAskableChat.ts new file mode 100644 index 00000000..92c638ed --- /dev/null +++ b/packages/qwik/src/useAskableChat.ts @@ -0,0 +1,150 @@ +import { useSignal } from '@builder.io/qwik'; +import type { AskableAgentRequest, AskableAgentRequestOptions, AskableContext } from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.js'; + +export type AskableChatRole = 'user' | 'assistant' | 'system'; + +export interface AskableChatMessage { + id: string; + role: AskableChatRole; + content: string; + request?: AskableAgentRequest; + createdAt: number; +} + +export type AskableChatStatus = 'idle' | 'streaming' | 'error'; + +export type AskableChatStreamHandler = ( + request: AskableAgentRequest, + messages: AskableChatMessage[], + emit: (chunk: string) => void, +) => Promise; + +export interface UseAskableChatOptions extends Omit { + initialMessages?: AskableChatMessage[]; + systemPrompt?: string | ((context: string) => string); + onChunk?: (chunk: string) => void; + onFinish?: (message: AskableChatMessage) => void; + onError?: (error: unknown) => void; + requestOptions?: AskableAgentRequestOptions; + ctx?: AskableContext; +} + +export interface UseAskableChatResult { + messages: ReturnType>; + status: ReturnType>; + error: ReturnType>; + isStreaming: ReturnType>; + append(content: string, handler: AskableChatStreamHandler): Promise; + clearMessages(): void; + abort(): void; + ctx: AskableContext; +} + +let idCounter = 0; +function nextId() { return `msg-${Date.now()}-${++idCounter}`; } + +/** + * Qwik hook for multi-turn AI chat. Injects the current UI context into every + * turn automatically. + * + * ```tsx + * export const ChatPanel = component$(() => { + * const { messages, append, isStreaming } = useAskableChat({ + * systemPrompt: (ctx) => `You are helpful.\n\n${ctx}`, + * }); + * + * return ( + * <> + * {messages.value.map((m) => ( + *

{m.content}

+ * ))} + * + * + * ); + * }); + * ``` + */ +export function useAskableChat(options: UseAskableChatOptions = {}): UseAskableChatResult { + const { initialMessages = [], systemPrompt, onChunk, onFinish, onError, requestOptions, ...askableOptions } = options; + const { ctx } = useAskable(askableOptions); + + const messages = useSignal([...initialMessages]); + const status = useSignal('idle'); + const error = useSignal(null); + const isStreaming = useSignal(false); + + let currentAc: AbortController | null = null; + let contentAccum = ''; + + function abort(): void { + currentAc?.abort(); + currentAc = null; + isStreaming.value = false; + status.value = 'idle'; + } + + function clearMessages(): void { + messages.value = []; + status.value = 'idle'; + error.value = null; + isStreaming.value = false; + } + + async function append(content: string, handler: AskableChatStreamHandler): Promise { + abort(); + currentAc = new AbortController(); + + const userMsg: AskableChatMessage = { id: nextId(), role: 'user', content, createdAt: Date.now() }; + messages.value = [...messages.value, userMsg]; + + let req = await ctx.toAgentRequest(content, requestOptions); + + if (systemPrompt) { + const sys = typeof systemPrompt === 'function' ? systemPrompt(ctx.toPromptContext()) : systemPrompt; + req = { ...req, systemPrompt: sys }; + } + + const assistantId = nextId(); + contentAccum = ''; + const assistantMsg: AskableChatMessage = { id: assistantId, role: 'assistant', content: '', request: req, createdAt: Date.now() }; + messages.value = [...messages.value, assistantMsg]; + + status.value = 'streaming'; + isStreaming.value = true; + error.value = null; + + try { + await handler(req, messages.value.slice(0, -1), (chunk) => { + contentAccum += chunk; + messages.value = messages.value.map((m) => + m.id === assistantId ? { ...m, content: contentAccum } : m, + ); + onChunk?.(chunk); + }); + + const finished = messages.value.find((m) => m.id === assistantId)!; + onFinish?.(finished); + status.value = 'idle'; + } catch (e) { + if ((e as Error)?.name !== 'AbortError') { + error.value = e; + status.value = 'error'; + onError?.(e); + } + } finally { + isStreaming.value = false; + currentAc = null; + } + } + + return { messages, status, error, isStreaming, append, clearMessages, abort, get ctx() { return ctx; } }; +} diff --git a/packages/qwik/src/useAskableErrorSource.ts b/packages/qwik/src/useAskableErrorSource.ts new file mode 100644 index 00000000..0c123eba --- /dev/null +++ b/packages/qwik/src/useAskableErrorSource.ts @@ -0,0 +1,26 @@ +import { createAskableErrorSource } from '@askable-ui/core'; +import type { AskableCreateErrorSourceOptions } from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export interface UseAskableErrorSourceOptions + extends UseAskableSourceOptions, + AskableCreateErrorSourceOptions { + id?: string; +} + +export type UseAskableErrorSourceResult = UseAskableSourceResult; + +/** + * Registers an error source that captures recent application errors + * so the AI can reference them in responses. + * + * ```tsx + * const { notifyChanged } = useAskableErrorSource(); + * // call notifyChanged() after catching an error + * ``` + */ +export function useAskableErrorSource(options: UseAskableErrorSourceOptions = {}): UseAskableErrorSourceResult { + const { id = 'errors', enabled, ctx, name, events, ...sourceOptions } = options; + const source = createAskableErrorSource(sourceOptions); + return useAskableSource(id, source, { enabled, ctx, name, events }); +} diff --git a/packages/qwik/src/useAskableFormSource.ts b/packages/qwik/src/useAskableFormSource.ts new file mode 100644 index 00000000..db0fe338 --- /dev/null +++ b/packages/qwik/src/useAskableFormSource.ts @@ -0,0 +1,28 @@ +import { createAskableFormSource } from '@askable-ui/core'; +import type { AskableCreateFormSourceOptions } from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export interface UseAskableFormSourceOptions + extends UseAskableSourceOptions, + AskableCreateFormSourceOptions { + id?: string; +} + +export type UseAskableFormSourceResult = UseAskableSourceResult; + +/** + * Registers a form source that serializes field values and validation state + * for a given `
` element. + * + * ```tsx + * export const ContactForm = component$(() => { + * useAskableFormSource({ selector: '#contact-form' }); + * // ... + * }); + * ``` + */ +export function useAskableFormSource(options: UseAskableFormSourceOptions = {}): UseAskableFormSourceResult { + const { id = 'form', enabled, ctx, name, events, ...sourceOptions } = options; + const source = createAskableFormSource(sourceOptions); + return useAskableSource(id, source, { enabled, ctx, name, events }); +} diff --git a/packages/qwik/src/useAskableHistory.ts b/packages/qwik/src/useAskableHistory.ts new file mode 100644 index 00000000..be13ffed --- /dev/null +++ b/packages/qwik/src/useAskableHistory.ts @@ -0,0 +1,62 @@ +import { useSignal, useVisibleTask$, useComputed$ } from '@builder.io/qwik'; +import type { AskableContext, AskableFocus } from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.js'; + +export interface UseAskableHistoryOptions extends UseAskableOptions { + maxEntries?: number; + dedupe?: boolean; +} + +export interface UseAskableHistoryResult { + history: ReturnType>; + current: ReturnType>; + ctx: AskableContext; +} + +/** + * Qwik hook that maintains a history of recent focus events. Useful for + * building AI chat that can reference what the user was looking at previously. + * + * ```tsx + * export const AskHistory = component$(() => { + * const { history } = useAskableHistory({ maxEntries: 5 }); + * return ( + *
    + * {history.value.map((f, i) => ( + *
  • {JSON.stringify(f.meta)}
  • + * ))} + *
+ * ); + * }); + * ``` + */ +export function useAskableHistory(options?: UseAskableHistoryOptions): UseAskableHistoryResult { + const maxEntries = options?.maxEntries ?? 10; + const dedupe = options?.dedupe ?? true; + + const { ctx } = useAskable(options); + const history = useSignal([]); + const current = useSignal(null); + + // eslint-disable-next-line qwik/no-use-visible-task + useVisibleTask$(({ cleanup }) => { + const handleFocus = (f: AskableFocus) => { + current.value = f; + const prev = history.value; + if (dedupe && prev.length > 0 && JSON.stringify(prev[0].meta) === JSON.stringify(f.meta)) return; + const next = [f, ...prev]; + history.value = next.length > maxEntries ? next.slice(0, maxEntries) : next; + }; + const handleClear = (_: null) => { current.value = null; }; + + ctx.on('focus', handleFocus); + ctx.on('clear', handleClear); + + cleanup(() => { + ctx.off('focus', handleFocus); + ctx.off('clear', handleClear); + }); + }); + + return { history, current, ctx }; +} diff --git a/packages/qwik/src/useAskableMultistepSource.ts b/packages/qwik/src/useAskableMultistepSource.ts new file mode 100644 index 00000000..cf5562b6 --- /dev/null +++ b/packages/qwik/src/useAskableMultistepSource.ts @@ -0,0 +1,105 @@ +import { useSignal } from '@builder.io/qwik'; +import { createAskableMultistepSource, buildMultistepSnapshot } from '@askable-ui/core'; +import type { + AskableCreateMultistepSourceOptions, + AskableMultistepStep, + AskableMultistepSourceSnapshot, +} from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export type { AskableMultistepStep, AskableMultistepSourceSnapshot }; + +export interface UseAskableMultistepSourceOptions + extends UseAskableSourceOptions, + Omit { + id?: string; + steps?: Pick[]; + initialStep?: number; +} + +export interface UseAskableMultistepSourceResult extends UseAskableSourceResult { + snapshot: ReturnType>; + next(): void; + prev(): void; + goTo(indexOrId: number | string): void; + setSteps(steps: Pick[]): void; +} + +/** + * Qwik hook that tracks wizard / stepper / checkout flow progress and exposes + * it to AI assistants. + * + * ```tsx + * export const Checkout = component$(() => { + * const wizard = useAskableMultistepSource({ + * steps: [ + * { id: 'cart', label: 'Cart' }, + * { id: 'shipping', label: 'Shipping' }, + * { id: 'payment', label: 'Payment' }, + * ], + * }); + * return ( + *
+ *

Step {wizard.snapshot.value?.currentIndex + 1}

+ * + *
+ * ); + * }); + * ``` + */ +export function useAskableMultistepSource(options: UseAskableMultistepSourceOptions = {}): UseAskableMultistepSourceResult { + const { id = 'multistep', steps: initialSteps = [], initialStep = 0, describe, kind, enabled, ctx, name, events } = options; + + const fullSteps: AskableMultistepStep[] = initialSteps.map((s, i) => ({ + ...s, + index: i, + isComplete: false, + isActive: i === initialStep, + isCurrent: i === initialStep, + })); + + const snapshot = useSignal( + fullSteps.length > 0 ? buildMultistepSnapshot(fullSteps, initialStep, new Date().toISOString()) : null, + ); + + const source = createAskableMultistepSource({ describe, kind, getSnapshot: () => snapshot.value }); + const result = useAskableSource(id, source, { enabled, ctx, name, events }); + + function currentSteps(): AskableMultistepStep[] { + return snapshot.value?.steps ?? fullSteps; + } + + function currentIndex(): number { + return snapshot.value?.currentIndex ?? initialStep; + } + + function applyIndex(idx: number): void { + const steps = currentSteps(); + if (idx < 0 || idx >= steps.length) return; + snapshot.value = buildMultistepSnapshot(steps, idx, new Date().toISOString()); + result.notifyChanged(); + } + + function next(): void { applyIndex(currentIndex() + 1); } + function prev(): void { applyIndex(currentIndex() - 1); } + + function goTo(indexOrId: number | string): void { + if (typeof indexOrId === 'number') { applyIndex(indexOrId); return; } + const idx = currentSteps().findIndex((s) => s.id === indexOrId); + if (idx >= 0) applyIndex(idx); + } + + function setSteps(steps: Pick[]): void { + const full: AskableMultistepStep[] = steps.map((s, i) => ({ + ...s, + index: i, + isComplete: false, + isActive: i === 0, + isCurrent: i === 0, + })); + snapshot.value = buildMultistepSnapshot(full, 0, new Date().toISOString()); + result.notifyChanged(); + } + + return { ...result, snapshot, next, prev, goTo, setSteps }; +} diff --git a/packages/qwik/src/useAskableNavigationSource.ts b/packages/qwik/src/useAskableNavigationSource.ts new file mode 100644 index 00000000..95e47e4d --- /dev/null +++ b/packages/qwik/src/useAskableNavigationSource.ts @@ -0,0 +1,26 @@ +import { createAskableNavigationSource } from '@askable-ui/core'; +import type { AskableCreateNavigationSourceOptions, AskableNavigationEntry } from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export type { AskableNavigationEntry }; + +export interface UseAskableNavigationSourceOptions + extends UseAskableSourceOptions, + AskableCreateNavigationSourceOptions { + id?: string; +} + +export type UseAskableNavigationSourceResult = UseAskableSourceResult; + +/** + * Registers a navigation source that tracks page route history. + * + * ```tsx + * useAskableNavigationSource({ maxEntries: 5 }); + * ``` + */ +export function useAskableNavigationSource(options: UseAskableNavigationSourceOptions = {}): UseAskableNavigationSourceResult { + const { id = 'navigation', enabled, ctx, name, events, ...sourceOptions } = options; + const source = createAskableNavigationSource(sourceOptions); + return useAskableSource(id, source, { enabled, ctx, name, events }); +} diff --git a/packages/qwik/src/useAskableNotificationSource.ts b/packages/qwik/src/useAskableNotificationSource.ts new file mode 100644 index 00000000..1391ffd6 --- /dev/null +++ b/packages/qwik/src/useAskableNotificationSource.ts @@ -0,0 +1,64 @@ +import { useSignal, useVisibleTask$ } from '@builder.io/qwik'; +import { createAskableNotificationSource } from '@askable-ui/core'; +import type { + AskableCreateNotificationSourceOptions, + AskableNotification, + AskableNotificationSeverity, +} from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export type { AskableNotification, AskableNotificationSeverity }; + +export interface UseAskableNotificationSourceOptions + extends UseAskableSourceOptions, + Omit { + id?: string; + maxEntries?: number; +} + +export interface UseAskableNotificationSourceResult extends UseAskableSourceResult { + push(notification: Omit): void; + dismiss(id: string): void; + clear(): void; +} + +/** + * Registers a notification source that tracks active toasts, alerts, and + * banners so the AI can reference them. + * + * ```tsx + * const notifs = useAskableNotificationSource(); + * notifs.push({ message: 'Order placed!', severity: 'success' }); + * ``` + */ +export function useAskableNotificationSource( + options: UseAskableNotificationSourceOptions = {}, +): UseAskableNotificationSourceResult { + const { id = 'notifications', enabled, ctx, name, events, maxEntries = 20, describe, kind } = options; + + const items = useSignal([]); + let nextId = 1; + + function push(notification: Omit): void { + const entry: AskableNotification = { + ...notification, + id: String(nextId++), + timestamp: new Date().toISOString(), + }; + const next = [entry, ...items.value]; + items.value = next.length > maxEntries ? next.slice(0, maxEntries) : next; + } + + function dismiss(id: string): void { + items.value = items.value.filter((n) => n.id !== id); + } + + function clear(): void { + items.value = []; + } + + const source = createAskableNotificationSource({ describe, kind, getSnapshot: () => ({ notifications: items.value, count: items.value.length }) }); + const result = useAskableSource(id, source, { enabled, ctx, name, events }); + + return { ...result, push, dismiss, clear }; +} diff --git a/packages/qwik/src/useAskablePageSource.ts b/packages/qwik/src/useAskablePageSource.ts new file mode 100644 index 00000000..7037b14c --- /dev/null +++ b/packages/qwik/src/useAskablePageSource.ts @@ -0,0 +1,28 @@ +import { createAskablePageSource } from '@askable-ui/core'; +import type { AskableCreatePageSourceOptions } from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export interface UseAskablePageSourceOptions + extends UseAskableSourceOptions, + AskableCreatePageSourceOptions { + id?: string; +} + +export type UseAskablePageSourceResult = UseAskableSourceResult; + +/** + * Registers a page source that captures the current document title, URL, + * meta description, headings, and optionally links. + * + * ```tsx + * export const Layout = component$(() => { + * useAskablePageSource(); + * return ; + * }); + * ``` + */ +export function useAskablePageSource(options: UseAskablePageSourceOptions = {}): UseAskablePageSourceResult { + const { id = 'page', enabled, ctx, name, events, describe, kind, root, includeLinks, maxLinks, maxHeadings, maxTextLength, textExtractor, sanitizeText } = options; + const source = createAskablePageSource({ describe, kind, root, includeLinks, maxLinks, maxHeadings, maxTextLength, textExtractor, sanitizeText }); + return useAskableSource(id, source, { enabled, ctx, name, events }); +} diff --git a/packages/qwik/src/useAskableSource.ts b/packages/qwik/src/useAskableSource.ts new file mode 100644 index 00000000..13a54829 --- /dev/null +++ b/packages/qwik/src/useAskableSource.ts @@ -0,0 +1,70 @@ +import { useVisibleTask$ } from '@builder.io/qwik'; +import type { + AskableAsyncPromptContextOptions, + AskableContext, + AskableContextSource, + AskableContextSourceHandle, + AskableContextSourceRequest, + AskableResolvedContextSource, +} from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.js'; + +export interface UseAskableSourceOptions extends Omit { + enabled?: boolean; +} + +export interface UseAskableSourceResult { + ctx: AskableContext; + sourceId: string; + resolve(request?: Omit): Promise; + toPromptContext( + options?: Omit + & { source?: Omit }, + ): Promise; + notifyChanged(): void; + unregister(): void; +} + +/** + * Qwik hook that registers an arbitrary context source on the shared + * AskableContext. The source is registered once the component mounts in the + * browser and unregistered on cleanup. + */ +export function useAskableSource( + id: string, + source: AskableContextSource, + options: UseAskableSourceOptions = {}, +): UseAskableSourceResult { + const { enabled = true, ...askableOptions } = options; + const { ctx: _ctxRef } = useAskable(askableOptions); + + let handle: AskableContextSourceHandle | null = null; + + // eslint-disable-next-line qwik/no-use-visible-task + useVisibleTask$(({ cleanup }) => { + const ctx = _ctxRef; + if (!ctx || !enabled || !id.trim()) return; + + handle = ctx.registerSource(id.trim(), source); + + cleanup(() => { + handle?.unregister(); + handle = null; + }); + }); + + return { + get ctx() { return _ctxRef; }, + sourceId: id, + resolve: (request?) => _ctxRef.resolveSource(id, request), + toPromptContext: (opts?) => { + const { source: sourceRequest, ...rest } = opts ?? {}; + return _ctxRef.toPromptContextAsync({ + ...rest, + sources: [{ id, ...sourceRequest }], + }); + }, + notifyChanged: () => handle?.notifyChanged(), + unregister: () => { handle?.unregister(); handle = null; }, + }; +} diff --git a/packages/qwik/src/useAskableStream.ts b/packages/qwik/src/useAskableStream.ts new file mode 100644 index 00000000..c6da337a --- /dev/null +++ b/packages/qwik/src/useAskableStream.ts @@ -0,0 +1,141 @@ +import { useSignal } from '@builder.io/qwik'; +import type { AskableAgentRequest, AskableAgentRequestOptions, AskableContext } from '@askable-ui/core'; +import { useAskable, type UseAskableOptions } from './useAskable.js'; + +export type AskableStreamStatus = 'idle' | 'streaming' | 'success' | 'error'; + +export type AskableStreamHandler = ( + request: AskableAgentRequest, + emit: (chunk: string) => void, +) => Promise; + +export interface UseAskableStreamOptions extends Omit { + onRequest?: (request: AskableAgentRequest) => AskableAgentRequest | void | undefined; + onChunk?: (chunk: string, content: string) => void; + onSuccess?: (content: string, request: AskableAgentRequest) => void; + onError?: (error: unknown, request: AskableAgentRequest) => void; + requestOptions?: AskableAgentRequestOptions; + ctx?: AskableContext; +} + +export interface UseAskableStreamResult { + stream(question: string, handler: AskableStreamHandler): Promise; + streamFrom(question: string, source: ReadableStream | AsyncIterable): Promise; + status: ReturnType>; + content: ReturnType>; + error: ReturnType>; + lastRequest: ReturnType>; + isStreaming: ReturnType>; + reset(): void; + abort(): void; + ctx: AskableContext; +} + +/** + * Qwik hook for streaming LLM responses. Reactive `content.value` updates as + * each chunk arrives, driving progressive rendering. + * + * ```tsx + * export const Chat = component$(() => { + * const { stream, content, isStreaming } = useAskableStream(); + * return ( + * <> + * {isStreaming.value && Thinking…} + *

{content.value}

+ * + * + * ); + * }); + * ``` + */ +export function useAskableStream(options: UseAskableStreamOptions = {}): UseAskableStreamResult { + const { onRequest, onChunk, onSuccess, onError, requestOptions, ...askableOptions } = options; + const { ctx } = useAskable(askableOptions); + + const status = useSignal('idle'); + const content = useSignal(''); + const error = useSignal(null); + const lastRequest = useSignal(null); + const isStreaming = useSignal(false); + + let abortController: AbortController | null = null; + + function reset(): void { + status.value = 'idle'; + content.value = ''; + error.value = null; + lastRequest.value = null; + isStreaming.value = false; + } + + function abort(): void { + abortController?.abort(); + abortController = null; + isStreaming.value = false; + status.value = 'idle'; + } + + async function stream(question: string, handler: AskableStreamHandler): Promise { + abort(); + abortController = new AbortController(); + let req = await ctx.toAgentRequest(question, requestOptions); + if (onRequest) { const override = onRequest(req); if (override) req = override; } + + lastRequest.value = req; + status.value = 'streaming'; + isStreaming.value = true; + content.value = ''; + error.value = null; + + try { + await handler(req, (chunk) => { + content.value += chunk; + onChunk?.(chunk, content.value); + }); + status.value = 'success'; + onSuccess?.(content.value, req); + return content.value; + } catch (e) { + if ((e as Error)?.name !== 'AbortError') { + error.value = e; + status.value = 'error'; + onError?.(e, req); + } + return undefined; + } finally { + isStreaming.value = false; + abortController = null; + } + } + + async function streamFrom(question: string, source: ReadableStream | AsyncIterable): Promise { + return stream(question, async (_req, emit) => { + if (source instanceof ReadableStream) { + const reader = source.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + emit(value); + } + } finally { + reader.releaseLock(); + } + } else { + for await (const chunk of source) emit(chunk); + } + }); + } + + return { stream, streamFrom, status, content, error, lastRequest, isStreaming, reset, abort, get ctx() { return ctx; } }; +} diff --git a/packages/qwik/src/useAskableTableSource.ts b/packages/qwik/src/useAskableTableSource.ts new file mode 100644 index 00000000..904e421e --- /dev/null +++ b/packages/qwik/src/useAskableTableSource.ts @@ -0,0 +1,25 @@ +import { createAskableTableSource } from '@askable-ui/core'; +import type { AskableCreateTableSourceOptions } from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export interface UseAskableTableSourceOptions + extends UseAskableSourceOptions, + AskableCreateTableSourceOptions { + id?: string; +} + +export type UseAskableTableSourceResult = UseAskableSourceResult; + +/** + * Registers a table source that serializes visible rows, columns, sort state, + * filters, and selection for a data grid element. + * + * ```tsx + * useAskableTableSource({ selector: '#data-table', maxRows: 50 }); + * ``` + */ +export function useAskableTableSource(options: UseAskableTableSourceOptions = {}): UseAskableTableSourceResult { + const { id = 'table', enabled, ctx, name, events, ...sourceOptions } = options; + const source = createAskableTableSource(sourceOptions); + return useAskableSource(id, source, { enabled, ctx, name, events }); +} diff --git a/packages/qwik/src/useAskableUserSource.ts b/packages/qwik/src/useAskableUserSource.ts new file mode 100644 index 00000000..3863fa8b --- /dev/null +++ b/packages/qwik/src/useAskableUserSource.ts @@ -0,0 +1,25 @@ +import { createAskableUserSource } from '@askable-ui/core'; +import type { AskableCreateUserSourceOptions } from '@askable-ui/core'; +import { useAskableSource, type UseAskableSourceOptions, type UseAskableSourceResult } from './useAskableSource.js'; + +export interface UseAskableUserSourceOptions + extends UseAskableSourceOptions, + AskableCreateUserSourceOptions { + id?: string; +} + +export type UseAskableUserSourceResult = UseAskableSourceResult; + +/** + * Registers a user source that exposes authenticated user identity + * (name, email, roles, preferences) to the AI. + * + * ```tsx + * useAskableUserSource({ getUser: () => session.user }); + * ``` + */ +export function useAskableUserSource(options: UseAskableUserSourceOptions = {}): UseAskableUserSourceResult { + const { id = 'user', enabled, ctx, name, events, ...sourceOptions } = options; + const source = createAskableUserSource(sourceOptions); + return useAskableSource(id, source, { enabled, ctx, name, events }); +} diff --git a/site/docs/guide/getting-started.md b/site/docs/guide/getting-started.md index 18f16e51..93e119f0 100644 --- a/site/docs/guide/getting-started.md +++ b/site/docs/guide/getting-started.md @@ -37,6 +37,10 @@ npm install @askable-ui/vue @askable-ui/core npm install @askable-ui/svelte @askable-ui/core ``` +```bash [Angular] +npm install @askable-ui/angular @askable-ui/core +``` + ```bash [Plain JS] npm install @askable-ui/core ``` @@ -98,6 +102,13 @@ function RevenueCard() { ``` +```html [Angular] + +
+ +
+``` + ::: ## Step 2 — Observe the page @@ -135,6 +146,15 @@ const { promptContext, destroy } = createAskableStore(); onDestroy(destroy); ``` +```ts [Angular] +import { inject } from '@angular/core'; +import { AskableService } from '@askable-ui/angular'; + +// In your component: +private readonly askable = inject(AskableService); // starts observing automatically +// this.askable.promptContext() — Signal +``` + ```ts [Plain JS] import { createAskableContext } from '@askable-ui/core'; diff --git a/site/docs/index.md b/site/docs/index.md index 2258ec27..1b8999b7 100644 --- a/site/docs/index.md +++ b/site/docs/index.md @@ -29,7 +29,7 @@ features: - icon: ⚡ title: Framework-native bindings - details: First-class hooks and components for React, React Native, Vue, and Svelte. Web bindings are reactive and SSR-safe; React Native ships a focused press-driven adapter on top of @askable-ui/core. + details: First-class hooks and components for React, Vue, Svelte, Angular, SolidJS, Qwik, and React Native. All web bindings are reactive and SSR-safe; React Native ships a press-driven adapter on top of @askable-ui/core. - icon: 🎯 title: Multiple interaction patterns