discourse-ai/assets/javascripts/discourse/lib/ai-streamer/progress-handlers.js
Sam c34fcc8a95
FEATURE: forum researcher persona for deep research (#1313)
This commit introduces a new Forum Researcher persona specialized in deep forum content analysis along with comprehensive improvements to our AI infrastructure.

Key additions:

    New Forum Researcher persona with advanced filtering and analysis capabilities
    Robust filtering system supporting tags, categories, dates, users, and keywords
    LLM formatter to efficiently process and chunk research results

Infrastructure improvements:

    Implemented CancelManager class to centrally manage AI completion cancellations
    Replaced callback-based cancellation with a more robust pattern
    Added systematic cancellation monitoring with callbacks

Other improvements:

    Added configurable default_enabled flag to control which personas are enabled by default
    Updated translation strings for the new researcher functionality
    Added comprehensive specs for the new components

    Renames Researcher -> Web Researcher

This change makes our AI platform more stable while adding powerful research capabilities that can analyze forum trends and surface relevant content.
2025-05-14 12:36:16 +10:00

213 lines
6.4 KiB
JavaScript

import { later } from "@ember/runloop";
import PostUpdater from "./updaters/post-updater";
const PROGRESS_INTERVAL = 40;
const GIVE_UP_INTERVAL = 600000; // 10 minutes which is our max thinking time for now
export const MIN_LETTERS_PER_INTERVAL = 6;
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 (
lastChild &&
lastChild.nodeType === Node.TEXT_NODE &&
!/\S/.test(lastChild.textContent)
) {
lastChild = lastChild.previousSibling;
}
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;
while (true) {
let lastChild = lastNonEmptyChild(lastBlock);
if (!lastChild) {
break;
}
if (lastChild.nodeType === Node.ELEMENT_NODE) {
lastBlock = lastChild;
} else {
break;
}
}
const dotElement = document.createElement("span");
dotElement.classList.add("progress-dot");
lastBlock.appendChild(dotElement);
}
/**
* 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();
if (Date.now() - status.startTime > GIVE_UP_INTERVAL) {
updater.streaming = false;
return true;
}
if (!updater.element) {
// wait till later
return false;
}
const oldRaw = updater.raw;
if (status.raw === oldRaw && !status.done) {
const hasProgressDot = updater.element.querySelector(".progress-dot");
if (hasProgressDot) {
return false;
}
}
if (status.raw !== undefined) {
let newRaw = status.raw;
if (!status.done) {
// rush update if we have a </details> tag (function call)
if (oldRaw.length === 0 && newRaw.indexOf("</details>") !== -1) {
newRaw = status.raw;
} else {
const diff = newRaw.length - oldRaw.length;
// progress interval is 40ms
// by default we add 6 letters per interval
// but ... we want to be done in MAX_FLUSH_TIME
let letters = Math.floor(diff / (MAX_FLUSH_TIME / PROGRESS_INTERVAL));
if (letters < MIN_LETTERS_PER_INTERVAL) {
letters = MIN_LETTERS_PER_INTERVAL;
}
newRaw = status.raw.substring(0, oldRaw.length + letters);
}
}
await updater.setRaw(newRaw, status.done);
}
if (status.done) {
if (status.cooked) {
await updater.setCooked(status.cooked);
}
updater.streaming = false;
}
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;
let keepPolling = false;
const promises = Object.keys(status).map(async (postId) => {
let postStatus = status[postId];
const postUpdater = new PostUpdater(postStream, postStatus.post_id);
const done = await applyProgress(postStatus, postUpdater);
if (done) {
delete status[postId];
} else {
keepPolling = true;
}
});
await Promise.all(promises);
return keepPolling;
}
/**
* 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 () => {
const keepPolling = await handleProgress(postStream);
progressTimer = null;
if (keepPolling) {
ensureProgress(postStream);
}
}, PROGRESS_INTERVAL);
}
}
/**
* 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;
}
let status = (postStream.aiStreamingStatus =
postStream.aiStreamingStatus || {});
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);
}
}