♻️ refactor(perf): user message renderer (#13108)

refactor(perf): user message renderer
This commit is contained in:
Innei
2026-03-18 21:58:29 +08:00
committed by GitHub
parent 1df5ae32f1
commit ac29897d72
8 changed files with 156 additions and 123 deletions

View File

@@ -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",

View File

@@ -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>
);
});

View File

@@ -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(

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';