Files
omnisvg-lite/lm_client.py
T
Mavis 2394eff1c0 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
2026-06-13 15:32:54 +03:00

347 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Клиент к LM Studio через OpenAI-compatible chat completions.
Использует синхронный httpx, чтобы не тащить openai SDK и не ловить
несовместимости их версий. Поддерживает текстовые и мультимодальные
(text + image_url) сообщения, а также n>1 для batch-генерации кандидатов.
"""
from __future__ import annotations
import base64
import io
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Any, Iterable
import httpx
log = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"
DEFAULT_MODEL = "qwen/qwen3.5-35b-a3b"
DEFAULT_API_KEY = "lm-studio" # LM Studio игнорирует значение, но заголовок обязателен
DEFAULT_TIMEOUT_S = 300.0 # qwen3.5 может долго рассуждать, не режем по таймауту
DEFAULT_MAX_TOKENS = 32768 # без жёсткого лимита: qwen3.5 ест reasoning + content в одном budget
class LMStudioUnavailable(RuntimeError):
"""LM Studio не отвечает, вернул 5xx или вернул неожиданный формат."""
@dataclass(frozen=True)
class LMTurnResult:
"""Результат одного запроса к LM Studio.
Attributes:
raw_texts: список текстов ответов — по одному на кандидат. Если модель
вернула tool_calls без `content`, элемент будет пустой строкой.
elapsed_s: время запроса в секундах.
model: фактическое имя модели, которое вернул сервер (если есть) или
то, что мы послали.
usage: словарь usage от сервера, может быть None.
finish_reasons: список finish_reason по кандидатам ("stop"/"length"/...).
"""
raw_texts: list[str]
elapsed_s: float
model: str
usage: dict | None = None
finish_reasons: list[str] = field(default_factory=list)
def _coerce_text_part(part: Any) -> str:
"""Достаёт текст из элемента content — поддерживает str и list[dict]."""
if isinstance(part, str):
return part
if isinstance(part, dict):
return str(part.get("text", "") or "")
return str(part)
def _normalize_messages(messages: Iterable[dict]) -> list[dict]:
"""Готовит сообщения к отправке: текст либо строкой, либо [{type:text},...].
Допускаем на входе content как str или list[dict] (text + image_url).
Возвращаем список в формате OpenAI chat completions.
"""
out: list[dict] = []
for msg in messages:
role = msg.get("role")
content = msg.get("content")
if role is None or content is None:
log.warning("пропускаю сообщение без role/content: %r", msg)
continue
if isinstance(content, str):
out.append({"role": role, "content": content})
else:
parts: list[dict] = []
for piece in content:
if isinstance(piece, str):
parts.append({"type": "text", "text": piece})
elif isinstance(piece, dict):
ptype = piece.get("type")
if ptype == "text":
parts.append({"type": "text", "text": str(piece.get("text", ""))})
elif ptype == "image_url":
url = piece.get("image_url")
if isinstance(url, dict):
url = url.get("url")
parts.append({"type": "image_url", "image_url": {"url": str(url)}})
else:
log.warning("неизвестный тип части content: %r", ptype)
out.append({"role": role, "content": parts})
return out
def chat(
*,
messages: list[dict],
model: str | None = None,
n: int = 1,
temperature: float = 0.4,
max_tokens: int = DEFAULT_MAX_TOKENS,
base_url: str | None = None,
api_key: str | None = None,
timeout_s: float | None = None,
) -> LMTurnResult:
"""Шлёт chat completion в LM Studio и возвращает N текстов ответов.
Args:
messages: список сообщений в формате OpenAI. content может быть str
или list[dict] (text/image_url).
model: имя модели; если None — берём DEFAULT_MODEL / env `DEFAULT_MODEL`.
n: количество кандидатов, 1..8.
temperature: 0.0..1.5.
max_tokens: верхняя граница длины ответа.
base_url: эндпоинт LM Studio; дефолт http://127.0.0.1:1234/v1.
api_key: Bearer-токен; дефолт "lm-studio".
timeout_s: общий таймаут httpx, дефолт 120 секунд.
Returns:
LMTurnResult с N текстами и метаданными.
Raises:
LMStudioUnavailable: при сетевых/HTTP/ошибках парсинга.
"""
if n < 1:
raise ValueError(f"n должно быть >= 1, получено {n}")
base = (base_url or os.environ.get("LM_STUDIO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
key = api_key if api_key is not None else os.environ.get("LM_STUDIO_API_KEY", DEFAULT_API_KEY)
mdl = model or os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)
timeout = float(
os.environ.get("REQUEST_TIMEOUT_S", str(timeout_s if timeout_s is not None else DEFAULT_TIMEOUT_S))
)
url = f"{base}/chat/completions"
payload: dict[str, Any] = {
"model": mdl,
"messages": _normalize_messages(messages),
"n": n,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
# попытка отключить thinking у qwen3.5; если LM Studio/модель не уважают — не страшно
"chat_template_kwargs": {"enable_thinking": False},
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
}
log.info(
"LM Studio → %s model=%s n=%d temp=%.2f timeout=%.0fs",
url, mdl, n, temperature, timeout,
)
started = time.monotonic()
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
raise LMStudioUnavailable(
f"LM Studio timeout at {url}: запрос превысил {timeout:.0f}с"
) from exc
except httpx.HTTPError as exc:
raise LMStudioUnavailable(
f"LM Studio недоступен по адресу {url}: {exc}"
) from exc
elapsed = time.monotonic() - started
if resp.status_code >= 500:
raise LMStudioUnavailable(
f"LM Studio error: {resp.status_code} {resp.text[:200]}"
)
if resp.status_code >= 400:
# 4xx — это наша ошибка (плохой запрос / модель не загружена / квота).
raise LMStudioUnavailable(
f"LM Studio вернул {resp.status_code}: {resp.text[:200]}"
)
try:
data = resp.json()
except ValueError as exc:
raise LMStudioUnavailable(
f"LM Studio вернул не-JSON: {resp.text[:200]}"
) from exc
choices = data.get("choices") or []
if not choices:
raise LMStudioUnavailable(
f"LM Studio не вернул ни одного choice: {resp.text[:200]}"
)
raw_texts: list[str] = []
finish_reasons: list[str] = []
for ch in choices:
msg = ch.get("message") or {}
content = msg.get("content")
if isinstance(content, str):
raw_texts.append(content)
elif isinstance(content, list):
raw_texts.append("".join(_coerce_text_part(p) for p in content))
elif content is None:
# Модель могла вернуть tool_calls — для нас это пустой кандидат.
raw_texts.append("")
else:
raw_texts.append(_coerce_text_part(content))
finish_reasons.append(str(ch.get("finish_reason") or ""))
# Если сервер вернул меньше choice'ов, чем n — добиваем пустыми строками.
while len(raw_texts) < n:
raw_texts.append("")
finish_reasons.append("missing")
return LMTurnResult(
raw_texts=raw_texts,
elapsed_s=elapsed,
model=str(data.get("model") or mdl),
usage=data.get("usage"),
finish_reasons=finish_reasons,
)
def encode_pil_to_data_url(image: Any, *, mime: str = "image/png") -> str:
"""Кодирует PIL-картинку в data: URL для передачи в image_url.
Args:
image: объект PIL.Image.
mime: MIME-тип, по умолчанию image/png (LM Studio VLM лучше работают с PNG).
Returns:
Строка вида `data:image/png;base64,<...>`.
"""
from PIL import Image # локальный импорт, чтобы не требовать Pillow без надобности
buf = io.BytesIO()
img = image
if getattr(img, "mode", None) == "RGBA" and mime == "image/jpeg":
img = img.convert("RGB")
elif getattr(img, "mode", None) not in ("RGB", "RGBA", "L") and mime == "image/png":
img = img.convert("RGBA")
img.save(buf, format="PNG" if mime == "image/png" else "JPEG", optimize=True)
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:{mime};base64,{encoded}"
def validate_image(image: Any, *, max_bytes: int = 10 * 1024 * 1024, max_side: int = 4096) -> None:
"""Проверяет картинку перед отправкой в LM Studio (US-2, edge case #3).
Args:
image: PIL.Image.
max_bytes: максимальный размер PNG-байт после перекодирования.
max_side: максимальная сторона в пикселях.
Raises:
ValueError: с человеко-читаемой причиной на русском.
"""
from PIL import Image, UnidentifiedImageError
if image is None:
raise ValueError("изображение не передано")
if not isinstance(image, Image.Image):
raise ValueError(f"ожидался PIL.Image, получен {type(image).__name__}")
w, h = image.size
if max(w, h) > max_side:
raise ValueError(
f"изображение слишком большое: {w}x{h}, максимум {max_side}x{max_side}"
)
if getattr(image, "format", None) and image.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError(
f"неподдерживаемый формат: {image.format}; допустимы PNG, JPEG, WEBP"
)
# Прикидка размера: для RGB 3 байта/пиксель + служебные.
approx = (w * h * 4) + (1024 * 64)
if approx > max_bytes:
raise ValueError(
f"изображение превышает {max_bytes // (1024*1024)} МБ после кодирования"
)
# ---------------------------------------------------------------------------
# Удобный высокоуровневый helper, чтобы UI не думал о формате messages.
# ---------------------------------------------------------------------------
def generate_svg(
prompt: str,
*,
image_b64: str | None = None,
mode: str = "icon",
n: int = 1,
temperature: float = 0.4,
model: str | None = None,
system_prompt: str | None = None,
) -> list[dict]:
"""Собирает messages, отправляет в LM Studio и возвращает сырые ответы.
Эта функция — тонкая обёртка над `chat()`, вынесенная для удобства UI.
За реальную сборку messages отвечает `prompts.build_messages`.
Args:
prompt: текст промпта пользователя.
image_b64: data: URL картинки (или None для text-to-SVG).
mode: "icon" или "illustration" — для тега system, если не задан явно.
n: число кандидатов.
temperature: 0..1.5.
model: имя модели (опц.).
system_prompt: если задан — используется как system; иначе caller должен
сам подставить его в messages (эта функция не знает про prompts/).
Returns:
Список словарей `{'svg_text': str, 'raw': str, 'finish_reason': str}`.
"""
if not (1 <= len(prompt.strip()) <= 1000):
raise ValueError("промпт должен быть от 1 до 1000 символов")
messages: list[dict] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
if image_b64:
user_content: Any = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": image_b64}},
]
else:
user_content = prompt
messages.append({"role": "user", "content": user_content})
result = chat(
messages=messages,
model=model,
n=n,
temperature=temperature,
)
return [
{
"svg_text": raw, # сырой текст; SVG вытащим extract_svg в валидаторе
"raw": raw,
"finish_reason": fr,
}
for raw, fr in zip(result.raw_texts, result.finish_reasons)
]