chore: 保存当前架构收口与 bug 修复快照

归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
lix-2026
2026-05-18 17:01:35 +08:00
parent 61ee4a38a2
commit a2cb1338c8
953 changed files with 14383 additions and 211845 deletions
+181 -52
View File
@@ -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,
&current_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(