From bc059649211bdda8445069473a29e353fbad6a8c Mon Sep 17 00:00:00 2001 From: ScrewThisNoise <51236954+ScrewThisNoise@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:34:25 +0200 Subject: [PATCH] Update Chat DeMod.user.js Capture streamed text and persist restored blocked messages Fix blocked-message recovery which broke because Chat now removes blocked messages from conversation history, making the post-stream redownload fail. - Capture the assistant response (text + full message object) live as it streams in, so blocked messages can be restored from what actually came through instead of relying on redownloading them afterwards. Redownload is kept only as a fallback when nothing was captured. - Persist restored messages and the latest moderation status per conversation in localStorage, and re-inject them into the conversation JSON on load so restored content survives a page refresh. Handles both emptied-but-present nodes and nodes removed entirely from the tree. - Add setText/setMessage to ChatEvent (fixes the WebSocket blocked path which called a non-existent replace()). - Bump version to 6.1. --- Chat DeMod.user.js | 301 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 290 insertions(+), 11 deletions(-) diff --git a/Chat DeMod.user.js b/Chat DeMod.user.js index 0f716b2..a106290 100644 --- a/Chat DeMod.user.js +++ b/Chat DeMod.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Chat DeMod // @namespace pl.4as.chat -// @version 6.0 +// @version 6.1 // @description Hides moderation results during conversations with Chat // @author 4as // @include /^https?:\/\/chatg.*t\.com.*/ @@ -141,6 +141,232 @@ return text; } + // --- Live capture of the response text as it streams in. This lets DeMod + // --- restore blocked messages using what actually streamed, instead of + // --- relying on re-downloading them from the conversation history afterwards + // --- (which stopped working because Chat removes blocked messages from history). + var streamed_parts = []; + var streamed_message = null; + var current_capture_path = null; + var pending_parent_id = null; //id of the user message the streamed answer replies to + + function resetStreamCapture() { + streamed_parts = []; + streamed_message = null; + current_capture_path = null; + } + + function getStreamedText() { + return streamed_parts.join("\n\n").trim(); + } + + // Rebuilds a full, "finished" message object out of whatever streamed in. + function getStreamedMessage() { + if (streamed_message === null) + return null; + var msg; + try { + msg = JSON.parse(JSON.stringify(streamed_message)); + } catch (e) { + return null; + } + if (!msg.content || typeof msg.content !== 'object') + msg.content = { "content_type": "text", "parts": [] }; + msg.content.parts = streamed_parts.slice(); + msg.status = "finished_successfully"; + msg.end_turn = true; + if (!msg.metadata || typeof msg.metadata !== 'object') + msg.metadata = {}; + msg.metadata.is_complete = true; + return msg; + } + + function captureChunk(chunk_data) { + if (chunk_data === null || typeof chunk_data !== 'object') + return; + + var op = chunk_data.o; + var path = chunk_data.hasOwnProperty('p') ? chunk_data.p : undefined; + var value = chunk_data.v; + + // Batch of patches: process each one individually. + if (op === "patch" && Array.isArray(value)) { + for (let i = 0; i < value.length; ++i) + captureChunk(value[i]); + return; + } + + // Start of a new assistant message: contains the full message object. + if (value !== null && typeof value === 'object' && value.message + && value.message.content && Array.isArray(value.message.content.parts)) { + var author = value.message.author; + if (!author || author.role === undefined || author.role === 'assistant') { + streamed_parts = []; + try { + streamed_message = JSON.parse(JSON.stringify(value.message)); + } catch (e) { + streamed_message = null; + } + var parts = value.message.content.parts; + for (let i = 0; i < parts.length; ++i) { + if (typeof parts[i] === 'string') + streamed_parts[i] = parts[i]; + } + current_capture_path = "/message/content/parts/0"; + } + return; + } + + // An explicit path updates the "current" path; compact deltas ({"v":"..."}) + // without a path reuse whatever path was last referenced by the server. + var target_path = (typeof path === 'string' && path.length > 0) ? path : current_capture_path; + if (typeof path === 'string' && path.length > 0) + current_capture_path = path; + + if (typeof target_path === 'string' && target_path.indexOf('/message/content/parts/') !== -1 + && typeof value === 'string') { + var match = target_path.match(/\/message\/content\/parts\/(\d+)/); + if (match) { + var idx = parseInt(match[1], 10); + if (op === 'replace' || op === 'add') + streamed_parts[idx] = value; + else + streamed_parts[idx] = (streamed_parts[idx] || "") + value; + } + } + } + + function captureStreamText(chunk_text) { + if (typeof chunk_text !== 'string') + return; + var start = chunk_text.indexOf("data: "); + while (start !== -1) { + var end = chunk_text.indexOf("\n", start); + if (end === -1) + end = chunk_text.length; + var data = chunk_text.substring(start + 5, end).trim(); + if (data.length > 0 && data !== DONE) { + try { + captureChunk(JSON.parse(data)); + } catch (e) {} + } + start = chunk_text.indexOf("data: ", end + 1); + } + } + + // --- Persistence: blocked messages get removed from Chat's own history, so we + // --- keep a copy in localStorage and re-inject it whenever the conversation is + // --- (re)loaded. This is what makes restored messages survive a page refresh. + const STORE_PREFIX = "DeModConv:"; + + function loadConvStore(conv_id) { + if (!conv_id) + return null; + try { + var raw = target_window.localStorage.getItem(STORE_PREFIX + conv_id); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } + } + + function saveConvStore(conv_id, store) { + if (!conv_id) + return; + try { + target_window.localStorage.setItem(STORE_PREFIX + conv_id, JSON.stringify(store)); + } catch (e) { + console.log("[DEMOD] Failed to persist restored message: " + e); + } + } + + // Remembers the moderation status of the latest turn for a conversation. + function rememberModResult(conv_id, mod_result_value) { + if (!conv_id || temp_chat) + return; + var store = loadConvStore(conv_id) || { "mod_result": ModerationResult.SAFE, "messages": {} }; + store.mod_result = mod_result_value; + saveConvStore(conv_id, store); + } + + // Stores a restored (blocked) message so it can be re-injected after a reload. + function rememberRestoredMessage(conv_id, message, parent_id, mod_result_value) { + if (!conv_id || temp_chat || message === null || !message.id) + return; + var store = loadConvStore(conv_id) || { "mod_result": ModerationResult.SAFE, "messages": {} }; + store.messages[message.id] = { "message": message, "parent": parent_id || null }; + store.mod_result = mod_result_value; + saveConvStore(conv_id, store); + } + + function conversationIdFromUrl(url) { + if (typeof url !== 'string') + return null; + var m = /\/conversation\/([0-9a-fA-F-]{36})/.exec(url); + return m ? m[1] : null; + } + + // Re-inserts stored messages into a freshly loaded conversation object and + // returns the moderation status remembered for this conversation. + function restoreConversation(convo_object, url_conv_id) { + if (convo_object === null || typeof convo_object !== 'object') + return ModerationResult.UNKNOWN; + + var conv_id = convo_object.conversation_id || convo_object.id || url_conv_id; + var store = loadConvStore(conv_id); + if (store === null) + return ModerationResult.UNKNOWN; + + var mapping = convo_object.mapping; + if (mapping && typeof mapping === 'object' && store.messages) { + for (var msg_id in store.messages) { + var entry = store.messages[msg_id]; + if (!entry || !entry.message) + continue; + + if (mapping.hasOwnProperty(msg_id)) { + // Node still exists (usually with its content emptied out) - refill it, + // but never clobber content Chat actually kept. + var node = mapping[msg_id]; + var needs_restore = false; + if (!node.message) { + needs_restore = true; + } + else { + var existing_parts = node.message.content && node.message.content.parts; + var existing_text = Array.isArray(existing_parts) ? existing_parts.join("").trim() : ""; + needs_restore = (existing_text.length === 0); + } + if (needs_restore) { + node.message = entry.message; + console.log("[DEMOD] Restored blocked message " + msg_id + " from local storage."); + } + } + else { + // Node was removed entirely - rebuild it and hook it back into the tree. + var parent_id = entry.parent; + mapping[msg_id] = { + "id": msg_id, + "message": entry.message, + "parent": parent_id || null, + "children": [] + }; + if (parent_id && mapping.hasOwnProperty(parent_id)) { + var siblings = mapping[parent_id].children || []; + if (siblings.indexOf(msg_id) === -1) + siblings.push(msg_id); + mapping[parent_id].children = siblings; + if (convo_object.current_node === parent_id) + convo_object.current_node = msg_id; + } + console.log("[DEMOD] Re-inserted removed message " + msg_id + " from local storage."); + } + } + } + + return store.mod_result || ModerationResult.UNKNOWN; + } + const ConversationType = { UNKNOWN: 0, INIT: 1, @@ -422,6 +648,7 @@ async process(current_blocked) { this.is_blocked = current_blocked; + captureStreamText(this.chunk); if (this.chunk_start == -1) { this.queue.push(this.chunk); @@ -438,15 +665,24 @@ if (chunk_text === DONE) { this.is_done = true; if (!temp_chat && this.handle_latest && this.is_blocked) { - console.log("[DEMOD] Blocked response finished, attempting to reload it from history."); - var latest = await redownloadLatest(); - if (latest !== null) { - this.payload.setMessage(latest); + var captured = getStreamedText(); + if (captured.length > 0) { + console.log("[DEMOD] Blocked response finished, restoring text captured during streaming."); + this.payload.setText(captured); this.queue.push(this.payload.getData()); + rememberRestoredMessage(this.conversation_id || last_conv_id, getStreamedMessage(), pending_parent_id, this.mod_result); } else { - this.payload.setText("DeMod: Request completed, but DeMod failed to access the history. Try refreshing the conversation instead."); - this.queue.push(this.payload.getData()); + console.log("[DEMOD] Blocked response finished with no captured text, attempting to reload it from history."); + var latest = await redownloadLatest(); + if (latest !== null) { + this.payload.setMessage(latest); + this.queue.push(this.payload.getData()); + } + else { + this.payload.setText("DeMod: Request completed, but DeMod failed to access the history. Try refreshing the conversation instead."); + this.queue.push(this.payload.getData()); + } } } @@ -562,6 +798,16 @@ return cloneEvent(this.event, updated_data); } + setText(text) { + this.response.payload.setText(text); + this.response_body = this.response.payload.getData(); + } + + setMessage(message) { + this.response.payload.setMessage(message); + this.response_body = this.response.payload.getData(); + } + clone() { var copy = new ChatEvent(this.payload, this.event); copy.response_body = this.response_body; @@ -637,6 +883,15 @@ } temp_chat = conv_body.hasOwnProperty("history_and_training_disabled") && conv_body.history_and_training_disabled; + + // Remember which user message this answer replies to, so a blocked + // answer can be re-attached to the tree when restored after a reload. + pending_parent_id = null; + if (Array.isArray(conv_body.messages) && conv_body.messages.length > 0) { + var user_msg = conv_body.messages[conv_body.messages.length - 1]; + if (user_msg && user_msg.id) + pending_parent_id = user_msg.id; + } } else if(fetch_url.indexOf('/conversation/') !== -1) { convo_type = ConversationType.INIT; @@ -660,6 +915,7 @@ case ConversationType.PROMPT: { payload = new ChatPayload(); + resetStreamCapture(); sequence_shift = 0; last_response = null; response_blocked = false; @@ -696,6 +952,8 @@ } if (response.is_done || done) { + if (!temp_chat && last_conv_id !== null) + rememberModResult(last_conv_id, mod_result); controller.close(); break; } @@ -714,9 +972,18 @@ console.log("[DEMOD] Processing conversation initialization. Checking if the conversation has existing moderation results."); var convo_init = await original_result.text(); + + var restored_mod = ModerationResult.UNKNOWN; + try { + var convo_object = JSON.parse(convo_init); + restored_mod = restoreConversation(convo_object, conversationIdFromUrl(fetch_url)); + convo_init = JSON.stringify(convo_object); + } catch (e) { + console.log("[DEMOD] Could not parse conversation for restoration: " + e); + } convo_init = clearFlagging(convo_init); - updateDeModMessageState(ModerationResult.UNKNOWN); + updateDeModMessageState(restored_mod); return new Response(convo_init, { status: original_result.status, @@ -769,15 +1036,27 @@ if (response_blocked) { if (response.is_done) { if (last_response != null) { - console.log("[DEMOD] Response blocked, redownloading from history."); - var latest = await redownloadLatest(); - last_response.replace(latest); + var captured = getStreamedText(); + if (captured.length > 0) { + console.log("[DEMOD] Response blocked, restoring text captured during streaming."); + last_response.setText(captured); + rememberRestoredMessage(last_conv_id, getStreamedMessage(), pending_parent_id, ModerationResult.BLOCKED); + } + else { + console.log("[DEMOD] Response blocked, redownloading from history."); + var latest = await redownloadLatest(); + if (latest != null) + last_response.setMessage(latest); + } buffer.push(last_response); sequence_shift++; } } } + if (response.is_done && !temp_chat && last_conv_id !== null) + rememberModResult(last_conv_id, mod_result); + response.sequence_id += sequence_shift; buffer.push(response);