Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit eae7716

Browse files
authored
DEV: Improve ai-streamer API (#851)
In preparation for applying the streaming animation elsewhere, we want to better improve the organization of folder structure and methods used in the `ai-streamer`
1 parent b604ff9 commit eae7716

File tree

8 files changed

+222
-163
lines changed

8 files changed

+222
-163
lines changed

assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-box.gjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import I18n from "discourse-i18n";
1818
import DMenu from "float-kit/components/d-menu";
1919
import DTooltip from "float-kit/components/d-tooltip";
2020
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
21-
import { streamSummaryText } from "../../lib/ai-streamer";
21+
import streamUpdaterText from "../../lib/ai-streamer/progress-handlers";
22+
import SummaryUpdater from "../../lib/ai-streamer/updaters/summary-updater";
2223

2324
export default class AiSummaryBox extends Component {
2425
@service siteSettings;
@@ -34,7 +35,7 @@ export default class AiSummaryBox extends Component {
3435
@tracked canRegenerate = false;
3536
@tracked loading = false;
3637
@tracked isStreaming = false;
37-
oldRaw = null; // used for comparison in SummaryUpdater in lib/ai-streamer
38+
oldRaw = null; // used for comparison in SummaryUpdater in lib/ai-streamer/updaters
3839
finalSummary = null;
3940

4041
get outdatedSummaryWarningText() {
@@ -150,7 +151,7 @@ export default class AiSummaryBox extends Component {
150151
this.loading = false;
151152

152153
this.isStreaming = true;
153-
streamSummaryText(topicSummary, this);
154+
streamUpdaterText(SummaryUpdater, topicSummary, this);
154155

155156
if (update.done) {
156157
this.isStreaming = false;
@@ -219,6 +220,7 @@ export default class AiSummaryBox extends Component {
219220
<article
220221
class={{concatClass
221222
"ai-summary-box"
223+
"streamable-content"
222224
(if this.isStreaming "streaming")
223225
}}
224226
>
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { later } from "@ember/runloop";
2-
import loadMorphlex from "discourse/lib/load-morphlex";
3-
import { cook } from "discourse/lib/text";
2+
import PostUpdater from "./updaters/post-updater";
43

54
const PROGRESS_INTERVAL = 40;
65
const GIVE_UP_INTERVAL = 60000;
@@ -9,6 +8,13 @@ const MAX_FLUSH_TIME = 800;
98

109
let progressTimer = null;
1110

11+
/**
12+
* Finds the last non-empty child element or text node of a given DOM element.
13+
* Iterates backward through the element's child nodes and skips over empty text nodes.
14+
*
15+
* @param {HTMLElement} element - The DOM element to inspect.
16+
* @returns {Node} - The last non-empty child node or null if none found.
17+
*/
1218
function lastNonEmptyChild(element) {
1319
let lastChild = element.lastChild;
1420
while (
@@ -21,6 +27,13 @@ function lastNonEmptyChild(element) {
2127
return lastChild;
2228
}
2329

30+
/**
31+
* Adds a progress dot (a span element with a "progress-dot" class) at the end of the
32+
* last non-empty block within a given DOM element. This is used to visually indicate
33+
* progress while content is being streamed.
34+
*
35+
* @param {HTMLElement} element - The DOM element to which the progress dot will be added.
36+
*/
2437
export function addProgressDot(element) {
2538
let lastBlock = element;
2639

@@ -42,143 +55,14 @@ export function addProgressDot(element) {
4255
lastBlock.appendChild(dotElement);
4356
}
4457

45-
// this is the interface we need to implement
46-
// for a streaming updater
47-
class StreamUpdater {
48-
set streaming(value) {
49-
throw "not implemented";
50-
}
51-
52-
async setCooked() {
53-
throw "not implemented";
54-
}
55-
56-
async setRaw() {
57-
throw "not implemented";
58-
}
59-
60-
get element() {
61-
throw "not implemented";
62-
}
63-
64-
get raw() {
65-
throw "not implemented";
66-
}
67-
}
68-
69-
class PostUpdater extends StreamUpdater {
70-
morphingOptions = {
71-
beforeAttributeUpdated: (element, attributeName) => {
72-
return !(element.tagName === "DETAILS" && attributeName === "open");
73-
},
74-
};
75-
76-
constructor(postStream, postId) {
77-
super();
78-
this.postStream = postStream;
79-
this.postId = postId;
80-
this.post = postStream.findLoadedPost(postId);
81-
82-
if (this.post) {
83-
this.postElement = document.querySelector(
84-
`#post_${this.post.post_number}`
85-
);
86-
}
87-
}
88-
89-
get element() {
90-
return this.postElement;
91-
}
92-
93-
set streaming(value) {
94-
if (this.postElement) {
95-
if (value) {
96-
this.postElement.classList.add("streaming");
97-
} else {
98-
this.postElement.classList.remove("streaming");
99-
}
100-
}
101-
}
102-
103-
async setRaw(value, done) {
104-
this.post.set("raw", value);
105-
const cooked = await cook(value);
106-
107-
// resets animation
108-
this.element.classList.remove("streaming");
109-
void this.element.offsetWidth;
110-
this.element.classList.add("streaming");
111-
112-
const cookedElement = document.createElement("div");
113-
cookedElement.innerHTML = cooked;
114-
115-
if (!done) {
116-
addProgressDot(cookedElement);
117-
}
118-
119-
await this.setCooked(cookedElement.innerHTML);
120-
}
121-
122-
async setCooked(value) {
123-
this.post.set("cooked", value);
124-
125-
(await loadMorphlex()).morphInner(
126-
this.postElement.querySelector(".cooked"),
127-
`<div>${value}</div>`,
128-
this.morphingOptions
129-
);
130-
}
131-
132-
get raw() {
133-
return this.post.get("raw") || "";
134-
}
135-
}
136-
137-
export class SummaryUpdater extends StreamUpdater {
138-
constructor(topicSummary, componentContext) {
139-
super();
140-
this.topicSummary = topicSummary;
141-
this.componentContext = componentContext;
142-
143-
if (this.topicSummary) {
144-
this.summaryBox = document.querySelector("article.ai-summary-box");
145-
}
146-
}
147-
148-
get element() {
149-
return this.summaryBox;
150-
}
151-
152-
set streaming(value) {
153-
if (this.element) {
154-
if (value) {
155-
this.componentContext.isStreaming = true;
156-
} else {
157-
this.componentContext.isStreaming = false;
158-
}
159-
}
160-
}
161-
162-
async setRaw(value, done) {
163-
this.componentContext.oldRaw = value;
164-
const cooked = await cook(value);
165-
166-
await this.setCooked(cooked);
167-
168-
if (done) {
169-
this.componentContext.finalSummary = cooked;
170-
}
171-
}
172-
173-
async setCooked(value) {
174-
this.componentContext.text = value;
175-
}
176-
177-
get raw() {
178-
return this.componentContext.oldRaw || "";
179-
}
180-
}
181-
58+
/**
59+
* Applies progress to a streaming operation, updating the raw and cooked text,
60+
* handling progress dots, and stopping streaming when complete.
61+
*
62+
* @param {Object} status - The current streaming status object.
63+
* @param {Object} updater - An instance of a stream updater (e.g., PostUpdater or SummaryUpdater).
64+
* @returns {Promise<boolean>} - Resolves to true if streaming is complete, otherwise false.
65+
*/
18266
export async function applyProgress(status, updater) {
18367
status.startTime = status.startTime || Date.now();
18468

@@ -235,6 +119,14 @@ export async function applyProgress(status, updater) {
235119
return status.done;
236120
}
237121

122+
/**
123+
* Handles progress updates for a post stream by applying the streaming status of
124+
* each post and updating its content accordingly. This function ensures that progress
125+
* is tracked and handled for multiple posts simultaneously.
126+
*
127+
* @param {Object} postStream - The post stream object containing the posts to be updated.
128+
* @returns {Promise<boolean>} - Resolves to true if polling should continue, otherwise false.
129+
*/
238130
async function handleProgress(postStream) {
239131
const status = postStream.aiStreamingStatus;
240132

@@ -257,22 +149,12 @@ async function handleProgress(postStream) {
257149
return keepPolling;
258150
}
259151

260-
export function streamSummaryText(topicSummary, context) {
261-
const summaryUpdater = new SummaryUpdater(topicSummary, context);
262-
263-
if (!progressTimer) {
264-
progressTimer = later(async () => {
265-
await applyProgress(topicSummary, summaryUpdater);
266-
267-
progressTimer = null;
268-
269-
if (!topicSummary.done) {
270-
await applyProgress(topicSummary, summaryUpdater);
271-
}
272-
}, PROGRESS_INTERVAL);
273-
}
274-
}
275-
152+
/**
153+
* Ensures that progress for a post stream is being updated. It starts a progress timer
154+
* if one is not already active, and continues polling for progress updates at regular intervals.
155+
*
156+
* @param {Object} postStream - The post stream object containing the posts to be updated.
157+
*/
276158
function ensureProgress(postStream) {
277159
if (!progressTimer) {
278160
progressTimer = later(async () => {
@@ -287,7 +169,14 @@ function ensureProgress(postStream) {
287169
}
288170
}
289171

290-
export default function streamText(postStream, data) {
172+
/**
173+
* Streams the raw text for a post by tracking its status and applying progress updates.
174+
* If streaming is already in progress, this function ensures it continues to update the content.
175+
*
176+
* @param {Object} postStream - The post stream object containing the post to be updated.
177+
* @param {Object} data - The data object containing raw and cooked content of the post.
178+
*/
179+
export function streamPostText(postStream, data) {
291180
if (data.noop) {
292181
return;
293182
}
@@ -297,3 +186,28 @@ export default function streamText(postStream, data) {
297186
status[data.post_id] = data;
298187
ensureProgress(postStream);
299188
}
189+
190+
/**
191+
* A generalized function to handle streaming of content using any specified updater class.
192+
* It applies progress updates to the content (raw and cooked) based on the given data.
193+
* Use this function to stream content for Glimmer components.
194+
*
195+
* @param {Function} updaterClass - The updater class to be used for streaming (e.g., PostUpdater, SummaryUpdater).
196+
* @param {Object} data - The data object containing the content to be streamed.
197+
* @param {Object} context - Additional context required for the updater (typically the context of the Ember component).
198+
*/
199+
export default function streamUpdaterText(updaterClass, data, context) {
200+
const updaterInstance = new updaterClass(data, context);
201+
202+
if (!progressTimer) {
203+
progressTimer = later(async () => {
204+
await applyProgress(data, updaterInstance);
205+
206+
progressTimer = null;
207+
208+
if (!data.done) {
209+
await applyProgress(data, updaterInstance);
210+
}
211+
}, PROGRESS_INTERVAL);
212+
}
213+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import loadMorphlex from "discourse/lib/load-morphlex";
2+
import { cook } from "discourse/lib/text";
3+
import { addProgressDot } from "../progress-handlers";
4+
import StreamUpdater from "./stream-updater";
5+
6+
export default class PostUpdater extends StreamUpdater {
7+
morphingOptions = {
8+
beforeAttributeUpdated: (element, attributeName) => {
9+
return !(element.tagName === "DETAILS" && attributeName === "open");
10+
},
11+
};
12+
13+
constructor(postStream, postId) {
14+
super();
15+
this.postStream = postStream;
16+
this.postId = postId;
17+
this.post = postStream.findLoadedPost(postId);
18+
19+
if (this.post) {
20+
this.postElement = document.querySelector(
21+
`#post_${this.post.post_number}`
22+
);
23+
}
24+
}
25+
26+
get element() {
27+
return this.postElement;
28+
}
29+
30+
set streaming(value) {
31+
if (this.postElement) {
32+
if (value) {
33+
this.postElement.classList.add("streaming");
34+
} else {
35+
this.postElement.classList.remove("streaming");
36+
}
37+
}
38+
}
39+
40+
async setRaw(value, done) {
41+
this.post.set("raw", value);
42+
const cooked = await cook(value);
43+
44+
// resets animation
45+
this.element.classList.remove("streaming");
46+
void this.element.offsetWidth;
47+
this.element.classList.add("streaming");
48+
49+
const cookedElement = document.createElement("div");
50+
cookedElement.innerHTML = cooked;
51+
52+
if (!done) {
53+
addProgressDot(cookedElement);
54+
}
55+
56+
await this.setCooked(cookedElement.innerHTML);
57+
}
58+
59+
async setCooked(value) {
60+
this.post.set("cooked", value);
61+
62+
(await loadMorphlex()).morphInner(
63+
this.postElement.querySelector(".cooked"),
64+
`<div>${value}</div>`,
65+
this.morphingOptions
66+
);
67+
}
68+
69+
get raw() {
70+
return this.post.get("raw") || "";
71+
}
72+
}

0 commit comments

Comments
 (0)