diff --git a/api/config.py b/api/config.py
index 0dbc462a71..103a47efc3 100644
--- a/api/config.py
+++ b/api/config.py
@@ -3807,6 +3807,18 @@ def _read_custom_endpoint_models(
"models": models_for_group,
}
)
+ elif pid == "custom" and cfg_base_url:
+ # Anonymous custom endpoint: /v1/models probe may have
+ # failed (e.g. llama-server, lightweight relay), but the
+ # chat endpoint itself may still work. Add the group
+ # with an empty model list so the user can type a model
+ # ID manually rather than being blocked by a silent
+ # probe failure (#2542).
+ groups.append({
+ "provider": provider_name,
+ "provider_id": pid,
+ "models": [],
+ })
else:
if default_model:
label = _get_label_for_model(default_model, groups)
diff --git a/static/index.html b/static/index.html
index 9682788c94..fb72a251b2 100644
--- a/static/index.html
+++ b/static/index.html
@@ -138,6 +138,12 @@
+
+
+
+
+
+
diff --git a/static/style.css b/static/style.css
index e4714e214b..67708bd6be 100644
--- a/static/style.css
+++ b/static/style.css
@@ -425,6 +425,18 @@
@media (display-mode: standalone), (display-mode: fullscreen){
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
}
+ /* Pull-to-refresh indicator for PWA standalone mode */
+ .pull-to-refresh-indicator{position:absolute;top:0;left:0;right:0;z-index:100;display:flex;align-items:center;justify-content:center;gap:8px;padding:12px;font-size:13px;color:var(--muted);pointer-events:none;opacity:0;transform:translateY(-100%);transition:opacity .2s,transform .2s;background:var(--bg);border-bottom:1px solid var(--border);}
+ .pull-to-refresh-indicator.active{opacity:1;transform:translateY(0);}
+ .pull-to-refresh-indicator .ptr-icon{display:inline-block;transition:transform .2s;font-size:16px;}
+ .pull-to-refresh-indicator .ptr-icon.ready{transform:rotate(180deg);color:var(--accent);}
+ /* Reload button — visible only in PWA standalone / fullscreen */
+ .app-titlebar-reload{display:none;align-items:center;justify-content:center;width:32px;height:32px;flex-shrink:0;background:none;border:none;color:var(--muted);border-radius:8px;cursor:pointer;padding:0;-webkit-tap-highlight-color:transparent;transition:background-color .15s,color .15s;}
+ .app-titlebar-reload:hover{background:var(--hover-bg);color:var(--text);}
+ @media (display-mode: standalone), (display-mode: fullscreen){
+ :root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
+ .app-titlebar-reload{display:inline-flex;}
+ }
}
html{background:var(--sidebar);}
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;flex-direction:column;}
diff --git a/static/ui.js b/static/ui.js
index 587b595fa5..450e1fb40c 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -1914,7 +1914,69 @@ if(typeof document!=='undefined'){
// prevent the new chat's first scroll comparing against the previous chat's
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
function _resetScrollDirectionTracker(){ _lastScrollTop=null; }
-if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetScrollDirectionTracker;
+if(typeof window!=='undefined') window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
+/* ── Pull-to-refresh for PWA standalone (Android) ── */
+(function(){
+ if(typeof document==='undefined') return;
+ const isStandalone=window.navigator?.standalone||matchMedia('(display-mode:standalone),(display-mode:fullscreen)').matches;
+ if(!isStandalone) return;
+ const el=document.getElementById('messages');
+ if(!el) return;
+ let _ptrState=0; // 0=idle, 1=pulling, 2=ready
+ let _ptrStartY=0;
+ let _ptrCurrentY=0;
+ const THRESHOLD=80;
+ let _indicator=null;
+ function _ptrCreateIndicator(){
+ if(_indicator) return;
+ _indicator=document.createElement('div');
+ _indicator.className='pull-to-refresh-indicator';
+ _indicator.innerHTML='↓ Pull to refresh ';
+ el.parentNode.insertBefore(_indicator,el);
+ }
+ function _ptrUpdate(progress){
+ _ptrCreateIndicator();
+ const pulling=progress<1;
+ _indicator.classList.toggle('active',progress>0);
+ const icon=_indicator.querySelector('.ptr-icon');
+ const text=_indicator.querySelector('.ptr-text');
+ if(icon) icon.classList.toggle('ready',!pulling);
+ if(text) text.textContent=pulling?'Pull to refresh':'Release to refresh';
+ }
+ function _ptrReset(){
+ _ptrState=0;
+ _ptrStartY=0;
+ _ptrCurrentY=0;
+ if(_indicator) _indicator.classList.remove('active');
+ }
+ el.addEventListener('touchstart',function(e){
+ if(el.scrollTop>0||_ptrState!==0) return;
+ _ptrStartY=e.touches[0].clientY;
+ _ptrState=1;
+ },{passive:true});
+ el.addEventListener('touchmove',function(e){
+ if(_ptrState!==1) return;
+ _ptrCurrentY=e.touches[0].clientY;
+ const pull=_ptrCurrentY-_ptrStartY;
+ if(pull<0){ _ptrReset(); return; }
+ /* If not at the top, smooth-scroll to top first.
+ Next pull gesture will trigger the refresh. */
+ if(el.scrollTop>0){
+ el.scrollTo({top:0,behavior:'smooth'});
+ _ptrReset();
+ return;
+ }
+ const progress=Math.min(pull/THRESHOLD,1);
+ _ptrUpdate(progress);
+ _ptrState=progress>=1?2:1;
+ if(progress>0.3) e.preventDefault();
+ },{passive:false});
+ el.addEventListener('touchend',function(){
+ if(_ptrState===2){ window.location.reload(); return; }
+ _ptrReset();
+ },{passive:true});
+ el.addEventListener('touchcancel',_ptrReset,{passive:true});
+})();
(function(){
const el=document.getElementById('messages');
if(!el) return;
diff --git a/tests/test_issue2542_anonymous_custom_endpoint.py b/tests/test_issue2542_anonymous_custom_endpoint.py
new file mode 100644
index 0000000000..c1b42ec3dd
--- /dev/null
+++ b/tests/test_issue2542_anonymous_custom_endpoint.py
@@ -0,0 +1,33 @@
+"""Tests for anonymous custom endpoint fallback in get_available_models().
+
+Verifies that when /v1/models probe fails for an anonymous custom endpoint
+(configured via bare base_url, not custom_providers[]), the provider group
+is still added to the picker with an empty model list so the user can type
+a model ID manually (#2542).
+"""
+
+import re
+
+REPO = __file__.rsplit("/", 2)[-3] if "/" in __file__ else "."
+CONFIG_PY = open(f"{REPO}/api/config.py").read() if REPO != "." else ""
+
+
+def test_custom_anonymous_endpoint_empty_models_fallback_in_get_available_models():
+ """get_available_models() must add a 'custom' provider group with empty
+ models when cfg_base_url is set but the /v1/models probe returned nothing
+ (anonymous endpoint — no match in custom_providers[])."""
+ assert 'elif pid == "custom" and cfg_base_url:' in CONFIG_PY, (
+ "Anonymous custom endpoints with failed /v1/models probe must get "
+ "an empty-model fallback group instead of being silently dropped (#2542)"
+ )
+ # Verify the empty models dict is correct
+ assert '"models": []' in CONFIG_PY.split('elif pid == "custom" and cfg_base_url:')[1].split("groups.append")[1][:200], (
+ "The fallback group must have an empty models list"
+ )
+
+
+def test_revert_custom_endpoint_fallback_comment():
+ """The #2542 comment should reference the issue."""
+ assert "#2542" in CONFIG_PY, (
+ "The fallback should reference the issue number for traceability"
+ )