AI 组件

聊天输入框

用于 AI 聊天场景下的输入框

使用场景

在 AI 聊天场景下,用户可通过 AIChatInput实现富文本输入、上传、引用、建议、模版、功能配置、及丰富自定义展示等需求。

AIChatInput 的富文本输入是基于 tiptap 实现,tiptap 是一款现代的富文本编辑器开发框架,支持 React、Vue 前端框架,具备极强的可定制性和扩展性。其组件化能力优秀,性能优良,内置多种常用拓展,并支持用户自定义节点、命令、插件与菜单,使复杂 AI 场景下的富文本输入能力能够灵活适配和扩展。Semi 的 AIChatInput 组件对 tiptap 进行了封装,开发者可开箱即用或按需按业务扩展。

代码演示

如何引入

import { AIChatInput, getConfigureItem } from '@fibrous/semi-ai-solid';

基本用法

支持文本输入以及文件上传,使用时可按需配置以下参数:

  • uploadProps 配置文件上传相关的参数,详见 UploadProps
  • onUploadChange 获取文件上传变化
  • 删除上传文件时,会触发 uploadProps.onRemove,并遵循 uploadProps.beforeRemove(支持 Promise)
  • placeholder 输入框的占位符
  • defaultContent 输入框的默认内容
  • onContentChange 输入框内容变化时的回调函数,参数为当前输入框的内容
代码
import { AIChatInput, type AIChatInputContent as Content } from '@fibrous/semi-ai-solid';
import type { UploadOnChangeProps as OnChangeProps } from '@fibrous/semi-ui-solid';

const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };

const onContentChange = (content: Content[]) => {
    console.log('onContentChange', content);
};

const onUploadChange = (fileList: OnChangeProps) => {
    console.log('onUploadChange', fileList);
};

export default function AIChatInputDemo() {
    return <AIChatInput placeholder="输入内容或者上传内容..." uploadProps={uploadProps} onContentChange={onContentChange} onUploadChange={onUploadChange} style={outerStyle} />;
}

消息发送

当输入框中有内容(包括输入文本、上传内容),将允许发送消息。点击消息发送按钮,会触发 onMessageSend 回调函数,参数为当前输入框的内容,包括输入区域的文本,引用内容,上传文件,配置区域内容。引用内容不参与是否可发送的判断,也可通过 canSend 直接指定。

用户可在 onMessageSend 中根据判断是否设置 generating 表示消息正在处理中,如果 generating 为 true,则 AIChatInput 会在发送按钮位置显示停止生成按钮,并清空输入区的消息,以及上传文件,另外,引用内容需要用户自行清除。

点击停止生成按钮,会触发 onStopGenerate 回调函数,用户可在该回调函数中处理停止生成的逻辑, 如将 generating 设为 false。

dy.jpeg
jpeg 130kb
resso.jpeg
jpeg 222kb
代码
import { createSignal } from 'solid-js';
import { AIChatInput, type AIChatInputContent as Content, type AIChatInputReference as Reference } from '@fibrous/semi-ai-solid';
import type { UploadOnChangeProps as OnChangeProps, UploadProps } from '@fibrous/semi-ui-solid';

const uploadProps: UploadProps = {
    action: 'https://api.semi.design/upload',
    defaultFileList: [
        {
            uid: '1',
            name: 'dy.jpeg',
            status: 'success',
            size: '130kb',
            url: '/assets/placeholder/avatar-a.svg',
        },
        {
            uid: '5',
            name: 'resso.jpeg',
            status: 'uploading',
            percent: 50,
            size: '222kb',
            url: '/assets/placeholder/avatar-b.svg',
        },
    ],
};
const outerStyle = { margin: '12px' };

const reference: Reference[] = [
    {
        id: '1',
        type: 'text',
        content: '测试文本,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字',
    },
];

const onContentChange = (content: Content[]) => {
    console.log('onContentChange', content);
};

const onUploadChange = (fileList: OnChangeProps) => {
    console.log('onUploadChange', fileList);
};

export default function AIChatInputDemo() {
    const [references, setReferences] = createSignal(reference);
    const [generating, setGenerating] = createSignal(false);

    const toggleGenerate = () => {
        setGenerating((value) => !value);
    };

    const onMessageSend = () => {
        toggleGenerate();
        setReferences([]);
    };

    const handleReferenceDelete = (item: Reference) => {
        setReferences((list) => list.filter((ref) => ref.id !== item.id));
    };

    return (
        <AIChatInput
            defaultContent="点击发送按钮,观察上传内容、引用内容、输入框内容变化"
            generating={generating()}
            uploadProps={uploadProps}
            onContentChange={onContentChange}
            onUploadChange={onUploadChange}
            style={outerStyle}
            onMessageSend={onMessageSend}
            onStopGenerate={toggleGenerate}
            onReferenceDelete={handleReferenceDelete}
            references={references()}
        />
    );
}

富文本输入区

AIChatInput 使用 tiptap 作为富文本输入框的编辑器,用户可以在输入框中输入文本,使用 AIChatInput 内置的 extensions(包括 input-slot,select-slot,skill-slot)。用户也可以自定义 extensions 来扩展编辑器的功能。

  • input-slot 支持用户输入文本,并支持 placeholder 占位符。
  • select-slot 支持用户进行简单的选择,选项仅支持 string 类型。
  • skill-slot 是用于技能展示的块,方便用户理解当前输入框中的技能。

可以通过 ref 的 setContent 方法来设置输入框的内容,使用 focusEditor 方法可以将输入框的焦点设置到编辑器中。

代码
import { For, createSignal } from 'solid-js';
import { AIChatInput, type AIChatInputElement } from '@fibrous/semi-ai-solid';

const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };
const temp = {
    'input-slot': '我是一个<input-slot placeholder="[职业]">程序员</input-slot>',
    'select-slot': `我是<select-slot value="前端开发" options='["设计","前端开发","后端开发"]'></select-slot>,帮我完成...`,
    'skill-slot': `<skill-slot data-label="AI Coding" data-value="AI Coding" data-template=false></skill-slot> 帮我完成...`,
};
const tabs = Object.keys(temp) as Array<keyof typeof temp>;

export default function AIChatInputDemo() {
    const [activeIndex, setActiveIndex] = createSignal(0);
    let input: AIChatInputElement | undefined;

    const setTemplate = (index: number) => {
        setActiveIndex(index);
        input?.setContent(temp[tabs[index]]);
        input?.focusEditor();
    };

    return (
        <>
            <div class="aiChatInput-radio" role="tablist">
                <For each={tabs}>
                    {(item, index) => (
                        <div
                            class={`aiChatInput-radio-item ${index() === activeIndex() ? 'aiChatInput-radio-item-selected' : ''}`}
                            role="tab"
                            aria-selected={index() === activeIndex()}
                            tabIndex={0}
                            onClick={() => setTemplate(index())}
                            onKeyDown={(e) => {
                                if (e.key === 'Enter') {
                                    setTemplate(index());
                                }
                            }}
                        >
                            {item}
                        </div>
                    )}
                </For>
            </div>
            <AIChatInput ref={(element) => (input = element)} defaultContent={temp['input-slot']} placeholder="输入内容或者上传内容" uploadProps={uploadProps} style={outerStyle} />
        </>
    );
}

引用

用户可以 references 传入引用内容,引用内容会展示在输入框的顶部。

  • renderReference 自定义单个引用内容的渲染。
  • onReferenceDelete 处理引用内容的删除。
  • onReferenceClick 处理引用内容的点击。
代码
import { createSignal } from 'solid-js';
import { AIChatInput, type AIChatInputReference as Reference } from '@fibrous/semi-ai-solid';

const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };
const referenceTemp: Reference[] = [
    {
        id: '1',
        type: 'text',
        content: '测试文本,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字',
    },
    {
        id: '2',
        name: '飞书文档.docx',
    },
    {
        id: '3',
        name: '飞书文档.pdf',
    },
    {
        id: '4',
        name: 'Music.mp4',
    },
    {
        id: '5',
        name: 'Image.jpeg',
        url: '/assets/placeholder/avatar-b.svg',
    },
    {
        id: '6',
        name: 'code.json',
    },
];

const handleReferenceClick = (item: Reference) => {
    console.log('点击了引用', item);
};

export default function AIChatInputDemo() {
    const [references, setReferences] = createSignal(referenceTemp);

    const handleReferenceDelete = (item: Reference) => {
        setReferences((list) => list.filter((ref) => ref.id !== item.id));
    };

    return <AIChatInput placeholder="用于查看引用内容的用例" onReferenceDelete={handleReferenceDelete} onReferenceClick={handleReferenceClick} references={references()} uploadProps={uploadProps} style={outerStyle} />;
}

配置区域

用户可以通过配置区域设置使用模型参数、联网搜索、深度思考等配置项,展示或者查看 MCP 工具。

可通过 renderConfigureArea API 自定义输入框的操作按钮。

使用 AIChatInput.Configure 中的 Select、Button、Mcp、RadioButton 等组件可以自定义配置项。

AIChatInput 将管理配置项的状态,用户可以通过 onConfigureChange API 监听配置项的变化。一定要配置 field 属性,用于标识配置项的唯一标识。如需设置初始值,可通过 initValue 属性设置。

如果用户有其他形式的配置需求,可以通过 getConfigureItem 将自定义组件扩展成 Configure 类型组件。

代码
import type { Component } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { AIChatInput, type AIChatInputLeftMenuChangeProps as LeftMenuChangeProps } from '@fibrous/semi-ai-solid';
import { IconBookOpenStroked, IconFeishuLogo, IconGit, IconFigma } from '@fibrous/semi-icons-solid';

const { Configure } = AIChatInput;
const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };

const modelOptions = [
    { value: 'GPT-5', label: 'GPT-5' },
    { value: 'GPT-4o', label: 'GPT-4o' },
    { value: 'Claude 3.5 Sonnet', label: 'Claude 3.5 Sonnet' },
];

const radioButtonProps = [
    { label: '极速', value: 'fast' },
    { label: '思考', value: 'think' },
    { label: '超能', value: 'super' },
];

const onConfigureButtonClick = () => {
    console.log('onConfigureButtonClick');
};

const onConfigureChange = (value: LeftMenuChangeProps, changedValue?: LeftMenuChangeProps) => {
    console.log('onConfigureChange', value, changedValue);
};

const mcpOptions = [
    { label: '飞书文档', value: 'feishu' },
    { label: 'Github Mcp', value: 'github' },
    { label: 'IconFigma Mcp', value: 'IconFigma' },
];

const mcpIcons: Record<string, Component> = {
    feishu: IconFeishuLogo,
    github: IconGit,
    IconFigma,
};

export default function AIChatInputDemo() {
    const renderLeftMenu = () => (
        <>
            <Configure.Select optionList={modelOptions} field="model" initValue="GPT-4o" />
            <Configure.Button icon={<IconBookOpenStroked />} field="onlineSearch">
                联网搜索
            </Configure.Button>
            <Configure.Mcp options={mcpOptions} renderOptionIcon={(option) => <Dynamic component={mcpIcons[option.value]} />} onConfigureButtonClick={onConfigureButtonClick} showConfigure />
            <Configure.RadioButton options={radioButtonProps} field="thinkType" initValue="fast" />
        </>
    );

    return <AIChatInput placeholder="用于查看左下方配置项的用例" renderConfigureArea={renderLeftMenu} onConfigureChange={onConfigureChange} uploadProps={uploadProps} style={outerStyle} />;
}

使用 getConfigureItem 扩展自定义组件为 Configure 类型组件。

function getConfigureItem<P extends object, VK extends keyof P, CK extends keyof P>(
    Item: Component<P>,
    opts?: {
        // 指定需要接管的组件接收值的 prop,默认是 value
        valueKey?: VK;
        // 指定值变化时调用的回调名,默认是 onChange
        onKeyChangeFnName?: CK;
        // 指定值变化回调第一个参数中 value 对应的路径,默认取参数本身
        valuePath?: string;
        class?: string;
        defaultProps?: Partial<P>;
    }
): (
    props: Omit<P, VK | CK | 'onChange' | 'class'> & {
        // 配置项在 Configure 值中的 key,必填
        field: string;
        initValue?: P[VK];
        onChange?: (value: unknown) => void;
        class?: string;
    }
) => JSX.Element;

返回的组件按被包装组件的 Props 检查属性,field 必填,initValue 的类型与 valueKey 对应的属性一致。onChange 由配置项占用,收到该项的新值,被包装组件自己的 onChange 不在可接受的属性中。

使用示例如下:

代码
import { AIChatInput, getConfigureItem, type AIChatInputLeftMenuChangeProps as LeftMenuChangeProps } from '@fibrous/semi-ai-solid';
import { Cascader, type CascaderProps } from '@fibrous/semi-ui-solid';

const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };
const cascaderModalOptions = [
    {
        label: 'GPT',
        value: 'GPT',
        children: [
            { label: 'GPT-4o', value: 'GPT-4o' },
            { value: 'GPT-5', label: 'GPT-5' },
        ],
    },
    {
        label: 'Claude',
        value: 'Claude',
        children: [{ label: 'Claude 3.5 Sonnet', value: 'Claude 3.5 Sonnet' }],
    },
];

const myCascader = (props: CascaderProps) => {
    return <Cascader {...props} />;
};

const CustomCascader = getConfigureItem(myCascader, {
    class: 'aiChatInput-cascader-configure',
});

const onConfigureChange = (value: LeftMenuChangeProps, changedValue?: LeftMenuChangeProps) => {
    console.log('onConfigureChange', value, changedValue);
};

export default function AIChatInputDemo() {
    const renderLeftMenu = () => <CustomCascader field="model" treeData={cascaderModalOptions} initValue={['GPT', 'GPT-4o']} />;

    return <AIChatInput placeholder="用于查看左下方配置项的用例" renderConfigureArea={renderLeftMenu} onConfigureChange={onConfigureChange} uploadProps={uploadProps} style={outerStyle} />;
}

操作区域

输入框右下角为操作区域,用户可以通过 renderActionArea API 自定义操作区域,展示自定义的操作按钮。menuItem 依次是上传节点(showUploadButton 为 false 时是隐藏的上传节点,仍需渲染才能粘贴上传)与发送或停止按钮。

interface ActionAreaProps {
    menuItem: JSX.Element[];
    class: string;
}

使用示例如下:

代码
import { AIChatInput, type AIChatInputActionAreaProps as ActionAreaProps } from '@fibrous/semi-ai-solid';
import { IconDeleteStroked } from '@fibrous/semi-icons-solid';
import { Button, Divider } from '@fibrous/semi-ui-solid';

const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };

const renderActionArea = (props: ActionAreaProps) => (
    <div class={props.class}>
        <div style={{ display: 'flex', 'align-items': 'center' }}>
            <Button aria-label="清空内容" type="tertiary" style={{ 'border-radius': '50%' }} icon={<IconDeleteStroked />} />
            <Divider layout="vertical" style={{ 'margin-left': '8px' }} />
        </div>
        {props.menuItem}
    </div>
);

export default function AIChatInputDemo() {
    return <AIChatInput renderActionArea={renderActionArea} placeholder="输入内容或者上传内容..." uploadProps={uploadProps} style={outerStyle} />;
}

自定义上传按钮

底部操作区左侧默认会渲染上传按钮。你可以通过 renderUploadButton 仅自定义按钮 UI(例如改成图标按钮、加 Tooltip 等)。

注意:这不会影响上传/粘贴上传逻辑(Upload 仍由组件内部托管),openFileDialog 会触发内部 Upload 的文件选择。

interface RenderUploadButtonProps {
    /* 创建默认上传按钮,在要放置按钮的位置调用;自定义节点不调用时,不会创建默认按钮 */
    defaultNode: () => JSX.Element;
    openFileDialog: () => void;
    disabled: boolean;
    attachments: Attachment[];
}
代码
import { AIChatInput, type RenderUploadButtonProps } from '@fibrous/semi-ai-solid';
import { IconUpload } from '@fibrous/semi-icons-solid';

const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };

const renderUploadButton = (props: RenderUploadButtonProps) => (
    <button
        type="button"
        aria-label="上传"
        disabled={props.disabled}
        class="semi-button semi-button-borderless"
        onClick={(e) => {
            e.stopPropagation();
            props.openFileDialog();
        }}
    >
        <IconUpload />
    </button>
);

export default function AIChatInputDemo() {
    return <AIChatInput placeholder="自定义上传按钮(仍支持粘贴上传)" uploadProps={uploadProps} renderUploadButton={renderUploadButton} style={outerStyle} />;
}

底部按钮形状

用户可以通过 round API 配置底部按钮的形状,默认是 true,是圆角按钮, 可以设置为 false 来配置为方形按钮。

代码
import { type Component, createSignal } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { AIChatInput } from '@fibrous/semi-ai-solid';
import { IconBookOpenStroked, IconFeishuLogo, IconGit, IconFigma } from '@fibrous/semi-icons-solid';
import { Radio, RadioGroup } from '@fibrous/semi-ui-solid';

const { Configure } = AIChatInput;
const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };
const modelOptions = [
    { value: 'GPT-5', label: 'GPT-5' },
    { value: 'GPT-4o', label: 'GPT-4o' },
    { value: 'Claude 3.5 Sonnet', label: 'Claude 3.5 Sonnet' },
];

const radioButtonProps = [
    { label: '极速', value: 'fast' },
    { label: '思考', value: 'think' },
    { label: '超能', value: 'super' },
];

const mcpOptions = [
    { label: '飞书文档', value: 'feishu' },
    { label: 'Github Mcp', value: 'github' },
    { label: 'IconFigma Mcp', value: 'IconFigma' },
];

const mcpIcons: Record<string, Component> = {
    feishu: IconFeishuLogo,
    github: IconGit,
    IconFigma,
};

export default function AIChatInputDemo() {
    const [round, setRound] = createSignal(false);

    const renderLeftMenu = () => (
        <>
            <Configure.Select optionList={modelOptions} field="model" initValue="GPT-4o" />
            <Configure.Button icon={<IconBookOpenStroked />} field="onlineSearch">
                联网搜索
            </Configure.Button>
            <Configure.Mcp options={mcpOptions} renderOptionIcon={(option) => <Dynamic component={mcpIcons[option.value]} />} />
            <Configure.RadioButton options={radioButtonProps} field="mode" initValue="fast" />
        </>
    );

    return (
        <>
            <RadioGroup onChange={(event) => setRound(event.target.value)} value={round()} aria-label="单选组合示例" name="demo-radio-group">
                <Radio value>圆形</Radio>
                <Radio value={false}>方形</Radio>
            </RadioGroup>
            <AIChatInput placeholder="下方按钮为方形的用例" round={round()} renderConfigureArea={renderLeftMenu} uploadProps={uploadProps} style={outerStyle} />
        </>
    );
}

建议

用户可通过 suggestions API 配置建议列表,功能类似于 AutoComplete 组件,用户可以根据输入的内容实现根据输入的内容动态展示建议列表。

使用键盘上下方向键切换建议列表的选项。按下 ESC 或者点击非建议列表,输入框区域,建议列表将关闭。

还可通过 renderSuggestionItem API 自定义建议列表的展示,需要把参数里的 id、class、role、aria-selected 与事件处理展开到根元素上。

代码
import { createSignal } from 'solid-js';
import { AIChatInput, type AIChatInputContent as Content } from '@fibrous/semi-ai-solid';

const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };
const suggestionTemplate = ['天气如何', '空气质量', '工作进程', '日程安排'];

export default function AIChatInputDemo() {
    const [suggestion, setSuggestion] = createSignal<string[]>([]);

    const onChange = (content: Content[]) => {
        const value = content.length && content[0].text ? content[0].text : undefined;
        if (value === undefined || value.includes('\n') || value.length >= 4) {
            setSuggestion([]);
            return;
        }
        setSuggestion(value.length ? suggestionTemplate.map((item) => `${value},${item}`) : []);
    };

    return <AIChatInput suggestions={suggestion()} onContentChange={onChange} uploadProps={uploadProps} style={outerStyle} placeholder="输入内容,当内容长度小于 4个字符可以看到建议,使用上下按键可切换侯选项" />;
}

技能及模版

用户可以通过 skills API 配置技能列表,使用 skillHotKey 配置技能的触发键。

skills 的格式如下

interface Skill {
    label?: string;
    value?: string;
    // 技能是否有模版,有模版的技能被选中后,将在输入框的底部展示模版按钮
    hasTemplate?: boolean;
}

技能只含数据,技能前的图标通过 renderSkillIcon(skill) 渲染,整项的展示可通过 renderSkillItem 自定义。

由于模版的展示形式丰富,因此我们不提供默认的展示形式,用户可以通过 renderTemplate API 自定义模版的展示。模版面板的展示和关闭可通过点击模版按钮实现。

renderTemplate?: (
    // 未选中技能、仅显示模版按钮时为 undefined
    skill: Skill | undefined,
    // 模版点击回调,点击模版后,用模版的内容替换输入框的内容
    onTemplateClick: (content: string) => void
) => JSX.Element;

使用示例如下:

代码
import { For, createSignal } from 'solid-js';
import { AIChatInput, type AIChatInputElement, type AIChatInputSkill as Skill } from '@fibrous/semi-ai-solid';
import { IconTemplateStroked, IconSearch } from '@fibrous/semi-icons-solid';

const { Configure } = AIChatInput;
const modelOptions = [
    { value: 'GPT-5', label: 'GPT-5' },
    { value: 'GPT-4o', label: 'GPT-4o' },
    { value: 'Claude 3.5 Sonnet', label: 'Claude 3.5 Sonnet' },
];
const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };

const template = [
    {
        group: '工作',
        children: [
            {
                bg: 'var(--semi-color-primary)',
                title: '总结汇报',
                desc: '凝练你的工作成效',
                content: `我的职业是<input-slot placeholder="[请输入职业]"></input-slot>,帮我写一份关于<input-slot placeholder="[输入目的:项目进展总结、团队工作成果或其他]"></input-slot>的总结汇报`,
            },
            {
                bg: 'var(--semi-color-warning)',
                title: '话术',
                desc: '满足不同场景表达需求',
                content: `我是一名<select-slot value="打工人" options='["打工人","学生"]'></select-slot> ,帮我写一段面向<input-slot placeholder="[输入对象]">陌生同事</input-slot>的话术内容`,
            },
        ],
    },
    {
        group: '商业营销',
        children: [
            {
                bg: 'var(--semi-color-primary)',
                title: '宣传文案',
                desc: '撰写各平台的推广文案',
                content: '帮我写一篇面向<input-slot placeholder="[输入目标人群]"></input-slot>职场人士,关于<input-slot placeholder="[输入产品]"></input-slot>的宣传文案,需要直击痛点,吸引用户点击。',
            },
            {
                bg: 'var(--semi-color-warning)',
                title: '方案策划',
                desc: '量身定制各种方案',
                content: '我是一名<input-slot placeholder="[输入职业]"></input-slot>职业策划人 ,帮我写一个<input-slot placeholder="[方案类型:如线下读书会活动方案等]"></input-slot>线下读书会活动 的方案,需要包含但不限于策划目标、详细计划、所需资源和预算、效果评估、风险应对等。',
            },
        ],
    },
];

interface TemplateContentProps {
    onTemplateClick: (content: string) => void;
}

function TemplateContent(props: TemplateContentProps) {
    const [groupIndex, setGroupIndex] = createSignal(0);

    return (
        <div class="aiChatInput-template">
            <div class="template-header">
                <For each={template}>
                    {(item, index) => (
                        <button type="button" class={`template-header-item ${groupIndex() === index() ? 'template-header-item-active' : ''}`} style={{ border: 'none', 'font-family': 'inherit' }} onClick={() => setGroupIndex(index())}>
                            {item.group}
                        </button>
                    )}
                </For>
            </div>
            <div class="template-content">
                <For each={template[groupIndex()]?.children ?? []}>
                    {(item) => (
                        <button type="button" class="template-content-item" style={{ font: 'inherit', 'text-align': 'left' }} onClick={() => props.onTemplateClick(item.content)}>
                            <span class="template-content-item-icon" style={{ background: item.bg }}>
                                <IconTemplateStroked />
                            </span>
                            <span class="template-content-item-title">{item.title}</span>
                            <span class="template-content-item-desc">{item.desc}</span>
                        </button>
                    )}
                </For>
            </div>
        </div>
    );
}

export default function AIChatInputDemo() {
    let input: AIChatInputElement | undefined;

    const skills: Skill[] = [
        { value: 'writing', label: '帮我写作', hasTemplate: true },
        { value: 'AI 编程', label: 'AI coding' },
    ];

    const renderSkillIcon = (skill: Skill) => (skill.value === 'writing' ? <IconTemplateStroked /> : <IconSearch />);

    const setTemplate = (content: string) => {
        input?.setContentWhileSaveTool(content);
        input?.focusEditor();
    };

    const renderTemplate = (skill?: Skill) => {
        if (skill?.value === 'writing') {
            return <TemplateContent onTemplateClick={setTemplate} />;
        }
        return undefined;
    };

    const renderLeftMenu = () => (
        <>
            <Configure.Select optionList={modelOptions} field="model" initValue="GPT-4o" />
        </>
    );

    return (
        <AIChatInput
            placeholder="输入 / 唤起技能,选择技能后,点击模版按钮可查看模版,可通过鼠标上下按键切换侯选项"
            renderConfigureArea={renderLeftMenu}
            ref={(element) => (input = element)}
            uploadProps={uploadProps}
            skills={skills}
            renderSkillIcon={renderSkillIcon}
            skillHotKey="/"
            renderTemplate={renderTemplate}
            style={outerStyle}
        />
    );
}

自定义渲染顶部区域

用户可以通过 renderTopSlot API 自定义渲染顶部区域,可自行渲染引用,上传内容以及配置项。可结合 showReference 和 showUploadFile API 控制是否展示引用和上传文件。另外,可通过 topSlotPosition API 配置自定义渲染内容相对于引用区域,上传展示区域的相对位置。renderTopSlot 只调用一次,参数的 getter 随当前值变化,需要随数据更新的内容放在 JSX 中读取。

interface TopSlotProps {
    // 引用
    references: Reference[];
    // 上传内容
    attachments: Attachment[];
    // 输入框消息
    content: Content[];
    handleUploadFileDelete: (attachment: Attachment) => void;
    handleReferenceDelete: (reference: Reference) => void;
}

使用用例如下:

horizontalScroller.tsx
AIChatInput
web
recentChange
BranchDiff with Main Branch
From 1-2
代码
import { For, Show, type Component, createSignal } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { AIChatInput, type AIChatInputAttachment as Attachment, type AIChatInputElement, type AIChatInputReference as Reference, type AIChatInputRenderTopSlotProps as RenderTopSlotProps } from '@fibrous/semi-ai-solid';
import { IconClose, IconUpload, IconFile, IconFolder, IconBranch, IconTerminal, IconGlobeStroke, IconConnectionPoint2, IconTemplateStroked, IconSearch, IconGit, IconCode, type IconProps } from '@fibrous/semi-icons-solid';

const { Configure } = AIChatInput;
const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };

const customReferences: Reference[] = [
    {
        type: 'file',
        id: '1',
        name: 'horizontalScroller.tsx',
        path: 'packages/semi-ui-react/AIChatInput/horizontalScroller.tsx',
    },
    {
        type: 'folder',
        id: '2',
        name: 'AIChatInput',
        path: 'packages/semi-ui-react/AIChatInput',
    },
    {
        type: 'web',
        id: '3',
        name: 'web',
    },
    {
        type: 'change',
        id: '4',
        name: 'recentChange',
    },
    {
        type: 'branch',
        id: '5',
        name: 'Branch',
        detail: 'Diff with Main Branch',
        branch: 'feat/aichatinput',
        targetBranch: 'feat/targetBranch',
    },
    {
        type: 'terminal',
        id: '6',
        name: 'From 1-2',
        from: 1,
        to: 2,
    },
];

// Icon components rather than elements: the top slot renders each one through Dynamic, with its own props
const refTypeToIconMap = new Map<string, Component<IconProps>>([
    ['file', IconFile],
    ['folder', IconFolder],
    ['branch', IconBranch],
    ['terminal', IconTerminal],
    ['web', IconGlobeStroke],
    ['change', IconConnectionPoint2],
    ['git', IconGit],
    ['code', IconCode],
]);

function isImageType(item: Attachment) {
    const PIC_PREFIX = 'image/';
    const PIC_SUFFIX_ARRAY = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'];
    const { name = '', fileInstance } = item;
    const suffix = name.split('.').pop() ?? '';
    let result = false;
    const type = fileInstance?.type ?? '';
    if (type.startsWith(PIC_PREFIX)) {
        result = true;
    } else if (PIC_SUFFIX_ARRAY.includes(suffix)) {
        result = true;
    }
    return result;
}

// the icon is the only visible label, so the label text becomes its name
const radioButtonProps = [
    { label: '快速', value: 'fast' },
    { label: '深度思考', value: 'think' },
];

const radioIcons: Record<string, Component<IconProps>> = {
    fast: IconTemplateStroked,
    think: IconSearch,
};

export default function AIChatInputDemo() {
    let input: AIChatInputElement | undefined;
    const [reference, setReference] = createSignal(customReferences);

    const renderLeftMenu = () => (
        <>
            <Configure.RadioButton options={radioButtonProps} renderLabel={(option) => <Dynamic component={radioIcons[String(option.value)]} aria-label={String(option.label)} />} initValue="fast" field="mode" />
        </>
    );

    const renderTopSlot = (slot: RenderTopSlotProps) => (
        <div class="ai-chat-input-topSlot">
            <For each={slot.references}>
                {(item) => (
                    <div class="item">
                        <span class="item-icon">
                            <Show when={item.type && refTypeToIconMap.get(item.type)} keyed>
                                {(icon) => <Dynamic component={icon} size="small" class="item-left item-icon" />}
                            </Show>
                            <button type="button" class="item-icon-delete" aria-label="删除" onClick={() => setReference((list) => list.filter((ref) => ref.id !== item.id))}>
                                <IconClose size="small" />
                            </button>
                        </span>
                        <span class="item-content">
                            {item.name}
                            <Show when={item.type === 'branch'}>
                                <span class="detail">{item.detail}</span>
                            </Show>
                        </span>
                    </div>
                )}
            </For>
            <For each={slot.attachments}>
                {(item) => (
                    <div class="item">
                        <span class="item-icon">
                            <Show when={isImageType(item)} fallback={<IconUpload size="small" class="item-left item-icon" />}>
                                <img class="item-image item-left" src={item.url} alt={item.name} />
                            </Show>
                            <button type="button" class="item-icon-delete" aria-label="删除" onClick={() => input?.deleteUploadFile(item)}>
                                <IconClose size="small" />
                            </button>
                        </span>
                        <span class="item-content">{item.name}</span>
                    </div>
                )}
            </For>
        </div>
    );

    return (
        <AIChatInput
            class="aiChatInput-customTopSlot"
            renderTopSlot={renderTopSlot}
            references={reference()}
            showUploadFile={false}
            showReference={false}
            renderConfigureArea={renderLeftMenu}
            ref={(element) => (input = element)}
            uploadProps={uploadProps}
            style={outerStyle}
            placeholder="自定义渲染顶部内容,可用于渲染上传内容、引用内容"
        />
    );
}

自定义扩展

富文本区域可以自定义扩展,自定义扩展的实现可参考 Tiptap 自定义扩展。通过 extensions API 可将自定义扩展添加到 AIChatInput 组件中。

如果添加了自定义扩展,需要在 transformer 中添加对应的转换规则, 以保证在 onContentChange 中得到的该节点数据符合用户预期。示例中的节点视图直接用 DOM 实现(tiptap 的 addNodeView),候选面板用 solid-js/web 的 render 挂载 Solid 组件。

添加自定义扩展时有以下注意事项:

  • 请在自定义扩展中添加 isCustomSlot 的属性,该属性和自定义扩展前后的光标高度有关
  • 由于 AIChatInput 使用 Enter 作为发送热键,如果自定义扩展有使用 Enter 作为快捷操作,需要自行设置 editor.storage 中的 SemiAIChatInput.allowHotKeySend 用于表示热键是否应该被 AIChatInput 用于发送,避免热键冲突。sendHotKey 为 shift+enter 时,allowHotKeySend 为 false 期间单独按下的 Enter 不再换行,交给自定义扩展处理

自定义扩展定义及注意事项的示例如下:

horizontalScroller.tsx
AIChatInput
web
recentChange
BranchDiff with Main Branch
From 1-2
代码
import { For, Show, type Accessor, type Component, type Setter, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js';
import { Dynamic, render } from 'solid-js/web';
import { computePosition, shift } from '@floating-ui/dom';
import { Node, mergeAttributes, posToDOMRect, type Editor } from '@tiptap/core';
import { Mention, type MentionNodeAttrs, type MentionOptions } from '@tiptap/extension-mention';
import { AIChatInput, type AIChatInputAttachment as Attachment, type AIChatInputContent as Content, type AIChatInputElement, type AIChatInputReference as Reference, type AIChatInputRenderTopSlotProps as RenderTopSlotProps } from '@fibrous/semi-ai-solid';
import { IconFile, IconFolder, IconBranch, IconCode, IconGit, IconGlobeStroke, IconChevronRight, IconClose, IconUpload, IconTerminal, IconConnectionPoint2, type IconProps } from '@fibrous/semi-icons-solid';

const uploadProps = { action: 'https://api.semi.design/upload' };
const outerStyle = { margin: '12px' };

interface PanelItem {
    icon: Component<IconProps>;
    key: string;
    type: string;
    name: string;
    path?: string;
}

const TestAction: Record<string, PanelItem[]> = {
    'Files & Folders': [
        {
            icon: IconFile,
            key: '1-1',
            type: 'file',
            name: 'TagInput.scss',
            path: 'package/semi-founctaion/TagInput.scss',
        },
        {
            icon: IconFolder,
            key: '1-2',
            type: 'folder',
            name: 'package',
            path: '/package',
        },
        {
            icon: IconFolder,
            key: '1-3',
            type: 'folder',
            name: 'semi-ui',
            path: '/package/semi-ui',
        },
    ],
    Git: [
        {
            icon: IconBranch,
            key: '2-1',
            type: 'branch',
            name: 'fix/tag',
        },
        {
            icon: IconCode,
            key: '2-2',
            type: 'code',
            name: 'v2.86.0',
            path: '/package',
        },
        {
            icon: IconGit,
            key: '2-3',
            type: 'git',
            name: 'chore: publish',
        },
    ],
};

const FirstLevel = Object.keys(TestAction);

// A ProseMirror node view that renders plain DOM: tiptap has no Solid binding, and the value is the only state
const ReferSlot = Node.create({
    name: 'referSlot',
    inline: true,
    group: 'inline',
    atom: true,
    selectable: false,
    addAttributes() {
        return {
            value: {
                default: '输入内容',
                parseHTML: (element) => element.getAttribute('data-value'),
                renderHTML: (attributes) => ({
                    'data-value': attributes.value,
                }),
            },
            info: {
                default: '',
                parseHTML: (element) => element.getAttribute('data-info'),
                renderHTML: (attributes) => ({
                    'data-info': attributes.info,
                }),
            },
            type: {
                default: 'text',
                parseHTML: (element) => element.getAttribute('data-type'),
                renderHTML: (attributes) => ({
                    'data-type': attributes.type,
                }),
            },
            uniqueKey: {
                default: '',
                parseHTML: (element) => element.getAttribute('data-unique-key'),
                renderHTML: (attributes) => ({
                    'data-unique-key': attributes.uniqueKey,
                }),
            },
            // Sizes the caret around the custom node; every custom node needs it
            isCustomSlot: AIChatInput.getCustomSlotAttribute(),
        };
    },
    parseHTML() {
        return [
            {
                tag: 'refer-slot',
            },
        ];
    },
    renderHTML({ HTMLAttributes }) {
        return ['refer-slot', mergeAttributes(HTMLAttributes)];
    },
    addNodeView() {
        return ({ node }) => {
            const dom = document.createElement('span');
            dom.className = 'ai-chat-input-refer-slot-wrapper';
            const slot = document.createElement('span');
            slot.className = 'ai-chat-input-refer-slot';
            slot.textContent = node.attrs.value ? node.attrs.value : '';
            dom.append(slot);
            return {
                dom,
                update: (updated) => {
                    if (updated.type !== node.type) {
                        return false;
                    }
                    slot.textContent = updated.attrs.value ? updated.attrs.value : '';
                    return true;
                },
            };
        };
    },
});

interface ReferSlotAttrs {
    value: string;
    info: string;
    type?: string;
    uniqueKey: string;
}

const updatePosition = (editor: Editor, element: HTMLElement) => {
    const virtualElement = {
        getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
    };
    void computePosition(virtualElement, element, {
        placement: 'bottom-start',
        strategy: 'absolute',
        middleware: [shift()],
    }).then(({ x, y, strategy }) => {
        element.style.width = 'max-content';
        element.style.position = strategy;
        element.style.left = `${x}px`;
        element.style.top = `${y}px`;
    });
};

type MentionSuggestion = MentionOptions['suggestion'];
type MentionStartProps = Parameters<NonNullable<ReturnType<NonNullable<MentionSuggestion['render']>>['onStart']>>[0];

// Content the panel returns through command, not limited to Mention's own attributes
interface MentionCommandProps extends Partial<MentionNodeAttrs> {
    item?: PanelItem;
    allowHotKeySend?: boolean;
}

interface MentionListHandle {
    onKeyDown: (props: { event: KeyboardEvent; exitCb?: () => void }) => boolean;
}

// tiptap types the command with the node attributes only; the panel also passes its item and hot key flag through it
interface PanelProps extends Omit<MentionStartProps, 'command'> {
    command: (props: MentionCommandProps) => void;
}

interface MentionListProps {
    /** The suggestion plugin pushes a new props object on every update, so the panel reads it through this accessor */
    current: Accessor<PanelProps>;
    register: (handle: MentionListHandle) => void;
}

const suggestion: MentionSuggestion = {
    items: () => FirstLevel,
    command: ({ editor, range, props }) => {
        const { item, allowHotKeySend } = props as MentionCommandProps;
        if (typeof allowHotKeySend === 'boolean') {
            // AIChatInput registers its state under the extension name, which tiptap's Storage type does not know
            (editor.storage as Record<string, { allowHotKeySend: boolean }>).SemiAIChatInput.allowHotKeySend = allowHotKeySend;
        }
        if (item) {
            editor
                .chain()
                .focus()
                .insertContentAt(range, {
                    type: 'referSlot',
                    attrs: {
                        type: item.type,
                        value: item.name || '',
                        info: JSON.stringify({ path: item.path }),
                        uniqueKey: item.key,
                    },
                })
                .run();
        }
    },
    render: () => {
        let element: HTMLDivElement | undefined;
        let dispose: (() => void) | undefined;
        let setPanel: Setter<PanelProps> | undefined;
        let handle: MentionListHandle | undefined;

        const onExit = () => {
            dispose?.();
        };

        return {
            onStart: (start) => {
                const [current, setCurrent] = createSignal(start as PanelProps);
                setPanel = setCurrent;
                element = document.createElement('div');
                dispose = render(() => <MentionList current={current} register={(next) => (handle = next)} />, element);
                if (!start.clientRect) return;
                element.style.position = 'absolute';
                document.body.appendChild(element);
                updatePosition(start.editor, element);
            },
            onUpdate(next) {
                setPanel?.(next as PanelProps);
                if (!next.clientRect || !element) return;
                updatePosition(next.editor, element);
            },
            onKeyDown(props) {
                return handle?.onKeyDown({ ...props, exitCb: onExit }) ?? false;
            },
            onExit() {
                element?.remove();
                dispose?.();
            },
        };
    },
};

function MentionList(props: MentionListProps) {
    const [level, setLevel] = createSignal(1);
    const [options, setOptions] = createSignal<(string | PanelItem)[]>(FirstLevel);
    const [selectedIndex, setSelectedIndex] = createSignal(0);

    const filterOptions = createMemo(() => {
        const query = props.current().query;
        if (!query) {
            return options();
        }
        return options().filter((item) => {
            const name = typeof item === 'string' ? item : item.name;
            return name.toLowerCase().includes(query.toLowerCase());
        });
    });

    // a new list or query starts again from the first match
    createEffect(on([options, () => props.current().query], () => setSelectedIndex(0), { defer: true }));

    // While the panel is open, Enter belongs to it, not to AIChatInput's send
    onMount(() => {
        props.current().command({ allowHotKeySend: false });
        onCleanup(() => props.current().command({ allowHotKeySend: true }));
    });

    const selectItem = (item: string | PanelItem) => {
        if (typeof item === 'string') {
            setLevel(2);
            setOptions(TestAction[item]);
        } else {
            props.current().command({ item });
        }
    };

    const onItemClick = (item: string | PanelItem) => {
        const atFirstLevel = level() === 1;
        selectItem(item);
        // Clicking an option blurs the editor, so return focus after entering the second level
        if (atFirstLevel) {
            props.current().editor.commands.focus();
        }
    };

    // registered once mounted: the suggestion plugin forwards key presses to the handle from then on
    onMount(() => {
        props.register({
            onKeyDown: ({ event, exitCb }) => {
                const list = filterOptions();
                if (event.key === 'ArrowUp') {
                    setSelectedIndex((selectedIndex() + list.length - 1) % list.length);
                    return true;
                }
                if (event.key === 'ArrowDown') {
                    setSelectedIndex((selectedIndex() + 1) % list.length);
                    return true;
                }
                if (event.key === 'Enter') {
                    const item = list[selectedIndex()];
                    if (item) {
                        selectItem(item);
                    }
                    return true;
                }
                if (event.key === 'Escape') {
                    if (level() === 1) {
                        exitCb?.();
                    } else {
                        setLevel(1);
                        setOptions(FirstLevel);
                    }
                    return true;
                }
                return false;
            },
        });
    });

    return (
        <div role="menu" class="ai-chat-input-custom-extension-dropdown-menu" style={{ width: level() === 1 ? '200px' : '300px' }}>
            <Show when={filterOptions().length} fallback={<div class="item">No result</div>}>
                <For each={filterOptions()}>
                    {(item, index) => (
                        <div
                            role="menuitem"
                            tabIndex={-1}
                            class={index() === selectedIndex() ? 'is-selected optionItem' : 'optionItem '}
                            onClick={() => onItemClick(item)}
                            onKeyDown={(e) => {
                                if (e.key === 'Enter') {
                                    onItemClick(item);
                                }
                            }}
                            onMouseEnter={() => setSelectedIndex(index())}
                        >
                            {typeof item === 'string' ? (
                                <span>{item}</span>
                            ) : (
                                <div class="level2Item">
                                    <Dynamic component={item.icon} />
                                    <span class="name">{item.name}</span>
                                    <span class="path">{item.path}</span>
                                </div>
                            )}
                            <Show when={level() === 1}>
                                <IconChevronRight class="option-item-arrow" />
                            </Show>
                        </div>
                    )}
                </For>
            </Show>
        </div>
    );
}

function isImageType(item: Attachment) {
    const PIC_PREFIX = 'image/';
    const PIC_SUFFIX_ARRAY = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'];
    const { name = '', fileInstance } = item;
    const suffix = name.split('.').pop() ?? '';
    let result = false;
    const type = fileInstance?.type ?? '';
    if (type.startsWith(PIC_PREFIX)) {
        result = true;
    } else if (PIC_SUFFIX_ARRAY.includes(suffix)) {
        result = true;
    }
    return result;
}

// Icon components rather than elements: the top slot renders each one through Dynamic, with its own props
const refTypeToIconMap = new Map<string, Component<IconProps>>([
    ['file', IconFile],
    ['folder', IconFolder],
    ['branch', IconBranch],
    ['terminal', IconTerminal],
    ['web', IconGlobeStroke],
    ['change', IconConnectionPoint2],
    ['git', IconGit],
    ['code', IconCode],
]);

const extensions = [
    ReferSlot,
    Mention.configure({
        HTMLAttributes: {
            class: 'mention',
        },
        suggestion,
    }),
];

const customReferences: Reference[] = [
    {
        type: 'file',
        id: '1',
        name: 'horizontalScroller.tsx',
        path: 'packages/semi-ui-react/AIChatInput/horizontalScroller.tsx',
    },
    {
        type: 'folder',
        id: '2',
        name: 'AIChatInput',
        path: 'packages/semi-ui-react/AIChatInput',
    },
    {
        type: 'web',
        id: '3',
        name: 'web',
    },
    {
        type: 'change',
        id: '4',
        name: 'recentChange',
    },
    {
        type: 'branch',
        id: '5',
        name: 'Branch',
        detail: 'Diff with Main Branch',
        branch: 'feat/aichatinput',
        targetBranch: 'feat/targetBranch',
    },
    {
        type: 'terminal',
        id: '6',
        name: 'From 1-2',
        from: 1,
        to: 2,
    },
];

const transformer = new Map([
    [
        'referSlot',
        ({ attrs }: { attrs: ReferSlotAttrs }) => {
            const { value, info, type = 'text', uniqueKey } = attrs;
            return {
                type: type,
                value: value,
                uniqueKey: uniqueKey,
                ...JSON.parse(info),
            };
        },
    ],
]);

const onContentChange = (content: Content[]) => {
    console.log('onContentChange', content);
};

export default function AIChatInputDemo() {
    let input: AIChatInputElement | undefined;
    const [reference, setReference] = createSignal(customReferences);

    const renderTopSlot = (slot: RenderTopSlotProps) => (
        <div class="ai-chat-input-topSlot">
            <For each={slot.content.filter((item) => item.type !== 'text')}>
                {(item) => (
                    <div class="item">
                        <span class="item-icon">
                            <Show when={item.type && refTypeToIconMap.get(item.type)} keyed>
                                {(icon) => <Dynamic component={icon} size="small" class="item-left item-icon" />}
                            </Show>
                            <button type="button" class="item-icon-delete" aria-label="删除" onClick={() => input?.deleteContent(item)}>
                                <IconClose size="small" />
                            </button>
                        </span>
                        <span class="item-content">
                            {item.name ? item.name : item.value}
                            <Show when={item.type === 'branch'}>
                                <span class="detail">{item.detail}</span>
                            </Show>
                        </span>
                    </div>
                )}
            </For>
            <For each={slot.references}>
                {(item) => (
                    <div class="item">
                        <span class="item-icon">
                            <Show when={item.type && refTypeToIconMap.get(item.type)} keyed>
                                {(icon) => <Dynamic component={icon} size="small" class="item-left item-icon" />}
                            </Show>
                            <button type="button" class="item-icon-delete" aria-label="删除" onClick={() => setReference((list) => list.filter((ref) => ref.id !== item.id))}>
                                <IconClose size="small" />
                            </button>
                        </span>
                        <span class="item-content">
                            {item.name}
                            <Show when={item.type === 'branch'}>
                                <span class="detail">{item.detail}</span>
                            </Show>
                        </span>
                    </div>
                )}
            </For>
            <For each={slot.attachments}>
                {(item) => (
                    <div class="item">
                        <span class="item-icon">
                            <Show when={isImageType(item)} fallback={<IconUpload size="small" class="item-left item-icon" />}>
                                <img class="item-image item-left" src={item.url} alt={item.name} />
                            </Show>
                            <button type="button" class="item-icon-delete" aria-label="删除" onClick={() => input?.deleteUploadFile(item)}>
                                <IconClose size="small" />
                            </button>
                        </span>
                        <span class="item-content">{item.name}</span>
                    </div>
                )}
            </For>
        </div>
    );

    return (
        <AIChatInput
            class="aiChatInput-customTopSlot"
            renderTopSlot={renderTopSlot}
            extensions={extensions}
            references={reference()}
            showUploadFile={false}
            showReference={false}
            onContentChange={onContentChange}
            ref={(element) => (input = element)}
            transformer={transformer}
            uploadProps={uploadProps}
            style={outerStyle}
            placeholder="使用 @ 触发"
        />
    );
}

API 参考

根元素带 aria-label 或 aria-labelledby 且未传 role 时,自动加 role="group"。附件卡的缩略图是装饰图(alt 为空),文件名由卡片内的可见文字提供。

AIChatInput

Props

属性说明类型默认值
canSend是否可以发送。未设置时,根据输入框内容和上传内容决定;引用内容不参与判断。boolean-
clearContentOnGeneratinggenerating 从 false 变为 true 时,是否清空输入框内容和附件。设为 false 可保留输入内容(适用于无限画布等非对话场景)。booleantrue
defaultContent输入框默认内容,支持 html string 以及 json 格式,同 Tiptap 的 Content;只在创建编辑器时读取。TiptapContent-
dropdownMatchTriggerWidth下拉弹出层是否与输入框宽度一致。booleantrue
extensions自定义 tiptap 扩展;只在创建编辑器时读取,之后变化不生效。Extensions-
generating是否正在生成中。为 true 时发送按钮变为停止生成按钮。booleanfalse
keepSkillAfterSend发送消息后是否保留已选的技能。booleanfalse
onBlur富文本输入区失去焦点时调用,参数为 focusout 事件。Type(event: FocusEvent) => void-
onConfigureChange配置区域的值变化时调用。移除配置项时 changedValue 为 undefined。Type(value: LeftMenuChangeProps, changedValue?: LeftMenuChangeProps) => void-
onContentChange输入框内容变化时调用,参数为当前内容。Type(contents: Content[]) => void-
onFocus富文本输入区获得焦点时调用,参数为 focusin 事件。Type(event: FocusEvent) => void-
onMessageSend点击发送按钮时调用,参数包含引用、上传文件、输入内容与配置区域的值。Type(props: MessageContent) => void-
onPaste输入区粘贴时调用,不阻止默认的粘贴行为,可通过 event.clipboardData 读取内容。Type(event: ClipboardEvent) => void-
onReferenceClick点击引用时调用,参数为该引用。Type(reference: Reference) => void-
onReferenceDelete删除引用时调用,参数为被删除的引用;引用列表由使用方持有,需要在回调中移除。Type(reference: Reference) => void-
onSkillChange技能切换时调用,移除技能时参数为 undefined。Type(skill?: Skill) => void-
onStopGenerate点击停止生成按钮时调用。Type() => void-
onUploadChange上传文件列表变化时调用。Type(props: OnChangeProps) => void-
placeholder输入框占位符,可以是字符串,也可以是按节点返回字符串的函数。PlaceholderProps-
popoverProps下拉弹出层的配置参数;class 与内部类名合并,style 与面板宽度合并。PopoverProps-
ref根元素,类型为 AIChatInputElement,带有 AIChatInputHandle 的方法。TypeRef<AIChatInputElement>-
references输入框的引用列表。Reference[]-
renderActionArea自定义底部的操作区域。参数 menuItem 依次为上传节点(showUploadButton 为 false 时是隐藏的上传节点,仍需渲染才能粘贴上传)与发送或停止按钮,class 为操作区域的类名。Type(props: ActionAreaProps) => JSX.Element-
renderConfigureArea自定义底部的配置区域,返回配置项(Configure 的子项)。Type() => JSX.Element-
renderReference自定义单个引用的渲染。Type(reference: Reference) => JSX.Element-
renderSkillIcon渲染技能列表中每项名称前的图标,参数为该技能。Type(skill: Skill) => JSX.Element-
renderSkillItem自定义技能列表中的单项。参数的 id、class、role、aria-selected、onClick 与 onMouseEnter 需要展开到根元素上。Type(props: RenderSkillItemProps) => JSX.Element-
renderSuggestionItem自定义建议列表中的单项。参数的 id、class、role、aria-selected、onClick 与 onMouseEnter 需要展开到根元素上。Type(props: RenderSuggestionItemProps) => JSX.Element-
renderTemplate自定义模版面板的内容。参数为当前技能(仅显示模版按钮且未选中技能时为 undefined)与模版点击回调,回调传入的内容会替换输入框内容。Type(skill: Skill | undefined, onTemplateClick: (content: string) => void) => JSX.Element-
renderTopSlot自定义顶部区域。参数包含引用、上传文件、输入内容与删除回调,参数的 getter 随当前值变化,函数只调用一次。Type(props: RenderTopSlotProps) => JSX.Element-
renderUploadButton自定义底部操作区的上传按钮 UI,上传与粘贴上传仍由组件内部托管。参数的 getter 随当前值变化,函数只调用一次。defaultNode 是创建默认按钮的渲染函数,在要放置按钮的位置调用。Type(props: RenderUploadButtonProps) => JSX.Element-
round底部的配置区域和操作区域按钮是否为全圆角。booleantrue
sendHotKey发送输入内容的键盘快捷键。enter 表示单独按下 Enter 发送,Shift 与 Enter 同时按下仅换行;shift+enter 相反。'enter' | 'shift+enter'"enter"
showPlaceholderWhenSkillOnly仅选中技能(无其他内容)时是否显示占位符,开启后占位符显示在技能后方;只在创建编辑器时读取。boolean-
showReference是否展示引用区域,用于配合 renderTopSlot 使用。booleantrue
showTemplateButton为 true 时始终展示模版按钮;未设置或为 false 时,由当前选中技能的 hasTemplate 决定是否展示。boolean-
showUploadButton是否显示右侧上传按钮。设为 false 时粘贴上传仍然有效。booleantrue
showUploadFile是否展示上传文件区域,用于配合 renderTopSlot 使用。booleantrue
skillHotKey输入框中唤起技能面板的热键。string-
skills技能列表,每项只含数据,图标由 renderSkillIcon 渲染。Skill[]-
suggestions建议列表,输入内容时按输入的内容展示。Suggestion[]-
templatesCls模版面板的类名。string-
templatesStyle模版面板的样式,与默认的最大高度合并,只接受对象。JSX.CSSProperties-
topSlotPosition顶部区域相对于引用区域与上传文件区域的位置。'top' | 'middle' | 'bottom'"top"
transformer自定义扩展的转换规则,用于保证 onContentChange 得到的节点数据符合预期。TypeMap<string, (obj: any) => any>-
uploadProps上传文件相关配置,取值同 Upload 的属性,不含 fileList 与 listType。TypeOmit<UploadProps, 'fileList' | 'listType'>-
uploadTipProps上传按钮的提示,取值同 Tooltip 的属性。使用 renderUploadButton 或 uploadProps.children 时,提示包裹在自定义节点外的 span 上。TooltipProps-

Methods

方法说明类型
changeTemplateVisible切换模版面板的可见性。Type(visible: boolean) => void
deleteContent删除富文本中的某一项,删除依据是 content 中的 uniqueKey。Type(content: Content) => void
deleteUploadFile删除上传文件中的某一项。Type(item: Attachment) => void
focusEditor聚焦输入框,默认聚焦到末尾,也可传入 FocusPosition。Type(pos?: FocusPosition) => void
getEditor获取当前的 tiptap Editor 实例,编辑器创建前为 null。Type() => Editor | null
setContent设置输入框内容。Type(content: TiptapContent) => void
setContentWhileSaveTool保留已选技能的同时设置输入框内容。Type(content: string) => void

class、style、ref 与未声明的原生属性落在根元素 div 上。onFocus、onBlur、onPaste 是富文本输入区的回调,不落在根元素上。

数据项类型:

type Suggestion = string | { content: string; [key: string]: any };

interface Reference {
    type?: string;
    id: string;
    [key: string]: any;
}

interface Content {
    type: string;
    [key: string]: any;
}

interface MessageContent {
    references?: Reference[];
    attachments?: Attachment[];
    inputContents?: Content[];
    setup?: Setup;
}

Configure.Select、Configure.Button、Configure.RadioButton 由 getConfigureItem 生成,值由 Configure 接管:不接受 value,field 必填,initValue 设置初始值,onChange 收到该项的新值。

属性说明类型默认值
field配置项在 Configure 值中的 key,必填string-
initValue初始值与被包装组件的 value 一致-
onChange该项的值变化回调(value: unknown) => void-

Configure.Select

其余属性同 SelectProps

Configure.Button

其余属性同 ButtonProps;值为 boolean,点击时切换,onClick 收到切换后的值 (value: boolean) => void

Configure.RadioButton

其余属性同 RadioGroupProps

Configure.Mcp

Props

属性说明类型默认值
afterClose菜单关闭且离场动画结束后调用。Type() => void-
afterOpen菜单打开且进场动画结束后调用;没有进场动画时打开后即调用;进场结束前被关闭的打开不调用。Type() => void-
aria-label弹出层 role 为 dialog 时的 aria-label。string-
aria-labelledby弹出层 role 为 dialog 时的 aria-labelledby。string-
arrowPointAtCenter箭头是否指向触发元素的中心;只在 showArrow 为 true 时生效。boolean-
autoAdjustOverflow弹出层被遮挡时是否自动调整方向,默认 true。boolean-
children替换标题下方内置的菜单,传入时不再由 options 生成菜单。JSX.Element-
class弹出层外层 .semi-dropdown-wrapper 的类名。string-
clickToHide在弹出层内点击时是否自动关闭弹出层。boolean-
clickTriggerToHide点击触发元素时是否关闭已打开的菜单。boolean-
closeOnEsc在触发元素或弹出层按 Esc 是否关闭菜单,默认 true。trigger 为 custom 时只调用 onEscKeyDown,由使用方决定是否关闭。boolean-
condition是否允许触发显示;仅显式设为 false 时,hover、click、focus 等触发行为不生效,trigger 为 custom 时仍按 visible 显示。由 true 变为 false 时立即关闭已显示的菜单。boolean-
contentClassName下拉菜单容器 .semi-dropdown 的类名。string-
disableFocusListenertrigger 为 hover 时,是否不响应键盘聚焦打开菜单,默认 false。boolean-
guardFocus焦点位于弹出层内时,Tab 是否在弹出层内循环,默认 false。boolean-
keepDOM关闭时是否保留菜单的 DOM 而不销毁,默认 false。boolean-
menu通过数据快速配置菜单,数据项为 node 为 item、title 或 divider 的对象,见下方 DropdownMenuItem;提供 render 时以 render 为准。DropDownMenuItem[]-
motion是否展示弹出层的进出场动画,默认 true。boolean-
mouseEnterDelay指针移入触发元素后延迟显示的毫秒数,默认 50,仅 trigger 为 hover 或 focus 时生效。number-
mouseLeaveDelay指针移出弹出层后延迟隐藏的毫秒数,仅 trigger 为 hover 或 focus 时生效,默认 100。number-
num触发器与标题中显示的数量,不传时为 options 的数量。number-
onClickOutSide按下触发元素与弹出层以外的区域时调用,参数是原生 MouseEvent。仅 trigger 为 click、contextMenu、custom 或开启 clickTriggerToHide 时生效。Type(e: MouseEvent) => void-
onConfigureButtonClick点击配置按钮时调用。Type() => void-
onEscKeyDown菜单打开期间按 Esc 时调用,参数是原生 KeyboardEvent。Type(e: KeyboardEvent) => void-
onOpen菜单显示、进场动画开始时调用,每次打开调用一次,早于 afterOpen;悬停打开后指针已离开而撤回的打开不调用。Type() => void-
onVisibleChange菜单显示状态变化时调用。visible 受控时只报告用户交互发起的请求,使用方自己改变 visible 不调用。Type(visible: boolean) => void-
optionsMCP 选项列表,选项只含数据,图标由 renderOptionIcon 渲染。McpOption[][]
position弹出菜单相对触发元素的位置,常用 bottom、bottomLeft、bottomRight,默认 bottom,更多见 Tooltip 的位置。Position-
prefixCls弹出层类名前缀,默认 semi-dropdown。string-
preventScroll聚焦时是否阻止浏览器滚动到被聚焦元素,默认 false。boolean-
renderOptionIcon渲染内置菜单每项的图标,参数为该选项。Type(option: McpOption) => JSX.Element-
returnFocusOnClose菜单关闭后焦点是否回到触发元素,默认 true。boolean-
role包裹菜单的弹层容器的 ARIA role,默认 presentation,菜单本身的 role 为 menu。string-
selectionMode本层菜单项的选择方式,已选项由 active 标记:single 时菜单项为带 aria-checked 的 menuitemradio,multiple 时为带 aria-checked 的 menuitemcheckbox;未设置时 showTick 开启则为带 aria-checked 的 menuitemcheckbox,否则为 menuitem。只影响角色与状态,勾的显示仍由 showTick 决定;嵌套的子菜单不继承。DropdownSelectionMode-
showArrow是否显示箭头,默认 false。boolean | JSX.Element-
showConfigure是否显示配置按钮。booleantrue
showTick是否在 active 的 Dropdown.Item 左侧展示表示选中的勾,默认 false;只作用于本 Dropdown 的菜单,嵌套的子菜单不继承。boolean-
spacing弹出层与触发元素的距离,单位为像素;缺省时顶层菜单为 4,嵌套的子菜单为 2。number | { x: number; y: number; }-
stopPropagation是否阻止弹出层上的 click、mousedown 与焦点进出事件冒泡,默认 false。boolean-
style下拉菜单容器 .semi-dropdown 的样式,接受对象(键用 kebab-case,如 'margin-top')或字符串。JSX.CSSProperties | string-
trigger触发菜单显示的行为,可选 hover、focus、click、custom、contextMenu,默认 hover;custom 时显示与否完全由 visible 决定。'hover' | 'focus' | 'click' | 'custom' | 'contextMenu'-
visible菜单是否显示。配合 trigger 为 custom 使用;传入后由该值决定显示,触发器、点击外部与 Esc 只通过 onVisibleChange 请求打开或关闭。boolean-
wrapperClassName触发元素被 span 包裹时,该 span 的类名。string-
wrapperId弹出层节点的 id,触发元素的 aria 属性指向它;缺省时自动生成。string-
wrapWhenSpecial触发元素为禁用或加载中的 Button 等特殊触发器时,是否在外层包裹 span 以保证触发行为,默认 true。boolean-

class、style 与未声明的属性透传给内部的 Dropdown,落点同 Dropdown。

选项只含数据,图标通过 renderOptionIcon(option) 渲染:

interface McpOption {
    value: string;
    label: string;
    [key: string]: unknown;
}

设计变量

下表是组件内部的 Sass 变量,不能覆写。定制样式请覆写 --semi-* CSS 变量(见 Design Token 页),或写不分层的 CSS 规则。
Sass 变量值用法
$color-aiChatInput-bordervar(--semi-color-border)AIChatInput的边框颜色
$color-aiChatInput_skill_item-bg-activevar(--semi-color-fill-0)AIChatInput技能项悬停时的背景颜色
$color-aiChatInput_suggestion_item-textvar(--semi-color-text-0)AIChatInput建议项的文本颜色
$color-aiChatInput_suggestion_item-bg-activevar(--semi-color-fill-0)AIChatInput建议项悬停时的背景颜色
$color-aiChatInput_references-textvar(--semi-color-text-2)AIChatInput引用项的文本颜色
$color-aiChatInput_reference-bgvar(--semi-color-fill-0)AIChatInput引用项的背景色
$color-aiChatInput_footer_send-textvar(--semi-white)AIChatInput底部发送按钮的内容颜色
$color-aiChatInput_footer_send-bg-defaultvar(--semi-color-primary)AIChatInput底部发送按钮的背景颜色 - 默认
$color-aiChatInput_footer_send-bg-hovervar(--semi-color-primary-hover)AIChatInput底部发送按钮的背景颜色-悬停态
$color-aiChatInput_footer_send-bg-activevar(--semi-color-primary-active)AIChatInput底部发送按钮的背景颜色-激活态
$color-aiChatInput_footer_send-bg-disabledvar(--semi-color-fill-2)AIChatInput底部发送按钮的背景颜色-禁用态
$color-aiChatInput_footer_upload-textvar(--semi-color-text-1)AIChatInput底部上传按钮的内容颜色
$color-aiChatInput_footer_upload-bg-defaulttransparentAIChatInput底部上传按钮的背景颜色 - 默认
$color-aiChatInput_footer_upload-bg-hovervar(--semi-color-fill-0)AIChatInput底部上传按钮的背景颜色-悬停态
$color-aiChatInput_footer_upload-bg-activevar(--semi-color-fill-1)AIChatInput底部上传按钮的背景颜色-悬停态
$color-aiChatInput_footer_configure_button-border-activevar(--semi-color-primary)AIChatInput底部配置区域按钮的按钮边框颜色 - 激活态
$color-aiChatInput_footer_configure_button-textvar(--semi-color-primary)AIChatInput底部配置区域按钮的按钮文字颜色 - 激活态
$color-aiChatInput_footer_configure_select-bordervar(--semi-color-border)AIChatInput底部配置区域下拉选择的按钮边框颜色
$color-aiChatInput_footer_configure_radio_button_checkedvar(--semi-color-text-1)AIChatInput底部配置区域下拉选择的单选按钮选中时的背景颜色
$color-aiChatInput_footer_configure_radio_button_checked-bgvar(--semi-color-bg-2)AIChatInput底部配置区域下拉选择的单选按钮选中时的背景颜色
$color-aiChatInput_footer-configure_mcp_header_title-textvar(--semi-color-text-2)AIChatInput底部配置区域mcp配置下拉的顶部内容的标题颜色
$color-aiChatInput_attachment-bgvar(--semi-color-fill-0)AIChatInput附件项的背景颜色
$color-aiChatInput_attachment_name-textvar(--semi-color-text-0)AIChatInput附件项内容名称的文本颜色
$color-aiChatInput_attachment_delete-bgvar(--semi-color-text-1)AIChatInput附件项内容的删除按钮的背景颜色
$color-aiChatInput_attachment_delete-iconvar(--semi-color-bg-0)AIChatInput附件项内容的删除按钮的图标颜色
$color-aiChatInput_attachment_scroll_button-bgvar(--semi-color-bg-2)AIChatInput附件项滚动按钮的背景颜色
$color-aiChatInput_attachment_scroll_buttonvar(--semi-color-text-2)AIChatInput附件项滚动按钮的背景颜色
$color-aiChatInput_rich_text-input_slot-bgvar(--semi-color-primary-light-default)AIChatInput富文本编辑器输入插槽的背景颜色
$color-aiChatInput_rich_text-input_slot-textvar(--semi-color-primary)AIChatInput富文本编辑器输入插槽的文本颜色
$color-aiChatInput_rich_text-input_slot_placeholdervar(--semi-color-primary-disabled)AIChatInput富文本编辑器输入插槽占位符的文本颜色
$color-aiChatInput_rich_text-select_slot-bgvar(--semi-color-primary-light-default)AIChatInput富文本编辑器选择器插槽的背景颜色
$color-aiChatInput_rich_text-select_selection-textvar(--semi-color-primary)AIChatInput富文本编辑器选择器插槽的选择器文本颜色
$color-aiChatInput_rich_text-select_slot_arrowvar(--semi-color-text-2)AIChatInput富文本编辑器选择器插槽的下拉箭头颜色
$color-aiChatInput_rich_text-skill_slot-textvar(--semi-color-primary)AIChatInput富文本编辑器技能插槽的文本颜色
$color-aiChatInput_rich_text-skill_slot-bg-hovervar(--semi-color-primary-light-default)AIChatInput富文本编辑器技能插槽的背景色 - 悬停态
$color-aiChatInput_rich_text-skill_slot_delete-bgvar(--semi-color-text-1)AIChatInput富文本编辑器技能插槽的删除按钮的背景颜色
$color-aiChatInput_rich_text-skill_slot_delete-textvar(--semi-color-bg-0)AIChatInput富文本编辑器技能插槽的删除按钮的背景颜色
$color-aiChatInput_ref_icon_word-bgvar(--semi-blue-4)AIChatInput 引用/上传内容为文本文件时的图标背景色
$color-aiChatInput_ref_icon_word-pdfvar(--semi-red-4)AIChatInput 引用/上传内容为 pdf 时的图标背景色
$color-aiChatInput_ref_icon_word-codevar(--semi-teal-5)AIChatInput 引用/上传内容为代码的图标背景色
$color-aiChatInput_ref_icon_word-excelvar(--semi-green-5)AIChatInput 引用/上传内容为表格类型的图标背景色
$color-aiChatInput_ref_icon_word-videovar(--semi-purple-4)AIChatInput 引用/上传内容为视频类型的图标背景色
$color-aiChatInput_ref_icon_word-audiovar(--semi-purple-4)AIChatInput 引用/上传内容为音频类型的图标背景色
$color-aiChatInput_ref_icon_word-unknownvar(--semi-grey-5)AIChatInput 引用/上传内容为未知类型的图标背景色
$color-aiChatInput_reference_delete-bgvar(--semi-color-fill-1)AIChatInput引用项删除图标的背景色 - 悬停态
$color-aiChatInput_footer_configure_select-textvar(--semi-color-text-1)AIChatInput底部配置区域下拉选择的按钮文字颜色
$color-aiChatInput_footer_configure_select-textvar(--semi-color-text-1)AIChatInput底部配置区域下拉选择的按钮文字颜色
$spacing-aiChatInput-padding20pxAIChatInput的外边距
$spacing-aiChatInput_skill-paddingY4pxAIChatInput技能面板的垂直内边距
$spacing-aiChatInput_skill-paddingX0pxAIChatInput技面板的水平内边距
$spacing-aiChatInput_skill_item-columnGap8pxAIChatInput技能项内容的水平间距
$spacing-aiChatInput_skill_item-paddingY8pxAIChatInput技能项的垂直内边距
$spacing-aiChatInput_skill_item-paddingX20pxAIChatInput技能项的水平内边距
$spacing-aiChatInput_suggestion_item-paddingY8pxAIChatInput建议项的垂直内边距
$spacing-aiChatInput_suggestion_item-paddingX20pxAIChatInput建议项的水平内边距
$spacing-aiChatInput_references-marginBottom8pxAIChatInput引用项的底部外边距
$spacing-aiChatInput_references-columnGap4pxAIChatInput技能块内容的水平间距
$spacing-aiChatInput_references-rowGap4pxAIChatInput技能块内容的垂直间距
$spacing-aiChatInput_reference-paddingY8pxAIChatInput引用项的垂直内边距
$spacing-aiChatInput_reference-paddingX12pxAIChatInput引用项的水平内边距
$spacing-aiChatInput_reference-columnGap8pxAIChatInput引用项内容的水平间距
$spacing-aiChatInput_footer-marginTop20pxAIChatInput引用项的顶部外边距
$spacing-aiChatInput_footer_action-columnGap8pxAIChatInput底部右侧操作区域项目的水平间距
$spacing-aiChatInput_footer_configure-columnGap8pxAIChatInput底部配置区域项目的水平间距
$spacing-aiChatInput_footer_configure_radio_button-padding4pxAIChatInput底部配置区域下拉选择的单选按钮的内边距
$spacing-aiChatInput_footer_configure_mcp_header-paddingTop8pxAIChatInput底部配置区域mcp配置下拉的顶部内容的上外边距
$spacing-aiChatInput_footer_configure_mcp_header-paddingBottom0pxAIChatInput底部配置区域mcp配置下拉的顶部内容的下外边距
$spacing-aiChatInput_footer_configure_mcp_header-paddingX16pxAIChatInput底部配置区域mcp配置下拉的顶部内容的水平外边距
$spacing-aiChatInput_footer_configure_mcp_columnGap4pxAIChatInput底部配置区域mcp配置下拉的顶部内容的水平间距
$spacing-aiChatInput_attachment-columnGap8pxAIChatInput附件项的水平间距
$spacing-aiChatInput_attachment-padding8pxAIChatInput附件项的内间距
$spacing-aiChatInput_attachment_content_size-columnGap4pxAIChatInput附件项类型和大小的间距
$spacing-aiChatInput_attachment_scroll_wrapper-marginBottom8pxAIChatInput附件项滚动区域的底部外边距
$spacing-aiChatInput_attachment_scroll_container8pxAIChatInput附件项滚动区域的内间距
$spacing-aiChatInput_attachment_scroll_button_offsetX10pxAIChatInput附件项滚动按钮的和滚动区域的水平距离
$spacing-aiChatInput_rich_text-input_slot-paddingY2pxAIChatInput富文本编辑器输入插槽的垂直内边距
$spacing-aiChatInput_rich_text-input_slot-paddingX4pxAIChatInput富文本编辑器输入插槽的水平内边距
$spacing-aiChatInput_rich_text-input_slot-marginY2pxAIChatInput富文本编辑器输入插槽的垂直内边距
$spacing-aiChatInput_rich_text-input_slot-marginX2pxAIChatInput富文本编辑器输入插槽的水平内边距
$spacing-aiChatInput_rich_text-select_slot-paddingX4pxAIChatInput富文本编辑器选择器插槽的水平内边距
$spacing-aiChatInput_rich_text-select_slot-paddingY2pxAIChatInput富文本编辑器选择器插槽的垂直内边距
$spacing-aiChatInput_rich_text-select_slot-marginX4pxAIChatInput富文本编辑器选择器插槽的垂直外边距
$spacing-aiChatInput_rich_text-select_slot-marginY2pxAIChatInput富文本编辑器选择器插槽的水平外边距
$spacing-aiChatInput_rich_text-select_selection-marginLeft0pxAIChatInput富文本编辑器选择器插槽的选择器文本的左侧外边距
$spacing-aiChatInput_rich_text-skill_slot-paddingX4pxAIChatInput富文本编辑器技能插槽的水平内边距
$spacing-aiChatInput_rich_text-skill_slot-paddingY0pxAIChatInput富文本编辑器技能插槽的垂直内边距
$spacing-aiChatInput_rich_text-skill_slot-marginX2pxAIChatInput富文本编辑器技能插槽的水平外边距
$spacing-aiChatInput_rich_text-skill_slot-marginY2pxAIChatInput富文本编辑器技能插槽的垂直外边距
$spacing-aiChatInput_rich_text-skill_slot-top0pxAIChatInput富文本编辑器技能插槽删除按钮距离顶部距离
$spacing-aiChatInput_rich_text-skill_slot-right0pxAIChatInput富文本编辑器技能插槽删除按钮距离右侧距离
$spacing-aiChatInput_reference_icon-marginRight4pxAIChatInput 引用项的图标距离右侧距离
$spacing-aiChatInput_references_delete-padding2pxAIChatInput引用项删除图标的内间距
$width-aiChatInput-border1pxAIChatInput的边框宽度
$width-aiChatInput_footer_action_button32pxAIChatInput底部右侧操作按钮的宽度
$height-aiChatInput_footer_action_button32pxAIChatInput底部右侧操作按钮的高度
$width-aiChatInput_footer_configure_button-border1pxAIChatInput底部配置区域按钮的边框宽度
$width-aiChatInput_footer_configure_select-border1pxAIChatInput底部配置区域下拉选择的边框宽度
$height-aiChatInput_footer_configure_mcp_header16pxAIChatInput底部配置区域mcp配置下拉的顶部内容的高度
$width-aiChatInput_attachment240pxAIChatInput附件项的宽度(含内边距)
$height-aiChatInput_attachment_left36pxAIChatInput附件项左侧图标/图片的宽高
$width-aiChatInput_attachment-content180pxAIChatInput附件项内容的宽度
$height-aiChatInput_attachment-content20pxAIChatInput附件项内容的高度
$width-aiChatInput_attachment_delete16pxAIChatInput附件项内容的删除按钮的宽度
$width-aiChatInput_attachment_scroll_button16pxAIChatInput附件项滚动按钮的宽度
$width-aiChatInput_attachment_scroll_button_icon12pxAIChatInput附件项滚动按钮的图标大小
$height-aiChatInput_rich_text-select-slot24pxAIChatInput富文本编辑器选择器插槽的高度
$width-aiChatInput_rich_text-select_slot_arrow16pxAIChatInput富文本编辑器选择器插槽的下拉箭头宽度
$width-aiChatInput_rich_text-select_slot_delete12pxAIChatInput富文本编辑器选择器插槽的删除按钮的宽高
$width-aiChatInput_rich_text-select_slot_delete_icon8pxAIChatInput富文本编辑器选择器插槽的删除按钮icon的宽高
$width-aiChatInput_rich_text-input_slot2pxAIChatInput富文本编辑器输入插槽的最小宽度
$width-aiChatInput_reference_icon16pxAIChatInput 引用项的图标宽度
$radius-aiChatInput12pxAIChatInput输入框的圆角
$radius-aiChatInput_skill8pxAIChatInput技能块的圆角
$radius-aiChatInput_reference6pxAIChatInput引用项的圆角
$radius-aiChatInput_footer_round9999pxAIChatInput底部操作项目全圆角模式的圆角
$radius-aiChatInput_footer_action_button8pxAIChatInput底部右侧操作按钮的圆角
$radius-aiChatInput_attachment6pxAIChatInput附件项的圆角
$radius-aiChatInput_rich_text-input_slot4pxAIChatInput富文本编辑器输入插槽的圆角
$radius-aiChatInput_rich_text-select_slot4pxAIChatInput富文本编辑器选择器插槽的圆角
$radius-aiChatInput_rich_text-skill_slot4pxAIChatInput富文本编辑器技能插槽的圆角
$radius-aiChatInput_reference_icon2pxAIChatInput 引用项的图标圆角
$radius-aiChatInput_suggestion_item6pxAIChatInput 建议项的圆角
$font-aiChatInput_footer_configure_select-fontWeight600AIChatInput底部配置区域下拉选择的字体粗细
$font-aiChatInput_attachment_content_name-fontWeight600AIChatInput附件项内容的名称字体粗细
$font-aiChatInput_attachment_content_delete-fontSize12pxAIChatInput附件项内容的删除按钮的图标大小
$font-aiChatInput_rich_text-fontSize$font-size-regularAIChatInput富文本编辑器文字大小
$font-aiChatInput_rich_text-lineHeight24pxAIChatInput富文本编辑器文本行高
$font-aiChatInput_rich_text-input_slot-fontSize$font-size-regularAIChatInput富文本编辑器输入插槽文字大小
$font-aiChatInput_rich_text-input_slot-lineHeight20pxAIChatInput富文本编辑器输入框插槽文本行高
$font-aiChatInput_rich_text-input_slot-fontWeight600AIChatInput富文本编辑器输入插槽文本字重
$font-aiChatInput_rich_text-skill_slot-fontWeight600AIChatInput富文本编辑器技能插槽文本字重
$font-aiChatInput_footer_configure_radio_button-fontSize14pxAIChatInput底部配置区域下拉选择的单选按钮的文字大小
$z-aiChatInput_attachment_scroll_button10AIChatInput附件项滚动按钮的z-index
$z-aiChatInput_rich_text-input_slot_placeholder1AIChatInput富文本编辑器输入插槽占位符的z-index
$shadow-aiChatInput_attachment_scroll_buttonvar(--semi-shadow-elevated)AIChatInput附件项滚动按钮的阴影