Adding model edit
This commit is contained in:
@@ -779,11 +779,11 @@ async def list_models(request: Request) -> dict:
|
||||
pool = request.app.state.pool
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, provider, model_name, created_at FROM models ORDER BY id"
|
||||
"SELECT id, provider, model_name, base_url, created_at FROM models ORDER BY id"
|
||||
)
|
||||
return {"models": [
|
||||
{"id": r["id"], "provider": r["provider"], "model_name": r["model_name"],
|
||||
"created_at": r["created_at"].isoformat()}
|
||||
"base_url": r["base_url"] or "", "created_at": r["created_at"].isoformat()}
|
||||
for r in rows
|
||||
]}
|
||||
|
||||
@@ -795,16 +795,19 @@ async def create_model(request: Request) -> dict:
|
||||
provider = data.get("provider", "").strip()
|
||||
model_name = data.get("model_name", "").strip()
|
||||
api_key = data.get("api_key", "").strip()
|
||||
if not provider or not model_name or not api_key:
|
||||
return {"error": "provider, model_name, and api_key are required"}
|
||||
if provider not in ("claude", "openai"):
|
||||
return {"error": "provider must be 'claude' or 'openai'"}
|
||||
base_url = data.get("base_url", "").strip()
|
||||
if not provider or not model_name:
|
||||
return {"error": "provider and model_name are required"}
|
||||
if provider not in ("claude", "openai", "lm-studio"):
|
||||
return {"error": "provider must be 'claude', 'openai', or 'lm-studio'"}
|
||||
if provider == "claude" and not api_key:
|
||||
return {"error": "api_key is required for claude provider"}
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO models (provider, model_name, api_key) VALUES ($1,$2,$3) RETURNING id",
|
||||
provider, model_name, api_key,
|
||||
"INSERT INTO models (provider, model_name, api_key, base_url) VALUES ($1,$2,$3,$4) RETURNING id",
|
||||
provider, model_name, api_key, base_url,
|
||||
)
|
||||
log.info("model created id=%d provider=%s model=%s", row["id"], provider, model_name)
|
||||
log.info("model created id=%d provider=%s model=%s base_url=%s", row["id"], provider, model_name, base_url)
|
||||
return {"status": "ok", "id": row["id"]}
|
||||
|
||||
|
||||
@@ -820,6 +823,28 @@ async def delete_model(model_id: int, request: Request) -> dict:
|
||||
return {"status": "ok", "deleted": model_id}
|
||||
|
||||
|
||||
@app.get("/models/discover")
|
||||
async def discover_models(base_url: str = "http://host.docker.internal:1234") -> dict:
|
||||
"""
|
||||
Proxy a GET to {base_url}/v1/models to discover models available in LM Studio
|
||||
(or any OpenAI-compatible server). Avoids browser CORS restrictions.
|
||||
"""
|
||||
url = base_url.rstrip("/") + "/v1/models"
|
||||
log.info("discover_models url=%s", url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(url)
|
||||
if not r.is_success:
|
||||
return {"error": f"server returned {r.status_code}", "models": []}
|
||||
data = r.json()
|
||||
model_ids = [m.get("id", m) for m in data.get("data", [])]
|
||||
return {"models": model_ids, "raw": data}
|
||||
except httpx.ConnectError:
|
||||
return {"error": f"could not connect to {url} — is LM Studio running?", "models": []}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "models": []}
|
||||
|
||||
|
||||
@app.get("/config")
|
||||
async def get_all_config(request: Request) -> dict:
|
||||
pool = request.app.state.pool
|
||||
@@ -1711,6 +1736,7 @@ ADMIN_HTML = """<!DOCTYPE html>
|
||||
<h1>Festinger</h1>
|
||||
<p class="subtitle">Ollama-compatible inference middleware — loop detection & Recollections world model
|
||||
— <a href="/graph" style="color:#1a1a2e">Knowledge Graph Explorer</a>
|
||||
— <a href="/models-ui" style="color:#1a1a2e">Model Manager</a>
|
||||
</p>
|
||||
|
||||
<h2>World model stats</h2>
|
||||
@@ -2002,6 +2028,438 @@ async def admin() -> str:
|
||||
return ADMIN_HTML.format()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /models-ui — model manager page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MODELS_HTML = r"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Festinger — Model Manager</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: monospace; background: #f5f5f5; color: #222; min-height: 100vh; }
|
||||
|
||||
/* ── Top bar ── */
|
||||
.topbar {
|
||||
background: #1a1a2e; color: #fff;
|
||||
padding: 0 28px; height: 52px;
|
||||
display: flex; align-items: center; gap: 20px;
|
||||
}
|
||||
.topbar-title { font-size: 1.1em; font-weight: bold; letter-spacing: 0.02em; }
|
||||
.topbar-sub { font-size: 0.78em; color: #666; }
|
||||
.topbar a { color: #7070cc; font-size: 0.8em; text-decoration: none; }
|
||||
.topbar a:hover { color: #9999ff; }
|
||||
.topbar-spacer { flex: 1; }
|
||||
|
||||
/* ── Layout ── */
|
||||
.page { max-width: 900px; margin: 0 auto; padding: 28px 20px 60px; }
|
||||
|
||||
/* ── Cards ── */
|
||||
.card {
|
||||
background: #fff; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
margin-bottom: 20px; overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
background: #f8f8f8; border-bottom: 1px solid #e8e8e8;
|
||||
padding: 12px 18px; display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.card-title { font-size: 0.95em; font-weight: bold; }
|
||||
.card-sub { font-size: 0.75em; color: #888; margin-left: auto; }
|
||||
.card-body { padding: 18px; }
|
||||
|
||||
/* ── Active badge ── */
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 9px; border-radius: 10px;
|
||||
font-size: 0.72em; font-weight: bold; letter-spacing: 0.03em;
|
||||
}
|
||||
.badge-resolve { background: #d4edda; color: #155724; }
|
||||
.badge-write { background: #cce5ff; color: #004085; }
|
||||
.badge-both { background: #fff3cd; color: #856404; }
|
||||
|
||||
/* ── Tables ── */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.84em; }
|
||||
th {
|
||||
text-align: left; border-bottom: 2px solid #eee; padding: 6px 10px;
|
||||
font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.06em; color: #888;
|
||||
}
|
||||
td { border-bottom: 1px solid #f2f2f2; padding: 7px 10px; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
|
||||
/* ── Forms ── */
|
||||
.field-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.field label { font-size: 0.78em; color: #666; }
|
||||
input[type="text"], input[type="password"], input[type="url"], select {
|
||||
font-family: monospace; font-size: 0.85em;
|
||||
padding: 7px 10px; border: 1px solid #ccc; border-radius: 4px;
|
||||
background: #fff; color: #222;
|
||||
}
|
||||
input[type="text"]:focus, input[type="password"]:focus,
|
||||
input[type="url"]:focus, select:focus {
|
||||
outline: none; border-color: #7070cc;
|
||||
}
|
||||
input[type="text"].wide { width: 280px; }
|
||||
input[type="password"].wide { width: 280px; }
|
||||
input[type="url"].wide { width: 300px; }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
padding: 7px 16px; border-radius: 4px; border: 1px solid #bbb;
|
||||
cursor: pointer; font-family: monospace; font-size: 0.84em;
|
||||
background: #fff; color: #333; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: #f0f0f0; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-primary { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
|
||||
.btn-primary:hover { background: #2a2a4e; }
|
||||
.btn-success { background: #1e6e3e; color: #fff; border-color: #1e6e3e; }
|
||||
.btn-success:hover { background: #27844b; }
|
||||
.btn-sm { padding: 3px 10px; font-size: 0.78em; }
|
||||
.btn-danger { color: #b00; border-color: #e0b0b0; }
|
||||
.btn-danger:hover { background: #fff0f0; }
|
||||
.btn-active { background: #d4edda; color: #155724; border-color: #b0d8bc; cursor: default; }
|
||||
|
||||
/* ── Divider ── */
|
||||
.divider { height: 1px; background: #eee; margin: 16px 0; }
|
||||
|
||||
/* ── Discovery list ── */
|
||||
#disc-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; min-height: 20px; }
|
||||
.disc-item {
|
||||
background: #eef0ff; border: 1px solid #c8ccee; border-radius: 4px;
|
||||
padding: 5px 12px; font-size: 0.82em; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.disc-item button { padding: 2px 8px; font-size: 0.78em; }
|
||||
|
||||
/* ── Status / toast ── */
|
||||
#toast {
|
||||
position: fixed; bottom: 24px; right: 24px; z-index: 100;
|
||||
background: #1a1a2e; color: #fff; padding: 10px 18px; border-radius: 6px;
|
||||
font-size: 0.85em; opacity: 0; transition: opacity 0.25s;
|
||||
pointer-events: none; max-width: 360px;
|
||||
}
|
||||
#toast.show { opacity: 1; }
|
||||
#toast.err { background: #8b0000; }
|
||||
|
||||
/* ── Separator label ── */
|
||||
.sep { font-size: 0.72em; color: #aaa; text-transform: uppercase; letter-spacing: 0.08em;
|
||||
margin: 18px 0 10px; }
|
||||
|
||||
/* ── Provider pill ── */
|
||||
.pill {
|
||||
display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 0.75em;
|
||||
}
|
||||
.pill-claude { background: #fce8d5; color: #6b3000; }
|
||||
.pill-openai { background: #d5f0e8; color: #00502a; }
|
||||
.pill-lm-studio { background: #e8d5fc; color: #3a006b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<span class="topbar-title">Festinger</span>
|
||||
<span class="topbar-sub">Model Manager</span>
|
||||
<div class="topbar-spacer"></div>
|
||||
<a href="/admin">← admin</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Currently configured models ── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Configured models</span>
|
||||
<span class="card-sub" id="cfg-sub">Loading…</span>
|
||||
</div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>ID</th><th>Provider</th><th>Model</th><th>Endpoint</th>
|
||||
<th>Role</th><th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="models-tbody">
|
||||
<tr><td colspan="6" style="color:#aaa;padding:18px">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── LM Studio ── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">LM Studio</span>
|
||||
<span class="card-sub">OpenAI-compatible local inference</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p style="font-size:0.83em;color:#666;margin-bottom:14px">
|
||||
Start LM Studio, load a model, and enable the local server
|
||||
(default port 1234). Festinger will discover available models
|
||||
and register them for conflict resolution.
|
||||
</p>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>LM Studio base URL</label>
|
||||
<input type="url" id="lms-url" class="wide" value="http://host.docker.internal:1234">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="discoverModels(this)">Discover models</button>
|
||||
</div>
|
||||
<div id="disc-error" style="display:none;color:#b00;font-size:0.82em;margin-top:6px"></div>
|
||||
<div id="disc-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add Claude / OpenAI ── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Add cloud model</span>
|
||||
<span class="card-sub">Claude or OpenAI-compatible</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Provider</label>
|
||||
<select id="add-provider" onchange="onProviderChange()">
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="lm-studio">Custom OpenAI-compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Model name</label>
|
||||
<input type="text" id="add-name" class="wide" value="claude-haiku-4-5-20251001">
|
||||
</div>
|
||||
<div class="field" id="add-key-field">
|
||||
<label>API key</label>
|
||||
<input type="password" id="add-key" class="wide" placeholder="sk-ant-…">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row" id="add-url-row" style="display:none">
|
||||
<div class="field">
|
||||
<label>Base URL</label>
|
||||
<input type="url" id="add-url" style="width:360px" placeholder="http://host.docker.internal:1234/v1">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="addModel(this)">Add model</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
let _cfg = {};
|
||||
let _models = [];
|
||||
|
||||
// ─── Toast ────────────────────────────────────────────────────────────────────
|
||||
let _toastTimer = null;
|
||||
function toast(msg, err = false) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'show' + (err ? ' err' : '');
|
||||
clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(() => el.className = '', 3000);
|
||||
}
|
||||
|
||||
// ─── Load ─────────────────────────────────────────────────────────────────────
|
||||
async function load() {
|
||||
const [mr, cr] = await Promise.all([fetch('/models'), fetch('/config')]);
|
||||
_models = (await mr.json()).models || [];
|
||||
_cfg = (await cr.json()).config || {};
|
||||
renderModels();
|
||||
}
|
||||
|
||||
function roleBadge(id) {
|
||||
const sid = String(id);
|
||||
const isResolve = _cfg['resolve_model_id'] === sid;
|
||||
const isWrite = _cfg['write_model_id'] === sid;
|
||||
if (isResolve && isWrite) return '<span class="badge badge-both">resolve + write</span>';
|
||||
if (isResolve) return '<span class="badge badge-resolve">resolve</span>';
|
||||
if (isWrite) return '<span class="badge badge-write">write</span>';
|
||||
return '<span style="color:#bbb;font-size:0.8em">—</span>';
|
||||
}
|
||||
|
||||
function providerPill(p) {
|
||||
const cls = 'pill-' + p;
|
||||
return `<span class="pill ${cls}">${p}</span>`;
|
||||
}
|
||||
|
||||
function renderModels() {
|
||||
const tbody = document.getElementById('models-tbody');
|
||||
const sub = document.getElementById('cfg-sub');
|
||||
|
||||
if (!_models.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="color:#aaa;padding:18px">No models configured yet.</td></tr>';
|
||||
sub.textContent = '0 models';
|
||||
return;
|
||||
}
|
||||
sub.textContent = _models.length + ' model' + (_models.length !== 1 ? 's' : '');
|
||||
|
||||
const resolveId = _cfg['resolve_model_id'] || '';
|
||||
const writeId = _cfg['write_model_id'] || '';
|
||||
|
||||
tbody.innerHTML = _models.map(m => {
|
||||
const sid = String(m.id);
|
||||
const endpoint = m.base_url
|
||||
? `<span style="color:#555;font-size:0.85em">${m.base_url}</span>`
|
||||
: '<span style="color:#ccc">—</span>';
|
||||
const rBtn = resolveId === sid
|
||||
? `<button class="btn btn-sm btn-active" disabled>✓ resolve</button>`
|
||||
: `<button class="btn btn-sm" onclick="setRole('resolve_model_id','${sid}')">set resolve</button>`;
|
||||
const wBtn = writeId === sid
|
||||
? `<button class="btn btn-sm btn-active" disabled>✓ write</button>`
|
||||
: `<button class="btn btn-sm" onclick="setRole('write_model_id','${sid}')">set write</button>`;
|
||||
return `<tr>
|
||||
<td style="color:#aaa">#${m.id}</td>
|
||||
<td>${providerPill(m.provider)}</td>
|
||||
<td>${m.model_name}</td>
|
||||
<td>${endpoint}</td>
|
||||
<td>${roleBadge(m.id)}</td>
|
||||
<td style="display:flex;gap:6px;padding:6px 10px">
|
||||
${rBtn} ${wBtn}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteModel(${m.id},this)">✕</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ─── Set role ─────────────────────────────────────────────────────────────────
|
||||
async function setRole(key, value) {
|
||||
const r = await fetch('/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key, value}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.error) { toast('Error: ' + d.error, true); return; }
|
||||
_cfg[key] = value;
|
||||
renderModels();
|
||||
toast(key.replace('_model_id', '') + ' model set to #' + value);
|
||||
}
|
||||
|
||||
// ─── Delete ───────────────────────────────────────────────────────────────────
|
||||
async function deleteModel(id, btn) {
|
||||
if (!confirm(`Delete model #${id}?`)) return;
|
||||
btn.disabled = true;
|
||||
const r = await fetch('/models/' + id, {method: 'DELETE'});
|
||||
const d = await r.json();
|
||||
btn.disabled = false;
|
||||
if (d.error) { toast('Error: ' + d.error, true); return; }
|
||||
toast('Model #' + id + ' deleted');
|
||||
await load();
|
||||
}
|
||||
|
||||
// ─── LM Studio discover ───────────────────────────────────────────────────────
|
||||
async function discoverModels(btn) {
|
||||
const base = document.getElementById('lms-url').value.trim();
|
||||
const errEl = document.getElementById('disc-error');
|
||||
const listEl = document.getElementById('disc-list');
|
||||
errEl.style.display = 'none';
|
||||
listEl.innerHTML = '<span style="color:#aaa;font-size:0.82em">Connecting…</span>';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const r = await fetch('/models/discover?base_url=' + encodeURIComponent(base));
|
||||
const d = await r.json();
|
||||
btn.disabled = false;
|
||||
|
||||
if (d.error) {
|
||||
errEl.textContent = d.error;
|
||||
errEl.style.display = 'block';
|
||||
listEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!d.models.length) {
|
||||
listEl.innerHTML = '<span style="color:#aaa;font-size:0.82em">No models loaded in LM Studio.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = d.models.map(name => `
|
||||
<div class="disc-item">
|
||||
<span>${name}</span>
|
||||
<button class="btn btn-sm btn-success"
|
||||
onclick="addLmStudioModel('${name}','${base}/v1',this)">
|
||||
+ add
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch(e) {
|
||||
btn.disabled = false;
|
||||
errEl.textContent = 'Request failed: ' + e.message;
|
||||
errEl.style.display = 'block';
|
||||
listEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function addLmStudioModel(modelName, baseUrl, btn) {
|
||||
btn.disabled = true;
|
||||
const r = await fetch('/models', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({provider: 'lm-studio', model_name: modelName, api_key: '', base_url: baseUrl}),
|
||||
});
|
||||
const d = await r.json();
|
||||
btn.disabled = false;
|
||||
if (d.error) { toast('Error: ' + d.error, true); return; }
|
||||
toast(`Added "${modelName}" (#${d.id}). Click "set resolve" to activate it.`);
|
||||
btn.textContent = '✓ added';
|
||||
btn.classList.add('btn-active');
|
||||
await load();
|
||||
}
|
||||
|
||||
// ─── Add cloud model ──────────────────────────────────────────────────────────
|
||||
function onProviderChange() {
|
||||
const p = document.getElementById('add-provider').value;
|
||||
document.getElementById('add-url-row').style.display = p === 'lm-studio' ? 'flex' : 'none';
|
||||
document.getElementById('add-key-field').style.display = p === 'lm-studio' ? 'none' : 'flex';
|
||||
|
||||
const nameDefaults = {
|
||||
claude: 'claude-haiku-4-5-20251001',
|
||||
openai: 'gpt-4o-mini',
|
||||
'lm-studio': '',
|
||||
};
|
||||
document.getElementById('add-name').value = nameDefaults[p] || '';
|
||||
}
|
||||
|
||||
async function addModel(btn) {
|
||||
const provider = document.getElementById('add-provider').value;
|
||||
const modelName = document.getElementById('add-name').value.trim();
|
||||
const apiKey = document.getElementById('add-key').value.trim();
|
||||
const baseUrl = document.getElementById('add-url').value.trim();
|
||||
|
||||
if (!modelName) { toast('Model name is required', true); return; }
|
||||
if (provider === 'claude' && !apiKey) { toast('API key required for Claude', true); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
const r = await fetch('/models', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({provider, model_name: modelName, api_key: apiKey, base_url: baseUrl}),
|
||||
});
|
||||
const d = await r.json();
|
||||
btn.disabled = false;
|
||||
if (d.error) { toast('Error: ' + d.error, true); return; }
|
||||
toast(`Model added (#${d.id}). Click "set resolve" to activate it.`);
|
||||
document.getElementById('add-key').value = '';
|
||||
await load();
|
||||
}
|
||||
|
||||
// ─── Boot ─────────────────────────────────────────────────────────────────────
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.get("/models-ui", response_class=HTMLResponse)
|
||||
async def models_ui() -> str:
|
||||
return MODELS_HTML
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passthrough — everything else forwarded to upstream Ollama
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user