blob: 17ffe94d533e019e10cda7b9bbd2bc7616a676c1 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* PR-UI-C0 review fixup (@kenji msg 7885a347) — pure trust-boundary
* helper for the Anthropic extended-thinking stream the renderer
* accumulates from `ThinkingDeltaEvent` / `ThinkingCompleteEvent`.
*
* The original C0 implementation appended `event.text` directly
* into the live-turn projection and rendered with
* `<pre>{thinkingText}</pre>` — no Markdown, no redaction, no size
* cap. Two trust-boundary failures: (1) model thinking output can
* echo prompts / env / tool stderr / pasted credentials, so the
* raw text must NOT enter React state without secondary
* `redactSecrets`; (2) extended thinking can stream tens or
* hundreds of KB, and `<pre>` `max-height: 320px` only bounds
* VISUAL height, not the DOM text length / React state / DevTools
* snapshot.
*
* This module mirrors the A3 `tool-output-stream` shape exactly:
* - pure helpers `applyThinkingDelta` / `applyThinkingComplete`
* - per-chunk cap (tail-keep with marker)
* - per-session total cap (tail-keep — thinking is sequential;
* oldest is least relevant for the user observing live
* reasoning)
* - secondary `redactSecrets` BEFORE state, with `redacted`
* monotonic (upstream claim survives; renderer can only
* escalate)
*
* The renderer stores both the accumulated text AND a per-session
* monotonic `truncated` flag so the UI can show a "已截断" pill
* in the `ReasoningPanel` header.
*/
import { redactSecrets } from './redact.js';
import {
appendStreamingDisplayRedaction,
createStreamingDisplayRedactionState,
truncateStreamingDisplayAppend,
truncateStreamingDisplayTail,
type StreamingDisplayRedactionState,
} from './streaming-display-redaction.js';
import type { UiLocale } from '@maka/core/ui-locale';
import { getSharedUiCopy } from './shared-ui-copy.js';
/**
* Default caps. Tuned to:
* - 4 KB per single delta: matches A3 tool-output's per-chunk
* cap and the runtime's `TOOL_OUTPUT_DELTA_MAX_CHARS`.
* - 32 KB total per session: thinking can run longer than tool
* stream (multiple paragraphs of reasoning before the answer),
* so 2× A3's per-tool cap. Above this we tail-keep so the
* "most recent" reasoning is what the user sees scrolling.
*/
export const THINKING_MAX_DELTA_CHARS = 4 * 1024;
export const THINKING_MAX_TOTAL_CHARS = 32 * 1024;
export interface ApplyThinkingOptions {
/** Override per-delta cap. */
maxDeltaChars?: number;
/** Override per-session total cap. */
maxTotalChars?: number;
/** Resolved UI locale for user-visible truncation markers. */
locale?: UiLocale;
/** Differential-safe state returned by the preceding delta. */
redactionState?: StreamingDisplayRedactionState;
}
export interface ApplyThinkingResult {
/** Resulting accumulated thinking text (post-redaction, post-cap). */
text: string;
/** True if any redaction happened during this call. */
redacted: boolean;
/** True if any drop / truncation happened during this call. */
truncated: boolean;
/** Bounded state needed to keep later prefixes oracle-equivalent. */
redactionState?: StreamingDisplayRedactionState;
}
/**
* Apply a single `thinking_delta` to the prior accumulated text.
* Pure: no React state, no DOM, no IPC.
*
* 1. Append through the differential-safe line/suffix redactor.
* 2. If the delta alone is oversized, cap the already-safe mutable suffix.
* 3. If the result exceeds `maxTotalChars`, tail-keep the most
* recent `maxTotalChars` characters with a head marker.
* Thinking is sequential reasoning; the user is looking at
* the CURRENT chain of thought, not the start.
*/
export function applyThinkingDelta(
prev: string,
rawDelta: string,
options: ApplyThinkingOptions = {},
): ApplyThinkingResult {
const maxDelta = options.maxDeltaChars ?? THINKING_MAX_DELTA_CHARS;
const maxTotal = options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS;
const copy = getSharedUiCopy(options.locale ?? 'zh').stream;
const truncatedHeadMarker = copy.thinkingHeadTruncated;
const truncatedChunkMarker = copy.thinkingChunkTruncated;
const previousText = prev ?? '';
// Defensive guard: a non-string `rawDelta` is a runtime contract
// violation. Drop it silently rather than coerce to '' and claim
// redaction happened.
if (typeof rawDelta !== 'string') {
return {
text: prev ?? '',
redacted: false,
truncated: false,
...(options.redactionState === undefined
? {}
: { redactionState: options.redactionState }),
};
}
const redactionState = options.redactionState ?? appendStreamingDisplayRedaction(
'',
previousText,
createStreamingDisplayRedactionState({
maxRecoveryChars: maxTotal + 1,
recovery: 'tail',
}),
).state;
// Oversize deltas retain redact-before-truncate. Normal deltas remain raw
// until the line-aware append so every streamed prefix can match the oracle.
const redactedDelta = redactSecrets(rawDelta);
const redactionHappened = redactedDelta !== rawDelta;
// L2: per-delta cap. Tail-keep with marker prepended.
let deltaTruncated = false;
const rawAppended = appendStreamingDisplayRedaction(
previousText,
rawDelta,
redactionState,
);
const appended = redactedDelta.length > maxDelta
? truncateStreamingDisplayAppend(
previousText,
rawAppended,
maxDelta,
truncatedChunkMarker,
)
: rawAppended;
deltaTruncated = appended !== rawAppended;
// L4: per-session total cap. Tail-keep most recent.
let result = appended.text;
let totalTruncated = false;
let capped = appended;
if (result.length > maxTotal) {
capped = truncateStreamingDisplayTail(appended, maxTotal, truncatedHeadMarker);
result = capped.text;
totalTruncated = true;
}
return {
text: result,
redacted: redactionHappened || appended.redacted,
truncated: deltaTruncated || totalTruncated,
redactionState: capped.state,
};
}
/**
* Apply a `thinking_complete` final payload. The provider's
* `ThinkingCompleteEvent.text` is the FULL final thinking text
* (not an incremental delta), so we replace rather than append.
* The same redaction + size cap rules apply.
*/
export function applyThinkingComplete(
rawText: string,
options: ApplyThinkingOptions = {},
): ApplyThinkingResult {
const maxTotal = options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS;
const truncatedHeadMarker = getSharedUiCopy(options.locale ?? 'zh').stream.thinkingHeadTruncated;
// Same defensive guard as `applyThinkingDelta`.
if (typeof rawText !== 'string') {
return { text: '', redacted: false, truncated: false };
}
// L1: secondary redaction.
const redacted = redactSecrets(rawText);
const redactionHappened = redacted !== rawText;
// L2: total cap. Tail-keep most recent reasoning.
let result = redacted;
let totalTruncated = false;
if (result.length > maxTotal) {
const keep = maxTotal - truncatedHeadMarker.length;
result = truncatedHeadMarker + result.slice(result.length - keep);
totalTruncated = true;
}
return {
text: result,
redacted: redactionHappened,
truncated: totalTruncated,
};
}