Download src/channels/plugins/outbound/presentation-limits.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 20.3 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/channels/plugins/outbound/presentation-limits.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/channels/plugins/outbound/presentation-limits.ts
-
curl -L -o presentation-limits.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/channels/plugins/outbound/presentation-limits.ts
20.3 kB
| /** | |
| * Presentation limit adapters for channel outbound payloads. | |
| * | |
| * Splits text and reshapes portable controls to match per-channel limits. | |
| */ | |
| import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; | |
| import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; | |
| import { | |
| renderMessagePresentationChartFallbackText, | |
| renderMessagePresentationControlFallbackLabel, | |
| renderMessagePresentationTableFallbackText, | |
| resolveMessagePresentationActionValue, | |
| resolveMessagePresentationButtonAction, | |
| resolveMessagePresentationOptionAction, | |
| } from "../../../interactive/payload.js"; | |
| import type { | |
| MessagePresentation, | |
| MessagePresentationBlock, | |
| MessagePresentationButton, | |
| MessagePresentationOption, | |
| } from "../../../interactive/payload.js"; | |
| import type { ChannelPresentationCapabilities } from "../outbound.types.js"; | |
| type ActionLimits = NonNullable<NonNullable<ChannelPresentationCapabilities["limits"]>["actions"]>; | |
| type SelectLimits = NonNullable<NonNullable<ChannelPresentationCapabilities["limits"]>["selects"]>; | |
| type TextLimits = NonNullable<NonNullable<ChannelPresentationCapabilities["limits"]>["text"]>; | |
| type ActionBudget = { | |
| remainingActions?: number; | |
| remainingRows?: number; | |
| maxActionsPerRow?: number; | |
| }; | |
| type ButtonCandidate = { | |
| original: MessagePresentationButton; | |
| adapted?: MessagePresentationButton; | |
| }; | |
| type SelectCandidate = { | |
| original: MessagePresentationOption; | |
| adapted?: MessagePresentationOption; | |
| }; | |
| type ButtonSelection = ReadonlySet<MessagePresentationButton> | undefined; | |
| const PRESENTATION_FALLBACK_CONTINUATION = Symbol.for( | |
| "openclaw.presentation.fallback-continuation", | |
| ); | |
| function positiveInteger(value: number | undefined): number | undefined { | |
| return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; | |
| } | |
| function truncateText(value: string, maxLength: number | undefined): string { | |
| const limit = positiveInteger(maxLength); | |
| if (!limit || value.length <= limit) { | |
| return value; | |
| } | |
| // A code point uses at most two UTF-16 units; later units cannot affect this prefix. | |
| return Array.from(value.slice(0, limit * 2)) | |
| .slice(0, limit) | |
| .join(""); | |
| } | |
| function truncateUtf8Bytes(value: string, limit: number): string { | |
| let bytes = 0; | |
| let result = ""; | |
| for (const char of value) { | |
| const nextBytes = utf8ByteLength(char); | |
| if (bytes + nextBytes > limit) { | |
| break; | |
| } | |
| bytes += nextBytes; | |
| result += char; | |
| } | |
| return result; | |
| } | |
| function truncatePresentationText(value: string, limits: TextLimits | undefined): string { | |
| const limit = positiveInteger(limits?.maxLength); | |
| if (!limit) { | |
| return value; | |
| } | |
| if (limits?.encoding === "utf8-bytes") { | |
| return truncateUtf8Bytes(value, limit); | |
| } | |
| if (limits?.encoding === "utf16-units") { | |
| return truncateUtf16Safe(value, limit); | |
| } | |
| return truncateText(value, limit); | |
| } | |
| function splitPresentationText(value: string, limits: TextLimits | undefined): string[] { | |
| const limit = positiveInteger(limits?.maxLength); | |
| if (!limit || truncatePresentationText(value, limits) === value) { | |
| return [value]; | |
| } | |
| const chunks: string[] = []; | |
| let remaining = value; | |
| while (remaining) { | |
| const prefix = truncatePresentationText(remaining, limits); | |
| if (!prefix || prefix === remaining) { | |
| chunks.push(remaining); | |
| break; | |
| } | |
| // Keep complete fallback lines together when possible. Retain the newline | |
| // itself so concatenating the blocks reconstructs every authored character. | |
| const newlineIndex = prefix.lastIndexOf("\n"); | |
| const splitIndex = newlineIndex > 0 ? newlineIndex + 1 : prefix.length; | |
| chunks.push(remaining.slice(0, splitIndex)); | |
| remaining = remaining.slice(splitIndex); | |
| } | |
| return chunks; | |
| } | |
| function presentationTextBlocks(params: { | |
| blockType: "context" | "text"; | |
| text: string; | |
| limits?: TextLimits; | |
| continuation?: boolean; | |
| }): Array<{ type: "context" | "text"; text: string }> { | |
| return splitPresentationText(params.text, params.limits).map((text, index) => { | |
| const block = { type: params.blockType, text }; | |
| if (index > 0 || params.continuation) { | |
| // Native fallback renderers must reassemble split fragments without inserting paragraph breaks. | |
| Object.defineProperty(block, PRESENTATION_FALLBACK_CONTINUATION, { value: true }); | |
| } | |
| return block; | |
| }); | |
| } | |
| function utf8ByteLength(value: string): number { | |
| return Buffer.byteLength(value, "utf8"); | |
| } | |
| function fitsByteLimit(value: string | undefined, maxBytes: number | undefined): boolean { | |
| const limit = positiveInteger(maxBytes); | |
| return !value || !limit || utf8ByteLength(value) <= limit; | |
| } | |
| function fallbackListBlocks(params: { | |
| blockType: "context" | "text"; | |
| heading: string; | |
| labels: readonly string[]; | |
| limits?: TextLimits; | |
| }): MessagePresentationBlock[] { | |
| const labels = normalizeStringEntries(params.labels); | |
| if (labels.length === 0) { | |
| return []; | |
| } | |
| // Action labels are operator-visible content; split like chart/table fallbacks instead of dropping them. | |
| return presentationTextBlocks({ | |
| blockType: params.blockType, | |
| text: `${params.heading}:\n${labels.map((label) => `- ${label}`).join("\n")}`, | |
| limits: params.limits, | |
| }); | |
| } | |
| function createActionBudget(limits: ActionLimits | undefined): ActionBudget { | |
| return { | |
| remainingActions: positiveInteger(limits?.maxActions), | |
| remainingRows: positiveInteger(limits?.maxRows), | |
| maxActionsPerRow: positiveInteger(limits?.maxActionsPerRow), | |
| }; | |
| } | |
| function buttonCapacity(budget: ActionBudget): number | undefined { | |
| if (budget.remainingActions === 0 || budget.remainingRows === 0) { | |
| return 0; | |
| } | |
| const rowCapacity = | |
| budget.remainingRows && budget.maxActionsPerRow | |
| ? budget.remainingRows * budget.maxActionsPerRow | |
| : undefined; | |
| if (budget.remainingActions !== undefined && rowCapacity !== undefined) { | |
| return Math.min(budget.remainingActions, rowCapacity); | |
| } | |
| return budget.remainingActions ?? rowCapacity; | |
| } | |
| function consumeButtonBudget(budget: ActionBudget, count: number): void { | |
| if (count <= 0) { | |
| return; | |
| } | |
| if (budget.remainingActions !== undefined) { | |
| budget.remainingActions = Math.max(0, budget.remainingActions - count); | |
| } | |
| if (budget.remainingRows !== undefined) { | |
| const perRow = budget.maxActionsPerRow ?? count; | |
| budget.remainingRows = Math.max(0, budget.remainingRows - Math.ceil(count / perRow)); | |
| } | |
| } | |
| function chunkButtons( | |
| buttons: readonly MessagePresentationButton[], | |
| maxActionsPerRow: number | undefined, | |
| ): MessagePresentationButton[][] { | |
| const rowSize = positiveInteger(maxActionsPerRow); | |
| if (!rowSize) { | |
| return buttons.length > 0 ? [[...buttons]] : []; | |
| } | |
| const rows: MessagePresentationButton[][] = []; | |
| for (let index = 0; index < buttons.length; index += rowSize) { | |
| rows.push(buttons.slice(index, index + rowSize)); | |
| } | |
| return rows; | |
| } | |
| function hasActionSlotBudget(budget: ActionBudget): boolean { | |
| return budget.remainingActions !== 0 && budget.remainingRows !== 0; | |
| } | |
| function consumeSelectBudget(budget: ActionBudget, count = 1): void { | |
| if (budget.remainingActions !== undefined) { | |
| budget.remainingActions = Math.max(0, budget.remainingActions - count); | |
| } | |
| if (budget.remainingRows !== undefined) { | |
| budget.remainingRows = Math.max(0, budget.remainingRows - count); | |
| } | |
| } | |
| function adaptButton( | |
| button: MessagePresentationButton, | |
| limits: ActionLimits | undefined, | |
| ): MessagePresentationButton | undefined { | |
| const hasExplicitAction = button.action !== undefined; | |
| const action = resolveMessagePresentationButtonAction(button); | |
| if (!action) { | |
| return undefined; | |
| } | |
| const actionValue = resolveMessagePresentationActionValue(action); | |
| const actionFits = actionValue === undefined || fitsByteLimit(actionValue, limits?.maxValueBytes); | |
| const legacyValueFits = fitsByteLimit(button.value, limits?.maxValueBytes); | |
| if ( | |
| (hasExplicitAction ? !actionFits : action.type === "callback" && !legacyValueFits) || | |
| (button.disabled === true && limits?.supportsDisabled !== true) | |
| ) { | |
| return undefined; | |
| } | |
| const adapted: MessagePresentationButton = { | |
| ...button, | |
| label: truncateText(button.label, limits?.maxLabelLength), | |
| }; | |
| if (!legacyValueFits) { | |
| delete adapted.value; | |
| } | |
| if (limits?.supportsStyles === false) { | |
| delete adapted.style; | |
| } | |
| return adapted; | |
| } | |
| function adaptButtonsBlock( | |
| block: Extract<MessagePresentationBlock, { type: "buttons" }>, | |
| limits: ActionLimits | undefined, | |
| budget: ActionBudget, | |
| fallbackBlockType: "context" | "text", | |
| buttonSelection: ButtonSelection, | |
| textLimits?: TextLimits, | |
| ): MessagePresentationBlock[] { | |
| const capacity = buttonCapacity(budget); | |
| const candidates: ButtonCandidate[] = block.buttons.map((button) => ({ | |
| original: button, | |
| adapted: adaptButton(button, limits), | |
| })); | |
| const renderableCandidates = candidates.filter( | |
| (candidate): candidate is ButtonCandidate & { adapted: MessagePresentationButton } => | |
| Boolean(candidate.adapted), | |
| ); | |
| const eligibleCandidates = buttonSelection | |
| ? renderableCandidates.filter((candidate) => buttonSelection.has(candidate.original)) | |
| : renderableCandidates; | |
| const selectedCandidates = | |
| capacity !== undefined && eligibleCandidates.length > capacity | |
| ? eligibleCandidates | |
| .map((candidate, index) => ({ candidate, index })) | |
| .toSorted((left, right) => { | |
| const priorityDelta = | |
| (right.candidate.adapted.priority ?? 0) - (left.candidate.adapted.priority ?? 0); | |
| return priorityDelta || left.index - right.index; | |
| }) | |
| .slice(0, capacity) | |
| .map((entry) => entry.candidate) | |
| : eligibleCandidates; | |
| const selected = new Set<ButtonCandidate>(selectedCandidates); | |
| const buttons = selectedCandidates.map((candidate) => candidate.adapted); | |
| const droppedLabels = candidates | |
| .filter((candidate) => !candidate.adapted || !selected.has(candidate)) | |
| .map((candidate) => renderMessagePresentationControlFallbackLabel(candidate.original)); | |
| consumeButtonBudget(budget, buttons.length); | |
| const fallback = fallbackListBlocks({ | |
| blockType: fallbackBlockType, | |
| heading: "Actions", | |
| labels: droppedLabels, | |
| limits: textLimits, | |
| }); | |
| if (buttons.length === 0) { | |
| return fallback; | |
| } | |
| const blocks: MessagePresentationBlock[] = chunkButtons(buttons, limits?.maxActionsPerRow).map( | |
| (row) => ({ | |
| type: "buttons", | |
| buttons: row, | |
| }), | |
| ); | |
| blocks.push(...fallback); | |
| return blocks; | |
| } | |
| function adaptOption( | |
| option: MessagePresentationOption, | |
| limits: SelectLimits | undefined, | |
| ): MessagePresentationOption | undefined { | |
| const hasExplicitAction = option.action !== undefined; | |
| const action = resolveMessagePresentationOptionAction(option); | |
| if (!action) { | |
| return undefined; | |
| } | |
| const actionValue = resolveMessagePresentationActionValue(action); | |
| const actionFits = actionValue === undefined || fitsByteLimit(actionValue, limits?.maxValueBytes); | |
| const legacyValueFits = fitsByteLimit(option.value, limits?.maxValueBytes); | |
| if (hasExplicitAction ? !actionFits : !legacyValueFits) { | |
| return undefined; | |
| } | |
| const adapted: MessagePresentationOption = { | |
| ...option, | |
| label: truncateText(option.label, limits?.maxLabelLength), | |
| }; | |
| if (!legacyValueFits) { | |
| delete adapted.value; | |
| } | |
| return adapted; | |
| } | |
| function adaptSelectBlock( | |
| block: Extract<MessagePresentationBlock, { type: "select" }>, | |
| limits: SelectLimits | undefined, | |
| budget: ActionBudget, | |
| fallbackBlockType: "context" | "text", | |
| textLimits?: TextLimits, | |
| ): MessagePresentationBlock[] { | |
| const candidates: SelectCandidate[] = block.options.map((option) => ({ | |
| original: option, | |
| adapted: adaptOption(option, limits), | |
| })); | |
| const renderableCandidates = candidates.filter( | |
| (candidate): candidate is SelectCandidate & { adapted: MessagePresentationOption } => | |
| Boolean(candidate.adapted), | |
| ); | |
| const maxOptions = positiveInteger(limits?.maxOptions); | |
| const selectedCandidates = maxOptions | |
| ? renderableCandidates.slice(0, maxOptions) | |
| : renderableCandidates; | |
| const selected = new Set<SelectCandidate>(selectedCandidates); | |
| const options = selectedCandidates.map((candidate) => candidate.adapted); | |
| const canRenderSelect = options.length > 0 && hasActionSlotBudget(budget); | |
| const fallback = fallbackListBlocks({ | |
| blockType: fallbackBlockType, | |
| heading: block.placeholder ?? "Options", | |
| labels: (canRenderSelect | |
| ? candidates.filter((candidate) => !candidate.adapted || !selected.has(candidate)) | |
| : candidates | |
| ).map((candidate) => renderMessagePresentationControlFallbackLabel(candidate.original)), | |
| limits: textLimits, | |
| }); | |
| if (!canRenderSelect) { | |
| return fallback; | |
| } | |
| consumeSelectBudget(budget); | |
| const blocks: MessagePresentationBlock[] = [ | |
| { | |
| type: "select", | |
| ...(block.placeholder | |
| ? { placeholder: truncateText(block.placeholder, limits?.maxLabelLength) } | |
| : {}), | |
| options, | |
| }, | |
| ]; | |
| blocks.push(...fallback); | |
| return blocks; | |
| } | |
| function countRenderableSelectBlocks( | |
| blocks: readonly MessagePresentationBlock[], | |
| capabilities: ChannelPresentationCapabilities | undefined, | |
| limits: SelectLimits | undefined, | |
| ): number { | |
| if (capabilities?.selects === false) { | |
| return 0; | |
| } | |
| let count = 0; | |
| for (const block of blocks) { | |
| // A valid maxOptions is at least one, so one accepted option reserves the slot. | |
| if (block.type === "select" && block.options.some((option) => adaptOption(option, limits))) { | |
| count += 1; | |
| } | |
| } | |
| return count; | |
| } | |
| function createGlobalButtonSelection(params: { | |
| presentation: MessagePresentation; | |
| capabilities: ChannelPresentationCapabilities | undefined; | |
| limits: ActionLimits | undefined; | |
| selectLimits: SelectLimits | undefined; | |
| }): ButtonSelection { | |
| if (params.capabilities?.buttons === false) { | |
| return undefined; | |
| } | |
| const reservationBudget = createActionBudget(params.limits); | |
| consumeSelectBudget( | |
| reservationBudget, | |
| countRenderableSelectBlocks( | |
| params.presentation.blocks, | |
| params.capabilities, | |
| params.selectLimits, | |
| ), | |
| ); | |
| // Without a per-row size, row-only limits follow authored block order rather than preselecting buttons. | |
| const capacity = | |
| reservationBudget.remainingRows === 0 && reservationBudget.maxActionsPerRow === undefined | |
| ? reservationBudget.remainingActions | |
| : buttonCapacity(reservationBudget); | |
| if (capacity === undefined) { | |
| return undefined; | |
| } | |
| const candidates = params.presentation.blocks.flatMap((block) => { | |
| if (block.type !== "buttons") { | |
| return []; | |
| } | |
| return block.buttons | |
| .map((button) => ({ | |
| original: button, | |
| adapted: adaptButton(button, params.limits), | |
| })) | |
| .filter( | |
| ( | |
| candidate, | |
| ): candidate is { | |
| original: MessagePresentationButton; | |
| adapted: MessagePresentationButton; | |
| } => Boolean(candidate.adapted), | |
| ); | |
| }); | |
| if (candidates.length <= capacity) { | |
| return undefined; | |
| } | |
| return new Set( | |
| candidates | |
| .map((candidate, index) => ({ candidate, index })) | |
| .toSorted((left, right) => { | |
| const priorityDelta = | |
| (right.candidate.adapted.priority ?? 0) - (left.candidate.adapted.priority ?? 0); | |
| return priorityDelta || left.index - right.index; | |
| }) | |
| .slice(0, capacity) | |
| .map((entry) => entry.candidate.original), | |
| ); | |
| } | |
| /** | |
| * Adapt a portable presentation to the target channel's advertised capabilities. | |
| * | |
| * Unsupported controls are downgraded to text/context fallback blocks where possible, and | |
| * controls honor channel limits while authored and fallback text retain every character. | |
| */ | |
| export function adaptMessagePresentationForChannel(params: { | |
| presentation: MessagePresentation; | |
| capabilities?: ChannelPresentationCapabilities; | |
| }): MessagePresentation { | |
| const capabilities = params.capabilities; | |
| const limits = params.capabilities?.limits; | |
| const actionBudget = createActionBudget(limits?.actions); | |
| const fallbackBlockType = capabilities?.context === false ? "text" : "context"; | |
| const buttonSelection = createGlobalButtonSelection({ | |
| presentation: params.presentation, | |
| capabilities, | |
| limits: limits?.actions, | |
| selectLimits: limits?.selects, | |
| }); | |
| const titleBlocks = params.presentation.title | |
| ? presentationTextBlocks({ | |
| blockType: "text", | |
| text: params.presentation.title, | |
| limits: limits?.text, | |
| }) | |
| : []; | |
| const blocks: MessagePresentationBlock[] = titleBlocks.slice(1); | |
| for (const block of params.presentation.blocks) { | |
| if (block.type === "text" || block.type === "context") { | |
| blocks.push( | |
| ...presentationTextBlocks({ | |
| blockType: block.type === "context" ? fallbackBlockType : "text", | |
| text: block.text, | |
| limits: limits?.text, | |
| continuation: | |
| Object.getOwnPropertyDescriptor(block, PRESENTATION_FALLBACK_CONTINUATION)?.value === | |
| true, | |
| }), | |
| ); | |
| continue; | |
| } | |
| if (block.type === "chart" && capabilities?.charts !== true) { | |
| blocks.push( | |
| ...presentationTextBlocks({ | |
| blockType: fallbackBlockType, | |
| text: renderMessagePresentationChartFallbackText(block), | |
| limits: limits?.text, | |
| }), | |
| ); | |
| continue; | |
| } | |
| if (block.type === "table" && capabilities?.tables !== true) { | |
| blocks.push( | |
| ...presentationTextBlocks({ | |
| blockType: fallbackBlockType, | |
| text: renderMessagePresentationTableFallbackText(block), | |
| limits: limits?.text, | |
| }), | |
| ); | |
| continue; | |
| } | |
| if (block.type === "buttons") { | |
| if (capabilities?.buttons === false) { | |
| blocks.push( | |
| ...fallbackListBlocks({ | |
| blockType: fallbackBlockType, | |
| heading: "Actions", | |
| labels: block.buttons.map(renderMessagePresentationControlFallbackLabel), | |
| limits: limits?.text, | |
| }), | |
| ); | |
| continue; | |
| } | |
| blocks.push( | |
| ...adaptButtonsBlock( | |
| block, | |
| limits?.actions, | |
| actionBudget, | |
| fallbackBlockType, | |
| buttonSelection, | |
| limits?.text, | |
| ), | |
| ); | |
| continue; | |
| } | |
| if (block.type === "select") { | |
| if (capabilities?.selects === false) { | |
| blocks.push( | |
| ...fallbackListBlocks({ | |
| blockType: fallbackBlockType, | |
| heading: block.placeholder ?? "Options", | |
| labels: block.options.map(renderMessagePresentationControlFallbackLabel), | |
| limits: limits?.text, | |
| }), | |
| ); | |
| continue; | |
| } | |
| blocks.push( | |
| ...adaptSelectBlock(block, limits?.selects, actionBudget, fallbackBlockType, limits?.text), | |
| ); | |
| continue; | |
| } | |
| if (block.type === "divider" && capabilities?.divider === false) { | |
| continue; | |
| } | |
| blocks.push(block); | |
| } | |
| return { | |
| ...params.presentation, | |
| ...(params.presentation.title ? { title: titleBlocks[0]?.text } : {}), | |
| blocks, | |
| }; | |
| } | |
| /** Return the subset of buttons that can still be rendered under action limits. */ | |
| export function applyPresentationActionLimits( | |
| buttons: readonly MessagePresentationButton[], | |
| capabilities?: ChannelPresentationCapabilities, | |
| ): MessagePresentationButton[] { | |
| const block = adaptButtonsBlock( | |
| { type: "buttons", buttons: [...buttons] }, | |
| capabilities?.limits?.actions, | |
| createActionBudget(capabilities?.limits?.actions), | |
| capabilities?.context === false ? "text" : "context", | |
| undefined, | |
| capabilities?.limits?.text, | |
| ); | |
| return block.flatMap((entry) => (entry.type === "buttons" ? entry.buttons : [])); | |
| } | |
| /** Resolve an action page size that leaves room for reserved actions on the target channel. */ | |
| export function presentationPageSize( | |
| capabilities?: ChannelPresentationCapabilities, | |
| reservedActions = 0, | |
| maxPageSize = Number.POSITIVE_INFINITY, | |
| ): number { | |
| const capacity = buttonCapacity(createActionBudget(capabilities?.limits?.actions)); | |
| const remaining = Math.max(0, (capacity ?? maxPageSize) - Math.max(0, reservedActions)); | |
| return Math.max(1, Math.min(remaining || 1, maxPageSize)); | |
| } | |