最开始做 AI 聊天,只需要一个输入框加发送按钮就能搞定。但随着 AI 功能越做越丰富,文件上传、语音输入、切换大模型、联网搜索、深度思考全都要塞进聊天底部,小小的输入框硬生生扩容成了全能的 AI 输入控制台。
这篇文章就拿 TinyRobot 里的 Sender 组件举例,顺着业务需求一路拆解,聊聊功能变多之后,组件边界是怎么一步步被撑大、又如何靠架构优化实现灵活适配。
一、需求越堆越多,Sender 组件被迫一路扩容
阶段 1:极简版 AI 输入框,够用就好
项目初期需求特别简单,只需要实现最基础的对话能力:
- 正常打字输入消息,一键发送
- AI 回复生成过程中,可以随时中断输出
- 一键清空输入内容,限制最大输入字数避免超长文本
配套最简代码如下(删减了尺寸、调试等无关配置):
html
<TrSender
v-model="message"
placeholder="给 AI 发送消息..."
:loading="loading"
clearable
show-word-limit
:max-length="2000"
@submit="sendMessage"
@cancel="stopGeneration"
/>
这个阶段 Sender 的工作非常纯粹:只管文字录入、字数管控、清空内容,点击发送触发消息推送;AI 正在输出时,发送按钮自动切换成停止按钮,仅此而已。
阶段 2:功能疯狂加量,普通输入框变身 AI 控制台
业务迭代加速,大家不再满足纯文字聊天,一大堆附加能力全部落地:
- 支持上传文件、图片附件,还能预览已上传文件(TrUploadButton、TrAttachments)
- 懒得打字可以直接语音转文字输入(TrVoiceButton)
- 随时切换不同 AI 模型,适配不同使用场景(ModelSelector)
- 一键开启深度思考,让 AI 推理更细致(DeepThinking)
- 开启联网搜索,保证 AI 能查到最新网络信息(WebSearch)

UI 布局和改造后的代码结构如下,各类能力都通过插槽嵌入 Sender:
代码说明: 以下从运行示例中抽取关键结构。
DeepThinking、WebSearch是对实际业务按钮的语义化抽象。
html
<!-- TinyRobot 核心 Sender 主组件 -->
<TrSender v-model="message" mode="multiple" :has-external-content="attachments.length > 0">
<!-- 顶部插槽:展示附件 -->
<template v-if="attachments.length" #header>
<TrAttachments v-model:items="attachments" :actions="[]" variant="card" wrap />
</template>
<!-- 底部左侧插槽:模型、思考、搜索开关 -->
<template #footer>
<ModelSelector v-model="model" />
<DeepThinking />
<WebSearch />
</template>
<!-- 底部右侧插槽:上传、语音按钮 -->
<template #footer-right>
<TrUploadButton @select="addFiles" />
<TrVoiceButton />
</template>
</TrSender>
此时,功能拆成独立组件,但整体框架依然被锁死。因此,我们又做了优化,没有把所有新功能全部堆在 Props 配置里,而是拆出附件、语音、上传这些独立小组件,通过 header、footer 等插槽嵌入主组件。这么做避免 Props 参数爆炸,单个小功能也能被其他页面复用。
但新的麻烦随之而来,整体自由度依旧很低:
- 组件能复用,摆放位置不能自由改:语音、上传按钮这些组件可以单独拿出来用,但装进 Sender 之后,只能固定放在预设插槽里。不能随心所欲调换按钮位置,改不了 DOM 层级关系。
- 外部数据需要手动同步状态 :附件列表是业务代码自己维护的,必须手动通过
has-external-content告知 Sender 有没有文件,否则组件判断不了能不能发送消息。 - 新增功能早晚还是要改 API:一旦后续要加新按钮、新模块,现有插槽放不下,只能新增插槽、新增 Props,API 持续膨胀。
简单总结:TinyRobot 拆分了零散功能,但页面长什么样、元素摆在哪,最终决定权还是攥在 Sender 组件手里,业务没办法自由定制。
阶段 3:PC、手机两套 UI 差异巨大,硬写判断越写越乱
电脑端屏幕空间充足,所有功能按钮全部平铺展示;而手机屏幕寸土寸金,必须做精简适配:
- PC:模型选择、深度思考、联网搜索完整展示文字按钮;附件、语音按钮直接外露
- 移动端:模型、思考、搜索只保留极简图标;附件收纳到加号展开菜单;点击麦克风,输入框直接变成全屏长按说话按钮,点键盘图标切回打字模式

因此为了复用同一套 Sender 组件,代码里只能疯狂加设备判断,写大量isMobile分支:
代码说明: 以下为运行源码的结构简化。
MoreActions、VoiceHoldButton、InputModeToggle是对实际容器和按钮的语义化抽象。
html
<TrSender v-model="message" mode="multiple">
<!-- 移动端语音模式替换文字输入框 -->
<template v-if="isMobile && voiceMode" #content>
<VoiceHoldButton />
</template>
<!-- 底部工具栏区分PC/手机样式 -->
<template #footer>
<template v-if="isMobile">
<MoreActions>
<TrUploadButton @select="addFiles" />
</MoreActions>
<ModelSelector v-model="model" compact />
<DeepThinking icon-only />
<WebSearch icon-only />
</template>
<template v-else>
<ModelSelector v-model="model" />
<DeepThinking />
<WebSearch />
</template>
</template>
<!-- 右下角操作栏区分两端 -->
<template #footer-right>
<template v-if="isMobile">
<InputModeToggle :mode="voiceMode ? 'keyboard' : 'voice'" @click="voiceMode = !voiceMode" />
</template>
<template v-else>
<TrUploadButton @select="addFiles" />
<TrVoiceButton />
</template>
</template>
</TrSender>
小结
第二阶段已经暴露出三大痛点:UI 结构固定死板、外部状态同步繁琐、API 越迭代越臃肿。等到第三阶段要同时适配电脑和手机,这些问题被无限放大,开发只能在固定插槽里堆砌各种终端判断,代码冗余难维护。
当下急需一次性解决 3 个核心痛点:
- 电脑、手机布局不一样,但共用一套状态逻辑,不用重复写输入、提交代码
- 附件这类外部数据能被组件自动识别,参与发送逻辑,不用手动传布尔值
- 后续新增功能、适配新设备,不用不停新增 Props、插槽、mode 模式
二、逻辑和页面分家:一套底层能力,不同的终端结构
老式 Sender 把三件事绑死在一起:状态数据、交互逻辑、页面 DOM 结构。想要灵活改布局,最关键的一步就是拆分。
我们推出SenderRoot作为底层能力容器:组件只管输入、提交、加载这些逻辑能力;页面长什么样、按钮怎么摆,全部交给业务自己说了算。
PC 端、移动端共用全套能力:文字输入、发消息、终止生成、禁用状态、附件增删、模型切换、思考 / 搜索开关、文字 / 语音切换。唯一区别只是两端屏幕大小不同,功能摆放优先级不一样。
PC 端:空间充足,功能全部平铺摆放
大屏不用省位置,所有按钮明明白白展示,布局直白易懂:
html
<SenderRoot v-model="message" @submit="submit">
<SenderAttachments v-model:items="attachments" />
<SenderEditor />
<footer class="desktop-toolbar">
<ModelSelector v-model="model" />
<DeepThinking />
<WebSearch />
<div class="desktop-actions">
<SenderUploadButton @select="addFiles" />
<SenderVoiceButton />
<SenderSubmit />
</div>
</footer>
</SenderRoot>
移动端:空间紧张,按需收纳折叠
手机屏幕窄,高频操作外露,低频功能收进折叠面板,还能一键切换打字 / 长按语音输入:
html
<SenderRoot v-model="message" @submit="submit">
<SenderAttachments v-model:items="attachments" />
<!-- 文字输入、语音按钮二选一展示 -->
<SenderEditor v-if="inputMode === 'text'" />
<VoiceHoldButton v-else />
<footer class="mobile-toolbar">
<MoreActionsTrigger />
<ModelSelector v-model="model" compact />
<DeepThinking icon-only />
<WebSearch icon-only />
<InputModeToggle />
<SenderSubmit />
</footer>
<!-- 点击加号展开上传附件 -->
<MoreActions v-if="moreActionsOpen">
<SenderUploadButton @select="addFiles" />
</MoreActions>
</SenderRoot>
多端切换实现方案
文档为了方便对比,会同时展示 PC 和移动端。真实业务可以先不抽取 Layout 组件,而是在
SenderRoot的插槽内容中判断当前终端,只挂载对应的 DOM:
html
<SenderRoot v-model="message" :loading="loading" @submit="submit">
<template v-if="isDesktop">
<!-- 使用上面展示的 PC 结构 -->
<!-- 可以直接访问 attachments、model、addFiles 等业务状态 -->
</template>
<template v-else>
<!-- 使用上面展示的移动端结构 -->
<!-- 可以直接访问同一份业务状态 -->
</template>
</SenderRoot>
切换设备只会替换页面 DOM 结构,消息文本、附件列表、选中模型、加载状态全都共用一套数据,布局来回切换不会重置内容。只有按钮显隐这类组件自身的临时 UI 状态,会随组件卸载重置。
这套方案不用给 SenderRoot 新增 mode、插槽,电脑手机复用同一套逻辑协议,只靠重新排列组件实现两套界面。
一句话总结:底层规则完全一致,UI 排布自由发挥。
本阶段小结
对照第一章提出的问题,组合式结构已经搞定两大前期难题:
- 多端共用一套状态逻辑,两端随便改布局,不用重复实现输入、提交逻辑
- 适配新终端不用改动组件 API,不用新增任何 Props 和插槽
但还有遗留问题:附件数据需要一层层传参数,当两套结构被抽成独立 Layout 后,后续拆分布局组件后,Props 参数又会开始泛滥,因此我们继续优化。
三、分层 Context 解耦:靠跨组件传参告别层层透传
上面的写法能直接切换两套 DOM,自由改布局。但当 PC、手机布局单独抽成DesktopSenderLayout、MobileSenderLayout两个组件时,新问题又来了------把所有代码留在一个文件中也会变得难以维护。
痛点:拆分布局,Props 疯狂层层传递
为了代码整洁,把两端布局拆成独立文件,代码如下:
html
<SenderRoot v-model="message" :loading="loading" @submit="submit">
<DesktopSenderLayout
v-if="isDesktop"
v-model:attachments="attachments"
v-model:model="model"
:disabled="disabled"
/>
<MobileSenderLayout
v-else
v-model:attachments="attachments"
v-model:model="model"
:disabled="disabled"
/>
</SenderRoot>
界面分层清晰了,但附件、模型、禁用状态一大堆参数,必须一层层传给两个 Layout 布局组件。随着功能增加,两个 Layout 的 Props 和事件会越来越多------API 膨胀只是从 Sender 转移到了业务组件。
解决方案: 拆分文件之后还要继续区分,哪些是所有 Sender 子组件共享的稳定能力,哪些只是某个业务领域的数据。把状态分成全局通用能力 、细分业务领域两层,用 Context 跨组件共享数据,不用逐层传参。
第一层:SenderRoot 全局通用 Context,搞定基础能力透传
SenderRoot 把所有 AI 输入通用能力(文字、加载、可提交状态、清空、发送、停止)通过 Context 向下穿透,后代子组件可以直接取用,不用父组件挨个传参数。
代码说明: 以下代码从当前实现中抽取,只省略类型声明和非关键逻辑。
javascript
// SenderRoot内部注入全局上下文
provide(senderContextKey, {
message,
loading,
disabled,
maxLength,
canSubmit,
clear,
submit,
cancel,
})
拿输入框组件 SenderEditor 举例,不再需要父组件传绑定值、禁用、字数限制,直接读取上下文就能拿到全部需要的数据,回车发送逻辑也直接调用 Context 里的 submit 方法:
javascript
// SenderEditor直接消费全局上下文
const { message, disabled, maxLength, submit } = useSenderContext()
// 文字实时更新
const handleInput = (event: Event) => {
message.value = (event.target as HTMLTextAreaElement).value
}
// 回车发送,Shift+回车换行
const handleKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
event.preventDefault()
submit()
}
因此,SenderEditor 可以出现在 SenderRoot 的任意后代位置,不依赖固定的 DOM 层级或插槽:
javascript
<SenderRoot v-model="message" @submit="submit">
<header>...</header>
<section>
<SenderEditor />
</section>
<footer>
<SenderSubmit />
</footer>
</SenderRoot>
这意味着 SenderEditor 不再需要声明由 SenderRoot 统一管理的状态与行为接口,例如 modelValue、disabled 和 submit;它只保留 placeholder 等仅影响自身表现的配置型 Props。同时,PC、手机布局组件不用再转发通用状态,彻底解决布局组件 Props 泛滥的问题:抽取 Layout 后,通用能力仍由内部子组件直接从 Context 获取,不需要经过 Layout 逐层转发。
模型选择、深度思考和联网搜索仍然属于业务状态,不需要全部进入 Root。Root 只管理所有 Sender 都需要共享的稳定能力,避免 Root 组件再次臃肿。通用能力全托管,业务状态按需单独管理。
第二层:领域专属 Context,附件状态单独闭环管理
SenderRoot Context 消除了输入、加载和提交等通用能力的透传,但附件仍然需要在上传按钮、附件列表和两个 Layout 之间共享。如果继续使用外部 Ref,每个 Layout 都要接收同样的附件 Props 和事件:
这是因为 SenderAttachments 是附件的消费者,SenderUploadButton 是附件的生产者。二者作为兄弟节点时,需要业务使用 Ref 和事件将它们连接起来。

这份 attachments Ref 和 addFiles 实际上属于附件领域。现在由业务层持有,只是为了连接附件的生产者与消费者;如果每个业务都重复编写这层接线,组合式组件仍然没有封装完整的附件协作能力。
因此,可以引入 SenderAttachmentProvider:由它统一持有附件状态和操作方法,并通过 Context 提供给后代组件。Provider 放在生产者与消费者的最小共同祖先上,只建立数据作用域,不规定它们的 DOM 位置。
html
<SenderRoot v-model="message" @submit="submit">
<SenderAttachmentProvider>
<SenderAttachments />
<SenderEditor />
<footer>
<SenderUploadButton />
<SenderSubmit />
</footer>
</SenderAttachmentProvider>
</SenderRoot>
SenderAttachmentProvider 提供附件领域的 Context:
typescript
interface SenderAttachmentContext {
items: Ref<Attachment[]>
addFiles: (files: File[]) => void
remove: (id: string) => void
clear: () => void
}
- 上传按钮调用
addFiles新增文件 - 附件列表读取
items展示,调用remove删除文件 - Provider 全权维护附件数据,上下游组件不用再手动对接
分层逻辑:通用能力走 Sender 全局 Context,附件这类细分业务走专属 Provider;谁生产、谁消费数据,就把 Provider 放在它们最近的公共父节点。
统一提交协议:附件自动参与提交,不用手动标记
早期的附件状态由独立 Provider 管理,但仍然需要参与"是否可以提交"和最终提交数据的判断。而之前的 Sender 不认识附件内容,只能由业务额外传入一个布尔值,告诉它外部是否存在可提交内容, Sender 只能靠has-external-content传 true/false,组件只知道 "有没有附件",不知道附件详情,最终提交还要业务手动拼接数据。
html
<TrSender
v-model="message"
:has-external-content="attachments.length > 0"
/>
而新版本设计统一内容注册协议,所有附加内容主动上报给 SenderRoot:
typescript
interface SenderContentRegistry {
registerContent<T>(source: string, payload: MaybeRefOrGetter<T>): () => void
}
- source:标记数据来源(attachments 附件、知识库等)
- payload:完整的原始数据
- 返回注销函数,组件销毁、数据清空自动解绑
附件 Provider 内部自动完成注册:
javascript
const unregister = registerContent('attachments', items)
用户点击发送时,submit 事件一次性带回文字内容 + 所有注册的附加数据:
javascript
const submit = ({ text, contents }: SenderSubmission) => {
// 直接取出附件完整数据
const attachments = contents.find(({ source }) => source === 'attachments')?.payload
}
到此,三大历史痛点全部闭环解决:
- 多端共享一套状态,布局自由修改
- 附件自动纳入提交逻辑,不用手动传布尔值同步
- 新增文件、知识库等附加内容,只需要新增 source 标识,不用修改 Sender 任何 API
最终布局代码极度清爽,两个布局组件完全不用转发附件参数:
html
<SenderRoot v-model="message" :loading="loading" @submit="submit">
<SenderAttachmentProvider>
<DesktopSenderLayout v-if="isDesktop" />
<MobileSenderLayout v-else />
</SenderAttachmentProvider>
</SenderRoot>
其他业务模块(模型选择等)依旧可以正常传参,不用强行塞进全局 Context。记住一个简单原则:多个组件共用一份数据,就用专属 Provider;简单独立组件直接传 Props 即可。状态交给数据最近的公共祖先管理,业务只管拼页面,组件负责管数据联动。
四、组合式不等于每次都从零拼装
这种拆分组合的模式,最大好处是 UI 自由度拉满,但缺点也很明显:每次使用都要手动拼装一堆小组件,常规简单场景写起来麻烦。
这也是 Headless 无头组件的典型思路:底层只给逻辑、状态、基础原子组件,界面排布全部交给开发者。市面上 shadcn/ui 就是这套玩法,底层是无样式无头逻辑,上层提供封装好的成品组件,兼顾灵活度和开发速度。
TinyRobot Sender 采用双层 API 设计,两头兼顾:
- 底层组合式原子组件:SenderRoot、SenderEditor、SenderAttachments 等基础零件,适合需要深度定制、多端差异化巨大的场景,想怎么排就怎么排。
- 上层封装预制组件:也就是最初版本完整的 Sender 组件,封装好通用布局样式。日常常规业务直接开箱即用,不用手动拼组件。
两套方案共用同一套底层逻辑,不是两套割裂代码。简单需求用成品组件快速落地;需要改结构、做多端适配时,随时下沉到底层原子组件自由组装。
一句话概括:预制组件保证上手快,组合组件保证改得动。
关于 OpenTiny NEXT
OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能助手、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。
欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
TinyRobot 代码仓库:github.com/opentiny/ti... (欢迎star ⭐)
如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!