From f0f3d5821053936b4664433a24ed2f2d23873697 Mon Sep 17 00:00:00 2001 From: yujiaze Date: Wed, 22 Jul 2026 19:41:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(card):=20=E6=96=B0=E5=A2=9E=20persistentSt?= =?UTF-8?q?reamCard=20=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=86=85=E5=A4=8D=E7=94=A8=E5=90=8C=E4=B8=80=E5=BC=A0?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认行为下每轮对话都会「发一张新流式卡 + 撤回上一轮旧卡」,长会话里卡片 反复重发/撤回,体验较吵。新增 per-bot 全局配置 persistentStreamCard: 开启后一个会话只在首轮 POST 一张卡片,后续每轮都用 updateMessage 原地 PATCH 同一张卡(标题跟随最新一轮),不再发新撤旧。 实现要点: - daemon.ts beginNewTurn:持久模式且已有存活卡片(streamCardId + workerPort) 时,跳过冻结旧卡入 frozenCards、且不置 streamCardPending。下一轮 screen_update 因此天然命中 worker-pool 既有的复用 PATCH 分支 (restoredCardId = streamCardId && !streamCardPending),无需新增 PATCH 逻辑。 首轮无卡时仍照常 POST 一次。 - 刻意不干预 re-fork / relay / repo 切换路径(它们显式清空 streamCardId, worker 端口已失效无法可靠复用),保持原有重发行为。 - 与 disableStreamingCard(完全不发卡)互斥,后者优先。 配置贯穿:bot-registry(字段 + normalize)、card-prefs-store(接口/默认值/ 读写同步)、dashboard-ipc-server + bot-payload + bot-defaults(前端类型)+ bot-defaults-page(卡片行为区新增开关)+ i18n(中英文案)。 文档:docs-site zh|en 的 cards.md 与 bots-json.md。 影响面:改动集中在飞书卡片层(im/lark 无关,daemon 卡片编排 + card-prefs), 与 CLI / 后端(PtyBackend/TmuxBackend)无关,默认关闭时行为完全不变。 话题会话 / 群会话 / 私聊均走同一 beginNewTurn 路径,一致生效。 验证: - tsc 全量编译通过(--noEmit 与 emit 均 exit 0) - dashboard 前端 esbuild bundle 成功 - 直接相关单测通过:card-prefs-auto-start + bot-registry(×2) 共 91/91 - 邻近单测通过:event-dispatcher + card-handler-grant + cli-adapters 共 550/550 - 全量 unit 另有若干 pre-existing 失败(session-discovery / v3-distillation-runner / vc-meeting 等,与本改动无关;stash 对比干净基线上同样失败) --- docs-site/docs/en/bots-json.md | 1 + docs-site/docs/en/cards.md | 1 + docs-site/docs/zh/bots-json.md | 1 + docs-site/docs/zh/cards.md | 1 + src/bot-registry.ts | 11 +++++++++++ src/core/dashboard-ipc-server.ts | 6 ++++-- src/daemon.ts | 24 ++++++++++++++++++++++-- src/dashboard/bot-payload.ts | 1 + src/dashboard/web/bot-defaults-page.tsx | 16 +++++++++++++++- src/dashboard/web/bot-defaults.ts | 1 + src/dashboard/web/i18n.ts | 4 ++++ src/services/card-prefs-store.ts | 14 ++++++++++++++ 12 files changed, 76 insertions(+), 5 deletions(-) diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md index 5d0893566..3c082dac4 100644 --- a/docs-site/docs/en/bots-json.md +++ b/docs-site/docs/en/bots-json.md @@ -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 ✅ | diff --git a/docs-site/docs/en/cards.md b/docs-site/docs/en/cards.md index b8dc8f87c..8e556145d 100644 --- a/docs-site/docs/en/cards.md +++ b/docs-site/docs/en/cards.md @@ -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). diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md index 1e7b324c5..2a216edc0 100644 --- a/docs-site/docs/zh/bots-json.md +++ b/docs-site/docs/zh/bots-json.md @@ -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),避免过早出现误导性的 ✅ | diff --git a/docs-site/docs/zh/cards.md b/docs-site/docs/zh/cards.md index 446272fa2..ad861bb1d 100644 --- a/docs-site/docs/zh/cards.md +++ b/docs-site/docs/zh/cards.md @@ -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 跳转,会触发回调的交互控件——回调按钮/下拉/输入/表单等——都会被拒绝)。 diff --git a/src/bot-registry.ts b/src/bot-registry.ts index effcc97f9..5466437cc 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -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 @@ -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, diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 0ba71ff87..f9aef71b7 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -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, @@ -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; @@ -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; diff --git a/src/daemon.ts b/src/daemon.ts index ef39cf3d6..5bf46f7e9 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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 索引),不再需要每轮清空: @@ -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); @@ -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); diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index f0fe0063e..a2399ef34 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -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, diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 1cc7f8ece..0a1a13ac2 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -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, @@ -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); @@ -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 { setBusy(key); @@ -1670,6 +1673,17 @@ function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: Ca void savePatch({ disableStreamingCard: checked }, 'streaming'); }} /> + { + setPersistentCard(checked); + void savePatch({ persistentStreamCard: checked }, 'persistent'); + }} + />