chore: 保存当前架构收口与 bug 修复快照
归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
@@ -10987,6 +10987,7 @@ fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
|
||||
"toc" | "toc_node" | "tocnode" => EditorBlockType::Toc,
|
||||
"page_reference" => EditorBlockType::PageReference,
|
||||
"block_reference" => EditorBlockType::BlockReference,
|
||||
"resource" | "resource_block" => EditorBlockType::Resource,
|
||||
_ => EditorBlockType::Paragraph,
|
||||
}
|
||||
}
|
||||
@@ -11105,6 +11106,13 @@ fn normalize_editor_block_from_legacy_path(block: &Value, path: &[usize]) -> Edi
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(normalized_block_type, EditorBlockType::Resource) {
|
||||
if let Some(props_map) = block.get("props").and_then(Value::as_object) {
|
||||
props
|
||||
.extra
|
||||
.insert("resourceProps".into(), Value::Object(props_map.clone()));
|
||||
}
|
||||
}
|
||||
let text = read_trimmed_string_field(block, &["content"])
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| extract_inline_text(block));
|
||||
@@ -11122,7 +11130,10 @@ fn normalize_editor_block_from_legacy_path(block: &Value, path: &[usize]) -> Edi
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor_document_from_legacy_content(document_id: &str, content: &Value) -> EditorBlockDocument {
|
||||
pub fn editor_document_from_legacy_content(
|
||||
document_id: &str,
|
||||
content: &Value,
|
||||
) -> EditorBlockDocument {
|
||||
let mut blocks = Vec::<EditorBlock>::new();
|
||||
let mut root_block_ids = Vec::<String>::new();
|
||||
for (index, block) in normalize_blocks_from_value(content).iter().enumerate() {
|
||||
@@ -11336,6 +11347,18 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
props.insert("projectionVersion".into(), json!(projection_version));
|
||||
}
|
||||
}
|
||||
EditorBlockType::Resource => {
|
||||
if let Some(resource_props) = block
|
||||
.props
|
||||
.extra
|
||||
.get("resourceProps")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
for (key, value) in resource_props {
|
||||
props.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(text_align) = block
|
||||
@@ -11372,6 +11395,7 @@ fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
|
||||
EditorBlockType::Toc => "toc",
|
||||
EditorBlockType::PageReference => "page_reference",
|
||||
EditorBlockType::BlockReference => "block_reference",
|
||||
EditorBlockType::Resource => "resource",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11836,7 +11860,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -11931,7 +11955,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -12513,6 +12537,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_document_roundtrip_preserves_resource_block_type() {
|
||||
let content = json!([
|
||||
{
|
||||
"id": "resource_1",
|
||||
"type": "resource",
|
||||
"props": {
|
||||
"resourceKind": "asset",
|
||||
"assetId": "asset_1"
|
||||
},
|
||||
"content": "资源块"
|
||||
}
|
||||
]);
|
||||
let document = editor_document_from_legacy_content("doc_1", &content);
|
||||
assert_eq!(document.blocks[0].block_type, EditorBlockType::Resource);
|
||||
|
||||
let legacy = legacy_content_from_editor_document(&document);
|
||||
assert_eq!(legacy[0]["id"], json!("resource_1"));
|
||||
assert_eq!(legacy[0]["type"], json!("resource"));
|
||||
assert_eq!(legacy[0]["props"]["resourceKind"], json!("asset"));
|
||||
assert_eq!(legacy[0]["props"]["assetId"], json!("asset_1"));
|
||||
|
||||
let projection = project_legacy_content_to_block_document("doc_1", &legacy, &json!(1))
|
||||
.expect("resource block should project");
|
||||
assert_eq!(projection["blocks"][0]["type"], json!("resource"));
|
||||
assert_eq!(projection["blocks"][0]["editable"], json!(false));
|
||||
assert_eq!(
|
||||
projection["blocks"][0]["unsupportedReason"],
|
||||
json!("复杂块暂不开放 AI 精确写入")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_command_apply_insert_preserves_nested_children() {
|
||||
let content = json!([{
|
||||
@@ -13186,7 +13242,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -13227,11 +13283,11 @@ mod tests {
|
||||
"children": [],
|
||||
})
|
||||
);
|
||||
assert_eq!(plan.args_json["createOnly"], json!(true));
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
assert_eq!(plan.args_json["createOnly"], json!(true));
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "mindmap.put",
|
||||
@@ -13308,11 +13364,11 @@ mod tests {
|
||||
.expect("mindmap update command plan should build");
|
||||
|
||||
match plan {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
})
|
||||
@@ -13772,7 +13828,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14469,7 +14525,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14570,7 +14626,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14676,7 +14732,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14743,7 +14799,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14885,6 +14941,108 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_body_save_artifact_preserves_editor_document_payload() {
|
||||
let context = demo_context();
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "page.body.save".into(),
|
||||
command_id: "cmd_save_artifact_editor_document".into(),
|
||||
idempotency_key: Some("idem_save_artifact_editor_document".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 9,
|
||||
"editorDocument": {
|
||||
"documentId": "doc_1",
|
||||
"rootBlockIds": ["editor_block_1"],
|
||||
"blocks": [{
|
||||
"blockId": "editor_block_1",
|
||||
"blockType": "paragraph",
|
||||
"contentNodes": [{
|
||||
"payload": {
|
||||
"type": "text",
|
||||
"text": "来自 editorDocument artifact"
|
||||
},
|
||||
"attrs": {}
|
||||
}],
|
||||
"childBlockIds": []
|
||||
}]
|
||||
},
|
||||
"content": [],
|
||||
"tiptapDocument": {
|
||||
"type": "doc",
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"attrs": { "blockId": "editor_block_1" },
|
||||
"content": [{ "type": "text", "text": "来自 tiptapDocument artifact" }]
|
||||
}]
|
||||
},
|
||||
"conflictDetectionKey": "doc_1:9"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存 artifact provenance".into()),
|
||||
refs: vec!["page-body-save-artifact-provenance".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: context.clone(),
|
||||
command: command.clone(),
|
||||
})
|
||||
.expect("page.body.save plan should build");
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
let artifacts = build_runtime_command_artifact_plan(
|
||||
&context,
|
||||
&command,
|
||||
&plan,
|
||||
&json!({"ok": true, "revision": 10}),
|
||||
"2026-05-18T00:00:00Z",
|
||||
)
|
||||
.expect("artifact plan should build");
|
||||
|
||||
assert_eq!(
|
||||
artifacts
|
||||
.command_log
|
||||
.payload
|
||||
.pointer("/editorDocument/rootBlockIds/0"),
|
||||
Some(&json!("editor_block_1"))
|
||||
);
|
||||
assert_eq!(
|
||||
artifacts
|
||||
.command_log
|
||||
.payload
|
||||
.pointer("/editorDocument/blocks/0/contentNodes/0/payload/text"),
|
||||
Some(&json!("来自 editorDocument artifact"))
|
||||
);
|
||||
assert_eq!(
|
||||
artifacts
|
||||
.command_log
|
||||
.payload
|
||||
.pointer("/tiptapDocument/content/0/type"),
|
||||
Some(&json!("paragraph"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_move_command_plan_maps_to_blocks_move() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
@@ -14900,7 +15058,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14995,7 +15153,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15161,7 +15319,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15250,7 +15408,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15333,18 +15491,18 @@ mod tests {
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
}),
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"commandProtocol": {
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.node.archive",
|
||||
"compatCommandName": "documents.delete",
|
||||
"deprecatedAlias": false
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "remove_document",
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"commandProtocol": {
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.node.archive",
|
||||
"compatCommandName": "documents.delete",
|
||||
"deprecatedAlias": false
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "remove_document",
|
||||
"args": {
|
||||
"documentId": "doc_1"
|
||||
}
|
||||
@@ -15375,18 +15533,18 @@ mod tests {
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
}),
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"commandProtocol": {
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.node.restore",
|
||||
"compatCommandName": "documents.restore",
|
||||
"deprecatedAlias": false
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "document_result",
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"commandProtocol": {
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.node.restore",
|
||||
"compatCommandName": "documents.restore",
|
||||
"deprecatedAlias": false
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "document_result",
|
||||
"args": {
|
||||
"documentField": "document"
|
||||
}
|
||||
@@ -15416,17 +15574,17 @@ mod tests {
|
||||
json!({
|
||||
"documentId": "doc_1",
|
||||
}),
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"commandProtocol": {
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.node.purge",
|
||||
"compatCommandName": "documents.purge",
|
||||
"deprecatedAlias": false
|
||||
}
|
||||
}),
|
||||
),
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"commandProtocol": {
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.node.purge",
|
||||
"compatCommandName": "documents.purge",
|
||||
"deprecatedAlias": false
|
||||
}
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (command_name, function_name, payload, args_json) in cases {
|
||||
@@ -15443,7 +15601,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15491,7 +15649,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15560,7 +15718,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15629,7 +15787,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15698,7 +15856,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15755,7 +15913,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15919,7 +16077,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15955,20 +16113,20 @@ mod tests {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(plan.command_name, "tree.subtree.move");
|
||||
assert_eq!(plan.function_name, "documents:move");
|
||||
assert_eq!(plan.args_json["sortOrder"], json!(-2));
|
||||
assert_eq!(
|
||||
plan.args_json["commandProtocol"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.subtree.move",
|
||||
"compatCommandName": "documents.move",
|
||||
"deprecatedAlias": false
|
||||
})
|
||||
);
|
||||
assert!(plan.args_json.get("normalizedMove").is_none());
|
||||
assert_eq!(plan.command_name, "tree.subtree.move");
|
||||
assert_eq!(plan.function_name, "documents:move");
|
||||
assert_eq!(plan.args_json["sortOrder"], json!(-2));
|
||||
assert_eq!(
|
||||
plan.args_json["commandProtocol"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.subtree.move",
|
||||
"compatCommandName": "documents.move",
|
||||
"deprecatedAlias": false
|
||||
})
|
||||
);
|
||||
assert!(plan.args_json.get("normalizedMove").is_none());
|
||||
assert_eq!(
|
||||
plan.args_json["treeWriteOperation"],
|
||||
json!({
|
||||
@@ -16022,111 +16180,123 @@ mod tests {
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_lifecycle_aliases_are_marked_as_deprecated_tree_protocol_aliases() {
|
||||
let cases = [
|
||||
("documents.create", "tree.node.create", "documents.create"),
|
||||
("documents.title.update", "tree.node.rename", "documents.title.update"),
|
||||
("documents.move", "tree.subtree.move", "documents.move"),
|
||||
("documents.delete", "tree.node.archive", "documents.delete"),
|
||||
("documents.restore", "tree.node.restore", "documents.restore"),
|
||||
("documents.purge", "tree.node.purge", "documents.purge"),
|
||||
("documents.copy_tree", "tree.subtree.copy", "documents.copy_tree"),
|
||||
];
|
||||
#[test]
|
||||
fn documents_lifecycle_aliases_are_marked_as_deprecated_tree_protocol_aliases() {
|
||||
let cases = [
|
||||
("documents.create", "tree.node.create", "documents.create"),
|
||||
(
|
||||
"documents.title.update",
|
||||
"tree.node.rename",
|
||||
"documents.title.update",
|
||||
),
|
||||
("documents.move", "tree.subtree.move", "documents.move"),
|
||||
("documents.delete", "tree.node.archive", "documents.delete"),
|
||||
(
|
||||
"documents.restore",
|
||||
"tree.node.restore",
|
||||
"documents.restore",
|
||||
),
|
||||
("documents.purge", "tree.node.purge", "documents.purge"),
|
||||
(
|
||||
"documents.copy_tree",
|
||||
"tree.subtree.copy",
|
||||
"documents.copy_tree",
|
||||
),
|
||||
];
|
||||
|
||||
for (command_name, preferred_command_name, compat_command_name) in cases {
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: command_name.into(),
|
||||
command_id: format!("cmd_{command_name}"),
|
||||
idempotency_key: Some(format!("idem_{command_name}")),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: match command_name {
|
||||
"documents.create" => json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null,
|
||||
"title": "新页面",
|
||||
"accessScope": "private",
|
||||
"content": []
|
||||
}),
|
||||
"documents.title.update" => json!({
|
||||
"documentId": "doc_1",
|
||||
"title": "改名"
|
||||
}),
|
||||
"documents.move" => json!({
|
||||
"documentId": "doc_1",
|
||||
"parentId": null,
|
||||
"sortOrder": 0
|
||||
}),
|
||||
"documents.delete" | "documents.restore" | "documents.purge" => json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1"
|
||||
}),
|
||||
"documents.copy_tree" => json!({
|
||||
"items": [{"documentId": "doc_1", "recursive": true}],
|
||||
"targetParentId": null
|
||||
}),
|
||||
_ => unreachable!("unexpected command"),
|
||||
},
|
||||
preflight_data: if command_name == "documents.move" {
|
||||
Some(json!({
|
||||
"documents": [
|
||||
{ "id": "doc_1", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }
|
||||
]
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
reason: Some("兼容 alias 标识验证".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command,
|
||||
})
|
||||
.expect("compat command plan should build");
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
for (command_name, preferred_command_name, compat_command_name) in cases {
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: command_name.into(),
|
||||
command_id: format!("cmd_{command_name}"),
|
||||
idempotency_key: Some(format!("idem_{command_name}")),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: match command_name {
|
||||
"documents.create" => json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null,
|
||||
"title": "新页面",
|
||||
"accessScope": "private",
|
||||
"content": []
|
||||
}),
|
||||
"documents.title.update" => json!({
|
||||
"documentId": "doc_1",
|
||||
"title": "改名"
|
||||
}),
|
||||
"documents.move" => json!({
|
||||
"documentId": "doc_1",
|
||||
"parentId": null,
|
||||
"sortOrder": 0
|
||||
}),
|
||||
"documents.delete" | "documents.restore" | "documents.purge" => json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1"
|
||||
}),
|
||||
"documents.copy_tree" => json!({
|
||||
"items": [{"documentId": "doc_1", "recursive": true}],
|
||||
"targetParentId": null
|
||||
}),
|
||||
_ => unreachable!("unexpected command"),
|
||||
},
|
||||
preflight_data: if command_name == "documents.move" {
|
||||
Some(json!({
|
||||
"documents": [
|
||||
{ "id": "doc_1", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }
|
||||
]
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
reason: Some("兼容 alias 标识验证".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command,
|
||||
})
|
||||
.expect("compat command plan should build");
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
plan.args_json["commandProtocol"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": preferred_command_name,
|
||||
"compatCommandName": compat_command_name,
|
||||
"deprecatedAlias": true
|
||||
}),
|
||||
"{command_name} 应标记为 tree compat deprecated alias"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
plan.args_json["commandProtocol"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": preferred_command_name,
|
||||
"compatCommandName": compat_command_name,
|
||||
"deprecatedAlias": true
|
||||
}),
|
||||
"{command_name} 应标记为 tree compat deprecated alias"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() {
|
||||
#[test]
|
||||
fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
@@ -16140,7 +16310,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16215,7 +16385,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16315,7 +16485,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16401,7 +16571,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16484,7 +16654,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16572,7 +16742,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16713,7 +16883,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16755,15 +16925,15 @@ mod tests {
|
||||
"recursive": true,
|
||||
}
|
||||
],
|
||||
"targetParentId": "parent_1",
|
||||
"commandProtocol": {
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.subtree.copy",
|
||||
"compatCommandName": "documents.copy_tree",
|
||||
"deprecatedAlias": false
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"targetParentId": "parent_1",
|
||||
"commandProtocol": {
|
||||
"family": "tree",
|
||||
"owner": "rust-runtime-kernel",
|
||||
"preferredCommandName": "tree.subtree.copy",
|
||||
"compatCommandName": "documents.copy_tree",
|
||||
"deprecatedAlias": false
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "copy_result",
|
||||
"args": {
|
||||
@@ -16812,7 +16982,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -16896,7 +17066,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16968,7 +17138,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17058,7 +17228,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17126,7 +17296,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "onlyoffice-callback".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17204,7 +17374,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17492,7 +17662,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17684,7 +17854,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17741,7 +17911,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17781,7 +17951,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17827,7 +17997,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17952,7 +18122,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18015,7 +18185,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18079,7 +18249,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18146,7 +18316,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18244,7 +18414,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18343,7 +18513,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18456,7 +18626,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18530,7 +18700,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
|
||||
@@ -19,6 +19,7 @@ pub enum EditorBlockType {
|
||||
Toc,
|
||||
PageReference,
|
||||
BlockReference,
|
||||
Resource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -588,6 +588,18 @@ fn block_to_tiptap_node(
|
||||
content: Vec::new(),
|
||||
})
|
||||
}
|
||||
EditorBlockType::Resource => Ok(TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: Some("resource".into()),
|
||||
mindmap_id: None,
|
||||
root_node_id: None,
|
||||
projection_version: None,
|
||||
extra: block.props.extra.clone(),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
EditorBlockType::Heading => Ok(TiptapNode::Heading {
|
||||
attrs: TiptapHeadingAttrs {
|
||||
level: block.props.heading_level.unwrap_or(1),
|
||||
@@ -698,6 +710,21 @@ fn node_to_block(
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
TiptapNode::Paragraph { attrs, content }
|
||||
if attrs.mnote_block_type.as_deref() == Some("resource") =>
|
||||
{
|
||||
let mut props = props_with_text_align(attrs.text_align.clone());
|
||||
for (key, value) in &attrs.extra {
|
||||
props.extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Resource,
|
||||
props,
|
||||
content_nodes: text_nodes_from_tiptap(content)?,
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Paragraph,
|
||||
|
||||
@@ -179,7 +179,7 @@ impl AcpRunBridge {
|
||||
/// Map an AcpSessionEvent to an SSE event for the frontend.
|
||||
///
|
||||
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
|
||||
/// Reference: wolai-frontend bridge.ts HermesRunEvent type
|
||||
/// Reference: recycle/wolai-frontend bridge.ts HermesRunEvent type (historic)
|
||||
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
match event {
|
||||
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
|
||||
@@ -233,6 +233,24 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
event: "usage.updated".into(),
|
||||
data: json!({ "used": used, "size": size }),
|
||||
}),
|
||||
AcpSessionEvent::PermissionRequest {
|
||||
permission_id,
|
||||
tool_name,
|
||||
params,
|
||||
decision,
|
||||
} => Some(SseEvent {
|
||||
event: if decision == "allowed" {
|
||||
"permission.allowed".into()
|
||||
} else {
|
||||
"permission.denied".into()
|
||||
},
|
||||
data: json!({
|
||||
"permissionId": permission_id,
|
||||
"toolName": tool_name,
|
||||
"params": params,
|
||||
"decision": decision,
|
||||
}),
|
||||
}),
|
||||
AcpSessionEvent::SessionInfoUpdate { .. } => {
|
||||
None // Not forwarded to frontend
|
||||
}
|
||||
@@ -292,6 +310,23 @@ mod tests {
|
||||
assert_eq!(sse.data["delta"], "internal reasoning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_permission_request_emits_frontend_decision_event() {
|
||||
let event = AcpSessionEvent::PermissionRequest {
|
||||
permission_id: "perm_1".into(),
|
||||
tool_name: "mnote.page.save".into(),
|
||||
params: json!({"documentId": "doc_1"}),
|
||||
decision: "denied".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("permission decision should be forwarded");
|
||||
assert_eq!(sse.event, "permission.denied");
|
||||
assert_eq!(sse.data["permissionId"], "perm_1");
|
||||
assert_eq!(sse.data["toolName"], "mnote.page.save");
|
||||
assert_eq!(sse.data["params"]["documentId"], "doc_1");
|
||||
assert_eq!(sse.data["decision"], "denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_tool_events_keep_detail_for_collapsible_ui() {
|
||||
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
|
||||
|
||||
@@ -149,7 +149,7 @@ impl AcpClient {
|
||||
.take()
|
||||
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
|
||||
|
||||
let writer = BufWriter::new(stdin);
|
||||
let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
@@ -159,15 +159,16 @@ impl AcpClient {
|
||||
// Start background reader task
|
||||
let pending_clone = pending.clone();
|
||||
let handler_clone = notification_handler.clone();
|
||||
let writer_clone = writer.clone();
|
||||
let child_pid = child.id().unwrap_or(0);
|
||||
tokio::spawn(async move {
|
||||
Self::reader_loop(reader, pending_clone, handler_clone).await;
|
||||
Self::reader_loop(reader, writer_clone, pending_clone, handler_clone).await;
|
||||
info!("ACP reader loop ended (pid={})", child_pid);
|
||||
});
|
||||
|
||||
let client = Self {
|
||||
child: Some(child),
|
||||
writer: Arc::new(Mutex::new(writer)),
|
||||
writer,
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
notification_handler,
|
||||
@@ -285,6 +286,7 @@ impl AcpClient {
|
||||
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
|
||||
async fn reader_loop(
|
||||
mut reader: BufReader<ChildStdout>,
|
||||
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
notification_handler: Arc<NotificationHandlerMutex>,
|
||||
) {
|
||||
@@ -319,7 +321,7 @@ impl AcpClient {
|
||||
}
|
||||
};
|
||||
|
||||
Self::dispatch_message(msg, &pending, ¬ification_handler).await;
|
||||
Self::dispatch_message(msg, &writer, &pending, ¬ification_handler).await;
|
||||
}
|
||||
|
||||
// Process died or EOF — resolve all pending
|
||||
@@ -334,6 +336,7 @@ impl AcpClient {
|
||||
/// Reference: `acpClient.ts` L160-200 (dispatch)
|
||||
async fn dispatch_message(
|
||||
msg: Value,
|
||||
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
notification_handler: &Arc<NotificationHandlerMutex>,
|
||||
) {
|
||||
@@ -348,11 +351,32 @@ impl AcpClient {
|
||||
// Incoming request from agent (e.g. session/request_permission)
|
||||
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||
// For now, reject all incoming requests since we don't need permission dialogs yet.
|
||||
// Reference: acpClient.ts handleIncomingRequest (L200-220)
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
// 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。
|
||||
warn!("ACP incoming request not handled: {method} (params={params:?})");
|
||||
// If we wanted to reply, we'd need to write back a response...
|
||||
// For now just log. Phase C will add permission support.
|
||||
let response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": format!("ACP incoming request not supported: {method}")
|
||||
}
|
||||
});
|
||||
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
|
||||
warn!("ACP incoming request response write failed: {error}");
|
||||
}
|
||||
if method == "session/request_permission" {
|
||||
let mut event_params = params;
|
||||
if let Some(object) = event_params.as_object_mut() {
|
||||
object.insert("decision".into(), Value::String("denied".into()));
|
||||
object.insert("method".into(), Value::String(method.clone()));
|
||||
object.insert("jsonrpcId".into(), id);
|
||||
}
|
||||
let handler_guard = notification_handler.lock().unwrap();
|
||||
if let Some(ref handler) = *handler_guard {
|
||||
handler(method, event_params);
|
||||
}
|
||||
}
|
||||
} else if has_id {
|
||||
// Response to one of our requests
|
||||
if let Some(id) = msg["id"].as_u64() {
|
||||
@@ -388,6 +412,18 @@ impl AcpClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_jsonrpc_message(
|
||||
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
msg: &Value,
|
||||
) -> Result<(), AcpError> {
|
||||
let line = serde_json::to_string(msg)?;
|
||||
let mut writer = writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AcpClient {
|
||||
@@ -443,6 +479,48 @@ rl.on('line', (line) => {
|
||||
.expect("spawn mock ACP")
|
||||
}
|
||||
|
||||
async fn spawn_permission_request_mock_server() -> AcpClient {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
let permissionResponse = null;
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id !== undefined && msg.method === 'initialize') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
|
||||
});
|
||||
setTimeout(() => send({
|
||||
jsonrpc: '2.0',
|
||||
id: 77,
|
||||
method: 'session/request_permission',
|
||||
params: { reason: 'test permission' }
|
||||
}), 10);
|
||||
} else if (msg.id === 77 && msg.method === undefined) {
|
||||
permissionResponse = msg;
|
||||
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { permissionResponse }
|
||||
});
|
||||
}
|
||||
});
|
||||
"#;
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_permission_request_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write permission mock script");
|
||||
|
||||
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn permission mock ACP")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_response() {
|
||||
let client = spawn_mock_acp_server().await;
|
||||
@@ -504,4 +582,18 @@ rl.on('line', (line) => {
|
||||
// spawn already calls initialize; if it fails, the test fails
|
||||
let _client = spawn_mock_acp_server().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_incoming_permission_request_gets_response() {
|
||||
let client = spawn_permission_request_mock_server().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let result: Value = client
|
||||
.request("get_permission_response", json!({}))
|
||||
.await
|
||||
.expect("permission response probe");
|
||||
let response = &result["permissionResponse"];
|
||||
assert_eq!(response["jsonrpc"], "2.0");
|
||||
assert_eq!(response["id"], 77);
|
||||
assert!(response.get("result").is_some() || response.get("error").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ pub enum AcpSessionEvent {
|
||||
},
|
||||
/// Context usage update.
|
||||
UsageUpdate { used: u64, size: u64 },
|
||||
/// Agent 发起权限请求;当前 runtime 会自动拒绝并同步给前端。
|
||||
PermissionRequest {
|
||||
permission_id: String,
|
||||
tool_name: String,
|
||||
params: Value,
|
||||
decision: String,
|
||||
},
|
||||
/// Session metadata update (e.g. auto-title).
|
||||
SessionInfoUpdate { title: String },
|
||||
/// Plan entries update.
|
||||
@@ -51,6 +58,16 @@ pub enum AcpSessionEvent {
|
||||
Disconnected { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AcpMnoteToolContext {
|
||||
pub mnote_session_id: Option<String>,
|
||||
pub run_id: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub trace_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Handler for session events.
|
||||
pub type SessionEventHandler = Arc<dyn Fn(AcpSessionEvent) + Send + Sync + 'static>;
|
||||
|
||||
@@ -101,6 +118,34 @@ impl AcpSessionManager {
|
||||
let in_prompt_clone = in_prompt.clone();
|
||||
|
||||
client.on_notification(move |method, params| {
|
||||
if method == "session/request_permission" {
|
||||
let permission_id = params
|
||||
.get("permissionId")
|
||||
.or_else(|| params.get("permission_id"))
|
||||
.or_else(|| params.get("requestId"))
|
||||
.or_else(|| params.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("permission_auto_denied")
|
||||
.to_string();
|
||||
let tool_name = params
|
||||
.get("toolName")
|
||||
.or_else(|| params.get("tool"))
|
||||
.or_else(|| params.get("name"))
|
||||
.or_else(|| params.get("method"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("session/request_permission")
|
||||
.to_string();
|
||||
let handler = event_handler_clone.lock().unwrap();
|
||||
if let Some(ref h) = *handler {
|
||||
h(AcpSessionEvent::PermissionRequest {
|
||||
permission_id,
|
||||
tool_name,
|
||||
params,
|
||||
decision: "denied".into(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if method != "session/update" {
|
||||
return;
|
||||
}
|
||||
@@ -193,6 +238,14 @@ impl AcpSessionManager {
|
||||
pub async fn run_prompt(
|
||||
&self,
|
||||
prompt: Vec<ContentBlock>,
|
||||
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
|
||||
self.run_prompt_with_mnote_context(prompt, None).await
|
||||
}
|
||||
|
||||
pub async fn run_prompt_with_mnote_context(
|
||||
&self,
|
||||
prompt: Vec<ContentBlock>,
|
||||
mnote_context: Option<AcpMnoteToolContext>,
|
||||
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
|
||||
{
|
||||
let mut in_prompt = self.in_prompt.lock().unwrap();
|
||||
@@ -215,9 +268,16 @@ impl AcpSessionManager {
|
||||
)
|
||||
})?;
|
||||
|
||||
let mnote_context = mnote_context.unwrap_or_default();
|
||||
let params = SessionPromptParams {
|
||||
session_id: session_id.clone(),
|
||||
prompt,
|
||||
mnote_session_id: mnote_context.mnote_session_id,
|
||||
run_id: mnote_context.run_id,
|
||||
actor_id: mnote_context.actor_id,
|
||||
trace_id: mnote_context.trace_id,
|
||||
workspace_id: mnote_context.workspace_id,
|
||||
document_id: mnote_context.document_id,
|
||||
};
|
||||
|
||||
debug!("ACP session/prompt (session={})", session_id);
|
||||
|
||||
@@ -163,6 +163,18 @@ pub struct ResourceContent {
|
||||
pub struct SessionPromptParams {
|
||||
pub session_id: String,
|
||||
pub prompt: Vec<ContentBlock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mnote_session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub run_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actor_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -430,5 +430,6 @@ fn block_type_name(block_type: &EditorBlockType) -> String {
|
||||
EditorBlockType::Toc => "table_of_contents".into(),
|
||||
EditorBlockType::PageReference => "page_reference".into(),
|
||||
EditorBlockType::BlockReference => "block_reference".into(),
|
||||
EditorBlockType::Resource => "resource".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ pub async fn block_replace(
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_allowed_target(context, input, &Value::Null, &block_id, "replace")?;
|
||||
let replacement_text = content_to_text(&content);
|
||||
let diff = json!([{
|
||||
"op": "replace",
|
||||
@@ -155,58 +156,73 @@ pub async fn block_insert_after(
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
||||
ensure_block_editable(context, &anchor)?;
|
||||
let content = input
|
||||
.arg_value("content")
|
||||
.or_else(|| input.arg_value("block"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.block.insert_after 缺少 content",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let new_block_id = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
|
||||
let new_block = build_insert_block(&new_block_id, &content);
|
||||
let diff = json!([{
|
||||
"op": "insert_after",
|
||||
"anchorBlockId": anchor_block_id,
|
||||
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
|
||||
"block": new_block
|
||||
}]);
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(dry_run_result(
|
||||
input,
|
||||
&aggregate,
|
||||
"block_insert_after",
|
||||
diff,
|
||||
false,
|
||||
));
|
||||
}
|
||||
let current_content = current_body_content(&aggregate);
|
||||
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
|
||||
after_block_id: anchor_block_id.clone(),
|
||||
block: build_editor_block(&new_block_id, &content),
|
||||
});
|
||||
let (next_content, delta_opt) = compute_next_content_via_actor(
|
||||
state,
|
||||
&aggregate,
|
||||
input,
|
||||
¤t_content,
|
||||
&command,
|
||||
"block_insert_after",
|
||||
)?;
|
||||
let mut result = execute_page_body_save(
|
||||
state,
|
||||
ensure_allowed_target(
|
||||
context,
|
||||
input,
|
||||
next_content,
|
||||
vec![json!({
|
||||
"blockId": new_block_id,
|
||||
&Value::Null,
|
||||
&anchor_block_id,
|
||||
"insert_after",
|
||||
)?;
|
||||
let insert_values = insert_after_values(context, input)?;
|
||||
let id_prefix = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
|
||||
let inserted_block_ids = insert_values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, _)| {
|
||||
if insert_values.len() == 1 {
|
||||
id_prefix.clone()
|
||||
} else {
|
||||
format!("{id_prefix}_{index}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let diff = insert_values
|
||||
.iter()
|
||||
.zip(inserted_block_ids.iter())
|
||||
.map(|(content, block_id)| {
|
||||
json!({
|
||||
"op": "insert_after",
|
||||
"anchorBlockId": anchor_block_id,
|
||||
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
|
||||
"block": build_insert_block(block_id, content)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
let mut result =
|
||||
dry_run_result(input, &aggregate, "block_insert_after", json!(diff), false);
|
||||
result["insertedBlockIds"] = json!(inserted_block_ids);
|
||||
return Ok(result);
|
||||
}
|
||||
let mut next_content = current_body_content(&aggregate);
|
||||
let mut changed_blocks = Vec::with_capacity(insert_values.len());
|
||||
let mut delta_opt = None;
|
||||
let mut after_block_id = anchor_block_id.clone();
|
||||
for (content, block_id) in insert_values.iter().zip(inserted_block_ids.iter()) {
|
||||
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
|
||||
after_block_id: after_block_id.clone(),
|
||||
block: build_editor_block(block_id, content),
|
||||
});
|
||||
let (content_after_command, command_delta) = compute_next_content_via_actor(
|
||||
state,
|
||||
&aggregate,
|
||||
input,
|
||||
&next_content,
|
||||
&command,
|
||||
"block_insert_after",
|
||||
)?;
|
||||
next_content = content_after_command;
|
||||
delta_opt = command_delta;
|
||||
changed_blocks.push(json!({
|
||||
"blockId": block_id,
|
||||
"op": "insert_after",
|
||||
"anchorBlockId": anchor_block_id
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
"anchorBlockId": after_block_id
|
||||
}));
|
||||
after_block_id = block_id.clone();
|
||||
}
|
||||
let mut result =
|
||||
execute_page_body_save(state, context, input, next_content, changed_blocks).await?;
|
||||
result["insertedBlockIds"] = json!(inserted_block_ids);
|
||||
merge_block_delta(&mut result, delta_opt);
|
||||
Ok(result)
|
||||
}
|
||||
@@ -224,6 +240,7 @@ pub async fn block_delete(
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_allowed_target(context, input, &Value::Null, &block_id, "delete")?;
|
||||
let leaf = block
|
||||
.get("children")
|
||||
.and_then(Value::as_array)
|
||||
@@ -298,6 +315,14 @@ pub async fn block_move_after(
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
||||
ensure_allowed_target(context, input, &Value::Null, &block_id, "move_after")?;
|
||||
ensure_allowed_target(
|
||||
context,
|
||||
input,
|
||||
&Value::Null,
|
||||
&anchor_block_id,
|
||||
"move_after_anchor",
|
||||
)?;
|
||||
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
|
||||
let leaf = block
|
||||
.get("children")
|
||||
@@ -413,6 +438,7 @@ pub async fn doc_apply_block_ops(
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
let mut next_content = current_body_content(&aggregate);
|
||||
@@ -431,6 +457,13 @@ pub async fn doc_apply_block_ops(
|
||||
"replace" | "block_replace" => {
|
||||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&block,
|
||||
"blockRevisionRef",
|
||||
)?;
|
||||
let block_id = block_id_of(&block).unwrap_or_default();
|
||||
let content = operation.get("content").cloned().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "replace 操作缺少 content")
|
||||
@@ -466,6 +499,13 @@ pub async fn doc_apply_block_ops(
|
||||
"insert_after" | "block_insert_after" => {
|
||||
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
||||
ensure_block_editable(context, &anchor)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&anchor,
|
||||
"anchorRevisionRef",
|
||||
)?;
|
||||
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
|
||||
ensure_allowed_target(context, input, operation, &anchor_block_id, "insert_after")?;
|
||||
let content = operation.get("content").cloned().ok_or_else(|| {
|
||||
@@ -511,6 +551,13 @@ pub async fn doc_apply_block_ops(
|
||||
"delete" | "block_delete" => {
|
||||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&block,
|
||||
"blockRevisionRef",
|
||||
)?;
|
||||
let block_id = block_id_of(&block).unwrap_or_default();
|
||||
ensure_allowed_target(context, input, operation, &block_id, "delete")?;
|
||||
ensure_leaf_block(context, &block, "delete")?;
|
||||
@@ -540,6 +587,20 @@ pub async fn doc_apply_block_ops(
|
||||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||||
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&block,
|
||||
"blockRevisionRef",
|
||||
)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&anchor,
|
||||
"anchorRevisionRef",
|
||||
)?;
|
||||
ensure_leaf_block(context, &block, "move_after")?;
|
||||
let block_id = block_id_of(&block).unwrap_or_default();
|
||||
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
|
||||
@@ -628,7 +689,10 @@ pub async fn doc_apply_block_ops(
|
||||
.await
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
pub(crate) fn ensure_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
@@ -833,6 +897,37 @@ fn ensure_block_revision_ref(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_operation_block_revision_ref(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
operation: &Value,
|
||||
block: &Value,
|
||||
arg_name: &'static str,
|
||||
) -> Result<(), WebError> {
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let expected = operation
|
||||
.get(arg_name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| missing_write_precondition(context, arg_name))?;
|
||||
let current = block
|
||||
.get("revisionRef")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
if expected != current {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_conflict",
|
||||
format!("{arg_name} 已过期,请重新读取 block projection 后再写入"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(), WebError> {
|
||||
if block
|
||||
.get("editable")
|
||||
@@ -851,6 +946,40 @@ fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(),
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
fn insert_after_values(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Vec<Value>, WebError> {
|
||||
if let Some(blocks) = input.arg_value("blocks") {
|
||||
let values = blocks.as_array().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.block.insert_after 的 blocks 必须是数组",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
if values.is_empty() || values.len() > 20 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.block.insert_after 的 blocks 数量必须在 1 到 20 之间",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
return Ok(values.clone());
|
||||
}
|
||||
input
|
||||
.arg_value("content")
|
||||
.or_else(|| input.arg_value("block"))
|
||||
.map(|value| vec![value])
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.block.insert_after 缺少 content、block 或 blocks",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_write_precondition_required",
|
||||
@@ -1049,7 +1178,7 @@ async fn execute_page_body_save(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn execute_page_body_save_from_aggregate(
|
||||
pub(crate) async fn execute_page_body_save_from_aggregate(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
@@ -1255,7 +1384,7 @@ fn merge_block_delta(response: &mut Value, delta_opt: Option<Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_body_content(aggregate: &Value) -> Value {
|
||||
pub(crate) fn current_body_content(aggregate: &Value) -> Value {
|
||||
aggregate
|
||||
.pointer("/body/content")
|
||||
.cloned()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -180,7 +180,11 @@ fn block_replace_tool() -> Value {
|
||||
"content": { "type": ["string", "object", "array"] },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" }
|
||||
"blockRevisionRef": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
@@ -193,7 +197,7 @@ fn block_replace_tool() -> Value {
|
||||
}
|
||||
|
||||
fn block_insert_after_tool() -> Value {
|
||||
write_tool(
|
||||
let mut tool = write_tool(
|
||||
"mnote.block.insert_after",
|
||||
"在指定块后插入新块;真实写入走 Rust page.body.save 链路",
|
||||
["block.write", "page.write"],
|
||||
@@ -201,18 +205,38 @@ fn block_insert_after_tool() -> Value {
|
||||
"anchorBlockId": { "type": "string" },
|
||||
"content": { "type": ["string", "object", "array"] },
|
||||
"block": { "type": "object" },
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 20,
|
||||
"items": { "type": ["string", "object", "array"] }
|
||||
},
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"anchorRevisionRef": { "type": "string" }
|
||||
"anchorRevisionRef": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
[
|
||||
"anchorBlockId",
|
||||
"content",
|
||||
"revision",
|
||||
"conflictDetectionKey",
|
||||
"anchorRevisionRef",
|
||||
],
|
||||
)
|
||||
);
|
||||
if let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) {
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{ "required": ["content"] },
|
||||
{ "required": ["block"] },
|
||||
{ "required": ["blocks"] }
|
||||
]),
|
||||
);
|
||||
}
|
||||
tool
|
||||
}
|
||||
|
||||
fn block_move_after_tool() -> Value {
|
||||
@@ -226,7 +250,11 @@ fn block_move_after_tool() -> Value {
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" },
|
||||
"anchorRevisionRef": { "type": "string" }
|
||||
"anchorRevisionRef": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
@@ -248,7 +276,11 @@ fn block_delete_tool() -> Value {
|
||||
"blockId": { "type": "string" },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" }
|
||||
"blockRevisionRef": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
@@ -411,11 +443,12 @@ fn available_tool(
|
||||
}
|
||||
|
||||
fn doc_markdown_edit_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert(
|
||||
"operations".into(),
|
||||
json!({
|
||||
let mut tool = write_tool(
|
||||
"mnote.doc.markdown_edit",
|
||||
"通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"description": "搜索替换操作列表",
|
||||
"items": {
|
||||
@@ -426,25 +459,24 @@ fn doc_markdown_edit_tool() -> Value {
|
||||
},
|
||||
"required": ["search", "replace"]
|
||||
}
|
||||
}),
|
||||
);
|
||||
map.insert(
|
||||
"full_content".into(),
|
||||
json!({
|
||||
},
|
||||
"full_content": {
|
||||
"type": "string",
|
||||
"description": "完整修改后的 markdown 文本(替代 operations)"
|
||||
}),
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
if let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) {
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{ "required": ["operations"] },
|
||||
{ "required": ["full_content"] }
|
||||
]),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.doc.markdown_edit",
|
||||
"description": "通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["block.write", "page.write"],
|
||||
"status": "available",
|
||||
"properties": properties,
|
||||
"annotations": tool_annotations(false, false, true, false)
|
||||
})
|
||||
tool
|
||||
}
|
||||
|
||||
fn tool_annotations(
|
||||
|
||||
@@ -20,3 +20,13 @@ pub mod tree_shell;
|
||||
pub mod workspace_shell;
|
||||
|
||||
pub use app::{build_app, AppConfig, AppState};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub(crate) fn hermes_env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -179,14 +179,26 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/client/sessions",
|
||||
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/search",
|
||||
get(hermes_client::search_sessions),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}",
|
||||
get(hermes_client::get_session),
|
||||
get(hermes_client::get_session).delete(hermes_client::delete_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/resume",
|
||||
post(hermes_client::resume_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/rename",
|
||||
post(hermes_client::rename_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/auto-title",
|
||||
post(hermes_client::auto_title_session),
|
||||
)
|
||||
.route("/client/gateway/health", get(hermes_client::gateway_health))
|
||||
.route("/client/profiles", get(hermes_client::list_profiles))
|
||||
.route(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{doc, ToolCallInput};
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -88,6 +88,30 @@ pub async fn block_edit_workflow(
|
||||
} else {
|
||||
context.auth.actor_id.clone()
|
||||
};
|
||||
let allowed_target_block_ids = ai_context
|
||||
.get("allowedTargetBlockIds")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut markdown_args = json!({
|
||||
"operations": markdown_operations.clone()
|
||||
});
|
||||
if !allowed_target_block_ids.is_empty() {
|
||||
if let Value::Object(map) = &mut markdown_args {
|
||||
map.insert(
|
||||
"allowedTargetBlockIds".into(),
|
||||
json!(allowed_target_block_ids),
|
||||
);
|
||||
}
|
||||
}
|
||||
let edit_input = ToolCallInput {
|
||||
tool_name: "mnote.doc.markdown_edit".into(),
|
||||
workspace_id: Some(workspace_id.clone()),
|
||||
@@ -101,12 +125,12 @@ pub async fn block_edit_workflow(
|
||||
idempotency_key: Some(format!("page_ai_fast_edit_{}", context.trace.request_id)),
|
||||
dry_run: Some(false),
|
||||
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
|
||||
args: Some(json!({
|
||||
"operations": markdown_operations
|
||||
})),
|
||||
args: Some(markdown_args),
|
||||
};
|
||||
let apply_started = Instant::now();
|
||||
let apply_result = doc::doc_markdown_edit(&state, &context, &edit_input).await?;
|
||||
let tool_response =
|
||||
crate::routes::hermes_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
|
||||
let apply_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
|
||||
let apply_ms = apply_started.elapsed().as_millis();
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
@@ -128,6 +152,7 @@ pub async fn block_edit_workflow(
|
||||
"traceId": trace_id,
|
||||
"operations": markdown_operations,
|
||||
"applyResult": apply_result,
|
||||
"toolExecution": tool_response,
|
||||
"message": "已通过页面 markdown 编辑快路径完成写入。",
|
||||
"timingsMs": {
|
||||
"total": started.elapsed().as_millis(),
|
||||
@@ -540,6 +565,124 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{direct_block_edit_operations, extract_operations_from_model_text};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
crate::test_support::hermes_env_lock()
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"can_edit": true,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_toc": true,
|
||||
"block_count": 2
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "服务端页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "p_1",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第一段" }]
|
||||
},
|
||||
{
|
||||
"id": "p_2",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第二段" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
|
||||
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
|
||||
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn spawn_mock_model_server() -> String {
|
||||
async fn completions() -> Json<Value> {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"ok\"}"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind mock model");
|
||||
let addr = listener.local_addr().expect("mock model addr");
|
||||
let server = Router::new().route("/chat/completions", post(completions));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, server).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_out_of_scope_mock_model_server() -> String {
|
||||
async fn completions() -> Json<Value> {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "{\"operations\":[{\"search\":\"第一段\",\"replace\":\"越权修改\"}],\"summary\":\"out_of_scope\"}"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind out-of-scope mock model");
|
||||
let addr = listener.local_addr().expect("mock model addr");
|
||||
let server = Router::new().route("/chat/completions", post(completions));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, server).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_operations_from_fenced_model_json() {
|
||||
@@ -570,4 +713,137 @@ mod tests {
|
||||
assert_eq!(operations[2]["op"], "delete");
|
||||
assert_eq!(operations[2]["matchText"], "第三段");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-disabled-tool-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
format!(
|
||||
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\nmnote:\n tools:\n disabled:\n - mnote.doc.markdown_edit\n"
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/page-ai/block-edit-workflow")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"message": "把第二段改成测试123",
|
||||
"profile": "mnoteai",
|
||||
"sessionId": "sess_page_ai_disabled",
|
||||
"runId": "run_page_ai_disabled",
|
||||
"traceId": "trace_page_ai_disabled",
|
||||
"pageContext": {
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"pageText": "第一段\n\n第二段",
|
||||
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
|
||||
"contextBlocks": [
|
||||
{"blockId": "p_1", "text": "第一段"},
|
||||
{"blockId": "p_2", "text": "第二段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "mnote_tool_disabled");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_out_of_scope_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-selection-scope-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
format!(
|
||||
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\n"
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/page-ai/block-edit-workflow")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"message": "把选中段落改成测试123",
|
||||
"profile": "mnoteai",
|
||||
"sessionId": "sess_page_ai_scope",
|
||||
"runId": "run_page_ai_scope",
|
||||
"traceId": "trace_page_ai_scope",
|
||||
"pageContext": {
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"allowedTargetBlockIds": ["p_2"],
|
||||
"pageText": "第一段\n\n第二段",
|
||||
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
|
||||
"contextBlocks": [
|
||||
{"blockId": "p_1", "text": "第一段"},
|
||||
{"blockId": "p_2", "text": "第二段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
|
||||
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
|
||||
StreamSnapshotQuery,
|
||||
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
|
||||
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||
StreamChangeKind, StreamSnapshotQuery,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
@@ -94,15 +94,11 @@ async fn events_with_stream_delta(
|
||||
if let Some(ref mut rx) = state.stream_delta_rx {
|
||||
match rx.try_recv() {
|
||||
Ok(payload) => {
|
||||
let hint = json!({
|
||||
"kind": "delta",
|
||||
"hint": "command_committed",
|
||||
"commandName": payload.get("commandName"),
|
||||
"commandId": payload.get("commandId"),
|
||||
"workspaceId": payload.get("workspaceId"),
|
||||
"requestId": payload.get("requestId"),
|
||||
"traceId": payload.get("traceId"),
|
||||
});
|
||||
let hint = build_stream_push_delta_hint(
|
||||
&payload,
|
||||
&state.context.trace.request_id,
|
||||
&state.context.trace.trace_id,
|
||||
);
|
||||
return Some((Ok(stream_event("delta", &hint)), Some(state)));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
||||
@@ -113,9 +109,68 @@ async fn events_with_stream_delta(
|
||||
}
|
||||
}
|
||||
|
||||
// If push-driven and we already checked both broadcasts, brief sleep then re-check
|
||||
// If push-driven and broadcasts are quiet, polling remains the safety net.
|
||||
if state.stream_delta_rx.is_some() {
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
if let Some(max_polls) = max_polls {
|
||||
if state.polls >= max_polls {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
state.polls += 1;
|
||||
sleep(Duration::from_millis(poll_ms)).await;
|
||||
|
||||
let poll_query = live_poll_query(&state.query);
|
||||
let Ok((workspace_id, overview)) =
|
||||
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if let Some(change) =
|
||||
resolve_stream_change(&overview, state.current_cursor.as_deref())
|
||||
{
|
||||
state.current_cursor = change.cursor.clone();
|
||||
match change.kind {
|
||||
StreamChangeKind::Delta => {
|
||||
let Ok(payload) = build_stream_delta_payload(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&poll_query,
|
||||
&workspace_id,
|
||||
&overview,
|
||||
change.cursor,
|
||||
change
|
||||
.delta
|
||||
.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||
}
|
||||
StreamChangeKind::Resync => {
|
||||
let Ok(snapshot_payload) = load_stream_snapshot(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&poll_query,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
state.current_cursor =
|
||||
read_stream_cursor_from_payload(&snapshot_payload);
|
||||
return Some((
|
||||
Ok(stream_event(
|
||||
"resync",
|
||||
&with_stream_kind(&snapshot_payload, "resync"),
|
||||
)),
|
||||
Some(state),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some((Ok(stream_event("heartbeat", &json!({}))), Some(state)));
|
||||
}
|
||||
|
||||
@@ -289,6 +344,8 @@ mod tests {
|
||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -395,6 +452,31 @@ mod tests {
|
||||
assert!(text.contains("\"revision\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_events_push_mode_honors_polling_safety_net_max_polls() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=1&pollMs=1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = timeout(
|
||||
Duration::from_secs(2),
|
||||
to_bytes(response.into_body(), usize::MAX),
|
||||
)
|
||||
.await
|
||||
.expect("push SSE stream should stop after maxPolls")
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
||||
assert!(text.contains("event: heartbeat") || text.contains("event:heartbeat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_poll_query_drops_bridge_pagination_cursor() {
|
||||
let query = StreamSnapshotQuery {
|
||||
|
||||
@@ -574,6 +574,18 @@ pub fn with_stream_kind(payload: &Value, kind: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_stream_push_delta_hint(payload: &Value, request_id: &str, trace_id: &str) -> Value {
|
||||
json!({
|
||||
"kind": "delta",
|
||||
"hint": "command_committed",
|
||||
"commandName": payload.get("commandName"),
|
||||
"commandId": payload.get("commandId"),
|
||||
"workspaceId": payload.get("workspaceId"),
|
||||
"requestId": request_id,
|
||||
"traceId": trace_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn build_stream_delta_payload(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -1042,7 +1054,7 @@ mod tests {
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.resource.delete",
|
||||
"command_name": "tree.resource.archive",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "remove_asset",
|
||||
|
||||
@@ -2018,12 +2018,17 @@ fn build_tree_shell_html(
|
||||
|
||||
const getFileTreeRowDocumentId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
||||
if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
|
||||
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
|
||||
return "";
|
||||
};
|
||||
|
||||
const getFileTreeRowOwnerDocumentId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
||||
return getFileTreeRowDocumentId(item);
|
||||
};
|
||||
|
||||
const getFileTreeRowAssetId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
|
||||
@@ -2084,6 +2089,7 @@ fn build_tree_shell_html(
|
||||
rowKind: "root",
|
||||
nodeId: null,
|
||||
documentId: null,
|
||||
ownerDocumentId: null,
|
||||
assetId: null,
|
||||
};
|
||||
}
|
||||
@@ -2092,6 +2098,7 @@ fn build_tree_shell_html(
|
||||
rowKind: normalizeText(row.dataset.rowKind, "document"),
|
||||
nodeId: normalizeText(row.dataset.nodeId) || null,
|
||||
documentId: normalizeText(row.dataset.documentId) || null,
|
||||
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
|
||||
assetId: normalizeText(row.dataset.assetId) || null,
|
||||
};
|
||||
};
|
||||
@@ -2934,13 +2941,14 @@ fn build_tree_shell_html(
|
||||
const targetItem = targetRowId ? fileTreeRowById.get(targetRowId) : null;
|
||||
const target = targetItem
|
||||
? {
|
||||
rowId: targetItem.rowId,
|
||||
rowKind: targetItem.rowKind,
|
||||
nodeId: targetItem.nodeId,
|
||||
documentId: getFileTreeRowDocumentId(targetItem) || null,
|
||||
assetId: getFileTreeRowAssetId(targetItem) || null,
|
||||
}
|
||||
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, assetId: null };
|
||||
rowId: targetItem.rowId,
|
||||
rowKind: targetItem.rowKind,
|
||||
nodeId: targetItem.nodeId,
|
||||
documentId: getFileTreeRowDocumentId(targetItem) || null,
|
||||
ownerDocumentId: getFileTreeRowOwnerDocumentId(targetItem) || null,
|
||||
assetId: getFileTreeRowAssetId(targetItem) || null,
|
||||
}
|
||||
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, ownerDocumentId: null, assetId: null };
|
||||
if (sourceKind === "local_folder") {
|
||||
const pasted = await executeLocalFileTreeInternalDrop(
|
||||
target,
|
||||
@@ -3030,7 +3038,12 @@ fn build_tree_shell_html(
|
||||
if (event.key === "F2") {
|
||||
event.preventDefault();
|
||||
const rowId = item?.rowId || fileTreeFocusedRowId;
|
||||
if (rowId) beginInlineRename("filetree", rowId);
|
||||
const renameItem = rowId ? fileTreeRowById.get(rowId) : null;
|
||||
if (rowId && getFileTreeRowDocumentId(renameItem)) {
|
||||
beginInlineRename("filetree", rowId);
|
||||
} else {
|
||||
setLastAction("当前资源暂不支持重命名", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
@@ -3057,7 +3070,11 @@ fn build_tree_shell_html(
|
||||
event.preventDefault();
|
||||
if (item) {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
if (documentId) handleNavigate(documentId);
|
||||
if (documentId) {
|
||||
handleNavigate(documentId);
|
||||
} else {
|
||||
openHydratedFileTreeItem(item);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3219,7 +3236,7 @@ fn build_tree_shell_html(
|
||||
});
|
||||
}
|
||||
normalizedItems.slice().forEach((item) => {
|
||||
const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId;
|
||||
const itemDocumentId = getFileTreeRowOwnerDocumentId(item) || item.nodeId;
|
||||
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
|
||||
removeTreeItemEverywhere(item);
|
||||
changed = true;
|
||||
@@ -4149,8 +4166,7 @@ fn build_tree_shell_html(
|
||||
rowKind === "markdown" ||
|
||||
rowKind === "document" ||
|
||||
rowKind === "folder" ||
|
||||
rowKind === "index" ||
|
||||
(localSource && rowKind === "asset");
|
||||
rowKind === "index";
|
||||
const canCopyCut =
|
||||
rowKind === "markdown" ||
|
||||
rowKind === "document" ||
|
||||
@@ -4282,7 +4298,9 @@ fn build_tree_shell_html(
|
||||
return;
|
||||
}
|
||||
if (kind === "rename") {
|
||||
if (target.rowId) beginInlineRename("filetree", target.rowId);
|
||||
if (target.rowId && getFileTreeRowDocumentId(item)) {
|
||||
beginInlineRename("filetree", target.rowId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind === "copy" || kind === "cut") {
|
||||
@@ -4639,8 +4657,14 @@ fn build_tree_shell_html(
|
||||
const id = inlineRenameState.id;
|
||||
const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
|
||||
const documentId = renameMode === "filetree"
|
||||
? getFileTreeRowDocumentId(item) || item?.nodeId || id
|
||||
? getFileTreeRowDocumentId(item)
|
||||
: id;
|
||||
if (!documentId) {
|
||||
inlineRenameState = { mode: null, id: null, committing: false };
|
||||
setLastAction("当前资源暂不支持重命名", "error");
|
||||
renderTree();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await sendCommand({
|
||||
action: "rename",
|
||||
@@ -4953,6 +4977,7 @@ fn build_tree_shell_html(
|
||||
|
||||
const openHydratedFileTreeItem = (item) => {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
||||
const assetId = getFileTreeRowAssetId(item);
|
||||
if (item.rowKind === "document" || item.rowKind === "index") {
|
||||
handleNavigate(documentId || item.nodeId);
|
||||
@@ -4960,12 +4985,12 @@ fn build_tree_shell_html(
|
||||
}
|
||||
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
target: { documentId: ownerDocumentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
@@ -5122,6 +5147,7 @@ fn build_tree_shell_html(
|
||||
const bindFileTreeRowEvents = (row, item) => {
|
||||
if (!(row instanceof HTMLElement) || !item) return;
|
||||
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
||||
const assetId = getFileTreeRowAssetId(item) || null;
|
||||
row.dataset.active = String(
|
||||
item.rowKind === "document" && documentId === currentActiveDocumentId
|
||||
@@ -5130,6 +5156,7 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowId = item.rowId;
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
@@ -5577,6 +5604,7 @@ fn build_tree_shell_html(
|
||||
|
||||
const openFileTreeItem = (item) => {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
||||
const assetId = getFileTreeRowAssetId(item);
|
||||
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
|
||||
handleNavigate(documentId || item.nodeId);
|
||||
@@ -5584,12 +5612,12 @@ fn build_tree_shell_html(
|
||||
}
|
||||
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
target: { documentId: ownerDocumentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
@@ -5649,6 +5677,7 @@ fn build_tree_shell_html(
|
||||
const children = getSiblings(item.nodeId);
|
||||
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
|
||||
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
||||
const assetId = getFileTreeRowAssetId(item) || null;
|
||||
|
||||
const row = document.createElement("div");
|
||||
@@ -5661,6 +5690,7 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowId = item.rowId;
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
@@ -5769,15 +5799,17 @@ fn build_tree_shell_html(
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tree-actions";
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.edit,
|
||||
"filetree-action-rename",
|
||||
`重命名 ${item.title}`,
|
||||
() => beginInlineRename("filetree", item.rowId),
|
||||
false,
|
||||
),
|
||||
);
|
||||
if (getFileTreeRowDocumentId(item)) {
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.edit,
|
||||
"filetree-action-rename",
|
||||
`重命名 ${item.title}`,
|
||||
() => beginInlineRename("filetree", item.rowId),
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.more,
|
||||
@@ -7143,6 +7175,11 @@ mod tests {
|
||||
assert!(html.contains("tree.filetree.external-drop"));
|
||||
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
||||
assert!(html.contains("\"resourceMeta\""));
|
||||
assert!(html.contains("const getFileTreeRowOwnerDocumentId = (item) => {"));
|
||||
assert!(html.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
||||
assert!(html.contains("if (rowId && getFileTreeRowDocumentId(renameItem))"));
|
||||
assert!(html.contains("if (getFileTreeRowDocumentId(item))"));
|
||||
assert!(html.contains("documentId: ownerDocumentId || null"));
|
||||
assert!(html.contains("dragover"));
|
||||
assert!(html.contains("hydrateInitialFileTree"));
|
||||
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
@@ -7897,7 +7934,7 @@ mod tests {
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_child".into())
|
||||
);
|
||||
assert_eq!(payload["result"]["sortOrder"], Value::from(1));
|
||||
assert_eq!(payload["result"]["sortOrder"], Value::Null);
|
||||
assert_eq!(
|
||||
payload["result"]["execution"]["deletedCount"],
|
||||
Value::from(1)
|
||||
|
||||
@@ -1210,6 +1210,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
titleEndpoint: '/api/documents/title',
|
||||
editorHostKind: 'leptos_tiptap_island',
|
||||
});
|
||||
const syncPageAggregateScript = (session, aggregate) => {
|
||||
if (!session || !aggregate) return;
|
||||
const scriptId = session.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__';
|
||||
const node = document.getElementById(scriptId);
|
||||
if (!node) return;
|
||||
try {
|
||||
node.textContent = JSON.stringify(aggregate);
|
||||
node.setAttribute('data-mnote-page-aggregate-synced-at', String(Date.now()));
|
||||
} catch (error) {
|
||||
console.warn('mnote Page Aggregate script 同步失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const documentSessionRegistry = new Map();
|
||||
const localFolderEventRegistry = new Map();
|
||||
@@ -1622,6 +1634,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
syncPageAggregateScript(session, nextAggregate);
|
||||
session.title = nextAggregate?.head?.title || session.title;
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
@@ -1891,6 +1904,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
sourceKind,
|
||||
rootUri: runtimeDescriptor.bootstrap.rootUri,
|
||||
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
|
||||
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
|
||||
latestAggregate: runtimeDescriptor.aggregate,
|
||||
title: runtimeDescriptor.aggregate.head?.title || '无标题',
|
||||
currentTiptapDocument: tiptapDocument,
|
||||
@@ -3082,6 +3096,7 @@ mod tests {
|
||||
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
|
||||
};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::response::Response;
|
||||
@@ -41,14 +43,15 @@ async fn handle_socket(
|
||||
delta_result = stream_delta_rx.recv() => {
|
||||
match delta_result {
|
||||
Ok(delta) => {
|
||||
let notify = json!({
|
||||
"kind": "delta",
|
||||
"data": delta,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"workspaceId": delta.get("workspaceId").and_then(Value::as_str).unwrap_or(""),
|
||||
});
|
||||
if socket.send(Message::Text(notify.to_string().into())).await.is_err() {
|
||||
if socket
|
||||
.send(serialize_delta_message(
|
||||
&delta,
|
||||
&context.trace.request_id,
|
||||
&context.trace.trace_id,
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +133,14 @@ fn serialize_snapshot_message(payload: &Value) -> Message {
|
||||
Message::Text(payload.to_string().into())
|
||||
}
|
||||
|
||||
fn serialize_delta_message(payload: &Value, request_id: &str, trace_id: &str) -> Message {
|
||||
Message::Text(
|
||||
build_stream_push_delta_hint(payload, request_id, trace_id)
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn serialize_resync_message(payload: &Value) -> Message {
|
||||
Message::Text(
|
||||
json!({
|
||||
@@ -156,7 +167,10 @@ fn is_resync_request(text: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_resync_request, serialize_resync_message, serialize_snapshot_message};
|
||||
use super::{
|
||||
is_resync_request, serialize_delta_message, serialize_resync_message,
|
||||
serialize_snapshot_message,
|
||||
};
|
||||
use axum::extract::ws::Message;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -183,4 +197,29 @@ mod tests {
|
||||
};
|
||||
assert!(resync_text.contains("\"kind\":\"resync\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_delta_serializer_uses_stream_hint_contract() {
|
||||
let delta = json!({
|
||||
"kind": "command_committed",
|
||||
"commandName": "tree.rename",
|
||||
"commandId": "cmd_1",
|
||||
"workspaceId": "ws_demo",
|
||||
"requestId": "req_command",
|
||||
"traceId": "trace_command"
|
||||
});
|
||||
let Message::Text(delta_text) = serialize_delta_message(&delta, "req_ws", "trace_ws")
|
||||
else {
|
||||
panic!("delta message 应该是文本帧");
|
||||
};
|
||||
let payload: serde_json::Value = serde_json::from_str(&delta_text).expect("delta json");
|
||||
assert_eq!(payload["kind"], "delta");
|
||||
assert_eq!(payload["hint"], "command_committed");
|
||||
assert_eq!(payload["commandName"], "tree.rename");
|
||||
assert_eq!(payload["commandId"], "cmd_1");
|
||||
assert_eq!(payload["workspaceId"], "ws_demo");
|
||||
assert_eq!(payload["requestId"], "req_ws");
|
||||
assert_eq!(payload["traceId"], "trace_ws");
|
||||
assert!(payload.get("data").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,13 +61,32 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiSkillQuery: '',
|
||||
pageAiSkillError: '',
|
||||
pageAiSessions: [],
|
||||
pageAiActiveSessionId: ''
|
||||
pageAiActiveSessionId: '',
|
||||
pageAiSessionSearchQuery: '',
|
||||
pageAiSessionSearchResults: [],
|
||||
pageAiSessionSearchTimer: 0,
|
||||
pageAiSessionError: '',
|
||||
pageAiPermissionRequests: []
|
||||
};
|
||||
|
||||
function closestAction(target, selector) {
|
||||
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
|
||||
}
|
||||
|
||||
function toggleWorkspaceSidebar(trigger) {
|
||||
var shell = document.querySelector('.mnote-shell, .wolai-workspace-shell');
|
||||
if (!(shell instanceof HTMLElement)) return;
|
||||
var collapsed = shell.getAttribute('data-mnote-sidebar-collapsed') === 'true';
|
||||
var next = !collapsed;
|
||||
shell.setAttribute('data-mnote-sidebar-collapsed', String(next));
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-collapsed', String(next));
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-toggle-applied', 'true');
|
||||
if (trigger instanceof HTMLElement) {
|
||||
trigger.setAttribute('aria-pressed', String(next));
|
||||
trigger.setAttribute('title', next ? '展开侧栏' : '切换侧栏');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
@@ -227,20 +246,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function currentPageAiContextSnapshot() {
|
||||
var aggregate = currentPageAggregate();
|
||||
var body = aggregate.body || {};
|
||||
var serverContent = body.content || null;
|
||||
var serverText = searchText(textFromUnknown(serverContent));
|
||||
var localBlocks = readLocalEditorBlocks();
|
||||
var localText = searchText(localBlocks.map(function(block) { return block.content || ''; }).join(' '));
|
||||
var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
|
||||
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
|
||||
if (localBlocks.length && localText && localText !== serverText) {
|
||||
return {
|
||||
aggregate: aggregate,
|
||||
body: Object.assign({}, body, { content: localBlocks }),
|
||||
subtree: buildPageAiLocalSubtree(localBlocks, title),
|
||||
pageSubtreeSource: 'local'
|
||||
};
|
||||
}
|
||||
return {
|
||||
aggregate: aggregate,
|
||||
body: body,
|
||||
@@ -3930,6 +3936,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return 'hermes_page_ai_session:' + currentDocumentId();
|
||||
}
|
||||
|
||||
function pageAiTimestamp(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
var parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function pageAiBackendSessionQuery(extra) {
|
||||
var params = new URLSearchParams();
|
||||
params.set('source', 'acp');
|
||||
params.set('workspaceId', resolveWorkspaceId(document.body));
|
||||
params.set('documentId', currentDocumentId());
|
||||
params.set('profile', pageAiRunProfile());
|
||||
Object.keys(extra || {}).forEach(function(key) {
|
||||
var value = extra[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function pageAiNewSession(title) {
|
||||
var now = Date.now();
|
||||
return {
|
||||
@@ -3939,10 +3969,126 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
source: 'local',
|
||||
usage: null,
|
||||
status: 'idle',
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiUsageSummary(usage) {
|
||||
if (!usage || typeof usage !== 'object') return '';
|
||||
var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
|
||||
var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens;
|
||||
var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
|
||||
var parts = [];
|
||||
if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used));
|
||||
if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size));
|
||||
if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output));
|
||||
return parts.join(' ') || '';
|
||||
}
|
||||
|
||||
function pageAiPermissionMessage(payload, eventType) {
|
||||
payload = payload && typeof payload === 'object' ? payload : {};
|
||||
var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim();
|
||||
var toolName = String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
|
||||
var args = payload.arguments || payload.args || payload.input || payload.params || payload;
|
||||
var decision = String(payload.decision || payload.result || '').trim();
|
||||
if (!decision && eventType === 'permission.denied') decision = 'denied';
|
||||
if (!decision && eventType === 'permission.allowed') decision = 'allowed';
|
||||
return {
|
||||
role: 'tool',
|
||||
kind: 'permission',
|
||||
permissionId: permissionId,
|
||||
toolName: toolName,
|
||||
argsSummary: pageAiPreviewValue(args),
|
||||
content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'),
|
||||
resolved: decision === 'denied' || decision === 'allowed',
|
||||
decision: decision
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiApplyPermissionEvent(eventName, payloadText) {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(payloadText || 'null');
|
||||
} catch (_) {
|
||||
payload = {};
|
||||
}
|
||||
var message = pageAiPermissionMessage(payload, eventName);
|
||||
var existing = pageUiState.pageAiMessages.find(function(item) {
|
||||
return item.kind === 'permission' && item.permissionId === message.permissionId;
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, message);
|
||||
} else {
|
||||
pageUiState.pageAiMessages.push(message);
|
||||
}
|
||||
pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) {
|
||||
return item.permissionId !== message.permissionId;
|
||||
}).concat([message]).slice(-20);
|
||||
if (!message.resolved) {
|
||||
pageAiShowPermissionDialog(message);
|
||||
} else {
|
||||
pageAiHidePermissionDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiResolvePermission(permissionId, decision) {
|
||||
permissionId = String(permissionId || '').trim();
|
||||
if (!permissionId) return;
|
||||
pageUiState.pageAiMessages.forEach(function(item) {
|
||||
if (item.kind === 'permission' && item.permissionId === permissionId) {
|
||||
item.resolved = true;
|
||||
item.decision = decision;
|
||||
item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求';
|
||||
}
|
||||
});
|
||||
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiHidePermissionDialog() {
|
||||
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
}
|
||||
|
||||
function pageAiShowPermissionDialog(message) {
|
||||
if (!message || message.kind !== 'permission') return;
|
||||
if (message.resolved) {
|
||||
pageAiHidePermissionDialog();
|
||||
return;
|
||||
}
|
||||
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (!(dialog instanceof HTMLElement)) {
|
||||
dialog = document.createElement('div');
|
||||
dialog.className = 'wolai-page-ai-permission-dialog';
|
||||
dialog.setAttribute('data-page-ai-permission-dialog', 'true');
|
||||
dialog.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-permission-panel" role="dialog" aria-modal="false">' +
|
||||
'<div class="wolai-page-ai-memory-title" data-page-ai-permission-tool></div>' +
|
||||
'<div class="wolai-page-ai-tool-meta" data-page-ai-permission-args></div>' +
|
||||
'<div class="wolai-page-ai-message-actions">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-dialog-action>允许</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-dialog-action>拒绝</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
var tool = dialog.querySelector('[data-page-ai-permission-tool]');
|
||||
if (tool instanceof HTMLElement) tool.textContent = message.toolName || 'session/request_permission';
|
||||
var args = dialog.querySelector('[data-page-ai-permission-args]');
|
||||
if (args instanceof HTMLElement) args.textContent = message.argsSummary || message.content || '';
|
||||
dialog.querySelectorAll('[data-page-ai-permission-dialog-action]').forEach(function(button) {
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
button.setAttribute('data-page-ai-permission-id', message.permissionId || '');
|
||||
button.disabled = Boolean(message.resolved);
|
||||
}
|
||||
});
|
||||
dialog.hidden = false;
|
||||
}
|
||||
|
||||
function pageAiNormalizeSessions(sessions) {
|
||||
return (Array.isArray(sessions) ? sessions : [])
|
||||
.slice(0, 20)
|
||||
@@ -3952,8 +4098,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
title: String(session && session.title || '').trim() || '新会话',
|
||||
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
|
||||
acpRuntime: String(session && session.acpRuntime || '').trim(),
|
||||
createdAt: Number(session && session.createdAt || Date.now()),
|
||||
updatedAt: Number(session && session.updatedAt || Date.now()),
|
||||
createdAt: pageAiTimestamp(session && session.createdAt),
|
||||
updatedAt: pageAiTimestamp(session && session.updatedAt),
|
||||
source: String(session && session.source || 'local').trim() || 'local',
|
||||
runId: String(session && (session.runId || session.run_id) || '').trim(),
|
||||
status: String(session && session.status || '').trim(),
|
||||
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
|
||||
preview: String(session && session.preview || '').trim(),
|
||||
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300) : []
|
||||
};
|
||||
})
|
||||
@@ -3962,6 +4113,43 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiNormalizeBackendSessionRow(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
var payload = row.payload && typeof row.payload === 'object' ? row.payload : {};
|
||||
var sessionId = String(row.sessionId || row.session_id || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var title = String(row.title || payload.title || payload.message || '').trim();
|
||||
if (title.length > 28) title = title.slice(0, 28) + '…';
|
||||
return {
|
||||
id: sessionId,
|
||||
title: title || '当前页问答',
|
||||
profile: String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default',
|
||||
acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(),
|
||||
createdAt: pageAiTimestamp(row.createdAt || row.created_at),
|
||||
updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at),
|
||||
source: 'acp',
|
||||
runId: String(row.runId || row.run_id || '').trim(),
|
||||
status: String(row.status || (row.runtime && row.runtime.status) || '').trim(),
|
||||
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
|
||||
preview: String(payload.message || row.snippet || '').trim(),
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiMergeSessions(localSessions, backendSessions) {
|
||||
var byId = {};
|
||||
pageAiNormalizeSessions(localSessions).forEach(function(session) {
|
||||
byId[session.id] = session;
|
||||
});
|
||||
pageAiNormalizeSessions(backendSessions).forEach(function(session) {
|
||||
var existing = byId[session.id];
|
||||
byId[session.id] = Object.assign({}, existing || {}, session, {
|
||||
messages: session.messages.length ? session.messages : (existing && existing.messages || [])
|
||||
});
|
||||
});
|
||||
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
|
||||
}
|
||||
|
||||
function pageAiLoadSessions() {
|
||||
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
||||
try {
|
||||
@@ -3996,6 +4184,135 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
|
||||
async function pageAiLoadBackendSessions() {
|
||||
var response = await fetch('/api/hermes/client/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
|
||||
}
|
||||
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
|
||||
if (!backendSessions.length) return [];
|
||||
pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions);
|
||||
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
|
||||
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
|
||||
}
|
||||
var active = pageAiCurrentSession();
|
||||
if (active) {
|
||||
if (active.profile) pageAiSetActiveProfile(active.profile);
|
||||
if (active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
|
||||
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages;
|
||||
}
|
||||
pageUiState.pageAiSessionError = '';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return backendSessions;
|
||||
}
|
||||
|
||||
function pageAiMessageFromRuntimeEvent(event) {
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
|
||||
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
|
||||
if (eventType === 'message.delta') {
|
||||
var delta = String(payload.delta || payload.text || payload.output_text || '').trim();
|
||||
return delta ? { role: 'assistant', content: delta } : null;
|
||||
}
|
||||
if (eventType === 'thought.delta') {
|
||||
var thought = String(payload.delta || payload.text || '').trim();
|
||||
return thought ? { role: 'assistant', kind: 'thought', content: thought } : null;
|
||||
}
|
||||
if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') {
|
||||
var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim();
|
||||
return {
|
||||
role: 'tool',
|
||||
content: toolName,
|
||||
toolName: toolName,
|
||||
toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''),
|
||||
toolKind: String(payload.kind || ''),
|
||||
status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'),
|
||||
argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input),
|
||||
resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error),
|
||||
traceId: String(payload.traceId || payload.trace_id || ''),
|
||||
auditId: String(payload.auditId || payload.audit_id || '')
|
||||
};
|
||||
}
|
||||
if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') {
|
||||
return pageAiPermissionMessage(payload, eventType);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pageAiApplyBackendSessionDetail(payload) {
|
||||
var sessionPayload = payload && payload.session ? payload.session : {};
|
||||
var runs = pageAiNormalizeArray(sessionPayload.runs);
|
||||
var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null;
|
||||
var events = pageAiNormalizeArray(payload && payload.events);
|
||||
var messages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) {
|
||||
return {
|
||||
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
|
||||
content: String(message.content || '')
|
||||
};
|
||||
}).filter(function(message) { return message.content; });
|
||||
events.forEach(function(event) {
|
||||
var message = pageAiMessageFromRuntimeEvent(event);
|
||||
if (message) messages.push(message);
|
||||
});
|
||||
var current = pageAiCurrentSession();
|
||||
if (latest && current) {
|
||||
Object.assign(current, latest);
|
||||
}
|
||||
if (current) {
|
||||
current.messages = messages.slice(-300);
|
||||
current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now());
|
||||
if (latest && latest.usage) current.usage = latest.usage;
|
||||
}
|
||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||
pageUiState.pageAiMessages = messages.slice(-300);
|
||||
pageAiPersistSessions();
|
||||
}
|
||||
|
||||
async function pageAiLoadBackendSessionDetail(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_detail_failed_' + response.status));
|
||||
}
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function pageAiSearchBackendSessions(query) {
|
||||
var q = String(query || '').trim();
|
||||
pageUiState.pageAiSessionSearchQuery = q;
|
||||
if (!q) {
|
||||
pageUiState.pageAiSessionSearchResults = [];
|
||||
renderPageAiConversation();
|
||||
return [];
|
||||
}
|
||||
var response = await fetch('/api/hermes/client/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_search_failed_' + response.status));
|
||||
}
|
||||
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) {
|
||||
var normalized = pageAiNormalizeBackendSessionRow(row) || {};
|
||||
normalized.snippet = String(row && row.snippet || normalized.preview || '').trim();
|
||||
return normalized;
|
||||
}).filter(function(row) { return row.id; });
|
||||
renderPageAiConversation();
|
||||
return pageUiState.pageAiSessionSearchResults;
|
||||
}
|
||||
|
||||
function pageAiPersistSessions() {
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||
@@ -4049,12 +4366,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function pageAiRestoreHermesSession() {
|
||||
var current = pageAiCurrentSession();
|
||||
if (!current || !String(current.id || '').startsWith('mnote_')) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume', {
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (payload && payload.persistence === 'convex_acp_runtime_store') {
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
|
||||
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||
@@ -4101,6 +4424,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) {
|
||||
void pageAiLoadBackendSessionDetail(session.id).catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiStartNewSession() {
|
||||
@@ -4114,6 +4443,71 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiRenameBackendSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
var title = window.prompt('重命名 AI 会话', session.title || '当前页问答');
|
||||
if (title === null) return;
|
||||
title = String(title || '').trim();
|
||||
if (!title) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify({ title: title })
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status));
|
||||
}
|
||||
session.title = String((payload.result && payload.result.title) || title);
|
||||
session.updatedAt = Date.now();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiDeleteBackendSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
if (!window.confirm('确定删除 AI 会话“' + (session.title || sessionId) + '”吗?')) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'DELETE',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_delete_failed_' + response.status));
|
||||
}
|
||||
pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(item) { return item.id !== sessionId; });
|
||||
if (pageUiState.pageAiActiveSessionId === sessionId) {
|
||||
var next = pageUiState.pageAiSessions[0] || pageAiNewSession();
|
||||
if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next];
|
||||
pageUiState.pageAiActiveSessionId = next.id;
|
||||
pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : [];
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiResumeBackendSession(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return;
|
||||
pageAiSetActiveSession(sessionId);
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_resume_failed_' + response.status));
|
||||
}
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiProviderLabel(provider) {
|
||||
if (provider === 'codex') return 'Codex';
|
||||
if (provider === 'claudecode') return 'ClaudeCode';
|
||||
@@ -4970,7 +5364,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
|
||||
if (sessionNode instanceof HTMLElement) {
|
||||
var session = pageAiCurrentSession();
|
||||
sessionNode.textContent = session && session.id ? String(session.title || '当前页问答') : '等待 Hermes session';
|
||||
var usageText = session && session.usage ? pageAiUsageSummary(session.usage) : '';
|
||||
sessionNode.textContent = session && session.id
|
||||
? [String(session.title || '当前页问答'), usageText].filter(Boolean).join(' · ')
|
||||
: '等待 Hermes session';
|
||||
}
|
||||
var modelNode = drawer.querySelector('[data-page-ai-model-status]');
|
||||
if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel();
|
||||
@@ -5024,6 +5421,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
skillError.textContent = pageUiState.pageAiSkillError || '';
|
||||
skillError.hidden = !pageUiState.pageAiSkillError;
|
||||
}
|
||||
var sessionError = drawer.querySelector('[data-page-ai-session-error]');
|
||||
if (sessionError instanceof HTMLElement) {
|
||||
sessionError.textContent = pageUiState.pageAiSessionError || '';
|
||||
sessionError.hidden = !pageUiState.pageAiSessionError;
|
||||
}
|
||||
var sessionSearch = drawer.querySelector('[data-page-ai-session-search]');
|
||||
if (sessionSearch instanceof HTMLInputElement && document.activeElement !== sessionSearch) {
|
||||
sessionSearch.value = pageUiState.pageAiSessionSearchQuery || '';
|
||||
}
|
||||
var skillSearch = drawer.querySelector('[data-page-ai-skill-search]');
|
||||
if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) {
|
||||
skillSearch.value = pageUiState.pageAiSkillQuery;
|
||||
@@ -5290,6 +5696,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<div class="wolai-page-ai-settings-head">' +
|
||||
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
|
||||
'</div>' +
|
||||
'<label class="wolai-page-ai-skill-search">' +
|
||||
'<span>搜索会话</span>' +
|
||||
'<input type="search" data-page-ai-session-search placeholder="搜索后端 AI 会话…" />' +
|
||||
'</label>' +
|
||||
'<div class="wolai-page-ai-inline-error" data-page-ai-session-error hidden></div>' +
|
||||
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
||||
'</section>' +
|
||||
'</div>' +
|
||||
@@ -5332,20 +5743,33 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]');
|
||||
if (!(conversation instanceof HTMLElement)) return;
|
||||
if (pageUiState.pageAiPage === 'history') {
|
||||
if (!pageUiState.pageAiSessions.length) {
|
||||
var historyRows = pageUiState.pageAiSessionSearchResults.length
|
||||
? pageUiState.pageAiSessionSearchResults
|
||||
: pageUiState.pageAiSessions;
|
||||
if (!historyRows.length) {
|
||||
conversation.innerHTML = '<div class="wolai-page-ai-empty">尚未产生历史会话。</div>';
|
||||
return;
|
||||
}
|
||||
conversation.innerHTML = pageUiState.pageAiSessions.map(function(session) {
|
||||
conversation.innerHTML = historyRows.map(function(session) {
|
||||
var preview = Array.isArray(session.messages) && session.messages.length
|
||||
? session.messages.slice(-1)[0].content
|
||||
: '暂无消息';
|
||||
: (session.snippet || session.preview || '暂无消息');
|
||||
var active = session.id === pageUiState.pageAiActiveSessionId;
|
||||
var usage = pageAiUsageSummary(session.usage);
|
||||
var meta = [session.source === 'acp' ? 'Convex' : '本地', session.status, usage].filter(Boolean).join(' · ');
|
||||
return '' +
|
||||
'<button type="button" class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session="' + escapeHtml(session.id) + '">' +
|
||||
'<div class="wolai-page-ai-message-role">会话</div>' +
|
||||
'<div class="wolai-page-ai-message-text"><strong>' + escapeHtml(session.title || '新会话') + '</strong><br />' + escapeHtml(preview) + '</div>' +
|
||||
'</button>';
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session-row="' + escapeHtml(session.id) + '">' +
|
||||
'<button type="button" class="wolai-page-ai-message-text" data-page-ai-session="' + escapeHtml(session.id) + '">' +
|
||||
'<strong>' + escapeHtml(session.title || '新会话') + '</strong><br />' +
|
||||
'<span>' + escapeHtml(preview) + '</span>' +
|
||||
(meta ? '<br /><span class="wolai-page-ai-tool-meta">' + escapeHtml(meta) + '</span>' : '') +
|
||||
'</button>' +
|
||||
'<div class="wolai-page-ai-message-actions">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-resume="' + escapeHtml(session.id) + '">恢复</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-rename="' + escapeHtml(session.id) + '">重命名</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-delete="' + escapeHtml(session.id) + '">删除</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return;
|
||||
}
|
||||
@@ -5376,6 +5800,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
if (item.kind === 'thought') {
|
||||
return '' +
|
||||
'<details class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-thought-delta="true">' +
|
||||
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.7;font-size:0.85em">思考过程</summary>' +
|
||||
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.6;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' + escapeHtml(item.content || '') + '</div>' +
|
||||
'</details>';
|
||||
}
|
||||
if (item.kind === 'permission') {
|
||||
var permissionActions = item.resolved ? '' : (
|
||||
'<div class="wolai-page-ai-message-actions">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">允许</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">拒绝</button>' +
|
||||
'</div>'
|
||||
);
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-permission-request="' + escapeHtml(item.permissionId || '') + '">' +
|
||||
'<div class="wolai-page-ai-message-role">权限</div>' +
|
||||
'<div class="wolai-page-ai-message-text">' +
|
||||
'<strong>' + escapeHtml(item.toolName || 'session/request_permission') + '</strong><br />' +
|
||||
'<span>' + escapeHtml(item.argsSummary || item.content || '') + '</span>' +
|
||||
permissionActions +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
|
||||
@@ -5401,7 +5849,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiLoadProfileMemory(),
|
||||
pageAiLoadSkills(),
|
||||
pageAiLoadTools(),
|
||||
pageAiLoadGatewayHealth()
|
||||
pageAiLoadGatewayHealth(),
|
||||
pageAiLoadBackendSessions()
|
||||
]).then(function() {
|
||||
renderPageAiControls();
|
||||
}).catch(function() {}).then(function() {
|
||||
@@ -5687,10 +6136,54 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (pageUiState.pageAiStoppedRunIds[runId]) return;
|
||||
assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText));
|
||||
}
|
||||
if (eventName === 'thought.delta') {
|
||||
try {
|
||||
var thoughtPayload = JSON.parse(payloadText || 'null');
|
||||
var thoughtText = String((thoughtPayload && (thoughtPayload.delta || thoughtPayload.text)) || '');
|
||||
if (thoughtText) {
|
||||
var msgs = pageUiState.pageAiMessages;
|
||||
var lastThought = msgs.length > 0 && msgs[msgs.length - 1].kind === 'thought' ? msgs[msgs.length - 1] : null;
|
||||
if (lastThought) {
|
||||
lastThought.content += thoughtText;
|
||||
} else {
|
||||
msgs.push({ role: 'assistant', kind: 'thought', content: thoughtText });
|
||||
}
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName === 'usage.updated') {
|
||||
try {
|
||||
var usagePayload = JSON.parse(payloadText || 'null') || {};
|
||||
var sessionForUsage = pageAiCurrentSession();
|
||||
if (sessionForUsage) {
|
||||
sessionForUsage.usage = {
|
||||
source: 'usage_update',
|
||||
used: Number(usagePayload.used ?? usagePayload.contextUsed ?? 0),
|
||||
size: Number(usagePayload.size ?? usagePayload.contextSize ?? 0)
|
||||
};
|
||||
pageAiPersistSessions();
|
||||
}
|
||||
} catch (_) {}
|
||||
renderPageAiControls();
|
||||
}
|
||||
if (eventName === 'permission.requested' || eventName === 'permission.denied' || eventName === 'permission.allowed') {
|
||||
pageAiApplyPermissionEvent(eventName, payloadText);
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
if (eventName === 'run.completed') {
|
||||
try {
|
||||
var completed = JSON.parse(payloadText || 'null');
|
||||
if (completed && completed.output) assistantText = String(completed.output || '');
|
||||
if (completed && completed.usage) {
|
||||
var completedSession = pageAiCurrentSession();
|
||||
if (completedSession) completedSession.usage = completed.usage;
|
||||
}
|
||||
} catch (_) {}
|
||||
pageAiSetRunStatus('completed', runId);
|
||||
}
|
||||
@@ -6181,6 +6674,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
|
||||
if (activeTreeContextMenu) closeTreeContextMenu();
|
||||
|
||||
var sidebarToggle = closestAction(e.target, '[data-mnote-action="toggle-sidebar"]');
|
||||
if (sidebarToggle) {
|
||||
e.preventDefault();
|
||||
toggleWorkspaceSidebar(sidebarToggle);
|
||||
return;
|
||||
}
|
||||
|
||||
var attachmentAction = closestAction(e.target, '[data-attachment-action]');
|
||||
if (attachmentAction) {
|
||||
e.preventDefault();
|
||||
@@ -6362,6 +6862,46 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionResume = closestAction(e.target, '[data-page-ai-session-resume]');
|
||||
if (pageAiSessionResume) {
|
||||
e.preventDefault();
|
||||
void pageAiResumeBackendSession(pageAiSessionResume.getAttribute('data-page-ai-session-resume') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionRename = closestAction(e.target, '[data-page-ai-session-rename]');
|
||||
if (pageAiSessionRename) {
|
||||
e.preventDefault();
|
||||
void pageAiRenameBackendSession(pageAiSessionRename.getAttribute('data-page-ai-session-rename') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionDelete = closestAction(e.target, '[data-page-ai-session-delete]');
|
||||
if (pageAiSessionDelete) {
|
||||
e.preventDefault();
|
||||
void pageAiDeleteBackendSession(pageAiSessionDelete.getAttribute('data-page-ai-session-delete') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiPermissionAction = closestAction(e.target, '[data-page-ai-permission-action]');
|
||||
if (pageAiPermissionAction) {
|
||||
e.preventDefault();
|
||||
pageAiResolvePermission(
|
||||
pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '',
|
||||
pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
|
||||
if (pageAiSession) {
|
||||
e.preventDefault();
|
||||
@@ -6396,6 +6936,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
if (pageUiState.pageAiPage === 'history') {
|
||||
void pageAiLoadBackendSessions().catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6661,6 +7207,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var sessionSearch = closestAction(event.target, '[data-page-ai-session-search]');
|
||||
if (sessionSearch instanceof HTMLInputElement) {
|
||||
var sessionQuery = sessionSearch.value;
|
||||
window.clearTimeout(pageUiState.pageAiSessionSearchTimer || 0);
|
||||
pageUiState.pageAiSessionSearchTimer = window.setTimeout(function() {
|
||||
void pageAiSearchBackendSessions(sessionQuery).catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
|
||||
if (memoryEditor instanceof HTMLTextAreaElement) {
|
||||
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
|
||||
@@ -6681,6 +7239,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
void pageAiLoadProfiles();
|
||||
if (next !== 'reasonix') void pageAiLoadProfileMemory();
|
||||
void pageAiLoadSkills();
|
||||
void pageAiLoadBackendSessions().catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
renderPageAiControls();
|
||||
renderPageAiProviderButtons();
|
||||
return;
|
||||
@@ -7343,7 +7904,7 @@ pub fn PageLayout(
|
||||
<div class="mnote-main">
|
||||
<header class="wolai-topbar" data-testid="wolai-topbar">
|
||||
<div class="wolai-topbar-left">
|
||||
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏" aria-pressed="false" data-mnote-action="toggle-sidebar" data-testid="wolai-sidebar-toggle"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
|
||||
<nav class="wolai-breadcrumb" aria-label="页面路径">
|
||||
<span class="wolai-breadcrumb-root">{ws_name.clone()}</span>
|
||||
<span class="wolai-breadcrumb-separator" aria-hidden="true">"›"</span>
|
||||
@@ -7445,6 +8006,59 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
|
||||
assert!(
|
||||
!SIDEBAR_TREE_JS.contains("pageSubtreeSource: 'local'"),
|
||||
"页面 AI context 不能从编辑器 DOM 派生 local page subtree;必须以 Rust Page Aggregate projection 为准"
|
||||
);
|
||||
assert!(
|
||||
!SIDEBAR_TREE_JS.contains("buildPageAiLocalSubtree(localBlocks"),
|
||||
"页面 AI context 不应调用本地 page subtree builder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_uses_backend_acp_session_runtime_store() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessions"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/hermes/client/sessions?"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("params.set('source', 'acp')"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessionDetail"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiSearchBackendSessions"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-rename"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-delete"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-resume"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-search"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("pageAiUsageSummary"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("usage.updated"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("thought.delta"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("permission.requested"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"allow\""));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"deny\""));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiHidePermissionDialog"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (!message.resolved)"));
|
||||
assert!(
|
||||
!SIDEBAR_TREE_JS.contains("(item.resolved ? ' disabled' : '')"),
|
||||
"已决 ACP permission 事件不能继续展示假审批按钮"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("usePreferredSidebarSnapshot"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("preferredSidebarSnapshot"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("liveSidebarTitle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_sidebar_toggle_is_wired_to_shell_state() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function toggleWorkspaceSidebar"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-collapsed"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("[data-mnote-action=\"toggle-sidebar\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() {
|
||||
assert!(
|
||||
@@ -7593,6 +8207,9 @@ mod tests {
|
||||
#[test]
|
||||
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-ws"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("new WebSocket(url.toString())"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("startWithSseFallback"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
|
||||
|
||||
@@ -224,6 +224,11 @@ a:hover {
|
||||
background: var(--wolai-bg);
|
||||
}
|
||||
|
||||
.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar,
|
||||
.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-workspace-shell {
|
||||
--wolai-bg-sidebar: #F7F7F6;
|
||||
--wolai-bg-selected: #F8E6E7;
|
||||
@@ -3376,7 +3381,6 @@ body {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message--history.is-active {
|
||||
@@ -3399,6 +3403,39 @@ body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
button.wolai-page-ai-message-text {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-dialog {
|
||||
position: fixed;
|
||||
right: 28px;
|
||||
bottom: 28px;
|
||||
z-index: 80;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.wolai-page-ai-tool-details {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -3670,6 +3707,7 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-main"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-content"));
|
||||
assert!(MNOTE_CSS.contains("[data-mnote-sidebar-collapsed=\"true\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3713,7 +3751,7 @@ mod tests {
|
||||
fn mnote_css_is_reasonably_sized() {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 70000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI 和双 pane 布局,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 90000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +83,14 @@ fn render_filetree_row(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let command_document_id = match row.row_kind.as_str() {
|
||||
"document" | "markdown" => row.document_id.as_deref().unwrap_or(&row.node_id),
|
||||
"index" => row.node_id.strip_prefix("index:").unwrap_or(&row.node_id),
|
||||
_ => "",
|
||||
};
|
||||
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
@@ -92,7 +98,8 @@ fn render_filetree_row(
|
||||
row_id = escape_html(&row.row_id),
|
||||
row_kind = escape_html(&row.row_kind),
|
||||
parent_attr = parent_attr,
|
||||
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
|
||||
document_id = escape_html(command_document_id),
|
||||
owner_document_id = escape_html(owner_document_id),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
@@ -208,6 +215,7 @@ mod tests {
|
||||
assert!(!html.contains("data-testid=\"filetree-index-row\""));
|
||||
assert!(!html.contains("index.md"));
|
||||
assert!(html.contains("data-doc-id=\"page_root\""));
|
||||
assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\""));
|
||||
assert!(html.contains("data-asset-id=\"mind_1\""));
|
||||
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
|
||||
assert!(html.contains("tree-children"));
|
||||
|
||||
@@ -171,7 +171,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -210,7 +210,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -257,7 +257,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -294,7 +294,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -334,7 +334,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -374,7 +374,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -413,7 +413,7 @@ mod tests {
|
||||
};
|
||||
let base_source = SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
};
|
||||
|
||||
let create_request = build_write_request(
|
||||
@@ -594,7 +594,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -651,7 +651,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -694,7 +694,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -745,7 +745,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -796,7 +796,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "onlyoffice-callback".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
|
||||
Reference in New Issue
Block a user