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 0000000000..dfb367782f Binary files /dev/null and b/docs/assets/pr-1057/reply-style-desktop.png differ 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 0000000000..26255faa22 Binary files /dev/null and b/docs/assets/pr-1057/reply-style-mobile.png differ 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/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..c96fa0c912 --- /dev/null +++ b/docs/plans/2026-08-27-reply-card-style-config.md @@ -0,0 +1,153 @@ +# 回复风格配置化(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`,另开) +- **卡头标题**:`{前缀}`;正文有首个 ATX H1/H2 则 `{前缀} · {标题文本}`,并取走该行不再当正文 heading 渲染。无 `--layout-title`,不从正文猜档。前缀:result=结果、progress=进度、risk=需要确认、blocked=受阻、handoff=交接 + +配置落在 **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 | 缺省 | 非空则替换内置配方区;缺省用内置五类配方 | +| `replyStyle.layoutColors` | 对象 | 主题缺省 | 每档卡头颜色,键为五档名,值必须是飞书 `header.template` 官方枚举 | +| `replyStyle.layoutTags` | 对象 | 主题缺省 | 每档标签文案。缺省:仅 `risk`/`blocked` 为「需要你」。空字符串 = 该档不显示标签 | + +`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` 非空时**替换**内置配方区,不是追加;空或省略仍用内置文本。 + +## 预设主题 + +五档语义固定,主题只调视觉重量。 + +| 主题 | 卡头 | 标签 | +|---|---|---| +| `default` | `green` / `blue` / `orange` / `red` / `indigo` | 仅 `risk`、`blocked` →「需要你」 | +| `minimal` | 五档都无彩色 template,只留标题 | 仅 `risk`、`blocked` →「需要你」 | +| `vivid` | 同 default 的五色 | 五档都带:完成 / 进行中 / 需要你 / 需要你 / 交接 | + +`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` 这两个名字。 + +## 卡头标题生成 + +档位只来自显式 `--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` 这类展示配置): + +```json +{ + "replyStyle": { + "recipes": true, + "layout": true, + "theme": "default", + "recipePrompt": "", + "layoutColors": { "handoff": "indigo" }, + "layoutTags": { "risk": "需要你", "blocked": "需要你" } + } +} +``` + +缺省整块省略 = 上表缺省。不要做成全局 daemon 配置,避免一改全员 Bot 变脸。`layoutColors` / `layoutTags` 只写要覆盖的档,未写的档走当前主题。 + +**Dashboard**:Bot 设置里、品牌文案附近加一小节「回复风格」。本轮 UI: + +- 配方引导:开 / 关 +- layout 壳:开 / 关 +- 主题:默认 / 极简 / 鲜明(下拉,枚举写死) +- 配方文本:多行输入,空 = 用内置引导 +- 每档卡头颜色:五档各自一个下拉,选项锁官方色板;另加「跟随主题」 +- 每档标签:五档各自一个短文本;空 = 跟随主题(default 下 result/progress/handoff 为空) + +**skill 注入**:`replyStyle.recipes === false` 时,`botmux-send` 内置指南去掉配方表和选型信号,其它发送契约不变。`layout === false` 时指南不提 `--layout`。 + +**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` 同门)。两层不矛盾,后人不要当成规格冲突。 + +**会话快照(自有 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`:非空则**替换**内置配方区;空或省略 = 内置文本;最长 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 配置 + +### 本轮之后 + +1. Diff 分栏(见 layer2 backlog) +2. 私聊按人覆盖(若要做);群聊永远不按读者分皮肤 + +## 明确不做(本轮) + +- `compare` 档、`diff` 档、进度条、假按钮、插件模板 +- 读者侧主题切换、一条消息两套渲染 +- 从标题或正文关键词自动选档、自动上色 +- 任意卡片 JSON 当主题 +- 用 grey 做 handoff 卡头 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 6b4e5d5c39..2eceb84b5d 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 @@ -2039,6 +2053,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 @@ -2436,6 +2451,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 @@ -2539,6 +2555,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 磁盘回退(不同于 @@ -3115,6 +3177,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. @@ -3291,6 +3358,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 1ebbae3c07..6111f6a335 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 自动取自会话) @@ -7245,16 +7247,31 @@ import { appendReplyCardFooterToV2Card, buildImageCardElements, buildReplyCardFooter, + createReplyCard, + extractFirstReplyCardHeading, prepareCardMarkdown, 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'; @@ -7284,6 +7301,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); } @@ -7387,6 +7405,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({ @@ -7584,6 +7606,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' || @@ -7669,6 +7692,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' || @@ -7728,6 +7752,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 @@ -7837,7 +7864,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) { @@ -8170,6 +8205,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) { @@ -8206,6 +8246,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 @@ -8353,6 +8398,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); @@ -9348,6 +9397,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) { @@ -9398,7 +9451,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. @@ -9474,7 +9533,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: ' ', }], }, @@ -9498,16 +9557,14 @@ 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 = 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: { update_multi: true }, body: { direction: 'vertical', elements }, - }), 'interactive'); + messageId = await dispatchPrimary(JSON.stringify(createReplyCard(elements, layoutHeader)), 'interactive'); } } diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index bcc68ffec1..597c2a8d4c 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, @@ -4309,6 +4321,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/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 7897d13303..468bbddd16 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -10332,6 +10332,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/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 127800ec40..1db38a5ae0 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/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index b314b36418..e2544f83d0 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -59,6 +59,23 @@ import { } from '../../services/grant-policy.js'; import { BOT_DESCRIPTION_MAX_CHARS, normalizeBotDescriptions } from '../../services/bot-description-schema.js'; import { reasoningEffortsForCliModel } from '../../services/codex-reasoning-effort.js'; +import { + REPLY_HEADER_COLORS, + REPLY_LAYOUT_TAG_MAX_CODEPOINTS, + REPLY_LAYOUTS, + REPLY_RECIPE_PROMPT_MAX_CODEPOINTS, + REPLY_THEMES, + type ReplyHeaderColor, + type ReplyLayout, + type ReplyTheme, +} from '../../im/lark/reply-card-style.js'; +import { + clampUnicodeCodePoints, + replyStyleConfigFromDraft, + replyStyleDraftFromConfig, + replyStyleDraftHasBlankCustomTag, + type ReplyTagMode, +} from './reply-style-form.js'; /** 会话群标签名的输入上限,与服务端 `MAX_SESSION_TAG_NAME_CODEPOINTS` * (services/feed-group-tagger.ts)保持一致。这里不 import 那个常量:该模块会连带 @@ -1070,6 +1087,7 @@ function BotDefaultsCard(props: {
+
@@ -5832,6 +5850,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 }))} + /> +
+
+
+