diff --git a/desktop/main.js b/desktop/main.js index 1fc2be6b..977830d7 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -96,6 +96,47 @@ function pickPort(preferredPort) { }); } +function envFlagOff(v) { + return ["0", "false", "no", "off"].includes(String(v ?? "").trim().toLowerCase()); +} + +/** When false, connect to OCLAW_DESKTOP_GATEWAY_BASE_URL (or default :8787) instead of spawning a child gateway. */ +function shouldEmbedBackend() { + if (envFlagOff(process.env.OCLAW_DESKTOP_EMBED_BACKEND)) return false; + return !String(process.env.OCLAW_DESKTOP_GATEWAY_BASE_URL || "").trim(); +} + +function normalizeGatewayBaseUrl(raw) { + const s = String(raw || "").trim(); + if (!s) throw new Error("gateway base URL is empty"); + const u = new URL(s); + const host = u.hostname || DEFAULT_HOST; + const port = u.port ? parseInt(u.port, 10) : u.protocol === "https:" ? 443 : 80; + const baseUrl = `${u.protocol}//${u.host}`; + return { host, port, baseUrl }; +} + +async function attachToExistingGateway() { + const raw = + String(process.env.OCLAW_DESKTOP_GATEWAY_BASE_URL || "").trim() || + `http://${DEFAULT_HOST}:${DEFAULT_PORT}`; + const parsed = normalizeGatewayBaseUrl(raw); + runtimeState = { + host: parsed.host, + port: parsed.port, + baseUrl: parsed.baseUrl, + pythonBin: resolvePythonBin(), + externalAttach: true, + }; + const deadlineAtMs = Date.now() + STARTUP_TIMEOUT_MS; + await waitForBackend(`${runtimeState.baseUrl}/health`, deadlineAtMs); + return runtimeState; +} + +function shouldSkipDesktopChannel() { + return ["1", "true", "yes", "on"].includes(String(process.env.OCLAW_DESKTOP_SKIP_CHANNEL || "").trim().toLowerCase()); +} + function waitForBackend(url, deadlineAtMs) { return new Promise((resolve, reject) => { const attempt = () => { @@ -126,6 +167,22 @@ function waitForBackend(url, deadlineAtMs) { }); } +/** Avoid hanging forever on session.clearStorageData / clearCache (seen on some Windows setups). */ +function awaitWithTimeout(promise, timeoutMs, logLabel) { + const ms = Math.max(200, Number(timeoutMs) || 8000); + return Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => { + try { + logDesktop(`${logLabel}:timeout_after_${ms}ms`); + } catch (_) {} + resolve(); + }, ms); + }), + ]); +} + function loadingHtml(text) { const safe = String(text || "").replace(//g, ">"); return ` @@ -190,7 +247,7 @@ async function startBackendProcess() { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); - runtimeState = { host, port, baseUrl, pythonBin }; + runtimeState = { host, port, baseUrl, pythonBin, externalAttach: false }; backendProc.stdout.on("data", (chunk) => { logStream.write(chunk); @@ -513,8 +570,13 @@ async function boot() { app.setName(APP_DISPLAY_NAME); createMainWindow(); // Show window immediately to avoid "black screen" during backend startup. + const embedGateway = shouldEmbedBackend(); try { - await mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(loadingHtml("Starting local gateway…"))}`); + await mainWindow.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent( + loadingHtml(embedGateway ? "Starting local gateway…" : "Connecting to existing gateway…"), + )}`, + ); } catch (_) {} mainWindow.show(); try { @@ -546,8 +608,13 @@ async function boot() { } })(); - await startBackendProcess(); - logDesktop(`boot:backendReady:${Date.now() - t0}ms`); + if (embedGateway) { + await startBackendProcess(); + logDesktop(`boot:backendReady:embedded:${Date.now() - t0}ms`); + } else { + await attachToExistingGateway(); + logDesktop(`boot:backendReady:external:${Date.now() - t0}ms`); + } try { await mainWindow.loadURL( @@ -555,19 +622,33 @@ async function boot() { ); } catch (_) {} - const channelOk = await startChannelProcess(); - logDesktop(`boot:channel:${channelOk ? "ok" : "fail"}:${Date.now() - t0}ms`); - if (!channelOk && !channelStartWarningShown) { - channelStartWarningShown = true; - await dialog.showMessageBox({ - type: "warning", - title: "Channel startup warning", - message: "企微通道启动失败(不影响桌面端启动)", - detail: "你可以在 Admin -> 运行时中查看并重试服务。", - }); + let channelOk = true; + if (shouldSkipDesktopChannel()) { + logDesktop(`boot:channel:skipped:${Date.now() - t0}ms`); + } else { + channelOk = await startChannelProcess(); + logDesktop(`boot:channel:${channelOk ? "ok" : "fail"}:${Date.now() - t0}ms`); + if (!channelOk && !channelStartWarningShown) { + channelStartWarningShown = true; + await dialog.showMessageBox({ + type: "warning", + title: "Channel startup warning", + message: "企微通道启动失败(不影响桌面端启动)", + detail: "你可以在 Admin -> 运行时中查看并重试服务。", + }); + } } - await clearStoragePromise; - await clearCachePromise; + try { + await mainWindow.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent( + loadingHtml( + `Backend ready at ${runtimeState.baseUrl}\nClearing login/session storage…\nThen opening chat…`, + ), + )}`, + ); + } catch (_) {} + // Do not block the window forever if Electron's clearStorageData/clearCache never settle. + await awaitWithTimeout(Promise.all([clearStoragePromise, clearCachePromise]), 12000, "boot:storageCleanup"); // Desktop policy: always require fresh login on every app restart. logDesktop(`boot:loadChat:start:${Date.now() - t0}ms`); await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?v=${Date.now()}`); @@ -593,6 +674,28 @@ ipcMain.handle("desktop:getRuntimeInfo", async () => { }); ipcMain.handle("desktop:restartBackend", async () => { + if (runtimeState && runtimeState.externalAttach) { + if (mainWindow && !mainWindow.isDestroyed()) { + try { + await mainWindow.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(loadingHtml("Reloading chat (external gateway)…"))}`, + ); + mainWindow.show(); + } catch (_) {} + try { + await waitForBackend(`${runtimeState.baseUrl}/health`, Date.now() + 15000); + } catch (_) {} + try { + await awaitWithTimeout(mainWindow.webContents.session.clearCache(), 8000, "restartBackend:clearCache"); + } catch (_) {} + await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?v=${Date.now()}`); + try { + mainWindow.show(); + mainWindow.focus(); + } catch (_) {} + } + return true; + } if (mainWindow && !mainWindow.isDestroyed()) { try { await mainWindow.loadURL( @@ -607,10 +710,12 @@ ipcMain.handle("desktop:restartBackend", async () => { await stopChannelProcess(); await stopBackendProcess(); await startBackendProcess(); - await startChannelProcess(); + if (!shouldSkipDesktopChannel()) { + await startChannelProcess(); + } if (mainWindow && !mainWindow.isDestroyed()) { try { - await mainWindow.webContents.session.clearCache(); + await awaitWithTimeout(mainWindow.webContents.session.clearCache(), 8000, "restartBackend:clearCache"); } catch (_) {} await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?v=${Date.now()}`); try { diff --git a/interfaces/admin/static/admin-theme.js b/interfaces/admin/static/admin-theme.js new file mode 100644 index 00000000..4f99bc3a --- /dev/null +++ b/interfaces/admin/static/admin-theme.js @@ -0,0 +1,51 @@ +/* Admin + standalone /chat: restore palette from localStorage before first paint. */ +(function () { + const STORAGE_KEY = "oclaw_admin_theme"; + const THEMES = ["deepseek", "github", "nord", "dracula", "forest", "catppuccin", "light"]; + const LEGIT = new Set(THEMES); + + function applyAdminTheme(theme) { + const t = theme && LEGIT.has(theme) ? theme : "deepseek"; + if (t === "deepseek") document.body.removeAttribute("data-admin-theme"); + else document.body.setAttribute("data-admin-theme", t); + } + + function initAdminThemeFromStorage() { + let raw = "deepseek"; + try { + raw = localStorage.getItem(STORAGE_KEY) || "deepseek"; + } catch (_) { + raw = "deepseek"; + } + if (!LEGIT.has(raw)) raw = "deepseek"; + applyAdminTheme(raw); + return raw; + } + + function persistAdminTheme(theme) { + const t = theme && LEGIT.has(theme) ? theme : "deepseek"; + try { + localStorage.setItem(STORAGE_KEY, t); + } catch (_) {} + applyAdminTheme(t); + } + + function currentAdminTheme() { + try { + const v = localStorage.getItem(STORAGE_KEY); + return v && LEGIT.has(v) ? v : "deepseek"; + } catch (_) { + return "deepseek"; + } + } + + window.OclawAdminTheme = { + STORAGE_KEY, + THEMES, + applyAdminTheme, + initAdminThemeFromStorage, + persistAdminTheme, + currentAdminTheme, + }; + initAdminThemeFromStorage(); +})(); diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 9b0f4a7a..6b7add42 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -232,6 +232,14 @@ const I18N = { "common.notFound": "未找到页面", "common.forbidden": "无权访问该页面。", "common.error": "错误", + "theme.label": "界面配色", + "theme.deepseek": "DeepSeek 默认", + "theme.github": "GitHub 暗色", + "theme.nord": "Nord", + "theme.dracula": "Dracula", + "theme.forest": "森绿", + "theme.catppuccin": "Catppuccin Mocha", + "theme.light": "浅色", "lang.switch": "English", "memory.title": "记忆治理", "memory.hits": "最近命中", @@ -665,6 +673,14 @@ const I18N = { "common.notFound": "Not found", "common.forbidden": "You do not have access to this page.", "common.error": "Error", + "theme.label": "Color theme", + "theme.deepseek": "DeepSeek (default)", + "theme.github": "GitHub Dark", + "theme.nord": "Nord", + "theme.dracula": "Dracula", + "theme.forest": "Forest", + "theme.catppuccin": "Catppuccin Mocha", + "theme.light": "Light", "lang.switch": "中文", "memory.title": "Memory Governance", "memory.hits": "Recent Hits", @@ -997,6 +1013,12 @@ function applyI18nStatic() { }); const langBtn = document.getElementById("btnLang"); if (langBtn) langBtn.textContent = t("lang.switch"); + const themeSel = document.getElementById("adminThemeSelect"); + if (themeSel && window.OclawAdminTheme) { + try { + themeSel.value = OclawAdminTheme.currentAdminTheme(); + } catch (_) {} + } } function toggleLang() { @@ -9164,6 +9186,14 @@ if (btnLangEl) btnLangEl.addEventListener("click", () => { toggleLang(); router(); }); +const adminThemeSelectEl = document.getElementById("adminThemeSelect"); +if (adminThemeSelectEl && window.OclawAdminTheme) { + adminThemeSelectEl.addEventListener("change", () => { + try { + window.OclawAdminTheme.persistAdminTheme(adminThemeSelectEl.value); + } catch (_) {} + }); +} const btnBackChatEl = document.getElementById("btnBackChat"); if (btnBackChatEl) { try { diff --git a/interfaces/admin/static/chat.html b/interfaces/admin/static/chat.html index 4e2d33e5..6b4d7fb9 100644 --- a/interfaces/admin/static/chat.html +++ b/interfaces/admin/static/chat.html @@ -7,6 +7,7 @@ +
+