OpenProgram Docs

Composer interaction modes — 输入框作为"用户决定"的统一承接点#

一句话#

聊天输入框(composer)不只是一个文本框,而是一个能切换多种形态的容器。 每一种形态是一个"变换"(mode):填函数表单是一种、回答 runtime.ask 的问题 是一种、批准一个工具是一种。所有"需要用户做决定"的交互都就地在输入框里 变形呈现,而不是各弹各的浮窗。每种 mode 各自一个文件夹、走同一套接口, 后续新交互要么直接复用一种 mode,要么在已有 mode 上做衍生。

为什么要有统一承接点#

有三种交互会把回合交还给用户:运行一个 @agentic_function、回答一个 runtime.ask 提问、批准一次工具执行。如果分开呈现——一个是就地表单、一个是 浮窗卡片、一个完全没有界面——用户的注意力会被拉到不同位置,每条路径也各写 一套代码。把三者都放进输入框呈现,用户的视线始终停在输入区,前端也获得了一个 承接"用户决定"的单一入口。

这也与事件层对齐:事件层是统一的事件流,任何"需要用户做决定"的事件 (question.asked,以及将来的 approval.asked / form.asked)都应当落到前端的 同一个出口——输入框的 mode 容器,由它挑选一种变换来呈现。后端一个注册表 (QuestionRegistry),前端一个承接点(composer),一条事件路径。 | 工具批准(permission ask) | 没有 UI(生产里是死的,只有测试在后台 resolve) | 无 ❌ |

三种"该用户了"的交互,一种就地变形、一种浮窗、一种没有。用户的注意力被 扯到不同地方,代码也各写各的。统一成一处:所有"该用户了"都发生在输入框, 用户视线不离开输入区;前端只有一个"用户决定"承接点。

这也跟事件层对齐:事件层是统一事件流,任何"需要用户决定"的事件(question.asked、 将来的 approval.asked / form.asked)在前端都该落到同一个出口——输入框的 mode 容器,由它选一种变换来呈现。一个后端 registry(QuestionRegistry)、一个 前端承接点(composer),一条事件路径。

现状:fn-form 已经是"变换"的范本#

web/components/chat/composer/ 得到的事实——fn-form 已经把"输入框变形" 做对了,新框架是把它的隐式约定显式化、再容纳更多 mode:

  • 触发态在 storesession-store.tsfnFormFunction(+ fnFormClosing), openFnForm(fn) / closeFnForm()。非空 = 当前处于 fn-form 形态。
  • 字段态在 hookuse-fn-form-state.ts(values / workdir / error / closing), fn 变了就重新播种默认值。
  • 视觉在 module.cssinputWrappermorphed 切形;outgoingLayer 做 fn→fn 切换时的交叉淡出动画。
  • Send 按钮行为跟着切onSendButtonClick = fnFormActive ? submitFnForm : submit, disabled / title 也随当前形态变。
  • 组件fn-form/fn-form.tsx(外形)+ fn-form-fields.tsx(字段渲染)。

问题(runtime.ask)现在没走这套,是独立浮窗(web/components/ui/question-prompt.tsx, 监听 op:question-asked window 事件、发 question_reply/question_reject)。 本设计让它退役,改成一种 mode。

模型:容器 + 变换(mode)#

容器(composer)#

composer 任一时刻处于一种 mode:

  • idle —— 普通打字(默认)。
  • fn-form —— 填函数参数表单。
  • question —— 回答 runtime.ask(选项 / 多选 / 自由文本)。
  • approval —— 批准/拒绝一个工具执行(question 的衍生:固定两个选项 + 危险动作摘要)。
  • 将来:form(runtime.form 多字段)、diff-approve(带 diff 预览的批准)……

同一时刻只有一种 mode 占据输入区(互斥)。mode 切换走容器的状态机,进入/退出 都有就地变形动画(沿用 outgoingLayer 交叉淡出)。

变形态的布局#

composer 在任何模式下都保持三段式:上方 env chips、中间 wrapper 盒子、下方一条 外挂控件行(权限 / 模型 / effort / context ring)。变形只是让 wrapper 向上长高, 控件行永远不动也不换样式。wrapper 内部的变形态 = 48px 头部 + 四周 12px 内边距的 body:

  • fn-form:运行按钮(与聊天发送键同一颗 24px 方)和 24px 关闭键并排钉在头部 右缘;body 在最后一个字段后直接收尾。
  • question / approval:头部放标签 + 进度点,「Chat about this」在头部右缘; 「‹ 上一题 / 下一题 › / 发送」是 body 的最后一行、右对齐正常排版——不做 绝对定位,也不预留底部空带。

按钮一律 6px 圆角矩形,不用胶囊或圆形。等比例静态示例见 fn-form-compact-mock.html

一种 mode 的统一接口#

每种 mode 是一个自包含单元,对容器暴露同一套契约(草案,实现时定型):

interface ComposerMode<TState> {
  id: string;                       // "fn-form" | "question" | "approval" | …
  // 进入这种 mode 需要的数据(fn 定义 / 问题 envelope / 批准请求)
  // 由触发源塞进 store,容器读出来传给 mode。
  useModeState(input): TState;      // 这种 mode 的局部状态 hook(如 use-fn-form-state)
  Body: React.FC<{ state: TState; ... }>;   // 输入区里渲染的主体
  // 主操作按钮(占住 composer 的 Send 位)的行为/文案/可用性
  primaryAction(state): { label; disabled; run: () => void };
  // 次操作(取消/拒绝),退出 mode
  secondaryAction(state): { label; run: () => void } | null;
  onExit?(): void;                  // 收尾(清状态、发未答信号等)
}

容器只认这个接口;加一种 mode = 加一个实现了它的文件夹,不改容器主体

文件组织#

web/components/chat/composer/
  modes/
    index.ts            # mode 注册表(id → ComposerMode),容器据此查表
    types.ts            # ComposerMode 接口
    fn-form/            # 现有 fn-form 迁进来,作为第一种 mode
    question/           # runtime.ask(吸收 question-prompt 的逻辑,去掉浮窗)
    approval/           # 工具批准(question 的衍生)
  index.tsx             # 容器:读当前 mode、查表、渲染 Body + 接管 Send

后续衍生:approval/ 直接 import question/ 的 Body 再包一层(加危险摘要), 就是"在已有变换上做衍生"。

三种通信形态:直接 store / 请求 / 广播(别混)#

每种 mode 是"怎么触发进来、用户操作后又怎么回传"。看代码会发现它们走三条 不同的通道,按一句话准则区分:跨进程/网络边界才考虑总线;同一处的状态变化 直接改 store;要后端做一件事并给个明确答复,用请求(HTTP/RPC),不是广播。

形态 通道 例子 为什么
直接 store Zustand action,同一处前端状态 点侧边栏函数 → openFnForm(fn) 进 fn-form 没跨任何边界,就是前端自己换个形态;上总线是多此一举
请求(command) HTTP POST / RPC,一来一回 fn-form 点"运行" → POST /api/function/{name};mode 答复 → question_reply WS action "我要你做件事 + 给我答复"(运行后回 session_id、resolve 哪个问题)。请求/响应模型最贴,总线是 fire-and-forget 拿不到回话
广播(event) 事件总线 → WS 函数运行的进度/产出/question.asked/file.changed 后端单向吐出"发生了什么",谁关心谁接、不等回话

fn-form 一条线看全:点函数(直接 store,开表单)→ 填参数(前端本地 state)→ 点运行(请求:POST 发起,回 session_id)→ 函数在子进程跑、动态回流(广播: 运行事件 / 中途 runtime.ask 都经事件层 → WS)。三段三种通道,各取最贴的工具。

反例(防以后踩):别把"发起运行"做成 fire-and-forget 的总线事件——那就拿不到 session_id,前端无从跳转/绑定会话。发起用请求、过程用广播。

事件如何路由进来#

这一节只讲上表第三类——广播:后端经事件层吐出的"要用户决定"事件 怎么落到 composer。用户操作后的答复是第二类(请求/WS action),见上表。

后端不变地经事件层发"要用户决定"的帧(question.asked,审批合流后也走它, 见下)。前端:

  1. use-ws.ts 收到 question.asked → 现在转成 op:question-asked window 事件 给浮窗。改成:写入 store 的 pendingDecision(envelope)。
  2. composer 容器订阅 pendingDecision:非空就根据 kind 选 mode (ask/confirm → question,approval → approval)进入该形态。
  3. 用户在输入区操作 → mode 的 primary/secondary 发 question_reply / question_reject(沿用现有 WS action,后端 _resolve_question 收口不变)。
  4. 别处先答了 / stop → 后端广播 question.replied/rejected → 前端清 pendingDecision、退出 mode(沿用现有"收回"逻辑)。

互斥与优先级(已定,2026-06-13):一次只呈现一个 mode,规则两条——

  • 系统决定之间排队:两个"系统要用户决定"的事件(如先来 question 再来 approval)→ FIFO 排队,一次一个。答完前一个,自动呈现下一个。不叠加、 不并排。
  • 系统决定 vs 用户主动开的 mode:用户自己点开的 fn-form 撞上一个系统 决定 → 直接取消 fn-form(用户主动开的,丢弃无所谓),让系统决定占住 输入区。不暂存、不恢复。

即:pendingDecision 是一个 FIFO 队列;队首非空时占据输入区。新系统决定入队; 若此刻是用户主动 mode(fn-form),清掉它再显示队首。实现简单,无栈、无快照。

后端:审批合流到 QuestionRegistry#

为了让"审批"也走同一条事件路径 → 落到同一个 composer 承接点,后端把 _approval.py 合流到 QuestionRegistry(user-input-requests.md 点 6):

  • await_user_approval 不再用独立的 ApprovalRegistry + 自定义 approval_request 信封,而是注册一个 kind="approval" 的 PendingQuestion (prompt = "允许执行 {tool}?",options = ["允许","拒绝"],detail = 参数摘要), 经事件层发 question.asked
  • 异步等待沿用 asyncio.to_thread(ev.wait, timeout)(工具 execute 是协程, 不能同步阻塞 loop)。
  • 布尔结果从问题三态映射:answered「允许」→ True;declined / timeout → False。
  • ApprovalRegistry 退役;approval_registry() 访问器保留为薄垫片或迁移 调用方;两个 dispatcher 批准测试改写成走 QuestionRegistry。

合流后:一个 registry、一种事件(question.asked)、一个前端承接点。审批顺带 复活(之前没 UI)。

退役(已完成)#

  • web/components/ui/question-prompt.tsx 浮窗 + app-shell 挂载 → 已删。
  • _approval.pyApprovalRegistry / approval_request 信封 → 已删; approval_registry() 返回统一 QuestionRegistry。

落地顺序(每步独立验证,全部完成)#

  1. 框架骨架(26685949):modes/types.ts(ComposerMode 接口)+ modes/index.ts(注册表)。fn-form 第一版仍内联(见 Status 偏差说明)。
  2. question mode(bc144b8c):modes/question/,runtime.ask 在输入框 就地呈现;删浮窗;use-ws → store 的 pendingDecisions 队列。真子进程端到端验证。
  3. 后端审批合流(73a094be):await_user_approval 走 QuestionRegistry (kind=approval),经事件层发 question.asked;ApprovalRegistry 退役;两个 dispatcher 批准测试迁到统一 registry + 事件总线契约。
  4. approval mode(27c05faa):modes/approval/,question 的衍生—— 危险摘要(工具名+参数)+ 允许/拒绝 + 拒绝附理由(理由变工具错误文本)。 真子进程端到端验证(含子进程审批桥)。
  5. 冲突排队 + 超时收回(c0c8956e):FIFO 队列一次一个;用户主动 fn-form 撞系统决定则取消;超时经 transport 广播 question.rejected 收回卡片 (修了"超时卡片挂死"的真 bug)。浏览器验证抢占。

每步都能在浏览器自验、独立提交。

决策(已定,2026-06-13)#

  • 同屏冲突:FIFO 队列;系统决定排队、一次一个;用户主动开的 fn-form 撞上 系统决定直接取消(见上)。
  • approval 危险摘要:显示命令/参数全文,超长截断(首尾保留);不做危险 token 高亮(第一版从简)。
  • 拒绝并附理由:approval 的 secondary 允许附文本理由,理由变成工具错误 文本回给模型(opencode 做法)。
  • timeout:mode 占输入区等用户,超时按 declined 收尾、自动退出 mode 并在 输入区给一行提示("超时未响应")。

关联#

Last updated · 2026-08-13