feat: restore Wolai workspace navigation and assets
This commit is contained in:
@@ -1017,8 +1017,19 @@ fn render_recent_navigation_items(records: &[NavigationRecentRecord]) -> String
|
||||
}
|
||||
|
||||
fn render_navigation_link(title: &str, href: &str, kind: &str, relative_path: &str) -> String {
|
||||
let is_page = kind == "page";
|
||||
let class_name = if is_page {
|
||||
"mnote-navigation-page__item mnote-page-block-link"
|
||||
} else {
|
||||
"mnote-navigation-page__item"
|
||||
};
|
||||
let page_attributes = if is_page {
|
||||
r#" data-area="page-block" data-block-type="page""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
r#"<a class="mnote-navigation-page__item" data-navigation-item-kind="{}" data-local-relative-path="{}" href="{}">{}</a>"#,
|
||||
r#"<a class="{class_name}" data-navigation-item-kind="{}" data-local-relative-path="{}"{page_attributes} href="{}">{}</a>"#,
|
||||
escape_html(kind),
|
||||
escape_html(relative_path),
|
||||
escape_html(href),
|
||||
@@ -1079,7 +1090,7 @@ fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String {
|
||||
|
||||
fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id: &str) -> String {
|
||||
let mut href = format!(
|
||||
"/documents/{}?sourceKind=local_folder&rootUri={}&treeView=filetree",
|
||||
"/documents/{}?sourceKind=local_folder&rootUri={}",
|
||||
query_escape(document_id),
|
||||
query_escape(root_uri)
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::body::Body;
|
||||
use axum::extract::{Extension, Json, Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::Response;
|
||||
use control_plane::{ControlPlaneError, KnowledgeBaseRecord, UpsertKnowledgeBaseInput};
|
||||
use core_protocol::evidence::{
|
||||
EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceResourceKind,
|
||||
};
|
||||
@@ -131,6 +132,15 @@ pub struct KnowledgeRagCreateKnowledgeBaseRequest {
|
||||
pub default_tool_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnowledgeRagDeleteKnowledgeBaseRequest {
|
||||
pub workspace_id: Option<String>,
|
||||
pub root_uri: String,
|
||||
pub knowledge_base_id: Option<String>,
|
||||
pub provider_knowledge_base_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnowledgeRagOpenReferenceRequest {
|
||||
@@ -334,6 +344,170 @@ fn filter_knowledge_bases_for_actor(bases: &mut KnowledgeRagKnowledgeBaseRegistr
|
||||
.retain(|base| knowledge_base_visible_to_actor(base, actor_id));
|
||||
}
|
||||
|
||||
fn control_plane_knowledge_base_to_registry_base(
|
||||
record: &KnowledgeBaseRecord,
|
||||
) -> KnowledgeRagKnowledgeBase {
|
||||
KnowledgeRagKnowledgeBase {
|
||||
base_id: record.id.clone(),
|
||||
user_id: record.user_id.clone(),
|
||||
workspace_id: record.workspace_id.clone().unwrap_or_default(),
|
||||
root_uri: record.root_uri.clone().unwrap_or_default(),
|
||||
name: record.name.clone(),
|
||||
description: record.description.clone(),
|
||||
provider: record.provider.clone(),
|
||||
provider_kb_id: record.provider_kb_id.clone(),
|
||||
default_tool_enabled: record.default_tool_enabled,
|
||||
can_write: record.can_write,
|
||||
source_count: record.source_count.max(0) as usize,
|
||||
chunk_count: record.chunk_count.max(0) as usize,
|
||||
status: record.status.clone(),
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn control_plane_knowledge_base_error(
|
||||
error: ControlPlaneError,
|
||||
context: &RequestContext,
|
||||
) -> WebError {
|
||||
match error {
|
||||
ControlPlaneError::NotFound(message) => WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"knowledge_rag_control_plane_kb_not_found",
|
||||
message,
|
||||
),
|
||||
ControlPlaneError::Conflict(message) => WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"knowledge_rag_control_plane_kb_conflict",
|
||||
message,
|
||||
),
|
||||
ControlPlaneError::InvalidInput(message) => {
|
||||
WebError::bad_request_code("knowledge_rag_control_plane_kb_invalid", message)
|
||||
}
|
||||
ControlPlaneError::Unauthorized(message) | ControlPlaneError::SessionExpired(message) => {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"knowledge_rag_control_plane_kb_forbidden",
|
||||
message,
|
||||
)
|
||||
}
|
||||
ControlPlaneError::Timeout(message) => {
|
||||
WebError::gateway_timeout_code("knowledge_rag_control_plane_kb_timeout", message)
|
||||
}
|
||||
ControlPlaneError::RateLimit(message) => WebError::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"knowledge_rag_control_plane_kb_rate_limited",
|
||||
message,
|
||||
),
|
||||
ControlPlaneError::Storage(message) => WebError::service_unavailable_code(
|
||||
"knowledge_rag_control_plane_kb_storage_failed",
|
||||
message,
|
||||
),
|
||||
}
|
||||
.with_context(context)
|
||||
}
|
||||
|
||||
fn control_plane_knowledge_bases_registry(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
) -> Result<KnowledgeRagKnowledgeBaseRegistry, WebError> {
|
||||
let records = state
|
||||
.control_plane()
|
||||
.list_knowledge_bases(
|
||||
context.auth.actor_id.as_str(),
|
||||
Some(workspace_id),
|
||||
Some(root_uri),
|
||||
)
|
||||
.map_err(|error| control_plane_knowledge_base_error(error, context))?;
|
||||
Ok(KnowledgeRagKnowledgeBaseRegistry {
|
||||
schema: KNOWLEDGE_BASES_SCHEMA.to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
root_uri: root_uri.to_string(),
|
||||
updated_at_ms: now_ms(),
|
||||
bases: records
|
||||
.iter()
|
||||
.map(control_plane_knowledge_base_to_registry_base)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_base_source_counts(
|
||||
sources: &KnowledgeRagSourceRegistry,
|
||||
provider_kb_id: &str,
|
||||
) -> (i64, i64) {
|
||||
let source_count = sources
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
!entry.stale
|
||||
&& entry.deleted_at_ms.is_none()
|
||||
&& entry.provider_knowledge_base_id.as_deref() == Some(provider_kb_id)
|
||||
})
|
||||
.count() as i64;
|
||||
(source_count, 0)
|
||||
}
|
||||
|
||||
fn control_plane_knowledge_base_upsert_input(
|
||||
record: &KnowledgeBaseRecord,
|
||||
source_count: i64,
|
||||
chunk_count: i64,
|
||||
) -> UpsertKnowledgeBaseInput {
|
||||
UpsertKnowledgeBaseInput {
|
||||
id: Some(record.id.clone()),
|
||||
user_id: record.user_id.clone(),
|
||||
workspace_id: record.workspace_id.clone(),
|
||||
root_uri: record.root_uri.clone(),
|
||||
name: record.name.clone(),
|
||||
description: record.description.clone(),
|
||||
provider: record.provider.clone(),
|
||||
provider_kb_id: record.provider_kb_id.clone(),
|
||||
default_tool_enabled: record.default_tool_enabled,
|
||||
can_write: record.can_write,
|
||||
source_count,
|
||||
chunk_count,
|
||||
status: record.status.clone(),
|
||||
metadata_json: record.metadata_json.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_lightrag_control_plane_knowledge_base_counts(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
sources: &KnowledgeRagSourceRegistry,
|
||||
) -> Result<(), WebError> {
|
||||
let records = state
|
||||
.control_plane()
|
||||
.list_knowledge_bases(
|
||||
context.auth.actor_id.as_str(),
|
||||
Some(workspace_id),
|
||||
Some(root_uri),
|
||||
)
|
||||
.map_err(|error| control_plane_knowledge_base_error(error, context))?;
|
||||
for record in records
|
||||
.iter()
|
||||
.filter(|record| is_legacy_lightrag_provider(&record.provider))
|
||||
{
|
||||
let (source_count, chunk_count) =
|
||||
knowledge_base_source_counts(sources, &record.provider_kb_id);
|
||||
if record.source_count == source_count && record.chunk_count == chunk_count {
|
||||
continue;
|
||||
}
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_knowledge_base(control_plane_knowledge_base_upsert_input(
|
||||
record,
|
||||
source_count,
|
||||
chunk_count,
|
||||
))
|
||||
.map_err(|error| control_plane_knowledge_base_error(error, context))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn registry_owner_for_provider_kb(
|
||||
sources: &KnowledgeRagSourceRegistry,
|
||||
provider_kb_id: &str,
|
||||
@@ -473,25 +647,26 @@ pub async fn status(
|
||||
} else {
|
||||
(None, Value::Array(Vec::new()))
|
||||
};
|
||||
let knowledge_bases = if active_knowledge_provider_kind() == KnowledgeProvider::Weknora {
|
||||
if let Some(root_uri) = query.root_uri.as_deref().filter(|value| !value.is_empty()) {
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||
&state, &context, root_uri,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let workspace_id = effective_workspace_id(query.workspace_id.as_deref(), root_uri);
|
||||
let registry =
|
||||
registry
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| KnowledgeRagSourceRegistry {
|
||||
schema: REGISTRY_SCHEMA.to_string(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
root_uri: root_uri.to_string(),
|
||||
updated_at_ms: now_ms(),
|
||||
indexed_roots: Vec::new(),
|
||||
entries: Vec::new(),
|
||||
});
|
||||
let knowledge_bases = if let Some(root_uri) =
|
||||
query.root_uri.as_deref().filter(|value| !value.is_empty())
|
||||
{
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||
&state, &context, root_uri,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let workspace_id = effective_workspace_id(query.workspace_id.as_deref(), root_uri);
|
||||
let registry = registry
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| KnowledgeRagSourceRegistry {
|
||||
schema: REGISTRY_SCHEMA.to_string(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
root_uri: root_uri.to_string(),
|
||||
updated_at_ms: now_ms(),
|
||||
indexed_roots: Vec::new(),
|
||||
entries: Vec::new(),
|
||||
});
|
||||
if active_knowledge_provider_kind() == KnowledgeProvider::Weknora {
|
||||
let mut bases = read_knowledge_bases_registry(&root_path, &workspace_id, root_uri)?;
|
||||
ensure_default_weknora_knowledge_bases(
|
||||
&mut bases,
|
||||
@@ -510,7 +685,19 @@ pub async fn status(
|
||||
filter_knowledge_bases_for_actor(&mut visible_bases, &context.auth.actor_id);
|
||||
Some(visible_bases)
|
||||
} else {
|
||||
None
|
||||
sync_lightrag_control_plane_knowledge_base_counts(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
root_uri,
|
||||
®istry,
|
||||
)?;
|
||||
Some(control_plane_knowledge_bases_registry(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
root_uri,
|
||||
)?)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
@@ -658,13 +845,6 @@ pub async fn create_knowledge_base(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<KnowledgeRagCreateKnowledgeBaseRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
if active_knowledge_provider() != WEKNORA_PROVIDER {
|
||||
return Err(WebError::bad_request_code(
|
||||
"knowledge_rag_create_kb_provider_unsupported",
|
||||
"当前只有 WeKnora provider 支持从 MNote 创建知识库",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let name = body.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
@@ -681,6 +861,71 @@ pub async fn create_knowledge_base(
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
|
||||
let mut source_registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
|
||||
|
||||
if active_knowledge_provider_kind() != KnowledgeProvider::Weknora {
|
||||
let now = now_ms();
|
||||
let provider_kb_id = format!(
|
||||
"lightrag-kb-{}",
|
||||
short_hash(&format!(
|
||||
"{}|{}|{}|{}",
|
||||
context.auth.actor_id, workspace_id, body.root_uri, now
|
||||
))
|
||||
);
|
||||
let (source_count, chunk_count) =
|
||||
knowledge_base_source_counts(&source_registry, &provider_kb_id);
|
||||
let record = state
|
||||
.control_plane()
|
||||
.upsert_knowledge_base(UpsertKnowledgeBaseInput {
|
||||
id: None,
|
||||
user_id: context.auth.actor_id.clone(),
|
||||
workspace_id: Some(workspace_id.clone()),
|
||||
root_uri: Some(body.root_uri.clone()),
|
||||
name: name.to_string(),
|
||||
description: body
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
provider: LEGACY_KNOWLEDGE_PROVIDER.to_string(),
|
||||
provider_kb_id: provider_kb_id.clone(),
|
||||
default_tool_enabled: body.default_tool_enabled.unwrap_or(true),
|
||||
can_write: true,
|
||||
source_count,
|
||||
chunk_count,
|
||||
status: "active".into(),
|
||||
metadata_json: json!({
|
||||
"source": "mnote.knowledge_rag",
|
||||
"workspaceId": workspace_id.clone(),
|
||||
"rootUri": body.root_uri.clone(),
|
||||
})
|
||||
.to_string(),
|
||||
})
|
||||
.map_err(|error| control_plane_knowledge_base_error(error, &context))?;
|
||||
let bases = control_plane_knowledge_bases_registry(
|
||||
&state,
|
||||
&context,
|
||||
&record.workspace_id.clone().unwrap_or_default(),
|
||||
record.root_uri.as_deref().unwrap_or(body.root_uri.as_str()),
|
||||
)?;
|
||||
let base = Some(control_plane_knowledge_base_to_registry_base(&record));
|
||||
return Ok(Json(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.knowledge_rag.create_knowledge_base_result.v1",
|
||||
"provider": LEGACY_KNOWLEDGE_PROVIDER,
|
||||
"providerConfig": knowledge_provider_config(),
|
||||
"knowledgeBase": base,
|
||||
"providerKnowledgeBaseId": provider_kb_id,
|
||||
"knowledgeBases": bases,
|
||||
"registry": {
|
||||
"schema": source_registry.schema,
|
||||
"workspaceId": source_registry.workspace_id,
|
||||
"rootUri": source_registry.root_uri,
|
||||
"sourceCount": source_registry.entries.len(),
|
||||
},
|
||||
})));
|
||||
}
|
||||
|
||||
let mut bases = read_knowledge_bases_registry(&root_path, &workspace_id, &body.root_uri)?;
|
||||
ensure_default_weknora_knowledge_bases(
|
||||
&mut bases,
|
||||
@@ -750,6 +995,95 @@ pub async fn create_knowledge_base(
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn delete_knowledge_base(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<KnowledgeRagDeleteKnowledgeBaseRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let requested_kb_id = body
|
||||
.provider_knowledge_base_id
|
||||
.as_deref()
|
||||
.or(body.knowledge_base_id.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"knowledge_rag_delete_kb_id_required",
|
||||
"删除资料库缺少 knowledgeBaseId",
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
if requested_kb_id == "lightrag-default" {
|
||||
return Err(WebError::bad_request_code(
|
||||
"knowledge_rag_delete_default_kb_forbidden",
|
||||
"默认资料库不能删除",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||
&state,
|
||||
&context,
|
||||
&body.root_uri,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
|
||||
let mut registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
|
||||
filter_source_registry_for_actor(&mut registry, &context.auth.actor_id);
|
||||
let active_source_count = registry
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
!entry.stale
|
||||
&& entry.deleted_at_ms.is_none()
|
||||
&& entry.provider_knowledge_base_id.as_deref() == Some(requested_kb_id)
|
||||
})
|
||||
.count();
|
||||
if active_source_count > 0 {
|
||||
return Err(WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"knowledge_rag_delete_kb_not_empty",
|
||||
"请先移除该资料库中的资料,再删除资料库",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let bases =
|
||||
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
|
||||
let base = bases
|
||||
.bases
|
||||
.iter()
|
||||
.find(|base| base.base_id == requested_kb_id || base.provider_kb_id == requested_kb_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"knowledge_rag_delete_kb_not_found",
|
||||
"资料库不存在,或当前用户无权删除",
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
if !base.can_write {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"knowledge_rag_delete_kb_readonly",
|
||||
"当前资料库不可删除",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
state
|
||||
.control_plane()
|
||||
.delete_knowledge_base(context.auth.actor_id.as_str(), &base.base_id)
|
||||
.map_err(|error| control_plane_knowledge_base_error(error, &context))?;
|
||||
let knowledge_bases =
|
||||
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.knowledge_rag.delete_knowledge_base_result.v1",
|
||||
"providerKnowledgeBaseId": base.provider_kb_id,
|
||||
"knowledgeBaseId": base.base_id,
|
||||
"knowledgeBases": knowledge_bases,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_registry_for_root(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
@@ -808,6 +1142,17 @@ pub async fn ingest(
|
||||
&context,
|
||||
));
|
||||
}
|
||||
let lightrag_requested_provider_kb_id = requested_lightrag_knowledge_base_id(
|
||||
body.knowledge_base_id.as_deref(),
|
||||
body.provider_knowledge_base_id.as_deref(),
|
||||
);
|
||||
let lightrag_bases =
|
||||
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
|
||||
let provider_kb_id = scoped_lightrag_provider_kb_id_for_registry(
|
||||
&lightrag_bases,
|
||||
lightrag_requested_provider_kb_id.as_deref(),
|
||||
&context,
|
||||
)?;
|
||||
let input_dir = lightrag_input_dir();
|
||||
fs::create_dir_all(&input_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -903,6 +1248,8 @@ pub async fn ingest(
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry.source_path == canonical_key
|
||||
&& entry.provider_knowledge_base_id.as_deref()
|
||||
== provider_kb_id.as_deref()
|
||||
&& entry.deleted_at_ms.is_none()
|
||||
&& !entry.stale
|
||||
})
|
||||
@@ -919,6 +1266,7 @@ pub async fn ingest(
|
||||
if !force {
|
||||
if let Some(existing) = registry.entries.iter().find(|entry| {
|
||||
entry.source_path == canonical_key
|
||||
&& entry.provider_knowledge_base_id.as_deref() == provider_kb_id.as_deref()
|
||||
&& entry.source_hash == source_hash
|
||||
&& entry.deleted_at_ms.is_none()
|
||||
&& !entry.stale
|
||||
@@ -937,6 +1285,7 @@ pub async fn ingest(
|
||||
"lightRagFilePath": existing.light_rag_file_path,
|
||||
"lightRagDocId": existing.light_rag_doc_id,
|
||||
"lightRagStatus": existing.light_rag_status,
|
||||
"providerKnowledgeBaseId": provider_kb_id,
|
||||
"skipped": true,
|
||||
"skipReason": "already_registered",
|
||||
}));
|
||||
@@ -955,7 +1304,11 @@ pub async fn ingest(
|
||||
let now = now_ms();
|
||||
let source_id = format!(
|
||||
"lightrag-source-{}",
|
||||
short_hash(&canonical.display().to_string())
|
||||
short_hash(&format!(
|
||||
"{}:{}",
|
||||
canonical.display(),
|
||||
provider_kb_id.as_deref().unwrap_or_default()
|
||||
))
|
||||
);
|
||||
upsert_registry_entry(
|
||||
&mut registry,
|
||||
@@ -975,7 +1328,7 @@ pub async fn ingest(
|
||||
provider_status: Some("submitted".into()),
|
||||
provider_source_id: None,
|
||||
provider_knowledge_id: None,
|
||||
provider_knowledge_base_id: None,
|
||||
provider_knowledge_base_id: provider_kb_id.clone(),
|
||||
light_rag_doc_id: None,
|
||||
light_rag_status: Some("submitted".into()),
|
||||
light_rag_file_path: light_rag_file_path.clone(),
|
||||
@@ -989,7 +1342,7 @@ pub async fn ingest(
|
||||
&relative,
|
||||
resolved.source_kind,
|
||||
LEGACY_KNOWLEDGE_PROVIDER,
|
||||
None,
|
||||
provider_kb_id.as_deref(),
|
||||
None,
|
||||
)),
|
||||
deleted_at_ms: None,
|
||||
@@ -1009,11 +1362,19 @@ pub async fn ingest(
|
||||
"parserHint": parser_hint,
|
||||
"providerDelete": provider_delete,
|
||||
"scanMode": if direct_scan_source { "direct" } else { "markdown_wrapper" },
|
||||
"providerKnowledgeBaseId": provider_kb_id,
|
||||
"lightRagStatus": "submitted",
|
||||
}));
|
||||
}
|
||||
}
|
||||
write_registry(&root_path, &mut registry)?;
|
||||
sync_lightrag_control_plane_knowledge_base_counts(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
&body.root_uri,
|
||||
®istry,
|
||||
)?;
|
||||
|
||||
let scan = lightrag_json(
|
||||
reqwest::Method::POST,
|
||||
@@ -1500,6 +1861,17 @@ pub async fn query_rag(
|
||||
&context,
|
||||
));
|
||||
}
|
||||
let requested_provider_kb_id = requested_lightrag_knowledge_base_id(
|
||||
body.knowledge_base_id.as_deref(),
|
||||
body.provider_knowledge_base_id.as_deref(),
|
||||
);
|
||||
let lightrag_bases =
|
||||
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
|
||||
let scoped_provider_kb_id = scoped_lightrag_provider_kb_id_for_registry(
|
||||
&lightrag_bases,
|
||||
requested_provider_kb_id.as_deref(),
|
||||
&context,
|
||||
)?;
|
||||
let requested_mode = normalize_lightrag_query_mode(body.mode.as_deref());
|
||||
let mode_decision =
|
||||
resolve_lightrag_query_mode_for_scope(®istry, &source_scope, &requested_mode);
|
||||
@@ -1523,6 +1895,7 @@ pub async fn query_rag(
|
||||
let mut references =
|
||||
mapped_references(&raw, ®istry, &body.root_uri, &root_path, Some(&query));
|
||||
filter_mapped_references_by_source_scope(&mut references, &source_scope);
|
||||
filter_mapped_references_by_provider_kb_id(&mut references, scoped_provider_kb_id.as_deref());
|
||||
let include_document_structure_index = body
|
||||
.include_document_structure_index
|
||||
.unwrap_or_else(|| mode_decision.reason == "source_scope_skip_kg_document");
|
||||
@@ -1620,6 +1993,17 @@ pub async fn search(
|
||||
&context,
|
||||
));
|
||||
}
|
||||
let requested_provider_kb_id = requested_lightrag_knowledge_base_id(
|
||||
body.knowledge_base_id.as_deref(),
|
||||
body.provider_knowledge_base_id.as_deref(),
|
||||
);
|
||||
let lightrag_bases =
|
||||
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
|
||||
let scoped_provider_kb_id = scoped_lightrag_provider_kb_id_for_registry(
|
||||
&lightrag_bases,
|
||||
requested_provider_kb_id.as_deref(),
|
||||
&context,
|
||||
)?;
|
||||
let requested_search_mode = normalize_knowledge_rag_search_mode(body.mode.as_deref());
|
||||
let mode_decision = resolve_knowledge_rag_search_mode_for_scope(
|
||||
®istry,
|
||||
@@ -1663,6 +2047,7 @@ pub async fn search(
|
||||
let mut references =
|
||||
mapped_references(&raw, ®istry, &body.root_uri, &root_path, Some(&query));
|
||||
filter_mapped_references_by_source_scope(&mut references, &source_scope);
|
||||
filter_mapped_references_by_provider_kb_id(&mut references, scoped_provider_kb_id.as_deref());
|
||||
if search_mode == "exact" {
|
||||
filter_mapped_references_by_search_query(&mut references, &query);
|
||||
rank_mapped_references_for_query(&mut references, &query);
|
||||
@@ -1988,6 +2373,43 @@ fn filter_mapped_references_by_source_scope(references: &mut Vec<Value>, source_
|
||||
});
|
||||
}
|
||||
|
||||
fn filter_mapped_references_by_provider_kb_id(
|
||||
references: &mut Vec<Value>,
|
||||
provider_kb_id: Option<&str>,
|
||||
) {
|
||||
let Some(provider_kb_id) = provider_kb_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
references.retain(|reference| {
|
||||
mapped_reference_provider_kb_id(reference).as_deref() == Some(provider_kb_id)
|
||||
});
|
||||
}
|
||||
|
||||
fn mapped_reference_provider_kb_id(reference: &Value) -> Option<String> {
|
||||
reference
|
||||
.get("providerKnowledgeBaseId")
|
||||
.or_else(|| reference.get("provider_knowledge_base_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
reference
|
||||
.get("providerIds")
|
||||
.and_then(|ids| {
|
||||
ids.get("knowledgeBaseId")
|
||||
.or_else(|| ids.get("knowledge_base_id"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn filter_mapped_references_by_search_query(references: &mut Vec<Value>, query: &str) {
|
||||
let query_normalized = normalize_text_for_match(query).to_ascii_lowercase();
|
||||
if query_normalized.is_empty() {
|
||||
@@ -2695,6 +3117,55 @@ fn requested_weknora_knowledge_base_id(
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn requested_lightrag_knowledge_base_id(
|
||||
knowledge_base_id: Option<&str>,
|
||||
provider_knowledge_base_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
provider_knowledge_base_id
|
||||
.or(knowledge_base_id)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "lightrag-default")
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn resolve_lightrag_provider_kb_id_for_registry(
|
||||
bases: &KnowledgeRagKnowledgeBaseRegistry,
|
||||
requested_kb_id: &str,
|
||||
) -> Result<String, ()> {
|
||||
let requested_kb_id = requested_kb_id.trim();
|
||||
if requested_kb_id.is_empty() || requested_kb_id == "lightrag-default" {
|
||||
return Err(());
|
||||
}
|
||||
bases
|
||||
.bases
|
||||
.iter()
|
||||
.find(|base| {
|
||||
is_legacy_lightrag_provider(&base.provider)
|
||||
&& base.can_write
|
||||
&& (base.provider_kb_id == requested_kb_id || base.base_id == requested_kb_id)
|
||||
})
|
||||
.map(|base| base.provider_kb_id.clone())
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn scoped_lightrag_provider_kb_id_for_registry(
|
||||
bases: &KnowledgeRagKnowledgeBaseRegistry,
|
||||
requested_provider_kb_id: Option<&str>,
|
||||
context: &RequestContext,
|
||||
) -> Result<Option<String>, WebError> {
|
||||
requested_provider_kb_id
|
||||
.map(|requested| resolve_lightrag_provider_kb_id_for_registry(bases, requested))
|
||||
.transpose()
|
||||
.map_err(|()| {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"knowledge_rag_lightrag_kb_not_writable",
|
||||
"当前资料库不存在,或无权写入",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn scoped_weknora_knowledge_base_ids(requested_provider_kb_id: Option<&str>) -> Vec<String> {
|
||||
if let Some(provider_kb_id) = requested_provider_kb_id
|
||||
@@ -4187,6 +4658,9 @@ fn map_reference_plan(
|
||||
let locator_degraded = entry.is_none_or(|entry| entry.stale || entry.deleted_at_ms.is_some());
|
||||
let source_path = entry.map(|entry| entry.source_path.clone());
|
||||
let source_root_relative_path = entry.map(|entry| entry.source_root_relative_path.clone());
|
||||
let provider_kb_id = entry
|
||||
.and_then(|entry| entry.provider_knowledge_base_id.as_deref())
|
||||
.unwrap_or_default();
|
||||
let primary_chunk = reference
|
||||
.get("chunks")
|
||||
.and_then(Value::as_array)
|
||||
@@ -4298,6 +4772,14 @@ fn map_reference_plan(
|
||||
"citationLabel": format!("[{}]", citation_id),
|
||||
"matchSource": if occurrence_index.is_null() { "lightrag_reference" } else { "lightrag_search" },
|
||||
"reference": reference,
|
||||
"providerIds": {
|
||||
"knowledgeBaseId": provider_kb_id,
|
||||
"knowledgeId": doc_id.unwrap_or_default(),
|
||||
"chunkId": chunk_id,
|
||||
},
|
||||
"providerKnowledgeBaseId": provider_kb_id,
|
||||
"providerKnowledgeId": doc_id.unwrap_or_default(),
|
||||
"providerChunkId": chunk_id,
|
||||
"filePath": file_path,
|
||||
"chunkId": chunk_id,
|
||||
"sourceChunkId": source_chunk_id,
|
||||
@@ -6907,13 +7389,18 @@ fn registry_entry_upsert_key_matches(
|
||||
if existing.source_path != next.source_path {
|
||||
return false;
|
||||
}
|
||||
let existing_provider = existing.provider.as_deref();
|
||||
let next_provider = next.provider.as_deref();
|
||||
if next_provider == Some(WEKNORA_PROVIDER)
|
||||
&& next.provider_knowledge_base_id.as_deref().is_some()
|
||||
if existing_provider.is_some() || next_provider.is_some() {
|
||||
if existing_provider != next_provider {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if existing.provider_knowledge_base_id.as_deref().is_some()
|
||||
|| next.provider_knowledge_base_id.as_deref().is_some()
|
||||
{
|
||||
return existing.provider.as_deref() == next_provider
|
||||
&& existing.provider_knowledge_base_id.as_deref()
|
||||
== next.provider_knowledge_base_id.as_deref();
|
||||
return existing.provider_knowledge_base_id.as_deref()
|
||||
== next.provider_knowledge_base_id.as_deref();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -3128,6 +3128,8 @@ pub fn resolve_local_markdown_page_aggregate(
|
||||
&parsed.body,
|
||||
&attachment_paths,
|
||||
);
|
||||
let content =
|
||||
infer_sibling_page_references_for_local_markdown(&canonical_root, &markdown_file.path, content);
|
||||
let attachment_refs = parse_markdown_attachment_refs(
|
||||
&parsed.body,
|
||||
&markdown_file.path.display().to_string(),
|
||||
@@ -3223,6 +3225,125 @@ pub fn resolve_local_markdown_page_aggregate(
|
||||
})
|
||||
}
|
||||
|
||||
fn infer_sibling_page_references_for_local_markdown(
|
||||
root: &Path,
|
||||
markdown_path: &Path,
|
||||
content: Value,
|
||||
) -> Value {
|
||||
let Some(blocks) = content.as_array() else {
|
||||
return content;
|
||||
};
|
||||
let sibling_pages = sibling_page_reference_targets(root, markdown_path);
|
||||
if sibling_pages.is_empty() {
|
||||
return content;
|
||||
}
|
||||
Value::Array(
|
||||
blocks
|
||||
.iter()
|
||||
.map(|block| {
|
||||
let Some(title) = plain_paragraph_page_title(block) else {
|
||||
return block.clone();
|
||||
};
|
||||
let Some(source_path) = sibling_pages.get(&title) else {
|
||||
return block.clone();
|
||||
};
|
||||
let block_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("local-page-ref")
|
||||
.to_string();
|
||||
json!({
|
||||
"id": block_id,
|
||||
"type": "page_reference",
|
||||
"props": {
|
||||
"title": title,
|
||||
"sourcePath": source_path,
|
||||
},
|
||||
"content": [{ "type": "text", "text": title, "styles": {} }],
|
||||
"children": [],
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn sibling_page_reference_targets(_root: &Path, markdown_path: &Path) -> BTreeMap<String, String> {
|
||||
let mut targets = BTreeMap::new();
|
||||
let Some(parent_dir) = markdown_path.parent() else {
|
||||
return targets;
|
||||
};
|
||||
let Ok(entries) = fs::read_dir(parent_dir) else {
|
||||
return targets;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path == markdown_path {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
let Some(dir_name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let candidate = path.join(format!("{dir_name}.md"));
|
||||
if candidate.is_file() {
|
||||
if let Some(relative_path) = relative_path_from_base(parent_dir, &candidate) {
|
||||
targets.insert(file_stem_title(dir_name), relative_path);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !is_markdown_file(file_name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(relative_path) = relative_path_from_base(parent_dir, &path) {
|
||||
targets.insert(file_stem_title(file_name), relative_path);
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
fn relative_path_from_base(base: &Path, path: &Path) -> Option<String> {
|
||||
let relative = path.strip_prefix(base).ok()?;
|
||||
Some(
|
||||
relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"),
|
||||
)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn plain_paragraph_page_title(block: &Value) -> Option<String> {
|
||||
if block.get("type").and_then(Value::as_str) != Some("paragraph") {
|
||||
return None;
|
||||
}
|
||||
let content = block.get("content").and_then(Value::as_array)?;
|
||||
if content.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let node = content.first()?;
|
||||
if node.get("type").and_then(Value::as_str) != Some("text") {
|
||||
return None;
|
||||
}
|
||||
if node
|
||||
.get("styles")
|
||||
.and_then(Value::as_object)
|
||||
.map(|styles| !styles.is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
node.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn save_local_markdown_page(
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
@@ -4094,7 +4215,8 @@ pub(crate) fn write_local_markdown_asset(
|
||||
));
|
||||
}
|
||||
|
||||
let asset_dir = markdown_dir.to_path_buf();
|
||||
let asset_type = local_upload_asset_type(kind, &file.content_type);
|
||||
let asset_dir = local_markdown_asset_dir(&canonical_root, &markdown_file.path, asset_type)?;
|
||||
if !asset_dir.starts_with(&canonical_root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_root_escape",
|
||||
@@ -4143,7 +4265,6 @@ pub(crate) fn write_local_markdown_asset(
|
||||
if let Some(ref mut value) = attachment_ref {
|
||||
value.authorized = Some(true);
|
||||
}
|
||||
let asset_type = local_upload_asset_type(kind, &file.content_type);
|
||||
let mut uploaded_assets = metadata.uploaded_assets;
|
||||
uploaded_assets.insert(
|
||||
root_relative_path.clone(),
|
||||
@@ -7583,6 +7704,7 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
|
||||
if matches!(
|
||||
lower_name.as_str(),
|
||||
".git"
|
||||
| ".assets"
|
||||
| ".mnote"
|
||||
| ".codegraph"
|
||||
| ".codex"
|
||||
@@ -7613,7 +7735,8 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
|
||||
{
|
||||
return true;
|
||||
}
|
||||
relative_path == ".mnote/trash"
|
||||
lower_name.ends_with(".assets")
|
||||
|| relative_path == ".mnote/trash"
|
||||
|| relative_path.starts_with(".mnote/trash/")
|
||||
|| is_local_index_artifact_entry(relative_path)
|
||||
|| is_local_ocr_intermediate_entry(relative_path, file_name)
|
||||
@@ -8817,10 +8940,62 @@ fn markdown_href_for_relative_path(relative_path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_markdown_asset_dir(
|
||||
root: &Path,
|
||||
markdown_path: &Path,
|
||||
asset_type: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
let markdown_dir = markdown_path.parent().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_asset_upload_bad_markdown_path",
|
||||
"本地 Markdown 文件路径无父目录",
|
||||
)
|
||||
})?;
|
||||
if !markdown_dir.starts_with(root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_root_escape",
|
||||
"本地资源目录不能越过 root",
|
||||
));
|
||||
}
|
||||
let stem = markdown_path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("Page")
|
||||
.trim();
|
||||
let parent_name = markdown_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let asset_root = if !stem.is_empty() && stem == parent_name {
|
||||
markdown_dir.join(".assets")
|
||||
} else {
|
||||
markdown_dir.join(format!(
|
||||
"{}.assets",
|
||||
if stem.is_empty() { "Page" } else { stem }
|
||||
))
|
||||
};
|
||||
Ok(asset_root.join(local_markdown_asset_bucket(asset_type)))
|
||||
}
|
||||
|
||||
fn local_markdown_asset_bucket(asset_type: &str) -> &'static str {
|
||||
match asset_type.trim().to_ascii_lowercase().as_str() {
|
||||
"image" => "image",
|
||||
"audio" => "audio",
|
||||
"video" => "video",
|
||||
_ => "file",
|
||||
}
|
||||
}
|
||||
|
||||
fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str {
|
||||
let normalized_kind = kind.trim().to_ascii_lowercase();
|
||||
if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") {
|
||||
let normalized_mime = mime_type.trim().to_ascii_lowercase();
|
||||
if normalized_kind == "image" || normalized_mime.starts_with("image/") {
|
||||
"image"
|
||||
} else if normalized_kind == "audio" || normalized_mime.starts_with("audio/") {
|
||||
"audio"
|
||||
} else if normalized_kind == "video" || normalized_mime.starts_with("video/") {
|
||||
"video"
|
||||
} else {
|
||||
"file"
|
||||
}
|
||||
@@ -10560,6 +10735,53 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_page_aggregate_infers_sibling_page_blocks_from_plain_lines() {
|
||||
let root = temp_root("mnote-local-sibling-page-lines");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("完结项目").join("爱斯特完结项目"))
|
||||
.expect("create child page dir");
|
||||
std::fs::write(
|
||||
root.join("完结项目").join("完结项目.md"),
|
||||
"# 完结项目\n\n爱斯特完结项目\n\n普通文本\n",
|
||||
)
|
||||
.expect("write parent md");
|
||||
std::fs::write(
|
||||
root.join("完结项目")
|
||||
.join("爱斯特完结项目")
|
||||
.join("爱斯特完结项目.md"),
|
||||
"# 爱斯特完结项目\n",
|
||||
)
|
||||
.expect("write child md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let document_id = format!(
|
||||
"local-md:{}",
|
||||
encode_local_id_segment("完结项目/完结项目.md")
|
||||
);
|
||||
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, &document_id)
|
||||
.expect("aggregate");
|
||||
let blocks = aggregate.body.content.as_array().expect("blocks");
|
||||
|
||||
assert_eq!(blocks[1]["type"], "page_reference");
|
||||
assert_eq!(blocks[1]["props"]["title"], "爱斯特完结项目");
|
||||
assert_eq!(
|
||||
blocks[1]["props"]["sourcePath"],
|
||||
"爱斯特完结项目/爱斯特完结项目.md"
|
||||
);
|
||||
assert_eq!(blocks[2]["type"], "paragraph");
|
||||
assert_eq!(
|
||||
aggregate.body.block_document["blocks"][1]["type"],
|
||||
"page_reference"
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate.body.block_document["blocks"][1]["attrs"]["sourcePath"],
|
||||
"爱斯特完结项目/爱斯特完结项目.md"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_does_not_migrate_runtime_open_url_inline_link() {
|
||||
let root = temp_root("mnote-local-uploaded-md-inline-link");
|
||||
@@ -13994,9 +14216,22 @@ fn main() {}
|
||||
"# Assets\n",
|
||||
)
|
||||
.expect("write md");
|
||||
std::fs::create_dir_all(root.join("docs").join("README")).expect("create page dir");
|
||||
std::fs::write(root.join("docs").join("README").join("photo.png"), b"old")
|
||||
.expect("write existing asset");
|
||||
std::fs::create_dir_all(
|
||||
root.join("docs")
|
||||
.join("README")
|
||||
.join(".assets")
|
||||
.join("image"),
|
||||
)
|
||||
.expect("create image asset dir");
|
||||
std::fs::write(
|
||||
root.join("docs")
|
||||
.join("README")
|
||||
.join(".assets")
|
||||
.join("image")
|
||||
.join("photo.png"),
|
||||
b"old",
|
||||
)
|
||||
.expect("write existing asset");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let asset = write_local_markdown_asset(
|
||||
@@ -14011,15 +14246,21 @@ fn main() {}
|
||||
)
|
||||
.expect("upload asset");
|
||||
|
||||
assert_eq!(asset["sourcePath"], "photo-1.png");
|
||||
assert_eq!(asset["file_url"], "photo-1.png");
|
||||
assert_eq!(asset["sourcePath"], ".assets/image/photo-1.png");
|
||||
assert_eq!(asset["file_url"], ".assets/image/photo-1.png");
|
||||
assert_eq!(asset["asset_type"], "image");
|
||||
assert_eq!(asset["sourceKind"], "local_folder");
|
||||
assert_eq!(asset["uploadIntent"], "editor.markdown.attach");
|
||||
assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png");
|
||||
assert_eq!(asset["markdownRelativePath"], "photo-1.png");
|
||||
assert_eq!(asset["markdownHref"], "./photo-1.png");
|
||||
assert_eq!(asset["attachmentRef"]["rawHref"], "./photo-1.png");
|
||||
assert_eq!(
|
||||
asset["rootRelativePath"],
|
||||
"docs/README/.assets/image/photo-1.png"
|
||||
);
|
||||
assert_eq!(asset["markdownRelativePath"], ".assets/image/photo-1.png");
|
||||
assert_eq!(asset["markdownHref"], "./.assets/image/photo-1.png");
|
||||
assert_eq!(
|
||||
asset["attachmentRef"]["rawHref"],
|
||||
"./.assets/image/photo-1.png"
|
||||
);
|
||||
assert_eq!(asset["attachmentRef"]["kind"], "pageLocal");
|
||||
assert_eq!(asset["attachmentRef"]["openKind"], "image");
|
||||
assert_eq!(asset["attachmentRef"]["authorized"], true);
|
||||
@@ -14028,8 +14269,14 @@ fn main() {}
|
||||
"local-md:docs~2FREADME~2FREADME.md"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("docs").join("README").join("photo-1.png"))
|
||||
.expect("read copied asset"),
|
||||
std::fs::read(
|
||||
root.join("docs")
|
||||
.join("README")
|
||||
.join(".assets")
|
||||
.join("image")
|
||||
.join("photo-1.png"),
|
||||
)
|
||||
.expect("read copied asset"),
|
||||
b"new-image"
|
||||
);
|
||||
let markdown_asset = write_local_markdown_asset(
|
||||
@@ -14043,11 +14290,17 @@ fn main() {}
|
||||
},
|
||||
)
|
||||
.expect("upload markdown asset");
|
||||
assert_eq!(markdown_asset["sourcePath"], "notes.md");
|
||||
assert_eq!(markdown_asset["sourcePath"], ".assets/file/notes.md");
|
||||
assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach");
|
||||
assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md");
|
||||
assert_eq!(markdown_asset["markdownRelativePath"], "notes.md");
|
||||
assert_eq!(markdown_asset["markdownHref"], "./notes.md");
|
||||
assert_eq!(
|
||||
markdown_asset["rootRelativePath"],
|
||||
"docs/README/.assets/file/notes.md"
|
||||
);
|
||||
assert_eq!(
|
||||
markdown_asset["markdownRelativePath"],
|
||||
".assets/file/notes.md"
|
||||
);
|
||||
assert_eq!(markdown_asset["markdownHref"], "./.assets/file/notes.md");
|
||||
assert_eq!(markdown_asset["attachmentRef"]["openKind"], "text");
|
||||
assert_eq!(
|
||||
markdown_asset["ownerDocumentId"],
|
||||
@@ -14071,16 +14324,16 @@ fn main() {}
|
||||
);
|
||||
assert_eq!(
|
||||
windows_path_asset["sourcePath"],
|
||||
"1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
".assets/file/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
);
|
||||
assert_eq!(
|
||||
windows_path_asset["rootRelativePath"],
|
||||
"docs/README/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
"docs/README/.assets/file/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
);
|
||||
let uploaded_asset_index =
|
||||
std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json"))
|
||||
.expect("uploaded asset index");
|
||||
assert!(uploaded_asset_index.contains("docs/README/notes.md"));
|
||||
assert!(uploaded_asset_index.contains("docs/README/.assets/file/notes.md"));
|
||||
|
||||
std::fs::write(root.join("docs").join("Loose.md"), "# Loose\n").expect("write loose md");
|
||||
let loose_asset = write_local_markdown_asset(
|
||||
@@ -14094,45 +14347,47 @@ fn main() {}
|
||||
},
|
||||
)
|
||||
.expect("upload loose markdown asset");
|
||||
assert_eq!(loose_asset["sourcePath"], "loose.pdf");
|
||||
assert_eq!(loose_asset["rootRelativePath"], "docs/loose.pdf");
|
||||
assert_eq!(loose_asset["markdownRelativePath"], "loose.pdf");
|
||||
assert_eq!(loose_asset["markdownHref"], "./loose.pdf");
|
||||
assert_eq!(loose_asset["sourcePath"], "Loose.assets/file/loose.pdf");
|
||||
assert_eq!(
|
||||
loose_asset["rootRelativePath"],
|
||||
"docs/Loose.assets/file/loose.pdf"
|
||||
);
|
||||
assert_eq!(
|
||||
loose_asset["markdownRelativePath"],
|
||||
"Loose.assets/file/loose.pdf"
|
||||
);
|
||||
assert_eq!(loose_asset["markdownHref"], "./Loose.assets/file/loose.pdf");
|
||||
assert!(
|
||||
root.join("docs").join("loose.pdf").is_file(),
|
||||
"非 bundle Markdown 上传应写入 md 同目录"
|
||||
root.join("docs")
|
||||
.join("Loose.assets")
|
||||
.join("file")
|
||||
.join("loose.pdf")
|
||||
.is_file(),
|
||||
"非 bundle Markdown 上传应写入页面专属 assets 目录"
|
||||
);
|
||||
assert!(
|
||||
!root.join("docs").join("Loose").join("loose.pdf").exists(),
|
||||
"非 bundle Markdown 上传不应写入同名子目录"
|
||||
!root.join("docs").join("loose.pdf").exists(),
|
||||
"非 bundle Markdown 上传不应写入 md 同目录"
|
||||
);
|
||||
|
||||
let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README")
|
||||
.expect("file tree");
|
||||
let items = snapshot.projection["items"].as_array().expect("items");
|
||||
let notes_row = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("notes.md"))
|
||||
.expect("uploaded markdown asset row");
|
||||
assert_eq!(notes_row["rowKind"].as_str(), Some("asset"));
|
||||
assert_eq!(notes_row["iconHint"].as_str(), Some("markdown"));
|
||||
assert_eq!(
|
||||
notes_row["resourceMeta"]["assetId"].as_str(),
|
||||
Some("local-file:docs/README/notes.md")
|
||||
assert!(
|
||||
!items
|
||||
.iter()
|
||||
.any(|item| item["title"].as_str() == Some(".assets")),
|
||||
"页面隐藏资源目录不应出现在 FileTree"
|
||||
);
|
||||
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
|
||||
let page_items = page_tree.projection["items"]
|
||||
.as_array()
|
||||
.expect("page items");
|
||||
let page_notes_row = page_items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("notes.md"))
|
||||
.expect("uploaded markdown asset page row");
|
||||
assert_eq!(page_notes_row["rowKind"].as_str(), Some("asset"));
|
||||
assert_eq!(page_notes_row["iconHint"].as_str(), Some("markdown"));
|
||||
assert_eq!(
|
||||
page_notes_row["resourceMeta"]["assetId"].as_str(),
|
||||
Some("local-file:docs/README/notes.md")
|
||||
assert!(
|
||||
!page_items
|
||||
.iter()
|
||||
.any(|item| item["title"].as_str() == Some("notes.md")),
|
||||
"页面隐藏资源目录内的 Markdown 附件不应进入 PageTree"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
@@ -52,6 +52,10 @@ enum MarkdownBlock {
|
||||
name: String,
|
||||
source_path: String,
|
||||
},
|
||||
PageReference {
|
||||
title: String,
|
||||
source_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -103,6 +107,7 @@ pub struct AttachmentRef {
|
||||
pub relative_path: Option<String>,
|
||||
pub ext: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub file_size: Option<u64>,
|
||||
pub exists: Option<bool>,
|
||||
pub authorized: Option<bool>,
|
||||
pub open_kind: String,
|
||||
@@ -346,6 +351,10 @@ fn build_attachment_ref(
|
||||
.as_deref()
|
||||
.and_then(attachment_content_type)
|
||||
.map(str::to_string);
|
||||
let file_size = resolved_absolute_path
|
||||
.as_ref()
|
||||
.and_then(|path| std::fs::metadata(path).ok())
|
||||
.map(|metadata| metadata.len());
|
||||
let exists = resolved_absolute_path
|
||||
.as_ref()
|
||||
.map(|path| Path::new(path).exists());
|
||||
@@ -378,6 +387,7 @@ fn build_attachment_ref(
|
||||
relative_path,
|
||||
ext,
|
||||
content_type,
|
||||
file_size,
|
||||
exists,
|
||||
authorized: None,
|
||||
open_kind,
|
||||
@@ -672,6 +682,10 @@ fn append_ast_paragraph<'a>(
|
||||
blocks.push(MarkdownBlock::Media { name, source_path });
|
||||
return;
|
||||
}
|
||||
if let Some((title, source_path)) = paragraph_page_reference(node) {
|
||||
blocks.push(MarkdownBlock::PageReference { title, source_path });
|
||||
return;
|
||||
}
|
||||
if let Some((name, source_path, remaining)) =
|
||||
paragraph_leading_attachment_media(node, attachment_paths)
|
||||
{
|
||||
@@ -696,6 +710,19 @@ fn append_ast_list_item<'a>(
|
||||
NodeValue::Item(_) => (false, false),
|
||||
_ => return append_ast_block(node, blocks, attachment_paths),
|
||||
};
|
||||
if !is_task {
|
||||
let mut children = node.children();
|
||||
if let Some(paragraph) = children.next() {
|
||||
if children.next().is_none()
|
||||
&& matches!(paragraph.data.borrow().value, NodeValue::Paragraph)
|
||||
{
|
||||
if let Some((title, source_path)) = paragraph_page_reference(paragraph) {
|
||||
blocks.push(MarkdownBlock::PageReference { title, source_path });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut content = Vec::new();
|
||||
for child in node.children() {
|
||||
match child.data.borrow().value.clone() {
|
||||
@@ -849,6 +876,23 @@ fn paragraph_attachment_media<'a>(
|
||||
link_attachment_media(first, attachment_paths)
|
||||
}
|
||||
|
||||
fn paragraph_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
let mut children = node.children();
|
||||
let first = children.next()?;
|
||||
if children.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
link_page_reference(first).or_else(|| {
|
||||
let inline = collect_inline_children(node);
|
||||
if inline.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let item = inline.first()?;
|
||||
let href = item.styles.link.as_deref()?;
|
||||
page_reference_from_target(href, &item.text)
|
||||
})
|
||||
}
|
||||
|
||||
fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
let mut children = node.children();
|
||||
let first = children.next()?;
|
||||
@@ -929,6 +973,67 @@ fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
))
|
||||
}
|
||||
|
||||
fn link_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
let NodeValue::Link(link) = &node.data.borrow().value else {
|
||||
return None;
|
||||
};
|
||||
page_reference_from_target(link.url.trim(), &collect_plain_text(node))
|
||||
}
|
||||
|
||||
fn page_reference_from_target(target: &str, label: &str) -> Option<(String, String)> {
|
||||
let target = normalized_markdown_link_target(target)?;
|
||||
if target.is_empty()
|
||||
|| target.starts_with("http://")
|
||||
|| target.starts_with("https://")
|
||||
|| target.starts_with('#')
|
||||
|| target.starts_with("mailto:")
|
||||
|| target.starts_with("file:")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path_part = target
|
||||
.split_once('#')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(target)
|
||||
.split_once('?')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(target);
|
||||
let extension = std::path::Path::new(path_part)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())?;
|
||||
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
|
||||
return None;
|
||||
}
|
||||
let fallback_title = std::path::Path::new(path_part)
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("页面")
|
||||
.trim();
|
||||
let title = label.trim();
|
||||
Some((
|
||||
if title.is_empty() {
|
||||
fallback_title.to_string()
|
||||
} else {
|
||||
title.to_string()
|
||||
},
|
||||
path_part.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn normalized_markdown_link_target(target: &str) -> Option<&str> {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(value) = trimmed
|
||||
.strip_prefix('<')
|
||||
.and_then(|value| value.strip_suffix('>'))
|
||||
{
|
||||
return Some(value.trim());
|
||||
}
|
||||
Some(trimmed)
|
||||
}
|
||||
|
||||
fn is_local_mindmap_file_name(file_name: &str) -> bool {
|
||||
let trimmed = file_name.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
@@ -1035,6 +1140,16 @@ fn markdown_block_to_json(block: &MarkdownBlock, block_number: usize) -> Value {
|
||||
"content": [],
|
||||
"children": [],
|
||||
}),
|
||||
MarkdownBlock::PageReference { title, source_path } => json!({
|
||||
"id": format!("local-block-{block_number}"),
|
||||
"type": "page_reference",
|
||||
"props": {
|
||||
"title": title,
|
||||
"sourcePath": source_path,
|
||||
},
|
||||
"content": [{ "type": "text", "text": title, "styles": {} }],
|
||||
"children": [],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1267,6 +1382,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_local_page_link_parses_as_page_reference_block() {
|
||||
let blocks = markdown_to_blocks("[完结项目](<完结项目/完结项目.md> \"完结项目\")\n");
|
||||
let first = blocks
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("first block");
|
||||
|
||||
assert_eq!(first["type"].as_str(), Some("page_reference"));
|
||||
assert_eq!(first["props"]["title"].as_str(), Some("完结项目"));
|
||||
assert_eq!(
|
||||
first["props"]["sourcePath"].as_str(),
|
||||
Some("完结项目/完结项目.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_list_item_with_only_local_page_link_parses_as_page_reference_block() {
|
||||
let blocks = markdown_to_blocks("* [知识](<知识/知识.md>)\n");
|
||||
let first = blocks
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("first block");
|
||||
|
||||
assert_eq!(first["type"].as_str(), Some("page_reference"));
|
||||
assert_eq!(first["props"]["title"].as_str(), Some("知识"));
|
||||
assert_eq!(first["props"]["sourcePath"].as_str(), Some("知识/知识.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_attachment_refs_parse_standard_href_variants() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
@@ -1305,6 +1449,7 @@ mod tests {
|
||||
assert_eq!(refs[0].kind, "pageLocal");
|
||||
assert_eq!(refs[0].open_kind, "pdf");
|
||||
assert_eq!(refs[0].exists, Some(true));
|
||||
assert_eq!(refs[0].file_size, Some(3));
|
||||
assert_eq!(
|
||||
refs[0].relative_path,
|
||||
Some("docs/同目录 文件.pdf".to_string())
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/knowledge-rag/knowledge-bases",
|
||||
post(knowledge_rag::create_knowledge_base),
|
||||
post(knowledge_rag::create_knowledge_base).delete(knowledge_rag::delete_knowledge_base),
|
||||
)
|
||||
.route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
|
||||
.route("/api/knowledge-rag/search", post(knowledge_rag::search))
|
||||
|
||||
@@ -4524,6 +4524,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_hot_runtime_serves_page_block_pane_navigation_bridge() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let js = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(
|
||||
js.contains("inline0.js?devHot="),
|
||||
"dev:hot 下 island 必须加载带 cache buster 的页面块导航 bridge"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leptos_tiptap_lazy_mindmap_runtime_asset_is_served() {
|
||||
let response = app()
|
||||
@@ -5495,6 +5523,9 @@ mod tests {
|
||||
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime"));
|
||||
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession"));
|
||||
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession"));
|
||||
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("navigateLocalMarkdownDeletedFallback"));
|
||||
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("localFolderNavigationFallbackUrl"));
|
||||
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("window.location.assign"));
|
||||
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("mnote:local-folder:document-changed"));
|
||||
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
|
||||
Reference in New Issue
Block a user