Adding model edit
This commit is contained in:
@@ -52,6 +52,10 @@ async def init_schema(pool: asyncpg.Pool) -> None:
|
|||||||
sql = SCHEMA_PATH.read_text()
|
sql = SCHEMA_PATH.read_text()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(sql)
|
await conn.execute(sql)
|
||||||
|
# Migration: base_url column for LM Studio / custom OpenAI-compatible endpoints
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE models ADD COLUMN IF NOT EXISTS base_url TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
log.info("schema applied")
|
log.info("schema applied")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ log = logging.getLogger("festinger.llm")
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ModelConfig:
|
class ModelConfig:
|
||||||
provider: str
|
provider: str # 'claude', 'openai', or 'lm-studio'
|
||||||
model_name: str
|
model_name: str
|
||||||
api_key: str
|
api_key: str
|
||||||
|
base_url: str = "" # custom endpoint for lm-studio / openai-compatible
|
||||||
|
|
||||||
|
|
||||||
async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelConfig]:
|
async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelConfig]:
|
||||||
@@ -32,7 +33,7 @@ async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelC
|
|||||||
return None
|
return None
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"SELECT provider, model_name, api_key FROM models WHERE id=$1", mid
|
"SELECT provider, model_name, api_key, base_url FROM models WHERE id=$1", mid
|
||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
@@ -40,6 +41,7 @@ async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelC
|
|||||||
provider=row["provider"],
|
provider=row["provider"],
|
||||||
model_name=row["model_name"],
|
model_name=row["model_name"],
|
||||||
api_key=row["api_key"],
|
api_key=row["api_key"],
|
||||||
|
base_url=row["base_url"] or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ async def call_llm(model: ModelConfig, prompt: str) -> str:
|
|||||||
"""Call the configured LLM and return the text response."""
|
"""Call the configured LLM and return the text response."""
|
||||||
if model.provider == "claude":
|
if model.provider == "claude":
|
||||||
return await _call_claude(model, prompt)
|
return await _call_claude(model, prompt)
|
||||||
elif model.provider == "openai":
|
elif model.provider in ("openai", "lm-studio"):
|
||||||
return await _call_openai(model, prompt)
|
return await _call_openai(model, prompt)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown LLM provider: {model.provider!r}")
|
raise ValueError(f"Unknown LLM provider: {model.provider!r}")
|
||||||
@@ -66,7 +68,10 @@ async def _call_claude(model: ModelConfig, prompt: str) -> str:
|
|||||||
|
|
||||||
async def _call_openai(model: ModelConfig, prompt: str) -> str:
|
async def _call_openai(model: ModelConfig, prompt: str) -> str:
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
client = AsyncOpenAI(api_key=model.api_key)
|
kwargs: dict = {"api_key": model.api_key or "lm-studio"}
|
||||||
|
if model.base_url:
|
||||||
|
kwargs["base_url"] = model.base_url
|
||||||
|
client = AsyncOpenAI(**kwargs)
|
||||||
response = await client.chat.completions.create(
|
response = await client.chat.completions.create(
|
||||||
model=model.model_name,
|
model=model.model_name,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
|||||||
@@ -779,11 +779,11 @@ async def list_models(request: Request) -> dict:
|
|||||||
pool = request.app.state.pool
|
pool = request.app.state.pool
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
rows = await conn.fetch(
|
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": [
|
return {"models": [
|
||||||
{"id": r["id"], "provider": r["provider"], "model_name": r["model_name"],
|
{"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
|
for r in rows
|
||||||
]}
|
]}
|
||||||
|
|
||||||
@@ -795,16 +795,19 @@ async def create_model(request: Request) -> dict:
|
|||||||
provider = data.get("provider", "").strip()
|
provider = data.get("provider", "").strip()
|
||||||
model_name = data.get("model_name", "").strip()
|
model_name = data.get("model_name", "").strip()
|
||||||
api_key = data.get("api_key", "").strip()
|
api_key = data.get("api_key", "").strip()
|
||||||
if not provider or not model_name or not api_key:
|
base_url = data.get("base_url", "").strip()
|
||||||
return {"error": "provider, model_name, and api_key are required"}
|
if not provider or not model_name:
|
||||||
if provider not in ("claude", "openai"):
|
return {"error": "provider and model_name are required"}
|
||||||
return {"error": "provider must be 'claude' or 'openai'"}
|
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:
|
async with pool.acquire() as conn:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"INSERT INTO models (provider, model_name, api_key) VALUES ($1,$2,$3) RETURNING id",
|
"INSERT INTO models (provider, model_name, api_key, base_url) VALUES ($1,$2,$3,$4) RETURNING id",
|
||||||
provider, model_name, api_key,
|
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"]}
|
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}
|
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")
|
@app.get("/config")
|
||||||
async def get_all_config(request: Request) -> dict:
|
async def get_all_config(request: Request) -> dict:
|
||||||
pool = request.app.state.pool
|
pool = request.app.state.pool
|
||||||
@@ -1711,6 +1736,7 @@ ADMIN_HTML = """<!DOCTYPE html>
|
|||||||
<h1>Festinger</h1>
|
<h1>Festinger</h1>
|
||||||
<p class="subtitle">Ollama-compatible inference middleware — loop detection & Recollections world model
|
<p class="subtitle">Ollama-compatible inference middleware — loop detection & Recollections world model
|
||||||
— <a href="/graph" style="color:#1a1a2e">Knowledge Graph Explorer</a>
|
— <a href="/graph" style="color:#1a1a2e">Knowledge Graph Explorer</a>
|
||||||
|
— <a href="/models-ui" style="color:#1a1a2e">Model Manager</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>World model stats</h2>
|
<h2>World model stats</h2>
|
||||||
@@ -2002,6 +2028,438 @@ async def admin() -> str:
|
|||||||
return ADMIN_HTML.format()
|
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
|
# Passthrough — everything else forwarded to upstream Ollama
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user