收口 MNote P0 P1 P2 审查尾项

- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
This commit is contained in:
lix-2026
2026-06-01 09:29:12 +08:00
parent 49a0545148
commit 1882db7681
143 changed files with 29810 additions and 3228 deletions
+14
View File
@@ -260,6 +260,20 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
event: "session.info.updated".into(),
data: json!({ "title": title }),
}),
AcpSessionEvent::ProviderConversationBound {
provider,
remote_conversation_id,
remote_url,
acp_session_id,
} => Some(SseEvent {
event: "provider.conversation.bound".into(),
data: json!({
"provider": provider,
"remoteConversationId": remote_conversation_id,
"remoteUrl": remote_url,
"acpSessionId": acp_session_id,
}),
}),
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
event: "plan.updated".into(),
data: json!({ "entries": entries }),
@@ -54,6 +54,13 @@ pub enum AcpSessionEvent {
},
/// 会话元数据更新,例如自动标题。
SessionInfoUpdate { title: String },
/// Provider 侧远端会话绑定,例如豆包 conversation_id。
ProviderConversationBound {
provider: String,
remote_conversation_id: String,
remote_url: Option<String>,
acp_session_id: Option<String>,
},
/// 计划条目更新。
PlanUpdate { entries: Vec<String> },
/// 连接关闭或异常。
@@ -765,6 +772,46 @@ impl AcpSessionManager {
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
}
SessionUpdate::Unknown {
session_update,
extra,
} if session_update == "provider.conversation.bound" => {
let provider = extra
.get("provider")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("doubao-web")
.to_string();
let remote_conversation_id = extra
.get("remoteConversationId")
.or_else(|| extra.get("remote_conversation_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let remote_url = extra
.get("remoteUrl")
.or_else(|| extra.get("remote_url"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let acp_session_id = extra
.get("acpSessionId")
.or_else(|| extra.get("acp_session_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
Some(AcpSessionEvent::ProviderConversationBound {
provider,
remote_conversation_id,
remote_url,
acp_session_id,
})
}
SessionUpdate::Unknown { .. } => {
warn!("ACP unknown session/update variant");
None
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ pub mod block;
pub mod context_tools;
pub mod doc;
pub mod manifest;
pub mod onlyoffice_live;
pub mod page;
pub mod resource;
pub mod skill;
File diff suppressed because it is too large Load Diff
@@ -23,11 +23,17 @@ pub async fn mindmap_fetch(
})?;
let data =
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| json!({ "raw": content }));
let nodes = collect_mindmap_nodes(&data);
let scope = input
.arg_string("scope")
.unwrap_or_else(|| "tree".into())
.to_ascii_lowercase();
let root = mindmap_root_value(&data).clone();
let nodes = collect_mindmap_nodes(&root);
let envelope = if scope == "full_envelope" {
data
} else {
Value::Null
};
Ok(json!({
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
@@ -35,6 +41,8 @@ pub async fn mindmap_fetch(
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"scope": scope,
"root": root,
"envelope": envelope,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
@@ -67,14 +75,150 @@ pub async fn mindmap_apply_ops(
"diff": [{"op": "mindmap.apply_ops", "ops": ops}]
}));
}
Err(WebError::bad_request_code(
"mnote_resource_native_patch_required",
format!(
"本地 mindmap resource 写入请使用 agent 原生 patch 编辑授权文件;已校验可写资源路径 {}",
path.display()
),
)
.with_context(context))
ensure_mindmap_revision_precondition(context, input, &path)?;
let content = fs::read_to_string(&path).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_read_failed",
format!("无法读取 mindmap resource: {error}"),
)
.with_context(context)
})?;
let mut envelope = serde_json::from_str::<Value>(&content).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_json_invalid",
format!("mindmap resource 不是有效 JSON: {error}"),
)
.with_context(context)
})?;
apply_mindmap_ops(context, &mut envelope, &ops)?;
let serialized = serde_json::to_string_pretty(&envelope).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_serialize_failed",
format!("无法序列化 mindmap JSON: {error}"),
)
.with_context(context)
})?;
fs::write(&path, serialized).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_write_failed",
format!("无法写入 mindmap resource: {error}"),
)
.with_context(context)
})?;
let root = mindmap_root_value(&envelope).clone();
let nodes = collect_mindmap_nodes(&root);
Ok(json!({
"dryRun": false,
"commandName": "mnote.mindmap.apply_ops",
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"root": root,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
"revision": file_revision(&path),
"changedFiles": [target.relative_path()],
"source": "local_folder"
}))
}
pub async fn mindmap_create_from_outline(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
ensure_resource_write_contract(context, input)?;
let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?;
ensure_resource_scope_allowed(context, input, &target)?;
let path = target.resolve_create_path(context, input)?;
let title = input
.arg_string("title")
.unwrap_or_else(|| "KMIND".to_string());
let outline = input.arg_value("outline").ok_or_else(|| {
WebError::bad_request_code("mnote_mindmap_outline_required", "创建思维导图缺少 outline")
.with_context(context)
})?;
let source_refs = input.arg_value("sourceRefs").unwrap_or_else(|| json!([]));
let envelope = mindmap_envelope_from_outline(&title, &outline, source_refs, context)?;
let root = mindmap_root_value(&envelope).clone();
let nodes = collect_mindmap_nodes(&root);
let resource_relative_path = target.relative_path();
let mut changed_files = if input.dry_run.unwrap_or(false) {
Vec::new()
} else {
vec![resource_relative_path.clone()]
};
let mut embed_result = Value::Null;
if !input.dry_run.unwrap_or(false) {
let parent = path.parent().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_bad_path", "无法解析 mindmap 父目录")
.with_context(context)
})?;
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_create_dir_failed",
format!("无法创建 mindmap 目录: {error}"),
)
.with_context(context)
})?;
let content = serde_json::to_string_pretty(&envelope).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_serialize_failed",
format!("无法序列化 mindmap JSON: {error}"),
)
.with_context(context)
})?;
fs::write(&path, content).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_write_failed",
format!("无法写入 mindmap resource: {error}"),
)
.with_context(context)
})?;
if input
.arg_value("embedIntoPage")
.and_then(|value| value.as_bool())
.unwrap_or(true)
{
embed_result = embed_mindmap_into_local_markdown_page(
context,
input,
&target.document_id,
&resource_relative_path,
&title,
)?;
if let Some(changed_file) = embed_result
.get("changedFile")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
changed_files.push(changed_file.to_string());
}
}
}
Ok(json!({
"dryRun": input.dry_run.unwrap_or(false),
"commandName": "mnote.mindmap.create_from_outline",
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"envelope": envelope,
"root": root,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
"revision": if input.dry_run.unwrap_or(false) { Value::Null } else { file_revision(&path) },
"changedFiles": changed_files,
"embedResult": embed_result,
"source": "local_folder"
}))
}
pub async fn office_fetch_summary(
@@ -235,6 +379,45 @@ impl ResourceToolTarget {
Ok(canonical)
}
fn resolve_create_path(
&self,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<PathBuf, WebError> {
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_root_uri_required",
"资源工具需要授权 rootUri",
)
.with_context(context)
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
let relative = self.relative_path();
let relative_path = Path::new(&relative);
if relative_path.is_absolute()
|| relative_path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
let path = root.join(relative_path);
if !path.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(path)
}
fn relative_path(&self) -> String {
self.resource_path
.as_deref()
@@ -315,9 +498,508 @@ fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
})
}
fn embed_mindmap_into_local_markdown_page(
context: &RequestContext,
input: &ToolCallInput,
document_id: &str,
resource_relative_path: &str,
title: &str,
) -> Result<Value, WebError> {
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_root_uri_required",
"资源工具需要授权 rootUri",
)
.with_context(context)
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
let page_relative_path = local_markdown_relative_path_from_document_id(context, document_id)?;
let page_path = root.join(&page_relative_path);
if !page_path.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
if !page_path.is_file() {
return Err(WebError::bad_request_code(
"mnote_resource_page_not_found",
"找不到要绑定 mindmap 的本地 Markdown 页面",
)
.with_context(context));
}
let href = markdown_href_from_page(&root, &page_path, resource_relative_path);
let mut markdown = fs::read_to_string(&page_path).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_page_read_failed",
format!("无法读取本地 Markdown 页面: {error}"),
)
.with_context(context)
})?;
let link = format!("[{}]({href})", markdown_link_label(title));
if markdown.contains(&link) || markdown.contains(&format!("]({href})")) {
return Ok(json!({
"status": "already_present",
"documentId": document_id,
"changedFile": page_relative_path,
"href": href
}));
}
if !markdown.ends_with('\n') {
markdown.push('\n');
}
if !markdown.ends_with("\n\n") {
markdown.push('\n');
}
markdown.push_str(&link);
markdown.push('\n');
fs::write(&page_path, markdown).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_page_write_failed",
format!("无法写入本地 Markdown 页面: {error}"),
)
.with_context(context)
})?;
Ok(json!({
"status": "embedded",
"documentId": document_id,
"changedFile": page_relative_path,
"href": href
}))
}
fn local_markdown_relative_path_from_document_id(
context: &RequestContext,
document_id: &str,
) -> Result<String, WebError> {
let encoded = document_id
.trim()
.strip_prefix("local-md:")
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_local_markdown_required",
"绑定 mindmap 需要 local-md 页面",
)
.with_context(context)
})?;
let relative = crate::routes::decode_local_id_segment(encoded)
.map_err(|error| error.with_context(context))?;
let path = Path::new(&relative);
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(relative)
}
fn markdown_href_from_page(root: &Path, page_path: &Path, resource_relative_path: &str) -> String {
let page_dir = page_path.parent().unwrap_or(root);
let target = root.join(resource_relative_path);
if let Ok(relative) = target.strip_prefix(page_dir) {
return path_to_markdown_href(relative);
}
let page_dir_relative = page_dir.strip_prefix(root).unwrap_or(Path::new(""));
let depth = page_dir_relative
.components()
.filter(|component| matches!(component, std::path::Component::Normal(_)))
.count();
let mut href = String::new();
for _ in 0..depth {
href.push_str("../");
}
href.push_str(&resource_relative_path.replace('\\', "/"));
href
}
fn path_to_markdown_href(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn markdown_link_label(value: &str) -> String {
value
.trim()
.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('\n', " ")
}
fn default_mindmap_view() -> Value {
json!({
"state": {
"scale": 1,
"sx": 0,
"sy": 0,
"x": -44.99991989135742_f64,
"y": -15.500006675720217_f64
},
"transform": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": -44.99991989135742_f64,
"f": -15.500006675720217_f64,
"originX": 0,
"originY": 0,
"rotate": 0,
"scaleX": 1,
"scaleY": 1,
"shear": 0,
"translateX": -44.99991989135742_f64,
"translateY": -15.500006675720217_f64
}
})
}
fn mindmap_envelope_from_outline(
title: &str,
outline: &Value,
source_refs: Value,
context: &RequestContext,
) -> Result<Value, WebError> {
let outline_items = outline.as_array().ok_or_else(|| {
WebError::bad_request_code(
"mnote_mindmap_outline_invalid",
"mindmap outline 必须是数组",
)
.with_context(context)
})?;
let title = title.trim();
let root_title = if title.is_empty() { "KMIND" } else { title };
Ok(json!({
"data": {
"children": mindmap_outline_items_to_children(outline_items, "node"),
"data": {
"expand": true,
"isActive": false,
"text": root_title,
"uid": "root"
}
},
"view": default_mindmap_view(),
"metadata": {
"sourceRefs": source_refs
}
}))
}
fn mindmap_outline_items_to_children(items: &[Value], prefix: &str) -> Vec<Value> {
items
.iter()
.enumerate()
.map(|(index, item)| {
let ordinal = index + 1;
let uid = format!("{prefix}_{ordinal}");
let text = item
.get("text")
.or_else(|| item.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未命名节点");
let children = item
.get("children")
.and_then(Value::as_array)
.map(|children| mindmap_outline_items_to_children(children, &uid))
.unwrap_or_default();
json!({
"data": {
"expand": true,
"isActive": false,
"text": text,
"uid": uid
},
"children": children
})
})
.collect()
}
fn mindmap_root_value(value: &Value) -> &Value {
value
.get("data")
.filter(|data| data.get("data").is_some() || data.get("children").is_some())
.unwrap_or(value)
}
fn mindmap_root_value_mut(value: &mut Value) -> Option<&mut Value> {
let has_envelope_root = value
.get("data")
.map(|data| data.get("data").is_some() || data.get("children").is_some())
.unwrap_or(false);
if has_envelope_root {
return value.get_mut("data");
}
Some(value)
}
fn apply_mindmap_ops(
context: &RequestContext,
envelope: &mut Value,
ops: &Value,
) -> Result<(), WebError> {
let ops = ops.as_array().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_ops_invalid", "mindmap ops 必须是数组")
.with_context(context)
})?;
let root = mindmap_root_value_mut(envelope).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_json_invalid", "mindmap 缺少 root")
.with_context(context)
})?;
for op in ops {
apply_mindmap_op(context, root, op)?;
}
Ok(())
}
fn apply_mindmap_op(
context: &RequestContext,
root: &mut Value,
op: &Value,
) -> Result<(), WebError> {
let op_name = op
.get("op")
.or_else(|| op.get("type"))
.or_else(|| op.get("action"))
.and_then(Value::as_str)
.map(normalize_mindmap_op_name)
.ok_or_else(|| {
WebError::bad_request_code("mnote_resource_op_required", "mindmap op 缺少 op")
.with_context(context)
})?;
match op_name.as_str() {
"updatetext" | "updatenode" => {
let node_id = mindmap_op_string(op, &["nodeId", "node_id", "id"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_required", "更新节点缺少 nodeId")
.with_context(context)
})?;
let text = mindmap_op_string(op, &["text", "title"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_text_required", "更新节点缺少 text")
.with_context(context)
})?;
let node = find_mindmap_node_mut(root, &node_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到要更新的节点")
.with_context(context)
})?;
set_mindmap_node_text(node, &text);
}
"insertchild" | "addchild" => {
let parent_id = mindmap_op_string(op, &["parentId", "parent_id", "nodeId"])
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_parent_required",
"插入子节点缺少 parentId",
)
.with_context(context)
})?;
let parent = find_mindmap_node_mut(root, &parent_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到父节点")
.with_context(context)
})?;
let child_input = op.get("node").unwrap_or(op);
let child = mindmap_node_from_input(context, child_input)?;
ensure_mindmap_children_array(context, parent)?.push(child);
}
"deletenode" => {
let node_id = mindmap_op_string(op, &["nodeId", "node_id", "id"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_required", "删除节点缺少 nodeId")
.with_context(context)
})?;
if mindmap_node_matches(root, &node_id) {
return Err(WebError::bad_request_code(
"mnote_resource_root_delete_forbidden",
"不能删除 mindmap 根节点",
)
.with_context(context));
}
remove_mindmap_node(root, &node_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到要删除的节点")
.with_context(context)
})?;
}
_ => {
return Err(WebError::bad_request_code(
"mnote_resource_op_unsupported",
format!("暂不支持 mindmap op: {op_name}"),
)
.with_context(context));
}
}
Ok(())
}
fn normalize_mindmap_op_name(value: &str) -> String {
value
.chars()
.filter(|ch| *ch != '_' && *ch != '-' && !ch.is_whitespace())
.flat_map(char::to_lowercase)
.collect()
}
fn mindmap_op_string(op: &Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
op.get(*key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn find_mindmap_node_mut<'a>(node: &'a mut Value, node_id: &str) -> Option<&'a mut Value> {
if mindmap_node_matches(node, node_id) {
return Some(node);
}
let children = node.get_mut("children").and_then(Value::as_array_mut)?;
for child in children {
if let Some(found) = find_mindmap_node_mut(child, node_id) {
return Some(found);
}
}
None
}
fn mindmap_node_matches(node: &Value, node_id: &str) -> bool {
mindmap_node_id(node)
.as_deref()
.map(|value| value == node_id)
.unwrap_or(false)
}
fn mindmap_node_id(node: &Value) -> Option<String> {
node.pointer("/data/uid")
.or_else(|| node.pointer("/data/id"))
.or_else(|| node.get("uid"))
.or_else(|| node.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn set_mindmap_node_text(node: &mut Value, text: &str) {
if let Some(data) = node.get_mut("data").and_then(Value::as_object_mut) {
data.insert("text".into(), json!(text));
return;
}
if let Some(object) = node.as_object_mut() {
object.insert("text".into(), json!(text));
}
}
fn ensure_mindmap_children_array<'a>(
context: &RequestContext,
node: &'a mut Value,
) -> Result<&'a mut Vec<Value>, WebError> {
let object = node.as_object_mut().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_invalid", "mindmap 节点必须是对象")
.with_context(context)
})?;
let children = object
.entry("children")
.or_insert_with(|| Value::Array(Vec::new()));
if !children.is_array() {
*children = Value::Array(Vec::new());
}
Ok(children.as_array_mut().expect("children 已归一为数组"))
}
fn mindmap_node_from_input(context: &RequestContext, input: &Value) -> Result<Value, WebError> {
if input.get("data").is_some() || input.get("children").is_some() {
let mut node = input.clone();
ensure_mindmap_children_array(context, &mut node)?;
return Ok(node);
}
let text = input
.get("text")
.or_else(|| input.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未命名节点");
let uid = input
.get("uid")
.or_else(|| input.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(text);
let mut node = json!({
"data": {
"expand": true,
"isActive": false,
"text": text,
"uid": uid
},
"children": []
});
if let Some(object) = node.as_object_mut() {
for key in ["metadata", "sourceRefs", "refs", "note", "hyperlink"] {
if let Some(value) = input.get(key) {
object.insert(key.into(), value.clone());
}
}
}
Ok(node)
}
fn remove_mindmap_node(parent: &mut Value, node_id: &str) -> Option<Value> {
let children = parent.get_mut("children").and_then(Value::as_array_mut)?;
if let Some(index) = children
.iter()
.position(|child| mindmap_node_matches(child, node_id))
{
return Some(children.remove(index));
}
for child in children {
if let Some(removed) = remove_mindmap_node(child, node_id) {
return Some(removed);
}
}
None
}
fn ensure_mindmap_revision_precondition(
context: &RequestContext,
input: &ToolCallInput,
path: &Path,
) -> Result<(), WebError> {
let Some(expected) = input.arg_value("expectedRevision") else {
return Ok(());
};
let current = file_revision(path);
if revision_label(&expected).as_deref() != revision_label(&current).as_deref() {
return Err(WebError::bad_request_code(
"mnote_resource_revision_conflict",
"mindmap resource revision 已变化,请重新读取后再写入",
)
.with_context(context));
}
Ok(())
}
fn revision_label(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.trim().to_string()).filter(|value| !value.is_empty()),
Value::Number(value) => Some(value.to_string()),
_ => None,
}
}
fn collect_mindmap_nodes(value: &Value) -> Vec<Value> {
let mut nodes = Vec::new();
collect_mindmap_nodes_inner(value, &mut nodes);
collect_mindmap_nodes_inner(mindmap_root_value(value), &mut nodes);
nodes
}
@@ -41,6 +41,68 @@ const SKILLS: &[MnoteSkill] = &[
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
},
MnoteSkill {
id: "mnote-onlyoffice-live",
title: "MNote ONLYOFFICE live bridge",
description: "Operate the currently open ONLYOFFICE editor session for Word, Excel, and PPT.",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["onlyoffice"],
tool_names: &[
"mnote.onlyoffice.session.current",
"mnote.onlyoffice.capabilities",
"mnote.onlyoffice.selection.get",
"mnote.onlyoffice.document.insert_text",
"mnote.onlyoffice.document.replace_selection",
"mnote.onlyoffice.document.insert_html",
"mnote.onlyoffice.document.export",
"mnote.onlyoffice.document.search_replace",
"mnote.onlyoffice.document.insert_table",
"mnote.onlyoffice.document.get_comments",
"mnote.onlyoffice.document.add_comment",
"mnote.onlyoffice.sheet.get_sheets",
"mnote.onlyoffice.sheet.add_sheet",
"mnote.onlyoffice.sheet.rename_sheet",
"mnote.onlyoffice.sheet.get_range",
"mnote.onlyoffice.sheet.get_range_values",
"mnote.onlyoffice.sheet.get_values",
"mnote.onlyoffice.sheet.set_value",
"mnote.onlyoffice.sheet.set_formula",
"mnote.onlyoffice.sheet.batch_set_values",
"mnote.onlyoffice.sheet.set_range_values",
"mnote.onlyoffice.sheet.format_range",
"mnote.onlyoffice.sheet.set_dimensions",
"mnote.onlyoffice.sheet.sort_range",
"mnote.onlyoffice.sheet.add_chart",
"mnote.onlyoffice.presentation.get_slides",
"mnote.onlyoffice.presentation.get_slide_texts",
"mnote.onlyoffice.presentation.get_shapes",
"mnote.onlyoffice.presentation.add_text_slide",
"mnote.onlyoffice.presentation.replace_text",
"mnote.onlyoffice.presentation.set_shape_text",
"mnote.onlyoffice.presentation.delete_slide",
"mnote.onlyoffice.presentation.add_table",
"mnote.onlyoffice.presentation.clear_slide",
"mnote.onlyoffice.presentation.add_shape",
],
content: include_str!("../../../../../skills/mnote-onlyoffice-live/SKILL.md"),
},
MnoteSkill {
id: "mnote-mindmap",
title: "MNote mindmap editing",
description: "Read, update, summarize, or create MNote mindmap resources, including generating a new mindmap from PDF or document outlines.",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder", "resource"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.mindmap.fetch",
"mnote.mindmap.apply_ops",
"mnote.mindmap.create_from_outline",
],
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
},
MnoteSkill {
id: "mnote-chat-only",
title: "MNote chat only",
@@ -146,4 +208,108 @@ mod tests {
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
assert!(find_skill("missing", Some("reasonix")).is_none());
}
#[test]
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
.expect("reasonix should see live ONLYOFFICE skill");
assert_eq!(skill["readOnly"], false);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.session.current"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.sheet.batch_set_values"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.sheet.add_chart"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.presentation.add_shape"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.presentation.replace_text"),
true
);
}
#[test]
fn skill_registry_exposes_mindmap_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-mindmap")
.expect("reasonix should see mindmap skill");
assert_eq!(skill["readOnly"], false);
assert!(skill["requiresContextRefs"]
.as_array()
.expect("context refs")
.iter()
.any(|value| value == "resource"));
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.mindmap.create_from_outline"));
}
#[tokio::test]
async fn skill_read_returns_mindmap_skill_content() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools/execute".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input: ToolCallInput = serde_json::from_value(json!({
"toolName": "mnote.skill.read",
"args": {
"skillId": "mnote-mindmap",
"agentId": "reasonix"
}
}))
.expect("input");
let payload = skill_read(&context, &input).await.expect("skill read");
assert_eq!(payload["skill"]["id"], "mnote-mindmap");
assert!(payload["content"]
.as_str()
.unwrap_or_default()
.contains("mnote.mindmap.create_from_outline"));
let content = payload["content"].as_str().unwrap_or_default();
assert!(content.contains("Mindmap outline format"));
assert!(content.contains("Use concise keywords or short phrases"));
assert!(content.contains("Avoid long paragraphs"));
assert!(payload["tools"]
.as_array()
.expect("tools")
.iter()
.any(|tool| tool == "mnote.mindmap.create_from_outline"));
}
}
@@ -65,7 +65,7 @@ impl PageAggregateBuilder {
Self {
document_id: String::new(),
workspace_id: "default".to_string(),
source: PageAggregateSource::CompatMetaContentJoin,
source: PageAggregateSource::KernelProjection,
parent_id: None,
path: Vec::new(),
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
@@ -372,3 +372,29 @@ impl Default for PageAggregateBuilder {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_defaults_to_kernel_projection() {
let aggregate = PageAggregateBuilder::new()
.document_id("doc_1")
.workspace_id("ws_demo")
.build();
assert_eq!(aggregate.source, PageAggregateSource::KernelProjection);
}
#[test]
fn explicit_compat_source_is_still_supported() {
let aggregate = PageAggregateBuilder::new()
.document_id("doc_1")
.workspace_id("ws_demo")
.source(PageAggregateSource::CompatMetaContentJoin)
.build();
assert_eq!(aggregate.source, PageAggregateSource::CompatMetaContentJoin);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,8 +8,8 @@ use crate::page_aggregate::{
use crate::routes::local_markdown_parser::{
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
};
use crate::routes::local_search_index;
use crate::routes::snapshot_support::ProjectionSnapshot;
use crate::routes::{local_ocr, local_search_index};
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json;
@@ -4076,8 +4076,13 @@ fn local_mindmap_file_name(mindmap_id: &str) -> String {
fn is_local_mindmap_file_name(file_name: &str) -> bool {
let trimmed = file_name.trim();
let lower = trimmed.to_ascii_lowercase();
if !lower.ends_with(".json") {
return false;
}
lower.ends_with(".mindmap.json")
|| (trimmed.starts_with("思维导图") && lower.ends_with(".json"))
|| lower.starts_with("mindmap-")
|| lower.starts_with("mindmap_")
|| trimmed.starts_with("思维导图")
}
fn resolve_local_mindmap_path(
@@ -7395,6 +7400,9 @@ fn scan_markdown_page_tree(
continue;
}
if is_markdown_file(&entry.file_name) {
if local_ocr::is_local_ocr_sidecar_relative_path(&entry.relative_path) {
continue;
}
let markdown = fs::read_to_string(&entry.path).unwrap_or_default();
let parsed = parse_markdown_page(&markdown, &entry.file_name);
let page_id = local_markdown_path_page_id(&entry.relative_path);
@@ -8426,7 +8434,6 @@ fn editor_blocks_to_markdown_with_rewrite(
lines.push(text);
} else {
let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context)
.map(|value| markdown_href_for_relative_path(&value))
.unwrap_or_else(|| src.to_string());
lines.push(format!(
"![{}]({})",
@@ -10424,6 +10431,61 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_generated_mindmap_id_roundtrips_as_mindmap_block() {
let root = temp_root("mnote-local-markdown-generated-mindmap-roundtrip");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
let root_uri = format!("file://{}", root.display());
write_local_mindmap_data(
&root_uri,
"local-md:Page~2FPage.md",
"mindmap-123456",
serde_json::json!({"data":{"text":"中心主题"},"children":[]}),
false,
)
.expect("write generated mindmap");
save_local_markdown_page(
&root_uri,
"local-md:Page~2FPage.md",
None,
&serde_json::json!([
{
"type": "mindmap",
"props": {
"name": "思维导图",
"mindmapId": "mindmap-123456",
"rootNodeId": "root"
}
}
]),
)
.expect("save");
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
assert!(saved.contains("[思维导图](mindmap-123456.json)"));
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page~2FPage.md")
.expect("aggregate");
let mindmap = aggregate.body.content.as_array().expect("blocks")[0].clone();
assert_eq!(mindmap["type"], "mindmap");
assert_eq!(mindmap["props"]["sourcePath"], "mindmap-123456.json");
assert_eq!(mindmap["props"]["mindmapId"], "mindmap-123456.json");
assert_eq!(
aggregate.body.block_document["blocks"][0]["attrs"]["mindmapId"],
serde_json::json!("mindmap-123456.json")
);
assert_eq!(
aggregate.body.block_document["blocks"][0]["attrs"]["sourcePath"],
serde_json::json!("mindmap-123456.json")
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_workspace_access_rejects_owner_mismatch() {
let root = temp_root("mnote-local-workspace-owner-mismatch");
@@ -12656,6 +12718,8 @@ fn main() {}
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
std::fs::write(root.join("Page").join("思维导图123456.json"), "{}").expect("write mindmap");
std::fs::write(root.join("Page").join("mindmap-123456.json"), "{}")
.expect("write generated mindmap");
std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
std::fs::write(root.join("Page").join("sheet.xlsx"), b"xlsx").expect("write xlsx");
std::fs::write(root.join("Page").join("slides.pptx"), b"pptx").expect("write pptx");
@@ -12687,6 +12751,17 @@ fn main() {}
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
Some("local-file:Page/思维导图123456.json")
);
let generated_mindmap = items
.iter()
.find(|item| item["title"].as_str() == Some("mindmap-123456.json"))
.expect("generated mindmap row");
assert_eq!(generated_mindmap["rowKind"].as_str(), Some("asset"));
assert_eq!(generated_mindmap["iconHint"].as_str(), Some("mindmap"));
assert_eq!(
generated_mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"]
.as_str(),
Some("mindmap")
);
let office = items
.iter()
@@ -13239,6 +13314,41 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_ocr_sidecar_is_filetree_resource_not_page_tree_document() {
let root = temp_root("mnote-local-ocr-sidecar-page-tree");
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::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");
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
.expect("file tree");
let file_items = file_tree.projection["items"]
.as_array()
.expect("file items");
assert!(file_items
.iter()
.any(|item| item["title"].as_str() == Some("photo.png.ocr.md")));
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");
assert!(page_items
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")));
assert!(!page_items.iter().any(|item| item["documentId"].as_str()
== Some("local-md:docs~2FPage.ocr~2Fphoto.png.ocr.md")));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_rename_markdown_page_renames_nested_bundle() {
let root = temp_root("mnote-local-rename-nested-bundle");
@@ -915,10 +915,7 @@ fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
.and_then(|value| value.to_str())
.unwrap_or(target)
.trim();
let lower = file_name.to_ascii_lowercase();
let is_mindmap = lower.ends_with(".mindmap.json")
|| (file_name.starts_with("思维导图") && lower.ends_with(".json"));
if !is_mindmap {
if !is_local_mindmap_file_name(file_name) {
return None;
}
let name = collect_plain_text(node).trim().to_string();
@@ -932,6 +929,18 @@ fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
))
}
fn is_local_mindmap_file_name(file_name: &str) -> bool {
let trimmed = file_name.trim();
let lower = trimmed.to_ascii_lowercase();
if !lower.ends_with(".json") {
return false;
}
lower.ends_with(".mindmap.json")
|| lower.starts_with("mindmap-")
|| lower.starts_with("mindmap_")
|| trimmed.starts_with("思维导图")
}
fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value {
Value::Array(
document
@@ -1239,6 +1248,25 @@ mod tests {
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
#[test]
fn markdown_mindmap_link_parses_generated_mindmap_json_as_mindmap_block() {
let blocks = markdown_to_blocks("[思维导图](mindmap-123456.json)\n");
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("mindmap"));
assert_eq!(
first["props"]["sourcePath"].as_str(),
Some("mindmap-123456.json")
);
assert_eq!(
first["props"]["mindmapId"].as_str(),
Some("mindmap-123456.json")
);
}
#[test]
fn markdown_attachment_refs_parse_standard_href_variants() {
let root = std::env::temp_dir().join(format!(
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ use crate::routes::local_folder_source::encode_local_id_segment;
use crate::routes::local_markdown_parser::{
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
};
use crate::routes::local_ocr;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
@@ -55,6 +56,7 @@ pub(crate) fn query_local_search_index(
limit: u32,
title_only: bool,
exact: bool,
include_ocr: bool,
) -> Result<Value, WebError> {
let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?;
let normalized_query = normalize_search_text(query);
@@ -90,6 +92,39 @@ pub(crate) fn query_local_search_index(
}
}
}
if include_ocr && results.len() < limit.max(1) as usize {
for entry in local_ocr::ocr_index_entries(root_path)? {
if let Some(page_id) = page_id {
if entry.owner_document_id != page_id {
continue;
}
}
if entry.status != "done" {
continue;
}
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
let markdown = match fs::read_to_string(&ocr_path) {
Ok(markdown) => markdown,
Err(_) => continue,
};
if local_ocr::parse_ocr_frontmatter(&markdown).is_none() {
continue;
}
let body = local_ocr::strip_ocr_frontmatter(&markdown);
if !local_search_ocr_matches(&entry, body, &normalized_query, title_only, exact) {
continue;
}
results.push(local_search_ocr_projection(
&entry,
body,
root_uri,
&normalized_query,
));
if results.len() >= limit.max(1) as usize {
break;
}
}
}
Ok(json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
@@ -100,7 +135,8 @@ pub(crate) fn query_local_search_index(
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
"resourceCount": index.resources.len(),
"includeOcr": include_ocr
},
"recentChanges": recent_changes,
"results": results
@@ -404,6 +440,14 @@ fn collect_markdown_documents(
continue;
}
if is_markdown_path(&path) {
let relative_path = path
.strip_prefix(root_path)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
continue;
}
documents.push(index_markdown_file(root_path, &path)?);
} else if resource_type_from_path(&path).is_some() {
resources.push(index_resource_file(root_path, &path)?);
@@ -565,6 +609,34 @@ fn local_search_resource_matches(
}
}
fn local_search_ocr_matches(
entry: &local_ocr::OcrIndexEntry,
body: &str,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&format!(
"{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path
))
} else {
normalize_search_text(&format!(
"{}\n{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path, body
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
}
}
fn local_search_document_projection(
document: &LocalSearchDocument,
root_uri: &str,
@@ -608,6 +680,42 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
})
}
fn local_search_ocr_projection(
entry: &local_ocr::OcrIndexEntry,
body: &str,
root_uri: &str,
query: &str,
) -> Value {
let title = Path::new(&entry.owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("OCR")
.to_string();
json!({
"id": format!("{}#ocr:{}", entry.owner_document_id, entry.source_root_relative_path),
"documentId": entry.owner_document_id,
"title": title,
"path": entry.owner_document_path,
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"hasOcr": true,
"snippet": ocr_search_snippet(body, query),
"ocrEvidence": {
"sourceRootRelativePath": entry.source_root_relative_path,
"ocrRootRelativePath": entry.ocr_root_relative_path,
"provider": entry.provider,
"status": entry.status,
},
"updatedAt": entry.updated_at_ms,
"publicPath": format!(
"/documents/{}?sourceKind=local_folder&rootUri={}",
entry.owner_document_id,
encode_query_component(root_uri),
)
})
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
@@ -764,6 +872,27 @@ fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
document.raw_text.chars().take(180).collect()
}
fn ocr_search_snippet(body: &str, query: &str) -> String {
let normalized_body = body.replace('\n', " ");
let normalized_query = query.trim();
if normalized_query.is_empty() {
return normalized_body.chars().take(160).collect();
}
let lower = normalized_body.to_ascii_lowercase();
let lower_query = normalized_query.to_ascii_lowercase();
if let Some(byte_index) = lower.find(&lower_query) {
let start = normalized_body[..byte_index]
.char_indices()
.rev()
.nth(40)
.map(|(idx, _)| idx)
.unwrap_or(0);
normalized_body[start..].chars().take(160).collect()
} else {
normalized_body.chars().take(160).collect()
}
}
fn is_markdown_path(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
@@ -845,6 +974,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("projection");
let results = projection["results"].as_array().expect("results");
@@ -898,6 +1028,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("child search");
let child_result = child_search["results"]
@@ -929,6 +1060,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("mindmap projection");
assert!(mindmap_projection["results"]
@@ -953,6 +1085,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("office projection");
assert!(office_projection["results"]
@@ -1017,6 +1150,104 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_ocr_sidecar_requires_include_ocr_and_returns_owner_page() {
let root = temp_root("mnote-local-search-ocr");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-ocr";
fs::write(root.join("docs").join("Page.md"), "# Page\n正文\n").expect("page");
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
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");
fs::create_dir_all(root.join(".mnote")).expect("mnote dir");
fs::write(
root.join(".mnote").join("ocr-index.json"),
serde_json::to_string_pretty(&json!({
"version": 1,
"entries": {
"docs/Page.assets/photo.png": {
"jobId": "ocr_test",
"ownerDocumentId": "local-md:docs~2FPage.md",
"ownerDocumentPath": "docs/Page.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md",
"provider": "mock",
"modelVersion": "vlm",
"status": "done",
"sourceSize": 3,
"sourceMtimeMs": 1,
"createdAtMs": 1,
"updatedAtMs": 1,
"plainTextPreview": "OCR-only-token 识别正文"
}
}
}))
.expect("serialize index"),
)
.expect("ocr index");
let without_ocr = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OCR-only-token",
None,
10,
false,
false,
false,
)
.expect("without ocr");
assert_eq!(without_ocr["results"].as_array().map(Vec::len), Some(0));
let with_ocr = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OCR-only-token",
None,
10,
false,
false,
true,
)
.expect("with ocr");
let result = with_ocr["results"]
.as_array()
.and_then(|items| items.first())
.expect("ocr result");
assert_eq!(
result["documentId"].as_str(),
Some("local-md:docs~2FPage.md")
);
assert_eq!(result["hasOcr"].as_bool(), Some(true));
assert_eq!(
result["ocrEvidence"]["sourceRootRelativePath"].as_str(),
Some("docs/Page.assets/photo.png")
);
assert_eq!(
result["ocrEvidence"]["ocrRootRelativePath"].as_str(),
Some("docs/Page.ocr/photo.png.ocr.md")
);
let index = read_local_search_index(&root)
.expect("read search index")
.expect("search index");
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_query_reads_existing_index_without_rebuilding() {
let root = temp_root("mnote-local-search-query-cache");
@@ -1038,6 +1269,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("first query");
assert_eq!(
@@ -1060,6 +1292,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("query existing index");
assert_eq!(
@@ -1078,6 +1311,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("query refreshed index");
assert_eq!(
+97 -1
View File
@@ -13,12 +13,14 @@ mod kernel;
mod local_folder_events;
mod local_folder_source;
mod local_markdown_parser;
mod local_ocr;
mod local_search_index;
mod media;
mod mindmap_api;
mod mindmap_shell;
pub(crate) mod navigation_recent;
mod onlyoffice;
pub(crate) mod onlyoffice_bridge;
mod page_ai_workflow;
mod query_support;
mod resource_trash;
@@ -35,7 +37,7 @@ pub(crate) mod web_shell;
mod ws;
pub(crate) use local_folder_source::{
ensure_local_path_read_access, ensure_local_workspace_access,
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
update_local_markdown_title, write_local_markdown_page_body,
};
@@ -183,6 +185,34 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
get(web_shell::sidebar_page_ai_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
get(web_shell::sidebar_page_ai_markdown_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
get(web_shell::sidebar_page_ai_render_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
get(web_shell::sidebar_page_ai_permission_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
get(web_shell::sidebar_page_ai_profile_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
get(web_shell::sidebar_page_ai_session_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
get(web_shell::sidebar_page_ai_skill_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
get(web_shell::sidebar_page_ai_target_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
get(web_shell::sidebar_page_settings_runtime_asset),
@@ -288,6 +318,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/auth/whoami", get(session::session))
.route("/api/auth/mnote-web-token", get(session::session))
.route("/api/auth/session/refresh", post(session::refresh_session))
.route(
"/api/ai/agent-profiles",
get(hermes_client::list_agent_profiles),
)
.route(
"/api/sidebar/shortcuts",
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
@@ -382,6 +416,54 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/onlyoffice/proxy", get(onlyoffice::proxy))
.route("/api/onlyoffice/callback", post(onlyoffice::callback))
.route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
.route(
"/api/onlyoffice/bridge/plugin/config",
get(onlyoffice_bridge::plugin_config),
)
.route(
"/api/onlyoffice/bridge/plugin/index",
get(onlyoffice_bridge::plugin_index),
)
.route(
"/api/onlyoffice/bridge/plugin/index/config.json",
get(onlyoffice_bridge::plugin_config),
)
.route(
"/api/onlyoffice/bridge/plugin/index/{state}",
get(onlyoffice_bridge::plugin_index_with_state),
)
.route(
"/api/onlyoffice/bridge/session",
post(onlyoffice_bridge::register_session),
)
.route(
"/api/onlyoffice/bridge/session/current",
get(onlyoffice_bridge::current_session),
)
.route(
"/api/onlyoffice/bridge/session/close",
post(onlyoffice_bridge::close_session),
)
.route(
"/api/onlyoffice/bridge/sessions",
get(onlyoffice_bridge::list_sessions),
)
.route(
"/api/onlyoffice/bridge/capabilities",
get(onlyoffice_bridge::capabilities),
)
.route(
"/api/onlyoffice/bridge/commands",
post(onlyoffice_bridge::enqueue_command),
)
.route(
"/api/onlyoffice/bridge/commands/next",
get(onlyoffice_bridge::next_command),
)
.route(
"/api/onlyoffice/bridge/results",
get(onlyoffice_bridge::get_result).post(onlyoffice_bridge::post_result),
)
.route("/api/media/upload", post(media::upload))
.route("/api/media/sign", get(media::sign))
.route("/api/media/batch", post(resource_trash::media_batch))
@@ -452,6 +534,13 @@ pub fn build_router(state: AppState) -> Router {
"/api/local-folder/events",
get(local_folder_events::local_folder_events),
)
.route(
"/api/local-folder/ocr/jobs",
get(local_ocr::list_jobs).post(local_ocr::create_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/workspaces/default",
post(local_folder_source::create_default_local_workspace),
@@ -1027,6 +1116,13 @@ mod tests {
"/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
"/api/mnote-browser-runtime/tree-live-controller.js",
+416 -10
View File
@@ -1,3 +1,4 @@
use super::onlyoffice_bridge;
use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
@@ -147,6 +148,9 @@ pub struct OnlyOfficeCallbackQuery {
asset_id: Option<String>,
#[serde(rename = "userId")]
user_id: Option<String>,
#[serde(rename = "sessionId")]
session_id: Option<String>,
token: Option<String>,
#[serde(rename = "rootUri")]
root_uri: Option<String>,
path: Option<String>,
@@ -506,10 +510,12 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
return null;
}}
}}
function buildCallbackUrl(assetId, userId, localFile) {{
function buildCallbackUrl(assetId, userId, localFile, bridgeSessionId, bridgeToken) {{
const callback = new URL("/api/onlyoffice/callback", callbackOrigin || location.origin);
if (assetId) callback.searchParams.set("assetId", assetId);
if (userId) callback.searchParams.set("userId", userId);
if (bridgeSessionId) callback.searchParams.set("sessionId", bridgeSessionId);
if (bridgeToken) callback.searchParams.set("token", bridgeToken);
if (localFile && localFile.rootUri && localFile.path) {{
callback.searchParams.set("rootUri", localFile.rootUri);
callback.searchParams.set("path", localFile.path);
@@ -598,6 +604,11 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
if (initial.fileUrl && initial.fileUrl.indexOf("/api/local-folder/files/open") !== -1) return true;
return false;
}}
function editModeLocationHref() {{
const next = new URL(location.href);
next.searchParams.set("mode", "edit");
return next.toString();
}}
async function resolveAssetUrlAndKey() {{
let effectiveFileUrl = initial.fileUrl;
let storageId = "";
@@ -639,6 +650,18 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
const localFile = localFolderOpenParams(fileState.effectiveFileUrl || initial.fileUrl);
const displayUserId = String(userId || initial.userId || "mnote-local-user").trim() || "mnote-local-user";
const displayUserName = displayUserId === "mnote-local-user" ? "MNote" : displayUserId;
const bridgeSessionSalt = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2));
const bridgeSessionId = "mnote-oo-" + fileState.docKey + "-" + bridgeSessionSalt;
const bridgeToken = (crypto && crypto.randomUUID) ? crypto.randomUUID() : ("mnote-oo-token-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2));
const bridgePluginConfigUrl = new URL("/api/onlyoffice/bridge/plugin/config", location.origin);
bridgePluginConfigUrl.searchParams.set("sessionId", bridgeSessionId);
bridgePluginConfigUrl.searchParams.set("token", bridgeToken);
bridgePluginConfigUrl.searchParams.set("apiBase", location.origin);
bridgePluginConfigUrl.searchParams.set("documentId", initial.documentId || "");
bridgePluginConfigUrl.searchParams.set("assetId", initial.assetId || "");
bridgePluginConfigUrl.searchParams.set("fileType", initial.fileType || "");
bridgePluginConfigUrl.searchParams.set("docKey", fileState.docKey);
bridgePluginConfigUrl.searchParams.set("pageOrigin", location.origin);
const config = {{
width: "100%",
height: "100%",
@@ -658,11 +681,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
editorConfig: {{
mode: resolvedMode,
lang: "zh-CN",
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile),
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile, bridgeSessionId, bridgeToken),
user: {{
id: displayUserId,
name: displayUserName
}},
plugins: {{
autostart: [MNOTE_AGENT_PLUGIN_GUID],
pluginsData: [bridgePluginConfigUrl.toString()]
}},
customization: {{
feedback: {{ visible: false }},
anonymous: {{ request: false, label: "Guest" }},
@@ -673,6 +700,14 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
events: {{
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onRequestEditRights: () => {{
const editHref = editModeLocationHref();
window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__ = {{
requested: true,
targetUrl: editHref
}};
window.location.replace(editHref);
}},
onError: (event) => showError(JSON.stringify(event))
}}
}};
@@ -690,7 +725,52 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
resolvedMode,
assetId: initial.assetId,
documentId: initial.documentId,
docKey: fileState.docKey
docKey: fileState.docKey,
bridgeSessionId,
bridgeToken,
bridgePluginConfigUrl: bridgePluginConfigUrl.toString()
}};
try {{
if (window.parent && window.parent !== window) {{
window.parent.postMessage({{
type: "mnote:onlyoffice-bridge-ready",
bridgeSessionId,
documentId: initial.documentId,
assetId: initial.assetId,
fileType: initial.fileType
}}, location.origin);
}}
}} catch (_) {{}}
window.__MNOTE_ONLYOFFICE_BRIDGE__ = {{
sessionId: bridgeSessionId,
run: async function(action, payload, timeoutMs) {{
const enqueue = await fetch("/api/onlyoffice/bridge/commands", {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify({{
sessionId: bridgeSessionId,
token: bridgeToken,
action,
payload: payload || {{}}
}})
}});
const enqueued = await enqueue.json().catch(() => null);
if (!enqueue.ok) throw new Error(enqueued && enqueued.message || "ONLYOFFICE bridge command enqueue failed");
const commandId = enqueued && enqueued.command && enqueued.command.id;
if (!commandId) throw new Error("ONLYOFFICE bridge command id missing");
const resultUrl = new URL("/api/onlyoffice/bridge/results", location.origin);
resultUrl.searchParams.set("sessionId", bridgeSessionId);
resultUrl.searchParams.set("token", bridgeToken);
resultUrl.searchParams.set("commandId", commandId);
resultUrl.searchParams.set("timeoutMs", String(timeoutMs || 25000));
const resultResponse = await fetch(resultUrl.toString());
if (resultResponse.status === 204) throw new Error("ONLYOFFICE bridge command timed out");
const result = await resultResponse.json().catch(() => null);
if (!resultResponse.ok || !result || result.ok === false) {{
throw new Error(result && result.error || result && result.message || "ONLYOFFICE bridge command failed");
}}
return result.result;
}}
}};
const readyDeadline = Date.now() + 120000;
const timer = window.setInterval(() => {{
@@ -1070,12 +1150,15 @@ fn onlyoffice_callback_success(extra: Value) -> Response {
}
fn onlyoffice_callback_failure(error: WebError) -> Response {
Json(json!({
(
error.status(),
Json(json!({
"error": 1,
"code": error.code(),
"message": error.message(),
}))
.into_response()
})),
)
.into_response()
}
async fn download_onlyoffice_callback_body(download_url: &str) -> Result<Bytes, WebError> {
@@ -1135,6 +1218,53 @@ async fn local_folder_onlyoffice_callback(
)
})?;
let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?;
let session_id = query
.session_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_session_required",
"OnlyOffice 本地保存缺少 bridge sessionId",
)
})?;
let token = query
.token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_token_required",
"OnlyOffice 本地保存缺少 bridge token",
)
})?;
if !onlyoffice_bridge::session_token_matches(session_id, token) {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_token_invalid",
"OnlyOffice 本地保存 bridge token 无效",
));
}
let session = onlyoffice_bridge::session_info(session_id).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_session_unregistered",
"OnlyOffice 本地保存 bridge session 未注册",
)
})?;
if let Some(session_asset_id) = session.asset_id.as_deref() {
if !session_asset_id.trim().is_empty() && session_asset_id.trim() != asset_id {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"onlyoffice_local_callback_asset_mismatch",
"OnlyOffice 本地保存 session 与资源不匹配",
));
}
}
let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await;
let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput {
asset_id: asset_id.to_string(),
@@ -1840,6 +1970,22 @@ mod tests {
));
assert!(html.contains("if (proxyOrigin && isLocalFolderFileOpen)"));
assert!(html.contains("if (proxyOrigin && (isLocal || url.searchParams.has(\"token\")))"));
assert!(html.contains(
"const MNOTE_AGENT_PLUGIN_GUID = \"asc.{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}\";"
));
assert!(html.contains(
"const bridgePluginConfigUrl = new URL(\"/api/onlyoffice/bridge/plugin/config\", location.origin);"
));
assert!(
html.contains("bridgePluginConfigUrl.searchParams.set(\"apiBase\", location.origin);")
);
assert!(html.contains("const bridgeSessionSalt ="));
assert!(html.contains(
"const bridgeSessionId = \"mnote-oo-\" + fileState.docKey + \"-\" + bridgeSessionSalt;"
));
assert!(html.contains("/api/onlyoffice/bridge/plugin/config"));
assert!(html.contains("pluginsData: [bridgePluginConfigUrl.toString()]"));
assert!(html.contains("window.__MNOTE_ONLYOFFICE_BRIDGE__"));
}
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
@@ -1897,6 +2043,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
session_id: None,
token: None,
root_uri: None,
path: None,
}),
@@ -1917,9 +2065,9 @@ mod tests {
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
async fn onlyoffice_local_callback_rejects_unauthenticated_local_write() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-{}",
"mnote-onlyoffice-local-callback-unauth-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
@@ -1938,6 +2086,74 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: None,
token: None,
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({
"status": 2,
"key": "doc_key",
"url": download_url
})),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 1);
assert_eq!(
payload["code"],
"onlyoffice_local_callback_session_required"
);
assert_eq!(fs::read(&target).expect("read target"), b"old");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
let session_id = format!("mnote-oo-local-callback-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
@@ -1958,6 +2174,147 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_six_body_to_original_file() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-status-six-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let (download_url, _captured) = spawn_legacy_json_server("status six bytes").await;
let session_id = format!("mnote-oo-local-callback-six-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({
"status": 6,
"key": "doc_key",
"url": download_url
})),
)
.await;
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 0);
assert_eq!(fs::read(&target).expect("read target"), b"status six bytes");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_rejects_root_escape_path() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-root-{}",
std::process::id()
));
let outside = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-outside-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
fs::create_dir_all(root.join("Page")).expect("create page");
fs::create_dir_all(&outside).expect("create outside");
fs::write(outside.join("report.docx"), b"outside").expect("write outside");
let (download_url, _captured) = spawn_legacy_json_server("should not write").await;
let session_id = format!("mnote-oo-local-callback-escape-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=..%2F{}%2Freport.docx",
session_id,
token,
root.display(),
outside.file_name().and_then(|value| value.to_str()).unwrap_or_default(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some(format!(
"../{}/report.docx",
outside
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
)),
}),
Json(json!({
"status": 2,
"key": "doc_key",
"url": download_url
})),
)
.await;
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(payload["error"], 1);
assert_eq!(payload["code"], "onlyoffice_local_file_root_escape");
assert_eq!(
fs::read(outside.join("report.docx")).expect("read outside"),
b"outside"
);
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
}
#[tokio::test]
async fn onlyoffice_local_callback_ignores_non_write_status() {
let root = std::env::temp_dir().join(format!(
@@ -1968,17 +2325,36 @@ mod tests {
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let session_id = format!("mnote-oo-local-callback-ignore-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
root.display()
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
@@ -2006,6 +2382,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()),
session_id: None,
token: None,
root_uri: None,
path: None,
}),
@@ -2128,4 +2506,32 @@ mod tests {
assert!(html.contains("anonymous: { request: false, label: \"Guest\" }"));
assert!(html.contains("features: { featuresTips: false }"));
}
#[tokio::test]
async fn onlyoffice_page_reinitializes_edit_url_on_request_edit_rights() {
let response = page(Query(OnlyOfficePageQuery {
file_url: Some(
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
.into(),
),
file_name: Some("report.docx".into()),
file_type: Some("docx".into()),
asset_id: Some("local-file:Page/report.docx".into()),
document_id: Some("local-md:Page".into()),
user_id: None,
mode: Some("view".into()),
}))
.await
.expect("onlyoffice page");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("function editModeLocationHref()"));
assert!(html.contains("next.searchParams.set(\"mode\", \"edit\");"));
assert!(html.contains("onRequestEditRights: () =>"));
assert!(html.contains("window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__"));
assert!(html.contains("window.location.replace(editHref);"));
}
}
File diff suppressed because it is too large Load Diff
@@ -1215,10 +1215,11 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
serde_json::from_slice(&body).expect("json")
}
@@ -1236,10 +1237,11 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
serde_json::from_slice(&body).expect("json")
}
@@ -1256,10 +1258,11 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
serde_json::from_slice(&body).expect("json")
}
@@ -192,6 +192,7 @@ pub async fn documents(
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?
} else {
load_search_results_with_filters(
+120 -1
View File
@@ -1623,6 +1623,104 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_markdown_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-markdown-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_render_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-render-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_permission_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-permission-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_profile_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-profile-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_session_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-session-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_skill_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-skill-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-target-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_settings_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js");
Response::builder()
@@ -3151,8 +3249,18 @@ mod tests {
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
));
assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document"));
let block_document_source_index = conversion_runtime
.find("if (blockDocument) return 'page_aggregate.block_document';")
.expect("blockDocument source should be explicit");
let local_markdown_source_index = conversion_runtime
.find("return 'local_markdown.content';")
.expect("local markdown legacy fallback should remain explicit");
assert!(
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")
block_document_source_index < local_markdown_source_index,
"local-first 浏览器转换应优先消费 blockDocument,再降级到 body.content"
);
assert!(
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body?.content")
);
assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref"));
assert!(conversion_runtime.contains("mnote-uploaded-attachment-code"));
@@ -4428,4 +4536,15 @@ mod tests {
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
));
}
#[test]
fn mindmap_resize_runtime_contract_includes_dimension_attrs() {
let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
assert!(runtime.contains("mindmapWidth"));
assert!(runtime.contains("mindmapHeight"));
assert!(runtime.contains("data?.mindmap_width"));
assert!(runtime.contains("dataset.mnoteMindmapWidth"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("--mnote-mindmap-block-max-width"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("currentPageWidthPreferences().mindmap"));
}
}
+101 -31
View File
@@ -230,6 +230,18 @@ mod tests {
include_str!("../../../browser/sidebar-page-tree-runtime.js");
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-runtime.js");
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-render-runtime.js");
const SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-permission-runtime.js");
const SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-profile-runtime.js");
const SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-session-runtime.js");
const SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-skill-runtime.js");
const SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-target-runtime.js");
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-settings-runtime.js");
const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str =
@@ -504,25 +516,62 @@ mod tests {
);
}
#[test]
fn page_ai_agent_target_picker_contract_is_visible_and_serialized() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS
.contains("export function createSidebarPageAiRenderRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-button"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-popover"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-option"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-chip"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("primaryTargetId"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("targets: ["));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("policy: {"));
}
#[test]
fn page_ai_uses_backend_acp_session_runtime_store() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadBackendSessions"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/client/sessions?"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("params.set('source', 'acp')"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiSearchBackendSessions"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-rename"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-delete"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-resume"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-search"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSkillRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiTargetRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiControls"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function ensurePageAiDrawer"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiConversation"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS
.contains("export function createSidebarPageAiSkillRuntime"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS
.contains("export function createSidebarPageAiTargetRuntime"));
assert!(
SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("function currentPageAiOpenEditorsSnapshot")
);
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("mnote.agent_target_package.v1"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillSourceOptions"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillPreferenceTable"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("ai.agent.reasonix.memory_enabled"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/hermes/client/sessions?"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("params.set('source', 'acp')"));
assert!(
SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")
);
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSearchBackendSessions"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-rename"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-search"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("usage.updated"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("thought.delta"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("permission.requested"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-permission-action=\"allow\""));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-permission-action=\"deny\""));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiHidePermissionDialog"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("if (!message.resolved)"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
.contains("data-page-ai-permission-action=\"allow\""));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
.contains("data-page-ai-permission-action=\"deny\""));
assert!(
SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiHidePermissionDialog")
);
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("if (!message.resolved)"));
assert!(
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("(item.resolved ? ' disabled' : '')"),
"已决 ACP permission 事件不能继续展示假审批按钮"
@@ -536,38 +585,50 @@ mod tests {
"ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端"
);
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-plan"),
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-plan"),
"plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板"
);
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("执行计划 · "),
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("执行计划 · "),
"plan 面板标题应显示执行计划和步数"
);
}
#[test]
fn page_ai_session_ui_labels_local_shared_and_cloud_storage() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiSessionStorageLabel"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("local_private"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("local_shared"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("convex_acp_runtime_store"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("本地私有"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("共享会话"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("云端会话"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("sessionStorage:"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("permissionLevel:"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("shareId:"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSessionStorageLabel"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_private"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_shared"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("convex_acp_runtime_store"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("本地私有"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("共享会话"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("云端会话"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sessionStorage:"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("permissionLevel:"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("shareId:"));
}
#[test]
fn page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 3"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("return ['reasonix', 'hermes']"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function ensurePageAiStateFacade"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiPermissionRuntime"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiResolvePermission"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("resolve-permission"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiProfileRuntime"));
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes"));
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("return ['reasonix', 'hermes']"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.handlePageAi"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("closestAction(e.target, '[data-page-ai-action"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
.contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("默认 (Hermes HTTP)"));
}
@@ -1009,7 +1070,8 @@ mod tests {
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
.contains("data-mnote-last-mindmap-asset-open-mode', 'local-mindmap-object-shell'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildLocalOnlyOfficeOpenUrl"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isLocalAsset ? onlyOfficeUrl"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
.contains("var localOfficeUrl = buildLocalOnlyOfficeOpenUrl"));
}
#[test]
@@ -1072,6 +1134,9 @@ mod tests {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("installMnoteDevHotReload"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/dev/hot-reload"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-dev-hot-reload"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("import.meta.url"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnoteDevHotReloadEnabled()"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("\n installMnoteDevHotReload();\n"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.clearInterval(timer)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:primary-document-activated"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:page-aggregate-synced"));
@@ -1591,6 +1656,11 @@ mod tests {
.contains("openLocalOfficeFileInActiveTab(detail, 'edit')"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("openEditorAttachmentNewWindow(detail, 'edit')"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
.contains("var requestedOfficeMode = forceEditMode ? 'edit' : 'view';"));
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains(
"var requestedOfficeMode = forceNewWindow || forceEditMode ? 'edit' : 'view';"
));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("forceEditMode ? 'edit' : 'view'"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.contains("url.searchParams.set('mode', requestedMode);"));
+52 -10
View File
@@ -3898,6 +3898,7 @@ body {
.wolai-page-ai-profile-select select,
.wolai-page-ai-context-select select,
.wolai-page-ai-skill-search input,
.wolai-page-ai-skill-search select,
.wolai-page-ai-agent-profile select {
width: 100%;
min-width: 0;
@@ -4499,25 +4500,48 @@ button.wolai-page-ai-message-text {
}
.wolai-page-ai-agent-picker,
.wolai-page-ai-context-picker {
.wolai-page-ai-context-picker,
.wolai-page-ai-target-picker {
position: relative;
flex: 0 0 auto;
}
.wolai-page-ai-agent-button,
.wolai-page-ai-context-button {
.wolai-page-ai-context-button,
.wolai-page-ai-target-button {
font-size: 17px;
line-height: 1;
}
.wolai-page-ai-agent-button[aria-expanded="true"],
.wolai-page-ai-context-button[aria-expanded="true"] {
.wolai-page-ai-context-button[aria-expanded="true"],
.wolai-page-ai-target-button[aria-expanded="true"] {
border-color: rgba(27, 28, 28, 0.32);
background: #F7F6F4;
}
.wolai-page-ai-agent-chip,
.wolai-page-ai-target-chip {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 180px;
height: 26px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 999px;
background: #F7F6F4;
color: #5A5A5A;
font-size: 12px;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-agent-popover,
.wolai-page-ai-context-popover {
.wolai-page-ai-context-popover,
.wolai-page-ai-target-popover {
position: absolute;
left: 0;
width: min(320px, calc(100vw - 44px));
@@ -4533,7 +4557,8 @@ button.wolai-page-ai-message-text {
}
.wolai-page-ai-agent-popover[hidden],
.wolai-page-ai-context-popover[hidden] {
.wolai-page-ai-context-popover[hidden],
.wolai-page-ai-target-popover[hidden] {
display: none !important;
}
@@ -4552,12 +4577,26 @@ button.wolai-page-ai-message-text {
gap: 8px;
}
.wolai-page-ai-agent-option-list {
.wolai-page-ai-agent-option-list,
.wolai-page-ai-target-option-list {
display: grid;
gap: 6px;
}
.wolai-page-ai-agent-option {
.wolai-page-ai-agent-popover-section {
display: grid;
gap: 6px;
}
.wolai-page-ai-agent-popover-title {
color: #8B8782;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.wolai-page-ai-agent-option,
.wolai-page-ai-target-option {
display: grid;
gap: 2px;
width: 100%;
@@ -4570,17 +4609,20 @@ button.wolai-page-ai-message-text {
cursor: pointer;
}
.wolai-page-ai-agent-option.is-active {
.wolai-page-ai-agent-option.is-active,
.wolai-page-ai-target-option.is-active {
border-color: rgba(27, 28, 28, 0.2);
background: #F7F6F4;
}
.wolai-page-ai-agent-option-label {
.wolai-page-ai-agent-option-label,
.wolai-page-ai-target-option-label {
font-size: 12px;
font-weight: 600;
}
.wolai-page-ai-agent-option-detail {
.wolai-page-ai-agent-option-detail,
.wolai-page-ai-target-option-detail {
color: #8B8782;
font-size: 11px;
}
+24 -7
View File
@@ -2,9 +2,11 @@ use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use bridge_runtime::{
RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan,
build_runtime_command_artifact_plan, RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan,
RuntimeQueryExecutionPlan,
};
use serde_json::Value;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
#[derive(Debug)]
pub struct RetiredCloudCommandExecution {
@@ -184,23 +186,38 @@ pub async fn execute_retired_mutation_by_name(
pub async fn persist_runtime_command_artifacts(
_config: &AppConfig,
context: &RequestContext,
_context: &RequestContext,
_artifacts: &RuntimeCommandArtifactPlan,
) -> Result<(), WebError> {
Err(retired_error(context, "convex_artifacts_retired"))
// Convex 已退役。Rust 侧仍会把 artifact plan 返回给调用方和 realtime
// consumer;这里保持 no-op,避免兼容路径因为历史持久化层退役而失败。
Ok(())
}
pub async fn execute_retired_command_plan_with_artifacts(
config: &AppConfig,
context: &RequestContext,
_runtime_context: &bridge_runtime::RuntimeBridgeContextWire,
_command: &bridge_runtime::RuntimeCommandEnvelopeWire,
runtime_context: &bridge_runtime::RuntimeBridgeContextWire,
command: &bridge_runtime::RuntimeCommandEnvelopeWire,
plan: &RuntimeCommandExecutionPlan,
) -> Result<RetiredCloudCommandExecution, WebError> {
let result = execute_retired_command_plan(config, context, plan).await?;
let now = OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
let artifacts =
build_runtime_command_artifact_plan(runtime_context, command, plan, &result, &now);
let artifact_error = if let Some(artifacts) = artifacts.as_ref() {
persist_runtime_command_artifacts(config, context, artifacts)
.await
.err()
.map(|error| error.message().to_string())
} else {
None
};
Ok(RetiredCloudCommandExecution {
result,
artifacts: None,
artifact_error: None,
artifacts,
artifact_error,
})
}