versioning: 0.0.6, ux: move buttons, feat: add cloud providers, feat: increese tool call limit
Build Release EXE / build-windows-exe (release) Successful in 51s

This commit is contained in:
2026-05-08 14:48:51 -04:00
parent a5a718b3e4
commit 6bd1e81a51
14 changed files with 1103 additions and 198 deletions
+224 -34
View File
@@ -1,5 +1,6 @@
const form = document.getElementById("chat-form");
const input = document.getElementById("message-input");
const composerImagesEl = document.getElementById("composer-images");
const messages = document.getElementById("messages");
const statusEl = document.getElementById("status");
const pendingEl = document.getElementById("pending-actions");
@@ -25,6 +26,7 @@ const ollamaDownloadButton = document.getElementById("ollama-download");
const ollamaInstallButton = document.getElementById("ollama-install");
const ollamaLaunchButton = document.getElementById("ollama-launch");
const ollamaPullButton = document.getElementById("ollama-pull");
const openaiModelsRefreshButton = document.getElementById("openai-models-refresh");
const ollamaStatusEl = document.getElementById("ollama-status");
const ollamaMessageEl = document.getElementById("ollama-message");
const updateCheckButton = document.getElementById("update-check");
@@ -61,25 +63,53 @@ let latestUpdate = null;
let currentThreadId = "default";
let currentNegotiationId = null;
let latestOllamaStatus = null;
let composerImages = [];
const clickedOllamaActions = new Set();
if (window.lucide) {
window.lucide.createIcons();
}
function addMessage(role, text) {
function addMessage(role, text, options = {}) {
const node = document.createElement("div");
node.className = `message ${role}`;
setMessageMarkdown(node, text);
setMessageMarkdown(node, text, options);
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
return node;
}
function setMessageMarkdown(node, text) {
function setMessageMarkdown(node, text, options = {}) {
const body = node.querySelector(".message-body") || node;
body.innerHTML = renderMarkdown(text);
enhanceNegotiationLinks(body);
body.innerHTML = "";
const attachedImages = options.images || [];
if (attachedImages.length) {
body.appendChild(renderImageGallery(attachedImages));
}
if (text) {
const markdown = document.createElement("div");
markdown.innerHTML = renderMarkdown(text);
body.appendChild(markdown);
enhanceNegotiationLinks(markdown);
}
}
function renderImageGallery(images) {
const gallery = document.createElement("div");
gallery.className = "message-images";
for (const image of images) {
const card = document.createElement("div");
card.className = "message-image";
const preview = document.createElement("img");
preview.src = image.preview_url || `data:${image.content_type || "image/png"};base64,${image.image_data}`;
preview.alt = image.name || "Attached image";
const label = document.createElement("span");
label.className = "message-image-label";
label.textContent = image.name || "Attached image";
card.append(preview, label);
gallery.appendChild(card);
}
return gallery;
}
function setMessageActivity(node, text, active = false) {
@@ -459,6 +489,74 @@ function escapeHtml(text) {
.replace(/'/g, "'");
}
function composerImageId() {
if (window.crypto?.randomUUID) return window.crypto.randomUUID();
return `image-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error(`Could not read ${file.name || "image"}`));
reader.readAsDataURL(file);
});
}
async function addComposerImages(files) {
const additions = [];
for (const file of files) {
if (!file || !String(file.type || "").startsWith("image/")) continue;
const previewUrl = await readFileAsDataUrl(file);
const [, imageData = ""] = previewUrl.split(",", 2);
if (!imageData) continue;
additions.push({
id: composerImageId(),
name: file.name || `pasted-image-${composerImages.length + additions.length + 1}.png`,
content_type: file.type || "image/png",
image_data: imageData,
preview_url: previewUrl,
});
}
if (!additions.length) return;
composerImages = [...composerImages, ...additions];
renderComposerImages();
}
function removeComposerImage(imageId) {
composerImages = composerImages.filter((image) => image.id !== imageId);
renderComposerImages();
}
function clearComposerImages() {
composerImages = [];
renderComposerImages();
}
function renderComposerImages() {
if (!composerImagesEl) return;
composerImagesEl.innerHTML = "";
composerImagesEl.hidden = !composerImages.length;
for (const image of composerImages) {
const card = document.createElement("div");
card.className = "composer-image";
const preview = document.createElement("img");
preview.src = image.preview_url;
preview.alt = image.name || "Pasted image";
const remove = document.createElement("button");
remove.type = "button";
remove.className = "composer-image-remove";
remove.textContent = "×";
remove.title = "Remove image";
remove.addEventListener("click", () => removeComposerImage(image.id));
const label = document.createElement("span");
label.className = "composer-image-name";
label.textContent = image.name || "Pasted image";
card.append(preview, remove, label);
composerImagesEl.appendChild(card);
}
}
function formatMetrics(event) {
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
@@ -494,9 +592,13 @@ const configFieldIds = {
};
const ollamaFieldIds = {
model_provider: "model-provider",
ollama_base_url: "ollama-base-url",
ollama_model: "ollama-model",
ollama_num_ctx: "ollama-num-ctx",
openai_base_url: "openai-base-url",
openai_api_key: "openai-api-key",
openai_model: "openai-model",
};
async function refreshConfig() {
@@ -527,7 +629,12 @@ function renderConfig(config) {
for (const [key, id] of Object.entries(ollamaFieldIds)) {
const field = document.getElementById(id);
if (!field) continue;
field.value = values[key] ?? "";
if (field.type === "password") {
field.value = "";
field.placeholder = secretsConfigured[key] ? "Configured" : "";
} else {
field.value = values[key] ?? "";
}
}
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
configStatusEl.textContent = "";
@@ -565,7 +672,7 @@ async function saveOllamaConfig(event) {
if (!field) continue;
values[key] = field.value;
}
setOllamaMessage("Saving Ollama config");
setOllamaMessage("Saving provider config");
try {
const response = await fetch("/api/config", {
method: "POST",
@@ -577,46 +684,71 @@ async function saveOllamaConfig(event) {
setOllamaMessage(result.message || "Saved");
await refreshOllamaStatus();
} catch (error) {
setOllamaMessage(`Ollama config save failed: ${fetchErrorMessage(error)}`);
setOllamaMessage(`Provider config save failed: ${fetchErrorMessage(error)}`);
}
}
async function refreshOllamaStatus() {
if (!ollamaStatusEl) return;
ollamaStatusEl.textContent = "Checking Ollama";
ollamaStatusEl.textContent = "Checking provider";
try {
const response = await fetch("/api/ollama/status");
const status = await response.json();
renderOllamaStatus(status);
} catch (error) {
ollamaStatusEl.textContent = `Ollama status failed: ${error.message}`;
ollamaStatusEl.textContent = `Provider status failed: ${error.message}`;
}
}
function renderOllamaStatus(status) {
if (!ollamaStatusEl) return;
latestOllamaStatus = status;
const provider = status.provider === "openai" ? "OpenAI" : "Ollama";
const models = status.models?.length ? status.models.join(", ") : "None detected";
const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning";
const ready = status.provider === "openai"
? Boolean(status.online && status.model_available)
: Boolean(status.installed && status.running && status.model_available);
const pillClass = ready ? "status-pill" : "status-pill warning";
const detailItems = [
ollamaStatusItem("Provider", provider),
ollamaStatusItem("Model", status.configured_model || ""),
ollamaStatusItem("URL", status.base_url || ""),
];
if (status.provider !== "openai") {
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
if (status.can_auto_install) detailItems.push(ollamaStatusItem("Auto Install", "Available"));
if (status.num_ctx) detailItems.push(ollamaStatusItem("Context", status.num_ctx));
} else {
detailItems.splice(1, 0, ollamaStatusItem("Connected", status.online ? "Yes" : "No"));
}
ollamaStatusEl.innerHTML = `
<div class="${pillClass}">${escapeHtml(status.message || "Unknown")}</div>
<div class="ollama-status-grid">
${ollamaStatusItem("Installed", status.installed ? "Yes" : "No")}
${ollamaStatusItem("Running", status.running ? "Yes" : "No")}
${ollamaStatusItem("Model", status.configured_model || "")}
${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")}
${ollamaStatusItem("URL", status.base_url || "")}
${status.can_auto_install ? ollamaStatusItem("Auto Install", "Available") : ""}
${detailItems.join("")}
</div>
${ollamaStatusItem("Installed Models", models)}
${ollamaStatusItem(status.provider === "openai" ? "Available Models" : "Installed Models", models)}
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
`;
if (ollamaDownloadButton) ollamaDownloadButton.hidden = status.provider === "openai";
if (ollamaInstallButton) {
ollamaInstallButton.hidden = !status.can_auto_install;
ollamaInstallButton.hidden = status.provider === "openai" || !status.can_auto_install;
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
}
if (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
if (ollamaLaunchButton) {
ollamaLaunchButton.hidden = status.provider === "openai";
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
}
if (ollamaPullButton) {
ollamaPullButton.hidden = status.provider === "openai";
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
}
if (openaiModelsRefreshButton) {
openaiModelsRefreshButton.hidden = status.provider !== "openai";
openaiModelsRefreshButton.disabled = false;
}
renderProviderModelOptions(status.models || []);
updateOllamaAttention(status);
}
@@ -659,12 +791,15 @@ function setOllamaButtonAttention(button, action, active) {
function updateOllamaAttention(status = null) {
const currentStatus = status || latestOllamaStatus;
if (!currentStatus) return;
const ready = Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
const ready = currentStatus.provider === "openai"
? Boolean(currentStatus.online && currentStatus.model_available)
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
ollamaToggle?.classList.toggle("attention-pulse", !ready);
setOllamaButtonAttention(ollamaDownloadButton, "download", !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.running && !currentStatus.model_available);
setOllamaButtonAttention(ollamaDownloadButton, "download", currentStatus.provider !== "openai" && !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", currentStatus.provider !== "openai" && !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.provider !== "openai" && currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.provider !== "openai" && currentStatus.running && !currentStatus.model_available);
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", currentStatus.provider === "openai" && !currentStatus.model_available);
if (ready) clickedOllamaActions.clear();
}
@@ -672,6 +807,31 @@ function configuredOllamaModel() {
return document.getElementById("ollama-model")?.value || "";
}
function renderProviderModelOptions(models) {
const datalist = document.getElementById("provider-models");
if (!datalist) return;
datalist.innerHTML = "";
for (const model of models) {
const option = document.createElement("option");
option.value = model;
datalist.appendChild(option);
}
}
async function refreshOpenAIModels() {
setOllamaMessage("Loading OpenAI models");
try {
const response = await fetch("/api/openai/models");
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderProviderModelOptions(result.models || []);
setOllamaMessage(result.message || "Loaded OpenAI models");
await refreshOllamaStatus();
} catch (error) {
setOllamaMessage(`OpenAI models failed: ${fetchErrorMessage(error)}`);
}
}
async function checkForUpdate(promptUser = false) {
if (!updateStatusEl) return;
updateStatusEl.textContent = "Checking releases";
@@ -1234,15 +1394,17 @@ async function checkHealth() {
const response = await fetch("/api/health");
const result = await response.json();
const health = result.ollama || {};
const provider = health.provider === "openai" ? "OpenAI" : "Ollama";
ollamaOnline = Boolean(health.online);
if (!ollamaOnline) {
statusEl.textContent = "Offline";
setWarning("Ollama needs attention. Open the Ollama tab and use the pulsing action button.");
setWarning(`${provider} needs attention. Open the model provider tab and use the pulsing action button.`);
ollamaToggle?.classList.add("attention-pulse");
return false;
}
if (health.model_available === false) {
setWarning(`Ollama needs the configured model "${health.model}". Open the Ollama tab and use Install Model.`);
const action = health.provider === "openai" ? "Load OpenAI Models." : "Install Model.";
setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`);
ollamaToggle?.classList.add("attention-pulse");
} else {
setWarning("");
@@ -1253,7 +1415,7 @@ async function checkHealth() {
} catch (error) {
ollamaOnline = false;
statusEl.textContent = "Offline";
setWarning("Could not check Ollama health. Open the Ollama tab and use the pulsing action button.");
setWarning("Could not check the active model provider. Open the model provider tab and use the pulsing action button.");
ollamaToggle?.classList.add("attention-pulse");
return false;
}
@@ -1426,6 +1588,23 @@ input.addEventListener("keydown", async (event) => {
}
});
input.addEventListener("paste", async (event) => {
const clipboardItems = [...(event.clipboardData?.items || [])];
const imageFiles = clipboardItems
.filter((item) => item.kind === "file" && String(item.type || "").startsWith("image/"))
.map((item) => item.getAsFile())
.filter(Boolean);
if (!imageFiles.length) return;
if (!event.clipboardData?.getData("text/plain")) {
event.preventDefault();
}
try {
await addComposerImages(imageFiles);
} catch (error) {
setWarning(`Image paste failed: ${fetchErrorMessage(error)}`);
}
});
memoryRefreshButton?.addEventListener("click", refreshMemory);
memoryClearButton?.addEventListener("click", clearMemory);
configRefreshButton?.addEventListener("click", refreshConfig);
@@ -1458,6 +1637,10 @@ ollamaPullButton?.addEventListener("click", () => {
markOllamaActionClicked("pull");
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
});
openaiModelsRefreshButton?.addEventListener("click", () => {
markOllamaActionClicked("openai-models");
refreshOpenAIModels();
});
updateCheckButton?.addEventListener("click", checkForUpdate);
updateInstallButton?.addEventListener("click", installUpdate);
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
@@ -1471,15 +1654,22 @@ updateModalInstall?.addEventListener("click", installUpdate);
async function sendMessage() {
const message = input.value.trim();
if (!message || input.disabled) return;
const attachedImages = composerImages.map(({ name, content_type, image_data, preview_url }) => ({
name,
content_type,
image_data,
preview_url,
}));
if ((!message && !attachedImages.length) || input.disabled) return;
const healthy = await checkHealth();
if (!healthy) {
addMessage("assistant warning-message", "Ollama needs attention before chat can continue. Open the Ollama tab and press the pulsing action button, then try again.");
addMessage("assistant warning-message", "The active model provider needs attention before chat can continue. Open the model provider tab and press the pulsing action button, then try again.");
return;
}
input.value = "";
clearComposerImages();
input.disabled = true;
addMessage("user", message);
addMessage("user", message, { images: attachedImages });
const assistantNode = addMessage("assistant streaming", "");
ensureStreamingChrome(assistantNode);
let assistantText = "";
@@ -1491,7 +1681,7 @@ async function sendMessage() {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, thread_id: currentThreadId }),
body: JSON.stringify({ message, thread_id: currentThreadId, images: attachedImages }),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
@@ -1537,7 +1727,7 @@ async function sendMessage() {
}
} catch (error) {
const message = error.message.includes("503")
? "Ollama needs attention before chat can continue. Open the Ollama tab and press the pulsing action button, then try again."
? "The active model provider needs attention before chat can continue. Open the model provider tab and press the pulsing action button, then try again."
: `Chat failed: ${error.message}`;
setWarning(message);
setMessageMarkdown(assistantNode, message);