Skip to content

Commit 307f182

Browse files
committed
DEV: Consolidate into a diff streamer
1 parent 38b9049 commit 307f182

File tree

4 files changed

+172
-131
lines changed

4 files changed

+172
-131
lines changed

assets/javascripts/discourse/components/ai-composer-helper-menu.gjs

+4
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,17 @@ export default class AiComposerHelperMenu extends Component {
123123
});
124124
}
125125

126+
const showResultAsDiff =
127+
option.prompt_type === "diff" && option.name !== "markdown_table";
128+
126129
return this.modal.show(ModalDiffModal, {
127130
model: {
128131
mode: option.id,
129132
selectedText: this.args.data.selectedText,
130133
revert: this.undoAiAction,
131134
toolbarEvent: this.args.data.toolbarEvent,
132135
customPromptValue: this.customPromptValue,
136+
showResultAsDiff,
133137
},
134138
});
135139
}

assets/javascripts/discourse/components/modal/diff-modal.gjs

+34-105
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,32 @@ import { ajax } from "discourse/lib/ajax";
1313
import { popupAjaxError } from "discourse/lib/ajax-error";
1414
import { bind } from "discourse/lib/decorators";
1515
import { i18n } from "discourse-i18n";
16+
import DiffStreamer from "../../lib/diff-streamer";
1617
import SmoothStreamer from "../../lib/smooth-streamer";
1718
import AiIndicatorWave from "../ai-indicator-wave";
18-
import { cancel, later } from "@ember/runloop";
19-
20-
const WORD_TYPING_DELAY = 200;
2119

2220
export default class ModalDiffModal extends Component {
2321
@service currentUser;
2422
@service messageBus;
2523

2624
@tracked loading = false;
27-
@tracked diff;
25+
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
2826
@tracked suggestion = "";
29-
@tracked isStreaming = false;
30-
@tracked lastResultText = "";
31-
// @tracked
32-
// smoothStreamer = new SmoothStreamer(
33-
// () => this.suggestion,
34-
// (newValue) => (this.suggestion = newValue)
35-
// );
36-
@tracked finalDiff = "";
37-
@tracked words = [];
38-
originalWords = [];
39-
typingTimer = null;
40-
currentWordIndex = 0;
27+
@tracked
28+
smoothStreamer = new SmoothStreamer(
29+
() => this.suggestion,
30+
(newValue) => (this.suggestion = newValue)
31+
);
4132

4233
constructor() {
4334
super(...arguments);
4435
this.suggestChanges();
4536
}
4637

38+
get isStreaming() {
39+
return this.diffStreamer.isStreaming || this.smoothStreamer.isStreaming;
40+
}
41+
4742
@bind
4843
subscribe() {
4944
const channel = "/discourse-ai/ai-helper/stream_composer_suggestion";
@@ -56,93 +51,21 @@ export default class ModalDiffModal extends Component {
5651
this.messageBus.subscribe(channel, this.updateResult);
5752
}
5853

59-
compareText(oldText = "", newText = "", opts = {}) {
60-
const oldWords = oldText.trim().split(/\s+/);
61-
const newWords = newText.trim().split(/\s+/);
62-
63-
const diff = [];
64-
let i = 0;
65-
66-
while (i < newWords.length) {
67-
const oldWord = oldWords[i];
68-
const newWord = newWords[i];
69-
70-
let wordHTML;
71-
if (oldWord === undefined) {
72-
wordHTML = `<ins>${newWord}</ins>`;
73-
} else if (oldWord !== newWord) {
74-
wordHTML = `<del>${oldWord}</del> <ins>${newWord}</ins>`;
75-
} else {
76-
wordHTML = newWord;
77-
}
78-
79-
if (i === newWords.length - 1 && opts.markLastWord) {
80-
wordHTML = `<mark class="highlight">${wordHTML}</mark>`;
81-
}
82-
83-
diff.push(wordHTML);
84-
i++;
85-
}
86-
87-
return diff.join(" ");
88-
}
89-
9054
@action
9155
async updateResult(result) {
9256
this.loading = false;
9357

94-
const newText = result.result;
95-
const diffText = newText.slice(this.lastResultText.length).trim();
96-
const newWords = diffText.split(/\s+/).filter(Boolean);
97-
98-
if (newWords.length > 0) {
99-
this.words.push(...newWords);
100-
if (!this.typingTimer) {
101-
this.streamNextWord();
102-
}
103-
}
104-
105-
if (result.done) {
106-
// this.finalDiff = result.diff;
107-
}
108-
109-
this.lastResultText = newText;
110-
this.isStreaming = !result.done;
111-
}
112-
113-
streamNextWord() {
114-
if (this.currentWordIndex === this.words.length) {
115-
this.diff = this.compareText(
116-
this.args.model.selectedText,
117-
this.suggestion,
118-
{ markLastWord: false }
119-
);
120-
}
121-
122-
if (this.currentWordIndex < this.words.length) {
123-
this.suggestion += this.words[this.currentWordIndex] + " ";
124-
this.diff = this.compareText(
125-
this.args.model.selectedText,
126-
this.suggestion,
127-
{ markLastWord: true }
128-
);
129-
130-
this.currentWordIndex++;
131-
this.typingTimer = later(this, this.streamNextWord, WORD_TYPING_DELAY);
58+
if (this.args.model.showResultAsDiff) {
59+
this.diffStreamer.updateResult(result, "result");
13260
} else {
133-
this.typingTimer = null;
61+
this.smoothStreamer.updateResult(result, "result");
13462
}
13563
}
13664

13765
@action
13866
async suggestChanges() {
139-
// this.smoothStreamer.resetStreaming();
140-
this.diff = null;
141-
this.suggestion = "";
142-
this.loading = true;
143-
this.lastResultText = "";
144-
this.words = [];
145-
this.currentWordIndex = 0;
67+
this.smoothStreamer.resetStreaming();
68+
this.diffStreamer.reset();
14669

14770
try {
14871
return await ajax("/discourse-ai/ai-helper/stream_suggestion", {
@@ -170,6 +93,13 @@ export default class ModalDiffModal extends Component {
17093
this.suggestion
17194
);
17295
}
96+
97+
if (this.args.model.showResultAsDiff && this.diffStreamer.suggestion) {
98+
this.args.model.toolbarEvent.replaceText(
99+
this.args.model.selectedText,
100+
this.diffStreamer.suggestion
101+
);
102+
}
173103
}
174104

175105
<template>
@@ -189,20 +119,18 @@ export default class ModalDiffModal extends Component {
189119
class={{concatClass
190120
"composer-ai-helper-modal__suggestion"
191121
"streamable-content"
122+
(if this.isStreaming "streaming")
123+
(if @model.showResultAsDiff "inline-diff")
192124
}}
193125
>
194-
{{!-- <CookText @rawText={{this.diff}} class="cooked" /> --}}
195-
{{htmlSafe this.diff}}
196-
{{!-- <div class="composer-ai-helper-modal__old-value">
197-
{{@model.selectedText}}
198-
{{!-- {{#if this.smoothStreamer.isStreaming}}
199-
<CookText
200-
@rawText={{this.smoothStreamer.renderedText}}
201-
class="cooked"
202-
/>
126+
{{#if @model.showResultAsDiff}}
127+
{{htmlSafe this.diffStreamer.diff}}
203128
{{else}}
204-
{{#if this.diff}}
205-
{{htmlSafe this.diff}}
129+
{{#if this.smoothStreamer.isStreaming}}
130+
<CookText
131+
@rawText={{this.smoothStreamer.renderedText}}
132+
class="cooked"
133+
/>
206134
{{else}}
207135
<div class="composer-ai-helper-modal__old-value">
208136
{{@model.selectedText}}
@@ -214,7 +142,7 @@ export default class ModalDiffModal extends Component {
214142
/>
215143
</div>
216144
{{/if}}
217-
{{/if}} --}}
145+
{{/if}}
218146
</div>
219147
{{/if}}
220148
</div>
@@ -232,6 +160,7 @@ export default class ModalDiffModal extends Component {
232160
{{else}}
233161
<DButton
234162
class="btn-primary confirm"
163+
@disabled={{this.isStreaming}}
235164
@action={{this.triggerConfirmChanges}}
236165
@label="discourse_ai.ai_helper.context_menu.confirm"
237166
/>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { tracked } from "@glimmer/tracking";
2+
import { later } from "@ember/runloop";
3+
4+
const DEFAULT_WORD_TYPING_DELAY = 200;
5+
6+
/**
7+
* DiffStreamer provides a word-by-word animation effect for streamed diff updates.
8+
*/
9+
export default class DiffStreamer {
10+
@tracked isStreaming = false;
11+
@tracked words = [];
12+
@tracked lastResultText = "";
13+
@tracked diff = "";
14+
@tracked suggestion = "";
15+
typingTimer = null;
16+
currentWordIndex = 0;
17+
18+
/**
19+
* @param {string} selectedText - The original text to compare against.
20+
* @param {number} [typingDelay] - Delay in milliseconds between each word (ommitting this will use default delay).
21+
*/
22+
constructor(selectedText, typingDelay) {
23+
this.selectedText = selectedText;
24+
this.typingDelay = typingDelay || DEFAULT_WORD_TYPING_DELAY;
25+
}
26+
27+
/**
28+
* Updates the result with a newly streamed text chunk, computes new words,
29+
* and begins or continues streaming animation.
30+
*
31+
* @param {object} result - Object containing the updated text under the given key.
32+
* @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")
33+
*/
34+
async updateResult(result, newTextKey) {
35+
const newText = result[newTextKey];
36+
const diffText = newText.slice(this.lastResultText.length).trim();
37+
const newWords = diffText.split(/\s+/).filter(Boolean);
38+
39+
if (newWords.length > 0) {
40+
this.isStreaming = true;
41+
this.words.push(...newWords);
42+
if (!this.typingTimer) {
43+
this.#streamNextWord();
44+
}
45+
}
46+
47+
this.lastResultText = newText;
48+
}
49+
50+
/**
51+
* Resets the streamer to its initial state.
52+
*/
53+
reset() {
54+
this.diff = null;
55+
this.suggestion = "";
56+
this.lastResultText = "";
57+
this.words = [];
58+
this.currentWordIndex = 0;
59+
}
60+
61+
/**
62+
* Internal method to animate the next word in the queue and update the diff.
63+
*
64+
* Highlights the current word if streaming is ongoing.
65+
*/
66+
#streamNextWord() {
67+
if (this.currentWordIndex === this.words.length) {
68+
this.diff = this.#compareText(this.selectedText, this.suggestion, {
69+
markLastWord: false,
70+
});
71+
this.isStreaming = false;
72+
}
73+
74+
if (this.currentWordIndex < this.words.length) {
75+
this.suggestion += this.words[this.currentWordIndex] + " ";
76+
this.diff = this.#compareText(this.selectedText, this.suggestion, {
77+
markLastWord: true,
78+
});
79+
80+
this.currentWordIndex++;
81+
this.typingTimer = later(this, this.#streamNextWord, this.typingDelay);
82+
} else {
83+
this.typingTimer = null;
84+
}
85+
}
86+
87+
/**
88+
* Computes a simple word-level diff between the original and new text.
89+
* Inserts <ins> for inserted words, <del> for removed/replaced words,
90+
* and <mark> for the currently streaming word.
91+
*
92+
* @param {string} [oldText=""] - Original text.
93+
* @param {string} [newText=""] - Updated suggestion text.
94+
* @param {object} opts - Options for diff display.
95+
* @param {boolean} opts.markLastWord - Whether to highlight the last word.
96+
* @returns {string} - HTML string with diff markup.
97+
*/
98+
#compareText(oldText = "", newText = "", opts = {}) {
99+
const oldWords = oldText.trim().split(/\s+/);
100+
const newWords = newText.trim().split(/\s+/);
101+
102+
const diff = [];
103+
let i = 0;
104+
105+
while (i < newWords.length) {
106+
const oldWord = oldWords[i];
107+
const newWord = newWords[i];
108+
109+
let wordHTML;
110+
if (oldWord === undefined) {
111+
wordHTML = `<ins>${newWord}</ins>`;
112+
} else if (oldWord !== newWord) {
113+
wordHTML = `<del>${oldWord}</del> <ins>${newWord}</ins>`;
114+
} else {
115+
wordHTML = newWord;
116+
}
117+
118+
if (i === newWords.length - 1 && opts.markLastWord) {
119+
wordHTML = `<mark class="highlight">${wordHTML}</mark>`;
120+
}
121+
122+
diff.push(wordHTML);
123+
i++;
124+
}
125+
126+
return diff.join(" ");
127+
}
128+
}

0 commit comments

Comments
 (0)