Skip to content
Open
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
301 changes: 290 additions & 11 deletions Chat DeMod.user.js
Original file line number Diff line number Diff line change
@@ -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.*/
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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());
}
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -660,6 +915,7 @@
case ConversationType.PROMPT: {

payload = new ChatPayload();
resetStreamCapture();
sequence_shift = 0;
last_response = null;
response_blocked = false;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand Down