FEATURE: Add diff streaming animation (#1355)

Previously we attempted to add a diff streaming animation to the AI composer helper: https://github.com/discourse/discourse-ai/pull/1332, but it resulted in issues despite attempted fixes (https://github.com/discourse/discourse-ai/pull/1338) so we temporarily suppressed the diff animation (https://github.com/discourse/discourse-ai/pull/1341).

This update makes a second attempt at implementing the diff streaming animation. Instead of creating a custom diff algorithm, we make use of a third-party library [`jsDiff`](https://github.com/kpdecker/jsdiff) (which we added to core here: https://github.com/discourse/discourse/pull/32833). While streaming, the diff animation often struggles with markdown links and images, so we make use of `markdown-it` parser to detect those cases and prevent breaking the animation.

---------

Co-authored-by: Sam Saffron <sam.saffron@gmail.com>
This commit is contained in:
Keegan George 2025-05-21 15:10:50 -07:00 committed by GitHub
parent d72ad84f8f
commit c29183fc2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 269 additions and 128 deletions

View File

@ -23,7 +23,6 @@ export default class ModalDiffModal extends Component {
@tracked loading = false;
@tracked finalResult = "";
@tracked showcasedDiff = "";
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
@tracked suggestion = "";
@tracked
@ -31,13 +30,16 @@ export default class ModalDiffModal extends Component {
() => this.suggestion,
(newValue) => (this.suggestion = newValue)
);
@tracked isStreaming = false;
constructor() {
super(...arguments);
this.suggestChanges();
}
get isStreaming() {
return this.diffStreamer.isStreaming || this.smoothStreamer.isStreaming;
}
get primaryBtnLabel() {
return this.loading
? i18n("discourse_ai.ai_helper.context_menu.loading")
@ -62,23 +64,14 @@ export default class ModalDiffModal extends Component {
@action
async updateResult(result) {
// TODO(@keegan)
// Temporarily we are removing the animation using the diff streamer
// and simply showing the diff streamed without a proper animation
// while we figure things out
// so that things are not too janky in the meantime.
this.loading = false;
this.isStreaming = true;
if (result.done) {
this.finalResult = result.result;
}
this.showcasedDiff = result.diff;
if (result.done) {
this.loading = false;
this.isStreaming = false;
}
if (this.args.model.showResultAsDiff) {
@ -143,7 +136,7 @@ export default class ModalDiffModal extends Component {
<div {{didInsert this.subscribe}} {{willDestroy this.unsubscribe}}>
{{#if this.loading}}
<div class="composer-ai-helper-modal__loading">
<CookText @rawText={{@model.selectedText}} />
{{~@model.selectedText~}}
</div>
{{else}}
<div
@ -152,10 +145,13 @@ export default class ModalDiffModal extends Component {
"streamable-content"
(if this.isStreaming "streaming")
(if @model.showResultAsDiff "inline-diff")
(if this.diffStreamer.isThinking "thinking")
}}
>
{{#if @model.showResultAsDiff}}
{{htmlSafe this.showcasedDiff}}
{{~#if @model.showResultAsDiff~}}
<span class="diff-inner">{{htmlSafe
this.diffStreamer.diff
}}</span>
{{else}}
{{#if this.smoothStreamer.isStreaming}}
<CookText

View File

@ -1,11 +1,10 @@
import { tracked } from "@glimmer/tracking";
import { later } from "@ember/runloop";
import loadJSDiff from "discourse/lib/load-js-diff";
import { parseAsync } from "discourse/lib/text";
const DEFAULT_WORD_TYPING_DELAY = 200;
const DEFAULT_CHAR_TYPING_DELAY = 30;
/**
* DiffStreamer provides a word-by-word animation effect for streamed diff updates.
*/
export default class DiffStreamer {
@tracked isStreaming = false;
@tracked words = [];
@ -14,146 +13,258 @@ export default class DiffStreamer {
@tracked suggestion = "";
@tracked isDone = false;
@tracked isThinking = false;
typingTimer = null;
currentWordIndex = 0;
currentCharIndex = 0;
jsDiff = null;
/**
* @param {string} selectedText - The original text to compare against.
* @param {number} [typingDelay] - Delay in milliseconds between each word (ommitting this will use default delay).
*/
constructor(selectedText, typingDelay) {
this.selectedText = selectedText;
this.typingDelay = typingDelay || DEFAULT_WORD_TYPING_DELAY;
this.typingDelay = typingDelay || DEFAULT_CHAR_TYPING_DELAY;
this.loadJSDiff();
}
async loadJSDiff() {
this.jsDiff = await loadJSDiff();
}
/**
* Updates the result with a newly streamed text chunk, computes new words,
* and begins or continues streaming animation.
*
* @param {object} result - Object containing the updated text under the given key.
* @param {string} newTextKey - The key where the updated suggestion text is found (e.g. if the JSON is { text: "Hello", done: false }, newTextKey would be "text")
*/
async updateResult(result, newTextKey) {
if (!this.jsDiff) {
await this.loadJSDiff();
}
const newText = result[newTextKey];
const diffText = newText.slice(this.lastResultText.length).trim();
const newWords = diffText.split(/\s+/).filter(Boolean);
this.isDone = result?.done;
this.isDone = !!result?.done;
if (this.isDone) {
this.isStreaming = false;
this.suggestion = newText;
this.words = [];
if (this.typingTimer) {
clearTimeout(this.typingTimer);
this.typingTimer = null;
}
const originalDiff = this.jsDiff.diffWordsWithSpace(
this.selectedText,
this.suggestion
);
this.diff = this.#formatDiffWithTags(originalDiff, false);
return;
}
if (newText.length < this.lastResultText.length) {
this.isThinking = false;
// reset if text got shorter (e.g., reset or new input)
this.words = [];
this.suggestion = "";
this.currentWordIndex = 0;
this.currentCharIndex = 0;
}
const diffText = newText.slice(this.lastResultText.length);
if (!diffText.trim()) {
this.lastResultText = newText;
return;
}
if (await this.#isIncompleteMarkdown(diffText)) {
this.isThinking = true;
return;
}
const newWords = this.#tokenizeMarkdownAware(diffText);
if (newWords.length > 0) {
this.isStreaming = true;
this.words.push(...newWords);
if (!this.typingTimer) {
this.#streamNextWord();
this.#streamNextChar();
}
}
this.lastResultText = newText;
}
/**
* Resets the streamer to its initial state.
*/
reset() {
this.diff = null;
this.diff = "";
this.suggestion = "";
this.lastResultText = "";
this.words = [];
this.currentWordIndex = 0;
}
/**
* Internal method to animate the next word in the queue and update the diff.
*
* Highlights the current word if streaming is ongoing.
*/
#streamNextWord() {
if (this.currentWordIndex === this.words.length && !this.isDone) {
this.isThinking = true;
}
if (this.currentWordIndex === this.words.length && this.isDone) {
this.isThinking = false;
this.diff = this.#compareText(this.selectedText, this.suggestion, {
markLastWord: false,
});
this.isStreaming = false;
}
if (this.currentWordIndex < this.words.length) {
this.isThinking = false;
this.suggestion += this.words[this.currentWordIndex] + " ";
this.diff = this.#compareText(this.selectedText, this.suggestion, {
markLastWord: true,
});
this.currentWordIndex++;
this.typingTimer = later(this, this.#streamNextWord, this.typingDelay);
} else {
this.currentCharIndex = 0;
this.isStreaming = false;
if (this.typingTimer) {
clearTimeout(this.typingTimer);
this.typingTimer = null;
}
}
/**
* Computes a simple word-level diff between the original and new text.
* Inserts <ins> for inserted words, <del> for removed/replaced words,
* and <mark> for the currently streaming word.
*
* @param {string} [oldText=""] - Original text.
* @param {string} [newText=""] - Updated suggestion text.
* @param {object} opts - Options for diff display.
* @param {boolean} opts.markLastWord - Whether to highlight the last word.
* @returns {string} - HTML string with diff markup.
*/
#compareText(oldText = "", newText = "", opts = {}) {
const oldWords = oldText.trim().split(/\s+/);
const newWords = newText.trim().split(/\s+/);
async #isIncompleteMarkdown(text) {
const tokens = await parseAsync(text);
// Track where the line breaks are in the original oldText
const lineBreakMap = (() => {
const lines = oldText.trim().split("\n");
const map = new Set();
let wordIndex = 0;
const hasImage = tokens.some((t) => t.type === "image");
const hasLink = tokens.some((t) => t.type === "link_open");
for (const line of lines) {
const wordsInLine = line.trim().split(/\s+/);
wordIndex += wordsInLine.length;
map.add(wordIndex - 1); // Mark the last word in each line
}
return map;
})();
const diff = [];
let i = 0;
while (i < oldWords.length || i < newWords.length) {
const oldWord = oldWords[i];
const newWord = newWords[i];
let wordHTML = "";
if (newWord === undefined) {
wordHTML = `<span class="ghost">${oldWord}</span>`;
} else if (oldWord === newWord) {
wordHTML = `<span class="same-word">${newWord}</span>`;
} else if (oldWord !== newWord) {
wordHTML = `<del>${oldWord ?? ""}</del> <ins>${newWord ?? ""}</ins>`;
}
if (i === newWords.length - 1 && opts.markLastWord) {
wordHTML = `<mark class="highlight">${wordHTML}</mark>`;
}
diff.push(wordHTML);
// Add a line break after this word if it ended a line in the original text
if (lineBreakMap.has(i)) {
diff.push("<br>");
}
i++;
if (hasImage || hasLink) {
return false;
}
return diff.join(" ");
const maybeUnfinishedImage =
/!\[[^\]]*$/.test(text) || /!\[[^\]]*]\(upload:\/\/[^\s)]+$/.test(text);
const maybeUnfinishedLink =
/\[[^\]]*$/.test(text) || /\[[^\]]*]\([^\s)]+$/.test(text);
return maybeUnfinishedImage || maybeUnfinishedLink;
}
async #streamNextChar() {
if (this.currentWordIndex < this.words.length) {
const currentToken = this.words[this.currentWordIndex];
const nextChar = currentToken.charAt(this.currentCharIndex);
this.suggestion += nextChar;
this.currentCharIndex++;
if (this.currentCharIndex >= currentToken.length) {
this.currentWordIndex++;
this.currentCharIndex = 0;
const originalDiff = this.jsDiff.diffWordsWithSpace(
this.selectedText,
this.suggestion
);
this.diff = this.#formatDiffWithTags(originalDiff);
if (this.currentWordIndex === 1) {
this.diff = this.diff.replace(/^\s+/, "");
}
}
this.typingTimer = later(this, this.#streamNextChar, this.typingDelay);
} else {
if (!this.suggestion || !this.selectedText || !this.jsDiff) {
return;
}
const originalDiff = this.jsDiff.diffWordsWithSpace(
this.selectedText,
this.suggestion
);
this.typingTimer = null;
this.diff = this.#formatDiffWithTags(originalDiff, false);
this.isStreaming = false;
}
}
#tokenizeMarkdownAware(text) {
const tokens = [];
let lastIndex = 0;
const regex = /!\[[^\]]*]\(upload:\/\/[^\s)]+\)/g;
let match;
while ((match = regex.exec(text)) !== null) {
const matchStart = match.index;
if (lastIndex < matchStart) {
const before = text.slice(lastIndex, matchStart);
tokens.push(...(before.match(/\S+\s*|\s+/g) || []));
}
tokens.push(match[0]);
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
const rest = text.slice(lastIndex);
tokens.push(...(rest.match(/\S+\s*|\s+/g) || []));
}
return tokens;
}
#wrapChunk(text, type) {
if (type === "added") {
return `<ins>${text}</ins>`;
}
if (type === "removed") {
if (/^\s+$/.test(text)) {
return "";
}
return `<del>${text}</del>`;
}
return `<span>${text}</span>`;
}
#formatDiffWithTags(diffArray, highlightLastWord = true) {
const wordsWithType = [];
diffArray.forEach((part) => {
const tokens = part.value.match(/\S+|\s+/g) || [];
tokens.forEach((token) => {
wordsWithType.push({
text: token,
type: part.added ? "added" : part.removed ? "removed" : "unchanged",
});
});
});
let lastWordIndex = -1;
if (highlightLastWord) {
for (let i = wordsWithType.length - 1; i >= 0; i--) {
if (
wordsWithType[i].type !== "removed" &&
/\S/.test(wordsWithType[i].text)
) {
lastWordIndex = i;
break;
}
}
}
const output = [];
for (let i = 0; i <= lastWordIndex; i++) {
const { text, type } = wordsWithType[i];
if (/^\s+$/.test(text)) {
output.push(text);
continue;
}
let content = this.#wrapChunk(text, type);
if (highlightLastWord && i === lastWordIndex) {
content = `<mark class="highlight">${content}</mark>`;
}
output.push(content);
}
if (lastWordIndex < wordsWithType.length - 1) {
let i = lastWordIndex + 1;
while (i < wordsWithType.length) {
let chunkType = wordsWithType[i].type;
let chunkText = "";
while (
i < wordsWithType.length &&
wordsWithType[i].type === chunkType
) {
chunkText += wordsWithType[i].text;
i++;
}
output.push(this.#wrapChunk(chunkText, chunkType));
}
}
return output.join("");
}
}

View File

@ -83,14 +83,48 @@ article.streaming .cooked {
@keyframes mark-blink {
0%,
100% {
border-color: var(--highlight-high);
border-color: transparent;
}
50% {
border-color: transparent;
border-color: var(--highlight-high);
}
}
@keyframes fade-in-highlight {
from {
opacity: 0.5;
}
to {
opacity: 1;
}
}
mark.highlight {
background-color: var(--highlight-high);
animation: fade-in-highlight 0.5s ease-in-out forwards;
}
.composer-ai-helper-modal__suggestion.thinking mark.highlight {
animation: mark-blink 1s step-start 0s infinite;
animation-name: mark-blink;
}
.composer-ai-helper-modal__loading {
white-space: pre-wrap;
}
.composer-ai-helper-modal__suggestion.inline-diff {
white-space: pre-wrap;
del:last-child {
text-decoration: none;
background-color: transparent;
color: var(--primary-low-mid);
}
.diff-inner {
display: inline;
}
}