From a3af35af19c21369b27bcbb706acf77fa016f7db Mon Sep 17 00:00:00 2001 From: ecokayiza Date: Wed, 16 Sep 2026 15:29:16 +0800 Subject: [PATCH 01/12] Cut streaming render cost A streamed answer arrives as a series of cumulative snapshots and every snapshot re-rendered the whole answer, so a long reply re-parsed from the top on each chunk. Coalesce the chunks into one render per animation frame (`answer-buffer.mjs`) and always flush the newest text before the answer is finalized. Buffered frames are dropped when the card unmounts, is cleared, or starts a new question, so a stale frame cannot resurrect an old answer. The buffer falls back to a timer where the host has no animation frames (jsdom and other headless renderers), which keeps the lifecycle tests working. Syntax auto-detection is limited to a common-language subset in `highlight-options.mjs`. `detect: true` compiles every registered grammar on first use; narrowing the scan keeps auto-detection and drops that cost. Explicitly labelled blocks are unaffected. Measured on a 394-character answer delivered as 19 chunks of 20 characters: - First code block on a cold page: ~132 ms before, ~4 ms after. - A burst: 48.6 ms of CPU over 19 renders before, 11.0 ms over 6 after. - A slow stream (60 ms per chunk): 48.6 ms before, 29.6 ms after. --- .../ConversationCard/answer-buffer.mjs | 71 +++++++++ src/components/ConversationCard/index.jsx | 21 ++- .../MarkdownRender/highlight-options.mjs | 34 +++++ .../MarkdownRender/markdown-without-katex.jsx | 13 +- src/components/MarkdownRender/markdown.jsx | 14 +- tests/unit/components/answer-buffer.test.mjs | 139 ++++++++++++++++++ .../components/highlight-options.test.mjs | 62 ++++++++ 7 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 src/components/ConversationCard/answer-buffer.mjs create mode 100644 src/components/MarkdownRender/highlight-options.mjs create mode 100644 tests/unit/components/answer-buffer.test.mjs create mode 100644 tests/unit/components/highlight-options.test.mjs diff --git a/src/components/ConversationCard/answer-buffer.mjs b/src/components/ConversationCard/answer-buffer.mjs new file mode 100644 index 000000000..13f8341f8 --- /dev/null +++ b/src/components/ConversationCard/answer-buffer.mjs @@ -0,0 +1,71 @@ +/** + * Animation frames are the fastest way to coalesce a burst of updates, but not every host + * has them (jsdom and other headless renderers do not). Fall back to a timer there so the + * buffer can always be created. + * @param {typeof globalThis} [host] + * @returns {{requestFrame: (callback: () => void) => unknown, cancelFrame: (handle: unknown) => void}} + */ +export function createFrameScheduler(host = globalThis) { + if ( + typeof host.requestAnimationFrame === 'function' && + typeof host.cancelAnimationFrame === 'function' + ) { + return { + requestFrame: (callback) => host.requestAnimationFrame(callback), + cancelFrame: (handle) => host.cancelAnimationFrame(handle), + } + } + return { + requestFrame: (callback) => setTimeout(callback, 16), + cancelFrame: (handle) => clearTimeout(handle), + } +} + +/** + * Coalesces streamed answer text so a burst of chunks renders once per frame instead of + * once per chunk, while never losing the newest text. + * @param {object} params + * @param {(callback: () => void) => unknown} params.requestFrame + * @param {(handle: unknown) => void} params.cancelFrame + * @param {(answer: string) => void} params.render + */ +export function createAnswerBuffer({ requestFrame, cancelFrame, render }) { + let pending = null + let frame = null + + const cancelPendingFrame = () => { + if (frame === null) return + cancelFrame(frame) + frame = null + } + + const takePending = () => { + const answer = pending + pending = null + return answer + } + + return { + /** Queue the newest answer, scheduling a render only when none is already scheduled. */ + push(answer) { + pending = answer + if (frame !== null) return + frame = requestFrame(() => { + frame = null + const latest = takePending() + if (latest !== null) render(latest) + }) + }, + /** Render the newest answer before the conversation is finalized. */ + flush() { + cancelPendingFrame() + const latest = takePending() + if (latest !== null) render(latest) + }, + /** Drop the newest answer without rendering it, e.g. when a retry starts. */ + discard() { + cancelPendingFrame() + pending = null + }, + } +} diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index dbba60187..d6fd3c64a 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -50,6 +50,7 @@ import { isSupersededGenerationMessage, isSupersededRequestMessage, } from './session.mjs' +import { createAnswerBuffer, createFrameScheduler } from './answer-buffer.mjs' const logo = Browser.runtime.getURL('logo.png') const UNMATCHED_API_MODE_VALUE = '__current-session-api-mode__' @@ -221,6 +222,19 @@ function ConversationCard(props) { }) } + const answerBufferRef = useRef(null) + if (answerBufferRef.current === null) { + answerBufferRef.current = createAnswerBuffer({ + ...createFrameScheduler(), + render: (answer) => updateAnswer(answer, false, 'answer'), + }) + } + + // A buffered frame can outlive a hidden page, so drop it when the card goes away. + useEffect(() => { + return () => answerBufferRef.current?.discard() + }, []) + const portMessageListener = (msg) => { if (disposedRef.current) return if (isSupersededRequestMessage(msg, requestGenerationIdRef.current)) return @@ -228,12 +242,13 @@ function ConversationCard(props) { if (msg.answer) { partialAnswerRef.current = msg.answer - updateAnswer(msg.answer, false, 'answer') + answerBufferRef.current.push(msg.answer) } if (msg.session) { setSession(msg.done ? { ...msg.session, isRetry: false } : msg.session) } if (msg.done) { + answerBufferRef.current.flush() const partialAnswer = partialAnswerRef.current const retryRecord = retryRecordRef.current const completionState = getInterruptedCompletionState(msg, partialAnswer, retryRecord) @@ -249,6 +264,7 @@ function ConversationCard(props) { setIsReady(true) } if (msg.error) { + answerBufferRef.current.flush() const retryRecord = retryRecordRef.current setSession((currentSession) => finalizeInterruptedSession(currentSession, '', retryRecord)) switch (msg.error) { @@ -479,6 +495,7 @@ function ConversationCard(props) { }, [port, conversationItemData]) const getRetryFn = (session) => async () => { + answerBufferRef.current.discard() updateAnswer(`

${t('Waiting for response...')}

`, false, 'answer') setIsReady(false) @@ -671,6 +688,7 @@ function ConversationCard(props) { } partialAnswerRef.current = '' retryRecordRef.current = null + answerBufferRef.current.discard() Browser.runtime.sendMessage({ type: 'DELETE_CONVERSATION', data: { @@ -797,6 +815,7 @@ function ConversationCard(props) { ) partialAnswerRef.current = '' retryRecordRef.current = null + answerBufferRef.current.discard() setConversationItemData([...conversationItemData, newQuestion, newAnswer]) setIsReady(false) diff --git a/src/components/MarkdownRender/highlight-options.mjs b/src/components/MarkdownRender/highlight-options.mjs new file mode 100644 index 000000000..f8ffb937f --- /dev/null +++ b/src/components/MarkdownRender/highlight-options.mjs @@ -0,0 +1,34 @@ +/** + * Shared rehype-highlight options. + * + * Auto-detection compiles every registered grammar the first time it runs, which costs + * >100ms on a cold page. Scanning only common languages keeps that near 3ms. Explicitly + * labelled code blocks are unaffected: the subset only narrows automatic detection, they + * still use every grammar lowlight has registered. + */ +export const highlightOptions = { + detect: true, + subset: [ + 'bash', + 'c', + 'cpp', + 'csharp', + 'css', + 'go', + 'java', + 'javascript', + 'json', + 'kotlin', + 'php', + 'python', + 'ruby', + 'rust', + 'sql', + 'swift', + 'typescript', + 'xml', + 'yaml', + ], + ignoreMissing: true, + plainText: ['diagnostic'], +} diff --git a/src/components/MarkdownRender/markdown-without-katex.jsx b/src/components/MarkdownRender/markdown-without-katex.jsx index 0733b7129..498bcc673 100644 --- a/src/components/MarkdownRender/markdown-without-katex.jsx +++ b/src/components/MarkdownRender/markdown-without-katex.jsx @@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Pre } from './Pre' import { Hyperlink } from './Hyperlink' +import { highlightOptions } from './highlight-options.mjs' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -176,17 +177,7 @@ export function MarkdownRender(props) { ]} unwrapDisallowed={true} remarkPlugins={[remarkGfm, remarkBreaks]} - rehypePlugins={[ - rehypeRaw, - [ - rehypeHighlight, - { - detect: true, - ignoreMissing: true, - plainText: ['diagnostic'], - }, - ], - ]} + rehypePlugins={[rehypeRaw, [rehypeHighlight, highlightOptions]]} components={{ a: Hyperlink, pre: Pre, diff --git a/src/components/MarkdownRender/markdown.jsx b/src/components/MarkdownRender/markdown.jsx index f9cb8eb3a..8f92ac47b 100644 --- a/src/components/MarkdownRender/markdown.jsx +++ b/src/components/MarkdownRender/markdown.jsx @@ -8,6 +8,7 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Pre } from './Pre' import { Hyperlink } from './Hyperlink' +import { highlightOptions } from './highlight-options.mjs' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -179,18 +180,7 @@ export function MarkdownRender(props) { ]} unwrapDisallowed={true} remarkPlugins={[remarkMath, remarkGfm, remarkBreaks]} - rehypePlugins={[ - rehypeKatex, - rehypeRaw, - [ - rehypeHighlight, - { - detect: true, - ignoreMissing: true, - plainText: ['diagnostic'], - }, - ], - ]} + rehypePlugins={[rehypeKatex, rehypeRaw, [rehypeHighlight, highlightOptions]]} components={{ a: Hyperlink, pre: Pre, diff --git a/tests/unit/components/answer-buffer.test.mjs b/tests/unit/components/answer-buffer.test.mjs new file mode 100644 index 000000000..3e2749354 --- /dev/null +++ b/tests/unit/components/answer-buffer.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + createAnswerBuffer, + createFrameScheduler, +} from '../../../src/components/ConversationCard/answer-buffer.mjs' + +function createFakeFrames() { + let nextHandle = 1 + const callbacks = new Map() + const cancelled = [] + + return { + cancelled, + requestFrame(callback) { + const handle = nextHandle++ + callbacks.set(handle, callback) + return handle + }, + cancelFrame(handle) { + cancelled.push(handle) + callbacks.delete(handle) + }, + runFrames() { + const pending = [...callbacks.values()] + callbacks.clear() + for (const callback of pending) callback() + }, + } +} + +function setup() { + const frames = createFakeFrames() + const renders = [] + const buffer = createAnswerBuffer({ + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + render: (answer) => renders.push(answer), + }) + return { frames, renders, buffer } +} + +test('a burst of chunks renders once per frame with only the newest text', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + buffer.push('ab') + buffer.push('abc') + assert.deepEqual(renders, []) + + frames.runFrames() + + assert.deepEqual(renders, ['abc']) +}) + +test('a chunk arriving after a frame schedules the next render', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + frames.runFrames() + buffer.push('ab') + frames.runFrames() + + assert.deepEqual(renders, ['a', 'ab']) +}) + +test('flush renders the newest chunk so completion cannot drop the last one', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + buffer.push('ab') + buffer.flush() + assert.deepEqual(renders, ['ab']) + assert.equal(frames.cancelled.length, 1) + + frames.runFrames() + assert.deepEqual(renders, ['ab'], 'the cancelled frame must not render again') +}) + +test('flush without pending text does not render', () => { + const { renders, buffer } = setup() + + buffer.flush() + + assert.deepEqual(renders, []) +}) + +test('discard drops the pending text without rendering it', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + buffer.discard() + frames.runFrames() + + assert.deepEqual(renders, []) +}) + +test('pushing after a discard schedules a fresh frame', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + buffer.discard() + buffer.push('b') + frames.runFrames() + + assert.deepEqual(renders, ['b']) +}) + +test('createFrameScheduler uses the host animation frames when available', () => { + const requested = [] + const cancelled = [] + const scheduler = createFrameScheduler({ + requestAnimationFrame: (callback) => { + requested.push(callback) + return 7 + }, + cancelAnimationFrame: (handle) => cancelled.push(handle), + }) + + const handle = scheduler.requestFrame(() => {}) + scheduler.cancelFrame(handle) + + assert.equal(requested.length, 1) + assert.deepEqual(cancelled, [7]) +}) + +test('createFrameScheduler falls back to a timer without animation frames', async () => { + const scheduler = createFrameScheduler({}) + const renders = [] + + await new Promise((resolve) => { + scheduler.requestFrame(() => { + renders.push('tick') + resolve() + }) + }) + + assert.deepEqual(renders, ['tick']) +}) diff --git a/tests/unit/components/highlight-options.test.mjs b/tests/unit/components/highlight-options.test.mjs new file mode 100644 index 000000000..31ad92ccc --- /dev/null +++ b/tests/unit/components/highlight-options.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkRehype from 'remark-rehype' +import rehypeHighlight from 'rehype-highlight' +import { highlightOptions } from '../../../src/components/MarkdownRender/highlight-options.mjs' + +async function renderCodeNodes(markdown) { + const processor = unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeHighlight, highlightOptions) + + const tree = await processor.run(processor.parse(markdown)) + const nodes = [] + const walk = (node) => { + if (node.type === 'element' && node.tagName === 'code') nodes.push(node) + for (const child of node.children ?? []) walk(child) + } + walk(tree) + return nodes +} + +function classNames(node) { + return node.properties?.className ?? [] +} + +test('auto-detection still labels blocks from the configured subset', async () => { + const [code] = await renderCodeNodes('```\nconst value = computeSomething(alpha, beta)\n```\n') + + const detected = classNames(code).find((name) => name.startsWith('language-')) + assert.ok( + detected && highlightOptions.subset.includes(detected.replace('language-', '')), + `expected a detection within the subset, got ${classNames(code).join(', ')}`, + ) + // A failure inside highlightAuto is swallowed by ignoreMissing, so token spans are the + // evidence that the whole subset resolved. + assert.ok( + code.children.some((child) => classNames(child).some((name) => name.startsWith('hljs-'))), + 'expected token spans from auto-detection', + ) +}) + +test('the subset only narrows detection, not labelled blocks', async () => { + // `lua` is registered by lowlight but deliberately left out of the detection subset. + assert.equal(highlightOptions.subset.includes('lua'), false) + + const [code] = await renderCodeNodes('```lua\nlocal value = 1\n```\n') + + assert.ok( + code.children.some((child) => classNames(child).some((name) => name.startsWith('hljs-'))), + 'expected token spans for a labelled language outside the detection subset', + ) +}) + +test('an unknown language label is ignored instead of failing the render', async () => { + const [code] = await renderCodeNodes('```not-a-language\nconst value = 1\n```\n') + + assert.equal(code.children.length, 1) + assert.equal(code.children[0].type, 'text') +}) From 815a0754544935b7d8be3f5921e19a21bf54336b Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 18:35:20 +0800 Subject: [PATCH 02/12] Keep common languages in syntax auto-detection The detection subset was meant to avoid compiling every grammar on first use, but it dropped languages that show up unlabelled in answers. A `diff` block rendered as `css`, `lua` as `sql`, `ini` as `csharp`, and `markdown` and `perl` as `bash`. Use lowlight's registered set minus the entries that only mislead detection: `shell` (a `bash` alias), `plaintext`/`python-repl`/ `php-template` (not useful for detection), and `arduino`/`objectivec`/ `vbnet`/`wasm` (rare in answers, and they win ambiguous `c`, `cpp`, `ini` and `sql` matches away). Keep lowlight's registration order, because `highlightAuto` settles equal-relevance candidates by subset order. On the sampled answers detection now matches or beats the full set while still compiling well under half the grammars. --- .../MarkdownRender/highlight-options.mjs | 23 +++++++++++++++--- .../components/highlight-options.test.mjs | 24 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/components/MarkdownRender/highlight-options.mjs b/src/components/MarkdownRender/highlight-options.mjs index f8ffb937f..075ef8da5 100644 --- a/src/components/MarkdownRender/highlight-options.mjs +++ b/src/components/MarkdownRender/highlight-options.mjs @@ -2,9 +2,16 @@ * Shared rehype-highlight options. * * Auto-detection compiles every registered grammar the first time it runs, which costs - * >100ms on a cold page. Scanning only common languages keeps that near 3ms. Explicitly - * labelled code blocks are unaffected: the subset only narrows automatic detection, they - * still use every grammar lowlight has registered. + * >100ms on a cold page. Scanning a curated subset keeps that near a few milliseconds. + * Explicitly labelled code blocks are unaffected: the subset only narrows automatic + * detection, they still use every grammar lowlight has registered. + * + * The subset is lowlight's registered set minus entries that only mislead detection: + * `shell` (a `bash` alias), `plaintext`/`python-repl`/`php-template` (not useful for + * detection), and `arduino`/`objectivec`/`vbnet`/`wasm` (rare in answers, and they win + * ambiguous `c`, `cpp`, `ini` and `sql` matches away from the right language). Keep the + * order as lowlight registers them: `highlightAuto` settles equal-relevance candidates by + * subset order, so reordering this list changes which language an ambiguous block detects as. */ export const highlightOptions = { detect: true, @@ -14,15 +21,25 @@ export const highlightOptions = { 'cpp', 'csharp', 'css', + 'diff', 'go', + 'graphql', + 'ini', 'java', 'javascript', 'json', 'kotlin', + 'less', + 'lua', + 'makefile', + 'markdown', + 'perl', 'php', 'python', + 'r', 'ruby', 'rust', + 'scss', 'sql', 'swift', 'typescript', diff --git a/tests/unit/components/highlight-options.test.mjs b/tests/unit/components/highlight-options.test.mjs index 31ad92ccc..fca17cec9 100644 --- a/tests/unit/components/highlight-options.test.mjs +++ b/tests/unit/components/highlight-options.test.mjs @@ -43,10 +43,10 @@ test('auto-detection still labels blocks from the configured subset', async () = }) test('the subset only narrows detection, not labelled blocks', async () => { - // `lua` is registered by lowlight but deliberately left out of the detection subset. - assert.equal(highlightOptions.subset.includes('lua'), false) + // `objectivec` is registered by lowlight but deliberately left out of the detection subset. + assert.equal(highlightOptions.subset.includes('objectivec'), false) - const [code] = await renderCodeNodes('```lua\nlocal value = 1\n```\n') + const [code] = await renderCodeNodes('```objectivec\nNSString *value = @"hi";\n```\n') assert.ok( code.children.some((child) => classNames(child).some((name) => name.startsWith('hljs-'))), @@ -54,6 +54,24 @@ test('the subset only narrows detection, not labelled blocks', async () => { ) }) +test('auto-detection keeps the common unlabelled languages', async () => { + const cases = [ + ['diff', 'diff --git a/x b/x\n@@ -1,3 +1,3 @@\n-old line\n+new line'], + ['markdown', '# Title\n\nSome *emphasis* and a [link](http://example.com).\n\n- item'], + ['lua', 'local function add(a, b)\n return a + b\nend\nprint(add(1, 2))'], + ] + + for (const [language, snippet] of cases) { + const [code] = await renderCodeNodes(`\`\`\`\n${snippet}\n\`\`\`\n`) + const detected = classNames(code).find((name) => name.startsWith('language-')) + assert.equal( + detected, + `language-${language}`, + `expected ${language} to stay auto-detectable, got ${classNames(code).join(', ')}`, + ) + } +}) + test('an unknown language label is ignored instead of failing the render', async () => { const [code] = await renderCodeNodes('```not-a-language\nconst value = 1\n```\n') From f2fae2f7a60c82f7712170d0758cc6d3fc1cd759 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 18:35:20 +0800 Subject: [PATCH 03/12] Discard buffered streamed frames when the card switches question The `props.question` effect reset the partial answer but left the answer buffer alone, so a frame queued by the previous answer could render after the new question started. `FloatingToolbar` and `DecisionCard` reuse the card for a new question, so the path is reachable. Also cover the wiring the unit tests missed: a burst stays buffered until completion flushes it, switching the question drops the previous answer, and the scheduler's timer fallback cancels a pending frame. --- src/components/ConversationCard/index.jsx | 1 + ...nversation-card-lifecycle-loader-hooks.mjs | 9 +++- tests/unit/components/answer-buffer.test.mjs | 11 +++++ .../conversation-card-lifecycle.test.mjs | 48 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index d6fd3c64a..9f34d4f97 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -188,6 +188,7 @@ function ConversationCard(props) { const newSession = initSession({ ...session, question: props.question }) partialAnswerRef.current = '' retryRecordRef.current = null + answerBufferRef.current.discard() setSession(newSession) await postMessage({ session: newSession }) } diff --git a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs index 2cd6889c0..f6c36fa7e 100644 --- a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs +++ b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs @@ -40,7 +40,14 @@ const sources = { return null } `, - 'test:conversation-item': 'export default function ConversationItem() { return null }', + 'test:conversation-item': ` + export default function ConversationItem(props) { + if (props.type === 'answer') { + globalThis.__CONVERSATION_LIFECYCLE_TEST__.answerContents.push(props.content) + } + return null + } + `, 'test:conversation-utils': ` export const apiModeToModelName = () => 'test-model' export const createElementAtPosition = () => document.createElement('div') diff --git a/tests/unit/components/answer-buffer.test.mjs b/tests/unit/components/answer-buffer.test.mjs index 3e2749354..2cad9e5f8 100644 --- a/tests/unit/components/answer-buffer.test.mjs +++ b/tests/unit/components/answer-buffer.test.mjs @@ -137,3 +137,14 @@ test('createFrameScheduler falls back to a timer without animation frames', asyn assert.deepEqual(renders, ['tick']) }) + +test('the timer fallback cancels a pending frame', async () => { + const scheduler = createFrameScheduler({}) + const renders = [] + const handle = scheduler.requestFrame(() => renders.push('tick')) + + scheduler.cancelFrame(handle) + await new Promise((resolve) => setTimeout(resolve, 32)) + + assert.deepEqual(renders, []) +}) diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs index fb5d37e39..d17a26c4e 100644 --- a/tests/unit/components/conversation-card-lifecycle.test.mjs +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -126,6 +126,7 @@ const resetState = () => { state.generateAnswersCount += 1 } state.runtimeOnMessage.clear() + state.answerContents = [] } const mountCard = (container, props = {}) => { @@ -730,3 +731,50 @@ test('provider failure disconnects fake Port and removes stale listeners', async act(() => render(null, container)) }) + +test('a burst of streamed chunks stays buffered until completion flushes it', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + const session = { + ...baseSession(), + question: 'why?', + conversationRecords: [{ question: 'why?', answer: 'partial' }], + } + + mountCard(container, { question: 'why?', session }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ answer: 'a' })) + act(() => port.onMessage.trigger({ answer: 'ab' })) + assert.equal(state.answerContents.includes('ab'), false, 'the burst must not render per chunk') + + act(() => port.onMessage.trigger({ answer: 'abc', done: true, session })) + + assert.equal(state.answerContents.includes('abc'), true, 'completion must flush the newest chunk') +}) + +test('switching the question drops a buffered answer from the previous one', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + + mountCard(container, { question: 'first', session: { ...baseSession(), question: 'first' } }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ answer: 'stale answer' })) + + act(() => { + render( + h(ConversationCard, { + session: { ...baseSession(), question: 'second' }, + question: 'second', + }), + container, + ) + }) + + await new Promise((resolve) => setTimeout(resolve, 32)) + + assert.equal(state.answerContents.includes('stale answer'), false) +}) From 7d73b325bc0a97f0e6ccff9e5a30f3431f100404 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 18:38:56 +0800 Subject: [PATCH 04/12] Flush the answer buffer when the transport drops A stream can end without a `done`/`error` message: an extension reload, a background crash or a dropped port. The buffered chunk then waited for the next animation frame, which never runs on a hidden page, so the card kept a stale answer until the tab became visible. Flush before reconnecting so the newest chunk renders synchronously either way. A lifecycle test drops the port mid-stream and asserts the chunk is rendered before the replacement Port is used. --- src/components/ConversationCard/index.jsx | 3 ++ .../conversation-card-lifecycle.test.mjs | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 9f34d4f97..130fdb122 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -448,6 +448,9 @@ function ConversationCard(props) { return } if (disposedRef.current) return + // A dropped transport ends the stream without a final message. Flush here so the newest + // chunk renders even on a hidden page, where animation frames are paused. + answerBufferRef.current.flush() const nextPort = Browser.runtime.connect() portRef.current = nextPort setPort(nextPort) diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs index d17a26c4e..384a16f0f 100644 --- a/tests/unit/components/conversation-card-lifecycle.test.mjs +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -778,3 +778,33 @@ test('switching the question drops a buffered answer from the previous one', asy assert.equal(state.answerContents.includes('stale answer'), false) }) + +test('a dropped transport flushes the buffered answer before reconnecting', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + const session = { + ...baseSession(), + question: 'why?', + conversationRecords: [{ question: 'why?', answer: 'partial' }], + } + + mountCard(container, { question: 'why?', session }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ answer: 'newest chunk' })) + assert.equal( + state.answerContents.includes('newest chunk'), + false, + 'the chunk must be buffered first', + ) + + act(() => port.emitRemoteDisconnect()) + + assert.equal(state.ports.length, 2) + assert.equal( + state.answerContents.includes('newest chunk'), + true, + 'a dropped transport must flush the newest chunk synchronously', + ) +}) From e3f81bfe8ee2900d60c3798245be29860f5b3fa9 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sun, 4 Oct 2026 00:10:35 +0800 Subject: [PATCH 05/12] Keep a foreground generation open when the keepalive Port drops A runtime Port that drops mid-generation is replaced, but a foreground generation (Bing web) streams through its own transport and only uses this Port as a keepalive. Flushing the buffer and calling setIsReady(true) here finalized that answer early and unlocked sending while chunks were still arriving, so guard both behind "no foreground generation is in flight". The highlight subset note also described `shell` as a `bash` alias; it is the Shell Session console-prompt grammar, and `bash` only aliases `sh`/`zsh`. --- src/components/ConversationCard/index.jsx | 12 +++++--- .../MarkdownRender/highlight-options.mjs | 7 +++-- .../conversation-card-lifecycle.test.mjs | 28 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 130fdb122..f3476d756 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -448,13 +448,17 @@ function ConversationCard(props) { return } if (disposedRef.current) return - // A dropped transport ends the stream without a final message. Flush here so the newest - // chunk renders even on a hidden page, where animation frames are paused. - answerBufferRef.current.flush() + // A dropped transport ends the stream without a final message, so flush here: the newest + // chunk still renders on a hidden page, where animation frames are paused. A foreground + // generation (Bing web) streams through its own transport, though, so this keepalive + // port dropping must not unlock sending. + if (foregroundPortsRef.current.size === 0) { + answerBufferRef.current.flush() + setIsReady(true) + } const nextPort = Browser.runtime.connect() portRef.current = nextPort setPort(nextPort) - setIsReady(true) } const closeChatsMessageListener = (message) => { diff --git a/src/components/MarkdownRender/highlight-options.mjs b/src/components/MarkdownRender/highlight-options.mjs index 075ef8da5..bedb598fb 100644 --- a/src/components/MarkdownRender/highlight-options.mjs +++ b/src/components/MarkdownRender/highlight-options.mjs @@ -7,9 +7,10 @@ * detection, they still use every grammar lowlight has registered. * * The subset is lowlight's registered set minus entries that only mislead detection: - * `shell` (a `bash` alias), `plaintext`/`python-repl`/`php-template` (not useful for - * detection), and `arduino`/`objectivec`/`vbnet`/`wasm` (rare in answers, and they win - * ambiguous `c`, `cpp`, `ini` and `sql` matches away from the right language). Keep the + * `shell` (the Shell Session console-prompt grammar, not a `bash` alias; `bash` aliases + * only `sh`/`zsh`), `plaintext`/`python-repl`/`php-template` (not useful for detection), + * and `arduino`/`objectivec`/`vbnet`/`wasm` (rare in answers, and they win ambiguous `c`, + * `cpp`, `ini` and `sql` matches away from the right language). Keep the * order as lowlight registers them: `highlightAuto` settles equal-relevance candidates by * subset order, so reordering this list changes which language an ambiguous block detects as. */ diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs index 384a16f0f..e91dd8322 100644 --- a/tests/unit/components/conversation-card-lifecycle.test.mjs +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -238,6 +238,34 @@ test('remote runtime Port disconnect reconnects and unmount cleans the replaceme assert.equal(state.ports.length, 2) }) +test('a remote Port disconnect during a foreground generation does not unlock sending', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + state.foreground = true + state.generateAnswers = () => { + state.generateAnswersCount += 1 + return new Promise(() => {}) + } + + mountCard(container) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + state.inputBoxProps.onSubmit('question') + await waitFor(() => state.generateAnswersCount === 1, 'foreground provider did not start') + await waitFor(() => state.inputBoxProps.enabled === false, 'sending was not locked') + + act(() => state.ports[0].emitRemoteDisconnect()) + + // The keepalive Port is replaced, but the foreground stream still owns the answer, so + // sending stays locked until that stream ends on its own. + assert.equal(state.ports.length, 2) + assert.equal(state.inputBoxProps.enabled, false) +}) + test('close button disposes foreground transport before onClose', async () => { const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ const container = document.createElement('div') From cab2d5928ccec389616e9587ed42e2b7ae4f0b7d Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 17:56:00 +0800 Subject: [PATCH 06/12] Render replies with HyperMarkdown Replace react-markdown with @aeven-ai/hypermarkdown, which parses only the block that is still changing and caches the blocks already settled, so a long answer is no longer re-parsed from the top on every update. Answers are handed over as deltas (`stream-delta.mjs`) and finalized once the stream ends. The react/react-dom alias moves from @preact/compat 17.1.2 to 18.3.2 so packages that declare a React 18 peer range can be installed. The alias is a thin re-export of preact/compat, so the runtime implementation is unchanged. Removed as redundant: - `markdown-without-katex.jsx`, a 200-line near-duplicate of the renderer. The KaTeX-free build now swaps `math-plugin.mjs` for `math-plugin-without-katex.mjs` and `mykatex.min.css` for `mykatex-without-katex.css` through the build hook that already existed for this purpose. The minimal build ships 0 KaTeX font faces; the full build is unchanged. - `Pre.jsx`, now that the renderer has its own code-block toolbar, and the orphaned `change-children-font-size.mjs` helper. - `react-markdown`, `remark-breaks`, `rehype-raw`, `github-markdown-css`, `parse5` and the `parse5` webpack alias (nothing in `src` imported it, and the alias broke `hast-util-raw`). Rendering fixes that came with the swap: the renderer stylesheets are imported by the content-script entry so every variant ships them (they used to land in a chunk whose CSS is never packaged), list markers are drawn by the browser (`list-markers.mjs`) so bullets nested in a numbered list stay bullets and a list that resumes at another number keeps its `start`, and a thrown error is stored as its message, since the renderer takes text and would otherwise break on an `Error` object. --- build.mjs | 17 +- package-lock.json | 1978 ++++++++++------- package.json | 24 +- src/_locales/en/main.json | 2 + src/_locales/zh-hans/main.json | 1 + src/_locales/zh-hant/main.json | 1 + src/components/ConversationCard/index.jsx | 5 +- src/components/ConversationItem/index.jsx | 5 +- src/components/MarkdownRender/Pre.jsx | 43 - .../MarkdownRender/list-markers.mjs | 13 + .../MarkdownRender/markdown-without-katex.jsx | 198 -- src/components/MarkdownRender/markdown.jsx | 261 +-- .../math-plugin-without-katex.mjs | 2 + src/components/MarkdownRender/math-plugin.mjs | 4 + .../MarkdownRender/mykatex-without-katex.css | 1 + .../MarkdownRender/stream-delta.mjs | 32 + src/content-script/index.jsx | 5 + src/content-script/styles.scss | 28 + src/utils/change-children-font-size.mjs | 10 - src/utils/index.mjs | 1 - ...-script-selection-toolbar-loader-hooks.mjs | 6 + tests/unit/components/list-markers.test.mjs | 33 + tests/unit/components/stream-delta.test.mjs | 74 + 23 files changed, 1471 insertions(+), 1273 deletions(-) delete mode 100644 src/components/MarkdownRender/Pre.jsx create mode 100644 src/components/MarkdownRender/list-markers.mjs delete mode 100644 src/components/MarkdownRender/markdown-without-katex.jsx create mode 100644 src/components/MarkdownRender/math-plugin-without-katex.mjs create mode 100644 src/components/MarkdownRender/math-plugin.mjs create mode 100644 src/components/MarkdownRender/mykatex-without-katex.css create mode 100644 src/components/MarkdownRender/stream-delta.mjs delete mode 100644 src/utils/change-children-font-size.mjs create mode 100644 tests/unit/components/list-markers.test.mjs create mode 100644 tests/unit/components/stream-delta.test.mjs diff --git a/build.mjs b/build.mjs index fd6d7c053..227260f47 100644 --- a/build.mjs +++ b/build.mjs @@ -220,11 +220,21 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, sourceBuil }), ...(isWithoutKatex ? [ - new webpack.NormalModuleReplacementPlugin(/markdown\.jsx/, (result) => { + new webpack.NormalModuleReplacementPlugin(/math-plugin\.mjs/, (result) => { if (result.request) { result.request = result.request.replace( - 'markdown.jsx', - 'markdown-without-katex.jsx', + 'math-plugin.mjs', + 'math-plugin-without-katex.mjs', + ) + } + }), + // The renderer stylesheet is swapped too: the KaTeX selectors and fonts it + // carries must not reach the KaTeX-free artifacts. + new webpack.NormalModuleReplacementPlugin(/mykatex\.min\.css/, (result) => { + if (result.request) { + result.request = result.request.replace( + 'mykatex.min.css', + 'mykatex-without-katex.css', ) } }), @@ -236,7 +246,6 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, sourceBuil // Disable symlink resolution for consistent behavior/perf; enable via BUILD_RESOLVE_SYMLINKS=1 when working with linked deps symlinks: resolveSymlinks, alias: { - parse5: path.resolve(__dirname, 'node_modules/parse5'), ...(minimal ? { buffer: path.resolve(__dirname, 'node_modules/buffer') } : { diff --git a/package-lock.json b/package-lock.json index 41b80ebf6..12f641cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "chatgptbox", "dependencies": { + "@aeven-ai/hypermarkdown": "^0.4.4", "@babel/runtime": "^7.24.7", "@mozilla/readability": "^0.6.0", "@nem035/gpt-3-encoder": "^1.1.7", @@ -15,32 +16,27 @@ "buffer": "^6.0.3", "countries-list": "^2.6.1", "file-saver": "^2.0.5", - "github-markdown-css": "^5.9.0", "gpt-3-encoder": "^1.1.4", "i18next": "^22.5.1", "js-sha3": "^0.9.3", - "katex": "^0.16.11", + "katex": "^0.16.47", "lodash-es": "^4.17.21", - "parse5": "^6.0.1", "preact": "^10.22.1", "process": "^0.11.10", "prop-types": "^15.8.1", "random-int": "^3.1.0", - "react": "npm:@preact/compat@^17.1.2", + "react": "npm:@preact/compat@^18.3.2", "react-bootstrap-icons": "^1.11.6", - "react-dom": "npm:@preact/compat@^17.1.2", + "react-dom": "npm:@preact/compat@^18.3.2", "react-draggable": "^4.4.6", "react-i18next": "^12.3.1", - "react-markdown": "^8.0.7", "react-tabs": "^4.3.0", "react-toastify": "^9.1.3", - "rehype-highlight": "^6.0.0", - "rehype-katex": "^6.0.3", - "rehype-raw": "^6.1.1", - "remark-breaks": "^3.0.3", - "remark-gfm": "^3.0.1", - "remark-math": "^5.1.1", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", "stream-browserify": "^3.0.0", + "tippy.js": "^6.3.7", "util": "^0.12.5", "uuid": "^11.1.1", "webextension-polyfill": "^0.12.0" @@ -72,6 +68,9 @@ "prettier": "^2.8.8", "progress-bar-webpack-plugin": "^2.1.0", "publish-browser-extension": "^4.0.5", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "run-script-os": "^1.1.6", "sass": "^1.91.0", "sass-embedded": "^1.91.0", @@ -79,6 +78,7 @@ "string-replace-loader": "^3.3.0", "style-loader": "^4.0.0", "thread-loader": "^4.0.4", + "unified": "^11.0.5", "webpack": "^5.92.1", "webpack-bundle-analyzer": "^4.10.2" }, @@ -105,6 +105,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@aeven-ai/hypermarkdown": { + "version": "0.4.4", + "resolved": "https://registry.npmmirror.com/@aeven-ai/hypermarkdown/-/hypermarkdown-0.4.4.tgz", + "integrity": "sha512-Bx5zvBCP0hMPno4umzx5QNqblYt3X5CwQhplaGby3jx2+L7wOZ4iaN9ey1ST1YVwxEsNFObWdUxCIk9S/rYv4g==", + "license": "MIT", + "dependencies": { + "hast-util-to-jsx-runtime": "^2.3.6", + "rehype-raw": "^7.0.0", + "rehype-react": "^8.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2" + }, + "peerDependencies": { + "katex": "^0.16.25", + "mermaid": "^11.12.0", + "react": ">=18", + "react-dom": ">=18", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-cjk-friendly": "^2.3.1", + "remark-math": "^6.0.0", + "tippy.js": "^6.3.1" + }, + "peerDependenciesMeta": { + "katex": { + "optional": true + }, + "mermaid": { + "optional": true + }, + "rehype-highlight": { + "optional": true + }, + "rehype-katex": { + "optional": true + }, + "remark-cjk-friendly": { + "optional": true + }, + "remark-math": { + "optional": true + }, + "tippy.js": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", @@ -3221,6 +3273,16 @@ "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", "dev": true }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@primer/octicons-react": { "version": "18.3.0", "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-18.3.0.tgz", @@ -3251,9 +3313,10 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", "dependencies": { "@types/ms": "*" } @@ -3282,9 +3345,17 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/fs-extra": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", @@ -3296,11 +3367,12 @@ } }, "node_modules/@types/hast": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.8.tgz", - "integrity": "sha512-aMIqAlFd2wTIDZuvLbhUT+TGvMxrNC8ECUIVtH6xxy0sQLs3iu6NO8Kp/VT5je7i5ufnebXzdV1dNDMnvaH6IQ==", + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", "dependencies": { - "@types/unist": "^2" + "@types/unist": "*" } }, "node_modules/@types/istanbul-lib-coverage": { @@ -3370,23 +3442,25 @@ } }, "node_modules/@types/katex": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.14.0.tgz", - "integrity": "sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA==", + "version": "0.16.8", + "resolved": "https://registry.npmmirror.com/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", "license": "MIT" }, "node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", "dependencies": { - "@types/unist": "^2" + "@types/unist": "*" } }, "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" }, "node_modules/@types/node": { "version": "20.9.2", @@ -3397,28 +3471,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/parse5": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", - "integrity": "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.10", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.10.tgz", - "integrity": "sha512-mxSnDQxPqsZxmeShFH+uwQ4kO4gcJcGahjjMFeLbKE95IAZiiZyiEepGZjtXJ7hN/yfu0bu9xN2ajcU0JcxX6A==" - }, - "node_modules/@types/react": { - "version": "18.2.37", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.37.tgz", - "integrity": "sha512-RGAYMi2bhRgEXT3f4B92WTohopH6bIXw05FuGlmJEnv/omEn190+QYEIYxIAuIBdKgboYYdVved2p1AxZVQnaw==", - "peer": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, "node_modules/@types/readdir-glob": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.4.tgz", @@ -3428,12 +3480,6 @@ "@types/node": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.6.tgz", - "integrity": "sha512-Vlktnchmkylvc9SnwwwozTv04L/e1NykF5vgoQ0XTmI8DD+wxfjQuHuvHS3p0r2jz2x2ghPs2h1FVeDirIteWA==", - "peer": true - }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -3473,7 +3519,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, "node_modules/@webassemblyjs/ast": { @@ -4580,6 +4625,36 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -5284,12 +5359,6 @@ "node": "20 || >=22" } }, - "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "peer": true - }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -5477,13 +5546,17 @@ "node": ">=0.10" } }, - "node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/doctrine": { @@ -5671,6 +5744,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, "engines": { "node": ">=0.12" }, @@ -6438,6 +6512,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -6665,6 +6751,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6838,19 +6934,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fault": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", - "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", - "license": "MIT", - "dependencies": { - "format": "^0.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -7012,14 +7095,6 @@ "node": ">= 18" } }, - "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "engines": { - "node": ">=0.4.x" - } - }, "node_modules/formdata-node": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz", @@ -7266,18 +7341,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-markdown-css": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.9.0.tgz", - "integrity": "sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7482,12 +7545,13 @@ } }, "node_modules/hast-util-from-dom": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-4.2.0.tgz", - "integrity": "sha512-t1RJW/OpJbCAJQeKi3Qrj1cAOLA0+av/iPFori112+0X7R3wng+jxLA+kXec8K4szqPRGI8vPxbbpEYvvpwaeQ==", + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", "license": "ISC", "dependencies": { - "hastscript": "^7.0.0", + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" }, "funding": { @@ -7496,16 +7560,17 @@ } }, "node_modules/hast-util-from-html": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-1.0.2.tgz", - "integrity": "sha512-LhrTA2gfCbLOGJq2u/asp4kwuG0y6NhWTXiPKP+n0qNukKy7hc10whqqCFfyvIA1Q5U5d0sp9HhNim9gglEH4A==", + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "hast-util-from-parse5": "^7.0.0", + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", - "vfile": "^5.0.0", - "vfile-message": "^3.0.0" + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" }, "funding": { "type": "opencollective", @@ -7513,45 +7578,58 @@ } }, "node_modules/hast-util-from-html-isomorphic": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-1.0.0.tgz", - "integrity": "sha512-Yu480AKeOEN/+l5LA674a+7BmIvtDj24GvOt7MtQWuhzUwlaaRWdEPXAh3Qm5vhuthpAipFb2vTetKXWOjmTvw==", + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "hast-util-from-dom": "^4.0.0", - "hast-util-from-html": "^1.0.0", - "unist-util-remove-position": "^4.0.0" + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/hast-util-from-html/node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "license": "MIT", "dependencies": { - "entities": "^4.4.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, "node_modules/hast-util-from-parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-7.1.2.tgz", - "integrity": "sha512-Nz7FfPBuljzsN3tCQ4kCBKqdNhQE2l0Tn+X1ubgKBPRoiDIu1mL08Cfw4k7q71+Duyaw7DXDN+VTAp4Vh3oCOw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/unist": "^2.0.0", - "hastscript": "^7.0.0", - "property-information": "^6.0.0", - "vfile": "^5.0.0", - "vfile-location": "^4.0.0", + "version": "8.0.3", + "resolved": "https://registry.npmmirror.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" }, "funding": { @@ -7559,14 +7637,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-parse5/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/hast-util-from-parse5/node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/hast-util-is-element": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-2.1.3.tgz", - "integrity": "sha512-O1bKah6mhgEq2WtVMk+Ta5K7pPMqsBBlmzysLdcwKVrqzZQ0CHqUPiIVspNhAG1rvxpvJjtGee17XfauZYKqVA==", + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "@types/unist": "^2.0.0" + "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", @@ -7574,12 +7667,12 @@ } }, "node_modules/hast-util-parse-selector": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz", - "integrity": "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==", + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0" + "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", @@ -7587,20 +7680,22 @@ } }, "node_modules/hast-util-raw": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-7.2.3.tgz", - "integrity": "sha512-RujVQfVsOrxzPOPSzZFiwofMArbQke6DJjnFfceiEbFh7S05CbPt0cYN+A5YeD3pso0JQk6O1aHBnx9+Pm2uqg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/parse5": "^6.0.0", - "hast-util-from-parse5": "^7.0.0", - "hast-util-to-parse5": "^7.0.0", - "html-void-elements": "^2.0.0", - "parse5": "^6.0.0", - "unist-util-position": "^4.0.0", - "unist-util-visit": "^4.0.0", - "vfile": "^5.0.0", + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" }, @@ -7609,59 +7704,168 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-parse5": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-7.1.0.tgz", - "integrity": "sha512-YNRgAJkH2Jky5ySkIqFXTQiaqcAtJyVE+D5lkN6CdtOqrnkLfGYYrEcKuHOJZlp+MwjSwuD3fZuawI+sic/RBw==", + "node_modules/hast-util-raw/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/hast-util-raw/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hast-util-raw/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", - "property-information": "^6.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-text": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-3.1.2.tgz", - "integrity": "sha512-tcllLfp23dJJ+ju5wCCZHVpzsQQ43+moJbqVX3jNWPB7z/KFC4FyZD6R7y94cHL6MQ33YtMZL8Z0aIXXI4XFTw==", + "node_modules/hast-util-to-jsx-runtime/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/hast-util-to-jsx-runtime/node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "@types/unist": "^2.0.0", - "hast-util-is-element": "^2.0.0", - "unist-util-find-after": "^4.0.0" + "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", - "integrity": "sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==", + "node_modules/hast-util-to-jsx-runtime/node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/hastscript": { + "node_modules/hast-util-to-parse5/node_modules/property-information": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-7.2.0.tgz", - "integrity": "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", + "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^3.0.0", - "property-information": "^6.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" }, "funding": { @@ -7669,10 +7873,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript/node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/highlight.js": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", - "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "version": "11.11.2", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.2.tgz", + "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" @@ -7706,9 +7920,9 @@ } }, "node_modules/html-void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz", - "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==", + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", "license": "MIT", "funding": { "type": "github", @@ -7771,6 +7985,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -7894,11 +8109,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/inline-style-parser": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", - "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7914,11 +8124,35 @@ "node": ">= 0.4" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dependencies": { + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" }, @@ -8058,6 +8292,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8119,6 +8363,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -8761,9 +9015,9 @@ } }, "node_modules/katex": { - "version": "0.16.21", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz", - "integrity": "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==", + "version": "0.16.47", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -8786,14 +9040,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "engines": { - "node": ">=6" - } - }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -9318,14 +9564,14 @@ } }, "node_modules/lowlight": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", - "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "fault": "^2.0.0", - "highlight.js": "~11.8.0" + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" }, "funding": { "type": "github", @@ -9372,9 +9618,9 @@ } }, "node_modules/markdown-table": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz", - "integrity": "sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==", + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", "funding": { "type": "github", @@ -9390,84 +9636,65 @@ "node": ">= 0.4" } }, - "node_modules/mdast-util-definitions": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", - "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdast-util-find-and-replace": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz", - "integrity": "sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==", + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", + "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.0.0" + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mdast-util-from-markdown": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", - "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", - "mdast-util-to-string": "^3.1.0", - "micromark": "^3.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-decode-string": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "unist-util-stringify-position": "^3.0.0", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-from-markdown/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/mdast-util-gfm": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-2.0.2.tgz", - "integrity": "sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==", + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", "dependencies": { - "mdast-util-from-markdown": "^1.0.0", - "mdast-util-gfm-autolink-literal": "^1.0.0", - "mdast-util-gfm-footnote": "^1.0.0", - "mdast-util-gfm-strikethrough": "^1.0.0", - "mdast-util-gfm-table": "^1.0.0", - "mdast-util-gfm-task-list-item": "^1.0.0", - "mdast-util-to-markdown": "^1.0.0" + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9475,15 +9702,16 @@ } }, "node_modules/mdast-util-gfm-autolink-literal": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-1.0.3.tgz", - "integrity": "sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", + "@types/mdast": "^4.0.0", "ccount": "^2.0.0", - "mdast-util-find-and-replace": "^2.0.0", - "micromark-util-character": "^1.0.0" + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9491,14 +9719,16 @@ } }, "node_modules/mdast-util-gfm-footnote": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-1.0.2.tgz", - "integrity": "sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-to-markdown": "^1.3.0", - "micromark-util-normalize-identifier": "^1.0.0" + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9506,13 +9736,14 @@ } }, "node_modules/mdast-util-gfm-strikethrough": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-1.0.3.tgz", - "integrity": "sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==", + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-to-markdown": "^1.3.0" + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9520,15 +9751,16 @@ } }, "node_modules/mdast-util-gfm-table": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-1.0.7.tgz", - "integrity": "sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==", + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^1.0.0", - "mdast-util-to-markdown": "^1.3.0" + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9536,13 +9768,15 @@ } }, "node_modules/mdast-util-gfm-task-list-item": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-1.0.2.tgz", - "integrity": "sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==", + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-to-markdown": "^1.3.0" + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9550,28 +9784,84 @@ } }, "node_modules/mdast-util-math": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-2.0.2.tgz", - "integrity": "sha512-8gmkKVp9v6+Tgjtq6SYx9kGPpTf6FVYRa53/DLh479aldR9AyP48qeVOgNZ5X7QUK7nOy4yw7vg6mbiGcs9jWQ==", + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", "longest-streak": "^3.0.0", - "mdast-util-to-markdown": "^1.3.0" + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-newline-to-break": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-1.0.0.tgz", - "integrity": "sha512-491LcYv3gbGhhCrLoeALncQmega2xPh+m3gbsIhVsOX4sw85+ShLFPvPyibxc1Swx/6GtzxgVodq+cGa/47ULg==", + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-find-and-replace": "^2.0.0" + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9579,13 +9869,13 @@ } }, "node_modules/mdast-util-phrasing": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz", - "integrity": "sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==", + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "unist-util-is": "^5.0.0" + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" }, "funding": { "type": "opencollective", @@ -9593,18 +9883,20 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz", - "integrity": "sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/mdast": "^3.0.0", - "mdast-util-definitions": "^5.0.0", - "micromark-util-sanitize-uri": "^1.1.0", + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", - "unist-util-generated": "^2.0.0", - "unist-util-position": "^4.0.0", - "unist-util-visit": "^4.0.0" + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", @@ -9612,18 +9904,19 @@ } }, "node_modules/mdast-util-to-markdown": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz", - "integrity": "sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==", + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^3.0.0", - "mdast-util-to-string": "^3.0.0", - "micromark-util-decode-string": "^1.0.0", - "unist-util-visit": "^4.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" }, "funding": { @@ -9631,12 +9924,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-to-markdown/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/mdast-util-to-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", - "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0" + "@types/mdast": "^4.0.0" }, "funding": { "type": "opencollective", @@ -9657,9 +9957,9 @@ "dev": true }, "node_modules/micromark": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", - "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", "funding": [ { "type": "GitHub Sponsors", @@ -9670,30 +9970,31 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", - "micromark-core-commonmark": "^1.0.1", - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-combine-extensions": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-sanitize-uri": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-core-commonmark": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", - "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", "funding": [ { "type": "GitHub Sponsors", @@ -9704,39 +10005,40 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "decode-named-character-reference": "^1.0.0", - "micromark-factory-destination": "^1.0.0", - "micromark-factory-label": "^1.0.0", - "micromark-factory-space": "^1.0.0", - "micromark-factory-title": "^1.0.0", - "micromark-factory-whitespace": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-classify-character": "^1.0.0", - "micromark-util-html-tag-name": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-extension-gfm": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-2.0.3.tgz", - "integrity": "sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==", + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", "license": "MIT", "dependencies": { - "micromark-extension-gfm-autolink-literal": "^1.0.0", - "micromark-extension-gfm-footnote": "^1.0.0", - "micromark-extension-gfm-strikethrough": "^1.0.0", - "micromark-extension-gfm-table": "^1.0.0", - "micromark-extension-gfm-tagfilter": "^1.0.0", - "micromark-extension-gfm-task-list-item": "^1.0.0", - "micromark-util-combine-extensions": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9744,15 +10046,15 @@ } }, "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-1.0.5.tgz", - "integrity": "sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", "license": "MIT", "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-sanitize-uri": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9760,19 +10062,19 @@ } }, "node_modules/micromark-extension-gfm-footnote": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-1.1.2.tgz", - "integrity": "sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", "license": "MIT", "dependencies": { - "micromark-core-commonmark": "^1.0.0", - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-sanitize-uri": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9780,17 +10082,17 @@ } }, "node_modules/micromark-extension-gfm-strikethrough": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-1.0.7.tgz", - "integrity": "sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", "license": "MIT", "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-classify-character": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9798,16 +10100,16 @@ } }, "node_modules/micromark-extension-gfm-table": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-1.0.7.tgz", - "integrity": "sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==", + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", "license": "MIT", "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9815,12 +10117,12 @@ } }, "node_modules/micromark-extension-gfm-tagfilter": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-1.0.2.tgz", - "integrity": "sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==", + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", "license": "MIT", "dependencies": { - "micromark-util-types": "^1.0.0" + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9828,16 +10130,16 @@ } }, "node_modules/micromark-extension-gfm-task-list-item": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-1.0.5.tgz", - "integrity": "sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", "license": "MIT", "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9845,34 +10147,28 @@ } }, "node_modules/micromark-extension-math": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-2.1.2.tgz", - "integrity": "sha512-es0CcOV89VNS9wFmyn+wyFTKweXGW4CEvdaAca6SWRWPyYCbBisnjaHLjWO4Nszuiud84jCpkHsqAJoa768Pvg==", + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", "license": "MIT", "dependencies": { "@types/katex": "^0.16.0", + "devlop": "^1.0.0", "katex": "^0.16.0", - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/micromark-extension-math/node_modules/@types/katex": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", - "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", - "license": "MIT" - }, "node_modules/micromark-factory-destination": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", - "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", "funding": [ { "type": "GitHub Sponsors", @@ -9883,16 +10179,17 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-factory-label": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", - "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", "funding": [ { "type": "GitHub Sponsors", @@ -9903,17 +10200,18 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-factory-space": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", - "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", "funding": [ { "type": "GitHub Sponsors", @@ -9924,15 +10222,16 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-factory-title": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", - "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", "funding": [ { "type": "GitHub Sponsors", @@ -9943,17 +10242,18 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-factory-whitespace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", - "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", "funding": [ { "type": "GitHub Sponsors", @@ -9964,17 +10264,18 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-util-character": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", - "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", "funding": [ { "type": "GitHub Sponsors", @@ -9985,15 +10286,16 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-util-chunked": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", - "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", "funding": [ { "type": "GitHub Sponsors", @@ -10004,14 +10306,15 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-symbol": "^1.0.0" + "micromark-util-symbol": "^2.0.0" } }, "node_modules/micromark-util-classify-character": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", - "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", "funding": [ { "type": "GitHub Sponsors", @@ -10022,16 +10325,17 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-util-combine-extensions": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", - "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", "funding": [ { "type": "GitHub Sponsors", @@ -10042,15 +10346,16 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-types": "^1.0.0" + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", - "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", "funding": [ { "type": "GitHub Sponsors", @@ -10061,14 +10366,15 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-symbol": "^1.0.0" + "micromark-util-symbol": "^2.0.0" } }, "node_modules/micromark-util-decode-string": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", - "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", "funding": [ { "type": "GitHub Sponsors", @@ -10079,17 +10385,18 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-symbol": "^1.0.0" + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, "node_modules/micromark-util-encode": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", - "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", "funding": [ { "type": "GitHub Sponsors", @@ -10099,12 +10406,13 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromark-util-html-tag-name": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", - "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", "funding": [ { "type": "GitHub Sponsors", @@ -10114,12 +10422,13 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromark-util-normalize-identifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", - "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", "funding": [ { "type": "GitHub Sponsors", @@ -10130,14 +10439,15 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-symbol": "^1.0.0" + "micromark-util-symbol": "^2.0.0" } }, "node_modules/micromark-util-resolve-all": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", - "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", "funding": [ { "type": "GitHub Sponsors", @@ -10148,14 +10458,15 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-types": "^1.0.0" + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-util-sanitize-uri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", - "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "funding": [ { "type": "GitHub Sponsors", @@ -10166,16 +10477,17 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-symbol": "^1.0.0" + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, "node_modules/micromark-util-subtokenize": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", - "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", "funding": [ { "type": "GitHub Sponsors", @@ -10186,17 +10498,18 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-util-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", - "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "funding": [ { "type": "GitHub Sponsors", @@ -10206,12 +10519,13 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", "funding": [ { "type": "GitHub Sponsors", @@ -10221,7 +10535,8 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -10341,14 +10656,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "engines": { - "node": ">=4" - } - }, "node_modules/mrmime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", @@ -10408,9 +10715,9 @@ "dev": true }, "node_modules/needle": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", - "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", "dev": true, "license": "MIT", "optional": true, @@ -10811,6 +11118,25 @@ "integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==", "optional": true }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/parse-node-version": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", @@ -10821,12 +11147,6 @@ "node": ">= 0.10" } }, - "node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11837,15 +12157,6 @@ "react-is": "^16.13.1" } }, - "node_modules/property-information": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.0.tgz", - "integrity": "sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -12003,9 +12314,10 @@ }, "node_modules/react": { "name": "@preact/compat", - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@preact/compat/-/compat-17.1.2.tgz", - "integrity": "sha512-7pOZN9lMDDRQ+6aWvjwTp483KR8/zOpfS83wmOo3zfuLKdngS8/5RLbsFWzFZMGdYlotAhX980hJ75bjOHTwWg==", + "version": "18.3.2", + "resolved": "https://registry.npmjs.org/@preact/compat/-/compat-18.3.2.tgz", + "integrity": "sha512-5vSl55K5yLMvocT7PBKxDOHGgYPjMrKQqqr6roSNjIXcJOtSgDDMjpiCAF3s7klRdmGrN75b/Przmjw8gmlg/w==", + "license": "MIT", "peerDependencies": { "preact": "*" } @@ -12024,9 +12336,10 @@ }, "node_modules/react-dom": { "name": "@preact/compat", - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@preact/compat/-/compat-17.1.2.tgz", - "integrity": "sha512-7pOZN9lMDDRQ+6aWvjwTp483KR8/zOpfS83wmOo3zfuLKdngS8/5RLbsFWzFZMGdYlotAhX980hJ75bjOHTwWg==", + "version": "18.3.2", + "resolved": "https://registry.npmjs.org/@preact/compat/-/compat-18.3.2.tgz", + "integrity": "sha512-5vSl55K5yLMvocT7PBKxDOHGgYPjMrKQqqr6roSNjIXcJOtSgDDMjpiCAF3s7klRdmGrN75b/Przmjw8gmlg/w==", + "license": "MIT", "peerDependencies": { "preact": "*" } @@ -12071,42 +12384,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, - "node_modules/react-markdown": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-8.0.7.tgz", - "integrity": "sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/prop-types": "^15.0.0", - "@types/unist": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^2.0.0", - "prop-types": "^15.0.0", - "property-information": "^6.0.0", - "react-is": "^18.0.0", - "remark-parse": "^10.0.0", - "remark-rehype": "^10.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-object": "^0.4.0", - "unified": "^10.0.0", - "unist-util-visit": "^4.0.0", - "vfile": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, - "node_modules/react-markdown/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" - }, "node_modules/react-tabs": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-4.3.0.tgz", @@ -12303,16 +12580,16 @@ } }, "node_modules/rehype-highlight": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-6.0.0.tgz", - "integrity": "sha512-q7UtlFicLhetp7K48ZgZiJgchYscMma7XjzX7t23bqEJF8m6/s+viXQEe4oHjrATTIZpX7RG8CKD7BlNZoh9gw==", + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "hast-util-to-text": "^3.0.0", - "lowlight": "^2.0.0", - "unified": "^10.0.0", - "unist-util-visit": "^4.0.0" + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", @@ -12320,17 +12597,18 @@ } }, "node_modules/rehype-katex": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-6.0.3.tgz", - "integrity": "sha512-ByZlRwRUcWegNbF70CVRm2h/7xy7jQ3R9LaY4VVSvjnoVWwWVhNL60DiZsBpC5tSzYQOCvDbzncIpIjPZWodZA==", + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "@types/katex": "^0.14.0", - "hast-util-from-html-isomorphic": "^1.0.0", - "hast-util-to-text": "^3.1.0", + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", - "unist-util-visit": "^4.0.0" + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", @@ -12338,29 +12616,43 @@ } }, "node_modules/rehype-raw": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-6.1.1.tgz", - "integrity": "sha512-d6AKtisSRtDRX4aSPsJGTfnzrX2ZkHQLE5kiUuGOeEoLpbEulFF4hj0mLPbsa+7vmguDKOVVEQdHKDSwoaIDsQ==", + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "hast-util-raw": "^7.2.0", - "unified": "^10.0.0" + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/remark-breaks": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.3.tgz", - "integrity": "sha512-C7VkvcUp1TPUc2eAYzsPdaUh8Xj4FSbQnYA5A9f80diApLZscTDeG7efiWP65W8hV2sEy3JuGVU0i6qr5D8Hug==", + "node_modules/rehype-react": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/rehype-react/-/rehype-react-8.0.0.tgz", + "integrity": "sha512-vzo0YxYbB2HE+36+9HWXVdxNoNDubx63r5LBzpxBGVWM8s9mdnMdbmuJBAX6TTyuGdZjZix6qU3GcSuKCIWivw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-newline-to-break": "^1.0.0", - "unified": "^10.0.0" + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" }, "funding": { "type": "opencollective", @@ -12368,15 +12660,17 @@ } }, "node_modules/remark-gfm": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz", - "integrity": "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==", + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-gfm": "^2.0.0", - "micromark-extension-gfm": "^2.0.0", - "unified": "^10.0.0" + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" }, "funding": { "type": "opencollective", @@ -12384,15 +12678,15 @@ } }, "node_modules/remark-math": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-5.1.1.tgz", - "integrity": "sha512-cE5T2R/xLVtfFI4cCePtiRn+e6jKMtFDR3P8V3qpv8wpKjwvHoBA4eJzvX+nVrnlNy0911bdGmuspCSwetfYHw==", + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-math": "^2.0.0", - "micromark-extension-math": "^2.0.0", - "unified": "^10.0.0" + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" }, "funding": { "type": "opencollective", @@ -12400,13 +12694,15 @@ } }, "node_modules/remark-parse": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.2.tgz", - "integrity": "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==", + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-from-markdown": "^1.0.0", - "unified": "^10.0.0" + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" }, "funding": { "type": "opencollective", @@ -12414,14 +12710,31 @@ } }, "node_modules/remark-rehype": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-10.1.0.tgz", - "integrity": "sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==", + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "@types/mdast": "^3.0.0", - "mdast-util-to-hast": "^12.1.0", - "unified": "^10.0.0" + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" }, "funding": { "type": "opencollective", @@ -12589,17 +12902,6 @@ "tslib": "^2.1.0" } }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -12679,6 +12981,7 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, + "license": "MIT", "optional": true, "peer": true }, @@ -13662,6 +13965,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -13714,12 +14031,28 @@ "webpack": "^5.27.0" } }, - "node_modules/style-to-object": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", - "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-js/node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/style-to-js/node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", "dependencies": { - "inline-style-parser": "0.1.1" + "inline-style-parser": "0.2.7" } }, "node_modules/stylehacks": { @@ -14053,6 +14386,15 @@ "webpack": "^5.0.0" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmmirror.com/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/tldts": { "version": "7.0.25", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", @@ -14359,126 +14701,136 @@ } }, "node_modules/unified": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", - "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", + "@types/unist": "^3.0.0", "bail": "^2.0.0", + "devlop": "^1.0.0", "extend": "^3.0.0", - "is-buffer": "^2.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", - "vfile": "^5.0.0" + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/unified/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } + "node_modules/unified/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" }, "node_modules/unist-util-find-after": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-4.0.1.tgz", - "integrity": "sha512-QO/PuPMm2ERxC6vFXEPtmAutOopy5PknD+Oq64gGwxKtk4xwo9Z97t9Av1obPmGU0IyTa6EKYUfTrK2QJS3Ozw==", + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-generated": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-2.0.1.tgz", - "integrity": "sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } + "node_modules/unist-util-find-after/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" }, "node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0" + "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-is/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/unist-util-position": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz", - "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==", + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0" + "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-position/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/unist-util-remove-position": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-4.0.2.tgz", - "integrity": "sha512-TkBb0HABNmxzAcfLf4qsIbFbaPDvMO6wa3b3j4VcEzFVaw1LBKwnW4/sRJ/atSLSzoIg41JWEdnE7N6DIhGDGQ==", + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-visit": "^4.0.0" + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-stringify-position": { + "node_modules/unist-util-remove-position/node_modules/@types/unist": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", - "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0" + "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-stringify-position/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/unist-util-visit": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", - "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.1.1" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, "funding": { "type": "opencollective", @@ -14486,18 +14838,31 @@ } }, "node_modules/unist-util-visit-parents": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", - "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-visit-parents/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/unist-util-visit/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -14587,23 +14952,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/uvu": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", - "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", - "dependencies": { - "dequal": "^2.0.0", - "diff": "^5.0.0", - "kleur": "^4.0.3", - "sade": "^1.7.3" - }, - "bin": { - "uvu": "bin.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -14627,14 +14975,13 @@ "license": "MIT" }, "node_modules/vfile": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", - "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "is-buffer": "^2.0.0", - "unist-util-stringify-position": "^3.0.0", - "vfile-message": "^3.0.0" + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" }, "funding": { "type": "opencollective", @@ -14642,53 +14989,50 @@ } }, "node_modules/vfile-location": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-4.1.0.tgz", - "integrity": "sha512-YF23YMyASIIJXpktBa4vIGLJ5Gs88UB/XePgqPmTa7cDA+JeO3yclbpheQYCHjVHBn/yePzrXuygIL+xbvRYHw==", + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "vfile": "^5.0.0" + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/vfile-message": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", - "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^3.0.0" + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/vfile/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } + "node_modules/vfile-message/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/vfile/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" }, "node_modules/void-elements": { "version": "3.1.0", @@ -14727,7 +15071,7 @@ }, "node_modules/web-namespaces": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "resolved": "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz", "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", "license": "MIT", "funding": { diff --git a/package.json b/package.json index 1e159d5eb..1b7b52494 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ } }, "dependencies": { + "@aeven-ai/hypermarkdown": "^0.4.4", "@babel/runtime": "^7.24.7", "@mozilla/readability": "^0.6.0", "@nem035/gpt-3-encoder": "^1.1.7", @@ -44,32 +45,27 @@ "buffer": "^6.0.3", "countries-list": "^2.6.1", "file-saver": "^2.0.5", - "github-markdown-css": "^5.9.0", "gpt-3-encoder": "^1.1.4", "i18next": "^22.5.1", "js-sha3": "^0.9.3", - "katex": "^0.16.11", + "katex": "^0.16.47", "lodash-es": "^4.17.21", - "parse5": "^6.0.1", "preact": "^10.22.1", "process": "^0.11.10", "prop-types": "^15.8.1", "random-int": "^3.1.0", - "react": "npm:@preact/compat@^17.1.2", + "react": "npm:@preact/compat@^18.3.2", "react-bootstrap-icons": "^1.11.6", - "react-dom": "npm:@preact/compat@^17.1.2", + "react-dom": "npm:@preact/compat@^18.3.2", "react-draggable": "^4.4.6", "react-i18next": "^12.3.1", - "react-markdown": "^8.0.7", "react-tabs": "^4.3.0", "react-toastify": "^9.1.3", - "rehype-highlight": "^6.0.0", - "rehype-katex": "^6.0.3", - "rehype-raw": "^6.1.1", - "remark-breaks": "^3.0.3", - "remark-gfm": "^3.0.1", - "remark-math": "^5.1.1", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", "stream-browserify": "^3.0.0", + "tippy.js": "^6.3.7", "util": "^0.12.5", "uuid": "^11.1.1", "webextension-polyfill": "^0.12.0" @@ -101,6 +97,9 @@ "prettier": "^2.8.8", "progress-bar-webpack-plugin": "^2.1.0", "publish-browser-extension": "^4.0.5", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "run-script-os": "^1.1.6", "sass": "^1.91.0", "sass-embedded": "^1.91.0", @@ -108,6 +107,7 @@ "string-replace-loader": "^3.3.0", "style-loader": "^4.0.0", "thread-loader": "^4.0.4", + "unified": "^11.0.5", "webpack": "^5.92.1", "webpack-bundle-analyzer": "^4.10.2" }, diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 8ad74e6ef..830240b74 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -31,6 +31,8 @@ "You": "You", "Collapse": "Collapse", "Expand": "Expand", + "Thinking Content": "Thinking content", + "Thought for {seconds}s": "Thought for {seconds}s", "Stop": "Stop", "Continue on official website": "Continue on official website", "Error": "Error", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index cfad6d2d1..6d130f8b6 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -179,6 +179,7 @@ "ChatGLM (CharGLM-3)": "ChatGLM (CharGLM-3, 角色扮演)", "Crop Text to ensure the input tokens do not exceed the model's limit": "裁剪文本以确保输入token不超过模型限制", "Thinking Content": "思考内容", + "Thought for {seconds}s": "思考了 {seconds} 秒", "This provider is still used by other API modes or saved conversations": "此提供商仍被其他 API 模式或已保存的对话使用", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "此 API 密钥设定在所选的自定义模式上。在这里编辑会为该模式创建专用提供商。", "Use shared key": "使用共享提供商密钥", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index 3dd20a229..9353c4196 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -174,6 +174,7 @@ "Custom": "自訂", "Crop Text to ensure the input tokens do not exceed the model's limit": "裁剪文本以確保輸入token不超過模型限制", "Thinking Content": "思考內容", + "Thought for {seconds}s": "思考了 {seconds} 秒", "This provider is still used by other API modes or saved conversations": "此供應商仍被其他 API 模式或已儲存的對話使用中", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "此 API 金鑰已設在目前選取的自訂模式。在這裡編輯會為該模式建立專用供應商。", "Use shared key": "使用共用供應商金鑰", diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index f3476d756..7d1a52e83 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -533,7 +533,7 @@ function ConversationCard(props) { setSession((currentSession) => finalizeInterruptedSession(currentSession, '', retryRecord)) partialAnswerRef.current = '' retryRecordRef.current = null - updateAnswer(e, false, 'error') + updateAnswer(e?.message ?? String(e), false, 'error') setIsReady(true) } } @@ -788,6 +788,7 @@ function ConversationCard(props) { type={data.type} descName={data.type === 'answer' && currentAiName} onRetry={idx === conversationItemData.length - 1 ? retryFn : null} + done={data.done} /> ))} @@ -833,7 +834,7 @@ function ConversationCard(props) { await postMessage({ session: newSession }) } catch (e) { if (disposedRef.current) return - updateAnswer(e, false, 'error') + updateAnswer(e?.message ?? String(e), false, 'error') } if (disposedRef.current || !bodyRef.current) return bodyRef.current.scrollTo({ diff --git a/src/components/ConversationItem/index.jsx b/src/components/ConversationItem/index.jsx index d9e9e9f35..2e999768f 100644 --- a/src/components/ConversationItem/index.jsx +++ b/src/components/ConversationItem/index.jsx @@ -16,7 +16,7 @@ AnswerTitle.propTypes = { descName: PropTypes.string, } -export function ConversationItem({ type, content, descName, onRetry }) { +export function ConversationItem({ type, content, descName, onRetry, done = true }) { const { t } = useTranslation() const [collapsed, setCollapsed] = useState(false) @@ -85,7 +85,7 @@ export function ConversationItem({ type, content, descName, onRetry }) { )} - {!collapsed && {content}} + {!collapsed && {content}} ) case 'error': @@ -130,6 +130,7 @@ ConversationItem.propTypes = { content: PropTypes.string.isRequired, descName: PropTypes.string, onRetry: PropTypes.func, + done: PropTypes.bool, } export default memo(ConversationItem) diff --git a/src/components/MarkdownRender/Pre.jsx b/src/components/MarkdownRender/Pre.jsx deleted file mode 100644 index 09fb4d3a4..000000000 --- a/src/components/MarkdownRender/Pre.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import CopyButton from '../CopyButton' -import PropTypes from 'prop-types' -import { changeChildrenFontSize } from '../../utils' - -export function Pre({ className, children }) { - const preRef = useRef(null) - const [fontSize, setFontSize] = useState(14) - const sizeList = [10, 12, 14, 16, 18] - - useEffect(() => { - changeChildrenFontSize(preRef.current.childNodes[1], fontSize + 'px') - }) - - return ( -
-      
-        
-         preRef.current.childNodes[1].textContent} size={14} />
-      
-      {children}
-    
- ) -} - -Pre.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.object.isRequired, -} diff --git a/src/components/MarkdownRender/list-markers.mjs b/src/components/MarkdownRender/list-markers.mjs new file mode 100644 index 000000000..fcdf3a6c0 --- /dev/null +++ b/src/components/MarkdownRender/list-markers.mjs @@ -0,0 +1,13 @@ +/** + * HyperMarkdown tags a list that begins at 1 with `start="0"` while it streams. Browsers + * honour that literally and number the list from zero, so the attribute has to go before + * the native markers are drawn. Lists that genuinely resume at another number keep theirs. + * @param {ParentNode | null | undefined} container + * @returns {number} how many lists were corrected + */ +export function normalizeListStarts(container) { + const lists = container?.querySelectorAll?.('ol[start="0"]') + if (!lists) return 0 + for (const list of lists) list.removeAttribute('start') + return lists.length +} diff --git a/src/components/MarkdownRender/markdown-without-katex.jsx b/src/components/MarkdownRender/markdown-without-katex.jsx deleted file mode 100644 index 498bcc673..000000000 --- a/src/components/MarkdownRender/markdown-without-katex.jsx +++ /dev/null @@ -1,198 +0,0 @@ -import ReactMarkdown from 'react-markdown' -import rehypeRaw from 'rehype-raw' -import rehypeHighlight from 'rehype-highlight' -import remarkGfm from 'remark-gfm' -import remarkBreaks from 'remark-breaks' -import { Pre } from './Pre' -import { Hyperlink } from './Hyperlink' -import { highlightOptions } from './highlight-options.mjs' -import { memo, useState } from 'react' -import { useTranslation } from 'react-i18next' - -// eslint-disable-next-line -const ThinkComponent = ({ node, children, ...props }) => { - const { t } = useTranslation() - const [isExpanded, setIsExpanded] = useState(true) - const isEmpty = - !children || - (Array.isArray(children) && - // eslint-disable-next-line - (children.length === 0 || - // eslint-disable-next-line - (children.length === 1 && typeof children[0] === 'string' && children[0].trim() === ''))) - - const toggleExpanded = () => { - setIsExpanded(!isExpanded) - } - - return isEmpty ? ( - <> - ) : ( -
-
-
- - - 💭 {t('Thinking Content')} - -
-
- ▼ -
-
-
-
- {children} -
-
- -
- ) -} - -export function MarkdownRender(props) { - return ( -
- - {props.children.replace('', '\n\n\n\n')} - -
- ) -} - -MarkdownRender.propTypes = { - ...ReactMarkdown.propTypes, -} - -export default memo(MarkdownRender) diff --git a/src/components/MarkdownRender/markdown.jsx b/src/components/MarkdownRender/markdown.jsx index 8f92ac47b..5ff5844ae 100644 --- a/src/components/MarkdownRender/markdown.jsx +++ b/src/components/MarkdownRender/markdown.jsx @@ -1,201 +1,94 @@ -import './mykatex.min.css' -import ReactMarkdown from 'react-markdown' -import rehypeRaw from 'rehype-raw' -import rehypeHighlight from 'rehype-highlight' -import rehypeKatex from 'rehype-katex' -import remarkMath from 'remark-math' -import remarkGfm from 'remark-gfm' -import remarkBreaks from 'remark-breaks' -import { Pre } from './Pre' +import { memo, useLayoutEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' +import { HyperMarkdown } from '@aeven-ai/hypermarkdown' +import { highlightPlugin } from '@aeven-ai/hypermarkdown/plugins/code' import { Hyperlink } from './Hyperlink' import { highlightOptions } from './highlight-options.mjs' -import { memo, useState } from 'react' -import { useTranslation } from 'react-i18next' - -// eslint-disable-next-line -const ThinkComponent = ({ node, children, ...props }) => { - const { t } = useTranslation() - const [isExpanded, setIsExpanded] = useState(true) - const isEmpty = - !children || - (Array.isArray(children) && - // eslint-disable-next-line - (children.length === 0 || - // eslint-disable-next-line - (children.length === 1 && typeof children[0] === 'string' && children[0].trim() === ''))) - - const toggleExpanded = () => { - setIsExpanded(!isExpanded) - } - - return isEmpty ? ( - <> - ) : ( -
-
-
- - - 💭 {t('Thinking Content')} - -
-
- ▼ -
-
-
-
- {children} -
-
- -
- ) -} - -export function MarkdownRender(props) { - return ( -
- { + const container = containerRef.current + if (!container) return + const correctStarts = () => normalizeListStarts(container) + correctStarts() + const observer = new MutationObserver(correctStarts) + observer.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['start'], + }) + return () => observer.disconnect() + }, []) - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', + // Answers arrive as a growing snapshot, but the renderer takes deltas and caches the + // blocks it has settled, so only the new text is parsed on each update. + useLayoutEffect(() => { + const renderer = rendererRef.current + if (!renderer) return + const step = deltaRef.current.next(children, done) + if (!step) return + if (step.reset) renderer.reset() + renderer.write(step.write, step.finalize) + }) - 'thead', - 'tbody', - 'tfoot', - 'u', - 's', - 'a', - 'pre', - 'cite', + // `{seconds}` is filled in by the renderer, and is not i18next interpolation syntax. + const translations = useMemo( + () => ({ thinking: t('Thinking Content'), thoughtFor: t('Thought for {seconds}s') }), + [t], + ) - 'think', - ]} - unwrapDisallowed={true} - remarkPlugins={[remarkMath, remarkGfm, remarkBreaks]} - rehypePlugins={[rehypeKatex, rehypeRaw, [rehypeHighlight, highlightOptions]]} - components={{ - a: Hyperlink, - pre: Pre, - think: ThinkComponent, - }} - {...props} - > - {props.children.replace('', '\n\n\n\n')} - + return ( +
+
) } MarkdownRender.propTypes = { - ...ReactMarkdown.propTypes, + children: PropTypes.string.isRequired, + done: PropTypes.bool, } export default memo(MarkdownRender) diff --git a/src/components/MarkdownRender/math-plugin-without-katex.mjs b/src/components/MarkdownRender/math-plugin-without-katex.mjs new file mode 100644 index 000000000..76abf9804 --- /dev/null +++ b/src/components/MarkdownRender/math-plugin-without-katex.mjs @@ -0,0 +1,2 @@ +/** The minimal build ships without KaTeX, so maths stays literal text. */ +export const mathPlugin = () => undefined diff --git a/src/components/MarkdownRender/math-plugin.mjs b/src/components/MarkdownRender/math-plugin.mjs new file mode 100644 index 000000000..8f34e7265 --- /dev/null +++ b/src/components/MarkdownRender/math-plugin.mjs @@ -0,0 +1,4 @@ +import { katexPlugin } from '@aeven-ai/hypermarkdown/plugins/math' + +/** The minimal build swaps this module for math-plugin-without-katex.mjs; see build.mjs. */ +export const mathPlugin = katexPlugin diff --git a/src/components/MarkdownRender/mykatex-without-katex.css b/src/components/MarkdownRender/mykatex-without-katex.css new file mode 100644 index 000000000..a47a190c5 --- /dev/null +++ b/src/components/MarkdownRender/mykatex-without-katex.css @@ -0,0 +1 @@ +/* The minimal build swaps out mykatex.min.css; see build.mjs. */ diff --git a/src/components/MarkdownRender/stream-delta.mjs b/src/components/MarkdownRender/stream-delta.mjs new file mode 100644 index 000000000..5217d5d01 --- /dev/null +++ b/src/components/MarkdownRender/stream-delta.mjs @@ -0,0 +1,32 @@ +/** + * Tracks what has already been handed to a streaming renderer, so a growing snapshot can + * be fed to it as deltas. Returns null when there is nothing to do. + * @returns {{ next: (content: string, done: boolean) => ({reset: boolean, write: string, finalize: boolean} | null) }} + */ +export function createStreamDelta() { + let written = null + let finalized = false + + return { + next(content, done) { + // The first snapshot, and one that no longer extends what was written, both start a + // new render rather than growing the previous one. + if (written === null || !content.startsWith(written)) { + written = content + finalized = done + return { reset: true, write: content, finalize: done } + } + const delta = content.slice(written.length) + if (delta) { + written = content + finalized = done + return { reset: false, write: delta, finalize: done } + } + if (done && !finalized) { + finalized = true + return { reset: false, write: '', finalize: true } + } + return null + }, + } +} diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx index c49164a8b..ce706f5e1 100644 --- a/src/content-script/index.jsx +++ b/src/content-script/index.jsx @@ -1,4 +1,9 @@ import './styles.scss' +// The Markdown renderer lives in the shared chunk, so its stylesheets are pulled in here +// instead; without this the minimal variants would emit them into an unshipped chunk. +import '@aeven-ai/hypermarkdown/styles.css' +import 'tippy.js/dist/tippy.css' +import '../components/MarkdownRender/mykatex.min.css' import { unmountComponentAtNode } from 'react-dom' import { render } from 'preact' import DecisionCard from '../components/DecisionCard' diff --git a/src/content-script/styles.scss b/src/content-script/styles.scss index be52d8c2c..d448e6f99 100644 --- a/src/content-script/styles.scss +++ b/src/content-script/styles.scss @@ -146,6 +146,34 @@ word-wrap: break-word; } + // HyperMarkdown draws list markers through `li::before` rules that match any item with + // an ancestor list of that kind, and through a counter that ignores the `start` attribute + // the renderer emits for lists that do not begin at 1. Bullets nested in a numbered list + // therefore come out numbered, and a list that resumes at 3 comes out as "1. 2." (or + // "1.1." once the native marker is added on top). Let the browser draw the markers + // instead: they follow the list type, honour `start`, and survive re-rendering. + .markdown-body .hypermarkdown ol, + .markdown-body .hypermarkdown ul { + list-style-position: outside !important; + padding-left: 2em !important; + } + + .markdown-body .hypermarkdown ol { + list-style-type: decimal !important; + } + + .markdown-body .hypermarkdown ul { + list-style-type: disc !important; + } + + .markdown-body .hypermarkdown li { + padding-left: 0 !important; + } + + .markdown-body .hypermarkdown li::before { + content: none !important; + } + .markdown-body .octicon { display: inline-block; fill: currentColor; diff --git a/src/utils/change-children-font-size.mjs b/src/utils/change-children-font-size.mjs deleted file mode 100644 index b65f236b9..000000000 --- a/src/utils/change-children-font-size.mjs +++ /dev/null @@ -1,10 +0,0 @@ -export function changeChildrenFontSize(element, size) { - try { - element.style.fontSize = size - } catch { - /* empty */ - } - for (let i = 0; i < element.childNodes.length; i++) { - changeChildrenFontSize(element.childNodes[i], size) - } -} diff --git a/src/utils/index.mjs b/src/utils/index.mjs index ef82e9846..f0e5bb4c5 100644 --- a/src/utils/index.mjs +++ b/src/utils/index.mjs @@ -1,4 +1,3 @@ -export * from './change-children-font-size' export * from './create-element-at-position' export * from './crop-text' export * from './ends-with-question-mark' diff --git a/tests/setup/content-script-selection-toolbar-loader-hooks.mjs b/tests/setup/content-script-selection-toolbar-loader-hooks.mjs index 912c43f62..b0e3485ef 100644 --- a/tests/setup/content-script-selection-toolbar-loader-hooks.mjs +++ b/tests/setup/content-script-selection-toolbar-loader-hooks.mjs @@ -3,6 +3,9 @@ import { fileURLToPath } from 'node:url' const contentScriptStubs = new Map([ ['./styles.scss', 'test:styles'], + ['@aeven-ai/hypermarkdown/styles.css', 'test:markdown-styles'], + ['tippy.js/dist/tippy.css', 'test:tippy-styles'], + ['../components/MarkdownRender/mykatex.min.css', 'test:katex-styles'], ['../components/DecisionCard', 'test:decision-card'], ['./site-adapters', 'test:site-adapters'], ['./selection-tools', 'test:selection-tools'], @@ -34,6 +37,9 @@ const floatingToolbarStubs = new Map([ const sources = { 'test:styles': '', + 'test:markdown-styles': '', + 'test:tippy-styles': '', + 'test:katex-styles': '', 'test:decision-card': 'export default function DecisionCard() { return null }', 'test:site-adapters': 'export const config = {}', 'test:selection-tools': 'export const config = {}', diff --git a/tests/unit/components/list-markers.test.mjs b/tests/unit/components/list-markers.test.mjs new file mode 100644 index 000000000..66c459249 --- /dev/null +++ b/tests/unit/components/list-markers.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { JSDOM } from 'jsdom' +import { normalizeListStarts } from '../../../src/components/MarkdownRender/list-markers.mjs' + +function createContainer(html) { + const dom = new JSDOM(`
${html}
`) + return dom.window.document.getElementById('root') +} + +test('a list the renderer marked as starting at 0 is numbered from 1 again', () => { + const container = createContainer('
  1. Alpha
  2. Beta
') + + assert.equal(normalizeListStarts(container), 1) + assert.equal(container.querySelector('ol').hasAttribute('start'), false) +}) + +test('a list that really resumes at another number keeps its start', () => { + const container = createContainer('
  1. Gamma
  2. Delta
') + + assert.equal(normalizeListStarts(container), 0) + assert.equal(container.querySelector('ol').getAttribute('start'), '3') +}) + +test('nested lists are corrected too and a missing container is tolerated', () => { + const container = createContainer( + '
  1. Alpha
    • nested
  1. Beta
', + ) + + assert.equal(normalizeListStarts(container), 2) + assert.equal(container.querySelectorAll('ol[start]').length, 0) + assert.equal(normalizeListStarts(null), 0) +}) diff --git a/tests/unit/components/stream-delta.test.mjs b/tests/unit/components/stream-delta.test.mjs new file mode 100644 index 000000000..ac94d2e27 --- /dev/null +++ b/tests/unit/components/stream-delta.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createStreamDelta } from '../../../src/components/MarkdownRender/stream-delta.mjs' + +test('the first snapshot is written whole', () => { + const stream = createStreamDelta() + + assert.deepEqual(stream.next('Hello', false), { + reset: true, + write: 'Hello', + finalize: false, + }) +}) + +test('growth is written as a delta', () => { + const stream = createStreamDelta() + stream.next('Hello', false) + + assert.deepEqual(stream.next('Hello world', false), { + reset: false, + write: ' world', + finalize: false, + }) +}) + +test('an unchanged snapshot does nothing', () => { + const stream = createStreamDelta() + stream.next('Hello', false) + + assert.equal(stream.next('Hello', false), null) +}) + +test('completion finalizes once, and only after the last text', () => { + const stream = createStreamDelta() + stream.next('Hello', false) + stream.next('Hello world', false) + + assert.deepEqual(stream.next('Hello world!', true), { + reset: false, + write: '!', + finalize: true, + }) + assert.equal(stream.next('Hello world!', true), null) +}) + +test('completion with no trailing text still finalizes once', () => { + const stream = createStreamDelta() + stream.next('Hello', false) + + assert.deepEqual(stream.next('Hello', true), { reset: false, write: '', finalize: true }) + assert.equal(stream.next('Hello', true), null) +}) + +test('a snapshot that does not extend the last one restarts the renderer', () => { + const stream = createStreamDelta() + stream.next('Hello world', true) + + assert.deepEqual(stream.next('Fresh', false), { + reset: true, + write: 'Fresh', + finalize: false, + }) +}) + +test('a completed answer already on screen is left alone', () => { + const stream = createStreamDelta() + + assert.deepEqual(stream.next('Stored answer', true), { + reset: true, + write: 'Stored answer', + finalize: true, + }) + assert.equal(stream.next('Stored answer', true), null) +}) From 8a12971aa8fd734d2c4fc17130610fae20169707 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 17:58:46 +0800 Subject: [PATCH 07/12] Show reasoning content from reasoning models Reasoning models put their thinking in `delta.reasoning_content` (DeepSeek R1) or `delta.reasoning` (OpenRouter-style) instead of `delta.content`. `buildMessageAnswer()` read only `delta.content`, so that text was dropped entirely and the answer appeared as if out of nowhere. The thinking is now streamed to the card on its own channel (a `{reasoning}` port message) and rendered as a reasoning block ahead of the answer: it opens while it arrives, collapses with a duration once it stops, and can be reopened afterwards. It is deliberately not merged into the answer. `pushRecord()` still stores only the answer text, so thinking never re-enters the conversation context on the next turn, which is what DeepSeek does with `reasoning_content` too. Both halves - thinking is shown, thinking is not sent back - are asserted in `tests/unit/services/apis/reasoning-content.test.mjs`. ``-style tags that a provider streams inside the answer are split out for the same reason (`inline-reasoning.mjs`): a leading block moves to the reasoning channel so it stays out of the saved answer, while tags the user typed - or that appear in prose or code - are rendered literally (`special-tags.mjs`). A block that never closes is only treated as thinking while the stream is still running; once it ends, the text is kept as the answer so a response truncated mid-thinking is not lost from the conversation record. Reasoning-only chunks no longer repost an unchanged answer, and a retry, a model switch or an error replacement clears the previous thinking. Requested in #839. --- src/components/ConversationCard/index.jsx | 45 ++++++- src/components/ConversationItem/index.jsx | 18 ++- src/components/MarkdownRender/markdown.jsx | 16 ++- .../MarkdownRender/reasoning-content.mjs | 24 ++++ .../MarkdownRender/special-tags.mjs | 80 ++++++++++++ src/services/apis/inline-reasoning.mjs | 29 +++++ src/services/apis/openai-compatible-core.mjs | 59 ++++++++- .../components/reasoning-content.test.mjs | 37 ++++++ tests/unit/components/special-tags.test.mjs | 55 ++++++++ tests/unit/services/apis/custom-api.test.mjs | 11 +- .../services/apis/inline-reasoning.test.mjs | 57 ++++++++ .../services/apis/reasoning-content.test.mjs | 122 ++++++++++++++++++ 12 files changed, 532 insertions(+), 21 deletions(-) create mode 100644 src/components/MarkdownRender/reasoning-content.mjs create mode 100644 src/components/MarkdownRender/special-tags.mjs create mode 100644 src/services/apis/inline-reasoning.mjs create mode 100644 tests/unit/components/reasoning-content.test.mjs create mode 100644 tests/unit/components/special-tags.test.mjs create mode 100644 tests/unit/services/apis/inline-reasoning.test.mjs create mode 100644 tests/unit/services/apis/reasoning-content.test.mjs diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 7d1a52e83..036d5b64b 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -61,11 +61,12 @@ class ConversationItemData extends Object { * @param {string} content * @param {bool} done */ - constructor(type, content, done = false) { + constructor(type, content, done = false, reasoning = '') { super() this.type = type this.content = content this.done = done + this.reasoning = reasoning } } @@ -208,8 +209,9 @@ function ConversationCard(props) { * @param {boolean} appended * @param {'question'|'answer'|'error'} newType * @param {boolean} done + * @param {string} [reasoning] reasoning for the replacement; omit to keep the existing one */ - const updateAnswer = (value, appended, newType, done = false) => { + const updateAnswer = (value, appended, newType, done = false, reasoning) => { setConversationItemData((old) => { const copy = [...old] const index = findLastIndex(copy, (v) => v.type === 'answer' || v.type === 'error') @@ -217,8 +219,25 @@ function ConversationCard(props) { copy[index] = new ConversationItemData( newType, appended ? copy[index].content + value : value, + done, + reasoning === undefined ? copy[index].reasoning : reasoning, + ) + return copy + }) + } + + // Thinking arrives alongside the answer and stays out of the conversation records. + const updateReasoning = (value) => { + setConversationItemData((old) => { + const copy = [...old] + const index = findLastIndex(copy, (v) => v.type === 'answer') + if (index === -1) return copy + copy[index] = new ConversationItemData( + copy[index].type, + copy[index].content, + copy[index].done, + value, ) - copy[index].done = done return copy }) } @@ -245,6 +264,7 @@ function ConversationCard(props) { partialAnswerRef.current = msg.answer answerBufferRef.current.push(msg.answer) } + if (msg.reasoning) updateReasoning(msg.reasoning) if (msg.session) { setSession(msg.done ? { ...msg.session, isRetry: false } : msg.session) } @@ -279,6 +299,8 @@ function ConversationCard(props) { )}`, false, 'error', + false, + '', ) break case 'CLOUDFLARE': @@ -293,6 +315,8 @@ function ConversationCard(props) { )}`, false, 'error', + false, + '', ) break default: { @@ -504,7 +528,14 @@ function ConversationCard(props) { const getRetryFn = (session) => async () => { answerBufferRef.current.discard() - updateAnswer(`

${t('Waiting for response...')}

`, false, 'answer') + // A retry starts a new generation, so the previous attempt's reasoning must go too. + updateAnswer( + `

${t('Waiting for response...')}

`, + false, + 'answer', + false, + '', + ) setIsReady(false) const conversationRecords = session.conversationRecords.map((record) => ({ ...record })) @@ -533,7 +564,8 @@ function ConversationCard(props) { setSession((currentSession) => finalizeInterruptedSession(currentSession, '', retryRecord)) partialAnswerRef.current = '' retryRecordRef.current = null - updateAnswer(e?.message ?? String(e), false, 'error') + // The renderer takes text, so the thrown error is stored as its message. + updateAnswer(e?.message ?? String(e), false, 'error', false, '') setIsReady(true) } } @@ -789,6 +821,7 @@ function ConversationCard(props) { descName={data.type === 'answer' && currentAiName} onRetry={idx === conversationItemData.length - 1 ? retryFn : null} done={data.done} + reasoning={data.reasoning} /> ))}
@@ -834,7 +867,7 @@ function ConversationCard(props) { await postMessage({ session: newSession }) } catch (e) { if (disposedRef.current) return - updateAnswer(e?.message ?? String(e), false, 'error') + updateAnswer(e?.message ?? String(e), false, 'error', false, '') } if (disposedRef.current || !bodyRef.current) return bodyRef.current.scrollTo({ diff --git a/src/components/ConversationItem/index.jsx b/src/components/ConversationItem/index.jsx index 2e999768f..9e70dd66d 100644 --- a/src/components/ConversationItem/index.jsx +++ b/src/components/ConversationItem/index.jsx @@ -16,7 +16,14 @@ AnswerTitle.propTypes = { descName: PropTypes.string, } -export function ConversationItem({ type, content, descName, onRetry, done = true }) { +export function ConversationItem({ + type, + content, + descName, + onRetry, + done = true, + reasoning = '', +}) { const { t } = useTranslation() const [collapsed, setCollapsed] = useState(false) @@ -48,7 +55,7 @@ export function ConversationItem({ type, content, descName, onRetry, done = true )} - {!collapsed && {content}} + {!collapsed && {content}} ) case 'answer': @@ -85,7 +92,11 @@ export function ConversationItem({ type, content, descName, onRetry, done = true )} - {!collapsed && {content}} + {!collapsed && ( + + {content} + + )} ) case 'error': @@ -131,6 +142,7 @@ ConversationItem.propTypes = { descName: PropTypes.string, onRetry: PropTypes.func, done: PropTypes.bool, + reasoning: PropTypes.string, } export default memo(ConversationItem) diff --git a/src/components/MarkdownRender/markdown.jsx b/src/components/MarkdownRender/markdown.jsx index 5ff5844ae..5985302ee 100644 --- a/src/components/MarkdownRender/markdown.jsx +++ b/src/components/MarkdownRender/markdown.jsx @@ -7,6 +7,8 @@ import { Hyperlink } from './Hyperlink' import { highlightOptions } from './highlight-options.mjs' import { mathPlugin } from './math-plugin.mjs' import { normalizeListStarts } from './list-markers.mjs' +import { buildStreamedContent } from './reasoning-content.mjs' +import { escapeReasoningTags } from './special-tags.mjs' import { createStreamDelta } from './stream-delta.mjs' // This component can land in a shared chunk, whose CSS is never packaged (see build.mjs), @@ -26,13 +28,21 @@ const ALLOWED_TAGS = { p: ['className'] } * @param {object} props * @param {string} props.children markdown, or the whole answer so far while streaming * @param {boolean} [props.done] false while the answer is still arriving + * @param {string} [props.reasoning] thinking to show ahead of the answer + * @param {boolean} [props.literalTags] the text is user input: every reasoning tag is shown + * as written, instead of a leading block being presented as thinking */ -export function MarkdownRender({ children, done = true }) { +export function MarkdownRender({ children, done = true, reasoning = '', literalTags = false }) { const { t } = useTranslation() const rendererRef = useRef(null) const containerRef = useRef(null) const deltaRef = useRef(null) if (deltaRef.current === null) deltaRef.current = createStreamDelta() + const content = buildStreamedContent( + escapeReasoningTags(children, { preserveLeadingBlock: !literalTags }), + reasoning, + done, + ) // The renderer draws its own markers with `li::before`, but a nested bullet list inherits // the numbered marker of the list around it and its counter ignores `start`. The markers @@ -58,7 +68,7 @@ export function MarkdownRender({ children, done = true }) { useLayoutEffect(() => { const renderer = rendererRef.current if (!renderer) return - const step = deltaRef.current.next(children, done) + const step = deltaRef.current.next(content, done) if (!step) return if (step.reset) renderer.reset() renderer.write(step.write, step.finalize) @@ -89,6 +99,8 @@ export function MarkdownRender({ children, done = true }) { MarkdownRender.propTypes = { children: PropTypes.string.isRequired, done: PropTypes.bool, + reasoning: PropTypes.string, + literalTags: PropTypes.bool, } export default memo(MarkdownRender) diff --git a/src/components/MarkdownRender/reasoning-content.mjs b/src/components/MarkdownRender/reasoning-content.mjs new file mode 100644 index 000000000..d147c7340 --- /dev/null +++ b/src/components/MarkdownRender/reasoning-content.mjs @@ -0,0 +1,24 @@ +// The card shows its "waiting for response" placeholder until the first answer chunk +// arrives. That placeholder is not an answer, so a still-thinking model must not be +// treated as if the answer had already started. +const ANSWER_PLACEHOLDER_CLASS = 'gpt-loading' + +/** + * Build the document handed to the streaming renderer. + * + * HyperMarkdown keeps a reasoning block streamed — expanded, extended in place, with its + * elapsed timer running — only while the closing tag is missing, and collapses it (freezing + * the timer) once the tag arrives. So the tag is added when the thinking has actually + * stopped: the answer has started arriving, or the stream has ended. + * @param {string} children the answer so far, or the card's loading placeholder + * @param {string} reasoning the thinking so far + * @param {boolean} done whether the response stream has finished + * @returns {string} + */ +export function buildStreamedContent(children, reasoning, done) { + if (!reasoning) return children + const answer = children.includes(ANSWER_PLACEHOLDER_CLASS) ? '' : children + const reasoningFinished = done || Boolean(answer) + if (!reasoningFinished) return `\n${reasoning}` + return `\n${reasoning}\n\n\n${answer}` +} diff --git a/src/components/MarkdownRender/special-tags.mjs b/src/components/MarkdownRender/special-tags.mjs new file mode 100644 index 000000000..78e8b5d99 --- /dev/null +++ b/src/components/MarkdownRender/special-tags.mjs @@ -0,0 +1,80 @@ +const REASONING_TAG_PATTERN = /<\/?\s*(?:think|thinking|reasoning)\b[^>]*>/gi +const FENCE_PATTERN = /^ {0,3}(?:```+|~~~+)/ + +function escapeTags(text) { + // Only the leading angle bracket is escaped, so the tag still reads as "". + return text.replace(REASONING_TAG_PATTERN, (tag) => `<${tag.slice(1)}`) +} + +function escapeTagsOutsideInlineCode(line) { + let result = '' + let index = 0 + while (index < line.length) { + const tickIndex = line.indexOf('`', index) + if (tickIndex === -1) { + result += escapeTags(line.slice(index)) + break + } + const ticks = /^`+/.exec(line.slice(tickIndex))[0] + const closeIndex = line.indexOf(ticks, tickIndex + ticks.length) + if (closeIndex === -1) { + result += escapeTags(line.slice(index)) + break + } + result += escapeTags(line.slice(index, tickIndex)) + result += line.slice(tickIndex, closeIndex + ticks.length) + index = closeIndex + ticks.length + } + return result +} + +function escapeTagsOutsideCode(text) { + let inFence = false + return text + .split(/(?<=\n)/) + .map((line) => { + const isFenceLine = FENCE_PATTERN.test(line) + if (inFence) { + if (isFenceLine) inFence = false + return line + } + if (isFenceLine) { + inFence = true + return line + } + return escapeTagsOutsideInlineCode(line) + }) + .join('') +} + +/** + * Show ``-style tags as literal text. HyperMarkdown treats them as markup: a leading + * one becomes a reasoning block, and any other one is stripped out of the answer. That is + * wrong for text the user typed, and for prose or examples that merely mention a tag. + * + * Fenced code blocks and inline code are left alone — their content is already rendered as + * literal text, and escaping it there would show the escape characters themselves. + * + * @param {string} text + * @param {{preserveLeadingBlock?: boolean}} [options] keep a leading reasoning block intact, + * for providers that stream their thinking inside the answer + * @returns {string} + */ +export function escapeReasoningTags(text, { preserveLeadingBlock = false } = {}) { + if (typeof text !== 'string' || !text.includes('<')) return text + + let head = '' + let body = text + if (preserveLeadingBlock) { + const leading = + /^\s*<(?:think|thinking|reasoning)\b[^>]*>[\s\S]*?(?:<\/\s*(?:think|thinking|reasoning)\s*>|$)/i.exec( + text, + ) + if (leading) { + head = leading[0] + body = text.slice(head.length) + } + } + + return head + escapeTagsOutsideCode(body) +} diff --git a/src/services/apis/inline-reasoning.mjs b/src/services/apis/inline-reasoning.mjs new file mode 100644 index 000000000..2d817acad --- /dev/null +++ b/src/services/apis/inline-reasoning.mjs @@ -0,0 +1,29 @@ +const REASONING_OPEN_REGEX = /^\s*<(think|thinking|reasoning)(?:\s[^>]*)?>/i + +/** + * Some models put their reasoning straight into the answer, wrapped in a ``-style + * tag, instead of streaming it through a reasoning field. A leading block like that is + * split off so it can be shown as reasoning and stay out of the conversation records. + * + * Anything else in the answer is left untouched: prose that merely mentions a tag, and + * code samples that happen to contain one, keep their text. + * + * @param {string} content the answer so far + * @returns {{reasoning: string, answer: string, unclosed: boolean}} the leading thinking, the + * remaining answer, and whether the block is still waiting for its closing tag + */ +export function splitInlineReasoning(content) { + const open = REASONING_OPEN_REGEX.exec(content) + if (!open) return { reasoning: '', answer: content, unclosed: false } + + const rest = content.slice(open[0].length) + // The closing tag may still be on its way while the model is thinking. + const close = new RegExp(``, 'i').exec(rest) + if (!close) return { reasoning: rest, answer: '', unclosed: true } + + return { + reasoning: rest.slice(0, close.index), + answer: rest.slice(close.index + close[0].length).replace(/^\s+/, ''), + unclosed: false, + } +} diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index 02dfe4a81..ff64066ce 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -4,6 +4,7 @@ import { isEmpty } from 'lodash-es' import { getCompletionPromptBase, pushRecord, setAbortController } from './shared.mjs' import { getChatCompletionsTokenParams } from './openai-token-params.mjs' import { getTemperatureParams } from './temperature-params.mjs' +import { splitInlineReasoning } from './inline-reasoning.mjs' function buildHeaders(apiKey, extraHeaders = {}) { const headers = { @@ -32,6 +33,17 @@ function hasFinished(data) { return Boolean(data?.choices?.[0]?.finish_reason) } +/** + * Reasoning models put their thinking in their own field rather than in the content, and + * deliberately do not replay it in context. Surfacing it separately keeps it out of the + * conversation records. + */ +function getReasoningDelta(data) { + const delta = data?.choices?.[0]?.delta + const reasoning = delta?.reasoning_content ?? delta?.reasoning + return typeof reasoning === 'string' ? reasoning : '' +} + /** * @param {object} params * @param {Browser.Runtime.Port} params.port @@ -115,11 +127,44 @@ export async function generateAnswersWithOpenAICompatible({ } let answer = '' + let reasoning = '' + let postedAnswer = '' + let postedReasoning = '' let finished = false + + // Reasoning that arrives through its own field wins. Providers that put the thinking in + // the answer instead (a leading -style block) get the same treatment, so it is + // shown as reasoning and stays out of the conversation records. + const resolveStreamText = ({ final = false } = {}) => { + if (reasoning) return { answer, reasoning } + const inline = splitInlineReasoning(answer) + // A block that never closed is only thinking while the stream is still running. Once it + // has ended the text is kept as the answer, so a response truncated mid-thinking (or an + // answer that merely starts with a tag) is not lost from the conversation. + if (final && inline.unclosed) return { answer, reasoning: '' } + return { answer: inline.answer, reasoning: inline.reasoning } + } + + const postStreamText = () => { + const streamText = resolveStreamText() + if (streamText.answer !== postedAnswer) { + postedAnswer = streamText.answer + port.postMessage({ answer: streamText.answer, done: false, session: null }) + } + if (streamText.reasoning && streamText.reasoning !== postedReasoning) { + postedReasoning = streamText.reasoning + port.postMessage({ reasoning: streamText.reasoning, done: false, session: null }) + } + } + const finish = () => { if (finished) return finished = true - pushRecord(session, question, answer) + // Finalisation only ever differs from what was streamed by no longer treating an + // unclosed block as thinking. The card already shows that text in its thinking block, + // so it is recorded here without being posted again as an answer — sending it would + // make the renderer show the same thinking twice. + pushRecord(session, question, resolveStreamText({ final: true }).answer) port.postMessage({ answer: null, done: true, session: session }) } @@ -142,8 +187,11 @@ export async function generateAnswersWithOpenAICompatible({ return } + const reasoningDelta = getReasoningDelta(data) + if (reasoningDelta) reasoning += reasoningDelta answer = buildMessageAnswer(answer, data, allowLegacyResponseField) - port.postMessage({ answer: answer, done: false, session: null }) + // A chunk can carry reasoning only; an unchanged answer is not posted again. + postStreamText() if (hasFinished(data)) { finish() @@ -154,10 +202,11 @@ export async function generateAnswersWithOpenAICompatible({ try { if (!finished) { if (aborted) { - const shouldPostSession = Boolean(answer) || session.isRetry + const streamText = resolveStreamText({ final: true }) + const shouldPostSession = Boolean(streamText.answer) || session.isRetry if (shouldPostSession && isCurrentSessionRequest()) { - if (answer) { - pushRecord(session, question, answer) + if (streamText.answer) { + pushRecord(session, question, streamText.answer) } session.isRetry = false try { diff --git a/tests/unit/components/reasoning-content.test.mjs b/tests/unit/components/reasoning-content.test.mjs new file mode 100644 index 000000000..f75d25693 --- /dev/null +++ b/tests/unit/components/reasoning-content.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildStreamedContent } from '../../../src/components/MarkdownRender/reasoning-content.mjs' + +const LOADING = '

Waiting for response...

' + +test('the answer alone is handed over unchanged when there is no reasoning', () => { + assert.equal(buildStreamedContent('Answer', '', false), 'Answer') + assert.equal(buildStreamedContent(LOADING, '', false), LOADING) +}) + +test('reasoning stays open while the answer has not started', () => { + assert.equal(buildStreamedContent('', 'thinking', false), '\nthinking') +}) + +test('the loading placeholder does not count as a started answer', () => { + // The block stays open, so the renderer keeps it expanded with the timer running. + assert.equal(buildStreamedContent(LOADING, 'thinking', false), '\nthinking') +}) + +test('the reasoning block closes once the answer starts', () => { + assert.equal( + buildStreamedContent('Answer', 'thinking', false), + '\nthinking\n\n\nAnswer', + ) +}) + +test('the reasoning block closes when the stream ends without an answer', () => { + assert.equal(buildStreamedContent('', 'thinking', true), '\nthinking\n\n\n') + assert.equal(buildStreamedContent(LOADING, 'thinking', true), '\nthinking\n\n\n') +}) + +test('a growing reasoning snapshot extends the still-open block', () => { + const first = buildStreamedContent(LOADING, 'thin', false) + const second = buildStreamedContent(LOADING, 'thinking', false) + assert.equal(second.startsWith(first), true) +}) diff --git a/tests/unit/components/special-tags.test.mjs b/tests/unit/components/special-tags.test.mjs new file mode 100644 index 000000000..af5b1f173 --- /dev/null +++ b/tests/unit/components/special-tags.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { escapeReasoningTags } from '../../../src/components/MarkdownRender/special-tags.mjs' + +test('reasoning tags in plain text are shown as written', () => { + assert.equal( + escapeReasoningTags('Use and to wrap thinking.'), + 'Use <think> and </think> to wrap thinking.', + ) +}) + +test('the tag name case and attributes do not matter', () => { + assert.equal( + escapeReasoningTags('x'), + '<THINKING level="2">x</Thinking>', + ) + assert.equal( + escapeReasoningTags('why'), + '<reasoning>why</reasoning>', + ) +}) + +test('other tags and autolinks are left alone', () => { + const text = 'See and
html
.' + assert.equal(escapeReasoningTags(text), text) +}) + +test('a leading reasoning block can be kept for the renderer', () => { + const answer = 'secret\n\nThe answer.' + assert.equal(escapeReasoningTags(answer, { preserveLeadingBlock: true }), answer) + assert.equal(escapeReasoningTags(answer), '<think>secret</think>\n\nThe answer.') +}) + +test('a later block is escaped even when a leading block is kept', () => { + const answer = 'secret\n\nMention again.' + assert.equal( + escapeReasoningTags(answer, { preserveLeadingBlock: true }), + 'secret\n\nMention <think> again.', + ) +}) + +test('fenced code and inline code keep their text untouched', () => { + const fenced = 'Example:\n\n```html\nexample\n```\n' + assert.equal(escapeReasoningTags(fenced), fenced) + + const inline = 'Write `text` to wrap it.' + assert.equal(escapeReasoningTags(inline), inline) +}) + +test('prose around code is still escaped', () => { + assert.equal( + escapeReasoningTags('`` means thinking.'), + '`` means <think>thinking</think>.', + ) +}) diff --git a/tests/unit/services/apis/custom-api.test.mjs b/tests/unit/services/apis/custom-api.test.mjs index 57a6f7fde..3e4f2cd75 100644 --- a/tests/unit/services/apis/custom-api.test.mjs +++ b/tests/unit/services/apis/custom-api.test.mjs @@ -567,7 +567,7 @@ test('retry mode overwrites last conversation record', async (t) => { assert.deepEqual(session.conversationRecords[0], { question: 'Q1', answer: 'new answer' }) }) -test('delta.content with empty string is appended (no skip)', async (t) => { +test('an empty delta.content leaves the answer unchanged and does not repost it', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ maxConversationContextLength: 2, @@ -599,9 +599,10 @@ test('delta.content with empty string is appended (no skip)', async (t) => { 'model', ) - // After empty delta, answer should still be "A" (empty appended, not skipped) + // The empty delta appends nothing, so the unchanged answer is not posted again. const streaming = port.postedMessages.filter((m) => m.done === false) - assert.equal(streaming[0].answer, 'A') - assert.equal(streaming[1].answer, 'A') // "A" + "" = "A" - assert.equal(streaming[2].answer, 'AB') + assert.deepEqual( + streaming.map((m) => m.answer), + ['A', 'AB'], + ) }) diff --git a/tests/unit/services/apis/inline-reasoning.test.mjs b/tests/unit/services/apis/inline-reasoning.test.mjs new file mode 100644 index 000000000..1a0fb95b2 --- /dev/null +++ b/tests/unit/services/apis/inline-reasoning.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { splitInlineReasoning } from '../../../../src/services/apis/inline-reasoning.mjs' + +test('a leading thinking block is split off the answer', () => { + assert.deepEqual(splitInlineReasoning('weighing options\n\nThe answer.'), { + reasoning: 'weighing options', + answer: 'The answer.', + unclosed: false, + }) +}) + +test('an unclosed thinking block is still arriving', () => { + assert.deepEqual(splitInlineReasoning('\nweighing options'), { + reasoning: 'weighing options', + answer: '', + unclosed: true, + }) +}) + +test('the tag name and its attributes do not matter', () => { + assert.deepEqual(splitInlineReasoning('whyAnswer'), { + reasoning: 'why', + answer: 'Answer', + unclosed: false, + }) + assert.deepEqual(splitInlineReasoning('whyAnswer'), { + reasoning: 'why', + answer: 'Answer', + unclosed: false, + }) +}) + +test('an answer without such a block is left alone', () => { + assert.deepEqual(splitInlineReasoning('Just an answer.'), { + reasoning: '', + answer: 'Just an answer.', + unclosed: false, + }) +}) + +test('prose and code that mention a tag keep their text', () => { + const prose = 'Use tags like this for reasoning.' + assert.deepEqual(splitInlineReasoning(prose), { + reasoning: '', + answer: prose, + unclosed: false, + }) + + const fence = 'Example:\n\n```html\nexample\n```\n\nDone.' + assert.deepEqual(splitInlineReasoning(fence), { reasoning: '', answer: fence, unclosed: false }) +}) + +test('a block that only appears later in the answer is left in place', () => { + const answer = 'The answer.\n\nlater' + assert.deepEqual(splitInlineReasoning(answer), { reasoning: '', answer, unclosed: false }) +}) diff --git a/tests/unit/services/apis/reasoning-content.test.mjs b/tests/unit/services/apis/reasoning-content.test.mjs new file mode 100644 index 000000000..507b2de2f --- /dev/null +++ b/tests/unit/services/apis/reasoning-content.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { generateAnswersWithOpenAICompatible } from '../../../../src/services/apis/openai-compatible-core.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const CONFIG = { + maxConversationContextLength: 9, + maxResponseTokenLength: 1000, + temperatureOverrideEnabled: false, + temperature: 1, +} + +async function run(t, chunks) { + t.mock.method(console, 'debug', () => {}) + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(chunks)) + const port = createFakePort() + const session = { conversationRecords: [] } + await generateAnswersWithOpenAICompatible({ + port, + question: 'hi', + session, + endpointType: 'chat', + requestUrl: 'https://example.com/v1/chat/completions', + model: 'deepseek-reasoner', + apiKey: 'key', + provider: 'openai', + config: CONFIG, + }) + return { port, session } +} + +test('reasoning_content is streamed to the card', async (t) => { + const { port } = await run(t, [ + 'data: {"choices":[{"delta":{"reasoning_content":"weighing "}}]}\n\n', + 'data: {"choices":[{"delta":{"reasoning_content":"options"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"The answer."},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + const reasoningUpdates = port.postedMessages.filter((message) => message.reasoning) + assert.deepEqual( + reasoningUpdates.map((message) => message.reasoning), + ['weighing ', 'weighing options'], + ) +}) + +test('reasoning stays out of what is sent back as context', async (t) => { + const { session } = await run(t, [ + 'data: {"choices":[{"delta":{"reasoning_content":"secret thinking"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"The answer."},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + assert.equal(session.conversationRecords.length, 1) + assert.equal(session.conversationRecords[0].answer, 'The answer.') +}) + +test('an answer with no reasoning behaves as before', async (t) => { + const { port, session } = await run(t, [ + 'data: {"choices":[{"delta":{"content":"Plain"},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + assert.equal(port.postedMessages.filter((message) => message.reasoning).length, 0) + assert.equal(session.conversationRecords[0].answer, 'Plain') +}) + +test('reasoning-only chunks do not repost the unchanged answer', async (t) => { + const { port } = await run(t, [ + 'data: {"choices":[{"delta":{"content":"The answer."}}]}\n\n', + 'data: {"choices":[{"delta":{"reasoning_content":"more thinking"}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + const answerUpdates = port.postedMessages.filter((message) => message.answer) + assert.deepEqual( + answerUpdates.map((message) => message.answer), + ['The answer.'], + ) +}) + +test('thinking written inside the answer is moved to the reasoning channel', async (t) => { + const { port, session } = await run(t, [ + 'data: {"choices":[{"delta":{"content":"weighing "}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"options\\n\\nThe answer."},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + const reasoningUpdates = port.postedMessages + .filter((message) => message.reasoning) + .map((message) => message.reasoning) + assert.deepEqual(reasoningUpdates, ['weighing ', 'weighing options']) + + const answerUpdates = port.postedMessages + .filter((message) => message.answer !== undefined && message.answer !== null) + .map((message) => message.answer) + // Nothing is posted for the answer while the thinking is still streaming. + assert.deepEqual(answerUpdates, ['The answer.']) + + assert.equal(session.conversationRecords[0].answer, 'The answer.') +}) + +test('thinking that never closes is kept as the answer once the stream ends', async (t) => { + const { port, session } = await run(t, [ + 'data: {"choices":[{"delta":{"content":"cut off while thinking"}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"length"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + assert.equal(session.conversationRecords[0].answer, 'cut off while thinking') + // The card already shows that text as thinking, so it must not also arrive as an answer. + assert.equal( + port.postedMessages.some((message) => message.answer === 'cut off while thinking'), + false, + ) + assert.deepEqual( + port.postedMessages.filter((message) => message.reasoning).map((message) => message.reasoning), + ['cut off while thinking'], + ) +}) From b89637ddeb07b4961b34fe8d5d1eeea01ba7fb79 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 21:59:21 +0800 Subject: [PATCH 08/12] Keep unfinished thinking out of the answer and the records An inline -style block used to be kept as the answer once the stream ended, so a response truncated mid-thought was recorded, sent back as conversation context, and rendered as a reasoning block when the conversation was reopened. Treat an unterminated block as reasoning instead, and merge it with an explicit reasoning field when a provider sends both. Escape reasoning delimiters before wrapping provider thinking in the synthetic block, so a literal tag inside the thinking cannot close it early and push the rest of the trace into the answer. --- .../MarkdownRender/reasoning-content.mjs | 11 ++++- .../MarkdownRender/special-tags.mjs | 15 ++++--- src/services/apis/openai-compatible-core.mjs | 32 +++++++-------- .../components/reasoning-content.test.mjs | 7 ++++ .../services/apis/reasoning-content.test.mjs | 40 +++++++++++++++++-- 5 files changed, 78 insertions(+), 27 deletions(-) diff --git a/src/components/MarkdownRender/reasoning-content.mjs b/src/components/MarkdownRender/reasoning-content.mjs index d147c7340..a7efa29fe 100644 --- a/src/components/MarkdownRender/reasoning-content.mjs +++ b/src/components/MarkdownRender/reasoning-content.mjs @@ -1,3 +1,5 @@ +import { escapeReasoningTags } from './special-tags.mjs' + // The card shows its "waiting for response" placeholder until the first answer chunk // arrives. That placeholder is not an answer, so a still-thinking model must not be // treated as if the answer had already started. @@ -10,6 +12,10 @@ const ANSWER_PLACEHOLDER_CLASS = 'gpt-loading' * elapsed timer running — only while the closing tag is missing, and collapses it (freezing * the timer) once the tag arrives. So the tag is added when the thinking has actually * stopped: the answer has started arriving, or the stream has ended. + * + * The reasoning is wrapped in synthetic tags, so any of those tags inside it are escaped + * first; otherwise a literal delimiter would close the block early and push the rest of + * the thinking into the answer. * @param {string} children the answer so far, or the card's loading placeholder * @param {string} reasoning the thinking so far * @param {boolean} done whether the response stream has finished @@ -18,7 +24,8 @@ const ANSWER_PLACEHOLDER_CLASS = 'gpt-loading' export function buildStreamedContent(children, reasoning, done) { if (!reasoning) return children const answer = children.includes(ANSWER_PLACEHOLDER_CLASS) ? '' : children + const thinking = escapeReasoningTags(reasoning) const reasoningFinished = done || Boolean(answer) - if (!reasoningFinished) return `\n${reasoning}` - return `\n${reasoning}\n\n\n${answer}` + if (!reasoningFinished) return `\n${thinking}` + return `\n${thinking}\n\n\n${answer}` } diff --git a/src/components/MarkdownRender/special-tags.mjs b/src/components/MarkdownRender/special-tags.mjs index 78e8b5d99..88958282e 100644 --- a/src/components/MarkdownRender/special-tags.mjs +++ b/src/components/MarkdownRender/special-tags.mjs @@ -1,4 +1,12 @@ -const REASONING_TAG_PATTERN = /<\/?\s*(?:think|thinking|reasoning)\b[^>]*>/gi +// The renderer treats all three names as reasoning tags, so one source keeps the patterns +// that look for them in sync. +const REASONING_TAG_NAME = 'think|thinking|reasoning' +const REASONING_TAG_PATTERN = new RegExp(`]*>`, 'gi') +// A leading block runs to its closing tag, or to the end of the text while it is still open. +const LEADING_REASONING_BLOCK_PATTERN = new RegExp( + `^\\s*<(?:${REASONING_TAG_NAME})\\b[^>]*>[\\s\\S]*?(?:|$)`, + 'i', +) const FENCE_PATTERN = /^ {0,3}(?:```+|~~~+)/ function escapeTags(text) { @@ -66,10 +74,7 @@ export function escapeReasoningTags(text, { preserveLeadingBlock = false } = {}) let head = '' let body = text if (preserveLeadingBlock) { - const leading = - /^\s*<(?:think|thinking|reasoning)\b[^>]*>[\s\S]*?(?:<\/\s*(?:think|thinking|reasoning)\s*>|$)/i.exec( - text, - ) + const leading = LEADING_REASONING_BLOCK_PATTERN.exec(text) if (leading) { head = leading[0] body = text.slice(head.length) diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index ff64066ce..c5117fd6c 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -132,17 +132,17 @@ export async function generateAnswersWithOpenAICompatible({ let postedReasoning = '' let finished = false - // Reasoning that arrives through its own field wins. Providers that put the thinking in - // the answer instead (a leading -style block) get the same treatment, so it is - // shown as reasoning and stays out of the conversation records. - const resolveStreamText = ({ final = false } = {}) => { - if (reasoning) return { answer, reasoning } + // The answer channel may carry the thinking itself, wrapped in a leading -style + // block, instead of (or alongside) a dedicated reasoning field; both sources reach the + // reader as reasoning and stay out of the conversation records. + const resolveStreamText = () => { const inline = splitInlineReasoning(answer) - // A block that never closed is only thinking while the stream is still running. Once it - // has ended the text is kept as the answer, so a response truncated mid-thinking (or an - // answer that merely starts with a tag) is not lost from the conversation. - if (final && inline.unclosed) return { answer, reasoning: '' } - return { answer: inline.answer, reasoning: inline.reasoning } + return { + // A block that never closed is thinking that was cut off mid-thought, so it is not an + // answer either: recording it would send the model its own unfinished reasoning back. + answer: inline.answer, + reasoning: [reasoning, inline.reasoning].filter(Boolean).join('\n\n'), + } } const postStreamText = () => { @@ -160,11 +160,11 @@ export async function generateAnswersWithOpenAICompatible({ const finish = () => { if (finished) return finished = true - // Finalisation only ever differs from what was streamed by no longer treating an - // unclosed block as thinking. The card already shows that text in its thinking block, - // so it is recorded here without being posted again as an answer — sending it would - // make the renderer show the same thinking twice. - pushRecord(session, question, resolveStreamText({ final: true }).answer) + const { answer: finalAnswer, reasoning: finalReasoning } = resolveStreamText() + // A turn that was nothing but thinking has no answer to record; keeping it out of the + // records is what stops the model from being sent its own unfinished reasoning back as + // context. A plain empty answer is still recorded, as it always has been. + if (finalAnswer || !finalReasoning) pushRecord(session, question, finalAnswer) port.postMessage({ answer: null, done: true, session: session }) } @@ -202,7 +202,7 @@ export async function generateAnswersWithOpenAICompatible({ try { if (!finished) { if (aborted) { - const streamText = resolveStreamText({ final: true }) + const streamText = resolveStreamText() const shouldPostSession = Boolean(streamText.answer) || session.isRetry if (shouldPostSession && isCurrentSessionRequest()) { if (streamText.answer) { diff --git a/tests/unit/components/reasoning-content.test.mjs b/tests/unit/components/reasoning-content.test.mjs index f75d25693..0d263583f 100644 --- a/tests/unit/components/reasoning-content.test.mjs +++ b/tests/unit/components/reasoning-content.test.mjs @@ -35,3 +35,10 @@ test('a growing reasoning snapshot extends the still-open block', () => { const second = buildStreamedContent(LOADING, 'thinking', false) assert.equal(second.startsWith(first), true) }) + +test('a reasoning tag inside the thinking cannot close the synthetic block', () => { + assert.equal( + buildStreamedContent('', 'before after', false), + '\nbefore </think> after', + ) +}) diff --git a/tests/unit/services/apis/reasoning-content.test.mjs b/tests/unit/services/apis/reasoning-content.test.mjs index 507b2de2f..843cc0d07 100644 --- a/tests/unit/services/apis/reasoning-content.test.mjs +++ b/tests/unit/services/apis/reasoning-content.test.mjs @@ -102,17 +102,20 @@ test('thinking written inside the answer is moved to the reasoning channel', asy assert.equal(session.conversationRecords[0].answer, 'The answer.') }) -test('thinking that never closes is kept as the answer once the stream ends', async (t) => { +test('thinking that never closes is not recorded as an answer', async (t) => { const { port, session } = await run(t, [ 'data: {"choices":[{"delta":{"content":"cut off while thinking"}}]}\n\n', 'data: {"choices":[{"delta":{},"finish_reason":"length"}]}\n\n', 'data: [DONE]\n\n', ]) - assert.equal(session.conversationRecords[0].answer, 'cut off while thinking') - // The card already shows that text as thinking, so it must not also arrive as an answer. + // The block never closed, so the turn has no answer: recording the thinking would send + // the model its own unfinished reasoning back as context. + assert.equal(session.conversationRecords.length, 0) assert.equal( - port.postedMessages.some((message) => message.answer === 'cut off while thinking'), + port.postedMessages.some( + (message) => typeof message.answer === 'string' && message.answer.includes('cut off'), + ), false, ) assert.deepEqual( @@ -120,3 +123,32 @@ test('thinking that never closes is kept as the answer once the stream ends', as ['cut off while thinking'], ) }) + +test('thinking split off the answer is combined with an explicit reasoning field', async (t) => { + const { port, session } = await run(t, [ + 'data: {"choices":[{"delta":{"reasoning_content":"from the field"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"in the answer\\n\\nThe answer."},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + const lastReasoning = port.postedMessages.filter((message) => message.reasoning).at(-1) + assert.equal(lastReasoning.reasoning, 'from the field\n\nin the answer') + assert.equal(session.conversationRecords[0].answer, 'The answer.') +}) + +test('a partial inline tag is cleared once the tag resolves', async (t) => { + const { port, session } = await run(t, [ + 'data: {"choices":[{"delta":{"content":""}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"thinking\\n\\nThe answer."},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + const answers = port.postedMessages + .filter((message) => typeof message.answer === 'string') + .map((message) => message.answer) + // The partial tag is posted first, then cleared once it turns out to be thinking, so the + // card never keeps the stale fragment as its answer. + assert.deepEqual(answers, [' Date: Sat, 3 Oct 2026 21:59:27 +0800 Subject: [PATCH 09/12] Coalesce streamed reasoning through the answer buffer Reasoning was written straight to state on every chunk, so the card re-rendered once per thinking token before the answer even started. Generalize the buffer into createStreamBuffer, carry {content, reasoning, done} patches, and render both channels in a single update. Accept an empty answer snapshot too: an inline thinking tag resolving away legitimately clears the answer, and dropping the snapshot left the partial tag behind. Finish the trailing answer when the stream errors or the transport drops so its reasoning block cannot stay open, and clear the loading placeholder when a turn was reasoning only. The getCompletedAnswerUpdate helper is no longer needed. --- src/components/ConversationCard/index.jsx | 73 +++++++++++-------- src/components/ConversationCard/session.mjs | 7 -- .../{answer-buffer.mjs => stream-buffer.mjs} | 23 +++--- ...nversation-card-lifecycle-loader-hooks.mjs | 8 +- .../conversation-card-lifecycle.test.mjs | 72 ++++++++++++++++++ .../conversation-card-session.test.mjs | 15 ---- ...buffer.test.mjs => stream-buffer.test.mjs} | 67 +++++++++++------ 7 files changed, 177 insertions(+), 88 deletions(-) rename src/components/ConversationCard/{answer-buffer.mjs => stream-buffer.mjs} (69%) rename tests/unit/components/{answer-buffer.test.mjs => stream-buffer.test.mjs} (62%) diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 036d5b64b..820230a0f 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -45,12 +45,11 @@ import { createConversationPortMessage, createRetrySession, finalizeInterruptedSession, - getCompletedAnswerUpdate, getInterruptedCompletionState, isSupersededGenerationMessage, isSupersededRequestMessage, } from './session.mjs' -import { createAnswerBuffer, createFrameScheduler } from './answer-buffer.mjs' +import { createFrameScheduler, createStreamBuffer } from './stream-buffer.mjs' const logo = Browser.runtime.getURL('logo.png') const UNMATCHED_API_MODE_VALUE = '__current-session-api-mode__' @@ -189,7 +188,7 @@ function ConversationCard(props) { const newSession = initSession({ ...session, question: props.question }) partialAnswerRef.current = '' retryRecordRef.current = null - answerBufferRef.current.discard() + streamBufferRef.current.discard() setSession(newSession) await postMessage({ session: newSession }) } @@ -226,33 +225,38 @@ function ConversationCard(props) { }) } - // Thinking arrives alongside the answer and stays out of the conversation records. - const updateReasoning = (value) => { + /** + * Write the newest streamed text onto the trailing answer item. The reasoning rides in the + * same patch as the answer, so a frame that carries both is a single state update. + * @param {{content?: string, reasoning?: string, done?: boolean}} patch + */ + const updateStreamItem = ({ content, reasoning, done }) => { setConversationItemData((old) => { + const index = findLastIndex(old, (v) => v.type === 'answer') + if (index === -1) return old + const item = old[index] const copy = [...old] - const index = findLastIndex(copy, (v) => v.type === 'answer') - if (index === -1) return copy copy[index] = new ConversationItemData( - copy[index].type, - copy[index].content, - copy[index].done, - value, + item.type, + content === undefined ? item.content : content, + done === undefined ? item.done : done, + reasoning === undefined ? item.reasoning : reasoning, ) return copy }) } - const answerBufferRef = useRef(null) - if (answerBufferRef.current === null) { - answerBufferRef.current = createAnswerBuffer({ + const streamBufferRef = useRef(null) + if (streamBufferRef.current === null) { + streamBufferRef.current = createStreamBuffer({ ...createFrameScheduler(), - render: (answer) => updateAnswer(answer, false, 'answer'), + render: updateStreamItem, }) } // A buffered frame can outlive a hidden page, so drop it when the card goes away. useEffect(() => { - return () => answerBufferRef.current?.discard() + return () => streamBufferRef.current?.discard() }, []) const portMessageListener = (msg) => { @@ -260,16 +264,18 @@ function ConversationCard(props) { if (isSupersededRequestMessage(msg, requestGenerationIdRef.current)) return if (isSupersededGenerationMessage(msg, retryGenerationIdRef.current)) return - if (msg.answer) { + // An answer snapshot can legitimately be empty (an inline thinking tag just resolved + // away), so only a missing field means "nothing to update for this channel". + if (typeof msg.answer === 'string') { partialAnswerRef.current = msg.answer - answerBufferRef.current.push(msg.answer) + streamBufferRef.current.push({ content: msg.answer }) } - if (msg.reasoning) updateReasoning(msg.reasoning) + if (msg.reasoning) streamBufferRef.current.push({ reasoning: msg.reasoning }) if (msg.session) { setSession(msg.done ? { ...msg.session, isRetry: false } : msg.session) } if (msg.done) { - answerBufferRef.current.flush() + streamBufferRef.current.flush() const partialAnswer = partialAnswerRef.current const retryRecord = retryRecordRef.current const completionState = getInterruptedCompletionState(msg, partialAnswer, retryRecord) @@ -280,12 +286,16 @@ function ConversationCard(props) { } partialAnswerRef.current = '' retryRecordRef.current = null - const answerUpdate = getCompletedAnswerUpdate(completionState.restoredRetryAnswer) - updateAnswer(answerUpdate.value, answerUpdate.appended, 'answer', true) + // Replacing the content with the streamed answer (instead of appending nothing) is + // what clears a loading placeholder left by a reasoning-only turn. + updateAnswer(completionState.restoredRetryAnswer ?? partialAnswer, false, 'answer', true) setIsReady(true) } if (msg.error) { - answerBufferRef.current.flush() + // The stream ended in an error: close the trailing answer so its reasoning block is + // not left open forever. + streamBufferRef.current.push({ done: true }) + streamBufferRef.current.flush() const retryRecord = retryRecordRef.current setSession((currentSession) => finalizeInterruptedSession(currentSession, '', retryRecord)) switch (msg.error) { @@ -472,12 +482,13 @@ function ConversationCard(props) { return } if (disposedRef.current) return - // A dropped transport ends the stream without a final message, so flush here: the newest - // chunk still renders on a hidden page, where animation frames are paused. A foreground - // generation (Bing web) streams through its own transport, though, so this keepalive - // port dropping must not unlock sending. + // A dropped transport ends the stream without a final message, so flush and close: the + // newest chunk still renders on a hidden page, where animation frames are paused. A + // foreground generation (Bing web) streams through its own transport, though, so this + // keepalive port dropping must not finalize the answer or unlock sending. if (foregroundPortsRef.current.size === 0) { - answerBufferRef.current.flush() + streamBufferRef.current.push({ done: true }) + streamBufferRef.current.flush() setIsReady(true) } const nextPort = Browser.runtime.connect() @@ -527,7 +538,7 @@ function ConversationCard(props) { }, [port, conversationItemData]) const getRetryFn = (session) => async () => { - answerBufferRef.current.discard() + streamBufferRef.current.discard() // A retry starts a new generation, so the previous attempt's reasoning must go too. updateAnswer( `

${t('Waiting for response...')}

`, @@ -728,7 +739,7 @@ function ConversationCard(props) { } partialAnswerRef.current = '' retryRecordRef.current = null - answerBufferRef.current.discard() + streamBufferRef.current.discard() Browser.runtime.sendMessage({ type: 'DELETE_CONVERSATION', data: { @@ -857,7 +868,7 @@ function ConversationCard(props) { ) partialAnswerRef.current = '' retryRecordRef.current = null - answerBufferRef.current.discard() + streamBufferRef.current.discard() setConversationItemData([...conversationItemData, newQuestion, newAnswer]) setIsReady(false) diff --git a/src/components/ConversationCard/session.mjs b/src/components/ConversationCard/session.mjs index 40c60f166..467c45ba4 100644 --- a/src/components/ConversationCard/session.mjs +++ b/src/components/ConversationCard/session.mjs @@ -60,13 +60,6 @@ export function createRetrySession(session, conversationRecords, retryRecord) { } } -export function getCompletedAnswerUpdate(restoredRetryAnswer) { - return { - value: restoredRetryAnswer ?? '', - appended: restoredRetryAnswer === null, - } -} - export function getInterruptedCompletionState(message, partialAnswer, retryRecord) { const shouldFinalize = Boolean( message.proxyDisconnected || (!message.session && (partialAnswer || retryRecord)), diff --git a/src/components/ConversationCard/answer-buffer.mjs b/src/components/ConversationCard/stream-buffer.mjs similarity index 69% rename from src/components/ConversationCard/answer-buffer.mjs rename to src/components/ConversationCard/stream-buffer.mjs index 13f8341f8..3b112eb8d 100644 --- a/src/components/ConversationCard/answer-buffer.mjs +++ b/src/components/ConversationCard/stream-buffer.mjs @@ -22,14 +22,15 @@ export function createFrameScheduler(host = globalThis) { } /** - * Coalesces streamed answer text so a burst of chunks renders once per frame instead of - * once per chunk, while never losing the newest text. + * Coalesces a burst of streamed patches into one render per frame. A patch only carries the + * fields it changes and later values win, so an answer chunk and a reasoning chunk that + * arrive together reach the state in a single update instead of two. * @param {object} params * @param {(callback: () => void) => unknown} params.requestFrame * @param {(handle: unknown) => void} params.cancelFrame - * @param {(answer: string) => void} params.render + * @param {(patch: object) => void} params.render */ -export function createAnswerBuffer({ requestFrame, cancelFrame, render }) { +export function createStreamBuffer({ requestFrame, cancelFrame, render }) { let pending = null let frame = null @@ -40,15 +41,15 @@ export function createAnswerBuffer({ requestFrame, cancelFrame, render }) { } const takePending = () => { - const answer = pending + const patch = pending pending = null - return answer + return patch } return { - /** Queue the newest answer, scheduling a render only when none is already scheduled. */ - push(answer) { - pending = answer + /** Merge a patch in, scheduling a render only when none is already scheduled. */ + push(patch) { + pending = { ...pending, ...patch } if (frame !== null) return frame = requestFrame(() => { frame = null @@ -56,13 +57,13 @@ export function createAnswerBuffer({ requestFrame, cancelFrame, render }) { if (latest !== null) render(latest) }) }, - /** Render the newest answer before the conversation is finalized. */ + /** Render the newest patch before the conversation is finalized. */ flush() { cancelPendingFrame() const latest = takePending() if (latest !== null) render(latest) }, - /** Drop the newest answer without rendering it, e.g. when a retry starts. */ + /** Drop the newest patch without rendering it, e.g. when a retry starts. */ discard() { cancelPendingFrame() pending = null diff --git a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs index f6c36fa7e..90fe37b53 100644 --- a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs +++ b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs @@ -43,7 +43,13 @@ const sources = { 'test:conversation-item': ` export default function ConversationItem(props) { if (props.type === 'answer') { - globalThis.__CONVERSATION_LIFECYCLE_TEST__.answerContents.push(props.content) + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + state.answerContents.push(props.content) + state.answerProps.push({ + content: props.content, + reasoning: props.reasoning, + done: props.done, + }) } return null } diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs index e91dd8322..92437f6d0 100644 --- a/tests/unit/components/conversation-card-lifecycle.test.mjs +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -127,6 +127,7 @@ const resetState = () => { } state.runtimeOnMessage.clear() state.answerContents = [] + state.answerProps = [] } const mountCard = (container, props = {}) => { @@ -835,4 +836,75 @@ test('a dropped transport flushes the buffered answer before reconnecting', () = true, 'a dropped transport must flush the newest chunk synchronously', ) + const flushed = state.answerProps.filter((props) => props.content === 'newest chunk').at(-1) + assert.equal(flushed.done, true, 'a dropped transport must finish the trailing answer') +}) + +test('an empty answer snapshot replaces the loading placeholder', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + + mountCard(container, { question: 'why?' }) + assert.match(state.answerProps.at(-1).content, /gpt-loading/) + + act(() => state.ports[0].onMessage.trigger({ answer: '' })) + await new Promise((resolve) => setTimeout(resolve, 32)) + + assert.equal(state.answerProps.at(-1).content, '') +}) + +test('reasoning chunks are coalesced into one render per frame', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + + mountCard(container, { question: 'why?' }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ reasoning: 'thin' })) + act(() => port.onMessage.trigger({ reasoning: 'thinking' })) + assert.equal( + state.answerProps.some((props) => props.reasoning === 'thinking'), + false, + 'reasoning must be buffered, not rendered per chunk', + ) + + await new Promise((resolve) => setTimeout(resolve, 32)) + assert.equal(state.answerProps.at(-1).reasoning, 'thinking') +}) + +test('a reasoning-only completion clears the placeholder and finishes the answer', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + const session = { ...baseSession(), question: 'why?' } + + mountCard(container, { question: 'why?', session }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ reasoning: 'thinking hard' })) + act(() => port.onMessage.trigger({ answer: null, done: true, session })) + + const answer = state.answerProps.at(-1) + assert.equal(answer.content, '') + assert.equal(answer.reasoning, 'thinking hard') + assert.equal(answer.done, true) +}) + +test('an error closes the trailing answer so its reasoning stops streaming', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + const session = { ...baseSession(), question: 'why?' } + + mountCard(container, { question: 'why?', session }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ answer: 'partial answer', reasoning: 'thinking' })) + await new Promise((resolve) => setTimeout(resolve, 32)) + act(() => port.onMessage.trigger({ error: 'boom' })) + + const partial = state.answerProps.filter((props) => props.content === 'partial answer').at(-1) + assert.equal(partial.done, true) }) diff --git a/tests/unit/components/conversation-card-session.test.mjs b/tests/unit/components/conversation-card-session.test.mjs index fdf543c72..97a6dd47d 100644 --- a/tests/unit/components/conversation-card-session.test.mjs +++ b/tests/unit/components/conversation-card-session.test.mjs @@ -4,7 +4,6 @@ import { createConversationPortMessage, createRetrySession, finalizeInterruptedSession, - getCompletedAnswerUpdate, getInterruptedCompletionState, isSupersededGenerationMessage, isSupersededRequestMessage, @@ -169,20 +168,6 @@ test('createRetrySession keeps provider retry mode when no target was removed', assert.equal(retrySession.isRetry, true) }) -test('getCompletedAnswerUpdate replaces loading content when restoring a retry answer', () => { - assert.deepEqual(getCompletedAnswerUpdate('Old answer'), { - value: 'Old answer', - appended: false, - }) -}) - -test('getCompletedAnswerUpdate preserves streamed content on normal completion', () => { - assert.deepEqual(getCompletedAnswerUpdate(null), { - value: '', - appended: true, - }) -}) - test('getInterruptedCompletionState restores retry context for a sessionless stop', () => { const retryRecord = { question: 'Q1', answer: 'Old answer' } diff --git a/tests/unit/components/answer-buffer.test.mjs b/tests/unit/components/stream-buffer.test.mjs similarity index 62% rename from tests/unit/components/answer-buffer.test.mjs rename to tests/unit/components/stream-buffer.test.mjs index 2cad9e5f8..64be90fa7 100644 --- a/tests/unit/components/answer-buffer.test.mjs +++ b/tests/unit/components/stream-buffer.test.mjs @@ -1,9 +1,9 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { - createAnswerBuffer, createFrameScheduler, -} from '../../../src/components/ConversationCard/answer-buffer.mjs' + createStreamBuffer, +} from '../../../src/components/ConversationCard/stream-buffer.mjs' function createFakeFrames() { let nextHandle = 1 @@ -32,52 +32,73 @@ function createFakeFrames() { function setup() { const frames = createFakeFrames() const renders = [] - const buffer = createAnswerBuffer({ + const buffer = createStreamBuffer({ requestFrame: frames.requestFrame, cancelFrame: frames.cancelFrame, - render: (answer) => renders.push(answer), + render: (patch) => renders.push(patch), }) return { frames, renders, buffer } } -test('a burst of chunks renders once per frame with only the newest text', () => { +test('a burst of chunks renders once per frame with only the newest values', () => { const { frames, renders, buffer } = setup() - buffer.push('a') - buffer.push('ab') - buffer.push('abc') + buffer.push({ content: 'a' }) + buffer.push({ content: 'ab' }) + buffer.push({ content: 'abc' }) assert.deepEqual(renders, []) frames.runFrames() - assert.deepEqual(renders, ['abc']) + assert.deepEqual(renders, [{ content: 'abc' }]) +}) + +test('answer and reasoning arriving together are applied in a single patch', () => { + const { frames, renders, buffer } = setup() + + buffer.push({ content: 'The answer.' }) + buffer.push({ reasoning: 'weighing options' }) + frames.runFrames() + + assert.deepEqual(renders, [{ content: 'The answer.', reasoning: 'weighing options' }]) +}) + +test('a patch keeps the fields it does not mention', () => { + const { frames, renders, buffer } = setup() + + buffer.push({ content: 'first' }) + frames.runFrames() + buffer.push({ reasoning: 'thinking' }) + frames.runFrames() + + assert.deepEqual(renders, [{ content: 'first' }, { reasoning: 'thinking' }]) }) test('a chunk arriving after a frame schedules the next render', () => { const { frames, renders, buffer } = setup() - buffer.push('a') + buffer.push({ content: 'a' }) frames.runFrames() - buffer.push('ab') + buffer.push({ content: 'ab' }) frames.runFrames() - assert.deepEqual(renders, ['a', 'ab']) + assert.deepEqual(renders, [{ content: 'a' }, { content: 'ab' }]) }) -test('flush renders the newest chunk so completion cannot drop the last one', () => { +test('flush renders the newest patch so completion cannot drop the last one', () => { const { frames, renders, buffer } = setup() - buffer.push('a') - buffer.push('ab') + buffer.push({ content: 'a' }) + buffer.push({ content: 'ab', done: true }) buffer.flush() - assert.deepEqual(renders, ['ab']) + assert.deepEqual(renders, [{ content: 'ab', done: true }]) assert.equal(frames.cancelled.length, 1) frames.runFrames() - assert.deepEqual(renders, ['ab'], 'the cancelled frame must not render again') + assert.deepEqual(renders, [{ content: 'ab', done: true }], 'the cancelled frame must not render') }) -test('flush without pending text does not render', () => { +test('flush without a pending patch does not render', () => { const { renders, buffer } = setup() buffer.flush() @@ -85,10 +106,10 @@ test('flush without pending text does not render', () => { assert.deepEqual(renders, []) }) -test('discard drops the pending text without rendering it', () => { +test('discard drops the pending patch without rendering it', () => { const { frames, renders, buffer } = setup() - buffer.push('a') + buffer.push({ content: 'a' }) buffer.discard() frames.runFrames() @@ -98,12 +119,12 @@ test('discard drops the pending text without rendering it', () => { test('pushing after a discard schedules a fresh frame', () => { const { frames, renders, buffer } = setup() - buffer.push('a') + buffer.push({ content: 'a' }) buffer.discard() - buffer.push('b') + buffer.push({ content: 'b' }) frames.runFrames() - assert.deepEqual(renders, ['b']) + assert.deepEqual(renders, [{ content: 'b' }]) }) test('createFrameScheduler uses the host animation frames when available', () => { From 90ea4cc4049b2963604fe3af4a685b8e21db28e6 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 21:59:34 +0800 Subject: [PATCH 10/12] Cover the HyperMarkdown rendering contract with a DOM test The migration moved every answer through a new renderer, so pin the markup its existing stylesheet targets: the loading placeholder class, highlight.js code classes, gfm tables, katex math, the reasoning open and collapsed states, literal reasoning tags in a question, raw html, and the shared link component. --- tests/setup/markdown-render-loader-hooks.mjs | 37 +++++ .../unit/components/markdown-render.test.mjs | 148 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 tests/setup/markdown-render-loader-hooks.mjs create mode 100644 tests/unit/components/markdown-render.test.mjs diff --git a/tests/setup/markdown-render-loader-hooks.mjs b/tests/setup/markdown-render-loader-hooks.mjs new file mode 100644 index 000000000..ed20b327d --- /dev/null +++ b/tests/setup/markdown-render-loader-hooks.mjs @@ -0,0 +1,37 @@ +// Loader hooks for rendering tests: resolve extensionless relative imports the way +// webpack does, and transform JSX through esbuild for the preact runtime. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const JSX_RE = /<[A-Z][A-Za-z0-9]*[\s/>]|<>/ + +export async function resolve(specifier, context, nextResolve) { + if (/^\.\.?\//.test(specifier) && !/\.[a-z0-9]+$/i.test(specifier)) { + for (const extension of ['.jsx', '.mjs', '.js']) { + try { + return await nextResolve(specifier + extension, context) + } catch { + /* try the next extension */ + } + } + } + return nextResolve(specifier, context) +} + +export async function load(url, context, nextLoad) { + if (url.startsWith('file://') && !url.includes('node_modules')) { + if (url.endsWith('.jsx') || url.endsWith('.mjs')) { + const source = await readFile(fileURLToPath(url), 'utf8') + if (url.endsWith('.jsx') || JSX_RE.test(source)) { + const esbuild = await import('esbuild') + const result = await esbuild.transform(source, { + loader: 'jsx', + jsx: 'automatic', + jsxImportSource: 'preact', + }) + return { shortCircuit: true, format: 'module', source: result.code } + } + } + } + return nextLoad(url, context) +} diff --git a/tests/unit/components/markdown-render.test.mjs b/tests/unit/components/markdown-render.test.mjs new file mode 100644 index 000000000..6c73bbe5d --- /dev/null +++ b/tests/unit/components/markdown-render.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict' +import { register } from 'node:module' +import { cwd } from 'node:process' +import { after, afterEach, before, test } from 'node:test' +import { pathToFileURL } from 'node:url' +import { JSDOM } from 'jsdom' +import { h, render } from 'preact' +import { act } from 'preact/test-utils' + +register('./tests/setup/markdown-render-loader-hooks.mjs', pathToFileURL(cwd() + '/').href) + +const LOADING = '

Waiting for response...

' + +// Everything the renderer touches, borrowed from the jsdom window. +const globalNames = [ + 'window', + 'document', + 'Node', + 'HTMLElement', + 'Element', + 'Event', + 'MouseEvent', + 'CustomEvent', + 'MutationObserver', + 'getComputedStyle', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'ResizeObserver', + 'IntersectionObserver', + 'DOMParser', + 'SVGElement', + 'DocumentFragment', +] + +let dom +let MarkdownRender +const containers = new Set() + +class NoopObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +before(async () => { + dom = new JSDOM('', { + url: 'https://example.com/', + pretendToBeVisual: true, + }) + for (const name of globalNames) { + if (dom.window[name] === undefined) continue + Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name] }) + } + if (!globalThis.ResizeObserver) globalThis.ResizeObserver = NoopObserver + if (!globalThis.IntersectionObserver) globalThis.IntersectionObserver = NoopObserver + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: dom.window.matchMedia, + }) + ;({ default: MarkdownRender } = await import( + '../../../src/components/MarkdownRender/markdown.jsx' + )) +}) + +afterEach(() => { + act(() => { + for (const container of containers) render(null, container) + }) + for (const container of containers) container.remove() + containers.clear() +}) + +after(() => { + dom.window.close() +}) + +const mount = (props) => { + const container = dom.window.document.createElement('div') + dom.window.document.body.append(container) + containers.add(container) + act(() => render(h(MarkdownRender, props), container)) + return container +} + +test('the streaming loading placeholder keeps the class its styles target', () => { + const container = mount({ children: LOADING, done: false }) + + const placeholder = container.querySelector('p.gpt-loading') + assert.ok(placeholder, 'the placeholder must survive sanitizing') + assert.equal(placeholder.textContent, 'Waiting for response...') +}) + +test('a fenced code block keeps the highlight.js classes the theme targets', () => { + const container = mount({ children: '```js\nconst answer = 42\n```' }) + + const code = container.querySelector('pre code') + assert.ok(code, 'the code block must render inside a pre/code pair') + assert.ok(code.classList.contains('hljs'), 'the hljs theme class must survive') + assert.ok(code.classList.contains('language-js'), 'the language class must survive') + assert.ok(container.querySelector('code .hljs-keyword'), 'keywords must be highlighted') +}) + +test('a gfm table renders as a table element', () => { + const container = mount({ children: '| a | b |\n| - | - |\n| 1 | 2 |' }) + + assert.equal(container.querySelectorAll('table th').length, 2) + assert.deepEqual( + [...container.querySelectorAll('table td')].map((cell) => cell.textContent), + ['1', '2'], + ) +}) + +test('inline math renders through katex', () => { + const container = mount({ children: 'Inline $a^2$ end' }) + + assert.ok(container.querySelector('.katex'), 'the katex markup must be present') + assert.equal(container.textContent.includes('$a^2$'), false) +}) + +test('reasoning stays open while streaming and collapses once done', () => { + const streaming = mount({ children: LOADING, reasoning: 'weighing options', done: false }) + assert.ok(streaming.querySelector('.reasoning-wrapper.open'), 'a live block stays open') + + const finished = mount({ children: 'The answer.', reasoning: 'weighing options', done: true }) + assert.ok(finished.querySelector('.reasoning-wrapper.collapsed'), 'a finished block collapses') +}) + +test('a question that mentions a reasoning tag renders it as text', () => { + const container = mount({ children: 'Use like this.', literalTags: true }) + + assert.equal(container.querySelector('.reasoning-wrapper'), null) + assert.match(container.textContent, /Use like this\./) +}) + +test('raw html in an answer is preserved', () => { + const container = mount({ children: 'a
b' }) + + assert.ok(container.querySelector('br'), 'a line break must survive sanitizing') +}) + +test('links render through the shared Hyperlink component', () => { + const container = mount({ children: '[docs](https://example.com/docs)' }) + + const link = container.querySelector('a') + assert.equal(link.getAttribute('href'), 'https://example.com/docs') + assert.equal(link.getAttribute('target'), '_blank') + assert.equal(link.getAttribute('rel'), 'nofollow noopener noreferrer') +}) From 31cc86b5cb96d2d50d9b34450e2ae218245ee104 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sun, 4 Oct 2026 00:05:48 +0800 Subject: [PATCH 11/12] Drive the thinking block from the API field, not parsed content The thinking block is decided by the API field alone. `reasoning_content`, `reasoning` and Anthropic's `thinking_delta` stream on their own channel, and the content channel is the answer, verbatim. Nothing is parsed out of the text, so a `` that a reader typed or a model wrote stays ordinary text and the markdown parser only sees the body of the block. `inline-reasoning.mjs` and `reasoning-content.mjs` go away with the content parsing they existed for. The renderer grows a ReasoningPanel with its own collapsible block and its own HyperMarkdown, so no tag inside the answer can open, close or reshape it. It keeps the `.reasoning-*` markup the stylesheet already targets, and one extra rule lets the nested renderer inherit the block's small, muted type. Sharing the pipeline also collapses the duplicated transport code: every endpoint reports errors through `createApiResponseError()` and decodes SSE through `parseJsonMessage()`, and Azure OpenAI reuses the shared core instead of carrying its own copy of the stream parser. Claude's extended thinking now surfaces as reasoning instead of being dropped. A keepalive Port that drops during a foreground Bing-web generation no longer finalizes the answer or unlocks sending; that stream ends on its own. --- src/components/ConversationCard/index.jsx | 34 ++--- src/components/ConversationItem/index.jsx | 2 +- .../MarkdownRender/ReasoningPanel.jsx | 121 +++++++++++++++++ src/components/MarkdownRender/markdown.jsx | 46 ++----- .../MarkdownRender/reasoning-content.mjs | 31 ----- .../MarkdownRender/renderer-config.mjs | 18 +++ .../MarkdownRender/special-tags.mjs | 40 ++---- .../MarkdownRender/waiting-placeholder.mjs | 12 ++ src/content-script/styles.scss | 10 ++ src/services/apis/azure-openai-api.mjs | 103 +++------------ src/services/apis/chatgpt-web.mjs | 8 +- src/services/apis/claude-api.mjs | 29 +++-- src/services/apis/inline-reasoning.mjs | 29 ----- src/services/apis/moonshot-web.mjs | 8 +- src/services/apis/openai-compatible-core.mjs | 122 +++++++++--------- src/services/apis/shared.mjs | 30 +++++ src/services/apis/waylaidwanderer-api.mjs | 20 ++- .../unit/components/markdown-render.test.mjs | 81 +++++++++++- .../components/reasoning-content.test.mjs | 44 ------- tests/unit/components/special-tags.test.mjs | 11 +- .../services/apis/azure-openai-api.test.mjs | 2 +- tests/unit/services/apis/claude-api.test.mjs | 26 ++++ .../services/apis/inline-reasoning.test.mjs | 57 -------- .../services/apis/reasoning-content.test.mjs | 88 +++++-------- 24 files changed, 480 insertions(+), 492 deletions(-) create mode 100644 src/components/MarkdownRender/ReasoningPanel.jsx delete mode 100644 src/components/MarkdownRender/reasoning-content.mjs create mode 100644 src/components/MarkdownRender/renderer-config.mjs create mode 100644 src/components/MarkdownRender/waiting-placeholder.mjs delete mode 100644 src/services/apis/inline-reasoning.mjs delete mode 100644 tests/unit/components/reasoning-content.test.mjs delete mode 100644 tests/unit/services/apis/inline-reasoning.test.mjs diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 820230a0f..943a17888 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -50,6 +50,7 @@ import { isSupersededRequestMessage, } from './session.mjs' import { createFrameScheduler, createStreamBuffer } from './stream-buffer.mjs' +import { waitingPlaceholder } from '../MarkdownRender/waiting-placeholder.mjs' const logo = Browser.runtime.getURL('logo.png') const UNMATCHED_API_MODE_VALUE = '__current-session-api-mode__' @@ -145,12 +146,7 @@ function ConversationCard(props) { useLayoutEffect(() => { if (session.conversationRecords.length === 0) { if (props.question && triggered) - setConversationItemData([ - new ConversationItemData( - 'answer', - `

${t(`Waiting for response...`)}

`, - ), - ]) + setConversationItemData([new ConversationItemData('answer', waitingPlaceholder(t))]) } else { const ret = [] for (const record of session.conversationRecords) { @@ -264,8 +260,8 @@ function ConversationCard(props) { if (isSupersededRequestMessage(msg, requestGenerationIdRef.current)) return if (isSupersededGenerationMessage(msg, retryGenerationIdRef.current)) return - // An answer snapshot can legitimately be empty (an inline thinking tag just resolved - // away), so only a missing field means "nothing to update for this channel". + // Only a missing field means "nothing to update for this channel": the answer may + // legitimately arrive as an empty string. if (typeof msg.answer === 'string') { partialAnswerRef.current = msg.answer streamBufferRef.current.push({ content: msg.answer }) @@ -343,7 +339,7 @@ function ConversationCard(props) { const lastItem = currentItems[currentItems.length - 1] if ( lastItem && - (lastItem.content.includes('gpt-loading') || lastItem.type === 'error') + (lastItem.content === waitingPlaceholder(t) || lastItem.type === 'error') ) { const updatedItems = [...currentItems] updatedItems[updatedItems.length - 1] = new ConversationItemData( @@ -540,13 +536,7 @@ function ConversationCard(props) { const getRetryFn = (session) => async () => { streamBufferRef.current.discard() // A retry starts a new generation, so the previous attempt's reasoning must go too. - updateAnswer( - `

${t('Waiting for response...')}

`, - false, - 'answer', - false, - '', - ) + updateAnswer(waitingPlaceholder(t), false, 'answer', false, '') setIsReady(false) const conversationRecords = session.conversationRecords.map((record) => ({ ...record })) @@ -841,12 +831,7 @@ function ConversationCard(props) { className="manual-btn" style={{ display: 'flex', justifyContent: 'center' }} onClick={() => { - setConversationItemData([ - new ConversationItemData( - 'answer', - `

${t(`Waiting for response...`)}

`, - ), - ]) + setConversationItemData([new ConversationItemData('answer', waitingPlaceholder(t))]) setTriggered(true) setIsReady(false) }} @@ -862,10 +847,7 @@ function ConversationCard(props) { reverseResizeDir={props.pageMode} onSubmit={async (question) => { const newQuestion = new ConversationItemData('question', question) - const newAnswer = new ConversationItemData( - 'answer', - `

${t('Waiting for response...')}

`, - ) + const newAnswer = new ConversationItemData('answer', waitingPlaceholder(t)) partialAnswerRef.current = '' retryRecordRef.current = null streamBufferRef.current.discard() diff --git a/src/components/ConversationItem/index.jsx b/src/components/ConversationItem/index.jsx index 9e70dd66d..41b154e46 100644 --- a/src/components/ConversationItem/index.jsx +++ b/src/components/ConversationItem/index.jsx @@ -55,7 +55,7 @@ export function ConversationItem({ )} - {!collapsed && {content}} + {!collapsed && {content}} ) case 'answer': diff --git a/src/components/MarkdownRender/ReasoningPanel.jsx b/src/components/MarkdownRender/ReasoningPanel.jsx new file mode 100644 index 000000000..e4e665d19 --- /dev/null +++ b/src/components/MarkdownRender/ReasoningPanel.jsx @@ -0,0 +1,121 @@ +import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' +import { HyperMarkdown } from '@aeven-ai/hypermarkdown' +import { ChevronDownIcon } from '@primer/octicons-react' +import { createStreamDelta } from './stream-delta.mjs' +import { escapeReasoningTags } from './special-tags.mjs' +import { ALLOWED_TAGS, COMPONENTS, CONTROLS, PLUGINS } from './renderer-config.mjs' + +/** + * The collapsible block that shows a model's thinking. + * + * Whether it exists is decided by the reasoning field alone. The thinking reaches its own + * renderer as plain markdown, so nothing written in the answer or the question -- a literal + * ``, a code fence, an escaped tag -- can open, close or reshape the block. The answer + * streams through a separate renderer for the same reason. + * + * The markup mirrors the renderer's own reasoning block (`.reasoning-*`, styled by + * hypermarkdown.css) so that stylesheet keeps working unchanged. The timer behaves the same + * way: it runs while the thinking is still arriving, then freezes and folds the block away + * unless the reader has taken over by toggling it. + * + * @param {object} props + * @param {string} props.reasoning the thinking so far + * @param {boolean} props.streaming whether more thinking is still on its way + */ +export function ReasoningPanel({ reasoning, streaming }) { + const { t } = useTranslation() + const [open, setOpen] = useState(true) + const [seconds, setSeconds] = useState(0) + const startedRef = useRef(null) + const toggledRef = useRef(false) + const deltaRef = useRef(null) + const rendererRef = useRef(null) + if (startedRef.current === null) startedRef.current = Date.now() + if (deltaRef.current === null) deltaRef.current = createStreamDelta() + + useEffect(() => { + if (!streaming) return + const timer = setInterval(() => { + setSeconds(Math.round((Date.now() - startedRef.current) / 1000)) + }, 1000) + return () => clearInterval(timer) + }, [streaming]) + + useEffect(() => { + // The thinking has stopped: freeze the timer and fold the block away, unless the reader + // has taken over by opening or closing it. + if (streaming || toggledRef.current) return + setSeconds(Math.round((Date.now() - startedRef.current) / 1000)) + setOpen(false) + }, [streaming]) + + useLayoutEffect(() => { + const renderer = rendererRef.current + if (!renderer) return + // The thinking is text from the model too, so a reasoning tag written inside it stays + // literal rather than opening another block. + const step = deltaRef.current.next(escapeReasoningTags(reasoning), !streaming) + if (!step) return + if (step.reset) renderer.reset() + renderer.write(step.write, step.finalize) + }) + + const title = streaming + ? t('Thinking Content') + : t('Thought for {seconds}s').replace('{seconds}', String(seconds)) + + return ( +
+
+
+ + {open ? ( +
+ +
+ ) : null} +
+
+
+ ) +} + +ReasoningPanel.propTypes = { + reasoning: PropTypes.string.isRequired, + streaming: PropTypes.bool.isRequired, +} + +export default memo(ReasoningPanel) diff --git a/src/components/MarkdownRender/markdown.jsx b/src/components/MarkdownRender/markdown.jsx index 5985302ee..e06c2285d 100644 --- a/src/components/MarkdownRender/markdown.jsx +++ b/src/components/MarkdownRender/markdown.jsx @@ -1,48 +1,35 @@ -import { memo, useLayoutEffect, useMemo, useRef } from 'react' +import { memo, useLayoutEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import PropTypes from 'prop-types' import { HyperMarkdown } from '@aeven-ai/hypermarkdown' -import { highlightPlugin } from '@aeven-ai/hypermarkdown/plugins/code' -import { Hyperlink } from './Hyperlink' -import { highlightOptions } from './highlight-options.mjs' -import { mathPlugin } from './math-plugin.mjs' import { normalizeListStarts } from './list-markers.mjs' -import { buildStreamedContent } from './reasoning-content.mjs' import { escapeReasoningTags } from './special-tags.mjs' import { createStreamDelta } from './stream-delta.mjs' +import { waitingPlaceholder } from './waiting-placeholder.mjs' +import ReasoningPanel from './ReasoningPanel.jsx' +import { ALLOWED_TAGS, COMPONENTS, CONTROLS, PLUGINS } from './renderer-config.mjs' // This component can land in a shared chunk, whose CSS is never packaged (see build.mjs), // so its stylesheets are imported by the content-script entry instead. -// rehype-react keys elements by component identity, so these maps have to stay stable -// across renders or every block remounts. -const PLUGINS = { math: mathPlugin(), code: highlightPlugin(highlightOptions) } -const COMPONENTS = { a: Hyperlink } -// Fullscreen and the HTML preview expect the host to hide its own chrome around them, -// which this card does not do; the copy button is the part that works on its own. -const CONTROLS = { code: { fullscreen: false, preview: false }, table: { fullscreen: false } } -// The loading placeholder is injected as HTML and styled through this class. -const ALLOWED_TAGS = { p: ['className'] } - /** * @param {object} props * @param {string} props.children markdown, or the whole answer so far while streaming * @param {boolean} [props.done] false while the answer is still arriving * @param {string} [props.reasoning] thinking to show ahead of the answer - * @param {boolean} [props.literalTags] the text is user input: every reasoning tag is shown - * as written, instead of a leading block being presented as thinking */ -export function MarkdownRender({ children, done = true, reasoning = '', literalTags = false }) { +export function MarkdownRender({ children, done = true, reasoning = '' }) { const { t } = useTranslation() const rendererRef = useRef(null) const containerRef = useRef(null) const deltaRef = useRef(null) if (deltaRef.current === null) deltaRef.current = createStreamDelta() - const content = buildStreamedContent( - escapeReasoningTags(children, { preserveLeadingBlock: !literalTags }), - reasoning, - done, - ) + // The card's placeholder is not answer text, so until real text arrives the thinking is + // still the part that is streaming. + const answerStarted = children !== '' && children !== waitingPlaceholder(t) + // Thinking is rendered from its own field, so any reasoning tag left in ordinary content is + // just text: it is escaped so the renderer cannot turn it into a block or strip it. + const content = escapeReasoningTags(children) // The renderer draws its own markers with `li::before`, but a nested bullet list inherits // the numbered marker of the list around it and its counter ignores `start`. The markers @@ -74,21 +61,17 @@ export function MarkdownRender({ children, done = true, reasoning = '', literalT renderer.write(step.write, step.finalize) }) - // `{seconds}` is filled in by the renderer, and is not i18next interpolation syntax. - const translations = useMemo( - () => ({ thinking: t('Thinking Content'), thoughtFor: t('Thought for {seconds}s') }), - [t], - ) - return (
+ {reasoning ? ( + + ) : null} @@ -100,7 +83,6 @@ MarkdownRender.propTypes = { children: PropTypes.string.isRequired, done: PropTypes.bool, reasoning: PropTypes.string, - literalTags: PropTypes.bool, } export default memo(MarkdownRender) diff --git a/src/components/MarkdownRender/reasoning-content.mjs b/src/components/MarkdownRender/reasoning-content.mjs deleted file mode 100644 index a7efa29fe..000000000 --- a/src/components/MarkdownRender/reasoning-content.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { escapeReasoningTags } from './special-tags.mjs' - -// The card shows its "waiting for response" placeholder until the first answer chunk -// arrives. That placeholder is not an answer, so a still-thinking model must not be -// treated as if the answer had already started. -const ANSWER_PLACEHOLDER_CLASS = 'gpt-loading' - -/** - * Build the document handed to the streaming renderer. - * - * HyperMarkdown keeps a reasoning block streamed — expanded, extended in place, with its - * elapsed timer running — only while the closing tag is missing, and collapses it (freezing - * the timer) once the tag arrives. So the tag is added when the thinking has actually - * stopped: the answer has started arriving, or the stream has ended. - * - * The reasoning is wrapped in synthetic tags, so any of those tags inside it are escaped - * first; otherwise a literal delimiter would close the block early and push the rest of - * the thinking into the answer. - * @param {string} children the answer so far, or the card's loading placeholder - * @param {string} reasoning the thinking so far - * @param {boolean} done whether the response stream has finished - * @returns {string} - */ -export function buildStreamedContent(children, reasoning, done) { - if (!reasoning) return children - const answer = children.includes(ANSWER_PLACEHOLDER_CLASS) ? '' : children - const thinking = escapeReasoningTags(reasoning) - const reasoningFinished = done || Boolean(answer) - if (!reasoningFinished) return `\n${thinking}` - return `\n${thinking}\n\n\n${answer}` -} diff --git a/src/components/MarkdownRender/renderer-config.mjs b/src/components/MarkdownRender/renderer-config.mjs new file mode 100644 index 000000000..9cd58edbd --- /dev/null +++ b/src/components/MarkdownRender/renderer-config.mjs @@ -0,0 +1,18 @@ +import { highlightPlugin } from '@aeven-ai/hypermarkdown/plugins/code' +import { Hyperlink } from './Hyperlink' +import { highlightOptions } from './highlight-options.mjs' +import { mathPlugin } from './math-plugin.mjs' + +// The answer and the thinking share one pipeline, so both renderers parse the same way. +// rehype-react keys elements by component identity, so these maps and plugin instances have +// to stay stable across renders or every block remounts. +export const PLUGINS = { math: mathPlugin(), code: highlightPlugin(highlightOptions) } +export const COMPONENTS = { a: Hyperlink } +// Fullscreen and the HTML preview expect the host to hide its own chrome around them, which +// this card does not do; the copy button is the part that works on its own. +export const CONTROLS = { + code: { fullscreen: false, preview: false }, + table: { fullscreen: false }, +} +// The loading placeholder is injected as HTML and styled through this class. +export const ALLOWED_TAGS = { p: ['className'] } diff --git a/src/components/MarkdownRender/special-tags.mjs b/src/components/MarkdownRender/special-tags.mjs index 88958282e..7c7012c5b 100644 --- a/src/components/MarkdownRender/special-tags.mjs +++ b/src/components/MarkdownRender/special-tags.mjs @@ -1,12 +1,7 @@ -// The renderer treats all three names as reasoning tags, so one source keeps the patterns -// that look for them in sync. +// The renderer treats all three names as reasoning tags, so one source keeps the pattern in +// sync. const REASONING_TAG_NAME = 'think|thinking|reasoning' const REASONING_TAG_PATTERN = new RegExp(`]*>`, 'gi') -// A leading block runs to its closing tag, or to the end of the text while it is still open. -const LEADING_REASONING_BLOCK_PATTERN = new RegExp( - `^\\s*<(?:${REASONING_TAG_NAME})\\b[^>]*>[\\s\\S]*?(?:|$)`, - 'i', -) const FENCE_PATTERN = /^ {0,3}(?:```+|~~~+)/ function escapeTags(text) { @@ -56,30 +51,21 @@ function escapeTagsOutsideCode(text) { } /** - * Show ``-style tags as literal text. HyperMarkdown treats them as markup: a leading - * one becomes a reasoning block, and any other one is stripped out of the answer. That is - * wrong for text the user typed, and for prose or examples that merely mention a tag. + * Show ``-style tags as literal text. * - * Fenced code blocks and inline code are left alone — their content is already rendered as - * literal text, and escaping it there would show the escape characters themselves. + * HyperMarkdown treats them as markup: a leading one becomes a reasoning block, and any other + * one is stripped out of the answer. That is wrong for text the user typed, and for prose that + * merely mentions a tag. + * + * A reasoning model's thinking never reaches this: it is rendered from its own field, so this + * only has to neutralise tags left in ordinary content. Fenced code and inline code are left + * alone -- their content is literal text already, and the streaming renderer does not look for + * a reasoning tag inside them. * * @param {string} text - * @param {{preserveLeadingBlock?: boolean}} [options] keep a leading reasoning block intact, - * for providers that stream their thinking inside the answer * @returns {string} */ -export function escapeReasoningTags(text, { preserveLeadingBlock = false } = {}) { +export function escapeReasoningTags(text) { if (typeof text !== 'string' || !text.includes('<')) return text - - let head = '' - let body = text - if (preserveLeadingBlock) { - const leading = LEADING_REASONING_BLOCK_PATTERN.exec(text) - if (leading) { - head = leading[0] - body = text.slice(head.length) - } - } - - return head + escapeTagsOutsideCode(body) + return escapeTagsOutsideCode(text) } diff --git a/src/components/MarkdownRender/waiting-placeholder.mjs b/src/components/MarkdownRender/waiting-placeholder.mjs new file mode 100644 index 000000000..6e3e7c2aa --- /dev/null +++ b/src/components/MarkdownRender/waiting-placeholder.mjs @@ -0,0 +1,12 @@ +/** + * The "waiting for response" placeholder the card shows until the first answer chunk arrives. + * + * Both the card, which writes it, and the streaming renderer, which must not mistake it for + * answer text, build it from this one template, so the two can never drift apart. + * + * @param {(key: string) => string} t translation function + * @returns {string} + */ +export function waitingPlaceholder(t) { + return `

${t('Waiting for response...')}

` +} diff --git a/src/content-script/styles.scss b/src/content-script/styles.scss index d448e6f99..62182109f 100644 --- a/src/content-script/styles.scss +++ b/src/content-script/styles.scss @@ -174,6 +174,16 @@ content: none !important; } + // The reasoning block renders through its own renderer so that no tag in the answer can + // open or close it. That renderer is another `.hypermarkdown` root, which would reset the + // small, muted type the reasoning block asks for; let the block's own type win instead. + .markdown-body .reasoning-content .hypermarkdown { + font-size: inherit; + line-height: inherit; + letter-spacing: inherit; + color: inherit; + } + .markdown-body .octicon { display: inline-block; fill: currentColor; diff --git a/src/services/apis/azure-openai-api.mjs b/src/services/apis/azure-openai-api.mjs index f5e1b4b6d..cf892ba10 100644 --- a/src/services/apis/azure-openai-api.mjs +++ b/src/services/apis/azure-openai-api.mjs @@ -1,94 +1,33 @@ import { getUserConfig } from '../../config/index.mjs' -import { pushRecord, setAbortController } from './shared.mjs' -import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' -import { fetchSSE } from '../../utils/fetch-sse.mjs' -import { isEmpty } from 'lodash-es' import { getModelValue } from '../../utils/model-name-convert.mjs' -import { getTemperatureParams } from './temperature-params.mjs' +import { generateAnswersWithOpenAICompatible } from './openai-compatible-core.mjs' /** + * Azure OpenAI wraps the same chat-completions payload as the other OpenAI-compatible + * providers, so it only has to describe the deployment URL and the `api-key` header; the + * shared core owns the request body and the stream parsing. + * * @param {Runtime.Port} port * @param {string} question * @param {Session} session */ export async function generateAnswersWithAzureOpenaiApi(port, question, session) { - const { controller, messageListener, disconnectListener } = setAbortController(port) const config = await getUserConfig() - let deploymentName = getModelValue(session) - if (!deploymentName) deploymentName = config.azureDeploymentName + const deploymentName = getModelValue(session) || config.azureDeploymentName + const baseUrl = config.azureEndpoint.replace(/\/$/, '') - const prompt = getConversationPairs( - session.conversationRecords.slice(-config.maxConversationContextLength), - false, - ) - prompt.push({ role: 'user', content: question }) - - let answer = '' - await fetchSSE( - `${config.azureEndpoint.replace( - /\/$/, - '', - )}/openai/deployments/${deploymentName}/chat/completions?api-version=2024-02-01`, - { - method: 'POST', - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - 'api-key': config.azureApiKey, - }, - body: JSON.stringify({ - messages: prompt, - stream: true, - max_tokens: config.maxResponseTokenLength, - // Azure deployment names are opaque aliases, not canonical model identifiers. - ...getTemperatureParams(config), - }), - onMessage(message) { - console.debug('sse message', message) - let data - try { - data = JSON.parse(message) - } catch (error) { - console.debug('json error', error) - return - } - if ( - data.choices && - data.choices.length > 0 && - data.choices[0] && - data.choices[0].delta && - 'content' in data.choices[0].delta - ) { - answer += data.choices[0].delta.content - port.postMessage({ answer: answer, done: false, session: null }) - } - - if (data.choices && data.choices.length > 0 && data.choices[0]?.finish_reason) { - pushRecord(session, question, answer) - console.debug('conversation history', { content: session.conversationRecords }) - port.postMessage({ answer: null, done: true, session: session }) - } - }, - async onStart() {}, - async onEnd(aborted) { - try { - if (!aborted) { - port.postMessage({ done: true }) - } - } finally { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) - } - }, - async onError(resp) { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) - if (resp instanceof Error) throw resp - const error = await resp.json().catch(() => ({})) - throw new Error( - !isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`, - ) - }, - }, - ) + await generateAnswersWithOpenAICompatible({ + port, + question, + session, + endpointType: 'chat', + requestUrl: `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=2024-02-01`, + // The deployment is in the URL and the key rides in a header, so no model and no bearer + // token are sent. The empty model also keeps the temperature allow-list from treating an + // opaque deployment alias as a canonical model id. + model: '', + apiKey: '', + config, + extraHeaders: { 'api-key': config.azureApiKey }, + }) } diff --git a/src/services/apis/chatgpt-web.mjs b/src/services/apis/chatgpt-web.mjs index 37f63d518..4a8777bda 100644 --- a/src/services/apis/chatgpt-web.mjs +++ b/src/services/apis/chatgpt-web.mjs @@ -1,9 +1,8 @@ // web version import { fetchSSE } from '../../utils/fetch-sse.mjs' -import { isEmpty } from 'lodash-es' import { getUserConfig, Models } from '../../config/index.mjs' -import { pushRecord, setAbortController } from './shared.mjs' +import { createApiResponseError, pushRecord, setAbortController } from './shared.mjs' import Browser from 'webextension-polyfill' import { v4 as uuidv4 } from 'uuid' import { t } from 'i18next' @@ -431,10 +430,7 @@ export async function generateAnswersWithChatgptWebApi(port, question, session, if (resp.status === 403) { throw new Error('CLOUDFLARE') } - const error = await resp.json().catch(() => ({})) - throw new Error( - !isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`, - ) + throw await createApiResponseError(resp) }, }) } finally { diff --git a/src/services/apis/claude-api.mjs b/src/services/apis/claude-api.mjs index 5d298877a..ad7fa413a 100644 --- a/src/services/apis/claude-api.mjs +++ b/src/services/apis/claude-api.mjs @@ -1,7 +1,11 @@ import { getUserConfig } from '../../config/index.mjs' -import { pushRecord, setAbortController } from './shared.mjs' +import { + createApiResponseError, + parseJsonMessage, + pushRecord, + setAbortController, +} from './shared.mjs' import { FETCH_RESPONSE_STREAM_FAILED, fetchSSE } from '../../utils/fetch-sse.mjs' -import { isEmpty } from 'lodash-es' import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' import { getModelValue } from '../../utils/model-name-convert.mjs' import { getTemperatureParams } from './temperature-params.mjs' @@ -39,6 +43,7 @@ export async function generateAnswersWithClaudeApi(port, question, session) { if (thinking) body.thinking = thinking let answer = '' + let reasoning = '' let stopReason = '' let completionError let wasAborted = false @@ -56,13 +61,8 @@ export async function generateAnswersWithClaudeApi(port, question, session) { onMessage(message) { console.debug('sse message', message) - let data - try { - data = JSON.parse(message) - } catch (error) { - console.debug('json error', error) - return - } + const data = parseJsonMessage(message) + if (data === undefined) return if (data?.type === 'error') { controller.abort() const error = new Error(JSON.stringify(data)) @@ -102,6 +102,14 @@ export async function generateAnswersWithClaudeApi(port, question, session) { return } + // Extended thinking arrives as its own delta type. It is shown as reasoning and never + // recorded as the answer, exactly like the reasoning field of an OpenAI-compatible API. + const thinkingDelta = data?.delta?.thinking + if (typeof thinkingDelta === 'string' && thinkingDelta) { + reasoning += thinkingDelta + port.postMessage({ reasoning, done: false, session: null }) + } + const delta = data?.delta?.text if (delta) { answer += delta @@ -121,8 +129,7 @@ export async function generateAnswersWithClaudeApi(port, question, session) { port.onMessage.removeListener(messageListener) port.onDisconnect.removeListener(disconnectListener) if (resp instanceof Error) throw resp - const error = await resp.json().catch(() => ({})) - throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) + throw await createApiResponseError(resp) }, }).catch((error) => error) if (wasAborted) return diff --git a/src/services/apis/inline-reasoning.mjs b/src/services/apis/inline-reasoning.mjs deleted file mode 100644 index 2d817acad..000000000 --- a/src/services/apis/inline-reasoning.mjs +++ /dev/null @@ -1,29 +0,0 @@ -const REASONING_OPEN_REGEX = /^\s*<(think|thinking|reasoning)(?:\s[^>]*)?>/i - -/** - * Some models put their reasoning straight into the answer, wrapped in a ``-style - * tag, instead of streaming it through a reasoning field. A leading block like that is - * split off so it can be shown as reasoning and stay out of the conversation records. - * - * Anything else in the answer is left untouched: prose that merely mentions a tag, and - * code samples that happen to contain one, keep their text. - * - * @param {string} content the answer so far - * @returns {{reasoning: string, answer: string, unclosed: boolean}} the leading thinking, the - * remaining answer, and whether the block is still waiting for its closing tag - */ -export function splitInlineReasoning(content) { - const open = REASONING_OPEN_REGEX.exec(content) - if (!open) return { reasoning: '', answer: content, unclosed: false } - - const rest = content.slice(open[0].length) - // The closing tag may still be on its way while the model is thinking. - const close = new RegExp(``, 'i').exec(rest) - if (!close) return { reasoning: rest, answer: '', unclosed: true } - - return { - reasoning: rest.slice(0, close.index), - answer: rest.slice(close.index + close[0].length).replace(/^\s+/, ''), - unclosed: false, - } -} diff --git a/src/services/apis/moonshot-web.mjs b/src/services/apis/moonshot-web.mjs index 2f2bfdd6d..e1d9cbebc 100644 --- a/src/services/apis/moonshot-web.mjs +++ b/src/services/apis/moonshot-web.mjs @@ -1,7 +1,6 @@ -import { pushRecord, setAbortController } from './shared.mjs' +import { createApiResponseError, pushRecord, setAbortController } from './shared.mjs' import { setUserConfig } from '../../config/index.mjs' import { fetchSSE } from '../../utils/fetch-sse.mjs' -import { isEmpty } from 'lodash-es' import { getModelValue } from '../../utils/model-name-convert.mjs' export class MoonshotWeb { @@ -442,10 +441,7 @@ export class Conversation { reject(resp) return } - const error = await resp.json().catch(() => ({})) - reject( - new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`), - ) + reject(await createApiResponseError(resp)) }, }) return returnPromise diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index c5117fd6c..92bb7da56 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -1,10 +1,14 @@ import { fetchSSE } from '../../utils/fetch-sse.mjs' import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' -import { isEmpty } from 'lodash-es' -import { getCompletionPromptBase, pushRecord, setAbortController } from './shared.mjs' +import { + createApiResponseError, + getCompletionPromptBase, + parseJsonMessage, + pushRecord, + setAbortController, +} from './shared.mjs' import { getChatCompletionsTokenParams } from './openai-token-params.mjs' import { getTemperatureParams } from './temperature-params.mjs' -import { splitInlineReasoning } from './inline-reasoning.mjs' function buildHeaders(apiKey, extraHeaders = {}) { const headers = { @@ -15,33 +19,48 @@ function buildHeaders(apiKey, extraHeaders = {}) { return headers } -function buildMessageAnswer(answer, data, allowLegacyResponseField) { +/** + * The answer carried by one response payload. Chat completions and legacy completions put + * the text in different places, and content that is not a plain string is ignored rather + * than coerced, so a stray object can never reach the card. + */ +function appendAnswerChunk(answer, data, allowLegacyResponseField) { if (allowLegacyResponseField && typeof data?.response === 'string' && data.response) { return data.response } - const delta = data?.choices?.[0]?.delta?.content - const content = data?.choices?.[0]?.message?.content - const text = data?.choices?.[0]?.text + const choice = data?.choices?.[0] + const delta = choice?.delta?.content if (typeof delta === 'string') return answer + delta + + // A non-streaming body, or a provider that sends the full message, replaces the answer. + const content = choice?.message?.content if (typeof content === 'string' && content) return content + + const text = choice?.text if (typeof text === 'string' && text) return answer + text - return answer -} -function hasFinished(data) { - return Boolean(data?.choices?.[0]?.finish_reason) + return answer } /** - * Reasoning models put their thinking in their own field rather than in the content, and - * deliberately do not replay it in context. Surfacing it separately keeps it out of the - * conversation records. + * The thinking a reasoning model keeps out of its content: DeepSeek's `reasoning_content`, + * OpenRouter-style `reasoning`, or a whole non-streaming message. It is read verbatim, so + * nothing is parsed out of, or into, the answer. */ -function getReasoningDelta(data) { - const delta = data?.choices?.[0]?.delta - const reasoning = delta?.reasoning_content ?? delta?.reasoning - return typeof reasoning === 'string' ? reasoning : '' +function readReasoningChunk(data) { + const choice = data?.choices?.[0] + const delta = choice?.delta?.reasoning_content ?? choice?.delta?.reasoning + if (typeof delta === 'string' && delta) return { text: delta, replace: false } + + const message = choice?.message?.reasoning_content ?? choice?.message?.reasoning + if (typeof message === 'string' && message) return { text: message, replace: true } + + return null +} + +function hasFinished(data) { + return Boolean(data?.choices?.[0]?.finish_reason) } /** @@ -88,6 +107,9 @@ export async function generateAnswersWithOpenAICompatible({ session.conversationRecords = conversationRecords const safeExtraBody = { ...extraBody } delete safeExtraBody.temperature + // Azure deployments are addressed by the URL, not by the body, so an empty model means + // "send none" rather than "send an empty string". + const modelParam = model ? { model } : {} if (endpointType === 'completion') { const prompt = (await getCompletionPromptBase()) + @@ -95,7 +117,7 @@ export async function generateAnswersWithOpenAICompatible({ `Human: ${question}\nAI: ` requestBody = { prompt, - model, + ...modelParam, stream: true, max_tokens: config.maxResponseTokenLength, ...getTemperatureParams(config, model), @@ -118,7 +140,7 @@ export async function generateAnswersWithOpenAICompatible({ delete safeExtraBody[conflictingTokenParamKey] requestBody = { messages, - model, + ...modelParam, stream: true, ...tokenParams, ...getTemperatureParams(config, model), @@ -132,39 +154,27 @@ export async function generateAnswersWithOpenAICompatible({ let postedReasoning = '' let finished = false - // The answer channel may carry the thinking itself, wrapped in a leading -style - // block, instead of (or alongside) a dedicated reasoning field; both sources reach the - // reader as reasoning and stay out of the conversation records. - const resolveStreamText = () => { - const inline = splitInlineReasoning(answer) - return { - // A block that never closed is thinking that was cut off mid-thought, so it is not an - // answer either: recording it would send the model its own unfinished reasoning back. - answer: inline.answer, - reasoning: [reasoning, inline.reasoning].filter(Boolean).join('\n\n'), - } - } - + // The thinking is whatever the reasoning field carried; the content channel is the answer, + // verbatim. Nothing is parsed out of the answer, so a model or a reader that writes a + // "" tag is writing text, and the renderer shows it as such. const postStreamText = () => { - const streamText = resolveStreamText() - if (streamText.answer !== postedAnswer) { - postedAnswer = streamText.answer - port.postMessage({ answer: streamText.answer, done: false, session: null }) + if (answer !== postedAnswer) { + postedAnswer = answer + port.postMessage({ answer, done: false, session: null }) } - if (streamText.reasoning && streamText.reasoning !== postedReasoning) { - postedReasoning = streamText.reasoning - port.postMessage({ reasoning: streamText.reasoning, done: false, session: null }) + if (reasoning && reasoning !== postedReasoning) { + postedReasoning = reasoning + port.postMessage({ reasoning, done: false, session: null }) } } const finish = () => { if (finished) return finished = true - const { answer: finalAnswer, reasoning: finalReasoning } = resolveStreamText() // A turn that was nothing but thinking has no answer to record; keeping it out of the // records is what stops the model from being sent its own unfinished reasoning back as // context. A plain empty answer is still recorded, as it always has been. - if (finalAnswer || !finalReasoning) pushRecord(session, question, finalAnswer) + if (answer || !reasoning) pushRecord(session, question, answer) port.postMessage({ answer: null, done: true, session: session }) } @@ -179,17 +189,15 @@ export async function generateAnswersWithOpenAICompatible({ finish() return } - let data - try { - data = JSON.parse(message) - } catch (error) { - console.debug('json error', error) - return - } + const data = parseJsonMessage(message) + if (data === undefined) return - const reasoningDelta = getReasoningDelta(data) - if (reasoningDelta) reasoning += reasoningDelta - answer = buildMessageAnswer(answer, data, allowLegacyResponseField) + const reasoningChunk = readReasoningChunk(data) + if (reasoningChunk) { + // A delta grows the thinking; a full message that replaces the content replaces it too. + reasoning = reasoningChunk.replace ? reasoningChunk.text : reasoning + reasoningChunk.text + } + answer = appendAnswerChunk(answer, data, allowLegacyResponseField) // A chunk can carry reasoning only; an unchanged answer is not posted again. postStreamText() @@ -202,11 +210,10 @@ export async function generateAnswersWithOpenAICompatible({ try { if (!finished) { if (aborted) { - const streamText = resolveStreamText() - const shouldPostSession = Boolean(streamText.answer) || session.isRetry + const shouldPostSession = Boolean(answer) || session.isRetry if (shouldPostSession && isCurrentSessionRequest()) { - if (streamText.answer) { - pushRecord(session, question, streamText.answer) + if (answer) { + pushRecord(session, question, answer) } session.isRetry = false try { @@ -232,8 +239,7 @@ export async function generateAnswersWithOpenAICompatible({ port.onMessage.removeListener(messageListener) port.onDisconnect.removeListener(disconnectListener) if (resp instanceof Error) throw resp - const error = await resp.json().catch(() => ({})) - throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) + throw await createApiResponseError(resp) }, }) } diff --git a/src/services/apis/shared.mjs b/src/services/apis/shared.mjs index a425305f1..9b5a04c01 100644 --- a/src/services/apis/shared.mjs +++ b/src/services/apis/shared.mjs @@ -1,3 +1,5 @@ +import { isEmpty } from 'lodash-es' + export const getChatSystemPromptBase = async () => { return `You are a helpful, creative, clever, and very friendly assistant. You are familiar with various languages in the world.` } @@ -83,3 +85,31 @@ export function pushRecord(session, question, answer) { if (session.isRetry && lastRecord && lastRecord.question === question) lastRecord.answer = answer else session.conversationRecords.push({ question: question, answer: answer }) } + +/** + * The user-facing error for a non-ok HTTP response: the provider's JSON body when there is + * one, and the status line otherwise. Every transport reports failures the same way. + * + * @param {Response} resp + * @returns {Promise} + */ +export async function createApiResponseError(resp) { + const error = await resp.json().catch(() => ({})) + return new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) +} + +/** + * Decode one SSE payload. A line that is not JSON is logged and dropped, which is how a + * stray comment or keep-alive is ignored. + * + * @param {string} message + * @returns {any} the decoded payload, or undefined when it was not JSON + */ +export function parseJsonMessage(message) { + try { + return JSON.parse(message) + } catch (error) { + console.debug('json error', error) + return undefined + } +} diff --git a/src/services/apis/waylaidwanderer-api.mjs b/src/services/apis/waylaidwanderer-api.mjs index a07cdf1bf..451ea04f4 100644 --- a/src/services/apis/waylaidwanderer-api.mjs +++ b/src/services/apis/waylaidwanderer-api.mjs @@ -1,7 +1,11 @@ -import { pushRecord, setAbortController } from './shared.mjs' +import { + createApiResponseError, + parseJsonMessage, + pushRecord, + setAbortController, +} from './shared.mjs' import { getUserConfig } from '../../config/index.mjs' import { fetchSSE } from '../../utils/fetch-sse.mjs' -import { isEmpty } from 'lodash-es' /** * @param {Runtime.Port} port @@ -42,13 +46,8 @@ export async function generateAnswersWithWaylaidwandererApi(port, question, sess port.postMessage({ answer: null, done: true, session: session }) return } - let data - try { - data = JSON.parse(message) - } catch (error) { - console.debug('json error', error) - return - } + const data = parseJsonMessage(message) + if (data === undefined) return if (data.conversationId) session.conversationId = data.conversationId if (data.parentMessageId) session.parentMessageId = data.parentMessageId if (data.encryptedConversationSignature) @@ -77,8 +76,7 @@ export async function generateAnswersWithWaylaidwandererApi(port, question, sess port.onMessage.removeListener(messageListener) port.onDisconnect.removeListener(disconnectListener) if (resp instanceof Error) throw resp - const error = await resp.json().catch(() => ({})) - throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) + throw await createApiResponseError(resp) }, }) } diff --git a/tests/unit/components/markdown-render.test.mjs b/tests/unit/components/markdown-render.test.mjs index 6c73bbe5d..9770b6677 100644 --- a/tests/unit/components/markdown-render.test.mjs +++ b/tests/unit/components/markdown-render.test.mjs @@ -126,12 +126,27 @@ test('reasoning stays open while streaming and collapses once done', () => { }) test('a question that mentions a reasoning tag renders it as text', () => { - const container = mount({ children: 'Use like this.', literalTags: true }) + const container = mount({ children: 'Use like this.' }) assert.equal(container.querySelector('.reasoning-wrapper'), null) assert.match(container.textContent, /Use like this\./) }) +test('a reasoning tag in the answer renders as text, never as a thinking block', () => { + const container = mount({ children: 'not thinking\n\nJust text.' }) + + assert.equal(container.querySelector('.reasoning-wrapper'), null) + assert.match(container.textContent, /not thinking<\/think>/) +}) + +test('a reasoning tag inside a code fence stays in the answer', () => { + const container = mount({ children: 'Example:\n\n```html\nx\n```' }) + + assert.equal(container.querySelector('.reasoning-wrapper'), null) + assert.ok(container.querySelector('pre code')) + assert.match(container.textContent, /x<\/think>/) +}) + test('raw html in an answer is preserved', () => { const container = mount({ children: 'a
b' }) @@ -146,3 +161,67 @@ test('links render through the shared Hyperlink component', () => { assert.equal(link.getAttribute('target'), '_blank') assert.equal(link.getAttribute('rel'), 'nofollow noopener noreferrer') }) + +test('an answer that mentions the loading class still renders with its reasoning', () => { + const container = mount({ + children: 'The class gpt-loading marks the waiting placeholder.', + reasoning: 'weighing options', + done: true, + }) + + assert.ok(container.querySelector('.reasoning-wrapper.collapsed')) + assert.match(container.textContent, /gpt-loading marks the waiting placeholder/) +}) + +test('a closing tag inside code in the reasoning cannot leak into the answer', () => { + const container = mount({ + children: 'The answer.', + reasoning: 'The user wrote `
` in code, then explained it.', + done: true, + }) + + assert.ok(container.querySelector('.reasoning-wrapper.collapsed')) + assert.match(container.textContent, /The answer\./) + assert.doesNotMatch(container.textContent, /then explained it/) +}) + +test('reasoning stays open when its code mentions a closing tag', () => { + const container = mount({ + children: LOADING, + reasoning: 'The user wrote `
` in code.', + done: false, + }) + + assert.ok(container.querySelector('.reasoning-wrapper.open')) +}) + +test('a reasoning tag inside the thinking stays literal, not a nested block', () => { + const container = mount({ + children: LOADING, + reasoning: 'nested still thinking', + done: false, + }) + + assert.equal(container.querySelectorAll('.reasoning-wrapper').length, 1) + assert.match(container.textContent, /nested<\/think>/) +}) + +test('the thinking is rendered as markdown inside its own block', () => { + const container = mount({ children: LOADING, reasoning: 'weighing **options**', done: false }) + + const content = container.querySelector('.reasoning-content') + assert.ok(content, 'the thinking block holds the content') + assert.ok(content.querySelector('strong'), 'the thinking goes through the markdown pipeline') +}) + +test('the thinking highlights code the same way the answer does', () => { + const container = mount({ + children: LOADING, + reasoning: '```js\nconst answer = 42\n```', + done: false, + }) + + const code = container.querySelector('.reasoning-content pre code') + assert.ok(code, 'the thinking renders its code block') + assert.ok(code.classList.contains('hljs'), 'the thinking uses the same highlight plugin') +}) diff --git a/tests/unit/components/reasoning-content.test.mjs b/tests/unit/components/reasoning-content.test.mjs deleted file mode 100644 index 0d263583f..000000000 --- a/tests/unit/components/reasoning-content.test.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict' -import { test } from 'node:test' -import { buildStreamedContent } from '../../../src/components/MarkdownRender/reasoning-content.mjs' - -const LOADING = '

Waiting for response...

' - -test('the answer alone is handed over unchanged when there is no reasoning', () => { - assert.equal(buildStreamedContent('Answer', '', false), 'Answer') - assert.equal(buildStreamedContent(LOADING, '', false), LOADING) -}) - -test('reasoning stays open while the answer has not started', () => { - assert.equal(buildStreamedContent('', 'thinking', false), '\nthinking') -}) - -test('the loading placeholder does not count as a started answer', () => { - // The block stays open, so the renderer keeps it expanded with the timer running. - assert.equal(buildStreamedContent(LOADING, 'thinking', false), '\nthinking') -}) - -test('the reasoning block closes once the answer starts', () => { - assert.equal( - buildStreamedContent('Answer', 'thinking', false), - '\nthinking\n\n\nAnswer', - ) -}) - -test('the reasoning block closes when the stream ends without an answer', () => { - assert.equal(buildStreamedContent('', 'thinking', true), '\nthinking\n\n\n') - assert.equal(buildStreamedContent(LOADING, 'thinking', true), '\nthinking\n\n\n') -}) - -test('a growing reasoning snapshot extends the still-open block', () => { - const first = buildStreamedContent(LOADING, 'thin', false) - const second = buildStreamedContent(LOADING, 'thinking', false) - assert.equal(second.startsWith(first), true) -}) - -test('a reasoning tag inside the thinking cannot close the synthetic block', () => { - assert.equal( - buildStreamedContent('', 'before after', false), - '\nbefore </think> after', - ) -}) diff --git a/tests/unit/components/special-tags.test.mjs b/tests/unit/components/special-tags.test.mjs index af5b1f173..14a355161 100644 --- a/tests/unit/components/special-tags.test.mjs +++ b/tests/unit/components/special-tags.test.mjs @@ -25,20 +25,11 @@ test('other tags and autolinks are left alone', () => { assert.equal(escapeReasoningTags(text), text) }) -test('a leading reasoning block can be kept for the renderer', () => { +test('a leading reasoning block is escaped, so it cannot open one', () => { const answer = 'secret\n\nThe answer.' - assert.equal(escapeReasoningTags(answer, { preserveLeadingBlock: true }), answer) assert.equal(escapeReasoningTags(answer), '<think>secret</think>\n\nThe answer.') }) -test('a later block is escaped even when a leading block is kept', () => { - const answer = 'secret\n\nMention again.' - assert.equal( - escapeReasoningTags(answer, { preserveLeadingBlock: true }), - 'secret\n\nMention <think> again.', - ) -}) - test('fenced code and inline code keep their text untouched', () => { const fenced = 'Example:\n\n```html\nexample\n```\n' assert.equal(escapeReasoningTags(fenced), fenced) diff --git a/tests/unit/services/apis/azure-openai-api.test.mjs b/tests/unit/services/apis/azure-openai-api.test.mjs index 1576fc8a0..1c915fcbf 100644 --- a/tests/unit/services/apis/azure-openai-api.test.mjs +++ b/tests/unit/services/apis/azure-openai-api.test.mjs @@ -221,7 +221,7 @@ test('azure-openai: aggregates SSE deltas and pushes record on finish', async (t port.postedMessages.some((m) => m.done === true && m.session === session), true, ) - assert.deepEqual(port.postedMessages.at(-1), { done: true }) + assert.deepEqual(port.postedMessages.at(-1), { answer: null, done: true, session }) assert.deepEqual(session.conversationRecords.at(-1), { question: 'CurrentQ', answer: 'Hello', diff --git a/tests/unit/services/apis/claude-api.test.mjs b/tests/unit/services/apis/claude-api.test.mjs index 0b0490f61..952683144 100644 --- a/tests/unit/services/apis/claude-api.test.mjs +++ b/tests/unit/services/apis/claude-api.test.mjs @@ -267,6 +267,32 @@ test('claude-api: delta.text streams accumulate and message_stop terminates', as ]) }) +test('claude-api: thinking deltas stream as reasoning, never as the answer', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"weighing "}}\n\n', + 'data: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"options"}}\n\n', + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"The answer."}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', + 'data: {"type":"message_stop"}\n\n', + ]), + ) + + await generateAnswersWithClaudeApi(port, 'CurrentQ', session) + + assert.deepEqual( + port.postedMessages.filter((message) => message.reasoning).map((message) => message.reasoning), + ['weighing ', 'weighing options'], + ) + assert.deepEqual(session.conversationRecords.at(-1), { + question: 'CurrentQ', + answer: 'The answer.', + }) +}) + test('claude-api: rejects incomplete Claude responses', async (t) => { t.mock.method(console, 'debug', () => {}) diff --git a/tests/unit/services/apis/inline-reasoning.test.mjs b/tests/unit/services/apis/inline-reasoning.test.mjs deleted file mode 100644 index 1a0fb95b2..000000000 --- a/tests/unit/services/apis/inline-reasoning.test.mjs +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'node:assert/strict' -import { test } from 'node:test' -import { splitInlineReasoning } from '../../../../src/services/apis/inline-reasoning.mjs' - -test('a leading thinking block is split off the answer', () => { - assert.deepEqual(splitInlineReasoning('weighing options\n\nThe answer.'), { - reasoning: 'weighing options', - answer: 'The answer.', - unclosed: false, - }) -}) - -test('an unclosed thinking block is still arriving', () => { - assert.deepEqual(splitInlineReasoning('\nweighing options'), { - reasoning: 'weighing options', - answer: '', - unclosed: true, - }) -}) - -test('the tag name and its attributes do not matter', () => { - assert.deepEqual(splitInlineReasoning('whyAnswer'), { - reasoning: 'why', - answer: 'Answer', - unclosed: false, - }) - assert.deepEqual(splitInlineReasoning('whyAnswer'), { - reasoning: 'why', - answer: 'Answer', - unclosed: false, - }) -}) - -test('an answer without such a block is left alone', () => { - assert.deepEqual(splitInlineReasoning('Just an answer.'), { - reasoning: '', - answer: 'Just an answer.', - unclosed: false, - }) -}) - -test('prose and code that mention a tag keep their text', () => { - const prose = 'Use tags like this for reasoning.' - assert.deepEqual(splitInlineReasoning(prose), { - reasoning: '', - answer: prose, - unclosed: false, - }) - - const fence = 'Example:\n\n```html\nexample\n```\n\nDone.' - assert.deepEqual(splitInlineReasoning(fence), { reasoning: '', answer: fence, unclosed: false }) -}) - -test('a block that only appears later in the answer is left in place', () => { - const answer = 'The answer.\n\nlater' - assert.deepEqual(splitInlineReasoning(answer), { reasoning: '', answer, unclosed: false }) -}) diff --git a/tests/unit/services/apis/reasoning-content.test.mjs b/tests/unit/services/apis/reasoning-content.test.mjs index 843cc0d07..22eda5d30 100644 --- a/tests/unit/services/apis/reasoning-content.test.mjs +++ b/tests/unit/services/apis/reasoning-content.test.mjs @@ -66,6 +66,20 @@ test('an answer with no reasoning behaves as before', async (t) => { assert.equal(session.conversationRecords[0].answer, 'Plain') }) +test('a non-streaming body carries its reasoning in the message', async (t) => { + const { port, session } = await run(t, [ + 'data: {"choices":[{"message":{"reasoning_content":"weighing options","content":"The answer."},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]) + + const reasoningUpdates = port.postedMessages.filter((message) => message.reasoning) + assert.deepEqual( + reasoningUpdates.map((message) => message.reasoning), + ['weighing options'], + ) + assert.equal(session.conversationRecords[0].answer, 'The answer.') +}) + test('reasoning-only chunks do not repost the unchanged answer', async (t) => { const { port } = await run(t, [ 'data: {"choices":[{"delta":{"content":"The answer."}}]}\n\n', @@ -81,74 +95,30 @@ test('reasoning-only chunks do not repost the unchanged answer', async (t) => { ) }) -test('thinking written inside the answer is moved to the reasoning channel', async (t) => { - const { port, session } = await run(t, [ - 'data: {"choices":[{"delta":{"content":"weighing "}}]}\n\n', - 'data: {"choices":[{"delta":{"content":"options\\n\\nThe answer."},"finish_reason":"stop"}]}\n\n', - 'data: [DONE]\n\n', - ]) - - const reasoningUpdates = port.postedMessages - .filter((message) => message.reasoning) - .map((message) => message.reasoning) - assert.deepEqual(reasoningUpdates, ['weighing ', 'weighing options']) - - const answerUpdates = port.postedMessages - .filter((message) => message.answer !== undefined && message.answer !== null) - .map((message) => message.answer) - // Nothing is posted for the answer while the thinking is still streaming. - assert.deepEqual(answerUpdates, ['The answer.']) - - assert.equal(session.conversationRecords[0].answer, 'The answer.') -}) - -test('thinking that never closes is not recorded as an answer', async (t) => { - const { port, session } = await run(t, [ - 'data: {"choices":[{"delta":{"content":"cut off while thinking"}}]}\n\n', +test('a turn that only reasoned is not recorded', async (t) => { + const { session } = await run(t, [ + 'data: {"choices":[{"delta":{"reasoning_content":"cut off while thinking"}}]}\n\n', 'data: {"choices":[{"delta":{},"finish_reason":"length"}]}\n\n', 'data: [DONE]\n\n', ]) - // The block never closed, so the turn has no answer: recording the thinking would send - // the model its own unfinished reasoning back as context. + // Recording thinking would send the model its own reasoning back as context. assert.equal(session.conversationRecords.length, 0) - assert.equal( - port.postedMessages.some( - (message) => typeof message.answer === 'string' && message.answer.includes('cut off'), - ), - false, - ) - assert.deepEqual( - port.postedMessages.filter((message) => message.reasoning).map((message) => message.reasoning), - ['cut off while thinking'], - ) }) -test('thinking split off the answer is combined with an explicit reasoning field', async (t) => { +test('thinking tags in the content stay in the answer instead of being parsed out', async (t) => { + const answer = 'weighing options\n\nThe answer.' const { port, session } = await run(t, [ - 'data: {"choices":[{"delta":{"reasoning_content":"from the field"}}]}\n\n', - 'data: {"choices":[{"delta":{"content":"in the answer\\n\\nThe answer."},"finish_reason":"stop"}]}\n\n', + `data: {"choices":[{"delta":{"content":${JSON.stringify( + answer, + )}},"finish_reason":"stop"}]}\n\n`, 'data: [DONE]\n\n', ]) - const lastReasoning = port.postedMessages.filter((message) => message.reasoning).at(-1) - assert.equal(lastReasoning.reasoning, 'from the field\n\nin the answer') - assert.equal(session.conversationRecords[0].answer, 'The answer.') -}) - -test('a partial inline tag is cleared once the tag resolves', async (t) => { - const { port, session } = await run(t, [ - 'data: {"choices":[{"delta":{"content":""}}]}\n\n', - 'data: {"choices":[{"delta":{"content":"thinking\\n\\nThe answer."},"finish_reason":"stop"}]}\n\n', - 'data: [DONE]\n\n', - ]) - - const answers = port.postedMessages - .filter((message) => typeof message.answer === 'string') - .map((message) => message.answer) - // The partial tag is posted first, then cleared once it turns out to be thinking, so the - // card never keeps the stale fragment as its answer. - assert.deepEqual(answers, [' message.reasoning).length, 0) + const answerUpdate = port.postedMessages.find((message) => typeof message.answer === 'string') + assert.equal(answerUpdate.answer, answer) + assert.equal(session.conversationRecords[0].answer, answer) }) From 2cccec290c532ae09fad6bf913412a08e57003bf Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sun, 4 Oct 2026 00:05:52 +0800 Subject: [PATCH 12/12] Translate the thinking labels for every locale The Thinking Content and "Thought for {seconds}s" labels ship only in English and the two Chinese locales, so the other ten fell back to English. Add both. Also correct the highlight subset note: `shell` is the Shell Session grammar, not a `bash` alias, so the entry narrows detection rather than removing a duplicate. --- src/_locales/de/main.json | 2 ++ src/_locales/es/main.json | 2 ++ src/_locales/fr/main.json | 2 ++ src/_locales/id/main.json | 2 ++ src/_locales/it/main.json | 2 ++ src/_locales/ja/main.json | 2 ++ src/_locales/ko/main.json | 2 ++ src/_locales/pt/main.json | 2 ++ src/_locales/ru/main.json | 2 ++ src/_locales/tr/main.json | 2 ++ 10 files changed, 20 insertions(+) diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index 8b0d204bc..bf0db4e85 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -31,6 +31,8 @@ "You": "Du", "Collapse": "Verkleinern", "Expand": "Erweitern", + "Thinking Content": "Denkprozess", + "Thought for {seconds}s": "{seconds} s nachgedacht", "Stop": "Stoppen", "Continue on official website": "Weiter auf der offiziellen Website", "Error": "Fehler", diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index c9f5b5a11..ac49657c4 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -31,6 +31,8 @@ "You": "Tú", "Collapse": "Colapsar", "Expand": "Expandir", + "Thinking Content": "Contenido del pensamiento", + "Thought for {seconds}s": "Pensó durante {seconds} s", "Stop": "Detener", "Continue on official website": "Continuar en el sitio web oficial", "Error": "Error", diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index 1fa86e8a3..8b1da8cdc 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -31,6 +31,8 @@ "You": "Vous", "Collapse": "Réduire", "Expand": "Agrandir", + "Thinking Content": "Contenu de la réflexion", + "Thought for {seconds}s": "Réflexion pendant {seconds} s", "Stop": "Arrêter", "Continue on official website": "Continuer sur le site officiel", "Error": "Erreur", diff --git a/src/_locales/id/main.json b/src/_locales/id/main.json index 5267098d2..5dac8f375 100644 --- a/src/_locales/id/main.json +++ b/src/_locales/id/main.json @@ -31,6 +31,8 @@ "You": "Anda", "Collapse": "Ciutkan", "Expand": "Perbesar", + "Thinking Content": "Isi pemikiran", + "Thought for {seconds}s": "Berpikir selama {seconds} dtk", "Stop": "Berhenti", "Continue on official website": "Lanjutkan di situs web resmi", "Error": "Kesalahan", diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index 4a592ff01..f63f3118c 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -31,6 +31,8 @@ "You": "Tu", "Collapse": "Comprimi", "Expand": "Espandi", + "Thinking Content": "Contenuto del ragionamento", + "Thought for {seconds}s": "Ha pensato per {seconds} s", "Stop": "Stop", "Continue on official website": "Continua sul sito ufficiale", "Error": "Errore", diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index 91c960949..bd0b4c85b 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -31,6 +31,8 @@ "You": "あなた", "Collapse": "折りたたむ", "Expand": "展開", + "Thinking Content": "思考内容", + "Thought for {seconds}s": "{seconds}秒考えました", "Stop": "停止", "Continue on official website": "公式サイトで続ける", "Error": "エラー", diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 252ed15f8..20a99be36 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -31,6 +31,8 @@ "You": "당신", "Collapse": "축소", "Expand": "확장", + "Thinking Content": "사고 내용", + "Thought for {seconds}s": "{seconds}초 동안 생각함", "Stop": "중지", "Continue on official website": "공식 웹사이트에서 계속하기", "Error": "오류", diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index b3379c159..44fed8bf6 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -31,6 +31,8 @@ "You": "Você", "Collapse": "Colapso", "Expand": "Expandir", + "Thinking Content": "Conteúdo do raciocínio", + "Thought for {seconds}s": "Pensou por {seconds} s", "Stop": "Parar", "Continue on official website": "Continuar no site oficial", "Error": "Erro", diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index d857f3f62..78b9c1d48 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -31,6 +31,8 @@ "You": "Ты", "Collapse": "Свернуть", "Expand": "Развернуть", + "Thinking Content": "Ход мыслей", + "Thought for {seconds}s": "Думал {seconds} с", "Stop": "Остановить", "Continue on official website": "Продолжить на официальном сайте", "Error": "Ошибка", diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index 1b2391cf4..a3ba40350 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -31,6 +31,8 @@ "You": "Sen", "Collapse": "Daralt", "Expand": "Genişlet", + "Thinking Content": "Düşünce içeriği", + "Thought for {seconds}s": "{seconds} sn düşündü", "Stop": "Durdur", "Continue on official website": "Resmi web sitesinde devam et", "Error": "Hata",