diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0b45b9..592a827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: @@ -23,4 +25,6 @@ jobs: - name: Lint run: npm run lint --if-present - name: Test + env: + WEB_UI_PARITY_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} run: npm run test --if-present diff --git a/.github/workflows/coderabbit-review.yml b/.github/workflows/coderabbit-review.yml index 4365755..b6efe1c 100644 --- a/.github/workflows/coderabbit-review.yml +++ b/.github/workflows/coderabbit-review.yml @@ -56,6 +56,7 @@ jobs: const body = [ "@coderabbitai re-review", "Stop making breaking changes, do a proper review!", + "If I merge this directly, will it introduce any regressions? Please list only the impacted issues. Do not include style suggestions, speculative concerns, or already-resolved items.", ``, ].join("\n"); diff --git a/cli.js b/cli.js index 8a35576..f79435d 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,201 @@ 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) => { + const basename = isDirectory ? '' : path.basename(target); + const watchTarget = isDirectory ? target : path.dirname(target); + const watcher = fs.watch(watchTarget, { 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 }); + let normalizedFilename = String(filename).replace(/\\/g, '/'); + if (!isDirectory) { + const fileNameOnly = normalizedFilename.split('/').pop(); + if (fileNameOnly !== basename) { + return; + } + normalizedFilename = basename; + } + const lower = normalizedFilename.toLowerCase(); + if (!(/\.(html|js|mjs|cjs|css)$/.test(lower))) return; + trigger({ target, eventType, filename: normalizedFilename }); + }); + watcher.on('error', () => { + closeWatcher(watchKey); + if (isDirectory && recursive && !fs.existsSync(target)) { + syncDirectoryTree(target); + addMissingDirectoryWatcher(target); + return; + } + if (isDirectory && !recursive) { + syncDirectoryTree(target); + } else if (fs.existsSync(target)) { + addWatcher(target, recursive, isDirectory); + } + }); + watcherEntries.set(watchKey, { + watcher, + target, + recursive, + isDirectory }); - disposers.push(() => watcher.close()); return true; } catch (e) { return false; } }; + const addMissingDirectoryWatcher = (target) => { + const parentDir = path.dirname(target); + if (!parentDir || parentDir === target || !fs.existsSync(parentDir)) { + return false; + } + const watchKey = `missing-dir:${target}`; + if (watcherEntries.has(watchKey)) { + return true; + } + const basename = path.basename(target); + try { + const watcher = fs.watch(parentDir, { recursive: false }, (_eventType, filename) => { + if (!filename) return; + const fileNameOnly = String(filename).replace(/\\/g, '/').split('/').pop(); + if (fileNameOnly !== basename) { + return; + } + if (!fs.existsSync(target)) { + syncDirectoryTree(target); + return; + } + closeWatcher(watchKey); + const ok = addWatcher(target, true, true); + if (!ok) { + syncDirectoryTree(target); + } + }); + watcher.on('error', () => { + closeWatcher(watchKey); + if (fs.existsSync(parentDir) && !fs.existsSync(target)) { + addMissingDirectoryWatcher(target); + } + }); + watcherEntries.set(watchKey, { + watcher, + target: parentDir, + recursive: false, + isDirectory: false + }); + return true; + } catch (_) { + 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,8 +10304,46 @@ 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(); + const writeWebUiAssetError = (res, requestPath, error) => { + const message = error && error.message ? error.message : String(error); + console.error(`! Web UI 资源读取失败 [${requestPath}]:`, message); + if (res.headersSent) { + try { + res.destroy(error); + } catch (_) {} + return; + } + res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Internal Server Error'); + }; const server = http.createServer((req, res) => { const requestPath = (req.url || '/').split('?')[0]; @@ -10562,6 +10773,14 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser res.end(errorBody, 'utf-8'); } }); + } else if (requestPath === '/web-ui') { + try { + const html = readBundledWebUiHtml(htmlPath); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + } catch (error) { + writeWebUiAssetError(res, requestPath, error); + } } else if (requestPath.startsWith('/web-ui/')) { const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, ''); const filePath = path.join(__dirname, normalized); @@ -10570,6 +10789,23 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser res.end('Forbidden'); return; } + const relativePath = path.relative(webDir, filePath).replace(/\\/g, '/'); + const dynamicAsset = PUBLIC_WEB_UI_DYNAMIC_ASSETS.get(relativePath); + if (dynamicAsset) { + try { + const assetBody = dynamicAsset.reader(filePath); + res.writeHead(200, { 'Content-Type': dynamicAsset.mime }); + res.end(assetBody, 'utf-8'); + } catch (error) { + writeWebUiAssetError(res, requestPath, error); + } + return; + } + if (!PUBLIC_WEB_UI_STATIC_ASSETS.has(relativePath)) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); @@ -10642,9 +10878,13 @@ 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'); - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(html); + try { + const html = readBundledWebUiHtml(htmlPath); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + } catch (error) { + writeWebUiAssetError(res, requestPath, error); + } } }); 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 个目录,可继续进入管理弹窗做筛选、导出和删除。