"""Gradio UI для OmniSVG-Lite. Слои: 1. UI-события → callbacks (on_generate / on_history_select) 2. callbacks → lm_client + prompts + validator + renderer + history 3. ошибки ловятся, отображаются в UI, попадают в history со status='failed' Запуск: `python app.py` → http://127.0.0.1:7860 """ from __future__ import annotations import logging import os import time import uuid from pathlib import Path from typing import Any, Iterator import gradio as gr # type: ignore # gradio is required to launch UI; tests can mock it from history import DEFAULT_DB_PATH, History, Record from incremental_svg import parse_to_valid from lm_client import ( DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_TIMEOUT_S, LMStudioUnavailable, chat, encode_pil_to_data_url, stream_chat, validate_image, ) from prompts import build_messages, load_system_prompt from renderer import render_png, save_png from validator import validate_svg # --------------------------------------------------------------------------- # Live-режим: настройки и состояние # --------------------------------------------------------------------------- # Минимальный интервал между yield'ами обновления превью (в секундах). # 150 мс ≈ 6-7 обновлений/сек на быстром стриме — глазом воспринимается # плавно, не перегружает Gradio/GPU-рендер. Магическое число 0.15 встречается # в этом файле именно в этом контексте; используется также в тестах. LIVE_THROTTLE_S: float = 0.15 # Токен отмены: инкрементируется при каждом новом live-запросе. Активный # генератор сравнивает свой токен с текущим; если не совпадает — отменяется # (backpressure на случай, если юзер успел отправить новый запрос, пока # старый ещё стримился). _live_cancel_token: int = 0 # --------------------------------------------------------------------------- # Логгер и настройки (env можно переопределить) # --------------------------------------------------------------------------- log = logging.getLogger("omnisvg") if not log.handlers: logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)-7s %(name)s | %(message)s", ) DEFAULT_PREVIEW_DIR = Path( os.environ.get("OMNISVG_PREVIEW_DIR", str(Path.home() / ".omnisvg_lite" / "previews")) ) PREVIEW_SIZE = { "icon": (256, 256), "illustration": (512, 512), } # --------------------------------------------------------------------------- # Хелперы # --------------------------------------------------------------------------- def _format_ts(ts: float) -> str: """Превращает time.time() в человеко-читаемую дату.""" try: return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) except Exception: # noqa: BLE001 return str(ts) def _check_prompt(prompt: str) -> str: """Возвращает очищенный промпт или бросает ValueError с понятной причиной.""" if prompt is None: raise ValueError("промпт пустой") cleaned = prompt.strip() if not (1 <= len(cleaned) <= 1000): raise ValueError("промпт должен быть от 1 до 1000 символов") return cleaned def _history_to_dataframe(records: list[dict[str, Any]]) -> list[list[Any]]: """Превращает список записей в плоский список строк для gr.Dataframe.""" rows: list[list[Any]] = [] for r in records: rows.append( [ r["id"], _format_ts(r["created_at"]), r["mode"], r["model"], r["n_requested"], r["n_returned"], r["status"], ] ) return rows def _refresh_history_df(limit: int = 20) -> list[list[Any]]: with History() as h: return _history_to_dataframe(h.list_recent(limit=limit)) # Число output-полей on_generate должно совпадать с сигнатурой ниже. _ON_GENERATE_NOUTPUTS = 7 def _empty_result(n_candidates: int) -> tuple[list, list, str, list, str, list, str]: """Стандартный «пустой» возврат on_generate для случаев раннего выхода. Gradio ждёт от каждого callback ровно столько значений, сколько объявлено в `outputs=[...]`. Когда мы хотим прервать работу через `gr.Warning()` / `gr.Error()` (а не через raise), мы ОБЯЗАНЫ вернуть плейсхолдеры для всех outputs, иначе Gradio поднимет `IndexError`/warning. Returns: Кортеж из 7 элементов: ([] , [] , "" , [] , "" , [] , ""). """ return ([], [], "", [], "", [], "") def _save_all_previews( record_id: int, svgs: list[str], mode: str, ) -> list[str]: """Рендерит PNG для каждого валидного SVG и сохраняет на диск. Returns: Список путей к PNG в том же порядке, что и svgs. Если рендер упал — вместо пути идёт пустая строка. """ out: list[str] = [] size = PREVIEW_SIZE.get(mode, (512, 512)) for i, svg in enumerate(svgs): png = render_png(svg, size=size) if png is None: log.warning("превью #%d пропущено: рендер не удался", i) out.append("") continue try: path = save_png( png, previews_dir=DEFAULT_PREVIEW_DIR, record_id=record_id, candidate_index=i, ) out.append(str(path)) except OSError as exc: log.warning("не удалось сохранить превью #%d: %s", i, exc) out.append("") return out # --------------------------------------------------------------------------- # Callbacks # --------------------------------------------------------------------------- def on_mode_change(mode: str) -> dict: """Меняет дефолт n_candidates при смене mode. По умолчанию 1 (один экземпляр).""" return gr.update(value=1) def fetch_lm_studio_models(base_url: str, api_key: str) -> tuple[list[str], str]: """Опрашивает LM Studio `/v1/models` и возвращает (список_id, статус). Args: base_url: например, http://127.0.0.1:1234/v1 api_key: bearer-токен (LM Studio игнорирует значение, но требует заголовок). Returns: (models, status_message). models — список id моделей, может быть пустым при ошибке. status_message — текст для UI ("OK: 12 моделей" / "ошибка: ..."). """ if not base_url or not base_url.strip(): return ([], "ошибка: пустой URL") base = base_url.strip().rstrip("/") # Если передали корень без /v1 — добавим if not base.endswith("/v1"): base = base + "/v1" url = base + "/models" headers = {"Authorization": f"Bearer {api_key or 'lm-studio'}"} try: import httpx r = httpx.get(url, headers=headers, timeout=10) if r.status_code != 200: return ([], f"ошибка HTTP {r.status_code}: {r.text[:200]}") data = r.json() items = data.get("data") or [] ids = [str(m.get("id")) for m in items if m.get("id")] if not ids: return ([], "OK, но список пуст") return (ids, f"OK: найдено {len(ids)} моделей") except Exception as exc: # noqa: BLE001 return ([], f"ошибка: {type(exc).__name__}: {exc}") def on_generate( prompt: str, mode: str, n_candidates: int, temperature: float, image: Any, palette: str, model: str, base_url: str, api_key: str, ) -> tuple[list[tuple[str, str]], list[dict], str, list[list[Any]], str, list[str], str]: """Обрабатывает клик «Сгенерировать». Returns: (gallery, gallery_hidden_value, svg_text_for_code, history_df, status_md, preview_paths, error_or_status) — последняя строка для ErrorBanner. """ # 1. UI pre-check # ВАЖНО: в Gradio 5.x `gr.Warning` и `gr.Error` — это ФУНКЦИИ, а не # исключения. Их нужно ВЫЗЫВАТЬ (а не `raise`). Проверено в Grad 5.37.0: # `raise gr.Warning(...)` → TypeError. Если кто-то в будущем вернётся к # `raise`, регрессионный тест test_on_generate_no_raise_on_bad_input # в tests/test_app.py это поймает. clean_prompt, n_candidates, mode = _common_precheck( prompt, mode, n_candidates, image ) if clean_prompt is None: return _empty_result(n_candidates) model = model or os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL) # 2. Сборка messages image_b64: str | None = None if image is not None: try: image_b64 = encode_pil_to_data_url(image, mime="image/png") except Exception as exc: # noqa: BLE001 gr.Warning(f"не удалось закодировать изображение: {exc}") return _empty_result(n_candidates) palette_clean = palette.strip() if palette else None try: messages = build_messages( prompt=clean_prompt, mode=mode, image_b64=image_b64, palette=palette_clean, n=n_candidates, temperature=float(temperature), ) except Exception as exc: # noqa: BLE001 log.exception("build_messages упал") gr.Error(f"ошибка сборки промпта: {exc}") return _empty_result(n_candidates) # 3. Запрос в LM Studio log.info("генерация: mode=%s n=%d temp=%.2f model=%s", mode, n_candidates, temperature, model) try: result = chat( messages=messages, model=model, n=n_candidates, temperature=float(temperature), base_url=(base_url or "").strip() or DEFAULT_BASE_URL, api_key=(api_key or "").strip() or "lm-studio", timeout_s=float(os.environ.get("REQUEST_TIMEOUT_S", DEFAULT_TIMEOUT_S)), ) except LMStudioUnavailable as exc: log.error("LM Studio недоступен: %s", exc) # Пишем в history как failed, чтобы пользователь не потерял попытку. with History() as h: h.add( Record( prompt=clean_prompt, mode=mode, model=model, n_requested=n_candidates, n_returned=0, temperature=float(temperature), status="failed", error_reason=str(exc), raw_outputs=[], validated_outputs=[], previews=[], ) ) gr.Error(str(exc)) return _empty_result(n_candidates) raw_texts = result.raw_texts log.info("получено %d сырых ответов за %.1fs", len(raw_texts), result.elapsed_s) # 4. Валидация + рендер validated: list[str] = [] invalid_reasons: list[str] = [] for i, raw in enumerate(raw_texts): ok, reason, cleaned = validate_svg(raw, mode=mode) if ok: validated.append(cleaned) else: log.warning("кандидат #%d невалиден: %s", i, reason) invalid_reasons.append(reason) # 5. Запись в БД (сначала insert, чтобы получить id для превью) with History() as h: # best_index — MVP-логика: инвертированный размер файла (меньше = лучше). best_index: int | None = None if validated: sizes = [len(s.encode("utf-8")) for s in validated] best_index = min(range(len(sizes)), key=lambda i: sizes[i]) record = Record( prompt=clean_prompt, mode=mode, model=model, n_requested=n_candidates, n_returned=len(raw_texts), temperature=float(temperature), status=( "ok" if validated and len(validated) == n_candidates else "partial" if validated else "failed" ), error_reason=None if validated else ( f"все {n_candidates} кандидатов невалидны: {invalid_reasons}" if invalid_reasons else "пустой ответ модели" ), raw_outputs=raw_texts, validated_outputs=validated, previews=[], best_index=best_index, ) record_id = h.add(record) # 6. Превью preview_paths = _save_all_previews(record_id, validated, mode) if validated and best_index is not None and preview_paths[best_index]: pass # пометка best на уровне caption # Дозаписываем previews в БД отдельным update-ом, чтобы не светить в Record. if any(preview_paths): with History() as h: conn = h.conn import json as _json conn.execute( "UPDATE generations SET previews = ? WHERE id = ?", (_json.dumps(preview_paths, ensure_ascii=False), record_id), ) conn.commit() # 7. Gallery и code-block captions: list[tuple[str, str]] = [] for i, p in enumerate(preview_paths): if not p: continue if best_index is not None and i == best_index: captions.append((p, f"★ best — #{i+1}")) else: captions.append((p, f"#{i+1}")) if not captions: captions = [] # gallery пустой best_svg = ( validated[best_index] if (validated and best_index is not None) else "" ) status_md = ( f"Сгенерировано {len(validated)}/{n_candidates} за {result.elapsed_s:.1f}с" if validated else f"Все {n_candidates} кандидатов невалидны. Подробности в history." ) return ( captions, # gallery captions, # hidden (для совместимости, не используется) best_svg, # svg code block _refresh_history_df(20), # history dataframe status_md, # status markdown preview_paths, # превью для архива "", # error placeholder ) # --------------------------------------------------------------------------- # Live-режим: streaming callback # --------------------------------------------------------------------------- def _save_live_png(png: bytes, path: Path) -> str: """Сохраняет live-preview PNG в `path` (перезаписывает). Возвращает строку-путь или "".""" try: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(png) return str(path) except OSError as exc: log.warning("не удалось сохранить live-превью %s: %s", path, exc) return "" def _common_precheck( prompt: str, mode: str, n_candidates: int, image: Any, ) -> tuple[str | None, int, str]: """Общая валидация входных данных для on_generate / on_generate_live. Возвращает (clean_prompt_or_None, normalized_n_candidates, mode) или (None, n_candidates, mode) если валидация упала. При падении колбэк УЖЕ вызвал gr.Warning — этого достаточно для UI, возвращаемое значение нужно просто чтобы корректно выдать _empty_result. """ try: clean_prompt = _check_prompt(prompt) except ValueError as exc: gr.Warning(str(exc)) return (None, n_candidates, mode) if image is not None: try: validate_image(image) except Exception as exc: # noqa: BLE001 gr.Warning(f"изображение отклонено: {exc}") return (None, n_candidates, mode) n_candidates = int(n_candidates) if not (1 <= n_candidates <= 8): gr.Warning("n_candidates должен быть от 1 до 8") return (None, n_candidates, mode) if mode not in ("icon", "illustration"): gr.Warning(f"неизвестный mode: {mode!r}") return (None, n_candidates, mode) return (clean_prompt, n_candidates, mode) def on_generate_live( prompt: str, mode: str, n_candidates: int, temperature: float, image: Any, palette: str, model: str, base_url: str, api_key: str, use_live: bool, ) -> Iterator[tuple]: """Генерирует SVG в live-режиме: стримит токены, обновляет PNG-превью. Args: ... (те же параметры, что у on_generate) use_live: если False — fallback на синхронный on_generate (один yield с финальным результатом). Yields: Кортежи из 7 элементов (gallery, gallery_state, svg_viewer, history_df, status_md, preview_paths, error) — столько же, сколько `outputs=[...]` в build_ui(). Между дельта-апдейтами Gallery заполняется текущим live-превью; после end-event — финальный результат, запись в history, итоговый status. Особенности: - Live-режим всегда работает с n=1 (OpenAI не поддерживает n>1 в стриме). Если юзер передал n>1, мы тихо понижаем до 1. - Throttle: между yield'ами — не менее LIVE_THROTTLE_S (0.15s). Это ~6-7 обновлений/сек на быстром стриме. - Финальный yield после end-event — ВСЕГДА (даже если throttle скипнул последний промежуточный). - Backpressure: при новом клике старый стрим отменяется по токену `_live_cancel_token`. - Ошибка рендера промежуточного SVG (render_png -> None) — это нормально, мы её скипаем и продолжаем накапливать буфер. """ if not use_live: # Fallback на синхронный путь — для совместимости со старым # контрактом и для случая, когда юзер явно выключил live-стрим. result = on_generate( prompt, mode, n_candidates, temperature, image, palette, model, base_url, api_key, ) yield result return # Pre-check (общий с on_generate). clean_prompt, n_candidates, mode = _common_precheck(prompt, mode, n_candidates, image) if clean_prompt is None: yield _empty_result(n_candidates) return # Подготовка аргументов для chat / stream_chat. model = model or os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL) image_b64: str | None = None if image is not None: try: image_b64 = encode_pil_to_data_url(image, mime="image/png") except Exception as exc: # noqa: BLE001 gr.Warning(f"не удалось закодировать изображение: {exc}") yield _empty_result(n_candidates) return palette_clean = palette.strip() if palette else None try: messages = build_messages( prompt=clean_prompt, mode=mode, image_b64=image_b64, palette=palette_clean, n=n_candidates, temperature=float(temperature), ) except Exception as exc: # noqa: BLE001 log.exception("build_messages упал") gr.Error(f"ошибка сборки промпта: {exc}") yield _empty_result(n_candidates) return # Live всегда n=1. Запросили n>1 — понижаем. if n_candidates > 1: log.warning( "on_generate_live: n=%d запрошено, но в stream режиме идём с n=1", n_candidates, ) # Захватываем токен отмены. Если до end-event текущий глобальный # токен изменится (пришёл новый запрос) — мы тихо сворачиваемся. global _live_cancel_token my_token = _live_cancel_token + 1 _live_cancel_token = my_token log.info("live-генерация: mode=%s temp=%.2f model=%s", mode, temperature, model) started = time.monotonic() session_id = uuid.uuid4().hex[:8] live_path = DEFAULT_PREVIEW_DIR / f"live_{session_id}.png" size = PREVIEW_SIZE.get(mode, (256, 256)) buffer = "" last_update_ts = -1.0 # -1 → первый yield пропускает throttle-проверку last_valid_svg = "" tokens = 0 final_model = model final_finish_reason = "" # Первый yield — статус "поехали". Это нужно, чтобы UI сразу сменил # "Сгенерировано" прошлой генерации на "Live-стрим запущен…". yield ( [], [], "", gr.update(), "Live-стрим запущен…", [], "", ) try: events = stream_chat( messages=messages, model=model, n=1, temperature=float(temperature), base_url=(base_url or "").strip() or DEFAULT_BASE_URL, api_key=(api_key or "").strip() or "lm-studio", timeout_s=float(os.environ.get("REQUEST_TIMEOUT_S", DEFAULT_TIMEOUT_S)), ) for event in events: # Backpressure: если юзер успел отправить новый запрос, # глобальный токен уже не наш — выходим. if my_token != _live_cancel_token: log.info("live-стрим отменён: пришёл более новый запрос") return if event.type == "delta": buffer += event.content tokens += 1 # Throttle: не чаще одного render-yield на LIVE_THROTTLE_S. now = time.monotonic() if now - last_update_ts < LIVE_THROTTLE_S: continue # Парсим + рендерим. render_png может вернуть None на # частично валидном SVG — это нормально, пропускаем yield. svg = parse_to_valid(buffer) last_valid_svg = svg png = render_png(svg, size=size) if png is None: continue path_str = _save_live_png(png, live_path) if not path_str: continue last_update_ts = now caption = (path_str, f"live · {tokens} tok") yield ( [caption], [caption], svg, gr.update(), # history_df не трогаем до финала f"Live-стрим: ~{tokens} токенов", [path_str], "", ) elif event.type == "end": final_model = event.model or model final_finish_reason = event.finish_reason break except LMStudioUnavailable as exc: log.error("LM Studio недоступен в live-режиме: %s", exc) with History() as h: h.add( Record( prompt=clean_prompt, mode=mode, model=model, n_requested=n_candidates, n_returned=0, temperature=float(temperature), status="failed", error_reason=str(exc), raw_outputs=[], validated_outputs=[], previews=[], ) ) gr.Error(str(exc)) yield _empty_result(n_candidates) return # ----- ФИНАЛ ----- # Парсим финальный буфер и сохраняем запись. Этот yield ВСЕГДА # выполняется (даже если throttle скипнул последний промежуточный). elapsed = time.monotonic() - started final_svg = parse_to_valid(buffer) if buffer else "" if final_svg: last_valid_svg = final_svg # Пытаемся валидировать (как в не-live режиме), чтобы финальный preview # был идентичен не-live пути. Если не вышло — fallback на parse_to_valid. validated: list[str] = [] if buffer: ok, reason, cleaned = validate_svg(buffer, mode=mode) if ok and cleaned: validated = [cleaned] final_svg = cleaned else: log.warning("live-финал не прошёл validate_svg: %s", reason) # Не валидно по строгим правилам, но parse_to_valid дал что-то # рендерабельное — оставляем его, в history пометим "partial". if last_valid_svg: validated = [last_valid_svg] status_str = "ok" if validated else "failed" error_reason: str | None = None if validated else ( "live-стрим завершён, но SVG не прошёл валидацию" if buffer else "пустой ответ модели" ) with History() as h: record = Record( prompt=clean_prompt, mode=mode, model=final_model, n_requested=n_candidates, n_returned=1, temperature=float(temperature), status=status_str, error_reason=error_reason, raw_outputs=[buffer] if buffer else [], validated_outputs=validated, previews=[], best_index=0 if validated else None, ) record_id = h.add(record) # Финальный PNG: сохраняем с привязкой к record_id, чтобы он попал # в историю. preview_paths: list[str] = [] if validated: png = render_png(validated[0], size=size) if png is not None: try: final_path = save_png( png, previews_dir=DEFAULT_PREVIEW_DIR, record_id=record_id, candidate_index=0, ) preview_paths = [str(final_path)] with History() as h: conn = h.conn import json as _json conn.execute( "UPDATE generations SET previews = ? WHERE id = ?", (_json.dumps(preview_paths, ensure_ascii=False), record_id), ) conn.commit() except OSError as exc: log.warning("не удалось сохранить финальный live-превью: %s", exc) captions: list[tuple[str, str]] = [] if preview_paths: captions = [(preview_paths[0], "★ best — #1")] if validated: status_md = f"Сгенерировано {len(validated)}/1 за {elapsed:.1f}с" else: status_md = f"Live-стрим завершён без валидного SVG за {elapsed:.1f}с" yield ( captions, # gallery captions, # gallery_state validated[0] if validated else "", # svg_viewer _refresh_history_df(20), # history_df refreshed status_md, # status preview_paths, # превью для архива "", # error ) def on_history_select( evt: gr.SelectData, history_data: list[list[Any]] | None, ) -> tuple[list[tuple[str, str]], str, str]: """По клику на строку history подгружает детали записи. Args: evt: событие выбора (index, row_payload). history_data: текущее содержимое dataframe (для поиска id). """ if evt is None or not history_data: return [], "", "" # В новых версиях Gradio evt.value может быть словарём строки, в старых — # индексом. Поддерживаем оба варианта. row_idx = None if isinstance(evt.index, (list, tuple)) and evt.index: row_idx = evt.index[0] elif isinstance(evt.index, int): row_idx = evt.index if row_idx is None or row_idx < 0 or row_idx >= len(history_data): return [], "", "" row = history_data[row_idx] try: record_id = int(row[0]) except (TypeError, ValueError): return [], "", "не удалось извлечь id записи" with History() as h: rec = h.get(record_id) if rec is None: return [], "", f"запись #{record_id} не найдена" previews = rec.get("previews") or [] captions: list[tuple[str, str]] = [] for i, p in enumerate(previews): if p and Path(p).is_file(): captions.append((p, f"#{i+1}")) validated = rec.get("validated_outputs") or [] best_idx = rec.get("best_index") best_svg = validated[best_idx] if (best_idx is not None and 0 <= best_idx < len(validated)) else ( validated[0] if validated else "" ) details_md = ( f"### Запись #{rec['id']}\n" f"- **Промпт:** {rec['prompt']}\n" f"- **Mode:** {rec['mode']}\n" f"- **Model:** {rec['model']}\n" f"- **N:** {rec['n_returned']}/{rec['n_requested']}\n" f"- **Status:** {rec['status']}" ) return captions, best_svg, details_md # --------------------------------------------------------------------------- # Сборка UI # --------------------------------------------------------------------------- def build_ui() -> gr.Blocks: """Создаёт объект Gradio Blocks.""" with gr.Blocks(title="OmniSVG-Lite") as demo: gr.Markdown( "# OmniSVG-Lite\n" "Текст/картинка → N SVG-кандидатов через LM Studio." ) with gr.Row(): with gr.Column(scale=1): prompt_tb = gr.Textbox( label="Промпт", placeholder="filled magnifying glass", lines=4, max_lines=8, ) image_in = gr.Image( label="Референс-картинка (опц.)", type="pil", sources=["upload", "clipboard"], ) palette_tb = gr.Textbox( label="Палитра (опц.)", placeholder="blue and teal", lines=1, ) mode_radio = gr.Radio( choices=["icon", "illustration"], value="icon", label="Mode", ) n_slider = gr.Slider( minimum=1, maximum=8, step=1, value=1, label="Кандидатов", ) temp_slider = gr.Slider( minimum=0.0, maximum=1.5, step=0.05, value=0.4, label="Temperature", ) with gr.Accordion("LM Studio", open=False): base_url_tb = gr.Textbox( label="Base URL", value=os.environ.get("LM_STUDIO_BASE_URL", DEFAULT_BASE_URL), placeholder="http://127.0.0.1:1234/v1", lines=1, ) api_key_tb = gr.Textbox( label="API token", value=os.environ.get("LM_STUDIO_API_KEY", "lm-studio"), lines=1, ) refresh_btn = gr.Button("Обновить список моделей", size="sm") fetch_status_md = gr.Markdown("") model_dd = gr.Dropdown( label="Модель", choices=[os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)], value=os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL), allow_custom_value=True, ) use_live_cb = gr.Checkbox( label="Live-стрим (превью в реальном времени)", value=True, ) gen_btn = gr.Button("Сгенерировать", variant="primary") status_md = gr.Markdown("") with gr.Column(scale=2): gallery = gr.Gallery( label="PNG-превью", columns=3, height=320, object_fit="contain", ) gallery_state = gr.State([]) with gr.Accordion("SVG-код лучшего кандидата", open=False): # gr.Code в Gradio 5.37 не имеет "xml" в whitelist языков # (есть python/sql/html/markdown/...). Используем "html" # — подсветка разметки близка к XML и работает стабильно. svg_viewer = gr.Code(label="SVG", language="html") with gr.Accordion("История (последние 20)", open=True): history_df = gr.Dataframe( headers=["id", "created_at", "mode", "model", "n", "returned", "status"], datatype=["number", "str", "str", "str", "number", "number", "str"], interactive=False, wrap=True, ) history_details = gr.Markdown("") # Связи mode_radio.change( on_mode_change, inputs=[mode_radio], outputs=[n_slider], ) refresh_btn.click( fetch_lm_studio_models, inputs=[base_url_tb, api_key_tb], outputs=[model_dd, fetch_status_md], ) gen_btn.click( on_generate_live, inputs=[ prompt_tb, mode_radio, n_slider, temp_slider, image_in, palette_tb, model_dd, base_url_tb, api_key_tb, use_live_cb, ], outputs=[gallery, gallery_state, svg_viewer, history_df, status_md, gr.State([]), gr.State("")], ) history_df.select( on_history_select, inputs=[history_df], outputs=[gallery, svg_viewer, history_details], ) return demo # Алиас, который просит задача для smoke-теста импорта. demo = None def main() -> None: global demo log.info( "starting model=%s base_url=%s", os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL), os.environ.get("LM_STUDIO_BASE_URL", DEFAULT_BASE_URL), ) log.info("db path: %s", DEFAULT_DB_PATH) log.info("preview dir: %s", DEFAULT_PREVIEW_DIR) demo = build_ui() port = int(os.environ.get("OMNISVG_PORT", "8788")) host = os.environ.get("OMNISVG_HOST", "127.0.0.1") log.info("launching Gradio on %s:%d", host, port) # allowed_paths нужен, чтобы Gradio отдавал PNG из ~/.omnisvg_lite/previews/ # иначе InvalidPathError на рендере превью demo.launch( server_name=host, server_port=port, allowed_paths=[str(DEFAULT_PREVIEW_DIR), str(Path.cwd())], ) if __name__ == "__main__": main()