From 26d3eeb3279d6132103c5bd5b2b22700da56ba2c Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Sat, 4 Apr 2026 09:30:36 +0800 Subject: [PATCH 01/22] refactor: split web ui modules and harden provider switching --- cli.js | 163 +- tests/e2e/run.js | 2 + tests/e2e/test-web-ui-assets.js | 124 + tests/unit/agents-diff-ui.test.mjs | 22 +- tests/unit/claude-settings-sync.test.mjs | 10 +- tests/unit/compact-layout-ui.test.mjs | 21 +- tests/unit/config-tabs-ui.test.mjs | 37 +- tests/unit/helpers/web-ui-app-options.mjs | 242 + tests/unit/helpers/web-ui-source.mjs | 35 + tests/unit/provider-share-command.test.mjs | 128 +- .../unit/provider-switch-regression.test.mjs | 158 + tests/unit/run.mjs | 3 + .../session-tab-switch-performance.test.mjs | 13 +- tests/unit/session-trash-state.test.mjs | 35 +- tests/unit/skills-modal-ui.test.mjs | 25 +- tests/unit/web-ui-behavior-parity.test.mjs | 930 +++ tests/unit/web-ui-restart.test.mjs | 222 + tests/unit/web-ui-source-bundle.test.mjs | 98 + web-ui/app.js | 6072 +---------------- web-ui/index.html | 2288 +------ web-ui/logic.agents-diff.mjs | 390 ++ web-ui/logic.claude.mjs | 103 + web-ui/logic.mjs | 798 +-- web-ui/logic.runtime.mjs | 122 + web-ui/logic.sessions.mjs | 173 + web-ui/modules/api.mjs | 42 + web-ui/modules/app.computed.dashboard.mjs | 113 + web-ui/modules/app.computed.index.mjs | 13 + web-ui/modules/app.computed.session.mjs | 127 + web-ui/modules/app.constants.mjs | 15 + web-ui/modules/app.methods.agents.mjs | 426 ++ web-ui/modules/app.methods.claude-config.mjs | 167 + web-ui/modules/app.methods.codex-config.mjs | 458 ++ web-ui/modules/app.methods.index.mjs | 86 + web-ui/modules/app.methods.install.mjs | 141 + web-ui/modules/app.methods.navigation.mjs | 422 ++ web-ui/modules/app.methods.openclaw-core.mjs | 489 ++ .../modules/app.methods.openclaw-editing.mjs | 290 + .../modules/app.methods.openclaw-persist.mjs | 181 + web-ui/modules/app.methods.providers.mjs | 248 + web-ui/modules/app.methods.runtime.mjs | 294 + .../modules/app.methods.session-actions.mjs | 455 ++ .../modules/app.methods.session-browser.mjs | 419 ++ .../modules/app.methods.session-timeline.mjs | 417 ++ web-ui/modules/app.methods.session-trash.mjs | 416 ++ web-ui/modules/app.methods.startup-claude.mjs | 345 + web-ui/partials/index/layout-footer.html | 69 + web-ui/partials/index/layout-header.html | 367 + .../index/modal-config-template-agents.html | 125 + .../partials/index/modal-confirm-toast.html | 27 + .../partials/index/modal-openclaw-config.html | 274 + web-ui/partials/index/modal-skills.html | 185 + web-ui/partials/index/modals-basic.html | 196 + .../partials/index/panel-config-claude.html | 95 + web-ui/partials/index/panel-config-codex.html | 248 + .../partials/index/panel-config-openclaw.html | 78 + web-ui/partials/index/panel-market.html | 174 + web-ui/partials/index/panel-sessions.html | 285 + web-ui/partials/index/panel-settings.html | 140 + web-ui/source-bundle.cjs | 229 + web-ui/styles.css | 4742 +------------ web-ui/styles/base-theme.css | 374 + web-ui/styles/controls-forms.css | 335 + web-ui/styles/feedback.css | 108 + web-ui/styles/layout-shell.css | 329 + web-ui/styles/modals-core.css | 429 ++ web-ui/styles/navigation-panels.css | 382 ++ web-ui/styles/openclaw-structured.css | 266 + web-ui/styles/responsive.css | 416 ++ web-ui/styles/sessions-list.css | 408 ++ web-ui/styles/sessions-preview.css | 405 ++ web-ui/styles/sessions-toolbar-trash.css | 244 + web-ui/styles/skills-list.css | 298 + web-ui/styles/skills-market.css | 335 + web-ui/styles/titles-cards.css | 399 ++ 75 files changed, 16109 insertions(+), 13631 deletions(-) create mode 100644 tests/e2e/test-web-ui-assets.js create mode 100644 tests/unit/helpers/web-ui-app-options.mjs create mode 100644 tests/unit/helpers/web-ui-source.mjs create mode 100644 tests/unit/provider-switch-regression.test.mjs create mode 100644 tests/unit/web-ui-behavior-parity.test.mjs create mode 100644 tests/unit/web-ui-source-bundle.test.mjs create mode 100644 web-ui/logic.agents-diff.mjs create mode 100644 web-ui/logic.claude.mjs create mode 100644 web-ui/logic.runtime.mjs create mode 100644 web-ui/logic.sessions.mjs create mode 100644 web-ui/modules/api.mjs create mode 100644 web-ui/modules/app.computed.dashboard.mjs create mode 100644 web-ui/modules/app.computed.index.mjs create mode 100644 web-ui/modules/app.computed.session.mjs create mode 100644 web-ui/modules/app.constants.mjs create mode 100644 web-ui/modules/app.methods.agents.mjs create mode 100644 web-ui/modules/app.methods.claude-config.mjs create mode 100644 web-ui/modules/app.methods.codex-config.mjs create mode 100644 web-ui/modules/app.methods.index.mjs create mode 100644 web-ui/modules/app.methods.install.mjs create mode 100644 web-ui/modules/app.methods.navigation.mjs create mode 100644 web-ui/modules/app.methods.openclaw-core.mjs create mode 100644 web-ui/modules/app.methods.openclaw-editing.mjs create mode 100644 web-ui/modules/app.methods.openclaw-persist.mjs create mode 100644 web-ui/modules/app.methods.providers.mjs create mode 100644 web-ui/modules/app.methods.runtime.mjs create mode 100644 web-ui/modules/app.methods.session-actions.mjs create mode 100644 web-ui/modules/app.methods.session-browser.mjs create mode 100644 web-ui/modules/app.methods.session-timeline.mjs create mode 100644 web-ui/modules/app.methods.session-trash.mjs create mode 100644 web-ui/modules/app.methods.startup-claude.mjs create mode 100644 web-ui/partials/index/layout-footer.html create mode 100644 web-ui/partials/index/layout-header.html create mode 100644 web-ui/partials/index/modal-config-template-agents.html create mode 100644 web-ui/partials/index/modal-confirm-toast.html create mode 100644 web-ui/partials/index/modal-openclaw-config.html create mode 100644 web-ui/partials/index/modal-skills.html create mode 100644 web-ui/partials/index/modals-basic.html create mode 100644 web-ui/partials/index/panel-config-claude.html create mode 100644 web-ui/partials/index/panel-config-codex.html create mode 100644 web-ui/partials/index/panel-config-openclaw.html create mode 100644 web-ui/partials/index/panel-market.html create mode 100644 web-ui/partials/index/panel-sessions.html create mode 100644 web-ui/partials/index/panel-settings.html create mode 100644 web-ui/source-bundle.cjs create mode 100644 web-ui/styles/base-theme.css create mode 100644 web-ui/styles/controls-forms.css create mode 100644 web-ui/styles/feedback.css create mode 100644 web-ui/styles/layout-shell.css create mode 100644 web-ui/styles/modals-core.css create mode 100644 web-ui/styles/navigation-panels.css create mode 100644 web-ui/styles/openclaw-structured.css create mode 100644 web-ui/styles/responsive.css create mode 100644 web-ui/styles/sessions-list.css create mode 100644 web-ui/styles/sessions-preview.css create mode 100644 web-ui/styles/sessions-toolbar-trash.css create mode 100644 web-ui/styles/skills-list.css create mode 100644 web-ui/styles/skills-market.css create mode 100644 web-ui/styles/titles-cards.css diff --git a/cli.js b/cli.js index 8a35576..26e6ff8 100644 --- a/cli.js +++ b/cli.js @@ -62,6 +62,12 @@ const { validateWorkflowDefinition, executeWorkflowDefinition } = require('./lib/workflow-engine'); +const { + readBundledWebUiCss, + readBundledWebUiHtml, + readExecutableBundledJavaScriptModule, + readExecutableBundledWebUiScript +} = require('./web-ui/source-bundle.cjs'); const DEFAULT_WEB_PORT = 3737; const DEFAULT_WEB_HOST = '0.0.0.0'; @@ -9945,10 +9951,11 @@ function formatHostForUrl(host) { return value; } +// #region watchPathsForRestart function watchPathsForRestart(targets, onChange) { - const disposers = []; const debounceMs = 300; let timer = null; + const watcherEntries = new Map(); const trigger = (info) => { if (timer) clearTimeout(timer); @@ -9958,35 +9965,133 @@ function watchPathsForRestart(targets, onChange) { }, debounceMs); }; - const addWatcher = (target, recursive) => { + const closeWatcher = (watchKey) => { + const entry = watcherEntries.get(watchKey); + if (!entry) return; + watcherEntries.delete(watchKey); + try { + entry.watcher.close(); + } catch (_) {} + }; + + const listDirectoryTree = (rootDir) => { + const queue = [rootDir]; + const directories = []; + const seen = new Set(); + while (queue.length) { + const current = queue.shift(); + if (!current || seen.has(current) || !fs.existsSync(current)) { + continue; + } + seen.add(current); + let stat = null; + try { + stat = fs.statSync(current); + } catch (_) { + continue; + } + if (!stat || !stat.isDirectory()) { + continue; + } + directories.push(current); + let entries = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch (_) { + continue; + } + for (const entry of entries) { + if (entry && typeof entry.isDirectory === 'function' && entry.isDirectory()) { + queue.push(path.join(current, entry.name)); + } + } + } + return directories; + }; + + const isSameOrNestedPath = (candidate, rootDir) => { + return candidate === rootDir || candidate.startsWith(`${rootDir}${path.sep}`); + }; + + const addWatcher = (target, recursive, isDirectory = false) => { if (!fs.existsSync(target)) return; + const watchKey = `${recursive ? 'recursive' : 'plain'}:${target}`; + if (watcherEntries.has(watchKey)) { + return true; + } try { const watcher = fs.watch(target, { recursive }, (eventType, filename) => { + if (isDirectory && !recursive && eventType === 'rename') { + syncDirectoryTree(target); + } if (!filename) return; const lower = filename.toLowerCase(); if (!(/\.(html|js|mjs|css)$/.test(lower))) return; trigger({ target, eventType, filename }); }); - disposers.push(() => watcher.close()); + watcherEntries.set(watchKey, { + watcher, + target, + recursive, + isDirectory + }); return true; } catch (e) { return false; } }; + const syncDirectoryTree = (rootDir) => { + const directories = listDirectoryTree(rootDir); + const existingDirectorySet = new Set(directories); + for (const [watchKey, entry] of Array.from(watcherEntries.entries())) { + if (!entry.isDirectory || entry.recursive) { + continue; + } + if (!isSameOrNestedPath(entry.target, rootDir)) { + continue; + } + if (!existingDirectorySet.has(entry.target)) { + closeWatcher(watchKey); + } + } + for (const directory of directories) { + addWatcher(directory, false, true); + } + }; + for (const target of targets) { - const ok = addWatcher(target, true); + if (!fs.existsSync(target)) continue; + let stat = null; + try { + stat = fs.statSync(target); + } catch (_) { + continue; + } + if (stat && stat.isDirectory()) { + const ok = addWatcher(target, true, true); + if (!ok) { + syncDirectoryTree(target); + } + continue; + } + const ok = addWatcher(target, true, false); if (!ok) { - addWatcher(target, false); + addWatcher(target, false, false); } } return () => { - for (const dispose of disposers) { - try { dispose(); } catch (_) {} + if (timer) { + clearTimeout(timer); + timer = null; + } + for (const watchKey of Array.from(watcherEntries.keys())) { + closeWatcher(watchKey); } }; } +// #endregion watchPathsForRestart function writeJsonResponse(res, statusCode, payload) { const body = JSON.stringify(payload, null, 2); @@ -10131,6 +10236,32 @@ async function handleImportSkillsZipUpload(req, res, options = {}) { } } +const PUBLIC_WEB_UI_DYNAMIC_ASSETS = new Map([ + ['app.js', { + mime: 'application/javascript; charset=utf-8', + reader: readExecutableBundledWebUiScript + }], + ['index.html', { + mime: 'text/html; charset=utf-8', + reader: readBundledWebUiHtml + }], + ['logic.mjs', { + mime: 'application/javascript; charset=utf-8', + reader: readExecutableBundledJavaScriptModule + }], + ['styles.css', { + mime: 'text/css; charset=utf-8', + reader: readBundledWebUiCss + }] +]); + +const PUBLIC_WEB_UI_STATIC_ASSETS = new Set([ + 'modules/config-mode.computed.mjs', + 'modules/skills.computed.mjs', + 'modules/skills.methods.mjs', + 'session-helpers.mjs' +]); + function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) { const connections = new Set(); @@ -10562,6 +10693,10 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser res.end(errorBody, 'utf-8'); } }); + } else if (requestPath === '/web-ui') { + const html = readBundledWebUiHtml(htmlPath); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); } else if (requestPath.startsWith('/web-ui/')) { const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, ''); const filePath = path.join(__dirname, normalized); @@ -10570,11 +10705,23 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser res.end('Forbidden'); return; } + const relativePath = path.relative(webDir, filePath).replace(/\\/g, '/'); if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); return; } + const dynamicAsset = PUBLIC_WEB_UI_DYNAMIC_ASSETS.get(relativePath); + if (dynamicAsset) { + res.writeHead(200, { 'Content-Type': dynamicAsset.mime }); + res.end(dynamicAsset.reader(filePath), 'utf-8'); + return; + } + if (!PUBLIC_WEB_UI_STATIC_ASSETS.has(relativePath)) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } const ext = path.extname(filePath).toLowerCase(); const mime = ext === '.js' || ext === '.mjs' ? 'application/javascript; charset=utf-8' @@ -10642,7 +10789,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser res.writeHead(200, { 'Content-Type': mime }); fs.createReadStream(filePath).pipe(res); } else { - const html = fs.readFileSync(htmlPath, 'utf-8'); + const html = readBundledWebUiHtml(htmlPath); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } diff --git a/tests/e2e/run.js b/tests/e2e/run.js index aa5442f..b81c320 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -23,6 +23,7 @@ const testMessages = require('./test-messages'); const testMcp = require('./test-mcp'); const testWorkflow = require('./test-workflow'); const testInvalidConfig = require('./test-invalid-config'); +const testWebUiAssets = require('./test-web-ui-assets'); async function main() { const realHome = os.homedir(); @@ -118,6 +119,7 @@ async function main() { await testMessages(ctx); await testMcp(ctx); await testWorkflow(ctx); + await testWebUiAssets(ctx); } finally { const waitForExit = new Promise((resolve) => { diff --git a/tests/e2e/test-web-ui-assets.js b/tests/e2e/test-web-ui-assets.js new file mode 100644 index 0000000..0c462b6 --- /dev/null +++ b/tests/e2e/test-web-ui-assets.js @@ -0,0 +1,124 @@ +const http = require('http'); +const { assert } = require('./helpers'); + +function getText(port, requestPath, timeoutMs = 2000) { + return new Promise((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', + port, + path: requestPath, + method: 'GET' + }, (res) => { + let body = ''; + res.setEncoding('utf-8'); + res.on('data', chunk => body += chunk); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers || {}, + body + }); + }); + }); + + req.on('error', reject); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error('Request timeout')); + }); + req.end(); + }); +} + +module.exports = async function testWebUiAssets(ctx) { + const { port } = ctx; + + const rootPage = await getText(port, '/'); + assert(rootPage.statusCode === 200, 'root web ui page should return 200'); + assert( + /^text\/html\b/.test(String(rootPage.headers['content-type'] || '')), + 'root web ui page should return html content type' + ); + assert(rootPage.body.includes('id="panel-market"'), 'root web ui page should inline market panel'); + assert(rootPage.body.includes('class="modal modal-wide skills-modal"'), 'root web ui page should inline skills modal'); + assert(rootPage.body.includes('src="/web-ui/app.js"'), 'root web ui page should point to the absolute app entry'); + assert(!rootPage.body.includes('src="web-ui/app.js"'), 'root web ui page should not use a relative app entry'); + assert(!/ -
service_tier。
- ~/.claude/settings.json。
- ~/.openclaw/openclaw.json。支持 JSON5(注释/尾逗号)。
- ~/.openclaw/workspace/AGENTS.md。
- .md 文件。
- {{ sessionStandaloneText }}
- {{ skillsRootPath || skillsDefaultRootPath }} 的前 6 个目录,可继续进入管理弹窗做筛选、导出和删除。