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);