@gravity-ui/aikit 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/package.json CHANGED
@@ -1,12 +1,23 @@
1
1
  {
2
2
  "name": "@gravity-ui/aikit",
3
- "version": "0.0.1",
4
- "description": "",
3
+ "version": "0.1.1",
4
+ "description": "Gravity UI base kit for building ai assistant chats",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
7
8
  "files": [
8
9
  "dist"
9
10
  ],
11
+ "engines": {
12
+ "node": ">= 20",
13
+ "npm": ">= 9",
14
+ "yarn": "Please use npm instead of yarn to install dependencies",
15
+ "pnpm": "Please use npm instead of pnpm to install dependencies"
16
+ },
17
+ "sideEffects": [
18
+ "*.css",
19
+ "*.scss"
20
+ ],
10
21
  "repository": {
11
22
  "type": "git",
12
23
  "url": "git+https://github.com/gravity-ui/aikit.git"
@@ -26,10 +37,11 @@
26
37
  "lint:styles:fix": "stylelint **/*.scss --fix",
27
38
  "lint:prettier": "prettier --check '**/*.{js,jsx,ts,tsx,css,scss,json,yaml,yml,md,mdx}'",
28
39
  "lint:prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx,css,scss,json,yaml,yml,md,mdx}'",
29
- "test": "jest --passWithNoTests",
40
+ "test": "npm run test:unit",
30
41
  "build": "tsc",
31
42
  "start": "TS_NODE_PROJECT=.storybook/tsconfig.json storybook dev",
32
43
  "prepublishOnly": "npm run build",
44
+ "test:unit": "jest --passWithNoTests",
33
45
  "playwright:install": "playwright install chromium webkit --with-deps",
34
46
  "playwright": "playwright test --config=playwright-ct.config.ts",
35
47
  "playwright:update": "npm run playwright -- -u all",
@@ -1,14 +1,4 @@
1
- var __rest = (this && this.__rest) || function (s, e) {
2
- var t = {};
3
- for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
4
- t[p] = s[p];
5
- if (s != null && typeof Object.getOwnPropertySymbols === "function")
6
- for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
7
- if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
8
- t[p[i]] = s[p[i]];
9
- }
10
- return t;
11
- };
1
+ import { __rest } from "tslib";
12
2
  import { jsx as _jsx } from "react/jsx-runtime";
13
3
  import React from 'react';
14
4
  import { ActionTooltip, Button } from '@gravity-ui/uikit';
@@ -2,5 +2,6 @@ import { AlertProps } from '../../atoms/Alert';
2
2
  export type ErrorAlertProps = {
3
3
  onRetry?: () => void;
4
4
  errorMessage?: AlertProps;
5
+ className?: string;
5
6
  };
6
- export declare function ErrorAlert({ onRetry, errorMessage }: ErrorAlertProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function ErrorAlert({ onRetry, errorMessage, className }: ErrorAlertProps): import("react/jsx-runtime").JSX.Element;
@@ -5,11 +5,11 @@ import { block } from '../../../utils/cn';
5
5
  import { Alert } from '../../atoms/Alert';
6
6
  import { i18n } from './i18n';
7
7
  const b = block('message-list');
8
- export function ErrorAlert({ onRetry, errorMessage }) {
8
+ export function ErrorAlert({ onRetry, errorMessage, className }) {
9
9
  return (_jsx(Alert, Object.assign({ text: i18n('default-error-text'), variant: "warning", button: onRetry
10
10
  ? {
11
11
  content: (_jsxs("div", { className: b('retry-button'), children: [_jsx(Icon, { data: ArrowRotateLeft, size: 14 }), i18n('retry-button')] })),
12
12
  onClick: onRetry,
13
13
  }
14
- : undefined }, errorMessage)));
14
+ : undefined, className: className }, errorMessage)));
15
15
  }
@@ -1,6 +1,7 @@
1
1
  import type { OptionsType } from '@diplodoc/transform/lib/typings';
2
2
  import { ChatStatus } from '../../../types';
3
- import type { TChatMessage, TMessageContent, TMessageMetadata } from '../../../types/messages';
3
+ import type { TAssistantMessage, TChatMessage, TMessageContent, TMessageMetadata, TUserMessage } from '../../../types/messages';
4
+ import { type DefaultMessageAction } from '../../../utils';
4
5
  import { type MessageRendererRegistry } from '../../../utils/messageTypeRegistry';
5
6
  import { AlertProps } from '../../atoms/Alert';
6
7
  import './MessageList.scss';
@@ -14,7 +15,9 @@ export type MessageListProps<TContent extends TMessageContent = never> = {
14
15
  showActionsOnHover?: boolean;
15
16
  showTimestamp?: boolean;
16
17
  showAvatar?: boolean;
18
+ userActions?: DefaultMessageAction<TUserMessage<TMessageMetadata>>[];
19
+ assistantActions?: DefaultMessageAction<TAssistantMessage<TContent, TMessageMetadata>>[];
17
20
  className?: string;
18
21
  qa?: string;
19
22
  };
20
- export declare function MessageList<TContent extends TMessageContent = never>({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, className, qa, status, errorMessage, onRetry, }: MessageListProps<TContent>): import("react/jsx-runtime").JSX.Element;
23
+ export declare function MessageList<TContent extends TMessageContent = never>({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, className, qa, status, errorMessage, onRetry, }: MessageListProps<TContent>): import("react/jsx-runtime").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { isAssistantMessage, isUserMessage } from '../../../utils';
2
+ import { useSmartScroll } from '../../../hooks';
3
+ import { isAssistantMessage, isUserMessage, resolveMessageActions, } from '../../../utils';
3
4
  import { block } from '../../../utils/cn';
4
5
  import { Loader } from '../../atoms/Loader';
5
6
  import { AssistantMessage } from '../AssistantMessage';
@@ -7,15 +8,23 @@ import { UserMessage } from '../UserMessage';
7
8
  import { ErrorAlert } from './ErrorAlert';
8
9
  import './MessageList.scss';
9
10
  const b = block('message-list');
10
- export function MessageList({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, className, qa, status, errorMessage, onRetry, }) {
11
+ export function MessageList({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, className, qa, status, errorMessage, onRetry, }) {
12
+ const isStreaming = status === 'streaming';
13
+ const messagesCount = messages.length;
14
+ const { containerRef, endRef } = useSmartScroll(isStreaming, messagesCount);
11
15
  const renderMessage = (message, index) => {
12
16
  if (isUserMessage(message)) {
13
- return (_jsx(UserMessage, { content: message.content, actions: message.actions, timestamp: message.timestamp, format: message.format, avatarUrl: message.avatarUrl, transformOptions: transformOptions, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp, showAvatar: showAvatar }, message.id || `message-${index}`));
17
+ const actions = resolveMessageActions(message, userActions);
18
+ return (_jsx(UserMessage, { content: message.content, actions: actions, timestamp: message.timestamp, format: message.format, avatarUrl: message.avatarUrl, transformOptions: transformOptions, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp, showAvatar: showAvatar }, message.id || `message-${index}`));
14
19
  }
15
20
  if (isAssistantMessage(message)) {
16
- return (_jsx(AssistantMessage, { content: message.content, actions: message.actions, timestamp: message.timestamp, id: message.id, messageRendererRegistry: messageRendererRegistry, transformOptions: transformOptions, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp }, message.id || `message-${index}`));
21
+ const showActions = message.status === 'complete';
22
+ const actions = showActions
23
+ ? resolveMessageActions(message, assistantActions)
24
+ : undefined;
25
+ return (_jsx(AssistantMessage, { content: message.content, actions: actions, timestamp: message.timestamp, id: message.id, messageRendererRegistry: messageRendererRegistry, transformOptions: transformOptions, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp }, message.id || `message-${index}`));
17
26
  }
18
27
  return null;
19
28
  };
20
- return (_jsxs("div", { className: b(null, className), "data-qa": qa, children: [_jsx("div", { className: b('messages', className), "data-qa": qa, children: messages.map(renderMessage) }), status === 'submitted' && _jsx(Loader, {}), status === 'error' && _jsx(ErrorAlert, { onRetry: onRetry, errorMessage: errorMessage })] }));
29
+ return (_jsxs("div", { ref: containerRef, className: b(null, className), "data-qa": qa, children: [_jsx("div", { className: b('messages', className), "data-qa": qa, children: messages.map(renderMessage) }), status === 'submitted' && _jsx(Loader, { className: b('loader') }), status === 'error' && (_jsx(ErrorAlert, { className: b('error-alert'), onRetry: onRetry, errorMessage: errorMessage })), _jsx("div", { ref: endRef })] }));
21
30
  }
@@ -20,3 +20,5 @@ interface ChartMessageData {
20
20
  }
21
21
  type ChartMessageContent = TMessageContent<'chart', ChartMessageData>;
22
22
  export declare const WithCustomMessageType: StoryObj<MessageListProps<ChartMessageContent>>;
23
+ export declare const WithStreamingMessage: StoryObj<MessageListProps>;
24
+ export declare const WithDefaultActions: StoryObj<MessageListProps>;
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
2
3
  import { Pencil } from '@gravity-ui/icons';
3
4
  import { Icon, Text } from '@gravity-ui/uikit';
4
5
  import { MessageList } from '..';
@@ -6,6 +7,7 @@ import { ContentWrapper } from '../../../../demo/ContentWrapper';
6
7
  import { Showcase } from '../../../../demo/Showcase';
7
8
  import { ShowcaseItem } from '../../../../demo/ShowcaseItem';
8
9
  import { createMessageRendererRegistry, registerMessageRenderer, } from '../../../../utils/messageTypeRegistry';
10
+ import { BaseMessageAction } from '../../../molecules/BaseMessage';
9
11
  import MDXDocs from './Docs.mdx';
10
12
  export default {
11
13
  title: 'organisms/MessageList',
@@ -166,3 +168,121 @@ export const WithCustomMessageType = {
166
168
  },
167
169
  decorators: defaultDecorators,
168
170
  };
171
+ const streamingText = 'React Hooks are functions that let you use state and other React features without writing a class. ' +
172
+ 'The most commonly used hooks are:\n\n' +
173
+ '1. **useState** - for managing component state\n' +
174
+ '2. **useEffect** - for side effects like data fetching or subscriptions\n' +
175
+ '3. **useContext** - for consuming context values\n' +
176
+ '4. **useCallback** - for memoizing functions\n' +
177
+ '5. **useMemo** - for memoizing computed values\n\n' +
178
+ 'Each hook serves a specific purpose and helps you write cleaner, more maintainable React code. ' +
179
+ 'Would you like examples of how to use any of these hooks?';
180
+ export const WithStreamingMessage = {
181
+ render: (args) => {
182
+ const [streamedText, setStreamedText] = useState('');
183
+ const [isStreaming, setIsStreaming] = useState(true);
184
+ useEffect(() => {
185
+ setStreamedText('');
186
+ setIsStreaming(true);
187
+ const resultText = streamingText.repeat(10);
188
+ let currentIndex = 0;
189
+ const interval = setInterval(() => {
190
+ if (currentIndex < resultText.length) {
191
+ const nextChunk = resultText.slice(0, currentIndex + 1);
192
+ setStreamedText(nextChunk);
193
+ currentIndex += Math.floor(Math.random() * 4) + 3;
194
+ }
195
+ else {
196
+ setIsStreaming(false);
197
+ clearInterval(interval);
198
+ }
199
+ }, 20);
200
+ return () => {
201
+ clearInterval(interval);
202
+ };
203
+ }, []);
204
+ const messages = [
205
+ {
206
+ id: 'user-1',
207
+ role: 'user',
208
+ timestamp: '2024-01-01T00:00:00Z',
209
+ content: 'Can you explain React Hooks to me?',
210
+ },
211
+ {
212
+ id: 'assistant-1',
213
+ role: 'assistant',
214
+ timestamp: '2024-01-01T00:00:01Z',
215
+ content: streamedText || ' ',
216
+ },
217
+ ];
218
+ return (_jsx(ShowcaseItem, { title: "With Streaming Message", children: _jsx(ContentWrapper, { width: "480px", height: "200px", display: "flex", children: _jsx(MessageList, Object.assign({}, args, { messages: messages, status: isStreaming ? 'streaming' : 'ready' })) }) }));
219
+ },
220
+ decorators: defaultDecorators,
221
+ };
222
+ export const WithDefaultActions = {
223
+ render: (args) => {
224
+ const userActions = [
225
+ {
226
+ type: BaseMessageAction.Edit,
227
+ onClick: (message) => {
228
+ // eslint-disable-next-line no-console
229
+ console.log('Edit user message:', message.id);
230
+ },
231
+ },
232
+ {
233
+ type: BaseMessageAction.Delete,
234
+ onClick: (message) => {
235
+ // eslint-disable-next-line no-console
236
+ console.log('Delete user message:', message.id);
237
+ },
238
+ },
239
+ ];
240
+ const assistantActions = [
241
+ {
242
+ type: BaseMessageAction.Copy,
243
+ onClick: (message) => {
244
+ // eslint-disable-next-line no-console
245
+ console.log('Copy assistant message:', message.id);
246
+ },
247
+ },
248
+ {
249
+ type: BaseMessageAction.Like,
250
+ onClick: (message) => {
251
+ // eslint-disable-next-line no-console
252
+ console.log('Like assistant message:', message.id);
253
+ },
254
+ },
255
+ ];
256
+ return (_jsx(ShowcaseItem, { title: "With Default Actions", children: _jsx(ContentWrapper, { width: "480px", children: _jsx(MessageList, Object.assign({}, args, { messages: [
257
+ {
258
+ id: 'user-1',
259
+ role: 'user',
260
+ timestamp: '2024-01-01T00:00:00Z',
261
+ content: 'Hello! This message has default actions.',
262
+ },
263
+ {
264
+ id: 'assistant-1',
265
+ role: 'assistant',
266
+ timestamp: '2024-01-01T00:00:01Z',
267
+ content: 'Hi! This message also has default actions.',
268
+ status: 'complete',
269
+ },
270
+ {
271
+ id: 'user-2',
272
+ role: 'user',
273
+ timestamp: '2024-01-01T00:00:02Z',
274
+ content: 'This message has custom actions.',
275
+ actions: [
276
+ {
277
+ type: 'custom',
278
+ onClick: () => {
279
+ // eslint-disable-next-line no-console
280
+ console.log('Custom action clicked');
281
+ },
282
+ },
283
+ ],
284
+ },
285
+ ], userActions: userActions, assistantActions: assistantActions })) }) }));
286
+ },
287
+ decorators: defaultDecorators,
288
+ };
@@ -1,15 +1,5 @@
1
1
  'use client';
2
- var __rest = (this && this.__rest) || function (s, e) {
3
- var t = {};
4
- for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
- t[p] = s[p];
6
- if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
- for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
- if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
- t[p[i]] = s[p[i]];
10
- }
11
- return t;
12
- };
2
+ import { __rest } from "tslib";
13
3
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
4
  import { ChevronDown, ChevronUp, Copy } from '@gravity-ui/icons';
15
5
  import { Button, Icon, Text } from '@gravity-ui/uikit';
@@ -79,24 +79,28 @@ const mockChatMessages = {
79
79
  role: 'user',
80
80
  content: 'What is quantum computing?',
81
81
  timestamp: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
82
+ status: 'complete',
82
83
  },
83
84
  {
84
85
  id: '1-2',
85
86
  role: 'assistant',
86
87
  content: 'Quantum computing is a revolutionary approach to computation that leverages the principles of quantum mechanics. Unlike classical computers that use bits (0 or 1), quantum computers use quantum bits or "qubits" that can exist in multiple states simultaneously through a phenomenon called superposition.\n\nKey concepts include:\n1. **Superposition**: Qubits can be in multiple states at once\n2. **Entanglement**: Qubits can be correlated in ways that classical bits cannot\n3. **Quantum Interference**: Used to amplify correct answers and cancel out wrong ones',
87
88
  timestamp: new Date(Date.now() - 1000 * 60 * 59).toISOString(),
89
+ status: 'complete',
88
90
  },
89
91
  {
90
92
  id: '1-3',
91
93
  role: 'user',
92
94
  content: 'Can you explain quantum entanglement in simple terms?',
93
95
  timestamp: new Date(Date.now() - 1000 * 60 * 58).toISOString(),
96
+ status: 'complete',
94
97
  },
95
98
  {
96
99
  id: '1-4',
97
100
  role: 'assistant',
98
101
  content: 'Quantum entanglement is like having two magic coins that are mysteriously connected. When you flip one coin and it lands on heads, the other coin - no matter how far away - will instantly land on tails (or vice versa).\n\nIn quantum physics terms:\n- Two particles become "entangled" and share a quantum state\n- Measuring one particle instantly affects the other\n- This happens regardless of the distance between them\n- Einstein called it "spooky action at a distance"\n\nThis phenomenon is fundamental to quantum computing and quantum communication technologies.',
99
102
  timestamp: new Date(Date.now() - 1000 * 60 * 57).toISOString(),
103
+ status: 'complete',
100
104
  },
101
105
  ],
102
106
  '2': [
@@ -105,24 +109,28 @@ const mockChatMessages = {
105
109
  role: 'user',
106
110
  content: 'Write a poem about autumn leaves',
107
111
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
112
+ status: 'complete',
108
113
  },
109
114
  {
110
115
  id: '2-2',
111
116
  role: 'assistant',
112
117
  content: "Golden whispers fall from trees,\nDancing softly on the breeze,\nCrimsons, ambers, russet bright,\nPainting pathways with their light.\n\nNature's canvas, rich and warm,\nBefore the coming winter storm,\nEach leaf a story, each one told,\nOf seasons passing, growing old.",
113
118
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 + 60000).toISOString(),
119
+ status: 'complete',
114
120
  },
115
121
  {
116
122
  id: '2-3',
117
123
  role: 'user',
118
124
  content: 'That was beautiful! Can you write one about winter snow?',
119
125
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 + 120000).toISOString(),
126
+ status: 'complete',
120
127
  },
121
128
  {
122
129
  id: '2-4',
123
130
  role: 'assistant',
124
131
  content: "Snowflakes drift from silent skies,\nBlankets white before our eyes,\nCrystal diamonds, soft and pure,\nWinter's gentle signature.\n\nFrosted windows, frozen streams,\nLandscapes wrapped in silver dreams,\nQuiet peace in cold embrace,\nNature sleeps in gentle grace.",
125
132
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 + 180000).toISOString(),
133
+ status: 'complete',
126
134
  },
127
135
  ],
128
136
  '3': [
@@ -131,24 +139,28 @@ const mockChatMessages = {
131
139
  role: 'user',
132
140
  content: "I have a bug in my async function. It's not waiting for the promise to resolve.",
133
141
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
142
+ status: 'complete',
134
143
  },
135
144
  {
136
145
  id: '3-2',
137
146
  role: 'assistant',
138
147
  content: "Let's debug this step by step. The most common issues with async/await are:\n\n1. **Missing await keyword**: Make sure you're using `await` before the promise\n2. **Function not marked as async**: The function must be declared with `async` keyword\n3. **Promise not being returned**: Ensure the function returns a promise\n\nCan you share your code? Here's a common pattern:\n\n```javascript\nasync function fetchData() {\n const result = await fetch('/api/data');\n const data = await result.json();\n return data;\n}\n```",
139
148
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2 + 60000).toISOString(),
149
+ status: 'complete',
140
150
  },
141
151
  {
142
152
  id: '3-3',
143
153
  role: 'user',
144
154
  content: 'Oh! I forgot to mark the parent function as async. That was the issue. Thanks!',
145
155
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2 + 120000).toISOString(),
156
+ status: 'complete',
146
157
  },
147
158
  {
148
159
  id: '3-4',
149
160
  role: 'assistant',
150
161
  content: "Great! That's a very common mistake. Remember: to use `await`, the containing function must be `async`. Also, if you're using `await` in a callback (like in `.map()` or `.forEach()`), make sure to make the callback function async too. Happy coding!",
151
162
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2 + 180000).toISOString(),
163
+ status: 'complete',
152
164
  },
153
165
  ],
154
166
  '4': [
@@ -157,12 +169,14 @@ const mockChatMessages = {
157
169
  role: 'user',
158
170
  content: 'What are the latest developments in AI?',
159
171
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
172
+ status: 'complete',
160
173
  },
161
174
  {
162
175
  id: '4-2',
163
176
  role: 'assistant',
164
177
  content: 'Recent AI developments include:\n\n**Large Language Models**\n- More efficient training methods\n- Better reasoning capabilities\n- Multimodal models (text, image, audio)\n\n**Computer Vision**\n- Improved object detection and segmentation\n- Real-time video analysis\n- 3D reconstruction from 2D images\n\n**AI Safety**\n- Constitutional AI and alignment research\n- Better interpretability tools\n- Red-teaming and safety testing\n\n**Edge AI**\n- Running models on mobile devices\n- Reduced latency for real-time applications\n- Privacy-preserving local inference',
165
178
  timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3 + 60000).toISOString(),
179
+ status: 'complete',
166
180
  },
167
181
  ],
168
182
  };
@@ -235,6 +249,7 @@ export const Playground = {
235
249
  role: 'user',
236
250
  content: data.content,
237
251
  timestamp: new Date().toISOString(),
252
+ status: 'complete',
238
253
  actions: createMessageActions(Date.now().toString(), 'user'),
239
254
  };
240
255
  setMessages((prev) => [...prev, userMessage]);
@@ -247,6 +262,7 @@ export const Playground = {
247
262
  role: 'assistant',
248
263
  content: `This is a mock response to: "${data.content}". In a real application, this would be a streamed response from an AI model.`,
249
264
  timestamp: new Date().toISOString(),
265
+ status: 'complete',
250
266
  actions: createMessageActions(assistantMessageId, 'assistant'),
251
267
  };
252
268
  setMessages((prev) => [...prev, assistantMessage]);
@@ -287,6 +303,7 @@ export const EmptyState = {
287
303
  id: Date.now().toString(),
288
304
  role: 'user',
289
305
  content: data.content,
306
+ status: 'complete',
290
307
  };
291
308
  setMessages((prev) => [...prev, userMessage]);
292
309
  };
@@ -312,6 +329,7 @@ export const WithMessages = {
312
329
  id: userMessageId,
313
330
  role: 'user',
314
331
  content: data.content,
332
+ status: 'complete',
315
333
  actions: createMessageActions(userMessageId, 'user'),
316
334
  };
317
335
  setMessages((prev) => [...prev, userMessage]);
@@ -337,6 +355,7 @@ export const WithStreaming = {
337
355
  id: userMessageId,
338
356
  role: 'user',
339
357
  content: data.content,
358
+ status: 'complete',
340
359
  actions: createMessageActions(userMessageId, 'user'),
341
360
  };
342
361
  setMessages((prev) => [...prev, userMessage]);
@@ -351,6 +370,7 @@ export const WithStreaming = {
351
370
  id: assistantMessageId,
352
371
  role: 'assistant',
353
372
  content: '',
373
+ status: 'streaming',
354
374
  actions: createMessageActions(assistantMessageId, 'assistant'),
355
375
  },
356
376
  ]);
@@ -359,7 +379,9 @@ export const WithStreaming = {
359
379
  for (let i = 0; i < words.length; i++) {
360
380
  await new Promise((resolve) => setTimeout(resolve, 100));
361
381
  const currentText = words.slice(0, i + 1).join(' ');
362
- setMessages((prev) => prev.map((msg) => msg.id === assistantMessageId ? Object.assign(Object.assign({}, msg), { content: currentText }) : msg));
382
+ const isLast = i === words.length - 1;
383
+ setMessages((prev) => prev.map((msg) => msg.id === assistantMessageId
384
+ ? Object.assign(Object.assign({}, msg), { content: currentText, status: isLast ? 'complete' : 'streaming' }) : msg));
363
385
  }
364
386
  setStatus('ready');
365
387
  };
@@ -391,6 +413,7 @@ export const WithHistory = {
391
413
  id: userMessageId,
392
414
  role: 'user',
393
415
  content: data.content,
416
+ status: 'complete',
394
417
  actions: createMessageActions(userMessageId, 'user'),
395
418
  };
396
419
  setMessages((prev) => [...prev, userMessage]);
@@ -455,6 +478,7 @@ export const WithI18nConfig = {
455
478
  id: Date.now().toString(),
456
479
  role: 'user',
457
480
  content: data.content,
481
+ status: 'complete',
458
482
  };
459
483
  setMessages((prev) => [...prev, userMessage]);
460
484
  };
@@ -486,6 +510,7 @@ export const WithComponentPropsOverride = {
486
510
  id: Date.now().toString(),
487
511
  role: 'user',
488
512
  content: data.content,
513
+ status: 'complete',
489
514
  };
490
515
  setMessages((prev) => [...prev, userMessage]);
491
516
  };
@@ -515,6 +540,7 @@ export const WithContextItems = {
515
540
  id: Date.now().toString(),
516
541
  role: 'user',
517
542
  content: data.content,
543
+ status: 'complete',
518
544
  };
519
545
  setMessages((prev) => [...prev, userMessage]);
520
546
  };
@@ -553,6 +579,7 @@ export const WithContextItemsAndIndicator = {
553
579
  id: Date.now().toString(),
554
580
  role: 'user',
555
581
  content: data.content,
582
+ status: 'complete',
556
583
  };
557
584
  setMessages((prev) => [...prev, userMessage]);
558
585
  };
@@ -595,6 +622,7 @@ export const ErrorState = {
595
622
  id: userMessageId,
596
623
  role: 'user',
597
624
  content: data.content,
625
+ status: 'complete',
598
626
  actions: createMessageActions(userMessageId, 'user'),
599
627
  };
600
628
  setMessages((prev) => [...prev, userMessage]);
@@ -636,11 +664,12 @@ export const FullStreamingExample = {
636
664
  role: 'user',
637
665
  content: data.content,
638
666
  timestamp: new Date().toISOString(),
667
+ status: 'complete',
639
668
  actions: createMessageActions(userMessageId, 'user'),
640
669
  };
641
670
  setMessages((prev) => [...prev, userMessage]);
642
- // Start streaming
643
- setStatus('streaming');
671
+ // Show submitted state first
672
+ setStatus('submitted');
644
673
  const abortController = new AbortController();
645
674
  setController(abortController);
646
675
  try {
@@ -654,14 +683,19 @@ export const FullStreamingExample = {
654
683
  // Simulate streaming for demo
655
684
  const assistantMessageId = (Date.now() + 1).toString();
656
685
  const fullResponse = `This is a detailed response to your question: "${data.content}"\n\nIn a production environment, this text would be streamed from an AI model in real-time. The streaming provides several benefits:\n\n1. **Better User Experience**: Users see the response as it's being generated\n2. **Lower Perceived Latency**: The wait feels shorter when content appears incrementally\n3. **Ability to Cancel**: Users can stop generation if they have enough information\n4. **Resource Efficiency**: Responses can be processed as they arrive\n\nThe implementation would use Server-Sent Events (SSE) or streaming fetch API to receive chunks of text from the backend, updating the message content in real-time.`;
686
+ // Wait a bit before starting streaming
687
+ await new Promise((resolve) => setTimeout(resolve, 500));
688
+ // Start streaming
689
+ setStatus('streaming');
657
690
  // Create empty assistant message
658
691
  setMessages((prev) => [
659
692
  ...prev,
660
693
  {
661
694
  id: assistantMessageId,
662
695
  role: 'assistant',
663
- content: '',
696
+ content: ' ',
664
697
  timestamp: new Date().toISOString(),
698
+ status: 'streaming',
665
699
  actions: createMessageActions(assistantMessageId, 'assistant'),
666
700
  },
667
701
  ]);
@@ -673,7 +707,9 @@ export const FullStreamingExample = {
673
707
  }
674
708
  await new Promise((resolve) => setTimeout(resolve, 50));
675
709
  const currentText = words.slice(0, i + 1).join(' ');
676
- setMessages((prev) => prev.map((msg) => msg.id === assistantMessageId ? Object.assign(Object.assign({}, msg), { content: currentText }) : msg));
710
+ const isLast = i === words.length - 1;
711
+ setMessages((prev) => prev.map((msg) => msg.id === assistantMessageId
712
+ ? Object.assign(Object.assign({}, msg), { content: currentText, status: isLast ? 'complete' : 'streaming' }) : msg));
677
713
  }
678
714
  }
679
715
  catch (error) {
@@ -52,6 +52,7 @@ const sampleMessages = [
52
52
  role: 'user',
53
53
  content: 'Hello! Can you help me with React?',
54
54
  timestamp: '2024-01-01T12:00:00Z',
55
+ status: 'complete',
55
56
  actions: sampleActions,
56
57
  },
57
58
  {
@@ -59,6 +60,7 @@ const sampleMessages = [
59
60
  role: 'assistant',
60
61
  content: 'Of course! I would be happy to help you with React. What would you like to know?',
61
62
  timestamp: '2024-01-01T12:00:05Z',
63
+ status: 'complete',
62
64
  actions: sampleActions,
63
65
  },
64
66
  {
@@ -66,6 +68,7 @@ const sampleMessages = [
66
68
  role: 'user',
67
69
  content: 'How do I use hooks?',
68
70
  timestamp: '2024-01-01T12:01:00Z',
71
+ status: 'complete',
69
72
  actions: sampleActions,
70
73
  },
71
74
  {
@@ -73,6 +76,7 @@ const sampleMessages = [
73
76
  role: 'assistant',
74
77
  content: 'React Hooks are functions that let you use state and other React features without writing a class. The most commonly used hooks are:\n\n1. **useState** - for managing component state\n2. **useEffect** - for side effects\n3. **useContext** - for consuming context\n\nWould you like examples of how to use any of these?',
75
78
  timestamp: '2024-01-01T12:01:10Z',
79
+ status: 'complete',
76
80
  actions: sampleActions,
77
81
  },
78
82
  ];
@@ -169,6 +173,7 @@ export const WithManyMessages = {
169
173
  role: 'user',
170
174
  content: 'Can you show me an example?',
171
175
  timestamp: '2024-01-01T12:02:00Z',
176
+ status: 'complete',
172
177
  actions: sampleActions,
173
178
  },
174
179
  {
@@ -193,6 +198,7 @@ function Counter() {
193
198
 
194
199
  This component maintains a count state and updates it when the button is clicked.`,
195
200
  timestamp: '2024-01-01T12:02:05Z',
201
+ status: 'complete',
196
202
  actions: sampleActions,
197
203
  },
198
204
  ],
@@ -212,6 +218,7 @@ export const Interactive = {
212
218
  role: 'user',
213
219
  content,
214
220
  timestamp: new Date().toISOString(),
221
+ status: 'complete',
215
222
  };
216
223
  setMessages([userMessage]);
217
224
  setView('chat');
@@ -222,6 +229,7 @@ export const Interactive = {
222
229
  role: 'assistant',
223
230
  content: `I received your message: "${content}". How can I help you further?`,
224
231
  timestamp: new Date().toISOString(),
232
+ status: 'complete',
225
233
  };
226
234
  setMessages((prev) => [...prev, assistantMessage]);
227
235
  }, 1000);
@@ -250,6 +258,7 @@ export const LongConversation = {
250
258
  role: 'user',
251
259
  content: 'Can you show me an example?',
252
260
  timestamp: '2024-01-01T12:02:00Z',
261
+ status: 'complete',
253
262
  actions: sampleActions,
254
263
  },
255
264
  {
@@ -274,6 +283,7 @@ function Counter() {
274
283
 
275
284
  This component maintains a count state and updates it when the button is clicked.`,
276
285
  timestamp: '2024-01-01T12:02:05Z',
286
+ status: 'complete',
277
287
  actions: sampleActions,
278
288
  },
279
289
  {
@@ -281,6 +291,7 @@ This component maintains a count state and updates it when the button is clicked
281
291
  role: 'user',
282
292
  content: 'What about useEffect?',
283
293
  timestamp: '2024-01-01T12:03:00Z',
294
+ status: 'complete',
284
295
  actions: sampleActions,
285
296
  },
286
297
  {
@@ -304,6 +315,7 @@ function DataFetcher() {
304
315
  }
305
316
  \`\`\``,
306
317
  timestamp: '2024-01-01T12:03:10Z',
318
+ status: 'complete',
307
319
  actions: sampleActions,
308
320
  },
309
321
  ],
@@ -1,14 +1,4 @@
1
- var __rest = (this && this.__rest) || function (s, e) {
2
- var t = {};
3
- for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
4
- t[p] = s[p];
5
- if (s != null && typeof Object.getOwnPropertySymbols === "function")
6
- for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
7
- if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
8
- t[p[i]] = s[p[i]];
9
- }
10
- return t;
11
- };
1
+ import { __rest } from "tslib";
12
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
3
  import { useRef, useState } from 'react';
14
4
  import { ClockArrowRotateLeft } from '@gravity-ui/icons';
@@ -1,7 +1,4 @@
1
1
  import './ContentWrapper.scss';
2
- type Props = React.PropsWithChildren<{
3
- width?: string;
4
- height?: string;
5
- }>;
6
- export declare function ContentWrapper({ children, width, height }: Props): import("react/jsx-runtime").JSX.Element;
2
+ type Props = React.PropsWithChildren<React.CSSProperties>;
3
+ export declare function ContentWrapper(props: Props): import("react/jsx-runtime").JSX.Element;
7
4
  export {};
@@ -1,7 +1,9 @@
1
+ import { __rest } from "tslib";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { cn } from '../../utils/cn';
3
4
  import './ContentWrapper.scss';
4
5
  const b = cn('content-wrapper');
5
- export function ContentWrapper({ children, width, height }) {
6
- return (_jsx("div", { className: b(), style: { width, height }, children: children }));
6
+ export function ContentWrapper(props) {
7
+ const { children } = props, style = __rest(props, ["children"]);
8
+ return (_jsx("div", { className: b(), style: style, children: children }));
7
9
  }
@@ -1,2 +1,3 @@
1
1
  export * from './useDateFormatter';
2
2
  export * from './useToolMessage';
3
+ export * from './useSmartScroll';
@@ -1,2 +1,3 @@
1
1
  export * from './useDateFormatter';
2
2
  export * from './useToolMessage';
3
+ export * from './useSmartScroll';
@@ -0,0 +1,7 @@
1
+ import { type RefObject } from 'react';
2
+ export interface UseSmartScrollReturn<T extends HTMLElement> {
3
+ containerRef: RefObject<T>;
4
+ endRef: RefObject<T>;
5
+ scrollToBottom: () => void;
6
+ }
7
+ export declare function useSmartScroll<T extends HTMLElement>(isStreaming?: boolean, messagesCount?: number): UseSmartScrollReturn<T>;
@@ -0,0 +1,70 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ const SCROLL_THRESHOLD = 10;
3
+ export function useSmartScroll(isStreaming = false, messagesCount = 0) {
4
+ const containerRef = useRef(null);
5
+ const endRef = useRef(null);
6
+ const [userScrolledUp, setUserScrolledUp] = useState(false);
7
+ const prevMessagesCount = useRef(messagesCount);
8
+ const checkIfUserScrolledUp = useCallback(() => {
9
+ const container = containerRef.current;
10
+ if (!container)
11
+ return false;
12
+ const { scrollTop, scrollHeight, clientHeight } = container;
13
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
14
+ return distanceFromBottom > SCROLL_THRESHOLD;
15
+ }, []);
16
+ const scrollToBottom = useCallback((behavior = 'instant') => {
17
+ if (!userScrolledUp) {
18
+ const end = endRef.current;
19
+ if (end) {
20
+ end.scrollIntoView({ behavior, block: 'end' });
21
+ }
22
+ }
23
+ }, [userScrolledUp]);
24
+ // Handle user scroll events
25
+ useEffect(() => {
26
+ const container = containerRef.current;
27
+ if (!container) {
28
+ return undefined;
29
+ }
30
+ const handleScroll = () => {
31
+ const scrolledUp = checkIfUserScrolledUp();
32
+ setUserScrolledUp(scrolledUp);
33
+ };
34
+ container.addEventListener('scroll', handleScroll, { passive: true });
35
+ return () => {
36
+ container.removeEventListener('scroll', handleScroll);
37
+ };
38
+ }, [checkIfUserScrolledUp]);
39
+ // Handle DOM mutations during streaming
40
+ useEffect(() => {
41
+ const container = containerRef.current;
42
+ if (!container || !isStreaming) {
43
+ return undefined;
44
+ }
45
+ const observer = new MutationObserver(() => {
46
+ scrollToBottom('instant');
47
+ });
48
+ observer.observe(container, {
49
+ childList: true,
50
+ subtree: true,
51
+ attributes: true,
52
+ characterData: true,
53
+ });
54
+ return () => {
55
+ observer.disconnect();
56
+ };
57
+ }, [isStreaming, scrollToBottom]);
58
+ // Handle new messages
59
+ useEffect(() => {
60
+ if (messagesCount > prevMessagesCount.current) {
61
+ scrollToBottom('smooth');
62
+ }
63
+ prevMessagesCount.current = messagesCount;
64
+ }, [messagesCount, scrollToBottom]);
65
+ return {
66
+ containerRef,
67
+ endRef,
68
+ scrollToBottom,
69
+ };
70
+ }
@@ -1,4 +1,15 @@
1
+ import type { IconData } from '@gravity-ui/uikit';
1
2
  import type { TAssistantMessage, TChatMessage, TMessageContent, TMessageContentUnion, TMessageMetadata, TUserMessage } from '../types';
2
3
  export declare function isUserMessage<Metadata = TMessageMetadata, TCustomMessageContent extends TMessageContent = never>(message: TChatMessage<TCustomMessageContent, Metadata>): message is TUserMessage<Metadata>;
3
4
  export declare function isAssistantMessage<Metadata = TMessageMetadata, TCustomMessageContent extends TMessageContent = never>(message: TChatMessage<TCustomMessageContent, Metadata>): message is TAssistantMessage<TCustomMessageContent, Metadata>;
4
5
  export declare function normalizeContent<TCustomMessageContent extends TMessageContent = never>(content: TAssistantMessage<TCustomMessageContent, TMessageMetadata>['content']): TMessageContentUnion<TCustomMessageContent>[];
6
+ export type DefaultMessageAction<TMessage> = {
7
+ type: string;
8
+ onClick: (message: TMessage) => void;
9
+ icon?: IconData;
10
+ };
11
+ export declare function resolveMessageActions<TMessage extends TChatMessage<TMessageContent, TMessageMetadata>>(message: TMessage, defaultActions?: DefaultMessageAction<TMessage>[]): Array<{
12
+ type: string;
13
+ onClick: () => void;
14
+ icon?: IconData;
15
+ }> | undefined;
@@ -23,3 +23,12 @@ export function normalizeContent(content) {
23
23
  }
24
24
  return [content];
25
25
  }
26
+ export function resolveMessageActions(message, defaultActions) {
27
+ if (message.actions) {
28
+ return message.actions;
29
+ }
30
+ if (defaultActions) {
31
+ return defaultActions.map((action) => (Object.assign(Object.assign({}, action), { onClick: () => action.onClick(message) })));
32
+ }
33
+ return undefined;
34
+ }
package/package.json CHANGED
@@ -1,12 +1,23 @@
1
1
  {
2
2
  "name": "@gravity-ui/aikit",
3
- "version": "0.0.1",
4
- "description": "",
3
+ "version": "0.1.1",
4
+ "description": "Gravity UI base kit for building ai assistant chats",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
7
8
  "files": [
8
9
  "dist"
9
10
  ],
11
+ "engines": {
12
+ "node": ">= 20",
13
+ "npm": ">= 9",
14
+ "yarn": "Please use npm instead of yarn to install dependencies",
15
+ "pnpm": "Please use npm instead of pnpm to install dependencies"
16
+ },
17
+ "sideEffects": [
18
+ "*.css",
19
+ "*.scss"
20
+ ],
10
21
  "repository": {
11
22
  "type": "git",
12
23
  "url": "git+https://github.com/gravity-ui/aikit.git"
@@ -26,10 +37,11 @@
26
37
  "lint:styles:fix": "stylelint **/*.scss --fix",
27
38
  "lint:prettier": "prettier --check '**/*.{js,jsx,ts,tsx,css,scss,json,yaml,yml,md,mdx}'",
28
39
  "lint:prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx,css,scss,json,yaml,yml,md,mdx}'",
29
- "test": "jest --passWithNoTests",
40
+ "test": "npm run test:unit",
30
41
  "build": "tsc",
31
42
  "start": "TS_NODE_PROJECT=.storybook/tsconfig.json storybook dev",
32
43
  "prepublishOnly": "npm run build",
44
+ "test:unit": "jest --passWithNoTests",
33
45
  "playwright:install": "playwright install chromium webkit --with-deps",
34
46
  "playwright": "playwright test --config=playwright-ct.config.ts",
35
47
  "playwright:update": "npm run playwright -- -u all",