2025-05-12 15:57:59 -07:00
import { tracked } from "@glimmer/tracking";
2025-05-23 16:23:06 +10:00
import { cancel, later } from "@ember/runloop";
2025-05-21 15:10:50 -07:00
import loadJSDiff from "discourse/lib/load-js-diff";
2025-05-24 15:19:48 +10:00
import { escapeExpression } from "discourse/lib/utilities";
2025-05-12 15:57:59 -07:00
2025-05-24 15:19:48 +10:00
const DEFAULT_CHAR_TYPING_DELAY = 10;
2025-05-22 16:31:21 +10:00
const STREAMING_DIFF_TRUNCATE_THRESHOLD = 0.1;
const STREAMING_DIFF_TRUNCATE_BUFFER = 10;
2025-05-30 06:50:12 +10:00
const RUSH_MAX_TICKS = 10; // ≤ 10 visual diff refreshes
const RUSH_TICK_INTERVAL = 100; // 100 ms between them → ≤ 1 s total
2025-05-12 15:57:59 -07:00
export default class DiffStreamer {
@tracked isStreaming = false;
@tracked words = [];
@tracked lastResultText = "";
2025-05-22 12:37:42 -07:00
@tracked diff = this.selectedText;
2025-05-12 15:57:59 -07:00
@tracked suggestion = "";
2025-05-15 11:38:46 -07:00
@tracked isDone = false;
2025-05-30 06:50:12 +10:00
@tracked isThinking = true;
2025-05-21 15:10:50 -07:00
2025-05-12 15:57:59 -07:00
typingTimer = null;
currentWordIndex = 0;
2025-05-21 15:10:50 -07:00
currentCharIndex = 0;
jsDiff = null;
2025-05-12 15:57:59 -07:00
2025-05-30 06:50:12 +10:00
bufferedToken = null;
rushMode = false;
rushBatchSize = 1;
rushTicksLeft = 0;
receivedFinalUpdate = false;
/**
* Initializes the DiffStreamer with initial text and typing delay.
* @param {string} selectedText - The original text to diff against.
* @param {number} typingDelay - (Optional) character typing delay in ms.
*/
2025-05-12 15:57:59 -07:00
constructor(selectedText, typingDelay) {
this.selectedText = selectedText;
2025-05-21 15:10:50 -07:00
this.typingDelay = typingDelay || DEFAULT_CHAR_TYPING_DELAY;
this.loadJSDiff();
}
2025-05-30 06:50:12 +10:00
/**
* Loads the jsDiff library asynchronously.
*/
2025-05-21 15:10:50 -07:00
async loadJSDiff() {
this.jsDiff = await loadJSDiff();
2025-05-12 15:57:59 -07:00
}
2025-05-30 06:50:12 +10:00
/**
* Main entry point for streaming updates from the backend.
* Handles both incremental and final updates.
* @param {object} result - The result object containing the new text and status
* @param {string} newTextKey - The key in result that holds the new text value (e.g. if the JSON is { text: "Hello", done: false }, newTextKey would be "text")
*/
2025-05-12 15:57:59 -07:00
async updateResult(result, newTextKey) {
2025-05-30 06:50:12 +10:00
if (this.receivedFinalUpdate) {
return;
}
2025-05-21 15:10:50 -07:00
if (!this.jsDiff) {
await this.loadJSDiff();
}
2025-05-30 06:50:12 +10:00
this.isThinking = false;
2025-05-12 15:57:59 -07:00
const newText = result[newTextKey];
2025-05-30 06:50:12 +10:00
const gotDoneFlag = !!result?.done;
2025-05-21 15:10:50 -07:00
2025-05-30 06:50:12 +10:00
if (gotDoneFlag) {
this.receivedFinalUpdate = true;
2025-05-21 15:10:50 -07:00
if (this.typingTimer) {
2025-05-23 16:23:06 +10:00
cancel(this.typingTimer);
2025-05-21 15:10:50 -07:00
this.typingTimer = null;
}
2025-05-30 06:50:12 +10:00
// flush buffered token so everything is renderable
if (this.bufferedToken) {
this.words.push(this.bufferedToken);
this.bufferedToken = null;
}
// tokenise whatever tail we haven’ t processed yet
const tail = newText.slice(this.lastResultText.length);
if (tail.length) {
this.words.push(...this.#tokenize(tail));
}
const charsLeft = newText.length - this.suggestion.length;
if (charsLeft <= 0) {
this.suggestion = newText;
this.diff = this.#formatDiffWithTags(
this.jsDiff.diffWordsWithSpace(this.selectedText, newText),
false
);
this.isStreaming = false;
this.isDone = true;
return;
}
this.rushBatchSize = Math.ceil(charsLeft / RUSH_MAX_TICKS);
this.rushTicksLeft = RUSH_MAX_TICKS;
this.rushMode = true;
this.isStreaming = true;
this.lastResultText = newText;
this.#streamNextChar();
2025-05-21 15:10:50 -07:00
return;
}
2025-05-30 06:50:12 +10:00
const delta = newText.slice(this.lastResultText.length);
if (!delta) {
2025-05-21 15:10:50 -07:00
this.lastResultText = newText;
return;
}
2025-05-30 06:50:12 +10:00
// combine any previous buffered token with new delta and retokenize
const combined = (this.bufferedToken || "") + delta;
const tokens = this.#tokenize(combined);
this.bufferedToken = tokens.pop() || null;
if (tokens.length) {
this.words.push(...tokens);
2025-05-21 15:10:50 -07:00
}
2025-05-30 06:50:12 +10:00
this.isStreaming = true;
if (!this.typingTimer) {
this.#streamNextChar();
2025-05-12 15:57:59 -07:00
}
this.lastResultText = newText;
}
2025-05-30 06:50:12 +10:00
/**
* Resets the streamer's internal state to allow reuse.
*/
2025-05-12 15:57:59 -07:00
reset() {
2025-05-21 15:10:50 -07:00
this.diff = "";
2025-05-12 15:57:59 -07:00
this.suggestion = "";
this.lastResultText = "";
this.words = [];
this.currentWordIndex = 0;
2025-05-21 15:10:50 -07:00
this.currentCharIndex = 0;
2025-05-30 06:50:12 +10:00
this.bufferedToken = null;
2025-05-21 15:10:50 -07:00
this.isStreaming = false;
2025-05-27 18:12:02 +10:00
this.isDone = false;
2025-05-30 06:50:12 +10:00
this.receivedFinalUpdate = false;
this.isThinking = true;
this.rushMode = false;
this.rushBatchSize = 1;
this.rushTicksLeft = 0;
2025-05-21 15:10:50 -07:00
if (this.typingTimer) {
2025-05-23 16:23:06 +10:00
cancel(this.typingTimer);
2025-05-12 15:57:59 -07:00
this.typingTimer = null;
}
}
2025-05-30 06:50:12 +10:00
/**
* Computes a truncated diff during streaming to avoid excessive churn.
* @param {string} original - The original text.
* @param {string} suggestion - The partially streamed suggestion.
* @returns {Array} Array of diff parts with `.added`, `.removed`, and `.value`.
*/
2025-05-22 16:31:21 +10:00
streamingDiff(original, suggestion) {
2025-05-30 06:50:12 +10:00
const max = Math.floor(
2025-05-22 16:31:21 +10:00
suggestion.length +
suggestion.length * STREAMING_DIFF_TRUNCATE_THRESHOLD +
STREAMING_DIFF_TRUNCATE_BUFFER
);
2025-05-30 06:50:12 +10:00
const head = original.slice(0, max);
const tail = original.slice(max);
2025-05-22 16:31:21 +10:00
2025-05-30 06:50:12 +10:00
const output = this.jsDiff.diffWordsWithSpace(head, suggestion);
2025-05-22 16:31:21 +10:00
2025-05-30 06:50:12 +10:00
if (tail.length) {
let last = output.at(-1);
let secondLast = output.at(-2);
2025-05-22 16:31:21 +10:00
2025-05-30 06:50:12 +10:00
if (last.added && secondLast?.removed) {
output.splice(-2, 2, last, secondLast);
2025-05-22 16:31:21 +10:00
last = secondLast;
}
if (!last.removed) {
2025-05-30 06:50:12 +10:00
last = { added: false, removed: true, value: "" };
output.push(last);
2025-05-22 16:31:21 +10:00
}
2025-05-30 06:50:12 +10:00
last.value += tail;
2025-05-22 16:31:21 +10:00
}
2025-05-30 06:50:12 +10:00
return output;
2025-05-22 16:31:21 +10:00
}
2025-05-30 06:50:12 +10:00
/**
* Internal loop that emits the next character(s) to simulate typing.
* Works in both normal and rush mode.
*/
#streamNextChar() {
if (!this.isStreaming) {
2025-05-24 15:19:48 +10:00
return;
}
2025-05-30 06:50:12 +10:00
const limit = this.rushMode ? this.rushBatchSize : 1;
let emitted = 0;
while (emitted < limit && this.currentWordIndex < this.words.length) {
const token = this.words[this.currentWordIndex];
this.suggestion += token.charAt(this.currentCharIndex);
2025-05-21 15:10:50 -07:00
this.currentCharIndex++;
2025-05-30 06:50:12 +10:00
emitted++;
2025-05-21 15:10:50 -07:00
2025-05-30 06:50:12 +10:00
if (this.currentCharIndex >= token.length) {
2025-05-21 15:10:50 -07:00
this.currentWordIndex++;
this.currentCharIndex = 0;
}
2025-05-30 06:50:12 +10:00
}
2025-05-21 15:10:50 -07:00
2025-05-30 06:50:12 +10:00
let refresh = false;
if (this.rushMode) {
if (this.rushTicksLeft > 0) {
this.rushTicksLeft--;
refresh = true;
}
2025-05-21 15:10:50 -07:00
} else {
2025-05-30 06:50:12 +10:00
refresh = this.currentCharIndex === 0;
}
2025-05-21 15:10:50 -07:00
2025-05-30 06:50:12 +10:00
if (refresh || this.currentWordIndex >= this.words.length) {
const useStreaming =
this.currentWordIndex < this.words.length || this.rushMode;
this.diff = this.#formatDiffWithTags(
useStreaming
? this.streamingDiff(this.selectedText, this.suggestion)
: this.jsDiff.diffWordsWithSpace(this.selectedText, this.suggestion),
!this.rushMode
2025-05-21 15:10:50 -07:00
);
2025-05-30 06:50:12 +10:00
}
2025-05-21 15:10:50 -07:00
2025-05-30 06:50:12 +10:00
const doneStreaming = this.currentWordIndex >= this.words.length;
if (doneStreaming) {
2025-05-21 15:10:50 -07:00
this.isStreaming = false;
2025-05-30 06:50:12 +10:00
this.rushMode = false;
this.typingTimer = null;
2025-05-21 15:10:50 -07:00
2025-05-30 06:50:12 +10:00
if (this.receivedFinalUpdate) {
this.isDone = true;
2025-05-21 15:10:50 -07:00
}
2025-05-30 06:50:12 +10:00
} else {
const delay = this.rushMode ? RUSH_TICK_INTERVAL : this.typingDelay;
this.typingTimer = later(this, this.#streamNextChar, delay);
2025-05-21 15:10:50 -07:00
}
}
2025-05-30 06:50:12 +10:00
/**
* Splits a string into tokens, preserving whitespace as separate entries.
* @param {string} text - The input string.
* @returns {Array} Array of tokens.
*/
#tokenize(text) {
return text.split(/(?<=\S)(?=\s)/);
}
/**
* Wraps a chunk of text in appropriate HTML tags based on its diff type.
* @param {string} text - The text chunk.
* @param {string} type - The type: 'added', 'removed', or 'unchanged'.
* @returns {string} HTML string.
*/
2025-05-21 15:10:50 -07:00
#wrapChunk(text, type) {
if (type === "added") {
return `<ins>${text}</ins>`;
}
if (type === "removed") {
2025-05-30 06:50:12 +10:00
return /^\s+$/.test(text) ? "" : `<del>${text}</del>`;
2025-05-21 15:10:50 -07:00
}
return `<span>${text}</span>`;
}
2025-05-30 06:50:12 +10:00
/**
* Converts a diff array into a string of HTML with highlight markup.
* @param {Array} diffArray - The array from a diff function.
* @param {boolean} highlightLastWord - Whether to highlight the last non-removed word.
* @returns {string} HTML representation of the diff.
*/
2025-05-21 15:10:50 -07:00
#formatDiffWithTags(diffArray, highlightLastWord = true) {
2025-05-30 06:50:12 +10:00
const words = [];
diffArray.forEach((part) =>
(part.value.match(/\S+|\s+/g) || []).forEach((tok) =>
words.push({
text: tok,
2025-05-21 15:10:50 -07:00
type: part.added ? "added" : part.removed ? "removed" : "unchanged",
2025-05-30 06:50:12 +10:00
})
)
);
2025-05-21 15:10:50 -07:00
2025-05-30 06:50:12 +10:00
let lastIndex = -1;
2025-05-21 15:10:50 -07:00
if (highlightLastWord) {
2025-05-30 06:50:12 +10:00
for (let i = words.length - 1; i >= 0; i--) {
if (words[i].type !== "removed" && /\S/.test(words[i].text)) {
lastIndex = i;
2025-05-21 15:10:50 -07:00
break;
}
}
}
2025-05-30 06:50:12 +10:00
const output = [];
for (let i = 0; i <= lastIndex; i++) {
let { text, type } = words[i];
2025-05-24 15:19:48 +10:00
text = escapeExpression(text);
2025-05-21 15:10:50 -07:00
if (/^\s+$/.test(text)) {
output.push(text);
continue;
}
2025-05-30 06:50:12 +10:00
let chunk = this.#wrapChunk(text, type);
if (highlightLastWord && i === lastIndex) {
chunk = `<mark class="highlight">${chunk}</mark>`;
2025-05-21 15:10:50 -07:00
}
2025-05-30 06:50:12 +10:00
output.push(chunk);
2025-05-21 15:10:50 -07:00
}
2025-05-30 06:50:12 +10:00
for (let i = lastIndex + 1; i < words.length; ) {
const type = words[i].type;
let buf = "";
while (i < words.length && words[i].type === type) {
buf += words[i++].text;
2025-05-21 15:10:50 -07:00
}
2025-05-30 06:50:12 +10:00
output.push(this.#wrapChunk(escapeExpression(buf), type));
2025-05-21 15:10:50 -07:00
}
return output.join("");
2025-05-12 15:57:59 -07:00
}
}