Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion packages/qwik/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
51 changes: 41 additions & 10 deletions packages/qwik/src/useAskable.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, AskableContext>();
const globalRefCount = new Map<string, number>();

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
Expand All @@ -33,17 +67,14 @@ const DEFAULT_EVENTS: AskableEvent[] = ['click', 'hover', 'focus'];
export function useAskable(options?: UseAskableOptions): UseAskableResult {
const focus = useSignal<AskableFocus | null>(null);
const promptContext = useSignal<string>('');
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;
Expand All @@ -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;
});
});
Expand Down
100 changes: 100 additions & 0 deletions packages/qwik/src/useAskableCartSource.ts
Original file line number Diff line number Diff line change
@@ -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<AskableCreateCartSourceOptions, 'getSnapshot'> {
id?: string;
items?: AskableCartItem[];
totals?: AskableCartTotals;
}

export interface UseAskableCartSourceResult extends UseAskableSourceResult {
snapshot: ReturnType<typeof useSignal<AskableCartSourceSnapshot | null>>;
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 <p>{snapshot.value?.itemCount} items</p>;
* });
* ```
*/
export function useAskableCartSource(options: UseAskableCartSourceOptions = {}): UseAskableCartSourceResult {
const { id = 'cart', items: initialItems = [], totals: initialTotals = {}, describe, kind, enabled, ctx, name, events } = options;

const snapshot = useSignal<AskableCartSourceSnapshot | null>(
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 };
}
150 changes: 150 additions & 0 deletions packages/qwik/src/useAskableChat.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

export interface UseAskableChatOptions extends Omit<UseAskableOptions, 'inspector'> {
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<typeof useSignal<AskableChatMessage[]>>;
status: ReturnType<typeof useSignal<AskableChatStatus>>;
error: ReturnType<typeof useSignal<unknown>>;
isStreaming: ReturnType<typeof useSignal<boolean>>;
append(content: string, handler: AskableChatStreamHandler): Promise<void>;
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) => (
* <p key={m.id} class={m.role}>{m.content}</p>
* ))}
* <button onClick$={() => append('Explain this', async (req, msgs, emit) => {
* const res = await fetch('/api/chat', { method: 'POST', body: JSON.stringify(req) });
* const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
* while (true) {
* const { done, value } = await reader.read();
* if (done) break;
* emit(value);
* }
* })}>Send</button>
* </>
* );
* });
* ```
*/
export function useAskableChat(options: UseAskableChatOptions = {}): UseAskableChatResult {
const { initialMessages = [], systemPrompt, onChunk, onFinish, onError, requestOptions, ...askableOptions } = options;
const { ctx } = useAskable(askableOptions);

const messages = useSignal<AskableChatMessage[]>([...initialMessages]);
const status = useSignal<AskableChatStatus>('idle');
const error = useSignal<unknown>(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<void> {
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; } };
}
Loading
Loading