Markdown Image Renderer for Google AI Studio

AI StudioでMarkdown画像が表示できるようになります(:character記法、クリック時に別ウィンドウ対応)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Markdown Image Renderer for Google AI Studio
// @namespace    https://rentry.co/3bnuvgwu
// @license      MIT
// @version      5.3
// @description  AI StudioでMarkdown画像が表示できるようになります(:character記法、クリック時に別ウィンドウ対応)
// @author       ForeverPWA
// @match        *://aistudio.google.com/*
// @grant        GM_xmlhttpRequest
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = "🖼️ MIR:";
    console.log(LOG_PREFIX, "v5.3 started");

    // =========================================
    // IndexedDB(image-manager.user.jsと同じDB)
    // =========================================
    const DB_NAME = 'ImageManagerDB';
    const DB_VERSION = 1;

    // キャッシュ: `:character/キャラ名/衣装名/表情名` → { data, mimeType }
    let imageCache = {};
    let cacheReady = false;

    function openDB() {
        return new Promise((resolve, reject) => {
            const req = indexedDB.open(DB_NAME, DB_VERSION);
            req.onerror = () => reject(req.error);
            req.onsuccess = () => resolve(req.result);
        });
    }

    // IndexedDBから全データを読み込んで :character/... 形式でキャッシュ
    async function loadImageCache() {
        try {
            const db = await openDB();

            // キャラクター一覧
            const characters = await getAll(db, 'characters');
            // 衣装一覧
            const outfits = await getAll(db, 'outfits');
            // 画像一覧
            const images = await getAll(db, 'characterImages');

            db.close();

            // キャラクターIDマップ
            const charMap = {};
            characters.forEach(c => { charMap[c.id] = c.name; });

            // 衣装IDマップ (outfitId → { charName, outfitName })
            const outfitMap = {};
            outfits.forEach(o => {
                outfitMap[o.id] = {
                    charName: charMap[o.characterId] || 'unknown',
                    outfitName: o.name
                };
            });

            // 画像をキャッシュに登録
            images.forEach(img => {
                const outfit = outfitMap[img.outfitId];
                if (outfit) {
                    const key = `:character/${outfit.charName}/${outfit.outfitName}/${img.name}`;
                    imageCache[key] = {
                        data: img.data,  // base64文字列
                        mimeType: img.mimeType
                    };
                }
            });

            cacheReady = true;
            console.log(LOG_PREFIX, `Loaded ${images.length} images, cache keys:`, Object.keys(imageCache).slice(0, 5));
        } catch (e) {
            console.warn(LOG_PREFIX, "IndexedDB error:", e);
            cacheReady = true;
        }
    }

    function getAll(db, storeName) {
        return new Promise((resolve, reject) => {
            const tx = db.transaction(storeName, 'readonly');
            const store = tx.objectStore(storeName);
            const req = store.getAll();
            req.onsuccess = () => resolve(req.result || []);
            req.onerror = () => reject(req.error);
        });
    }

    // =========================================
    // URL画像取得(従来通り)
    // =========================================
    function fetchImageAsBlob(url, callback) {
        if (url.startsWith(window.location.origin) || url.startsWith('/')) {
            fetch(url)
                .then(r => r.ok ? r.blob() : Promise.reject())
                .then(callback)
                .catch(() => callback(null));
            return;
        }
        GM_xmlhttpRequest({
            method: 'GET',
            url: url,
            responseType: 'blob',
            onload: (r) => callback(r.status >= 200 && r.status < 300 ? r.response : null),
            onerror: () => callback(null)
        });
    }

    // =========================================
    // pre要素処理
    // =========================================
    function processPreElement(pre) {
        if (pre.dataset.mir) return;

        const text = pre.textContent || '';
        const match = text.match(/!\[([^\]]*)\]\(([^)]+)\)/);
        if (!match) return;

        const [, alt, src] = match;
        pre.dataset.mir = '1';

        // :character/パスの場合 → キャッシュから取得
        if (src.startsWith(':character/')) {
            if (!cacheReady) return;

            // 拡張子なしで検索
            const pathNoExt = src.replace(/\.[^/.]+$/, '');
            const imgData = imageCache[pathNoExt] || imageCache[src];

            if (imgData) {
                console.log(LOG_PREFIX, `✓ Found: ${src}`);

                const img = document.createElement('img');
                // data は既にbase64文字列
                img.src = imgData.data;
                img.alt = alt || src;
                img.style.cssText = "max-width:100%;height:auto;display:block;margin:10px 0;border-radius:8px;cursor:pointer;";
                img.title = src;
                img.onclick = () => {
                    // base64の場合はBlobURLに変換して開く
                    if (imgData.data.startsWith('data:')) {
                        try {
                            const [header, base64Data] = imgData.data.split(',');
                            const mimeType = header.match(/data:([^;]+)/)?.[1] || 'image/png';
                            const byteCharacters = atob(base64Data);
                            const byteNumbers = new Array(byteCharacters.length);
                            for (let i = 0; i < byteCharacters.length; i++) {
                                byteNumbers[i] = byteCharacters.charCodeAt(i);
                            }
                            const byteArray = new Uint8Array(byteNumbers);
                            const blob = new Blob([byteArray], { type: mimeType });
                            const blobUrl = URL.createObjectURL(blob);
                            window.open(blobUrl, 'imagePreviewWindow');
                        } catch (err) {
                            console.error(LOG_PREFIX, '画像を開けませんでした:', err);
                        }
                    } else {
                        window.open(imgData.data, 'imagePreviewWindow');
                    }
                };

                pre.style.display = 'none';
                pre.parentNode?.insertBefore(img, pre.nextSibling);
            } else {
                console.log(LOG_PREFIX, `✗ Not found: ${src} (keys: ${Object.keys(imageCache).length})`);
            }
            return;
        }

        // URL画像の場合 → 従来通りfetch
        console.log(LOG_PREFIX, `Fetch: ${src}`);

        const img = document.createElement('img');
        img.alt = alt || 'Loading...';
        img.style.cssText = "max-width:100%;height:auto;display:block;margin:10px 0;border-radius:8px;background:#f0f0f0;min-height:50px;cursor:pointer;";
        img.title = src;
        img.onclick = () => window.open(src, 'imagePreviewWindow');

        pre.style.display = 'none';
        pre.parentNode?.insertBefore(img, pre.nextSibling);

        fetchImageAsBlob(src, (blob) => {
            if (blob) {
                const url = URL.createObjectURL(blob);
                img.src = url;
                img.alt = alt;
                img.onload = () => URL.revokeObjectURL(url);
            } else {
                img.alt = `[Failed] ${alt}`;
                img.style.border = "2px dashed #d93025";
            }
        });
    }

    function scan() {
        document.querySelectorAll('ms-chat-turn pre, .prompt-textarea pre').forEach(processPreElement);
    }

    // =========================================
    // 初期化
    // =========================================
    let timer = null;
    new MutationObserver(() => {
        clearTimeout(timer);
        timer = setTimeout(scan, 300);
    }).observe(document.body, { childList: true, subtree: true });

    loadImageCache().then(() => {
        setTimeout(scan, 500);
    });

})();