From f46c2fb5d099e585ec16e7497a98d69f5ed0a591 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sat, 6 Jun 2026 00:38:54 +0800 Subject: [PATCH] fix: stabilize local OCR and prod startup --- .../browser/document-resource-tab-runtime.js | 43 ++++- .../browser/sidebar-page-settings-runtime.js | 22 +++ rust/crates/mnote-web/src/routes/local_ocr.rs | 2 +- scripts/prod-build-start.js | 157 +++++++++++++++++- 4 files changed, 212 insertions(+), 12 deletions(-) diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js index c520750e..25ad3081 100644 --- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js +++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js @@ -1506,6 +1506,12 @@ export const createResourceTabRuntime = (dependencies = {}) => { const statusTextForLocalOcrJob = (job) => { const status = String(job?.status || '').trim(); if (job?.stageLabel) return String(job.stageLabel); + if (job?.taskKind === 'local_index') { + return status === 'done' ? '索引已完成' + : status === 'failed' ? '索引失败' + : status === 'running' ? '索引中' + : status || '未知'; + } return status === 'done' ? '已识别' : status === 'failed' ? '识别失败' : status === 'stale' ? '来源已变化' @@ -1513,6 +1519,11 @@ export const createResourceTabRuntime = (dependencies = {}) => { : status || '未知'; }; + const localOcrDisplayStatus = (job, fallback = 'done') => { + const status = String(job?.status || fallback).trim() || fallback; + return job?.stale === true && status === 'done' ? 'stale' : status; + }; + const localOcrTaskCategory = (job) => { const status = String(job?.status || '').trim(); if (['failed', 'stale', 'retry_scheduled'].includes(status)) return 'attention'; @@ -1755,11 +1766,12 @@ export const createResourceTabRuntime = (dependencies = {}) => { if (open instanceof HTMLButtonElement) { open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || '')); open.disabled = !job.ocrRootRelativePath; + open.hidden = job.taskKind === 'local_index'; } const retry = row.querySelector('[data-mnote-local-ocr-task-retry]'); if (retry instanceof HTMLButtonElement) { retry.setAttribute('data-mnote-local-ocr-task-retry', String(job.sourceRootRelativePath || '')); - retry.hidden = !['failed', 'stale'].includes(String(job.status || '')); + retry.hidden = job.taskKind === 'local_index' || !['failed', 'stale'].includes(String(job.status || '')); } const clear = row.querySelector('[data-mnote-local-ocr-task-clear]'); if (clear instanceof HTMLButtonElement) { @@ -1768,7 +1780,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { const deleteOcr = row.querySelector('[data-mnote-local-ocr-task-delete]'); if (deleteOcr instanceof HTMLButtonElement) { deleteOcr.setAttribute('data-mnote-local-ocr-task-delete', String(job.sourceRootRelativePath || '')); - deleteOcr.hidden = !job.ocrRootRelativePath; + deleteOcr.hidden = job.taskKind === 'local_index' || !job.ocrRootRelativePath; } list.appendChild(row); }); @@ -1882,7 +1894,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.ok !== true) { - const message = payload?.error?.message || `local_ocr_job_failed_${response.status}`; + const message = payload?.error?.message || payload?.message || payload?.error?.code || `local_ocr_job_failed_${response.status}`; const failedJob = { ...pendingJob, status: 'failed', @@ -1896,7 +1908,8 @@ export const createResourceTabRuntime = (dependencies = {}) => { throw new Error(message); } const job = payload.job && typeof payload.job === 'object' ? { ...payload.job, rootUri: entryRootUri } : null; - setLocalOcrStatus(entry, String(job?.status || 'done'), job?.stale ? 'OCR 需更新' : 'OCR 已完成', job); + const displayStatus = localOcrDisplayStatus(job, 'done'); + setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? 'OCR 需更新' : 'OCR 已完成', job); if (job) updateLocalOcrTaskState(job); return job; }; @@ -2052,6 +2065,25 @@ export const createResourceTabRuntime = (dependencies = {}) => { } }); + window.addEventListener('mnote:local-background-task-updated', (event) => { + const task = event?.detail?.task && typeof event.detail.task === 'object' ? event.detail.task : null; + if (!task) return; + updateLocalOcrTaskState({ + taskKind: String(task.taskKind || 'local_index'), + jobId: String(task.jobId || task.taskId || `local-index-${Date.now()}`), + sourceRootRelativePath: String(task.sourceRootRelativePath || task.taskId || 'local-index'), + rootUri: String(task.rootUri || localOcrTaskState.rootUri || currentWebShellRootUri() || '').trim(), + ocrRootRelativePath: '', + provider: String(task.provider || 'mnote-web'), + status: String(task.status || 'running'), + stageLabel: String(task.stageLabel || ''), + stale: false, + updatedAtMs: Number(task.updatedAtMs || Date.now()), + finishedAtMs: task.finishedAtMs == null ? null : Number(task.finishedAtMs), + error: task.error || null, + }); + }); + const renderLocalOcrToolbar = (entry) => { if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return; const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]'); @@ -2092,7 +2124,8 @@ export const createResourceTabRuntime = (dependencies = {}) => { void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); void readLocalOcrStatus(entry).then((job) => { if (!job) return; - setLocalOcrStatus(entry, job.stale ? 'stale' : String(job.status || 'done'), job.stale ? 'OCR 需更新' : 'OCR 已完成', job); + const displayStatus = localOcrDisplayStatus(job, 'done'); + setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? 'OCR 需更新' : 'OCR 已完成', job); updateLocalOcrTaskState(job); }).catch(() => undefined); }; diff --git a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js index 60f737b0..3909d14b 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js @@ -961,8 +961,28 @@ export function createSidebarPageSettingsRuntime(context) { } } + function emitLocalIndexBackgroundTask(status, label, error) { + var now = Date.now(); + window.dispatchEvent(new CustomEvent('mnote:local-background-task-updated', { + detail: { + task: { + taskKind: 'local_index', + taskId: 'local-index', + sourceRootRelativePath: '本地索引', + rootUri: currentRootUri(), + status: status, + stageLabel: label, + updatedAtMs: now, + finishedAtMs: status === 'running' ? null : now, + error: error || null + } + } + })); + } + async function refreshLocalIndex() { if (!pageSettingsLocalIndexIsAvailable()) return; + emitLocalIndexBackgroundTask('running', '本地索引重建中', null); pageUiState.localIndexSummary = Object.assign({}, pageUiState.localIndexSummary || {}, { scopeKey: pageSettingsLocalIndexScopeKey(), loading: true, @@ -982,8 +1002,10 @@ export function createSidebarPageSettingsRuntime(context) { if (!response.ok || !payload || payload.ok !== true) { throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'local_index_refresh_' + response.status); } + emitLocalIndexBackgroundTask('done', '本地索引已完成', null); await loadPageSettingsLocalIndex(true); } catch (error) { + emitLocalIndexBackgroundTask('failed', '本地索引失败', error instanceof Error ? error.message : String(error)); pageUiState.localIndexSummary = Object.assign({}, pageUiState.localIndexSummary || {}, { scopeKey: pageSettingsLocalIndexScopeKey(), loading: false, diff --git a/rust/crates/mnote-web/src/routes/local_ocr.rs b/rust/crates/mnote-web/src/routes/local_ocr.rs index 8571762a..9d9c6887 100644 --- a/rust/crates/mnote-web/src/routes/local_ocr.rs +++ b/rust/crates/mnote-web/src/routes/local_ocr.rs @@ -1712,7 +1712,7 @@ fn broadcast_ocr_job_update(state: &AppState, root: &Path, root_uri: &str, entry } fn ocr_job_payload(root: &Path, entry: &OcrIndexEntry) -> Value { - let stale = source_is_stale(root, entry); + let stale = entry.status == "done" && source_is_stale(root, entry); json!({ "jobId": entry.job_id, "ownerDocumentId": entry.owner_document_id, diff --git a/scripts/prod-build-start.js b/scripts/prod-build-start.js index 52a00c68..6116abe6 100644 --- a/scripts/prod-build-start.js +++ b/scripts/prod-build-start.js @@ -25,6 +25,80 @@ const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod"); const RUN_STATE_PATH = path.join(RUN_DIR, "process.json"); const DEFAULT_PORT = 3003; const DEFAULT_CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; +const ENV_ALL_PATH = path.join(ROOT, ".env.all"); +const RELEASE_BUILD_SCHEMA = "mnote-web-prod-runtime-assets-v2"; +const SUPPLEMENTAL_RUNTIME_ASSETS = [ + { + source: "node_modules/jszip/dist/jszip.min.js", + target: "node_modules/jszip/dist/jszip.min.js", + }, + { + source: "node_modules/docx-preview/dist/docx-preview.min.js", + target: "node_modules/docx-preview/dist/docx-preview.min.js", + }, + { + source: "node_modules/@e965/xlsx/dist/xlsx.full.min.js", + target: "node_modules/@e965/xlsx/dist/xlsx.full.min.js", + }, + { + source: "node_modules/pptx-preview/dist/pptx-preview.umd.js", + target: "node_modules/pptx-preview/dist/pptx-preview.umd.js", + }, + { + source: "node_modules/pdfjs-dist/build/pdf.mjs", + target: "node_modules/pdfjs-dist/build/pdf.mjs", + }, + { + source: "node_modules/pdfjs-dist/build/pdf.worker.mjs", + target: "node_modules/pdfjs-dist/build/pdf.worker.mjs", + }, + { + source: "reference-code/leptos-tiptap/src/js/generated/tiptap_mindmap_paragraph_runtime.js", + target: "reference-code/leptos-tiptap/src/js/generated/tiptap_mindmap_paragraph_runtime.js", + }, +]; + +const STARTUP_ASSET_PROBES = [ + "/api/office-preview/vendor/docx-preview.min.js", + "/api/office-preview/vendor/xlsx.full.min.js", + "/api/office-preview/vendor/pptx-preview.umd.js", + "/api/pdfjs/pdf.mjs", + "/api/pdfjs/pdf.worker.mjs", + "/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js", +]; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return {}; + const content = fs.readFileSync(filePath, "utf8"); + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .reduce((acc, line) => { + const normalized = line.startsWith("export ") ? line.slice("export ".length).trim() : line; + const idx = normalized.indexOf("="); + if (idx === -1) return acc; + const key = normalized.slice(0, idx).trim(); + let value = normalized.slice(idx + 1).trim(); + if (!key) return acc; + if ( + (value.startsWith('"') && value.endsWith('"')) + || (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + acc[key] = value; + return acc; + }, {}); +} + +function loadProdEnv() { + const envFromAll = loadEnvFile(ENV_ALL_PATH); + if (Object.keys(envFromAll).length > 0) { + Object.assign(process.env, envFromAll); + console.log(`[prod] 已加载 .env.all (${Object.keys(envFromAll).length} 项)`); + } +} function runChecked(command, args, options = {}) { const result = spawnSync(command, args, { @@ -88,6 +162,15 @@ async function pathExists(filePath) { } } +async function releaseRuntimeAssetsReady(sourceDir) { + for (const asset of SUPPLEMENTAL_RUNTIME_ASSETS) { + if (!(await pathExists(path.join(sourceDir, asset.target)))) { + return false; + } + } + return true; +} + async function listReleases() { if (!(await pathExists(RELEASE_ROOT))) return []; const entries = await fsp.readdir(RELEASE_ROOT, { withFileTypes: true }); @@ -104,7 +187,10 @@ async function listReleases() { const metadata = JSON.parse(await fsp.readFile(metadataPath, "utf8")); const binaryPath = path.join(releaseDir, "mnote-web"); const sourceDir = path.join(releaseDir, "source"); - const usable = (await pathExists(binaryPath)) && (await pathExists(sourceDir)); + const usable = (await pathExists(binaryPath)) + && (await pathExists(sourceDir)) + && metadata.buildSchema === RELEASE_BUILD_SCHEMA + && (await releaseRuntimeAssetsReady(sourceDir)); releases.push({ version: entry.name, releaseDir, metadata, usable }); } catch { releases.push({ version: entry.name, releaseDir, metadata: null, usable: false }); @@ -170,6 +256,23 @@ function pipeGitArchive(head, destination) { }); } +async function copyRuntimeAsset(sourceDir, asset) { + const from = path.join(ROOT, asset.source); + const to = path.join(sourceDir, asset.target); + if (!(await pathExists(from))) { + throw new Error(`缺少生产运行时资产:${asset.source},请先在仓库根目录运行 npm install`); + } + await fsp.mkdir(path.dirname(to), { recursive: true }); + await fsp.copyFile(from, to); +} + +async function copySupplementalRuntimeAssets(sourceDir) { + console.log("[prod] 打包 Office/PDF/思维导图运行时资产 ..."); + for (const asset of SUPPLEMENTAL_RUNTIME_ASSETS) { + await copyRuntimeAsset(sourceDir, asset); + } +} + async function buildRelease(head) { const version = await nextReleaseVersion(); const releaseDir = path.join(RELEASE_ROOT, version); @@ -180,8 +283,9 @@ async function buildRelease(head) { await fsp.mkdir(sourceDir, { recursive: true }); console.log(`[prod] 创建 release ${version}: ${releaseDir}`); await pipeGitArchive(head, sourceDir); + await copySupplementalRuntimeAssets(sourceDir); - const targetDir = path.join(BUILD_CACHE_DIR, "target"); + const targetDir = path.join(BUILD_CACHE_DIR, version, "target"); await fsp.mkdir(targetDir, { recursive: true }); console.log(`[prod] 构建已提交 HEAD ${head.slice(0, 12)} ...`); @@ -204,14 +308,17 @@ async function buildRelease(head) { const metadata = { schema: "mnote.prod_release.v1", + buildSchema: RELEASE_BUILD_SCHEMA, version, gitHead: head, builtAt: new Date().toISOString(), binaryPath, sourceDir, cargoTargetDir: targetDir, + runtimeAssets: SUPPLEMENTAL_RUNTIME_ASSETS.map((asset) => asset.target), }; await fsp.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); + await fsp.rm(targetDir, { recursive: true, force: true }); return { version, releaseDir, metadata, usable: true }; } @@ -358,6 +465,28 @@ function requestLocalAuth(port) { }); } +function requestLocalPath(port, requestPath) { + return new Promise((resolve) => { + const req = http.get( + { + host: "127.0.0.1", + port, + path: requestPath, + timeout: 1000, + }, + (res) => { + res.resume(); + resolve(res.statusCode && res.statusCode >= 200 && res.statusCode < 300); + }, + ); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + req.on("error", () => resolve(false)); + }); +} + async function waitForReady(port, logPath, pid) { for (let i = 0; i < 60; i += 1) { if (!isProcessAlive(pid)) break; @@ -374,6 +503,18 @@ async function waitForReady(port, logPath, pid) { throw new Error(`mnote-web 启动失败或未就绪,日志:\n${logTail}`); } +async function verifyStartupAssets(port) { + const failed = []; + for (const requestPath of STARTUP_ASSET_PROBES) { + // eslint-disable-next-line no-await-in-loop + const ok = await requestLocalPath(port, requestPath); + if (!ok) failed.push(requestPath); + } + if (failed.length > 0) { + throw new Error(`生产运行时资产探测失败:${failed.join(", ")}`); + } +} + async function startRelease(release) { const port = Number(process.env.MNOTE_PROD_PORT || DEFAULT_PORT); if (!Number.isInteger(port) || port <= 0 || port > 65535) { @@ -419,6 +560,7 @@ async function startRelease(release) { }; await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8"); await waitForReady(port, logPath, child.pid); + await verifyStartupAssets(port); console.log(`[prod] 已启动 mnote-web release ${release.version}`); console.log(`[prod] pid=${child.pid}`); @@ -430,6 +572,7 @@ async function startRelease(release) { } async function main() { + loadProdEnv(); const head = currentGitHead(); await fsp.mkdir(RELEASE_ROOT, { recursive: true }); @@ -443,7 +586,9 @@ async function main() { await startRelease(release); } -main().catch((error) => { - console.error(`[prod] ${error.message}`); - process.exit(1); -}); +if (require.main === module) { + main().catch((error) => { + console.error(`[prod] ${error.message}`); + process.exit(1); + }); +}