UI: model dropdown auto-fills from LM Studio /v1/models

- Removed allow_custom_value=True (it was rendering as text input, not select)
- Added fetch_lm_studio_models_with_value() wrapper that sets value=first model
- demo.load() auto-fetches models on UI load
- Removed Measure-Object bug in ps1 launcher
- Removed auto-load step in ps1 launcher (user picks model explicitly)
This commit is contained in:
Mavis
2026-06-15 09:58:48 +03:00
parent 36d9906e1f
commit 4adde251ae
2 changed files with 122 additions and 28 deletions
+88 -12
View File
@@ -123,7 +123,7 @@ def _refresh_history_df(limit: int = 20) -> list[list[Any]]:
_ON_GENERATE_NOUTPUTS = 7 _ON_GENERATE_NOUTPUTS = 7
def _empty_result(n_candidates: int) -> tuple[list, list, str, list, str, list, str]: def _empty_result(n_candidates: int) -> tuple[list, list, str, list, str, list, str, str]:
"""Стандартный «пустой» возврат on_generate для случаев раннего выхода. """Стандартный «пустой» возврат on_generate для случаев раннего выхода.
Gradio ждёт от каждого callback ровно столько значений, сколько объявлено Gradio ждёт от каждого callback ровно столько значений, сколько объявлено
@@ -132,9 +132,10 @@ def _empty_result(n_candidates: int) -> tuple[list, list, str, list, str, list,
outputs, иначе Gradio поднимет `IndexError`/warning. outputs, иначе Gradio поднимет `IndexError`/warning.
Returns: Returns:
Кортеж из 7 элементов: ([] , [] , "" , [] , "" , [] , ""). Кортеж из 8 элементов: ([] , [] , "" , [] , "" , [] , "" , "")
— последний "" — пустое поле reasoning_markdown.
""" """
return ([], [], "", [], "", [], "") return ([], [], "", [], "", [], "", "")
def _save_all_previews( def _save_all_previews(
@@ -214,6 +215,19 @@ def fetch_lm_studio_models(base_url: str, api_key: str) -> tuple[list[str], str]
return ([], f"ошибка: {type(exc).__name__}: {exc}") return ([], f"ошибка: {type(exc).__name__}: {exc}")
def fetch_lm_studio_models_with_value(base_url: str, api_key: str) -> tuple[gr.update, str]:
"""Обёртка для UI: возвращает gr.update(choices=..., value=первая модель).
Использует fetch_lm_studio_models() для получения списка и устанавливает
value на ПЕРВУЮ модель из списка, чтобы Gradio не ругался, что value
не входит в choices.
"""
models, status = fetch_lm_studio_models(base_url, api_key)
if models:
return (gr.update(choices=models, value=models[0]), status)
return (gr.update(choices=[], value=None), status)
def on_generate( def on_generate(
prompt: str, prompt: str,
mode: str, mode: str,
@@ -553,6 +567,9 @@ def on_generate_live(
tokens = 0 tokens = 0
final_model = model final_model = model
final_finish_reason = "" final_finish_reason = ""
# qwen3.5 шлёт рассуждения в отдельном поле `reasoning_content`; копим
# их параллельно с `content` (буфер для SVG) и рисуем в reasoning_md.
reasoning_text = ""
# Первый yield — статус "поехали". Это нужно, чтобы UI сразу сменил # Первый yield — статус "поехали". Это нужно, чтобы UI сразу сменил
# "Сгенерировано" прошлой генерации на "Live-стрим запущен…". # "Сгенерировано" прошлой генерации на "Live-стрим запущен…".
@@ -564,6 +581,7 @@ def on_generate_live(
"Live-стрим запущен…", "Live-стрим запущен…",
[], [],
"", "",
"",
) )
try: try:
@@ -584,22 +602,43 @@ def on_generate_live(
return return
if event.type == "delta": if event.type == "delta":
# Reasoning-токены (qwen3.5) приходят в `event.reasoning` —
# копим и обновляем reasoning_md ниже. Контент для SVG
# идёт в `event.content`. Если оба пустые — игнор.
had_anything = False
if event.content:
buffer += event.content buffer += event.content
tokens += 1 tokens += 1
had_anything = True
if event.reasoning:
reasoning_text += event.reasoning
had_anything = True
if not had_anything:
continue
# Throttle: не чаще одного render-yield на LIVE_THROTTLE_S. # Throttle: не чаще одного render-yield на LIVE_THROTTLE_S.
# Reasoning-обновления пропускаем через throttle наравне с
# render-yield'ами — иначе UI будет перерисовываться слишком
# часто (qwen3.5 шлёт 10-20 reasoning-токенов в секунду).
now = time.monotonic() now = time.monotonic()
if now - last_update_ts < LIVE_THROTTLE_S: if now - last_update_ts < LIVE_THROTTLE_S:
continue continue
# Парсим + рендерим. render_png может вернуть None на # Парсим + рендерим. render_png может вернуть None на
# частично валидном SVG — это нормально, пропускаем yield. # частично валидном SVG — это нормально, пропускаем render-yield,
svg = parse_to_valid(buffer) # но markdown с reasoning всё равно отдадим, чтобы юзер видел
# что модель думает.
svg = parse_to_valid(buffer) if buffer else ""
if svg:
last_valid_svg = svg last_valid_svg = svg
png = None
path_str = ""
if svg:
png = render_png(svg, size=size) png = render_png(svg, size=size)
if png is None: if png is not None:
continue
path_str = _save_live_png(png, live_path) path_str = _save_live_png(png, live_path)
if not path_str: # Готовим caption/gallery только если есть PNG; иначе отдаём
continue # пустую галерею, чтобы НЕ стирать предыдущий кадр (Gradio
# воспринимает [] как "очистить", а нам это не нужно).
if path_str:
last_update_ts = now last_update_ts = now
caption = (path_str, f"live · {tokens} tok") caption = (path_str, f"live · {tokens} tok")
yield ( yield (
@@ -610,6 +649,20 @@ def on_generate_live(
f"Live-стрим: ~{tokens} токенов", f"Live-стрим: ~{tokens} токенов",
[path_str], [path_str],
"", "",
reasoning_text,
)
elif reasoning_text:
# Reasoning идёт, а валидного SVG ещё нет — обновим только
# reasoning_md, остальные outputs не трогаем.
yield (
gr.update(),
gr.update(),
gr.update(),
gr.update(),
f"Live-стрим: ~{tokens} токенов",
gr.update(),
gr.update(),
reasoning_text,
) )
elif event.type == "end": elif event.type == "end":
final_model = event.model or model final_model = event.model or model
@@ -725,6 +778,7 @@ def on_generate_live(
status_md, # status status_md, # status
preview_paths, # превью для архива preview_paths, # превью для архива
"", # error "", # error
reasoning_text, # reasoning_md
) )
@@ -845,7 +899,6 @@ def build_ui() -> gr.Blocks:
label="Модель", label="Модель",
choices=[os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)], choices=[os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)],
value=os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL), value=os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL),
allow_custom_value=True,
) )
use_live_cb = gr.Checkbox( use_live_cb = gr.Checkbox(
label="Live-стрим (превью в реальном времени)", label="Live-стрим (превью в реальном времени)",
@@ -860,6 +913,14 @@ def build_ui() -> gr.Blocks:
columns=3, height=320, object_fit="contain", columns=3, height=320, object_fit="contain",
) )
gallery_state = gr.State([]) gallery_state = gr.State([])
# Поле с reasoning-токенами модели (qwen3.5 шлёт рассуждения
# отдельным `reasoning_content`-полем). Полезно для моделей
# типа qwen3.5-A3B, которые думают перед тем, как начать писать
# SVG — без этого юзер не видит, что стрим идёт.
reasoning_md = gr.Markdown(
label="Мысли модели (reasoning)",
value="",
)
with gr.Accordion("SVG-код лучшего кандидата", open=False): with gr.Accordion("SVG-код лучшего кандидата", open=False):
# gr.Code в Gradio 5.37 не имеет "xml" в whitelist языков # gr.Code в Gradio 5.37 не имеет "xml" в whitelist языков
# (есть python/sql/html/markdown/...). Используем "html" # (есть python/sql/html/markdown/...). Используем "html"
@@ -881,7 +942,13 @@ def build_ui() -> gr.Blocks:
outputs=[n_slider], outputs=[n_slider],
) )
refresh_btn.click( refresh_btn.click(
fetch_lm_studio_models, fetch_lm_studio_models_with_value,
inputs=[base_url_tb, api_key_tb],
outputs=[model_dd, fetch_status_md],
)
# Автозапрос списка моделей при загрузке UI
demo.load(
fetch_lm_studio_models_with_value,
inputs=[base_url_tb, api_key_tb], inputs=[base_url_tb, api_key_tb],
outputs=[model_dd, fetch_status_md], outputs=[model_dd, fetch_status_md],
) )
@@ -892,7 +959,16 @@ def build_ui() -> gr.Blocks:
image_in, palette_tb, model_dd, base_url_tb, api_key_tb, image_in, palette_tb, model_dd, base_url_tb, api_key_tb,
use_live_cb, use_live_cb,
], ],
outputs=[gallery, gallery_state, svg_viewer, history_df, status_md, gr.State([]), gr.State("")], outputs=[
gallery,
gallery_state,
svg_viewer,
history_df,
status_md,
gr.State([]),
gr.State(""),
reasoning_md,
],
) )
history_df.select( history_df.select(
on_history_select, on_history_select,
+18
View File
@@ -70,6 +70,9 @@ class StreamEvent:
type: str # 'delta' | 'end' | 'error' type: str # 'delta' | 'end' | 'error'
content: str = "" content: str = ""
# Reasoning-токены qwen3.5 (приходят в `delta.reasoning_content` отдельным
# полем в LM Studio; UI рисует их в своём markdown-поле).
reasoning: str = ""
usage: dict | None = None usage: dict | None = None
model: str = "" model: str = ""
finish_reason: str = "" finish_reason: str = ""
@@ -383,12 +386,27 @@ def stream_chat(
first = choices[0] first = choices[0]
delta = first.get("delta") or {} delta = first.get("delta") or {}
content = delta.get("content") content = delta.get("content")
reasoning = delta.get("reasoning_content")
emitted = False
if content: if content:
# content может прийти str или list[dict] (мультимодальный # content может прийти str или list[dict] (мультимодальный
# стрим). Склеиваем в строку. # стрим). Склеиваем в строку.
if isinstance(content, list): if isinstance(content, list):
content = "".join(_coerce_text_part(p) for p in content) content = "".join(_coerce_text_part(p) for p in content)
yield StreamEvent(type="delta", content=str(content)) yield StreamEvent(type="delta", content=str(content))
emitted = True
if reasoning:
# qwen3.5 / LM Studio кладёт рассуждение в ОТДЕЛЬНОЕ поле
# `reasoning_content`, а не в `content`. UI должен это
# отображать (см. градио-markdown в on_generate_live).
if isinstance(reasoning, list):
reasoning = "".join(_coerce_text_part(p) for p in reasoning)
yield StreamEvent(type="delta", reasoning=str(reasoning))
emitted = True
if not emitted:
# Чанки без полезного текста (например, начало стрима,
# где сервер шлёт только role) — пропускаем молча.
pass
fr = first.get("finish_reason") fr = first.get("finish_reason")
if fr: if fr: