UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

264 lines (244 loc) • 9.36 kB
"use client"; import { useMemo, type FC } from "react"; import { useAui } from "@assistant-ui/store"; import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerCategory, Unstable_TriggerItem, } from "@assistant-ui/core"; import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core"; import type { ReadonlyJSONObject } from "assistant-stream/utils"; import { matchesTriggerItemQuery } from "../primitives/composer/trigger/matchesTriggerItemQuery"; import { shallowEqualRecords, useModelContextSnapshot, type ModelContextSnapshotSource, } from "./useModelContextSnapshot"; /** * Icon component shape consumed by `ComposerTriggerPopover`'s `iconMap`. * * @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */ export type Unstable_IconComponent = FC<{ className?: string }>; /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */ export type Unstable_Mention = { readonly id: string; readonly type: string; readonly label: string; readonly description?: string | undefined; /** Shortcut for `metadata.icon`; merged with `metadata` if both are given. */ readonly icon?: string | undefined; readonly metadata?: ReadonlyJSONObject | undefined; }; /** @deprecated Experimental since 2026-03-16. Not scheduled for removal; the API may change in any release. */ export type Unstable_MentionCategory = { readonly id: string; readonly label: string; readonly items: readonly Unstable_Mention[]; }; /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */ export type Unstable_ModelContextToolsOptions = { /** * Wrap tools in a dedicated category. Selects drill-down mode on its own * when `categories` is unset and `items` is omitted. */ readonly category?: { readonly id: string; readonly label: string }; /** Format tool name for display. */ readonly formatLabel?: (toolName: string) => string; /** Default icon key for each tool. */ readonly icon?: string; }; /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */ export type Unstable_UseMentionAdapterOptions = { /** * Flat mention list. Ignored when `categories` is set, and keeps the * adapter flat when a tool `category` is configured. */ readonly items?: readonly Unstable_Mention[]; /** Categorized mentions for drill-down navigation. */ readonly categories?: readonly Unstable_MentionCategory[]; /** * How tools registered in model context integrate. * - `false`: exclude. * - `true`: include (default when no `items`/`categories`; as a category * if `categories` is set, flat otherwise). * - object: explicit config; `category` also selects drill-down mode. * * Omitted → defaults to `true` iff neither `items` nor `categories`. */ readonly includeModelContextTools?: | boolean | Unstable_ModelContextToolsOptions; /** Directive formatter. @default unstable_defaultDirectiveFormatter */ readonly formatter?: Unstable_DirectiveFormatter; /** Fires after an item is inserted into the composer. */ readonly onInserted?: (item: Unstable_TriggerItem) => void; /** Maps `metadata.icon` / `category.id` string keys to React components. */ readonly iconMap?: Record<string, Unstable_IconComponent>; /** Fallback icon when no entry in `iconMap` matches. */ readonly fallbackIcon?: Unstable_IconComponent; }; /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */ export type Unstable_MentionDirective = { readonly formatter: Unstable_DirectiveFormatter; readonly onInserted?: ((item: Unstable_TriggerItem) => void) | undefined; }; const EMPTY_TOOL_MENTIONS: Readonly<Record<string, string | undefined>> = Object.freeze({}); // The description is copied out rather than reached through the tool, so a // provider that returns a stable tool object and edits it in place is observed. const toolMentionSource: ModelContextSnapshotSource< Readonly<Record<string, string | undefined>> > = { empty: EMPTY_TOOL_MENTIONS, read: (aui) => { const tools = aui.thread.getModelContext().tools; if (!tools) return EMPTY_TOOL_MENTIONS; const mentions = Object.create(null) as Record<string, string | undefined>; for (const [name, tool] of Object.entries(tools)) { mentions[name] = tool.description; } return mentions; }, subscribe: (aui, onChange) => { const unsubscribeContext = aui.on("thread.modelContextUpdate", onChange); // Rebinding the thread event subject to a new thread does not replay it, // so a switch between threads carrying different providers is its own // refresh trigger. Subscribed globally because a composer can render // without a thread list scope. const unsubscribeSelection = aui.on( { scope: "*", event: "threads.selectionChanged" }, onChange, ); return () => { unsubscribeContext(); unsubscribeSelection(); }; }, isEqual: shallowEqualRecords, }; /** * Creates a spreadable `{ adapter, directive }` bundle for `@` mentions. * Supports tools registered in model context, explicit items, or both — * flat or categorized. * * @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. * * @example * ```tsx * const mention = unstable_useMentionAdapter(); * <ComposerTriggerPopover char="@" {...mention} /> * ``` */ export function unstable_useMentionAdapter( options?: Unstable_UseMentionAdapterOptions, ): { adapter: Unstable_TriggerAdapter; directive: Unstable_MentionDirective; iconMap?: Record<string, Unstable_IconComponent>; fallbackIcon?: Unstable_IconComponent; } { const aui = useAui(); const items = options?.items; const categories = options?.categories; const includeTools = options?.includeModelContextTools ?? (!items && !categories); const toolsConfig = typeof includeTools === "object" ? includeTools : undefined; const wantsTools = includeTools !== false; const formatter = options?.formatter; const onInserted = options?.onInserted; const isCategorized = categories !== undefined || (toolsConfig?.category !== undefined && items === undefined); const toolMentions = useModelContextSnapshot( aui, wantsTools, toolMentionSource, ); const adapter = useMemo<Unstable_TriggerAdapter>(() => { const formatLabel = toolsConfig?.formatLabel; const defaultIcon = toolsConfig?.icon; const toolItems = wantsTools ? Object.entries(toolMentions).map(([name, description]) => toTriggerItem({ id: name, type: "tool", label: formatLabel ? formatLabel(name) : name, description, icon: defaultIcon, }), ) : []; // Categorized: drill-down mode if (isCategorized) { const groups = (categories ?? []).map((cat) => ({ id: cat.id, label: cat.label, items: cat.items.map(toTriggerItem), })); const allGroups = toolItems.length > 0 ? [ ...groups, { id: toolsConfig?.category?.id ?? "tools", label: toolsConfig?.category?.label ?? "Tools", items: toolItems, }, ] : groups; return { categories: () => allGroups.map(({ id, label }) => ({ id, label })), categoryItems: (id) => allGroups.find((g) => g.id === id)?.items ?? [], search: (query) => { const lower = query.toLowerCase(); return allGroups .flatMap((g) => g.items) .filter((item) => matchesTriggerItemQuery(item, lower)); }, }; } // Flat: items + (optionally) tools, all in one search pool const flatItems = (items ?? []).map(toTriggerItem); // Dedupe by id — explicit items win. const seen = new Set(flatItems.map((i) => i.id)); const flatPool = [ ...flatItems, ...toolItems.filter((t) => !seen.has(t.id)), ]; return { categories: (): readonly Unstable_TriggerCategory[] => [], categoryItems: () => [], search: (query) => { const lower = query.toLowerCase(); return flatPool.filter((item) => matchesTriggerItemQuery(item, lower)); }, }; }, [items, categories, wantsTools, toolsConfig, isCategorized, toolMentions]); const directive = useMemo<Unstable_MentionDirective>( () => ({ formatter: formatter ?? unstable_defaultDirectiveFormatter, ...(onInserted ? { onInserted } : {}), }), [formatter, onInserted], ); return { adapter, directive, ...(options?.iconMap ? { iconMap: options.iconMap } : {}), ...(options?.fallbackIcon ? { fallbackIcon: options.fallbackIcon } : {}), }; } function toTriggerItem(m: Unstable_Mention): Unstable_TriggerItem { const metadata = m.icon !== undefined ? { ...(m.metadata ?? {}), icon: m.icon } : m.metadata; return { id: m.id, type: m.type, label: m.label, ...(m.description !== undefined ? { description: m.description } : {}), ...(metadata !== undefined ? { metadata } : {}), }; }