Improve Ollama setup and model endpoint handling
This commit is contained in:
17
.env.example
17
.env.example
@@ -9,7 +9,12 @@
|
||||
LLM_HOST=localhost
|
||||
|
||||
# Additional LLM hosts, comma-separated (for model discovery)
|
||||
# LLM_HOSTS=llm-host.local:8000,backup-llm.local:8001
|
||||
# Use hostnames/IPs only; Odysseus scans common serve ports, including Ollama's 11434.
|
||||
# LLM_HOSTS=llm-host.local,backup-llm.local
|
||||
|
||||
# Optional Ollama base URL. In Docker, host Ollama is usually reachable here
|
||||
# when started with OLLAMA_HOST=0.0.0.0:11434.
|
||||
# OLLAMA_BASE_URL=http://host.docker.internal:11434/v1
|
||||
|
||||
# OpenAI API key (only needed if using OpenAI models).
|
||||
# Do not commit real keys. Keep this commented until needed.
|
||||
@@ -61,6 +66,16 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# CHROMADB_HOST=localhost
|
||||
# CHROMADB_PORT=8100
|
||||
|
||||
# Docker Compose host-port bind addresses for bundled services.
|
||||
# Defaults are loopback-only for safety. To expose ntfy only on Tailscale,
|
||||
# set NTFY_BIND to your host's Tailscale IP and update NTFY_BASE_URL.
|
||||
# CHROMADB_BIND=127.0.0.1
|
||||
# NTFY_BIND=127.0.0.1
|
||||
# NTFY_BASE_URL=http://localhost:8091
|
||||
# Example:
|
||||
# NTFY_BIND=100.x.y.z
|
||||
# NTFY_BASE_URL=http://100.x.y.z:8091
|
||||
|
||||
# ============================================================
|
||||
# RAG / Embeddings
|
||||
# ============================================================
|
||||
|
||||
32
README.md
32
README.md
@@ -172,15 +172,39 @@ Key settings:
|
||||
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
|
||||
|
||||
### Bundled services
|
||||
Docker Compose includes these by default:
|
||||
Docker Compose includes these by default. The bundled service ports bind to `127.0.0.1` unless you opt in to a different bind address in `.env`, so they are reachable from the host machine but not from your LAN or the public internet by default:
|
||||
|
||||
- **ChromaDB** → vector store for semantic memory. In Docker, Odysseus connects to `chromadb:8000`; from the host it is exposed as `localhost:8100`.
|
||||
- **SearXNG** → meta search for web search. In Docker, Odysseus connects to `searxng:8080`; from the host it is exposed only on `127.0.0.1:8080`.
|
||||
- **ntfy** → local notification service, exposed as `localhost:8091`.
|
||||
- **ChromaDB** → vector store for semantic memory. In Docker, Odysseus connects to `chromadb:8000`; from the host it is exposed as `${CHROMADB_BIND:-127.0.0.1}:8100`.
|
||||
- **SearXNG** → meta search for web search. In Docker, Odysseus connects to `searxng:8080`; from the host it is exposed as `127.0.0.1:8080`.
|
||||
- **ntfy** → local notification service, exposed as `${NTFY_BIND:-127.0.0.1}:8091`.
|
||||
|
||||
**Phone push notifications via ntfy:** A phone cannot subscribe to `127.0.0.1` on your server. To expose ntfy safely without opening it on every interface:
|
||||
|
||||
- **Tailscale (recommended)** — set `NTFY_BIND=<tailscale-host-ip>` and `NTFY_BASE_URL=http://<tailscale-host-ip>:8091` in `.env`, recreate ntfy, then point the ntfy Android/iOS app at `http://<tailscale-host-ip>:8091/<your-topic>`.
|
||||
- **Enable ntfy auth and bind to LAN** — add `NTFY_AUTH_FILE` + `NTFY_AUTH_DEFAULT_ACCESS=deny-all` to the `ntfy` service, create a user with `docker compose exec ntfy ntfy user add ...`, then set `NTFY_BIND` to your LAN IP. See the [ntfy docs](https://docs.ntfy.sh/config/#access-control).
|
||||
|
||||
### Optional external services
|
||||
- **Ollama** → local LLM server -- [ollama.ai](https://ollama.ai)
|
||||
|
||||
### Ollama with Docker
|
||||
If Odysseus is running in Docker and Ollama is running on the host, add the endpoint in Settings as:
|
||||
|
||||
`http://host.docker.internal:11434/v1`
|
||||
|
||||
The default Compose file already maps `host.docker.internal` on Linux. Ollama also needs to listen outside its own loopback interface:
|
||||
|
||||
```bash
|
||||
OLLAMA_HOST=0.0.0.0:11434 ollama serve
|
||||
```
|
||||
|
||||
For a systemd Ollama install, set that in the Ollama service override. If Odysseus can see Ollama but requests hang or fail, check that your host firewall allows Docker bridge traffic to port `11434`.
|
||||
|
||||
First-token latency is usually Ollama/model/hardware, not Odysseus. To compare, test Ollama directly:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:11434/v1/models
|
||||
```
|
||||
|
||||
## Architecture
|
||||
```
|
||||
app.py # FastAPI entry point
|
||||
|
||||
75
app.py
75
app.py
@@ -600,7 +600,7 @@ app.include_router(setup_contacts_routes())
|
||||
|
||||
def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
||||
"""Read an HTML file and inject the CSP nonce into inline <script> tags."""
|
||||
with open(file_path, "r") as f:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
nonce = getattr(request.state, "csp_nonce", "")
|
||||
html = html.replace("{{CSP_NONCE}}", nonce)
|
||||
@@ -670,6 +670,26 @@ async def get_version():
|
||||
async def health_check() -> Dict[str, str]:
|
||||
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
@app.get("/api/runtime")
|
||||
async def runtime_info() -> Dict[str, object]:
|
||||
in_docker = os.path.exists("/.dockerenv")
|
||||
if not in_docker:
|
||||
try:
|
||||
with open("/proc/1/cgroup", "r", encoding="utf-8", errors="ignore") as fh:
|
||||
cg = fh.read()
|
||||
in_docker = any(marker in cg for marker in ("docker", "containerd", "kubepods"))
|
||||
except Exception:
|
||||
in_docker = False
|
||||
ollama_url = (
|
||||
os.getenv("OLLAMA_BASE_URL")
|
||||
or os.getenv("OLLAMA_URL")
|
||||
or ("http://host.docker.internal:11434/v1" if in_docker else "http://127.0.0.1:11434/v1")
|
||||
)
|
||||
return {
|
||||
"in_docker": in_docker,
|
||||
"ollama_base_url": ollama_url,
|
||||
}
|
||||
|
||||
# ========= LIFECYCLE =========
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -896,33 +916,60 @@ async def startup_event():
|
||||
logger.warning(f"Nightly skill audit failed: {e}")
|
||||
|
||||
_startup_tasks.append(asyncio.create_task(_skill_audit_nightly_loop()))
|
||||
# Auto-detect local Ollama instance — run in background to avoid blocking startup
|
||||
# Auto-detect Ollama — run in background to avoid blocking startup. In Docker,
|
||||
# localhost is the container, so also try host.docker.internal.
|
||||
async def _detect_ollama():
|
||||
try:
|
||||
import shutil
|
||||
if not shutil.which("ollama"):
|
||||
return
|
||||
import httpx
|
||||
raw_candidates = [
|
||||
os.getenv("OLLAMA_BASE_URL", ""),
|
||||
os.getenv("OLLAMA_URL", ""),
|
||||
"http://localhost:11434/v1",
|
||||
"http://host.docker.internal:11434/v1",
|
||||
]
|
||||
candidates = []
|
||||
for raw in raw_candidates:
|
||||
base = (raw or "").strip().rstrip("/")
|
||||
if not base:
|
||||
continue
|
||||
if base.endswith("/api"):
|
||||
base = base[:-4].rstrip("/")
|
||||
if not base.endswith("/v1"):
|
||||
base = base + "/v1"
|
||||
if base not in candidates:
|
||||
candidates.append(base)
|
||||
|
||||
found_base = ""
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get("http://localhost:11434/v1/models", timeout=3)
|
||||
if r.status_code != 200:
|
||||
return
|
||||
for base in candidates:
|
||||
try:
|
||||
r = await client.get(base + "/models", timeout=2)
|
||||
if r.status_code == 200:
|
||||
found_base = base
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not found_base:
|
||||
return
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.base_url == "http://localhost:11434/v1"
|
||||
).first()
|
||||
existing = None
|
||||
for base in candidates:
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base).first()
|
||||
if existing:
|
||||
break
|
||||
if not existing:
|
||||
host = found_base.replace("http://", "").replace("https://", "").split("/")[0]
|
||||
ep = ModelEndpoint(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
name="Ollama (local)",
|
||||
base_url="http://localhost:11434/v1",
|
||||
name="Ollama" if host.startswith("localhost") else f"Ollama ({host})",
|
||||
base_url=found_base,
|
||||
is_enabled=True,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
logger.info("Auto-added Ollama endpoint (localhost:11434)")
|
||||
logger.info(f"Auto-added Ollama endpoint ({found_base})")
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
|
||||
@@ -308,11 +308,47 @@ class SessionManager:
|
||||
if not cached.history and getattr(cached, "message_count", 0) > 0:
|
||||
self._load_session_from_db(session_id)
|
||||
|
||||
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
||||
# DB row while a session object is still cached in RAM.
|
||||
self.sync_session_metadata(session_id)
|
||||
|
||||
# Update last_accessed
|
||||
self._touch_session(session_id)
|
||||
|
||||
return self.sessions[session_id]
|
||||
|
||||
def sync_session_metadata(self, session_id: str) -> bool:
|
||||
"""Refresh non-message session fields from the DB into the cached object."""
|
||||
session = self.sessions.get(session_id)
|
||||
if session is None:
|
||||
return False
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session is None:
|
||||
return False
|
||||
headers = db_session.headers
|
||||
if isinstance(headers, str):
|
||||
try:
|
||||
headers = json.loads(headers)
|
||||
except json.JSONDecodeError:
|
||||
headers = {}
|
||||
session.name = db_session.name
|
||||
session.endpoint_url = db_session.endpoint_url or ""
|
||||
session.model = db_session.model or ""
|
||||
session.headers = headers or {}
|
||||
session.rag = db_session.rag
|
||||
session.archived = db_session.archived
|
||||
session.owner = getattr(db_session, "owner", None)
|
||||
session.is_important = getattr(db_session, "is_important", False) or False
|
||||
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _load_session_from_db(self, session_id: str):
|
||||
"""Hydrate a single session (with messages) from the database."""
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -12,6 +12,10 @@ services:
|
||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||
- ./data/huggingface:/app/.cache/huggingface
|
||||
extra_hosts:
|
||||
# Lets the container reach local services on the Docker host, including
|
||||
# Ollama at http://host.docker.internal:11434.
|
||||
- "host.docker.internal:host-gateway"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -38,7 +42,7 @@ services:
|
||||
chromadb:
|
||||
image: chromadb/chroma:latest
|
||||
ports:
|
||||
- "8100:8000"
|
||||
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
|
||||
volumes:
|
||||
- chromadb-data:/chroma/chroma
|
||||
environment:
|
||||
@@ -66,11 +70,11 @@ services:
|
||||
image: binwiederhier/ntfy
|
||||
command: serve
|
||||
ports:
|
||||
- "8091:80"
|
||||
- "${NTFY_BIND:-127.0.0.1}:8091:80"
|
||||
volumes:
|
||||
- ntfy-cache:/var/cache/ntfy
|
||||
environment:
|
||||
- NTFY_BASE_URL=http://localhost:8091
|
||||
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://localhost:8091}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -391,7 +391,7 @@
|
||||
<a href="#testimonials">Testimonials</a>
|
||||
<a href="#how">How it started</a>
|
||||
<a href="#start">Get started</a>
|
||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
|
||||
<a class="btn" href="https://github.com/odysseus-ui/odysseus" target="_blank">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
@@ -419,7 +419,7 @@
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn primary" href="#start">Get started</a>
|
||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">View on GitHub</a>
|
||||
<a class="btn" href="https://github.com/odysseus-ui/odysseus" target="_blank">View on GitHub</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -660,9 +660,9 @@
|
||||
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5"/><path d="M12 19h8"/></svg>Get started</div>
|
||||
<h2 class="h" style="margin-bottom:6px;">Odysseus is yours.</h2>
|
||||
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request, no Trojan horse.</p>
|
||||
<div class="codeblock"><span class="prompt">$</span> git clone https://github.com/pewdiepie-archdaemon/odysseus.git && cd odysseus</div>
|
||||
<div class="codeblock"><span class="prompt">$</span> git clone https://github.com/odysseus-ui/odysseus.git && cd odysseus</div>
|
||||
<div>
|
||||
<a class="btn primary" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
||||
<a class="btn primary" href="https://github.com/odysseus-ui/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
||||
</div>
|
||||
<div class="pill-row">
|
||||
<span class="pill">Self-hosted</span>
|
||||
|
||||
@@ -482,7 +482,10 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
}
|
||||
return {"ok": False, "message": f"ntfy returned HTTP {r.status_code} from {full_url}: {r.text[:200]}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "message": f"ntfy publish to {full_url} failed: {e}"[:300]}
|
||||
hint = ""
|
||||
if parsed.hostname not in ("127.0.0.1", "localhost"):
|
||||
hint = " If this is Docker Compose ntfy, set NTFY_BIND to that host/Tailscale IP and NTFY_BASE_URL to the same server URL in .env, then recreate ntfy."
|
||||
return {"ok": False, "message": f"ntfy publish to {full_url} failed: {e}.{hint}"[:500]}
|
||||
|
||||
# All other presets: GET against a known health endpoint.
|
||||
# Fall back to detecting from name if preset is missing.
|
||||
|
||||
@@ -902,7 +902,8 @@ def setup_calendar_routes() -> APIRouter:
|
||||
lines.append(f"DTSTART:{ev.dtstart.strftime('%Y%m%dT%H%M%S')}")
|
||||
lines.append(f"DTEND:{ev.dtend.strftime('%Y%m%dT%H%M%S')}")
|
||||
if ev.description:
|
||||
lines.append(f"DESCRIPTION:{ev.description.replace(chr(10), '\\n')}")
|
||||
escaped_desc = ev.description.replace(chr(10), "\\n")
|
||||
lines.append(f"DESCRIPTION:{escaped_desc}")
|
||||
if ev.location:
|
||||
lines.append(f"LOCATION:{ev.location}")
|
||||
if ev.rrule:
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, AsyncGenerator, List
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form, Query
|
||||
@@ -17,6 +18,7 @@ from src.agent_loop import stream_agent_loop
|
||||
from src import agent_runs
|
||||
from src.model_context import estimate_tokens
|
||||
from src.chat_helpers import coerce_message_and_session
|
||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from core.exceptions import SessionNotFoundError
|
||||
from src.auth_helpers import get_current_user
|
||||
@@ -87,6 +89,46 @@ def _message_needs_tools(text: str) -> bool:
|
||||
return any(p.search(text) for p in _TOOL_INTENT_PATTERNS)
|
||||
|
||||
|
||||
def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
||||
if not session_url or not endpoint_base:
|
||||
return False
|
||||
sess = session_url.rstrip("/")
|
||||
base = _normalize_base(endpoint_base).rstrip("/")
|
||||
variants = {
|
||||
base,
|
||||
base + "/chat/completions",
|
||||
build_chat_url(base).rstrip("/"),
|
||||
}
|
||||
return sess in variants or sess.startswith(base + "/")
|
||||
|
||||
|
||||
def _clear_orphaned_session_endpoint(sess) -> bool:
|
||||
"""Clear a session model if its endpoint was deleted from ModelEndpoint."""
|
||||
if not getattr(sess, "endpoint_url", ""):
|
||||
return False
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
for ep in endpoints:
|
||||
if _session_url_matches_endpoint(sess.endpoint_url or "", ep.base_url or ""):
|
||||
return False
|
||||
db_session = db.query(DBSession).filter(DBSession.id == sess.id).first()
|
||||
if db_session:
|
||||
db_session.endpoint_url = ""
|
||||
db_session.model = ""
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
sess.endpoint_url = ""
|
||||
sess.model = ""
|
||||
sess.headers = {}
|
||||
return True
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def setup_chat_routes(
|
||||
session_manager,
|
||||
chat_handler,
|
||||
@@ -121,6 +163,8 @@ def setup_chat_routes(
|
||||
sess = session_manager.get_session(session)
|
||||
except KeyError:
|
||||
raise HTTPException(404, f"Session '{session}' not found")
|
||||
if _clear_orphaned_session_endpoint(sess):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
|
||||
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
|
||||
# non-streaming path can't be used to bypass).
|
||||
@@ -259,6 +303,8 @@ def setup_chat_routes(
|
||||
# but BEFORE loading. Prevents cross-user session hijack.
|
||||
_verify_session_owner(request, session)
|
||||
sess = session_manager.get_session(session)
|
||||
if _clear_orphaned_session_endpoint(sess):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
except SessionNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except (ValueError, ValidationError):
|
||||
|
||||
@@ -6,12 +6,13 @@ import json
|
||||
import time as _time
|
||||
import logging
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import APIRouter, HTTPException, Form, Query, Body, Request
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import StreamingResponse
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||
from core.middleware import require_admin
|
||||
from src.llm_core import _detect_provider, ANTHROPIC_MODELS
|
||||
from src.settings import load_settings as _load_settings, save_settings as _save_settings
|
||||
@@ -301,6 +302,21 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
logger.warning(f"Failed to probe {url} with API key: {e}")
|
||||
return []
|
||||
logger.warning(f"Failed to probe {url}: {e}")
|
||||
|
||||
# Older Ollama builds and some proxies expose native /api/tags even when
|
||||
# the OpenAI-compatible /v1/models path is unavailable.
|
||||
try:
|
||||
parsed = urlparse(base)
|
||||
if parsed.port == 11434 or "ollama" in (parsed.hostname or "").lower():
|
||||
root = base[:-3].rstrip("/") if base.endswith("/v1") else base
|
||||
r = httpx.get(root + "/api/tags", timeout=timeout)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
||||
if models:
|
||||
return models
|
||||
except Exception as e:
|
||||
logger.debug(f"Ollama /api/tags probe failed for {base}: {e}")
|
||||
# Fall back to curated list if the provider has a URL-based match (e.g. z.ai has no /models endpoint)
|
||||
curated_key = _match_provider_curated(base, None)
|
||||
fallback = _PROVIDER_CURATED.get(curated_key) if curated_key else None
|
||||
@@ -310,6 +326,51 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
return []
|
||||
|
||||
|
||||
def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> Dict[str, Any]:
|
||||
"""Reachability probe that does not require installed/listed models."""
|
||||
from src.endpoint_resolver import resolve_url
|
||||
base = resolve_url(_normalize_base(base_url))
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
url = base + "/models"
|
||||
try:
|
||||
r = httpx.get(url, headers=headers, timeout=timeout)
|
||||
if 300 <= r.status_code < 400:
|
||||
loc = r.headers.get("location", "")
|
||||
if loc.startswith("/login") or "/login" in loc:
|
||||
return {
|
||||
"reachable": False,
|
||||
"status_code": r.status_code,
|
||||
"error": "That is Odysseus, not a model server. Use the Ollama URL, usually http://host.docker.internal:11434/v1 in Docker.",
|
||||
}
|
||||
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code} redirect"}
|
||||
if r.status_code < 500:
|
||||
return {"reachable": r.status_code < 400, "status_code": r.status_code, "error": None if r.status_code < 400 else f"HTTP {r.status_code}"}
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
else:
|
||||
last_error = f"HTTP {r.status_code}"
|
||||
|
||||
try:
|
||||
parsed = urlparse(base)
|
||||
if parsed.port == 11434 or "ollama" in (parsed.hostname or "").lower():
|
||||
root = base[:-3].rstrip("/") if base.endswith("/v1") else base
|
||||
for path in ("/api/version", "/api/tags"):
|
||||
try:
|
||||
r = httpx.get(root + path, timeout=timeout)
|
||||
if r.status_code < 400:
|
||||
return {"reachable": True, "status_code": r.status_code, "error": None}
|
||||
last_error = f"HTTP {r.status_code}"
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"reachable": False, "status_code": None, "error": last_error}
|
||||
|
||||
|
||||
def setup_model_routes(model_discovery):
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -549,15 +610,16 @@ def setup_model_routes(model_discovery):
|
||||
db.close()
|
||||
|
||||
async def _probe_one(ep_id: str, base: str, api_key: Optional[str]) -> Dict[str, Any]:
|
||||
url = base.rstrip("/") + "/models"
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
t0 = _time.time()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=1.5) as client:
|
||||
r = await client.get(url, headers=headers)
|
||||
models = _probe_endpoint(base, api_key, timeout=2.5)
|
||||
lat = round((_time.time() - t0) * 1000)
|
||||
return {"alive": r.status_code < 400, "latency_ms": lat,
|
||||
"status_code": r.status_code, "error": None if r.status_code < 400 else f"HTTP {r.status_code}"}
|
||||
return {
|
||||
"alive": bool(models),
|
||||
"latency_ms": lat,
|
||||
"status_code": 200 if models else None,
|
||||
"error": None if models else "No models found",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"alive": False, "latency_ms": None, "status_code": None, "error": str(e)[:120]}
|
||||
|
||||
@@ -789,6 +851,12 @@ def setup_model_routes(model_discovery):
|
||||
except Exception:
|
||||
pass
|
||||
visible = [m for m in all_models if m not in hidden]
|
||||
status = "online" if all_models else "offline"
|
||||
ping = None
|
||||
if not all_models and r.is_enabled:
|
||||
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0)
|
||||
if ping.get("reachable"):
|
||||
status = "empty"
|
||||
results.append({
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
@@ -797,7 +865,9 @@ def setup_model_routes(model_discovery):
|
||||
"is_enabled": r.is_enabled,
|
||||
"models": visible,
|
||||
"hidden_count": len(hidden),
|
||||
"online": len(all_models) > 0,
|
||||
"online": status != "offline",
|
||||
"status": status,
|
||||
"ping_error": (ping or {}).get("error") if ping else None,
|
||||
"model_type": getattr(r, "model_type", None) or "llm",
|
||||
"supports_tools": getattr(r, "supports_tools", None),
|
||||
})
|
||||
@@ -840,7 +910,11 @@ def setup_model_routes(model_discovery):
|
||||
should_probe = require_model_list or not _truthy(skip_probe)
|
||||
|
||||
# Quick model list fetch (1s timeout — if endpoint is slow, it'll update on next refresh)
|
||||
model_ids = _probe_endpoint(base_url, api_key.strip() or None, timeout=1) if should_probe else []
|
||||
_probe_timeout = 3 if (":11434" in base_url or "ollama" in base_url.lower()) else 1
|
||||
model_ids = _probe_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout) if should_probe else []
|
||||
ping = {"reachable": False, "error": None}
|
||||
if should_probe and not model_ids:
|
||||
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout)
|
||||
if require_model_list and not model_ids:
|
||||
raise HTTPException(400, "No models found for that provider/key")
|
||||
|
||||
@@ -876,6 +950,7 @@ def setup_model_routes(model_discovery):
|
||||
settings["default_model"] = model_ids[0] if model_ids else ""
|
||||
_save_settings(settings)
|
||||
_invalidate_models_cache()
|
||||
_local_probe_cache["data"] = None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -883,8 +958,38 @@ def setup_model_routes(model_discovery):
|
||||
return {
|
||||
"id": ep_id,
|
||||
"name": name.strip(),
|
||||
"base_url": base_url,
|
||||
"models": model_ids,
|
||||
"online": len(model_ids) > 0,
|
||||
"online": bool(model_ids) or bool(ping.get("reachable")),
|
||||
"status": "online" if model_ids else ("empty" if ping.get("reachable") else "offline"),
|
||||
"ping_error": ping.get("error") if ping else None,
|
||||
}
|
||||
|
||||
@router.post("/model-endpoints/test")
|
||||
def test_model_endpoint(
|
||||
request: Request,
|
||||
base_url: str = Form(...),
|
||||
api_key: str = Form(""),
|
||||
):
|
||||
require_admin(request)
|
||||
base_url = base_url.strip().rstrip("/")
|
||||
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
|
||||
if base_url.endswith(suffix):
|
||||
base_url = base_url[:-len(suffix)].rstrip("/")
|
||||
if not base_url:
|
||||
raise HTTPException(400, "Base URL is required")
|
||||
from src.endpoint_resolver import resolve_url
|
||||
base_url = resolve_url(base_url)
|
||||
probe_timeout = 3 if (":11434" in base_url or "ollama" in base_url.lower()) else 2
|
||||
models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
||||
ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
||||
return {
|
||||
"base_url": base_url,
|
||||
"online": bool(models) or bool(ping.get("reachable")),
|
||||
"status": "online" if models else ("empty" if ping.get("reachable") else "offline"),
|
||||
"ping_error": ping.get("error") if ping else None,
|
||||
"models": models,
|
||||
"count": len(models),
|
||||
}
|
||||
|
||||
@router.get("/model-endpoints/{ep_id}/probe")
|
||||
@@ -1175,6 +1280,49 @@ def setup_model_routes(model_discovery):
|
||||
_save_settings(settings)
|
||||
return cleared
|
||||
|
||||
def _session_uses_endpoint_url(session_url: str, base_url: str) -> bool:
|
||||
if not session_url or not base_url:
|
||||
return False
|
||||
sess = session_url.rstrip("/")
|
||||
base = _normalize_base(base_url).rstrip("/")
|
||||
variants = {
|
||||
base,
|
||||
base + "/chat/completions",
|
||||
build_chat_url(base).rstrip("/"),
|
||||
}
|
||||
return sess in variants or sess.startswith(base + "/")
|
||||
|
||||
def _clear_sessions_for_endpoint(db, base_url: str) -> int:
|
||||
cleared = 0
|
||||
rows = db.query(DbSession).filter(DbSession.endpoint_url.isnot(None)).all()
|
||||
for row in rows:
|
||||
if _session_uses_endpoint_url(row.endpoint_url or "", base_url):
|
||||
row.endpoint_url = ""
|
||||
row.model = ""
|
||||
row.updated_at = datetime.utcnow()
|
||||
cleared += 1
|
||||
return cleared
|
||||
|
||||
def _clear_loaded_sessions_for_endpoint(base_url: str) -> int:
|
||||
try:
|
||||
from src.ai_interaction import get_session_manager
|
||||
manager = get_session_manager()
|
||||
except Exception:
|
||||
manager = None
|
||||
if not manager:
|
||||
return 0
|
||||
cleared = 0
|
||||
try:
|
||||
for sess in list(getattr(manager, "sessions", {}).values()):
|
||||
if _session_uses_endpoint_url(getattr(sess, "endpoint_url", "") or "", base_url):
|
||||
sess.endpoint_url = ""
|
||||
sess.model = ""
|
||||
sess.headers = {}
|
||||
cleared += 1
|
||||
except Exception:
|
||||
return cleared
|
||||
return cleared
|
||||
|
||||
@router.get("/model-endpoints/{ep_id}/dependents")
|
||||
def get_endpoint_dependents(ep_id: str, request: Request):
|
||||
"""Check which settings depend on this endpoint."""
|
||||
@@ -1191,10 +1339,18 @@ def setup_model_routes(model_discovery):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
# Clean up any settings that reference this endpoint
|
||||
cleared = _clear_settings_for_endpoint(ep_id)
|
||||
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url)
|
||||
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url)
|
||||
db.delete(ep)
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
return {"deleted": True, "cleared_settings": cleared}
|
||||
_local_probe_cache["data"] = None
|
||||
return {
|
||||
"deleted": True,
|
||||
"cleared_settings": cleared,
|
||||
"cleared_sessions": cleared_sessions,
|
||||
"cleared_loaded_sessions": cleared_loaded_sessions,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -284,11 +284,19 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
db.close()
|
||||
# Switch model/endpoint mid-session
|
||||
if model is not None and endpoint_url is not None:
|
||||
if endpoint_id:
|
||||
from core.database import ModelEndpoint
|
||||
_db = SessionLocal()
|
||||
try:
|
||||
ep = _db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id).first()
|
||||
if not ep:
|
||||
raise HTTPException(400, "Model endpoint no longer exists")
|
||||
finally:
|
||||
_db.close()
|
||||
session.model = model
|
||||
session.endpoint_url = endpoint_url
|
||||
# Update auth headers from the endpoint's stored API key
|
||||
if endpoint_id:
|
||||
from core.database import ModelEndpoint
|
||||
_db = SessionLocal()
|
||||
try:
|
||||
ep = _db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id).first()
|
||||
|
||||
@@ -4,8 +4,6 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import fcntl
|
||||
import shlex
|
||||
import shutil
|
||||
import uuid
|
||||
@@ -13,6 +11,13 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
import pty
|
||||
except ImportError:
|
||||
fcntl = None
|
||||
pty = None
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -97,6 +102,11 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An
|
||||
|
||||
async def _generate_pty(cmd: str, timeout: int, request: Request):
|
||||
"""Run command in a pseudo-TTY so tqdm/progress bars work natively."""
|
||||
if pty is None or fcntl is None:
|
||||
yield f"data: {json.dumps({'stream': 'stderr', 'data': 'PTY streaming is not available on Windows'})}\n\n"
|
||||
yield f"data: {json.dumps({'exit_code': -1})}\n\n"
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ def _provider_label(url: str) -> str:
|
||||
if "googleapis.com" in u or "generativelanguage" in u: return "Google"
|
||||
if "together.xyz" in u or "together.ai" in u: return "Together"
|
||||
if "fireworks.ai" in u: return "Fireworks"
|
||||
if "ollama" in u or ":11434" in u: return "Ollama"
|
||||
if "localhost" in u or "127.0.0.1" in u: return "local endpoint"
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
@@ -375,8 +376,20 @@ def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT,
|
||||
h.update(headers)
|
||||
r = httpx.get(base_chat_url.replace("/chat/completions", "/models"), headers=h, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return [m.get("id") for m in (r.json().get("data") or []) if m.get("id")]
|
||||
data = r.json()
|
||||
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if ids:
|
||||
return ids
|
||||
return [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
||||
except Exception:
|
||||
try:
|
||||
if ":11434" in base_chat_url or "ollama" in base_chat_url.lower():
|
||||
root = base_chat_url.replace("/v1/chat/completions", "").replace("/chat/completions", "").rstrip("/")
|
||||
r = httpx.get(root + "/api/tags", timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return [m.get("name") or m.get("model") for m in (r.json().get("models") or []) if m.get("name") or m.get("model")]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def normalize_model_id(endpoint_url: str, requested: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT) -> Optional[str]:
|
||||
|
||||
@@ -3,8 +3,10 @@ import json
|
||||
import time
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import List, Dict, Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -75,7 +77,11 @@ class ModelDiscovery:
|
||||
|
||||
def _get_hosts(self) -> List[str]:
|
||||
"""Get all hosts to scan, using env override, Tailscale, or default."""
|
||||
import os
|
||||
def _append_host(out: List[str], host: str) -> None:
|
||||
host = (host or "").strip()
|
||||
if not host or host in out:
|
||||
return
|
||||
out.append(host)
|
||||
|
||||
# Manual override takes priority
|
||||
extra = os.getenv("LLM_HOSTS", "").strip()
|
||||
@@ -84,6 +90,7 @@ class ModelDiscovery:
|
||||
# Always include the default host too
|
||||
if self.default_host not in hosts:
|
||||
hosts.insert(0, self.default_host)
|
||||
_append_host(hosts, "host.docker.internal")
|
||||
return hosts
|
||||
|
||||
# Try Tailscale discovery
|
||||
@@ -92,10 +99,23 @@ class ModelDiscovery:
|
||||
# Ensure default_host is included
|
||||
if self.default_host not in ts_hosts:
|
||||
ts_hosts.insert(0, self.default_host)
|
||||
_append_host(ts_hosts, "host.docker.internal")
|
||||
return ts_hosts
|
||||
|
||||
# Fallback to single host
|
||||
return [self.default_host]
|
||||
hosts = [self.default_host]
|
||||
# Docker desktop/Linux compose maps this to the host machine. That is
|
||||
# the common "I started Ollama normally on this computer" case.
|
||||
_append_host(hosts, "host.docker.internal")
|
||||
for env_name in ("OLLAMA_BASE_URL", "OLLAMA_URL"):
|
||||
raw = os.getenv(env_name, "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
parsed = urlparse(raw if "://" in raw else "http://" + raw)
|
||||
_append_host(hosts, parsed.hostname or "")
|
||||
except Exception:
|
||||
pass
|
||||
return hosts
|
||||
|
||||
def _check_port(self, host: str, port: int) -> Optional[Dict[str, Any]]:
|
||||
"""Check a single host:port for models."""
|
||||
@@ -125,8 +145,10 @@ class ModelDiscovery:
|
||||
|
||||
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
|
||||
|
||||
# Build list of (host, port) to check
|
||||
targets = [(h, p) for h in hosts for p in range(8000, 8021)]
|
||||
# Build list of (host, port) to check. 8000-8020 catches vLLM,
|
||||
# llama.cpp, SGLang, and Cookbook serves; 11434 catches Ollama.
|
||||
ports = list(range(8000, 8021)) + [11434]
|
||||
targets = [(h, p) for h in hosts for p in ports]
|
||||
|
||||
seen_models = set() # dedupe by (port, model_ids) to avoid same machine via different IPs
|
||||
|
||||
|
||||
@@ -980,7 +980,12 @@
|
||||
<div class="model-picker-wrap" id="model-picker-wrap">
|
||||
<button type="button" class="model-picker-btn" id="model-picker-btn" title="Switch model"><span id="model-picker-label">Select model</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg></button>
|
||||
<div class="model-picker-menu hidden" id="model-picker-menu">
|
||||
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off">
|
||||
<div class="model-picker-search-row">
|
||||
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off">
|
||||
<button type="button" class="model-picker-action-btn primary" id="model-picker-add-models-btn" title="Add model endpoints" aria-label="Add model endpoints">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="model-picker-list" id="model-picker-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1963,7 +1968,7 @@
|
||||
<div data-settings-panel="services">
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>Add Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Connect to a cloud API or scan your network for a local model server.</div>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Connect local models first, or add a cloud API.</div>
|
||||
|
||||
<!-- Local subsection -->
|
||||
<div class="adm-add-section collapsible collapsed" id="adm-add-local">
|
||||
@@ -1974,19 +1979,30 @@
|
||||
</div>
|
||||
<div class="admin-model-form">
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epLocalUrl" type="text" placeholder="Paste an endpoint URL, e.g. http://localhost:8000/v1" style="flex:1">
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<button class="admin-btn-sm" id="adm-epDiscoverBtn" title="Scan your network for running model servers">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan for Servers
|
||||
</button>
|
||||
<span style="flex:1"></span>
|
||||
<select id="adm-epLocalType" style="padding:5px;width:80px;">
|
||||
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1">
|
||||
<select id="adm-epLocalType" style="padding:5px;width:72px;flex-shrink:0;">
|
||||
<option value="llm">LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="width:55px;text-align:center;">Test</button>
|
||||
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="width:55px;text-align:center;">Add</button>
|
||||
</div>
|
||||
<div class="adm-quickstart-section collapsed" id="adm-add-local-quickstart">
|
||||
<div class="adm-quickstart-toggle" role="button" tabindex="0" aria-expanded="false">
|
||||
<span>Quickstart</span>
|
||||
<svg class="adm-section-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<div class="adm-quickstart-body">
|
||||
<button class="admin-btn-sm" id="adm-epDiscoverBtn" title="Scan your network for running model servers">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan for Servers
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epOllamaBtn" title="Fill the default Ollama endpoint">Ollama</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="adm-epLocalMsg" class="adm-ep-inline-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2001,9 +2017,10 @@
|
||||
<!-- Custom picker (with logos). Hidden native <select> mirrors
|
||||
its value so the existing JS that reads adm-epProvider
|
||||
keeps working unchanged. -->
|
||||
<div class="adm-provider-picker" id="adm-provider-picker">
|
||||
<button type="button" class="adm-provider-btn" id="adm-provider-btn">
|
||||
<span class="adm-provider-current"><span class="adm-provider-logo"></span><span class="adm-provider-name">Custom URL</span></span>
|
||||
<div class="adm-provider-picker adm-provider-combo" id="adm-provider-picker">
|
||||
<input id="adm-epUrl" type="text" placeholder="Base URL or pick provider" autocomplete="off">
|
||||
<button type="button" class="adm-provider-btn" id="adm-provider-btn" title="Pick provider">
|
||||
<span class="adm-provider-current"><span class="adm-provider-logo"></span><span class="adm-provider-name">Provider</span></span>
|
||||
<svg class="adm-provider-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="adm-provider-menu hidden" id="adm-provider-menu"></div>
|
||||
@@ -2011,7 +2028,7 @@
|
||||
<select id="adm-epProvider" style="display:none">
|
||||
<option value="">Custom URL</option>
|
||||
<option value="https://api.anthropic.com" data-logo="anthropic">Anthropic</option>
|
||||
<option value="https://api.deepseek.com/v1" data-logo="deepseek">DeepSeek</option>
|
||||
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
|
||||
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
|
||||
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
|
||||
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
|
||||
@@ -2022,21 +2039,19 @@
|
||||
<option value="https://api.x.ai/v1" data-logo="grok">xAI Grok</option>
|
||||
<option value="https://api.z.ai/api/paas/v4" data-logo="zhipu">Z.AI (Zhipu)</option>
|
||||
</select>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epUrl" type="text" placeholder="Base URL (e.g. https://api.example.com/v1)" style="flex:1">
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key">
|
||||
<select id="adm-epType" style="padding:5px;width:80px;">
|
||||
<option value="llm">LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="width:55px;text-align:center;">Test</button>
|
||||
<button class="admin-btn-sm hidden" id="adm-epApiCancelTestBtn" style="width:62px;text-align:center;">Cancel</button>
|
||||
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
|
||||
</div>
|
||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="adm-epMsg"></div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>Added Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span></h2>
|
||||
|
||||
@@ -280,6 +280,51 @@ function _isLocalEndpoint(url) {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function _refreshAfterEndpointChange(deletedEndpointId) {
|
||||
try {
|
||||
const sm = window.sessionModule;
|
||||
const pending = sm && sm.getPendingChat ? sm.getPendingChat() : null;
|
||||
if (deletedEndpointId && pending && String(pending.endpointId || '') === String(deletedEndpointId)) {
|
||||
if (sm.setPendingChat) sm.setPendingChat(null);
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
await window.modelsModule.refreshModels(true);
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', {
|
||||
detail: { deletedEndpointId: deletedEndpointId || null }
|
||||
}));
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||
window.sessionModule.updateModelPicker();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function _selectAddedModelInChat(endpoint) {
|
||||
const modelId = endpoint && Array.isArray(endpoint.models) ? endpoint.models[0] : '';
|
||||
if (!modelId) return;
|
||||
try {
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
await window.modelsModule.refreshModels(true);
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('odysseus:auto-select-model', {
|
||||
detail: {
|
||||
endpointId: endpoint.id || '',
|
||||
endpointName: endpoint.name || '',
|
||||
modelId,
|
||||
url: endpoint.base_url || '',
|
||||
}
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadEndpoints() {
|
||||
const listLocal = el('adm-epList-local');
|
||||
const listApi = el('adm-epList-api');
|
||||
@@ -306,7 +351,7 @@ async function loadEndpoints() {
|
||||
try { data = await res.json(); } catch { data = []; }
|
||||
}
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
const empty = '<div class="admin-empty">No endpoints configured</div>';
|
||||
const empty = '<div class="admin-empty">None</div>';
|
||||
if (listLocal) listLocal.innerHTML = empty;
|
||||
if (listApi) listApi.innerHTML = '<div class="admin-empty">None</div>';
|
||||
if (listLegacy) listLegacy.innerHTML = empty;
|
||||
@@ -319,9 +364,11 @@ async function loadEndpoints() {
|
||||
// empty, but we still need to render the expand panel so the user can
|
||||
// un-hide them. Gate on the total instead.
|
||||
const hasModels = ep.online && totalCount > 0;
|
||||
const statusBadge = ep.online
|
||||
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
|
||||
: '<span class="admin-badge admin-badge-off">offline</span>';
|
||||
const statusBadge = ep.status === 'empty'
|
||||
? '<span class="admin-badge">no models</span>'
|
||||
: ep.online
|
||||
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
|
||||
: '<span class="admin-badge admin-badge-off">offline</span>';
|
||||
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
|
||||
return `
|
||||
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
||||
@@ -417,7 +464,10 @@ async function loadEndpoints() {
|
||||
// Optimistic: remove from UI immediately
|
||||
const row = btn.closest('[data-adm-ep-id]');
|
||||
if (row) row.remove();
|
||||
fetch('/api/model-endpoints/' + epId, { method: 'DELETE' }).then(() => loadEndpoints()).catch(() => loadEndpoints());
|
||||
fetch('/api/model-endpoints/' + epId, { method: 'DELETE' })
|
||||
.then(() => _refreshAfterEndpointChange(epId))
|
||||
.then(() => loadEndpoints())
|
||||
.catch(() => loadEndpoints());
|
||||
});
|
||||
});
|
||||
// Clear the just-added marker now that the row has been rendered
|
||||
@@ -571,6 +621,7 @@ function initEndpointForm() {
|
||||
if (picker && pickerBtn && pickerMenu && pickerCurrent) {
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
if (provider.value && !urlInput.value) urlInput.value = provider.value;
|
||||
pickerBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
pickerMenu.classList.toggle('hidden');
|
||||
@@ -593,6 +644,13 @@ function initEndpointForm() {
|
||||
if (provider.value) urlInput.value = provider.value;
|
||||
else urlInput.value = '';
|
||||
});
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
provider.value = '';
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
}
|
||||
});
|
||||
function _normalizeBaseUrl(raw) {
|
||||
let u = raw.trim();
|
||||
// Fix common protocol typos
|
||||
@@ -623,15 +681,96 @@ function initEndpointForm() {
|
||||
return u;
|
||||
}
|
||||
|
||||
async function _defaultOllamaUrl() {
|
||||
try {
|
||||
const res = await fetch('/api/runtime', { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.ollama_base_url) return data.ollama_base_url;
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'http://127.0.0.1:11434/v1';
|
||||
}
|
||||
|
||||
function _renderEndpointTestResult(msg, res, d) {
|
||||
if (res.ok && d.status === 'empty') {
|
||||
msg.textContent = 'Online — no models found';
|
||||
msg.className = 'admin-success';
|
||||
return;
|
||||
}
|
||||
if (res.ok && d.online) {
|
||||
const models = d.models || [];
|
||||
const preview = models.slice(0, 3).map(m => esc(String(m).split('/').pop())).join(', ');
|
||||
msg.innerHTML = `Online — found ${models.length} model${models.length !== 1 ? 's' : ''}${preview ? `: ${preview}${models.length > 3 ? ', …' : ''}` : ''}`;
|
||||
msg.className = 'admin-success';
|
||||
return;
|
||||
}
|
||||
msg.textContent = (d && d.detail) || (d && d.ping_error ? `Offline — ${d.ping_error}` : 'Offline');
|
||||
msg.className = 'admin-error';
|
||||
}
|
||||
|
||||
function _endpointMsg(kind) {
|
||||
return el(kind === 'local' ? 'adm-epLocalMsg' : 'adm-epApiMsg') || el('adm-epMsg');
|
||||
}
|
||||
|
||||
let apiTestController = null;
|
||||
const apiTestBtn = el('adm-epApiTestBtn');
|
||||
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
|
||||
if (apiTestBtn) {
|
||||
apiTestBtn.addEventListener('click', async () => {
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
const apiKey = el('adm-epApiKey').value.trim();
|
||||
if (!rawUrl) { msg.textContent = 'Select a provider or enter a base URL'; msg.className = 'admin-error'; return; }
|
||||
if (provider.value && !apiKey) { msg.textContent = 'API key is required for cloud providers'; msg.className = 'admin-error'; return; }
|
||||
const url = provider.value && rawUrl === provider.value ? rawUrl : _normalizeBaseUrl(rawUrl);
|
||||
apiTestController = new AbortController();
|
||||
apiTestBtn.disabled = true;
|
||||
apiTestBtn.textContent = 'Testing...';
|
||||
if (apiCancelTestBtn) apiCancelTestBtn.classList.remove('hidden');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
const res = await fetch('/api/model-endpoints/test', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
signal: apiTestController.signal,
|
||||
});
|
||||
const d = await res.json();
|
||||
_renderEndpointTestResult(msg, res, d);
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') {
|
||||
msg.textContent = 'Test canceled';
|
||||
msg.className = '';
|
||||
} else {
|
||||
msg.textContent = 'Test failed: ' + (e && e.message ? e.message : 'request failed');
|
||||
msg.className = 'admin-error';
|
||||
}
|
||||
}
|
||||
apiTestController = null;
|
||||
apiTestBtn.disabled = false;
|
||||
apiTestBtn.textContent = 'Test';
|
||||
if (apiCancelTestBtn) apiCancelTestBtn.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
if (apiCancelTestBtn) {
|
||||
apiCancelTestBtn.addEventListener('click', () => {
|
||||
if (apiTestController) apiTestController.abort();
|
||||
});
|
||||
}
|
||||
|
||||
el('adm-epAddBtn').addEventListener('click', async () => {
|
||||
const msg = el('adm-epMsg');
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (provider.value || urlInput.value).trim();
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
const apiKey = el('adm-epApiKey').value.trim();
|
||||
if (!rawUrl) { msg.textContent = 'Select a provider or enter a base URL'; msg.className = 'admin-error'; return; }
|
||||
if (provider.value && !apiKey) { msg.textContent = 'API key is required for cloud providers'; msg.className = 'admin-error'; return; }
|
||||
// Normalize URL (fix typos, add /v1, strip wrong paths)
|
||||
const url = provider.value ? rawUrl : _normalizeBaseUrl(rawUrl);
|
||||
const url = provider.value && rawUrl === provider.value ? rawUrl : _normalizeBaseUrl(rawUrl);
|
||||
const btn = el('adm-epAddBtn');
|
||||
btn.disabled = true; btn.textContent = 'Adding...';
|
||||
try {
|
||||
@@ -640,7 +779,7 @@ function initEndpointForm() {
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
const epType = el('adm-epType');
|
||||
if (epType) fd.append('model_type', epType.value);
|
||||
fd.append('skip_probe', 'true');
|
||||
fd.append('skip_probe', 'false');
|
||||
const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await res.json();
|
||||
if (res.ok) {
|
||||
@@ -649,45 +788,17 @@ function initEndpointForm() {
|
||||
el('adm-epApiKey').value = ''; provider.value = '';
|
||||
if (epType) epType.value = 'llm';
|
||||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||||
loadEndpoints();
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d);
|
||||
if (!d.online) {
|
||||
msg.textContent = 'Added (endpoint offline — will retry on next load)';
|
||||
msg.className = 'admin-error';
|
||||
} else {
|
||||
msg.innerHTML = `Added — found ${count} model${count !== 1 ? 's' : ''}. `
|
||||
+ `<a href="#" id="adm-probe-now" style="text-decoration:underline;cursor:pointer;">Probe models?</a>`;
|
||||
} else if (d.status === 'empty') {
|
||||
msg.textContent = 'Added — endpoint reachable, no models found';
|
||||
msg.className = 'admin-success';
|
||||
} else {
|
||||
msg.textContent = `Added — found ${count} model${count !== 1 ? 's' : ''}`;
|
||||
msg.className = 'admin-success';
|
||||
const probeLink = el('adm-probe-now');
|
||||
if (probeLink) {
|
||||
probeLink.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
msg.textContent = 'Probing models...';
|
||||
try {
|
||||
const es = new EventSource(`/api/model-endpoints/${d.id}/probe`);
|
||||
let lines = [];
|
||||
es.onmessage = (ev) => {
|
||||
const r = JSON.parse(ev.data);
|
||||
if (r.type === 'probe_result') {
|
||||
const dot = r.status === 'ok' ? '<span style="color:var(--color-success);">●</span>'
|
||||
: r.status === 'timeout' ? '<span style="color:var(--color-warning);">●</span>'
|
||||
: '<span style="color:var(--color-error);">●</span>';
|
||||
const lat = r.latency_ms ? ` ${r.latency_ms}ms` : '';
|
||||
const err = r.error ? ` — ${esc(r.error)}` : '';
|
||||
lines.push(`${dot} ${esc(r.model.split('/').pop())}${lat}${err}`);
|
||||
msg.innerHTML = `Probing... ${lines.length} checked<div style="font-size:0.78rem;margin-top:4px;">${lines.join('<br>')}</div>`;
|
||||
} else if (r.type === 'probe_done') {
|
||||
es.close();
|
||||
let txt = `Done — ${r.ok}/${r.ok + r.hidden} models responding`;
|
||||
if (r.hidden) txt += ` — ${r.hidden} non-responding hidden`;
|
||||
txt += `<div style="font-size:0.78rem;margin-top:4px;">${lines.join('<br>')}</div>`;
|
||||
msg.innerHTML = txt;
|
||||
loadEndpoints();
|
||||
}
|
||||
};
|
||||
es.onerror = () => { es.close(); msg.textContent += ' (probe connection lost)'; };
|
||||
} catch (e) { msg.textContent = 'Probe failed'; msg.className = 'admin-error'; }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
|
||||
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
|
||||
@@ -696,9 +807,33 @@ function initEndpointForm() {
|
||||
|
||||
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||
const localAddBtn = el('adm-epLocalAddBtn');
|
||||
const localTestBtn = el('adm-epLocalTestBtn');
|
||||
if (localTestBtn) {
|
||||
localTestBtn.addEventListener('click', async () => {
|
||||
const msg = _endpointMsg('local');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||||
if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; }
|
||||
const url = _normalizeBaseUrl(raw);
|
||||
localTestBtn.disabled = true;
|
||||
localTestBtn.textContent = 'Testing...';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
const res = await fetch('/api/model-endpoints/test', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await res.json();
|
||||
_renderEndpointTestResult(msg, res, d);
|
||||
} catch (e) {
|
||||
msg.textContent = 'Test failed: ' + (e && e.message ? e.message : 'request failed');
|
||||
msg.className = 'admin-error';
|
||||
}
|
||||
localTestBtn.disabled = false;
|
||||
localTestBtn.textContent = 'Test';
|
||||
});
|
||||
}
|
||||
if (localAddBtn) {
|
||||
localAddBtn.addEventListener('click', async () => {
|
||||
const msg = el('adm-epMsg');
|
||||
const msg = _endpointMsg('local');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||||
if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; }
|
||||
@@ -709,16 +844,19 @@ function initEndpointForm() {
|
||||
fd.append('base_url', url);
|
||||
const lt = el('adm-epLocalType');
|
||||
if (lt) fd.append('model_type', lt.value);
|
||||
fd.append('skip_probe', 'true');
|
||||
fd.append('skip_probe', 'false');
|
||||
const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await res.json();
|
||||
if (res.ok) {
|
||||
el('adm-epLocalUrl').value = '';
|
||||
if (lt) lt.value = 'llm';
|
||||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||||
loadEndpoints();
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d);
|
||||
const count = (d.models || []).length;
|
||||
msg.textContent = d.online
|
||||
msg.textContent = d.status === 'empty'
|
||||
? 'Added — Ollama is running, no models pulled yet'
|
||||
: d.online
|
||||
? `Added — found ${count} model${count !== 1 ? 's' : ''}`
|
||||
: 'Added (offline — will retry on next load)';
|
||||
msg.className = d.online ? 'admin-success' : 'admin-error';
|
||||
@@ -728,11 +866,27 @@ function initEndpointForm() {
|
||||
});
|
||||
}
|
||||
|
||||
const ollamaBtn = el('adm-epOllamaBtn');
|
||||
if (ollamaBtn) {
|
||||
ollamaBtn.addEventListener('click', async () => {
|
||||
const input = el('adm-epLocalUrl');
|
||||
if (input) {
|
||||
input.value = await _defaultOllamaUrl();
|
||||
input.focus();
|
||||
}
|
||||
const msg = _endpointMsg('local');
|
||||
if (msg) {
|
||||
msg.innerHTML = '<span style="font-size:11px;opacity:0.55;">Ollama ready to test.</span>';
|
||||
msg.className = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Discover local models button
|
||||
const discoverBtn = el('adm-epDiscoverBtn');
|
||||
if (discoverBtn) {
|
||||
discoverBtn.addEventListener('click', async () => {
|
||||
const msg = el('adm-epMsg');
|
||||
const msg = _endpointMsg('local');
|
||||
discoverBtn.disabled = true;
|
||||
// Keep the button's icon as-is while scanning; the whirlpool +
|
||||
// status text below is enough feedback. (Two spinning indicators
|
||||
@@ -747,7 +901,7 @@ function initEndpointForm() {
|
||||
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
|
||||
wrap.appendChild(wp.element);
|
||||
const txt = document.createElement('span');
|
||||
txt.textContent = 'Scanning ports 8000-8020 for model servers...';
|
||||
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
|
||||
txt.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||
wrap.appendChild(txt);
|
||||
msg.appendChild(wrap);
|
||||
@@ -758,7 +912,7 @@ function initEndpointForm() {
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
msg.textContent = 'No model servers found. Make sure vLLM, Ollama, or similar is running.';
|
||||
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need OLLAMA_HOST=0.0.0.0:11434.';
|
||||
msg.className = 'admin-error';
|
||||
} else {
|
||||
// Auto-add each discovered endpoint
|
||||
@@ -767,7 +921,7 @@ function initEndpointForm() {
|
||||
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', base);
|
||||
fd.append('skip_probe', 'true');
|
||||
fd.append('skip_probe', 'false');
|
||||
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
|
||||
if (r.ok) {
|
||||
added++;
|
||||
@@ -813,6 +967,27 @@ function initEndpointForm() {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.adm-quickstart-section').forEach((sec) => {
|
||||
const head = sec.querySelector('.adm-quickstart-toggle');
|
||||
if (!head) return;
|
||||
const key = 'odysseus.addModels.' + sec.id + '.open';
|
||||
let open = false;
|
||||
try { open = localStorage.getItem(key) === '1'; } catch {}
|
||||
const apply = () => {
|
||||
sec.classList.toggle('collapsed', !open);
|
||||
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
apply();
|
||||
const toggle = () => {
|
||||
open = !open;
|
||||
try { localStorage.setItem(key, open ? '1' : '0'); } catch {}
|
||||
apply();
|
||||
};
|
||||
head.addEventListener('click', toggle);
|
||||
head.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
|
||||
@@ -452,9 +452,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
} else {
|
||||
addMessage('assistant',
|
||||
'No chat session active. You can:\n\n' +
|
||||
'- Pick a model from the sidebar to start a chat\n' +
|
||||
'- Run `/setup` to configure an endpoint\n' +
|
||||
'- Run `/new` to create a session manually\n' +
|
||||
'- Open the model picker in the chat box and pick a model\n' +
|
||||
'- Use the `+` button in the model picker to add a model endpoint\n' +
|
||||
'- Use `/help` to see all available commands');
|
||||
_releaseSendFlag();
|
||||
return;
|
||||
@@ -462,9 +461,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
} catch (e) {
|
||||
addMessage('assistant',
|
||||
'No chat session active. You can:\n\n' +
|
||||
'- Pick a model from the sidebar to start a chat\n' +
|
||||
'- Run `/setup` to configure an endpoint\n' +
|
||||
'- Run `/new` to create a session manually\n' +
|
||||
'- Open the model picker in the chat box and pick a model\n' +
|
||||
'- Use the `+` button in the model picker to add a model endpoint\n' +
|
||||
'- Use `/help` to see all available commands');
|
||||
_releaseSendFlag();
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { providerLogo } from './providers.js';
|
||||
import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
@@ -31,6 +32,20 @@ function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
|
||||
|
||||
// Dependencies injected via initModelPicker()
|
||||
let _deps = null;
|
||||
let _autoSelectingDefault = false;
|
||||
|
||||
function _modelExists(modelId, url) {
|
||||
if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false;
|
||||
const items = window.modelsModule.getCachedItems() || [];
|
||||
if (!items.length) return true;
|
||||
const targetUrl = (url || '').replace(/\/+$/, '');
|
||||
return items.some(item => {
|
||||
if (item.offline) return false;
|
||||
const itemUrl = (item.url || '').replace(/\/+$/, '');
|
||||
const models = (item.models || []).concat(item.models_extra || []);
|
||||
return models.includes(modelId) && (!targetUrl || itemUrl === targetUrl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the model picker dropdown.
|
||||
@@ -52,6 +67,7 @@ function _initModelPickerDropdown() {
|
||||
const menu = document.getElementById('model-picker-menu');
|
||||
const search = document.getElementById('model-picker-search');
|
||||
const listEl = document.getElementById('model-picker-list');
|
||||
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
|
||||
if (!wrap || !btn || !menu || !search || !listEl) return;
|
||||
|
||||
function _close() {
|
||||
@@ -76,6 +92,27 @@ function _initModelPickerDropdown() {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function _openPickerShortcut(kind) {
|
||||
_close();
|
||||
try {
|
||||
if (kind === 'cookbook') {
|
||||
if (window.cookbookModule && typeof window.cookbookModule.open === 'function') {
|
||||
window.cookbookModule.open();
|
||||
} else {
|
||||
const btn = document.getElementById('tool-cookbook-btn') || document.getElementById('rail-cookbook');
|
||||
if (btn) btn.click();
|
||||
else location.hash = '#cookbook';
|
||||
}
|
||||
} else if (kind === 'settings') {
|
||||
if (settingsModule && typeof settingsModule.open === 'function') settingsModule.open();
|
||||
} else if (window.adminModule && typeof window.adminModule.open === 'function') {
|
||||
window.adminModule.open('services');
|
||||
} else if (settingsModule && typeof settingsModule.open === 'function') {
|
||||
settingsModule.open('services');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Local endpoint health — only probed for LOCAL endpoints, since
|
||||
// cloud APIs are essentially always up. Cached briefly on the
|
||||
// server side too (8s TTL). Picker opens trigger a refresh.
|
||||
@@ -126,6 +163,15 @@ function _initModelPickerDropdown() {
|
||||
listEl.innerHTML = '';
|
||||
const all = _getAllModels();
|
||||
const q = (filter || '').toLowerCase();
|
||||
const hasAnyModel = all.length > 0;
|
||||
listEl.classList.toggle('is-empty', !hasAnyModel);
|
||||
menu.classList.toggle('no-models', !hasAnyModel);
|
||||
if (search) {
|
||||
search.placeholder = hasAnyModel ? 'Search models…' : 'No models connected';
|
||||
}
|
||||
if (searchRow) {
|
||||
searchRow.classList.toggle('searching', !!filter);
|
||||
}
|
||||
|
||||
// Load favorites
|
||||
const favs = (function() { try { return JSON.parse(localStorage.getItem('odysseus-model-favorites') || '[]'); } catch { return []; } })();
|
||||
@@ -192,7 +238,11 @@ function _initModelPickerDropdown() {
|
||||
if (listEl.children.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'model-switch-empty';
|
||||
empty.textContent = 'No models available';
|
||||
if (hasAnyModel) {
|
||||
empty.textContent = 'No matching models';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
listEl.appendChild(empty);
|
||||
}
|
||||
}
|
||||
@@ -249,12 +299,62 @@ function _initModelPickerDropdown() {
|
||||
uiModule.showToast(`Using ${m.display}`);
|
||||
}
|
||||
|
||||
document.addEventListener('odysseus:auto-select-model', async (e) => {
|
||||
const detail = (e && e.detail) || {};
|
||||
const currentSessionId = _deps.getCurrentSessionId();
|
||||
const sessions = _deps.getSessions();
|
||||
const current = sessions.find(x => x.id === currentSessionId);
|
||||
const pending = _deps.getPendingChat();
|
||||
if ((current && current.model) || (pending && pending.modelId)) return;
|
||||
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
try { await window.modelsModule.refreshModels(true); } catch (_) {}
|
||||
}
|
||||
const items = window.modelsModule && window.modelsModule.getCachedItems ? window.modelsModule.getCachedItems() : [];
|
||||
const targetEndpointId = detail.endpointId ? String(detail.endpointId) : '';
|
||||
const targetModel = detail.modelId || '';
|
||||
let match = null;
|
||||
for (const item of items) {
|
||||
if (item.offline) continue;
|
||||
if (targetEndpointId && String(item.endpoint_id || '') !== targetEndpointId) continue;
|
||||
const models = (item.models || []).concat(item.models_extra || []);
|
||||
const displays = (item.models_display || []).concat(item.models_extra_display || []);
|
||||
const idx = targetModel ? models.indexOf(targetModel) : (models.length ? 0 : -1);
|
||||
if (idx >= 0) {
|
||||
match = {
|
||||
mid: models[idx],
|
||||
display: (displays[idx] || models[idx]).split('/').pop(),
|
||||
url: item.url || detail.url || '',
|
||||
endpointId: item.endpoint_id || detail.endpointId || '',
|
||||
epName: item.endpoint_name || detail.endpointName || '',
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match && detail.modelId && detail.url) {
|
||||
match = {
|
||||
mid: detail.modelId,
|
||||
display: String(detail.modelId).split('/').pop(),
|
||||
url: detail.url,
|
||||
endpointId: detail.endpointId || '',
|
||||
epName: detail.endpointName || '',
|
||||
};
|
||||
}
|
||||
if (match) await _pick(match);
|
||||
});
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.classList.contains('hidden') || menu.classList.contains('closing')) {
|
||||
// Force-clear any in-progress close animation
|
||||
menu.classList.remove('closing', 'hidden');
|
||||
_populate('');
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(true).then(() => {
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
}).catch(() => {});
|
||||
}
|
||||
// Kick off a local-endpoint probe — when it returns, re-render
|
||||
// the list so stale local servers get dimmed. Cloud entries
|
||||
// aren't probed; they stay visible.
|
||||
@@ -275,6 +375,13 @@ function _initModelPickerDropdown() {
|
||||
search.addEventListener('keydown', (e) => {
|
||||
_handlePickerKeydown(e, listEl, '.model-switch-item', _close);
|
||||
});
|
||||
const addModelsBtn = document.getElementById('model-picker-add-models-btn');
|
||||
if (addModelsBtn) {
|
||||
addModelsBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_openPickerShortcut('models');
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
|
||||
_close();
|
||||
@@ -310,8 +417,15 @@ export function updateModelPicker() {
|
||||
let modelId = null;
|
||||
if (s && s.model) {
|
||||
modelId = s.model;
|
||||
if (!_modelExists(modelId, s.endpoint_url || '')) {
|
||||
modelId = null;
|
||||
}
|
||||
} else if (_pendingChat && _pendingChat.modelId) {
|
||||
modelId = _pendingChat.modelId;
|
||||
if (!_modelExists(modelId, _pendingChat.url || '')) {
|
||||
_deps.setPendingChat(null);
|
||||
modelId = null;
|
||||
}
|
||||
}
|
||||
// SECURITY: deliberately NOT auto-injecting `odysseus-model-favorites[0]`
|
||||
// here. localStorage favorites are per-browser, not per-user, so on a
|
||||
@@ -338,6 +452,27 @@ export function updateModelPicker() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!modelId && !_autoSelectingDefault && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
const items = window.modelsModule.getCachedItems();
|
||||
const first = items.find(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
|
||||
if (first) {
|
||||
const models = (first.models || []).concat(first.models_extra || []);
|
||||
modelId = models[0];
|
||||
if (!currentSessionId) {
|
||||
_deps.setPendingChat({ url: first.url, modelId, endpointId: first.endpoint_id });
|
||||
} else {
|
||||
if (s) { s.model = modelId; s.endpoint_url = first.url; }
|
||||
_autoSelectingDefault = true;
|
||||
const fd = new FormData();
|
||||
fd.append('model', modelId);
|
||||
fd.append('endpoint_url', first.url || '');
|
||||
if (first.endpoint_id) fd.append('endpoint_id', first.endpoint_id);
|
||||
fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd })
|
||||
.catch(() => {})
|
||||
.finally(() => { _autoSelectingDefault = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = modelId ? modelId.split('/').pop() : 'Select model';
|
||||
const logo = modelId ? providerLogo(modelId) : null;
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
// All SVGs use viewBox="0 0 24 24" fill="currentColor"
|
||||
|
||||
const _PROVIDERS = [
|
||||
// Ollama
|
||||
[/ollama|:11434/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5c-3.1 0-5.65 2.43-5.86 5.48A6.62 6.62 0 0 0 3 13.62C3 18 6.8 21.5 12 21.5s9-3.5 9-7.88a6.62 6.62 0 0 0-3.14-5.64C17.65 4.93 15.1 2.5 12 2.5Zm-2.7 8.25a1.15 1.15 0 1 1 0 2.3 1.15 1.15 0 0 1 0-2.3Zm5.4 0a1.15 1.15 0 1 1 0 2.3 1.15 1.15 0 0 1 0-2.3Zm-5.15 5.15c.75.7 1.55 1.04 2.45 1.04s1.7-.34 2.45-1.04c.26-.24.66-.23.9.03.24.26.23.66-.03.9-.98.91-2.08 1.37-3.32 1.37s-2.34-.46-3.32-1.37a.64.64 0 0 1-.03-.9.64.64 0 0 1 .9-.03Z"/></svg>'],
|
||||
|
||||
// OpenAI — GPT, o1, o3, dall-e, chatgpt
|
||||
[/openai|gpt-|^o[13]-|chatgpt|dall-e/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>'],
|
||||
|
||||
@@ -3117,13 +3117,14 @@ async function initUnifiedIntegrations() {
|
||||
<div class="settings-row"><label class="settings-label">Preset</label><select id="uf-api-preset" class="settings-select"><option value="">Custom (no preset)</option>${selectOpts}</select></div>
|
||||
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-api-name" class="settings-input" placeholder="My Service"></div>
|
||||
<div class="settings-row"><label class="settings-label">Base URL</label><input id="uf-api-url" class="settings-input" placeholder="http://localhost:8080"></div>
|
||||
<div id="uf-api-ntfy-hint" style="display:none;font-size:11px;line-height:1.35;opacity:0.68;margin:-2px 0 2px 106px;"></div>
|
||||
<div class="settings-row"><label class="settings-label">Auth${_apiHint('How this service expects the credential to be sent. <b>Bearer</b> = sends "Authorization: Bearer YOUR_KEY" (most modern APIs, ntfy, OpenAI-style). <b>Header</b> = sends YOUR_KEY verbatim under a header name you choose (Miniflux uses X-Auth-Token). <b>Basic</b> = HTTP basic auth (user:pass). <b>None</b> = the API is open / no auth.')}</label><select id="uf-api-auth" class="settings-input"><option value="bearer">Bearer (most common)</option><option value="header">Header</option><option value="basic">Basic</option><option value="none">None</option></select></div>
|
||||
<div class="settings-row" id="uf-api-header-row"><label class="settings-label">Header${_apiHint('The HTTP header name the key goes under (Miniflux: X-Auth-Token; most others: Authorization). Only used when Auth = Header.')}</label><input id="uf-api-header" class="settings-input" placeholder="X-Auth-Token"></div>
|
||||
<div class="settings-row"><label class="settings-label">API Key${_apiHint('The secret token the service issued you (generated in its admin panel / settings). Used to prove your identity on each request. Required for any Auth mode except None.')}</label><input id="uf-api-key" class="settings-input" type="password" placeholder="Token/key"></div>
|
||||
<div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-api-save">Save</button><button class="admin-btn-sm" id="uf-api-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-api-cancel" style="opacity:0.7">Cancel</button><span id="uf-api-msg" style="font-size:11px"></span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key');
|
||||
const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key'), ntfyHint = el('uf-api-ntfy-hint');
|
||||
let _editId = editId && editId !== 'new' ? editId : null;
|
||||
// Load existing
|
||||
if (_editId) {
|
||||
@@ -3138,12 +3139,23 @@ async function initUnifiedIntegrations() {
|
||||
// no typed-name → key lookup is needed (datalist-era leftover).
|
||||
const _applyPreset = () => {
|
||||
const p = presets[preset.value];
|
||||
const isNtfy = preset.value === 'ntfy' || (p && (p.name || '').toLowerCase() === 'ntfy');
|
||||
if (ntfyHint) {
|
||||
ntfyHint.style.display = isNtfy ? 'block' : 'none';
|
||||
if (isNtfy) {
|
||||
ntfyHint.innerHTML = 'Enter the ntfy server URL Odysseus can reach. Examples: <code>http://127.0.0.1:8091</code>, <code>http://100.x.y.z:8091</code>, or <code>https://ntfy.example.com</code>.';
|
||||
}
|
||||
}
|
||||
if (url) {
|
||||
url.placeholder = isNtfy ? 'http://127.0.0.1:8091' : 'http://localhost:8080';
|
||||
}
|
||||
if (!p) return;
|
||||
name.value = p.name || '';
|
||||
auth.value = p.auth_type || 'none';
|
||||
header.value = p.auth_header || '';
|
||||
};
|
||||
preset.addEventListener('change', _applyPreset);
|
||||
_applyPreset();
|
||||
el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
el('uf-api-save').addEventListener('click', async () => {
|
||||
const presetKey = preset.value || undefined;
|
||||
@@ -3176,7 +3188,7 @@ async function initUnifiedIntegrations() {
|
||||
el('uf-api-msg').textContent = d.message || 'Connected';
|
||||
el('uf-api-msg').style.color = 'var(--green,#50fa7b)';
|
||||
} else {
|
||||
el('uf-api-msg').textContent = (d.message || d.error || d.detail || `HTTP ${r.status}`).slice(0, 200);
|
||||
el('uf-api-msg').textContent = (d.message || d.error || d.detail || `HTTP ${r.status}`).slice(0, 360);
|
||||
el('uf-api-msg').style.color = 'var(--red)';
|
||||
}
|
||||
} catch (e) { el('uf-api-msg').textContent = 'Error: ' + e.message; el('uf-api-msg').style.color = 'var(--red)'; }
|
||||
|
||||
@@ -823,7 +823,7 @@ async function _cmdSessionNew(args, ctx) {
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
if (!endpointUrl || !model) {
|
||||
slashReply('No model available — pick one from the sidebar or run <code>/setup</code> to configure an endpoint');
|
||||
slashReply('No model available — open the model picker and use the <code>+</code> button to add a model endpoint.');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
131
static/style.css
131
static/style.css
@@ -2578,6 +2578,26 @@ body.bg-pattern-sparkles {
|
||||
animation: picker-roll-down 0.15s ease-in forwards;
|
||||
}
|
||||
.model-picker-menu.hidden { display: none; }
|
||||
.model-picker-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 30px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
transition: grid-template-columns 0.18s ease, gap 0.18s ease;
|
||||
}
|
||||
.model-picker-menu.no-models .model-picker-search-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.model-picker-search-row.searching {
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
gap: 0;
|
||||
}
|
||||
.model-picker-search-row.searching .model-picker-action-btn {
|
||||
opacity: 0;
|
||||
transform: translateX(10px) scale(0.88);
|
||||
pointer-events: none;
|
||||
}
|
||||
.model-picker-menu input[type="text"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -2589,8 +2609,8 @@ body.bg-pattern-sparkles {
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
margin-bottom: 4px;
|
||||
transition: border-color 0.15s;
|
||||
min-width: 0;
|
||||
transition: border-color 0.15s, padding 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.model-picker-menu input[type="text"]:focus {
|
||||
border-color: var(--red);
|
||||
@@ -2598,10 +2618,51 @@ body.bg-pattern-sparkles {
|
||||
.model-picker-menu input[type="text"]::placeholder {
|
||||
color: color-mix(in srgb, var(--fg) 30%, transparent);
|
||||
}
|
||||
.model-picker-action-btn {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
color: color-mix(in srgb, var(--fg) 66%, transparent);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transform: translateX(0) scale(1);
|
||||
transition: opacity 0.16s ease, transform 0.18s ease, border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.model-picker-action-btn:hover,
|
||||
.model-picker-action-btn:focus-visible {
|
||||
border-color: var(--red);
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--red) 10%, var(--panel));
|
||||
outline: none;
|
||||
}
|
||||
.model-picker-action-btn.primary {
|
||||
color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 8%, transparent);
|
||||
}
|
||||
.model-picker-action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
.model-picker-action-btn:hover svg,
|
||||
.model-picker-action-btn:focus-visible svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.model-picker-list {
|
||||
max-height: min(280px, 50dvh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.model-picker-list.is-empty {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.model-picker-list .model-switch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2615,6 +2676,14 @@ body.bg-pattern-sparkles {
|
||||
.model-picker-list .model-switch-item:hover {
|
||||
background: color-mix(in srgb, var(--red) 8%, transparent);
|
||||
}
|
||||
.model-picker-list .model-switch-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 8px;
|
||||
color: color-mix(in srgb, var(--fg) 50%, transparent);
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.model-picker-list .mp-section-label {
|
||||
font-size: 0.72em;
|
||||
text-transform: uppercase;
|
||||
@@ -13102,9 +13171,54 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
/* Collapsed: hide the form body and point the caret right. */
|
||||
.adm-add-section.collapsed .admin-model-form { display: none; }
|
||||
.adm-add-section.collapsed .adm-section-caret { transform: rotate(-90deg); }
|
||||
.adm-quickstart-section {
|
||||
margin-top: 7px;
|
||||
}
|
||||
.adm-quickstart-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
opacity: 0.72;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: color-mix(in srgb, var(--fg) 3%, transparent);
|
||||
}
|
||||
.adm-quickstart-toggle:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--red);
|
||||
}
|
||||
.adm-quickstart-section:not(.collapsed) .adm-quickstart-toggle {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.adm-quickstart-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 6px 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.adm-quickstart-section.collapsed .adm-quickstart-body { display: none; }
|
||||
.adm-quickstart-section.collapsed .adm-section-caret { transform: rotate(-90deg); }
|
||||
|
||||
/* Custom provider picker (logo + name) replacing the native <select> */
|
||||
.adm-provider-picker { position: relative; margin-bottom: 6px; }
|
||||
.adm-provider-combo {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.adm-provider-combo input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.adm-provider-btn {
|
||||
width: 100%;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
@@ -13113,6 +13227,14 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
padding: 5px 8px; font-family: inherit; font-size: 12px;
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.adm-provider-combo .adm-provider-btn {
|
||||
width: 128px;
|
||||
flex-shrink: 0;
|
||||
border-left: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.adm-provider-btn:hover { border-color: color-mix(in srgb, var(--fg) 30%, var(--border)); }
|
||||
.adm-provider-current { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.adm-provider-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -13343,6 +13465,11 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
gap: 6px;
|
||||
}
|
||||
.admin-model-form-row input { flex: 1; }
|
||||
.adm-ep-inline-msg {
|
||||
min-height: 16px;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Endpoint items */
|
||||
.admin-ep-item {
|
||||
|
||||
Reference in New Issue
Block a user