diff --git a/src/components/ui/MarkdownRenderer.tsx b/src/components/ui/MarkdownRenderer.tsx index 271b01f..9efa023 100644 --- a/src/components/ui/MarkdownRenderer.tsx +++ b/src/components/ui/MarkdownRenderer.tsx @@ -1,4 +1,4 @@ -import {memo, useEffect, useId, useState} from "react"; +import {memo, useEffect, useMemo, useState} from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeSanitize from "rehype-sanitize"; @@ -30,7 +30,6 @@ function CodeBlock({children, className}: { children: React.ReactNode; className const [copied, setCopied] = useState(false); const [isOpen, setIsOpen] = useState(false); const preview = useCodePreview(); - const reactId = useId(); const cls = Array.isArray(className) ? className.join(" ") : (className || ""); const match = /language-([^\s]+)/.exec(cls); const language = match?.[1] || ""; @@ -40,7 +39,15 @@ function CodeBlock({children, className}: { children: React.ReactNode; className const lineCount = content.trim() ? lines.length : 0; const opensPanel = lineCount > INLINE_CODE_LINE_LIMIT && !!preview; const canExpandInline = !opensPanel; - const previewId = `${reactId}-${displayLanguage}`; + const previewId = useMemo(() => { + // Stable id across streaming rerenders/remounts. + let hash = 5381; + const seed = `${displayLanguage}\n${content}`; + for (let i = 0; i < seed.length; i++) { + hash = ((hash << 5) + hash) ^ seed.charCodeAt(i); + } + return `md-code-${displayLanguage}-${(hash >>> 0).toString(36)}`; + }, [displayLanguage, content]); const activePreviewId = preview?.activeCode?.id; const openCodePreview = preview?.openCodePreview; diff --git a/src/lib/ir/parser.ts b/src/lib/ir/parser.ts index 722f933..2904617 100644 --- a/src/lib/ir/parser.ts +++ b/src/lib/ir/parser.ts @@ -1,4 +1,4 @@ -import type { +import type { IrNode, IrTextNode, IrMentionNode, IrContentBlock, IrCodeBlockNode, IrHtmlFragmentNode, IrMermaidNode, @@ -6,12 +6,12 @@ import type { const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g; const WRAPPED_MENTION_RE = /(?:\*{1,2}|_{1,2})@\[([a-z]+):([^:\]]+):([^\]]+)\](?:\*{1,2}|_{1,2})/g; -// Fenced code blocks — captures language tag + content. +// Fenced code blocks 鈥?captures language tag + content. // Uses a negative lookbehind for backtick count to avoid matching // inline code (single backticks) or mid-paragraph code spans. const CODE_BLOCK_RE = /```([^\s`]*)\n([\s\S]*?)```/g; -/** Strip markdown emphasis wrapping mentions: **@[...]** → @[...] */ +/** Strip markdown emphasis wrapping mentions: **@[...]** 鈫?@[...] */ function stripMentionWrapping(content: string): string { return content.replace(WRAPPED_MENTION_RE, (_, type, id, label) => { return `@[${type}:${id}:${label}]`; @@ -19,8 +19,21 @@ function stripMentionWrapping(content: string): string { } let nodeIdCounter = 0; + +function hashString(input: string): string { + let hash = 5381; + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) + hash) ^ input.charCodeAt(i); + } + return (hash >>> 0).toString(36); +} + +function stableId(type: string, start: number, end: number, contentSeed: string): string { + return `ir-${type}-${start}-${end}-${hashString(contentSeed)}`; +} + function nextId(): string { - return `ir-${++nodeIdCounter}`; + return `ir-legacy-${++nodeIdCounter}`; } /** Pattern scanner that finds the earliest special pattern in content. @@ -78,9 +91,9 @@ function findNextPattern(content: string, fromIndex: number): PatternHit | null * IrTextNode rendered by MarkdownRenderer. * * Code blocks with special languages get their own node types: - * - ```mermaid → IrMermaidNode (rendered by MermaidRenderer) - * - ```html → IrHtmlFragmentNode (rendered by HtmlBlockRenderer) - * - other → IrCodeBlockNode (rendered as static pre+code) */ + * - ```mermaid 鈫?IrMermaidNode (rendered by MermaidRenderer) + * - ```html 鈫?IrHtmlFragmentNode (rendered by HtmlBlockRenderer) + * - other 鈫?IrCodeBlockNode (rendered as static pre+code) */ export function extractIrNodes(content: string): IrNode[] { if (!content) return []; @@ -91,22 +104,37 @@ export function extractIrNodes(content: string): IrNode[] { while (lastIndex < cleaned.length) { const hit = findNextPattern(cleaned, lastIndex); if (!hit) { - // No more patterns — emit remaining text + // No more patterns 鈥?emit remaining text if (lastIndex < cleaned.length) { - nodes.push({ id: nextId(), type: 'text', content: cleaned.slice(lastIndex) } as IrTextNode); + const text = cleaned.slice(lastIndex); + nodes.push({ + id: stableId('text', lastIndex, cleaned.length, text), + type: 'text', + content: text, + } as IrTextNode); } break; } // Text before the pattern if (hit.start > lastIndex) { - nodes.push({ id: nextId(), type: 'text', content: cleaned.slice(lastIndex, hit.start) } as IrTextNode); + const text = cleaned.slice(lastIndex, hit.start); + nodes.push({ + id: stableId('text', lastIndex, hit.start, text), + type: 'text', + content: text, + } as IrTextNode); } // Emit the pattern node if (hit.type === 'mention') { nodes.push({ - id: nextId(), + id: stableId( + 'mention', + hit.start, + hit.end, + `${hit.data.entityType}:${hit.data.entityId}:${hit.data.entityLabel}` + ), type: 'mention', entity_type: hit.data.entityType, entity_id: hit.data.entityId, @@ -116,9 +144,18 @@ export function extractIrNodes(content: string): IrNode[] { const language = hit.data.language; const codeContent = hit.data.content; if (language === 'mermaid') { - nodes.push({ id: nextId(), type: 'mermaid', source: codeContent } as IrMermaidNode); + nodes.push({ + id: stableId('mermaid', hit.start, hit.end, codeContent), + type: 'mermaid', + source: codeContent, + } as IrMermaidNode); } else { - nodes.push({ id: nextId(), type: 'code_block', language: language || '', content: codeContent } as IrCodeBlockNode); + nodes.push({ + id: stableId('code_block', hit.start, hit.end, `${language || ''}\n${codeContent}`), + type: 'code_block', + language: language || '', + content: codeContent, + } as IrCodeBlockNode); } } @@ -127,13 +164,17 @@ export function extractIrNodes(content: string): IrNode[] { // If no patterns found, return one text node if (nodes.length === 0) { - return [{ id: nextId(), type: 'text', content } as IrTextNode]; + return [{ + id: stableId('text', 0, content.length, content), + type: 'text', + content, + } as IrTextNode]; } return nodes; } -// ─── Content block parsing ─── +// 鈹€鈹€鈹€ Content block parsing 鈹€鈹€鈹€ // Converts the backend persistence format into IrContentBlock[], // handling both old format ({role, content: string}) and future // format ({role, nodes: IrNode[]}), plus the __chunks__ format @@ -149,7 +190,7 @@ function normalizeThinking(content: string): string { .trim(); } -/** Normalize answer content — collapse stray single newlines. */ +/** Normalize answer content 鈥?collapse stray single newlines. */ function normalizeAnswer(content: string): string { return content .replace(/\n{3,}/g, "\n\n") @@ -229,16 +270,16 @@ export function parseContentBlocks(raw: unknown): IrContentBlock[] { const obj = item as Record; const role = (typeof obj.role === "string" ? obj.role : "assistant") as IrContentBlock['role']; - // New IR format — nodes already provided + // New IR format 鈥?nodes already provided if (Array.isArray(obj.nodes)) { blocks.push({ role, nodes: obj.nodes as IrNode[] }); continue; } - // Legacy format — content is a string, parse into IrNode[] + // Legacy format 鈥?content is a string, parse into IrNode[] const content = typeof obj.content === "string" ? obj.content : ""; - // Handle tool_call/tool_result roles — parse JSON content directly into IR nodes + // Handle tool_call/tool_result roles 鈥?parse JSON content directly into IR nodes if (role === "tool_call") { try { const parsed = JSON.parse(content); @@ -323,3 +364,4 @@ export function extractFullText(blocks: IrContentBlock[]): string { export function hasSpecialNodes(nodes: IrNode[]): boolean { return nodes.some((n) => n.type !== 'text'); } +