Initial commit: OmniSVG-Lite MVP before live-streaming work

- LM Studio client (httpx-based, OpenAI-compatible)
- SVG validator (lxml, whitelist tags, no <script>/<foreignObject>/http refs)
- PNG renderer (resvg-py primary, cairosvg fallback - no native cairo dep)
- History (SQLite, tracks raw/validated/preview paths)
- Gradio UI on 127.0.0.1:8788 with:
  * mode radio (icon/illustration)
  * n_candidates slider (default 1)
  * image upload for image-to-SVG
  * LM Studio URL/token inputs
  * model dropdown + refresh button
- prompts/ with system_icon.txt, system_illustration.txt, few_shot_examples.txt
- docs/spec.md, docs/design.md
- 122 unit/integration tests passing
This commit is contained in:
Mavis
2026-06-13 15:32:54 +03:00
commit 2394eff1c0
21 changed files with 5116 additions and 0 deletions
+587
View File
@@ -0,0 +1,587 @@
"""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()