mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-24 16:42:15 +00:00
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:
parent
d72ad84f8f
commit
c29183fc2d
@ -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
|
||||
|
@ -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("");
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user