use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source; use axum::extract::{Extension, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::Json; use core_protocol::{ EvidenceLocator, EvidenceOpenRequest, EvidenceReadRequest, EvidenceSearchRequest, }; use serde_json::{json, Value}; pub async fn search( State(_state): State, Extension(context): Extension, Json(_body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let mut headers = HeaderMap::new(); headers.insert("content-type", HeaderValue::from_static("application/json")); Ok(( StatusCode::GONE, headers, Json(json!({ "ok": false, "code": "mnote_evidence_search_retired", "message": "旧 LiteParse/evidence 搜索已退役;PDF、Office、图片 OCR 与资料索引统一走 /api/knowledge-rag/query", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, })), )) } pub(crate) fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String { let label = citation_label_for_locator(locator); let url = citation_url_for_locator(locator); format!( "[{}]({})", markdown_link_label_escape(&label), url.replace(')', "%29") ) } fn citation_label_for_locator(locator: &EvidenceLocator) -> String { let source_path = locator .resource_path .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or(locator.owner_document_path.as_str()); let name = source_path .rsplit('/') .find(|part| !part.trim().is_empty()) .unwrap_or(source_path) .trim(); let mut parts = vec![if name.is_empty() { "证据".to_string() } else { name.to_string() }]; if let Some(page) = locator.page { parts.push(format!("p.{page}")); } if let Some(section) = locator .section_path .last() .filter(|value| !value.trim().is_empty()) { parts.push(section.to_string()); } parts.join(" · ") } pub(crate) fn citation_url_for_locator(locator: &EvidenceLocator) -> String { let owner_document_id = locator.owner_document_id.trim(); let mut url = if owner_document_id.is_empty() { let existing = locator.open_action.url.trim(); if existing.is_empty() { "/".to_string() } else { existing.to_string() } } else { format!("/documents/{owner_document_id}") }; append_query_param(&mut url, "sourceKind", "local_folder"); append_query_param(&mut url, "rootUri", locator.root_uri.trim()); if let Some(resource_path) = locator .resource_path .as_deref() .filter(|value| !value.trim().is_empty()) { append_query_param( &mut url, "resourceTab", &format!( "resource:file:{}:{}", locator.root_uri.trim(), resource_path ), ); append_query_param(&mut url, "resourcePath", resource_path); } if let Some(page) = locator.page { append_query_param(&mut url, "page", &page.to_string()); } if let Some(bbox) = &locator.bbox { append_query_param( &mut url, "bbox", &format!("{},{},{},{}", bbox.x0, bbox.y0, bbox.x1, bbox.y1), ); } if let Some(block_id) = locator.block_id.as_deref() { append_query_param(&mut url, "blockId", block_id); } if let Some(source_map_path) = locator.source_map_path.as_deref() { append_query_param(&mut url, "sourceMapPath", source_map_path); } append_open_action_param(&mut url, &locator.open_action.params, "paragraphOrdinal"); append_open_action_param(&mut url, &locator.open_action.params, "paraIdStart"); append_open_action_param(&mut url, &locator.open_action.params, "paraIdEnd"); append_open_action_param(&mut url, &locator.open_action.params, "textFingerprint"); if let Some(query) = locator .open_action .params .get("query") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { append_query_param(&mut url, "evidenceText", query); } if let Some(search_query) = locator .open_action .params .get("searchQuery") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { append_query_param(&mut url, "searchQuery", search_query); } if let Some(line_range) = &locator.line_range { append_query_param( &mut url, "lineRange", &format!("{}-{}", line_range.start, line_range.end), ); } if let Some(char_range) = &locator.char_range { append_query_param( &mut url, "charRange", &format!("{}-{}", char_range.start, char_range.end), ); } url } fn append_open_action_param(url: &mut String, params: &Value, key: &str) { let Some(value) = params.get(key) else { return; }; if let Some(text) = value.as_str() { append_query_param(url, key, text); } else if value.is_number() || value.is_boolean() { append_query_param(url, key, &value.to_string()); } } fn append_query_param(url: &mut String, key: &str, value: &str) { let value = value.trim(); if value.is_empty() { return; } let separator = if url.contains('?') { '&' } else { '?' }; url.push(separator); url.push_str(&encode_query_component(key)); url.push('='); url.push_str(&encode_query_component(value)); } fn encode_query_component(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.as_bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { encoded.push(*byte as char); } else { encoded.push_str(&format!("%{byte:02X}")); } } encoded } fn markdown_link_label_escape(value: &str) -> String { value .replace('\\', "\\\\") .replace('[', "\\[") .replace(']', "\\]") } pub async fn read( State(_state): State, Extension(context): Extension, Json(_body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let mut headers = HeaderMap::new(); headers.insert("content-type", HeaderValue::from_static("application/json")); Ok(( StatusCode::GONE, headers, Json(json!({ "ok": false, "code": "mnote_evidence_read_retired", "message": "旧 evidence 读回已退役;Agent 引文打开请使用 /api/knowledge-rag/open-reference 或 citationUrl", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, })), )) } pub async fn open( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let response = open_payload(&state, &context, body).await?; let mut headers = HeaderMap::new(); headers.insert("content-type", HeaderValue::from_static("application/json")); Ok((StatusCode::OK, headers, Json(response))) } pub(crate) async fn open_payload( state: &AppState, context: &RequestContext, body: EvidenceOpenRequest, ) -> Result { let mut locator = body.locator; let citation_url = citation_url_for_locator(&locator); locator.open_action.url = citation_url.clone(); let root_uri = locator.root_uri.trim().to_string(); if root_uri.is_empty() { return Err( WebError::bad_request_code("evidence_root_required", "证据打开缺少 rootUri") .with_context(context), ); } let _ = local_folder_source::ensure_local_workspace_read_access_with_state( state, context, &root_uri, ) .map_err(|error| error.with_context(context))?; let response = json!({ "ok": true, "locator": locator.clone(), "openAction": locator.open_action, "citationUrl": citation_url, "citationLabel": citation_label_for_locator(&locator), "citationMarkdown": citation_markdown_for_locator(&locator), }); Ok(response) } #[cfg(test)] mod tests { use super::*; use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use core_protocol::{EvidenceOpenAction, EvidenceResourceKind}; use tower::util::ServiceExt; #[tokio::test] async fn evidence_search_route_returns_retired_guard() { let app = test_app(); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/evidence/search") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "query": "SqliteRouteToken", "scope": { "workspaceId": "local-ws-evidence-route", "rootUri": "file:///workspace", "targetDocumentId": "local-md:missing.md", "includeResources": true, "includeOcr": true }, "mode": "hybrid", "topK": 5 }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::GONE); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], false); assert_eq!( payload["code"].as_str(), Some("mnote_evidence_search_retired") ); } #[tokio::test] async fn evidence_read_route_returns_retired_guard() { let app = test_app(); let locator = sample_locator("file:///workspace"); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/evidence/read") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "locator": locator }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::GONE); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], false); assert_eq!( payload["code"].as_str(), Some("mnote_evidence_read_retired") ); } #[test] fn evidence_citation_url_keeps_locator_bridge_params() { let mut locator = sample_locator("file:///workspace"); locator.page = Some(3); locator.block_id = Some("para-7".into()); locator.resource_path = Some("docs/Page.assets/spec.pdf".into()); locator.source_map_path = Some("docs/Page.assets/spec.source-map.json".into()); let url = citation_url_for_locator(&locator); assert!(url.starts_with("/documents/local-md:docs~2FPage.md?")); assert!(url.contains("sourceKind=local_folder")); assert!(url.contains("rootUri=file%3A%2F%2F%2Fworkspace")); assert!(url.contains("resourcePath=docs%2FPage.assets%2Fspec.pdf")); assert!(url.contains("page=3")); assert!(url.contains("blockId=para-7")); } fn sample_locator(root_uri: &str) -> EvidenceLocator { EvidenceLocator::new( root_uri, "local-md:docs~2FPage.md", "docs/Page.md", EvidenceResourceKind::Pdf, EvidenceOpenAction { action_type: "mnote.open_resource_locator".into(), url: "/documents/local-md:docs~2FPage.md".into(), params: json!({"resourcePath":"docs/Page.assets/spec.pdf"}), }, ) } fn test_app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } }