Skip to content
Merged
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
31 changes: 28 additions & 3 deletions .storybook/utils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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
Expand Down Expand Up @@ -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]);
}
2 changes: 1 addition & 1 deletion config/version-info.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
2 changes: 1 addition & 1 deletion packages/ai/src/components/Input/Input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Input component implementing simple stream handling.
const [placeholder, setPlaceholder] = useState(initialPlaceholder);
const hasHistory = versionHistory.length > 0;
const currentActionRef = useRef<string>('');
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));
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/src/components/Input/Input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const WithFakeStream: Story = {
const [placeholder, setPlaceholder] = useState(initialPlaceholder);
const hasHistory = versionHistory.length > 0;
const currentActionRef = useRef<string>('');
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));
Expand Down
217 changes: 217 additions & 0 deletions packages/ai/src/components/TextArea/TextArea.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,221 @@ import * as ComponentStories from './TextArea.stories.tsx';

<ControlsWithNote of={ComponentStories.Default} />

## TextArea with Fake Stream

TextArea component implementing simple stream handling.

<Canvas of={ComponentStories.WithFakeStream} />

<details>

<summary>Show Static Code</summary>

```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<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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<VersionHistoryItem[]>([]);
const [currentHistoryIndex, setCurrentHistoryIndex] = useState(-1);
const [promptDescription, setPromptDescription] = useState('');
const currentActionRef = useRef<string>('');
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 (
<TextArea
{...props}
value={value || versionHistory[currentHistoryIndex]?.value || ''}
currentVersion={currentHistoryIndex + 1}
totalVersions={versionHistory.length}
loading={isLoading}
promptDescription={promptDescription || versionHistory[currentHistoryIndex]?.promptDescription || ''}
onStopGeneration={handleStopGeneration}
onVersionChange={handleVersionChange}
onInput={handleInput}
menu={
<Menu onItemClick={handleItemClick}>
<MenuItem text="Generate text" data-action="generate" />
</Menu>
}
/>
);
}
```

</details>

<Footer />
Loading
Loading