math: dollar delimiters end-to-end (exact answers, escaping examples, context rendering)
- switch the math authoring/rendering contract from \(...\)/\[...\] to the standard $...$ inline / $$...$$ display: prompts (20-question-tools, 40-formatting), singleDollarTextMath on the renderer, legacy reader kept, and escape-debris recovery extended to dollar spans (mirroring micromark delimiter-run rules) - exact answer membership now precedes debris cleanup in coerceCorrectAnswer (calculus incident: an exact sentence-final-period label was stripped as model debris, then rejected); option keys are passed at the boundary/grading seams, the dock no longer pre-coerces - a complete, phase-correct JSON-escaping example rides the probe/quiz tool descriptions (serialized from one canonical body; quiz leads with type+nodeId), plus the one-sentence JSON-escaping rule in 40-formatting - ask option descriptions render markdown/LaTeX in the pending dock; settled cards keep the details line and option descriptions retroactively, read from the persisted call arguments (chat passes part.input to AnsweredCard) 1566/1566 tests on both navigator configs; typecheck, lint, production build, standalone packaging, and Electron-runtime smoke all green.
This commit is contained in:
@@ -331,10 +331,10 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
// streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its
|
||||
// own highlighter); the cast papers over the .d.ts mismatch.
|
||||
//
|
||||
// singleDollarTextMath: false — a bare $ is ALWAYS literal currency (the
|
||||
// teacher-prompt contract). Inline math arrives as \(...\) and display math
|
||||
// as \[...\]; transformMathDelimiters (below) rewrites both to the
|
||||
// two-dollar forms remark-math still parses. $ can never start math.
|
||||
// Standard dollar authoring (owner, 2026-10-12): $...$ inline, $$ fenced
|
||||
// on separate lines for display. transformMathDelimiters stays as the
|
||||
// compatibility reader for old \(...\)/\[...\] content; its two-dollar
|
||||
// output remains valid alongside native single-dollar math.
|
||||
//
|
||||
// remark-breaks rides in the cjk plugin's remarkPluginsAfter slot (public
|
||||
// PluginConfig API): Streamdown's bare `remarkPlugins` PROP would REPLACE the
|
||||
@@ -352,16 +352,14 @@ export const streamdownPlugins = {
|
||||
remarkPluginsAfter: [...cjk.remarkPluginsAfter, remarkBreaks],
|
||||
},
|
||||
code,
|
||||
math: createMathPlugin({ singleDollarTextMath: false }),
|
||||
math: createMathPlugin({ singleDollarTextMath: true }),
|
||||
} as unknown as PluginConfig;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, children, ...props }: MessageResponseProps) => {
|
||||
// Delimiter preprocessing is a single linear scan (no markdown parsing)
|
||||
// — cheap enough for the render path of this children-memoized
|
||||
// component. Unbalanced delimiters pass through untouched, so mid-stream
|
||||
// chunks render literally until the closing delimiter arrives and the
|
||||
// full text is re-transformed.
|
||||
// Legacy backslash delimiters are converted in a single linear scan;
|
||||
// native dollar math passes through to Streamdown unchanged. Incomplete
|
||||
// legacy spans are left for a later render when their closer arrives.
|
||||
const content =
|
||||
typeof children === "string"
|
||||
? transformMathDelimiters(children)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ThinkingBlock } from "@/components/thinking-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import type { QuestionAnswerData } from "@/src/core";
|
||||
import { parseAskInput, parseQuizInput } from "@/src/core";
|
||||
|
||||
/**
|
||||
* Static rendering of an answered question (tool part in `output-available`).
|
||||
@@ -18,7 +19,7 @@ import type { QuestionAnswerData } from "@/src/core";
|
||||
* client — the card reveals his answer, the committed correct answer, and
|
||||
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
|
||||
*/
|
||||
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) {
|
||||
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
|
||||
if (answer.dismissed) {
|
||||
return (
|
||||
@@ -32,6 +33,15 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
const isExplain = answer.kind === "explain";
|
||||
const selectedIdx = new Set(answer.answers.map((a) => a.index));
|
||||
const correctIdx = new Set(answer.correctIndices ?? []);
|
||||
// The question's own context rides the ORIGINAL CALL, not the answer
|
||||
// snapshot (owner, 2026-10-12): the details line — and, for ask
|
||||
// questions, option descriptions — re-render from the persisted
|
||||
// arguments, so every existing transcript shows them too. The lean
|
||||
// snapshot (what the model sees, compactAnswerForModel) is untouched;
|
||||
// any parse failure or absent input renders exactly the older card.
|
||||
const askInput = answer.kind === "ask" ? parseAskInput(input) : undefined;
|
||||
const parsedDetails = askInput?.details ?? (answer.kind === "quiz" ? parseQuizInput(input)?.details : undefined);
|
||||
const descByIndex = new Map((askInput?.options ?? []).map((o, i) => [i + 1, o.description]));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,6 +88,13 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
<div className="mb-3 font-medium">
|
||||
<MessageResponse>{answer.questionText}</MessageResponse>
|
||||
</div>
|
||||
{parsedDetails && (
|
||||
// The details line, same paint as the pending dock (owner,
|
||||
// 2026-10-12): full markdown, LaTeX included.
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
<MessageResponse>{parsedDetails}</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((answer.options?.length ?? 0) > 0 || answer.other) && (
|
||||
<div className="flex flex-col gap-1.5 text-sm">
|
||||
@@ -115,6 +132,14 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
)}
|
||||
<span className={cn(!sel && "text-muted-foreground")}>
|
||||
<MessageResponse>{opt.label}</MessageResponse>
|
||||
{descByIndex.get(opt.index) && (
|
||||
// Ask-option subtext (owner, 2026-10-12): same markdown path
|
||||
// as the pending dock — LaTeX included. Quiz options carry
|
||||
// none (owner ruling 2026-09-26); the map stays empty for them.
|
||||
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
|
||||
{descByIndex.get(opt.index)}
|
||||
</MessageResponse>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-1
@@ -1487,7 +1487,9 @@ export function Chat({
|
||||
// Pending/streaming questions live in the dock, not the
|
||||
// transcript; answered ones render as graded records.
|
||||
if (part.state === "output-available") {
|
||||
return <AnsweredCard key={pi} answer={part.output as QuestionAnswerData} />;
|
||||
return (
|
||||
<AnsweredCard key={pi} answer={part.output as QuestionAnswerData} input={part.input} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildAnswer,
|
||||
coerceCorrectAnswer,
|
||||
normalizeQuizType,
|
||||
CONFIDENCE_STOPS,
|
||||
DEFAULT_CONFIDENCE_INDEX,
|
||||
@@ -170,7 +169,7 @@ function teaserText(q: string): string {
|
||||
return q
|
||||
.replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math
|
||||
.replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math
|
||||
.replace(/\$\$?([^$]*)\$\$?/g, "$1") // legacy $...$ (pre-switch sessions)
|
||||
.replace(/\$\$?([^$]*)\$\$?/g, "$1") // standard $...$/$$...$$ math
|
||||
.replace(/\\frac\{([^{}]*)\}\{([^{}]*)\}/g, "$1/$2")
|
||||
.replace(/\\([a-zA-Z]+)/g, "$1")
|
||||
.replace(/[{}]/g, "")
|
||||
@@ -293,7 +292,7 @@ function QuestionDockForm({
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (quizInput) {
|
||||
const resolved = resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), ordered);
|
||||
const resolved = resolveCorrect(quizInput.correctAnswer, ordered);
|
||||
if (resolved.error) console.error("[quiz] correctAnswer did not resolve:", resolved.error);
|
||||
q.correctIndices = resolved.indices;
|
||||
q.explanation = quizInput.explanation;
|
||||
@@ -607,8 +606,12 @@ function QuestionDockForm({
|
||||
// the "I don't know" copy stays JetBrains Mono. QUIZ options render
|
||||
// NO description (owner ruling 2026-09-26): the model used them to
|
||||
// telegraph correctness — the schema dropped the field and old
|
||||
// records must not leak either.
|
||||
<span className="lh-prose block text-xs text-muted-foreground">{opt.description}</span>
|
||||
// records must not leak either. Same MessageResponse path as the
|
||||
// label (owner, 2026-10-12): descriptions are full markdown —
|
||||
// LaTeX included — not plain text.
|
||||
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
|
||||
{opt.description}
|
||||
</MessageResponse>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user