blob: 604212316202e304dcb22824358960543f45227b [file]
/**
* 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 type { UiLocale } from '@maka/core';
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;
}
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;
}
/**
* Apply a single `thinking_delta` to the prior accumulated text.
* Pure: no React state, no DOM, no IPC.
*
* 1. `redactSecrets(rawDelta)` — secondary mask BEFORE state.
* 2. If the delta alone is over `maxDeltaChars`, tail-keep it
* with a head truncation marker. (A single multi-MB delta is
* a runtime misbehavior; renderer must not echo it raw.)
* 3. Append to `prev`.
* 4. 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;
// 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 };
}
// L1: secondary redaction.
const redactedDelta = redactSecrets(rawDelta);
const redactionHappened = redactedDelta !== rawDelta;
// L2: per-delta cap. Tail-keep with marker prepended.
let delta = redactedDelta;
let deltaTruncated = false;
if (delta.length > maxDelta) {
const keep = maxDelta - truncatedChunkMarker.length;
delta = truncatedChunkMarker + delta.slice(delta.length - keep);
deltaTruncated = true;
}
// L3: append.
const appended = (prev ?? '') + delta;
// L4: per-session total cap. Tail-keep most recent.
let result = appended;
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: deltaTruncated || totalTruncated,
};
}
/**
* 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,
};
}