diff --git a/packages/dmworkbase/src/Components/Conversation/__tests__/clientFallbackFinalize.test.ts b/packages/dmworkbase/src/Components/Conversation/__tests__/clientFallbackFinalize.test.ts new file mode 100644 index 000000000..0589d0051 --- /dev/null +++ b/packages/dmworkbase/src/Components/Conversation/__tests__/clientFallbackFinalize.test.ts @@ -0,0 +1,204 @@ +// @vitest-environment jsdom + +/** + * WS-99 client-fallback race 闭环回归。 + * + * 兜底逻辑:收到某 assistant 的 final text 后把它最近一张「未终态」progress 卡本地标记为 + * `localFallbackApplied`(渲染层显示「未收到显式终态」banner)。本 suite 覆盖评审 blocker—— + * 当权威终态帧随后到达(走 `updateMessageByMessageExtras` 的 contentEdit)时,必须撤回该本地 + * 标记,让真实终态正常渲染;而只读扩展(read receipt,无 contentEdit)不得误撤回。 + * + * 与 malformedMessageRender.test 一样用 REAL SDK + REAL MessageWrap + REAL + * InteractiveCardContent,只 mock 掉 ConversationVM 构造所需的重型 App/Service 单例。 + */ + +import { afterEach, describe, it, expect, vi } from "vitest" +import { + Channel, + ChannelTypeGroup, + Message, + MessageExtra, + MessageStatus, + MessageText, +} from "wukongimjssdk" + +// 重型 App/Service 单例(镜像 malformedMessageRender.test,去掉 wukongimjssdk / +// Service/Model 的 mock —— 这里要真实 SDK + 真实 MessageWrap)。 +vi.mock("../../../App", () => ({ + default: { + loginInfo: { uid: "me", realnameVerified: false }, + config: { pageSizeOfMessage: 30 }, + dataSource: { channelDataSource: { subscribers: () => Promise.resolve([]) } }, + mittBus: { on: () => {}, off: () => {} }, + emojiService: { getImage: () => undefined }, + conversationProvider: { + markConversationUnread: () => Promise.resolve(), + syncMessages: () => Promise.resolve([]), + }, + shared: { + currentSpaceId: "", + notifyMessageDeleteListener: () => {}, + avatarUser: () => "", + }, + }, +})) +vi.mock("../../../Service/DataSource/DataProvider", () => ({ SyncMessageOptions: class {} })) +vi.mock("../../../Service/Provider", () => ({ + ProviderListener: class { + callback?: Function + notifyListener(done?: Function) { this.callback?.(); done?.() } + listen(f: Function) { this.callback = f } + clearListeners() { this.callback = undefined } + didMount() {} + didUnMount() {} + }, +})) +vi.mock("react-scroll", () => ({ animateScroll: { scrollToBottom: () => {} }, scroller: { scrollTo: () => {} } })) +vi.mock("../../../Messages/Time", () => ({ TimeContent: class {} })) +vi.mock("../../../Messages/HistorySplit", () => ({ HistorySplitContent: class {} })) +vi.mock("../../../Messages/Mergeforward", () => ({ default: class {} })) +vi.mock("../foldSessionSummary", () => ({ getFoldSessionExpandedMessages: () => [] })) +vi.mock("../historyScroll", () => ({ + getPulldownRestoredScrollTop: () => 0, + getRestoredAnchorScrollTop: ({ anchorOffsetTop, keepOffsetY }: any) => anchorOffsetTop + keepOffsetY, +})) +vi.mock("../../../Service/Convert", () => ({ applyMsgLevelExternalFieldsWithFallback: () => {} })) +vi.mock("../../../Utils/sendContentProxy", () => ({ wrapSendContentForInjection: (content: any) => content })) +vi.mock("../../../Service/messageSelection", () => ({ isMessageSelectable: () => true })) +// i18n barrel 会 transitively 拉 lottie-web,在 jsdom(无 canvas)import 即崩;echo key 即可。 +vi.mock("../../../i18n", () => ({ + t: (key: string) => key, + useI18n: () => ({ t: (key: string) => key }), +})) +vi.mock("../../../Service/ProhibitwordsService", () => ({ + ProhibitwordsService: { + shared: { filter: (text: unknown) => (typeof text === "string" ? text : ""), getProhibitwords: () => [] }, + }, +})) + +import ConversationVM from "../vm" +import { MessageWrap } from "../../../Service/Model" +import { InteractiveCardContent } from "../../../Messages/InteractiveCard/InteractiveCardContent" + +const channel = new Channel("g1", ChannelTypeGroup) +const SENDER = "bot1" + +/** 构造一张与 openclaw card-render 输出同形的 agent_progress 卡内容(header 决定终态)。 */ +function makeCardContent( + headerText: string, + opts: { layout?: boolean } = {} +): InteractiveCardContent { + const { layout = true } = opts + const content = new InteractiveCardContent() + const card: Record = { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "stretch", + items: [ + { type: "RichTextBlock", inlines: [{ type: "TextRun", text: headerText }] }, + ], + }, + ], + }, + { type: "Container", id: "timeline_detail", items: [] }, + ], + } + if (layout) card.metadata = { octo_layout: "agent_progress_v1" } + content.card = card + return content +} + +function makeCardWrap(headerText: string): MessageWrap { + const message = new Message() + message.messageID = "card-1" + message.messageSeq = 1 + message.clientMsgNo = "card-1" + message.timestamp = 100 + message.fromUID = SENDER + message.channel = channel + message.status = MessageStatus.Normal + message.remoteExtra = new MessageExtra() + message.content = makeCardContent(headerText) + return new MessageWrap(message) +} + +function makeFinalTextWrap(): MessageWrap { + const message = new Message() + message.messageID = "text-1" + message.messageSeq = 2 + message.clientMsgNo = "text-1" + message.timestamp = 200 + message.fromUID = SENDER + message.channel = channel + message.status = MessageStatus.Normal + message.remoteExtra = new MessageExtra() + message.content = new MessageText("任务战报:全部完成") + return new MessageWrap(message) +} + +/** 建 VM,塞入一张卡 + 触发 final text 兜底,返回卡的 wrap(已 localFallbackApplied=true)。 */ +function primeFallback(header = "🤖 正在处理…"): { vm: any; cardWrap: MessageWrap } { + const vm: any = new ConversationVM(channel) + const cardWrap = makeCardWrap(header) + cardWrap.progressUpdatedAtSec = 0 // 远早于 now → 空闲足够 + vm.messages.push(cardWrap) + vm.messagesOfOrigin.push(cardWrap) + vm.maybeFinalizeStuckProgressCard(makeFinalTextWrap()) + return { vm, cardWrap } +} + +/** 造一条 MessageExtra(终态编辑帧 / 只读扩展)。 */ +function makeExtra(contentEdit?: InteractiveCardContent): MessageExtra { + const extra = new MessageExtra() + extra.messageID = "card-1" + if (contentEdit) { + extra.isEdit = true + extra.contentEdit = contentEdit + } + return extra +} + +afterEach(() => vi.clearAllMocks()) + +describe("WS-99 client fallback finalize race", () => { + it("final text 到达后把未终态卡标记为兜底", () => { + const { cardWrap } = primeFallback() + expect(cardWrap.localFallbackApplied).toBe(true) + }) + + it("权威终态帧(✅ 已完成,agent_progress)到达后撤回兜底标记", () => { + const { vm, cardWrap } = primeFallback() + expect(cardWrap.localFallbackApplied).toBe(true) + + vm.updateMessageByMessageExtras([makeExtra(makeCardContent("✅ 已完成 · 3 步"))]) + expect(cardWrap.localFallbackApplied).toBe(false) + }) + + it("终态+回答合并帧(剥离了 layout metadata)到达后也撤回兜底标记", () => { + const { vm, cardWrap } = primeFallback() + vm.updateMessageByMessageExtras([ + makeExtra(makeCardContent("✅ 已完成", { layout: false })), + ]) + expect(cardWrap.localFallbackApplied).toBe(false) + }) + + it("只读扩展(read receipt,无 contentEdit)不撤回兜底标记", () => { + const { vm, cardWrap } = primeFallback() + const readReceipt = makeExtra() // 无 contentEdit → 有效卡仍是原未终态卡 + readReceipt.readedCount = 3 + vm.updateMessageByMessageExtras([readReceipt]) + expect(cardWrap.localFallbackApplied).toBe(true) + }) + + it("仍为进行中的新帧(🤖)不误撤回兜底标记", () => { + const { vm, cardWrap } = primeFallback() + vm.updateMessageByMessageExtras([makeExtra(makeCardContent("🤖 正在处理…"))]) + expect(cardWrap.localFallbackApplied).toBe(true) + }) +}) diff --git a/packages/dmworkbase/src/Components/Conversation/vm.ts b/packages/dmworkbase/src/Components/Conversation/vm.ts index c2e89908b..54b51cb50 100644 --- a/packages/dmworkbase/src/Components/Conversation/vm.ts +++ b/packages/dmworkbase/src/Components/Conversation/vm.ts @@ -48,6 +48,12 @@ import { setImChannelSubscribersCache, syncImChannelSubscribers, } from "../../im-runtime/channelRuntime"; +import { resolveEffectiveCardContent } from "../../Messages/InteractiveCard/resolveContent"; +import { isAgentProgressCard } from "../../Messages/InteractiveCard/cardLayout"; +import { + isNonTerminalProgressCard, + isProgressCardIdleEnough, +} from "../../Messages/InteractiveCard/agentProgressFallback"; export interface FoldSessionParticipant { uid: string @@ -910,6 +916,8 @@ export default class ConversationVM extends ProviderListener { const messageWrap = new MessageWrap(message) this.fillOrder(messageWrap) + this.stampProgressCardArrival(messageWrap) + this.maybeFinalizeStuckProgressCard(messageWrap) this.appendMessage(messageWrap) } WKSDK.shared().chatManager.addMessageListener(this.messageListener) @@ -1429,6 +1437,87 @@ export default class ConversationVM extends ProviderListener { this.notifyListener() } + /** + * type=17 progress 卡「客户端兜底」——判定当前有效卡片的 AdaptiveCard 树。 + * 非 InteractiveCard / 非 agent_progress 卡返回 null。 + */ + private resolveProgressCard(messageWrap: MessageWrap): Record | null { + if (messageWrap.contentType !== MessageContentTypeConst.interactiveCard) { + return null + } + const content = messageWrap.content + if (!(content instanceof InteractiveCardContent)) { + return null + } + const effective = resolveEffectiveCardContent(content, messageWrap.message.remoteExtra) + return isAgentProgressCard(effective.card) ? effective.card : null + } + + /** 记录一张 progress 卡的最近帧到达时刻(创建或 patch),供 final-text 兜底做空闲判定。 */ + private stampProgressCardArrival(messageWrap: MessageWrap) { + if (this.resolveProgressCard(messageWrap)) { + messageWrap.progressUpdatedAtSec = Date.now() / 1000 + } + } + + /** + * 该 type=17 卡当前有效帧是否已「终态」——用于收到权威新帧后撤回本地兜底注解。 + * 终态 = 有效卡不再是「未终态 progress 卡」:要么命中终态标记(✅/⚠️/…),要么已不是 + * agent_progress 卡(如终态+回答合并帧剥离了 layout metadata)。只读扩展(read receipt 等, + * 无 contentEdit)不改变有效卡,仍判为非终态,不会误撤回兜底。 + */ + private progressCardFrameIsTerminal(messageWrap: MessageWrap): boolean { + if (messageWrap.contentType !== MessageContentTypeConst.interactiveCard) { + return false + } + const content = messageWrap.content + if (!(content instanceof InteractiveCardContent)) { + return false + } + const effective = resolveEffectiveCardContent(content, messageWrap.message.remoteExtra) + return !isNonTerminalProgressCard(effective.card) + } + + /** + * 收到某 assistant 的 type=1 final text 后的客户端兜底:若该 assistant 最近一张 type=17 卡是 + * 「未终态」的 progress 卡、且距其最后一次帧到达已空闲够久,则把它降级显示为「已完成(未收到 + * 显式终态)」。严格按 senderId 匹配「最近一张卡」,多助理并发不误伤别人;只叠加本地 UI 态, + * 不写回消息内容。发方补发终态(WS-97 Sub-1 根治)后不冲突:真终态帧会走正常渲染。 + */ + private maybeFinalizeStuckProgressCard(finalTextWrap: MessageWrap) { + if (finalTextWrap.contentType !== MessageContentType.text) return + if (finalTextWrap.send) return // 自己发的文本不触发 + if (finalTextWrap.message.streamNo) return // 流式分片非 final(正常已在上游早退,双保险) + const text = (finalTextWrap.content as MessageText)?.text + if (!text || !text.trim()) return + const senderId = finalTextWrap.fromUID + if (!senderId) return + + const finalAtSec = Date.now() / 1000 + for (let i = this.messagesOfOrigin.length - 1; i >= 0; i--) { + const m = this.messagesOfOrigin[i] + if (m.fromUID !== senderId) continue + if (m.contentType !== MessageContentTypeConst.interactiveCard) continue + // m = 该 assistant 最近一张 type=17 卡:只对这一张判定,不再往前看(对齐「最近一张」语义)。 + if (m.localFallbackApplied) return // 幂等 + const card = this.resolveProgressCard(m) + if ( + card && + isNonTerminalProgressCard(card) && + isProgressCardIdleEnough(m.progressUpdatedAtSec, finalAtSec) + ) { + m.localFallbackApplied = true + const idleSec = finalAtSec - (m.progressUpdatedAtSec ?? finalAtSec) + console.debug( + `[interactive-card] client fallback finalized stuck progress card sender=${senderId} messageID=${m.messageID} idleSec=${idleSec.toFixed(1)}` + ) + this.rebuildRenderItems() + this.notifyListener() + } + return + } + } + // 更新消息扩展数据 updateMessageByMessageExtras(messageExtras: MessageExtra[]) { if (!messageExtras || messageExtras.length == 0) { @@ -1440,6 +1529,12 @@ export default class ConversationVM extends ProviderListener { if (message) { message.message.remoteExtra = messageExtra message.resetParts() + this.stampProgressCardArrival(message) + // 权威终态帧到达即覆盖本地兜底注解:撤回「未收到显式终态」标记,让真实终态正常 + // 渲染,闭环 client fallback race(评审 blocker)。只读扩展不判为终态,不误撤回。 + if (message.localFallbackApplied && this.progressCardFrameIsTerminal(message)) { + message.localFallbackApplied = false + } } } this.notifyListener() diff --git a/packages/dmworkbase/src/Messages/InteractiveCard/InteractiveCardCell.tsx b/packages/dmworkbase/src/Messages/InteractiveCard/InteractiveCardCell.tsx index 03fd53f65..91e2f91eb 100644 --- a/packages/dmworkbase/src/Messages/InteractiveCard/InteractiveCardCell.tsx +++ b/packages/dmworkbase/src/Messages/InteractiveCard/InteractiveCardCell.tsx @@ -186,9 +186,10 @@ export class InteractiveCardCell extends MessageCell { this.renderedKey = null; return; } + const fallbackFinalized = this.isFallbackFinalized(decision.card); const key = `${decision.renderProfile}:${ decision.allowInteractive ? "v2" : "v1" - }:${JSON.stringify(decision.card)}`; + }:${fallbackFinalized ? "fb:" : ""}${JSON.stringify(decision.card)}`; if (key === this.renderedKey) return; this.renderedKey = key; // 新帧到达:作废在飞提交(响应/超时不再生效)并重置交互态(loading/错误/超时)。 @@ -205,6 +206,7 @@ export class InteractiveCardCell extends MessageCell { tableCopyLabel: t("base.message.interactiveCard.copyTable"), onTableCopy: (text) => this.handleTableCopy(text), renderProfile: decision.renderProfile, + fallbackFinalized, }); } catch { // 已过 octo 预校验仍渲染失败属极端边角 → fail-safe 渲纯文本(不走 markdown/HTML)。 @@ -223,6 +225,16 @@ export class InteractiveCardCell extends MessageCell { }); } + /** + * 该卡是否已被客户端兜底判定为「已完成(未收到显式终态)」。 + * 仅 agent_progress 卡 + VM 置了 localFallbackApplied 时为 true。 + */ + private isFallbackFinalized(card: Record): boolean { + return ( + !!this.props.message.localFallbackApplied && isAgentProgressCard(card) + ); + } + private enhanceMountedCard() { const target = this.cardMountRef.current; if (!target) return; @@ -235,6 +247,7 @@ export class InteractiveCardCell extends MessageCell { tableCopyLabel: t("base.message.interactiveCard.copyTable"), onTableCopy: (text) => this.handleTableCopy(text), renderProfile: decision.renderProfile, + fallbackFinalized: this.isFallbackFinalized(decision.card), }); } @@ -490,14 +503,28 @@ export class InteractiveCardCell extends MessageCell { ): React.ReactNode { switch (decision.kind) { case "card": { + const fallbackFinalized = this.isFallbackFinalized(decision.card); const cls = cardMountRootClass(decision.renderProfile) + (agentProgress ? " wk-interactive-card-sdk--agent-progress" : "") + + (fallbackFinalized + ? " wk-interactive-card-sdk--fallback-finalized" + : "") + (this.submitting ? " wk-interactive-card-sdk--submitting" : "") + // webhook 卡展示-only:输入置灰不可交互(提交侧另有 handleSubmit 双保险)。 (decision.interactive ? "" : " wk-interactive-card-sdk--readonly"); return ( <> + {fallbackFinalized && ( +
+ + {t("base.message.interactiveCard.fallbackFinalized")} + + + {t("base.message.interactiveCard.fallbackTag")} + +
+ )}
{this.submitError && (
diff --git a/packages/dmworkbase/src/Messages/InteractiveCard/__tests__/agentProgressFallback.dom.test.ts b/packages/dmworkbase/src/Messages/InteractiveCard/__tests__/agentProgressFallback.dom.test.ts new file mode 100644 index 000000000..934086618 --- /dev/null +++ b/packages/dmworkbase/src/Messages/InteractiveCard/__tests__/agentProgressFallback.dom.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { + AGENT_PROGRESS_FALLBACK_CLASS, + applyAgentProgressFallbackVisual, +} from "../sdk/agentProgressFallback"; + +describe("applyAgentProgressFallbackVisual", () => { + it("把运行中步骤的沙漏 ⏳ 换成 ⚠️ 并给 timeline 打标记类", () => { + const target = document.createElement("div"); + target.innerHTML = ` +
+
+
⏳ 执行命令:export
+
✅ 读取文件 · 1s
+
+
+ `; + + applyAgentProgressFallbackVisual(target); + + const timeline = target.querySelector("#timeline_detail"); + expect(timeline?.classList.contains(AGENT_PROGRESS_FALLBACK_CLASS)).toBe( + true + ); + expect(timeline?.textContent).toContain("⚠️ 执行命令"); + expect(timeline?.textContent).not.toContain("⏳"); + // 非运行中步骤保持不变 + expect(timeline?.textContent).toContain("✅ 读取文件"); + }); + + it("无 timeline 容器时安全 no-op", () => { + const target = document.createElement("div"); + target.innerHTML = `
纯文本
`; + expect(() => applyAgentProgressFallbackVisual(target)).not.toThrow(); + }); +}); diff --git a/packages/dmworkbase/src/Messages/InteractiveCard/__tests__/agentProgressFallback.test.ts b/packages/dmworkbase/src/Messages/InteractiveCard/__tests__/agentProgressFallback.test.ts new file mode 100644 index 000000000..7c830c821 --- /dev/null +++ b/packages/dmworkbase/src/Messages/InteractiveCard/__tests__/agentProgressFallback.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_PROGRESS_LAYOUT } from "../cardLayout"; +import { + CARD_FALLBACK_MIN_IDLE_SEC, + getProgressCardHeaderText, + isNonTerminalProgressCard, + isProgressCardIdleEnough, +} from "../agentProgressFallback"; + +/** 构造一张与 openclaw card-render 输出同形的 progress 卡(header 为 RichTextBlock)。 */ +function progressCard( + headerText: string, + opts: { layout?: boolean; rich?: boolean } = {} +): Record { + const { layout = true, rich = true } = opts; + const headerItem = rich + ? { + type: "RichTextBlock", + inlines: [{ type: "TextRun", text: headerText, weight: "Bolder" }], + } + : { type: "TextBlock", text: headerText, weight: "Bolder" }; + const card: Record = { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "ColumnSet", + columns: [{ type: "Column", width: "stretch", items: [headerItem] }], + }, + { type: "Container", id: "timeline_detail", items: [] }, + ], + }; + if (layout) card.metadata = { octo_layout: AGENT_PROGRESS_LAYOUT }; + return card; +} + +describe("getProgressCardHeaderText", () => { + it("从 RichTextBlock header 拼接文本", () => { + expect(getProgressCardHeaderText(progressCard("🤖 正在处理…"))).toBe( + "🤖 正在处理…" + ); + }); + + it("从 TextBlock header 取文本", () => { + expect( + getProgressCardHeaderText( + progressCard("✅ 已完成 · 3 步", { rich: false }) + ) + ).toBe("✅ 已完成 · 3 步"); + }); + + it("结构异常(无 ColumnSet)返回 null", () => { + expect( + getProgressCardHeaderText({ type: "AdaptiveCard", body: [] }) + ).toBeNull(); + expect(getProgressCardHeaderText({ type: "AdaptiveCard" })).toBeNull(); + }); +}); + +describe("isNonTerminalProgressCard", () => { + it("进行中 header(🤖 / ⏳)→ 触发", () => { + expect(isNonTerminalProgressCard(progressCard("🤖 正在处理…"))).toBe(true); + expect(isNonTerminalProgressCard(progressCard("🤖 思考中…"))).toBe(true); + expect(isNonTerminalProgressCard(progressCard("🤖 正在整理结果"))).toBe( + true + ); + expect(isNonTerminalProgressCard(progressCard("⏳ 执行命令"))).toBe(true); + }); + + it("终态 header(✅ / ⚠️ / ❌ / ⏹ / ⏱)→ 不触发", () => { + expect(isNonTerminalProgressCard(progressCard("✅ 已完成 · 3 步"))).toBe( + false + ); + expect(isNonTerminalProgressCard(progressCard("⚠️ 已中断:boom"))).toBe( + false + ); + expect(isNonTerminalProgressCard(progressCard("❌ 出错"))).toBe(false); + expect(isNonTerminalProgressCard(progressCard("⏹ 已停止"))).toBe(false); + expect(isNonTerminalProgressCard(progressCard("⏱️ 等待超时"))).toBe(false); + }); + + it("等待子任务(⏸️)不介入", () => { + expect(isNonTerminalProgressCard(progressCard("⏸️ 等待任务结果"))).toBe( + false + ); + }); + + it("非 agent_progress 卡不触发", () => { + expect( + isNonTerminalProgressCard(progressCard("🤖 正在处理…", { layout: false })) + ).toBe(false); + }); + + it("未识别 header 保守不触发", () => { + expect(isNonTerminalProgressCard(progressCard("普通标题"))).toBe(false); + }); +}); + +describe("isProgressCardIdleEnough", () => { + it("空闲 ≥ 阈值 → true", () => { + expect( + isProgressCardIdleEnough(100, 100 + CARD_FALLBACK_MIN_IDLE_SEC) + ).toBe(true); + expect(isProgressCardIdleEnough(100, 110)).toBe(true); + }); + + it("空闲 < 阈值(快速动作序列)→ false", () => { + expect(isProgressCardIdleEnough(100, 101)).toBe(false); + }); + + it("未记录 patch 时刻 → 保守 false", () => { + expect(isProgressCardIdleEnough(undefined, 100)).toBe(false); + }); +}); diff --git a/packages/dmworkbase/src/Messages/InteractiveCard/agentProgressFallback.ts b/packages/dmworkbase/src/Messages/InteractiveCard/agentProgressFallback.ts new file mode 100644 index 000000000..3bae1b009 --- /dev/null +++ b/packages/dmworkbase/src/Messages/InteractiveCard/agentProgressFallback.ts @@ -0,0 +1,100 @@ +import { isAgentProgressCard } from "./cardLayout"; + +/** + * 客户端兜底:进度卡「未终态」判定 + 触发时机。 + * + * 背景:agent 框架(openclaw / cc / hermes / codex)发出 type=17 进度卡后,正常应在收尾时把 + * 卡片切到终态(✅ 已完成 / ⚠️ 已中断)。发方漏发终态帧时卡片会永久停在「🤖 正在处理…」, + * 而同一 assistant 往往已发出 type=1 的 final text(战报/结论)。本模块提供纯判定,供 + * ConversationVM 在收到 final text 后决定是否把该 assistant 最近一张未终态卡降级显示。 + * + * 判定只依赖 octo card 协议里 progress 卡 header 的首字标记(见 openclaw card-render.ts + * `headerText`):非终态以 🤖 / ⏳ 起始;终态以 ✅ / ⚠️ / ❌ / ⏹ / ⏱ 起始;⏸ 表示「等待任务 + * 结果」(sessions_yield,故意保持非终态),兜底不介入。未识别的 header 一律保守视为「不触发」。 + */ + +/** 从卡最后一次 patch 到收到 final text 的最小空闲间隔(秒)。低于此值视为快速动作序列,不触发。 */ +export const CARD_FALLBACK_MIN_IDLE_SEC = 3; + +/** header 首字为这些标记之一 → 卡片正在进行中(可被兜底降级)。 */ +const ACTIVE_HEADER_MARKERS = ["🤖", "⏳"] as const; + +/** + * header 首字为这些标记之一 → 卡片已是终态或故意等待,兜底不介入。 + * ⏸ = 等待子任务结果(sessions_yield),不是「卡住」;⏱ = 等待超时(发方已给结论)。 + */ +const TERMINAL_HEADER_MARKERS = ["✅", "⚠️", "❌", "⏹", "⏱", "⏸"] as const; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** 从单个展示元素取文本:TextBlock 取 text;RichTextBlock 拼接 inlines[].text。 */ +function elementText(node: unknown): string { + const el = asRecord(node); + if (!el) return ""; + if (el.type === "TextBlock") { + return typeof el.text === "string" ? el.text : ""; + } + if (el.type === "RichTextBlock" && Array.isArray(el.inlines)) { + return el.inlines + .map((inline) => { + const run = asRecord(inline); + return run && typeof run.text === "string" ? run.text : ""; + }) + .join(""); + } + return ""; +} + +/** + * 取进度卡 header 文案。specialized layout 契约:`body[0]` 为 ColumnSet,其 `columns[0].items` + * 首个展示元素即 header(RichTextBlock 或 TextBlock)。结构异常时返回 null(保守,不触发)。 + */ +export function getProgressCardHeaderText( + card: Record +): string | null { + const body = card.body; + if (!Array.isArray(body)) return null; + const columnSet = body.find((node) => asRecord(node)?.type === "ColumnSet"); + const columns = asRecord(columnSet)?.columns; + if (!Array.isArray(columns)) return null; + const items = asRecord(columns[0])?.items; + if (!Array.isArray(items)) return null; + for (const item of items) { + const text = elementText(item).trim(); + if (text) return text; + } + return null; +} + +/** + * 该卡是否为「仍在进行中」的 agent progress 卡(可被 final-text 兜底降级)。 + * + * 严格保守:仅当确认是 agent_progress_v1 卡、且 header 首字命中进行中标记(🤖/⏳)且未命中任何 + * 终态/等待标记时返回 true;其余(终态、等待、未识别、非 progress 卡)一律返回 false。 + */ +export function isNonTerminalProgressCard( + card: Record +): boolean { + if (!isAgentProgressCard(card)) return false; + const header = getProgressCardHeaderText(card); + if (!header) return false; + if (TERMINAL_HEADER_MARKERS.some((m) => header.startsWith(m))) return false; + return ACTIVE_HEADER_MARKERS.some((m) => header.startsWith(m)); +} + +/** + * 距卡片最后一次 patch 是否已空闲够久,可触发兜底。 + * `cardUpdatedAtSec` 缺失(从未记录)时保守返回 false。 + */ +export function isProgressCardIdleEnough( + cardUpdatedAtSec: number | undefined, + finalTextAtSec: number, + minIdleSec: number = CARD_FALLBACK_MIN_IDLE_SEC +): boolean { + if (typeof cardUpdatedAtSec !== "number") return false; + return finalTextAtSec - cardUpdatedAtSec >= minIdleSec; +} diff --git a/packages/dmworkbase/src/Messages/InteractiveCard/index.css b/packages/dmworkbase/src/Messages/InteractiveCard/index.css index d2bf05118..bef346de4 100644 --- a/packages/dmworkbase/src/Messages/InteractiveCard/index.css +++ b/packages/dmworkbase/src/Messages/InteractiveCard/index.css @@ -306,6 +306,32 @@ color: var(--wk-color-danger, var(--wk-text-secondary)); } +/* ── 客户端兜底:收到 final text 后把同 assistant 未终态 progress 卡降级显示 ── + * 只在渲染层叠加提示 + tag(不改消息内容);运行中步骤的沙漏由 DOM 层换成 ⚠️。 */ +.wk-interactive-card-fallback { + display: flex; + align-items: center; + gap: var(--wk-sp-2, 8px); + margin-bottom: var(--wk-sp-1, 4px); + font-size: 12px; + color: var(--wk-color-warning, var(--wk-text-secondary)); +} + +.wk-interactive-card-fallback__tag { + padding: 0 var(--wk-sp-1, 4px); + border-radius: var(--wk-r-sm, 4px); + font-size: 11px; + line-height: 1.6; + color: var(--wk-text-secondary); + background: var(--wk-fill-secondary, transparent); + border: 1px solid var(--wk-border-subtle, transparent); +} + +/* 兜底后弱化进行中视觉(spinner/glow 收敛,与「已完成」语义一致)。 */ +.wk-interactive-card-sdk--fallback-finalized { + opacity: 0.92; +} + /* ── SDK(AdaptiveCards) 输出基线样式 ── * SDK 未引入默认样式表,按钮/输入本无样式;此处用 --wk-* token 补基线,待真机微调。 * 颜色/间距/字体等由 HostConfig 就地注入(inline),此处只补 SDK 未覆盖的控件外观。 */ diff --git a/packages/dmworkbase/src/Messages/InteractiveCard/sdk/agentProgressFallback.ts b/packages/dmworkbase/src/Messages/InteractiveCard/sdk/agentProgressFallback.ts new file mode 100644 index 000000000..6d82cf61c --- /dev/null +++ b/packages/dmworkbase/src/Messages/InteractiveCard/sdk/agentProgressFallback.ts @@ -0,0 +1,34 @@ +/** + * 兜底降级的 DOM 叠加:卡片被客户端判定为「已完成(未收到显式终态)」时,在 SDK 渲出的 + * progress 卡上就地把运行中步骤的沙漏 ⏳ 换成 ⚠️,并给 timeline 容器打标记类,供样式弱化 + * 进行中视觉。**只叠加展示,不改消息数据**;每次 SDK 重挂载后由 Cell 重新调用。 + */ + +const RUNNING_STEP_GLYPH = "⏳"; +const FALLBACK_STEP_GLYPH = "⚠️"; +export const AGENT_PROGRESS_FALLBACK_CLASS = + "wk-interactive-card-progress--fallback"; + +export function applyAgentProgressFallbackVisual(target: HTMLElement): void { + const timeline = target.querySelector("#timeline_detail"); + if (!timeline) return; + timeline.classList.add(AGENT_PROGRESS_FALLBACK_CLASS); + + const walker = target.ownerDocument.createTreeWalker( + timeline, + NodeFilter.SHOW_TEXT + ); + const pending: Text[] = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (node.nodeValue && node.nodeValue.includes(RUNNING_STEP_GLYPH)) { + pending.push(node as Text); + } + } + for (const textNode of pending) { + textNode.nodeValue = textNode + .nodeValue!.split(RUNNING_STEP_GLYPH) + .join(FALLBACK_STEP_GLYPH); + } +} + +export default applyAgentProgressFallbackVisual; diff --git a/packages/dmworkbase/src/Messages/InteractiveCard/sdk/renderOctoCard.ts b/packages/dmworkbase/src/Messages/InteractiveCard/sdk/renderOctoCard.ts index 9a64751f3..975894f6e 100644 --- a/packages/dmworkbase/src/Messages/InteractiveCard/sdk/renderOctoCard.ts +++ b/packages/dmworkbase/src/Messages/InteractiveCard/sdk/renderOctoCard.ts @@ -5,6 +5,7 @@ import { browserCssVarResolver, buildOctoHostConfig } from "./octoHostConfig"; import { createOctoSerializationContext } from "./octoSerialization"; import { sanitizeCardTree } from "./sanitizeCardTree"; import { enhanceAgentProgressLayout } from "./agentProgressLayout"; +import { applyAgentProgressFallbackVisual } from "./agentProgressFallback"; import { attachTableCopyButtons } from "./tableCopy"; import { FORGE_RENDER_PROFILE, @@ -43,6 +44,11 @@ export interface RenderOctoCardOptions { tableCopyLabel?: string; onTableCopy?: (text: string) => void; renderProfile?: ResolvedCardRenderProfile; + /** + * 客户端兜底:该 progress 卡已被判定为「已完成(未收到显式终态)」。置 true 时把运行中步骤的 + * 沙漏 ⏳ 就地换成 ⚠️ 并给 timeline 打标记类(见 applyAgentProgressFallbackVisual)。 + */ + fallbackFinalized?: boolean; } export function createCardHostConfig( @@ -55,8 +61,10 @@ export function createCardHostConfig( } export function enhanceRenderedOctoCard(options: RenderOctoCardOptions): void { - const { card, target, tableCopyLabel, onTableCopy } = options; + const { card, target, tableCopyLabel, onTableCopy, fallbackFinalized } = + options; enhanceAgentProgressLayout(card, target); + if (fallbackFinalized) applyAgentProgressFallbackVisual(target); if (tableCopyLabel && onTableCopy) { attachTableCopyButtons({ card, @@ -75,6 +83,7 @@ export function renderOctoCard(options: RenderOctoCardOptions): void { tableCopyLabel, onTableCopy, renderProfile = "legacy", + fallbackFinalized, } = options; ensureMarkdownHook(); const ac = new AdaptiveCard(); @@ -92,6 +101,7 @@ export function renderOctoCard(options: RenderOctoCardOptions): void { onAction, tableCopyLabel, onTableCopy, + fallbackFinalized, }); } diff --git a/packages/dmworkbase/src/Service/Model.tsx b/packages/dmworkbase/src/Service/Model.tsx index 7dedc3e77..be102b750 100644 --- a/packages/dmworkbase/src/Service/Model.tsx +++ b/packages/dmworkbase/src/Service/Model.tsx @@ -220,6 +220,11 @@ export class MessageWrap { public message: Message public checked!: boolean // 是否选中 public locateRemind?: boolean // 定位到消息后是否需要提醒 + // ── type=17 progress 卡客户端兜底(本地 UI 态,不写回消息内容/服务端)── + /** 该 progress 卡最近一次帧到达(创建或 patch)的本地时刻(秒)。用于 final-text 兜底的空闲判定。 */ + public progressUpdatedAtSec?: number + /** 已被 final-text 兜底判定为「已完成(未收到显式终态)」,渲染层据此降级显示。 */ + public localFallbackApplied?: boolean constructor(message: Message) { this.message = message this.order = message.messageSeq > 0 ? message.messageSeq * OrderFactor : 0 diff --git a/packages/dmworkbase/src/i18n/locales/en-US.json b/packages/dmworkbase/src/i18n/locales/en-US.json index b996eca91..f188e6444 100644 --- a/packages/dmworkbase/src/i18n/locales/en-US.json +++ b/packages/dmworkbase/src/i18n/locales/en-US.json @@ -979,6 +979,8 @@ "message.interactiveCard.copySuccess": "Copied", "message.interactiveCard.copyFailed": "Copy failed", "message.interactiveCard.copyTable": "Copy", + "message.interactiveCard.fallbackFinalized": "⚠️ Completed (no terminal update received)", + "message.interactiveCard.fallbackTag": "Client fallback", "message.interactiveCard.denyDialog.title": "Deny document access request", "message.interactiveCard.denyDialog.descWithContext": "Once denied, {{actor}} will not get view access to \"{{title}}\".", "message.interactiveCard.denyDialog.desc": "Once denied, the requester will not get view access to this document.", diff --git a/packages/dmworkbase/src/i18n/locales/zh-CN.json b/packages/dmworkbase/src/i18n/locales/zh-CN.json index 47603e081..9ea4f4841 100644 --- a/packages/dmworkbase/src/i18n/locales/zh-CN.json +++ b/packages/dmworkbase/src/i18n/locales/zh-CN.json @@ -979,6 +979,8 @@ "message.interactiveCard.copySuccess": "已复制", "message.interactiveCard.copyFailed": "复制失败", "message.interactiveCard.copyTable": "复制", + "message.interactiveCard.fallbackFinalized": "⚠️ 已完成(未收到显式终态)", + "message.interactiveCard.fallbackTag": "兜底触发", "message.interactiveCard.denyDialog.title": "拒绝文档访问申请", "message.interactiveCard.denyDialog.descWithContext": "拒绝后,{{actor}} 将无法获得“{{title}}”的查看权限。", "message.interactiveCard.denyDialog.desc": "拒绝后,申请人将无法获得该文档的查看权限。",