From 2a00bcb6a758573366889af3275d6708ef9a637d Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Tue, 20 Oct 2026 04:58:53 -0700 Subject: [PATCH 01/15] feat(chat): enhance ChatUI with anchor support and input management --- docs/demos/chat/input-controls.vue | 51 ++++ docs/demos/chat/message-anchor.vue | 51 ++++ docs/demos/chat/sender-options.vue | 8 +- docs/src/components/bubble.md | 5 +- docs/src/components/sender.md | 2 + docs/src/suites/chat-runtime.md | 4 +- docs/src/suites/chat.md | 229 +++++++++++++++--- packages/chat/src/Chat.vue | 10 +- packages/chat/src/ChatUI.vue | 180 ++++++++++---- .../chat/src/composables/useChatDraft.test.ts | 28 +++ .../src/composables/useChatRenderContext.ts | 27 +++ packages/chat/src/index.ts | 2 + packages/chat/src/types/base.ts | 9 + packages/chat/src/types/ui/options.ts | 8 + packages/chat/src/types/ui/slots.ts | 1 - .../chat/src/ui/composer/ChatInputRegion.vue | 11 +- packages/chat/src/ui/composer/ChatSender.vue | 18 +- packages/chat/src/ui/layout/ChatHeader.vue | 16 +- .../chat/src/ui/messages/ChatMessages.vue | 110 ++++++++- .../chat/src/ui/messages/ScrollToBottom.vue | 14 +- .../src/ui/messages/messageIdentity.test.ts | 57 +++++ .../chat/src/ui/messages/messageIdentity.ts | 39 +++ .../components/layouts/MultiLineLayout.vue | 5 +- .../components/layouts/SenderHeader.vue | 24 ++ .../components/layouts/SingleLineLayout.vue | 5 +- .../src/shared/composables/useAutoScroll.ts | 17 +- 26 files changed, 802 insertions(+), 129 deletions(-) create mode 100644 docs/demos/chat/input-controls.vue create mode 100644 docs/demos/chat/message-anchor.vue create mode 100644 packages/chat/src/composables/useChatDraft.test.ts create mode 100644 packages/chat/src/composables/useChatRenderContext.ts create mode 100644 packages/chat/src/ui/messages/messageIdentity.test.ts create mode 100644 packages/chat/src/ui/messages/messageIdentity.ts create mode 100644 packages/components/src/sender/components/layouts/SenderHeader.vue diff --git a/docs/demos/chat/input-controls.vue b/docs/demos/chat/input-controls.vue new file mode 100644 index 0000000000..104c6a9ac9 --- /dev/null +++ b/docs/demos/chat/input-controls.vue @@ -0,0 +1,51 @@ + + + + + + 仅回填 + 清空文本 + 聚焦 + + + + + + diff --git a/docs/demos/chat/message-anchor.vue b/docs/demos/chat/message-anchor.vue new file mode 100644 index 0000000000..e06d8beb63 --- /dev/null +++ b/docs/demos/chat/message-anchor.vue @@ -0,0 +1,51 @@ + + + + + + + + + diff --git a/docs/demos/chat/sender-options.vue b/docs/demos/chat/sender-options.vue index a60e5efb5a..6e457eefb1 100644 --- a/docs/demos/chat/sender-options.vue +++ b/docs/demos/chat/sender-options.vue @@ -7,6 +7,12 @@ import { useChatCaseRuntime } from './shared/createChatRuntime' const showHelp = shallowRef(false) const currentTemplate = shallowRef([]) +const helpText = computed(() => { + if (currentTemplate.value.length > 1) + return '报告模板已填入,文本块可以编辑,下拉字段可以选择。内容超过 300 字时无法提交。' + if (currentTemplate.value.length) return '常用问题已填入,可以修改后发送。内容超过 300 字时无法提交。' + return '@ 用于插入对象名称,不会切换模型;点击“写报告”或“常用问题”填入内容。' +}) const mentions: MentionItem[] = [ { label: '产品组', value: 'product' }, { label: '研发组', value: 'engineering' }, @@ -86,7 +92,7 @@ function fillCommonQuestion() { - @ 用于插入对象名称,不会切换模型;模板中的文本块可以编辑,下拉字段可以选择。内容超过 300 字时无法提交。 + {{ helpText }} diff --git a/docs/src/components/bubble.md b/docs/src/components/bubble.md index c8cd3de8c5..9162aaec70 100644 --- a/docs/src/components/bubble.md +++ b/docs/src/components/bubble.md @@ -172,7 +172,7 @@ BubbleList 支持多种分组策略。分组时,连续的 `hidden` 消息会 #### useAutoScroll -`useAutoScroll` 是 BubbleList 内部使用的公开组合式函数。新代码推荐使用对象参数,并分别传入滚动容器和承载全部内容的内部元素: +`useAutoScroll` 用于在内容增长时自动跟随到底部,向上浏览时暂停跟随。分别传入滚动容器和承载全部内容的元素: ```ts import { useAutoScroll } from '@opentiny/tiny-robot' @@ -203,7 +203,8 @@ const { scrollToBottom, arrivedState } = useAutoScroll({ | 返回值 | 类型 | 说明 | | ---------------- | ---------------------------------------------- | --------------------------------------- | -| `scrollToBottom` | `(behavior?: ScrollBehavior) => Promise` | 命令式滚动到底部;不受 `enabled` 限制。 | +| `pause` | `() => void` | 暂停自动跟随,适用于跳转到指定消息。 | +| `scrollToBottom` | `(behavior?: ScrollBehavior) => Promise` | 滚动到底部并恢复跟随;不受 `enabled` 限制。 | | `arrivedState` | `UseScrollReturn['arrivedState']` | 当前是否到达各滚动边界的响应式状态。 | 旧的位置参数签名仍为兼容性保留,但已弃用: diff --git a/docs/src/components/sender.md b/docs/src/components/sender.md index e0a36487e9..16ab187c32 100644 --- a/docs/src/components/sender.md +++ b/docs/src/components/sender.md @@ -249,6 +249,8 @@ Sender 提供了多个插槽位置,方便扩展功能: - **`footer`** - 底部左侧区域,可添加功能按钮 - **`footer-right`** - 底部右侧区域,可添加操作按钮 +`header` 内容为空时不显示顶部留白和分隔线。条件显示时,请在插槽声明或直接子节点上使用 `v-if`;使用 `v-show` 或仅在子组件内部隐藏内容仍可能占位。 + :::info 当前插槽作用域 当前版本只有 `content` 插槽提供 `editor`。`actions-inline`、`footer` 和 `footer-right` 只负责放置内容,不提供作用域参数;需要操作输入内容时,请通过 Sender 实例公开的方法接入。 ::: diff --git a/docs/src/suites/chat-runtime.md b/docs/src/suites/chat-runtime.md index fd442eb34f..6d15526f5f 100644 --- a/docs/src/suites/chat-runtime.md +++ b/docs/src/suites/chat-runtime.md @@ -680,7 +680,7 @@ const history = useChatHistoryData({ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ChatConversationInfo` | `id: string`;`title: string`;`createdAt?: number`;`updatedAt?: number`;`metadata?: Record`;可添加项目所需的额外字段,字段值类型为 `unknown`。 | | `ChatConversation` | 包含 `ChatConversationInfo` 的全部字段,另有消息列表 `messages: readonly ChatMessageItem[]`、请求状态 `requestState: ChatRequestState` 和处理状态 `processingState?: ChatProcessingState`。 | -| `ChatMessageItem` | `role?: string`;`content?: string \| ChatMessagePart[]`;`reasoning_content?: string`;`tool_calls?: ChatToolCall[]`;`tool_call_id?: string`;`name?: string`;`id?: string`;`loading?: boolean`;`state?: Record`;`metadata?: Record` | +| `ChatMessageItem` | `role?: string`;`content?: string \| ChatMessagePart[]`;`reasoning_content?: string`;`tool_calls?: ChatToolCall[]`;`tool_call_id?: string`;`name?: string`;`id?: string`;`renderKey?: string`;`streamState?: 'streaming' \| 'finished' \| 'unknown'`;`loading?: boolean`;`state?: Record`;`metadata?: Record` | | `ChatMessageContent` | `string \| ChatMessagePart[]` | | `ChatMessagePart` | 消息内容的一部分;`type: string` 标识内容种类,其他字段按该种类提供,字段值类型为 `unknown`。 | | `ChatStructuredDataItem` | 提交的结构化数据项;`type: string` 标识数据种类,其他字段按该种类提供,字段值类型为 `unknown`。 | @@ -690,6 +690,8 @@ const history = useChatHistoryData({ | `ChatRequestState` | `'idle' \| 'processing' \| 'completed' \| 'paused' \| 'aborted' \| 'error'` | | `ChatProcessingState` | `'requesting' \| 'completing' \| string` | +消息对象和服务端 ID 都可能变化时,请提供稳定的 `renderKey`。`streamState` 用于标记消息是否仍在处理中;自定义渲染器的接入见 [Renderer 上下文](./chat#自定义-renderer-上下文)。 + #### 模型与 MCP | 类型 | 定义 | diff --git a/docs/src/suites/chat.md b/docs/src/suites/chat.md index 9a951985f4..c2f7b8ad66 100644 --- a/docs/src/suites/chat.md +++ b/docs/src/suites/chat.md @@ -91,6 +91,22 @@ const ui: ChatUIOptions = { ``` +#### 调整宽度与间距 + +消息列表与输入框默认对齐,回到底部按钮默认位于右侧。在组件或父容器上设置 CSS 变量,可以调整内容宽度、内边距和按钮位置: + +```css +.business-chat { + --tr-chat-ui-content-max-width: 800px; + --tr-chat-ui-content-padding-inline: 20px; + --tr-chat-ui-header-padding: 16px 20px 8px; + --tr-chat-ui-footer-padding: 8px 20px 16px; + --tr-chat-scroll-button-align: center; +} +``` + +完整变量和默认值见 [CSS 变量](#css-变量)。 + ### 配置欢迎区和推荐问题 设置欢迎内容和推荐问题,帮助用户开始对话。 @@ -157,6 +173,16 @@ function handlePromptClick({ item }: ChatPromptClickPayload) { 更多展示配置见 [Welcome](../components/welcome#props) 和 [Prompts](../components/prompts#props)。 +#### 控制欢迎页显示 + +默认在没有可见消息时显示欢迎页。应用也可以通过 `is-empty` 自行决定,例如仅在没有活动会话时显示: + +```vue + +``` + +`activeConversation` 为应用当前选中的会话。已有会话即使尚无消息,也会保持工作区布局。此设置同时影响页头插槽的状态和输入框位置,`TrChatUI` 同样适用。 + ### 配置输入区 为输入框添加提及、可编辑模板和自定义按钮,满足不同输入需求。 @@ -175,8 +201,29 @@ function handlePromptClick({ item }: ChatPromptClickPayload) { 示例通过 `ui.sender.toolbar` 配置“写报告”和“常用问题”,分别填入可编辑模板和预设问题;通过 `sender-footer-right` 添加“使用说明”,配合 `sender-header` 展示操作提示。 +点击“使用说明”显示或隐藏提示,再点击“写报告”或“常用问题”查看提示更新。输入框上方可按需显示标签或提示,隐藏后不保留空白: + +```vue + + {{ helpText }} + +``` + 通过 `ui.sender` 调整输入框、配置提及和模板功能;通过 `ui.sender.toolbar` 或输入区插槽添加自定义按钮,无需重新实现消息发送和取消。 +#### 回填、清空与聚焦 + +可从外部填入提示词、清空文本或聚焦输入框。回填不会自动发送,清空文本不会移除附件或已选 Skill。 + + + +`TrChat` 与 `TrChatUI` 均支持这些操作,完整说明见 [TrChat 组件方法](#trchat-methods) 和 [TrChatUI 组件方法](#trchatui-methods)。 + #### 基础设置 以下默认值为 Chat 的输入区配置,与单独使用 Sender 时不同: @@ -318,6 +365,8 @@ const ui: ChatUIOptions = { 更多配置见 [头像和位置](../components/bubble#头像和位置)、[气泡形状](../components/bubble#气泡形状) 和 [BubbleList 属性](../components/bubble#bubblelist)。 +需要自定义消息内容时,参考 [Bubble 自定义渲染器](../components/bubble#实现自定义渲染器)。渲染器也可获取当前会话、消息状态和滚动容器,见[自定义消息渲染上下文](#自定义消息渲染上下文)。 + #### 消息错误提示配置 请求失败时,在对应的 AI 消息下显示错误提示,帮助用户了解失败原因。 @@ -359,6 +408,25 @@ const disabledErrorUI: ChatUIOptions = { 请求异常处理和操作失败通知见 [请求失败时的提示](./chat-runtime#请求失败时的提示)。 +#### 用户消息目录 + +开启 `ui.anchor` 后,可通过目录快速跳转到用户消息。定位后气泡短暂闪动,滚动时同步当前目录项;点击回到底部可返回最新内容。 + + + +```ts +const ui: ChatUIOptions = { + anchor: {}, +} +``` + +目录默认关闭,开启后在浏览器视口宽度小于 `768px` 时隐藏。应用也可以控制其显示,完整选项见 [anchor 配置](#anchor)。 + ### 配置历史会话列表 自定义历史会话的排序、筛选和分组,同时保留内置会话操作。 @@ -443,6 +511,8 @@ const ui: ChatUIOptions = { - `bubble-content-footer`:添加消息反馈按钮。 - `composer-after`:添加输入框下方的提示。 +额度提醒等页头内容也可放在 `layout-header` 中,由应用控制显示和排版。 + 完整插槽列表及参数见 [插槽](#插槽)。 ### 右侧面板 @@ -724,6 +794,7 @@ const data = computed(() => ({ | 属性名 | 说明 | 类型 | 默认值 | 必填 | | ------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------- | -------------- | ---- | | `runtime` | 提供会话、输入区状态和操作方法。 | [`ChatRuntime`](./chat-runtime#状态与方法) | — | 是 | +| `is-empty` | 是否显示欢迎布局;传入时优先于消息数判断,同时影响页头插槽状态与输入框位置。 | `boolean` | 按可见消息数判断 | 否 | | `ui` | 配置页面布局、文案和区域。 | [`ChatUIOptions`](#界面配置) | 默认界面配置 | 否 | | `title` | 覆盖当前会话提供的页面标题。 | `string` | — | 否 | | `history-data` | 覆盖 Runtime 会话生成的历史列表或分组。 | [`ChatHistoryData`](#展示数据类型) | — | 否 | @@ -760,11 +831,16 @@ const data = computed(() => ({ | 方法 | 签名 | 说明 | | ------------------------- | ------------------------------------------------ | ------------------------------ | | `send` | `(payload: ChatSendPayload) => Promise` | 通过 Runtime 发送消息。 | +| `setInputValue` | `(value: string) => void` | 仅回填文本,不发送。 | +| `clearInput` | `() => void` | 仅清空文本。 | +| `focusInput` | `() => Promise` | 聚焦当前默认输入器,返回是否成功。 | | `openRightAside` | `(panel?: ChatRightAsidePanelId) => void` | 打开右栏,可同时指定面板。 | | `closeRightAside` | `() => void` | 关闭右栏。 | | `toggleRightAside` | `(panel?: ChatRightAsidePanelId) => void` | 切换右栏,可同时指定面板。 | | `activateRightAsidePanel` | `(panel: ChatRightAsidePanelId) => boolean` | 激活存在的面板并返回是否成功。 | +`setInputValue` 和 `clearInput` 只更新文本,不自动发送,也不移除附件或已选 Skill。`focusInput` 返回是否聚焦成功;无默认输入框或输入框不可用时返回 `false`,自定义输入框的聚焦由应用处理。 + ## TrChatUI API ### 属性 {#trchatui-props} @@ -775,6 +851,7 @@ const data = computed(() => ({ | `ui` | 配置页面布局、文案和区域。 | [`ChatUIOptions`](#界面配置) | 默认界面配置 | 否 | | `input-value` | 应用管理的输入内容,需处理更新事件。 | `string` | — | 否 | | `default-input-value` | 组件管理输入时的初始内容。 | `string` | `''` | 否 | +| `is-empty` | 是否显示欢迎布局;传入时优先于消息数判断,同时影响页头插槽状态与输入框位置。 | `boolean` | 按可见消息数判断 | 否 | | `floating-state` | 由应用管理的浮动窗口位置和尺寸。 | [`LayoutFloatingState`](../components/layout#layout-floating-state) | — | 否 | | `right-aside-open` | 由应用管理的右栏开闭状态。 | `boolean` | — | 否 | | `default-right-aside-open` | 组件管理右栏开闭时的初始值。 | `boolean` | `false` | 否 | @@ -823,11 +900,16 @@ const data = computed(() => ({ | 方法 | 签名 | 说明 | | ------------------------- | ------------------------------------------- | ------------------------------ | +| `setInputValue` | `(value: string) => void` | 仅回填文本;受控时请求更新。 | +| `clearInput` | `() => void` | 仅清空文本;受控时请求更新。 | +| `focusInput` | `() => Promise` | 聚焦当前默认输入器,返回是否成功。 | | `openRightAside` | `(panel?: ChatRightAsidePanelId) => void` | 打开右栏,可同时指定面板。 | | `closeRightAside` | `() => void` | 关闭右栏。 | | `toggleRightAside` | `(panel?: ChatRightAsidePanelId) => void` | 切换右栏,可同时指定面板。 | | `activateRightAsidePanel` | `(panel: ChatRightAsidePanelId) => boolean` | 激活存在的面板并返回是否成功。 | +输入方法的行为与 [TrChat](#trchat-methods) 相同。绑定 `input-value` 时,需使用 `v-model:input-value` 或处理 `update:inputValue`,回填和清空才会同步到输入框。 + ### 显示数据 {#chatuidata} `ChatUIData` 是应用传给 `TrChatUI` 的显示数据,组件不会修改它。以下字段均为可选,对应区域见下表。 @@ -837,11 +919,11 @@ const data = computed(() => ({ | 字段 | 类型 | 对应区域 | 默认行为 | | -------------- | ---------------------- | ---------------------------- | ------------------------------------- | | `conversation` | `ChatConversationView` | 页头标题、会话列表和当前选中 | 空列表、无选中,标题为“新对话”。 | -| `bubble` | `ChatBubbleView` | 消息主区 | `messages` 为空,进入空状态。 | +| `bubble` | `ChatBubbleView` | 消息主区 | 消息为空时显示欢迎布局;传入 `is-empty` 时以该值为准。 | | `sender` | `ChatSenderView` | 输入区可用性与加载反馈 | 三个布尔状态均为 `false`。 | | `model` | `ChatModelView` | 输入区模型选择器与能力开关 | 不显示模型选择器。 | | `mcp` | `ChatMcpView` | 输入区 MCP 入口与内置右栏 | 不显示 MCP 入口或面板。 | -| `request` | `ChatRequestView` | 自定义主区和空状态插槽参数 | `undefined`;默认界面不额外显示状态。 | +| `request` | `ChatRequestView` | 插槽参数、消息流状态推断 | `undefined`;不单独显示请求状态。 | #### `conversation` @@ -897,7 +979,7 @@ const data = computed(() => ({ | `state` | `'idle' \| 'processing' \| 'completed' \| 'paused' \| 'aborted' \| 'error'` | 是 | 请求状态。 | | `processingState` | `'requesting' \| 'completing' \| string` | 否 | 应用定义的处理中阶段。 | -`request` 会传给 `layout-main` 和 `layout-empty-state`。默认界面的发送中反馈读取 `sender.loading`,消息错误读取 `message.state.error`;只更新 `request` 不会自动渲染这些反馈。 +`request` 会传给 `layout-main` 和 `layout-empty-state`,也用于推断最新消息的流状态,见[自定义消息渲染上下文](#自定义消息渲染上下文)。输入区的发送中反馈读取 `sender.loading`,消息错误读取 `message.state.error`。 ## 公共配置 @@ -909,20 +991,21 @@ const data = computed(() => ({ 未配置时使用默认界面。支持 `false` 的区域可通过它隐藏,其他选项见下表。 -| 字段 | 类型 | 配置说明 | `false` 的作用 | -| ------------ | -------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------- | -| `layout` | `ChatLayoutOptions` | 未配置的布局字段使用默认值;窗口、输入区和左右侧栏可分别配置。 | 不支持;使用 `leftAside` / `rightAside`。 | -| `brand` | `ChatBrandOptions` | 提供的选项覆盖对应的默认名称或图标,默认名称为 `TinyRobot`。 | 不支持。 | -| `labels` | `Partial` | 按字段覆盖内置中文文案;也会更新默认欢迎文案和历史菜单文案。 | 不支持。 | -| `header` | `false` | 省略时显示默认 Header。 | 移除 Header。 | -| `history` | `false \| ChatHistoryOptions` | 与默认 History 选项合并;`menuItems` 整体替换默认重命名、删除菜单。 | 隐藏历史列表,左栏品牌和操作仍在。 | -| `welcome` | `false \| ChatWelcomeOptions` | 与默认欢迎区配置合并;未提供标题和描述时使用 `labels` 中的文案。 | 空会话不显示欢迎区。 | -| `prompts` | `false \| ChatPromptsOptions` | 与默认 Prompts 选项合并;`items` 整体替换,默认 `[]`。 | 不显示提示项。 | -| `bubble` | `ChatBubbleOptions` | 合并气泡渲染和列表配置;`autoScroll` 默认 `true`,`system` 消息默认隐藏。 | 不支持。 | -| `sender` | `false \| ChatSenderOptions` | 提供的选项覆盖对应的默认输入区选项。 | 移除输入区及其插槽。 | -| `model` | `false \| ChatModelOptions` | 默认 `{}`;只有 `data.model` 存在时显示。 | 即使存在模型数据也隐藏选择器。 | -| `mcp` | `false \| ChatMcpOptions` | 默认 `{}`;当前没有额外配置字段。 | 即使存在 MCP 数据也隐藏入口和面板。 | -| `extensions` | `false \| ChatExtensionsOptions` | 注册应用扩展页签,可设置默认页签及二级面板状态。 | 不注册应用扩展页签。 | +| 字段 | 类型 | 配置说明 | `false` 的作用 | +| --------- | ----------------------------- | ------------------------------------------------------------------------- | ----------------------------------------- | +| `layout` | `ChatLayoutOptions` | 未配置的布局字段使用默认值;窗口、输入区和左右侧栏可分别配置。 | 不支持;使用 `leftAside` / `rightAside`。 | +| `brand` | `ChatBrandOptions` | 提供的选项覆盖对应的默认名称或图标,默认名称为 `TinyRobot`。 | 不支持。 | +| `labels` | `Partial` | 按字段覆盖内置中文文案;也会更新默认欢迎文案和历史菜单文案。 | 不支持。 | +| `header` | `false` | 省略时显示默认 Header。 | 移除 Header。 | +| `history` | `false \| ChatHistoryOptions` | 与默认 History 选项合并;`menuItems` 整体替换默认重命名、删除菜单。 | 隐藏历史列表,左栏品牌和操作仍在。 | +| `welcome` | `false \| ChatWelcomeOptions` | 与默认欢迎区配置合并;未提供标题和描述时使用 `labels` 中的文案。 | 空会话不显示欢迎区。 | +| `prompts` | `false \| ChatPromptsOptions` | 与默认 Prompts 选项合并;`items` 整体替换,默认 `[]`。 | 不显示提示项。 | +| `bubble` | `ChatBubbleOptions` | 合并气泡渲染和列表配置;`autoScroll` 默认 `true`,`system` 消息默认隐藏。 | 不支持。 | +| `sender` | `false \| ChatSenderOptions` | 提供的选项覆盖对应的默认输入区选项。 | 移除输入区及其插槽。 | +| `model` | `false \| ChatModelOptions` | 默认 `{}`;只有 `data.model` 存在时显示。 | 即使存在模型数据也隐藏选择器。 | +| `mcp` | `false \| ChatMcpOptions` | 默认 `{}`;当前没有额外配置字段。 | 即使存在 MCP 数据也隐藏入口和面板。 | +| `extensions` | `false \| ChatExtensionsOptions` | 注册应用扩展页签,可设置默认页签及二级面板状态。 | 不注册应用扩展页签。 | +| `anchor` | `false \| ChatAnchorOptions` | 默认关闭;传入对象开启,见 [anchor 配置](#anchor)。 | 隐藏用户消息目录。 | #### `ui.extensions` {#ui-extensions} @@ -965,10 +1048,10 @@ const ui: ChatUIOptions = { | `surface.mode` | `'normal' \| 'floating'` | `'normal'` | 页面或浮动窗口;浮动时读取 `floatingOptions` 与 `floating-state`。 | | `emptyState` | `'start' \| 'center'` | `'start'` | 空状态在主区起始位置或居中。 | | `composer.welcome` | `'footer' \| 'center'` | `'footer'` | 空会话时的输入区位置:`'footer'` 位于页面底部;`'center'` 随内置欢迎区居中显示,需保留默认内容布局、欢迎区和输入区。 | -| `contentMaxWidth` | `string \| number` | `980` | 消息、欢迎区和输入区的最大内容宽度。 | -| `panelPadding` / `panelGap` | `string \| number` | `12` / `12` | 内容区内边距与区域间距。 | -| `leftAside` | `false \| ChatAsideOptions` | Dock,`300` / `56`,关闭 | 左栏;`open` 由应用管理,`defaultOpen` 仅设置组件管理开闭时的初始值。 | -| `rightAside` | `false \| ChatRightAsideOptions` | Dock,宽 `320`,不可缩放 | 右栏;只有注册应用面板或存在可见 MCP 数据时才渲染。 | +| `contentMaxWidth` | `string \| number` | `980` | 消息、欢迎区和输入区的最大内容宽度。 | +| `panelPadding` / `panelGap` | `string \| number` | `12` / `12` | 消息内容纵向内边距与区域间距;水平内边距通过 CSS 变量设置。 | +| `leftAside` | `false \| ChatAsideOptions` | Dock,`300` / `56`,关闭 | 左栏;`open` 由应用管理,`defaultOpen` 仅设置组件管理开闭时的初始值。 | +| `rightAside` | `false \| ChatRightAsideOptions` | Dock,宽 `320`,不可缩放 | 右栏;只有注册应用面板或存在可见 MCP 数据时才渲染。 | `leftAside` 和 `rightAside` 的配置字段如下,尺寸字段的单位均为 px: @@ -989,6 +1072,17 @@ const ui: ChatUIOptions = { `panels` 需要配合 `layout-right-aside` 或 `layout-right-aside-panel` 插槽提供内容;面板 ID 不能重复,也不能使用内置保留 ID `mcp`。`layout-right-aside-title` 和 `layout-right-aside-panel` 只用于应用添加的面板,不替换 MCP 面板。 +#### `anchor` + +传入 `ui.anchor: {}` 开启用户消息目录,按用户消息的展示文本生成目录项。没有文本的消息不生成目录项;欢迎布局或自定义 `layout-main` 时不显示目录。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `visible` | `boolean` | `true` | 是否显示目录,可用于分享等场景下临时隐藏。 | +| `minWidth` | `number` | `768` | 显示目录所需的最小浏览器视口宽度,单位为 px;不按聊天容器宽度判断。 | + +点击目录或向上浏览会暂停自动跟随,点击回到底部可恢复已启用的自动跟随。 + #### 各区域配置 | 配置项 | 说明 | 相关组件 | @@ -1044,6 +1138,31 @@ toolbar: (defaults) => [ `sender-footer` 插槽仍追加在工具栏之后,需要参与混排的按钮应配置到 `toolbar`。清空、发送和停止按钮继续由原有 Sender 管理。工具栏用于默认多行输入框;替换 `layout-footer` 时由业务自行渲染。 +### CSS 变量 + +在 `TrChat`、`TrChatUI` 或父容器上设置以下变量。长度使用 CSS 长度值,如 `20px` 或 `0`。表中宽度指浏览器视口宽度。 + +| 变量 | 默认值(≥960px) | 默认值(<960px) | 用途 | +| --- | --- | --- | --- | +| `--tr-chat-ui-content-max-width` | `980px` | `980px` | 页头、消息区和输入区的内容最大宽度。 | +| `--tr-chat-ui-content-padding-inline` | `24px` | `16px` | 各区域共享的水平内边距。 | +| `--tr-chat-ui-panel-padding` | `12px` | `12px` | 消息内容纵向内边距。 | +| `--tr-chat-ui-panel-gap` | `12px` | `12px` | 内容容器的子项间距。 | +| `--tr-chat-ui-header-padding` | `24px 24px 0` | `16px 16px 0` | 页头内边距,包含自定义页头。 | +| `--tr-chat-ui-main-padding` | `12px 24px` | `12px 16px` | 消息区内边距。 | +| `--tr-chat-ui-footer-padding` | `0 24px 24px` | `0 16px 16px` | 底部输入区内边距。 | +| `--tr-chat-ui-header-gap` | `8px` | `8px` | 内置页头子项间距,自定义页头自行排版。 | +| `--tr-chat-ui-message-list-padding` | `8px 0` | `8px 0` | 气泡列表内边距。 | +| `--tr-chat-ui-composer-gap` | `0px` | `0px` | 输入框与 `composer-before`、`composer-after` 内容的间距。 | +| `--tr-chat-ui-welcome-composer-gap` | `16px` | `16px` | 内置居中欢迎区与输入框的间距。 | +| `--tr-chat-scroll-button-align` | `flex-end` | `flex-end` | 回到底部按钮的位置:`flex-start` 左侧、`center` 居中、`flex-end` 右侧。 | + +- 设置 `ui.layout.contentMaxWidth`、`panelPadding`、`panelGap` 后,对应默认值随之调整;同项 CSS 变量优先。 +- 页头、消息区和输入区的左右内边距跟随共享水平内边距,也可分别覆盖;消息区上下内边距跟随 `--tr-chat-ui-panel-padding`。 +- 视口宽度 ≥960px 且使用 `composer-after` 时,底部输入区下内边距默认为 `8px`。 + +回到底部按钮以底部输入区为基准对齐;没有底部输入区时,使用共享内容宽度和水平内边距,底边距跟随 `--tr-chat-ui-panel-padding`。 + ### 插槽 以下插槽适用于 `TrChat` 和 `TrChatUI`。插槽参数中的发送、会话、模型和 MCP 操作,在 `TrChat` 中调用当前 Runtime;在 `TrChatUI` 中触发事件,需要项目处理事件并更新数据。 @@ -1067,12 +1186,41 @@ toolbar: (defaults) => [ | `sender-header` | 无 | 在默认输入框内部上方添加内容。 | | `sender-footer` | 无 | 在默认多行输入框内部底部左侧添加内容,与模型和工具操作同一区域。 | | `sender-footer-right` | 无 | 在默认多行输入框内部底部右侧、默认操作按钮前添加内容。 | -| `extension-header-actions` | `ChatExtensionSlotProps` | 在当前扩展页签标题区添加应用操作。 | -| `extension-overlay` | `ChatExtensionSlotProps` | 在扩展右栏显示由应用管理的导入、详情等二级面板。 | -| `header-notice` / `welcome-footer` / `prompts-footer` | 无 | 扩展对应区域。 | +| `welcome-footer` / `prompts-footer` | 无 | 扩展对应区域。 | +| `extension-header-actions` / `extension-overlay` | `ChatExtensionSlotProps` | 为注册的应用扩展页签添加页头操作或二级面板内容。 | | `bubble-prefix` / `bubble-suffix` / `bubble-after` | `ChatBubbleSlotProps` | 在消息周围添加内容。 | | `bubble-content-footer` | `ChatBubbleContentFooterSlotProps` | 在消息内容下方添加内容。 | +`sender-header`、`sender-footer`、`sender-footer-right` 支持动态显示和更新。控制顶部内容显隐时,将 `v-if` 放在插槽声明或直接子节点上;`v-show` 或子组件内部返回空内容仍可能占位。 + +### 自定义消息渲染上下文 + +自定义消息渲染器可通过 `useChatRenderContext(props)` 获取当前会话、消息状态和滚动容器。以下代码位于渲染器的 ` - - - - diff --git a/packages/test/component/chat/ChatEnhancements.spec.ts b/packages/test/component/chat/ChatEnhancements.spec.ts new file mode 100644 index 0000000000..b33a6dc05a --- /dev/null +++ b/packages/test/component/chat/ChatEnhancements.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ChatEnhancements from './ChatEnhancements.fixture.vue' + +for (const path of ['custom', 'center', 'footer'] as const) { + for (const mode of ['single', 'multiple'] as const) { + test(`${path}/${mode}: updates slots without replacing the editor`, async ({ mount, page }) => { + if (path === 'footer') await page.setViewportSize({ width: 600, height: 800 }) + const component = await mount(ChatEnhancements, { props: { path, mode } }) + const editor = component.locator('.ProseMirror') + await expect(editor).toHaveCount(1) + await expect(component.getByTestId('extension-creates')).toHaveText('1') + await editor.fill('draft') + await editor.evaluate((el) => { + el.setAttribute('data-editor-identity', 'original') + }) + await component.getByTestId('add').click() + await expect(component.getByTestId('tag')).toHaveText('Auto') + if (mode === 'multiple') { + await expect(component.getByTestId('footer-tag')).toHaveText('Auto') + await expect(component.getByTestId('right-tag')).toHaveText('Auto') + } + await component.getByTestId('update').click() + await expect(component.getByTestId('tag')).toHaveText('Updated') + if (mode === 'multiple') { + await expect(component.getByTestId('footer-tag')).toHaveText('Updated') + await expect(component.getByTestId('right-tag')).toHaveText('Updated') + } + await component.getByTestId('visible').click() + await expect(component.locator('.tr-sender-header')).toHaveCount(0) + await component.getByTestId('visible').click() + await expect(component.locator('.tr-sender-header')).toHaveCount(1) + await component.getByTestId('add').click() + await expect(component.locator('.tr-sender-header')).toHaveCount(0) + await expect(component.getByTestId('footer-tag')).toHaveCount(0) + await expect(component.getByTestId('right-tag')).toHaveCount(0) + await expect(editor).toHaveAttribute('data-editor-identity', 'original') + await expect(editor).toHaveText('draft') + await expect(component.getByTestId('extension-creates')).toHaveText('1') + await expect(component.getByTestId('extension-destroys')).toHaveText('0') + }) + } + + for (const entry of ['ui', 'runtime'] as const) { + test(`${entry}/${path}: public input actions preserve attachments and do not send`, async ({ mount }) => { + const component = await mount(ChatEnhancements, { props: { entry, path } }) + await component.getByTestId('fill').click() + await expect(component.locator('.ProseMirror')).toHaveText('host prompt') + await component.getByTestId('clear').click() + await expect(component.locator('.ProseMirror')).toHaveText('') + await expect(component.getByTestId('attachment')).toBeVisible() + await component.getByTestId('focus').click() + await expect(component.getByTestId('focus-result')).toHaveText('true') + await expect(component.locator('.ProseMirror')).toBeFocused() + await expect(component.getByTestId('sends')).toHaveText('0') + }) + } + + test(`${path}: slot updates preserve an active composition`, async ({ mount }) => { + const component = await mount(ChatEnhancements, { props: { path } }) + const editor = component.locator('.ProseMirror') + await editor.focus() + await editor.dispatchEvent('compositionstart', { data: '中' }) + await component.getByTestId('add').dispatchEvent('click') + await expect(component.getByTestId('tag')).toHaveText('Auto') + await component.getByTestId('composition').dispatchEvent('click') + await expect(component.getByTestId('composing')).toHaveText('true') + await expect(editor).toBeFocused() + await expect(component.getByTestId('extension-creates')).toHaveText('1') + await editor.dispatchEvent('compositionend', { data: '中' }) + await expect(component.getByTestId('sends')).toHaveText('0') + }) +} + +test('controlled empty state governs Header, welcome and composer placement together', async ({ mount }) => { + const component = await mount(ChatEnhancements, { props: { path: 'custom', controlledEmpty: false } }) + await expect(component.getByTestId('empty')).toHaveText('false') + await expect(component.getByTestId('custom-welcome')).toHaveCount(0) + await expect(component.locator('.chat-panel-content--footer .ProseMirror')).toHaveCount(1) + await component.update({ props: { path: 'custom', controlledEmpty: true } }) + await expect(component.getByTestId('empty')).toHaveText('true') + await expect(component.getByTestId('custom-welcome').locator('.ProseMirror')).toHaveCount(1) + await expect(component.locator('.chat-panel-content--footer')).toHaveCount(0) +}) + +test('disabled input reports unsuccessful focus', async ({ mount }) => { + const component = await mount(ChatEnhancements, { props: { disabled: true } }) + await component.getByTestId('focus').click() + await expect(component.getByTestId('focus-result')).toHaveText('false') +}) + +test('missing input reports unsuccessful focus', async ({ mount }) => { + const component = await mount(ChatEnhancements, { props: { hideSender: true } }) + await component.getByTestId('focus').click() + await expect(component.getByTestId('focus-result')).toHaveText('false') +}) + +test('hidden system messages keep the default welcome and can be overridden by an existing empty conversation', async ({ + mount, +}) => { + const component = await mount(ChatEnhancements, { props: { path: 'custom', systemOnly: true } }) + await expect(component.getByTestId('empty')).toHaveText('true') + await expect(component.getByTestId('custom-welcome')).toBeVisible() + await component.update({ props: { path: 'custom', systemOnly: true, controlledEmpty: false } }) + await expect(component.getByTestId('empty')).toHaveText('false') + await expect(component.getByTestId('custom-welcome')).toHaveCount(0) + await expect(component.locator('.tr-bubble[data-role="system"]')).not.toBeVisible() + await expect(component.locator('.chat-panel-content--footer .ProseMirror')).toHaveCount(1) +}) + +test('inherited CSS variables override layout defaults', async ({ mount }) => { + const component = await mount(ChatEnhancements) + await expect(component.locator('.chat-panel-content--header')).toHaveCSS('padding', '3px 5px 7px') + await expect(component.locator('.chat-panel-content--main')).toHaveCSS('padding', '9px') + await expect(component.locator('.chat-panel-content--footer')).toHaveCSS('padding', '11px 13px 15px') + await expect(component.locator('.chat-panel-content--footer')).toHaveCSS('max-width', '700px') +}) diff --git a/packages/test/component/chat/ChatRenderContext.fixture.vue b/packages/test/component/chat/ChatRenderContext.fixture.vue new file mode 100644 index 0000000000..4d34416d7b --- /dev/null +++ b/packages/test/component/chat/ChatRenderContext.fixture.vue @@ -0,0 +1,124 @@ + + + + + Grow + Finish + Switch + Toggle sender header + + Custom main + Attachment preview + + + + + diff --git a/packages/test/component/chat/ChatRenderContext.spec.ts b/packages/test/component/chat/ChatRenderContext.spec.ts new file mode 100644 index 0000000000..465b5e733a --- /dev/null +++ b/packages/test/component/chat/ChatRenderContext.spec.ts @@ -0,0 +1,224 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ChatRenderContext from './ChatRenderContext.fixture.vue' + +test('Renderer context preserves identity through copies and exposes the real scroll root', async ({ mount }) => { + const component = await mount(ChatRenderContext) + const probes = component.locator('.context-probe') + const last = probes.last() + await expect(last).toHaveAttribute('data-state', 'streaming') + await expect(last).toHaveAttribute('data-root', 'true') + await expect(probes.first()).toHaveAttribute('data-index', '0') + await expect(probes.nth(1)).toHaveAttribute('data-index', '1') + await expect(probes.nth(2)).toHaveAttribute('data-state', 'finished') + const key = await last.getAttribute('data-key') + const instance = await last.getAttribute('data-instance') + await component.getByTestId('grow').click() + await expect(last).toHaveAttribute('data-key', key!) + await expect(last).toHaveAttribute('data-instance', instance!) + await component.getByTestId('finish').click() + await expect(last).toHaveAttribute('data-state', 'finished') + await component.getByTestId('thread').click() + await expect(last).toHaveAttribute('data-thread', 'thread-b') + await expect(last).not.toHaveAttribute('data-key', key!) +}) + +test('Anchor uses display questions, targets only the first split and retains caller attributes', async ({ + mount, + page, +}) => { + await page.setViewportSize({ width: 1200, height: 800 }) + const component = await mount(ChatRenderContext) + const items = component.locator('.tr-anchor__item') + await expect(items).toHaveCount(6) + await expect(items.first()).toContainText('Displayed question 0') + const userBubbles = component.locator('.tr-bubble[data-role="user"]') + await expect(userBubbles.first().locator('.tr-chat-anchor-target[data-anchor-id]')).toHaveCount(1) + await expect(userBubbles.first().locator('[data-business="kept"]')).toHaveCount(2) + await expect(userBubbles.first().locator('.business-box')).toHaveCount(2) + await expect(userBubbles.first().locator('.business-box').first()).toHaveCSS('border-top-width', '3px') + await expect(userBubbles.first().locator('[data-business-content="kept"]')).toHaveCount(2) +}) + +test('Anchor reuses caller target IDs and preserves callbacks for every split', async ({ mount, page }) => { + await page.setViewportSize({ width: 1200, height: 800 }) + const component = await mount(ChatRenderContext, { props: { callerAnchors: true } }) + const user = component.locator('.tr-bubble[data-role="user"]').first() + await expect(user.locator('[data-anchor-id="business-user-0-0"]')).toHaveCount(1) + await expect(user.locator('[data-anchor-id="business-user-0-1"]')).toHaveCount(1) + await expect(component.locator('.tr-anchor__item').first()).toHaveAttribute('data-item-id', 'business-user-0-0') + await expect(user.locator('[data-business-index="0"]')).toHaveCount(1) + await expect(user.locator('[data-business-index="1"]')).toHaveCount(1) + await component.locator('.tr-anchor__item').first().click() + await expect(user.locator('[data-anchor-id="business-user-0-0"]')).toHaveClass(/tr-chat-anchor-target-active/) +}) + +test('Anchor combines callback attributes and injects only the first split target', async ({ mount, page }) => { + await page.setViewportSize({ width: 1200, height: 800 }) + const component = await mount(ChatRenderContext, { props: { callbackAttributes: true } }) + const user = component.locator('.tr-bubble[data-role="user"]').first() + await expect(user.locator('.tr-chat-anchor-target[data-anchor-id]')).toHaveCount(1) + await expect(user.locator('.business-box')).toHaveCount(2) + await expect(user.locator('[data-business-index="1"]')).toHaveCount(1) +}) + +test('disabled Anchor leaves box attributes unchanged', async ({ mount }) => { + const component = await mount(ChatRenderContext, { props: { anchor: false } }) + await expect(component.locator('.tr-chat-anchor-target')).toHaveCount(0) + await expect(component.locator('.tr-chat-anchor-target[data-anchor-id]')).toHaveCount(0) + await expect(component.locator('.business-box')).toHaveCount(18) +}) + +test('Anchor navigation pauses growth following and back-to-bottom resumes it', async ({ mount, page }) => { + await page.setViewportSize({ width: 1200, height: 800 }) + const component = await mount(ChatRenderContext) + const root = component.locator('.chat-main-scroll-host') + const distance = () => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop) + await expect.poll(distance).toBeLessThanOrEqual(2) + await component.locator('.tr-anchor__item').first().click() + await expect(component.locator('.tr-chat-anchor-target-active')).toHaveCount(1) + await expect.poll(() => root.evaluate((el) => el.scrollTop)).toBeLessThan(30) + await component.getByTestId('grow').click() + await expect.poll(distance).toBeGreaterThan(500) + await component.locator('.tr-chat-scroll-to-bottom').click() + await expect.poll(distance).toBeLessThanOrEqual(2) + await component.getByTestId('grow').click() + await expect.poll(distance).toBeLessThanOrEqual(2) +}) + +test('Anchor follows the viewport rule and stays absent with custom main or disabled configuration', async ({ + mount, + page, +}) => { + await page.setViewportSize({ width: 767, height: 800 }) + const component = await mount(ChatRenderContext) + await expect(component.locator('.tr-anchor')).toHaveCount(0) + await page.setViewportSize({ width: 768, height: 800 }) + await expect(component.locator('.tr-anchor')).toHaveCount(1) + await component.update({ props: { customMain: true } }) + await expect(component.locator('.tr-anchor')).toHaveCount(0) + await component.update({ props: { customMain: false, anchor: false } }) + await expect(component.locator('.tr-anchor')).toHaveCount(0) +}) + +for (const theme of ['light', 'dark'] as const) { + test(`Anchor uses the documented 1.8s flash in ${theme} mode`, async ({ mount, page }) => { + await page.setViewportSize({ width: 1200, height: 800 }) + await page.evaluate((mode) => document.documentElement.setAttribute('data-tr-color-mode', mode), theme) + const component = await mount(ChatRenderContext) + const target = component.locator('.tr-chat-anchor-target').first() + const restingColor = await target.evaluate((el) => getComputedStyle(el).backgroundColor) + await page.clock.install() + await component.locator('.tr-anchor__item').first().click() + await expect(target).toHaveClass(/tr-chat-anchor-target-active/) + await expect(target).toHaveCSS('animation-duration', '1.8s') + await expect(target).toHaveCSS('animation-timing-function', 'ease-out') + await expect(target).toHaveCSS('outline-style', 'none') + const frames = await target.evaluate((el) => el.getAnimations()[0].effect!.getKeyframes()) + expect(frames.map((frame) => frame.offset)).toEqual([0, 0.25, 0.45, 0.65, 0.85, 1]) + expect(frames[0].backgroundColor).not.toBe(restingColor) + expect(frames[2].backgroundColor).toBe(restingColor) + expect(frames[5].backgroundColor).toBe(restingColor) + await page.clock.fastForward(900) + await expect(target).toHaveClass(/tr-chat-anchor-target-active/) + await page.clock.fastForward(1000) + await expect(target).not.toHaveClass(/tr-chat-anchor-target-active/) + }) +} + +for (const layout of [ + { name: 'desktop', viewport: 1200, props: { contentMaxWidth: 640 } }, + { name: 'mobile', viewport: 600, props: { width: 420 } }, + { name: 'CSS override', viewport: 1200, props: { contentMaxWidth: 640, contentPaddingInline: '37px' } }, + { name: 'floating', viewport: 1200, props: { floating: true, width: 600 } }, +] as const) { + test(`${layout.name}: messages, Sender and back-to-bottom share their content edges`, async ({ mount, page }) => { + await page.setViewportSize({ width: layout.viewport, height: 800 }) + const props = layout.props + const component = await mount(ChatRenderContext, { props: { anchor: false, ...props } }) + const chat = page.locator('.tr-chat-ui') + const root = chat.locator('.chat-main-scroll-host') + const button = chat.locator('.tr-chat-scroll-to-bottom') + await expect + .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)) + .toBeLessThanOrEqual(2) + await root.evaluate((el) => { + el.scrollTop = 0 + }) + await expect(button).toBeVisible() + + const aligned = async () => { + const list = (await chat.locator('.tr-bubble-list__content').boundingBox())! + const sender = (await chat.locator('.tr-sender').boundingBox())! + const action = (await button.boundingBox())! + const composer = (await chat.locator('.chat-footer').boundingBox())! + expect(Math.abs(list.x - sender.x)).toBeLessThanOrEqual(1) + expect(Math.abs(list.x + list.width - sender.x - sender.width)).toBeLessThanOrEqual(1) + expect(Math.abs(action.x + action.width - sender.x - sender.width)).toBeLessThanOrEqual(1) + expect(Math.abs(composer.y - action.y - action.height - 8)).toBeLessThanOrEqual(1) + } + await expect(aligned).toPass() + + const composerHeight = (await chat.locator('.chat-footer').boundingBox())!.height + await component.getByTestId('sender-header').dispatchEvent('click') + await expect + .poll(async () => (await chat.locator('.chat-footer').boundingBox())!.height) + .toBeGreaterThan(composerHeight) + await expect(aligned).toPass() + if ('floating' in props) { + await component.update({ props: { anchor: false, ...props, width: 420 } }) + await expect(chat).toHaveCSS('width', '420px') + await expect(aligned).toPass() + } + }) +} + +test('back-to-bottom remains available when the Sender is hidden', async ({ mount }) => { + const component = await mount(ChatRenderContext, { props: { hideSender: true, anchor: false } }) + const root = component.locator('.chat-main-scroll-host') + await expect + .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)) + .toBeLessThanOrEqual(2) + await root.evaluate((el) => { + el.scrollTop = 0 + }) + await expect(component.locator('.chat-scroll-actions--main .tr-chat-scroll-to-bottom')).toBeVisible() + await component.locator('.tr-chat-scroll-to-bottom').click() + await expect + .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)) + .toBeLessThanOrEqual(2) +}) + +for (const hideSender of [false, true]) { + test(`CSS controls back-to-bottom alignment ${hideSender ? 'without' : 'with'} a Sender`, async ({ mount }) => { + const props = { anchor: false, hideSender, contentMaxWidth: 640, contentPaddingInline: '37px' } + const component = await mount(ChatRenderContext, { props }) + const root = component.locator('.chat-main-scroll-host') + const button = component.locator('.tr-chat-scroll-to-bottom') + await expect + .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)) + .toBeLessThanOrEqual(2) + await root.evaluate((el) => { + el.scrollTop = 0 + }) + await expect(button).toBeVisible() + + for (const scrollButtonAlign of ['flex-start', 'center', 'flex-end', undefined] as const) { + await component.update({ props: { ...props, scrollButtonAlign } }) + await expect(async () => { + const content = (await component.locator('.tr-bubble-list__content').boundingBox())! + const action = (await button.boundingBox())! + const expectedX = + scrollButtonAlign === 'flex-start' + ? content.x + : scrollButtonAlign === 'center' + ? content.x + (content.width - action.width) / 2 + : content.x + content.width - action.width + expect(Math.abs(action.x - expectedX)).toBeLessThanOrEqual(1) + }).toPass() + } + await button.click() + await expect + .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)) + .toBeLessThanOrEqual(2) + }) +} diff --git a/packages/test/component/chat/RenderContextProbe.vue b/packages/test/component/chat/RenderContextProbe.vue new file mode 100644 index 0000000000..8069f1568b --- /dev/null +++ b/packages/test/component/chat/RenderContextProbe.vue @@ -0,0 +1,28 @@ + + + + + {{ contentText }} + + diff --git a/packages/test/component/sender/SenderHeader.fixture.vue b/packages/test/component/sender/SenderHeader.fixture.vue new file mode 100644 index 0000000000..4ef91a01ed --- /dev/null +++ b/packages/test/component/sender/SenderHeader.fixture.vue @@ -0,0 +1,48 @@ + + + + + + {{ name }} + + + + diff --git a/packages/test/component/sender/SenderHeader.spec.ts b/packages/test/component/sender/SenderHeader.spec.ts new file mode 100644 index 0000000000..8d32d5c08b --- /dev/null +++ b/packages/test/component/sender/SenderHeader.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import SenderHeader from './SenderHeader.fixture.vue' + +for (const mode of ['single', 'multiple'] as const) { + test(`${mode}: omits structurally empty headers and preserves valid VNodes`, async ({ mount }) => { + const component = await mount(SenderHeader, { props: { mode } }) + for (const name of ['comment', 'fragment', 'whitespace', 'empty']) { + await component.getByRole('button', { name, exact: true }).click() + await expect(component.locator('.tr-sender-header')).toHaveCount(0) + } + for (const name of ['zero', 'element', 'component']) { + await component.getByRole('button', { name, exact: true }).click() + await expect(component.locator('.tr-sender-header')).toHaveCount(1) + } + await component.getByRole('button', { name: 'zero', exact: true }).click() + await expect(component.locator('.tr-sender-header')).toHaveText('0') + }) +} From 2e5a41d6d51ebf2a03f81aafe16575928570b521 Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Tue, 20 Oct 2026 19:03:06 -0700 Subject: [PATCH 03/15] feat(template): enhance template cursor management and backspace logic --- .../bubble/BubbleListAutoScrollDemo.spec.ts | 24 ++++----- .../src/sender/helpers/template-helper.ts | 24 +++++++++ packages/test/src/sender/index.vue | 49 ++++++++++++++++--- .../sender/specs/template/backspace.spec.ts | 47 ++++++++---------- 4 files changed, 99 insertions(+), 45 deletions(-) diff --git a/packages/test/component/bubble/BubbleListAutoScrollDemo.spec.ts b/packages/test/component/bubble/BubbleListAutoScrollDemo.spec.ts index 0650c18d24..0a0acb87a5 100644 --- a/packages/test/component/bubble/BubbleListAutoScrollDemo.spec.ts +++ b/packages/test/component/bubble/BubbleListAutoScrollDemo.spec.ts @@ -12,6 +12,7 @@ test.describe('BubbleList auto-scroll demo', () => { await page.route('https://assets.science.nasa.gov/**', async (route) => { await route.fulfill({ body: earthriseImage, contentType: 'image/svg+xml' }) }) + await page.clock.install() }) test('does not render the asynchronous region before it is triggered', async ({ mount }) => { @@ -21,8 +22,8 @@ test.describe('BubbleList auto-scroll demo', () => { }) test('shows the loading region for 500ms, then mounts a compact image card at once', async ({ mount, page }) => { - await page.clock.install() const component = await mount(BubbleListAutoScrollDemo) + await page.clock.pauseAt((await page.evaluate(() => Date.now())) + 1000) const region = component.locator('.async-content') const button = component.getByRole('button', { name: '模拟图片异步加载' }) const list = component.locator('.tr-bubble-list') @@ -34,13 +35,14 @@ test.describe('BubbleList auto-scroll demo', () => { await expect(region).toContainText('图片加载中…') await expect(region.getByRole('img')).toHaveCount(0) - await page.clock.fastForward(400) + await page.clock.runFor(499) await expect(region).toContainText('图片加载中…') await expect(region.getByRole('img')).toHaveCount(0) - await page.clock.fastForward(100) + await page.clock.runFor(1) await expect(region.getByRole('img', { name: '从月球地平线上升起的地球' })).toBeVisible() await expect(region).toContainText('Earthrise · Apollo 8') + await page.clock.resume() const sizes = await component.locator('.scroll-container').evaluate((container) => { const content = container.querySelector('.async-content')! @@ -57,15 +59,15 @@ test.describe('BubbleList auto-scroll demo', () => { }) test('creates one asynchronous region per source message', async ({ mount, page }) => { - await page.clock.install() const component = await mount(BubbleListAutoScrollDemo) + await page.clock.pauseAt((await page.evaluate(() => Date.now())) + 1000) const sourceBubble = component.locator('.tr-bubble').filter({ hasText: '当然,这是 Apollo 8 拍摄的 Earthrise:', }) const loadButton = component.getByRole('button', { name: '模拟图片异步加载' }) await loadButton.click() - await page.clock.fastForward(500) + await page.clock.runFor(500) await expect(sourceBubble.getByRole('img', { name: '从月球地平线上升起的地球' })).toBeVisible() await component.getByRole('button', { name: '添加消息' }).click() @@ -77,18 +79,18 @@ test.describe('BubbleList auto-scroll demo', () => { await expect(component.locator('.async-content')).toHaveCount(2) await expect(sourceBubble.getByRole('img', { name: '从月球地平线上升起的地球' })).toBeVisible() - await page.clock.fastForward(500) + await page.clock.runFor(500) await expect(laterBubble.getByRole('img', { name: '从月球地平线上升起的地球' })).toBeVisible() }) test('replays asynchronous loading for the current source message', async ({ mount, page }) => { - await page.clock.install() const component = await mount(BubbleListAutoScrollDemo) + await page.clock.pauseAt((await page.evaluate(() => Date.now())) + 1000) const region = component.locator('.async-content') const loadButton = component.getByRole('button', { name: '模拟图片异步加载' }) await loadButton.click() - await page.clock.fastForward(500) + await page.clock.runFor(500) await expect(region.getByRole('img', { name: '从月球地平线上升起的地球' })).toBeVisible() await loadButton.click() @@ -96,18 +98,18 @@ test.describe('BubbleList auto-scroll demo', () => { await expect(region).toContainText('图片加载中…') await expect(region.getByRole('img')).toHaveCount(0) - await page.clock.fastForward(500) + await page.clock.runFor(500) await expect(region.getByRole('img', { name: '从月球地平线上升起的地球' })).toBeVisible() }) test('aligns a loaded user asynchronous region to the end', async ({ mount, page }) => { - await page.clock.install() const component = await mount(BubbleListAutoScrollDemo) + await page.clock.pauseAt((await page.evaluate(() => Date.now())) + 1000) await component.getByRole('button', { name: '添加消息' }).click() const userBubble = component.locator('.tr-bubble').filter({ hasText: '第 3 条消息' }) await component.getByRole('button', { name: '模拟图片异步加载' }).click() - await page.clock.fastForward(500) + await page.clock.runFor(500) await expect(userBubble.getByRole('img', { name: '从月球地平线上升起的地球' })).toBeVisible() const alignment = await userBubble.evaluate((bubble) => { diff --git a/packages/test/src/sender/helpers/template-helper.ts b/packages/test/src/sender/helpers/template-helper.ts index 7402975006..1958027d00 100644 --- a/packages/test/src/sender/helpers/template-helper.ts +++ b/packages/test/src/sender/helpers/template-helper.ts @@ -145,6 +145,30 @@ export function createTemplateTestHelper(page: Page) { await block.click() }, + async expectTemplateCursor(index: number, position: 'before' | 'start' | 'end' | 'after') { + await expect + .poll(() => + page.evaluate( + ({ index, position }) => { + return window.__senderTestApi?.isCursorAtTemplate(index, position) ?? false + }, + { index, position }, + ), + ) + .toBe(true) + }, + + async focusTemplateAt(index: number, position: 'before' | 'start' | 'end' | 'after') { + const moved = await page.evaluate( + ({ index, position }) => { + return window.__senderTestApi?.moveCursorToTemplate(index, position) ?? false + }, + { index, position }, + ) + expect(moved).toBe(true) + await this.expectTemplateCursor(index, position) + }, + /** * 从模板块左侧触发 Delete,避免依赖外层键盘时序 */ diff --git a/packages/test/src/sender/index.vue b/packages/test/src/sender/index.vue index 0901611dca..631386beac 100644 --- a/packages/test/src/sender/index.vue +++ b/packages/test/src/sender/index.vue @@ -11,10 +11,14 @@ import type { TemplateItem, } from '@opentiny/tiny-robot' +type TemplateCursorPosition = 'before' | 'start' | 'end' | 'after' + declare global { interface Window { __senderTestApi?: { moveCursorBeforeTemplate: (index: number) => boolean + moveCursorToTemplate: (index: number, position: TemplateCursorPosition) => boolean + isCursorAtTemplate: (index: number, position: TemplateCursorPosition) => boolean pressDeleteBeforeTemplate: (index: number) => boolean setTemplateAppendTo: (value: string | HTMLElement | undefined) => void } @@ -227,18 +231,23 @@ const extensions = computed(() => { return exts }) -const moveCursorBeforeTemplate = (index: number) => { +const getTemplatePosition = (index: number, position: TemplateCursorPosition) => { const exposedEditor = senderRef.value?.editor const editor = exposedEditor?.value ?? exposedEditor - if (!editor) return false + if (!editor) return null let currentIndex = 0 let templatePosition: number | null = null - editor.state.doc.descendants((node: { type: { name: string } }, pos: number) => { + editor.state.doc.descendants((node: { type: { name: string }; nodeSize: number }, pos: number) => { if (node.type.name === 'templateBlock') { if (currentIndex === index) { - templatePosition = pos + templatePosition = { + before: pos, + start: pos + 1, + end: pos + node.nodeSize - 1, + after: pos + node.nodeSize, + }[position] return false } currentIndex += 1 @@ -246,14 +255,36 @@ const moveCursorBeforeTemplate = (index: number) => { return true }) - if (templatePosition === null) return false + return templatePosition +} - editor.commands.setTextSelection(templatePosition) - editor.commands.focus(templatePosition) +const isCursorAtTemplate = (index: number, position: TemplateCursorPosition) => { + const exposedEditor = senderRef.value?.editor + const editor = exposedEditor?.value ?? exposedEditor + const target = getTemplatePosition(index, position) + return ( + !!editor && + target !== null && + editor.view.hasFocus() && + editor.state.selection.empty && + editor.state.selection.from === target + ) +} - return editor.state.selection.$from.nodeAfter?.type?.name === 'templateBlock' +const moveCursorToTemplate = (index: number, position: TemplateCursorPosition) => { + const exposedEditor = senderRef.value?.editor + const editor = exposedEditor?.value ?? exposedEditor + const target = getTemplatePosition(index, position) + if (!editor || target === null) return false + + // 同步聚焦后设置选区,避免延迟 focus 与真实键盘操作交错。 + editor.view.focus() + editor.commands.setTextSelection(target) + return isCursorAtTemplate(index, position) } +const moveCursorBeforeTemplate = (index: number) => moveCursorToTemplate(index, 'before') + const pressDeleteBeforeTemplate = (index: number) => { const exposedEditor = senderRef.value?.editor const editor = exposedEditor?.value ?? exposedEditor @@ -271,6 +302,8 @@ const pressDeleteBeforeTemplate = (index: number) => { onMounted(() => { window.__senderTestApi = { moveCursorBeforeTemplate, + moveCursorToTemplate, + isCursorAtTemplate, pressDeleteBeforeTemplate, setTemplateAppendTo, } diff --git a/packages/test/src/sender/specs/template/backspace.spec.ts b/packages/test/src/sender/specs/template/backspace.spec.ts index d66911bfdd..afbfe5950b 100644 --- a/packages/test/src/sender/specs/template/backspace.spec.ts +++ b/packages/test/src/sender/specs/template/backspace.spec.ts @@ -3,8 +3,6 @@ import { createSenderTestHelper } from '../../helpers' import { createTemplateTestHelper } from '../../helpers/template-helper' test.describe('Template Block - Backspace 删除逻辑', () => { - test.describe.configure({ mode: 'serial' }) - let page: Page let helper: ReturnType let templateHelper: ReturnType @@ -17,75 +15,72 @@ test.describe('Template Block - Backspace 删除逻辑', () => { templateHelper = createTemplateTestHelper(page) await helper.toggleTemplate() - await helper.wait(300) - await helper.clearContent() await templateHelper.clearTemplates() }) test('TC-BS-01: 应该能够删除模板块内的字符', async () => { await templateHelper.setSimpleTemplate() - await templateHelper.focusTemplateEnd(0) + await templateHelper.expectTemplateCursor(0, 'end') await templateHelper.pressBackspace() await templateHelper.expectTemplateText(0, '张') + await templateHelper.expectTemplateCursor(0, 'end') }) test('TC-BS-02: 删除最后一个字符时应该保留模板块', async () => { await templateHelper.setSimpleTemplate() - await templateHelper.focusTemplateEnd(0) + await templateHelper.expectTemplateCursor(0, 'end') await templateHelper.pressBackspace() await templateHelper.pressBackspace() await templateHelper.expectTemplateCount(1) + await templateHelper.expectNormalizedEditorText('我是,来自') }) test('TC-BS-03: 空模板块内按 Backspace 应该跳出模板块', async () => { await templateHelper.setEmptyTemplate() - await templateHelper.clickTemplate(0) - await templateHelper.wait(100) + await templateHelper.expectTemplateCursor(0, 'end') await templateHelper.pressBackspace() await templateHelper.expectTemplateCount(1) + await templateHelper.expectTemplateCursor(0, 'before') + await templateHelper.expectNormalizedEditorText('我是,来自') }) test('TC-BS-04: 模板块开头按 Backspace 应该跳出且不吸入文本', async () => { await templateHelper.setSimpleTemplate() - await templateHelper.focusTemplateStart(0) + await templateHelper.expectTemplateCursor(0, 'end') + await templateHelper.focusTemplateAt(0, 'start') await templateHelper.pressBackspace() await templateHelper.expectEditorToContainText('我是') await templateHelper.expectTemplateCount(1) + await templateHelper.expectTemplateCursor(0, 'before') + await templateHelper.expectNormalizedEditorText('我是张三,来自') }) test('TC-BS-05: 从模板块右侧按 Backspace 应该进入模板块', async () => { await templateHelper.setSimpleTemplate() - await helper.getEditor().click() - await templateHelper.wait(100) - await helper.getEditor().press('End') - await templateHelper.pressArrowLeft(3) + await templateHelper.expectTemplateCursor(0, 'end') + await templateHelper.focusTemplateAt(0, 'after') await templateHelper.pressBackspace() await templateHelper.expectTemplateCount(1) await templateHelper.expectTemplateText(0, '张三') + await templateHelper.expectTemplateCursor(0, 'end') + await templateHelper.expectNormalizedEditorText('我是张三,来自') }) - test('TC-BS-06: 从右侧删除空模板块需要多次操作', async () => { + test('TC-BS-06: 从右侧按 Backspace 应删除空模板块', async () => { await templateHelper.setEmptyTemplate() - await templateHelper.clickTemplate(0) - await templateHelper.wait(100) + await templateHelper.expectTemplateCursor(0, 'end') await templateHelper.pressArrowRight() - await templateHelper.wait(50) - - for (let i = 0; i < 4; i++) { - if ((await templateHelper.getTemplateCount()) === 0) { - break - } - await templateHelper.pressBackspace() - await templateHelper.wait(100) - } + await templateHelper.expectTemplateCursor(0, 'after') + await templateHelper.pressBackspace() await templateHelper.expectTemplateCount(0) + await templateHelper.expectNormalizedEditorText('我是,来自') }) test('TC-BS-07: 选区删除应该包含模板块', async () => { await templateHelper.setSimpleTemplate() - await helper.getEditor().click() + await templateHelper.expectTemplateCursor(0, 'end') await templateHelper.selectText() await templateHelper.pressBackspace() From 28882e555e6927cadf22073d27b0719fee522d62 Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Tue, 20 Oct 2026 23:33:46 -0700 Subject: [PATCH 04/15] feat(anchor): support configurable right inset through CSS variable --- packages/components/src/anchor/components/AnchorOverlay.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/anchor/components/AnchorOverlay.vue b/packages/components/src/anchor/components/AnchorOverlay.vue index 41ecedb830..796efd502c 100644 --- a/packages/components/src/anchor/components/AnchorOverlay.vue +++ b/packages/components/src/anchor/components/AnchorOverlay.vue @@ -65,7 +65,7 @@ defineExpose({ bottom: 0; &.is-right { - right: 0; + right: var(--tr-chat-anchor-inset-inline-end, 0px); } &.is-left { From 9281fe02b63f7bc9a4717d2d7468f152c3adfb10 Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Wed, 21 Oct 2026 00:48:35 -0700 Subject: [PATCH 05/15] feat(tests): enhance message identity tests and improve auto-scroll functionality --- .../src/ui/messages/messageIdentity.test.ts | 19 +++++++ .../chat/src/ui/messages/messageIdentity.ts | 2 +- .../component/bubble/AutoScroll.fixture.vue | 3 +- .../test/component/bubble/AutoScroll.spec.ts | 55 +++++++++++++++++-- .../src/sender/helpers/template-helper.ts | 24 -------- packages/test/src/sender/index.vue | 49 +++-------------- .../sender/specs/template/backspace.spec.ts | 47 +++++++++------- 7 files changed, 107 insertions(+), 92 deletions(-) diff --git a/packages/chat/src/ui/messages/messageIdentity.test.ts b/packages/chat/src/ui/messages/messageIdentity.test.ts index 7999fcd39a..5085db2a2b 100644 --- a/packages/chat/src/ui/messages/messageIdentity.test.ts +++ b/packages/chat/src/ui/messages/messageIdentity.test.ts @@ -11,6 +11,25 @@ describe('Chat message identity', () => { expect(identify({ id: 'server' }, 'a')).toBe(identify({ id: 'server', content: 'updated' }, 'a')) }) + it('falls back from empty render keys to stable message IDs', () => { + const identify = createMessageIdentity('chat') + const key = identify({ renderKey: '', id: 'first' }, 'a') + expect(key).toBe(identify({ id: 'first', content: 'updated' }, 'a')) + expect(identify({ renderKey: '', id: 'second' }, 'a')).not.toBe(key) + }) + + it.each([{ renderKey: '' }, { id: '' }, { renderKey: '', id: '' }])( + 'uses object identity for empty identifiers %j', + (identifiers) => { + const identify = createMessageIdentity('chat') + const message = { ...identifiers } + const key = identify(message, 'a') + expect(identify({ ...identifiers }, 'a')).not.toBe(key) + expect(identify(message, 'a')).toBe(key) + expect(identify(reactive(message), 'a')).toBe(key) + }, + ) + it('isolates conversations and Chat instances, and recognizes raw/proxied originals', () => { const identify = createMessageIdentity('chat-a') const message = { content: 'hello' } diff --git a/packages/chat/src/ui/messages/messageIdentity.ts b/packages/chat/src/ui/messages/messageIdentity.ts index 35ccc95a34..81cf9f6626 100644 --- a/packages/chat/src/ui/messages/messageIdentity.ts +++ b/packages/chat/src/ui/messages/messageIdentity.ts @@ -5,7 +5,7 @@ export function createMessageIdentity(scope: string) { const objects = new WeakMap() let nextId = 0 return (message: ChatMessageItem, conversationId?: string | null): string => { - const key = message.renderKey != null ? ['key', message.renderKey] : message.id != null ? ['id', message.id] : null + const key = message.renderKey ? ['key', message.renderKey] : message.id ? ['id', message.id] : null if (key) return JSON.stringify([scope, conversationId ?? null, ...key]) const original = toRaw(message) diff --git a/packages/test/component/bubble/AutoScroll.fixture.vue b/packages/test/component/bubble/AutoScroll.fixture.vue index 1a0eebc656..7a2ed2c583 100644 --- a/packages/test/component/bubble/AutoScroll.fixture.vue +++ b/packages/test/component/bubble/AutoScroll.fixture.vue @@ -7,7 +7,7 @@ const contentRef = ref(null) const enabled = ref(true) const blockHeight = ref(520) -const { scrollToBottom } = useAutoScroll({ +const { pause, scrollToBottom } = useAutoScroll({ scrollRef, contentRef, enabled, @@ -55,6 +55,7 @@ const growLegacyContent = () => { Grow observed content Toggle auto scroll + Pause auto scroll Smooth scroll to bottom diff --git a/packages/test/component/bubble/AutoScroll.spec.ts b/packages/test/component/bubble/AutoScroll.spec.ts index 5ec6ed5271..ec5ae50f25 100644 --- a/packages/test/component/bubble/AutoScroll.spec.ts +++ b/packages/test/component/bubble/AutoScroll.spec.ts @@ -9,6 +9,9 @@ const expectAtBottom = async (scroller: Locator) => { await expect.poll(() => distanceToBottom(scroller)).toBeLessThanOrEqual(1) } +// Chromium headless may otherwise complete smooth scrolling without intermediate frames. +test.use({ launchOptions: { args: ['--enable-smooth-scrolling'] } }) + test.describe('useAutoScroll', () => { test('follows repeated large content growth while at the bottom', async ({ mount }) => { const component = await mount(AutoScrollFixture) @@ -37,7 +40,7 @@ test.describe('useAutoScroll', () => { await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBe(before) }) - test('stays away from the bottom when the user interrupts a smooth scroll', async ({ mount, page }) => { + test('stays away from the bottom when the user interrupts a smooth scroll', async ({ mount }) => { const component = await mount(AutoScrollFixture) const scroller = component.getByTestId('observed-scroll') @@ -45,14 +48,35 @@ test.describe('useAutoScroll', () => { await component.getByRole('button', { name: 'Grow observed content' }).click() await component.getByRole('button', { name: 'Grow observed content' }).click() await expectAtBottom(scroller) + await component.getByRole('button', { name: 'Pause auto scroll' }).click() await scroller.evaluate((element) => { element.scrollTop = 0 element.dispatchEvent(new Event('scroll')) + return new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + await scroller.evaluate((element) => { + // Interrupt inside the browser so Playwright round trips cannot miss the animation. + const interrupt = () => { + const distance = element.scrollHeight - element.clientHeight - element.scrollTop + if (element.scrollTop <= 0 || distance <= 20) return + element.removeEventListener('scroll', interrupt) + requestAnimationFrame(() => { + element.setAttribute('data-interrupted-at', String(element.scrollTop)) + element.setAttribute( + 'data-interrupted-distance', + String(element.scrollHeight - element.clientHeight - element.scrollTop), + ) + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll')) + }) + } + element.addEventListener('scroll', interrupt) }) await component.getByRole('button', { name: 'Smooth scroll to bottom' }).click() - await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) - await scroller.hover() - await page.mouse.wheel(0, -1000) + await expect(scroller).toHaveAttribute('data-interrupted-at', /\d+/) + expect(Number(await scroller.getAttribute('data-interrupted-at'))).toBeGreaterThan(0) + expect(Number(await scroller.getAttribute('data-interrupted-distance'))).toBeGreaterThan(20) + await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBe(0) await scroller.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500))) await expect.poll(() => distanceToBottom(scroller)).toBeGreaterThan(20) @@ -70,6 +94,29 @@ test.describe('useAutoScroll', () => { await expectAtBottom(scroller) }) + test('does not resume following after pause when the scroll position is near the bottom', async ({ mount }) => { + const component = await mount(AutoScrollFixture) + const scroller = component.getByTestId('observed-scroll') + + await expectAtBottom(scroller) + await component.getByRole('button', { name: 'Pause auto scroll' }).click() + await scroller.evaluate((element) => { + element.scrollTop -= 10 + element.dispatchEvent(new Event('scroll')) + }) + await expect.poll(() => distanceToBottom(scroller)).toBe(10) + const scrollTopBeforeGrowth = await scroller.evaluate((element) => element.scrollTop) + + await component.getByRole('button', { name: 'Grow observed content' }).click() + + await expect(scroller).toHaveJSProperty('scrollHeight', 840) + await scroller.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))), + ) + await expect(scroller).toHaveJSProperty('scrollTop', scrollTopBeforeGrowth) + await expect.poll(() => distanceToBottom(scroller)).toBeGreaterThan(100) + }) + test('keeps the deprecated source signature when the target ref also has a scrollRef property', async ({ mount }) => { const component = await mount(AutoScrollFixture) const scroller = component.getByTestId('legacy-scroll') diff --git a/packages/test/src/sender/helpers/template-helper.ts b/packages/test/src/sender/helpers/template-helper.ts index 1958027d00..7402975006 100644 --- a/packages/test/src/sender/helpers/template-helper.ts +++ b/packages/test/src/sender/helpers/template-helper.ts @@ -145,30 +145,6 @@ export function createTemplateTestHelper(page: Page) { await block.click() }, - async expectTemplateCursor(index: number, position: 'before' | 'start' | 'end' | 'after') { - await expect - .poll(() => - page.evaluate( - ({ index, position }) => { - return window.__senderTestApi?.isCursorAtTemplate(index, position) ?? false - }, - { index, position }, - ), - ) - .toBe(true) - }, - - async focusTemplateAt(index: number, position: 'before' | 'start' | 'end' | 'after') { - const moved = await page.evaluate( - ({ index, position }) => { - return window.__senderTestApi?.moveCursorToTemplate(index, position) ?? false - }, - { index, position }, - ) - expect(moved).toBe(true) - await this.expectTemplateCursor(index, position) - }, - /** * 从模板块左侧触发 Delete,避免依赖外层键盘时序 */ diff --git a/packages/test/src/sender/index.vue b/packages/test/src/sender/index.vue index 631386beac..0901611dca 100644 --- a/packages/test/src/sender/index.vue +++ b/packages/test/src/sender/index.vue @@ -11,14 +11,10 @@ import type { TemplateItem, } from '@opentiny/tiny-robot' -type TemplateCursorPosition = 'before' | 'start' | 'end' | 'after' - declare global { interface Window { __senderTestApi?: { moveCursorBeforeTemplate: (index: number) => boolean - moveCursorToTemplate: (index: number, position: TemplateCursorPosition) => boolean - isCursorAtTemplate: (index: number, position: TemplateCursorPosition) => boolean pressDeleteBeforeTemplate: (index: number) => boolean setTemplateAppendTo: (value: string | HTMLElement | undefined) => void } @@ -231,23 +227,18 @@ const extensions = computed(() => { return exts }) -const getTemplatePosition = (index: number, position: TemplateCursorPosition) => { +const moveCursorBeforeTemplate = (index: number) => { const exposedEditor = senderRef.value?.editor const editor = exposedEditor?.value ?? exposedEditor - if (!editor) return null + if (!editor) return false let currentIndex = 0 let templatePosition: number | null = null - editor.state.doc.descendants((node: { type: { name: string }; nodeSize: number }, pos: number) => { + editor.state.doc.descendants((node: { type: { name: string } }, pos: number) => { if (node.type.name === 'templateBlock') { if (currentIndex === index) { - templatePosition = { - before: pos, - start: pos + 1, - end: pos + node.nodeSize - 1, - after: pos + node.nodeSize, - }[position] + templatePosition = pos return false } currentIndex += 1 @@ -255,36 +246,14 @@ const getTemplatePosition = (index: number, position: TemplateCursorPosition) => return true }) - return templatePosition -} + if (templatePosition === null) return false -const isCursorAtTemplate = (index: number, position: TemplateCursorPosition) => { - const exposedEditor = senderRef.value?.editor - const editor = exposedEditor?.value ?? exposedEditor - const target = getTemplatePosition(index, position) - return ( - !!editor && - target !== null && - editor.view.hasFocus() && - editor.state.selection.empty && - editor.state.selection.from === target - ) -} + editor.commands.setTextSelection(templatePosition) + editor.commands.focus(templatePosition) -const moveCursorToTemplate = (index: number, position: TemplateCursorPosition) => { - const exposedEditor = senderRef.value?.editor - const editor = exposedEditor?.value ?? exposedEditor - const target = getTemplatePosition(index, position) - if (!editor || target === null) return false - - // 同步聚焦后设置选区,避免延迟 focus 与真实键盘操作交错。 - editor.view.focus() - editor.commands.setTextSelection(target) - return isCursorAtTemplate(index, position) + return editor.state.selection.$from.nodeAfter?.type?.name === 'templateBlock' } -const moveCursorBeforeTemplate = (index: number) => moveCursorToTemplate(index, 'before') - const pressDeleteBeforeTemplate = (index: number) => { const exposedEditor = senderRef.value?.editor const editor = exposedEditor?.value ?? exposedEditor @@ -302,8 +271,6 @@ const pressDeleteBeforeTemplate = (index: number) => { onMounted(() => { window.__senderTestApi = { moveCursorBeforeTemplate, - moveCursorToTemplate, - isCursorAtTemplate, pressDeleteBeforeTemplate, setTemplateAppendTo, } diff --git a/packages/test/src/sender/specs/template/backspace.spec.ts b/packages/test/src/sender/specs/template/backspace.spec.ts index afbfe5950b..d66911bfdd 100644 --- a/packages/test/src/sender/specs/template/backspace.spec.ts +++ b/packages/test/src/sender/specs/template/backspace.spec.ts @@ -3,6 +3,8 @@ import { createSenderTestHelper } from '../../helpers' import { createTemplateTestHelper } from '../../helpers/template-helper' test.describe('Template Block - Backspace 删除逻辑', () => { + test.describe.configure({ mode: 'serial' }) + let page: Page let helper: ReturnType let templateHelper: ReturnType @@ -15,72 +17,75 @@ test.describe('Template Block - Backspace 删除逻辑', () => { templateHelper = createTemplateTestHelper(page) await helper.toggleTemplate() + await helper.wait(300) + await helper.clearContent() await templateHelper.clearTemplates() }) test('TC-BS-01: 应该能够删除模板块内的字符', async () => { await templateHelper.setSimpleTemplate() - await templateHelper.expectTemplateCursor(0, 'end') + await templateHelper.focusTemplateEnd(0) await templateHelper.pressBackspace() await templateHelper.expectTemplateText(0, '张') - await templateHelper.expectTemplateCursor(0, 'end') }) test('TC-BS-02: 删除最后一个字符时应该保留模板块', async () => { await templateHelper.setSimpleTemplate() - await templateHelper.expectTemplateCursor(0, 'end') + await templateHelper.focusTemplateEnd(0) await templateHelper.pressBackspace() await templateHelper.pressBackspace() await templateHelper.expectTemplateCount(1) - await templateHelper.expectNormalizedEditorText('我是,来自') }) test('TC-BS-03: 空模板块内按 Backspace 应该跳出模板块', async () => { await templateHelper.setEmptyTemplate() - await templateHelper.expectTemplateCursor(0, 'end') + await templateHelper.clickTemplate(0) + await templateHelper.wait(100) await templateHelper.pressBackspace() await templateHelper.expectTemplateCount(1) - await templateHelper.expectTemplateCursor(0, 'before') - await templateHelper.expectNormalizedEditorText('我是,来自') }) test('TC-BS-04: 模板块开头按 Backspace 应该跳出且不吸入文本', async () => { await templateHelper.setSimpleTemplate() - await templateHelper.expectTemplateCursor(0, 'end') - await templateHelper.focusTemplateAt(0, 'start') + await templateHelper.focusTemplateStart(0) await templateHelper.pressBackspace() await templateHelper.expectEditorToContainText('我是') await templateHelper.expectTemplateCount(1) - await templateHelper.expectTemplateCursor(0, 'before') - await templateHelper.expectNormalizedEditorText('我是张三,来自') }) test('TC-BS-05: 从模板块右侧按 Backspace 应该进入模板块', async () => { await templateHelper.setSimpleTemplate() - await templateHelper.expectTemplateCursor(0, 'end') - await templateHelper.focusTemplateAt(0, 'after') + await helper.getEditor().click() + await templateHelper.wait(100) + await helper.getEditor().press('End') + await templateHelper.pressArrowLeft(3) await templateHelper.pressBackspace() await templateHelper.expectTemplateCount(1) await templateHelper.expectTemplateText(0, '张三') - await templateHelper.expectTemplateCursor(0, 'end') - await templateHelper.expectNormalizedEditorText('我是张三,来自') }) - test('TC-BS-06: 从右侧按 Backspace 应删除空模板块', async () => { + test('TC-BS-06: 从右侧删除空模板块需要多次操作', async () => { await templateHelper.setEmptyTemplate() - await templateHelper.expectTemplateCursor(0, 'end') + await templateHelper.clickTemplate(0) + await templateHelper.wait(100) await templateHelper.pressArrowRight() - await templateHelper.expectTemplateCursor(0, 'after') - await templateHelper.pressBackspace() + await templateHelper.wait(50) + + for (let i = 0; i < 4; i++) { + if ((await templateHelper.getTemplateCount()) === 0) { + break + } + await templateHelper.pressBackspace() + await templateHelper.wait(100) + } await templateHelper.expectTemplateCount(0) - await templateHelper.expectNormalizedEditorText('我是,来自') }) test('TC-BS-07: 选区删除应该包含模板块', async () => { await templateHelper.setSimpleTemplate() - await templateHelper.expectTemplateCursor(0, 'end') + await helper.getEditor().click() await templateHelper.selectText() await templateHelper.pressBackspace() From 1e1af5543931b981ce3e7ead1ad698ba65115a1d Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Wed, 21 Oct 2026 04:42:37 -0700 Subject: [PATCH 06/15] feat(chat): enhance chat draft handling and streaming state management --- docs/src/suites/chat-runtime.md | 7 +- .../chat/src/composables/useChatDraft.test.ts | 141 +++++++++++++++ packages/chat/src/composables/useChatDraft.ts | 40 +++-- .../composables/useChatRuntimeAdapter.test.ts | 30 +++- .../src/composables/useChatRuntimeAdapter.ts | 82 ++------- packages/chat/src/index.ts | 5 +- .../useChatRuntimeFromConversation.test.ts | 164 ++++++++++++++++++ .../runtime/useChatRuntimeFromConversation.ts | 146 ++++++++++------ .../chat/src/ui/messages/ChatMessages.vue | 11 +- .../src/ui/messages/messageIdentity.test.ts | 24 ++- .../chat/src/ui/messages/messageIdentity.ts | 15 +- .../ui/messages/useChatStreamState.test.ts | 127 ++++++++++++++ .../src/ui/messages/useChatStreamState.ts | 97 +++++++++++ .../chat/ChatRenderContext.fixture.vue | 50 +++++- .../component/chat/ChatRenderContext.spec.ts | 22 +++ 15 files changed, 814 insertions(+), 147 deletions(-) create mode 100644 packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts create mode 100644 packages/chat/src/ui/messages/useChatStreamState.test.ts create mode 100644 packages/chat/src/ui/messages/useChatStreamState.ts diff --git a/docs/src/suites/chat-runtime.md b/docs/src/suites/chat-runtime.md index 6d15526f5f..3eb93902d9 100644 --- a/docs/src/suites/chat-runtime.md +++ b/docs/src/suites/chat-runtime.md @@ -480,7 +480,7 @@ const adapter = useChatRuntimeAdapter({ | `conversation` | 提供已有 Kit 会话实例 | `UseConversationReturn` | 是 | 无默认值 | | `titleGenerator` | 生成默认发送自动创建的会话标题 | `(text: string) => string` | 否 | 与 `useChatRuntime` 相同 | | `beforeSend` | 发送前检查 | `ChatBeforeSend` | 否 | 不执行自定义检查 | -| `send` | 自行处理会话创建、消息保存和请求,允许空文本 | `(payload: ChatSendPayload & { conversationId: string \| null; runConfig?: ChatRunConfig }) => void \| Promise` | 否 | 使用已有 Kit 会话执行默认发送 | +| `send` | 自行处理会话创建、消息保存和请求,允许空文本 | `(payload: KitRuntimeSendPayload) => boolean \| void \| Promise`;仅严格返回 `false` 表示拒绝 | 否 | 使用已有 Kit 会话执行默认发送 | | `composer` | 提供输入区状态、模型和 MCP 操作 | `ChatComposerRuntime` | 否 | `{}`;默认发送会根据模型和工具状态限制提交 | ### 错误记录配置 @@ -690,7 +690,7 @@ const history = useChatHistoryData({ | `ChatRequestState` | `'idle' \| 'processing' \| 'completed' \| 'paused' \| 'aborted' \| 'error'` | | `ChatProcessingState` | `'requesting' \| 'completing' \| string` | -消息对象和服务端 ID 都可能变化时,请提供稳定的 `renderKey`。`streamState` 用于标记消息是否仍在处理中;自定义渲染器的接入见 [Renderer 上下文](./chat#自定义-renderer-上下文)。 +消息对象和服务端 ID 都可能变化时,请提供稳定的 `renderKey`。显式 `streamState` 优先;自动流状态仅在请求起点可识别且确认新增助手消息属于当前请求时标记为 `streaming`。请求进行中无法确定归属时为 `unknown`,`loading` 不决定流状态。暂停期间保留本轮身份,结束或切换会话后清理;中途挂载且无法重建归属时使用 `unknown`。自定义渲染器的接入见 [Renderer 上下文](./chat#自定义-renderer-上下文)。 #### 模型与 MCP @@ -739,7 +739,8 @@ const history = useChatHistoryData({ | 类型 | 定义 | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `UseChatRuntimeOptions` | `conversation?: Omit & { useMessageOptions?: Partial }`;`titleGenerator?: (text: string) => string`;`beforeSend?: ChatBeforeSend`;`composer?: Pick`;`modelProviders?: readonly ChatProviderConfig[]`;`mcp?: UseChatRuntimeMcpAdapter`;`mcpServers?: ChatMcpServers` | -| `UseChatRuntimeFromConversationOptions` | `conversation: UseConversationReturn`;`titleGenerator?: (text: string) => string`;`beforeSend?: ChatBeforeSend`;`send?: (payload: ChatSendPayload & { conversationId: string \| null; runConfig?: ChatRunConfig }) => void \| Promise`;`composer?: ChatComposerRuntime` | +| `UseChatRuntimeFromConversationOptions` | `conversation: UseConversationReturn`;`titleGenerator?: (text: string) => string`;`beforeSend?: ChatBeforeSend`;`send?: (payload: KitRuntimeSendPayload) => boolean \| void \| Promise`(仅严格返回 `false` 表示拒绝);`composer?: ChatComposerRuntime` | +| `KitRuntimeSendPayload` | `ChatSendPayload`,另有 `conversationId: string \| null` 和 `runConfig?: ChatRunConfig`。默认发送等待绑定会话的 `engine.send()` 完成后返回 `true`;直接调用 `runtime.actions.send` 时错误会 reject,Adapter 调用会发出 `runtime-action-error` 并按失败处理草稿。 | | `UseChatRuntimeAdapterOptions` | `runtime: MaybeRefOrGetter`;`title?: MaybeRefOrGetter`;`historyData?: MaybeRefOrGetter`;`onActionError(payload)` | | `UseChatHistoryItemsOptions` | `conversations: MaybeRefOrGetter`;`defaultTitle: MaybeRefOrGetter` | | `UseChatHistoryDataOptions` | 包含 `UseChatHistoryItemsOptions` 的 `conversations` 和 `defaultTitle`,另有 `history?: MaybeRefOrGetter`,用于提供历史会话列表或分组。 | diff --git a/packages/chat/src/composables/useChatDraft.test.ts b/packages/chat/src/composables/useChatDraft.test.ts index a6eb19e5db..f51cf4c0f8 100644 --- a/packages/chat/src/composables/useChatDraft.test.ts +++ b/packages/chat/src/composables/useChatDraft.test.ts @@ -25,4 +25,145 @@ describe('external input updates during a pending send', () => { await draft.send({ text: 'original' }) expect(draft.inputValue.value).toBe('original') }) + + it('restores rejected and failed drafts but leaves accepted sends cleared', async () => { + const rejected = useChatDraft({ send: async () => false }) + rejected.setInputValue('retry') + await rejected.send({ text: 'retry' }) + expect(rejected.inputValue.value).toBe('retry') + + const failed = useChatDraft({ + send: async () => { + throw new Error('failed') + }, + }) + failed.setInputValue('retry failed') + await expect(failed.send({ text: 'retry failed' })).rejects.toThrow('failed') + expect(failed.inputValue.value).toBe('retry failed') + + const accepted = useChatDraft({ send: async () => true }) + accepted.setInputValue('sent') + await accepted.send({ text: 'sent' }) + expect(accepted.inputValue.value).toBe('') + }) + + it('does not let a later rejection revoke an earlier send restoration', async () => { + let finishFirst!: (accepted: boolean) => void + let finishSecond!: (accepted: boolean) => void + let call = 0 + const draft = useChatDraft({ + send: () => + new Promise((resolve) => { + if (call++ === 0) finishFirst = resolve + else finishSecond = resolve + }), + }) + draft.setInputValue('original') + const first = draft.send({ text: 'original' }) + await Promise.resolve() + const second = draft.send({ text: 'duplicate' }) + await Promise.resolve() + finishSecond(false) + await second + finishFirst(false) + await first + expect(draft.inputValue.value).toBe('original') + }) + + it('keeps the draft cleared when a concurrent send is accepted', async () => { + let finishFirst!: (accepted: boolean) => void + let finishSecond!: (accepted: boolean) => void + let call = 0 + const draft = useChatDraft({ + send: () => + new Promise((resolve) => { + if (call++ === 0) finishFirst = resolve + else finishSecond = resolve + }), + }) + draft.setInputValue('original') + const first = draft.send({ text: 'original' }) + await Promise.resolve() + const second = draft.send({ text: 'newer' }) + await Promise.resolve() + finishFirst(false) + await first + expect(draft.inputValue.value).toBe('') + finishSecond(true) + await second + expect(draft.inputValue.value).toBe('') + }) + + it('keeps the draft cleared when a later send accepts before an earlier rejection', async () => { + let finishFirst!: (accepted: boolean) => void + let finishSecond!: (accepted: boolean) => void + let call = 0 + const draft = useChatDraft({ + send: () => + new Promise((resolve) => { + if (call++ === 0) finishFirst = resolve + else finishSecond = resolve + }), + }) + draft.setInputValue('original') + const first = draft.send({ text: 'original' }) + await Promise.resolve() + const second = draft.send({ text: 'newer' }) + await Promise.resolve() + finishSecond(true) + await second + finishFirst(false) + await first + expect(draft.inputValue.value).toBe('') + }) + + it('restores after concurrent rejection and failure', async () => { + let failFirst!: (error: Error) => void + let finishSecond!: (accepted: boolean) => void + let call = 0 + const failure = new Error('failed') + const draft = useChatDraft({ + send: () => + new Promise((resolve, reject) => { + if (call++ === 0) failFirst = reject + else finishSecond = resolve + }), + }) + draft.setInputValue('original') + const first = draft.send({ text: 'original' }) + await Promise.resolve() + const second = draft.send({ text: 'duplicate' }) + await Promise.resolve() + failFirst(failure) + await expect(first).rejects.toBe(failure) + expect(draft.inputValue.value).toBe('') + finishSecond(false) + await second + expect(draft.inputValue.value).toBe('original') + }) + + it('does not restore after the user clears the input or a host writes a new draft', async () => { + let finish!: (accepted: boolean) => void + const draft = useChatDraft({ + send: () => + new Promise((resolve) => { + finish = resolve + }), + }) + draft.setInputValue('original') + const pending = draft.send({ text: 'original' }) + await Promise.resolve() + draft.setInputValue('') + finish(false) + await pending + expect(draft.inputValue.value).toBe('') + + draft.setInputValue('original again') + const next = draft.send({ text: 'original again' }) + await Promise.resolve() + draft.setInputValue('host draft') + finish(false) + await next + expect(draft.inputValue.value).toBe('host draft') + }) }) diff --git a/packages/chat/src/composables/useChatDraft.ts b/packages/chat/src/composables/useChatDraft.ts index 8b5e5e8c62..a8e8489875 100644 --- a/packages/chat/src/composables/useChatDraft.ts +++ b/packages/chat/src/composables/useChatDraft.ts @@ -11,14 +11,15 @@ export interface ChatDraft { export interface UseChatDraftOptions { send: (payload: ChatSendPayload) => Promise + prepareSend?: () => (payload: ChatSendPayload) => Promise allowEmptyText?: boolean abort?: () => Promise | void } export function useChatDraft(options: UseChatDraftOptions): ChatDraft { const inputValue = shallowRef('') - let latestSendId = 0 let inputRevision = 0 + const pendingSends = new Map() function setInputValue(value: string) { inputRevision++ @@ -29,6 +30,20 @@ export function useChatDraft(options: UseChatDraftOptions): ChatDraft { inputRevision++ } + function completeSend(revision: number, accepted: boolean) { + const pending = pendingSends.get(revision) + if (!pending) return + + pending.count-- + pending.accepted ||= accepted + if (pending.count) return + + pendingSends.delete(revision) + if (!pending.accepted && inputRevision === revision && inputValue.value === '') { + inputValue.value = pending.previousInputValue + } + } + async function send(payload: ChatSendPayload): Promise { const text = payload.text.trim() @@ -36,28 +51,27 @@ export function useChatDraft(options: UseChatDraftOptions): ChatDraft { return false } - const sendId = ++latestSendId const revisionAtClear = inputRevision const previousInputValue = inputValue.value + const send = options.prepareSend?.() ?? options.send + const pending = pendingSends.get(revisionAtClear) + if (pending) { + pending.count++ + } else { + pendingSends.set(revisionAtClear, { count: 1, accepted: false, previousInputValue }) + } + let accepted = false try { inputValue.value = '' await Promise.resolve() - const accepted = await options.send({ + accepted = await send({ ...payload, text, }) - - if (!accepted && sendId === latestSendId && inputRevision === revisionAtClear && inputValue.value === '') { - inputValue.value = previousInputValue - } - return accepted - } catch (error) { - if (sendId === latestSendId && inputRevision === revisionAtClear && inputValue.value === '') { - inputValue.value = previousInputValue - } - throw error + } finally { + completeSend(revisionAtClear, accepted) } } diff --git a/packages/chat/src/composables/useChatRuntimeAdapter.test.ts b/packages/chat/src/composables/useChatRuntimeAdapter.test.ts index d795f33f0b..1bd982dfd0 100644 --- a/packages/chat/src/composables/useChatRuntimeAdapter.test.ts +++ b/packages/chat/src/composables/useChatRuntimeAdapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { ref, shallowRef } from 'vue' import type { ChatConversation, @@ -91,6 +91,34 @@ describe('useChatRuntimeAdapter error channels', () => { }) describe('useChatRuntimeAdapter runtime-scoped drafts', () => { + it('rejects a submission whose conversation changes before the draft microtask', async () => { + const send = vi.fn(async () => true) + const current = createRuntime('conversation-1', send) + const adapter = useChatRuntimeAdapter({ runtime: current.runtime, onActionError: () => undefined }) + adapter.setInputValue('bound draft') + const pending = adapter.send({ text: 'bound draft' }) + current.activeConversation.value = { + id: 'conversation-2', + title: 'conversation-2', + messages: [], + requestState: 'idle', + } + await expect(pending).resolves.toBe(false) + expect(send).not.toHaveBeenCalled() + expect(adapter.inputValue.value).toBe('') + }) + + it('rejects a submission whose runtime changes before the draft microtask', async () => { + const firstSend = vi.fn(async () => true) + const first = createRuntime('conversation-1', firstSend) + const second = createRuntime('conversation-1') + const runtime = shallowRef(first.runtime) + const adapter = useChatRuntimeAdapter({ runtime, onActionError: () => undefined }) + const pending = adapter.send({ text: 'bound draft' }) + runtime.value = second.runtime + await expect(pending).resolves.toBe(false) + expect(firstSend).not.toHaveBeenCalled() + }) it('clears the draft when the runtime changes with the same active conversation id', () => { const first = createRuntime('conversation-1') const second = createRuntime('conversation-1') diff --git a/packages/chat/src/composables/useChatRuntimeAdapter.ts b/packages/chat/src/composables/useChatRuntimeAdapter.ts index ef50fe4180..e23ce614be 100644 --- a/packages/chat/src/composables/useChatRuntimeAdapter.ts +++ b/packages/chat/src/composables/useChatRuntimeAdapter.ts @@ -45,80 +45,32 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { { flush: 'sync' }, ) - const sendsInFlight = new WeakMap() - - function getSendsInFlight(targetRuntime: ChatRuntime) { - return sendsInFlight.get(targetRuntime) ?? 0 - } - - function incrementSendsInFlight(targetRuntime: ChatRuntime) { - sendsInFlight.set(targetRuntime, getSendsInFlight(targetRuntime) + 1) - } - - function decrementSendsInFlight(targetRuntime: ChatRuntime) { - const nextCount = getSendsInFlight(targetRuntime) - 1 - if (nextCount === 0) { - sendsInFlight.delete(targetRuntime) - } else { - sendsInFlight.set(targetRuntime, nextCount) - } - } - - async function send(payload: ChatSendPayload) { + function prepareSend() { const actionRuntime = runtime.value - const getActionConversationId = () => actionRuntime.activeConversation.value?.id ?? null - const getActionNavigationRevision = () => actionRuntime.conversationNavigationRevision?.value - const startConversationId = getActionConversationId() - const startNavigationRevision = getActionNavigationRevision() - incrementSendsInFlight(actionRuntime) - - let actionResult: boolean | undefined - let synchronousConversationId = startConversationId - try { - let action: Promise | undefined + const conversationId = actionRuntime.activeConversation.value?.id ?? null + const navigationRevision = actionRuntime.conversationNavigationRevision?.value + return async (payload: ChatSendPayload) => { + const currentConversationId = actionRuntime.activeConversation.value?.id ?? null + if ( + runtime.value !== actionRuntime || + currentConversationId !== conversationId || + actionRuntime.conversationNavigationRevision?.value !== navigationRevision + ) { + return false + } try { - action = actionRuntime.actions.send(payload) + return await actionRuntime.actions.send(payload) } catch (error) { options.onActionError({ action: 'send', payload, error }) + return false } - - synchronousConversationId = getActionConversationId() - - if (action) { - try { - actionResult = await action - } catch (error) { - options.onActionError({ action: 'send', payload, error }) - } - } - } finally { - decrementSendsInFlight(actionRuntime) } - - if (runtime.value !== actionRuntime) { - return actionResult ?? false - } - - const endConversationId = getActionConversationId() - const navigationChanged = getActionNavigationRevision() !== startNavigationRevision - const selfCreatedConversation = - startConversationId === null && - synchronousConversationId !== null && - endConversationId === synchronousConversationId - - if ( - navigationChanged || - (startNavigationRevision === undefined && endConversationId !== startConversationId && !selfCreatedConversation) - ) { - invalidateDraftForNavigation() - } - - return actionResult ?? false } const input = useChatDraft({ allowEmptyText: true, - send, + send: (payload) => prepareSend()(payload), + prepareSend, }) function invalidateDraftForNavigation() { @@ -134,7 +86,7 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { if ( conversationNavigationRevision.value === undefined && nextId !== previousId && - getSendsInFlight(runtime.value) === 0 + runtime.value.conversationNavigationRevision === undefined ) { invalidateDraftForNavigation() } diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index d80217a9fe..abe9541569 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -18,7 +18,10 @@ export type { LayoutFloatingState, } from '@opentiny/tiny-robot' -export type { UseChatRuntimeFromConversationOptions } from './runtime/useChatRuntimeFromConversation' +export type { + KitRuntimeSendPayload, + UseChatRuntimeFromConversationOptions, +} from './runtime/useChatRuntimeFromConversation' export type { UseChatRuntimeMcpAdapter, UseChatRuntimeOptions } from './runtime/useChatRuntime' export type { ChatErrorPluginContext, ErrorStatePluginOptions } from './runtime/plugins/errorStatePlugin' export type { UseChatRuntimeAdapterOptions } from './composables/useChatRuntimeAdapter' diff --git a/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts b/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts new file mode 100644 index 0000000000..c4c8c7b55f --- /dev/null +++ b/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts @@ -0,0 +1,164 @@ +import { computed, ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import type { UseConversationReturn } from '@opentiny/tiny-robot-kit' +import type { ChatRequestState } from '../types' +import { useChatRuntimeFromConversation } from './useChatRuntimeFromConversation' + +function createConversation(id: string, canStart = true) { + return { + id, + title: id, + createdAt: Date.now(), + updatedAt: Date.now(), + engine: { + messages: ref([]), + requestState: ref('idle'), + processingState: ref(), + canStartTurn: ref(canStart), + send: vi.fn(async () => undefined), + }, + } +} + +function createHarness(initialId = 'a', canStart = true) { + const items = new Map([[initialId, createConversation(initialId, canStart)]]) + const activeConversationId = ref(initialId) + const conversations = ref([...items.values()].map(({ engine: _engine, ...item }) => item)) + const activeConversation = computed(() => { + const item = activeConversationId.value ? items.get(activeConversationId.value) : undefined + return item ?? null + }) + const api = { + conversations, + activeConversationId, + activeConversation, + createConversation: vi.fn((payload?: { id?: string; title?: string }) => { + const id = payload?.id ?? `created-${items.size}` + const item = createConversation(id) + item.title = payload?.title ?? id + items.set(id, item) + conversations.value = [...items.values()].map(({ engine: _engine, ...conversation }) => conversation) + activeConversationId.value = id + return item + }), + switchConversation: vi.fn(async (id: string) => { + activeConversationId.value = id + return activeConversation.value + }), + deleteConversation: vi.fn(async () => undefined), + clear: vi.fn(), + updateConversationTitle: vi.fn(), + saveMessages: vi.fn(async () => undefined), + sendMessage: vi.fn(async () => undefined), + abortActiveRequest: vi.fn(async () => undefined), + } as unknown as UseConversationReturn + return { api, items, activeConversationId } +} + +describe('useChatRuntimeFromConversation', () => { + it('uses false as the only custom-send rejection and propagates errors', async () => { + for (const result of [true, undefined, false] as const) { + const harness = createHarness() + const send = vi.fn(async () => result) + const runtime = useChatRuntimeFromConversation({ conversation: harness.api, send }) + await expect(runtime.actions.send({ text: 'hello' })).resolves.toBe(result !== false) + expect(send).toHaveBeenCalledOnce() + } + const failure = new Error('failed') + const runtime = useChatRuntimeFromConversation({ + conversation: createHarness().api, + send: async () => { + throw failure + }, + }) + await expect(runtime.actions.send({ text: 'hello' })).rejects.toBe(failure) + }) + + it('applies busy and paused engine gates to custom and default sends', async () => { + for (const custom of [true, false]) { + const harness = createHarness('a', false) + const send = vi.fn(async () => true) + const runtime = useChatRuntimeFromConversation({ conversation: harness.api, ...(custom ? { send } : {}) }) + expect(runtime.composer.submitDisabled?.value).toBe(true) + await expect(runtime.actions.send({ text: 'blocked' })).resolves.toBe(false) + expect(send).not.toHaveBeenCalled() + } + }) + + it('honors beforeSend branches and releases an occupied target on every exit', async () => { + const harness = createHarness() + const handled = useChatRuntimeFromConversation({ + conversation: harness.api, + beforeSend: async () => 'handled' as const, + send: vi.fn(async () => true), + }) + await expect(handled.actions.send({ text: 'handled' })).resolves.toBe(true) + + const rejected = useChatRuntimeFromConversation({ + conversation: createHarness().api, + beforeSend: async () => 'reject' as const, + send: vi.fn(async () => true), + }) + await expect(rejected.actions.send({ text: 'rejected' })).resolves.toBe(false) + + let finish!: (value: undefined) => void + const pending = new Promise((resolve) => { + finish = resolve + }) + const send = vi.fn(() => pending) + const runtime = useChatRuntimeFromConversation({ conversation: createHarness().api, send }) + const first = runtime.actions.send({ text: 'first' }) + await Promise.resolve() + await expect(runtime.actions.send({ text: 'second' })).resolves.toBe(false) + finish(undefined) + await expect(first).resolves.toBe(true) + await expect(runtime.actions.send({ text: 'third' })).resolves.toBe(true) + }) + + it('rejects a default send when navigation occurs during beforeSend', async () => { + const harness = createHarness() + let continueSend!: () => void + const beforeSend = new Promise((resolve) => { + continueSend = resolve + }) + const runtime = useChatRuntimeFromConversation({ + conversation: harness.api, + beforeSend: async () => { + await beforeSend + return 'continue' as const + }, + }) + const sending = runtime.actions.send({ text: 'hello' }) + await Promise.resolve() + harness.activeConversationId.value = null + continueSend() + await expect(sending).resolves.toBe(false) + expect(harness.items.get('a')?.engine.send).not.toHaveBeenCalled() + }) + + it('binds default sends to the created target and waits for its engine', async () => { + const harness = createHarness() + harness.activeConversationId.value = null + let finish!: (value: undefined) => void + const pending = new Promise((resolve) => { + finish = resolve + }) + const created = vi.spyOn(harness.api, 'createConversation') + created.mockImplementation((payload) => { + const item = createConversation('created') + item.title = payload?.title ?? 'created' + item.engine.send.mockImplementation(() => pending) + harness.items.set(item.id, item) + harness.activeConversationId.value = item.id + return item as unknown as ReturnType + }) + const runtime = useChatRuntimeFromConversation({ conversation: harness.api }) + const send = runtime.actions.send({ text: 'first' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(created).toHaveBeenCalledOnce() + expect(runtime.composer.submitDisabled?.value).toBe(true) + finish(undefined) + await expect(send).resolves.toBe(true) + expect(harness.items.get('created')?.engine.send).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/chat/src/runtime/useChatRuntimeFromConversation.ts b/packages/chat/src/runtime/useChatRuntimeFromConversation.ts index ce91b8f327..9d03413d1b 100644 --- a/packages/chat/src/runtime/useChatRuntimeFromConversation.ts +++ b/packages/chat/src/runtime/useChatRuntimeFromConversation.ts @@ -1,4 +1,4 @@ -import { computed, shallowRef } from 'vue' +import { computed, shallowRef, watch } from 'vue' import type { UseConversationReturn } from '@opentiny/tiny-robot-kit' import type { ChatConversation, @@ -22,7 +22,7 @@ type KitConversationInfo = UseConversationReturn['conversations']['value'][numbe type KitConversation = NonNullable type UseKitChatComposerOptions = ChatRuntime['composer'] -interface KitRuntimeSendPayload extends ChatSendPayload { +export interface KitRuntimeSendPayload extends ChatSendPayload { readonly conversationId: string | null runConfig?: ChatRunConfig } @@ -31,7 +31,7 @@ export interface UseChatRuntimeFromConversationOptions { conversation: UseConversationReturn titleGenerator?: TitleGenerator beforeSend?: ChatBeforeSend - send?: (payload: KitRuntimeSendPayload) => Promise | void + send?: (payload: KitRuntimeSendPayload) => boolean | void | Promise composer?: UseKitChatComposerOptions } const toChatConversationInfo = (item: KitConversationInfo): ChatConversationInfo => { @@ -47,6 +47,19 @@ const toChatConversationInfo = (item: KitConversationInfo): ChatConversationInfo export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConversationOptions): ChatRuntime { const { conversation, titleGenerator, beforeSend, send, composer: composerOptions } = options const conversationNavigationRevision = shallowRef(0) + const creatingConversationForSend = shallowRef(false) + const occupiedTargets = shallowRef(new Set()) + let previousConversationId = conversation.activeConversationId.value + watch( + conversation.activeConversationId, + (id) => { + if (id !== previousConversationId) { + previousConversationId = id + if (!creatingConversationForSend.value) conversationNavigationRevision.value++ + } + }, + { flush: 'sync' }, + ) const resolveTitle = titleGenerator ?? createDefaultChatTitle const activeKitConversation = computed(() => conversation.activeConversation.value) @@ -68,17 +81,20 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver }) const sourceComposer = composerOptions ?? {} + const getTargetKey = (id: string | null) => (id === null ? 'empty' : `conversation:${id}`) const submitDisabled = computed(() => { const model = sourceComposer.model const selectedModel = model?.options.value.find((item) => item.id === model.selectedId.value) - const noUsableModel = !send && Boolean(model && (!selectedModel || selectedModel.disabled)) + const noUsableModel = Boolean(model && (!selectedModel || selectedModel.disabled)) const active = activeKitConversation.value + const targetKey = getTargetKey(active?.id ?? null) return ( Boolean(sourceComposer.submitDisabled?.value) || !areEnabledMcpToolsReady(sourceComposer.mcp) || noUsableModel || - Boolean(!send && active && !active.engine.canStartTurn.value) + occupiedTargets.value.has(targetKey) || + Boolean(active && !active.engine.canStartTurn.value) ) }) const composer: ChatRuntime['composer'] = { @@ -141,50 +157,91 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver async function handleSend(payload: ChatSendPayload): Promise { const text = payload.text.trim() - - if ((!text && !send) || composer.disabled?.value || composer.submitDisabled?.value) { - return false - } - let targetConversation = conversation.activeConversation.value const targetConversationId = targetConversation?.id ?? null - - const effectivePayload = { - ...payload, - text, - conversationId: targetConversationId, - runConfig: cloneRunConfig(resolveComposerRunConfig(composer)), + const targetEngine = targetConversation?.engine + const navigationRevision = conversationNavigationRevision.value + const targetKey = getTargetKey(targetConversationId) + if (occupiedTargets.value.has(targetKey)) return false + occupiedTargets.value = new Set(occupiedTargets.value).add(targetKey) + const eligible = () => { + const model = composer.model + const selectedModel = model?.options.value.find((item) => item.id === model.selectedId.value) + return ( + !composer.disabled?.value && + !sourceComposer.submitDisabled?.value && + areEnabledMcpToolsReady(composer.mcp) && + (!model || Boolean(selectedModel && !selectedModel.disabled)) && + (!targetEngine || targetEngine.canStartTurn.value) + ) } + try { + if ((!text && !send) || !eligible()) { + return false + } - const beforeSendResult = await beforeSend?.( - createBeforeSendContext({ ...payload, text }, effectivePayload.runConfig), - ) + if ( + (conversation.activeConversation.value?.id ?? null) !== targetConversationId || + conversationNavigationRevision.value !== navigationRevision || + conversation.activeConversation.value?.engine !== targetEngine + ) { + return false + } - if (beforeSendResult === 'reject') { - return false - } + const effectivePayload = { + ...payload, + text, + conversationId: targetConversationId, + runConfig: cloneRunConfig(resolveComposerRunConfig(composer)), + } - if (beforeSendResult === 'handled') { - return true - } + const beforeSendResult = beforeSend + ? await beforeSend(createBeforeSendContext({ ...payload, text }, effectivePayload.runConfig)) + : undefined - if ((conversation.activeConversation.value?.id ?? null) !== targetConversationId) { - return false - } + if (beforeSendResult === 'reject') { + return false + } - if (!send && targetConversation && !targetConversation.engine.canStartTurn.value) { - return false - } + if (beforeSendResult === 'handled') { + return true + } - if (!send && !targetConversation) { - targetConversation = conversation.createConversation({ title: resolveTitle(text) }) - } + if ( + (conversation.activeConversation.value?.id ?? null) !== targetConversationId || + conversationNavigationRevision.value !== navigationRevision || + conversation.activeConversation.value?.engine !== targetEngine || + !eligible() + ) { + return false + } - const task = send - ? Promise.resolve(send(effectivePayload)) - : Promise.resolve(sendDefaultMessage(effectivePayload, targetConversation!)) - await task - return true + if (send) { + const result = await send(effectivePayload) + return result !== false + } + if (targetConversationId === null) { + creatingConversationForSend.value = true + let created: KitConversation + try { + created = conversation.createConversation({ title: resolveTitle(text) }) + } finally { + creatingConversationForSend.value = false + } + targetConversation = created ?? conversation.activeConversation.value + if (targetConversation) { + occupiedTargets.value = new Set(occupiedTargets.value).add(getTargetKey(targetConversation.id)) + } + } + if (!targetConversation) return false + await sendDefaultMessage(effectivePayload, targetConversation) + return true + } finally { + const next = new Set(occupiedTargets.value) + next.delete(targetKey) + if (targetConversationId === null && targetConversation) next.delete(getTargetKey(targetConversation.id)) + occupiedTargets.value = next + } } return { @@ -196,7 +253,6 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver clearActiveConversation: () => { if (conversation.activeConversationId.value === null) return conversation.activeConversationId.value = null - conversationNavigationRevision.value++ }, send: handleSend, abort: async () => { @@ -204,25 +260,15 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver }, createConversation: (payload) => { conversation.createConversation(payload) - conversationNavigationRevision.value++ }, switchConversation: async (id) => { - const previousId = conversation.activeConversationId.value await conversation.switchConversation(id) - if (conversation.activeConversationId.value !== previousId) { - conversationNavigationRevision.value++ - } }, renameConversation: (id, title) => { conversation.updateConversationTitle(id, title) }, deleteConversation: async (id) => { - const previousId = conversation.activeConversationId.value await conversation.deleteConversation(id) - - if (conversation.activeConversationId.value !== previousId) { - conversationNavigationRevision.value++ - } }, }, } diff --git a/packages/chat/src/ui/messages/ChatMessages.vue b/packages/chat/src/ui/messages/ChatMessages.vue index 488377fdd4..86ccc5eea1 100644 --- a/packages/chat/src/ui/messages/ChatMessages.vue +++ b/packages/chat/src/ui/messages/ChatMessages.vue @@ -16,6 +16,7 @@ import { import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs' import { CHAT_RENDER_ENVIRONMENT } from '../../composables/useChatRenderContext' import { createMessageIdentity, getMessageAnchorText, resolveStreamState } from './messageIdentity' +import { useChatStreamState } from './useChatStreamState' import type { ChatRequestView } from '../../types' import type { ChatBubbleEventPayload, ChatBubbleStateChangePayload, ChatLabels, ChatMessageItem } from '../../types' import type { @@ -65,11 +66,17 @@ const defaultRoleConfigs: Record = { const lastMessage = computed(() => props.messages.at(-1)) const contentRef = ref(null) const messageIdentity = createMessageIdentity(`chat-${getCurrentInstance()!.uid}`) +const getMessageStreamState = useChatStreamState( + () => props.messages, + () => props.request, + () => props.conversationId, + messageIdentity, +) const bubbleMessages = computed(() => - props.messages.map((message, index) => ({ + props.messages.map((message) => ({ ...message, renderKey: messageIdentity(message, props.conversationId), - streamState: resolveStreamState(message, props.request, index === props.messages.length - 1), + streamState: resolveStreamState(message, props.request, getMessageStreamState(message)), })), ) provide(CHAT_RENDER_ENVIRONMENT, { diff --git a/packages/chat/src/ui/messages/messageIdentity.test.ts b/packages/chat/src/ui/messages/messageIdentity.test.ts index 5085db2a2b..afc2bf81ad 100644 --- a/packages/chat/src/ui/messages/messageIdentity.test.ts +++ b/packages/chat/src/ui/messages/messageIdentity.test.ts @@ -45,16 +45,28 @@ describe('Chat message identity', () => { describe('Chat stream state', () => { it('keeps history finished when streaming fields are absent', () => { expect(resolveStreamState({ role: 'assistant' })).toBe('finished') - expect(resolveStreamState({ role: 'assistant' }, { state: 'processing' }, false)).toBe('finished') - expect(resolveStreamState({ role: 'assistant' }, { state: 'completed' }, true)).toBe('finished') + expect(resolveStreamState({ role: 'assistant' }, { state: 'processing' }, 'history')).toBe('finished') + expect(resolveStreamState({ role: 'assistant' }, { state: 'completed' }, 'current')).toBe('finished') }) - it('separates explicit streams, completed messages and insufficient active state', () => { - expect(resolveStreamState({ loading: true })).toBe('streaming') - expect(resolveStreamState({ loading: false }, { state: 'processing' }, true)).toBe('finished') - expect(resolveStreamState({ role: 'assistant' }, { state: 'processing' }, true)).toBe('unknown') + it('keeps explicit state first and ignores loading for automatic state', () => { + expect(resolveStreamState({ streamState: 'streaming', loading: false })).toBe('streaming') expect(resolveStreamState({ streamState: 'unknown', loading: false })).toBe('unknown') expect(resolveStreamState({ streamState: 'finished', loading: true })).toBe('finished') + expect(resolveStreamState({ loading: true }, { state: 'processing' }, 'history')).toBe('finished') + expect(resolveStreamState({ loading: false }, { state: 'processing' }, 'current')).toBe('streaming') + expect(resolveStreamState({}, { state: 'processing' }, 'unknown')).toBe('unknown') + }) + + it('finishes user, system, tool-stage and completed request messages', () => { + expect(resolveStreamState({ role: 'user' }, { state: 'processing' }, 'current')).toBe('finished') + expect(resolveStreamState({ role: 'system' }, { state: 'processing' }, 'current')).toBe('finished') + expect(resolveStreamState({}, { state: 'processing', processingState: 'calling-tools' }, 'current')).toBe( + 'finished', + ) + for (const state of ['completed', 'aborted', 'paused', 'error'] as const) { + expect(resolveStreamState({}, { state }, 'current')).toBe('finished') + } }) }) diff --git a/packages/chat/src/ui/messages/messageIdentity.ts b/packages/chat/src/ui/messages/messageIdentity.ts index 81cf9f6626..250d7393a5 100644 --- a/packages/chat/src/ui/messages/messageIdentity.ts +++ b/packages/chat/src/ui/messages/messageIdentity.ts @@ -1,6 +1,8 @@ import { toRaw } from 'vue' import type { ChatMessageItem, ChatRequestView } from '../../types' +export type ChatStreamMessageState = 'history' | 'current' | 'unknown' + export function createMessageIdentity(scope: string) { const objects = new WeakMap() let nextId = 0 @@ -17,12 +19,17 @@ export function createMessageIdentity(scope: string) { export function resolveStreamState( message: ChatMessageItem, request?: ChatRequestView, - isLatest = false, + messageState: ChatStreamMessageState = 'history', ): NonNullable { if (message.streamState) return message.streamState - if (message.loading === true) return 'streaming' - if (message.loading === false || message.role === 'user' || message.role === 'system') return 'finished' - if (request?.state === 'processing' && isLatest) return 'unknown' + if (request && ['completed', 'aborted', 'paused', 'error'].includes(request.state)) return 'finished' + if (message.role === 'user' || message.role === 'system') return 'finished' + if (request?.state === 'processing') { + if (messageState === 'current' && !['calling-tools', 'pausing'].includes(request.processingState ?? '')) { + return 'streaming' + } + return messageState === 'unknown' ? 'unknown' : 'finished' + } return 'finished' } diff --git a/packages/chat/src/ui/messages/useChatStreamState.test.ts b/packages/chat/src/ui/messages/useChatStreamState.test.ts new file mode 100644 index 0000000000..7c365318b3 --- /dev/null +++ b/packages/chat/src/ui/messages/useChatStreamState.test.ts @@ -0,0 +1,127 @@ +import { effectScope, nextTick, shallowRef } from 'vue' +import type { ShallowRef } from 'vue' +import { describe, expect, it } from 'vitest' +import type { ChatMessageItem, ChatRequestView } from '../../types' +import { createMessageIdentity, resolveStreamState } from './messageIdentity' +import { useChatStreamState } from './useChatStreamState' + +function createTracker( + messages: ShallowRef, + request: ShallowRef, + conversationId = shallowRef('a'), +) { + const scope = effectScope() + const identify = createMessageIdentity('test') + const state = scope.run(() => + useChatStreamState( + () => messages.value, + () => request.value, + () => conversationId.value, + identify, + ), + )! + return { state, scope, conversationId } +} + +describe('useChatStreamState', () => { + it('confirms an assistant appended after an empty request starts', async () => { + const messages = shallowRef([]) + const request = shallowRef({ state: 'idle' }) + const tracker = createTracker(messages, request) + request.value = { state: 'processing' } + await nextTick() + const assistant = { id: 'answer', role: 'assistant' } + messages.value = [assistant] + await nextTick() + expect(tracker.state(assistant)).toBe('current') + expect(resolveStreamState(assistant, request.value, tracker.state(assistant))).toBe('streaming') + tracker.scope.stop() + }) + + it('keeps snapshots finished and treats a mounted active request as uncertain', async () => { + const history = { id: 'history', role: 'assistant' } + const messages = shallowRef([history]) + const request = shallowRef({ state: 'processing' }) + const tracker = createTracker(messages, request) + const appended = { id: 'later', role: 'assistant' } + messages.value = [...messages.value, appended] + await nextTick() + expect(tracker.state(history)).toBe('history') + expect(tracker.state(appended)).toBe('unknown') + tracker.scope.stop() + }) + + it('keeps assistants added with a request start uncertain regardless of update order', async () => { + const messages = shallowRef([]) + const request = shallowRef({ state: 'idle' }) + const tracker = createTracker(messages, request) + const assistant = { id: 'same-batch', role: 'assistant' } + messages.value = [assistant] + request.value = { state: 'processing' } + await nextTick() + expect(tracker.state(assistant)).toBe('unknown') + tracker.scope.stop() + }) + + it('clears a completed turn before tracking the next assistant', async () => { + const history = { id: 'history', role: 'assistant' } + const messages = shallowRef([history]) + const request = shallowRef({ state: 'idle' }) + const tracker = createTracker(messages, request) + request.value = { state: 'processing' } + await nextTick() + expect(tracker.state(history)).toBe('history') + request.value = { state: 'completed' } + await nextTick() + request.value = { state: 'processing' } + await nextTick() + const assistant = { id: 'next', role: 'assistant' } + messages.value = [...messages.value, assistant] + await nextTick() + expect(tracker.state(history)).toBe('history') + expect(tracker.state(assistant)).toBe('current') + tracker.scope.stop() + }) + + it('retains identity through immutable replacement, pauses and waits for the next assistant after tools', async () => { + const messages = shallowRef([]) + const request = shallowRef({ state: 'idle' }) + const tracker = createTracker(messages, request) + request.value = { state: 'processing' } + await nextTick() + messages.value = [{ id: 'answer', renderKey: 'turn', role: 'assistant' }] + await nextTick() + const replacement = { id: 'server-answer', renderKey: 'turn', role: 'assistant' } + messages.value = [replacement] + await nextTick() + expect(tracker.state(replacement)).toBe('current') + request.value = { state: 'paused' } + await nextTick() + expect(resolveStreamState(replacement, request.value, tracker.state(replacement))).toBe('finished') + request.value = { state: 'processing', processingState: 'calling-tools' } + await nextTick() + expect(resolveStreamState(replacement, request.value, tracker.state(replacement))).toBe('finished') + const next = { id: 'next', role: 'assistant' } + messages.value = [...messages.value, next] + request.value = { state: 'processing' } + await nextTick() + expect(tracker.state(replacement)).toBe('history') + expect(tracker.state(next)).toBe('current') + tracker.scope.stop() + }) + + it('clears tracking on conversation changes', async () => { + const messages = shallowRef([]) + const request = shallowRef({ state: 'idle' }) + const tracker = createTracker(messages, request) + request.value = { state: 'processing' } + await nextTick() + messages.value = [{ id: 'answer', role: 'assistant' }] + await nextTick() + tracker.conversationId.value = 'b' + messages.value = [{ id: 'other', role: 'assistant' }] + await nextTick() + expect(tracker.state(messages.value[0])).toBe('unknown') + tracker.scope.stop() + }) +}) diff --git a/packages/chat/src/ui/messages/useChatStreamState.ts b/packages/chat/src/ui/messages/useChatStreamState.ts new file mode 100644 index 0000000000..436848d6c8 --- /dev/null +++ b/packages/chat/src/ui/messages/useChatStreamState.ts @@ -0,0 +1,97 @@ +import { nextTick, watch } from 'vue' +import type { ChatMessageItem, ChatRequestView } from '../../types' +import type { ChatStreamMessageState } from './messageIdentity' + +export function useChatStreamState( + getMessages: () => readonly ChatMessageItem[], + getRequest: () => ChatRequestView | undefined, + getConversationId: () => string | null | undefined, + identify: (message: ChatMessageItem, conversationId?: string | null) => string, +) { + let conversationId = getConversationId() ?? null + let active = false + let trusted = false + let snapshot = new Set() + let assistants = new Set() + let unknownAssistants = new Set() + let pendingAssistants = new Set() + let knownMessages = new Set(getMessages().map((message) => identify(message, conversationId))) + let currentAssistant: string | null = null + let navigationPending = false + + const capture = (canInfer: boolean) => { + active = true + trusted = canInfer + snapshot = new Set(getMessages().map((message) => identify(message, conversationId))) + assistants = new Set() + unknownAssistants = new Set([...pendingAssistants].filter((key) => snapshot.has(key))) + pendingAssistants = new Set() + currentAssistant = null + } + const clear = () => { + active = false + trusted = false + snapshot = new Set() + assistants = new Set() + unknownAssistants = new Set() + pendingAssistants = new Set() + currentAssistant = null + } + const observeMessages = () => { + const messages = getMessages() + const keys = new Set(messages.map((message) => identify(message, conversationId))) + if (!active) { + for (const message of messages) { + const key = identify(message, conversationId) + if (message.role === 'assistant' && !knownMessages.has(key)) pendingAssistants.add(key) + } + knownMessages = keys + void nextTick(() => { + pendingAssistants = new Set() + }) + return + } + knownMessages = keys + if (!trusted || getRequest()?.state !== 'processing') return + for (const message of messages) { + const key = identify(message, conversationId) + if (message.role === 'assistant' && !snapshot.has(key)) { + assistants.add(key) + currentAssistant = key + } + } + } + const requestState = getRequest()?.state + if (requestState === 'processing' || requestState === 'paused') capture(false) + + watch( + [getConversationId, () => getRequest()?.state], + ([nextId, state], [previousId, previousState]) => { + if (nextId !== previousId) { + conversationId = nextId ?? null + navigationPending = true + void nextTick(() => { + navigationPending = false + }) + if (state === 'processing' || state === 'paused') capture(false) + else clear() + } else if (state === 'processing' && previousState !== 'processing' && previousState !== 'paused') { + capture(!navigationPending) + } else if (state === 'paused' && !active) capture(previousState === 'processing') + else if (state !== 'processing' && state !== 'paused') { + clear() + } + }, + { flush: 'sync' }, + ) + watch(() => getMessages().map((message) => identify(message, conversationId)), observeMessages, { flush: 'sync' }) + + return (message: ChatMessageItem): ChatStreamMessageState => { + const key = identify(message, conversationId) + if (unknownAssistants.has(key)) return 'unknown' + if (!active || snapshot.has(key) || assistants.has(key)) { + return key === currentAssistant ? 'current' : 'history' + } + return message.role === 'assistant' ? 'unknown' : 'history' + } +} diff --git a/packages/test/component/chat/ChatRenderContext.fixture.vue b/packages/test/component/chat/ChatRenderContext.fixture.vue index 4d34416d7b..c44aff14d3 100644 --- a/packages/test/component/chat/ChatRenderContext.fixture.vue +++ b/packages/test/component/chat/ChatRenderContext.fixture.vue @@ -1,7 +1,7 @@ @@ -96,6 +137,11 @@ function finish() { Grow Finish Switch + Start automatic stream + Grow automatic stream + Grow automatic table + Finish automatic stream + Switch automatic stream Toggle sender header { + const component = await mount(ChatRenderContext) + await component.getByTestId('auto-start').click() + const automatic = component.locator('.context-probe').filter({ hasText: 'first' }) + await expect(automatic).toHaveAttribute('data-state', 'streaming') + const instance = await automatic.getAttribute('data-instance') + await component.getByTestId('auto-grow').click() + await expect(automatic).toContainText('first update') + await expect(automatic).toHaveAttribute('data-instance', instance!) + await expect(automatic).toHaveAttribute('data-state', 'streaming') + await component.getByTestId('auto-table').click() + await expect(automatic).toContainText('column') + await expect(automatic).toHaveAttribute('data-state', 'streaming') + await component.getByTestId('auto-finish').click() + await expect(automatic).toHaveAttribute('data-state', 'finished') + await component.getByTestId('auto-switch').click() + await expect(component.locator('.context-probe').filter({ hasText: 'other' })).toHaveAttribute( + 'data-state', + 'unknown', + ) +}) + test('Anchor uses display questions, targets only the first split and retains caller attributes', async ({ mount, page, From 3ef31b9134e3f1e53e5dbcfbb3de471b4e05bfcf Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Thu, 22 Oct 2026 04:34:22 -0700 Subject: [PATCH 07/15] feat(chat): enhance chat sender slots and improve message rendering logic --- docs/src/suites/chat-runtime.md | 2 +- docs/src/suites/chat.md | 2 +- packages/chat/src/Chat.vue | 6 ++ packages/chat/src/ChatUI.vue | 14 +++++ .../composables/useChatRuntimeAdapter.test.ts | 59 +++++++++++++++++++ .../src/composables/useChatRuntimeAdapter.ts | 8 ++- .../useChatRuntimeFromConversation.test.ts | 45 ++++++++++++-- .../runtime/useChatRuntimeFromConversation.ts | 28 ++++++--- packages/chat/src/types/ui/slots.ts | 4 +- .../chat/src/ui/composer/ChatInputRegion.vue | 9 +++ packages/chat/src/ui/composer/ChatSender.vue | 13 ++++ .../chat/src/ui/messages/ChatMessages.vue | 23 ++++++-- .../components/layouts/MultiLineLayout.vue | 10 ++++ .../components/layouts/SingleLineLayout.vue | 8 +++ packages/components/src/sender/index.vue | 15 ++--- .../src/shared/composables/useAutoScroll.ts | 5 +- .../test/component/bubble/AutoScroll.spec.ts | 18 ++++++ .../chat/ChatRenderContext.fixture.vue | 47 +++++++++------ .../component/chat/ChatRenderContext.spec.ts | 13 ++++ 19 files changed, 281 insertions(+), 48 deletions(-) diff --git a/docs/src/suites/chat-runtime.md b/docs/src/suites/chat-runtime.md index 3eb93902d9..acceddebf8 100644 --- a/docs/src/suites/chat-runtime.md +++ b/docs/src/suites/chat-runtime.md @@ -481,7 +481,7 @@ const adapter = useChatRuntimeAdapter({ | `titleGenerator` | 生成默认发送自动创建的会话标题 | `(text: string) => string` | 否 | 与 `useChatRuntime` 相同 | | `beforeSend` | 发送前检查 | `ChatBeforeSend` | 否 | 不执行自定义检查 | | `send` | 自行处理会话创建、消息保存和请求,允许空文本 | `(payload: KitRuntimeSendPayload) => boolean \| void \| Promise`;仅严格返回 `false` 表示拒绝 | 否 | 使用已有 Kit 会话执行默认发送 | -| `composer` | 提供输入区状态、模型和 MCP 操作 | `ChatComposerRuntime` | 否 | `{}`;默认发送会根据模型和工具状态限制提交 | +| `composer` | 提供输入区状态、模型和 MCP 操作 | `ChatComposerRuntime` | 否 | `{}`;默认发送会根据模型、工具和 Engine 状态限制提交;自定义 `send` 仅受输入区禁用状态及显式 `submitDisabled` 限制 | ### 错误记录配置 diff --git a/docs/src/suites/chat.md b/docs/src/suites/chat.md index c2f7b8ad66..88c3d01b05 100644 --- a/docs/src/suites/chat.md +++ b/docs/src/suites/chat.md @@ -1217,7 +1217,7 @@ const context = useChatRenderContext(props) 消息对象和服务端 ID 都可能变化时,请提供唯一且稳定的 `renderKey`。使用 `messageKey` 和 `contentIndex` 可区分渲染内容,无需解析 `messageKey`。 -`message.streamState` 可显式指定流状态。未指定时根据 `loading` 和请求状态判断:历史消息默认按已结束处理;当前请求中的最新消息状态不足时为 `unknown`。 +`message.streamState` 可显式指定流状态。未指定时根据当前请求的可追踪归属判断:历史消息默认按已结束处理;当前请求中的最新消息状态不足时为 `unknown`。`loading` 不决定流状态。 渲染器的定义与注册见 [Bubble 自定义渲染器](../components/bubble#实现自定义渲染器) 和 [Provider 配置](../components/bubble#通过-bubbleprovider-配置渲染器)。 diff --git a/packages/chat/src/Chat.vue b/packages/chat/src/Chat.vue index 581edb0c4d..14c34a1cd7 100644 --- a/packages/chat/src/Chat.vue +++ b/packages/chat/src/Chat.vue @@ -328,6 +328,12 @@ function handleFloatingResizeEnd(detail: LayoutFloatingResizeDetail) { + + + + + + diff --git a/packages/chat/src/ChatUI.vue b/packages/chat/src/ChatUI.vue index c415889712..ae3b78c115 100644 --- a/packages/chat/src/ChatUI.vue +++ b/packages/chat/src/ChatUI.vue @@ -480,6 +480,8 @@ function renderEmptyStateComposer() { 'composer-after': currentSlots.value['composer-after'], 'layout-footer': currentSlots.value['layout-footer'], 'sender-header': currentSlots.value['sender-header'], + 'sender-content': currentSlots.value['sender-content'], + 'sender-actions-inline': currentSlots.value['sender-actions-inline'], 'sender-footer': currentSlots.value['sender-footer'], 'sender-footer-right': currentSlots.value['sender-footer-right'], }, @@ -660,6 +662,12 @@ function renderEmptyStateComposer() { + + + + + + @@ -730,6 +738,12 @@ function renderEmptyStateComposer() { + + + + + + diff --git a/packages/chat/src/composables/useChatRuntimeAdapter.test.ts b/packages/chat/src/composables/useChatRuntimeAdapter.test.ts index 1bd982dfd0..0b46d00d9e 100644 --- a/packages/chat/src/composables/useChatRuntimeAdapter.test.ts +++ b/packages/chat/src/composables/useChatRuntimeAdapter.test.ts @@ -91,6 +91,65 @@ describe('useChatRuntimeAdapter error channels', () => { }) describe('useChatRuntimeAdapter runtime-scoped drafts', () => { + it('restores a rejected draft when send creates the first conversation', async () => { + const activeConversation = shallowRef(null) + const runtime: ChatRuntime = { + conversations: ref([]), + activeConversation, + composer: {}, + actions: { + send: async () => { + activeConversation.value = { id: 'created', title: 'created', messages: [], requestState: 'idle' } + return false + }, + clearActiveConversation: () => undefined, + createConversation: () => undefined, + switchConversation: () => undefined, + renameConversation: () => undefined, + deleteConversation: () => undefined, + }, + } + const adapter = useChatRuntimeAdapter({ runtime, onActionError: () => undefined }) + adapter.setInputValue('retry') + + await expect(adapter.send({ text: 'retry' })).resolves.toBe(false) + expect(adapter.inputValue.value).toBe('retry') + }) + + it('preserves text entered while a send creates the first conversation', async () => { + const activeConversation = shallowRef(null) + let finish!: () => void + const wait = new Promise((resolve) => { + finish = resolve + }) + const runtime: ChatRuntime = { + conversations: ref([]), + activeConversation, + composer: {}, + actions: { + send: async () => { + await wait + activeConversation.value = { id: 'created', title: 'created', messages: [], requestState: 'idle' } + return false + }, + clearActiveConversation: () => undefined, + createConversation: () => undefined, + switchConversation: () => undefined, + renameConversation: () => undefined, + deleteConversation: () => undefined, + }, + } + const adapter = useChatRuntimeAdapter({ runtime, onActionError: () => undefined }) + adapter.setInputValue('retry') + const sending = adapter.send({ text: 'retry' }) + await Promise.resolve() + adapter.setInputValue('new draft') + finish() + + await expect(sending).resolves.toBe(false) + expect(adapter.inputValue.value).toBe('new draft') + }) + it('rejects a submission whose conversation changes before the draft microtask', async () => { const send = vi.fn(async () => true) const current = createRuntime('conversation-1', send) diff --git a/packages/chat/src/composables/useChatRuntimeAdapter.ts b/packages/chat/src/composables/useChatRuntimeAdapter.ts index e23ce614be..097f1c1d0a 100644 --- a/packages/chat/src/composables/useChatRuntimeAdapter.ts +++ b/packages/chat/src/composables/useChatRuntimeAdapter.ts @@ -1,4 +1,4 @@ -import { computed, shallowRef, toValue, watch } from 'vue' +import { computed, nextTick, shallowRef, toValue, watch } from 'vue' import type { MaybeRefOrGetter } from 'vue' import { useChatDraft } from './useChatDraft' import type { @@ -31,6 +31,7 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { const pendingModelFeatureIds = shallowRef>(new Set()) const pendingMcpServerIds = shallowRef>(new Set()) const pendingMcpToolIds = shallowRef>>(new Map()) + let sendingFromEmptyConversation = false watch( runtime, @@ -59,10 +60,14 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { return false } try { + sendingFromEmptyConversation = conversationId === null return await actionRuntime.actions.send(payload) } catch (error) { options.onActionError({ action: 'send', payload, error }) return false + } finally { + await nextTick() + sendingFromEmptyConversation = false } } } @@ -88,6 +93,7 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { nextId !== previousId && runtime.value.conversationNavigationRevision === undefined ) { + if (sendingFromEmptyConversation && previousId === null && nextId !== null) return invalidateDraftForNavigation() } }, diff --git a/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts b/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts index c4c8c7b55f..431b748c12 100644 --- a/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts +++ b/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts @@ -74,17 +74,54 @@ describe('useChatRuntimeFromConversation', () => { await expect(runtime.actions.send({ text: 'hello' })).rejects.toBe(failure) }) - it('applies busy and paused engine gates to custom and default sends', async () => { + it('applies engine availability gates only to default sends', async () => { for (const custom of [true, false]) { const harness = createHarness('a', false) const send = vi.fn(async () => true) const runtime = useChatRuntimeFromConversation({ conversation: harness.api, ...(custom ? { send } : {}) }) - expect(runtime.composer.submitDisabled?.value).toBe(true) - await expect(runtime.actions.send({ text: 'blocked' })).resolves.toBe(false) - expect(send).not.toHaveBeenCalled() + expect(runtime.composer.submitDisabled?.value).toBe(!custom) + await expect(runtime.actions.send({ text: 'blocked' })).resolves.toBe(custom) + expect(send).toHaveBeenCalledTimes(custom ? 1 : 0) } }) + it('allows custom sends without a selected model', async () => { + const harness = createHarness() + const model = { + options: ref([]), + selectedId: ref(null), + features: ref({}), + select: vi.fn(), + setFeature: vi.fn(), + setReasoningEffort: vi.fn(), + } + const send = vi.fn(async () => true) + const runtime = useChatRuntimeFromConversation({ conversation: harness.api, send, composer: { model } }) + + await expect(runtime.actions.send({ text: 'route elsewhere' })).resolves.toBe(true) + expect(send).toHaveBeenCalledOnce() + }) + + it('does not count a custom send-created first conversation as navigation', async () => { + const harness = createHarness('') + harness.activeConversationId.value = null + let finish!: (accepted: boolean) => void + const pending = new Promise((resolve) => { + finish = resolve + }) + const send = vi.fn(async () => { + harness.api.createConversation({ id: 'created' }) + return pending + }) + const runtime = useChatRuntimeFromConversation({ conversation: harness.api, send }) + const sending = runtime.actions.send({ text: 'first' }) + await Promise.resolve() + expect(runtime.conversationNavigationRevision?.value).toBe(0) + finish(false) + await expect(sending).resolves.toBe(false) + expect(runtime.conversationNavigationRevision?.value).toBe(0) + }) + it('honors beforeSend branches and releases an occupied target on every exit', async () => { const harness = createHarness() const handled = useChatRuntimeFromConversation({ diff --git a/packages/chat/src/runtime/useChatRuntimeFromConversation.ts b/packages/chat/src/runtime/useChatRuntimeFromConversation.ts index 9d03413d1b..35e7a0e99b 100644 --- a/packages/chat/src/runtime/useChatRuntimeFromConversation.ts +++ b/packages/chat/src/runtime/useChatRuntimeFromConversation.ts @@ -48,14 +48,17 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver const { conversation, titleGenerator, beforeSend, send, composer: composerOptions } = options const conversationNavigationRevision = shallowRef(0) const creatingConversationForSend = shallowRef(false) + let awaitingCustomCreation = false const occupiedTargets = shallowRef(new Set()) let previousConversationId = conversation.activeConversationId.value watch( conversation.activeConversationId, (id) => { if (id !== previousConversationId) { + const createdForCustomSend = awaitingCustomCreation && previousConversationId === null && id !== null previousConversationId = id - if (!creatingConversationForSend.value) conversationNavigationRevision.value++ + if (createdForCustomSend) awaitingCustomCreation = false + else if (!creatingConversationForSend.value) conversationNavigationRevision.value++ } }, { flush: 'sync' }, @@ -91,10 +94,11 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver return ( Boolean(sourceComposer.submitDisabled?.value) || - !areEnabledMcpToolsReady(sourceComposer.mcp) || - noUsableModel || occupiedTargets.value.has(targetKey) || - Boolean(active && !active.engine.canStartTurn.value) + (!send && + (!areEnabledMcpToolsReady(sourceComposer.mcp) || + noUsableModel || + Boolean(active && !active.engine.canStartTurn.value))) ) }) const composer: ChatRuntime['composer'] = { @@ -170,9 +174,10 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver return ( !composer.disabled?.value && !sourceComposer.submitDisabled?.value && - areEnabledMcpToolsReady(composer.mcp) && - (!model || Boolean(selectedModel && !selectedModel.disabled)) && - (!targetEngine || targetEngine.canStartTurn.value) + (Boolean(send) || + (areEnabledMcpToolsReady(composer.mcp) && + (!model || Boolean(selectedModel && !selectedModel.disabled)) && + (!targetEngine || targetEngine.canStartTurn.value))) ) } try { @@ -217,8 +222,13 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver } if (send) { - const result = await send(effectivePayload) - return result !== false + if (targetConversationId === null) awaitingCustomCreation = true + try { + const result = await send(effectivePayload) + return result !== false + } finally { + awaitingCustomCreation = false + } } if (targetConversationId === null) { creatingConversationForSend.value = true diff --git a/packages/chat/src/types/ui/slots.ts b/packages/chat/src/types/ui/slots.ts index edc0e42b0a..2e232e14a4 100644 --- a/packages/chat/src/types/ui/slots.ts +++ b/packages/chat/src/types/ui/slots.ts @@ -1,7 +1,7 @@ import type { ChatConversationInfo, ChatMessageItem, ChatStructuredData } from '../base' import type { ChatRightAsidePanelId, ChatRightAsidePanelOptions } from './options' import type { ChatConversationView, ChatHistoryData, ChatRequestView } from './data' -import type { BubbleMessage } from '@opentiny/tiny-robot' +import type { BubbleMessage, SenderSlotScope } from '@opentiny/tiny-robot' import type { VNode } from 'vue' export interface ChatHeaderSlotProps { @@ -117,6 +117,8 @@ export interface ChatUISlots { 'bubble-after'?: (props: ChatBubbleSlotProps) => unknown 'bubble-content-footer'?: (props: ChatBubbleContentFooterSlotProps) => unknown 'sender-header'?: () => unknown + 'sender-content'?: (props: { editor: unknown }) => unknown + 'sender-actions-inline'?: (scope: SenderSlotScope) => unknown 'sender-footer'?: () => unknown 'sender-footer-right'?: () => unknown 'extension-header-actions'?: (props: ChatExtensionSlotProps) => unknown diff --git a/packages/chat/src/ui/composer/ChatInputRegion.vue b/packages/chat/src/ui/composer/ChatInputRegion.vue index 2b502059c2..9ea314df75 100644 --- a/packages/chat/src/ui/composer/ChatInputRegion.vue +++ b/packages/chat/src/ui/composer/ChatInputRegion.vue @@ -1,5 +1,6 @@ @@ -163,6 +170,12 @@ function handleOpenExtensionPanel() { + + + + + + diff --git a/packages/chat/src/ui/messages/ChatMessages.vue b/packages/chat/src/ui/messages/ChatMessages.vue index 86ccc5eea1..ae4b459965 100644 --- a/packages/chat/src/ui/messages/ChatMessages.vue +++ b/packages/chat/src/ui/messages/ChatMessages.vue @@ -117,10 +117,25 @@ const boxAttributes: BubbleBoxAttributesResolver = (messages, content, contentIn const contentAttributes: BubbleContentAttributesResolver = (message, content, contentIndex) => { const configured = props.options.bubbleProvider?.contentAttributes const attributes = typeof configured === 'function' ? configured(message, content, contentIndex) : configured - return { - ...attributes, - key: attributes?.key ?? JSON.stringify([(message as ChatMessageItem).renderKey, contentIndex]), - } + const item = message as ChatMessageItem + const messageIndex = bubbleMessages.value.indexOf(item) + const isAdditionalAnchorTarget = + props.anchorEnabled && + props.options.bubbleList.groupStrategy === 'consecutive' && + item.role === 'user' && + messageIndex > 0 && + bubbleMessages.value[messageIndex - 1]?.role === 'user' && + (contentIndex ?? 0) === 0 + const boxAttributes = isAdditionalAnchorTarget ? resolveBoxAttributes([item], content, 0) : undefined + return mergeProps(attributes ?? {}, { + ...(isAdditionalAnchorTarget + ? { + class: 'tr-chat-anchor-target', + 'data-anchor-id': boxAttributes?.['data-anchor-id'] ?? item.renderKey, + } + : {}), + key: (attributes?.key as string | number | symbol | undefined) ?? JSON.stringify([item.renderKey, contentIndex]), + }) } const bubbleProviderProps = computed(() => ({ fallbackContentRenderer: BubbleRenderers.Markdown, diff --git a/packages/components/src/sender/components/layouts/MultiLineLayout.vue b/packages/components/src/sender/components/layouts/MultiLineLayout.vue index 4937da1360..0198b215ae 100644 --- a/packages/components/src/sender/components/layouts/MultiLineLayout.vue +++ b/packages/components/src/sender/components/layouts/MultiLineLayout.vue @@ -1,12 +1,22 @@ diff --git a/packages/components/src/sender/components/layouts/SingleLineLayout.vue b/packages/components/src/sender/components/layouts/SingleLineLayout.vue index 68bbfa3f04..78b4817687 100644 --- a/packages/components/src/sender/components/layouts/SingleLineLayout.vue +++ b/packages/components/src/sender/components/layouts/SingleLineLayout.vue @@ -1,12 +1,20 @@ diff --git a/packages/components/src/sender/index.vue b/packages/components/src/sender/index.vue index a18f4bc85d..4c682e1456 100644 --- a/packages/components/src/sender/index.vue +++ b/packages/components/src/sender/index.vue @@ -1,5 +1,5 @@ + + + + 将图片拖到输入框,或复制图片后粘贴。此示例只显示文件名,不会上传文件。 + + + + 释放图片以添加附件 + + + + {{ receivedFiles.length ? `收到:${receivedFiles.join('、')}` : '尚未收到文件' }} + + + + + diff --git a/docs/src/suites/chat.md b/docs/src/suites/chat.md index 408a34b8ba..7940d407db 100644 --- a/docs/src/suites/chat.md +++ b/docs/src/suites/chat.md @@ -212,6 +212,27 @@ function handlePromptClick({ item }: ChatPromptClickPayload) { 通过 `ui.sender` 调整输入框、配置提及和模板功能;通过 `ui.sender.toolbar` 或输入区插槽添加自定义按钮,无需重新实现消息发送和取消。 +#### 接收拖入和粘贴的文件 + +`TrChat` 和 `TrChatUI` 会将 Sender 上的文件拖拽与粘贴事件交给宿主。宿主需在 `sender-drag-over` 中对原生 `event` 调用 `preventDefault()`,浏览器才会触发放置事件并交付文件。 + + + +```vue + event.preventDefault()" + @sender-drop="({ event }) => handleFiles(event.dataTransfer?.files)" + @sender-paste="(event) => handleFiles(event.clipboardData?.files)" +/> +``` + +拖拽事件参数包含原生 `event` 和 Sender 根节点 `target`。可在 `sender-drag-enter` 与 `sender-drag-leave` 控制拖拽提示层;粘贴事件收到 `ClipboardEvent`。上传校验、状态和请求由宿主负责。 + #### 回填、清空与聚焦 可从外部填入提示词、清空文本或聚焦输入框。回填不会自动发送,清空文本不会移除附件或已选 Skill。 @@ -809,6 +830,8 @@ const data = computed(() => ({ `TrChat` 会直接处理提交、取消、会话切换、模型选择和 MCP 开关操作,调用 Runtime 对应方法;这些事件不会再次向外发出。 +Sender 的文件拖拽与粘贴事件会透传给应用,由应用处理文件,详见[接收拖入和粘贴的文件](#接收拖入和粘贴的文件)。 + | 事件 | 参数 | 触发时机 | | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `runtime-action-error` | `ChatRuntimeActionErrorPayload` | Runtime 操作失败;send 错误详情仍从所属消息读取。 | @@ -820,6 +843,8 @@ const data = computed(() => ({ | `extension-tab-change` | `ExtensionManagerTabChangeEvent` | 扩展管理器切换页签。 | | `bubble-state-change` | `ChatBubbleStateChangePayload` | 气泡内部状态变化。 | | `bubble-event` | `ChatBubbleEventPayload` | 气泡内容发出自定义事件。 | +| `sender-drag-enter` / `sender-drag-over` / `sender-drag-leave` / `sender-drop` | `SenderDragPayload` | Sender 上对应的原生文件拖拽事件;放置文件需在 `sender-drag-over` 中调用 `event.preventDefault()`。 | +| `sender-paste` | `ClipboardEvent` | Sender 收到粘贴事件。 | | `left-aside-open-change` / `right-aside-open-change` | `ChatAsideOpenChangePayload` | 侧栏状态变化。 | | `update:right-aside-open` | `boolean` | 通知应用更新右栏开闭状态。 | | `update:active-right-aside-panel-id` | `ChatRightAsidePanelId \| undefined` | 通知应用更新当前右栏面板。 | @@ -883,6 +908,8 @@ const data = computed(() => ({ | `model-select` | `ChatModelSelectPayload` | 更新 `data.model.selectedId`。 | | `model-feature-change` | `ChatModelFeatureChangePayload` | 更新能力开关;异步时可同步 `pendingFeatureIds`。 | | `model-reasoning-effort-change` | `ChatModelReasoningEffortChangePayload` | 更新思考强度。 | +| `sender-drag-enter` / `sender-drag-over` / `sender-drag-leave` / `sender-drop` | `SenderDragPayload` | Sender 上对应的原生文件拖拽事件;放置文件需在 `sender-drag-over` 中调用 `event.preventDefault()`。 | +| `sender-paste` | `ClipboardEvent` | Sender 收到粘贴事件。 | | `mcp-add-server` / `mcp-remove-server` | `ChatMcpAddServerPayload` / `ChatMcpRemoveServerPayload` | 更新 MCP 服务列表。 | | `mcp-create-server` | `ChatMcpCreateServerPayload` | 创建并接入自定义 MCP 服务。 | | `mcp-server-enabled-change` | `ChatMcpServerEnabledChangePayload` | 更新 MCP 服务启用状态。 | @@ -1315,6 +1342,7 @@ const context = useChatRenderContext(props) | `ChatSwitchConversationPayload` | `conversationId: string` | | `ChatRenameConversationPayload` | `conversationId: string`;`title: string` | | `ChatPromptClickPayload` | `event: MouseEvent`;`item: PromptProps` | +| `SenderDragPayload` | `event: DragEvent`;`target: HTMLElement` | | `ChatModelSelectPayload` | `modelId: string \| null` | | `ChatModelFeatureChangePayload` | `featureId: 'thinking' \| 'search'`;`enabled: boolean` | | `ChatModelReasoningEffortChangePayload` | `effort: string \| null` | diff --git a/packages/chat/src/Chat.vue b/packages/chat/src/Chat.vue index 17b113f9fc..b9fd4ab99f 100644 --- a/packages/chat/src/Chat.vue +++ b/packages/chat/src/Chat.vue @@ -27,6 +27,7 @@ import type { ChatHistoryData, ChatRightAsidePanelId, ChatSwitchConversationPayload, + SenderDragPayload, LayoutFloatingDragDetail, LayoutFloatingResizeDetail, LayoutFloatingState, @@ -82,6 +83,11 @@ const emit = defineEmits<{ 'prompt-click': [payload: ChatPromptClickPayload] 'bubble-state-change': [payload: ChatBubbleStateChangePayload] 'bubble-event': [payload: ChatBubbleEventPayload] + 'sender-drag-enter': [payload: SenderDragPayload] + 'sender-drag-over': [payload: SenderDragPayload] + 'sender-drag-leave': [payload: SenderDragPayload] + 'sender-drop': [payload: SenderDragPayload] + 'sender-paste': [event: ClipboardEvent] 'left-aside-open-change': [payload: ChatAsideOpenChangePayload] 'right-aside-open-change': [payload: ChatAsideOpenChangePayload] 'update:right-aside-open': [value: boolean] @@ -168,6 +174,26 @@ function handleBubbleEvent(payload: ChatBubbleEventPayload) { emit('bubble-event', payload) } +function handleSenderDragEnter(payload: SenderDragPayload) { + emit('sender-drag-enter', payload) +} + +function handleSenderDragOver(payload: SenderDragPayload) { + emit('sender-drag-over', payload) +} + +function handleSenderDragLeave(payload: SenderDragPayload) { + emit('sender-drag-leave', payload) +} + +function handleSenderDrop(payload: SenderDragPayload) { + emit('sender-drop', payload) +} + +function handleSenderPaste(event: ClipboardEvent) { + emit('sender-paste', event) +} + function handleLeftAsideOpenChange(payload: ChatAsideOpenChangePayload) { emit('left-aside-open-change', payload) } @@ -232,6 +258,11 @@ function handleFloatingResizeEnd(detail: LayoutFloatingResizeDetail) { @prompt-click="handlePromptClick" @bubble-state-change="handleBubbleStateChange" @bubble-event="handleBubbleEvent" + @sender-drag-enter="handleSenderDragEnter" + @sender-drag-over="handleSenderDragOver" + @sender-drag-leave="handleSenderDragLeave" + @sender-drop="handleSenderDrop" + @sender-paste="handleSenderPaste" @left-aside-open-change="handleLeftAsideOpenChange" @right-aside-open-change="handleRightAsideOpenChange" @update:right-aside-open="handleUpdateRightAsideOpen" diff --git a/packages/chat/src/ChatUI.vue b/packages/chat/src/ChatUI.vue index a7046aaa05..8653ecc473 100644 --- a/packages/chat/src/ChatUI.vue +++ b/packages/chat/src/ChatUI.vue @@ -36,6 +36,7 @@ import type { ChatModelReasoningEffortChangePayload, ChatRightAsidePanelOptions, ChatSendPayload, + SenderDragPayload, ChatUIEmits, ChatUIProps, ChatUISlots, @@ -364,6 +365,26 @@ function handleModelReasoningEffortChange(payload: ChatModelReasoningEffortChang emit('model-reasoning-effort-change', payload) } +function handleSenderDragEnter(payload: SenderDragPayload) { + emit('sender-drag-enter', payload) +} + +function handleSenderDragOver(payload: SenderDragPayload) { + emit('sender-drag-over', payload) +} + +function handleSenderDragLeave(payload: SenderDragPayload) { + emit('sender-drag-leave', payload) +} + +function handleSenderDrop(payload: SenderDragPayload) { + emit('sender-drop', payload) +} + +function handleSenderPaste(event: ClipboardEvent) { + emit('sender-paste', event) +} + function handleMcpAddServer(payload: { id: string }) { emit('mcp-add-server', { serverId: payload.id }) } @@ -447,6 +468,11 @@ const composerEvents = { onModelSelect: handleModelSelect, onModelFeatureChange: handleModelFeatureChange, onModelReasoningEffortChange: handleModelReasoningEffortChange, + onSenderDragEnter: handleSenderDragEnter, + onSenderDragOver: handleSenderDragOver, + onSenderDragLeave: handleSenderDragLeave, + onSenderDrop: handleSenderDrop, + onSenderPaste: handleSenderPaste, onOpenMcpPanel: handleOpenMcpPanel, onOpenExtensionsPanel: handleOpenExtensionsPanel, } @@ -459,6 +485,11 @@ const composerTemplateEvents = { modelSelect: handleModelSelect, modelFeatureChange: handleModelFeatureChange, modelReasoningEffortChange: handleModelReasoningEffortChange, + senderDragEnter: handleSenderDragEnter, + senderDragOver: handleSenderDragOver, + senderDragLeave: handleSenderDragLeave, + senderDrop: handleSenderDrop, + senderPaste: handleSenderPaste, openMcpPanel: handleOpenMcpPanel, openExtensionsPanel: handleOpenExtensionsPanel, } diff --git a/packages/chat/src/types/ui/events.ts b/packages/chat/src/types/ui/events.ts index b01658a220..3f7b54cf56 100644 --- a/packages/chat/src/types/ui/events.ts +++ b/packages/chat/src/types/ui/events.ts @@ -54,6 +54,11 @@ export interface ChatModelReasoningEffortChangePayload { readonly effort: string | null } +export interface SenderDragPayload { + readonly event: DragEvent + readonly target: HTMLElement +} + export interface ChatMcpAddServerPayload { readonly serverId: string } @@ -114,6 +119,11 @@ export interface ChatUIEmits { 'model-select': [payload: ChatModelSelectPayload] 'model-feature-change': [payload: ChatModelFeatureChangePayload] 'model-reasoning-effort-change': [payload: ChatModelReasoningEffortChangePayload] + 'sender-drag-enter': [payload: SenderDragPayload] + 'sender-drag-over': [payload: SenderDragPayload] + 'sender-drag-leave': [payload: SenderDragPayload] + 'sender-drop': [payload: SenderDragPayload] + 'sender-paste': [event: ClipboardEvent] 'mcp-add-server': [payload: ChatMcpAddServerPayload] 'mcp-create-server': [payload: ChatMcpCreateServerPayload] 'mcp-remove-server': [payload: ChatMcpRemoveServerPayload] diff --git a/packages/chat/src/ui/composer/ChatInputRegion.vue b/packages/chat/src/ui/composer/ChatInputRegion.vue index 1ce613d5b9..fcfb6ac0d0 100644 --- a/packages/chat/src/ui/composer/ChatInputRegion.vue +++ b/packages/chat/src/ui/composer/ChatInputRegion.vue @@ -10,6 +10,7 @@ import type { ChatSenderSlotProps, ChatSenderView, ChatSendPayload, + SenderDragPayload, } from '../../types' import type { ResolvedChatModelOptions, ResolvedChatSenderOptions } from '../resolveOptions' @@ -34,6 +35,11 @@ const emit = defineEmits<{ modelSelect: [payload: { id: string | null }] modelFeatureChange: [payload: { id: ChatBuiltInModelFeature; enabled: boolean }] modelReasoningEffortChange: [payload: { effort: string | null }] + senderDragEnter: [payload: SenderDragPayload] + senderDragOver: [payload: SenderDragPayload] + senderDragLeave: [payload: SenderDragPayload] + senderDrop: [payload: SenderDragPayload] + senderPaste: [event: ClipboardEvent] }>() defineSlots<{ @@ -95,6 +101,11 @@ const layoutFooterProps = computed(() => ({ @model-select="emit('modelSelect', $event)" @model-feature-change="emit('modelFeatureChange', $event)" @model-reasoning-effort-change="emit('modelReasoningEffortChange', $event)" + @sender-drag-enter="emit('senderDragEnter', $event)" + @sender-drag-over="emit('senderDragOver', $event)" + @sender-drag-leave="emit('senderDragLeave', $event)" + @sender-drop="emit('senderDrop', $event)" + @sender-paste="emit('senderPaste', $event)" > diff --git a/packages/chat/src/ui/composer/ChatSender.vue b/packages/chat/src/ui/composer/ChatSender.vue index 0daf482ede..b890c80353 100644 --- a/packages/chat/src/ui/composer/ChatSender.vue +++ b/packages/chat/src/ui/composer/ChatSender.vue @@ -14,6 +14,7 @@ import type { ChatSenderView, ChatSenderToolbarItem, ChatSendPayload, + SenderDragPayload, } from '../../types' import type { ResolvedChatModelOptions, ResolvedChatSenderOptions } from '../resolveOptions' @@ -38,6 +39,11 @@ const emit = defineEmits<{ modelSelect: [payload: { id: string | null }] modelFeatureChange: [payload: { id: ChatBuiltInModelFeature; enabled: boolean }] modelReasoningEffortChange: [payload: { effort: string | null }] + senderDragEnter: [payload: SenderDragPayload] + senderDragOver: [payload: SenderDragPayload] + senderDragLeave: [payload: SenderDragPayload] + senderDrop: [payload: SenderDragPayload] + senderPaste: [event: ClipboardEvent] }>() defineSlots<{ @@ -145,6 +151,11 @@ function handleOpenExtensionsPanel() { emit('openExtensionsPanel') } +function handleSenderDrag(event: DragEvent, emitEvent: (payload: SenderDragPayload) => void) { + const target = event.currentTarget + if (target instanceof HTMLElement) emitEvent({ event, target }) +} + function handleOpenExtensionPanel() { if (props.hasRegisteredExtensions) { handleOpenExtensionsPanel() @@ -166,6 +177,11 @@ function getSenderSlotScope(scope: unknown): SenderSlotScope { @submit="handleSubmit" @cancel="emit('cancel')" @clear="emit('clear')" + @dragenter.capture="handleSenderDrag($event, (payload) => emit('senderDragEnter', payload))" + @dragover.capture="handleSenderDrag($event, (payload) => emit('senderDragOver', payload))" + @dragleave.capture="handleSenderDrag($event, (payload) => emit('senderDragLeave', payload))" + @drop.capture="handleSenderDrag($event, (payload) => emit('senderDrop', payload))" + @paste.capture="emit('senderPaste', $event)" > @@ -224,14 +240,18 @@ function getSenderSlotScope(scope: unknown): SenderSlotScope { diff --git a/packages/chat/src/types/ui/options.ts b/packages/chat/src/types/ui/options.ts index c6a8b9a25a..bde12c7954 100644 --- a/packages/chat/src/types/ui/options.ts +++ b/packages/chat/src/types/ui/options.ts @@ -56,6 +56,7 @@ export interface ChatAnchorOptions { export interface ChatLayoutOptions { /** Show the built-in scroll-to-bottom button in the main or footer region. */ readonly showScrollToBottom?: boolean + readonly mobileBreakpoint?: number readonly composer?: Readonly readonly surface?: Readonly readonly emptyState?: 'start' | 'center' diff --git a/packages/chat/src/types/ui/slots.ts b/packages/chat/src/types/ui/slots.ts index 7d841b2461..c922906c2f 100644 --- a/packages/chat/src/types/ui/slots.ts +++ b/packages/chat/src/types/ui/slots.ts @@ -9,6 +9,7 @@ export interface ChatHeaderSlotProps { readonly title: string readonly isEmpty: boolean readonly conversation: ChatConversationView + readonly isMobileViewport: boolean readonly createConversation: () => void readonly isLeftAsideOpen: boolean readonly openLeftAside: () => void diff --git a/packages/chat/src/ui/defaults.ts b/packages/chat/src/ui/defaults.ts index f53495b710..025f494e1c 100644 --- a/packages/chat/src/ui/defaults.ts +++ b/packages/chat/src/ui/defaults.ts @@ -16,6 +16,8 @@ import type { ChatWelcomeOptions, } from '../types' +export const DEFAULT_CHAT_MOBILE_BREAKPOINT = 960 + export interface DefaultChatUIData { conversation: Required> & Pick bubble: { @@ -101,6 +103,7 @@ export function createDefaultChatUIOptions(): DefaultChatUIOptions { surface: { mode: 'normal', }, + mobileBreakpoint: DEFAULT_CHAT_MOBILE_BREAKPOINT, emptyState: 'start', composer: { welcome: 'footer', diff --git a/packages/chat/src/ui/layout/ChatHeader.vue b/packages/chat/src/ui/layout/ChatHeader.vue index b9ec00d814..c3c540fcdb 100644 --- a/packages/chat/src/ui/layout/ChatHeader.vue +++ b/packages/chat/src/ui/layout/ChatHeader.vue @@ -7,6 +7,7 @@ defineProps<{ title: string isEmpty: boolean conversation: Required> & Pick + isMobileViewport: boolean isLeftAsideVisible: boolean isLeftAsideDrawer: boolean isLeftAsideOpen: boolean @@ -54,7 +55,7 @@ function openRightAside() { :toggle-left-aside="toggleLeftAside" :open-right-aside="openRightAside" > - + @@ -132,34 +133,32 @@ function openRightAside() { background: var(--tr-container-bg-hover); } -@media (max-width: 959px) { - .chat-header { - position: relative; - display: grid; - grid-template-columns: 32px minmax(0, 1fr) 32px; - justify-content: stretch; - min-height: 32px; - } - - .chat-header__aside-cell, - .chat-header__actions { - width: 32px; - justify-content: center; - } - - .chat-header__title { - text-align: center; - } - - .chat-header__aside-toggle { - display: inline-flex; - background: var(--tr-container-bg-default); - color: var(--tr-text-secondary); - } - - .chat-header__new.tr-icon-button { - display: inline-flex; - color: var(--tr-text-secondary); - } +.chat-header--mobile { + position: relative; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) 32px; + justify-content: stretch; + min-height: 32px; +} + +.chat-header--mobile .chat-header__aside-cell, +.chat-header--mobile .chat-header__actions { + width: 32px; + justify-content: center; +} + +.chat-header--mobile .chat-header__title { + text-align: center; +} + +.chat-header--mobile .chat-header__aside-toggle { + display: inline-flex; + background: var(--tr-container-bg-default); + color: var(--tr-text-secondary); +} + +.chat-header--mobile .chat-header__new.tr-icon-button { + display: inline-flex; + color: var(--tr-text-secondary); } diff --git a/packages/chat/src/ui/resolveOptions.ts b/packages/chat/src/ui/resolveOptions.ts index 61c7694583..d2d9e10a89 100644 --- a/packages/chat/src/ui/resolveOptions.ts +++ b/packages/chat/src/ui/resolveOptions.ts @@ -1,4 +1,4 @@ -import { createDefaultChatUIOptions } from './defaults' +import { createDefaultChatUIOptions, DEFAULT_CHAT_MOBILE_BREAKPOINT } from './defaults' import { CHAT_MCP_RIGHT_ASIDE_PANEL_ID } from '../types' import type { DefaultChatUIOptions } from './defaults' import type { @@ -26,6 +26,7 @@ import type { export interface ResolvedChatLayoutOptions { showScrollToBottom: boolean surface: ResolvedChatSurfaceOptions + mobileBreakpoint: number emptyState: 'start' | 'center' composer: ResolvedChatComposerLayoutOptions contentMaxWidth: ChatCssSize @@ -130,6 +131,7 @@ function resolveLayout( ): ResolvedChatLayoutOptions { return { showScrollToBottom: options?.showScrollToBottom ?? defaults.showScrollToBottom, + mobileBreakpoint: resolveMobileBreakpoint(options?.mobileBreakpoint ?? defaults.mobileBreakpoint), surface: { mode: options?.surface?.mode ?? defaults.surface.mode, floatingOptions: options?.surface?.floatingOptions, @@ -146,6 +148,10 @@ function resolveLayout( } } +function resolveMobileBreakpoint(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_CHAT_MOBILE_BREAKPOINT +} + function resolveLeftAside( defaults: DefaultChatUIOptions['layout']['leftAside'], options: false | ChatAsideOptions | undefined, From 252f6c6ac9696bc218b3cf03653408e9630a420e Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Mon, 26 Oct 2026 01:36:12 -0700 Subject: [PATCH 13/15] feat(chat): enhance chat runtime with external payloads and improve draft restoration logic --- docs/src/suites/chat-runtime.md | 2 +- docs/src/suites/chat.md | 8 +++- .../composables/useChatRuntimeAdapter.test.ts | 41 ++++++++++++++++++ .../src/composables/useChatRuntimeAdapter.ts | 15 +++++-- .../useChatRuntimeFromConversation.test.ts | 28 +++++++++++- .../runtime/useChatRuntimeFromConversation.ts | 18 +++++--- .../chat/src/ui/messages/ChatMessages.vue | 43 +++++++++++++++---- .../chat/ChatRenderContext.fixture.vue | 15 ++++++- .../component/chat/ChatRenderContext.spec.ts | 11 +++++ 9 files changed, 159 insertions(+), 22 deletions(-) diff --git a/docs/src/suites/chat-runtime.md b/docs/src/suites/chat-runtime.md index acceddebf8..ebbac2911a 100644 --- a/docs/src/suites/chat-runtime.md +++ b/docs/src/suites/chat-runtime.md @@ -686,7 +686,7 @@ const history = useChatHistoryData({ | `ChatStructuredDataItem` | 提交的结构化数据项;`type: string` 标识数据种类,其他字段按该种类提供,字段值类型为 `unknown`。 | | `ChatStructuredData` | `ChatStructuredDataItem[]` | | `ChatToolCall` | `id: string`;`type: 'function' \| string`;`function: { name: string; arguments: string }` | -| `ChatSendPayload` | `text: string`;`structuredData?: ChatStructuredData` | +| `ChatSendPayload` | `text: string`;`structuredData?: ChatStructuredData`;`externalPayloads?: SenderExternalPayload[]`。默认 Kit Runtime 将外部 payload 保留在用户消息的 `externalPayloads` 字段,供消息持久化和 ResponseProvider 使用。 | | `ChatRequestState` | `'idle' \| 'processing' \| 'completed' \| 'paused' \| 'aborted' \| 'error'` | | `ChatProcessingState` | `'requesting' \| 'completing' \| string` | diff --git a/docs/src/suites/chat.md b/docs/src/suites/chat.md index 7940d407db..195246b110 100644 --- a/docs/src/suites/chat.md +++ b/docs/src/suites/chat.md @@ -1021,7 +1021,7 @@ Sender 的文件拖拽与粘贴事件会透传给应用,由应用处理文件 | 字段 | 类型 | 配置说明 | `false` 的作用 | | --------- | ----------------------------- | ------------------------------------------------------------------------- | ----------------------------------------- | -| `layout` | `ChatLayoutOptions` | 未配置的布局字段使用默认值;窗口、输入区和左右侧栏可分别配置。 | 不支持;使用 `leftAside` / `rightAside`。 | +| `layout` | `ChatLayoutOptions` | 未配置的布局字段使用默认值;窗口、输入区和左右侧栏可分别配置。`mobileBreakpoint` 默认 `960`,视口宽度低于该值启用移动布局;非正数或非有限值回退到 `960`。 | 不支持;使用 `leftAside` / `rightAside`。 | | `brand` | `ChatBrandOptions` | 提供的选项覆盖对应的默认名称或图标,默认名称为 `TinyRobot`。 | 不支持。 | | `labels` | `Partial` | 按字段覆盖内置中文文案;也会更新默认欢迎文案和历史菜单文案。 | 不支持。 | | `header` | `false` | 省略时显示默认 Header。 | 移除 Header。 | @@ -1035,6 +1035,8 @@ Sender 的文件拖拽与粘贴事件会透传给应用,由应用处理文件 | `extensions` | `false \| ChatExtensionsOptions` | 注册应用扩展页签,可设置默认页签及二级面板状态。 | 不注册应用扩展页签。 | | `anchor` | `false \| ChatAnchorOptions` | 默认关闭;传入对象开启,见 [anchor 配置](#anchor)。 | 隐藏用户消息目录。 | +`ui.layout.mobileBreakpoint` 控制移动布局断点,默认 `960`(像素);视口宽度小于该值时启用移动布局。非正数或非有限数值回退到 `960`。`layout-header` 插槽参数 `ChatHeaderSlotProps.isMobileViewport` 可用于同步自定义页头布局状态。 + #### `ui.extensions` {#ui-extensions} 通过 `ui.extensions.tabs` 向内置扩展管理器注册 Skills 等应用能力。若同时提供 `mcp`,MCP 和应用页签会在同一面板中展示。原有 MCP 事件保持不变。`mcp` 是保留页签 ID,重复 ID 会被忽略。 @@ -1212,6 +1214,8 @@ toolbar: (defaults) => [ | `composer-before` | `ChatSenderSlotProps` | 在输入框外上方添加内容,不替换输入框;随输入区显示,`ui.sender: false` 时隐藏。 | | `composer-after` | `ChatSenderSlotProps` | 在输入框外下方添加提示、链接等内容,不替换输入框;随输入区显示,`ui.sender: false` 时隐藏。 | | `sender-header` | 无 | 在默认输入框内部上方添加内容。 | +| `sender-content` | `{ editor }` | 替换默认输入框的编辑区域。 | +| `sender-actions-inline` | `SenderSlotScope` | 在默认行内操作区、默认操作之前添加内容。 | | `sender-footer` | `SenderSlotScope` | 在默认多行输入框内部底部左侧添加内容,与模型和工具操作同一区域;提供插入、聚焦和状态方法。 | | `sender-footer-right` | `SenderSlotScope` | 在默认多行输入框内部底部右侧、默认操作按钮前添加内容;提供插入、聚焦和状态方法。 | | `welcome-footer` / `prompts-footer` | 无 | 扩展对应区域。 | @@ -1219,7 +1223,7 @@ toolbar: (defaults) => [ | `bubble-prefix` / `bubble-suffix` / `bubble-after` | `ChatBubbleSlotProps` | 在消息周围添加内容。 | | `bubble-content-footer` | `ChatBubbleContentFooterSlotProps` | 在消息内容下方添加内容。 | -`sender-header`、`sender-footer`、`sender-footer-right` 支持动态显示和更新。控制顶部内容显隐时,将 `v-if` 放在插槽声明或直接子节点上;`v-show` 或子组件内部返回空内容仍可能占位。 +`sender-header`、`sender-content`、`sender-actions-inline`、`sender-footer`、`sender-footer-right` 支持动态显示和更新。控制顶部内容显隐时,将 `v-if` 放在插槽声明或直接子节点上;`v-show` 或子组件内部返回空内容仍可能占位。 ### 自定义消息渲染上下文 diff --git a/packages/chat/src/composables/useChatRuntimeAdapter.test.ts b/packages/chat/src/composables/useChatRuntimeAdapter.test.ts index d789508815..0d75341ade 100644 --- a/packages/chat/src/composables/useChatRuntimeAdapter.test.ts +++ b/packages/chat/src/composables/useChatRuntimeAdapter.test.ts @@ -147,6 +147,47 @@ describe('useChatRuntimeAdapter runtime-scoped drafts', () => { expect(observedErrors).toEqual([{ action: 'send', payload: { text: 'retry' }, error: sendError }]) }) + it('restores a draft when concurrent empty-target sends create the conversation', async () => { + const activeConversation = shallowRef(null) + let finishFirst!: (accepted: boolean) => void + let finishSecond!: (accepted: boolean) => void + const firstResult = new Promise((resolve) => { + finishFirst = resolve + }) + const secondResult = new Promise((resolve) => { + finishSecond = resolve + }) + let calls = 0 + const runtime: ChatRuntime = { + conversations: ref([]), + activeConversation, + composer: {}, + actions: { + send: async () => { + calls++ + if (calls === 2) + activeConversation.value = { id: 'created', title: 'created', messages: [], requestState: 'idle' } + return calls === 1 ? firstResult : secondResult + }, + clearActiveConversation: () => undefined, + createConversation: () => undefined, + switchConversation: () => undefined, + renameConversation: () => undefined, + deleteConversation: () => undefined, + }, + } + const adapter = useChatRuntimeAdapter({ runtime, onActionError: () => undefined }) + adapter.setInputValue('retry') + const first = adapter.send({ text: 'first' }) + const second = adapter.send({ text: 'second' }) + await Promise.resolve() + finishFirst(false) + await first + finishSecond(false) + await second + expect(adapter.inputValue.value).toBe('retry') + }) + it('preserves text entered while a send creates the first conversation', async () => { const activeConversation = shallowRef(null) let finish!: () => void diff --git a/packages/chat/src/composables/useChatRuntimeAdapter.ts b/packages/chat/src/composables/useChatRuntimeAdapter.ts index 097f1c1d0a..b2c552d7af 100644 --- a/packages/chat/src/composables/useChatRuntimeAdapter.ts +++ b/packages/chat/src/composables/useChatRuntimeAdapter.ts @@ -31,7 +31,7 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { const pendingModelFeatureIds = shallowRef>(new Set()) const pendingMcpServerIds = shallowRef>(new Set()) const pendingMcpToolIds = shallowRef>>(new Map()) - let sendingFromEmptyConversation = false + const emptyConversationSendSnapshots = new Set>() watch( runtime, @@ -50,6 +50,8 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { const actionRuntime = runtime.value const conversationId = actionRuntime.activeConversation.value?.id ?? null const navigationRevision = actionRuntime.conversationNavigationRevision?.value + const emptySendSnapshot = + conversationId === null ? new Set(actionRuntime.conversations.value.map((item) => item.id)) : undefined return async (payload: ChatSendPayload) => { const currentConversationId = actionRuntime.activeConversation.value?.id ?? null if ( @@ -60,14 +62,14 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { return false } try { - sendingFromEmptyConversation = conversationId === null + if (emptySendSnapshot) emptyConversationSendSnapshots.add(emptySendSnapshot) return await actionRuntime.actions.send(payload) } catch (error) { options.onActionError({ action: 'send', payload, error }) return false } finally { await nextTick() - sendingFromEmptyConversation = false + if (emptySendSnapshot) emptyConversationSendSnapshots.delete(emptySendSnapshot) } } } @@ -93,7 +95,12 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { nextId !== previousId && runtime.value.conversationNavigationRevision === undefined ) { - if (sendingFromEmptyConversation && previousId === null && nextId !== null) return + const creationSnapshot = + nextId === null ? undefined : [...emptyConversationSendSnapshots].find((snapshot) => !snapshot.has(nextId)) + if (previousId === null && nextId !== null && creationSnapshot) { + emptyConversationSendSnapshots.delete(creationSnapshot) + return + } invalidateDraftForNavigation() } }, diff --git a/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts b/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts index 431b748c12..9794286fef 100644 --- a/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts +++ b/packages/chat/src/runtime/useChatRuntimeFromConversation.test.ts @@ -103,7 +103,7 @@ describe('useChatRuntimeFromConversation', () => { }) it('does not count a custom send-created first conversation as navigation', async () => { - const harness = createHarness('') + const harness = createHarness('existing') harness.activeConversationId.value = null let finish!: (accepted: boolean) => void const pending = new Promise((resolve) => { @@ -122,6 +122,32 @@ describe('useChatRuntimeFromConversation', () => { expect(runtime.conversationNavigationRevision?.value).toBe(0) }) + it('counts selecting an existing conversation during a custom send as navigation', async () => { + const harness = createHarness('existing') + harness.activeConversationId.value = null + const runtime = useChatRuntimeFromConversation({ + conversation: harness.api, + send: async () => { + await harness.api.switchConversation('existing') + return false + }, + }) + + await expect(runtime.actions.send({ text: 'first' })).resolves.toBe(false) + expect(runtime.conversationNavigationRevision?.value).toBe(1) + }) + + it('forwards external payloads through the default Kit message metadata', async () => { + const harness = createHarness() + const payloads = [{ source: 'attachment', payload: { id: 'file-1' } }] + const runtime = useChatRuntimeFromConversation({ conversation: harness.api }) + + await expect(runtime.actions.send({ text: 'inspect', externalPayloads: payloads })).resolves.toBe(true) + expect(harness.items.get('a')?.engine.send).toHaveBeenCalledWith( + expect.objectContaining({ externalPayloads: payloads }), + ) + }) + it('honors beforeSend branches and releases an occupied target on every exit', async () => { const harness = createHarness() const handled = useChatRuntimeFromConversation({ diff --git a/packages/chat/src/runtime/useChatRuntimeFromConversation.ts b/packages/chat/src/runtime/useChatRuntimeFromConversation.ts index 35e7a0e99b..b7846a91b4 100644 --- a/packages/chat/src/runtime/useChatRuntimeFromConversation.ts +++ b/packages/chat/src/runtime/useChatRuntimeFromConversation.ts @@ -48,16 +48,21 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver const { conversation, titleGenerator, beforeSend, send, composer: composerOptions } = options const conversationNavigationRevision = shallowRef(0) const creatingConversationForSend = shallowRef(false) - let awaitingCustomCreation = false + const customSendConversationSnapshots = new Set>() const occupiedTargets = shallowRef(new Set()) let previousConversationId = conversation.activeConversationId.value watch( conversation.activeConversationId, (id) => { if (id !== previousConversationId) { - const createdForCustomSend = awaitingCustomCreation && previousConversationId === null && id !== null + const isCreatedConversation = conversation.conversations.value.some((item) => item.id === id) + const creationSnapshot = + id === null + ? undefined + : [...customSendConversationSnapshots].find((snapshot) => !snapshot.has(id) && isCreatedConversation) + const createdForCustomSend = previousConversationId === null && id !== null && Boolean(creationSnapshot) previousConversationId = id - if (createdForCustomSend) awaitingCustomCreation = false + if (createdForCustomSend && creationSnapshot) customSendConversationSnapshots.delete(creationSnapshot) else if (!creatingConversationForSend.value) conversationNavigationRevision.value++ } }, @@ -145,6 +150,7 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver await targetConversation.engine.send({ role: 'user', content: nextText, + ...(payload.externalPayloads ? { externalPayloads: payload.externalPayloads } : {}), metadata: { createdAt: now, updatedAt: now, @@ -222,12 +228,14 @@ export function useChatRuntimeFromConversation(options: UseChatRuntimeFromConver } if (send) { - if (targetConversationId === null) awaitingCustomCreation = true + const customSendSnapshot = + targetConversationId === null ? new Set(conversation.conversations.value.map((item) => item.id)) : undefined + if (customSendSnapshot) customSendConversationSnapshots.add(customSendSnapshot) try { const result = await send(effectivePayload) return result !== false } finally { - awaitingCustomCreation = false + if (customSendSnapshot) customSendConversationSnapshots.delete(customSendSnapshot) } } if (targetConversationId === null) { diff --git a/packages/chat/src/ui/messages/ChatMessages.vue b/packages/chat/src/ui/messages/ChatMessages.vue index 8444af99db..222e76e22e 100644 --- a/packages/chat/src/ui/messages/ChatMessages.vue +++ b/packages/chat/src/ui/messages/ChatMessages.vue @@ -10,6 +10,7 @@ import { type BubbleBoxAttributesResolver, type BubbleContentAttributesResolver, type BubbleMessage, + type BubbleMessageGroup, type BubbleRoleConfig, type PromptProps, } from '@opentiny/tiny-robot' @@ -79,6 +80,38 @@ const bubbleMessages = computed(() => streamState: resolveStreamState(message, props.request, getMessageStreamState(message)), })), ) +const messageIndexes = computed(() => + props.anchorEnabled ? new Map(bubbleMessages.value.map((message, index) => [message, index])) : undefined, +) +const additionalAnchorIndexes = computed(() => { + if (!props.anchorEnabled) return new Set() + const strategy = props.options.bubbleList.groupStrategy + let groups: BubbleMessageGroup[] = [] + if (typeof strategy === 'function') { + groups = strategy(bubbleMessages.value, props.options.bubbleList.dividerRole ?? 'user') + } else if (strategy === 'consecutive') { + for (const [index, message] of bubbleMessages.value.entries()) { + const previous = groups.at(-1) + if ( + previous && + previous.role === message.role && + !bubbleListProps.value.roleConfigs?.[message.role ?? '']?.hidden + ) { + previous.messages.push(message) + previous.messageIndexes.push(index) + } else { + groups.push({ role: message.role ?? '', messages: [message], messageIndexes: [index] }) + } + } + } + return new Set( + groups.flatMap((group) => + group.role === 'user' + ? group.messageIndexes.slice(1).filter((index) => bubbleMessages.value[index]?.role === 'user') + : [], + ), + ) +}) provide(CHAT_RENDER_ENVIRONMENT, { scrollRoot: computed(() => props.scrollTarget), conversationId: computed(() => props.conversationId), @@ -118,14 +151,8 @@ const contentAttributes: BubbleContentAttributesResolver = (message, content, co const configured = props.options.bubbleProvider?.contentAttributes const attributes = typeof configured === 'function' ? configured(message, content, contentIndex) : configured const item = message as ChatMessageItem - const messageIndex = bubbleMessages.value.indexOf(item) - const isAdditionalAnchorTarget = - props.anchorEnabled && - props.options.bubbleList.groupStrategy === 'consecutive' && - item.role === 'user' && - messageIndex > 0 && - bubbleMessages.value[messageIndex - 1]?.role === 'user' && - (contentIndex ?? 0) === 0 + const messageIndex = messageIndexes.value?.get(item) ?? -1 + const isAdditionalAnchorTarget = additionalAnchorIndexes.value.has(messageIndex) && (contentIndex ?? 0) === 0 const boxAttributes = isAdditionalAnchorTarget ? resolveBoxAttributes([item], content, 0) : undefined return mergeProps(attributes ?? {}, { ...(isAdditionalAnchorTarget diff --git a/packages/test/component/chat/ChatRenderContext.fixture.vue b/packages/test/component/chat/ChatRenderContext.fixture.vue index bfb2f68c80..ed2ea9f1d9 100644 --- a/packages/test/component/chat/ChatRenderContext.fixture.vue +++ b/packages/test/component/chat/ChatRenderContext.fixture.vue @@ -10,6 +10,7 @@ const props = withDefaults( consecutiveUsers?: boolean customMain?: boolean callerAnchors?: boolean + customConsecutiveUsers?: boolean callbackAttributes?: boolean floating?: boolean width?: number @@ -68,7 +69,19 @@ const ui = computed(() => ({ bubble: { bubbleList: { contentRenderMode: 'split', - ...(props.consecutiveUsers ? { groupStrategy: 'consecutive' as const } : {}), + ...(props.customConsecutiveUsers + ? { + groupStrategy: (items: import('@opentiny/tiny-robot').BubbleMessage[]) => [ + { + role: 'user', + messages: items, + messageIndexes: items.map((_, index) => index), + }, + ], + } + : props.consecutiveUsers + ? { groupStrategy: 'consecutive' as const } + : {}), }, bubbleProvider: { fallbackContentRenderer: markRaw(RenderContextProbe), diff --git a/packages/test/component/chat/ChatRenderContext.spec.ts b/packages/test/component/chat/ChatRenderContext.spec.ts index 884823c7cc..cc242fb027 100644 --- a/packages/test/component/chat/ChatRenderContext.spec.ts +++ b/packages/test/component/chat/ChatRenderContext.spec.ts @@ -87,6 +87,17 @@ test('Anchor gives every message in a consecutive user group its own target', as ) }) +test('Anchor uses actual custom group results for targets', async ({ mount, page }) => { + await page.setViewportSize({ width: 1200, height: 800 }) + const component = await mount(ChatRenderContext, { props: { consecutiveUsers: true, customConsecutiveUsers: true } }) + const items = component.locator('.tr-anchor__item') + const targets = component.locator('.tr-chat-anchor-target[data-anchor-id]') + await expect(items).toHaveCount(2) + await expect(targets).toHaveCount(2) + await items.nth(1).click() + await expect(targets.nth(1)).toHaveClass(/tr-chat-anchor-target-active/) +}) + test('Anchor combines callback attributes and injects only the first split target', async ({ mount, page }) => { await page.setViewportSize({ width: 1200, height: 800 }) const component = await mount(ChatRenderContext, { props: { callbackAttributes: true } }) From 87208a3b059aa222dd48cbc3b25a29a641d71aa2 Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Tue, 27 Oct 2026 00:12:46 -0700 Subject: [PATCH 14/15] feat(chat): implement message grouping logic and enhance message identity tests --- .../composables/useChatRuntimeAdapter.test.ts | 52 +++++++ .../src/composables/useChatRuntimeAdapter.ts | 1 + .../chat/src/ui/messages/ChatMessages.vue | 39 ++---- .../src/ui/messages/messageIdentity.test.ts | 23 +++- .../chat/src/ui/messages/messageIdentity.ts | 9 ++ packages/components/src/bubble/BubbleList.vue | 114 +--------------- packages/components/src/bubble/index.ts | 1 + .../components/src/bubble/messageGroups.ts | 128 ++++++++++++++++++ packages/components/src/index.ts | 1 + packages/kit/src/message/adapters/vue.ts | 6 +- packages/kit/src/message/test/vue.test.ts | 21 ++- 11 files changed, 254 insertions(+), 141 deletions(-) create mode 100644 packages/components/src/bubble/messageGroups.ts diff --git a/packages/chat/src/composables/useChatRuntimeAdapter.test.ts b/packages/chat/src/composables/useChatRuntimeAdapter.test.ts index 0d75341ade..d4f129b23e 100644 --- a/packages/chat/src/composables/useChatRuntimeAdapter.test.ts +++ b/packages/chat/src/composables/useChatRuntimeAdapter.test.ts @@ -318,4 +318,56 @@ describe('useChatRuntimeAdapter runtime-scoped drafts', () => { resolveFirstSend(true) await send }) + + it('does not match replacement runtime navigation against a pending empty send snapshot', async () => { + let finishFirstSend!: (accepted: boolean) => void + const firstActiveConversation = shallowRef(null) + const firstRuntime: ChatRuntime = { + conversations: ref([]), + activeConversation: firstActiveConversation, + composer: {}, + actions: { + send: () => + new Promise((resolve) => { + finishFirstSend = resolve + }), + clearActiveConversation: () => undefined, + createConversation: () => undefined, + switchConversation: () => undefined, + renameConversation: () => undefined, + deleteConversation: () => undefined, + }, + } + const secondActiveConversation = shallowRef(null) + const secondRuntime: ChatRuntime = { + conversations: ref([{ id: 'existing', title: 'existing' }]), + activeConversation: secondActiveConversation, + composer: {}, + actions: { + send: async () => true, + clearActiveConversation: () => undefined, + createConversation: () => undefined, + switchConversation: () => undefined, + renameConversation: () => undefined, + deleteConversation: () => undefined, + }, + } + const runtime = shallowRef(firstRuntime) + const adapter = useChatRuntimeAdapter({ runtime, onActionError: () => undefined }) + const send = adapter.send({ text: 'first runtime send' }) + await Promise.resolve() + + runtime.value = secondRuntime + adapter.setInputValue('replacement runtime draft') + secondActiveConversation.value = { + id: 'existing', + title: 'existing', + messages: [], + requestState: 'idle', + } + + expect(adapter.inputValue.value).toBe('') + finishFirstSend(true) + await send + }) }) diff --git a/packages/chat/src/composables/useChatRuntimeAdapter.ts b/packages/chat/src/composables/useChatRuntimeAdapter.ts index b2c552d7af..9b0635e569 100644 --- a/packages/chat/src/composables/useChatRuntimeAdapter.ts +++ b/packages/chat/src/composables/useChatRuntimeAdapter.ts @@ -41,6 +41,7 @@ export function useChatRuntimeAdapter(options: UseChatRuntimeAdapterOptions) { pendingModelFeatureIds.value = new Set() pendingMcpServerIds.value = new Set() pendingMcpToolIds.value = new Map() + emptyConversationSendSnapshots.clear() invalidateDraftForNavigation() }, { flush: 'sync' }, diff --git a/packages/chat/src/ui/messages/ChatMessages.vue b/packages/chat/src/ui/messages/ChatMessages.vue index 222e76e22e..1d76cfa7a0 100644 --- a/packages/chat/src/ui/messages/ChatMessages.vue +++ b/packages/chat/src/ui/messages/ChatMessages.vue @@ -7,6 +7,7 @@ import { TrPrompts, TrWelcome, useAutoScroll, + getBubbleMessageGroups, type BubbleBoxAttributesResolver, type BubbleContentAttributesResolver, type BubbleMessage, @@ -16,7 +17,12 @@ import { } from '@opentiny/tiny-robot' import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs' import { CHAT_RENDER_ENVIRONMENT } from '../../composables/useChatRenderContext' -import { createMessageIdentity, getMessageAnchorText, resolveStreamState } from './messageIdentity' +import { + createMessageIdentity, + getAdditionalAnchorIndexes, + getMessageAnchorText, + resolveStreamState, +} from './messageIdentity' import { useChatStreamState } from './useChatStreamState' import type { ChatRequestView } from '../../types' import type { ChatBubbleEventPayload, ChatBubbleStateChangePayload, ChatLabels, ChatMessageItem } from '../../types' @@ -85,32 +91,13 @@ const messageIndexes = computed(() => ) const additionalAnchorIndexes = computed(() => { if (!props.anchorEnabled) return new Set() - const strategy = props.options.bubbleList.groupStrategy - let groups: BubbleMessageGroup[] = [] - if (typeof strategy === 'function') { - groups = strategy(bubbleMessages.value, props.options.bubbleList.dividerRole ?? 'user') - } else if (strategy === 'consecutive') { - for (const [index, message] of bubbleMessages.value.entries()) { - const previous = groups.at(-1) - if ( - previous && - previous.role === message.role && - !bubbleListProps.value.roleConfigs?.[message.role ?? '']?.hidden - ) { - previous.messages.push(message) - previous.messageIndexes.push(index) - } else { - groups.push({ role: message.role ?? '', messages: [message], messageIndexes: [index] }) - } - } - } - return new Set( - groups.flatMap((group) => - group.role === 'user' - ? group.messageIndexes.slice(1).filter((index) => bubbleMessages.value[index]?.role === 'user') - : [], - ), + const groups: BubbleMessageGroup[] = getBubbleMessageGroups( + bubbleMessages.value, + props.options.bubbleList.groupStrategy, + props.options.bubbleList.dividerRole, + bubbleListProps.value.roleConfigs, ) + return getAdditionalAnchorIndexes(groups, bubbleMessages.value) }) provide(CHAT_RENDER_ENVIRONMENT, { scrollRoot: computed(() => props.scrollTarget), diff --git a/packages/chat/src/ui/messages/messageIdentity.test.ts b/packages/chat/src/ui/messages/messageIdentity.test.ts index afc2bf81ad..d7bfbd409c 100644 --- a/packages/chat/src/ui/messages/messageIdentity.test.ts +++ b/packages/chat/src/ui/messages/messageIdentity.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' import { reactive } from 'vue' -import { createMessageIdentity, getMessageAnchorText, resolveStreamState } from './messageIdentity' +import { getBubbleMessageGroups } from '../../../../components/src/bubble/messageGroups' +import type { ChatMessageItem } from '../../types' +import { + createMessageIdentity, + getAdditionalAnchorIndexes, + getMessageAnchorText, + resolveStreamState, +} from './messageIdentity' describe('Chat message identity', () => { it('keeps explicit keys across immutable replacement and server ID changes', () => { @@ -86,3 +93,17 @@ describe('Chat user message anchor text', () => { expect(getMessageAnchorText({ role: 'user', content: '请保留当前输入草稿' })).toBe('请保留当前输入草稿') }) }) + +describe('Chat user message anchor targets', () => { + it('includes consecutive user messages when assistant is the divider role', () => { + const messages = [ + { role: 'user', content: 'first' }, + { role: 'user', content: 'second' }, + { role: 'assistant', content: 'reply' }, + ] as ChatMessageItem[] + const groups = getBubbleMessageGroups(messages, 'divider', 'assistant') + + expect(groups.map((group) => group.messageIndexes)).toEqual([[0, 1], [2]]) + expect([...getAdditionalAnchorIndexes(groups, messages)]).toEqual([1]) + }) +}) diff --git a/packages/chat/src/ui/messages/messageIdentity.ts b/packages/chat/src/ui/messages/messageIdentity.ts index 250d7393a5..29b93256e2 100644 --- a/packages/chat/src/ui/messages/messageIdentity.ts +++ b/packages/chat/src/ui/messages/messageIdentity.ts @@ -1,4 +1,5 @@ import { toRaw } from 'vue' +import type { BubbleMessageGroup } from '@opentiny/tiny-robot' import type { ChatMessageItem, ChatRequestView } from '../../types' export type ChatStreamMessageState = 'history' | 'current' | 'unknown' @@ -44,3 +45,11 @@ export function getMessageAnchorText(message: ChatMessageItem): string { .trim() return '' } + +export function getAdditionalAnchorIndexes(groups: BubbleMessageGroup[], messages: ChatMessageItem[]): Set { + return new Set( + groups.flatMap((group) => + group.role === 'user' ? group.messageIndexes.slice(1).filter((index) => messages[index]?.role === 'user') : [], + ), + ) +} diff --git a/packages/components/src/bubble/BubbleList.vue b/packages/components/src/bubble/BubbleList.vue index c3a3ae7bde..7460463988 100644 --- a/packages/components/src/bubble/BubbleList.vue +++ b/packages/components/src/bubble/BubbleList.vue @@ -4,6 +4,7 @@ import { useAutoScroll } from '../shared/composables' import BubbleItem from './BubbleItem.vue' import { setupBubbleStore, useCopyCleanup } from './composables' import { BUBBLE_LIST_CONTEXT_KEY } from './constants' +import { getBubbleMessageGroups } from './messageGroups' import type { BubbleEvent, BubbleListProps, BubbleListSlots, BubbleMessage, BubbleMessageGroup } from './index.type' const props = withDefaults(defineProps(), { @@ -38,16 +39,6 @@ setupBubbleStore() // 提供 bubble list 上下文,标识 Bubble 组件在 BubbleList 下 provide(BUBBLE_LIST_CONTEXT_KEY, true) -/** - * 判断一个 role 是否是 hidden - */ -const isRoleHidden = (role: string | undefined): boolean => { - if (!role) { - return false - } - return Boolean(props.roleConfigs?.[role]?.hidden) -} - const scrollContainerRef = ref(null) const contentRef = ref(null) const lastMessage = computed(() => props.messages.at(-1)) @@ -71,109 +62,8 @@ watch( // 设置复制事件处理器,清理复制文本中的多余换行 useCopyCleanup(scrollContainerRef) -/** - * 按角色分组 - * - 连续相同角色的消息会被合并到一组 - * - hidden 的消息需要单独分组,连续的 hidden 可以同一组 - */ -const groupByRole = (messages: BubbleMessage[]): BubbleMessageGroup[] => { - const groups: BubbleMessageGroup[] = [] - let isLastGroupHidden = false - - for (const [index, message] of messages.entries()) { - const lastGroup = groups[groups.length - 1] - const messageRole = message.role || '' - const isMessageHidden = isRoleHidden(message.role) - - // 满足以下条件之一则添加到上一组: - // 1. 连续的 hidden 消息(不管角色是否相同) - // 2. 角色相同且 hidden 状态相同 - if ( - lastGroup && - ((isLastGroupHidden && isMessageHidden) || - (lastGroup.role === messageRole && isLastGroupHidden === isMessageHidden)) - ) { - lastGroup.messages.push(message) - lastGroup.messageIndexes.push(index) - } else { - // 创建新的分组 - groups.push({ - role: messageRole, - messages: [message], - messageIndexes: [index], - }) - } - // 创建新组后统一更新 hidden 状态 - isLastGroupHidden = isMessageHidden - } - - return groups -} - -/** - * 按分割角色分组 - * - 分割角色消息每条单独分组 - * - 非分割角色消息会被分到一组,直到遇到下一个分割角色消息 - * - hidden 的消息需要单独分组,连续的 hidden 可以同一组 - */ -const groupByDivider = (messages: BubbleMessage[], dividerRole: string): BubbleMessageGroup[] => { - const groups: BubbleMessageGroup[] = [] - let isLastGroupHidden = false - - for (const [index, message] of messages.entries()) { - const lastGroup = groups[groups.length - 1] - const isDivider = message.role === dividerRole - const messageRole = message.role || '' - const isMessageHidden = isRoleHidden(message.role) - - // 满足以下条件之一则添加到上一组: - // 1. 连续的 hidden 消息(不管分割/非分割类型是否相同) - // 2. 分割/非分割类型相同且 hidden 状态相同 - if ( - lastGroup && - // divider 消息(分割角色)永远不与任何组进行合并 - !isDivider && - // divider 组(分割角色)不允许被追加消息,确保 divider 组永远只有 1 条 message - lastGroup.role !== dividerRole && - // hidden / 非 hidden 分组隔离 - isLastGroupHidden === isMessageHidden - ) { - lastGroup.messages.push(message) - lastGroup.messageIndexes.push(index) - } else { - // 创建新的分组 - groups.push({ - role: isDivider ? dividerRole : messageRole, - messages: [message], - messageIndexes: [index], - }) - } - // 创建新组后统一更新 hidden 状态 - isLastGroupHidden = isMessageHidden - } - - return groups -} - -/** - * 根据分组策略计算消息分组 - */ const messageGroups = computed(() => { - if (props.messages.length === 0) { - return [] - } - - // 如果是自定义函数,直接调用 - if (typeof props.groupStrategy === 'function') { - return props.groupStrategy(props.messages, props.dividerRole) - } - - // 使用预定义策略 - if (props.groupStrategy === 'consecutive') { - return groupByRole(props.messages) - } else { - return groupByDivider(props.messages, props.dividerRole) - } + return getBubbleMessageGroups(props.messages, props.groupStrategy, props.dividerRole, props.roleConfigs) }) const resolveMessageIndex = (group: BubbleMessageGroup, messageIndex: number): number | undefined => { diff --git a/packages/components/src/bubble/index.ts b/packages/components/src/bubble/index.ts index 447962f0d2..6c892f2fb2 100644 --- a/packages/components/src/bubble/index.ts +++ b/packages/components/src/bubble/index.ts @@ -47,3 +47,4 @@ export { } from './composables' export { BubbleRendererMatchPriority } from './constants' export { BubbleRenderers } from './renderers/allRenderers' +export { getBubbleMessageGroups } from './messageGroups' diff --git a/packages/components/src/bubble/messageGroups.ts b/packages/components/src/bubble/messageGroups.ts new file mode 100644 index 0000000000..5880f844e2 --- /dev/null +++ b/packages/components/src/bubble/messageGroups.ts @@ -0,0 +1,128 @@ +import type { BubbleListProps, BubbleMessage, BubbleMessageGroup, BubbleRoleConfig } from './index.type' + +/** + * 判断一个 role 是否是 hidden + */ +const isRoleHidden = (role: string | undefined, roleConfigs?: Record): boolean => { + if (!role) { + return false + } + return Boolean(roleConfigs?.[role]?.hidden) +} + +/** + * 按角色分组 + * - 连续相同角色的消息会被合并到一组 + * - hidden 的消息需要单独分组,连续的 hidden 可以同一组 + */ +const groupByRole = ( + messages: BubbleMessage[], + roleConfigs?: Record, +): BubbleMessageGroup[] => { + const groups: BubbleMessageGroup[] = [] + let isLastGroupHidden = false + + for (const [index, message] of messages.entries()) { + const lastGroup = groups[groups.length - 1] + const messageRole = message.role || '' + const isMessageHidden = isRoleHidden(message.role, roleConfigs) + + // 满足以下条件之一则添加到上一组: + // 1. 连续的 hidden 消息(不管角色是否相同) + // 2. 角色相同且 hidden 状态相同 + if ( + lastGroup && + ((isLastGroupHidden && isMessageHidden) || + (lastGroup.role === messageRole && isLastGroupHidden === isMessageHidden)) + ) { + lastGroup.messages.push(message) + lastGroup.messageIndexes.push(index) + } else { + // 创建新的分组 + groups.push({ + role: messageRole, + messages: [message], + messageIndexes: [index], + }) + } + // 创建新组后统一更新 hidden 状态 + isLastGroupHidden = isMessageHidden + } + + return groups +} + +/** + * 按分割角色分组 + * - 分割角色消息每条单独分组 + * - 非分割角色消息会被分到一组,直到遇到下一个分割角色消息 + * - hidden 的消息需要单独分组,连续的 hidden 可以同一组 + */ +const groupByDivider = ( + messages: BubbleMessage[], + dividerRole: string, + roleConfigs?: Record, +): BubbleMessageGroup[] => { + const groups: BubbleMessageGroup[] = [] + let isLastGroupHidden = false + + for (const [index, message] of messages.entries()) { + const lastGroup = groups[groups.length - 1] + const isDivider = message.role === dividerRole + const messageRole = message.role || '' + const isMessageHidden = isRoleHidden(message.role, roleConfigs) + + // 满足以下条件之一则添加到上一组: + // 1. 连续的 hidden 消息(不管分割/非分割类型是否相同) + // 2. 分割/非分割类型相同且 hidden 状态相同 + if ( + lastGroup && + // divider 消息(分割角色)永远不与任何组进行合并 + !isDivider && + // divider 组(分割角色)不允许被追加消息,确保 divider 组永远只有 1 条 message + lastGroup.role !== dividerRole && + // hidden / 非 hidden 分组隔离 + isLastGroupHidden === isMessageHidden + ) { + lastGroup.messages.push(message) + lastGroup.messageIndexes.push(index) + } else { + // 创建新的分组 + groups.push({ + role: isDivider ? dividerRole : messageRole, + messages: [message], + messageIndexes: [index], + }) + } + // 创建新组后统一更新 hidden 状态 + isLastGroupHidden = isMessageHidden + } + + return groups +} + +/** + * 根据分组策略计算消息分组 + */ +export function getBubbleMessageGroups( + messages: BubbleMessage[], + groupStrategy: BubbleListProps['groupStrategy'] = 'divider', + dividerRole = 'user', + roleConfigs?: Record, +): BubbleMessageGroup[] { + if (messages.length === 0) { + return [] + } + + // 如果是自定义函数,直接调用 + if (typeof groupStrategy === 'function') { + return groupStrategy(messages, dividerRole) + } + + // 使用预定义策略 + if (groupStrategy === 'consecutive') { + return groupByRole(messages, roleConfigs) + } + + return groupByDivider(messages, dividerRole, roleConfigs) +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 1e12bd1f56..5ee28141b1 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -73,6 +73,7 @@ export type { SenderContentRegister } from './shared/composables/useSenderConten export { BubbleRendererMatchPriority, BubbleRenderers, + getBubbleMessageGroups, useBubbleBoxRenderer, useBubbleContentRenderer, useBubbleErrorRenderer, diff --git a/packages/kit/src/message/adapters/vue.ts b/packages/kit/src/message/adapters/vue.ts index aae198660a..656de4d95f 100644 --- a/packages/kit/src/message/adapters/vue.ts +++ b/packages/kit/src/message/adapters/vue.ts @@ -28,7 +28,11 @@ const toPlainValue = (value: T): T => { return rawValue.map((item) => toPlainValue(item)) as T } - if (rawValue && typeof rawValue === 'object') { + if ( + rawValue && + typeof rawValue === 'object' && + (Object.getPrototypeOf(rawValue) === Object.prototype || Object.getPrototypeOf(rawValue) === null) + ) { const result: Record = {} for (const [key, item] of Object.entries(rawValue)) { diff --git a/packages/kit/src/message/test/vue.test.ts b/packages/kit/src/message/test/vue.test.ts index b3bdc1c0b7..091a356a11 100644 --- a/packages/kit/src/message/test/vue.test.ts +++ b/packages/kit/src/message/test/vue.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { toRaw, watch } from 'vue' import { createVueMessageAdapter } from '../adapters/vue' import { createMessageEngine } from '../core/engine' @@ -144,4 +144,23 @@ describe('createVueMessageAdapter', () => { const distinctSnapshots = contentSnapshots.filter((content, index) => content !== contentSnapshots[index - 1]) expect(distinctSnapshots).toEqual(['', 'hello', 'hello world']) }) + + it('preserves File objects in the response provider request body', async () => { + const file = new File(['attachment'], 'attachment.txt', { type: 'text/plain' }) + const responseProvider = vi.fn(mockResponseProvider('ok')) + const adapter = createVueMessageAdapter() + const engine = createMessageEngine(adapter, { + plugins: silentDefaultPlugins, + responseProvider, + }) + + await engine.send({ + role: 'user', + content: 'attachment', + file, + }) + + const requestMessage = responseProvider.mock.calls[0][0].messages[0] + expect(requestMessage.file).toBe(file) + }) }) From 52587493f179198499f0d8325c5fe4c9b37e59f2 Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Tue, 27 Oct 2026 00:43:31 -0700 Subject: [PATCH 15/15] feat(chat): enhance message handling with assistant divider and external payloads support --- packages/kit/src/message/test/vue.test.ts | 12 ++++++-- .../chat/ChatRenderContext.fixture.vue | 30 +++++++++++-------- .../component/chat/ChatRenderContext.spec.ts | 24 +++++++++++++++ 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/packages/kit/src/message/test/vue.test.ts b/packages/kit/src/message/test/vue.test.ts index 091a356a11..b5dffeb78c 100644 --- a/packages/kit/src/message/test/vue.test.ts +++ b/packages/kit/src/message/test/vue.test.ts @@ -145,8 +145,14 @@ describe('createVueMessageAdapter', () => { expect(distinctSnapshots).toEqual(['', 'hello', 'hello world']) }) - it('preserves File objects in the response provider request body', async () => { + it('preserves attachment File objects in the response provider request body', async () => { const file = new File(['attachment'], 'attachment.txt', { type: 'text/plain' }) + const externalPayloads = [ + { + source: 'attachment', + payload: [{ id: 'attachment-1', name: file.name, size: file.size, rawFile: file }], + }, + ] const responseProvider = vi.fn(mockResponseProvider('ok')) const adapter = createVueMessageAdapter() const engine = createMessageEngine(adapter, { @@ -157,10 +163,10 @@ describe('createVueMessageAdapter', () => { await engine.send({ role: 'user', content: 'attachment', - file, + externalPayloads, }) const requestMessage = responseProvider.mock.calls[0][0].messages[0] - expect(requestMessage.file).toBe(file) + expect(requestMessage.externalPayloads[0].payload[0].rawFile).toBe(file) }) }) diff --git a/packages/test/component/chat/ChatRenderContext.fixture.vue b/packages/test/component/chat/ChatRenderContext.fixture.vue index ed2ea9f1d9..060eeef2d5 100644 --- a/packages/test/component/chat/ChatRenderContext.fixture.vue +++ b/packages/test/component/chat/ChatRenderContext.fixture.vue @@ -8,6 +8,7 @@ const props = withDefaults( defineProps<{ anchor?: boolean consecutiveUsers?: boolean + assistantDivider?: boolean customMain?: boolean callerAnchors?: boolean customConsecutiveUsers?: boolean @@ -32,6 +33,7 @@ const messages = shallowRef( ? [ { id: 'adjacent-user-1', role: 'user', content: 'Adjacent question one' }, { id: 'adjacent-user-2', role: 'user', content: 'Adjacent question two' }, + ...(props.assistantDivider ? [{ id: 'assistant-divider', role: 'assistant', content: 'x'.repeat(1000) }] : []), ] : Array.from({ length: 6 }, (_, index) => [ { @@ -69,19 +71,21 @@ const ui = computed(() => ({ bubble: { bubbleList: { contentRenderMode: 'split', - ...(props.customConsecutiveUsers - ? { - groupStrategy: (items: import('@opentiny/tiny-robot').BubbleMessage[]) => [ - { - role: 'user', - messages: items, - messageIndexes: items.map((_, index) => index), - }, - ], - } - : props.consecutiveUsers - ? { groupStrategy: 'consecutive' as const } - : {}), + ...(props.assistantDivider + ? { groupStrategy: 'divider' as const, dividerRole: 'assistant' } + : props.customConsecutiveUsers + ? { + groupStrategy: (items: import('@opentiny/tiny-robot').BubbleMessage[]) => [ + { + role: 'user', + messages: items, + messageIndexes: items.map((_, index) => index), + }, + ], + } + : props.consecutiveUsers + ? { groupStrategy: 'consecutive' as const } + : {}), }, bubbleProvider: { fallbackContentRenderer: markRaw(RenderContextProbe), diff --git a/packages/test/component/chat/ChatRenderContext.spec.ts b/packages/test/component/chat/ChatRenderContext.spec.ts index cc242fb027..e5fee6279d 100644 --- a/packages/test/component/chat/ChatRenderContext.spec.ts +++ b/packages/test/component/chat/ChatRenderContext.spec.ts @@ -98,6 +98,30 @@ test('Anchor uses actual custom group results for targets', async ({ mount, page await expect(targets.nth(1)).toHaveClass(/tr-chat-anchor-target-active/) }) +test('Anchor navigates every user message grouped by an assistant divider', async ({ mount, page }) => { + await page.setViewportSize({ width: 1200, height: 800 }) + const component = await mount(ChatRenderContext, { props: { consecutiveUsers: true, assistantDivider: true } }) + const items = component.locator('.tr-anchor__item') + const targets = component.locator('.tr-chat-anchor-target[data-anchor-id]') + const scrollRoot = component.locator('.chat-main-scroll-host') + + await expect(items).toHaveCount(2) + await expect(targets).toHaveCount(2) + expect(await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-item-id')))).toEqual( + await targets.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-anchor-id'))), + ) + + await scrollRoot.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + const initialScrollTop = await scrollRoot.evaluate((element) => element.scrollTop) + expect(initialScrollTop).toBeGreaterThan(0) + + await items.nth(1).click() + await expect(targets.nth(1)).toHaveClass(/tr-chat-anchor-target-active/) + await expect.poll(() => scrollRoot.evaluate((element) => element.scrollTop)).toBeLessThan(initialScrollTop) +}) + test('Anchor combines callback attributes and injects only the first split target', async ({ mount, page }) => { await page.setViewportSize({ width: 1200, height: 800 }) const component = await mount(ChatRenderContext, { props: { callbackAttributes: true } })
- @ 用于插入对象名称,不会切换模型;模板中的文本块可以编辑,下拉字段可以选择。内容超过 300 字时无法提交。 + {{ helpText }}
{{ helpText }}
将图片拖到输入框,或复制图片后粘贴。此示例只显示文件名,不会上传文件。
+ {{ receivedFiles.length ? `收到:${receivedFiles.join('、')}` : '尚未收到文件' }} +