@plteam/chat-ui
Version:
CUI Kit is a free and open-source library for creating AI assistant chat interfaces, built with React, Material UI, and TypeScript
79 lines (78 loc) • 4.34 kB
JavaScript
import * as React from 'react';
import { ChatViewConstants } from '../../../ChatViewConstants';
import { chatClassNames } from '../../../core/chatClassNames';
class SmoothManager {
running = false;
// Set when check() is called while a run is in flight (e.g. the action buttons just
// mounted). Ensures the request is honored after the current run instead of being
// silently dropped.
rerunRequested = false;
animatedElements = new WeakSet();
check = async (typingSpeed, staggerStep) => {
if (this.running) {
this.rerunRequested = true;
return;
}
this.running = true;
const allMarkdownElements = document.getElementsByClassName(chatClassNames.messageAssistantRoot);
const parent = allMarkdownElements.item(allMarkdownElements.length - 1);
const pending = Array.from(parent?.querySelectorAll(`.${chatClassNames.markdownSmoothedPending}`) ?? []);
const batch = pending.filter(el => !this.animatedElements.has(el));
const batchSet = new Set(batch);
const toAnimate = [];
const toSkip = [];
batch.forEach(el => {
const isListEl = ['li', 'ol', 'ul'].some(tag => tag === el.tagName.toLowerCase());
const hasChildInBatch = !isListEl && Array.from(el.childNodes)
.some(child => batchSet.has(child));
(hasChildInBatch ? toSkip : toAnimate).push(el);
});
// Mark the whole batch immediately to prevent reprocessing in the recursive call.
batch.forEach(el => this.animatedElements.add(el));
// Parent containers that have children animating: make visible without a separate animation.
toSkip.forEach(el => {
el.classList.remove(chatClassNames.markdownSmoothedPending);
el.style.opacity = '1';
});
// New leaf/block elements: fade in sequentially. `querySelectorAll` returns document
// order, so the stagger always runs top-to-bottom. The cumulative delay is capped so a
// big batch starts almost simultaneously instead of crawling in a long wave.
toAnimate.forEach((el, i) => {
el.classList.remove(chatClassNames.markdownSmoothedPending);
el.classList.add(chatClassNames.markdownSmoothedAnimating);
el.style.animationDelay = `${Math.min(i * staggerStep, ChatViewConstants.TEXT_SMOOTH_STAGGER_MAX_MS)}ms`;
});
if (toAnimate.length > 0) {
// Wait until the last (capped) delayed animation has finished before cleanup.
const maxDelay = Math.min((toAnimate.length - 1) * staggerStep, ChatViewConstants.TEXT_SMOOTH_STAGGER_MAX_MS);
const totalMs = maxDelay + typingSpeed;
await new Promise((resolve) => setTimeout(resolve, totalMs));
toAnimate.forEach(el => {
el.classList.remove(chatClassNames.markdownSmoothedAnimating);
el.style.animationDelay = '0s';
// Override the pending class's opacity:0 so the element stays visible
// when React re-applies the pending class on the next render cycle.
el.style.opacity = '1';
});
}
this.running = false;
// Re-scan if new pending elements appeared during this run, or another check() was
// requested while we were busy (e.g. the action buttons mounted after the last chunk).
if (batch.length > 0 || this.rerunRequested) {
this.rerunRequested = false;
this.check(typingSpeed, staggerStep);
}
};
}
const smoothManager = new SmoothManager();
// Imperative entry point for elements that mount outside the streaming text flow (the
// message action buttons + footer), which otherwise never trigger a check().
export const triggerSmoothCheck = (typingSpeed, staggerStep) => {
smoothManager.check(typingSpeed || ChatViewConstants.TEXT_SMOOTH_ANIMATION_DURATION_MS, staggerStep ?? ChatViewConstants.TEXT_SMOOTH_STAGGER_STEP_MS);
};
export const useSmoothManager = (text, inProgress, typingSpeed, staggerStep) => {
React.useEffect(() => {
if (inProgress)
triggerSmoothCheck(typingSpeed, staggerStep);
}, [text, inProgress]);
};