Fix chat typing experience and Persian ordered list numbering
- Eliminate slow character-by-character typewriter delay on completed responses; render AI responses smoothly and immediately. - Fix Persian/RTL list marker orientation by isolating numbers and placing delimiter in natural RTL flow (display 1. instead of .1). - Ensure standard ordered lists in chat have explicit decimal numbering.
This commit is contained in:
@@ -20,7 +20,7 @@ type MessageBubbleProps = {
|
||||
type ContentBlock =
|
||||
| { type: 'p'; text: string }
|
||||
| { type: 'heading'; text: string }
|
||||
| { type: 'list'; ordered: boolean; items: string[]; markers?: string[] }
|
||||
| { type: 'list'; ordered: boolean; items: string[]; markers?: string[]; delimiters?: string[] }
|
||||
| { type: 'table'; headers: string[]; rows: string[][] };
|
||||
|
||||
const stripImageMarkdown = (text: string): string =>
|
||||
@@ -132,16 +132,16 @@ const isTableLine = (line: string): boolean =>
|
||||
line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2;
|
||||
|
||||
/** Western, Persian, or Arabic-Indic digits + . or ) */
|
||||
const ORDERED_ITEM_RE = /^\s*((?:\d+|[۰-۹]+|[٠-٩]+))[.)]\s+(\S.*)$/;
|
||||
const ORDERED_ITEM_RE = /^\s*((?:\d+|[۰-۹]+|[٠-٩]+))([.)])\s+(\S.*)$/;
|
||||
|
||||
const isOrderedItem = (line: string): boolean => ORDERED_ITEM_RE.test(line);
|
||||
|
||||
const isBulletItem = (line: string): boolean => /^\s*(?:[-•]|[*])\s+\S/.test(line);
|
||||
|
||||
const parseOrderedItem = (line: string): { marker: string; text: string } | null => {
|
||||
const parseOrderedItem = (line: string): { marker: string; delimiter: string; text: string } | null => {
|
||||
const match = line.match(ORDERED_ITEM_RE);
|
||||
if (!match) return null;
|
||||
return { marker: match[1] ?? '', text: match[2] ?? '' };
|
||||
return { marker: match[1] ?? '', delimiter: match[2] ?? '.', text: match[3] ?? '' };
|
||||
};
|
||||
|
||||
const stripListMarker = (line: string): string =>
|
||||
@@ -201,6 +201,7 @@ const parseBlocks = (text: string): ContentBlock[] => {
|
||||
const ordered = isOrderedItem(line);
|
||||
const items: string[] = [];
|
||||
const markers: string[] = [];
|
||||
const delimiters: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const current = lines[i] ?? '';
|
||||
if (!current.trim()) break;
|
||||
@@ -210,6 +211,7 @@ const parseBlocks = (text: string): ContentBlock[] => {
|
||||
if (!parsed) break;
|
||||
// Keep the model's own numbering (۱/۲/۳…) — do not renumber with CSS 1,2,3.
|
||||
markers.push(parsed.marker);
|
||||
delimiters.push(parsed.delimiter);
|
||||
items.push(parsed.text);
|
||||
} else {
|
||||
items.push(stripListMarker(current));
|
||||
@@ -217,7 +219,9 @@ const parseBlocks = (text: string): ContentBlock[] => {
|
||||
i += 1;
|
||||
}
|
||||
blocks.push(
|
||||
ordered ? { type: 'list', ordered: true, items, markers } : { type: 'list', ordered: false, items }
|
||||
ordered
|
||||
? { type: 'list', ordered: true, items, markers, delimiters }
|
||||
: { type: 'list', ordered: false, items }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -351,8 +355,9 @@ export const MessageBubble = ({
|
||||
>
|
||||
{block.ordered ? (
|
||||
<>
|
||||
<span className="chat-num shrink-0 font-semibold" dir="ltr">
|
||||
{block.markers?.[itemIndex] ?? String(itemIndex + 1)}.
|
||||
<span className="chat-num shrink-0 font-semibold select-none">
|
||||
<span dir="ltr">{block.markers?.[itemIndex] ?? String(itemIndex + 1)}</span>
|
||||
{block.delimiters?.[itemIndex] ?? '.'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{renderInline(item, isActivelyTyping)}
|
||||
|
||||
@@ -8,23 +8,10 @@ type UseStreamTypewriterOptions = {
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
/** ~20% faster than the original pacing (delays × 0.8). */
|
||||
const SPEED = 0.8;
|
||||
|
||||
const randomDelayMs = (char: string, nextChar = ''): number => {
|
||||
if (char === '\n') {
|
||||
if (nextChar === '\n') return (340 + Math.random() * 140) * SPEED;
|
||||
return (200 + Math.random() * 90) * SPEED;
|
||||
}
|
||||
if (/[.!?…۔؟]/.test(char)) return (80 + Math.random() * 90) * SPEED;
|
||||
if (/[,،;:]/.test(char)) return (40 + Math.random() * 40) * SPEED;
|
||||
if (/\s/.test(char)) return (12 + Math.random() * 10) * SPEED;
|
||||
return (15 + Math.random() * 15) * SPEED;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulated streaming typewriter with variable per-character delay.
|
||||
* Stable across parent re-renders (onComplete/onProgress via refs).
|
||||
* Direct, continuous message stream hook.
|
||||
* Avoids slow character-by-character timer replay on complete responses
|
||||
* while cleanly maintaining progress and completion callbacks.
|
||||
*/
|
||||
export function useStreamTypewriter({
|
||||
text,
|
||||
@@ -32,13 +19,8 @@ export function useStreamTypewriter({
|
||||
onProgress,
|
||||
onComplete,
|
||||
}: UseStreamTypewriterOptions) {
|
||||
const [displayedText, setDisplayedText] = useState(() => (enabled ? '' : text));
|
||||
const [isStreaming, setIsStreaming] = useState(Boolean(enabled && text));
|
||||
const indexRef = useRef(0);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const textRef = useRef(text);
|
||||
const enabledRef = useRef(enabled);
|
||||
const completedForTextRef = useRef<string | null>(enabled ? null : text);
|
||||
const [displayedText, setDisplayedText] = useState(text);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const onProgressRef = useRef(onProgress);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
|
||||
@@ -51,79 +33,18 @@ export function useStreamTypewriter({
|
||||
}, [onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
setDisplayedText('');
|
||||
setIsStreaming(false);
|
||||
indexRef.current = 0;
|
||||
textRef.current = '';
|
||||
enabledRef.current = enabled;
|
||||
completedForTextRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
setDisplayedText(text);
|
||||
setIsStreaming(false);
|
||||
textRef.current = text;
|
||||
enabledRef.current = false;
|
||||
completedForTextRef.current = text;
|
||||
return;
|
||||
}
|
||||
|
||||
const textChanged = textRef.current !== text;
|
||||
const enabledChanged = enabledRef.current !== enabled;
|
||||
textRef.current = text;
|
||||
enabledRef.current = enabled;
|
||||
|
||||
if (!textChanged && !enabledChanged && completedForTextRef.current === text) {
|
||||
setDisplayedText(text);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRestart =
|
||||
textChanged || enabledChanged || completedForTextRef.current !== text;
|
||||
|
||||
if (shouldRestart) {
|
||||
indexRef.current = 0;
|
||||
completedForTextRef.current = null;
|
||||
setDisplayedText('');
|
||||
setIsStreaming(true);
|
||||
|
||||
const tick = () => {
|
||||
const full = textRef.current;
|
||||
if (indexRef.current >= full.length) {
|
||||
setIsStreaming(false);
|
||||
completedForTextRef.current = full;
|
||||
onCompleteRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentChar = full[indexRef.current] ?? '';
|
||||
const nextChar = full[indexRef.current + 1] ?? '';
|
||||
const nextIndex = indexRef.current + 1;
|
||||
const nextText = full.slice(0, nextIndex);
|
||||
indexRef.current = nextIndex;
|
||||
setDisplayedText(nextText);
|
||||
onProgressRef.current?.(nextText);
|
||||
timeoutRef.current = setTimeout(tick, randomDelayMs(currentChar, nextChar));
|
||||
};
|
||||
|
||||
timeoutRef.current = setTimeout(tick, randomDelayMs(text[0] ?? ' ', text[1] ?? ''));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
setDisplayedText(text);
|
||||
setIsStreaming(false);
|
||||
onProgressRef.current?.(text);
|
||||
onCompleteRef.current?.();
|
||||
}, [text, enabled]);
|
||||
|
||||
return { displayedText, isStreaming };
|
||||
}
|
||||
|
||||
|
||||
@@ -949,6 +949,10 @@
|
||||
margin-inline-end: 0.15rem;
|
||||
}
|
||||
|
||||
.bubble-ai ol:not(.list-none) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* Ordered lists keep source numbers (۱. ۲. ۳.) — no CSS auto-renumber padding */
|
||||
.bubble-ai ol.list-none {
|
||||
padding-inline-start: 0;
|
||||
|
||||
Reference in New Issue
Block a user