diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml
new file mode 100644
index 0000000..b25c00c
--- /dev/null
+++ b/.github/workflows/publish-wiki.yml
@@ -0,0 +1,71 @@
+name: Publish Wiki
+
+on:
+ push:
+ branches:
+ - main
+ - master
+ paths:
+ - "docs/wiki/**"
+ - ".github/workflows/publish-wiki.yml"
+ workflow_dispatch: {}
+
+permissions:
+ contents: write
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Sync docs/wiki -> GitHub Wiki
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+
+ if [ ! -d "docs/wiki" ]; then
+ echo "::error::docs/wiki does not exist"
+ exit 1
+ fi
+
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+
+ wiki_repo="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.wiki.git"
+
+ rm -rf wiki
+ if ! git ls-remote "$wiki_repo" > /dev/null 2>&1; then
+ echo "::error::Unable to access wiki repo. Ensure Wiki is enabled in repository Settings -> Features -> Wiki."
+ exit 1
+ fi
+
+ mkdir -p wiki
+ cd wiki
+ git init
+ git remote add origin "$wiki_repo"
+ git fetch origin || true
+
+ if git show-ref --quiet refs/remotes/origin/master; then
+ git checkout -B master origin/master
+ elif git show-ref --quiet refs/remotes/origin/main; then
+ git checkout -B main origin/main
+ else
+ git checkout -B master
+ fi
+ cd ..
+
+ rsync -av --delete --exclude ".git" docs/wiki/ wiki/
+
+ cd wiki
+ git add -A
+ if git diff --cached --quiet; then
+ echo "No wiki changes to publish."
+ exit 0
+ fi
+
+ git commit -m "Sync wiki from docs/wiki (${GITHUB_SHA})"
+ git push origin HEAD
+
diff --git a/.gitignore b/.gitignore
index 33ee743..304e390 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,5 +16,7 @@ comfyworkflows_sharekey
github-stats-cache.json
pip_overrides.json
*.json
+# Allow version-controlled workflow examples / assets
+!assets/*.json
check2.sh
-/venv/
\ No newline at end of file
+/venv/
diff --git a/README.md b/README.md
index af1fd97..70781a7 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,15 @@ Simple, no-nonsense LLM chat nodes for ComfyUI. Connect to OpenAI, Claude, Gemin
- **Multimodal Support**: Chat with images using vision-capable models.
- **NoASS Roleplay Mode (Exp)**: Experimental Hardcore roleplay node with Assistant Prefill support.
- **Gemini Native Features**: Dedicated nodes for Gemini's image generation and editing capabilities.
+- **Template Variables (Mustache)**: Write `{{var}}` in any prompt/system field and inject values via a node.
+- **JSON Field Extractor**: Parse JSON and split values into multiple outputs (fixed 8 ports).
+- **Markdown Preview**: Render markdown output in a safe, sanitized popup.
+
+## Wiki (GitHub Native)
+
+- **Wiki Home**: `https://github.com/Moeblack/ComfyUI-SimpleChat/wiki`
+- **Source of truth**: `docs/wiki/` (auto-published by GitHub Actions)
+- **Note**: Ensure Wiki is enabled in your repo Settings -> Features -> Wiki
## Installation
@@ -56,6 +65,7 @@ Use this for standard text generation or Q&A.
* **Connection**: Connect `config` from **API Config** to `config` on **Chat**.
* **Input**: Enter your prompt in `text`.
* **Output**: Returns the AI's response as a string.
+* **Template vars (optional)**: Connect `Mustache Var` output `vars` to `Chat` input `vars`, then you can use `{{var}}` in `prompt/system` and it will be replaced.
### 3. Chat with Images
Use this to have the AI analyze or describe an image.
@@ -89,6 +99,43 @@ Modify existing images using text instructions.
* **Node**: `Gemini Image Edit`
* **Input**: Source image + Mask image + Prompt.
+### 7. JSON Parse
+Parse an LLM-produced JSON string and extract up to 8 fields.
+
+* **Node**: `JSON Parse`
+* **Inputs**:
+ * `json_text`: JSON string (can auto-extract from ```json ... ``` fences)
+ * `path1..path8`: dot/bracket paths like `a.b.c` or `items[0].name`
+* **Outputs**: `out1..out8` + `obj` (the full parsed object)
+
+### 7.1 Prompt JSON Unpack (recommended)
+If your LLM outputs a fixed JSON schema (positive/negative/width/height/steps/cfg/sampler/seed/notes), use this node to unpack everything with **proper typed outputs** (INT/FLOAT/SAMPLER) so you can wire directly into ComfyUI nodes without manual paths or casting.
+
+* **Node**: `Prompt JSON Unpack`
+* **Input**: `json_text` (can auto-extract from ```json ... ``` fences)
+* **Outputs**:
+ * `positive` / `negative` (STRING)
+ * `width` / `height` / `steps` / `seed` (INT)
+ * `cfg` (FLOAT)
+ * `sampler` (SAMPLER)
+ * `notes` (STRING)
+ * `vars` (SIMPLECHAT_VARS mapping for Mustache: `{{positive}}`, `{{width}}`, etc.)
+
+### 8. Markdown Preview
+Preview markdown content in a popup (sanitized).
+
+* **Node**: `Markdown Preview`
+* **Usage**: Connect any text output to `text`, execute to open the preview popup.
+
+### 9. Anima prompt template (docs)
+If you use SimpleChat to have an LLM generate Anima prompts, see:
+
+* `docs/anima_prompt.md`
+* `docs/anima_scheme.md` (JSON fields + built-in Mustache vars scheme)
+
+(Optional) Example workflow:
+* `assets/AnimaPromptScheme.json`
+
## FAQ
**Q: I don't see my model in the list.**
diff --git a/README_CN.md b/README_CN.md
index 9f8a23e..b93d92e 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -16,6 +16,15 @@ ComfyUI 的极简 LLM 聊天节点。轻松连接 OpenAI, Claude, Gemini 以及
- **多模态支持**: 使用具备视觉能力的模型与图片进行对话。
- **NoASS 角色扮演模式 (实验性)**: 专为硬核角色扮演设计的节点,支持 Assistant Prefill (预填) 技术。
- **Gemini 原生功能**: 专为 Gemini 设计的文生图和图片编辑节点。
+- **模板变量 (Mustache)**: 在 prompt/system 等任意文本里写 `{{变量名}}`,通过节点统一注入替换。
+- **JSON 解析拆字段**: 将 JSON 自动解析并按 path 输出到多个端口(固定 8 路)。
+- **Markdown 渲染预览**: 将文本以 Markdown 方式弹窗渲染展示(已做净化,避免脚本注入)。
+
+## Wiki(GitHub 原生)
+
+- **Wiki 首页**:`https://github.com/Moeblack/ComfyUI-SimpleChat/wiki`
+- **仓库内维护源**:`docs/wiki/`(由 GitHub Actions 自动同步发布)
+- **注意**:需要在 GitHub 仓库 Settings -> Features -> 勾选 Wiki
## 安装 (Installation)
@@ -56,6 +65,7 @@ git clone https://github.com/Moeblack/ComfyUI-SimpleChat.git
* **连接**: 将 **API Config** 的 `config` 输出连接到 **Chat** 的 `config` 输入。
* **输入**: 在 `text` 中输入您的提示词。
* **输出**: 返回 AI 的回复字符串。
+* **模板变量(可选)**: 将 `Mustache Var` 的 `vars` 输出接到 `Chat` 的 `vars` 输入后,`prompt/system` 中支持写 `{{变量名}}` 并自动替换。
### 3. 图片对话 (Chat with Images)
让 AI 分析或描述图片。
@@ -89,6 +99,43 @@ git clone https://github.com/Moeblack/ComfyUI-SimpleChat.git
* **节点**: `Gemini Image Edit`
* **输入**: 原图 (Source image) + 遮罩 (Mask image) + 提示词 (Prompt)。
+### 7. JSON 解析拆字段 (JSON Parse)
+把 LLM 输出的 JSON 自动解析成多个字段,便于接入后续节点。
+
+* **节点**: `JSON Parse`
+* **输入**:
+ * `json_text`: JSON 字符串(支持从 ```json ... ``` 代码块中自动提取)
+ * `path1..path8`: 取值路径(支持 `a.b.c` 与 `items[0].name`)
+* **输出**: `out1..out8` + `obj`(完整解析后的对象)
+
+### 7.1 Prompt JSON 拆包(强烈推荐)
+如果你的 LLM 输出遵循固定 JSON 格式(positive/negative/width/height/steps/cfg/sampler/seed/notes),推荐用这个节点“一键拆包”,并且 **width/height/steps/cfg/seed/sampler 都是带类型输出**,可直接接到 ComfyUI 的对应输入口(不用手动填 path,也不用字符串转数字)。
+
+* **节点**: `Prompt JSON Unpack`
+* **输入**: `json_text`(支持从 ```json ... ``` 代码块中自动提取)
+* **输出**:
+ * `positive` / `negative`(STRING)
+ * `width` / `height` / `steps` / `seed`(INT)
+ * `cfg`(FLOAT)
+ * `sampler`(SAMPLER)
+ * `notes`(STRING)
+ * `vars`(SIMPLECHAT_VARS:自动把这些字段映射成 `{{positive}}`/`{{width}}` 等胡子变量,另含中文别名)
+
+### 8. Markdown 渲染预览 (Markdown Preview)
+把文本以 Markdown 渲染的方式展示(弹窗),适合预览 LLM 的结构化输出/说明文档。
+
+* **节点**: `Markdown Preview`
+* **用法**: 把任意文本输出接到 `text`,执行后自动弹窗渲染。
+
+### 9. Anima 提示词模板(文档)
+如果你用 SimpleChat 让 LLM 帮你写 Anima 的提示词,推荐直接用这份 System Prompt:
+
+* `docs/anima_prompt.md`
+* `docs/anima_scheme.md`(JSON 字段 + 内置胡子变量命名方案)
+
+(可选)示例工作流:
+* `assets/AnimaPromptScheme.json`
+
## 常见问题 (FAQ)
**Q: 列表里找不到我要的模型。**
diff --git a/__init__.py b/__init__.py
index 7ce1eb3..53e59ef 100644
--- a/__init__.py
+++ b/__init__.py
@@ -11,6 +11,12 @@ from .nodes import (
SimpleChatNoASS,
GeminiImageGen,
GeminiImageEdit,
+ SimpleChatMustacheVar,
+ SimpleChatMustacheRender,
+ SimpleChatTextInput,
+ SimpleChatJsonParse,
+ SimpleChatMarkdownPreview,
+ SimpleChatPromptJsonUnpack,
)
# Register API routes
@@ -25,6 +31,12 @@ NODE_CLASS_MAPPINGS = {
"SimpleChatNoASS": SimpleChatNoASS,
"GeminiImageGen": GeminiImageGen,
"GeminiImageEdit": GeminiImageEdit,
+ "SimpleChatMustacheVar": SimpleChatMustacheVar,
+ "SimpleChatMustacheRender": SimpleChatMustacheRender,
+ "SimpleChatTextInput": SimpleChatTextInput,
+ "SimpleChatJsonParse": SimpleChatJsonParse,
+ "SimpleChatMarkdownPreview": SimpleChatMarkdownPreview,
+ "SimpleChatPromptJsonUnpack": SimpleChatPromptJsonUnpack,
}
# Display names for the UI
@@ -35,6 +47,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SimpleChatNoASS": "Chat NoASS",
"GeminiImageGen": "Gemini Image Gen",
"GeminiImageEdit": "Gemini Image Edit",
+ "SimpleChatMustacheVar": "Mustache Var",
+ "SimpleChatMustacheRender": "Mustache Render",
+ "SimpleChatTextInput": "Text Input",
+ "SimpleChatJsonParse": "JSON Parse",
+ "SimpleChatMarkdownPreview": "Markdown Preview",
+ "SimpleChatPromptJsonUnpack": "Prompt JSON Unpack",
}
# Expose web directory for JS extensions
diff --git a/assets/AnimaPromptScheme.json b/assets/AnimaPromptScheme.json
new file mode 100644
index 0000000..37de247
--- /dev/null
+++ b/assets/AnimaPromptScheme.json
@@ -0,0 +1,319 @@
+{
+ "id": "a6f6a4f0-9e1a-4c34-bcf9-8b2d9bb78c2a",
+ "revision": 0,
+ "last_node_id": 6,
+ "last_link_id": 5,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "SimpleChatConfig",
+ "pos": [
+ -1248.0,
+ 360.0
+ ],
+ "size": [
+ 270,
+ 178
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "config",
+ "type": "SIMPLECHAT_CONFIG",
+ "links": [
+ 1
+ ]
+ }
+ ],
+ "properties": {
+ "aux_id": "Moeblack/ComfyUI-SimpleChat",
+ "Node name for S&R": "SimpleChatConfig"
+ },
+ "widgets_values": [
+ "openai",
+ "",
+ "",
+ "refresh",
+ ""
+ ]
+ },
+ {
+ "id": 2,
+ "type": "SimpleChatText",
+ "pos": [
+ -820.0,
+ 360.0
+ ],
+ "size": [
+ 430,
+ 240
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "config",
+ "type": "SIMPLECHAT_CONFIG",
+ "link": 1
+ }
+ ],
+ "outputs": [
+ {
+ "name": "text",
+ "type": "STRING",
+ "links": [
+ 2
+ ]
+ }
+ ],
+ "properties": {
+ "aux_id": "Moeblack/ComfyUI-SimpleChat",
+ "Node name for S&R": "SimpleChatText"
+ },
+ "widgets_values": [
+ "在这里写你的绘画需求(建议包含:人数/角色外观/构图/光照/背景/画风/安全标签/画师@):\n\n示例:\n1girl, black long hair, red eyes, black trench coat, holding umbrella, rainy night neon street, cel shading + slight painterly, half body, cold mood. safe. artist ref: @xxx",
+ "你是“Anima(circlestone-labs/Anima)专用提示词工程师”,目标是在 ComfyUI 里用 Anima 生成高质量二次元/插画向图像(非写实、非摄影)。\n\n### 模型规则(必须遵守)\n1) 你必须输出“可直接粘贴到 ComfyUI”的提示词与参数建议(通过 JSON 字段给出)。\n2) Anima 支持 Danbooru 风格 tags + 自然语言混合输入;输出时以 tags 为主,自然语言必须至少 2 句(多人/复杂构图时更要写清楚)。\n3) Anima 标签顺序固定为:\n [质量/元数据/年份/安全标签] [人物数量] [角色名] [作品名] [画师] [一般描述标签]\n4) 画师标签必须以 @ 开头(例如 @xxx),否则影响很弱。\n5) 默认生成参数(可按用户需求微调):\n - 分辨率:约 1MP(如 1024x1024 / 896x1152 / 1152x896)\n - Steps:30–50\n - CFG:4–5\n - Sampler:优先 er_sde;也可 euler_a;想更“发散/创意”可 dpmpp_2m_sde_gpu\n6) 安全标签必须明确:safe / sensitive / nsfw / explicit;并在 negative 里加入相反约束(例如 positive 里是 safe,则 negative 要包含 nsfw, explicit)。\n7) 不要追求写实:避免 photo, realistic, 真实摄影、皮肤毛孔等措辞;应强调 illustration, anime style, painterly, lineart 等。\n8) 若用户想要非二次元风格:positive 第一行写 dataset tag(ye-pop 或 deviantart),换行后再给标题/描述,再给正常标签行。\n\n### 输出格式(必须是 JSON,且不要夹带额外解释文字)\n输出一个 JSON 对象,键固定如下:\n{\n \"positive\": \"...\",\n \"negative\": \"...\",\n \"width\": 1024,\n \"height\": 1024,\n \"steps\": 40,\n \"cfg\": 4.5,\n \"sampler\": \"er_sde\",\n \"seed\": -1,\n \"notes\": \"1-3条简短技巧/提醒\"\n}\n\n### notes 里要写的技巧(1-3 条,短句)\n- 用年份/时期控制画风:year 2025 / newest / recent / mid / early / old\n- 用质量标签:masterpiece / best quality 或 score_9..score_1(可混用)\n- 角色外观要说清:发色发型、眼睛、服装、表情、姿态、镜头、光照、背景元素\n- 多人时逐个描述,避免只堆角色名\n- Tag dropout 存在:不必塞满所有标签,但关键标签必须有\n\n现在开始:根据用户输入生成 Anima 提示词 JSON。",
+ 1,
+ 2048
+ ]
+ },
+ {
+ "id": 3,
+ "type": "SimpleChatPromptJsonUnpack",
+ "pos": [
+ -330.0,
+ 360.0
+ ],
+ "size": [
+ 420,
+ 300
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "json_text",
+ "type": "STRING",
+ "link": 2
+ }
+ ],
+ "outputs": [
+ {
+ "name": "positive",
+ "type": "STRING",
+ "links": [
+ 3
+ ]
+ },
+ {
+ "name": "negative",
+ "type": "STRING",
+ "links": [
+ 4
+ ]
+ },
+ {
+ "name": "width",
+ "type": "INT",
+ "links": null
+ },
+ {
+ "name": "height",
+ "type": "INT",
+ "links": null
+ },
+ {
+ "name": "steps",
+ "type": "INT",
+ "links": null
+ },
+ {
+ "name": "cfg",
+ "type": "FLOAT",
+ "links": null
+ },
+ {
+ "name": "sampler",
+ "type": "SAMPLER",
+ "links": null
+ },
+ {
+ "name": "seed",
+ "type": "INT",
+ "links": null
+ },
+ {
+ "name": "notes",
+ "type": "STRING",
+ "links": [
+ 5
+ ]
+ },
+ {
+ "name": "vars",
+ "type": "SIMPLECHAT_VARS",
+ "links": null
+ },
+ {
+ "name": "obj",
+ "type": "SIMPLECHAT_JSON",
+ "links": null
+ },
+ {
+ "name": "sampler_name",
+ "type": "STRING",
+ "links": null
+ }
+ ],
+ "properties": {
+ "aux_id": "Moeblack/ComfyUI-SimpleChat",
+ "Node name for S&R": "SimpleChatPromptJsonUnpack"
+ },
+ "widgets_values": [
+ "",
+ true
+ ]
+ },
+ {
+ "id": 4,
+ "type": "PreviewAny",
+ "pos": [
+ 160.0,
+ 240.0
+ ],
+ "size": [
+ 300,
+ 230
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 3
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 5,
+ "type": "PreviewAny",
+ "pos": [
+ 160.0,
+ 500.0
+ ],
+ "size": [
+ 300,
+ 230
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 4
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 6,
+ "type": "SimpleChatMarkdownPreview",
+ "pos": [
+ 160.0,
+ 770.0
+ ],
+ "size": [
+ 320,
+ 210
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "text",
+ "type": "STRING",
+ "link": 5
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "aux_id": "Moeblack/ComfyUI-SimpleChat",
+ "Node name for S&R": "SimpleChatMarkdownPreview"
+ },
+ "widgets_values": [
+ ""
+ ]
+ }
+ ],
+ "links": [
+ [
+ 1,
+ 1,
+ 0,
+ 2,
+ 0,
+ "SIMPLECHAT_CONFIG"
+ ],
+ [
+ 2,
+ 2,
+ 0,
+ 3,
+ 0,
+ "STRING"
+ ],
+ [
+ 3,
+ 3,
+ 0,
+ 4,
+ 0,
+ "*"
+ ],
+ [
+ 4,
+ 3,
+ 1,
+ 5,
+ 0,
+ "*"
+ ],
+ [
+ 5,
+ 3,
+ 8,
+ 6,
+ 0,
+ "STRING"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "frontendVersion": "1.33.10"
+ },
+ "version": 0.4
+}
+
diff --git a/core/template.py b/core/template.py
new file mode 100644
index 0000000..8c62f1d
--- /dev/null
+++ b/core/template.py
@@ -0,0 +1,47 @@
+"""
+Template utilities for SimpleChat.
+
+Currently provides a lightweight Mustache-style replacement:
+ - Replace {{ key }} with vars[key] (stringified).
+ - Unmatched placeholders are left as-is by default (easier debugging).
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any, Mapping
+
+
+_MUSTACHE_RE = re.compile(r"\{\{\s*([^\{\}\n]+?)\s*\}\}")
+
+
+def render_mustache(text: str, vars: Mapping[str, Any] | None, *, keep_unmatched: bool = True) -> str:
+ """
+ Render {{var}} placeholders in text using vars mapping.
+
+ Args:
+ text: Input text (any string field).
+ vars: Mapping of variable name -> value. Values will be stringified.
+ keep_unmatched: If True, keep unknown placeholders like {{foo}} unchanged.
+ """
+ if not isinstance(text, str) or not text:
+ return text
+
+ # Fast path: avoid regex unless needed
+ if "{{" not in text or "}}" not in text:
+ return text
+
+ if not vars or not isinstance(vars, Mapping):
+ return text
+
+ def _repl(match: re.Match) -> str:
+ key = match.group(1).strip()
+ if not key:
+ return match.group(0) if keep_unmatched else ""
+ if key in vars:
+ value = vars.get(key)
+ return "" if value is None else str(value)
+ return match.group(0) if keep_unmatched else ""
+
+ return _MUSTACHE_RE.sub(_repl, text)
+
diff --git a/docs/anima_prompt.md b/docs/anima_prompt.md
new file mode 100644
index 0000000..bfb66ef
--- /dev/null
+++ b/docs/anima_prompt.md
@@ -0,0 +1,101 @@
+# Anima(CircleStone Labs × Comfy Org)提示词生成模板(给 SimpleChat 用)
+
+> 目标:让 LLM 帮你把“想画什么”转换成 **Anima 可用的 Danbooru 标签 + 自然语言混合提示词**,并附带推荐生成参数。
+>
+> 模型特点:偏二次元/插画,不擅长写真。建议 1MP 分辨率附近(如 1024×1024)。
+
+---
+
+## 1) 直接可用的 System Prompt(强制:只输出一个 JSON,便于工作流“拆字段”)
+
+把下面内容粘贴到 `SimpleChat -> Chat` 节点的 `system` 输入里即可:
+
+```text
+你是“Anima(circlestone-labs/Anima)专用提示词工程师”,目标是在 ComfyUI 里用 Anima 生成高质量二次元/插画向图像(非写实、非摄影)。
+
+### 模型规则(必须遵守)
+1) 你必须输出“可直接粘贴到 ComfyUI”的提示词与参数建议(通过 JSON 字段给出)。
+2) Anima 支持 Danbooru 风格 tags + 自然语言混合输入;输出时以 tags 为主,自然语言必须至少 2 句(多人/复杂构图时更要写清楚)。
+3) Anima 标签顺序固定为:
+ [质量/元数据/年份/安全标签] [人物数量] [角色名] [作品名] [画师] [一般描述标签]
+4) 画师标签必须以 @ 开头(例如 @xxx),否则影响很弱。
+5) 默认生成参数(可按用户需求微调):
+ - 分辨率:约 1MP(如 1024x1024 / 896x1152 / 1152x896)
+ - Steps:30–50
+ - CFG:4–5
+ - Sampler:优先 er_sde;也可 euler_a;想更“发散/创意”可 dpmpp_2m_sde_gpu
+6) 安全标签必须明确:safe / sensitive / nsfw / explicit;并在 negative 里加入相反约束(例如 positive 里是 safe,则 negative 要包含 nsfw, explicit)。
+7) 不要追求写实:避免 photo, realistic, 真实摄影、皮肤毛孔等措辞;应强调 illustration, anime style, painterly, lineart 等。
+8) 若用户想要非二次元风格:positive 第一行写 dataset tag(ye-pop 或 deviantart),换行后再给标题/描述,再给正常标签行。
+
+### 输出格式(必须是 JSON,且不要夹带额外解释文字)
+输出一个 JSON 对象,键固定如下:
+{
+ "positive": "...",
+ "negative": "...",
+ "width": 1024,
+ "height": 1024,
+ "steps": 40,
+ "cfg": 4.5,
+ "sampler": "er_sde",
+ "seed": -1,
+ "notes": "1-3条简短技巧/提醒"
+}
+
+### 质量/年份/安全标签建议
+- 质量:masterpiece / best quality 或 score_9~score_1(二选一或都用)
+- 年份:year 2025 / newest / recent / mid / early / old
+- 安全:safe / sensitive / nsfw / explicit(按用户要求)
+
+### 负面提示词建议(按需挑选,不要堆太多)
+worst quality, low quality, blurry, bad anatomy, bad hands, extra fingers, missing fingers, text, watermark, logo, jpeg artifacts
+
+### notes 里要写的技巧(1-3 条,短句)
+- 用年份/时期控制画风:year 2025 / newest / recent / mid / early / old
+- 用质量标签:masterpiece / best quality 或 score_9..score_1(可混用)
+- 角色外观要说清:发色发型、眼睛、服装、表情、姿态、镜头、光照、背景元素
+- 多人时逐个描述,避免只堆角色名
+- Tag dropout 存在:不必塞满所有标签,但关键标签必须有
+
+现在开始:根据用户输入生成 Anima 提示词 JSON。
+```
+
+---
+
+## 2) 用户输入(User Prompt)怎么写更出片
+
+给 LLM 的用户输入建议包含这些维度(越具体越稳定):
+- **题材**:角色设计/概念图/封面插画/立绘/表情包风格等
+- **人物**:人数、性别、年龄段、发色/发型、服饰、表情、动作
+- **构图**:半身/全身、仰视/俯视、景别、镜头焦段感(如“广角夸张透视”)
+- **场景**:室内/室外、季节、时间、光照(柔光/逆光/霓虹)
+- **风格**:赛璐璐/厚涂/水彩/线稿/2.5D 等
+- **安全**:safe/nsfw/explicit(明确写)
+- **可选:画师**:必须带 `@`
+
+示例用户输入:
+
+```text
+画一张二次元角色设定插画:1girl,黑长直,红瞳,黑色风衣,手持雨伞,雨夜街头霓虹灯,赛璐璐+少量厚涂,情绪冷淡,半身构图。safe。画师参考:@xxx
+```
+
+---
+
+## 3) 生成“非二次元艺术风格”的特殊用法
+
+Anima 也支持额外数据集标签(放在第一行,换行后写标题/描述):
+
+```text
+ye-pop
+For Sale: Others by Arun Prem
+Abstract, oil painting of ...
+```
+
+或:
+
+```text
+deviantart
+Flame
+Digital painting of a fiery dragon ...
+```
+
diff --git a/docs/anima_scheme.md b/docs/anima_scheme.md
new file mode 100644
index 0000000..32749f2
--- /dev/null
+++ b/docs/anima_scheme.md
@@ -0,0 +1,87 @@
+# Anima Prompt Scheme(JSON + 内置胡子变量)
+
+本方案用于:**LLM 只输出一个 JSON** → ComfyUI 里用 `Prompt JSON Unpack` **一键拆字段** → 直接接入你的图像工作流(positive/negative/参数),同时自动得到一套可复用的 Mustache 变量。
+
+---
+
+## 1) LLM 必须输出的 JSON Schema(固定)
+
+> 你在 `docs/anima_prompt.md` 的 System Prompt 已经强制要求输出这个结构。
+
+```json
+{
+ "positive": "...",
+ "negative": "...",
+ "width": 1024,
+ "height": 1024,
+ "steps": 40,
+ "cfg": 4.5,
+ "sampler": "er_sde",
+ "seed": -1,
+ "notes": "1-3条简短技巧/提醒"
+}
+```
+
+---
+
+## 2) `Prompt JSON Unpack` 的输出(端口)
+
+- **positive/negative**: `STRING`
+- **width/height/steps/seed**: `INT`
+- **cfg**: `FLOAT`
+- **sampler**: `SAMPLER`(对象,适配 custom-sampling 工作流)
+- **sampler_name**: `STRING`(文本形式,便于展示/拼接/日志)
+- **notes**: `STRING`
+- **vars**: `SIMPLECHAT_VARS`(下文“内置胡子变量”)
+- **obj**: `SIMPLECHAT_JSON`(完整 dict)
+
+---
+
+## 3) 内置胡子变量(Mustache Vars)
+
+把 `Prompt JSON Unpack.vars` 接到任何 SimpleChat 节点的 `vars` 输入后,就能在 `prompt/system` 里直接使用这些变量:
+
+### 3.1 推荐使用(带前缀,避免变量冲突)
+
+- `{{anima.positive}}`
+- `{{anima.negative}}`
+- `{{anima.width}}`
+- `{{anima.height}}`
+- `{{anima.steps}}`
+- `{{anima.cfg}}`
+- `{{anima.sampler}}` / `{{anima.sampler_name}}`
+- `{{anima.seed}}`
+- `{{anima.notes}}`
+
+### 3.2 简写(不带前缀)
+
+- `{{positive}}`
+- `{{negative}}`
+- `{{width}}`
+- `{{height}}`
+- `{{steps}}`
+- `{{cfg}}`
+- `{{sampler}}` / `{{sampler_name}}`
+- `{{seed}}`
+- `{{notes}}`
+
+### 3.3 中文别名(可选)
+
+- `{{正面提示词}}` / `{{anima.正面提示词}}`
+- `{{负面提示词}}` / `{{anima.负面提示词}}`
+- `{{宽}}` / `{{anima.宽}}`
+- `{{高}}` / `{{anima.高}}`
+- `{{步数}}` / `{{anima.步数}}`
+- `{{采样器}}` / `{{anima.采样器}}`
+- `{{种子}}` / `{{anima.种子}}`
+- `{{备注}}` / `{{anima.备注}}`
+
+---
+
+## 4) 最小工作流接线(推荐)
+
+1) `SimpleChatText`(system 用 `docs/anima_prompt.md`,prompt 写你的需求)
+2) 输出接 `Prompt JSON Unpack.json_text`
+3) 把 `positive/negative/width/...` 分别接到你现有出图工作流对应口
+4) 如需二次加工:把 `Prompt JSON Unpack.vars` 接到下一个 `SimpleChatText.vars`,就能用上面的胡子变量复用字段
+
diff --git a/docs/wiki/Anima.md b/docs/wiki/Anima.md
new file mode 100644
index 0000000..e305d6d
--- /dev/null
+++ b/docs/wiki/Anima.md
@@ -0,0 +1,73 @@
+# Anima(circlestone-labs/Anima)专用方案
+
+目标:在 ComfyUI 里用 Anima 生成高质量二次元/插画向图像(非写实、非摄影)。
+
+---
+
+## 1) 强制 JSON 输出(推荐)
+
+建议你在 `Chat.system` 中使用仓库提供的模板(已强制“只输出一个 JSON”):
+- `docs/anima_prompt.md`
+
+LLM 输出 JSON schema(固定):
+
+```json
+{
+ "positive": "...",
+ "negative": "...",
+ "width": 1024,
+ "height": 1024,
+ "steps": 40,
+ "cfg": 4.5,
+ "sampler": "er_sde",
+ "seed": -1,
+ "notes": "1-3条简短技巧/提醒"
+}
+```
+
+---
+
+## 2) 一键拆字段(避免“只能接 positive 不能接 json”)
+
+把 LLM 的输出接到:
+- `Prompt JSON Unpack.json_text`
+
+你会得到强类型输出口(可直接接到 ComfyUI 节点):
+- `positive/negative`(STRING)
+- `width/height/steps/seed`(INT)
+- `cfg`(FLOAT)
+- `sampler`(SAMPLER 对象,用于 custom-sampling 图)
+- `sampler_name`(STRING,便于展示/拼接)
+- `notes`(STRING)
+
+---
+
+## 3) 内置胡子变量(vars)
+
+`Prompt JSON Unpack` 同时输出 `vars`(`SIMPLECHAT_VARS`),可直接用于 Mustache 模板渲染:
+
+推荐用带前缀(防冲突):
+- `{{anima.positive}}` / `{{anima.negative}}`
+- `{{anima.width}}` / `{{anima.height}}`
+- `{{anima.steps}}` / `{{anima.cfg}}`
+- `{{anima.sampler}}` / `{{anima.sampler_name}}`
+- `{{anima.seed}}` / `{{anima.notes}}`
+
+也支持不带前缀的简写:`{{positive}}`、`{{width}}` 等;并提供中文别名(如 `{{正面提示词}}`)。
+
+更完整的变量清单见:
+- `docs/anima_scheme.md`
+
+---
+
+## 4) 规则要点(给提示词工程)
+
+- **标签顺序固定**:
+ `[质量/元数据/年份/安全] [人数] [角色名] [作品名] [画师] [通用标签]`
+- **画师必须 @**:例如 `@xxx`
+- **混合输入**:tags + 自然语言(自然语言建议至少 2 句,复杂构图更要写清楚)
+- **默认参数**:约 1MP;steps 30–50;cfg 4–5;sampler 优先 `er_sde`
+- **安全标签**:safe/sensitive/nsfw/explicit 必须明确;negative 里写相反约束
+- **避免写实**:不要用 photo/realistic/皮肤毛孔等措辞
+- **非二次元风格**:第一行写 `ye-pop` 或 `deviantart`,换行后再给标题/描述与正常标签行
+
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
new file mode 100644
index 0000000..b4af0da
--- /dev/null
+++ b/docs/wiki/Home.md
@@ -0,0 +1,27 @@
+# ComfyUI-SimpleChat Wiki
+
+这份 Wiki 用于集中放置插件的使用文档与工作流方案。
+
+> 维护方式:本仓库的 `docs/wiki/` 会通过 GitHub Actions **自动同步**到 GitHub Wiki(原生 Wiki)。
+
+---
+
+## 快速入口
+
+- **节点清单与用途**:见 `Nodes.md`
+- **Anima(circlestone-labs/Anima)专用方案**:见 `Anima.md`
+- **Wiki 自动发布(配置/排错)**:见 `Wiki-Sync.md`
+
+---
+
+## 推荐工作流(Anima:LLM→JSON→拆字段→接入出图)
+
+核心思路:
+1) LLM **只输出一个固定 JSON**(包含 positive/negative/width/height/steps/cfg/sampler/seed/notes)
+2) 用 `Prompt JSON Unpack` 把 JSON **拆成多个强类型端口**(STRING/INT/FLOAT/SAMPLER)
+3) 把各字段直接接到 ComfyUI 的对应输入口(避免“只接收 positive 不接收 json”的问题)
+4) 同时得到 `vars`(内置胡子变量)用于复用/二次加工
+
+示例工作流文件:
+- `assets/AnimaPromptScheme.json`
+
diff --git a/docs/wiki/Nodes.md b/docs/wiki/Nodes.md
new file mode 100644
index 0000000..445eb70
--- /dev/null
+++ b/docs/wiki/Nodes.md
@@ -0,0 +1,27 @@
+# Nodes(节点清单)
+
+分类路径均为 `SimpleChat` 或 `SimpleChat/Utils`(右键菜单里可搜到)。
+
+---
+
+## SimpleChat(主节点)
+
+- **API Config**:统一配置 OpenAI / Claude / Gemini(支持刷新模型列表)
+- **Chat**:文本对话(支持 `system`)
+- **Chat with Image**:图文对话
+- **Chat NoASS**:NoASS 角色扮演模式(实验性)
+- **Gemini Image Gen / Edit**:Gemini 原生文生图/图片编辑
+
+> 以上节点均支持可选输入 `vars`:用于把 `{{变量}}` 模板渲染进 prompt/system 等文本字段。
+
+---
+
+## SimpleChat/Utils(工具节点)
+
+- **Text Input**:纯文本输入框(输出 STRING)
+- **Mustache Var**:定义变量(输出 `SIMPLECHAT_VARS` 字典,支持菊花链合并)
+- **Mustache Render**:不经过 Chat,也能把 `{{变量}}` 渲染成纯文本(输出 STRING)
+- **JSON Parse**:解析 JSON 并按 path 拆 8 路输出(支持 `a.b[0].c`、支持 ```json 代码块提取)
+- **Prompt JSON Unpack**:固定 schema 的“一键拆包”(强类型输出 + 自动生成 `vars`)
+- **Markdown Preview**:把文本以 Markdown 方式弹窗预览(已净化)
+
diff --git a/docs/wiki/Wiki-Sync.md b/docs/wiki/Wiki-Sync.md
new file mode 100644
index 0000000..f8ed5ce
--- /dev/null
+++ b/docs/wiki/Wiki-Sync.md
@@ -0,0 +1,33 @@
+# Wiki Sync(GitHub 原生 Wiki 自动发布)
+
+本仓库采用 **GitHub 原生 Wiki**,并使用 GitHub Actions 把 `docs/wiki/` 自动同步到 Wiki 仓库。
+
+---
+
+## 1) 需要在 GitHub 仓库里打开 Wiki 功能
+
+进入 GitHub 仓库:
+- Settings -> Features -> 勾选 **Wiki**
+
+若未启用,Actions 会在克隆 `${repo}.wiki.git` 时失败。
+
+---
+
+## 2) 文档如何更新
+
+请直接改动仓库内:
+- `docs/wiki/*.md`
+
+推送到 `main/master` 后会自动发布到 Wiki。
+
+---
+
+## 3) 同步工作流文件在哪里
+
+自动发布配置位于:
+- `.github/workflows/publish-wiki.yml`
+
+同步逻辑:
+- 把 `docs/wiki/` rsync 到 Wiki 仓库根目录
+- 自动提交并 push
+
diff --git a/nodes/__init__.py b/nodes/__init__.py
index 9b6e614..892b683 100644
--- a/nodes/__init__.py
+++ b/nodes/__init__.py
@@ -7,6 +7,12 @@ from .chat_image import SimpleChatImage
from .chat_noass import SimpleChatNoASS
from .gemini_gen import GeminiImageGen
from .gemini_edit import GeminiImageEdit
+from .mustache_var import SimpleChatMustacheVar
+from .mustache_render import SimpleChatMustacheRender
+from .text_input import SimpleChatTextInput
+from .json_parse import SimpleChatJsonParse
+from .markdown_preview import SimpleChatMarkdownPreview
+from .prompt_json_unpack import SimpleChatPromptJsonUnpack
__all__ = [
"SimpleChatConfig",
@@ -15,4 +21,10 @@ __all__ = [
"SimpleChatNoASS",
"GeminiImageGen",
"GeminiImageEdit",
+ "SimpleChatMustacheVar",
+ "SimpleChatMustacheRender",
+ "SimpleChatTextInput",
+ "SimpleChatJsonParse",
+ "SimpleChatMarkdownPreview",
+ "SimpleChatPromptJsonUnpack",
]
diff --git a/nodes/chat.py b/nodes/chat.py
index 8f0ca4d..2cb5e92 100644
--- a/nodes/chat.py
+++ b/nodes/chat.py
@@ -2,6 +2,7 @@
Chat node - Basic text conversation.
"""
from ..core import get_provider, ChatConfig
+from ..core.template import render_mustache
class SimpleChatText:
@@ -16,6 +17,7 @@ class SimpleChatText:
},
"optional": {
"system": ("STRING", {"multiline": True, "default": ""}),
+ "vars": ("SIMPLECHAT_VARS",),
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 128000}),
}
@@ -32,9 +34,14 @@ class SimpleChatText:
config: ChatConfig,
prompt: str,
system: str = "",
+ vars=None,
temperature: float = 1.0,
max_tokens: int = 2048,
):
+ # Template rendering ({{var}}) for prompt/system
+ prompt = render_mustache(prompt, vars)
+ system = render_mustache(system, vars)
+
# Build messages
messages = []
if system.strip():
diff --git a/nodes/chat_image.py b/nodes/chat_image.py
index 5dbcaa0..cad9cdc 100644
--- a/nodes/chat_image.py
+++ b/nodes/chat_image.py
@@ -3,6 +3,7 @@ Chat with Image node - Send image to LLM for analysis.
"""
import torch
from ..core import get_provider, ChatConfig
+from ..core.template import render_mustache
class SimpleChatImage:
@@ -18,6 +19,7 @@ class SimpleChatImage:
},
"optional": {
"system": ("STRING", {"multiline": True, "default": ""}),
+ "vars": ("SIMPLECHAT_VARS",),
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 128000}),
}
@@ -35,9 +37,14 @@ class SimpleChatImage:
image: torch.Tensor,
prompt: str,
system: str = "",
+ vars=None,
temperature: float = 1.0,
max_tokens: int = 2048,
):
+ # Template rendering ({{var}}) for prompt/system
+ prompt = render_mustache(prompt, vars)
+ system = render_mustache(system, vars)
+
# Build messages
messages = []
if system.strip():
diff --git a/nodes/chat_noass.py b/nodes/chat_noass.py
index d6415ee..f9de18f 100644
--- a/nodes/chat_noass.py
+++ b/nodes/chat_noass.py
@@ -11,6 +11,7 @@ from ..core import (
build_full_history,
get_stop_sequences,
)
+from ..core.template import render_mustache
class SimpleChatNoASS:
@@ -39,6 +40,7 @@ class SimpleChatNoASS:
"placeholder": "Force the start of the assistant's response (Prefill)."
}),
"history": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
+ "vars": ("SIMPLECHAT_VARS",),
"image": ("IMAGE",),
"user_name": ("STRING", {"default": "User"}),
"char_name": ("STRING", {"default": "Assistant"}),
@@ -60,12 +62,20 @@ class SimpleChatNoASS:
user_action: str,
prefill_start: str = "",
history: str = "",
+ vars=None,
image: torch.Tensor = None,
user_name: str = "User",
char_name: str = "Assistant",
temperature: float = 1.0,
max_tokens: int = 2048,
):
+ # Template rendering ({{var}}) for scenario/user/prefill
+ scenario_instructions = render_mustache(scenario_instructions, vars)
+ user_action = render_mustache(user_action, vars)
+ prefill_start = render_mustache(prefill_start, vars)
+ user_name = render_mustache(user_name, vars)
+ char_name = render_mustache(char_name, vars)
+
# Format in NoASS style
system_prompt, prefilled = format_noass_prompt(
system=scenario_instructions,
diff --git a/nodes/gemini_edit.py b/nodes/gemini_edit.py
index 42ff0b1..983952b 100644
--- a/nodes/gemini_edit.py
+++ b/nodes/gemini_edit.py
@@ -3,6 +3,7 @@ Gemini Image Edit node - Edit images using Gemini.
"""
import torch
from ..core import get_provider, ChatConfig
+from ..core.template import render_mustache
class GeminiImageEdit:
@@ -17,6 +18,7 @@ class GeminiImageEdit:
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
+ "vars": ("SIMPLECHAT_VARS",),
"size": (["1K", "2K", "4K"], {"default": "1K"}),
}
}
@@ -32,8 +34,12 @@ class GeminiImageEdit:
config: ChatConfig,
image: torch.Tensor,
prompt: str,
+ vars=None,
size: str = "1K",
):
+ # Template rendering ({{var}}) for prompt
+ prompt = render_mustache(prompt, vars)
+
# Validate provider
if config.provider != "gemini":
raise ValueError("Gemini Image Edit requires Gemini provider. Please use a Gemini config.")
diff --git a/nodes/gemini_gen.py b/nodes/gemini_gen.py
index e8b7dac..97e3b8f 100644
--- a/nodes/gemini_gen.py
+++ b/nodes/gemini_gen.py
@@ -3,6 +3,7 @@ Gemini Image Gen node - Text-to-image generation with Gemini.
"""
import torch
from ..core import get_provider, ChatConfig
+from ..core.template import render_mustache
class GeminiImageGen:
@@ -16,6 +17,7 @@ class GeminiImageGen:
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
+ "vars": ("SIMPLECHAT_VARS",),
"aspect_ratio": (["1:1", "16:9", "9:16", "4:3", "3:4", "5:4", "4:5"], {"default": "1:1"}),
"size": (["1K", "2K", "4K"], {"default": "1K"}),
}
@@ -31,9 +33,13 @@ class GeminiImageGen:
self,
config: ChatConfig,
prompt: str,
+ vars=None,
aspect_ratio: str = "1:1",
size: str = "1K",
):
+ # Template rendering ({{var}}) for prompt
+ prompt = render_mustache(prompt, vars)
+
# Validate provider
if config.provider != "gemini":
raise ValueError("Gemini Image Gen requires Gemini provider. Please use a Gemini config.")
diff --git a/nodes/json_parse.py b/nodes/json_parse.py
new file mode 100644
index 0000000..9fa373a
--- /dev/null
+++ b/nodes/json_parse.py
@@ -0,0 +1,169 @@
+"""
+JSON parsing node for SimpleChat.
+
+Parses a JSON string (optionally wrapped in ```json code fences) and extracts
+up to 8 fields via simple dot/bracket paths.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+
+_CODE_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```", re.IGNORECASE)
+
+
+def _strip_code_fence(text: str) -> str:
+ if not isinstance(text, str):
+ return text
+ m = _CODE_FENCE_RE.search(text)
+ return m.group(1) if m else text
+
+
+def _tokenize_path(path: str) -> list[Any]:
+ """
+ Tokenize a simple path like:
+ a.b[0].c
+ items[0]['name']
+ items[0][\"name\"]
+
+ Returns tokens: ["a","b",0,"c"] ...
+ """
+ if not isinstance(path, str):
+ return []
+ s = path.strip()
+ if not s:
+ return []
+
+ tokens: list[Any] = []
+ i = 0
+ n = len(s)
+ while i < n:
+ ch = s[i]
+ if ch == ".":
+ i += 1
+ continue
+
+ if ch == "[":
+ j = s.find("]", i + 1)
+ if j == -1:
+ # malformed, treat rest as a single token
+ tokens.append(s[i:])
+ break
+ inner = s[i + 1 : j].strip()
+ # quoted key
+ if (inner.startswith('"') and inner.endswith('"')) or (inner.startswith("'") and inner.endswith("'")):
+ tokens.append(inner[1:-1])
+ else:
+ try:
+ tokens.append(int(inner))
+ except Exception:
+ tokens.append(inner)
+ i = j + 1
+ continue
+
+ # normal key token until '.' or '['
+ j = i
+ while j < n and s[j] not in ".[":
+ j += 1
+ key = s[i:j].strip()
+ if key:
+ tokens.append(key)
+ i = j
+
+ return tokens
+
+
+_MISSING = object()
+
+
+def _get_by_tokens(obj: Any, tokens: list[Any]) -> Any:
+ cur = obj
+ for tok in tokens:
+ if isinstance(tok, int):
+ if isinstance(cur, (list, tuple)) and -len(cur) <= tok < len(cur):
+ cur = cur[tok]
+ else:
+ return _MISSING
+ else:
+ if isinstance(cur, dict) and tok in cur:
+ cur = cur[tok]
+ else:
+ return _MISSING
+ return cur
+
+
+def _stringify(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return value
+ if isinstance(value, (int, float, bool)):
+ return str(value)
+ try:
+ return json.dumps(value, ensure_ascii=False)
+ except Exception:
+ return str(value)
+
+
+class SimpleChatJsonParse:
+ """
+ Parse a JSON string and split into multiple outputs (fixed 8 ports).
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "json_text": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
+ "strip_code_fence": ("BOOLEAN", {"default": True}),
+ "default": ("STRING", {"default": ""}),
+ "path1": ("STRING", {"default": ""}),
+ "path2": ("STRING", {"default": ""}),
+ "path3": ("STRING", {"default": ""}),
+ "path4": ("STRING", {"default": ""}),
+ "path5": ("STRING", {"default": ""}),
+ "path6": ("STRING", {"default": ""}),
+ "path7": ("STRING", {"default": ""}),
+ "path8": ("STRING", {"default": ""}),
+ }
+ }
+
+ RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "SIMPLECHAT_JSON")
+ RETURN_NAMES = ("out1", "out2", "out3", "out4", "out5", "out6", "out7", "out8", "obj")
+ FUNCTION = "parse"
+ CATEGORY = "SimpleChat/Utils"
+ DESCRIPTION = "Parse JSON and extract up to 8 values by dot/bracket paths."
+
+ def parse(
+ self,
+ json_text: str,
+ strip_code_fence: bool = True,
+ default: str = "",
+ path1: str = "",
+ path2: str = "",
+ path3: str = "",
+ path4: str = "",
+ path5: str = "",
+ path6: str = "",
+ path7: str = "",
+ path8: str = "",
+ ):
+ raw = _strip_code_fence(json_text) if strip_code_fence else json_text
+ data = json.loads(raw) if isinstance(raw, str) else raw
+
+ paths = [path1, path2, path3, path4, path5, path6, path7, path8]
+ outs: list[str] = []
+ for p in paths:
+ p = (p or "").strip()
+ if not p:
+ outs.append(default)
+ continue
+ tokens = _tokenize_path(p)
+ val = _get_by_tokens(data, tokens)
+ outs.append(default if val is _MISSING else _stringify(val))
+
+ return (*outs, data)
+
diff --git a/nodes/markdown_preview.py b/nodes/markdown_preview.py
new file mode 100644
index 0000000..3da19ee
--- /dev/null
+++ b/nodes/markdown_preview.py
@@ -0,0 +1,28 @@
+"""
+Markdown preview node.
+
+Backend returns UI text; frontend JS renders markdown to HTML safely.
+"""
+
+from __future__ import annotations
+
+
+class SimpleChatMarkdownPreview:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "text": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
+ }
+ }
+
+ RETURN_TYPES = ()
+ FUNCTION = "preview"
+ OUTPUT_NODE = True
+ CATEGORY = "SimpleChat/Utils"
+ DESCRIPTION = "Render input text as Markdown (frontend-rendered)."
+
+ def preview(self, text: str):
+ # Use the standard 'ui.text' channel (same shape as PreviewAny)
+ return {"ui": {"text": (text or "",)}}
+
diff --git a/nodes/mustache_render.py b/nodes/mustache_render.py
new file mode 100644
index 0000000..b1579cb
--- /dev/null
+++ b/nodes/mustache_render.py
@@ -0,0 +1,41 @@
+"""
+Mustache text render node.
+
+This node renders {{var}} placeholders in arbitrary text using SIMPLECHAT_VARS
+without requiring any SimpleChat "main" chat/image nodes.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ..core.template import render_mustache
+
+
+class SimpleChatMustacheRender:
+ """
+ Render a text template with {{var}} placeholders.
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "text": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
+ },
+ "optional": {
+ # Accept either vars dict (SIMPLECHAT_VARS) or a parsed JSON object (SIMPLECHAT_JSON)
+ "vars": ("SIMPLECHAT_VARS,SIMPLECHAT_JSON",),
+ "keep_unmatched": ("BOOLEAN", {"default": True}),
+ },
+ }
+
+ RETURN_TYPES = ("STRING",)
+ RETURN_NAMES = ("text",)
+ FUNCTION = "render"
+ CATEGORY = "SimpleChat/Utils"
+ DESCRIPTION = "Render {{var}} placeholders in text using SIMPLECHAT_VARS."
+
+ def render(self, text: str, vars: Any = None, keep_unmatched: bool = True):
+ return (render_mustache(text, vars, keep_unmatched=keep_unmatched),)
+
diff --git a/nodes/mustache_var.py b/nodes/mustache_var.py
new file mode 100644
index 0000000..55cd709
--- /dev/null
+++ b/nodes/mustache_var.py
@@ -0,0 +1,48 @@
+"""
+Mustache variables node - define {{var}} values.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+class SimpleChatMustacheVar:
+ """
+ Define a single Mustache variable and output a merged vars dict.
+
+ Usage:
+ - Set name/value in this node
+ - Connect output to other SimpleChat nodes via optional `vars` input
+ - Use {{name}} anywhere in prompts to inject the value
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "name": ("STRING", {"default": "自定义胡子变量"}),
+ "value": ("STRING", {"multiline": True, "default": ""}),
+ },
+ "optional": {
+ "vars_in": ("SIMPLECHAT_VARS",),
+ },
+ }
+
+ RETURN_TYPES = ("SIMPLECHAT_VARS",)
+ RETURN_NAMES = ("vars",)
+ FUNCTION = "set_var"
+ CATEGORY = "SimpleChat/Utils"
+ DESCRIPTION = "Define a {{var}} value for prompt templating."
+
+ def set_var(self, name: str, value: str, vars_in: Any = None):
+ merged = {}
+ if isinstance(vars_in, dict):
+ merged.update(vars_in)
+
+ key = (name or "").strip()
+ if key:
+ merged[key] = value
+
+ return (merged,)
+
diff --git a/nodes/prompt_json_unpack.py b/nodes/prompt_json_unpack.py
new file mode 100644
index 0000000..0d2cbdb
--- /dev/null
+++ b/nodes/prompt_json_unpack.py
@@ -0,0 +1,247 @@
+"""
+Prompt JSON unpacker (Anima-oriented schema).
+
+This node is designed to take an LLM output that is *one JSON object only*:
+{
+ "positive": "...",
+ "negative": "...",
+ "width": 1024,
+ "height": 1024,
+ "steps": 40,
+ "cfg": 4.5,
+ "sampler": "er_sde",
+ "seed": -1,
+ "notes": "..."
+}
+
+And split it into typed ComfyUI outputs, plus a SIMPLECHAT_VARS dict for
+Mustache templating in other SimpleChat nodes.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+
+_CODE_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```", re.IGNORECASE)
+
+
+def _strip_code_fence(text: str) -> str:
+ if not isinstance(text, str):
+ return text
+ m = _CODE_FENCE_RE.search(text)
+ return m.group(1) if m else text
+
+
+def _try_parse_json(text: str) -> Any:
+ """
+ Try strict JSON first; if it fails, apply a minimal cleanup and retry.
+ """
+ try:
+ return json.loads(text)
+ except Exception:
+ # Common LLM mistake: trailing commas
+ cleaned = re.sub(r",\s*([}\]])", r"\1", text)
+ return json.loads(cleaned)
+
+
+def _as_int(value: Any, default: int) -> int:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return int(value)
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ return int(round(value))
+ if isinstance(value, str):
+ s = value.strip()
+ if not s:
+ return default
+ try:
+ return int(s)
+ except Exception:
+ try:
+ return int(float(s))
+ except Exception:
+ return default
+ return default
+
+
+def _as_float(value: Any, default: float) -> float:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return float(int(value))
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ s = value.strip()
+ if not s:
+ return default
+ try:
+ return float(s)
+ except Exception:
+ return default
+ return default
+
+
+def _as_str(value: Any, default: str = "") -> str:
+ if value is None:
+ return default
+ if isinstance(value, str):
+ return value
+ try:
+ return json.dumps(value, ensure_ascii=False)
+ except Exception:
+ return str(value)
+
+
+_SAMPLER_ALIASES = {
+ # community shorthand -> ComfyUI names
+ "euler_a": "euler_ancestral",
+ "euler-ancestral": "euler_ancestral",
+ "euler ancestral": "euler_ancestral",
+}
+
+
+def _normalize_sampler(name: str, default: str) -> str:
+ if not isinstance(name, str):
+ return default
+ s = name.strip()
+ if not s:
+ return default
+ low = s.lower()
+ return _SAMPLER_ALIASES.get(low, low)
+
+
+class SimpleChatPromptJsonUnpack:
+ """
+ Unpack a fixed-schema JSON (positive/negative/params/notes) into typed outputs.
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "json_text": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
+ },
+ "optional": {
+ "strip_code_fence": ("BOOLEAN", {"default": True}),
+ },
+ }
+
+ RETURN_TYPES = (
+ "STRING", # positive
+ "STRING", # negative
+ "INT", # width
+ "INT", # height
+ "INT", # steps
+ "FLOAT", # cfg
+ "SAMPLER", # sampler
+ "INT", # seed
+ "STRING", # notes
+ "SIMPLECHAT_VARS", # vars
+ "SIMPLECHAT_JSON", # obj
+ "STRING", # sampler_name (string)
+ )
+ RETURN_NAMES = (
+ "positive",
+ "negative",
+ "width",
+ "height",
+ "steps",
+ "cfg",
+ "sampler",
+ "seed",
+ "notes",
+ "vars",
+ "obj",
+ "sampler_name",
+ )
+ FUNCTION = "unpack"
+ CATEGORY = "SimpleChat/Utils"
+ DESCRIPTION = "Unpack an LLM-produced JSON into ComfyUI-typed fields + Mustache vars."
+
+ def unpack(self, json_text: str, strip_code_fence: bool = True):
+ raw = _strip_code_fence(json_text) if strip_code_fence else json_text
+ data = _try_parse_json(raw) if isinstance(raw, str) else raw
+ if not isinstance(data, dict):
+ raise ValueError("Expected a JSON object (dict) at top-level.")
+
+ positive = _as_str(data.get("positive", ""), "")
+ negative = _as_str(data.get("negative", ""), "")
+
+ width = _as_int(data.get("width"), 1024)
+ height = _as_int(data.get("height"), 1024)
+ steps = _as_int(data.get("steps"), 40)
+ cfg = _as_float(data.get("cfg"), 4.5)
+
+ sampler_name = _normalize_sampler(_as_str(data.get("sampler", ""), ""), "er_sde")
+ seed = _as_int(data.get("seed"), -1)
+ notes = _as_str(data.get("notes", ""), "")
+
+ vars_dict = {
+ # English keys (match JSON schema)
+ "positive": positive,
+ "negative": negative,
+ "width": width,
+ "height": height,
+ "steps": steps,
+ "cfg": cfg,
+ "sampler": sampler_name,
+ "sampler_name": sampler_name,
+ "seed": seed,
+ "notes": notes,
+ # Prefixed scheme keys (avoid collisions)
+ "anima.positive": positive,
+ "anima.negative": negative,
+ "anima.width": width,
+ "anima.height": height,
+ "anima.steps": steps,
+ "anima.cfg": cfg,
+ "anima.sampler": sampler_name,
+ "anima.sampler_name": sampler_name,
+ "anima.seed": seed,
+ "anima.notes": notes,
+ # Chinese aliases (optional convenience)
+ "正面提示词": positive,
+ "负面提示词": negative,
+ "宽": width,
+ "高": height,
+ "步数": steps,
+ "采样器": sampler_name,
+ "种子": seed,
+ "备注": notes,
+ # Chinese aliases (prefixed)
+ "anima.正面提示词": positive,
+ "anima.负面提示词": negative,
+ "anima.宽": width,
+ "anima.高": height,
+ "anima.步数": steps,
+ "anima.采样器": sampler_name,
+ "anima.种子": seed,
+ "anima.备注": notes,
+ }
+
+ # Build an actual sampler object for SAMPLER output (for custom-sampling graphs)
+ import comfy.samplers
+ sampler_obj = comfy.samplers.sampler_object(sampler_name)
+
+ return (
+ positive,
+ negative,
+ width,
+ height,
+ steps,
+ cfg,
+ sampler_obj,
+ seed,
+ notes,
+ vars_dict,
+ data,
+ sampler_name,
+ )
+
diff --git a/nodes/text_input.py b/nodes/text_input.py
new file mode 100644
index 0000000..5ebd7e8
--- /dev/null
+++ b/nodes/text_input.py
@@ -0,0 +1,27 @@
+"""
+Simple text input node.
+
+Provides a standalone multiline text box and outputs a STRING.
+"""
+
+from __future__ import annotations
+
+
+class SimpleChatTextInput:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "text": ("STRING", {"multiline": True, "default": ""}),
+ }
+ }
+
+ RETURN_TYPES = ("STRING",)
+ RETURN_NAMES = ("text",)
+ FUNCTION = "output"
+ CATEGORY = "SimpleChat/Utils"
+ DESCRIPTION = "Standalone multiline text input."
+
+ def output(self, text: str):
+ return (text,)
+
diff --git a/web/markdown_preview.js b/web/markdown_preview.js
new file mode 100644
index 0000000..466b0e7
--- /dev/null
+++ b/web/markdown_preview.js
@@ -0,0 +1,187 @@
+import { app } from "../../scripts/app.js";
+
+const EXT_NAME = "ComfyUI.SimpleChat.MarkdownPreview";
+const STYLE_ID = "simplechat-markdown-preview-style";
+
+function chainCallback(object, property, callback) {
+ if (!object) return;
+ if (property in object && object[property]) {
+ const callback_orig = object[property];
+ object[property] = function () {
+ const r = callback_orig.apply(this, arguments);
+ return callback.apply(this, arguments) ?? r;
+ };
+ } else {
+ object[property] = callback;
+ }
+}
+
+function ensureStyles() {
+ if (document.getElementById(STYLE_ID)) return;
+ const style = document.createElement("style");
+ style.id = STYLE_ID;
+ style.type = "text/css";
+ style.innerHTML = `
+ .simplechat-md-modal {
+ position: fixed;
+ right: 18px;
+ top: 18px;
+ width: min(720px, calc(100vw - 36px));
+ height: min(70vh, 760px);
+ background: var(--comfy-menu-bg);
+ color: var(--fg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ z-index: 9999;
+ box-shadow: 0 16px 40px rgba(0,0,0,0.35);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+ .simplechat-md-modal header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border-color);
+ background: rgba(0,0,0,0.08);
+ font: 12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ }
+ .simplechat-md-modal header .title {
+ opacity: 0.9;
+ }
+ .simplechat-md-modal header .actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+ .simplechat-md-modal header button {
+ border: 1px solid var(--border-color);
+ background: transparent;
+ color: var(--fg-color);
+ border-radius: 8px;
+ padding: 4px 8px;
+ cursor: pointer;
+ font-size: 12px;
+ }
+ .simplechat-md-modal header button:hover {
+ background: rgba(255,255,255,0.06);
+ }
+ .simplechat-md-body {
+ padding: 12px;
+ overflow: auto;
+ }
+ .simplechat-md-body pre, .simplechat-md-body code {
+ background: rgba(0,0,0,0.16);
+ border: 1px solid rgba(255,255,255,0.08);
+ border-radius: 8px;
+ padding: 8px 10px;
+ overflow: auto;
+ }
+ .simplechat-md-body a {
+ color: #ffd166;
+ }
+ .simplechat-md-body a:visited {
+ color: #f4a261;
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+function loadScript(url) {
+ return new Promise((resolve, reject) => {
+ const existing = document.querySelector(`script[src="${url}"]`);
+ if (existing) return resolve(true);
+ const el = document.createElement("script");
+ el.async = true;
+ el.type = "text/javascript";
+ el.src = url;
+ el.addEventListener("load", () => resolve(true));
+ el.addEventListener("error", () => reject(new Error(`Failed to load ${url}`)));
+ document.body.appendChild(el);
+ });
+}
+
+let _libsPromise = null;
+async function ensureMarkdownLibs() {
+ if (window.marked && window.DOMPurify) return;
+ if (_libsPromise) return _libsPromise;
+ const markedUrl = new URL("./vendor/marked.min.js", import.meta.url).href;
+ const purifyUrl = new URL("./vendor/purify.min.js", import.meta.url).href;
+ _libsPromise = (async () => {
+ await loadScript(markedUrl);
+ await loadScript(purifyUrl);
+ })();
+ return _libsPromise;
+}
+
+function escapeHtml(text) {
+ return (text || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+let modalEl = null;
+function showMarkdown(rawText, title = "Markdown Preview") {
+ ensureStyles();
+
+ if (!modalEl) {
+ modalEl = document.createElement("div");
+ modalEl.className = "simplechat-md-modal";
+ modalEl.innerHTML = `
+
${escapeHtml(rawText || "")}`;
+ }
+}
+
+app.registerExtension({
+ name: EXT_NAME,
+ async beforeRegisterNodeDef(nodeType, nodeData) {
+ if (nodeData?.name !== "SimpleChatMarkdownPreview") return;
+
+ // Ensure libraries are loaded once; if it fails, we fall back to plain text
+ ensureMarkdownLibs().catch((e) => console.warn("[SimpleChat] markdown libs load failed:", e));
+
+ const onExecuted = nodeType.prototype.onExecuted;
+ nodeType.prototype.onExecuted = function (message) {
+ onExecuted?.apply(this, [message]);
+ const text = (message?.text && message.text.length) ? message.text[0] : "";
+ showMarkdown(text, "SimpleChat Markdown Preview");
+ };
+ },
+});
+
diff --git a/web/vendor/marked.min.js b/web/vendor/marked.min.js
new file mode 100644
index 0000000..cdd1109
--- /dev/null
+++ b/web/vendor/marked.min.js
@@ -0,0 +1,6 @@
+/**
+ * marked v12.0.1 - a markdown parser
+ * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length'+(n?e:c(e,!0))+"\n":""+(n?e:c(e,!0))+"\n"}blockquote(e){return`\n${e}\n`}html(e,t){return e}heading(e,t,n){return`
${e}
\n`}table(e,t){return t&&(t=`${t}`),"${e}`}br(){return"An error occurred:
"+c(n.message+"",!0)+"";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); diff --git a/web/vendor/purify.min.js b/web/vendor/purify.min.js new file mode 100644 index 0000000..c2f5164 --- /dev/null +++ b/web/vendor/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.0.11 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.11/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=b(Array.prototype.forEach),m=b(Array.prototype.pop),p=b(Array.prototype.push),f=b(String.prototype.toLowerCase),d=b(String.prototype.toString),h=b(String.prototype.match),g=b(String.prototype.replace),T=b(String.prototype.indexOf),y=b(String.prototype.trim),E=b(Object.prototype.hasOwnProperty),A=b(RegExp.prototype.test),_=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n