浏览代码

fix: Fixed issues with the display of skill slots and internal skill … (#3027)

* fix: Fixed issues with the display of skill slots and internal skill states in AIChatInput

* chore: AIChatInput type change
YyumeiZhang 1 周之前
父节点
当前提交
9ea89ddff5

+ 1 - 1
content/ai/aiChatInput/index.md

@@ -170,7 +170,7 @@ const outerStyle = { margin: 12 };
 const temp = {
     'input-slot': '我是一个<input-slot placeholder="[职业]">程序员</input-slot>',
     'select-slot': `我是<select-slot value="前端开发" options='["设计","前端开发","后端开发"]'></select-slot>,帮我完成...`,
-    'skill-slot': `<skill-slot data-value="AI Coding"></skill-slot> 帮我完成...`,
+    'skill-slot': `<skill-slot data-label="AI Coding" data-value="AI Coding" data-template=false></skill-slot> 帮我完成...`,
 };
 
 function RichTextExample() {

+ 40 - 9
packages/semi-foundation/aiChatInput/foundation.ts

@@ -2,7 +2,7 @@ import BaseFoundation, { DefaultAdapter } from '../base/foundation';
 import { Attachment, BaseSkill, Suggestion, Reference, Content, LeftMenuChangeProps, MessageContent } from './interface';
 import { isNumber, isString } from 'lodash';
 import { cssClasses } from './constants';
-import { transformJSONResult } from './utils';
+import { findSkillSlotInString, getSkillSlotString, transformJSONResult } from './utils';
 
 export interface AIChatInputAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
     reposPopover: () => void;
@@ -16,6 +16,7 @@ export interface AIChatInputAdapter<P = Record<string, any>, S = Record<string,
     manualUpload: (files: File[]) => void;
     notifyMessageSend: (props: MessageContent) => void;
     notifyStopGenerate: () => void;
+    notifySkillChange: (skill: BaseSkill) => void;
     clearContent: () => void;
     clearAttachments: () => void;
     getRichTextDiv: () => HTMLDivElement | null;
@@ -51,7 +52,8 @@ export default class AIChatInputFoundation extends BaseFoundation<AIChatInputAda
             skill: skill,
             skillVisible: false
         });
-        this._adapter.setContent(`<skill-slot data-value="${skill.label}"></skill-slot>`);
+        this._adapter.notifySkillChange(skill);
+        this._adapter.setContent(getSkillSlotString(skill));
         this._adapter.focusEditor();
     }
 
@@ -199,16 +201,25 @@ export default class AIChatInputFoundation extends BaseFoundation<AIChatInputAda
         const { transformer } = this.getProps();
         const { skill } = this.getStates();
         const editor = this._adapter.getEditor();
-        const jsonResult = editor.getJSON();
-        const finalResult = transformJSONResult(jsonResult, transformer);
-        this._adapter.notifyContentChange(finalResult);
         const html = editor.getHTML();
-        if (content === '' && Object.keys(skill).length && !html.includes('</skill-slot>')) {
+        if (skill && !html.includes('</skill-slot>')) {
             this.setState({ 
-                skill: {} as BaseSkill,
+                skill: undefined,
                 templateVisible: false
             });
+            this._adapter.notifySkillChange(undefined);
+            this._adapter.notifyContentChange([]);
+            return;
+        } else if (html.includes('</skill-slot>')) {
+            const newSkill = findSkillSlotInString(html);
+            if (newSkill?.value !== skill?.value) {
+                this.setState({ skill: newSkill });
+                this._adapter.notifySkillChange(newSkill);
+            }
         }
+        const jsonResult = editor.getJSON();
+        const finalResult = transformJSONResult(jsonResult, transformer);
+        this._adapter.notifyContentChange(finalResult);
         this.setState({
             content: jsonResult,
         });
@@ -265,7 +276,7 @@ export default class AIChatInputFoundation extends BaseFoundation<AIChatInputAda
     }
 
     handleSend = () => {
-        const { generating } = this.getProps();
+        const { generating, transformer } = this.getProps();
         if (generating) {
             this._adapter.notifyStopGenerate();
             return;
@@ -279,7 +290,7 @@ export default class AIChatInputFoundation extends BaseFoundation<AIChatInputAda
             let richTextResult = [];
             if (editor) {
                 const json = editor.getJSON?.();
-                richTextResult = transformJSONResult(json);
+                richTextResult = transformJSONResult(json, transformer);
             }
             // close popup layer for template/skill/suggestion
             this.setState({
@@ -338,6 +349,26 @@ export default class AIChatInputFoundation extends BaseFoundation<AIChatInputAda
             this.handleSend();
             return true;
         }
+        if (event.key === 'Enter' && event.shiftKey) {
+            /**
+             * Tiptap 默认情况下 Enter + Shift 时候是使用 <br /> 实现换行
+             * 为保证自定义的一些逻辑生效(比如零宽字符的插入),Enter + Shift 希望实现通过新建 p 标签的方式完成换行
+             * 此处拦截默认操作,使用新建 p 标签方式实现换行
+             * Tiptap, by default, uses <br /> to create a newline character when you press Enter + Shift.
+             * To ensure that some custom logic works (such as the insertion of zero-width characters), 
+             * we want Enter + Shift to create a new p tag to initiate a line break.
+             * This section intercepts the default operation and uses a newly created `<p>` tag to achieve line breaks.
+             */
+            event.preventDefault();
+            const editor = this._adapter.getEditor();
+            if (editor && editor.chain && editor.chain().splitBlock) {
+                editor.chain().focus().splitBlock().run();
+            } else if (editor && editor.view) {
+                const { state, view } = editor;
+                view.dispatch(state.tr.split(state.selection.from));
+            }
+            return true;
+        }
         if (event.key !== 'Backspace') return false;
         return false;
     }

+ 4 - 4
packages/semi-foundation/aiChatInput/interface.ts

@@ -6,10 +6,10 @@ export interface RichTextJSON {
 }
 
 export interface BaseSkill {
-    icon: any;
-    value: string;
-    label: string;
-    hasTemplate: boolean;
+    icon?: any;
+    value?: string;
+    label?: string;
+    hasTemplate?: boolean;
     [key: string]: any
 }
 

+ 53 - 11
packages/semi-foundation/aiChatInput/utils.ts

@@ -1,4 +1,4 @@
-import { Attachment, Reference } from "./interface";
+import { Attachment, BaseSkill, Reference } from "./interface";
 import { strings } from './constants';
 
 export function getAttachmentType(item: Attachment | Reference) {
@@ -53,12 +53,8 @@ export function transformSelectSlot(obj: any) {
 
 export function transformSkillSlot(obj: any) {
     const { type, attrs } = obj;
-    const { value, info } = attrs;
-    return {
-        type,
-        value,
-        ...(JSON.parse(info) ?? {}),
-    };
+    const { value, label, hasTemplate } = attrs;
+    return omitUndefinedFromObj({ type, value, label, hasTemplate });
 }
 
 export function transformInputSlot(obj: any) {
@@ -71,14 +67,18 @@ export function transformInputSlot(obj: any) {
 }
 
 export function transformText(obj: any) {
-    return { ...obj };
+    const { text } = obj;
+    return { 
+        type: 'text',
+        text: text !== strings.ZERO_WIDTH_CHAR ? text : ''
+    };
 }
 
-export const transformMap = new Map([
+export const transformMap = new Map<string, any>([
     ['text', transformText],
     ['selectSlot', transformSelectSlot],
     ['inputSlot', transformInputSlot],
-    ['toolSlot', transformSkillSlot],
+    ['skillSlot', transformSkillSlot],
 ]);
 
 export function transformJSONResult(input: any, customTransformObj: Map<string, (obj: any) => any> = new Map()) {
@@ -116,6 +116,10 @@ export function transformJSONResult(input: any, customTransformObj: Map<string,
                 const lastItem = output[output.length - 1];
                 if (lastItem && lastItem.type === 'text') {
                     lastItem.text += result.text;
+                } else if (typeof result.text === 'string') {
+                    // 如果 result.text 为空字符串(比如text 节点中只有单个的零宽字符),则无需作为 output 结果
+                    // if result.text is an empty string,then it does not need to be included in output result.
+                    result.text.length && output.push(result);
                 } else {
                     output.push(result);
                 }
@@ -134,7 +138,45 @@ export function getCustomSlotAttribute() {
         default: true,
         parseHTML: element => true,
         renderHTML: attributes => ({
-            'data-custom-slot': attributes.isCustomSlot ? 'true' : undefined,
+            'data-custom-slot': attributes.isCustomSlot ? true : undefined,
         }),
     };
+}
+
+export function findSkillSlotInString(content: string) {
+    const reg = /<skill-slot\s+([^>]*)><\/skill-slot>/i;
+    const attrReg = /([\w-]+)=["']?([^"'\s>]+)["']?/g;
+    const match = reg.exec(content);
+    if (match) {
+        const attrsStr = match[1];
+        let attrMatch;
+        let attrs = {};
+        while ((attrMatch = attrReg.exec(attrsStr)) !== null) {
+            attrs[attrMatch[1]] = attrMatch[2];
+        }
+        if (attrs['data-value']) {
+            const obj = {
+                label: attrs['data-label'],
+                value: attrs['data-value'],
+                hasTemplate: attrs['data-template'] ? attrs['data-template'] === 'true' : undefined
+            };
+            return omitUndefinedFromObj(obj);
+        }
+    }
+    return undefined;
+}
+
+
+function omitUndefinedFromObj(obj: { [key: string]: any }) {
+    return Object.fromEntries(
+        Object.entries(obj).filter(([key, value]) => value !== undefined)
+    );
+}
+
+export function getSkillSlotString(skill: BaseSkill) {
+    let skillParams = '';
+    skill.label && (skillParams += ` data-label=${skill.label}`);
+    skill.value && (skillParams += ` data-value=${skill.value}`);
+    (typeof skill.hasTemplate === 'boolean') && (skillParams += ` data-template=${skill.hasTemplate}`);
+    return `<skill-slot ${skillParams}"></skill-slot>`;
 }

+ 21 - 2
packages/semi-ui/aiChatInput/_story/aiChatInput.stories.jsx

@@ -52,7 +52,11 @@ export const Basic = () => {
 const temp = {
     'input-slot': `我是一名<input-slot placeholder="[职业]">学生</input-slot>,帮我写一段面向<input-slot placeholder="[输入对象]"></input-slot>的话术内容`,
     'select-slot': `我的职业是<select-slot value="打工人" options='["打工人", "学生"]'></select-slot>,帮我写一份...`,
-    'skill-slot': '<skill-slot data-value="帮我写作"></skill-slot>',
+    // 'skill-slot': '<skill-slot data-label="帮我写作" data-value="writing" data-template=true></skill-slot>帮我完成...',
+    'skill-slot': {
+        type: "skillSlot",
+        attrs: { label: "帮我写作", value: 'writing',  hasTemplate: false }
+      },
 };
 
 export const RichTextExample = () => {
@@ -69,6 +73,14 @@ export const RichTextExample = () => {
     }
   }, [ref]);
 
+  const onContentChange = useCallback((content) => {
+    console.log('onContentChange', content);
+  }, []);
+
+  const onSkillChange = useCallback((skill) => {
+    console.log("skill", skill);
+  })
+
   return (<>
       <div className="aiChatInput-radio">
         {Object.keys(temp).map((item, index) => {
@@ -85,8 +97,16 @@ export const RichTextExample = () => {
           defaultContent={temp['input-slot']}
           placeholder={'输入内容或者上传内容'} 
           uploadProps={uploadProps}
+          onSkillChange={onSkillChange}
+          onContentChange={onContentChange}
           style={outerStyle} 
       />
+      <Button onClick={() => {
+        const html = ref.current.editor.getHTML();
+        const json = ref.current.editor.getJSON();
+        console.log('html', html);
+        console.log('json', json);
+      }}>点击获取</Button>
   </>);
 }
 
@@ -376,7 +396,6 @@ export const CustomRenderTop = () => {
 
   const renderTopSlot = useCallback((props) => {
     const { attachments = [], references } = props;
-    console.log('attachments', attachments);
     return <div className="topSlot">
       {references?.map((item, index) => {
         const { type, name, detail, key, ...rest } = item;

+ 37 - 0
packages/semi-ui/aiChatInput/_story/aiChatInput.stories.tsx

@@ -0,0 +1,37 @@
+import React, { useCallback, useState } from 'react';
+import { AIChatInput } from '../../index';
+import { storiesOf } from '@storybook/react';
+import { uploadProps } from './constant';
+
+const stories = storiesOf('AIChatInput', module);
+
+const outerStyle = { margin: 12, maxHeight: 300 };
+
+stories.add('default', () => {
+  const [generating, setGenerating] = useState(false);
+    const onContentChange = useCallback((content) => {
+      console.log('onContentChange', content);
+    }, []);
+  
+    const onUploadChange = useCallback((fileList) => {
+      console.log('onUploadChange', fileList);
+    }, []);
+  
+    const toggleGenerate = useCallback(() => {
+      setGenerating(value => !value);
+    }, []);
+    
+    return (
+      <AIChatInput
+        generating={generating}
+        defaultContent={''}
+        placeholder={'输入内容或者上传内容'} 
+        uploadProps={uploadProps}
+        onContentChange={onContentChange}
+        onUploadChange={onUploadChange}
+        style={outerStyle}
+        onMessageSend={toggleGenerate}
+        onStopGenerate={toggleGenerate}
+      />
+    );
+});

+ 2 - 2
packages/semi-ui/aiChatInput/extension/plugins.ts

@@ -144,7 +144,7 @@ export function keyDownHandlePlugin(schema: any) {
                                  * 结果: [前一个 Paragraph、光标、换行、零宽字符、 ····]
                                  */
                                 const nextCursorPos = $from.before() - 1;
-                                dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
+                                nextCursorPos > 0 && dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
                                 event.preventDefault();
                                 return true;
                             }
@@ -190,7 +190,7 @@ export function keyDownHandlePlugin(schema: any) {
                                 event.preventDefault();
                                 return true;
                             }
-                        } else if ($from.nodeBefore.isText && $from.nodeBefore.text.startsWith(strings.ZERO_WIDTH_CHAR)) {
+                        } else if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text.startsWith(strings.ZERO_WIDTH_CHAR)) {
                             // Backup,当零宽字符出现在 text 节点中
                             const nextCursorPos = $from.pos + 2;
                             dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));

+ 12 - 4
packages/semi-ui/aiChatInput/extension/skillSlot/index.tsx

@@ -6,13 +6,14 @@ import React from 'react';
 import { getCustomSlotAttribute } from '@douyinfe/semi-foundation/aiChatInput/utils';
 
 function SkillSlotComponent(props: NodeViewProps) {
-    const { node, deleteNode } = props;
-    const value: string = node.attrs.value ?? '';
+    const { node, editor } = props;
+    
+    const value: string = node.attrs.label ?? node.attrs.value ?? '';
 
     const onRemove = (e: React.MouseEvent) => {
         e.preventDefault();
         e.stopPropagation();
-        deleteNode?.();
+        editor?.commands.clearContent();
     };
 
     if (value === '') {
@@ -24,7 +25,6 @@ function SkillSlotComponent(props: NodeViewProps) {
             <span className='skill-slot'>
                 {value}
                 <IconClose
-                    onMouseDown={onRemove}
                     onClick={onRemove}
                     className='skill-slot-delete'
                 />
@@ -48,6 +48,14 @@ const SkillSlot = Node.create({
                 parseHTML: (element: HTMLElement) => (element as HTMLElement).getAttribute('data-value'),
                 renderHTML: (attributes: Record<string, any>) => ({ 'data-value': attributes.value }),
             },
+            label: {
+                parseHTML: (element: HTMLElement) => (element as HTMLElement).getAttribute('data-label'),
+                renderHTML: (attributes: Record<string, any>) => ({ 'data-label': attributes.label }),
+            },
+            hasTemplate: {
+                parseHTML: (element: HTMLElement) => (element as HTMLElement).getAttribute('data-template'),
+                renderHTML: (attributes: Record<string, any>) => ({ 'data-template': attributes.hasTemplate }),
+            },
             isCustomSlot: getCustomSlotAttribute(),
         };
     },

+ 5 - 2
packages/semi-ui/aiChatInput/index.tsx

@@ -68,7 +68,7 @@ class AIChatInput extends BaseComponent<AIChatInputProps, AIChatInputState> {
             attachments: defaultAttachment,
             content: null,
             popupWidth: null,
-            skill: {} as Skill,
+            skill: undefined,
             activeSkillIndex: 0,
             activeSuggestionIndex: 0,
             /**
@@ -131,6 +131,9 @@ class AIChatInput extends BaseComponent<AIChatInputProps, AIChatInputState> {
             },
             getEditor: () => this.editor,
             getPopupID: () => this.popUpOptionListID,
+            notifySkillChange: (skill: Skill) => {
+                this.props.onSkillChange?.(skill);
+            },
             notifyContentChange: (result: Content[]) => {
                 this.transformedContent = result;
                 this.props.onContentChange?.(result);
@@ -509,7 +512,7 @@ class AIChatInput extends BaseComponent<AIChatInputProps, AIChatInputState> {
     renderUploadButton = () => {
         const { uploadTipProps, uploadProps } = this.props;
         const { attachments } = this.state;
-        const { className, onChange, renderFileItem, children, ...rest } = uploadProps;
+        const { className, onChange, renderFileItem, children, ...rest } = uploadProps ?? {};
         const realUploadProps = {
             ...rest,
             onChange: this.foundation.onUploadChange,

+ 5 - 14
packages/semi-ui/aiChatInput/interface.ts

@@ -5,6 +5,7 @@ import { BaseSkill, Reference, Suggestion, Attachment, Content, Setup, LeftMenuC
 import { Extension } from "@tiptap/core";
 import { Content as TiptapContent } from "@tiptap/core";
 import { PopoverProps } from "../popover";
+export * from "@douyinfe/semi-foundation/aiChatInput/interface";
 
 export interface AIChatInputState {
     templateVisible: boolean;
@@ -28,7 +29,7 @@ export interface AIChatInputProps {
     placeholder?: string;
     extensions?: Extension[];
     onContentChange?: (contents: Content[]) => void;
-    defaultContent?: TiptapContent[];
+    defaultContent?: TiptapContent;
     onFocus?: (event: React.FocusEvent) => void;
     onBlur?: (event: React.FocusEvent) => void;
     // Reference related
@@ -50,7 +51,7 @@ export interface AIChatInputProps {
     onMessageSend?: (props: MessageContent) => void;
     onStopGenerate?: () => void;
     uploadTipProps?: TooltipProps;
-    generating: boolean;
+    generating?: boolean;
     // Configure area related
     renderConfigureArea?: (className?: string) => ReactNode;
     onConfigureChange?: (value: LeftMenuChangeProps, changedValue: LeftMenuChangeProps) => void;
@@ -61,7 +62,7 @@ export interface AIChatInputProps {
     renderSuggestionItem?: (props: RenderSuggestionItemProps) => ReactNode;
     onSuggestClick?: (suggestion: Suggestion) => void;
     // Skill related
-    skills: Skill[];
+    skills?: Skill[];
     skillHotKey?: string;
     templatesStyle?: React.CSSProperties;
     templatesCls?: string;
@@ -92,7 +93,7 @@ export interface RenderSkillItemProps {
 }
 
 export interface Skill extends BaseSkill {
-    icon: ReactNode
+    icon?: ReactNode
 }
 
 interface ActionAreaProps {
@@ -108,13 +109,3 @@ interface RenderTopSlotProps {
     handleUploadFileDelete: (attachment: Attachment) => void;
     handleReferenceDelete: (reference: Reference) => void
 }
-
-export {
-    BaseSkill, 
-    Reference, 
-    Suggestion, 
-    Content, 
-    Setup, 
-    Attachment,
-    LeftMenuChangeProps 
-};

+ 1 - 1
packages/semi-ui/aiChatInput/richTextInput.tsx

@@ -19,7 +19,7 @@ const PREFIX = cssClasses.PREFIX;
 
 export default (props: {
     innerRef?: React.Ref<HTMLDivElement>;
-    defaultContent?: TiptapContent[];
+    defaultContent?: TiptapContent;
     placeholder?: string;
     setEditor?: (editor: Editor) => void;
     onKeyDown?: (e: KeyboardEvent) => void;