"""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 from pathlib import Path from typing import Any 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 lm_client import ( DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_TIMEOUT_S, LMStudioUnavailable, chat, encode_pil_to_data_url, validate_image, ) from prompts import build_messages, load_system_prompt from renderer import render_png, save_png from validator import validate_svg # --------------------------------------------------------------------------- # Логгер и настройки (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 это поймает. try: clean_prompt = _check_prompt(prompt) except ValueError as exc: gr.Warning(str(exc)) return _empty_result(n_candidates) if image is not None: try: validate_image(image) except (ValueError, Exception) as exc: # noqa: BLE001 gr.Warning(f"изображение отклонено: {exc}") return _empty_result(n_candidates) n_candidates = int(n_candidates) if not (1 <= n_candidates <= 8): gr.Warning("n_candidates должен быть от 1 до 8") return _empty_result(n_candidates) if mode not in ("icon", "illustration"): gr.Warning(f"неизвестный mode: {mode!r}") 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 ) 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, ) 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, inputs=[prompt_tb, mode_radio, n_slider, temp_slider, image_in, palette_tb, model_dd, base_url_tb, api_key_tb], 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()