Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs-site/docs/en/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ You can also add it to the corresponding bot entry directly (manual `bots.json`
|------|------|
| `brandLabel` | Branding text at the bottom of the card. `undefined` = default `botmux` link; `""` = hidden; any other string = rendered as-is (supports markdown). Purely cosmetic, does not affect routing / permissions |
| `disableStreamingCard` | When `true`, no real-time streaming session card is sent at all (the Web Terminal still runs and the final reply still arrives via `botmux send`, there's just no auto-refreshing status card). For users who find the real-time card noisy |
| `persistentStreamCard` | When `true`, a session posts its streaming card only on the **first turn** and PATCHes that **same card in place** via `updateMessage` on every subsequent turn — no more "post a fresh card + recall the old one". The card title tracks the latest turn. For users who dislike the card being withdrawn and re-posted each turn. Mutually exclusive with `disableStreamingCard` (no card at all takes precedence) |
| `silentTurnReactions` | When `true`, card-off sessions no longer add GoGoGo / DONE reactions to the triggering message. Only affects the lightweight status reactions used when `disableStreamingCard` or `noCardChats` suppresses live cards; defaults to `false` |
| `receivedReactionEmoji` | Feishu emoji_type for the "received" reaction in card-off sessions; `undefined` = default `GoGoGo` (冲!). Free-form string; a bad value just silently fails to attach (best-effort) |
| `doneReactionEmoji` | Feishu emoji_type for the "done" reaction in card-off sessions; `undefined` = default `DONE` (✅). Set it equal to `receivedReactionEmoji` to keep the marker unchanged on turn-end — handy for CLIs whose idle detection can fire early (e.g. Pi), avoiding a premature, misleading ✅ |
Expand Down
1 change: 1 addition & 0 deletions docs-site/docs/en/cards.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Every conversation turn produces a live-updating Lark card, your primary window
- **Live status indicator**: 🟡 Starting → Analyzing → 🔵 Working / Executing → 🟢 Waiting for input; when the quota is used up, it shows "Limit reached · Retryable."
- **Operate directly from the card**: open a (writable) terminal, 🔑 grab an operation link, restart / close / adopt the session, and resend the last task.
- **A fresh card per turn**: the previous card freezes as an archive, keeping conversation history clear and traceable; after a session is moved to another group with [`/relay`](/en/relay), the original card also automatically freezes as an archive.
> Dislike the "post a fresh card + recall the old one" churn each turn? Set `persistentStreamCard` to `true` for the bot in [bots.json](/en/bots-json) (or flip the matching toggle under Dashboard → Bot config → Card behavior): a session posts one card and patches it in place every subsequent turn, with the title tracking the latest turn.
- **An "recoverable" card on close**: it includes the CLI's native resume command, so you can click back in to continue anytime.

> The card body is a **live screenshot (image)** of the terminal, not text rendering. Messages the CLI proactively sends (via `botmux send`) are separate rich-text / image-and-text messages that can carry images, files, and @mentions; for fully custom display cards, `--card-file` / `--card-json` can send raw interactive card JSON (display + open_url only — any callback-firing control, such as callback buttons, dropdowns, inputs, or form submits, is rejected).
1 change: 1 addition & 0 deletions docs-site/docs/zh/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
|------|------|
| `brandLabel` | 卡片底部品牌文案。`undefined`=默认 `botmux` 链接;`""`=隐藏;其它字符串=原样渲染(支持 markdown)。纯样式,不影响路由 / 权限 |
| `disableStreamingCard` | `true` 时彻底不发实时流式 session 卡片(web 终端仍跑、最终答复仍经 `botmux send` 到达,只是没有自动刷新的状态卡)。给嫌实时卡吵的用户 |
| `persistentStreamCard` | `true` 时,一个会话只在**首轮**发一张流式卡片,后续每轮都用 `updateMessage` **原地更新同一张卡**,不再「发新卡 + 撤回旧卡」。卡片标题跟随最新一轮。适合嫌每轮卡片被撤回重发的用户。与 `disableStreamingCard`(完全不发卡)互斥,后者优先 |
| `silentTurnReactions` | `true` 时,无卡片会话不再给触发消息添加 GoGoGo / DONE reaction。只影响 `disableStreamingCard` 或 `noCardChats` 关闭实时卡片后的轻量状态提示;默认 `false` |
| `receivedReactionEmoji` | 无卡片会话「已收到」reaction 的飞书 emoji_type;`undefined`=默认 `GoGoGo`(冲!)。自由字符串,填错只是静默不加表情(best-effort) |
| `doneReactionEmoji` | 无卡片会话「已完成」reaction 的飞书 emoji_type;`undefined`=默认 `DONE`(✅)。设成与 `receivedReactionEmoji` 相同值可让完成态不翻脸——适合 idle 判定可能提前触发的 CLI(如 Pi),避免过早出现误导性的 ✅ |
Expand Down
1 change: 1 addition & 0 deletions docs-site/docs/zh/cards.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- **状态实时指示**:🟡 启动中 → 正在分析 → 🔵 工作中 / 执行中 → 🟢 等待输入;额度用满会标「限额已达 · 可重试」。
- **卡片上直接操作**:打开(可写)终端、🔑 取操作链接、重启 / 关闭 / 接管会话、重发上一条任务。
- **每轮一张新卡片**:上一轮卡片冻结存档,对话历史清晰可回溯;会话用 [`/relay`](/relay) 搬到别的群后,原卡片也会自动冻结为存档。
> 不喜欢每轮「发新卡 + 撤回旧卡」?在 [bots.json](/bots-json) 里把该 bot 的 `persistentStreamCard` 设为 `true`(或在 Dashboard「Bot 配置 → 卡片行为」里打开对应开关):一个会话只发一张卡,后续每轮都原地更新它,卡片标题跟随最新一轮。
- **关闭时给「可恢复」卡片**:附上 CLI 原生 resume 命令,随时点回来继续。

> 卡片正文是终端画面的**实时截图(图片)**,不是文本渲染。CLI 主动发的消息(通过 `botmux send`)则是独立的富文本 / 图文消息,可带图片、文件、@mention;需要完全自定义展示时也可以用 `--card-file` / `--card-json` 发送原始 interactive 卡片 JSON(纯展示 + open_url 跳转,会触发回调的交互控件——回调按钮/下拉/输入/表单等——都会被拒绝)。
11 changes: 11 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,16 @@ export interface BotConfig {
* (undefined) keeps the streaming card. For users who find the live card noisy.
*/
disableStreamingCard?: boolean;
/**
* When true, a session POSTs its streaming card only once (on the first turn)
* and every subsequent turn PATCHes that same card via `updateMessage`
* instead of the default "post a fresh card + recall the previous one" flow.
* The card title tracks the latest turn. Default (undefined) keeps the
* per-turn fresh-card behaviour. For users who dislike the card being
* withdrawn and re-posted on every turn. Mutually exclusive with
* {@link disableStreamingCard} (no card at all takes precedence).
*/
persistentStreamCard?: boolean;
/**
* When true, suppress the lightweight GoGoGo → DONE message reactions used as
* progress markers in card-off sessions. Missing/false preserves the current
Expand Down Expand Up @@ -1908,6 +1918,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
// means "use default botmux brand". Don't trim-to-undefined here.
brandLabel: typeof entry.brandLabel === 'string' ? entry.brandLabel : undefined,
disableStreamingCard: entry.disableStreamingCard === true || undefined,
persistentStreamCard: entry.persistentStreamCard === true || undefined,
silentTurnReactions: entry.silentTurnReactions === true || undefined,
receivedReactionEmoji: typeof entry.receivedReactionEmoji === 'string' && entry.receivedReactionEmoji.trim()
? entry.receivedReactionEmoji.trim() : undefined,
Expand Down
6 changes: 4 additions & 2 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1983,6 +1983,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
readIsolationSupported: readIsolationEnforceable(cachedLarkAppId),
backendType: backendTypeStore.getBotBackendType(cachedLarkAppId) ?? null,
disableStreamingCard: cardPrefs.disableStreamingCard,
persistentStreamCard: cardPrefs.persistentStreamCard,
silentTurnReactions: cardPrefs.silentTurnReactions,
codexAppCleanInput: cardPrefs.codexAppCleanInput,
writableTerminalLinkInCard: cardPrefs.writableTerminalLinkInCard,
Expand Down Expand Up @@ -2022,7 +2023,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => {
if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' });
let body: {
disableStreamingCard?: unknown; silentTurnReactions?: unknown; codexAppCleanInput?: unknown; writableTerminalLinkInCard?: unknown; privateCard?: unknown;
disableStreamingCard?: unknown; persistentStreamCard?: unknown; silentTurnReactions?: unknown; codexAppCleanInput?: unknown; writableTerminalLinkInCard?: unknown; privateCard?: unknown;
botToBotSameDir?: unknown;
autoStartOnGroupJoin?: unknown; autoStartOnGroupJoinPrompt?: unknown; autoStartOnNewTopic?: unknown;
regularGroupReplyMode?: unknown; regularGroupMentionMode?: unknown; docSubscribeDefaultMode?: unknown;
Expand All @@ -2031,13 +2032,14 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => {
catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); }

const patch: {
disableStreamingCard?: boolean; silentTurnReactions?: boolean; codexAppCleanInput?: boolean; writableTerminalLinkInCard?: boolean; privateCard?: boolean;
disableStreamingCard?: boolean; persistentStreamCard?: boolean; silentTurnReactions?: boolean; codexAppCleanInput?: boolean; writableTerminalLinkInCard?: boolean; privateCard?: boolean;
botToBotSameDir?: boolean;
autoStartOnGroupJoin?: boolean; autoStartOnGroupJoinPrompt?: string; autoStartOnNewTopic?: boolean;
regularGroupReplyMode?: ChatReplyMode; regularGroupMentionMode?: 'always' | 'topic' | 'never' | 'ambient';
docSubscribeDefaultMode?: 'mention-only' | 'all';
} = {};
if (typeof body.disableStreamingCard === 'boolean') patch.disableStreamingCard = body.disableStreamingCard;
if (typeof body.persistentStreamCard === 'boolean') patch.persistentStreamCard = body.persistentStreamCard;
if (typeof body.botToBotSameDir === 'boolean') patch.botToBotSameDir = body.botToBotSameDir;
if (typeof body.silentTurnReactions === 'boolean') patch.silentTurnReactions = body.silentTurnReactions;
if (typeof body.codexAppCleanInput === 'boolean') patch.codexAppCleanInput = body.codexAppCleanInput;
Expand Down
24 changes: 22 additions & 2 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3324,6 +3324,14 @@ function getActiveCount(): number {
* passthrough slash-command path (/model, /clear, /compact, etc.) — without
* this, passthrough commands silently PATCH the previous card and the user
* sees no visible response.
*
* Persistent-card mode (per-bot `persistentStreamCard`): when a live card
* already exists for this session, skip both the freeze-and-park step and the
* `streamCardPending` flag. The next screen_update then falls into the PATCH
* branch (`!streamCardPending && streamCardId`) and reuses the same card via
* `updateMessage` — so the session keeps ONE card that tracks the latest turn,
* instead of post-fresh + recall-previous every turn. The first turn (no card
* yet) still POSTs once through the normal `!streamCardId` path.
*/
function beginNewTurn(ds: DaemonSession, title: string): void {
// docCommentTargets 改为 per-turn map(按 turnId 索引),不再需要每轮清空:
Expand All @@ -3333,9 +3341,18 @@ function beginNewTurn(ds: DaemonSession, title: string): void {
// in. A new turn returns to the config default (noCardChats / disableStreamingCard).
// Use `/card on` to persistently restore cards for the chat.
ds.streamingCardForced = undefined;
// Persistent-card mode: reuse the existing live card (PATCH) rather than
// freeze+repost. Only when a real (non-sentinel) card is already live and the
// worker port is up; otherwise fall through to the normal fresh-card flow so
// the first turn still posts exactly one card.
const reuseExistingCard =
getBot(ds.larkAppId).config.persistentStreamCard === true
&& !!ds.streamCardId
&& ds.streamCardId !== CARD_POSTING_SENTINEL
&& !!ds.workerPort;
const previousUsageLimit = ds.usageLimit;
const previousStatus = ds.lastScreenStatus === 'limited' && previousUsageLimit ? 'limited' : 'idle';
if (ds.streamCardId && ds.workerPort) {
if (!reuseExistingCard && ds.streamCardId && ds.workerPort) {
const readUrl = buildTerminalUrl(ds);
const dsBotCfg = getBot(ds.larkAppId).config;
const prevTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(dsBotCfg.cliId);
Expand Down Expand Up @@ -3367,7 +3384,10 @@ function beginNewTurn(ds: DaemonSession, title: string): void {
ds.usageLimitRetryTimer = undefined;
}
ds.usageLimit = undefined;
ds.streamCardPending = true;
// Persistent-card reuse leaves streamCardPending false so the next
// screen_update PATCHes the existing card; the normal path forces a fresh
// card POST.
if (!reuseExistingCard) ds.streamCardPending = true;
ds.currentTurnTitle = title.substring(0, 50);
ds.currentImageKey = undefined;
persistStreamCardState(ds);
Expand Down
1 change: 1 addition & 0 deletions src/dashboard/bot-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?:
readIsolationSupported: j?.readIsolationSupported === true,
backendType: typeof j?.backendType === 'string' ? j.backendType : null,
disableStreamingCard: j?.disableStreamingCard === true,
persistentStreamCard: j?.persistentStreamCard === true,
silentTurnReactions: j?.silentTurnReactions === true,
codexAppCleanInput: j?.codexAppCleanInput === true,
writableTerminalLinkInCard: j?.writableTerminalLinkInCard === true,
Expand Down
16 changes: 15 additions & 1 deletion src/dashboard/web/bot-defaults-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ function patchCardPrefsFromBody(bot: BotDefaultsRow, body: any): BotDefaultsRow
return {
...bot,
disableStreamingCard: body.disableStreamingCard,
persistentStreamCard: body.persistentStreamCard,
silentTurnReactions: body.silentTurnReactions,
codexAppCleanInput: body.codexAppCleanInput,
writableTerminalLinkInCard: body.writableTerminalLinkInCard,
Expand Down Expand Up @@ -1629,6 +1630,7 @@ function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: Ca
const tr = useT();
const { bot, putCardPref } = props;
const [disableStreaming, setDisableStreaming] = useState(bot.disableStreamingCard === true);
const [persistentCard, setPersistentCard] = useState(bot.persistentStreamCard === true);
const [silentReactions, setSilentReactions] = useState(bot.silentTurnReactions === true);
const [writableLink, setWritableLink] = useState(bot.writableTerminalLinkInCard === true);
const [privateCard, setPrivateCard] = useState(bot.privateCard === true);
Expand All @@ -1637,10 +1639,11 @@ function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: Ca

useEffect(() => {
setDisableStreaming(bot.disableStreamingCard === true);
setPersistentCard(bot.persistentStreamCard === true);
setSilentReactions(bot.silentTurnReactions === true);
setWritableLink(bot.writableTerminalLinkInCard === true);
setPrivateCard(bot.privateCard === true);
}, [bot.disableStreamingCard, bot.privateCard, bot.silentTurnReactions, bot.writableTerminalLinkInCard]);
}, [bot.disableStreamingCard, bot.persistentStreamCard, bot.privateCard, bot.silentTurnReactions, bot.writableTerminalLinkInCard]);

async function savePatch(patch: CardPrefPatch, key: string): Promise<void> {
setBusy(key);
Expand Down Expand Up @@ -1670,6 +1673,17 @@ function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: Ca
void savePatch({ disableStreamingCard: checked }, 'streaming');
}}
/>
<ToggleRow
checked={persistentCard}
disabled={disableStreaming || busy === 'persistent'}
dataAction="toggle-persistent-card"
title={tr('botDefaults.persistentCard')}
help={disableStreaming ? tr('botDefaults.writableLinkMoot') : tr('botDefaults.persistentCardHelp')}
onChange={checked => {
setPersistentCard(checked);
void savePatch({ persistentStreamCard: checked }, 'persistent');
}}
/>
<ToggleRow
checked={silentReactions}
disabled={!disableStreaming || busy === 'silent'}
Expand Down
1 change: 1 addition & 0 deletions src/dashboard/web/bot-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type BotDefaultsRow = {
readIsolationSupported?: boolean;
backendType?: string | null;
disableStreamingCard?: boolean;
persistentStreamCard?: boolean;
silentTurnReactions?: boolean;
codexAppCleanInput?: boolean;
writableTerminalLinkInCard?: boolean;
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard/web/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,8 @@ const zh: DashboardMessages = {
'botDefaults.sectionCard': '卡片行为',
'botDefaults.disableStreaming': '关闭飞书流式卡片',
'botDefaults.disableStreamingHelp': '关闭实时会话状态卡和 Web Terminal 入口;最终结果仍会以消息发送。',
'botDefaults.persistentCard': '会话只发一张流式卡(后续原地更新)',
'botDefaults.persistentCardHelp': '每个会话只在首轮发一张流式卡片,后续每轮都原地更新(updateMessage)同一张卡,不再「发新卡+撤回旧卡」。卡片标题跟随最新一轮。',
'botDefaults.silentTurnReactions': '关闭无卡片模式下的状态 reaction',
'botDefaults.silentTurnReactionsHelp': '关闭流式卡片后,默认用 reaction 标记收到/完成。开启后不再添加这些状态 reaction。',
'botDefaults.writableLink': '卡片上直接给可操作(可写)终端链接',
Expand Down Expand Up @@ -3590,6 +3592,8 @@ const en: DashboardMessages = {
'botDefaults.sectionCard': 'Card Behavior',
'botDefaults.disableStreaming': 'Disable streaming card',
'botDefaults.disableStreamingHelp': 'Disables the live session card and Web Terminal entry. Final results are still sent as messages.',
'botDefaults.persistentCard': 'One streaming card per session (patch in place)',
'botDefaults.persistentCardHelp': 'Post the streaming card only on the first turn, then patch that same card in place (updateMessage) every subsequent turn — no more "post a fresh card + recall the old one". The card title tracks the latest turn.',
'botDefaults.silentTurnReactions': 'Disable status reactions in card-off mode',
'botDefaults.silentTurnReactionsHelp': 'When streaming cards are off, botmux can mark received/done with reactions. Enable this to suppress those reactions too.',
'botDefaults.writableLink': 'Put a writable terminal link on the card',
Expand Down
Loading