Skip to content

[Accessibility] AI对话页面消息浏览存在严重无障碍问题,视障用户无法有效使用 #735

Description

@baich110

背景

我是一名视障用户,完全依赖 TalkBack(Android 屏幕阅读器)使用 Operit。在 AI 对话页面浏览消息时,遇到了严重的无障碍问题,导致几乎无法有效阅读对话内容。连最基本的消息浏览都无法正常完成,更谈不上使用其他操作功能。

经过对源码的分析,我将问题整理如下。


具体问题

1. 消息容器缺少语义分组 - 一条消息被拆成碎片(最严重)

相关文件: AiMessageComposable.ktUserMessageComposable.kt

AiMessageComposableUserMessageComposable 的最外层 Column 均未设置 semantics(mergeDescendants = true)

TalkBack 下的表现: 一条 AI 消息内的 "Response" 标题、正文文本、工具调用块、工具结果块被拆成多个独立节点逐个朗读。用户听到的是一堆碎片化的内容,完全无法感知"这是一条完整的 AI 回复"。

建议修复: 在每个消息 Composable 的根容器上添加 Modifier.semantics(mergeDescendants = true),并设置包含发送者角色和内容摘要的 contentDescription,例如 "AI回复:{内容前N字}" / "用户消息:{内容前N字}"


2. 代码块等特殊内容被合成一个超长焦点,无法辨识结构(非常严重)

相关文件: StreamMarkdownRendererAiMessageComposable.kt

AI 回复中的 Markdown 代码块、表格等特殊内容,在 StreamMarkdownRenderer 渲染后,TalkBack 将其当作一个巨大的纯文本节点朗读。用户无法知道:

  • 这是一个代码块还是普通文本
  • 代码块从哪里开始、到哪里结束
  • 代码的语言是什么

整段内容被"粘"在一起,TalkBack 一直读下去,极其冗长且无法快速跳过。

TalkBack 下的表现: 遇到一段 AI 回复包含代码块时,TalkBack 会把正文+代码+正文全部连在一起读,没有任何结构标记,体验极其痛苦。

建议修复:

  • 为代码块添加 semantics { contentDescription = "代码块(语言:Kotlin)" } 或类似的结构化标签
  • 代码块应作为独立的无障碍节点,与前后文本分开
  • 考虑使用 heading() 为代码块标题添加语义,让用户可以通过 TalkBack 的标题导航快速跳过

3. 工具调用/结果与正文混在同一无障碍树

相关文件: CustomXmlRenderer.ktAiMessageComposable.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.ktUserMessageComposable.kt

消息标题("Response"、"Prompt")只是普通 Text,没有 Modifier.semantics { heading() }。TalkBack 不把它们当作标题,视障用户无法使用 TalkBack 的标题导航功能在消息之间快速跳转。

建议修复: 为消息标题添加 heading() 语义,让用户可以通过 TalkBack 的"按标题导航"功能快速在消息之间跳转。


5. 流式响应缺少 liveRegion,TalkBack 不播报更新

相关文件: AiMessageComposable.ktChatArea.kt

AI 正在流式输出时,内容动态更新但未设置 liveRegion。TalkBack 不会主动播报新内容,用户不知道消息正在更新,也不知道何时更新完成。

建议修复: 对正在流式输出的消息设置 Modifier.semantics { liveRegion = LiveRegionMode.Polite },让 TalkBack 在内容更新时播报。


6. 消息操作对 TalkBack 用户完全不可达

相关文件: ChatArea.ktMessageItem 函数)

连基本的消息浏览都无法正常完成,MessageItemcombinedClickable 提供的长按菜单(复制/删除/重新生成/回滚等)更是无从谈起。长按手势对 TalkBack 用户而言不直观,且没有提供无障碍自定义操作(CustomAccessibilityAction)。

建议修复: 在消息浏览体验修复的基础上,为消息项添加 customActions,让 TalkBack 用户可以通过双击激活菜单中的操作,如复制、删除、重新生成等。


总体建议:基于 Compose Semantics API 重新构建消息列表的无障碍结构

当前的消息渲染架构将所有内容(正文、代码块、工具调用、工具结果、思考过程)都通过 StreamMarkdownRenderer 作为一个扁平的 Markdown 流来渲染。这种方式在视觉上表现良好,但在无障碍语义树上产生了一个无结构的扁平节点列表——有些该分开的内容粘在一起(代码块),有些不该分开的内容却被拆碎(消息标题与正文)。

建议从架构层面,直接利用 Compose 的 Semantics API 重新构建消息列表的无障碍结构:

  1. 每条消息作为一个语义单元:使用 semantics(mergeDescendants = true) 将一条消息的所有内容(标题+正文+工具调用+工具结果)合并为一个 TalkBack 可朗读的节点,并附带角色标签("AI回复"/"用户消息")和内容摘要。

  2. 消息内的结构化内容作为子语义节点:代码块、工具调用、工具结果等应在合并的语义节点内部拥有各自的 contentDescription,让用户可以在消息内部按结构导航。

  3. 消息标题使用 heading() 语义:让 TalkBack 用户可以通过标题导航在消息之间快速跳转。

  4. 流式消息使用 liveRegion:让 TalkBack 在消息更新时自动播报。

  5. 消息操作使用 customActions:让 TalkBack 用户可以无障碍地访问复制、删除、重新生成等功能。

Compose 框架本身提供了完整的无障碍 API(semanticsmergeDescendantsheading()liveRegioncustomActionscontentDescriptionstateDescription 等),只要在消息列表的 Composable 层面正确使用这些 API,就可以从根本上解决上述所有问题,而不需要在 Markdown 渲染器内部做复杂的无障碍处理。


影响范围

  • 受影响用户群体: 所有依赖 TalkBack 的视障用户
  • 严重程度: 高 - 基本无法有效使用 AI 对话功能
  • 当前仓库中无任何无障碍相关 Issue,这是首个

环境

  • 设备:Android 手机
  • 屏幕阅读器:TalkBack
  • Operit 版本:最新版

希望维护者能够重视无障碍体验,让视障用户也能顺畅使用 Operit。感谢!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions