This commit is contained in:
lix-2026
2026-05-16 12:34:48 +08:00
parent f9336257a5
commit d8bfaea306
17 changed files with 2817 additions and 543 deletions
@@ -14,11 +14,11 @@ pub fn manifest() -> Value {
},
"tools": [
page_get_tool(),
planned_tool("mnote.page.save", ["page.write"]),
planned_tool("mnote.page.update_title", ["page.write"]),
planned_tool("mnote.page.update_options", ["page.write"]),
planned_tool("mnote.artifact.create_summary", ["artifact.write"]),
planned_tool("mnote.artifact.create_ai_note", ["artifact.write"])
page_save_tool(),
available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]),
available_tool("mnote.page.update_options", "更新当前页面设置", ["page.write"]),
available_tool("mnote.artifact.create_summary", "为当前页面创建或更新 AI Summary", ["artifact.write"]),
available_tool("mnote.artifact.create_ai_note", "基于当前页面创建新的 AI Note", ["artifact.write"])
]
})
}
@@ -47,12 +47,49 @@ fn page_get_tool() -> Value {
})
}
fn planned_tool(name: &str, scope: impl IntoIterator<Item = &'static str>) -> Value {
fn page_save_tool() -> Value {
json!({
"name": name,
"description": "已冻结合同,按 7-4 后续 task 接入 Rust runtime / kernel",
"name": "mnote.page.save",
"description": "保存当前页面正文;replace 覆盖正文,append/prepend 会先读取当前 Page Aggregate 后合成完整正文再保存",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["page.write"],
"status": "available",
"inputSchema": {
"type": "object",
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "content", "dryRun", "idempotencyKey"],
"properties": {
"workspaceId": { "type": "string" },
"documentId": { "type": "string" },
"sessionId": { "type": "string" },
"runId": { "type": "string" },
"toolCallId": { "type": "string" },
"traceId": { "type": "string" },
"content": {
"description": "要写入的正文块数组、{blocks:[...]}、TipTap content 数组或纯文本",
"type": ["array", "object", "string"]
},
"mode": {
"type": "string",
"enum": ["replace", "append", "prepend"],
"default": "replace"
},
"dryRun": { "type": "boolean" },
"idempotencyKey": { "type": "string" }
}
}
})
}
fn available_tool(
name: &str,
description: &str,
scope: impl IntoIterator<Item = &'static str>,
) -> Value {
json!({
"name": name,
"description": description,
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": scope.into_iter().collect::<Vec<_>>(),
"status": "planned"
"status": "available"
})
}
+152 -2
View File
@@ -91,14 +91,20 @@ pub async fn page_save(
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.save 缺少 content")
.with_context(context)
})?;
let mode = input
.arg_string("mode")
.unwrap_or_else(|| "replace".into())
.trim()
.to_ascii_lowercase();
let save_content = resolve_page_save_content(state, context, input, content, &mode).await?;
page_command(
state,
context,
input,
"page.body.save",
json!({
"content": content,
"mode": input.arg_string("mode").unwrap_or_else(|| "replace".into())
"content": save_content,
"mode": mode
}),
None,
)
@@ -261,6 +267,98 @@ fn merge_page_payload(document_id: &str, workspace_id: Option<&str>, patch: Valu
payload
}
async fn resolve_page_save_content(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
content: Value,
mode: &str,
) -> Result<Value, WebError> {
match mode {
"replace" | "" => Ok(content),
"append" | "prepend" => {
let document_id = input.effective_document_id().ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "页面写工具缺少 documentId")
.with_context(context)
})?;
let workspace_id = input.effective_workspace_id();
let aggregate = build_page_aggregate_snapshot(
state,
context,
&document_id,
workspace_id.as_deref(),
None,
None,
)
.await?;
let aggregate_value = serde_json::to_value(&aggregate)
.map_err(|error| WebError::internal(error.to_string()))?;
let current = aggregate_value
.pointer("/body/content")
.cloned()
.unwrap_or_else(|| json!([]));
Ok(merge_page_content_for_mode(current, content, mode))
}
other => Err(WebError::bad_request_code(
"mnote_tool_bad_request",
format!("mnote.page.save 不支持 mode={other}"),
)
.with_context(context)),
}
}
fn normalize_page_save_blocks(value: Value) -> Vec<Value> {
match value {
Value::Array(items) => items.into_iter().map(normalize_page_save_block).collect(),
Value::Object(mut map) => {
if let Some(Value::Array(items)) = map.remove("blocks") {
return items.into_iter().map(normalize_page_save_block).collect();
}
if let Some(Value::Array(items)) = map.get("content").cloned() {
return items.into_iter().map(normalize_page_save_block).collect();
}
vec![normalize_page_save_block(Value::Object(map))]
}
Value::String(text) => vec![json!({
"type": "paragraph",
"content": text
})],
other => vec![other],
}
}
fn normalize_page_save_block(value: Value) -> Value {
let Value::Object(mut map) = value else {
return value;
};
if !map.contains_key("type") {
map.insert("type".into(), Value::String("paragraph".into()));
}
if !map.contains_key("content") {
if let Some(text) = map
.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
{
map.insert("content".into(), Value::String(text.to_string()));
}
}
Value::Object(map)
}
fn merge_page_content_for_mode(current: Value, next: Value, mode: &str) -> Value {
let mut current_blocks = normalize_page_save_blocks(current);
let mut next_blocks = normalize_page_save_blocks(next);
if mode == "prepend" {
next_blocks.extend(current_blocks);
Value::Array(next_blocks)
} else {
current_blocks.extend(next_blocks);
Value::Array(current_blocks)
}
}
fn filter_wired_page_options(options: Value) -> (Value, Vec<String>, Vec<Value>) {
let allowed = ["wideLayout", "smallText", "showToc", "protectEditing"];
let mut out = serde_json::Map::new();
@@ -339,3 +437,55 @@ fn collect_text(value: &Value) -> String {
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_save_append_merges_current_and_next_blocks() {
let merged = merge_page_content_for_mode(
json!([
{"id": "block_1", "type": "paragraph", "content": "原正文"}
]),
json!([
{"type": "paragraph", "content": [{"type": "text", "text": "AI 追加"}]}
]),
"append",
);
assert_eq!(merged.as_array().map(Vec::len), Some(2));
assert_eq!(merged[0]["id"], "block_1");
assert_eq!(collect_text(&merged[1]), "AI 追加");
}
#[test]
fn page_save_prepend_accepts_plain_text() {
let merged = merge_page_content_for_mode(
json!([
{"id": "block_1", "type": "paragraph", "content": "原正文"}
]),
json!("AI 前置"),
"prepend",
);
assert_eq!(merged.as_array().map(Vec::len), Some(2));
assert_eq!(collect_text(&merged[0]), "AI 前置");
assert_eq!(merged[1]["id"], "block_1");
}
#[test]
fn page_save_accepts_text_field_blocks_from_ai() {
let merged = merge_page_content_for_mode(
json!([]),
json!([
{"type": "paragraph", "text": "AI 使用 text 字段"}
]),
"append",
);
assert_eq!(merged.as_array().map(Vec::len), Some(1));
assert_eq!(merged[0]["content"], "AI 使用 text 字段");
assert_eq!(collect_text(&merged[0]), "AI 使用 text 字段");
}
}