fix(ui): stable IR node IDs for streaming rerenders
- Replace useId + reactId with content-based hash in MarkdownRenderer - Add stableId function using position + content hash in IR parser - Fix legacy ID path for old nextId() calls
This commit is contained in:
parent
54d6f01981
commit
d6167af8ce
@ -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;
|
||||
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user