diff --git a/assets/javascripts/discourse/components/modal/diff-modal.gjs b/assets/javascripts/discourse/components/modal/diff-modal.gjs
index 4e1b7cf0..9cbd3cae 100644
--- a/assets/javascripts/discourse/components/modal/diff-modal.gjs
+++ b/assets/javascripts/discourse/components/modal/diff-modal.gjs
@@ -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 {
{{#if this.loading}}
-
+ {{~@model.selectedText~}}
{{else}}
- {{#if @model.showResultAsDiff}}
- {{htmlSafe this.showcasedDiff}}
+ {{~#if @model.showResultAsDiff~}}
+ {{htmlSafe
+ this.diffStreamer.diff
+ }}
{{else}}
{{#if this.smoothStreamer.isStreaming}}
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 for inserted words, for removed/replaced words,
- * and 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 = `${oldWord}`;
- } else if (oldWord === newWord) {
- wordHTML = `${newWord}`;
- } else if (oldWord !== newWord) {
- wordHTML = `${oldWord ?? ""} ${newWord ?? ""}`;
- }
-
- if (i === newWords.length - 1 && opts.markLastWord) {
- wordHTML = `${wordHTML}`;
- }
-
- 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("
");
- }
-
- 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 `${text}`;
+ }
+ if (type === "removed") {
+ if (/^\s+$/.test(text)) {
+ return "";
+ }
+ return `${text}`;
+ }
+ return `${text}`;
+ }
+
+ #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 = `${content}`;
+ }
+
+ 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("");
}
}
diff --git a/assets/stylesheets/common/streaming.scss b/assets/stylesheets/common/streaming.scss
index b26c914e..8315eaa0 100644
--- a/assets/stylesheets/common/streaming.scss
+++ b/assets/stylesheets/common/streaming.scss
@@ -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;
+ }
}