Fifteen changes aimed at the first day of real use: - Import and media jobs persist to <data>/jobs-*.json and reload on start (running ones settle as errors); the Register button no longer vanishes because the gateway restarted. - Docker ships the cortiq binary (built in the builder stage) — local .cmf chat models and image/video generation now work in containers, where auto-install never could (no cargo in the runtime image). libvulkan1 added for optional GPU offload. - Open admin console on 0.0.0.0 without a token now warns in the log and shows a dismissible banner — the VPS footgun is at least labeled. - "Restart gateway" button in Settings: stops managed model servers, spawns a fresh copy (bind retried while the port frees), Docker relies on the container restart policy instead. - Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's own RSS), free disk at the models dir — sampled by sysinfo every 2 s. - Featured model cards warn "not enough disk" / "may not fit in RAM" before the download, from the same live snapshot. - Log viewer in the sidebar: a 500-line in-memory ring fed by a second ANSI-free tracing layer; filter, level, pause, copy. - Update check: crates.io polled twice a day, the console shows an "update available" notice. - Media generations queue up (with position shown) instead of being rejected; progress gained an honest ETA from measured throughput. - Generation gallery under the image/video tabs: lazy PPM thumbnails, click to reopen with seed/steps, delete. - Playground chat is a real dialogue now: history is resent, bubbles, Clear, Cmd/Ctrl+Enter. - "Connect your app" snippets (curl / Python / JS) on the keys page, with the real key inlined right after creation. - One-click benchmark on the Models page via `cortiq bench --json`. - Config export now downloads the actual gateway.toml (comments intact); import accepts TOML (validated, hot-reloaded) and the legacy JSON. - Streaming token counts are exact when the model server provides usage (cortiq-server patch, shipped separately); the gateway fallback now counts content chunks (1/token on cortiq) instead of chars/4. Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
254 lines
9.1 KiB
JavaScript
Vendored
254 lines
9.1 KiB
JavaScript
Vendored
// Thin client over the gateway's /admin/api surface. Adds the Bearer admin token,
|
|
// parses JSON, and throws Error(message) on non-2xx so views can try/catch.
|
|
// A 401 sets an "unauthorized" flag the app uses to show the login screen.
|
|
|
|
const BASE = "/admin/api";
|
|
const TOKEN_KEY = "allaigate_token";
|
|
|
|
let onUnauthorized = null;
|
|
export function setUnauthorizedHandler(fn) {
|
|
onUnauthorized = fn;
|
|
}
|
|
|
|
export function getToken() {
|
|
return localStorage.getItem(TOKEN_KEY) || "";
|
|
}
|
|
export function setToken(tok) {
|
|
if (tok) localStorage.setItem(TOKEN_KEY, tok);
|
|
else localStorage.removeItem(TOKEN_KEY);
|
|
}
|
|
|
|
async function j(method, path, body) {
|
|
const tok = getToken();
|
|
const headers = {};
|
|
if (tok) headers["Authorization"] = "Bearer " + tok;
|
|
const opt = { method, headers };
|
|
if (body !== undefined) {
|
|
opt.headers["content-type"] = "application/json";
|
|
opt.body = JSON.stringify(body);
|
|
}
|
|
const r = await fetch(BASE + path, opt);
|
|
if (r.status === 401) {
|
|
if (onUnauthorized) onUnauthorized();
|
|
throw new Error("unauthorized");
|
|
}
|
|
const text = await r.text();
|
|
let data = null;
|
|
try {
|
|
data = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
data = text;
|
|
}
|
|
if (!r.ok) {
|
|
const msg =
|
|
(data && data.error && (data.error.message || data.error)) ||
|
|
(data && (data.detail || data.message)) ||
|
|
r.statusText;
|
|
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export const api = {
|
|
// meta / health
|
|
meta: () => j("GET", "/meta"),
|
|
health: () => j("GET", "/health"),
|
|
probeRouter: () => j("POST", "/router/probe"),
|
|
|
|
// config
|
|
getConfig: () => j("GET", "/config"),
|
|
putConfig: (b) => j("PUT", "/config", b),
|
|
|
|
// models
|
|
listModels: () => j("GET", "/models"),
|
|
createModel: (b) => j("POST", "/models", b),
|
|
updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b),
|
|
deleteModel: (id) => j("DELETE", "/models/" + encodeURIComponent(id)),
|
|
probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"),
|
|
|
|
// routing
|
|
getRouting: () => j("GET", "/routing"),
|
|
putRouting: (b) => j("PUT", "/routing", b),
|
|
|
|
// protocols
|
|
getProtocols: () => j("GET", "/protocols"),
|
|
putProtocols: (b) => j("PUT", "/protocols", b),
|
|
|
|
// settings
|
|
getSettings: () => j("GET", "/settings"),
|
|
putSettings: (b) => j("PUT", "/settings", b),
|
|
|
|
// keys
|
|
listKeys: () => j("GET", "/keys"),
|
|
createKey: (b) => j("POST", "/keys", b),
|
|
deleteKey: (k) => j("DELETE", "/keys/" + encodeURIComponent(k)),
|
|
|
|
// secrets
|
|
listSecrets: () => j("GET", "/secrets"),
|
|
setSecret: (name, value) => j("PUT", "/secrets", { name, value }),
|
|
clearSecret: (name) => j("DELETE", "/secrets?name=" + encodeURIComponent(name)),
|
|
|
|
// stats
|
|
stats: (range, groupby) => j("GET", `/stats?range=${range}&groupby=${groupby}`),
|
|
clearStats: () => j("DELETE", "/stats"),
|
|
requests: (limit = 50, offset = 0) => j("GET", `/requests?limit=${limit}&offset=${offset}`),
|
|
|
|
// playground
|
|
test: (b) => j("POST", "/test", b),
|
|
|
|
// shadow / self-warming loop
|
|
shadow: () => j("GET", "/shadow"),
|
|
|
|
// CMF runtime (the `cortiq` CLI that runs the .cmf format)
|
|
cmfStatus: () => j("GET", "/cmf"),
|
|
cmfInstall: () => j("POST", "/cmf/install"),
|
|
cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)),
|
|
cmfFiles: () => j("GET", "/cmf/files"),
|
|
providerModels: (b) => j("POST", "/provider/models", b),
|
|
|
|
// CMF model factory (HuggingFace → local .cmf)
|
|
hfSearch: (q, limit = 24) =>
|
|
j("GET", `/hf/search?q=${encodeURIComponent(q || "")}&limit=${limit}`),
|
|
startImport: (b) => j("POST", "/import", b),
|
|
listImports: () => j("GET", "/import"),
|
|
importStatus: (job) => j("GET", "/import/" + encodeURIComponent(job)),
|
|
cancelImport: (job) =>
|
|
j("POST", "/import/" + encodeURIComponent(job) + "/cancel"),
|
|
deleteImport: (job) => j("DELETE", "/import/" + encodeURIComponent(job)),
|
|
registerImport: (job) =>
|
|
j("POST", "/import/" + encodeURIComponent(job) + "/register"),
|
|
|
|
// media generation (cortiq imagine / animate over local .cmf)
|
|
mediaModels: () => j("GET", "/media/models"),
|
|
mediaGenerate: (b) => j("POST", "/media", b),
|
|
mediaJobs: () => j("GET", "/media"),
|
|
mediaJob: (id) => j("GET", "/media/" + encodeURIComponent(id)),
|
|
mediaCancel: (id) => j("POST", "/media/" + encodeURIComponent(id) + "/cancel"),
|
|
mediaDelete: (id) => j("DELETE", "/media/" + encodeURIComponent(id)),
|
|
|
|
// system / logs / lifecycle
|
|
system: () => j("GET", "/system"),
|
|
logs: (limit = 300) => j("GET", "/logs?limit=" + limit),
|
|
restart: () => j("POST", "/restart"),
|
|
benchStart: (model) => j("POST", "/bench", { model }),
|
|
benchList: () => j("GET", "/bench"),
|
|
|
|
// first-run onboarding
|
|
setupStatus: () => j("GET", "/setup/status"),
|
|
// Connects every finished download as a local model unless register === false.
|
|
setupComplete: (lang, register) => j("POST", "/setup/complete", { lang, register }),
|
|
setupFeatured: () => j("GET", "/setup/featured"),
|
|
// `name` is the exact output stem from setup/featured — it wins over the
|
|
// repo/variant guess so the delete always hits the file the download wrote.
|
|
deleteSetupFile: (repo, variant, name) =>
|
|
j(
|
|
"DELETE",
|
|
`/setup/file?repo=${encodeURIComponent(repo)}` +
|
|
(variant ? `&variant=${encodeURIComponent(variant)}` : "") +
|
|
(name ? `&name=${encodeURIComponent(name)}` : "")
|
|
),
|
|
};
|
|
|
|
// Raw-body POST (no JSON encoding) — the config import takes TOML text as-is.
|
|
export async function postRaw(path, text) {
|
|
const tok = getToken();
|
|
const headers = { "content-type": "application/toml" };
|
|
if (tok) headers["Authorization"] = "Bearer " + tok;
|
|
const r = await fetch(BASE + path, { method: "POST", headers, body: text });
|
|
const body = await r.text();
|
|
if (!r.ok) {
|
|
let msg = body;
|
|
try { msg = JSON.parse(body).error?.message || body; } catch { /* raw */ }
|
|
throw new Error(msg || r.statusText);
|
|
}
|
|
return body ? JSON.parse(body) : null;
|
|
}
|
|
|
|
// Streaming playground: POST /test/stream, calls onDelta(text) per content chunk,
|
|
// returns the routing decision read from X-Cortiq-* response headers.
|
|
export async function testStream(body, onDelta) {
|
|
const tok = getToken();
|
|
const headers = { "content-type": "application/json" };
|
|
if (tok) headers["Authorization"] = "Bearer " + tok;
|
|
const r = await fetch(BASE + "/test/stream", {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (r.status === 401) {
|
|
if (onUnauthorized) onUnauthorized();
|
|
throw new Error("unauthorized");
|
|
}
|
|
if (!r.ok || !r.body) {
|
|
throw new Error((await r.text()) || r.statusText);
|
|
}
|
|
const reader = r.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = "";
|
|
const t0 = performance.now();
|
|
let ttftMs = null; // time to first content token
|
|
let usage = null; // OpenAI-style final-chunk usage, when the provider sends it
|
|
let usageEstimated = false; // gateway-injected estimate rather than tokenizer truth
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
let idx;
|
|
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
const line = buf.slice(0, idx).split("\n").find((l) => l.startsWith("data:"));
|
|
buf = buf.slice(idx + 2);
|
|
if (!line) continue;
|
|
const data = line.slice(5).trim();
|
|
if (!data || data === "[DONE]") continue;
|
|
try {
|
|
const chunk = JSON.parse(data);
|
|
if (chunk.usage) {
|
|
usage = chunk.usage;
|
|
usageEstimated = !!chunk.cortiq_estimated;
|
|
}
|
|
const d = chunk.choices?.[0]?.delta?.content;
|
|
if (d) {
|
|
if (ttftMs == null) ttftMs = Math.round(performance.now() - t0);
|
|
onDelta(d);
|
|
}
|
|
} catch {
|
|
/* ignore malformed chunk */
|
|
}
|
|
}
|
|
}
|
|
const h = r.headers;
|
|
return {
|
|
task_label: h.get("x-cortiq-task-label") || "",
|
|
tier: h.get("x-cortiq-complexity-tier") || "",
|
|
score: parseFloat(h.get("x-cortiq-complexity-score") || "0"),
|
|
selected_model: h.get("x-cortiq-selected-model") || "",
|
|
route_source: h.get("x-cortiq-route-source") || "",
|
|
cost_usd: parseFloat(h.get("x-cortiq-cost-usd") || "0"),
|
|
usage,
|
|
usage_estimated: usageEstimated,
|
|
ttft_ms: ttftMs,
|
|
};
|
|
}
|
|
|
|
// Fetch a media job's output with the admin token and hand back an object URL
|
|
// (plain <img src>/<video src> cannot carry the Authorization header).
|
|
export async function mediaFileUrl(job, kind) {
|
|
const tok = getToken();
|
|
const headers = {};
|
|
if (tok) headers["Authorization"] = "Bearer " + tok;
|
|
const r = await fetch(`${BASE}/media/${encodeURIComponent(job)}/file/${encodeURIComponent(kind)}`, { headers });
|
|
if (!r.ok) throw new Error((await r.text()) || r.statusText);
|
|
return { url: URL.createObjectURL(await r.blob()), size: +(r.headers.get("content-length") || 0) };
|
|
}
|
|
|
|
// Same fetch, raw bytes — for outputs the browser cannot display natively
|
|
// (P6 PPM, MJPEG AVI) that we decode in JS.
|
|
export async function mediaFileBytes(job, kind) {
|
|
const tok = getToken();
|
|
const headers = {};
|
|
if (tok) headers["Authorization"] = "Bearer " + tok;
|
|
const r = await fetch(`${BASE}/media/${encodeURIComponent(job)}/file/${encodeURIComponent(kind)}`, { headers });
|
|
if (!r.ok) throw new Error((await r.text()) || r.statusText);
|
|
return r.arrayBuffer();
|
|
}
|