From 4adde251ae0d26a719d3b2d97093b36fa97c4a3c Mon Sep 17 00:00:00 2001 From: Mavis Date: Mon, 15 Jun 2026 09:58:48 +0300 Subject: [PATCH] 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) --- app.py | 132 ++++++++++++++++++++++++++++++++++++++++----------- lm_client.py | 18 +++++++ 2 files changed, 122 insertions(+), 28 deletions(-) diff --git a/app.py b/app.py index 42652dc..21298f4 100644 --- a/app.py +++ b/app.py @@ -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, diff --git a/lm_client.py b/lm_client.py index 2b20d27..c9d2738 100644 --- a/lm_client.py +++ b/lm_client.py @@ -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: