AionUi 对话发送草稿箱:AI 回复期间消息排队交互的完整设计与源码实现
【免费下载链接】AionUi免费、本地、开源的 24/7 全天候 Cowork 应用,以及适用于 Gemini CLI、Claude Code、Codex、OpenCode、Qwen Code、Goose CLI、Auggie 等的 OpenClaw | 🌟 喜欢就点star吧项目地址: https://gitcode.com/iOfficeAI/AionUi
本文围绕 AionUi(iOfficeAI/AionUi 开源仓库)对话模块中的「发送草稿箱」(原「排队面板」)展开,系统梳理其从交互优化需求(PRD:
docs/prds/conversations/send-drafts.md)到渲染层落地的完整链路:包括每条草稿的常驻操作、立即发送插队、自动/手动双模式、清空防误触、拖拽排序与窄栏适配等七项功能规格,并结合CommandQueuePanel组件、useConversationCommandQueue状态 hook 与配套单元测试,逐层讲解其数据模型、持久化策略、发送门控与容错机制。读完本文,你将掌握该功能的交互设计决策、状态机原理与源码级实现细节,可直接用于理解或二次开发同类"消息暂存/排队"能力。
一、背景与目标:从「排队面板」到「发送草稿箱」
在 AionUi 中,AI(如 Gemini CLI、Claude Code、Codex、OpenCode、Qwen Code、Goose CLI、Auggie 等 Agent 运行时)正在回复时,用户仍可继续输入并发送消息,这些消息会进入一个位于对话输入区上方的排队面板等待发出。这一能力原本已经存在:支持入队、编辑、删除、清空、当前 turn 结束后自动发送,以及拖拽调整顺序。
但根据微信群用户反馈,旧交互存在三个明显痛点:
- 每条排队消息只有「删除」直接可见,「编辑」被折叠进「⋯」更多菜单,要点两下才找得到;
- 没有「立即发送」能力——排队的消息只能等 AI 回复结束后被动等待,用户无法让某一条马上发出去;
- 排队面板缺少「是否自动发送」的总控开关,用户无法选择"我先攒着、想发再发"。
因此本次优化的核心目标,是把排队面板重新定位成一个「发送草稿箱」:AI 忙时用户写下的消息先存到这里,用户可以清楚看到并随手操作(立即发送 / 编辑 / 删除 / 调序),并且可以选择让草稿箱自动依次发出、还是手动由用户逐条发。按 PRD 约定,整个改造不改后端,全部在渲染层完成(涉及范围为对话输入区上方的排队面板)。
三个关键名词约定:
- 发送草稿箱:面板的新名字,指 AI 回复期间用户已发送、正在等待发出的消息集合(下文沿用旧代码中的「排队 / 队列」时指同一事物);
- 草稿:草稿箱里的单条待发消息,含文字与文件(以及会话引用);
- 自动 / 手动:草稿箱的两种发送模式(见 F-DRAFT-03)。
在源码中,该功能对应的实现文件为:
- 面板 UI:
packages/desktop/src/renderer/components/chat/CommandQueuePanel.tsx; - 状态与执行逻辑:
packages/desktop/src/renderer/pages/conversation/platforms/useConversationCommandQueue.ts; - 接入点:ACP 平台发送框
AcpSendBox.tsx、AionRS 平台发送框AionrsSendBox.tsx。
二、功能规格全景:七项 F-DRAFT 需求
PRD 将本次改造拆解为七个功能点,其中两项为新增、四项为优化、一项为保留:
| 编号 | 功能 | 类型 |
|---|---|---|
| F-DRAFT-01 | 每条草稿的三个常驻操作(立即发送 / 编辑 / 删除) | 优化 |
| F-DRAFT-02 | 立即发送(打断当前回复插队发出) | 新增 |
| F-DRAFT-03 | 自动 / 手动发送模式 | 新增 |
| F-DRAFT-04 | 草稿箱标题行与说明 | 优化 |
| F-DRAFT-05 | 清空草稿箱(防误触) | 优化 |
| F-DRAFT-06 | 拖拽调整顺序 | 保留 |
| F-DRAFT-07 | 窄栏 / 移动端适配 | 新增 |
下文逐项说明其用户故事、正常流程、异常情况与验收标准,并在对应小节后给出源码实现证据。
三、(F-DRAFT-01) 每条草稿的三个常驻操作
用户故事:每条排队的消息都能一眼看到「立即发送 / 编辑 / 删除」三个操作,随手就能点,不用翻菜单。
正常流程(用户视角):
- AI 回复期间,用户发送的消息进入发送草稿箱,逐条列在输入框上方;
- 每条草稿右侧常驻三个操作按钮,鼠标不悬停也始终可见:
- 立即发送:见 F-DRAFT-02;
- 编辑:把这条草稿的文字与文件放回输入框供修改,该草稿从箱中移除,改完重新发送即重新入箱;
- 删除:把这条草稿从箱中移除,不再发出;
- 三个操作在移动端、窄栏下同样常驻可点(不依赖 hover)。
异常情况:草稿箱为空时,面板整体不显示。
验收标准:三个按钮无需 hover 或展开菜单即常驻可见;「编辑」不再藏在「⋯」菜单里;「删除」将该条从草稿箱移除;「编辑」将该条内容与文件回填输入框并从箱中移除;三个按钮在移动端 / 窄栏下均可直接点击。
源码实现:在CommandQueuePanel.tsx中,每条草稿卡片的右侧渲染了三个renderQueueActionIconButton图标按钮,分别为:SendOne(立即发送,accent主色强调)、Edit(编辑)、Delete(删除,danger危险色),三者均以size='mini'、圆形文本按钮形式常驻显示,不依赖 hover。按钮通过aria-label提供无障碍访问,文案来自 i18n 的conversation.commandQueue.sendNow / edit / remove键。当items.length === 0时组件直接return null,即草稿箱为空时面板整体不渲染(对应代码)。
四、(F-DRAFT-02) 立即发送:打断当前回复插队发出
用户故事:当用户发现某条消息在排队、但希望它马上就走时,点一下就立刻发出去。
正常流程(用户视角):
- 用户点击某条草稿的「立即发送」;
- 系统打断 AI 当前正在进行的回复,将该条草稿立即出队并发送;
- 用户能理解这打断了当前回复——不额外弹提示条;
- 草稿箱里剩余的草稿行为完全不变:原本自动就继续自动依次发,原本手动就继续等待用户逐条发;发送模式、内容、顺序都不受这次插队影响。
边界说明:「立即发送」只作用于被点击的这一条。它是一次"让这条插队立刻走"的动作,不是模式切换,不引入「暂停 / 继续」这类额外状态。
异常情况:AI 当前没有正在进行的回复(已空闲)时,直接发送该条,无需打断。
验收标准:点击「立即发送」会中止 AI 当前回复并立刻发出该条草稿;不出现"已插队 / 队列已暂停"之类的提示条;剩余草稿的发送模式、内容、顺序均不因这次插队而改变;AI 空闲时点击「立即发送」直接发出,不产生打断副作用。
源码实现:hook 中的sendNow(commandId)先按commandId定位目标草稿,仅删除目标项(其余项保持原 mode、顺序与 paused 标志),然后直接调用onExecuteRef.current(target)执行发送(useConversationCommandQueue.ts)。发送失败时,如果错误可归类为"会话忙"(classifyConversationBusyError),草稿会被放回队列头部并进入 busy-wait 等待状态;其他错误则恢复该条并将队列置为暂停态,同时弹出提示"下一条排队指令启动失败。请编辑、调整顺序或移除后再继续"。这保证了"立即发送"失败时用户的消息不会丢失,而是回到草稿箱待处理。
五、(F-DRAFT-03) 自动 / 手动发送模式
用户故事:用户能选择草稿箱是「AI 回完自动帮我依次发」还是「先攒着、我想发再发」。
正常流程(用户视角):
- 草稿箱标题行右侧有一个模式切换按钮,显示当前模式(自动 / 手动),点一下即切到另一模式,无需二次确认;
- 自动模式:AI 每结束一轮回复,草稿箱自动把下一条发出去,一条接一条,直到发完,用户无需操作;
- 手动模式:草稿只暂存在箱中、不自动发出,由用户逐条点「立即发送」决定何时发。
作用范围(会话级,纯前端):
- 模式是每个对话各自记录的,A 对话设手动不影响 B 对话;
- 每个对话启动时默认「自动」(PRD 设计;当前仓库实现中默认值见下文差异说明);
- 该设置与现有排队内容一样保存在前端会话级存储中:应用运行期间切走再切回该对话,模式与草稿都保留;整个应用关闭重开后重置(草稿与模式都回到初始,与现有排队行为一致)。
异常情况:草稿箱为空时面板不显示,但模式设置在后台保留,下次有草稿入箱时按该模式运作。
验收标准:切换按钮显示当前模式、点击即切换无二次确认;自动模式下 AI 每轮结束后自动依次发出草稿;手动模式下草稿不自动发出;新对话默认自动模式;模式按对话独立记录互不影响;应用关闭重开后模式回到默认。
源码实现:类型层面定义ConversationCommandQueueMode = 'auto' | 'manual'(useConversationCommandQueue.ts)。toggleMode()在auto与manual之间翻转并记录mode-changed日志(L1019-L1032)。自动发送的主循环位于组件内一个useEffect中:仅当mode === 'auto'、执行门控可执行、未暂停、无正在等待的 turn 且队列非空时,取出队首草稿发送,并通过turnCompleted事件驱动"每轮结束后发下一条"的节奏(L1080-L1164)。此外还实现了后台 runner:对话切走(组件卸载)后,drainBackgroundCommandQueue仍会监听ipcBridge.conversation.turnCompleted事件继续把自动模式的队列发完(L432-L561)。
PRD 与实现的差异说明:PRD 设计"新对话默认自动",而从当前仓库源码看,
createDefaultQueueState()返回的默认 mode 为'manual',normalizeQueueMode()也把非'auto'的值一律归一为'manual'(L100-L106)。可以推断实现侧选择了更保守的默认值——避免用户未察觉时消息被自动发出。若要以 PRD 的"默认自动"落地,只需调整默认状态与归一化逻辑,属纯前端改动。
六、(F-DRAFT-04) 草稿箱标题行与说明
用户故事:用户一眼看懂这个面板是什么、当前处于什么模式,需要时能查到自动 / 手动的含义。
标题行布局(草稿箱框内顶部一行,自左到右):
- 标题「发送草稿箱」+ 数量徽标(显示当前草稿条数);
- 模式切换按钮(F-DRAFT-03);
- 帮助「?」:hover 显示说明——草稿箱是什么,以及自动 / 手动分别的含义;
- 更多「⋯」:见 F-DRAFT-05。
帮助文案(tooltip):
发送草稿箱:AI 回复期间你发的消息会先存到这里。 自动:AI 回复结束后,自动依次发送草稿箱里的消息。 手动:消息只暂存、不自动发,由你逐条点「立即发送」。
验收标准:标题行显示「发送草稿箱」与草稿数量;「?」hover 出说明,覆盖草稿箱含义与两种模式。
源码实现:标题行位于CommandQueuePanel.tsx。左侧为自定义DraftBoxActionIconSVG 图标 + 标题(i18nconversation.commandQueue.title,中文为「草稿箱」)与数量徽标(直接渲染items.length);右侧为模式切换按钮与「⋯」下拉菜单。值得注意的是,当前实现把帮助入口与模式切换按钮合二为一:Tooltip包裹模式按钮,hover 即显示三行帮助文案(helpIntro/helpAuto/helpManual),组件注释明确说明"模式切换按钮兼作帮助入口,因此无需单独的「?」按钮"(L505-L526)。这与 PRD 中"单独「?」帮助按钮"的设计略有出入,属实现侧的等价简化——更省宽度,且语义信息没有丢失。中文帮助文案见conversation.json的commandQueue.help*键。
七、(F-DRAFT-05) 清空草稿箱(防误触)
用户故事:能一次清空整箱草稿,但不要轻易误点到。
正常流程(用户视角):
- 「清空草稿箱」放在标题行「⋯」更多菜单里,菜单项以危险色(红)呈现;
- 点击后再弹一次二次确认,确认后才清空全部草稿。
验收标准:「清空草稿箱」位于「⋯」菜单内而非标题行直接可点;菜单项以危险色呈现;点击后需二次确认才执行清空。
源码实现:moreMenu下拉菜单中,清空项Menu.Item的内联样式使用color: 'rgb(var(--danger-6))'呈现危险色(CommandQueuePanel.tsx)。点击后调用Modal.confirm弹出二次确认框,标题"确定清空发送草稿箱?"、正文"所有待发送的消息都会被移除,且无法恢复。",确认按钮同样以status: 'danger'呈现(L434-L444)。确认后执行onClear,对应 hook 中的clear()——它会重置所有等待标记并清除持久化状态(L748-L756)。
八、(F-DRAFT-06) 拖拽调整顺序(保留能力)
用户故事:用户能调整草稿的发送先后顺序。
正常流程(用户视角):
- 每条草稿最前有一个拖拽手柄,hover 时显现,按住可上下拖动调整顺序;
- 顺序即草稿箱自动 / 手动发出的先后次序。
验收标准:桌面端 hover 草稿时显现拖拽手柄,可上下拖动排序;排序结果即后续发送次序。
源码实现:拖拽基于@dnd-kit/core与@dnd-kit/sortable(verticalListSortingStrategy)。每条草稿的拖拽手柄是一个带Drag图标的按钮,默认opacity-0,group-hover:opacity-100即在 hover 时显现(CommandQueuePanel.tsx)。为了把拖动限制在队列容器内,组件实现了createRestrictToQueueContainerModifier,与restrictToVerticalAxis组合使用,保证拖拽不会超出面板边界(L43-L62)。onReorder(activeCommandId, overCommandId)对应 hook 中纯函数reorderQueuedCommand——按 id 找到起止索引后 splice 移动(L326-L342)。排序结果即队列顺序,也就是自动 / 手动发送时的出队次序。
九、(F-DRAFT-07) 窄栏 / 移动端适配
用户故事:在小屏、移动端或团队并排 Agent 视图下,草稿箱在窄栏里也摆得下、点得到。
适配策略(约 300px 宽及以下):
- 标题「发送草稿箱」收成一个图标 + 数量徽标,hover / 长按显示全名,省出宽度;
- 模式切换按钮始终保留(核心功能),文字压到「自动 / 手动」两字;
- 「?」帮助并入「⋯」菜单:窄栏头部只保留一个「⋯」,其中包含「使用说明」与「清空草稿箱」;
- 每条草稿的三个操作按钮不变,消息文字自动截断;
- 拖拽手柄在窄栏隐藏,改为长按整条拖动。
验收标准:窄栏下标题收成图标 + 徽标、切换按钮保留;「?」并入「⋯」菜单、头部仅一个「⋯」;每条三个操作按钮仍可点击、文字自动截断;团队并排 Agent 视图下草稿箱布局不溢出、不错位。
源码实现:CommandQueuePanel接收isMobile属性。窄栏时左侧标题用Tooltip包裹的DraftBoxActionIcon图标代替文字标题(hover 显示全名)(L485-L496);「⋯」菜单在isMobile时额外加入一个help菜单项,把帮助内容以多行文本形式放进菜单(L452-L466)。拖拽方面,移动端dragViaCard为 true:隐藏手柄、改为长按整条 200ms触发拖动(PointerSensor的activationConstraint在移动端为{ delay: 200, tolerance: 6 },桌面端为{ distance: 8 }),200ms 的延迟同时也避免普通点击操作按钮被误判为拖拽(L360-L367)。列表容器最大高度在移动端为min(48vh, 320px),桌面端为min(36vh, 320px)(L560-L562)。
十、源码级纵深:状态模型、持久化与执行门控
10.1 数据模型与常量
队列项类型ConversationCommandQueueItem包含五个字段(useConversationCommandQueue.ts):
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 唯一标识(uuid()生成) |
input | string | 消息正文 |
files | ChatFileRef[] | 附件文件引用(按chatFileRefKey去重) |
sessions | SessionRef[] | @@会话引用(必须随消息存活,否则 Agent 静默丢失会话块) |
created_at | number | 入队时间戳 |
队列状态ConversationCommandQueueState = { items, isPaused, mode }。配套的硬性容量上限常量(超出即拒绝入队并弹提示):
MAX_QUEUED_COMMANDS = 20(最多 20 条);MAX_QUEUED_COMMAND_INPUT_LENGTH = 20_000(单条正文最长 20000 字符);MAX_QUEUED_COMMAND_FILES = 50(单条最多 50 个文件);MAX_QUEUED_COMMAND_STATE_BYTES = 256 * 1024(整个队列持久化状态不超过 256KB)。
入队前会通过validateQueuedCommandItem做完整校验,失败原因枚举包括emptyInput / inputTooLong / tooManyFiles / queueFull / queueTooLarge,每种都有对应的 i18n 提示文案(中文见conversation.json)。
10.2 会话级持久化:sessionStorage
模式与草稿按对话隔离存储:存储键为`conversation-command-queue/${conversation_id}`,写入window.sessionStorage(L110、L303-L319)。这与 PRD 的"会话级存储"设计完全一致:
- 应用运行期间切走再切回,模式与草稿从 sessionStorage 恢复保留;
- 整个应用关闭重开后 sessionStorage 清空,草稿与模式回到初始;
- 当队列为空、未暂停且为默认模式时,持久化键会被主动删除,避免残留脏数据。
读取时使用normalizeQueueState做严格的数据净化:逐条校验 id/input/files/created_at 类型,非法的sessions引用被丢弃而非让整条消息作废("丢失过期引用可恢复,丢失用户输入不可恢复"),并再次套用容量上限(L168-L200)。
10.3 执行门控(Execution Gate)
队列是否允许出队发送由getCommandQueueExecutionGate统一裁决:常规场景下canExecute = !isBusy;在团队运行时场景下,则透传runtimeGate(canSendMessage && !isProcessing),与团队并排 Agent 视图的运行时状态联动(L387-L409)。hook 内部用一组 ref 状态机跟踪waitingForTurnStart / waitingForTurnCompletion / waitingForBusyRelease,并通过conversation.turnCompleted事件精确感知"一轮回复结束",从而决定何时发送下一条(L621-L680)。发送失败时按"忙等待 / 真失败"分流:忙则把草稿放回队首继续等;真失败则暂停队列并提示用户编辑、重排或移除问题项。
10.4 与发送框的接线
在 ACP 平台发送框AcpSendBox.tsx中,useConversationCommandQueue被接入(L421-L431),CommandQueuePanel渲染在输入区上方(L783),其isBusy判断为isCancelling || runtimeGate.isProcessing || !runtimeGate.canSendMessage(L249)。发送框还提供「存到草稿箱」能力:AI 处理中时直接发送被拦截,用户可按⌘ + Enter / Ctrl + Enter将输入存为草稿(对应 i18naddToQueueShortcut)。
10.5 配套测试
该功能在仓库中有完整的测试覆盖,可作为行为契约参考:
CommandQueuePanel.dom.test.tsx:面板交互(常驻操作、模式切换、清空确认、拖拽等)的 DOM 级测试;conversationCommandQueueMode.dom.test.tsx:自动 / 手动模式行为;conversationCommandQueueDrain.dom.test.tsx:turn 结束后自动出队发送链路;conversationCommandQueueGate.test.ts:执行门控裁决逻辑;conversationCommandQueueSessions.test.ts与conversationCommandQueueChatFileRef.test.ts:@@会话引用与文件引用的持久化保真。
十一、待讨论事项与落地取舍
PRD 在「待讨论模块」中记录了两个问题,可作为后续迭代方向参考:
- 控件形态已定:模式切换采用「单按钮状态切换」(按钮显示当前模式,点击切到另一模式),不采用拨动开关或分段按钮。当前实现与此一致——标题行右侧就是一个带模式文字与
SortTwo图标的胶囊按钮。 - 是否需要「全局默认模式」:当前为纯会话级、新对话默认(PRD 规划为自动,当前实现为手动)。若后续收到"每次开新对话都要重设太麻烦"的反馈,可再评估增加一个全局默认值设置项。此项本期不做。
此外,实现相对 PRD 有两处值得注意的取舍:
- 默认模式:PRD 设计默认「自动」,当前源码默认「手动」(见 F-DRAFT-03 差异说明),建议以仓库实际行为为准进行体验评估;
- 帮助入口:PRD 规划独立「?」按钮,当前实现将帮助 tooltip 合并到模式切换按钮上,窄栏下再并入「⋯」菜单,兼顾了信息可达性与宽度占用。
结语
AionUi 的「发送草稿箱」是一个"纯渲染层、不改后端"的典型交互升级:它把原本被动等待的排队面板,升级为具备常驻操作、立即插队、自动/手动双模式、防误触清空、拖拽排序与窄栏适配的完整消息暂存与发送控制台。从CommandQueuePanel的 UI 实现到useConversationCommandQueue的状态机、持久化与执行门控,再到覆盖行为契约的单元测试,整个能力链路清晰可查。若你正在设计同类"AI 忙碌时的消息缓冲"交互,这份 PRD 与其落地源码是极具参考价值的完整范例。
【免费下载链接】AionUi免费、本地、开源的 24/7 全天候 Cowork 应用,以及适用于 Gemini CLI、Claude Code、Codex、OpenCode、Qwen Code、Goose CLI、Auggie 等的 OpenClaw | 🌟 喜欢就点star吧项目地址: https://gitcode.com/iOfficeAI/AionUi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考