背景
我是一名视障用户,完全依赖 TalkBack(Android 屏幕阅读器)使用 Operit。在 AI 对话页面浏览消息时,遇到了严重的无障碍问题,导致几乎无法有效阅读对话内容。连最基本的消息浏览都无法正常完成,更谈不上使用其他操作功能。
经过对源码的分析,我将问题整理如下。
具体问题
1. 消息容器缺少语义分组 - 一条消息被拆成碎片(最严重)
相关文件: AiMessageComposable.kt、UserMessageComposable.kt
AiMessageComposable 和 UserMessageComposable 的最外层 Column 均未设置 semantics(mergeDescendants = true)。
TalkBack 下的表现: 一条 AI 消息内的 "Response" 标题、正文文本、工具调用块、工具结果块被拆成多个独立节点逐个朗读。用户听到的是一堆碎片化的内容,完全无法感知"这是一条完整的 AI 回复"。
建议修复: 在每个消息 Composable 的根容器上添加 Modifier.semantics(mergeDescendants = true),并设置包含发送者角色和内容摘要的 contentDescription,例如 "AI回复:{内容前N字}" / "用户消息:{内容前N字}"。
2. 代码块等特殊内容被合成一个超长焦点,无法辨识结构(非常严重)
相关文件: StreamMarkdownRenderer、AiMessageComposable.kt
AI 回复中的 Markdown 代码块、表格等特殊内容,在 StreamMarkdownRenderer 渲染后,TalkBack 将其当作一个巨大的纯文本节点朗读。用户无法知道:
- 这是一个代码块还是普通文本
- 代码块从哪里开始、到哪里结束
- 代码的语言是什么
整段内容被"粘"在一起,TalkBack 一直读下去,极其冗长且无法快速跳过。
TalkBack 下的表现: 遇到一段 AI 回复包含代码块时,TalkBack 会把正文+代码+正文全部连在一起读,没有任何结构标记,体验极其痛苦。
建议修复:
- 为代码块添加
semantics { contentDescription = "代码块(语言:Kotlin)" } 或类似的结构化标签
- 代码块应作为独立的无障碍节点,与前后文本分开
- 考虑使用
heading() 为代码块标题添加语义,让用户可以通过 TalkBack 的标题导航快速跳过
3. 工具调用/结果与正文混在同一无障碍树
相关文件: CustomXmlRenderer.kt、AiMessageComposable.kt
AI 消息通过 StreamMarkdownRenderer 渲染,其中的 <tool>、<tool_result>、<think> 等 XML 块虽然各自有 contentDescription(如"工具调用块"),但它们和正文文本是平级节点。TalkBack 读起来就是"正文…工具调用块…正文…工具结果块…",无法区分哪些是 AI 的回复、哪些是工具执行过程。
更严重的是,当一个 think 块折叠后,其中的工具调用和结果会通过 AnimatedVisibility(visible = false) 隐藏,TalkBack 完全读不到工具名称和执行结果,只剩一句"N个工具"的标题。
建议修复: 工具调用和结果应作为独立的无障碍区域,与 AI 正文明确区分。可以通过 semantics(mergeDescendants = true) 将每个工具调用+其结果合并为一个节点,并设置描述如 "工具调用:{工具名},执行{成功/失败}"。
4. "Response" / "Prompt" 标题没有 heading 语义
相关文件: AiMessageComposable.kt、UserMessageComposable.kt
消息标题("Response"、"Prompt")只是普通 Text,没有 Modifier.semantics { heading() }。TalkBack 不把它们当作标题,视障用户无法使用 TalkBack 的标题导航功能在消息之间快速跳转。
建议修复: 为消息标题添加 heading() 语义,让用户可以通过 TalkBack 的"按标题导航"功能快速在消息之间跳转。
5. 流式响应缺少 liveRegion,TalkBack 不播报更新
相关文件: AiMessageComposable.kt、ChatArea.kt
AI 正在流式输出时,内容动态更新但未设置 liveRegion。TalkBack 不会主动播报新内容,用户不知道消息正在更新,也不知道何时更新完成。
建议修复: 对正在流式输出的消息设置 Modifier.semantics { liveRegion = LiveRegionMode.Polite },让 TalkBack 在内容更新时播报。
6. 消息操作对 TalkBack 用户完全不可达
相关文件: ChatArea.kt(MessageItem 函数)
连基本的消息浏览都无法正常完成,MessageItem 的 combinedClickable 提供的长按菜单(复制/删除/重新生成/回滚等)更是无从谈起。长按手势对 TalkBack 用户而言不直观,且没有提供无障碍自定义操作(CustomAccessibilityAction)。
建议修复: 在消息浏览体验修复的基础上,为消息项添加 customActions,让 TalkBack 用户可以通过双击激活菜单中的操作,如复制、删除、重新生成等。
总体建议:基于 Compose Semantics API 重新构建消息列表的无障碍结构
当前的消息渲染架构将所有内容(正文、代码块、工具调用、工具结果、思考过程)都通过 StreamMarkdownRenderer 作为一个扁平的 Markdown 流来渲染。这种方式在视觉上表现良好,但在无障碍语义树上产生了一个无结构的扁平节点列表——有些该分开的内容粘在一起(代码块),有些不该分开的内容却被拆碎(消息标题与正文)。
建议从架构层面,直接利用 Compose 的 Semantics API 重新构建消息列表的无障碍结构:
-
每条消息作为一个语义单元:使用 semantics(mergeDescendants = true) 将一条消息的所有内容(标题+正文+工具调用+工具结果)合并为一个 TalkBack 可朗读的节点,并附带角色标签("AI回复"/"用户消息")和内容摘要。
-
消息内的结构化内容作为子语义节点:代码块、工具调用、工具结果等应在合并的语义节点内部拥有各自的 contentDescription,让用户可以在消息内部按结构导航。
-
消息标题使用 heading() 语义:让 TalkBack 用户可以通过标题导航在消息之间快速跳转。
-
流式消息使用 liveRegion:让 TalkBack 在消息更新时自动播报。
-
消息操作使用 customActions:让 TalkBack 用户可以无障碍地访问复制、删除、重新生成等功能。
Compose 框架本身提供了完整的无障碍 API(semantics、mergeDescendants、heading()、liveRegion、customActions、contentDescription、stateDescription 等),只要在消息列表的 Composable 层面正确使用这些 API,就可以从根本上解决上述所有问题,而不需要在 Markdown 渲染器内部做复杂的无障碍处理。
影响范围
- 受影响用户群体: 所有依赖 TalkBack 的视障用户
- 严重程度: 高 - 基本无法有效使用 AI 对话功能
- 当前仓库中无任何无障碍相关 Issue,这是首个
环境
- 设备:Android 手机
- 屏幕阅读器:TalkBack
- Operit 版本:最新版
希望维护者能够重视无障碍体验,让视障用户也能顺畅使用 Operit。感谢!
背景
我是一名视障用户,完全依赖 TalkBack(Android 屏幕阅读器)使用 Operit。在 AI 对话页面浏览消息时,遇到了严重的无障碍问题,导致几乎无法有效阅读对话内容。连最基本的消息浏览都无法正常完成,更谈不上使用其他操作功能。
经过对源码的分析,我将问题整理如下。
具体问题
1. 消息容器缺少语义分组 - 一条消息被拆成碎片(最严重)
相关文件:
AiMessageComposable.kt、UserMessageComposable.ktAiMessageComposable和UserMessageComposable的最外层Column均未设置semantics(mergeDescendants = true)。TalkBack 下的表现: 一条 AI 消息内的 "Response" 标题、正文文本、工具调用块、工具结果块被拆成多个独立节点逐个朗读。用户听到的是一堆碎片化的内容,完全无法感知"这是一条完整的 AI 回复"。
建议修复: 在每个消息 Composable 的根容器上添加
Modifier.semantics(mergeDescendants = true),并设置包含发送者角色和内容摘要的contentDescription,例如"AI回复:{内容前N字}"/"用户消息:{内容前N字}"。2. 代码块等特殊内容被合成一个超长焦点,无法辨识结构(非常严重)
相关文件:
StreamMarkdownRenderer、AiMessageComposable.ktAI 回复中的 Markdown 代码块、表格等特殊内容,在
StreamMarkdownRenderer渲染后,TalkBack 将其当作一个巨大的纯文本节点朗读。用户无法知道:整段内容被"粘"在一起,TalkBack 一直读下去,极其冗长且无法快速跳过。
TalkBack 下的表现: 遇到一段 AI 回复包含代码块时,TalkBack 会把正文+代码+正文全部连在一起读,没有任何结构标记,体验极其痛苦。
建议修复:
semantics { contentDescription = "代码块(语言:Kotlin)" }或类似的结构化标签heading()为代码块标题添加语义,让用户可以通过 TalkBack 的标题导航快速跳过3. 工具调用/结果与正文混在同一无障碍树
相关文件:
CustomXmlRenderer.kt、AiMessageComposable.ktAI 消息通过
StreamMarkdownRenderer渲染,其中的<tool>、<tool_result>、<think>等 XML 块虽然各自有contentDescription(如"工具调用块"),但它们和正文文本是平级节点。TalkBack 读起来就是"正文…工具调用块…正文…工具结果块…",无法区分哪些是 AI 的回复、哪些是工具执行过程。更严重的是,当一个 think 块折叠后,其中的工具调用和结果会通过
AnimatedVisibility(visible = false)隐藏,TalkBack 完全读不到工具名称和执行结果,只剩一句"N个工具"的标题。建议修复: 工具调用和结果应作为独立的无障碍区域,与 AI 正文明确区分。可以通过
semantics(mergeDescendants = true)将每个工具调用+其结果合并为一个节点,并设置描述如"工具调用:{工具名},执行{成功/失败}"。4. "Response" / "Prompt" 标题没有 heading 语义
相关文件:
AiMessageComposable.kt、UserMessageComposable.kt消息标题("Response"、"Prompt")只是普通
Text,没有Modifier.semantics { heading() }。TalkBack 不把它们当作标题,视障用户无法使用 TalkBack 的标题导航功能在消息之间快速跳转。建议修复: 为消息标题添加
heading()语义,让用户可以通过 TalkBack 的"按标题导航"功能快速在消息之间跳转。5. 流式响应缺少 liveRegion,TalkBack 不播报更新
相关文件:
AiMessageComposable.kt、ChatArea.ktAI 正在流式输出时,内容动态更新但未设置
liveRegion。TalkBack 不会主动播报新内容,用户不知道消息正在更新,也不知道何时更新完成。建议修复: 对正在流式输出的消息设置
Modifier.semantics { liveRegion = LiveRegionMode.Polite },让 TalkBack 在内容更新时播报。6. 消息操作对 TalkBack 用户完全不可达
相关文件:
ChatArea.kt(MessageItem函数)连基本的消息浏览都无法正常完成,
MessageItem的combinedClickable提供的长按菜单(复制/删除/重新生成/回滚等)更是无从谈起。长按手势对 TalkBack 用户而言不直观,且没有提供无障碍自定义操作(CustomAccessibilityAction)。建议修复: 在消息浏览体验修复的基础上,为消息项添加
customActions,让 TalkBack 用户可以通过双击激活菜单中的操作,如复制、删除、重新生成等。总体建议:基于 Compose Semantics API 重新构建消息列表的无障碍结构
当前的消息渲染架构将所有内容(正文、代码块、工具调用、工具结果、思考过程)都通过
StreamMarkdownRenderer作为一个扁平的 Markdown 流来渲染。这种方式在视觉上表现良好,但在无障碍语义树上产生了一个无结构的扁平节点列表——有些该分开的内容粘在一起(代码块),有些不该分开的内容却被拆碎(消息标题与正文)。建议从架构层面,直接利用 Compose 的 Semantics API 重新构建消息列表的无障碍结构:
每条消息作为一个语义单元:使用
semantics(mergeDescendants = true)将一条消息的所有内容(标题+正文+工具调用+工具结果)合并为一个 TalkBack 可朗读的节点,并附带角色标签("AI回复"/"用户消息")和内容摘要。消息内的结构化内容作为子语义节点:代码块、工具调用、工具结果等应在合并的语义节点内部拥有各自的
contentDescription,让用户可以在消息内部按结构导航。消息标题使用
heading()语义:让 TalkBack 用户可以通过标题导航在消息之间快速跳转。流式消息使用
liveRegion:让 TalkBack 在消息更新时自动播报。消息操作使用
customActions:让 TalkBack 用户可以无障碍地访问复制、删除、重新生成等功能。Compose 框架本身提供了完整的无障碍 API(
semantics、mergeDescendants、heading()、liveRegion、customActions、contentDescription、stateDescription等),只要在消息列表的 Composable 层面正确使用这些 API,就可以从根本上解决上述所有问题,而不需要在 Markdown 渲染器内部做复杂的无障碍处理。影响范围
环境
希望维护者能够重视无障碍体验,让视障用户也能顺畅使用 Operit。感谢!