一个最简单的 AI 对话页面并不难:维护消息数组,调用模型接口,再把结果渲染出来即可。

真正进入产品开发后,复杂度很快会从“调一次接口”扩散到整个页面:流式内容需要持续合并,请求需要能够停止,多轮上下文要正确组织,不同会话要隔离运行状态,页面刷新后还要恢复历史消息。如果这些逻辑都直接堆在 Vue 组件里,请求、消息和 UI 状态会越来越难拆开。

@opentiny/tiny-robot-kit(下文简称 Kit)提供的正是一层面向 AI 对话的数据处理能力。它不负责决定页面长什么样,而是把消息生命周期、会话运行时、后端响应适配、持久化和插件扩展组织成几块可以独立组合的能力。

本文不会只按 API 顺序介绍 useMessageuseConversation。我们会从一个可运行的 Vue 3 Demo 出发,先看 Kit 如何划分职责,再逐步验证几件更接近真实产品的问题:

  • 单个会话如何处理流式回复和请求取消;
  • 为什么替换后端接口不需要重写消息状态管理;
  • 多个会话如何拥有彼此独立的消息 Engine;
  • 切换会话时,仍在生成的后台会话为什么不会被一起销毁;
  • 会话和消息如何通过 Storage Strategy 持久化;
  • Reasoning、自动续写和 Tool Calling 如何进入同一条消息处理链路。

本文最终 Demo 演示如下,源码地址:https://github.com/opentiny/tiny-robot/tree/demo/kit-article-260828

full-demo

AI 对话的数据层,究竟在管理什么

把聊天页面抽象成一个 messages 数组,只能覆盖“已经拿到结果以后怎么显示”的部分。真正需要长期维护的是一套运行时状态:当前请求处于什么阶段、正在更新哪条助手消息、某个会话是否仍在后台生成、什么时候保存消息、收到模型的特殊字段后应该触发什么行为。

Kit 将这些职责拆成五个边界:

能力 在 Kit 中的角色 解决的问题
useMessage 单会话消息 Engine 消息、请求状态、流式合并、取消请求
useConversation 多会话管理层 会话列表、Engine 创建与切换、后台运行状态
responseProvider 响应适配边界 将业务后端或模型服务转换成统一响应流
Storage Strategy 持久化边界 会话元数据和历史消息的保存、恢复
Plugins 消息处理扩展 Reasoning、自动续写、Tool Calling 等

它们之间的关系可以概括为:

挂载到消息处理链路

挂载到消息处理链路

聊天 UI

useConversation

useMessage

Message Engine A

Message Engine B

responseProvider

业务后端 / 模型服务

Storage Strategy

Plugins

这里最重要的不是多了几个 Composable,而是职责边界变清楚了:UI 只消费响应式状态,Message Engine 管理一次会话的运行过程,Conversation 管理多个 Engine,Provider 隔离后端协议,Storage 隔离持久化实现。

后面的 Demo 都围绕这几个边界展开。

环境准备

本文基于 @opentiny/tiny-robot-kit 0.5.1,面向熟悉 Vue 3 基础用法的前端开发者。

准备环境:

  • Node.js:22+
  • pnpm:10+

使用 Vite 创建 Vue 3 + TypeScript 工程:

pnpm create vite tiny-robot-kit-article-demo --template vue-ts
cd tiny-robot-kit-article-demo
pnpm install

安装 TinyRobot 组件与 Kit:

pnpm add @opentiny/tiny-robot@0.5.1 \
  @opentiny/tiny-robot-kit@0.5.1 \
  @opentiny/tiny-robot-svgs@0.5.1

在入口文件中引入组件库样式:

import '@opentiny/tiny-robot/dist/style.css'

为了把注意力放在数据层,Demo 的 UI 只使用 TrBubbleListTrSenderTrHistory 这几个组件。

第一步:让 useMessage 接管单会话请求生命周期

单会话是最小运行单元。useMessage 对外提供消息列表、请求状态、发送和取消能力,组件不需要自己维护“当前正在生成哪条消息”这样的中间状态:

const { messages, isProcessing, sendMessage, abortRequest } = useMessage({
  responseProvider: mockResponseProvider,
})

这里真正决定数据从哪里来的,是 responseProvider

先创建 src/mockResponseProvider.ts,用异步生成器模拟模型逐字符返回:

import type { ChatCompletion, ResponseProvider } from '@opentiny/tiny-robot-kit'

const characters = [...'这是模拟的流式回复。Kit 会持续合并消息,页面只需要绑定状态和组件。']

export const mockResponseProvider: ResponseProvider = async function* (_, signal) {
  for (const [index, content] of characters.entries()) {
    await new Promise((resolve) => setTimeout(resolve, 40))
    if (signal.aborted) return

    yield {
      id: 'mock-response',
      object: 'chat.completion.chunk',
      created: Math.floor(Date.now() / 1000),
      model: 'mock',
      system_fingerprint: null,
      choices: [
        {
          index: 0,
          message: undefined,
          delta: { role: index === 0 ? 'assistant' : undefined, content },
          finish_reason: index === characters.length - 1 ? 'stop' : null,
          logprobs: null,
        },
      ],
    } satisfies ChatCompletion
  }
}

这里有两个细节值得注意。

第一,Provider 不直接修改 Vue 状态,而是持续产出 ChatCompletion chunk;Message Engine 负责把增量内容合并到当前助手消息中。

第二,Provider 接收 Kit 传入的 AbortSignal。请求取消不需要组件和 Provider 之间再维护一套额外状态,只要 Provider 在合适的位置响应同一个 signal 即可。

App.vue 中,页面只绑定 Kit 暴露出来的状态:

<script setup lang="ts">
import { TrBubbleList, TrSender, type BubbleRoleConfig } from '@opentiny/tiny-robot'
import { useMessage } from '@opentiny/tiny-robot-kit'
import { ref } from 'vue'
import { mockResponseProvider } from './mockResponseProvider'

const input = ref('')
const { messages, isProcessing, sendMessage, abortRequest } = useMessage({
  responseProvider: mockResponseProvider,
})

const roles: Record<string, BubbleRoleConfig> = {
  assistant: { placement: 'start' },
  user: { placement: 'end' },
}

function submit(content: string) {
  if (!content.trim()) return
  void sendMessage(content)
  input.value = ''
}
</script>

<template>
  <main class="chat-shell">
    <header>
      <span>TinyRobot Kit</span>
      <small>Mock 流式响应</small>
    </header>

    <TrBubbleList class="messages" :messages="messages" :role-configs="roles" auto-scroll />

    <TrSender
      v-model="input"
      :loading="isProcessing"
      :placeholder="isProcessing ? '正在生成…' : '输入问题体验流式回复'"
      @submit="submit"
      @cancel="abortRequest"
    />
  </main>
</template>

为了让 Demo 可以直接运行,再补一组最小布局样式:

<style scoped>
.chat-shell {
  display: grid;
  grid-template-rows: auto 1fr auto;
  width: min(880px, calc(100% - 32px));
  height: min(680px, calc(100vh - 64px));
  margin: 32px auto;
  padding: 24px;
  background: white;
  border-radius: 16px;
  box-shadow: 0 16px 48px #23395d14;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 16px;
  font-weight: 700;
}

header small {
  color: #667085;
  font-weight: 400;
}

.messages {
  min-height: 0;
  overflow: auto;
  padding: 16px 0;
}
</style>

默认模板中的 src/style.css 可以简化为:

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: #f5f7fa;
}

从页面视角看,状态只是在不断变化;从数据层视角看,实际发生的是一条完整的请求链路:

responseProvider useMessage / Message Engine Vue UI responseProvider useMessage / Message Engine Vue UI sendMessage(content) requestBody + AbortSignal chunk 1 更新 messages / isProcessing chunk 2...n 持续更新当前助手消息 abortRequest() AbortSignal

运行:

pnpm dev

发送消息后,可以看到回复内容持续写入同一条助手消息;生成过程中点击停止或等待请求结束,输入区随即恢复。

useMessage

Response Provider:把后端协议留在数据层边界之外

前面的 Mock Demo 有一个容易被忽略的价值:页面并不知道响应来自 Mock。

ResponseProvider 的职责很窄。它接收当前请求体和 AbortSignal,然后返回 Kit 可以消费的单次结果或异步数据流:

export type ResponseProvider<T = ChatCompletion> = (
  requestBody: MessageRequestBody,
  abortSignal: AbortSignal,
) => AsyncStreamableResult<T>

因此,Mock、企业内部 AI 网关、OpenAI Compatible API 或其他 SSE 服务,都可以放在这一层适配。只要 Provider 最终产出的数据符合 Message Engine 的消费格式,上层的:

  • messages
  • isProcessing
  • abortRequest
  • 多会话管理;
  • UI 绑定;

都不需要因为后端变化而重写。

这也是后面从 Mock 切到真实 SSE 时,只替换 Provider 就能继续使用同一套会话逻辑的原因。

第二步:用 useConversation 组织多个独立 Engine

单会话只需要一个 Message Engine。加入历史会话后,需要进一步解决三个问题:

  1. 每个会话的消息和请求状态如何隔离;
  2. 切换历史会话时,哪个 Engine 应该进入当前视图;
  3. 离开当前会话以后,仍在生成的请求是否继续保留。

useConversation 就位于这一层。它为会话管理对应的 useMessage Engine,并通过 activeConversation 暴露当前会话:

const {
  conversations,
  activeConversation,
  activeConversationId,
  createConversation,
  switchConversation,
  sendMessage,
  abortActiveRequest,
} = useConversation({
  useMessageOptions: { responseProvider: mockResponseProvider },
})

const messages = computed(() => activeConversation.value?.engine.messages.value ?? [])
const isProcessing = computed(() => activeConversation.value?.engine.isProcessing.value ?? false)

这里的 conversations 主要保存会话元数据;真正运行消息请求的是每个会话自己的 Engine。

用长回复验证会话隔离

为了让后台运行状态更容易观察,把 Mock Provider 改成同时支持普通回复和长回复:

const defaultReply = '这是模拟的流式回复。Kit 会持续合并消息,页面只需要绑定状态和组件。'
const longReply =
  '这是用于演示多会话隔离的长回复。当前会话会持续接收字符,切换到其他会话后仍会保持自己的消息和请求状态。'
    .repeat(20)
    .slice(0, 300)

export const mockResponseProvider: ResponseProvider = async function* ({ messages }, signal) {
  const prompt = messages.at(-1)?.content
  const reply = typeof prompt === 'string' && prompt.startsWith('/long') ? longReply : defaultReply
  const characters = [...reply]

  for (const [index, content] of characters.entries()) {
    await new Promise((resolve) => setTimeout(resolve, 40))
    if (signal.aborted) return

    yield {
      id: 'mock-response',
      object: 'chat.completion.chunk',
      created: Math.floor(Date.now() / 1000),
      model: 'mock',
      system_fingerprint: null,
      choices: [
        {
          index: 0,
          message: undefined,
          delta: { role: index === 0 ? 'assistant' : undefined, content },
          finish_reason: index === characters.length - 1 ? 'stop' : null,
          logprobs: null,
        },
      ],
    } satisfies ChatCompletion
  }
}

新对话采用“首条消息创建会话”的方式:点击“新对话”只清空当前选择,真正发送第一条消息时再创建会话。

function submit(content: string) {
  if (!content.trim()) return

  if (!activeConversation.value) {
    createConversation({ title: content.slice(0, 16) })
  }

  sendMessage(content)
  input.value = ''
}

function startConversation() {
  activeConversationId.value = null
  input.value = ''
}

function openConversation(item: HistoryItem) {
  if (item.id) void switchConversation(item.id)
}

页面加入 TrHistory

<template>
  <main class="app-shell">
    <aside class="history-panel">
      <div class="history-header">
        <strong>历史会话</strong>
        <button class="new-chat" type="button" @click="startConversation">新对话</button>
      </div>

      <TrHistory
        class="history-list"
        :data="conversations as HistoryItem[]"
        :selected="activeConversationId ?? undefined"
        :menu-items="[]"
        @item-click="openConversation"
      />
    </aside>

    <header>
      <span>TinyRobot Kit</span>
      <small>多会话</small>
    </header>

    <TrBubbleList class="messages" :messages="messages" :role-configs="roles" auto-scroll />

    <TrSender
      class="sender"
      v-model="input"
      :loading="isProcessing"
      :placeholder="isProcessing ? '正在生成…' : '输入问题体验流式回复'"
      @submit="submit"
      @cancel="abortActiveRequest"
    />
  </main>
</template>

多会话版 App.vue 需要补充以下 import:

import { TrBubbleList, TrHistory, TrSender, type BubbleRoleConfig, type HistoryItem } from '@opentiny/tiny-robot'
import { useConversation } from '@opentiny/tiny-robot-kit'
import { computed, ref } from 'vue'
import { mockResponseProvider } from './mockResponseProvider'

布局调整为左侧会话列表、右侧聊天区:

<style scoped>
.app-shell {
  display: grid;
  grid-template-columns: 240px minmax(0, 1fr);
  grid-template-rows: auto minmax(0, 1fr) auto;
  width: min(1040px, calc(100% - 32px));
  height: min(680px, calc(100vh - 64px));
  margin: 32px auto;
  overflow: hidden;
  background: white;
  border-radius: 16px;
  box-shadow: 0 16px 48px #23395d14;
}

.history-panel {
  grid-row: 1 / -1;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  padding: 20px 16px;
  background: #f7f8fa;
  border-right: 1px solid #e4e7ec;
}

.history-header,
.app-shell > header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.history-header {
  padding: 0 8px 16px;
}

.new-chat {
  padding: 6px 10px;
  color: white;
  font: inherit;
  background: #1476ff;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
}

.history-list {
  min-height: 0;
  overflow: auto;
}

.app-shell > header {
  grid-column: 2;
  padding: 24px 24px 0;
  font-weight: 700;
}

.app-shell > header small {
  color: #667085;
  font-weight: 400;
}

.messages {
  grid-column: 2;
  min-height: 0;
  overflow: auto;
  padding: 16px 24px;
}

.sender {
  grid-column: 2;
  margin: 0 24px 24px;
}

@media (max-width: 700px) {
  .app-shell {
    grid-template-columns: 1fr;
    grid-template-rows: 180px auto minmax(0, 1fr) auto;
  }

  .history-panel {
    grid-row: auto;
    border-right: 0;
    border-bottom: 1px solid #e4e7ec;
  }

  .app-shell > header,
  .messages,
  .sender {
    grid-column: 1;
  }
}
</style>

切换的是当前视图,不是把后台请求一起销毁

这一点比“能显示历史会话列表”更重要。

useConversation 内部会缓存当前需要工作的 Message Engine。切换会话时,它会确保目标会话的 Engine 已经存在,然后清理不再处理请求的非当前 Engine;仍处于 isProcessing 状态的 Engine 会继续保留。

因此可以出现这样的运行状态:

后台继续生成

会话 A

Engine A
processing

会话 B
当前视图

Engine B
processing

会话 C

仅保留持久化数据

Provider

这意味着“切换会话”与“取消会话请求”是两件不同的事。切换本身不会取消后台请求;显式调用 abortActiveRequest、删除会话或清空会话等操作,才会中止对应的运行任务。

实际验证时,可以在会话 A 输入:

/long 演示多会话隔离

随后新建会话 B 并发送普通问题。B 回复结束后切回 A,可以看到 A 的长回复仍然在自己的 Engine 中继续更新。

useConversation

第三步:用 Storage Strategy 让运行时跨刷新恢复

多会话解决的是运行时隔离,页面刷新以后还需要恢复会话和消息。

Kit 没有把持久化写死在 useConversation 内部,而是通过 Storage Strategy 暴露保存和加载边界。当前版本在没有显式传入 storage 时,会默认创建 LocalStorage 策略。

为了让 Demo 的持久化行为写得更明确,这里仍然显式传入默认策略:

const {
  conversations,
  activeConversation,
  activeConversationId,
  createConversation,
  switchConversation,
  updateConversationTitle,
  deleteConversation,
  sendMessage,
  abortActiveRequest,
} = useConversation({
  useMessageOptions: { responseProvider: mockResponseProvider },
  storage: localStorageStrategyFactory(),
  autoSaveMessages: true,
  autoSaveThrottle: 500,
})

这里需要区分两个概念:

  • LocalStorage Strategy 负责提供会话和消息的保存、加载能力;
  • autoSaveMessages 决定消息发生变化时是否自动保存,默认值是 false
  • autoSaveThrottle 控制流式输出期间自动保存的节流间隔,避免每个 chunk 都触发一次持久化写入。

会话列表会从 Storage 中恢复;某个历史会话真正被切换到前台时,useConversation 再为它创建或恢复 Message Engine,并加载对应消息。这使“历史数据”和“当前正在工作的 Engine”不必始终一一常驻内存。

重命名和删除仍然走同一个会话边界

加入会话操作:

function renameConversation(title: string, item: HistoryItem) {
  if (item.id) updateConversationTitle(item.id, title)
}

function handleHistoryAction(action: HistoryMenuItem, item: HistoryItem) {
  if (action.id === 'delete' && item.id) {
    void deleteConversation(item.id)
  }
}

TrHistory 绑定对应事件:

<TrHistory
  class="history-list"
  :data="conversations as HistoryItem[]"
  :selected="activeConversationId ?? undefined"
  @item-click="openConversation"
  @item-title-change="renameConversation"
  @item-action="handleHistoryAction"
/>

删除会话时,Kit 会先中止该会话仍在运行的请求,再移除对应 Engine 和持久化数据。重命名则会同步更新会话元数据。

完成后可以验证:

  1. 创建多个会话并分别发送消息;
  2. 重命名其中一个会话;
  3. 刷新页面;
  4. 切换到历史会话,消息能够重新加载;
  5. 删除会话后刷新,已删除的数据不会再次出现。

conversationPersistence

历史消息很多时换成 IndexedDB

Storage Strategy 的价值在于,上层会话逻辑不需要关心实际存在哪里。

当历史消息量超过 LocalStorage 更适合承担的范围时,只需要替换策略:

storage: indexedDBStorageStrategyFactory({
  dbName: 'tiny-robot-chat',
  dbVersion: 1,
})

useConversation、Message Engine 和页面组件都不需要跟着重写。

第四步:替换 Provider,接入真实 SSE

到这里,消息和会话逻辑仍然运行在 Mock Provider 上。因为 Provider 与 Message Engine 解耦,接入真实接口只需要替换数据源。

Kit 提供了 sseStreamToGenerator,可以把标准 SSE Response 转换成异步生成器,让真实接口和前面的 Mock 使用相同的消费模型。

创建 src/sseResponseProvider.ts

import { sseStreamToGenerator, type ResponseProvider } from '@opentiny/tiny-robot-kit'

export const sseResponseProvider: ResponseProvider = async (requestBody, signal) => {
  const response = await fetch(`${import.meta.env.VITE_LLM_BASE_URL}/chat/completions`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${import.meta.env.VITE_LLM_API_KEY}`,
    },
    body: JSON.stringify({
      ...requestBody,
      model: import.meta.env.VITE_LLM_MODEL,
      stream: true,
    }),
    signal,
  })

  if (!response.ok) {
    throw new Error(`模型请求失败:${response.status}`)
  }

  return sseStreamToGenerator(response, { signal })
}

这段 Provider 做了三件事:发起请求、检查 HTTP 状态、把 SSE 转换为 Kit 可消费的数据流。消息合并、请求状态和会话逻辑仍由原来的数据层处理。

在项目根目录创建 .env.local

VITE_LLM_BASE_URL=https://your-llm-endpoint.example.com
VITE_LLM_API_KEY=your_api_key
VITE_LLM_MODEL=your_model_name

如果使用 DeepSeek、企业内部 AI 网关或其他兼容 Chat Completions 流式格式的服务,只需要把这里的地址、鉴权方式和模型名换成实际配置。

VITE_ 开头的变量会进入浏览器构建产物。这里为了缩短 Demo 链路才从浏览器直连模型服务;生产环境应把 API Key 放在服务端或 API 网关中。

Provider 切换只需要改 useConversation 的这一处配置:

- responseProvider: mockResponseProvider
+ responseProvider: sseResponseProvider

重新启动开发服务器后,页面层不需要其他改动。

sseResponseProvider

为什么响应里出现 reasoning_content 后,页面能显示思考状态

这不是 SSE 层额外写了一套逻辑。

Message Engine 默认注册了 thinkingPlugin。当响应 chunk 中包含 reasoning_content 时,插件会更新当前助手消息的 state.thinkingstate.open;思考内容结束后,再把对应状态收起。

也就是说,Provider 负责“把数据送进来”,Plugin 负责“看到某类数据后怎么扩展消息行为”,两者仍然是分开的。

从聊天到 Agent:插件机制把能力挂到同一条消息链路

Message Engine 除了处理普通文本,还提供插件生命周期。当前版本默认注册 thinkingPluginlengthPlugin,业务还可以按需加入 toolPlugin 等插件。

插件不是另起一套状态管理,而是参与同一条消息处理链路:请求前可以修改请求体,收到 chunk 时可以更新当前消息,请求结束后还可以决定是否追加消息并继续下一次请求。

Reasoning:thinkingPlugin

如果模型返回 reasoning_content,默认 thinkingPlugin 会维护思考状态。业务不需要这项行为时,可以用同名插件覆盖默认配置并禁用:

plugins: [thinkingPlugin({ disabled: true })]

自动续写:lengthPlugin

当模型返回 finish_reason: 'length' 时,默认 lengthPlugin 会追加一条续写消息并继续请求。

需要自定义续写指令时:

plugins: [
  lengthPlugin({
    continueContent: '请继续上一段回答。',
  }),
]

这里的“继续请求”仍然发生在同一个 Message Engine 中,因此消息、请求状态和取消逻辑不需要再实现一遍。

Tool Calling:toolPlugin

toolPlugin 把模型工具调用接入消息生命周期。getTools 向模型提供可用工具,callTool 执行业务函数;工具结果形成 tool 消息后,Message Engine 可以继续下一轮模型请求。

plugins: [
  toolPlugin({
    getTools: () => [
      {
        type: 'function',
        function: {
          name: 'get_weather',
          description: '查询城市天气',
          parameters: {
            type: 'object',
            properties: {
              city: { type: 'string' },
            },
            required: ['city'],
          },
        },
      },
    ],
    callTool: async (toolCall) => {
      const { city } = JSON.parse(toolCall.function?.arguments || '{}')
      return `${city}:晴,25℃`
    },
  }),
]

在真实业务中,callTool 可以进一步连接 MCP、企业 API、检索服务或其他领域能力。

一张图看懂完整运行链路

经过前面的几个步骤,Demo 已经从“一个输入框调用一个接口”演变成完整的对话运行时:

管理

持久化

请求 / 流式响应

TrSender
用户输入

useConversation

Message Engine 运行池
当前会话 / 后台会话

Storage Strategy
LocalStorage / IndexedDB

Engine 独立运行
Plugins + responseProvider

模型服务
业务 AI 网关

响应式 messages / request state
由 TrBubbleList/TrSender 消费

这套结构的价值并不是“少写几个 ref”,而是把变化最频繁的几部分拆开了:

  • UI 可以替换,不影响消息运行时;
  • 模型或业务后端可以替换,只需要适配 Provider;
  • LocalStorage 可以换成 IndexedDB,不影响会话 API;
  • 一个会话的请求状态不会和另一个会话混在一起;
  • Reasoning、自动续写和工具调用通过插件进入同一条消息链路。

对于只有一个简单问答框的页面,可以直接使用 useMessage;当产品开始出现历史会话、后台生成和持久化需求,再把管理层提升到 useConversation。两者使用的是同一套 Message Engine 能力,不需要重做消息模型。

常见问题

页面能运行,但没有 TinyRobot 样式

确认入口文件已经引入组件库样式:

import '@opentiny/tiny-robot/dist/style.css'

接入真实模型后发送消息没有回复

先看浏览器 Network 面板:

  • 请求是否成功返回;
  • 响应是否为预期的流式格式;
  • Provider 产出的 chunk 是否包含 Kit 可以消费的 choices 数据;
  • .env.local 中的接口地址、鉴权和模型配置是否正确。

修改 Vite 环境变量后,需要重新启动开发服务器。

刷新后会话还在,但消息丢失

会话列表能够保存,不代表消息已经开启自动保存。确认:

autoSaveMessages: true

同时检查刷新前后使用的是不是同一套 Storage Strategy 和存储配置。

流式回复期间写入 LocalStorage 太频繁

通过 autoSaveThrottle 控制自动保存频率:

autoSaveThrottle: 500

具体值应根据消息长度、写入成本和业务对恢复实时性的要求调整。

点击停止后,网络请求仍然继续

abortRequest 会触发 Kit 管理的取消信号,但自定义 Provider 也必须把同一个 AbortSignal 继续传给实际网络请求和流解析:

fetch(url, { signal })
return sseStreamToGenerator(response, { signal })

切换到其他会话后,原会话为什么还在生成

这是 useConversation 的运行时设计:切换当前会话不会自动取消仍在 processing 的其他 Engine。

如果业务希望离开会话时立即停止生成,需要在自己的交互逻辑里显式调用对应的取消行为;删除会话时,Kit 会主动中止该会话仍在运行的请求。

什么时候适合把 Kit 放进项目

如果应用只有一次性的模型调用,没有流式消息、历史会话和复杂状态,直接调用接口通常已经足够。

当页面开始出现下面这些需求时,独立的数据层会越来越有价值:

  • 同一条助手消息需要持续接收流式 chunk;
  • 用户需要随时停止生成;
  • 多个会话拥有独立的消息和请求状态;
  • 会话切换后仍要保留后台运行任务;
  • 历史消息需要跨页面刷新恢复;
  • 后端可能在 Mock、企业网关和不同模型服务之间切换;
  • Reasoning、自动续写、Tool Calling 等能力需要进入同一套消息生命周期。

TinyRobot Kit 的定位不是替代业务后端,也不是把 UI 和模型调用绑成一个黑盒,而是在两者之间提供稳定的数据运行层。对于 AI 对话类前端,这一层往往也是业务复杂度最先开始积累的地方。

关于 OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot 智能组件库、GenUI 等新产品,实现 AI 理解用户意图自主完成任务,加速企业应用的智能化改造。

欢迎加入 OpenTiny 开源社区。
OpenTiny 官网:opentiny.design
TinyRobot 代码仓库:github.com/opentiny/tiny-robot(欢迎 star ⭐)
如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

Logo

OpenTiny 是企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大自主核心技术为基础,加速企业应用的智能化改造。我们会在社区定期为大家分享一些前后端的技术文章。

更多推荐