なにこれ
ブラウザ動作するAIチャットに自前で用意した顔グラフィックとか一枚絵を使わせたり
ローカルStableDiffusionの画像生成をさせて表示したい
っていうツール
使用例
こんな感じで、チャットAI任せで組ませたプロンプトにより、チャットAIが文字列を差し込んだ場所にリアルタイムに画像生成される
絵柄はローカルのモデル次第。ポン出しによる指の破綻とか気にしない人向け
更新履歴
- 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は勝手にできる
画像形式
webpにしか対応してない
サーバ起動
一番下にあるサーバープログラムをメモ帳などで保存して動作させる
- Python 3.8以上が必要。依存ライブラリは
requests
だけ。 -
自己署名証明書が必要。
cert/server.pem
に配置。server.pem
の作り方がわからなければAIに聞け。keyとcertを一つにまとめたpemファイルが必要
最速で済ませたいなら↓のコマンドを叩け:
スマホからアクセスするなら、証明書作成時にサーバーのIPアドレスを含める作り方をする必要がある
PCで動作確認した後で作りなおしてもいい
サーバーコードのALLOWED_IPSのところにアクセスする端末のIPアドレスの記入も必要 - サーバを起動:
バッチファイルを作るか、コマンドラインで
TamperMonkeyスクリプト導入
- TamperMonkeyをブラウザに入れる(ChromeかFirefox)
- 下のスクリプトをユーザースクリプトとしてコピペ
- スクリプト冒頭の
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は以下のように有効にしてやらないと動かない
強調構文でない () つきタグの対応
キャラタグにはときどき (作品名) がつくことがある(例: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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | // ==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\ude84《prompt:\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はエラー除けで、なくても動く
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | 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()
|