mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
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:
@@ -2,6 +2,20 @@
|
||||
|
||||
## [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)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -3194,6 +3194,16 @@ def get_available_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:
|
||||
_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:
|
||||
_dedup_key = f"{_slug}:{_cp_model}" if _slug else _cp_model
|
||||
|
||||
@@ -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,
|
||||
and reversible migration gates for moving WebUI execution behind an explicit
|
||||
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
|
||||
recovering interrupted chat submissions.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -508,6 +508,7 @@ const LOCALES = {
|
||||
settings_tab_conversation: 'Conversation',
|
||||
settings_tab_appearance: 'Appearance',
|
||||
settings_tab_preferences: 'Preferences',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_system: 'System',
|
||||
settings_title: 'Settings',
|
||||
settings_save_btn: 'Save Settings',
|
||||
@@ -1697,6 +1698,7 @@ const LOCALES = {
|
||||
settings_tab_conversation: 'Conversazione',
|
||||
settings_tab_appearance: 'Aspetto',
|
||||
settings_tab_preferences: 'Preferenze',
|
||||
settings_tab_plugins: 'Plugin',
|
||||
settings_tab_system: 'Sistema',
|
||||
settings_title: 'Impostazioni',
|
||||
settings_save_btn: 'Salva Impostazioni',
|
||||
@@ -2878,6 +2880,7 @@ const LOCALES = {
|
||||
settings_tab_conversation: '会話',
|
||||
settings_tab_appearance: '外観',
|
||||
settings_tab_preferences: '環境設定',
|
||||
settings_tab_plugins: 'プラグイン',
|
||||
settings_tab_system: 'システム',
|
||||
settings_title: '設定',
|
||||
settings_save_btn: '設定を保存',
|
||||
@@ -4564,6 +4567,7 @@ const LOCALES = {
|
||||
settings_tab_appearance: 'Appearance',
|
||||
settings_tab_conversation: 'Conversation',
|
||||
settings_tab_preferences: 'Preferences',
|
||||
settings_tab_plugins: 'Плагины',
|
||||
settings_tab_system: 'System',
|
||||
status_updated: 'Updated',
|
||||
status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.',
|
||||
@@ -5672,6 +5676,7 @@ const LOCALES = {
|
||||
settings_tab_appearance: 'Appearance',
|
||||
settings_tab_conversation: 'Conversation',
|
||||
settings_tab_preferences: 'Preferences',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_system: 'System',
|
||||
status_updated: 'Updated',
|
||||
status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.',
|
||||
@@ -6516,6 +6521,7 @@ const LOCALES = {
|
||||
settings_tab_appearance: 'Appearance',
|
||||
settings_tab_conversation: 'Conversation',
|
||||
settings_tab_preferences: 'Preferences',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_system: 'System',
|
||||
status_updated: 'Updated',
|
||||
status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.',
|
||||
@@ -7909,6 +7915,7 @@ const LOCALES = {
|
||||
settings_tab_appearance: '外观',
|
||||
settings_tab_conversation: '对话',
|
||||
settings_tab_preferences: '偏好',
|
||||
settings_tab_plugins: '插件',
|
||||
settings_tab_system: '系统',
|
||||
status_updated: '已更新',
|
||||
status_ephemeral: '临时快照 — 不会保存到对话记录。',
|
||||
@@ -8368,6 +8375,7 @@ const LOCALES = {
|
||||
settings_tab_conversation: '對話',
|
||||
settings_tab_appearance: '外觀',
|
||||
settings_tab_preferences: '偏好設定',
|
||||
settings_tab_plugins: '外掛',
|
||||
settings_tab_system: '系統',
|
||||
settings_title: '\u8a2d\u5b9a',
|
||||
settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a',
|
||||
@@ -9637,6 +9645,7 @@ const LOCALES = {
|
||||
settings_tab_conversation: 'Conversa',
|
||||
settings_tab_appearance: 'Aparência',
|
||||
settings_tab_preferences: 'Preferências',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_system: 'Sistema',
|
||||
settings_title: 'Configurações',
|
||||
settings_save_btn: 'Salvar Configurações',
|
||||
@@ -10721,6 +10730,7 @@ const LOCALES = {
|
||||
settings_tab_conversation: '대화',
|
||||
settings_tab_appearance: '외형',
|
||||
settings_tab_preferences: '환경설정',
|
||||
settings_tab_plugins: '플러그인',
|
||||
settings_tab_system: '시스템',
|
||||
settings_title: '설정',
|
||||
settings_save_btn: '설정 저장',
|
||||
@@ -11822,6 +11832,7 @@ const LOCALES = {
|
||||
settings_tab_conversation: 'Conversation',
|
||||
settings_tab_appearance: 'Apparence',
|
||||
settings_tab_preferences: 'Préférences',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_system: 'Système',
|
||||
settings_title: 'Paramètres',
|
||||
settings_save_btn: 'Enregistrer les paramètres',
|
||||
|
||||
+5
-5
@@ -279,15 +279,15 @@
|
||||
<div class="side-menu" id="settingsMenu">
|
||||
<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>
|
||||
<span>Conversation</span>
|
||||
<span data-i18n="settings_tab_conversation">Conversation</span>
|
||||
</button>
|
||||
<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>
|
||||
<span>Appearance</span>
|
||||
<span data-i18n="settings_tab_appearance">Appearance</span>
|
||||
</button>
|
||||
<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>
|
||||
<span>Preferences</span>
|
||||
<span data-i18n="settings_tab_preferences">Preferences</span>
|
||||
</button>
|
||||
<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>
|
||||
@@ -295,11 +295,11 @@
|
||||
</button>
|
||||
<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>
|
||||
<span>Plugins</span>
|
||||
<span data-i18n="settings_tab_plugins">Plugins</span>
|
||||
</button>
|
||||
<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>
|
||||
<span>System</span>
|
||||
<span data-i18n="settings_tab_system">System</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -906,6 +906,7 @@
|
||||
.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 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 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;}
|
||||
|
||||
@@ -241,3 +241,161 @@ class TestCustomProvidersModelsDict:
|
||||
ids = [m["id"] for m in group["models"]]
|
||||
assert "@custom:sub2api:gpt-5.4-mini" 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}"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user