2026-06-04 18:51:16 +08:00
|
|
|
use crate::app::AppState;
|
|
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::error::WebError;
|
2026-06-09 09:20:56 +08:00
|
|
|
use crate::routes::local_folder_source;
|
2026-06-04 18:51:16 +08:00
|
|
|
use axum::extract::{Extension, State};
|
|
|
|
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
|
|
|
|
use axum::Json;
|
|
|
|
|
use core_protocol::{
|
2026-06-09 09:20:56 +08:00
|
|
|
EvidenceLocator, EvidenceOpenRequest, EvidenceReadRequest, EvidenceSearchRequest,
|
2026-06-04 18:51:16 +08:00
|
|
|
};
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
|
|
|
|
|
pub async fn search(
|
2026-06-07 01:10:31 +08:00
|
|
|
State(_state): State<AppState>,
|
2026-06-04 18:51:16 +08:00
|
|
|
Extension(context): Extension<RequestContext>,
|
2026-06-07 01:10:31 +08:00
|
|
|
Json(_body): Json<EvidenceSearchRequest>,
|
2026-06-04 18:51:16 +08:00
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
headers.insert("content-type", HeaderValue::from_static("application/json"));
|
2026-06-07 01:10:31 +08:00
|
|
|
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,
|
|
|
|
|
})),
|
|
|
|
|
))
|
2026-06-04 18:51:16 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-07 01:10:31 +08:00
|
|
|
pub(crate) fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
|
2026-06-05 23:00:53 +08:00
|
|
|
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(" · ")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 01:10:31 +08:00
|
|
|
pub(crate) fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
|
2026-06-05 23:00:53 +08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-09 09:20:56 +08:00
|
|
|
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");
|
2026-06-08 20:35:49 +08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-05 23:00:53 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:20:56 +08:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 23:00:53 +08:00
|
|
|
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(']', "\\]")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:51:16 +08:00
|
|
|
pub async fn read(
|
2026-06-09 09:20:56 +08:00
|
|
|
State(_state): State<AppState>,
|
2026-06-04 18:51:16 +08:00
|
|
|
Extension(context): Extension<RequestContext>,
|
2026-06-09 09:20:56 +08:00
|
|
|
Json(_body): Json<EvidenceReadRequest>,
|
2026-06-04 18:51:16 +08:00
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
headers.insert("content-type", HeaderValue::from_static("application/json"));
|
2026-06-09 09:20:56 +08:00
|
|
|
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,
|
|
|
|
|
})),
|
|
|
|
|
))
|
2026-06-04 18:51:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn open(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(body): Json<EvidenceOpenRequest>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Value, WebError> {
|
2026-06-05 23:00:53 +08:00
|
|
|
let mut locator = body.locator;
|
|
|
|
|
let citation_url = citation_url_for_locator(&locator);
|
|
|
|
|
locator.open_action.url = citation_url.clone();
|
2026-06-04 18:51:16 +08:00
|
|
|
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,
|
2026-06-05 23:00:53 +08:00
|
|
|
"citationUrl": citation_url,
|
|
|
|
|
"citationLabel": citation_label_for_locator(&locator),
|
|
|
|
|
"citationMarkdown": citation_markdown_for_locator(&locator),
|
2026-06-04 18:51:16 +08:00
|
|
|
});
|
|
|
|
|
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};
|
2026-06-09 09:20:56 +08:00
|
|
|
use core_protocol::{EvidenceOpenAction, EvidenceResourceKind};
|
2026-06-04 18:51:16 +08:00
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-06-07 10:35:21 +08:00
|
|
|
async fn evidence_search_route_returns_retired_guard() {
|
2026-06-09 09:20:56 +08:00
|
|
|
let app = test_app();
|
2026-06-04 18:51:16 +08:00
|
|
|
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",
|
2026-06-09 09:20:56 +08:00
|
|
|
"rootUri": "file:///workspace",
|
2026-06-05 23:00:53 +08:00
|
|
|
"targetDocumentId": "local-md:missing.md",
|
2026-06-04 18:51:16 +08:00
|
|
|
"includeResources": true,
|
|
|
|
|
"includeOcr": true
|
|
|
|
|
},
|
|
|
|
|
"mode": "hybrid",
|
|
|
|
|
"topK": 5
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
2026-06-07 10:35:21 +08:00
|
|
|
assert_eq!(response.status(), StatusCode::GONE);
|
2026-06-04 18:51:16 +08:00
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
2026-06-07 10:35:21 +08:00
|
|
|
assert_eq!(payload["ok"], false);
|
2026-06-04 18:51:16 +08:00
|
|
|
assert_eq!(
|
2026-06-07 10:35:21 +08:00
|
|
|
payload["code"].as_str(),
|
|
|
|
|
Some("mnote_evidence_search_retired")
|
2026-06-04 18:51:16 +08:00
|
|
|
);
|
2026-06-09 09:20:56 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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")
|
|
|
|
|
);
|
2026-06-04 18:51:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-09 09:20:56 +08:00
|
|
|
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());
|
2026-06-04 18:51:16 +08:00
|
|
|
|
2026-06-09 09:20:56 +08:00
|
|
|
let url = citation_url_for_locator(&locator);
|
2026-06-04 18:51:16 +08:00
|
|
|
|
2026-06-09 09:20:56 +08:00
|
|
|
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"));
|
2026-06-04 18:51:16 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:20:56 +08:00
|
|
|
fn sample_locator(root_uri: &str) -> EvidenceLocator {
|
|
|
|
|
EvidenceLocator::new(
|
|
|
|
|
root_uri,
|
2026-06-04 18:51:16 +08:00
|
|
|
"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"}),
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-06-09 09:20:56 +08:00
|
|
|
}
|
2026-06-04 18:51:16 +08:00
|
|
|
|
2026-06-09 09:20:56 +08:00
|
|
|
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(),
|
|
|
|
|
}))
|
2026-06-04 18:51:16 +08:00
|
|
|
}
|
|
|
|
|
}
|