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 ContentBlock =
|
||||||
| { type: 'p'; text: string }
|
| { type: 'p'; text: string }
|
||||||
| { type: 'heading'; 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[][] };
|
| { type: 'table'; headers: string[]; rows: string[][] };
|
||||||
|
|
||||||
const stripImageMarkdown = (text: string): string =>
|
const stripImageMarkdown = (text: string): string =>
|
||||||
@@ -132,16 +132,16 @@ const isTableLine = (line: string): boolean =>
|
|||||||
line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2;
|
line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2;
|
||||||
|
|
||||||
/** Western, Persian, or Arabic-Indic digits + . or ) */
|
/** 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 isOrderedItem = (line: string): boolean => ORDERED_ITEM_RE.test(line);
|
||||||
|
|
||||||
const isBulletItem = (line: string): boolean => /^\s*(?:[-•]|[*])\s+\S/.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);
|
const match = line.match(ORDERED_ITEM_RE);
|
||||||
if (!match) return null;
|
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 =>
|
const stripListMarker = (line: string): string =>
|
||||||
@@ -201,6 +201,7 @@ const parseBlocks = (text: string): ContentBlock[] => {
|
|||||||
const ordered = isOrderedItem(line);
|
const ordered = isOrderedItem(line);
|
||||||
const items: string[] = [];
|
const items: string[] = [];
|
||||||
const markers: string[] = [];
|
const markers: string[] = [];
|
||||||
|
const delimiters: string[] = [];
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const current = lines[i] ?? '';
|
const current = lines[i] ?? '';
|
||||||
if (!current.trim()) break;
|
if (!current.trim()) break;
|
||||||
@@ -210,6 +211,7 @@ const parseBlocks = (text: string): ContentBlock[] => {
|
|||||||
if (!parsed) break;
|
if (!parsed) break;
|
||||||
// Keep the model's own numbering (۱/۲/۳…) — do not renumber with CSS 1,2,3.
|
// Keep the model's own numbering (۱/۲/۳…) — do not renumber with CSS 1,2,3.
|
||||||
markers.push(parsed.marker);
|
markers.push(parsed.marker);
|
||||||
|
delimiters.push(parsed.delimiter);
|
||||||
items.push(parsed.text);
|
items.push(parsed.text);
|
||||||
} else {
|
} else {
|
||||||
items.push(stripListMarker(current));
|
items.push(stripListMarker(current));
|
||||||
@@ -217,7 +219,9 @@ const parseBlocks = (text: string): ContentBlock[] => {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
blocks.push(
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -351,8 +355,9 @@ export const MessageBubble = ({
|
|||||||
>
|
>
|
||||||
{block.ordered ? (
|
{block.ordered ? (
|
||||||
<>
|
<>
|
||||||
<span className="chat-num shrink-0 font-semibold" dir="ltr">
|
<span className="chat-num shrink-0 font-semibold select-none">
|
||||||
{block.markers?.[itemIndex] ?? String(itemIndex + 1)}.
|
<span dir="ltr">{block.markers?.[itemIndex] ?? String(itemIndex + 1)}</span>
|
||||||
|
{block.delimiters?.[itemIndex] ?? '.'}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
{renderInline(item, isActivelyTyping)}
|
{renderInline(item, isActivelyTyping)}
|
||||||
|
|||||||
@@ -8,23 +8,10 @@ type UseStreamTypewriterOptions = {
|
|||||||
onComplete?: () => void;
|
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.
|
* Direct, continuous message stream hook.
|
||||||
* Stable across parent re-renders (onComplete/onProgress via refs).
|
* Avoids slow character-by-character timer replay on complete responses
|
||||||
|
* while cleanly maintaining progress and completion callbacks.
|
||||||
*/
|
*/
|
||||||
export function useStreamTypewriter({
|
export function useStreamTypewriter({
|
||||||
text,
|
text,
|
||||||
@@ -32,13 +19,8 @@ export function useStreamTypewriter({
|
|||||||
onProgress,
|
onProgress,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: UseStreamTypewriterOptions) {
|
}: UseStreamTypewriterOptions) {
|
||||||
const [displayedText, setDisplayedText] = useState(() => (enabled ? '' : text));
|
const [displayedText, setDisplayedText] = useState(text);
|
||||||
const [isStreaming, setIsStreaming] = useState(Boolean(enabled && text));
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
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 onProgressRef = useRef(onProgress);
|
const onProgressRef = useRef(onProgress);
|
||||||
const onCompleteRef = useRef(onComplete);
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
|
||||||
@@ -51,79 +33,18 @@ export function useStreamTypewriter({
|
|||||||
}, [onComplete]);
|
}, [onComplete]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
setDisplayedText('');
|
setDisplayedText('');
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
indexRef.current = 0;
|
|
||||||
textRef.current = '';
|
|
||||||
enabledRef.current = enabled;
|
|
||||||
completedForTextRef.current = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!enabled) {
|
setDisplayedText(text);
|
||||||
setDisplayedText(text);
|
setIsStreaming(false);
|
||||||
setIsStreaming(false);
|
onProgressRef.current?.(text);
|
||||||
textRef.current = text;
|
onCompleteRef.current?.();
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [text, enabled]);
|
}, [text, enabled]);
|
||||||
|
|
||||||
return { displayedText, isStreaming };
|
return { displayedText, isStreaming };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
margin-inline-end: 0.15rem;
|
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 */
|
/* Ordered lists keep source numbers (۱. ۲. ۳.) — no CSS auto-renumber padding */
|
||||||
.bubble-ai ol.list-none {
|
.bubble-ai ol.list-none {
|
||||||
padding-inline-start: 0;
|
padding-inline-start: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user