收口 MinerU OCR 任务链与上下文
This commit is contained in:
@@ -1039,6 +1039,169 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return payload.job || null;
|
||||
};
|
||||
|
||||
const localOcrTaskState = {
|
||||
rootUri: '',
|
||||
jobsBySource: new Map(),
|
||||
drawerOpen: false,
|
||||
eventSource: null,
|
||||
};
|
||||
|
||||
const localOcrJobKey = (job) => String(job?.sourceRootRelativePath || job?.jobId || '').trim();
|
||||
|
||||
const updateLocalOcrTaskState = (job) => {
|
||||
const key = localOcrJobKey(job);
|
||||
if (!key) return;
|
||||
localOcrTaskState.jobsBySource.set(key, job);
|
||||
renderLocalOcrTaskDock();
|
||||
};
|
||||
|
||||
const statusTextForLocalOcrJob = (job) => {
|
||||
const status = String(job?.status || '').trim();
|
||||
if (job?.stageLabel) return String(job.stageLabel);
|
||||
return status === 'done' ? '已识别'
|
||||
: status === 'failed' ? '识别失败'
|
||||
: status === 'stale' ? '来源已变化'
|
||||
: status === 'running' ? '处理中'
|
||||
: status || '未知';
|
||||
};
|
||||
|
||||
const ensureLocalOcrTaskDock = () => {
|
||||
let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]');
|
||||
if (dock instanceof HTMLElement) return dock;
|
||||
dock = document.createElement('section');
|
||||
dock.className = 'mnote-local-ocr-task-dock';
|
||||
dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock');
|
||||
dock.innerHTML = '<button type="button" class="mnote-local-ocr-task-button" data-testid="mnote-local-ocr-task-toggle" aria-expanded="false">OCR 0</button><div class="mnote-local-ocr-task-drawer" data-testid="mnote-local-ocr-task-drawer" hidden><div class="mnote-local-ocr-task-head"><strong>OCR 任务</strong></div><div class="mnote-local-ocr-task-list" data-testid="mnote-local-ocr-task-list"></div></div>';
|
||||
document.body.appendChild(dock);
|
||||
const toggle = dock.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
|
||||
if (toggle instanceof HTMLButtonElement) {
|
||||
toggle.addEventListener('click', () => {
|
||||
localOcrTaskState.drawerOpen = !localOcrTaskState.drawerOpen;
|
||||
renderLocalOcrTaskDock();
|
||||
});
|
||||
}
|
||||
dock.addEventListener('click', (event) => {
|
||||
const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-open]') : null;
|
||||
if (openButton instanceof HTMLElement) {
|
||||
const sourcePath = openButton.getAttribute('data-mnote-local-ocr-task-open') || '';
|
||||
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
|
||||
if (job) {
|
||||
void openResourceInActiveTab({
|
||||
kind: 'markdown',
|
||||
title: String(job.ocrRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR',
|
||||
path: job.ocrRootRelativePath,
|
||||
objectIdentity: `local-ocr:${job.ocrRootRelativePath}`,
|
||||
assetId: `local-ocr:${job.ocrRootRelativePath}`,
|
||||
documentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
|
||||
ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
|
||||
workspaceId: currentWebShellWorkspaceId() || '',
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: localOcrTaskState.rootUri || '',
|
||||
resourceKind: 'markdown',
|
||||
}).catch((error) => console.warn('mnote local OCR 任务打开失败', error));
|
||||
}
|
||||
}
|
||||
const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-retry]') : null;
|
||||
if (retryButton instanceof HTMLElement) {
|
||||
const sourcePath = retryButton.getAttribute('data-mnote-local-ocr-task-retry') || '';
|
||||
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
|
||||
if (job) {
|
||||
const entry = {
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: localOcrTaskState.rootUri,
|
||||
path: sourcePath,
|
||||
kind: sourcePath.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image',
|
||||
title: sourcePath.split('/').filter(Boolean).pop() || sourcePath,
|
||||
documentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
|
||||
ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
|
||||
workspaceId: currentWebShellWorkspaceId() || '',
|
||||
localOcrJob: job,
|
||||
};
|
||||
void createLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 任务重试失败', error));
|
||||
}
|
||||
}
|
||||
});
|
||||
return dock;
|
||||
};
|
||||
|
||||
const renderLocalOcrTaskDock = () => {
|
||||
const dock = ensureLocalOcrTaskDock();
|
||||
const jobs = Array.from(localOcrTaskState.jobsBySource.values())
|
||||
.sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0));
|
||||
const runningCount = jobs.filter((job) => !['done', 'failed', 'stale'].includes(String(job?.status || ''))).length;
|
||||
const toggle = dock.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
|
||||
if (toggle instanceof HTMLButtonElement) {
|
||||
toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`;
|
||||
toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
|
||||
}
|
||||
const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
|
||||
if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen;
|
||||
const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]');
|
||||
if (!(list instanceof HTMLElement)) return;
|
||||
list.replaceChildren();
|
||||
if (!jobs.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'mnote-local-ocr-task-empty';
|
||||
empty.textContent = '暂无 OCR 任务';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
jobs.forEach((job) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mnote-local-ocr-task-row';
|
||||
row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || ''));
|
||||
row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || ''));
|
||||
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR';
|
||||
row.innerHTML = '<div class="mnote-local-ocr-task-main"><strong></strong><span></span></div><div class="mnote-local-ocr-task-actions"><button type="button" data-mnote-local-ocr-task-open>打开 OCR</button><button type="button" data-mnote-local-ocr-task-retry>重试</button></div>';
|
||||
row.querySelector('strong').textContent = title;
|
||||
row.querySelector('span').textContent = statusTextForLocalOcrJob(job);
|
||||
const open = row.querySelector('[data-mnote-local-ocr-task-open]');
|
||||
if (open instanceof HTMLButtonElement) {
|
||||
open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || ''));
|
||||
open.disabled = !job.ocrRootRelativePath;
|
||||
}
|
||||
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 || ''));
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const loadLocalOcrJobs = async (rootUri) => {
|
||||
const normalizedRoot = String(rootUri || '').trim();
|
||||
if (!normalizedRoot) return;
|
||||
localOcrTaskState.rootUri = normalizedRoot;
|
||||
const url = new URL('/api/local-folder/ocr/jobs', window.location.origin);
|
||||
url.searchParams.set('rootUri', normalizedRoot);
|
||||
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) return;
|
||||
(Array.isArray(payload.jobs) ? payload.jobs : []).forEach(updateLocalOcrTaskState);
|
||||
renderLocalOcrTaskDock();
|
||||
};
|
||||
|
||||
const ensureLocalOcrTaskEvents = (rootUri) => {
|
||||
const normalizedRoot = String(rootUri || '').trim();
|
||||
if (!normalizedRoot || typeof window.EventSource !== 'function') return;
|
||||
if (localOcrTaskState.eventSource && localOcrTaskState.rootUri === normalizedRoot) return;
|
||||
if (localOcrTaskState.eventSource) {
|
||||
try { localOcrTaskState.eventSource.close(); } catch (_) {}
|
||||
localOcrTaskState.eventSource = null;
|
||||
}
|
||||
localOcrTaskState.rootUri = normalizedRoot;
|
||||
const url = new URL('/api/local-folder/events', window.location.origin);
|
||||
url.searchParams.set('rootUri', normalizedRoot);
|
||||
const eventSource = new EventSource(url.toString());
|
||||
localOcrTaskState.eventSource = eventSource;
|
||||
eventSource.addEventListener('local_ocr.job.updated', (event) => {
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(event.data || '{}'); } catch (_) {}
|
||||
if (payload?.job) updateLocalOcrTaskState(payload.job);
|
||||
});
|
||||
};
|
||||
|
||||
const openLocalOcrSidecar = async (entry, job) => {
|
||||
const target = job || entry?.localOcrJob || null;
|
||||
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
|
||||
@@ -1108,6 +1271,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
const job = payload.job || null;
|
||||
setLocalOcrStatus(entry, String(job?.status || 'done'), job?.stale ? 'OCR 需更新' : 'OCR 已完成', job);
|
||||
if (job) updateLocalOcrTaskState(job);
|
||||
return job;
|
||||
};
|
||||
|
||||
@@ -1146,9 +1310,13 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
});
|
||||
}
|
||||
setLocalOcrStatus(entry, 'idle', 'OCR 未生成', null);
|
||||
ensureLocalOcrTaskDock();
|
||||
ensureLocalOcrTaskEvents(entry.rootUri);
|
||||
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);
|
||||
updateLocalOcrTaskState(job);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
|
||||
@@ -463,6 +463,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
const pageAiWorkspacePathForTarget = (...args) => pageAiTargetRuntime.pageAiWorkspacePathForTarget(...args);
|
||||
const pageAiBuildAllowedRoots = (...args) => pageAiTargetRuntime.pageAiBuildAllowedRoots(...args);
|
||||
const pageAiBuildContextRefs = (...args) => pageAiTargetRuntime.pageAiBuildContextRefs(...args);
|
||||
const pageAiEnrichOcrContextRefs = (...args) => pageAiTargetRuntime.pageAiEnrichOcrContextRefs(...args);
|
||||
const pageAiBuildRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiBuildRunTargetSnapshot(...args);
|
||||
const pageAiPageContextForRefs = (...args) => pageAiTargetRuntime.pageAiPageContextForRefs(...args);
|
||||
const pageAiSetRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiSetRunTargetSnapshot(...args);
|
||||
@@ -1828,6 +1829,11 @@ export function createSidebarPageAiRuntime(context) {
|
||||
var contextRefs = pageAiBuildContextRefs(scopedContext, runTargetSnapshot);
|
||||
var allowedRoots = pageAiBuildAllowedRoots();
|
||||
var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot);
|
||||
if (typeof pageAiEnrichOcrContextRefs === 'function') {
|
||||
var ocrContext = await pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, scopedContext.editorTarget);
|
||||
contextRefs = ocrContext.contextRefs || contextRefs;
|
||||
agentTargetPackage = ocrContext.agentTargetPackage || agentTargetPackage;
|
||||
}
|
||||
if (scopedContext.pageContext && scopedContext.pageContext.aiContext) {
|
||||
scopedContext.pageContext.aiContext.runTargetSnapshot = runTargetSnapshot;
|
||||
scopedContext.pageContext.aiContext.agentTargetPackage = agentTargetPackage;
|
||||
|
||||
@@ -412,6 +412,84 @@ export function createSidebarPageAiTargetRuntime(context) {
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiOcrEligibleResourceKind(value) {
|
||||
var normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'image' || normalized === 'pdf' || normalized === 'attachment' || normalized === 'resource';
|
||||
}
|
||||
|
||||
function pageAiOcrEligiblePath(value) {
|
||||
var path = String(value || '').trim().toLowerCase();
|
||||
return /\.(png|jpg|jpeg|webp|bmp|tif|tiff|pdf)$/.test(path);
|
||||
}
|
||||
|
||||
function pageAiOcrBodyPreview(markdown) {
|
||||
var body = String(markdown || '').replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
|
||||
return body.slice(0, 1600);
|
||||
}
|
||||
|
||||
async function fetchPageAiOcrSidecarContext(editorTarget) {
|
||||
if (currentSourceKind() !== 'local_folder') return null;
|
||||
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
|
||||
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
|
||||
var relativePath = String(workspacePath.relativePath || target.path || '').trim();
|
||||
var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim();
|
||||
if (!relativePath || !pageAiOcrEligiblePath(relativePath) || !pageAiOcrEligibleResourceKind(resourceKind)) return null;
|
||||
var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim();
|
||||
if (!rootUri) return null;
|
||||
try {
|
||||
var statusUrl = new URL('/api/local-folder/ocr/status', window.location.origin);
|
||||
statusUrl.searchParams.set('rootUri', rootUri);
|
||||
statusUrl.searchParams.set('sourceRootRelativePath', relativePath);
|
||||
var statusResponse = await fetch(statusUrl.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
var statusPayload = await statusResponse.json().catch(function() { return null; });
|
||||
var job = statusResponse.ok && statusPayload && statusPayload.ok === true ? statusPayload.job : null;
|
||||
var ocrPath = String(job && job.ocrRootRelativePath || '').trim();
|
||||
if (!ocrPath || String(job.status || '') === 'failed') return null;
|
||||
var readUrl = new URL('/api/local-folder/ocr/read', window.location.origin);
|
||||
readUrl.searchParams.set('rootUri', rootUri);
|
||||
readUrl.searchParams.set('ocrRootRelativePath', ocrPath);
|
||||
var readResponse = await fetch(readUrl.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
var readPayload = await readResponse.json().catch(function() { return null; });
|
||||
if (!readResponse.ok || !readPayload || readPayload.ok !== true) return null;
|
||||
return {
|
||||
schema: 'mnote.local_ocr_context.v1',
|
||||
source: 'local_ocr_sidecar',
|
||||
sourceRootRelativePath: relativePath,
|
||||
ocrRootRelativePath: ocrPath,
|
||||
status: String(job.status || ''),
|
||||
stale: job.stale === true,
|
||||
provider: String(job.provider || ''),
|
||||
modelVersion: String(job.modelVersion || ''),
|
||||
plainTextPreview: pageAiOcrBodyPreview(readPayload.markdown || ''),
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, editorTarget) {
|
||||
var ocrContext = await fetchPageAiOcrSidecarContext(editorTarget);
|
||||
if (!ocrContext) return { contextRefs, agentTargetPackage };
|
||||
var nextRefs = pageAiNormalizeArray(contextRefs).map(function(ref) {
|
||||
if (ref && ref.kind === 'active_editor') return Object.assign({}, ref, { ocrContext: ocrContext });
|
||||
return ref;
|
||||
});
|
||||
var nextPackage = agentTargetPackage && typeof agentTargetPackage === 'object'
|
||||
? Object.assign({}, agentTargetPackage, { ocrContext: ocrContext })
|
||||
: agentTargetPackage;
|
||||
if (nextPackage && nextPackage.currentFile && typeof nextPackage.currentFile === 'object') {
|
||||
nextPackage.currentFile = Object.assign({}, nextPackage.currentFile, { ocrRootRelativePath: ocrContext.ocrRootRelativePath });
|
||||
}
|
||||
if (nextPackage && Array.isArray(nextPackage.targets)) {
|
||||
nextPackage.targets = nextPackage.targets.map(function(target, index) {
|
||||
return index === 0 && target && typeof target === 'object'
|
||||
? Object.assign({}, target, { ocrContext: ocrContext })
|
||||
: target;
|
||||
});
|
||||
}
|
||||
return { contextRefs: nextRefs, agentTargetPackage: nextPackage };
|
||||
}
|
||||
|
||||
function pageAiBuildAllowedRoots() {
|
||||
return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) {
|
||||
return {
|
||||
@@ -733,6 +811,7 @@ export function createSidebarPageAiTargetRuntime(context) {
|
||||
pageAiBuildAgentTargetPackage,
|
||||
pageAiBuildAllowedRoots,
|
||||
pageAiBuildContextRefs,
|
||||
pageAiEnrichOcrContextRefs,
|
||||
pageAiBuildRunTargetSnapshot,
|
||||
pageAiCloneJson,
|
||||
pageAiContextKindsFromRefs,
|
||||
|
||||
@@ -9,9 +9,10 @@ use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use axum::Router;
|
||||
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{error, warn};
|
||||
|
||||
@@ -139,6 +140,8 @@ pub struct AppState {
|
||||
pub editor_actor: EditorRuntimeActor,
|
||||
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub local_ocr_job_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub local_ocr_active_jobs: Arc<RwLock<BTreeMap<String, serde_json::Value>>>,
|
||||
pub acp_runtime: Arc<AcpRuntimeManager>,
|
||||
pub buffer_store: BufferStore,
|
||||
control_plane: Arc<SqliteControlPlaneStore>,
|
||||
@@ -148,6 +151,7 @@ impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
let (block_delta_tx, _) = broadcast::channel(256);
|
||||
let (stream_delta_tx, _) = broadcast::channel(256);
|
||||
let (local_ocr_job_tx, _) = broadcast::channel(256);
|
||||
let actor = EditorRuntimeActor::new();
|
||||
actor.set_block_delta_tx(block_delta_tx.clone());
|
||||
let buffer_store = BufferStore::new();
|
||||
@@ -158,6 +162,8 @@ impl AppState {
|
||||
editor_actor: actor,
|
||||
block_delta_tx,
|
||||
stream_delta_tx,
|
||||
local_ocr_job_tx,
|
||||
local_ocr_active_jobs: Arc::new(RwLock::new(BTreeMap::new())),
|
||||
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
|
||||
buffer_store,
|
||||
control_plane,
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::broadcast::{error::RecvError, Receiver};
|
||||
use tokio::time::timeout;
|
||||
|
||||
type BoxedEventStream =
|
||||
@@ -80,6 +80,7 @@ async fn build_document_events_stream(
|
||||
.local_folder_watcher_registry()
|
||||
.subscribe(&canonical_root)
|
||||
.map_err(|error| WebError::internal(error).with_context(&context))?;
|
||||
let local_ocr_job_rx = state.local_ocr_job_tx.subscribe();
|
||||
|
||||
let initial = json!({
|
||||
"sourceKind": "local_folder",
|
||||
@@ -89,29 +90,79 @@ async fn build_document_events_stream(
|
||||
"revision": system_time_ms(SystemTime::now()),
|
||||
});
|
||||
let stream = stream::unfold(
|
||||
(Some(initial), subscription, document_relative_path),
|
||||
|(initial, mut subscription, document_relative_path)| async move {
|
||||
(
|
||||
Some(initial),
|
||||
subscription,
|
||||
document_relative_path,
|
||||
query.root_uri.clone(),
|
||||
local_ocr_job_rx,
|
||||
),
|
||||
|(
|
||||
initial,
|
||||
mut subscription,
|
||||
document_relative_path,
|
||||
root_uri,
|
||||
mut local_ocr_job_rx,
|
||||
)| async move {
|
||||
if let Some(payload) = initial {
|
||||
return Some((
|
||||
Ok(stream_event("ready", &payload)),
|
||||
(None, subscription, document_relative_path),
|
||||
(
|
||||
None,
|
||||
subscription,
|
||||
document_relative_path,
|
||||
root_uri,
|
||||
local_ocr_job_rx,
|
||||
),
|
||||
));
|
||||
}
|
||||
loop {
|
||||
match subscription.receiver.recv().await {
|
||||
Ok(payload) => {
|
||||
if let Some(expected) = document_relative_path.as_deref() {
|
||||
if !document_event_targets_relative_path(&payload, expected) {
|
||||
continue;
|
||||
tokio::select! {
|
||||
watcher_result = subscription.receiver.recv() => {
|
||||
match watcher_result {
|
||||
Ok(payload) => {
|
||||
if let Some(expected) = document_relative_path.as_deref() {
|
||||
if !document_event_targets_relative_path(&payload, expected) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Some((
|
||||
Ok(stream_event("change", &payload)),
|
||||
(
|
||||
None,
|
||||
subscription,
|
||||
document_relative_path,
|
||||
root_uri,
|
||||
local_ocr_job_rx,
|
||||
),
|
||||
));
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
ocr_result = recv_matching_ocr_event(&mut local_ocr_job_rx, &root_uri) => {
|
||||
match ocr_result {
|
||||
Some(payload) => {
|
||||
if let Some(expected) = document_relative_path.as_deref() {
|
||||
if !document_event_targets_relative_path(&payload, expected) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Some((
|
||||
Ok(stream_event("local_ocr.job.updated", &payload)),
|
||||
(
|
||||
None,
|
||||
subscription,
|
||||
document_relative_path,
|
||||
root_uri,
|
||||
local_ocr_job_rx,
|
||||
),
|
||||
));
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
return Some((
|
||||
Ok(stream_event("change", &payload)),
|
||||
(None, subscription, document_relative_path),
|
||||
));
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -121,6 +172,20 @@ async fn build_document_events_stream(
|
||||
Ok((HeaderMap::new(), stream))
|
||||
}
|
||||
|
||||
async fn recv_matching_ocr_event(rx: &mut Receiver<Value>, root_uri: &str) -> Option<Value> {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(payload) => {
|
||||
if payload.get("rootUri").and_then(Value::as_str) == Some(root_uri) {
|
||||
return Some(payload);
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the tree live stream: emits `snapshot` (initial) and `resync` (on watcher change)
|
||||
/// with full sidebar + file tree projections.
|
||||
///
|
||||
|
||||
@@ -181,9 +181,11 @@ pub(crate) async fn create_job(
|
||||
body.force,
|
||||
)?;
|
||||
let now = now_ms();
|
||||
let mut entry = build_index_entry(&plan, "queued", now, "", None);
|
||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
||||
if let Some(error) = body.mock_error.as_deref() {
|
||||
let entry = build_index_entry(&plan, "failed", now, "", Some(redact_error(error)));
|
||||
upsert_ocr_index_entry(&root, entry.clone())?;
|
||||
entry = advance_ocr_entry(&entry, "failed", now_ms(), "", Some(redact_error(error)));
|
||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
||||
return Ok(ok_json(
|
||||
&context,
|
||||
json!({
|
||||
@@ -193,27 +195,44 @@ pub(crate) async fn create_job(
|
||||
));
|
||||
}
|
||||
let markdown = if provider == "mock" {
|
||||
entry = advance_ocr_entry(&entry, "writing_sidecar", now_ms(), "", None);
|
||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
||||
body.mock_markdown
|
||||
.unwrap_or_else(|| format!("OCR mock result for {}", plan.source_root_relative_path))
|
||||
} else {
|
||||
match run_mineru_ocr(&root, &plan, token.unwrap_or_default()).await {
|
||||
entry = advance_ocr_entry(&entry, "uploading", now_ms(), "", None);
|
||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
||||
match run_mineru_ocr(
|
||||
&state,
|
||||
&root,
|
||||
root_uri,
|
||||
&plan,
|
||||
token.unwrap_or_default(),
|
||||
entry.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(markdown) => markdown,
|
||||
Err(error) => {
|
||||
let failed_entry = build_index_entry(
|
||||
&plan,
|
||||
let failed_entry = advance_ocr_entry(
|
||||
&entry,
|
||||
"failed",
|
||||
now,
|
||||
now_ms(),
|
||||
"",
|
||||
Some(redact_error(error.message())),
|
||||
);
|
||||
upsert_ocr_index_entry(&root, failed_entry)?;
|
||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, failed_entry)?;
|
||||
return Err(error.with_context(&context));
|
||||
}
|
||||
}
|
||||
};
|
||||
if entry.status != "writing_sidecar" {
|
||||
entry = advance_ocr_entry(&entry, "writing_sidecar", now_ms(), "", None);
|
||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
||||
}
|
||||
write_ocr_sidecar(&plan, &markdown, now)?;
|
||||
let entry = build_index_entry(&plan, "done", now, &markdown, None);
|
||||
upsert_ocr_index_entry(&root, entry.clone())?;
|
||||
entry = advance_ocr_entry(&entry, "done", now_ms(), &markdown, None);
|
||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
||||
Ok(ok_json(
|
||||
&context,
|
||||
json!({
|
||||
@@ -463,9 +482,12 @@ pub(crate) fn strip_ocr_frontmatter(markdown: &str) -> &str {
|
||||
}
|
||||
|
||||
async fn run_mineru_ocr(
|
||||
state: &AppState,
|
||||
root: &Path,
|
||||
root_uri: &str,
|
||||
plan: &OcrSidecarPlan,
|
||||
token: String,
|
||||
mut entry: OcrIndexEntry,
|
||||
) -> Result<String, WebError> {
|
||||
let config = MineruClientConfig {
|
||||
api_base_url: mineru_api_base_url(),
|
||||
@@ -491,7 +513,11 @@ async fn run_mineru_ocr(
|
||||
let client = reqwest::Client::new();
|
||||
let upload = create_mineru_upload_task(&client, &config, file_name).await?;
|
||||
upload_mineru_source(&client, &upload.upload_url, bytes).await?;
|
||||
entry = advance_ocr_entry(&entry, "mineru_processing", now_ms(), "", None);
|
||||
upsert_and_broadcast_ocr_index_entry(state, root, root_uri, entry.clone())?;
|
||||
let zip_url = poll_mineru_result_zip_url(&client, &config, &upload.batch_id).await?;
|
||||
entry = advance_ocr_entry(&entry, "downloading", now_ms(), "", None);
|
||||
upsert_and_broadcast_ocr_index_entry(state, root, root_uri, entry)?;
|
||||
let zip_bytes = download_mineru_result_zip(&client, &zip_url).await?;
|
||||
extract_mineru_markdown_from_zip(&zip_bytes)
|
||||
}
|
||||
@@ -953,6 +979,23 @@ fn build_index_entry(
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_ocr_entry(
|
||||
previous: &OcrIndexEntry,
|
||||
status: &str,
|
||||
now: u128,
|
||||
markdown: &str,
|
||||
error: Option<String>,
|
||||
) -> OcrIndexEntry {
|
||||
let mut next = previous.clone();
|
||||
next.status = status.to_string();
|
||||
next.updated_at_ms = now;
|
||||
if !markdown.is_empty() {
|
||||
next.plain_text_preview = markdown.chars().take(240).collect();
|
||||
}
|
||||
next.error = error;
|
||||
next
|
||||
}
|
||||
|
||||
fn read_ocr_index(root: &Path) -> Result<OcrIndex, WebError> {
|
||||
let path = ocr_index_path(root);
|
||||
if !path.exists() {
|
||||
@@ -1011,6 +1054,42 @@ fn upsert_ocr_index_entry(root: &Path, entry: OcrIndexEntry) -> Result<(), WebEr
|
||||
write_ocr_index(root, &index)
|
||||
}
|
||||
|
||||
fn upsert_and_broadcast_ocr_index_entry(
|
||||
state: &AppState,
|
||||
root: &Path,
|
||||
root_uri: &str,
|
||||
entry: OcrIndexEntry,
|
||||
) -> Result<(), WebError> {
|
||||
upsert_ocr_index_entry(root, entry.clone())?;
|
||||
broadcast_ocr_job_update(state, root, root_uri, &entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn broadcast_ocr_job_update(state: &AppState, root: &Path, root_uri: &str, entry: &OcrIndexEntry) {
|
||||
let job = ocr_job_payload(root, entry);
|
||||
let key = format!("{root_uri}:{}", entry.source_root_relative_path);
|
||||
if matches!(entry.status.as_str(), "done" | "failed" | "interrupted") {
|
||||
if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
|
||||
jobs.remove(&key);
|
||||
}
|
||||
} else if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
|
||||
jobs.insert(key, job.clone());
|
||||
}
|
||||
let payload = json!({
|
||||
"schema": "mnote.local_ocr.job.updated.v1",
|
||||
"kind": "local_ocr_job_updated",
|
||||
"eventType": "local_ocr.job.updated",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": entry.source_root_relative_path,
|
||||
"documentId": entry.owner_document_id,
|
||||
"revision": entry.updated_at_ms.to_string(),
|
||||
"job": job,
|
||||
});
|
||||
let _ = state.local_ocr_job_tx.send(payload.clone());
|
||||
let _ = state.stream_delta_tx.send(payload);
|
||||
}
|
||||
|
||||
fn ocr_job_payload(root: &Path, entry: &OcrIndexEntry) -> Value {
|
||||
let stale = source_is_stale(root, entry);
|
||||
json!({
|
||||
@@ -1267,8 +1346,8 @@ mod tests {
|
||||
root
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
fn test_app_state() -> AppState {
|
||||
AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
@@ -1287,7 +1366,15 @@ mod tests {
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(test_app_state())
|
||||
}
|
||||
|
||||
fn app_with_state(state: AppState) -> axum::Router {
|
||||
build_app(state)
|
||||
}
|
||||
|
||||
fn write_workspace_manifest(root: &Path) {
|
||||
@@ -1501,6 +1588,70 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
|
||||
let root = temp_root("mnote-local-ocr-events");
|
||||
write_workspace_manifest(&root);
|
||||
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.assets").join("photo.png"),
|
||||
b"png",
|
||||
)
|
||||
.expect("photo");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let state = test_app_state();
|
||||
let mut events = state.local_ocr_job_tx.subscribe();
|
||||
let response = app_with_state(state.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/local-folder/ocr/jobs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"rootUri": root_uri,
|
||||
"documentId": "local-md:docs~2FPage.md",
|
||||
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||
"provider": "mock",
|
||||
"mockMarkdown": "Event OCR Token"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let mut statuses = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let payload = tokio::time::timeout(Duration::from_secs(1), events.recv())
|
||||
.await
|
||||
.expect("ocr event timeout")
|
||||
.expect("ocr event");
|
||||
assert_eq!(payload["eventType"].as_str(), Some("local_ocr.job.updated"));
|
||||
assert_eq!(
|
||||
payload["job"]["sourceRootRelativePath"].as_str(),
|
||||
Some("docs/Page.assets/photo.png")
|
||||
);
|
||||
statuses.push(
|
||||
payload["job"]["status"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
assert_eq!(statuses, vec!["queued", "writing_sidecar", "done"]);
|
||||
assert!(state
|
||||
.local_ocr_active_jobs
|
||||
.read()
|
||||
.expect("active jobs lock")
|
||||
.is_empty());
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_ocr_jobs_route_rejects_missing_mineru_token() {
|
||||
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
|
||||
|
||||
@@ -2778,6 +2778,89 @@ body {
|
||||
background: #f7f6f3;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-dock {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 80;
|
||||
color: #37352f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-button {
|
||||
height: 32px;
|
||||
border: 1px solid rgba(55, 53, 47, 0.16);
|
||||
border-radius: 4px;
|
||||
padding: 0 11px;
|
||||
background: #FFF;
|
||||
color: #37352f;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-drawer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 40px;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
max-height: min(420px, calc(100vh - 120px));
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(55, 53, 47, 0.14);
|
||||
border-radius: 6px;
|
||||
background: #FFF;
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-head {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(55, 53, 47, 0.1);
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(55, 53, 47, 0.08);
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-main strong,
|
||||
.mnote-local-ocr-task-main span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-main span,
|
||||
.mnote-local-ocr-task-empty {
|
||||
color: #787774;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-empty {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-actions button {
|
||||
height: 26px;
|
||||
border: 1px solid rgba(55, 53, 47, 0.14);
|
||||
border-radius: 4px;
|
||||
background: #FFF;
|
||||
color: #37352f;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-frame,
|
||||
.mnote-resource-tab-image,
|
||||
.mnote-resource-tab-text-shell {
|
||||
|
||||
Reference in New Issue
Block a user