mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
♻️ refactor(perf): user message renderer (#13108)
refactor(perf): user message renderer
This commit is contained in:
@@ -252,7 +252,7 @@
|
||||
"@lobehub/chat-plugin-sdk": "^1.32.4",
|
||||
"@lobehub/chat-plugins-gateway": "^1.9.0",
|
||||
"@lobehub/desktop-ipc-typings": "workspace:*",
|
||||
"@lobehub/editor": "^4.0.0",
|
||||
"@lobehub/editor": "^4.2.0",
|
||||
"@lobehub/icons": "^5.0.0",
|
||||
"@lobehub/market-sdk": "^0.31.3",
|
||||
"@lobehub/tts": "^5.1.2",
|
||||
@@ -530,4 +530,4 @@
|
||||
"stylelint-config-clean-order": "7.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Tag, Tooltip } from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
import { CLICK_COMMAND, COMMAND_PRIORITY_LOW, type LexicalEditor } from 'lexical';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { ActionTagNode } from './ActionTagNode';
|
||||
import { styles } from './style';
|
||||
import type { ActionTagCategory } from './types';
|
||||
import { ActionTagView } from './ActionTagView';
|
||||
|
||||
interface ActionTagProps {
|
||||
editor: LexicalEditor;
|
||||
@@ -14,39 +10,8 @@ interface ActionTagProps {
|
||||
node: ActionTagNode;
|
||||
}
|
||||
|
||||
const CATEGORY_COLOR: Record<ActionTagCategory, string> = {
|
||||
command: 'purple',
|
||||
skill: 'blue',
|
||||
tool: 'gold',
|
||||
};
|
||||
|
||||
const CATEGORY_I18N_KEY: Record<ActionTagCategory, string> = {
|
||||
command: 'actionTag.category.command',
|
||||
skill: 'actionTag.category.skill',
|
||||
tool: 'actionTag.category.tool',
|
||||
};
|
||||
|
||||
const CATEGORY_TOOLTIP_I18N_KEY: Record<ActionTagCategory, string> = {
|
||||
command: 'actionTag.tooltip.command',
|
||||
skill: 'actionTag.tooltip.skill',
|
||||
tool: 'actionTag.tooltip.tool',
|
||||
};
|
||||
|
||||
const CATEGORY_STYLE_KEY: Record<ActionTagCategory, 'commandTag' | 'skillTag' | 'toolTag'> = {
|
||||
command: 'commandTag',
|
||||
skill: 'skillTag',
|
||||
tool: 'toolTag',
|
||||
};
|
||||
|
||||
const ActionTag = memo<ActionTagProps>(({ node, editor, label }) => {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const { t } = useTranslation('editor');
|
||||
|
||||
const category = node.actionCategory;
|
||||
const categoryLabel = t(CATEGORY_I18N_KEY[category] as any);
|
||||
const categoryDescription = t(CATEGORY_TOOLTIP_I18N_KEY[category] as any);
|
||||
const color = CATEGORY_COLOR[category];
|
||||
const styleKey = CATEGORY_STYLE_KEY[category];
|
||||
|
||||
const onClick = useCallback((payload: MouseEvent) => {
|
||||
if (payload.target === spanRef.current || spanRef.current?.contains(payload.target as Node)) {
|
||||
@@ -60,20 +25,8 @@ const ActionTag = memo<ActionTagProps>(({ node, editor, label }) => {
|
||||
}, [editor, onClick]);
|
||||
|
||||
return (
|
||||
<span className={cx(styles[styleKey])} ref={spanRef}>
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{label}</div>
|
||||
<div>{categoryLabel}</div>
|
||||
<div>{categoryDescription}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag color={color} variant="filled">
|
||||
{label}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<span ref={spanRef}>
|
||||
<ActionTagView category={node.actionCategory} label={label} />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { addClassNamesToElement } from '@lexical/utils';
|
||||
import { getKernelFromEditor } from '@lobehub/editor';
|
||||
import type { HeadlessRenderableNode, HeadlessRenderContext } from '@lobehub/editor/renderer';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
DecoratorNode,
|
||||
@@ -11,7 +12,9 @@ import {
|
||||
type SerializedLexicalNode,
|
||||
type Spread,
|
||||
} from 'lexical';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { ActionTagView } from './ActionTagView';
|
||||
import type { ActionTagCategory, ActionTagType } from './types';
|
||||
|
||||
export type SerializedActionTagNode = Spread<
|
||||
@@ -23,7 +26,7 @@ export type SerializedActionTagNode = Spread<
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
export class ActionTagNode extends DecoratorNode<any> {
|
||||
export class ActionTagNode extends DecoratorNode<any> implements HeadlessRenderableNode {
|
||||
__actionType: ActionTagType;
|
||||
__actionCategory: ActionTagCategory;
|
||||
__actionLabel: string;
|
||||
@@ -121,6 +124,14 @@ export class ActionTagNode extends DecoratorNode<any> {
|
||||
render: decorator.render(this, editor),
|
||||
};
|
||||
}
|
||||
|
||||
renderHeadless({ key }: HeadlessRenderContext) {
|
||||
return createElement(ActionTagView, {
|
||||
category: this.__actionCategory as ActionTagCategory,
|
||||
key,
|
||||
label: this.__actionLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function $createActionTagNode(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Tag, Tooltip } from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { styles } from './style';
|
||||
import type { ActionTagCategory } from './types';
|
||||
|
||||
export interface ActionTagViewProps {
|
||||
category: ActionTagCategory;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CATEGORY_COLOR: Record<ActionTagCategory, string> = {
|
||||
command: 'purple',
|
||||
skill: 'blue',
|
||||
tool: 'gold',
|
||||
};
|
||||
|
||||
const CATEGORY_I18N_KEY: Record<ActionTagCategory, string> = {
|
||||
command: 'actionTag.category.command',
|
||||
skill: 'actionTag.category.skill',
|
||||
tool: 'actionTag.category.tool',
|
||||
};
|
||||
|
||||
const CATEGORY_TOOLTIP_I18N_KEY: Record<ActionTagCategory, string> = {
|
||||
command: 'actionTag.tooltip.command',
|
||||
skill: 'actionTag.tooltip.skill',
|
||||
tool: 'actionTag.tooltip.tool',
|
||||
};
|
||||
|
||||
const CATEGORY_STYLE_KEY: Record<ActionTagCategory, 'commandTag' | 'skillTag' | 'toolTag'> = {
|
||||
command: 'commandTag',
|
||||
skill: 'skillTag',
|
||||
tool: 'toolTag',
|
||||
};
|
||||
|
||||
export const ActionTagView = memo<ActionTagViewProps>(({ category, label }) => {
|
||||
const { t } = useTranslation('editor');
|
||||
|
||||
const categoryLabel = t(CATEGORY_I18N_KEY[category] as any);
|
||||
const categoryDescription = t(CATEGORY_TOOLTIP_I18N_KEY[category] as any);
|
||||
const color = CATEGORY_COLOR[category];
|
||||
const styleKey = CATEGORY_STYLE_KEY[category];
|
||||
|
||||
return (
|
||||
<span className={cx(styles[styleKey])}>
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{label}</div>
|
||||
<div>{categoryLabel}</div>
|
||||
<div>{categoryDescription}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag color={color} variant="filled">
|
||||
{label}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
ActionTagView.displayName = 'ActionTagView';
|
||||
@@ -1,47 +1,14 @@
|
||||
import { Tag } from '@lobehub/ui';
|
||||
import { MessageSquarePlusIcon } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import { TAG_MARGIN_INLINE_END } from '../constants';
|
||||
import type { ReferTopicNode } from './ReferTopicNode';
|
||||
import { ReferTopicView } from './ReferTopicView';
|
||||
|
||||
interface ReferTopicProps {
|
||||
node: ReferTopicNode;
|
||||
}
|
||||
|
||||
const ReferTopic = memo<ReferTopicProps>(({ node }) => {
|
||||
const { t } = useTranslation('topic');
|
||||
const title = node.topicTitle || t('defaultTitle');
|
||||
const switchTopic = useChatStore((s) => s.switchTopic);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (node.topicId) {
|
||||
switchTopic(node.topicId);
|
||||
}
|
||||
},
|
||||
[node.topicId, switchTopic],
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
userSelect: 'none',
|
||||
marginInlineEnd: TAG_MARGIN_INLINE_END,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Tag color="green" icon={<MessageSquarePlusIcon size={12} />} variant="outlined">
|
||||
{title}
|
||||
</Tag>
|
||||
</span>
|
||||
);
|
||||
return <ReferTopicView fallbackTitle={node.topicTitle} topicId={node.topicId} />;
|
||||
});
|
||||
|
||||
ReferTopic.displayName = 'ReferTopic';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { addClassNamesToElement } from '@lexical/utils';
|
||||
import { getKernelFromEditor } from '@lobehub/editor';
|
||||
import type { HeadlessRenderableNode, HeadlessRenderContext } from '@lobehub/editor/renderer';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
DecoratorNode,
|
||||
@@ -11,6 +12,9 @@ import {
|
||||
type SerializedLexicalNode,
|
||||
type Spread,
|
||||
} from 'lexical';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { ReferTopicView } from './ReferTopicView';
|
||||
|
||||
export type SerializedReferTopicNode = Spread<
|
||||
{
|
||||
@@ -20,7 +24,7 @@ export type SerializedReferTopicNode = Spread<
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
export class ReferTopicNode extends DecoratorNode<any> {
|
||||
export class ReferTopicNode extends DecoratorNode<any> implements HeadlessRenderableNode {
|
||||
__topicId: string;
|
||||
__topicTitle: string;
|
||||
|
||||
@@ -99,6 +103,14 @@ export class ReferTopicNode extends DecoratorNode<any> {
|
||||
render: decorator.render(this, editor),
|
||||
};
|
||||
}
|
||||
|
||||
renderHeadless({ key }: HeadlessRenderContext) {
|
||||
return createElement(ReferTopicView, {
|
||||
fallbackTitle: this.__topicTitle,
|
||||
key,
|
||||
topicId: this.__topicId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function $createReferTopicNode(topicId: string, topicTitle: string): ReferTopicNode {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Tag } from '@lobehub/ui';
|
||||
import { MessageSquarePlusIcon } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/slices/topic/selectors';
|
||||
|
||||
import { TAG_MARGIN_INLINE_END } from '../constants';
|
||||
|
||||
export interface ReferTopicViewProps {
|
||||
fallbackTitle?: string;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
export const ReferTopicView = memo<ReferTopicViewProps>(({ topicId, fallbackTitle }) => {
|
||||
const { t } = useTranslation('topic');
|
||||
const title = useChatStore(topicSelectors.getTopicById(topicId))?.title || fallbackTitle;
|
||||
const switchTopic = useChatStore((s) => s.switchTopic);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (topicId) {
|
||||
switchTopic(topicId);
|
||||
}
|
||||
},
|
||||
[switchTopic, topicId],
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
cursor: topicId ? 'pointer' : 'default',
|
||||
display: 'inline-flex',
|
||||
marginInlineEnd: TAG_MARGIN_INLINE_END,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Tag color="green" icon={<MessageSquarePlusIcon size={12} />} variant="outlined">
|
||||
{title || t('defaultTitle')}
|
||||
</Tag>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
ReferTopicView.displayName = 'ReferTopicView';
|
||||
@@ -1,49 +1,26 @@
|
||||
import { ReactMentionPlugin, ReactTablePlugin } from '@lobehub/editor';
|
||||
import { Editor, useEditor } from '@lobehub/editor/react';
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { LexicalRenderer } from '@lobehub/editor/renderer';
|
||||
import type { SerializedEditorState } from 'lexical';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { createChatInputRichPlugins } from '@/features/ChatInput/InputEditor/plugins';
|
||||
import { ActionTagNode } from '@/features/ChatInput/InputEditor/ActionTag/ActionTagNode';
|
||||
import { ReferTopicNode } from '@/features/ChatInput/InputEditor/ReferTopic/ReferTopicNode';
|
||||
|
||||
interface RichTextMessageProps {
|
||||
editorState: unknown;
|
||||
}
|
||||
|
||||
const EDITOR_PLUGINS = [...createChatInputRichPlugins(), ReactTablePlugin, ReactMentionPlugin];
|
||||
const EXTRA_NODES = [ActionTagNode, ReferTopicNode];
|
||||
|
||||
const RichTextMessage = memo<RichTextMessageProps>(({ editorState }) => {
|
||||
const editor = useEditor();
|
||||
|
||||
const content = useMemo(() => {
|
||||
const value = useMemo(() => {
|
||||
if (!editorState || typeof editorState !== 'object') return null;
|
||||
if (Object.keys(editorState as Record<string, unknown>).length === 0) return null;
|
||||
|
||||
try {
|
||||
return JSON.stringify(editorState);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return editorState as SerializedEditorState;
|
||||
}, [editorState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content) {
|
||||
editor.setDocument('json', content);
|
||||
}
|
||||
}, [editor, content]);
|
||||
if (!value) return null;
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<Editor
|
||||
content={content}
|
||||
editable={false}
|
||||
editor={editor}
|
||||
enablePasteMarkdown={false}
|
||||
markdownOption={false}
|
||||
plugins={EDITOR_PLUGINS}
|
||||
type={'json'}
|
||||
variant={'chat'}
|
||||
/>
|
||||
);
|
||||
return <LexicalRenderer extraNodes={EXTRA_NODES} value={value} variant="chat" />;
|
||||
});
|
||||
|
||||
RichTextMessage.displayName = 'RichTextMessage';
|
||||
|
||||
Reference in New Issue
Block a user