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:
@@ -123,7 +123,7 @@ def _refresh_history_df(limit: int = 20) -> list[list[Any]]:
|
||||
_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 для случаев раннего выхода.
|
||||
|
||||
Gradio ждёт от каждого callback ровно столько значений, сколько объявлено
|
||||
@@ -132,9 +132,10 @@ def _empty_result(n_candidates: int) -> tuple[list, list, str, list, str, list,
|
||||
outputs, иначе Gradio поднимет `IndexError`/warning.
|
||||
|
||||
Returns:
|
||||
Кортеж из 7 элементов: ([] , [] , "" , [] , "" , [] , "").
|
||||
Кортеж из 8 элементов: ([] , [] , "" , [] , "" , [] , "" , "")
|
||||
— последний "" — пустое поле reasoning_markdown.
|
||||
"""
|
||||
return ([], [], "", [], "", [], "")
|
||||
return ([], [], "", [], "", [], "", "")
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
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(
|
||||
prompt: str,
|
||||
mode: str,
|
||||
@@ -553,6 +567,9 @@ def on_generate_live(
|
||||
tokens = 0
|
||||
final_model = model
|
||||
final_finish_reason = ""
|
||||
# qwen3.5 шлёт рассуждения в отдельном поле `reasoning_content`; копим
|
||||
# их параллельно с `content` (буфер для SVG) и рисуем в reasoning_md.
|
||||
reasoning_text = ""
|
||||
|
||||
# Первый yield — статус "поехали". Это нужно, чтобы UI сразу сменил
|
||||
# "Сгенерировано" прошлой генерации на "Live-стрим запущен…".
|
||||
@@ -564,6 +581,7 @@ def on_generate_live(
|
||||
"Live-стрим запущен…",
|
||||
[],
|
||||
"",
|
||||
"",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -584,33 +602,68 @@ def on_generate_live(
|
||||
return
|
||||
|
||||
if event.type == "delta":
|
||||
buffer += event.content
|
||||
tokens += 1
|
||||
# Reasoning-токены (qwen3.5) приходят в `event.reasoning` —
|
||||
# копим и обновляем reasoning_md ниже. Контент для SVG
|
||||
# идёт в `event.content`. Если оба пустые — игнор.
|
||||
had_anything = False
|
||||
if event.content:
|
||||
buffer += event.content
|
||||
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.
|
||||
# Reasoning-обновления пропускаем через throttle наравне с
|
||||
# render-yield'ами — иначе UI будет перерисовываться слишком
|
||||
# часто (qwen3.5 шлёт 10-20 reasoning-токенов в секунду).
|
||||
now = time.monotonic()
|
||||
if now - last_update_ts < LIVE_THROTTLE_S:
|
||||
continue
|
||||
# Парсим + рендерим. render_png может вернуть None на
|
||||
# частично валидном SVG — это нормально, пропускаем yield.
|
||||
svg = parse_to_valid(buffer)
|
||||
last_valid_svg = svg
|
||||
png = render_png(svg, size=size)
|
||||
if png is None:
|
||||
continue
|
||||
path_str = _save_live_png(png, live_path)
|
||||
if not path_str:
|
||||
continue
|
||||
last_update_ts = now
|
||||
caption = (path_str, f"live · {tokens} tok")
|
||||
yield (
|
||||
[caption],
|
||||
[caption],
|
||||
svg,
|
||||
gr.update(), # history_df не трогаем до финала
|
||||
f"Live-стрим: ~{tokens} токенов",
|
||||
[path_str],
|
||||
"",
|
||||
)
|
||||
# частично валидном SVG — это нормально, пропускаем render-yield,
|
||||
# но markdown с reasoning всё равно отдадим, чтобы юзер видел
|
||||
# что модель думает.
|
||||
svg = parse_to_valid(buffer) if buffer else ""
|
||||
if svg:
|
||||
last_valid_svg = svg
|
||||
png = None
|
||||
path_str = ""
|
||||
if svg:
|
||||
png = render_png(svg, size=size)
|
||||
if png is not None:
|
||||
path_str = _save_live_png(png, live_path)
|
||||
# Готовим caption/gallery только если есть PNG; иначе отдаём
|
||||
# пустую галерею, чтобы НЕ стирать предыдущий кадр (Gradio
|
||||
# воспринимает [] как "очистить", а нам это не нужно).
|
||||
if path_str:
|
||||
last_update_ts = now
|
||||
caption = (path_str, f"live · {tokens} tok")
|
||||
yield (
|
||||
[caption],
|
||||
[caption],
|
||||
svg,
|
||||
gr.update(), # history_df не трогаем до финала
|
||||
f"Live-стрим: ~{tokens} токенов",
|
||||
[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":
|
||||
final_model = event.model or model
|
||||
final_finish_reason = event.finish_reason
|
||||
@@ -725,6 +778,7 @@ def on_generate_live(
|
||||
status_md, # status
|
||||
preview_paths, # превью для архива
|
||||
"", # error
|
||||
reasoning_text, # reasoning_md
|
||||
)
|
||||
|
||||
|
||||
@@ -845,7 +899,6 @@ def build_ui() -> gr.Blocks:
|
||||
label="Модель",
|
||||
choices=[os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)],
|
||||
value=os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL),
|
||||
allow_custom_value=True,
|
||||
)
|
||||
use_live_cb = gr.Checkbox(
|
||||
label="Live-стрим (превью в реальном времени)",
|
||||
@@ -860,6 +913,14 @@ def build_ui() -> gr.Blocks:
|
||||
columns=3, height=320, object_fit="contain",
|
||||
)
|
||||
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):
|
||||
# gr.Code в Gradio 5.37 не имеет "xml" в whitelist языков
|
||||
# (есть python/sql/html/markdown/...). Используем "html"
|
||||
@@ -881,7 +942,13 @@ def build_ui() -> gr.Blocks:
|
||||
outputs=[n_slider],
|
||||
)
|
||||
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],
|
||||
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,
|
||||
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(
|
||||
on_history_select,
|
||||
|
||||
@@ -70,6 +70,9 @@ class StreamEvent:
|
||||
|
||||
type: str # 'delta' | 'end' | 'error'
|
||||
content: str = ""
|
||||
# Reasoning-токены qwen3.5 (приходят в `delta.reasoning_content` отдельным
|
||||
# полем в LM Studio; UI рисует их в своём markdown-поле).
|
||||
reasoning: str = ""
|
||||
usage: dict | None = None
|
||||
model: str = ""
|
||||
finish_reason: str = ""
|
||||
@@ -383,12 +386,27 @@ def stream_chat(
|
||||
first = choices[0]
|
||||
delta = first.get("delta") or {}
|
||||
content = delta.get("content")
|
||||
reasoning = delta.get("reasoning_content")
|
||||
emitted = False
|
||||
if content:
|
||||
# content может прийти str или list[dict] (мультимодальный
|
||||
# стрим). Склеиваем в строку.
|
||||
if isinstance(content, list):
|
||||
content = "".join(_coerce_text_part(p) for p in 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")
|
||||
if fr:
|
||||
|
||||
Reference in New Issue
Block a user