4chan chub.ai preview userscript.

koosh koosh koosh koosh koosh koosh koosh koosh koosh koosh koosh koosh koosh koosh

// ==UserScript==
// @name         4chan chub preview
// @namespace    http://tampermonkey.net/
// @version      0.0.11
// @description  Preview chub.ai characters.
// @author       Anonymous
// @match        https://boards.4chan.org/*/thread/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=4chan.org
// @grant        none
// @run-at document-start
// ==/UserScript==

// Link: 
// Changelog:
// 0.0.11:
// - Fix permanent token formula
// 0.0.10:
// - Support for characterhub.org links
// 0.0.9:
// - Change textarea to look more like 4chan's
// - Select personality by default
// 0.0.8:
// - Add more complex init logic, since DOMContentLoaded doesn't fire when navigating for some reason
// - Allow to turn off console logging
// 0.0.7:
// - Fix matching urls sent my formatlets e.g with a full stop or question mark at the end
// 0.0.6:
// - Add functinoality to blur based on the NSFW tag
// - Rentry
// 0.0.5:
// - Blur is now selective and can be turned off
// - Fix adding <style> to <head> before DOMContentLoaded
// - Finally run prettier on the script
// 0.0.4:
// - Blur NSFW images
// 0.0.3:
// - Now match every board instead of just /g/
// 0.0.2:
// - Add textarea and select for full character definitions
// 0.0.1:
// - initial

const blurNSFW = true;
const blurWithNSFWTag = false;
const consoleLogging = true;

const log = (...args) => consoleLogging && console.log(...args);
const logError = (...args) => consoleLogging && console.error(...args);

const preparePost = (postWithChub, shouldBlur) => {
    postWithChub.classList.add("chubPreview");

    // elements
    const name = document.createElement("div");
    name.classList.add("name");

    const img = document.createElement("img");
    const imgContainer = document.createElement("div");
    imgContainer.appendChild(img);
    imgContainer.classList.add("imgContainer");
    imgContainer.tabIndex = "-1";

    const info = document.createElement("div");
    info.classList.add("info");

    const tagContainer = document.createElement("div");
    tagContainer.classList.add("tags");

    const tokens = document.createElement("div");
    tagContainer.classList.add("tokens");

    const container = document.createElement("div");
    container.classList.add("chubPreviewContainer");

    const details = document.createElement("details");
    details.classList.add("chubPreviewDetails");
    const summary = document.createElement("summary");
    details.appendChild(summary);
    details.appendChild(container);

    if (shouldBlur && blurNSFW) {
        details.classList.add("nsfw");
    }

    const select = document.createElement("select");
    select.classList.add("moar");

    const fetchingNote = document.createElement("div");
    fetchingNote.innerText = "Fetching full definitions...";
    fetchingNote.classList.add("fetchingNote");

    const definitionTextarea = document.createElement("textarea");
    definitionTextarea.readOnly = true;
    definitionTextarea.classList.add("definitionText");

    container.appendChild(name);
    container.appendChild(imgContainer);
    container.appendChild(info);
    container.appendChild(tagContainer);
    container.appendChild(tokens);
    container.appendChild(select);
    container.appendChild(fetchingNote);
    container.appendChild(definitionTextarea);
    postWithChub.querySelector(".postMessage").appendChild(details);

    return {summary, tokens, name, img, info, tagContainer, tokens, select, definitionTextarea, details};
};

const addFetchFullListener = (fetchFullDefs, details, select, definitionTextarea) => {
    const makeOption = (text, value) => {
        const option = document.createElement("option");
        option.innerText = text;
        option.value = value;
        return option;
    };
    const listener = async () => {
        const fullDefs = await fetchFullDefs();
        log("fetched full defs");
        if (!fullDefs) {
            return;
        }

        const {
            node: {
                definition: {first_message, personality, scenario, alternate_greetings: alternateGreetings},
            },
        } = fullDefs;
        !!personality && select.appendChild(makeOption("Personality", personality));
        !!first_message && select.appendChild(makeOption("First message", first_message));
        !!scenario && select.appendChild(makeOption("Scenario", scenario));
        !!alternateGreetings.length &&
            select.append(...alternateGreetings.map((v, i) => makeOption(`Alternate greeting ${i + 1}`, v)));

        select.addEventListener("change", (ev) => {
            const select = ev.currentTarget;
            const selected = select.selectedOptions[0];
            const text = selected.value;
            definitionTextarea.value = text;
        });
        const selected = select.selectedOptions[0];
        const text = selected.value;
        definitionTextarea.value = text;
    };
    details.addEventListener("mouseenter", listener, {once: true});
};

const processChub = (postData, postWithChub, fetchFullDefs) => {
    const formatTokenCount = (input) => {
        if (!input) {
            return null;
        }
        const {
            description,
            personality,
            scenario,
            _mes_example,
            _first_mes,
            system_prompt,
            post_history_instructions,
            total,
        } = Object.fromEntries(Object.entries(input).map(([k, v]) => [k, isNaN(+v) ? 0 : +v]));
        const permanent = description + personality + scenario;
        return `${permanent} permanent, ${total} total`;
    };

    if (!postData) {
        logError("postData null", postWithChub);
        return;
    }
    if (postData.errors !== null) {
        logError("postData has error", postData);
        return;
    }
    if (!postData.node) {
        logError("no node in postData", postWithChub);
        return;
    }

    const {
        node: {name: characterName, topics: tags, tagline, avatar_url: imageUrl, labels, nsfw_image},
    } = postData;
    const {summary, name, img, info, tagContainer, tokens, select, definitionTextarea, details} = preparePost(
        postWithChub,
        !!nsfw_image || (blurWithNSFWTag && tags.includes("NSFW"))
    );

    addFetchFullListener(fetchFullDefs, details, select, definitionTextarea);

    const tokenCountString =
          formatTokenCount(JSON.parse(labels.find((v) => v.title === "TOKEN_COUNTS")?.description)) ?? "unknown";

    summary.innerText = "Chub.ai preview for " + characterName;
    img.src = imageUrl;
    name.innerText = characterName;
    info.innerText = tagline;
    tagContainer.append(
        ...tags.map((v) => {
            const el = document.createElement("div");
            el.innerText = v;
            return el;
        })
    );
    tokens.innerText = "Token count: " + tokenCountString;
};

const processPosts = (postv) => {
    log("Processing", postv.length, "posts:", postv);
    for (const post of postv) {
        if (!(post.innerText.includes("chub.ai") || post.innerText.includes("characterhub.org"))) {
            continue;
        }
        // matches chub.ai/<user>/<name>/?(version)?/
        const matches = [
            ...post
            .querySelector(".postMessage")
            .innerText.matchAll(
                /https?:\/\/(?:www\.)?(?:(?:chub\.ai)|(?:characterhub\.org))\/characters\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)(?:\/([a-zA-Z0-9_-]+))?/g
            ),
        ];
        if (!matches.length) {
            continue;
        }

        for (const match of matches) {
            // not sure what to do with version
            const [link, user, name, _version] = match;
            if (encodeURI(link) !== link) {
                log("bad chub link!", link);
                continue;
            }

            // expose more data if you want others not to use internal endpoinds, chuddie
            const apiLink = `https://api.chub.ai/api/characters/${user}/${name}?full=false`;

            // lazy fetch, since defs are pretty fucking big compared to just chub metadata
            const fetchFullDefs = async () => {
                return await fetch(apiLink.replace("?full=false", "?full=true"), {method: "GET"})
                    .then((v) => v.json())
                    .catch((err) => logError("could not fetch full defs", apiLink, "got error:", err));
            };

            fetch(apiLink, {method: "GET"})
                .then((v) => v.json())
                .catch((err) => logError("could not fetch", apiLink, "got error:", err))
                .then((data) => processChub(data, post, fetchFullDefs))
                .catch((err) => logError("could not process", apiLink, "got error:", err));
        }
    }
};

const run = () => {
    "use strict";

    // set a flag to indicate that we'd loaded
    window.__chub_preview_loaded = true;

    const thread = document.querySelector(".thread");

    processPosts([...document.querySelectorAll(".postContainer")]);

    const observer = new MutationObserver((mutations) => {
        const newPosts = mutations
        .map((v) => [...v.addedNodes].filter((v) => v.classList?.contains("postContainer")))
        .flat();
        if (newPosts.length) {
            processPosts(newPosts);
        }
    });

    observer.observe(thread, {childList: true, subtree: true});
};

{
    const style = document.createElement("style");
    style.innerHTML = `
.chubPreview {
    --chub-width: 400px;
}
.chubPreview .post {
}
details.chubPreviewDetails  {
    margin: 1em 0;
}
.chubPreviewDetails summary {
    cursor: pointer;
    max-width: var(--chub-width);

    white-space: nowrap;
}
.chubPreviewDetails summary::before {
    content: "Open ";
}
.chubPreviewDetails[open] summary::before {
    content: "Close ";
}
.chubPreviewContainer {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;

    box-sizing: border-box;

    width: var(--chub-width);
    padding: 4px;

    text-align: center;
}
.chubPreviewContainer > * {
    width: var(--chub-width);
    max-width: var(--chub-width);
}
.chubPreviewContainer .name {
    font-weight: bold;
}
.chubPreviewContainer .imgContainer {
    max-width: 300px;
    border-radius: 4px;
    position: relative;
}
.imgContainer img {
    width: 100%
}
.chubPreviewContainer .info {
    padding: 0.33em;
}
.chubPreviewContainer .tags {
    display: flex;
    flex-wrap: wrap;
    gap: 4px 6px;

    width: var(--chub-width);

    font-family: monospace;
}
.chubPreviewContainer .tags > div {
    background-color: rgba(255, 255, 255, 0.4);
    padding: 2px 4px;
    border-radius: 4px;
}
.chubPreviewContainer .tags > div:not(:last-child) {
    margin-right: 4px;
}
.chubPreviewContainer .tokens {
}
.chubPreviewContainer .moar {
}
.chubPreviewContainer .moar:empty {
    display: none;
}
.chubPreviewContainer .definitionText {
    outline: none;
    resize: vertical;
    height: 220px;

    border: 1px solid #aaa;
    font-family: sans-serif;
    line-height: 1.2;
    font-size: 10pt;
    outline: medium none;
    padding: 2px;
    margin: 0 0 1px 0;
}
.chubPreviewContainer .moar:not(:empty) ~ .fetchingNote,
.chubPreviewContainer .moar:empty ~ .definitionText {
    display: none;
}

.chubPreviewDetails.nsfw .imgContainer:not(:focus)::before {
    content: "Click to view NSFW image";
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -60%);
    z-index: 1;

    background-color: rgba(255,255,255,0.3);
    box-shadow: 0 0 12px 12px rgba(255,255,255,0.3);
}
.chubPreviewDetails.nsfw .imgContainer:not(:focus) img {
    filter: blur(14px);
}
`;

    const init = () => {
        document.head.appendChild(style);
        run();
    };

    // this waits for either DOMContentLoaded or document.readyState === "complete" and runs the init function.
    Promise.race([
        new Promise((resolve, _) => document.addEventListener("DOMContentLoaded", () => resolve("event"), { once: true })),
        new Promise((resolve, _) => { const h = setInterval(() => { if(document.readyState === "complete") { clearInterval(h); resolve("poll"); } }, 200) })
    ]).then(reason => window.__chub_preview_loaded ? log("already init chub.ai preview") : (init(), log("init chub.ai preview because of", reason)));
}
Edit
Pub: 03 Apr 2024 12:42 UTC
Edit: 23 Jul 2024 01:58 UTC
Views: 949