fix: stabilize local folder AI document workflow

- scope local-folder PageTree revision and document sidebar rendering to fileTreeScope

- preserve projected table/image attrs for local Markdown aggregate fallback

- avoid FileTree restore forced layouts on cold design open

- add API ChatOnly provider runtime and local OCR task handling regressions
This commit is contained in:
lix-2026
2026-06-02 17:17:49 +08:00
parent 610b23d3a5
commit 9f4b5c4d48
27 changed files with 3825 additions and 229 deletions
+368
View File
@@ -0,0 +1,368 @@
use crate::acp_bridge::SseEvent;
use serde_json::{json, Value};
use std::env;
const DEFAULT_API_CHAT_BASE_URL: &str = "http://127.0.0.1:20128/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApiChatProfile {
pub profile_id: &'static str,
pub base_profile: &'static str,
pub isolated_profile: &'static str,
pub label: &'static str,
pub model: &'static str,
pub provider_kind: &'static str,
pub status: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedApiChatProfile {
pub profile_id: String,
pub base_profile: String,
pub isolated_profile: String,
pub label: String,
pub model: String,
pub provider_kind: String,
pub status: String,
pub base_url: String,
pub api_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiChatError {
pub code: &'static str,
pub message: String,
}
impl ApiChatError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl std::fmt::Display for ApiChatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ApiChatError {}
pub const API_CHAT_PROFILES: &[ApiChatProfile] = &[
ApiChatProfile {
profile_id: "shared_api_deepseek_flash_chat",
base_profile: "api-deepseek-flash-chat",
isolated_profile: "api-deepseek-flash-chat",
label: "DeepSeek Flash Chat",
model: "deepseek-v4-flash",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_deepseek_pro_chat",
base_profile: "api-deepseek-pro-chat",
isolated_profile: "api-deepseek-pro-chat",
label: "DeepSeek Pro Chat",
model: "deepseek-v4-pro",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_gpt_chat",
base_profile: "api-gpt-chat",
isolated_profile: "api-gpt-chat",
label: "GPT Chat",
model: "aisz-chat/gpt-5.5-extra-high-fast",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_kimi_chat",
base_profile: "api-kimi-chat",
isolated_profile: "api-kimi-chat",
label: "Kimi Chat",
model: "aisz-chat/kimi-k2.5",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_gemini_chat",
base_profile: "api-gemini-chat",
isolated_profile: "api-gemini-chat",
label: "Gemini API Chat",
model: "aisz-chat/gemini-3.1-pro",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_grok_chat",
base_profile: "api-grok-chat",
isolated_profile: "api-grok-chat",
label: "Grok API Chat",
model: "aisz-chat/grok-4.3",
provider_kind: "api-chat",
status: "active",
},
];
pub fn api_chat_profiles() -> &'static [ApiChatProfile] {
API_CHAT_PROFILES
}
pub fn api_chat_profile_by_id(value: &str) -> Option<ApiChatProfile> {
let needle = value.trim();
if needle.is_empty() {
return None;
}
API_CHAT_PROFILES
.iter()
.copied()
.find(|profile| profile_matches(*profile, needle))
}
pub fn payload_uses_api_chat_profile(payload: &Value, registration_profile: &str) -> bool {
let agent_id = payload
.get("agentId")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if agent_id != "chat_only" {
return false;
}
let agent_profile = payload.get("agentProfileRef");
[
Some(registration_profile),
payload.get("profile").and_then(Value::as_str),
payload.get("profileId").and_then(Value::as_str),
payload.get("profile_id").and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("baseProfile"))
.and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("isolatedProfile"))
.and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("profileId"))
.and_then(Value::as_str),
]
.into_iter()
.flatten()
.any(|candidate| api_chat_profile_by_id(candidate).is_some())
}
pub fn resolve_api_chat_profile(value: &str) -> Result<ResolvedApiChatProfile, ApiChatError> {
let profile = api_chat_profile_by_id(value).ok_or_else(|| {
ApiChatError::new(
"api_chat_profile_unknown",
format!("未知 API Chat profile: {value}"),
)
})?;
let env_prefix = profile_env_prefix(profile.profile_id);
let model = env_value(&format!("{env_prefix}_MODEL"))
.or_else(|| env_value("MNOTE_API_CHAT_MODEL"))
.unwrap_or_else(|| profile.model.to_string());
let base_url = env_value(&format!("{env_prefix}_BASE_URL"))
.or_else(|| env_value("MNOTE_API_CHAT_BASE_URL"))
.unwrap_or_else(|| DEFAULT_API_CHAT_BASE_URL.to_string());
let api_key = env_value(&format!("{env_prefix}_API_KEY"))
.or_else(|| env_value("MNOTE_API_CHAT_API_KEY"))
.or_else(|| env_value("OPENAI_API_KEY"));
Ok(ResolvedApiChatProfile {
profile_id: profile.profile_id.to_string(),
base_profile: profile.base_profile.to_string(),
isolated_profile: profile.isolated_profile.to_string(),
label: profile.label.to_string(),
model,
provider_kind: profile.provider_kind.to_string(),
status: profile.status.to_string(),
base_url: base_url.trim().trim_end_matches('/').to_string(),
api_key,
})
}
pub fn runtime_events_from_openai_sse_chunk(
run_id: &str,
chunk: &str,
) -> Result<Vec<SseEvent>, ApiChatError> {
let mut decoder = OpenAiSseDecoder::default();
decoder.push_chunk(run_id, chunk)
}
#[derive(Debug, Default)]
pub struct OpenAiSseDecoder {
buffer: String,
output: String,
completed: bool,
}
impl OpenAiSseDecoder {
pub fn push_chunk(&mut self, run_id: &str, chunk: &str) -> Result<Vec<SseEvent>, ApiChatError> {
self.buffer.push_str(chunk);
let mut frames = self
.buffer
.split("\n\n")
.map(str::to_string)
.collect::<Vec<_>>();
self.buffer = frames.pop().unwrap_or_default();
let mut events = Vec::new();
for frame in frames {
events.extend(self.parse_frame(run_id, &frame)?);
}
Ok(events)
}
pub fn finish(&mut self, run_id: &str) -> Result<Vec<SseEvent>, ApiChatError> {
let rest = std::mem::take(&mut self.buffer);
let mut events = if rest.trim().is_empty() {
Vec::new()
} else {
self.parse_frame(run_id, &rest)?
};
if !self.completed {
self.completed = true;
events.push(self.completed_event());
}
Ok(events)
}
fn parse_frame(&mut self, run_id: &str, frame: &str) -> Result<Vec<SseEvent>, ApiChatError> {
let mut events = Vec::new();
for data in sse_frame_data_lines(frame) {
if data == "[DONE]" {
if !self.completed {
self.completed = true;
events.push(self.completed_event());
}
continue;
}
let payload = serde_json::from_str::<Value>(&data).map_err(|error| {
ApiChatError::new(
"api_chat_stream_parse_error",
format!("OpenAI SSE chunk 解析失败: {error}"),
)
})?;
if let Some(error) = payload.get("error") {
self.completed = true;
events.push(SseEvent {
event: "run.failed".into(),
data: json!({
"runId": run_id,
"code": "api_chat_upstream_error",
"message": error
.get("message")
.and_then(Value::as_str)
.unwrap_or("API Chat upstream error"),
"error": error
}),
});
continue;
}
for delta in extract_delta_texts(&payload) {
self.output.push_str(&delta);
events.push(SseEvent {
event: "message.delta".into(),
data: json!({
"runId": run_id,
"delta": delta
}),
});
}
}
Ok(events)
}
fn completed_event(&self) -> SseEvent {
SseEvent {
event: "run.completed".into(),
data: json!({
"output": self.output
}),
}
}
}
fn profile_matches(profile: ApiChatProfile, value: &str) -> bool {
profile.profile_id == value
|| profile.base_profile == value
|| profile.isolated_profile == value
|| profile.label == value
}
fn profile_env_prefix(profile_id: &str) -> String {
let suffix = profile_id
.trim()
.strip_prefix("shared_api_")
.unwrap_or(profile_id)
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
format!("MNOTE_API_CHAT_{suffix}")
}
fn env_value(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn sse_frame_data_lines(frame: &str) -> Vec<String> {
let mut lines = Vec::new();
for line in frame.lines() {
let trimmed = line.trim();
if let Some(data) = trimmed.strip_prefix("data:") {
lines.push(data.trim().to_string());
}
}
lines
}
fn extract_delta_texts(payload: &Value) -> Vec<String> {
let mut texts = Vec::new();
if let Some(choices) = payload.get("choices").and_then(Value::as_array) {
for choice in choices {
for value in [
choice
.get("delta")
.and_then(|delta| delta.get("content"))
.and_then(Value::as_str),
choice
.get("message")
.and_then(|message| message.get("content"))
.and_then(Value::as_str),
choice.get("text").and_then(Value::as_str),
]
.into_iter()
.flatten()
{
if !value.is_empty() {
texts.push(value.to_string());
}
}
}
}
if texts.is_empty() {
for value in [
payload.get("delta").and_then(Value::as_str),
payload.get("text").and_then(Value::as_str),
payload.get("content").and_then(Value::as_str),
]
.into_iter()
.flatten()
{
if !value.is_empty() {
texts.push(value.to_string());
}
}
}
texts
}
+1
View File
@@ -5,6 +5,7 @@ pub mod acp_client;
pub mod acp_runtime;
pub mod acp_session_manager;
pub mod acp_types;
pub mod api_chat;
pub mod app;
pub mod context;
pub mod document_buffer_store;
File diff suppressed because it is too large Load Diff
@@ -2838,7 +2838,11 @@ fn load_local_folder_page_tree_snapshot_for_scope(
"PageTree scope parentRelativePath 必须指向目录",
));
}
let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?;
let watch_revision = if parent_relative_path.is_empty() {
local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?
} else {
local_folder_watch_revision_for_directory(&canonical_root, &scan_root, &root_source_uri)?
};
let cache_key = format!("{root_source_uri}\n{parent_relative_path}");
if let Ok(cache) = local_page_tree_snapshot_cache().lock() {
if let Some(entry) = cache.get(&cache_key) {
@@ -7046,7 +7050,45 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
if matches!(file_name, ".git" | "node_modules" | ".mnote") {
return true;
}
relative_path == ".mnote/trash" || relative_path.starts_with(".mnote/trash/")
relative_path == ".mnote/trash"
|| relative_path.starts_with(".mnote/trash/")
|| is_local_ocr_intermediate_entry(relative_path, file_name)
}
fn is_local_ocr_intermediate_entry(relative_path: &str, file_name: &str) -> bool {
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
if normalized.is_empty() {
return false;
}
let segments = normalized
.split('/')
.filter(|segment| !segment.trim().is_empty())
.collect::<Vec<_>>();
if segments.is_empty() {
return false;
}
let in_ocr_dir = segments
.len()
.checked_sub(2)
.and_then(|index| segments.get(index))
.map(|segment| segment.ends_with(".ocr"))
.unwrap_or(false);
let in_ocr_images_dir = segments
.windows(2)
.any(|window| window[0].ends_with(".ocr") && window[1] == "images");
if in_ocr_images_dir {
return true;
}
if !in_ocr_dir {
return false;
}
let lower = file_name.to_ascii_lowercase();
lower == "layout.json"
|| lower == "images"
|| lower.ends_with("_content_list.json")
|| lower.ends_with("_content_list_v2.json")
|| lower.ends_with("_model.json")
|| lower.ends_with("_origin.pdf")
}
fn compare_local_entries(a: &LocalFolderEntry, b: &LocalFolderEntry) -> Ordering {
@@ -7723,6 +7765,14 @@ pub(crate) fn local_workspace_id(root: &Path) -> String {
fn local_folder_watch_revision_for_root(
root: &Path,
root_source_uri: &str,
) -> Result<LocalFolderWatchRevision, WebError> {
local_folder_watch_revision_for_directory(root, root, root_source_uri)
}
fn local_folder_watch_revision_for_directory(
root: &Path,
directory: &Path,
root_source_uri: &str,
) -> Result<LocalFolderWatchRevision, WebError> {
let mut hasher = DefaultHasher::new();
let mut entry_count = 0usize;
@@ -7763,7 +7813,7 @@ fn local_folder_watch_revision_for_root(
visit(
root,
root,
directory,
&mut hasher,
&mut entry_count,
&mut latest_modified_ms,
@@ -9282,9 +9332,10 @@ mod tests {
get_share_links, get_user_access_policy, initialize_local_page_id,
initialize_local_workspace_for_actor, load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id,
open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change,
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_markdown_path_page_id,
local_resource_write_editor_blocks, local_workspace_id, open_local_file,
read_local_resource, record_shared_cache, record_sync_pending_change,
resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
@@ -9424,6 +9475,30 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_folder_page_tree_scope_cache_ignores_outside_changes() {
let root = temp_root("mnote-page-tree-scope-cache");
init_workspace(&root);
std::fs::create_dir_all(root.join("design")).expect("create design");
std::fs::create_dir_all(root.join("notes")).expect("create notes");
std::fs::write(root.join("design").join("page.md"), "# Design\n").expect("write design");
std::fs::write(root.join("notes").join("outside.md"), "# Outside\n")
.expect("write outside");
let root_uri = format!("file://{}", root.display());
super::reset_local_page_tree_snapshot_test_loads();
let first =
load_local_folder_page_tree_scope_snapshot(&root_uri, "design").expect("first scope");
std::fs::write(root.join("notes").join("next.md"), "# Next\n").expect("write next");
let second =
load_local_folder_page_tree_scope_snapshot(&root_uri, "design").expect("second scope");
assert_eq!(first.projection, second.projection);
assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_identity_uses_path_even_when_frontmatter_has_mnote_id() {
let root = temp_root("mnote-local-frontmatter-path-id");
@@ -13320,12 +13395,41 @@ fn main() {}
init_workspace(&root);
let root_uri = format!("file://{}", root.display());
std::fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
std::fs::create_dir_all(root.join("docs").join("Page.ocr").join("images"))
.expect("ocr images dir");
std::fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
std::fs::write(
root.join("docs").join("Page.ocr").join("photo.png.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: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token\n",
)
.expect("ocr markdown");
std::fs::write(root.join("docs").join("Page.ocr").join("layout.json"), "{}")
.expect("ocr layout");
std::fs::write(
root.join("docs")
.join("Page.ocr")
.join("abc_content_list.json"),
"[]",
)
.expect("ocr content list");
std::fs::write(
root.join("docs").join("Page.ocr").join("abc_model.json"),
"{}",
)
.expect("ocr model");
std::fs::write(
root.join("docs").join("Page.ocr").join("abc_origin.pdf"),
b"%PDF-1.4\n",
)
.expect("ocr origin pdf");
std::fs::write(
root.join("docs")
.join("Page.ocr")
.join("images")
.join("page_1.jpg"),
b"jpg",
)
.expect("ocr image asset");
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
.expect("file tree");
@@ -13335,6 +13439,20 @@ fn main() {}
assert!(file_items
.iter()
.any(|item| item["title"].as_str() == Some("photo.png.ocr.md")));
for hidden_title in [
"layout.json",
"abc_content_list.json",
"abc_model.json",
"abc_origin.pdf",
"images",
] {
assert!(
!file_items
.iter()
.any(|item| item["title"].as_str() == Some(hidden_title)),
"OCR 中间产物不应出现在 FileTree: {hidden_title}"
);
}
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
let page_items = page_tree.projection["items"]
+307 -15
View File
@@ -64,6 +64,13 @@ pub(crate) struct OcrInsertRequest {
mode: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OcrDeleteRequest {
root_uri: String,
source_root_relative_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OcrIndex {
@@ -108,6 +115,18 @@ struct MineruClientConfig {
max_polls: usize,
}
#[derive(Debug)]
struct MineruZipAsset {
relative_path: PathBuf,
bytes: Vec<u8>,
}
#[derive(Debug)]
struct MineruZipExtraction {
markdown: String,
assets: Vec<MineruZipAsset>,
}
#[derive(Debug, Clone)]
struct OcrSidecarPlan {
owner_document_path: String,
@@ -284,6 +303,47 @@ pub(crate) async fn status(
Ok(ok_json(&context, json!({ "ok": true, "job": job })))
}
pub(crate) async fn delete_job(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<OcrDeleteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let root =
ensure_local_workspace_write_access_with_state(&state, &context, request.root_uri.trim())
.map_err(|error| error.with_context(&context))?;
let source = normalize_relative_path(&request.source_root_relative_path)?;
let mut index = read_ocr_index(&root)?;
let removed = index.entries.remove(&source);
if let Some(entry) = &removed {
let sidecar = root.join(&entry.ocr_root_relative_path);
ensure_target_under_root(&root, &sidecar, "local_ocr_delete_root_escape")?;
if sidecar.exists() {
fs::remove_file(&sidecar).map_err(|error| {
WebError::bad_request_code(
"local_ocr_delete_failed",
format!("无法删除 OCR Markdown {}: {error}", sidecar.display()),
)
.with_context(&context)
})?;
}
cleanup_empty_ocr_sidecar_dir(&root, &sidecar)?;
}
write_ocr_index(&root, &index)?;
let key = format!("{}:{source}", request.root_uri.trim());
if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
jobs.remove(&key);
}
broadcast_ocr_job_deleted(&state, request.root_uri.trim(), &source, removed.as_ref());
Ok(ok_json(
&context,
json!({
"ok": true,
"deleted": removed.is_some(),
"sourceRootRelativePath": source,
}),
))
}
pub(crate) async fn read(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -519,7 +579,9 @@ async fn run_mineru_ocr(
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)
let extraction = extract_mineru_markdown_and_assets_from_zip(&zip_bytes)?;
write_mineru_zip_assets(plan, &extraction.assets)?;
Ok(extraction.markdown)
}
#[derive(Debug)]
@@ -704,7 +766,9 @@ async fn download_mineru_result_zip(
})
}
fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
fn extract_mineru_markdown_and_assets_from_zip(
bytes: &[u8],
) -> Result<MineruZipExtraction, WebError> {
let cursor = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|error| {
WebError::bad_gateway_code(
@@ -713,6 +777,7 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
)
})?;
let mut candidates = Vec::<(String, String)>::new();
let mut assets = Vec::<MineruZipAsset>::new();
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| {
WebError::bad_gateway_code(
@@ -721,19 +786,36 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
)
})?;
let name = file.name().replace('\\', "/");
if !name.to_ascii_lowercase().ends_with(".md") || name.contains("/.") {
if file.is_dir() || name.contains("/.") {
continue;
}
let mut markdown = String::new();
file.read_to_string(&mut markdown).map_err(|error| {
if name.to_ascii_lowercase().ends_with(".md") {
let mut markdown = String::new();
file.read_to_string(&mut markdown).map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_markdown_read_failed",
format!("MinerU Markdown 读取失败: {error}"),
)
})?;
candidates.push((name, markdown));
continue;
}
let Some(relative_path) = safe_mineru_asset_relative_path(&name) else {
continue;
};
let mut asset_bytes = Vec::new();
file.read_to_end(&mut asset_bytes).map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_markdown_read_failed",
format!("MinerU Markdown 读取失败: {error}"),
"mineru_result_asset_read_failed",
format!("MinerU 资源读取失败: {error}"),
)
})?;
candidates.push((name, markdown));
assets.push(MineruZipAsset {
relative_path,
bytes: asset_bytes,
});
}
candidates
let markdown = candidates
.into_iter()
.max_by_key(|(name, markdown)| {
let preferred =
@@ -747,7 +829,55 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
"mineru_result_markdown_missing",
"MinerU 结果包中缺少 Markdown 文件",
)
})
})?;
Ok(MineruZipExtraction { markdown, assets })
}
fn safe_mineru_asset_relative_path(name: &str) -> Option<PathBuf> {
let normalized = name.trim().trim_start_matches('/').replace('\\', "/");
if normalized.is_empty() {
return None;
}
let mut path = PathBuf::new();
for component in Path::new(&normalized).components() {
match component {
Component::Normal(value) => {
let text = value.to_str()?.trim();
if text.is_empty() || text == "." || text == ".." || text.starts_with('.') {
return None;
}
path.push(text);
}
_ => return None,
}
}
(!path.as_os_str().is_empty()).then_some(path)
}
fn write_mineru_zip_assets(
plan: &OcrSidecarPlan,
assets: &[MineruZipAsset],
) -> Result<(), WebError> {
let sidecar_dir = plan.ocr_path.parent().unwrap_or_else(|| Path::new(""));
for asset in assets {
let target = sidecar_dir.join(&asset.relative_path);
ensure_target_under_root(sidecar_dir, &target, "local_ocr_asset_root_escape")?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_ocr_asset_create_failed",
format!("无法创建 OCR 资源目录 {}: {error}", parent.display()),
)
})?;
}
fs::write(&target, &asset.bytes).map_err(|error| {
WebError::bad_request_code(
"local_ocr_asset_write_failed",
format!("无法写入 OCR 资源 {}: {error}", target.display()),
)
})?;
}
Ok(())
}
fn parse_mineru_json(body: &str, code: &'static str) -> Result<Value, WebError> {
@@ -760,7 +890,52 @@ fn find_upload_url(value: &Value) -> Option<String> {
if let Some(url) = find_json_string_by_keys(value, &["upload_url", "uploadUrl"]) {
return Some(url);
}
None
find_json_url_array_item_by_keys(
value,
&[
"file_urls",
"fileUrls",
"file_url",
"fileUrl",
"urls",
"upload_urls",
"uploadUrls",
],
)
}
fn find_json_url_array_item_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
match value {
Value::Object(map) => {
for key in keys {
if let Some(found) = map.get(*key).and_then(find_first_non_empty_json_string) {
return Some(found);
}
}
for nested in map.values() {
if let Some(found) = find_json_url_array_item_by_keys(nested, keys) {
return Some(found);
}
}
None
}
Value::Array(items) => items
.iter()
.find_map(|item| find_json_url_array_item_by_keys(item, keys)),
_ => None,
}
}
fn find_first_non_empty_json_string(value: &Value) -> Option<String> {
match value {
Value::String(text) => {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
Value::Array(items) => items.iter().find_map(find_first_non_empty_json_string),
Value::Object(map) => map.values().find_map(find_first_non_empty_json_string),
_ => None,
}
}
fn find_json_string_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
@@ -1065,6 +1240,70 @@ fn upsert_and_broadcast_ocr_index_entry(
Ok(())
}
fn cleanup_empty_ocr_sidecar_dir(root: &Path, sidecar: &Path) -> Result<(), WebError> {
let Some(parent) = sidecar.parent() else {
return Ok(());
};
ensure_target_under_root(root, parent, "local_ocr_delete_root_escape")?;
let Ok(entries) = fs::read_dir(parent) else {
return Ok(());
};
let has_other_sidecars = entries.filter_map(Result::ok).any(|entry| {
entry
.file_name()
.to_string_lossy()
.to_ascii_lowercase()
.ends_with(".ocr.md")
});
if !has_other_sidecars {
fs::remove_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_ocr_delete_failed",
format!("无法删除 OCR sidecar 目录 {}: {error}", parent.display()),
)
})?;
}
Ok(())
}
fn broadcast_ocr_job_deleted(
state: &AppState,
root_uri: &str,
source_root_relative_path: &str,
removed: Option<&OcrIndexEntry>,
) {
let now = now_ms();
let job = json!({
"jobId": removed.map(|entry| entry.job_id.as_str()).unwrap_or(""),
"ownerDocumentId": removed.map(|entry| entry.owner_document_id.as_str()).unwrap_or(""),
"ownerDocumentPath": removed.map(|entry| entry.owner_document_path.as_str()).unwrap_or(""),
"sourceRootRelativePath": source_root_relative_path,
"ocrRootRelativePath": removed.map(|entry| entry.ocr_root_relative_path.as_str()).unwrap_or(""),
"provider": removed.map(|entry| entry.provider.as_str()).unwrap_or(""),
"modelVersion": removed.map(|entry| entry.model_version.as_str()).unwrap_or(""),
"status": "deleted",
"stageLabel": "已删除",
"stale": false,
"updatedAtMs": now,
"finishedAtMs": now,
"plainTextPreview": "",
"error": null,
});
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": source_root_relative_path,
"documentId": removed.map(|entry| entry.owner_document_id.as_str()).unwrap_or(""),
"revision": now.to_string(),
"job": job,
});
let _ = state.local_ocr_job_tx.send(payload.clone());
let _ = state.stream_delta_tx.send(payload);
}
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);
@@ -1420,7 +1659,7 @@ mod tests {
.collect()
}
fn build_test_mineru_zip(markdown: &str) -> Vec<u8> {
fn build_test_mineru_zip_with_files(markdown: &str, files: &[(&str, &[u8])]) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::<u8>::new());
{
let mut writer = zip::ZipWriter::new(&mut bytes);
@@ -1428,6 +1667,12 @@ mod tests {
.start_file("full.md", zip::write::SimpleFileOptions::default())
.expect("zip start file");
writer.write_all(markdown.as_bytes()).expect("zip markdown");
for (name, content) in files {
writer
.start_file(*name, zip::write::SimpleFileOptions::default())
.expect("zip asset start file");
writer.write_all(content).expect("zip asset");
}
writer.finish().expect("zip finish");
}
bytes.into_inner()
@@ -1585,6 +1830,35 @@ mod tests {
assert!(read_payload["markdown"]
.as_str()
.is_some_and(|markdown| markdown.contains("Route OCR Token")));
let delete_response = app()
.oneshot(
Request::builder()
.method("DELETE")
.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,
"sourceRootRelativePath": "docs/Page.assets/photo.png"
})
.to_string(),
))
.expect("delete request"),
)
.await
.expect("delete response");
assert_eq!(delete_response.status(), StatusCode::OK);
assert!(!root
.join("docs")
.join("Page.ocr")
.join("photo.png.ocr.md")
.exists());
assert!(read_ocr_index(&root)
.expect("index after delete")
.entries
.is_empty());
let _ = fs::remove_dir_all(root);
}
@@ -1690,7 +1964,10 @@ mod tests {
let base_url = format!("http://{}", listener.local_addr().expect("mock addr"));
let upload_count = Arc::new(AtomicUsize::new(0));
let poll_count = Arc::new(AtomicUsize::new(0));
let zip_bytes = Arc::new(build_test_mineru_zip("# MinerU Result\n\n识别文本"));
let zip_bytes = Arc::new(build_test_mineru_zip_with_files(
"# MinerU Result\n\n![OCR 图](images/ocr.png)\n\n识别文本",
&[("images/ocr.png", b"png-bytes")],
));
let mock_mineru = axum::Router::new()
.route(
@@ -1699,8 +1976,12 @@ mod tests {
let base_url = base_url.clone();
|| async move {
Json(json!({
"batch_id": "batch_1",
"file_urls": [{ "upload_url": format!("{base_url}/upload/source") }]
"code": 0,
"msg": "ok",
"data": {
"batch_id": "batch_1",
"file_urls": [format!("{base_url}/upload/source")]
}
}))
}
}),
@@ -1831,7 +2112,18 @@ mod tests {
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("![OCR 图](images/ocr.png)"));
assert!(sidecar.contains("识别文本"));
assert_eq!(
fs::read(
root.join("docs")
.join("Page.ocr")
.join("images")
.join("ocr.png")
)
.expect("sidecar image"),
b"png-bytes"
);
mock_handle.abort();
let _ = fs::remove_dir_all(root);
+14 -2
View File
@@ -536,11 +536,14 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/local-folder/ocr/jobs",
get(local_ocr::list_jobs).post(local_ocr::create_job),
get(local_ocr::list_jobs)
.post(local_ocr::create_job)
.delete(local_ocr::delete_job),
)
.route("/api/local-folder/ocr/status", get(local_ocr::status))
.route("/api/local-folder/ocr/read", get(local_ocr::read))
.route("/api/local-folder/ocr/insert", post(local_ocr::insert))
.route("/api/local-folder/ocr/delete", post(local_ocr::delete_job))
.route(
"/api/local-folder/workspaces/default",
post(local_folder_source::create_default_local_workspace),
@@ -1015,7 +1018,8 @@ mod tests {
"current_page": true,
"folder": true
},
"ai.agent.hermes.profile_id": "mnoteai"
"ai.agent.hermes.profile_id": "mnoteai",
"localOcr.autoEnabled": true
}
})
.to_string(),
@@ -1074,6 +1078,10 @@ mod tests {
alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
"mnoteai"
);
assert_eq!(
alice_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
true
);
let mut bob_get = Request::builder()
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
@@ -1094,6 +1102,10 @@ mod tests {
.as_object()
.map(|value| value.is_empty())
.unwrap_or(false));
assert_eq!(
bob_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
false
);
let _ = fs::remove_dir_all(&root);
}
@@ -60,6 +60,7 @@ struct EffectivePagePreferences {
page_options: PageOptions,
page_width_preferences: BTreeMap<String, EffectivePageWidthPreference>,
ai_preferences: BTreeMap<String, Value>,
local_ocr_preferences: BTreeMap<String, Value>,
sources: BTreeMap<String, String>,
}
@@ -246,10 +247,12 @@ fn resolve_effective_page_preferences(
let mut sources = BTreeMap::new();
let mut page_width_preferences = default_page_width_preferences();
let mut ai_preferences = BTreeMap::new();
let mut local_ocr_preferences = default_local_ocr_preferences();
apply_preference_records(
&mut page_options,
&mut page_width_preferences,
&mut ai_preferences,
&mut local_ocr_preferences,
&mut sources,
&scope,
&preferences,
@@ -260,6 +263,7 @@ fn resolve_effective_page_preferences(
page_options,
page_width_preferences,
ai_preferences,
local_ocr_preferences,
sources,
})
}
@@ -268,6 +272,7 @@ fn apply_preference_records(
page_options: &mut PageOptions,
page_width_preferences: &mut BTreeMap<String, EffectivePageWidthPreference>,
ai_preferences: &mut BTreeMap<String, Value>,
local_ocr_preferences: &mut BTreeMap<String, Value>,
sources: &mut BTreeMap<String, String>,
scope: &PagePreferenceScope,
preferences: &[UserUiPreferenceRecord],
@@ -277,6 +282,7 @@ fn apply_preference_records(
"source_family".to_string(),
"workspace".to_string(),
"document".to_string(),
"localOcr".to_string(),
];
for preference in preferences {
if preference.scope_kind.starts_with("ai.") && !scope_kinds.contains(&preference.scope_kind)
@@ -295,6 +301,7 @@ fn apply_preference_records(
"workspace" => preference.scope_id.trim() == scope.workspace_id,
"document" => preference.scope_id.trim() == scope.document_id,
kind if kind.starts_with("ai.") => preference.scope_id.trim() == scope.workspace_id,
"localOcr" => preference.scope_id.trim() == scope.workspace_id,
_ => false,
};
if !scope_matches {
@@ -311,6 +318,11 @@ fn apply_preference_records(
sources.insert(preference.key.clone(), scope_kind.to_string());
continue;
}
if preference.key.starts_with("localOcr.") {
local_ocr_preferences.insert(preference.key.clone(), value);
sources.insert(preference.key.clone(), scope_kind.to_string());
continue;
}
if let Some(content_type) = page_width_content_type_for_key(&preference.key) {
if let Some(normalized) = normalize_page_width_preference(content_type, &value) {
if let Some(preference_value) = page_width_preferences.get_mut(content_type) {
@@ -368,6 +380,9 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
return Some((format!("ai.agent.{agent}"), scope.workspace_id.clone()));
}
}
if trimmed.starts_with("localOcr.") {
return Some(("localOcr".to_string(), scope.workspace_id.clone()));
}
if page_width_content_type_for_key(key).is_some() {
return Some(("global".to_string(), "default".to_string()));
}
@@ -401,7 +416,11 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
}
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
if scope_kind == "workspace"
|| scope_kind == "document"
|| scope_kind.starts_with("ai.")
|| scope_kind == "localOcr"
{
Some(workspace_id.to_string())
} else {
None
@@ -409,7 +428,11 @@ fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Optio
}
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
if scope_kind == "workspace"
|| scope_kind == "document"
|| scope_kind.starts_with("ai.")
|| scope_kind == "localOcr"
{
Some(source_kind.to_string())
} else {
None
@@ -422,6 +445,8 @@ fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
serde_json::to_value(&effective.page_width_preferences).unwrap_or_else(|_| json!({}));
let ai_preferences =
serde_json::to_value(&effective.ai_preferences).unwrap_or_else(|_| json!({}));
let local_ocr_preferences =
serde_json::to_value(&effective.local_ocr_preferences).unwrap_or_else(|_| json!({}));
let sources = effective
.sources
.into_iter()
@@ -440,6 +465,7 @@ fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
"pageOptions": page_options,
"pageWidthPreferences": page_width_preferences,
"aiPreferences": ai_preferences,
"localOcrPreferences": local_ocr_preferences,
"sources": Value::Object(sources),
}
})
@@ -594,6 +620,10 @@ fn default_page_width_preferences() -> BTreeMap<String, EffectivePageWidthPrefer
.collect()
}
fn default_local_ocr_preferences() -> BTreeMap<String, Value> {
BTreeMap::from([("localOcr.autoEnabled".to_string(), Value::Bool(false))])
}
fn normalize_page_width_preference(
content_type: &str,
value: &Value,
+65 -4
View File
@@ -11,8 +11,8 @@ use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot,
resolve_local_markdown_page_aggregate,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
@@ -215,7 +215,8 @@ pub async fn document_page_shell(
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
let root_uri = query.root_uri.as_deref().unwrap_or_default();
(
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
render_local_sidebar_tree_html_scoped(root_uri, Some(&document_id), file_tree_scope)
.unwrap_or_default(),
render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope)
.unwrap_or_default(),
)
@@ -2659,7 +2660,22 @@ pub(crate) fn render_local_sidebar_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
render_local_sidebar_tree_html_scoped(root_uri, active_document_id, None)
}
pub(crate) fn render_local_sidebar_tree_html_scoped(
root_uri: &str,
active_document_id: Option<&str>,
file_tree_scope: Option<&str>,
) -> Result<String, WebError> {
let snapshot = if let Some(scope) = file_tree_scope
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
};
Ok(render_local_sidebar_tree_html_from_snapshot(
&snapshot,
active_document_id,
@@ -4277,6 +4293,51 @@ mod tests {
));
}
#[tokio::test]
async fn document_shell_local_folder_filetree_scope_renders_scoped_page_tree() {
let root = std::env::temp_dir().join(format!(
"mnote-local-document-shell-scoped-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("design").join("done")).expect("create design done");
std::fs::write(root.join("Home.md"), "# Home\n").expect("write home");
std::fs::write(
root.join("design").join("done").join("Target.md"),
"# Target\n",
)
.expect("write target");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:design~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains(r#"data-node-id="local-md:design~2Fdone~2FTarget.md""#));
assert!(
!html.contains(r#"data-node-id="local-md:Home.md""#),
"文档页带 fileTreeScope 时 PageTree 不应回退到 workspace root 全量扫描"
);
}
#[test]
fn document_conflict_panel_runtime_contains_dom_helpers() {
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =
@@ -188,6 +188,7 @@ pub fn PageLayout(
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button mnote-local-ocr-task-toggle" title="OCR 任务" aria-label="OCR 任务" data-testid="mnote-local-ocr-task-toggle" data-mnote-action="toggle-ocr-tasks"><span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
@@ -815,6 +816,14 @@ mod tests {
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("uploadFileToMediaAsset: uploadFileToMediaAsset"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("uploadFilesWithResolvedTarget"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("__mnoteLastEditorUploadRoot"));
assert!(
LOCAL_UPLOAD_RUNTIME_JS.contains("var imageUrl = localOpenUrl || fallbackUrl || markdownHref;"),
"图片上传首屏必须使用本地 open URL 渲染,避免相对 href 在 /documents 下命中退役 Convex 兼容路径"
);
assert!(
LOCAL_UPLOAD_RUNTIME_JS.contains("setImage({ src: imageUrl"),
"插入图片时不能直接把 ./asset.png 作为 img.src,否则刷新前会显示破损"
);
}
#[test]
+48 -15
View File
@@ -182,6 +182,7 @@ a:hover {
.material-symbols-outlined[data-icon="mode_comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="document_scanner"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7 3H5a2 2 0 0 0-2 2v2M17 3h2a2 2 0 0 1 2 2v2M7 21H5a2 2 0 0 1-2-2v-2M17 21h2a2 2 0 0 0 2-2v-2M7 8h10M7 12h10M7 16h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="article"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 9A6 6 0 0 1 18 6M16.5 15A6 6 0 0 1 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="drive_file_rename_outline"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m4 17-.5 3.5L7 20l11-11-3-3zM13 8l3 3M5 6h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
@@ -2780,28 +2781,36 @@ body {
.mnote-local-ocr-task-dock {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 80;
right: 16px;
top: 48px;
z-index: 90;
color: #37352f;
font-size: 13px;
pointer-events: none;
}
.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-toggle {
position: relative;
}
.mnote-local-ocr-task-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 999px;
background: #d1453b;
color: #fff;
font-size: 10px;
line-height: 14px;
text-align: center;
box-sizing: border-box;
pointer-events: none;
}
.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;
@@ -2809,13 +2818,33 @@ body {
border-radius: 6px;
background: #FFF;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
pointer-events: auto;
}
.mnote-local-ocr-task-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(55, 53, 47, 0.1);
}
.mnote-local-ocr-task-close {
width: 24px;
height: 24px;
border: 0;
border-radius: 4px;
background: transparent;
color: #787774;
cursor: pointer;
}
.mnote-local-ocr-task-close:hover {
background: rgba(55, 53, 47, 0.08);
color: #37352f;
}
.mnote-local-ocr-task-row {
display: flex;
align-items: center;
@@ -2861,6 +2890,10 @@ body {
cursor: pointer;
}
.mnote-local-ocr-task-actions button[data-mnote-local-ocr-task-delete] {
color: #b3261e;
}
.mnote-resource-tab-frame,
.mnote-resource-tab-image,
.mnote-resource-tab-text-shell {