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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import rehypeSanitize from "rehype-sanitize";
|
import rehypeSanitize from "rehype-sanitize";
|
||||||
@ -30,7 +30,6 @@ function CodeBlock({children, className}: { children: React.ReactNode; className
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const preview = useCodePreview();
|
const preview = useCodePreview();
|
||||||
const reactId = useId();
|
|
||||||
const cls = Array.isArray(className) ? className.join(" ") : (className || "");
|
const cls = Array.isArray(className) ? className.join(" ") : (className || "");
|
||||||
const match = /language-([^\s]+)/.exec(cls);
|
const match = /language-([^\s]+)/.exec(cls);
|
||||||
const language = match?.[1] || "";
|
const language = match?.[1] || "";
|
||||||
@ -40,7 +39,15 @@ function CodeBlock({children, className}: { children: React.ReactNode; className
|
|||||||
const lineCount = content.trim() ? lines.length : 0;
|
const lineCount = content.trim() ? lines.length : 0;
|
||||||
const opensPanel = lineCount > INLINE_CODE_LINE_LIMIT && !!preview;
|
const opensPanel = lineCount > INLINE_CODE_LINE_LIMIT && !!preview;
|
||||||
const canExpandInline = !opensPanel;
|
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 activePreviewId = preview?.activeCode?.id;
|
||||||
const openCodePreview = preview?.openCodePreview;
|
const openCodePreview = preview?.openCodePreview;
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
IrNode, IrTextNode, IrMentionNode,
|
IrNode, IrTextNode, IrMentionNode,
|
||||||
IrContentBlock,
|
IrContentBlock,
|
||||||
IrCodeBlockNode, IrHtmlFragmentNode, IrMermaidNode,
|
IrCodeBlockNode, IrHtmlFragmentNode, IrMermaidNode,
|
||||||
@ -6,12 +6,12 @@ import type {
|
|||||||
|
|
||||||
const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
|
const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
|
||||||
const WRAPPED_MENTION_RE = /(?:\*{1,2}|_{1,2})@\[([a-z]+):([^:\]]+):([^\]]+)\](?:\*{1,2}|_{1,2})/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
|
// Uses a negative lookbehind for backtick count to avoid matching
|
||||||
// inline code (single backticks) or mid-paragraph code spans.
|
// inline code (single backticks) or mid-paragraph code spans.
|
||||||
const CODE_BLOCK_RE = /```([^\s`]*)\n([\s\S]*?)```/g;
|
const CODE_BLOCK_RE = /```([^\s`]*)\n([\s\S]*?)```/g;
|
||||||
|
|
||||||
/** Strip markdown emphasis wrapping mentions: **@[...]** → @[...] */
|
/** Strip markdown emphasis wrapping mentions: **@[...]** 鈫?@[...] */
|
||||||
function stripMentionWrapping(content: string): string {
|
function stripMentionWrapping(content: string): string {
|
||||||
return content.replace(WRAPPED_MENTION_RE, (_, type, id, label) => {
|
return content.replace(WRAPPED_MENTION_RE, (_, type, id, label) => {
|
||||||
return `@[${type}:${id}:${label}]`;
|
return `@[${type}:${id}:${label}]`;
|
||||||
@ -19,8 +19,21 @@ function stripMentionWrapping(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let nodeIdCounter = 0;
|
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 {
|
function nextId(): string {
|
||||||
return `ir-${++nodeIdCounter}`;
|
return `ir-legacy-${++nodeIdCounter}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pattern scanner that finds the earliest special pattern in content.
|
/** 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.
|
* IrTextNode rendered by MarkdownRenderer.
|
||||||
*
|
*
|
||||||
* Code blocks with special languages get their own node types:
|
* Code blocks with special languages get their own node types:
|
||||||
* - ```mermaid → IrMermaidNode (rendered by MermaidRenderer)
|
* - ```mermaid 鈫?IrMermaidNode (rendered by MermaidRenderer)
|
||||||
* - ```html → IrHtmlFragmentNode (rendered by HtmlBlockRenderer)
|
* - ```html 鈫?IrHtmlFragmentNode (rendered by HtmlBlockRenderer)
|
||||||
* - other → IrCodeBlockNode (rendered as static pre+code) */
|
* - other 鈫?IrCodeBlockNode (rendered as static pre+code) */
|
||||||
export function extractIrNodes(content: string): IrNode[] {
|
export function extractIrNodes(content: string): IrNode[] {
|
||||||
if (!content) return [];
|
if (!content) return [];
|
||||||
|
|
||||||
@ -91,22 +104,37 @@ export function extractIrNodes(content: string): IrNode[] {
|
|||||||
while (lastIndex < cleaned.length) {
|
while (lastIndex < cleaned.length) {
|
||||||
const hit = findNextPattern(cleaned, lastIndex);
|
const hit = findNextPattern(cleaned, lastIndex);
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
// No more patterns — emit remaining text
|
// No more patterns 鈥?emit remaining text
|
||||||
if (lastIndex < cleaned.length) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text before the pattern
|
// Text before the pattern
|
||||||
if (hit.start > lastIndex) {
|
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
|
// Emit the pattern node
|
||||||
if (hit.type === 'mention') {
|
if (hit.type === 'mention') {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: nextId(),
|
id: stableId(
|
||||||
|
'mention',
|
||||||
|
hit.start,
|
||||||
|
hit.end,
|
||||||
|
`${hit.data.entityType}:${hit.data.entityId}:${hit.data.entityLabel}`
|
||||||
|
),
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
entity_type: hit.data.entityType,
|
entity_type: hit.data.entityType,
|
||||||
entity_id: hit.data.entityId,
|
entity_id: hit.data.entityId,
|
||||||
@ -116,9 +144,18 @@ export function extractIrNodes(content: string): IrNode[] {
|
|||||||
const language = hit.data.language;
|
const language = hit.data.language;
|
||||||
const codeContent = hit.data.content;
|
const codeContent = hit.data.content;
|
||||||
if (language === 'mermaid') {
|
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 {
|
} 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 no patterns found, return one text node
|
||||||
if (nodes.length === 0) {
|
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;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Content block parsing ───
|
// 鈹€鈹€鈹€ Content block parsing 鈹€鈹€鈹€
|
||||||
// Converts the backend persistence format into IrContentBlock[],
|
// Converts the backend persistence format into IrContentBlock[],
|
||||||
// handling both old format ({role, content: string}) and future
|
// handling both old format ({role, content: string}) and future
|
||||||
// format ({role, nodes: IrNode[]}), plus the __chunks__ format
|
// format ({role, nodes: IrNode[]}), plus the __chunks__ format
|
||||||
@ -149,7 +190,7 @@ function normalizeThinking(content: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize answer content — collapse stray single newlines. */
|
/** Normalize answer content 鈥?collapse stray single newlines. */
|
||||||
function normalizeAnswer(content: string): string {
|
function normalizeAnswer(content: string): string {
|
||||||
return content
|
return content
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
@ -229,16 +270,16 @@ export function parseContentBlocks(raw: unknown): IrContentBlock[] {
|
|||||||
const obj = item as Record<string, unknown>;
|
const obj = item as Record<string, unknown>;
|
||||||
const role = (typeof obj.role === "string" ? obj.role : "assistant") as IrContentBlock['role'];
|
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)) {
|
if (Array.isArray(obj.nodes)) {
|
||||||
blocks.push({ role, nodes: obj.nodes as IrNode[] });
|
blocks.push({ role, nodes: obj.nodes as IrNode[] });
|
||||||
continue;
|
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 : "";
|
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") {
|
if (role === "tool_call") {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
@ -323,3 +364,4 @@ export function extractFullText(blocks: IrContentBlock[]): string {
|
|||||||
export function hasSpecialNodes(nodes: IrNode[]): boolean {
|
export function hasSpecialNodes(nodes: IrNode[]): boolean {
|
||||||
return nodes.some((n) => n.type !== 'text');
|
return nodes.some((n) => n.type !== 'text');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user