@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
264 lines (244 loc) • 9.36 kB
text/typescript
"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 } : {}),
};
}