推进 OCR UI 与 provider 删除验收
This commit is contained in:
@@ -985,6 +985,173 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
const isLocalOcrSourceEntry = (entry) => {
|
||||
if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false;
|
||||
if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false;
|
||||
return entry.kind === 'image' || entry.kind === 'pdf';
|
||||
};
|
||||
|
||||
const localOcrProvider = () => {
|
||||
const override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase();
|
||||
return override === 'mock' ? 'mock' : 'mineru';
|
||||
};
|
||||
|
||||
const setLocalOcrStatus = (entry, status, message, job) => {
|
||||
if (!(entry?.panel instanceof HTMLElement)) return;
|
||||
const normalizedStatus = String(status || '').trim() || 'unknown';
|
||||
entry.localOcrJob = job && typeof job === 'object' ? job : entry.localOcrJob || null;
|
||||
entry.panel.setAttribute('data-mnote-local-ocr-status', normalizedStatus);
|
||||
if (entry.localOcrJob?.ocrRootRelativePath) {
|
||||
entry.panel.setAttribute('data-mnote-local-ocr-path', String(entry.localOcrJob.ocrRootRelativePath));
|
||||
}
|
||||
const statusNode = entry.panel.querySelector('[data-mnote-local-ocr-status-text]');
|
||||
if (statusNode instanceof HTMLElement) {
|
||||
statusNode.textContent = message || (
|
||||
normalizedStatus === 'done' ? 'OCR 已完成'
|
||||
: normalizedStatus === 'running' ? 'OCR 处理中'
|
||||
: normalizedStatus === 'failed' ? 'OCR 失败'
|
||||
: normalizedStatus === 'stale' ? 'OCR 需更新'
|
||||
: 'OCR 未生成'
|
||||
);
|
||||
}
|
||||
const openButton = entry.panel.querySelector('[data-mnote-local-ocr-action="open"]');
|
||||
if (openButton instanceof HTMLButtonElement) openButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
|
||||
const insertButton = entry.panel.querySelector('[data-mnote-local-ocr-action="insert"]');
|
||||
if (insertButton instanceof HTMLButtonElement) insertButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
|
||||
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
|
||||
detail: {
|
||||
status: normalizedStatus,
|
||||
job: entry.localOcrJob || null,
|
||||
rootUri: entry.rootUri || '',
|
||||
sourceRootRelativePath: entry.path || '',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const readLocalOcrStatus = async (entry) => {
|
||||
if (!isLocalOcrSourceEntry(entry)) return null;
|
||||
const url = new URL('/api/local-folder/ocr/status', window.location.origin);
|
||||
url.searchParams.set('rootUri', entry.rootUri);
|
||||
url.searchParams.set('sourceRootRelativePath', entry.path);
|
||||
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 null;
|
||||
return payload.job || null;
|
||||
};
|
||||
|
||||
const openLocalOcrSidecar = async (entry, job) => {
|
||||
const target = job || entry?.localOcrJob || null;
|
||||
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
|
||||
if (!ocrPath || typeof openResourceInActiveTab !== 'function') return false;
|
||||
const title = ocrPath.split('/').filter(Boolean).pop() || 'OCR';
|
||||
return await openResourceInActiveTab({
|
||||
kind: 'markdown',
|
||||
title,
|
||||
path: ocrPath,
|
||||
objectIdentity: `local-ocr:${ocrPath}`,
|
||||
assetId: `local-ocr:${ocrPath}`,
|
||||
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
|
||||
ownerDocumentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
|
||||
workspaceId: String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(),
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: String(entry.rootUri || '').trim(),
|
||||
resourceKind: 'markdown',
|
||||
paneRole: normalizePaneRole(entry.paneRole),
|
||||
});
|
||||
};
|
||||
|
||||
const insertLocalOcrLink = async (entry, job) => {
|
||||
const target = job || entry?.localOcrJob || null;
|
||||
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
|
||||
if (!ocrPath) return false;
|
||||
const response = await fetch('/api/local-folder/ocr/insert', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rootUri: String(entry.rootUri || '').trim(),
|
||||
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
|
||||
ocrRootRelativePath: ocrPath,
|
||||
mode: 'link',
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(payload?.error?.message || `local_ocr_insert_failed_${response.status}`);
|
||||
}
|
||||
setLocalOcrStatus(entry, 'done', 'OCR 链接已插入正文', target);
|
||||
return true;
|
||||
};
|
||||
|
||||
const createLocalOcrJob = async (entry) => {
|
||||
if (!isLocalOcrSourceEntry(entry)) return null;
|
||||
setLocalOcrStatus(entry, 'running', 'OCR 处理中', entry.localOcrJob || null);
|
||||
const provider = localOcrProvider();
|
||||
const body = {
|
||||
rootUri: String(entry.rootUri || '').trim(),
|
||||
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
|
||||
sourceRootRelativePath: String(entry.path || '').trim(),
|
||||
provider,
|
||||
};
|
||||
if (provider === 'mock') {
|
||||
body.mockMarkdown = `# OCR Result\n\n${entry.title || entry.path} OCR UI smoke text`;
|
||||
}
|
||||
const response = await fetch('/api/local-folder/ocr/jobs', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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}`;
|
||||
setLocalOcrStatus(entry, 'failed', message, entry.localOcrJob || null);
|
||||
throw new Error(message);
|
||||
}
|
||||
const job = payload.job || null;
|
||||
setLocalOcrStatus(entry, String(job?.status || 'done'), job?.stale ? 'OCR 需更新' : 'OCR 已完成', job);
|
||||
return job;
|
||||
};
|
||||
|
||||
const renderLocalOcrToolbar = (entry) => {
|
||||
if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return;
|
||||
const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]');
|
||||
if (!(toolbar instanceof HTMLElement)) return;
|
||||
const runButton = toolbar.querySelector('[data-mnote-local-ocr-action="run"]');
|
||||
const openButton = toolbar.querySelector('[data-mnote-local-ocr-action="open"]');
|
||||
const insertButton = toolbar.querySelector('[data-mnote-local-ocr-action="insert"]');
|
||||
if (runButton instanceof HTMLButtonElement) {
|
||||
runButton.addEventListener('click', async () => {
|
||||
runButton.disabled = true;
|
||||
try {
|
||||
await createLocalOcrJob(entry);
|
||||
} catch (error) {
|
||||
console.warn('mnote local OCR 生成失败', error);
|
||||
} finally {
|
||||
runButton.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (openButton instanceof HTMLButtonElement) {
|
||||
openButton.addEventListener('click', () => {
|
||||
void openLocalOcrSidecar(entry, entry.localOcrJob).catch((error) => {
|
||||
console.warn('mnote local OCR 打开失败', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (insertButton instanceof HTMLButtonElement) {
|
||||
insertButton.addEventListener('click', () => {
|
||||
void insertLocalOcrLink(entry, entry.localOcrJob).catch((error) => {
|
||||
console.warn('mnote local OCR 插入失败', error);
|
||||
setLocalOcrStatus(entry, 'failed', error instanceof Error ? error.message : String(error), entry.localOcrJob || null);
|
||||
});
|
||||
});
|
||||
}
|
||||
setLocalOcrStatus(entry, 'idle', 'OCR 未生成', null);
|
||||
void readLocalOcrStatus(entry).then((job) => {
|
||||
if (!job) return;
|
||||
setLocalOcrStatus(entry, job.stale ? 'stale' : String(job.status || 'done'), job.stale ? 'OCR 需更新' : 'OCR 已完成', job);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const createResourceSession = (entry, input, readResult) => {
|
||||
const resourcePath = String(input.path || '');
|
||||
const tiptapDocument = localizeTiptapAssetUrls(
|
||||
@@ -1112,16 +1279,21 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
const openPassiveResourceTab = (entry, input) => {
|
||||
const href = String(input.officeUrl || input.href || '').trim();
|
||||
if (entry.kind === 'image') {
|
||||
entry.panel.innerHTML = '<img class="mnote-resource-tab-image" alt="">';
|
||||
entry.panel.innerHTML = isLocalOcrSourceEntry(entry)
|
||||
? '<div class="mnote-resource-tab-passive-shell" data-mnote-local-ocr-source="true"><div class="mnote-local-ocr-toolbar" data-testid="mnote-local-ocr-toolbar"><span class="mnote-local-ocr-status" data-testid="mnote-local-ocr-status" data-mnote-local-ocr-status-text>OCR 未生成</span><button type="button" data-testid="mnote-local-ocr-run" data-mnote-local-ocr-action="run">生成 OCR</button><button type="button" data-testid="mnote-local-ocr-open" data-mnote-local-ocr-action="open" disabled>打开 OCR</button><button type="button" data-testid="mnote-local-ocr-insert" data-mnote-local-ocr-action="insert" disabled>插入正文</button></div><img class="mnote-resource-tab-image" alt=""></div>'
|
||||
: '<img class="mnote-resource-tab-image" alt="">';
|
||||
const img = entry.panel.querySelector('img');
|
||||
if (img instanceof HTMLImageElement) {
|
||||
img.src = href;
|
||||
img.alt = entry.title;
|
||||
}
|
||||
renderLocalOcrToolbar(entry);
|
||||
installPassiveResourceWatch(entry);
|
||||
return;
|
||||
}
|
||||
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
|
||||
entry.panel.innerHTML = isLocalOcrSourceEntry(entry)
|
||||
? '<div class="mnote-resource-tab-passive-shell" data-mnote-local-ocr-source="true"><div class="mnote-local-ocr-toolbar" data-testid="mnote-local-ocr-toolbar"><span class="mnote-local-ocr-status" data-testid="mnote-local-ocr-status" data-mnote-local-ocr-status-text>OCR 未生成</span><button type="button" data-testid="mnote-local-ocr-run" data-mnote-local-ocr-action="run">生成 OCR</button><button type="button" data-testid="mnote-local-ocr-open" data-mnote-local-ocr-action="open" disabled>打开 OCR</button><button type="button" data-testid="mnote-local-ocr-insert" data-mnote-local-ocr-action="insert" disabled>插入正文</button></div><iframe class="mnote-resource-tab-frame" title=""></iframe></div>'
|
||||
: '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
|
||||
const frame = entry.panel.querySelector('iframe');
|
||||
if (frame instanceof HTMLIFrameElement) {
|
||||
frame.title = entry.title;
|
||||
@@ -1133,6 +1305,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
frame.src = href;
|
||||
}
|
||||
renderLocalOcrToolbar(entry);
|
||||
installPassiveResourceWatch(entry);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
openEditorAttachmentDownload,
|
||||
openEditorAttachmentEditTab,
|
||||
openEditorAttachmentNewWindow,
|
||||
openLocalResourceInActiveTab,
|
||||
refreshLocalFolderSidebarSnapshot,
|
||||
removeFileTreeAssetRow,
|
||||
revealFileTreeResource,
|
||||
@@ -379,9 +380,114 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
callback();
|
||||
}
|
||||
|
||||
function isLocalOcrSourceFileName(fileName) {
|
||||
return /\.(png|jpe?g|webp|gif|bmp|tiff?|pdf)$/i.test(String(fileName || '').trim());
|
||||
}
|
||||
|
||||
function localOcrSourceRelativePath(detail) {
|
||||
var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
|
||||
var relativePath = String(
|
||||
detail && detail.localRelativePath
|
||||
|| workspacePath && (workspacePath.relativePath || workspacePath.localRelativePath)
|
||||
|| ''
|
||||
).trim();
|
||||
if (!relativePath && detail && detail.assetId) relativePath = localFilePathFromAssetId(detail.assetId);
|
||||
return relativePath.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function localOcrRootUri(detail, trigger) {
|
||||
var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
|
||||
var rootUri = String(
|
||||
detail && (detail.localRootUri || detail.rootUri)
|
||||
|| workspacePath && workspacePath.rootUri
|
||||
|| ''
|
||||
).trim();
|
||||
if (!rootUri && trigger && typeof trigger.closest === 'function') {
|
||||
var row = trigger.closest('.tree-row[data-shell-mode="filetree"]');
|
||||
if (row instanceof HTMLElement) rootUri = String(row.getAttribute('data-root-uri') || '').trim();
|
||||
}
|
||||
return rootUri || currentRootUri() || '';
|
||||
}
|
||||
|
||||
function supportsLocalOcr(detail) {
|
||||
var path = localOcrSourceRelativePath(detail);
|
||||
var title = String(detail && (detail.title || detail.fileName) || '').trim() || path.split('/').pop() || '';
|
||||
return Boolean(path && localOcrRootUri(detail, null) && isLocalOcrSourceFileName(title || path));
|
||||
}
|
||||
|
||||
function localOcrProvider() {
|
||||
var override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase();
|
||||
return override === 'mock' ? 'mock' : 'mineru';
|
||||
}
|
||||
|
||||
async function runLocalOcrForDetail(detail, trigger) {
|
||||
var sourceRootRelativePath = localOcrSourceRelativePath(detail);
|
||||
var rootUri = localOcrRootUri(detail, trigger);
|
||||
var documentId = String(detail && detail.documentId || currentDocumentId() || '').trim();
|
||||
if (!sourceRootRelativePath || !rootUri || !documentId) {
|
||||
throw new Error('缺少 OCR 来源、rootUri 或 owner documentId');
|
||||
}
|
||||
if (!isLocalOcrSourceFileName(String(detail && (detail.title || detail.fileName) || sourceRootRelativePath))) {
|
||||
throw new Error('OCR 仅支持图片和 PDF');
|
||||
}
|
||||
var provider = localOcrProvider();
|
||||
var body = {
|
||||
rootUri: rootUri,
|
||||
documentId: documentId,
|
||||
sourceRootRelativePath: sourceRootRelativePath,
|
||||
provider: provider
|
||||
};
|
||||
if (provider === 'mock') {
|
||||
body.mockMarkdown = '# OCR Result\n\n' + (detail.title || sourceRootRelativePath) + ' OCR menu smoke text';
|
||||
}
|
||||
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'running');
|
||||
var response = await fetch('/api/local-folder/ocr/jobs', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
var message = payload && payload.error && payload.error.message ? payload.error.message : 'OCR 生成失败';
|
||||
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'failed');
|
||||
throw new Error(message);
|
||||
}
|
||||
var job = payload.job || {};
|
||||
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', String(job.status || 'done'));
|
||||
if (job.ocrRootRelativePath) {
|
||||
document.documentElement.setAttribute('data-mnote-local-ocr-menu-path', String(job.ocrRootRelativePath));
|
||||
if (typeof openLocalResourceInActiveTab === 'function') {
|
||||
await openLocalResourceInActiveTab({
|
||||
path: String(job.ocrRootRelativePath),
|
||||
title: String(job.ocrRootRelativePath).split('/').filter(Boolean).pop() || 'OCR',
|
||||
kind: 'markdown',
|
||||
assetId: 'local-ocr:' + String(job.ocrRootRelativePath),
|
||||
documentId: documentId,
|
||||
workspaceId: String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
|
||||
rootUri: rootUri
|
||||
});
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
|
||||
detail: { status: String(job.status || 'done'), job: job, rootUri: rootUri, sourceRootRelativePath: sourceRootRelativePath }
|
||||
}));
|
||||
return job;
|
||||
}
|
||||
|
||||
function handleTreeContextMenuAction(action, detail, trigger) {
|
||||
closeTreeContextMenu();
|
||||
detail = detail || {};
|
||||
if (action === 'local-ocr') {
|
||||
recordFileTreeAction('local-ocr', detail);
|
||||
recordFileTreeActionStatus('pending', detail);
|
||||
void runLocalOcrForDetail(detail, trigger).then(function(job) {
|
||||
recordFileTreeActionStatus(String(job && job.status || 'done'), detail);
|
||||
}).catch(function(error) {
|
||||
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
|
||||
window.alert(error && error.message ? error.message : 'OCR 生成失败');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (detail.contextKind === 'attachment') {
|
||||
if (action === 'copy-link') {
|
||||
void copyTreeContextValue(detail.href || '', 'attachment-copy-link');
|
||||
@@ -841,6 +947,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
menu.setAttribute('data-command-context-target-kind', String(ctx['tree.targetResourceKind'] || ''));
|
||||
menu.setAttribute('data-command-context-editor-dirty', String(ctx['editor.dirty'] === true));
|
||||
menu.setAttribute('data-command-context-ai-can-write', String(ctx['ai.canWrite'] === true));
|
||||
var localOcrSupported = supportsLocalOcr(detail);
|
||||
var items = isAttachment ? [
|
||||
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly' },
|
||||
@@ -903,6 +1010,11 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' }
|
||||
];
|
||||
if (localOcrSupported) {
|
||||
var ocrItem = { action: 'local-ocr', icon: 'auto_awesome', label: '生成 OCR', when: '!workspace.readonly' };
|
||||
var insertAt = isAttachment ? 11 : isAsset ? 3 : -1;
|
||||
if (insertAt >= 0) items.splice(insertAt, 0, ocrItem);
|
||||
}
|
||||
items.forEach(function(item) {
|
||||
if (item.when !== undefined && !evaluateSidebarFileTreeWhen(ctx, item.when)) {
|
||||
var reason = ctx['workspace.readonly'] === true
|
||||
|
||||
@@ -2019,6 +2019,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
openEditorAttachmentDownload: (...args) => openEditorAttachmentDownload(...args),
|
||||
openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args),
|
||||
openEditorAttachmentNewWindow: (...args) => openEditorAttachmentNewWindow(...args),
|
||||
openLocalResourceInActiveTab: (...args) => openLocalResourceInActiveTab(...args),
|
||||
refreshLocalFolderSidebarSnapshot,
|
||||
removeFileTreeAssetRow,
|
||||
revealFileTreeResource,
|
||||
|
||||
@@ -199,8 +199,13 @@ pub(crate) async fn create_job(
|
||||
match run_mineru_ocr(&root, &plan, token.unwrap_or_default()).await {
|
||||
Ok(markdown) => markdown,
|
||||
Err(error) => {
|
||||
let failed_entry =
|
||||
build_index_entry(&plan, "failed", now, "", Some(redact_error(error.message())));
|
||||
let failed_entry = build_index_entry(
|
||||
&plan,
|
||||
"failed",
|
||||
now,
|
||||
"",
|
||||
Some(redact_error(error.message())),
|
||||
);
|
||||
upsert_ocr_index_entry(&root, failed_entry)?;
|
||||
return Err(error.with_context(&context));
|
||||
}
|
||||
@@ -457,7 +462,11 @@ pub(crate) fn strip_ocr_frontmatter(markdown: &str) -> &str {
|
||||
trimmed[end + 4..].trim_start_matches('\n')
|
||||
}
|
||||
|
||||
async fn run_mineru_ocr(root: &Path, plan: &OcrSidecarPlan, token: String) -> Result<String, WebError> {
|
||||
async fn run_mineru_ocr(
|
||||
root: &Path,
|
||||
plan: &OcrSidecarPlan,
|
||||
token: String,
|
||||
) -> Result<String, WebError> {
|
||||
let config = MineruClientConfig {
|
||||
api_base_url: mineru_api_base_url(),
|
||||
token,
|
||||
@@ -515,7 +524,10 @@ async fn create_mineru_upload_task(
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_upload_task_failed",
|
||||
format!("MinerU 上传任务创建失败: {}", redact_error(&error.to_string())),
|
||||
format!(
|
||||
"MinerU 上传任务创建失败: {}",
|
||||
redact_error(&error.to_string())
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
@@ -527,8 +539,8 @@ async fn create_mineru_upload_task(
|
||||
));
|
||||
}
|
||||
let value = parse_mineru_json(&body, "mineru_upload_task_json_invalid")?;
|
||||
let batch_id = find_json_string_by_keys(&value, &["batch_id", "batchId", "id"])
|
||||
.ok_or_else(|| {
|
||||
let batch_id =
|
||||
find_json_string_by_keys(&value, &["batch_id", "batchId", "id"]).ok_or_else(|| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_upload_task_batch_missing",
|
||||
"MinerU 上传任务响应缺少 batch_id",
|
||||
@@ -559,7 +571,10 @@ async fn upload_mineru_source(
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_source_upload_failed",
|
||||
format!("MinerU 源文件上传失败: {}", redact_error(&error.to_string())),
|
||||
format!(
|
||||
"MinerU 源文件上传失败: {}",
|
||||
redact_error(&error.to_string())
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
@@ -610,7 +625,14 @@ async fn poll_mineru_result_zip_url(
|
||||
}
|
||||
if let Some(zip_url) = find_json_string_by_keys(
|
||||
&value,
|
||||
&["full_zip_url", "fullZipUrl", "zip_url", "zipUrl", "result_url", "resultUrl"],
|
||||
&[
|
||||
"full_zip_url",
|
||||
"fullZipUrl",
|
||||
"zip_url",
|
||||
"zipUrl",
|
||||
"result_url",
|
||||
"resultUrl",
|
||||
],
|
||||
) {
|
||||
return Ok(zip_url);
|
||||
}
|
||||
@@ -629,7 +651,10 @@ async fn download_mineru_result_zip(
|
||||
let response = client.get(zip_url).send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_result_download_failed",
|
||||
format!("MinerU 结果包下载失败: {}", redact_error(&error.to_string())),
|
||||
format!(
|
||||
"MinerU 结果包下载失败: {}",
|
||||
redact_error(&error.to_string())
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
@@ -638,12 +663,19 @@ async fn download_mineru_result_zip(
|
||||
format!("MinerU 结果包下载失败: HTTP {}", response.status().as_u16()),
|
||||
));
|
||||
}
|
||||
response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_result_download_failed",
|
||||
format!("MinerU 结果包读取失败: {}", redact_error(&error.to_string())),
|
||||
)
|
||||
})
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|bytes| bytes.to_vec())
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_result_download_failed",
|
||||
format!(
|
||||
"MinerU 结果包读取失败: {}",
|
||||
redact_error(&error.to_string())
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
|
||||
@@ -678,7 +710,8 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.max_by_key(|(name, markdown)| {
|
||||
let preferred = name.ends_with("/full.md") || name == "full.md" || name.ends_with("/result.md");
|
||||
let preferred =
|
||||
name.ends_with("/full.md") || name == "full.md" || name.ends_with("/result.md");
|
||||
(preferred, markdown.len())
|
||||
})
|
||||
.map(|(_, markdown)| markdown)
|
||||
@@ -1307,9 +1340,7 @@ mod tests {
|
||||
writer
|
||||
.start_file("full.md", zip::write::SimpleFileOptions::default())
|
||||
.expect("zip start file");
|
||||
writer
|
||||
.write_all(markdown.as_bytes())
|
||||
.expect("zip markdown");
|
||||
writer.write_all(markdown.as_bytes()).expect("zip markdown");
|
||||
writer.finish().expect("zip finish");
|
||||
}
|
||||
bytes.into_inner()
|
||||
@@ -1570,7 +1601,9 @@ mod tests {
|
||||
}),
|
||||
);
|
||||
let mock_handle = tokio::spawn(async move {
|
||||
axum::serve(listener, mock_mineru).await.expect("mock mineru server");
|
||||
axum::serve(listener, mock_mineru)
|
||||
.await
|
||||
.expect("mock mineru server");
|
||||
});
|
||||
|
||||
let old_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
|
||||
@@ -1643,12 +1676,9 @@ mod tests {
|
||||
assert_eq!(payload["job"]["provider"].as_str(), Some("mineru"));
|
||||
assert_eq!(upload_count.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(poll_count.load(Ordering::SeqCst), 1);
|
||||
let sidecar = fs::read_to_string(
|
||||
root.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("photo.png.ocr.md"),
|
||||
)
|
||||
.expect("sidecar");
|
||||
let sidecar =
|
||||
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
|
||||
.expect("sidecar");
|
||||
assert!(sidecar.contains("provider: mineru"));
|
||||
assert!(sidecar.contains("识别文本"));
|
||||
|
||||
|
||||
@@ -185,6 +185,18 @@ pub(crate) fn refresh_local_search_index_for_path(
|
||||
index
|
||||
.resources
|
||||
.retain(|resource| resource.path != relative_path);
|
||||
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
|
||||
index.built_at = now_ms();
|
||||
write_local_search_index(root_path, &index)?;
|
||||
return Ok(json!({
|
||||
"version": index.version,
|
||||
"rootUri": index.root_uri,
|
||||
"workspaceId": index.workspace_id,
|
||||
"builtAt": index.built_at,
|
||||
"documentCount": index.documents.len(),
|
||||
"resourceCount": index.resources.len()
|
||||
}));
|
||||
}
|
||||
let absolute_path = root_path.join(&relative_path);
|
||||
if absolute_path.exists() && absolute_path.is_file() && is_markdown_path(&absolute_path) {
|
||||
index
|
||||
@@ -1168,6 +1180,11 @@ mod tests {
|
||||
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token 识别正文\n",
|
||||
)
|
||||
.expect("ocr markdown");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.ocr").join("photo.png-704905.ocr.md"),
|
||||
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 2\nstatus: done\ncreated_at: 2\nupdated_at: 2\n---\n\nOCR-hash-token 识别正文\n",
|
||||
)
|
||||
.expect("hashed ocr markdown");
|
||||
fs::create_dir_all(root.join(".mnote")).expect("mnote dir");
|
||||
fs::write(
|
||||
root.join(".mnote").join("ocr-index.json"),
|
||||
@@ -1208,6 +1225,22 @@ mod tests {
|
||||
)
|
||||
.expect("without ocr");
|
||||
assert_eq!(without_ocr["results"].as_array().map(Vec::len), Some(0));
|
||||
let without_hashed_ocr = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"OCR-hash-token",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("without hashed ocr");
|
||||
assert_eq!(
|
||||
without_hashed_ocr["results"].as_array().map(Vec::len),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
let with_ocr = query_local_search_index(
|
||||
&root,
|
||||
@@ -1245,6 +1278,24 @@ mod tests {
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
|
||||
refresh_local_search_index_for_path(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"docs/Page.ocr/photo.png-704905.ocr.md",
|
||||
)
|
||||
.expect("refresh hashed ocr sidecar");
|
||||
let refreshed_index = read_local_search_index(&root)
|
||||
.expect("read refreshed search index")
|
||||
.expect("refreshed search index");
|
||||
assert!(!refreshed_index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -2729,6 +2729,55 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-passive-shell {
|
||||
min-height: calc(100vh - 80px);
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid rgba(55, 53, 47, 0.12);
|
||||
background: #FFF;
|
||||
color: #37352f;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-status {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #787774;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-toolbar button {
|
||||
flex: 0 0 auto;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(55, 53, 47, 0.16);
|
||||
border-radius: 4px;
|
||||
padding: 0 9px;
|
||||
background: #FFF;
|
||||
color: #37352f;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-toolbar button:hover:not(:disabled) {
|
||||
background: rgba(55, 53, 47, 0.06);
|
||||
}
|
||||
|
||||
.mnote-local-ocr-toolbar button:disabled {
|
||||
cursor: default;
|
||||
color: #a8a29e;
|
||||
background: #f7f6f3;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-frame,
|
||||
.mnote-resource-tab-image,
|
||||
.mnote-resource-tab-text-shell {
|
||||
|
||||
Reference in New Issue
Block a user