AI 组件

聊天输入框

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

使用场景

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

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

代码演示

如何引入

<script setup lang="ts">
    import { AIChatInput, getConfigureItem } from '@fibrous/semi-ai-vue';
</script>

基本用法

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

  • uploadProps 配置文件上传相关的参数,取值同 Upload 的属性,不含 fileList 与 listType;Upload 的事件以 onSuccess、onError 等监听属性传入
  • @uploadChange 获取文件上传变化
  • 删除上传文件时,会调用 uploadProps.onRemove,并遵循 uploadProps.beforeRemove(支持 Promise)
  • placeholder 输入框的占位符
  • defaultContent 输入框的默认内容
  • @contentChange 输入框内容变化时触发,参数为当前输入框的内容
代码
<script setup lang="ts">
    import { AIChatInput, type AIChatInputContent as Content } from '@fibrous/semi-ai-vue';
    import type { UploadOnChangeProps as OnChangeProps } from '@fibrous/semi-ui-vue';

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

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

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

<template>
    <AIChatInput placeholder="输入内容或者上传内容..." style="margin: 12px" :uploadProps="uploadProps" @contentChange="onContentChange" @uploadChange="onUploadChange" />
</template>

消息发送

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

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

点击停止生成按钮,会触发 @stopGenerate,用户可在该事件中处理停止生成的逻辑, 如将 generating 设为 false。

dy.jpeg
jpeg 130kb
resso.jpeg
jpeg 222kb
代码
<script setup lang="ts">
    import { ref } from 'vue';
    import { AIChatInput, type AIChatInputContent as Content, type AIChatInputReference as Reference, type AIChatInputUploadProps } from '@fibrous/semi-ai-vue';
    import type { UploadOnChangeProps } from '@fibrous/semi-ui-vue';

    const uploadProps: AIChatInputUploadProps = {
        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 references = ref<Reference[]>([
        {
            id: '1',
            type: 'text',
            content: '测试文本,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字,这里是一段很长的文字',
        },
    ]);
    const generating = ref(false);

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

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

    const toggleGenerate = () => {
        generating.value = !generating.value;
    };

    const onMessageSend = () => {
        toggleGenerate();
        references.value = [];
    };

    const handleReferenceDelete = (item: Reference) => {
        references.value = references.value.filter((ref) => ref.id !== item.id);
    };
</script>

<template>
    <AIChatInput
        defaultContent="点击发送按钮,观察上传内容、引用内容、输入框内容变化"
        :generating="generating"
        :references="references"
        style="margin: 12px"
        :uploadProps="uploadProps"
        @contentChange="onContentChange"
        @messageSend="onMessageSend"
        @referenceDelete="handleReferenceDelete"
        @stopGenerate="toggleGenerate"
        @uploadChange="onUploadChange"
    />
</template>

富文本输入区

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

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

可以通过组件实例(模板 ref)的 setContent 方法来设置输入框的内容,使用 focusEditor 方法可以将输入框的焦点设置到编辑器中。

代码
<script setup lang="ts">
    import { ref, useTemplateRef } from 'vue';
    import { AIChatInput, type AIChatInputHandle } from '@fibrous/semi-ai-vue';

    const uploadProps = { action: 'https://api.semi.design/upload' };
    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 names = Object.keys(temp);
    const contents = Object.values(temp);

    const activeIndex = ref(0);
    const input = useTemplateRef<AIChatInputHandle>('input');

    const setTemplate = (index: number) => {
        activeIndex.value = index;
        input.value?.setContent(contents[index]);
        input.value?.focusEditor();
    };
</script>

<template>
    <div class="aiChatInput-radio" role="tablist">
        <div v-for="(item, index) in names" :key="item" :aria-selected="index === activeIndex" :class="['aiChatInput-radio-item', { 'aiChatInput-radio-item-selected': index === activeIndex }]" role="tab" tabindex="0" @click="setTemplate(index)" @keydown.enter="setTemplate(index)">
            {{ item }}
        </div>
    </div>
    <AIChatInput ref="input" :defaultContent="temp['input-slot']" placeholder="输入内容或者上传内容" style="margin: 12px" :uploadProps="uploadProps" />
</template>

引用

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

  • #reference 插槽自定义单个引用内容的渲染,作用域参数为 { item, index }。
  • @referenceDelete 处理引用内容的删除。
  • @referenceClick 处理引用内容的点击。
代码
<script setup lang="ts">
    import { ref } from 'vue';
    import { AIChatInput, type AIChatInputReference as Reference } from '@fibrous/semi-ai-vue';

    const uploadProps = { action: 'https://api.semi.design/upload' };
    const references = ref<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 handleReferenceDelete = (item: Reference) => {
        references.value = references.value.filter((ref) => ref.id !== item.id);
    };

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

<template>
    <AIChatInput placeholder="用于查看引用内容的用例" :references="references" style="margin: 12px" :uploadProps="uploadProps" @referenceClick="handleReferenceClick" @referenceDelete="handleReferenceDelete" />
</template>

配置区域

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

可通过 #configureArea 插槽自定义输入框的操作按钮。

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

AIChatInput 将管理配置项的状态,用户可以通过 @configureChange 监听配置项的变化。一定要配置 field 属性,用于标识配置项的唯一标识。如需设置初始值,可通过 initValue 属性设置。配置项自身的值变化也可通过 @change 监听,参数为该项的新值。

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

代码
<script setup lang="ts">
    import type { Component } from 'vue';
    import { AIChatInput, type AIChatInputLeftMenuChangeProps as LeftMenuChangeProps } from '@fibrous/semi-ai-vue';
    import { IconBookOpenStroked, IconFeishuLogo, IconGit, IconFigma } from '@fibrous/semi-icons-vue';

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

    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 mcpOptions = [
        { label: '飞书文档', value: 'feishu' },
        { label: 'Github Mcp', value: 'github' },
        { label: 'IconFigma Mcp', value: 'IconFigma' },
    ];
    const mcpIcons: Record<string, Component> = { feishu: IconFeishuLogo, github: IconGit, IconFigma };

    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);
    };
</script>

<template>
    <AIChatInput placeholder="用于查看左下方配置项的用例" style="margin: 12px" :uploadProps="uploadProps" @configureChange="onConfigureChange">
        <template #configureArea>
            <Configure.Select field="model" initValue="GPT-4o" :optionList="modelOptions" />
            <Configure.Button field="onlineSearch">
                <template #icon>
                    <IconBookOpenStroked />
                </template>
                联网搜索
            </Configure.Button>
            <Configure.Mcp :options="mcpOptions" showConfigure @configureButtonClick="onConfigureButtonClick">
                <template #optionIcon="{ item }">
                    <component :is="mcpIcons[item.value]" />
                </template>
            </Configure.Mcp>
            <Configure.RadioButton field="thinkType" initValue="fast" :options="radioButtonProps" />
        </template>
    </AIChatInput>
</template>

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

function getConfigureItem<C extends Component, VK extends keyof PropsOf<C>, CK extends keyof PropsOf<C>>(
    component: C,
    opts?: {
        // 指定需要接管的组件接收值的 prop,默认是 modelValue
        valueKey?: VK;
        // 指定需要接管的组件的值变化对应的监听名,默认是 onUpdate:modelValue
        onKeyChangeFnName?: CK;
        className?: string;
        defaultProps?: Partial<PropsOf<C>>;
    }
): Component<
    Omit<PropsOf<C>, VK | CK | 'onChange'> & {
        // 配置项在 AIChatInput 配置值中的 key,必填
        field: string;
        initValue?: PropsOf<C>[VK];
    }
>;

返回的组件按被包装组件的 props 检查属性,field 必填,initValue 的类型与 valueKey 对应的属性一致。被包装组件的其余属性、监听和插槽原样传入。值变化监听的第一个参数必须就是新值,该值同时由配置项的 @change 事件发出。

使用示例如下:

代码
<script setup lang="ts">
    import { AIChatInput, getConfigureItem, type AIChatInputLeftMenuChangeProps as LeftMenuChangeProps } from '@fibrous/semi-ai-vue';
    import { Cascader } from '@fibrous/semi-ui-vue';

    const uploadProps = { action: 'https://api.semi.design/upload' };
    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 CustomCascader = getConfigureItem(Cascader, {
        className: 'aiChatInput-cascader-configure',
    });

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

<template>
    <AIChatInput placeholder="用于查看左下方配置项的用例" style="margin: 12px" :uploadProps="uploadProps" @configureChange="onConfigureChange">
        <template #configureArea>
            <CustomCascader field="model" :initValue="['GPT', 'GPT-4o']" :treeData="cascaderModalOptions" />
        </template>
    </AIChatInput>
</template>

操作区域

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

interface AIChatInputActionAreaSlotProps {
    menuItem: VNode[];
    class: string;
}

使用示例如下:

代码
<script setup lang="ts">
    import { AIChatInput } from '@fibrous/semi-ai-vue';
    import { IconDeleteStroked } from '@fibrous/semi-icons-vue';
    import { Divider, Button } from '@fibrous/semi-ui-vue';

    const uploadProps = { action: 'https://api.semi.design/upload' };
</script>

<template>
    <AIChatInput placeholder="输入内容或者上传内容..." style="margin: 12px" :uploadProps="uploadProps">
        <template #actionArea="{ menuItem, class: actionClass }">
            <div :class="actionClass">
                <div style="display: flex; align-items: center">
                    <Button aria-label="清空内容" style="border-radius: 50%" type="tertiary">
                        <template #icon>
                            <IconDeleteStroked />
                        </template>
                    </Button>
                    <Divider layout="vertical" style="margin-left: 8px" />
                </div>
                <component :is="node" v-for="(node, index) in menuItem" :key="index" />
            </div>
        </template>
    </AIChatInput>
</template>

自定义上传按钮

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

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

interface AIChatInputUploadButtonSlotProps {
    defaultNode: VNode;
    onOpenFileDialog: () => void;
    disabled: boolean;
    attachments: Attachment[];
}
代码
<script setup lang="ts">
    import { AIChatInput } from '@fibrous/semi-ai-vue';
    import { IconUpload } from '@fibrous/semi-icons-vue';

    const uploadProps = { action: 'https://api.semi.design/upload' };
</script>

<template>
    <AIChatInput placeholder="自定义上传按钮(仍支持粘贴上传)" style="margin: 12px" :uploadProps="uploadProps">
        <template #uploadButton="{ onOpenFileDialog, disabled }">
            <button aria-label="上传" class="semi-button semi-button-borderless" :disabled="disabled" type="button" @click.stop="onOpenFileDialog">
                <IconUpload />
            </button>
        </template>
    </AIChatInput>
</template>

底部按钮形状

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

代码
<script setup lang="ts">
    import { ref } from 'vue';
    import { AIChatInput } from '@fibrous/semi-ai-vue';
    import { IconBookOpenStroked, IconFeishuLogo, IconGit, IconFigma } from '@fibrous/semi-icons-vue';
    import { RadioGroup, Radio, type RadioValue } from '@fibrous/semi-ui-vue';

    const { Configure } = AIChatInput;
    const uploadProps = { action: 'https://api.semi.design/upload' };
    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 mcpOptions = [
        { label: '飞书文档', value: 'feishu' },
        { label: 'Github Mcp', value: 'github' },
        { label: 'IconFigma Mcp', value: 'IconFigma' },
    ];
    const mcpIcons = { feishu: IconFeishuLogo, github: IconGit, IconFigma };

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

    const shape = ref<RadioValue | undefined>('square');
</script>

<template>
    <RadioGroup v-model="shape" aria-label="单选组合示例" name="demo-radio-group">
        <Radio value="round">圆形</Radio>
        <Radio value="square">方形</Radio>
    </RadioGroup>
    <AIChatInput placeholder="下方按钮为方形的用例" :round="shape === 'round'" style="margin: 12px" :uploadProps="uploadProps">
        <template #configureArea>
            <Configure.Select field="model" initValue="GPT-4o" :optionList="modelOptions" />
            <Configure.Button field="onlineSearch">
                <template #icon>
                    <IconBookOpenStroked />
                </template>
                联网搜索
            </Configure.Button>
            <Configure.Mcp :options="mcpOptions">
                <template #optionIcon="{ item }">
                    <component :is="mcpIcons[item.value as keyof typeof mcpIcons]" />
                </template>
            </Configure.Mcp>
            <Configure.RadioButton field="mode" initValue="fast" :options="radioButtonProps" />
        </template>
    </AIChatInput>
</template>

建议

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

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

还可通过 #suggestionItem 插槽自定义建议列表的展示,需要把作用域参数里除 suggestion 之外的 id、class、role、aria-selected、onClick、onMouseenter 用 v-bind 绑定到根元素上。

代码
<script setup lang="ts">
    import { ref } from 'vue';
    import { AIChatInput, type AIChatInputContent as Content } from '@fibrous/semi-ai-vue';

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

    const suggestions = ref<string[]>([]);

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

<template>
    <AIChatInput placeholder="输入内容,当内容长度小于 4个字符可以看到建议,使用上下按键可切换侯选项" style="margin: 12px" :suggestions="suggestions" :uploadProps="uploadProps" @contentChange="onChange" />
</template>

技能及模版

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

skills 的格式如下

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

技能只含数据,技能前的图标通过 #skillIcon="{ item, index }" 插槽渲染,整项的展示可通过 #skillItem 插槽自定义(绑定方式同建议项)。

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

interface AIChatInputTemplateSlotProps {
    skill: Skill | undefined;
    // 模版点击回调,点击模版后,用模版的内容替换输入框的内容
    onTemplateClick: (content: string) => void;
}

使用示例如下:

代码
<script setup lang="ts">
    import { ref, useTemplateRef } from 'vue';
    import { AIChatInput, type AIChatInputHandle } from '@fibrous/semi-ai-vue';
    import { IconTemplateStroked, IconSearch } from '@fibrous/semi-icons-vue';

    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 skills = [
        {
            value: 'writing',
            label: '帮我写作',
            hasTemplate: true,
        },
        {
            value: 'AI 编程',
            label: 'AI coding',
        },
    ];
    const skillIcons: Record<string, typeof IconSearch> = { writing: IconTemplateStroked, 'AI 编程': IconSearch };

    const templates = [
        {
            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>线下读书会活动 的方案,需要包含但不限于策划目标、详细计划、所需资源和预算、效果评估、风险应对等。',
                },
            ],
        },
    ];

    const groupIndex = ref(0);
    const input = useTemplateRef<AIChatInputHandle>('input');

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

<template>
    <AIChatInput ref="input" placeholder="输入 / 唤起技能,选择技能后,点击模版按钮可查看模版,可通过鼠标上下按键切换侯选项" skillHotKey="/" :skills="skills" style="margin: 12px" :uploadProps="uploadProps">
        <template #configureArea>
            <Configure.Select field="model" initValue="GPT-4o" :optionList="modelOptions" />
        </template>
        <template #skillIcon="{ item }">
            <component :is="skillIcons[item.value ?? '']" />
        </template>
        <template #template="{ skill }">
            <div v-if="skill?.value === 'writing'" class="aiChatInput-template">
                <div class="template-header">
                    <button v-for="(item, index) in templates" :key="item.group" :class="['template-header-item', { 'template-header-item-active': groupIndex === index }]" style="border: none; font-family: inherit" type="button" @click="groupIndex = index">
                        {{ item.group }}
                    </button>
                </div>
                <div class="template-content">
                    <button v-for="item in templates[groupIndex].children" :key="item.title" class="template-content-item" style="font: inherit; text-align: left" type="button" @click="setTemplate(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>
                </div>
            </div>
        </template>
    </AIChatInput>
</template>

自定义渲染顶部区域

用户可以通过 #topSlot 插槽自定义渲染顶部区域,可自行渲染引用,上传内容以及配置项。可结合 showReference 和 showUploadFile API 控制是否展示引用和上传文件。另外,可通过 topSlotPosition API 配置自定义渲染内容相对于引用区域,上传展示区域的相对位置。

interface AIChatInputTopSlotProps {
    // 引用
    references: Reference[];
    // 上传内容
    attachments: Attachment[];
    // 输入框消息
    content: Content[];
    onUploadFileDelete: (attachment: Attachment) => void;
    // 触发 @referenceDelete,引用由使用方持有,需要在事件中移除
    onReferenceDelete: (reference: Reference) => void;
}

使用用例如下:

horizontalScroller.tsx
AIChatInput
web
recentChange
Branch Diff with Main Branch
From 1-2
代码
<script setup lang="ts">
    import { ref, type Component } from 'vue';
    import { AIChatInput, type AIChatInputAttachment as Attachment, type AIChatInputReference as Reference } from '@fibrous/semi-ai-vue';
    import { IconClose, IconUpload, IconFile, IconFolder, IconBranch, IconTerminal, IconGlobeStroke, IconConnectionPoint2, IconTemplateStroked, IconSearch, IconGit, IconCode } from '@fibrous/semi-icons-vue';

    const { Configure } = AIChatInput;
    const radioButtonProps = [
        { label: '快速', value: 'fast' },
        { label: '深度思考', value: 'think' },
    ];
    const modeIcons: Record<string, Component> = { fast: IconTemplateStroked, think: IconSearch };
    const uploadProps = { action: 'https://api.semi.design/upload' };

    const references = ref<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,
        },
    ]);

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

    const refTypeToIconMap: Record<string, Component> = {
        file: IconFile,
        folder: IconFolder,
        branch: IconBranch,
        terminal: IconTerminal,
        web: IconGlobeStroke,
        change: IconConnectionPoint2,
        git: IconGit,
        code: IconCode,
    };

    const onReferenceDelete = (reference: Reference) => {
        references.value = references.value.filter((item) => item.id !== reference.id);
    };
</script>

<template>
    <AIChatInput class="aiChatInput-customTopSlot" placeholder="自定义渲染顶部内容,可用于渲染上传内容、引用内容" :references="references" :showReference="false" :showUploadFile="false" style="margin: 12px" :uploadProps="uploadProps" @referenceDelete="onReferenceDelete">
        <template #topSlot="{ attachments, references: items, onReferenceDelete: deleteReference, onUploadFileDelete }">
            <div class="ai-chat-input-topSlot">
                <div v-for="item in items" :key="item.id" class="item">
                    <span class="item-icon">
                        <component :is="refTypeToIconMap[item.type ?? '']" v-if="item.type && refTypeToIconMap[item.type]" class="item-left item-icon" size="small" />
                        <button aria-label="删除" class="item-icon-delete" type="button" @click="deleteReference(item)">
                            <IconClose size="small" />
                        </button>
                    </span>
                    <span class="item-content">
                        {{ item.name }}
                        <span v-if="item.type === 'branch'" class="detail">{{ item.detail }}</span>
                    </span>
                </div>
                <div v-for="item in attachments" :key="item.uid" class="item">
                    <span class="item-icon">
                        <img v-if="isImageType(item)" :alt="item.name" class="item-image item-left" :src="item.url" />
                        <IconUpload v-else class="item-left item-icon" size="small" />
                        <button aria-label="删除" class="item-icon-delete" type="button" @click="onUploadFileDelete(item)">
                            <IconClose size="small" />
                        </button>
                    </span>
                    <span class="item-content">{{ item.name }}</span>
                </div>
            </div>
        </template>
        <template #configureArea>
            <Configure.RadioButton field="mode" initValue="fast" :options="radioButtonProps">
                <!-- the icon is the only label, so it carries the name -->
                <template #label="{ option }">
                    <component :is="modeIcons[String(option.value)]" :aria-label="option.label" />
                </template>
            </Configure.RadioButton>
        </template>
    </AIChatInput>
</template>

自定义扩展

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

自定义节点的视图用 @tiptap/vue-3 的 VueNodeViewRenderer 渲染。如果添加了自定义扩展,需要在 transformer 中添加对应的转换规则, 以保证在 @contentChange 中得到的该节点数据符合用户预期。

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

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

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

horizontalScroller.tsx
AIChatInput
web
recentChange
Branch Diff with Main Branch
From 1-2
代码
<script setup lang="ts">
    import { computed, defineComponent, h, ref, shallowRef, useTemplateRef, watch, type Component, type CSSProperties } from 'vue';
    import { computePosition, shift } from '@floating-ui/dom';
    import { Node, mergeAttributes } from '@tiptap/core';
    import { Mention, type MentionNodeAttrs, type MentionOptions } from '@tiptap/extension-mention';
    import { NodeViewWrapper, VueNodeViewRenderer, nodeViewProps, posToDOMRect } from '@tiptap/vue-3';
    import { AIChatInput, type AIChatInputHandle, type AIChatInputAttachment as Attachment, type AIChatInputContent as Content, type AIChatInputReference as Reference } from '@fibrous/semi-ai-vue';
    import { IconFile, IconFolder, IconBranch, IconCode, IconGit, IconGlobeStroke, IconChevronRight, IconClose, IconUpload, IconTerminal, IconConnectionPoint2 } from '@fibrous/semi-icons-vue';
    import { useMounted } from '@fibrous/semi-ui-vue/hooks';

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

    interface PanelItem {
        icon: Component;
        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);

    // Node views must be components but a demo file holds one SFC, so it is a render function
    const ReferSlotView = defineComponent({
        props: nodeViewProps,
        setup: (props) => () => h(NodeViewWrapper, { as: 'span', class: 'ai-chat-input-refer-slot-wrapper' }, () => h('span', { class: 'ai-chat-input-refer-slot' }, props.node.attrs.value ?? '')),
    });

    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 VueNodeViewRenderer(ReferSlotView);
        },
    });

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

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

    type MentionPanelProps = Omit<MentionStartProps, 'command'> & {
        command: (props: MentionCommandProps) => void;
    };

    // The Teleport target is the browser's body, which the server render and the hydration pass do not have
    const mounted = useMounted();
    // Suggestion panel state; null means closed
    const panel = shallowRef<MentionPanelProps | null>(null);
    const level = ref(1);
    const options = shallowRef<(string | PanelItem)[]>(FirstLevel);
    const selectedIndex = ref(0);
    const floating = useTemplateRef<HTMLElement>('floating');
    const floatingStyle = ref<CSSProperties>({ position: 'absolute', width: 'max-content', left: '0px', top: '0px' });

    const filterOptions = computed(() => {
        const query = panel.value?.query;
        return query
            ? options.value.filter((item) => {
                  const name = typeof item === 'string' ? item : item.name;
                  return name.toLowerCase().includes(query.toLowerCase());
              })
            : options.value;
    });

    watch([options, () => panel.value?.query], () => {
        selectedIndex.value = 0;
    });

    // The panel follows the caret; option changes resize it, so reposition
    watch(
        [panel, filterOptions],
        () => {
            const current = panel.value;
            if (!current?.clientRect || !floating.value) return;
            const virtualElement = {
                getBoundingClientRect: () => posToDOMRect(current.editor.view, current.editor.state.selection.from, current.editor.state.selection.to),
            };
            void computePosition(virtualElement, floating.value, {
                placement: 'bottom-start',
                strategy: 'absolute',
                middleware: [shift()],
            }).then(({ x, y, strategy }) => {
                floatingStyle.value = { position: strategy, width: 'max-content', left: `${x}px`, top: `${y}px` };
            });
        },
        { flush: 'post' }
    );

    const setHotKeySend = (allow: boolean) => {
        // While the panel is open, Enter belongs to it, not to AIChatInput's send
        panel.value?.command({ allowHotKeySend: allow });
    };

    const closePanel = () => {
        setHotKeySend(true);
        panel.value = null;
    };

    const selectItem = (item: string | PanelItem) => {
        if (typeof item === 'string') {
            level.value = 2;
            options.value = TestAction[item];
        } else {
            panel.value?.command({ item });
        }
    };

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

    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: () => ({
            onStart: (props) => {
                level.value = 1;
                options.value = FirstLevel;
                panel.value = props as MentionPanelProps;
                setHotKeySend(false);
            },
            onUpdate: (props) => {
                // After Escape closes the panel it stays closed until this suggestion ends
                if (panel.value) {
                    panel.value = props as MentionPanelProps;
                }
            },
            onKeyDown: ({ event }) => {
                if (!panel.value) return false;
                if (event.key === 'ArrowUp') {
                    selectedIndex.value = (selectedIndex.value + filterOptions.value.length - 1) % filterOptions.value.length;
                    return true;
                }
                if (event.key === 'ArrowDown') {
                    selectedIndex.value = (selectedIndex.value + 1) % filterOptions.value.length;
                    return true;
                }
                if (event.key === 'Enter') {
                    const item = filterOptions.value[selectedIndex.value];
                    if (item) {
                        selectItem(item);
                    }
                    return true;
                }
                if (event.key === 'Escape') {
                    if (level.value === 1) {
                        closePanel();
                    } else {
                        level.value = 1;
                        options.value = FirstLevel;
                    }
                    return true;
                }
                return false;
            },
            onExit: closePanel,
        }),
    };

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

    const references = ref<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,
        },
    ]);

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

    const refTypeToIconMap: Record<string, Component> = {
        file: IconFile,
        folder: IconFolder,
        branch: IconBranch,
        terminal: IconTerminal,
        web: IconGlobeStroke,
        change: IconConnectionPoint2,
        git: IconGit,
        code: IconCode,
    };

    const input = useTemplateRef<AIChatInputHandle>('input');

    const richContent = (content: Content[]) => content.filter((item) => item.type !== 'text');

    const onReferenceDelete = (reference: Reference) => {
        references.value = references.value.filter((item) => item.id !== reference.id);
    };

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

    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),
                };
            },
        ],
    ]);
</script>

<template>
    <AIChatInput
        ref="input"
        class="aiChatInput-customTopSlot"
        :extensions="extensions"
        placeholder="使用 @ 触发"
        :references="references"
        :showReference="false"
        :showUploadFile="false"
        style="margin: 12px"
        :transformer="transformer"
        :uploadProps="uploadProps"
        @contentChange="onContentChange"
        @referenceDelete="onReferenceDelete"
    >
        <template #topSlot="{ attachments, content, references: items, onReferenceDelete: deleteReference, onUploadFileDelete }">
            <div class="ai-chat-input-topSlot">
                <div v-for="(item, index) in richContent(content)" :key="item.key ?? index" class="item">
                    <span class="item-icon">
                        <component :is="refTypeToIconMap[item.type]" v-if="refTypeToIconMap[item.type]" class="item-left item-icon" size="small" />
                        <button aria-label="删除" class="item-icon-delete" type="button" @click="input?.deleteContent(item)">
                            <IconClose size="small" />
                        </button>
                    </span>
                    <span class="item-content">
                        {{ item.name ? item.name : item.value }}
                        <span v-if="item.type === 'branch'" class="detail">{{ item.detail }}</span>
                    </span>
                </div>
                <div v-for="item in items" :key="item.id" class="item">
                    <span class="item-icon">
                        <component :is="refTypeToIconMap[item.type ?? '']" v-if="item.type && refTypeToIconMap[item.type]" class="item-left item-icon" size="small" />
                        <button aria-label="删除" class="item-icon-delete" type="button" @click="deleteReference(item)">
                            <IconClose size="small" />
                        </button>
                    </span>
                    <span class="item-content">
                        {{ item.name }}
                        <span v-if="item.type === 'branch'" class="detail">{{ item.detail }}</span>
                    </span>
                </div>
                <div v-for="item in attachments" :key="item.uid" class="item">
                    <span class="item-icon">
                        <img v-if="isImageType(item)" :alt="item.name" class="item-image item-left" :src="item.url" />
                        <IconUpload v-else class="item-left item-icon" size="small" />
                        <button aria-label="删除" class="item-icon-delete" type="button" @click="onUploadFileDelete(item)">
                            <IconClose size="small" />
                        </button>
                    </span>
                    <span class="item-content">{{ item.name }}</span>
                </div>
            </div>
        </template>
    </AIChatInput>
    <Teleport v-if="mounted" to="body">
        <div v-if="panel" ref="floating" :style="floatingStyle">
            <div class="ai-chat-input-custom-extension-dropdown-menu" role="menu" :style="{ width: level === 1 ? '200px' : '300px' }">
                <template v-if="filterOptions.length">
                    <div v-for="(item, index) in filterOptions" :key="index" :class="['optionItem', { 'is-selected': index === selectedIndex }]" role="menuitem" tabindex="-1" @click="onItemClick(item)" @focusin="selectedIndex = index" @keydown.enter="onItemClick(item)" @mouseenter="selectedIndex = index">
                        <span v-if="typeof item === 'string'">{{ item }}</span>
                        <div v-else class="level2Item">
                            <component :is="item.icon" />
                            <span class="name">{{ item.name }}</span>
                            <span class="path">{{ item.path }}</span>
                        </div>
                        <IconChevronRight v-if="level === 1" class="option-item-arrow" />
                    </div>
                </template>
                <div v-else class="item">No result</div>
            </div>
        </div>
    </Teleport>
</template>

API 参考

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

AIChatInput

Props

属性说明类型默认值
canSend是否可以发送。未设置时,根据输入框内容和上传内容决定;引用内容不参与判断。booleansemiGlobalDefault("AIChatInput", "canSend")
clearContentOnGeneratinggenerating 从 false 变为 true 时,是否清空输入框内容和附件。设为 false 可保留输入内容(适用于无限画布等非对话场景)。booleansemiDefault("AIChatInput", "clearContentOnGenerating", true)
defaultContent输入框默认内容,支持 html string 以及 json 格式,同 Tiptap 的 Content;只在创建编辑器时读取。ContentsemiGlobalDefault("AIChatInput", "defaultContent")
dropdownMatchTriggerWidth下拉弹出层是否与输入框宽度一致。booleansemiDefault("AIChatInput", "dropdownMatchTriggerWidth", true)
extensions自定义 tiptap 扩展;只在创建编辑器时读取,之后变化不生效。ExtensionssemiGlobalDefault("AIChatInput", "extensions")
generating是否正在生成中。为 true 时发送按钮变为停止生成按钮。booleansemiDefault("AIChatInput", "generating", false)
keepSkillAfterSend发送消息后是否保留已选的技能。booleansemiDefault("AIChatInput", "keepSkillAfterSend", false)
placeholder输入框占位符,可以是字符串,也可以是按节点返回字符串的函数。PlaceholderPropsundefined
popoverProps下拉弹出层的配置参数;class 与内部类名合并,style 与面板宽度合并。AIChatInputPopoverPropssemiGlobalDefault("AIChatInput", "popoverProps")
references输入框的引用列表。Reference[]semiGlobalDefault("AIChatInput", "references")
round底部的配置区域和操作区域按钮是否为全圆角。booleansemiDefault("AIChatInput", "round", true)
sendHotKey发送输入内容的键盘快捷键。enter 表示单独按下 Enter 发送,Shift 与 Enter 同时按下仅换行;shift+enter 相反。"enter" | "shift+enter"semiDefault("AIChatInput", "sendHotKey", "enter")
showPlaceholderWhenSkillOnly仅选中技能(无其他内容)时是否显示占位符,开启后占位符显示在技能后方;只在创建编辑器时读取。booleansemiDefault("AIChatInput", "showPlaceholderWhenSkillOnly", false)
showReference是否展示引用区域,用于配合 #topSlot 使用。booleansemiDefault("AIChatInput", "showReference", true)
showTemplateButton是否展示模板按钮。未设置时,根据当前选中技能的 hasTemplate 决定。booleansemiDefault("AIChatInput", "showTemplateButton", false)
showUploadButton是否显示右侧上传按钮。设为 false 时粘贴上传仍然有效。booleansemiDefault("AIChatInput", "showUploadButton", true)
showUploadFile是否展示上传文件区域,用于配合 #topSlot 使用。booleansemiDefault("AIChatInput", "showUploadFile", true)
skillHotKey输入框中触发技能面板的热键。stringsemiGlobalDefault("AIChatInput", "skillHotKey")
skills技能列表。技能只含数据,图标使用 #skillIcon 插槽。Skill[]semiGlobalDefault("AIChatInput", "skills")
suggestions建议列表,每项为字符串或带 content 的对象。Suggestion[]semiGlobalDefault("AIChatInput", "suggestions")
templatesCls模板面板的类名。stringsemiGlobalDefault("AIChatInput", "templatesCls")
templatesStyle模板面板的样式。StyleValuesemiGlobalDefault("AIChatInput", "templatesStyle")
topSlotPosition#topSlot 的位置,相对于引用区域与上传文件区域。"top" | "bottom" | "middle"semiDefault("AIChatInput", "topSlotPosition", "top")
transformer自定义扩展的转换规则,键为节点名,值为把节点转换成 contentChange 内容项的函数。TypeMap<string, (obj: any) => any>semiGlobalDefault("AIChatInput", "transformer")
uploadTipProps上传按钮提示的 Tooltip 配置,不含 content;提示内容使用 #uploadTip 插槽。TypeOmit<TooltipProps, "content">semiGlobalDefault("AIChatInput", "uploadTipProps")
uploadProps上传文件相关配置,同 Upload,但不接受 fileList 与 listType;onChange、onRemove 由 AIChatInput 自己调用,beforeRemove 支持 Promise。AIChatInputUploadPropssemiGlobalDefault("AIChatInput", "uploadProps")

Events

事件说明回调参数
blur富文本输入框失焦时触发。会遮住根元素上的原生 blur。[event: FocusEvent]
configureChange配置区域发生变化时触发,参数为全部配置值和本次变化的值;移除配置项时第二个参数为 undefined。Type[value: LeftMenuChangeProps, changedValue?: LeftMenuChangeProps | undefined]
contentChange输入框内容变化时触发,参数为当前内容项列表。[contents: Content[]]
focus富文本输入框聚焦时触发。会遮住根元素上的原生 focus。[event: FocusEvent]
messageSend点击发送按钮时触发,参数包含引用、上传文件、输入内容和配置区域内容。[message: MessageContent]
paste监听输入框的粘贴事件,不阻止默认粘贴行为,可通过 event.clipboardData 获取内容。会遮住根元素上的原生 paste。[event: ClipboardEvent]
referenceClick点击引用时触发。[reference: Reference]
referenceDelete删除引用时触发。引用由使用方持有,需要在回调中自行移除。[reference: Reference]
skillChange技能切换时触发,移除技能时参数为 undefined。[skill: Skill | undefined]
stopGenerate点击停止生成按钮时触发。[]
uploadChange上传文件列表变化时触发。[props: UploadOnChangeProps]

Slots

插槽说明作用域参数
reference自定义渲染每个引用,作用域参数为 item 与 index。AIChatInputReferenceSlotProps
topSlot自定义顶部区域,作用域参数含引用、上传内容、输入内容和删除方法。位置由 topSlotPosition 决定。AIChatInputTopSlotProps
uploadButton自定义上传按钮内容,上传与粘贴上传逻辑仍由组件托管;作用域参数含 defaultNode、onOpenFileDialog、disabled、attachments。AIChatInputUploadButtonSlotProps
uploadTip上传按钮提示的内容。any
configureArea底部配置区域,放置 AIChatInput.Configure 的各配置项。any
actionArea自定义底部操作区域,作用域参数 menuItem 为上传节点(showUploadButton 为 false 时是隐藏的 Upload,仍需渲染才能粘贴上传)与发送或停止按钮,class 为操作区域的类名。AIChatInputActionAreaSlotProps
suggestionItem自定义每个建议项,作用域参数含 suggestion 和需要绑定到根元素的 id、class、role、aria-selected、onClick、onMouseenter。AIChatInputSuggestionItemSlotProps
skillItem自定义技能列表的每一项,作用域参数含 skill 和需要绑定到根元素的 id、class、role、aria-selected、onClick、onMouseenter。AIChatInputSkillItemSlotProps
skillIcon技能面板中每个技能前的图标,作用域参数为 item 与 index。AIChatInputSkillIconSlotProps
template模板面板的内容,作用域参数为当前技能 skill 与用传入内容替换输入框内容的 onTemplateClick。AIChatInputTemplateSlotProps

Exposes

名称说明类型
changeTemplateVisible切换模板弹出层的可见性。Type(visible: boolean) => void
deleteContent删除富文本中的某一项,按 content 中的 uniqueKey 匹配。Type(content: Content) => void
deleteUploadFile删除上传文件中的某一项。Type(item: Attachment) => void
focusEditor聚焦输入框,默认聚焦到末尾。Type(pos?: FocusPosition | undefined) => void
getEditor获取 tiptap 的 editor 实例,编辑器创建前为 null。Type() => Editor | null
setContent设置输入框内容。Type(content: Content) => void
setContentWhileSaveTool保留技能项的同时设置输入框内容。Type(content: string) => void

class、style 与未声明的属性落在根元素上。focus、blur、paste 事件来自富文本输入框,与原生事件同名,会遮住根元素上的原生同名事件。

数据项类型:

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 生成,值由 AIChatInput 接管:不接受 modelValue,field 必填,initValue 设置初始值,@change 收到该项的新值。

属性说明类型默认值
field配置项在配置值中的 key,必填string-
initValue初始值与被包装组件的 modelValue 一致-
@change该项的值变化时触发,参数为新值(value: unknown) => void-

Configure.Select

其余属性同 SelectProps

Configure.Button

其余属性同 ButtonProps,图标使用 #icon 插槽;值为 boolean,点击时切换,@click 收到切换后的值 (value: boolean) => void,并输出 aria-pressed

Configure.RadioButton

其余属性同 RadioGroupProps

Configure.Mcp

Props

属性说明类型默认值
optionsMCP 选项列表。选项只含数据,图标使用 #optionIcon 插槽。McpOption[]undefined
num触发器与标题中显示的数量,不传时为 options 的数量。numberundefined
showConfigure是否显示配置按钮。booleantrue

Events

事件说明回调参数
configureButtonClick点击配置按钮时触发。[]

Slots

插槽说明作用域参数
default替换标题下方的内置菜单。any
optionIcon每个选项的图标,作用域参数为 item 与 index。McpOptionIconSlotProps

Exposes

名称说明类型
无

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

选项只含数据,图标通过 #optionIcon 插槽渲染:

interface McpOption {
    value: string;
    label: string;
    disabled?: boolean;
    [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附件项滚动按钮的阴影