|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import os |
|
|
import re |
|
|
import traceback |
|
|
import importlib |
|
|
from typing import Any, List, Tuple, Optional |
|
|
|
|
|
import gradio as gr |
|
|
from modules import script_callbacks, scripts, shared |
|
|
from modules.shared import opts |
|
|
|
|
|
|
|
|
from modules import prompt_parser as pp |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _highlight_at(text: str, line: int, column: int, context: int = 2) -> str: |
|
|
"""Возвращает компактный подсвет с кареткой и близлежащими строками.""" |
|
|
lines = text.splitlines() |
|
|
i = max(0, line - 1) |
|
|
start = max(0, i - context) |
|
|
end = min(len(lines), i + context + 1) |
|
|
|
|
|
out = [] |
|
|
for idx in range(start, end): |
|
|
prefix = ">> " if idx == i else " " |
|
|
out.append(f"{prefix}{idx+1:>4}: {lines[idx]}") |
|
|
if idx == i: |
|
|
caret = " " * (column + 7 + len(str(idx+1))) + "^" |
|
|
out.append(caret) |
|
|
return "\n".join(out) |
|
|
|
|
|
def _guess_suggestion(src: str) -> List[str]: |
|
|
"""Простые эвристики подсказок по частым ошибкам.""" |
|
|
tips = [] |
|
|
if src.count("(") != src.count(")"): |
|
|
tips.append("Скобки () несбалансированы — проверьте пары.") |
|
|
if src.count("[") != src.count("]"): |
|
|
tips.append("Скобки [] несбалансированы — проверьте пары.") |
|
|
if src.count("{") != src.count("}"): |
|
|
tips.append("Скобки {} несбалансированы — проверьте пары.") |
|
|
if "::" in src and not any(term in src for term in ["!", ";", "!!"]): |
|
|
tips.append("Похоже, вы используете sequence (::), но не поставили терминатор (! или ; / !! для top-level).") |
|
|
if re.search(r":[A-Za-z]", src): |
|
|
tips.append("После ':' обычно ожидается число для веса (например, :1.2) — проверьте места с двоеточием.") |
|
|
if re.search(r"\[[^]]+\]!\s*!", src): |
|
|
tips.append("alternate_distinct записывается как [a|b]! — одно '!' сразу после ']'.") |
|
|
return tips |
|
|
|
|
|
def format_lark_error(prompt: str, err: Exception) -> str: |
|
|
"""Превращаем Lark-исключение в понятный отчёт с подсветкой и советами.""" |
|
|
title = f"**Ошибка парсинга:** `{type(err).__name__}`: {getattr(err, 'message', str(err))}" |
|
|
try: |
|
|
line = getattr(err, 'line', None) |
|
|
column = getattr(err, 'column', None) |
|
|
pos = getattr(err, 'pos_in_stream', None) |
|
|
except Exception: |
|
|
line = column = pos = None |
|
|
|
|
|
block = "" |
|
|
if line is not None and column is not None: |
|
|
block = "```\n" + _highlight_at(prompt, line, column) + "\n```" |
|
|
elif pos is not None: |
|
|
prefix = prompt[:pos] |
|
|
l = prefix.count("\n") + 1 |
|
|
c = len(prefix.split("\n")[-1]) + 1 |
|
|
block = "```\n" + _highlight_at(prompt, l, c) + "\n```" |
|
|
|
|
|
tips = _guess_suggestion(prompt) |
|
|
tips_md = "\n".join([f"- {t}" for t in tips]) if tips else "- Проверьте синтаксис согласно подсказкам во вкладке «Справка и примеры»." |
|
|
return f"{title}\n\n{block}\n\n**Как исправить:**\n{tips_md}\n" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EnvState: |
|
|
def __init__(self): |
|
|
self.allow_empty_alt = os.environ.get("ALLOW_EMPTY_ALTERNATE", "0") |
|
|
self.expand_alt_per_step = os.environ.get("EXPAND_ALTERNATE_PER_STEP", "1") |
|
|
self.group_combo_limit = os.environ.get("GROUP_COMBO_LIMIT", "100") |
|
|
|
|
|
def apply(self, allow_empty_alt: bool, expand_alt: bool, combo_limit: int) -> bool: |
|
|
"""Вернёт True, если переменные изменились и модуль нужно перезагрузить.""" |
|
|
changed = False |
|
|
new_allow = "1" if allow_empty_alt else "0" |
|
|
new_expand = "1" if expand_alt else "0" |
|
|
new_limit = str(int(combo_limit)) |
|
|
|
|
|
if new_allow != self.allow_empty_alt: |
|
|
os.environ["ALLOW_EMPTY_ALTERNATE"] = new_allow |
|
|
self.allow_empty_alt = new_allow |
|
|
changed = True |
|
|
|
|
|
if new_expand != self.expand_alt_per_step: |
|
|
os.environ["EXPAND_ALTERNATE_PER_STEP"] = new_expand |
|
|
self.expand_alt_per_step = new_expand |
|
|
changed = True |
|
|
|
|
|
if new_limit != self.group_combo_limit: |
|
|
os.environ["GROUP_COMBO_LIMIT"] = new_limit |
|
|
self.group_combo_limit = new_limit |
|
|
changed = True |
|
|
|
|
|
return changed |
|
|
|
|
|
ENV_STATE = EnvState() |
|
|
|
|
|
def maybe_reload_prompt_parser(allow_empty_alt: bool, expand_alt: bool, combo_limit: int): |
|
|
"""Если флаги изменились, перезагружаем modules.prompt_parser, чтобы обновить грамматику.""" |
|
|
changed = ENV_STATE.apply(allow_empty_alt, expand_alt, combo_limit) |
|
|
if changed: |
|
|
global pp |
|
|
from modules import prompt_parser as _pp |
|
|
pp = importlib.reload(_pp) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_parse_tree(prompt: str): |
|
|
"""Пробует распарсить и вернуть (tree_str, error_md).""" |
|
|
try: |
|
|
tree = pp.schedule_parser.parse(prompt) |
|
|
tree_str = tree.pretty() if hasattr(tree, "pretty") else str(tree) |
|
|
return tree_str, "" |
|
|
except Exception as e: |
|
|
return "", format_lark_error(prompt, e) |
|
|
|
|
|
def _make_schedule(prompt: str, steps: int, seed: Optional[int], use_visitor: bool) -> Tuple[List[List[Any]], str]: |
|
|
"""Возвращает (schedule, error_md). schedule: [[end_step, text], ...]""" |
|
|
try: |
|
|
schedule = pp.get_schedule(prompt, steps, True, seed, use_visitor=use_visitor) |
|
|
if not schedule: |
|
|
return [], "**Пустое расписание.** Проверьте синтаксис." |
|
|
return schedule, "" |
|
|
except Exception as e: |
|
|
return [], f"**Ошибка при построении расписания:** {e}" |
|
|
|
|
|
def _schedule_table_md(schedule: List[List[Any]]) -> str: |
|
|
"""Markdown-таблица из расписания.""" |
|
|
rows = ["| end_step | text |", "|---:|---|"] |
|
|
for end_step, text in schedule: |
|
|
safe_text = text.replace("|", "\\|") |
|
|
rows.append(f"| {end_step} | {safe_text} |") |
|
|
return "\n".join(rows) |
|
|
|
|
|
def _per_step_timeline_md(schedule: List[List[Any]], steps: int) -> str: |
|
|
"""Показывает активный текст на каждом шаге 1..steps.""" |
|
|
rows = ["| step | active text |", "|---:|---|"] |
|
|
for s in range(1, steps + 1): |
|
|
txt = pp.at_step_from_schedule(s, schedule) |
|
|
safe_txt = txt.replace("|", "\\|") |
|
|
rows.append(f"| {s} | {safe_txt} |") |
|
|
return "\n".join(rows) |
|
|
|
|
|
def _compare_vis_vs_trans(prompt: str, steps: int, seed: Optional[int]) -> str: |
|
|
"""Сравнение use_visitor=True/False.""" |
|
|
vis, e1 = _make_schedule(prompt, steps, seed, True) |
|
|
trn, e2 = _make_schedule(prompt, steps, seed, False) |
|
|
if e1 or e2: |
|
|
return (e1 or "") + (e2 or "") |
|
|
md = ["**Visitor (сбор в визиторе)**", _schedule_table_md(vis), |
|
|
"", "**Transformer (пересбор текста на end_step)**", _schedule_table_md(trn)] |
|
|
return "\n\n".join(md) |
|
|
|
|
|
def analyze(prompt: str, |
|
|
steps: int, |
|
|
seed: Optional[int], |
|
|
use_visitor: bool, |
|
|
allow_empty_alt: bool, |
|
|
expand_alt_per_step: bool, |
|
|
group_combo_limit: int, |
|
|
view_mode: str, |
|
|
at_step_n: int) -> Tuple[str, str, str, str, str]: |
|
|
""" |
|
|
Главная точка: возвращаем 5 выходов: |
|
|
1) status_md — статус/ошибки/подсказки |
|
|
2) schedule_md — таблица расписания |
|
|
3) timeline_md — поминутка по шагам |
|
|
4) text_at_step_md — текст на указанном шаге |
|
|
5) tree_md — дерево/сравнение, в зависимости от режима |
|
|
""" |
|
|
|
|
|
try: |
|
|
maybe_reload_prompt_parser(allow_empty_alt, expand_alt_per_step, group_combo_limit) |
|
|
except Exception as e: |
|
|
err = f"**Не удалось применить ENV-флаги:** {e}" |
|
|
return err, "", "", "", "" |
|
|
|
|
|
status_md = "" |
|
|
tree_md = "" |
|
|
schedule_md = "" |
|
|
timeline_md = "" |
|
|
text_at_step_md = "" |
|
|
|
|
|
|
|
|
tree_str, err_md = _safe_parse_tree(prompt) |
|
|
if err_md: |
|
|
status_md = err_md |
|
|
else: |
|
|
status_md = "**OK:** Парсинг прошёл успешно." |
|
|
if view_mode == "Parse tree": |
|
|
tree_md = "```\n" + tree_str + "\n```" |
|
|
|
|
|
|
|
|
schedule, sched_err = _make_schedule(prompt, steps, seed, use_visitor) |
|
|
if sched_err: |
|
|
status_md += "\n\n" + sched_err |
|
|
return status_md, "", "", "", tree_md |
|
|
|
|
|
|
|
|
if view_mode in ("Schedule", "All"): |
|
|
schedule_md = _schedule_table_md(schedule) |
|
|
|
|
|
if view_mode in ("Timeline (per step)", "All"): |
|
|
timeline_md = _per_step_timeline_md(schedule, steps) |
|
|
|
|
|
|
|
|
try: |
|
|
t = pp.at_step_from_schedule(at_step_n, schedule) |
|
|
text_at_step_md = f"**Шаг {at_step_n}:** `{t}`" |
|
|
except Exception as e: |
|
|
text_at_step_md = f"Ошибка получения текста на шаге {at_step_n}: {e}" |
|
|
|
|
|
|
|
|
if view_mode == "Visitor vs Transformer": |
|
|
tree_md = _compare_vis_vs_trans(prompt, steps, seed) |
|
|
|
|
|
return status_md, schedule_md, timeline_md, text_at_step_md, tree_md |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_group(items_csv: str) -> str: |
|
|
"""'a, b, c' -> '{ a, b, c }'""" |
|
|
items = [s.strip() for s in items_csv.split(",") if s.strip()] |
|
|
return "{ " + ", ".join(items) + " }" if items else "{}" |
|
|
|
|
|
def build_alternate(items_bar: str, distinct: bool) -> str: |
|
|
"""'a|b|c' -> '[ a|b|c ]' или '[ a|b|c ]!'""" |
|
|
core = items_bar.strip() |
|
|
if not core: |
|
|
return "[]" |
|
|
return f"[ {core} ]!" if distinct else f"[ {core} ]" |
|
|
|
|
|
def build_numbered(n: int, options_bar_or_group: str, distinct: bool) -> str: |
|
|
"""N и 'a|b|c' или '{...}' -> 'N! {...}' / 'N {...}'""" |
|
|
n = max(1, int(n)) |
|
|
body = options_bar_or_group.strip() |
|
|
mark = "!" if distinct else "" |
|
|
return f"{n}{mark} {body}" |
|
|
|
|
|
def build_sequence(owner: str, pairs_text: str, terminator: str) -> str: |
|
|
""" |
|
|
owner='character' |
|
|
pairs_text='outfit: leather jacket; mood: brooding' |
|
|
terminator = '!' или ';' |
|
|
""" |
|
|
owner = owner.strip() |
|
|
pairs = [] |
|
|
for chunk in pairs_text.split(";"): |
|
|
chunk = chunk.strip() |
|
|
if not chunk: |
|
|
continue |
|
|
if ":" not in chunk: |
|
|
pairs.append(chunk) |
|
|
else: |
|
|
k, v = chunk.split(":", 1) |
|
|
pairs.append(f"{k.strip()} :: {v.strip()}") |
|
|
term = "!" if terminator == "!" else ";" |
|
|
return f"{owner} :: " + ", ".join(pairs) + f" {term}" |
|
|
|
|
|
def build_top_level_sequence(owner: str, pairs_text: str, trailing_plain: str) -> str: |
|
|
"""top-level: owner ::: ... !! , trailing""" |
|
|
seq = build_sequence(owner, pairs_text, "!") |
|
|
inner = seq.replace(f"{owner} :: ", "").rstrip("!;").strip() |
|
|
tail = (", " + trailing_plain.strip()) if trailing_plain.strip() else "" |
|
|
return f"{owner.strip()} ::: {inner} !!{tail}" |
|
|
|
|
|
def helpers_demo_md() -> str: |
|
|
return """ |
|
|
### Быстрые шпаргалки (конструкторы) |
|
|
|
|
|
- **Group:** `{ a, b, c }` |
|
|
Смысл: держим фразы как один блок (можно затем оборачивать в `1! { ... }`, `N! {...}`). |
|
|
|
|
|
- **Alternates:** `[ a | b | c ]` / `[ a | b | c ]!` |
|
|
Карусель по шагам (если EXPAND_ALT_PER_STEP=1) или один выбор на прогон (`]!`). |
|
|
|
|
|
- **Numbered:** `3! { a | b | c | d }` |
|
|
Возьми 3 уникальных варианта без повторов (или `3 { ... }` с повторами). |
|
|
|
|
|
- **Sequence:** |
|
|
`character :: outfit :: leather jacket, mood :: brooding !` |
|
|
|
|
|
- **Top-level sequence + хвост:** |
|
|
`portrait ::: hair :: auburn, eyes :: blue !!, ultra-detailed skin texture` |
|
|
|
|
|
- **Scheduled (особый случай с одним элементом):** |
|
|
`[ "add rim light" ] : 25` — до 25-го шага пусто, после — фраза включается. |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PromptParserAnalyzerScript(scripts.Script): |
|
|
def title(self): |
|
|
return "Prompt Parser Analyzer" |
|
|
|
|
|
def show(self, is_txt2img): |
|
|
return scripts.AlwaysVisible |
|
|
|
|
|
def ui(self, is_txt2img): |
|
|
with gr.Group(): |
|
|
with gr.Accordion("Prompt Parser Analyzer", open=False): |
|
|
with gr.Tabs(): |
|
|
with gr.Tab("Analyzer"): |
|
|
prompt_in = gr.Textbox(label="Source Prompt", lines=6, placeholder="Вставьте промпт…") |
|
|
with gr.Row(): |
|
|
steps = gr.Slider(1, 300, value=50, step=1, label="Steps") |
|
|
seed = gr.Number(value=42, precision=0, label="Seed (для случайных конструкций)") |
|
|
at_step_n = gr.Slider(1, 300, value=25, step=1, label="Показать текст на шаге N") |
|
|
with gr.Row(): |
|
|
use_visitor = gr.Checkbox(value=True, label="use_visitor (быстрее; alternate2 может отличаться)") |
|
|
view_mode = gr.Dropdown( |
|
|
choices=["All", "Schedule", "Timeline (per step)", "Parse tree", "Visitor vs Transformer"], |
|
|
value="All", |
|
|
label="Режим вывода", |
|
|
) |
|
|
with gr.Row(): |
|
|
allow_empty_alt = gr.Checkbox(value=False, label="ALLOW_EMPTY_ALTERNATE (разрешать пустые [a||b])") |
|
|
expand_alt = gr.Checkbox(value=True, label="EXPAND_ALTERNATE_PER_STEP (чередовать по шагам)") |
|
|
group_limit = gr.Slider(1, 5000, value=100, step=1, label="GROUP_COMBO_LIMIT") |
|
|
|
|
|
analyze_btn = gr.Button("Analyze", variant="primary") |
|
|
|
|
|
status_md = gr.Markdown() |
|
|
schedule_md = gr.Markdown() |
|
|
timeline_md = gr.Markdown() |
|
|
text_at_step_md = gr.Markdown() |
|
|
tree_md = gr.Markdown() |
|
|
|
|
|
with gr.Tab("Builders"): |
|
|
gr.Markdown("### Конструкторы Lark-грамматики (собирают корректный синтаксис)") |
|
|
with gr.Row(): |
|
|
grp_items = gr.Textbox(label="Group items (через запятую)", placeholder="a, b, c") |
|
|
grp_out = gr.Textbox(label="Результат", lines=2) |
|
|
grp_btn = gr.Button("Build {group}") |
|
|
with gr.Row(): |
|
|
alt_items = gr.Textbox(label="Alternates (через |)", placeholder="a|b|c") |
|
|
alt_distinct = gr.Checkbox(value=False, label="distinct (!)") |
|
|
alt_out = gr.Textbox(label="Результат", lines=2) |
|
|
alt_btn = gr.Button("Build [alternate]") |
|
|
with gr.Row(): |
|
|
num_n = gr.Number(value=3, precision=0, label="N (сколько взять)") |
|
|
num_body = gr.Textbox(label="Опции: '{...}' или 'a|b|c'", placeholder="{ a | b | c } или a|b|c") |
|
|
num_distinct = gr.Checkbox(value=True, label="distinct (!)") |
|
|
num_out = gr.Textbox(label="Результат", lines=2) |
|
|
num_btn = gr.Button("Build numbered") |
|
|
gr.Markdown("—") |
|
|
with gr.Row(): |
|
|
seq_owner = gr.Textbox(label="Sequence owner", value="character") |
|
|
seq_pairs = gr.Textbox(label="Sequence pairs (k:v; k:v)", value="outfit: leather jacket; mood: brooding") |
|
|
seq_term = gr.Dropdown(choices=["!", ";"], value="!", label="Terminator") |
|
|
seq_out = gr.Textbox(label="Результат", lines=2) |
|
|
seq_btn = gr.Button("Build sequence") |
|
|
with gr.Row(): |
|
|
top_owner = gr.Textbox(label="Top-level owner", value="portrait") |
|
|
top_pairs = gr.Textbox(label="Pairs (k:v; k:v)", value="hair: auburn; eyes: blue") |
|
|
top_tail = gr.Textbox(label="Trailing plain (опционально)", value="ultra-detailed skin texture") |
|
|
top_out = gr.Textbox(label="Результат", lines=2) |
|
|
top_btn = gr.Button("Build top-level sequence") |
|
|
|
|
|
analyze_btn.click( |
|
|
fn=analyze, |
|
|
inputs=[prompt_in, steps, seed, use_visitor, allow_empty_alt, expand_alt, group_limit, view_mode, at_step_n], |
|
|
outputs=[status_md, schedule_md, timeline_md, text_at_step_md, tree_md], |
|
|
) |
|
|
|
|
|
grp_btn.click(fn=build_group, inputs=[grp_items], outputs=[grp_out]) |
|
|
alt_btn.click(fn=build_alternate, inputs=[alt_items, alt_distinct], outputs=[alt_out]) |
|
|
num_btn.click(fn=build_numbered, inputs=[num_n, num_body, num_distinct], outputs=[num_out]) |
|
|
seq_btn.click(fn=build_sequence, inputs=[seq_owner, seq_pairs, seq_term], outputs=[seq_out]) |
|
|
top_btn.click(fn=build_top_level_sequence, inputs=[top_owner, top_pairs, top_tail], outputs=[top_out]) |
|
|
|
|
|
return [prompt_in, steps, seed, at_step_n, use_visitor] |
|
|
|
|
|
def on_ui_settings(): |
|
|
section = ('prompt-parser-analyzer', "Prompt Parser Analyzer") |
|
|
|
|
|
|
|
|
|
|
|
script_callbacks.on_ui_settings(on_ui_settings) |
|
|
|