1.0 mvp
This commit is contained in:
@@ -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"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 字段");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user