chore: 保存当前架构收口与 bug 修复快照
归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user