拒绝臃肿!TinyRobot从“AI 输入框”到“AI输入控制台”的可扩展进化

最开始做 AI 聊天,只需要一个输入框加发送按钮就能搞定。但随着 AI 功能越做越丰富,文件上传、语音输入、切换大模型、联网搜索、深度思考全都要塞进聊天底部,小小的输入框硬生生扩容成了全能的 AI 输入控制台。

这篇文章就拿 TinyRobot 里的 Sender 组件举例,顺着业务需求一路拆解,聊聊功能变多之后,组件边界是怎么一步步被撑大、又如何靠架构优化实现灵活适配。

一、需求越堆越多,Sender 组件被迫一路扩容

阶段 1:极简版 AI 输入框,够用就好

项目初期需求特别简单,只需要实现最基础的对话能力:

  1. 正常打字输入消息,一键发送
  2. AI 回复生成过程中,可以随时中断输出
  3. 一键清空输入内容,限制最大输入字数避免超长文本

配套最简代码如下(删减了尺寸、调试等无关配置):

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:

代码说明: 以下从运行示例中抽取关键结构。DeepThinkingWebSearch 是对实际业务按钮的语义化抽象。

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 参数爆炸,单个小功能也能被其他页面复用。

但新的麻烦随之而来,整体自由度依旧很低:

  1. 组件能复用,摆放位置不能自由改:语音、上传按钮这些组件可以单独拿出来用,但装进 Sender 之后,只能固定放在预设插槽里。不能随心所欲调换按钮位置,改不了 DOM 层级关系。
  2. 外部数据需要手动同步状态 :附件列表是业务代码自己维护的,必须手动通过has-external-content告知 Sender 有没有文件,否则组件判断不了能不能发送消息。
  3. 新增功能早晚还是要改 API:一旦后续要加新按钮、新模块,现有插槽放不下,只能新增插槽、新增 Props,API 持续膨胀。

简单总结:TinyRobot 拆分了零散功能,但页面长什么样、元素摆在哪,最终决定权还是攥在 Sender 组件手里,业务没办法自由定制。

阶段 3:PC、手机两套 UI 差异巨大,硬写判断越写越乱

电脑端屏幕空间充足,所有功能按钮全部平铺展示;而手机屏幕寸土寸金,必须做精简适配:

  • PC:模型选择、深度思考、联网搜索完整展示文字按钮;附件、语音按钮直接外露
  • 移动端:模型、思考、搜索只保留极简图标;附件收纳到加号展开菜单;点击麦克风,输入框直接变成全屏长按说话按钮,点键盘图标切回打字模式

因此为了复用同一套 Sender 组件,代码里只能疯狂加设备判断,写大量isMobile分支:

代码说明: 以下为运行源码的结构简化。MoreActionsVoiceHoldButtonInputModeToggle 是对实际容器和按钮的语义化抽象。

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 个核心痛点:

  1. 电脑、手机布局不一样,但共用一套状态逻辑,不用重复写输入、提交代码
  2. 附件这类外部数据能被组件自动识别,参与发送逻辑,不用手动传布尔值
  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 排布自由发挥。

本阶段小结

对照第一章提出的问题,组合式结构已经搞定两大前期难题:

  1. 多端共用一套状态逻辑,两端随便改布局,不用重复实现输入、提交逻辑
  2. 适配新终端不用改动组件 API,不用新增任何 Props 和插槽

但还有遗留问题:附件数据需要一层层传参数,当两套结构被抽成独立 Layout 后,后续拆分布局组件后,Props 参数又会开始泛滥,因此我们继续优化。

三、分层 Context 解耦:靠跨组件传参告别层层透传

上面的写法能直接切换两套 DOM,自由改布局。但当 PC、手机布局单独抽成DesktopSenderLayoutMobileSenderLayout两个组件时,新问题又来了------把所有代码留在一个文件中也会变得难以维护。

痛点:拆分布局,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 统一管理的状态与行为接口,例如 modelValuedisabledsubmit;它只保留 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
}

到此,三大历史痛点全部闭环解决:

  1. 多端共享一套状态,布局自由修改
  2. 附件自动纳入提交逻辑,不用手动传布尔值同步
  3. 新增文件、知识库等附加内容,只需要新增 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 设计,两头兼顾:

  1. 底层组合式原子组件:SenderRoot、SenderEditor、SenderAttachments 等基础零件,适合需要深度定制、多端差异化巨大的场景,想怎么排就怎么排。
  2. 上层封装预制组件:也就是最初版本完整的 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标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

相关推荐
淼澄研学3 小时前
PyTorch深度学习实战:5个核心方法从0到1构建神经网络
前端·数据库·python
英勇无比的消炎药3 小时前
Vue 工程接入 WebMCP SDK 最佳实践:从零构建智能前端应用
vue.js
MartinYeung53 小时前
[论文学习]WASP:面向提示注入攻击的Web代理安全性基准测试
前端·网络·学习
lemon_sjdk3 小时前
从 ServletRequest 到 Spring 抽象:Web 请求的底层基石与演化
java·前端·spring
程序员黑豆4 小时前
鸿蒙应用开发之状态变化通知:@Watch 装饰器详解与实战
前端·harmonyos
swipe4 小时前
13|(前端转全栈)支付成功不等于结束:回调、幂等、超时关单和状态竞争
前端·后端·面试
swipe4 小时前
12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?
前端·后端·面试
小林ixn4 小时前
React + TypeScript 实战:从“类型体操”到“数据持久化”,一次讲透组件通信与副作用管理
前端·react.js·typescript
何时梦醒4 小时前
React + TypeScript 企业级开发实战:从零搭建到组件架构演进
前端·react.js·全栈