Skip to content

feat(ask): 新增 per-bot 提问选项布局配置与 Dashboard 开关 - #1587

Merged
deepcoldy merged 3 commits into
deepcoldy:masterfrom
kingchao1024:pr/ask-option-layout
Sep 29, 2026
Merged

deepcoldy merged 3 commits into
deepcoldy:masterfrom
kingchao1024:pr/ask-option-layout

Conversation

@kingchao1024

@kingchao1024 kingchao1024 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

动机

botmux ask 提问卡片的选项按钮目前固定为 action 行横排(每行最多 4 个)。选项文案较长时标签被挤压省略,在移动端尤其难读、难点。本 PR 新增 per-bot 的 askOptionLayout 配置,允许按 bot 切换为「每个选项独占一行」的竖排布局;默认 compact,未配置的 bot 渲染与现状完全一致,纯增量。

vertical 效果:每个选项一个单列 column_set(weighted 列宽占满整行),长文案一行读完。column_set 在旧版卡片 schema 同样受支持,本 PR 不涉及 schema 迁移。

改动内容

配置链路完整镜像现有 replyStyle 体系(13 文件,+515/-3):

层 改动
bots.json per-bot askOptionLayout 字段;稀疏存储(compact 即缺省,从 json 删除该键)
daemon IPC GET 投影新增 askOptionLayout(fail-soft 归一化 + warning);新增 PUT /api/bot-ask-option-layout(1KB body 上限、严格 key 白名单校验、rmwBotEntry 原子落盘、live 配置热更新)
dashboard 代理路由(同样有界 body + 413 映射)、botDefaultsPayload 字段映射、离线恢复行透传
配置页 「卡片」tab 新增布局开关(DropdownField + 保存状态提示,zh/en i18n)
渲染 新零依赖叶子模块 ask-option-layout.ts:bot-registry 注入 lookup(同 i18n setBotLookup 防环模式,避免 ask-card → turn-reply-ask → bot-registry 成环),非法手改值 fail-soft 回退 compact,排版笔误永不影响发卡或 daemon 启动

测试

基于 upstream master(32a0e98e):

  • bunx vitest run --project unit test/ask-card.test.ts test/dashboard-bot-payload.test.ts test/dashboard-ask-option-layout-proxy.test.ts → 86/86 通过
  • bunx vitest run --project unit test/dashboard-ipc.test.ts → 291 通过 1 跳过(新增 PUT 路由用例覆盖:vertical 持久化/热更新/落盘回读、非法值 400、超限 body 413、compact 置空稀疏删除)
  • bun run test(unit 全量 26115 用例)→ 26027 通过;68 个失败为本机容器既存环境用例(linux-isolation 内核探针等),在未含本改动的 upstream 基底上失败完全相同
  • bunx tsc --noEmit 通过

截图

见下方评论:Dashboard 开关 + compact/vertical 卡片对比。

备注

  • replyStyle 在 dashboard 离线恢复行未透传属同型既存缺口,本 PR 未一并修复,可另行处理。

🤖 Generated with Claude Code

@kingchao1024

kingchao1024 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor Author

实机验证截图(飞书 + Dashboard):

Dashboard「卡片」tab 新增「提问选项布局」开关,保存后下一张卡片生效、无需重启会话:
PR #1587 1

vertical:每个选项独占一行,长文案不被挤压:
PR #1587 2

compact(默认,与现状完全一致):每行最多 4 个按钮(6 个选项 → 4+2 两行):
PR #1587 3

@deepcoldy

Copy link
Copy Markdown
Owner

你好 @kingchao1024 ,这个 PR 的评审群已经建好(自动流程):请点击这个链接加入飞书评审群(一年有效):
https://applink.larkoffice.com/client/chat/chatter/add_by_link?link_token=ceambc57-0131-4aaf-802e-a6706a573482

自动拉群时发现你暂时不在自动拉群名单中(系统邀请提示无协作权限)。也可以把你的 GitHub 账号和飞书信息补进这个名单文档:https://bytedance.larkoffice.com/wiki/WJ1nwWbtxi89erkNGNbcgkt9nUe ,补好后后续评审会自动把你拉进群。评审意见会同步在群里和本 PR,谢谢!

@deepcoldy

Copy link
Copy Markdown
Owner

你好,感谢这个 PR!整体设计质量很高:完整镜像了现有 replyStyle 的配置链路(稀疏存储、写严读宽、1KB 限体、精确 key 白名单、原子落盘 + 热更新),叶子模块 + lookup 注入规避 import 环的手法也很到位,测试和真机截图都很充分。以下是自动评审的初步意见(最终以维护者审阅为准):

阻断项:daemon 重启后配置读不回来(静默回退 compact)

BotConfig 接口里声明了 askOptionLayout(bot-registry.ts),PUT 路由也会写盘并热更新当前进程的内存配置,但 parseBotConfigsFromText 在构造 config: BotConfig 字面量时没有从 entry.askOptionLayout 取值——全文件只有那一处接口声明,没有任何赋值点(对照 replyStyle:约 3583 行 normalizeReplyStyleConfig(entry.replyStyle) → 约 3827 行 replyStyle: normalizedReplyStyle.config 是走通的)。

因此真实生命周期是:

  1. Dashboard 保存 → 写盘成功 + 当前进程热更新 → 下一张卡确实变 vertical ✅
  2. daemon 一旦重启(daemon:restart、崩溃恢复、机器/Supervisor 重启)→ 重新 parse 时该字段被丢弃 → getBot(appId).config.askOptionLayout === undefined → 渲染回退 compact
  3. 而 Dashboard 离线恢复行是直接读磁盘原始 JSON 的,UI 上仍显示「竖放」——用户以为配置还在生效,卡片却已悄悄变回紧凑布局

复现方式(在本 PR 代码上):往 bots.json 写入 askOptionLayout: "vertical"(可同时放 replyCardMode: "unified" 作对照),调用 loadBotConfigs() 后读取:askOptionLayout 为 undefined,而同一层的 replyCardMode / replyStyle 都能正常读出。loadBotConfigAtIndex() 共用同一个 parser,行为相同。

建议修法(与 replyStyle 完全同构):

const normalizedAskOptionLayout = normalizeAskOptionLayout(entry.askOptionLayout);
for (const warning of normalizedAskOptionLayout.warnings) {
  logger.warn(`[bot-registry:${entry.larkAppId}] ${warning}`);
}
// config 字面量中(稀疏语义:只在 vertical 时带值也可以):
askOptionLayout: normalizedAskOptionLayout.layout,

并建议补两条冷读回归用例(修复前应当变红):

  • bots.json 含 askOptionLayout: "vertical" → loadBotConfigs() / registerBot → askOptionLayoutForBot(appId) === "vertical";
  • 再补一条经 loadBotConfigAtIndex() 的(daemon 自身 slot 走这条解析路径)。

现有用例没拦住这个问题的原因:dashboard-ipc 用例对磁盘的断言是直接 JSON.parse(readFileSync(...)) 读原始文件,ask-card 用例则是手动注入 lookup 假对象,两条都绕开了「parser 把磁盘字段解析进 BotConfig」这个唯一断点。

建议同批处理:unified 实时卡里的内嵌 ask 不受该配置控制

当 bot 配置 replyCardMode: "unified" / "final-only" 时,agent 运行中的 ask 会内嵌在实时回复卡中,由 buildTurnReplyAskElements(src/im/lark/turn-reply-ask-elements.ts)渲染(column_set + flex_mode: "flow",每行 3 个按钮),这条路径不读取 askOptionLayout,配置对该形态静默无效。独立卡(默认 legacy 形态)完全正常。内嵌的门槛只看选项数/字节数(≤16 选项且 ≤3000 字节就内嵌),所以「选项不多但文案长」的 ask 仍会走内嵌,是实际可遇的场景。

两种处理方式任选:

  • 让内嵌形态也读取该配置(record.larkAppId 在手边,可直接用叶子模块的 askOptionLayoutForBot);注意内嵌是 Card JSON 2.0(behaviors:[{type:'callback'}]),column_set 的字段形态与独立卡 1.0 不同,不能直接复用 appendActionRows,需要单独实现一份竖排;
  • 或在配置项的 help 文案中明确注明「仅独立提问卡生效,实时回复卡内嵌提问暂不受控」。

小建议:help 文案与实际渲染对齐

文案写的是竖放模式按钮「占满整行宽度」,但从真机截图看短标签按钮仍是按内容宽度(hug-content)排列的。消息卡片 1.0 的 button 支持 width: "fill",可以补上让按钮真正填满列宽;如果不打算改渲染,建议把文案调整为「每行 1 个、不被同排按钮挤压」,避免文案与视觉不一致。


以上为自动评审的初步意见,阻断项修复后我们会再复验,最终结论以维护者审阅为准。再次感谢贡献,期待更新!

@deepcoldy

Copy link
Copy Markdown
Owner

补充上一条评审意见(根因定位更精确后,修法请注意一个坑):

1) 修复请直接在 parser 字面量取值,不要改用 applyConfigField 接这个字段。

排查中发现,applyConfigField(src/services/bot-config-store.ts)对它管理的字段做的也是「rmwBotEntry 写盘 + 同步当前进程 bot.config」,并没有进入冷读 parser —— 仓库里既有的 envelopeInjection 字段走的正是这条路径,存在完全同形的问题:设置后当前进程生效,daemon 重启后因 parser 不读取而静默回退。所以如果用 applyConfigField 来接 askOptionLayout,只是复制同一个 bug。唯一可靠的修法仍是上一条说的:在 parseBotConfigsFromText 的 const config: BotConfig = {…} 字面量里,像 replyStyle 那样真正从 entry.askOptionLayout 取值(normalize + warning + 赋值)。

2) 回归用例最省的证伪点可以直接打 parser,比走完整 HTTP 链路更聚焦:

parseBotConfigsFromText(JSON.stringify([{
  larkAppId: 'probe', larkAppSecret: 'x', cliId: 'codex',
  askOptionLayout: 'vertical',
}]))[0].askOptionLayout === 'vertical'

再配一条经 loadBotConfigAtIndex() 的(daemon 自身 slot / reloadExactDaemonBotConfig 走这条路径)。这两条在修复前都应当是红的,修复后转绿。

3) 范围说明(无需在本 PR 处理):排查时顺带发现两个既有字段 dshProfile 和 envelopeInjection 在同一个 parser 里也没有冷读赋值,属于与本 PR 无关的历史遗留(二者各自有真实消费方,重启后同样静默回退默认)。不建议在本 PR 里顺手修,避免扩大评审面;我们会另行作为独立问题跟进。本 PR 只需保证 askOptionLayout 自身被 parser 正确线程化即可。

以上仍为自动评审的初步意见,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

更正我上一条补充评论中关于既有字段 dshProfile 的机制描述(进一步核对写入侧后,结论需要收紧;对本 PR 的要求不变):

  • envelopeInjection:结论不变——它在 CONFIG_FIELDS 里有 spec,经 applyConfigField 写盘并同步当前进程,只是 parser 不做冷读赋值,属于「有写无读」,重启后静默回退 inline。
  • dshProfile:更准确的病灶是「无写无读」。Dashboard UI 确实会在 PUT /api/bots/:id/agent 的 body 里带上 dshProfile(且保存响应回填也读 res.body.dshProfile),但 daemon 的 /api/bot-agent handler 的 body 类型里没有这个字段、全程不读取也不写 entry.dshProfile(对照同文件里 dshRuntime 有完整的「字段存在性检测 → 校验 → 写盘 → live 同步 → 回包」链路);bot-config-store 的 CONFIG_FIELDS 里也没有它的 spec。该 handler 不拒绝未知 key,所以字段被静默忽略,保存响应不含该字段,UI 下拉会弹回原值。另外 parser 同样不读它,手工编辑 bots.json 写入也不会生效。文档 docs-site/docs/{zh,en}/adapters.md 目前承诺了 per-bot dshProfile 覆盖,属于文档先行、实现缺失。因此它的修复需要先补路由写入链路,再补 parser 赋值,量级明显大于一行 parser——更适合独立处理,不在本 PR 范围。

本 PR 的阻断项与修法不变:只需保证 askOptionLayout 在 parser 中被正确线程化。以上仍为自动评审初步意见,最终以维护者审阅为准。

@kingchao1024

Copy link
Copy Markdown
Contributor Author

Review comments addressed in 0317eaa:

1. Parser cold-read gap (blocker) — fixed as suggested. parseBotConfigsFromText now threads askOptionLayout exactly like replyStyle: normalizeAskOptionLayout(entry.askOptionLayout) → warnings logged per bot → assigned literally onto the BotConfig. Deliberately NOT via applyConfigField — that path is rmwBotEntry + hot-sync only, which is the same shape as the bug. Sparse semantics match the write side: absent/invalid → undefined (compact behavior); explicit vertical (or hand-edited compact) reads back as-is.

Regression tests added red-before-fix, all targeting the parser directly:

  • test/bot-registry.test.ts ×3: vertical threaded into BotConfig; unset → undefined / explicit compact → compact / invalid sideways → undefined; loadBotConfigAtIndex cold-read path via BOTS_CONFIG + fs mock.

2. Inline ask cards now respect the config. buildTurnReplyAskElements (unified / final-only reply mode) resolves askOptionLayoutForBot(ask.larkAppId); vertical emits the same one-button-per-row semantics as the standalone card, expressed in Card JSON 2.0 (column_set flex none + single weighted column — the 1.0 appendActionRows shape can't be reused here). Covered by test/turn-reply-ask.test.ts ×2 (compact → flow/auto rows of ≤3; vertical → one weighted row per option).

3. Help copy aligned with actual rendering (hug-content, not full-width): zh/en now say "one button per row, never squeezed by siblings" instead of "full-width".

Verification: bunx vitest run --project unit test/bot-registry.test.ts test/turn-reply-ask.test.ts → 178 passed; test/ask-card.test.ts → 48 passed; test/dashboard-ask-option-layout-proxy.test.ts → 2 passed; bunx tsc --noEmit → clean.

dshProfile / envelopeInjection left untouched per scope note.

kingchao1024 and others added 2 commits September 28, 2026 23:53
为 botmux ask 提问卡片新增 askOptionLayout 配置(compact/vertical):
compact 保持现状(action 行每行最多 4 按钮,默认、纯增量);vertical
每个选项独占一行(单列 column_set 整行宽度),长选项文案不再被挤压。

配置链路镜像 replyStyle 体系:bots.json per-bot 字段 → daemon IPC
(GET 投影 + PUT /api/bot-ask-option-layout,1KB 上限 + 严格校验 +
rmwBotEntry 原子落盘 + 热更新)→ dashboard 代理与 bot-payload 映射
(含离线恢复行透传)→ 配置页「卡片」tab 开关(zh/en i18n)。渲染侧经
零依赖叶子模块的 lookup 解析(同 i18n setBotLookup 防环模式),非法
手改值 fail-soft 回退 compact,不影响发卡。column_set 在旧版卡片
schema 同样受支持,本提交不涉及 schema 迁移。

验证(基于 upstream master 32a0e98):
- bunx vitest run --project unit test/ask-card.test.ts test/dashboard-bot-payload.test.ts test/dashboard-ask-option-layout-proxy.test.ts → 86/86 通过
- bunx vitest run --project unit test/dashboard-ipc.test.ts → 291 通过 1 跳过(含新增 PUT 路由用例:vertical 持久化/热更新/落盘、非法值 400、超限 413、compact 置空稀疏删除)
- bun run test(unit 全量 26115 用例)→ 26027 通过;68 个失败为本容器既存环境用例(linux-isolation 内核探针等),在无本改动的 upstream 基底上失败完全相同
- bunx tsc --noEmit 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
… cards

按评审意见修复:

1. 解析器冷读补齐(阻断项):parseBotConfigsFromText 现在与 replyStyle 同款
   方式 normalize + warn + 赋值 askOptionLayout。此前该字段只有写侧
   (PUT → rmwBotEntry 落盘 + 热更新),daemon 重启后磁盘配置不会读回
   BotConfig,静默回退 compact,而 Dashboard 离线恢复行仍显示旧值。
   修复刻意走 parser 字面量赋值而非 applyConfigField(后者是
   rmwBotEntry + 热同步,与本 bug 同形)。

2. 内嵌 ask 卡(unified/final-only 模式)尊重配置:buildTurnReplyAskElements
   经 askOptionLayoutForBot(ask.larkAppId) 取布局,vertical 时输出与独立卡
   同语义的一行一按钮(Card JSON 2.0 column_set flex none + weighted 单列,
   字段形态与独立卡 1.0 不同故不复用 appendActionRows)。

3. Dashboard 帮助文案对齐实际渲染:竖放为「每行 1 个、不被同排按钮挤压」
   (hug-content 渲染,并非占满整行宽度)。

回归测试(先红后绿验证):
- test/bot-registry.test.ts 新增 3 例:vertical 线程化进 BotConfig;
  缺省/非法值 → undefined;loadBotConfigAtIndex 冷读路径
- test/turn-reply-ask.test.ts 新增 2 例:compact 输出 flow/auto 行(3+1);
  vertical 输出每选项一行(flex none + weighted 单列)

验证命令与结果:
- bunx vitest run --project unit test/bot-registry.test.ts test/turn-reply-ask.test.ts → 178 passed
- bunx vitest run --project unit test/ask-card.test.ts → 48 passed
- bunx vitest run --project unit test/dashboard-ask-option-layout-proxy.test.ts → 2 passed
- bunx tsc --noEmit → 无错误

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner

好消息:P0(parser 冷读)和 P2(unified 内嵌卡受控)的修复已经过双轮评审验证、维护者认可,PR 可以合并。合并前只剩一处纯文案/注释一致性清理——之前把竖放的「占满整行宽度」改成了「不被同排按钮挤压」(实际是 hug-content 渲染),但同样的说法和 compact「每行最多 4 个」的数字还残留在几处注释/测试描述里;而 compact 在独立卡是每行 4 个、在实时卡内嵌形态是每行 3 个,写死数字在 unified 模式下对不上。麻烦一轮改完,纯字符串改动,不涉及任何行为逻辑。

用户可见文案(4 处,src/dashboard/web/i18n.ts,行号以当前 head 为准、按 key 匹配)

去掉写死的「4」,改成对两种形态都成立的「自动换行」:

  • botDefaults.askOptionLayoutHelp(zh):
    控制 botmux ask 提问卡片里选项按钮的排布:紧凑模式按行自动换行;竖放模式每行 1 个、不被同排按钮挤压,长选项文字更易读。保存后下一张提问卡片生效,无需重启会话。
  • botDefaults.askOptionLayout.compact(zh):紧凑(自动换行)
  • botDefaults.askOptionLayoutHelp(en):
    Controls how option buttons are arranged on botmux ask cards: compact wraps buttons across rows automatically; vertical places one button per row so labels are never squeezed by siblings and long option text stays readable. Applies to the next ask card — no session restart needed.
  • botDefaults.askOptionLayout.compact(en):Compact (auto-wrap)

竖放的「每行 1 个 / one per row」两种形态确实都是 1 个,保持不动。

源码注释与测试描述(6 处,去数字 / 去「占满整行」,与上面口径对齐)

  1. src/im/lark/ask-option-layout.ts 头注第 4 行 compact 描述:每行最多 4 个按钮的 action 行 → 改为类似「按行自动换行排列(独立卡 action 行、实时卡内嵌为 flow 行)」的表述;
  2. 同文件第 5 行 vertical 头注:各自包在单列 column_set 里占满整行宽度 → 各自包在单列 column_set 里单列排布,不被同排按钮挤压;
  3. src/bot-registry.ts BotConfig.askOptionLayout 字段注释:'compact'(默认,每行最多 4 个) → 'compact'(默认,按行自动换行);
  4. src/im/lark/ask-card.ts vertical 分支注释(// 竖放:… 那段)中的 占满整行宽度, → 单列排布,;
  5. src/im/lark/turn-reply-ask-elements.ts vertical 注释:(单列 weighted 占满整行) → (单列 weighted、不被同排挤压);
  6. test/turn-reply-ask.test.ts 用例描述串 vertical:每个选项一行(单列 weighted 占满整行) → …(单列 weighted、不被同排挤压)(纯描述文字,不断言)。

请不要动的两处(容易误改):

  • test/ask-card.test.ts 里「默认 compact:action 行每行最多 4 个按钮」的用例描述——它针对独立卡,4 个是事实正确;
  • 任何与终端框线相关的「占满整行」(如 test/resume-first-prompt-seed.test.ts)与本特性无关。

改完自查建议:全仓宽匹配反查 每行最多 4 / 每行 4 / up to 4 / 占满整行 / full-width,与 ask 布局相关的残留应为 0;zh/en 成对改(漏一边 tsc 会报)。

以上为自动评审的初步意见,最终以维护者审阅为准。再次感谢,这轮改完我们就走合并流程!

@deepcoldy
deepcoldy merged commit acb83fc into deepcoldy:master Sep 29, 2026
9 checks passed
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.33.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants