From 62794af6ddd8aca18d473462bfd4ce7b96a2fe29 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 10 Nov 2025 09:18:57 +0100 Subject: [PATCH] docs(TextArea (AI)): add stream example --- .storybook/utils.ts | 31 ++- config/version-info.json | 2 +- packages/ai/src/components/Input/Input.mdx | 2 +- .../ai/src/components/Input/Input.stories.tsx | 2 +- .../ai/src/components/TextArea/TextArea.mdx | 217 ++++++++++++++++++ .../components/TextArea/TextArea.stories.tsx | 107 +++++++++ 6 files changed, 355 insertions(+), 6 deletions(-) diff --git a/.storybook/utils.ts b/.storybook/utils.ts index 8ffa0a22afa..d3b1bba33f0 100644 --- a/.storybook/utils.ts +++ b/.storybook/utils.ts @@ -1,5 +1,5 @@ import type * as CEM from '@ui5/webcomponents-tools/lib/cem/types-internal'; -import { useMemo, useRef, useState, useTransition } from 'react'; +import { useEffect, useMemo, useRef, useState, useTransition } from 'react'; // @ts-expect-error: storybook can handle this import cemAi from './custom-element-manifests/ai.json'; // @ts-expect-error: storybook can handle this @@ -91,13 +91,17 @@ type StartStreamOptions = { onComplete?: (fullText: string) => void; onProcessingComplete?: () => void; }; -export function useFakeStream(typingDelay = 10, startingDelay = 1500) { - const [value, setValue] = useState(''); +export function useFakeStream(initialValue = '', typingDelay = 10, startingDelay = 1500) { + const [value, setValue] = useState(initialValue); const [transitionIsPending, startTransition] = useTransition(); // active character updates const [isProcessing, setIsProcessing] = useState(false); // starting delay const [isTyping, setIsTyping] = useState(false); // actively typing characters const intervalRef = useRef | null>(null); const timeoutRef = useRef | null>(null); + const isProcessingRef = useRef(isProcessing); + const isTypingRef = useRef(isTyping); + isProcessingRef.current = isProcessing; + isTypingRef.current = isTyping; const startStream = ({ text, onComplete, onProcessingComplete }: StartStreamOptions) => { // Stop previous stream and timeout @@ -161,3 +165,24 @@ export function useFakeStream(typingDelay = 10, startingDelay = 1500) { return { value, transitionIsPending, isProcessing, isTyping, setValue, startStream, stopStream }; } + +export function useStopStreamByESC(loading: boolean, stopStream: () => void, onStop?: () => void) { + const loadingRef = useRef(loading); + loadingRef.current = loading; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && loadingRef.current) { + stopStream(); + if (onStop) { + onStop(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [stopStream, onStop]); +} diff --git a/config/version-info.json b/config/version-info.json index 13d1d6738aa..a7ec921d225 100644 --- a/config/version-info.json +++ b/config/version-info.json @@ -58,5 +58,5 @@ "2.13.1": "2.13.0", "2.14.0": "2.14.0", "2.15.0": "2.15.0", - "2.16.0": "2.16.1" + "2.16.1": "2.16.0" } diff --git a/packages/ai/src/components/Input/Input.mdx b/packages/ai/src/components/Input/Input.mdx index 9a8f202fd9a..031106452dd 100644 --- a/packages/ai/src/components/Input/Input.mdx +++ b/packages/ai/src/components/Input/Input.mdx @@ -206,7 +206,7 @@ Input component implementing simple stream handling. const [placeholder, setPlaceholder] = useState(initialPlaceholder); const hasHistory = versionHistory.length > 0; const currentActionRef = useRef(''); - const { value, isTyping, isProcessing, setValue, startStream, stopStream } = useFakeStream(50); + const { value, isTyping, isProcessing, setValue, startStream, stopStream } = useFakeStream(); const handleVersionChange: InputPropTypes['onVersionChange'] = (e) => { setCurrentHistoryIndex((prev) => (e.detail.backwards ? prev - 1 : prev + 1)); diff --git a/packages/ai/src/components/Input/Input.stories.tsx b/packages/ai/src/components/Input/Input.stories.tsx index c4e6bd8ae33..f18c06de7d7 100644 --- a/packages/ai/src/components/Input/Input.stories.tsx +++ b/packages/ai/src/components/Input/Input.stories.tsx @@ -130,7 +130,7 @@ export const WithFakeStream: Story = { const [placeholder, setPlaceholder] = useState(initialPlaceholder); const hasHistory = versionHistory.length > 0; const currentActionRef = useRef(''); - const { value, isTyping, isProcessing, setValue, startStream, stopStream } = useFakeStream(50); + const { value, isTyping, isProcessing, setValue, startStream, stopStream } = useFakeStream(); const handleVersionChange: InputPropTypes['onVersionChange'] = (e) => { setCurrentHistoryIndex((prev) => (e.detail.backwards ? prev - 1 : prev + 1)); diff --git a/packages/ai/src/components/TextArea/TextArea.mdx b/packages/ai/src/components/TextArea/TextArea.mdx index fe96c0d6659..8bc6131f8b1 100644 --- a/packages/ai/src/components/TextArea/TextArea.mdx +++ b/packages/ai/src/components/TextArea/TextArea.mdx @@ -16,4 +16,221 @@ import * as ComponentStories from './TextArea.stories.tsx'; +## TextArea with Fake Stream + +TextArea component implementing simple stream handling. + + + +
+ + Show Static Code + + ```tsx + import { TextArea, TextAreaPropTypes } from '@ui5/webcomponents-ai-react'; + import { Menu, MenuItem, MenuPropTypes } from '@ui5/webcomponents-react'; + import { useEffect, useRef, useState, useTransition } from 'react'; + + type StartStreamOptions = { + text: string; + onComplete?: (fullText: string) => void; + onProcessingComplete?: () => void; + }; + export function useFakeStream(initialValue = '', typingDelay = 10, startingDelay = 1500) { + const [value, setValue] = useState(initialValue); + const [transitionIsPending, startTransition] = useTransition(); // active character updates + const [isProcessing, setIsProcessing] = useState(false); // starting delay + const [isTyping, setIsTyping] = useState(false); // actively typing characters + const intervalRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); + const isProcessingRef = useRef(isProcessing); + const isTypingRef = useRef(isTyping); + isProcessingRef.current = isProcessing; + isTypingRef.current = isTyping; + + const startStream = ({ text, onComplete, onProcessingComplete }: StartStreamOptions) => { + // Stop previous stream and timeout + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + setValue(''); + setIsProcessing(true); + + timeoutRef.current = setTimeout(() => { + setIsProcessing(false); + + if (onProcessingComplete) { + onProcessingComplete(); + } + + setIsTyping(true); + let index = 0; + + intervalRef.current = setInterval(() => { + if (index < text.length) { + const nextChar = text[index]; + index++; + + startTransition(() => { + setValue((prev) => prev + nextChar); + }); + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsTyping(false); + + if (onComplete) { + onComplete(text); + } + } + }, typingDelay); + }, startingDelay); + }; + + const stopStream = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setIsProcessing(false); + setIsTyping(false); + }; + + return { value, transitionIsPending, isProcessing, isTyping, setValue, startStream, stopStream }; + } + + export function useStopStreamByESC(loading: boolean, stopStream: () => void, onStop?: () => void) { + const loadingRef = useRef(loading); + loadingRef.current = loading; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && loadingRef.current) { + stopStream(); + if (onStop) { + onStop(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [stopStream, onStop]); + } + + const SAMPLE_TEXT = + 'Innovation managers operate with both creativity and business acumen, driving initiatives that cultivate an innovation-friendly culture, streamline the execution of new ideas, and ultimately unlock value for the organization and its customers.'; + + type VersionHistoryItem = { + action: string; + endAction: string; + timestamp: string; + value: string; + promptDescription: string; + }; + + function AITextArea(props) { + const { value, isTyping, isProcessing, setValue, startStream, stopStream } = useFakeStream(); + const [versionHistory, setVersionHistory] = useState([]); + const [currentHistoryIndex, setCurrentHistoryIndex] = useState(-1); + const [promptDescription, setPromptDescription] = useState(''); + const currentActionRef = useRef(''); + const isLoading = isProcessing || isTyping; + + const handleItemClick: MenuPropTypes['onItemClick'] = (e) => { + const { action } = e.detail.item.dataset; + if (isProcessing || !action) { + return; + } + currentActionRef.current = action; + setPromptDescription('Generating text...'); + startStream({ + text: SAMPLE_TEXT, + onComplete: (fullText) => { + setVersionHistory((prev) => [ + ...prev, + { + action, + endAction: 'completed', + timestamp: new Date().toISOString(), + value: fullText, + promptDescription: 'Generated text', + }, + ]); + setCurrentHistoryIndex((prev) => prev + 1); + setValue(''); + setPromptDescription(''); + }, + }); + }; + + const handleStopGeneration: TextAreaPropTypes['onStopGeneration'] = () => { + stopStream(); + handleStop(); + }; + + const handleStop = () => { + setVersionHistory((prev) => [ + ...prev, + { + action: currentActionRef.current, + endAction: 'stopped', + timestamp: new Date().toISOString(), + value: value, + promptDescription: 'Generated text (stopped)', + }, + ]); + setCurrentHistoryIndex((prev) => prev + 1); + setValue(''); + setPromptDescription(''); + }; + + const handleVersionChange: TextAreaPropTypes['onVersionChange'] = (e) => { + setCurrentHistoryIndex((prev) => (e.detail.backwards ? prev - 1 : prev + 1)); + setValue(''); + }; + + const handleInput: TextAreaPropTypes['onInput'] = (e) => { + setValue(e.target.value); + }; + + useStopStreamByESC(isLoading, stopStream, handleStop); + + return ( +