- 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
19 KiB
OmniSVG-Lite — Design
Архитектура MVP. Backend-dev может взять этот документ +
prompts/и писать код без уточнений.
1. Стек и обоснование
| Компонент | Выбор | Зачем |
|---|---|---|
| Язык | Python 3.11 | Зафиксировано пользователем. |
| UI | Gradio 5 | Blocks + встроенный Gallery. Альтернативы (Streamlit, NiceGUI) хуже по лайауту. |
| HTTP | httpx (sync) | Проще openai SDK, JSON+timeouts из коробки. |
| Парсинг/валидация | lxml | Строгий, XMLSyntaxError с координатами, iter() для обхода. |
| PNG-рендер | cairosvg | Декларативный, синхронный. Pillow+svglib — хуже на градиентах. |
| БД | sqlite3 (stdlib) | WAL, 1 таблица. SQLAlchemy — overkill. |
| Логирование | logging (stdlib) | Достаточно. |
| Concurrency | threading (stdlib) | UI не фризит во время LM-вызова. |
| Тесты | pytest | validator/renderer/prompts покрыты. |
Не берём: openai SDK (httpx достаточно), pydantic (dataclass хватит), tenacity (одна попытка в MVP).
2. Структура модулей
OmniSVG-Lite/
├── app.py # Gradio UI, callbacks, main()
├── lm_client.py # httpx-обёртка над LM Studio
├── prompts.py # Сборка messages[], загрузка шаблонов
├── validator.py # lxml-парсинг + проверка правил SVG
├── renderer.py # cairosvg → PNG bytes + сохранение
├── history.py # SQLite-схема, CRUD
├── data/{omnisvg.db, previews/<id>_<n>.png}
├── prompts/{system_icon.txt, system_illustration.txt, few_shot_examples.txt}
├── docs/{spec.md, design.md}
└── tests/{test_validator.py, test_renderer.py, test_prompts.py}
2.1. lm_client.py — httpx → LM Studio
Импорт: app.py. Зависимости: httpx, base64, json, logging.
class LMTurnResult:
raw_texts: list[str] # N строк, по одной на кандидат
elapsed_s: float
model: str
usage: dict | None
def chat(*, base_url, api_key, model, messages, n, temperature,
max_tokens=4096, timeout_s=60.0) -> LMTurnResult: ...
2.2. prompts.py — сборка messages
Импорт: app.py. Зависимости: pathlib, prompts/.
def load_system_prompt(mode: Literal["icon","illustration"]) -> str: ...
def load_few_shot() -> list[dict]: ... # [{role,content}, ...]
def build_messages(*, mode, prompt, image_b64: str|None,
palette: str|None, n: int, temperature: float) -> list[dict]: ...
2.3. validator.py — lxml + правила
Импорт: app.py. Зависимости: lxml.etree, re.
class ValidatorError(Exception): # code: malformed_xml|not_svg|missing_viewbox|bad_viewbox|disallowed_tag|unknown_tag|external_ref|too_large
code: str; detail: str
ALLOWED_TAGS = {"svg","g","defs","symbol","use","path","rect","circle","ellipse",
"line","polygon","polyline","linearGradient","radialGradient","stop",
"filter","feGaussianBlur","feOffset","feBlend","feMerge","feMergeNode",
"feFlood","feComposite","feColorMatrix","clipPath","mask","pattern",
"text","tspan","textPath","title","desc"}
DISALLOWED_TAGS = {"script","foreignObject","image"}
MAX_BYTES = {"icon": 32*1024, "illustration": 256*1024}
def extract_svg(text: str) -> str: ... # regex
def validate(svg_text: str, *, mode) -> etree._Element: ... # raises ValidatorError
2.4. renderer.py — cairosvg
Импорт: app.py. Зависимости: cairosvg, pathlib, io.
def svg_to_png_bytes(svg_text: str, *, output_width: int) -> bytes: ...
# добавляет xmlns если нет, output_width=256 (icon) / 512 (illustration)
def save_preview(png_bytes, *, job_id: int, candidate_index: int) -> Path: ...
def to_pil_image(png_bytes: bytes) -> "PIL.Image.Image": ...
2.5. history.py — SQLite CRUD
Импорт: app.py. Зависимости: sqlite3, json, time, pathlib.
DB_PATH = Path("data/omnisvg.db")
def init_db() -> None: ... # WAL mode
def insert_job(*, prompt, mode, model, n_candidates, status,
raw_outputs: list[str], validated_outputs: list[str],
preview_paths: list[str], error: str|None = None) -> int: ...
def list_jobs(limit: int = 50) -> list[dict]: ... # для History table
def get_job(job_id: int) -> dict | None: ...
2.6. app.py — UI + wiring
Импорт: всё перечисленное + gradio.
def on_generate(prompt, mode, n, temperature, image, palette, model) -> \
tuple[list[tuple[str,str]], list[dict], str]: ...
def on_history_select(evt: gr.SelectData): ... # row click
def main() -> None: ...
3. LM Studio client — детали
- POST
http://127.0.0.1:1234/v1/chat/completions, заголовки:Content-Type: application/json,Authorization: Bearer lm-studio(значение игнорируется, но требуется OpenAI-совместимым клиентом). stream=falseв payload: n>1 плохо сочетается со streaming, плюс инкрементальный парсинг сложнее.- Payload (text-to-SVG):
{"model", "messages":[{"role":"system","content":...}], "n", "temperature", "max_tokens":4096, "stream":false}. - Payload (image-to-SVG):
contentэто[{type:"text", text:...}, {type:"image_url", image_url:{url:"data:image/png;base64,..."}}]— текст первым, картинка второй. - Парсинг ответа:
choices[i].message.content— N строк.tool_callsне передаём; если модель их всё же вернула, игнорируем (еслиcontentпустой — кандидат invalid). - Кодирование картинки: convert в RGB, проверка
max(size) <= 4096, PIL save PNG optimize → base64 в data-URL. JPEG не используем (LM Studio VLM обучены на PNG, артефактов на границах не будет).
4. Промпт-стратегия
Структура messages[]:
system: <system_icon.txt | system_illustration.txt>
user: <few-shot user 1>
assistant: <few-shot svg 1>
user: <few-shot user 2>
assistant: <few-shot svg 2>
... (3-5 пар)
user: <actual user prompt + (опц.) image>
Требования к SVG в ответе модели:
- Один корневой
<svg>...</svg>, ничего больше (никаких```, никаких пояснений). viewBoxобязателен и совпадает с mode:0 0 64 64(icon) или0 0 512 512(illustration).- Без
<script>,<foreignObject>,<image>,http://,https://,data:в href. - Размер файла ≤ лимита mode (32/256 KB).
xmlns="http://www.w3.org/2000/svg"желателен (валидатор примет и без, но cairosvg ругается — добавляем автоматически в renderer).
Few-shot (готов в prompts/few_shot_examples.txt): 2 иконки (filled magnifying glass, outline battery), 2 иллюстрации (flat fox, isometric server room), 1 пример refusal (NSFW-отказ без SVG).
5. Валидация — что проверяет lxml
def validate(svg_text, *, mode):
svg_text = extract_svg(svg_text) # вытащить <svg>...</svg>
try:
root = etree.fromstring(svg_text.encode("utf-8"))
except etree.XMLSyntaxError as e:
raise ValidatorError("malformed_xml", str(e))
local_root = root.tag.split("}")[-1]
if local_root != "svg": raise ValidatorError("not_svg", root.tag)
if not root.get("viewBox"): raise ValidatorError("missing_viewbox", "")
try:
vb = [float(x) for x in root.get("viewBox").split()]
if len(vb) != 4: raise ValueError
except ValueError:
raise ValidatorError("bad_viewbox", root.get("viewBox"))
for el in root.iter():
local = el.tag.split("}")[-1]
if local in DISALLOWED_TAGS: raise ValidatorError("disallowed_tag", local)
if local not in ALLOWED_TAGS: raise ValidatorError("unknown_tag", local)
for attr, val in el.attrib.items():
if val and ("http://" in val or "https://" in val):
if attr.endswith("href") or "url(" in val:
raise ValidatorError("external_ref", f"{attr}={val[:50]}")
if len(svg_text.encode("utf-8")) > MAX_BYTES[mode]:
raise ValidatorError("too_large", f"{len(svg_text)} bytes")
return root
SVG_NS = "http://www.w3.org/2000/svg". lxml возвращает теги в Clark-notation {ns}local.
6. Рендер
def svg_to_png_bytes(svg_text, *, output_width):
if "xmlns=" not in svg_text.split(">", 1)[0]:
svg_text = svg_text.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"', 1)
return cairosvg.svg2png(
bytestring=svg_text.encode("utf-8"),
output_width=output_width, # 256 icon, 512 illustration
background_color="white",
)
PNG bytes → save_preview (файл) + to_pil_image (для Gallery). Лучший получает caption "★ best — #1".
Windows + Cairo: если pycairo поставлен через conda — работает. Через pip — может потребоваться GTK runtime. В README отметить, в MVP не чинить.
7. История — SQLite схема
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at REAL NOT NULL, -- time.time()
prompt TEXT NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('icon','illustration')),
model TEXT NOT NULL,
n_candidates INTEGER NOT NULL,
temperature REAL NOT NULL,
status TEXT NOT NULL, -- ok | all_invalid | error
error TEXT,
raw_outputs TEXT NOT NULL, -- JSON list[str]
validated_outputs TEXT NOT NULL, -- JSON list[str]
preview_paths TEXT NOT NULL, -- JSON list[str] (rel paths)
best_index INTEGER -- индекс лучшего или NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at DESC);
WAL mode в init_db(): PRAGMA journal_mode=WAL. JSON через json.dumps/loads. 1 запись = 1 запуск, N кандидатов внутри.
8. Gradio UI
8.1. Layout (ASCII)
┌─────────────────────────────────────────────────────────────────┐
│ OmniSVG-Lite [Model: ▼] │
├──────────────────┬──────────────────────────────────────────────┤
│ Prompt │ Gallery (PNG превью) │
│ [textbox] │ [★best] [#2] [#3] │
│ Reference image │ │
│ [image upload] │ Selected SVG: │
│ Palette (opt) │ [code block] │
│ [textbox] │ │
│ Mode │ History │
│ (•) icon ( ) ill │ [gr.Dataframe] │
│ Candidates 1..8 │ │
│ [slider] │ │
│ Temperature │ │
│ [slider] │ │
│ [Generate] │ │
│ Status: [md] │ │
└──────────────────┴──────────────────────────────────────────────┘
8.2. Компоненты
| gr.X | Имя | Параметры |
|---|---|---|
Textbox |
prompt_tb |
lines=4, max_lines=8 |
Image |
image_in |
type="pil", sources=["upload","clipboard"] |
Textbox |
palette_tb |
lines=1 |
Radio |
mode_radio |
["icon","illustration"], value="icon" |
Slider |
n_slider |
1..8, step=1, value=4 (icon)/2 (illustration) |
Slider |
temp_slider |
0..1.5, step=0.05, value=0.4 |
Button |
gen_btn |
"Generate", variant="primary" |
Gallery |
gallery |
columns=3, height=320, object_fit="contain" |
Code |
svg_viewer |
language="xml" |
Dataframe |
history_df |
id/created_at/mode/model/n/status |
Markdown |
status_md |
для статус-строки |
Dropdown |
model_dd |
из DEFAULT_MODEL env |
8.3. Callbacks (контракт)
def on_mode_change(mode): return gr.update(value=4 if mode=="icon" else 2)
def on_generate(prompt, mode, n, temp, image, palette, model):
if not (1 <= len(prompt.strip()) <= 1000):
raise gr.Warning("Prompt must be 1–1000 characters")
if image is not None: _check_image(image) # size/format
messages = build_messages(mode=mode, prompt=prompt,
image_b64=encode_image(image) if image else None,
palette=palette or None, n=n, temperature=temp)
result = chat(base_url=..., model=model, messages=messages, n=n, temperature=temp)
previews, validated = [], []
for i, raw in enumerate(result.raw_texts):
try:
svg = extract_svg(raw); validate(svg, mode=mode)
png = svg_to_png_bytes(svg, output_width=256 if mode=="icon" else 512)
path = save_preview(png, job_id=0, candidate_index=i)
validated.append(svg); previews.append((str(path), f"#{i+1}"))
except ValidatorError as e:
log.warning("invalid candidate %d: %s", i, e.code)
best_idx = None
if validated:
sizes = [len(s.encode("utf-8")) for s in validated]
best_idx = min(range(len(sizes)), key=lambda i: sizes[i])
previews[best_idx] = (previews[best_idx][0], f"★ best — #{best_idx+1}")
job_id = insert_job(prompt=prompt, mode=mode, model=model, n_candidates=n,
temperature=temp, status="ok" if validated else "all_invalid",
raw_outputs=result.raw_texts, validated_outputs=validated,
preview_paths=[p[0] for p in previews],
best_index=best_idx)
return previews, validated[best_idx] if validated else "", _refresh_history(), \
f"Generated {len(validated)}/{n}"
def on_history_select(evt: gr.SelectData):
job = get_job(evt.value[0])
return [(p, "") for p in job["preview_paths"]], \
job["validated_outputs"][job["best_index"] or 0]
Gradio сам сериализует вызовы одного callback; gen_btn.interactive = False через .then() достаточно.
9. Обработка ошибок
| Слой | Что ловим | UI | Лог | history.status |
|---|---|---|---|---|
| UI pre-check | пустой/длинный prompt, плохая картинка | gr.Warning |
INFO | — (запись не создаётся) |
| LM Studio | timeout, 5xx, network | gr.Error |
ERROR+tb | error |
| Парсер | модель вернула текст без <svg> |
— | WARNING | all_invalid / частично ok |
| Валидатор | malformed, viewBox, tag, ref, size | — | WARNING (с code) | как выше |
| Renderer | cairosvg бросил | invalid | WARNING | как выше |
| History | SQLite locked / disk full | gr.Error("DB error") |
ERROR | — |
Принцип: всё, что ниже UI pre-check, не блокирует показ уже валидных кандидатов. Из 4 валиден 1 → показываем 1, статус ok, в raw_outputs все 4 (включая невалидные).
10. Запуск
10.1. Команды
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
python app.py
UI поднимется на http://127.0.0.1:7860 (Gradio default).
10.2. requirements.txt (стартовая точка)
gradio>=5.0,<6
httpx>=0.27
lxml>=5.0
cairosvg>=2.7
Pillow>=10.0
pytest>=8.0 # dev only
10.3. Env-переменные (опционально, с дефолтами)
| Переменная | Дефолт | Назначение |
|---|---|---|
LM_STUDIO_BASE_URL |
http://127.0.0.1:1234/v1 |
endpoint |
LM_STUDIO_API_KEY |
lm-studio |
Authorization header |
DEFAULT_MODEL |
qwen/qwen3.5-35b-a3b |
Dropdown default |
REQUEST_TIMEOUT_S |
60 |
httpx timeout |
OMNISVG_DB_PATH |
data/omnisvg.db |
SQLite |
OMNISVG_PREVIEW_DIR |
data/previews |
PNG-превью |
LOG_LEVEL |
INFO |
DEBUG/INFO/WARNING |
10.4. Логи при старте
INFO omnisvg starting model=qwen/qwen3.5-35b-a3b base_url=http://127.0.0.1:1234/v1
INFO omnisvg db initialized at data/omnisvg.db (WAL)
INFO omnisvg ui on http://127.0.0.1:7860
11. Open Questions
- Streaming — не идём, non-stream. Если понадобится: второй endpoint в
lm_client.py(stream=true+ SSE-парсер) + callback сgr.update(...)для прогресс-бара. - Per-mode
ndefault — решено: 4 для icon, 2 для illustration (§8.2). - PNG-превью лучшего крупнее — нет, все одинаковые. Акцент через caption "★ best".
- Regenerate — нет в MVP. Если нужен: ~20 строк, кнопка копирует параметры в поля и дёргает
on_generate. - cairosvg на Windows — README предупреждает про GTK runtime. Не блокер MVP.
- Cleanup старых превью — нет, диск заполняется. В прод — TTL/джоба.