diff --git a/dappnode_package.json b/dappnode_package.json index 949f020..57190a9 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -1,16 +1,23 @@ { "name": "openclaw.dnp.dappnode.eth", "version": "0.1.1", - "upstreamVersion": "v2026.3.24", + "upstreamVersion": "v2026.4.5", "upstreamRepo": "openclaw/openclaw", "upstreamArg": "UPSTREAM_VERSION", "shortDescription": "Personal AI assistant gateway with multi-LLM support", "description": "OpenClaw is a powerful, self-hosted AI agent gateway that runs on your own devices. It provides a unified interface for interacting with multiple LLM providers (OpenAI, Anthropic Claude, Google Gemini, local models via Ollama). Features include:\n\n- **Web UI & API**: Full-featured web interface at port 18789\n- **Multi-Channel Messaging**: WhatsApp, Telegram, Slack, Discord, Matrix, and more\n- **Agent Orchestration**: Claude AI integration with tool use and autonomous agents\n- **Code Execution**: Sandboxed environment for running code\n- **Canvas UI**: Visual interactions and image generation\n- **Voice Support**: Text-to-speech and speech-to-text capabilities\n\nRun your own AI infrastructure with full control over your data and API keys.", "type": "service", - "architectures": ["linux/amd64", "linux/arm64"], + "architectures": [ + "linux/amd64", + "linux/arm64" + ], "author": "DAppNode Association ", "license": "Apache-2.0", - "categories": ["AI", "Developer tools", "Communications"], + "categories": [ + "AI", + "Developer tools", + "Communications" + ], "keywords": [ "ai", "llm", @@ -89,4 +96,4 @@ "featuredBackground": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", "featuredColor": "white" } -} +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8dfab78..9501416 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: context: . dockerfile: Dockerfile args: - UPSTREAM_VERSION: v2026.3.24 + UPSTREAM_VERSION: v2026.4.5 image: openclaw.dnp.dappnode.eth:0.1.0 container_name: DAppNodePackage-openclaw.dnp.dappnode.eth restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh index f850ba0..5fdbc10 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -58,6 +58,14 @@ try { " || true fi +# --------------------------------------------------------------------------- +# Ensure WhatsApp plugin is installed (from npm, no interactive prompts) +# --------------------------------------------------------------------------- +echo "Ensuring WhatsApp plugin is installed..." +if ! OPENCLAW_STATE_DIR="$OPENCLAW_DIR" openclaw plugins list 2>/dev/null | grep -q "@openclaw/whatsapp"; then + OPENCLAW_STATE_DIR="$OPENCLAW_DIR" openclaw plugins install @openclaw/whatsapp || true +fi + # --------------------------------------------------------------------------- # Start setup wizard web UI in the background on port 8080 # --------------------------------------------------------------------------- diff --git a/setup-wizard/index.html b/setup-wizard/index.html index 8ac44da..65276a8 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -423,6 +423,242 @@ background: rgba(52, 211, 153, 0.15); color: var(--success); } + + /* Provider list on home step */ + .provider-list-section { + margin-bottom: 1.5rem; + } + + .provider-list-section-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.75rem; + } + + .provider-list-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; + margin-bottom: 0.6rem; + } + + .provider-list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + } + + .provider-list-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .provider-list-name { + font-weight: 600; + font-size: 0.9rem; + } + + .provider-list-url { + font-size: 0.72rem; + color: var(--text-muted); + font-family: 'SF Mono', 'Fira Code', monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .provider-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + margin-left: 0.75rem; + } + + .provider-action-btn { + font-size: 0.75rem; + padding: 3px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + } + + .provider-action-btn:hover { + border-color: var(--primary); + color: var(--primary); + } + + .provider-action-btn.danger:hover { + border-color: var(--error); + color: var(--error); + } + + .add-provider-btn { + display: block; + width: 100%; + padding: 0.9rem 1.25rem; + background: transparent; + border: 2px dashed var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + text-align: center; + transition: all 0.2s; + margin-top: 0.75rem; + } + + .add-provider-btn:hover { + border-color: var(--primary); + color: var(--primary); + } + + .model-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0; + border-top: 1px solid var(--border); + } + + .model-row-name { + font-size: 0.85rem; + color: var(--text); + } + + .model-row.model-row-default .model-row-name { + color: var(--primary); + font-weight: 500; + } + + .model-default-badge { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 99px; + background: rgba(102, 126, 234, 0.15); + color: var(--primary); + flex-shrink: 0; + } + + .set-default-btn { + font-size: 0.75rem;zq + padding: 3px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; + } + + .set-default-btn:hover { + border-color: var(--primary); + color: var(--primary); + } + + .set-default-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Multi-agent section on home step */ + .multi-agent-section { + margin-top: 1.5rem; + } + + .agent-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0; + border-top: 1px solid var(--border); + } + + .agent-row:first-child { + border-top: none; + } + + .agent-row-id { + font-size: 0.85rem; + font-weight: 600; + font-family: 'SF Mono', 'Fira Code', monospace; + } + + .agent-row-name { + font-size: 0.8rem; + color: var(--text-muted); + margin-left: 0.25rem; + } + + /* Channels section on home step */ + .channels-section { + margin-top: 1.5rem; + } + + .channels-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; + } + + .channel-row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0; + } + + .channel-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .channel-dot.on { background: var(--success); } + .channel-dot.off { background: var(--border); } + + .channel-name { + font-size: 0.85rem; + flex: 1; + } + + .channel-status-text { + font-size: 0.75rem; + color: var(--text-muted); + } + + .configure-channels-btn { + display: block; + width: 100%; + margin-top: 0.75rem; + padding: 7px 0; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + font-size: 0.82rem; + cursor: pointer; + transition: all 0.2s; + } + + .configure-channels-btn:hover { + border-color: var(--primary); + color: var(--primary); + } @@ -444,18 +680,9 @@

OpenClaw Setup Wizard

-

Configuration Found

-
You already have a provider configured. What would you like to do?
-
- - -
+

Your AI Providers

+
+
@@ -496,9 +723,32 @@

Integrations & Security

From the Discord Developer Portal.
+
+ +
Scan the QR code with your phone to link WhatsApp. Open WhatsApp → Linked Devices → Link a Device, then scan.
+
+ + +
+ +
+ +
- - + +
@@ -685,6 +935,130 @@

Configuration Saved!

document.getElementById("telegram-users-field").style.display = e.target.value.trim() ? "block" : "none"; }); + document.getElementById("whatsapp-enabled").addEventListener("change", (e) => { + document.getElementById("whatsapp-allowfrom-field").style.display = e.target.checked ? "block" : "none"; + }); + + // --------------------------------------------------------------------------- + // WhatsApp QR login + // --------------------------------------------------------------------------- + let whatsappEventSource = null; + + async function checkWhatsAppLinked() { + try { + const resp = await fetch("/api/whatsapp/linked"); + const data = await resp.json(); + const banner = document.getElementById("whatsapp-status-banner"); + const label = document.getElementById("whatsapp-btn-label"); + if (data.linked) { + if (banner) banner.innerHTML = '
WhatsApp linked — your account is connected.
'; + if (label) label.textContent = "Reconnect WhatsApp"; + const cb = document.getElementById("whatsapp-enabled"); + if (cb && !cb.checked) { cb.checked = true; cb.dispatchEvent(new Event("change")); } + } else { + if (banner) banner.innerHTML = ""; + if (label) label.textContent = "Connect WhatsApp"; + } + } catch {} + } + + // Decode Unicode block characters into QR pixel data and draw on canvas + function renderBlockQr(lines, canvas) { + const moduleSize = 6; + const cols = Math.max(...lines.map(l => [...l].length)); + const rows = lines.length * 2; + canvas.width = cols * moduleSize; + canvas.height = rows * moduleSize; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#000000"; + for (let li = 0; li < lines.length; li++) { + const chars = [...lines[li]]; + for (let ci = 0; ci < chars.length; ci++) { + let t = false, b = false; + switch (chars[ci]) { + case "\u2588": t = true; b = true; break; // █ + case "\u2580": t = true; break; // ▀ + case "\u2584": b = true; break; // ▄ + } + if (t) ctx.fillRect(ci * moduleSize, li * 2 * moduleSize, moduleSize, moduleSize); + if (b) ctx.fillRect(ci * moduleSize, (li * 2 + 1) * moduleSize, moduleSize, moduleSize); + } + } + canvas.style.display = "block"; + } + + function tryRenderAsciiQr(text, canvas) { + const allLines = text.split("\n"); + let best = [], cur = []; + for (const line of allLines) { + const t = line.trimEnd(); + if (/[\u2588\u2580\u2584]/.test(t) && [...t].length >= 15) { + cur.push(t); + } else { + if (cur.length > best.length) best = cur; + cur = []; + } + } + if (cur.length > best.length) best = cur; + if (best.length < 8) return false; + renderBlockQr(best, canvas); + return true; + } + + function startWhatsAppLogin() { + const qrArea = document.getElementById("whatsapp-qr-area"); + const qrCanvas = document.getElementById("whatsapp-qr-canvas"); + const qrLog = document.getElementById("whatsapp-qr-log"); + const btn = document.getElementById("whatsapp-connect-btn"); + + qrArea.style.display = "block"; + qrCanvas.style.display = "none"; + qrLog.textContent = "Connecting\u2026"; + btn.disabled = true; + + if (whatsappEventSource) { whatsappEventSource.close(); whatsappEventSource = null; } + + whatsappEventSource = new EventSource("/api/whatsapp/login-stream"); + let logBuf = ""; + let qrRendered = false; + + whatsappEventSource.onmessage = async (e) => { + const msg = JSON.parse(e.data); + if (msg.type === "qr") { + // Raw QR string — render with QRCode.js + try { + qrCanvas.style.display = "block"; + await QRCode.toCanvas(qrCanvas, msg.qr, { width: 256, margin: 2, color: { dark: "#000", light: "#fff" } }); + qrLog.textContent = "Scan with WhatsApp \u2192 Linked Devices \u2192 Link a Device"; + qrRendered = true; + } catch { qrLog.textContent += "\n[raw qr: " + msg.qr + "]"; } + } else if (msg.type === "log") { + logBuf += msg.data; + // Show only non-block-char lines in the status log + const statusLines = logBuf.split("\n").filter(l => !/[\u2588\u2580\u2584]/.test(l) && l.trim()).join("\n"); + if (statusLines) qrLog.textContent = statusLines; + // Try to extract and render ASCII art QR to canvas + if (!qrRendered) { + qrRendered = tryRenderAsciiQr(logBuf, qrCanvas); + if (qrRendered) qrLog.textContent = "Scan with WhatsApp \u2192 Linked Devices \u2192 Link a Device"; + } + } else if (msg.type === "done") { + whatsappEventSource.close(); + whatsappEventSource = null; + btn.disabled = false; + checkWhatsAppLinked(); + } + }; + + whatsappEventSource.onerror = () => { + if (whatsappEventSource) { whatsappEventSource.close(); whatsappEventSource = null; } + btn.disabled = false; + checkWhatsAppLinked(); + }; + } + // --------------------------------------------------------------------------- // Load existing config on startup and pre-fill the form // --------------------------------------------------------------------------- @@ -697,13 +1071,6 @@

Configuration Saved!

existingProviderIds = Object.keys(providers); if (existingProviderIds.length > 0) { - // Show home step so user can choose to edit or add a provider - const providerNames = existingProviderIds.map(pid => { - const known = PROVIDERS.find(p => p.id === pid); - return known ? known.name : pid; - }); - document.getElementById("home-subtitle").textContent = - `You already have ${providerNames.join(", ")} configured. What would you like to do?`; goTo("home"); } else { // Config exists but no providers yet — just prefill and continue @@ -763,6 +1130,16 @@

Configuration Saved!

} } + // WhatsApp — pre-check and prefill if already enabled in config + if (cfg.channels && cfg.channels.whatsapp && cfg.channels.whatsapp.enabled) { + const cb = document.getElementById("whatsapp-enabled"); + if (cb) { cb.checked = true; cb.dispatchEvent(new Event("change")); } + const wa = cfg.channels.whatsapp; + if (wa.allowFrom && wa.allowFrom.length > 0) { + const af = document.getElementById("whatsapp-allow-from"); + if (af) af.value = wa.allowFrom.join(", "); + } + } } function startUpdate() { @@ -803,6 +1180,125 @@

Configuration Saved!

} } + function backFromStep2() { + if (wizardMode === 'channels-only') { + goTo('home'); + } else { + goTo(1); + } + } + + function nextFromStep2() { + if (wizardMode === 'channels-only') { + saveChannelsOnly(); + } else { + showReview(); + } + } + + function configureChannels() { + wizardMode = 'channels-only'; + + // Pre-fill from existingConfig + if (existingConfig && existingConfig.channels) { + const tg = existingConfig.channels.telegram; + if (tg && tg.botToken) { + document.getElementById("telegram-token").value = tg.botToken; + document.getElementById("telegram-users-field").style.display = "block"; + if (tg.allowFrom && tg.allowFrom.length > 0) { + document.getElementById("telegram-users").value = tg.allowFrom.filter(Boolean).join(", "); + } + } + const dc = existingConfig.channels.discord; + if (dc && dc.botToken) { + document.getElementById("discord-token").value = dc.botToken; + } + } + + document.getElementById("existing-config-banner").innerHTML = ""; + goTo(2); + } + + function buildChannelsConfig() { + const config = { channels: {}, plugins: { entries: {} } }; + const telegramToken = document.getElementById("telegram-token").value.trim(); + const telegramUsers = document.getElementById("telegram-users").value.trim(); + const discordToken = document.getElementById("discord-token").value.trim(); + + if (telegramToken) { + config.channels.telegram = { + enabled: true, + botToken: telegramToken, + dmPolicy: "pairing", + groupPolicy: "allowlist", + groups: { "*": { requireMention: true } }, + streamMode: "partial", + }; + if (telegramUsers) { + config.channels.telegram.allowFrom = telegramUsers.split(",").map(s => s.trim()).filter(Boolean); + } + config.plugins.entries.telegram = { enabled: true }; + } + + if (discordToken) { + config.channels.discord = { + enabled: true, + botToken: discordToken, + dmPolicy: "pairing", + groupPolicy: "allowlist", + }; + config.plugins.entries.discord = { enabled: true }; + } + + const whatsappEnabled = !!(document.getElementById("whatsapp-enabled") && document.getElementById("whatsapp-enabled").checked); + if (whatsappEnabled) { + const allowFromRaw = (document.getElementById("whatsapp-allow-from").value || "").trim(); + const allowFrom = allowFromRaw ? allowFromRaw.split(",").map(s => s.trim()).filter(Boolean) : []; + config.channels = config.channels || {}; + config.channels.whatsapp = { + enabled: true, + dmPolicy: allowFrom.length > 0 ? "allowlist" : "pairing", + ...(allowFrom.length > 0 ? { allowFrom } : {}), + }; + config.plugins.entries.whatsapp = { enabled: true }; + } + + return config; + } + + async function saveChannelsOnly() { + const btn = document.getElementById("btn-step2-next"); + btn.disabled = true; + btn.textContent = "Saving..."; + + try { + const config = buildChannelsConfig(); + const resp = await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config) + }); + const data = await resp.json(); + if (resp.ok) { + // Reload existingConfig to reflect merged result + try { + const cfgResp = await fetch("/api/config"); + if (cfgResp.ok) existingConfig = await cfgResp.json(); + } catch {} + goTo("home"); + showHomeBanner("success", "Chat integrations saved."); + } else { + alert("Failed to save: " + (data.error || "Unknown error")); + btn.disabled = false; + btn.textContent = "Save"; + } + } catch (err) { + alert("Network error: " + err.message); + btn.disabled = false; + btn.textContent = "Save"; + } + } + function backFromReview() { if (wizardMode === 'add-provider') { goTo(1); @@ -820,7 +1316,7 @@

Configuration Saved!

// Update progress bar const progressEl = document.getElementById("progress"); const bars = document.querySelectorAll(".progress .bar"); - if (step === "home") { + if (step === "home" || (step === 2 && wizardMode === "channels-only")) { progressEl.style.display = "none"; } else { progressEl.style.display = ""; @@ -842,8 +1338,16 @@

Configuration Saved!

} } + // Render existing providers on home step + if (step === "home") { renderHomeProviders(); checkWhatsAppLinked(); } // Render provider config when entering step 1 if (step === 1 && selectedProvider) renderProviderConfig(); + // Check WhatsApp link status when entering step 2 + if (step === 2) { + checkWhatsAppLinked(); + const btn = document.getElementById("btn-step2-next"); + if (btn) btn.textContent = wizardMode === "channels-only" ? "Save" : "Review"; + } window.scrollTo({ top: 0, behavior: "smooth" }); } @@ -1087,6 +1591,219 @@

Configure ${p.name}

return models.find(m => m.id === select.value) || null; } + // --------------------------------------------------------------------------- + // Home step: render configured providers + default model picker + // --------------------------------------------------------------------------- + function renderHomeProviders() { + const container = document.getElementById("home-providers"); + if (!container || !existingConfig) return; + + const providers = (existingConfig.models && existingConfig.models.providers) || {}; + const providerEntries = Object.entries(providers); + + if (providerEntries.length === 0) { + // No providers left — jump straight to setup flow + goTo(0); + return; + } + + const currentDefault = ( + existingConfig.agents && + existingConfig.agents.defaults && + existingConfig.agents.defaults.model && + existingConfig.agents.defaults.model.primary + ) || ""; + + let html = '
'; + + for (const [providerId, providerCfg] of providerEntries) { + const models = providerCfg.models || []; + const displayUrl = (providerCfg.baseUrl || "").replace(/\/v1\/?$/, ""); + html += `
`; + html += `
`; + html += `
`; + html += `${providerId}`; + if (displayUrl) html += `${displayUrl}`; + html += `
`; + html += `
`; + html += ``; + html += ``; + html += `
`; + html += `
`; + + for (const model of models) { + const modelKey = `${providerId}/${model.id}`; + const isDefault = currentDefault === modelKey; + html += `
`; + html += `${model.name || model.id}`; + if (isDefault) { + html += `Default`; + } else { + html += ``; + } + html += `
`; + } + + html += `
`; + } + + html += '
'; + html += ``; + html += renderChannelsSection(); + html += renderMultiAgentSection(); + container.innerHTML = html; + } + + function renderChannelsSection() { + const tgOn = !!(existingConfig && existingConfig.channels && existingConfig.channels.telegram && existingConfig.channels.telegram.botToken); + const dcOn = !!(existingConfig && existingConfig.channels && existingConfig.channels.discord && existingConfig.channels.discord.botToken); + const channels = [ + { name: "Telegram", connected: tgOn }, + { name: "Discord", connected: dcOn }, + { name: "WhatsApp", connected: !!(existingConfig && existingConfig.channels && existingConfig.channels.whatsapp && existingConfig.channels.whatsapp.enabled) }, + ]; + let html = `
`; + html += ``; + html += `
`; + for (const ch of channels) { + html += `
`; + html += ``; + html += `${ch.name}`; + html += `${ch.connected ? "Connected" : "Not configured"}`; + html += `
`; + } + html += ``; + html += `
`; + return html; + } + + async function setDefaultModel(providerId, modelId) { + // Disable all set-default buttons while saving + document.querySelectorAll(".set-default-btn").forEach(b => b.disabled = true); + + const modelKey = `${providerId}/${modelId}`; + try { + const resp = await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agents: { defaults: { model: { primary: modelKey } } } }) + }); + if (resp.ok) { + // Patch local config and re-render + if (!existingConfig.agents) existingConfig.agents = {}; + if (!existingConfig.agents.defaults) existingConfig.agents.defaults = {}; + if (!existingConfig.agents.defaults.model) existingConfig.agents.defaults.model = {}; + existingConfig.agents.defaults.model.primary = modelKey; + renderHomeProviders(); + showHomeBanner("success", `Default model set to ${modelKey}`); + } else { + const data = await resp.json().catch(() => ({})); + showHomeBanner("warning", "Failed to update: " + (data.error || "Unknown error")); + document.querySelectorAll(".set-default-btn").forEach(b => b.disabled = false); + } + } catch (err) { + showHomeBanner("warning", "Network error: " + err.message); + document.querySelectorAll(".set-default-btn").forEach(b => b.disabled = false); + } + } + + function editProvider(providerId) { + const providers = (existingConfig.models && existingConfig.models.providers) || {}; + const providerCfg = providers[providerId]; + if (!providerCfg) return; + + wizardMode = 'update'; + + const knownProvider = PROVIDERS.find(p => p.id === providerId); + const cardId = knownProvider ? providerId : "custom"; + + // Set up _loaded* fields so renderProviderConfig() can pre-fill + existingConfig._loadedProvider = cardId; + existingConfig._loadedApiKey = providerCfg.apiKey || ""; + existingConfig._loadedBaseUrl = providerCfg.baseUrl || ""; + const models = providerCfg.models || []; + existingConfig._loadedModelId = models.length > 0 ? models[0].id : ""; + existingConfig._loadedContextWindow = models.length > 0 ? (models[0].contextWindow || null) : null; + existingConfig._loadedMaxTokens = models.length > 0 ? (models[0].maxTokens || null) : null; + if (!knownProvider) { + existingConfig._loadedCustomName = providerId; + existingConfig._loadedCustomApi = providerCfg.api || "openai-completions"; + } + + selectProviderCard(cardId); + + // Pre-fill messaging channels + if (existingConfig.channels && existingConfig.channels.telegram) { + const tg = existingConfig.channels.telegram; + if (tg.botToken) { + document.getElementById("telegram-token").value = tg.botToken; + document.getElementById("telegram-users-field").style.display = "block"; + } + if (tg.allowFrom && tg.allowFrom.length > 0) { + document.getElementById("telegram-users").value = tg.allowFrom.filter(Boolean).join(", "); + document.getElementById("telegram-users-field").style.display = "block"; + } + } + if (existingConfig.channels && existingConfig.channels.discord && existingConfig.channels.discord.botToken) { + document.getElementById("discord-token").value = existingConfig.channels.discord.botToken; + } + checkWhatsAppLinked(); + + document.getElementById("existing-config-banner").innerHTML = + ``; + + goTo(1); + } + + async function removeProvider(providerId) { + if (!confirm(`Remove provider "${providerId}"? This cannot be undone.`)) return; + + // Build new config without this provider (deep-copy, strip private keys) + const newConfig = JSON.parse(JSON.stringify(existingConfig)); + for (const key of Object.keys(newConfig)) { + if (key.startsWith("_")) delete newConfig[key]; + } + + if (newConfig.models && newConfig.models.providers) { + delete newConfig.models.providers[providerId]; + } + + // Clear default model if it was from the removed provider + const primary = newConfig.agents && newConfig.agents.defaults && newConfig.agents.defaults.model && newConfig.agents.defaults.model.primary || ""; + if (primary.startsWith(providerId + "/")) { + newConfig.agents.defaults.model.primary = ""; + } + + try { + const resp = await fetch("/api/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newConfig, null, 2) + }); + if (resp.ok) { + existingConfig = newConfig; + existingProviderIds = Object.keys((newConfig.models && newConfig.models.providers) || {}); + renderHomeProviders(); // will auto-redirect to step 0 if empty + if (existingProviderIds.length > 0) { + showHomeBanner("success", `Provider ${providerId} removed.`); + } + } else { + const data = await resp.json().catch(() => ({})); + showHomeBanner("warning", "Failed to remove: " + (data.error || "Unknown error")); + } + } catch (err) { + showHomeBanner("warning", "Network error: " + err.message); + } + } + + function showHomeBanner(type, message) { + const banner = document.getElementById("home-banner"); + if (!banner) return; + const cls = type === "success" ? "success" : "warning"; + banner.innerHTML = ``; + setTimeout(() => { banner.innerHTML = ""; }, 3500); + } + async function probeOllama() { const btn = document.getElementById("probe-btn"); const icon = document.getElementById("probe-icon"); @@ -1289,10 +2006,23 @@

Configure ${p.name}

config.plugins.entries.discord = { enabled: true }; } + const whatsappEnabled2 = !!(document.getElementById("whatsapp-enabled") && document.getElementById("whatsapp-enabled").checked); + if (whatsappEnabled2) { + const allowFromRaw2 = (document.getElementById("whatsapp-allow-from").value || "").trim(); + const allowFrom2 = allowFromRaw2 ? allowFromRaw2.split(",").map(s => s.trim()).filter(Boolean) : []; + config.channels = config.channels || {}; + config.channels.whatsapp = { + enabled: true, + dmPolicy: allowFrom2.length > 0 ? "allowlist" : "pairing", + ...(allowFrom2.length > 0 ? { allowFrom: allowFrom2 } : {}), + }; + config.plugins.entries.whatsapp = { enabled: true }; + } + return config; } - function buildAddProviderConfig() { +function buildAddProviderConfig() { // Builds only the new provider + updated default — server deep-merges with existing config const p = selectedProvider; const config = { @@ -1396,7 +2126,263 @@

Configure ${p.name}

btn.textContent = "Save Configuration"; } } + // --------------------------------------------------------------------------- + // Multi-Agent section + // --------------------------------------------------------------------------- + function renderMultiAgentSection() { + const agentList = (existingConfig && existingConfig.agents && existingConfig.agents.list) || []; + const bindings = (existingConfig && existingConfig.bindings) || []; + const hasAgents = agentList.length > 0; + + let html = `
`; + html += ``; + + if (!hasAgents) { + html += ``; + html += `
Run multiple isolated agents with separate workspaces, identities, and routing rules.
`; + } else { + // Agent list + html += `
`; + for (const agent of agentList) { + html += `
`; + html += `${agent.id}`; + if (agent.default) html += `default`; + if (agent.name) html += `${agent.name}`; + html += ``; + html += `
`; + } + html += `
`; + + // Add-agent inline form + html += ``; + + html += ``; + + // Bindings + if (bindings.length > 0) { + html += ``; + html += `
`; + for (let i = 0; i < bindings.length; i++) { + const b = bindings[i]; + const matchStr = [b.match.channel, b.match.accountId ? `@${b.match.accountId}` : null].filter(Boolean).join(' '); + const accCfg = b.match.accountId && + existingConfig.channels && + existingConfig.channels[b.match.channel] && + existingConfig.channels[b.match.channel].accounts && + existingConfig.channels[b.match.channel].accounts[b.match.accountId]; + const hasToken = !!(accCfg && accCfg.botToken); + html += `
`; + html += `${matchStr}`; + if (hasToken) html += `token set`; + html += ``; + html += `${b.agentId}`; + html += ``; + html += `
`; + } + html += `
`; + } + + // Add-binding inline form + const agentOptions = agentList.map(a => ``).join(''); + html += ``; + + html += ``; + + html += `
`; + html += ``; + html += `
`; + } + + html += `
`; + return html; + } + + function showAddAgentForm() { + document.getElementById('add-agent-form').style.display = 'block'; + document.getElementById('new-agent-id').focus(); + } + + function showAddBindingForm() { + document.getElementById('add-binding-form').style.display = 'block'; + onBindingChannelChange(); + } + + function onBindingChannelChange() { + const channel = document.getElementById('new-binding-channel').value; + const tokenField = document.getElementById('new-binding-token-field'); + const tokenHint = document.getElementById('new-binding-token-hint'); + if (!tokenField) return; + if (channel === 'whatsapp') { + tokenField.style.display = 'none'; + } else { + tokenField.style.display = 'block'; + if (tokenHint) tokenHint.innerHTML = channel === 'telegram' + ? 'From @BotFather on Telegram.' + : 'From the Discord Developer Portal.'; + } + } + + function buildCleanConfig() { + const c = JSON.parse(JSON.stringify(existingConfig)); + for (const k of Object.keys(c)) { + if (k.startsWith('_')) delete c[k]; + } + return c; + } + + async function saveFullConfig(config, successMsg) { + try { + const resp = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config, null, 2) + }); + if (resp.ok) { + existingConfig = config; + renderHomeProviders(); + showHomeBanner('success', successMsg); + } else { + const data = await resp.json().catch(() => ({})); + showHomeBanner('warning', 'Failed: ' + (data.error || 'Unknown error')); + } + } catch (err) { + showHomeBanner('warning', 'Network error: ' + err.message); + } + } + + async function enableMultiAgent() { + const newConfig = buildCleanConfig(); + if (!newConfig.agents) newConfig.agents = {}; + const workspace = (newConfig.agents.defaults && newConfig.agents.defaults.workspace) || '/home/node/.openclaw/workspace'; + newConfig.agents.list = [{ id: 'main', default: true, workspace }]; + await saveFullConfig(newConfig, 'Multi-agent enabled with default "main" agent.'); + } + + async function disableMultiAgent() { + if (!confirm('Disable multi-agent routing? The agent list and all bindings will be removed.')) return; + const newConfig = buildCleanConfig(); + if (newConfig.agents) delete newConfig.agents.list; + delete newConfig.bindings; + await saveFullConfig(newConfig, 'Multi-agent disabled.'); + } + + async function removeAgent(agentId) { + const agentList = (existingConfig.agents && existingConfig.agents.list) || []; + if (agentList.length <= 1) { + return disableMultiAgent(); + } + if (!confirm(`Remove agent "${agentId}"?`)) return; + const newConfig = buildCleanConfig(); + newConfig.agents.list = agentList.filter(a => a.id !== agentId); + if (!newConfig.agents.list.find(a => a.default)) { + newConfig.agents.list[0].default = true; + } + if (newConfig.bindings) { + newConfig.bindings = newConfig.bindings.filter(b => b.agentId !== agentId); + if (newConfig.bindings.length === 0) delete newConfig.bindings; + } + await saveFullConfig(newConfig, `Agent "${agentId}" removed.`); + } + + async function confirmAddAgent() { + const raw = (document.getElementById('new-agent-id').value || '').trim(); + const id = raw.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, ''); + if (!id) { alert('Agent ID is required.'); return; } + const agentList = (existingConfig.agents && existingConfig.agents.list) || []; + if (agentList.find(a => a.id === id)) { alert(`Agent "${id}" already exists.`); return; } + const name = (document.getElementById('new-agent-name').value || '').trim(); + const newAgent = { id, workspace: `/home/node/.openclaw/agents/${id}/workspace` }; + if (name) newAgent.name = name; + const newConfig = buildCleanConfig(); + newConfig.agents.list = [...agentList, newAgent]; + await saveFullConfig(newConfig, `Agent "${id}" added.`); + } + + async function confirmAddBinding() { + const channel = document.getElementById('new-binding-channel').value; + const accountId = (document.getElementById('new-binding-account').value || '').trim(); + const agentId = document.getElementById('new-binding-agent').value; + const botToken = ((document.getElementById('new-binding-token') || {}).value || '').trim(); + + if (!accountId) { alert('Account ID is required.'); return; } + + const match = { channel, accountId }; + const newConfig = buildCleanConfig(); + newConfig.bindings = [...(newConfig.bindings || []), { agentId, match }]; + + // Store the bot token under channels..accounts. + if (botToken && channel !== 'whatsapp') { + if (!newConfig.channels) newConfig.channels = {}; + if (!newConfig.channels[channel]) newConfig.channels[channel] = { enabled: true }; + if (!newConfig.channels[channel].accounts) newConfig.channels[channel].accounts = {}; + newConfig.channels[channel].accounts[accountId] = { botToken }; + if (!newConfig.plugins) newConfig.plugins = { entries: {} }; + if (!newConfig.plugins.entries) newConfig.plugins.entries = {}; + newConfig.plugins.entries[channel] = { enabled: true }; + } + + await saveFullConfig(newConfig, 'Binding added.'); + } + + async function removeBinding(index) { + const newConfig = buildCleanConfig(); + const removed = (newConfig.bindings || [])[index]; + newConfig.bindings = (newConfig.bindings || []).filter((_, i) => i !== index); + if (newConfig.bindings.length === 0) delete newConfig.bindings; + + // Remove the bot token account entry if no other binding uses this accountId on this channel + if (removed && removed.match.accountId) { + const { channel, accountId } = removed.match; + const stillUsed = (newConfig.bindings || []).some(b => b.match.channel === channel && b.match.accountId === accountId); + if (!stillUsed && newConfig.channels && newConfig.channels[channel] && newConfig.channels[channel].accounts) { + delete newConfig.channels[channel].accounts[accountId]; + if (Object.keys(newConfig.channels[channel].accounts).length === 0) { + delete newConfig.channels[channel].accounts; + } + } + } + + await saveFullConfig(newConfig, 'Binding removed.'); + } + \ No newline at end of file diff --git a/setup-wizard/server.cjs b/setup-wizard/server.cjs index f65a025..1b1bf1f 100644 --- a/setup-wizard/server.cjs +++ b/setup-wizard/server.cjs @@ -4,6 +4,7 @@ const http = require("node:http"); const fs = require("node:fs"); const path = require("node:path"); +const { spawn } = require("node:child_process"); function deepMerge(base, override) { if (typeof base !== "object" || base === null || Array.isArray(base)) return override; @@ -24,6 +25,10 @@ const OLLAMA_CANDIDATES = [ "http://ollama.ollama-nvidia-openwebui.dappnode:11434", "http://ollama.ollama-amd-openwebui.dappnode:11434", "http://ollama.ollama-cpu-openwebui.dappnode:11434", + "http://ollama-nvidia.dappnode:11434", + "http://ollama-amd.dappnode:11434", + "http://ollama-cpu.dappnode:11434", + "http://localhost:11434", ]; function readBody(req) { @@ -49,7 +54,7 @@ async function probeOllama() { const models = (data.models || []).map((m) => m.name); return { reachable: true, url, models }; } - } catch {} + } catch { } } return { reachable: false, url: null, models: [] }; } @@ -57,7 +62,7 @@ async function probeOllama() { const server = http.createServer(async (req, res) => { // CORS for same-origin page res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } @@ -94,7 +99,7 @@ const server = http.createServer(async (req, res) => { const body = await readBody(req); const incoming = JSON.parse(body); let existing = {}; - try { existing = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch {} + try { existing = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { } const merged = deepMerge(existing, incoming); fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), "utf-8"); @@ -105,6 +110,20 @@ const server = http.createServer(async (req, res) => { return; } + // Full-replace config (used for provider removal — deep-merge can't delete keys) + if (req.method === "PUT" && url.pathname === "/api/config") { + try { + const body = await readBody(req); + const incoming = JSON.parse(body); + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(incoming, null, 2), "utf-8"); + json(res, 200, { ok: true, path: CONFIG_FILE }); + } catch (err) { + json(res, 400, { error: err.message }); + } + return; + } + // Probe Ollama if (req.method === "GET" && url.pathname === "/api/ollama/probe") { const result = await probeOllama(); @@ -112,6 +131,64 @@ const server = http.createServer(async (req, res) => { return; } + // Check if WhatsApp is linked (creds file exists for any account) + if (req.method === "GET" && url.pathname === "/api/whatsapp/linked") { + const credsDir = path.join(CONFIG_DIR, "credentials", "whatsapp"); + let linked = false; + try { + const accounts = fs.readdirSync(credsDir); + linked = accounts.some(account => + fs.existsSync(path.join(credsDir, account, "creds.json")) + ); + } catch {} + json(res, 200, { linked }); + return; + } + + // WhatsApp QR login — SSE stream from `openclaw channels login --channel whatsapp` + if (req.method === "GET" && url.pathname === "/api/whatsapp/login-stream") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + + const child = spawn("openclaw", ["channels", "login", "--channel", "whatsapp"], { + env: { ...process.env, OPENCLAW_STATE_DIR: CONFIG_DIR }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const sendMsg = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`); + + const cleanAnsi = (text) => text + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") + .replace(/\x1b\][^\x07]*\x07/g, "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + + // WhatsApp QR data from baileys: `1@base64,base64,base64` or bare multi-segment base64 + const QR_PATTERN = /(?:^|\n)(\d+@[A-Za-z0-9+/=,]{40,}|[A-Za-z0-9+/=]{20,}(?:,[A-Za-z0-9+/=]{20,}){2,})/m; + + const onData = (chunk) => { + const text = chunk.toString(); + const match = text.match(QR_PATTERN); + if (match) { + sendMsg({ type: "qr", qr: match[1].trim() }); + } else { + sendMsg({ type: "log", data: cleanAnsi(text) }); + } + }; + + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.on("close", (code) => { + sendMsg({ type: "done", code }); + res.end(); + }); + req.on("close", () => child.kill()); + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found"); });