Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d68bc85ec | ||
|
|
7a0dd36c1f | ||
|
|
e874207f33 | ||
|
|
f84abc9e8a | ||
|
|
6949a6f236 | ||
|
|
40b2a5fa92 | ||
|
|
c65a9a8f4a | ||
|
|
d1975cc511 | ||
|
|
a977b97d29 | ||
|
|
288275c100 | ||
|
|
af8f8cc25d | ||
|
|
a3a9fc5b63 | ||
|
|
b3933446bd | ||
|
|
d61c2f3806 | ||
|
|
7207aa0d7f |
+4
-1
@@ -32,6 +32,9 @@ env/
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# API Keys
|
||||
api_key.txt
|
||||
|
||||
# Model files (too large for git)
|
||||
*.gguf
|
||||
*.safetensors
|
||||
@@ -49,4 +52,4 @@ Thumbs.db
|
||||
output/
|
||||
temp/
|
||||
|
||||
experiments/
|
||||
experiments/
|
||||
@@ -0,0 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to ComfyUI-ScriptFlow will be documented in this file.
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [1.3.0] - 2026-09-29
|
||||
|
||||
- Added `MultiOutputScript (Jev)` node (`utils`)
|
||||
- Same inputs, outputs, and script rules as `MultiOutputScript`, plus a `jev` namespace that answers typed questions about text
|
||||
- `jev.yes(state, question[, threshold])`, `jev.noul(state, question)`, `jev.choice(state, question, options)`, `jev.probabilities(state, question, options)`, and `jev.score(state, question, levels)`
|
||||
- Using a `jev.noul()` probability directly as a condition raises an error instead of always being true
|
||||
- Identical questions within a run are asked only once; `max_jev_calls` limits API requests or model evaluations per run
|
||||
- Default script picks one of eight aspect ratios for the prompt in `in_text_1`, sizes it to the megapixels in `in_value_1`, and lists every ratio's probability in `out_text_1`
|
||||
|
||||
- Added two backends selected by the `model` input; scripts are the same for both
|
||||
- `TypeSafe API`: asks TypeSafe AI's Jev through the optional `typesafe-sdk` package, with the API key read from `api_key.txt` in the node's installation directory on every run
|
||||
- Local GGUF models as a fallback when the Jev API is not available: models under `models/LLM` and `models/text_encoders`, including paths registered in `extra_model_paths.yaml` on other drives
|
||||
- The local backend reads the decision from next-token probabilities of lettered options in one forward pass through the optional `llama-cpp-python` package, adapted from SemIf (OpenJev, MIT License); no text is generated and nothing is sent over the network
|
||||
- The local model is loaded on the first `jev` question of a run and always unloaded when the run ends, freeing VRAM on CUDA, Intel XPU, and other ComfyUI devices
|
||||
|
||||
- Kept `MultiOutputScript` and `centi` unchanged
|
||||
- `jev` is not available in `MultiOutputScript`, which still makes no network requests and loads no models
|
||||
- Existing nodes do not require `typesafe-sdk` or `llama-cpp-python`
|
||||
|
||||
- Added `api_key.txt` to `.gitignore`
|
||||
- Moved release notes from `README.md` to `CHANGELOG.md`
|
||||
- Replaced the README demo with a `MultiOutputScript (Jev)` demo at the top of the README
|
||||
|
||||
|
||||
## [1.2.0] - 2026-05-31
|
||||
|
||||
- Replaced direct Python execution with a safe AST interpreter for ComfyUI Registry compatibility.
|
||||
- Added Python-subset support for helper functions, loops, dictionaries, lists, string methods, f-strings, `random`, `datetime`, and `math`.
|
||||
- Replaced trace-based timeout handling with step and loop limits.
|
||||
- Kept existing `MultiOutputScript` inputs and outputs unchanged.
|
||||
|
||||
|
||||
## [1.1.1] - 2026-04-06
|
||||
|
||||
- Added safe-mode AST validation before script execution.
|
||||
- Blocked unsafe syntax (`Import`, `ImportFrom`, `Global`, `Nonlocal`, `ClassDef`, `Try`, `With`, `AsyncWith`).
|
||||
- Blocked unsafe dynamic execution, file/input access, namespace inspection, and dynamic attribute mutation calls.
|
||||
- Added execution timeout guard (`1.5s` default) to stop runaway scripts.
|
||||
- Updated security notes for trusted-workflow usage.
|
||||
|
||||
|
||||
## [1.1.0] - 2026-04-03
|
||||
|
||||
Added `centi` node (`utils`).
|
||||
- Minimal connector-only utility node.
|
||||
- Three optional integer inputs: `int_1`, `int_2`, `int_3`.
|
||||
- Three float outputs: `float_1`, `float_2`, `float_3`.
|
||||
- Each connected input is converted by `int_n / 100`; unconnected inputs return `None`.
|
||||
|
||||
|
||||
## [1.0.1] - 2026-04-01
|
||||
|
||||
- Improved documentation and project metadata.
|
||||
|
||||
|
||||
## [1.0.0] - 2026-03-31
|
||||
|
||||
- Initial release
|
||||
@@ -0,0 +1,13 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 or later
|
||||
(SPDX: GPL-3.0-or-later).
|
||||
|
||||
For the full license text, see:
|
||||
https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
# ComfyUI-ScriptFlow
|
||||
|
||||
**Version:** 1.1.0
|
||||
**Version:** 1.3.0
|
||||
**License:** GPL-3.0
|
||||
|
||||
A general-purpose calculation node that executes user-written Python code and returns multiple text and numeric outputs based on the inputs.
|
||||
Safe Python-like script node for ComfyUI with a System One decision model.
|
||||
Ask yes/no, choice, and score questions via TypeSafe AI's Jev API, with local GGUF LLMs as a fallback, and use the answer directly in an `if` statement.
|
||||
|
||||
This custom node accepts numbers (int/float) and text (string) as inputs, runs calculations and logical operations internally, and returns multiple values through separate output ports. It consolidates workflows that previously required combining several basic nodes into a single node.
|
||||

|
||||
|
||||
`MultiOutputScript (Jev)` picks an aspect ratio for a text prompt through the TypeSafe API, lists every ratio's probability, and passes the width and height to an image node.
|
||||
|
||||
A simplified version of the demo: connect a text prompt to `in_text_1`, and the answer picks the width and height.
|
||||
|
||||
```python
|
||||
if jev.yes(it1, "Does a portrait orientation suit this prompt?"):
|
||||
ov1, ov2 = 832, 1280
|
||||
else:
|
||||
ov1, ov2 = 1280, 832
|
||||
```
|
||||
|
||||
A System One model answers with probabilities in a single fast pass instead of generating text, so the answer can drive workflow logic directly.
|
||||
|
||||
Multiple inputs and outputs let one node handle math, logic, text editing, conditional routing, resolution selection, filename generation, and more — work that previously took several basic nodes. Scripts run in a restricted interpreter with no file or OS access.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -16,11 +32,33 @@ This custom node accepts numbers (int/float) and text (string) as inputs, runs c
|
||||
```
|
||||
3. Restart ComfyUI.
|
||||
|
||||
The `MultiOutputScript` and `centi` nodes need no extra packages. `MultiOutputScript (Jev)` needs the setup below for the backend you use.
|
||||
|
||||
### For `MultiOutputScript (Jev)`
|
||||
|
||||
#### TypeSafe API
|
||||
|
||||
1. Install the SDK in the Python environment that runs ComfyUI:
|
||||
```bash
|
||||
pip install typesafe-sdk
|
||||
```
|
||||
2. Create `api_key.txt` in the actual installation directory of this custom node and add your TypeSafe API key (single line, no quotes):
|
||||
```
|
||||
ComfyUI/custom_nodes/<your-installation-folder>/api_key.txt
|
||||
```
|
||||
The key is read from this file on every run; the `TYPESAFE_API_KEY` environment variable is not used. `api_key.txt` is listed in `.gitignore`; never commit it.
|
||||
|
||||
#### Local models (fallback)
|
||||
|
||||
- Install [llama-cpp-python](https://github.com/JamePeng/llama-cpp-python) with GPU support for your platform in the Python environment that runs ComfyUI. Tested with the JamePeng fork and the upstream [abetlen/llama-cpp-python](https://github.com/abetlen/llama-cpp-python) 0.3.35. A CPU-only build works but is much slower.
|
||||
- Put an instruction-tuned GGUF model with a chat template in `ComfyUI/models/LLM` (or `models/text_encoders`). Tested with Qwen3.5-9B Q8_0 and Gemma-4-E4B Q8.
|
||||
|
||||
## Key Features
|
||||
- Multiple inputs: freely combine numeric and text inputs.
|
||||
- Calculations and logic: supports arithmetic, comparison, and simple conditional branching.
|
||||
- Multiple outputs: pass results to downstream nodes via separate output ports.
|
||||
- Safer execution: restricted environment (no file/OS access).
|
||||
- AI decisions: `jev.yes` / `jev.choice` / `jev.score` in `MultiOutputScript (Jev)`, with probabilities, through TypeSafe AI's Jev API or a local GGUF model as a fallback.
|
||||
- Safer execution: scripts run in an AST interpreter with restricted syntax and no file/OS access. Only the Jev node's `jev` functions reach a model or the TypeSafe API.
|
||||
|
||||
## Use Cases
|
||||
- In Wan2.2 I2V workflows, automatically select a recommended resolution
|
||||
@@ -28,7 +66,9 @@ This custom node accepts numbers (int/float) and text (string) as inputs, runs c
|
||||
- Batch-generate file names (e.g., local LLM prompt history, output logs).
|
||||
- Apply conditional logic to control workflow behavior (e.g., switch parameters or routes based on inputs).
|
||||
- Parse and transform LLM outputs into structured values for downstream nodes.
|
||||
Dynamically generate numeric parameters (e.g., seeds, thresholds, scaling values) using custom calculations.
|
||||
- Dynamically generate numeric parameters (e.g., seeds, thresholds, scaling values) using custom calculations.
|
||||
- Pick an aspect ratio and size for a text prompt before generation, with the probability of every ratio (Jev node default script).
|
||||
- Route workflows by the meaning of text, e.g. check whether a prompt describes a night scene or classify its main subject.
|
||||
|
||||
## Nodes
|
||||
|
||||
@@ -74,6 +114,62 @@ w, h = iv1, iv2
|
||||
ov1, ov2 = (512, 384) if w >= h else (384, 512)
|
||||
```
|
||||
|
||||
### `MultiOutputScript (Jev)`
|
||||
**Category:** `utils`
|
||||
|
||||
Same inputs, outputs, and script rules as `MultiOutputScript`, plus a `jev` namespace that asks [Jev](https://typesafe.ai), TypeSafe AI's decision model, typed questions about text and lets scripts branch on the answer.
|
||||
|
||||
- **TypeSafe API** (primary): sends the questions to Jev over the internet.
|
||||
- **Local GGUF model** (fallback): for when the Jev API is not available, or when text must stay on your machine. It approximates Jev-style decisions with a general instruction-tuned model, reading the answer from next-token probabilities of lettered options in one forward pass, following [SemIf (OpenJev)](https://github.com/TheoLeeCJ/SemIf-OpenJev). No text is generated and nothing is sent over the network. Answers and probabilities differ from Jev's.
|
||||
|
||||
Scripts are the same for both. See [Installation](#installation) for the setup of each backend.
|
||||
|
||||
Extra inputs:
|
||||
- `model`: `TypeSafe API`, or a local GGUF model (`mmproj-*` files are hidden).
|
||||
- `max_jev_calls` (INT, default 8): maximum API requests or model evaluations per run. Identical questions within a run are asked only once.
|
||||
|
||||
Functions (`state` is a string, dict, or list; text only):
|
||||
- `jev.yes(state, question[, threshold])` → `bool` (yes-probability ≥ `threshold`, default `0.5`)
|
||||
- `jev.noul(state, question)` → yes-probability (`0.0`–`1.0`). Compare it with a threshold; using it directly as a condition raises an error.
|
||||
- `jev.choice(state, question, options)` → the most likely option (`str`). `options` is a list of labels, or a dict of labels to descriptions (2–16 options for local models).
|
||||
- `jev.probabilities(state, question, options)` → `dict` of each option to its probability (sums to `1.0`). Same `options` as `jev.choice`; asking both with the same arguments asks the model once.
|
||||
- `jev.score(state, question, levels)` → expected level (`float`, `0` to `len(levels) - 1`) for ordered level descriptions (2–16 levels for local models)
|
||||
|
||||
```python
|
||||
# it1: a prompt text
|
||||
if jev.yes(it1, "Does this prompt describe a night scene?", 0.7):
|
||||
ov1 = 30
|
||||
else:
|
||||
ov1 = 20
|
||||
|
||||
kind = jev.choice(it1, "Which orientation suits this prompt?", ["portrait", "landscape"])
|
||||
ov2, ov3 = (384, 512) if kind == "portrait" else (512, 384)
|
||||
|
||||
probs = jev.probabilities(it1, "Which orientation suits this prompt?", ["portrait", "landscape"])
|
||||
ot1 = f"{kind} (portrait: {probs['portrait']:.3f}, landscape: {probs['landscape']:.3f})"
|
||||
```
|
||||
|
||||
The node's default script picks one of eight aspect ratios for the prompt in `it1`, sizes it to the megapixels in `in_value_1` (default `1.0` when unconnected; width and height rounded to multiples of 64), outputs the width and height to `out_value_1` / `out_value_2`, and lists every ratio's probability in `out_text_1`.
|
||||
|
||||
Each function asks one question. With the TypeSafe API it maps to one `system_one` request of the [TypeSafe Python SDK](https://pypi.org/project/typesafe-sdk/):
|
||||
|
||||
| ScriptFlow | TypeSafe SDK |
|
||||
| --- | --- |
|
||||
| `jev.noul(state, q)` | `client.system_one(state=state, questions={"q": Noul(instructions=q)}).answers["q"].noul` |
|
||||
| `jev.yes(state, q, t)` | `... .answers["q"].noul >= t` |
|
||||
| `jev.choice(state, q, options)` | `client.system_one(state=state, questions={"q": Choice(instructions=q, criteria=options)}).answers["q"].choice` |
|
||||
| `jev.probabilities(state, q, options)` | `... .answers["q"].probabilities` (same request as `jev.choice`) |
|
||||
| `jev.score(state, q, levels)` | `client.system_one(state=state, questions={"q": Score(instructions=q, criteria=levels)}).answers["q"].score` |
|
||||
|
||||
A list of `options` is sent as `criteria={label: None, ...}`. `confidence`, the Score level probabilities, and Noul `criteria` are not exposed.
|
||||
|
||||
Notes:
|
||||
- The TypeSafe API sends `state` and questions over the internet. Do not use it with text you cannot share externally.
|
||||
- A local question takes one forward pass (about 1–1.5 s with a 4B–9B model on GPU). The model is loaded on the first question of each run (several seconds) and unloaded when the run ends to free VRAM for the rest of the workflow.
|
||||
- Local probabilities are uncalibrated and depend on the model; test thresholds on your own inputs.
|
||||
- `MultiOutputScript` itself never loads a model or makes network requests; `jev` is not available there.
|
||||
- This project is independent and not affiliated with TypeSafe AI.
|
||||
|
||||
### `centi`
|
||||
**Category:** `utils`
|
||||
|
||||
@@ -94,41 +190,51 @@ Notes:
|
||||
---
|
||||
|
||||
## Execution Environment
|
||||
The script is executed using:
|
||||
```python
|
||||
exec(code, globals_dict, locals_dict)
|
||||
```
|
||||
The script is parsed with Python AST and evaluated by ScriptFlow's safe interpreter. It supports common workflow logic such as assignment, arithmetic, branching, loops, user-defined helper functions, dictionaries, lists, strings, and selected methods.
|
||||
|
||||
### Allowed builtins
|
||||
### Allowed functions
|
||||
- `len`, `min`, `max`, `sum`, `abs`, `round`
|
||||
- `int`, `float`, `str`, `bool`
|
||||
- `sorted`, `reversed`
|
||||
- `enumerate`, `range`, `zip`, `map`, `filter`
|
||||
- `enumerate`, `range`, `zip`
|
||||
- `any`, `all`, `pow`, `divmod`
|
||||
- `list`, `dict`, `tuple`
|
||||
|
||||
### Allowed modules
|
||||
- `random`
|
||||
- `datetime`
|
||||
- `math`
|
||||
### Allowed namespaces
|
||||
- `random`: `random`, `randint`, `uniform`, `choice`
|
||||
- `datetime`: `datetime.now`, `date.today`, date/time fields such as `year`, `month`, `day`, `hour`, `minute`, `second`, plus `isoformat` and `strftime` (`strftime` requires v1.2.0 or later)
|
||||
- `math`: `ceil`, `floor`, `sqrt`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `log`, `log10`, `exp`, `pow`, `fabs`, `isfinite`, `pi`, `e`, `tau`
|
||||
|
||||
### Allowed methods
|
||||
- `str`: `replace`, `find`, `strip`, `split`, `splitlines`, `startswith`, `endswith`, `join`, `lower`, `upper`
|
||||
- `list`: `append`
|
||||
|
||||
### Not allowed
|
||||
- `import` statements
|
||||
- file access (`open`, etc.)
|
||||
- OS operations (`os`, `sys`)
|
||||
- re-running `eval`/`exec`
|
||||
- dynamic Python execution
|
||||
- blocked syntax in safe mode: `Import`, `ImportFrom`, `Global`, `Nonlocal`, `ClassDef`, `Try`, `With`, `AsyncWith`, `Lambda`, `Delete`, `Yield`, `Await`
|
||||
- blocked calls in safe mode: dynamic execution, file/input access, runtime namespace inspection, and dynamic attribute mutation
|
||||
|
||||
### Execution Notes
|
||||
- Using `random` or `datetime` makes outputs non-deterministic.
|
||||
- `datetime.strftime(...)` is supported in v1.2.0 or later for concise timestamp formatting:
|
||||
```python
|
||||
now = datetime.datetime.now()
|
||||
ot1 = now.strftime("%Y%m%d%H%M%S")
|
||||
```
|
||||
- For versions earlier than v1.2.0, format the same timestamp manually:
|
||||
```python
|
||||
now = datetime.datetime.now()
|
||||
ot1 = f'{now.year:04d}{now.month:02d}{now.day:02d}{now.hour:02d}{now.minute:02d}{now.second:02d}'
|
||||
```
|
||||
- Type mismatches raise an error and stop execution.
|
||||
- Safe mode validates AST before evaluation and stops scripts that exceed the step or loop limit.
|
||||
- Use only trusted scripts/workflows. Do not run untrusted code from unknown sources.
|
||||
|
||||
## Examples
|
||||
|
||||
### Demo
|
||||

|
||||
|
||||
Demonstrates running the sample workflow with a landscape image and a portrait image.
|
||||
|
||||
### Example Workflow
|
||||
|
||||
```
|
||||
@@ -136,6 +242,189 @@ examples/
|
||||
├─ example_workflow.json
|
||||
```
|
||||
|
||||
## Application Recipes
|
||||
|
||||
### Convert `LLM Dialogue Cycle` transcript text to `dialogue_segments_json`
|
||||
|
||||
<details>
|
||||
<summary>Show ComfyUI-LLM-Session transcript conversion recipe</summary>
|
||||
|
||||
`ComfyUI-LLM-Session` returns the full result of `LLM Dialogue Cycle` as `transcript_text`.
|
||||
You can connect that text to `MultiOutputScript.in_text_1` and paste the script below into
|
||||
the `code` field to generate a speaker-tagged JSON text for downstream dialogue TTS nodes.
|
||||
|
||||
Recommended workflow:
|
||||
|
||||
```text
|
||||
LLM Dialogue Cycle
|
||||
-> transcript_text
|
||||
-> MultiOutputScript.in_text_1
|
||||
-> MultiOutputScript.out_text_1
|
||||
-> dialogue_segments_json input of a TTS node
|
||||
```
|
||||
|
||||
Paste this code into `MultiOutputScript.code`:
|
||||
|
||||
```python
|
||||
# Convert ComfyUI-LLM-Session "LLM Dialogue Cycle" transcript_text
|
||||
# into dialogue_segments_json for TTS.
|
||||
# Only text inside Japanese corner quotes 「...」 is used as spoken dialogue.
|
||||
#
|
||||
# Input:
|
||||
# it1: transcript_text
|
||||
#
|
||||
# Output:
|
||||
# ot1: JSON text with schema "kantan.dialogue.v1"
|
||||
#
|
||||
# Expected transcript lines:
|
||||
# [2026-05-16T12:00:00] USER -> A: initial prompt
|
||||
# [2026-05-16T12:00:01] A: 彼女は少し考えた。「こんにちは。」
|
||||
# [2026-05-16T12:00:02] B: 彼は笑った。「やあ。」「調子はどう?」
|
||||
#
|
||||
# The parser also accepts the unicode arrow form:
|
||||
# USER → A:
|
||||
|
||||
def json_escape(value):
|
||||
s = str(value)
|
||||
s = s.replace("\\", "\\\\")
|
||||
s = s.replace('"', '\\"')
|
||||
s = s.replace("\n", "\\n")
|
||||
s = s.replace("\r", "\\r")
|
||||
s = s.replace("\t", "\\t")
|
||||
return s
|
||||
|
||||
def extract_spoken_text(value):
|
||||
source = str(value)
|
||||
parts = []
|
||||
position = 0
|
||||
|
||||
while position < len(source):
|
||||
start = source.find("「", position)
|
||||
if start < 0:
|
||||
position = len(source)
|
||||
else:
|
||||
end = source.find("」", start + 1)
|
||||
if end < 0:
|
||||
position = len(source)
|
||||
else:
|
||||
spoken = source[start + 1:end].strip()
|
||||
if spoken:
|
||||
parts.append(spoken)
|
||||
position = end + 1
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
transcript = str(it1 or "")
|
||||
lines = transcript.splitlines()
|
||||
utterances = []
|
||||
current = None
|
||||
|
||||
for raw_line in lines:
|
||||
line = str(raw_line)
|
||||
speaker = None
|
||||
text = None
|
||||
timestamp = ""
|
||||
|
||||
if line.startswith("[") and "] " in line:
|
||||
close_index = line.find("] ")
|
||||
timestamp = line[1:close_index]
|
||||
rest = line[close_index + 2:]
|
||||
|
||||
if rest.startswith("A:"):
|
||||
speaker = "A"
|
||||
text = rest[2:].strip()
|
||||
elif rest.startswith("B:"):
|
||||
speaker = "B"
|
||||
text = rest[2:].strip()
|
||||
elif rest.startswith("USER -> A:"):
|
||||
speaker = "USER"
|
||||
text = rest[len("USER -> A:"):].strip()
|
||||
elif rest.startswith("USER → A:"):
|
||||
speaker = "USER"
|
||||
text = rest[len("USER → A:"):].strip()
|
||||
|
||||
if speaker == "A" or speaker == "B":
|
||||
if current is not None:
|
||||
utterances.append(current)
|
||||
current = {
|
||||
"speaker": speaker,
|
||||
"text": text,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
else:
|
||||
if current is not None:
|
||||
if current["text"]:
|
||||
current["text"] = current["text"] + "\n" + line
|
||||
else:
|
||||
current["text"] = line
|
||||
|
||||
if current is not None:
|
||||
utterances.append(current)
|
||||
|
||||
items = []
|
||||
index = 1
|
||||
for u in utterances:
|
||||
spoken_text = extract_spoken_text(u["text"])
|
||||
if not spoken_text:
|
||||
continue
|
||||
|
||||
item = (
|
||||
'{"index":'
|
||||
+ str(index)
|
||||
+ ',"speaker":"'
|
||||
+ json_escape(u["speaker"])
|
||||
+ '","text":"'
|
||||
+ json_escape(spoken_text)
|
||||
+ '","timestamp":"'
|
||||
+ json_escape(u["timestamp"])
|
||||
+ '"}'
|
||||
)
|
||||
items.append(item)
|
||||
index = index + 1
|
||||
|
||||
ot1 = (
|
||||
'{"schema":"kantan.dialogue.v1",'
|
||||
+ '"source":"ComfyUI-LLM-Session LLM Dialogue Cycle",'
|
||||
+ '"utterances":['
|
||||
+ ",".join(items)
|
||||
+ "]}"
|
||||
)
|
||||
```
|
||||
|
||||
The resulting `out_text_1` is a JSON string like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "kantan.dialogue.v1",
|
||||
"source": "ComfyUI-LLM-Session LLM Dialogue Cycle",
|
||||
"utterances": [
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "A",
|
||||
"text": "こんにちは。",
|
||||
"timestamp": "2026-05-16T12:00:01"
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"speaker": "B",
|
||||
"text": "やあ。\n調子はどう?",
|
||||
"timestamp": "2026-05-16T12:00:02"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `USER -> A` / `USER → A` is intentionally skipped so TTS reads only character dialogue.
|
||||
- Multiline model output is kept inside the previous `A` or `B` utterance.
|
||||
- Only text inside `「...」` is emitted to `utterances[].text`; narration and inner thoughts outside quotes are skipped.
|
||||
- If one utterance contains multiple quoted lines, they are joined with newlines.
|
||||
- Utterances without `「...」` are omitted from the TTS script.
|
||||
- `out_text_2`, `out_text_3`, and numeric outputs are unused by this recipe.
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **GNU General Public License v3.0**.
|
||||
@@ -152,20 +441,19 @@ You should have received a copy of the GNU General Public License along with thi
|
||||
## Support
|
||||
|
||||
- **Issues**: Report bugs or request features via GitHub Issues
|
||||
- **Documentation**: See [CHANGELOG.md](CHANGELOG.md) for version history
|
||||
- **Examples**: Check [examples/](examples/) for workflow templates
|
||||
|
||||
---
|
||||
|
||||
## Release Notes
|
||||
### 1.1.0
|
||||
Added `centi` node (`utils`).
|
||||
- Minimal connector-only utility node.
|
||||
- Three optional integer inputs: `int_1`, `int_2`, `int_3`.
|
||||
- Three float outputs: `float_1`, `float_2`, `float_3`.
|
||||
- Each connected input is converted by `int_n / 100`; unconnected inputs return `None`.
|
||||
## Changelog
|
||||
|
||||
### 1.0.1
|
||||
- Improved documentation and project metadata.
|
||||
See [CHANGELOG.md](CHANGELOG.md) for detailed version history.
|
||||
|
||||
### 1.0.0
|
||||
- Initial release
|
||||
### Current Version: 1.3.0
|
||||
- Added `MultiOutputScript (Jev)` node with a `jev` namespace for typed decisions in scripts
|
||||
- Added `jev.yes`, `jev.noul`, `jev.choice`, `jev.probabilities`, and `jev.score`
|
||||
- Added TypeSafe API backend with the API key read from `api_key.txt`
|
||||
- Added local GGUF backend through llama-cpp-python, adapted from SemIf (OpenJev)
|
||||
- Added a default script that picks one of eight aspect ratios and sizes it from megapixels
|
||||
- Kept `MultiOutputScript` and `centi` unchanged
|
||||
|
||||
+872
-68
@@ -8,41 +8,741 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "1.1.0"
|
||||
|
||||
import ast
|
||||
import datetime
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from .jev_backend import (
|
||||
JevBackend,
|
||||
LocalJev,
|
||||
TypeSafeClient,
|
||||
TypeSafeJev,
|
||||
list_local_models,
|
||||
unload_local_model,
|
||||
)
|
||||
|
||||
_ALLOWED_BUILTINS = {
|
||||
"len": len,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"str": str,
|
||||
"bool": bool,
|
||||
"sorted": sorted,
|
||||
"reversed": reversed,
|
||||
"enumerate": enumerate,
|
||||
"range": range,
|
||||
"zip": zip,
|
||||
"map": map,
|
||||
"filter": filter,
|
||||
"any": any,
|
||||
"all": all,
|
||||
"pow": pow,
|
||||
"divmod": divmod,
|
||||
"list": list,
|
||||
"dict": dict,
|
||||
"tuple": tuple,
|
||||
|
||||
_DEFAULT_MAX_STEPS = 10000
|
||||
_DEFAULT_MAX_CALL_DEPTH = 32
|
||||
_MAX_RANGE_SIZE = 10000
|
||||
_API_KEY_PATH = Path(__file__).resolve().parent / "api_key.txt"
|
||||
_TYPESAFE_API = "TypeSafe API"
|
||||
|
||||
_BLOCKED_AST_NODES = (
|
||||
ast.Import,
|
||||
ast.ImportFrom,
|
||||
ast.Global,
|
||||
ast.Nonlocal,
|
||||
ast.ClassDef,
|
||||
ast.Try,
|
||||
ast.With,
|
||||
ast.AsyncWith,
|
||||
ast.Lambda,
|
||||
ast.Delete,
|
||||
ast.Yield,
|
||||
ast.YieldFrom,
|
||||
ast.Await,
|
||||
)
|
||||
|
||||
_BLOCKED_CALL_NAMES = {
|
||||
"eval",
|
||||
"exec",
|
||||
"compile",
|
||||
"open",
|
||||
"input",
|
||||
"globals",
|
||||
"locals",
|
||||
"vars",
|
||||
"getattr",
|
||||
"setattr",
|
||||
"delattr",
|
||||
"hasattr",
|
||||
}
|
||||
|
||||
_SAFE_FUNCTIONS = {
|
||||
"len",
|
||||
"min",
|
||||
"max",
|
||||
"sum",
|
||||
"abs",
|
||||
"round",
|
||||
"int",
|
||||
"float",
|
||||
"str",
|
||||
"bool",
|
||||
"sorted",
|
||||
"reversed",
|
||||
"enumerate",
|
||||
"range",
|
||||
"zip",
|
||||
"any",
|
||||
"all",
|
||||
"pow",
|
||||
"divmod",
|
||||
"list",
|
||||
"dict",
|
||||
"tuple",
|
||||
}
|
||||
|
||||
_SAFE_STR_METHODS = {
|
||||
"replace",
|
||||
"find",
|
||||
"strip",
|
||||
"split",
|
||||
"splitlines",
|
||||
"startswith",
|
||||
"endswith",
|
||||
"join",
|
||||
"lower",
|
||||
"upper",
|
||||
}
|
||||
|
||||
_SAFE_LIST_METHODS = {
|
||||
"append",
|
||||
}
|
||||
|
||||
_SAFE_DATETIME_ATTRS = {
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
"hour",
|
||||
"minute",
|
||||
"second",
|
||||
"microsecond",
|
||||
}
|
||||
|
||||
_SAFE_MATH_FUNCTIONS = {
|
||||
"ceil",
|
||||
"floor",
|
||||
"sqrt",
|
||||
"sin",
|
||||
"cos",
|
||||
"tan",
|
||||
"asin",
|
||||
"acos",
|
||||
"atan",
|
||||
"atan2",
|
||||
"log",
|
||||
"log10",
|
||||
"exp",
|
||||
"pow",
|
||||
"fabs",
|
||||
"isfinite",
|
||||
}
|
||||
|
||||
_SAFE_MATH_CONSTANTS = {
|
||||
"pi": math.pi,
|
||||
"e": math.e,
|
||||
"tau": math.tau,
|
||||
}
|
||||
|
||||
|
||||
class _BreakSignal(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _ContinueSignal(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _ReturnSignal(Exception):
|
||||
def __init__(self, value: Any):
|
||||
self.value = value
|
||||
|
||||
|
||||
class _SafeNamespace:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
|
||||
class _JevProbability(float):
|
||||
pass
|
||||
|
||||
|
||||
class _SafeFunction:
|
||||
def __init__(self, name: str, args: ast.arguments, body: list[ast.stmt]):
|
||||
self.name = name
|
||||
self.arg_names = [arg.arg for arg in args.args]
|
||||
self.body = body
|
||||
if args.vararg or args.kwarg or args.kwonlyargs or args.defaults:
|
||||
raise ValueError(f"Unsupported function signature: {name}")
|
||||
|
||||
def call(self, interpreter: "_SafeScriptInterpreter", args: list[Any]) -> Any:
|
||||
if len(args) != len(self.arg_names):
|
||||
raise ValueError(
|
||||
f"{self.name}() expected {len(self.arg_names)} arguments, got {len(args)}"
|
||||
)
|
||||
if interpreter.call_depth >= interpreter.max_call_depth:
|
||||
raise RuntimeError("Script exceeded maximum call depth")
|
||||
frame = dict(zip(self.arg_names, args))
|
||||
interpreter.call_depth += 1
|
||||
interpreter.frames.append(frame)
|
||||
try:
|
||||
interpreter._run_block(self.body)
|
||||
except _ReturnSignal as signal:
|
||||
return signal.value
|
||||
finally:
|
||||
interpreter.frames.pop()
|
||||
interpreter.call_depth -= 1
|
||||
return None
|
||||
|
||||
|
||||
class _SafeScriptInterpreter:
|
||||
def __init__(
|
||||
self,
|
||||
variables: Dict[str, Any],
|
||||
max_steps: int = _DEFAULT_MAX_STEPS,
|
||||
max_call_depth: int = _DEFAULT_MAX_CALL_DEPTH,
|
||||
jev: JevBackend | None = None,
|
||||
):
|
||||
self.frames = [variables]
|
||||
self.jev = jev
|
||||
self.max_steps = max_steps
|
||||
self.max_call_depth = max_call_depth
|
||||
self.steps = 0
|
||||
self.call_depth = 0
|
||||
|
||||
def run(self, code: str) -> Dict[str, Any]:
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError as exc:
|
||||
raise ValueError(f"Syntax error in code: {exc}") from exc
|
||||
_validate_ast_tree(tree)
|
||||
self._run_block(tree.body)
|
||||
return self.frames[0]
|
||||
|
||||
def _step(self) -> None:
|
||||
self.steps += 1
|
||||
if self.steps > self.max_steps:
|
||||
raise RuntimeError("Script exceeded step limit")
|
||||
|
||||
def _run_block(self, statements: list[ast.stmt]) -> None:
|
||||
for statement in statements:
|
||||
self._step()
|
||||
self._run_statement(statement)
|
||||
|
||||
def _run_statement(self, node: ast.stmt) -> None:
|
||||
if isinstance(node, ast.Assign):
|
||||
value = self._resolve(node.value)
|
||||
for target in node.targets:
|
||||
self._assign(target, value)
|
||||
return
|
||||
if isinstance(node, ast.AugAssign):
|
||||
current = self._resolve_target(node.target)
|
||||
value = self._apply_binop(node.op, current, self._resolve(node.value))
|
||||
self._assign(node.target, value)
|
||||
return
|
||||
if isinstance(node, ast.Expr):
|
||||
self._resolve(node.value)
|
||||
return
|
||||
if isinstance(node, ast.If):
|
||||
if self._truthy(self._resolve(node.test)):
|
||||
self._run_block(node.body)
|
||||
else:
|
||||
self._run_block(node.orelse)
|
||||
return
|
||||
if isinstance(node, ast.For):
|
||||
iterable = self._resolve(node.iter)
|
||||
count = 0
|
||||
for item in iterable:
|
||||
count += 1
|
||||
if count > _MAX_RANGE_SIZE:
|
||||
raise RuntimeError("Loop exceeded iteration limit")
|
||||
self._step()
|
||||
self._assign(node.target, item)
|
||||
try:
|
||||
self._run_block(node.body)
|
||||
except _ContinueSignal:
|
||||
continue
|
||||
except _BreakSignal:
|
||||
break
|
||||
else:
|
||||
self._run_block(node.orelse)
|
||||
return
|
||||
if isinstance(node, ast.While):
|
||||
count = 0
|
||||
while self._truthy(self._resolve(node.test)):
|
||||
count += 1
|
||||
if count > _MAX_RANGE_SIZE:
|
||||
raise RuntimeError("Loop exceeded iteration limit")
|
||||
self._step()
|
||||
try:
|
||||
self._run_block(node.body)
|
||||
except _ContinueSignal:
|
||||
continue
|
||||
except _BreakSignal:
|
||||
break
|
||||
else:
|
||||
self._run_block(node.orelse)
|
||||
return
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
self._set_name(node.name, _SafeFunction(node.name, node.args, node.body))
|
||||
return
|
||||
if isinstance(node, ast.Return):
|
||||
raise _ReturnSignal(None if node.value is None else self._resolve(node.value))
|
||||
if isinstance(node, ast.Break):
|
||||
raise _BreakSignal()
|
||||
if isinstance(node, ast.Continue):
|
||||
raise _ContinueSignal()
|
||||
if isinstance(node, ast.Pass):
|
||||
return
|
||||
raise ValueError(f"Unsupported syntax: {type(node).__name__}")
|
||||
|
||||
def _resolve(self, node: ast.AST) -> Any:
|
||||
self._step()
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name):
|
||||
return self._get_name(node.id)
|
||||
if isinstance(node, ast.List):
|
||||
return [self._resolve(item) for item in node.elts]
|
||||
if isinstance(node, ast.Tuple):
|
||||
return tuple(self._resolve(item) for item in node.elts)
|
||||
if isinstance(node, ast.Dict):
|
||||
return {
|
||||
self._resolve(key): self._resolve(value)
|
||||
for key, value in zip(node.keys, node.values)
|
||||
}
|
||||
if isinstance(node, ast.BinOp):
|
||||
return self._apply_binop(node.op, self._resolve(node.left), self._resolve(node.right))
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
return self._apply_unaryop(node.op, self._resolve(node.operand))
|
||||
if isinstance(node, ast.BoolOp):
|
||||
return self._resolve_boolop(node)
|
||||
if isinstance(node, ast.Compare):
|
||||
return self._resolve_compare(node)
|
||||
if isinstance(node, ast.IfExp):
|
||||
return self._resolve(node.body if self._truthy(self._resolve(node.test)) else node.orelse)
|
||||
if isinstance(node, ast.Subscript):
|
||||
return self._resolve(node.value)[self._resolve_slice(node.slice)]
|
||||
if isinstance(node, ast.Slice):
|
||||
return self._resolve_slice(node)
|
||||
if isinstance(node, ast.Attribute):
|
||||
return self._resolve_attribute(self._resolve(node.value), node.attr)
|
||||
if isinstance(node, ast.Call):
|
||||
return self._resolve_call(node)
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
return "".join(str(self._resolve_joined_value(value)) for value in node.values)
|
||||
if isinstance(node, ast.FormattedValue):
|
||||
return self._resolve_joined_value(node)
|
||||
raise ValueError(f"Unsupported expression: {type(node).__name__}")
|
||||
|
||||
def _resolve_joined_value(self, node: ast.AST) -> Any:
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
if isinstance(node, ast.FormattedValue):
|
||||
value = self._resolve(node.value)
|
||||
if node.conversion == 115:
|
||||
value = str(value)
|
||||
elif node.conversion == 114:
|
||||
value = repr(value)
|
||||
elif node.conversion == 97:
|
||||
value = ascii(value)
|
||||
spec = ""
|
||||
if node.format_spec is not None:
|
||||
spec = str(self._resolve(node.format_spec))
|
||||
return format(value, spec)
|
||||
return self._resolve(node)
|
||||
|
||||
def _resolve_slice(self, node: ast.AST) -> Any:
|
||||
if isinstance(node, ast.Slice):
|
||||
lower = None if node.lower is None else self._resolve(node.lower)
|
||||
upper = None if node.upper is None else self._resolve(node.upper)
|
||||
step = None if node.step is None else self._resolve(node.step)
|
||||
return slice(lower, upper, step)
|
||||
return self._resolve(node)
|
||||
|
||||
def _resolve_call(self, node: ast.Call) -> Any:
|
||||
if node.keywords:
|
||||
raise ValueError("Keyword arguments are not supported")
|
||||
args = [self._resolve(arg) for arg in node.args]
|
||||
if isinstance(node.func, ast.Name):
|
||||
return self._call_name(node.func.id, args)
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
owner = self._resolve(node.func.value)
|
||||
return self._call_attribute(owner, node.func.attr, args)
|
||||
raise ValueError("Unsupported function call")
|
||||
|
||||
def _call_name(self, name: str, args: list[Any]) -> Any:
|
||||
if name.startswith("__"):
|
||||
raise ValueError(f"Blocked function call: {name}")
|
||||
value = self._find_name(name)
|
||||
if isinstance(value, _SafeFunction):
|
||||
return value.call(self, args)
|
||||
if name in _SAFE_FUNCTIONS:
|
||||
return self._call_safe_function(name, args)
|
||||
raise ValueError(f"Unsupported function call: {name}")
|
||||
|
||||
def _call_safe_function(self, name: str, args: list[Any]) -> Any:
|
||||
if name == "len":
|
||||
return len(*args)
|
||||
if name == "min":
|
||||
return min(*args)
|
||||
if name == "max":
|
||||
return max(*args)
|
||||
if name == "sum":
|
||||
return sum(*args)
|
||||
if name == "abs":
|
||||
return abs(*args)
|
||||
if name == "round":
|
||||
return round(*args)
|
||||
if name == "int":
|
||||
return int(*args)
|
||||
if name == "float":
|
||||
return float(*args)
|
||||
if name == "str":
|
||||
return str(*args)
|
||||
if name == "bool":
|
||||
return bool(*args)
|
||||
if name == "sorted":
|
||||
return sorted(*args)
|
||||
if name == "reversed":
|
||||
return list(reversed(*args))
|
||||
if name == "enumerate":
|
||||
return enumerate(*args)
|
||||
if name == "range":
|
||||
result = range(*args)
|
||||
if len(result) > _MAX_RANGE_SIZE:
|
||||
raise RuntimeError("range() result is too large")
|
||||
return result
|
||||
if name == "zip":
|
||||
return zip(*args)
|
||||
if name == "any":
|
||||
return any(*args)
|
||||
if name == "all":
|
||||
return all(*args)
|
||||
if name == "pow":
|
||||
return pow(*args)
|
||||
if name == "divmod":
|
||||
return divmod(*args)
|
||||
if name == "list":
|
||||
return list(*args)
|
||||
if name == "dict":
|
||||
return dict(*args)
|
||||
if name == "tuple":
|
||||
return tuple(*args)
|
||||
raise ValueError(f"Unsupported function call: {name}")
|
||||
|
||||
def _call_attribute(self, owner: Any, name: str, args: list[Any]) -> Any:
|
||||
if name.startswith("__"):
|
||||
raise ValueError(f"Blocked method call: {name}")
|
||||
if isinstance(owner, str) and name in _SAFE_STR_METHODS:
|
||||
return self._call_str_method(owner, name, args)
|
||||
if isinstance(owner, list) and name in _SAFE_LIST_METHODS:
|
||||
if name == "append":
|
||||
if len(args) != 1:
|
||||
raise ValueError("list.append() expects 1 argument")
|
||||
owner.append(args[0])
|
||||
return None
|
||||
if isinstance(owner, _SafeNamespace):
|
||||
return self._call_namespace(owner.name, name, args)
|
||||
if isinstance(owner, (datetime.datetime, datetime.date)) and name == "isoformat":
|
||||
return owner.isoformat(*args)
|
||||
if isinstance(owner, (datetime.datetime, datetime.date)) and name == "strftime":
|
||||
return self._call_datetime_strftime(owner, args)
|
||||
raise ValueError(f"Unsupported method call: {name}")
|
||||
|
||||
@staticmethod
|
||||
def _call_datetime_strftime(owner: datetime.datetime | datetime.date, args: list[Any]) -> str:
|
||||
if len(args) != 1:
|
||||
raise ValueError("strftime() expects 1 argument")
|
||||
fmt = args[0]
|
||||
if not isinstance(fmt, str):
|
||||
raise ValueError("strftime() format must be str")
|
||||
if len(fmt) > 256:
|
||||
raise ValueError("strftime() format is too long")
|
||||
return owner.strftime(fmt)
|
||||
|
||||
def _call_str_method(self, owner: str, name: str, args: list[Any]) -> Any:
|
||||
if name == "replace":
|
||||
return owner.replace(*args)
|
||||
if name == "find":
|
||||
return owner.find(*args)
|
||||
if name == "strip":
|
||||
return owner.strip(*args)
|
||||
if name == "split":
|
||||
return owner.split(*args)
|
||||
if name == "splitlines":
|
||||
return owner.splitlines(*args)
|
||||
if name == "startswith":
|
||||
return owner.startswith(*args)
|
||||
if name == "endswith":
|
||||
return owner.endswith(*args)
|
||||
if name == "join":
|
||||
return owner.join(*args)
|
||||
if name == "lower":
|
||||
return owner.lower(*args)
|
||||
if name == "upper":
|
||||
return owner.upper(*args)
|
||||
raise ValueError(f"Unsupported string method: {name}")
|
||||
|
||||
def _call_namespace(self, namespace: str, name: str, args: list[Any]) -> Any:
|
||||
if namespace == "random":
|
||||
if name == "random":
|
||||
if args:
|
||||
raise ValueError("random.random() expects no arguments")
|
||||
return random.random()
|
||||
if name == "randint":
|
||||
if len(args) != 2:
|
||||
raise ValueError("random.randint() expects 2 arguments")
|
||||
return random.randint(int(args[0]), int(args[1]))
|
||||
if name == "uniform":
|
||||
if len(args) != 2:
|
||||
raise ValueError("random.uniform() expects 2 arguments")
|
||||
return random.uniform(float(args[0]), float(args[1]))
|
||||
if name == "choice":
|
||||
if len(args) != 1:
|
||||
raise ValueError("random.choice() expects 1 argument")
|
||||
return random.choice(args[0])
|
||||
if namespace == "datetime.datetime" and name == "now":
|
||||
if args:
|
||||
raise ValueError("datetime.datetime.now() expects no arguments")
|
||||
return datetime.datetime.now()
|
||||
if namespace == "datetime.date" and name == "today":
|
||||
if args:
|
||||
raise ValueError("datetime.date.today() expects no arguments")
|
||||
return datetime.date.today()
|
||||
if namespace == "math" and name in _SAFE_MATH_FUNCTIONS:
|
||||
return self._call_math(name, args)
|
||||
if namespace == "jev":
|
||||
return self._call_jev(name, args)
|
||||
raise ValueError(f"Unsupported namespace call: {namespace}.{name}")
|
||||
|
||||
def _call_jev(self, name: str, args: list[Any]) -> Any:
|
||||
if name == "yes":
|
||||
if len(args) not in (2, 3):
|
||||
raise ValueError("jev.yes() expects 2 or 3 arguments")
|
||||
threshold = args[2] if len(args) == 3 else 0.5
|
||||
return self.jev.noul(args[0], args[1]) >= threshold
|
||||
if name == "noul":
|
||||
if len(args) != 2:
|
||||
raise ValueError("jev.noul() expects 2 arguments")
|
||||
return _JevProbability(self.jev.noul(*args))
|
||||
if name == "choice":
|
||||
if len(args) != 3:
|
||||
raise ValueError("jev.choice() expects 3 arguments")
|
||||
return self.jev.choice(*args)
|
||||
if name == "probabilities":
|
||||
if len(args) != 3:
|
||||
raise ValueError("jev.probabilities() expects 3 arguments")
|
||||
return self.jev.probabilities(*args)
|
||||
if name == "score":
|
||||
if len(args) != 3:
|
||||
raise ValueError("jev.score() expects 3 arguments")
|
||||
return self.jev.score(*args)
|
||||
raise ValueError(f"Unsupported namespace call: jev.{name}")
|
||||
|
||||
def _call_math(self, name: str, args: list[Any]) -> Any:
|
||||
if name == "ceil":
|
||||
return math.ceil(*args)
|
||||
if name == "floor":
|
||||
return math.floor(*args)
|
||||
if name == "sqrt":
|
||||
return math.sqrt(*args)
|
||||
if name == "sin":
|
||||
return math.sin(*args)
|
||||
if name == "cos":
|
||||
return math.cos(*args)
|
||||
if name == "tan":
|
||||
return math.tan(*args)
|
||||
if name == "asin":
|
||||
return math.asin(*args)
|
||||
if name == "acos":
|
||||
return math.acos(*args)
|
||||
if name == "atan":
|
||||
return math.atan(*args)
|
||||
if name == "atan2":
|
||||
return math.atan2(*args)
|
||||
if name == "log":
|
||||
return math.log(*args)
|
||||
if name == "log10":
|
||||
return math.log10(*args)
|
||||
if name == "exp":
|
||||
return math.exp(*args)
|
||||
if name == "pow":
|
||||
return math.pow(*args)
|
||||
if name == "fabs":
|
||||
return math.fabs(*args)
|
||||
if name == "isfinite":
|
||||
return math.isfinite(*args)
|
||||
raise ValueError(f"Unsupported math function: {name}")
|
||||
|
||||
def _resolve_attribute(self, owner: Any, name: str) -> Any:
|
||||
if name.startswith("__"):
|
||||
raise ValueError(f"Blocked attribute access: {name}")
|
||||
if isinstance(owner, _SafeNamespace):
|
||||
namespace_name = f"{owner.name}.{name}"
|
||||
if namespace_name in ("datetime.datetime", "datetime.date"):
|
||||
return _SafeNamespace(namespace_name)
|
||||
if owner.name == "math" and name in _SAFE_MATH_CONSTANTS:
|
||||
return _SAFE_MATH_CONSTANTS[name]
|
||||
raise ValueError(f"Unsupported namespace attribute: {owner.name}.{name}")
|
||||
if isinstance(owner, datetime.datetime) and name in _SAFE_DATETIME_ATTRS:
|
||||
return self._datetime_attr(owner, name)
|
||||
if isinstance(owner, datetime.date) and name in {"year", "month", "day"}:
|
||||
return self._date_attr(owner, name)
|
||||
raise ValueError(f"Unsupported attribute access: {name}")
|
||||
|
||||
@staticmethod
|
||||
def _datetime_attr(value: datetime.datetime, name: str) -> int:
|
||||
if name == "year":
|
||||
return value.year
|
||||
if name == "month":
|
||||
return value.month
|
||||
if name == "day":
|
||||
return value.day
|
||||
if name == "hour":
|
||||
return value.hour
|
||||
if name == "minute":
|
||||
return value.minute
|
||||
if name == "second":
|
||||
return value.second
|
||||
if name == "microsecond":
|
||||
return value.microsecond
|
||||
raise ValueError(f"Unsupported datetime attribute: {name}")
|
||||
|
||||
@staticmethod
|
||||
def _date_attr(value: datetime.date, name: str) -> int:
|
||||
if name == "year":
|
||||
return value.year
|
||||
if name == "month":
|
||||
return value.month
|
||||
if name == "day":
|
||||
return value.day
|
||||
raise ValueError(f"Unsupported date attribute: {name}")
|
||||
|
||||
def _apply_binop(self, op: ast.operator, left: Any, right: Any) -> Any:
|
||||
if isinstance(op, ast.Add):
|
||||
return left + right
|
||||
if isinstance(op, ast.Sub):
|
||||
return left - right
|
||||
if isinstance(op, ast.Mult):
|
||||
return left * right
|
||||
if isinstance(op, ast.Div):
|
||||
return left / right
|
||||
if isinstance(op, ast.FloorDiv):
|
||||
return left // right
|
||||
if isinstance(op, ast.Mod):
|
||||
return left % right
|
||||
if isinstance(op, ast.Pow):
|
||||
return left**right
|
||||
raise ValueError(f"Unsupported operator: {type(op).__name__}")
|
||||
|
||||
def _apply_unaryop(self, op: ast.unaryop, value: Any) -> Any:
|
||||
if isinstance(op, ast.UAdd):
|
||||
return +value
|
||||
if isinstance(op, ast.USub):
|
||||
return -value
|
||||
if isinstance(op, ast.Not):
|
||||
return not self._truthy(value)
|
||||
raise ValueError(f"Unsupported unary operator: {type(op).__name__}")
|
||||
|
||||
def _resolve_boolop(self, node: ast.BoolOp) -> Any:
|
||||
if isinstance(node.op, ast.And):
|
||||
result = True
|
||||
for value_node in node.values:
|
||||
result = self._resolve(value_node)
|
||||
if not self._truthy(result):
|
||||
return result
|
||||
return result
|
||||
if isinstance(node.op, ast.Or):
|
||||
result = False
|
||||
for value_node in node.values:
|
||||
result = self._resolve(value_node)
|
||||
if self._truthy(result):
|
||||
return result
|
||||
return result
|
||||
raise ValueError(f"Unsupported boolean operator: {type(node.op).__name__}")
|
||||
|
||||
def _resolve_compare(self, node: ast.Compare) -> bool:
|
||||
left = self._resolve(node.left)
|
||||
for op, comparator in zip(node.ops, node.comparators):
|
||||
right = self._resolve(comparator)
|
||||
if not self._compare(op, left, right):
|
||||
return False
|
||||
left = right
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _compare(op: ast.cmpop, left: Any, right: Any) -> bool:
|
||||
if isinstance(op, ast.Eq):
|
||||
return left == right
|
||||
if isinstance(op, ast.NotEq):
|
||||
return left != right
|
||||
if isinstance(op, ast.Lt):
|
||||
return left < right
|
||||
if isinstance(op, ast.LtE):
|
||||
return left <= right
|
||||
if isinstance(op, ast.Gt):
|
||||
return left > right
|
||||
if isinstance(op, ast.GtE):
|
||||
return left >= right
|
||||
if isinstance(op, ast.Is):
|
||||
return left is right
|
||||
if isinstance(op, ast.IsNot):
|
||||
return left is not right
|
||||
if isinstance(op, ast.In):
|
||||
return left in right
|
||||
if isinstance(op, ast.NotIn):
|
||||
return left not in right
|
||||
raise ValueError(f"Unsupported comparison: {type(op).__name__}")
|
||||
|
||||
def _assign(self, target: ast.AST, value: Any) -> None:
|
||||
if isinstance(target, ast.Name):
|
||||
self._set_name(target.id, value)
|
||||
return
|
||||
if isinstance(target, (ast.Tuple, ast.List)):
|
||||
values = list(value)
|
||||
if len(target.elts) != len(values):
|
||||
raise ValueError("Unpack target and value length mismatch")
|
||||
for child, item in zip(target.elts, values):
|
||||
self._assign(child, item)
|
||||
return
|
||||
if isinstance(target, ast.Subscript):
|
||||
owner = self._resolve(target.value)
|
||||
owner[self._resolve_slice(target.slice)] = value
|
||||
return
|
||||
raise ValueError(f"Unsupported assignment target: {type(target).__name__}")
|
||||
|
||||
def _resolve_target(self, target: ast.AST) -> Any:
|
||||
if isinstance(target, ast.Name):
|
||||
return self._get_name(target.id)
|
||||
if isinstance(target, ast.Subscript):
|
||||
return self._resolve(target)
|
||||
raise ValueError(f"Unsupported assignment target: {type(target).__name__}")
|
||||
|
||||
def _find_name(self, name: str) -> Any:
|
||||
if name.startswith("__"):
|
||||
raise ValueError(f"Blocked name: {name}")
|
||||
for frame in reversed(self.frames):
|
||||
if name in frame:
|
||||
return frame[name]
|
||||
if name in ("random", "datetime", "math"):
|
||||
return _SafeNamespace(name)
|
||||
if name == "jev" and self.jev is not None:
|
||||
return _SafeNamespace(name)
|
||||
return None
|
||||
|
||||
def _get_name(self, name: str) -> Any:
|
||||
value = self._find_name(name)
|
||||
if value is not None or any(name in frame for frame in self.frames):
|
||||
return value
|
||||
raise NameError(f"Name is not defined: {name}")
|
||||
|
||||
def _set_name(self, name: str, value: Any) -> None:
|
||||
if name.startswith("__"):
|
||||
raise ValueError(f"Blocked name: {name}")
|
||||
self.frames[-1][name] = value
|
||||
|
||||
@staticmethod
|
||||
def _truthy(value: Any) -> bool:
|
||||
if isinstance(value, _JevProbability):
|
||||
raise ValueError("Compare jev.noul() with a threshold, or use jev.yes()")
|
||||
return bool(value)
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool: # allow any connection type
|
||||
@@ -52,13 +752,22 @@ class AnyType(str):
|
||||
_ANY_TYPE = AnyType("*")
|
||||
|
||||
|
||||
def _build_globals() -> Dict[str, Any]:
|
||||
return {
|
||||
"__builtins__": _ALLOWED_BUILTINS,
|
||||
"random": random,
|
||||
"datetime": datetime,
|
||||
"math": math,
|
||||
}
|
||||
def _validate_ast_tree(tree: ast.AST) -> None:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, _BLOCKED_AST_NODES):
|
||||
raise ValueError(
|
||||
f"Unsupported syntax in safe mode: {type(node).__name__}"
|
||||
)
|
||||
if isinstance(node, ast.Attribute) and node.attr.startswith("__"):
|
||||
raise ValueError(f"Blocked attribute access in safe mode: {node.attr}")
|
||||
if isinstance(node, ast.Name) and node.id.startswith("__"):
|
||||
raise ValueError(f"Blocked name in safe mode: {node.id}")
|
||||
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
if node.func.id in _BLOCKED_CALL_NAMES:
|
||||
raise ValueError(
|
||||
f"Blocked function call in safe mode: {node.func.id}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_outputs(locals_dict: Dict[str, Any]) -> None:
|
||||
@@ -158,43 +867,136 @@ class MultiOutputScript:
|
||||
in_value_2: Any = None,
|
||||
in_value_3: Any = None,
|
||||
):
|
||||
it1 = _select_text_value("", in_text_1, "in_text_1")
|
||||
it2 = _select_text_value("", in_text_2, "in_text_2")
|
||||
it3 = _select_text_value("", in_text_3, "in_text_3")
|
||||
iv1 = _select_numeric_value(0.0, in_value_1, "in_value_1")
|
||||
iv2 = _select_numeric_value(0.0, in_value_2, "in_value_2")
|
||||
iv3 = _select_numeric_value(0.0, in_value_3, "in_value_3")
|
||||
|
||||
locals_dict: Dict[str, Any] = {
|
||||
"it1": it1,
|
||||
"it2": it2,
|
||||
"it3": it3,
|
||||
"iv1": iv1,
|
||||
"iv2": iv2,
|
||||
"iv3": iv3,
|
||||
"ot1": None,
|
||||
"ot2": None,
|
||||
"ot3": None,
|
||||
"ov1": None,
|
||||
"ov2": None,
|
||||
"ov3": None,
|
||||
}
|
||||
|
||||
globals_dict = _build_globals()
|
||||
exec(code, globals_dict, locals_dict)
|
||||
|
||||
_validate_outputs(locals_dict)
|
||||
|
||||
return (
|
||||
locals_dict.get("ot1"),
|
||||
locals_dict.get("ot2"),
|
||||
locals_dict.get("ot3"),
|
||||
None if locals_dict.get("ov1") is None else int(locals_dict.get("ov1")),
|
||||
None if locals_dict.get("ov2") is None else int(locals_dict.get("ov2")),
|
||||
None if locals_dict.get("ov3") is None else int(locals_dict.get("ov3")),
|
||||
return _run_script(
|
||||
code, None, in_text_1, in_text_2, in_text_3, in_value_1, in_value_2, in_value_3
|
||||
)
|
||||
|
||||
|
||||
class MultiOutputScriptJev(MultiOutputScript):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
types = super().INPUT_TYPES()
|
||||
types["required"]["code"][1]["default"] = (
|
||||
"# Jev: jev.yes(state, question[, threshold]) -> bool\n"
|
||||
"# jev.noul(state, question) -> probability\n"
|
||||
"# jev.choice(state, question, options) -> str\n"
|
||||
"# jev.probabilities(state, question, options) -> {option: probability}\n"
|
||||
"# jev.score(state, question, levels) -> float\n"
|
||||
"# Example: pick an aspect ratio for the prompt in it1 and size it to iv1 megapixels\n"
|
||||
"megapixels = iv1 if iv1 > 0 else 1.0\n"
|
||||
"multiple = 64\n"
|
||||
"ratios = [\n"
|
||||
" \"1:1 (Square)\",\n"
|
||||
" \"2:3 (Portrait Photo)\",\n"
|
||||
" \"3:2 (Photo)\",\n"
|
||||
" \"3:4 (Portrait Standard)\",\n"
|
||||
" \"4:3 (Standard)\",\n"
|
||||
" \"9:16 (Portrait Widescreen)\",\n"
|
||||
" \"16:9 (Widescreen)\",\n"
|
||||
" \"21:9 (Ultrawide)\",\n"
|
||||
"]\n"
|
||||
"question = \"Which image aspect ratio best suits this prompt?\"\n"
|
||||
"best = jev.choice(it1, question, ratios)\n"
|
||||
"probs = jev.probabilities(it1, question, ratios)\n"
|
||||
"rw, rh = best.split(\" \")[0].split(\":\")\n"
|
||||
"pixels = megapixels * 1024 * 1024\n"
|
||||
"ov1 = round(math.sqrt(pixels * int(rw) / int(rh)) / multiple) * multiple\n"
|
||||
"ov2 = round(math.sqrt(pixels * int(rh) / int(rw)) / multiple) * multiple\n"
|
||||
"lines = [f\"{best} -> {ov1}x{ov2}\"]\n"
|
||||
"for ratio in ratios:\n"
|
||||
" lines.append(f\"{ratio}: {probs[ratio]:.3f}\")\n"
|
||||
"ot1 = \"\\n\".join(lines)\n"
|
||||
)
|
||||
types["required"]["model"] = ([_TYPESAFE_API] + list(list_local_models()),)
|
||||
types["required"]["max_jev_calls"] = ("INT", {"default": 8, "min": 1, "max": 64})
|
||||
return types
|
||||
|
||||
def run(
|
||||
self,
|
||||
code: str,
|
||||
model: str,
|
||||
max_jev_calls: int,
|
||||
in_text_1: Any = None,
|
||||
in_text_2: Any = None,
|
||||
in_text_3: Any = None,
|
||||
in_value_1: Any = None,
|
||||
in_value_2: Any = None,
|
||||
in_value_3: Any = None,
|
||||
):
|
||||
inputs = (in_text_1, in_text_2, in_text_3, in_value_1, in_value_2, in_value_3)
|
||||
if model == _TYPESAFE_API:
|
||||
with _typesafe_client() as client:
|
||||
return _run_script(code, JevBackend(TypeSafeJev(client), max_jev_calls), *inputs)
|
||||
model_path = list_local_models().get(model)
|
||||
if model_path is None:
|
||||
raise ValueError(f"GGUF model not found under models/LLM or models/text_encoders: {model}")
|
||||
try:
|
||||
return _run_script(code, JevBackend(LocalJev(model_path), max_jev_calls), *inputs)
|
||||
finally:
|
||||
unload_local_model()
|
||||
|
||||
|
||||
def _typesafe_client() -> Any:
|
||||
if TypeSafeClient is None:
|
||||
raise RuntimeError("typesafe-sdk is not installed. Run: pip install typesafe-sdk")
|
||||
if not _API_KEY_PATH.exists():
|
||||
raise EnvironmentError(
|
||||
f"API key file not found: {_API_KEY_PATH}\nPlease create this file with your TypeSafe API key."
|
||||
)
|
||||
api_key = _API_KEY_PATH.read_text(encoding="utf-8").strip()
|
||||
if not api_key:
|
||||
raise EnvironmentError(
|
||||
f'API key is not set in "{_API_KEY_PATH}"\nPlease add your TypeSafe API key to this file.'
|
||||
)
|
||||
return TypeSafeClient(api_key=api_key)
|
||||
|
||||
|
||||
def _run_script(
|
||||
code: str,
|
||||
jev: JevBackend | None,
|
||||
in_text_1: Any,
|
||||
in_text_2: Any,
|
||||
in_text_3: Any,
|
||||
in_value_1: Any,
|
||||
in_value_2: Any,
|
||||
in_value_3: Any,
|
||||
):
|
||||
it1 = _select_text_value("", in_text_1, "in_text_1")
|
||||
it2 = _select_text_value("", in_text_2, "in_text_2")
|
||||
it3 = _select_text_value("", in_text_3, "in_text_3")
|
||||
iv1 = _select_numeric_value(0.0, in_value_1, "in_value_1")
|
||||
iv2 = _select_numeric_value(0.0, in_value_2, "in_value_2")
|
||||
iv3 = _select_numeric_value(0.0, in_value_3, "in_value_3")
|
||||
|
||||
locals_dict: Dict[str, Any] = {
|
||||
"it1": it1,
|
||||
"it2": it2,
|
||||
"it3": it3,
|
||||
"iv1": iv1,
|
||||
"iv2": iv2,
|
||||
"iv3": iv3,
|
||||
"ot1": None,
|
||||
"ot2": None,
|
||||
"ot3": None,
|
||||
"ov1": None,
|
||||
"ov2": None,
|
||||
"ov3": None,
|
||||
}
|
||||
|
||||
locals_dict = _SafeScriptInterpreter(locals_dict, jev=jev).run(code)
|
||||
|
||||
_validate_outputs(locals_dict)
|
||||
|
||||
return (
|
||||
locals_dict.get("ot1"),
|
||||
locals_dict.get("ot2"),
|
||||
locals_dict.get("ot3"),
|
||||
None if locals_dict.get("ov1") is None else int(locals_dict.get("ov1")),
|
||||
None if locals_dict.get("ov2") is None else int(locals_dict.get("ov2")),
|
||||
None if locals_dict.get("ov3") is None else int(locals_dict.get("ov3")),
|
||||
)
|
||||
|
||||
|
||||
class Centi:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -227,10 +1029,12 @@ class Centi:
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"MultiOutputScript": MultiOutputScript,
|
||||
"MultiOutputScriptJev": MultiOutputScriptJev,
|
||||
"centi": Centi,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"MultiOutputScript": "MultiOutputScript",
|
||||
"MultiOutputScriptJev": "MultiOutputScript (Jev)",
|
||||
"centi": "centi",
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
+221
@@ -0,0 +1,221 @@
|
||||
# ComfyUI-ScriptFlow
|
||||
# Copyright (C) 2026 kantan-kanto (https://github.com/kantan-kanto)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# The decision readout is adapted from SemIf (formerly OpenJev),
|
||||
# Copyright (c) 2026 TheoLeeCJ, MIT License: https://github.com/TheoLeeCJ/SemIf-OpenJev
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import comfy.model_management
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from llama_cpp import Llama, llama_get_logits_ith, llama_get_memory, llama_memory_clear
|
||||
from llama_cpp.llama_chat_format import Jinja2ChatFormatter
|
||||
except ImportError:
|
||||
Llama = None
|
||||
|
||||
try:
|
||||
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient
|
||||
except ImportError:
|
||||
TypeSafeClient = None
|
||||
|
||||
|
||||
_LETTERS = "ABCDEFGHIJKLMNOP"
|
||||
_DIRECT_SYSTEM = (
|
||||
"Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. "
|
||||
"Respond with only its uppercase letter, with no explanation or reasoning."
|
||||
)
|
||||
_N_CTX = 8192
|
||||
|
||||
_loaded_model: tuple[str, Any, Any] | None = None
|
||||
|
||||
|
||||
class JevBackend:
|
||||
"""Caches answers and limits model calls for a single script run."""
|
||||
|
||||
def __init__(self, engine: Any, max_calls: int):
|
||||
self.engine = engine
|
||||
self.max_calls = max_calls
|
||||
self.calls = 0
|
||||
self.cache: dict[str, Any] = {}
|
||||
|
||||
def noul(self, state: Any, question: Any) -> float:
|
||||
return self._ask(state, "noul", question, None)[0]
|
||||
|
||||
def choice(self, state: Any, question: Any, options: list | dict) -> str:
|
||||
probabilities = self.probabilities(state, question, options)
|
||||
return max(probabilities, key=probabilities.get)
|
||||
|
||||
def probabilities(self, state: Any, question: Any, options: list | dict) -> dict[str, float]:
|
||||
if isinstance(options, list):
|
||||
options = {str(option): None for option in options}
|
||||
return dict(zip(options, self._ask(state, "choice", question, options)))
|
||||
|
||||
def score(self, state: Any, question: Any, levels: list) -> float:
|
||||
probabilities = self._ask(state, "score", question, list(levels))
|
||||
return sum(level * p for level, p in enumerate(probabilities))
|
||||
|
||||
def _ask(self, state: Any, kind: str, question: Any, criteria: Any) -> list[float]:
|
||||
key = json.dumps([state, kind, question, criteria], sort_keys=True, ensure_ascii=False)
|
||||
if key in self.cache:
|
||||
return self.cache[key]
|
||||
if self.calls >= self.max_calls:
|
||||
raise RuntimeError(f"Script exceeded max_jev_calls ({self.max_calls})")
|
||||
self.calls += 1
|
||||
probabilities = self.engine.ask(state, kind, question, criteria)
|
||||
self.cache[key] = probabilities
|
||||
return probabilities
|
||||
|
||||
|
||||
class LocalJev:
|
||||
"""Answers with a local GGUF model. Noul is asked as Yes/No options."""
|
||||
|
||||
def __init__(self, model_path: str):
|
||||
self.model_path = model_path
|
||||
|
||||
def ask(self, state: Any, kind: str, question: Any, criteria: Any) -> list[float]:
|
||||
if kind == "noul":
|
||||
options = ["Yes", "No"]
|
||||
elif kind == "choice":
|
||||
options = [label if description is None else f"{label}: {description}" for label, description in criteria.items()]
|
||||
else:
|
||||
options = criteria
|
||||
return _option_probabilities(self.model_path, state, question, options)
|
||||
|
||||
|
||||
class TypeSafeJev:
|
||||
"""Answers with TypeSafe's Jev API. Returns probabilities in the order of the options."""
|
||||
|
||||
def __init__(self, client: Any):
|
||||
self.client = client
|
||||
|
||||
def ask(self, state: Any, kind: str, question: Any, criteria: Any) -> list[float]:
|
||||
if kind == "noul":
|
||||
p = self._answer(state, Noul(instructions=question)).noul
|
||||
return [p, 1.0 - p]
|
||||
if kind == "choice":
|
||||
probabilities = self._answer(state, Choice(instructions=question, criteria=criteria)).probabilities
|
||||
return [probabilities[label] for label in criteria]
|
||||
probabilities = self._answer(state, Score(instructions=question, criteria=criteria)).probabilities
|
||||
return [probabilities[level] for level in range(len(criteria))]
|
||||
|
||||
def _answer(self, state: Any, question: Any) -> Any:
|
||||
return self.client.system_one(state=state, questions={"q": question}).answers["q"]
|
||||
|
||||
|
||||
def _option_probabilities(model_path: str, state: Any, question: Any, options: list) -> list[float]:
|
||||
"""Reads one decision from the next-token logits of the option letters, like SemIf's direct path."""
|
||||
if not 2 <= len(options) <= len(_LETTERS):
|
||||
raise ValueError(f"Jev questions need 2-{len(_LETTERS)} options, got {len(options)}")
|
||||
llm, formatter = load_local_model(model_path)
|
||||
payload = {
|
||||
"evidence": state,
|
||||
"criterion": question,
|
||||
"options": [{"letter": letter, "description": option} for letter, option in zip(_LETTERS, options)],
|
||||
}
|
||||
messages = [
|
||||
{"role": "system", "content": _DIRECT_SYSTEM},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||||
]
|
||||
prompt = formatter(messages=messages, enable_thinking=False).prompt
|
||||
tokens = llm.tokenize(prompt.encode("utf-8"), add_bos=False, special=True)
|
||||
if len(tokens) >= llm.n_ctx():
|
||||
raise ValueError(f"Jev prompt is {len(tokens)} tokens, over the {llm.n_ctx()} token context")
|
||||
slots = []
|
||||
for letter in _LETTERS[: len(options)]:
|
||||
encoded = llm.tokenize(letter.encode("utf-8"), add_bos=False, special=False)
|
||||
if len(encoded) != 1:
|
||||
raise ValueError(f"Model tokenizer does not encode {letter!r} as one token")
|
||||
slots.append(encoded[0])
|
||||
# reset() alone keeps the recurrent state of hybrid models such as Qwen3.5
|
||||
llama_memory_clear(llama_get_memory(llm.ctx), True)
|
||||
llm.reset()
|
||||
llm.eval(tokens)
|
||||
# Llama.scores is not filled without logits_all in upstream llama-cpp-python, so read the context directly
|
||||
vocabulary = np.ctypeslib.as_array(llama_get_logits_ith(llm.ctx, -1), shape=(llm.n_vocab(),))
|
||||
logits = vocabulary[slots].astype(np.float64)
|
||||
weights = np.exp(logits - logits.max())
|
||||
return (weights / weights.sum()).tolist()
|
||||
|
||||
|
||||
def _model_dirs() -> list[str]:
|
||||
dirs = []
|
||||
for key in ("LLM", "llm", "text_encoders"):
|
||||
try:
|
||||
dirs.extend(folder_paths.get_folder_paths(key))
|
||||
except KeyError:
|
||||
pass
|
||||
dirs.append(os.path.join(folder_paths.models_dir, "LLM"))
|
||||
return list(dict.fromkeys(os.path.normpath(d) for d in dirs))
|
||||
|
||||
|
||||
def list_local_models() -> dict[str, str]:
|
||||
"""GGUF language models under models/LLM and models/text_encoders, keyed by display name."""
|
||||
models = {}
|
||||
for root in _model_dirs():
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for current_dir, _, files in os.walk(root):
|
||||
for file_name in files:
|
||||
if file_name.endswith(".gguf") and not file_name.startswith("mmproj"):
|
||||
path = os.path.join(current_dir, file_name)
|
||||
models[_display_name(path)] = path
|
||||
return dict(sorted(models.items(), key=lambda item: item[0].lower()))
|
||||
|
||||
|
||||
def _display_name(path: str) -> str:
|
||||
# models registered through extra_model_paths.yaml may be outside models_dir or on another drive
|
||||
try:
|
||||
rel = os.path.relpath(path, folder_paths.models_dir)
|
||||
except ValueError:
|
||||
return path
|
||||
return path if rel.startswith("..") else rel.replace("\\", "/")
|
||||
|
||||
|
||||
def load_local_model(model_path: str) -> tuple[Any, Any]:
|
||||
global _loaded_model
|
||||
if _loaded_model is not None and _loaded_model[0] == model_path:
|
||||
return _loaded_model[1], _loaded_model[2]
|
||||
if Llama is None:
|
||||
raise RuntimeError("llama-cpp-python is not installed. See the README for installation.")
|
||||
unload_local_model()
|
||||
device = comfy.model_management.get_torch_device()
|
||||
comfy.model_management.free_memory(os.path.getsize(model_path) * 1.2, device)
|
||||
llm = Llama(model_path=model_path, n_ctx=_N_CTX, n_gpu_layers=-1, verbose=False)
|
||||
template = llm.metadata.get("tokenizer.chat_template")
|
||||
if not template:
|
||||
llm.close()
|
||||
raise ValueError(f"GGUF has no chat template: {model_path}")
|
||||
bos, eos = llm.token_bos(), llm.token_eos()
|
||||
formatter = Jinja2ChatFormatter(
|
||||
template=template,
|
||||
bos_token=llm.detokenize([bos], special=True).decode("utf-8") if bos != -1 else "",
|
||||
eos_token=llm.detokenize([eos], special=True).decode("utf-8") if eos != -1 else "",
|
||||
)
|
||||
_loaded_model = (model_path, llm, formatter)
|
||||
return llm, formatter
|
||||
|
||||
|
||||
def unload_local_model() -> None:
|
||||
# Same cleanup as ComfyUI-LLM-Session's Unload LLM Model: drop the model, collect, then empty the device cache
|
||||
global _loaded_model
|
||||
if _loaded_model is None:
|
||||
return
|
||||
llm = _loaded_model[1]
|
||||
_loaded_model = None
|
||||
llm.close()
|
||||
del llm
|
||||
gc.collect()
|
||||
comfy.model_management.soft_empty_cache()
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI-ScriptFlow"
|
||||
description = "Python script node for ComfyUI supporting math, calc, logic, and text editing with multi output. Handle conditional workflows, auto-select resolution, parse LLM outputs, and generate filenames — all in one flexible node."
|
||||
version = "1.1.0"
|
||||
description = "Safe Python-like script node with a System One decision model: ask yes/no, choice, and score questions via TypeSafe AI's Jev API, with local GGUF LLMs as a fallback. Multiple inputs and outputs let one node handle math, logic, text editing, conditional routing, resolution selection, filename generation, and more."
|
||||
version = "1.3.0"
|
||||
license = {file = "LICENSE"}
|
||||
# classifiers = [
|
||||
# # For OS-independent nodes (works on all operating systems)
|
||||
|
||||
Reference in New Issue
Block a user