Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
import DTooltip from "float-kit/components/d-tooltip";
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
import { streamSummaryText } from "../../lib/ai-streamer";
import streamUpdaterText from "../../lib/ai-streamer/progress-handlers";
import SummaryUpdater from "../../lib/ai-streamer/updaters/summary-updater";

export default class AiSummaryBox extends Component {
@service siteSettings;
Expand All @@ -34,7 +35,7 @@ export default class AiSummaryBox extends Component {
@tracked canRegenerate = false;
@tracked loading = false;
@tracked isStreaming = false;
oldRaw = null; // used for comparison in SummaryUpdater in lib/ai-streamer
oldRaw = null; // used for comparison in SummaryUpdater in lib/ai-streamer/updaters
finalSummary = null;

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

this.isStreaming = true;
streamSummaryText(topicSummary, this);
streamUpdaterText(SummaryUpdater, topicSummary, this);

if (update.done) {
this.isStreaming = false;
Expand Down Expand Up @@ -219,6 +220,7 @@ export default class AiSummaryBox extends Component {
<article
class={{concatClass
"ai-summary-box"
"streamable-content"
(if this.isStreaming "streaming")
}}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { later } from "@ember/runloop";
import loadMorphlex from "discourse/lib/load-morphlex";
import { cook } from "discourse/lib/text";
import PostUpdater from "./updaters/post-updater";

const PROGRESS_INTERVAL = 40;
const GIVE_UP_INTERVAL = 60000;
Expand All @@ -9,6 +8,13 @@ const MAX_FLUSH_TIME = 800;

let progressTimer = null;

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

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

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

// this is the interface we need to implement
// for a streaming updater
class StreamUpdater {
set streaming(value) {
throw "not implemented";
}

async setCooked() {
throw "not implemented";
}

async setRaw() {
throw "not implemented";
}

get element() {
throw "not implemented";
}

get raw() {
throw "not implemented";
}
}

class PostUpdater extends StreamUpdater {
morphingOptions = {
beforeAttributeUpdated: (element, attributeName) => {
return !(element.tagName === "DETAILS" && attributeName === "open");
},
};

constructor(postStream, postId) {
super();
this.postStream = postStream;
this.postId = postId;
this.post = postStream.findLoadedPost(postId);

if (this.post) {
this.postElement = document.querySelector(
`#post_${this.post.post_number}`
);
}
}

get element() {
return this.postElement;
}

set streaming(value) {
if (this.postElement) {
if (value) {
this.postElement.classList.add("streaming");
} else {
this.postElement.classList.remove("streaming");
}
}
}

async setRaw(value, done) {
this.post.set("raw", value);
const cooked = await cook(value);

// resets animation
this.element.classList.remove("streaming");
void this.element.offsetWidth;
this.element.classList.add("streaming");

const cookedElement = document.createElement("div");
cookedElement.innerHTML = cooked;

if (!done) {
addProgressDot(cookedElement);
}

await this.setCooked(cookedElement.innerHTML);
}

async setCooked(value) {
this.post.set("cooked", value);

(await loadMorphlex()).morphInner(
this.postElement.querySelector(".cooked"),
`<div>${value}</div>`,
this.morphingOptions
);
}

get raw() {
return this.post.get("raw") || "";
}
}

export class SummaryUpdater extends StreamUpdater {
constructor(topicSummary, componentContext) {
super();
this.topicSummary = topicSummary;
this.componentContext = componentContext;

if (this.topicSummary) {
this.summaryBox = document.querySelector("article.ai-summary-box");
}
}

get element() {
return this.summaryBox;
}

set streaming(value) {
if (this.element) {
if (value) {
this.componentContext.isStreaming = true;
} else {
this.componentContext.isStreaming = false;
}
}
}

async setRaw(value, done) {
this.componentContext.oldRaw = value;
const cooked = await cook(value);

await this.setCooked(cooked);

if (done) {
this.componentContext.finalSummary = cooked;
}
}

async setCooked(value) {
this.componentContext.text = value;
}

get raw() {
return this.componentContext.oldRaw || "";
}
}

/**
* Applies progress to a streaming operation, updating the raw and cooked text,
* handling progress dots, and stopping streaming when complete.
*
* @param {Object} status - The current streaming status object.
* @param {Object} updater - An instance of a stream updater (e.g., PostUpdater or SummaryUpdater).
* @returns {Promise<boolean>} - Resolves to true if streaming is complete, otherwise false.
*/
export async function applyProgress(status, updater) {
status.startTime = status.startTime || Date.now();

Expand Down Expand Up @@ -235,6 +119,14 @@ export async function applyProgress(status, updater) {
return status.done;
}

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

Expand All @@ -257,22 +149,12 @@ async function handleProgress(postStream) {
return keepPolling;
}

export function streamSummaryText(topicSummary, context) {
const summaryUpdater = new SummaryUpdater(topicSummary, context);

if (!progressTimer) {
progressTimer = later(async () => {
await applyProgress(topicSummary, summaryUpdater);

progressTimer = null;

if (!topicSummary.done) {
await applyProgress(topicSummary, summaryUpdater);
}
}, PROGRESS_INTERVAL);
}
}

/**
* Ensures that progress for a post stream is being updated. It starts a progress timer
* if one is not already active, and continues polling for progress updates at regular intervals.
*
* @param {Object} postStream - The post stream object containing the posts to be updated.
*/
function ensureProgress(postStream) {
if (!progressTimer) {
progressTimer = later(async () => {
Expand All @@ -287,7 +169,14 @@ function ensureProgress(postStream) {
}
}

export default function streamText(postStream, data) {
/**
* Streams the raw text for a post by tracking its status and applying progress updates.
* If streaming is already in progress, this function ensures it continues to update the content.
*
* @param {Object} postStream - The post stream object containing the post to be updated.
* @param {Object} data - The data object containing raw and cooked content of the post.
*/
export function streamPostText(postStream, data) {
if (data.noop) {
return;
}
Expand All @@ -297,3 +186,28 @@ export default function streamText(postStream, data) {
status[data.post_id] = data;
ensureProgress(postStream);
}

/**
* A generalized function to handle streaming of content using any specified updater class.
* It applies progress updates to the content (raw and cooked) based on the given data.
* Use this function to stream content for Glimmer components.
*
* @param {Function} updaterClass - The updater class to be used for streaming (e.g., PostUpdater, SummaryUpdater).
* @param {Object} data - The data object containing the content to be streamed.
* @param {Object} context - Additional context required for the updater (typically the context of the Ember component).
*/
export default function streamUpdaterText(updaterClass, data, context) {
const updaterInstance = new updaterClass(data, context);

if (!progressTimer) {
progressTimer = later(async () => {
await applyProgress(data, updaterInstance);

progressTimer = null;

if (!data.done) {
await applyProgress(data, updaterInstance);
}
}, PROGRESS_INTERVAL);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import loadMorphlex from "discourse/lib/load-morphlex";
import { cook } from "discourse/lib/text";
import { addProgressDot } from "../progress-handlers";
import StreamUpdater from "./stream-updater";

export default class PostUpdater extends StreamUpdater {
morphingOptions = {
beforeAttributeUpdated: (element, attributeName) => {
return !(element.tagName === "DETAILS" && attributeName === "open");
},
};

constructor(postStream, postId) {
super();
this.postStream = postStream;
this.postId = postId;
this.post = postStream.findLoadedPost(postId);

if (this.post) {
this.postElement = document.querySelector(
`#post_${this.post.post_number}`
);
}
}

get element() {
return this.postElement;
}

set streaming(value) {
if (this.postElement) {
if (value) {
this.postElement.classList.add("streaming");
} else {
this.postElement.classList.remove("streaming");
}
}
}

async setRaw(value, done) {
this.post.set("raw", value);
const cooked = await cook(value);

// resets animation
this.element.classList.remove("streaming");
void this.element.offsetWidth;
this.element.classList.add("streaming");

const cookedElement = document.createElement("div");
cookedElement.innerHTML = cooked;

if (!done) {
addProgressDot(cookedElement);
}

await this.setCooked(cookedElement.innerHTML);
}

async setCooked(value) {
this.post.set("cooked", value);

(await loadMorphlex()).morphInner(
this.postElement.querySelector(".cooked"),
`<div>${value}</div>`,
this.morphingOptions
);
}

get raw() {
return this.post.get("raw") || "";
}
}
Loading