diff --git a/packages/@react-spectrum/ai/src/AIButton.tsx b/packages/@react-spectrum/ai/src/AIButton.tsx index 234c51316d3..242b11df4ab 100644 --- a/packages/@react-spectrum/ai/src/AIButton.tsx +++ b/packages/@react-spectrum/ai/src/AIButton.tsx @@ -236,6 +236,9 @@ const bg = css(` } `); +/** + * An AIButton triggers an AI-powered action with a customizable branded appearance. + */ export function AIButton({size = 'M', brandColor, children, ...otherProps}: AIButtonProps) { let ref = useRef(null); return ( diff --git a/packages/@react-spectrum/ai/src/Alert.tsx b/packages/@react-spectrum/ai/src/Alert.tsx index 97e2e979d25..9ffd170b933 100644 --- a/packages/@react-spectrum/ai/src/Alert.tsx +++ b/packages/@react-spectrum/ai/src/Alert.tsx @@ -79,6 +79,9 @@ const text = style({ minWidth: 0 }); +/** + * An Alert shows an error message within a Chat thread or PromptField. + */ export const Alert = forwardRef(function Alert(props: AlertProps, ref: DOMRef) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let {children, variant = 'neutral', onDismiss, styles} = props; diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index f66edf52c29..d19efd7ed33 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -320,6 +320,7 @@ const flexRow = { const tagListStyles = style({ ...flexRow, + flexGrow: 1, gap: 8, overflowX: 'auto', overflowY: 'clip', @@ -439,6 +440,9 @@ function CarouselNavButton({side, ...otherProps}: ButtonProps & {side: 'start' | // oxlint-enable react/react-compiler } +/** + * An AttachmentList displays removable file attachments with previews and upload states. + */ export const AttachmentList = (forwardRef as forwardRefType)(function AttachmentList( props: AttachmentListProps, ref: DOMRef @@ -602,6 +606,9 @@ function AttachmentCard({ ); } +/** + * Attachment displays an individual file attachment within a PromptFieldAttachmentList. + */ export const Attachment = forwardRef(function Attachment( props: AttachmentProps, ref: DOMRef @@ -666,6 +673,9 @@ export interface AttachmentPreviewProps extends ImageProps { mimeType: string; } +/** + * AttachmentPreview renders a preview of a file attachment. + */ export function AttachmentPreview(props: AttachmentPreviewProps) { let {mimeType, ...otherProps} = props; let {isInvalid, uploadProgress, size} = useContext(AttachmentPreviewContext)!; diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 3789d006628..869d8b15549 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -120,6 +120,9 @@ export interface ChatProps { children?: ReactNode; } +/** + * A Chat displays an accessible, streaming conversation between a user and an AI. + */ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( props: ChatProps, ref: DOMRef @@ -249,6 +252,9 @@ export interface ThreadProps extends Pick< scrollEndThreshold?: number; } +/** + * A Thread shows a conversation within a Chat. + */ export function Thread(props: ThreadProps) { let { items, @@ -324,6 +330,9 @@ export interface ThreadScrollButtonProps { // TODO: wrapper so we can do the "if isNearBottom then hide" logic, could do this via inline styles perhaps // and ditch the wrapper? +/** + * A ThreadScrollButton displays a button to scroll to the bottom of a Chat thread. + */ export function ThreadScrollButton({children}: ThreadScrollButtonProps) { let {isNearBottom, scrollToBottom, ...buttonProps} = useContext(ThreadScrollButtonContext); let ref = useRef(null); @@ -378,6 +387,9 @@ export interface ThreadItemProps extends Pick< shouldAnnounceOnMount?: boolean; } +/** + * A ThreadItem displays an individual chat message. + */ export function ThreadItem(props: ThreadItemProps) { let { styles, @@ -429,6 +441,9 @@ export interface ThreadLoadMoreItemProps extends GridListLoadMoreItemProps {} // TODO: Reuse GridListLoadMoreItem instead when Thread component moves into RAC. // Re-implementing here so we can avoid passing 'direction' to the LoadMore item +/** + * A ThreadLoadMoreItem loads more chat messages when it is scrolled into the viewport. + */ export const ThreadLoadMoreItem = createLeafComponent( LoaderNode, function GridListLoadingIndicator( diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 2211996278f..4fee489e00f 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -98,6 +98,7 @@ export interface PromptFieldProps { onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; onAITermsPress?: () => void; styles?: StyleString; + /** @default 'balanced' */ variant?: 'balanced' | 'prominent' | 'subtle'; brandColor?: string; /** @@ -256,6 +257,10 @@ function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { }); } +/** + * A PromptField allows users to compose and submit prompts containing text, tokens, and + * attachments. + */ export const PromptField = forwardRef(function PromptField( props: PromptFieldProps, ref: FocusableRef @@ -389,6 +394,9 @@ export interface PromptFieldAttachmentListProps extends AttachmentListProps React.ReactNode; } +/** + * PromptFieldAttachmentList displays a list of file attachments within a PromptField. + */ export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) { let {children} = props; let {attachments, setAttachments, onRemoveAttachments, inputRef} = useContext(PromptFieldContext); @@ -436,6 +444,10 @@ export interface PromptTokenFieldProps { menuWidth?: number; } +/** + * PromptTokenField renders an editable text input for a prompt, and supports inserting inline + * object references as tokens via autocomplete. + */ export function PromptTokenField(props: PromptTokenFieldProps) { let { completionTrigger, @@ -792,6 +804,9 @@ export interface PromptTokenProps extends Omit void; } +/** + * PromptFieldVoiceButton triggers voice input for the PromptField. + */ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { let {lang: langProp, isDisabled: isDisabledProp, onError, onToggle} = props; let {locale} = useLocale(); @@ -1008,6 +1030,9 @@ export interface InsertMenuItemProps extends Pick {} +/** + * AttachFileMenuItem triggers a system file dialog to attach files within an InsertMenuButton. + */ export function AttachFileMenuItem(props: AttachFileMenuItemProps) { let {onAction, ...otherProps} = props; let {acceptedAttachmentTypes, setAttachments, onAddAttachments} = useContext(PromptFieldContext); @@ -1144,6 +1172,10 @@ export interface InsertTokenMenuItemProps extends Omit< token: TokenSegment; } +/** + * InsertTokenMenuItem inserts a token (i.e. object reference) into the PromptField within an + * InsertMenuButton. + */ export function InsertTokenMenuItem(props: InsertTokenMenuItemProps) { let insert = useInsertPromptSegment([props.token]); @@ -1175,6 +1207,9 @@ export interface InsertTextMenuItemProps extends Omit< text: string; } +/** + * InsertTextMenuItem inserts plain text into the PromptField from within an InsertMenuButton. + */ export function InsertTextMenuItem(props: InsertTextMenuItemProps) { let insert = useInsertPromptSegment([{type: 'text', text: props.text}]); @@ -1205,6 +1240,9 @@ export interface CommandMenuItemProps extends Omit< // specifically for menu items that only trigger a callback in the autocomplete menu // since they dont end up inserting a token or text, we need to clear the partial text that the user used // to filter the menu +/** + * CommandMenuItem performs an immediate action from within an InsertMenuButton. + */ export function CommandMenuItem(props: CommandMenuItemProps) { let insert = useInsertPromptSegment([]); return ( diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index bbcae6bd957..bf9eeb7c8ee 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -589,7 +589,7 @@ const executionTraceDetailStyle = style({ // focus ring needs to be here instead of child detail div since the fade fades the focus ring ...focusRing(), outlineOffset: -2, - backgroundColor: 'layer-1', + backgroundColor: 'transparent-overlay-50', borderRadius: 'lg', font: 'body-2xs', color: 'gray-600' diff --git a/packages/@react-spectrum/ai/src/UserMessage.tsx b/packages/@react-spectrum/ai/src/UserMessage.tsx index 29b5d91677b..533df340ea8 100644 --- a/packages/@react-spectrum/ai/src/UserMessage.tsx +++ b/packages/@react-spectrum/ai/src/UserMessage.tsx @@ -54,7 +54,7 @@ const bubble = style({ default: 16, ':has(img)': 8 }, - backgroundColor: 'gray-50', + backgroundColor: 'transparent-overlay-50', color: 'neutral', borderRadius: 'lg', font: 'body', diff --git a/packages/@react-spectrum/ai/src/loader/react.tsx b/packages/@react-spectrum/ai/src/loader/react.tsx index 6dcdebfc562..fb8f2895e75 100644 --- a/packages/@react-spectrum/ai/src/loader/react.tsx +++ b/packages/@react-spectrum/ai/src/loader/react.tsx @@ -295,7 +295,7 @@ export function PixelLoader(props: PixelLoaderProps) { } return matrix; }, [cells]); - const isHighDPI = window.devicePixelRatio >= 2; + const isHighDPI = (typeof document !== 'undefined' && window.devicePixelRatio >= 2) ?? false; return (
void) | null; - onsoundstart: ((this: SpeechRecognition, ev: Event) => void) | null; - onspeechstart: ((this: SpeechRecognition, ev: Event) => void) | null; - onspeechend: ((this: SpeechRecognition, ev: Event) => void) | null; - onsoundend: ((this: SpeechRecognition, ev: Event) => void) | null; - onaudioend: ((this: SpeechRecognition, ev: Event) => void) | null; - onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; - onnomatch: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; - onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null; - onstart: ((this: SpeechRecognition, ev: Event) => void) | null; - onend: ((this: SpeechRecognition, ev: Event) => void) | null; -} + interface SpeechRecognitionErrorEvent extends Event { + readonly error: SpeechRecognitionErrorCode; + readonly message: string; + } -interface SpeechRecognitionConstructor { - new (): SpeechRecognition; -} + interface SpeechRecognition extends EventTarget { + lang: string; + continuous: boolean; + interimResults: boolean; + maxAlternatives: number; + start(): void; + stop(): void; + abort(): void; + onaudiostart: ((this: SpeechRecognition, ev: Event) => void) | null; + onsoundstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onspeechstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onspeechend: ((this: SpeechRecognition, ev: Event) => void) | null; + onsoundend: ((this: SpeechRecognition, ev: Event) => void) | null; + onaudioend: ((this: SpeechRecognition, ev: Event) => void) | null; + onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; + onnomatch: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; + onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null; + onstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onend: ((this: SpeechRecognition, ev: Event) => void) | null; + } -interface Window { - SpeechRecognition?: SpeechRecognitionConstructor; - webkitSpeechRecognition?: SpeechRecognitionConstructor; -} + interface SpeechRecognitionConstructor { + new (): SpeechRecognition; + } -// User-Agent Client Hints — present in Chromium, absent in Safari/Firefox. -interface NavigatorUABrandVersion { - readonly brand: string; - readonly version: string; -} + interface Window { + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; + } -interface NavigatorUAData { - readonly brands: ReadonlyArray; - readonly platform?: string; -} + // User-Agent Client Hints — present in Chromium, absent in Safari/Firefox. + interface NavigatorUABrandVersion { + readonly brand: string; + readonly version: string; + } + + interface NavigatorUAData { + readonly brands: ReadonlyArray; + readonly platform?: string; + } -interface Navigator { - readonly userAgentData?: NavigatorUAData; + interface Navigator { + readonly userAgentData?: NavigatorUAData; + } } diff --git a/packages/@react-spectrum/ai/src/useVoiceInput.ts b/packages/@react-spectrum/ai/src/useVoiceInput.ts index 3030bcaa8c7..6ac69484ba2 100644 --- a/packages/@react-spectrum/ai/src/useVoiceInput.ts +++ b/packages/@react-spectrum/ai/src/useVoiceInput.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +/// + import { Dispatch, RefObject, diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index c1c835e28af..b7b695e3d0c 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -20,9 +20,9 @@ import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import {Collection} from 'react-aria-components'; import {Content} from '@react-spectrum/s2/Content'; import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; -import {Image} from '@react-spectrum/s2/Image'; -import {MenuItem} from '@react-spectrum/s2/Menu'; import { + ExecutionTrace, + ExecutionTraceItem, MessageFeedback, MessageSource, MessageSuggestion, @@ -43,6 +43,8 @@ import { TokenFieldValue, UserMessage } from '@react-spectrum/ai'; +import {Image} from '@react-spectrum/s2/Image'; +import {MenuItem} from '@react-spectrum/s2/Menu'; import type {Meta} from '@storybook/react'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; import {prose} from '../src/style/prose' with {type: 'macro'}; @@ -147,15 +149,21 @@ let initialResponses = [ } ] as Message[]; +interface ExecutionStep { + id: number; + label: string; + status: 'pending' | 'success'; + detail?: string; +} + type StreamingMessage = | {id: number; type: 'user'; content: string} | {id: number; type: 'system'; content: string; isStreaming?: boolean; sources?: string[]} | { id: number; type: 'status'; - label: string; - isStreaming: boolean; - details: string; + status: 'pending' | 'success'; + steps: ExecutionStep[]; } | {id: number; type: 'card'; title: string; description: string; imageUrl: string} | {id: number; type: 'suggestions'; title: string; suggestions: string[]}; @@ -208,6 +216,44 @@ function CardMessage({ ); } +function StatusThreadItem({msg}: {msg: Extract}) { + let isStreaming = msg.status === 'pending'; + let lastStep = msg.steps[msg.steps.length - 1]; + let title = isStreaming + ? `${lastStep.label}…` + : msg.steps.length > 1 + ? `Completed ${msg.steps.length} steps` + : lastStep.label; + let announcement = isStreaming ? `${lastStep.label}…` : `${title} complete`; + // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? + // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we + // probably want to make that on a case by case basis + // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) + return ( + + + {title} + + + {msg.steps.map(step => ( + {step.detail}

+ ) + }> + {step.label} +
+ ))} +
+
+
+
+ ); +} + export function VirtualizedStreamingChat() { let [messages, setMessages] = useState( initialResponses as StreamingMessage[] @@ -226,38 +272,67 @@ export function VirtualizedStreamingChat() { {id: nextId.current++, type: 'user', content: prompt.toString()} ]); - function addTool(label: string, replaceStatus = false) { - setMessages(prev => - replaceStatus - ? [ - ...prev.slice(0, -1), - { - id: nextId.current++, - type: 'status', - label, - isStreaming: true, - details: '' - } - ] - : [ - ...prev, - { - id: nextId.current++, - type: 'status', - label, - isStreaming: true, - details: '' - } - ] - ); + // Starts a new grouped status message containing a single pending execution trace step. + function startToolGroup(label: string) { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + status: 'pending', + steps: [{id: nextId.current++, label, status: 'pending'}] + } + ]); } - function completeTool(details: string) { - setMessages(prev => - prev.map(m => - m.type === 'status' && m.isStreaming ? {...m, isStreaming: false, details} : m - ) - ); + // Adds a new step to the trailing status group if one is still open, otherwise starts a new group. + function addStep(label: string) { + setMessages(prev => { + let last = prev[prev.length - 1]; + let newStep: ExecutionStep = {id: nextId.current++, label, status: 'pending'}; + if (last?.type === 'status' && last.status === 'pending') { + return [ + ...prev.slice(0, -1), + { + ...last, + steps: [ + ...last.steps.slice(0, -1), + {...last.steps[last.steps.length - 1], status: 'success'}, + newStep + ] + } + ]; + } + return [ + ...prev, + {id: nextId.current++, type: 'status', status: 'pending', steps: [newStep]} + ]; + }); + } + + // Completes the last step of the trailing status group, optionally updating its label. + function completeStep(detail: string, label?: string) { + setMessages(prev => { + let last = prev[prev.length - 1]; + if (last?.type !== 'status') { + return prev; + } + let steps = last.steps.slice(); + let step = steps[steps.length - 1]; + steps[steps.length - 1] = {...step, label: label ?? step.label, status: 'success', detail}; + return [...prev.slice(0, -1), {...last, steps}]; + }); + } + + // Marks the trailing status group as complete once all of its steps have finished. + function completeGroup() { + setMessages(prev => { + let last = prev[prev.length - 1]; + if (last?.type !== 'status') { + return prev; + } + return [...prev.slice(0, -1), {...last, status: 'success'}]; + }); } function streamText(content: string, sources?: string[]) { @@ -299,39 +374,25 @@ export function VirtualizedStreamingChat() { let timestamp = 0; let toolCallDuration = 1000; // Status added after short delay so user message announcement plays first - addTimeout( - () => { - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'status', - label: 'Generating response', - isStreaming: true, - details: '' - } - ]); - }, - (timestamp += 500) - ); - addTimeout(() => addTool('Thinking', true), (timestamp += 500)); + addTimeout(() => startToolGroup('Thinking'), (timestamp += 500)); addTimeout( () => - completeTool( + completeStep( 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' ), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Loading tool'), (timestamp += 500)); + addTimeout(() => addStep('Loading tool'), (timestamp += 500)); addTimeout( - () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), + () => completeStep('Asset search tool loaded with access to the Hilton brand library.'), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Searching'), (timestamp += 500)); + addTimeout(() => addStep('Searching'), (timestamp += 500)); addTimeout( - () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), + () => completeStep('Found 15 assets matching the brand criteria across 3 campaigns.'), (timestamp += toolCallDuration) ); + addTimeout(() => completeGroup(), (timestamp += 200)); addTimeout( () => streamText( @@ -341,49 +402,30 @@ export function VirtualizedStreamingChat() { ); // then does searching, streaming more text, returning a card and sources - addTimeout(() => addTool('Searching'), (timestamp += 1000)); + addTimeout(() => startToolGroup('Searching'), (timestamp += 1000)); addTimeout( () => - completeTool('Identified additional brand materials related to the presentation context.'), + completeStep('Identified additional brand materials related to the presentation context.'), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Querying database'), (timestamp += 1000)); + addTimeout(() => addStep('Querying database'), (timestamp += 1000)); addTimeout( () => - completeTool( + completeStep( 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' ), (timestamp += toolCallDuration) ); + addTimeout(() => addStep('Generating response'), (timestamp += 500)); addTimeout( () => - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'status', - label: 'Generating response', - isStreaming: true, - details: '' - } - ]), - (timestamp += 500) - ); - addTimeout( - () => - setMessages(prev => [ - ...prev.slice(0, -1), - { - id: nextId.current++, - type: 'status', - label: 'Response generated', - isStreaming: false, - details: - 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.' - } - ]), + completeStep( + 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.', + 'Response generated' + ), (timestamp += 1000) ); + addTimeout(() => completeGroup(), (timestamp += 200)); let secondStreamContent = 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + 'layering in supporting visuals and data to reinforce the core message. The main themes ' + @@ -422,11 +464,19 @@ export function VirtualizedStreamingChat() { timeouts.current.forEach(clearTimeout); timeouts.current = []; setMessages(prev => - prev.map(m => - (m.type === 'system' || m.type === 'status') && m.isStreaming - ? {...m, isStreaming: false} - : m - ) + prev.map(m => { + if (m.type === 'system' && m.isStreaming) { + return {...m, isStreaming: false}; + } + if (m.type === 'status' && m.status === 'pending') { + return { + ...m, + status: 'success', + steps: m.steps.map((s, i) => (i === m.steps.length - 1 ? {...s, status: 'success'} : s)) + }; + } + return m; + }) ); setGenerating(false); } @@ -499,27 +549,7 @@ export function VirtualizedStreamingChat() { ); } if (msg.type === 'status') { - let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; - let title = msg.isStreaming ? `${msg.label}…` : msg.label; - // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? - // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we - // probably want to make that on a case by case basis - // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) - return ( - - - {title} - - {msg.details && ( -

{msg.details}

- )} -
-
-
- ); + return ; } if (msg.type === 'card') { return ( @@ -727,23 +757,7 @@ export function EmptyChat() { ); } if (msg.type === 'status') { - let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; - let title = msg.isStreaming ? `${msg.label}…` : msg.label; - return ( - - - {title} - - {msg.details && ( -

{msg.details}

- )} -
-
-
- ); + return ; } if (msg.type === 'card') { return ( diff --git a/packages/dev/s2-docs/assets/component-illustrations/dark/AIComponents.avif b/packages/dev/s2-docs/assets/component-illustrations/dark/AIComponents.avif new file mode 100644 index 00000000000..6a3e6158f0e Binary files /dev/null and b/packages/dev/s2-docs/assets/component-illustrations/dark/AIComponents.avif differ diff --git a/packages/dev/s2-docs/assets/component-illustrations/dark/WorkingWithAI.avif b/packages/dev/s2-docs/assets/component-illustrations/dark/WorkingWithAI.avif deleted file mode 100644 index c4aaac62105..00000000000 Binary files a/packages/dev/s2-docs/assets/component-illustrations/dark/WorkingWithAI.avif and /dev/null differ diff --git a/packages/dev/s2-docs/assets/component-illustrations/light/AIComponents.avif b/packages/dev/s2-docs/assets/component-illustrations/light/AIComponents.avif new file mode 100644 index 00000000000..7e6282be204 Binary files /dev/null and b/packages/dev/s2-docs/assets/component-illustrations/light/AIComponents.avif differ diff --git a/packages/dev/s2-docs/assets/component-illustrations/light/WorkingWithAI.avif b/packages/dev/s2-docs/assets/component-illustrations/light/WorkingWithAI.avif deleted file mode 100644 index 2b70a63514f..00000000000 Binary files a/packages/dev/s2-docs/assets/component-illustrations/light/WorkingWithAI.avif and /dev/null differ diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx new file mode 100644 index 00000000000..a4a8231f9da --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -0,0 +1,399 @@ +import {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; +import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; +import {getIcon} from './promptfield'; +import { + Chat, + ExecutionTrace, + ExecutionTraceItem, + MessageFeedback, + MessageSuggestion, + MessageSuggestionList, + ResponseStatus, + ResponseStatusPanel, + ResponseStatusTitle, + Thread, + ThreadItem, + ThreadScrollButton, + TokenFieldValue, + UserMessage +} from '@react-spectrum/ai'; +import * as loaders from '@react-spectrum/ai/loader'; +import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; +import {ReactNode, useEffect, useMemo, useRef, useState} from 'react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +let initialResponses = [ + { + id: 0, + type: 'user', + content: 'Can you help me plan a short trip to the mountains this weekend?' + }, + { + id: 1, + type: 'system', + content: 'Sure! How many days do you have, and do you prefer hiking, skiing, or just relaxing?' + }, + { + id: 2, + type: 'user', + content: 'Two days, and I want a mix of hiking and relaxing.' + }, + { + id: 3, + type: 'system', + content: + 'Day one: a moderate morning hike with a scenic overlook, then lunch in town. Day two: a slow start, a short walk, and time to unwind before heading back.' + } +]; + +interface ExecutionStep { + id: number; + label: string; + status: 'pending' | 'success'; + detail?: string; +} + +type StreamingMessage = + | {id: number | string; type: 'user'; content: string} + | {id: number | string; type: 'system'; content: string; isStreaming?: boolean} + | { + id: number | string; + type: 'status'; + status: 'pending' | 'success'; + steps: ExecutionStep[]; + } + | {id: number | string; type: 'suggestions'; suggestions: TokenFieldValue[]}; + +export interface VirtualizedStreamingChatProps { + children: (onSend: (prompt: TokenFieldValue) => void, isGenerating: boolean) => ReactNode; + /** Suggestions shown at the end of the thread. Hidden while a response is streaming in. */ + suggestions?: TokenFieldValue[]; + onSelectSuggestion?: (suggestion: TokenFieldValue) => void; +} + +export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) { + let {children, suggestions, onSelectSuggestion} = props; + let [messages, setMessages] = useState( + initialResponses as StreamingMessage[] + ); + let nextId = useRef(initialResponses.length); + let [isGenerating, setGenerating] = useState(false); + let timeouts = useRef([]); + + function handleSend(prompt: TokenFieldValue) { + setGenerating(true); + // user message added first so its announcement plays before the status updates + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'user', content: prompt.toString()} + ]); + + // Starts a new grouped status message containing a single pending execution trace step. + function startToolGroup(label: string) { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + status: 'pending', + steps: [{id: nextId.current++, label, status: 'pending'}] + } + ]); + } + + // Adds a new step to the trailing status group if one is still open, otherwise starts a new group. + function addStep(label: string) { + setMessages(prev => { + let last = prev[prev.length - 1]; + let newStep: ExecutionStep = {id: nextId.current++, label, status: 'pending'}; + if (last?.type === 'status' && last.status === 'pending') { + return [ + ...prev.slice(0, -1), + { + ...last, + steps: [ + ...last.steps.slice(0, -1), + {...last.steps[last.steps.length - 1], status: 'success'}, + newStep + ] + } + ]; + } + return [ + ...prev, + {id: nextId.current++, type: 'status', status: 'pending', steps: [newStep]} + ]; + }); + } + + // Completes the last step of the trailing status group. + function completeStep(detail: string) { + setMessages(prev => { + let last = prev[prev.length - 1]; + if (last?.type !== 'status') { + return prev; + } + let steps = last.steps.slice(); + let step = steps[steps.length - 1]; + steps[steps.length - 1] = {...step, status: 'success', detail}; + return [...prev.slice(0, -1), {...last, steps}]; + }); + } + + // Marks the trailing status group as complete once all of its steps have finished. + function completeGroup() { + setMessages(prev => { + let last = prev[prev.length - 1]; + if (last?.type !== 'status') { + return prev; + } + return [...prev.slice(0, -1), {...last, status: 'success'}]; + }); + } + + function streamText(content: string) { + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'system', content: '', isStreaming: true} + ]); + let tokens = content.split(' '); + let accumulated = ''; + tokens.forEach((token, i) => { + setTimeout(() => { + accumulated += (i === 0 ? '' : ' ') + token; + let isLastToken = i === tokens.length - 1; + setMessages(prev => + prev.map(m => + m.type === 'system' && m.isStreaming + ? {...m, content: accumulated, isStreaming: !isLastToken} + : m + ) + ); + }, i * 80); + }); + } + + let addTimeout = (callback: () => void, delay: number) => { + let timeout = setTimeout(callback, delay); + timeouts.current.push(timeout); + return timeout; + }; + + let timestamp = 0; + let toolCallDuration = 1000; + // Status added after a short delay so the user message announcement plays first. + addTimeout(() => startToolGroup('Searching Yelp'), (timestamp += 500)); + addTimeout( + () => completeStep('Found 12 restaurants near the trailhead.'), + (timestamp += toolCallDuration) + ); + addTimeout(() => addStep('Searching Google Maps'), (timestamp += 500)); + addTimeout( + () => completeStep('Compared ratings and walking distances.'), + (timestamp += toolCallDuration) + ); + addTimeout(() => addStep('Searching TripAdvisor'), (timestamp += 500)); + addTimeout( + () => completeStep('Checked recent reviews for the top matches.'), + (timestamp += toolCallDuration) + ); + addTimeout(() => addStep('Filtering by distance'), (timestamp += 500)); + addTimeout( + () => completeStep('Narrowed the list down to places within range.'), + (timestamp += toolCallDuration) + ); + addTimeout(() => completeGroup(), (timestamp += 200)); + + let replyContent = + 'A few good options within range: Trailhead Café for a quick bite, Base Camp Diner for ' + + 'something heartier, and Riverside Grill if you want a sit-down meal.'; + addTimeout(() => streamText(replyContent), (timestamp += 500)); + + let streamEndTimestamp = timestamp + (replyContent.split(' ').length - 1) * 80 + 500; + addTimeout(() => setGenerating(false), streamEndTimestamp); + } + + useEffect( + () => () => { + timeouts.current.forEach(clearTimeout); + }, + [] + ); + + let items = useMemo(() => { + if (isGenerating || !suggestions) { + return messages; + } + return [...messages, {id: 'suggestions', type: 'suggestions', suggestions}]; + }, [messages, isGenerating, suggestions]); + + return ( +
+ +
+
+ + + + + +
+ + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + return ; + } + if (msg.type === 'suggestions') { + return ( + + + {msg.suggestions.map((s, i) => ( + onSelectSuggestion?.(s)}> + + + ))} + + + ); + } + return ( + +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
+ ); + }} +
+
+ {children(handleSend, isGenerating)} +
+
+ ); +} + +function StatusThreadItem({msg}: {msg: Extract}) { + let isStreaming = msg.status === 'pending'; + let lastStep = msg.steps[msg.steps.length - 1]; + let title = isStreaming + ? `${lastStep.label}…` + : msg.steps.length > 1 + ? `Completed ${msg.steps.length} steps` + : lastStep.label; + let announcement = isStreaming ? `${lastStep.label}…` : `${title} complete`; + return ( + + + {title} + + + {msg.steps.map(step => ( + {step.detail}

+ ) + }> + {step.label} +
+ ))} +
+
+
+
+ ); +} + +let suggestionToken = style({ + outlineStyle: { + default: 'solid', + isPlaceholder: 'dashed' + }, + outlineWidth: 1, + outlineColor: { + default: 'transparent-overlay-1000/20', + isPlaceholder: 'transparent-overlay-1000/40' + }, + outlineOffset: -1, + borderRadius: 'pill', + paddingX: 8, + paddingY: 0, + fontSize: 'ui', + display: 'inline-flex', + alignItems: 'baseline', + gap: 4, + verticalAlign: 'baseline' +}); + +function SuggestionLabel({value}: {value: TokenFieldValue}) { + return ( + <> + {value.segments.map((seg, i) => + seg.type === 'token' ? ( + + {getIcon(seg) && {getIcon(seg)}} + {seg.text} + + ) : ( + seg.text + ) + )} + + ); +} diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx new file mode 100644 index 00000000000..9937756d0bf --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx @@ -0,0 +1,163 @@ +import { + CommandMenuItem, + InsertTokenMenuItem, + PromptFieldTokenValue, + PromptFieldValue +} from '@react-spectrum/ai'; +import FileText from '@react-spectrum/s2/icons/FileText'; +import {Header, Heading, MenuSection, Text} from '@react-spectrum/s2/Menu'; +import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; +import LinkIcon from '@react-spectrum/s2/icons/Link'; +import Plugin from '@react-spectrum/s2/icons/Plugin'; +import Project from '@react-spectrum/s2/icons/Project'; +import Prompt from '@react-spectrum/s2/icons/Prompt'; +import {TokenSegment} from 'react-stately'; +import UserGroup from '@react-spectrum/s2/icons/UserGroup'; + +export const slashCommands = [ + {command: '/clear', kind: 'command', description: 'Clear the conversation'}, + {command: '/compact', kind: 'command', description: 'Summarize the conversation so far'}, + {command: '/docx', kind: 'skill', description: 'Create or edit a Word document'}, + {command: '/pptx', kind: 'skill', description: 'Create a slide deck'} +]; + +const icons = { + command: , + skill: , + person: , + document: , + project: , + url: +} as const; + +export function getIcon(token: TokenSegment) { + switch (token.value?.type) { + case 'placeholder': + return token.value.placeholderType === 'token' && token.value.valueType + ? icons[token.value.valueType] + : null; + case 'url': + return icons.url; + case 'custom': + return icons[token.value.valueType]; + } +} + +export const objects = [ + { + section: 'People', + type: 'person', + items: [ + {kind: 'person', title: 'Alex Rivera'}, + {kind: 'person', title: 'Jamie Chen'}, + {kind: 'person', title: 'Morgan Taylor'} + ] + }, + { + section: 'Documents', + type: 'document', + items: [ + {kind: 'document', title: 'Project plan'}, + {kind: 'document', title: 'Meeting notes'}, + {kind: 'document', title: 'Research summary'} + ] + }, + { + section: 'Projects', + type: 'project', + items: [ + {kind: 'project', title: 'Website redesign'}, + {kind: 'project', title: 'Mobile app launch'} + ] + } +]; + +interface CompletionCallbacks { + valueType?: string | null; + onClear?: () => void; + onCompact?: () => void; +} + +export function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) { + if (filterValue.startsWith('/')) { + return slashCommands + .filter( + item => + item.command.includes(filterValue.slice(1)) && + (callbacks?.valueType ? item.kind === callbacks.valueType : true) + ) + .map(item => + item.kind === 'command' ? ( + + + {item.command} + {item.description} + + ) : ( + + + {item.command} + {item.description} + + ) + ); + } else if (filterValue.startsWith('@')) { + return objects + .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) + .map(section => { + let matchingItems = section.items + .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) + .map(item => ( + + {item.title} + + )); + + if (matchingItems.length > 0) { + return ( + +
+ {section.section} +
+ {matchingItems} +
+ ); + } else { + return null; + } + }) + .filter(v => v != null); + } + return null; +} + +export interface UploadState { + status: 'uploading' | 'completed'; + progress?: number; +} + +export const suggestions = [ + new PromptFieldValue([{type: 'text', text: 'Summarize this conversation'}]), + new PromptFieldValue([ + {type: 'text', text: 'Suggest places to eat after the hike within '}, + {type: 'token', text: '#', value: {type: 'placeholder', placeholderType: 'text'}}, + {type: 'text', text: ' miles'} + ]) +]; diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx new file mode 100644 index 00000000000..4aa5489c515 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -0,0 +1,777 @@ +import {Layout} from '../../src/Layout'; +import {InstallCommand} from '../../src/InstallCommand'; +import {InlineAlert, Heading, Content} from '@react-spectrum/s2'; +import {VersionBadge} from '../../src/VersionBadge'; +export default Layout; + +import docs from 'docs:@react-spectrum/ai'; + +export const section = 'Guides'; +export const tags = ['spectrum', 'ai']; +export const description = 'Build AI-powered experiences with prompts, messages, suggestions, attachments, and voice input.'; +export const version = 'alpha'; + +# AI Components + +React Spectrum provides components for building AI-powered experiences, including prompts, messages, suggestions, attachments, and voice input. + +```tsx render type="s2" files={["packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx", "packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx"]} +"use client"; +import {useState, useRef} from 'react'; +import { + AttachFileMenuItem, + CommandMenuItem, + InsertTokenMenuItem, + InsertMenuButton, + PromptField, + Attachment, + AttachmentPreview, + PromptFieldAttachment, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptFieldVoiceButton, + PromptToken, + PromptTokenField +} from '@react-spectrum/ai'; +import {type FocusableRefValue} from '@react-types/shared'; +import {getIcon, slashCommands, objects, renderCompletions, suggestions, type UploadState} from './ai-component-helpers/promptfield'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Collection, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; +import Data from '@react-spectrum/s2/icons/Data'; +import Plugin from '@react-spectrum/s2/icons/Plugin'; +import Prompt from '@react-spectrum/s2/icons/Prompt'; +import {VirtualizedStreamingChat} from './ai-component-helpers/chat'; + +function Example() { + let [value, setValue] = useState(() => new PromptFieldValue([])); + let promptFieldRef = useRef>(null); + let [attachments, setAttachments] = useState([]); + let [attachmentState, setAttachmentState] = useState>(new Map()); + + let mockUpload = async (id: string) => { + await new Promise(resolve => setTimeout(resolve, Math.random() * 30)); + setAttachmentState(prev => { + let item = prev.get(id); + if (!item || item.status === 'completed') { + return prev; + } + let newState = new Map(prev); + let progress = (item.progress ?? 0) + 1; + if (progress >= 100) { + newState.set(id, {status: 'completed'}); + } else { + newState.set(id, {status: 'uploading', progress}); + mockUpload(id); + } + return newState; + }); + }; + + let clearPrompt = () => { + setValue(new PromptFieldValue([])); + setAttachments([]); + alert('Conversation cleared'); + }; + + let compactPrompt = () => { + alert('Conversation compacted'); + }; + + return ( +
+ {/*- begin focus -*/} + { + setValue(value as PromptFieldValue); + promptFieldRef.current?.focus(); + }}> + {/*- end focus -*/} + {(onSend, isGenerating) => ( + { + onSend(prompt); + setValue(new PromptFieldValue([])); + setAttachments([]); + setAttachmentState(new Map()); + }} + acceptedAttachmentTypes={['*/*']} + onAddAttachments={newAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + newAttachments.forEach(attachment => { + newState.set(attachment.id, {status: 'uploading', progress: 0}); + mockUpload(attachment.id); + }); + return newState; + }); + }} + onRemoveAttachments={removedAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + removedAttachments.forEach(attachment => { + newState.delete(attachment.id); + }); + return newState; + }); + }}> + + {attachment => { + let state = attachmentState.get(attachment.id); + return ( + + + + ); + }} + + { + return renderCompletions(filterValue, {valueType, onClear: clearPrompt, onCompact: compactPrompt}); + }}> + {token => ( + + {getIcon(token)} + {token.text} + + )} + + +
+ + + + + + Commands + + item.kind === 'command')}> + {item => ( + + + {item.command} + {item.description} + + )} + + + + + + Skills + + item.kind === 'skill')}> + {item => ( + + + {item.command} + {item.description} + + )} + + + + + + Reference an object + + + {item => ( + +
+ {item.section} +
+ + {item => ( + + {item.title} + + )} + +
+ )} +
+
+
+
+
+ + +
+
+
+ )} +
+
+ ); +} +``` + +## Installation + +AI components are published as a separate package from `@react-spectrum/s2`. + + + +## Prompt fields + +Use `PromptField` as the foundation for allowing the user to submit a prompt. It manages an editable sequence of text and tokens, while `PromptTokenField` renders the input and `PromptFieldToolbar` contains its actions. + +```tsx render wide type="s2" docs={docs.exports.PromptField} props={['variant', 'size', 'isGenerating']} +"use client"; +import {useState} from 'react'; +import { + PromptField, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptTokenField +} from '@react-spectrum/ai'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +function BasicPrompt(props) { + let [value, setValue] = useState(() => new PromptFieldValue([])); + + return ( +
+ {/*- begin highlight -*/} + { + console.log(value.toString()); + setValue(new PromptFieldValue([])); + }}> + {/*- end highlight -*/} + + +
+ +
+
+
+
+ ); +} +``` + +### Suggestions and tokens + +Suggestions can prefill a prompt, and a token field can offer context-aware completions such as mentions or commands. + +```tsx render type="s2" +"use client"; +import {useState} from 'react'; +import { + InsertTokenMenuItem, + MessageSuggestion, + MessageSuggestionList, + PromptField, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptTokenField +} from '@react-spectrum/ai'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +/*- begin collapse -*/ +let suggestionToken = style({ + outlineStyle: 'solid', + outlineWidth: 1, + outlineColor: 'transparent-overlay-1000/20', + outlineOffset: -1, + borderRadius: 'pill', + paddingX: 8, + paddingY: 0, + fontSize: 'ui', + display: 'inline-flex', + verticalAlign: 'baseline' +}); +/*- end collapse -*/ + +let people = ['Customers', 'Designers', 'Developers']; + +let suggestions = [ + new PromptFieldValue([{type: 'text', text: 'Summarize this report'}]), + new PromptFieldValue([ + {type: 'text', text: 'Draft a project brief for '}, + { + type: 'token', + text: 'Designers', + value: {type: 'custom', anchor: '@', valueType: 'person', data: 'Designers'} + } + ]), + new PromptFieldValue([{type: 'text', text: 'Find risks in this plan'}]) +]; + +function PromptSuggestions() { + let [value, setValue] = useState(() => new PromptFieldValue([])); + + return ( +
+ + {suggestions.map((suggestion, i) => ( + setValue(suggestion)}> + {suggestion.segments.map((segment, j) => + segment.type === 'token' ? ( + {segment.text} + ) : ( + segment.text + ) + )} + + ))} + + + + people + .filter(person => person.toLowerCase().includes(filterValue.slice(1).toLowerCase())) + .map(person => ( + + {person} + + )) + } + /*- end highlight -*/ + placeholder="Ask about @customers" /> + +
+ +
+
+
+
+ ); +} +``` + +### Attachments and actions + +Use `PromptFieldAttachmentList` to render a preview of attached files the user has dragged onto the PromptField or added via the `InsertMenuButton` in the toolbar. Custom menu items and toolbar controls can be added as well. + +```tsx render type="s2" +"use client"; +import {useState} from 'react'; +import { + Attachment, + AttachmentPreview, + AttachFileMenuItem, + InsertMenuButton, + InsertTextMenuItem, + PromptField, + PromptFieldAttachment, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldVoiceButton, + PromptFieldValue, + PromptTokenField +} from '@react-spectrum/ai'; +import {Text} from '@react-spectrum/s2'; +import CommentText from '@react-spectrum/s2/icons/CommentText'; + +function PromptAttachments() { + let [attachments, setAttachments] = useState([ + {id: '0', file: new File([], 'preview.png', {type: 'image/png'}), image: 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'}, + {id: '1', file: new File([], 'notes.txt', {type: 'text/plain'}), image: ''} + ]); + + return ( + + {/*- begin highlight -*/} + + {attachment => ( + + + + )} + + {/*- end highlight -*/} + + + + + + + Summarize image + + +
+ + +
+
+
+ ); +} +``` + +## Chat threads + +Compose a conversation from `Chat`, `Thread`, `ResponseStatus`, and message components. The thread can be driven by a collection as messages arrive from your application. + +```tsx render type="s2" +"use client"; +import {Chat, Thread, ThreadItem, UserMessage, ResponseStatus, ResponseStatusTitle, ResponseStatusPanel, ExecutionTrace, ExecutionTraceItem} from '@react-spectrum/ai'; +import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +let messages = [ + {id: 1, type: 'user', text: 'Summarize the campaign results.'}, + { + id: 2, + type: 'status', + status: 'success' as const, + text: 'Response complete', + steps: [ + {id: 1, label: 'Fetching campaign', status: 'success' as const, detail: 'Loaded campaign details and fetched engagement results from the database.'}, + {id: 2, label: 'Summarizing results', status: 'success' as const, detail: 'Created a full channel report and summarized the results.'} + ] + }, + { + id: 3, + type: 'assistant', + text: 'Engagement increased 18% this month, led by email and social. See the full report for a channel breakdown.', + content: ( + <> + Engagement increased 18% this month, led by email and social. See the{' '} + full report for a channel breakdown. + + ) + }, + {id: 4, type: 'user', text: 'Which channel performed best?'}, + { + id: 5, + type: 'assistant', + text: 'Email drove the most conversions, with social close behind: Email with 4,200 conversions, Social with 3,100 conversions.', + content: ( + <> +

Email drove the most conversions, with social close behind:

+
    +
  • Email: 4,200 conversions
  • +
  • Social: 3,100 conversions
  • +
+ + ) + } +]; + +function BasicChat() { + return ( + /*- begin highlight -*/ + + + {message => { + switch (message.type) { + case 'user': + return ( + + {message.text} + + ); + case 'assistant': + return ( + +
{message.content}
+
+ ); + case 'status': + return ( + + + {message.text} + + + {message.steps?.map(step => ( + {step.detail}

}> + {step.label} +
+ ))} +
+
+
+
+ ); + } + }} +
+
+ ); +} +``` + +## API + +```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadScrollButton: '#threadscrollbutton', PromptField: '#promptfield'}} + + + + + + + +``` + +## Chat + + + +### ThreadScrollButton + + + +## Thread + +```tsx links={{Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', MessageSuggestionList: '#messagesuggestionlist', UserMessage: '#usermessage', ResponseStatus: '#responsestatus', MessageSource: '#messagesource', MessageFeedback: '#messagefeedback'}} + + + or or or assistant content + and/or + + + +``` + + + +### ThreadItem + + + +### ThreadLoadMoreItem + + + +### UserMessage + + + +### ResponseStatus + +```tsx links={{ResponseStatus: '#responsestatus', ResponseStatusTitle: '#responsestatustitle', ResponseStatusPanel: '#responsestatuspanel', ExecutionTrace: '#executiontrace', ExecutionTraceItem: '#executiontraceitem'}} + + + + + + + + +``` + + + +#### ResponseStatusTitle + + + +#### ResponseStatusPanel + + + +#### ExecutionTrace + + + +#### ExecutionTraceItem + + + +### Alert + + + +### MessageSuggestionList + +```tsx links={{MessageSuggestionList: '#messagesuggestionlist', MessageSuggestion: '#messagesuggestion'}} + + + +``` + + + +#### MessageSuggestion + + + +### MessageFeedback + + + +### MessageSource + +```tsx links={{MessageSource: '#messagesource', SourceList: '#sourcelist', SourceListItem: '#sourcelistitem'}} + + + + + +``` + + + +#### SourceList + + + +#### SourceListItem + + + +## PromptField + +```tsx links={{PromptField: '#promptfield', PromptTokenField: '#prompttokenfield', PromptFieldToolbar: '#promptfieldtoolbar'}} + + + + + +``` + + + +### PromptFieldAttachmentList + +```tsx links={{PromptFieldAttachmentList: '#promptfieldattachmentlist', Attachment: '#attachment', AttachmentPreview: '#attachmentpreview'}} + + {attachment => ( + + + + )} + +``` + + + +#### Attachment + + + +#### AttachmentPreview + + + +### PromptTokenField + +```tsx links={{PromptTokenField: '#prompttokenfield', PromptToken: '#prompttoken'}} + + {token => } + +``` + + + +#### PromptToken + + + +### PromptFieldToolbar + +```tsx links={{PromptFieldToolbar: '#promptfieldtoolbar', InsertMenuButton: '#insertmenubutton', AttachFileMenuItem: '#attachfilemenuitem', InsertTextMenuItem: '#inserttextmenuitem', InsertTokenMenuItem: '#inserttokenmenuitem', CommandMenuItem: '#commandmenuitem', SubmenuTrigger: 'Menu#submenutrigger', Menu: 'Menu#menu', PromptFieldVoiceButton: '#promptfieldvoicebutton', PromptFieldSubmitButton: '#promptfieldsubmitbutton'}} + + + + + or or + + + + + + +``` + + + +#### InsertMenuButton + + + +#### AttachFileMenuItem + + + +#### InsertTextMenuItem + + + +#### InsertTokenMenuItem + + + +#### CommandMenuItem + + + +#### PromptFieldVoiceButton + + + +#### PromptFieldSubmitButton + + diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx index 975f89cffa5..6d780a5ed6b 100644 --- a/packages/dev/s2-docs/src/ComponentCard.tsx +++ b/packages/dev/s2-docs/src/ComponentCard.tsx @@ -12,6 +12,8 @@ import ActionMenuDark from 'url:../assets/component-illustrations/dark/ActionMen import ActionMenuLight from 'url:../assets/component-illustrations/light/ActionMenu.avif'; import AdobeDark from 'url:../assets/component-illustrations/dark/Adobe.avif'; import AdobeLight from 'url:../assets/component-illustrations/light/Adobe.avif'; +import AIComponentsDark from 'url:../assets/component-illustrations/dark/AIComponents.avif'; +import AIComponentsLight from 'url:../assets/component-illustrations/light/AIComponents.avif'; import AutocompleteDark from 'url:../assets/component-illustrations/dark/Autocomplete.avif'; import AutocompleteLight from 'url:../assets/component-illustrations/light/Autocomplete.avif'; import AvatarDark from 'url:../assets/component-illustrations/dark/Avatar.avif'; @@ -199,8 +201,6 @@ import TreeDark from 'url:../assets/component-illustrations/dark/Tree.avif'; import TreeLight from 'url:../assets/component-illustrations/light/Tree.avif'; import UtilityDark from 'url:../assets/component-illustrations/dark/Utility.avif'; import UtilityLight from 'url:../assets/component-illustrations/light/Utility.avif'; -import WorkingWithAIDark from 'url:../assets/component-illustrations/dark/WorkingWithAI.avif'; -import WorkingWithAILight from 'url:../assets/component-illustrations/light/WorkingWithAI.avif'; export interface ComponentCardItem { id: string; @@ -307,6 +307,7 @@ const componentIllustrations: Record = { Virtualizer: [CollectionLight, CollectionDark], VisuallyHidden: [AccessibilityLight, AccessibilityDark], // Guides + 'AI Components': [AIComponentsLight, AIComponentsDark], Collections: [CollectionLight, CollectionDark], Customization: [StyleLight, StyleDark], 'Drag and Drop': [DragAndDropLight, DragAndDropDark], @@ -315,7 +316,7 @@ const componentIllustrations: Record = { 'Getting started': [GettingStartedLight, GettingStartedDark], 'MCP Server': [McpServerLight, McpServerDark], Quality: [AccessibilityLight, AccessibilityDark], - 'Working with AI': [WorkingWithAILight, WorkingWithAIDark], + 'Working with AI': [AIComponentsLight, AIComponentsDark], Selection: [SelectionLight, SelectionDark], 'Style Macro': [StyleMacroLight, StyleMacroDark], Styling: [StyleLight, StyleDark],