Merge pull request #2369 from nesquena/stage-367

Release v0.51.74 (stage-367) — 4-PR safe-lane batch with first-timer contributions
This commit is contained in:
nesquena-hermes 2026-05-15 22:58:20 -07:00 committed by GitHub
commit e3035b3e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 373 additions and 5 deletions

View file

@ -2,6 +2,20 @@
## [Unreleased] ## [Unreleased]
## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n)
### Added
- **PR #2363** by @franksong2702 (refs #2361, refs #1925) — Adds `docs/rfcs/webui-run-state-consistency-contract.md` as a documentation companion to the #1925 runtime-boundary RFC. Documents the shared coherence contract across visible transcript, model context, pending turn metadata, live stream, run journal, compression handoff, browser timeline cache, and sidebar metadata. Complementary to #1925: that RFC says where execution ownership should move, this one says what must stay coherent across the current and future state layers.
### Fixed
- **PR #2362** by @franksong2702 (fixes #2360) — Markdown table rows no longer become too tall when cell text is wrapped in paragraph tags by the renderer. Adds a table-specific CSS reset for `.msg-body td p` and `.msg-body th p` so the global `margin-bottom: 10px` rule on `.msg-body p` doesn't add unwanted vertical space inside table cells. Especially visible on narrow viewports such as iPad Safari/Chrome.
- **PR #2365** by @mccxj (fixes #1106) — `get_available_models()` now handles YAML-list format `custom_providers.models` entries in addition to dict format. Pre-fix, declaring models as a list (`[m1, m2]`) or list-of-dicts (`[{id: m1, label: ...}]`) in `config.yaml` silently discarded every model from that provider in the picker dropdown because the code only recognized dict shape (`{model_id: {}}`). Now supports all three YAML shapes consistently with existing provider-config and live-models-fallback handlers.
- **PR #2367** by @mccxj — Settings sidebar menu items (Conversation, Appearance, Preferences, Plugins, System) now respect locale selection. Pre-fix these were hardcoded English; only Providers had `data-i18n`. Adds `data-i18n` attributes plus the missing `settings_tab_plugins` key. **Stage-367 maintainer fix applied inline**: the PR only added the new key to English, breaking 5 locale-parity tests. Added `settings_tab_plugins` translations to all 10 non-English locales (it/ja/ru/es/de/zh/zh-TW/pt/ko/fr).
## [v0.51.73] — 2026-05-16 — Release AW (stage-366 — 1-PR safe-lane batch — #2357 compression reference card anchoring fix) ## [v0.51.73] — 2026-05-16 — Release AW (stage-366 — 1-PR safe-lane batch — #2357 compression reference card anchoring fix)
### Fixed ### Fixed

View file

@ -3194,6 +3194,16 @@ def get_available_models() -> dict:
for _m_id in _cp_models_dict: for _m_id in _cp_models_dict:
if isinstance(_m_id, str) and _m_id.strip() and _m_id not in _cp_model_ids: if isinstance(_m_id, str) and _m_id.strip() and _m_id not in _cp_model_ids:
_cp_model_ids.append(_m_id.strip()) _cp_model_ids.append(_m_id.strip())
elif isinstance(_cp_models_dict, list):
for _item in _cp_models_dict:
if isinstance(_item, str):
_mid = _item.strip()
if _mid and _mid not in _cp_model_ids:
_cp_model_ids.append(_mid)
elif isinstance(_item, dict):
_mid = str(_item.get("id") or _item.get("model") or _item.get("name") or "").strip()
if _mid and _mid not in _cp_model_ids:
_cp_model_ids.append(_mid)
for _cp_model in _cp_model_ids: for _cp_model in _cp_model_ids:
_dedup_key = f"{_slug}:{_cp_model}" if _slug else _cp_model _dedup_key = f"{_slug}:{_cp_model}" if _slug else _cp_model

View file

@ -42,5 +42,9 @@ First-time contributor RFCs should be discussed in an issue before opening a PR.
event/control contract, runtime-state ownership matrix, acceptance catalog, event/control contract, runtime-state ownership matrix, acceptance catalog,
and reversible migration gates for moving WebUI execution behind an explicit and reversible migration gates for moving WebUI execution behind an explicit
adapter boundary. adapter boundary.
- [`webui-run-state-consistency-contract.md`](webui-run-state-consistency-contract.md)
— #2361 consistency rules for keeping transcript, model context, live streams,
replay, compression, and session metadata coherent during active and recovered
WebUI runs.
- [`turn-journal.md`](turn-journal.md) — Crash-safe WebUI turn journal for - [`turn-journal.md`](turn-journal.md) — Crash-safe WebUI turn journal for
recovering interrupted chat submissions. recovering interrupted chat submissions.

View file

@ -0,0 +1,150 @@
# WebUI Run State Consistency Contract
- **Status:** Proposed
- **Author:** @franksong2702
- **Created:** 2026-05-16
- **Tracking issue:** [#2361](https://github.com/nesquena/hermes-webui/issues/2361)
- **Related architecture:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925), [`hermes-run-adapter-contract.md`](hermes-run-adapter-contract.md)
## Problem
A single WebUI agent turn is represented by several overlapping state layers:
- the visible transcript the user can read,
- the model context / `context_messages` the agent actually receives,
- `pending_user_message` and active stream metadata,
- live SSE events and in-memory stream state,
- durable run journal / replay state,
- automatic compression summaries and active-task handoff text,
- the browser's live timeline DOM/cache,
- sidebar ordering, unread state, and `updated_at` metadata.
Those layers are not independent. When they drift apart, the user sees failures
that look unrelated: a prompt is visible but missing from recovered model
context, a live run loses or reorders thinking/tool cards after switching
sessions, cleanup makes old sessions look newly active, replay duplicates content,
or automatic compression reference material appears inside the active turn.
This RFC defines a consistency contract for those layers. It complements the
larger run adapter direction in #1925 by documenting what must remain coherent
while WebUI still has multiple overlapping state stores.
## Goals
- Define the state layers involved in active and recovered WebUI turns.
- Make the source-of-truth expectations explicit for each layer.
- Give reviewers a checklist for streaming, replay, compression, recovery,
model-context, and sidebar changes.
- Map recent real issues to reusable invariants so future fixes do not solve the
same class of bug one symptom at a time.
## Non-goals
- Do not implement a runner process, sidecar, or new runtime boundary here.
- Do not replace #1925 or the run adapter contract.
- Do not rewrite the streaming protocol in this RFC.
- Do not reopen already-fixed narrow bugs.
- Do not make this a catch-all for unrelated UI polish.
## State Layers
| Layer | Purpose | Source-of-truth expectation | Must not do |
|---|---|---|---|
| Visible transcript | Shows what the user and assistant said | Session transcript plus live replay should produce one chronological user-visible story | Hide the user turn that started active work, or show internal recovery text as current user intent |
| Model context / `context_messages` | Supplies conversation state to the agent | Must include the current visible user turn unless deliberately excluded with a user-visible reason | Let the agent resume from context that contradicts what the user can see |
| Pending turn metadata | Bridges submitted-but-not-yet-finalized user input | Must identify the user turn and stream that own active work | Become a permanent duplicate transcript row after recovery |
| Live stream / SSE | Delivers active runtime events to the browser | Must remain an observation path, not the only durable truth for already-emitted events | Lose the visible scene on refresh, reconnect, or session switch |
| Run journal / replay | Rebuilds emitted runtime events after reconnect or restart | Must be cursor-safe and idempotent | Duplicate assistant text, thinking text, tool cards, or compression cards |
| Compression summary / handoff | Gives the agent recovery context after automatic compression | Must remain agent-facing recovery material unless explicitly rendered as history | Pollute the active turn or become implicit current user intent |
| Live UI scene/cache | Preserves expanded rows, in-progress cards, local scroll, and transient grouping | May optimize presentation but must be rebuildable or degradable from transcript/replay | Become the only place where chronological ordering exists |
| Sidebar/session metadata | Helps the user find active and recent sessions | Must reflect meaningful user or assistant activity | Treat background cleanup as a fresh user-facing update |
## Core Invariants
1. **Visible current turns enter model context.** If the user can see a current
prompt and WebUI asks the model to continue that work, the prompt must be in
the reconstructed model context unless WebUI shows an explicit reason it was
excluded.
2. **Active turn UI keeps its owner.** The user turn that started active work
must remain visible before assistant text, thinking cards, tool cards, or
activity groups that belong to that work.
3. **Reattach preserves order or degrades clearly.** Refresh, reconnect, and
session switch must preserve chronological live-scene order. If WebUI cannot
restore the exact live scene, it should downgrade to an explicit structured
replay state instead of silently reordering content.
4. **Maintenance is not activity.** Runtime maintenance such as stale-stream
cleanup, orphan repair, or background compression must not refresh sidebar
ordering, unread markers, or active-session affordances as if the user or
assistant just acted.
5. **Replay is idempotent.** Replaying a run from a cursor must not duplicate
transcript rows, thinking content, interim assistant text, tool cards, or
compression cards.
6. **Compression is not current intent.** Automatic compression summaries and
reference cards are recovery/handoff material. They must not be treated as a
new user request, active-turn content, or the default visible explanation for
the current answer.
7. **Observation has a degraded path.** Long-running or many-session observation
should expose enough heartbeat/degraded status that the UI does not appear
silent and ordinary APIs do not stall behind active streams.
8. **Every mutation names its layer.** A PR touching streaming, recovery,
context reconstruction, compression, replay, or sidebar metadata should state
which layer it changes and what regression proves the invariant still holds.
## Review Checklist
Use this checklist for PRs that touch run state, streaming, replay, compression,
context reconstruction, or session metadata:
- Which state layers does this PR read or write?
- Which layer is the source of truth after this change?
- Can the visible transcript and model context diverge? If yes, is that
deliberate and user-visible?
- What happens after browser refresh, session switch, SSE reconnect, and WebUI
restart?
- Does replay rebuild the same scene without duplicates?
- Can this change move a session in the sidebar without meaningful user or
assistant activity?
- Can automatic compression or recovery text become visible active-turn content?
- What test or manual evidence proves the invariant?
## Existing Issue Map
| Example | State boundary exposed | Relevant invariant |
|---|---|---|
| [#2341](https://github.com/nesquena/hermes-webui/issues/2341) / [#2342](https://github.com/nesquena/hermes-webui/pull/2342) | Active reattach could show agent activity without the pending user turn that started it | 2 |
| [#2344](https://github.com/nesquena/hermes-webui/issues/2344) / [#2347](https://github.com/nesquena/hermes-webui/pull/2347) | Session switching could lose or reorder the live thinking/tool/interim timeline | 3, 5 |
| [#2345](https://github.com/nesquena/hermes-webui/issues/2345) / [#2349](https://github.com/nesquena/hermes-webui/pull/2349) | Stale stream cleanup could mutate `updated_at` and resurface old sessions | 4 |
| [#2346](https://github.com/nesquena/hermes-webui/issues/2346) / [#2348](https://github.com/nesquena/hermes-webui/pull/2348) | Thinking cards could repeat interim assistant progress text | 5 |
| [#2353](https://github.com/nesquena/hermes-webui/issues/2353) / [#2354](https://github.com/nesquena/hermes-webui/pull/2354) | Recovered pending user turns could be visible but missing from model context | 1 |
| [#2355](https://github.com/nesquena/hermes-webui/issues/2355) / [#2357](https://github.com/nesquena/hermes-webui/pull/2357) | Auto-compression rotation could leave reference-only cards in the active conversation tail | 3, 6 |
| [#2308](https://github.com/nesquena/hermes-webui/issues/2308) / [#2309](https://github.com/nesquena/hermes-webui/pull/2309) | Compressed sessions could resume stale agent tasks when the user starts an ordinary fresh chat | 6 |
| [#2283](https://github.com/nesquena/hermes-webui/pull/2283) | Run event journal replay provides the foundation for ordered recovery | 5 |
These references are evidence for the contract. This RFC does not make the
linked implementation PRs dependent on this document, and it does not close the
tracking issue by itself.
## Relationship To The Run Adapter RFC
The run adapter RFC defines the longer-term event/control boundary for WebUI and
Hermes runtime ownership. This RFC defines the consistency rules that the current
WebUI and any future adapter-backed implementation must preserve.
The two documents should be read together:
- The adapter contract answers: "Where should execution ownership live?"
- This consistency contract answers: "How do transcript, context, streams,
replay, compression, and UI metadata stay coherent while execution is active
or being recovered?"
## Rollout Plan
1. Land this RFC as a reviewable draft and refine it through PR discussion.
2. Link future streaming/recovery/compression/sidebar PRs back to the invariant
they intentionally preserve or change.
3. Convert recurring checklist items into focused regression tests where
practical.
4. If #1925 introduces a new adapter-backed runtime layer, update this RFC or
replace it with the accepted implementation contract so these invariants do
not live only in historical discussion.

View file

@ -508,6 +508,7 @@ const LOCALES = {
settings_tab_conversation: 'Conversation', settings_tab_conversation: 'Conversation',
settings_tab_appearance: 'Appearance', settings_tab_appearance: 'Appearance',
settings_tab_preferences: 'Preferences', settings_tab_preferences: 'Preferences',
settings_tab_plugins: 'Plugins',
settings_tab_system: 'System', settings_tab_system: 'System',
settings_title: 'Settings', settings_title: 'Settings',
settings_save_btn: 'Save Settings', settings_save_btn: 'Save Settings',
@ -1697,6 +1698,7 @@ const LOCALES = {
settings_tab_conversation: 'Conversazione', settings_tab_conversation: 'Conversazione',
settings_tab_appearance: 'Aspetto', settings_tab_appearance: 'Aspetto',
settings_tab_preferences: 'Preferenze', settings_tab_preferences: 'Preferenze',
settings_tab_plugins: 'Plugin',
settings_tab_system: 'Sistema', settings_tab_system: 'Sistema',
settings_title: 'Impostazioni', settings_title: 'Impostazioni',
settings_save_btn: 'Salva Impostazioni', settings_save_btn: 'Salva Impostazioni',
@ -2878,6 +2880,7 @@ const LOCALES = {
settings_tab_conversation: '会話', settings_tab_conversation: '会話',
settings_tab_appearance: '外観', settings_tab_appearance: '外観',
settings_tab_preferences: '環境設定', settings_tab_preferences: '環境設定',
settings_tab_plugins: 'プラグイン',
settings_tab_system: 'システム', settings_tab_system: 'システム',
settings_title: '設定', settings_title: '設定',
settings_save_btn: '設定を保存', settings_save_btn: '設定を保存',
@ -4564,6 +4567,7 @@ const LOCALES = {
settings_tab_appearance: 'Appearance', settings_tab_appearance: 'Appearance',
settings_tab_conversation: 'Conversation', settings_tab_conversation: 'Conversation',
settings_tab_preferences: 'Preferences', settings_tab_preferences: 'Preferences',
settings_tab_plugins: 'Плагины',
settings_tab_system: 'System', settings_tab_system: 'System',
status_updated: 'Updated', status_updated: 'Updated',
status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.',
@ -5672,6 +5676,7 @@ const LOCALES = {
settings_tab_appearance: 'Appearance', settings_tab_appearance: 'Appearance',
settings_tab_conversation: 'Conversation', settings_tab_conversation: 'Conversation',
settings_tab_preferences: 'Preferences', settings_tab_preferences: 'Preferences',
settings_tab_plugins: 'Plugins',
settings_tab_system: 'System', settings_tab_system: 'System',
status_updated: 'Updated', status_updated: 'Updated',
status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.',
@ -6516,6 +6521,7 @@ const LOCALES = {
settings_tab_appearance: 'Appearance', settings_tab_appearance: 'Appearance',
settings_tab_conversation: 'Conversation', settings_tab_conversation: 'Conversation',
settings_tab_preferences: 'Preferences', settings_tab_preferences: 'Preferences',
settings_tab_plugins: 'Plugins',
settings_tab_system: 'System', settings_tab_system: 'System',
status_updated: 'Updated', status_updated: 'Updated',
status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.',
@ -7909,6 +7915,7 @@ const LOCALES = {
settings_tab_appearance: '外观', settings_tab_appearance: '外观',
settings_tab_conversation: '对话', settings_tab_conversation: '对话',
settings_tab_preferences: '偏好', settings_tab_preferences: '偏好',
settings_tab_plugins: '插件',
settings_tab_system: '系统', settings_tab_system: '系统',
status_updated: '已更新', status_updated: '已更新',
status_ephemeral: '临时快照 — 不会保存到对话记录。', status_ephemeral: '临时快照 — 不会保存到对话记录。',
@ -8368,6 +8375,7 @@ const LOCALES = {
settings_tab_conversation: '對話', settings_tab_conversation: '對話',
settings_tab_appearance: '外觀', settings_tab_appearance: '外觀',
settings_tab_preferences: '偏好設定', settings_tab_preferences: '偏好設定',
settings_tab_plugins: '外掛',
settings_tab_system: '系統', settings_tab_system: '系統',
settings_title: '\u8a2d\u5b9a', settings_title: '\u8a2d\u5b9a',
settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a', settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a',
@ -9637,6 +9645,7 @@ const LOCALES = {
settings_tab_conversation: 'Conversa', settings_tab_conversation: 'Conversa',
settings_tab_appearance: 'Aparência', settings_tab_appearance: 'Aparência',
settings_tab_preferences: 'Preferências', settings_tab_preferences: 'Preferências',
settings_tab_plugins: 'Plugins',
settings_tab_system: 'Sistema', settings_tab_system: 'Sistema',
settings_title: 'Configurações', settings_title: 'Configurações',
settings_save_btn: 'Salvar Configurações', settings_save_btn: 'Salvar Configurações',
@ -10721,6 +10730,7 @@ const LOCALES = {
settings_tab_conversation: '대화', settings_tab_conversation: '대화',
settings_tab_appearance: '외형', settings_tab_appearance: '외형',
settings_tab_preferences: '환경설정', settings_tab_preferences: '환경설정',
settings_tab_plugins: '플러그인',
settings_tab_system: '시스템', settings_tab_system: '시스템',
settings_title: '설정', settings_title: '설정',
settings_save_btn: '설정 저장', settings_save_btn: '설정 저장',
@ -11822,6 +11832,7 @@ const LOCALES = {
settings_tab_conversation: 'Conversation', settings_tab_conversation: 'Conversation',
settings_tab_appearance: 'Apparence', settings_tab_appearance: 'Apparence',
settings_tab_preferences: 'Préférences', settings_tab_preferences: 'Préférences',
settings_tab_plugins: 'Plugins',
settings_tab_system: 'Système', settings_tab_system: 'Système',
settings_title: 'Paramètres', settings_title: 'Paramètres',
settings_save_btn: 'Enregistrer les paramètres', settings_save_btn: 'Enregistrer les paramètres',

View file

@ -279,15 +279,15 @@
<div class="side-menu" id="settingsMenu"> <div class="side-menu" id="settingsMenu">
<button type="button" class="side-menu-item active" data-settings-section="conversation" onclick="switchSettingsSection('conversation')"> <button type="button" class="side-menu-item active" data-settings-section="conversation" onclick="switchSettingsSection('conversation')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>Conversation</span> <span data-i18n="settings_tab_conversation">Conversation</span>
</button> </button>
<button type="button" class="side-menu-item" data-settings-section="appearance" onclick="switchSettingsSection('appearance')"> <button type="button" class="side-menu-item" data-settings-section="appearance" onclick="switchSettingsSection('appearance')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<span>Appearance</span> <span data-i18n="settings_tab_appearance">Appearance</span>
</button> </button>
<button type="button" class="side-menu-item" data-settings-section="preferences" onclick="switchSettingsSection('preferences')"> <button type="button" class="side-menu-item" data-settings-section="preferences" onclick="switchSettingsSection('preferences')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span>Preferences</span> <span data-i18n="settings_tab_preferences">Preferences</span>
</button> </button>
<button type="button" class="side-menu-item" data-settings-section="providers" onclick="switchSettingsSection('providers')"> <button type="button" class="side-menu-item" data-settings-section="providers" onclick="switchSettingsSection('providers')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
@ -295,11 +295,11 @@
</button> </button>
<button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins')"> <button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3 7h7l-5.5 4.3 2.1 7L12 16.2 5.4 20.3l2.1-7L2 9h7z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3 7h7l-5.5 4.3 2.1 7L12 16.2 5.4 20.3l2.1-7L2 9h7z"/></svg>
<span>Plugins</span> <span data-i18n="settings_tab_plugins">Plugins</span>
</button> </button>
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system')"> <button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span>System</span> <span data-i18n="settings_tab_system">System</span>
</button> </button>
</div> </div>
</div> </div>

View file

@ -906,6 +906,7 @@
.role-icon.assistant{background:var(--accent-bg-strong);color:var(--accent-text);border:1px solid var(--accent-bg-strong);} .role-icon.assistant{background:var(--accent-bg-strong);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;} .msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;}
.msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;} .msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;}
.msg-body td p,.msg-body th p{margin:0;}
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;} .msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
.msg-body h1,.msg-body h2,.msg-body h3,.msg-body h4,.msg-body h5,.msg-body h6{font-weight:700;color:var(--strong,var(--text));line-height:1.3;} .msg-body h1,.msg-body h2,.msg-body h3,.msg-body h4,.msg-body h5,.msg-body h6{font-weight:700;color:var(--strong,var(--text));line-height:1.3;}
.msg-body h1{font-size:24px;margin:24px 0 12px;border-bottom:1px solid var(--border);padding-bottom:6px;} .msg-body h1{font-size:24px;margin:24px 0 12px;border-bottom:1px solid var(--border);padding-bottom:6px;}

View file

@ -241,3 +241,161 @@ class TestCustomProvidersModelsDict:
ids = [m["id"] for m in group["models"]] ids = [m["id"] for m in group["models"]]
assert "@custom:sub2api:gpt-5.4-mini" in ids assert "@custom:sub2api:gpt-5.4-mini" in ids
assert "@custom:sub2api:gpt-5.4" in ids assert "@custom:sub2api:gpt-5.4" in ids
class TestCustomProvidersModelsList:
"""custom_providers entries with a 'models' list should also populate the dropdown."""
def test_models_list_of_strings_appear_in_dropdown(self):
"""Each entry in custom_providers[].models list of strings should appear as a selectable model."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "MultiModel",
"base_url": "http://multi:8080/v1",
"model": "base-v1",
"models": ["model-a", "model-b", "model-c"],
}
],
)
ids = _all_model_ids_bare(result)
for expected in ["base-v1", "model-a", "model-b", "model-c"]:
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
def test_models_list_of_dicts_with_id_appear_in_dropdown(self):
"""Each dict entry in models list with 'id' key should appear."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "ApiHub",
"models": [
{"id": "gpt-5", "label": "GPT-5"},
{"id": "claude-4", "label": "Claude Opus 4"},
],
}
],
)
ids = _all_model_ids_bare(result)
assert "gpt-5" in ids
assert "claude-4" in ids
def test_list_of_dicts_falls_back_to_model_name_when_no_id(self):
"""Dict entries in models list should fall back to 'model' or 'name' when 'id' is absent."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "FlexAPI",
"models": [
{"model": "via-model-key", "label": "From Model"},
{"name": "via-name-key", "label": "From Name"},
],
}
],
)
ids = _all_model_ids_bare(result)
assert "via-model-key" in ids
assert "via-name-key" in ids
def test_model_plus_list_dedup(self):
"""When singular 'model' also appears in 'models' list, it should not be duplicated."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "MyServer",
"model": "shared-model",
"models": ["shared-model", "unique-model"],
}
],
)
ids = _all_model_ids_bare(result)
assert ids.count("shared-model") == 1, f"'shared-model' should appear exactly once, got {ids.count('shared-model')}"
assert "unique-model" in ids
def test_empty_models_list_is_ignored(self):
"""An empty 'models' list should not break anything."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "TestServer",
"model": "only-model",
"models": [],
}
],
)
ids = _all_model_ids_bare(result)
assert "only-model" in ids
def test_unnamed_provider_models_list_works(self):
"""custom_providers without 'name' and with a 'models' list should still populate 'Custom' group."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"models": ["anon-a", "anon-b"],
}
],
)
ids = _all_model_ids(result)
for expected in ["anon-a", "anon-b"]:
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
def test_list_and_dict_providers_together(self):
"""A mix of list-format and dict-format custom providers should all contribute models."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "ListProv",
"models": ["list-m1", "list-m2"],
},
{
"name": "DictProv",
"models": {"dict-m1": {}, "dict-m2": {}},
},
],
)
ids = _all_model_ids_bare(result)
for expected in ["list-m1", "list-m2", "dict-m1", "dict-m2"]:
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
def test_mixed_list_items_string_and_dict(self):
"""A single models list mixing strings and dicts should produce all entries."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "MixedProv",
"models": [
"plain-string",
{"id": "dict-id", "label": "Dict Label"},
],
}
],
)
ids = _all_model_ids_bare(result)
assert "plain-string" in ids
assert "dict-id" in ids
def test_named_custom_list_models_prefixed_when_not_active_provider(self):
"""List-format custom provider models must carry routing prefix when another provider is active."""
result = _models_with_cfg(
model_cfg={"provider": "deepseek", "default": "deepseek-v4-pro"},
custom_providers=[
{
"name": "sub2api",
"base_url": "http://127.0.0.1:8080/v1",
"models": ["gpt-5.4-mini", "gpt-5.4"],
}
],
)
group = _group_for(result, "sub2api")
assert group is not None, "sub2api group missing"
assert group["provider_id"] == "custom:sub2api"
ids = [m["id"] for m in group["models"]]
assert "@custom:sub2api:gpt-5.4-mini" in ids, f"Expected @-prefixed ID, got {ids}"
assert "@custom:sub2api:gpt-5.4" in ids, f"Expected @-prefixed ID, got {ids}"

View file

@ -0,0 +1,20 @@
"""Regression tests for Markdown table cell spacing."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
def test_table_cell_paragraph_margins_are_reset():
"""Paragraphs inserted inside Markdown table cells should not add extra row height."""
assert ".msg-body td p,.msg-body th p{margin:0;}" in STYLE_CSS
def test_table_cell_paragraph_reset_follows_global_message_paragraph_rule():
"""The table-specific reset must override the generic message paragraph spacing rule."""
generic_rule = ".msg-body p{margin-bottom:10px;}"
table_reset = ".msg-body td p,.msg-body th p{margin:0;}"
assert generic_rule in STYLE_CSS
assert STYLE_CSS.index(generic_rule) < STYLE_CSS.index(table_reset)