Merge pull request #316 from sillyWillieBilly/feat/console-enhancements

feat(console): theme-aware banner, judge model support, Google provider in admin
This commit is contained in:
Patrick Buckley
2026-04-06 00:56:39 -07:00
committed by GitHub
5 changed files with 187 additions and 79 deletions
+4 -3
View File
@@ -394,7 +394,8 @@ class ClusterCollector:
{
"type": "ws_created",
"ws_id": ws_id,
"name": ws.get("name", ""),
"name": ws.get("title", "") or ws.get("name", ""),
"title": ws.get("title", ""),
"node_id": node_id,
}
)
@@ -418,8 +419,8 @@ class ClusterCollector:
"content": new_w.get("content", ""),
}
)
old_name = old_ws.get("name", "")
new_name = new_w.get("name", "")
old_name = old_ws.get("title", "") or old_ws.get("name", "")
new_name = new_w.get("title", "") or new_w.get("name", "")
if old_name != new_name and new_name:
pending.append({"type": "ws_rename", "ws_id": ws_id, "name": new_name})
node.workstreams = new_ws
+45 -15
View File
@@ -129,21 +129,36 @@ _JS_PROXY_SHIM = """\
"""
_CONSOLE_BANNER_TEMPLATE = (
'<div style="background:#111827;border-bottom:1px solid rgba(229,160,66,0.3);'
"padding:6px 20px;font-family:'IBM Plex Mono',monospace;font-size:12px;"
'display:flex;align-items:center;gap:12px;position:relative;z-index:9999">'
'<a href="/" style="color:#8a93ad;text-decoration:none;font-weight:500;'
'padding:2px 0" '
"onmouseover=\"this.style.color='#e5a042'\" "
"onmouseout=\"this.style.color='#8a93ad'\">"
"&larr; Console</a>"
'<span style="color:#3b4463">\u2502</span>'
'<span style="color:#8a93ad;font-size:11px">NODE_ID_PLACEHOLDER</span>'
'<div class="console-banner">'
'<a href="/" class="console-banner-link">&larr; Console</a>'
'<span class="console-banner-sep">\u2502</span>'
'<a href="NODE_LINK_PLACEHOLDER" class="console-banner-node">'
"NODE_ID_PLACEHOLDER</a>"
"</div>"
)
# Injected <style> offsets fixed-position overlays below the console banner.
_CONSOLE_PROXY_STYLE = "<style>.dashboard-overlay{top:32px!important}</style>"
# Injected <style>: offsets fixed-position overlays and provides theme-aware
# banner styling so the banner adapts to light/dark without inline colours.
_CONSOLE_PROXY_STYLE = (
"<style>"
".dashboard-overlay{top:32px!important}"
".console-banner{background:#111827;border-bottom:1px solid rgba(229,160,66,0.3);"
"padding:6px 20px;font-family:'IBM Plex Mono',monospace;font-size:12px;"
"display:flex;align-items:center;gap:12px;position:relative;z-index:9999}"
".console-banner-link,.console-banner-node{color:#8a93ad;text-decoration:none}"
".console-banner-link{font-weight:500;padding:2px 0}"
".console-banner-node{font-size:11px}"
".console-banner-sep{color:#3b4463}"
".console-banner-link:hover,.console-banner-node:hover{color:#e5a042}"
':root[data-theme="light"] .console-banner{background:#f8fafc;'
"border-bottom-color:rgba(229,160,66,0.5)}"
':root[data-theme="light"] .console-banner-link,'
':root[data-theme="light"] .console-banner-node{color:#64748b}'
':root[data-theme="light"] .console-banner-sep{color:#cbd5e1}'
':root[data-theme="light"] .console-banner-link:hover,'
':root[data-theme="light"] .console-banner-node:hover{color:#e5a042}'
"</style>"
)
_VALID_NODE_ID = re.compile(r"^[a-zA-Z0-9._-]+$")
@@ -446,6 +461,7 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_node_id = body.get("node_id", "")
raw_name = body.get("name", "")
raw_model = body.get("model", "")
raw_judge_model = body.get("judge_model", "")
raw_initial_message = body.get("initial_message", "")
raw_skill = body.get("skill", "")
raw_resume_ws = body.get("resume_ws", "")
@@ -455,6 +471,8 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_name = "" if raw_name is None else None
if not isinstance(raw_model, str):
raw_model = "" if raw_model is None else None
if not isinstance(raw_judge_model, str):
raw_judge_model = "" if raw_judge_model is None else None
if not isinstance(raw_initial_message, str):
raw_initial_message = "" if raw_initial_message is None else None
if not isinstance(raw_skill, str):
@@ -465,19 +483,21 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_node_id is None
or raw_name is None
or raw_model is None
or raw_judge_model is None
or raw_initial_message is None
or raw_skill is None
or raw_resume_ws is None
):
return JSONResponse(
{
"error": "node_id, name, model, initial_message, skill, and resume_ws must be strings"
"error": "node_id, name, model, judge_model, initial_message, skill, and resume_ws must be strings"
},
status_code=400,
)
node_id = raw_node_id
name = raw_name[:256]
model = raw_model[:128]
judge_model = raw_judge_model[:128]
initial_message = raw_initial_message[:4096]
skill = raw_skill[:256]
resume_ws = raw_resume_ws[:64]
@@ -509,6 +529,7 @@ async def create_workstream(request: Request) -> JSONResponse:
ws_body = {
"name": name,
"model": model,
"judge_model": judge_model,
"initial_message": initial_message,
"skill": skill,
"resume_ws": resume_ws,
@@ -918,7 +939,7 @@ async def proxy_index(request: Request) -> Response:
page = page.replace('href="/shared/', f'href="{prefix}/shared/')
page = page.replace('src="/shared/', f'src="{prefix}/shared/')
# Inject console-return banner + proxy shim after <body>
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", html.escape(node_id))
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", html.escape(node_id)).replace("NODE_LINK_PLACEHOLDER", html.escape(prefix + "/"))
shim = (
"<script>"
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
@@ -5151,7 +5172,12 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
# ---------------------------------------------------------------------------
_MODEL_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible"})
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible", "google"})
_PROVIDER_DEFAULT_URLS: dict[str, str] = {
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
"google": "https://generativelanguage.googleapis.com/v1beta/openai/",
}
def _mask_model_secrets(model: dict[str, Any]) -> dict[str, Any]:
@@ -5591,6 +5617,10 @@ async def admin_detect_model(request: Request) -> JSONResponse:
if not base_url:
base_url = row.get("base_url", "")
# Apply provider default URL if still empty
if not base_url:
base_url = _PROVIDER_DEFAULT_URLS.get(provider, "")
# For commercial endpoints an api_key is required
_normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else ""
_hostname = (urllib.parse.urlparse(_normalized).hostname or "") if _normalized else ""
+108 -60
View File
@@ -403,8 +403,8 @@ function confirmDeleteUser(userId, username) {
showConfirmModal(
"Delete User",
"Delete user \u2018" +
username +
"\u2019 and all their tokens and channel links? This cannot be undone.",
username +
"\u2019 and all their tokens and channel links? This cannot be undone.",
"Delete",
function () {
authFetch("/v1/api/admin/users/" + encodeURIComponent(userId), {
@@ -570,19 +570,19 @@ function _confirmUnlinkOidc(issuer, subject, username, userId) {
showConfirmModal(
"Unlink OIDC Identity",
"Unlink " +
shortIssuer +
" identity \u2018" +
shortSubject +
"\u2019 from user " +
username +
"?\n\nThe user will need to log in via OIDC again to re-link.",
shortIssuer +
" identity \u2018" +
shortSubject +
"\u2019 from user " +
username +
"?\n\nThe user will need to log in via OIDC again to re-link.",
"Unlink",
function () {
authFetch(
"/v1/api/admin/oidc-identities?issuer=" +
encodeURIComponent(issuer) +
"&subject=" +
encodeURIComponent(subject),
encodeURIComponent(issuer) +
"&subject=" +
encodeURIComponent(subject),
{ method: "DELETE" },
)
.then(function (r) {
@@ -608,8 +608,8 @@ function _confirmUnlinkOidc(issuer, subject, username, userId) {
'<span class="oidc-detail-empty">Loading\u2026</span>';
authFetch(
"/v1/api/admin/users/" +
encodeURIComponent(userId) +
"/oidc-identities",
encodeURIComponent(userId) +
"/oidc-identities",
)
.then(function (r2) {
if (!r2.ok) throw new Error("Failed");
@@ -878,17 +878,17 @@ function confirmUnlinkChannel(channelType, channelUserId) {
showConfirmModal(
"Unlink Channel",
"Unlink " +
channelType +
" account \u2018" +
channelUserId +
"\u2019? The user will need to re-link via /link to interact with the bot.",
channelType +
" account \u2018" +
channelUserId +
"\u2019? The user will need to re-link via /link to interact with the bot.",
"Unlink",
function () {
authFetch(
"/v1/api/admin/channels/" +
encodeURIComponent(channelType) +
"/" +
encodeURIComponent(channelUserId),
encodeURIComponent(channelType) +
"/" +
encodeURIComponent(channelUserId),
{ method: "DELETE" },
)
.then(function (r) {
@@ -1059,8 +1059,8 @@ function confirmDeleteSchedule(taskId, name) {
showConfirmModal(
"Delete Schedule",
"Delete schedule \u2018" +
name +
"\u2019 and its run history? This cannot be undone.",
name +
"\u2019 and its run history? This cannot be undone.",
"Delete",
function () {
authFetch("/v1/api/admin/schedules/" + encodeURIComponent(taskId), {
@@ -1671,12 +1671,12 @@ function _renderWatches(watches) {
var statusDot = active ? "\u25cf " : "\u25cb ";
var cancelBtn = active
? '<button class="admin-btn-danger" data-cancel-watch="' +
escapeHtml(w.watch_id) +
'" data-watch-node="' +
escapeHtml(w.node_id || "") +
'" data-watch-name="' +
escapeHtml(name) +
'" title="Cancel watch">cancel</button>'
escapeHtml(w.watch_id) +
'" data-watch-node="' +
escapeHtml(w.node_id || "") +
'" data-watch-name="' +
escapeHtml(name) +
'" title="Cancel watch">cancel</button>'
: "";
html +=
'<div class="admin-row" role="listitem">' +
@@ -1797,8 +1797,8 @@ function submitCreateChannel() {
authFetch(
"/v1/api/admin/users/" +
encodeURIComponent(_adminChannelUserId) +
"/channels",
encodeURIComponent(_adminChannelUserId) +
"/channels",
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -3044,6 +3044,22 @@ function _saveSettingValue(key) {
showToast(
"Saved " + key + (restartBadge ? " \u2014 restart required" : ""),
);
// If this is a theme setting, apply it immediately
if (key === "interface.theme") {
var newTheme = value;
document.documentElement.dataset.theme =
newTheme === "light" ? "light" : "";
localStorage.setItem(
"turnstone-theme",
newTheme === "light" ? "light" : "dark",
);
var themeBtn = document.getElementById("theme-toggle");
if (themeBtn) {
themeBtn.textContent =
newTheme === "light" ? "\u2600" : "\u263E";
}
}
})
.catch(function (err) {
if (saveBtn) {
@@ -3058,8 +3074,8 @@ function _resetSetting(key) {
showConfirmModal(
"Reset Setting",
"Reset \u2018" +
key +
"\u2019 to its default value? The stored override will be removed.",
key +
"\u2019 to its default value? The stored override will be removed.",
"Reset",
function () {
authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), {
@@ -3202,13 +3218,13 @@ function _renderMcpServers(items) {
var actions = isConfig
? ""
: '<button class="admin-btn-action" data-mcp-edit="' +
escapeHtml(s.server_id) +
'">edit</button>' +
'<button class="admin-btn-danger" data-mcp-delete="' +
escapeHtml(s.server_id) +
'" data-mcp-name="' +
escapeHtml(s.name) +
'">del</button>';
escapeHtml(s.server_id) +
'">edit</button>' +
'<button class="admin-btn-danger" data-mcp-delete="' +
escapeHtml(s.server_id) +
'" data-mcp-name="' +
escapeHtml(s.name) +
'">del</button>';
html +=
'<div class="admin-row mcp-grid ' +
@@ -3404,11 +3420,11 @@ function _parseMcpForm() {
var argsText = document.getElementById("mcp-args").value.trim();
payload.args = argsText
? argsText
.split("\n")
.map(function (l) {
return l.trim();
})
.filter(Boolean)
.split("\n")
.map(function (l) {
return l.trim();
})
.filter(Boolean)
: [];
var envText = document.getElementById("mcp-env").value.trim();
var envObj = {};
@@ -3560,7 +3576,7 @@ function _openMcpDetail(s) {
'<p style="font-size:12px;color:var(--fg-dim)">Args: <code>' +
escapeHtml(a.join(" ")) +
"</code></p>";
} catch (e) {}
} catch (e) { }
} else {
html +=
'<p style="font-size:12px;color:var(--fg-dim)">URL: <code>' +
@@ -3599,7 +3615,7 @@ function _openMcpDetail(s) {
escapeHtml(meta.website_url) +
"</a></p>";
}
} catch (e) {}
} catch (e) { }
html += "</div>";
}
html += "</div>";
@@ -3891,8 +3907,8 @@ function _renderRegistryResults() {
"</div>" +
(srv.description
? '<div class="mcp-reg-card-desc">' +
escapeHtml(srv.description) +
"</div>"
escapeHtml(srv.description) +
"</div>"
: "") +
'<div class="mcp-reg-card-meta">' +
sourceBadges +
@@ -3900,8 +3916,8 @@ function _renderRegistryResults() {
'<div class="mcp-reg-card-actions">' +
(srv.version
? '<span class="mcp-reg-card-version">v' +
escapeHtml(srv.version) +
"</span>"
escapeHtml(srv.version) +
"</span>"
: "") +
actionHtml +
"</div></div>";
@@ -3924,9 +3940,9 @@ function _renderRegistryResults() {
moreBtn.style.display = "";
countEl.textContent = isFiltered
? visibleCount +
" of " +
_registryResults.length +
" loaded (more available)"
" of " +
_registryResults.length +
" loaded (more available)"
: "Showing " + visibleCount + " results";
} else {
pagEl.style.display = visibleCount > 0 ? "" : "none";
@@ -4004,8 +4020,8 @@ function _showInstallMcpModal(srv, hasRemote, hasPackage) {
"</div>" +
(srv.description
? '<div class="mcp-install-summary-desc">' +
escapeHtml(srv.description) +
"</div>"
escapeHtml(srv.description) +
"</div>"
: "");
// Source selector (only if both remote AND package)
@@ -4325,7 +4341,7 @@ function _pollInstallStatus(serverId, serverName, attempt) {
_pollInstallStatus(serverId, serverName, attempt + 1);
}
})
.catch(function () {});
.catch(function () { });
}, 3000);
}
@@ -4570,6 +4586,7 @@ function showCreateModelModal() {
document.getElementById("model-detect-btn").disabled = false;
document.getElementById("model-detect-btn").textContent = "Detect";
_refreshModelSuggestions();
_applyProviderDefaults();
document.getElementById("model-alias").focus();
_modelCreateTrap = _installTrap("model-create-overlay", "model-create-box");
}
@@ -4606,6 +4623,7 @@ function showEditModelModal(definitionId) {
if (caps === "{}") caps = "";
document.getElementById("model-capabilities").value = caps;
document.getElementById("model-enabled").checked = m.enabled !== false;
_applyProviderDefaults();
})
.catch(function () {
showToast("Failed to load model details");
@@ -4780,6 +4798,18 @@ function detectModel() {
}
resultDiv.appendChild(_detectResultLine(msg, "yellow"));
}
if (d.available_models && d.available_models.length > 0) {
var dl = document.getElementById("model-name-suggestions");
if (dl) {
dl.textContent = "";
d.available_models.forEach(function (m) {
var opt = document.createElement("option");
opt.value = m;
dl.appendChild(opt);
});
}
}
if (d.context_window) {
resultDiv.appendChild(
_detectResultLine(
@@ -4826,9 +4856,9 @@ function _onModelFieldChange() {
if (!modelName) return;
authFetch(
"/v1/api/admin/model-capabilities?provider=" +
encodeURIComponent(provider) +
"&model=" +
encodeURIComponent(modelName),
encodeURIComponent(provider) +
"&model=" +
encodeURIComponent(modelName),
)
.then(function (r) {
return r.json();
@@ -4859,6 +4889,23 @@ function _onModelFieldChange() {
});
}, 500);
}
/* Provider-specific placeholder hints for base_url and model ID fields. */
var _providerDefaults = {
openai: { urlPlaceholder: "https://api.openai.com/v1", modelPlaceholder: "gpt-5" },
anthropic: { urlPlaceholder: "https://api.anthropic.com", modelPlaceholder: "claude-" },
google: { urlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/", modelPlaceholder: "gemini-" },
"openai-compatible": { urlPlaceholder: "e.g. https://your-provider.com/v1", modelPlaceholder: "GLM5" },
};
/* Update placeholders when provider changes. */
function _applyProviderDefaults() {
var provider = document.getElementById("model-provider").value;
var def = _providerDefaults[provider];
if (!def) return;
document.getElementById("model-base-url").placeholder = def.urlPlaceholder;
document.getElementById("model-name").placeholder = def.modelPlaceholder;
}
/* Populate the model name datalist with known model prefixes for the
selected provider. Called on page load and provider change. */
function _refreshModelSuggestions() {
@@ -4867,7 +4914,7 @@ function _refreshModelSuggestions() {
var provider = document.getElementById("model-provider").value;
authFetch(
"/v1/api/admin/model-capabilities/known?provider=" +
encodeURIComponent(provider),
encodeURIComponent(provider),
)
.then(function (r) {
return r.json();
@@ -4894,6 +4941,7 @@ function _refreshModelSuggestions() {
provEl.addEventListener("change", _onModelFieldChange);
provEl.addEventListener("change", _refreshModelSuggestions);
provEl.addEventListener("change", _clearDetectResult);
provEl.addEventListener("change", _applyProviderDefaults);
}
/* Clear stale detect results when probe-relevant inputs change */
["model-base-url", "model-api-key"].forEach(function (id) {
+25 -1
View File
@@ -11,6 +11,13 @@ window.onLogout = function () {
window.onThemeChange = function (next) {
var btn = document.getElementById("theme-toggle");
if (btn) btn.textContent = next === "light" ? "\u2600" : "\u263E";
// Persist to server so admin settings and node UIs see the change
var themeValue = next === "light" ? "light" : "dark";
authFetch("/v1/api/admin/settings/interface.theme", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: themeValue }),
}).catch(function () {});
};
// Set initial theme button text
(function () {
@@ -1114,7 +1121,7 @@ function renderWsTable(container, wsList) {
// NAME
var nameCell = document.createElement("span");
nameCell.className = "dash-cell-name";
nameCell.textContent = ws.name || ws.id || "";
nameCell.textContent = ws.name || ws.title || ws.id || "";
main.appendChild(nameCell);
// MODEL
@@ -1281,11 +1288,20 @@ function showNewWsModal() {
});
// Populate model dropdown
var modelSelect = document.getElementById("new-ws-model");
var judgeSelect = document.getElementById("new-ws-judge");
modelSelect.textContent = "";
judgeSelect.textContent = "";
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "Default model";
modelSelect.appendChild(defaultOpt);
var defaultJudgeOpt = document.createElement("option");
defaultJudgeOpt.value = "";
defaultJudgeOpt.textContent = "Default (same as agent)";
judgeSelect.appendChild(defaultJudgeOpt);
authFetch("/v1/api/models")
.then(function (r) {
return r.json();
@@ -1297,6 +1313,11 @@ function showNewWsModal() {
opt.textContent =
m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
modelSelect.appendChild(opt);
var jOpt = document.createElement("option");
jOpt.value = m.alias;
jOpt.textContent = opt.textContent;
judgeSelect.appendChild(jOpt);
});
})
.catch(function () {
@@ -1304,6 +1325,7 @@ function showNewWsModal() {
});
document.getElementById("new-ws-name").value = "";
modelSelect.value = "";
judgeSelect.value = "";
var taskEl = document.getElementById("new-ws-task");
taskEl.value = "";
var mod =
@@ -1363,6 +1385,7 @@ function submitNewWs() {
var nodeId = document.getElementById("new-ws-node").value;
var name = document.getElementById("new-ws-name").value.trim();
var model = document.getElementById("new-ws-model").value.trim();
var judgeModel = document.getElementById("new-ws-judge").value.trim();
var skill = document.getElementById("new-ws-skill").value;
var task = document.getElementById("new-ws-task").value.trim();
var errEl = document.getElementById("new-ws-error");
@@ -1376,6 +1399,7 @@ function submitNewWs() {
if (nodeId) body.node_id = nodeId;
if (name) body.name = name;
if (model) body.model = model;
if (judgeModel) body.judge_model = judgeModel;
if (task) body.initial_message = task;
if (skill) body.skill = skill;
+5
View File
@@ -791,6 +791,10 @@ window.TURNSTONE_KB_SHORTCUTS = [
<select id="new-ws-skill">
<option value="">Use defaults</option>
</select>
<label for="new-ws-judge">Judge Model <span class="label-hint">optional</span></label>
<select id="new-ws-judge">
<option value="">Default (same as agent)</option>
</select>
<div id="new-ws-buttons">
<button id="new-ws-cancel" onclick="hideNewWsModal()">Cancel</button>
<button id="new-ws-submit" onclick="submitNewWs()">Create</button>
@@ -1521,6 +1525,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
<select id="model-provider">
<option value="openai">openai</option>
<option value="anthropic">anthropic</option>
<option value="google">google</option>
<option value="openai-compatible">openai-compatible</option>
</select>
<label for="model-base-url">Base URL <span style="font-weight:400;text-transform:none">(empty = provider default)</span></label>