@gravity-ui/aikit 1.0.2 → 1.2.0

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.
Files changed (25) hide show
  1. package/README.md +14 -4
  2. package/dist/components/atoms/MarkdownRenderer/MarkdownRenderer.d.ts +4 -2
  3. package/dist/components/atoms/MarkdownRenderer/MarkdownRenderer.js +23 -18
  4. package/dist/components/atoms/MarkdownRenderer/MarkdownRenderer.scss +1 -0
  5. package/dist/components/atoms/MarkdownRenderer/__stories__/MarkdownRenderer.stories.d.ts +3 -2
  6. package/dist/components/atoms/MarkdownRenderer/__stories__/MarkdownRenderer.stories.js +51 -1
  7. package/dist/components/organisms/AssistantMessage/AssistantMessage.d.ts +2 -1
  8. package/dist/components/organisms/AssistantMessage/AssistantMessage.js +3 -3
  9. package/dist/components/organisms/AssistantMessage/defaultMessageTypeRegistry.d.ts +1 -1
  10. package/dist/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js +2 -2
  11. package/dist/components/organisms/MessageList/MessageList.d.ts +2 -1
  12. package/dist/components/organisms/MessageList/MessageList.js +3 -3
  13. package/dist/components/organisms/UserMessage/index.d.ts +1 -0
  14. package/dist/components/organisms/UserMessage/index.js +2 -2
  15. package/dist/components/pages/ChatContainer/ChatContainer.js +12 -2
  16. package/dist/components/pages/ChatContainer/types.d.ts +2 -0
  17. package/dist/hooks/useMarkdownTransform.d.ts +3 -0
  18. package/dist/hooks/useMarkdownTransform.js +48 -0
  19. package/dist/hooks/useRemend.d.ts +1 -0
  20. package/dist/hooks/useRemend.js +18 -0
  21. package/dist/utils/markdownUtils.d.ts +2 -0
  22. package/dist/utils/markdownUtils.js +19 -0
  23. package/dist/utils/parse-blocks.d.ts +1 -0
  24. package/dist/utils/parse-blocks.js +62 -0
  25. package/package.json +9 -7
package/README.md CHANGED
@@ -1,10 +1,20 @@
1
- # @gravity-ui/aikit
1
+ # AIKit · [![npm package](https://img.shields.io/npm/v/@gravity-ui/aikit?logo=npm)](https://www.npmjs.com/package/@gravity-ui/aikit) [![CI](https://img.shields.io/github/actions/workflow/status/gravity-ui/aikit/.github/workflows/ci.yml?branch=main&label=CI&logo=github)](https://github.com/gravity-ui/aikit/actions/workflows/ci.yml?query=branch:main) [![storybook](https://img.shields.io/badge/Storybook-deployed-ff4685?logo=storybook)](https://preview.gravity-ui.com/aikit/?path=/docs/pages-chatcontainer--docs)
2
2
 
3
- [![npm package](https://img.shields.io/npm/v/@gravity-ui/aikit?logo=npm)](https://www.npmjs.com/package/@gravity-ui/aikit) [![CI](https://img.shields.io/github/actions/workflow/status/gravity-ui/aikit/.github/workflows/ci.yml?branch=main&label=CI&logo=github)](https://github.com/gravity-ui/aikit/actions/workflows/ci.yml?query=branch:main) [![storybook](https://img.shields.io/badge/Storybook-deployed-ff4685?logo=storybook)](https://preview.gravity-ui.com/aikit/?path=/docs/pages-chatcontainer--docs)
3
+ UI component library for AI chats built with Atomic Design principles.
4
4
 
5
- ---
5
+ <!--GITHUB_BLOCK-->
6
6
 
7
- UI component library for AI chats built with Atomic Design principles.
7
+ ![Cover image](https://raw.githubusercontent.com/gravity-ui/aikit/main/docs/assets/aikit_cover.png)
8
+
9
+ ## Resources
10
+
11
+ ### ![Globe Logo Light](https://raw.githubusercontent.com/gravity-ui/aikit/main/docs/assets/globe_light.svg#gh-light-mode-only) ![Globe Logo Dark](https://raw.githubusercontent.com/gravity-ui/aikit/main/docs/assets/globe_dark.svg#gh-dark-mode-only) [Website](https://gravity-ui.com/libraries/aikit)
12
+
13
+ ### ![Storybook Logo Light](https://raw.githubusercontent.com/gravity-ui/aikit/main/docs/assets/storybook_light.svg#gh-light-mode-only) ![Storybook Logo Dark](https://raw.githubusercontent.com/gravity-ui/aikit/main/docs/assets/storybook_dark.svg#gh-dark-mode-only) [Storybook](https://preview.gravity-ui.com/aikit/)
14
+
15
+ ### ![Community Logo Light](https://raw.githubusercontent.com/gravity-ui/aikit/main/docs/assets/telegram_light.svg#gh-light-mode-only) ![Community Logo Dark](https://raw.githubusercontent.com/gravity-ui/aikit/main/docs/assets/telegram_dark.svg#gh-dark-mode-only) [Community](https://t.me/gravity_ui)
16
+
17
+ <!--/GITHUB_BLOCK-->
8
18
 
9
19
  ## Description
10
20
 
@@ -1,4 +1,3 @@
1
- import '@diplodoc/transform/dist/js/yfm';
2
1
  import { OptionsType } from '@diplodoc/transform/lib/typings';
3
2
  import './MarkdownRenderer.scss';
4
3
  export interface MarkdownRendererProps {
@@ -6,5 +5,8 @@ export interface MarkdownRendererProps {
6
5
  className?: string;
7
6
  qa?: string;
8
7
  transformOptions?: OptionsType;
8
+ shouldParseIncompleteMarkdown?: boolean;
9
9
  }
10
- export declare function MarkdownRenderer({ content, className, qa, transformOptions, }: MarkdownRendererProps): import("react/jsx-runtime").JSX.Element;
10
+ declare function MarkdownRendererComponent({ content, className, qa, transformOptions, shouldParseIncompleteMarkdown, }: MarkdownRendererProps): import("react/jsx-runtime").JSX.Element;
11
+ export declare const MarkdownRenderer: import("react").MemoExoticComponent<typeof MarkdownRendererComponent>;
12
+ export {};
@@ -1,24 +1,29 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useMemo } from 'react';
3
- import transform from '@diplodoc/transform';
4
- import '@diplodoc/transform/dist/js/yfm';
2
+ import { memo } from 'react';
3
+ import { useMarkdownTransform } from '../../../hooks/useMarkdownTransform';
4
+ import { useRemend } from '../../../hooks/useRemend';
5
5
  import { block } from '../../../utils/cn';
6
+ import { areOptionsEqual } from '../../../utils/markdownUtils';
6
7
  import './MarkdownRenderer.scss';
7
8
  const b = block('markdown-renderer');
8
- export function MarkdownRenderer({ content, className, qa, transformOptions, }) {
9
- const html = useMemo(() => {
10
- if (typeof content !== 'string') {
11
- return '';
12
- }
13
- try {
14
- const result = transform(content, transformOptions);
15
- return result.result.html;
16
- }
17
- catch (error) {
18
- // eslint-disable-next-line no-console
19
- console.error('Error transforming markdown:', error);
20
- return '';
21
- }
22
- }, [content, transformOptions]);
9
+ function MarkdownRendererComponent({ content, className, qa, transformOptions, shouldParseIncompleteMarkdown = false, }) {
10
+ const closedContent = useRemend(content, shouldParseIncompleteMarkdown);
11
+ const html = useMarkdownTransform(closedContent, transformOptions);
23
12
  return (_jsx("div", { className: b(null, [className, 'yfm']), "data-qa": qa, dangerouslySetInnerHTML: { __html: html } }));
24
13
  }
14
+ export const MarkdownRenderer = memo(MarkdownRendererComponent, (prevProps, nextProps) => {
15
+ if (prevProps.content !== nextProps.content) {
16
+ return false;
17
+ }
18
+ if (prevProps.shouldParseIncompleteMarkdown !== nextProps.shouldParseIncompleteMarkdown) {
19
+ return false;
20
+ }
21
+ if (prevProps.className !== nextProps.className) {
22
+ return false;
23
+ }
24
+ if (prevProps.qa !== nextProps.qa) {
25
+ return false;
26
+ }
27
+ return areOptionsEqual(prevProps.transformOptions, nextProps.transformOptions);
28
+ });
29
+ MarkdownRenderer.displayName = 'MarkdownRenderer';
@@ -6,4 +6,5 @@ $block: '.#{variables.$ns}markdown-renderer';
6
6
 
7
7
  #{$block} {
8
8
  max-width: 100%;
9
+ color: var(--g-color-text-primary);
9
10
  }
@@ -1,6 +1,7 @@
1
- import { Meta, StoryFn } from '@storybook/react-webpack5';
2
- import { MarkdownRendererProps } from '..';
1
+ import { Meta, StoryFn, StoryObj } from '@storybook/react-webpack5';
2
+ import { MarkdownRenderer, MarkdownRendererProps } from '..';
3
3
  declare const _default: Meta;
4
4
  export default _default;
5
5
  export declare const Playground: StoryFn<MarkdownRendererProps>;
6
6
  export declare const WithTransformOptions: StoryFn<MarkdownRendererProps>;
7
+ export declare const WithParsingIncompleteMarkdown: StoryObj<typeof MarkdownRenderer>;
@@ -1,5 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
3
  import { MarkdownRenderer } from '..';
4
+ import { ContentWrapper } from '../../../../demo/ContentWrapper';
5
+ import { Showcase } from '../../../../demo/Showcase';
6
+ import { ShowcaseItem } from '../../../../demo/ShowcaseItem';
3
7
  import MDXDocs from './Docs.mdx';
4
8
  export default {
5
9
  title: 'atoms/MarkdownRenderer',
@@ -24,6 +28,9 @@ export default {
24
28
  },
25
29
  },
26
30
  };
31
+ const defaultDecorators = [
32
+ (Story) => (_jsx(Showcase, { children: _jsx(Story, {}) })),
33
+ ];
27
34
  export const Playground = (args) => _jsx(MarkdownRenderer, Object.assign({}, args));
28
35
  Playground.args = {
29
36
  content: '# Hello World\n\nThis is **bold** text and this is *italic* text.',
@@ -47,3 +54,46 @@ export const WithTransformOptions = () => {
47
54
  const content = `# Custom Plugin Example\n\nThis is **bold text** with custom styling applied via plugin.`;
48
55
  return _jsx(MarkdownRenderer, { content: content, transformOptions: transformOptions });
49
56
  };
57
+ const STREAMING_CONTENT = `**This is a very long bold text that keeps going and going without a clear end, so you can see how unterminated bold blocks are handled by the renderer.**
58
+
59
+ *Here is an equally lengthy italicized sentence that stretches on and on, never quite reaching a conclusion, so you can observe how unterminated italic blocks behave in a streaming Markdown context, particularly when the content is verbose.*
60
+
61
+ \`This is a long inline code block that should be unterminated and continues for quite a while, including some code-like content such as const foo = "bar"; and more, to see how the parser deals with it when the code block is not properly closed\`
62
+
63
+ [This is a very long link text that is unterminated and keeps going to show how unterminated links are rendered in the preview, especially when the link text is verbose and the URL is missing or incomplete](https://gravity-ui.com/ru/libraries/aikit)`;
64
+ function StreamingMarkdownComparison() {
65
+ const tokens = useMemo(() => STREAMING_CONTENT.split(''), []);
66
+ const [content, setContent] = useState('');
67
+ const intervalRef = useRef(null);
68
+ useEffect(() => {
69
+ if (intervalRef.current) {
70
+ clearInterval(intervalRef.current);
71
+ intervalRef.current = null;
72
+ }
73
+ setContent('');
74
+ let currentContent = '';
75
+ let index = 0;
76
+ intervalRef.current = setInterval(() => {
77
+ if (index < tokens.length) {
78
+ currentContent += tokens[index];
79
+ setContent(currentContent);
80
+ index += 1;
81
+ }
82
+ if (index >= tokens.length && intervalRef.current) {
83
+ clearInterval(intervalRef.current);
84
+ intervalRef.current = null;
85
+ }
86
+ }, 15);
87
+ return () => {
88
+ if (intervalRef.current) {
89
+ clearInterval(intervalRef.current);
90
+ intervalRef.current = null;
91
+ }
92
+ };
93
+ }, [tokens]);
94
+ return (_jsxs(_Fragment, { children: [_jsx(ShowcaseItem, { title: "Without shouldParseIncompleteMarkdown (default)", children: _jsx(ContentWrapper, { width: "400px", children: _jsx(MarkdownRenderer, { content: content, shouldParseIncompleteMarkdown: false }) }) }), _jsx(ShowcaseItem, { title: "With shouldParseIncompleteMarkdown", children: _jsx(ContentWrapper, { width: "400px", children: _jsx(MarkdownRenderer, { content: content, shouldParseIncompleteMarkdown: true }) }) })] }));
95
+ }
96
+ export const WithParsingIncompleteMarkdown = {
97
+ render: () => _jsx(StreamingMarkdownComparison, {}),
98
+ decorators: defaultDecorators,
99
+ };
@@ -7,8 +7,9 @@ type AssistantMessagePick<TContent extends TMessageContent> = Pick<TAssistantMes
7
7
  export type AssistantMessageProps<TContent extends TMessageContent = never> = BaseMessagePick & AssistantMessagePick<TContent> & {
8
8
  messageRendererRegistry?: MessageRendererRegistry;
9
9
  transformOptions?: OptionsType;
10
+ shouldParseIncompleteMarkdown?: boolean;
10
11
  className?: string;
11
12
  qa?: string;
12
13
  };
13
- export declare function AssistantMessage<TContent extends TMessageContent = never>({ content, actions, timestamp, id, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, className, qa, }: AssistantMessageProps<TContent>): import("react/jsx-runtime").JSX.Element | null;
14
+ export declare function AssistantMessage<TContent extends TMessageContent = never>({ content, actions, timestamp, id, messageRendererRegistry, transformOptions, shouldParseIncompleteMarkdown, showActionsOnHover, showTimestamp, className, qa, }: AssistantMessageProps<TContent>): import("react/jsx-runtime").JSX.Element | null;
14
15
  export {};
@@ -7,14 +7,14 @@ import { BaseMessage } from '../../molecules/BaseMessage';
7
7
  import { createDefaultMessageRegistry } from './defaultMessageTypeRegistry';
8
8
  import './AssistantMessage.scss';
9
9
  const b = block('assistant-message');
10
- export function AssistantMessage({ content, actions, timestamp, id, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, className, qa, }) {
10
+ export function AssistantMessage({ content, actions, timestamp, id, messageRendererRegistry, transformOptions, shouldParseIncompleteMarkdown, showActionsOnHover, showTimestamp, className, qa, }) {
11
11
  const registry = useMemo(() => {
12
- const defaultRegistry = createDefaultMessageRegistry(transformOptions);
12
+ const defaultRegistry = createDefaultMessageRegistry(transformOptions, shouldParseIncompleteMarkdown);
13
13
  if (messageRendererRegistry) {
14
14
  return mergeMessageRendererRegistries(defaultRegistry, messageRendererRegistry);
15
15
  }
16
16
  return defaultRegistry;
17
- }, [messageRendererRegistry, transformOptions]);
17
+ }, [messageRendererRegistry, transformOptions, shouldParseIncompleteMarkdown]);
18
18
  const parts = normalizeContent(content);
19
19
  if (parts.length === 0) {
20
20
  return null;
@@ -1,3 +1,3 @@
1
1
  import type { OptionsType } from '@diplodoc/transform/lib/typings';
2
2
  import { type MessageRendererRegistry } from '../../../utils/messageTypeRegistry';
3
- export declare function createDefaultMessageRegistry(transformOptions?: OptionsType): MessageRendererRegistry;
3
+ export declare function createDefaultMessageRegistry(transformOptions?: OptionsType, shouldParseIncompleteMarkdown?: boolean): MessageRendererRegistry;
@@ -3,10 +3,10 @@ import { createMessageRendererRegistry, registerMessageRenderer, } from '../../.
3
3
  import { MarkdownRenderer } from '../../atoms/MarkdownRenderer';
4
4
  import { ThinkingMessage } from '../ThinkingMessage';
5
5
  import { ToolMessage } from '../ToolMessage';
6
- export function createDefaultMessageRegistry(transformOptions) {
6
+ export function createDefaultMessageRegistry(transformOptions, shouldParseIncompleteMarkdown) {
7
7
  const registry = createMessageRendererRegistry();
8
8
  registerMessageRenderer(registry, 'text', {
9
- component: ({ part }) => (_jsx(MarkdownRenderer, { content: part.data.text, transformOptions: transformOptions })),
9
+ component: ({ part }) => (_jsx(MarkdownRenderer, { content: part.data.text, transformOptions: transformOptions, shouldParseIncompleteMarkdown: shouldParseIncompleteMarkdown })),
10
10
  });
11
11
  registerMessageRenderer(registry, 'tool', {
12
12
  component: ({ part }) => _jsx(ToolMessage, Object.assign({}, part.data)),
@@ -12,6 +12,7 @@ export type MessageListProps<TContent extends TMessageContent = never> = {
12
12
  onRetry?: () => void;
13
13
  messageRendererRegistry?: MessageRendererRegistry;
14
14
  transformOptions?: OptionsType;
15
+ shouldParseIncompleteMarkdown?: boolean;
15
16
  showActionsOnHover?: boolean;
16
17
  showTimestamp?: boolean;
17
18
  showAvatar?: boolean;
@@ -24,4 +25,4 @@ export type MessageListProps<TContent extends TMessageContent = never> = {
24
25
  hasPreviousMessages?: boolean;
25
26
  onLoadPreviousMessages?: () => void;
26
27
  };
27
- export declare function MessageList<TContent extends TMessageContent = never>({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, loaderStatuses, className, qa, status, errorMessage, onRetry, hasPreviousMessages, onLoadPreviousMessages, }: MessageListProps<TContent>): import("react/jsx-runtime").JSX.Element;
28
+ export declare function MessageList<TContent extends TMessageContent = never>({ messages, messageRendererRegistry, transformOptions, shouldParseIncompleteMarkdown, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, loaderStatuses, className, qa, status, errorMessage, onRetry, hasPreviousMessages, onLoadPreviousMessages, }: MessageListProps<TContent>): import("react/jsx-runtime").JSX.Element;
@@ -9,7 +9,7 @@ import { UserMessage } from '../UserMessage';
9
9
  import { ErrorAlert } from './ErrorAlert';
10
10
  import './MessageList.scss';
11
11
  const b = block('message-list');
12
- export function MessageList({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, loaderStatuses = ['submitted'], className, qa, status, errorMessage, onRetry, hasPreviousMessages = false, onLoadPreviousMessages, }) {
12
+ export function MessageList({ messages, messageRendererRegistry, transformOptions, shouldParseIncompleteMarkdown, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, loaderStatuses = ['submitted'], className, qa, status, errorMessage, onRetry, hasPreviousMessages = false, onLoadPreviousMessages, }) {
13
13
  const isStreaming = status === 'streaming';
14
14
  const isSubmitted = status === 'submitted';
15
15
  const showLoader = status && loaderStatuses.includes(status);
@@ -23,7 +23,7 @@ export function MessageList({ messages, messageRendererRegistry, transformOption
23
23
  const renderMessage = (message, index) => {
24
24
  if (isUserMessage(message)) {
25
25
  const actions = resolveMessageActions(message, userActions);
26
- 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}`));
26
+ return (_jsx(UserMessage, { content: message.content, actions: actions, timestamp: message.timestamp, format: message.format, avatarUrl: message.avatarUrl, transformOptions: transformOptions, shouldParseIncompleteMarkdown: shouldParseIncompleteMarkdown, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp, showAvatar: showAvatar }, message.id || `message-${index}`));
27
27
  }
28
28
  if (isAssistantMessage(message)) {
29
29
  const isLastMessage = index === messages.length - 1;
@@ -32,7 +32,7 @@ export function MessageList({ messages, messageRendererRegistry, transformOption
32
32
  const actions = showActions
33
33
  ? resolveMessageActions(message, assistantActions)
34
34
  : undefined;
35
- 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}`));
35
+ return (_jsx(AssistantMessage, { content: message.content, actions: actions, timestamp: message.timestamp, id: message.id, messageRendererRegistry: messageRendererRegistry, transformOptions: transformOptions, shouldParseIncompleteMarkdown: shouldParseIncompleteMarkdown, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp }, message.id || `message-${index}`));
36
36
  }
37
37
  return null;
38
38
  };
@@ -7,6 +7,7 @@ export type UserMessageProps = Pick<BaseMessageProps, 'actions' | 'showActionsOn
7
7
  showAvatar?: boolean;
8
8
  avatarUrl?: string;
9
9
  transformOptions?: OptionsType;
10
+ shouldParseIncompleteMarkdown?: boolean;
10
11
  className?: string;
11
12
  qa?: string;
12
13
  };
@@ -7,6 +7,6 @@ import { BaseMessage } from '../../molecules/BaseMessage';
7
7
  import './UserMessage.scss';
8
8
  const b = block('user-message');
9
9
  export const UserMessage = (props) => {
10
- const { className, qa, content, actions, showActionsOnHover, showAvatar, avatarUrl = '', timestamp = '', showTimestamp, format = 'plain', transformOptions, } = props;
11
- return (_jsxs("div", { className: b(null, className), "data-qa": qa, children: [showAvatar ? _jsx(Avatar, { imgUrl: avatarUrl, size: "s", view: "filled" }) : null, _jsx(BaseMessage, { role: "user", actions: actions, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp, timestamp: timestamp, children: _jsx(MessageBalloon, { className: modsClassName(b({ format })), children: format === 'markdown' ? (_jsx(MarkdownRenderer, { content: content, transformOptions: transformOptions })) : (content) }) })] }));
10
+ const { className, qa, content, actions, showActionsOnHover, showAvatar, avatarUrl = '', timestamp = '', showTimestamp, format = 'plain', transformOptions, shouldParseIncompleteMarkdown, } = props;
11
+ return (_jsxs("div", { className: b(null, className), "data-qa": qa, children: [showAvatar ? _jsx(Avatar, { imgUrl: avatarUrl, size: "s", view: "filled" }) : null, _jsx(BaseMessage, { role: "user", actions: actions, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp, timestamp: timestamp, children: _jsx(MessageBalloon, { className: modsClassName(b({ format })), children: format === 'markdown' ? (_jsx(MarkdownRenderer, { content: content, transformOptions: transformOptions, shouldParseIncompleteMarkdown: shouldParseIncompleteMarkdown })) : (content) }) })] }));
12
12
  };
@@ -19,7 +19,7 @@ const b = block('chat-container');
19
19
  */
20
20
  export function ChatContainer(props) {
21
21
  var _a, _b;
22
- const { chats = [], messages = [], onSendMessage, onDeleteChat, onCancel, onRetry, status = 'ready', error = null, showActionsOnHover = false, contextItems = [], transformOptions, messageListConfig, headerProps = {}, contentProps = {}, emptyContainerProps = {}, promptInputProps = {}, disclaimerProps = {}, historyProps = {}, welcomeConfig, i18nConfig = {}, hideTitleOnEmptyChat = false, className, headerClassName, contentClassName, footerClassName, qa, } = props;
22
+ const { chats = [], messages = [], onSendMessage, onDeleteChat, onCancel, onRetry, status = 'ready', error = null, showActionsOnHover = false, contextItems = [], transformOptions, shouldParseIncompleteMarkdown, messageListConfig, headerProps = {}, contentProps = {}, emptyContainerProps = {}, promptInputProps = {}, disclaimerProps = {}, historyProps = {}, welcomeConfig, i18nConfig = {}, hideTitleOnEmptyChat = false, className, headerClassName, contentClassName, footerClassName, qa, } = props;
23
23
  const hookState = useChatContainer(props);
24
24
  // Collect i18n texts with overrides
25
25
  const headerTitle = useMemo(() => {
@@ -69,7 +69,17 @@ export function ChatContainer(props) {
69
69
  const messageListProps = useMemo(() => (Object.assign(Object.assign({}, messageListConfig), { messages,
70
70
  status, errorMessage: (messageListConfig === null || messageListConfig === void 0 ? void 0 : messageListConfig.errorMessage) || (error ? { text: error.message } : undefined), onRetry,
71
71
  showActionsOnHover,
72
- transformOptions })), [messages, status, error, onRetry, showActionsOnHover, transformOptions, messageListConfig]);
72
+ transformOptions,
73
+ shouldParseIncompleteMarkdown })), [
74
+ messages,
75
+ status,
76
+ error,
77
+ onRetry,
78
+ showActionsOnHover,
79
+ transformOptions,
80
+ shouldParseIncompleteMarkdown,
81
+ messageListConfig,
82
+ ]);
73
83
  // Build props for PromptInput
74
84
  const finalPromptInputProps = useMemo(() => {
75
85
  var _a, _b, _c, _d;
@@ -127,6 +127,8 @@ export interface ChatContainerProps {
127
127
  contextItems?: ContextItemConfig[];
128
128
  /** Transform options for markdown rendering */
129
129
  transformOptions?: OptionsType;
130
+ /** Should parse incomplete markdown (e.g., during streaming) */
131
+ shouldParseIncompleteMarkdown?: boolean;
130
132
  /** MessageList configuration for actions and loader behavior */
131
133
  messageListConfig?: MessageListConfig;
132
134
  /** Props override for Header component */
@@ -0,0 +1,3 @@
1
+ import '@diplodoc/transform/dist/js/yfm';
2
+ import { OptionsType } from '@diplodoc/transform/lib/typings';
3
+ export declare function useMarkdownTransform(content: string, options?: OptionsType): string;
@@ -0,0 +1,48 @@
1
+ import { useMemo, useRef } from 'react';
2
+ import transform from '@diplodoc/transform';
3
+ import '@diplodoc/transform/dist/js/yfm';
4
+ import { areOptionsEqual } from '../utils/markdownUtils';
5
+ import { parseMarkdownIntoBlocks } from '../utils/parse-blocks';
6
+ export function useMarkdownTransform(content, options) {
7
+ const cacheRef = useRef(new Map());
8
+ const prevOptionsRef = useRef(options);
9
+ return useMemo(() => {
10
+ if (!content) {
11
+ return '';
12
+ }
13
+ const optionsChanged = !areOptionsEqual(prevOptionsRef.current, options);
14
+ if (optionsChanged) {
15
+ cacheRef.current.clear();
16
+ prevOptionsRef.current = options;
17
+ }
18
+ try {
19
+ const blocks = parseMarkdownIntoBlocks(content);
20
+ const cache = cacheRef.current;
21
+ const htmlParts = [];
22
+ for (const block of blocks) {
23
+ let html = cache.get(block);
24
+ if (!html) {
25
+ try {
26
+ const result = transform(block, options);
27
+ html = result.result.html;
28
+ cache.set(block, html);
29
+ }
30
+ catch (_a) {
31
+ html = '';
32
+ }
33
+ }
34
+ htmlParts.push(html);
35
+ }
36
+ const currentBlocksSet = new Set(blocks);
37
+ for (const key of cache.keys()) {
38
+ if (!currentBlocksSet.has(key)) {
39
+ cache.delete(key);
40
+ }
41
+ }
42
+ return htmlParts.join('');
43
+ }
44
+ catch (_b) {
45
+ return '';
46
+ }
47
+ }, [content, options]);
48
+ }
@@ -0,0 +1 @@
1
+ export declare function useRemend(content: string, enabled: boolean): string;
@@ -0,0 +1,18 @@
1
+ import { useMemo } from 'react';
2
+ import remend from 'remend';
3
+ export function useRemend(content, enabled) {
4
+ return useMemo(() => {
5
+ if (typeof content !== 'string' || content === '') {
6
+ return '';
7
+ }
8
+ if (!enabled) {
9
+ return content;
10
+ }
11
+ try {
12
+ return remend(content.trim());
13
+ }
14
+ catch (_a) {
15
+ return content;
16
+ }
17
+ }, [content, enabled]);
18
+ }
@@ -0,0 +1,2 @@
1
+ import { OptionsType } from '@diplodoc/transform/lib/typings';
2
+ export declare function areOptionsEqual(prev?: OptionsType, next?: OptionsType): boolean;
@@ -0,0 +1,19 @@
1
+ export function areOptionsEqual(prev, next) {
2
+ if (prev === next) {
3
+ return true;
4
+ }
5
+ if (!prev || !next) {
6
+ return false;
7
+ }
8
+ const prevKeys = Object.keys(prev);
9
+ const nextKeys = Object.keys(next);
10
+ if (prevKeys.length !== nextKeys.length) {
11
+ return false;
12
+ }
13
+ for (const key of prevKeys) {
14
+ if (prev[key] !== next[key]) {
15
+ return false;
16
+ }
17
+ }
18
+ return true;
19
+ }
@@ -0,0 +1 @@
1
+ export declare const parseMarkdownIntoBlocks: (markdown: string) => string[];
@@ -0,0 +1,62 @@
1
+ import { Lexer } from 'marked';
2
+ const footnoteReferencePattern = /\[\^[^\]\s]{1,200}\](?!:)/;
3
+ const footnoteDefinitionPattern = /\[\^[^\]\s]{1,200}\]:/;
4
+ const closingTagPattern = /<\/(\w+)>/;
5
+ const openingTagPattern = /<(\w+)[\s>]/;
6
+ const handleHtmlBlock = (token, htmlStack, lastBlock) => {
7
+ if (htmlStack.length === 0) {
8
+ return null;
9
+ }
10
+ const merged = lastBlock + token.raw;
11
+ let shouldPopStack = false;
12
+ if (token.type === 'html') {
13
+ const closingTagMatch = token.raw.match(closingTagPattern);
14
+ if (closingTagMatch) {
15
+ const closingTag = closingTagMatch[1];
16
+ if (htmlStack[htmlStack.length - 1] === closingTag) {
17
+ shouldPopStack = true;
18
+ }
19
+ }
20
+ }
21
+ return { merged, shouldPopStack };
22
+ };
23
+ const processOpeningHtmlTag = (token, htmlStack) => {
24
+ if (token.type !== 'html' || !token.block) {
25
+ return;
26
+ }
27
+ const openingTagMatch = token.raw.match(openingTagPattern);
28
+ if (openingTagMatch) {
29
+ const tagName = openingTagMatch[1];
30
+ const hasClosingTag = token.raw.includes(`</${tagName}>`);
31
+ if (!hasClosingTag) {
32
+ htmlStack.push(tagName);
33
+ }
34
+ }
35
+ };
36
+ export const parseMarkdownIntoBlocks = (markdown) => {
37
+ const hasFootnoteReference = footnoteReferencePattern.test(markdown);
38
+ const hasFootnoteDefinition = footnoteDefinitionPattern.test(markdown);
39
+ if (hasFootnoteReference || hasFootnoteDefinition) {
40
+ return [markdown];
41
+ }
42
+ const tokens = Lexer.lex(markdown, { gfm: true });
43
+ const mergedBlocks = [];
44
+ const htmlStack = [];
45
+ for (const token of tokens) {
46
+ const currentBlock = token.raw;
47
+ if (mergedBlocks.length > 0) {
48
+ const lastBlock = mergedBlocks[mergedBlocks.length - 1];
49
+ const htmlResult = handleHtmlBlock(token, htmlStack, lastBlock);
50
+ if (htmlResult) {
51
+ mergedBlocks[mergedBlocks.length - 1] = htmlResult.merged;
52
+ if (htmlResult.shouldPopStack) {
53
+ htmlStack.pop();
54
+ }
55
+ continue;
56
+ }
57
+ }
58
+ processOpeningHtmlTag(token, htmlStack);
59
+ mergedBlocks.push(currentBlock);
60
+ }
61
+ return mergedBlocks;
62
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravity-ui/aikit",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Gravity UI base kit for building ai assistant chats",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
@@ -73,13 +73,13 @@
73
73
  "@babel/preset-typescript": "^7.28.5",
74
74
  "@commitlint/cli": "^19.0.3",
75
75
  "@commitlint/config-conventional": "^19.0.3",
76
+ "@diplodoc/transform": "^4.63.3",
76
77
  "@gravity-ui/eslint-config": "^3.1.1",
78
+ "@gravity-ui/i18n": "^1.8.0",
79
+ "@gravity-ui/icons": "^2.16.0",
77
80
  "@gravity-ui/prettier-config": "^1.1.0",
78
81
  "@gravity-ui/stylelint-config": "^4.0.1",
79
82
  "@gravity-ui/tsconfig": "^1.0.0",
80
- "@diplodoc/transform": "^4.63.3",
81
- "@gravity-ui/i18n": "^1.8.0",
82
- "@gravity-ui/icons": "^2.16.0",
83
83
  "@gravity-ui/uikit": "^7.25.0",
84
84
  "@playwright/experimental-ct-react": "^1.56.1",
85
85
  "@playwright/test": "^1.56.1",
@@ -118,13 +118,13 @@
118
118
  "typescript": "^5.4.2"
119
119
  },
120
120
  "peerDependencies": {
121
- "react": "^18.0.0 || ^19.0.0",
122
- "react-dom": "^18.0.0 || ^19.0.0",
123
121
  "@diplodoc/transform": "^4.63.3",
124
122
  "@gravity-ui/i18n": "^1.8.0",
125
123
  "@gravity-ui/icons": "^2.16.0",
126
124
  "@gravity-ui/uikit": "^7.25.0",
127
- "highlight.js": "^11.11.1"
125
+ "highlight.js": "^11.11.1",
126
+ "react": "^18.0.0 || ^19.0.0",
127
+ "react-dom": "^18.0.0 || ^19.0.0"
128
128
  },
129
129
  "nano-staged": {
130
130
  "*.{scss}": [
@@ -140,7 +140,9 @@
140
140
  "dependencies": {
141
141
  "@bem-react/classname": "^1.7.0",
142
142
  "dayjs": "^1.11.19",
143
+ "marked": "^17.0.1",
143
144
  "react-window": "^2.2.1",
145
+ "remend": "^1.0.1",
144
146
  "uuid": "^13.0.0"
145
147
  }
146
148
  }