なにこれ

ブラウザ動作するAIチャットに自前で用意した顔グラフィックとか一枚絵を使わせたり
ローカルStableDiffusionの画像生成をさせて表示したい
っていうツール

使用例
こんな感じで、チャットAI任せで組ませたプロンプトにより、チャットAIが文字列を差し込んだ場所にリアルタイムに画像生成される
絵柄はローカルのモデル次第。ポン出しによる指の破綻とか気にしない人向け
sample1

更新履歴

  • 6/9 公開
  • 6/10 素のパープレ公式だと動かないことに気が付いて注意書き追加。拡張機能complexityが必要。素で動くようにコード修正はしない。
  • 6/22 https://rentry.co/e5fygm7y 別ページでTamperMonkeyスクリプトを更新。環境によっては不安定かもしれないので別に置く
    • プロンプト可視/不可視モードを設定可能にした
    • 動作を軽くしようと思ってスキャンのやり方を変えてるけどあまり変わらん
  • 6/28 サーバープログラム更新。キャラタグの作品名などにつける(xxx)をサーバー側で\(xxx\)にエスケープする処理を追加
  • 7/25 サーバープログラム更新。上記エスケープ処理でNegpipのマイナス強度が使えなくなっていた不具合を修正

免責事項

本ソフトウェアは無保証で提供されています。
使用によって生じたいかなる損害についても、開発者は一切の責任を負いません。
使用者の責任においてご利用ください。
素人がAI任せで作ったサーバーなんかそのまま使って大丈夫か?


動作イメージ

チャット画面に特定の文字列が現れると動作
📷《misaki/smile.webp》 → misakiフォルダのsmile.webp画像を表示

🪄《prompt: 1girl, brown hair, milf, mature female, dress shirt, seductive smile | filename: 2025-06-08/hogehoge.webp》
→ すぐに画像を生成して出来上がり次第表示(既存のファイル名なら生成せずに表示)

「ブラウザ拡張で文字列を画像に置き換えるユーザースクリプト」と
「リクエストされた画像がなければ、その場で生成して返すサーバー」の2本立て構成


動作確認環境

Windows11上のChrome、Android版Firefox
ChatGPTとパープレの公式サイト ※6/10 追記:パープレはcomplexityという拡張を入れていること前提のコードです
※他のサイトの場合はスクリプトを少し弄れば動作するはず
Stable Diffusion Web UI Forge 旧版(reForgeとかで動くかは知らない)
RTX3060(12GB)


説明書

注意書き

ほぼChatGPTによって生成されています。製作者は内容を深く把握してません
この説明書もだいたいChatGPT製
そのまま動くものではないのでとりあえずコードをAIに見せて教えを乞えばいい
StableDiffusionを既に動かしてる人でも、導入までエラーと格闘しながら数時間かかると思う

不具合

動けばOKの適当なサーバーなので自己証明書エラーが何度でも出る
その都度ブラウザでサーバーURLに直接アクセスし、エラーを出させて許可する手順が必要

セッションが長くなると画面表示が重くなる。スマホはストリーミング表示しなくなる

フォルダ構成

cert,presets,publicはこの名前でフォルダを作らないとエラーで止まります
generateは勝手にできる

1
2
3
4
5
6
7
8
9
your_project/
├── https_server_sdapi.py        HTTPS画像生成サーバ。このページの下のほうにコードがあるよ
├── cert/
   └── server.pem               自己署名証明書
├── presets/
   └── default.json             StableDiffusionのpayload 複数使い分けると便利
└── public/                      画像ファイルの保存兼参照場所
       ├── generate/               🪄生成のときスクリプトが自動生成するフォルダ
       └── キャラ名/                表情画像などを分類する任意のフォルダ

画像形式

webpにしか対応してない


サーバ起動

一番下にあるサーバープログラムをメモ帳などで保存して動作させる

  1. Python 3.8以上が必要。依存ライブラリは requests だけ。
  2. 自己署名証明書が必要。cert/server.pem に配置。

    server.pemの作り方がわからなければAIに聞け。keyとcertを一つにまとめたpemファイルが必要
    最速で済ませたいなら↓のコマンドを叩け:

    openssl req -new -x509 -days 3650 -nodes -out cert/server.pem -keyout cert/server.pem
    

    スマホからアクセスするなら、証明書作成時にサーバーのIPアドレスを含める作り方をする必要がある
    PCで動作確認した後で作りなおしてもいい
    サーバーコードのALLOWED_IPSのところにアクセスする端末のIPアドレスの記入も必要

  3. サーバを起動:
    バッチファイルを作るか、コマンドラインで
    python https_server_sdapi.py
    

TamperMonkeyスクリプト導入
  1. TamperMonkeyをブラウザに入れる(ChromeかFirefox)
  2. 下のスクリプトをユーザースクリプトとしてコピペ
  3. スクリプト冒頭の BASE_URL をサーバーPCのIPに合わせる(同PCでしか表示しないなら "https://localhost:ポート番号/" でOK)

Stable Diffusionの設定

画像の保存形式はwebpにする

「Stable Diffusionの--api起動とpayloadの説明」でAIに聞いて、API受付モードで起動し、
一度コマンドラインからAPI生成を試してみることを推奨
そのときに作ったpayloadをdefault.jsonとしてpresetsフォルダに置く

実際に使ってるpayloadの例
DMD2で高速化する前提の設定
ワイルドカード機能で__baseprompt__から<lora:DMD2:1> masterpiece,bestquality等に置換
NegPiPは以下のように有効にしてやらないと動かない

{
    "prompt": "__baseprompt__",
    "width": 768,
    "height": 768,
    "sampler_name": "Euler A SGMUniform",
    "steps": 6,
    "cfg_scale": 1.0,
    "alwayson_scripts": {
        "NegPiP": {"args": [true]}
    }
}
強調構文でない () つきタグの対応

キャラタグにはときどき (作品名) がつくことがある(例:rem (re:zero))が、sdでは\(xxx\)にエスケープが必要
しかしチャットに指示して\(xxx\)を書かせてもマークダウン処理されて消えてしまい、意図した結果を得られないことがある
その対策として(xxx)をサーバー側で\(xxx\)にエスケープ処理する。(xxx:1.2)などは触らない。()内の":数値"の有無で判断

Dynamic Promptsのワイルドカード対応について

ChatGPTのチャット欄では、__abc__ のような表記がマークダウンの下線扱いになってしまい、意図したとおりに表示されない。
そのせいで、Dynamic Prompts のワイルドカード置換定義 __キャラ名__ などがうまく使えない問題がある。

この問題を避けるため、サーバー側では
[abc]__abc__ に自動で置換する仕組みを入れている。
ワイルドカードを使いたいときは、チャット欄で __キャラ名__ ではなく [キャラ名] と書く

その代わり、[value1:value2:steps] のようなスケジュール構文は使えなくなっている


チャットAIへの使わせ方

実例を見せるのが一番早い
説明例

📷《キャラ名/表情名.webp》 で顔グラフィックを表示できます。実装してある表情はneutral,smile,surprised~
例:📷《misaki/smile.webp》 

これだけでファイル名で察して高い精度で使ってくれるはず

🪄《prompt: 1girl, brown hair, milf, mature female, dress shirt, seductive smile | filename: 2025-06-08/hogehoge001.webp》 
適宜このような文字列を使って場を盛り上げて下さい

これでだいたい通じる。「画像生成」というワードを出すと公式サービスがしゃしゃり出てきやすいので注意

🪄《prompt: ... | filename: ... | preset: superwide.json》
presetオプションで生成設定を切り替えることができます。画像の縦横比をdefault,wide,tall,superwideから選択できます。拡張子jsonを含めて指定して下さい

あらかじめpayloadのjsonファイルを複数作っておくと、こういう指示で切り替えできる。モデル切り替えもできるっぽい


画像の表示サイズ

  • 変えたければスクリプトの max-width:〇px が並んでるところを弄る
  • 📷モード
    • 顔グラフィック用に、標準では小さめに表示。幅200px(PC)/150px(スマホ)
    • ファイル名にcg_つきの画像は幅500px(PC)/70%(スマホ)で表示
    • ファイル名に_wideつきの画像は幅700px(PC)/100%(スマホ)で表示
  • 🪄モード
    • 生成画像のアスペクト比が6:5以上に横長だったら幅大きめに表示

以下コード本体(2ブロックあるよ)

TanperMonkeyスクリプト

ChatGPTとパープレ以外で使うなら@matchにURLを追加して、 CONTAINER_FILTER_MODE = "off" にすればとりあえず動く

CONTAINER_FILTER_MODE = "strict" のとき
ChatGPTとパープレのアンサー欄のテキストだけを置換対象にする条件を ALLOWED_PARENT_SELECTORS に列挙してる
他サイトでも変なところを置換されないよう置換対象を制限した方が安心かも

6/10 追記:パープレ用の記述(data-cplx-component="message-block-answer")は、complexityという拡張を入れているときのみ有効。 素のパープレで動くようにする設定は難しそうで、私は困ってないからやりません。
最新コードはこっち https://rentry.co/e5fygm7y

// ==UserScript==
// @name         AIChat Image Replacer + GenImage Trigger (v118)
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @match        https://www.perplexity.ai/*
// @version      v118
// @grant        none
// ==/UserScript==

(function () {
  const BASE_URL = "https://192.168.1.XXX:8443/";
  const CONTAINER_FILTER_MODE = "strict"; // "strict" | "off"
  const ALLOWED_PARENT_SELECTORS = [
    '[data-message-author-role="assistant"]',
    '[data-cplx-component="message-block-answer"]'
  ];

  const cameraRegex = /\ud83d\udcf7([\w\-/]+\.webp)/gi;
  const genImageRegex = /\ud83e\ude84prompt:\s*([^|]+?)\s*\|\s*filename:\s*([\w\-/]+\.webp)(?:\s*\|\s*preset:\s*([\w\-.]+))?\s*\u300b/gi;

  const isInputElement = (node) => {
    if (!node || !node.parentNode) return false;
    let current = node.parentNode;
    while (current && current !== document.body) {
      if (current.contentEditable === 'true') return true;
      const tagName = current.tagName;
      if (["INPUT", "TEXTAREA", "SELECT"].includes(tagName)) return true;
      if (current.getAttribute && (
        current.getAttribute("contenteditable") === "true" ||
        current.classList.contains("ProseMirror") ||
        current.id === "prompt-textarea" ||
        current.getAttribute("data-testid") === "textbox"
      )) return true;
      current = current.parentNode;
    }
    return false;
  };

  const isInsideCodeBlock = (node) => {
    let current = node.parentNode;
    while (current && current !== document.body) {
      const tag = current.tagName;
      const classAttr = current.getAttribute?.("class") || "";
      const isCodeTag = tag === "CODE" || tag === "PRE";
      const isMarkdownCode = classAttr.includes("markdown") && (classAttr.includes("code") || classAttr.includes("highlight"));
      if (isCodeTag || isMarkdownCode) return true;
      current = current.parentNode;
    }
    return false;
  };

  const isInsideAllowedContainer = (node) => {
    if (CONTAINER_FILTER_MODE === "off") return true;
    if (!node || !node.parentNode) return false;
    let current = node.parentNode;
    while (current && current !== document.body) {
      if (ALLOWED_PARENT_SELECTORS.some(sel => current.matches?.(sel))) {
        return true;
      }
      current = current.parentNode;
    }
    return false;
  };

  const replaceTextNode = (node) => {
    cameraRegex.lastIndex = 0;
    if (node.nodeType !== Node.TEXT_NODE || !cameraRegex.test(node.textContent) ||
        isInputElement(node) || isInsideCodeBlock(node) || !isInsideAllowedContainer(node)) {
      return false;
    }

    const span = document.createElement("span");
    const parts = node.textContent.split(cameraRegex);

    for (let i = 0; i < parts.length; i++) {
      if (i % 2 === 0) {
        span.appendChild(document.createTextNode(parts[i]));
      } else {
        const filename = parts[i];
        const url = BASE_URL + filename;
        const baseName = filename.split("/").pop();
        const isCG = baseName.startsWith("cg_");
        const isWide = baseName.includes("_wide");
        const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);

        const img = document.createElement("img");
        img.src = url;
        img.alt = `\u26a0\ufe0f ${filename}`;
        img.style.verticalAlign = "middle";

        const style = isCG
          ? (isWide
            ? (isMobile ? "max-width:100%; margin:8px 0;" : "max-width:700px; margin:8px 0;")
            : (isMobile ? "max-width:70%; margin:8px 0;" : "max-width:500px; margin:8px 0;"))
          : (isMobile ? "max-width:150px; margin:4px 0;" : "max-width:200px; margin:4px 0;");
        img.style.cssText += style;

        const wrapper = document.createElement("span");
        wrapper.style.display = "inline-block";
        wrapper.style.marginRight = "8px";
        wrapper.appendChild(img);
        span.appendChild(wrapper);
      }
    }

    node.parentNode.replaceChild(span, node);
    return true;
  };

  const getVisibleTextNodes = (root) => {
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode: (node) =>
        isInputElement(node) || isInsideCodeBlock(node) || !isInsideAllowedContainer(node)
          ? NodeFilter.FILTER_REJECT
          : NodeFilter.FILTER_ACCEPT
    });
    const textNodes = [];
    let node;
    while ((node = walker.nextNode())) {
      textNodes.push(node);
    }
    return textNodes;
  };

  const searchAndReplaceGenImageTriggers = (root) => {
    genImageRegex.lastIndex = 0;
    const nodes = getVisibleTextNodes(root);
    const fullText = nodes.map(n => n.textContent).join('');
    const replacements = [];
    let match;
    while ((match = genImageRegex.exec(fullText)) !== null) {
      const prompt = match[1];
      const filename = match[2];
      const preset = match[3];
      const wandOffset = match.index + match[0].indexOf("\ud83e\ude84");
      replacements.push({ wandOffset, filename, prompt, preset });
    }

    let offset = 0;
    const pending = [];

    for (const node of nodes) {
      const nodeStart = offset;
      const nodeEnd = offset + node.textContent.length;
      for (const repl of replacements) {
        if (!repl.done && nodeStart <= repl.wandOffset && repl.wandOffset < nodeEnd) {
          const local = repl.wandOffset - nodeStart;
          pending.push({ node, localOffset: local, filename: repl.filename, prompt: repl.prompt, preset: repl.preset });
          repl.done = true;
        }
      }
      offset = nodeEnd;
    }

    for (const { node, localOffset, filename, prompt, preset } of pending) {
      const text = node.textContent;
      const fullTextArray = Array.from(text);
      const before = fullTextArray.slice(0, localOffset).join('');
      const after = fullTextArray.slice(localOffset + 1).join('');
      const baseName = filename.split("/").pop();

      let url = `${BASE_URL}generate/${filename}?gen_image=1&prompt=${encodeURIComponent(prompt)}`;
      if (preset) {
        url += `&preset=${encodeURIComponent(preset)}`;
      }

      const img = document.createElement("img");
      img.src = url;
      img.alt = `\ud83e\ude84 generate/${filename}`;
      img.style = `vertical-align:middle; background-color:#e0f7fa; border:1px solid #fcfcfc; min-width:200px; min-height:200px;`;
      img.style.visibility = "hidden";
      const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
      img.style.maxWidth = isMobile ? "70%" : "500px";

      img.onload = () => {
        img.style.backgroundColor = "transparent";
        img.style.border = "none";
        const aspect = img.naturalWidth / img.naturalHeight;
        const isWide = aspect >= 1.2;
        const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
        img.style.maxWidth = isWide ? (isMobile ? "100%" : "700px") : (isMobile ? "70%" : "500px");
        img.style.margin = "8px 0";
    img.style.visibility = "visible";
      };

      img.onerror = () => {
        img.style.backgroundColor = "#fee";
        img.style.border = "1px solid red";
        img.alt = "\u274c failed to load image";
      };

      const span = document.createElement("span");
      span.style.display = "inline-block";
      span.style.marginRight = "8px";
      span.appendChild(img);

      const parent = node.parentNode;
      if (!parent) continue;
      parent.insertBefore(document.createTextNode(before), node);
      parent.insertBefore(span, node);
      parent.insertBefore(document.createTextNode(after), node);
      parent.removeChild(node);
    }
  };

  const scan = (root) => {
    const nodes = getVisibleTextNodes(root);
    nodes.forEach(n => replaceTextNode(n));
  };

  const THROTTLE_DELAY = 500;
  const SCAN_INTERVAL = 3000;
  const SCAN_COUNT = 20;
  let activeCount = SCAN_COUNT;
  let intervalId = setInterval(() => {
    if (activeCount <= 0) {
      clearInterval(intervalId);
      intervalId = null;
      return;
    }
    scan(document.body);
    setTimeout(() => searchAndReplaceGenImageTriggers(document.body), 500);
    activeCount--;
  }, SCAN_INTERVAL);

  const restartInterval = () => {
    if (intervalId !== null) return;
    activeCount = SCAN_COUNT;
    intervalId = setInterval(() => {
      if (activeCount <= 0) {
        clearInterval(intervalId);
        intervalId = null;
        return;
      }
      scan(document.body);
      setTimeout(() => searchAndReplaceGenImageTriggers(document.body), 500);
      activeCount--;
    }, SCAN_INTERVAL);
  };

  const throttle = (func, delay) => {
    let timerFlag = null;
    return (...args) => {
      if (timerFlag === null) {
        func(...args);
        timerFlag = setTimeout(() => { timerFlag = null; }, delay);
      }
    };
  };

  const throttledScan = throttle((node) => {
    scan(node);
    restartInterval();
  }, THROTTLE_DELAY);

  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === Node.ELEMENT_NODE) {
          throttledScan(node);
        }
      });
    });
  });

  observer.observe(document.body, { childList: true, subtree: true });
  window.addEventListener("load", () => {
    scan(document.body);
    setTimeout(() => searchAndReplaceGenImageTriggers(document.body), 1000);
  });
})();

サーバープログラム

メモ帳などでhttps_server_sdapi.pyとして保存(名前はなんでもいい)
アクセス許可IPを増やすなら ALLOWED_IPS = ['127.0.0.1', '192.168.1.130', '192.168.1.131'] みたいに列挙
Do_Getメソッドだけが核で、残りはAIが秒で出力した平凡なサーバー
do_OPTIONSはエラー除けで、なくても動く

import os
import ssl
import urllib.parse
import json
import re
import base64
import requests
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler


# === 設定 ===
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PUBLIC_DIR = os.path.join(BASE_DIR, "public")
CERT_PATH = os.path.join(BASE_DIR, "cert", "server.pem")
PRESET_DIR = os.path.join(BASE_DIR, "presets")
ALLOWED_IPS = ['127.0.0.1'] # アクセス許可するIPを列挙する。自分のPCとスマホ。localhost宛てでしかアクセスしない人は127.0.0.1だけでいい

DEFAULT_PRESET_PATH = os.path.join(PRESET_DIR, "default.json")

# 公開ディレクトリに移動
os.chdir(PUBLIC_DIR)

# プロンプト文字列処理 強調構文以外の "(xxx)"  "\(xxx\)" にエスケープ。":数値" の有無で判断
def escape_plain_paren(text):
    pattern = re.compile(r'\(([^()]+?)\)')
    i = 0
    while True:
        def replacer(m):
            inner = m.group(1)
            # 強調構文の判定:末尾が :数字 or :数字.数字 マイナス数値もあり
            if re.search(r':-?[0-9]+(?:\.[0-9]+)?$', inner):
                return m.group(0)
            # 直前バックスラッシュ1個以上はスキップ
            if m.start() > 0 and text[m.start() - 1] == '\\':
                return m.group(0)
            # それ以外はエスケープ
            return f'\\({inner}\\)'
        new_text = pattern.sub(replacer, text)
        if new_text == text or i > 10:
            break
        text = new_text
        i += 1
    return text

class GenImageHandler(SimpleHTTPRequestHandler):
    def address_string(self):
        return self.client_address[0]

    def do_GET(self):
        client_ip = self.client_address[0]
        if client_ip not in ALLOWED_IPS:
            self.send_error(403, "Forbidden")
            return

        parsed = urllib.parse.urlparse(self.path)
        filename = parsed.path.lstrip("/")
        filepath = os.path.join(PUBLIC_DIR, filename)
        qs = urllib.parse.parse_qs(parsed.query)

        if not os.path.exists(filepath) and "gen_image" in qs and "prompt" in qs:
            prompt = qs["prompt"][0]
            print(f"[🪄] Generating {filename}")
            os.makedirs(os.path.dirname(filepath), exist_ok=True)

            try:
                preset_name = qs.get("preset", [None])[0]
                preset_path = os.path.join(PRESET_DIR, preset_name) if preset_name else DEFAULT_PRESET_PATH
                payload = {}

                if os.path.exists(preset_path):
                    with open(preset_path, "r", encoding="utf-8") as f:
                        payload = json.load(f)
                else:
                    print(f"[⚠️] Preset not found: {preset_path}, falling back to hardcoded minimum")
                    payload = {
                        "prompt": "__baseprompt__",
                        "width": 768,
                        "height": 768,
                        "sampler_name": "Euler A SGMUniform",
                        "steps": 6,
                        "cfg_scale": 1.0
                    }

                base_prompt = payload.get("prompt", "").strip()
                prompt = escape_plain_paren(prompt)
                user_prompt = re.sub(r'\[([^\[\]]+)\]', r'__\1__', prompt).strip()

                if base_prompt and user_prompt:
                    final_prompt = f"{base_prompt}, {user_prompt}"
                else:
                    final_prompt = base_prompt or user_prompt

                payload["prompt"] = final_prompt

                response = requests.post(
                    "http://127.0.0.1:7860/sdapi/v1/txt2img",
                    json=payload
                )
                image_data = base64.b64decode(response.json()["images"][0].split(",", 1)[-1])
                with open(filepath, "wb") as f:
                    f.write(image_data)
                print(f"[✅] Image generated via API: {filename}")
            except Exception as e:
                print(f"[❌] API generation failed: {e}")
                self.send_error(500, "Image generation failed")
                return

        if os.path.exists(filepath):
            return super().do_GET()
        else:
            self.send_error(404, "Not Found")

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "*")
        self.end_headers()

PORT = 8443
httpd = ThreadingHTTPServer(('0.0.0.0', PORT), GenImageHandler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=CERT_PATH, keyfile=CERT_PATH)
httpd.socket = context.wrap_socket(httpd.socket, server_side=True)

print(f"🧪 GenImage server running on https://0.0.0.0:{PORT}, allowed IPs: {ALLOWED_IPS}")
httpd.serve_forever()
Edit

Pub: 09 Jun 2025 15:54 UTC

Edit: 25 Jul 2025 09:15 UTC

Views: 811