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; + } }