876 lines
33 KiB
JavaScript
876 lines
33 KiB
JavaScript
// Custom render for the PromptChain.AI setting row. Three top-level
|
|
// options: None / Cloud / Local. Cloud drills down into a service
|
|
// picker (Claude, OpenAI, Grok, Gemini, OpenRouter, DeepSeek, Groq,
|
|
// Mistral, Other). Local is Ollama / llama.cpp / LM Studio / etc.
|
|
//
|
|
// API keys are posted once and stored server-side — the browser never
|
|
// receives them back (responses carry has_key flags instead).
|
|
|
|
const CLOUD_SERVICES = [
|
|
{
|
|
id: "claude",
|
|
label: "Claude (Anthropic)",
|
|
help: "https://console.anthropic.com/settings/keys",
|
|
keyPlaceholder: "sk-ant-...",
|
|
models: [
|
|
{ id: "claude-haiku-4-5", label: "Haiku 4.5 (fast, cheap)" },
|
|
{ id: "claude-sonnet-4-6", label: "Sonnet 4.6 (balanced)" },
|
|
{ id: "claude-opus-4-7", label: "Opus 4.7 (best)" },
|
|
],
|
|
},
|
|
{
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
help: "https://platform.openai.com/api-keys",
|
|
keyPlaceholder: "sk-proj-...",
|
|
detectable: true,
|
|
modelPlaceholder: "gpt-5 / gpt-5-mini / gpt-4o / ...",
|
|
},
|
|
{
|
|
id: "grok",
|
|
label: "xAI (Grok)",
|
|
help: "https://console.x.ai/",
|
|
keyPlaceholder: "xai-...",
|
|
models: [
|
|
{ id: "grok-4", label: "Grok 4" },
|
|
{ id: "grok-4-fast", label: "Grok 4 Fast" },
|
|
{ id: "grok-code-fast-1", label: "Grok Code Fast 1" },
|
|
],
|
|
detectable: true,
|
|
},
|
|
{
|
|
id: "gemini",
|
|
label: "Google Gemini",
|
|
help: "https://aistudio.google.com/app/apikey",
|
|
keyPlaceholder: "AIza...",
|
|
models: [
|
|
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
|
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
|
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
|
],
|
|
},
|
|
{
|
|
id: "openrouter",
|
|
label: "OpenRouter",
|
|
help: "https://openrouter.ai/settings/keys",
|
|
keyPlaceholder: "sk-or-v1-...",
|
|
detectable: true,
|
|
modelPlaceholder: "anthropic/claude-sonnet-4-5 / openai/gpt-5 / ...",
|
|
hint: "One key → Claude, GPT, Grok, Gemini, Llama, and many more.",
|
|
},
|
|
{
|
|
id: "deepseek",
|
|
label: "DeepSeek",
|
|
help: "https://platform.deepseek.com/api_keys",
|
|
keyPlaceholder: "sk-...",
|
|
models: [
|
|
{ id: "deepseek-chat", label: "DeepSeek Chat" },
|
|
{ id: "deepseek-reasoner", label: "DeepSeek Reasoner" },
|
|
],
|
|
},
|
|
{
|
|
id: "groq",
|
|
label: "Groq (fast inference)",
|
|
help: "https://console.groq.com/keys",
|
|
keyPlaceholder: "gsk_...",
|
|
detectable: true,
|
|
modelPlaceholder: "llama-3.3-70b-versatile / ...",
|
|
},
|
|
{
|
|
id: "mistral",
|
|
label: "Mistral",
|
|
help: "https://console.mistral.ai/api-keys/",
|
|
keyPlaceholder: "...",
|
|
detectable: true,
|
|
modelPlaceholder: "mistral-large-latest / ...",
|
|
},
|
|
{
|
|
id: "other",
|
|
label: "Other OpenAI-compatible",
|
|
help: null,
|
|
keyPlaceholder: "(if your provider needs one)",
|
|
detectable: true,
|
|
requiresBaseUrl: true,
|
|
modelPlaceholder: "model name",
|
|
},
|
|
];
|
|
|
|
function serviceById(id) {
|
|
return CLOUD_SERVICES.find(s => s.id === id) || CLOUD_SERVICES[0];
|
|
}
|
|
|
|
const INPUT_STYLE = "padding:6px 10px;background:var(--comfy-input-bg, #2a2a2a);border:1px solid var(--border-color, #555);border-radius:4px;color:var(--input-text, #ddd);font-size:13px;outline:none;box-sizing:border-box;";
|
|
const BTN_STYLE = "padding:5px 14px;border:none;border-radius:4px;background:var(--comfy-input-bg, #333);color:var(--input-text, #ddd);font-size:12px;cursor:pointer;white-space:nowrap;";
|
|
|
|
export function renderAiSetting() {
|
|
const wrap = document.createElement("div");
|
|
wrap.style.cssText = "display:flex;flex-direction:column;gap:10px;width:100%;";
|
|
|
|
// Provider radios — immediately interactive. Only the Local block does
|
|
// Ollama detection, and it shows its own "checking" state internally;
|
|
// None/Cloud don't need detection and must never wait on it.
|
|
const radioRow = document.createElement("div");
|
|
radioRow.style.cssText = "display:flex;gap:16px;flex-wrap:wrap;";
|
|
const radios = {};
|
|
for (const [val, label] of [[null, "None"], ["cloud", "Cloud"], ["local", "Local (Ollama / llama.cpp / LM Studio)"]]) {
|
|
const lbl = document.createElement("label");
|
|
lbl.style.cssText = "display:flex;align-items:center;gap:6px;cursor:pointer;";
|
|
const input = document.createElement("input");
|
|
input.type = "radio";
|
|
input.name = "pcr-ai-provider";
|
|
input.value = val ?? "";
|
|
const span = document.createElement("span");
|
|
span.textContent = label;
|
|
span.style.cssText = "font-size:13px;color:var(--input-text, #ddd);";
|
|
lbl.append(input, span);
|
|
radioRow.append(lbl);
|
|
radios[val ?? "none"] = input;
|
|
}
|
|
wrap.append(radioRow);
|
|
|
|
const cloudBlock = buildCloudBlock();
|
|
wrap.append(cloudBlock.el);
|
|
|
|
// When the guided setup finishes downloading the recommended model, flip
|
|
// the provider to Local, select that model, and persist — so a user who
|
|
// came in with nothing is fully configured without touching the form.
|
|
const localBlock = buildLocalBlock((modelName) => {
|
|
setProviderUI("local");
|
|
localBlock.modelInput.value = modelName;
|
|
save({ provider: "local" });
|
|
});
|
|
wrap.append(localBlock.el);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.style.cssText = "display:flex;align-items:center;gap:10px;";
|
|
const saveBtn = document.createElement("button");
|
|
saveBtn.textContent = "Save";
|
|
saveBtn.style.cssText = BTN_STYLE + "background:#0e639c;color:#fff;";
|
|
const testBtn = document.createElement("button");
|
|
testBtn.textContent = "Test connection";
|
|
testBtn.style.cssText = BTN_STYLE;
|
|
const status = document.createElement("span");
|
|
status.style.cssText = "font-size:12px;color:#888;";
|
|
actions.append(saveBtn, testBtn, status);
|
|
wrap.append(actions);
|
|
|
|
let currentProvider = null;
|
|
let cloudHasKey = false;
|
|
|
|
function updateVisibility() {
|
|
cloudBlock.el.style.display = currentProvider === "cloud" ? "" : "none";
|
|
localBlock.el.style.display = currentProvider === "local" ? "" : "none";
|
|
actions.style.display = currentProvider ? "" : "none";
|
|
}
|
|
|
|
function setProviderUI(p) {
|
|
currentProvider = p;
|
|
radios[p ?? "none"].checked = true;
|
|
updateVisibility();
|
|
}
|
|
|
|
for (const input of Object.values(radios)) {
|
|
input.addEventListener("change", () => {
|
|
if (!input.checked) return;
|
|
currentProvider = input.value || null;
|
|
updateVisibility();
|
|
save({ provider: currentProvider });
|
|
});
|
|
}
|
|
|
|
async function loadConfig() {
|
|
try {
|
|
let cfg = await (await fetch("/promptchain/ai/config")).json();
|
|
// First-run: default to Local if Ollama already serves the recommended
|
|
// model, then re-read so the UI reflects it.
|
|
if (!cfg.provider) {
|
|
try {
|
|
const auto = await (await fetch("/promptchain/ai/auto-configure", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "{}",
|
|
})).json();
|
|
if (auto.configured) cfg = await (await fetch("/promptchain/ai/config")).json();
|
|
} catch {}
|
|
}
|
|
setProviderUI(cfg.provider || null);
|
|
|
|
const cloudService = cfg.cloud?.service || "claude";
|
|
cloudHasKey = !!cfg.cloud?.has_key;
|
|
cloudBlock.applyConfig({
|
|
service: cloudService,
|
|
model: cfg.cloud?.model || "",
|
|
baseUrl: cfg.cloud?.base_url || "",
|
|
hasKey: cloudHasKey,
|
|
});
|
|
|
|
localBlock.urlInput.value = cfg.local?.base_url || "http://localhost:11434/v1";
|
|
localBlock.modelInput.value = cfg.local?.model || "";
|
|
localBlock.autoStartInput.checked = cfg.local?.auto_start !== false;
|
|
} catch {
|
|
status.textContent = "✗ could not load config";
|
|
status.style.color = "#e55";
|
|
}
|
|
}
|
|
|
|
function buildPayload(overrides = {}) {
|
|
const body = { provider: "provider" in overrides ? overrides.provider : currentProvider };
|
|
const activeProvider = body.provider;
|
|
if (activeProvider === "cloud" || currentProvider === "cloud") {
|
|
const cloudState = cloudBlock.readState();
|
|
const cloud = {
|
|
service: cloudState.service,
|
|
model: cloudState.model,
|
|
};
|
|
if (cloudState.key) cloud.api_key = cloudState.key;
|
|
if (cloudState.baseUrl) cloud.base_url = cloudState.baseUrl;
|
|
body.cloud = cloud;
|
|
}
|
|
if (activeProvider === "local" || currentProvider === "local") {
|
|
body.local = {
|
|
base_url: localBlock.urlInput.value.trim(),
|
|
model: localBlock.modelInput.value.trim(),
|
|
auto_start: localBlock.autoStartInput.checked,
|
|
};
|
|
}
|
|
return body;
|
|
}
|
|
|
|
async function save(overrides = {}) {
|
|
const body = buildPayload(overrides);
|
|
try {
|
|
const r = await fetch("/promptchain/ai/config", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const cfg = await r.json();
|
|
cloudHasKey = !!cfg.cloud?.has_key;
|
|
cloudBlock.clearKey(cloudHasKey);
|
|
// Let already-mounted nodes re-attempt their auto-open now that a
|
|
// provider exists (e.g. user configured AI during onboarding).
|
|
if (cfg.provider) window.dispatchEvent(new CustomEvent("promptchain:ai-configured"));
|
|
status.textContent = "✓ saved";
|
|
status.style.color = "#4ec96b";
|
|
setTimeout(() => {
|
|
if (status.textContent === "✓ saved") status.textContent = "";
|
|
}, 1500);
|
|
} catch {
|
|
status.textContent = "✗ save failed";
|
|
status.style.color = "#e55";
|
|
}
|
|
}
|
|
|
|
async function test() {
|
|
status.textContent = "testing...";
|
|
status.style.color = "#888";
|
|
const body = buildPayload();
|
|
try {
|
|
const r = await fetch("/promptchain/ai/test", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await r.json();
|
|
if (data.ok) {
|
|
status.textContent = "✓ connected";
|
|
status.style.color = "#4ec96b";
|
|
} else {
|
|
status.textContent = "✗ " + (data.error || "failed");
|
|
status.style.color = "#e55";
|
|
}
|
|
} catch (e) {
|
|
status.textContent = "✗ " + (e.message || "failed");
|
|
status.style.color = "#e55";
|
|
}
|
|
}
|
|
|
|
saveBtn.addEventListener("click", () => save());
|
|
testBtn.addEventListener("click", test);
|
|
|
|
loadConfig();
|
|
return wrap;
|
|
}
|
|
|
|
// ── cloud block ─────────────────────────────────────────────────────
|
|
|
|
function buildCloudBlock() {
|
|
const el = document.createElement("div");
|
|
el.style.cssText = "display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:rgba(255,255,255,0.03);border-radius:4px;";
|
|
|
|
// Service picker.
|
|
const svcRow = rowWithLabel("Service");
|
|
const svcSelect = document.createElement("select");
|
|
svcSelect.style.cssText = INPUT_STYLE + "flex:1;";
|
|
for (const s of CLOUD_SERVICES) {
|
|
const opt = document.createElement("option");
|
|
opt.value = s.id;
|
|
opt.textContent = s.label;
|
|
svcSelect.append(opt);
|
|
}
|
|
svcRow.row.append(svcSelect);
|
|
el.append(svcRow.row);
|
|
|
|
// Base URL row (only visible for "other").
|
|
const baseRow = rowWithLabel("Base URL");
|
|
const baseInput = document.createElement("input");
|
|
baseInput.type = "text";
|
|
baseInput.spellcheck = false;
|
|
baseInput.autocomplete = "off";
|
|
baseInput.placeholder = "https://api.example.com/v1";
|
|
baseInput.style.cssText = INPUT_STYLE + "flex:1;font-family:monospace;";
|
|
baseRow.row.append(baseInput);
|
|
el.append(baseRow.row);
|
|
|
|
// API key.
|
|
const keyRow = rowWithLabel("API key");
|
|
// type=text + CSS masking rather than type=password — avoids the browser's
|
|
// "save password?" prompt firing on these API-key fields.
|
|
const keyInput = document.createElement("input");
|
|
keyInput.type = "text";
|
|
keyInput.spellcheck = false;
|
|
keyInput.autocomplete = "off";
|
|
keyInput.style.cssText = INPUT_STYLE + "flex:1;font-family:monospace;-webkit-text-security:disc;";
|
|
const keyHint = document.createElement("a");
|
|
keyHint.target = "_blank";
|
|
keyHint.textContent = "get key";
|
|
keyHint.style.cssText = "font-size:12px;color:#5b9bd5;";
|
|
keyRow.row.append(keyInput, keyHint);
|
|
el.append(keyRow.row);
|
|
|
|
// Model.
|
|
const modelRow = rowWithLabel("Model");
|
|
const modelSelect = document.createElement("select");
|
|
modelSelect.style.cssText = INPUT_STYLE + "flex:1;";
|
|
const modelInput = document.createElement("input");
|
|
modelInput.type = "text";
|
|
modelInput.spellcheck = false;
|
|
modelInput.autocomplete = "off";
|
|
modelInput.setAttribute("list", "pcr-ai-cloud-models");
|
|
modelInput.style.cssText = INPUT_STYLE + "flex:1;font-family:monospace;";
|
|
const modelsDatalist = document.createElement("datalist");
|
|
modelsDatalist.id = "pcr-ai-cloud-models";
|
|
const detectBtn = document.createElement("button");
|
|
detectBtn.textContent = "Detect";
|
|
detectBtn.style.cssText = BTN_STYLE;
|
|
modelRow.row.append(modelSelect, modelInput, modelsDatalist, detectBtn);
|
|
el.append(modelRow.row);
|
|
|
|
const detectStatus = document.createElement("div");
|
|
detectStatus.style.cssText = "font-size:11px;color:#888;padding-left:78px;";
|
|
el.append(detectStatus);
|
|
|
|
const hintEl = document.createElement("div");
|
|
hintEl.style.cssText = "font-size:11px;color:#888;padding-left:78px;";
|
|
el.append(hintEl);
|
|
|
|
function applyService(serviceId) {
|
|
const svc = serviceById(serviceId);
|
|
keyInput.placeholder = svc.keyPlaceholder || "";
|
|
if (svc.help) {
|
|
keyHint.href = svc.help;
|
|
keyHint.style.display = "";
|
|
} else {
|
|
keyHint.style.display = "none";
|
|
}
|
|
baseRow.row.style.display = svc.requiresBaseUrl ? "" : "none";
|
|
if (svc.models) {
|
|
modelSelect.textContent = "";
|
|
for (const m of svc.models) {
|
|
const opt = document.createElement("option");
|
|
opt.value = m.id;
|
|
opt.textContent = m.label;
|
|
modelSelect.append(opt);
|
|
}
|
|
modelSelect.style.display = "";
|
|
modelInput.style.display = "none";
|
|
detectBtn.style.display = svc.detectable ? "" : "none";
|
|
} else {
|
|
modelSelect.style.display = "none";
|
|
modelInput.style.display = "";
|
|
modelInput.placeholder = svc.modelPlaceholder || "model name";
|
|
detectBtn.style.display = svc.detectable ? "" : "none";
|
|
}
|
|
hintEl.textContent = svc.hint || "";
|
|
hintEl.style.display = svc.hint ? "" : "none";
|
|
detectStatus.textContent = "";
|
|
}
|
|
|
|
async function detectModels() {
|
|
const svc = serviceById(svcSelect.value);
|
|
detectStatus.textContent = "detecting...";
|
|
detectStatus.style.color = "#888";
|
|
const baseUrl = svc.requiresBaseUrl
|
|
? baseInput.value.trim().replace(/\/+$/, "")
|
|
: null; // server looks up the registry entry
|
|
// For cloud services, backend uses the registry unless requiresBaseUrl
|
|
// is set. Passing base_url only for "other" keeps the detect endpoint
|
|
// simple.
|
|
const body = {
|
|
base_url: baseUrl || _registryBaseUrlForClient(svc.id),
|
|
api_key: keyInput.value.trim() || undefined,
|
|
};
|
|
try {
|
|
const r = await fetch("/promptchain/ai/detect-models", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await r.json();
|
|
const models = data.models || [];
|
|
if (models.length === 0) {
|
|
detectStatus.textContent = data.error ? "✗ " + data.error : "no models found";
|
|
detectStatus.style.color = "#e55";
|
|
return;
|
|
}
|
|
modelsDatalist.textContent = "";
|
|
for (const m of models) {
|
|
const opt = document.createElement("option");
|
|
opt.value = m.name;
|
|
modelsDatalist.append(opt);
|
|
}
|
|
if (!modelInput.value && models[0]) modelInput.value = models[0].name;
|
|
detectStatus.textContent = `✓ ${models.length} model${models.length === 1 ? "" : "s"}`;
|
|
detectStatus.style.color = "#4ec96b";
|
|
} catch (e) {
|
|
detectStatus.textContent = "✗ " + (e.message || "failed");
|
|
detectStatus.style.color = "#e55";
|
|
}
|
|
}
|
|
|
|
svcSelect.addEventListener("change", () => applyService(svcSelect.value));
|
|
detectBtn.addEventListener("click", detectModels);
|
|
|
|
applyService("claude");
|
|
|
|
return {
|
|
el,
|
|
applyConfig({ service, model, baseUrl, hasKey }) {
|
|
svcSelect.value = service;
|
|
applyService(service);
|
|
baseInput.value = baseUrl || "";
|
|
keyInput.value = "";
|
|
keyInput.placeholder = hasKey
|
|
? "•••••••• (key saved — blank to keep)"
|
|
: (serviceById(service).keyPlaceholder || "");
|
|
const svc = serviceById(service);
|
|
if (svc.models) modelSelect.value = model || svc.models[0].id;
|
|
else modelInput.value = model || "";
|
|
},
|
|
clearKey(hasKey) {
|
|
keyInput.value = "";
|
|
const svc = serviceById(svcSelect.value);
|
|
keyInput.placeholder = hasKey
|
|
? "•••••••• (key saved — blank to keep)"
|
|
: (svc.keyPlaceholder || "");
|
|
},
|
|
readState() {
|
|
const svc = serviceById(svcSelect.value);
|
|
const model = svc.models ? modelSelect.value : modelInput.value.trim();
|
|
return {
|
|
service: svcSelect.value,
|
|
model,
|
|
key: keyInput.value.trim(),
|
|
baseUrl: svc.requiresBaseUrl ? baseInput.value.trim().replace(/\/+$/, "") : "",
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
// Mirror of the server's registry, used only so the Detect button in
|
|
// the UI can POST a concrete base_url without a round trip. Kept short;
|
|
// adding a new service means editing both places (one-time maintenance).
|
|
function _registryBaseUrlForClient(serviceId) {
|
|
return {
|
|
openai: "https://api.openai.com/v1",
|
|
grok: "https://api.x.ai/v1",
|
|
gemini: "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
openrouter: "https://openrouter.ai/api/v1",
|
|
deepseek: "https://api.deepseek.com/v1",
|
|
groq: "https://api.groq.com/openai/v1",
|
|
mistral: "https://api.mistral.ai/v1",
|
|
}[serviceId] || "";
|
|
}
|
|
|
|
function rowWithLabel(labelText) {
|
|
const row = document.createElement("div");
|
|
row.style.cssText = "display:flex;align-items:center;gap:8px;";
|
|
const lbl = document.createElement("span");
|
|
lbl.textContent = labelText;
|
|
lbl.style.cssText = "font-size:12px;color:#888;min-width:70px;";
|
|
row.append(lbl);
|
|
return { row, label: lbl };
|
|
}
|
|
|
|
// ── local block ─────────────────────────────────────────────────────
|
|
|
|
// Reads an SSE POST stream, invoking onEvent for each parsed `data:` payload.
|
|
// Endpoints that fail before streaming return a JSON {error} with a non-2xx
|
|
// status instead — surfaced as a thrown Error.
|
|
async function streamSSE(url, body, onEvent) {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body || {}),
|
|
});
|
|
if (!res.ok) {
|
|
let msg = `HTTP ${res.status}`;
|
|
try { msg = (await res.json()).error || msg; } catch {}
|
|
throw new Error(msg);
|
|
}
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = "";
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
let sep;
|
|
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
const frame = buf.slice(0, sep);
|
|
buf = buf.slice(sep + 2);
|
|
const data = frame.split("\n").find(l => l.startsWith("data:"));
|
|
if (!data) continue;
|
|
try { onEvent(JSON.parse(data.slice(5).trim())); } catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildLocalBlock(onModelReady) {
|
|
const el = document.createElement("div");
|
|
el.style.cssText = "display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:rgba(255,255,255,0.03);border-radius:4px;";
|
|
|
|
// ── guided setup: detect Ollama, offer install + recommended-model pull ──
|
|
const setup = document.createElement("div");
|
|
setup.style.cssText = "display:flex;flex-direction:column;gap:8px;padding:10px;background:rgba(91,155,213,0.08);border:1px solid rgba(91,155,213,0.25);border-radius:4px;";
|
|
|
|
const setupStatus = document.createElement("div");
|
|
setupStatus.style.cssText = "font-size:12px;color:var(--input-text, #ddd);display:flex;flex-direction:column;gap:3px;";
|
|
|
|
const setupActions = document.createElement("div");
|
|
setupActions.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
|
|
|
|
const progressWrap = document.createElement("div");
|
|
progressWrap.style.cssText = "display:none;flex-direction:column;gap:4px;";
|
|
const progressTrack = document.createElement("div");
|
|
progressTrack.style.cssText = "height:6px;border-radius:3px;background:rgba(255,255,255,0.12);overflow:hidden;";
|
|
const progressBar = document.createElement("div");
|
|
progressBar.style.cssText = "height:100%;width:0%;background:#5b9bd5;transition:width 0.2s;";
|
|
progressTrack.append(progressBar);
|
|
const progressText = document.createElement("div");
|
|
progressText.style.cssText = "font-size:11px;color:#aaa;";
|
|
progressWrap.append(progressTrack, progressText);
|
|
|
|
setup.append(setupStatus, setupActions, progressWrap);
|
|
|
|
// One status line: a fixed dot beside text that wraps on its own. Kept as a
|
|
// single flex row so the dot never detaches from its label.
|
|
function statusRow(ok, html) {
|
|
const row = document.createElement("div");
|
|
row.style.cssText = "display:flex;align-items:flex-start;gap:7px;line-height:1.45;";
|
|
const d = document.createElement("span");
|
|
d.style.cssText = `color:${ok ? "#4ec96b" : "#888"};flex:none;`;
|
|
d.textContent = ok ? "●" : "○";
|
|
const t = document.createElement("span");
|
|
t.innerHTML = html;
|
|
row.append(d, t);
|
|
return row;
|
|
}
|
|
|
|
function showProgress(pct, text) {
|
|
progressWrap.style.display = "flex";
|
|
if (pct != null) progressBar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
|
|
progressText.textContent = text || "";
|
|
}
|
|
function hideProgress() { progressWrap.style.display = "none"; }
|
|
|
|
async function refreshSetup() {
|
|
setupStatus.innerHTML = "<span style='color:#888;'>Checking local setup…</span>";
|
|
setupActions.replaceChildren();
|
|
let s;
|
|
try {
|
|
s = await (await fetch("/promptchain/ai/setup-status")).json();
|
|
} catch {
|
|
setupStatus.innerHTML = "<span style='color:#e55;'>Couldn't check setup status.</span>";
|
|
manual.style.display = "flex"; // reveal so manual config is still possible
|
|
return;
|
|
}
|
|
renderSetup(s);
|
|
manual.style.display = "flex";
|
|
}
|
|
|
|
function renderSetup(s) {
|
|
setupStatus.replaceChildren(
|
|
statusRow(s.ollama_installed,
|
|
`Ollama ${s.ollama_installed ? (s.ollama_running ? "running" : "installed") : "not installed"}`),
|
|
statusRow(s.model_present,
|
|
`Recommended model <code>${s.recommended_model}</code> — ${s.model_present ? "ready" : "not downloaded"}`),
|
|
);
|
|
setupActions.replaceChildren();
|
|
|
|
if (s.model_present && s.ollama_running) {
|
|
const ready = document.createElement("span");
|
|
ready.style.cssText = "font-size:12px;color:#4ec96b;";
|
|
ready.textContent = "✓ Ready to use the AI Assistant locally.";
|
|
setupActions.append(ready);
|
|
return;
|
|
}
|
|
|
|
if (!s.ollama_installed) {
|
|
if (s.winget_available) {
|
|
const installBtn = document.createElement("button");
|
|
installBtn.textContent = "Install Ollama";
|
|
installBtn.style.cssText = BTN_STYLE + "background:#0e639c;color:#fff;";
|
|
installBtn.addEventListener("click", () => installOllama(installBtn));
|
|
setupActions.append(installBtn);
|
|
const note = document.createElement("span");
|
|
note.style.cssText = "font-size:11px;color:#888;";
|
|
note.textContent = "via winget — may ask for permission.";
|
|
setupActions.append(note);
|
|
} else {
|
|
const link = document.createElement("a");
|
|
link.href = "https://ollama.com/download";
|
|
link.target = "_blank";
|
|
link.textContent = "Download Ollama →";
|
|
link.style.cssText = "font-size:12px;color:#5b9bd5;";
|
|
setupActions.append(link);
|
|
const note = document.createElement("span");
|
|
note.style.cssText = "font-size:11px;color:#888;";
|
|
note.textContent = "then click Re-check.";
|
|
setupActions.append(note);
|
|
}
|
|
} else if (!s.model_present) {
|
|
const pullBtn = document.createElement("button");
|
|
pullBtn.textContent = `Download ${s.recommended_model}`;
|
|
pullBtn.style.cssText = BTN_STYLE + "background:#0e639c;color:#fff;";
|
|
pullBtn.addEventListener("click", () => pullModel(pullBtn, s.recommended_model));
|
|
setupActions.append(pullBtn);
|
|
const note = document.createElement("span");
|
|
note.style.cssText = "font-size:11px;color:#888;";
|
|
note.textContent = "~6 GB download.";
|
|
setupActions.append(note);
|
|
}
|
|
|
|
const recheck = document.createElement("button");
|
|
recheck.textContent = "Re-check";
|
|
recheck.style.cssText = BTN_STYLE;
|
|
recheck.addEventListener("click", refreshSetup);
|
|
setupActions.append(recheck);
|
|
|
|
// Already-running non-Ollama servers (LM Studio, llama.cpp, …). We can
|
|
// use these over the OpenAI-compat path even though we can't manage them.
|
|
for (const srv of (s.detected_servers || [])) {
|
|
const altRow = document.createElement("div");
|
|
altRow.style.cssText = "display:flex;align-items:center;gap:8px;font-size:12px;color:#aaa;margin-top:2px;";
|
|
const text = document.createElement("span");
|
|
text.innerHTML = `Found <strong>${srv.label}</strong> on <code>:${srv.port}</code>`;
|
|
const useBtn = document.createElement("button");
|
|
useBtn.textContent = "Use it";
|
|
useBtn.style.cssText = BTN_STYLE;
|
|
useBtn.addEventListener("click", () => useDetectedServer(useBtn, srv));
|
|
altRow.append(text, useBtn);
|
|
setupActions.append(altRow);
|
|
}
|
|
}
|
|
|
|
async function useDetectedServer(btn, srv) {
|
|
btn.disabled = true;
|
|
btn.textContent = "Connecting…";
|
|
urlInput.value = srv.base_url;
|
|
await detectLocal();
|
|
const model = modelInput.value;
|
|
if (model) {
|
|
onModelReady?.(model);
|
|
btn.textContent = "✓ Connected";
|
|
} else {
|
|
btn.disabled = false;
|
|
btn.textContent = "Use it";
|
|
}
|
|
}
|
|
|
|
async function installOllama(btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = "Installing…";
|
|
showProgress(null, "Installing Ollama via winget…");
|
|
try {
|
|
await streamSSE("/promptchain/ai/install-ollama", {}, (evt) => {
|
|
if (evt.line) progressText.textContent = evt.line;
|
|
if (evt.error) progressText.textContent = "✗ " + evt.error;
|
|
if (evt.done) progressText.textContent = evt.ok ? "✓ Installed." : "✗ Install failed.";
|
|
});
|
|
} catch (e) {
|
|
progressText.textContent = "✗ " + (e.message || "install failed");
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = "Install Ollama";
|
|
setTimeout(hideProgress, 1500);
|
|
refreshSetup();
|
|
}
|
|
}
|
|
|
|
async function pullModel(btn, model) {
|
|
btn.disabled = true;
|
|
btn.textContent = "Downloading…";
|
|
showProgress(0, "Starting download…");
|
|
let ok = false;
|
|
try {
|
|
await streamSSE("/promptchain/ai/pull-model", { model }, (evt) => {
|
|
if (evt.error) { progressText.textContent = "✗ " + evt.error; return; }
|
|
if (evt.total && evt.completed != null) {
|
|
showProgress((evt.completed / evt.total) * 100,
|
|
`${evt.status || "downloading"} — ${fmtBytes(evt.completed)} / ${fmtBytes(evt.total)}`);
|
|
} else if (evt.status) {
|
|
progressText.textContent = evt.status;
|
|
}
|
|
if (evt.done) ok = true;
|
|
});
|
|
} catch (e) {
|
|
progressText.textContent = "✗ " + (e.message || "download failed");
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = `Download ${model}`;
|
|
if (ok) {
|
|
showProgress(100, "✓ Downloaded.");
|
|
urlInput.value = urlInput.value.trim() || "http://localhost:11434/v1";
|
|
setModel(model);
|
|
onModelReady?.(model);
|
|
}
|
|
setTimeout(hideProgress, 1500);
|
|
refreshSetup();
|
|
}
|
|
}
|
|
|
|
el.append(setup);
|
|
|
|
const urlRow = rowWithLabel("Base URL");
|
|
const urlInput = document.createElement("input");
|
|
urlInput.type = "text";
|
|
urlInput.spellcheck = false;
|
|
urlInput.autocomplete = "off";
|
|
urlInput.style.cssText = INPUT_STYLE + "flex:1;font-family:monospace;";
|
|
urlInput.placeholder = "http://localhost:11434/v1";
|
|
urlRow.row.append(urlInput);
|
|
|
|
const urlHint = document.createElement("div");
|
|
urlHint.style.cssText = "font-size:11px;color:#888;padding-left:78px;";
|
|
urlHint.innerHTML = "Ollama: <code>http://localhost:11434/v1</code> · llama.cpp: <code>:8080/v1</code> · LM Studio: <code>:1234/v1</code>";
|
|
|
|
const modelRow = rowWithLabel("Model");
|
|
const modelSelect = document.createElement("select");
|
|
modelSelect.style.cssText = INPUT_STYLE + "flex:1;font-family:monospace;";
|
|
const detectBtn = document.createElement("button");
|
|
detectBtn.textContent = "Detect";
|
|
detectBtn.style.cssText = BTN_STYLE;
|
|
modelRow.row.append(modelSelect, detectBtn);
|
|
|
|
const detectStatus = document.createElement("div");
|
|
detectStatus.style.cssText = "font-size:11px;color:#888;padding-left:78px;";
|
|
|
|
// Auto-start toggle — when Ollama isn't reachable on panel open, the
|
|
// probe spawns `ollama serve` once instead of just showing an error.
|
|
// Off for users who manage Ollama externally (docker, systemd, etc.).
|
|
const autoStartRow = document.createElement("label");
|
|
autoStartRow.style.cssText = "display:flex;align-items:center;gap:8px;padding-left:78px;cursor:pointer;";
|
|
const autoStartInput = document.createElement("input");
|
|
autoStartInput.type = "checkbox";
|
|
autoStartInput.checked = true;
|
|
const autoStartLabel = document.createElement("span");
|
|
autoStartLabel.textContent = "Auto-start Ollama when offline";
|
|
autoStartLabel.style.cssText = "font-size:12px;color:var(--input-text, #ddd);";
|
|
autoStartRow.append(autoStartInput, autoStartLabel);
|
|
const autoStartHint = document.createElement("div");
|
|
autoStartHint.style.cssText = "font-size:11px;color:#888;padding-left:78px;";
|
|
autoStartHint.textContent = "If PromptChain can't reach Ollama, it'll try `ollama serve` once before erroring.";
|
|
|
|
// Manual config (URL / model / auto-start) stays hidden until detection
|
|
// resolves, so the user never edits these before their state is known.
|
|
const manual = document.createElement("div");
|
|
manual.style.cssText = "display:none;flex-direction:column;gap:8px;";
|
|
manual.append(urlRow.row, urlHint, modelRow.row, detectStatus, autoStartRow, autoStartHint);
|
|
el.append(manual);
|
|
|
|
// The select reflects what the server returned from /api/tags. Before
|
|
// Detect runs we only have the saved model name from config; render it
|
|
// as a single option so the control doesn't look empty on first paint.
|
|
function setModel(name) {
|
|
modelSelect.textContent = "";
|
|
if (!name) {
|
|
const placeholder = document.createElement("option");
|
|
placeholder.value = "";
|
|
placeholder.textContent = "click Detect to list installed models";
|
|
placeholder.disabled = true;
|
|
placeholder.selected = true;
|
|
modelSelect.append(placeholder);
|
|
return;
|
|
}
|
|
const opt = document.createElement("option");
|
|
opt.value = name;
|
|
opt.textContent = name;
|
|
modelSelect.append(opt);
|
|
modelSelect.value = name;
|
|
}
|
|
setModel("");
|
|
|
|
async function detectLocal() {
|
|
detectStatus.textContent = "detecting...";
|
|
detectStatus.style.color = "#888";
|
|
try {
|
|
const r = await fetch("/promptchain/ai/detect-models", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ base_url: urlInput.value.trim() }),
|
|
});
|
|
const data = await r.json();
|
|
const models = data.models || [];
|
|
if (models.length === 0) {
|
|
detectStatus.textContent = data.error ? "✗ " + data.error : "no models found";
|
|
detectStatus.style.color = "#e55";
|
|
return;
|
|
}
|
|
const previous = modelSelect.value;
|
|
modelSelect.textContent = "";
|
|
for (const m of models) {
|
|
const opt = document.createElement("option");
|
|
opt.value = m.name;
|
|
opt.textContent = m.vision ? `${m.name} (vision)` : m.name;
|
|
modelSelect.append(opt);
|
|
}
|
|
// Saved model no longer installed — keep it visible so the user
|
|
// can tell what happened, but flag it.
|
|
if (previous && !models.some(m => m.name === previous)) {
|
|
const opt = document.createElement("option");
|
|
opt.value = previous;
|
|
opt.textContent = `${previous} (not installed)`;
|
|
modelSelect.insertBefore(opt, modelSelect.firstChild);
|
|
}
|
|
modelSelect.value = previous && [...modelSelect.options].some(o => o.value === previous)
|
|
? previous
|
|
: models[0].name;
|
|
detectStatus.textContent = `✓ ${models.length} model${models.length === 1 ? "" : "s"} installed`;
|
|
detectStatus.style.color = "#4ec96b";
|
|
} catch (e) {
|
|
detectStatus.textContent = "✗ " + (e.message || "failed");
|
|
detectStatus.style.color = "#e55";
|
|
}
|
|
}
|
|
detectBtn.addEventListener("click", detectLocal);
|
|
|
|
// Keep the external .value get/set shape the outer renderAiSetting uses.
|
|
const modelInput = {
|
|
get value() { return modelSelect.value || ""; },
|
|
set value(v) { setModel(v); },
|
|
};
|
|
|
|
// Detection is scoped to this block (its status area shows its own
|
|
// "checking" state) — it never blocks the None/Cloud choices above.
|
|
refreshSetup();
|
|
return { el, urlInput, modelInput, autoStartInput, refreshSetup };
|
|
}
|
|
|
|
function fmtBytes(n) {
|
|
if (!n) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
|
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`;
|
|
}
|