From d79f5a155935cfa89008fd0836f244c124320cfb Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:22:13 -0700 Subject: [PATCH 01/17] =?UTF-8?q?feat(lark):=20=E4=BC=98=E5=8C=96=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E5=8D=A1=E7=89=87=E7=BB=93=E6=9E=84=E5=8C=96=E6=8E=92?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-27-reply-card-layer2-backlog.md | 50 ++++++++ src/cli.ts | 5 +- src/im/lark/md-card.ts | 58 +++++++-- src/im/lark/message-parser.ts | 85 +++++++++++++ src/skills/definitions.ts | 29 ++++- test/builtin-skills.test.ts | 29 +++++ test/md-card.test.ts | 119 +++++++++++++++++- test/message-parser.test.ts | 84 +++++++++++++ 8 files changed, 441 insertions(+), 18 deletions(-) create mode 100644 docs/plans/2026-08-27-reply-card-layer2-backlog.md diff --git a/docs/plans/2026-08-27-reply-card-layer2-backlog.md b/docs/plans/2026-08-27-reply-card-layer2-backlog.md new file mode 100644 index 0000000000..1ef89d80a3 --- /dev/null +++ b/docs/plans/2026-08-27-reply-card-layer2-backlog.md @@ -0,0 +1,50 @@ +# 回复卡第二层 backlog(未启动) + +本轮已收口第一层:自由 Markdown + 五类写作配方;回复卡 `width_mode: fill`、H1/H2 `heading-2`;原生表格 quoted 回读认 CardKit 归一化单元格。第二层等下轮拍板后再细化,本文只记账。 + +## 硬约束(任何第二层布局都要满足) + +**quoted / history 回读必须对等。** 发出去的结构,跨 Agent `botmux quoted` / `history` 必须还能读回同等事实。本轮教训:`botmux send` 把表格发成 schema 2.0 native `table`,飞书读回时把 `lark_md` 单元格收成 `tag: markdown → property.elements[].property.content`(还可能夹 `code_span`)。只测 builder 原始 JSON 会绿、线上仍丢表。 + +下轮新增任何组件(`column_set` Diff、卡头、layout 壳)必须: + +- 解析器吃 **live 归一化形态**,不只 builder 输出 +- 单元格 / 分栏 / 标题文本都能还原成可读 Markdown +- 回归里同时覆盖 send 形态和 `quoted --raw` 形态 +- 不用 `quoted --raw` 的 config 反证发送字段:飞书会剥掉 `width_mode` / `text_size` 并把标题收成 `**加粗**` + +## 开放能力:`--layout` 薄壳 + +**语义:** 默认仍是自由 Markdown。Agent **显式 opt-in** 才套壳,不是选择器,也不是插件。 + +候选(下轮再砍): + +| 名字 | 意图 | 壳做什么 | 正文 | +|---|---|---|---| +| 不传 | 短确认 / 对不上配方 | 无 | 现有 renderer | +| `result` / `progress` / `compare` / `risk` / `handoff` | 与五类写作配方对齐 | 最多加中性卡头标题,不上色、不猜状态 | 仍走现在的 Markdown 渲染 | +| `diff` | 代码前后对比 | 见下一节 | 见下一节 | + +不做:`--card-template`、插件加载器、自动根据正文猜 layout、彩色状态头、假按钮(选择继续走 `botmux ask`)。 + +## 开放能力:Diff 分栏 opt-in + +**语义:** 只有 Agent 明确要 Diff(`--layout diff` 或等价约定)才左右分栏。禁止从普通相邻代码块猜测。 + +- 桌面:`column_set` 两列,左「之前 / 删除」、右「新增 / 变更后」 +- **移动端回退:** 列宽不够时不得挤成两条窄栏。`flex_mode` 在窄屏上改为上下堆叠(先 before 后 after),或直接退回顺序代码块。回退必须保持同样的文本事实,quoted 不能只剩其中一列 +- 单元格/代码块同样要走 live 归一化回读;`extractElementText` 已递归 `column_set`,但要补 **CardKit 读回后的列结构** 测例 +- 体量上限先沿用现有卡片截断策略,完整 Diff 给链接,不在卡里塞整份 patch + +## 字段清理(本轮刻意没动) + +同类失效字段,下轮和 layout 一起扫,避免再出现「JSON 改了、客户端没变化」。 + +1. **`wrapAdoptCard`**(`src/im/lark/card-builder.ts`):`schema: '2.0'` 却 `config.wide_screen_mode: true`。2.0 宽屏字段是 `width_mode: fill | compact | default`。与本轮回复卡修过的洞同构。只改 adopt 管理卡,不要把全部 schema 1.0 流式卡的 `wide_screen_mode` 误改掉。 +2. **回复卡 footer** `text_size: 'notation_small_v2'`(`buildReplyCardFooter`)。2.0 markdown 枚举是 `notation`;其它值回落 `normal`,页脚可能并不比正文更小。改字号时必须同步 `message-parser.ts` 里按 `notation_small_v2` 识别 footer 的分支,否则会漏剥或误剥。 + +## 下轮启动前先确认 + +- 要不要 `--layout` 这个 CLI 面,还是继续只靠写作配方 +- Diff 是否值得单独做;不要和进度条、自动上色绑在一起 +- adopt / footer 字段清理是否并进同一 PR(建议:字段清理可先于 layout,风险更小) diff --git a/src/cli.ts b/src/cli.ts index 89002a7f62..c799358cb1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7231,6 +7231,7 @@ import { buildImageCardElements, buildReplyCardFooter, prepareCardMarkdown, + REPLY_CARD_CONFIG, type CardUsageSnapshot, type LocalHomeLinkMode, } from './im/lark/md-card.js'; @@ -9483,7 +9484,7 @@ async function cmdSend(rest: string[]): Promise { } if (feedbackPolicy && effectiveResponseKind === 'final') { - const canonicalCard = { schema: '2.0', config: { update_multi: true }, body: { direction: 'vertical', elements: [...elements] } } as { schema: string; config: Record; body: { direction: string; elements: unknown[] } }; + const canonicalCard = { schema: '2.0', config: { ...REPLY_CARD_CONFIG }, body: { direction: 'vertical', elements: [...elements] } } as { schema: string; config: Record; body: { direction: string; elements: unknown[] } }; const feedbackElement = buildFeedbackElement(feedbackPolicy); const footerIndex = canonicalCard.body.elements.findIndex((element: any) => element?.element_id === 'botmux_reply_footer'); canonicalCard.body.elements.splice(footerIndex >= 0 ? footerIndex : canonicalCard.body.elements.length, 0, feedbackElement); @@ -9491,7 +9492,7 @@ async function cmdSend(rest: string[]): Promise { messageId = await dispatchPrimary(JSON.stringify(feedbackBaseCard), 'interactive'); } else { messageId = await dispatchPrimary(JSON.stringify({ - schema: '2.0', config: { update_multi: true }, body: { direction: 'vertical', elements }, + schema: '2.0', config: { ...REPLY_CARD_CONFIG }, body: { direction: 'vertical', elements }, }), 'interactive'); } } diff --git a/src/im/lark/md-card.ts b/src/im/lark/md-card.ts index fd3cffcef7..5ff0a655af 100644 --- a/src/im/lark/md-card.ts +++ b/src/im/lark/md-card.ts @@ -37,6 +37,19 @@ export { REPLY_CARD_FOOTER_MARKER } from './reply-card-footer-signature.js'; const md = new MarkdownIt({ html: false, linkify: false, breaks: false }); const MAX_LOCAL_HOME_LINK_REPAIRS = 256; +/** Keep structured replies readable without letting heading-heavy model output + * multiply schema-v2 body elements without bound. Each promoted heading can + * split one prose buffer into another element, so six keeps ordinary cards + * bounded while still covering the sections in a typical result report. */ +const MAX_PROMOTED_CARD_HEADINGS = 6; + +/** Canonical chrome for ordinary Bot Session reply cards. The CLI send path + * and daemon final-output fallback both spread this object so layout cannot + * drift between an explicit `botmux send` and an automatic fallback reply. */ +export const REPLY_CARD_CONFIG = { + update_multi: true, + width_mode: 'fill', +} as const; export type LocalHomeLinkMode = 'filesystem' | 'lexical' | 'disabled'; @@ -642,14 +655,15 @@ export function prepareCardMarkdown( * Split markdown into card v2 body elements: * 1. Pipe tables → native `table` widget (Feishu's markdown widget can't * render them as a grid). - * 2. Headings → bold (Feishu's markdown widget doesn't render ATX `#`). + * 2. H1/H2 → bounded standalone heading widgets; H3-H6 → bold (Feishu's + * markdown widget doesn't render ATX `#`). * 3. Code fences → re-emitted with the original backtick run, joined with * blank lines on either side (Feishu's widget needs them to recognise the * fence). * 4. Everything else → original source slice, glued by blank lines. * - * All non-table blocks are merged into a single `markdown` element to keep - * card element counts modest. + * Prose between promoted headings/tables/image rows is merged into one + * `markdown` element to keep card element counts modest. */ export function buildCardBodyElements( input: string, @@ -665,14 +679,18 @@ export function buildCardBodyElements( // else flows through the markdown element builder unchanged. Fence-aware so // image-looking lines inside ``` code blocks are left intact. const elements: any[] = []; + const layoutBudget = { promotedHeadings: 0 }; for (const seg of splitImageRowSegments(input)) { if (seg.type === 'imgrow') elements.push(imageRowElement(seg.keys)); - else elements.push(...buildMarkdownElements(seg.content)); + else elements.push(...buildMarkdownElements(seg.content, layoutBudget)); } return elements; } -function buildMarkdownElements(input: string): any[] { +function buildMarkdownElements( + input: string, + layoutBudget: { promotedHeadings: number }, +): any[] { if (!input) return []; input = unescapeFenceLines(input); const tokens = md.parse(input, {}); @@ -705,7 +723,27 @@ function buildMarkdownElements(input: string): any[] { if (t.type === 'heading_open') { const inline = tokens[i + 1]; const text = (inline?.content ?? '').replace(/^#{1,6}\s+/, '').trim(); - if (text) buf.push(`**${text}**`); + const level = Number.parseInt(t.tag.slice(1), 10); + const promote = text + && level <= 2 + && /^#{1,2}$/.test(t.markup) + && layoutBudget.promotedHeadings < MAX_PROMOTED_CARD_HEADINGS; + if (promote) { + // Feishu's markdown widget does not render ATX markers. A standalone + // Card JSON 2.0 20px heading restores hierarchy without making H1 + // model output dominate the card. + flushBuf(); + elements.push({ + tag: 'markdown', + text_size: 'heading-2', + content: text, + }); + layoutBudget.promotedHeadings++; + } else if (text) { + // H3-H6 and headings beyond the safety budget retain the established + // compact fallback instead of increasing the component count further. + buf.push(`**${text}**`); + } i += 3; // heading_open, inline, heading_close continue; } @@ -956,7 +994,7 @@ export function buildMarkdownCard( } return JSON.stringify({ schema: '2.0', - config: { update_multi: true }, + config: { ...REPLY_CARD_CONFIG }, body: { direction: 'vertical', elements }, }); } @@ -984,7 +1022,7 @@ export function buildCanonicalFinalReplyCard(opts: { locale: opts.locale, }); if (footer) elements.push({ tag: 'hr' }, footer.element); - return JSON.stringify({ schema: '2.0', config: { update_multi: true }, body: { direction: 'vertical', elements } }); + return JSON.stringify({ schema: '2.0', config: { ...REPLY_CARD_CONFIG }, body: { direction: 'vertical', elements } }); } /** Prefix every line with `> ` so Feishu's markdown widget renders it as a @@ -1040,7 +1078,7 @@ export function buildContextualReplyCard(opts: { elements.push({ tag: 'markdown', - text_size: 'heading_2_v2', + text_size: 'heading-2', content: title, }); @@ -1078,7 +1116,7 @@ export function buildContextualReplyCard(opts: { return JSON.stringify({ schema: '2.0', - config: { update_multi: true }, + config: { ...REPLY_CARD_CONFIG }, body: { direction: 'vertical', elements }, }); } diff --git a/src/im/lark/message-parser.ts b/src/im/lark/message-parser.ts index b5c73a89f8..e746b20cb9 100644 --- a/src/im/lark/message-parser.ts +++ b/src/im/lark/message-parser.ts @@ -1291,6 +1291,86 @@ function withImgAlt(label: string, alt: string): string { return label.endsWith(']') ? `${label.slice(0, -1)}: ${alt}]` : `${label} (${alt})`; } +/** Normalize a schema-v2 table label/cell into one safe pipe-table cell. + * Lark table values are usually strings (`lark_md`), but third-party cards + * may wrap text in a plain-text object. Message reads also return CardKit's + * normalized shape (`property.elements[].property.content`) rather than the + * original string sent by botmux. */ +function nestedCardTableText(value: unknown): string | undefined { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (Array.isArray(value)) { + const parts = value.map(nestedCardTableText).filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join('') : undefined; + } + if (!value || typeof value !== 'object') return undefined; + + const record = value as Record; + const direct = firstNonEmptyString( + record.text?.content, + record.content, + record.property?.content, + typeof record.text === 'string' ? record.text : undefined, + ); + if (direct) return direct; + + // CardKit may expose both a rendered `elements` tree and markdown metadata. + // Prefer the rendered tree and only fall back to metadata to avoid repeating + // the same visible cell text twice. + const childGroups = [ + record.property?.elements, + record.elements, + record.property?.markdownElements, + record.markdownElements, + ]; + for (const children of childGroups) { + if (!Array.isArray(children) || children.length === 0) continue; + const nested = nestedCardTableText(children); + if (nested) return nested; + } + return undefined; +} + +/** Newlines become `
` so a single value cannot split the reconstructed + * table into extra rows. */ +function cardTableCellText(value: unknown): string { + const text = nestedCardTableText(value); + return (text ?? '') + .replace(/\r\n?/g, '\n') + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\n/g, '
'); +} + +/** Reconstruct an original schema-v2 native table as readable pipe Markdown. + * `botmux send` deliberately emits native tables because Lark's markdown + * widget cannot draw a grid; history/quoted/cross-bot reads must still retain + * those facts instead of silently dropping the whole component. */ +function extractTableMarkdown(el: any): string | null { + if (!Array.isArray(el?.columns) || el.columns.length === 0) return null; + const columns = el.columns.map((column: any, index: number) => ({ + key: typeof column?.name === 'string' && column.name ? column.name : `c${index}`, + label: cardTableCellText(column?.display_name) || `列 ${index + 1}`, + })); + const lines = [ + `| ${columns.map((column: any) => column.label).join(' | ')} |`, + `| ${columns.map(() => '---').join(' | ')} |`, + ]; + for (const row of Array.isArray(el.rows) ? el.rows : []) { + const cells = columns.map((column: any, index: number) => { + const value = Array.isArray(row) + ? row[index] + : row && typeof row === 'object' + ? row[column.key] + : undefined; + return cardTableCellText(value); + }); + lines.push(`| ${cells.join(' | ')} |`); + } + return lines.join('\n'); +} + type ResourcePusher = (resources: MessageResource[], r: MessageResource) => void; /** Recursively extract image resources from an original-format card element. */ @@ -1345,6 +1425,11 @@ function extractElementText(el: any, parts: string[], imgLabel: (key: string) => if (text) parts.push(text); } + if (tag === 'table') { + const table = extractTableMarkdown(el); + if (table) parts.push(table); + } + // div.fields[] — v2 cards put most body text in a fields array of lark_md // cells (规则/报警时间/Tags/检测结果/详情链接…), NOT in el.text. Without this // the entire detail section of field-based cards (e.g. Argos alarm cards) diff --git a/src/skills/definitions.ts b/src/skills/definitions.ts index 8d7a8c3894..51019ceb16 100644 --- a/src/skills/definitions.ts +++ b/src/skills/definitions.ts @@ -279,7 +279,7 @@ description: 向飞书话题发送消息。用户在飞书上阅读看不到终 **发送成功判定 & 不要重发**:\`botmux send\` 退出码为 0(返回 \`{"success":true,...}\`)就代表消息**已经送达**用户——即使你的终端里看不到任何回执,也不用再发一遍。发完 \`botmux send\` 后,本轮「终端没有可见文本、直接安静结束」是正常且预期的。如果之后看到类似「你上一条回复没有可见输出,请继续并产出用户可见回复」这样的提示,那是底层 CLI(Claude Code 等)的误判——**不要重发**,只有当 \`botmux send\` 自己报错(非零退出或打印「发送失败」)时才需要重试。 -**格式自动处理**:内容含 markdown 语法时自动用飞书卡片(schema 2.0)发送,原生渲染;纯文本走普通消息。**该用 md 就用 md**——结构化内容(列表、表格、代码块)不要手撸成纯文本。 +**格式自动处理**:普通回复统一用飞书卡片(schema 2.0)发送;单句纯文本仍保持轻量正文,Markdown 标题和表格转换为独立组件,代码块由富文本组件原生渲染。**该用 md 就用 md**——结构化内容不要手撸成纯文本或 ASCII 表格。 ## 什么时候用 @@ -294,6 +294,30 @@ description: 向飞书话题发送消息。用户在飞书上阅读看不到终 - 给自己看的分析笔记 - 纯粹的代码操作(编辑/运行命令) +## 输出排版:按信息复杂度分流 + +- **单句确认 / 简短状态**:直接写一段,不强加标题、表格或“结论/详情”空壳。 +- **复杂结果 / 方案 / 风险**:先用一个简短的 \`#\` 或 \`##\` 标题,再用一句话给结论;正文只保留 2–4 个有信息量的分节。 +- **多项同类事实**用列表,对比或字段映射用 pipe 表格,命令与代码用 fenced code block;不要靠连续空格、全角符号或 ASCII 线框对齐。 +- **需要用户继续动作**时,把“下一步 / 需要你确认”放在最后一节;状态 emoji 只作少量语义提示,不堆装饰。 + +短消息保持轻,只有信息确实需要层级时才使用结构化 Markdown。 + +### 可选排版配方(参考,不是强制模板) + +默认使用自由 Markdown;根据回复的真实语义选择配方,不匹配就不用,也可以混搭。标题名称、区块顺序、语气和少量 emoji 都可以个性化,**不要输出没有内容的占位区块**。 + +| 语义信号 | 参考配方 | 示例骨架 | +|---|---|---| +| 已完成并交付 | 结果摘要 | \`# 结果\` → 一句话结论 → \`## 变更\` → \`## 验证\` → 可选的下一步 | +| 任务仍在运行,需要汇报走到哪 | 进度更新 | \`# 进度\`(仅有可靠数据时附 N%)→ 当前状态 → 已完成 / 进行中 / 下一步 | +| 两个以上方案需要比较 | 方案对比 | \`# 方案对比\` → 一句话判断 → 对比表格 → 推荐与理由 | +| 存在风险或需要用户拍板 | 风险 / 待确认 | \`# 需要确认\` → 影响 → 选项或风险表 → 明确请用户决定什么 | +| 任务失败或被硬阻塞(需要人介入) | 风险 / 待确认 | 失败 / 阻塞原因 → 影响 → 需要用户做什么;发送时加 \`--attention\` | +| 交给下一个 Agent 或人继续 | 交接说明 | \`# 交接\` → 已完成与产物 → 剩余事项 / 风险 → 下一位执行者和动作 | + +配方提供稳定的信息结构,不规定 Agent 的口吻:表中的标题、顺序、emoji 和具体骨架都只是示例,可以改名、删减、重排或组合,保留自己的表达风格。单句确认和简短状态仍直接回复;对不上这五类时继续用自由 Markdown,不为“使用模板”而使用模板。配方只影响 Markdown 写法,不自动选择彩色卡头、状态色或特殊布局;需要用户从选项中做真实选择时使用 \`botmux ask\`,不要在普通回复里画不能点击的按钮。 + ## 卡住了需要人介入:\`--attention\` 自主跑任务时撞到**只有人类才能解除**的硬阻碍、无法继续时,回消息带 \`--attention\` 举手:消息照常发出,同时本会话进 dashboard「需要你」列、并把原因显示给用户。 @@ -363,7 +387,8 @@ botmux send --content-file $msg | 语法 | 渲染 | |---|---| -| \`# / ## / ###\` 标题 | 转**加粗**(v2 markdown 元素不支持 ATX 标题) | +| \`# / ##\` 标题 | 独立标题组件(最多提升前 6 个,超出后回退为加粗) | +| \`###\`–\`######\` 标题 | 转**加粗**,避免把卡片拆得过碎 | | \`**加粗**\` / \`*斜体*\` / \`~~删除线~~\` | 原生渲染 | | \`\\\`inline code\\\`\` / \\\`\\\`\\\` 代码块 \\\`\\\`\\\` | 原生渲染(代码块内 \`#\` 和 \`|\` 不会被误解析) | | \`- 项\` / \`1. 项\` / 嵌套列表 | 原生渲染 | diff --git a/test/builtin-skills.test.ts b/test/builtin-skills.test.ts index acfcda405b..1a5d9f3b00 100644 --- a/test/builtin-skills.test.ts +++ b/test/builtin-skills.test.ts @@ -38,6 +38,35 @@ describe('built-in botmux-send skill', () => { expect(skill!.content).toContain('--content-file > 位置参数 > stdin'); expect(skill!.content).toContain('多行正文推荐只放在 heredoc/stdin 中'); }); + + it('teaches complexity-adaptive layout that matches the schema-v2 renderer', () => { + const skill = BUILTIN_SKILLS.find(s => s.name === 'botmux-send'); + expect(skill).toBeDefined(); + expect(skill!.content).toContain('普通回复统一用飞书卡片(schema 2.0)'); + expect(skill!.content).toContain('单句确认 / 简短状态'); + expect(skill!.content).toContain('复杂结果 / 方案 / 风险'); + expect(skill!.content).toContain('独立标题组件(最多提升前 6 个'); + expect(skill!.content).toContain('下一步 / 需要你确认'); + expect(skill!.content).toContain('可选排版配方(参考,不是强制模板)'); + expect(skill!.content).toContain('结果摘要'); + expect(skill!.content).toContain('进度更新'); + expect(skill!.content).toContain('方案对比'); + expect(skill!.content).toContain('风险 / 待确认'); + expect(skill!.content).toContain('交接说明'); + expect(skill!.content).toContain('默认使用自由 Markdown'); + expect(skill!.content).toContain('语义信号'); + expect(skill!.content).toContain('两个以上方案需要比较'); + expect(skill!.content).toContain('任务失败或被硬阻塞(需要人介入)'); + expect(skill!.content).toContain('发送时加 `--attention`'); + expect(skill!.content).toContain('对不上这五类时继续用自由 Markdown'); + expect(skill!.content).toContain('仅有可靠数据时附 N%'); + expect(skill!.content).toContain('可以改名、删减、重排或组合'); + expect(skill!.content).toContain('不要输出没有内容的占位区块'); + expect(skill!.content).toContain('配方只影响 Markdown 写法'); + expect(skill!.content).toContain('使用 `botmux ask`'); + expect(skill!.content).toContain('不要在普通回复里画不能点击的按钮'); + expect(skill!.content).not.toContain('纯文本走普通消息'); + }); }); describe('built-in botmux-history skill', () => { diff --git a/test/md-card.test.ts b/test/md-card.test.ts index 8e3ef3f6c0..a770af28f3 100644 --- a/test/md-card.test.ts +++ b/test/md-card.test.ts @@ -13,6 +13,7 @@ import { join } from 'node:path'; import { appendReplyCardFooterToV2Card, buildCardBodyElements, + buildCanonicalFinalReplyCard, buildImageCardElements, buildMarkdownCard, buildContextualReplyCard, @@ -40,11 +41,78 @@ describe('buildCardBodyElements', () => { expect(out[0]).toMatchObject({ tag: 'markdown', content: 'hello world' }); }); - it('promotes ATX headings to bold (Feishu markdown widget can\'t render #)', () => { + it('promotes H1/H2 to standalone heading components', () => { const out = buildCardBodyElements('# Title\n\nbody text'); - const text = mdElements(out)[0].content; - expect(text).toContain('**Title**'); - expect(text).not.toMatch(/^#\s/m); + expect(out).toEqual([ + { tag: 'markdown', text_size: 'heading-2', content: 'Title' }, + { tag: 'markdown', content: 'body text' }, + ]); + + expect(buildCardBodyElements('## Section\n\nbody')).toEqual([ + { tag: 'markdown', text_size: 'heading-2', content: 'Section' }, + { tag: 'markdown', content: 'body' }, + ]); + }); + + it('keeps H3-H6 compact as bold markdown', () => { + const out = buildCardBodyElements('### Detail\n\nbody text'); + expect(out).toEqual([{ tag: 'markdown', content: '**Detail**\n\nbody text' }]); + }); + + it('keeps setext headings on the compact fallback path', () => { + const out = buildCardBodyElements('Legacy title\n===\n\nbody text'); + expect(out).toEqual([{ tag: 'markdown', content: '**Legacy title**\n\nbody text' }]); + }); + + it('keeps structured heading, prose, table, and code sections in source order', () => { + const input = [ + '# 执行结果', + '', + '核心链路已验证。', + '', + '| 项目 | 结果 |', + '| --- | --- |', + '| build | pass |', + '', + '## 验证命令', + '', + '```bash', + 'bun run build', + '```', + ].join('\n'); + const out = buildCardBodyElements(input); + + expect(out.map(element => element.tag)).toEqual([ + 'markdown', 'markdown', 'table', 'markdown', 'markdown', + ]); + expect(out[0]).toMatchObject({ text_size: 'heading-2', content: '执行结果' }); + expect(out[1].content).toBe('核心链路已验证。'); + expect(out[2].rows).toEqual([{ c0: 'build', c1: 'pass' }]); + expect(out[3]).toMatchObject({ text_size: 'heading-2', content: '验证命令' }); + expect(out[4].content).toContain('```bash\nbun run build\n```'); + }); + + it('bounds promoted headings and preserves overflow headings as bold text', () => { + const input = Array.from( + { length: 8 }, + (_, index) => `# Section ${index + 1}\n\nbody ${index + 1}`, + ).join('\n\n'); + const out = buildCardBodyElements(input); + const promoted = out.filter(element => element.text_size === 'heading-2'); + const rendered = mdElements(out).map(element => element.content).join('\n\n'); + + expect(promoted).toHaveLength(6); + expect(rendered).toContain('**Section 7**'); + expect(rendered).toContain('**Section 8**'); + expect(rendered).toContain('body 8'); + }); + + it('does not promote heading-looking lines inside a code fence', () => { + const out = buildCardBodyElements('```markdown\n# literal heading\n```'); + expect(out).toEqual([{ + tag: 'markdown', + content: '```markdown\n# literal heading\n```', + }]); }); it('Bug A: code fence with no blank line before/after still emits a fenced block', () => { @@ -663,6 +731,33 @@ describe('normalizeLocalHomeLinks', () => { }); describe('buildMarkdownCard', () => { + it('uses wide-screen chrome consistently across ordinary reply builders', () => { + const cards = [ + buildMarkdownCard('hello'), + buildCanonicalFinalReplyCard({ markdown: 'hello' }), + buildContextualReplyCard({ + title: 'Local turn', + assistantText: 'hello', + assistantLabel: 'Codex', + }), + ].map(json => JSON.parse(json)); + + for (const card of cards) { + expect(card.config).toEqual({ update_multi: true, width_mode: 'fill' }); + } + }); + + it('keeps explicit builder and fallback-final body layout identical', () => { + const markdown = '# 结果\n\n已完成。\n\n## 验证\n\n- build\n- test'; + const explicitSendElements = buildImageCardElements(markdown, []); + const fallbackElements = JSON.parse(buildCanonicalFinalReplyCard({ + markdown, + brand: '', + })).body.elements; + + expect(fallbackElements).toEqual(explicitSendElements); + }); + it('renders native context in the reply-card footer (token stays off the footer)', () => { const json = buildMarkdownCard('hello', 'ou_abc', undefined, 'zh', undefined, 'filesystem', { context: { usedTokens: 159_861, windowTokens: 258_400, percentUsed: 62 }, @@ -1241,6 +1336,22 @@ describe('buildImageCardElements', () => { expect(out[2]).toMatchObject({ tag: 'markdown', content: 'bottom' }); }); + it('shares the six-heading promotion budget across image-row segments', () => { + const input = Array.from({ length: 8 }, (_, index) => [ + `# Section ${index + 1}`, + `body ${index + 1}`, + index < 7 ? '![](img:0,1)' : '', + ].filter(Boolean).join('\n\n')).join('\n\n'); + const out = buildImageCardElements(input, K.slice(0, 2)); + const promoted = out.filter(element => element.text_size === 'heading-2'); + const rendered = mdElements(out).map(element => element.content).join('\n\n'); + + expect(out.filter(element => element.tag === 'column_set')).toHaveLength(7); + expect(promoted).toHaveLength(6); + expect(rendered).toContain('**Section 7**'); + expect(rendered).toContain('**Section 8**'); + }); + it('mixed: grouped row + an unreferenced image appended at the end', () => { const out = buildImageCardElements('![](img:0,1)', K.slice(0, 3)); expect(out.some(e => e.tag === 'column_set')).toBe(true); diff --git a/test/message-parser.test.ts b/test/message-parser.test.ts index d6eb2c40af..85a982d7f1 100644 --- a/test/message-parser.test.ts +++ b/test/message-parser.test.ts @@ -671,6 +671,90 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = expect(result.content).not.toContain('发送给'); }); + it('round-trips standalone reply headings without losing section text', () => { + const raw = buildMarkdownCard( + '# 执行结果\n\n核心链路已验证。\n\n## 下一步\n\n请在飞书确认排版。', + 'ou_owner', + ); + const result = parseApiMessage(makeMsg('interactive', JSON.parse(raw))); + + expect(result.content).toContain('执行结果'); + expect(result.content).toContain('核心链路已验证。'); + expect(result.content).toContain('下一步'); + expect(result.content).toContain('请在飞书确认排版。'); + expect(result.content).not.toContain('botmux'); + expect(result.content).not.toContain('发送给'); + }); + + it('round-trips native reply tables as readable pipe Markdown', () => { + const raw = buildMarkdownCard([ + '## 验证结果', + '', + '| 项目 | 结果 |', + '| --- | --- |', + '| build | pass |', + '| test | 280 passed |', + ].join('\n'), 'ou_owner'); + const result = parseApiMessage(makeMsg('interactive', JSON.parse(raw))); + + expect(result.content).toContain('| 项目 | 结果 |'); + expect(result.content).toContain('| --- | --- |'); + expect(result.content).toContain('| build | pass |'); + expect(result.content).toContain('| test | 280 passed |'); + expect(result.content).not.toContain('botmux'); + expect(result.content).not.toContain('发送给'); + }); + + it('escapes pipes and folds newlines when flattening third-party table cells', () => { + const card = { + schema: '2.0', + body: { elements: [{ + tag: 'table', + columns: [ + { name: 'name', display_name: { tag: 'plain_text', content: '名称' } }, + { name: 'detail', display_name: '详情' }, + ], + rows: [{ name: 'A | B', detail: 'line 1\nline 2' }], + }] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + + expect(result.content).toContain('| 名称 | 详情 |'); + expect(result.content).toContain('| A \\| B | line 1
line 2 |'); + }); + + it('round-trips CardKit-normalized table cells returned by the live message API', () => { + const normalizedCell = (content: string) => ({ + tag: 'markdown', + property: { + elements: [{ + tag: 'plain_text', + property: { content, textAlign: 'left' }, + }], + markdownElements: [], + originTag: 'lark_md', + }, + }); + const card = { + schema: '2.0', + body: { elements: [{ + tag: 'table', + columns: [ + { name: 'c0', display_name: normalizedCell('检查项') }, + { name: 'c1', display_name: '预期结果' }, + ], + rows: [{ + c0: normalizedCell('标题层级'), + c1: normalizedCell('H1/H2 明显大于正文'), + }], + }] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + + expect(result.content).toContain('| 检查项 | 预期结果 |'); + expect(result.content).toContain('| 标题层级 | H1/H2 明显大于正文 |'); + }); + it('round-trips a footer whose custom brand contains an unmatched bracket', () => { const raw = buildMarkdownCard('正文内容', 'ou_owner', 'Acme [beta'); const result = parseApiMessage(makeMsg('interactive', JSON.parse(raw))); From c7323d5a62c20f20e97f93f49bdcbad022ac231c Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:35:54 -0700 Subject: [PATCH 02/17] =?UTF-8?q?fix(lark):=20=E4=BF=AE=E6=AD=A3=20Card=20?= =?UTF-8?q?2.0=20=E5=AE=BD=E5=BA=A6=E4=B8=8E=E9=A1=B5=E8=84=9A=E5=AD=97?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 2 +- src/im/lark/card-builder.ts | 2 +- src/im/lark/md-card.ts | 4 ++-- src/im/lark/message-parser.ts | 6 +++++- test/card-builder.test.ts | 6 ++++++ test/md-card.test.ts | 8 ++++---- test/message-parser.test.ts | 10 ++++++++-- 7 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index c799358cb1..dad708fd50 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9460,7 +9460,7 @@ async function cmdSend(rest: string[]): Promise { tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [footer?.element ?? { tag: 'markdown', - text_size: 'notation_small_v2', + text_size: 'notation', content: ' ', }], }, diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index a76d89c3f6..db07f12da8 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -2850,7 +2850,7 @@ export function buildAdoptSelectCard( function wrapAdoptCard(elements: any[], locale?: Locale): any { return { schema: '2.0', - config: { update_multi: true, wide_screen_mode: true }, + config: { update_multi: true, width_mode: 'fill' }, header: { template: 'blue', title: { tag: 'plain_text', content: t('card.adopt.title', undefined, locale) }, diff --git a/src/im/lark/md-card.ts b/src/im/lark/md-card.ts index 5ff0a655af..094a33adc0 100644 --- a/src/im/lark/md-card.ts +++ b/src/im/lark/md-card.ts @@ -86,7 +86,7 @@ export interface ReplyCardFooter { element: { tag: 'markdown'; element_id: typeof REPLY_CARD_FOOTER_ELEMENT_ID; - text_size: 'notation_small_v2'; + text_size: 'notation'; content: string; }; } @@ -489,7 +489,7 @@ export function buildReplyCardFooter(opts: { element: { tag: 'markdown', element_id: REPLY_CARD_FOOTER_ELEMENT_ID, - text_size: 'notation_small_v2', + text_size: 'notation', content, }, }; diff --git a/src/im/lark/message-parser.ts b/src/im/lark/message-parser.ts index e746b20cb9..6c01c3ad59 100644 --- a/src/im/lark/message-parser.ts +++ b/src/im/lark/message-parser.ts @@ -1412,7 +1412,11 @@ function extractElementText(el: any, parts: string[], imgLabel: (key: string) => if ( el.element_id === REPLY_CARD_FOOTER_ELEMENT_ID && tag === 'markdown' - && (el.text_size === undefined || el.text_size === 'notation_small_v2') + && ( + el.text_size === undefined + || el.text_size === 'notation' + || el.text_size === 'notation_small_v2' + ) && typeof elementText === 'string' && hasExactMarkerMarkdown(elementText) ) { diff --git a/test/card-builder.test.ts b/test/card-builder.test.ts index 967ad0ad8a..75daca91f5 100644 --- a/test/card-builder.test.ts +++ b/test/card-builder.test.ts @@ -93,6 +93,12 @@ describe('buildAdoptSelectCard (V2 picker)', () => { .filter((e: any) => e.tag === 'interactive_container') .map((e: any) => (e.elements ?? []).map((x: any) => x.content ?? '').join('\n')); + it('uses Card JSON 2.0 fill width instead of the legacy wide-screen field', () => { + const card = parse(buildAdoptSelectCard([], 'om_root', 'en')); + expect(card.schema).toBe('2.0'); + expect(card.config).toEqual({ update_multi: true, width_mode: 'fill' }); + }); + it('renders a live session as a card showing CLI / source / path / target, not a dropdown option', () => { const card = parse(buildAdoptSelectCard([{ source: 'herdr', diff --git a/test/md-card.test.ts b/test/md-card.test.ts index a770af28f3..42ec809ca8 100644 --- a/test/md-card.test.ts +++ b/test/md-card.test.ts @@ -1023,7 +1023,7 @@ describe('buildReplyCardFooter', () => { expect(footer?.element).toMatchObject({ tag: 'markdown', element_id: 'botmux_reply_footer', - text_size: 'notation_small_v2', + text_size: 'notation', content: footer?.content, }); }); @@ -1213,7 +1213,7 @@ describe('buildMarkdownCard footer brand', () => { it('empty brand + no recipient → no footer at all (no orphan hr)', () => { const els = JSON.parse(buildMarkdownCard('hi', undefined, '')).body.elements; expect(els.some((e: any) => e.tag === 'hr')).toBe(false); - expect(els.some((e: any) => e.text_size === 'notation_small_v2')).toBe(false); + expect(els.some((e: any) => e.element_id === 'botmux_reply_footer')).toBe(false); expect(JSON.stringify(els)).not.toContain('botmux'); expect(els.some((e: any) => e.tag === 'markdown' && /hi/.test(e.content))).toBe(true); }); @@ -1400,7 +1400,7 @@ describe('buildContextualReplyCard footer brand', () => { title: 'T', assistantText: 'a', assistantLabel: 'Claude', recipientOpenId: 'ou_x', brand: 'Acme', })).body.elements; const last = els[els.length - 1]; - expect(last.text_size).toBe('notation_small_v2'); + expect(last.text_size).toBe('notation'); expect(last.content).toContain('Acme'); expect(last.content).not.toContain('botmux'); }); @@ -1409,7 +1409,7 @@ describe('buildContextualReplyCard footer brand', () => { const els = JSON.parse(buildContextualReplyCard({ title: 'T', assistantText: 'a', assistantLabel: 'Claude', brand: '', })).body.elements; - expect(els.some((e: any) => e.text_size === 'notation_small_v2')).toBe(false); + expect(els.some((e: any) => e.element_id === 'botmux_reply_footer')).toBe(false); expect(JSON.stringify(els)).not.toContain('botmux'); }); }); diff --git a/test/message-parser.test.ts b/test/message-parser.test.ts index 85a982d7f1..ac507fd0af 100644 --- a/test/message-parser.test.ts +++ b/test/message-parser.test.ts @@ -810,7 +810,11 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = // ─── Structural footer strip (brand-agnostic, for per-bot custom brands) ── describe('Interactive card parsing: footer stripped structurally (custom brand)', () => { - it('drops a schema 2.0 footer element without text_size when it carries the exact split-font marker', () => { + it.each([ + { name: 'without text_size', textSize: undefined }, + { name: 'with Card 2.0 notation', textSize: 'notation' }, + { name: 'with legacy notation_small_v2', textSize: 'notation_small_v2' }, + ])('drops a schema 2.0 footer $name when it carries the exact split-font marker', ({ textSize }) => { const card = { schema: '2.0', body: { elements: [ @@ -819,6 +823,7 @@ describe('Interactive card parsing: footer stripped structurally (custom brand)' { element_id: 'botmux_reply_footer', tag: 'markdown', + ...(textSize === undefined ? {} : { text_size: textSize }), content: '[botmux](https://github.com/deepcoldy/botmux)' + " " + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1)' @@ -837,6 +842,7 @@ describe('Interactive card parsing: footer stripped structurally (custom brand)' footer: { element_id: 'botmux_reply_footer', tag: 'markdown', + text_size: 'notation', content: '[footer spec](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1)', }, expected: 'footer spec', @@ -1003,7 +1009,7 @@ describe('botmux internal callback buttons (🔊 语音总结 …) dropped from { tag: 'hr' }, { tag: 'column_set', flex_mode: 'none', columns: [ { tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', - elements: [{ tag: 'markdown', text_size: 'notation_small_v2', content: ' ' }] }, + elements: [{ tag: 'markdown', text_size: 'notation', content: ' ' }] }, { tag: 'column', width: 'auto', vertical_align: 'center', elements: [{ tag: 'button', text: { tag: 'plain_text', content: '🔊 语音总结' }, From 1b2f187c1bf5a755344c10920414bbf6949d31a0 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:11:58 -0700 Subject: [PATCH 03/17] =?UTF-8?q?docs(lark):=20=E8=A1=A5=E5=85=85=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E5=8D=A1=20layout=20=E9=85=8D=E7=BD=AE=E8=A7=84?= =?UTF-8?q?=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 作为 --layout 终态实现规格:五档卡头与标签定稿、按 bot 开关和预设主题、本轮排除 Diff/对比档。 --- .../2026-08-27-reply-card-style-config.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/plans/2026-08-27-reply-card-style-config.md diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md new file mode 100644 index 0000000000..a5a783e647 --- /dev/null +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -0,0 +1,100 @@ +# 回复风格配置化(PR #1057 终态规格) + +`--layout` 薄壳 + 按 bot 配置的实现规格。写作配方、`width_mode: fill`、`heading-2`、表格 live 回读已在同 PR 落地。 + +**本轮定稿(实现必须对齐,不要自行改语义):** + +- 五档:`result` / `progress` / `risk` / `blocked` / `handoff` +- 卡头:绿 / 蓝 / 橙 / 红 / **indigo**(交接不用 grey,避免像已失效) +- 标签:只有 `risk`、`blocked` 带「需要你」;其它档不带标签 +- **不设** `compare` 档:对比用自由 Markdown;要人做选择走 `risk` + `botmux ask` +- **Diff 分栏不进本轮**(仍记在 `docs/plans/2026-08-27-reply-card-layer2-backlog.md`,另开) + +配置落在 **bot 维度**。飞书一条卡片只有一份渲染,群里不能按读者换皮肤。私聊按人配不进本轮。 + +## 底线(任何配置组合都不放开) + +- 不做假按钮;要选择走 `botmux ask` +- 不接受调用方注入任意卡片 JSON 当「主题」(`--card-json` 仍是逃生阀,和风格配置无关) +- quoted / history 回读对等:换主题不能让跨 bot 读回丢字段 +- 不从正文猜成功/失败再上色;颜色只来自 Agent 显式 `--layout <档>` + 该 bot 的主题映射 + +## 配置项清单 + +| 键 | 类型 | 缺省 | 作用 | +|---|---|---|---| +| `replyStyle.recipes` | bool | `true` | 是否把五类写作配方注入 `botmux-send` 指南。`false` → 指南回到纯自由 Markdown | +| `replyStyle.layout` | bool | `true`(layout 能力落地后) | 是否允许 `--layout`。`false` → flag 被忽略,回退普通回复卡 | +| `replyStyle.theme` | `'default' \| 'minimal' \| 'vivid'` | `'default'` | 预设主题,只改变壳的「重」,不改变五档语义 | +| `replyStyle.recipePrompt` | string | 缺省 | **本轮不做**:覆写/追加该 bot 的配方引导文案 | +| `replyStyle.layoutColors` | 对象 | 缺省映射 | **本轮不做**:在飞书 `header.template` 官方枚举内改档位颜色 | +| `replyStyle.layoutTags` | 对象 | 见下 | **本轮不做**:改标签文案。空字符串 = 该档不显示标签 | + +`layoutColors` 只允许飞书卡头 template 枚举:`blue` / `wathet` / `turquoise` / `green` / `yellow` / `orange` / `red` / `carmine` / `violet` / `purple` / `indigo` / `grey`。非法值忽略并打日志,回退主题缺省,不发送失败。 + +## 预设主题(第一批就要能跑) + +五档语义固定,主题只调视觉重量。 + +| 主题 | 卡头 | 标签 | +|---|---|---| +| `default` | `green` / `blue` / `orange` / `red` / `indigo` | 仅 `risk`、`blocked` →「需要你」 | +| `minimal` | 五档都无彩色 template,只留标题 | 仅 `risk`、`blocked` →「需要你」 | +| `vivid` | 同 default 的五色 | 五档都带:完成 / 进行中 / 需要你 / 需要你 / 交接 | + +`vivid` 的额外标签是主题增量;**default 才是定稿观感**。实现时 default 不得给 result/progress/handoff 加标签。 + +短确认、未传 `--layout`:三种主题都 **不套壳**。没有 `compare`、`diff` 这两个名字。 + +## 落点 + +**bots.json**(每个 bot 一条,紧挨 `brandLabel` / `usageDisplay` 这类展示配置): + +```json +{ + "replyStyle": { + "recipes": true, + "layout": true, + "theme": "default" + } +} +``` + +缺省整块省略 = 上表缺省。不要做成全局 daemon 配置,避免一改全员 Bot 变脸。 + +**Dashboard**:Bot 设置里、品牌文案附近加一小节「回复风格」。第一批 UI 只需: + +- 配方引导:开 / 关 +- layout 壳:开 / 关 +- 主题:默认 / 极简 / 鲜明(下拉,枚举写死) + +第二批再放开「配方文本」多行输入、每档颜色下拉(同样锁官方枚举)。 + +**skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。 + +**CLI**:`--layout` 只在 `layout !== false` 时生效;关掉则 stderr 一行提示已忽略,消息仍按普通回复卡发出,不 fail。 + +## 切分 + +### 本轮终态(本 PR) + +1. 五档 layout 薄壳:卡头按上表 + 规定标签 + 正文仍走现有 Markdown(不加原生分栏、不加进度条、不加按钮) +2. `replyStyle.recipes` / `replyStyle.layout` / `replyStyle.theme`;枚举锁死,非法值忽略并回退缺省,发送不失败 +3. bots.json 解析 + Dashboard 三个控件 +4. `recipes === false` 时指南去掉配方表和选型信号;`layout === false` 时指南不提 `--layout`,CLI 忽略 flag +5. 回读:换主题后 `quoted` / `history` 仍能还原正文和表格;测例必须用 **live 归一化形态**,不认 builder 原始 JSON + +### 本轮之后 + +1. `recipePrompt` 覆写/追加写作引导 +2. `layoutColors` / `layoutTags` 枚举内微调 +3. Diff 分栏(见 layer2 backlog) +4. 私聊按人覆盖(若要做);群聊永远不按读者分皮肤 + +## 明确不做(本轮) + +- `compare` 档、`diff` 档、进度条、假按钮、插件模板 +- 读者侧主题切换、一条消息两套渲染 +- 从标题或正文关键词自动选档、自动上色 +- 任意卡片 JSON 当主题 +- 用 grey 做 handoff 卡头 From f8fb7082977c904139e51296b0c63e69a3e1ccc1 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:15:43 -0700 Subject: [PATCH 04/17] =?UTF-8?q?docs(lark):=20=E5=B0=86=E9=85=8D=E6=96=B9?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E4=B8=8E=E8=89=B2=E6=9D=BF=E5=BE=AE=E8=B0=83?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=20layout=20=E7=BB=88=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recipePrompt、layoutColors、layoutTags 及对应 Dashboard 控件改为本轮范围;本轮之后只留 Diff 与私聊按人覆盖。 --- .../2026-08-27-reply-card-style-config.md | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index a5a783e647..d5dad2e1d0 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -26,13 +26,13 @@ | `replyStyle.recipes` | bool | `true` | 是否把五类写作配方注入 `botmux-send` 指南。`false` → 指南回到纯自由 Markdown | | `replyStyle.layout` | bool | `true`(layout 能力落地后) | 是否允许 `--layout`。`false` → flag 被忽略,回退普通回复卡 | | `replyStyle.theme` | `'default' \| 'minimal' \| 'vivid'` | `'default'` | 预设主题,只改变壳的「重」,不改变五档语义 | -| `replyStyle.recipePrompt` | string | 缺省 | **本轮不做**:覆写/追加该 bot 的配方引导文案 | -| `replyStyle.layoutColors` | 对象 | 缺省映射 | **本轮不做**:在飞书 `header.template` 官方枚举内改档位颜色 | -| `replyStyle.layoutTags` | 对象 | 见下 | **本轮不做**:改标签文案。空字符串 = 该档不显示标签 | +| `replyStyle.recipePrompt` | string | 缺省 | 覆写/追加该 bot 的配方引导文案;缺省用内置五类配方 | +| `replyStyle.layoutColors` | 对象 | 主题缺省 | 每档卡头颜色,键为五档名,值必须是飞书 `header.template` 官方枚举 | +| `replyStyle.layoutTags` | 对象 | 主题缺省 | 每档标签文案。缺省:仅 `risk`/`blocked` 为「需要你」。空字符串 = 该档不显示标签 | -`layoutColors` 只允许飞书卡头 template 枚举:`blue` / `wathet` / `turquoise` / `green` / `yellow` / `orange` / `red` / `carmine` / `violet` / `purple` / `indigo` / `grey`。非法值忽略并打日志,回退主题缺省,不发送失败。 +`layoutColors` 只允许飞书卡头 template 枚举:`blue` / `wathet` / `turquoise` / `green` / `yellow` / `orange` / `red` / `carmine` / `violet` / `purple` / `indigo` / `grey`。未知档名、非法颜色、非字符串标签一律忽略并打日志,该档回退当前主题缺省,整张卡仍发出、不 fail。微调不能突破底线:不能用这个口子加按钮、进度条或任意 JSON。 -## 预设主题(第一批就要能跑) +## 预设主题 五档语义固定,主题只调视觉重量。 @@ -55,20 +55,24 @@ "replyStyle": { "recipes": true, "layout": true, - "theme": "default" + "theme": "default", + "recipePrompt": "", + "layoutColors": { "handoff": "indigo" }, + "layoutTags": { "risk": "需要你", "blocked": "需要你" } } } ``` -缺省整块省略 = 上表缺省。不要做成全局 daemon 配置,避免一改全员 Bot 变脸。 +缺省整块省略 = 上表缺省。不要做成全局 daemon 配置,避免一改全员 Bot 变脸。`layoutColors` / `layoutTags` 只写要覆盖的档,未写的档走当前主题。 -**Dashboard**:Bot 设置里、品牌文案附近加一小节「回复风格」。第一批 UI 只需: +**Dashboard**:Bot 设置里、品牌文案附近加一小节「回复风格」。本轮 UI: - 配方引导:开 / 关 - layout 壳:开 / 关 - 主题:默认 / 极简 / 鲜明(下拉,枚举写死) - -第二批再放开「配方文本」多行输入、每档颜色下拉(同样锁官方枚举)。 +- 配方文本:多行输入,空 = 用内置引导 +- 每档卡头颜色:五档各自一个下拉,选项锁官方色板;另加「跟随主题」 +- 每档标签:五档各自一个短文本;空 = 跟随主题(default 下 result/progress/handoff 为空) **skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。 @@ -80,16 +84,16 @@ 1. 五档 layout 薄壳:卡头按上表 + 规定标签 + 正文仍走现有 Markdown(不加原生分栏、不加进度条、不加按钮) 2. `replyStyle.recipes` / `replyStyle.layout` / `replyStyle.theme`;枚举锁死,非法值忽略并回退缺省,发送不失败 -3. bots.json 解析 + Dashboard 三个控件 -4. `recipes === false` 时指南去掉配方表和选型信号;`layout === false` 时指南不提 `--layout`,CLI 忽略 flag -5. 回读:换主题后 `quoted` / `history` 仍能还原正文和表格;测例必须用 **live 归一化形态**,不认 builder 原始 JSON +3. `replyStyle.recipePrompt`:覆写/追加配方引导;空或省略 = 内置文本 +4. `replyStyle.layoutColors` / `replyStyle.layoutTags`:官方色板内每档微调;非法值按档回退主题缺省 +5. bots.json 解析 + Dashboard:三个开关/主题下拉、配方多行文本框、每档颜色下拉、每档标签输入 +6. `recipes === false` 时指南去掉配方表和选型信号(自定义 `recipePrompt` 也不注入);`layout === false` 时指南不提 `--layout`,CLI 忽略 flag,颜色/标签配置不生效 +7. 回读:换主题或微调颜色/标签后 `quoted` / `history` 仍能还原正文和表格;测例必须用 **live 归一化形态**,不认 builder 原始 JSON ### 本轮之后 -1. `recipePrompt` 覆写/追加写作引导 -2. `layoutColors` / `layoutTags` 枚举内微调 -3. Diff 分栏(见 layer2 backlog) -4. 私聊按人覆盖(若要做);群聊永远不按读者分皮肤 +1. Diff 分栏(见 layer2 backlog) +2. 私聊按人覆盖(若要做);群聊永远不按读者分皮肤 ## 明确不做(本轮) From eba42c0b2f4660cf26ba1c8c077ffb44c12db6d0 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:30:43 -0700 Subject: [PATCH 05/17] =?UTF-8?q?docs(lark):=20=E5=86=99=E6=AD=BB=20layout?= =?UTF-8?q?=20=E5=8D=A1=E5=A4=B4=E6=A0=87=E9=A2=98=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E4=B8=8E=E5=9B=9E=E8=AF=BB=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit header.title 为前缀或「前缀 · 首个 H1/H2」;重复前缀时只保留前缀;被取走的标题必须能从 live header 回读。 --- .../2026-08-27-reply-card-style-config.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index d5dad2e1d0..b233ca7689 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -9,6 +9,7 @@ - 标签:只有 `risk`、`blocked` 带「需要你」;其它档不带标签 - **不设** `compare` 档:对比用自由 Markdown;要人做选择走 `risk` + `botmux ask` - **Diff 分栏不进本轮**(仍记在 `docs/plans/2026-08-27-reply-card-layer2-backlog.md`,另开) +- **卡头标题**:`{前缀}`;正文有首个 ATX H1/H2 则 `{前缀} · {标题文本}`,并取走该行不再当正文 heading 渲染。无 `--layout-title`,不从正文猜档。前缀:result=结果、progress=进度、risk=需要确认、blocked=受阻、handoff=交接 配置落在 **bot 维度**。飞书一条卡片只有一份渲染,群里不能按读者换皮肤。私聊按人配不进本轮。 @@ -46,6 +47,24 @@ 短确认、未传 `--layout`:三种主题都 **不套壳**。没有 `compare`、`diff` 这两个名字。 +## 卡头标题生成 + +档位只来自显式 `--layout`。`header.title` 按下面确定规则生成,不要新增 CLI flag。 + +| `--layout` | 固定前缀 | +|---|---| +| `result` | 结果 | +| `progress` | 进度 | +| `risk` | 需要确认 | +| `blocked` | 受阻 | +| `handoff` | 交接 | + +1. 扫描正文第一个 ATX H1 或 H2(与会提升成 `heading-2` 的同一批)。H3+、Setext、代码围栏里的 `#` 都不算。 +2. 没有这样的标题 → `header.title.content` = 前缀。 +3. 有标题 → 默认 `{前缀} · {该标题文本}`,**并从正文去掉这一行**,不再渲染成 heading-2。 +4. **防重复**:去掉空白后,标题文本等于前缀,或只是前缀的重复表述(如 `# 结果`、`# 需要确认`、`# 结果 · 结果`)→ 卡头只显示前缀,不出现「结果 · 结果」。该行仍从正文取走,避免正文再出一遍同样的 heading。 +5. **回读对等**:标题进 `header.title`(以及 risk/blocked 的 `header.text_tag_list`)后,quoted/history 必须能从 header 读回。测例用 **live 归一化形态**(飞书可能把 title 收成 `{ tag, content }`),不能只测 builder 输出。取走正文行的前提是 header 回读不丢,否则就是本轮表格教训重演。 + ## 落点 **bots.json**(每个 bot 一条,紧挨 `brandLabel` / `usageDisplay` 这类展示配置): @@ -88,7 +107,7 @@ 4. `replyStyle.layoutColors` / `replyStyle.layoutTags`:官方色板内每档微调;非法值按档回退主题缺省 5. bots.json 解析 + Dashboard:三个开关/主题下拉、配方多行文本框、每档颜色下拉、每档标签输入 6. `recipes === false` 时指南去掉配方表和选型信号(自定义 `recipePrompt` 也不注入);`layout === false` 时指南不提 `--layout`,CLI 忽略 flag,颜色/标签配置不生效 -7. 回读:换主题或微调颜色/标签后 `quoted` / `history` 仍能还原正文和表格;测例必须用 **live 归一化形态**,不认 builder 原始 JSON +7. 回读:换主题或微调颜色/标签后 `quoted` / `history` 仍能还原正文、表格、以及被取进 `header.title` / `text_tag_list` 的标题与标签;测例必须用 **live 归一化形态**,不认 builder 原始 JSON ### 本轮之后 From 2bf9d490a7d25e5c7dc3c3826585b75adeacbc3d Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:36:59 -0700 Subject: [PATCH 06/17] =?UTF-8?q?docs(lark):=20=E5=8D=A1=E5=A4=B4=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=94=B9=E4=B8=BA=E5=9B=BA=E5=AE=9A=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E3=80=81=E6=AD=A3=E6=96=87=E6=A0=87=E9=A2=98=E4=B8=8D=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按终态拍板采用 B 形态:header.title 只写五档前缀,不消费 H1;指南建议 --layout 时不要重复前缀词。 --- .../2026-08-27-reply-card-style-config.md | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index b233ca7689..04db331c71 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -9,7 +9,7 @@ - 标签:只有 `risk`、`blocked` 带「需要你」;其它档不带标签 - **不设** `compare` 档:对比用自由 Markdown;要人做选择走 `risk` + `botmux ask` - **Diff 分栏不进本轮**(仍记在 `docs/plans/2026-08-27-reply-card-layer2-backlog.md`,另开) -- **卡头标题**:`{前缀}`;正文有首个 ATX H1/H2 则 `{前缀} · {标题文本}`,并取走该行不再当正文 heading 渲染。无 `--layout-title`,不从正文猜档。前缀:result=结果、progress=进度、risk=需要确认、blocked=受阻、handoff=交接 +- **卡头标题(B 形态,最终)**:只用固定前缀,**不读、不删正文 H1/H2**。正文标题照常提升 `heading-2`。无 `--layout-title`,不从正文猜档。前缀:result=结果、progress=进度、risk=需要确认、blocked=受阻、handoff=交接 配置落在 **bot 维度**。飞书一条卡片只有一份渲染,群里不能按读者换皮肤。私聊按人配不进本轮。 @@ -47,11 +47,13 @@ 短确认、未传 `--layout`:三种主题都 **不套壳**。没有 `compare`、`diff` 这两个名字。 -## 卡头标题生成 +## 卡头标题生成(B 形态) -档位只来自显式 `--layout`。`header.title` 按下面确定规则生成,不要新增 CLI flag。 +方案 A(派生 `{前缀} · {H1}` 并取走正文行)**作废**。卡头只承担状态,「特色」来自颜色 + 标签,不搬正文。 -| `--layout` | 固定前缀 | +档位只来自显式 `--layout`。`header.title.content` **永远等于下表前缀**,不要新增 CLI flag,不要读正文。 + +| `--layout` | `header.title` | |---|---| | `result` | 结果 | | `progress` | 进度 | @@ -59,11 +61,10 @@ | `blocked` | 受阻 | | `handoff` | 交接 | -1. 扫描正文第一个 ATX H1 或 H2(与会提升成 `heading-2` 的同一批)。H3+、Setext、代码围栏里的 `#` 都不算。 -2. 没有这样的标题 → `header.title.content` = 前缀。 -3. 有标题 → 默认 `{前缀} · {该标题文本}`,**并从正文去掉这一行**,不再渲染成 heading-2。 -4. **防重复**:去掉空白后,标题文本等于前缀,或只是前缀的重复表述(如 `# 结果`、`# 需要确认`、`# 结果 · 结果`)→ 卡头只显示前缀,不出现「结果 · 结果」。该行仍从正文取走,避免正文再出一遍同样的 heading。 -5. **回读对等**:标题进 `header.title`(以及 risk/blocked 的 `header.text_tag_list`)后,quoted/history 必须能从 header 读回。测例用 **live 归一化形态**(飞书可能把 title 收成 `{ tag, content }`),不能只测 builder 输出。取走正文行的前提是 header 回读不丢,否则就是本轮表格教训重演。 +1. 正文 H1/H2 **原样保留**,并照常提升为 `heading-2`。 +2. 不消费、不去重、不拼接。避免「结果 · 结果」靠指南,不靠 builder 改写正文。 +3. **回读对等**:`header.title` 与 risk/blocked 的 `header.text_tag_list` 仍要用 **live 归一化形态**测(飞书可能把 title 收成 `{ tag, content }`)。正文标题走现有 markdown 回读,不另造 header 兜底。 +4. **指南**:用了 `--layout` 时,顶部标题不要再写与前缀同义的词(`# 结果`、`# 需要确认`、`# 受阻`)。卡头已经表状态,正文第一句直接写具体主题,例如 `# 回复卡宽屏已落地`。 ## 落点 @@ -93,7 +94,7 @@ - 每档卡头颜色:五档各自一个下拉,选项锁官方色板;另加「跟随主题」 - 每档标签:五档各自一个短文本;空 = 跟随主题(default 下 result/progress/handoff 为空) -**skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。 +**skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。`layout` 开启时指南加一句:用 `--layout` 后顶部标题不要写与卡头前缀同义的词,直接写具体主题。 **CLI**:`--layout` 只在 `layout !== false` 时生效;关掉则 stderr 一行提示已忽略,消息仍按普通回复卡发出,不 fail。 @@ -106,8 +107,8 @@ 3. `replyStyle.recipePrompt`:覆写/追加配方引导;空或省略 = 内置文本 4. `replyStyle.layoutColors` / `replyStyle.layoutTags`:官方色板内每档微调;非法值按档回退主题缺省 5. bots.json 解析 + Dashboard:三个开关/主题下拉、配方多行文本框、每档颜色下拉、每档标签输入 -6. `recipes === false` 时指南去掉配方表和选型信号(自定义 `recipePrompt` 也不注入);`layout === false` 时指南不提 `--layout`,CLI 忽略 flag,颜色/标签配置不生效 -7. 回读:换主题或微调颜色/标签后 `quoted` / `history` 仍能还原正文、表格、以及被取进 `header.title` / `text_tag_list` 的标题与标签;测例必须用 **live 归一化形态**,不认 builder 原始 JSON +6. `recipes === false` 时指南去掉配方表和选型信号(自定义 `recipePrompt` 也不注入);`layout === false` 时指南不提 `--layout`,CLI 忽略 flag,颜色/标签配置不生效;layout 开启时指南建议正文顶部不要重复前缀词 +7. 回读:换主题或微调后 `quoted` / `history` 仍能还原正文(含未改动的 H1/H2)、表格、固定前缀 `header.title` 与 `text_tag_list`;测例必须用 **live 归一化形态**,不认 builder 原始 JSON ### 本轮之后 @@ -118,6 +119,6 @@ - `compare` 档、`diff` 档、进度条、假按钮、插件模板 - 读者侧主题切换、一条消息两套渲染 -- 从标题或正文关键词自动选档、自动上色 +- 从标题或正文关键词自动选档、自动上色、把正文 H1 搬进卡头或从正文删掉标题行 - 任意卡片 JSON 当主题 - 用 grey 做 handoff 卡头 From 009ab3c7a86915cd7dafd9e84c0f2751be53df40 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:40:29 -0700 Subject: [PATCH 07/17] =?UTF-8?q?docs(lark):=20=E6=81=A2=E5=A4=8D=20layout?= =?UTF-8?q?=20=E5=8D=A1=E5=A4=B4=E6=A0=87=E9=A2=98=E4=B8=BA=E6=B4=BE?= =?UTF-8?q?=E7=94=9F=E8=A7=84=E5=88=99=20A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 终裁改回 {前缀} · {首个 H1/H2}、取走正文行并防重复;去掉 B 形态指南。header 回读仍用 live 归一化测例。 --- .../2026-08-27-reply-card-style-config.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index 04db331c71..afd7871165 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -9,7 +9,7 @@ - 标签:只有 `risk`、`blocked` 带「需要你」;其它档不带标签 - **不设** `compare` 档:对比用自由 Markdown;要人做选择走 `risk` + `botmux ask` - **Diff 分栏不进本轮**(仍记在 `docs/plans/2026-08-27-reply-card-layer2-backlog.md`,另开) -- **卡头标题(B 形态,最终)**:只用固定前缀,**不读、不删正文 H1/H2**。正文标题照常提升 `heading-2`。无 `--layout-title`,不从正文猜档。前缀:result=结果、progress=进度、risk=需要确认、blocked=受阻、handoff=交接 +- **卡头标题(A 形态,最终)**:`{前缀}`;正文有首个 ATX H1/H2 则 `{前缀} · {标题文本}`,并取走该行不再当正文 heading 渲染。无 `--layout-title`,不从正文猜档。前缀:result=结果、progress=进度、risk=需要确认、blocked=受阻、handoff=交接 配置落在 **bot 维度**。飞书一条卡片只有一份渲染,群里不能按读者换皮肤。私聊按人配不进本轮。 @@ -47,13 +47,11 @@ 短确认、未传 `--layout`:三种主题都 **不套壳**。没有 `compare`、`diff` 这两个名字。 -## 卡头标题生成(B 形态) +## 卡头标题生成(A 形态,最终) -方案 A(派生 `{前缀} · {H1}` 并取走正文行)**作废**。卡头只承担状态,「特色」来自颜色 + 标签,不搬正文。 +B 形态(卡头只用前缀、正文 H1 原样保留)**作废**。档位只来自显式 `--layout`。`header.title` 按下面确定规则生成,不要新增 CLI flag。 -档位只来自显式 `--layout`。`header.title.content` **永远等于下表前缀**,不要新增 CLI flag,不要读正文。 - -| `--layout` | `header.title` | +| `--layout` | 固定前缀 | |---|---| | `result` | 结果 | | `progress` | 进度 | @@ -61,10 +59,11 @@ | `blocked` | 受阻 | | `handoff` | 交接 | -1. 正文 H1/H2 **原样保留**,并照常提升为 `heading-2`。 -2. 不消费、不去重、不拼接。避免「结果 · 结果」靠指南,不靠 builder 改写正文。 -3. **回读对等**:`header.title` 与 risk/blocked 的 `header.text_tag_list` 仍要用 **live 归一化形态**测(飞书可能把 title 收成 `{ tag, content }`)。正文标题走现有 markdown 回读,不另造 header 兜底。 -4. **指南**:用了 `--layout` 时,顶部标题不要再写与前缀同义的词(`# 结果`、`# 需要确认`、`# 受阻`)。卡头已经表状态,正文第一句直接写具体主题,例如 `# 回复卡宽屏已落地`。 +1. 扫描正文第一个 ATX H1 或 H2(与会提升成 `heading-2` 的同一批)。H3+、Setext、代码围栏里的 `#` 都不算。 +2. 没有这样的标题 → `header.title.content` = 前缀。 +3. 有标题 → 默认 `{前缀} · {该标题文本}`,**并从正文去掉这一行**,不再渲染成 heading-2。 +4. **防重复**:去掉空白后,标题文本等于前缀,或只是前缀的重复表述(如 `# 结果`、`# 需要确认`、`# 结果 · 结果`)→ 卡头只显示前缀,不出现「结果 · 结果」。该行仍从正文取走,避免正文再出一遍同样的 heading。 +5. **回读对等**:标题进 `header.title`(以及 risk/blocked 的 `header.text_tag_list`)后,quoted/history 必须能从 header 读回。测例用 **live 归一化形态**(飞书可能把 title 收成 `{ tag, content }`),不能只测 builder 输出。取走正文行的前提是 header 回读不丢,否则就是本轮表格教训重演。 ## 落点 @@ -94,7 +93,7 @@ - 每档卡头颜色:五档各自一个下拉,选项锁官方色板;另加「跟随主题」 - 每档标签:五档各自一个短文本;空 = 跟随主题(default 下 result/progress/handoff 为空) -**skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。`layout` 开启时指南加一句:用 `--layout` 后顶部标题不要写与卡头前缀同义的词,直接写具体主题。 +**skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。不要再写「顶部别写同义标题」——防重复规则已覆盖。 **CLI**:`--layout` 只在 `layout !== false` 时生效;关掉则 stderr 一行提示已忽略,消息仍按普通回复卡发出,不 fail。 @@ -107,8 +106,8 @@ 3. `replyStyle.recipePrompt`:覆写/追加配方引导;空或省略 = 内置文本 4. `replyStyle.layoutColors` / `replyStyle.layoutTags`:官方色板内每档微调;非法值按档回退主题缺省 5. bots.json 解析 + Dashboard:三个开关/主题下拉、配方多行文本框、每档颜色下拉、每档标签输入 -6. `recipes === false` 时指南去掉配方表和选型信号(自定义 `recipePrompt` 也不注入);`layout === false` 时指南不提 `--layout`,CLI 忽略 flag,颜色/标签配置不生效;layout 开启时指南建议正文顶部不要重复前缀词 -7. 回读:换主题或微调后 `quoted` / `history` 仍能还原正文(含未改动的 H1/H2)、表格、固定前缀 `header.title` 与 `text_tag_list`;测例必须用 **live 归一化形态**,不认 builder 原始 JSON +6. `recipes === false` 时指南去掉配方表和选型信号(自定义 `recipePrompt` 也不注入);`layout === false` 时指南不提 `--layout`,CLI 忽略 flag,颜色/标签配置不生效 +7. 回读:换主题或微调后 `quoted` / `history` 仍能还原正文、表格、以及被取进 `header.title` / `text_tag_list` 的标题与标签;测例必须用 **live 归一化形态**。取走正文标题行的前提是 header 回读不丢 ### 本轮之后 @@ -119,6 +118,7 @@ - `compare` 档、`diff` 档、进度条、假按钮、插件模板 - 读者侧主题切换、一条消息两套渲染 -- 从标题或正文关键词自动选档、自动上色、把正文 H1 搬进卡头或从正文删掉标题行 +- 从标题或正文关键词自动选档、自动上色 +- 卡头只用固定前缀却保留正文同义 H1 的 B 形态(已作废) - 任意卡片 JSON 当主题 - 用 grey 做 handoff 卡头 From e70ac5921283ac212f45a0f0a20c6f777cd1f8c1 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:48:22 -0700 Subject: [PATCH 08/17] =?UTF-8?q?docs(lark):=20=E8=A7=84=E6=A0=BC=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=81=A2=E5=A4=8D=E4=B8=BA=200aeafbcb=20=E7=9A=84=20A?= =?UTF-8?q?=20=E5=BD=A2=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fdd47f2f 的 B 改判已撤销;title 规则以派生 A 为准,文档内容与 0aeafbcb 对齐。 --- docs/plans/2026-08-27-reply-card-style-config.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index afd7871165..b233ca7689 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -9,7 +9,7 @@ - 标签:只有 `risk`、`blocked` 带「需要你」;其它档不带标签 - **不设** `compare` 档:对比用自由 Markdown;要人做选择走 `risk` + `botmux ask` - **Diff 分栏不进本轮**(仍记在 `docs/plans/2026-08-27-reply-card-layer2-backlog.md`,另开) -- **卡头标题(A 形态,最终)**:`{前缀}`;正文有首个 ATX H1/H2 则 `{前缀} · {标题文本}`,并取走该行不再当正文 heading 渲染。无 `--layout-title`,不从正文猜档。前缀:result=结果、progress=进度、risk=需要确认、blocked=受阻、handoff=交接 +- **卡头标题**:`{前缀}`;正文有首个 ATX H1/H2 则 `{前缀} · {标题文本}`,并取走该行不再当正文 heading 渲染。无 `--layout-title`,不从正文猜档。前缀:result=结果、progress=进度、risk=需要确认、blocked=受阻、handoff=交接 配置落在 **bot 维度**。飞书一条卡片只有一份渲染,群里不能按读者换皮肤。私聊按人配不进本轮。 @@ -47,9 +47,9 @@ 短确认、未传 `--layout`:三种主题都 **不套壳**。没有 `compare`、`diff` 这两个名字。 -## 卡头标题生成(A 形态,最终) +## 卡头标题生成 -B 形态(卡头只用前缀、正文 H1 原样保留)**作废**。档位只来自显式 `--layout`。`header.title` 按下面确定规则生成,不要新增 CLI flag。 +档位只来自显式 `--layout`。`header.title` 按下面确定规则生成,不要新增 CLI flag。 | `--layout` | 固定前缀 | |---|---| @@ -93,7 +93,7 @@ B 形态(卡头只用前缀、正文 H1 原样保留)**作废**。档位只 - 每档卡头颜色:五档各自一个下拉,选项锁官方色板;另加「跟随主题」 - 每档标签:五档各自一个短文本;空 = 跟随主题(default 下 result/progress/handoff 为空) -**skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。不要再写「顶部别写同义标题」——防重复规则已覆盖。 +**skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。 **CLI**:`--layout` 只在 `layout !== false` 时生效;关掉则 stderr 一行提示已忽略,消息仍按普通回复卡发出,不 fail。 @@ -107,7 +107,7 @@ B 形态(卡头只用前缀、正文 H1 原样保留)**作废**。档位只 4. `replyStyle.layoutColors` / `replyStyle.layoutTags`:官方色板内每档微调;非法值按档回退主题缺省 5. bots.json 解析 + Dashboard:三个开关/主题下拉、配方多行文本框、每档颜色下拉、每档标签输入 6. `recipes === false` 时指南去掉配方表和选型信号(自定义 `recipePrompt` 也不注入);`layout === false` 时指南不提 `--layout`,CLI 忽略 flag,颜色/标签配置不生效 -7. 回读:换主题或微调后 `quoted` / `history` 仍能还原正文、表格、以及被取进 `header.title` / `text_tag_list` 的标题与标签;测例必须用 **live 归一化形态**。取走正文标题行的前提是 header 回读不丢 +7. 回读:换主题或微调颜色/标签后 `quoted` / `history` 仍能还原正文、表格、以及被取进 `header.title` / `text_tag_list` 的标题与标签;测例必须用 **live 归一化形态**,不认 builder 原始 JSON ### 本轮之后 @@ -119,6 +119,5 @@ B 形态(卡头只用前缀、正文 H1 原样保留)**作废**。档位只 - `compare` 档、`diff` 档、进度条、假按钮、插件模板 - 读者侧主题切换、一条消息两套渲染 - 从标题或正文关键词自动选档、自动上色 -- 卡头只用固定前缀却保留正文同义 H1 的 B 形态(已作废) - 任意卡片 JSON 当主题 - 用 grey 做 handoff 卡头 From c06f360d819ab1722a95aab9d901f45c87722e95 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:06:32 -0700 Subject: [PATCH 09/17] =?UTF-8?q?docs(lark):=20=E9=94=81=E5=AE=9A=20vivid?= =?UTF-8?q?=20=E6=A0=87=E7=AD=BE=E8=AF=AD=E4=B9=89=E8=89=B2=E4=B8=94?= =?UTF-8?q?=E4=B8=8D=E9=9A=8F=E5=8D=A1=E5=A4=B4=E8=89=B2=E6=BC=82=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit result=green、progress=blue、risk/blocked=red、handoff=indigo;非法 text_tag 色回退 neutral,发送不失败。 --- docs/plans/2026-08-27-reply-card-style-config.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index b233ca7689..b53505f60f 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -45,6 +45,18 @@ `vivid` 的额外标签是主题增量;**default 才是定稿观感**。实现时 default 不得给 result/progress/handoff 加标签。 +**标签颜色(已拍板):语义色固定,不随 `layoutColors` 卡头色漂移。** `text_tag.color` 用飞书标签官方枚举(与 `header.template` 不是同一张表;标签有 `neutral`/`lime`,没有 `grey`)。 + +| 档 | 标签文案(vivid 全开;default 仅 risk/blocked) | `text_tag.color` | +|---|---|---| +| result | 完成 | green | +| progress | 进行中 | blue | +| risk | 需要你 | red | +| blocked | 需要你 | red | +| handoff | 交接 | indigo | + +不在标签官方枚举内的色忽略并打日志,该档标签色回退 `neutral`,整张卡仍发出、不 fail。改 `layoutColors` 只动卡头 template,不动上表。 + 短确认、未传 `--layout`:三种主题都 **不套壳**。没有 `compare`、`diff` 这两个名字。 ## 卡头标题生成 From f7d1398b5ca64e08a9c768d49150b04e33f7b15e Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:11:58 -0700 Subject: [PATCH 10/17] =?UTF-8?q?docs(lark):=20=E5=86=99=E6=98=8E=20layout?= =?UTF-8?q?=20=E7=9A=84=20CLI=20fail-soft=20=E4=B8=8E=20relay=20=E7=A1=AC?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户输入非法 --layout 不失败;host 校验只拒绝伪造 relay,与 --response-kind 同门。 --- docs/plans/2026-08-27-reply-card-style-config.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index b53505f60f..bc8567ed1b 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -107,7 +107,9 @@ **skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。 -**CLI**:`--layout` 只在 `layout !== false` 时生效;关掉则 stderr 一行提示已忽略,消息仍按普通回复卡发出,不 fail。 +**CLI**:`--layout` 只在 `layout !== false` 时生效;关掉则 stderr 一行提示已忽略,消息仍按普通回复卡发出,不 fail。非法名称(`diff` / `compare` / 缺值 / 重复)同样 fail-soft:stderr 提示后当普通回复卡发出。 + +**CLI vs relay:** CLI 层 fail-soft 面向用户输入,保证合法调用「发送不失败」。sandbox relay 的 host 校验面向伪造/篡改的 outbox——沙箱内 CLI 只会转发五个 canonical 名,host 再见到非法 `--layout` 只可能是绕过 child 的请求,硬拒绝(与 `--response-kind` 同门)。两层不矛盾,后人不要当成规格冲突。 ## 切分 From 7b96a7ad0648337811c5ce3a170bc5e61990a478 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:41:09 -0700 Subject: [PATCH 11/17] =?UTF-8?q?docs(lark):=20=E5=86=99=E6=98=8E=20recipe?= =?UTF-8?q?Prompt/=E6=A0=87=E7=AD=BE=E9=95=BF=E5=BA=A6=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E4=B8=8E=20adopt=20=E8=AF=9A=E5=AE=9E=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-27-reply-card-style-config.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index bc8567ed1b..6bca7aa896 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -33,6 +33,13 @@ `layoutColors` 只允许飞书卡头 template 枚举:`blue` / `wathet` / `turquoise` / `green` / `yellow` / `orange` / `red` / `carmine` / `violet` / `purple` / `indigo` / `grey`。未知档名、非法颜色、非字符串标签一律忽略并打日志,该档回退当前主题缺省,整张卡仍发出、不 fail。微调不能突破底线:不能用这个口子加按钮、进度条或任意 JSON。 +长度与安全硬上限(按 Unicode code point 计,避免 emoji 被 UTF-16 腰斩): + +- `recipePrompt` 最多 **4096** 个 code point。超限、含 `NUL`、非字符串 → 忽略该字段并打日志,回退内置配方。 +- `layoutTags` 单档最多 **32** 个 code point。超限、含 `Cc` 控制字符、非字符串、未知档名 → 忽略该档并打日志,回退当前主题缺省。 + +都是**逐项**忽略回退,不整块拒绝,不阻断 bots.json 加载或发送。`recipePrompt` 非空时**替换**内置配方区,不是追加;空或省略仍用内置文本。 + ## 预设主题 五档语义固定,主题只调视觉重量。 @@ -111,17 +118,26 @@ **CLI vs relay:** CLI 层 fail-soft 面向用户输入,保证合法调用「发送不失败」。sandbox relay 的 host 校验面向伪造/篡改的 outbox——沙箱内 CLI 只会转发五个 canonical 名,host 再见到非法 `--layout` 只可能是绕过 child 的请求,硬拒绝(与 `--response-kind` 同门)。两层不矛盾,后人不要当成规格冲突。 +**会话快照(自有 pane):** worker spawn 把归一化后的稀疏对象冻进 `BOTMUX_REPLY_STYLE`。同一 pane 里 `botmux skill show botmux-send` 与 `botmux send` 都读这份快照,避免长会话中途改配置导致指南和渲染分叉。Riff/Mojo 在用户 env 合并后再冻一次,防止旧值/伪造值覆盖。共享持久后端在会话边界清理该键,避免跨 bot 泄漏。 + +**adopt / restore-adopt(非侵入,按 A 收边界):** 不向已运行的外部 CLI 注入 env / skill / 动态指南,init **不带** `replyStyle`。不要把这条写成「adopt 不支持 replyStyle」: + +- 指南注入不生效;磁盘 native/global 的 `botmux-send` 永远是稳定 loader(不含个性化配方或 `--layout`) +- `botmux skill show botmux-send` 只有同时存在 `BOTMUX_SESSION_ID`、`BOTMUX_LARK_APP_ID`、`BOTMUX_REPLY_STYLE` 才按快照个性化;缺任一则固定出厂默认指南(忽略环境里残留的无关快照) +- `botmux send --layout` 照常可用:按该 session 的 `larkAppId` 读 live `bots.json` / 内存 registry(无 worker 快照时,Dashboard 改完即时生效) + ## 切分 ### 本轮终态(本 PR) 1. 五档 layout 薄壳:卡头按上表 + 规定标签 + 正文仍走现有 Markdown(不加原生分栏、不加进度条、不加按钮) 2. `replyStyle.recipes` / `replyStyle.layout` / `replyStyle.theme`;枚举锁死,非法值忽略并回退缺省,发送不失败 -3. `replyStyle.recipePrompt`:覆写/追加配方引导;空或省略 = 内置文本 -4. `replyStyle.layoutColors` / `replyStyle.layoutTags`:官方色板内每档微调;非法值按档回退主题缺省 +3. `replyStyle.recipePrompt`:非空则**替换**内置配方区;空或省略 = 内置文本;最长 4096 code point,超限/NUL 逐项忽略 +4. `replyStyle.layoutColors` / `replyStyle.layoutTags`:官方色板内每档微调;非法值按档回退主题缺省;单档标签最长 32 code point,超限/`Cc` 控制字符逐项忽略 5. bots.json 解析 + Dashboard:三个开关/主题下拉、配方多行文本框、每档颜色下拉、每档标签输入 6. `recipes === false` 时指南去掉配方表和选型信号(自定义 `recipePrompt` 也不注入);`layout === false` 时指南不提 `--layout`,CLI 忽略 flag,颜色/标签配置不生效 7. 回读:换主题或微调颜色/标签后 `quoted` / `history` 仍能还原正文、表格、以及被取进 `header.title` / `text_tag_list` 的标题与标签;测例必须用 **live 归一化形态**,不认 builder 原始 JSON +8. 自有会话冻 `BOTMUX_REPLY_STYLE`;adopt 不注入指南;`skill show` 无完整会话标记则固定默认指南;adopt/global 的 `send --layout` 按 `larkAppId` 读 live 配置 ### 本轮之后 From 445a4d332edfd1cacaefd3d096ddf47a0d43eaa1 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:42:58 -0700 Subject: [PATCH 12/17] =?UTF-8?q?docs(lark):=20recipePrompt=20=E8=A1=A8?= =?UTF-8?q?=E9=A1=B9=E4=B8=8E=E6=AD=A3=E6=96=87=E4=B8=80=E8=87=B4=E4=B8=BA?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E5=86=85=E7=BD=AE=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-27-reply-card-style-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-27-reply-card-style-config.md b/docs/plans/2026-08-27-reply-card-style-config.md index 6bca7aa896..c96fa0c912 100644 --- a/docs/plans/2026-08-27-reply-card-style-config.md +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -27,7 +27,7 @@ | `replyStyle.recipes` | bool | `true` | 是否把五类写作配方注入 `botmux-send` 指南。`false` → 指南回到纯自由 Markdown | | `replyStyle.layout` | bool | `true`(layout 能力落地后) | 是否允许 `--layout`。`false` → flag 被忽略,回退普通回复卡 | | `replyStyle.theme` | `'default' \| 'minimal' \| 'vivid'` | `'default'` | 预设主题,只改变壳的「重」,不改变五档语义 | -| `replyStyle.recipePrompt` | string | 缺省 | 覆写/追加该 bot 的配方引导文案;缺省用内置五类配方 | +| `replyStyle.recipePrompt` | string | 缺省 | 非空则替换内置配方区;缺省用内置五类配方 | | `replyStyle.layoutColors` | 对象 | 主题缺省 | 每档卡头颜色,键为五档名,值必须是飞书 `header.template` 官方枚举 | | `replyStyle.layoutTags` | 对象 | 主题缺省 | 每档标签文案。缺省:仅 `risk`/`blocked` 为「需要你」。空字符串 = 该档不显示标签 | From 9b58e3bf344396e75349230ef14e5a701f9b76ee Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:15:19 -0700 Subject: [PATCH 13/17] =?UTF-8?q?feat(lark):=20=E6=94=AF=E6=8C=81=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=9A=84=E4=BA=94=E6=A1=A3=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=8D=A1=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/sandbox.ts | 5 +- src/bot-registry.ts | 68 +++++ src/cli.ts | 72 ++++- src/im/lark/md-card.ts | 87 +++++- src/im/lark/message-parser.ts | 81 +++++- src/im/lark/reply-card-style.ts | 423 +++++++++++++++++++++++++++++ test/bot-registry.test.ts | 122 +++++++++ test/cli-send-hook-context.test.ts | 42 ++- test/md-card.test.ts | 60 ++++ test/message-parser.test.ts | 71 +++++ test/reply-card-style.test.ts | 258 ++++++++++++++++++ test/sandbox.test.ts | 21 ++ 12 files changed, 1279 insertions(+), 31 deletions(-) create mode 100644 src/im/lark/reply-card-style.ts create mode 100644 test/reply-card-style.test.ts diff --git a/src/adapters/backend/sandbox.ts b/src/adapters/backend/sandbox.ts index 75e83a5f40..9ec2619c15 100644 --- a/src/adapters/backend/sandbox.ts +++ b/src/adapters/backend/sandbox.ts @@ -986,7 +986,7 @@ export interface RelayRequest { // content/attachments come from validated outbox files, and session-id is // forced by the worker. const RELAY_FLAGS_NOVAL = new Set(['--mention-back', '--no-mention', '--no-quote', '--voice', '--slash']); -const RELAY_FLAGS_VAL = new Set(['--mention', '--quote', '--response-kind']); +const RELAY_FLAGS_VAL = new Set(['--mention', '--quote', '--response-kind', '--layout']); export interface ValidatedRelay { contentName: string; @@ -1061,6 +1061,9 @@ export function validateRelayRequest(req: RelayRequest): { ok: true; value: Vali if (f === '--response-kind' && !['progress', 'final', 'auxiliary'].includes(v)) { return { ok: false, error: 'flag --response-kind must be progress, final, or auxiliary' }; } + if (f === '--layout' && !['result', 'progress', 'risk', 'blocked', 'handoff'].includes(v)) { + return { ok: false, error: 'flag --layout must be result, progress, risk, blocked, or handoff' }; + } flags.push(f, v); i++; continue; } return { ok: false, error: `flag not allowed: ${f}` }; diff --git a/src/bot-registry.ts b/src/bot-registry.ts index aa7ad072e9..1aa5d22f1c 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -38,6 +38,10 @@ import { import type { FeedbackPolicy, FeedbackPolicyInput } from './services/feedback-policy.js'; import { normalizeFeedbackPolicyLayer } from './services/feedback-policy-resolver.js'; import type { FeedbackWebhookDestination } from './services/feedback-outbox.js'; +import { + normalizeReplyStyleConfig, + type ReplyStyleConfig, +} from './im/lark/reply-card-style.js'; import { cliModelSupportsReasoningEffort, isConfigurableReasoningCliId, isCodexReasoningEffort } from './services/codex-reasoning-effort.js'; import { normalizeSessionOwnerReminderConfig, @@ -1752,6 +1756,16 @@ export interface BotConfig { * routing or permissions. */ brandLabel?: string; + /** + * Sparse per-bot reply-writing/card-style overrides. Missing fields inherit + * the built-in recipe and theme defaults; malformed hand-edited fields are + * dropped independently by {@link normalizeReplyStyleConfig}. + * + * Workers freeze the normalized sparse object into BOTMUX_REPLY_STYLE when a + * session starts so the guide an Agent reads and the card renderer it invokes + * cannot drift halfway through a long-lived pane. + */ + replyStyle?: ReplyStyleConfig; /** * Where to show native Context / Token usage for this bot's Session cards: * • `'streaming'` (default / unset) → in the live streaming card body @@ -2031,6 +2045,7 @@ export function __testOnly_resetBotRegistry(): void { brandLabelCache = null; cachedLarkUploadHttpInstance = undefined; usageDisplayCache = null; + replyStyleCache = null; } // Wire the i18n lookup so `localeForBot()` can resolve per-bot locale without @@ -2428,6 +2443,7 @@ export function isChatOncallBoundForAnyBot(chatId: string): boolean { // the configured value (undefined when the bot has no brandLabel key). let brandLabelCache: { mtimeMs: number; map: Map } | null = null; let usageDisplayCache: { mtimeMs: number; map: Map } | null = null; +let replyStyleCache: { mtimeMs: number; map: Map } | null = null; /** Normalize a raw bots.json entry's usage-display intent to the enum, applying * backward compat: an explicit `usageDisplay` wins; otherwise a legacy @@ -2531,6 +2547,52 @@ export function resolveUsageDisplay(larkAppId: string): UsageDisplayMode { } } +/** + * Resolve the normalized sparse reply-style snapshot for a bot. + * + * Unlike hot display-only settings, a running CLI pane must keep the same + * replyStyle it learned through the botmux-send guide when it later invokes + * `botmux send`. Therefore the spawn-time env snapshot intentionally wins for + * the current app; daemon callers without that snapshot use the loaded registry, + * and one-shot host CLIs fall back to an mtime-cached bots.json read. + */ +export function resolveReplyStyleConfig(larkAppId: string): ReplyStyleConfig | undefined { + if (process.env.BOTMUX_SESSION_ID + && process.env.BOTMUX_LARK_APP_ID === larkAppId + && 'BOTMUX_REPLY_STYLE' in process.env) { + try { + const parsed = JSON.parse(process.env.BOTMUX_REPLY_STYLE || '{}'); + return normalizeReplyStyleConfig(parsed).config; + } catch { + return undefined; + } + } + + const inMem = bots.get(larkAppId); + if (inMem) return inMem.config.replyStyle; + + const path = loadedConfigPath ?? botsConfigDiskPath(); + if (!path) return undefined; + try { + const stat = statSync(path); + if (!replyStyleCache || replyStyleCache.mtimeMs !== stat.mtimeMs) { + const raw = JSON.parse(readFileSync(path, 'utf-8')); + const map = new Map(); + if (Array.isArray(raw)) { + for (const entry of raw) { + if (entry && typeof entry.larkAppId === 'string') { + map.set(entry.larkAppId, normalizeReplyStyleConfig(entry.replyStyle).config); + } + } + } + replyStyleCache = { mtimeMs: stat.mtimeMs, map }; + } + return replyStyleCache.map.get(larkAppId); + } catch { + return undefined; + } +} + /** * 只读 accessor:该 bot 配置的 tuiSlashAllow allowlist(TUI 通用 slash 注入用)。 * 仅读内存态注册表,daemon 进程内使用;无需 bots.json 磁盘回退(不同于 @@ -3107,6 +3169,11 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { const sanitizedEnv = sanitizePerBotEnv(entry.env); const env = Object.keys(sanitizedEnv).length > 0 ? sanitizedEnv : undefined; + const normalizedReplyStyle = normalizeReplyStyleConfig(entry.replyStyle); + for (const warning of normalizedReplyStyle.warnings) { + logger.warn(`[bot-registry:${entry.larkAppId}] ${warning}`); + } + const skills = readBotSkillPolicy(entry.skills); // Presence is semantic for plugins: [] is an exact "none" override, while // an absent field inherits the machine defaults. @@ -3283,6 +3350,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { // Preserve '' distinctly from undefined: '' means "brand off", undefined // means "use default botmux brand". Don't trim-to-undefined here. brandLabel: typeof entry.brandLabel === 'string' ? entry.brandLabel : undefined, + replyStyle: normalizedReplyStyle.config, // Persist only a non-default usage-display mode; 'streaming' (default) and // an absent key both mean streaming. Legacy showUsageInCardFooter:false is // still honored on read (see normalizeUsageDisplay) but never re-emitted. diff --git a/src/cli.ts b/src/cli.ts index dad708fd50..f12479e397 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6166,6 +6166,8 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接 --video-covers 视频封面图片(可重复,按顺序对应 --videos) --card-file 直接发送飞书/Lark interactive 卡片 JSON --card-json 直接发送飞书/Lark interactive 卡片 JSON 字符串 + --layout result|progress|risk|blocked|handoff + 可选回复卡卡头薄壳;只在关键结果/进度/风险/阻塞/交接节点显式使用 --response-kind progress|final|auxiliary 可选;未声明按 progress/非 final,只有 final 挂反馈 --mention @提及(可重复) --mention-back @回本轮触发消息的发送者(open_id 自动取自会话) @@ -7230,17 +7232,31 @@ import { appendReplyCardFooterToV2Card, buildImageCardElements, buildReplyCardFooter, + createReplyCard, + extractFirstReplyCardHeading, prepareCardMarkdown, - REPLY_CARD_CONFIG, type CardUsageSnapshot, type LocalHomeLinkMode, } from './im/lark/md-card.js'; +import { + buildReplyLayoutHeader, + parseReplyLayoutRequest, + resolveReplyStyle, + type ReplyLayout, +} from './im/lark/reply-card-style.js'; import { buildFeedbackElement } from './im/lark/skill-feedback-card.js'; import { resolveFeedbackPolicyForDelivery, resolveFeedbackTeamId } from './services/feedback-policy-resolver.js'; import { normalizeFeedbackPolicy } from './services/feedback-policy.js'; import { applyInlineMentions } from './im/lark/inline-mentions.js'; import { renderBrandTemplate } from './im/lark/brand-template.js'; -import { effectiveDefaultWorkingDir, loadBotConfigs, resolveBrandLabel, resolveUsageDisplay, getBot } from './bot-registry.js'; +import { + effectiveDefaultWorkingDir, + getBot, + loadBotConfigs, + resolveBrandLabel, + resolveReplyStyleConfig, + resolveUsageDisplay, +} from './bot-registry.js'; import { resolvePricingConfig, type ResolvedModelPricing } from './services/model-pricing.js'; import { config } from './config.js'; import { getSessionUsageSnapshot } from './core/cost-calculator.js'; @@ -7270,6 +7286,7 @@ import { async function relaySend( rest: string[], relayDir: string, + replyLayout?: ReplyLayout, ): Promise { const sid = argValue(rest, '--session-id') ?? process.env.BOTMUX_SESSION_ID; if (!sid) { console.error('relay: 无法确定 session-id'); process.exit(1); } @@ -7373,6 +7390,10 @@ async function relaySend( } // else dropped } + // `--layout` is parsed before entering the sandbox relay. Forward only the + // canonical five-name form; malformed requests already warned and fell back + // in the child, while the host validator still rejects forged outbox input. + if (replyLayout) flags.push('--layout', replyLayout); // 原子写:req.json 是 host watcher 的触发文件,rename 让它「完整出现」, // watcher 永远不会读到半截 JSON(tmp 后缀不匹配 .req.json 过滤)。 atomicWriteFileSync(join(relayDir, `${id}.req.json`), JSON.stringify({ @@ -7570,6 +7591,7 @@ async function registerSelfFromCredFile(): Promise { cliId: 'claude-code', brand: cred.brand as 'feishu' | 'lark' | undefined, feedback: cred.feedback, + replyStyle: resolveReplyStyleConfig(appId), usageDisplay: process.env.BOTMUX_USAGE_DISPLAY === 'streaming' || process.env.BOTMUX_USAGE_DISPLAY === 'footer' || @@ -7655,6 +7677,7 @@ function riffModeSession(opts: { evenWithLocalSessions?: boolean } = {}): { sess cliId: 'riff', allowedUsers: [], feedback, + replyStyle: resolveReplyStyleConfig(appId), usageDisplay: process.env.BOTMUX_USAGE_DISPLAY === 'streaming' || process.env.BOTMUX_USAGE_DISPLAY === 'footer' || @@ -7714,6 +7737,9 @@ async function cmdSend(rest: string[]): Promise { // central session-capability gate — same hard door every Feishu-touching CLI // command consults. assertTurnTransportOrExit('send'); + const replyLayoutRequest = parseReplyLayoutRequest(rest); + if (replyLayoutRequest.warning) console.error(replyLayoutRequest.warning); + let replyLayout = replyLayoutRequest.layout; // Resolve isolation marker-first. A visible host marker always wins over a // leftover capability. Linux bwrap keeps its host-execution outbox; macOS // read isolation instead challenges the owning daemon and trusts only the @@ -7823,7 +7849,15 @@ async function cmdSend(rest: string[]): Promise { // The relay watcher therefore binds a short-lived host-issued capability to // the worker's live turn and performs the authoritative policy check. if (relayDir && isolatedCapabilityCtx) { - await relaySend(rest, relayDir); + if (replyLayout) { + const ownAppId = process.env.BOTMUX_LARK_APP_ID?.trim(); + const style = resolveReplyStyle(ownAppId ? resolveReplyStyleConfig(ownAppId) : undefined); + if (!style.layout) { + console.error('botmux send: 当前 Bot 已关闭 layout,本次按普通回复卡发送'); + replyLayout = undefined; + } + } + await relaySend(rest, relayDir, replyLayout); return; } if (relayDir && !liveMarkerCtx?.sessionId) { @@ -8156,6 +8190,11 @@ async function cmdSend(rest: string[]): Promise { process.exit(2); } } + if (replyLayout && (customCardRequested || asVoice || isSlashSend)) { + const mode = customCardRequested ? '自定义卡片' : asVoice ? '语音气泡' : '原生斜杠命令'; + console.error(`botmux send: --layout 不作用于${mode},本次已忽略`); + replyLayout = undefined; + } const sid = sessionIdArg ?? ancestorCtx?.sessionId ?? process.env.BOTMUX_SESSION_ID ?? null; if (!sid) { @@ -8192,6 +8231,11 @@ async function cmdSend(rest: string[]): Promise { if (!s) { console.error(`未找到 session ${sid}`); process.exit(1); } if (!s.larkAppId) { console.error(`session ${sid} 缺少 larkAppId`); process.exit(1); } + const replyStyle = resolveReplyStyle(resolveReplyStyleConfig(s.larkAppId)); + if (replyLayout && !replyStyle.layout) { + console.error('botmux send: 当前 Bot 已关闭 layout,本次按普通回复卡发送'); + replyLayout = undefined; + } // Target-aware gate on the RESOLVED source session: `send --session-id ` // (or an apiOnly bot's session) must be refused even if the ambient env looks // transport-capable, and regardless of any `--chat-id` override — a no-transport @@ -8339,6 +8383,10 @@ async function cmdSend(rest: string[]): Promise { const isOriginDocCommentTurn = exactOriginDispatch?.deliverySink === 'doc_comment' || (!exactOriginDispatch && originSession?.cliId !== 'codex-app' && !!docTarget); if (isOriginDocCommentTurn) { + if (replyLayout) { + console.error('botmux send: --layout 不作用于文档评论回复,本次已忽略'); + replyLayout = undefined; + } if (!docTarget || !originSession?.larkAppId) { console.error('botmux send refused: this turn is bound to a document comment, but its exact origin target is no longer available'); process.exit(2); @@ -9334,6 +9382,10 @@ async function cmdSend(rest: string[]): Promise { videoCount: videoAttachments.length, mentionCount: mentions.length, }); + if (pureVideoSend && replyLayout) { + console.error('botmux send: --layout 不作用于纯视频消息,本次已忽略'); + replyLayout = undefined; + } if (customCard) { messageId = await dispatchPrimary(JSON.stringify(customCard), 'interactive'); } else if (isSlashSend) { @@ -9384,7 +9436,13 @@ async function cmdSend(rest: string[]): Promise { // Inline `@Name` → `` at the exact spot it's written (CJK-name // aware, see applyInlineMentions); any --mention not inlined here is // rendered on the footer `发送给:` line below, not the body. - const { text: md, usedIds } = applyInlineMentions(text, mentions); + const layoutBody = replyLayout + ? extractFirstReplyCardHeading(text) + : { markdown: text, heading: undefined }; + const layoutHeader = replyLayout + ? buildReplyLayoutHeader(replyLayout, layoutBody.heading, replyStyle) + : undefined; + const { text: md, usedIds } = applyInlineMentions(layoutBody.markdown, mentions); // Non-inlined mentions are no longer dangled as a trailing @ block at the // body bottom — they're consolidated onto the footer `发送给:` line below // (human addressee first, then explicit targets). See orderedFooterRecipients. @@ -9484,16 +9542,14 @@ async function cmdSend(rest: string[]): Promise { } if (feedbackPolicy && effectiveResponseKind === 'final') { - const canonicalCard = { schema: '2.0', config: { ...REPLY_CARD_CONFIG }, body: { direction: 'vertical', elements: [...elements] } } as { schema: string; config: Record; body: { direction: string; elements: unknown[] } }; + const canonicalCard = createReplyCard([...elements], layoutHeader); const feedbackElement = buildFeedbackElement(feedbackPolicy); const footerIndex = canonicalCard.body.elements.findIndex((element: any) => element?.element_id === 'botmux_reply_footer'); canonicalCard.body.elements.splice(footerIndex >= 0 ? footerIndex : canonicalCard.body.elements.length, 0, feedbackElement); feedbackBaseCard = canonicalCard as unknown as Record; messageId = await dispatchPrimary(JSON.stringify(feedbackBaseCard), 'interactive'); } else { - messageId = await dispatchPrimary(JSON.stringify({ - schema: '2.0', config: { ...REPLY_CARD_CONFIG }, body: { direction: 'vertical', elements }, - }), 'interactive'); + messageId = await dispatchPrimary(JSON.stringify(createReplyCard(elements, layoutHeader)), 'interactive'); } } diff --git a/src/im/lark/md-card.ts b/src/im/lark/md-card.ts index 094a33adc0..ab34fbe5a6 100644 --- a/src/im/lark/md-card.ts +++ b/src/im/lark/md-card.ts @@ -32,6 +32,7 @@ import { } from './reply-card-footer-signature.js'; import { buildFeedbackElement } from './skill-feedback-card.js'; import type { FeedbackPolicy } from '../../services/feedback-policy.js'; +import type { ReplyCardHeader } from './reply-card-style.js'; export { REPLY_CARD_FOOTER_MARKER } from './reply-card-footer-signature.js'; @@ -51,6 +52,26 @@ export const REPLY_CARD_CONFIG = { width_mode: 'fill', } as const; +export interface ReplyCardV2 { + schema: '2.0'; + config: { update_multi: true; width_mode: 'fill' }; + header?: ReplyCardHeader; + body: { direction: 'vertical'; elements: any[] }; +} + +/** Single envelope shared by direct sends and fallback reply-card builders. */ +export function createReplyCard( + elements: any[], + header?: ReplyCardHeader, +): ReplyCardV2 { + return { + schema: '2.0', + config: { ...REPLY_CARD_CONFIG }, + ...(header ? { header } : {}), + body: { direction: 'vertical', elements }, + }; +} + export type LocalHomeLinkMode = 'filesystem' | 'lexical' | 'disabled'; /** Native usage facts rendered in a Bot reply-card footer. Context is the @@ -651,6 +672,58 @@ export function prepareCardMarkdown( return normalizeLocalHomeLinks(input, homedir(), cwd, existsSync, localHomeLinkMode); } +export interface ExtractedReplyCardHeading { + /** Markdown body with the selected heading source line removed. */ + markdown: string; + /** Plain visible text for `header.title`; absent when no eligible heading exists. */ + heading?: string; +} + +function inlineTokenPlainText(token: Token | undefined): string { + if (!token) return ''; + if (!Array.isArray(token.children)) return token.content.trim(); + return token.children + .map(child => { + if (child.type === 'text' || child.type === 'code_inline') return child.content; + if (child.type === 'softbreak' || child.type === 'hardbreak') return ' '; + if (child.type === 'image') return child.content; + return ''; + }) + .join('') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Consume the first heading that the ordinary body renderer would promote: + * a top-level ATX H1/H2 outside code fences. The exact source line is removed + * so the Card 2.0 header and body do not repeat it. No other markdown changes. + */ +export function extractFirstReplyCardHeading(input: string): ExtractedReplyCardHeading { + if (!input) return { markdown: input }; + const parseInput = unescapeFenceLines(input); + const tokens = md.parse(parseInput, {}); + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + if ( + token.level !== 0 + || token.type !== 'heading_open' + || !/^h[12]$/.test(token.tag) + || !/^#{1,2}$/.test(token.markup) + || !token.map + ) { + continue; + } + const heading = inlineTokenPlainText(tokens[index + 1]); + if (!heading) continue; + const lines = input.split('\n'); + const [start, end] = token.map as [number, number]; + lines.splice(start, Math.max(1, end - start)); + return { markdown: lines.join('\n'), heading }; + } + return { markdown: input }; +} + /** * Split markdown into card v2 body elements: * 1. Pipe tables → native `table` widget (Feishu's markdown widget can't @@ -992,11 +1065,7 @@ export function buildMarkdownCard( elements.push({ tag: 'hr' }); elements.push(footer.element); } - return JSON.stringify({ - schema: '2.0', - config: { ...REPLY_CARD_CONFIG }, - body: { direction: 'vertical', elements }, - }); + return JSON.stringify(createReplyCard(elements)); } /** Build the canonical final-answer card. Streaming/progress/session cards @@ -1022,7 +1091,7 @@ export function buildCanonicalFinalReplyCard(opts: { locale: opts.locale, }); if (footer) elements.push({ tag: 'hr' }, footer.element); - return JSON.stringify({ schema: '2.0', config: { ...REPLY_CARD_CONFIG }, body: { direction: 'vertical', elements } }); + return JSON.stringify(createReplyCard(elements)); } /** Prefix every line with `> ` so Feishu's markdown widget renders it as a @@ -1114,9 +1183,5 @@ export function buildContextualReplyCard(opts: { elements.push(footer.element); } - return JSON.stringify({ - schema: '2.0', - config: { ...REPLY_CARD_CONFIG }, - body: { direction: 'vertical', elements }, - }); + return JSON.stringify(createReplyCard(elements)); } diff --git a/src/im/lark/message-parser.ts b/src/im/lark/message-parser.ts index 6c01c3ad59..40dcbadc44 100644 --- a/src/im/lark/message-parser.ts +++ b/src/im/lark/message-parser.ts @@ -920,8 +920,11 @@ export function extractCardContent(rawContent: string, numberer?: ImgNumberer): // 卡片的 outgoing 消息)让 elements 内容自己说话,避免在正文前堆一行 // 多余的 `[卡片]`。整张卡片真的没内容时下方 `parts.join('\n') || '[卡片]'` // 会兜底返回占位。 - const title = card.title ?? card.header?.title?.content; + const title = nestedCardText(card.title) ?? nestedCardText(card.header?.title); if (title) parts.push(`[卡片: ${title}]`); + for (const tagText of extractCardHeaderTagTexts(card.header)) { + parts.push(`[标签: ${tagText}]`); + } // v2 cards nest elements under `body`; fall back to legacy top-level. const rootElements = Array.isArray(card.body?.elements) @@ -1129,6 +1132,34 @@ export function mergeCardText(textA: string, textB: string): string { const baseLines = b.split('\n').filter(l => !isPureCardUpgradeFallback(l)); const bAll = baseLines.map(normalizeForDedup).join(''); + // CardKit can expose header metadata in only one of the two message-detail + // representations. B remains authoritative for the single card title, while + // tags are an exact-value union: if B renders one tag and A renders a second, + // neither fact may disappear merely because both share the same metadata kind. + const metadataKind = (line: string): 'card' | 'tag' | undefined => { + if (/^\[卡片(?::[^\]]+)?\]$/.test(line.trim())) return 'card'; + if (/^\[标签:[^\]]+\]$/.test(line.trim())) return 'tag'; + return undefined; + }; + const bHasCardMetadata = baseLines.some(line => metadataKind(line) === 'card'); + const bTagMetadata = new Set( + baseLines + .filter(line => metadataKind(line) === 'tag') + .map(normalizeForDedup) + .filter(Boolean), + ); + const seenMetadata = new Set(); + const missingMetadata = a.split('\n').filter(line => { + const kind = metadataKind(line); + if (!kind) return false; + const key = normalizeForDedup(line); + if (kind === 'card' && bHasCardMetadata) return false; + if (kind === 'tag' && bTagMetadata.has(key)) return false; + if (!key || seenMetadata.has(key)) return false; + seenMetadata.add(key); + return true; + }); + const filled = baseLines.map(line => { const sm = stripInlineMarkup(line).trimEnd(); // empty-value field label, e.g. "值班人:" — but not bracketed section @@ -1142,6 +1173,16 @@ export function mergeCardText(textA: string, textB: string): string { if (normalizeForDedup(value) && bAll.includes(normalizeForDedup(value))) return line; return `${line.replace(/\s*$/, '')} ${value}`; }); + const missingCards = missingMetadata.filter(line => metadataKind(line) === 'card'); + const missingTags = missingMetadata.filter(line => metadataKind(line) === 'tag'); + if (missingCards.length > 0) filled.unshift(...missingCards); + if (missingTags.length > 0) { + let lastCardIndex = -1; + for (let index = 0; index < filled.length; index++) { + if (metadataKind(filled[index]) === 'card') lastCardIndex = index; + } + filled.splice(lastCardIndex + 1, 0, ...missingTags); + } // One honest marker if A carried sub-cards Lark only renders client-side. if (aHoleCount > 0) filled.push(CARD_EMBEDDED_PLACEHOLDER); @@ -1291,17 +1332,16 @@ function withImgAlt(label: string, alt: string): string { return label.endsWith(']') ? `${label.slice(0, -1)}: ${alt}]` : `${label} (${alt})`; } -/** Normalize a schema-v2 table label/cell into one safe pipe-table cell. - * Lark table values are usually strings (`lark_md`), but third-party cards - * may wrap text in a plain-text object. Message reads also return CardKit's - * normalized shape (`property.elements[].property.content`) rather than the - * original string sent by botmux. */ -function nestedCardTableText(value: unknown): string | undefined { +/** Read visible text from a CardKit value. Lark message reads often replace + * the builder JSON with a rendered tree (`property.elements[].property.content`). + * Header titles/tags and native table labels/cells all use this same normalizer + * so quoted/history does not depend on the original send shape. */ +function nestedCardText(value: unknown): string | undefined { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); } if (Array.isArray(value)) { - const parts = value.map(nestedCardTableText).filter((part): part is string => Boolean(part)); + const parts = value.map(nestedCardText).filter((part): part is string => Boolean(part)); return parts.length > 0 ? parts.join('') : undefined; } if (!value || typeof value !== 'object') return undefined; @@ -1326,16 +1366,37 @@ function nestedCardTableText(value: unknown): string | undefined { ]; for (const children of childGroups) { if (!Array.isArray(children) || children.length === 0) continue; - const nested = nestedCardTableText(children); + const nested = nestedCardText(children); if (nested) return nested; } return undefined; } +function extractCardHeaderTagTexts(header: unknown): string[] { + if (!header || typeof header !== 'object') return []; + const record = header as Record; + const groups: unknown[] = [record.text_tag_list]; + if (record.i18n_text_tag_list && typeof record.i18n_text_tag_list === 'object') { + groups.push(...Object.values(record.i18n_text_tag_list)); + } + const result: string[] = []; + const seen = new Set(); + for (const group of groups) { + if (!Array.isArray(group)) continue; + for (const tag of group) { + const text = nestedCardText(tag?.text ?? tag)?.trim(); + if (!text || seen.has(text)) continue; + seen.add(text); + result.push(text); + } + } + return result; +} + /** Newlines become `
` so a single value cannot split the reconstructed * table into extra rows. */ function cardTableCellText(value: unknown): string { - const text = nestedCardTableText(value); + const text = nestedCardText(value); return (text ?? '') .replace(/\r\n?/g, '\n') .replace(/\\/g, '\\\\') diff --git a/src/im/lark/reply-card-style.ts b/src/im/lark/reply-card-style.ts new file mode 100644 index 0000000000..88bc51c365 --- /dev/null +++ b/src/im/lark/reply-card-style.ts @@ -0,0 +1,423 @@ +/** + * Bot reply-card layout vocabulary and Card 2.0 header builder. + * + * This module is deliberately browser-safe: Dashboard code, bot config + * parsing, the CLI send path, and tests can share the exact same enums without + * importing Node-only registry or transport code. + */ + +export const REPLY_LAYOUTS = [ + 'result', + 'progress', + 'risk', + 'blocked', + 'handoff', +] as const; + +export type ReplyLayout = typeof REPLY_LAYOUTS[number]; + +export const REPLY_THEMES = ['default', 'minimal', 'vivid'] as const; +export type ReplyTheme = typeof REPLY_THEMES[number]; + +/** Keep the spawn-time JSON env comfortably below OS per-entry limits even + * for four-byte Unicode, while leaving ample room for a custom recipe guide. */ +export const REPLY_RECIPE_PROMPT_MAX_CODEPOINTS = 4096; +/** Header tags are deliberately short labels, not another body text channel. */ +export const REPLY_LAYOUT_TAG_MAX_CODEPOINTS = 32; + +/** Official Feishu/Lark Card 2.0 `header.template` palette. */ +export const REPLY_HEADER_COLORS = [ + 'blue', + 'wathet', + 'turquoise', + 'green', + 'yellow', + 'orange', + 'red', + 'carmine', + 'violet', + 'purple', + 'indigo', + 'grey', +] as const; + +export type ReplyHeaderColor = typeof REPLY_HEADER_COLORS[number]; + +/** Official Card header `text_tag.color` palette. It differs from the header + * template palette: tags use `neutral`/`lime` and do not accept `grey`. */ +export const REPLY_TEXT_TAG_COLORS = [ + 'neutral', + 'blue', + 'turquoise', + 'lime', + 'orange', + 'violet', + 'indigo', + 'wathet', + 'green', + 'yellow', + 'red', + 'purple', + 'carmine', +] as const; + +export type ReplyTextTagColor = typeof REPLY_TEXT_TAG_COLORS[number]; + +/** Sparse per-bot overrides persisted under `BotConfig.replyStyle`. */ +export interface ReplyStyleConfig { + recipes?: boolean; + layout?: boolean; + theme?: ReplyTheme; + recipePrompt?: string; + layoutColors?: Partial>; + /** Missing key inherits the theme; an explicit empty string hides the tag. */ + layoutTags?: Partial>; +} + +export interface ReplyCardTextTag { + tag: 'text_tag'; + color: ReplyTextTagColor; + text: { + tag: 'plain_text'; + content: string; + }; +} + +export interface ReplyCardHeader { + title: { + tag: 'plain_text'; + content: string; + }; + template?: ReplyHeaderColor; + text_tag_list?: ReplyCardTextTag[]; +} + +/** Semantic prefixes are fixed; only an explicitly selected layout can add + * one. The first eligible body heading may personalise the rest of the title + * but never selects or changes the layout. */ +export const DEFAULT_REPLY_LAYOUT_TITLES: Readonly> = { + result: '结果', + progress: '进度', + risk: '需要确认', + blocked: '受阻', + handoff: '交接', +}; + +export const DEFAULT_REPLY_LAYOUT_COLORS: Readonly> = { + result: 'green', + progress: 'blue', + risk: 'orange', + blocked: 'red', + handoff: 'indigo', +}; + +/** Empty string means the default theme intentionally renders no tag. */ +export const DEFAULT_REPLY_LAYOUT_TAGS: Readonly> = { + result: '', + progress: '', + risk: '需要你', + blocked: '需要你', + handoff: '', +}; + +export const VIVID_REPLY_LAYOUT_TAGS: Readonly> = { + result: '完成', + progress: '进行中', + risk: '需要你', + blocked: '需要你', + handoff: '交接', +}; + +/** Text-tag colors are semantic and stay stable when a custom header color is + * applied. This prevents a cosmetic header override from turning an attention + * label green or a completion label red. */ +export const REPLY_LAYOUT_TAG_COLORS: Readonly> = { + result: 'green', + progress: 'blue', + risk: 'red', + blocked: 'red', + handoff: 'indigo', +}; + +export function isReplyLayout(value: unknown): value is ReplyLayout { + return typeof value === 'string' && (REPLY_LAYOUTS as readonly string[]).includes(value); +} + +export function isReplyTheme(value: unknown): value is ReplyTheme { + return typeof value === 'string' && (REPLY_THEMES as readonly string[]).includes(value); +} + +export function isReplyHeaderColor(value: unknown): value is ReplyHeaderColor { + return typeof value === 'string' && (REPLY_HEADER_COLORS as readonly string[]).includes(value); +} + +export function isReplyTextTagColor(value: unknown): value is ReplyTextTagColor { + return typeof value === 'string' && (REPLY_TEXT_TAG_COLORS as readonly string[]).includes(value); +} + +export interface NormalizedReplyStyleResult { + /** Sparse validated override. Undefined means the entire block is default. */ + config?: ReplyStyleConfig; + warnings: string[]; +} + +export interface ResolvedReplyLayoutStyle { + /** Omitted for the minimal theme unless an explicit color override exists. */ + template?: ReplyHeaderColor; + /** Omitted when the theme/override intentionally hides the tag. */ + tag?: string; + tagColor: ReplyTextTagColor; +} + +export interface ResolvedReplyStyle { + recipes: boolean; + layout: boolean; + theme: ReplyTheme; + /** A nonblank custom replacement for the built-in recipe section. */ + recipePrompt?: string; + layouts: Record; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function exceedsCodePointLimit(value: string, max: number): boolean { + let count = 0; + for (const _ of value) { + count += 1; + if (count > max) return true; + } + return false; +} + +/** + * Validate a hand-edited bots.json block without turning cosmetic mistakes + * into daemon/send failures. Invalid fields are dropped one by one and the + * caller decides where to log the returned diagnostics. + */ +export function normalizeReplyStyleConfig(raw: unknown): NormalizedReplyStyleResult { + if (raw === undefined || raw === null) return { warnings: [] }; + if (!isRecord(raw)) { + return { warnings: ['replyStyle 必须是对象,已忽略并使用缺省回复风格'] }; + } + + const warnings: string[] = []; + const config: ReplyStyleConfig = {}; + for (const key of ['recipes', 'layout'] as const) { + const value = raw[key]; + if (value === undefined) continue; + if (typeof value === 'boolean') config[key] = value; + else warnings.push(`replyStyle.${key} 必须是 boolean,已忽略`); + } + + if (raw.theme !== undefined) { + if (isReplyTheme(raw.theme)) config.theme = raw.theme; + else warnings.push(`replyStyle.theme 不支持“${String(raw.theme)}”,已回退 default`); + } + + if (raw.recipePrompt !== undefined) { + if (typeof raw.recipePrompt !== 'string') { + warnings.push('replyStyle.recipePrompt 必须是 string,已忽略'); + } else { + const prompt = raw.recipePrompt.trim(); + if (prompt.includes('\0')) { + warnings.push('replyStyle.recipePrompt 不能包含 NUL,已忽略'); + } else if (exceedsCodePointLimit(prompt, REPLY_RECIPE_PROMPT_MAX_CODEPOINTS)) { + warnings.push(`replyStyle.recipePrompt 不能超过 ${REPLY_RECIPE_PROMPT_MAX_CODEPOINTS} 个 Unicode 字符,已忽略`); + } else if (prompt) { + config.recipePrompt = prompt; + } + } + } + + if (raw.layoutColors !== undefined) { + if (!isRecord(raw.layoutColors)) { + warnings.push('replyStyle.layoutColors 必须是对象,已忽略'); + } else { + const colors: Partial> = {}; + for (const [rawLayout, color] of Object.entries(raw.layoutColors)) { + if (!isReplyLayout(rawLayout)) { + warnings.push(`replyStyle.layoutColors.${rawLayout} 不是支持的 layout,已忽略`); + continue; + } + if (!isReplyHeaderColor(color)) { + warnings.push(`replyStyle.layoutColors.${rawLayout} 不是官方颜色,已忽略`); + continue; + } + if (rawLayout === 'handoff' && color === 'grey') { + warnings.push('replyStyle.layoutColors.handoff 不允许 grey,已回退主题颜色'); + continue; + } + colors[rawLayout] = color; + } + if (Object.keys(colors).length > 0) config.layoutColors = colors; + } + } + + if (raw.layoutTags !== undefined) { + if (!isRecord(raw.layoutTags)) { + warnings.push('replyStyle.layoutTags 必须是对象,已忽略'); + } else { + const tags: Partial> = {}; + for (const [rawLayout, tag] of Object.entries(raw.layoutTags)) { + if (!isReplyLayout(rawLayout)) { + warnings.push(`replyStyle.layoutTags.${rawLayout} 不是支持的 layout,已忽略`); + continue; + } + if (typeof tag !== 'string') { + warnings.push(`replyStyle.layoutTags.${rawLayout} 必须是 string,已忽略`); + continue; + } + const normalizedTag = tag.trim(); + if (/\p{Cc}/u.test(normalizedTag)) { + warnings.push(`replyStyle.layoutTags.${rawLayout} 不能包含控制字符,已忽略`); + continue; + } + if (exceedsCodePointLimit(normalizedTag, REPLY_LAYOUT_TAG_MAX_CODEPOINTS)) { + warnings.push(`replyStyle.layoutTags.${rawLayout} 不能超过 ${REPLY_LAYOUT_TAG_MAX_CODEPOINTS} 个 Unicode 字符,已忽略`); + continue; + } + // Missing key inherits; an explicit empty string intentionally hides. + tags[rawLayout] = normalizedTag; + } + if (Object.keys(tags).length > 0) config.layoutTags = tags; + } + } + + return Object.keys(config).length > 0 + ? { config, warnings } + : { warnings }; +} + +export function resolveReplyStyle(config?: ReplyStyleConfig): ResolvedReplyStyle { + const theme = config?.theme ?? 'default'; + const themeTags = theme === 'vivid' + ? VIVID_REPLY_LAYOUT_TAGS + : DEFAULT_REPLY_LAYOUT_TAGS; + const layouts = Object.fromEntries(REPLY_LAYOUTS.map(layout => { + const template = config?.layoutColors?.[layout] + ?? (theme === 'minimal' ? undefined : DEFAULT_REPLY_LAYOUT_COLORS[layout]); + const tag = config?.layoutTags && Object.prototype.hasOwnProperty.call(config.layoutTags, layout) + ? config.layoutTags[layout] + : themeTags[layout]; + return [layout, { + ...(template ? { template } : {}), + ...(tag ? { tag } : {}), + tagColor: REPLY_LAYOUT_TAG_COLORS[layout], + } satisfies ResolvedReplyLayoutStyle]; + })) as Record; + return { + recipes: config?.recipes ?? true, + layout: config?.layout ?? true, + theme, + ...(config?.recipePrompt ? { recipePrompt: config.recipePrompt } : {}), + layouts, + }; +} + +export interface ReplyLayoutRequest { + /** Whether any `--layout` spelling was present. */ + present: boolean; + /** Set only for one well-formed, supported layout request. */ + layout?: ReplyLayout; + /** Human-readable fail-soft diagnostic for stderr. */ + warning?: string; +} + +const LAYOUT_USAGE = REPLY_LAYOUTS.join('|'); + +/** + * Parse `--layout value` / `--layout=value` without letting a missing value + * consume the next flag. Invalid, empty, or duplicate requests deliberately + * fall back to the ordinary reply card instead of failing the send. + */ +export function parseReplyLayoutRequest(args: readonly string[]): ReplyLayoutRequest { + const occurrences: Array<{ token: string; index: number }> = []; + for (let index = 0; index < args.length; index++) { + const token = args[index]; + if (token === '--layout' || token.startsWith('--layout=')) { + occurrences.push({ token, index }); + } + } + if (occurrences.length === 0) return { present: false }; + + const warning = (detail: string): ReplyLayoutRequest => ({ + present: true, + warning: `botmux send: --layout ${detail},已忽略并按普通回复卡发送(支持 ${LAYOUT_USAGE})`, + }); + if (occurrences.length !== 1) return warning('只能指定一次'); + + const [{ token, index }] = occurrences; + const value = token === '--layout' + ? args[index + 1] + : token.slice('--layout='.length); + if (!value || value.startsWith('--')) return warning('缺少有效名称'); + if (!isReplyLayout(value)) return warning(`不支持“${value}”`); + return { present: true, layout: value }; +} + +function normalizedTitleForDedup(value: string): string { + return value + .normalize('NFKC') + .toLocaleLowerCase() + .replace(/[\s·::,,.。、!!??\-—_\/\\|()[\]{}()【】]/g, ''); +} + +function isRepeatedLayoutPrefix(subject: string, prefix: string): boolean { + const normalizedSubject = normalizedTitleForDedup(subject); + const normalizedPrefix = normalizedTitleForDedup(prefix); + if (!normalizedSubject || !normalizedPrefix) return false; + if (normalizedSubject.length % normalizedPrefix.length !== 0) return false; + return normalizedSubject === normalizedPrefix.repeat( + normalizedSubject.length / normalizedPrefix.length, + ); +} + +/** + * Build the finalised default-theme Card 2.0 header for one explicit layout. + * The optional heading comes only from the first top-level ATX H1/H2 selected + * by the markdown builder; it personalises the title but never selects the + * layout. A heading that merely repeats the prefix is suppressed. + */ +export function buildReplyLayoutHeader( + layout: ReplyLayout, + heading?: string, + style: ResolvedReplyStyle = resolveReplyStyle(), +): ReplyCardHeader { + const layoutStyle = style.layouts[layout]; + const tagText = layoutStyle.tag; + const tagColor = isReplyTextTagColor(layoutStyle.tagColor) + ? layoutStyle.tagColor + : 'neutral'; + const prefix = DEFAULT_REPLY_LAYOUT_TITLES[layout]; + const subject = heading?.trim(); + const title = subject && !isRepeatedLayoutPrefix(subject, prefix) + ? `${prefix} · ${subject}` + : prefix; + return { + ...(layoutStyle.template ? { template: layoutStyle.template } : {}), + title: { + tag: 'plain_text', + content: title, + }, + ...(tagText + ? { + text_tag_list: [{ + tag: 'text_tag' as const, + color: tagColor, + text: { tag: 'plain_text' as const, content: tagText }, + }], + } + : {}), + }; +} + +/** Backward-compatible default-theme helper used by the first implementation + * block and external callers that do not need per-bot configuration. */ +export function buildDefaultReplyLayoutHeader( + layout: ReplyLayout, + heading?: string, +): ReplyCardHeader { + return buildReplyLayoutHeader(layout, heading); +} diff --git a/test/bot-registry.test.ts b/test/bot-registry.test.ts index 88ded02b9b..9d7b9818a4 100644 --- a/test/bot-registry.test.ts +++ b/test/bot-registry.test.ts @@ -1090,6 +1090,58 @@ describe('parseBotConfigsFromText — autoStartOnGroupJoinSeed', () => { }); }); +describe('parseBotConfigsFromText — replyStyle', () => { + let mod: Awaited>; + + beforeEach(async () => { + mod = await freshImport(); + }); + + it('keeps a normalized sparse replyStyle without freezing theme defaults', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([{ + larkAppId: 'a', + larkAppSecret: 's', + replyStyle: { + recipes: false, + layout: true, + theme: 'vivid', + recipePrompt: ' 自定义配方 ', + layoutColors: { progress: 'wathet', handoff: 'grey' }, + layoutTags: { result: '', blocked: ' 等你拍板 ' }, + }, + }])); + + expect(cfg.replyStyle).toEqual({ + recipes: false, + layout: true, + theme: 'vivid', + recipePrompt: '自定义配方', + layoutColors: { progress: 'wathet' }, + layoutTags: { result: '', blocked: '等你拍板' }, + }); + }); + + it('drops malformed replyStyle fields independently instead of rejecting the bot', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([{ + larkAppId: 'a', + larkAppSecret: 's', + replyStyle: { + recipes: 'yes', + layout: false, + theme: 'rainbow', + layoutColors: { risk: 'not-a-color', progress: 'blue' }, + layoutTags: { blocked: 1, risk: '需要你' }, + }, + }])); + + expect(cfg.replyStyle).toEqual({ + layout: false, + layoutColors: { progress: 'blue' }, + layoutTags: { risk: '需要你' }, + }); + }); +}); + // ─── parseBotConfigsFromText — apiOnly (core-only / headless) ────────────── describe('parseBotConfigsFromText — apiOnly', () => { @@ -1366,16 +1418,22 @@ describe('getBot / getBotClient', () => { describe('resolveBrandLabel — sandbox env-first (footer role name fix)', () => { let mod: Awaited>; const saved = { + session: process.env.BOTMUX_SESSION_ID, app: process.env.BOTMUX_LARK_APP_ID, brand: process.env.BOTMUX_BRAND_LABEL, usageDisplay: process.env.BOTMUX_USAGE_DISPLAY, + replyStyle: process.env.BOTMUX_REPLY_STYLE, }; beforeEach(async () => { mod = await freshImport(); }); afterEach(() => { + if (saved.session === undefined) delete process.env.BOTMUX_SESSION_ID; + else process.env.BOTMUX_SESSION_ID = saved.session; if (saved.app === undefined) delete process.env.BOTMUX_LARK_APP_ID; else process.env.BOTMUX_LARK_APP_ID = saved.app; if (saved.brand === undefined) delete process.env.BOTMUX_BRAND_LABEL; else process.env.BOTMUX_BRAND_LABEL = saved.brand; if (saved.usageDisplay === undefined) delete process.env.BOTMUX_USAGE_DISPLAY; else process.env.BOTMUX_USAGE_DISPLAY = saved.usageDisplay; + if (saved.replyStyle === undefined) delete process.env.BOTMUX_REPLY_STYLE; + else process.env.BOTMUX_REPLY_STYLE = saved.replyStyle; }); it('returns the injected env brandLabel for the own appId WITHOUT reading bots.json (the sandbox path)', () => { @@ -1440,6 +1498,70 @@ describe('resolveBrandLabel — sandbox env-first (footer role name fix)', () => mod.registerBot(makeCfg({ larkAppId: 'app_hot' })); expect(mod.resolveUsageDisplay('app_hot')).toBe('streaming'); }); + + it('prefers the frozen replyStyle env for its own app but never leaks it across apps', () => { + process.env.BOTMUX_SESSION_ID = 'session_frozen'; + process.env.BOTMUX_LARK_APP_ID = 'app_frozen'; + process.env.BOTMUX_REPLY_STYLE = JSON.stringify({ layout: false, theme: 'minimal' }); + mod.registerBot(makeCfg({ + larkAppId: 'app_frozen', + replyStyle: { layout: true, theme: 'vivid' }, + })); + mod.registerBot(makeCfg({ + larkAppId: 'app_other', + replyStyle: { recipes: false }, + })); + + expect(mod.resolveReplyStyleConfig('app_frozen')).toEqual({ + layout: false, + theme: 'minimal', + }); + expect(mod.resolveReplyStyleConfig('app_other')).toEqual({ recipes: false }); + }); + + it('uses the live registry when an adopt/global send has no worker snapshot', () => { + delete process.env.BOTMUX_SESSION_ID; + delete process.env.BOTMUX_LARK_APP_ID; + delete process.env.BOTMUX_REPLY_STYLE; + mod.registerBot(makeCfg({ + larkAppId: 'app_adopt_live', + replyStyle: { layout: true, theme: 'vivid' }, + })); + expect(mod.resolveReplyStyleConfig('app_adopt_live')).toEqual({ + layout: true, + theme: 'vivid', + }); + + mod.registerBot(makeCfg({ + larkAppId: 'app_adopt_live', + replyStyle: { layout: false, theme: 'minimal' }, + })); + expect(mod.resolveReplyStyleConfig('app_adopt_live')).toEqual({ + layout: false, + theme: 'minimal', + }); + }); + + it('ignores stale ambient style outside a BotMux session and uses live config', () => { + delete process.env.BOTMUX_SESSION_ID; + process.env.BOTMUX_LARK_APP_ID = 'app_adopt_live'; + process.env.BOTMUX_REPLY_STYLE = JSON.stringify({ layout: false, theme: 'minimal' }); + mod.registerBot(makeCfg({ + larkAppId: 'app_adopt_live', + replyStyle: { layout: true, theme: 'vivid' }, + })); + expect(mod.resolveReplyStyleConfig('app_adopt_live')).toEqual({ + layout: true, + theme: 'vivid', + }); + }); + + it('fails soft to the default replyStyle when the injected snapshot is malformed', () => { + process.env.BOTMUX_SESSION_ID = 'session_frozen'; + process.env.BOTMUX_LARK_APP_ID = 'app_frozen'; + process.env.BOTMUX_REPLY_STYLE = '{broken'; + expect(mod.resolveReplyStyleConfig('app_frozen')).toBeUndefined(); + }); }); // ─── getAllBots ──────────────────────────────────────────────────────────── diff --git a/test/cli-send-hook-context.test.ts b/test/cli-send-hook-context.test.ts index 7f3fac53ff..9bed4d0f54 100644 --- a/test/cli-send-hook-context.test.ts +++ b/test/cli-send-hook-context.test.ts @@ -52,6 +52,24 @@ describe('cmdSend hook context wiring', () => { expect(cmdSend).toContain('发送失败: ${describeSendFailure(err)}'); }); + it('parses and relays an explicit layout before building the canonical reply card', () => { + const cmdSendStart = cliSource.indexOf('async function cmdSend('); + const cmdDispatchStart = cliSource.indexOf('async function cmdDispatch(', cmdSendStart); + const cmdSend = cliSource.slice(cmdSendStart, cmdDispatchStart); + const parseAt = cmdSend.indexOf('const replyLayoutRequest = parseReplyLayoutRequest(rest);'); + const relayAt = cmdSend.indexOf('await relaySend(rest, relayDir, replyLayout);'); + + expect(parseAt).toBeGreaterThanOrEqual(0); + expect(relayAt).toBeGreaterThan(parseAt); + expect(cmdSend).toContain('let replyLayout = replyLayoutRequest.layout;'); + expect(cmdSend).toContain('extractFirstReplyCardHeading(text)'); + expect(cmdSend).toContain('buildReplyLayoutHeader(replyLayout, layoutBody.heading, replyStyle)'); + expect(cmdSend).toContain('resolveReplyStyle(resolveReplyStyleConfig(s.larkAppId))'); + expect(cmdSend).toContain('createReplyCard([...elements], layoutHeader)'); + expect(cmdSend).toContain('createReplyCard(elements, layoutHeader)'); + expect(cliSource).toContain('--layout result|progress|risk|blocked|handoff'); + }); + it('strips trailing memory citations before relay and direct-send rendering', () => { const relayStart = cliSource.indexOf('async function relaySend('); const relayEnd = cliSource.indexOf('\nfunction currentBotIsApiOnly', relayStart); @@ -257,6 +275,7 @@ describe('cmdSend hook context wiring', () => { process.stdout.write(JSON.stringify({ command: argv[0], content: readFileSync(argv[argv.indexOf('--content-file') + 1], 'utf8'), + layout: argv.includes('--layout') ? argv[argv.indexOf('--layout') + 1] : null, responseKind: argv.includes('--response-kind') ? argv[argv.indexOf('--response-kind') + 1] : null, sessionId: argv[argv.indexOf('--session-id') + 1], turnId: process.env.BOTMUX_TURN_ID, @@ -281,7 +300,7 @@ describe('cmdSend hook context wiring', () => { }); try { const result = await runCli( - ['send', 'relay body', '--session-id', 'session', '--no-mention'], + ['send', 'relay body', '--layout', 'risk', '--session-id', 'session', '--no-mention'], { ...process.env, SESSION_DATA_DIR: dataDir, @@ -295,12 +314,33 @@ describe('cmdSend hook context wiring', () => { expect(JSON.parse(result.stdout)).toEqual({ command: 'send', content: 'relay body', + layout: 'risk', responseKind: null, sessionId: 'session', turnId: 'turn-live', dispatchAttempt: '4', requiresLedger: '1', }); + + const layoutOff = await runCli( + ['send', 'relay body without shell', '--layout', 'risk', '--session-id', 'session', '--no-mention'], + { + ...process.env, + SESSION_DATA_DIR: dataDir, + BOTMUX_SESSION_ID: 'session', + BOTMUX_SEND_RELAY: outbox, + BOTMUX_HOST_RELAY_AUTHORIZED: '', + BOTMUX_WORKFLOW: '', + BOTMUX_LARK_APP_ID: 'app-a', + BOTMUX_REPLY_STYLE: JSON.stringify({ layout: false }), + }, + ); + expect(layoutOff.code, layoutOff.stderr).toBe(0); + expect(layoutOff.stderr).toContain('当前 Bot 已关闭 layout'); + expect(JSON.parse(layoutOff.stdout)).toMatchObject({ + content: 'relay body without shell', + layout: null, + }); } finally { stop(); rmSync(root, { recursive: true, force: true }); diff --git a/test/md-card.test.ts b/test/md-card.test.ts index 42ec809ca8..a0c65c1308 100644 --- a/test/md-card.test.ts +++ b/test/md-card.test.ts @@ -21,7 +21,9 @@ import { brandFooterSegment, cardUsageFooterSegment, cardUsageRuntimeSegment, + createReplyCard, DEFAULT_BRAND_LABEL, + extractFirstReplyCardHeading, hasMarkdown, normalizeLocalHomeLinks, } from '../src/im/lark/md-card.js'; @@ -30,6 +32,64 @@ function mdElements(out: any[]): Array<{ tag: 'markdown'; content: string }> { return out.filter(e => e.tag === 'markdown'); } +describe('reply-card layout envelope and title extraction', () => { + it('keeps the ordinary v2 envelope identical when no header is requested', () => { + expect(createReplyCard([{ tag: 'markdown', content: '正文' }])).toEqual({ + schema: '2.0', + config: { update_multi: true, width_mode: 'fill' }, + body: { + direction: 'vertical', + elements: [{ tag: 'markdown', content: '正文' }], + }, + }); + }); + + it('puts an optional Card 2.0 header on the same canonical envelope', () => { + const header = { + template: 'indigo' as const, + title: { tag: 'plain_text' as const, content: '交接 · 下一位接手发布' }, + }; + expect(createReplyCard([], header)).toMatchObject({ + schema: '2.0', + config: { update_multi: true, width_mode: 'fill' }, + header, + body: { direction: 'vertical', elements: [] }, + }); + }); + + it('consumes only the first top-level ATX H1/H2 and preserves the rest', () => { + const input = [ + '> # 引用标题', + '', + '```md', + '# 代码里的标题', + '```', + '', + '### 细节', + '', + '## **发布** [说明](https://example.com) `v2`', + '', + '正文', + '', + '# 第二个标题', + ].join('\n'); + const extracted = extractFirstReplyCardHeading(input); + + expect(extracted.heading).toBe('发布 说明 v2'); + expect(extracted.markdown).not.toContain('## **发布**'); + expect(extracted.markdown).toContain('> # 引用标题'); + expect(extracted.markdown).toContain('# 代码里的标题'); + expect(extracted.markdown).toContain('### 细节'); + expect(extracted.markdown).toContain('# 第二个标题'); + }); + + it('does not consume H3, setext, or an empty ATX heading', () => { + for (const input of ['### 细节\n\n正文', '旧标题\n===\n\n正文', '#\n\n正文']) { + expect(extractFirstReplyCardHeading(input)).toEqual({ markdown: input }); + } + }); +}); + describe('buildCardBodyElements', () => { it('returns [] for empty input', () => { expect(buildCardBodyElements('')).toEqual([]); diff --git a/test/message-parser.test.ts b/test/message-parser.test.ts index ac507fd0af..1cc540b5cb 100644 --- a/test/message-parser.test.ts +++ b/test/message-parser.test.ts @@ -755,6 +755,60 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = expect(result.content).toContain('| 标题层级 | H1/H2 明显大于正文 |'); }); + it('round-trips a live-normalized layout header, tag, body, and native table together', () => { + const normalizedMarkdown = (...contents: string[]) => ({ + tag: 'markdown', + property: { + elements: contents.map((content, index) => ({ + tag: index === 0 ? 'plain_text' : 'code_span', + property: { content, textAlign: 'left' }, + })), + markdownElements: [], + originTag: 'lark_md', + }, + }); + // Fixture mirrors `botmux quoted --raw` after CardKit normalisation; it is + // intentionally not the JSON emitted by the reply-card builder. + const card = { + schema: '2.0', + config: { enable_forward_interaction: false, streaming_mode: false, width_mode: 'fill' }, + header: { + template: 'orange', + text_tag_list: [{ + color: 'red', + tag: 'text_tag', + text: { content: '需要你', tag: 'plain_text' }, + }], + title: { content: '需要确认 · 回复卡样式是否收口', tag: 'plain_text' }, + }, + body: { direction: 'vertical', elements: [ + { tag: 'markdown', content: '请确认下面三项。' }, + { + tag: 'table', + columns: [ + { name: 'c0', display_name: normalizedMarkdown('检查项') }, + { name: 'c1', display_name: normalizedMarkdown('实现配置') }, + ], + rows: [{ + c0: normalizedMarkdown('卡片宽度'), + c1: normalizedMarkdown('Card 2.0 ', 'width_mode: fill'), + }, { + c0: normalizedMarkdown('标题层级'), + c1: normalizedMarkdown('H1/H2 ', 'heading-2'), + }], + }, + ] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + + expect(result.content).toContain('[卡片: 需要确认 · 回复卡样式是否收口]'); + expect(result.content).toContain('[标签: 需要你]'); + expect(result.content).toContain('请确认下面三项。'); + expect(result.content).toContain('| 检查项 | 实现配置 |'); + expect(result.content).toContain('| 卡片宽度 | Card 2.0 width_mode: fill |'); + expect(result.content).toContain('| 标题层级 | H1/H2 heading-2 |'); + }); + it('round-trips a footer whose custom brand contains an unmatched bracket', () => { const raw = buildMarkdownCard('正文内容', 'ou_owner', 'Acme [beta'); const result = parseApiMessage(makeMsg('interactive', JSON.parse(raw))); @@ -1232,6 +1286,23 @@ describe('mergeCardText', () => { expect(mergeCardText('', '[卡片: x]\n正文')).toBe('[卡片: x]\n正文'); expect(mergeCardText('[卡片: y]\n正文', '请升级至最新版本客户端,以查看内容')).toContain('正文'); }); + + it('preserves header metadata present only in A without duplicating B metadata', () => { + const textA = '[卡片: 需要确认]\n[标签: 需要你]\n正文'; + expect(mergeCardText(textA, '[卡片: 需要确认]\n正文')).toBe( + '[卡片: 需要确认]\n[标签: 需要你]\n正文', + ); + expect(mergeCardText(textA, '[卡片: 需要确认]\n[标签: 需要你]\n正文')) + .toBe('[卡片: 需要确认]\n[标签: 需要你]\n正文'); + }); + + it('unions distinct header tags by value instead of dropping a second tag by kind', () => { + const textA = '[卡片: 进度]\n[标签: 进行中]\n[标签: 需要你]\n正文'; + const textB = '[卡片: 进度]\n[标签: 进行中]\n正文'; + expect(mergeCardText(textA, textB)).toBe( + '[卡片: 进度]\n[标签: 需要你]\n[标签: 进行中]\n正文', + ); + }); }); describe('wrapResolvedCardText sentinel', () => { diff --git a/test/reply-card-style.test.ts b/test/reply-card-style.test.ts new file mode 100644 index 0000000000..02ab7c8c88 --- /dev/null +++ b/test/reply-card-style.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDefaultReplyLayoutHeader, + buildReplyLayoutHeader, + DEFAULT_REPLY_LAYOUT_COLORS, + DEFAULT_REPLY_LAYOUT_TAGS, + DEFAULT_REPLY_LAYOUT_TITLES, + normalizeReplyStyleConfig, + parseReplyLayoutRequest, + REPLY_LAYOUT_TAG_MAX_CODEPOINTS, + REPLY_LAYOUT_TAG_COLORS, + REPLY_LAYOUTS, + REPLY_RECIPE_PROMPT_MAX_CODEPOINTS, + resolveReplyStyle, +} from '../src/im/lark/reply-card-style.js'; + +describe('reply-card layout request', () => { + it('accepts both flag spellings for every supported layout', () => { + for (const layout of REPLY_LAYOUTS) { + expect(parseReplyLayoutRequest(['正文', '--layout', layout])).toEqual({ + present: true, + layout, + }); + expect(parseReplyLayoutRequest([`--layout=${layout}`, '正文'])).toEqual({ + present: true, + layout, + }); + } + }); + + it('distinguishes an absent request from fail-soft invalid requests', () => { + expect(parseReplyLayoutRequest(['正文'])).toEqual({ present: false }); + + for (const args of [ + ['--layout'], + ['--layout', '--no-mention'], + ['--layout='], + ['--layout', 'diff'], + ['--layout=compare'], + ['--layout', 'result', '--layout', 'risk'], + ]) { + const parsed = parseReplyLayoutRequest(args); + expect(parsed.present).toBe(true); + expect(parsed.layout).toBeUndefined(); + expect(parsed.warning).toMatch(/已忽略并按普通回复卡发送/); + } + }); +}); + +describe('default reply-card layout headers', () => { + it('keeps the finalised five prefixes and official color mapping', () => { + expect(DEFAULT_REPLY_LAYOUT_TITLES).toEqual({ + result: '结果', + progress: '进度', + risk: '需要确认', + blocked: '受阻', + handoff: '交接', + }); + expect(DEFAULT_REPLY_LAYOUT_COLORS).toEqual({ + result: 'green', + progress: 'blue', + risk: 'orange', + blocked: 'red', + handoff: 'indigo', + }); + }); + + it('adds a red “需要你” tag only to risk and blocked', () => { + expect(DEFAULT_REPLY_LAYOUT_TAGS).toEqual({ + result: '', + progress: '', + risk: '需要你', + blocked: '需要你', + handoff: '', + }); + + for (const layout of REPLY_LAYOUTS) { + const header = buildDefaultReplyLayoutHeader(layout); + expect(header.template).toBe(DEFAULT_REPLY_LAYOUT_COLORS[layout]); + expect(header.title.content).toBe(DEFAULT_REPLY_LAYOUT_TITLES[layout]); + if (layout === 'risk' || layout === 'blocked') { + expect(header.text_tag_list).toEqual([{ + tag: 'text_tag', + color: 'red', + text: { tag: 'plain_text', content: '需要你' }, + }]); + } else { + expect(header).not.toHaveProperty('text_tag_list'); + } + } + }); + + it('derives a personalised title without duplicating its semantic prefix', () => { + expect(buildDefaultReplyLayoutHeader('result', '依赖升级完成').title.content) + .toBe('结果 · 依赖升级完成'); + expect(buildDefaultReplyLayoutHeader('result', '结果').title.content).toBe('结果'); + expect(buildDefaultReplyLayoutHeader('result', ' 结果: ').title.content).toBe('结果'); + expect(buildDefaultReplyLayoutHeader('result', '结果 · 结果').title.content).toBe('结果'); + expect(buildDefaultReplyLayoutHeader('blocked', '生产验证受阻').title.content) + .toBe('受阻 · 生产验证受阻'); + }); +}); + +describe('replyStyle normalisation and theme resolution', () => { + it('keeps the config sparse while preserving explicit hidden tags', () => { + expect(normalizeReplyStyleConfig(undefined)).toEqual({ warnings: [] }); + expect(normalizeReplyStyleConfig({ + recipes: false, + layout: true, + theme: 'minimal', + recipePrompt: ' 用自定义配方写作 ', + layoutColors: { risk: 'purple' }, + layoutTags: { result: '', risk: '请确认' }, + })).toEqual({ + config: { + recipes: false, + layout: true, + theme: 'minimal', + recipePrompt: '用自定义配方写作', + layoutColors: { risk: 'purple' }, + layoutTags: { result: '', risk: '请确认' }, + }, + warnings: [], + }); + }); + + it('drops malformed fields one by one and reports every fallback', () => { + const normalized = normalizeReplyStyleConfig({ + recipes: 'yes', + layout: 1, + theme: 'loud', + recipePrompt: 42, + layoutColors: { + risk: 'pink', + handoff: 'grey', + result: 'green', + compare: 'blue', + }, + layoutTags: { risk: 42, blocked: ' 需要你 ', diff: 'no' }, + }); + + expect(normalized.config).toEqual({ + layoutColors: { result: 'green' }, + layoutTags: { blocked: '需要你' }, + }); + expect(normalized.warnings).toHaveLength(9); + expect(normalized.warnings.join('\n')).toContain('handoff 不允许 grey'); + expect(normalized.warnings.join('\n')).toContain('layoutColors.compare'); + expect(normalized.warnings.join('\n')).toContain('layoutTags.diff'); + }); + + it('accepts exact Unicode limits and drops only fields that exceed them', () => { + const promptAtLimit = '🧪'.repeat(REPLY_RECIPE_PROMPT_MAX_CODEPOINTS); + const tagAtLimit = '✅'.repeat(REPLY_LAYOUT_TAG_MAX_CODEPOINTS); + expect(normalizeReplyStyleConfig({ + recipePrompt: promptAtLimit, + layoutTags: { risk: tagAtLimit }, + })).toEqual({ + config: { recipePrompt: promptAtLimit, layoutTags: { risk: tagAtLimit } }, + warnings: [], + }); + + const over = normalizeReplyStyleConfig({ + recipes: false, + recipePrompt: `${promptAtLimit}超`, + layoutTags: { + risk: `${tagAtLimit}长`, + blocked: '请处理', + }, + }); + expect(over.config).toEqual({ + recipes: false, + layoutTags: { blocked: '请处理' }, + }); + expect(over.warnings).toHaveLength(2); + expect(over.warnings.join('\n')).toContain(`${REPLY_RECIPE_PROMPT_MAX_CODEPOINTS} 个 Unicode 字符`); + expect(over.warnings.join('\n')).toContain(`layoutTags.risk 不能超过 ${REPLY_LAYOUT_TAG_MAX_CODEPOINTS}`); + }); + + it('drops environment-breaking or non-label control characters field by field', () => { + const normalized = normalizeReplyStyleConfig({ + recipePrompt: '不能进入环境\0尾部', + layoutTags: { + risk: '第一行\n第二行', + progress: '进度\u0085标签', + blocked: '需要你', + }, + }); + expect(normalized.config).toEqual({ layoutTags: { blocked: '需要你' } }); + expect(normalized.warnings).toHaveLength(3); + expect(normalized.warnings.join('\n')).toContain('recipePrompt 不能包含 NUL'); + expect(normalized.warnings.join('\n')).toContain('layoutTags.risk 不能包含控制字符'); + expect(normalized.warnings.join('\n')).toContain('layoutTags.progress 不能包含控制字符'); + }); + + it('falls back to the default block when the entire value is malformed', () => { + expect(normalizeReplyStyleConfig('default')).toEqual({ + warnings: ['replyStyle 必须是对象,已忽略并使用缺省回复风格'], + }); + expect(resolveReplyStyle()).toMatchObject({ + recipes: true, + layout: true, + theme: 'default', + }); + }); + + it('resolves default, minimal, and vivid without changing layout semantics', () => { + const defaultStyle = resolveReplyStyle(); + expect(defaultStyle.layouts.result).toEqual({ template: 'green', tagColor: 'green' }); + expect(defaultStyle.layouts.risk).toEqual({ template: 'orange', tag: '需要你', tagColor: 'red' }); + + const minimal = resolveReplyStyle({ theme: 'minimal' }); + expect(minimal.layouts.result).toEqual({ tagColor: 'green' }); + expect(minimal.layouts.blocked).toEqual({ tag: '需要你', tagColor: 'red' }); + + const vivid = resolveReplyStyle({ theme: 'vivid' }); + expect(vivid.layouts).toMatchObject({ + result: { template: 'green', tag: '完成', tagColor: 'green' }, + progress: { template: 'blue', tag: '进行中', tagColor: 'blue' }, + risk: { template: 'orange', tag: '需要你', tagColor: 'red' }, + blocked: { template: 'red', tag: '需要你', tagColor: 'red' }, + handoff: { template: 'indigo', tag: '交接', tagColor: 'indigo' }, + }); + expect(REPLY_LAYOUT_TAG_COLORS).toEqual({ + result: 'green', progress: 'blue', risk: 'red', blocked: 'red', handoff: 'indigo', + }); + }); + + it('applies sparse color/tag overrides after the theme defaults', () => { + const style = resolveReplyStyle({ + theme: 'minimal', + layoutColors: { progress: 'wathet' }, + layoutTags: { risk: '', handoff: '交给你' }, + }); + expect(style.layouts.progress.template).toBe('wathet'); + expect(style.layouts.result.template).toBeUndefined(); + expect(style.layouts.risk.tag).toBeUndefined(); + expect(style.layouts.handoff).toMatchObject({ tag: '交给你', tagColor: 'indigo' }); + }); + + it('builds themed headers and fail-softs a forged non-tag color to neutral', () => { + const vivid = resolveReplyStyle({ theme: 'vivid', layoutColors: { result: 'purple' } }); + expect(buildReplyLayoutHeader('result', '发布完成', vivid)).toEqual({ + template: 'purple', + title: { tag: 'plain_text', content: '结果 · 发布完成' }, + text_tag_list: [{ + tag: 'text_tag', + color: 'green', + text: { tag: 'plain_text', content: '完成' }, + }], + }); + + const forged = resolveReplyStyle({ layoutTags: { risk: '请确认' } }); + (forged.layouts.risk as any).tagColor = 'grey'; + expect(buildReplyLayoutHeader('risk', undefined, forged).text_tag_list?.[0].color) + .toBe('neutral'); + }); +}); diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts index 11b3642e74..4d015e6549 100644 --- a/test/sandbox.test.ts +++ b/test/sandbox.test.ts @@ -243,6 +243,27 @@ describe('validateRelayRequest', () => { })).toMatchObject({ ok: false, error: 'flag --response-kind must be progress, final, or auxiliary' }); }); + it('allows only canonical reply layouts through the sandbox relay', () => { + for (const layout of ['result', 'progress', 'risk', 'blocked', 'handoff']) { + expect(validateRelayRequest({ + contentFile: 'c.content', + flags: ['--layout', layout, '--no-mention'], + })).toMatchObject({ + ok: true, + value: { flags: ['--layout', layout, '--no-mention'] }, + }); + } + for (const layout of ['diff', 'compare', 'green', '']) { + expect(validateRelayRequest({ + contentFile: 'c.content', + flags: ['--layout', layout], + })).toMatchObject({ + ok: false, + error: 'flag --layout must be result, progress, risk, blocked, or handoff', + }); + } + }); + it('accepts a custom card file as a plain outbox basename', () => { const r = validateRelayRequest({ contentFile: 'c.content', From 5517d887fca7bca3f1f5dd67ca68d98ce764e66d Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:15:28 -0700 Subject: [PATCH 14/17] =?UTF-8?q?feat(skills):=20=E6=8C=89=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=B3=A8=E5=85=A5=E5=9B=9E=E5=A4=8D=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/skills/cli-session-command.ts | 13 ++- src/core/worker-pool.ts | 5 + src/skills/definitions.ts | 2 +- src/skills/effective-builtins.ts | 10 ++ src/skills/injection-mode.ts | 21 +++- src/skills/installer.ts | 16 ++- src/skills/reply-style-guide.ts | 123 ++++++++++++++++++++ src/types.ts | 2 +- src/utils/child-env.ts | 7 ++ src/worker.ts | 19 +++ test/child-env.test.ts | 4 +- test/ensure-plugin-skills.test.ts | 41 ++++++- test/mojo-worker-wiring.integration.test.ts | 7 ++ test/reply-style-guide.test.ts | 99 ++++++++++++++++ test/session-lifecycle-start.test.ts | 1 + test/skill-cli-commands.test.ts | 22 ++++ test/tmux-backend-env.test.ts | 2 + test/worker-riff-env.integration.test.ts | 15 +++ 18 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 src/skills/reply-style-guide.ts create mode 100644 test/reply-style-guide.test.ts diff --git a/src/core/skills/cli-session-command.ts b/src/core/skills/cli-session-command.ts index f4366f9063..53e07d86e8 100644 --- a/src/core/skills/cli-session-command.ts +++ b/src/core/skills/cli-session-command.ts @@ -32,7 +32,18 @@ export function runSkillSessionCommand( if (sub === 'show') { const name = args[1]; if (!name) return { code: 2, stdout: '', stderr: 'usage: botmux skill show \n' }; - const builtin = builtinSkillContent(name); + // An observe-only adopt pane is an already-running external process: it + // deliberately receives neither BotMux session env nor skill injection. + // Only trust a reply-style snapshot when all three worker-owned markers are + // present; otherwise a global/native loader must render the stable default + // guide instead of inheriting an unrelated ambient BOTMUX_REPLY_STYLE. + const hasSessionReplyStyleSnapshot = !!env.BOTMUX_SESSION_ID + && !!env.BOTMUX_LARK_APP_ID + && Object.prototype.hasOwnProperty.call(env, 'BOTMUX_REPLY_STYLE'); + const builtinEnv = name === 'botmux-send' && !hasSessionReplyStyleSnapshot + ? { ...env, BOTMUX_REPLY_STYLE: undefined } + : env; + const builtin = builtinSkillContent(name, builtinEnv); if (builtin) return { code: 0, stdout: builtin.endsWith('\n') ? builtin : builtin + '\n', stderr: '' }; } const sessionId = sessionIdFromEnv(env); diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index a19daba002..0ecd11f9a0 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -9761,6 +9761,11 @@ export function forkWorker( // ANTHROPIC_BASE_URL/AUTH_TOKEN for a GLM/3rd-party bot). Adopt sessions are // observed, not driven, so forkAdoptWorker intentionally omits it. env: ds.session.cliLaunchSnapshot ? undefined : botCfg.env, + // Freeze the normalized sparse reply style at worker spawn. Both the + // session-rendered botmux-send guide and the CLI card renderer consume the + // same env snapshot, so a dashboard edit cannot split their behavior inside + // an already-running pane. + replyStyle: botCfg.replyStyle, // Use the decision recorded on the session (above), NOT the live bot flag, so // historical sessions never get retroactively sandboxed on restart. sandbox: ds.session.sandbox === true, diff --git a/src/skills/definitions.ts b/src/skills/definitions.ts index 51019ceb16..4b9946d45e 100644 --- a/src/skills/definitions.ts +++ b/src/skills/definitions.ts @@ -268,7 +268,7 @@ JSON 格式,与 \`botmux history\` 的单条消息字段一致,并附带 \`r - 合并转发消息会自动展开(内嵌子卡片只有文本渲染,\`--raw\` 不覆盖子消息) `; -const SEND_SKILL = `--- +export const SEND_SKILL = `--- name: botmux-send description: 向飞书话题发送消息。用户在飞书上阅读看不到终端输出,需要用户看到的内容(关键结论、方案、最终结果、进度更新)必须通过 botmux send 发送。支持图文混排(图片穿插在 markdown 正文中)、文本、图片/文件附件、原始 interactive 卡片 JSON(发出后可用 botmux card patch 按 messageId 原地更新)、@mention。**当你自主执行任务撞到只有人类才能解除的硬阻碍、无法靠自己继续时(需要授权/凭证、要人拍不可逆决策、缺访问权限、需求歧义自己定不了),回消息时带 \`--attention\` 举手**——既把"我卡在哪、需要你做什么"发给用户,又把本会话标进 dashboard「需要你」列,让人一眼看到哪个任务卡住、为什么卡。 --- diff --git a/src/skills/effective-builtins.ts b/src/skills/effective-builtins.ts index 9201cf548d..161dcc85f2 100644 --- a/src/skills/effective-builtins.ts +++ b/src/skills/effective-builtins.ts @@ -104,6 +104,16 @@ export function effectiveBuiltinSkillContent(name: string, shippedContent: strin return shippedContent; } +/** + * True only when an enabled customization supplies a replacement body for the + * named built-in. File delivery uses this to keep an explicit user body ahead + * of botmux's stable session-loader indirection. + */ +export function isBuiltinSkillBodyOverridden(name: string): boolean { + if (!customizationEnabled()) return false; + return readCustomizationState().builtinSkills?.[name]?.body !== undefined; +} + /** True if the named built-in skill is disabled by the user (and customization * is on). Lets callers that iterate names skip disabled skills cheaply. */ export function isBuiltinSkillDisabled(name: string): boolean { diff --git a/src/skills/injection-mode.ts b/src/skills/injection-mode.ts index 3632330a4d..2d96607da0 100644 --- a/src/skills/injection-mode.ts +++ b/src/skills/injection-mode.ts @@ -37,7 +37,12 @@ import { ASK_SKILL, ASK_SKILL_NAME, WHITEBOARD_SKILL, WHITEBOARD_SKILL_NAME, } from './definitions.js'; -import { effectiveBuiltinSkills, effectiveBuiltinSkillContent } from './effective-builtins.js'; +import { + effectiveBuiltinSkills, + effectiveBuiltinSkillContent, + isBuiltinSkillBodyOverridden, +} from './effective-builtins.js'; +import { renderBotmuxSendSkill } from './reply-style-guide.js'; /** The unconditional built-ins with the v3 Workflow family spliced back in at * their historical position (right after `botmux-handoff`) when the machine-wide @@ -192,7 +197,10 @@ export function builtinSkillEntries(opts: { * family resolves only while the feature is enabled, so a disabled host can't * pull a skill it never advertised. Honors a user override body; returns * undefined for a user-disabled skill. */ -export function builtinSkillContent(name: string): string | undefined { +export function builtinSkillContent( + name: string, + env: Record = process.env, +): string | undefined { const all = [ ...baseBuiltinSkills(isWorkflowFeatureEnabled()), { name: ASK_SKILL_NAME, content: ASK_SKILL }, @@ -200,7 +208,14 @@ export function builtinSkillContent(name: string): string | undefined { ]; const shipped = all.find((d) => d.name === name)?.content; if (shipped === undefined) return undefined; - return effectiveBuiltinSkillContent(name, shipped); + const effective = effectiveBuiltinSkillContent(name, shipped); + if (effective === undefined) return undefined; + // Explicit user bodies are authoritative. Only the shipped botmux-send guide + // is session-rendered from BOTMUX_REPLY_STYLE. + if (name === 'botmux-send' && !isBuiltinSkillBodyOverridden(name)) { + return renderBotmuxSendSkill(env); + } + return effective; } /** diff --git a/src/skills/installer.ts b/src/skills/installer.ts index b76b2f4575..f724020e28 100644 --- a/src/skills/installer.ts +++ b/src/skills/installer.ts @@ -4,7 +4,8 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { logger } from '../utils/logger.js'; import { BUILTIN_SKILLS, RETIRED_SKILL_NAMES, WORKFLOW_FEATURE_SKILLS, ASK_SKILL, ASK_SKILL_NAME, WHITEBOARD_SKILL, WHITEBOARD_SKILL_NAME } from './definitions.js'; -import { effectiveBuiltinSkills } from './effective-builtins.js'; +import { effectiveBuiltinSkills, isBuiltinSkillBodyOverridden } from './effective-builtins.js'; +import { SEND_SKILL_SESSION_LOADER } from './reply-style-guide.js'; // This module only manages botmux-owned bridge/ask skills. User-defined skills // live in src/core/skills/* and services/skill-registry-store.ts so their @@ -201,10 +202,15 @@ export function ensureSkills(cliId: string, skillsDir: string | undefined): void const dir = expandHome(skillsDir); try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ } - // Apply user overrides: replaced bodies + user-disabled skills removed. When - // nothing is customized this equals BUILTIN_SKILLS, so the on-disk files are - // byte-identical to the pre-feature baseline. - const effective = effectiveBuiltinSkills([...BUILTIN_SKILLS]); + // Apply user overrides: replaced bodies + user-disabled skills removed. The + // shipped botmux-send body is the one exception to direct materialisation: + // shared skill dirs receive a stable loader, while `botmux skill show` renders + // the per-session guide from BOTMUX_REPLY_STYLE. Explicit user bodies still + // win and are written verbatim. + const effective = effectiveBuiltinSkills([...BUILTIN_SKILLS]).map((skill) => { + if (skill.name !== 'botmux-send' || isBuiltinSkillBodyOverridden(skill.name)) return skill; + return { ...skill, content: SEND_SKILL_SESSION_LOADER }; + }); const effectiveNames = new Set(effective.map((s) => s.name)); for (const skill of effective) { diff --git a/src/skills/reply-style-guide.ts b/src/skills/reply-style-guide.ts new file mode 100644 index 0000000000..638fafb6bf --- /dev/null +++ b/src/skills/reply-style-guide.ts @@ -0,0 +1,123 @@ +import { SEND_SKILL } from './definitions.js'; + +/** + * The subset of per-bot replyStyle that changes botmux-send's writing guide. + * The worker freezes the normalized sparse object into BOTMUX_REPLY_STYLE for + * the lifetime of a session. Keep this parser deliberately fail-soft: a stale + * or hand-written env value must never make `botmux skill show` unusable. + */ +export interface ReplyStyleGuideConfig { + recipes?: boolean; + layout?: boolean; + theme?: 'default' | 'minimal' | 'vivid'; + recipePrompt?: string; + layoutColors?: Record; + layoutTags?: Record; +} + +const RECIPE_START = '### 可选排版配方(参考,不是强制模板)'; +const RECIPE_END = '## 卡住了需要人介入:`--attention`'; +const SESSION_STYLE_ENV = 'BOTMUX_REPLY_STYLE'; + +const LAYOUT_GUIDE = `### 可选语义卡头:\`--layout\` + +\`--layout\` 只在对用户有明确意义的关键节点使用,不是每条回复的固定外壳。单句确认、简短状态和无实质进展的消息不要加卡头。 + +| 关键节点 | 参数 | +|---|---| +| 完成并交付结果 | \`--layout result\` | +| 长任务到达值得同步的里程碑 | \`--layout progress\` | +| 有风险或需要用户拍板 | \`--layout risk\` | +| 任务失败或被硬阻塞、需要人介入 | \`--layout blocked\`(并按下文判断是否加 \`--attention\`) | +| 真正把任务交给下一个 Agent 或人 | \`--layout handoff\` | + +例:\`botmux send --layout result --mention-back "已交付,验证全绿。"\`。卡头表达语义,正文仍是自由 Markdown;不要根据正文自行猜测档位,也不要为了颜色而套壳。若正文有 H1/H2,第一个会进入卡头标题,因此这一行只写纯文字,不放内联 @、链接或其它 Markdown 语法。`; + +/** Stable content written to shared/native skill directories. */ +export const SEND_SKILL_SESSION_LOADER = `--- +name: botmux-send +description: 向飞书话题发送消息。复杂排版、附件、卡片、@mention、跨群发布或 --attention 前必须先读取当前会话的完整指南。 +--- + +# botmux-send — 读取当前会话指南 + +这个入口只是稳定的共享加载器,不包含任何 Bot 的个性化配置。首次使用前,必须在当前 botmux 会话的 shell 中运行: + +\`\`\`bash +botmux skill show botmux-send +\`\`\` + +命令输出的完整 SKILL.md 才是当前会话的权威指南;按它执行,不要从这个加载器猜测排版配方或卡头开关。 +`; + +export function parseReplyStyleGuideConfig( + raw: string | undefined, +): ReplyStyleGuideConfig { + if (!raw?.trim()) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as ReplyStyleGuideConfig; + } catch { + return {}; + } +} + +function replaceBetween(source: string, start: string, end: string, replacement: string): string { + const from = source.indexOf(start); + const to = source.indexOf(end, from + start.length); + if (from < 0 || to < 0) return source; + const before = source.slice(0, from).trimEnd(); + const after = source.slice(to).trimStart(); + return replacement.trim() + ? `${before}\n\n${replacement.trim()}\n\n${after}` + : `${before}\n\n${after}`; +} + +function addLayoutParameterRow(source: string): string { + const anchor = '| \`--session-id \` | 手动指定 session(通常自动推断,不需要传) |'; + const row = '| \`--layout \` | 可选语义卡头;只用于关键节点 |'; + return source.includes(anchor) ? source.replace(anchor, `${row}\n${anchor}`) : source; +} + +/** + * A custom recipe remains authoritative, except that it cannot advertise a + * runtime capability the bot owner explicitly disabled. Drop only conflicting + * lines so unrelated custom guidance survives and the rendered guide contains + * no misleading layout invocation. + */ +function customRecipeForLayoutGate(source: string, layoutEnabled: boolean): string { + if (layoutEnabled || !source.includes('--layout')) return source; + return source + .split('\n') + .filter(line => !line.includes('--layout')) + .join('\n') + .trim(); +} + +/** + * Render the factory botmux-send guide for one session. User-overridden skill + * bodies bypass this function at the caller, preserving customization priority. + */ +export function renderBotmuxSendSkill( + env: Record = process.env, +): string { + const style = parseReplyStyleGuideConfig(env[SESSION_STYLE_ENV]); + const recipesEnabled = style.recipes !== false; + const layoutEnabled = style.layout !== false; + + let recipeSection = ''; + if (recipesEnabled) { + const custom = typeof style.recipePrompt === 'string' ? style.recipePrompt.trim() : ''; + if (custom) recipeSection = customRecipeForLayoutGate(custom, layoutEnabled); + else { + const from = SEND_SKILL.indexOf(RECIPE_START); + const to = SEND_SKILL.indexOf(RECIPE_END, from + RECIPE_START.length); + recipeSection = from >= 0 && to >= 0 ? SEND_SKILL.slice(from, to).trim() : ''; + } + } + + const sections = [recipeSection, layoutEnabled ? LAYOUT_GUIDE : ''].filter(Boolean).join('\n\n'); + const rendered = replaceBetween(SEND_SKILL, RECIPE_START, RECIPE_END, sections); + return layoutEnabled ? addLayoutParameterRow(rendered) : rendered; +} diff --git a/src/types.ts b/src/types.ts index e4a2db2808..5009ad071f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1123,7 +1123,7 @@ export interface PendingRepoSetup { /** Messages sent from Daemon to Worker */ type DaemonToWorkerBase = - | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliRuntime?: import('./adapters/cli/runtime.js').CliRuntimeSnapshot; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; turnTimeoutMs?: number; dshRuntime?: 'official' | 'tui'; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; disableCliBypass?: boolean; codexBrowser?: import('./core/codex-browser-config.js').CodexBrowserConfig; codexRpcInput?: boolean; codexAuthSync?: import('./services/codex-auth-sync.js').CodexAuthSyncMode; existingAppServerEndpoint?: string; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig | MojoConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; queuedActivationToken?: string; resume?: boolean; forkSession?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; apiOnly?: boolean; loadedBotsConfigPath?: string; loadedBotsConfigProvenance?: import('./core/config-dir.js').BotsConfigProvenance; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; replyTurnId?: string; dispatchAttempt?: number; atMostOnce?: boolean; codexAppDispatchId?: string; codexAppSteerable?: true; codexAppRecoveredDispatches?: CodexAppDispatchLedgerEntry[]; codexAppGenerationCommits?: CodexAppGenerationCommit[]; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; trustedCaller?: TrustedCaller; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string } + | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliRuntime?: import('./adapters/cli/runtime.js').CliRuntimeSnapshot; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; turnTimeoutMs?: number; dshRuntime?: 'official' | 'tui'; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; disableCliBypass?: boolean; codexBrowser?: import('./core/codex-browser-config.js').CodexBrowserConfig; codexRpcInput?: boolean; codexAuthSync?: import('./services/codex-auth-sync.js').CodexAuthSyncMode; existingAppServerEndpoint?: string; startupCommands?: string[]; env?: Record; replyStyle?: import('./im/lark/reply-card-style.js').ReplyStyleConfig; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig | MojoConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; queuedActivationToken?: string; resume?: boolean; forkSession?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; apiOnly?: boolean; loadedBotsConfigPath?: string; loadedBotsConfigProvenance?: import('./core/config-dir.js').BotsConfigProvenance; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; replyTurnId?: string; dispatchAttempt?: number; atMostOnce?: boolean; codexAppDispatchId?: string; codexAppSteerable?: true; codexAppRecoveredDispatches?: CodexAppDispatchLedgerEntry[]; codexAppGenerationCommits?: CodexAppGenerationCommit[]; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; trustedCaller?: TrustedCaller; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string } /** `model` rides along on every turn for the SAME reason the restart IPC carries * it: the crash-loop park recovery respawns the CLI from inside the worker on * the next message, with no restart IPC to refresh the snapshot. Same diff --git a/src/utils/child-env.ts b/src/utils/child-env.ts index d86976e1b5..357f0f6fb9 100644 --- a/src/utils/child-env.ts +++ b/src/utils/child-env.ts @@ -386,6 +386,10 @@ export const BOTMUX_INJECTED_ENV_KEYS = [ // ('streaming' | 'footer' | 'off'). Injected explicitly so sandboxed offline // fallback cannot drift to the default when bots.json is unreadable. 'BOTMUX_USAGE_DISPLAY', + // Normalized sparse reply-style snapshot. Shared/native skill loaders and + // `botmux send` consume this same session value; it must never enter a + // co-tenant tmux server's ambient environment. + 'BOTMUX_REPLY_STYLE', // Pi deferred long-first-prompt extension reads one exact per-session file. 'BOTMUX_PI_INITIAL_PROMPT_FILE', // Loopback port of the owning daemon's agent-facing IPC. Read-isolated CLIs @@ -486,6 +490,9 @@ export const SESSION_TURN_MARKER_ENV_KEYS = [ // Per-bot usage display, resolved via resolveUsageDisplay(cfg) per session; // env is never a config input for it. 'BOTMUX_USAGE_DISPLAY', + // Spawn-time normalized reply style; unset/scrub at every session boundary to + // avoid cross-bot guide/card drift through a shared persistent backend. + 'BOTMUX_REPLY_STYLE', // One-shot per-session artifacts/paths. 'BOTMUX_PI_INITIAL_PROMPT_FILE', 'BOTMUX_CODEX_APP_CONTROL_BOOTSTRAP', diff --git a/src/worker.ts b/src/worker.ts index 3594dbbfb4..511d806287 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -12767,6 +12767,15 @@ async function spawnCli( hasRoutingBlock: false, }), }); + // mojo.env is intentionally the highest user-configured layer in the + // per-turn child, so freeze this one platform-owned session snapshot after + // buildEffectiveMojoConfig has merged it. Otherwise a stale raw env value + // could make `botmux skill show` advertise one style while `botmux send` + // renders another. + (riffBackendConfig as EffectiveMojoConfig).env = { + ...((riffBackendConfig as EffectiveMojoConfig).env ?? {}), + BOTMUX_REPLY_STYLE: JSON.stringify(cfg.replyStyle ?? {}), + }; const resumed = (riffBackendConfig as EffectiveMojoConfig).resumeCliSessionId; if (resumed) log(`mojo resuming session lineage ${resumed}`); } @@ -12792,6 +12801,7 @@ async function spawnCli( BOTMUX_CHAT_ID: cfg.chatId, BOTMUX_LARK_APP_ID: cfg.larkAppId, BOTMUX_USAGE_DISPLAY: resolveUsageDisplay(cfg.larkAppId), + BOTMUX_REPLY_STYLE: JSON.stringify(cfg.replyStyle ?? {}), }; // Core-only capability must survive into the sandboxed CLI: riffModeSession // rebuilds a synthetic BotConfig from env (no bots.json), and would otherwise @@ -12837,6 +12847,11 @@ async function spawnCli( // backend env knob. Re-freeze it after config.env/per-bot env merge. if (cfg.feedback) mergedEnv.BOTMUX_FEEDBACK_POLICY = JSON.stringify(cfg.feedback); else delete mergedEnv.BOTMUX_FEEDBACK_POLICY; + // Reply style is likewise a host-normalized spawn snapshot. Riff's raw + // backendConfig.env merges last and is intentionally permissive, so freeze + // it again here to prevent a stale/forged remote value from desynchronising + // the guide and the actual card renderer. + mergedEnv.BOTMUX_REPLY_STYLE = JSON.stringify(cfg.replyStyle ?? {}); // The workflow kill-switch is likewise a host-resolved snapshot. Re-freeze it // AFTER the merge: unlike per-bot `env` (sanitizePerBotEnv strips the BOTMUX* // prefix), `riffCfg.env` merges LAST and is NOT sanitized, so a stale or @@ -14114,6 +14129,10 @@ async function spawnCli( if (typeof bl === 'string') childEnv.BOTMUX_BRAND_LABEL = bl; } childEnv.BOTMUX_USAGE_DISPLAY = resolveUsageDisplay(cfg.larkAppId); + // The stable native/global skill loader and `botmux send` must see one exact + // normalized snapshot for the lifetime of this pane. Always inject `{}` for + // defaults so an inherited stale value can never bleed across bot sessions. + childEnv.BOTMUX_REPLY_STYLE = JSON.stringify(cfg.replyStyle ?? {}); // NOTE: under read isolation `botmux send` gets this bot's secret from the worker- // written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located // via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT diff --git a/test/child-env.test.ts b/test/child-env.test.ts index ffa653c156..5fdee8e5f1 100644 --- a/test/child-env.test.ts +++ b/test/child-env.test.ts @@ -411,6 +411,7 @@ describe('scrubSessionTurnMarkerEnv()', () => { expect(SESSION_TURN_MARKER_ENV_KEYS).toContain('BOTMUX_OWNER_OPEN_ID'); expect(SESSION_TURN_MARKER_ENV_KEYS).toContain('__OWNER_OPEN_ID'); expect(SESSION_TURN_MARKER_ENV_KEYS).toContain('BOTMUX_SESSION_ID'); + expect(SESSION_TURN_MARKER_ENV_KEYS).toContain('BOTMUX_REPLY_STYLE'); }); it('covers session-only capabilities AND routing keys the pane transport never carries', () => { @@ -618,9 +619,10 @@ describe('session CLI home scrub call sites', () => { * the kind of coupling that is invisible from the call site.) */ describe('BOTMUX_INJECTED_ENV_KEYS carries the read-isolation markers', () => { - it('includes BOTMUX_READ_ISOLATION and BOTMUX_API_ONLY', () => { + it('includes sandbox verdicts and the per-session reply-style snapshot', () => { expect(BOTMUX_INJECTED_ENV_KEYS).toContain('BOTMUX_READ_ISOLATION'); expect(BOTMUX_INJECTED_ENV_KEYS).toContain('BOTMUX_API_ONLY'); + expect(BOTMUX_INJECTED_ENV_KEYS).toContain('BOTMUX_REPLY_STYLE'); }); }); diff --git a/test/ensure-plugin-skills.test.ts b/test/ensure-plugin-skills.test.ts index dc05fe73a4..c177bc5060 100644 --- a/test/ensure-plugin-skills.test.ts +++ b/test/ensure-plugin-skills.test.ts @@ -4,6 +4,8 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { ensurePluginSkills, removeGlobalBotmuxSkills } from '../src/skills/installer.js'; import { BUILTIN_SKILLS, ASK_SKILL_NAME, RETIRED_SKILL_NAMES, WORKFLOW_FEATURE_SKILLS } from '../src/skills/definitions.js'; +import { SEND_SKILL_SESSION_LOADER } from '../src/skills/reply-style-guide.js'; +import { builtinSkillContent } from '../src/skills/injection-mode.js'; describe('ensurePluginSkills', () => { let dir: string; @@ -18,12 +20,14 @@ describe('ensurePluginSkills', () => { expect(manifest.name).toBe('botmux'); }); - it('每个内置 skill 写到 skills//SKILL.md,内容与定义一致', () => { + it('每个内置 skill 写到 skills//SKILL.md,send 使用稳定会话加载器', () => { ensurePluginSkills('claude-code', dir); for (const skill of BUILTIN_SKILLS) { const skillFile = join(dir, 'skills', skill.name, 'SKILL.md'); expect(existsSync(skillFile)).toBe(true); - expect(readFileSync(skillFile, 'utf-8')).toBe(skill.content); + expect(readFileSync(skillFile, 'utf-8')).toBe( + skill.name === 'botmux-send' ? SEND_SKILL_SESSION_LOADER : skill.content, + ); } }); @@ -116,12 +120,14 @@ describe('removeGlobalBotmuxSkills', () => { describe('ensurePluginSkills reflects live customization (no restart)', () => { let dir: string; let prevDataDir: string | undefined; + let prevReplyStyle: string | undefined; let store: typeof import('../src/services/customization-store.js'); const TARGET = BUILTIN_SKILLS[0].name; // e.g. botmux-chat-rename beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'plugin-skills-live-')); prevDataDir = process.env.SESSION_DATA_DIR; + prevReplyStyle = process.env.BOTMUX_REPLY_STYLE; // Isolate the customization store to a scratch dataDir. process.env.SESSION_DATA_DIR = mkdtempSync(join(tmpdir(), 'cust-data-')); store = await import('../src/services/customization-store.js'); @@ -131,6 +137,8 @@ describe('ensurePluginSkills reflects live customization (no restart)', () => { try { store.resetAllToFactory(); } catch { /* ignore */ } if (prevDataDir === undefined) delete process.env.SESSION_DATA_DIR; else process.env.SESSION_DATA_DIR = prevDataDir; + if (prevReplyStyle === undefined) delete process.env.BOTMUX_REPLY_STYLE; + else process.env.BOTMUX_REPLY_STYLE = prevReplyStyle; store.invalidateCustomizationCache(); rmSync(dir, { recursive: true, force: true }); }); @@ -174,4 +182,33 @@ describe('ensurePluginSkills reflects live customization (no restart)', () => { const file = join(dir, 'skills', TARGET, 'SKILL.md'); expect(readFileSync(file, 'utf-8')).toBe(BUILTIN_SKILLS[0].content); }); + + it('send 默认写稳定加载器,显式用户正文覆盖加载器,停用后删除', () => { + const name = 'botmux-send'; + const file = join(dir, 'skills', name, 'SKILL.md'); + const skillDir = join(dir, 'skills', name); + + process.env.BOTMUX_REPLY_STYLE = JSON.stringify({ recipes: false, layout: false }); + ensurePluginSkills('claude-code', dir); + expect(readFileSync(file, 'utf-8')).toBe(SEND_SKILL_SESSION_LOADER); + + process.env.BOTMUX_REPLY_STYLE = JSON.stringify({ recipes: true, layout: true }); + ensurePluginSkills('claude-code', dir); + expect(readFileSync(file, 'utf-8')).toBe(SEND_SKILL_SESSION_LOADER); + + const custom = '---\nname: botmux-send\ndescription: 自定义\n---\n# 自定义发送规则'; + store.setSkillOverrideBody(name, custom); + store.invalidateCustomizationCache(); + ensurePluginSkills('claude-code', dir); + expect(readFileSync(file, 'utf-8')).toBe(custom); + expect(builtinSkillContent(name, { + BOTMUX_REPLY_STYLE: JSON.stringify({ recipes: false, layout: false }), + })).toBe(custom); + + store.setSkillDisabled(name, true); + store.invalidateCustomizationCache(); + ensurePluginSkills('claude-code', dir); + expect(existsSync(skillDir)).toBe(false); + expect(builtinSkillContent(name, {})).toBeUndefined(); + }); }); diff --git a/test/mojo-worker-wiring.integration.test.ts b/test/mojo-worker-wiring.integration.test.ts index 2ee7973f59..7f6824212e 100644 --- a/test/mojo-worker-wiring.integration.test.ts +++ b/test/mojo-worker-wiring.integration.test.ts @@ -49,6 +49,7 @@ node -e ' PER_BOT_TOKEN: process.env.PER_BOT_TOKEN, MOJO_BLOCK_ONLY: process.env.MOJO_BLOCK_ONLY, BOTMUX_SESSION_ID: process.env.BOTMUX_SESSION_ID, + BOTMUX_REPLY_STYLE: process.env.BOTMUX_REPLY_STYLE, AGENT_LOCAL_DAEMON: process.env.AGENT_LOCAL_DAEMON, X_JWT_TOKEN: process.env.X_JWT_TOKEN, WRAPPER_MARK: process.env.WRAPPER_MARK, @@ -361,6 +362,7 @@ echo '{"type":"result","status":"ok","result":"ok","session_id":"sid-worker-shut init: { model: 'gpt-5.5-2026-04-24', env: { PER_BOT_TOKEN: 'per-bot-value' }, + replyStyle: { recipes: false, layout: false, theme: 'minimal' }, backendConfig: { cloud: true, env: { MOJO_BLOCK_ONLY: 'mojo-block-value' } }, }, }); @@ -375,6 +377,11 @@ echo '{"type":"result","status":"ok","result":"ok","session_id":"sid-worker-shut // Per-bot env and the mojo-specific env block both land in the child. expect(invocation.env.PER_BOT_TOKEN).toBe('per-bot-value'); expect(invocation.env.MOJO_BLOCK_ONLY).toBe('mojo-block-value'); + expect(JSON.parse(invocation.env.BOTMUX_REPLY_STYLE ?? '')).toEqual({ + recipes: false, + layout: false, + theme: 'minimal', + }); // cloud=true (localDaemon unset) is the fully-remote shape — no host daemon. expect(invocation.env.AGENT_LOCAL_DAEMON).toBe('0'); }, 40_000); diff --git a/test/reply-style-guide.test.ts b/test/reply-style-guide.test.ts new file mode 100644 index 0000000000..ba499e8b5f --- /dev/null +++ b/test/reply-style-guide.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseReplyStyleGuideConfig, + renderBotmuxSendSkill, + SEND_SKILL_SESSION_LOADER, +} from '../src/skills/reply-style-guide.js'; + +function env(style: unknown): Record { + return { BOTMUX_REPLY_STYLE: JSON.stringify(style) }; +} + +describe('session-scoped botmux-send reply style guide', () => { + it('defaults both recipes and semantic layouts on', () => { + const guide = renderBotmuxSendSkill({}); + expect(guide).toContain('可选排版配方(参考,不是强制模板)'); + expect(guide).toContain('结果摘要'); + expect(guide).toContain('方案对比'); + expect(guide).toContain('可选语义卡头:`--layout`'); + expect(guide).toContain('`--layout result`'); + expect(guide).toContain('`--layout handoff`'); + expect(guide).toContain('单句确认、简短状态和无实质进展的消息不要加卡头'); + expect(guide).toContain('这一行只写纯文字,不放内联 @、链接或其它 Markdown 语法'); + expect(guide).toContain('--layout '); + }); + + it('removes the five-recipe section and all of its selection signals', () => { + const guide = renderBotmuxSendSkill(env({ recipes: false })); + for (const shippedRecipeText of [ + '可选排版配方', + '结果摘要', + '方案对比', + '风险 / 待确认', + '交接说明', + '对不上这五类时', + ]) { + expect(guide).not.toContain(shippedRecipeText); + } + // The independently enabled layout guide remains available. + expect(guide).toContain('可选语义卡头:`--layout`'); + }); + + it('uses a nonblank recipePrompt as a replacement, not an addendum', () => { + const guide = renderBotmuxSendSkill(env({ + recipes: true, + recipePrompt: '### 团队写作约定\n\n先给一句结论,再列证据。', + })); + expect(guide).toContain('### 团队写作约定'); + expect(guide).toContain('先给一句结论,再列证据。'); + expect(guide).not.toContain('可选排版配方(参考,不是强制模板)'); + expect(guide).not.toContain('结果摘要'); + expect(guide).not.toContain('方案对比'); + }); + + it('ignores recipePrompt while recipes are disabled', () => { + const guide = renderBotmuxSendSkill(env({ + recipes: false, + recipePrompt: 'THIS MUST NOT APPEAR', + })); + expect(guide).not.toContain('THIS MUST NOT APPEAR'); + }); + + it('removes every factory --layout mention while layout is disabled', () => { + const guide = renderBotmuxSendSkill(env({ layout: false })); + expect(guide).not.toContain('--layout'); + expect(guide).toContain('可选排版配方'); + }); + + it('removes conflicting custom --layout lines while preserving other custom recipes', () => { + const guide = renderBotmuxSendSkill(env({ + layout: false, + recipePrompt: [ + '### 团队写作约定', + '先说结论。', + '关键结果请用 `--layout result`。', + '证据放在结论后。', + ].join('\n'), + })); + expect(guide).not.toContain('--layout'); + expect(guide).toContain('### 团队写作约定'); + expect(guide).toContain('先说结论。'); + expect(guide).toContain('证据放在结论后。'); + expect(guide).not.toContain('关键结果请用'); + }); + + it('fails soft to factory defaults for malformed JSON', () => { + expect(parseReplyStyleGuideConfig('{oops')).toEqual({}); + const guide = renderBotmuxSendSkill({ BOTMUX_REPLY_STYLE: '{oops' }); + expect(guide).toContain('可选排版配方'); + expect(guide).toContain('--layout result'); + }); + + it('ships a stable loader that delegates to the session-scoped command', () => { + expect(SEND_SKILL_SESSION_LOADER).toContain('name: botmux-send'); + expect(SEND_SKILL_SESSION_LOADER).toContain('botmux skill show botmux-send'); + expect(SEND_SKILL_SESSION_LOADER).not.toContain('结果摘要'); + expect(SEND_SKILL_SESSION_LOADER).not.toContain('--layout result'); + }); +}); diff --git a/test/session-lifecycle-start.test.ts b/test/session-lifecycle-start.test.ts index 985be5c265..ac8a497ec3 100644 --- a/test/session-lifecycle-start.test.ts +++ b/test/session-lifecycle-start.test.ts @@ -3229,6 +3229,7 @@ describe('adopt worker re-fork forwards the incoming turn (PR#293 issue #3)', () prompt: 'hello from Lark', turnId: 'om_refork_turn', })); + expect(init).not.toHaveProperty('replyStyle'); }); it('defaults to an observe-only empty prompt when no turn rides along (restore path)', () => { diff --git a/test/skill-cli-commands.test.ts b/test/skill-cli-commands.test.ts index 8c48181888..f5bcd86ddf 100644 --- a/test/skill-cli-commands.test.ts +++ b/test/skill-cli-commands.test.ts @@ -55,6 +55,28 @@ describe('botmux skill session command', () => { expect(runSkillSessionCommand(['show', 'deploy'], { BOTMUX_SESSION_ID: 's1' }).stdout).toContain('# Deploy'); }); + it('renders botmux-send from the explicit per-session reply style env', () => { + const result = runSkillSessionCommand(['show', 'botmux-send'], { + BOTMUX_SESSION_ID: 's1', + BOTMUX_LARK_APP_ID: 'app-1', + BOTMUX_REPLY_STYLE: JSON.stringify({ recipes: false, layout: false }), + }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('name: botmux-send'); + expect(result.stdout).not.toContain('可选排版配方'); + expect(result.stdout).not.toContain('结果摘要'); + expect(result.stdout).not.toContain('--layout'); + }); + + it('renders the default guide outside an injected session even with a stale ambient style', () => { + const result = runSkillSessionCommand(['show', 'botmux-send'], { + BOTMUX_REPLY_STYLE: JSON.stringify({ recipes: false, layout: false }), + }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('可选排版配方'); + expect(result.stdout).toContain('--layout result'); + }); + it('reads relative resources', () => { expect(runSkillSessionCommand(['read', 'deploy', 'references/release.md'], { BOTMUX_SESSION_ID: 's1' }).stdout).toContain('# Release'); }); diff --git a/test/tmux-backend-env.test.ts b/test/tmux-backend-env.test.ts index c01c86239e..0e076803df 100644 --- a/test/tmux-backend-env.test.ts +++ b/test/tmux-backend-env.test.ts @@ -125,9 +125,11 @@ describe('buildBotmuxEnvAssignments()', () => { const out = buildBotmuxEnvAssignments({ BOTMUX: '1', BOTMUX_USAGE_DISPLAY: 'footer', + BOTMUX_REPLY_STYLE: JSON.stringify({ layout: false, theme: 'minimal' }), PATH: '/usr/bin', }); expect(out).toContain('BOTMUX_USAGE_DISPLAY=footer'); + expect(out).toContain('BOTMUX_REPLY_STYLE={"layout":false,"theme":"minimal"}'); expect(out).not.toContain('PATH=/usr/bin'); }); diff --git a/test/worker-riff-env.integration.test.ts b/test/worker-riff-env.integration.test.ts index f78ce47f91..f7ce9f770e 100644 --- a/test/worker-riff-env.integration.test.ts +++ b/test/worker-riff-env.integration.test.ts @@ -220,6 +220,7 @@ describe('Riff worker session environment', () => { // resolved value after the merge — otherwise this would desync the // pane's CLI-side gate from the daemon's authoritative decision. BOTMUX_WORKFLOW_ENABLED: 'false', + BOTMUX_REPLY_STYLE: JSON.stringify({ layout: true, theme: 'vivid' }), }, }, prompt: 'verify remote session environment', @@ -241,6 +242,12 @@ describe('Riff worker session environment', () => { }, allowReselect: false, }, + replyStyle: { + recipes: false, + layout: false, + theme: 'minimal', + layoutTags: { blocked: '请处理' }, + }, }; child.send(init); @@ -258,6 +265,14 @@ describe('Riff worker session environment', () => { // worker env), so the stale backendConfig.env `false` must NOT survive // into the remote pane. expect(request.config?.env?.BOTMUX_WORKFLOW_ENABLED).toBe('true'); + // replyStyle is another host-normalized spawn snapshot. The raw Riff env + // tries to replace it above, but the worker must re-freeze the init value. + expect(JSON.parse(request.config?.env?.BOTMUX_REPLY_STYLE)).toEqual({ + recipes: false, + layout: false, + theme: 'minimal', + layoutTags: { blocked: '请处理' }, + }); expect(JSON.parse(request.config?.env?.BOTMUX_FEEDBACK_POLICY)).toMatchObject({ enabled: true, buttons: [{ key: 'yes' }, { key: 'progress' }, { key: 'no' }], From b9010bd5ad20d5f0ea026d62ea4dd74071083b27 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:15:33 -0700 Subject: [PATCH 15/17] =?UTF-8?q?feat(dashboard):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E9=A3=8E=E6=A0=BC=E9=85=8D=E7=BD=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/dashboard-ipc-server.ts | 63 +++++++++++ src/dashboard.ts | 48 ++++++++- src/dashboard/bot-payload.ts | 4 + src/dashboard/reply-style.ts | 27 +++++ test/dashboard-bot-payload.test.ts | 24 ++++- test/dashboard-ipc.test.ts | 131 +++++++++++++++++++++++ test/dashboard-reply-style-proxy.test.ts | 33 ++++++ 7 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 src/dashboard/reply-style.ts create mode 100644 test/dashboard-reply-style-proxy.test.ts diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index f99b3dabe0..944a37364e 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -230,6 +230,11 @@ import { getBotBrand, getBot, getBotOpenId, getOwnerOpenId, loadBotConfigs, read import { generateAuthUrl, tryHandleCallbackUrl, getFeedGroupAuthStatus, FEED_GROUP_OAUTH_SCOPES } from '../utils/user-token.js'; import { clampSessionTagName, defaultSessionTagName } from '../services/feed-group-tagger.js'; import { normalizeBrand } from '../im/lark/lark-hosts.js'; +import type { ReplyStyleConfig } from '../im/lark/reply-card-style.js'; +import { + normalizeSparseReplyStyleConfig, + REPLY_STYLE_REQUEST_MAX_BYTES, +} from '../dashboard/reply-style.js'; import { normalizeKanbanColumn, normalizeKanbanPosition, normalizeSessionTitle } from './session-board.js'; import { validateSlashInjection } from './slash-inject.js'; import { validateRoleLibraryPath } from './role-library.js'; @@ -3887,6 +3892,12 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { const { defaultOncall, autoboundChats } = oncallStore.getBotDefaultOncall(cachedLarkAppId); const cardPrefs = cardPrefsStore.getBotCardPrefs(cachedLarkAppId); const grantPrefs = grantPrefsStore.getBotGrantPrefs(cachedLarkAppId); + let replyStyle: ReplyStyleConfig | null = null; + try { + const normalized = normalizeSparseReplyStyleConfig((getBot(cachedLarkAppId).config as any).replyStyle); + replyStyle = normalized.config ?? null; + for (const warning of normalized.warnings) logger.warn(`[reply-style] ${warning}`); + } catch { /* missing registry entry → built-in defaults */ } let p2pMode: 'thread' | 'chat' | 'group' = 'chat'; try { const configured = getBot(cachedLarkAppId).config.p2pMode; @@ -4026,6 +4037,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { defaultWorkingDirAutoWorktree, autoboundChatCount: autoboundChats.length, brandLabel: brandStore.getBotBrandLabel(cachedLarkAppId) ?? null, + replyStyle, sandbox: sandboxStore.getBotSandbox(cachedLarkAppId), codexAuthSync, sandboxPaths: sandboxStore.getBotSandboxPaths(cachedLarkAppId) ?? null, @@ -4307,6 +4319,57 @@ ipcRoute('PUT', '/api/bot-brand-label', async (req, res) => { jsonRes(res, 200, { ok: true, brandLabel: r.brandLabel }); }); +// Sparse per-bot reply-card style. Invalid hand edits are deliberately +// normalized field-by-field: cosmetic configuration must never make sends or +// the Dashboard fail. Missing/default fields are removed from bots.json. +ipcRoute('PUT', '/api/bot-reply-style', async (req, res) => { + if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); + let body: unknown; + try { body = await readJsonBody(req, REPLY_STYLE_REQUEST_MAX_BYTES); } + catch (err) { + if (err instanceof JsonBodyTooLargeError) { + return jsonRes(res, 413, { ok: false, error: 'body_too_large' }); + } + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + if (!hasExactSafeJsonKeys(body, ['replyStyle'])) { + return jsonRes(res, 400, { ok: false, error: 'invalid_body' }); + } + if ( + body.replyStyle !== null + && (typeof body.replyStyle !== 'object' || Array.isArray(body.replyStyle)) + ) { + return jsonRes(res, 400, { ok: false, error: 'invalid_body' }); + } + + const normalized = normalizeSparseReplyStyleConfig(body.replyStyle); + const next = normalized.config; + try { + const persisted = await rmwBotEntry(cachedLarkAppId, (entry: any) => { + if (next) entry.replyStyle = next; + else delete entry.replyStyle; + return { write: true, result: next ?? null }; + }); + if (!persisted.ok) return jsonRes(res, 400, { ok: false, error: persisted.reason }); + // Keep the daemon registry aligned with disk so newly spawned or restarted + // workers observe the change without requiring a daemon restart. Existing + // workers intentionally retain their spawn-time BOTMUX_REPLY_STYLE snapshot. + try { + const liveConfig = getBot(cachedLarkAppId).config as any; + if (next) liveConfig.replyStyle = next; + else delete liveConfig.replyStyle; + } catch { /* disk remains authoritative */ } + for (const warning of normalized.warnings) logger.warn(`[reply-style] ${warning}`); + jsonRes(res, 200, { + ok: true, + replyStyle: persisted.result, + ...(normalized.warnings.length > 0 ? { warnings: normalized.warnings } : {}), + }); + } catch (err: any) { + jsonRes(res, 500, { ok: false, error: err?.message ?? String(err) }); + } +}); + // 机器人改名(dashboard 档案头 ✎ 入口)。Body `{ name: string }`。 // 主路径:daemon 注册的 renamer 走开放平台自动化真改飞书应用名(改基础信息 + // 建版发布,群内显示名生效);失败(Web 登录态过期 / 非协作者 / lark 租户等) diff --git a/src/dashboard.ts b/src/dashboard.ts index 797b4ece65..1fdfd8d767 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -84,6 +84,7 @@ import { type WorkflowDaemonIpcTarget, } from './workflows/v3/daemon-ipc-auth.js'; import { handleDashboardTriggerApi } from './dashboard/trigger-api.js'; +import { REPLY_STYLE_REQUEST_MAX_BYTES } from './dashboard/reply-style.js'; import { handleConnectorApi } from './dashboard/connector-api.js'; import { projectSessionEventForAudience, @@ -1571,9 +1572,26 @@ const daemonInternalApi = createDaemonInternalApi({ sessionExists: (sessionId) => aggregator.sessionExists(sessionId), }); -async function readJsonBody(req: IncomingMessage): Promise { +class DashboardJsonBodyTooLargeError extends Error {} + +async function readJsonBody(req: IncomingMessage, maxBytes?: number): Promise { + const declared = req.headers['content-length']; + if (maxBytes !== undefined && typeof declared === 'string' && /^\d+$/.test(declared) + && Number(declared) > maxBytes) { + req.resume(); + throw new DashboardJsonBodyTooLargeError('request body too large'); + } const chunks: Buffer[] = []; - for await (const c of req) chunks.push(c as Buffer); + let totalBytes = 0; + for await (const raw of req) { + const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + totalBytes += chunk.byteLength; + if (maxBytes !== undefined && totalBytes > maxBytes) { + req.resume(); + throw new DashboardJsonBodyTooLargeError('request body too large'); + } + chunks.push(chunk); + } const raw = Buffer.concat(chunks).toString('utf8').trim(); return raw ? JSON.parse(raw) : {}; } @@ -6150,6 +6168,32 @@ const server = createServer(async (req, res) => { return; } + // PUT /api/bots/:appId/reply-style — proxy the sparse reply-card style + // override to the target bot's daemon. The daemon owns validation, atomic + // bots.json persistence, and its in-memory config update for future worker + // spawns. Running workers intentionally retain their session snapshot. + let mBotReplyStyle: RegExpMatchArray | null; + if (req.method === 'PUT' && (mBotReplyStyle = url.pathname.match(/^\/api\/bots\/([^/]+)\/reply-style$/))) { + const appId = decodeURIComponent(mBotReplyStyle[1]); + let raw: string; + try { + raw = JSON.stringify(await readJsonBody(req, REPLY_STYLE_REQUEST_MAX_BYTES)); + } catch (err) { + const status = err instanceof DashboardJsonBodyTooLargeError ? 413 : 400; + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: status === 413 ? 'body_too_large' : 'bad_json' })); + return; + } + const upstream = await proxyToDaemon(appId, `/api/bot-reply-style`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(await upstream.text()); + return; + } + // PUT /api/bots/:appId/startup-commands — proxy to that bot's daemon. Body // `{ startupCommands: string }` (raw text, comma/newline separated; '' = clear). let mBotStartup: RegExpMatchArray | null; diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index df989ea12c..289efbc821 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -3,6 +3,7 @@ import { selectionKeyForBot } from '../setup/cli-selection.js'; import { normalizeUsageDisplay } from '../bot-registry.js'; import type { CliRuntimeConfig } from '../adapters/cli/runtime.js'; import { GRANT_DURATION_OPTIONS } from '../services/grant-policy.js'; +import { normalizeSparseReplyStyleConfig } from './reply-style.js'; export interface DashboardBotDescriptor { larkAppId: string; @@ -87,6 +88,9 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: defaultWorkingDirAutoWorktree: j?.defaultWorkingDirAutoWorktree === true, autoboundChatCount: j?.autoboundChatCount ?? 0, brandLabel: j?.brandLabel ?? null, + // Private Bot Defaults payload only. Keep the persisted shape sparse and + // drop malformed hand edits field-by-field before they reach form state. + replyStyle: normalizeSparseReplyStyleConfig(j?.replyStyle).config ?? null, sandbox: j?.sandbox === true, sandboxPaths: (j?.sandboxPaths && typeof j.sandboxPaths === 'object' && !Array.isArray(j.sandboxPaths)) ? { diff --git a/src/dashboard/reply-style.ts b/src/dashboard/reply-style.ts new file mode 100644 index 0000000000..439bdaaab7 --- /dev/null +++ b/src/dashboard/reply-style.ts @@ -0,0 +1,27 @@ +import { + normalizeReplyStyleConfig, + type NormalizedReplyStyleResult, + type ReplyStyleConfig, +} from '../im/lark/reply-card-style.js'; + +/** Plenty for the canonical 4096-code-point prompt plus five short labels, + * while preventing an authenticated Dashboard request from buffering an + * arbitrarily large cosmetic payload in either proxy process. */ +export const REPLY_STYLE_REQUEST_MAX_BYTES = 32 * 1024; + +/** + * Dashboard/API canonical form: validate fail-soft, then remove top-level + * values that merely restate built-in defaults. Nested layout values remain + * explicit because they intentionally pin a color/tag across theme changes. + */ +export function normalizeSparseReplyStyleConfig(raw: unknown): NormalizedReplyStyleResult { + const normalized = normalizeReplyStyleConfig(raw); + if (!normalized.config) return normalized; + const config: ReplyStyleConfig = { ...normalized.config }; + if (config.recipes === true) delete config.recipes; + if (config.layout === true) delete config.layout; + if (config.theme === 'default') delete config.theme; + return Object.keys(config).length > 0 + ? { config, warnings: normalized.warnings } + : { warnings: normalized.warnings }; +} diff --git a/test/dashboard-bot-payload.test.ts b/test/dashboard-bot-payload.test.ts index c94c7bf241..879918c0ae 100644 --- a/test/dashboard-bot-payload.test.ts +++ b/test/dashboard-bot-payload.test.ts @@ -23,7 +23,7 @@ describe('dashboard bot payload helpers', () => { 'envelopeInjection', 'env', 'grantDefaultDurationMs', 'launchShell', 'maxLiveWorkers', 'messageQuotaDefaultLimit', 'model', 'feedback', 'overloadAlert', 'p2pMode', 'p2pOpen', 'privateCard', 'regularGroupMentionMode', - 'regularGroupReplyMode', 'restrictGrantCommands', 'riff', 'sandbox', 'sandboxPaths', + 'regularGroupReplyMode', 'replyStyle', 'restrictGrantCommands', 'riff', 'sandbox', 'sandboxPaths', 'silentTurnReactions', 'skillInjection', 'startupCommands', 'substituteMode', 'summaryMemory', 'summaryMemoryPath', 'summaryRange', 'senderTag', 'writableTerminalLinkInCard', 'sessionOwnerReminder', @@ -43,6 +43,28 @@ describe('dashboard bot payload helpers', () => { expect(botSummaryPayload({ larkAppId: 'app' })).not.toHaveProperty('feedback'); }); + it('exposes only the normalized sparse reply style in private Bot Defaults payloads', () => { + const replyStyle = { + recipes: false, + theme: 'vivid', + recipePrompt: ' 先说风险 ', + layoutColors: { result: 'green', blocked: 'laser', unknown: 'blue' }, + layoutTags: { result: '', risk: '请确认', progress: 42 }, + }; + expect(botDefaultsPayload({ larkAppId: 'app' }, { replyStyle })).toMatchObject({ + replyStyle: { + recipes: false, + theme: 'vivid', + recipePrompt: '先说风险', + layoutColors: { result: 'green' }, + layoutTags: { result: '', risk: '请确认' }, + }, + }); + expect(botDefaultsPayload({ larkAppId: 'app' }, { replyStyle: 'secret-looking-invalid' })) + .toMatchObject({ replyStyle: null }); + expect(botSummaryPayload({ larkAppId: 'app' })).not.toHaveProperty('replyStyle'); + }); + it('keeps executable runtime details out of public group roster summaries', () => { const cliRuntime = { id: 'vendor-codex', diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 35bae21c85..7ca7754cd6 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -42,6 +42,11 @@ import { withBotTurnAdmission, } from '../src/core/bot-turn-mutation-gate.js'; import { SESSION_WAKE_DEADLINE_HEADER } from '../src/core/session-wake-deadline.js'; +import { REPLY_STYLE_REQUEST_MAX_BYTES } from '../src/dashboard/reply-style.js'; +import { + REPLY_LAYOUT_TAG_MAX_CODEPOINTS, + REPLY_RECIPE_PROMPT_MAX_CODEPOINTS, +} from '../src/im/lark/reply-card-style.js'; // Loopback-HMAC the write-link routes require. Inject a known secret per test // (setIpcAuthSecret) and sign with it, so the suite doesn't depend on a real @@ -1312,6 +1317,132 @@ describe('PUT /api/bot-card-prefs — reply-card usage display mode', () => { }); }); +describe('PUT /api/bot-reply-style — sparse reply-card appearance', () => { + it('persists normalized overrides, hot-updates GET, and clears the default block', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-reply-style-')); + const configPath = join(dir, 'bots.json'); + const appId = 'test-reply-style-app'; + const prevBotsConfig = process.env.BOTS_CONFIG; + try { + process.env.BOTS_CONFIG = configPath; + writeFileSync(configPath, JSON.stringify([{ + larkAppId: appId, + larkAppSecret: 'must-not-leak', + cliId: 'codex', + }], null, 2)); + loadBotConfigs().forEach((c: any) => registerBot(c)); + setLarkAppId(appId); + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const base = `http://127.0.0.1:${handle.port}`; + + const put = await fetch(`${base}/api/bot-reply-style`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + replyStyle: { + recipes: false, + layout: true, + theme: 'vivid', + recipePrompt: ' 风险优先 ', + layoutColors: { result: 'turquoise', blocked: 'laser', handoff: 'grey' }, + layoutTags: { result: '', risk: '请确认', progress: 42 }, + }, + }), + }); + expect(put.status).toBe(200); + const body = await put.json(); + expect(body).toMatchObject({ + ok: true, + replyStyle: { + recipes: false, + theme: 'vivid', + recipePrompt: '风险优先', + layoutColors: { result: 'turquoise' }, + layoutTags: { result: '', risk: '请确认' }, + }, + }); + expect(body.warnings).toHaveLength(3); + expect(body).not.toHaveProperty('larkAppSecret'); + + const disk = JSON.parse(readFileSync(configPath, 'utf-8'))[0]; + expect(disk.replyStyle).toEqual(body.replyStyle); + expect(disk.larkAppSecret).toBe('must-not-leak'); + expect((getBot(appId).config as any).replyStyle).toEqual(body.replyStyle); + expect(await (await fetch(`${base}/api/bot-default-oncall`)).json()) + .toMatchObject({ replyStyle: body.replyStyle }); + + const overLimit = await fetch(`${base}/api/bot-reply-style`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + replyStyle: { + recipes: false, + recipePrompt: '配'.repeat(REPLY_RECIPE_PROMPT_MAX_CODEPOINTS + 1), + layoutTags: { + risk: '签'.repeat(REPLY_LAYOUT_TAG_MAX_CODEPOINTS + 1), + blocked: '请处理', + }, + }, + }), + }); + expect(overLimit.status).toBe(200); + expect(await overLimit.json()).toMatchObject({ + ok: true, + replyStyle: { recipes: false, layoutTags: { blocked: '请处理' } }, + warnings: [expect.stringContaining('recipePrompt'), expect.stringContaining('layoutTags.risk')], + }); + + for (const replyStyle of [[], 'primitive', 42]) { + const invalidReplyStyle = await fetch(`${base}/api/bot-reply-style`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ replyStyle }), + }); + expect(invalidReplyStyle.status, JSON.stringify(replyStyle)).toBe(400); + expect(await invalidReplyStyle.json()) + .toMatchObject({ ok: false, error: 'invalid_body' }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].replyStyle) + .toEqual({ recipes: false, layoutTags: { blocked: '请处理' } }); + } + + const clear = await fetch(`${base}/api/bot-reply-style`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ replyStyle: null }), + }); + expect(clear.status).toBe(200); + expect(await clear.json()).toMatchObject({ ok: true, replyStyle: null }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].replyStyle).toBeUndefined(); + expect(await (await fetch(`${base}/api/bot-default-oncall`)).json()) + .toMatchObject({ replyStyle: null }); + + for (const raw of ['null', '[]', '"primitive"', '{}', '{"replyStyle":null,"extra":true}']) { + const invalid = await fetch(`${base}/api/bot-reply-style`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + expect(invalid.status, raw).toBe(400); + expect(await invalid.json()).toMatchObject({ ok: false, error: 'invalid_body' }); + } + + const oversized = await fetch(`${base}/api/bot-reply-style`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ replyStyle: { recipePrompt: 'x'.repeat(REPLY_STYLE_REQUEST_MAX_BYTES) } }), + }); + expect(oversized.status).toBe(413); + expect(await oversized.json()).toMatchObject({ ok: false, error: 'body_too_large' }); + } finally { + if (handle) await handle.close(); + handle = null; + if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG; + else process.env.BOTS_CONFIG = prevBotsConfig; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('POST /api/grants/chat', () => { it('requires loopback HMAC before invoking the permission service', async () => { const handler = vi.fn(); diff --git a/test/dashboard-reply-style-proxy.test.ts b/test/dashboard-reply-style-proxy.test.ts new file mode 100644 index 0000000000..6eec24de0b --- /dev/null +++ b/test/dashboard-reply-style-proxy.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { REPLY_STYLE_REQUEST_MAX_BYTES } from '../src/dashboard/reply-style.js'; + +const source = readFileSync(new URL('../src/dashboard.ts', import.meta.url), 'utf8'); + +function replyStyleRouteRegion(): string { + const start = source.indexOf('// PUT /api/bots/:appId/reply-style'); + const end = source.indexOf('// PUT /api/bots/:appId/startup-commands', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe('dashboard reply-style proxy', () => { + it('forwards a bounded PUT payload to the exact daemon route', () => { + const block = replyStyleRouteRegion(); + expect(REPLY_STYLE_REQUEST_MAX_BYTES).toBe(32 * 1024); + expect(block).toContain("req.method === 'PUT'"); + expect(block).toContain('url.pathname.match(/^\\/api\\/bots\\/([^/]+)\\/reply-style$/)'); + expect(block).toContain('readJsonBody(req, REPLY_STYLE_REQUEST_MAX_BYTES)'); + expect(block).toContain('JSON.stringify(await readJsonBody'); + expect(block).toContain("proxyToDaemon(appId, `/api/bot-reply-style`"); + expect(block).toContain("method: 'PUT'"); + }); + + it('maps malformed JSON to 400 and an oversized body to 413', () => { + const block = replyStyleRouteRegion(); + expect(block).toContain('err instanceof DashboardJsonBodyTooLargeError ? 413 : 400'); + expect(block).toContain("status === 413 ? 'body_too_large' : 'bad_json'"); + expect(block).toContain('res.writeHead(status'); + }); +}); From aac3f1536438f3055d26259ab096a699a330d107 Mon Sep 17 00:00:00 2001 From: sensuossss <285924750+sensuossss@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:15:52 -0700 Subject: [PATCH 16/17] =?UTF-8?q?feat(dashboard):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E9=A3=8E=E6=A0=BC=E8=AE=BE=E7=BD=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/assets/pr-1057/reply-style-desktop.png | Bin 0 -> 68927 bytes docs/assets/pr-1057/reply-style-mobile.png | Bin 0 -> 72345 bytes src/dashboard/web/bot-defaults-page.tsx | 224 ++++++++++++++++++++ src/dashboard/web/bot-defaults.ts | 3 + src/dashboard/web/i18n.ts | 94 ++++++++ src/dashboard/web/reply-style-form.ts | 95 +++++++++ src/dashboard/web/style.css | 70 ++++++ test/dashboard-reply-style.test.ts | 133 ++++++++++++ 8 files changed, 619 insertions(+) create mode 100644 docs/assets/pr-1057/reply-style-desktop.png create mode 100644 docs/assets/pr-1057/reply-style-mobile.png create mode 100644 src/dashboard/web/reply-style-form.ts create mode 100644 test/dashboard-reply-style.test.ts diff --git a/docs/assets/pr-1057/reply-style-desktop.png b/docs/assets/pr-1057/reply-style-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..dfb367782fdfaa2a0f0ee503fd557a72e58fe453 GIT binary patch literal 68927 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 z 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 U5*7LmY{8I~RFo)q`TFDk06RZdkpKVy literal 0 HcmV?d00001 diff --git a/docs/assets/pr-1057/reply-style-mobile.png b/docs/assets/pr-1057/reply-style-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..26255faa22d0f77026d03576fcea74e297e0be91 GIT binary patch literal 72345 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 ye

+
@@ -5812,6 +5830,212 @@ function BrandSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { ); } +const REPLY_LAYOUT_LABEL_KEYS: Record = { + result: 'botDefaults.replyStyleLayout.result', + progress: 'botDefaults.replyStyleLayout.progress', + risk: 'botDefaults.replyStyleLayout.risk', + blocked: 'botDefaults.replyStyleLayout.blocked', + handoff: 'botDefaults.replyStyleLayout.handoff', +}; + +function ReplyStyleSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { + const tr = useT(); + const [draft, setDraft] = useState(() => replyStyleDraftFromConfig(props.bot.replyStyle)); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + setDraft(replyStyleDraftFromConfig(props.bot.replyStyle)); + setStatus(null); + }, [props.bot.replyStyle]); + + const themeOptions = REPLY_THEMES.map(theme => ({ + value: theme, + label: tr(`botDefaults.replyStyleTheme.${theme}`), + })); + const tagModeOptions: Array<{ value: ReplyTagMode; label: string }> = [ + { value: 'inherit', label: tr('botDefaults.replyStyleTag.inherit') }, + { value: 'hidden', label: tr('botDefaults.replyStyleTag.hidden') }, + { value: 'custom', label: tr('botDefaults.replyStyleTag.custom') }, + ]; + + async function save(): Promise { + setStatus(null); + if (replyStyleDraftHasBlankCustomTag(draft)) { + setStatus({ text: `✗ ${tr('botDefaults.replyStyleCustomTagRequired')}` }); + return; + } + setBusy(true); + try { + const replyStyle = replyStyleConfigFromDraft(draft) ?? null; + const res = await sendJson( + 'PUT', + `/api/bots/${encodeURIComponent(props.bot.larkAppId)}/reply-style`, + { replyStyle }, + ); + if (!res.ok || !res.body.ok) { + setStatus({ text: `✗ ${responseErrorText(res)}` }); + return; + } + const next = res.body.replyStyle ?? null; + setDraft(replyStyleDraftFromConfig(next)); + props.patchBot(props.bot.larkAppId, { replyStyle: next }); + const warningCount = Array.isArray(res.body.warnings) ? res.body.warnings.length : 0; + setStatus({ + text: `✓ ${warningCount > 0 + ? tr('botDefaults.replyStyleSavedWithWarnings', { count: warningCount }) + : tr('botDefaults.replyStyleSaved')}`, + ok: warningCount === 0, + }); + } catch (e: any) { + setStatus({ text: `✗ ${caughtErrorText(e)}` }); + } finally { + setBusy(false); + } + } + + return ( +
+

+ {tr('botDefaults.sectionReplyStyle')} +

+
+ setDraft(current => ({ ...current, recipes }))} + /> + setDraft(current => ({ ...current, layout }))} + /> +
+
+
+ {tr('botDefaults.replyStyleTheme')} + + dataInput="replyStyle.theme" + ariaLabel={tr('botDefaults.replyStyleTheme')} + value={draft.theme} + disabled={busy} + options={themeOptions} + onChange={theme => setDraft(current => ({ ...current, theme }))} + /> +
+
+
+